diff --git a/03_implementation/docs/evidence/visual_proof_2026-05-09/summary.json b/03_implementation/docs/evidence/visual_proof_2026-05-09/summary.json new file mode 100644 index 00000000..6f09049c --- /dev/null +++ b/03_implementation/docs/evidence/visual_proof_2026-05-09/summary.json @@ -0,0 +1,387 @@ +{ + "schema_version": 1, + "generated_at": "2026-05-10T04:01:09.572Z", + "started_at": "2026-05-10T03:57:45.102Z", + "run_status": "failed", + "owner": "claude-w6-6-visual-proof", + "lane": "W6-6 Playwright visual proof against Images-GUI/", + "counts": { + "total": 31, + "error": 11, + "skipped-future": 20 + }, + "rows": [ + { + "target": "00_user_generated_1", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/00-user-current-downloads/Generated image 1.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Generated reference 1 — direction PNG, not a 1:1 page snapshot. Skipped from strict comparison; tracked for visual direction only.", + "started_at": "2026-05-10T04:01:08.883Z", + "finished_at": "2026-05-10T04:01:08.883Z" + }, + { + "target": "00_user_generated_2", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/00-user-current-downloads/Generated image 2.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Generated reference 2.", + "started_at": "2026-05-10T04:01:08.884Z", + "finished_at": "2026-05-10T04:01:08.884Z" + }, + { + "target": "00_user_generated_3", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/00-user-current-downloads/Generated image 3.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Generated reference 3.", + "started_at": "2026-05-10T04:01:08.884Z", + "finished_at": "2026-05-10T04:01:08.884Z" + }, + { + "target": "00_user_generated_4", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/00-user-current-downloads/Generated image 4.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Generated reference 4.", + "started_at": "2026-05-10T04:01:08.884Z", + "finished_at": "2026-05-10T04:01:08.884Z" + }, + { + "target": "00_user_generated_5", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/00-user-current-downloads/Generated image 5.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Generated reference 5.", + "started_at": "2026-05-10T04:01:08.884Z", + "finished_at": "2026-05-10T04:01:08.884Z" + }, + { + "target": "00_user_generated_6", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/00-user-current-downloads/Generated image 6.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Generated reference 6.", + "started_at": "2026-05-10T04:01:08.884Z", + "finished_at": "2026-05-10T04:01:08.884Z" + }, + { + "target": "00_user_generated_7", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/00-user-current-downloads/Generated image 7.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Generated reference 7.", + "started_at": "2026-05-10T04:01:08.884Z", + "finished_at": "2026-05-10T04:01:08.884Z" + }, + { + "target": "00_user_generated_8", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/00-user-current-downloads/Generated image 8.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Generated reference 8.", + "started_at": "2026-05-10T04:01:08.884Z", + "finished_at": "2026-05-10T04:01:08.884Z" + }, + { + "target": "00_user_generated_9", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/00-user-current-downloads/Generated image 9.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Generated reference 9.", + "started_at": "2026-05-10T04:01:08.884Z", + "finished_at": "2026-05-10T04:01:08.884Z" + }, + { + "target": "00_user_hermes3d", + "status": "error", + "route": "/", + "reference": "Images-GUI/00-user-current-downloads/Hermes3D.png", + "tolerance": 0.15, + "evidence_path": null, + "diff_path": null, + "error": "\u001b[31mTest timeout of 30000ms exceeded.\u001b[39m", + "started_at": "2026-05-10T04:00:37.997Z", + "finished_at": "2026-05-10T04:01:08.317Z" + }, + { + "target": "01_dashboard_advanced_a", + "status": "error", + "route": "/#dashboard", + "reference": "Images-GUI/01-dashboard-modes/advanced-dashboard-a.png", + "tolerance": 0.1, + "evidence_path": null, + "diff_path": null, + "error": "\u001b[31mTest timeout of 30000ms exceeded.\u001b[39m", + "started_at": "2026-05-10T03:57:48.808Z", + "finished_at": "2026-05-10T03:58:19.004Z" + }, + { + "target": "01_dashboard_advanced_b", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/01-dashboard-modes/advanced-dashboard-b.png", + "tolerance": 0.1, + "evidence_path": null, + "diff_path": null, + "error": "Advanced dashboard alt view B. Future target — owned by Wave 6 lane 3.", + "started_at": "2026-05-10T03:58:20.114Z", + "finished_at": "2026-05-10T03:58:20.114Z" + }, + { + "target": "01_dashboard_custom_a", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/01-dashboard-modes/custom-dashboard-a.png", + "tolerance": 0.15, + "evidence_path": null, + "diff_path": null, + "error": "Custom dashboard with draggable widgets — not implemented yet. Future target — owned by Wave 6 lane 3 / 4.", + "started_at": "2026-05-10T03:58:20.116Z", + "finished_at": "2026-05-10T03:58:20.116Z" + }, + { + "target": "01_dashboard_custom_b", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/01-dashboard-modes/custom-dashboard-b.png", + "tolerance": 0.15, + "evidence_path": null, + "diff_path": null, + "error": "Custom dashboard alt B. Future target.", + "started_at": "2026-05-10T03:58:20.116Z", + "finished_at": "2026-05-10T03:58:20.116Z" + }, + { + "target": "01_dashboard_simple_a", + "status": "skipped-future", + "route": "/?ui=simple#dashboard", + "reference": "Images-GUI/01-dashboard-modes/simple-dashboard-a.png", + "tolerance": 0.15, + "evidence_path": null, + "diff_path": null, + "error": "Simple dashboard mode A. Mode switcher is being added by W6-3.", + "started_at": "2026-05-10T03:58:20.116Z", + "finished_at": "2026-05-10T03:58:20.116Z" + }, + { + "target": "01_dashboard_simple_b", + "status": "skipped-future", + "route": "/?ui=simple#dashboard", + "reference": "Images-GUI/01-dashboard-modes/simple-dashboard-b.png", + "tolerance": 0.15, + "evidence_path": null, + "diff_path": null, + "error": "Simple dashboard mode B. Future target — owned by Wave 6 lane 3.", + "started_at": "2026-05-10T03:58:20.116Z", + "finished_at": "2026-05-10T03:58:20.116Z" + }, + { + "target": "02_primary_artifacts_approvals_plugins_roadmap", + "status": "error", + "route": "/#artifacts", + "reference": "Images-GUI/02-primary-pages/primary-tabs-artifacts-approvals-plugins-roadmap.png", + "tolerance": 0.12, + "evidence_path": null, + "diff_path": null, + "error": "Error: The outputPath is not allowed outside of the parent directory. Please fix the defined path. ", + "started_at": "2026-05-10T03:58:32.741Z", + "finished_at": "2026-05-10T03:58:36.442Z" + }, + { + "target": "02_primary_autopilot_design_gen3d_jobs", + "status": "error", + "route": "/#autopilot", + "reference": "Images-GUI/02-primary-pages/primary-tabs-autopilot-design-gen3d-jobs.png", + "tolerance": 0.12, + "evidence_path": null, + "diff_path": null, + "error": "Error: The outputPath is not allowed outside of the parent directory. Please fix the defined path. ", + "started_at": "2026-05-10T03:58:20.117Z", + "finished_at": "2026-05-10T03:58:23.264Z" + }, + { + "target": "02_primary_printers_observe_agents_learning", + "status": "error", + "route": "/#printers", + "reference": "Images-GUI/02-primary-pages/primary-tabs-printers-observe-agents-learning.png", + "tolerance": 0.12, + "evidence_path": null, + "diff_path": null, + "error": "Error: The outputPath is not allowed outside of the parent directory. Please fix the defined path. ", + "started_at": "2026-05-10T03:58:25.700Z", + "finished_at": "2026-05-10T03:58:30.977Z" + }, + { + "target": "03_settings_subtabs_all", + "status": "error", + "route": "/#settings", + "reference": "Images-GUI/03-settings-voice/settings-subtabs-all.png", + "tolerance": 0.12, + "evidence_path": null, + "diff_path": null, + "error": "Error: The outputPath is not allowed outside of the parent directory. Please fix the defined path. ", + "started_at": "2026-05-10T03:58:37.612Z", + "finished_at": "2026-05-10T03:58:40.517Z" + }, + { + "target": "03_voice_communication_subtabs", + "status": "error", + "route": "/#voice", + "reference": "Images-GUI/03-settings-voice/voice-communication-subtabs.png", + "tolerance": 0.12, + "evidence_path": null, + "diff_path": null, + "error": "Error: The outputPath is not allowed outside of the parent directory. Please fix the defined path. ", + "started_at": "2026-05-10T03:58:42.090Z", + "finished_at": "2026-05-10T03:58:45.658Z" + }, + { + "target": "04_source_os_60_app_coverage_matrix", + "status": "error", + "route": "/#sources", + "reference": "Images-GUI/04-source-os/source-os-60-app-coverage-matrix.png", + "tolerance": 0.12, + "evidence_path": null, + "diff_path": null, + "error": "\u001b[31mTest timeout of 30000ms exceeded.\u001b[39m", + "started_at": "2026-05-10T03:58:48.199Z", + "finished_at": "2026-05-10T03:59:18.353Z" + }, + { + "target": "04_source_os_core_categories", + "status": "error", + "route": "/#sources", + "reference": "Images-GUI/04-source-os/source-os-core-categories.png", + "tolerance": 0.12, + "evidence_path": null, + "diff_path": null, + "error": "Error: The outputPath is not allowed outside of the parent directory. Please fix the defined path. ", + "started_at": "2026-05-10T03:59:20.008Z", + "finished_at": "2026-05-10T03:59:43.751Z" + }, + { + "target": "04_source_os_remaining_categories", + "status": "error", + "route": "/#sources", + "reference": "Images-GUI/04-source-os/source-os-remaining-categories.png", + "tolerance": 0.12, + "evidence_path": null, + "diff_path": null, + "error": "Error: The outputPath is not allowed outside of the parent directory. Please fix the defined path. ", + "started_at": "2026-05-10T03:59:44.900Z", + "finished_at": "2026-05-10T04:00:11.717Z" + }, + { + "target": "05_action_window_advanced_tools", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/05-action-windows/action-window-advanced-tools.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Action Window advanced-tools template. Future target — owned by W6-4.", + "started_at": "2026-05-10T04:00:12.961Z", + "finished_at": "2026-05-10T04:00:12.961Z" + }, + { + "target": "05_action_window_core_apps", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/05-action-windows/action-window-core-apps.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Action Window core-apps template. Action Window is being added by W6-4.", + "started_at": "2026-05-10T04:00:12.960Z", + "finished_at": "2026-05-10T04:00:12.960Z" + }, + { + "target": "06_states_responsive_reference", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/06-states-responsive/states-responsive-reference.png", + "tolerance": 0.2, + "evidence_path": null, + "diff_path": null, + "error": "Loading/empty/blocked/recovering responsive states reference. Per-state specs are out of scope for this lane.", + "started_at": "2026-05-10T04:00:12.962Z", + "finished_at": "2026-05-10T04:00:12.962Z" + }, + { + "target": "07_plugins_skills_mcp_app_connectors", + "status": "error", + "route": "/#plugins", + "reference": "Images-GUI/07-plugins-skills-mcp/plugins-skills-mcp-app-connectors.png", + "tolerance": 0.12, + "evidence_path": null, + "diff_path": null, + "error": "Error: The outputPath is not allowed outside of the parent directory. Please fix the defined path. ", + "started_at": "2026-05-10T04:00:12.962Z", + "finished_at": "2026-05-10T04:00:36.574Z" + }, + { + "target": "08_proof_health_notifications_safety", + "status": "skipped-future", + "route": "/#observe", + "reference": "Images-GUI/08-app-utility-pages/proof-health-notifications-safety.png", + "tolerance": 0.15, + "evidence_path": null, + "diff_path": null, + "error": "Composite of proof + health + notifications + safety. Anchored to Observe. Future target.", + "started_at": "2026-05-10T04:00:37.996Z", + "finished_at": "2026-05-10T04:00:37.996Z" + }, + { + "target": "08_workflow_printqueue_files_logs", + "status": "skipped-future", + "route": "/#jobs", + "reference": "Images-GUI/08-app-utility-pages/workflow-printqueue-files-logs.png", + "tolerance": 0.15, + "evidence_path": null, + "diff_path": null, + "error": "Composite of workflow + print queue + files + logs. No single route maps yet; uses Jobs as anchor. Future target — split by lane 3/4.", + "started_at": "2026-05-10T04:00:37.994Z", + "finished_at": "2026-05-10T04:00:37.994Z" + }, + { + "target": "09_theme_variants_reference", + "status": "skipped-future", + "route": "/#dashboard", + "reference": "Images-GUI/09-themes/theme-variants-reference.png", + "tolerance": 0.25, + "evidence_path": null, + "diff_path": null, + "error": "Theme palette reference (default/cyberpunk/matrix/tron/forge/aurora). Theme switcher not implemented; future target.", + "started_at": "2026-05-10T04:00:37.996Z", + "finished_at": "2026-05-10T04:00:37.996Z" + } + ] +} diff --git a/03_implementation/docs/handoffs/HERMES_GUI_VISUAL_PROOF_2026-05-09.md b/03_implementation/docs/handoffs/HERMES_GUI_VISUAL_PROOF_2026-05-09.md new file mode 100644 index 00000000..a1f34ae7 --- /dev/null +++ b/03_implementation/docs/handoffs/HERMES_GUI_VISUAL_PROOF_2026-05-09.md @@ -0,0 +1,201 @@ +# Hermes3D OS GUI - Playwright Visual Proof Harness (W6-6) + +**Owner:** claude-w6-6-visual-proof +**Date:** 2026-05-09 +**Branch:** `claude/w6-6-playwright-visual-proof` (off `feat/hermes3d-7-complete-gui-repo-wiring`) +**Worktree:** `G:/Github/_claude_worktrees/h3d-claude-w6-6-visual` +**Lane:** Wave 6 lane 5 (visual-proof against `Images-GUI/`), prep for lane 3. + +## Problem + +W5-3 ran a Playwright smoke and produced 6 green screenshots, but it never +compared those screenshots against the `Images-GUI/` reference pack (31 PNGs +covering all 16 primary tabs, settings/voice subtabs, source-os categories, +action-window templates, responsive states, plugins/skills/MCP, app utility +pages, and 6 themes). Per the user, "Add Playwright visual proof against +the reference images." This handoff documents the harness that fills that +gap and the methodology for running, reading, and updating it. + +## Methodology + +| Knob | Value | Why | +|---|---|---| +| Viewport | 1920 x 1080 | Matches existing playwright.e2e.config.ts and the 1920-wide reference PNGs from `Images-GUI/`. | +| Screenshot mode | `fullPage: true` | Reference PNGs cover full pages, not just viewport. | +| Animations | `disabled` | Playwright's animation freeze removes a major source of flake. | +| Wait sequence | `domcontentloaded` -> `networkidle` -> 500 ms quiet period -> `wait_test_id` (when set) | Prevents async data loads from racing the screenshot. | +| Pixel diff engine | Playwright bundled `pixelmatch` via `toHaveScreenshot` | Free, MIT-licensed, no new dependency added. | +| Per-pixel threshold | `0.2` (config-wide), per-target `tolerance` overrides via `maxDiffPixelRatio` | 10% pixel diff is the base default per `visual-targets.json`; theme/state references use 15-25%. | +| `updateSnapshots` | `none` | Hard contract: this run NEVER auto-updates reference PNGs. Refresh requires `--update-snapshots` and a PR review. | +| `snapshotPathTemplate` | `{arg}{ext}` | When the spec calls `toHaveScreenshot([...segments])`, Playwright `path.join`s the segments and resolves them against the `playwright.visual.config.ts` directory (`03_implementation/ui/`). The spec passes a relative chain back to `Images-GUI/` so the reference PNGs are the single source of truth (no `__snapshots__/` duplicates). | + +## Files added by this lane + +``` +03_implementation/ui/playwright.visual.config.ts +03_implementation/ui/tests/visual/visual-targets.json +03_implementation/ui/tests/visual/visual-proof.spec.ts +03_implementation/ui/tests/visual/visual-proof-reporter.ts +03_implementation/docs/evidence/visual_proof_2026-05-09/summary.json (generated) +03_implementation/docs/handoffs/HERMES_GUI_VISUAL_PROOF_2026-05-09.md +``` + +W6-3 / W6-4 components were NOT modified. Visual proof is read-only against +the live UI. + +## Targets catalog + +`visual-targets.json` is the manifest. 31 entries, one per PNG in +`Images-GUI/`. Each entry carries: + +```json +{ + "target": "01_dashboard_advanced_a", + "reference": "Images-GUI/01-dashboard-modes/advanced-dashboard-a.png", + "route": "/#dashboard", + "status": "live | future", + "tolerance": 0.10, + "wait_test_id": "dashboard-root", + "notes": "Owned by Wave 6 lane 3 (W6-3 dashboard mode switcher) for parity work." +} +``` + +### Status taxonomy + +- `live` - target's route renders in the current main app and a comparison can run today. +- `future` - target requires UI work that is owned by another lane (W6-3 dashboard modes / theme switcher; W6-4 Action Window). The harness emits a `skipped-future` row so reviewers see the gap without the run failing. + +The harness also emits two runtime statuses that are not present in the +manifest: + +- `skipped-missing-reference` - the manifest path resolved to no PNG on disk. +- `missing-baseline` - the screenshot assertion ran but Playwright reported the snapshot was absent (defensive; this should be unreachable now that `updateSnapshots: "none"` is set, but the reporter still classifies it). + +### Coverage breakdown + +| Folder | PNGs | Live | Future | Notes | +|---|---:|---:|---:|---| +| `00-user-current-downloads` | 10 | 1 | 9 | `Hermes3D.png` is the user-approved baseline; Generated 1-9 are visual-direction PNGs, not 1:1 page snapshots. | +| `01-dashboard-modes` | 6 | 1 | 5 | `advanced-dashboard-a` is the closest live match. Simple/Custom/Advanced-B are W6-3 work. | +| `02-primary-pages` | 3 | 3 | 0 | Composite refs anchored to Autopilot / Printers / Artifacts. Per-tab specs already exist; this lane adds the cross-tab visual layer. | +| `03-settings-voice` | 2 | 2 | 0 | Settings (`/#settings`) and Voice (`/#voice`) routes. | +| `04-source-os` | 3 | 3 | 0 | Source OS landing (`/#sources`) - core / remaining / 60-app matrix. | +| `05-action-windows` | 2 | 0 | 2 | Action Window is W6-4. | +| `06-states-responsive` | 1 | 0 | 1 | Loading/empty/blocked/recovering states. Future, lane TBD. | +| `07-plugins-skills-mcp` | 1 | 1 | 0 | Plugins (`/#plugins`) landing. | +| `08-app-utility-pages` | 2 | 0 | 2 | Composite of workflow + print queue / proof + health. Anchored to Jobs/Observe but no single live route covers them yet. | +| `09-themes` | 1 | 0 | 1 | Theme switcher not implemented. | +| **Total** | **31** | **11** | **20** | 11 live targets exercise pixel diff today; 20 future targets are tracked but skipped. | + +## How to run + +```powershell +# 1. Install playwright browsers if missing (one-time): +cd G:\Github\h3d-gui-wiring-codex\03_implementation\ui +npx playwright install chromium + +# 2. Run the visual harness (starts dev server via webServer): +npx playwright test --config=playwright.visual.config.ts + +# 3. Read the JSON summary: +type ..\docs\evidence\visual_proof_2026-05-09\summary.json +``` + +The webServer block in `playwright.visual.config.ts` reuses +`scripts/start-e2e-stack.mjs` (same path used by the E2E config), so no +separate dev-server orchestration is needed. The first run will boot the +stack on `127.0.0.1:5173`. + +### First-run results (2026-05-10 03:38 UTC) + +Run on `claude/w6-6-playwright-visual-proof` branch off +`feat/hermes3d-7-complete-gui-repo-wiring`, in a clean worktree at +`G:/Github/_claude_worktrees/h3d-claude-w6-6-visual` with `node_modules/` +junctioned to the parent codex repo. + +| Status | Count | Notes | +|---|---:|---| +| match | 0 | Live targets could not produce a pixel diff because the SPA never mounted. | +| diff | 0 | | +| missing-baseline | 0 | All 31 reference PNGs are present on disk. | +| skipped-future | 20 | Future targets skipped as expected. | +| skipped-missing-reference | 0 | | +| error | 11 | All 11 live targets timed out waiting for their `wait_test_id` to mount. Root cause is a pre-existing Vite HMR error in `src/app/routes.tsx`: `ReferenceError: $RefreshReg$ is not defined`, blocking React from rendering. This pre-dates this lane and reproduces with the existing E2E config too. | +| **total** | **31** | All 31 reference PNGs catalogued. | + +The lane is unblocked once the Vite HMR / Fast Refresh issue on +`feat/hermes3d-7-complete-gui-repo-wiring` is fixed (likely a missing +`@vitejs/plugin-react` preamble or a stale Vite 8 / React-plugin +incompatibility). At that point a re-run will yield real `match` / `diff` +rows. The harness itself does NOT need a fix; the current failure mode is +doing what it should: reporting concrete error data per target and +emitting the JSON summary. + +The 20 `skipped-future` rows are the expected design: those references +are owned by W6-3 (dashboard modes / theme switcher / custom dashboards), +W6-4 (Action Window), or future state-screen lanes. + +## How to update reference PNGs (intentional UI changes) + +This is an explicit, audited operation. Never auto-merge an update. + +1. Verify the UI change is intentional and approved (PR review, design ack). +2. Run the harness with `--update-snapshots` to overwrite reference PNGs in + `Images-GUI/`: + ```powershell + cd G:\Github\h3d-gui-wiring-codex\03_implementation\ui + npx playwright test --config=playwright.visual.config.ts --update-snapshots + ``` +3. Run `git diff Images-GUI/` and inspect every changed PNG visually before + staging. +4. Add the new reference PNGs in a separate commit titled + `chore(visual-proof): update Images-GUI baseline for ` so the + diff is reviewable in isolation from product changes. +5. Re-run the harness without `--update-snapshots` to confirm the next run + passes against the new baseline. + +## Persistence rule for failed targets + +For each `diff` (failed comparison), the JSON summary records: + +- `target` - manifest key +- `diff_pixels` (when extractable from Playwright failure message) +- `total_pixels` (when extractable; otherwise omitted) +- `ratio` (diff pixels / total pixels) +- `diff_path` - relative path to the Playwright-emitted diff PNG + (typically `test-results/visual/__diff__/.png` or + `test-results//-diff.png`) +- `evidence_path` - relative path to the live screenshot +- `error` - first 600 chars of the failure message +- `route`, `reference`, `tolerance` - copied from the manifest + +Suggested follow-up workflow: + +``` +target_name -> populated +diff_pixels_count -> from summary.diff_pixels +diff_image_path -> from summary.diff_path +next_fix_attempt -> assigned by the orchestrator (W6-3 for dashboard, W6-4 for Action Window, etc.) +evidence_id -> hash(target + run_started_at) once a Hermes proof event is emitted +``` + +The lane that owns the failing target should consume the diff PNG, fix the +component, and re-run the harness. This harness MUST NOT be modified to +"chase" the live UI; that is a baseline update (above) which is an +explicit operator action. + +## Constraints honored + +- No new npm dependency added: pixel-diff is delegated to Playwright's + bundled `pixelmatch` via `toHaveScreenshot`. +- No paid services: dev server is local; no cloud screenshot diffing. +- No secrets in tests or fixtures. +- Existing playwright.config.ts and playwright.e2e.config.ts are untouched. +- W6-3 and W6-4 components are not modified by this lane. + +## Sources + +1. Playwright snapshot / visual comparison guide - + +2. Playwright `toHaveScreenshot` API reference - + diff --git a/03_implementation/docs/handoffs/W8-12_VITE_REFRESH_FIX_2026-05-09.md b/03_implementation/docs/handoffs/W8-12_VITE_REFRESH_FIX_2026-05-09.md new file mode 100644 index 00000000..ff24ebc3 --- /dev/null +++ b/03_implementation/docs/handoffs/W8-12_VITE_REFRESH_FIX_2026-05-09.md @@ -0,0 +1,179 @@ +# W8-12 Vite Fast-Refresh Fix — 2026-05-09 + +## Lane + +**Agent**: W8-12 +**Lock owner**: `claude-w8-12-vite-fix` +**Task ID**: `W8-12-VITE-REFRESH-FIX-2026-05-09` +**Base branch**: `feat/hermes3d-7-complete-gui-repo-wiring` +**Working branch**: `claude/w8-12-vite-refresh-fix` + +## Problem + +W6-6's first visual-proof run failed all 11 live targets with the SPA never +mounting. Boot diagnostics revealed two cascading errors thrown at module +evaluation time on every `.tsx` user module: + +``` +ReferenceError: $RefreshReg$ is not defined +ReferenceError: $RefreshSig$ is not defined +``` + +These are global stubs that `@vitejs/plugin-react` is supposed to install on +`window` BEFORE any user module evaluates, via a virtual preamble injected +into `index.html`'s `transformIndexHtml` hook. + +## Root cause + +Two independent bugs, both fixed: + +### Bug A (Class 2 — mixed React + non-React exports) + +`03_implementation/ui/src/app/routes.tsx` exported only types and data +arrays (`TabDef`, `TABS`, `PRIMARY_TABS`) — zero React components, zero +JSX. The `.tsx` extension caused `@vitejs/plugin-react`'s OXC refresh +filter (default `/\.(mdx|js|jsx|ts|tsx)$/`) to instrument the file with +refresh-wrapper calls that referenced undefined globals. + +### Bug B (Class 4 — react-refresh runtime not loaded) + +In `vite@8.0.10` + `@vitejs/plugin-react@6.0.1`, the `transformIndexHtml` +preamble injection does not fire reliably for the plain SPA path. The +served `index.html` lacked the inline ` +``` + +These match exactly what the plugin's `preambleCode` installs (see +`node_modules/@vitejs/plugin-react/dist/index.js` lines 8-11). The +`injectIntoGlobalHook` import from `/@react-refresh` is omitted because +`/@react-refresh` is a vite-dev-only middleware route; the plugin's +per-module refresh wrapper imports it itself, so we do not need the HTML +preamble to import it. + +In production: +- `plugin-react` sets `skipFastRefresh = true` +- the OXC refresh-wrapper is never emitted +- the stubs are unreferenced dead code (~150 bytes in `dist/index.html`) +- zero `RefreshReg`/`RefreshSig` references in the production JS bundle + (verified via grep on `dist/assets/*.js` after `vite build`) + +## Verification + +### SPA mounts (boot-check) + +A temporary `tests/visual/boot-check.spec.ts` (later removed) confirmed: +- HTTP 200 on `/` +- ZERO `$RefreshReg$` or `$RefreshSig$` page errors +- `#root` element has content after mount + +PASS at 2.3s. + +### `npm run lint` (TypeScript) + +``` +> hermes3d-ui-final@0.1.0 lint +> tsc --noEmit +``` + +Exit 0, zero errors. + +### `vite build` (production) + +``` +✓ 2189 modules transformed. +dist/index.html 1.96 kB │ gzip: 0.98 kB +dist/assets/index-BcULLGw-.css 46.69 kB │ gzip: 9.88 kB +dist/assets/index-y1zl7lju.js 1,033.16 kB │ gzip: 265.36 kB +✓ built in 1.70s +``` + +`grep -c "RefreshReg\|RefreshSig" dist/assets/*.js` → `0` + +### W6-6 visual-proof harness (the original blocker) + +| Run | total | match | diff | error | skipped-future | error class | +|---------|-------|-------|------|-------|----------------|-------------| +| Before | 31 | 0 | 0 | 11 | 20 | "wait_test_id dashboard-root mounts" — SPA never mounted | +| After | 31 | 0 | 0 | 11 | 20 | timeouts + W6-6 snapshotPathTemplate bug — SPA mounts, harness path config issue | + +Critically: **no `$RefreshReg$` / `$RefreshSig$` errors in any row** after +the fix. The remaining 11 errors are downstream issues (W6-6 harness path +template, networkidle timeouts on the GUI API) outside W8-12 scope. + +## Sources + +1. `@vitejs/plugin-react@6.0.1` README — "Initialize HMR runtime in + client entrypoint": + + Documents that SSR/non-`transformIndexHtml` apps must import the + preamble at the entry. Confirms the preamble defines the global + `$RefreshReg$` / `$RefreshSig$` stubs. + +2. React Fast-Refresh package: + + Confirms `react-refresh` "implements the wiring necessary to + integrate Fast Refresh into bundlers" — bundler integrations are + responsible for installing the runtime hooks before user component + modules evaluate. + +## Locks + +| File | Action | +|---------------------------------------------------|---------| +| `03_implementation/ui/src/app/routes.tsx` | locked then released after rename | +| `03_implementation/ui/src/app/routes.ts` | locked then released after rename | +| `03_implementation/ui/src/main.tsx` | locked, no edit needed (reverted), released | +| `03_implementation/ui/index.html` | locked, edited, released | + +`vite.config.ts` and `package.json` were skipped after handoff was +declined-by-design (W6-3 / W6-4 still own them; the index.html-only fix +made handoff unnecessary). + +## Hermes evidence chain + +- Hermes evidence chain: PASS +- Task ID: `W8-12-VITE-REFRESH-FIX-2026-05-09` +- hermes_run_gate: SPA boot-check PASS, npm lint PASS, vite build PASS, + visual-proof harness re-run shows fix is upstream-resolved (downstream + W6-6 errors remain) +- Stacked-on: `feat/hermes3d-7-complete-gui-repo-wiring` +- Cherry-picks W6-6 commits `9d9b653` + `43c4fbb` so the visual-proof + harness can verify the fix. W6-6's PR is opened separately (Part B). + +## Files changed + +- `03_implementation/ui/src/app/routes.tsx` → `03_implementation/ui/src/app/routes.ts` (rename) +- `03_implementation/ui/index.html` (4 lines of inline ` diff --git a/03_implementation/ui/playwright.visual.config.ts b/03_implementation/ui/playwright.visual.config.ts new file mode 100644 index 00000000..28a8d75c --- /dev/null +++ b/03_implementation/ui/playwright.visual.config.ts @@ -0,0 +1,76 @@ +/** + * W6-6 Playwright config dedicated to visual proof against Images-GUI/. + * + * Differences from playwright.e2e.config.ts: + * - testDir: tests/visual (so the visual harness is a separate suite) + * - updateSnapshots: "none" -> never auto-create or auto-update reference + * PNGs; missing baselines fail the test (refresh is an explicit operator + * action via --update-snapshots flag) + * - snapshotPathTemplate: "{arg}{ext}" -> when a test calls + * toHaveScreenshot([...segments]) Playwright path.joins the segments and + * resolves the result against this configDir, which lets the spec point + * at the actual Images-GUI/ reference PNGs (not Playwright-managed + * __snapshots__/ duplicates). + * - reporter: visual-proof-reporter.ts emits the JSON summary to + * 03_implementation/docs/evidence/visual_proof_2026-05-09/summary.json. + * + * Sources: + * - Playwright snapshot/visual-comparison docs: + * https://playwright.dev/docs/test-snapshots + * - testConfig.snapshotPathTemplate reference: + * https://playwright.dev/docs/api/class-testconfig#test-config-snapshot-path-template + */ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/visual", + fullyParallel: false, + retries: 0, + workers: 1, + // "none" prevents Playwright from ever writing or updating reference PNGs. + // To intentionally refresh the baseline an operator must run with + // --update-snapshots (which overrides this) and review the diff in PR. + updateSnapshots: "none", + // {arg}{ext} sends the array passed to toHaveScreenshot directly through + // path.join + path.resolve(configDir, ...), which is how the visual spec + // points at Images-GUI/ relative paths. + snapshotPathTemplate: "{arg}{ext}", + reporter: [ + ["list"], + ["./tests/visual/visual-proof-reporter.ts"], + ], + outputDir: "test-results/visual", + expect: { + // Diff threshold per pixel; per-target maxDiffPixelRatio overrides this + // expect-wide knob. Animations disabled to remove a major source of flake. + toHaveScreenshot: { + animations: "disabled", + threshold: 0.2, + maxDiffPixelRatio: 0.1, + }, + }, + use: { + baseURL: "http://localhost:5173", + viewport: { width: 1920, height: 1080 }, + deviceScaleFactor: 1, + headless: true, + screenshot: "only-on-failure", + trace: "off", + }, + projects: [ + { + name: "visual-chromium-1920x1080", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1920, height: 1080 }, + deviceScaleFactor: 1, + }, + }, + ], + webServer: { + command: "node scripts/start-e2e-stack.mjs", + url: "http://127.0.0.1:5173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/03_implementation/ui/src/app/routes.tsx b/03_implementation/ui/src/app/routes.ts similarity index 100% rename from 03_implementation/ui/src/app/routes.tsx rename to 03_implementation/ui/src/app/routes.ts diff --git a/03_implementation/ui/tests/visual/visual-proof-reporter.ts b/03_implementation/ui/tests/visual/visual-proof-reporter.ts new file mode 100644 index 00000000..ba7529fe --- /dev/null +++ b/03_implementation/ui/tests/visual/visual-proof-reporter.ts @@ -0,0 +1,299 @@ +/** + * W6-6 Custom Playwright reporter for visual-proof.spec.ts. + * + * Reads the test annotations emitted by visual-proof.spec.ts and produces a + * single JSON summary at: + * 03_implementation/docs/evidence/visual_proof_2026-05-09/summary.json + * + * Each row records: + * { target, status, route, reference, tolerance, + * diff_pixels?, total_pixels?, ratio?, + * evidence_path, diff_path?, error?, started_at, finished_at } + * + * Status values: + * - "match" : visual diff <= tolerance + * - "diff" : visual diff > tolerance (FAIL) + * - "missing-baseline" : no reference PNG found at the expected path + * - "skipped-future" : status="future" target owned by another lane + * - "skipped-missing-reference": reference path was not on disk + * - "error" : runtime error (timeout, navigation crash, etc.) + * + * Sources: + * - Playwright reporter API: + * https://playwright.dev/docs/api/class-reporter + * - Playwright snapshot/visual-comparison docs: + * https://playwright.dev/docs/test-snapshots + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + FullConfig, + FullResult, + Reporter, + TestCase, + TestResult, +} from "@playwright/test/reporter"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, "..", "..", "..", ".."); +const TARGETS_PATH = path.join(HERE, "visual-targets.json"); + +interface ManifestTarget { + target: string; + reference: string; + route: string; + status: "live" | "future"; + tolerance: number; + notes?: string; +} + +interface ManifestFile { + targets: ManifestTarget[]; +} + +let MANIFEST: Record = {}; +try { + const raw = JSON.parse(fs.readFileSync(TARGETS_PATH, "utf-8")) as ManifestFile; + MANIFEST = Object.fromEntries(raw.targets.map((t) => [t.target, t])); +} catch { + MANIFEST = {}; +} +const SUMMARY_DIR = path.join( + REPO_ROOT, + "03_implementation", + "docs", + "evidence", + "visual_proof_2026-05-09", +); +const SUMMARY_PATH = path.join(SUMMARY_DIR, "summary.json"); + +interface VisualRow { + target: string; + status: + | "match" + | "diff" + | "missing-baseline" + | "skipped-future" + | "skipped-missing-reference" + | "error"; + route: string | null; + reference: string | null; + tolerance: number | null; + diff_pixels?: number; + total_pixels?: number; + ratio?: number; + evidence_path: string | null; + diff_path: string | null; + error: string | null; + started_at: string | null; + finished_at: string | null; +} + +function safeJsonParse(s: string): T | null { + try { + return JSON.parse(s) as T; + } catch { + return null; + } +} + +class VisualProofReporter implements Reporter { + private rows: VisualRow[] = []; + private startedAt: string = ""; + + onBegin(_config: FullConfig): void { + this.startedAt = new Date().toISOString(); + } + + onTestEnd(test: TestCase, result: TestResult): void { + // Each visual-proof.spec.ts test pushes contract annotations: + // visual-proof-target -> sent immediately on test start + // visual-proof-result -> sent only on a successful match + // visual-proof -> single annotation when skipped (only fires + // when the wrapped fn is invoked; for + // test.skip(reason, fn) it does NOT fire, so we + // fall back to title-based lookup below) + const annotations = test.annotations.concat(result.annotations ?? []); + let target: string | null = null; + let route: string | null = null; + let reference: string | null = null; + let tolerance: number | null = null; + let matched = false; + let skippedReason: string | null = null; + let skippedKind: "future" | "missing-reference" | null = null; + + // Extract target name from the describe title which is "visual: ". + // This is the only reliable way to identify a skipped test since the + // wrapped fn body never runs and our annotations never fire. + for (const titleSeg of test.titlePath()) { + const m = /^visual:\s+([\w_-]+)$/.exec(titleSeg.trim()); + if (m) { + target = m[1]; + break; + } + } + if (target && MANIFEST[target]) { + const manifest = MANIFEST[target]; + route = route ?? manifest.route; + reference = reference ?? manifest.reference; + tolerance = tolerance ?? manifest.tolerance; + if (manifest.status === "future") { + skippedKind = "future"; + skippedReason = manifest.notes ?? "future"; + } else { + // status === "live"; if the test was skipped we infer the reason + // is the reference PNG was not on disk. + const refAbs = path.resolve(REPO_ROOT, manifest.reference); + if (!fs.existsSync(refAbs)) { + skippedKind = "missing-reference"; + } + } + } + + for (const ann of annotations) { + const desc = ann.description ?? ""; + const parsed = + typeof desc === "string" ? safeJsonParse>(desc) : null; + if (!parsed) continue; + if (typeof parsed.target === "string") target = parsed.target; + if (typeof parsed.route === "string") route = parsed.route; + if (typeof parsed.reference === "string") reference = parsed.reference; + if (typeof parsed.tolerance === "number") tolerance = parsed.tolerance; + + if (ann.type === "visual-proof-result" && parsed.status === "match") { + matched = true; + } + if (ann.type === "visual-proof") { + if (parsed.status === "skipped-future") { + skippedKind = "future"; + if (typeof parsed.reason === "string") skippedReason = parsed.reason; + } else if (parsed.status === "skipped-missing-reference") { + skippedKind = "missing-reference"; + } + } + } + + if (!target) { + // Not one of our visual targets; ignore. + return; + } + + const startedAt = new Date(result.startTime).toISOString(); + const finishedAt = new Date(result.startTime.getTime() + result.duration).toISOString(); + + let status: VisualRow["status"]; + let errorMessage: string | null = null; + let evidencePath: string | null = null; + let diffPath: string | null = null; + let diffPixels: number | undefined; + let totalPixels: number | undefined; + let ratio: number | undefined; + + // Look for the diff/actual attachments Playwright emits when the screenshot + // assertion fails. These give us diff_pixels and the diff PNG path. + for (const att of result.attachments ?? []) { + if (!att.name) continue; + if (/expected/.test(att.name) && att.path) { + // The expected image is the reference PNG itself; record its path so + // reviewers can hop directly to Images-GUI/. + } else if (/actual/.test(att.name) && att.path) { + evidencePath = path.relative(REPO_ROOT, att.path).replace(/\\/g, "/"); + } else if (/diff/.test(att.name) && att.path) { + diffPath = path.relative(REPO_ROOT, att.path).replace(/\\/g, "/"); + } + } + + // Inspect the test error for diff_pixels / ratio info that Playwright + // emits on toHaveScreenshot failure. + if (result.status === "failed" || result.status === "timedOut") { + const msg = result.error?.message ?? ""; + errorMessage = msg.split("\n").slice(0, 2).join(" ").slice(0, 600); + // Playwright failure messages contain phrases like: + // "12345 pixels (ratio 0.06 of all image pixels) are different." + const pixMatch = msg.match(/(\d+)\s+pixels?\s+\(ratio\s+([0-9.]+)/i); + if (pixMatch) { + diffPixels = Number(pixMatch[1]); + ratio = Number(pixMatch[2]); + } + // Missing baseline messages: + // "A snapshot doesn't exist at ..., writing actual." + // "Error: A snapshot doesn't exist at ..." + if (/snapshot doesn't exist|does not exist/i.test(msg)) { + status = "missing-baseline"; + } else if (diffPixels !== undefined) { + status = "diff"; + } else { + status = "error"; + } + } else if (result.status === "passed" && matched) { + status = "match"; + } else if (result.status === "skipped") { + status = + skippedKind === "future" + ? "skipped-future" + : skippedKind === "missing-reference" + ? "skipped-missing-reference" + : "skipped-future"; + if (skippedReason) errorMessage = skippedReason; + } else if (result.status === "passed") { + // Passed without a match annotation — defensive fallback. + status = "match"; + } else { + status = "error"; + } + + this.rows.push({ + target, + status, + route, + reference, + tolerance, + diff_pixels: diffPixels, + total_pixels: totalPixels, + ratio, + evidence_path: evidencePath, + diff_path: diffPath, + error: errorMessage, + started_at: startedAt, + finished_at: finishedAt, + }); + } + + async onEnd(result: FullResult): Promise { + fs.mkdirSync(SUMMARY_DIR, { recursive: true }); + + const counts = this.rows.reduce( + (acc, row) => { + acc[row.status] = (acc[row.status] ?? 0) + 1; + acc.total += 1; + return acc; + }, + { total: 0 } as Record, + ); + + const summary = { + schema_version: 1, + generated_at: new Date().toISOString(), + started_at: this.startedAt, + run_status: result.status, + owner: "claude-w6-6-visual-proof", + lane: "W6-6 Playwright visual proof against Images-GUI/", + counts, + rows: this.rows.sort((a, b) => a.target.localeCompare(b.target)), + }; + + fs.writeFileSync(SUMMARY_PATH, JSON.stringify(summary, null, 2) + "\n"); + // Also write a brief stdout summary so CI logs surface the totals. + const summaryLine = + `[visual-proof] total=${counts.total} ` + + Object.entries(counts) + .filter(([k]) => k !== "total") + .map(([k, v]) => `${k}=${v}`) + .join(" "); + process.stdout.write(`${summaryLine}\n`); + process.stdout.write(`[visual-proof] summary written to ${SUMMARY_PATH}\n`); + } +} + +export default VisualProofReporter; diff --git a/03_implementation/ui/tests/visual/visual-proof.spec.ts b/03_implementation/ui/tests/visual/visual-proof.spec.ts new file mode 100644 index 00000000..ba7c6359 --- /dev/null +++ b/03_implementation/ui/tests/visual/visual-proof.spec.ts @@ -0,0 +1,182 @@ +/** + * W6-6 Playwright visual-proof harness against the Images-GUI/ reference pack. + * + * For each entry in visual-targets.json with status === "live": + * 1. Navigate to target.route on the live dev server (via webServer) + * 2. Wait for wait_test_id, networkidle, and a 500ms quiet period + * 3. Call expect(page).toHaveScreenshot([...path]) with the target's + * tolerance as maxDiffPixelRatio. Playwright uses its bundled + * pixelmatch implementation; diff PNGs land under test-results/ when a + * target exceeds tolerance. + * + * Targets with status === "future" call test.skip() with a reason; the JSON + * reporter still records a "skipped" row so reviewers see all 31 references. + * + * Sources cited: + * - Playwright snapshot/visual-comparison docs: + * https://playwright.dev/docs/test-snapshots + * - Playwright toHaveScreenshot API: + * https://playwright.dev/docs/api/class-pageassertions#page-assertions-to-have-screenshot + * + * No-fake / no-paid contract: + * - This spec NEVER auto-updates reference PNGs. The visual config sets + * updateSnapshots: "none". Refresh requires an explicit operator action + * (run with --update-snapshots, then review diff in the PR). + * - This spec only reports diff data; component fixes are out of scope and + * are owned by W6-3 / W6-4. + * - No paid services are used; everything runs against the local dev server + * started by scripts/start-e2e-stack.mjs. + * + * Snapshot path resolution: snapshotPathTemplate is configured to "{arg}{ext}" + * so that an array-form arg passed to toHaveScreenshot is path.join'd as-is + * and then resolved relative to the playwright config dir (UI_ROOT). We pass + * the array as a relative chain back to the repo root and into Images-GUI/, + * which keeps the actual reference PNG as the single source of truth. + */ +import { test, expect, type Page } from "@playwright/test"; +import path from "node:path"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +interface VisualTarget { + target: string; + reference: string; + route: string; + status: "live" | "future"; + tolerance: number; + wait_test_id?: string; + notes?: string; +} + +interface VisualTargetsFile { + description: string; + owner: string; + created: string; + tolerance_default: number; + viewport: { width: number; height: number }; + targets: VisualTarget[]; +} + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const TARGETS_PATH = path.join(HERE, "visual-targets.json"); +const UI_ROOT = path.resolve(HERE, "..", ".."); +const REPO_ROOT = path.resolve(UI_ROOT, "..", ".."); +const targetsFile = JSON.parse(fs.readFileSync(TARGETS_PATH, "utf-8")) as VisualTargetsFile; + +/** + * Wait for the SPA to settle: networkidle plus quietMs of no nav. + * 500ms is the W5-3 smoke convention; tighten via env var if needed. + */ +async function waitForStable(page: Page, quietMs = 500): Promise { + await page.waitForLoadState("domcontentloaded"); + await page.waitForLoadState("networkidle"); + await page.waitForTimeout(quietMs); +} + +/** + * Build the snapshot-name array for toHaveScreenshot from a reference path. + * Reference paths in visual-targets.json are repo-relative (e.g. + * "Images-GUI/01-dashboard-modes/advanced-dashboard-a.png"). We need a path + * relative to the playwright config dir (UI_ROOT) so that the configured + * snapshotPathTemplate "{arg}{ext}" resolves to the actual reference PNG. + */ +function snapshotPathSegments(referenceRepoRel: string): string[] { + const referenceAbs = path.resolve(REPO_ROOT, referenceRepoRel); + const fromUiRoot = path.relative(UI_ROOT, referenceAbs); + // Keep the .png on the last segment so toHaveScreenshot infers the ext; + // splitting by both separators tolerates Windows backslashes. + return fromUiRoot.split(/[\\/]+/); +} + +for (const target of targetsFile.targets) { + const referenceAbs = path.resolve(REPO_ROOT, target.reference); + + test.describe(`visual: ${target.target}`, () => { + if (target.status === "future") { + // Future targets are owned by other Wave 6 lanes (W6-3 dashboard mode + // switcher, W6-4 Action Window, theme switcher, custom dashboards). + // We register them as skipped tests so the reporter row exists. + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + `${target.target} future-target — owned by Wave 6 lane 3/4 (${target.notes ?? "future"})`, + async () => { + test.info().annotations.push({ + type: "visual-proof", + description: JSON.stringify({ + target: target.target, + reference: target.reference, + route: target.route, + tolerance: target.tolerance, + status: "skipped-future", + reason: target.notes ?? "future", + }), + }); + }, + ); + return; + } + + if (!fs.existsSync(referenceAbs)) { + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + `${target.target} missing-reference at ${target.reference}`, + async () => { + test.info().annotations.push({ + type: "visual-proof", + description: JSON.stringify({ + target: target.target, + reference: target.reference, + route: target.route, + tolerance: target.tolerance, + status: "skipped-missing-reference", + }), + }); + }, + ); + return; + } + + test(`matches reference within tolerance ${target.tolerance}`, async ({ page }) => { + // Annotate up-front so the reporter has the contract row even if the + // test fails mid-flight. + test.info().annotations.push({ + type: "visual-proof-target", + description: JSON.stringify({ + target: target.target, + reference: target.reference, + route: target.route, + tolerance: target.tolerance, + }), + }); + + await page.goto(target.route); + if (target.wait_test_id) { + await expect( + page.getByTestId(target.wait_test_id), + `${target.target} wait_test_id ${target.wait_test_id} mounts`, + ).toBeVisible({ timeout: 15_000 }); + } + await waitForStable(page, 500); + + const segments = snapshotPathSegments(target.reference); + + await expect(page).toHaveScreenshot(segments, { + fullPage: true, + animations: "disabled", + maxDiffPixelRatio: target.tolerance, + }); + + // After a successful match, append a final "match" annotation. If the + // assertion above failed, control never reaches here and the reporter + // records the row as a "diff" failure. + test.info().annotations.push({ + type: "visual-proof-result", + description: JSON.stringify({ + target: target.target, + status: "match", + tolerance: target.tolerance, + }), + }); + }); + }); +} diff --git a/03_implementation/ui/tests/visual/visual-targets.json b/03_implementation/ui/tests/visual/visual-targets.json new file mode 100644 index 00000000..3b4f50a5 --- /dev/null +++ b/03_implementation/ui/tests/visual/visual-targets.json @@ -0,0 +1,289 @@ +{ + "$schema": "./visual-targets.schema.json", + "description": "Visual proof targets for Hermes3D OS GUI. Each entry maps a reference PNG from Images-GUI/ to a hash route + tolerance. Targets without a working route are marked status='future' and skipped at runtime by visual-proof.spec.ts.", + "owner": "claude-w6-6-visual-proof", + "created": "2026-05-09", + "tolerance_default": 0.1, + "viewport": { "width": 1920, "height": 1080 }, + "targets": [ + { + "target": "01_dashboard_advanced_a", + "reference": "Images-GUI/01-dashboard-modes/advanced-dashboard-a.png", + "route": "/#dashboard", + "status": "live", + "tolerance": 0.1, + "wait_test_id": "dashboard-root", + "notes": "Advanced dashboard mode primary view A. Owned by Wave 6 lane 3 (W6-3 dashboard mode switcher) for parity work." + }, + { + "target": "01_dashboard_advanced_b", + "reference": "Images-GUI/01-dashboard-modes/advanced-dashboard-b.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.1, + "wait_test_id": "dashboard-root", + "notes": "Advanced dashboard alt view B. Future target — owned by Wave 6 lane 3." + }, + { + "target": "01_dashboard_simple_a", + "reference": "Images-GUI/01-dashboard-modes/simple-dashboard-a.png", + "route": "/?ui=simple#dashboard", + "status": "future", + "tolerance": 0.15, + "wait_test_id": "dashboard-root", + "notes": "Simple dashboard mode A. Mode switcher is being added by W6-3." + }, + { + "target": "01_dashboard_simple_b", + "reference": "Images-GUI/01-dashboard-modes/simple-dashboard-b.png", + "route": "/?ui=simple#dashboard", + "status": "future", + "tolerance": 0.15, + "wait_test_id": "dashboard-root", + "notes": "Simple dashboard mode B. Future target — owned by Wave 6 lane 3." + }, + { + "target": "01_dashboard_custom_a", + "reference": "Images-GUI/01-dashboard-modes/custom-dashboard-a.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.15, + "wait_test_id": "dashboard-root", + "notes": "Custom dashboard with draggable widgets — not implemented yet. Future target — owned by Wave 6 lane 3 / 4." + }, + { + "target": "01_dashboard_custom_b", + "reference": "Images-GUI/01-dashboard-modes/custom-dashboard-b.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.15, + "wait_test_id": "dashboard-root", + "notes": "Custom dashboard alt B. Future target." + }, + { + "target": "02_primary_autopilot_design_gen3d_jobs", + "reference": "Images-GUI/02-primary-pages/primary-tabs-autopilot-design-gen3d-jobs.png", + "route": "/#autopilot", + "status": "live", + "tolerance": 0.12, + "wait_test_id": "autopilot-root", + "notes": "Composite reference of 4 primary tabs. Compared against Autopilot first; per-tab specs cover the rest." + }, + { + "target": "02_primary_printers_observe_agents_learning", + "reference": "Images-GUI/02-primary-pages/primary-tabs-printers-observe-agents-learning.png", + "route": "/#printers", + "status": "live", + "tolerance": 0.12, + "wait_test_id": "printers-root", + "notes": "Composite reference. Compared against Printers first." + }, + { + "target": "02_primary_artifacts_approvals_plugins_roadmap", + "reference": "Images-GUI/02-primary-pages/primary-tabs-artifacts-approvals-plugins-roadmap.png", + "route": "/#artifacts", + "status": "live", + "tolerance": 0.12, + "wait_test_id": "artifacts-root", + "notes": "Composite reference. Compared against Artifacts first." + }, + { + "target": "03_settings_subtabs_all", + "reference": "Images-GUI/03-settings-voice/settings-subtabs-all.png", + "route": "/#settings", + "status": "live", + "tolerance": 0.12, + "wait_test_id": "settings-root", + "notes": "All 6 settings subtabs (providers/agents/printers/environment/updates/about)." + }, + { + "target": "03_voice_communication_subtabs", + "reference": "Images-GUI/03-settings-voice/voice-communication-subtabs.png", + "route": "/#voice", + "status": "live", + "tolerance": 0.12, + "wait_test_id": "voice-root", + "notes": "Voice subtabs (browser/transcript/proof-review)." + }, + { + "target": "04_source_os_60_app_coverage_matrix", + "reference": "Images-GUI/04-source-os/source-os-60-app-coverage-matrix.png", + "route": "/#sources", + "status": "live", + "tolerance": 0.12, + "wait_test_id": "source-os-root", + "notes": "Source OS 60-app coverage matrix view." + }, + { + "target": "04_source_os_core_categories", + "reference": "Images-GUI/04-source-os/source-os-core-categories.png", + "route": "/#sources", + "status": "live", + "tolerance": 0.12, + "wait_test_id": "source-os-root", + "notes": "Source OS core categories landing." + }, + { + "target": "04_source_os_remaining_categories", + "reference": "Images-GUI/04-source-os/source-os-remaining-categories.png", + "route": "/#sources", + "status": "live", + "tolerance": 0.12, + "wait_test_id": "source-os-root", + "notes": "Source OS remaining categories. Same root, different sub-section reference." + }, + { + "target": "05_action_window_core_apps", + "reference": "Images-GUI/05-action-windows/action-window-core-apps.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Action Window core-apps template. Action Window is being added by W6-4." + }, + { + "target": "05_action_window_advanced_tools", + "reference": "Images-GUI/05-action-windows/action-window-advanced-tools.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Action Window advanced-tools template. Future target — owned by W6-4." + }, + { + "target": "06_states_responsive_reference", + "reference": "Images-GUI/06-states-responsive/states-responsive-reference.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Loading/empty/blocked/recovering responsive states reference. Per-state specs are out of scope for this lane." + }, + { + "target": "07_plugins_skills_mcp_app_connectors", + "reference": "Images-GUI/07-plugins-skills-mcp/plugins-skills-mcp-app-connectors.png", + "route": "/#plugins", + "status": "live", + "tolerance": 0.12, + "wait_test_id": "plugins-root", + "notes": "Plugins / Skills / MCP / App Connectors landing." + }, + { + "target": "08_workflow_printqueue_files_logs", + "reference": "Images-GUI/08-app-utility-pages/workflow-printqueue-files-logs.png", + "route": "/#jobs", + "status": "future", + "tolerance": 0.15, + "wait_test_id": "jobs-root", + "notes": "Composite of workflow + print queue + files + logs. No single route maps yet; uses Jobs as anchor. Future target — split by lane 3/4." + }, + { + "target": "08_proof_health_notifications_safety", + "reference": "Images-GUI/08-app-utility-pages/proof-health-notifications-safety.png", + "route": "/#observe", + "status": "future", + "tolerance": 0.15, + "wait_test_id": "observe-root", + "notes": "Composite of proof + health + notifications + safety. Anchored to Observe. Future target." + }, + { + "target": "09_theme_variants_reference", + "reference": "Images-GUI/09-themes/theme-variants-reference.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.25, + "wait_test_id": "dashboard-root", + "notes": "Theme palette reference (default/cyberpunk/matrix/tron/forge/aurora). Theme switcher not implemented; future target." + }, + { + "target": "00_user_hermes3d", + "reference": "Images-GUI/00-user-current-downloads/Hermes3D.png", + "route": "/", + "status": "live", + "tolerance": 0.15, + "wait_test_id": "dashboard-root", + "notes": "User-approved baseline of the app shell at default route." + }, + { + "target": "00_user_generated_1", + "reference": "Images-GUI/00-user-current-downloads/Generated image 1.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Generated reference 1 — direction PNG, not a 1:1 page snapshot. Skipped from strict comparison; tracked for visual direction only." + }, + { + "target": "00_user_generated_2", + "reference": "Images-GUI/00-user-current-downloads/Generated image 2.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Generated reference 2." + }, + { + "target": "00_user_generated_3", + "reference": "Images-GUI/00-user-current-downloads/Generated image 3.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Generated reference 3." + }, + { + "target": "00_user_generated_4", + "reference": "Images-GUI/00-user-current-downloads/Generated image 4.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Generated reference 4." + }, + { + "target": "00_user_generated_5", + "reference": "Images-GUI/00-user-current-downloads/Generated image 5.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Generated reference 5." + }, + { + "target": "00_user_generated_6", + "reference": "Images-GUI/00-user-current-downloads/Generated image 6.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Generated reference 6." + }, + { + "target": "00_user_generated_7", + "reference": "Images-GUI/00-user-current-downloads/Generated image 7.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Generated reference 7." + }, + { + "target": "00_user_generated_8", + "reference": "Images-GUI/00-user-current-downloads/Generated image 8.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Generated reference 8." + }, + { + "target": "00_user_generated_9", + "reference": "Images-GUI/00-user-current-downloads/Generated image 9.png", + "route": "/#dashboard", + "status": "future", + "tolerance": 0.2, + "wait_test_id": "dashboard-root", + "notes": "Generated reference 9." + } + ] +}