From fc50215f17c2b1d00f19852a8b134f56afed54e8 Mon Sep 17 00:00:00 2001 From: Stackwright Bot Date: Sun, 31 May 2026 09:58:52 -0400 Subject: [PATCH] fix(a11y): add tabIndex=-1 to main landmark + harden skip-link e2e test (stackwright-8c4) - Add tabIndex={-1} to
so activating the skip-to-content link actually moves keyboard focus (not just scrolls) - Harden skip-link e2e test from soft console.warn to hard expect assertion - Skip SSR test that was incorrect for App Router static export (stackwright-pzr) --- .beads/issues.jsonl | 10 +++++ .changeset/skip-link-tabindex.md | 5 +++ .../src/components/structural/PageLayout.tsx | 2 +- .../tests/a11y/keyboard-navigation.spec.ts | 33 ++++++--------- .../tests/edge-cases/error-scenarios.spec.ts | 40 +++++++++---------- 5 files changed, 49 insertions(+), 41 deletions(-) create mode 100644 .changeset/skip-link-tabindex.md diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f3ed6888..56553477 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,9 +1,15 @@ {"_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-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-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-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} @@ -35,6 +41,10 @@ {"_type":"issue","id":"stackwright-2o8","title":"feat(mcp): add MCP tools for integration management (list/get/add)","description":"Add stackwright_list_integrations, stackwright_get_integration, stackwright_add_integration MCP tools. Reads/writes stackwright.yml. Depends on integrations config schema issue. Estimated 3-4 hours. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/239","status":"closed","priority":2,"issue_type":"feature","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:47Z","created_by":"Stackwright Bot","updated_at":"2026-05-25T17:38:37Z","started_at":"2026-05-25T17:36:00Z","closed_at":"2026-05-25T17:38:37Z","close_reason":"Closed","dependencies":[{"issue_id":"stackwright-2o8","depends_on_id":"stackwright-5ak","type":"blocks","created_at":"2026-05-18T18:34:26Z","created_by":"Stackwright Bot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-5y5","title":"a11y: carousel white text on amber background fails WCAG AA (2.14:1 contrast)","description":"Carousel/feature cards use color:#FFFFFF on background:#f59e0b (amber), producing 2.14:1 contrast ratio. WCAG AA requires 4.5:1 normal / 3:1 large text. Fix: darken background, change text color, or adjust card background YAML field. Affects @stackwright/core carousel component and the hellostackwright showcase page. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/444","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:44Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:21:57Z","closed_at":"2026-05-19T00:21:57Z","close_reason":"Fixed: OverflowImageCard now uses getBetterTextColor('#1a1a1a','#FFFFFF',backgroundColor) instead of theme.colors.text in PR fix/a11y-cluster (PR #448)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-5ak","title":"feat(types): add integrations config field to siteConfigSchema","description":"Add integrations field to siteConfigSchema in @stackwright/types. Schema accepts array of integration objects with type (openapi|graphql|rest), name, and passthrough additional properties. Estimated 1-2 hours. This is a prerequisite for the MCP and CLI integration management tools. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/240","status":"closed","priority":2,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:43Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:22:32Z","closed_at":"2026-05-19T00:22:32Z","close_reason":"Already implemented: integrationConfigSchema fully built in siteConfig.ts with openapi|graphql|rest enum, name kebab-case validation, path traversal protection. Unblocks stackwright-2o8 and stackwright-als.","dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"stackwright-cvg","title":"Integration: First-party analytics bridge using existing consent system","description":"## Problem\n@stackwright/core exports getConsentState(), setConsentState(), hasConsent(analytics) with full IAB TCF categories. But NOTHING in the framework consumes them. The consent system is architecturally complete but functionally dead. Meanwhile, every real site needs analytics.\n\n## Proposed Solution\nCreate @stackwright/analytics package providing a consent-aware analytics bridge:\n- Supports Plausible (privacy-first, no cookies needed for necessary tier)\n- Supports Umami (self-hosted, GDPR-compliant)\n- Optional GA4 support (gated behind hasConsent(analytics))\n- Configuration in stackwright.yml:\n analytics:\n provider: plausible\n domain: mysite.com\n- Auto-injects script tag via StackwrightLayout/StackwrightDocument\n- Respects consent categories\n- Page view tracking automatic (listens to Next.js route changes)\n- OSS libraries: plausible-tracker (1.5KB) or @umami/tracker","acceptance_criteria":"- [ ] @stackwright/analytics package created with provider abstraction\n- [ ] Plausible provider implemented and tested\n- [ ] Umami provider implemented and tested\n- [ ] Script injection gated by hasConsent(analytics)\n- [ ] Cookie-free providers (Plausible) work without explicit consent\n- [ ] Page view tracking on route changes (App Router compatible)\n- [ ] stackwright.yml analytics config added to siteConfigSchema\n- [ ] JSON schema regenerated\n- [ ] Unit tests for consent gating logic\n- [ ] Documentation in AGENTS.md","status":"open","priority":3,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:31:42Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:31:42Z","labels":["consent","feature","integration"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"stackwright-v16","title":"Feature: Pagination component for collection_list","description":"## Problem\nCollectionList.tsx accepts limit to cap displayed entries, but there is no way for users to navigate to page 2. CollectionListOptions already has offset and limit — the data layer is ready but there is no UI. A site with 50 blog posts can only show the first N.\n\n## Proposed Solution\nAdd optional pagination config to collection_list schema:\n - type: collection_list\n source: blog\n card: { title: title, subtitle: excerpt, meta: date }\n limit: 10\n pagination:\n style: numbered # or load-more or infinite\n pageSize: 10\n\n- Implement client-side pagination (entries already in _entries from prebuild)\n- numbered: classic 1 2 3 ... N pagination bar\n- load-more: Show More button that reveals next batch\n- Keyboard accessible, announces page changes to screen readers","acceptance_criteria":"- [ ] collectionListContentSchema extended with optional pagination field\n- [ ] Numbered pagination component renders correctly\n- [ ] Load-more variant works correctly\n- [ ] Keyboard navigation through pagination controls\n- [ ] aria-live announces page change (Showing items 11-20 of 50)\n- [ ] Responsive: works at 320px viewport\n- [ ] URL state preserved (query param for page number)\n- [ ] Unit tests for pagination logic\n- [ ] JSON schema regenerated","status":"open","priority":3,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:31:11Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:31:11Z","labels":["collections","content-types","feature"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"stackwright-wx3","title":"Integration: Replace Prism.js with Shiki for code highlighting","description":"## Problem\nprismHighlighter.ts statically imports Prism + 10 language grammars with hardcoded light/dark color palettes. Issues:\n- Bundles all 10 grammars regardless of which languages a site actually uses\n- No connection to the theme system (colors are hardcoded hex values)\n- Only 10 languages supported without source modification\n- Client-side highlighting adds to JS bundle\n\n## Proposed Solution\nReplace with Shiki (powers VS Code, GitHub, VitePress):\n- Tree-shakes: only bundle grammars for languages used in YAML (detectable at prebuild)\n- 200+ languages supported out of the box\n- Theme-aware: can map Stackwright colors.surface / colors.text into a Shiki theme\n- SSR-friendly: renders to HTML with inline styles (no client JS needed)\n- Generate highlighted HTML at prebuild time → zero client-side Prism bundle\n\n## Migration Path\n- Prebuild detects all language values used across page YAML code_blocks\n- Generates pre-highlighted HTML stored in the page JSON\n- CodeBlock component renders static HTML (no client-side JS for highlighting)\n- Fallback: if pre-highlighted HTML not available, use lightweight client highlight","acceptance_criteria":"- [ ] Shiki integrated as prebuild-time highlighter\n- [ ] All 10 currently supported languages continue to work\n- [ ] Code blocks render without client-side JS for highlighting\n- [ ] Theme colors map to Shiki token colors\n- [ ] Dark mode uses appropriate token colors\n- [ ] Bundle size regression test (should decrease)\n- [ ] Visual regression tests pass for code-block screenshots\n- [ ] prismjs dependency removed","status":"open","priority":3,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:30:56Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:30:56Z","labels":["dx","integration","performance"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"stackwright-6g7","title":"Feature: RSS/Atom feed generation for collections","description":"## Problem\nThe collections system supports blog posts, articles, and news items with sort, indexFields, and entryPage config. But there is no feed output. Sites using Stackwright for content marketing have no way to offer RSS feeds without custom code.\n\n## Proposed Solution\nAdd optional feed config to _collection.yaml:\n feed:\n format: rss # or atom or both\n title: Blog\n description: Latest articles\n fields:\n title: title\n content: body\n date: publishedAt\n author: author\n\n- During prebuild, generate public/feed.xml (RSS 2.0) and/or public/atom.xml\n- Auto-add link rel=alternate type=application/rss+xml to StackwrightLayout head\n- Zero runtime cost — pure prebuild output\n- OSS library: feed (3KB, generates RSS 2.0, Atom 1.0, JSON Feed)","acceptance_criteria":"- [ ] collectionConfigSchema extended with optional feed field\n- [ ] prebuild generates public/feed.xml for collections with feed config\n- [ ] RSS 2.0 format validates against W3C Feed Validation Service\n- [ ] Atom 1.0 format validates when selected\n- [ ] StackwrightLayout auto-injects link rel=alternate in head\n- [ ] Feed respects locale (separate feeds per locale)\n- [ ] Unit tests for feed generation\n- [ ] JSON schema regenerated","status":"open","priority":3,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:30:40Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:30:40Z","labels":["collections","feature","prebuild"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-wln","title":"fix(e2e): add CI tolerance to runtime vitals benchmarks","description":"Runtime vitals benchmarks (LCP, theme switch time) in tests/performance/runtime-vitals.bench.ts fail intermittently in CI due to GitHub Actions runner performance variance. The tests treat timing measurements as deterministic but shared CI runners have variable load. Options: (1) add retry/tolerance margins for CI (e.g., 2x budget in CI mode), (2) mark runtime vitals as soft-fail in CI, (3) only run runtime vitals locally or in dedicated perf runners. Discovered during stackwright-rqj CI validation.","status":"open","priority":3,"issue_type":"task","owner":"bot@per-aspera.dev","created_at":"2026-05-30T22:20:41Z","created_by":"Stackwright Bot","updated_at":"2026-05-30T22:20:41Z","labels":["e2e","performance"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-wh7","title":"perf: Performance benchmark budget exceeded in CI","description":"## What breaks\n\nThe `Performance Benchmarks` CI job fails with:\n\n```\n❌ One or more performance benchmarks exceeded their budgets!\nReview the logs and performance-report.md for details.\n```\n\n## Context\n\nThis appears to be a pre-existing failure unrelated to specific code changes. Observed on PR #468 (CI run 26684947779) which only modified scaffold template files and static-generation reserved file list — changes that should have zero perf impact.\n\n## Suggested investigation\n\n- Check if benchmark budgets need recalibration after recent dependency upgrades\n- Verify CI runner variance isn't causing false positives (compare against baseline runs on `dev`)\n- Review `performance-report.md` artifact from a failing run for specific budget violations","status":"open","priority":3,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-30T14:32:01Z","created_by":"Stackwright Bot","updated_at":"2026-05-30T14:32:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-dqz","title":"deprecate(types,core): formally deprecate contact_form_stub content type","description":"The `form` content type (stackwright-rn9) fully supersedes contact_form_stub. Formally deprecate contact_form_stub: (1) Add a @deprecated JSDoc comment and console.warn in the component render. (2) Add a deprecation notice to the AGENTS.md content type table entry. (3) Add a migration note in CONTRIBUTING.md or a MIGRATION.md doc. (4) Do NOT remove it yet — wait for a major version bump. This should follow after the examples migration (chore issue) is complete.","status":"open","priority":3,"issue_type":"chore","owner":"bot@per-aspera.dev","created_at":"2026-05-28T20:18:21Z","created_by":"Stackwright Bot","updated_at":"2026-05-28T20:18:21Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.changeset/skip-link-tabindex.md b/.changeset/skip-link-tabindex.md new file mode 100644 index 00000000..2a352b31 --- /dev/null +++ b/.changeset/skip-link-tabindex.md @@ -0,0 +1,5 @@ +--- +"@stackwright/core": patch +--- + +fix(a11y): add tabIndex={-1} to main content area so skip-to-content link correctly moves keyboard focus (WCAG 2.4.1) diff --git a/packages/core/src/components/structural/PageLayout.tsx b/packages/core/src/components/structural/PageLayout.tsx index 5e48af08..5e2142db 100644 --- a/packages/core/src/components/structural/PageLayout.tsx +++ b/packages/core/src/components/structural/PageLayout.tsx @@ -125,7 +125,7 @@ export default function PageLayout({ pageContent, siteConfig, onNavigate }: Page {/* Content column: main grows to fill space, footer sits at the bottom */}
-
+
{renderContent(pageContent, { contentItemsOnly: true })}
diff --git a/packages/e2e/tests/a11y/keyboard-navigation.spec.ts b/packages/e2e/tests/a11y/keyboard-navigation.spec.ts index e7d6f53b..06efeb64 100644 --- a/packages/e2e/tests/a11y/keyboard-navigation.spec.ts +++ b/packages/e2e/tests/a11y/keyboard-navigation.spec.ts @@ -419,32 +419,25 @@ test.describe('Site-wide Keyboard Navigation', () => { test('Skip link is present and functional', async ({ page }) => { await page.goto('/', { waitUntil: 'networkidle' }); - // Press Tab to potentially reveal skip link + // Tab once — the skip link is the first focusable element and becomes + // visible on focus (visually-hidden-until-focused pattern). await page.keyboard.press('Tab'); - // Look for skip link (common patterns) - const skipLink = page - .locator('a[href="#main"], a[href="#content"], a[href="#main-content"]') - .first(); + // Hard assertion: the skip link MUST be present + const skipLink = page.locator('a[href="#main-content"]').first(); + await expect(skipLink).toBeVisible({ timeout: 2000 }); - if (await skipLink.isVisible()) { - // Activate the skip link - await skipLink.focus(); - await page.keyboard.press('Enter'); + // Activate the skip link + await page.keyboard.press('Enter'); + await page.waitForTimeout(100); - // Check that focus moved to main content - const focused = await getFocusedElement(page); - const focusedId = await focused.evaluate((el) => el?.id || ''); + // Focus should now be on
+ const focused = await page.evaluateHandle(() => document.activeElement); + const focusedId = await focused.evaluate((el) => (el as HTMLElement)?.id ?? ''); - expect( - ['main', 'content', 'main-content'].some((id) => focusedId.includes(id)), - 'Skip link should move focus to main content area' - ).toBe(true); + expect(focusedId).toBe('main-content'); - console.log('✅ Skip link is functional'); - } else { - console.warn('⚠️ No skip link found - consider adding one for better accessibility'); - } + console.log(' Skip link is present and moves focus to #main-content'); }); test('Modal/Dialog can be closed with Escape key', async ({ page }) => { diff --git a/packages/e2e/tests/edge-cases/error-scenarios.spec.ts b/packages/e2e/tests/edge-cases/error-scenarios.spec.ts index 24a233e9..007581b9 100644 --- a/packages/e2e/tests/edge-cases/error-scenarios.spec.ts +++ b/packages/e2e/tests/edge-cases/error-scenarios.spec.ts @@ -150,7 +150,7 @@ test.describe('Missing/Broken Media Handling', () => { }); test.describe('Extreme Content Length', () => { - test('Very long strings (10,000 chars) don\'t break layout', async ({ page }) => { + test("Very long strings (10,000 chars) don't break layout", async ({ page }) => { await page.goto('/', { waitUntil: 'networkidle' }); // Create a 10,000 character string @@ -207,7 +207,7 @@ test.describe('Extreme Content Length', () => { console.log('✅ Long unbroken words wrap correctly'); }); - test('Many nested elements (deep DOM) don\'t crash', async ({ page }) => { + test("Many nested elements (deep DOM) don't crash", async ({ page }) => { await page.goto('/', { waitUntil: 'networkidle' }); // Create deeply nested structure (100 levels) @@ -226,7 +226,7 @@ test.describe('Extreme Content Length', () => { const deepElement = page.locator('.nested-99'); await expect(deepElement).toBeVisible(); - console.log('✅ Deeply nested DOM doesn\'t crash'); + console.log("✅ Deeply nested DOM doesn't crash"); }); }); @@ -312,9 +312,7 @@ test.describe('Unicode and Special Characters', () => { console.log('✅ HTML special characters are escaped'); }); - - - test('Special markdown/code characters don\'t break rendering', async ({ page }) => { + test("Special markdown/code characters don't break rendering", async ({ page }) => { await page.goto('/', { waitUntil: 'networkidle' }); const specialChars = '`~!@#$%^&*()_+-={}[]|\\:";\'<>?,./'; @@ -337,7 +335,7 @@ test.describe('Unicode and Special Characters', () => { }); test.describe('Boundary Conditions', () => { - test('Empty content arrays don\'t crash', async ({ page }) => { + test("Empty content arrays don't crash", async ({ page }) => { // This would need a test page with empty content_items // For now, just verify showcase doesn't have empty content breaking things await page.goto(SHOWCASE_PAGE, { waitUntil: 'networkidle' }); @@ -364,12 +362,10 @@ test.describe('Boundary Conditions', () => { const zeroDiv = page.locator('#zero-size-test'); expect(await zeroDiv.count()).toBe(1); - console.log('✅ Zero-sized elements don\'t crash'); + console.log("✅ Zero-sized elements don't crash"); }); - - - test('Maximum viewport width doesn\'t break layout', async ({ page }) => { + test("Maximum viewport width doesn't break layout", async ({ page }) => { // Test with ultra-wide viewport await page.setViewportSize({ width: 3840, height: 2160 }); await page.goto('/', { waitUntil: 'networkidle' }); @@ -407,8 +403,8 @@ test.describe('Boundary Conditions', () => { }); test.describe('Rapid State Changes', () => { - test('Rapid page navigation doesn\'t crash', async ({ page }) => { - const pages = PAGES.slice(0, 4).map(p => p.path); + test("Rapid page navigation doesn't crash", async ({ page }) => { + const pages = PAGES.slice(0, 4).map((p) => p.path); for (let i = 0; i < 3; i++) { for (const pagePath of pages) { @@ -421,7 +417,7 @@ test.describe('Rapid State Changes', () => { console.log('✅ Rapid navigation handled gracefully'); }); - test('Spam clicking navigation links doesn\'t break', async ({ page }) => { + test("Spam clicking navigation links doesn't break", async ({ page }) => { await page.goto('/', { waitUntil: 'networkidle' }); const navLink = page.locator('header a[href], nav a[href]').first(); @@ -447,7 +443,7 @@ test.describe('Rapid State Changes', () => { }); test.describe('Browser Compatibility Edge Cases', () => { - test('localStorage unavailable doesn\'t crash', async ({ page }) => { + test("localStorage unavailable doesn't crash", async ({ page }) => { // Disable localStorage await page.addInitScript(() => { Object.defineProperty(window, 'localStorage', { @@ -463,17 +459,21 @@ test.describe('Browser Compatibility Edge Cases', () => { console.log('✅ Site works without localStorage'); }); - test('JavaScript disabled still shows content (SSR)', async ({ page, context }) => { - // Disable JavaScript + // App Router static export (output: 'export') produces client-rendered pages. + // JavaScript is REQUIRED for content to appear — this is expected and correct + // behavior for static exports (not a regression). The test below would only be + // meaningful for Pages Router SSR, which is deprecated in this project. + // See: https://nextjs.org/docs/app/building-your-application/deploying/static-exports + test.skip('JavaScript disabled still shows content (SSR)', async ({ page, context }) => { + // NOTE: Skipped — static export is client-rendered by design. + // Remove this skip and update the assertion if SSR is ever re-enabled. await context.setOffline(false); await page.goto('/', { waitUntil: 'domcontentloaded' }); - - // Turn off JS after initial load to test SSR content await context.clearCookies(); const bodyText = await page.locator('body').innerText(); expect(bodyText.length).toBeGreaterThan(0); - console.log('✅ SSR content visible without JS'); + console.log(' SSR content visible without JS'); }); });