diff --git a/.changeset/README.md b/.changeset/README.md index 5149d32..2e0343c 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -7,5 +7,15 @@ npm run changeset ``` Select only packages whose published contract changes. Documentation-only site -changes do not need a changeset. Package publishing remains disabled until the -standalone WeBaseUI repository and registry credentials are configured. +changes do not need a changeset. Pull requests that change publishable package +files are checked automatically, and releases publish the resulting versions to npm. + +The `Release` workflow creates or updates the release PR on `main`. After that PR +is merged, the same workflow versions packages and synchronizes `package-lock.json`, +then publishes both packages with npm trusted publishing, +OIDC, and provenance enabled, then installs the published versions in the registry +smoke fixture. The stable action uses `commitMode: github-api`, so it does not +depend on persisted checkout credentials. `workflow_dispatch` supports `next` +and `canary` pre-release tags; pre-release publication never runs from a local +shell. The exact npm trusted-publisher setup and provenance verification steps +live in [`docs/RELEASE_RUNBOOK.md`](../docs/RELEASE_RUNBOOK.md). diff --git a/.changeset/bright-tokens-rise.md b/.changeset/bright-tokens-rise.md new file mode 100644 index 0000000..8e974ae --- /dev/null +++ b/.changeset/bright-tokens-rise.md @@ -0,0 +1,14 @@ +--- +"@webaseui/core": minor +"@webaseui/svelte": patch +--- + +Add a documented three-layer token contract for palette, semantic, and +component values, then migrate form, choice, overlay, feedback, navigation, +selection, and action styles to shared geometry and motion tokens without +changing their rendered defaults. Accordion, Card, Divider, EmptyState, +SectionHeader, and Skeleton presentation values use the same contract. Add an +exact per-component declaration budget and separately audited media-query list +so future hardcoded-value growth requires explicit review. Tighten the example +brand theme's muted ink and hover brand colors so normal text combinations meet +WCAG 2.2 contrast requirements. diff --git a/.changeset/calm-forms-act.md b/.changeset/calm-forms-act.md new file mode 100644 index 0000000..6fb7942 --- /dev/null +++ b/.changeset/calm-forms-act.md @@ -0,0 +1,6 @@ +--- +"@webaseui/core": patch +"@webaseui/svelte": minor +--- + +Include package license files, add native form-control props and Select form participation, and make Dialog, Card, and EmptyState actions explicitly actionable. diff --git a/.changeset/clear-directions-hold.md b/.changeset/clear-directions-hold.md new file mode 100644 index 0000000..868f438 --- /dev/null +++ b/.changeset/clear-directions-hold.md @@ -0,0 +1,10 @@ +--- +"@webaseui/core": patch +"@webaseui/svelte": patch +--- + +Map semantic tokens to system colors in forced-colors mode so component +surfaces, borders, focus rings, and state colors remain distinguishable. Make +direction-sensitive component layout use logical properties, align Select and +Tooltip floating content from the anchor's inherited direction, and reverse +Tabs horizontal arrow semantics in RTL. diff --git a/.changeset/focused-collections-roam.md b/.changeset/focused-collections-roam.md new file mode 100644 index 0000000..666d942 --- /dev/null +++ b/.changeset/focused-collections-roam.md @@ -0,0 +1,7 @@ +--- +"@webaseui/svelte": patch +--- + +Unify Tabs and Accordion keyboard navigation through a tested internal roving +focus helper, preserving wrapping and Home/End behavior while normalizing +state against the latest dynamic collection. diff --git a/.changeset/locale-numbers-flow.md b/.changeset/locale-numbers-flow.md new file mode 100644 index 0000000..a6f9839 --- /dev/null +++ b/.changeset/locale-numbers-flow.md @@ -0,0 +1,7 @@ +--- +"@webaseui/svelte": minor +--- + +Add consumer-controlled numeric formatting callbacks to Accordion, Pagination, +Progress, Slider, and Textarea. Existing visual defaults remain unchanged while +localized consumers can use Intl.NumberFormat or domain-specific notation. diff --git a/.changeset/localized-copy-fits.md b/.changeset/localized-copy-fits.md new file mode 100644 index 0000000..440b294 --- /dev/null +++ b/.changeset/localized-copy-fits.md @@ -0,0 +1,7 @@ +--- +"@webaseui/svelte": patch +--- + +Expose the EmptyState kicker as a localization prop and make long labels wrap +inside alerts, empty states, tabs, form labels, links, and card actions. Mirror +directional action icons and slider progress in RTL. diff --git a/.changeset/quick-options-cycle.md b/.changeset/quick-options-cycle.md new file mode 100644 index 0000000..de716bb --- /dev/null +++ b/.changeset/quick-options-cycle.md @@ -0,0 +1,7 @@ +--- +"@webaseui/svelte": patch +--- + +Make Select typeahead refine multi-character queries, cycle repeated-character +matches, skip disabled options, and clean up its reset timer through a reusable +internal controller. diff --git a/.changeset/quiet-floating-edges-align.md b/.changeset/quiet-floating-edges-align.md new file mode 100644 index 0000000..1f010d0 --- /dev/null +++ b/.changeset/quiet-floating-edges-align.md @@ -0,0 +1,8 @@ +--- +"@webaseui/svelte": patch +--- + +Share manual-popover synchronization and collision-aware floating positioning +between Select and Tooltip. Overlays now flip and shift within the visual +viewport, follow nested scrolling and resize changes, keep Select trigger width, +and retain a fixed-position fallback when the Popover API is unavailable. diff --git a/.changeset/steady-overlays-lock.md b/.changeset/steady-overlays-lock.md new file mode 100644 index 0000000..0e38304 --- /dev/null +++ b/.changeset/steady-overlays-lock.md @@ -0,0 +1,7 @@ +--- +"@webaseui/svelte": patch +--- + +Make Dialog body scroll locking safe for nested overlays, restore opener focus +consistently across Chromium, Firefox, and WebKit, and reuse a shared, +cleaned-up pointer-outside listener for Select dismissal. diff --git a/.changeset/tactile-targets-rest.md b/.changeset/tactile-targets-rest.md new file mode 100644 index 0000000..6750c21 --- /dev/null +++ b/.changeset/tactile-targets-rest.md @@ -0,0 +1,8 @@ +--- +"@webaseui/core": minor +"@webaseui/svelte": patch +--- + +Add a public minimum interactive target token, reduced-motion coverage for +Field and Pagination, touch-safe hover behavior, and 24px hit areas for +choice controls, sliders, navigation links, breadcrumbs, and card actions. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..4e26fd7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# Default review responsibility for the design-system repository. +* @willxue + +# Package and release changes need the repository maintainer's review. +/packages/ @willxue +/.github/ @willxue +/DEVELOPMENT_PLAN.md @willxue diff --git a/.github/ISSUE_TEMPLATE/accessibility.md b/.github/ISSUE_TEMPLATE/accessibility.md new file mode 100644 index 0000000..acf744e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/accessibility.md @@ -0,0 +1,27 @@ +--- +name: Accessibility issue +about: Report an accessibility barrier or propose an accessibility improvement +title: "[a11y] " +labels: "accessibility" +assignees: "" +--- + +## Barrier + +Describe what prevents access or makes the interaction unclear. + +## Affected surface + +- Component or documentation route: +- Package and version: +- Assistive technology, browser, and operating system: +- Input mode (keyboard, touch, pointer, voice, or screen reader): + +## Reproduction and expected behavior + +Provide steps, the current announcement or focus result, and the expected +result based on the relevant WAI-ARIA pattern or WCAG criterion. + +## Evidence + + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..eed6639 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Report a reproducible problem in a published component or the documentation +title: "" +labels: "bug" +assignees: "" +--- + +## What happened? + +Describe the observed behavior and the expected behavior. + +## Reproduction + +- Package and version: +- Svelte, Node, browser, and operating system: +- Minimal reproduction or repository: +- Steps to reproduce: + +## Accessibility and compatibility + +- Does this affect keyboard, focus, screen-reader, reduced-motion, RTL, forced-colors, touch, or zoom behavior? +- Which browser engines reproduce it? + +## Evidence + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3b81cc7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/WeOpen/WeBaseUI/security/advisories/new + about: Use GitHub private vulnerability reporting. Do not disclose security issues publicly. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..4b2af35 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,30 @@ +--- +name: Component or API proposal +about: Propose a user-facing capability with a concrete consumer need +title: "" +labels: "enhancement" +assignees: "" +--- + +## Consumer problem + +Which application workflow is blocked, and who is affected? + +## Proposed API + +Describe the component, props, events, snippets, native attributes, and token +surface. Include a small usage example when possible. + +## Alternatives and scope + +What existing component or composition was considered? What is explicitly out +of scope for the first version? + +## Quality and maintenance + +- Keyboard and ARIA pattern: +- Reduced-motion and equivalent-input behavior: +- RTL, forced-colors, and localization considerations: +- SSR/hydration considerations: +- Expected package-size or dependency impact: +- Is a Changeset and migration note required? diff --git a/.github/ISSUE_TEMPLATE/release_review.md b/.github/ISSUE_TEMPLATE/release_review.md new file mode 100644 index 0000000..a5a0202 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release_review.md @@ -0,0 +1,43 @@ +--- +name: Minor release review +about: Record adoption, defects, accessibility, compatibility, and package-size evidence after a minor release +title: "[release review] " +labels: "release" +assignees: "" +--- + +## Release identity + +- Package versions: +- Release commit and workflow run: +- npm provenance attestations: +- Registry smoke result: + +## Quality evidence + +- Blocking CI, browser, visual, SSR/hydration, consumer, audit, and size runs: +- Manual VoiceOver/Safari audit record: +- Manual NVDA/Firefox or Chrome audit record: +- Open P0 defects: +- Open P1 defects: + +## Adoption review + +- External consumers checked or upgraded: +- API escape hatches or component bypasses: +- Theme overrides that required copied component CSS: +- SSR, hydration, routing, or form-integration issues: +- Missing capability backed by a real workflow: + +## Performance and compatibility + +- Package and consumer bundle changes from the previous baseline: +- Changes above the 5% review threshold and rationale: +- Weekly Node, Svelte, TypeScript, Vite, and browser warnings: +- Dependency additions, removals, or license changes: + +## Follow-up + +List each accepted action with an owner and target milestone. Link rejected or +deferred requests to the API, accessibility, maintenance-cost, or consumer +evidence that supports the decision. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2286e91 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## Change summary + + + +## Quality gate + +- [ ] Public API, native attribute forwarding, and controlled/bindable state are unchanged or documented. +- [ ] Keyboard, focus, screen-reader, reduced-motion, and equivalent-input behavior were checked for affected components. +- [ ] Tests cover the changed behavior at the appropriate unit, browser, SSR, or visual layer. +- [ ] Documentation and migration notes are updated when the public contract changes. +- [ ] A Changeset is included for published package changes, or the omission is intentional and explained. +- [ ] Package-size and visual-baseline changes are reviewed and explained. +- [ ] `npm run check`, `npm run check:consumer`, and relevant browser/visual checks pass. + +## Verification notes + + diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 366acf5..9b4b5d6 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -22,11 +22,65 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22.13 cache: npm + - run: npm install --global npm@11.5.1 - run: npm ci + - run: npm run check:changeset - run: npm audit --audit-level=high - run: npm run check - run: npm run check:consumer + + browser: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + browser: + - chromium + - firefox + - webkit + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.13 + cache: npm + - run: npm install --global npm@11.5.1 + - run: npm ci + - run: npx playwright install --with-deps ${{ matrix.browser }} + - run: npm run test:browser:project -- --project=${{ matrix.browser }} + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-${{ matrix.browser }} + path: test-results + retention-days: 7 + + visual: + runs-on: macos-14 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.13 + cache: npm + - run: npm install --global npm@11.5.1 + - run: npm ci + - run: npx playwright install chromium + - run: npm run test:visual + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-visual + path: test-results + retention-days: 7 diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml new file mode 100644 index 0000000..369bb65 --- /dev/null +++ b/.github/workflows/compatibility.yml @@ -0,0 +1,65 @@ +name: Compatibility warning + +on: + schedule: + - cron: '17 3 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: compatibility-${{ github.ref }} + cancel-in-progress: true + +jobs: + node-current-lts: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + - run: npm install --global npm@11.5.1 + - run: npm ci + - run: npm run check + - run: npm run check:consumer + + ecosystem-latest: + continue-on-error: true + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + - run: npm install --global npm@11.5.1 + - run: npm ci + - run: npm run build + - run: node scripts/test-webaseui-consumer.mjs --include-latest + + browser-latest: + continue-on-error: true + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + - run: npm install --global npm@11.5.1 + - run: npm ci + - run: npm install --no-save @playwright/test@latest + - run: npx playwright install --with-deps chromium firefox webkit + - run: npm run test:browser diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..550f607 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,124 @@ +name: Release + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + channel: + description: Release channel + required: true + default: stable + type: choice + options: + - stable + - next + - canary + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + quality: + if: github.event_name == 'push' || inputs.channel != '' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.13 + cache: npm + - run: npm install --global npm@11.5.1 + - run: npm ci + - run: npx playwright install --with-deps chromium firefox webkit + - run: npm run check:changeset + - run: npm audit --audit-level=high + - run: npm run check + - run: npm run check:consumer + - run: npm run test:browser + + visual: + if: github.event_name == 'push' || inputs.channel != '' + needs: quality + runs-on: macos-14 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.13 + cache: npm + - run: npm install --global npm@11.5.1 + - run: npm ci + - run: npx playwright install chromium + - run: npm run test:visual + + stable: + if: github.event_name == 'push' || inputs.channel == 'stable' + needs: [quality, visual] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.13 + cache: npm + - run: npm install --global npm@11.5.1 + - run: npm ci + - id: release + uses: changesets/action@52025003bf4794281597cb5b5e566485d9473389 # v1.5.0 + with: + version: npm run version-packages + publish: npm run release:ci + title: 'chore: release packages' + commit: 'chore: release packages' + commitMode: github-api + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_CONFIG_PROVENANCE: true + NPM_CONFIG_ACCESS: public + - if: steps.release.outputs.published == 'true' + run: npm run check:registry -- --tag=latest --require-provenance + + prerelease: + if: github.event_name == 'workflow_dispatch' && inputs.channel != 'stable' + needs: [quality, visual] + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.13 + cache: npm + - run: npm install --global npm@11.5.1 + - run: npm ci + - run: node scripts/release-channel.mjs "${{ inputs.channel }}" --publish + env: + CI: true + NPM_CONFIG_PROVENANCE: true + NPM_CONFIG_ACCESS: public + - run: npm run check:registry -- --tag=${{ inputs.channel }} --require-provenance diff --git a/.gitignore b/.gitignore index 5659d18..228fbbe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules/ dist/ .svelte-kit/ +playwright-report/ +test-results/ +coverage/ .DS_Store *.log *.tgz diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07e8a8b..cd319e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,31 @@ Before opening a pull request: ```sh npm run check npm run check:consumer +npm run test:unit +npm run test:browser +npm run test:visual +npm run check:size +npm run check:support +npm run check:changeset npm audit --audit-level=high ``` +The unit command includes the repository's 90% V8 coverage gate for core state utilities. Coverage drops fail locally and in CI through `npm run check`. + +Visual baselines live beside the Playwright visual spec. Review intentional design changes before running `npm run test:visual:update`, then include the updated PNG files in the same pull request as the component change. + Add a Changeset for changes that affect a published package. Documentation-only and repository-maintenance changes do not require one. + +Stable releases are prepared and published by `.github/workflows/release.yml`. +Do not run `npm publish` locally. Use the workflow's manual `next` or `canary` +channel only when a pre-release is needed; it uses OIDC trusted publishing and +finishes with a registry install/build smoke test that requires SLSA provenance. + +The default review owner is recorded in `.github/CODEOWNERS`. Public API, +token, accessibility, release, and support-policy changes require that review +or an explicitly documented maintainer handoff. + +Every release candidate also requires the two manual assistive-technology passes +in `docs/SCREEN_READER_AUDIT.md`. After each minor release, open a release review +from the repository issue template and record adoption, P0/P1 defects, package +size, compatibility warnings, and follow-up ownership. diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md new file mode 100644 index 0000000..6004274 --- /dev/null +++ b/DEVELOPMENT_PLAN.md @@ -0,0 +1,545 @@ +# WeBaseUI 成熟化开发计划 + +> 状态:In progress +> +> 基线日期:2026-08-06 +> 适用范围:`@webaseui/core`、`@webaseui/svelte`、文档站、测试与发布工具链 + +## 执行进度 + +截至 2026-08-17,0.4 的公共 API 契约、跨浏览器行为、SSR/水合、自动无障碍和组件参考基线已完成。0.5 的三层 Token、全部 28 个公开组件迁移、契约校验、ADR、品牌主题、主题示例和视觉回归已完成;组件声明中的直接 `px/em/rem/ms/s/deg` 字面量精确预算从本轮开始时的 281 降至 0,三个无法使用 CSS 变量的响应式断点单独受 CI 清单约束。视觉回归共 49 张基线,覆盖 28 个 light specimen、dark/brand/mobile 代表场景、focus/open/hover/disabled/error/loading、Select/Tooltip viewport collision、RTL/forced-colors,以及 CJK/阿拉伯语/伪本地化 200% 重排。包体积基线已接入 5% 增长门槛;Vitest 对每个核心状态工具执行 90% V8 覆盖率门槛。0.6 的 overlay/focus 前置切片已完成,Dialog、Select 和 Tooltip 复用内部基础设施。0.7.1/0.7.2 已覆盖 RTL、forced-colors、长文本/数字本地化、reduced-motion、coarse pointer 和 24px 最小目标尺寸,并由三浏览器、axe 和视觉基线验证。0.7.3 已建立 Changesets release PR、trusted publishing/OIDC、stable/next/canary channel 和 registry smoke;真实 npm trust policy 与首次 OIDC 发布仍需在外部仓库/npm 设置中完成。0.7.4 已完成 xue 的 `0.2.0 → 0.3.2` 升级演练、Fig 正式 registry 内容站试用和外部 CRUD 候选 tarball 表单 action 试用,覆盖内容/导航与表单/CRUD 两种画像。1.0 RC 的 Node/Svelte/TypeScript/浏览器支持窗口、security、PR/issue/CODEOWNERS、发布复盘模板和人工屏幕阅读器审计矩阵已经落地;实际 VoiceOver/NVDA 审计仍待执行。 + +## 1. 目标 + +WeBaseUI 已经具备一个可发布组件库的基本形态,但距离可以长期维护、稳定升级并被多个真实项目采用的成熟组件库,还需要补齐公共契约、无障碍、测试矩阵、主题扩展、文档、发布自动化和治理体系。 + +本计划的目标不是单纯增加组件数量,而是让每一个公开组件都满足以下标准: + +- API 一致、可组合、可升级,并有明确的兼容性边界; +- 满足 WCAG 2.2 AA 和对应 WAI-ARIA APG 交互模式; +- 在服务端渲染、客户端水合、键盘、触摸和主流浏览器中行为一致; +- 有完整的组件参考、示例、主题说明和迁移指引; +- 发布过程可重复、可审计,包体积和依赖变化可被发现; +- 新组件不会绕过统一的设计、测试和发布门槛。 + +规划里程碑以 `@webaseui/svelte` 版本为主要标记。`@webaseui/core` 继续独立版本化,不要求两个包同步升级。 + +## 2. 当前基线 + +截至基线日期,仓库已经具备: + +- `@webaseui/core`:框架无关的 CSS token 和浅色、深色主题; +- `@webaseui/svelte`:28 个 Svelte 5 组件以及包根级 Props 类型导出; +- Svelte 5.20 最低版本和当前版本两套消费者打包验证; +- Changesets、语义化版本和弃用策略; +- 类型检查、文档构建、包内容检查、tree-shaking 检查和 npm audit; +- 基于 Playwright 的 Chromium、Firefox、WebKit 行为测试、SSR/水合 fixture 和 46 组视觉基准; +- `docs/package-size-baseline.json` 记录 core/svelte tarball、组件源文件和真实消费者 bundle 的 gzip 基线; +- 独立文档应用和 Cloudflare Pages 部署保护; +- 表单原生属性透传、Dialog、Select 等关键交互的浏览器级验证。 + +现阶段的主要缺口如下: + +| 领域 | 当前状态 | 主要风险 | 优先级 | +| --- | --- | --- | --- | +| 公共 API | 已有 Props 类型和版本策略,但缺少统一 API 规范与逐组件契约清单 | 组件之间命名、事件、受控状态和组合方式逐渐分叉 | P0 | +| 无障碍 | 源码已有 ARIA、键盘和 reduced-motion 处理,但没有自动化审计和人工测试矩阵 | 回归无法在合并前稳定发现 | P0 | +| 测试 | 已有 Vitest 纯函数层、三浏览器、SSR/水合、axe,以及覆盖关键交互类别的 46 组视觉基准 | 计时器和更多复杂组件状态边界仍主要依赖浏览器测试发现 | P0 | +| 文档 | 文档站是单页目录和部分示例,没有逐组件 API 参考 | 使用者需要读源码才能确认 props、默认值和交互 | P0 | +| Token 与主题 | 已有颜色、字体、阴影和动效变量,组件内仍存在大量硬编码尺寸 | 自定义主题、密度和品牌扩展成本高 | P1 | +| 组件架构 | Select 等复杂组件独立承担行为逻辑,缺少共享 overlay、focus、dismiss 基础设施 | 新增 Menu、Popover、Combobox 时重复实现高风险逻辑 | P1 | +| 发布 | 有本地发布命令和 Changeset 检查,没有自动化 npm 发布和发布后验证 | 人工操作容易遗漏 provenance、tag 或烟雾测试 | P1 | +| 兼容性 | 已验证 Svelte 最低/当前版本,没有正式浏览器、Node、SSR 和 TypeScript 支持策略 | 用户无法判断升级和运行环境边界 | P1 | +| 治理 | 有贡献说明,缺少组件准入标准、RFC/ADR、维护责任和安全策略 | 组件数量增长后质量和决策难以保持一致 | P2 | + +## 3. 成熟度原则 + +后续工作遵循以下顺序: + +```text +公共契约与质量门槛 + ↓ +文档、主题与共享行为基础设施 + ↓ +关键组件能力补齐 + ↓ +真实消费者验证与生态扩展 + ↓ +1.0 稳定承诺 +``` + +### 3.1 先稳定契约,再扩展框架 + +React 适配器不是 Svelte 1.0 的前置条件。应先稳定框架无关 token、组件行为规范、无障碍规则和测试用例,再评估 `@webaseui/react`。否则会复制尚未稳定的 API,并使修复成本翻倍。 + +### 3.2 复杂组件先建设共享能力 + +Popover、Menu、Combobox、Drawer 等组件依赖相同的浮层、焦点、关闭、滚动锁定和层级管理。共享基础设施未完成前,不批量新增复杂浮层组件。 + +### 3.3 文档是公共契约的一部分 + +公开 props、默认值、事件、绑定、键盘行为、ARIA 关系、token 和迁移方式都必须在文档中可查。未文档化的行为不得作为稳定 API 承诺。 + +## 4. 路线图总览 + +| 里程碑 | 主题 | 建议周期 | 结果 | +| --- | --- | --- | --- | +| 0.4 | 契约与可信质量基线 | 2–3 周 | API 规则、自动无障碍、跨浏览器、SSR/水合和发布门槛成型 | +| 0.5 | 文档与主题系统 | 3–4 周 | 每个组件有完整参考,token 可扩展,视觉回归进入 CI | +| 0.6 | 关键能力补齐 | 4–6 周 | 表单组合、overlay 基础设施和高频组件达到成熟定义 | +| 0.7 | 兼容性与采用验证 | 2–3 周 | 国际化、RTL、高对比度、真实消费者和发布自动化完成 | +| 1.0 RC | 稳定性审计 | 2 周 | 冻结公共契约,完成迁移、性能、安全和支持审计 | + +周期按 1 名主要维护者加 1 名评审者估算。若资源更少,应保持里程碑顺序,不通过降低验收标准来压缩周期。 + +## 5. 0.4:契约与可信质量基线 + +### 5.1 建立组件 API 规范 + +工作项: + +- 为所有公开组件建立 API 清单:props、默认值、绑定、事件、snippet、根元素和原生属性透传; +- 统一命名规范,包括 `open`、`value`、`selected`、`checked`、`disabled`、`loading`、`on*` 回调和原生事件; +- 定义受控、非受控和 `$bindable` 状态的行为,特别是外部传入非法值、表单 reset 和程序化更新; +- 定义字符串便利属性与 snippet 组合点的共存规则; +- 明确 root props 与 `inputProps`、`selectProps`、`textareaProps` 等内部原生元素 props 的优先级; +- 将可访问名称和界面文案全部设计为可覆盖 API,移除只能使用英文默认文案的公共限制; +- 为 Tabs、Accordion、Pagination 等集合组件定义空数组、越界索引、动态增删和重复值行为; +- 为公共 Props 添加 JSDoc,作为后续 API 文档生成来源。 + +验收标准: + +- 28 个公开组件全部进入 API 清单; +- 同类状态和事件不再出现无说明的命名差异; +- 每个可绑定 prop 至少有一条程序化更新测试; +- 每个表单控件验证提交、reset、required、disabled、name、form 关联和原生 change/input 行为; +- 不兼容调整均有 Changeset、迁移示例和至少一个版本的弃用窗口。 + +### 5.2 建立测试金字塔 + +实施进度(2026-08-16):已接入 `vitest run` 与 V8 coverage,并覆盖集合导航边界(含动态增删后的索引重算)、overlay 清理与嵌套滚动锁、文档契约解析、Toast 自动关闭的启动/暂停/恢复/取消计时边界、Select 受控值回退和 typeahead 匹配状态,以及 Dialog 受控状态与原生生命周期同步。计时、选择、Dialog 生命周期、roving focus 与 typeahead 已下沉为不公开的内部纯工具;核心状态工具的 statements、branches、functions、lines 均设 90% CI 门槛。Tabs 与 Accordion 复用同一套 Home/End、方向键、循环和动态集合边界;Select 复用独立的短时查询控制器,覆盖多字符细化、重复字符循环、disabled 跳过、动态集合与卸载清理。Playwright 同步验证真实 DOM 中的复合组件焦点移动、Tabs 自动激活、集合收缩和 Select 三浏览器键盘匹配;Toast 与 Dialog 的计时和跨浏览器焦点恢复继续由浏览器层验证。 + +工作项: + +- 引入 Vitest,覆盖纯函数、状态边界、计时器和复杂组件内部状态机; +- 保留 Playwright 作为真实浏览器交互验证,按组件而不是按历史 bug 组织测试; +- 为 Chromium、Firefox、WebKit 建立 CI 矩阵; +- 增加 SSR 渲染和客户端水合 fixture,阻止模块加载阶段访问 `window`、`document`; +- 增加最低 Svelte、当前 Svelte 和下一版本预警任务; +- 将失败 trace、截图和测试报告作为 CI artifact 保存。 + +测试优先级: + +1. Select、Dialog、Tabs、Accordion、Tooltip、Toast; +2. Field、Textarea、Check、Radio、Switch、Slider、Button; +3. Pagination、Breadcrumbs、Link、Tag; +4. Alert、Progress、Loader、Skeleton 和纯展示组件。 + +验收标准: + +- 所有交互组件至少覆盖鼠标、键盘和程序化状态更新; +- Chromium、Firefox、WebKit 在 CI 中全部通过; +- SSR 和水合过程无异常、无 ID 不一致、无 hydration warning; +- 最低支持版本的消费者 fixture 持续构建通过; +- 不以单纯代码覆盖率替代行为覆盖,但核心状态工具的分支覆盖率达到 90%。 + +### 5.3 建立自动无障碍门槛 + +工作项: + +- 在组件标准状态、disabled、error、loading、open 等状态接入 axe 自动扫描; +- 为复合组件建立 WAI-ARIA APG 键盘行为表; +- 验证焦点进入、循环、恢复和 Escape 关闭行为; +- 检查 200%/400% 缩放、触摸目标、可见焦点和横向溢出; +- 建立 VoiceOver + Safari、NVDA + Firefox/Chrome 的人工抽查表; +- 将无障碍行为纳入版本化公共契约。 + +验收标准: + +- 标准组件页面无 axe serious/critical 问题; +- 所有交互组件都有文档化键盘行为; +- Dialog 关闭后焦点可恢复,Tooltip 可用 Escape 消失,Toast 计时可暂停; +- 每个候选发布版本完成至少一轮屏幕阅读器抽查。 + +### 5.4 记录关键架构决策 + +实施进度(2026-08-08):已完成 token 分层 ADR 和 platform-first overlay/focus ADR。后者确定原生 `` top layer、引用计数滚动锁、共享 pointer-outside、内部 primitive 不公开,以及 Portal/floating position 按现有组件需求逐步引入的边界。 + +创建 ADR,至少覆盖: + +- 自研交互基础设施还是引入现有 Svelte headless primitives; +- 文档 API 元数据从源码、类型声明还是独立 schema 生成; +- token 分层和未来多主题的兼容策略; +- overlay portal、焦点管理和 z-index 的统一方案; +- React 适配器的启动条件,而不是启动日期。 + +## 6. 0.5:文档与主题系统 + +### 6.1 将文档站升级为组件参考 + +#### 文档站设计硬规则 + +文档站重构必须遵守以下规则,并将规则纳入设计评审与自动化检查: + +1. **图标系统**:界面图标统一使用 Lucide。优先通过 `WeBaseIcon` 和其他基于 Lucide 的 WeBaseUI 组件输出,禁止手写 SVG、混用其他图标库或使用 Unicode 符号代替图标。品牌 Logo 不属于界面图标,可继续使用 `logo.svg`。 +2. **零表情符号**:页面文案、按钮、导航、状态、代码示例和装饰元素中禁止使用表情符号。需要表达状态或动作时使用 Lucide 图标和明确文字。 +3. **设计品质**:视觉完成度对标 Awwwards、FWA、CSS Design Awards 的每日最佳网站。排版、构图、转场、交互反馈、响应式细节和内容节奏均按精品发布标准验收。 +4. **创意自由度**:将浏览器作为交互式艺术画布,允许使用非对称网格、实验性排版、滚动叙事、动态遮罩和物理反馈,但不得影响文档信息架构、搜索、复制代码和键盘操作。 +5. **沉浸式整体体验**:视觉、内容、组件演示和动效必须服务同一叙事,不拼贴孤立效果。高级渲染按需加载,并保持 SSR、性能和无障碍降级路径。 +6. **动效约束**:每个动画必须承担层级、叙事、反馈或状态转换职责;只动画 `transform` 和 `opacity`,完整支持 `prefers-reduced-motion`,禁止以持续滚动监听驱动框架状态。 +7. **质量优先**:先锋视觉不能覆盖组件本身。真实组件 specimen、API、可访问性和复制体验始终是页面主角。 + +建议设计参数:`DESIGN_VARIANCE: 9`、`MOTION_INTENSITY: 8`、`VISUAL_DENSITY: 4`。页面采用统一主题语言和单一主强调色,浅色与深色模式保持相同的信息层级。 + +每个组件页面至少包含: + +- 用途与不适用场景; +- 安装和最小示例; +- props、默认值、类型、绑定和事件; +- snippet、原生属性和表单集成方式; +- 所有视觉状态与主题状态; +- 键盘交互、ARIA 结构和屏幕阅读器注意事项; +- 常见错误、迁移说明和相关组件; +- 可复制且由 CI 编译的示例。 + +同时补充: + +- Getting Started; +- 主题定制指南; +- 表单集成指南; +- SSR/水合指南; +- 无障碍原则; +- 版本、弃用和升级指南; +- 组件选择指南,例如 Link 与 Button、Alert 与 Toast、Check 与 Switch 的区别; +- 全局搜索、侧边导航和移动端文档体验。 + +验收标准: + +- 28 个公开组件的 API 覆盖率为 100%; +- 所有示例从包根导入,并参与类型检查或浏览器测试; +- README 可在两次点击内到达任意组件参考; +- 文档展示的包版本从 package manifest 派生,不手工维护; +- 文档链接检查和示例编译进入 CI。 + +### 6.2 重构 token 层级 + +实施进度(2026-08-09):三层 token、兼容别名、light/dark palette 切换和 `@webaseui/core/brand-theme.css` 已完成;浏览器 fixture 现在验证品牌语义色切换和组件几何不变,并有独立视觉基准。全部 28 个公开组件已迁移到共享或组件级 geometry/type/motion token;`docs/component-style-literal-baseline.json` 对声明实施零字面量预算,并单独锁定 EmptyState、Pagination、SectionHeader 的三个响应式断点。硬编码清理工作项完成,后续新增公共 token 仍必须证明复用价值或具体主题需求。 + +建议形成三层 token: + +```text +基础值:颜色阶、字体、尺寸、时间 + ↓ +语义值:canvas、surface、ink、border、brand、danger + ↓ +组件值:button-height、dialog-width、control-radius、overlay-z-index +``` + +工作项: + +- 补齐 spacing、size、radius、typography、line-height、z-index、motion 和 control height token; +- 统计并逐步替换组件内重复硬编码值; +- 分清 `tokens.css` 与 `theme.css` 职责,避免两个入口语义模糊; +- 定义品牌主题覆盖入口和主题最小必需 token; +- 保留现有 `--webase-*` 公共变量的兼容迁移层; +- 增加 token 完整性、循环引用、未定义引用和主题对比度测试; +- 评估 CSS cascade layer,但只有在不破坏消费者覆盖顺序时启用。 + +验收标准: + +- 组件源码不再重复定义同一类交互尺寸和层级值; +- 浅色、深色和一个示例品牌主题使用同一语义 token 契约; +- 主题切换无闪烁、无布局偏移; +- 公共 token 有说明、默认值、用途和弃用策略; +- 新主题无需复制组件 CSS。 + +### 6.3 建立视觉回归 + +实施进度(2026-08-09):已建立独立 `visual-chromium` 项目、28 个公开组件的 light specimen 基准、代表性 dark/brand/mobile 基准,以及 13 个交互状态基准;新增专用 fixture 覆盖 Button/IconButton hover、disabled、loading、pressed,Field/Textarea/Select error 与 disabled,Check/Radio/Switch/Slider disabled,Alert/Toast error、Alert warning、Pagination hover/disabled、Tabs/Accordion hover 和 Toast open。共 44 组截图由 macOS 固定环境在 CI 中阻止未批准差异,focus、hover、open、disabled、error、loading 验收类别已覆盖。后续按真实变更补充完整主题/viewport 组合,不预先制造无差异截图。 + +工作项: + +- 为每个组件建立稳定 specimen; +- 覆盖 light/dark、desktop/mobile 和关键交互状态; +- 固定字体、动画、时区和测试数据,降低截图波动; +- 对设计变更使用显式基准图更新流程; +- 设置变更面积阈值和人工批准要求。 + +验收标准: + +- 28 个组件均有至少一组视觉基准; +- 关键交互组件覆盖 focus、hover、open、disabled、error 和 loading 状态; +- 未批准的视觉变化无法合并。 + +## 7. 0.6:关键能力补齐 + +### 7.1 先建设内部行为基础设施 + +实施进度(2026-08-16):新增内部 overlay、Dialog lifecycle、roving-focus、typeahead、manual popover 与 floating-position 工具,Dialog 使用按 document 引用计数的 body scroll lock,并将受控 open 状态与原生 `showModal()`/`close()` 同步集中到可单测边界;Select 使用共享 pointer-outside 注册、短时查询控制器与浮层定位,Tooltip 复用同一套 Popover/碰撞/auto-update 生命周期;Tabs 与 Accordion 复用同一套水平/垂直方向键、Home/End、循环和动态集合焦点解析。浮层优先使用 `popover="manual"` top layer,在不支持的浏览器回退到 fixed 定位,支持 preferred side 翻转、视觉 viewport 偏移、水平避让、Select 宽度匹配、available-height 约束,以及 scroll/resize/visualViewport/ResizeObserver 更新。Vitest 覆盖嵌套释放、幂等清理、外部目标判断、Dialog 生命周期、roving focus、typeahead 与 floating/popover 的边界;Playwright 在 Chromium、Firefox、WebKit 覆盖真实外部点击关闭、Dialog 滚动锁恢复、原生焦点恢复、复合组件动态收缩、Select typeahead、嵌套滚动容器和 viewport edge collision。按 ADR 0002,Portal、Focus Scope 和 Presence 只在现有组件出现无法由原生 dialog/popover 解决的需求时引入;当前没有相应消费者需求,不是 1.0 阻塞项。 + +建议建立非公开或低层级 primitives: + +- Portal; +- Dismissable Layer; +- Focus Scope / focus restore; +- Scroll Lock; +- Presence / transition lifecycle; +- Roving Focus; +- Typeahead; +- Floating position 和 collision detection; +- Stable collection 与 item registration。 + +这些能力必须先服务现有 Dialog、Select、Tabs、Accordion 和 Tooltip,再用于新组件,以证明抽象不是为未来假设设计。 + +验收标准: + +- 现有复杂组件复用共享基础设施后行为不回退; +- 基础设施具备独立单元测试和浏览器测试; +- 嵌套 overlay、滚动容器、移动端 viewport 和 SSR 场景均有验证; +- 内部 primitives 默认不作为 1.0 公共 API 暴露。 + +### 7.2 补齐高频组合能力 + +第一批建议组件: + +- `WeBaseInput`:把输入控件与当前带 label 的 Field 模式解耦; +- `WeBaseFormField`:统一 label、description、error、required 和 control 关联; +- `WeBaseRadioGroup` 与 `WeBaseCheckboxGroup`:提供组级 label、error、方向和键盘语义; +- `WeBasePopover`; +- `WeBaseMenu` / `WeBaseDropdownMenu`; +- `WeBaseCombobox`; +- `WeBaseDrawer`。 + +第二批候选组件仅在有真实消费者需求时进入计划: + +- Avatar、Table、Stepper、Command Palette; +- Date Picker、Data Grid、Tree View、Rich Text Editor。 + +Date Picker 和 Data Grid 不应作为早期“组件数量”目标,它们需要更高的国际化、键盘、虚拟化和维护投入。 + +### 7.3 新组件准入标准 + +每个新组件必须同时具备: + +- 明确的用户场景和至少一个真实消费者; +- API 与交互规格; +- light/dark/disabled/error/loading 等适用状态; +- 单元、浏览器、无障碍和视觉回归测试; +- 逐组件文档和最小使用示例; +- tree-shaking、SSR 和最低版本消费者验证; +- Changeset 和变更日志; +- 维护者与后续升级责任。 + +不满足以上条件的组件保留在实验区,不进入包根稳定导出。 + +## 8. 0.7:兼容性、发布与采用验证 + +### 8.1 国际化与布局方向 + +实施进度(2026-08-16):已完成 RTL 基础方向切片、第一组长文本重排验证和现有数字界面的 locale hook。Tabs 从根节点 computed direction 推导水平 roving-focus 方向;Select 与共享 floating-position 将 `start/end` 按锚点 computed direction 解释,触发器、选项、选择标记和多个方向敏感组件改用 CSS 逻辑属性。EmptyState kicker 进入可覆盖 API;Alert、EmptyState、Tabs、Field、Textarea、Link、Card、Select、Pagination 等长文案在窄视口使用逻辑尺寸、换行或窄屏收缩策略。Accordion、Pagination、Progress、Slider、Textarea 分别通过 `formatIndex`、`formatPage`、`formatValue` 和 `formatCount` 将可见数字格式交给消费者 locale,原生数值与 ARIA range 保持语言无关。Chromium、Firefox、WebKit 浏览器测试验证 RTL、CJK、阿拉伯语、伪本地化、`Intl.NumberFormat('ar-EG')` 和 200% 重排,视觉基线锁定对应层级。仓库尚无日期类组件;真实文档 specimen 和后续新增组件的 locale 审计仍需持续执行。 + +工作项: + +- 所有内置文案允许覆盖; +- 对外暴露格式化和 label 生成钩子,而不是在组件内部拼接英文; +- 支持 `dir="rtl"`,检查方向图标、键盘方向、浮层定位和布局; +- 验证长文本、CJK、阿拉伯语和伪本地化; +- 对日期、数字等复杂格式使用消费者 locale,不内置固定格式。 + +验收标准: + +- RTL 文档 specimen 和视觉回归通过; +- 组件不依赖英文字符串完成可访问名称; +- 200% 文本缩放和长标签不造成信息丢失或水平滚动。 + +### 8.2 高对比度、动效和输入方式 + +实施进度(2026-08-17):`@webaseui/core` 已增加 `forced-colors: active` 语义 token fallback,使用 `Canvas`、`CanvasText`、`LinkText`、`Highlight` 等系统颜色保留表面、边界、状态和焦点含义;Chromium、Firefox、WebKit fixture 验证 token 与可见焦点,axe 在 forced-colors 下无 serious/critical 问题,独立视觉基线锁定高对比度层级。0.7.2 已完成 reduced-motion、触控/coarse pointer、目标尺寸和语义色彩组合审计:所有含 animation/transition 的公开组件由 token 检查强制声明 reduced-motion fallback;纯 hover 样式限制在 fine-pointer 设备,selected/open/pressed 状态保持独立;Check、Radio、Slider、Breadcrumb、导航链接和 Card action 使用 `--webase-interactive-target-min`,默认 24px,Slider 增加 `touch-action: pan-y` 并保留原生键盘步进作为拖动等价操作;三浏览器测试覆盖 reduced-motion、目标尺寸和 coarse pointer;`check:contrast` 对 light/dark/brand 的正文与非文本语义色组合分别执行 4.5:1/3:1 门槛。当前 RC 剩余项是人工屏幕阅读器抽查;未来新增复杂拖动组件仍必须重新审阅等价操作。 + +工作项: + +- 全面覆盖 `prefers-reduced-motion`; +- 增加 `forced-colors` 模式; +- 检查键盘、鼠标、触控和 coarse pointer; +- 为拖动、hover 或颜色表达提供等价替代; +- 建立可访问色彩组合自动校验。 + +验收标准: + +- 所有动画组件均能在 reduced-motion 下移除非必要运动; +- Windows High Contrast 下焦点、边界和状态仍可识别; +- 交互目标满足 WCAG 2.2 目标尺寸要求或具备等价间距。 + +### 8.3 自动化发布 + +实施进度(2026-08-16):已新增 `.github/workflows/release.yml`。稳定发布先在 Ubuntu/macOS 运行完整 package、consumer、三浏览器和视觉门槛,再由 Changesets action 创建或更新 release PR;stable checkout 关闭 persisted credentials,并显式使用 `commitMode: github-api` 通过 `GITHUB_TOKEN` API 写入 release PR/commit/tag,避免默认 Git CLI push 失败。合并后通过 `id-token: write`、npm 11.5.1、`NPM_CONFIG_PROVENANCE=true` 和 `NPM_CONFIG_ACCESS=public` 使用 trusted publishing/OIDC 发布,不保存长寿命 npm token。稳定发布成功后执行 `scripts/registry-smoke.mjs`,从 `latest` registry dist-tag 安装两个包并构建真实 consumer;手动 workflow dispatch 支持 `next`/`canary`,由 `scripts/release-channel.mjs` 进入 pre-release 模式并按独立 dist-tag 发布。真实 npm trust policy 与首次发布仍需在仓库设置中完成一次性配置。 + +工作项: + +- 使用 Changesets 自动维护 release PR; +- 使用 npm trusted publishing/OIDC 和 provenance 发布; +- 发布前运行完整质量门槛并检查 tarball 内容; +- 发布后从 npm registry 安装并运行烟雾测试; +- 支持 `next` 或 `canary` 预发布 channel; +- 为 `@webaseui/core` 和 `@webaseui/svelte` 都维护 changelog; +- 自动同步文档站版本和对应版本文档。 + +验收标准: + +- 正式发布不依赖维护者本地执行 `npm publish`; +- 每个发布包可追溯到 commit、workflow 和 provenance; +- 发布后 registry 消费者构建失败时阻止继续推广 dist-tag; +- 预发布版本与 stable 版本不会互相覆盖。 + +### 8.4 真实消费者验证 + +实施进度(2026-08-17):已建立 [`docs/ADOPTION_MATRIX.md`](./docs/ADOPTION_MATRIX.md)。xue 固定使用 `@webaseui/core@0.1.0` 和 `@webaseui/svelte@0.3.2`,只读检查通过;基于历史 commit `2cf5bdd` 的 `@webaseui/svelte@0.2.0` → `0.3.2` 升级演练完成,升级前 check/build 和升级后 check/build、59 项 E2E 全部通过。FruitsAI/Fig 的 disposable checkout 使用正式 registry 包替换真实分类与搜索控件,check/lint/SSR build、Chromium、axe、移动端和 tree-shaking 通过,总客户端 JS/CSS gzip 增加 3,497 B(4.4%)。外部 SvelteKit CRUD 应用使用当前 workspace 候选 tarball 替换创建表单,`inputProps`/`textareaProps` 进入原生 FormData,enhanced action 返回 200,Svelte check、SSR build、axe 子树、移动端和 tree-shaking 通过;其极小基线首次引入完整主题和 3 个组件后总客户端 gzip 增加 12,068 B(32.5%),已作为采用成本记录而未改写仓库基线。原始外部工作树均未修改。仓库内 consumer 仍只计维护者 fixture;WeMail 与 Fangcun 是 React 应用,不计入 Svelte 采用。两个真实应用和内容/导航、表单/CRUD 两种画像的技术门槛现已满足。 + +至少选择两个不同复杂度的真实应用进行试用: + +- 一个以表单和 CRUD 为主的应用; +- 一个以内容展示和导航为主的应用。 + +收集并处理: + +- 被迫绕过组件的场景; +- 主题覆盖困难点; +- 包体积和加载性能; +- SSR、水合和路由集成; +- API 易错点和缺失组件; +- 升级体验和弃用提示。 + +1.0 之前至少完成一次从旧 minor 到新 minor 的真实升级演练。 + +## 9. 1.0 RC:稳定性审计 + +1.0 不以组件数量为发布条件,以公共契约可长期维护为条件。 + +### 9.1 1.0 发布门槛 + +实施进度(2026-08-17):自动化公共契约、三浏览器、SSR/水合、视觉、包体积、支持策略和外部采用证据已具备;fresh `npm ci` 后的完整 check、消费者、浏览器、视觉、changeset、audit、registry smoke 和 workflow lint 均已通过。发布 workflow 现在显式包含 changeset、high-level audit,并在发布后以 `--require-provenance` 验证 SLSA attestations;该强制模式已用当前历史包验证会正确拒绝缺失 provenance。本地 P0/P1 与安全清零审计已完成,发布前仍需重跑。尚未闭合的发布门槛是两套人工屏幕阅读器结果、真实 npm trusted publishing/OIDC 首次运行与 provenance,以及真实发布后的 registry smoke。 + +- 公开组件 API、token、键盘行为和支持矩阵全部文档化; +- 所有公开组件通过 Chromium、Firefox、WebKit; +- 所有交互组件通过自动无障碍检查和人工屏幕阅读器抽查; +- SSR、水合、tree-shaking、最低/current Svelte 消费者测试通过; +- light/dark/RTL/forced-colors/reduced-motion 基准覆盖完成; +- 包体积基线已记录,单次增长超过 5% 必须解释和批准; +- 没有未解决的 P0/P1 缺陷; +- 没有计划在 1.1 立即移除的已知不合理 API; +- 迁移指南、版本策略、安全策略和贡献流程完整; +- 至少两个真实消费者完成采用或升级验证; +- npm 自动发布、provenance 和发布后烟雾测试稳定运行。 + +### 9.2 支持策略 + +实施进度(2026-08-17):[`docs/support-policy.json`](./docs/support-policy.json) 已冻结 Node `^22.13.0 || ^24.0.0`、Svelte `>=5.20.0 <6`、TypeScript `>=5.5.4 <7` 和主流浏览器最近两个稳定 major 的 1.0 窗口;`check:support` 约束 manifest、consumer fixture、文档和 workflow 不漂移,weekly compatibility workflow 对 Node 24、最新 Svelte/Vite/TypeScript 和最新 Playwright 浏览器提供非阻塞预警。 + +1.0 前发布并冻结以下策略: + +- Svelte、Node.js、TypeScript 和 Vite 支持范围; +- 浏览器支持范围,建议为主流 evergreen 浏览器最近两个稳定版本; +- 安全修复和严重 bug 的响应等级; +- 弃用周期和 major 发布节奏; +- 对旧版本分支的维护期限。 + +## 10. 持续质量门槛 + +建议将 CI 分为快速检查和完整检查: + +| 检查 | Pull Request | main | 发布前 | +| --- | --- | --- | --- | +| 格式、类型、构建 | 必须 | 必须 | 必须 | +| 单元测试 | 必须 | 必须 | 必须 | +| Chromium 行为测试 | 必须 | 必须 | 必须 | +| Firefox/WebKit | 必须 | 必须 | 必须 | +| axe 自动审计 | 必须 | 必须 | 必须 | +| 视觉回归 | 变更组件必跑 | 全量 | 全量 | +| SSR/水合 | 必须 | 必须 | 必须 | +| 最低/current 消费者 | 必须 | 必须 | 必须 | +| 下一版本依赖预警 | 可选、不阻塞 | 定时 | 不阻塞 | +| 包体积与 tree-shaking | 必须 | 必须 | 必须 | +| npm audit | high 阻塞 | high 阻塞 | high 阻塞 | +| registry 安装烟雾测试 | 不适用 | 不适用 | 发布后必须 | + +## 11. 工程治理 + +实施进度(2026-08-17):已新增 [`docs/SUPPORT_MATRIX.md`](./docs/SUPPORT_MATRIX.md)、`docs/support-policy.json`、[`docs/SCREEN_READER_AUDIT.md`](./docs/SCREEN_READER_AUDIT.md)、`SECURITY.md`、PR 模板、bug/组件提案/无障碍/release review issue 模板和 `.github/CODEOWNERS`。Svelte、Node、TypeScript、Vite、SSR、浏览器、自动无障碍、弃用、安全响应和旧 major 维护窗口已经冻结并受 `check:support` 约束;每个 minor 的 adoption、缺陷、包体积和兼容性复盘已有模板。外部升级和两类应用试用已完成,实际 VoiceOver/NVDA 抽查仍未完成。 + +需要新增或完善: + +- `SECURITY.md`:漏洞报告渠道和支持版本; +- PR 模板:API、无障碍、测试、文档和 Changeset 检查项; +- issue 模板:bug、组件提案、无障碍问题; +- ADR 目录:记录不可逆或跨包决策; +- 组件提案模板:场景、替代方案、API、a11y、维护成本; +- CODEOWNERS 或明确评审责任; +- 定期依赖更新和兼容性预警; +- 每个 minor 发布后的 adoption、缺陷和包体积复盘。 + +## 12. 性能与包体积 + +实施进度(2026-08-16):已建立 `docs/package-size-baseline.json` 与 `npm run check:size`。门槛覆盖两个发布包的 packed/unpacked 大小、Svelte dist 与每个公开组件的 gzip 源文件大小,以及真实消费者 fixture 的 JS/CSS/HTML 输出;任一已记录指标增长超过 5% 时检查失败,基线更新必须显式执行并在评审中说明原因。本轮先将 Dialog lifecycle 发布产物由约 4.1 KB 收紧到约 2.95 KB,再为跨浏览器焦点恢复、floating-position、typeahead 和本地化重排能力等有意新增的内部能力更新基线。RTL/forced-colors 切片使 core CSS 原始大小从 17,707 增至 18,669 bytes(5.4%),Switch 源文件从 2,738 增至 2,885 bytes(5.4%);本地化重排使 EmptyState 从 3,266 增至 3,577 bytes(9.5%),Slider 从 3,788 增至 3,979 bytes(5.0%);locale formatter 又使 Progress 从 1,758 增至 1,909 bytes(8.6%)、Slider 从 3,979 增至 4,183 bytes(5.1%)、Textarea 从 3,255 增至 3,423 bytes(5.2%);0.7.2 目标尺寸和 fine-pointer 媒体分支又使 Breadcrumbs 1,815 → 1,911 bytes、IconButton 2,017 → 2,186 bytes、Pagination 3,169 → 3,375 bytes,均已显式更新基线。为 core 增加公开 changelog 后,当前 core/svelte tarball 分别为 6,556/40,732 bytes,仍在旧基线 6,412/40,289 的 5% 门槛内;真实消费者 JS/CSS gzip 为 21,156/4,754 bytes。 + +首先记录基线,再设置门槛,避免使用脱离实际的绝对数字。 + +建议追踪: + +- 每个组件独立引入后的 minified + gzip 体积; +- 从包根导入单个组件时的 tree-shaking 结果; +- icon 依赖是否仅包含使用到的图标; +- CSS 重复规则和未使用 token; +- 文档站 LCP、CLS、INP; +- Dialog、Select 等首次交互的脚本执行时间。 + +规则: + +- 单个组件或公共入口体积较基线增长超过 5%,PR 必须说明原因; +- 新依赖必须说明体积、许可证、维护状态和替代方案; +- 不允许为了文档示例把未使用组件打入消费者 bundle; +- 性能优化不得牺牲键盘、屏幕阅读器或 API 可理解性。 + +## 13. 暂不执行的事项 + +- 不立即启动 React 适配器; +- 不把组件数量作为成熟度 KPI; +- 不在缺少真实需求时实现 Date Picker、Data Grid 或 Rich Text Editor; +- 不公开尚未稳定的内部 overlay 和 focus primitives; +- 不支持 Svelte 4; +- 不引入全局 CSS reset 或默认修改消费者页面元素; +- 不为了追求覆盖率数字编写没有行为价值的测试。 + +## 14. 执行方式 + +每个里程碑开始时: + +1. 从本计划拆出 issue 和依赖关系; +2. 明确本阶段不做事项; +3. 为公共 API 变化先写规格和测试; +4. 对高风险交互先完成最小 vertical slice; +5. 在真实消费者中验证后再扩大组件范围。 + +每个里程碑结束时: + +1. 运行完整质量门槛; +2. 更新组件成熟度矩阵; +3. 记录新增技术债和未解决风险; +4. 更新版本、迁移和发布说明; +5. 根据真实采用反馈调整下一阶段优先级。 + +## 15. 下一步行动清单 + +建议立即创建以下首批工作项: + +1. 建立 28 个组件的公共 API 与测试覆盖矩阵; +2. 编写组件 API、事件、绑定和原生属性规范; +3. 接入 axe,并先覆盖 Select、Dialog、Tabs、Accordion、Tooltip、Toast; +4. 将 Playwright 扩展到 Firefox 和 WebKit; +5. 增加 SSR/水合消费者 fixture; +6. 为每个组件创建文档页面模板和元数据来源; +7. 建立 token 硬编码值清单并设计三层 token ADR; +8. 记录 Select/overlay 共享基础设施 ADR; +9. 建立包体积基线; +10. 配置 Changesets 自动 release PR 和 npm trusted publishing 方案。 + +其中前五项应作为 0.4 的阻塞任务。完成它们之前,除修复 P0/P1 缺陷外,不建议继续扩充公开组件数量。 diff --git a/README.md b/README.md index 3b04ed3..553fcf0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ WeBaseUI is an editorial design system built around calm typography, paper-like ## Packages - `@webaseui/core` — CSS design tokens and theme primitives. -- `@webaseui/svelte` — 26 typed Svelte 5 components. +- `@webaseui/svelte` — 28 typed Svelte 5 components. ## Install @@ -32,15 +32,18 @@ All public component and Props exports use the `WeBase*` prefix. Public design t ## Development -Requires Node.js 22.13 or newer. +Requires Node.js 22.13+ on Node 22, or Node 24. ```sh npm install +npm run test:unit npm run check npm run check:consumer +npm run test:browser +npm run test:visual ``` -`npm run check` validates the generated package artifacts and public exports. `npm run check:consumer` packs both workspaces and builds an isolated Svelte app from the tarballs, catching errors that workspace links can hide. +`npm run test:unit` covers collection, overlay, Select, Toast, documentation, and package-script boundaries with Vitest, and enforces 90% statements, branches, functions, and lines across the core state tools. `npm run check` runs those unit tests, Svelte type checks, builds the packages and documentation app, and validates package artifacts, public exports, token contracts, documentation policy, size budgets, and visual-baseline coverage. `npm run check:consumer` packs both workspaces and builds isolated apps against the minimum and current supported Svelte versions. `npm run test:browser` exercises component behavior, SSR/hydration, and the documentation UI in Chromium, Firefox, and WebKit. `npm run test:visual` compares the 28 public component specimens and key states against the reviewed Chromium baselines. ## Documentation app @@ -62,6 +65,16 @@ npm run changeset The framework-neutral layer lives in `@webaseui/core`. Framework bindings consume that shared layer and own only component behavior and rendering. The planned React package will therefore be added as `@webaseui/react` alongside `@webaseui/svelte`, rather than translating Svelte components directly. +See the [maturity development plan](./DEVELOPMENT_PLAN.md) for the roadmap from the current package baseline to a stable 1.0 release. + +Release-candidate governance is tracked in the +[support matrix](./docs/SUPPORT_MATRIX.md), +[adoption and upgrade matrix](./docs/ADOPTION_MATRIX.md), +[screen-reader audit](./docs/SCREEN_READER_AUDIT.md), +[release runbook](./docs/RELEASE_RUNBOOK.md), +[RC readiness](./docs/RC_READINESS.md), and +[security policy](./SECURITY.md). + ## License [MIT](./LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b8f3988 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Supported versions + +Only the latest published minor line receives routine security fixes while the +project is in `0.x`. Once `1.0` is released, the latest `1.x` minor is the +primary supported line; older lines are maintained only when a release note +explicitly says so. + +WeBaseUI is a client-side component library. Reports about an application that +uses WeBaseUI should include the application repository and the smallest +reproduction, but must not include secrets or personal data. + +## Reporting a vulnerability + +Please use GitHub's private vulnerability reporting channel: + + + +Do not open a public issue for an undisclosed vulnerability. Include the +affected package and version, impact, reproduction or proof of concept, and any +known workaround. If private reporting is unavailable, contact the repository +maintainers through the GitHub organization and request a private channel. + +Maintainers will acknowledge a report within five business days, assess +severity and affected versions, and coordinate a fix or mitigation. Timelines +can change when the report requires upstream browser, Svelte, or dependency +coordination. Credit is offered in the release note unless the reporter asks to +remain anonymous. diff --git a/apps/docs/DESIGN_STANDARD.md b/apps/docs/DESIGN_STANDARD.md new file mode 100644 index 0000000..4d70488 --- /dev/null +++ b/apps/docs/DESIGN_STANDARD.md @@ -0,0 +1,77 @@ +# WeBaseUI Documentation Design Standard + +This document is the design gate for the public documentation site. It applies to every route, component specimen, transition, illustration, and content change in `apps/docs`. + +## Design read + +The site is an interactive editorial exhibition for designers and frontend engineers. It combines a precise component reference with kinetic typography, asymmetric composition, restrained material depth, and physically motivated motion. + +- `DESIGN_VARIANCE: 9` +- `MOTION_INTENSITY: 8` +- `VISUAL_DENSITY: 4` +- Stack: Svelte 5, native CSS, WeBaseUI components, Lucide icons + +## Hard rules + +### Icons + +- Use Lucide for every interface icon. +- Prefer `WeBaseIcon` and WeBaseUI components that render Lucide internally. +- Do not hand-write SVG paths or mix icon families. +- Do not replace an icon with a Unicode arrow, dingbat, or pictographic symbol. +- `logo.svg` is a brand asset and is the only SVG exception in the page chrome. + +### No emoji + +- Do not use emoji in visible copy, navigation, labels, examples, status messages, empty states, metadata, or decoration. +- Express meaning with a Lucide icon plus clear text when an icon is useful. + +### Award-level finish + +- Hold the visual system to the finish expected from Awwwards, FWA, and CSS Design Awards daily winners. +- Treat typography, spacing, responsive composition, transitions, loading behavior, focus states, and copy as one designed system. +- Do not ship default framework layouts, generic three-card rows, decorative glass panels, or effects without a content purpose. + +### Creative freedom + +- Treat the browser as an interactive art canvas. +- Use asymmetric grids, kinetic type, scroll storytelling, spatial transitions, and experimental composition when they clarify hierarchy or narrative. +- Preserve stable navigation, searchable reference content, selectable text, copyable code, and predictable keyboard behavior. + +### Immersive coherence + +- The page must feel authored as one continuous experience, not assembled from unrelated showcase blocks. +- Component specimens must be real, interactive package imports rather than fake screenshots. +- Advanced rendering must be lazy, optional, and disposable without losing content or functionality. + +## Motion contract + +- Every animation must communicate hierarchy, storytelling, feedback, or state change. +- Animate only `transform` and `opacity` during continuous motion. +- Do not update Svelte state on every scroll frame. +- Prefer CSS scroll-driven animation or IntersectionObserver for lightweight reveals. +- Use a dedicated animation library only when native CSS cannot express the interaction reliably. +- All motion must collapse to a static or instant equivalent under `prefers-reduced-motion: reduce`. +- Avoid perpetual motion unless it communicates an active system state. + +## Accessibility and performance guardrails + +- Meet WCAG 2.2 AA for content and controls. +- Maintain visible focus and full keyboard access through every experimental layout. +- Preserve readable order when CSS layout is removed. +- Keep LCP below 2.5 seconds, INP below 200 milliseconds, and CLS below 0.1 at the 75th percentile target. +- Reserve media dimensions and avoid loading heavy rendering logic above the fold unless it is the hero's primary content. +- Support Chromium, Firefox, WebKit, narrow mobile viewports, zoom, dark mode, forced colors, and reduced motion. + +## Review checklist + +- [ ] All interface icons come from Lucide. +- [ ] No emoji, pictographic symbols, inline SVG icons, or Unicode arrow icons appear in source or visible copy. +- [ ] The page uses real WeBaseUI component specimens. +- [ ] The primary message and first action fit in the initial viewport. +- [ ] The information architecture remains understandable without animation. +- [ ] Each major section uses a deliberate composition rather than repeating one layout pattern. +- [ ] Motion has a documented purpose and reduced-motion fallback. +- [ ] Light and dark themes maintain the same hierarchy and contrast. +- [ ] Mobile layout is explicitly composed, not only stacked by accident. +- [ ] Keyboard, screen reader, zoom, contrast, and Core Web Vitals checks pass. diff --git a/apps/docs/index.html b/apps/docs/index.html index d59fc63..d518ccc 100644 --- a/apps/docs/index.html +++ b/apps/docs/index.html @@ -4,7 +4,16 @@ - WeBaseUI — Svelte component library + + + + + + + + + + WeBaseUI - Svelte component library
diff --git a/apps/docs/package.json b/apps/docs/package.json index 7e73870..db8b16c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "build": "vite build", - "dev": "vite" + "dev": "vite", + "preview": "vite preview", + "typecheck": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { "@webaseui/core": "0.1.0", diff --git a/apps/docs/src/App.svelte b/apps/docs/src/App.svelte index 42ca62d..d8ec2d8 100644 --- a/apps/docs/src/App.svelte +++ b/apps/docs/src/App.svelte @@ -1,184 +1,565 @@ - +
-
-

Public proof / 0.3

-

Interfaces with
a registered mark.

-

A compact foundation of namespaced tokens and accessible Svelte 5 components, designed to travel between products without losing its character.

-
- document.querySelector('#components')?.scrollIntoView({ behavior: 'smooth' })} /> - View source ↗ +
+
+

Build witha visible contract.

+

Accessible Svelte 5 components, named tokens, and a public contract designed to move between products.

+
+ document.querySelector('#components')?.scrollIntoView({ behavior: scrollBehavior() })} /> + +
-
+ +
+

Interactive component specimen

+
+ +
+ + +
+ {#snippet footer()} + Three browser engines + + {/snippet} +
+
+ +
+ +
+ +
+ (heroSaved = !heroSaved)} /> + + +
+
+
+ +
+
Components
28
-
Framework
Svelte 5
-
Tokens
--webase-*
+
Runtime
Svelte 5
+
Engines
3
License
MIT
-
- -
- {#each tokens as [label, token]} -
- - {label} - {token} -
- {/each} +
+
+ +

Tokens carry the identity.

+

Semantic variables keep color, type, focus, and motion coherent across every framework surface.

+
+ +
+ + +
+ {#each tokens as token} +
+ +
+ {token.label} + {token.token} +
+
+ {/each} +
-
- - -
-
-
Actions
-
- - - - - - - -
-
+
+
+

Reference Desk

+

Search, inspect, and exercise every public component against the versioned API contract.

+ +
-
-
Content surface
-
- -

Children and footer are Svelte 5 snippets supplied by the consumer.

- {#snippet footer()}Updated 04 Aug{/snippet} -
-
-
+
+ + +
-
-
Feedback
- -
+
+
+ + + {#key selectedComponent.name} +
+
+
+

{selectedComponent.group}

+

{selectedComponent.name}

+

+ {#each inlineParts(selectedComponent.summary) as part} + {#if part.code}{part.text}{:else}{part.text}{/if} + {/each} +

+
+ v{packageVersion} +
+ +
+
+

Live specimen

+ Package root import +
+
+ +
+
+ +
+
+
+

Public contract

+ {selectedComponent.fields.length} fields +
+
+ {#each selectedComponent.fields as field} +
+
{field.label}
+
+ {#each inlineParts(field.value) as part} + {#if part.code}{part.text}{:else}{part.text}{/if} + {/each} +
+
+ {/each} +
+
- -
- {#each components as [name, group], index} -
- {String(index + 1).padStart(2, '0')} - WeBase{name} - {group} +
+
+

Usage

+ +
+ +
{`import { ${selectedComponent.name} } from '@webaseui/svelte';\n\n${selectedComponent.example}`}
+
+
- {/each} + {/key}
-
- -
-
npm install @webaseui/core @webaseui/svelte
-
{`import '@webaseui/core/theme.css';
-import { WeBaseButton } from '@webaseui/svelte';`}
+
+
+

Install the boundary.

+

Tokens and Svelte components are versioned independently, then verified together in a clean consumer.

+
+ +
+
+
+ Packages + +
+ +
{installCommand}
+

@webaseui/svelte v{packageVersion}

+
+ +
+
+ Import + +
+ +
{importCode}
+
+ +
-

React support remains intentionally deferred until a real consumer defines its runtime and accessibility requirements.

+ + (searchOpen = false)} + onkeydown={(event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + closeSearch(); + }} + onclick={(event) => { if (event.target === searchDialog) closeSearch(); }} +> +
+
+
+

Global search

+

Search the public contract

+
+ +
+ + + +
+

{searchQuery ? `${searchResults.length} matches` : 'Browse components'}

+ {#if searchResults.length > 0} +
    + {#each searchResults as component} +
  • + +
  • + {/each} +
+ {:else} +
+ + No contract found + Try a component name, prop, binding, or behavior. +
+ {/if} +
+
+
diff --git a/apps/docs/src/app.css b/apps/docs/src/app.css index 77b4e23..3d41dcd 100644 --- a/apps/docs/src/app.css +++ b/apps/docs/src/app.css @@ -1,76 +1,1427 @@ @import '@webaseui/core/theme.css'; +:root { + --page-gutter: clamp(20px, 4vw, 64px); + --page-max: 1520px; + --content-max: 1360px; + --header-height: 70px; +} + * { box-sizing: border-box; } -html { scroll-behavior: smooth; background: var(--paper); } -body { margin: 0; color: var(--ink); background: var(--paper); font-family: var(--font); } + +html { + scroll-behavior: smooth; + scroll-padding-top: calc(var(--header-height) + 24px); + background: var(--paper); +} + +body { + min-width: 320px; + margin: 0; + overflow-x: clip; + color: var(--ink); + background: + linear-gradient(90deg, transparent 0 49.95%, color-mix(in srgb, var(--hairline) 45%, transparent) 50%, transparent 50.05%) top center / min(100%, var(--content-max)) 100% no-repeat, + var(--paper); + font-family: var(--font); + text-rendering: optimizeLegibility; +} + +body::before { + position: fixed; + z-index: 50; + inset: 0; + opacity: .055; + background-image: + radial-gradient(circle at 20% 30%, color-mix(in srgb, var(--ink) 38%, transparent) 0 .45px, transparent .7px), + radial-gradient(circle at 72% 64%, color-mix(in srgb, var(--ink) 24%, transparent) 0 .4px, transparent .7px); + background-size: 5px 5px, 7px 7px; + content: ''; + pointer-events: none; +} + a { color: inherit; } -button, input, textarea { font: inherit; } - -.site-header { position: sticky; top: 0; z-index: 20; display: flex; max-width: 1440px; min-height: 74px; align-items: center; justify-content: space-between; gap: 24px; margin: 0 auto; padding: 12px clamp(20px, 5vw, 72px); border-bottom: 1px solid var(--hairline); background: color-mix(in srgb, var(--paper) 94%, transparent); backdrop-filter: blur(16px); } -.brand { display: inline-flex; align-items: center; gap: 11px; text-decoration: none; } -.brand > span:last-child { display: grid; gap: 2px; } -.brand strong { font-family: var(--sans); font-size: 12px; letter-spacing: .1em; text-transform: uppercase; } -.brand small { color: var(--ink-muted); font-family: var(--mono); font-size: 8px; letter-spacing: .08em; text-transform: uppercase; } -.seal { display: grid; width: 38px; height: 38px; place-items: center; border: 1px solid var(--brand); border-radius: 50%; color: var(--brand); box-shadow: inset 0 0 0 3px var(--paper), inset 0 0 0 4px color-mix(in srgb, var(--brand) 32%, transparent); transform: rotate(-7deg); } -.site-header nav { display: flex; align-items: center; gap: clamp(12px, 2vw, 26px); font-family: var(--sans); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; } -.site-header nav > a { text-decoration: none; } -.site-header nav > a:hover { color: var(--brand); } -.theme-control { display: flex; align-items: center; gap: 8px; color: var(--ink-muted); } +button, input, select, textarea { font: inherit; } +button, a, input, select, textarea { -webkit-tap-highlight-color: transparent; } + +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +dialog:focus-visible, +pre:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + +::selection { + color: var(--paper); + background: var(--brand); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.site-header { + position: sticky; + z-index: 30; + top: 0; + width: 100%; + border-bottom: 1px solid color-mix(in srgb, var(--hairline-strong) 78%, transparent); + background: color-mix(in srgb, var(--paper) 97%, var(--surface)); + backdrop-filter: blur(18px) saturate(1.25); + -webkit-backdrop-filter: blur(18px) saturate(1.25); +} + +.header-inner { + display: grid; + width: min(100%, var(--page-max)); + min-height: var(--header-height); + grid-template-columns: auto minmax(190px, 330px) 1fr auto; + align-items: center; + gap: clamp(16px, 2.4vw, 36px); + margin: 0 auto; + padding: 8px var(--page-gutter); +} + +.brand { + display: inline-flex; + align-items: center; + text-decoration: none; +} + +.brand-logo { + display: block; + width: 38px; + height: 41px; + flex: 0 0 auto; + object-fit: contain; +} + +.search-trigger { + display: grid; + min-width: 0; + min-height: 42px; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 0 10px 0 13px; + border: 1px solid var(--hairline-strong); + color: var(--ink-muted); + background: color-mix(in srgb, var(--surface) 72%, transparent); + cursor: pointer; + text-align: left; + transition: border-color var(--duration-ui) var(--ease-out), color var(--duration-ui) var(--ease-out), transform var(--duration-fast) var(--ease-out); +} + +.search-trigger:hover { + border-color: var(--brand); + color: var(--ink); + transform: translateY(-1px); +} + +.search-trigger:active { transform: translateY(0); } + +.search-trigger span { + overflow: hidden; + font-family: var(--sans); + font-size: 10px; + letter-spacing: .07em; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +kbd { + padding: 4px 6px; + border: 1px solid var(--hairline); + color: var(--ink-soft); + background: var(--surface-muted); + font-family: var(--mono); + font-size: 8px; + font-weight: 400; + letter-spacing: .04em; +} + +.primary-nav { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-end; + gap: clamp(16px, 2vw, 30px); + font-family: var(--sans); + font-size: 10px; + letter-spacing: .09em; + text-transform: uppercase; + white-space: nowrap; +} + +.primary-nav a { + position: relative; + padding: 10px 0; + text-decoration: none; +} + +.primary-nav a::after { + position: absolute; + right: 0; + bottom: 4px; + left: 0; + height: 1px; + background: var(--brand); + content: ''; + transform: scaleX(0); + transform-origin: right; + transition: transform var(--duration-ui) var(--ease-out); +} + +.primary-nav a:hover::after, +.primary-nav a:focus-visible::after { + transform: scaleX(1); + transform-origin: left; +} + +.header-actions, +.theme-control { + display: flex; + align-items: center; +} + +.header-actions { gap: 12px; } + +.theme-control { + gap: 8px; + color: var(--ink-muted); + font-family: var(--mono); + font-size: 8px; + letter-spacing: .08em; + text-transform: uppercase; +} + .theme-control .ds-switch-copy { display: none; } -main { max-width: 1180px; margin: 0 auto; padding: 0 clamp(20px, 5vw, 72px); } -.hero { padding: clamp(90px, 14vw, 180px) 0 90px; } -.kicker { margin: 0 0 22px; color: var(--brand); font-family: var(--mono); font-size: 10px; letter-spacing: .16em; text-transform: uppercase; } -h1 { max-width: 900px; margin: 0; font-size: clamp(56px, 9vw, 112px); font-weight: 500; letter-spacing: -.075em; line-height: .82; } -h1 em { color: var(--brand); font-weight: 400; } -.hero-copy { max-width: 610px; margin: 34px 0 0; color: var(--ink-soft); font-size: clamp(17px, 2vw, 21px); line-height: 1.55; } -.hero-actions { display: flex; align-items: center; gap: 22px; margin-top: 34px; } -.hero-actions > a { color: var(--brand); font-family: var(--sans); font-size: 10px; letter-spacing: .09em; text-transform: uppercase; text-underline-offset: 5px; } -.ledger { display: grid; grid-template-columns: repeat(4, 1fr); margin: 76px 0 0; border-block: 1px solid var(--hairline-strong); } -.ledger div { padding: 18px; border-right: 1px solid var(--hairline); } -.ledger div:last-child { border: 0; } -.ledger dt { color: var(--ink-muted); font-family: var(--mono); font-size: 8px; letter-spacing: .12em; text-transform: uppercase; } -.ledger dd { margin: 8px 0 0; color: var(--brand); font-size: 22px; } -.section { scroll-margin-top: 82px; padding: 90px 0; border-top: 1px solid var(--hairline-strong); } - -.token-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } -.token-grid article { display: grid; grid-template-columns: 54px 1fr; gap: 3px 14px; align-items: center; padding: 14px; border: 1px solid var(--hairline); background: var(--surface); } -.swatch { grid-row: 1 / 3; width: 54px; height: 54px; border: 1px solid var(--hairline-strong); background: var(--swatch); } -.token-grid strong { font-family: var(--sans); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; } -code { color: var(--brand); font-family: var(--mono); font-size: 10px; } - -.specimen-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } -.specimen { min-width: 0; border: 1px solid var(--hairline-strong); background: var(--surface); box-shadow: 7px 7px 0 color-mix(in srgb, var(--brand) 9%, transparent); } -.specimen-wide { grid-column: 1 / -1; } -.specimen > header { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 14px; border-bottom: 1px solid var(--hairline); color: var(--brand); font-family: var(--mono); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; } -.canvas { padding: 24px; } -.action-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } -.feedback-stack, .choice-stack { display: grid; gap: 16px; } -.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: start; } -.form-grid .choice-stack { grid-column: 1 / -1; grid-template-columns: repeat(3, 1fr); } -.component-index { display: grid; grid-template-columns: repeat(2, 1fr); border-top: 1px solid var(--hairline-strong); } -.component-index article { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 12px; min-height: 52px; padding: 0 14px; border-right: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline); } -.component-index article:nth-child(2n) { border-right: 0; } -.component-index span, .component-index small { color: var(--ink-muted); font-family: var(--mono); font-size: 8px; letter-spacing: .08em; text-transform: uppercase; } -.component-index strong { font-family: var(--sans); font-size: 11px; font-weight: 500; letter-spacing: .04em; } - -.install-grid { display: grid; grid-template-columns: .8fr 1.2fr; gap: 16px; } -pre { min-width: 0; margin: 0; padding: 22px; overflow: auto; border: 1px solid var(--hairline-strong); background: var(--surface); box-shadow: 6px 6px 0 color-mix(in srgb, var(--brand) 9%, transparent); } -pre code { color: var(--ink); line-height: 1.7; } -.install > p { max-width: 68ch; margin: 30px 0 0; color: var(--ink-muted); line-height: 1.6; } -footer { display: flex; max-width: 1180px; justify-content: space-between; gap: 24px; margin: 0 auto; padding: 30px clamp(20px, 5vw, 72px) 60px; border-top: 1px solid var(--hairline); color: var(--ink-muted); font-family: var(--mono); font-size: 9px; letter-spacing: .09em; text-transform: uppercase; } - -@media (max-width: 760px) { - .site-header nav > a, .theme-control > span { display: none; } - .hero { padding-top: 90px; } - .ledger { grid-template-columns: 1fr 1fr; } - .ledger div:nth-child(2) { border-right: 0; } - .ledger div:nth-child(-n + 2) { border-bottom: 1px solid var(--hairline); } - .token-grid, .specimen-grid, .form-grid, .install-grid { grid-template-columns: 1fr; } - .specimen-wide { grid-column: auto; } - .form-grid .choice-stack { grid-column: auto; grid-template-columns: 1fr; } - .component-index { grid-template-columns: 1fr; } - .component-index article, .component-index article:nth-child(2n) { border-right: 0; } -} - -@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } +.mobile-menu-button { + display: none; + min-height: 40px; + padding: 0; + border: 0; + color: var(--ink); + background: transparent; + cursor: pointer; + font-family: var(--sans); + font-size: 10px; + letter-spacing: .08em; + text-transform: uppercase; +} + +.mobile-navigation { + position: absolute; + top: 100%; + right: 0; + left: 0; + display: grid; + padding: 18px var(--page-gutter) 24px; + border-bottom: 1px solid var(--hairline-strong); + background: var(--paper); + box-shadow: var(--whisper-shadow); +} + +.mobile-navigation a, +.mobile-navigation button { + min-height: 48px; + padding: 0; + border: 0; + border-bottom: 1px solid var(--hairline); + color: var(--ink); + background: transparent; + cursor: pointer; + font-family: var(--sans); + font-size: 12px; + letter-spacing: .08em; + line-height: 48px; + text-align: left; + text-decoration: none; + text-transform: uppercase; +} + +main { overflow: clip; } + +.hero { + display: grid; + width: min(100%, var(--page-max)); + min-height: calc(100dvh - var(--header-height)); + grid-template-columns: minmax(0, 1.08fr) minmax(430px, .92fr); + align-items: center; + gap: clamp(36px, 4.5vw, 80px); + margin: 0 auto; + padding: clamp(52px, 7vw, 94px) var(--page-gutter); +} + +.hero-copy-block { position: relative; z-index: 2; } + +.hero h1 { + max-width: 760px; + margin: 0; + font-family: var(--display); + font-size: clamp(58px, 5.9vw, 92px); + font-weight: 500; + letter-spacing: -.07em; + line-height: .82; +} + +.hero h1 span, +.hero h1 em { display: block; } + +.hero h1 em { + padding-bottom: .07em; + color: var(--brand); + font-weight: 400; + white-space: nowrap; +} + +.hero-copy { + max-width: 560px; + margin: 30px 0 0; + color: var(--ink-soft); + font-size: clamp(17px, 1.55vw, 21px); + line-height: 1.55; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 30px; +} + +.hero-stage { + position: relative; + min-width: 0; + min-height: min(520px, calc(100dvh - 150px)); + isolation: isolate; +} + +.hero-stage::before { + position: absolute; + z-index: -2; + inset: 4% 3% 9% 9%; + border: 1px solid color-mix(in srgb, var(--brand) 18%, var(--hairline)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--brand-tint) 74%, transparent), transparent 54%), + repeating-linear-gradient(90deg, transparent 0 54px, color-mix(in srgb, var(--hairline) 55%, transparent) 55px), + var(--surface); + box-shadow: 18px 18px 0 color-mix(in srgb, var(--brand) 8%, transparent); + content: ''; + transform: rotate(-1.5deg); +} + +.hero-stage::after { + position: absolute; + z-index: -1; + top: 0; + right: 0; + width: 38%; + aspect-ratio: 1; + border: 1px solid color-mix(in srgb, var(--brand) 34%, transparent); + background: radial-gradient(circle at 32% 30%, color-mix(in srgb, var(--brand) 34%, transparent), transparent 62%); + content: ''; + transform: translate(7%, -6%); +} + +.stage-card { + position: absolute; + z-index: 2; + top: 11%; + right: 7%; + width: min(78%, 540px); + transform: rotate(1deg); +} + +.hero-card-content { display: grid; gap: 24px; } + +.stage-alert { + position: absolute; + z-index: 3; + top: 6%; + left: 0; + width: min(70%, 430px); + transform: rotate(-2deg); +} + +.stage-actions { + position: absolute; + z-index: 4; + right: 0; + bottom: 3%; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 10px; + width: min(84%, 560px); + padding: 14px; + border: 1px solid var(--hairline-strong); + background: color-mix(in srgb, var(--paper) 88%, transparent); + box-shadow: 10px 10px 0 color-mix(in srgb, var(--brand) 9%, transparent); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +.proof-rail { + width: min(100%, var(--content-max)); + margin: 0 auto; + padding: 0 var(--page-gutter); +} + +.proof-rail dl { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0; + border-block: 1px solid var(--hairline-strong); +} + +.proof-rail dl > div { + display: flex; + min-height: 92px; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 0 clamp(14px, 2vw, 26px); + border-right: 1px solid var(--hairline); +} + +.proof-rail dl > div:last-child { border-right: 0; } + +.proof-rail dt { + color: var(--ink-muted); + font-family: var(--mono); + font-size: 9px; + letter-spacing: .1em; + text-transform: uppercase; +} + +.proof-rail dd { + margin: 0; + color: var(--brand); + font-family: var(--display); + font-size: clamp(26px, 3vw, 38px); +} + +.page-section { + width: min(100%, var(--content-max)); + margin: 0 auto; + padding: clamp(96px, 10vw, 156px) var(--page-gutter); + scroll-margin-top: calc(var(--header-height) + 20px); +} + +.section-heading { + display: grid; + max-width: 780px; + gap: 18px; + margin-bottom: clamp(44px, 6vw, 82px); +} + +.section-label { + margin: 0; + color: var(--brand); + font-family: var(--mono); + font-size: 9px; + letter-spacing: .16em; + text-transform: uppercase; +} + +.section-heading h2 { + max-width: 760px; + margin: 0; + font-family: var(--display); + font-size: clamp(52px, 6.5vw, 94px); + font-weight: 500; + letter-spacing: -.055em; + line-height: .9; +} + +.section-heading > p:not(.section-label) { + max-width: 620px; + margin: 0; + color: var(--ink-soft); + font-size: clamp(16px, 1.4vw, 19px); + line-height: 1.6; +} + +.tokens-section { border-top: 1px solid var(--hairline-strong); } + +.token-composition { + display: grid; + grid-template-columns: minmax(320px, .82fr) minmax(0, 1.18fr); + align-items: stretch; + gap: clamp(20px, 3vw, 44px); +} + +.token-poster { + position: relative; + min-height: 560px; + overflow: hidden; + border: 1px solid var(--hairline-strong); + background: var(--webase-color-canvas); + box-shadow: 14px 14px 0 color-mix(in srgb, var(--brand) 8%, transparent); +} + +.token-poster span { position: absolute; display: block; } + +.poster-canvas { + inset: 8% 10% 16% 8%; + border: 1px solid var(--webase-color-border-strong); + background: var(--webase-color-surface); + transform: rotate(-3deg); +} + +.poster-brand { + top: 17%; + right: 4%; + width: 48%; + height: 36%; + background: var(--webase-color-brand); + transform: rotate(6deg); +} + +.poster-ink { + bottom: 9%; + left: 16%; + width: 58%; + height: 26%; + background: var(--webase-color-ink); + transform: rotate(-7deg); +} + +.poster-signal { + right: 13%; + bottom: 15%; + width: 20%; + aspect-ratio: 1; + border: 12px solid var(--webase-color-success); + background: var(--webase-color-warning-field); + transform: rotate(10deg); +} + +.token-poster strong { + position: absolute; + z-index: 2; + bottom: 4%; + left: 4%; + color: var(--webase-color-brand); + font-family: var(--mono); + font-size: clamp(18px, 2.4vw, 30px); + font-weight: 400; + letter-spacing: -.03em; +} + +.token-index { + display: grid; + min-width: 0; + grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-auto-rows: minmax(102px, auto); + gap: 10px; +} + +.token-chip { + display: grid; + min-width: 0; + grid-template-columns: 12px minmax(0, 1fr); + align-items: end; + gap: 14px; + padding: 16px; + border: 1px solid color-mix(in srgb, var(--token-swatch) 30%, var(--hairline)); + background: color-mix(in srgb, var(--token-swatch) 10%, var(--surface)); +} + +.token-canvas { grid-column: span 4; } +.token-surface { grid-column: span 2; } +.token-ink { grid-column: span 3; } +.token-muted { grid-column: span 3; } +.token-brand { grid-column: span 2; grid-row: span 2; } +.token-tint { grid-column: span 4; } +.token-success, +.token-warning, +.token-error { grid-column: span 2; } + +.token-swatch { + width: 12px; + height: 100%; + min-height: 58px; + background: var(--token-swatch); +} + +.token-chip div { min-width: 0; } + +.token-chip strong { + display: block; + margin-bottom: 8px; + font-family: var(--sans); + font-size: 10px; + font-weight: 500; + letter-spacing: .08em; + text-transform: uppercase; +} + +code { + color: var(--brand); + font-family: var(--mono); + font-size: 12px; +} + +.token-chip code { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.components-section { + width: min(100%, var(--page-max)); + border-top: 1px solid var(--hairline-strong); +} + +.components-heading { margin-bottom: clamp(38px, 5vw, 64px); } + +.section-search-button { + display: inline-flex; + width: max-content; + min-height: 44px; + align-items: center; + gap: 10px; + padding: 0 14px; + border: 1px solid var(--hairline-strong); + color: var(--brand); + background: var(--surface); + cursor: pointer; + font-family: var(--sans); + font-size: 10px; + letter-spacing: .08em; + text-transform: uppercase; + transition: border-color var(--duration-ui) var(--ease-out), background-color var(--duration-ui) var(--ease-out), transform var(--duration-fast) var(--ease-out); +} + +.section-search-button:hover { + border-color: var(--brand); + background: var(--brand-tint); + transform: translateY(-1px); +} + +.section-search-button:active { transform: translateY(0); } + +.mobile-component-select { display: none; } + +.reference-shell { + display: grid; + min-width: 0; + grid-template-columns: 236px minmax(0, 1fr); + align-items: start; + gap: clamp(28px, 3vw, 50px); +} + +.reference-sidebar { + position: sticky; + top: calc(var(--header-height) + 24px); + max-height: calc(100dvh - var(--header-height) - 48px); + min-width: 0; + padding-right: 22px; + overflow: auto; + border-right: 1px solid var(--hairline-strong); + scrollbar-width: thin; +} + +.sidebar-search { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 9px; + min-height: 42px; + padding: 0 10px; + border: 1px solid var(--hairline-strong); + color: var(--ink-muted); + background: var(--surface); +} + +.sidebar-search:focus-within { + border-color: var(--brand); + outline: var(--focus-ring); + outline-offset: 2px; +} + +.sidebar-search input { + min-width: 0; + border: 0; + outline: 0; + color: var(--ink); + background: transparent; + font-family: var(--sans); + font-size: 11px; +} + +.sidebar-search input::placeholder { color: var(--ink-muted); opacity: 1; } + +.sidebar-count { + margin: 10px 0 26px; + color: var(--ink-muted); + font-family: var(--mono); + font-size: 9px; +} + +.sidebar-groups { display: grid; gap: 24px; } + +.sidebar-group h3 { + margin: 0 0 8px; + color: var(--ink-muted); + font-family: var(--mono); + font-size: 8px; + font-weight: 400; + letter-spacing: .12em; + text-transform: uppercase; +} + +.sidebar-group > div { display: grid; gap: 2px; } + +.sidebar-group button { + min-height: 34px; + padding: 0 10px; + border: 0; + border-left: 2px solid transparent; + color: var(--ink-soft); + background: transparent; + cursor: pointer; + font-family: var(--sans); + font-size: 11px; + text-align: left; + transition: border-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out), background-color var(--duration-ui) var(--ease-out), transform var(--duration-fast) var(--ease-out); +} + +.sidebar-group button:hover { + color: var(--ink); + background: color-mix(in srgb, var(--brand-tint) 55%, transparent); + transform: translateX(2px); +} + +.sidebar-group button.selected { + border-left-color: var(--brand); + color: var(--brand); + background: var(--brand-tint); +} + +.sidebar-empty { + margin: 0; + color: var(--ink-muted); + font-size: 14px; + line-height: 1.5; +} + +.reference-detail { + min-width: 0; + scroll-margin-top: calc(var(--header-height) + 24px); +} + +.reference-detail:focus { outline: none; } + +.reference-detail:focus .reference-header::before { + position: absolute; + top: 0; + bottom: 0; + left: -14px; + width: 2px; + background: var(--brand); + content: ''; +} + +.reference-header { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 30px; + margin-bottom: 28px; +} + +.reference-header > div > p:first-child { + margin: 0 0 10px; + color: var(--brand); + font-family: var(--mono); + font-size: 9px; + letter-spacing: .12em; + text-transform: uppercase; +} + +.reference-header h3 { + margin: 0; + font-family: var(--display); + font-size: clamp(44px, 5.4vw, 78px); + font-weight: 500; + letter-spacing: -.05em; + line-height: .92; +} + +.reference-header > div > p:last-child { + max-width: 760px; + margin: 18px 0 0; + color: var(--ink-soft); + font-size: 16px; + line-height: 1.6; +} + +.reference-header > div > p:last-child code { + padding: 1px 4px; + background: color-mix(in srgb, var(--brand-tint) 60%, transparent); + font-size: .82em; +} + +.reference-version { + padding: 7px 8px; + border: 1px solid var(--hairline); + color: var(--ink-muted); + font-family: var(--mono); + font-size: 8px; +} + +.preview-panel, +.contract-panel, +.example-panel { + min-width: 0; + border: 1px solid var(--hairline-strong); + background: var(--surface); +} + +.preview-panel > header, +.contract-panel > header, +.example-panel > header, +.install-primary > header, +.install-import > header { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 0 14px; + border-bottom: 1px solid var(--hairline); +} + +.preview-panel h4, +.contract-panel h4, +.example-panel h4 { + margin: 0; + font-family: var(--sans); + font-size: 10px; + font-weight: 500; + letter-spacing: .09em; + text-transform: uppercase; +} + +.preview-panel > header span, +.contract-panel > header span { + color: var(--ink-muted); + font-family: var(--mono); + font-size: 8px; + letter-spacing: .06em; + text-transform: uppercase; +} + +.preview-canvas { + display: grid; + min-height: 320px; + align-items: center; + padding: clamp(22px, 4vw, 54px); + background: + linear-gradient(90deg, transparent 49.8%, color-mix(in srgb, var(--hairline) 55%, transparent) 50%, transparent 50.2%), + linear-gradient(0deg, transparent 49.8%, color-mix(in srgb, var(--hairline) 55%, transparent) 50%, transparent 50.2%), + color-mix(in srgb, var(--brand-tint) 20%, var(--paper)); + background-size: 72px 72px; +} + +.reference-lower { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(300px, .85fr); + align-items: start; + gap: 16px; + margin-top: 16px; +} + +.contract-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin: 0; + padding: 14px; +} + +.contract-grid > div { + min-width: 0; + padding: 16px; + background: color-mix(in srgb, var(--brand-tint) 28%, var(--paper)); +} + +.contract-grid > div.contract-wide { grid-column: 1 / -1; } + +.contract-grid dt { + margin-bottom: 9px; + color: var(--brand); + font-family: var(--mono); + font-size: 8px; + letter-spacing: .1em; + text-transform: uppercase; +} + +.contract-grid dd { + margin: 0; + color: var(--ink-soft); + font-size: 14px; + line-height: 1.55; +} + +.contract-grid dd code { + padding: 1px 3px; + background: color-mix(in srgb, var(--brand-tint) 62%, transparent); + white-space: break-spaces; +} + +.example-panel { position: sticky; top: calc(var(--header-height) + 24px); } + +.example-panel header button, +.install-primary header button, +.install-import header button { + display: inline-flex; + min-height: 30px; + align-items: center; + gap: 6px; + padding: 0 8px; + border: 1px solid var(--hairline); + color: var(--brand); + background: transparent; + cursor: pointer; + font-family: var(--sans); + font-size: 9px; + letter-spacing: .07em; + text-transform: uppercase; +} + +pre { + min-width: 0; + margin: 0; + overflow: auto; +} + +.example-panel pre, +.install-primary pre, +.install-import pre { + padding: clamp(18px, 2.5vw, 28px); +} + +pre code { + color: var(--ink); + font-size: 12px; + line-height: 1.75; + white-space: pre; +} + +.install-section { border-top: 1px solid var(--hairline-strong); } + +.install-composition { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 16px; +} + +.install-primary, +.install-import { + min-width: 0; + border: 1px solid var(--hairline-strong); + background: var(--surface); + box-shadow: 9px 9px 0 color-mix(in srgb, var(--brand) 8%, transparent); +} + +.install-primary { grid-column: span 7; } +.install-import { grid-column: span 5; transform: translateY(42px); } + +.install-primary header span, +.install-import header span { + color: var(--brand); + font-family: var(--sans); + font-size: 10px; + letter-spacing: .09em; + text-transform: uppercase; +} + +.install-primary > p { + margin: 0; + padding: 0 28px 24px; + color: var(--ink-muted); + font-family: var(--mono); + font-size: 9px; +} + +.install-notes { + display: grid; + grid-column: 1 / -1; + grid-template-columns: 1.2fr .9fr 1.15fr 1fr; + margin-top: 58px; + border-block: 1px solid var(--hairline-strong); +} + +.install-notes > div { + display: grid; + min-height: 96px; + align-content: center; + gap: 8px; + padding: 16px 20px; + border-right: 1px solid var(--hairline); +} + +.install-notes > div:last-child { border-right: 0; } + +.install-notes strong { + color: var(--brand); + font-family: var(--mono); + font-size: 9px; + font-weight: 400; + letter-spacing: .08em; + text-transform: uppercase; +} + +.install-notes span { + color: var(--ink-soft); + font-size: 14px; +} + +footer { + display: flex; + width: min(100%, var(--content-max)); + min-height: 124px; + align-items: center; + justify-content: space-between; + gap: 28px; + margin: 0 auto; + padding: 28px var(--page-gutter) 44px; + border-top: 1px solid var(--hairline-strong); +} + +.footer-brand { + display: inline-flex; + align-items: center; + gap: 10px; + font-family: var(--sans); + font-size: 11px; + letter-spacing: .08em; + text-decoration: none; + text-transform: uppercase; +} + +.footer-brand img { display: block; object-fit: contain; } + +footer nav { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 20px; + color: var(--ink-muted); + font-family: var(--mono); + font-size: 9px; +} + +footer nav a { text-underline-offset: 4px; } + +.search-dialog { + width: min(940px, calc(100vw - 36px)); + max-height: min(760px, calc(100dvh - 36px)); + margin: auto; + padding: 0; + overflow: visible; + border: 0; + color: var(--ink); + background: transparent; +} + +.search-dialog::backdrop { + background: color-mix(in srgb, var(--ink) 58%, transparent); + backdrop-filter: blur(9px); + -webkit-backdrop-filter: blur(9px); +} + +.search-panel { + max-height: min(760px, calc(100dvh - 36px)); + overflow: auto; + border: 1px solid var(--hairline-strong); + background: var(--paper); + box-shadow: 20px 20px 0 color-mix(in srgb, var(--brand) 15%, transparent); +} + +.search-panel > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: clamp(20px, 3vw, 34px); +} + +.search-panel > header p { + margin: 0 0 8px; + color: var(--brand); + font-family: var(--mono); + font-size: 8px; + letter-spacing: .12em; + text-transform: uppercase; +} + +.search-panel > header h2 { + margin: 0; + font-family: var(--display); + font-size: clamp(34px, 5vw, 56px); + font-weight: 500; + letter-spacing: -.045em; + line-height: .95; +} + +.search-input { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-height: 62px; + margin: 0 clamp(20px, 3vw, 34px); + padding: 0 16px; + border: 1px solid var(--hairline-strong); + color: var(--brand); + background: var(--surface); +} + +.search-input:focus-within { + border-color: var(--brand); + outline: var(--focus-ring); + outline-offset: 3px; +} + +.search-input input { + min-width: 0; + border: 0; + outline: 0; + color: var(--ink); + background: transparent; + font-size: clamp(16px, 2vw, 20px); +} + +.search-input input::placeholder { color: var(--ink-muted); opacity: 1; } + +.search-results { padding: 24px clamp(20px, 3vw, 34px) 34px; } + +.search-results > p { + margin: 0 0 12px; + color: var(--ink-muted); + font-family: var(--mono); + font-size: 9px; + letter-spacing: .08em; + text-transform: uppercase; +} + +.search-results ul { + display: grid; + margin: 0; + padding: 0; + list-style: none; +} + +.search-results li { border-top: 1px solid var(--hairline); } +.search-results li:last-child { border-bottom: 1px solid var(--hairline); } + +.search-results li button { + display: grid; + width: 100%; + min-height: 74px; + grid-template-columns: minmax(180px, .65fr) minmax(0, 1.35fr) auto; + align-items: center; + gap: 22px; + padding: 12px 10px; + border: 0; + color: var(--ink); + background: transparent; + cursor: pointer; + text-align: left; + transition: color var(--duration-ui) var(--ease-out), background-color var(--duration-ui) var(--ease-out), transform var(--duration-fast) var(--ease-out); +} + +.search-results li button:hover, +.search-results li button:focus-visible { + color: var(--brand); + background: var(--brand-tint); + transform: translateX(3px); +} + +.search-results li button > span:first-child { display: grid; gap: 4px; } + +.search-results strong { + font-family: var(--sans); + font-size: 11px; + font-weight: 500; +} + +.search-results small { + color: var(--ink-muted); + font-family: var(--mono); + font-size: 8px; + letter-spacing: .08em; + text-transform: uppercase; +} + +.search-results li button > span:nth-child(2) { + display: -webkit-box; + overflow: hidden; + color: var(--ink-soft); + font-size: 13px; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.search-empty { + display: grid; + min-height: 220px; + place-items: center; + align-content: center; + gap: 10px; + border: 1px dashed var(--hairline-strong); + color: var(--ink-muted); + text-align: center; +} + +.search-empty strong { color: var(--ink); font-size: 16px; } +.search-empty span { font-size: 14px; } + +@media (prefers-reduced-motion: no-preference) { + .hero-copy-block > * { + opacity: 0; + animation: hero-copy-in 780ms var(--ease-out) both; + } + + .hero-copy-block > :nth-child(2) { animation-delay: 110ms; } + .hero-copy-block > :nth-child(3) { animation-delay: 190ms; } + + .hero-stage { + opacity: 0; + animation: stage-in 980ms var(--ease-out) 120ms both; + } + + .stage-alert { animation: layer-in 720ms var(--ease-out) 460ms both; } + .stage-actions { animation: layer-in 720ms var(--ease-out) 580ms both; } + + [data-reveal] { + opacity: 0; + transform: translateY(34px); + transition: opacity 760ms var(--ease-out), transform 760ms var(--ease-out); + } + + [data-reveal].is-visible { + opacity: 1; + transform: translateY(0); + } + + .reference-detail { animation: reference-in 520ms var(--ease-out) both; } + .search-dialog[open] .search-panel { animation: search-in 420ms var(--ease-out) both; } + .mobile-navigation { animation: mobile-menu-in 320ms var(--ease-drawer) both; } +} + +@keyframes hero-copy-in { + from { opacity: 0; transform: translateY(28px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes stage-in { + from { opacity: 0; transform: translate3d(36px, 18px, 0) rotate(1deg); } + to { opacity: 1; transform: translate3d(0, 0, 0) rotate(0); } +} + +@keyframes layer-in { + from { opacity: 0; transform: translateY(20px) rotate(0); } + to { opacity: 1; } +} + +@keyframes reference-in { + from { opacity: 0; transform: translateY(18px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes search-in { + from { opacity: 0; transform: translateY(24px) scale(.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes mobile-menu-in { + from { opacity: 0; transform: translateY(-12px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (max-width: 1180px) { + .header-inner { grid-template-columns: auto minmax(160px, 240px) 1fr auto; } + .hero { grid-template-columns: minmax(0, 1fr) minmax(400px, .9fr); gap: 42px; } + .hero h1 { font-size: clamp(56px, 6.2vw, 78px); } + .reference-shell { grid-template-columns: 210px minmax(0, 1fr); } + .reference-lower { grid-template-columns: 1fr; } + .example-panel { position: static; } +} + +@media (max-width: 960px) { + .header-inner { grid-template-columns: auto minmax(150px, 220px) 1fr auto; gap: 16px; } + .primary-nav { gap: 16px; } + .hero { + min-height: auto; + grid-template-columns: 1fr; + padding-top: 78px; + padding-bottom: 90px; + } + .hero h1 em { white-space: normal; } + .hero-copy-block { max-width: 760px; } + .hero-stage { width: min(720px, 100%); min-height: 560px; margin-left: auto; } + .token-composition { grid-template-columns: 1fr; } + .token-poster { min-height: 440px; } + .reference-shell { grid-template-columns: 190px minmax(0, 1fr); gap: 28px; } +} + +@media (max-width: 820px) { + :root { --header-height: 66px; } + .site-header { background: var(--paper); } + .header-inner { grid-template-columns: auto minmax(0, 1fr) auto; } + .primary-nav { display: none; } + .mobile-menu-button { display: inline-flex; align-items: center; } + .theme-control > span { display: none; } + .proof-rail dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .proof-rail dl > div:nth-child(2) { border-right: 0; } + .proof-rail dl > div:nth-child(-n + 2) { border-bottom: 1px solid var(--hairline); } + .reference-sidebar { display: none; } + .reference-shell { display: block; } + .mobile-component-select { + display: grid; + gap: 8px; + margin-bottom: 28px; + } + .mobile-component-select label { + font-family: var(--sans); + font-size: 10px; + letter-spacing: .08em; + text-transform: uppercase; + } + .mobile-component-select select { + width: 100%; + min-height: 48px; + padding: 0 42px 0 12px; + border: 1px solid var(--hairline-strong); + border-radius: 0; + color: var(--ink); + background: var(--surface); + } + .reference-header h3 { font-size: clamp(42px, 10vw, 68px); } + .install-primary, + .install-import { grid-column: 1 / -1; } + .install-import { transform: none; } + .install-notes { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 16px; } + .install-notes > div:nth-child(2) { border-right: 0; } + .install-notes > div:nth-child(-n + 2) { border-bottom: 1px solid var(--hairline); } +} + +@media (max-width: 620px) { + :root { --header-height: 64px; } + body { background: var(--paper); } + body::before { opacity: .035; } + .header-inner { gap: 10px; } + .brand-logo { width: 34px; height: 37px; } + .search-trigger { width: 42px; min-width: 42px; grid-template-columns: 1fr; place-items: center; padding: 0; } + .search-trigger span, + .search-trigger kbd { display: none; } + .theme-control { gap: 0; } + .mobile-menu-button { min-width: 46px; justify-content: flex-end; } + .hero { gap: 34px; padding-top: 56px; padding-bottom: 72px; } + .hero h1 { font-size: clamp(52px, 16vw, 72px); line-height: .86; } + .hero-copy { margin-top: 24px; font-size: 17px; } + .hero-actions { align-items: stretch; } + .hero-stage { min-height: 490px; } + .hero-stage::before { inset: 5% 1% 12% 4%; } + .hero-stage::after { width: 44%; } + .stage-card { top: 13%; right: 1%; width: 91%; } + .stage-alert { top: 1%; width: 88%; } + .stage-actions { right: 2%; bottom: 0; width: 94%; justify-content: flex-start; } + .stage-actions .ds-tooltip { display: none; } + .proof-rail dl > div { min-height: 78px; } + .proof-rail dd { font-size: 28px; } + .page-section { padding-top: 78px; padding-bottom: 84px; } + .section-heading { gap: 14px; margin-bottom: 38px; } + .section-heading h2 { font-size: clamp(46px, 14vw, 66px); } + .token-poster { min-height: 360px; } + .token-index { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .token-chip, + .token-canvas, + .token-surface, + .token-ink, + .token-muted, + .token-brand, + .token-tint, + .token-success, + .token-warning, + .token-error { grid-column: auto; grid-row: auto; } + .token-brand { grid-column: 1 / -1; } + .reference-header { grid-template-columns: 1fr; gap: 14px; } + .reference-version { width: max-content; } + .preview-canvas { min-height: 280px; padding: 20px; background-size: 52px 52px; } + .contract-grid { grid-template-columns: 1fr; } + .contract-grid > div.contract-wide { grid-column: auto; } + .install-notes { grid-template-columns: 1fr; } + .install-notes > div, + .install-notes > div:nth-child(2) { border-right: 0; border-bottom: 1px solid var(--hairline); } + .install-notes > div:last-child { border-bottom: 0; } + footer { align-items: flex-start; flex-direction: column; } + footer nav { justify-content: flex-start; } + .search-dialog { width: calc(100vw - 20px); max-height: calc(100dvh - 20px); } + .search-panel { max-height: calc(100dvh - 20px); box-shadow: 8px 8px 0 color-mix(in srgb, var(--brand) 15%, transparent); } + .search-panel > header { padding: 20px; } + .search-input { min-height: 56px; margin: 0 20px; } + .search-input kbd { display: none; } + .search-results { padding: 20px; } + .search-results li button { min-height: 86px; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; } + .search-results li button > span:nth-child(2) { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { + animation: none !important; + scroll-behavior: auto !important; + transition: none !important; + } + [data-reveal] { opacity: 1; transform: none; } +} + +@media (prefers-reduced-transparency: reduce) { + .site-header, + .stage-actions, + .search-dialog::backdrop { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + .site-header, + .stage-actions { background: var(--paper); } +} + +@media (forced-colors: active) { + body::before, + .hero-stage::before, + .hero-stage::after { display: none; } + .token-chip, + .preview-panel, + .contract-panel, + .example-panel, + .install-primary, + .install-import { border: 1px solid CanvasText; } +} diff --git a/apps/docs/src/lib/ComponentPreview.svelte b/apps/docs/src/lib/ComponentPreview.svelte new file mode 100644 index 0000000..d064d9d --- /dev/null +++ b/apps/docs/src/lib/ComponentPreview.svelte @@ -0,0 +1,212 @@ + + +
+ {#if name === 'WeBaseAccordion'} + + {:else if name === 'WeBaseAlert'} + {#if alertOpen} + + {:else} + (alertOpen = true)} /> + {/if} + {:else if name === 'WeBaseBadge'} +
+ + + +
+ {:else if name === 'WeBaseBreadcrumbs'} + + {:else if name === 'WeBaseButton'} +
+ + + + +
+ {:else if name === 'WeBaseCard'} +
+ (cardActions += 1)}> +

Children and footer remain owned by the consumer. Action count: {cardActions}.

+ {#snippet footer()}Package root import{/snippet} +
+
+ {:else if name === 'WeBaseCheck'} +
+ + + (indeterminate = !indeterminate)} /> +
+ {:else if name === 'WeBaseDialog'} + (dialogOpen = true)} /> + + {:else if name === 'WeBaseDivider'} +
+ +
CoreSvelte
+
+ {:else if name === 'WeBaseEmptyState'} + (emptyActions += 1)} /> + {#if emptyActions > 0}

Action received.

{/if} + {:else if name === 'WeBaseField'} +
+ +
+ {:else if name === 'WeBaseIcon'} +
+ archive + bookmark + search + settings-2 + sparkles +
+ {:else if name === 'WeBaseIconButton'} +
+ + + +
+ {:else if name === 'WeBaseLink'} +
+ + + +
+ {:else if name === 'WeBaseLoader'} + + {:else if name === 'WeBasePagination'} + + {:else if name === 'WeBaseProgress'} +
+ + +
+ {:else if name === 'WeBaseRadio'} +
+ + +
+ {:else if name === 'WeBaseSectionHeader'} + + {:else if name === 'WeBaseSelect'} +
+ +
+ {:else if name === 'WeBaseSkeleton'} +
+ {:else if name === 'WeBaseSlider'} +
+ {:else if name === 'WeBaseSwitch'} + + {:else if name === 'WeBaseTag'} +
+ + + +
+ {:else if name === 'WeBaseTabs'} + + {:else if name === 'WeBaseTextarea'} +
+ {:else if name === 'WeBaseToast'} +
+ (toastOpen = true)} /> + +
+ {:else if name === 'WeBaseTooltip'} +
+ + +
+ {/if} +
+ + diff --git a/apps/docs/src/lib/reference-utils.ts b/apps/docs/src/lib/reference-utils.ts new file mode 100644 index 0000000..c1ef4b6 --- /dev/null +++ b/apps/docs/src/lib/reference-utils.ts @@ -0,0 +1,36 @@ +export interface ParsedContractField { + label: string; + value: string; +} + +export function parseFields(body: string): ParsedContractField[] { + const bullets: string[] = []; + let current = ''; + + for (const line of body.split(/\r?\n/)) { + if (line.startsWith('- ')) { + if (current) bullets.push(current); + current = line.slice(2).trim(); + } else if (current && /^\s{2,}\S/.test(line)) { + current += ` ${line.trim()}`; + } + } + + if (current) bullets.push(current); + + return bullets.map((bullet) => { + const separator = bullet.indexOf(': '); + if (separator < 0) return { label: 'Contract', value: bullet }; + return { + label: bullet.slice(0, separator), + value: bullet.slice(separator + 2) + }; + }); +} + +export function inlineParts(value: string) { + return value + .split('`') + .map((text, index) => ({ text, code: index % 2 === 1 })) + .filter(({ text }) => text.length > 0); +} diff --git a/apps/docs/src/lib/reference.ts b/apps/docs/src/lib/reference.ts new file mode 100644 index 0000000..5be3c79 --- /dev/null +++ b/apps/docs/src/lib/reference.ts @@ -0,0 +1,134 @@ +import contractSource from '../../../../packages/webaseui-svelte/API_CONTRACT.md?raw'; +import packageSource from '../../../../packages/webaseui-svelte/package.json?raw'; +import { inlineParts, parseFields, type ParsedContractField } from './reference-utils.js'; + +export { inlineParts, parseFields } from './reference-utils.js'; + +export const componentGroups = { + WeBaseAccordion: 'Disclosure', + WeBaseAlert: 'Feedback', + WeBaseBadge: 'Status', + WeBaseBreadcrumbs: 'Navigation', + WeBaseButton: 'Action', + WeBaseCard: 'Surface', + WeBaseCheck: 'Selection', + WeBaseDialog: 'Overlay', + WeBaseDivider: 'Structure', + WeBaseEmptyState: 'Content', + WeBaseField: 'Form', + WeBaseIcon: 'Foundation', + WeBaseIconButton: 'Action', + WeBaseLink: 'Navigation', + WeBaseLoader: 'Feedback', + WeBasePagination: 'Navigation', + WeBaseProgress: 'Feedback', + WeBaseRadio: 'Selection', + WeBaseSectionHeader: 'Structure', + WeBaseSelect: 'Form', + WeBaseSkeleton: 'Feedback', + WeBaseSlider: 'Form', + WeBaseSwitch: 'Selection', + WeBaseTag: 'Navigation', + WeBaseTabs: 'Navigation', + WeBaseTextarea: 'Form', + WeBaseToast: 'Feedback', + WeBaseTooltip: 'Overlay' +} as const; + +export type ComponentName = keyof typeof componentGroups; + +export const groupOrder = [ + 'Action', + 'Content', + 'Disclosure', + 'Feedback', + 'Form', + 'Foundation', + 'Navigation', + 'Overlay', + 'Selection', + 'Status', + 'Structure', + 'Surface' +] as const; + +export type ContractField = ParsedContractField; + +export interface ComponentReference { + name: ComponentName; + shortName: string; + slug: string; + group: (typeof componentGroups)[ComponentName]; + summary: string; + fields: ContractField[]; + example: string; + searchText: string; +} + +const usageExamples: Record = { + WeBaseAccordion: ``, + WeBaseAlert: ``, + WeBaseBadge: ``, + WeBaseBreadcrumbs: ``, + WeBaseButton: ``, + WeBaseCard: ``, + WeBaseCheck: ``, + WeBaseDialog: ``, + WeBaseDivider: ``, + WeBaseEmptyState: ``, + WeBaseField: ``, + WeBaseIcon: ``, + WeBaseIconButton: ``, + WeBaseLink: ``, + WeBaseLoader: ``, + WeBasePagination: ``, + WeBaseProgress: ``, + WeBaseRadio: ``, + WeBaseSectionHeader: ``, + WeBaseSelect: ``, + WeBaseSkeleton: ``, + WeBaseSlider: ``, + WeBaseSwitch: ``, + WeBaseTag: ``, + WeBaseTabs: ``, + WeBaseTextarea: ``, + WeBaseToast: ``, + WeBaseTooltip: `` +}; + +function isComponentName(value: string): value is ComponentName { + return value in componentGroups; +} + +const sectionPattern = /^### (WeBase[A-Za-z]+)\s*$([\s\S]*?)(?=^### |^## Change policy)/gm; + +export const components: ComponentReference[] = Array.from(contractSource.matchAll(sectionPattern)).flatMap((match) => { + const name = match[1]; + if (!name || !isComponentName(name)) return []; + + const fields = parseFields(match[2] ?? ''); + const boundary = fields.find((field) => field.label === 'Boundary')?.value; + const rawSummary = boundary ?? fields.at(-1)?.value ?? 'Public component contract.'; + const summary = `${rawSummary.charAt(0).toLocaleUpperCase()}${rawSummary.slice(1)}`; + const shortName = name.replace(/^WeBase/, ''); + const slug = shortName.toLocaleLowerCase(); + const group = componentGroups[name]; + const example = usageExamples[name]; + + return [{ + name, + shortName, + slug, + group, + summary, + fields, + example, + searchText: `${name} ${shortName} ${group} ${fields.map(({ label, value }) => `${label} ${value}`).join(' ')}`.toLocaleLowerCase() + }]; +}); + +if (components.length !== 28) { + throw new Error(`Expected 28 component contracts, found ${components.length}.`); +} + +export const packageVersion = (JSON.parse(packageSource) as { version: string }).version; diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 072b88b..1c93d76 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -2,8 +2,18 @@ "compilerOptions": { "module": "ESNext", "moduleResolution": "bundler", + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable", + "ESNext.Disposable" + ], "strict": true, - "target": "ES2022" + "target": "ES2022", + "types": [ + "node", + "vite/client" + ] }, "include": [ "src/**/*.ts", diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts index 3332039..083fbb2 100644 --- a/apps/docs/vite.config.ts +++ b/apps/docs/vite.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { fileURLToPath } from 'node:url'; export default defineConfig({ - plugins: [svelte()] + plugins: [svelte()], + publicDir: fileURLToPath(new URL('../../static', import.meta.url)) }); diff --git a/docs/ADOPTION_MATRIX.md b/docs/ADOPTION_MATRIX.md new file mode 100644 index 0000000..053db0b --- /dev/null +++ b/docs/ADOPTION_MATRIX.md @@ -0,0 +1,167 @@ +# Adoption and Upgrade Matrix + +This document records which applications currently consume WeBaseUI, what was +actually verified, and which adoption claims are still pending. It is an +evidence log, not a list of intended integrations. + +## Status at 2026-08-17 + +| Consumer | Runtime | WeBaseUI packages | Role | Evidence | Status | +| --- | --- | --- | --- | --- | --- | +| xue (external checkout) | SvelteKit / Svelte 5 | `@webaseui/core@0.1.0`, `@webaseui/svelte@0.3.2` | External production-oriented SvelteKit consumer | `npm run check` passed with 0 Svelte diagnostics; package imports and design-system routes are present | Verified | +| FruitsAI/Fig at `3f37914` (disposable checkout) | SvelteKit / Svelte 5.56.6 / Cloudflare adapter | Registry `@webaseui/core@0.1.0`, `@webaseui/svelte@0.3.2` | External content, navigation, and search consumer trial | Button and Field replaced real category/search controls; check, lint, SSR build, Chromium interaction, axe, mobile reflow, and tree-shaking checks passed | Verified external trial | +| Sebastian1com/evaluacion-sveltekit-crud at `673cd63` (disposable checkout) | SvelteKit / Svelte 5.43.14 / form actions | Workspace candidate tarballs with version fields `0.1.0` / `0.3.2` | External create/read/update/delete workflow trial | Field, Textarea, and Button preserved native names, FormData, enhanced action submission, SSR build, axe subtree, mobile reflow, and tree shaking | Verified candidate trial | +| `examples/webaseui-svelte-consumer` | Vite / Svelte 5 | Registry-resolved package versions | Maintainer fixture for packed-artifact and registry smoke checks | `npm run check:registry -- --tag=latest` installed both packages from npm and built the fixture without workspace links | Verified fixture | +| WeMail | React | Does not consume `@webaseui/svelte` | Separate React application | Repository architecture is React; no Svelte package boundary exists | Not applicable | +| Fangcun | Next.js / React | Does not consume `@webaseui/svelte` | Separate React application | Repository architecture is React; no Svelte package boundary exists | Not applicable | + +The two React applications remain useful product contexts, but they do not +count toward the Svelte consumer requirement. A future React adapter must first +meet the framework-neutral token and behavior contracts; this matrix must not be +used to imply that adapter work has started. + +## Reproducible checks + +Run these from the WeBaseUI repository: + +```sh +npm run check:consumer +npm run check:registry -- --tag=latest +``` + +The registry smoke test resolves the current `latest` dist-tag for both public +packages, installs those versions into a temporary copy of the maintainer +fixture, builds it, and fails if either package resolves to this workspace. + +Run the external consumer check without changing its working tree: + +```sh +cd /Users/willxue/will/github/xue +npm ls @webaseui/core @webaseui/svelte --depth=0 +npm run check +``` + +At the status date above, this produced: + +```text +@webaseui/core@0.1.0 +@webaseui/svelte@0.3.2 +svelte-check found 0 errors and 0 warnings +``` + +The xue checkout already contains unrelated user changes. Adoption checks are +read-only and must not reset, clean, or otherwise overwrite that worktree. + +## Upgrade rehearsal + +The historical xue upgrade below satisfies the pre-1.0 minor-to-minor rehearsal. +Repeat the same process for every future minor in a disposable branch or +worktree: + +```sh +npm install @webaseui/core@ @webaseui/svelte@ +npm run check +npm run test:e2e +npm run build +``` + +Record the previous and new versions, any deprecation output, changed token or +component behavior, bundle-size result, and the exact rollback command. Do not +use a workspace link for this rehearsal; the purpose is to exercise the public +registry artifacts and the consumer's package boundary. + +### 2026-08-17 xue minor upgrade rehearsal + +The historical xue commit `2cf5bdd` was archived into a disposable checkout. It +was pinned to `@webaseui/svelte@0.2.0` and passed the baseline `npm run check` +and `npm run build`. The same checkout then upgraded to exact registry versions +`@webaseui/core@0.1.0` and `@webaseui/svelte@0.3.2`: + +| Check | Result | +| --- | --- | +| `npm ls @webaseui/core @webaseui/svelte --depth=0` | Exact `0.1.0` / `0.3.2` | +| `npm run check` | Passed; 0 Svelte diagnostics | +| `npm run build` | Passed; static production build generated | +| `npm run test:e2e` | 59/59 passed, including Chromium, Firefox smoke, and WebKit smoke | + +The original xue checkout was never modified. To roll back a real branch after +this rehearsal, restore its manifest and lockfile, then reinstall: + +```sh +git restore package.json package-lock.json +npm ci +``` + +## 2026-08-17 Fig registry adoption trial + +FruitsAI/Fig commit `3f37914a76a0ae2a0c04f9f29c84529285816990` +was cloned into a disposable directory. Exact public registry packages were +installed, core tokens and theme CSS were layered before the application's own +theme, and the article category buttons and search field were replaced with +`WeBaseButton` and `WeBaseField`. The source checkout at +`/Users/willxue/will/FruitsAI/Fig` was not modified. + +| Check | Result | +| --- | --- | +| `npm run check` | Passed; 0 errors and 0 warnings | +| `npm run lint` | Passed; Prettier and ESLint clean | +| `npm run build` | Passed; SvelteKit SSR and Cloudflare adapter output generated | +| Chromium interaction | Five category controls, search filtering, existing lightbox open/close, and pressed state passed | +| Accessibility and mobile | 0 serious/critical axe violations with reduced motion; no horizontal overflow at 390 x 844 | +| Tree shaking | Field and Button output present; Select, Dialog, and Tooltip signatures absent | +| Total client JS/CSS gzip | 79,717 B baseline to 83,214 B integrated, +3,497 B (+4.4%) | + +The published confirmation-oriented Dialog does not replace Fig's media +lightbox, which needs arbitrary image, caption, and future zoom behavior. That +remains an application composition rather than a forced component bypass. A +generic media-dialog proposal needs another concrete consumer and its own API +and accessibility specification. + +## 2026-08-17 CRUD candidate adoption trial + +Public repository `Sebastian1com/evaluacion-sveltekit-crud` commit +`673cd6347b57c4ad51bfe7d334056c4e15a28046` was cloned into a disposable +directory. Unlike the Fig trial, it installed tarballs packed from this +workspace so the unreleased native form-prop contract could be exercised. The +tarball version fields remain `0.1.0` / `0.3.2`; this evidence must not be +misread as a registry release. + +The create form used `WeBaseField`, `WeBaseTextarea`, and `WeBaseButton` while +the host application's existing edit/delete flows remained intact: + +| Check | Result | +| --- | --- | +| Candidate artifact boundary | Installed local tarballs; no workspace symlink or deep component import | +| Svelte check | Svelte Check 4.3.4 with TypeScript 5.9.3 passed; 0 errors and 0 warnings | +| SSR production build | Passed with Svelte 5.43.14 and Vite 7.2.4 | +| Native form contract | `inputProps.name=title` and `textareaProps.name=body` appeared in `FormData`; max length and submit type were preserved | +| Enhanced action | A real SvelteKit `?/crear` form action returned HTTP 200 | +| Accessibility and mobile | Candidate form subtree had 0 serious/critical axe violations; no horizontal overflow at 390 x 844; no browser console errors | +| Tree shaking | Field, Textarea, and Button output present; Select, Dialog, and Tooltip signatures absent | +| Total client JS/CSS gzip | 37,097 B baseline to 49,165 B integrated, +12,068 B (+32.5%) | + +The percentage increase is large because the host is a very small unstyled +demo and this is its first complete theme plus component dependency. The +absolute delta and absence of unused overlay code are the useful signals; the +result is recorded for review rather than normalized into the repository's +existing consumer baseline. A full-page axe scan also found the host's +pre-existing missing document title, so the component result is scoped to the +integrated form subtree and does not claim the host application is globally +accessible. + +The two-application and content/navigation plus form/CRUD adoption profiles are +now covered by external application trials. This closes the technical 1.0 +adoption gate; it does not replace registry publication, provenance, release +smoke, or the manual assistive-technology sign-off. + +## Adoption feedback ledger + +Update this table after each minor release or upgrade rehearsal. + +| Area | Current observation | Follow-up | +| --- | --- | --- | +| API escape hatches | All three external trials used package-root imports; the CRUD form validated native prop bags without wrapper DOM workarounds | Record any new root or native-prop escape hatch before adding a component | +| Theme overrides | xue and Fig layer application composition CSS over shared tokens; neither copied component CSS | Record token overrides that require component CSS copies; prefer a new semantic token | +| SSR and routing | xue E2E/build, Fig Cloudflare SSR build, and the CRUD form-action build passed | Repeat the relevant consumer build during each minor upgrade | +| Bundle impact | Fig added 4.4% total client gzip; the tiny CRUD demo added 32.5% on first theme adoption while unused overlays remained tree-shaken | Keep repository size gates authoritative and review absolute external deltas after each minor | +| Missing capability | Fig's media lightbox remains application-specific; no second consumer currently justifies a generic media dialog or a React adapter | Require a concrete second workflow plus API/a11y proposal before expanding the public surface | diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..917f4be --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,54 @@ +# Performance and package-size baseline + +The repository keeps a measured baseline in +[`package-size-baseline.json`](./package-size-baseline.json). It records the +packed sizes of `@webaseui/core` and `@webaseui/svelte`, the shipped Svelte +source footprint, each public component's source footprint, and the gzip sizes +of the consumer fixture's JavaScript and CSS output. + +Run the gate with: + +```sh +npm run check:size +``` + +An intentional change that increases a tracked metric by more than 5% must +include a short explanation in the pull request. After review, refresh the +numbers explicitly: + +```sh +npm run size:update +``` + +The baseline is a change detector, not a universal performance score. The +consumer bundle is the most useful signal for application impact; packed and +source sizes explain what changed inside the published artifacts. + +## Reviewed baseline changes + +### 2026-08-16: publish the core changelog + +`@webaseui/core` now ships an initial `CHANGELOG.md` and includes it in its +package `files` allowlist. The current dry-run tarballs are 6,556 B for core +and 40,732 B for Svelte, compared with the tracked 6,412 B and 40,289 B +baselines. Both remain below the 5% growth gate; the baseline is intentionally +not reset so the next package change is measured against the pre-changelog +artifact. + +### 2026-08-09: complete component token migration + +The component token contract moved geometry, typography, focus, and motion +values out of all 28 component declaration blocks. The public custom-property +names increase CSS text size while leaving JavaScript unchanged: + +| Metric | Previous | Current | Change | +| --- | ---: | ---: | ---: | +| Core CSS gzip | 2,854 B | 3,716 B | +862 B | +| Consumer CSS gzip | 3,572 B | 4,450 B | +878 B | +| Consumer JS gzip | 21,156 B | 21,156 B | 0 B | +| Svelte tarball | 31,026 B | 31,456 B | +430 B | + +This is an intentional theme-extensibility cost: consumers can now override +shared interaction and component geometry without copying component CSS. The +5% growth gate continues from the updated baseline; further token additions +should demonstrate reuse or a concrete theming requirement. diff --git a/docs/RC_READINESS.md b/docs/RC_READINESS.md new file mode 100644 index 0000000..75507cb --- /dev/null +++ b/docs/RC_READINESS.md @@ -0,0 +1,33 @@ +# 1.0 RC readiness + +This is the release-candidate audit for the current repository state. It is +deliberately evidence-based: a documented process is not marked passed until a +command, artifact, or human record exists. + +| Gate | Evidence | Status on 2026-08-17 | +| --- | --- | --- | +| Public API, token, and keyboard contract | `npm run check:api-contract`, `npm run check:tokens`, 28 component references, `API_CONTRACT.md`; fresh-install `npm run check` passed on 2026-08-17 | Passed in local CI-equivalent rerun | +| Chromium, Firefox, WebKit behavior | `npm run test:browser` with pinned Playwright engines (84/84 passed); `actionlint` validates workflow matrix | Passed in local CI-equivalent rerun | +| Automated accessibility | axe browser fixtures, `npm run check:contrast`, forced-colors/reduced-motion/coarse-pointer suites; included in 84/84 browser pass | Passed in local CI-equivalent rerun | +| Manual screen-reader audit | [`SCREEN_READER_AUDIT.md`](./SCREEN_READER_AUDIT.md) has VoiceOver/Safari and NVDA/Firefox-or-Chrome procedures | Pending human records | +| SSR, hydration, and tree shaking | `tests/ssr`, browser SSR spec, minimum/current consumer fixture, external Fig and CRUD trials; fresh-install consumer check passed | Passed in local CI-equivalent rerun | +| Support window | [`support-policy.json`](./support-policy.json), `npm run check:support`, Node 22/24 compatibility jobs | Ready | +| Visual and package-size gates | 47 Playwright visual tests passed; `npm run check:visual-baselines`, `docs/package-size-baseline.json`, 5% gate | Passed in local CI-equivalent rerun | +| P0/P1 and security | GitHub open issues: none; Dependabot alerts: none; security advisories: none; fresh-install `npm audit --audit-level=high`: 0 | Ready at local audit time; recheck immediately before release | +| Migration, versioning, security, and contribution policy | `VERSIONING.md`, `ADOPTION_MATRIX.md`, `SECURITY.md`, `CONTRIBUTING.md`, PR and issue templates | Ready | +| External consumers | xue minor upgrade, Fig registry trial, CRUD candidate tarball trial; content/navigation and form/CRUD profiles covered | Ready | +| npm publication, provenance, post-publish smoke | [`RELEASE_RUNBOOK.md`](./RELEASE_RUNBOOK.md), local `.github/workflows/release.yml`, OIDC configuration, and `scripts/registry-smoke.mjs --require-provenance`; the enforced mode correctly rejects current `@webaseui/core@0.1.0` for missing SLSA provenance. GitHub API returns 404 for `release.yml` on `origin/main`, local `npm whoami` is unauthenticated, and latest packages expose npm signatures but no release provenance attestation | Pending publishing the workflow, repository/npm trust setup, and one live release | + +## Required final sign-off + +The repository is not a 1.0 RC until the two pending rows have direct evidence: + +1. Attach completed VoiceOver/Safari and NVDA/Firefox or Chrome records. The + audit cannot be inferred from axe or browser snapshots. +2. Configure npm trusted publishing for both packages and the exact release + workflow, run one candidate or stable publication from GitHub Actions, verify + provenance, and let the post-publish registry smoke pass. + +The current public registry smoke is still useful: it proves the existing +`latest` packages install without workspace links. It is not provenance proof +for the next release and does not close the live publication row. diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md new file mode 100644 index 0000000..2e2cca0 --- /dev/null +++ b/docs/RELEASE_RUNBOOK.md @@ -0,0 +1,77 @@ +# Release Runbook + +This repository publishes `@webaseui/core` and `@webaseui/svelte` through +GitHub Actions. Do not run `npm publish` from a developer workstation. + +## Before The First Release + +1. Merge the release workflow at `.github/workflows/release.yml` onto the + default branch. The npm trusted publisher matches the exact repository and + workflow path, so an unpushed local workflow is not sufficient. +2. In npm package settings, add a GitHub Actions trusted publisher for both + packages with these values: + - Owner: `WeOpen` + - Repository: `WeBaseUI` + - Workflow filename: `release.yml` + - Environment: blank, unless the workflow is later changed to use one +3. Keep the stable job permissions at `contents: write`, + `pull-requests: write`, and `id-token: write`. The pre-release job needs + `id-token: write`. +4. Confirm the package access is public and that the npm account can manage + both package settings. No long-lived npm token belongs in GitHub secrets. + +## Stable Flow + +1. Add a changeset and merge it to `main`. +2. The release workflow runs the full quality, consumer, browser, and visual + gates, then creates or updates the Changesets release PR. +3. Merge the release PR. The stable job versions packages, synchronizes the + lockfile, publishes with OIDC and provenance, and runs the provenance-aware + registry smoke test. +4. Record the package versions, release commit, workflow run URL, npm + attestation metadata, and smoke result in the release review issue. + +The local version command is deliberately lockfile-aware: + +```sh +npm run version-packages +``` + +It runs `changeset version` followed by a package-lock-only install. The +workflow must be the source of the actual publish. + +## Verification + +For a published version, verify both packages expose the SLSA provenance +predicate before accepting the release: + +```sh +npm view @webaseui/core@VERSION dist.attestations --json +npm view @webaseui/svelte@VERSION dist.attestations --json +npm run check:registry -- --tag=latest --require-provenance +``` + +The metadata must include: + +```json +{ + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1" + } +} +``` + +The default `npm run check:registry -- --tag=latest` remains useful for +historical packages that predate provenance, but it is not a release sign-off. + +## Release Candidate Accessibility Sign-Off + +Complete [`SCREEN_READER_AUDIT.md`](./SCREEN_READER_AUDIT.md) in both required +environments before calling the candidate an RC: + +- macOS VoiceOver with Safari; +- Windows NVDA with Firefox or Chrome. + +Record the assistive-technology and browser versions, tester, date, every +scenario result, and linked issues. Axe, keyboard, and browser snapshots are +supporting evidence only and cannot replace these records. diff --git a/docs/SCREEN_READER_AUDIT.md b/docs/SCREEN_READER_AUDIT.md new file mode 100644 index 0000000..8b7acd4 --- /dev/null +++ b/docs/SCREEN_READER_AUDIT.md @@ -0,0 +1,54 @@ +# Screen-reader release audit + +Complete this audit for every release candidate. Automated axe and keyboard +checks remain mandatory, but they do not replace listening to the accessibility +tree through supported assistive technology. + +## Required environments + +| Environment | Browser | Input | Required result | +| --- | --- | --- | --- | +| macOS VoiceOver | Safari from a supported stable major | VoiceOver keys and keyboard | All scenarios below pass | +| Windows NVDA | Firefox or Chrome from a supported stable major | NVDA browse/focus modes and keyboard | All scenarios below pass | + +Record the operating system, assistive-technology version, browser version, +package version, tester, date, and issue link for every failure. A recording may +supplement the notes, but the written result must identify the announced name, +role, value or state, and focus destination. + +## Scenario matrix + +| Surface | Procedure | Expected announcement and behavior | +| --- | --- | --- | +| Button and IconButton | Focus, activate, then test disabled, loading, and pressed examples | Name and button role are announced once; disabled, busy, and pressed states are exposed when present | +| Field and Textarea | Read label, help, required, error, count, and disabled states; edit and reset a containing form | Label and description relationships are clear; invalid and required states are announced; editing is not duplicated | +| Check, Radio, Switch, and Slider | Move through each control, change its value, and test disabled or indeterminate states | Role, accessible name, current state or value, group context, and changes are announced | +| Select | Open with keyboard, move through enabled and disabled options, use typeahead, choose an option, and close with Escape | Combobox state, active option, position, disabled state, selection, and collapse are announced; focus returns to the trigger | +| Tabs and Accordion | Traverse with arrow keys, Home, and End; activate or collapse content | Tab/disclosure role, selected or expanded state, position, and associated panel are understandable | +| Dialog | Open from a known button, inspect title and description, cancel with Escape, confirm, and close with the close control | Modal context and title are announced; background content is unavailable; every close path returns focus to the opener | +| Tooltip | Focus and hover the trigger, dismiss with Escape, then move focus away | Trigger description is available without trapping focus; dismissed content is no longer announced | +| Alert and Toast | Trigger info, warning, error, and timed examples; pause a toast by focus or hover | Status or alert copy is announced once without moving focus; time-sensitive content remains available while paused | +| Breadcrumbs, Link, Pagination, and Tag | Navigate landmarks and current/selected states | Navigation labels, destinations, current page, and disabled controls are distinguishable | +| Loader, Progress, Skeleton, and EmptyState | Inspect loading, progress, completed, and empty examples | Decorative output stays silent; meaningful status/value text is announced without repetition | + +## Release record + +Copy this table for each candidate. `Blocked` requires a linked P0/P1 decision; +an RC cannot pass with a blocked required environment. + +| Candidate | Environment | Versions | Tester and date | Result | Issues | +| --- | --- | --- | --- | --- | --- | +| Unscheduled | VoiceOver / Safari | Not recorded | Not run | Pending | None | +| Unscheduled | NVDA / Firefox or Chrome | Not recorded | Not run | Pending | None | + +## Sign-off rules + +- Every scenario must pass in both required environments, or have a documented + browser/assistive-technology defect with an equivalent usable path and an RC + risk decision. +- Any focus loss, inaccessible name failure, state mismatch, or unavailable + operation is P0 or P1 until triaged. +- Fixes restart the affected environment and component scenario; changes to + shared overlay, focus, collection, or form behavior restart both environments. +- Attach the completed table to the minor release review and update the support + matrix only after both records are complete. diff --git a/docs/SUPPORT_MATRIX.md b/docs/SUPPORT_MATRIX.md new file mode 100644 index 0000000..a43382c --- /dev/null +++ b/docs/SUPPORT_MATRIX.md @@ -0,0 +1,48 @@ +# Support Matrix + +This is the frozen 1.0 support contract. Machine-readable values live in +[`support-policy.json`](./support-policy.json) and are checked by +`npm run check:support`. A supported version is part of a continuously tested +range, not a claim that every application toolchain combination is defect-free. + +| Surface | 1.0 support position | Current evidence | Status | +| --- | --- | --- | --- | +| Svelte | Peer range `>=5.20.0 <6`; Svelte 4 is out of scope | Packed consumers typecheck and build with Svelte 5.20.0 and the repository-current 5.56.8 | Frozen and validated | +| Node.js | Repository, consumer, and release tooling run on `^22.13.0 || ^24.0.0` | Blocking CI uses 22.13; the weekly compatibility workflow runs the same package gates on Node 24 | Frozen and validated | +| TypeScript | `>=5.5.4 <7` | Packed consumers typecheck with the minimum 5.5.4 and repository-current 6.0.3 compilers | Frozen and validated | +| Vite / Svelte plugin | Vite 6 with plugin 5.1.1 through Vite 8 with plugin 7.2.0 while they remain compatible with supported Svelte 5 | Minimum and current packed-consumer fixtures typecheck and build | Frozen and validated | +| Rendering | SSR-safe module loading and warning-free client hydration | Dedicated server-render and hydration fixtures run in Chromium, Firefox, and WebKit | Frozen and validated | +| Browsers | Chromium-family, Firefox, and Safari/WebKit current and previous stable major releases | Playwright 1.62.1 currently validates Chromium 151.0.7922.34, Firefox 153.0, and WebKit 26.5; the weekly non-blocking job exercises the latest Playwright engines | Frozen policy; pinned engines validated | +| Accessibility modes | WCAG 2.2 AA behavior for keyboard, focus, RTL, forced colors, reduced motion, coarse pointer, 200% reflow, and documented equivalent input | axe, keyboard, layout, and visual suites cover the automated surface | Frozen automated contract; manual screen-reader sign-off required per RC | + +## Browser version policy + +"Current and previous stable major releases" is evaluated when an RC or minor +release is cut, not when a user first reports an issue. Updating Playwright must +refresh the validated engine versions above and pass all three behavior suites. +Chromium coverage represents Chrome and Edge engine behavior; browser-specific +integration defects are still triaged separately. + +## Lifecycle policy + +- The latest `0.x` minor is the supported development line until 1.0. +- After 1.0, the latest minor of the current major receives routine fixes. The + immediately previous major receives critical security fixes for six months + after the next major is released. +- Security reports are acknowledged within five business days. Confirmed P0 + issues receive an immediate mitigation or release plan; P1 issues receive a + target release within ten business days unless an upstream dependency blocks it. +- A major release is the normal removal window for deprecated public API. + Deprecations remain documented and functional for at least one minor release + before 1.0 and for at least six months after 1.0. +- Node, TypeScript, Svelte, and browser windows are reviewed during every minor + release review. Dropping a supported version requires a Changeset and migration + note. + +## RC sign-off + +The automated support contract is frozen. Before declaring an RC ready, attach +the completed VoiceOver/Safari and NVDA/Firefox or Chrome records from +[`SCREEN_READER_AUDIT.md`](./SCREEN_READER_AUDIT.md), the two external consumer +records from [`ADOPTION_MATRIX.md`](./ADOPTION_MATRIX.md), and a release review +created from the repository issue template. diff --git a/docs/adr/0001-token-layers.md b/docs/adr/0001-token-layers.md new file mode 100644 index 0000000..c6b1924 --- /dev/null +++ b/docs/adr/0001-token-layers.md @@ -0,0 +1,68 @@ +# ADR 0001: Three-layer public token contract + +- Status: Accepted +- Date: 2026-08-06 +- Scope: `@webaseui/core` and all framework bindings + +## Context + +The core package already exposed color, typography, shadow, and motion custom +properties. They were useful, but their roles were mixed together. A consumer +could not tell whether a value was a raw palette choice, a semantic surface, +or a component geometry contract. Component styles also repeated control +heights, radii, and overlay layers in several files. + +## Decision + +`tokens.css` exposes three layers in one stable `--webase-*` namespace: + +1. Primitive tokens contain palette slots, spacing, sizes, type scales, z-index + levels, and motion values. +2. Semantic tokens map those values to roles such as canvas, surface, ink, + border, brand, and status. +3. Component tokens define reusable geometry and layering for controls, + buttons, choices, switches, selects, dialogs, tooltips, feedback, and + navigation components. + +Consumers should override semantic or component tokens. Primitive values are +available for advanced themes, but are not the preferred customization point. +The existing short aliases such as `--brand`, `--surface`, and `--focus-ring` +remain mapped to semantic tokens during the 1.x compatibility window. + +Dark mode changes the primitive palette slots under +`:root[data-theme='dark']`; semantic and component relationships stay intact. +This keeps theme changes from requiring a second copy of component CSS. + +The token contract is enforced with an exact component-style literal budget. +The checker counts signed `px`, `em`, `rem`, `ms`, `s`, and `deg` values in +public component declarations and compares them with +`docs/component-style-literal-baseline.json`. The current declaration count is +zero. Media-query conditions are extracted and checked separately; their three +pixel breakpoints remain explicit because custom properties cannot be used in +media-query conditions. Any change must update the baseline deliberately. + +Not every one-off presentation value becomes a public token. Values are +promoted when they represent shared interaction geometry, themeable component +geometry, layering, typography, or motion. Unique editorial layout values can +remain local until another component or consumer demonstrates a shared need. +Responsive breakpoints remain a small, separately audited exception. + +## Consequences + +- New components can consume a named geometry token instead of inventing a + one-off size or layer value. +- Brand themes can override semantic colors without copying component styles. +- The token checker validates required layers, references, and dark palette + overrides, plus the exact component literal budget, as part of + `npm run check`. +- Removing a compatibility alias is a breaking change and requires a + Changeset plus a migration note. + +## Alternatives considered + +- **Separate files per layer**: rejected for now because a single import is a + smaller consumer contract and preserves the current `theme.css` entrypoint. +- **Only semantic tokens**: rejected because spacing, type, and geometry need + shared primitives to prevent drift. +- **Component-local variables only**: rejected because they cannot support + cross-framework themes or a documented public override surface. diff --git a/docs/adr/0002-overlay-focus-infrastructure.md b/docs/adr/0002-overlay-focus-infrastructure.md new file mode 100644 index 0000000..7b3eead --- /dev/null +++ b/docs/adr/0002-overlay-focus-infrastructure.md @@ -0,0 +1,72 @@ +# ADR 0002: Platform-first internal overlay and focus infrastructure + +- Status: Accepted +- Date: 2026-08-08 +- Scope: `@webaseui/svelte` overlay components and future framework bindings + +## Context + +Dialog, Select, and Tooltip already implement related behavior: dismissal, +focus handling, body scroll locking, and layer ordering. Keeping these concerns +inside each component would make Menu, Popover, Combobox, and Drawer repeat the +same edge cases. Publishing a headless primitive API now would also freeze an +abstraction before existing components have proved it. + +## Decision + +WeBaseUI will build a small internal overlay layer and adopt it incrementally: + +1. Modal dialogs continue to use the native `` top layer for focus + trapping, Escape handling, background inertness, and focus restoration. +2. Body scroll locking is reference counted per document. Nested overlays do + not restore scrolling until the final lock releases, and the consumer's + previous inline overflow value is preserved. +3. Pointer-outside listening is shared. Components still decide whether an + outside action is cancellable and which callback it represents. +4. Non-modal overlays remain anchored in their component DOM and use + `popover="manual"` as a platform-first top-layer mechanism when supported. + A shared fixed-position collision primitive supplies the fallback and keeps + the DOM ancestry intact; components must not create one-off document-body + portals. +5. Layer ordering stays in `@webaseui/core` component tokens. Native dialog top + layer behavior is not emulated with an arbitrarily larger z-index. +6. Floating placement prefers the requested side, flips when the opposite side + has more room, shifts inside the visual viewport, matches Select trigger + width, constrains available height, and auto-updates on scroll, resize, + visual viewport changes, and anchor/floating `ResizeObserver` events. +7. Inline `start` and `end` placement resolve from the anchor's computed + direction. RTL support is inherited from the DOM rather than duplicated as + component props, and logical alignment remains collision constrained. +8. Internal helpers are not exported from the package root. Public exposure is + deferred until Dialog, Select, Tooltip, and at least one new overlay all use + the same contracts without component-specific escape hatches. + +Focus Scope, explicit focus restoration, Portal, and Presence remain planned +internal primitives. They should be added only when an existing component needs +them and can supply browser tests for the behavior. + +## Consequences + +- Dialog scroll locking now behaves correctly when overlays are nested. +- Select uses the same pointer-outside registration and short-lived typeahead + contracts future overlays and composite widgets can reuse. +- Select and Tooltip now share manual-popover synchronization and floating + collision behavior, including visual viewport offsets and nested scroll + containers. Their inline alignment follows the anchor's inherited direction. + Browsers without the Popover API use the same DOM with a fixed fallback rather + than losing theme inheritance. +- SSR remains safe because the helpers access documents only when called after + component mounting or dialog creation. +- Future overlay work has a defined migration order without committing 1.0 to + an unproven low-level public API. + +## Alternatives considered + +- **Adopt a third-party headless library immediately**: deferred because the + current native dialog behavior is strong, and dependency/API cost should be + justified by collision positioning or composite widgets we cannot maintain. +- **Portal every overlay to `document.body`**: rejected because it complicates + theming, SSR, containment, and nested ownership before positioning needs are + proven. +- **Keep utilities component-local**: rejected because nested scroll locks and + pointer-outside cleanup are cross-component invariants, not visual details. diff --git a/docs/component-style-literal-baseline.json b/docs/component-style-literal-baseline.json new file mode 100644 index 0000000..3438285 --- /dev/null +++ b/docs/component-style-literal-baseline.json @@ -0,0 +1,39 @@ +{ + "description": "Exact count of signed px, em, rem, ms, s, and deg literals in public component declarations. Media-query conditions are tracked separately because CSS custom properties cannot be used there. Any change requires an explicit baseline update.", + "total": 0, + "mediaQueries": { + "WeBaseEmptyState.svelte": ["620px"], + "WeBasePagination.svelte": ["480px"], + "WeBaseSectionHeader.svelte": ["700px"] + }, + "components": { + "WeBaseAccordion.svelte": 0, + "WeBaseAlert.svelte": 0, + "WeBaseBadge.svelte": 0, + "WeBaseBreadcrumbs.svelte": 0, + "WeBaseButton.svelte": 0, + "WeBaseCard.svelte": 0, + "WeBaseCheck.svelte": 0, + "WeBaseDialog.svelte": 0, + "WeBaseDivider.svelte": 0, + "WeBaseEmptyState.svelte": 0, + "WeBaseField.svelte": 0, + "WeBaseIcon.svelte": 0, + "WeBaseIconButton.svelte": 0, + "WeBaseLink.svelte": 0, + "WeBaseLoader.svelte": 0, + "WeBasePagination.svelte": 0, + "WeBaseProgress.svelte": 0, + "WeBaseRadio.svelte": 0, + "WeBaseSectionHeader.svelte": 0, + "WeBaseSelect.svelte": 0, + "WeBaseSkeleton.svelte": 0, + "WeBaseSlider.svelte": 0, + "WeBaseSwitch.svelte": 0, + "WeBaseTabs.svelte": 0, + "WeBaseTag.svelte": 0, + "WeBaseTextarea.svelte": 0, + "WeBaseToast.svelte": 0, + "WeBaseTooltip.svelte": 0 + } +} diff --git a/docs/package-size-baseline.json b/docs/package-size-baseline.json new file mode 100644 index 0000000..eff7662 --- /dev/null +++ b/docs/package-size-baseline.json @@ -0,0 +1,150 @@ +{ + "schemaVersion": 1, + "platform": "darwin", + "packages": { + "core": { + "tarballBytes": 6412, + "unpackedBytes": 24054, + "css": { + "bytes": 18769, + "gzipBytes": 3914 + } + }, + "svelte": { + "tarballBytes": 40289, + "unpackedBytes": 174585, + "dist": { + "bytes": 150786, + "gzipBytes": 27176 + }, + "entry": { + "bytes": 7663, + "gzipBytes": 732 + }, + "components": { + "WeBaseAccordion": { + "bytes": 4217, + "gzipBytes": 1645 + }, + "WeBaseAlert": { + "bytes": 3874, + "gzipBytes": 1284 + }, + "WeBaseBadge": { + "bytes": 1846, + "gzipBytes": 836 + }, + "WeBaseBreadcrumbs": { + "bytes": 1911, + "gzipBytes": 874 + }, + "WeBaseButton": { + "bytes": 4013, + "gzipBytes": 1323 + }, + "WeBaseCard": { + "bytes": 5008, + "gzipBytes": 1619 + }, + "WeBaseCheck": { + "bytes": 2734, + "gzipBytes": 1043 + }, + "WeBaseDialog": { + "bytes": 5945, + "gzipBytes": 1917 + }, + "WeBaseDivider": { + "bytes": 1390, + "gzipBytes": 606 + }, + "WeBaseEmptyState": { + "bytes": 3623, + "gzipBytes": 1321 + }, + "WeBaseField": { + "bytes": 3703, + "gzipBytes": 1321 + }, + "WeBaseIcon": { + "bytes": 3473, + "gzipBytes": 1052 + }, + "WeBaseIconButton": { + "bytes": 2186, + "gzipBytes": 921 + }, + "WeBaseLink": { + "bytes": 3187, + "gzipBytes": 1156 + }, + "WeBaseLoader": { + "bytes": 1521, + "gzipBytes": 687 + }, + "WeBasePagination": { + "bytes": 3375, + "gzipBytes": 1299 + }, + "WeBaseProgress": { + "bytes": 1909, + "gzipBytes": 863 + }, + "WeBaseRadio": { + "bytes": 2479, + "gzipBytes": 958 + }, + "WeBaseSectionHeader": { + "bytes": 2311, + "gzipBytes": 963 + }, + "WeBaseSelect": { + "bytes": 15213, + "gzipBytes": 3842 + }, + "WeBaseSkeleton": { + "bytes": 2006, + "gzipBytes": 844 + }, + "WeBaseSlider": { + "bytes": 4217, + "gzipBytes": 1436 + }, + "WeBaseSwitch": { + "bytes": 2885, + "gzipBytes": 1004 + }, + "WeBaseTabs": { + "bytes": 3337, + "gzipBytes": 1366 + }, + "WeBaseTag": { + "bytes": 2865, + "gzipBytes": 1018 + }, + "WeBaseTextarea": { + "bytes": 3423, + "gzipBytes": 1305 + }, + "WeBaseToast": { + "bytes": 4164, + "gzipBytes": 1681 + }, + "WeBaseTooltip": { + "bytes": 5018, + "gzipBytes": 1664 + } + } + } + }, + "consumer": { + "jsBytes": 58027, + "jsGzipBytes": 21156, + "cssBytes": 24393, + "cssGzipBytes": 4754, + "htmlBytes": 409, + "htmlGzipBytes": 274, + "totalBytes": 82829, + "totalGzipBytes": 26184 + } +} diff --git a/docs/support-policy.json b/docs/support-policy.json new file mode 100644 index 0000000..91e4258 --- /dev/null +++ b/docs/support-policy.json @@ -0,0 +1,24 @@ +{ + "node": { + "engine": "^22.13.0 || ^24.0.0", + "release": "22.13", + "compatibility": "24" + }, + "svelte": { + "peer": ">=5.20.0 <6", + "minimum": "5.20.0" + }, + "typescript": { + "minimum": "5.5.4", + "maximumExclusive": "7.0.0" + }, + "browsers": { + "policy": "current and previous stable major releases", + "playwright": "1.62.1", + "validated": { + "chromium": "151.0.7922.34", + "firefox": "153.0", + "webkit": "26.5" + } + } +} diff --git a/examples/webaseui-svelte-consumer/package.json b/examples/webaseui-svelte-consumer/package.json index 6520174..ab0b777 100644 --- a/examples/webaseui-svelte-consumer/package.json +++ b/examples/webaseui-svelte-consumer/package.json @@ -4,16 +4,19 @@ "version": "0.0.0", "type": "module", "scripts": { - "build": "vite build" + "build": "vite build", + "typecheck": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { "@webaseui/core": "0.1.0", - "@webaseui/svelte": "0.3.0", - "svelte": "^5.56.8" + "@webaseui/svelte": "0.3.2", + "svelte": "5.56.8" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^7.2.0", - "typescript": "^6.0.3", - "vite": "^8.1.5" + "@sveltejs/vite-plugin-svelte": "7.2.0", + "@types/node": "22.20.1", + "svelte-check": "4.7.4", + "typescript": "6.0.3", + "vite": "8.1.5" } } diff --git a/examples/webaseui-svelte-consumer/src/App.svelte b/examples/webaseui-svelte-consumer/src/App.svelte index ffabffd..47bc4e1 100644 --- a/examples/webaseui-svelte-consumer/src/App.svelte +++ b/examples/webaseui-svelte-consumer/src/App.svelte @@ -1,6 +1,7 @@ + +
+ `الصفحة ${number.format(value)}`} + formatPage={(value) => number.format(value)} + /> + `${number.format(value)} بالمئة`} + /> +
+``` diff --git a/packages/webaseui-svelte/VERSIONING.md b/packages/webaseui-svelte/VERSIONING.md index 27df3a4..1f6b8a7 100644 --- a/packages/webaseui-svelte/VERSIONING.md +++ b/packages/webaseui-svelte/VERSIONING.md @@ -24,7 +24,7 @@ The following are public API and require version review: - documented props, defaults, bindings, snippets, and root attribute behavior; - `--webase-*` CSS custom properties and theme selectors; - keyboard behavior, focus management, ARIA relationships, and live regions; -- supported Svelte peer dependency ranges. +- supported Svelte peer dependency ranges. The package currently requires Svelte 5.20 or newer because it uses the stable `$props.id()` rune. Internal component paths, `ds-*` implementation classes, private helpers, and undocumented markup structure are not public API. @@ -41,3 +41,20 @@ undocumented markup structure are not public API. Accessibility fixes may adjust undocumented DOM structure in a patch release, but must preserve the documented interaction contract and pass browser tests. + +## Automated publication + +The `Release` GitHub Actions workflow is the only supported publication path. +Its quality jobs run the complete package, consumer, browser, and visual gates +before the Changesets action creates a release PR or publishes a merged release. +The publish job grants `id-token: write`, upgrades npm to a trusted-publishing +compatible version, and sets `NPM_CONFIG_PROVENANCE=true`; npm must be configured +in advance to trust this repository and workflow for each package. No long-lived +`NPM_TOKEN` is stored in GitHub secrets. + +After a stable publish, the workflow resolves both packages from the `latest` +dist-tag and builds the registry consumer fixture. Manual `workflow_dispatch` +runs can select `next` or `canary`; those runs enter Changesets pre-release mode, +publish with the selected dist-tag, and run the same registry smoke test. Stable +and pre-release tags are independent and are never overwritten by a local +`npm publish` command. diff --git a/packages/webaseui-svelte/package.json b/packages/webaseui-svelte/package.json index 7644a14..fcb6b48 100644 --- a/packages/webaseui-svelte/package.json +++ b/packages/webaseui-svelte/package.json @@ -23,19 +23,22 @@ "files": [ "dist", "README.md", + "API_CONTRACT.md", "CHANGELOG.md", - "VERSIONING.md" + "VERSIONING.md", + "LICENSE" ], "scripts": { "build": "svelte-package", - "prepack": "npm run build" + "prepack": "npm run build", + "typecheck": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { "@lucide/svelte": "^1.28.0", "@webaseui/core": "0.1.0" }, "peerDependencies": { - "svelte": "^5.0.0" + "svelte": ">=5.20.0 <6" }, "devDependencies": { "@sveltejs/package": "^2.5.8", diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseAccordion.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseAccordion.svelte index 606feca..338d217 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseAccordion.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseAccordion.svelte @@ -1,23 +1,28 @@ @@ -35,7 +40,7 @@ onclick={() => toggle(index)} onkeydown={(event) => handleKeydown(event, index)} > - {String(index + 1).padStart(2, '0')}{item.title} + {formatIndex(index + 1)}{item.title} @@ -48,19 +53,19 @@ diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseAlert.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseAlert.svelte index 3028030..f4b44f0 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseAlert.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseAlert.svelte @@ -5,21 +5,22 @@ type Tone = 'info' | 'success' | 'warning' | 'error'; - interface Props extends Omit, 'children' | 'title'> { + interface Props extends Omit, 'children' | 'title'> { title: string; message?: string; tone?: Tone; dismissible?: boolean; + dismissLabel?: string; open?: boolean; children?: Snippet; actions?: Snippet; } - let { title, message = '', tone = 'info', dismissible = false, open = $bindable(true), children, actions, class: className = '', ...rest }: Props = $props(); + let { title, message = '', tone = 'info', dismissible = false, dismissLabel = 'Dismiss alert', open = $bindable(true), children, actions, class: className = '', ...rest }: Props = $props(); {#if open} - + {/if} diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseBadge.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseBadge.svelte index e8e090f..1da6e08 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseBadge.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseBadge.svelte @@ -23,19 +23,19 @@ .ds-badge { display: inline-flex; align-items: center; - gap: 6px; - min-height: 24px; - padding: 3px 8px; - border: 1px solid currentColor; - border-radius: 999px; + gap: var(--webase-component-badge-gap); + min-height: var(--webase-component-badge-min-height); + padding: var(--webase-component-badge-padding-block) var(--webase-component-badge-padding-inline); + border: var(--webase-border-thin) solid currentColor; + border-radius: var(--webase-radius-pill); font-family: var(--sans); - font-size: 10px; - letter-spacing: .1em; + font-size: var(--webase-font-size-overline); + letter-spacing: var(--webase-letter-spacing-meta); line-height: 1; text-transform: uppercase; } .ds-badge-canonical { color: var(--brand); background: var(--brand-tint); } .ds-badge-one-off { color: var(--ink-muted); background: var(--surface-muted); } .ds-badge-experiment { color: var(--status-warning); background: var(--status-warning-field); } - i { display: block; width: 5px; height: 5px; border-radius: 50%; background: currentColor; } + i { display: block; width: var(--webase-component-badge-dot-size); height: var(--webase-component-badge-dot-size); border-radius: var(--webase-radius-circle); background: currentColor; } diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseBreadcrumbs.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseBreadcrumbs.svelte index a59b627..c5c7d6a 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseBreadcrumbs.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseBreadcrumbs.svelte @@ -1,14 +1,14 @@ -
diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseDivider.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseDivider.svelte index 198aa3e..8e46e89 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseDivider.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseDivider.svelte @@ -14,7 +14,7 @@ {/if} diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseEmptyState.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseEmptyState.svelte index 818e287..d2d4af3 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseEmptyState.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseEmptyState.svelte @@ -3,29 +3,31 @@ import type { HTMLAttributes } from 'svelte/elements'; import WeBaseIcon from './WeBaseIcon.svelte'; - interface Props extends Omit, 'children' | 'onclick' | 'title'> { title?: string; message?: string; actionLabel?: string; onclick?: (event: MouseEvent) => void; children?: Snippet; actions?: Snippet; } - let { title = 'No notes in this drawer', message = 'Start a new note or adjust the current filters.', actionLabel = 'Create note', onclick, children, actions, class: className = '', ...rest }: Props = $props(); + interface Props extends Omit, 'children' | 'onclick' | 'title'> { kicker?: string; title?: string; message?: string; actionLabel?: string; onclick?: (event: MouseEvent) => void; children?: Snippet; actions?: Snippet; } + let { kicker = 'Empty / archive', title = 'No notes in this drawer', message = 'Start a new note or adjust the current filters.', actionLabel = 'Create note', onclick, children, actions, class: className = '', ...rest }: Props = $props();
-

Empty / archive

{title}

{#if children}{@render children()}{:else}{message}{/if}
+

{kicker}

{title}

{#if children}{@render children()}{:else}{message}{/if}
{#if actions}
{@render actions()}
- {:else} + {:else if onclick} {/if}
diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseField.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseField.svelte index a2915bb..c5600f3 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseField.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseField.svelte @@ -1,5 +1,7 @@ diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseIcon.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseIcon.svelte index 87c39c0..c251a8a 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseIcon.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseIcon.svelte @@ -7,6 +7,7 @@ export type WeBaseIconName = | 'archive' | 'archive-x' + | 'arrow-down' | 'arrow-left' | 'arrow-right' | 'arrow-up-right' @@ -31,11 +32,34 @@ - + diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseIconButton.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseIconButton.svelte index ad2ed37..db2e952 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseIconButton.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseIconButton.svelte @@ -10,18 +10,20 @@ onclick?: (event: MouseEvent) => void; } - let { label, icon = 'arrow-up-right', pressed = $bindable(false), disabled = false, onclick, class: className = '', ...rest }: Props = $props(); + let { label, icon = 'arrow-up-right', pressed = $bindable(), disabled = false, onclick, class: className = '', ...rest }: Props = $props(); + const isPressed = $derived(pressed === true); - diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseLink.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseLink.svelte index 6390383..bfdb54b 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseLink.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseLink.svelte @@ -45,21 +45,22 @@ .ds-link { display: inline-flex; align-items: center; - gap: 7px; + gap: var(--webase-component-link-gap); color: inherit; - text-decoration-thickness: 1px; - text-underline-offset: 4px; + text-decoration-thickness: var(--webase-border-thin); + text-underline-offset: var(--webase-space-2); transition: color var(--duration-ui) var(--ease-out), border-color var(--duration-ui) var(--ease-out), background-color var(--duration-ui) var(--ease-out), transform var(--duration-fast) var(--ease-out); } - .ds-link:hover { color: var(--brand); } .ds-link:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-ring-offset); } .ds-link-inline { color: var(--brand); text-decoration-line: underline; text-decoration-color: var(--hairline-strong); } - .ds-link-inline:hover { text-decoration-color: var(--brand); } - .ds-link-back, .ds-link-nav, .ds-link-action { font-family: var(--sans); font-size: 10px; letter-spacing: var(--track-nav); text-transform: uppercase; } + .ds-link-back, .ds-link-nav, .ds-link-action { font-family: var(--sans); font-size: var(--webase-font-size-overline); letter-spacing: var(--track-nav); text-transform: uppercase; } + .ds-link-back, .ds-link-nav { min-height: var(--webase-interactive-target-min); } .ds-link-back { color: var(--ink-muted); text-decoration: none; } .ds-link-nav { color: var(--ink-muted); text-decoration: none; } - .ds-link-action { min-height: 36px; padding: 0 12px; border: 1px solid var(--hairline-strong); background: transparent; text-decoration: none; } - .ds-link-action:hover { border-color: var(--brand); background: var(--brand-tint); transform: translateY(-1px); } + .ds-link-action { min-height: var(--webase-size-control-sm); padding: 0 var(--webase-space-6); border: var(--webase-border-thin) solid var(--hairline-strong); background: transparent; text-decoration: none; } .ds-link-action:active { transform: translateY(0); } + .ds-link > span { min-width: 0; overflow-wrap: anywhere; } + :global([dir='rtl']) .ds-link-back :global(svg), :global([dir='rtl']) .ds-link-action :global(svg) { transform: scaleX(-1); } + @media (hover: hover) and (pointer: fine) { .ds-link:hover { color: var(--brand); } .ds-link-inline:hover { text-decoration-color: var(--brand); } .ds-link-action:hover { border-color: var(--brand); background: var(--brand-tint); transform: translateY(var(--webase-component-link-hover-offset)); } } @media (prefers-reduced-motion: reduce) { .ds-link { transition: none; } } diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseLoader.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseLoader.svelte index 7a3206a..16ef1d9 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseLoader.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseLoader.svelte @@ -8,11 +8,11 @@
{label}
diff --git a/packages/webaseui-svelte/src/lib/components/WeBasePagination.svelte b/packages/webaseui-svelte/src/lib/components/WeBasePagination.svelte index 38c614f..5104a64 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBasePagination.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBasePagination.svelte @@ -1,28 +1,56 @@ diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseProgress.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseProgress.svelte index 67de33f..ac62bf0 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseProgress.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseProgress.svelte @@ -1,23 +1,24 @@
-
{label}{safeValue}%
+
{label}{formatValue(safeValue)}
diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseRadio.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseRadio.svelte index 4ac51f6..5187eed 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseRadio.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseRadio.svelte @@ -1,25 +1,27 @@ diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseSectionHeader.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseSectionHeader.svelte index f89da2c..d731480 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseSectionHeader.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseSectionHeader.svelte @@ -32,39 +32,39 @@ display: grid; grid-template-columns: 1fr minmax(0, 2fr); align-items: end; - column-gap: 32px; - margin-bottom: 34px; + column-gap: var(--webase-space-14); + margin-bottom: var(--webase-component-section-header-margin-bottom); } .ds-section-kicker { grid-column: 1 / -1; - margin: 0 0 12px; + margin: 0 0 var(--webase-space-6); color: var(--brand); font-family: var(--mono); - font-size: 10px; - letter-spacing: .14em; + font-size: var(--webase-font-size-overline); + letter-spacing: var(--webase-component-section-header-kicker-letter-spacing); text-transform: uppercase; } h2 { margin: 0; font-family: var(--font); - font-size: clamp(38px, 6vw, 72px); + font-size: clamp(var(--webase-component-section-header-title-min-size), var(--webase-component-section-header-title-fluid-size), var(--webase-component-section-header-title-max-size)); font-weight: 500; - letter-spacing: -.06em; + letter-spacing: var(--webase-component-section-header-title-letter-spacing); line-height: .9; } .ds-section-note { - max-width: 280px; + max-width: var(--webase-component-section-header-note-max-width); margin: 0; color: var(--ink-muted); - font-size: 14px; - line-height: 1.45; + font-size: var(--webase-font-size-body); + line-height: var(--webase-line-height-body); } @media (max-width: 700px) { .ds-section-head { display: block; } - h2 { margin-bottom: 14px; } + h2 { margin-bottom: var(--webase-space-7); } } diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseSelect.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseSelect.svelte index 095cec5..5d35e79 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseSelect.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseSelect.svelte @@ -1,21 +1,34 @@
{label} + - diff --git a/packages/webaseui-svelte/src/lib/components/WeBaseTooltip.svelte b/packages/webaseui-svelte/src/lib/components/WeBaseTooltip.svelte index caa3a76..6c1e319 100644 --- a/packages/webaseui-svelte/src/lib/components/WeBaseTooltip.svelte +++ b/packages/webaseui-svelte/src/lib/components/WeBaseTooltip.svelte @@ -1,38 +1,120 @@ - + - {text} + diff --git a/packages/webaseui-svelte/src/lib/index.ts b/packages/webaseui-svelte/src/lib/index.ts index 43c9903..c8ea7ad 100644 --- a/packages/webaseui-svelte/src/lib/index.ts +++ b/packages/webaseui-svelte/src/lib/index.ts @@ -29,6 +29,15 @@ export { default as WeBaseTextarea } from './components/WeBaseTextarea.svelte'; export { default as WeBaseToast } from './components/WeBaseToast.svelte'; export { default as WeBaseTooltip } from './components/WeBaseTooltip.svelte'; export type { WeBaseIconName } from './components/WeBaseIcon.svelte'; +export type { + WeBaseAccordionItem, + WeBaseBreadcrumbItem, + WeBaseCountFormatter, + WeBaseNumberFormatter, + WeBasePageLabel, + WeBaseSelectOption, + WeBaseSliderValueFormatter +} from './types.js'; export type WeBaseAccordionProps = ComponentProps; export type WeBaseAlertProps = ComponentProps; diff --git a/packages/webaseui-svelte/src/lib/internal/dialog-lifecycle.ts b/packages/webaseui-svelte/src/lib/internal/dialog-lifecycle.ts new file mode 100644 index 0000000..70a4b8c --- /dev/null +++ b/packages/webaseui-svelte/src/lib/internal/dialog-lifecycle.ts @@ -0,0 +1,76 @@ +import { acquireBodyScrollLock } from './overlay.js'; + +interface FocusTarget extends Element { + focus(): void; +} + +type ScheduleFocusRestore = (callback: () => void) => void; + +function asFocusTarget(value: unknown): FocusTarget | undefined { + if (!value || typeof value !== 'object' || !('focus' in value)) return; + return typeof value.focus === 'function' ? value as FocusTarget : undefined; +} + +export function createDialogLifecycle( + scheduleFocusRestore: ScheduleFocusRestore = queueMicrotask +) { + let focusTarget: FocusTarget | undefined; + let focusDocument: Document | undefined; + let activeDialog: HTMLDialogElement | undefined; + let lastInteractionTarget: FocusTarget | undefined; + let lastInteractionAt = 0; + + function listenForInteractionTarget(targetDocument: Document): () => void { + const handleClick = (event: MouseEvent) => { + lastInteractionTarget = event.target === targetDocument.body ? undefined : asFocusTarget(event.target); + lastInteractionAt = lastInteractionTarget ? Date.now() : 0; + }; + + targetDocument.addEventListener('click', handleClick, true); + return () => targetDocument.removeEventListener('click', handleClick, true); + } + + function synchronize(dialog: HTMLDialogElement, requestedOpen: boolean): (() => void) | undefined { + if (!requestedOpen) { + if (dialog.open) dialog.close(); + return; + } + + if (!dialog.open) { + const activeElement = dialog.ownerDocument.activeElement; + const recentInteractionTarget = lastInteractionTarget && Date.now() - lastInteractionAt <= 1_000 + ? lastInteractionTarget + : undefined; + focusTarget = activeElement === dialog.ownerDocument.body + ? recentInteractionTarget + : asFocusTarget(activeElement) ?? recentInteractionTarget; + lastInteractionTarget = undefined; + lastInteractionAt = 0; + focusDocument = dialog.ownerDocument; + activeDialog = dialog; + dialog.showModal(); + } + + return acquireBodyScrollLock(dialog.ownerDocument); + } + + function restoreFocus() { + const target = focusTarget; + const targetDocument = focusDocument; + const dialog = activeDialog; + focusTarget = undefined; + focusDocument = undefined; + activeDialog = undefined; + + if (!target || !targetDocument || target.isConnected === false) return; + scheduleFocusRestore(() => { + if (target.isConnected === false) return; + const current = targetDocument.activeElement; + if ( + !current || current === targetDocument.body || current === dialog || current === target || dialog?.contains(current) + ) target.focus(); + }); + } + + return { listenForInteractionTarget, restoreFocus, synchronize }; +} diff --git a/packages/webaseui-svelte/src/lib/internal/floating-position.ts b/packages/webaseui-svelte/src/lib/internal/floating-position.ts new file mode 100644 index 0000000..64557de --- /dev/null +++ b/packages/webaseui-svelte/src/lib/internal/floating-position.ts @@ -0,0 +1,226 @@ +export type FloatingSide = 'bottom' | 'top'; +export type FloatingAlign = 'start' | 'center' | 'end'; +export type FloatingDirection = 'ltr' | 'rtl'; + +export interface FloatingRect { + top: number; + right: number; + bottom: number; + left: number; + width: number; + height: number; +} + +export interface FloatingViewport { + x: number; + y: number; + width: number; + height: number; +} + +export interface FloatingPositionOptions { + side?: FloatingSide; + align?: FloatingAlign; + direction?: FloatingDirection; + offset?: number; + viewportPadding?: number; + matchAnchorWidth?: boolean; + maxWidth?: number; + maxHeight?: number; +} + +export interface FloatingPosition { + x: number; + y: number; + width?: number; + maxWidth: number; + maxHeight: number; + side: FloatingSide; + align: FloatingAlign; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(Math.max(value, minimum), maximum); +} + +function bounded(value: number): number { + return Math.max(0, value); +} + +/** Calculate a fixed-position placement inside a visual viewport boundary. */ +export function computeFloatingPosition( + anchor: FloatingRect, + floating: Pick, + viewport: FloatingViewport, + options: FloatingPositionOptions = {} +): FloatingPosition { + const preferredSide = options.side ?? 'bottom'; + const align = options.align ?? 'start'; + const direction = options.direction ?? 'ltr'; + const offset = bounded(options.offset ?? 0); + const padding = bounded(options.viewportPadding ?? 0); + const viewportLeft = viewport.x + padding; + const viewportTop = viewport.y + padding; + const viewportRight = viewport.x + viewport.width - padding; + const viewportBottom = viewport.y + viewport.height - padding; + const availableWidth = bounded(viewportRight - viewportLeft); + const configuredMaxWidth = options.maxWidth ?? availableWidth; + const renderedWidth = Math.min( + options.matchAnchorWidth ? anchor.width : floating.width, + configuredMaxWidth, + availableWidth + ); + const maximumX = Math.max(viewportLeft, viewportRight - renderedWidth); + const alignedX = align === 'center' + ? anchor.left + (anchor.width - renderedWidth) / 2 + : align === 'start' + ? direction === 'rtl' ? anchor.right - renderedWidth : anchor.left + : direction === 'rtl' ? anchor.left : anchor.right - renderedWidth; + const x = clamp(alignedX, viewportLeft, maximumX); + + const space = { + bottom: bounded(viewportBottom - anchor.bottom - offset), + top: bounded(anchor.top - viewportTop - offset) + }; + const alternateSide: FloatingSide = preferredSide === 'bottom' ? 'top' : 'bottom'; + const desiredHeight = Math.min(floating.height, options.maxHeight ?? floating.height); + const side = desiredHeight > space[preferredSide] && space[alternateSide] > space[preferredSide] + ? alternateSide + : preferredSide; + const maxHeight = Math.min(options.maxHeight ?? space[side], space[side]); + const renderedHeight = Math.min(floating.height, maxHeight); + const y = side === 'bottom' + ? anchor.bottom + offset + : anchor.top - offset - renderedHeight; + + return { + x, + y, + width: options.matchAnchorWidth ? renderedWidth : undefined, + maxWidth: Math.min(configuredMaxWidth, availableWidth), + maxHeight, + side, + align + }; +} + +function px(value: number): string { + return `${Math.round(value * 1_000) / 1_000}px`; +} + +function viewportFor(element: HTMLElement): FloatingViewport { + const view = element.ownerDocument.defaultView; + const visualViewport = view?.visualViewport; + return { + x: visualViewport?.offsetLeft ?? 0, + y: visualViewport?.offsetTop ?? 0, + width: visualViewport?.width ?? view?.innerWidth ?? element.ownerDocument.documentElement.clientWidth, + height: visualViewport?.height ?? view?.innerHeight ?? element.ownerDocument.documentElement.clientHeight + }; +} + +/** Read a resolved length-valued custom property without leaking DOM access to SSR. */ +export function readCssPixel(element: HTMLElement, property: string, fallback = 0): number { + const value = element.ownerDocument.defaultView + ?.getComputedStyle(element) + .getPropertyValue(property); + const parsed = Number.parseFloat(value ?? ''); + return Number.isFinite(parsed) ? parsed : fallback; +} + +/** Measure and apply one floating placement, returning the applied geometry. */ +export function positionFloatingElement( + anchor: HTMLElement, + floating: HTMLElement, + options: FloatingPositionOptions = {} +): FloatingPosition { + const anchorRect = anchor.getBoundingClientRect(); + const viewport = viewportFor(anchor); + const view = anchor.ownerDocument.defaultView as (Window & { getComputedStyle?: (element: Element) => CSSStyleDeclaration }) | null; + const direction = view && typeof view.getComputedStyle === 'function' && view.getComputedStyle(anchor).direction === 'rtl' + ? 'rtl' + : options.direction; + const padding = bounded(options.viewportPadding ?? 0); + const availableWidth = bounded(viewport.width - padding * 2); + const maxWidth = Math.min(options.maxWidth ?? availableWidth, availableWidth); + + floating.style.width = options.matchAnchorWidth + ? px(Math.min(anchorRect.width, availableWidth)) + : ''; + floating.style.maxWidth = px(maxWidth); + floating.style.maxHeight = options.maxHeight === undefined ? '' : px(options.maxHeight); + + const floatingRect = floating.getBoundingClientRect(); + const borderHeight = bounded(floatingRect.height - floating.clientHeight); + const intrinsicHeight = Math.max(floatingRect.height, floating.scrollHeight + borderHeight); + const position = computeFloatingPosition( + anchorRect, + { width: floatingRect.width, height: intrinsicHeight }, + viewport, + { ...options, direction } + ); + + floating.style.left = px(position.x); + floating.style.top = px(position.y); + floating.style.width = position.width === undefined ? '' : px(position.width); + floating.style.maxWidth = px(position.maxWidth); + floating.style.maxHeight = px(position.maxHeight); + floating.dataset.side = position.side; + floating.dataset.align = position.align; + return position; +} + +type ResizeObserverWindow = Window & typeof globalThis & { + ResizeObserver?: typeof ResizeObserver; +}; + +/** + * Reposition on viewport, scroll-container, visual-viewport, and element-size + * changes. Scroll uses capture so non-bubbling nested scroll events are seen. + */ +export function autoUpdateFloatingPosition( + anchor: HTMLElement, + floating: HTMLElement, + update: () => void +): () => void { + const view = anchor.ownerDocument.defaultView as ResizeObserverWindow | null; + if (!view) { + update(); + return () => {}; + } + + let frame: number | undefined; + let stopped = false; + const schedule = () => { + if (stopped || frame !== undefined) return; + if (typeof view.requestAnimationFrame !== 'function') { + update(); + return; + } + frame = view.requestAnimationFrame(() => { + frame = undefined; + if (!stopped) update(); + }); + }; + + update(); + view.addEventListener('resize', schedule); + view.addEventListener('scroll', schedule, true); + view.visualViewport?.addEventListener('resize', schedule); + view.visualViewport?.addEventListener('scroll', schedule); + + const observer = view.ResizeObserver ? new view.ResizeObserver(schedule) : undefined; + observer?.observe(anchor); + observer?.observe(floating); + + return () => { + if (stopped) return; + stopped = true; + view.removeEventListener('resize', schedule); + view.removeEventListener('scroll', schedule, true); + view.visualViewport?.removeEventListener('resize', schedule); + view.visualViewport?.removeEventListener('scroll', schedule); + observer?.disconnect(); + if (frame !== undefined) view.cancelAnimationFrame(frame); + }; +} diff --git a/packages/webaseui-svelte/src/lib/internal/overlay.ts b/packages/webaseui-svelte/src/lib/internal/overlay.ts new file mode 100644 index 0000000..a0c4e0c --- /dev/null +++ b/packages/webaseui-svelte/src/lib/internal/overlay.ts @@ -0,0 +1,68 @@ +interface ScrollLockDocument { + body: { + style: { + overflow: string; + }; + }; +} + +interface ScrollLockState { + count: number; + previousOverflow: string; +} + +interface PointerOutsideDocument { + addEventListener(type: 'pointerdown', listener: (event: PointerEvent) => void): void; + removeEventListener(type: 'pointerdown', listener: (event: PointerEvent) => void): void; +} + +interface PointerOutsideRoot { + ownerDocument: PointerOutsideDocument; + contains(target: Node | null): boolean; +} + +const scrollLocks = new WeakMap(); + +/** + * Lock body scrolling with reference counting so nested overlays restore the + * consumer's original inline overflow value only after the final release. + */ +export function acquireBodyScrollLock(targetDocument: ScrollLockDocument): () => void { + const documentKey = targetDocument as object; + const existing = scrollLocks.get(documentKey); + + if (existing) { + existing.count += 1; + } else { + scrollLocks.set(documentKey, { + count: 1, + previousOverflow: targetDocument.body.style.overflow + }); + targetDocument.body.style.overflow = 'hidden'; + } + + let released = false; + return () => { + if (released) return; + released = true; + + const current = scrollLocks.get(documentKey); + if (!current) return; + current.count -= 1; + + if (current.count === 0) { + targetDocument.body.style.overflow = current.previousOverflow; + scrollLocks.delete(documentKey); + } + }; +} + +/** Register one pointer listener and dismiss only when the target is outside. */ +export function listenForPointerOutside(root: PointerOutsideRoot, onOutside: (event: PointerEvent) => void): () => void { + const handlePointerDown = (event: PointerEvent) => { + if (!root.contains(event.target as Node | null)) onOutside(event); + }; + + root.ownerDocument.addEventListener('pointerdown', handlePointerDown); + return () => root.ownerDocument.removeEventListener('pointerdown', handlePointerDown); +} diff --git a/packages/webaseui-svelte/src/lib/internal/popover.ts b/packages/webaseui-svelte/src/lib/internal/popover.ts new file mode 100644 index 0000000..8d0110e --- /dev/null +++ b/packages/webaseui-svelte/src/lib/internal/popover.ts @@ -0,0 +1,52 @@ +type ManualPopoverElement = HTMLElement & { + hidePopover?: () => void; + showPopover?: () => void; +}; + +function supportsPopover(element: ManualPopoverElement): boolean { + return typeof element.showPopover === 'function' && typeof element.hidePopover === 'function'; +} + +function isPopoverOpen(element: ManualPopoverElement): boolean { + try { + return element.matches(':popover-open'); + } catch { + return false; + } +} + +/** + * Keep a manual popover synchronized with component state. Browsers without + * the Popover API use the element's hidden state and fixed-position styles. + */ +export function synchronizeManualPopover(element: ManualPopoverElement, open: boolean): boolean { + const nativePopover = supportsPopover(element); + + if (!nativePopover) { + element.removeAttribute('popover'); + element.hidden = !open; + return false; + } + + if (open) { + element.hidden = false; + if (!isPopoverOpen(element)) { + try { + element.showPopover?.(); + } catch { + // A disconnected element can reject showPopover during teardown. + } + } + } else { + if (isPopoverOpen(element)) { + try { + element.hidePopover?.(); + } catch { + // The browser may already have removed the element from the top layer. + } + } + element.hidden = true; + } + + return true; +} diff --git a/packages/webaseui-svelte/src/lib/internal/roving-focus.ts b/packages/webaseui-svelte/src/lib/internal/roving-focus.ts new file mode 100644 index 0000000..ace8a2b --- /dev/null +++ b/packages/webaseui-svelte/src/lib/internal/roving-focus.ts @@ -0,0 +1,51 @@ +export type RovingFocusOrientation = 'horizontal' | 'vertical'; +export type RovingFocusDirection = 'ltr' | 'rtl'; + +type IsItemDisabled = (item: T, index: number) => boolean; + +function findEnabledIndex( + items: readonly T[], + start: number, + direction: 1 | -1, + isItemDisabled: IsItemDisabled +): number { + if (items.length === 0) return -1; + + const length = items.length; + const normalizedStart = ((Math.trunc(start) % length) + length) % length; + + for (let offset = 0; offset < length; offset += 1) { + const index = (normalizedStart + offset * direction + length) % length; + const item = items[index]; + if (item !== undefined && !isItemDisabled(item, index)) return index; + } + + return -1; +} + +/** + * Resolve APG-style roving focus keys to the next enabled item. Unsupported + * keys return undefined so components can leave native keyboard behavior alone. + */ +export function findRovingFocusIndex( + items: readonly T[], + currentIndex: number, + key: string, + orientation: RovingFocusOrientation, + isItemDisabled: IsItemDisabled = () => false, + inlineDirection: RovingFocusDirection = 'ltr' +): number | undefined { + if (key === 'Home') return findEnabledIndex(items, 0, 1, isItemDisabled); + if (key === 'End') return findEnabledIndex(items, items.length - 1, -1, isItemDisabled); + + const previousKey = orientation === 'horizontal' + ? inlineDirection === 'rtl' ? 'ArrowRight' : 'ArrowLeft' + : 'ArrowUp'; + const nextKey = orientation === 'horizontal' + ? inlineDirection === 'rtl' ? 'ArrowLeft' : 'ArrowRight' + : 'ArrowDown'; + if (key !== previousKey && key !== nextKey) return; + + const movement = key === nextKey ? 1 : -1; + return findEnabledIndex(items, currentIndex + movement, movement, isItemDisabled); +} diff --git a/packages/webaseui-svelte/src/lib/internal/select-state.ts b/packages/webaseui-svelte/src/lib/internal/select-state.ts new file mode 100644 index 0000000..580edd6 --- /dev/null +++ b/packages/webaseui-svelte/src/lib/internal/select-state.ts @@ -0,0 +1,9 @@ +import type { WeBaseSelectOption } from '../types.js'; +import { findEnabledIndex } from '../utils/collection.js'; + +/** Resolve the controlled value to an enabled option, or the first fallback. */ +export function findSelectedOptionIndex(options: readonly WeBaseSelectOption[], value: string): number { + const selectedIndex = options.findIndex((option) => option.value === value); + if (selectedIndex >= 0 && !options[selectedIndex]?.disabled) return selectedIndex; + return findEnabledIndex(options, 0, 1); +} diff --git a/packages/webaseui-svelte/src/lib/internal/timing.ts b/packages/webaseui-svelte/src/lib/internal/timing.ts new file mode 100644 index 0000000..942e34e --- /dev/null +++ b/packages/webaseui-svelte/src/lib/internal/timing.ts @@ -0,0 +1,35 @@ +export interface TimeoutScheduler { + setTimeout(callback: () => void, delay: number): Handle; + clearTimeout(handle: Handle): void; +} + +export interface AutoDismissState { + open: boolean; + paused: boolean; + duration: number; +} + +/** + * Schedule one auto-dismiss cycle for a mounted notification. + * + * The component remains responsible for starting a fresh cycle whenever its + * reactive state changes. Keeping that lifecycle boundary outside this helper + * makes pause, resume, and cancellation deterministic and independently + * testable without introducing a long-lived timer controller. + */ +export function scheduleAutoDismiss( + state: AutoDismissState, + onDismiss: () => void, + scheduler: TimeoutScheduler +): () => void { + if (!state.open || state.paused || state.duration <= 0) return () => {}; + + const handle = scheduler.setTimeout(onDismiss, state.duration); + let active = true; + + return () => { + if (!active) return; + active = false; + scheduler.clearTimeout(handle); + }; +} diff --git a/packages/webaseui-svelte/src/lib/internal/typeahead.ts b/packages/webaseui-svelte/src/lib/internal/typeahead.ts new file mode 100644 index 0000000..ac90254 --- /dev/null +++ b/packages/webaseui-svelte/src/lib/internal/typeahead.ts @@ -0,0 +1,81 @@ +import type { TimeoutScheduler } from './timing.js'; + +type ItemText = (item: T, index: number) => string; +type IsItemDisabled = (item: T, index: number) => boolean; + +/** Find the first enabled prefix match from a cyclic collection position. */ +export function findTypeaheadMatchIndex( + items: readonly T[], + query: string, + startIndex: number, + itemText: ItemText, + isItemDisabled: IsItemDisabled = () => false +): number { + if (items.length === 0 || !query) return -1; + + const normalizedQuery = query.toLocaleLowerCase(); + const normalizedStart = ((Math.trunc(startIndex) % items.length) + items.length) % items.length; + + for (let offset = 0; offset < items.length; offset += 1) { + const index = (normalizedStart + offset) % items.length; + const item = items[index]; + if ( + item !== undefined && + !isItemDisabled(item, index) && + itemText(item, index).toLocaleLowerCase().startsWith(normalizedQuery) + ) return index; + } + + return -1; +} + +/** + * Keep the short-lived typeahead query outside component state. Repeating one + * character cycles to the next match; adding a different character refines the + * current match until the reset delay expires. + */ +export function createTypeaheadController( + scheduler: TimeoutScheduler, + resetDelay = 500 +) { + let query = ''; + let resetHandle: Handle; + let resetPending = false; + + function cancelReset() { + if (!resetPending) return; + resetPending = false; + scheduler.clearTimeout(resetHandle); + } + + function reset() { + cancelReset(); + query = ''; + } + + function search( + items: readonly T[], + currentIndex: number, + key: string, + itemText: ItemText, + isItemDisabled: IsItemDisabled = () => false + ): number | undefined { + if (key.length !== 1 || !/\S/.test(key)) return; + + const normalizedKey = key.toLocaleLowerCase(); + const repeatedCharacter = query.length > 0 && Array.from(query).every((character) => character === normalizedKey); + query = repeatedCharacter ? normalizedKey : `${query}${normalizedKey}`; + + cancelReset(); + resetPending = true; + resetHandle = scheduler.setTimeout(() => { + query = ''; + resetPending = false; + }, resetDelay); + + const startIndex = repeatedCharacter && currentIndex >= 0 ? currentIndex + 1 : Math.max(currentIndex, 0); + return findTypeaheadMatchIndex(items, query, startIndex, itemText, isItemDisabled); + } + + return { reset, search }; +} diff --git a/packages/webaseui-svelte/src/lib/types.ts b/packages/webaseui-svelte/src/lib/types.ts new file mode 100644 index 0000000..8845f46 --- /dev/null +++ b/packages/webaseui-svelte/src/lib/types.ts @@ -0,0 +1,30 @@ +/** One disclosure entry rendered by WeBaseAccordion. */ +export interface WeBaseAccordionItem { + title: string; + content: string; +} + +/** One navigation entry rendered by WeBaseBreadcrumbs. */ +export interface WeBaseBreadcrumbItem { + label: string; + href?: string; +} + +/** One choice rendered by WeBaseSelect. Values must be unique within a select. */ +export interface WeBaseSelectOption { + label: string; + value: string; + disabled?: boolean; +} + +/** Produces an accessible label for a numbered pagination control. */ +export type WeBasePageLabel = (page: number) => string; + +/** Formats a visible numeric value using the consumer's locale and notation. */ +export type WeBaseNumberFormatter = (value: number) => string; + +/** Formats a slider value together with its consumer-defined unit. */ +export type WeBaseSliderValueFormatter = (value: number, unit: string) => string; + +/** Formats a current and maximum character count. */ +export type WeBaseCountFormatter = (current: number, maximum: number) => string; diff --git a/packages/webaseui-svelte/src/lib/utils/collection.ts b/packages/webaseui-svelte/src/lib/utils/collection.ts new file mode 100644 index 0000000..495d256 --- /dev/null +++ b/packages/webaseui-svelte/src/lib/utils/collection.ts @@ -0,0 +1,32 @@ +export interface CollectionItem { + disabled?: boolean; +} + +/** Return the first enabled item while wrapping around the collection. */ +export function findEnabledIndex(items: readonly T[], start: number, direction: 1 | -1): number { + if (items.length === 0) return -1; + + const length = items.length; + const normalizedStart = ((Math.trunc(start) % length) + length) % length; + + for (let offset = 0; offset < length; offset += 1) { + const index = (normalizedStart + offset * direction + length) % length; + if (!items[index]?.disabled) return index; + } + + return -1; +} + +/** Normalize a controlled collection index, using -1 for an empty collection. */ +export function normalizeIndex(value: number, length: number, fallback = 0): number { + if (length <= 0) return -1; + const safeFallback = Math.min(Math.max(Math.trunc(fallback), 0), length - 1); + if (!Number.isInteger(value) || value < 0 || value >= length) return safeFallback; + return value; +} + +/** Move an index by a signed step and wrap it into the collection. */ +export function wrapIndex(index: number, length: number, step: number): number { + if (length <= 0) return -1; + return ((Math.trunc(index) + Math.trunc(step)) % length + length) % length; +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..746658e --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,47 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/browser/specs', + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'line', + use: { + screenshot: 'only-on-failure', + trace: 'retain-on-failure' + }, + projects: [ + { name: 'chromium', testIgnore: /visual\.spec\.ts/, use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', testIgnore: /visual\.spec\.ts/, use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', testIgnore: /visual\.spec\.ts/, use: { ...devices['Desktop Safari'] } }, + { + name: 'visual-chromium', + testMatch: /visual\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 1000 }, + reducedMotion: 'reduce' + } + } + ], + webServer: [ + { + command: 'npm run preview --workspace @webaseui/docs -- --host 127.0.0.1 --port 4175', + url: 'http://127.0.0.1:4175', + reuseExistingServer: !process.env.CI + }, + { + command: 'npm exec -- vite --config tests/browser/vite.config.ts --host 127.0.0.1 --port 4176', + url: 'http://127.0.0.1:4176', + reuseExistingServer: !process.env.CI + }, + { + command: 'node tests/ssr/server.mjs', + url: 'http://127.0.0.1:4177', + reuseExistingServer: !process.env.CI + } + ] +}); diff --git a/scripts/check-api-contract.mjs b/scripts/check-api-contract.mjs new file mode 100644 index 0000000..86d59dc --- /dev/null +++ b/scripts/check-api-contract.mjs @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); +const indexSource = readFileSync(path.join(root, 'packages/webaseui-svelte/src/lib/index.ts'), 'utf8'); +const contractSource = readFileSync(path.join(root, 'packages/webaseui-svelte/API_CONTRACT.md'), 'utf8'); + +const exportedComponents = [...indexSource.matchAll(/export \{ default as (WeBase\w+) \}/g)] + .map((match) => match[1]) + .sort(); +const documentedComponents = [...contractSource.matchAll(/^### (WeBase\w+)$/gm)] + .map((match) => match[1]) + .sort(); + +const missing = exportedComponents.filter((component) => !documentedComponents.includes(component)); +const stale = documentedComponents.filter((component) => !exportedComponents.includes(component)); +const duplicates = documentedComponents.filter((component, index) => documentedComponents.indexOf(component) !== index); + +if (exportedComponents.length !== 28) { + throw new Error(`Expected 28 public component exports, found ${exportedComponents.length}.`); +} + +if (missing.length || stale.length || duplicates.length) { + throw new Error([ + 'Public API contract coverage failed.', + missing.length ? `Missing: ${missing.join(', ')}` : '', + stale.length ? `Stale: ${stale.join(', ')}` : '', + duplicates.length ? `Duplicates: ${[...new Set(duplicates)].join(', ')}` : '' + ].filter(Boolean).join('\n')); +} + +for (const component of documentedComponents) { + const section = contractSource.split(`### ${component}\n`)[1]?.split('\n### ')[0] ?? ''; + for (const requiredLabel of ['Props:', 'Binding']) { + if (!section.includes(requiredLabel)) { + throw new Error(`${component} is missing ${requiredLabel} contract metadata.`); + } + } +} + +console.log(`Validated public API contract coverage for ${documentedComponents.length} components.`); diff --git a/scripts/check-changeset.mjs b/scripts/check-changeset.mjs new file mode 100644 index 0000000..e3e990e --- /dev/null +++ b/scripts/check-changeset.mjs @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; + +const root = path.resolve(import.meta.dirname, '..'); +const requestedBase = process.argv.find((argument) => argument.startsWith('--base='))?.slice('--base='.length); +const baseRef = requestedBase ?? (process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : 'origin/main'); + +function git(args) { + const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); + if (result.status !== 0) { + process.stderr.write(result.stderr); + throw new Error(`git ${args.join(' ')} failed`); + } + return result.stdout.trim(); +} + +const diffBase = git(['merge-base', baseRef, 'HEAD']); +const changedFiles = new Set([ + ...git(['diff', '--name-only', diffBase]).split('\n').filter(Boolean), + ...git(['ls-files', '--others', '--exclude-standard']).split('\n').filter(Boolean) +]); +const publishablePackages = new Map([ + ['packages/webaseui-core/', '@webaseui/core'], + ['packages/webaseui-svelte/', '@webaseui/svelte'] +]); +const changedPackages = new Set(); + +for (const changedFile of changedFiles) { + for (const [directory, packageName] of publishablePackages) { + if (changedFile.startsWith(directory)) changedPackages.add(packageName); + } +} + +if (changedPackages.size === 0) { + console.log('No publishable package changes detected; no changeset required.'); + process.exit(0); +} + +const changesetFiles = [...changedFiles].filter((file) => /^\.changeset\/(?!README\.md$).+\.md$/.test(file)); +if (changesetFiles.length === 0) { + throw new Error(`Missing changeset for: ${[...changedPackages].join(', ')}`); +} + +const coveredPackages = new Set(); +for (const changesetFile of changesetFiles) { + const source = readFileSync(path.join(root, changesetFile), 'utf8'); + const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''; + for (const line of frontmatter.split('\n')) { + const packageName = line.match(/^["']?(@webaseui\/[A-Za-z0-9_-]+)["']?:\s+(?:major|minor|patch)\s*$/)?.[1]; + if (packageName) coveredPackages.add(packageName); + } +} + +const missingPackages = [...changedPackages].filter((packageName) => !coveredPackages.has(packageName)); +if (missingPackages.length > 0) { + throw new Error(`Changeset does not cover: ${missingPackages.join(', ')}`); +} + +console.log(`Changeset coverage validated for: ${[...changedPackages].join(', ')}`); diff --git a/scripts/check-docs-design.mjs b/scripts/check-docs-design.mjs new file mode 100644 index 0000000..3f8703f --- /dev/null +++ b/scripts/check-docs-design.mjs @@ -0,0 +1,63 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); +const docsRoot = path.join(root, 'apps/docs'); +const sourceRoots = [path.join(docsRoot, 'src'), path.join(docsRoot, 'index.html')]; +const inspectedExtensions = new Set(['.css', '.html', '.js', '.json', '.svelte', '.ts']); +const pictographicPattern = /[\p{Extended_Pictographic}\u2190-\u2bff]/u; +const forbiddenDashPattern = /[—–]/u; +const inlineSvgPattern = / { + if (pictographicPattern.test(line)) { + failures.push(`${relativePath}:${index + 1} uses an emoji, pictographic symbol, or Unicode icon`); + } + if (forbiddenDashPattern.test(line)) { + failures.push(`${relativePath}:${index + 1} uses a typographic dash that is disallowed in the site design language`); + } + if (inlineSvgPattern.test(line)) { + failures.push(`${relativePath}:${index + 1} contains inline SVG; use a Lucide-backed component instead`); + } + + const iconImport = line.match(forbiddenIconPackagePattern)?.[1]; + if (iconImport && !allowedIconPackages.has(iconImport)) { + failures.push(`${relativePath}:${index + 1} imports ${iconImport}; documentation icons must use Lucide`); + } + }); +} + +if (failures.length > 0) { + throw new Error(`Documentation design policy failed:\n${failures.join('\n')}`); +} + +console.log('Validated documentation icon, emoji, and visual-language source policies.'); diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs new file mode 100644 index 0000000..8810d7b --- /dev/null +++ b/scripts/check-package-size.mjs @@ -0,0 +1,170 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { parseNpmPackOutput } from './lib/npm-pack.mjs'; + +const root = path.resolve(import.meta.dirname, '..'); +const baselinePath = path.join(root, 'docs/package-size-baseline.json'); +const fixturePath = path.join(root, 'examples/webaseui-svelte-consumer'); +const writeBaseline = process.argv.includes('--write'); + +function run(command, args, cwd = root) { + const result = spawnSync(command, args, { cwd, encoding: 'utf8', stdio: 'pipe' }); + if (result.status !== 0) { + process.stderr.write(result.stdout); + process.stderr.write(result.stderr); + throw new Error(`${command} ${args.join(' ')} failed`); + } + return result.stdout; +} + +function bytesForFile(filePath) { + return statSync(filePath).size; +} + +function gzipBytes(buffer) { + return gzipSync(buffer, { level: 9 }).byteLength; +} + +function filesUnder(directory) { + if (!existsSync(directory)) return []; + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? filesUnder(target) : [target]; + }); +} + +function aggregateFiles(files) { + const sorted = [...files].sort(); + const content = Buffer.concat(sorted.map((file) => readFileSync(file))); + return { + bytes: content.byteLength, + gzipBytes: gzipBytes(content) + }; +} + +function packMetrics(workspace) { + const output = run('npm', ['pack', '--dry-run', '--json', '--ignore-scripts', '--workspace', workspace]); + const pack = parseNpmPackOutput(output, workspace); + if (typeof pack.size !== 'number' || typeof pack.unpackedSize !== 'number') { + throw new Error(`npm pack returned incomplete size metadata for ${workspace}`); + } + return { + tarballBytes: pack.size, + unpackedBytes: pack.unpackedSize + }; +} + +function ensureSvelteBuild() { + const entry = path.join(root, 'packages/webaseui-svelte/dist/index.js'); + if (!existsSync(entry)) run('npm', ['run', 'build']); +} + +function buildConsumer() { + const temporaryRoot = mkdtempSync(path.join(os.tmpdir(), 'webaseui-size-')); + const outputDirectory = path.join(temporaryRoot, 'dist'); + + try { + run('npm', ['run', 'build', '--prefix', fixturePath, '--', '--outDir', outputDirectory]); + const assets = filesUnder(outputDirectory); + const byExtension = (extension) => assets.filter((file) => path.extname(file) === extension); + const js = aggregateFiles(byExtension('.js')); + const css = aggregateFiles(byExtension('.css')); + const html = aggregateFiles(byExtension('.html')); + return { + jsBytes: js.bytes, + jsGzipBytes: js.gzipBytes, + cssBytes: css.bytes, + cssGzipBytes: css.gzipBytes, + htmlBytes: html.bytes, + htmlGzipBytes: html.gzipBytes, + totalBytes: js.bytes + css.bytes + html.bytes, + totalGzipBytes: js.gzipBytes + css.gzipBytes + html.gzipBytes + }; + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function collectMetrics() { + ensureSvelteBuild(); + + const coreSource = path.join(root, 'packages/webaseui-core/src'); + const svelteDist = path.join(root, 'packages/webaseui-svelte/dist'); + const coreFiles = filesUnder(coreSource).filter((file) => path.extname(file) === '.css'); + const svelteFiles = filesUnder(svelteDist); + const componentFiles = svelteFiles.filter((file) => /[\\/]components[\\/]WeBase[A-Za-z]+\.svelte$/.test(file)); + const componentMetrics = Object.fromEntries(componentFiles.sort().map((file) => { + const name = path.basename(file, '.svelte'); + const content = readFileSync(file); + return [name, { bytes: content.byteLength, gzipBytes: gzipBytes(content) }]; + })); + const svelteAggregate = aggregateFiles(svelteFiles); + + return { + schemaVersion: 1, + platform: process.platform, + packages: { + core: { + ...packMetrics('@webaseui/core'), + css: aggregateFiles(coreFiles) + }, + svelte: { + ...packMetrics('@webaseui/svelte'), + dist: svelteAggregate, + entry: aggregateFiles(svelteFiles.filter((file) => /[\\/]index\.(js|d\.ts)$/.test(file))), + components: componentMetrics + } + }, + consumer: buildConsumer() + }; +} + +function compareNumber(current, baseline, label, failures) { + if (typeof baseline !== 'number' || baseline <= 0 || current <= baseline * 1.05) return; + failures.push(`${label} grew from ${baseline} to ${current} bytes (${((current / baseline - 1) * 100).toFixed(1)}%, limit 5%)`); +} + +function compareMetrics(current, baseline) { + const failures = []; + const compareObject = (currentObject, baselineObject, prefix) => { + for (const [key, value] of Object.entries(currentObject)) { + const baselineValue = baselineObject?.[key]; + if (typeof value === 'number') compareNumber(value, baselineValue, `${prefix}.${key}`, failures); + else if (value && typeof value === 'object') compareObject(value, baselineValue ?? {}, `${prefix}.${key}`); + } + }; + + compareObject(current, baseline, 'size'); + return failures; +} + +const current = collectMetrics(); + +if (writeBaseline) { + writeFileSync(baselinePath, `${JSON.stringify(current, null, 2)}\n`); + console.log(`Wrote package size baseline to ${path.relative(root, baselinePath)}.`); +} else { + if (!existsSync(baselinePath)) { + throw new Error(`Missing ${path.relative(root, baselinePath)}. Run npm run size:update to create it.`); + } + + const baseline = JSON.parse(readFileSync(baselinePath, 'utf8')); + const failures = compareMetrics(current, baseline); + if (failures.length > 0) { + throw new Error(`Package size budget failed:\n${failures.join('\n')}\nUpdate the baseline only with an intentional, reviewed size change.`); + } + + console.log('Package size baseline is within the 5% growth budget.'); +} + +const summary = [ + ['core tarball', current.packages.core.tarballBytes], + ['svelte tarball', current.packages.svelte.tarballBytes], + ['svelte dist', current.packages.svelte.dist.bytes], + ['consumer JS gzip', current.consumer.jsGzipBytes], + ['consumer CSS gzip', current.consumer.cssGzipBytes] +]; +console.log(summary.map(([label, value]) => `${label}: ${value} bytes`).join(' | ')); diff --git a/scripts/check-support-policy.mjs b/scripts/check-support-policy.mjs new file mode 100644 index 0000000..7ecc257 --- /dev/null +++ b/scripts/check-support-policy.mjs @@ -0,0 +1,83 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); + +function read(relativePath) { + return readFileSync(path.join(root, relativePath), 'utf8'); +} + +function readJson(relativePath) { + return JSON.parse(read(relativePath)); +} + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +function compareVersions(left, right) { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) return Math.sign(difference); + } + return 0; +} + +const policy = readJson('docs/support-policy.json'); +const rootManifest = readJson('package.json'); +const svelteManifest = readJson('packages/webaseui-svelte/package.json'); +const consumerManifest = readJson('examples/webaseui-svelte-consumer/package.json'); +const consumerTypeScript = readJson('examples/webaseui-svelte-consumer/tsconfig.json'); +const supportMatrix = read('docs/SUPPORT_MATRIX.md'); +const consumerScript = read('scripts/test-webaseui-consumer.mjs'); +const checkWorkflow = read('.github/workflows/check.yml'); +const releaseWorkflow = read('.github/workflows/release.yml'); +const compatibilityWorkflow = read('.github/workflows/compatibility.yml'); + +invariant(rootManifest.engines?.node === policy.node.engine, 'root Node engine must match the support policy'); +invariant(svelteManifest.peerDependencies?.svelte === policy.svelte.peer, 'Svelte peer range must match the support policy'); +invariant(rootManifest.devDependencies?.['@playwright/test'] === policy.browsers.playwright, 'Playwright must be pinned to the validated support-policy version'); +invariant(consumerManifest.devDependencies?.['@types/node'] === rootManifest.devDependencies?.['@types/node'], 'consumer fixture must pin the repository Node declarations'); +invariant(consumerTypeScript.compilerOptions?.lib?.includes('ESNext.Disposable'), 'minimum consumer must include Rollup disposable declarations'); +invariant(consumerTypeScript.compilerOptions?.types?.includes('node'), 'minimum consumer must include Node declarations'); +invariant(consumerTypeScript.compilerOptions?.types?.includes('vite/client'), 'consumer fixture must include Vite CSS declarations'); +invariant(consumerScript.includes("docs/support-policy.json"), 'consumer checks must read the shared support policy'); +invariant(!consumerScript.includes("typescript: '5.5.4'"), 'minimum TypeScript must not be duplicated in the consumer script'); + +const currentTypeScript = consumerManifest.devDependencies?.typescript; +invariant(typeof currentTypeScript === 'string', 'consumer fixture must declare TypeScript'); +invariant(compareVersions(currentTypeScript, policy.typescript.minimum) >= 0, 'current TypeScript is older than the supported minimum'); +invariant(compareVersions(currentTypeScript, policy.typescript.maximumExclusive) < 0, 'current TypeScript is outside the frozen support range'); + +for (const workflow of [checkWorkflow, releaseWorkflow]) { + const versions = [...workflow.matchAll(/node-version:\s*([^\n]+)/g)].map((match) => match[1].trim()); + invariant(versions.length > 0, 'blocking workflows must select a Node release'); + invariant(versions.every((version) => version === policy.node.release), 'blocking workflows must use the frozen release Node version'); +} + +invariant(compatibilityWorkflow.includes(`node-version: ${policy.node.compatibility}`), 'compatibility workflow must exercise the second supported Node LTS line'); +invariant(compatibilityWorkflow.includes('@playwright/test@latest'), 'compatibility workflow must warn on the latest browser toolchain'); +invariant(compatibilityWorkflow.includes('npm run test:browser'), 'latest browser compatibility job must run the behavior suite'); + +for (const requiredText of [ + policy.node.engine, + policy.svelte.peer, + `>=${policy.typescript.minimum} <${policy.typescript.maximumExclusive.split('.')[0]}`, + policy.browsers.policy, + policy.browsers.playwright, + policy.browsers.validated.chromium, + policy.browsers.validated.firefox, + policy.browsers.validated.webkit +]) { + invariant(supportMatrix.includes(requiredText), `support matrix is missing policy value: ${requiredText}`); +} + +invariant(!supportMatrix.includes('RC decision'), 'support matrix still contains an unresolved RC policy decision'); + +console.log( + `Validated support policy: Node ${policy.node.engine}, Svelte ${policy.svelte.peer}, ` + + `TypeScript >=${policy.typescript.minimum} <${policy.typescript.maximumExclusive.split('.')[0]}, ` + + `${policy.browsers.policy}.` +); diff --git a/scripts/check-token-contrast.mjs b/scripts/check-token-contrast.mjs new file mode 100644 index 0000000..e38c714 --- /dev/null +++ b/scripts/check-token-contrast.mjs @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); +const tokenSource = readFileSync(path.join(root, 'packages/webaseui-core/src/tokens.css'), 'utf8'); +const brandSource = readFileSync(path.join(root, 'packages/webaseui-core/src/brand-theme.css'), 'utf8'); + +function ruleBody(source, selector) { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const body = source.match(new RegExp(`^${escaped}\\s*\\{([\\s\\S]*?)^\\}`, 'm'))?.[1]; + if (!body) throw new Error(`Missing token rule: ${selector}`); + return body; +} + +function declarations(body) { + return Object.fromEntries( + [...body.matchAll(/^\s*(--webase-[a-z0-9-]+):\s*([^;]+);/gm)] + .map(([, name, value]) => [name, value.trim()]) + ); +} + +function resolveColor(tokens, name, seen = new Set()) { + if (seen.has(name)) throw new Error(`Circular color token reference: ${[...seen, name].join(' -> ')}`); + const value = tokens[name]; + if (!value) throw new Error(`Missing color token: ${name}`); + const hex = value.match(/^#([0-9a-f]{6})$/i)?.[1]; + if (hex) return hex; + const reference = value.match(/^var\((--webase-[a-z0-9-]+)\)$/)?.[1]; + if (!reference) throw new Error(`${name} does not resolve to a six-digit hex color: ${value}`); + return resolveColor(tokens, reference, new Set([...seen, name])); +} + +function relativeLuminance(hex) { + const channels = [0, 2, 4] + .map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255) + .map((channel) => channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; +} + +function contrastRatio(first, second) { + const firstLuminance = relativeLuminance(first); + const secondLuminance = relativeLuminance(second); + return (Math.max(firstLuminance, secondLuminance) + 0.05) / + (Math.min(firstLuminance, secondLuminance) + 0.05); +} + +const base = declarations(ruleBody(tokenSource, ':root')); +const themes = { + light: base, + dark: { ...base, ...declarations(ruleBody(tokenSource, ":root[data-theme='dark']")) }, + brand: { ...base, ...declarations(ruleBody(brandSource, ":root[data-theme='brand']")) } +}; + +const textPairs = [ + ['--webase-color-ink', '--webase-color-canvas'], + ['--webase-color-ink', '--webase-color-surface'], + ['--webase-color-ink-soft', '--webase-color-canvas'], + ['--webase-color-ink-soft', '--webase-color-surface'], + ['--webase-color-ink-muted', '--webase-color-canvas'], + ['--webase-color-ink-muted', '--webase-color-surface'], + ['--webase-color-brand', '--webase-color-canvas'], + ['--webase-color-brand', '--webase-color-surface'], + ['--webase-color-brand-light', '--webase-color-canvas'], + ['--webase-color-brand-light', '--webase-color-surface'], + ['--webase-color-error', '--webase-color-canvas'], + ['--webase-color-error', '--webase-color-surface'], + ['--webase-color-warning', '--webase-color-warning-field'] +]; + +const graphicalPairs = [ + ['--webase-color-info', '--webase-color-canvas'], + ['--webase-color-info', '--webase-color-surface'], + ['--webase-color-success', '--webase-color-canvas'], + ['--webase-color-success', '--webase-color-surface'], + ['--webase-color-warning', '--webase-color-canvas'], + ['--webase-color-warning', '--webase-color-surface'], + ['--webase-color-canvas', '--webase-color-info'], + ['--webase-color-canvas', '--webase-color-success'], + ['--webase-color-canvas', '--webase-color-warning'], + ['--webase-color-canvas', '--webase-color-error'] +]; + +const failures = []; +for (const [themeName, tokens] of Object.entries(themes)) { + for (const [pairs, minimum, role] of [[textPairs, 4.5, 'text'], [graphicalPairs, 3, 'graphical']]) { + for (const [foreground, background] of pairs) { + const ratio = contrastRatio(resolveColor(tokens, foreground), resolveColor(tokens, background)); + if (ratio + Number.EPSILON < minimum) { + failures.push(`${themeName} ${role}: ${foreground} on ${background} is ${ratio.toFixed(2)}:1; expected ${minimum}:1`); + } + } + } +} + +if (failures.length > 0) { + throw new Error(`Token contrast gate failed:\n${failures.join('\n')}`); +} + +console.log(`Validated ${textPairs.length + graphicalPairs.length} accessible color pairs across light, dark, and brand themes.`); diff --git a/scripts/check-tokens.mjs b/scripts/check-tokens.mjs new file mode 100644 index 0000000..1b1fecf --- /dev/null +++ b/scripts/check-tokens.mjs @@ -0,0 +1,142 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); +const tokenPath = path.join(root, 'packages/webaseui-core/src/tokens.css'); +const themePath = path.join(root, 'packages/webaseui-core/src/theme.css'); +const brandThemePath = path.join(root, 'packages/webaseui-core/src/brand-theme.css'); +const componentDirectory = path.join(root, 'packages/webaseui-svelte/src/lib/components'); +const componentLiteralBaselinePath = path.join(root, 'docs/component-style-literal-baseline.json'); +const coreManifest = JSON.parse(readFileSync(path.join(root, 'packages/webaseui-core/package.json'), 'utf8')); +const componentLiteralBaseline = JSON.parse(readFileSync(componentLiteralBaselinePath, 'utf8')); +const tokenSource = readFileSync(tokenPath, 'utf8'); +const themeSource = readFileSync(themePath, 'utf8'); +const brandThemeSource = readFileSync(brandThemePath, 'utf8'); + +const requiredLayers = { + primitive: [ + '--webase-palette-canvas', + '--webase-palette-surface', + '--webase-space-1', + '--webase-space-8', + '--webase-size-control-sm', + '--webase-size-control-lg', + '--webase-size-target-min', + '--webase-radius-sm', + '--webase-font-size-overline', + '--webase-font-size-control', + '--webase-z-dialog', + '--webase-duration-fast' + ], + semantic: [ + '--webase-color-canvas', + '--webase-color-surface', + '--webase-color-ink', + '--webase-color-brand', + '--webase-color-border-strong', + '--webase-focus-ring', + '--webase-interactive-target-min' + ], + component: [ + '--webase-component-control-height', + '--webase-component-control-padding-inline', + '--webase-component-button-height', + '--webase-component-button-height-sm', + '--webase-component-icon-button-size', + '--webase-component-switch-width', + '--webase-component-select-menu-max-height', + '--webase-component-dialog-width', + '--webase-component-overlay-z-index', + '--webase-component-overlay-viewport-padding', + '--webase-component-alert-min-height', + '--webase-component-toast-max-width', + '--webase-component-slider-track-height', + '--webase-component-tabs-trigger-height', + '--webase-component-accordion-trigger-height', + '--webase-component-card-min-height', + '--webase-component-empty-min-height', + '--webase-component-section-header-title-min-size', + '--webase-component-skeleton-media-size' + ] +}; + +function fail(message) { + throw new Error(`Token contract failed: ${message}`); +} + +const definitions = new Set( + [...tokenSource.matchAll(/(--webase-[a-z0-9-]+)\s*:/g)].map(([, name]) => name) +); +const missing = Object.values(requiredLayers).flat().filter((name) => !definitions.has(name)); +if (missing.length) fail(`missing definitions: ${missing.join(', ')}`); + +const references = [...tokenSource.matchAll(/var\((--webase-[a-z0-9-]+)/g)].map(([, name]) => name); +const undefinedReferences = [...new Set(references.filter((name) => !definitions.has(name)))]; +if (undefinedReferences.length) fail(`undefined references: ${undefinedReferences.join(', ')}`); + +if (!themeSource.includes("@import './tokens.css';")) { + fail('theme.css must import tokens.css'); +} + +if (coreManifest.exports?.['./brand-theme.css'] !== './src/brand-theme.css') { + fail('core package must export the example brand theme'); +} + +for (const name of ['--webase-color-brand', '--webase-color-canvas', '--webase-color-surface']) { + if (!brandThemeSource.includes(`${name}:`)) fail(`brand theme must override ${name}`); +} + +const darkTheme = tokenSource.match(/:root\[data-theme=['"]dark['"]\]\s*\{([\s\S]*?)\n\}/)?.[1] ?? ''; +for (const name of ['--webase-palette-canvas', '--webase-palette-surface', '--webase-palette-ink', '--webase-palette-brand']) { + if (!darkTheme.includes(`${name}:`)) fail(`dark theme must override ${name}`); +} + +const componentLayer = tokenSource.match(/\/\* Component tokens \*\/([\s\S]*?)\/\* Compatibility aliases \*\//)?.[1] ?? ''; +if (!componentLayer.trim()) fail('component token layer is empty'); + +const componentFiles = readdirSync(componentDirectory).filter((name) => name.endsWith('.svelte')).sort(); +const baselineFiles = Object.keys(componentLiteralBaseline.components).sort(); +if (componentFiles.join('\n') !== baselineFiles.join('\n')) { + fail('component literal baseline must list every public component exactly once'); +} + +const literalPattern = /(? { + const source = readFileSync(path.join(componentDirectory, name), 'utf8'); + const style = source.match(/ diff --git a/tests/browser/fixture/src/VisualStates.svelte b/tests/browser/fixture/src/VisualStates.svelte new file mode 100644 index 0000000..340359d --- /dev/null +++ b/tests/browser/fixture/src/VisualStates.svelte @@ -0,0 +1,112 @@ + + +
+
+

