diff --git a/.changeset/clear-buttons-focus.md b/.changeset/clear-buttons-focus.md new file mode 100644 index 00000000..574f27bb --- /dev/null +++ b/.changeset/clear-buttons-focus.md @@ -0,0 +1,5 @@ +--- +'@sanring/cli': patch +--- + +Improve the installed Button component's destructive variant contrast for WCAG AA readability. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6445bee8..54f7b13b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,3 +119,39 @@ jobs: # 不會被上面那個指令覆蓋到,要另外查。 - name: Type check @sanring/cli schematics run: pnpm --filter @sanring/cli exec tsc -p tsconfig.schematics.json --noEmit + + docs-e2e: + name: Docs E2E + visual regression + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run docs E2E + run: pnpm test:e2e:docs + + - name: Upload Playwright diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore + retention-days: 7 diff --git a/DEVLOG.md b/DEVLOG.md index 37666696..52c2d3f3 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -661,6 +661,20 @@ P9 golden fixture 掃完 53 元件後發現一批長期存在的 registry 宣告 **範圍限制**:Component Docs 全面掃描效率與工程證據尚未完成,所以本輪只用 `/components/button` 作為 representative component page;其餘 component docs 完成後,應用同一套驗證重新覆蓋。 +## P29 Phase 4 — Component Docs 掃描效率與工程證據收斂(2026-08-22) + +**已完成**:本輪將先前已建立的 metadata/evidence 資料層收斂成一套明確的 component reference surface,改動全部落在共用 `component-page-*` primitives,因此一次覆蓋 52 個 component pages: + +- Header 把 install command 與 package path 提升成主要 manifest panel;registry name、shipped status、shared deps、SSR/browser boundary、a11y、keyboard、state model 與 latest version 收斂成第二層 evidence chips,並新增 Installation/API/Recent changes 快速 anchor。Radio 因 API 拆成 group/item 兩張表,專用 anchor 指到 `#api-group`。 +- Previewer 採 `01 Preview` / `02 Source` 兩區清楚標頭與 rendered/copy-ready 狀態,並把預覽高度壓到 desktop 320px、mobile 280px。沒有重新引入 Preview/Code tabs:先前使用者已要求撤回會隱藏 code 的 tabs,所以這次保留 demo/source 同時可見,用層級而非隱藏來提升掃描效率。 +- API reference 新增 row count/reference header、編號、type code pill 與更緊湊的 desktop columns;mobile 維持 cards,但將 type/default 改成雙欄、description 獨立收尾,長型別與預設值仍可斷行。 +- Recent changes 從一般頁尾面板改成最多 3 筆的 compact release strip,保留 version/date/type/breaking/text 與完整 changelog 入口。 +- 安裝指令在手機版改用 surface 內橫向捲動,避免 CLI token 在單字中間斷行;頁面本身仍維持零水平 overflow。 + +**視覺觀察**:Button 作為簡單基準時,manifest 與 Preview/Source 層級可在第一屏辨識;Dialog 的 keyboard/a11y/state evidence 可正常換行;Table 的寬 demo 與高密度 API 沒有撐破 content rail。Light/dark 的 command surface 均保持深色 code 語彙,mint 只用在 status/index/active signal。Mobile header 會把 command/path 疊成單欄,evidence chips 與 jump links 自然換行;API cards 在 360px/390px 仍可掃描。 + +**驗證**:`pnpm exec tsc --noEmit`、scoped ESLint、`git diff --check` 通過;Angular development bundle 冷啟動成功。Playwright 更新 component-page assertions 來覆蓋 install command、Installation/API anchors、Preview/Source 同時可見、API reference surface、recent changes 與 Radio 的 `#api-group`;Phase 4 視覺矩陣擴充為 Button/Dialog/Table 的 desktop light、desktop dark、360px、390px,並對 header/previewer/API/release strip 另拍局部截圖。Desktop Chromium **11 passed**、mobile Chromium **11 passed**。 + --- ## P30 — `checkbox` Enter 鍵「無法切換」查證後確認不是缺口 @@ -901,3 +915,29 @@ P30 先前已收完 15 個必修缺口;這輪把剩下 15 組建議項目逐 **額外的流程修正(跟這兩個 CI bug 無關,是同一輪順手發現並修正的操作失誤)**:原本手動跑了 `pnpm changeset version` 把兩份 changeset 直接消耗掉、產生 0.24.0 並連同版號改動一起開 PR,結果讓 `.github/workflows/require-changeset.yml` 判定「這個 PR 沒有待處理的 changeset」而失敗。查 `gh pr list` 撈出的完整歷史(#1–#26)後確認:這個 repo 27 次版本紀錄裡有 26 次都是 `changesets/action` bot 自動開的 `chore: version packages` PR(分支 `changeset-release/main`)在做真正的 bump,只有一次(0.23.2→0.23.3)是例外手動直接推到 `main`(沒經過 PR,所以沒撞到這個檢查)。修正方式是把 `packages/cli/package.json`/`CHANGELOG.md` 改回 0.23.3、把兩份 changeset 檔案原樣留著(不消耗),讓 bot 在這個 PR 合併進 `main` 後自己開版號 PR。**教訓**:`packages/cli`/`registry` 的版本發布一律讓 changeset 檔案跟著功能改動落地就好,不要手動跑 `changeset version`——那是 bot 的工作,手動搶著做只會跟 `require-changeset.yml` 打架。 **驗證**:Docker 重現(`node:24-bookworm`、`CI=true`、`GITHUB_ACTIONS=true`、`--frozen-lockfile`、`sync-registry`、`vitest run`)240/240 通過;`pnpm changeset status --since=origin/main` 通過;PR #27 的六個 GitHub Actions check(Lint、Test (@sanring/cli)、Test (@sanring/ui)、Type check、Registry Sync Check、Require Changeset)全綠。 + +--- + +## P11 — Docs component page light/dark accessibility smoke test(2026-08-22) + +**已完成**:新增 `apps/docs/e2e/component-page-accessibility.spec.ts`,以 Button component page 作為代表 reference surface,在 desktop/mobile Chromium 各跑 light/dark 兩種主題。每輪會用既有 `axe-core` 對完整 `main article` 執行 WCAG 2 A/AA 掃描,並另外明確驗證 page title、description、installation、preview source、Command/Manual tabs、兩處 copy-code control 與 header install-copy control。Keyboard assertions 會實際用 ArrowRight/ArrowLeft 切換 installation tabs,並以 Shift+Tab/Tab 回到目標 control,確認 active element、`:focus-visible` 以及 outline/box-shadow focus indicator 同時存在,不是只檢查 DOM 上有 class。 + +**測試抓到並修正的真實問題**: + +- Mobile code blocks 的 `overflow-auto` region 原本沒有 tab stop,鍵盤使用者無法進入後用方向鍵水平捲動。共享 `ComponentPageCodeBlock` 的 scroll surface 補 `tabindex="0"` 與 inset focus ring,一次修正 component docs 與 long-form pages 的同一種 code surface。 +- Light theme 的 `--docs-accent-strong` 使用 `primary-70`,在 eyebrow 與 latest-version 小字上低於 axe 的 AA 門檻;改用 `primary-80`。Installation 的非作用中 package manager 與 code line number 原本把一般頁面的 `--docs-muted` 放在固定深色 code surface 上,改用專屬 `--docs-code-muted`/`--docs-code-fg`。 +- Button `destructive` variant 原本是 `error-50` 白字,light/dark 都無法達到一般文字 4.5:1。`packages/ui` 與可安裝的 `registry` source 同步改為 `error-70` 白字、`error-80` hover、`error-60` focus ring,保留 destructive 語意但提高對比。 + +**驗證**:新增 accessibility smoke desktop/mobile × light/dark **4/4 passed**;連同既有 component-page 與 Phase 4 matrix 共 **26/26 passed**。`pnpm exec tsc --noEmit`、repo 全域 `pnpm lint`、`git diff --check` 通過;Button directive targeted unit tests **8/8 passed**;registry sync(52/52)與 registry parity(52 directories)皆通過。 + +--- + +## P11 — Docs 自動化視覺回歸 baseline + CI quality gate(2026-08-22) + +**已完成**:新增 `apps/docs/e2e/visual-regression.spec.ts` 與 6 張核准 baseline,固定以 `1440x900` desktop Chromium 覆蓋 home、Button component page、CLI overview 的 light/dark 畫面。Home/CLI 比對第一個 viewport,Button 則直接比對完整 Basic section,確保真正的 preview 與 source code 都進入 baseline。這三個 surface 分別代表品牌首頁、共用 component docs layout/preview 與 long-form docs/深色 workflow panel;mobile/breakpoint 正確性仍由既有 structural E2E matrix 負責,避免把大量高度不一的 full-page PNG 當成難維護的測試資產。 + +**穩定性設計**:`visual-chromium` 是獨立 Playwright project,不會在 desktop/mobile structural projects 重複執行;固定 viewport、locale、timezone、reduced motion,每個案例在 screenshot 前鎖定 `sanring-docs-theme`、等待 route landmark/network idle/`document.fonts.ready`,並停用 animation、transition、caret 與 smooth scroll。pixel comparison 保留 3% diff-ratio 與 0.3 color threshold,容忍 macOS/Linux Chromium 的字型 anti-aliasing 差異,但版面位移、surface/token 色彩與元件外觀變化仍會超過門檻。baseline 路徑不含 host OS,讓本機與 GitHub Actions 共用同一組 approved PNG。 + +**CI 與維護流程**:`ci.yml` 新增 `Docs E2E + visual regression` job,乾淨 checkout 會安裝 Chromium、執行完整 `pnpm test:e2e:docs`;失敗時上傳 7 天保存的 Playwright HTML report、actual/expected/diff 圖。`DOCS_VISUAL_SYSTEM.md` 已改寫 automation 邊界,並記錄視覺 subset 與 `--update-snapshots` 指令;只有人工檢查 actual/diff、確認是刻意改版後才能更新 baseline。`ROADMAP.md` 同步把 accessibility 與 visual regression 從未完成 quality infrastructure 移到 Recently shipped,TODOLIST 的 P11 現在只剩真正 CLI e2e。 + +**驗證**:Home/CLI 4 張 viewport baseline 均人工抽查完成載入且尺寸為 `1440x900`;審圖時發現 Button 第一屏只露出 preview 外框,隨即把另 2 張改成直接擷取完整 Basic section,確保實際 variants 受保護。baseline 產生後以無 `--update-snapshots` 模式重跑 visual diff **6/6 passed**;完整 `pnpm test:e2e:docs` **46/46 passed**。Playwright `--list` 確認 suite 為 structural/a11y desktop+mobile 40 個案例加 visual-only 6 個案例,沒有重複執行 screenshot spec;repo 全域 lint、`tsc --noEmit`、`git diff --check` 皆通過。 diff --git a/ROADMAP.md b/ROADMAP.md index e3c3b712..f7c4fd48 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,17 +18,13 @@ This is a snapshot, not a commitment or a timeline. Items move, get reprioritize ## Quality infrastructure (ongoing, lower urgency) -- Automated accessibility regression testing (axe-core or similar) -- Visual regression testing for CSS changes (screenshot diffing against an approved baseline) — the - docs site now has Playwright e2e smoke coverage (see Recently shipped), which is the runner this - would build on, but there's no baseline/diffing set up yet - Real end-to-end CLI tests against a freshly scaffolded Angular project ## Recently shipped -- Docs site Playwright e2e coverage — structural smoke tests (renders, no console errors, no - horizontal overflow, mobile nav, theme switching) across home, a component page, and a long-form - page; `pnpm test:e2e:docs` to run +- Docs site Playwright quality gate — structural smoke tests, axe-core accessibility coverage, and + approved visual baselines for representative home/component/CLI surfaces in both themes; CI runs + the full suite through `pnpm test:e2e:docs` - Docs site visual system pass — consistent `--docs-*` tokens, WCAG-verified color contrast in both themes, and a documented type scale/spacing contract (`apps/docs/DOCS_VISUAL_SYSTEM.md`) - `sanring build` — auto-generate a third-party registry's `registry.json` (component deps, shared deps, peer dependencies) from a source directory, instead of hand-writing it against the schema diff --git a/TODOLIST.md b/TODOLIST.md index 1f9400fd..2e452320 100644 --- a/TODOLIST.md +++ b/TODOLIST.md @@ -6,63 +6,8 @@ --- -## P29 — Docs visual refresh 三階段整理 / 翻新 / 收斂 - -目標是把 docs 站從目前偏保守、偏死板的技術文件介面,推進成更現代、精緻、可掃描的產品文件體驗。前置提交已先整理 `apps/docs` 的 visual system、docs semantic tokens、shell/page surfaces 與部分頁面樣式。**Phase 1(整理基線)、Phase 2(視覺翻新裡所有能用規則/數字/codebase 先例客觀驗證的部分)、Phase 3(重構與驗證收斂)都已收斂完成**,細節見 `DEVLOG.md`。 - -### Phase 4 — 視覺精修與 Sanring 風格差異化 - -Phase 4 已解封:Playwright 截圖 + `Read` 工具可以實際檢視 home(light/dark/mobile)、component page、long-form pages 的畫面。前一輪基準快照沒有抓到明顯 bug,所以 Phase 4 不是修壞掉的畫面,而是進入「有方向的重新設計」:讓 Sanring 跟原生 shadcn 的極簡灰階文件感拉開差距,建立更高辨識度的工程產品語言。 - -**設計判斷**:這裡跟原本 Phase 2 不重複。Phase 2 已處理「符合規範」與可客觀驗證的部分;Phase 4 處理的是超出規範以外的品牌辨識度、視覺記憶點與掃描體驗。原本列在 Phase 4 的三個大項保留為 epic,下面新增具體可執行拆解。 - -#### Direction — 視覺定位 - -- [x] 定義 Sanring docs 的視覺 thesis:`compact engineering control surface for installing, inspecting, and composing Angular UI primitives` -- [x] 避免走 shadcn clone 路線:不以大留白、黑白灰、單純 code preview card 作為主要記憶點 -- [x] 建立 Sanring 專屬視覺語彙:CLI command center、registry nodes、component dependency graph、token mapping、install result timeline、agent-readable status -- [x] 保持專業工具感:radius 維持俐落(`6px`/`8px`/少量 `12px`),避免過度柔和、大圓角、大陰影、行銷式漸層 - -#### Home — 首屏與首頁節奏 - -- [x] 重新設計 home page 首屏與主要內容節奏:保留 Sanring 的工程感,但提高視覺層次、品牌記憶點與第一眼完成度。首屏是否要用 `app-docs-page-header` 不是卡點:規範刻意把 Display `56px` 保留給 home H1,跟 DocsPageHeaderComponent 的 Page title `36px` 分開 -- [x] 首版方向已撤回:registry / CLI command center 曾完成驗證,但不符合使用者對首頁整體的期待,保留於 `DEVLOG.md` 作為歷史紀錄 -- [x] 第二版方向已撤回:移除 command center 後改成產品入口 / 系統導覽 / 元件索引 / 文件探索的首頁方案,仍被使用者判定視覺方向很差,不得作為後續依據 -- [x] 重新盤點首頁資訊架構:首屏聚焦 source-first 主張與 source composition 證據,中段呈現三個工程原則,再進入 Components 探索與文件 CTA,避免 Components / Registry / CLI 資訊重複或互相搶層級 -- [x] 重新提出至少一版更大幅度的首頁視覺方向:採開放式 editorial hero + 完整 source composition panel,不沿用產品入口 / 系統導覽配置,也沒有把 `Curated component entry points` 的語彙擴張成整頁 -- [x] 保留並重新安置使用者目前唯一認可的 `Curated component entry points` 區塊:作為首頁主要探索區,位於工程證據之後、收束 CTA 之前 -- [x] 驗證重做後的首頁在 light/dark/mobile 狀態下沒有導覽、排版或主題切換回歸 - -#### Long-form Docs — 內容頁視覺提升 - -- [x] 翻新 long-form docs pages(introduction、CLI、registry、MCP、theming、roadmap、changelog):超出「符合規範」以外、真正讓頁面更精緻好掃描的視覺提升 -- [x] CLI page 使用 command groups、流程線、exit state、dry-run/result summary,讓頁面像可操作的 CLI 參考面板 -- [x] Registry page 使用 registry schema、source graph、component/shared/block 分區,強化 Sanring registry-first 的產品差異 -- [x] MCP page 使用 agent tool map、read/write boundary、safe operation flow,呈現 agent-ready 的工作方式 -- [x] Theming page 使用 token cascade、light/dark comparison、semantic token map,把主題系統做成可理解的視覺模型 -- [x] Changelog page 往 release console / timeline 方向調整,比普通 news feed 更像工程釋出紀錄 - -#### Component Docs — 掃描效率與工程證據 - -- [ ] 翻新 component docs:component page header、examples、installation、API table、recent changes 超出「符合規範」以外的視覺層次與掃描效率提升 -- [ ] Component header 補強工程 metadata 呈現:registry name、install command、package path、stability/status、updated/recent changes affordance -- [ ] Example previewer 強化 Preview / Code / Install / API 的切換與視覺階層,讓使用者更快定位可複製資訊 -- [ ] API table 朝 dense reference surface 調整:提高欄位掃描效率,但保留 mobile card layout 的可讀性 -- [ ] Recent changes 改成 compact release strip,避免像頁尾附錄 -- [ ] 補一致的 evidence chips:a11y、keyboard support、controlled/uncontrolled、SSR/browser-only、registry deps 等,把 Sanring 的工程品質變成可見資產 - -#### Verification — 視覺驗證 - -- [ ] 每次 Phase 4 改動後用 Playwright 重拍 home light/dark/mobile、代表性 long-form page、代表性 component page -- [ ] 檢查 `360px` / `390px` 無水平 overflow,長 command/code line 不撐破版面,中英文文案長度不互相遮擋 -- [ ] 完成後將具體設計決策、截圖觀察與驗證結果同步到 `DEVLOG.md` - ---- - ## P11 — 品質關卡類(優先度較低,長期補強) -- [ ] Docs 站補 light/dark theme accessibility smoke test,至少覆蓋 component 頁面的標題、說明文字、tabs、installation/code block、copy buttons 與 focus ring,確認文字對比、可讀性與鍵盤操作都通過 -- [ ] 視覺回歸測試(如 Chromatic / Playwright screenshot),CSS 改動有沒有意外破壞其他元件外觀,現在沒有自動偵測 - [ ] CLI 補真正的 e2e 測試(拉一個全新 Angular 專案、真的跑 `sanring add`、真的 `ng build`)——現有的 `add.test.ts`/`doctor.test.ts` 等是對假的檔案系統 mock 驗證邏輯,不是「CLI 真的能在使用者機器上跑起來」的保證 --- @@ -80,9 +25,23 @@ Phase 4 已解封:Playwright 截圖 + `Read` 工具可以實際檢視 home(light - `registry/blocks/` 目錄,每個 block 是一個 Angular component(可含多個 child component) - `registry.json` 加入 `blocks` 陣列(類似 `components`),每筆有 `name`、`description`、`componentDeps`、`files` - CLI 的 `add` 指令識別 `block/` prefix,路由到 blocks registry -- 初始 blocks 建議:`auth/login`、`auth/register`、`layout/dashboard-shell`、`layout/settings-page` +- Block 分兩類,架構不同:**shell**(包住整個 app 的持久性 chrome,例如 `layout/dashboard-shell`,包一個 ``/router-outlet,不是「一頁內容」)與 **page**(一頁完整內容組合,裝進某個 route) + +**頁面類型與元件組合**(2026-08-22 盤點,已對照現有 52 個 component 逐一核對,全部可用現有元件組成,沒有缺元件擋路,不必先補元件才能動工): + +- `layout/dashboard-shell`(shell):`sidebar` + `dropdown-menu`(user menu)+ `avatar` + `breadcrumb` + `badge` +- `auth/login`(page):`card` + `field` + `input` + `label` + `button` + `checkbox`(remember me)+ `link`(forgot password)+ `divider`(or)+ `alert`(錯誤訊息) +- `auth/register`(page):同 login + `select`(可選:角色/國家) +- `auth/forgot-password`(page):`card` + `field` + `input` + `button` + `alert` +- `layout/settings-page`(page):`tabs`(分區)+ `field` + `input` + `avatar`(頭像上傳)+ `switch` + `select` + `divider` + `button` + `alert-dialog`(刪除確認) +- `data/table-page`(page):`table` + `pagination` + `input`(搜尋)+ `select`(篩選)+ `dropdown-menu`(列操作)+ `checkbox`(批次選取)+ `badge`(狀態)+ `sheet`(新增/編輯抽屜)+ `skeleton` + `toast` +- `content/detail-page`(page):`card` + `avatar` + `badge` + `tabs` + `breadcrumb` + `timeline` + `tag` +- `form/wizard`(page):`stepper` + `field` + `input` + `select` + `date-picker` + `radio` + `file-upload` + `button` + `progress` +- `billing/pricing-page`(page):`card` + `badge` + `table` + `toggle`(月/年切換)+ `button` + `tag` + +**起手三個**(驗證 CLI 機制,而非追求覆蓋率):`layout/dashboard-shell`(驗證 shell 型 block 的安裝機制跟一般 component block 不同)、`auth/login`(元件最單純,驗證 `blocks/` 目錄結構/`registry.json` blocks schema/CLI routing 三件事都跑得通)、`data/table-page`(驗證單一 block 內多個子元件互相協作的複雜組合)。其餘六個(`register`、`forgot-password`、`settings-page`、`detail-page`、`wizard`、`pricing-page`)待前三個跑通機制後再逐步擴充。 -**成本**:高。單個 block 的設計/實作本身不難,但要做出夠多、夠有代表性的 blocks 讓功能有意義,需要持續投入。建議先做 2–3 個 blocks 驗證 CLI 流程,再逐步擴充。 +**成本**:高。單個 block 的設計/實作本身不難,但要做出夠多、夠有代表性的 blocks 讓功能有意義,需要持續投入。先做上述 3 個驗證 CLI 流程,再逐步擴充其餘 6 個。 --- diff --git a/apps/docs/DOCS_VISUAL_SYSTEM.md b/apps/docs/DOCS_VISUAL_SYSTEM.md index 590a8feb..373e167a 100644 --- a/apps/docs/DOCS_VISUAL_SYSTEM.md +++ b/apps/docs/DOCS_VISUAL_SYSTEM.md @@ -292,6 +292,10 @@ Rules: - Major preview container uses `--sanring-radius-lg`. - Stage must have stable min height and responsive padding. +- Preview and source stay simultaneously visible. Do not hide either zone behind tabs; side-by-side + comparison while scrolling is more useful than reducing vertical space. +- Label the zones as `01 Preview` and `02 Source`; installation and API remain one-hop anchor targets + from the component header. - Code block scrolls horizontally internally; it must not widen the page. - Copy code action is always visible and keyboard accessible. @@ -312,6 +316,7 @@ Mobile: ### Recent Changes Recent changes are a supporting surface, not the main page ending. +Render at most three entries in a compact release strip; link to the changelog for full history. - Limit to current component. - Keep compact rows. @@ -546,8 +551,27 @@ doesn't apply. Pages to sample: home, a component page (e.g. `button`), a long-f `apps/docs/e2e/` (Playwright, see `apps/docs/playwright.config.ts`) covers: page renders without console errors, key landmarks/headings are present, no horizontal overflow at two viewports, the -mobile sheet nav opens, and theme switching updates `data-theme` and persists. It does **not** -cover subjective visual quality — spacing rhythm, color harmony, "does this look premium" — none of -that is testable without a human (or a visual-diff tool with an approved baseline, which this repo -doesn't have yet). Run `pnpm test:e2e:docs` before merging a visual change; still walk through this -checklist by eye for anything the automated suite doesn't assert on. +mobile sheet nav opens, theme switching updates `data-theme` and persists, and representative docs +surfaces still match approved screenshots. `visual-regression.spec.ts` compares the first viewport +of home and the CLI overview plus the complete Button Basic section at `1440x900` in both themes. +The committed PNGs live under `apps/docs/e2e/visual-baselines/visual-chromium/`; the dedicated +Playwright project fixes viewport, locale, timezone, reduced motion, animation, and caret behavior. +CI runs the complete suite and uploads the Playwright report plus image diffs when it fails. + +Run the visual subset locally with: + +```bash +pnpm exec playwright test --config=apps/docs/playwright.config.ts --project=visual-chromium +``` + +If an intentional visual change should become the new standard, first inspect every affected +`actual`/`diff` image, then update and review the committed baselines explicitly: + +```bash +pnpm exec playwright test --config=apps/docs/playwright.config.ts --project=visual-chromium --update-snapshots +``` + +Screenshot diffing detects pixel-level departures from the approved compositions; it still cannot +decide whether a deliberate redesign improves spacing rhythm, color harmony, or perceived quality. +Run `pnpm test:e2e:docs` before merging a visual change and use this checklist for the remaining +human judgment. diff --git a/apps/docs/e2e/component-page-accessibility.spec.ts b/apps/docs/e2e/component-page-accessibility.spec.ts new file mode 100644 index 00000000..524dcfc6 --- /dev/null +++ b/apps/docs/e2e/component-page-accessibility.spec.ts @@ -0,0 +1,134 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; +import axe from 'axe-core'; + +type Theme = 'light' | 'dark'; + +interface AxeViolation { + id: string; + impact: string | null; + help: string; + targets: string[][]; +} + +async function expectNoAxeViolations(page: Page) { + await page.addScriptTag({ content: axe.source }); + + const violations = await page.evaluate(async () => { + const axeApi = ( + window as Window & { + axe: { + run: ( + context: Element, + options: object, + ) => Promise<{ + violations: Array<{ + id: string; + impact: string | null; + help: string; + nodes: Array<{ target: string[] }>; + }>; + }>; + }; + } + ).axe; + + const article = document.querySelector('main article'); + if (!article) throw new Error('Component article was not rendered'); + + const results = await axeApi.run(article, { + runOnly: { + type: 'tag', + values: ['wcag2a', 'wcag2aa'], + }, + }); + + return results.violations.map( + (violation): AxeViolation => ({ + id: violation.id, + impact: violation.impact, + help: violation.help, + targets: violation.nodes.map((node) => node.target), + }), + ); + }); + + expect(violations, JSON.stringify(violations, null, 2)).toEqual([]); +} + +async function expectKeyboardFocusIndicator(page: Page, target: Locator) { + await target.scrollIntoViewIfNeeded(); + await target.focus(); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Tab'); + await expect(target).toBeFocused(); + + const indicator = await target.evaluate((element) => { + const style = getComputedStyle(element); + const outlineWidth = Number.parseFloat(style.outlineWidth); + + return { + focusVisible: element.matches(':focus-visible'), + hasOutline: style.outlineStyle !== 'none' && outlineWidth > 0, + hasShadow: style.boxShadow !== 'none', + }; + }); + + expect(indicator.focusVisible).toBe(true); + expect(indicator.hasOutline || indicator.hasShadow).toBe(true); +} + +async function useTheme(page: Page, theme: Theme) { + const label = theme === 'light' ? 'Light theme' : 'Dark theme'; + await page.getByRole('button', { name: label }).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', theme); +} + +test.describe('component docs accessibility smoke', () => { + for (const theme of ['light', 'dark'] as const) { + test(`${theme} theme keeps reference content readable and keyboard accessible`, async ({ + page, + }) => { + await page.goto('/components/button'); + await useTheme(page, theme); + + const title = page.locator('app-component-page-header h1'); + const description = page.locator('app-component-page-header h1 + p'); + const installSection = page.locator('#installation'); + const previewSource = page.locator('#basic app-component-page-code-block'); + const installCommandSource = installSection + .getByRole('tabpanel', { name: 'Command' }) + .locator('pre code'); + const installManualSource = installSection + .getByRole('tabpanel', { name: 'Manual' }) + .locator('pre code'); + const installCommandTab = installSection.getByRole('tab', { name: 'Command' }); + const installManualTab = installSection.getByRole('tab', { name: 'Manual' }); + const installCopyButton = installSection.getByRole('button', { name: 'Copy code' }); + const previewCopyButton = previewSource.getByRole('button', { name: 'Copy code' }); + + await expect(title).toBeVisible(); + await expect(description).toBeVisible(); + await expect(installSection).toBeVisible(); + await expect(previewSource).toBeVisible(); + await expect(installCommandSource).toBeVisible(); + await expect(installCopyButton).toBeVisible(); + await expect(previewCopyButton).toBeVisible(); + + await installCommandTab.focus(); + await page.keyboard.press('ArrowRight'); + await expect(installManualTab).toHaveAttribute('aria-selected', 'true'); + await expect(installManualSource).toContainText("import { ButtonDirective }"); + await page.keyboard.press('ArrowLeft'); + await expect(installCommandTab).toHaveAttribute('aria-selected', 'true'); + + await expectKeyboardFocusIndicator(page, installCopyButton); + await expectKeyboardFocusIndicator(page, previewCopyButton); + await expectKeyboardFocusIndicator( + page, + page.getByRole('button', { name: 'Copy install command' }), + ); + + await expectNoAxeViolations(page); + }); + } +}); diff --git a/apps/docs/e2e/component-page.spec.ts b/apps/docs/e2e/component-page.spec.ts index ba8883bc..f6bca059 100644 --- a/apps/docs/e2e/component-page.spec.ts +++ b/apps/docs/e2e/component-page.spec.ts @@ -7,6 +7,13 @@ test.describe('component page', () => { await expect(page.locator('h1')).toContainText('Button'); await expect(page.locator('#basic')).toBeVisible(); await expect(page.locator('#api')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Copy install command' })).toBeVisible(); + await expect(page.locator('a[href="#installation"]').first()).toBeVisible(); + await expect(page.locator('a[href="#api"]').first()).toBeVisible(); + await expect(page.getByRole('group', { name: 'Preview' }).first()).toBeVisible(); + await expect(page.getByRole('group', { name: 'Source' }).first()).toBeVisible(); + await expect(page.getByText('Reference surface')).toBeVisible(); + await expect(page.locator('#recent-changes')).toBeVisible(); }); test('code block copy action is keyboard accessible', async ({ page }) => { @@ -17,4 +24,31 @@ test.describe('component page', () => { await copyButton.focus(); await expect(copyButton).toBeFocused(); }); + + test('keeps component reference pages inside 360px and 390px viewports', async ({ page }) => { + for (const width of [360, 390]) { + await page.setViewportSize({ width, height: 800 }); + + for (const route of ['/components/button', '/components/dialog', '/components/table']) { + await page.goto(route); + await expect(page.locator('h1')).toBeVisible(); + + const { scrollWidth, clientWidth } = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + + expect( + scrollWidth, + `${route} overflows at ${width}px: ${scrollWidth}px > ${clientWidth}px`, + ).toBeLessThanOrEqual(clientWidth); + } + } + }); + + test('uses the radio group API anchor for its split reference tables', async ({ page }) => { + await page.goto('/components/radio'); + + await expect(page.locator('a[href="#api-group"]').first()).toBeVisible(); + }); }); diff --git a/apps/docs/e2e/phase4-visual-verification.spec.ts b/apps/docs/e2e/phase4-visual-verification.spec.ts index 48c79f1e..8e7a5620 100644 --- a/apps/docs/e2e/phase4-visual-verification.spec.ts +++ b/apps/docs/e2e/phase4-visual-verification.spec.ts @@ -1,14 +1,18 @@ import { expect, test, type Page } from '@playwright/test'; const LONG_FORM_ROUTES = ['/introduction', '/cli', '/registry', '/mcp', '/theming']; -const REPRESENTATIVE_ROUTES = ['/', ...LONG_FORM_ROUTES, '/components/button']; +const COMPONENT_ROUTES = ['/components/button', '/components/dialog', '/components/table']; +const REPRESENTATIVE_ROUTES = ['/', ...LONG_FORM_ROUTES, ...COMPONENT_ROUTES]; async function expectNoPageOverflow(page: Page) { const result = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })); - expect(result.scrollWidth, `horizontal overflow: ${result.scrollWidth}px > ${result.clientWidth}px`).toBeLessThanOrEqual(result.clientWidth); + expect( + result.scrollWidth, + `horizontal overflow: ${result.scrollWidth}px > ${result.clientWidth}px`, + ).toBeLessThanOrEqual(result.clientWidth); } test.describe('Phase 4 visual verification', () => { @@ -19,7 +23,9 @@ test.describe('Phase 4 visual verification', () => { await page.goto(route); await expect(page.locator('h1')).toBeVisible(); await expectNoPageOverflow(page); - await page.screenshot({ path: `/tmp/sanring-phase4-light-${route === '/' ? 'home' : route.slice(1).replaceAll('/', '-')}.png` }); + await page.screenshot({ + path: `/tmp/sanring-phase4-light-${route === '/' ? 'home' : route.slice(1).replaceAll('/', '-')}.png`, + }); } }); @@ -32,18 +38,51 @@ test.describe('Phase 4 visual verification', () => { await page.screenshot({ path: '/tmp/sanring-phase4-dark-home.png' }); }); + test('captures representative component pages in dark theme', async ({ page }) => { + for (const route of COMPONENT_ROUTES) { + await page.goto(route); + await page.getByRole('button', { name: 'Dark theme' }).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(page.locator('h1')).toBeVisible(); + await expectNoPageOverflow(page); + await page.screenshot({ + path: `/tmp/sanring-phase4-dark-${route.slice(1).replaceAll('/', '-')}.png`, + }); + } + }); + for (const width of [360, 390]) { - test(`captures home and CLI at ${width}px without overflow`, async ({ page }) => { + test(`captures representative pages at ${width}px without overflow`, async ({ page }) => { await page.setViewportSize({ width, height: 800 }); - for (const route of ['/', '/cli']) { + for (const route of ['/', '/cli', ...COMPONENT_ROUTES]) { await page.goto(route); await expect(page.locator('h1')).toBeVisible(); await expectNoPageOverflow(page); - await page.screenshot({ path: `/tmp/sanring-phase4-${width}-${route === '/' ? 'home' : 'cli'}.png` }); + await page.screenshot({ + path: `/tmp/sanring-phase4-${width}-${ + route === '/' ? 'home' : route.slice(1).replaceAll('/', '-') + }.png`, + }); } }); } + test('captures component header, API, and release surfaces', async ({ page }) => { + await page.goto('/components/button'); + await expect(page.locator('h1')).toBeVisible(); + + await page.locator('app-component-page-header').screenshot({ + path: '/tmp/sanring-phase4-component-header.png', + }); + await page.locator('#basic app-component-page-code-previewer').screenshot({ + path: '/tmp/sanring-phase4-component-previewer.png', + }); + await page.locator('#api').screenshot({ path: '/tmp/sanring-phase4-component-api.png' }); + await page + .locator('#recent-changes') + .screenshot({ path: '/tmp/sanring-phase4-component-recent-changes.png' }); + }); + test('keeps long code lines inside a scrollable code surface', async ({ page }) => { await page.goto('/cli'); const uncontainedOverflow = await page.locator('pre, code').evaluateAll((elements) => diff --git a/apps/docs/e2e/visual-baselines/visual-chromium/dark-button-component.png b/apps/docs/e2e/visual-baselines/visual-chromium/dark-button-component.png new file mode 100644 index 00000000..2397bcb1 Binary files /dev/null and b/apps/docs/e2e/visual-baselines/visual-chromium/dark-button-component.png differ diff --git a/apps/docs/e2e/visual-baselines/visual-chromium/dark-cli-overview.png b/apps/docs/e2e/visual-baselines/visual-chromium/dark-cli-overview.png new file mode 100644 index 00000000..1b34e05e Binary files /dev/null and b/apps/docs/e2e/visual-baselines/visual-chromium/dark-cli-overview.png differ diff --git a/apps/docs/e2e/visual-baselines/visual-chromium/dark-home.png b/apps/docs/e2e/visual-baselines/visual-chromium/dark-home.png new file mode 100644 index 00000000..387d9c63 Binary files /dev/null and b/apps/docs/e2e/visual-baselines/visual-chromium/dark-home.png differ diff --git a/apps/docs/e2e/visual-baselines/visual-chromium/light-button-component.png b/apps/docs/e2e/visual-baselines/visual-chromium/light-button-component.png new file mode 100644 index 00000000..c6996a45 Binary files /dev/null and b/apps/docs/e2e/visual-baselines/visual-chromium/light-button-component.png differ diff --git a/apps/docs/e2e/visual-baselines/visual-chromium/light-cli-overview.png b/apps/docs/e2e/visual-baselines/visual-chromium/light-cli-overview.png new file mode 100644 index 00000000..355934d2 Binary files /dev/null and b/apps/docs/e2e/visual-baselines/visual-chromium/light-cli-overview.png differ diff --git a/apps/docs/e2e/visual-baselines/visual-chromium/light-home.png b/apps/docs/e2e/visual-baselines/visual-chromium/light-home.png new file mode 100644 index 00000000..d80a94b5 Binary files /dev/null and b/apps/docs/e2e/visual-baselines/visual-chromium/light-home.png differ diff --git a/apps/docs/e2e/visual-regression.spec.ts b/apps/docs/e2e/visual-regression.spec.ts new file mode 100644 index 00000000..98cdd7d7 --- /dev/null +++ b/apps/docs/e2e/visual-regression.spec.ts @@ -0,0 +1,64 @@ +import { expect, test, type Page } from '@playwright/test'; + +type Theme = 'light' | 'dark'; + +const surfaces = [ + { + name: 'home', + path: '/', + ready: (page: Page) => page.getByRole('heading', { level: 1 }), + }, + { + name: 'button-component', + path: '/components/button', + ready: (page: Page) => page.locator('#basic app-component-page-code-previewer'), + capture: (page: Page) => page.locator('#basic'), + }, + { + name: 'cli-overview', + path: '/cli', + ready: (page: Page) => page.getByRole('region', { name: 'CLI workflow overview' }), + }, +] as const; + +async function prepareStableScreenshot(page: Page, theme: Theme, path: string): Promise { + await page.addInitScript((selectedTheme: Theme) => { + localStorage.setItem('sanring-docs-theme', selectedTheme); + }, theme); + + await page.goto(path); + await expect(page.locator('html')).toHaveAttribute('data-theme', theme); + await page.waitForLoadState('networkidle'); + await page.evaluate(() => document.fonts.ready); + await page.addStyleTag({ + content: ` + *, *::before, *::after { + animation-delay: 0s !important; + animation-duration: 0s !important; + caret-color: transparent !important; + transition-delay: 0s !important; + transition-duration: 0s !important; + } + html { scroll-behavior: auto !important; } + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { animation: none !important; } + `, + }); + await page.evaluate(() => window.scrollTo(0, 0)); +} + +for (const theme of ['light', 'dark'] as const) { + for (const surface of surfaces) { + test(`${surface.name} / ${theme}`, async ({ page }) => { + await prepareStableScreenshot(page, theme, surface.path); + const readySurface = surface.ready(page); + await expect(readySurface).toBeVisible(); + if ('capture' in surface) { + await expect(surface.capture(page)).toHaveScreenshot(`${theme}-${surface.name}.png`); + } else { + await expect(page).toHaveScreenshot(`${theme}-${surface.name}.png`); + } + }); + } +} diff --git a/apps/docs/playwright.config.ts b/apps/docs/playwright.config.ts index 88bef37f..29777df5 100644 --- a/apps/docs/playwright.config.ts +++ b/apps/docs/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; const PORT = 4310; const BASE_URL = `http://localhost:${PORT}`; +const VISUAL_REGRESSION_SPEC = '**/visual-regression.spec.ts'; export default defineConfig({ testDir: './e2e', @@ -9,6 +10,16 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + snapshotPathTemplate: '{testDir}/visual-baselines/{projectName}/{arg}{ext}', + expect: { + toHaveScreenshot: { + animations: 'disabled', + caret: 'hide', + scale: 'css', + threshold: 0.3, + maxDiffPixelRatio: 0.03, + }, + }, use: { baseURL: BASE_URL, trace: 'on-first-retry', @@ -16,12 +27,26 @@ export default defineConfig({ projects: [ { name: 'desktop-chromium', + testIgnore: VISUAL_REGRESSION_SPEC, use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } }, }, { name: 'mobile-chromium', + testIgnore: VISUAL_REGRESSION_SPEC, use: { ...devices['Pixel 7'] }, }, + { + name: 'visual-chromium', + testMatch: VISUAL_REGRESSION_SPEC, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 900 }, + colorScheme: 'light', + locale: 'en-US', + reducedMotion: 'reduce', + timezoneId: 'UTC', + }, + }, ], webServer: { command: `pnpm exec ng serve docs --port ${PORT} --configuration development`, diff --git a/apps/docs/src/app/i18n/locales/en/common.ts b/apps/docs/src/app/i18n/locales/en/common.ts index c0607a45..ba78cf6c 100644 --- a/apps/docs/src/app/i18n/locales/en/common.ts +++ b/apps/docs/src/app/i18n/locales/en/common.ts @@ -93,10 +93,15 @@ export const commonTranslations = { 'components.updatedEmpty': 'No updated components yet.', 'components.allTitle': 'All components', 'component.recentChanges.title': 'Recent changes', - 'component.recentChanges.description': 'Latest registry and documentation updates for this component.', + 'component.recentChanges.description': + 'Latest registry and documentation updates for this component.', + 'component.recentChanges.signal': 'Release signal', 'component.recentChanges.viewAll': 'View changelog', 'component.header.registry': 'Registry', 'component.header.shipped': 'Shipped', + 'component.header.installCommand': 'Install command', + 'component.header.copyInstall': 'Copy install command', + 'component.header.copyReady': 'copy-ready', 'component.header.packagePath': 'Path', 'component.header.ssrSafe': 'SSR-safe', 'component.header.browserOnly': 'Browser-only', @@ -107,8 +112,12 @@ export const commonTranslations = { 'component.header.stateful': 'Stateful', 'component.header.cva': 'CVA-integrated', 'component.header.updated': 'Updated', + 'component.header.quickAccess': 'Jump to', 'component.header.jumpInstall': 'Installation', 'component.header.jumpApi': 'API', + 'component.previewer.preview': 'Preview', + 'component.previewer.rendered': 'rendered output', + 'component.previewer.source': 'Source', 'components.disabledNotice.title': 'Why are some components greyed out?', 'components.disabledNotice.description': "They're still in development — their docs page exists but isn't ready to use yet, so it isn't linked from this list. Track progress on the", @@ -129,6 +138,9 @@ export const commonTranslations = { 'docs.api.type': 'Type', 'docs.api.default': 'Default', 'docs.api.description': 'Description', + 'docs.api.surface': 'Reference surface', + 'docs.api.members': 'members', + 'docs.api.caption': 'Component API properties, types, defaults, and descriptions', 'docs.keyboard.key': 'Key', 'docs.keyboard.action': 'Action', 'docs.usage.imports.convenience': 'Convenience import', diff --git a/apps/docs/src/app/i18n/locales/zh/common.ts b/apps/docs/src/app/i18n/locales/zh/common.ts index e6aef08e..5f993aee 100644 --- a/apps/docs/src/app/i18n/locales/zh/common.ts +++ b/apps/docs/src/app/i18n/locales/zh/common.ts @@ -92,9 +92,13 @@ export const commonTranslations = { 'components.allTitle': '所有元件', 'component.recentChanges.title': '近期變更', 'component.recentChanges.description': '這個元件最近的 registry 與文件更新。', + 'component.recentChanges.signal': '釋出訊號', 'component.recentChanges.viewAll': '查看完整 changelog', 'component.header.registry': 'Registry', 'component.header.shipped': '已上線', + 'component.header.installCommand': '安裝指令', + 'component.header.copyInstall': '複製安裝指令', + 'component.header.copyReady': '可複製', 'component.header.packagePath': '路徑', 'component.header.ssrSafe': 'SSR 安全', 'component.header.browserOnly': '僅限瀏覽器', @@ -105,8 +109,12 @@ export const commonTranslations = { 'component.header.stateful': '有內部狀態', 'component.header.cva': '支援 CVA', 'component.header.updated': '最近更新', + 'component.header.quickAccess': '快速定位', 'component.header.jumpInstall': '安裝方式', 'component.header.jumpApi': 'API', + 'component.previewer.preview': '預覽', + 'component.previewer.rendered': '即時輸出', + 'component.previewer.source': '程式碼', 'components.disabledNotice.title': '為什麼有些元件是灰色的?', 'components.disabledNotice.description': '它們還在開發中——文件頁面已經存在,但內容還沒準備好,所以清單裡先不開放點擊。可以到', @@ -127,6 +135,9 @@ export const commonTranslations = { 'docs.api.type': '型別', 'docs.api.default': '預設值', 'docs.api.description': '說明', + 'docs.api.surface': '參考面板', + 'docs.api.members': '個成員', + 'docs.api.caption': '元件 API 的屬性、型別、預設值與說明', 'docs.keyboard.key': '按鍵', 'docs.keyboard.action': '操作說明', 'docs.usage.imports.convenience': '便利整包匯入', diff --git a/apps/docs/src/app/layouts/component-page/component-page-api-table.component.ts b/apps/docs/src/app/layouts/component-page/component-page-api-table.component.ts index bbf75201..bb6575a9 100644 --- a/apps/docs/src/app/layouts/component-page/component-page-api-table.component.ts +++ b/apps/docs/src/app/layouts/component-page/component-page-api-table.component.ts @@ -6,17 +6,40 @@ import { I18nService } from '../../i18n/i18n.service'; selector: 'app-component-page-api-table', standalone: true, template: ` -