From 6cf4b88cc1b13fc49d4c3d8bf66081988ab5da6d Mon Sep 17 00:00:00 2001 From: Timofey Zhivenok Date: Sat, 25 Apr 2026 00:34:11 +0200 Subject: [PATCH 1/4] feat(qa): add component testing framework --- qa/.gitignore | 4 + qa/README.md | 119 + qa/component-suite-runner.js | 388 ++++ qa/component-test-runner.js | 524 +++++ qa/components.json | 3316 ++++++++++++++++++++++++++++ qa/global-setup.ts | 66 + qa/layers/layer1-accessibility.js | 313 +++ qa/layers/layer2-interaction.js | 741 +++++++ qa/layers/layer3-props.js | 320 +++ qa/layers/layer4-responsive.js | 255 +++ qa/layers/utils.js | 70 + qa/playwright.config.ts | 27 + qa/spec-generator.js | 603 +++++ qa/specs/accordion.spec.ts | 83 + qa/specs/accordionitem.spec.ts | 89 + qa/specs/alert.spec.ts | 180 ++ qa/specs/avatar.spec.ts | 37 + qa/specs/badge.spec.ts | 343 +++ qa/specs/bottompanel.spec.ts | 89 + qa/specs/button.spec.ts | 284 +++ qa/specs/buttoninput.spec.ts | 89 + qa/specs/card.spec.ts | 89 + qa/specs/checkbox.spec.ts | 89 + qa/specs/checklistcard.spec.ts | 89 + qa/specs/code-block.spec.ts | 158 ++ qa/specs/codeblock.spec.ts | 159 ++ qa/specs/confirm-dialog.spec.ts | 143 ++ qa/specs/confirmdialog.spec.ts | 144 ++ qa/specs/dashcard.spec.ts | 89 + qa/specs/distribution-bar.spec.ts | 82 + qa/specs/distributionbar.spec.ts | 83 + qa/specs/divider.spec.ts | 89 + qa/specs/docsection.spec.ts | 89 + qa/specs/dropdown.spec.ts | 181 ++ qa/specs/dropdowncontainer.spec.ts | 137 ++ qa/specs/dropdowndivider.spec.ts | 89 + qa/specs/dropdownitem.spec.ts | 89 + qa/specs/dropzone.spec.ts | 244 ++ qa/specs/empty-state.spec.ts | 102 + qa/specs/emptystate.spec.ts | 103 + qa/specs/fileupload.spec.ts | 89 + qa/specs/filterchip.spec.ts | 89 + qa/specs/icon-input.spec.ts | 91 + qa/specs/iconinput.spec.ts | 92 + qa/specs/imagedropzone.spec.ts | 89 + qa/specs/input.spec.ts | 192 ++ qa/specs/lazyimage.spec.ts | 89 + qa/specs/leftbar.spec.ts | 89 + qa/specs/leftbargroup.spec.ts | 57 + qa/specs/leftbaritem.spec.ts | 89 + qa/specs/leftbarsection.spec.ts | 89 + qa/specs/leftbartoggle.spec.ts | 89 + qa/specs/link.spec.ts | 89 + qa/specs/linkcard.spec.ts | 89 + qa/specs/membercard.spec.ts | 89 + qa/specs/metriccard.spec.ts | 89 + qa/specs/modal.spec.ts | 176 ++ qa/specs/navbar.spec.ts | 89 + qa/specs/number-input.spec.ts | 40 + qa/specs/numberinput.spec.ts | 41 + qa/specs/optioncard.spec.ts | 89 + qa/specs/page-header.spec.ts | 36 + qa/specs/pageheader.spec.ts | 37 + qa/specs/pin-input.spec.ts | 115 + qa/specs/pininput.spec.ts | 116 + qa/specs/popover.spec.ts | 89 + qa/specs/progressbar.spec.ts | 89 + qa/specs/propstable.spec.ts | 89 + qa/specs/radio.spec.ts | 67 + qa/specs/radiogroup.spec.ts | 89 + qa/specs/range-slider.spec.ts | 91 + qa/specs/rangeslider.spec.ts | 92 + qa/specs/search-input.spec.ts | 91 + qa/specs/searchinput.spec.ts | 92 + qa/specs/select.spec.ts | 41 + qa/specs/settingcard.spec.ts | 89 + qa/specs/settingitem.spec.ts | 89 + qa/specs/skeleton.spec.ts | 89 + qa/specs/spinner.spec.ts | 89 + qa/specs/splitpane.spec.ts | 89 + qa/specs/stat.spec.ts | 89 + qa/specs/statusline.spec.ts | 89 + qa/specs/switch.spec.ts | 92 + qa/specs/tabs.spec.ts | 97 + qa/specs/textarea.spec.ts | 89 + qa/specs/themeselect.spec.ts | 89 + qa/specs/themetoggle.spec.ts | 89 + qa/specs/toast.spec.ts | 75 + qa/specs/tooltip.spec.ts | 69 + 89 files changed, 14480 insertions(+) create mode 100644 qa/.gitignore create mode 100644 qa/README.md create mode 100644 qa/component-suite-runner.js create mode 100644 qa/component-test-runner.js create mode 100644 qa/components.json create mode 100644 qa/global-setup.ts create mode 100644 qa/layers/layer1-accessibility.js create mode 100644 qa/layers/layer2-interaction.js create mode 100644 qa/layers/layer3-props.js create mode 100644 qa/layers/layer4-responsive.js create mode 100644 qa/layers/utils.js create mode 100644 qa/playwright.config.ts create mode 100644 qa/spec-generator.js create mode 100644 qa/specs/accordion.spec.ts create mode 100644 qa/specs/accordionitem.spec.ts create mode 100644 qa/specs/alert.spec.ts create mode 100644 qa/specs/avatar.spec.ts create mode 100644 qa/specs/badge.spec.ts create mode 100644 qa/specs/bottompanel.spec.ts create mode 100644 qa/specs/button.spec.ts create mode 100644 qa/specs/buttoninput.spec.ts create mode 100644 qa/specs/card.spec.ts create mode 100644 qa/specs/checkbox.spec.ts create mode 100644 qa/specs/checklistcard.spec.ts create mode 100644 qa/specs/code-block.spec.ts create mode 100644 qa/specs/codeblock.spec.ts create mode 100644 qa/specs/confirm-dialog.spec.ts create mode 100644 qa/specs/confirmdialog.spec.ts create mode 100644 qa/specs/dashcard.spec.ts create mode 100644 qa/specs/distribution-bar.spec.ts create mode 100644 qa/specs/distributionbar.spec.ts create mode 100644 qa/specs/divider.spec.ts create mode 100644 qa/specs/docsection.spec.ts create mode 100644 qa/specs/dropdown.spec.ts create mode 100644 qa/specs/dropdowncontainer.spec.ts create mode 100644 qa/specs/dropdowndivider.spec.ts create mode 100644 qa/specs/dropdownitem.spec.ts create mode 100644 qa/specs/dropzone.spec.ts create mode 100644 qa/specs/empty-state.spec.ts create mode 100644 qa/specs/emptystate.spec.ts create mode 100644 qa/specs/fileupload.spec.ts create mode 100644 qa/specs/filterchip.spec.ts create mode 100644 qa/specs/icon-input.spec.ts create mode 100644 qa/specs/iconinput.spec.ts create mode 100644 qa/specs/imagedropzone.spec.ts create mode 100644 qa/specs/input.spec.ts create mode 100644 qa/specs/lazyimage.spec.ts create mode 100644 qa/specs/leftbar.spec.ts create mode 100644 qa/specs/leftbargroup.spec.ts create mode 100644 qa/specs/leftbaritem.spec.ts create mode 100644 qa/specs/leftbarsection.spec.ts create mode 100644 qa/specs/leftbartoggle.spec.ts create mode 100644 qa/specs/link.spec.ts create mode 100644 qa/specs/linkcard.spec.ts create mode 100644 qa/specs/membercard.spec.ts create mode 100644 qa/specs/metriccard.spec.ts create mode 100644 qa/specs/modal.spec.ts create mode 100644 qa/specs/navbar.spec.ts create mode 100644 qa/specs/number-input.spec.ts create mode 100644 qa/specs/numberinput.spec.ts create mode 100644 qa/specs/optioncard.spec.ts create mode 100644 qa/specs/page-header.spec.ts create mode 100644 qa/specs/pageheader.spec.ts create mode 100644 qa/specs/pin-input.spec.ts create mode 100644 qa/specs/pininput.spec.ts create mode 100644 qa/specs/popover.spec.ts create mode 100644 qa/specs/progressbar.spec.ts create mode 100644 qa/specs/propstable.spec.ts create mode 100644 qa/specs/radio.spec.ts create mode 100644 qa/specs/radiogroup.spec.ts create mode 100644 qa/specs/range-slider.spec.ts create mode 100644 qa/specs/rangeslider.spec.ts create mode 100644 qa/specs/search-input.spec.ts create mode 100644 qa/specs/searchinput.spec.ts create mode 100644 qa/specs/select.spec.ts create mode 100644 qa/specs/settingcard.spec.ts create mode 100644 qa/specs/settingitem.spec.ts create mode 100644 qa/specs/skeleton.spec.ts create mode 100644 qa/specs/spinner.spec.ts create mode 100644 qa/specs/splitpane.spec.ts create mode 100644 qa/specs/stat.spec.ts create mode 100644 qa/specs/statusline.spec.ts create mode 100644 qa/specs/switch.spec.ts create mode 100644 qa/specs/tabs.spec.ts create mode 100644 qa/specs/textarea.spec.ts create mode 100644 qa/specs/themeselect.spec.ts create mode 100644 qa/specs/themetoggle.spec.ts create mode 100644 qa/specs/toast.spec.ts create mode 100644 qa/specs/tooltip.spec.ts diff --git a/qa/.gitignore b/qa/.gitignore new file mode 100644 index 0000000..ea8e9e3 --- /dev/null +++ b/qa/.gitignore @@ -0,0 +1,4 @@ +reports/ +screenshots/ +.auth/ +node_modules/ diff --git a/qa/README.md b/qa/README.md new file mode 100644 index 0000000..17a2a0a --- /dev/null +++ b/qa/README.md @@ -0,0 +1,119 @@ +--- +title: QA Runner — Usage & Environments +type: readme +date_created: 2026-04-24 +last_updated: 2026-04-24 +project: Selify.ai +tags: [qa, storybook, runner, environments, readme] +--- + +# QA Component Runner + +Automated 4-layer accessibility and interaction test suite for Jera UI components. + +--- + +## Layers + +| Layer | What it tests | +|---|---| +| L1 — Accessibility | axe-core (WCAG 2.1 AA) + canvas pixel contrast sampling | +| L2 — Interaction | Tab focus, focus ring, Enter/Space, Escape, click, hover, disabled, 5 ARIA checks | +| L3 — Props edge cases | Empty string, long text, special chars (XSS), all variants, numeric zero, whitespace | +| L4 — Responsive | 375×812 · 768×1024 · 1440×900 — render, overflow, text readability, layout | + +--- + +## Environments + +### Selify (default) + +Uses `admin.selify.ai` — a custom component documentation system, **not** standard Storybook. +No `STORYBOOK_MODE` configuration needed; `selify` is the default. + +**Selectors used:** +- Preview area: `.preview-area` +- Controls panel: `.controls-panel` +- Variant select: `.controls-panel select.jera-select` +- Dark/light toggle: `.dark-light-toggle` (L1 runs both light + dark) + +**Run single component:** +```bash +source load-credentials.sh +STORYBOOK_URL=https://admin.selify.ai node component-test-runner.js --component Badge +``` + +**Run full suite:** +```bash +source load-credentials.sh +STORYBOOK_URL=https://admin.selify.ai node component-suite-runner.js +``` + +--- + +### Standard Storybook + +Set `STORYBOOK_MODE=storybook` for a standard Storybook v7/v8 instance (e.g. Nicholas's `localhost:6006`). + +**Selectors used:** +- Preview area: `.docs-story` (first story block in docs view) +- Controls panel: `.docblock-argstable` +- Variant select: `.docblock-argstable select` +- Dark/light toggle: none (L1 runs light mode only — no built-in toggle) + +**URL format:** `{STORYBOOK_URL}/?path=/docs/{slug}--docs` + +**Run single component (CI, no auth):** +```bash +STORYBOOK_MODE=storybook STORYBOOK_URL=http://localhost:6006 \ + node component-test-runner.js --component Badge +``` + +**Run full suite (CI):** +```bash +STORYBOOK_MODE=storybook STORYBOOK_URL=http://localhost:6006 \ + node component-suite-runner.js --ci +``` + +> **Note:** Selectors are based on Storybook v7/v8 docs view DOM structure. +> If selectors don't match your setup, please open an issue or check your Storybook version. + +--- + +## Flags + +| Flag | Description | +|---|---| +| `--component ` | Run a single component by PascalCase name (e.g. `--component Badge`) | +| `--dry-run` | Discover components but skip tests | +| `--ci` | Skip authentication (for CI environments) | +| `--no-specs` | Skip auto-generating `.spec.ts` files after a suite run | +| `--components a,b` | Suite only: run a comma-separated subset of components | + +--- + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `STORYBOOK_URL` | `https://admin.selify.ai` | Base URL of the docs/Storybook server | +| `STORYBOOK_MODE` | `selify` | `selify` or `storybook` — controls URL format and DOM selectors | +| `CI` | — | Set to `true` to skip authentication | +| `SELIFY_EMAIL` | — | Login email (Selify mode only) | +| `SELIFY_PASSWORD` | — | Login password (Selify mode only) | + +--- + +## Output + +| File | Location | +|---|---| +| Per-component JSON | `System/Projects/Selify.ai/Reports/{Component}-test-results.json` | +| Suite report | `System/Projects/Selify.ai/Reports/suite-test-results.json` | +| Playwright specs | `qa/specs/{component}.spec.ts` (auto-generated for failing components) | +| Screenshots | `System/Projects/Selify.ai/Assets/Screenshots/` | + +--- + +## Related +[[component-test-runner]] | [[component-suite-runner]] | [[spec-generator]] | [[Architecture-v2]] diff --git a/qa/component-suite-runner.js b/qa/component-suite-runner.js new file mode 100644 index 0000000..18f6757 --- /dev/null +++ b/qa/component-suite-runner.js @@ -0,0 +1,388 @@ +/* + component-suite-runner.js + ───────────────────────────────────────────────────────────────────────────── + Full component library test suite. Loads components from Jera src/index.js + (source of truth), cross-references components.json for stage metadata, and + runs component-test-runner.js for each stable/beta component. + + Skills used (via component-test-runner.js): + - component-props-parser.md → variant discovery + - axe-core.md → accessibility scan + - screenshot-capture.md → evidence capture + - dark-light-toggle.md → theme switching + + Usage: + source load-credentials.sh + node component-suite-runner.js + + Optional flags: + --dry-run Discover components but do not run tests + --no-specs Skip automatic .spec.ts generation after run + --ci CI mode (set via CI=true env var or this flag) + --components button,badge Run only these components (comma-separated, case-insensitive) + + Environment: + STORYBOOK_URL Base URL for Storybook (default: http://localhost:6006) + + Output: + Per-component JSONs → qa/reports/ + Suite report → qa/reports/suite-test-results.json + ───────────────────────────────────────────────────────────────────────────── +*/ + +'use strict'; + +const { execSync } = require('child_process'); +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +const { testComponent, ensureAuthenticated } = require('./component-test-runner'); + +// ── Paths ────────────────────────────────────────────────────────────────── +const REPORTS_DIR = path.join(__dirname, 'reports'); +const STORYBOOK_URL = (process.env.STORYBOOK_URL || 'http://localhost:6006').replace(/\/$/, ''); +// Selify mode: uses custom /docs/components/ pages +// Standard Storybook mode: uses /?path=/docs/ with v7/v8 DOM structure +const STORYBOOK_MODE = process.env.STORYBOOK_MODE || 'selify'; + +// src/index.js — local path works when running from Jera repo (qa/ dir); +// falls back to GitHub raw if not present (QA-System standalone context). +const JERA_INDEX_LOCAL = path.join(__dirname, '../src/index.js'); +const JERA_INDEX_REMOTE = 'https://raw.githubusercontent.com/miozu-com/jera/main/src/index.js'; + +// components.json — stage metadata. +const COMPONENTS_JSON_LOCAL = path.join(__dirname, 'components.json'); + +// ── Load components from Jera src/index.js ──────────────────────────────── +// Source of truth: miozu-com/jera/src/index.js +// Cross-references components.json for stage (stable/beta only). +// Returns: [{ component: 'ButtonGroup', slug: 'button-group', storyUrl, stage }] +function loadComponents() { + // Step 1: Read src/index.js (local first, then GitHub fallback) + let indexContent; + if (fs.existsSync(JERA_INDEX_LOCAL)) { + indexContent = fs.readFileSync(JERA_INDEX_LOCAL, 'utf8'); + console.log('[Components] Using local src/index.js'); + } else { + console.log('[Components] Local src/index.js not found — fetching from GitHub...'); + try { + indexContent = execSync(`curl -sf "${JERA_INDEX_REMOTE}"`, { encoding: 'utf8', timeout: 15000 }); + } catch (err) { + throw new Error(`Could not fetch Jera src/index.js from GitHub: ${err.message}`); + } + } + + // Extract PascalCase names: `export { default as ComponentName } from ...` + const componentNames = []; + const re = /export\s*\{\s*default\s+as\s+(\w+)\s*\}/g; + let m; + while ((m = re.exec(indexContent)) !== null) { + componentNames.push(m[1]); + } + console.log(`[Components] Found ${componentNames.length} component(s) in src/index.js`); + + // Step 2: Load stage metadata from components.json + const stageMap = {}; + for (const p of [COMPONENTS_JSON_LOCAL]) { + if (fs.existsSync(p)) { + try { + const data = JSON.parse(fs.readFileSync(p, 'utf8')); + for (const c of data.components) { + stageMap[c.name] = c.stage; + } + console.log(`[Components] Loaded stage metadata from ${p}`); + break; + } catch (err) { + console.warn(`[Components] Could not read ${p}: ${err.message}`); + } + } + } + + // Step 3: Build list — stable + beta only + const components = []; + const skipped = []; + + for (const name of componentNames) { + const stage = stageMap[name] ?? 'draft'; + // PascalCase → kebab-case + const slug = name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + // Selify mode: uses custom /docs/components/ pages + // Standard Storybook mode: uses /?path=/docs/ with v7/v8 DOM structure + const storyUrl = STORYBOOK_MODE === 'storybook' + ? `${STORYBOOK_URL}/?path=/docs/${slug}--docs` + : `${STORYBOOK_URL}/docs/components/${slug}/`; + + if (stage === 'stable' || stage === 'beta') { + components.push({ component: name, slug, storyUrl, stage }); + } else { + skipped.push({ component: name, stage }); + } + } + + if (skipped.length) { + console.log(`[Components] Skipped ${skipped.length} component(s) (not stable/beta):`); + for (const s of skipped) { + console.log(` ✗ ${s.component} — ${s.stage}`); + } + } + + return components; +} + +// ── CLI args ─────────────────────────────────────────────────────────────── +const args = process.argv.slice(2); +const dryRun = args.includes('--dry-run'); +const noSpecs = args.includes('--no-specs'); +const CI_MODE = process.env.CI === 'true' || args.includes('--ci'); + +const componentsFlag = (() => { + const idx = args.indexOf('--components'); + if (idx === -1) return null; + return new Set(args[idx + 1].toLowerCase().split(',').map(s => s.trim())); +})(); + +// ── Aggregate results into suite report ─────────────────────────────────── + +function buildSuiteReport(componentResults, startTime) { + const suite = { + date: new Date().toISOString().slice(0, 10), + duration_ms: Date.now() - startTime, + components_tested: componentResults.length, + summary: { + total_variants: 0, + total_violations: 0, + s1: 0, s2: 0, s3: 0, s4: 0, + components_clean: 0, + components_failed: 0, + components_error: 0, + }, + components: [], + }; + + for (const r of componentResults) { + const totalViolations = r.summary.total ?? r.summary.total_violations ?? 0; + const hasViolations = totalViolations > 0; + suite.summary.total_variants += r.variants_tested?.length ?? r.summary.total_variants ?? 0; + suite.summary.total_violations += totalViolations; + suite.summary.s1 += r.summary.s1 ?? 0; + suite.summary.s2 += r.summary.s2 ?? 0; + suite.summary.s3 += r.summary.s3 ?? 0; + suite.summary.s4 += r.summary.s4 ?? 0; + if (r.error) suite.summary.components_error++; + else if (hasViolations) suite.summary.components_failed++; + else suite.summary.components_clean++; + + suite.components.push({ + component: r.component, + variants_tested: r.summary.total_variants, + total_violations: (r.summary.s1 ?? 0) + (r.summary.s2 ?? 0) + (r.summary.s3 ?? 0) + (r.summary.s4 ?? 0), + s1: r.summary.s1, + s2: r.summary.s2, + s3: r.summary.s3, + s4: r.summary.s4, + light_failed: r.layer1?.failed ?? r.light_mode?.failed ?? [], + dark_failed: r.layer1?.failed ?? r.dark_mode?.failed ?? [], + error: r.error ?? null, + status: r.error ? 'ERROR' : hasViolations ? 'FAIL' : 'PASS', + }); + } + + return suite; +} + +// ── CI: commit reports to qa-reports branch ─────────────────────────────── + +/** + * In CI mode: stash reports onto a dedicated `qa-reports` branch, then return + * to the original working branch. This keeps report artifacts out of feature + * branches while still versioning them in git. + * + */ +function commitReportsToQaBranch() { + const QA_BRANCH = 'qa-reports'; + const reportsDir = REPORTS_DIR; + + let originalBranch; + try { + originalBranch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim(); + } catch (_) { + console.warn('[qa-reports] Not inside a git repo — skipping branch commit.'); + return; + } + + console.log(`\n[qa-reports] Committing reports (current branch: ${originalBranch})...`); + + try { + // Create qa-reports branch if it doesn't exist; otherwise just check it out + const branches = execSync('git branch --list', { encoding: 'utf8' }); + if (branches.split('\n').some(b => b.trim().replace(/^\* /, '') === QA_BRANCH)) { + execSync(`git checkout ${QA_BRANCH}`, { stdio: 'pipe' }); + } else { + execSync(`git checkout -b ${QA_BRANCH}`, { stdio: 'pipe' }); + } + + // Stage all JSON reports in the reports dir + execSync(`git add "${reportsDir}"/*.json`, { stdio: 'pipe' }); + + const date = new Date().toISOString().slice(0, 10); + const message = `qa-reports: suite run ${date}`; + execSync(`git commit -m "${message}" --allow-empty`, { stdio: 'pipe' }); + + // Push to remote (best-effort — CI runner may not have push rights) + try { + execSync(`git push origin ${QA_BRANCH} --force-with-lease`, { stdio: 'pipe' }); + console.log(`[qa-reports] Pushed to origin/${QA_BRANCH}`); + } catch (_) { + console.warn(`[qa-reports] Push skipped (no remote write access or offline)`); + } + + } catch (err) { + console.error(`[qa-reports] Branch commit failed: ${err.message}`); + } finally { + // Always return to original branch + try { + execSync(`git checkout ${originalBranch}`, { stdio: 'pipe' }); + console.log(`[qa-reports] Returned to ${originalBranch}`); + } catch (switchErr) { + console.error(`[qa-reports] Could not return to ${originalBranch}: ${switchErr.message}`); + } + } +} + +// ── Main ─────────────────────────────────────────────────────────────────── + +async function main() { + const startTime = Date.now(); + const run_id = `suite-${startTime}`; + + console.log('\n═══════════════════════════════════════════════════'); + console.log(' Component Suite Runner — Selify.ai'); + console.log(` STORYBOOK_URL : ${STORYBOOK_URL}`); + console.log(` STORYBOOK_MODE : ${STORYBOOK_MODE}`); + console.log(` CI mode : ${CI_MODE}`); + console.log(` Dry run : ${dryRun}`); + if (componentsFlag) console.log(` Filter : ${[...componentsFlag].join(', ')}`); + console.log('═══════════════════════════════════════════════════\n'); + + // Step 1: Load components from Jera src/index.js + console.log('[Discovery] Loading components from Jera src/index.js...'); + let components = loadComponents(); + + if (!components.length) { + console.error('No stable/beta components found. Check src/index.js and components.json.'); + process.exit(1); + } + + // Step 2: Apply --components filter if provided + if (componentsFlag) { + components = components.filter(c => componentsFlag.has(c.component.toLowerCase())); + } + + console.log(`\n → Running tests on ${components.length} component(s):`); + for (const c of components) { + console.log(` [${c.stage}] ${c.component} — ${c.storyUrl}`); + } + + if (dryRun) { + console.log('\n[Dry Run] Discovery complete — skipping tests.'); + return; + } + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + + try { + // Step 3: Auth — single login for the whole suite + console.log('\n[Auth] Checking authentication...'); + await ensureAuthenticated(page); + + // Step 4: Run tests for each component + fs.mkdirSync(REPORTS_DIR, { recursive: true }); + const componentResults = []; + + for (let i = 0; i < components.length; i++) { + const { component, storyUrl } = components[i]; + console.log(`\n[${i + 1}/${components.length}] Testing: ${component}`); + + try { + const result = await testComponent(page, context, component, storyUrl, run_id); + componentResults.push(result); + + const reportPath = path.join(REPORTS_DIR, `${component}-test-results.json`); + fs.writeFileSync(reportPath, JSON.stringify(result, null, 2), 'utf8'); + console.log(` → Saved: ${reportPath}`); + } catch (err) { + console.error(` → ERROR testing ${component}: ${err.message}`); + componentResults.push({ + component, + url: storyUrl, + error: err.message, + variants_tested: [], + light_mode: { passed: [], failed: [] }, + dark_mode: { passed: [], failed: [] }, + violations: [], + summary: { total_variants: 0, total_violations: 0, s1: 0, s2: 0, s3: 0, s4: 0 }, + }); + } + } + + // Step 5: Build and save suite report + const suite = buildSuiteReport(componentResults, startTime); + const suiteReportPath = path.join(REPORTS_DIR, 'suite-test-results.json'); + fs.writeFileSync(suiteReportPath, JSON.stringify(suite, null, 2), 'utf8'); + + // Step 6: Print summary + const durationSec = (suite.duration_ms / 1000).toFixed(1); + console.log('\n═══════════════════════════════════════════════════'); + console.log(' SUITE COMPLETE'); + console.log(` Duration : ${durationSec}s`); + console.log(` Components : ${suite.components_tested}`); + console.log(` Variants tested : ${suite.summary.total_variants}`); + console.log(` Total violations: ${suite.summary.total_violations}`); + console.log(` S1: ${suite.summary.s1} S2: ${suite.summary.s2} S3: ${suite.summary.s3} S4: ${suite.summary.s4}`); + console.log(` Clean: ${suite.summary.components_clean} Failed: ${suite.summary.components_failed} Error: ${suite.summary.components_error}`); + console.log(''); + + if (suite.summary.components_failed > 0) { + console.log(' Failed components:'); + for (const c of suite.components.filter(c => c.status === 'FAIL')) { + console.log(` ✗ ${c.component} — ${c.total_violations} violation(s) (S1:${c.s1} S2:${c.s2} S3:${c.s3} S4:${c.s4})`); + } + console.log(''); + } + + console.log(` Suite report: ${suiteReportPath}`); + console.log('═══════════════════════════════════════════════════\n'); + + // Step 7: Auto-generate .spec.ts files for failed components + if (!noSpecs && suite.summary.components_failed > 0) { + try { + const { generateSpecs } = require('./spec-generator'); + generateSpecs(suiteReportPath); + } catch (err) { + console.warn(`[spec-generator] Could not generate specs: ${err.message}`); + } + } + + // Step 8: CI — commit reports to qa-reports branch, then return to original branch + if (CI_MODE) { + commitReportsToQaBranch(); + } + + // Exit with non-zero if any S1/S2 violations found + if (suite.summary.s1 > 0 || suite.summary.s2 > 0) { + console.error('S1/S2 violations found — review immediately.'); + process.exit(1); + } + + } finally { + await browser.close(); + } +} + +main().catch(err => { + console.error('FATAL ERROR:', err.message); + process.exit(1); +}); diff --git a/qa/component-test-runner.js b/qa/component-test-runner.js new file mode 100644 index 0000000..a117396 --- /dev/null +++ b/qa/component-test-runner.js @@ -0,0 +1,524 @@ +/* + component-test-runner.js (v2 — 4-layer modular architecture) + ───────────────────────────────────────────────────────────────────────────── + Orchestrates all 4 test layers for a single component. + + Layer 1 — Accessibility (layers/layer1-accessibility.js) + axe-core + canvas pixel sampling contrast · screenshot on violation only + + Layer 2 — Interaction (layers/layer2-interaction.js) + Tab focus · focus visible · Enter/Space · click · hover · disabled + + Layer 3 — Props (layers/layer3-props.js) + Empty string · long text · special chars · all variants · numeric zero · whitespace + + Layer 4 — Responsive (layers/layer4-responsive.js) + 375×812 · 768×1024 · 1440×900 · render / overflow / text / layout + + Usage: + source load-credentials.sh + node component-test-runner.js https://admin.selify.ai/docs/components/badge/ + node component-test-runner.js --component Badge + + Environment variables: + STORYBOOK_URL Base URL of the Storybook/docs server (default: http://localhost:6006) + STORYBOOK_MODE 'selify' (default) or 'storybook' — controls URL format and DOM selectors + CI Set to 'true' to skip authentication + + STORYBOOK_MODE values: + selify → Selify custom /docs/components/ pages with .preview-area + .controls-panel + storybook → Standard Storybook v7/v8 /?path=/docs/ with .docs-story + .docblock-argstable + + Output: + Screenshots → qa/screenshots/ + JSON report → qa/reports/[component]-full-test-results.json + ───────────────────────────────────────────────────────────────────────────── +*/ + +'use strict'; + +// ── Config ────────────────────────────────────────────────────────────────── +// CI mode: set CI=true env var (standard). --ci flag is also accepted for +// local overrides (e.g. running a single component against a local server). +const CI_MODE = process.env.CI === 'true' || process.argv.includes('--ci'); + +// Selify mode: uses custom /docs/components/ pages +// Standard Storybook mode: uses /?path=/docs/ with v7/v8 DOM structure +const STORYBOOK_MODE = process.env.STORYBOOK_MODE || 'selify'; +const STORYBOOK_URL = (process.env.STORYBOOK_URL || 'http://localhost:6006').replace(/\/$/, ''); + +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +const { runLayer1All } = require('./layers/layer1-accessibility'); +const { runLayer2All } = require('./layers/layer2-interaction'); +const { runLayer3All } = require('./layers/layer3-props'); +const { runLayer4All } = require('./layers/layer4-responsive'); +const { getSelectors } = require('./layers/utils'); + +// ── Paths ────────────────────────────────────────────────────────────────── +const SCREENSHOTS_DIR = path.join(__dirname, 'screenshots'); +const REPORTS_DIR = path.join(__dirname, 'reports'); + +// ── URL builder ───────────────────────────────────────────────────────────── + +/** + * Build the component URL from a PascalCase component name. + * Selify mode: uses custom /docs/components/ pages + * Standard Storybook mode: uses /?path=/docs/ with v7/v8 DOM structure + */ +function buildComponentUrl(componentName) { + const slug = componentName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + if (STORYBOOK_MODE === 'storybook') { + return `${STORYBOOK_URL}/?path=/docs/${slug}--docs`; + } + return `${STORYBOOK_URL}/docs/components/${slug}/`; +} + +// ── Auth ──────────────────────────────────────────────────────────────────── + +async function ensureAuthenticated(page) { + if (CI_MODE) { + console.log(' → CI mode — skipping authentication'); + return; + } + const EMAIL = process.env.STORYBOOK_EMAIL; + const PASSWORD = process.env.STORYBOOK_PASSWORD; + if (!EMAIL || !PASSWORD) { + throw new Error('STORYBOOK_EMAIL / STORYBOOK_PASSWORD not set. Run: source load-credentials.sh'); + } + + const adminUrl = STORYBOOK_URL; + await page.goto(adminUrl, { waitUntil: 'networkidle', timeout: 30000 }); + const url = page.url(); + + if (!url.includes('/auth') && !url.includes('/login') && !url.includes('/sign')) { + console.log(' → Already authenticated'); + return; + } + + console.log(' → Not authenticated — logging in...'); + + // Step 1: Fill email + const emailField = page.locator('input[type="email"], input[name="email"], input[placeholder*="mail" i]').first(); + await emailField.waitFor({ state: 'visible', timeout: 10000 }); + await emailField.fill(EMAIL); + await page.waitForTimeout(300); + + // Step 2: If password is not yet visible, click "Sign in with password" to reveal it + const passwordField = page.locator('input[type="password"]').first(); + let passwordVisible = await passwordField.isVisible().catch(() => false); + if (!passwordVisible) { + const pwdBtn = page.locator('button:has-text("Sign in with password"), a:has-text("Sign in with password")').first(); + if (await pwdBtn.isVisible().catch(() => false)) { + await pwdBtn.click(); + await page.waitForTimeout(800); + } + passwordVisible = await passwordField.isVisible().catch(() => false); + } + + if (passwordVisible) { + // Step 3: Fill password + await passwordField.fill(PASSWORD); + await page.waitForTimeout(300); + + // Step 4: Submit — press Enter on password field (button click silently fails + // on dash.selify.ai SvelteKit form; Enter correctly fires the POST) + await passwordField.press('Enter'); + } + + await page.waitForTimeout(4000); + await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {}); + const finalUrl = page.url(); + if (finalUrl.includes('/auth') || finalUrl.includes('/login')) { + throw new Error(`Login failed — still on auth page: ${finalUrl}`); + } + console.log(' → Login successful'); +} + +// ── Variant parsing ───────────────────────────────────────────────────────── + +async function parseVariants(page, componentUrl) { + const selectors = getSelectors(STORYBOOK_MODE); + + await page.goto(componentUrl, { waitUntil: 'networkidle', timeout: 30000 }); + + const hasControls = await page.waitForSelector(selectors.controls, { timeout: 8000 }) + .then(() => true) + .catch(() => false); + + if (!hasControls) { + // Selify mode: .controls-panel not found — no variant switching available + // Standard Storybook mode: .docblock-argstable not found — same fallback + console.warn(` [WARN] Controls not found with selector "${selectors.controls}" in ${STORYBOOK_MODE} mode. Check STORYBOOK_MODE env var.`); + return null; // signal fallback mode + } + + await page.waitForTimeout(500); + + const options = await page.evaluate((variantSel) => { + const select = document.querySelector(variantSel); + if (!select) return []; + return Array.from(select.options) + .map(o => o.value) + .filter(v => v !== ''); + }, selectors.variantSelect); + + return options.length ? options : null; // null = fallback mode +} + +// ── Variant + mode helpers ─────────────────────────────────────────────────── + +async function applyVariant(page, variant) { + const selectors = getSelectors(STORYBOOK_MODE); + await page.selectOption(selectors.variantSelect, variant); + await page.waitForTimeout(300); +} + +async function getCurrentTheme(page) { + return page.evaluate(() => document.documentElement.getAttribute('data-theme') ?? 'light'); +} + +async function toggleTheme(page) { + // Selify mode: dark/light toggle is a checkbox inside .controls-panel + // Standard Storybook mode: no built-in toggle — returns null (caller skips dark mode) + if (STORYBOOK_MODE === 'storybook') return null; + + const toggled = await page.evaluate(() => { + const rows = Array.from(document.querySelectorAll('.controls-panel .control-row')); + const darkRow = rows.find(r => r.textContent?.toLowerCase().includes('dark')); + if (!darkRow) return false; + const input = darkRow.querySelector('input[type="checkbox"]'); + if (!input) return false; + input.click(); + return true; + }); + if (!toggled) return null; // non-fatal — caller handles missing toggle + await page.waitForTimeout(400); + return await page.evaluate(() => document.documentElement.getAttribute('data-theme')); +} + +async function ensureMode(page, mode) { + // Standard Storybook mode: no dark/light toggle — always stay in light mode + if (STORYBOOK_MODE === 'storybook') return; + + const current = await getCurrentTheme(page); + const isDark = current === 'dark' || current === 'miozu-dark'; + const wantDark = mode === 'dark'; + if (isDark !== wantDark) { + const result = await toggleTheme(page); + if (result === null) { + console.warn(` [mode] Dark mode toggle not found — staying in current mode`); + return; // skip dark mode gracefully + } + await page.waitForTimeout(300); + } +} + +// ── Fallback: no controls panel — L1 + L4 only ─────────────────────────────── + +async function testComponentFallback(page, context, component, storyUrl, runId) { + const started_at = new Date().toISOString(); + const selectors = getSelectors(STORYBOOK_MODE); + console.log(` → Fallback mode — L1+L4 only (no controls panel)`); + + const noOp = async () => {}; + + // L1 — scan preview area if present, otherwise body; light mode only + // Selify mode: .preview-area is the component container + // Standard Storybook mode: .docs-story is the component container + const previewExists = await page.$(selectors.previewArea).then(el => !!el).catch(() => false); + const l1Selector = previewExists ? selectors.previewArea : 'body'; + + const layerOpts = { + page, + component, + variants: ['default'], + modes: ['light'], + screenshotsDir: SCREENSHOTS_DIR, + applyVariant: noOp, + ensureMode: noOp, + selector: l1Selector, + selectors, + }; + + console.log(`\n[${component}] Layer 1 — Accessibility (fallback)...`); + const layer1 = await runLayer1All(layerOpts); + console.log(` → L1: ${layer1.violations.length} violation(s) | passed: ${layer1.passed.length} | failed: ${layer1.failed.length}`); + + // L4 — responsive, no variant switching + console.log(`\n[${component}] Layer 4 — Responsive (fallback)...`); + const layer4 = await runLayer4All({ + context, + componentUrl: storyUrl, + component, + variants: [], + screenshotsDir: SCREENSHOTS_DIR, + applyVariant: noOp, + ensureAuthenticated, + }); + console.log(` → L4: mobile:${layer4.mobile?.render} | tablet:${layer4.tablet?.render} | desktop:${layer4.desktop?.render}`); + + const allViolations = [...layer1.violations, ...(layer4.violations || [])]; + + const summary = { + overall: allViolations.length === 0 ? 'pass' : 'fail', + s1: 0, s2: 0, s3: 0, s4: 0, + total: allViolations.length, + layers_failed: [], + fallback: true, + layers_skipped: ['layer2-interaction', 'layer3-props'], + }; + + for (const v of allViolations) { + const sev = (v.severity || 'S4').toLowerCase(); + summary[sev] = (summary[sev] || 0) + 1; + } + + if (layer1.summary.total > 0) summary.layers_failed.push('layer1-accessibility'); + if (layer4.summary?.total > 0) summary.layers_failed.push('layer4-responsive'); + + const layer2 = { skipped: true, reason: 'no controls panel', violations: [], summary: { total: 0, s1: 0, s2: 0, s3: 0, s4: 0 } }; + const layer3 = { skipped: true, reason: 'no controls panel', violations: [], summary: { total: 0, s1: 0, s2: 0, s3: 0, s4: 0 } }; + + const artifacts = [ + ...(layer1.screenshots || []), + ...(layer4.violations?.filter(v => v.screenshot).map(v => v.screenshot) || []), + ].filter(Boolean); + + return { + run_id: runId ?? `run-${Date.now()}`, + pipeline: 'component', + target: component, + layers: { layer1, layer2, layer3, layer4 }, + status: summary.overall, + started_at, + completed_at: new Date().toISOString(), + artifacts, + violations_found: summary.total, + // legacy fields — kept for suite runner compatibility + component, + url: storyUrl, + variants_tested: ['default'], + fallback_mode: true, + layer1, layer2, layer3, layer4, + summary, + }; +} + +// ── Core orchestrator ──────────────────────────────────────────────────────── + +/** + * Run all 4 test layers for a single component and return a unified result object. + * + * @param {import('playwright').Page} page - Playwright page (authenticated) + * @param {import('playwright').BrowserContext} context - Browser context (for L4 viewports) + * @param {string} componentName - PascalCase component name + * @param {string} storyUrl - Full URL to the component doc page + * @param {string} [runId] - Optional run identifier + * @returns {Promise} Unified result with layers, summary, and artifacts + */ +async function testComponent(page, context, componentName, storyUrl, runId) { + const component = componentName.toLowerCase(); + const started_at = new Date().toISOString(); + const selectors = getSelectors(STORYBOOK_MODE); + + fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + fs.mkdirSync(REPORTS_DIR, { recursive: true }); + + // Parse variants — returns null if controls not found (triggers fallback) + console.log(`\n[${component}] Parsing variants...`); + const variants = await parseVariants(page, storyUrl); + if (variants === null) return testComponentFallback(page, context, component, storyUrl, runId); + console.log(` → Found ${variants.length} variant(s): ${variants.join(', ')}`); + + // Warn if preview area not found — common misconfiguration signal + const previewExists = await page.$(selectors.previewArea).then(el => !!el).catch(() => false); + if (!previewExists) { + // Selify mode: .preview-area not found — check STORYBOOK_MODE + // Standard Storybook mode: .docs-story not found — check STORYBOOK_MODE + console.warn(` [WARN] Preview area not found with selector "${selectors.previewArea}" in ${STORYBOOK_MODE} mode. Check STORYBOOK_MODE env var.`); + } + + // Shared helpers passed into each layer. + // modes: only run dark mode when a toggle exists (Selify only). + const layerOpts = { + page, + component, + variants, + modes: selectors.modeToggle ? ['light', 'dark'] : ['light'], + screenshotsDir: SCREENSHOTS_DIR, + applyVariant, + ensureMode, + selector: selectors.previewArea, + selectors, + }; + + // ── Layer 1 — Accessibility ──────────────────────────────────────────────── + console.log(`\n[${component}] Layer 1 — Accessibility...`); + const layer1 = await runLayer1All(layerOpts); + console.log(` → L1: ${layer1.violations.length} violation(s) | passed: ${layer1.passed.length} | failed: ${layer1.failed.length}`); + + // ── Layer 2 — Interaction ───────────────────────────────────────────────── + console.log(`\n[${component}] Layer 2 — Interaction...`); + await ensureMode(page, 'light'); + const layer2 = await runLayer2All(layerOpts); + console.log(` → L2: ${layer2.violations.length} violation(s) across ${variants.length} variants`); + + // ── Layer 3 — Props ─────────────────────────────────────────────────────── + console.log(`\n[${component}] Layer 3 — Props edge cases...`); + await ensureMode(page, 'light'); + const layer3 = await runLayer3All(layerOpts); + console.log(` → L3: ${layer3.violations.length} violation(s)`); + console.log(` empty_string:${layer3.empty_string} | long_text:${layer3.long_text} | special_chars:${layer3.special_chars}`); + + // ── Layer 4 — Responsive ────────────────────────────────────────────────── + console.log(`\n[${component}] Layer 4 — Responsive (3 viewports)...`); + const layer4 = await runLayer4All({ + context, + componentUrl: storyUrl, + component, + variants, + screenshotsDir: SCREENSHOTS_DIR, + applyVariant, + ensureAuthenticated, + }); + console.log(` → L4: mobile:${layer4.mobile?.render} | tablet:${layer4.tablet?.render} | desktop:${layer4.desktop?.render}`); + + // ── Unified summary ─────────────────────────────────────────────────────── + const allViolations = [ + ...layer1.violations, + ...layer2.violations, + ...(layer3.violations || []), + ...(layer4.violations || []), + ]; + + const summary = { + overall: allViolations.length === 0 ? 'pass' : 'fail', + s1: 0, s2: 0, s3: 0, s4: 0, + total: allViolations.length, + layers_failed: [], + }; + + for (const v of allViolations) { + const sev = (v.severity || 'S4').toLowerCase(); + summary[sev] = (summary[sev] || 0) + 1; + } + + if (layer1.summary.total > 0) summary.layers_failed.push('layer1-accessibility'); + if (layer2.summary.total > 0) summary.layers_failed.push('layer2-interaction'); + if (layer3.summary?.total > 0) summary.layers_failed.push('layer3-props'); + if (layer4.summary?.total > 0) summary.layers_failed.push('layer4-responsive'); + + const artifacts = [ + ...(layer1.screenshots || []), + ...(layer2.results?.flatMap(r => r.screenshot ? [r.screenshot] : []) || []), + ...(layer3.violations?.filter(v => v.screenshot).map(v => v.screenshot) || []), + ...(layer4.violations?.filter(v => v.screenshot).map(v => v.screenshot) || []), + ].filter(Boolean); + + const result = { + run_id: runId ?? `run-${Date.now()}`, + pipeline: 'component', + target: component, + layers: { layer1, layer2, layer3, layer4 }, + status: summary.overall, + started_at, + completed_at: new Date().toISOString(), + artifacts, + violations_found: summary.total, + // legacy fields — kept for suite runner compatibility + component, + url: storyUrl, + variants_tested: variants, + layer1, layer2, layer3, layer4, + summary, + }; + + return result; +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + const args = process.argv.slice(2).filter(a => a !== '--ci'); + + // Support both a full URL and --component + // Selify mode: built URL → ${STORYBOOK_URL}/docs/components/${slug}/ + // Standard Storybook mode: built URL → ${STORYBOOK_URL}/?path=/docs/${slug}--docs + let componentUrl, componentName; + const compIdx = args.indexOf('--component'); + if (compIdx !== -1) { + componentName = args[compIdx + 1]; + if (!componentName) { + console.error('--component requires a component name (e.g. --component Badge)'); + process.exit(1); + } + componentUrl = buildComponentUrl(componentName); + } else { + componentUrl = args[0]; + componentName = componentUrl?.replace(/\/$/, '').split('/').pop(); + } + + if (!componentUrl) { + console.error('Usage:'); + console.error(' node component-test-runner.js '); + console.error(' node component-test-runner.js --component '); + console.error(''); + console.error('Examples:'); + console.error(' node component-test-runner.js https://admin.selify.ai/docs/components/badge/'); + console.error(' STORYBOOK_URL=https://admin.selify.ai node component-test-runner.js --component Badge'); + console.error(' STORYBOOK_MODE=storybook STORYBOOK_URL=http://localhost:6006 node component-test-runner.js --component Badge'); + process.exit(1); + } + + console.log(`\n═══════════════════════════════════════════════════`); + console.log(` Component Test Runner v2 — ${componentName.toUpperCase()}`); + console.log(` URL: ${componentUrl}`); + console.log(` Mode: ${STORYBOOK_MODE}`); + console.log(` Layers: 1-Accessibility · 2-Interaction · 3-Props · 4-Responsive`); + console.log(`═══════════════════════════════════════════════════`); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + + const run_id = `run-${Date.now()}`; + + try { + console.log('\n[Auth] Checking authentication...'); + await ensureAuthenticated(page); + + const result = await testComponent(page, context, componentName, componentUrl, run_id); + + // Save JSON report + const reportPath = path.join(REPORTS_DIR, `${componentName}-full-test-results.json`); + fs.writeFileSync(reportPath, JSON.stringify(result, null, 2), 'utf8'); + + // Print summary + console.log('\n═══════════════════════════════════════════════════'); + console.log(` DONE — ${componentName.toUpperCase()}`); + console.log(` run_id: ${result.run_id}`); + console.log(` Status: ${result.status.toUpperCase()}`); + console.log(` Variants tested: ${result.variants_tested.length}`); + console.log(` Violations: ${result.violations_found}`); + console.log(` S1: ${result.summary.s1} S2: ${result.summary.s2} S3: ${result.summary.s3} S4: ${result.summary.s4}`); + console.log(` Layers failed: ${result.summary.layers_failed.join(', ') || 'none'}`); + console.log(` Report: ${reportPath}`); + console.log('═══════════════════════════════════════════════════\n'); + + return result; + } finally { + await browser.close(); + } +} + +if (require.main === module) { + main().catch(err => { + console.error('FATAL ERROR:', err.message); + process.exit(1); + }); +} else { + module.exports = { testComponent, ensureAuthenticated, applyVariant, ensureMode, parseVariants }; +} diff --git a/qa/components.json b/qa/components.json new file mode 100644 index 0000000..51014ce --- /dev/null +++ b/qa/components.json @@ -0,0 +1,3316 @@ +{ + "schemaVersion": "2.0.0", + "library": "@miozu/jera", + "description": "Zero-dependency, AI-first component library for Svelte 5", + "framework": "svelte5", + "cssSystem": "scoped-css-base16", + "totalComponents": 90, + "stageDefinitions": { + "draft": "Under development, API may change", + "beta": "Functional, not yet in production consumers", + "stable": "Production-ready, used by consumers", + "deprecated": "Replaced, will be removed" + }, + "docLevelDefinitions": { + "none": "No docs page", + "minimal": "Playground + props table", + "standard": "Playground + header + variants + props + usage examples", + "complete": "Standard + design guidance + component-specific demos" + }, + "components": [ + { + "name": "Button", + "category": "primitives", + "path": "src/components/primitives/Button.svelte", + "description": "Primary action trigger, polymorphic (a/button)", + "props": { + "variant": { + "type": "'primary'|'secondary'|'outline'|'ghost'|'success'|'danger'|'warning'|'info'", + "default": "'primary'", + "description": "Visual style variant" + }, + "size": { + "type": "'xs'|'sm'|'md'|'lg'", + "default": "'md'", + "description": "Button size" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable interactions" + }, + "loading": { + "type": "boolean", + "default": "false", + "description": "Show default spinner (simple mode)" + }, + "loader": { + "type": "{active, icon?, text?, style?, position?}", + "default": "null", + "description": "Rich loading state — replaces content with custom icon/text. icon is a snippet, position is 'left'|'right'" + }, + "icon": { + "type": "{icon, position?}", + "default": "null", + "description": "Structured icon placement — icon is a snippet, position is 'left'|'right' (default left)" + }, + "fullWidth": { + "type": "boolean", + "default": "false", + "description": "Expand to full container width" + }, + "href": { + "type": "string", + "default": "undefined", + "description": "Renders as tag when set" + }, + "type": { + "type": "string", + "default": "'button'", + "description": "HTML button type attribute" + } + }, + "snippets": [ + "children" + ], + "events": [ + "onclick" + ], + "consumers": [ + "dash", + "admin", + "miozu" + ], + "stage": "stable", + "revision": 5, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Badge", + "category": "primitives", + "path": "src/components/primitives/Badge.svelte", + "description": "Status tag with semantic color variants, optional indicator dot, and icon support. Bordered tinted style (10% bg, 30% border, 2px radius).", + "props": { + "variant": { + "type": "'default'|'primary'|'secondary'|'success'|'warning'|'error'|'info'", + "default": "'default'", + "description": "Color variant" + }, + "size": { + "type": "'xs'|'sm'|'md'|'lg'", + "default": "'md'", + "description": "Badge size" + }, + "label": { + "type": "string", + "default": "''", + "description": "Convenience text content (used when no children)" + }, + "indicator": { + "type": "boolean", + "default": "false", + "description": "Show colored dot matching variant color" + }, + "class": { + "type": "string", + "default": "''", + "description": "Additional CSS classes on the badge element" + } + }, + "snippets": [ + "children" + ], + "events": [ + "onclick" + ], + "consumers": [ + "dash", + "admin", + "miozu" + ], + "stage": "stable", + "revision": 4, + "lastReviewed": "2026-03-05", + "breaking": [ + "v2: Removed iconLeft/iconRight snippets, clickable prop. Changed from pill (9999px) to 4px radius with 1px border.", + "v3: Changed border-radius from 4px (radius-default) to 2px (radius-sm)." + ], + "docLevel": "standard" + }, + { + "name": "Divider", + "category": "primitives", + "path": "src/components/primitives/Divider.svelte", + "description": "Visual separator with optional label", + "props": { + "orientation": { + "type": "'horizontal'|'vertical'", + "default": "'horizontal'", + "description": "Divider direction" + }, + "thickness": { + "type": "string", + "default": "'1px'", + "description": "Line thickness" + }, + "spacing": { + "type": "string", + "default": "'1rem'", + "description": "Margin around divider" + } + }, + "snippets": [ + "children" + ], + "consumers": [ + "dash", + "admin", + "miozu" + ], + "stage": "stable", + "revision": 1, + "lastReviewed": "2026-02-22", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Avatar", + "category": "primitives", + "path": "src/components/primitives/Avatar.svelte", + "description": "User avatar with image, algorithmic Truchet fallback, initials, and status indicator", + "props": { + "src": { + "type": "string", + "default": "''", + "description": "Image URL" + }, + "seed": { + "type": "string", + "default": "''", + "description": "Seed for algorithmic fallback" + }, + "name": { + "type": "string", + "default": "''", + "description": "Used for initials fallback and alt text" + }, + "size": { + "type": "'xs'|'sm'|'md'|'lg'|'xl'|'2xl'", + "default": "'md'", + "description": "Avatar size" + }, + "status": { + "type": "null|'online'|'offline'|'busy'|'away'", + "default": "null", + "description": "Status indicator dot" + }, + "radius": { + "type": "number|string", + "default": "4", + "description": "Border radius in px (number) or CSS value (string, e.g. '50%')" + }, + "alt": { + "type": "string", + "default": "''", + "description": "Alt text for img element (falls back to name)" + }, + "class": { + "type": "string", + "default": "''", + "description": "Custom CSS class" + } + }, + "consumers": [ + "admin", + "miozu" + ], + "stage": "stable", + "revision": 1, + "lastReviewed": "2026-02-22", + "breaking": [], + "docLevel": "complete" + }, + { + "name": "Stat", + "category": "primitives", + "path": "src/components/primitives/Stat.svelte", + "description": "Metric display with optional progress bar", + "props": { + "value": { + "type": "string|number", + "required": true, + "description": "Metric value" + }, + "label": { + "type": "string", + "required": true, + "description": "Metric label" + }, + "status": { + "type": "''|'success'|'warning'|'error'|'info'", + "default": "''", + "description": "Status color" + }, + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Display size" + }, + "showBar": { + "type": "boolean", + "default": "false", + "description": "Show progress bar below value" + }, + "description": { + "type": "string", + "default": "''", + "description": "Description text below the value (alias for secondary, takes priority)" + } + }, + "consumers": [ + "dash" + ], + "stage": "stable", + "revision": 3, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "ThemeToggle", + "category": "primitives", + "path": "src/components/primitives/ThemeToggle.svelte", + "description": "Animated sun/moon theme switcher", + "props": { + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Toggle button size" + }, + "variant": { + "type": "'ghost'|'outline'|'subtle'", + "default": "'ghost'", + "description": "Button style" + } + }, + "consumers": [ + "miozu" + ], + "stage": "stable", + "revision": 3, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "ThemeSelect", + "category": "primitives", + "path": "src/components/primitives/ThemeSelect.svelte", + "description": "Three-option theme selector (light/dark/system)", + "props": { + "variant": { + "type": "'segmented'|'dropdown'", + "default": "'segmented'", + "description": "Selector style" + }, + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Control size" + } + }, + "consumers": [ + "dash" + ], + "stage": "stable", + "revision": 3, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Input", + "category": "forms", + "path": "src/components/forms/Input.svelte", + "description": "Flexible text input with full browser feature control, error states, and aria-invalid accessibility", + "props": { + "value": { + "type": "string", + "default": "''", + "bindable": true, + "description": "Input value (bindable)" + }, + "ref": { + "type": "HTMLInputElement", + "bindable": true, + "description": "DOM element reference (bindable)" + }, + "type": { + "type": "string", + "default": "'text'", + "description": "HTML input type (text, email, password, number, tel, url, search)" + }, + "placeholder": { + "type": "string", + "default": "''", + "description": "Placeholder text" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable input" + }, + "required": { + "type": "boolean", + "default": "false", + "description": "Mark as required" + }, + "error": { + "type": "boolean", + "default": "false", + "description": "Show error state with red border and aria-invalid" + }, + "name": { + "type": "string", + "default": "''", + "description": "Form field name" + }, + "id": { + "type": "string", + "default": "''", + "description": "Element ID" + }, + "maxlength": { + "type": "number", + "description": "Maximum character count" + }, + "minlength": { + "type": "number", + "description": "Minimum character count" + }, + "inputmode": { + "type": "string", + "description": "Virtual keyboard hint (numeric, tel, email, url, search)" + }, + "autocomplete": { + "type": "string", + "default": "'on'", + "description": "Browser autocomplete behavior" + }, + "disableBrowserFeatures": { + "type": "boolean", + "default": "false", + "description": "Disable autofill, autocorrect, autocapitalize, and spellcheck" + }, + "unstyled": { + "type": "boolean", + "default": "false", + "description": "Remove default styling" + }, + "class": { + "type": "string", + "default": "''", + "description": "Additional CSS classes" + } + }, + "events": [ + "oninput", + "onchange", + "onkeydown", + "onfocus", + "onblur" + ], + "consumers": [ + "dash", + "admin", + "miozu" + ], + "stage": "stable", + "revision": 4, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Textarea", + "category": "forms", + "path": "src/components/forms/Textarea.svelte", + "description": "Multiline text input with optional auto-resize", + "props": { + "value": { + "type": "string", + "default": "''", + "bindable": true, + "description": "Textarea value (bindable)" + }, + "rows": { + "type": "number", + "default": "3", + "description": "Number of visible rows" + }, + "autoResize": { + "type": "boolean", + "default": "false", + "description": "Auto-grow with content" + }, + "error": { + "type": "boolean", + "default": "false", + "description": "Show error state" + } + }, + "events": [ + "oninput", + "onchange" + ], + "consumers": [ + "admin" + ], + "stage": "stable", + "revision": 4, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Select", + "category": "forms", + "path": "src/components/forms/Select.svelte", + "description": "Native select element styled with base16 tokens", + "props": { + "options": { + "type": "array", + "default": "[]", + "description": "Array of {value, label} objects" + }, + "value": { + "type": "any", + "default": "null", + "bindable": true, + "description": "Selected value (bindable)" + }, + "placeholder": { + "type": "string", + "default": "'Select...'", + "description": "Placeholder when no selection" + }, + "labelKey": { + "type": "string", + "default": "'label'", + "description": "Key to use for option display text" + }, + "valueKey": { + "type": "string", + "default": "'value'", + "description": "Key to use for option value" + }, + "size": { + "type": "'xs'|'sm'|'md'|'lg'", + "default": "'md'", + "description": "Select size" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable selection" + }, + "error": { + "type": "boolean", + "default": "false", + "description": "Show error state" + }, + "id": { + "type": "string", + "default": "undefined", + "description": "HTML id attribute" + }, + "name": { + "type": "string", + "default": "undefined", + "description": "Form field name (native select handles submission)" + }, + "required": { + "type": "boolean", + "default": "false", + "description": "Mark field as required" + } + }, + "events": [ + "onchange" + ], + "consumers": [ + "admin", + "miozu" + ], + "stage": "stable", + "revision": 6, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Checkbox", + "category": "forms", + "path": "src/components/forms/Checkbox.svelte", + "description": "Checkbox with label via children snippet", + "props": { + "checked": { + "type": "boolean", + "default": "false", + "bindable": true, + "description": "Checked state (bindable)" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable checkbox" + }, + "error": { + "type": "boolean", + "default": "false", + "description": "Show error state" + } + }, + "snippets": [ + "children" + ], + "events": [ + "onchange" + ], + "consumers": [ + "admin", + "miozu" + ], + "stage": "stable", + "revision": 3, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Switch", + "category": "forms", + "path": "src/components/forms/Switch.svelte", + "description": "Accessible toggle switch for boolean values with smooth CSS transitions", + "props": { + "checked": { + "type": "boolean", + "default": "false", + "bindable": true, + "description": "Toggle state (bindable)" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable toggle" + }, + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Switch size" + }, + "name": { + "type": "string", + "default": "''", + "description": "Form input name attribute" + }, + "id": { + "type": "string", + "default": "undefined", + "description": "HTML id (auto-generated if omitted)" + }, + "label": { + "type": "string", + "default": "''", + "description": "Label text displayed next to the switch (alternative to children snippet)" + }, + "description": { + "type": "string", + "default": "''", + "description": "Secondary description text below the label" + }, + "class": { + "type": "string", + "default": "''", + "description": "Additional CSS classes on the wrapper label" + } + }, + "snippets": [ + "children" + ], + "events": [ + "onchange" + ], + "consumers": [ + "dash", + "admin" + ], + "stage": "stable", + "revision": 5, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "SearchInput", + "category": "forms", + "path": "src/components/forms/SearchInput.svelte", + "description": "Search field with icon, clear button, loading state", + "props": { + "value": { + "type": "string", + "default": "''", + "bindable": true, + "description": "Search value (bindable)" + }, + "placeholder": { + "type": "string", + "default": "'Search...'", + "description": "Placeholder text" + }, + "loading": { + "type": "boolean", + "default": "false", + "description": "Show loading spinner" + }, + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Input size" + }, + "id": { + "type": "string", + "default": "undefined", + "description": "HTML id attribute" + }, + "name": { + "type": "string", + "default": "undefined", + "description": "Form field name" + }, + "required": { + "type": "boolean", + "default": "false", + "description": "Mark field as required" + }, + "error": { + "type": "boolean", + "default": "false", + "description": "Show error border state" + } + }, + "events": [ + "oninput", + "onchange", + "onclear" + ], + "consumers": [ + "admin" + ], + "stage": "stable", + "revision": 4, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Toast", + "category": "feedback", + "path": "src/components/feedback/Toast.svelte", + "description": "Enterprise toast system — singleton state, dual progress (ring/bar), pause-on-hover, Svelte transitions, popover top-layer", + "statePattern": "singleton: getToastState()", + "consumers": [ + "dash", + "admin", + "miozu" + ], + "stage": "stable", + "revision": 7, + "lastReviewed": "2026-03-28", + "breaking": [ + { + "revision": 5, + "change": "Convenience methods changed from (message, options?) to (title, messageOrOptions?, options?). Context API deprecated in favor of getToastState() singleton." + } + ], + "docLevel": "standard" + }, + { + "name": "Alert", + "category": "feedback", + "path": "src/components/feedback/Alert.svelte", + "description": "Inline alert with icon and actions", + "props": { + "variant": { + "type": "'info'|'success'|'warning'|'error'", + "default": "'info'", + "description": "Alert color and icon" + }, + "title": { + "type": "string", + "default": "''", + "description": "Alert title" + }, + "dismissible": { + "type": "boolean", + "default": "false", + "description": "Show close button" + }, + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Alert size" + } + }, + "snippets": [ + "children", + "icon", + "actions" + ], + "events": [ + "onclose" + ], + "consumers": [ + "dash", + "admin" + ], + "stage": "stable", + "revision": 3, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Spinner", + "category": "feedback", + "path": "src/components/feedback/Spinner.svelte", + "description": "Loading indicator", + "props": { + "size": { + "type": "'xs'|'sm'|'md'|'lg'|'xl'", + "default": "'md'", + "description": "Spinner size" + }, + "color": { + "type": "string", + "default": "'currentColor'", + "description": "Spinner color" + } + }, + "consumers": [ + "admin" + ], + "stage": "stable", + "revision": 3, + "lastReviewed": "2026-03-07", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "EmptyState", + "category": "feedback", + "path": "src/components/feedback/EmptyState.svelte", + "description": "Empty data placeholder", + "props": { + "title": { + "type": "string", + "default": "'No data found'", + "description": "Title text" + }, + "description": { + "type": "string", + "default": "''", + "description": "Description text" + }, + "size": { + "type": "'default'|'compact'|'large'", + "default": "'default'", + "description": "Display size" + } + }, + "snippets": [ + "icon", + "actions" + ], + "consumers": [ + "admin" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Skeleton", + "category": "feedback", + "path": "src/components/feedback/Skeleton.svelte", + "description": "Loading placeholder with shimmer animation. Variant mode for primitives, preset mode for layout patterns.", + "props": { + "variant": { + "type": "'text'|'heading'|'circle'|'rect'", + "default": "'text'", + "description": "Shape variant (ignored when preset is set)" + }, + "preset": { + "type": "'card'|'content'|'toc'|null", + "default": "null", + "description": "Layout preset pattern" + }, + "width": { + "type": "string", + "default": "'100%'", + "description": "CSS width" + }, + "height": { + "type": "string", + "default": "null", + "description": "CSS height override" + }, + "size": { + "type": "string", + "default": "null", + "description": "Width and height for circle variant" + }, + "lines": { + "type": "number", + "default": "1", + "description": "Number of lines (text variant or content/toc preset)" + }, + "animate": { + "type": "boolean", + "default": "true", + "description": "Enable shimmer animation" + } + }, + "consumers": [ + "miozu" + ], + "stage": "stable", + "revision": 4, + "lastReviewed": "2026-03-07", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "ProgressBar", + "category": "feedback", + "path": "src/components/feedback/ProgressBar.svelte", + "description": "Determinate/indeterminate progress indicator", + "props": { + "value": { + "type": "number", + "default": "0", + "description": "Current progress value" + }, + "max": { + "type": "number", + "default": "100", + "description": "Maximum value" + }, + "variant": { + "type": "'primary'|'success'|'warning'|'error'|'info'", + "default": "'primary'", + "description": "Color variant" + }, + "indeterminate": { + "type": "boolean", + "default": "false", + "description": "Show indeterminate animation" + } + }, + "consumers": [ + "miozu" + ], + "stage": "stable", + "revision": 4, + "lastReviewed": "2026-03-07", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Modal", + "category": "overlays", + "path": "src/components/overlays/Modal.svelte", + "description": "Dialog overlay with variants and fill mode", + "props": { + "open": { + "type": "boolean", + "default": "false", + "bindable": true, + "description": "Modal visibility (bindable)" + }, + "title": { + "type": "string", + "default": "''", + "description": "Modal title" + }, + "size": { + "type": "'sm'|'md'|'lg'|'xl'|'full'", + "default": "'md'", + "description": "Dialog width" + }, + "variant": { + "type": "'default'|'danger'|'warning'|'success'|'info'", + "default": "'default'", + "description": "Title color variant" + }, + "fill": { + "type": "boolean", + "default": "false", + "description": "Anchor to stable height (80dvh)" + }, + "closeOnBackdrop": { + "type": "boolean", + "default": "true", + "description": "Close when clicking backdrop" + }, + "closeOnEscape": { + "type": "boolean", + "default": "true", + "description": "Close on Escape key" + } + }, + "snippets": [ + "children", + "footer", + "icon" + ], + "events": [ + "onclose" + ], + "consumers": [ + "dash", + "admin", + "miozu" + ], + "stage": "stable", + "revision": 3, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Popover", + "category": "overlays", + "path": "src/components/overlays/Popover.svelte", + "description": "Hover popover with CSS Anchor Positioning", + "props": { + "content": { + "type": "string", + "default": "''", + "description": "Popover text content" + }, + "position": { + "type": "'top'|'bottom'|'left'|'right'", + "default": "'top'", + "description": "Popover position" + }, + "offset": { + "type": "number", + "default": "8", + "description": "Distance from trigger (px)" + } + }, + "snippets": [ + "children", + "content" + ], + "consumers": [ + "miozu" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "ConfirmDialog", + "category": "overlays", + "path": "src/components/overlays/ConfirmDialog.svelte", + "description": "Pre-built confirmation modal", + "props": { + "open": { + "type": "boolean", + "default": "false", + "bindable": true, + "description": "Dialog visibility (bindable)" + }, + "title": { + "type": "string", + "default": "'Confirm Action'", + "description": "Dialog title" + }, + "message": { + "type": "string", + "default": "'Are you sure?'", + "description": "Confirmation message" + }, + "variant": { + "type": "'danger'|'warning'|'success'|'info'", + "default": "'danger'", + "description": "Color variant" + } + }, + "events": [ + "onconfirm", + "oncancel" + ], + "consumers": [ + "dash" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Tabs", + "category": "navigation", + "path": "src/components/navigation/Tabs.svelte", + "description": "Tab navigation with animated sliding indicator and configurable accent color", + "props": { + "tabs": { + "type": "array", + "default": "[]", + "description": "Array of {id, label, badge?, icon?, disabled?, panelId?}" + }, + "active": { + "type": "string", + "default": "null", + "bindable": true, + "description": "Active tab id (bindable)" + }, + "variant": { + "type": "'default'|'segment'|'underline'|'pills'", + "default": "'default'", + "description": "Visual style variant" + }, + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Tab size" + }, + "color": { + "type": "'primary'|'danger'|'warning'|'success'|'info'", + "default": "'primary'", + "description": "Accent color for indicator, focus ring, and active badge" + }, + "fullWidth": { + "type": "boolean", + "default": "false", + "description": "Stretch tabs to fill container width" + }, + "class": { + "type": "string", + "default": "''", + "description": "Additional CSS classes on the tabs container" + } + }, + "events": [ + "onchange" + ], + "consumers": [ + "dash", + "admin", + "miozu" + ], + "stage": "stable", + "revision": 3, + "lastReviewed": "2026-03-02", + "breaking": [ + "v3: Segment variant — removed box-shadow, simplified to base00 bg with base02 border. Added 2px inset padding around thumb." + ], + "docLevel": "standard" + }, + { + "name": "Accordion", + "category": "navigation", + "path": "src/components/navigation/Accordion.svelte", + "description": "Collapsible content sections", + "props": { + "expanded": { + "type": "array", + "default": "[]", + "bindable": true, + "description": "Array of expanded item ids (bindable)" + }, + "multiple": { + "type": "boolean", + "default": "false", + "description": "Allow multiple items open" + } + }, + "snippets": [ + "children" + ], + "consumers": [ + "dash", + "miozu" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "AccordionItem", + "category": "navigation", + "path": "src/components/navigation/AccordionItem.svelte", + "description": "Expandable item with title and custom indicator", + "props": { + "id": { + "type": "string", + "default": "auto", + "description": "Unique item identifier" + }, + "title": { + "type": "string", + "default": "''", + "description": "Item header text" + }, + "expanded": { + "type": "boolean", + "default": "false", + "bindable": true, + "description": "Expanded state (bindable)" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable toggle" + }, + "badge": { + "type": "string|number", + "default": "null", + "description": "Badge text in header" + } + }, + "snippets": [ + "children", + "leading", + "trailing", + "indicator" + ], + "consumers": [ + "dash", + "miozu" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "NavBar", + "category": "navigation", + "path": "src/components/navigation/NavBar.svelte", + "description": "Horizontal mega-menu navigation bar", + "props": { + "brand": { + "type": "object", + "default": "{label:'Home'}", + "description": "Brand config: {icon, label, description, breadcrumbs}" + }, + "sections": { + "type": "array", + "default": "[]", + "description": "Menu sections with items" + }, + "subnavItems": { + "type": "array", + "default": "[]", + "description": "Sub-navigation items" + }, + "sticky": { + "type": "boolean", + "default": "false", + "description": "Stick to top of viewport" + } + }, + "snippets": [ + "actions", + "center", + "subnav", + "renderIcon" + ], + "consumers": [ + "dash", + "admin" + ], + "stage": "stable", + "revision": 3, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "LeftBar", + "category": "navigation", + "path": "src/components/navigation/LeftBar.svelte", + "description": "Collapsible sidebar with context and persistence", + "props": { + "collapsed": { + "type": "boolean", + "default": "false", + "bindable": true, + "description": "Sidebar collapsed state (bindable)" + }, + "persistKey": { + "type": "string", + "default": "null", + "description": "LocalStorage key for persistence" + }, + "cookieKey": { + "type": "string", + "default": "null", + "description": "Cookie key for SSR persistence" + } + }, + "snippets": [ + "header", + "navigation", + "footer", + "children" + ], + "consumers": [ + "dash", + "admin", + "miozu" + ], + "stage": "stable", + "revision": 4, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "LeftBarSection", + "category": "navigation", + "path": "src/components/navigation/LeftBarSection.svelte", + "description": "Grouped navigation section with title", + "props": { + "title": { + "type": "string", + "default": "''", + "description": "Section title" + } + }, + "snippets": [ + "children" + ], + "consumers": [ + "dash", + "admin", + "miozu" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "LeftBarItem", + "category": "navigation", + "path": "src/components/navigation/LeftBarItem.svelte", + "description": "Nav item with icon, badge, expandable subroutes", + "props": { + "href": { + "type": "string", + "default": "null", + "description": "Navigation URL" + }, + "label": { + "type": "string", + "default": "''", + "description": "Item label" + }, + "icon": { + "type": "Component", + "default": "null", + "description": "Svelte icon component" + }, + "active": { + "type": "boolean", + "default": "false", + "description": "Active state" + }, + "expandable": { + "type": "boolean", + "default": "false", + "description": "Show expand arrow" + }, + "expanded": { + "type": "boolean", + "default": "false", + "bindable": true, + "description": "Expanded state (bindable)" + }, + "subroutes": { + "type": "array", + "default": "[]", + "description": "Sub-navigation items" + }, + "badge": { + "type": "string|number", + "default": "null", + "description": "Badge text" + }, + "variant": { + "type": "'default'|'warning'|'danger'|'success'", + "default": "'default'", + "description": "Color variant" + } + }, + "snippets": [ + "leading", + "trailing", + "children" + ], + "consumers": [ + "dash", + "admin", + "miozu" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "LeftBarGroup", + "category": "navigation", + "path": "src/components/navigation/LeftBarGroup.svelte", + "description": "Dynamic item group with search and avatar support", + "props": { + "title": { + "type": "string", + "default": "''", + "description": "Group title" + }, + "items": { + "type": "array", + "default": "[]", + "description": "Group items" + }, + "expanded": { + "type": "boolean", + "default": "false", + "bindable": true, + "description": "Expanded state (bindable)" + }, + "searchable": { + "type": "boolean", + "default": "false", + "description": "Enable search filter" + }, + "showAdd": { + "type": "boolean", + "default": "true", + "description": "Show add button" + } + }, + "snippets": [ + "item", + "children" + ], + "consumers": [ + "dash" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "LeftBarToggle", + "category": "navigation", + "path": "src/components/navigation/LeftBarToggle.svelte", + "description": "Sidebar collapse toggle, reads LeftBar context", + "consumers": [ + "dash", + "admin" + ], + "stage": "stable", + "revision": 3, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard", + "props": { + "class": { + "type": "string", + "default": "''", + "description": "Additional CSS class names" + } + } + }, + { + "name": "DropdownContainer", + "category": "navigation", + "path": "src/components/navigation/DropdownContainer.svelte", + "description": "User account menu with theme toggle and sign out", + "props": { + "user": { + "type": "object", + "default": "null" + }, + "themeState": { + "type": "ThemeState", + "default": "null" + }, + "isCollapsed": { + "type": "boolean", + "default": "false" + } + }, + "snippets": [ + "userHeader", + "envSection", + "menuItems", + "footerContent", + "children" + ], + "consumers": [ + "admin" + ], + "stage": "stable", + "revision": 4, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "PageHeader", + "category": "layout", + "path": "src/components/layout/PageHeader.svelte", + "description": "Flexible page header with stats and action slots", + "props": { + "title": { + "type": "string", + "default": "''", + "description": "Page title" + }, + "description": { + "type": "string", + "default": "''", + "description": "Page description" + }, + "stats": { + "type": "array", + "default": "[]", + "description": "Stat items: {label, value, variant?}" + }, + "size": { + "type": "'default'|'compact'|'large'", + "default": "'default'", + "description": "Header size" + } + }, + "snippets": [ + "icon", + "actions", + "search", + "filters" + ], + "consumers": [ + "admin" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "SettingCard", + "category": "layout", + "path": "src/components/layout/SettingCard.svelte", + "description": "Settings group card", + "props": { + "title": { + "type": "string", + "default": "''", + "description": "Card title" + }, + "variant": { + "type": "'default'|'danger'", + "default": "'default'", + "description": "Card color variant" + } + }, + "snippets": [ + "children" + ], + "consumers": [ + "dash" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "SettingItem", + "category": "layout", + "path": "src/components/layout/SettingItem.svelte", + "description": "Individual setting row", + "props": { + "label": { + "type": "string", + "default": "''", + "description": "Setting label" + }, + "description": { + "type": "string", + "default": "''", + "description": "Setting description" + } + }, + "snippets": [ + "leading", + "action" + ], + "consumers": [ + "dash" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "BottomPanel", + "category": "layout", + "path": "src/components/layout/BottomPanel.svelte", + "description": "Slide-up panel with drag-to-resize", + "props": { + "open": { + "type": "boolean", + "default": "false", + "bindable": true, + "description": "Panel visibility (bindable)" + }, + "minimized": { + "type": "boolean", + "default": "false", + "bindable": true, + "description": "Minimized state (bindable)" + }, + "height": { + "type": "number", + "default": "280", + "bindable": true, + "description": "Panel height in px (bindable)" + }, + "minHeight": { + "type": "number", + "default": "120", + "description": "Minimum height" + }, + "resizable": { + "type": "boolean", + "default": "true", + "description": "Enable drag-to-resize" + }, + "offsetLeft": { + "type": "number", + "default": "0", + "description": "Left offset for sidebar clearance" + } + }, + "snippets": [ + "header", + "sidebar", + "children" + ], + "events": [ + "onresize" + ], + "consumers": [ + "dash", + "admin" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "CodeBlock", + "category": "docs", + "path": "src/components/docs/CodeBlock.svelte", + "description": "Syntax-highlighted code with Shiki and copy button", + "props": { + "code": { + "type": "string", + "default": "''", + "description": "Source code to highlight" + }, + "lang": { + "type": "string", + "default": "'javascript'", + "description": "Language for syntax highlighting" + }, + "filename": { + "type": "string", + "default": "''", + "description": "Filename shown in header bar" + }, + "showLineNumbers": { + "type": "boolean", + "default": "false", + "description": "Show line numbers via CSS counters" + } + }, + "consumers": [ + "admin", + "miozu" + ], + "stage": "stable", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "IconInput", + "category": "forms", + "path": "src/components/forms/IconInput.svelte", + "description": "Input with leading/trailing icon slots, loading spinner, and clear button", + "props": { + "value": { + "type": "string", + "default": "''", + "bindable": true, + "description": "Input value (bindable)" + }, + "type": { + "type": "string", + "default": "'text'", + "description": "HTML input type" + }, + "placeholder": { + "type": "string", + "default": "''", + "description": "Placeholder text" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable input" + }, + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Input size" + }, + "loading": { + "type": "boolean", + "default": "false", + "description": "Show spinner in left icon slot" + }, + "clearable": { + "type": "boolean", + "default": "false", + "description": "Show clear button when value present" + } + }, + "snippets": [ + "leftIcon", + "rightIcon" + ], + "events": [ + "oninput", + "onchange", + "onclear" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "NumberInput", + "category": "forms", + "path": "src/components/forms/NumberInput.svelte", + "description": "Stepper input with decrement/increment buttons and min/max enforcement", + "props": { + "value": { + "type": "number", + "default": "0", + "bindable": true, + "description": "Numeric value (bindable)" + }, + "min": { + "type": "number", + "default": "-Infinity", + "description": "Minimum allowed value" + }, + "max": { + "type": "number", + "default": "Infinity", + "description": "Maximum allowed value" + }, + "step": { + "type": "number", + "default": "1", + "description": "Increment/decrement step" + }, + "placeholder": { + "type": "string", + "default": "''", + "description": "Placeholder text" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable input" + }, + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Input size" + }, + "error": { + "type": "boolean", + "default": "false", + "description": "Show error state" + } + }, + "events": [ + "oninput", + "onchange" + ], + "stage": "beta", + "revision": 3, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Radio", + "category": "forms", + "path": "src/components/forms/Radio.svelte", + "description": "Single radio button with animated dot indicator (use inside RadioGroup)", + "props": { + "value": { + "type": "any", + "required": true, + "description": "Option value for this radio" + }, + "label": { + "type": "string", + "default": "''", + "description": "Radio label text" + }, + "description": { + "type": "string", + "default": "''", + "description": "Help text below label" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable this option" + } + }, + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "RadioGroup", + "category": "forms", + "path": "src/components/forms/RadioGroup.svelte", + "description": "Radio group container with context for child Radio components", + "props": { + "value": { + "type": "any", + "default": "null", + "bindable": true, + "description": "Selected value (bindable)" + }, + "name": { + "type": "string", + "default": "''", + "description": "Form field name" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable all radios" + }, + "orientation": { + "type": "'vertical'|'horizontal'", + "default": "'vertical'", + "description": "Layout direction" + } + }, + "snippets": [ + "children" + ], + "events": [ + "onchange" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "FileUpload", + "category": "forms", + "path": "src/components/forms/FileUpload.svelte", + "description": "Drag-and-drop upload zone with file list, size validation, and multi-file support", + "props": { + "accept": { + "type": "string", + "default": "'*'", + "description": "Accepted file types" + }, + "multiple": { + "type": "boolean", + "default": "false", + "description": "Allow multiple files" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable upload" + }, + "maxSize": { + "type": "number|null", + "default": "null", + "description": "Max file size in bytes" + }, + "label": { + "type": "string", + "default": "'Drop files here or click to upload'", + "description": "Zone label text" + }, + "hint": { + "type": "string", + "default": "''", + "description": "Hint text below label" + } + }, + "events": [ + "onchange", + "onerror" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "RangeSlider", + "category": "forms", + "path": "src/components/forms/RangeSlider.svelte", + "description": "Styled range input with fill bar, labels, and custom value formatting", + "props": { + "value": { + "type": "number", + "default": "50", + "bindable": true, + "description": "Slider value (bindable)" + }, + "min": { + "type": "number", + "default": "0", + "description": "Minimum value" + }, + "max": { + "type": "number", + "default": "100", + "description": "Maximum value" + }, + "step": { + "type": "number", + "default": "1", + "description": "Step increment" + }, + "label": { + "type": "string", + "default": "''", + "description": "Label above slider" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable slider" + }, + "showValues": { + "type": "boolean", + "default": "true", + "description": "Show min/max labels" + }, + "showCurrentValue": { + "type": "boolean", + "default": "true", + "description": "Show current value" + }, + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Slider size" + } + }, + "events": [ + "oninput", + "onchange" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "PinInput", + "category": "forms", + "path": "src/components/forms/PinInput.svelte", + "description": "OTP/PIN code entry with per-digit inputs, auto-advance, and paste support", + "props": { + "value": { + "type": "string", + "default": "''", + "bindable": true, + "description": "PIN value (bindable)" + }, + "length": { + "type": "number", + "default": "4", + "description": "Number of digits" + }, + "masked": { + "type": "boolean", + "default": "false", + "description": "Mask input as password" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable input" + }, + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Input size" + } + }, + "events": [ + "oncomplete" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Dropzone", + "category": "forms", + "path": "src/components/forms/Dropzone.svelte", + "description": "Single-file drag-and-drop zone with file preview and type validation", + "props": { + "accept": { + "type": "string", + "default": "'*/*'", + "description": "Accepted MIME types" + }, + "placeholder": { + "type": "string", + "default": "'Drag & drop a file here, or click to upload'", + "description": "Zone placeholder text" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable upload" + } + }, + "events": [ + "ondrop" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Tooltip", + "category": "primitives", + "path": "src/components/primitives/Tooltip.svelte", + "description": "Hover/focus tooltip with arrow pointer, delay, and optional rich content snippet", + "props": { + "text": { + "type": "string", + "default": "''", + "description": "Tooltip text" + }, + "position": { + "type": "'top'|'bottom'|'left'|'right'", + "default": "'top'", + "description": "Tooltip position" + }, + "delay": { + "type": "number", + "default": "50", + "description": "Show delay in ms" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable tooltip" + } + }, + "snippets": [ + "children", + "content" + ], + "stage": "beta", + "revision": 4, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Card", + "category": "primitives", + "path": "src/components/primitives/Card.svelte", + "description": "Bordered container with optional title and semantic color variants", + "props": { + "title": { + "type": "string", + "default": "''", + "description": "Card title (supports HTML)" + }, + "variant": { + "type": "'default'|'danger'|'warning'|'success'", + "default": "'default'", + "description": "Color variant" + } + }, + "snippets": [ + "children" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Link", + "category": "primitives", + "path": "src/components/primitives/Link.svelte", + "description": "Polymorphic link/button with arrow icon that shifts on hover", + "props": { + "href": { + "type": "string|null", + "default": "null", + "description": "URL (renders as when set)" + }, + "external": { + "type": "boolean", + "default": "false", + "description": "Open in new tab" + }, + "showIcon": { + "type": "boolean", + "default": "true", + "description": "Show arrow icon" + } + }, + "snippets": [ + "children", + "icon" + ], + "events": [ + "onclick" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "LinkCard", + "category": "primitives", + "path": "src/components/primitives/LinkCard.svelte", + "description": "Full-width clickable card rendered as anchor with label and trailing slot", + "props": { + "href": { + "type": "string", + "required": true, + "description": "Navigation URL" + }, + "label": { + "type": "string", + "required": true, + "description": "Card label text" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable interaction" + } + }, + "snippets": [ + "trailing" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "LazyImage", + "category": "primitives", + "path": "src/components/primitives/LazyImage.svelte", + "description": "Image with IntersectionObserver lazy loading and fade-in animation", + "props": { + "src": { + "type": "string", + "required": true, + "description": "Image source URL" + }, + "alt": { + "type": "string", + "default": "''", + "description": "Alt text" + }, + "width": { + "type": "number|string", + "default": "undefined", + "description": "Image width" + }, + "height": { + "type": "number|string", + "default": "undefined", + "description": "Image height" + }, + "threshold": { + "type": "number", + "default": "0.01", + "description": "IntersectionObserver threshold" + }, + "placeholder": { + "type": "string|null", + "default": "null", + "description": "Placeholder image src" + } + }, + "events": [ + "onload" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "MetricCard", + "category": "primitives", + "path": "src/components/primitives/MetricCard.svelte", + "description": "Dashboard KPI card with icon, value, unit, progress bar, and auto-colored thresholds", + "props": { + "label": { + "type": "string", + "default": "''", + "description": "Metric label" + }, + "value": { + "type": "any", + "default": "0", + "description": "Metric value" + }, + "unit": { + "type": "string", + "default": "''", + "description": "Value unit suffix" + }, + "sublabel": { + "type": "string", + "default": "''", + "description": "Secondary label" + }, + "status": { + "type": "''|'success'|'warning'|'error'", + "default": "''", + "description": "Status color" + }, + "progress": { + "type": "number|null", + "default": "null", + "description": "Progress bar value (0-100)" + }, + "href": { + "type": "string|null", + "default": "null", + "description": "Makes card clickable link" + }, + "subtitle": { + "type": "string", + "default": "''", + "description": "Secondary label (alias for sublabel, takes priority)" + }, + "trend": { + "type": "string", + "default": "''", + "description": "Trend indicator (e.g. '+12%', '-3%'). Auto-colors: + green, - red, else muted" + }, + "size": { + "type": "'sm'|'md'", + "default": "'md'", + "description": "Card density. 'sm' removes background, reduces padding, and uses lighter borders" + } + }, + "snippets": [ + "icon", + "badge" + ], + "events": [ + "onclick" + ], + "stage": "beta", + "revision": 4, + "lastReviewed": "2026-03-29", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "FilterChip", + "category": "primitives", + "path": "src/components/primitives/FilterChip.svelte", + "description": "Toggleable filter chip with active state indicator", + "stage": "beta", + "revision": 3, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard", + "props": { + "label": { + "type": "string", + "default": "''", + "description": "Chip label text" + }, + "active": { + "type": "boolean", + "default": "false", + "description": "Active/selected state" + }, + "count": { + "type": "number|null", + "default": "null", + "description": "Optional count badge" + }, + "variant": { + "type": "'default'|'error'|'warning'|'success'|'info'", + "default": "'default'", + "description": "Active state color variant" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable interaction" + } + }, + "snippets": [ + "icon" + ], + "events": [ + "onclick" + ] + }, + { + "name": "StatusLine", + "category": "primitives", + "path": "src/components/primitives/StatusLine.svelte", + "description": "Horizontal row of items separated by configurable delimiter", + "props": { + "separator": { + "type": "string", + "default": "'\\u00b7'", + "description": "Separator character" + }, + "size": { + "type": "'sm'|'md'|'lg'", + "default": "'md'", + "description": "Text size" + }, + "items": { + "type": "array", + "default": "[]", + "description": "Array of strings or {text, icon} objects" + } + }, + "snippets": [ + "children" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "MemberCard", + "category": "primitives", + "path": "src/components/primitives/MemberCard.svelte", + "description": "Team member row with avatar, status dot, role, timestamp, and hover actions", + "props": { + "name": { + "type": "string", + "default": "''", + "description": "Member name" + }, + "email": { + "type": "string", + "default": "''", + "description": "Email address" + }, + "role": { + "type": "string", + "default": "''", + "description": "Role label" + }, + "avatarUrl": { + "type": "string|null", + "default": "null", + "description": "Avatar image URL" + }, + "status": { + "type": "null|'online'|'offline'|'away'|'busy'", + "default": "null", + "description": "Online status indicator" + }, + "timestamp": { + "type": "string|null", + "default": "null", + "description": "ISO date for relative time" + }, + "variant": { + "type": "'default'|'compact'", + "default": "'default'", + "description": "Display density" + } + }, + "snippets": [ + "badge", + "actions" + ], + "events": [ + "onclick" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "DashCard", + "category": "primitives", + "path": "src/components/primitives/DashCard.svelte", + "description": "Glassmorphism dashboard section card with stagger-fade animation on mount", + "props": { + "title": { + "type": "string", + "default": "''", + "description": "Card title" + }, + "href": { + "type": "string", + "default": "''", + "description": "Header link URL" + }, + "index": { + "type": "number", + "default": "0", + "description": "Stagger animation delay multiplier" + }, + "variant": { + "type": "'default'|'accent'", + "default": "'default'", + "description": "Card style" + }, + "subtitle": { + "type": "string", + "default": "''", + "description": "Subtitle text below the card title" + } + }, + "snippets": [ + "icon", + "linkLabel", + "children", + "actions" + ], + "stage": "beta", + "revision": 3, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "DistributionBar", + "category": "primitives", + "path": "src/components/primitives/DistributionBar.svelte", + "description": "Horizontal segmented bar with auto-calculated percentages and optional legend", + "props": { + "entries": { + "type": "array", + "default": "[]", + "description": "Array of {label, value, color?}" + }, + "height": { + "type": "'sm'|'md'", + "default": "'md'", + "description": "Bar height (4px or 6px)" + }, + "showLegend": { + "type": "boolean|'hover'", + "default": "true", + "description": "Show color legend below bar. 'hover' shows legend on parent hover via absolute positioning" + } + }, + "stage": "beta", + "revision": 3, + "lastReviewed": "2026-03-29", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "ChecklistCard", + "category": "primitives", + "path": "src/components/primitives/ChecklistCard.svelte", + "description": "Sequential onboarding checklist. Shows steps with done/current/future states. Auto-hides when all steps complete.", + "props": { + "title": { + "type": "string", + "default": "'Quick setup'", + "description": "Card heading" + }, + "steps": { + "type": "Array<{id: string, label: string, done: boolean, href?: string, onclick?: function, actionLabel?: string}>", + "default": "[]", + "description": "Ordered step definitions" + } + }, + "snippets": [], + "events": [], + "stage": "beta", + "revision": 1, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "minimal" + }, + { + "name": "Dropdown", + "category": "overlays", + "path": "src/components/overlays/Dropdown.svelte", + "description": "Portaled action menu with viewport-aware positioning, keyboard navigation, and ARIA", + "props": { + "open": { + "type": "boolean", + "default": "false", + "bindable": true, + "description": "Menu visibility (bindable)" + }, + "position": { + "type": "'bottom-start'|'bottom-end'|'bottom-center'|'top-start'|'top-end'|'top-center'", + "default": "'bottom-start'", + "description": "Preferred menu position (auto-flips if near viewport edge)" + } + }, + "snippets": [ + "trigger", + "children" + ], + "stage": "beta", + "revision": 5, + "lastReviewed": "2026-03-28", + "breaking": [ + "v2: Content portaled to body via fixed positioning instead of relative. Consumers using :global(.dropdown-content) position overrides should remove them." + ], + "docLevel": "standard" + }, + { + "name": "DropdownItem", + "category": "overlays", + "path": "src/components/overlays/DropdownItem.svelte", + "description": "Menu item with role=menuitem, keyboard support, icon and danger variant", + "props": { + "variant": { + "type": "'default'|'danger'", + "default": "'default'", + "description": "Item style" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable item" + } + }, + "snippets": [ + "icon", + "children" + ], + "events": [ + "onclick" + ], + "stage": "beta", + "revision": 3, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "DropdownDivider", + "category": "overlays", + "path": "src/components/overlays/DropdownDivider.svelte", + "description": "Horizontal separator line for dropdown menu groups", + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "PropsTable", + "category": "docs", + "path": "src/components/docs/PropsTable.svelte", + "description": "Styled API documentation table with type chips and default value highlights", + "props": { + "props": { + "type": "array", + "default": "[]", + "description": "Array of {name, type, default?, description, required?}" + } + }, + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "SplitPane", + "category": "docs", + "path": "src/components/docs/SplitPane.svelte", + "description": "Two-column layout with configurable ratio, responsive stacking, and optional sticky right pane", + "props": { + "ratio": { + "type": "string", + "default": "'1:1'", + "description": "Column width ratio (e.g. '2:1')" + }, + "gap": { + "type": "string", + "default": "'2rem'", + "description": "Gap between columns" + }, + "stickyRight": { + "type": "boolean", + "default": "false", + "description": "Stick right pane on scroll" + } + }, + "snippets": [ + "left", + "right" + ], + "stage": "beta", + "revision": 3, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "DocSection", + "category": "docs", + "path": "src/components/docs/DocSection.svelte", + "description": "Documentation section with heading, anchor link, and configurable heading level", + "props": { + "id": { + "type": "string", + "default": "''", + "description": "Section anchor id" + }, + "title": { + "type": "string", + "default": "''", + "description": "Section heading text" + }, + "description": { + "type": "string", + "default": "''", + "description": "Description paragraph" + }, + "level": { + "type": "number", + "default": "2", + "description": "Heading level (2-6)" + }, + "showAnchor": { + "type": "boolean", + "default": "true", + "description": "Show hover anchor link" + } + }, + "snippets": [ + "children" + ], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-05", + "breaking": [], + "docLevel": "standard" + }, + { + "name": "Sidebar", + "category": "navigation", + "path": "src/components/navigation/Sidebar.svelte", + "description": "Legacy sidebar — replaced by LeftBar", + "replacedBy": "LeftBar", + "stage": "deprecated", + "revision": 3, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "none" + }, + { + "name": "NavigationContainer", + "category": "navigation", + "path": "src/components/navigation/NavigationContainer.svelte", + "description": "Legacy enterprise nav — unused", + "stage": "deprecated", + "revision": 2, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "none" + }, + { + "name": "TabNav", + "category": "navigation", + "path": "src/components/navigation/TabNav.svelte", + "description": "Legacy tab nav — use Tabs instead", + "replacedBy": "Tabs", + "stage": "deprecated", + "revision": 3, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "none" + }, + { + "name": "ScrollNav", + "category": "navigation", + "path": "src/components/navigation/ScrollNav.svelte", + "description": "Legacy scroll wrapper — unused", + "stage": "deprecated", + "revision": 2, + "lastReviewed": "2026-03-28", + "breaking": [], + "docLevel": "none" + }, + { + "name": "WorkspaceMenu", + "category": "navigation", + "path": "src/components/navigation/WorkspaceMenu.svelte", + "description": "Legacy workspace switcher — unused", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "breaking": [], + "docLevel": "none" + }, + { + "name": "SidebarSection", + "category": "navigation", + "path": "src/components/navigation/SidebarSection.svelte", + "description": "Legacy sidebar section — replaced by LeftBarSection", + "replacedBy": "LeftBarSection", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "SidebarItem", + "category": "navigation", + "path": "src/components/navigation/SidebarItem.svelte", + "description": "Legacy sidebar item — replaced by LeftBarItem", + "replacedBy": "LeftBarItem", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "SidebarGroup", + "category": "navigation", + "path": "src/components/navigation/SidebarGroup.svelte", + "description": "Legacy sidebar group — replaced by LeftBarGroup", + "replacedBy": "LeftBarGroup", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "SidebarPopover", + "category": "navigation", + "path": "src/components/navigation/SidebarPopover.svelte", + "description": "Legacy sidebar popover — unused", + "replacedBy": "LeftBarPopover", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "SidebarToggle", + "category": "navigation", + "path": "src/components/navigation/SidebarToggle.svelte", + "description": "Legacy sidebar toggle — replaced by LeftBarToggle", + "replacedBy": "LeftBarToggle", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "SidebarSearch", + "category": "navigation", + "path": "src/components/navigation/SidebarSearch.svelte", + "description": "Legacy sidebar search — unused", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "SidebarGroupSwitcher", + "category": "navigation", + "path": "src/components/navigation/SidebarGroupSwitcher.svelte", + "description": "Legacy sidebar group switcher — unused", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "SidebarAccountGroup", + "category": "navigation", + "path": "src/components/navigation/SidebarAccountGroup.svelte", + "description": "Legacy sidebar account group — unused", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "SidebarAccountItem", + "category": "navigation", + "path": "src/components/navigation/SidebarAccountItem.svelte", + "description": "Legacy sidebar account item — unused", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "SidebarNavigationItem", + "category": "navigation", + "path": "src/components/navigation/SidebarNavigationItem.svelte", + "description": "Legacy sidebar navigation item — unused", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "LeftBarPopover", + "category": "navigation", + "path": "src/components/navigation/LeftBarPopover.svelte", + "description": "LeftBar context-aware popover — internal", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "ChipNav", + "category": "navigation", + "path": "src/components/navigation/ChipNav.svelte", + "description": "Internal chip navigation — used inside NavBar", + "stage": "deprecated", + "revision": 2, + "lastReviewed": "2026-03-28", + "docLevel": "none", + "breaking": [] + }, + { + "name": "NavigationSearch", + "category": "navigation", + "path": "src/components/navigation/blocks/NavigationSearch.svelte", + "description": "Legacy enterprise nav search block — unused", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "NavigationSection", + "category": "navigation", + "path": "src/components/navigation/blocks/NavigationSection.svelte", + "description": "Legacy enterprise nav section block — unused", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "NavigationAccountGroup", + "category": "navigation", + "path": "src/components/navigation/blocks/NavigationAccountGroup.svelte", + "description": "Legacy enterprise nav account block — unused", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "NavigationGroupSwitcher", + "category": "navigation", + "path": "src/components/navigation/blocks/NavigationGroupSwitcher.svelte", + "description": "Legacy enterprise nav group switcher — unused", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "NavigationCustomBlock", + "category": "navigation", + "path": "src/components/navigation/blocks/NavigationCustomBlock.svelte", + "description": "Legacy enterprise nav custom block — unused", + "stage": "deprecated", + "revision": 1, + "lastReviewed": "2026-02-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "ImageDropzone", + "category": "forms", + "path": "src/components/forms/ImageDropzone.svelte", + "description": "Drag-and-drop image upload with preview grid and file validation", + "props": { + "files": { + "type": "Array<{file, preview, name, size}>", + "default": "[]", + "bindable": true, + "description": "Uploaded files" + }, + "accept": { + "type": "string", + "default": "'image/*'", + "description": "Accepted MIME types" + }, + "multiple": { + "type": "boolean", + "default": "true", + "description": "Allow multiple files" + }, + "maxFiles": { + "type": "number", + "default": "10", + "description": "Maximum file count" + }, + "minFiles": { + "type": "number", + "default": "0", + "description": "Minimum required file count for status indicator" + }, + "maxSize": { + "type": "number", + "default": "10485760", + "description": "Max file size in bytes (10MB)" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable upload" + }, + "error": { + "type": "string", + "default": "''", + "description": "Error message" + }, + "previews": { + "type": "boolean", + "default": "true", + "description": "Show image thumbnails" + }, + "showNumbers": { + "type": "boolean", + "default": "false", + "description": "Show index numbers on preview thumbnails" + }, + "onchange": { + "type": "function", + "default": "null", + "description": "Files changed callback" + }, + "onremove": { + "type": "function", + "default": "null", + "description": "File removed callback (index)" + } + }, + "stage": "beta", + "revision": 3, + "lastReviewed": "2026-03-22", + "docLevel": "none", + "breaking": [] + }, + { + "name": "ButtonInput", + "category": "forms", + "path": "src/components/forms/ButtonInput.svelte", + "description": "Input with attached button for search, submit, or inline actions", + "props": { + "value": { + "type": "string", + "default": "''", + "bindable": true, + "description": "Input value" + }, + "ref": { + "type": "HTMLInputElement", + "default": "undefined", + "bindable": true, + "description": "Input element reference" + }, + "buttonLabel": { + "type": "string", + "default": "'Submit'", + "description": "Button text (overridden by children snippet)" + }, + "buttonVariant": { + "type": "string", + "default": "'primary'", + "description": "Button variant" + }, + "buttonSize": { + "type": "string", + "default": "'md'", + "description": "Button size" + }, + "buttonType": { + "type": "string", + "default": "'button'", + "description": "Button type attribute (button|submit|reset)" + }, + "buttonPosition": { + "type": "'left'|'right'", + "default": "'right'", + "description": "Button placement" + }, + "buttonIcon": { + "type": "{icon, position}|null", + "default": "null", + "description": "Icon in button: {icon: Component, position: 'left'|'right'}" + }, + "buttonDisabled": { + "type": "boolean", + "default": "false", + "description": "Disable button only" + }, + "buttonLoading": { + "type": "boolean", + "default": "false", + "description": "Loading state for button only" + }, + "inputDisabled": { + "type": "boolean", + "default": "false", + "description": "Disable input only" + }, + "inputIcon": { + "type": "{icon, position}|null", + "default": "null", + "description": "Icon overlay in input: {icon: Component, position: 'right'}" + }, + "loading": { + "type": "boolean", + "default": "false", + "description": "Loading state (disables both)" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable both input and button" + }, + "onsubmit": { + "type": "function", + "default": "null", + "description": "Submit handler (value) => void, fires on click and Enter" + }, + "wrapperClass": { + "type": "string", + "default": "''", + "description": "CSS class for the outer wrapper div" + }, + "class": { + "type": "string", + "default": "''", + "description": "Additional CSS class for the root element" + } + }, + "snippets": [ + "children" + ], + "stage": "stable", + "revision": 3, + "lastReviewed": "2026-03-28", + "docLevel": "minimal", + "breaking": [] + }, + { + "name": "DatePicker", + "category": "forms", + "path": "src/components/forms/DatePicker.svelte", + "description": "Calendar date picker with month navigation, min/max constraints. Zero dependencies — uses native Date + Intl.DateTimeFormat", + "props": { + "value": { + "type": "string|null", + "default": "null", + "bindable": true, + "description": "Selected date (ISO YYYY-MM-DD)" + }, + "min": { + "type": "string|null", + "default": "null", + "description": "Earliest selectable date (ISO)" + }, + "max": { + "type": "string|null", + "default": "null", + "description": "Latest selectable date (ISO)" + }, + "placeholder": { + "type": "string", + "default": "'Select date'", + "description": "Trigger placeholder" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable picker" + }, + "required": { + "type": "boolean", + "default": "false", + "description": "Required field" + }, + "id": { + "type": "string", + "default": "null", + "description": "Hidden input id" + }, + "name": { + "type": "string", + "default": "null", + "description": "Hidden input name" + }, + "onchange": { + "type": "function", + "default": "null", + "description": "Date change callback" + } + }, + "dependencies": [], + "stage": "draft", + "revision": 2, + "lastReviewed": "2026-03-28", + "docLevel": "none", + "breaking": [] + }, + { + "name": "TagInput", + "category": "forms", + "path": "src/components/forms/TagInput.svelte", + "description": "Tag/chip input with add/remove, deduplication, and variant colors", + "props": { + "tags": { + "type": "string[]", + "default": "[]", + "bindable": true, + "description": "Array of tag strings" + }, + "placeholder": { + "type": "string", + "default": "'Add tag...'", + "description": "Input placeholder" + }, + "variant": { + "type": "'default'|'accent'|'success'|'warning'|'error'", + "default": "'default'", + "description": "Chip color variant" + }, + "maxTags": { + "type": "number", + "default": "Infinity", + "description": "Maximum number of tags" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable input" + }, + "duplicates": { + "type": "boolean", + "default": "false", + "description": "Allow duplicate tags" + }, + "transform": { + "type": "function|null", + "default": "null", + "description": "Transform function applied to tag value before adding" + }, + "onchange": { + "type": "function|null", + "default": "null", + "description": "Called with updated tags array" + } + }, + "dependencies": [], + "stage": "draft", + "revision": 1, + "lastReviewed": "2026-03-07", + "docLevel": "none", + "breaking": [] + }, + { + "name": "OptionCard", + "category": "forms", + "path": "src/components/forms/OptionCard.svelte", + "description": "Selectable option card for multi-choice layouts", + "props": { + "selected": { + "type": "boolean", + "default": "false", + "description": "Whether the option is selected" + }, + "disabled": { + "type": "boolean", + "default": "false", + "description": "Disable the option" + }, + "variant": { + "type": "'default'|'compact'", + "default": "'default'", + "description": "Size variant" + }, + "onclick": { + "type": "function|null", + "default": "null", + "description": "Click handler" + } + }, + "snippets": [ + "children" + ], + "dependencies": [], + "stage": "beta", + "revision": 2, + "lastReviewed": "2026-03-22", + "docLevel": "none", + "breaking": [] + } + ], + "utilities": [ + { + "name": "cn", + "description": "Class name concatenation with falsy filtering", + "stage": "stable" + }, + { + "name": "cv", + "description": "Class variants for type-safe variant composition", + "stage": "stable" + }, + { + "name": "getTheme", + "description": "SSR-safe theme singleton", + "stage": "stable" + }, + { + "name": "createActiveChecker", + "description": "Route matching utility", + "stage": "stable" + } + ], + "actions": [ + { + "name": "clickOutside", + "description": "Detect clicks outside element", + "stage": "stable" + }, + { + "name": "focusTrap", + "description": "Trap focus within element", + "stage": "stable" + }, + { + "name": "escapeKey", + "description": "Handle Escape key press", + "stage": "stable" + }, + { + "name": "portal", + "description": "Render in different DOM location", + "stage": "stable" + } + ] +} \ No newline at end of file diff --git a/qa/global-setup.ts b/qa/global-setup.ts new file mode 100644 index 0000000..78c70bf --- /dev/null +++ b/qa/global-setup.ts @@ -0,0 +1,66 @@ +import { chromium, FullConfig } from '@playwright/test'; +import path from 'path'; +import fs from 'fs'; +import { execSync } from 'child_process'; + +export default async function globalSetup(_config: FullConfig) { + const EMAIL = process.env.SELIFY_EMAIL; + const PASSWORD = process.env.SELIFY_PASSWORD; + + if (!EMAIL || !PASSWORD) { + throw new Error('SELIFY_EMAIL / SELIFY_PASSWORD not set. Run: source load-credentials.sh'); + } + + const AUTH_DIR = path.join(__dirname, '.auth'); + const AUTH_FILE = path.join(AUTH_DIR, 'state.json'); + fs.mkdirSync(AUTH_DIR, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const baseURL = process.env.STORYBOOK_URL || 'https://admin.selify.ai'; + + await page.goto(baseURL, { waitUntil: 'networkidle', timeout: 30000 }); + + const url = page.url(); + if (!url.includes('/auth') && !url.includes('/login') && !url.includes('/sign')) { + console.log('[global-setup] Already authenticated'); + await page.context().storageState({ path: AUTH_FILE }); + await browser.close(); + return; + } + + console.log('[global-setup] Logging in...'); + const emailField = page.locator('input[type="email"], input[name="email"]').first(); + await emailField.waitFor({ state: 'visible', timeout: 10000 }); + await emailField.fill(EMAIL); + await page.waitForTimeout(300); + + const passwordField = page.locator('input[type="password"]').first(); + let passwordVisible = await passwordField.isVisible().catch(() => false); + if (!passwordVisible) { + const pwdBtn = page.locator('button:has-text("Sign in with password")').first(); + if (await pwdBtn.isVisible().catch(() => false)) { + await pwdBtn.click(); + await page.waitForTimeout(800); + } + passwordVisible = await passwordField.isVisible().catch(() => false); + } + + if (passwordVisible) { + await passwordField.fill(PASSWORD); + await page.waitForTimeout(300); + await passwordField.press('Enter'); + } + + await page.waitForTimeout(4000); + await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {}); + + const finalUrl = page.url(); + if (finalUrl.includes('/auth') || finalUrl.includes('/login')) { + throw new Error(`Login failed — still on auth page: ${finalUrl}`); + } + + console.log('[global-setup] Login successful — saving auth state'); + await page.context().storageState({ path: AUTH_FILE }); + await browser.close(); +} diff --git a/qa/layers/layer1-accessibility.js b/qa/layers/layer1-accessibility.js new file mode 100644 index 0000000..2dad256 --- /dev/null +++ b/qa/layers/layer1-accessibility.js @@ -0,0 +1,313 @@ +/* + layer1-accessibility.js + ───────────────────────────────────────────────────────────────────────────── + Layer 1 — Accessibility + Extracted from component-test-runner.js for modular use. + + Runs per variant × mode: + 1. axe-core scan (wcag2a, wcag2aa, wcag21aa) + 2. Canvas pixel sampling contrast check (catches oklch/color(srgb)) + 3. Element-scoped screenshot — ONLY when a violation is found + + Returns: + { + violations: [...], // deduplicated after all variants run + screenshots: [...], // paths captured (only for failing variants) + passed: [...], // "variant-mode" strings + failed: [...], // "variant-mode" strings + summary: { s1, s2, s3, s4, total } + } + + Skills: + axe-core.md · contrast-ratio.md · screenshot-capture.md + ───────────────────────────────────────────────────────────────────────────── +*/ + +'use strict'; + +const { AxeBuilder } = require('@axe-core/playwright'); +const path = require('path'); +const fs = require('fs'); + +// ── Severity helpers ──────────────────────────────────────────────────────── + +function axeImpactToSeverity(impact) { + return { critical: 'S1', serious: 'S2', moderate: 'S3', minor: 'S4' }[impact] ?? 'S4'; +} + +function extractWcagCriteria(tags) { + return tags + .filter(t => /^wcag\d/.test(t)) + .map(t => { + const digits = t.replace('wcag', ''); + if (/^\d{3}$/.test(digits)) return digits.replace(/(\d)(\d)(\d)/, '$1.$2.$3'); + return null; + }) + .filter(Boolean) + .join(', '); +} + +// ── Screenshot capture ────────────────────────────────────────────────────── + +async function captureElement(page, selector, outputPath, padding = 24) { + const element = await page.$(selector); + if (!element) throw new Error(`Element not found: ${selector}`); + const box = await element.boundingBox(); + if (!box) throw new Error(`Cannot get bounding box: ${selector}`); + await page.screenshot({ + path: outputPath, + clip: { + x: Math.max(0, box.x - padding), + y: Math.max(0, box.y - padding), + width: box.width + padding * 2, + height: box.height + padding * 2, + }, + }); +} + +// ── axe-core scan ─────────────────────────────────────────────────────────── + +async function runAxeScan(page, selector = '.preview-area') { + const builder = new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']); + // Only include selector if it actually exists on the page + const exists = await page.$(selector).then(el => !!el).catch(() => false); + if (exists) builder.include(selector); + const results = await builder.analyze(); + return results.violations; +} + +// ── Canvas pixel sampling contrast check ──────────────────────────────────── + +async function runContrastCheck(page, variant, mode, screenshotName, selector = '.preview-area', component = '') { + const violations = await page.evaluate(([sel, comp]) => { + function colorToRgba(cssColor) { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = cssColor; + ctx.fillRect(0, 0, 1, 1); + const d = ctx.getImageData(0, 0, 1, 1).data; + return [d[0], d[1], d[2], d[3] / 255]; + } + + function alphaComposite(fg, fgAlpha, bg) { + return fg.map((c, i) => Math.round(c * fgAlpha + bg[i] * (1 - fgAlpha))); + } + + function luminance([r, g, b]) { + return [r, g, b] + .map(c => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }) + .reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0); + } + + function contrastRatio(l1, l2) { + const [light, dark] = l1 > l2 ? [l1, l2] : [l2, l1]; + return (light + 0.05) / (dark + 0.05); + } + + const preview = document.querySelector(sel); + if (!preview) return []; + + const el = (comp ? preview.querySelector(`[class*=jera-${comp}]`) : null) + ?? preview.querySelector('[class*=jera-]') + ?? preview.firstElementChild; + if (!el) return []; + + const styles = getComputedStyle(el); + const fgRgba = colorToRgba(styles.color); + const bgRgba = colorToRgba(styles.backgroundColor); + let fg = fgRgba.slice(0, 3); + let bg = bgRgba.slice(0, 3); + const bgAlpha = bgRgba[3]; + + if (bgAlpha < 0.99) { + const pageBgRgba = colorToRgba(getComputedStyle(document.body).backgroundColor); + bg = alphaComposite(bg, bgAlpha, pageBgRgba.slice(0, 3)); + } + + const ratio = Math.round(contrastRatio(luminance(fg), luminance(bg)) * 100) / 100; + const fontSize = parseFloat(styles.fontSize) || 0; + const fontWeight = parseInt(styles.fontWeight) || 400; + const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700); + const threshold = isLargeText ? 3.0 : 4.5; + + if (ratio >= threshold) return []; + + return [{ + ratio, + threshold, + fg: `rgb(${fg})`, + bg: `rgb(${bg})`, + fontSize: `${fontSize}px`, + fontWeight, + element: el.outerHTML.slice(0, 200), + }]; + }, [selector, component]); + + if (!violations.length) return []; + const v = violations[0]; + return [{ + variant, + mode, + axe_id: 'color-contrast-manual', + impact: 'serious', + severity: 'S2', + wcag: '1.4.3', + contrast_ratio: v.ratio, + required_ratio: v.threshold, + fg: v.fg, + bg: v.bg, + font_size: v.fontSize, + element: v.element, + fix: `Contrast ratio ${v.ratio}:1 below WCAG AA minimum of ${v.threshold}:1`, + screenshot: screenshotName, + }]; +} + +// ── Layer 1 main export ───────────────────────────────────────────────────── + +/** + * Run accessibility layer for a single variant + mode. + * Page must already be on the component URL with the correct variant selected. + * + * @param {object} opts + * @param {import('playwright').Page} opts.page + * @param {string} opts.component - component name (e.g. "badge") + * @param {string} opts.variant - variant name (e.g. "warning") + * @param {string} opts.mode - "light" | "dark" + * @param {string} opts.screenshotsDir - absolute path to screenshots folder + * @returns {Promise} + */ +async function runLayer1(opts) { + const { page, component, variant, mode, screenshotsDir, selector = '.preview-area' } = opts; + + fs.mkdirSync(screenshotsDir, { recursive: true }); + + // Screenshot name is defined upfront — captured only when violations are found + const screenshotName = `${component}-${variant}-${mode}-l1-violation.png`; + const screenshotPath = path.join(screenshotsDir, screenshotName); + + // axe scan + const axeViolations = await runAxeScan(page, selector).catch(err => { + console.warn(` [L1] axe scan failed: ${err.message}`); + return []; + }); + + // Contrast check + const contrastViolations = await runContrastCheck(page, variant, mode, screenshotName, selector, component).catch(err => { + console.warn(` [L1] Contrast check failed: ${err.message}`); + return []; + }); + + const violations = []; + + for (const v of axeViolations) { + const severity = axeImpactToSeverity(v.impact); + const wcag = extractWcagCriteria(v.tags); + for (const node of v.nodes) { + violations.push({ + layer: 1, + variant, mode, + axe_id: v.id, + impact: v.impact, + severity, + wcag: wcag || 'N/A', + element: node.html?.slice(0, 200), + fix: node.failureSummary?.slice(0, 300), + screenshot: screenshotName, + }); + } + } + + for (const v of contrastViolations) { + violations.push({ layer: 1, ...v }); + } + + // Screenshot only when violations were found + let screenshotCaptured = false; + if (violations.length > 0) { + await captureElement(page, selector, screenshotPath, 16) + .then(() => { screenshotCaptured = true; }) + .catch(err => console.warn(` [L1] Screenshot failed: ${err.message}`)); + + if (!screenshotCaptured) { + for (const v of violations) delete v.screenshot; + } + } + + const passed = violations.length === 0; + const key = `${variant}-${mode}`; + + return { + key, + violations, + screenshot: screenshotCaptured ? screenshotPath : null, + passed, + }; +} + +/** + * Run Layer 1 across all variants × modes. + * Page must be on the component URL; caller manages variant/mode switching. + * + * @param {object} opts + * @param {import('playwright').Page} opts.page + * @param {string} opts.component + * @param {string[]} opts.variants + * @param {string[]} opts.modes - default ["light","dark"] + * @param {string} opts.screenshotsDir + * @param {Function} opts.applyVariant - async (page, variant) => void + * @param {Function} opts.ensureMode - async (page, mode) => void + * @returns {Promise} - layer1 block for unified result + */ +async function runLayer1All(opts) { + const { page, component, variants, modes = ['light', 'dark'], screenshotsDir, applyVariant, ensureMode, selector = '.preview-area' } = opts; + + const allViolations = []; + const screenshots = []; + const passed = []; + const failed = []; + + for (const mode of modes) { + await ensureMode(page, mode); + for (const variant of variants) { + await applyVariant(page, variant); + const r = await runLayer1({ page, component, variant, mode, screenshotsDir, selector }); + if (r.screenshot) screenshots.push(r.screenshot); + if (r.passed) { + passed.push(r.key); + } else { + failed.push(r.key); + allViolations.push(...r.violations); + } + } + } + + // Deduplicate violations by axe_id + element signature + const seen = new Map(); + for (const v of allViolations) { + const key = (v.axe_id || '') + '|' + (v.element || '').slice(0, 100); + if (!seen.has(key)) { + seen.set(key, { ...v, count: 1 }); + } else { + const existing = seen.get(key); + existing.count++; + existing.note = `deduplicated — same issue in ${existing.count} variant/mode combos`; + } + } + const deduped = Array.from(seen.values()); + + const summary = { total: 0, s1: 0, s2: 0, s3: 0, s4: 0 }; + for (const v of deduped) { + const sev = (v.severity || 'S4').toLowerCase(); + summary[sev] = (summary[sev] || 0) + 1; + summary.total++; + } + + return { violations: deduped, screenshots, passed, failed, summary }; +} + +module.exports = { runLayer1, runLayer1All }; diff --git a/qa/layers/layer2-interaction.js b/qa/layers/layer2-interaction.js new file mode 100644 index 0000000..7aa2d71 --- /dev/null +++ b/qa/layers/layer2-interaction.js @@ -0,0 +1,741 @@ +/* + layer2-interaction.js + ───────────────────────────────────────────────────────────────────────────── + Layer 2 — Interaction Testing + Tests keyboard, focus, click, hover, and disabled behavior for each variant. + + Checks per variant: + 1. Tab key → component receives focus? + 2. Focus visible → outline/ring visible? (CSS outline-width > 0) + 3. Enter/Space → click handler fires? (click event dispatched) + 4. Escape → closes if applicable (dialog/dropdown pattern) + 5. Click → works without error? + 6. Hover → state changes (class added, style mutated)? + 7. Disabled → ignores all interactions? + + Non-interactive components are automatically marked N/A. + + Returns per variant: + { + variant, + interactive: true|false, + tab_focus: "pass"|"fail"|"n/a", + focus_visible: "pass"|"fail"|"n/a", + enter_activates: "pass"|"fail"|"n/a", + escape_closes: "pass"|"fail"|"n/a"|"not-applicable", + click_works: "pass"|"fail"|"n/a", + hover_state: "pass"|"fail"|"n/a", + disabled_respected:"pass"|"fail"|"n/a", + violations: [...], // S2 for keyboard fails on interactive elements + notes: [] + } + + Skills: keyboard-navigation.md · focus-management.md · playwright-selectors.md + ───────────────────────────────────────────────────────────────────────────── +*/ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const { capturePreview } = require('./utils'); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Detect whether the component in the preview area is interactive. + * A component is interactive if it has tabindex, role button/link/combobox, + * or is a native interactive element. + * + * @param {import('playwright').Page} page + * @param {string} previewSel - selector for the preview area + */ +async function isInteractive(page, previewSel = '.preview-area') { + return page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return false; + const interactiveTags = ['button', 'a', 'input', 'select', 'textarea']; + const interactiveRoles = ['button', 'link', 'checkbox', 'radio', 'combobox', + 'listbox', 'menuitem', 'tab', 'switch']; + const el = preview.querySelector( + interactiveTags.join(',') + ',' + + interactiveRoles.map(r => `[role="${r}"]`).join(',') + ',' + + '[tabindex]:not([tabindex="-1"])' + ); + return !!el; + }, previewSel); +} + +/** + * Detect whether the component has a disabled variant active. + * + * @param {import('playwright').Page} page + * @param {string} previewSel - selector for the preview area + */ +async function isDisabledState(page, previewSel = '.preview-area') { + return page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return false; + const el = preview.querySelector('[disabled], [aria-disabled="true"], [class*="disabled"]'); + return !!el; + }, previewSel); +} + +// ── Individual checks ──────────────────────────────────────────────────────── + +/** + * Tab focus check: press Tab from body up to MAX_TABS times, checking after + * each press whether focus has entered .preview-area. + * + * One Tab press is not sufficient — in complex page layouts (docs sidebar, + * controls panel, header) many elements precede the preview area in tab order. + * Pressing once and checking would false-fail on valid native button elements. + * + * Also checks: if element inside .preview-area is a native interactive element + * (button, a, input, select, textarea) it is inherently focusable — skip the + * Tab walk and return 'pass' directly. This prevents false positives on pages + * with deep tab orders. + */ +async function checkTabFocus(page, previewSel = '.preview-area') { + try { + const triggerIsNative = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return false; + const trigger = + preview.querySelector('[aria-haspopup]') ?? + preview.querySelector('[role="button"]') ?? + preview.querySelector('button, a, input, select, textarea') ?? + preview.firstElementChild; + if (!trigger) return false; + const nativeTags = ['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA']; + return nativeTags.includes(trigger.tagName); + }, previewSel); + if (triggerIsNative) return 'pass'; + + // Slow path: non-native interactive (div-based). Tab-walk up to 15 times. + const MAX_TABS = 15; + await page.evaluate(() => document.body.focus()); + + for (let i = 0; i < MAX_TABS; i++) { + await page.keyboard.press('Tab'); + await page.waitForTimeout(100); + const focusedInPreview = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + return preview ? preview.contains(document.activeElement) : false; + }, previewSel); + if (focusedInPreview) return 'pass'; + } + + return 'fail'; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +/** + * Focus visible check: after Tab, inspect CSS outline-width and + * :focus-visible pseudo-class on the active element. + */ +async function checkFocusVisible(page) { + try { + await page.evaluate(() => document.body.focus()); + await page.keyboard.press('Tab'); + await page.waitForTimeout(200); + + const result = await page.evaluate(() => { + const active = document.activeElement; + if (!active || active === document.body) return 'fail'; + const styles = getComputedStyle(active); + const outlineWidth = parseFloat(styles.outlineWidth) || 0; + const outlineStyle = styles.outlineStyle; + const boxShadow = styles.boxShadow; + + // Check: outline width > 0, or box-shadow is used as focus ring substitute + const hasOutline = outlineWidth > 0 && outlineStyle !== 'none'; + const hasBoxShadow = boxShadow && boxShadow !== 'none' && !boxShadow.includes('rgba(0, 0, 0, 0)'); + + return (hasOutline || hasBoxShadow) ? 'pass' : 'fail'; + }); + + return result; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +/** + * Enter/Space activation: focus the element, attach a click listener, + * press Enter, verify the listener fired. + */ +async function checkEnterActivates(page, previewSel = '.preview-area') { + try { + await page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return; + const el = preview.querySelector('button, a, [role="button"], [tabindex]:not([tabindex="-1"])'); + if (el) { + window.__layer2_clicked = false; + el.addEventListener('click', () => { window.__layer2_clicked = true; }, { once: true }); + el.focus(); + } + }, previewSel); + + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); + + const fired = await page.evaluate(() => window.__layer2_clicked === true); + return fired ? 'pass' : 'fail'; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +/** + * Escape closes check: only relevant for dialog/dropdown/popover patterns. + * Scoped to the preview area — only looks for closeable elements within the + * component under test, not elsewhere on the docs page. + * + * Workflow: click trigger to open → press Escape → check if closed. + * Returns 'not-applicable' if no closeable pattern found inside the preview area. + */ +async function checkEscapeCloses(page, previewSel = '.preview-area') { + try { + const hasCloseable = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return false; + return !!(preview.querySelector('[role="dialog"],[role="listbox"],[role="menu"],[popover],[data-open],[aria-expanded="true"]')); + }, previewSel); + + if (!hasCloseable) return 'not-applicable'; + + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + + const stillOpen = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return false; + return !!(preview.querySelector('[role="dialog"]:not([hidden]),[role="listbox"]:not([hidden]),[aria-expanded="true"]')); + }, previewSel); + + return stillOpen ? 'fail' : 'pass'; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +/** + * Click check: click the primary interactive element, verify no JS error + * thrown and element still in DOM. + */ +async function checkClickWorks(page, previewSel = '.preview-area') { + try { + const hasTarget = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return false; + return !!(preview.querySelector('button, a, [role="button"]') ?? preview.firstElementChild); + }, previewSel); + + if (!hasTarget) return 'n/a'; + + let errorFired = false; + page.once('pageerror', () => { errorFired = true; }); + + await page.locator(previewSel).first().click({ timeout: 3000, force: false }).catch(() => {}); + await page.waitForTimeout(200); + + return errorFired ? 'fail' : 'pass'; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +/** + * Hover state check: hover directly over the primary interactive element + * inside the preview area, detect background-color change. + * + * Hovers the element itself (not the container) to ensure :hover CSS fires. + * Waits 400ms for CSS transition to settle before reading computed style. + */ +async function checkHoverState(page, previewSel = '.preview-area') { + try { + const elSelector = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return null; + const el = preview.querySelector('button, a, [role="button"]') ?? preview.firstElementChild; + if (!el) return null; + const cls = el.className?.trim().split(/\s+/)[0]; + return cls ? `${sel} .${cls}` : null; + }, previewSel); + + if (!elSelector) return 'n/a'; + + const before = await page.evaluate((sel) => { + const el = document.querySelector(sel); + return el ? getComputedStyle(el).backgroundColor : null; + }, elSelector); + + await page.locator(elSelector).first().hover({ timeout: 3000 }).catch(() => {}); + await page.waitForTimeout(400); + + const after = await page.evaluate((sel) => { + const el = document.querySelector(sel); + return el ? getComputedStyle(el).backgroundColor : null; + }, elSelector); + + if (before === null || after === null) return 'n/a'; + return before !== after ? 'pass' : 'fail'; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +/** + * Disabled state check: if element is disabled, verify Tab doesn't focus it + * and click doesn't fire a click event. + */ +async function checkDisabledRespected(page, previewSel = '.preview-area') { + try { + await page.evaluate((sel) => { + const preview = document.querySelector(sel); + const el = preview?.querySelector('[disabled],[aria-disabled="true"],[class*="disabled"]'); + if (el) { + window.__layer2_disabled_clicked = false; + el.addEventListener('click', () => { window.__layer2_disabled_clicked = true; }, { once: true }); + } + }, previewSel); + + await page.locator(`${previewSel} [disabled], ${previewSel} [aria-disabled="true"]`).first() + .click({ force: true, timeout: 2000 }).catch(() => {}); + await page.waitForTimeout(200); + + const clicked = await page.evaluate(() => window.__layer2_disabled_clicked === true); + return clicked ? 'fail' : 'pass'; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +// ── Dynamic ARIA checks (CHECK 1–5) ───────────────────────────────────────── + +/** + * CHECK 1 — aria-expanded toggles correctly. + * Clicks the element with aria-expanded, asserts it toggles true then false. + */ +async function checkAriaExpanded(page, previewSel = '.preview-area') { + try { + const hasAriaExpanded = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + return preview ? !!preview.querySelector('[aria-expanded]') : false; + }, previewSel); + if (!hasAriaExpanded) return 'n/a'; + + await page.evaluate((sel) => { + const el = document.querySelector(`${sel} [aria-expanded]`); + if (el) el.click(); + }, previewSel); + await page.waitForTimeout(300); + + const afterOpen = await page.evaluate((sel) => { + const el = document.querySelector(`${sel} [aria-expanded]`); + return el ? el.getAttribute('aria-expanded') : null; + }, previewSel); + + if (afterOpen !== 'true') return 'fail: aria-expanded did not toggle'; + + await page.evaluate((sel) => { + const el = document.querySelector(`${sel} [aria-expanded]`); + if (el) el.click(); + }, previewSel); + await page.waitForTimeout(300); + + const afterClose = await page.evaluate((sel) => { + const el = document.querySelector(`${sel} [aria-expanded]`); + return el ? el.getAttribute('aria-expanded') : null; + }, previewSel); + + return afterClose === 'false' ? 'pass' : 'fail: aria-expanded did not toggle'; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +/** + * CHECK 2 — aria-live regions announce changes. + * Captures initial text, triggers a click, waits up to 2000ms for text change. + */ +async function checkAriaLive(page, previewSel = '.preview-area') { + try { + const hasAriaLive = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + return preview ? !!preview.querySelector('[aria-live]') : false; + }, previewSel); + if (!hasAriaLive) return 'n/a'; + + await page.evaluate((sel) => { + const el = document.querySelector(`${sel} [aria-live]`); + window.__ariaLiveBefore = el ? el.textContent : ''; + }, previewSel); + + await page.evaluate((sel) => { + const preview = document.querySelector(sel); + const trigger = preview?.querySelector('button, [role="button"], input, a'); + if (trigger) trigger.click(); + }, previewSel); + + const changed = await page.waitForFunction((sel) => { + const el = document.querySelector(`${sel} [aria-live]`); + return el ? el.textContent !== window.__ariaLiveBefore : false; + }, previewSel, { timeout: 2000 }).then(() => true).catch(() => false); + + return changed ? 'pass' : 'fail: live region did not update'; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +/** + * CHECK 3 — Focus trap inside Modal/Dialog. + * Only runs if component name includes: modal, dialog, drawer, sheet. + * Opens the modal, tabs through all focusable elements, asserts focus never escapes. + * Then presses Escape and asserts modal closes. + */ +async function checkFocusTrap(page, component, previewSel = '.preview-area') { + const modalNames = ['modal', 'dialog', 'drawer', 'sheet']; + if (!modalNames.some(n => (component || '').toLowerCase().includes(n))) return 'n/a'; + + try { + await page.evaluate((sel) => { + const preview = document.querySelector(sel); + const trigger = preview?.querySelector('button, [role="button"]'); + if (trigger) trigger.click(); + }, previewSel); + await page.waitForTimeout(500); + + const modalOpen = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + return !!preview?.querySelector('[role="dialog"], [role="alertdialog"]'); + }, previewSel); + if (!modalOpen) return 'n/a'; + + let escaped = false; + for (let i = 0; i < 20; i++) { + await page.keyboard.press('Tab'); + await page.waitForTimeout(100); + const focusEscaped = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + const modal = preview?.querySelector('[role="dialog"], [role="alertdialog"]'); + if (!modal) return false; + return !modal.contains(document.activeElement); + }, previewSel); + if (focusEscaped) { escaped = true; break; } + } + + if (escaped) return 'fail: focus escaped modal'; + + await page.keyboard.press('Escape'); + await page.waitForTimeout(400); + + const stillOpen = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + return !!preview?.querySelector('[role="dialog"]:not([hidden]), [role="alertdialog"]:not([hidden])'); + }, previewSel); + + return stillOpen ? 'fail: focus escaped modal' : 'pass'; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +/** + * CHECK 4 — aria-describedby points to real element. + * For each ID in aria-describedby, asserts the element exists in DOM and is not empty. + */ +async function checkAriaDescribedby(page, previewSel = '.preview-area') { + try { + const result = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return 'n/a'; + const el = preview.querySelector('[aria-describedby]'); + if (!el) return 'n/a'; + const ids = (el.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean); + for (const id of ids) { + const target = document.getElementById(id); + if (!target || !target.textContent?.trim()) { + return `fail: #${id} not found in DOM`; + } + } + return 'pass'; + }, previewSel); + return result; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +/** + * CHECK 5 — Tab order is logical. + * Finds all interactive elements inside the preview area and asserts none have tabIndex -1. + */ +async function checkTabOrder(page, previewSel = '.preview-area') { + try { + const result = await page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return 'n/a'; + const focusable = Array.from(preview.querySelectorAll( + 'button, a, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [tabindex]' + )).filter(el => !el.closest('[disabled]') && !el.hasAttribute('disabled')); + + if (focusable.length === 0) return 'n/a'; + + for (const el of focusable) { + const tabIdx = parseInt(el.getAttribute('tabindex') ?? '0', 10); + const isNative = ['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName); + const hasRole = ['button', 'link', 'checkbox', 'radio'].includes(el.getAttribute('role') ?? ''); + if ((isNative || hasRole) && tabIdx === -1) { + const label = el.tagName.toLowerCase() + (el.className ? '.' + el.className.trim().split(/\s+/)[0] : ''); + return `fail: tabIndex -1 on interactive element ${label}`; + } + } + return 'pass'; + }, previewSel); + return result; + } catch (e) { + return `error: ${e.message.slice(0, 80)}`; + } +} + +// ── Layer 2 main export ───────────────────────────────────────────────────── + +/** + * Run interaction layer for a single variant. + * Page must already be on the component URL with the variant applied. + * + * @param {object} opts + * @param {import('playwright').Page} opts.page + * @param {string} opts.variant + * @param {string} opts.mode - "light"|"dark" + * @param {string} [opts.component] - component name (used for screenshot naming) + * @param {string} [opts.screenshotsDir] - screenshot output directory + * @returns {Promise} + */ +async function runLayer2(opts) { + const { page, variant, mode, component, screenshotsDir, selectors = {} } = opts; + // Selify mode: .preview-area / Standard Storybook mode: .docs-story + const previewSel = selectors.previewArea || '.preview-area'; + + const interactive = await isInteractive(page, previewSel); + const disabled = await isDisabledState(page, previewSel); + + const result = { + layer: 2, + variant, + mode, + interactive, + disabled_variant: disabled, + tab_focus: 'n/a', + focus_visible: 'n/a', + enter_activates: 'n/a', + escape_closes: 'n/a', + click_works: 'n/a', + hover_state: 'n/a', + disabled_respected: 'n/a', + aria_expanded: 'n/a', + aria_live: 'n/a', + focus_trap: 'n/a', + aria_describedby: 'n/a', + tab_order: 'n/a', + violations: [], + notes: [], + }; + + if (!interactive) { + result.notes.push('Component is not interactive — all interaction checks marked n/a'); + return result; + } + + if (disabled) { + result.disabled_respected = await checkDisabledRespected(page, previewSel); + if (result.disabled_respected === 'fail') { + result.violations.push({ + layer: 2, + variant, mode, + axe_id: 'disabled-not-respected', + severity: 'S2', + wcag: '4.1.2', + fix: 'Disabled state must prevent user interaction — click event still firing', + }); + } + result.notes.push('Disabled variant — only disabled_respected check run'); + return result; + } + + // Run all checks + result.tab_focus = await checkTabFocus(page, previewSel); + result.focus_visible = await checkFocusVisible(page); + result.enter_activates = await checkEnterActivates(page, previewSel); + result.escape_closes = await checkEscapeCloses(page, previewSel); + result.click_works = await checkClickWorks(page, previewSel); + result.hover_state = await checkHoverState(page, previewSel); + + // Dynamic ARIA checks + result.aria_expanded = await checkAriaExpanded(page, previewSel); + result.aria_live = await checkAriaLive(page, previewSel); + result.focus_trap = await checkFocusTrap(page, component, previewSel); + result.aria_describedby = await checkAriaDescribedby(page, previewSel); + result.tab_order = await checkTabOrder(page, previewSel); + + // Keyboard accessibility violations + if (result.tab_focus === 'fail') { + result.violations.push({ + layer: 2, + variant, mode, + axe_id: 'keyboard-tab-focus', + severity: 'S2', + wcag: '2.1.1', + fix: 'Interactive element not reachable via Tab key — add tabindex="0" or use native interactive element', + }); + } + + if (result.focus_visible === 'fail') { + result.violations.push({ + layer: 2, + variant, mode, + axe_id: 'focus-not-visible', + severity: 'S2', + wcag: '2.4.7', + fix: 'Focus ring not visible — set outline or box-shadow on :focus-visible, do not use outline:none without replacement', + }); + } + + if (result.enter_activates === 'fail') { + result.violations.push({ + layer: 2, + variant, mode, + axe_id: 'enter-key-no-activate', + severity: 'S2', + wcag: '2.1.1', + fix: 'Enter key does not activate component — add keydown handler or use native button element', + }); + } + + if (result.click_works === 'fail') { + result.violations.push({ + layer: 2, + variant, mode, + axe_id: 'click-throws-error', + severity: 'S2', + wcag: 'N/A', + fix: 'Click interaction throws a JS error — check click handler implementation', + }); + } + + if (result.aria_expanded === 'fail: aria-expanded did not toggle') { + result.violations.push({ + layer: 2, + variant, mode, + axe_id: 'aria-expanded-no-toggle', + severity: 'S2', + wcag: '4.1.2', + fix: 'aria-expanded attribute does not update when element opens/closes — update attribute on state change', + }); + } + + if (result.aria_live?.startsWith('fail')) { + result.violations.push({ + layer: 2, + variant, mode, + axe_id: 'aria-live-no-announce', + severity: 'S3', + wcag: '4.1.3', + fix: 'aria-live region text did not change after interaction — verify dynamic content updates are written to the live region', + }); + } + + if (result.focus_trap?.startsWith('fail')) { + result.violations.push({ + layer: 2, + variant, mode, + axe_id: 'focus-trap-escape', + severity: 'S2', + wcag: '2.1.2', + fix: result.focus_trap.includes('escaped') ? 'Focus escapes modal container — trap focus inside dialog with a focus sentinel' : 'Escape key does not close modal — add keydown Escape handler', + }); + } + + if (result.aria_describedby?.startsWith('fail')) { + result.violations.push({ + layer: 2, + variant, mode, + axe_id: 'aria-describedby-missing', + severity: 'S3', + wcag: '1.3.1', + fix: `${result.aria_describedby} — ensure the referenced element exists in the DOM and contains text`, + }); + } + + if (result.tab_order?.startsWith('fail')) { + result.violations.push({ + layer: 2, + variant, mode, + axe_id: 'tab-order-invalid', + severity: 'S3', + wcag: '2.4.3', + fix: `${result.tab_order} — remove tabindex="-1" from interactive elements or use aria-hidden="true" if intentionally excluded`, + }); + } + + // Screenshot only when violations were found + if (result.violations.length > 0 && component && screenshotsDir) { + fs.mkdirSync(screenshotsDir, { recursive: true }); + const issueSlug = result.violations[0].axe_id.replace(/[^a-z0-9]+/gi, '-').toLowerCase(); + const screenshotName = `${component}-${variant}-${mode}-l2-${issueSlug}.png`; + const screenshotPath = path.join(screenshotsDir, screenshotName); + await capturePreview(page, screenshotPath, 'L2', previewSel); + for (const v of result.violations) v.screenshot = screenshotName; + result.screenshot = screenshotPath; + } + + return result; +} + +/** + * Run Layer 2 across all variants × modes. + * + * @param {object} opts + * @param {import('playwright').Page} opts.page + * @param {string[]} opts.variants + * @param {string[]} opts.modes + * @param {Function} opts.applyVariant - async (page, variant) => void + * @param {Function} opts.ensureMode - async (page, mode) => void + * @returns {Promise} - layer2 block for unified result + */ +async function runLayer2All(opts) { + // modes is accepted for API consistency with other layers but interaction + // checks are mode-independent (contrast is L1's responsibility), so we + // always run in light mode only. + const { page, variants, modes = ['light', 'dark'], applyVariant, ensureMode, component, screenshotsDir, selectors = {} } = opts; + + const results = []; + const violations = []; + const summary = { total: 0, s1: 0, s2: 0, s3: 0, s4: 0 }; + + const testMode = modes.includes('light') ? 'light' : modes[0]; + await ensureMode(page, testMode); + + for (const variant of variants) { + await applyVariant(page, variant); + const r = await runLayer2({ page, variant, mode: testMode, component, screenshotsDir, selectors }); + results.push(r); + + for (const v of r.violations) { + const sev = (v.severity || 'S4').toLowerCase(); + summary[sev] = (summary[sev] || 0) + 1; + summary.total++; + violations.push(v); + } + } + + return { results, violations, summary }; +} + +module.exports = { runLayer2, runLayer2All }; diff --git a/qa/layers/layer3-props.js b/qa/layers/layer3-props.js new file mode 100644 index 0000000..9d3aac1 --- /dev/null +++ b/qa/layers/layer3-props.js @@ -0,0 +1,320 @@ +/* + layer3-props.js + ───────────────────────────────────────────────────────────────────────────── + Layer 3 — Props Edge Cases + Tests component resilience to edge-case prop values. + + Checks (run once per component, not per variant): + 1. empty_string — label="" or equivalent empty prop + 2. long_text — 100+ character string → overflow detected? + 3. special_chars — !@#$%^&*<>'"/ → rendered or escaped correctly? + 4. all_variants — all variant options from select render without error + 5. numeric_zero — prop value "0" (boundary test) + 6. whitespace — label=" " (spaces only) + + Detection strategy for overflow: + - scrollWidth > clientWidth OR scrollHeight > clientHeight on the element + - OR element's bounding box exceeds .preview-area bounding box + + Returns: + { + empty_string: "pass"|"fail"|"n/a"|"error: ...", + long_text: "pass"|"fail: overflow detected"|"n/a", + special_chars: "pass"|"fail: unescaped HTML"|"n/a", + all_variants: "pass"|"fail: [variant] threw error"|"n/a", + numeric_zero: "pass"|"fail"|"n/a", + whitespace: "pass"|"fail"|"n/a", + violations: [...], + notes: [] + } + + Skills: props-edge-cases.md + ───────────────────────────────────────────────────────────────────────────── +*/ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const { capturePreview } = require('./utils'); + +const LONG_TEXT = 'A'.repeat(120); +const SPECIAL_CHARS = '!@#$%^&*()<>\'"/ injection test'; +const WHITESPACE = ' '; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Find the label/text input control in the controls panel. + * Returns null if no text input found. + * + * @param {import('playwright').Page} page + * @param {string} controlsSel - selector for the controls panel + */ +async function findLabelControl(page, controlsSel = '.controls-panel') { + return page.evaluate((sel) => { + const panel = document.querySelector(sel); + if (!panel) return null; + // Look for text inputs in control rows (not selects, not checkboxes) + const inputs = Array.from(panel.querySelectorAll('input[type="text"], input:not([type])')); + return inputs.length > 0 ? 'input[type="text"]' : null; + }, controlsSel); +} + +/** + * Set a value in the first text input in the controls panel. + * Selify mode: .controls-panel — Svelte-compatible native setter + * Standard Storybook mode: .docblock-argstable — same setter approach + * + * @param {import('playwright').Page} page + * @param {string} value + * @param {string} controlsSel - selector for the controls panel + */ +async function setLabelValue(page, value, controlsSel = '.controls-panel') { + await page.evaluate(([val, sel]) => { + const panel = document.querySelector(sel); + const input = panel?.querySelector('input[type="text"], input:not([type="checkbox"]):not([type="radio"])'); + if (!input) return false; + // React/Svelte-compatible value setting + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + nativeInputValueSetter.call(input, val); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + return true; + }, [value, controlsSel]); + await page.waitForTimeout(400); +} + +/** + * Check if the component element overflows its container. + * + * @param {import('playwright').Page} page + * @param {string} previewSel - selector for the preview area + */ +async function checkOverflow(page, previewSel = '.preview-area') { + return page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return false; + const el = preview.querySelector('[class*=jera-]') ?? preview.firstElementChild; + if (!el) return false; + + const elBox = el.getBoundingClientRect(); + const previewBox = preview.getBoundingClientRect(); + + const overflowsContainer = + elBox.right > previewBox.right + 4 || + elBox.bottom > previewBox.bottom + 4; + + const scrollOverflow = + el.scrollWidth > el.clientWidth + 4 || + el.scrollHeight > el.clientHeight + 4; + + return overflowsContainer || scrollOverflow; + }, previewSel); +} + +/** + * Check if innerHTML of the preview area contains raw unescaped HTML injected + * from the special chars test. + * + * @param {import('playwright').Page} page + * @param {string} previewSel - selector for the preview area + */ +async function checkHtmlInjection(page, previewSel = '.preview-area') { + return page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return false; + return preview.querySelector('script') !== null; + }, previewSel); +} + +/** + * Check if the component renders without a visible error (red error box, + * uncaught exception overlay, or component disappears). + * + * @param {import('playwright').Page} page + * @param {string} previewSel - selector for the preview area + */ +async function componentRendered(page, previewSel = '.preview-area') { + return page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return false; + const errorOverlay = document.querySelector('.vite-error-overlay, [class*="error-overlay"], [class*="ErrorBoundary"]'); + if (errorOverlay) return false; + return preview.children.length > 0; + }, previewSel); +} + +// ── Individual checks ──────────────────────────────────────────────────────── + +async function checkEmptyString(page, previewSel, controlsSel) { + try { + const hasControl = await findLabelControl(page, controlsSel); + if (!hasControl) return 'n/a'; + + await setLabelValue(page, '', controlsSel); + const rendered = await componentRendered(page, previewSel); + await page.waitForTimeout(200); + return rendered ? 'pass' : 'fail: component disappeared on empty string'; + } catch (e) { + return `error: ${e.message.slice(0, 100)}`; + } +} + +async function checkLongText(page, previewSel, controlsSel) { + try { + const hasControl = await findLabelControl(page, controlsSel); + if (!hasControl) return 'n/a'; + + await setLabelValue(page, LONG_TEXT, controlsSel); + await page.waitForTimeout(300); + const overflows = await checkOverflow(page, previewSel); + return overflows ? `fail: overflow detected (${LONG_TEXT.length} chars)` : 'pass'; + } catch (e) { + return `error: ${e.message.slice(0, 100)}`; + } +} + +async function checkSpecialChars(page, previewSel, controlsSel) { + try { + const hasControl = await findLabelControl(page, controlsSel); + if (!hasControl) return 'n/a'; + + await setLabelValue(page, SPECIAL_CHARS, controlsSel); + await page.waitForTimeout(300); + + const injected = await checkHtmlInjection(page, previewSel); + if (injected) return 'fail: unescaped HTML injection possible'; + + const rendered = await componentRendered(page, previewSel); + return rendered ? 'pass' : 'fail: component did not render special chars'; + } catch (e) { + return `error: ${e.message.slice(0, 100)}`; + } +} + +async function checkAllVariants(page, variants, applyVariant, previewSel) { + const errors = []; + for (const variant of variants) { + try { + await applyVariant(page, variant); + const rendered = await componentRendered(page, previewSel); + if (!rendered) errors.push(`${variant}: did not render`); + } catch (e) { + errors.push(`${variant}: ${e.message.slice(0, 60)}`); + } + } + return errors.length > 0 ? `fail: ${errors.join('; ')}` : 'pass'; +} + +async function checkNumericZero(page, previewSel, controlsSel) { + try { + const hasControl = await findLabelControl(page, controlsSel); + if (!hasControl) return 'n/a'; + + await setLabelValue(page, '0', controlsSel); + await page.waitForTimeout(200); + const rendered = await componentRendered(page, previewSel); + return rendered ? 'pass' : 'fail: component disappeared on value "0"'; + } catch (e) { + return `error: ${e.message.slice(0, 100)}`; + } +} + +async function checkWhitespace(page, previewSel, controlsSel) { + try { + const hasControl = await findLabelControl(page, controlsSel); + if (!hasControl) return 'n/a'; + + await setLabelValue(page, WHITESPACE, controlsSel); + await page.waitForTimeout(200); + const rendered = await componentRendered(page, previewSel); + return rendered ? 'pass' : 'fail: component crashed on whitespace-only value'; + } catch (e) { + return `error: ${e.message.slice(0, 100)}`; + } +} + +// ── Layer 3 main export ───────────────────────────────────────────────────── + +/** + * Run props edge case layer for the component (run once, not per variant). + * Page must be on the component URL. Variant is reset to default after checks. + * + * @param {object} opts + * @param {import('playwright').Page} opts.page + * @param {string} opts.component + * @param {string[]} opts.variants - all variants (for all_variants check) + * @param {Function} opts.applyVariant - async (page, variant) => void + * @param {Function} opts.ensureMode - async (page, mode) => void + * @param {string} [opts.screenshotsDir] - screenshot output directory + * @returns {Promise} + */ +async function runLayer3All(opts) { + const { page, component, variants, applyVariant, ensureMode, screenshotsDir, selectors = {} } = opts; + // Selify mode: .preview-area / .controls-panel + // Standard Storybook mode: .docs-story / .docblock-argstable + const previewSel = selectors.previewArea || '.preview-area'; + const controlsSel = selectors.controls || '.controls-panel'; + + // Run in light mode, reset to first variant + await ensureMode(page, 'light'); + if (variants[0]) await applyVariant(page, variants[0]); + + const result = { + layer: 3, + component, + violations: [], + notes: [], + }; + + if (screenshotsDir) fs.mkdirSync(screenshotsDir, { recursive: true }); + + // Run each check sequentially — screenshot immediately on failure while page + // state still reflects the failing condition + const checkDefs = [ + { name: 'empty_string', fn: () => checkEmptyString(page, previewSel, controlsSel) }, + { name: 'long_text', fn: () => checkLongText(page, previewSel, controlsSel) }, + { name: 'special_chars',fn: () => checkSpecialChars(page, previewSel, controlsSel) }, + { name: 'all_variants', fn: () => checkAllVariants(page, variants, applyVariant, previewSel) }, + { name: 'numeric_zero', fn: () => checkNumericZero(page, previewSel, controlsSel) }, + { name: 'whitespace', fn: () => checkWhitespace(page, previewSel, controlsSel) }, + ]; + + for (const { name, fn } of checkDefs) { + const val = await fn(); + result[name] = val; + + if (typeof val === 'string' && val.startsWith('fail')) { + let screenshotName = null; + if (screenshotsDir) { + screenshotName = `${component}-l3-${name.replace(/_/g, '-')}.png`; + await capturePreview(page, path.join(screenshotsDir, screenshotName), 'L3', previewSel); + } + result.violations.push({ + layer: 3, + component, + check: name, + severity: name === 'special_chars' ? 'S1' : 'S3', + wcag: name === 'special_chars' ? 'N/A (XSS risk)' : 'N/A', + fix: `Props edge case failure: ${name} → ${val}`, + ...(screenshotName && { screenshot: screenshotName }), + }); + } + } + + // Reset to default state + if (variants[0]) await applyVariant(page, variants[0]).catch(() => {}); + + const summary = { total: 0, s1: 0, s2: 0, s3: 0, s4: 0 }; + for (const v of result.violations) { + const sev = (v.severity || 'S3').toLowerCase(); + summary[sev] = (summary[sev] || 0) + 1; + summary.total++; + } + + result.summary = summary; + return result; +} + +module.exports = { runLayer3All }; diff --git a/qa/layers/layer4-responsive.js b/qa/layers/layer4-responsive.js new file mode 100644 index 0000000..c33bf5f --- /dev/null +++ b/qa/layers/layer4-responsive.js @@ -0,0 +1,255 @@ +/* + layer4-responsive.js + ───────────────────────────────────────────────────────────────────────────── + Layer 4 — Responsive Testing + Tests component rendering at 3 standard viewport sizes. + + Viewports: + Mobile: 375×812 (iPhone 14) + Tablet: 768×1024 (iPad) + Desktop: 1440×900 (standard laptop) + + For each viewport: + 1. Resize browser to viewport + 2. Navigate to component (or just resize if same page) + 3. Check: component renders correctly (no missing element) + 4. Check: no overflow/clipping (bounding box within viewport) + 5. Check: text readable (font-size >= 12px after zoom) + 6. Check: layout intact (no zero-height, no negative positions) + 7. Screenshot — ONLY when a check fails at this viewport + + Returns: + { + mobile: { viewport: "375×812", render: "pass|fail", overflow: "none|detected", text_readable: "pass|fail", layout: "pass|fail", screenshot: "..." }, + tablet: { ... }, + desktop: { ... }, + violations: [...], + summary: { total, s1, s2, s3, s4 } + } + + Skills: responsive-testing.md + ───────────────────────────────────────────────────────────────────────────── +*/ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); + +const VIEWPORTS = [ + { name: 'mobile', width: 375, height: 812 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1440, height: 900 }, +]; + +// ── Checks ─────────────────────────────────────────────────────────────────── + +/** + * Check that the component element exists and has non-zero dimensions. + * + * @param {import('playwright').Page} page + * @param {string} previewSel - selector for the preview area + */ +async function checkRenders(page, previewSel = '.preview-area') { + return page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return 'not-checked'; + const el = preview.querySelector('[class*=jera-]') ?? preview.firstElementChild; + if (!el) return 'fail: no component element found'; + const box = el.getBoundingClientRect(); + if (box.width === 0 || box.height === 0) return 'fail: element has zero dimensions'; + return 'pass'; + }, previewSel); +} + +/** + * Check that the component doesn't overflow the viewport horizontally. + * + * @param {import('playwright').Page} page + * @param {number} viewportWidth + * @param {string} previewSel - selector for the preview area + */ +async function checkOverflow(page, viewportWidth, previewSel = '.preview-area') { + return page.evaluate(([vw, sel]) => { + const preview = document.querySelector(sel); + if (!preview) return 'not-checked'; + const box = preview.getBoundingClientRect(); + if (box.right > vw + 4) return `detected: right edge at ${Math.round(box.right)}px (viewport ${vw}px)`; + if (document.body.scrollWidth > vw + 4) { + return `detected: body scrollWidth ${document.body.scrollWidth}px (viewport ${vw}px)`; + } + return 'none'; + }, [viewportWidth, previewSel]); +} + +/** + * Check that text in the component is readable (font-size >= 12px). + * + * @param {import('playwright').Page} page + * @param {string} previewSel - selector for the preview area + */ +async function checkTextReadable(page, previewSel = '.preview-area') { + return page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return 'not-checked'; + const textEls = Array.from(preview.querySelectorAll('*')).filter(el => { + return el.childElementCount === 0 && el.textContent.trim().length > 0; + }); + if (textEls.length === 0) return 'n/a'; + + for (const el of textEls) { + const fontSize = parseFloat(getComputedStyle(el).fontSize) || 16; + if (fontSize < 12) return `fail: font-size ${fontSize}px on "${el.textContent.trim().slice(0, 30)}"`; + } + return 'pass'; + }, previewSel); +} + +/** + * Check layout integrity: no element at negative top position, + * no element with near-zero height. + * + * @param {import('playwright').Page} page + * @param {string} previewSel - selector for the preview area + */ +async function checkLayout(page, previewSel = '.preview-area') { + return page.evaluate((sel) => { + const preview = document.querySelector(sel); + if (!preview) return 'not-checked'; + const box = preview.getBoundingClientRect(); + if (box.top < -10) return `fail: preview area at negative top: ${Math.round(box.top)}px`; + if (box.height < 4) return `fail: preview area height too small: ${Math.round(box.height)}px`; + return 'pass'; + }, previewSel); +} + +// ── Layer 4 main export ───────────────────────────────────────────────────── + +/** + * Run responsive layer (single viewport test call, not per variant). + * Reuses auth — navigates to componentUrl at each viewport size. + * + * @param {object} opts + * @param {import('playwright').BrowserContext} opts.context - Playwright context + * @param {string} opts.componentUrl + * @param {string} opts.component + * @param {string[]} opts.variants - apply first variant only + * @param {string} opts.screenshotsDir + * @param {Function} opts.applyVariant - async (page, variant) => void + * @param {Function} opts.ensureAuthenticated - async (page) => void + * @returns {Promise} + */ +async function runLayer4All(opts) { + const { context, componentUrl, component, variants, screenshotsDir, applyVariant, ensureAuthenticated, selectors = {} } = opts; + // Selify mode: .preview-area / Standard Storybook mode: .docs-story + const previewSel = selectors.previewArea || '.preview-area'; + + fs.mkdirSync(screenshotsDir, { recursive: true }); + + const results = {}; + const violations = []; + + for (const viewport of VIEWPORTS) { + const { name, width, height } = viewport; + console.log(` [L4] Testing ${name} (${width}×${height})...`); + + const vpage = await context.newPage(); + await vpage.setViewportSize({ width, height }); + + try { + // Auth + await ensureAuthenticated(vpage); + + // Navigate to component + await vpage.goto(componentUrl, { waitUntil: 'networkidle', timeout: 30000 }); + // Selify mode: wait for .preview-area + // Standard Storybook mode: wait for .docs-story + await vpage.waitForSelector(previewSel, { timeout: 15000 }).catch(() => {}); + await vpage.waitForTimeout(500); + + // Apply first variant + if (variants[0]) { + await applyVariant(vpage, variants[0]).catch(() => {}); + } + + // Run checks first — screenshot only on failure + const renderResult = await checkRenders(vpage, previewSel); + const overflowResult = await checkOverflow(vpage, width, previewSel); + const textResult = await checkTextReadable(vpage, previewSel); + const layoutResult = await checkLayout(vpage, previewSel); + + // Determine which checks failed + const failures = []; + if (renderResult !== 'pass' && renderResult !== 'not-checked') failures.push('render'); + if (overflowResult !== 'none' && overflowResult !== 'not-checked') failures.push('overflow'); + if (textResult !== 'pass' && textResult !== 'n/a' && textResult !== 'not-checked') failures.push('text-readable'); + if (layoutResult !== 'pass' && layoutResult !== 'not-checked') failures.push('layout'); + + // Screenshot only when a failure was detected + let screenshotCaptured = false; + let screenshotPath = null; + let screenshotName = null; + if (failures.length > 0) { + screenshotName = `${component}-${name}-l4-${failures[0]}.png`; + screenshotPath = path.join(screenshotsDir, screenshotName); + await vpage.screenshot({ path: screenshotPath, fullPage: false }) + .then(() => { screenshotCaptured = true; }) + .catch(err => console.warn(` [L4] Screenshot failed: ${err.message}`)); + } + + results[name] = { + viewport: `${width}×${height}`, + render: renderResult, + overflow: overflowResult, + text_readable: textResult, + layout: layoutResult, + screenshot: screenshotCaptured ? screenshotPath : null, + }; + + // Build violations — attach screenshot if captured + if (renderResult !== 'pass' && renderResult !== 'not-checked') { + violations.push({ layer: 4, viewport: name, check: 'render', severity: 'S2', fix: renderResult, ...(screenshotCaptured && { screenshot: screenshotName }) }); + } + if (overflowResult !== 'none' && overflowResult !== 'not-checked') { + violations.push({ layer: 4, viewport: name, check: 'overflow', severity: 'S3', fix: `Overflow at ${name}: ${overflowResult}`, ...(screenshotCaptured && { screenshot: screenshotName }) }); + } + if (textResult !== 'pass' && textResult !== 'n/a' && textResult !== 'not-checked') { + violations.push({ layer: 4, viewport: name, check: 'text_readable', severity: 'S3', fix: textResult, ...(screenshotCaptured && { screenshot: screenshotName }) }); + } + if (layoutResult !== 'pass' && layoutResult !== 'not-checked') { + violations.push({ layer: 4, viewport: name, check: 'layout', severity: 'S2', fix: layoutResult, ...(screenshotCaptured && { screenshot: screenshotName }) }); + } + + } catch (err) { + console.warn(` [L4] Error testing ${name}: ${err.message}`); + results[name] = { + viewport: `${width}×${height}`, + render: `error: ${err.message.slice(0, 100)}`, + overflow: 'not-checked', + text_readable: 'not-checked', + layout: 'not-checked', + screenshot: null, + }; + violations.push({ layer: 4, viewport: name, check: 'all', severity: 'S3', fix: `Layer 4 error at ${name}: ${err.message.slice(0, 100)}` }); + } finally { + await vpage.close(); + } + } + + const summary = { total: 0, s1: 0, s2: 0, s3: 0, s4: 0 }; + for (const v of violations) { + const sev = (v.severity || 'S3').toLowerCase(); + summary[sev] = (summary[sev] || 0) + 1; + summary.total++; + } + + return { + mobile: results['mobile'] ?? null, + tablet: results['tablet'] ?? null, + desktop: results['desktop'] ?? null, + violations, + summary, + }; +} + +module.exports = { runLayer4All, VIEWPORTS }; diff --git a/qa/layers/utils.js b/qa/layers/utils.js new file mode 100644 index 0000000..1ad5f5e --- /dev/null +++ b/qa/layers/utils.js @@ -0,0 +1,70 @@ +'use strict'; + +/** + * Screenshot the preview area (with padding), falling back to full page. + * Used by layer2-interaction.js and layer3-props.js. + * + * @param {import('playwright').Page} page + * @param {string} outputPath + * @param {string} [logPrefix] + * @param {string} [previewSel] - selector for the preview area + */ +async function capturePreview(page, outputPath, logPrefix = 'L?', previewSel = '.preview-area') { + try { + const preview = await page.$(previewSel); + if (!preview) { + await page.screenshot({ path: outputPath }); + return; + } + const box = await preview.boundingBox(); + const padding = 16; + await page.screenshot({ + path: outputPath, + clip: box ? { + x: Math.max(0, box.x - padding), + y: Math.max(0, box.y - padding), + width: box.width + padding * 2, + height: box.height + padding * 2, + } : undefined, + }); + } catch (err) { + console.warn(` [${logPrefix}] Screenshot failed: ${err.message}`); + } +} + +// ── Environment selector sets ───────────────────────────────────────────────── +// Selectors differ between Selify's custom doc pages and standard Storybook. +// +// Selify mode: uses custom /docs/components/ pages +// Standard Storybook mode: uses /?path=/docs/ with v7/v8 DOM structure + +const SELECTORS = { + selify: { + previewArea: '.preview-area', + controls: '.controls-panel', + variantSelect: '.controls-panel select.jera-select', + // non-null: dark mode toggle exists — L1 runs both light + dark + modeToggle: '.dark-light-toggle', + }, + storybook: { + // Storybook v7/v8 docs view: first story block is the live preview + previewArea: '.docs-story', + controls: '.docblock-argstable', + variantSelect: '.docblock-argstable select', + // null: no built-in dark/light toggle — L1 runs light mode only + modeToggle: null, + }, +}; + +/** + * Return the selector set for the given Storybook mode. + * Falls back to 'selify' for unknown modes. + * + * @param {'selify'|'storybook'} mode + * @returns {{ previewArea: string, controls: string, variantSelect: string, modeToggle: string|null }} + */ +function getSelectors(mode = 'selify') { + return SELECTORS[mode] || SELECTORS.selify; +} + +module.exports = { capturePreview, getSelectors, SELECTORS }; diff --git a/qa/playwright.config.ts b/qa/playwright.config.ts new file mode 100644 index 0000000..0fae4d7 --- /dev/null +++ b/qa/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'path'; + +const STORYBOOK_URL = process.env.STORYBOOK_URL || 'http://localhost:6006'; +const AUTH_FILE = path.join(__dirname, '.auth', 'state.json'); +const needsAuth = STORYBOOK_URL.includes('admin.selify.ai'); + +export default defineConfig({ + testDir: './specs', + timeout: 45000, + retries: 0, + reporter: 'list', + globalSetup: needsAuth ? path.join(__dirname, 'global-setup.ts') : undefined, + + use: { + baseURL: STORYBOOK_URL, + storageState: needsAuth ? AUTH_FILE : undefined, + headless: true, + viewport: { width: 1440, height: 900 }, + screenshot: 'only-on-failure', + actionTimeout: 15000, + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +}); diff --git a/qa/spec-generator.js b/qa/spec-generator.js new file mode 100644 index 0000000..66187ba --- /dev/null +++ b/qa/spec-generator.js @@ -0,0 +1,603 @@ +/* + spec-generator.js + ───────────────────────────────────────────────────────────────────────────── + Reads a component-test-runner JSON findings file and generates Playwright + .spec.ts test files for every failing check. + + Handles both output formats: + Format A (current) — has run_id, pipeline, target, layers, status + Format B (legacy) — has component, layer1/2/3/4 at top level + Suite report — has date + components[]. Auto-discovers per-component JSONs. + + Usage: + node spec-generator.js # single component or suite + node spec-generator.js --dry-run # preview without writing + + Output: + qa/specs/[component-name].spec.ts + ───────────────────────────────────────────────────────────────────────────── +*/ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// ── Constants ────────────────────────────────────────────────────────────── + +const SPECS_DIR = path.join(__dirname, 'specs'); + +const VIEWPORTS = { + mobile: { width: 375, height: 812 }, + tablet: { width: 768, height: 1024 }, + desktop: { width: 1440, height: 900 }, +}; + +const L3_VALUES = { + empty_string: '', + long_text: 'A'.repeat(120), + special_chars: "", + numeric_zero: '0', + whitespace: ' ', +}; + +const L3_DESCS = { + empty_string: 'empty string ""', + long_text: '120-character string (overflow test)', + special_chars: 'XSS injection string', + numeric_zero: '"0" (numeric zero)', + whitespace: 'whitespace-only " "', +}; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const I = (n) => ' '.repeat(n); + +/** + * Normalise a raw JSON result into a consistent report shape regardless of format (A/B/legacy). + * @param {object} raw - Raw parsed JSON from a component test result file + * @returns {object} - Normalised report with run_id, component, url, layers, summary + */ +function normalizeReport(raw) { + return { + run_id: raw.run_id || null, + component: ((raw.target ?? raw.component) || '').toLowerCase(), + url: raw.url || null, + status: raw.status || raw.summary?.overall || 'unknown', + fallback: raw.fallback_mode || false, + layer1: raw.layers?.layer1 || raw.layer1 || { violations: [], summary: { total: 0 } }, + layer2: raw.layers?.layer2 || raw.layer2 || { violations: [], summary: { total: 0 } }, + layer3: raw.layers?.layer3 || raw.layer3 || { violations: [], summary: { total: 0 } }, + layer4: raw.layers?.layer4 || raw.layer4 || { violations: [], summary: { total: 0 } }, + summary: raw.summary || {}, + }; +} + +function isSuiteReport(raw) { + return Array.isArray(raw.components) && typeof raw.date === 'string'; +} + +function getUrlPath(report) { + if (report.url) { + try { return new URL(report.url).pathname; } catch (_) {} + } + return `/docs/components/${report.component}/`; +} + +// ── Variant / mode setup block (inlined into each test) ─────────────────── + +function variantBlock(variant, mode) { + const lines = [ + `${I(2)}await page.selectOption('.controls-panel select.jera-select', ${JSON.stringify(variant)}).catch(() => {});`, + `${I(2)}await page.waitForTimeout(300);`, + ]; + if (mode === 'dark') { + lines.push( + `${I(2)}// Enable dark mode`, + `${I(2)}await page.evaluate(() => {`, + `${I(3)}const rows = Array.from(document.querySelectorAll('.controls-panel .control-row'));`, + `${I(3)}const darkRow = rows.find((r: Element) => r.textContent?.toLowerCase().includes('dark'));`, + `${I(3)}(darkRow?.querySelector('input[type="checkbox"]') as HTMLInputElement | null)?.click();`, + `${I(2)}});`, + `${I(2)}await page.waitForTimeout(400);`, + ); + } + return lines.join('\n'); +} + +// ── L1 — Accessibility ───────────────────────────────────────────────────── + +function genL1ContrastTest(component, v) { + const title = `${component} / ${v.variant} / ${v.mode} — L1 contrast ratio must meet WCAG AA (${v.wcag})`; + const compSel = '[class*="jera-' + component + '"]'; + const ctxLabel = `${component} / ${v.variant} / ${v.mode}`; + const evalBody = [ + ' function toRgba(css) {', + ' const c = document.createElement("canvas"); c.width = c.height = 1;', + ' const ctx = c.getContext("2d"); ctx.fillStyle = css; ctx.fillRect(0, 0, 1, 1);', + ' const d = ctx.getImageData(0, 0, 1, 1).data; return [d[0], d[1], d[2], d[3]];', + ' }', + ' function lum(r, g, b) {', + ' return [r, g, b].map(function(c) { var s = c/255; return s <= 0.03928 ? s/12.92 : Math.pow((s+0.055)/1.055, 2.4); })', + ' .reduce(function(sum, c, i) { return sum + c * [0.2126, 0.7152, 0.0722][i]; }, 0);', + ' }', + ' function cr(a, b) { var hi = a > b ? a : b, lo = a > b ? b : a; return (hi + 0.05) / (lo + 0.05); }', + ' var preview = document.querySelector(".preview-area");', + ' if (!preview) return null;', + ' var el = preview.querySelector(' + JSON.stringify(compSel) + ')', + ' || preview.querySelector("[class*=\\"jera-\\"]")', + ' || preview.firstElementChild;', + ' if (!el) return null;', + ' var s = getComputedStyle(el);', + ' var fgRgba = toRgba(s.color), bgRgba = toRgba(s.backgroundColor);', + ' var bgAlpha = bgRgba[3] / 255;', + ' var fg = [fgRgba[0], fgRgba[1], fgRgba[2]];', + ' var bg;', + ' if (bgAlpha < 0.99) {', + ' var p = toRgba(getComputedStyle(document.body).backgroundColor);', + ' bg = [Math.round(bgRgba[0]*bgAlpha + p[0]*(1-bgAlpha)), Math.round(bgRgba[1]*bgAlpha + p[1]*(1-bgAlpha)), Math.round(bgRgba[2]*bgAlpha + p[2]*(1-bgAlpha))];', + ' } else {', + ' bg = [bgRgba[0], bgRgba[1], bgRgba[2]];', + ' }', + ' var ratio = Math.round(cr(lum(fg[0],fg[1],fg[2]), lum(bg[0],bg[1],bg[2])) * 100) / 100;', + ' var fs = parseFloat(s.fontSize) || 0, fw = parseInt(s.fontWeight) || 400;', + ' var required = (fs >= 18 || (fs >= 14 && fw >= 700)) ? 3.0 : 4.5;', + ' return { ratio: ratio, required: required, fg: "rgb("+fg+")", bg: "rgb("+bg+")" };', + ].join('\n'); + + return ` +${I(1)}test(${JSON.stringify(title)}, async ({ page }) => { +${I(2)}// Canvas-sampled contrast — axe marks oklch tokens as 'incomplete', we detect via pixel sampling +${I(2)}// Measured: ${v.contrast_ratio}:1 Required: ${v.required_ratio}:1 FG: ${v.fg} BG: ${v.bg} Font: ${v.font_size} +${I(2)}// Fix: ${v.fix} +${I(2)}await page.goto(COMPONENT_URL); +${I(2)}await page.waitForSelector('.preview-area', { timeout: 15000 }); +${I(2)}await page.waitForTimeout(500); +${variantBlock(v.variant, v.mode)} + +${I(2)}/* eslint-disable */ +${I(2)}const result = await page.evaluate(function() { +${evalBody} +${I(2)}}); +${I(2)}/* eslint-enable */ + +${I(2)}expect(result, 'Could not locate jera element for contrast measurement').not.toBeNull(); +${I(2)}expect(result!.ratio, +${I(3)}\`Contrast \${result!.ratio}:1 must meet WCAG AA (\${result!.required}:1) — ${ctxLabel}\`) +${I(3)}.toBeGreaterThanOrEqual(result!.required); +${I(1)}});`; +} + +function genL1AxeTest(component, v) { + const title = `${component} / ${v.variant} / ${v.mode} — L1 axe: ${v.axe_id} (WCAG ${v.wcag})`; + return ` +${I(1)}test(${JSON.stringify(title)}, async ({ page }) => { +${I(2)}// axe violation: ${v.axe_id} — impact: ${v.impact} — WCAG ${v.wcag} +${I(2)}// Fix: ${v.fix || `See axe documentation for rule "${v.axe_id}"`} +${I(2)}await page.goto(COMPONENT_URL); +${I(2)}await page.waitForSelector('.preview-area', { timeout: 15000 }); +${I(2)}await page.waitForTimeout(500); +${variantBlock(v.variant, v.mode)} + +${I(2)}const results = await new AxeBuilder({ page }) +${I(3)}.include('.preview-area') +${I(3)}.withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) +${I(3)}.analyze(); +${I(2)}const violations = results.violations.filter(v => v.id === ${JSON.stringify(v.axe_id)}); +${I(2)}expect(violations, +${I(3)}\`axe rule "${v.axe_id}" must not fire on ${component}/${v.variant}/${v.mode}\`) +${I(3)}.toHaveLength(0); +${I(1)}});`; +} + +// ── L2 — Interaction ─────────────────────────────────────────────────────── + +const L2_BODIES = { + 'keyboard-tab-focus': (v) => ` +${I(2)}// Tab through the page up to 15 times; expect focus to enter .preview-area +${I(2)}await page.evaluate(() => document.body.focus()); +${I(2)}let focused = false; +${I(2)}for (let i = 0; i < 15; i++) { +${I(3)}await page.keyboard.press('Tab'); +${I(3)}await page.waitForTimeout(100); +${I(3)}focused = await page.evaluate(() => { +${I(4)}const preview = document.querySelector('.preview-area'); +${I(4)}return !!preview?.contains(document.activeElement); +${I(3)}}); +${I(3)}if (focused) break; +${I(2)}} +${I(2)}expect(focused, 'Tab key must move focus into .preview-area').toBe(true);`, + + 'focus-not-visible': (v) => ` +${I(2)}// After Tab, focused element must show a visible focus ring +${I(2)}await page.evaluate(() => document.body.focus()); +${I(2)}await page.keyboard.press('Tab'); +${I(2)}await page.waitForTimeout(200); +${I(2)}const visible = await page.evaluate(() => { +${I(3)}const el = document.activeElement as HTMLElement; +${I(3)}if (!el || el === document.body) return false; +${I(3)}const s = getComputedStyle(el); +${I(3)}const hasOutline = parseFloat(s.outlineWidth) > 0 && s.outlineStyle !== 'none'; +${I(3)}const hasShadow = s.boxShadow !== 'none' && !s.boxShadow.includes('rgba(0, 0, 0, 0)'); +${I(3)}return hasOutline || hasShadow; +${I(2)}}); +${I(2)}expect(visible, 'Focused element must have a visible focus ring (outline or box-shadow)').toBe(true);`, + + 'enter-key-no-activate': (v) => ` +${I(2)}// Focus trigger element, press Enter, expect click event fires +${I(2)}await page.evaluate(() => { +${I(3)}const preview = document.querySelector('.preview-area'); +${I(3)}const el = preview?.querySelector('button, a, [role="button"], [tabindex]:not([tabindex="-1"])') as HTMLElement; +${I(3)}if (el) { +${I(4)}(window as any).__specActivated = false; +${I(4)}el.addEventListener('click', () => { (window as any).__specActivated = true; }, { once: true }); +${I(4)}el.focus(); +${I(3)}} +${I(2)}}); +${I(2)}await page.keyboard.press('Enter'); +${I(2)}await page.waitForTimeout(200); +${I(2)}const activated = await page.evaluate(() => (window as any).__specActivated === true); +${I(2)}expect(activated, 'Enter key must trigger activation (click event) on the interactive element').toBe(true);`, + + 'escape-closes': (v) => ` +${I(2)}// Click trigger to open overlay, press Escape, expect overlay closes +${I(2)}await page.locator('.preview-area [aria-haspopup], .preview-area [role="button"]').first().click().catch(() => {}); +${I(2)}await page.waitForTimeout(300); +${I(2)}await page.keyboard.press('Escape'); +${I(2)}await page.waitForTimeout(300); +${I(2)}const stillOpen = await page.evaluate(() => { +${I(3)}const preview = document.querySelector('.preview-area'); +${I(3)}return !!preview?.querySelector('[role="dialog"]:not([hidden]),[role="listbox"]:not([hidden]),[aria-expanded="true"]'); +${I(2)}}); +${I(2)}expect(stillOpen, 'Escape key must close any open overlay or dropdown').toBe(false);`, + + 'click-throws-error': (v) => ` +${I(2)}// Click primary element; expect no JS page error fires +${I(2)}const errors: string[] = []; +${I(2)}page.on('pageerror', (err) => errors.push(err.message)); +${I(2)}await page.locator('.preview-area').first().click({ timeout: 3000, force: false }).catch(() => {}); +${I(2)}await page.waitForTimeout(300); +${I(2)}expect(errors, 'Clicking the component must not throw a JS page error').toHaveLength(0);`, + + 'tab-order-invalid': (_v) => ` +${I(2)}// Tab through the component; interactive elements (button.tab, [role="tab"]) must NOT have tabindex="-1" +${I(2)}const invalidEl = await page.evaluate(() => { +${I(3)}const preview = document.querySelector('.preview-area'); +${I(3)}if (!preview) return null; +${I(3)}const candidates = Array.from(preview.querySelectorAll('button.tab, [role="tab"], button')) as HTMLElement[]; +${I(3)}const bad = candidates.find(el => el.tabIndex === -1); +${I(3)}return bad ? (bad.tagName.toLowerCase() + (bad.className ? '.' + bad.className.trim().split(' ')[0] : '')) : null; +${I(2)}}); +${I(2)}expect(invalidEl, \`Interactive tab element must not have tabindex="-1" — WCAG 2.4.3 Focus Order\`).toBeNull();`, + + 'disabled-not-respected': (v) => ` +${I(2)}// Force-click a disabled element; expect click event does NOT fire +${I(2)}await page.evaluate(() => { +${I(3)}const el = document.querySelector('.preview-area [disabled], .preview-area [aria-disabled="true"]') as HTMLElement; +${I(3)}if (el) { +${I(4)}(window as any).__specDisabledClicked = false; +${I(4)}el.addEventListener('click', () => { (window as any).__specDisabledClicked = true; }, { once: true }); +${I(3)}} +${I(2)}}); +${I(2)}await page.locator('.preview-area [disabled], .preview-area [aria-disabled="true"]') +${I(3)}.first().click({ force: true, timeout: 2000 }).catch(() => {}); +${I(2)}await page.waitForTimeout(200); +${I(2)}const clicked = await page.evaluate(() => (window as any).__specDisabledClicked === true); +${I(2)}expect(clicked, 'Disabled element must not fire a click event even when force-clicked').toBe(false);`, +}; + +function genL2Test(component, v) { + const bodyFn = L2_BODIES[v.axe_id]; + if (!bodyFn) return null; + const title = `${component} / ${v.variant} / L2 keyboard: ${v.axe_id} (WCAG ${v.wcag})`; + return ` +${I(1)}test(${JSON.stringify(title)}, async ({ page }) => { +${I(2)}// L2 Interaction — ${v.axe_id} — WCAG ${v.wcag} +${I(2)}// Fix: ${v.fix} +${I(2)}await page.goto(COMPONENT_URL); +${I(2)}await page.waitForSelector('.preview-area', { timeout: 15000 }); +${I(2)}await page.waitForTimeout(500); +${variantBlock(v.variant, v.mode || 'light')} +${bodyFn(v)} +${I(1)}});`; +} + +// ── L3 — Props edge cases ───────────────────────────────────────────────── + +function genL3Test(component, v) { + if (L3_VALUES[v.check] === undefined) return null; + + const val = L3_VALUES[v.check]; + const desc = L3_DESCS[v.check] || v.check; + const isXSS = v.check === 'special_chars'; + const isOvfl = v.check === 'long_text'; + const title = `${component} / L3 props: ${v.check} — handles ${desc} without ${isXSS ? 'XSS' : isOvfl ? 'overflow' : 'crash'}`; + + const assertion = isXSS ? ` +${I(2)}// XSS check: no