Action state matrix

+
+
Hover
+
Disabled
+
Loading
+
Pressed
+
Disabled icon
+
+
+ +
+

Form error state matrix

+
+ + +
+
+
+ +
+

Form disabled state matrix

+
+ + + + +
+
+ +
+

Selection disabled state matrix

+
+ + + +
+
+ +
+

Feedback tone state matrix

+ +
+ +
+ + +
+
+ + diff --git a/tests/browser/fixture/src/main.ts b/tests/browser/fixture/src/main.ts new file mode 100644 index 0000000..3fcb539 --- /dev/null +++ b/tests/browser/fixture/src/main.ts @@ -0,0 +1,4 @@ +import { mount } from 'svelte'; +import App from './App.svelte'; + +mount(App, { target: document.getElementById('app')! }); diff --git a/tests/browser/specs/components.spec.ts b/tests/browser/specs/components.spec.ts new file mode 100644 index 0000000..77be1a1 --- /dev/null +++ b/tests/browser/specs/components.spec.ts @@ -0,0 +1,460 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Locator } from '@playwright/test'; + +const fixtureUrl = 'http://127.0.0.1:4176'; + +async function expectMinimumTarget(locator: Locator, name: string) { + const box = await locator.boundingBox(); + expect(box, `${name} must be rendered`).not.toBeNull(); + expect(box!.width, `${name} target width`).toBeGreaterThanOrEqual(24); + expect(box!.height, `${name} target height`).toBeGreaterThanOrEqual(24); +} + +async function expectStaticMotion(locator: Locator, name: string) { + const motion = await locator.evaluate((element) => { + const style = getComputedStyle(element); + return { + animationDuration: style.animationDuration, + transitionDuration: style.transitionDuration + }; + }); + const durations = `${motion.animationDuration},${motion.transitionDuration}` + .split(',') + .map((value) => Number.parseFloat(value)); + expect(durations.every((duration) => duration === 0), `${name} motion: ${JSON.stringify(motion)}`).toBe(true); +} + +test.beforeEach(async ({ page }) => { + await page.goto(fixtureUrl); +}); + +test('native controls participate in form serialization', async ({ page }) => { + await page.getByTestId('serialize-form').click(); + + await expect(page.getByTestId('form-output')).toHaveText(JSON.stringify({ + title: 'Field notes', + notes: 'A portable form contract.', + include: 'yes', + updates: 'enabled', + coverage: '30', + plan: 'standard', + channel: 'stable' + })); +}); + +test('select supports keyboard selection and synchronizes its native control', async ({ page }) => { + const trigger = page.getByRole('combobox', { name: 'Release channel', exact: true }); + await trigger.focus(); + await trigger.press('Enter'); + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + await trigger.press('ArrowDown'); + await trigger.press('Enter'); + + await expect(trigger).toContainText('Preview'); + await expect(page.getByTestId('native-select')).toHaveValue('preview'); + await expect(page.getByTestId('native-select')).toHaveClass(/custom-native-select/); + await expect(page.getByTestId('select-changes')).toHaveText('1'); +}); + +test('select typeahead refines queries, cycles repeated characters, and skips disabled options', async ({ page }) => { + const trigger = page.getByRole('combobox', { name: 'Release channel', exact: true }); + await trigger.focus(); + await trigger.press('p'); + + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + await expect(page.getByRole('option', { name: 'Preview' })).toHaveClass(/is-highlighted/); + await trigger.press('p'); + await expect(page.getByRole('option', { name: 'Paused' })).not.toHaveClass(/is-highlighted/); + await expect(page.getByRole('option', { name: 'Production' })).toHaveClass(/is-highlighted/); + + await page.waitForTimeout(550); + await trigger.press('c'); + await expect(page.getByRole('option', { name: 'Canary' })).toHaveClass(/is-highlighted/); + await trigger.press('Enter'); + await expect(trigger).toContainText('Canary'); +}); + +test('select dismisses its listbox after a pointer action outside the component', async ({ page }) => { + const trigger = page.getByRole('combobox', { name: 'Release channel', exact: true }); + await trigger.click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + await page.getByRole('heading', { name: 'Native form fixture' }).click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); +}); + +test('floating overlays escape scroll clipping, track anchors, flip, and shift inside the viewport', async ({ page }) => { + const container = page.getByTestId('floating-scroll-container'); + const select = page.getByRole('combobox', { name: 'Scrollable release channel' }); + await container.scrollIntoViewIfNeeded(); + await container.evaluate((element) => { element.scrollTop = element.scrollHeight; }); + await select.click(); + + const listbox = page.getByRole('listbox', { name: 'Scrollable release channel' }); + await expect(listbox).toBeVisible(); + await expect.poll(async () => page.evaluate(() => { + const containerElement = document.querySelector('[data-testid="floating-scroll-container"]'); + const listboxElement = document.querySelector('[role="listbox"][data-side]'); + if (!containerElement || !listboxElement) return false; + const containerRect = containerElement.getBoundingClientRect(); + const listboxRect = listboxElement.getBoundingClientRect(); + return listboxRect.top < containerRect.top || listboxRect.bottom > containerRect.bottom; + })).toBe(true); + + await container.evaluate((element) => { element.scrollTop -= 24; }); + await expect.poll(async () => { + const triggerRect = await select.boundingBox(); + const menuRect = await listbox.boundingBox(); + const side = await listbox.getAttribute('data-side'); + if (!triggerRect || !menuRect || !side) return -1; + return side === 'bottom' + ? Math.abs(menuRect.y - (triggerRect.y + triggerRect.height) - 9) + : Math.abs(triggerRect.y - (menuRect.y + menuRect.height) - 9); + }).toBeLessThan(1); + + await select.press('Escape'); + const tooltipTrigger = page.getByRole('button', { name: 'Viewport edge help' }); + await tooltipTrigger.evaluate((element) => element.scrollIntoView({ block: 'start', inline: 'end' })); + await tooltipTrigger.focus(); + const tooltip = page.getByRole('tooltip').filter({ hasText: 'This tooltip shifts and flips' }); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toHaveAttribute('data-side', 'bottom'); + + const bounds = await tooltip.boundingBox(); + const viewport = page.viewportSize(); + expect(bounds).not.toBeNull(); + expect(viewport).not.toBeNull(); + expect(bounds!.x).toBeGreaterThanOrEqual(7); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport!.width - 7); + + await tooltip.hover(); + await expect(tooltip).toBeVisible(); +}); + +test('hover-triggered tooltip is dismissible with Escape and reopens after attention leaves', async ({ page }) => { + const trigger = page.getByRole('button', { name: 'Viewport edge help' }); + const tooltip = page.getByRole('tooltip').filter({ hasText: 'This tooltip shifts and flips' }); + + await trigger.hover(); + await expect(tooltip).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(tooltip).toBeHidden(); + + await page.getByRole('heading', { name: 'Floating overlay fixture' }).hover(); + await trigger.hover(); + await expect(tooltip).toBeVisible(); +}); + +test('right-to-left direction preserves logical floating alignment and keyboard order', async ({ page }) => { + await page.evaluate(() => { document.documentElement.dir = 'rtl'; }); + + const select = page.getByRole('combobox', { name: 'Release channel', exact: true }); + await select.click(); + const listbox = page.getByRole('listbox', { name: 'Release channel' }); + await expect(listbox).toBeVisible(); + + const triggerBox = await select.boundingBox(); + const listboxBox = await listbox.boundingBox(); + expect(triggerBox).not.toBeNull(); + expect(listboxBox).not.toBeNull(); + expect(listboxBox!.x + listboxBox!.width).toBeCloseTo(triggerBox!.x + triggerBox!.width, 0); + + await select.press('Escape'); + const composite = page.getByRole('region', { name: 'Composite accessibility fixture' }); + const overview = composite.getByRole('tab', { name: 'Overview' }); + await overview.focus(); + await overview.press('ArrowLeft'); + await expect(composite.getByRole('tab', { name: 'Usage' })).toBeFocused(); + await expect(page.getByTestId('composite-tab-active')).toHaveText('1'); +}); + +test('forced-colors tokens keep focus and contrast semantics available', async ({ page }) => { + await page.emulateMedia({ forcedColors: 'active' }); + const trigger = page.getByRole('combobox', { name: 'Release channel', exact: true }); + await trigger.focus(); + + const tokens = await page.evaluate(() => { + const root = getComputedStyle(document.documentElement); + const control = document.querySelector('[role="combobox"]'); + const style = control ? getComputedStyle(control) : null; + return { + ink: root.getPropertyValue('--webase-color-ink').trim(), + surface: root.getPropertyValue('--webase-color-surface').trim(), + brand: root.getPropertyValue('--webase-color-brand').trim(), + outlineStyle: style?.outlineStyle, + outlineWidth: style?.outlineWidth + }; + }); + + expect(tokens).toMatchObject({ ink: 'CanvasText', surface: 'Canvas', brand: 'LinkText' }); + expect(tokens.outlineStyle).toBe('solid'); + expect(tokens.outlineWidth).toBe('2px'); + + const scan = await new AxeBuilder({ page }).include('main').analyze(); + const violations = scan.violations.filter(({ impact }) => impact === 'serious' || impact === 'critical'); + expect(violations, JSON.stringify(violations, null, 2)).toEqual([]); +}); + +test('localized copy wraps without horizontal overflow at 200 percent zoom', async ({ page }) => { + // A 195px CSS viewport models a 390px device at 200% browser zoom while + // preserving normal reflow instead of scaling the entire document canvas. + await page.setViewportSize({ width: 195, height: 844 }); + const fixture = page.getByTestId('i18n-fixture'); + await fixture.evaluate((element) => { + const main = document.querySelector('main'); + if (main) main.style.gridTemplateColumns = 'minmax(0, 1fr)'; + document.querySelectorAll('main > *:not([data-testid="i18n-fixture"])').forEach((section) => { + (section as HTMLElement).style.display = 'none'; + }); + element.scrollIntoView({ block: 'start' }); + }); + + const metrics = await page.evaluate(() => { + const root = document.documentElement; + const fixtureElement = document.querySelector('[data-testid="i18n-fixture"]'); + return { + documentOverflow: root.scrollWidth > root.clientWidth, + fixtureOverflow: fixtureElement ? fixtureElement.scrollWidth > fixtureElement.clientWidth : true, + tabs: document.querySelectorAll('[data-testid="i18n-fixture"] [role="tab"]').length + }; + }); + + expect(metrics.documentOverflow, JSON.stringify(metrics)).toBe(false); + expect(metrics.fixtureOverflow, JSON.stringify(metrics)).toBe(false); + expect(metrics.tabs).toBe(3); + await expect(page.getByRole('combobox', { name: 'قناة الإصدار والبيئة المستهدفة' })).toBeVisible(); + await expect(fixture.getByText('٦٢ بالمئة', { exact: true })).toBeVisible(); + await expect(fixture.getByText('٣٠ درجة', { exact: true })).toBeVisible(); + await expect(fixture.getByText('٧ من ٢٠', { exact: true })).toBeVisible(); + await expect(fixture.getByRole('button', { name: 'الصفحة ٢' })).toHaveText('٢'); +}); + +test('dialog reports confirm, cancel, escape, and backdrop actions only', async ({ page }) => { + const result = page.getByTestId('dialog-result'); + const openButton = page.getByRole('button', { name: 'Open dialog' }); + + await openButton.click(); + await expect(page.locator('body')).toHaveCSS('overflow', 'hidden'); + await page.getByRole('button', { name: 'Confirm' }).click(); + await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden'); + await expect(openButton).toBeFocused(); + await expect(result).toHaveText('confirm'); + await expect(page.getByTestId('dialog-clicks')).toHaveText('1'); + await expect(page.getByTestId('dialog-closes')).toHaveText('1'); + + await openButton.click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(result).toHaveText('cancel'); + + await openButton.click(); + await page.keyboard.press('Escape'); + await expect(result).toHaveText('cancel'); + + await openButton.click(); + await page.mouse.click(6, 6); + await expect(result).toHaveText('cancel'); + + await openButton.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.evaluate(() => (window as Window & { closeFixtureDialog?: () => void }).closeFixtureDialog?.()); + await expect(page.getByRole('dialog')).toBeHidden(); + await expect(openButton).toBeFocused(); + await expect(result).toHaveText('none'); +}); + +test('select does not display an option that does not match its bound value', async ({ page }) => { + await page.getByRole('button', { name: 'Set invalid selection' }).click(); + + await expect(page.getByRole('combobox', { name: 'Release channel', exact: true })).toContainText('Select an option'); +}); + +test('card and empty-state actions never render as enabled inert controls', async ({ page }) => { + await page.getByRole('button', { name: 'Run action' }).click(); + await expect(page.getByTestId('card-actions')).toHaveText('1'); + await expect(page.getByRole('button', { name: 'Hidden action' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Hidden empty action' })).toHaveCount(0); +}); + +test('toast auto-dismiss pauses on hover and restarts after pointer leave', async ({ page }) => { + await page.getByRole('button', { name: 'Show timed toast' }).click(); + const timedRegion = page.getByRole('status').filter({ hasText: 'Timed fixture' }); + const toast = timedRegion.locator('aside'); + + await expect(toast).toBeVisible(); + await toast.hover(); + await page.waitForTimeout(750); + await expect(toast).toBeVisible(); + + await page.getByRole('heading', { name: 'Composite accessibility fixture' }).hover(); + await page.waitForTimeout(300); + await expect(toast).toBeVisible(); + await expect(toast).toBeHidden({ timeout: 700 }); +}); + +test('tabs and accordion share wrapping roving focus and react to collection changes', async ({ page }) => { + const compositeFixture = page.getByRole('region', { name: 'Composite accessibility fixture' }); + const tabList = compositeFixture.getByRole('tablist', { name: 'Fixture tabs' }); + const overviewTab = tabList.getByRole('tab', { name: 'Overview' }); + const releaseTab = tabList.getByRole('tab', { name: 'Release' }); + + await overviewTab.focus(); + await overviewTab.press('ArrowLeft'); + await expect(releaseTab).toBeFocused(); + await expect(releaseTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId('composite-tab-active')).toHaveText('2'); + + const firstAccordionTrigger = compositeFixture.getByRole('button', { name: /Keyboard contract/ }); + const lastAccordionTrigger = compositeFixture.getByRole('button', { name: /Dynamic collection/ }); + await firstAccordionTrigger.focus(); + await firstAccordionTrigger.press('ArrowUp'); + await expect(lastAccordionTrigger).toBeFocused(); + await expect(firstAccordionTrigger).toHaveAttribute('aria-expanded', 'true'); + await expect(lastAccordionTrigger).toHaveAttribute('aria-expanded', 'false'); + + await compositeFixture.getByRole('button', { name: 'Remove last composite item' }).click(); + await expect(releaseTab).toHaveCount(0); + await expect(lastAccordionTrigger).toHaveCount(0); + await expect(overviewTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId('composite-tab-active')).toHaveText('0'); +}); + +test('the example brand theme changes semantics without changing component geometry', async ({ page }) => { + const action = page.getByRole('button', { name: 'Brand action' }); + const lightBox = await action.boundingBox(); + + await page.getByRole('button', { name: 'Use brand theme' }).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'brand'); + + const brandTokens = await page.evaluate(() => { + const style = getComputedStyle(document.documentElement); + return { + canvas: style.getPropertyValue('--webase-color-canvas').trim(), + surface: style.getPropertyValue('--webase-color-surface').trim(), + brand: style.getPropertyValue('--webase-color-brand').trim(), + controlHeight: style.getPropertyValue('--webase-component-button-height').trim() + }; + }); + expect(brandTokens).toEqual({ + canvas: '#f4f6f2', + surface: '#fbfcf8', + brand: '#315c52', + controlHeight: '44px' + }); + + const brandBox = await action.boundingBox(); + expect(brandBox && lightBox ? { width: brandBox.width, height: brandBox.height } : null).toEqual( + lightBox ? { width: lightBox.width, height: lightBox.height } : null + ); +}); + +test('reduced motion makes component transitions and entrances static', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.reload(); + + const select = page.getByRole('combobox', { name: 'Release channel', exact: true }); + await select.click(); + const tooltipTrigger = page.getByRole('button', { name: 'Keyboard help' }); + await tooltipTrigger.focus(); + + const samples: Array<[string, Locator]> = [ + ['field', page.getByTestId('field-input')], + ['button', page.getByRole('button', { name: 'Hover action' })], + ['card', page.getByRole('article').filter({ hasText: 'Action card' })], + ['pagination', page.getByRole('button', { name: 'Page 2' })], + ['radio', page.getByTestId('radio-input').locator('+ .ds-radio-dot')], + ['select trigger', select], + ['select menu', page.getByRole('listbox', { name: 'Release channel' })], + ['toast', page.getByRole('status').filter({ hasText: 'Fixture ready' }).locator('aside')], + ['tooltip', page.getByRole('tooltip').filter({ hasText: 'Press Escape' })] + ]; + + for (const [name, locator] of samples) await expectStaticMotion(locator, name); + + await select.press('Escape'); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Open dialog' }).click(); + await expectStaticMotion(page.getByRole('dialog').locator('.ds-dialog'), 'dialog'); +}); + +test('interactive controls meet the WCAG 2.2 minimum target size', async ({ page }) => { + const targets: Array<[string, Locator]> = [ + ['check', page.getByTestId('check-input').locator('..')], + ['radio', page.getByTestId('radio-input').locator('..')], + ['switch', page.getByTestId('switch-input').locator('..')], + ['slider', page.getByTestId('slider-input')], + ['card action', page.getByRole('button', { name: 'Run action' })], + ['action link', page.getByRole('link', { name: 'Back to form' })], + ['icon button', page.getByRole('button', { name: 'Pressed icon action' })], + ['pagination', page.getByRole('button', { name: 'Page 2' })], + ['tab', page.getByRole('tab', { name: 'Behavior' })], + ['accordion', page.getByRole('button', { name: /Keyboard contract/ })], + ['select', page.getByRole('combobox', { name: 'Release channel', exact: true })], + ['tooltip', page.getByRole('button', { name: 'Keyboard help' })], + ['alert dismiss', page.getByRole('button', { name: 'Dismiss alert' })], + ['toast dismiss', page.getByRole('button', { name: 'Dismiss notification' }).first()] + ]; + + for (const [name, locator] of targets) await expectMinimumTarget(locator, name); + + await page.getByRole('button', { name: 'Open dialog' }).click(); + await expectMinimumTarget(page.getByRole('button', { name: 'Close dialog' }), 'dialog close'); + await expectMinimumTarget(page.getByRole('button', { name: 'Cancel' }), 'dialog cancel'); + await expectMinimumTarget(page.getByRole('button', { name: 'Confirm' }), 'dialog confirm'); +}); + +test('priority components have no serious or critical accessibility violations', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + const trigger = page.getByRole('combobox', { name: 'Release channel', exact: true }); + await trigger.click(); + + const openSelectScan = await new AxeBuilder({ page }).include('main').analyze(); + const openSelectViolations = openSelectScan.violations.filter(({ impact }) => impact === 'serious' || impact === 'critical'); + expect(openSelectViolations, JSON.stringify(openSelectViolations, null, 2)).toEqual([]); + + await trigger.press('Escape'); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + const tooltipTrigger = page.getByRole('button', { name: 'Keyboard help' }); + await tooltipTrigger.focus(); + await expect(page.getByRole('tooltip').filter({ hasText: 'Press Escape' })).toBeVisible(); + const openTooltipScan = await new AxeBuilder({ page }).include('main').analyze(); + const openTooltipViolations = openTooltipScan.violations.filter(({ impact }) => impact === 'serious' || impact === 'critical'); + expect(openTooltipViolations, JSON.stringify(openTooltipViolations, null, 2)).toEqual([]); + await page.keyboard.press('Escape'); + + await page.getByRole('button', { name: 'Open dialog' }).click(); + + const openDialogScan = await new AxeBuilder({ page }).include('main').analyze(); + const openDialogViolations = openDialogScan.violations.filter(({ impact }) => impact === 'serious' || impact === 'critical'); + expect(openDialogViolations, JSON.stringify(openDialogViolations, null, 2)).toEqual([]); +}); + +test.describe('coarse pointer input', () => { + test.use({ hasTouch: true, viewport: { width: 390, height: 844 } }); + + test('touch keeps hover-only motion off and preserves equivalent controls', async ({ page }) => { + const media = await page.evaluate(() => ({ + coarse: matchMedia('(pointer: coarse)').matches, + hover: matchMedia('(hover: hover)').matches + })); + expect(media).toEqual({ coarse: true, hover: false }); + + const tooltipTrigger = page.getByRole('button', { name: 'Keyboard help' }); + await tooltipTrigger.focus(); + await expect(page.getByRole('tooltip').filter({ hasText: 'Press Escape' })).toBeVisible(); + await expect(tooltipTrigger).toBeFocused(); + + const check = page.getByTestId('check-input'); + await check.locator('..').tap(); + await expect(check).not.toBeChecked(); + + const action = page.getByRole('button', { name: 'Hover action' }); + await action.tap(); + await expect(action).toHaveCSS('transform', 'none'); + await expect(page.getByRole('article').filter({ hasText: 'Action card' })).toHaveCSS('transform', 'none'); + + await expectMinimumTarget(check.locator('..'), 'coarse pointer check'); + await expectMinimumTarget(page.getByTestId('slider-input'), 'coarse pointer slider'); + await expectMinimumTarget(page.getByRole('button', { name: 'Run action' }), 'coarse pointer card action'); + }); +}); diff --git a/tests/browser/specs/docs.spec.ts b/tests/browser/specs/docs.spec.ts new file mode 100644 index 0000000..c117bb2 --- /dev/null +++ b/tests/browser/specs/docs.spec.ts @@ -0,0 +1,165 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +const docsUrl = 'http://127.0.0.1:4175'; + +test.beforeEach(async ({ page }) => { + await page.goto(docsUrl); +}); + +test('docs header keeps the logo-only brand, stable anchors, theme, and search', async ({ page }) => { + const brand = page.locator('header .brand'); + await expect(brand.locator('img')).toHaveAttribute('src', '/logo.svg'); + await expect(brand.locator('strong, small')).toHaveCount(0); + + await expect(page.getByRole('navigation', { name: 'Documentation' }).getByRole('link')).toHaveText(['Tokens', 'Components', 'Install']); + + await page.getByRole('switch', { name: 'Toggle dark theme' }).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + await page.getByRole('button', { name: 'Search documentation' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('searchbox', { name: 'Search components and API fields' })).toBeFocused(); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toBeHidden(); + await expect(page.getByRole('button', { name: 'Search documentation' })).toBeFocused(); +}); + +test('global search selects a component and exposes its parsed API contract', async ({ page }) => { + await page.keyboard.press('/'); + const search = page.getByRole('searchbox', { name: 'Search components and API fields' }); + await expect(search).toBeFocused(); + await search.fill('dialog'); + + await page.getByRole('button', { name: /WeBaseDialog/ }).click(); + await expect(page.getByRole('dialog')).toBeHidden(); + await expect(page).toHaveURL(/#component-dialog$/); + await expect(page.locator('[data-selected-component="WeBaseDialog"]')).toBeVisible(); + await expect(page.locator('[data-selected-component="WeBaseDialog"]')).toBeFocused(); + await expect(page.getByRole('heading', { name: 'WeBaseDialog', level: 3 })).toBeVisible(); + await expect(page.locator('.contract-panel')).toContainText('showModal()'); +}); + +test('the reference desk indexes all 28 components and supports local filtering', async ({ page }) => { + await expect(page.locator('.sidebar-group button')).toHaveCount(28); + + await page.getByRole('searchbox', { name: 'Filter components' }).fill('slider'); + await expect(page.locator('.sidebar-group button')).toHaveCount(1); + await page.getByRole('button', { name: 'Slider', exact: true }).click(); + + await expect(page.locator('[data-selected-component="WeBaseSlider"]')).toBeVisible(); + await expect(page.locator('.contract-panel')).toContainText('inputProps'); +}); + +test('usage examples and install commands provide copy feedback', async ({ page }) => { + const exampleCopy = page.getByRole('button', { name: 'Copy WeBaseButton example' }); + await exampleCopy.click(); + await expect(exampleCopy).toContainText('Copied'); + + await page.locator('#install').scrollIntoViewIfNeeded(); + const installCopy = page.getByRole('button', { name: 'Copy install command' }); + await installCopy.click(); + await expect(installCopy).toContainText('Copied'); +}); + +test('favicon metadata covers scalable, small, touch, and installed-app icons', async ({ page, request }) => { + const iconHrefs = await page.locator('link[rel~="icon"], link[rel="apple-touch-icon"], link[rel="manifest"]').evaluateAll((links) => { + return links.map((link) => link.getAttribute('href')).filter((href): href is string => Boolean(href)); + }); + + expect(iconHrefs).toEqual(expect.arrayContaining([ + '/logo.svg', + '/favicon-16x16.png', + '/favicon-32x32.png', + '/favicon-48x48.png', + '/favicon.ico', + '/apple-touch-icon.png', + '/site.webmanifest' + ])); + + for (const href of iconHrefs) { + const response = await request.get(new URL(href, docsUrl).toString()); + expect(response.ok(), `${href} should be served`).toBe(true); + } + + const manifestResponse = await request.get(new URL('/site.webmanifest', docsUrl).toString()); + const manifest = await manifestResponse.json() as { icons?: Array<{ src: string; sizes: string; type: string; purpose?: string }> }; + expect(manifest.icons).toEqual(expect.arrayContaining([ + { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } + ])); + + for (const icon of manifest.icons ?? []) { + const response = await request.get(new URL(icon.src, docsUrl).toString()); + expect(response.ok(), `${icon.src} should be served`).toBe(true); + } + + const robotsResponse = await request.get(new URL('/robots.txt', docsUrl).toString()); + expect(robotsResponse.ok()).toBe(true); + expect(await robotsResponse.text()).toContain('User-agent: *'); +}); + +test('mobile navigation and component selection are explicitly composed without overflow', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + + await expect(page.locator('header .brand-logo')).toBeVisible(); + await page.getByRole('button', { name: 'Menu' }).click(); + await expect(page.getByRole('navigation', { name: 'Mobile documentation' })).toBeVisible(); + await page.getByRole('link', { name: 'Components', exact: true }).last().click(); + + await page.getByLabel('Choose a component').selectOption('WeBaseDialog'); + await expect(page.locator('[data-selected-component="WeBaseDialog"]')).toBeVisible(); + + const horizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth); + expect(horizontalOverflow).toBe(false); +}); + +test('reduced motion removes entry, reveal, and state transition animations', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.reload(); + + const motion = await page.locator('.hero-stage').evaluate((element) => { + const heroStyle = getComputedStyle(element); + const revealStyle = getComputedStyle(document.querySelector('[data-reveal]')!); + return { + animationName: heroStyle.animationName, + transitionDuration: heroStyle.transitionDuration, + revealOpacity: revealStyle.opacity, + revealTransform: revealStyle.transform + }; + }); + + expect(motion.animationName).toBe('none'); + expect(motion.transitionDuration).toBe('0s'); + expect(motion.revealOpacity).toBe('1'); + expect(motion.revealTransform).toBe('none'); +}); + +test('the documentation surface has no serious or critical accessibility violations', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.reload(); + + const pageScan = await new AxeBuilder({ page }).include('main').analyze(); + const pageViolations = pageScan.violations.filter(({ impact }) => impact === 'serious' || impact === 'critical'); + expect(pageViolations, JSON.stringify(pageViolations, null, 2)).toEqual([]); + + await page.getByRole('button', { name: 'Search documentation' }).click(); + const lightSearchScan = await new AxeBuilder({ page }).include('.search-dialog').analyze(); + const lightSearchViolations = lightSearchScan.violations.filter(({ impact }) => impact === 'serious' || impact === 'critical'); + expect(lightSearchViolations, JSON.stringify(lightSearchViolations, null, 2)).toEqual([]); + + await page.getByRole('button', { name: 'Close search' }).click(); + await page.getByRole('switch', { name: 'Toggle dark theme' }).click(); + + const darkPageScan = await new AxeBuilder({ page }).include('main').analyze(); + const darkPageViolations = darkPageScan.violations.filter(({ impact }) => impact === 'serious' || impact === 'critical'); + expect(darkPageViolations, JSON.stringify(darkPageViolations, null, 2)).toEqual([]); + + await page.getByRole('button', { name: 'Search documentation' }).click(); + const darkSearchScan = await new AxeBuilder({ page }).include('.search-dialog').analyze(); + const darkSearchViolations = darkSearchScan.violations.filter(({ impact }) => impact === 'serious' || impact === 'critical'); + expect(darkSearchViolations, JSON.stringify(darkSearchViolations, null, 2)).toEqual([]); +}); diff --git a/tests/browser/specs/ssr.spec.ts b/tests/browser/specs/ssr.spec.ts new file mode 100644 index 0000000..eb5f4c8 --- /dev/null +++ b/tests/browser/specs/ssr.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; + +const ssrUrl = 'http://127.0.0.1:4177'; + +test('all public components render on the server and hydrate without warnings', async ({ page, request }) => { + const serverResponse = await request.get(ssrUrl); + expect(serverResponse.ok()).toBe(true); + const serverHtml = await serverResponse.text(); + expect(serverHtml).toContain('data-testid="ssr-root"'); + expect(serverHtml).toContain('Increment hydrated count'); + expect(serverHtml).toContain('data-component="WeBaseTooltip"'); + + const runtimeIssues: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error' || message.type() === 'warning') runtimeIssues.push(message.text()); + }); + page.on('pageerror', (error) => runtimeIssues.push(error.message)); + + await page.goto(ssrUrl); + await expect(page.locator('html')).toHaveAttribute('data-hydrated', 'true'); + await page.getByRole('button', { name: 'Increment hydrated count' }).click(); + await expect(page.getByTestId('hydration-count')).toHaveText('1'); + expect(runtimeIssues.filter((message) => /hydration|mismatch|uncaught|error/i.test(message))).toEqual([]); +}); diff --git a/tests/browser/specs/visual-cases.ts b/tests/browser/specs/visual-cases.ts new file mode 100644 index 0000000..d317fce --- /dev/null +++ b/tests/browser/specs/visual-cases.ts @@ -0,0 +1,32 @@ +export const visualComponents = [ + { name: 'WeBaseAccordion', slug: 'accordion' }, + { name: 'WeBaseAlert', slug: 'alert' }, + { name: 'WeBaseBadge', slug: 'badge' }, + { name: 'WeBaseBreadcrumbs', slug: 'breadcrumbs' }, + { name: 'WeBaseButton', slug: 'button' }, + { name: 'WeBaseCard', slug: 'card' }, + { name: 'WeBaseCheck', slug: 'check' }, + { name: 'WeBaseDialog', slug: 'dialog' }, + { name: 'WeBaseDivider', slug: 'divider' }, + { name: 'WeBaseEmptyState', slug: 'emptystate' }, + { name: 'WeBaseField', slug: 'field' }, + { name: 'WeBaseIcon', slug: 'icon' }, + { name: 'WeBaseIconButton', slug: 'iconbutton' }, + { name: 'WeBaseLink', slug: 'link' }, + { name: 'WeBaseLoader', slug: 'loader' }, + { name: 'WeBasePagination', slug: 'pagination' }, + { name: 'WeBaseProgress', slug: 'progress' }, + { name: 'WeBaseRadio', slug: 'radio' }, + { name: 'WeBaseSectionHeader', slug: 'sectionheader' }, + { name: 'WeBaseSelect', slug: 'select' }, + { name: 'WeBaseSkeleton', slug: 'skeleton' }, + { name: 'WeBaseSlider', slug: 'slider' }, + { name: 'WeBaseSwitch', slug: 'switch' }, + { name: 'WeBaseTag', slug: 'tag' }, + { name: 'WeBaseTabs', slug: 'tabs' }, + { name: 'WeBaseTextarea', slug: 'textarea' }, + { name: 'WeBaseToast', slug: 'toast' }, + { name: 'WeBaseTooltip', slug: 'tooltip' } +] as const; + +export type VisualComponent = (typeof visualComponents)[number]; diff --git a/tests/browser/specs/visual.spec.ts b/tests/browser/specs/visual.spec.ts new file mode 100644 index 0000000..c708e59 --- /dev/null +++ b/tests/browser/specs/visual.spec.ts @@ -0,0 +1,228 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; +import { visualComponents, type VisualComponent } from './visual-cases'; + +const docsUrl = 'http://127.0.0.1:4175'; +const fixtureUrl = 'http://127.0.0.1:4176'; +const screenshotOptions = { + animations: 'disabled' as const, + caret: 'hide' as const, + scale: 'css' as const, + threshold: 0.2, + maxDiffPixelRatio: 0.01 +}; + +async function openSpecimen(page: Page, component: VisualComponent, theme: 'light' | 'dark' = 'light') { + await page.addInitScript((selectedTheme) => { + localStorage.setItem('webaseui-theme', selectedTheme); + }, theme); + await page.goto(`${docsUrl}/#component-${component.slug}`); + await page.addStyleTag({ + content: ` + body::before { display: none !important; } + *, *::before, *::after { + animation: none !important; + caret-color: transparent !important; + transition: none !important; + } + [data-reveal] { opacity: 1 !important; transform: none !important; } + ` + }); + + await expect(page.locator('html')).toHaveAttribute('data-theme', theme); + await page.evaluate(async () => { + await document.fonts?.ready; + }); + + const detail = page.locator(`[data-selected-component="${component.name}"]`); + await expect(detail).toBeVisible(); + await detail.scrollIntoViewIfNeeded(); + return detail; +} + +async function openFixtureState(page: Page, testId: string) { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(fixtureUrl); + await page.addStyleTag({ + content: ` + *, *::before, *::after { + animation: none !important; + caret-color: transparent !important; + transition: none !important; + } + ` + }); + await page.evaluate(async () => { + await document.fonts?.ready; + }); + + const state = page.getByTestId(testId); + await expect(state).toBeVisible(); + await state.scrollIntoViewIfNeeded(); + return state; +} + +function previewOf(detail: Locator) { + return detail.locator('.preview-panel'); +} + +test.describe('component visual baselines', () => { + for (const component of visualComponents) { + test(`${component.name} light specimen`, async ({ page }) => { + const detail = await openSpecimen(page, component); + await expect(previewOf(detail)).toHaveScreenshot(`${component.slug}-light.png`, screenshotOptions); + }); + } +}); + +test.describe('theme and viewport coverage', () => { + test('dark theme preserves the component reference hierarchy', async ({ page }) => { + const detail = await openSpecimen(page, { name: 'WeBaseButton', slug: 'button' }, 'dark'); + await expect(previewOf(detail)).toHaveScreenshot('button-dark.png', screenshotOptions); + }); + + test('mobile specimen stays composed without horizontal clipping', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + const detail = await openSpecimen(page, { name: 'WeBaseSelect', slug: 'select' }); + await expect(previewOf(detail)).toHaveScreenshot('select-mobile.png', screenshotOptions); + }); + + test('brand theme preserves component geometry on the shared semantic contract', async ({ page }) => { + await page.goto(fixtureUrl); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.getByRole('button', { name: 'Use brand theme' }).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'brand'); + await expect(page.getByTestId('brand-theme-fixture')).toHaveScreenshot('brand-theme.png', screenshotOptions); + }); +}); + +test.describe('key interaction states', () => { + test('button focus is visible', async ({ page }) => { + const detail = await openSpecimen(page, { name: 'WeBaseButton', slug: 'button' }); + await detail.getByRole('button', { name: 'Save changes' }).focus(); + await expect(previewOf(detail)).toHaveScreenshot('button-focus.png', screenshotOptions); + }); + + test('select open state stays inside its specimen canvas', async ({ page }) => { + const detail = await openSpecimen(page, { name: 'WeBaseSelect', slug: 'select' }); + await detail.getByRole('combobox', { name: 'Release channel' }).click(); + await expect(detail.getByRole('listbox')).toBeVisible(); + await expect(previewOf(detail)).toHaveScreenshot('select-open.png', screenshotOptions); + }); + + test('dialog open state has a stable modal baseline', async ({ page }) => { + const detail = await openSpecimen(page, { name: 'WeBaseDialog', slug: 'dialog' }); + await detail.getByRole('button', { name: 'Open dialog' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveScreenshot('dialog-open.png', screenshotOptions); + }); + + test('tooltip focus state remains readable and dismissible', async ({ page }) => { + const detail = await openSpecimen(page, { name: 'WeBaseTooltip', slug: 'tooltip' }); + await detail.getByRole('button', { name: 'Hover or focus' }).focus(); + await expect(detail.getByRole('tooltip').first()).toBeVisible(); + await expect(previewOf(detail)).toHaveScreenshot('tooltip-focus.png', screenshotOptions); + }); + + test('floating overlays remain composed at scroll and viewport edges', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 420 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(fixtureUrl); + const container = page.getByTestId('floating-scroll-container'); + await container.evaluate((element) => { element.scrollTop = element.scrollHeight; }); + + const select = page.getByRole('combobox', { name: 'Scrollable release channel' }); + await select.evaluate((element) => element.scrollIntoView({ block: 'end' })); + await select.click(); + await expect(page.getByRole('listbox', { name: 'Scrollable release channel' })).toHaveAttribute('data-side', 'top'); + await expect(page).toHaveScreenshot('floating-select-collision.png', screenshotOptions); + + await select.press('Escape'); + const tooltipTrigger = page.getByRole('button', { name: 'Viewport edge help' }); + await tooltipTrigger.evaluate((element) => element.scrollIntoView({ block: 'start', inline: 'end' })); + await tooltipTrigger.focus(); + await expect(page.getByRole('tooltip').filter({ hasText: 'This tooltip shifts and flips' })).toHaveAttribute('data-side', 'bottom'); + await expect(page).toHaveScreenshot('floating-tooltip-collision.png', screenshotOptions); + }); + + test('right-to-left and forced-colors fixtures retain hierarchy', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 420 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(fixtureUrl); + await page.evaluate(() => { document.documentElement.dir = 'rtl'; }); + const select = page.getByRole('combobox', { name: 'Release channel', exact: true }); + await select.click(); + await expect(page.getByRole('listbox', { name: 'Release channel' })).toBeVisible(); + await expect(page).toHaveScreenshot('rtl-floating.png', screenshotOptions); + + await select.press('Escape'); + await page.emulateMedia({ forcedColors: 'active' }); + await expect(page).toHaveScreenshot('forced-colors-fixture.png', screenshotOptions); + }); + + test('localized copy remains composed at 200 percent text scale', async ({ page }) => { + await page.setViewportSize({ width: 195, height: 844 }); + await page.goto(fixtureUrl); + const fixture = page.getByTestId('i18n-fixture'); + await fixture.evaluate((element) => { + const main = document.querySelector('main'); + if (main) main.style.gridTemplateColumns = 'minmax(0, 1fr)'; + document.querySelectorAll('main > *:not([data-testid="i18n-fixture"])').forEach((section) => { + (section as HTMLElement).style.display = 'none'; + }); + element.scrollIntoView({ block: 'start' }); + }); + await expect(fixture).toHaveScreenshot('localized-copy-zoom.png', screenshotOptions); + }); + + test('button hover, disabled, loading, and pressed states remain distinct', async ({ page }) => { + const state = await openFixtureState(page, 'action-state-matrix'); + await state.getByRole('button', { name: 'Hover action' }).hover(); + await expect(state).toHaveScreenshot('action-state-matrix.png', screenshotOptions); + }); + + test('form error states keep visible messages and invalid affordances', async ({ page }) => { + const state = await openFixtureState(page, 'form-error-state-matrix'); + await expect(state).toHaveScreenshot('form-error-state-matrix.png', screenshotOptions); + }); + + test('disabled form controls remain legible as a group', async ({ page }) => { + const state = await openFixtureState(page, 'form-disabled-state-matrix'); + await expect(state).toHaveScreenshot('form-disabled-state-matrix.png', screenshotOptions); + }); + + test('disabled selection controls preserve state and hierarchy', async ({ page }) => { + const state = await openFixtureState(page, 'selection-disabled-state-matrix'); + await expect(state).toHaveScreenshot('selection-disabled-state-matrix.png', screenshotOptions); + }); + + test('warning and error feedback tones remain distinguishable', async ({ page }) => { + const state = await openFixtureState(page, 'feedback-tone-state-matrix'); + await expect(state).toHaveScreenshot('feedback-tone-state-matrix.png', screenshotOptions); + }); + + test('navigation hover and disabled states remain composed', async ({ page }) => { + const state = await openFixtureState(page, 'navigation-state-matrix'); + await state.getByRole('button', { name: 'Page 2' }).hover(); + await expect(state).toHaveScreenshot('navigation-state-matrix.png', screenshotOptions); + }); + + test('tabs expose a stable hover state', async ({ page }) => { + const detail = await openSpecimen(page, { name: 'WeBaseTabs', slug: 'tabs' }); + await detail.getByRole('tab', { name: 'Behavior' }).hover(); + await expect(previewOf(detail)).toHaveScreenshot('tabs-hover.png', screenshotOptions); + }); + + test('accordion exposes a stable hover state', async ({ page }) => { + const detail = await openSpecimen(page, { name: 'WeBaseAccordion', slug: 'accordion' }); + await detail.getByRole('button', { name: /Consumer surface/ }).hover(); + await expect(previewOf(detail)).toHaveScreenshot('accordion-hover.png', screenshotOptions); + }); + + test('toast open state remains readable and dismissible', async ({ page }) => { + const detail = await openSpecimen(page, { name: 'WeBaseToast', slug: 'toast' }); + await detail.getByRole('button', { name: 'Show toast' }).click(); + await expect(detail.getByRole('status').getByText('Saved locally', { exact: true })).toBeVisible(); + await expect(previewOf(detail)).toHaveScreenshot('toast-open.png', screenshotOptions); + }); +}); diff --git a/tests/browser/specs/visual.spec.ts-snapshots/accordion-hover-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/accordion-hover-visual-chromium-darwin.png new file mode 100644 index 0000000..65cf123 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/accordion-hover-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/accordion-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/accordion-light-visual-chromium-darwin.png new file mode 100644 index 0000000..480eed3 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/accordion-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/action-state-matrix-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/action-state-matrix-visual-chromium-darwin.png new file mode 100644 index 0000000..614ef34 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/action-state-matrix-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/alert-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/alert-light-visual-chromium-darwin.png new file mode 100644 index 0000000..e72da79 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/alert-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/badge-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/badge-light-visual-chromium-darwin.png new file mode 100644 index 0000000..e6fb3e5 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/badge-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/brand-theme-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/brand-theme-visual-chromium-darwin.png new file mode 100644 index 0000000..ad2ae6a Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/brand-theme-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/breadcrumbs-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/breadcrumbs-light-visual-chromium-darwin.png new file mode 100644 index 0000000..94cb385 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/breadcrumbs-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/button-dark-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/button-dark-visual-chromium-darwin.png new file mode 100644 index 0000000..c74c907 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/button-dark-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/button-focus-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/button-focus-visual-chromium-darwin.png new file mode 100644 index 0000000..108f8e8 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/button-focus-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/button-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/button-light-visual-chromium-darwin.png new file mode 100644 index 0000000..1501e1a Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/button-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/card-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/card-light-visual-chromium-darwin.png new file mode 100644 index 0000000..f9aee76 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/card-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/check-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/check-light-visual-chromium-darwin.png new file mode 100644 index 0000000..9b60c40 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/check-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/dialog-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/dialog-light-visual-chromium-darwin.png new file mode 100644 index 0000000..f707d74 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/dialog-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/dialog-open-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/dialog-open-visual-chromium-darwin.png new file mode 100644 index 0000000..29ee4d7 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/dialog-open-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/divider-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/divider-light-visual-chromium-darwin.png new file mode 100644 index 0000000..ec1f7a6 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/divider-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/emptystate-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/emptystate-light-visual-chromium-darwin.png new file mode 100644 index 0000000..7330044 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/emptystate-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/feedback-tone-state-matrix-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/feedback-tone-state-matrix-visual-chromium-darwin.png new file mode 100644 index 0000000..2b6ba82 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/feedback-tone-state-matrix-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/field-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/field-light-visual-chromium-darwin.png new file mode 100644 index 0000000..05faf1a Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/field-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/floating-select-collision-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/floating-select-collision-visual-chromium-darwin.png new file mode 100644 index 0000000..0bc9cd2 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/floating-select-collision-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/floating-tooltip-collision-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/floating-tooltip-collision-visual-chromium-darwin.png new file mode 100644 index 0000000..73e19a9 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/floating-tooltip-collision-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/forced-colors-fixture-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/forced-colors-fixture-visual-chromium-darwin.png new file mode 100644 index 0000000..40d7103 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/forced-colors-fixture-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/form-disabled-state-matrix-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/form-disabled-state-matrix-visual-chromium-darwin.png new file mode 100644 index 0000000..640dfc5 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/form-disabled-state-matrix-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/form-error-state-matrix-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/form-error-state-matrix-visual-chromium-darwin.png new file mode 100644 index 0000000..c254dfc Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/form-error-state-matrix-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/icon-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/icon-light-visual-chromium-darwin.png new file mode 100644 index 0000000..db91524 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/icon-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/iconbutton-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/iconbutton-light-visual-chromium-darwin.png new file mode 100644 index 0000000..dff0035 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/iconbutton-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/link-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/link-light-visual-chromium-darwin.png new file mode 100644 index 0000000..5ae0ffb Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/link-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/loader-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/loader-light-visual-chromium-darwin.png new file mode 100644 index 0000000..90cd815 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/loader-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/localized-copy-zoom-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/localized-copy-zoom-visual-chromium-darwin.png new file mode 100644 index 0000000..8d3f87d Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/localized-copy-zoom-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/navigation-state-matrix-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/navigation-state-matrix-visual-chromium-darwin.png new file mode 100644 index 0000000..c7ab1c5 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/navigation-state-matrix-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/pagination-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/pagination-light-visual-chromium-darwin.png new file mode 100644 index 0000000..87dcaf6 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/pagination-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/progress-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/progress-light-visual-chromium-darwin.png new file mode 100644 index 0000000..ab8a37f Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/progress-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/radio-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/radio-light-visual-chromium-darwin.png new file mode 100644 index 0000000..60d185e Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/radio-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/rtl-floating-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/rtl-floating-visual-chromium-darwin.png new file mode 100644 index 0000000..d71c9c8 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/rtl-floating-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/sectionheader-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/sectionheader-light-visual-chromium-darwin.png new file mode 100644 index 0000000..eba0035 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/sectionheader-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/select-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/select-light-visual-chromium-darwin.png new file mode 100644 index 0000000..09fcd0a Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/select-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/select-mobile-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/select-mobile-visual-chromium-darwin.png new file mode 100644 index 0000000..d4988b9 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/select-mobile-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/select-open-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/select-open-visual-chromium-darwin.png new file mode 100644 index 0000000..e318dc8 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/select-open-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/selection-disabled-state-matrix-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/selection-disabled-state-matrix-visual-chromium-darwin.png new file mode 100644 index 0000000..47eb27e Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/selection-disabled-state-matrix-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/skeleton-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/skeleton-light-visual-chromium-darwin.png new file mode 100644 index 0000000..6f33dab Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/skeleton-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/slider-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/slider-light-visual-chromium-darwin.png new file mode 100644 index 0000000..ff80aff Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/slider-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/switch-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/switch-light-visual-chromium-darwin.png new file mode 100644 index 0000000..a629423 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/switch-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/tabs-hover-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/tabs-hover-visual-chromium-darwin.png new file mode 100644 index 0000000..d819de2 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/tabs-hover-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/tabs-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/tabs-light-visual-chromium-darwin.png new file mode 100644 index 0000000..45152a4 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/tabs-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/tag-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/tag-light-visual-chromium-darwin.png new file mode 100644 index 0000000..5785310 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/tag-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/textarea-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/textarea-light-visual-chromium-darwin.png new file mode 100644 index 0000000..ba39cb6 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/textarea-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/toast-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/toast-light-visual-chromium-darwin.png new file mode 100644 index 0000000..9f4823e Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/toast-light-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/toast-open-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/toast-open-visual-chromium-darwin.png new file mode 100644 index 0000000..62c9aa8 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/toast-open-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/tooltip-focus-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/tooltip-focus-visual-chromium-darwin.png new file mode 100644 index 0000000..d6ea5d8 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/tooltip-focus-visual-chromium-darwin.png differ diff --git a/tests/browser/specs/visual.spec.ts-snapshots/tooltip-light-visual-chromium-darwin.png b/tests/browser/specs/visual.spec.ts-snapshots/tooltip-light-visual-chromium-darwin.png new file mode 100644 index 0000000..ab20036 Binary files /dev/null and b/tests/browser/specs/visual.spec.ts-snapshots/tooltip-light-visual-chromium-darwin.png differ diff --git a/tests/browser/svelte.config.js b/tests/browser/svelte.config.js new file mode 100644 index 0000000..21b9399 --- /dev/null +++ b/tests/browser/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess() +}; diff --git a/tests/browser/tsconfig.json b/tests/browser/tsconfig.json new file mode 100644 index 0000000..22551f0 --- /dev/null +++ b/tests/browser/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "target": "ES2022", + "types": [ + "node", + "vite/client" + ] + }, + "include": [ + "fixture/src/**/*.ts", + "fixture/src/**/*.svelte", + "specs/**/*.ts", + "vite.config.ts" + ] +} diff --git a/tests/browser/vite.config.ts b/tests/browser/vite.config.ts new file mode 100644 index 0000000..8552f93 --- /dev/null +++ b/tests/browser/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + root: fileURLToPath(new URL('./fixture', import.meta.url)), + plugins: [svelte()] +}); diff --git a/tests/ssr/index.html b/tests/ssr/index.html new file mode 100644 index 0000000..4f49723 --- /dev/null +++ b/tests/ssr/index.html @@ -0,0 +1,13 @@ + + + + + + WeBaseUI SSR hydration fixture + + + +
+ + + diff --git a/tests/ssr/server.mjs b/tests/ssr/server.mjs new file mode 100644 index 0000000..670e974 --- /dev/null +++ b/tests/ssr/server.mjs @@ -0,0 +1,52 @@ +import { readFile } from 'node:fs/promises'; +import { createServer as createHttpServer } from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer as createViteServer } from 'vite'; + +const root = fileURLToPath(new URL('.', import.meta.url)); +const templatePath = path.join(root, 'index.html'); +const port = Number(process.env.WEBASE_SSR_PORT ?? 4177); +const host = '127.0.0.1'; + +const vite = await createViteServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + server: { middlewareMode: true }, + appType: 'custom' +}); + +const server = createHttpServer((request, response) => { + vite.middlewares(request, response, async () => { + try { + const url = request.url ?? '/'; + let template = await readFile(templatePath, 'utf8'); + template = await vite.transformIndexHtml(url, template); + const { renderPage } = await vite.ssrLoadModule('/src/entry-server.ts'); + const rendered = renderPage(); + const html = template + .replace('', rendered.head) + .replace('', rendered.body); + + response.statusCode = 200; + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + response.end(html); + } catch (error) { + vite.ssrFixStacktrace(error); + response.statusCode = 500; + response.end(error instanceof Error ? error.stack : String(error)); + } + }); +}); + +server.listen(port, host, () => { + console.log(`WeBaseUI SSR fixture listening on http://${host}:${port}`); +}); + +async function close() { + await vite.close(); + server.close(); +} + +process.once('SIGINT', close); +process.once('SIGTERM', close); diff --git a/tests/ssr/src/App.svelte b/tests/ssr/src/App.svelte new file mode 100644 index 0000000..1ee771b --- /dev/null +++ b/tests/ssr/src/App.svelte @@ -0,0 +1,124 @@ + + +
+
+ +

