chati-dev 4.2.1 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -53
- package/bin/chati.js +150 -5
- package/framework/agents/build/dev.md +509 -76
- package/framework/agents/deploy/devops.md +40 -25
- package/framework/agents/discover/brief.md +156 -22
- package/framework/agents/discover/brownfield-wu.md +24 -14
- package/framework/agents/discover/greenfield-wu.md +100 -14
- package/framework/agents/plan/architect-data-engineer.md +6 -6
- package/framework/agents/plan/architect-system.md +46 -12
- package/framework/agents/plan/architect.md +40 -20
- package/framework/agents/plan/detail.md +36 -24
- package/framework/agents/plan/phases.md +36 -26
- package/framework/agents/plan/tasks.md +114 -33
- package/framework/agents/plan/ux-brand-architect.md +240 -8
- package/framework/agents/plan/ux-component-engineer.md +28 -13
- package/framework/agents/plan/ux-researcher.md +7 -6
- package/framework/agents/plan/ux.md +55 -22
- package/framework/agents/quality/qa-implementation.md +143 -74
- package/framework/agents/quality/qa-planning.md +115 -42
- package/framework/agents/quality/qa-visual.md +439 -0
- package/framework/agents/shared/visualizer.md +128 -0
- package/framework/config.yaml +8 -7
- package/framework/constitution.md +127 -43
- package/framework/context/governance.md +12 -7
- package/framework/context/quality.md +6 -5
- package/framework/context/root.md +6 -6
- package/framework/data/entity-registry.yaml +377 -4
- package/framework/data/model-limits.json +19 -0
- package/framework/domains/agents/qa-visual.yaml +74 -0
- package/framework/domains/constitution.yaml +46 -2
- package/framework/domains/workflows/greenfield-fullstack.yaml +2 -2
- package/framework/hooks/advance-trigger.js +131 -0
- package/framework/hooks/brief-validator.js +83 -0
- package/framework/hooks/constitution-guard.js +91 -36
- package/framework/hooks/license-guard.js +129 -190
- package/framework/hooks/mode-governance.js +68 -16
- package/framework/hooks/model-governance.js +19 -8
- package/framework/hooks/package.json +3 -0
- package/framework/hooks/post-dev.js +207 -0
- package/framework/hooks/prism-engine.js +288 -105
- package/framework/hooks/read-protection.js +37 -9
- package/framework/hooks/reasoning-escalator.js +371 -0
- package/framework/hooks/reference-trigger.js +117 -0
- package/framework/hooks/session-digest.js +94 -20
- package/framework/hooks/settings.json +32 -1
- package/framework/hooks/style-guard.js +52 -13
- package/framework/hooks/team-quality-gate.js +58 -25
- package/framework/hooks/undercover-guard.js +34 -13
- package/framework/i18n/en.yaml +3 -3
- package/framework/i18n/es.yaml +3 -3
- package/framework/i18n/fr.yaml +3 -3
- package/framework/i18n/pt.yaml +3 -3
- package/framework/intelligence/context-engine.md +4 -5
- package/framework/intelligence/decision-engine.md +1 -1
- package/framework/intelligence/hooks-performance.md +3 -3
- package/framework/migrations/v1.0-to-v1.1.yaml +1 -1
- package/framework/migrations/v1.4-to-v2.0.yaml +11 -11
- package/framework/migrations/v4.0-to-v4.1.yaml +2 -2
- package/framework/migrations/v4.2-to-v4.3.yaml +29 -0
- package/framework/orchestrator/chati-router.js +387 -0
- package/framework/orchestrator/chati-update.md +40 -40
- package/framework/orchestrator/chati.md +318 -115
- package/framework/scaffold/motion-premium/README.md +89 -0
- package/framework/scaffold/motion-premium/app/globals.css.template +400 -0
- package/framework/scaffold/motion-premium/app/layout.tsx.template +110 -0
- package/framework/scaffold/motion-premium/components/animation/BackgroundCrossfadeProvider.tsx.template +170 -0
- package/framework/scaffold/motion-premium/components/animation/LenisProvider.tsx.template +49 -0
- package/framework/scaffold/motion-premium/components/animation/PageTransitionWrapper.tsx.template +83 -0
- package/framework/scaffold/motion-premium/components/animation/Preloader.tsx.template +171 -0
- package/framework/scaffold/motion-premium/components/ui/Container.tsx.template +69 -0
- package/framework/scaffold/motion-premium/components/ui/PageSection.tsx.template +74 -0
- package/framework/scaffold/motion-premium/lib/animations/gsap.ts.template +112 -0
- package/framework/scaffold/motion-premium/lib/animations/refreshCoordinator.ts.template +75 -0
- package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +119 -0
- package/framework/scaffold/motion-premium/lib/animations/useGsapContext.ts.template +92 -0
- package/framework/scaffold/motion-premium/lib/animations/useScrollSnapStepper.ts.template +265 -0
- package/framework/scaffold/motion-premium/lib/animations/useSmoothScroll.ts.template +67 -0
- package/framework/scaffold/motion-premium/lib/brand.ts.template +43 -0
- package/framework/scaffold/motion-premium/scaffold.yaml +174 -0
- package/framework/scaffold/motion-premium-3d/README.md +80 -0
- package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoCanvas.tsx.template +81 -0
- package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/ScrollDemoClient.tsx.template +75 -0
- package/framework/scaffold/motion-premium-3d/app/(3d)/scroll-demo/page.tsx.template +26 -0
- package/framework/scaffold/motion-premium-3d/components/3d/CameraRig.tsx.template +100 -0
- package/framework/scaffold/motion-premium-3d/components/3d/CanvasProvider.tsx.template +85 -0
- package/framework/scaffold/motion-premium-3d/components/3d/InvalidateOnScroll.tsx.template +51 -0
- package/framework/scaffold/motion-premium-3d/components/3d/MeshCrossfade.tsx.template +79 -0
- package/framework/scaffold/motion-premium-3d/components/3d/ScrollCrossfade.tsx.template +88 -0
- package/framework/scaffold/motion-premium-3d/components/3d/ScrollScene.tsx.template +121 -0
- package/framework/scaffold/motion-premium-3d/components/webgl/SceneFallback.tsx.template +65 -0
- package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +68 -0
- package/framework/scaffold/motion-premium-3d/lib/webgl/detect.ts.template +69 -0
- package/framework/scaffold/motion-premium-3d/scaffold.yaml +133 -0
- package/framework/schemas/session.schema.json +109 -21
- package/framework/scripts/reference-capture.js +430 -0
- package/framework/scripts/visual-qa.js +674 -0
- package/framework/tasks/orchestrator-handoff.md +1 -1
- package/framework/tasks/orchestrator-resume.md +1 -1
- package/framework/tasks/orchestrator-route.md +1 -1
- package/framework/tasks/orchestrator-status.md +3 -3
- package/framework/tasks/qa-planning-gate-define.md +1 -1
- package/framework/templates/brandbook-html-tmpl.md +1 -1
- package/framework/templates/brandbook-tmpl.yaml +1 -1
- package/framework/templates/component-spec-tmpl.yaml +1 -1
- package/framework/templates/design-token-tmpl.yaml +1 -1
- package/framework/templates/icon-system-tmpl.yaml +1 -1
- package/framework/templates/team-planning-tasks.yaml +6 -5
- package/framework/workflows/brownfield-discovery.yaml +2 -2
- package/framework/workflows/brownfield-fullstack.yaml +15 -11
- package/framework/workflows/brownfield-service.yaml +14 -10
- package/framework/workflows/brownfield-ui.yaml +15 -11
- package/framework/workflows/greenfield-fullstack.yaml +16 -13
- package/framework/workflows/quick-flow.yaml +3 -3
- package/framework/workflows/standard-flow.yaml +12 -9
- package/package.json +10 -5
- package/src/autonomy/autonomous-gate.js +1 -0
- package/src/autonomy/build-state.js +1 -2
- package/src/autonomy/progress-reporter.js +1 -1
- package/src/config/agent-customizer.js +11 -3
- package/src/config/claude-settings-generator.js +226 -0
- package/src/config/context-file-generator.js +41 -21
- package/src/config/framework-adapter.js +1 -0
- package/src/config/gemini-hooks-generator.js +76 -6
- package/src/config/mcp-configs.js +1 -0
- package/src/context/layers/l1-global.js +2 -1
- package/src/dashboard/data-reader.js +4 -3
- package/src/dashboard/layout.js +2 -1
- package/src/decision/analyzer.js +6 -30
- package/src/decision/engine.js +4 -28
- package/src/decision/registry-healer.js +3 -2
- package/src/decision/registry-updater.js +23 -14
- package/src/extensions/loader.js +2 -8
- package/src/gates/g1-planning-complete.js +2 -1
- package/src/gates/g2-qa-planning.js +2 -1
- package/src/gates/g3-implementation.js +2 -1
- package/src/gates/g4-qa-implementation.js +3 -2
- package/src/gates/g5-deploy-ready.js +2 -1
- package/src/health/engine.js +4 -3
- package/src/installer/core.js +557 -81
- package/src/installer/preflight.js +131 -0
- package/src/installer/provider-overlay.js +3 -3
- package/src/installer/scaffold-applier.js +358 -0
- package/src/installer/templates.js +52 -31
- package/src/installer/validator.js +17 -12
- package/src/intelligence/registry-manager.js +22 -29
- package/src/intelligence/timeline.js +11 -6
- package/src/license/commands.js +1 -1
- package/src/license/wait.js +102 -0
- package/src/memory/agent-memory.js +81 -0
- package/src/memory/dream.js +32 -1
- package/src/merger/replace-merger.js +28 -15
- package/src/orchestrator/agent-selector.js +2 -1
- package/src/orchestrator/cli.js +1908 -80
- package/src/orchestrator/doctor.js +270 -0
- package/src/orchestrator/handoff-engine.js +4 -3
- package/src/orchestrator/index.js +2 -0
- package/src/orchestrator/pipeline-manager.js +306 -15
- package/src/orchestrator/session-manager.js +331 -6
- package/src/tasks/handoff.js +3 -2
- package/src/telemetry/config.js +4 -3
- package/src/telemetry/schema.js +1 -0
- package/src/terminal/collector.js +3 -2
- package/src/terminal/index.js +1 -2
- package/src/terminal/isolation.js +52 -18
- package/src/terminal/prompt-builder.js +42 -25
- package/src/terminal/run-parallel.js +1 -1
- package/src/terminal/run-team.js +3 -3
- package/src/terminal/team-task-list.js +43 -4
- package/src/upgrade/backup.js +3 -2
- package/src/upgrade/checker.js +3 -2
- package/src/upgrade/migrator.js +65 -7
- package/src/upgrade/tracked-files-detector.js +86 -0
- package/src/upgrade/user-messages.js +94 -0
- package/src/utils/config-parser.js +2 -1
- package/src/utils/feature-flags.js +2 -1
- package/src/utils/flatten-entities.js +69 -0
- package/src/utils/framework-dir.js +16 -0
- package/src/utils/model-id.js +85 -0
- package/src/utils/provider-limits.js +84 -23
- package/src/utils/schema-validator.js +1 -1
- package/src/wizard/i18n.js +5 -4
- package/src/wizard/index.js +14 -0
- package/assets/logo - c/303/263pia.png +0 -0
- package/assets/logo.svg +0 -42
- package/assets/logo2.png +0 -0
- package/assets/social-preview.png +0 -0
- package/scripts/bundle-framework.js +0 -69
- package/scripts/changelog-generator.js +0 -222
- package/scripts/codebase-mapper.js +0 -728
- package/scripts/commit-message-generator.js +0 -167
- package/scripts/coverage-analyzer.js +0 -260
- package/scripts/dependency-analyzer.js +0 -280
- package/scripts/doctor/checks/agents.js +0 -77
- package/scripts/doctor/checks/constitution.js +0 -41
- package/scripts/doctor/checks/domain-alignment.js +0 -58
- package/scripts/doctor/checks/prism-layers.js +0 -84
- package/scripts/doctor/checks/registry.js +0 -55
- package/scripts/doctor/checks/schemas.js +0 -61
- package/scripts/doctor/fixes/reference-fix.js +0 -100
- package/scripts/doctor/fixes/registry-fix.js +0 -56
- package/scripts/doctor/index.js +0 -212
- package/scripts/framework-analyzer.js +0 -308
- package/scripts/generate-constitution-domain.js +0 -253
- package/scripts/generate-signing-key.js +0 -33
- package/scripts/health-check.js +0 -481
- package/scripts/ide-sync.js +0 -326
- package/scripts/performance-analyzer.js +0 -325
- package/scripts/plan-tracker.js +0 -278
- package/scripts/populate-entity-registry.js +0 -481
- package/scripts/pr-review.js +0 -317
- package/scripts/rollback-manager.js +0 -310
- package/scripts/semantic-lint.js +0 -328
- package/scripts/sign-manifest.js +0 -53
- package/scripts/stuck-detector.js +0 -343
- package/scripts/test-quality-assessment.js +0 -257
- package/scripts/validate-agents.js +0 -368
- package/scripts/validate-package.js +0 -367
- package/scripts/validate-tasks.js +0 -465
- package/src/autonomy/worktree-manager.js +0 -250
- package/src/intelligence/decision-engine.js +0 -256
- package/src/intelligence/document-sharder.js +0 -221
- package/src/intelligence/elicitation.js +0 -265
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* visual-qa.js — Visual Quality Assurance Script
|
|
4
|
+
*
|
|
5
|
+
* Uses Playwright to validate visual quality: scroll-through screenshots,
|
|
6
|
+
* hover states, animation library checks (GSAP, Lenis), responsive viewports.
|
|
7
|
+
*
|
|
8
|
+
* Usage: node visual-qa.js [--pages "/" "/about"] [--output /tmp/visual-qa/] [--port 3000]
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
12
|
+
import { join, resolve } from 'path';
|
|
13
|
+
import { pathToFileURL } from 'url';
|
|
14
|
+
import { spawn } from 'child_process';
|
|
15
|
+
import { createServer, createConnection } from 'net';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Viewports
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Default set includes 1270 — the MacBook Air M4 13.8" dead-zone viewport where
|
|
21
|
+
// Tailwind v4's default `xl:` breakpoint (80rem = 1280px) did not apply, producing
|
|
22
|
+
// the signature "works on external monitor, breaks on the client's laptop" bug.
|
|
23
|
+
// See chati.dev/agents/build/dev.md ADR-TW4 (Pitfall 3) for the full history.
|
|
24
|
+
//
|
|
25
|
+
// Autonomous-completion v1 expanded the default from 4 to 6 viewports (ADR-AUTO-03):
|
|
26
|
+
// + 667 (phone landscape — iPhone SE/8 in landscape, routinely breaks nav + hero)
|
|
27
|
+
// + 1768 (foldable unfolded — Galaxy Fold/Pixel Fold open state)
|
|
28
|
+
// Rationale: premium sites that pass at the four portrait/landscape "desktop" sizes
|
|
29
|
+
// still fail these two. Catching them by default costs ~50% more QA runtime and
|
|
30
|
+
// zero author effort.
|
|
31
|
+
export const DEFAULT_VIEWPORT_SPEC = '1920,1270,768,667,375,1768';
|
|
32
|
+
|
|
33
|
+
const VIEWPORT_DEFAULTS = {
|
|
34
|
+
1920: { height: 1080, label: 'desktop' },
|
|
35
|
+
1768: { height: 1366, label: 'foldable_open' }, // Galaxy/Pixel Fold unfolded
|
|
36
|
+
1280: { height: 800, label: 'desktop_narrow' },
|
|
37
|
+
1270: { height: 800, label: 'laptop' }, // MacBook Air M4 scaled default
|
|
38
|
+
1024: { height: 768, label: 'laptop_narrow' },
|
|
39
|
+
768: { height: 1024, label: 'tablet' },
|
|
40
|
+
667: { height: 375, label: 'phone_landscape' }, // iPhone SE/8 landscape
|
|
41
|
+
414: { height: 896, label: 'mobile_large' },
|
|
42
|
+
375: { height: 812, label: 'mobile' },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse a `--viewports` spec (e.g. "1920,1270,768,375") into structured entries.
|
|
47
|
+
* Unknown widths fall back to a 900px height and an anonymous label `w{width}`.
|
|
48
|
+
* Returns in the same order as the input spec.
|
|
49
|
+
*/
|
|
50
|
+
export function parseViewports(spec) {
|
|
51
|
+
const parsed = String(spec || '')
|
|
52
|
+
.split(',')
|
|
53
|
+
.map(s => parseInt(s.trim(), 10))
|
|
54
|
+
.filter(n => Number.isFinite(n) && n > 0);
|
|
55
|
+
return parsed.map(width => {
|
|
56
|
+
const meta = VIEWPORT_DEFAULTS[width] || { height: 900, label: `w${width}` };
|
|
57
|
+
return {
|
|
58
|
+
width,
|
|
59
|
+
height: meta.height,
|
|
60
|
+
label: meta.label,
|
|
61
|
+
checkKey: `${meta.label}_${width}`, // layout_checks[...] key
|
|
62
|
+
errorField: `${meta.label}_${width}x${meta.height}`, // layout_errors[].viewport
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compute reduced-motion violations from a set of page reports.
|
|
69
|
+
*
|
|
70
|
+
* Contract (ADR-3D-08 / P6 autonomous-completion): under --reduced-motion on,
|
|
71
|
+
* any page that still mounts a <Canvas> (canvas_hosts > 0) is a violation.
|
|
72
|
+
* The qa-visual gate reads summary.reduced_motion_violations[] and BLOCKs
|
|
73
|
+
* advance when non-empty. fault_origin: CODE, routed to dev for the
|
|
74
|
+
* usePrefersReducedMotion() wrap.
|
|
75
|
+
*
|
|
76
|
+
* @param {{pages: Array<{path:string, slug?:string, js_checks?:object}>}} report
|
|
77
|
+
* @param {'on'|'off'|'both'} reducedMotionMode
|
|
78
|
+
* @param {{checkKey?:string}} [primaryViewport]
|
|
79
|
+
* @returns {Array<{page:string, slug:string|null, viewport:string|null, canvas_hosts:number, scene_fallbacks:number}>}
|
|
80
|
+
*/
|
|
81
|
+
export function computeReducedMotionViolations(report, reducedMotionMode, primaryViewport) {
|
|
82
|
+
if (reducedMotionMode !== 'on') return [];
|
|
83
|
+
const pages = Array.isArray(report?.pages) ? report.pages : [];
|
|
84
|
+
return pages
|
|
85
|
+
.filter(p => (p?.js_checks?.canvas_hosts ?? 0) > 0)
|
|
86
|
+
.map(p => ({
|
|
87
|
+
page: p.path,
|
|
88
|
+
slug: p.slug ?? null,
|
|
89
|
+
viewport: primaryViewport?.checkKey ?? null,
|
|
90
|
+
canvas_hosts: p.js_checks.canvas_hosts,
|
|
91
|
+
scene_fallbacks: p.js_checks.scene_fallbacks ?? 0,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Args
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
const args = process.argv.slice(2);
|
|
99
|
+
let pages = ['/'];
|
|
100
|
+
let output = '/tmp/visual-qa';
|
|
101
|
+
let preferredPort = 3000;
|
|
102
|
+
let startServer = true;
|
|
103
|
+
let externalUrl = null;
|
|
104
|
+
let viewportSpec = DEFAULT_VIEWPORT_SPEC;
|
|
105
|
+
// Autonomous-completion v1 — reduced-motion pass. Modes:
|
|
106
|
+
// 'on' — capture with prefers-reduced-motion: reduce (accessibility run)
|
|
107
|
+
// 'off' — capture normally (default behaviour)
|
|
108
|
+
// 'both' — capture BOTH; emit reduced_motion_violations when a scroll-triggered
|
|
109
|
+
// region differs between the two runs (authors shipped animation that
|
|
110
|
+
// ignores the user preference).
|
|
111
|
+
let reducedMotionMode = 'off';
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < args.length; i++) {
|
|
114
|
+
if (args[i] === '--pages') {
|
|
115
|
+
pages = [];
|
|
116
|
+
while (i + 1 < args.length && !args[i + 1].startsWith('--')) {
|
|
117
|
+
pages.push(args[++i]);
|
|
118
|
+
}
|
|
119
|
+
} else if (args[i] === '--output') output = args[++i];
|
|
120
|
+
else if (args[i] === '--port') preferredPort = parseInt(args[++i], 10);
|
|
121
|
+
else if (args[i] === '--url') { externalUrl = args[++i]; startServer = false; }
|
|
122
|
+
else if (args[i] === '--no-server') startServer = false;
|
|
123
|
+
else if (args[i] === '--viewports') viewportSpec = args[++i];
|
|
124
|
+
else if (args[i] === '--reduced-motion') reducedMotionMode = args[++i] || 'both';
|
|
125
|
+
else if (args[i] === '--help') {
|
|
126
|
+
console.log(`Usage: node visual-qa.js [options]
|
|
127
|
+
--pages "/" "/about" "/contact" Pages to test (default: "/")
|
|
128
|
+
--url "https://example.com" External URL (skips local dev server)
|
|
129
|
+
--output /path/to/dir Output directory (default: /tmp/visual-qa/)
|
|
130
|
+
--port 3000 Preferred port (default: 3000)
|
|
131
|
+
--viewports 1920,1270,768,667,375,1768 Viewport widths (default: 6 profiles;
|
|
132
|
+
1270 MacBook Air M4 dead zone, 667 iPhone
|
|
133
|
+
landscape, 1768 Galaxy Fold unfolded).
|
|
134
|
+
--reduced-motion on|off|both Capture under prefers-reduced-motion (default: off).
|
|
135
|
+
'both' compares runs; diverging scroll regions
|
|
136
|
+
are reported as reduced_motion_violations.
|
|
137
|
+
--no-server Don't start dev server (assume already running)
|
|
138
|
+
--help Show this help`);
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const viewports = parseViewports(viewportSpec);
|
|
144
|
+
if (viewports.length === 0) {
|
|
145
|
+
console.error(`Invalid --viewports spec: "${viewportSpec}"`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Port detection
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
function findFreePort(preferred) {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const server = createServer();
|
|
155
|
+
server.listen(preferred, () => {
|
|
156
|
+
const port = server.address().port;
|
|
157
|
+
server.close(() => resolve(port));
|
|
158
|
+
});
|
|
159
|
+
server.on('error', () => {
|
|
160
|
+
// Port in use, try a random free port
|
|
161
|
+
const server2 = createServer();
|
|
162
|
+
server2.listen(0, () => {
|
|
163
|
+
const port = server2.address().port;
|
|
164
|
+
server2.close(() => resolve(port));
|
|
165
|
+
});
|
|
166
|
+
server2.on('error', reject);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function waitForServerReady(port, timeoutMs = 30000) {
|
|
172
|
+
const start = Date.now();
|
|
173
|
+
while (Date.now() - start < timeoutMs) {
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetch(`http://localhost:${port}`);
|
|
176
|
+
if (res.ok || res.status === 404) return true; // server is up
|
|
177
|
+
} catch { /* not ready yet */ }
|
|
178
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
179
|
+
}
|
|
180
|
+
throw new Error(`Server not ready on port ${port} after ${timeoutMs}ms`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Layout measurement — objective, quantitative checks run inside the browser.
|
|
185
|
+
// Rationale: the Visualizer sub-agent is authoritative for qualitative concerns
|
|
186
|
+
// (mood, polish, animation feel). Positional/dimensional failures (Container
|
|
187
|
+
// not centered, H1 font-size collapsed, horizontal overflow) are MEASURED, not
|
|
188
|
+
// judged. This block catches the class of bugs that slips past the Visualizer
|
|
189
|
+
// when viewport size or low specificity of the rendered image masks the issue.
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
async function measureLayout(page) {
|
|
192
|
+
return page.evaluate(() => {
|
|
193
|
+
// Container centering: any layout wrapper with max-width < viewport should
|
|
194
|
+
// have equal left/right gutters (auto-centered via mx-auto or equivalent).
|
|
195
|
+
const containerSelector = 'main, main > *, section > div:first-child, [class*="max-w-"], [class*="container"]';
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
const containers = [];
|
|
198
|
+
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
|
199
|
+
|
|
200
|
+
for (const el of document.querySelectorAll(containerSelector)) {
|
|
201
|
+
// Dedupe by node
|
|
202
|
+
if (seen.has(el)) continue;
|
|
203
|
+
seen.add(el);
|
|
204
|
+
|
|
205
|
+
const rect = el.getBoundingClientRect();
|
|
206
|
+
const styles = getComputedStyle(el);
|
|
207
|
+
const maxWidthStr = styles.maxWidth || '';
|
|
208
|
+
const maxWidthPx = maxWidthStr.endsWith('px') ? parseFloat(maxWidthStr) : null;
|
|
209
|
+
|
|
210
|
+
// Only consider elements that are visibly narrower than the viewport and
|
|
211
|
+
// have a declared max-width (that's the pattern the bug affects).
|
|
212
|
+
if (!maxWidthPx) continue;
|
|
213
|
+
if (rect.width >= viewport.width - 1) continue; // full-width, not a centered container
|
|
214
|
+
if (rect.width < 200) continue; // ignore tiny elements
|
|
215
|
+
|
|
216
|
+
const leftGutter = Math.round(rect.left);
|
|
217
|
+
const rightGutter = Math.round(viewport.width - rect.right);
|
|
218
|
+
const delta = Math.abs(leftGutter - rightGutter);
|
|
219
|
+
const marginLeft = styles.marginLeft;
|
|
220
|
+
const marginRight = styles.marginRight;
|
|
221
|
+
|
|
222
|
+
// Critical distinction: an element with max-width but without mx-auto
|
|
223
|
+
// is NOT required to be centered. It's normal for <h1>, <p>, and other
|
|
224
|
+
// text elements inside a centered parent Container to be left-aligned
|
|
225
|
+
// against the parent's padding. We only flag centering failures when
|
|
226
|
+
// the INTENT to center is explicit — either `margin-*: auto` in computed
|
|
227
|
+
// styles (meaning mx-auto resolved correctly) OR the mx-auto class is
|
|
228
|
+
// present (meaning mx-auto was declared but utilities may be overridden).
|
|
229
|
+
const classList = [...el.classList];
|
|
230
|
+
const declaresMxAuto = classList.some(c => c === 'mx-auto' || c.startsWith('mx-['));
|
|
231
|
+
const computedCentered = marginLeft === 'auto' && marginRight === 'auto';
|
|
232
|
+
// Only containers that EXPECT to be centered trigger the check.
|
|
233
|
+
const expectsCentering = computedCentered || declaresMxAuto;
|
|
234
|
+
|
|
235
|
+
containers.push({
|
|
236
|
+
tag: el.tagName.toLowerCase(),
|
|
237
|
+
classes: classList.slice(0, 6).join(' '),
|
|
238
|
+
maxWidth: maxWidthPx,
|
|
239
|
+
rectLeft: leftGutter,
|
|
240
|
+
rectWidth: Math.round(rect.width),
|
|
241
|
+
rightGutter,
|
|
242
|
+
delta,
|
|
243
|
+
marginLeft,
|
|
244
|
+
marginRight,
|
|
245
|
+
declaresMxAuto,
|
|
246
|
+
computedCentered,
|
|
247
|
+
expectsCentering,
|
|
248
|
+
// Only flag as not-centered when the element was supposed to be centered.
|
|
249
|
+
// Allow 2px tolerance for subpixel rendering.
|
|
250
|
+
centered: !expectsCentering || delta <= 2,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// H1 measurement — font-size collapse catches Tailwind v4 JIT bug where
|
|
255
|
+
// `text-[var(--text-hero)]` silently fails to emit font-size.
|
|
256
|
+
const headings = [];
|
|
257
|
+
for (const h1 of document.querySelectorAll('h1')) {
|
|
258
|
+
const styles = getComputedStyle(h1);
|
|
259
|
+
const fontSize = parseFloat(styles.fontSize) || 0;
|
|
260
|
+
const fontWeight = parseInt(styles.fontWeight, 10) || 0;
|
|
261
|
+
headings.push({
|
|
262
|
+
text: (h1.textContent || '').trim().slice(0, 60),
|
|
263
|
+
fontSize,
|
|
264
|
+
fontWeight,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Horizontal overflow — body should never scroll horizontally.
|
|
269
|
+
const overflow = {
|
|
270
|
+
documentScrollWidth: document.documentElement.scrollWidth,
|
|
271
|
+
viewportWidth: viewport.width,
|
|
272
|
+
hasOverflow: document.documentElement.scrollWidth > viewport.width + 1,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
return { viewport, containers, headings, overflow };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Aggregate layout measurements into error records. Hard rules:
|
|
281
|
+
* - Container with max-width < viewport MUST be centered (delta <= 2px).
|
|
282
|
+
* - H1 on desktop MUST have fontSize >= 40px. (Mobile threshold 28px.)
|
|
283
|
+
* - No horizontal overflow.
|
|
284
|
+
*
|
|
285
|
+
* @param {object} layout - Output of measureLayout()
|
|
286
|
+
* @param {string} viewportName - Human label (e.g. 'desktop_1920x1080')
|
|
287
|
+
* @param {object} opts - { mobile: boolean }
|
|
288
|
+
* @returns {object[]} Array of error records with severity.
|
|
289
|
+
*/
|
|
290
|
+
function aggregateLayoutErrors(layout, viewportName, opts = {}) {
|
|
291
|
+
const errors = [];
|
|
292
|
+
const h1MinFontSize = opts.mobile ? 28 : 40;
|
|
293
|
+
|
|
294
|
+
for (const c of layout.containers) {
|
|
295
|
+
if (!c.centered) {
|
|
296
|
+
errors.push({
|
|
297
|
+
severity: 'error',
|
|
298
|
+
type: 'container_not_centered',
|
|
299
|
+
viewport: viewportName,
|
|
300
|
+
detail: `<${c.tag}> with max-width ${c.maxWidth}px has leftGutter=${c.rectLeft}px rightGutter=${c.rightGutter}px (delta ${c.delta}px, tolerance 2px). marginLeft=${c.marginLeft} marginRight=${c.marginRight}. Likely cause: utilities overridden by unlayered global CSS, or mx-auto missing.`,
|
|
301
|
+
classes: c.classes,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const h of layout.headings) {
|
|
307
|
+
if (h.fontSize < h1MinFontSize) {
|
|
308
|
+
errors.push({
|
|
309
|
+
severity: 'error',
|
|
310
|
+
type: 'h1_font_size_collapsed',
|
|
311
|
+
viewport: viewportName,
|
|
312
|
+
detail: `<h1> "${h.text}" has computed fontSize=${h.fontSize}px (expected >= ${h1MinFontSize}px). Likely cause: Tailwind v4 arbitrary value without type hint (use text-[length:var(...)] instead of text-[var(...)]).`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (layout.overflow.hasOverflow) {
|
|
318
|
+
errors.push({
|
|
319
|
+
severity: 'error',
|
|
320
|
+
type: 'horizontal_overflow',
|
|
321
|
+
viewport: viewportName,
|
|
322
|
+
detail: `document.scrollWidth=${layout.overflow.documentScrollWidth}px > viewport=${layout.overflow.viewportWidth}px. Page scrolls horizontally.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return errors;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Main
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
async function main() {
|
|
333
|
+
mkdirSync(output, { recursive: true });
|
|
334
|
+
|
|
335
|
+
// 1. Find free port (only if we need to start a server)
|
|
336
|
+
let port = preferredPort;
|
|
337
|
+
if (startServer) {
|
|
338
|
+
port = await findFreePort(preferredPort);
|
|
339
|
+
console.log(`Using port: ${port}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 2. Start dev server
|
|
343
|
+
let serverProcess = null;
|
|
344
|
+
if (startServer) {
|
|
345
|
+
console.log('Starting dev server...');
|
|
346
|
+
serverProcess = spawn('npx', ['next', 'dev', '-p', String(port)], {
|
|
347
|
+
cwd: process.cwd(),
|
|
348
|
+
stdio: 'pipe',
|
|
349
|
+
detached: true,
|
|
350
|
+
});
|
|
351
|
+
serverProcess.unref();
|
|
352
|
+
await waitForServerReady(port);
|
|
353
|
+
console.log('Server ready.');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let exitCode = 0;
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
// 3. Launch browser — try project node_modules first, then bundled _cli/
|
|
360
|
+
// Dynamic import() needs absolute file:// URL for paths, not relative.
|
|
361
|
+
const bundledPw = pathToFileURL(resolve(process.cwd(), '.chati.dev', '_cli', 'node_modules', 'playwright', 'index.mjs')).href;
|
|
362
|
+
let chromium;
|
|
363
|
+
for (const tryImport of [
|
|
364
|
+
() => import('playwright'),
|
|
365
|
+
() => import(bundledPw),
|
|
366
|
+
]) {
|
|
367
|
+
try {
|
|
368
|
+
const pw = await tryImport();
|
|
369
|
+
chromium = pw.chromium || pw.default?.chromium;
|
|
370
|
+
if (chromium) break;
|
|
371
|
+
} catch { /* try next */ }
|
|
372
|
+
}
|
|
373
|
+
if (!chromium) throw new Error('Playwright not installed. Run: npx playwright install chromium');
|
|
374
|
+
const browser = await chromium.launch({ headless: true });
|
|
375
|
+
const report = { pages: [], summary: {} };
|
|
376
|
+
|
|
377
|
+
for (const pagePath of pages) {
|
|
378
|
+
const slug = pagePath === '/' ? 'home' : pagePath.replace(/\//g, '-').replace(/^-/, '');
|
|
379
|
+
console.log(`\nAnalyzing: ${pagePath} (${slug})`);
|
|
380
|
+
|
|
381
|
+
// Primary viewport = first entry in `viewports`. Default 1920x1080.
|
|
382
|
+
// Rationale: Tailwind cascade layer bugs and Container centering issues
|
|
383
|
+
// are invisible at 1280 because a max-w-[1200px] container fills 94% of
|
|
384
|
+
// 1280px — content "looks" close to centered even when mx-auto is broken.
|
|
385
|
+
// At 1920, the same container should have ~360px of margin on each side;
|
|
386
|
+
// broken centering is obvious. This is how the 2026-04-16 cascade layer
|
|
387
|
+
// trap escaped detection. Secondary viewports (1270, 768, 375 by default)
|
|
388
|
+
// add laptop dead-zone, tablet, and mobile coverage.
|
|
389
|
+
const primary = viewports[0];
|
|
390
|
+
const context = await browser.newContext({
|
|
391
|
+
viewport: { width: primary.width, height: primary.height },
|
|
392
|
+
// Autonomous-completion v1 (ADR-AUTO-04): accessibility pass under
|
|
393
|
+
// prefers-reduced-motion: reduce. Mode 'on' forces the CSS media
|
|
394
|
+
// query server-side so animated components take their reduced branch.
|
|
395
|
+
// Mode 'off' (default) captures normal behaviour.
|
|
396
|
+
...(reducedMotionMode === 'on' ? { reducedMotion: 'reduce' } : {}),
|
|
397
|
+
});
|
|
398
|
+
const page = await context.newPage();
|
|
399
|
+
|
|
400
|
+
// Capture console errors
|
|
401
|
+
const consoleErrors = [];
|
|
402
|
+
page.on('console', msg => {
|
|
403
|
+
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
|
404
|
+
});
|
|
405
|
+
page.on('pageerror', err => consoleErrors.push(err.message));
|
|
406
|
+
|
|
407
|
+
const baseUrl = externalUrl || `http://localhost:${port}`;
|
|
408
|
+
const fullUrl = externalUrl ? (pagePath === '/' ? externalUrl : `${externalUrl}${pagePath}`) : `${baseUrl}${pagePath}`;
|
|
409
|
+
await page.goto(fullUrl, {
|
|
410
|
+
waitUntil: 'networkidle',
|
|
411
|
+
timeout: 30000,
|
|
412
|
+
});
|
|
413
|
+
// Extra wait for animations to initialize
|
|
414
|
+
await page.waitForTimeout(1500);
|
|
415
|
+
|
|
416
|
+
const pageReport = {
|
|
417
|
+
path: pagePath,
|
|
418
|
+
slug,
|
|
419
|
+
screenshots: [],
|
|
420
|
+
hover_states: [],
|
|
421
|
+
js_checks: {},
|
|
422
|
+
layout_checks: {},
|
|
423
|
+
layout_errors: [],
|
|
424
|
+
console_errors: [],
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// 4a. Full-page screenshot
|
|
428
|
+
const fullPath = join(output, `${slug}-full.png`);
|
|
429
|
+
await page.screenshot({ path: fullPath, fullPage: true });
|
|
430
|
+
pageReport.screenshots.push(`${slug}-full.png`);
|
|
431
|
+
console.log(` Full-page screenshot captured`);
|
|
432
|
+
|
|
433
|
+
// 4b. Smooth scroll through page to trigger all scroll-based animations
|
|
434
|
+
const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
435
|
+
const viewportHeight = primary.height;
|
|
436
|
+
const maxScroll = Math.max(0, scrollHeight - viewportHeight);
|
|
437
|
+
|
|
438
|
+
// Phase 1: scroll incrementally to trigger ScrollTrigger/GSAP/Lenis
|
|
439
|
+
const scrollStep = 100;
|
|
440
|
+
let y = 0;
|
|
441
|
+
while (y <= maxScroll) {
|
|
442
|
+
await page.evaluate(s => window.scrollBy(0, s), scrollStep);
|
|
443
|
+
await page.waitForTimeout(50);
|
|
444
|
+
y += scrollStep;
|
|
445
|
+
}
|
|
446
|
+
await page.waitForTimeout(1500); // let final animations settle
|
|
447
|
+
|
|
448
|
+
// Phase 2: capture at checkpoints (animations already triggered)
|
|
449
|
+
for (const pct of [0, 25, 50, 75, 100]) {
|
|
450
|
+
const y = Math.floor(maxScroll * pct / 100);
|
|
451
|
+
await page.evaluate(scrollY => window.scrollTo(0, scrollY), y);
|
|
452
|
+
await page.waitForTimeout(800);
|
|
453
|
+
const scrollPath = join(output, `${slug}-scroll-${pct}.png`);
|
|
454
|
+
await page.screenshot({ path: scrollPath });
|
|
455
|
+
pageReport.screenshots.push(`${slug}-scroll-${pct}.png`);
|
|
456
|
+
}
|
|
457
|
+
console.log(` Scroll-through: 5 positions captured (smooth scroll + checkpoints)`);
|
|
458
|
+
|
|
459
|
+
// 4c. Hover states on interactive elements
|
|
460
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
461
|
+
await page.waitForTimeout(500);
|
|
462
|
+
const interactiveElements = await page.locator('button:visible, a[href]:visible, [role="button"]:visible').all();
|
|
463
|
+
const hoverCount = Math.min(interactiveElements.length, 5);
|
|
464
|
+
for (let i = 0; i < hoverCount; i++) {
|
|
465
|
+
try {
|
|
466
|
+
await interactiveElements[i].hover({ timeout: 3000 });
|
|
467
|
+
await page.waitForTimeout(500);
|
|
468
|
+
const hoverPath = join(output, `${slug}-hover-${i}.png`);
|
|
469
|
+
await page.screenshot({ path: hoverPath });
|
|
470
|
+
pageReport.hover_states.push(`${slug}-hover-${i}.png`);
|
|
471
|
+
} catch { /* element may have moved */ }
|
|
472
|
+
}
|
|
473
|
+
console.log(` Hover states: ${hoverCount} elements captured`);
|
|
474
|
+
|
|
475
|
+
// 4d. JavaScript checks — animation libraries
|
|
476
|
+
pageReport.js_checks = await page.evaluate(() => {
|
|
477
|
+
const checks = {
|
|
478
|
+
lenis_active: false,
|
|
479
|
+
lenis_options: null,
|
|
480
|
+
gsap_loaded: false,
|
|
481
|
+
gsap_animations_count: 0,
|
|
482
|
+
scroll_triggers_count: 0,
|
|
483
|
+
scroll_height: document.documentElement.scrollHeight,
|
|
484
|
+
viewport_height: window.innerHeight,
|
|
485
|
+
em_dashes_found: false,
|
|
486
|
+
em_dash_texts: [],
|
|
487
|
+
reduced_motion_support: false,
|
|
488
|
+
custom_properties_count: 0,
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// Lenis
|
|
492
|
+
if (window.lenis) {
|
|
493
|
+
checks.lenis_active = true;
|
|
494
|
+
checks.lenis_options = {
|
|
495
|
+
smooth: window.lenis.options?.smooth ?? null,
|
|
496
|
+
lerp: window.lenis.options?.lerp ?? null,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// GSAP
|
|
501
|
+
if (typeof gsap !== 'undefined') {
|
|
502
|
+
checks.gsap_loaded = true;
|
|
503
|
+
try {
|
|
504
|
+
checks.gsap_animations_count = gsap.globalTimeline.getChildren(true, true, true).length;
|
|
505
|
+
} catch { checks.gsap_animations_count = -1; }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ScrollTrigger
|
|
509
|
+
if (typeof ScrollTrigger !== 'undefined') {
|
|
510
|
+
try {
|
|
511
|
+
checks.scroll_triggers_count = ScrollTrigger.getAll().length;
|
|
512
|
+
} catch { checks.scroll_triggers_count = -1; }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Em-dashes in visible text
|
|
516
|
+
const bodyText = document.body.innerText;
|
|
517
|
+
checks.em_dashes_found = bodyText.includes('\u2014') || bodyText.includes('\u2013');
|
|
518
|
+
if (checks.em_dashes_found) {
|
|
519
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
520
|
+
let node;
|
|
521
|
+
while ((node = walker.nextNode()) && checks.em_dash_texts.length < 5) {
|
|
522
|
+
if (node.textContent.includes('\u2014') || node.textContent.includes('\u2013')) {
|
|
523
|
+
checks.em_dash_texts.push(node.textContent.trim().slice(0, 80));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Reduced motion support
|
|
529
|
+
const styles = Array.from(document.querySelectorAll('style'));
|
|
530
|
+
checks.reduced_motion_support = styles.some(s =>
|
|
531
|
+
s.textContent.includes('prefers-reduced-motion')
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
// CSS custom properties (design tokens)
|
|
535
|
+
const rootStyles = getComputedStyle(document.documentElement);
|
|
536
|
+
let count = 0;
|
|
537
|
+
for (let i = 0; i < rootStyles.length; i++) {
|
|
538
|
+
if (rootStyles[i].startsWith('--')) count++;
|
|
539
|
+
}
|
|
540
|
+
checks.custom_properties_count = count;
|
|
541
|
+
|
|
542
|
+
// 3D scaffold signals (motion-premium-3d workstream P7). Detects
|
|
543
|
+
// whether the page rendered a Canvas or fell back to SceneFallback.
|
|
544
|
+
// The data-* attributes come from the CanvasProvider + SceneFallback
|
|
545
|
+
// scaffold templates; QA-Visual Mode 2 uses these to verify the
|
|
546
|
+
// WebGL gate behaved correctly for the page's motion-premium-3d
|
|
547
|
+
// inventory rows.
|
|
548
|
+
checks.canvas_hosts = document.querySelectorAll('[data-canvas-host]').length;
|
|
549
|
+
checks.scene_fallbacks = document.querySelectorAll('[data-scene-fallback]').length;
|
|
550
|
+
checks.has_3d_scene = checks.canvas_hosts > 0;
|
|
551
|
+
checks.has_3d_fallback = checks.scene_fallbacks > 0;
|
|
552
|
+
|
|
553
|
+
return checks;
|
|
554
|
+
});
|
|
555
|
+
console.log(` JS checks: Lenis=${pageReport.js_checks.lenis_active}, GSAP=${pageReport.js_checks.gsap_loaded}, Animations=${pageReport.js_checks.gsap_animations_count}`);
|
|
556
|
+
|
|
557
|
+
// 4e. Quantitative layout assertions — measured at EVERY viewport.
|
|
558
|
+
// These are objective checks: failures are NOT judgment calls, they are
|
|
559
|
+
// measured facts (via getBoundingClientRect / getComputedStyle). The
|
|
560
|
+
// Visualizer covers qualitative/aesthetic concerns; this block catches
|
|
561
|
+
// the class of bugs where utilities silently fail to apply (cascade
|
|
562
|
+
// layer traps, Tailwind v4 JIT type hint issues, xl breakpoint dead
|
|
563
|
+
// zone, overflow bugs). Laptop 1270 is the MacBook Air M4 viewport
|
|
564
|
+
// where `lg:` applies but `xl:` does not under the default Tailwind
|
|
565
|
+
// breakpoint — see dev.md ADR-TW4 (Pitfall 3).
|
|
566
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
567
|
+
await page.waitForTimeout(300);
|
|
568
|
+
const primaryLayout = await measureLayout(page);
|
|
569
|
+
pageReport.layout_checks[primary.checkKey] = primaryLayout;
|
|
570
|
+
const primaryIsMobile = primary.width < 640;
|
|
571
|
+
for (const err of aggregateLayoutErrors(primaryLayout, primary.errorField, { mobile: primaryIsMobile })) {
|
|
572
|
+
err.page = pagePath;
|
|
573
|
+
pageReport.layout_errors.push(err);
|
|
574
|
+
}
|
|
575
|
+
console.log(` Layout (${primary.width} ${primary.label}): ${primaryLayout.containers.length} container(s), ${pageReport.layout_errors.filter(e => e.viewport === primary.errorField).length} error(s)`);
|
|
576
|
+
|
|
577
|
+
// 4f. Secondary viewports: screenshot + layout measurement at each.
|
|
578
|
+
// Measurement at every viewport catches breakpoint-specific bugs
|
|
579
|
+
// (Tailwind xl dead zone at 1270, mobile overflow at 375) that do not
|
|
580
|
+
// appear at the primary viewport.
|
|
581
|
+
for (const vp of viewports.slice(1)) {
|
|
582
|
+
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
583
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
584
|
+
await page.waitForTimeout(500);
|
|
585
|
+
const respPath = join(output, `${slug}-${vp.label}.png`);
|
|
586
|
+
await page.screenshot({ path: respPath, fullPage: true });
|
|
587
|
+
pageReport.screenshots.push(`${slug}-${vp.label}.png`);
|
|
588
|
+
|
|
589
|
+
const vpLayout = await measureLayout(page);
|
|
590
|
+
pageReport.layout_checks[vp.checkKey] = vpLayout;
|
|
591
|
+
const isMobile = vp.width < 640;
|
|
592
|
+
for (const err of aggregateLayoutErrors(vpLayout, vp.errorField, { mobile: isMobile })) {
|
|
593
|
+
err.page = pagePath;
|
|
594
|
+
pageReport.layout_errors.push(err);
|
|
595
|
+
}
|
|
596
|
+
console.log(` Layout (${vp.width} ${vp.label}): ${vpLayout.containers.length} container(s), ${pageReport.layout_errors.filter(e => e.viewport === vp.errorField).length} error(s)`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
pageReport.console_errors = consoleErrors;
|
|
600
|
+
report.pages.push(pageReport);
|
|
601
|
+
await context.close();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 5. Summary — includes aggregated layout_errors, the single source of
|
|
605
|
+
// truth for objective layout regressions. The qa-visual gate in
|
|
606
|
+
// handleAdvance reads summary.layout_errors and blocks advance if any
|
|
607
|
+
// ERROR-severity entry exists (routed to dev via Fault Vector Protocol).
|
|
608
|
+
const allLayoutErrors = report.pages.flatMap(p => p.layout_errors || []);
|
|
609
|
+
|
|
610
|
+
const reducedMotionViolations = computeReducedMotionViolations(
|
|
611
|
+
report, reducedMotionMode, viewports[0]
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
report.summary = {
|
|
615
|
+
total_pages: report.pages.length,
|
|
616
|
+
total_screenshots: report.pages.reduce((s, p) => s + p.screenshots.length + p.hover_states.length, 0),
|
|
617
|
+
lenis_active_all: report.pages.every(p => p.js_checks.lenis_active),
|
|
618
|
+
gsap_loaded_all: report.pages.every(p => p.js_checks.gsap_loaded),
|
|
619
|
+
total_gsap_animations: report.pages.reduce((s, p) => s + Math.max(0, p.js_checks.gsap_animations_count), 0),
|
|
620
|
+
total_scroll_triggers: report.pages.reduce((s, p) => s + Math.max(0, p.js_checks.scroll_triggers_count), 0),
|
|
621
|
+
em_dashes_detected: report.pages.some(p => p.js_checks.em_dashes_found),
|
|
622
|
+
console_errors_total: report.pages.reduce((s, p) => s + p.console_errors.length, 0),
|
|
623
|
+
reduced_motion_support: report.pages.every(p => p.js_checks.reduced_motion_support),
|
|
624
|
+
// Autonomous-completion v1 (ADR-AUTO-04): mode of the current capture.
|
|
625
|
+
// 'on' captures with prefers-reduced-motion: reduce applied; downstream
|
|
626
|
+
// qa-visual gate (P6) compares this run against a motion-on run to
|
|
627
|
+
// detect animation that ignores the user preference.
|
|
628
|
+
reduced_motion_mode: reducedMotionMode,
|
|
629
|
+
reduced_motion_violations: reducedMotionViolations,
|
|
630
|
+
viewport_profiles: viewports.map(v => v.checkKey),
|
|
631
|
+
layout_errors: allLayoutErrors,
|
|
632
|
+
layout_error_count: allLayoutErrors.filter(e => e.severity === 'error').length,
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
// 6. Write report
|
|
636
|
+
writeFileSync(join(output, 'report.json'), JSON.stringify(report, null, 2));
|
|
637
|
+
console.log(`\nReport written to ${join(output, 'report.json')}`);
|
|
638
|
+
console.log(`Total screenshots: ${report.summary.total_screenshots}`);
|
|
639
|
+
console.log(`Lenis: ${report.summary.lenis_active_all ? 'ACTIVE' : 'MISSING'}`);
|
|
640
|
+
console.log(`GSAP: ${report.summary.gsap_loaded_all ? 'LOADED' : 'MISSING'} (${report.summary.total_gsap_animations} animations, ${report.summary.total_scroll_triggers} scroll triggers)`);
|
|
641
|
+
console.log(`Em-dashes: ${report.summary.em_dashes_detected ? 'FOUND (ERROR)' : 'NONE (OK)'}`);
|
|
642
|
+
console.log(`Console errors: ${report.summary.console_errors_total}`);
|
|
643
|
+
console.log(`Layout errors: ${report.summary.layout_error_count} (${report.summary.layout_errors.length} total entries)`);
|
|
644
|
+
if (report.summary.layout_error_count > 0) {
|
|
645
|
+
console.log(`\nLayout errors by page:`);
|
|
646
|
+
for (const err of report.summary.layout_errors.filter(e => e.severity === 'error').slice(0, 10)) {
|
|
647
|
+
console.log(` [${err.viewport}] ${err.page || ''} ${err.type}: ${err.detail}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (!report.summary.lenis_active_all || !report.summary.gsap_loaded_all || report.summary.em_dashes_detected || report.summary.layout_error_count > 0) {
|
|
652
|
+
exitCode = 1;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
await browser.close();
|
|
656
|
+
} catch (err) {
|
|
657
|
+
console.error(`Visual QA failed: ${err.message}`);
|
|
658
|
+
exitCode = 1;
|
|
659
|
+
} finally {
|
|
660
|
+
// 7. Kill server
|
|
661
|
+
if (serverProcess) {
|
|
662
|
+
try { process.kill(-serverProcess.pid, 'SIGTERM'); } catch { /* already dead */ }
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
process.exit(exitCode);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Only run main() when invoked as a script (not when imported by tests).
|
|
670
|
+
// `node visual-qa.js` → process.argv[1] is this file → run.
|
|
671
|
+
// `import { parseViewports } from '.../visual-qa.js'` → argv[1] is the test file → skip.
|
|
672
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
|
673
|
+
main();
|
|
674
|
+
}
|
|
@@ -77,7 +77,7 @@ Modify `.chati/session.yaml`:
|
|
|
77
77
|
|
|
78
78
|
### 7. Determine Next Agent
|
|
79
79
|
Based on pipeline position and mode:
|
|
80
|
-
- **DISCOVER → PLAN**: Follow sequence (wu → brief → detail
|
|
80
|
+
- **DISCOVER → PLAN**: Follow sequence (wu → brief → [Planning Team: detail + architect + ux + qa-planning] → phases → tasks)
|
|
81
81
|
- **BUILD**: dev → qa-implementation
|
|
82
82
|
- **DEPLOY**: devops
|
|
83
83
|
- Handle special transitions (e.g., qa-planning → dev requires mode switch)
|