SSR hydration fixture

+ +
+ +
+ (clicks += 1)} /> + {clicks} +
+ +
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + diff --git a/tests/ssr/src/entry-client.ts b/tests/ssr/src/entry-client.ts new file mode 100644 index 0000000..7c5a074 --- /dev/null +++ b/tests/ssr/src/entry-client.ts @@ -0,0 +1,4 @@ +import { hydrate } from 'svelte'; +import App from './App.svelte'; + +hydrate(App, { target: document.getElementById('app')! }); diff --git a/tests/ssr/src/entry-server.ts b/tests/ssr/src/entry-server.ts new file mode 100644 index 0000000..4faa93b --- /dev/null +++ b/tests/ssr/src/entry-server.ts @@ -0,0 +1,6 @@ +import { render } from 'svelte/server'; +import App from './App.svelte'; + +export function renderPage() { + return render(App); +} diff --git a/tests/ssr/svelte.config.js b/tests/ssr/svelte.config.js new file mode 100644 index 0000000..21b9399 --- /dev/null +++ b/tests/ssr/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess() +}; diff --git a/tests/ssr/tsconfig.json b/tests/ssr/tsconfig.json new file mode 100644 index 0000000..084eedb --- /dev/null +++ b/tests/ssr/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "target": "ES2022", + "types": [ + "node", + "vite/client" + ] + }, + "include": [ + "src/**/*.ts", + "src/**/*.svelte", + "vite.config.ts" + ] +} diff --git a/tests/ssr/vite.config.ts b/tests/ssr/vite.config.ts new file mode 100644 index 0000000..65decff --- /dev/null +++ b/tests/ssr/vite.config.ts @@ -0,0 +1,9 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [svelte()], + ssr: { + noExternal: ['@lucide/svelte', '@webaseui/core', '@webaseui/svelte'] + } +}); diff --git a/tests/unit/collection.test.ts b/tests/unit/collection.test.ts new file mode 100644 index 0000000..8816aca --- /dev/null +++ b/tests/unit/collection.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { findEnabledIndex, normalizeIndex, wrapIndex } from '../../packages/webaseui-svelte/src/lib/utils/collection.js'; + +describe('findEnabledIndex', () => { + const items = [ + { disabled: true }, + { disabled: false }, + {}, + { disabled: true } + ]; + + it('finds the first enabled item in the requested direction', () => { + expect(findEnabledIndex(items, 0, 1)).toBe(1); + expect(findEnabledIndex(items, 3, -1)).toBe(2); + }); + + it('wraps starts that fall outside the collection', () => { + expect(findEnabledIndex(items, 6, 1)).toBe(2); + expect(findEnabledIndex(items, -1, 1)).toBe(1); + }); + + it('returns -1 for empty or fully disabled collections', () => { + expect(findEnabledIndex([], 0, 1)).toBe(-1); + expect(findEnabledIndex([{ disabled: true }], 0, -1)).toBe(-1); + }); + + it('uses the latest collection after enabled items are added or removed', () => { + const dynamicItems = [{ disabled: true }, { disabled: false }]; + expect(findEnabledIndex(dynamicItems, 0, 1)).toBe(1); + + dynamicItems.splice(1, 1); + expect(findEnabledIndex(dynamicItems, 0, 1)).toBe(-1); + + dynamicItems.push({ disabled: false }); + expect(findEnabledIndex(dynamicItems, 0, 1)).toBe(1); + }); +}); + +describe('normalizeIndex', () => { + it('preserves a valid controlled index', () => { + expect(normalizeIndex(2, 4)).toBe(2); + }); + + it('uses the fallback for fractional, negative, and overflowing values', () => { + expect(normalizeIndex(1.5, 4)).toBe(0); + expect(normalizeIndex(-1, 4, 2)).toBe(2); + expect(normalizeIndex(8, 4, 2)).toBe(2); + }); + + it('clamps the fallback and returns -1 for an empty collection', () => { + expect(normalizeIndex(Number.NaN, 4, 99)).toBe(3); + expect(normalizeIndex(0, 0)).toBe(-1); + }); + + it('re-normalizes a stale controlled index after the collection shrinks', () => { + expect(normalizeIndex(3, 4, 1)).toBe(3); + expect(normalizeIndex(3, 2, 1)).toBe(1); + }); +}); + +describe('wrapIndex', () => { + it('moves in both directions and wraps across either boundary', () => { + expect(wrapIndex(2, 3, 1)).toBe(0); + expect(wrapIndex(0, 3, -1)).toBe(2); + }); + + it('returns -1 for an empty collection', () => { + expect(wrapIndex(0, 0, 1)).toBe(-1); + }); +}); diff --git a/tests/unit/dialog-lifecycle.test.ts b/tests/unit/dialog-lifecycle.test.ts new file mode 100644 index 0000000..5952be5 --- /dev/null +++ b/tests/unit/dialog-lifecycle.test.ts @@ -0,0 +1,258 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createDialogLifecycle } from '../../packages/webaseui-svelte/src/lib/internal/dialog-lifecycle.js'; + +afterEach(() => { + vi.useRealTimers(); +}); + +function createDialog(open: boolean, overflow = 'clip') { + const body = { style: { overflow } }; + const trigger = { + isConnected: true, + focus: vi.fn() + }; + const ownerDocument = { + body, + activeElement: trigger as unknown, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }; + trigger.focus.mockImplementation(() => { + ownerDocument.activeElement = trigger; + }); + + const dialog = { + open, + ownerDocument, + close: vi.fn(() => { + dialog.open = false; + ownerDocument.activeElement = body; + }), + contains: vi.fn((target: Node | null) => target as unknown === dialog), + showModal: vi.fn(() => { + dialog.open = true; + ownerDocument.activeElement = dialog; + }) + }; + return { body, dialog, ownerDocument, trigger }; +} + +describe('createDialogLifecycle', () => { + it('leaves an already closed dialog unchanged', () => { + const { dialog } = createDialog(false); + const lifecycle = createDialogLifecycle(); + + expect(lifecycle.synchronize(dialog, false)).toBeUndefined(); + expect(dialog.close).not.toHaveBeenCalled(); + expect(dialog.showModal).not.toHaveBeenCalled(); + expect(dialog.ownerDocument.body.style.overflow).toBe('clip'); + }); + + it('closes a native dialog when controlled state becomes false', () => { + const { dialog } = createDialog(true); + const lifecycle = createDialogLifecycle(); + + expect(lifecycle.synchronize(dialog, false)).toBeUndefined(); + expect(dialog.close).toHaveBeenCalledTimes(1); + expect(dialog.open).toBe(false); + }); + + it('opens a closed dialog, locks scrolling, and restores the prior overflow on cleanup', () => { + const { dialog } = createDialog(false); + const lifecycle = createDialogLifecycle(); + + const cleanup = lifecycle.synchronize(dialog, true); + + expect(dialog.showModal).toHaveBeenCalledTimes(1); + expect(dialog.open).toBe(true); + expect(dialog.ownerDocument.body.style.overflow).toBe('hidden'); + + cleanup?.(); + expect(dialog.ownerDocument.body.style.overflow).toBe('clip'); + }); + + it('does not call showModal again for an already open native dialog', () => { + const { dialog } = createDialog(true, 'auto'); + const lifecycle = createDialogLifecycle(); + + const cleanup = lifecycle.synchronize(dialog, true); + + expect(dialog.showModal).not.toHaveBeenCalled(); + expect(dialog.ownerDocument.body.style.overflow).toBe('hidden'); + + cleanup?.(); + expect(dialog.ownerDocument.body.style.overflow).toBe('auto'); + }); + + it('restores the opening focus target after native close settles', () => { + const scheduled: Array<() => void> = []; + const { dialog, trigger } = createDialog(false); + const lifecycle = createDialogLifecycle((callback) => scheduled.push(callback)); + + lifecycle.synchronize(dialog, true); + dialog.close(); + lifecycle.restoreFocus(); + + expect(trigger.focus).not.toHaveBeenCalled(); + expect(scheduled).toHaveLength(1); + scheduled[0]?.(); + expect(trigger.focus).toHaveBeenCalledTimes(1); + }); + + it('uses the opening click target when WebKit leaves activeElement on body', () => { + const scheduled: Array<() => void> = []; + const { body, dialog, ownerDocument, trigger } = createDialog(false); + const lifecycle = createDialogLifecycle((callback) => scheduled.push(callback)); + let clickListener: ((event: MouseEvent) => void) | undefined; + ownerDocument.addEventListener.mockImplementation((_type, listener) => { + clickListener = listener; + }); + + const stop = lifecycle.listenForInteractionTarget(ownerDocument); + clickListener?.({ target: body } as unknown as MouseEvent); + ownerDocument.activeElement = body; + clickListener?.({ target: trigger } as unknown as MouseEvent); + lifecycle.synchronize(dialog, true); + dialog.close(); + lifecycle.restoreFocus(); + scheduled.forEach((callback) => callback()); + + expect(trigger.focus).toHaveBeenCalledTimes(1); + stop(); + expect(ownerDocument.removeEventListener).toHaveBeenCalledWith('click', clickListener, true); + }); + + it('does not reuse a stale click target for a later programmatic open', () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const scheduled: Array<() => void> = []; + const { body, dialog, ownerDocument, trigger } = createDialog(false); + const lifecycle = createDialogLifecycle((callback) => scheduled.push(callback)); + let clickListener: ((event: MouseEvent) => void) | undefined; + ownerDocument.addEventListener.mockImplementation((_type, listener) => { + clickListener = listener; + }); + + lifecycle.listenForInteractionTarget(ownerDocument); + clickListener?.({ target: trigger } as unknown as MouseEvent); + vi.setSystemTime(2_001); + ownerDocument.activeElement = body; + lifecycle.synchronize(dialog, true); + dialog.close(); + lifecycle.restoreFocus(); + + expect(scheduled).toHaveLength(0); + }); + + it('falls back to the current click when activeElement is not focusable', () => { + const scheduled: Array<() => void> = []; + const { dialog, ownerDocument, trigger } = createDialog(false); + const lifecycle = createDialogLifecycle((callback) => scheduled.push(callback)); + let clickListener: ((event: MouseEvent) => void) | undefined; + ownerDocument.addEventListener.mockImplementation((_type, listener) => { + clickListener = listener; + }); + + lifecycle.listenForInteractionTarget(ownerDocument); + clickListener?.({ target: trigger } as unknown as MouseEvent); + ownerDocument.activeElement = {}; + lifecycle.synchronize(dialog, true); + dialog.close(); + lifecycle.restoreFocus(); + scheduled.forEach((callback) => callback()); + + expect(trigger.focus).toHaveBeenCalledTimes(1); + }); + + it.each([null, 'body', {}, { focus: 'not-a-function' }])( + 'ignores a non-focusable active element %#', + (activeElement) => { + const scheduled: Array<() => void> = []; + const { dialog, ownerDocument } = createDialog(false); + const lifecycle = createDialogLifecycle((callback) => scheduled.push(callback)); + ownerDocument.activeElement = activeElement; + + lifecycle.synchronize(dialog, true); + dialog.close(); + lifecycle.restoreFocus(); + + expect(scheduled).toHaveLength(0); + } + ); + + it('uses a microtask by default so native close can settle before restoration', async () => { + const { dialog, trigger } = createDialog(false); + const lifecycle = createDialogLifecycle(); + + lifecycle.synchronize(dialog, true); + dialog.close(); + lifecycle.restoreFocus(); + + expect(trigger.focus).not.toHaveBeenCalled(); + await new Promise((resolve) => queueMicrotask(resolve)); + expect(trigger.focus).toHaveBeenCalledTimes(1); + }); + + it('treats missing or non-focusable opening targets as a restoration no-op', () => { + const scheduled: Array<() => void> = []; + const lifecycle = createDialogLifecycle((callback) => scheduled.push(callback)); + lifecycle.restoreFocus(); + + const { body, dialog, ownerDocument } = createDialog(false); + ownerDocument.activeElement = body; + lifecycle.synchronize(dialog, true); + dialog.close(); + lifecycle.restoreFocus(); + + expect(scheduled).toHaveLength(0); + }); + + it('does not override focus explicitly moved by a close callback', () => { + const scheduled: Array<() => void> = []; + const { dialog, ownerDocument, trigger } = createDialog(false); + const consumerTarget = { focus: vi.fn() }; + const lifecycle = createDialogLifecycle((callback) => scheduled.push(callback)); + + lifecycle.synchronize(dialog, true); + dialog.close(); + ownerDocument.activeElement = consumerTarget; + lifecycle.restoreFocus(); + scheduled[0]?.(); + + expect(trigger.focus).not.toHaveBeenCalled(); + }); + + it('restores when WebKit leaves focus inside the closed dialog', () => { + const scheduled: Array<() => void> = []; + const { dialog, ownerDocument, trigger } = createDialog(false); + const dialogControl = {} as Node; + const lifecycle = createDialogLifecycle((callback) => scheduled.push(callback)); + + lifecycle.synchronize(dialog, true); + dialog.close(); + ownerDocument.activeElement = dialogControl; + dialog.contains.mockImplementation((target) => target === dialogControl); + lifecycle.restoreFocus(); + scheduled[0]?.(); + + expect(trigger.focus).toHaveBeenCalledTimes(1); + }); + + it('skips a trigger removed before or during deferred restoration', () => { + const scheduled: Array<() => void> = []; + const first = createDialog(false); + const lifecycle = createDialogLifecycle((callback) => scheduled.push(callback)); + + lifecycle.synchronize(first.dialog, true); + first.trigger.isConnected = false; + lifecycle.restoreFocus(); + expect(scheduled).toHaveLength(0); + + const second = createDialog(false); + lifecycle.synchronize(second.dialog, true); + lifecycle.restoreFocus(); + second.trigger.isConnected = false; + scheduled[0]?.(); + expect(second.trigger.focus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/floating-position.test.ts b/tests/unit/floating-position.test.ts new file mode 100644 index 0000000..b71577a --- /dev/null +++ b/tests/unit/floating-position.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + autoUpdateFloatingPosition, + computeFloatingPosition, + positionFloatingElement, + readCssPixel +} from '../../packages/webaseui-svelte/src/lib/internal/floating-position.js'; + +const viewport = { x: 0, y: 0, width: 320, height: 240 }; + +describe('computeFloatingPosition', () => { + it('uses stable defaults when no placement options are provided', () => { + expect(computeFloatingPosition( + { top: 10, right: 60, bottom: 30, left: 10, width: 50, height: 20 }, + { width: 40, height: 30 }, + viewport + )).toEqual({ + x: 10, + y: 30, + width: undefined, + maxWidth: 320, + maxHeight: 210, + side: 'bottom', + align: 'start' + }); + }); + + it('places a start-aligned anchor-width overlay on its preferred side', () => { + expect(computeFloatingPosition( + { top: 40, right: 140, bottom: 80, left: 40, width: 100, height: 40 }, + { width: 60, height: 80 }, + viewport, + { offset: 8, viewportPadding: 10, matchAnchorWidth: true, maxHeight: 120 } + )).toEqual({ + x: 40, + y: 88, + width: 100, + maxWidth: 300, + maxHeight: 120, + side: 'bottom', + align: 'start' + }); + }); + + it('flips to the roomier side and shifts centered content inside the viewport', () => { + const result = computeFloatingPosition( + { top: 190, right: 316, bottom: 230, left: 276, width: 40, height: 40 }, + { width: 180, height: 100 }, + viewport, + { side: 'bottom', align: 'center', offset: 6, viewportPadding: 8, maxWidth: 200 } + ); + + expect(result).toMatchObject({ x: 132, y: 84, side: 'top', align: 'center', maxHeight: 176 }); + expect(result.width).toBeUndefined(); + }); + + it('supports end alignment and constrains height when neither side fully fits', () => { + const result = computeFloatingPosition( + { top: 96, right: 180, bottom: 152, left: 120, width: 60, height: 56 }, + { width: 90, height: 200 }, + viewport, + { side: 'top', align: 'end', offset: 4, viewportPadding: 8, maxHeight: 180 } + ); + + expect(result).toMatchObject({ x: 90, y: 8, side: 'top', maxHeight: 84, maxWidth: 304 }); + }); + + it('treats start and end as logical alignments in right-to-left layouts', () => { + const anchor = { top: 40, right: 180, bottom: 80, left: 100, width: 80, height: 40 }; + const floating = { width: 50, height: 40 }; + const start = computeFloatingPosition(anchor, floating, viewport, { align: 'start', direction: 'rtl' }); + const end = computeFloatingPosition(anchor, floating, viewport, { align: 'end', direction: 'rtl' }); + + expect(start.x).toBe(130); + expect(end.x).toBe(100); + }); +}); + +function createPositionFixture() { + const style = { width: '', maxWidth: '', maxHeight: '', left: '', top: '' }; + const visualViewport = { offsetLeft: 12, offsetTop: 18, width: 280, height: 180 }; + const view = { + visualViewport, + innerWidth: 320, + innerHeight: 240, + getComputedStyle: vi.fn(() => ({ getPropertyValue: () => '11.5px' })) + }; + const ownerDocument = { + defaultView: view, + documentElement: { clientWidth: 320, clientHeight: 240 } + }; + const anchor = { + ownerDocument, + getBoundingClientRect: () => ({ top: 130, right: 282, bottom: 160, left: 202, width: 80, height: 30 }) + }; + const floating = { + ownerDocument, + style, + dataset: {} as Record, + clientHeight: 60, + scrollHeight: 100, + getBoundingClientRect: () => ({ top: 0, right: 0, bottom: 0, left: 0, width: 80, height: 62 }) + }; + return { anchor, floating, style }; +} + +describe('positionFloatingElement', () => { + it('uses visual viewport offsets, intrinsic scroll height, and applies styles', () => { + const { anchor, floating, style } = createPositionFixture(); + const result = positionFloatingElement( + anchor as unknown as HTMLElement, + floating as unknown as HTMLElement, + { side: 'bottom', align: 'end', offset: 4, viewportPadding: 8, matchAnchorWidth: true, maxHeight: 120 } + ); + + expect(result.side).toBe('top'); + expect(result.x).toBe(202); + expect(style).toMatchObject({ left: '202px', top: '26px', width: '80px', maxHeight: '100px' }); + expect(floating.dataset).toEqual({ side: 'top', align: 'end' }); + }); + + it('reads resolved CSS pixels and falls back for non-numeric values', () => { + const { anchor } = createPositionFixture(); + expect(readCssPixel(anchor as unknown as HTMLElement, '--offset')).toBe(11.5); + + anchor.ownerDocument.defaultView = { + ...anchor.ownerDocument.defaultView, + getComputedStyle: vi.fn(() => ({ getPropertyValue: () => 'auto' })) + }; + expect(readCssPixel(anchor as unknown as HTMLElement, '--offset', 7)).toBe(7); + }); + + it('uses layout viewport and document fallbacks without forcing a width', () => { + const style = { width: 'old', maxWidth: '', maxHeight: 'old', left: '', top: '' }; + const documentElement = { clientWidth: 240, clientHeight: 180 }; + const ownerDocument = { + defaultView: null as null | { innerWidth: number; innerHeight: number }, + documentElement + }; + const anchor = { + ownerDocument, + getBoundingClientRect: () => ({ top: 20, right: 80, bottom: 50, left: 20, width: 60, height: 30 }) + }; + const floating = { + ownerDocument, + style, + dataset: {} as Record, + clientHeight: 42, + scrollHeight: 40, + getBoundingClientRect: () => ({ top: 0, right: 0, bottom: 0, left: 0, width: 100, height: 40 }) + }; + + const first = positionFloatingElement(anchor as unknown as HTMLElement, floating as unknown as HTMLElement); + expect(first).toMatchObject({ x: 20, y: 50, maxWidth: 240, maxHeight: 130 }); + expect(style.width).toBe(''); + expect(readCssPixel(anchor as unknown as HTMLElement, '--missing', 3)).toBe(3); + + ownerDocument.defaultView = { innerWidth: 260, innerHeight: 200 }; + const second = positionFloatingElement(anchor as unknown as HTMLElement, floating as unknown as HTMLElement, { + side: 'top', + maxWidth: 90 + }); + expect(second).toMatchObject({ side: 'bottom', maxWidth: 90 }); + }); +}); + +describe('autoUpdateFloatingPosition', () => { + it('observes scroll, resize, visual viewport, and both elements with batched updates', () => { + const listeners = new Map void>(); + const visualListeners = new Map void>(); + const frames = new Map void>(); + let frameId = 0; + const observe = vi.fn(); + const disconnect = vi.fn(); + class ResizeObserverMock { + constructor(_callback: ResizeObserverCallback) {} + observe = observe; + disconnect = disconnect; + } + const view = { + addEventListener: vi.fn((type: string, listener: () => void) => listeners.set(type, listener)), + removeEventListener: vi.fn(), + requestAnimationFrame: vi.fn((callback: () => void) => { + frameId += 1; + frames.set(frameId, callback); + return frameId; + }), + cancelAnimationFrame: vi.fn((id: number) => frames.delete(id)), + visualViewport: { + addEventListener: vi.fn((type: string, listener: () => void) => visualListeners.set(type, listener)), + removeEventListener: vi.fn() + }, + ResizeObserver: ResizeObserverMock + }; + const anchor = { ownerDocument: { defaultView: view } } as unknown as HTMLElement; + const floating = {} as HTMLElement; + const update = vi.fn(); + + const stop = autoUpdateFloatingPosition(anchor, floating, update); + expect(update).toHaveBeenCalledTimes(1); + expect(observe).toHaveBeenCalledTimes(2); + + listeners.get('scroll')?.(); + visualListeners.get('resize')?.(); + expect(view.requestAnimationFrame).toHaveBeenCalledTimes(1); + frames.get(1)?.(); + expect(update).toHaveBeenCalledTimes(2); + + listeners.get('resize')?.(); + const stoppedFrame = frames.get(2); + stop(); + stop(); + expect(view.cancelAnimationFrame).toHaveBeenCalledWith(2); + expect(disconnect).toHaveBeenCalledTimes(1); + stoppedFrame?.(); + expect(update).toHaveBeenCalledTimes(2); + }); + + it('updates synchronously without animation frames and works without a window', () => { + const update = vi.fn(); + const view = { + addEventListener: vi.fn((_type: string, listener: () => void) => listener()), + removeEventListener: vi.fn() + }; + const anchor = { ownerDocument: { defaultView: view } } as unknown as HTMLElement; + const stop = autoUpdateFloatingPosition(anchor, {} as HTMLElement, update); + expect(update.mock.calls.length).toBeGreaterThan(1); + stop(); + + const windowlessUpdate = vi.fn(); + const stopWindowless = autoUpdateFloatingPosition( + { ownerDocument: { defaultView: null } } as unknown as HTMLElement, + {} as HTMLElement, + windowlessUpdate + ); + expect(windowlessUpdate).toHaveBeenCalledTimes(1); + stopWindowless(); + }); +}); diff --git a/tests/unit/npm-pack.test.ts b/tests/unit/npm-pack.test.ts new file mode 100644 index 0000000..bbe312f --- /dev/null +++ b/tests/unit/npm-pack.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { parseNpmPackOutput } from '../../scripts/lib/npm-pack.mjs'; + +const pack = { + name: '@webaseui/svelte', + size: 10, + unpackedSize: 20, + files: [{ path: 'dist/index.js' }] +}; + +describe('parseNpmPackOutput', () => { + it('accepts the legacy array format', () => { + expect(parseNpmPackOutput(JSON.stringify([pack]), pack.name)).toEqual(pack); + }); + + it('accepts the workspace-keyed object format', () => { + expect(parseNpmPackOutput(JSON.stringify({ [pack.name]: pack }), pack.name)).toEqual(pack); + }); + + it('rejects invalid JSON and empty payloads with workspace context', () => { + expect(() => parseNpmPackOutput('{', pack.name)).toThrow(pack.name); + expect(() => parseNpmPackOutput('{}', pack.name)).toThrow(pack.name); + }); +}); diff --git a/tests/unit/overlay.test.ts b/tests/unit/overlay.test.ts new file mode 100644 index 0000000..03f9078 --- /dev/null +++ b/tests/unit/overlay.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import { acquireBodyScrollLock, listenForPointerOutside } from '../../packages/webaseui-svelte/src/lib/internal/overlay.js'; + +describe('acquireBodyScrollLock', () => { + it('restores the original overflow value after the last nested release', () => { + const targetDocument = { body: { style: { overflow: 'clip' } } }; + const releaseFirst = acquireBodyScrollLock(targetDocument); + const releaseSecond = acquireBodyScrollLock(targetDocument); + + expect(targetDocument.body.style.overflow).toBe('hidden'); + releaseFirst(); + expect(targetDocument.body.style.overflow).toBe('hidden'); + releaseSecond(); + expect(targetDocument.body.style.overflow).toBe('clip'); + }); + + it('makes each release callback idempotent', () => { + const targetDocument = { body: { style: { overflow: '' } } }; + const release = acquireBodyScrollLock(targetDocument); + + release(); + release(); + expect(targetDocument.body.style.overflow).toBe(''); + }); +}); + +describe('listenForPointerOutside', () => { + it('ignores contained targets, reports outside targets, and unregisters', () => { + const inside = {} as Node; + const outside = {} as Node; + let listener: ((event: PointerEvent) => void) | undefined; + const ownerDocument = { + addEventListener: vi.fn((_type: 'pointerdown', nextListener: (event: PointerEvent) => void) => { + listener = nextListener; + }), + removeEventListener: vi.fn() + }; + const root = { + ownerDocument, + contains: (target: Node | null) => target === inside + }; + const onOutside = vi.fn(); + + const stop = listenForPointerOutside(root, onOutside); + listener?.({ target: inside } as unknown as PointerEvent); + listener?.({ target: outside } as unknown as PointerEvent); + + expect(onOutside).toHaveBeenCalledTimes(1); + stop(); + expect(ownerDocument.removeEventListener).toHaveBeenCalledWith('pointerdown', listener); + }); +}); diff --git a/tests/unit/popover.test.ts b/tests/unit/popover.test.ts new file mode 100644 index 0000000..321828a --- /dev/null +++ b/tests/unit/popover.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { synchronizeManualPopover } from '../../packages/webaseui-svelte/src/lib/internal/popover.js'; + +function createPopover(native = true) { + let open = false; + const element = { + hidden: true, + matches: vi.fn(() => open), + removeAttribute: vi.fn(), + showPopover: native ? vi.fn(() => { open = true; }) : undefined, + hidePopover: native ? vi.fn(() => { open = false; }) : undefined + }; + return element; +} + +describe('synchronizeManualPopover', () => { + it('uses hidden-state fallback and removes unsupported popover semantics', () => { + const element = createPopover(false); + + expect(synchronizeManualPopover(element as unknown as HTMLElement, true)).toBe(false); + expect(element.hidden).toBe(false); + expect(element.removeAttribute).toHaveBeenCalledWith('popover'); + + synchronizeManualPopover(element as unknown as HTMLElement, false); + expect(element.hidden).toBe(true); + }); + + it('opens and closes a native manual popover idempotently', () => { + const element = createPopover(); + + expect(synchronizeManualPopover(element as unknown as HTMLElement, true)).toBe(true); + synchronizeManualPopover(element as unknown as HTMLElement, true); + expect(element.showPopover).toHaveBeenCalledTimes(1); + expect(element.hidden).toBe(false); + + synchronizeManualPopover(element as unknown as HTMLElement, false); + synchronizeManualPopover(element as unknown as HTMLElement, false); + expect(element.hidePopover).toHaveBeenCalledTimes(1); + expect(element.hidden).toBe(true); + }); + + it('tolerates selector and native method races during teardown', () => { + const element = createPopover(); + element.matches.mockImplementation(() => { throw new Error('detached'); }); + element.showPopover?.mockImplementation(() => { throw new Error('detached'); }); + + expect(() => synchronizeManualPopover(element as unknown as HTMLElement, true)).not.toThrow(); + expect(() => synchronizeManualPopover(element as unknown as HTMLElement, false)).not.toThrow(); + expect(element.hidden).toBe(true); + }); +}); diff --git a/tests/unit/reference-utils.test.ts b/tests/unit/reference-utils.test.ts new file mode 100644 index 0000000..eea5262 --- /dev/null +++ b/tests/unit/reference-utils.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { inlineParts, parseFields } from '../../apps/docs/src/lib/reference-utils.js'; + +describe('parseFields', () => { + it('parses named fields and joins indented continuation lines', () => { + expect(parseFields(` +- Props: \`label\`, \`disabled\` + and native attributes +- Boundary: keeps the root stable +`)).toEqual([ + { label: 'Props', value: '`label`, `disabled` and native attributes' }, + { label: 'Boundary', value: 'keeps the root stable' } + ]); + }); + + it('preserves colons inside the value and labels unlabeled bullets as Contract', () => { + expect(parseFields(` +- Events: callback: event +- Keyboard behavior remains documented +`)).toEqual([ + { label: 'Events', value: 'callback: event' }, + { label: 'Contract', value: 'Keyboard behavior remains documented' } + ]); + }); + + it('ignores prose outside the bullet contract', () => { + expect(parseFields('Introduction only.')).toEqual([]); + }); +}); + +describe('inlineParts', () => { + it('marks alternating backtick segments as inline code', () => { + expect(inlineParts('Use `open` with `onclose`.')).toEqual([ + { text: 'Use ', code: false }, + { text: 'open', code: true }, + { text: ' with ', code: false }, + { text: 'onclose', code: true }, + { text: '.', code: false } + ]); + }); + + it('omits empty segments without changing code parity', () => { + expect(inlineParts('`value`')).toEqual([{ text: 'value', code: true }]); + }); +}); diff --git a/tests/unit/roving-focus.test.ts b/tests/unit/roving-focus.test.ts new file mode 100644 index 0000000..4db1b75 --- /dev/null +++ b/tests/unit/roving-focus.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { findRovingFocusIndex } from '../../packages/webaseui-svelte/src/lib/internal/roving-focus.js'; + +describe('findRovingFocusIndex', () => { + const items = ['First', 'Second', 'Third']; + + it('moves and wraps horizontal collections without consuming vertical arrows', () => { + expect(findRovingFocusIndex(items, 0, 'ArrowRight', 'horizontal')).toBe(1); + expect(findRovingFocusIndex(items, 0, 'ArrowLeft', 'horizontal')).toBe(2); + expect(findRovingFocusIndex(items, 0, 'ArrowDown', 'horizontal')).toBeUndefined(); + }); + + it('reverses horizontal arrow meaning for right-to-left collections', () => { + expect(findRovingFocusIndex(items, 0, 'ArrowLeft', 'horizontal', undefined, 'rtl')).toBe(1); + expect(findRovingFocusIndex(items, 0, 'ArrowRight', 'horizontal', undefined, 'rtl')).toBe(2); + }); + + it('moves and wraps vertical collections without consuming horizontal arrows', () => { + expect(findRovingFocusIndex(items, 2, 'ArrowDown', 'vertical')).toBe(0); + expect(findRovingFocusIndex(items, 0, 'ArrowUp', 'vertical')).toBe(2); + expect(findRovingFocusIndex(items, 0, 'ArrowRight', 'vertical')).toBeUndefined(); + }); + + it('moves Home and End to the collection boundaries in either orientation', () => { + expect(findRovingFocusIndex(items, 1, 'Home', 'horizontal')).toBe(0); + expect(findRovingFocusIndex(items, 1, 'End', 'vertical')).toBe(2); + }); + + it('skips disabled items while moving, wrapping, and resolving boundaries', () => { + const choices = [ + { label: 'First', disabled: true }, + { label: 'Second' }, + { label: 'Third', disabled: true } + ]; + const isDisabled = (item: (typeof choices)[number]) => item.disabled === true; + + expect(findRovingFocusIndex(choices, 1, 'ArrowRight', 'horizontal', isDisabled)).toBe(1); + expect(findRovingFocusIndex(choices, 2, 'Home', 'horizontal', isDisabled)).toBe(1); + expect(findRovingFocusIndex(choices, 0, 'End', 'vertical', isDisabled)).toBe(1); + }); + + it('returns no target for empty or fully disabled collections', () => { + expect(findRovingFocusIndex([], 0, 'Home', 'horizontal')).toBe(-1); + expect(findRovingFocusIndex([{ disabled: true }], 0, 'ArrowDown', 'vertical', (item) => item.disabled)).toBe(-1); + }); + + it('uses the latest collection after items are added or removed', () => { + const dynamicItems = ['First', 'Second']; + expect(findRovingFocusIndex(dynamicItems, 1, 'ArrowDown', 'vertical')).toBe(0); + + dynamicItems.splice(1, 1); + expect(findRovingFocusIndex(dynamicItems, 1, 'End', 'vertical')).toBe(0); + + dynamicItems.push('Third'); + expect(findRovingFocusIndex(dynamicItems, 0, 'End', 'vertical')).toBe(1); + }); +}); diff --git a/tests/unit/select-state.test.ts b/tests/unit/select-state.test.ts new file mode 100644 index 0000000..626192c --- /dev/null +++ b/tests/unit/select-state.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { findSelectedOptionIndex } from '../../packages/webaseui-svelte/src/lib/internal/select-state.js'; +import type { WeBaseSelectOption } from '../../packages/webaseui-svelte/src/lib/types.js'; + +const options: WeBaseSelectOption[] = [ + { label: 'Alpha', value: 'alpha', disabled: true }, + { label: 'Beta', value: 'beta' }, + { label: 'Gamma', value: 'gamma' } +]; + +describe('findSelectedOptionIndex', () => { + it('keeps an enabled controlled value and falls back from missing or disabled values', () => { + expect(findSelectedOptionIndex(options, 'gamma')).toBe(2); + expect(findSelectedOptionIndex(options, 'alpha')).toBe(1); + expect(findSelectedOptionIndex(options, 'missing')).toBe(1); + }); + + it('returns -1 for empty and fully disabled option sets', () => { + expect(findSelectedOptionIndex([], 'missing')).toBe(-1); + expect(findSelectedOptionIndex([{ label: 'Locked', value: 'locked', disabled: true }], 'locked')).toBe(-1); + }); + + it('re-resolves the controlled value after options are added or removed', () => { + const dynamicOptions = options.slice(); + expect(findSelectedOptionIndex(dynamicOptions, 'gamma')).toBe(2); + + dynamicOptions.splice(2, 1); + expect(findSelectedOptionIndex(dynamicOptions, 'gamma')).toBe(1); + + dynamicOptions.unshift({ label: 'Available', value: 'available' }); + expect(findSelectedOptionIndex(dynamicOptions, 'available')).toBe(0); + }); +}); diff --git a/tests/unit/timing.test.ts b/tests/unit/timing.test.ts new file mode 100644 index 0000000..bfecae7 --- /dev/null +++ b/tests/unit/timing.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { scheduleAutoDismiss } from '../../packages/webaseui-svelte/src/lib/internal/timing.js'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('scheduleAutoDismiss', () => { + it('dismisses once after the configured duration', () => { + vi.useFakeTimers(); + const onDismiss = vi.fn(); + const cleanup = scheduleAutoDismiss( + { open: true, paused: false, duration: 500 }, + onDismiss, + { + setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay), + clearTimeout: (handle) => globalThis.clearTimeout(handle) + } + ); + + vi.advanceTimersByTime(499); + expect(onDismiss).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it.each([ + { open: false, paused: false, duration: 500 }, + { open: true, paused: true, duration: 500 }, + { open: true, paused: false, duration: 0 }, + { open: true, paused: false, duration: -1 } + ])('does not schedule inactive state $open/$paused/$duration', (state) => { + const scheduler = { + setTimeout: vi.fn(() => Symbol('timer')), + clearTimeout: vi.fn() + }; + + const cleanup = scheduleAutoDismiss(state, vi.fn(), scheduler); + + expect(scheduler.setTimeout).not.toHaveBeenCalled(); + cleanup(); + expect(scheduler.clearTimeout).not.toHaveBeenCalled(); + }); + + it('cancels a pending cycle exactly once so pause and close transitions are safe', () => { + vi.useFakeTimers(); + const onDismiss = vi.fn(); + const clearTimeout = vi.fn((handle: ReturnType) => globalThis.clearTimeout(handle)); + const cleanup = scheduleAutoDismiss( + { open: true, paused: false, duration: 500 }, + onDismiss, + { + setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay), + clearTimeout + } + ); + + cleanup(); + cleanup(); + vi.advanceTimersByTime(500); + + expect(clearTimeout).toHaveBeenCalledTimes(1); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('starts a full fresh cycle when a paused notification resumes', () => { + vi.useFakeTimers(); + const onDismiss = vi.fn(); + const scheduler = { + setTimeout: (callback: () => void, delay: number) => globalThis.setTimeout(callback, delay), + clearTimeout: (handle: ReturnType) => globalThis.clearTimeout(handle) + }; + + const cancelBeforePause = scheduleAutoDismiss( + { open: true, paused: false, duration: 500 }, + onDismiss, + scheduler + ); + vi.advanceTimersByTime(300); + cancelBeforePause(); + scheduleAutoDismiss({ open: true, paused: true, duration: 500 }, onDismiss, scheduler); + vi.advanceTimersByTime(500); + expect(onDismiss).not.toHaveBeenCalled(); + + scheduleAutoDismiss({ open: true, paused: false, duration: 500 }, onDismiss, scheduler); + vi.advanceTimersByTime(499); + expect(onDismiss).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/typeahead.test.ts b/tests/unit/typeahead.test.ts new file mode 100644 index 0000000..96a556f --- /dev/null +++ b/tests/unit/typeahead.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createTypeaheadController, + findTypeaheadMatchIndex +} from '../../packages/webaseui-svelte/src/lib/internal/typeahead.js'; + +afterEach(() => { + vi.useRealTimers(); +}); + +const options = [ + { label: 'Alpha', disabled: true }, + { label: 'Beta' }, + { label: 'Bravo' }, + { label: 'Release' } +]; +const itemText = (item: (typeof options)[number]) => item.label; +const isDisabled = (item: (typeof options)[number]) => item.disabled === true; + +function createController(delay = 500) { + return createTypeaheadController( + { + setTimeout: (callback, timeout) => globalThis.setTimeout(callback, timeout), + clearTimeout: (handle) => globalThis.clearTimeout(handle) + }, + delay + ); +} + +describe('findTypeaheadMatchIndex', () => { + it('matches case-insensitively from a cyclic start while skipping disabled items', () => { + expect(findTypeaheadMatchIndex(options, 'BR', 1, itemText, isDisabled)).toBe(2); + expect(findTypeaheadMatchIndex(options, 'be', 2, itemText, isDisabled)).toBe(1); + expect(findTypeaheadMatchIndex(options, 'al', 0, itemText, isDisabled)).toBe(-1); + }); + + it('returns no match for empty input, unmatched input, or an empty collection', () => { + expect(findTypeaheadMatchIndex(options, '', 0, itemText)).toBe(-1); + expect(findTypeaheadMatchIndex(options, 'missing', 0, itemText)).toBe(-1); + expect(findTypeaheadMatchIndex([], 'a', 0, () => '')).toBe(-1); + }); + + it('uses the latest dynamic collection for every lookup', () => { + const dynamicOptions = options.slice(); + expect(findTypeaheadMatchIndex(dynamicOptions, 'br', 0, itemText)).toBe(2); + + dynamicOptions.splice(2, 1); + expect(findTypeaheadMatchIndex(dynamicOptions, 'br', 0, itemText)).toBe(-1); + + dynamicOptions.push({ label: 'Broadcast' }); + expect(findTypeaheadMatchIndex(dynamicOptions, 'br', 0, itemText)).toBe(3); + }); +}); + +describe('createTypeaheadController', () => { + it('builds a multi-character query and refines from the current match', () => { + vi.useFakeTimers(); + const controller = createController(); + + expect(controller.search(options, 0, 'b', itemText, isDisabled)).toBe(1); + expect(controller.search(options, 1, 'r', itemText, isDisabled)).toBe(2); + }); + + it('cycles repeated characters through matching enabled items', () => { + vi.useFakeTimers(); + const controller = createController(); + + expect(controller.search(options, 0, 'b', itemText, isDisabled)).toBe(1); + expect(controller.search(options, 1, 'B', itemText, isDisabled)).toBe(2); + expect(controller.search(options, 2, 'b', itemText, isDisabled)).toBe(1); + }); + + it('starts a new query after the reset delay expires', () => { + vi.useFakeTimers(); + const controller = createController(500); + + expect(controller.search(options, 0, 'b', itemText, isDisabled)).toBe(1); + vi.advanceTimersByTime(500); + expect(controller.search(options, 1, 'r', itemText, isDisabled)).toBe(3); + }); + + it('ignores non-printable and whitespace keys without scheduling a reset', () => { + const scheduler = { + setTimeout: vi.fn(() => Symbol('timer')), + clearTimeout: vi.fn() + }; + const controller = createTypeaheadController(scheduler); + + expect(controller.search(options, 0, 'ArrowDown', itemText)).toBeUndefined(); + expect(controller.search(options, 0, ' ', itemText)).toBeUndefined(); + expect(scheduler.setTimeout).not.toHaveBeenCalled(); + }); + + it('cancels a pending reset exactly once when disposed', () => { + const scheduler = { + setTimeout: vi.fn(() => Symbol('timer')), + clearTimeout: vi.fn() + }; + const controller = createTypeaheadController(scheduler); + + controller.search(options, 0, 'b', itemText); + controller.reset(); + controller.reset(); + + expect(scheduler.clearTimeout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..58dfaff --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + include: [ + 'packages/webaseui-svelte/src/lib/internal/**/*.ts', + 'packages/webaseui-svelte/src/lib/utils/**/*.ts' + ], + reporter: ['text', 'json-summary'], + reportsDirectory: 'coverage', + thresholds: { + perFile: true, + branches: 90, + functions: 90, + lines: 90, + statements: 90 + } + }, + environment: 'node', + include: ['tests/unit/**/*.test.ts'], + passWithNoTests: false + } +});