@haposoft/cafekit 0.16.7 → 0.18.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 +14 -22
- package/bin/__tests__/codex-hooks-ownership.test.js +2 -2
- package/bin/__tests__/codex-hooks.test.js +0 -36
- package/bin/__tests__/codex-native.test.js +46 -88
- package/bin/__tests__/develop-contract.test.js +26 -57
- package/bin/__tests__/docs-sync-on-demand.test.js +93 -0
- package/bin/__tests__/grok-envelope.test.js +0 -56
- package/bin/__tests__/hook-payload.test.js +4 -6
- package/bin/__tests__/legacy-authority-decoupled.test.js +159 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +221 -0
- package/bin/__tests__/omp-bridge.test.js +20 -17
- package/bin/__tests__/omp-hooks.test.js +0 -19
- package/bin/__tests__/optional-skill-inventory.test.js +112 -4
- package/bin/__tests__/orca-session.test.js +0 -2
- package/bin/__tests__/package-inventory.test.js +100 -541
- package/bin/__tests__/runtime-dir.test.js +0 -3
- package/bin/__tests__/runtime-schema.test.js +3 -2
- package/bin/__tests__/skill-routing-source.test.js +27 -15
- package/bin/__tests__/spec-narrowing.test.js +0 -27
- package/bin/__tests__/specs-v2-execution-closeout.test.js +4 -69
- package/bin/__tests__/ui-ux-skill.test.js +101 -0
- package/bin/__tests__/usage-hook-retired.test.js +114 -0
- package/bin/__tests__/validator-grounding.test.js +9 -28
- package/bin/phases/codex-runtime.js +24 -0
- package/bin/phases/omp-runtime.js +22 -0
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +3 -10
- package/src/claude/agents/brainstormer.md +7 -3
- package/src/claude/agents/code-auditor.md +35 -50
- package/src/claude/agents/debugger.md +5 -79
- package/src/claude/agents/deployer.md +3 -5
- package/src/claude/agents/docs-keeper.md +2 -7
- package/src/claude/agents/implementer.md +6 -9
- package/src/claude/agents/inspector.md +2 -3
- package/src/claude/agents/project-manager.md +7 -15
- package/src/claude/agents/researcher.md +9 -3
- package/src/claude/agents/spec-maker.md +11 -11
- package/src/claude/hooks/__tests__/fixtures/statusline-default.golden +1 -1
- package/src/claude/hooks/__tests__/spec-gate.test.js +74 -3
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +134 -0
- package/src/claude/hooks/__tests__/state.test.js +68 -0
- package/src/claude/hooks/__tests__/statusline.test.js +140 -11
- package/src/claude/hooks/agent.cjs +2 -1
- package/src/claude/hooks/lib/context.cjs +0 -89
- package/src/claude/hooks/lib/hook-payload.cjs +2 -2
- package/src/claude/hooks/rules.cjs +2 -1
- package/src/claude/hooks/session.cjs +0 -1
- package/src/claude/hooks/spec-gate.cjs +17 -28
- package/src/claude/hooks/spec-state.cjs +95 -1
- package/src/claude/hooks/state.cjs +21 -2
- package/src/claude/migration-manifest.json +30 -15
- package/src/claude/rules/ai-dev-rules.md +1 -1
- package/src/claude/rules/hook-protocols.md +8 -15
- package/src/claude/rules/manage-docs.md +9 -23
- package/src/claude/rules/orchestrator.md +3 -4
- package/src/claude/rules/review-audit-self-decision.md +3 -3
- package/src/claude/rules/skill-domain-routing.md +1 -3
- package/src/claude/rules/skill-workflow-routing.md +4 -5
- package/src/claude/rules/state-sync.md +3 -12
- package/src/claude/rules/workflow.md +10 -18
- package/src/claude/runtime.schema.json +5 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -6
- package/src/claude/scripts/provenance.cjs +23 -5
- package/src/claude/scripts/spec-receipt.cjs +24 -7
- package/src/claude/scripts/spec-resolver.cjs +1 -2
- package/src/claude/scripts/validate-spec-output.cjs +5 -32
- package/src/claude/settings/settings.json +1 -32
- package/src/claude/skills/{question → ask}/SKILL.md +9 -75
- package/src/claude/skills/brainstorm/SKILL.md +43 -131
- package/src/claude/skills/code-review/SKILL.md +48 -17
- package/src/claude/skills/code-review/references/spec-compliance-review.md +2 -3
- package/src/claude/skills/code-review/references/verification-gate.md +22 -27
- package/src/claude/skills/debug/SKILL.md +29 -149
- package/src/claude/skills/develop/SKILL.md +27 -36
- package/src/claude/skills/develop/references/parallel-waves.md +0 -8
- package/src/claude/skills/develop/references/quality-gate.md +15 -13
- package/src/claude/skills/docs/SKILL.md +2 -2
- package/src/claude/skills/docs/references/update-workflow.md +1 -1
- package/src/claude/skills/fix/SKILL.md +153 -0
- package/src/claude/skills/{hotfix → fix}/references/diagnosis-protocol.md +2 -2
- package/src/claude/skills/{hotfix → fix}/references/parallel-patterns.md +2 -2
- package/src/claude/skills/{hotfix → fix}/references/prevention-gate.md +3 -3
- package/src/claude/skills/{hotfix → fix}/references/review-cycle.md +1 -1
- package/src/claude/skills/git/SKILL.md +48 -9
- package/src/claude/skills/git/references/commit-protocols.md +35 -16
- package/src/claude/skills/git/references/worktree-blueprint.md +42 -6
- package/src/claude/skills/orca/SKILL.md +9 -8
- package/src/claude/skills/research/SKILL.md +2 -0
- package/src/claude/skills/scout/SKILL.md +61 -0
- package/src/claude/skills/{inspect → scout}/references/internal-inspection.md +26 -10
- package/src/claude/skills/specs/SKILL.md +22 -29
- package/src/claude/skills/specs/references/review.md +8 -6
- package/src/claude/skills/specs/references/templates.md +34 -41
- package/src/claude/skills/sync/SKILL.md +8 -15
- package/src/claude/skills/sync/references/rebind-and-audit.md +59 -0
- package/src/claude/skills/sync/references/sync-protocols.md +4 -13
- package/src/claude/skills/test/SKILL.md +22 -3
- package/src/claude/skills/test/references/execution-strategy.md +83 -0
- package/src/claude/skills/test/references/failure-triage.md +1 -1
- package/src/claude/skills/ui-ux/LICENSE +21 -0
- package/src/claude/skills/ui-ux/SKILL.md +497 -0
- package/src/claude/skills/ui-ux/references/brand-tokens.md +90 -0
- package/src/claude/skills/ui-ux/references/budgets.md +113 -0
- package/src/claude/skills/ui-ux/references/checklist.md +271 -0
- package/src/claude/skills/ui-ux/references/components/accordion.md +246 -0
- package/src/claude/skills/ui-ux/references/components/avatar.md +153 -0
- package/src/claude/skills/ui-ux/references/components/banner.md +43 -0
- package/src/claude/skills/ui-ux/references/components/breadcrumb.md +201 -0
- package/src/claude/skills/ui-ux/references/components/button.md +142 -0
- package/src/claude/skills/ui-ux/references/components/card.md +113 -0
- package/src/claude/skills/ui-ux/references/components/charts.md +376 -0
- package/src/claude/skills/ui-ux/references/components/chat.md +132 -0
- package/src/claude/skills/ui-ux/references/components/choice-controls.md +369 -0
- package/src/claude/skills/ui-ux/references/components/comment-thread.md +17 -0
- package/src/claude/skills/ui-ux/references/components/description-list.md +116 -0
- package/src/claude/skills/ui-ux/references/components/empty-state.md +98 -0
- package/src/claude/skills/ui-ux/references/components/file-upload.md +107 -0
- package/src/claude/skills/ui-ux/references/components/inline-edit.md +128 -0
- package/src/claude/skills/ui-ux/references/components/input.md +161 -0
- package/src/claude/skills/ui-ux/references/components/list-row.md +56 -0
- package/src/claude/skills/ui-ux/references/components/loading.md +198 -0
- package/src/claude/skills/ui-ux/references/components/logo.md +147 -0
- package/src/claude/skills/ui-ux/references/components/otp-input.md +43 -0
- package/src/claude/skills/ui-ux/references/components/quantity-input.md +100 -0
- package/src/claude/skills/ui-ux/references/components/range-slider.md +16 -0
- package/src/claude/skills/ui-ux/references/components/small-controls.md +340 -0
- package/src/claude/skills/ui-ux/references/components/sortable-header.md +86 -0
- package/src/claude/skills/ui-ux/references/components/tag-input.md +14 -0
- package/src/claude/skills/ui-ux/references/components/timeline.md +38 -0
- package/src/claude/skills/ui-ux/references/components/tree.md +30 -0
- package/src/claude/skills/ui-ux/references/design-process.md +650 -0
- package/src/claude/skills/ui-ux/references/layouts/app-kanban.html +303 -0
- package/src/claude/skills/ui-ux/references/layouts/app.md +1369 -0
- package/src/claude/skills/ui-ux/references/layouts/form.md +430 -0
- package/src/claude/skills/ui-ux/references/layouts/overlay.md +552 -0
- package/src/claude/skills/ui-ux/references/layouts/pricing.md +204 -0
- package/src/claude/skills/ui-ux/references/locked-rules.md +35 -0
- package/src/claude/skills/ui-ux/references/principles.md +408 -0
- package/src/claude/skills/ui-ux/references/refactor.md +245 -0
- package/src/claude/skills/ui-ux/references/responsive.md +195 -0
- package/src/claude/skills/ui-ux/references/review.md +528 -0
- package/src/claude/skills/ui-ux/references/rules-color.md +623 -0
- package/src/claude/skills/ui-ux/references/rules-form.md +235 -0
- package/src/claude/skills/ui-ux/references/rules-state.md +680 -0
- package/src/claude/skills/ui-ux/references/rules-type.md +369 -0
- package/src/claude/skills/ui-ux/references/styles.md +311 -0
- package/src/claude/skills/ui-ux/references/system.md +219 -0
- package/src/claude/skills/ui-ux/references/tailwind-v4-traps.md +237 -0
- package/src/claude/skills/ui-ux/references/tokens.css +362 -0
- package/src/claude/skills/ui-ux/scripts/lint-skill.mjs +216 -0
- package/src/claude/skills/ui-ux/scripts/package.json +9 -0
- package/src/claude/skills/ui-ux/scripts/probe.mjs +4447 -0
- package/src/claude/skills/ui-ux-pro-max/SKILL.md +1 -1
- package/src/claude/status.cjs +149 -144
- package/src/codex/AGENTS.md +8 -13
- package/src/codex/hooks/agent.cjs +2 -1
- package/src/codex/hooks/lib/spec-receipt.cjs +2 -2
- package/src/codex/hooks/lib/spec-utils.cjs +0 -7
- package/src/codex/hooks/rules.cjs +2 -1
- package/src/codex/hooks/session.cjs +0 -2
- package/src/codex/hooks/spec-gate.cjs +7 -18
- package/src/codex/hooks.json +0 -20
- package/src/codex/rules/state-sync.md +3 -12
- package/src/common/AGENTS.md +2 -2
- package/src/omp/extensions/cafekit-bridge.mjs +6 -6
- package/src/claude/hooks/__tests__/completion-authority.test.js +0 -990
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +0 -259
- package/src/claude/hooks/completion-authority-check.cjs +0 -56
- package/src/claude/hooks/completion-authority-state.cjs +0 -527
- package/src/claude/hooks/completion-authority.cjs +0 -163
- package/src/claude/hooks/semantic-review-authority.cjs +0 -154
- package/src/claude/hooks/task-scaffold-guard.cjs +0 -114
- package/src/claude/hooks/usage.cjs +0 -197
- package/src/claude/skills/delegate/SKILL.md +0 -124
- package/src/claude/skills/delegate/references/codex-delegation.md +0 -71
- package/src/claude/skills/delegate/references/grok-delegation.md +0 -84
- package/src/claude/skills/hotfix/SKILL.md +0 -289
- package/src/claude/skills/inspect/SKILL.md +0 -142
- package/src/claude/skills/loop/SKILL.md +0 -114
- package/src/claude/skills/loop/references/bounded-loop-protocol.md +0 -104
- package/src/claude/skills/loop/references/metric-and-guard-contract.md +0 -76
- package/src/claude/skills/route/SKILL.md +0 -94
- package/src/claude/skills/route/references/agent-timing.md +0 -46
- package/src/claude/skills/route/references/chaining-patterns.md +0 -53
- package/src/claude/skills/route/references/task-taxonomy.md +0 -48
- package/src/codex/hooks/completion-authority-check.cjs +0 -67
- package/src/codex/hooks/completion-authority-state.cjs +0 -527
- package/src/codex/hooks/completion-authority.cjs +0 -109
- package/src/codex/hooks/semantic-review-authority.cjs +0 -130
- package/src/codex/hooks/task-scaffold-guard.cjs +0 -63
- /package/src/claude/skills/{question → ask}/templates/question.md +0 -0
- /package/src/claude/skills/{hotfix → fix}/references/escalation-tactics.md +0 -0
- /package/src/claude/skills/{hotfix → fix}/references/workflow-specialized.md +0 -0
|
@@ -0,0 +1,4447 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Mở trang thật ở nhiều bề rộng, đo những lỗi máy đo được, chụp ảnh để mắt soi phần còn lại.
|
|
3
|
+
// Dùng ở cổng 3 của checklist (references/checklist.md). Chỉ đọc trang, không sửa gì.
|
|
4
|
+
//
|
|
5
|
+
// node probe.mjs <url> [--widths 375,768,1024,1280,1440,1920] [--out <thư mục>] [--dark] [--wait 800] [--dpr 1]
|
|
6
|
+
// [--sweep [1440,375,20]] [--wireframe <link phương án đã chọn>]
|
|
7
|
+
//
|
|
8
|
+
// --wireframe: so bản dựng với wireframe đã chọn (design-process.md, U4) ở 1440 và 375: khoảng nào cao thấp khác,
|
|
9
|
+
// chữ nào đổi hay thiếu, cỡ chữ, độ đậm nào khác; link có mau=mau thì so cả màu chữ, màu icon, nền. Ghi vào danh sách P.
|
|
10
|
+
//
|
|
11
|
+
// --sweep: đo xong các khổ cố định thì kéo bề rộng từ 1440 xuống 375, mỗi bước 20px, chụp từng bước và
|
|
12
|
+
// báo khoảng bề rộng có lỗi (cuộn ngang, khung giấu chữ, chữ trong nút xuống dòng, hàng rớt dòng, chữ cắt
|
|
13
|
+
// nuốt mất số hay còn quá ngắn). Bắt
|
|
14
|
+
// lỗi nằm giữa hai khổ cố định, ví dụ nav xuống dòng ở 900px. Dùng ở nhánh soi UI (references/review.md).
|
|
15
|
+
//
|
|
16
|
+
// Playwright tìm theo thứ tự: --pw <thư mục có node_modules/playwright>, thư mục đang đứng, thư mục script.
|
|
17
|
+
// Chưa có thì cài vào một thư mục tạm, đừng cài vào dự án:
|
|
18
|
+
// npm i --prefix "$TMPDIR/evon-probe" playwright && node probe.mjs <url> --pw "$TMPDIR/evon-probe"
|
|
19
|
+
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { dirname, join, resolve } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
const defaultWidths = [375, 768, 1024, 1280, 1440, 1920];
|
|
27
|
+
const defaultSweep = [1440, 375, 20];
|
|
28
|
+
const mobileWidthLimit = 640;
|
|
29
|
+
// Sàn cỡ bấm của skill: nút h-8 trong bảng dày là nhỏ nhất được phép (list-row.md).
|
|
30
|
+
const minTapSize = 32;
|
|
31
|
+
// Số lần bấm Tab tối đa, và số phần tử cùng kiểu (cùng thẻ + class) được chụp so: nút thứ ba trở đi của
|
|
32
|
+
// 8 card giống nhau thì Tab lướt qua, để trang nhiều card vẫn đi tới được nút nổi ở cuối trang.
|
|
33
|
+
const maxTabStops = 160;
|
|
34
|
+
const maxFocusChecksPerKind = 2;
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const options = { url: "", widths: defaultWidths, out: "", isDark: false, waitMs: 800, dpr: 1, playwrightDir: "", sweep: null, wireframeUrl: "" };
|
|
38
|
+
const rest = [...argv];
|
|
39
|
+
|
|
40
|
+
while (rest.length > 0) {
|
|
41
|
+
const arg = rest.shift();
|
|
42
|
+
|
|
43
|
+
if (arg === "--widths") options.widths = rest.shift().split(",").map(Number);
|
|
44
|
+
else if (arg === "--sweep") {
|
|
45
|
+
const [from, to, step] = /^\d+,\d+(,\d+)?$/.test(rest[0] ?? "") ? rest.shift().split(",").map(Number) : defaultSweep;
|
|
46
|
+
options.sweep = { from: Math.max(from, to), to: Math.min(from, to), step: step || defaultSweep[2] };
|
|
47
|
+
}
|
|
48
|
+
else if (arg === "--out") options.out = rest.shift();
|
|
49
|
+
else if (arg === "--dark") options.isDark = true;
|
|
50
|
+
else if (arg === "--wait") options.waitMs = Number(rest.shift());
|
|
51
|
+
else if (arg === "--dpr") options.dpr = Number(rest.shift());
|
|
52
|
+
else if (arg === "--pw") options.playwrightDir = rest.shift();
|
|
53
|
+
else if (arg === "--wireframe") options.wireframeUrl = rest.shift();
|
|
54
|
+
else if (!arg.startsWith("--")) options.url = arg;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!options.out) options.out = join(tmpdir(), `evon-probe-${Date.now()}`);
|
|
58
|
+
|
|
59
|
+
return options;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadPlaywright(playwrightDir) {
|
|
63
|
+
const searchDirs = [playwrightDir, process.cwd(), fileURLToPath(new URL(".", import.meta.url))].filter(Boolean);
|
|
64
|
+
|
|
65
|
+
for (const searchDir of searchDirs) {
|
|
66
|
+
try {
|
|
67
|
+
return createRequire(join(resolve(searchDir), "noop.js"))("playwright");
|
|
68
|
+
} catch {
|
|
69
|
+
// Thử thư mục kế tiếp.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function launchBrowser(chromium) {
|
|
77
|
+
try {
|
|
78
|
+
return await chromium.launch();
|
|
79
|
+
} catch {
|
|
80
|
+
// Chưa tải Chromium của Playwright thì dùng Chrome đã cài trên máy.
|
|
81
|
+
return chromium.launch({ channel: "chrome" });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------- Các phép đo, chạy trong trang ----------
|
|
86
|
+
|
|
87
|
+
// Tắt transition và animation để đo và chụp ra trạng thái cuối, không phải giữa chừng.
|
|
88
|
+
const freezeMotionCss = "*,*::before,*::after{transition:none!important;animation-duration:0s!important;animation-delay:0s!important;caret-color:transparent!important}";
|
|
89
|
+
|
|
90
|
+
// Probe tắt mọi chuyển động trước khi đo (ảnh chụp ổn định), nên ghi lại `transition` của từng phần tử vào
|
|
91
|
+
// `data-evon-transition` trước đó, để các mục đo chuyển động (18f, 18g) còn đọc được.
|
|
92
|
+
function stampTransitions() {
|
|
93
|
+
for (const element of document.body.querySelectorAll("*")) {
|
|
94
|
+
const style = getComputedStyle(element);
|
|
95
|
+
if (parseFloat(style.transitionDuration) > 0) element.dataset.evonTransition = style.transitionProperty;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function measureInPage({ minTapSize, isMobile, isSweep = false }) {
|
|
100
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
101
|
+
|
|
102
|
+
// 0. Trang tự cuộn khi vừa tải: cửa sổ hay khung cuộn chính (cao từ 60% màn) đã rời đầu trang trước
|
|
103
|
+
// khi ai chạm vào. Khung nhỏ cuộn sẵn xuống đáy (danh sách tin nhắn) là cố ý, không tính.
|
|
104
|
+
const autoScrolledAreas = [];
|
|
105
|
+
if (window.scrollY > 0) autoScrolledAreas.push(`cửa sổ đã cuộn ${Math.round(window.scrollY)}px`);
|
|
106
|
+
for (const container of document.querySelectorAll("body *")) {
|
|
107
|
+
const overflowY = getComputedStyle(container).overflowY;
|
|
108
|
+
if (!["auto", "scroll"].includes(overflowY) || container.scrollTop <= 0 || container.clientHeight < window.innerHeight * 0.6) continue;
|
|
109
|
+
// Lưới giờ cuộn sẵn tới vạch "bây giờ" (`data-now`, `layouts/app.md` "Lưới giờ trong ngày") là cố ý: vạch
|
|
110
|
+
// nằm trong phần đang thấy của khung thì không tính (báo nhầm 30/09/2026, lịch hẹn nha khoa).
|
|
111
|
+
const containerRect = container.getBoundingClientRect();
|
|
112
|
+
const isScrolledToNow = [...container.querySelectorAll("[data-now]")].some((marker) => {
|
|
113
|
+
const markerRect = marker.getBoundingClientRect();
|
|
114
|
+
return markerRect.top >= containerRect.top && markerRect.bottom <= containerRect.bottom;
|
|
115
|
+
});
|
|
116
|
+
if (isScrolledToNow) continue;
|
|
117
|
+
const classes = (container.getAttribute("class") || "").trim().split(/\s+/).slice(0, 5).join(".");
|
|
118
|
+
autoScrolledAreas.push(`${container.tagName.toLowerCase()}${classes ? "." + classes : ""} đã cuộn ${Math.round(container.scrollTop)}px`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function describe(element) {
|
|
122
|
+
const tag = element.tagName.toLowerCase();
|
|
123
|
+
const label = (element.getAttribute("aria-label") || element.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40);
|
|
124
|
+
const classes = (element.getAttribute("class") || "").trim().split(/\s+/).slice(0, 6).join(".");
|
|
125
|
+
|
|
126
|
+
return `${tag}${classes ? "." + classes : ""}${label ? ` "${label}"` : ""}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isVisible(element) {
|
|
130
|
+
const rect = element.getBoundingClientRect();
|
|
131
|
+
const style = getComputedStyle(element);
|
|
132
|
+
|
|
133
|
+
return rect.width > 2 && rect.height > 2 && style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity) > 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isClippedHorizontally(element) {
|
|
137
|
+
for (let ancestor = element.parentElement; ancestor && ancestor !== document.body; ancestor = ancestor.parentElement) {
|
|
138
|
+
const overflowX = getComputedStyle(ancestor).overflowX;
|
|
139
|
+
if (overflowX !== "visible") return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getFirstTextRect(element) {
|
|
146
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
|
|
147
|
+
acceptNode: (textNode) => (textNode.textContent.trim() && isVisible(textNode.parentElement) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT),
|
|
148
|
+
});
|
|
149
|
+
const textNode = walker.nextNode();
|
|
150
|
+
if (!textNode) return null;
|
|
151
|
+
|
|
152
|
+
const range = document.createRange();
|
|
153
|
+
const text = textNode.textContent;
|
|
154
|
+
const start = text.length - text.trimStart().length;
|
|
155
|
+
range.setStart(textNode, start);
|
|
156
|
+
range.setEnd(textNode, text.trimEnd().length);
|
|
157
|
+
|
|
158
|
+
const rect = range.getBoundingClientRect();
|
|
159
|
+
|
|
160
|
+
return { left: rect.left, right: rect.right, width: rect.width, text: text.trim().slice(0, 12), node: textNode };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Chữ trong chip / badge (khối có nền hoặc viền) thì mép thẳng cột là mép khối, không phải mép chữ:
|
|
164
|
+
// chữ "VIP" thụt 8px trong pill là đúng (báo nhầm 27/09/2026, panel xem nhanh khách hàng).
|
|
165
|
+
function getBoxedTextEdge(textRect, cell) {
|
|
166
|
+
for (let node = textRect.node.parentElement; node && node !== cell; node = node.parentElement) {
|
|
167
|
+
const style = getComputedStyle(node);
|
|
168
|
+
const hasFill = style.backgroundColor !== "rgba(0, 0, 0, 0)" && style.backgroundColor !== "transparent";
|
|
169
|
+
const hasBorder = parseFloat(style.borderLeftWidth) > 0 && style.borderLeftStyle !== "none";
|
|
170
|
+
if (hasFill || hasBorder) {
|
|
171
|
+
const boxRect = node.getBoundingClientRect();
|
|
172
|
+
|
|
173
|
+
return { left: boxRect.left, right: boxRect.right };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return textRect;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const isColorClass = (className) =>
|
|
181
|
+
/^(bg|fill|stroke|ring|inset-ring|outline|decoration|shadow|from|via|to)-/.test(className) ||
|
|
182
|
+
(/^text-/.test(className) && !/^text-(xs|sm|base|lg|\d?xl|\[)/.test(className)) ||
|
|
183
|
+
(/^border-/.test(className) && !/^border-(\d|[trblxy]($|-\d))/.test(className));
|
|
184
|
+
// Tập các đường thẻ + class (bỏ class màu) của con cháu ba tầng, không tính số lượng: ô lịch hai việc
|
|
185
|
+
// với ô ba việc cùng cấu trúc, nên ô ba việc cao lệch 2px vẫn bị bắt (26/09/2026).
|
|
186
|
+
function getStructureSignature(element) {
|
|
187
|
+
const paths = new Set();
|
|
188
|
+
const visit = (node, prefix, depth) => {
|
|
189
|
+
for (const child of node.children) {
|
|
190
|
+
const classKey = (child.getAttribute("class") || "").split(/\s+/).filter((className) => className && !isColorClass(className)).sort().join(".");
|
|
191
|
+
const path = `${prefix}>${child.tagName}.${classKey}`;
|
|
192
|
+
paths.add(path);
|
|
193
|
+
if (depth < 3) visit(child, path, depth + 1);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
visit(element, "", 1);
|
|
197
|
+
|
|
198
|
+
return [...paths].sort().join("|");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Thanh công cụ và khung lý do của trang wireframe (`design-process.md` U3) nằm ngoài bản thiết kế: không đo.
|
|
202
|
+
// Đã báo nhầm 30/09/2026, wireframe nha khoa: "hàng nút header không đồng cỡ", chỗ bấm nhỏ, vạch lệch với thanh.
|
|
203
|
+
const isWireframeChrome = (element) => Boolean(element.closest(".wf-bar, .wf-reason, [data-wf-reason]"));
|
|
204
|
+
const allElements = [...document.body.querySelectorAll("*")].filter((element) => !["SCRIPT", "STYLE", "svg", "path"].includes(element.tagName) && !isWireframeChrome(element));
|
|
205
|
+
|
|
206
|
+
// 1. Cuộn ngang: trang rộng hơn màn, và phần tử nào lòi ra ngoài mép phải.
|
|
207
|
+
const pageScrollWidth = document.documentElement.scrollWidth;
|
|
208
|
+
const overflowingElements = allElements
|
|
209
|
+
.filter((element) => isVisible(element) && element.getBoundingClientRect().right > viewportWidth + 1 && !isClippedHorizontally(element))
|
|
210
|
+
.map((element) => ({ element: describe(element), right: Math.round(element.getBoundingClientRect().right) }))
|
|
211
|
+
.slice(0, 8);
|
|
212
|
+
|
|
213
|
+
// 1b. Khung giấu mất chữ: khung overflow hidden / clip mà có chữ bên trong nằm ngoài khung (hàng chip
|
|
214
|
+
// cao cố định giấu hàng thứ hai, số liệu bị xén). Chữ nằm trong một khung cắt hay khung cuộn nhỏ
|
|
215
|
+
// hơn thì để khung đó tự báo: dấu … và line-clamp đã có mục 2, bảng cuộn ngang là cố ý.
|
|
216
|
+
function findHiddenText(container, isClippingX, isClippingY) {
|
|
217
|
+
const box = container.getBoundingClientRect();
|
|
218
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
219
|
+
|
|
220
|
+
for (let textNode = walker.nextNode(); textNode; textNode = walker.nextNode()) {
|
|
221
|
+
const holder = textNode.parentElement;
|
|
222
|
+
if (!textNode.textContent.trim() || !holder || holder.closest("[aria-hidden='true'], [inert]")) continue;
|
|
223
|
+
if (getComputedStyle(holder).visibility === "hidden") continue;
|
|
224
|
+
|
|
225
|
+
let isInnerClip = false;
|
|
226
|
+
for (let node = holder; node && node !== container; node = node.parentElement) {
|
|
227
|
+
const style = getComputedStyle(node);
|
|
228
|
+
if (style.overflowX !== "visible" || style.overflowY !== "visible") isInnerClip = true;
|
|
229
|
+
}
|
|
230
|
+
if (isInnerClip) continue;
|
|
231
|
+
|
|
232
|
+
const range = document.createRange();
|
|
233
|
+
range.selectNodeContents(textNode);
|
|
234
|
+
for (const rect of range.getClientRects()) {
|
|
235
|
+
if (rect.width === 0) continue;
|
|
236
|
+
const isOutsideX = isClippingX && (rect.right > box.right + 1 || rect.left < box.left - 1);
|
|
237
|
+
const isOutsideY = isClippingY && (rect.bottom > box.bottom + 1 || rect.top < box.top - 1);
|
|
238
|
+
if (isOutsideX || isOutsideY) {
|
|
239
|
+
return textNode.textContent.trim().replace(/\s+/g, " ").slice(0, 30);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const clippedBlocks = [];
|
|
248
|
+
for (const element of allElements) {
|
|
249
|
+
if (clippedBlocks.length >= 8) break;
|
|
250
|
+
const style = getComputedStyle(element);
|
|
251
|
+
// Xét từng chiều: cột trang `overflow-x-hidden overflow-y-auto` cuộn dọc được, chữ dưới mép màn
|
|
252
|
+
// không bị giấu (báo nhầm 27/09/2026, dự án mồi phase 2, mọi khổ desktop).
|
|
253
|
+
const isClippingX = ["hidden", "clip"].includes(style.overflowX);
|
|
254
|
+
const isClippingY = ["hidden", "clip"].includes(style.overflowY);
|
|
255
|
+
const isEllipsis = style.textOverflow === "ellipsis" || style.webkitLineClamp !== "none";
|
|
256
|
+
const hasMoreContent = (isClippingX && element.scrollWidth > element.clientWidth + 1) || (isClippingY && element.scrollHeight > element.clientHeight + 1);
|
|
257
|
+
if (isEllipsis || !hasMoreContent || !isVisible(element)) continue;
|
|
258
|
+
|
|
259
|
+
const hiddenText = findHiddenText(element, isClippingX, isClippingY);
|
|
260
|
+
if (hiddenText) clippedBlocks.push({ element: describe(element), hiddenText });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 1c. Chữ trong nút, link, tab xuống hai dòng: nút bị bóp. Chỉ tính nhãn ngắn một mảnh chữ; link nằm
|
|
264
|
+
// trong đoạn văn, card bọc link, mục menu có dòng mô tả thì nhiều dòng là đúng.
|
|
265
|
+
const wrappedControls = [];
|
|
266
|
+
for (const control of document.querySelectorAll("a, button, [role='tab'], [role='menuitem']")) {
|
|
267
|
+
if (wrappedControls.length >= 8) break;
|
|
268
|
+
if (!isVisible(control) || getComputedStyle(control).display === "inline") continue;
|
|
269
|
+
// Nút "nhãn dài" cố ý đặt trong khung hẹp trên trang design system (`D9`, `data-demo-state`): báo mãi ở
|
|
270
|
+
// cả 4a lẫn 4b ngày 30/09/2026.
|
|
271
|
+
if (control.closest("[data-demo-state]")) continue;
|
|
272
|
+
// Ô dạng icon trên chữ dưới (ô danh mục cao từ 56px) thì nhãn hai dòng là thiết kế, không phải nút bị
|
|
273
|
+
// bóp (báo nhầm 27/09/2026, ô danh mục ở dự án mồi phase 2).
|
|
274
|
+
const controlStyle = getComputedStyle(control);
|
|
275
|
+
if (controlStyle.flexDirection.startsWith("column") && controlStyle.display.includes("flex") && control.getBoundingClientRect().height >= 56) continue;
|
|
276
|
+
|
|
277
|
+
const textNodes = [];
|
|
278
|
+
const walker = document.createTreeWalker(control, NodeFilter.SHOW_TEXT);
|
|
279
|
+
for (let textNode = walker.nextNode(); textNode; textNode = walker.nextNode()) if (textNode.textContent.trim()) textNodes.push(textNode);
|
|
280
|
+
if (textNodes.length !== 1 || textNodes[0].textContent.trim().length > 40) continue;
|
|
281
|
+
|
|
282
|
+
const range = document.createRange();
|
|
283
|
+
range.selectNodeContents(textNodes[0]);
|
|
284
|
+
const lineTops = new Set([...range.getClientRects()].filter((rect) => rect.width > 0).map((rect) => Math.round(rect.top)));
|
|
285
|
+
if (lineTops.size > 1) wrappedControls.push(describe(control));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 1d. Hàng rớt dòng trong header, nav, thanh công cụ, thanh tab: con của một hàng flex-wrap nằm trên
|
|
289
|
+
// hai dòng. Lưới card flex-wrap ở thân trang thì rớt dòng là cố ý, không đo.
|
|
290
|
+
const wrappedRows = [];
|
|
291
|
+
for (const row of document.querySelectorAll("header, header *, nav, nav *, [role='toolbar'], [role='tablist']")) {
|
|
292
|
+
if (wrappedRows.length >= 6) break;
|
|
293
|
+
const style = getComputedStyle(row);
|
|
294
|
+
if (!style.display.includes("flex") || !style.flexDirection.startsWith("row") || style.flexWrap !== "wrap" || !isVisible(row)) continue;
|
|
295
|
+
|
|
296
|
+
const visibleChildren = [...row.children].filter(isVisible);
|
|
297
|
+
const children = visibleChildren.map((child) => child.getBoundingClientRect());
|
|
298
|
+
if (children.length < 2) continue;
|
|
299
|
+
const shortestHeight = Math.min(...children.map((rect) => rect.height));
|
|
300
|
+
const topSpread = Math.max(...children.map((rect) => rect.top)) - Math.min(...children.map((rect) => rect.top));
|
|
301
|
+
if (topSpread <= shortestHeight / 2) continue;
|
|
302
|
+
// Chỉ dòng chữ (không có control) tách lên dòng riêng, các control vẫn chung một hàng: phân trang
|
|
303
|
+
// "1–8 trên 34" nằm trên dãy số trang ở màn hẹp là cố ý (báo nhầm hai lần 30/09/2026, dự án mồi kho hàng).
|
|
304
|
+
const interactiveSelector = "a[href], button, input, select, textarea, [role='button'], [role='tab'], [tabindex]";
|
|
305
|
+
const controlTops = visibleChildren
|
|
306
|
+
.filter((child) => child.matches(interactiveSelector) || child.querySelector(interactiveSelector))
|
|
307
|
+
.map((child) => Math.round(child.getBoundingClientRect().top));
|
|
308
|
+
const isCaptionOnlyWrap = controlTops.length > 0 && controlTops.length < visibleChildren.length && Math.max(...controlTops) - Math.min(...controlTops) <= shortestHeight / 2;
|
|
309
|
+
if (!isCaptionOnlyWrap) wrappedRows.push(describe(row));
|
|
310
|
+
}
|
|
311
|
+
// 1d2. Hàng nút ở bất kỳ đâu (footer panel, card) mà nút chỉ icon (⋯) rớt xuống dòng dưới một mình: nhìn
|
|
312
|
+
// như một nút lạc (30/09/2026, panel chi tiết 352px của wireframe lịch hẹn: "Bắt đầu khám", "Mở hồ sơ"
|
|
313
|
+
// một dòng, ⋯ dòng dưới). Sửa: nút không `flex-1`, rút nhãn, hoặc ⋯ lên header panel.
|
|
314
|
+
for (const row of document.querySelectorAll("div, footer, section, ul, ol, p")) {
|
|
315
|
+
if (wrappedRows.length >= 6) break;
|
|
316
|
+
const style = getComputedStyle(row);
|
|
317
|
+
if (!style.display.includes("flex") || !style.flexDirection.startsWith("row") || style.flexWrap !== "wrap" || !isVisible(row)) continue;
|
|
318
|
+
const children = [...row.children].filter(isVisible);
|
|
319
|
+
if (children.length < 2) continue;
|
|
320
|
+
const lastChild = children.at(-1);
|
|
321
|
+
const lastTop = lastChild.getBoundingClientRect().top;
|
|
322
|
+
const isAlone = children.slice(0, -1).every((child) => child.getBoundingClientRect().bottom <= lastTop + 1);
|
|
323
|
+
if (!isAlone) continue;
|
|
324
|
+
const isButtonRow = children.every((child) => child.matches("button, a[href], [role='button']"));
|
|
325
|
+
if (isButtonRow && !lastChild.textContent.trim()) {
|
|
326
|
+
wrappedRows.push(`nút chỉ icon rớt dòng một mình: ${describe(lastChild)} trong ${describe(row)}`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
// `R3`: hàng mục ngắn (chú thích, số đếm, chip) từ ba mục mà dòng dưới chỉ còn một mục (30/09/2026, chú
|
|
330
|
+
// thích sơ đồ răng 375px: "Mất răng" rớt một mình). Lưới card (mục rộng quá nửa hàng) thì rớt là bình thường.
|
|
331
|
+
const rowWidth = row.getBoundingClientRect().width;
|
|
332
|
+
const isShortItems = children.every((child) => child.getBoundingClientRect().width < rowWidth / 2 && child.getBoundingClientRect().height < 48);
|
|
333
|
+
if (children.length >= 3 && isShortItems) wrappedRows.push(`mục rớt dòng một mình (R3): ${describe(lastChild)} trong ${describe(row)}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 2. Chữ bị cắt còn quá ngắn: ô chỉ đọc được vài ký tự thì như không có chữ.
|
|
337
|
+
const truncatedTexts = allElements
|
|
338
|
+
.filter((element) => {
|
|
339
|
+
const style = getComputedStyle(element);
|
|
340
|
+
const isEllipsis = style.textOverflow === "ellipsis" || style.webkitLineClamp !== "none";
|
|
341
|
+
|
|
342
|
+
return isEllipsis && isVisible(element) && (element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1);
|
|
343
|
+
})
|
|
344
|
+
.map((element) => {
|
|
345
|
+
const fullText = element.textContent.trim().replace(/\s+/g, " ");
|
|
346
|
+
const style = getComputedStyle(element);
|
|
347
|
+
const isClamp = style.webkitLineClamp !== "none";
|
|
348
|
+
const visibleRatio = isClamp ? element.clientHeight / element.scrollHeight : element.clientWidth / element.scrollWidth;
|
|
349
|
+
|
|
350
|
+
return { element: describe(element), fullText, visibleChars: Math.floor(fullText.length * visibleRatio), width: Math.round(element.clientWidth), isSingleLine: !isClamp };
|
|
351
|
+
});
|
|
352
|
+
const tooShortTexts = truncatedTexts.filter((item) => item.visibleChars < 10 && item.fullText.length > item.visibleChars + 3);
|
|
353
|
+
|
|
354
|
+
// 18d. Chữ cắt nuốt mất số: dòng `truncate` một dòng mà phần bị giấu có số kèm đơn vị (m², triệu, đ, %).
|
|
355
|
+
// Số thường là thứ người dùng dùng để so sánh ("Duplex gác xép… " nuốt "210m²", 28/09/2026).
|
|
356
|
+
// Ô chỉ có một con số (số liệu, giá) mà bị cắt thì cắt ở đâu cũng đọc thành số khác: "1.284.500.…"
|
|
357
|
+
// của "1.284.500.000 ₫", thẻ số liệu hẹp ở 816–989px (sót 30/09/2026, lịch khám: phần giấu "000 ₫"
|
|
358
|
+
// không lọt phép cũ vì lượt quét chưa đo chữ cắt).
|
|
359
|
+
const numberWithUnit = /\d+(?:[.,]\d+)?\s?(?:m²|m2|triệu|tr\b|đ\b|₫|%|km\b|người|phòng)/i;
|
|
360
|
+
const numberOnlyValue = /^[\s\d.,:+\-–%$€£¥₫]*\d[\s\d.,:+\-–%$€£¥₫]*(?:đ|vnđ|vnd|tr|triệu|tỷ|k)?$/i;
|
|
361
|
+
const swallowedNumbers = truncatedTexts
|
|
362
|
+
.filter((item) => item.isSingleLine && (numberWithUnit.test(item.fullText.slice(item.visibleChars)) || numberOnlyValue.test(item.fullText)))
|
|
363
|
+
.slice(0, 6)
|
|
364
|
+
.map((item) => `giấu "${item.fullText.slice(item.visibleChars).trim().slice(0, 24)}" của "${item.fullText.slice(0, 32)}": ${item.element}`);
|
|
365
|
+
|
|
366
|
+
if (isSweep) {
|
|
367
|
+
return {
|
|
368
|
+
autoScrolledAreas,
|
|
369
|
+
viewportWidth,
|
|
370
|
+
pageScrollWidth,
|
|
371
|
+
hasHorizontalScroll: pageScrollWidth > viewportWidth + 1,
|
|
372
|
+
overflowingElements: overflowingElements.slice(0, 3),
|
|
373
|
+
clippedBlocks,
|
|
374
|
+
wrappedControls,
|
|
375
|
+
wrappedRows,
|
|
376
|
+
tooShortTexts,
|
|
377
|
+
swallowedNumbers,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 3. Anh em cùng loại cao gần bằng mà không bằng (lệch 1-4px): thường là khe baseline của
|
|
382
|
+
// inline-block, viền thừa, padding lệch. Lệch lớn là nội dung khác, bỏ qua.
|
|
383
|
+
const unevenSiblingGroups = [];
|
|
384
|
+
for (const parent of allElements) {
|
|
385
|
+
const groups = new Map();
|
|
386
|
+
|
|
387
|
+
for (const child of parent.children) {
|
|
388
|
+
if (!isVisible(child)) continue;
|
|
389
|
+
// Bỏ class viền một cạnh khỏi khoá: ô cuối hàng thiếu border-r vẫn là cùng loại ô.
|
|
390
|
+
const classKey = (child.getAttribute("class") || "").split(/\s+/).filter((className) => !/^border-[trblxy]$/.test(className)).join(" ");
|
|
391
|
+
const key = `${child.tagName}.${classKey}`;
|
|
392
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
393
|
+
groups.get(key).push(child);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Chỉ so các khối cùng cấu trúc con: hàng có badge `py-1` cao hơn hàng chữ trơn, mục gói tên 16px
|
|
397
|
+
// cao hơn mục thẻ tên 14px, là nội dung khác chứ không phải khe baseline (báo nhầm 27/09/2026,
|
|
398
|
+
// danh sách mô tả ở /components, /dashboard/settings/billing/states). Class màu bỏ khỏi khoá:
|
|
399
|
+
// badge xanh với badge xám vẫn cùng cấu trúc.
|
|
400
|
+
const splitByStructure = (siblings) => {
|
|
401
|
+
const byStructure = new Map();
|
|
402
|
+
for (const sibling of siblings) {
|
|
403
|
+
const structure = getStructureSignature(sibling);
|
|
404
|
+
if (!byStructure.has(structure)) byStructure.set(structure, []);
|
|
405
|
+
byStructure.get(structure).push(sibling);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return [...byStructure.values()];
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
for (const siblings of [...groups.values()].flatMap(splitByStructure)) {
|
|
412
|
+
if (siblings.length < 3) continue;
|
|
413
|
+
|
|
414
|
+
const innerHeights = siblings.map((sibling) => {
|
|
415
|
+
const style = getComputedStyle(sibling);
|
|
416
|
+
|
|
417
|
+
return sibling.getBoundingClientRect().height - parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth);
|
|
418
|
+
});
|
|
419
|
+
const heightCounts = new Map();
|
|
420
|
+
for (const height of innerHeights) heightCounts.set(Math.round(height), (heightCounts.get(Math.round(height)) || 0) + 1);
|
|
421
|
+
const commonHeight = [...heightCounts.entries()].sort((first, second) => second[1] - first[1])[0][0];
|
|
422
|
+
const nearMisses = innerHeights.filter((height) => Math.abs(height - commonHeight) >= 0.75 && Math.abs(height - commonHeight) <= 4);
|
|
423
|
+
|
|
424
|
+
if (nearMisses.length > 0) {
|
|
425
|
+
unevenSiblingGroups.push({
|
|
426
|
+
element: describe(siblings[0]),
|
|
427
|
+
count: siblings.length,
|
|
428
|
+
commonHeight,
|
|
429
|
+
otherHeights: [...new Set(nearMisses.map((height) => Math.round(height * 10) / 10))],
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// 4. Chữ cùng cột lệch mép: các lưới cùng khung cột (hàng tiêu đề + lưới ô), mỗi cột so mép
|
|
436
|
+
// chữ đầu tiên của từng ô. Chữ không căn giữa ô mà mép trái lẫn mép phải đều lệch vài px là
|
|
437
|
+
// hai kiểu căn trộn nhau (vd tên thứ căn trái, số ngày căn giữa một vòng tròn nhỏ).
|
|
438
|
+
const gridsBySignature = new Map();
|
|
439
|
+
for (const element of allElements) {
|
|
440
|
+
const style = getComputedStyle(element);
|
|
441
|
+
if (style.display !== "grid" || !isVisible(element) || element.children.length < 2) continue;
|
|
442
|
+
|
|
443
|
+
const columnCount = style.gridTemplateColumns.split(" ").length;
|
|
444
|
+
if (columnCount < 2) continue;
|
|
445
|
+
|
|
446
|
+
const signature = `${Math.round(element.getBoundingClientRect().left)}|${Math.round(element.getBoundingClientRect().width)}|${columnCount}`;
|
|
447
|
+
if (!gridsBySignature.has(signature)) gridsBySignature.set(signature, []);
|
|
448
|
+
gridsBySignature.get(signature).push(element);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const misalignedColumns = [];
|
|
452
|
+
for (const grids of gridsBySignature.values()) {
|
|
453
|
+
const cellsByColumn = new Map();
|
|
454
|
+
|
|
455
|
+
for (const grid of grids) {
|
|
456
|
+
for (const cell of grid.children) {
|
|
457
|
+
if (!isVisible(cell)) continue;
|
|
458
|
+
const cellRect = cell.getBoundingClientRect();
|
|
459
|
+
const textRect = getFirstTextRect(cell);
|
|
460
|
+
if (!textRect || textRect.width === 0) continue;
|
|
461
|
+
|
|
462
|
+
const columnKey = Math.round(cellRect.left);
|
|
463
|
+
if (!cellsByColumn.has(columnKey)) cellsByColumn.set(columnKey, []);
|
|
464
|
+
const edge = getBoxedTextEdge(textRect, cell);
|
|
465
|
+
cellsByColumn.get(columnKey).push({
|
|
466
|
+
cell,
|
|
467
|
+
leftOffset: edge.left - cellRect.left,
|
|
468
|
+
rightOffset: cellRect.right - edge.right,
|
|
469
|
+
textLeftOffset: textRect.left - cellRect.left,
|
|
470
|
+
textRightOffset: cellRect.right - textRect.right,
|
|
471
|
+
isBoxed: edge !== textRect,
|
|
472
|
+
centerDelta: textRect.left + textRect.width / 2 - (cellRect.left + cellRect.width / 2),
|
|
473
|
+
text: textRect.text,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const cells of cellsByColumn.values()) {
|
|
479
|
+
// Ô căn giữa cả ô (lịch chọn ngày) thì mép chữ lệch theo độ dài là đúng, bỏ qua.
|
|
480
|
+
const startAlignedCells = cells.filter((item) => Math.abs(item.centerDelta) > 2);
|
|
481
|
+
if (startAlignedCells.length < 3) continue;
|
|
482
|
+
|
|
483
|
+
// Chữ trong khối có nền: mép khối hoặc mép chữ trùng cột đều được. Pill "VIP" thẳng theo mép
|
|
484
|
+
// khối; số hôm nay trong vòng `min-w-7` thẳng theo mép chữ với tên thứ (báo nhầm 27/09/2026, lịch
|
|
485
|
+
// tháng: "27" đúng mép chữ "CN" nhưng mép vòng lệch 6px).
|
|
486
|
+
const plainCells = startAlignedCells.filter((item) => !item.isBoxed);
|
|
487
|
+
if (plainCells.length > 0) {
|
|
488
|
+
const plainLeft = plainCells.map((item) => item.leftOffset).sort((first, second) => first - second)[Math.floor(plainCells.length / 2)];
|
|
489
|
+
const plainRight = plainCells.map((item) => item.rightOffset).sort((first, second) => first - second)[Math.floor(plainCells.length / 2)];
|
|
490
|
+
for (const item of startAlignedCells) {
|
|
491
|
+
if (!item.isBoxed) continue;
|
|
492
|
+
if (Math.abs(item.textLeftOffset - plainLeft) < Math.abs(item.leftOffset - plainLeft)) item.leftOffset = item.textLeftOffset;
|
|
493
|
+
if (Math.abs(item.textRightOffset - plainRight) < Math.abs(item.rightOffset - plainRight)) item.rightOffset = item.textRightOffset;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const leftOffsets = startAlignedCells.map((item) => item.leftOffset);
|
|
498
|
+
const rightOffsets = startAlignedCells.map((item) => item.rightOffset);
|
|
499
|
+
const leftSpread = Math.max(...leftOffsets) - Math.min(...leftOffsets);
|
|
500
|
+
const rightSpread = Math.max(...rightOffsets) - Math.min(...rightOffsets);
|
|
501
|
+
|
|
502
|
+
if (leftSpread > 1.5 && leftSpread <= 8 && rightSpread > 1.5) {
|
|
503
|
+
const leftmost = startAlignedCells.reduce((best, item) => (item.leftOffset < best.leftOffset ? item : best));
|
|
504
|
+
const rightmost = startAlignedCells.reduce((best, item) => (item.leftOffset > best.leftOffset ? item : best));
|
|
505
|
+
misalignedColumns.push({
|
|
506
|
+
element: describe(startAlignedCells[0].cell.parentElement),
|
|
507
|
+
leftSpread: Math.round(leftSpread * 10) / 10,
|
|
508
|
+
example: `"${leftmost.text}" cách mép ô ${leftmost.leftOffset.toFixed(1)}px, "${rightmost.text}" ${rightmost.leftOffset.toFixed(1)}px`,
|
|
509
|
+
});
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// 5. Chỗ bấm dưới 44px ở màn cảm ứng. Nút nhỏ mà có vùng bấm nới ra (::before phủ 44px) thì
|
|
516
|
+
// elementFromPoint ở mép 44px vẫn trúng chính nó, không tính là lỗi.
|
|
517
|
+
const smallTapTargets = [];
|
|
518
|
+
if (isMobile) {
|
|
519
|
+
// scrollIntoView cuộn cả khung cuộn bên trong (bảng cuộn ngang), window.scrollTo cuối vòng không
|
|
520
|
+
// trả chúng về: ảnh chụp sau đó ra bảng lệch hẳn sang phải, mất cột tên (đã dính 27/09/2026,
|
|
521
|
+
// /dashboard/tasks ở 375px). Ghi vị trí cuộn của mọi khung trước, trả lại sau.
|
|
522
|
+
const scrolledContainers = [...document.querySelectorAll("*")]
|
|
523
|
+
.filter((container) => container.scrollWidth > container.clientWidth || container.scrollHeight > container.clientHeight)
|
|
524
|
+
.map((container) => ({ container, left: container.scrollLeft, top: container.scrollTop }));
|
|
525
|
+
const interactiveElements = [...document.querySelectorAll('button, a[href], input:not([type="hidden"]), select, textarea, [role="button"], [role="tab"], [role="checkbox"], [role="switch"], [role="menuitem"]')];
|
|
526
|
+
|
|
527
|
+
for (const element of interactiveElements) {
|
|
528
|
+
if (!isVisible(element) || element.closest("[inert], [aria-hidden='true']") || isWireframeChrome(element)) continue;
|
|
529
|
+
// Chỗ bấm nằm trong câu (link, nút `inline` như "Xoá tìm kiếm" cuối câu rỗng, `empty-state.md`) được
|
|
530
|
+
// WCAG 2.5.8 miễn cỡ. Trước chỉ miễn thẻ <a>: báo nhầm 30/09/2026, design system phòng khám bản shadcn.
|
|
531
|
+
// Nút `inline` thì trình duyệt tính ra `inline-block`, nên nhận bằng chữ trơn đứng cạnh trong cùng cha.
|
|
532
|
+
const elementDisplay = getComputedStyle(element).display;
|
|
533
|
+
const isInSentence = elementDisplay === "inline-block" && [...element.parentElement.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim().length > 1);
|
|
534
|
+
if (elementDisplay === "inline" || isInSentence) continue;
|
|
535
|
+
// Không nhận chạm thì không phải chỗ bấm: input range chồng dưới thanh trượt hai đầu.
|
|
536
|
+
if (getComputedStyle(element).pointerEvents === "none") continue;
|
|
537
|
+
|
|
538
|
+
const rect = element.getBoundingClientRect();
|
|
539
|
+
if (rect.width >= minTapSize && rect.height >= minTapSize) continue;
|
|
540
|
+
// Ô nằm trong <label> đủ cỡ (dòng lựa chọn min-h-11, I26): cả nhãn là vùng bấm. Ô đứng sát mép
|
|
541
|
+
// trái nhãn nên điểm thử bên trái rơi ra ngoài; đã báo nhầm 27/09/2026, radio ở /dashboard/tasks/new.
|
|
542
|
+
const hasLargeWrappingLabel = [...(element.labels || [])].some((label) => {
|
|
543
|
+
const labelRect = label.getBoundingClientRect();
|
|
544
|
+
|
|
545
|
+
return label.contains(element) && labelRect.width >= minTapSize && labelRect.height >= minTapSize;
|
|
546
|
+
});
|
|
547
|
+
if (hasLargeWrappingLabel) continue;
|
|
548
|
+
|
|
549
|
+
element.scrollIntoView({ block: "center", inline: "center" });
|
|
550
|
+
const centeredRect = element.getBoundingClientRect();
|
|
551
|
+
// Nằm ngoài màn (sidebar trượt ra ngoài khi đóng) thì người dùng không bấm được, bỏ qua.
|
|
552
|
+
if (centeredRect.right <= 0 || centeredRect.left >= viewportWidth) continue;
|
|
553
|
+
const centerX = centeredRect.left + centeredRect.width / 2;
|
|
554
|
+
const centerY = centeredRect.top + centeredRect.height / 2;
|
|
555
|
+
const reach = minTapSize / 2 - 1;
|
|
556
|
+
const probePoints = [
|
|
557
|
+
[centerX, centerY - reach],
|
|
558
|
+
[centerX, centerY + reach],
|
|
559
|
+
[centerX - reach, centerY],
|
|
560
|
+
[centerX + reach, centerY],
|
|
561
|
+
];
|
|
562
|
+
const isEachProbeHit = probePoints.every(([pointX, pointY]) => {
|
|
563
|
+
const hitElement = document.elementFromPoint(pointX, pointY);
|
|
564
|
+
|
|
565
|
+
if (!hitElement) return false;
|
|
566
|
+
// Checkbox, radio: bấm vào nhãn cũng là bấm vào ô (I26).
|
|
567
|
+
const isInsideLabel = [...(element.labels || [])].some((label) => label.contains(hitElement));
|
|
568
|
+
|
|
569
|
+
return element === hitElement || element.contains(hitElement) || isInsideLabel;
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
if (!isEachProbeHit) smallTapTargets.push({ element: describe(element), size: `${Math.round(rect.width)}×${Math.round(rect.height)}` });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (const { container, left, top } of scrolledContainers) {
|
|
576
|
+
container.scrollLeft = left;
|
|
577
|
+
container.scrollTop = top;
|
|
578
|
+
}
|
|
579
|
+
window.scrollTo(0, 0);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 6. Dấu câu rơi xuống đầu dòng (". Đổi tài khoản", "· 3 ngày"): thường do chữ đứng trước là
|
|
583
|
+
// inline-block (EmailText, badge) nên trình duyệt được phép ngắt ngay trước dấu.
|
|
584
|
+
const orphanPunctuation = [];
|
|
585
|
+
const punctuationPattern = /[.,;:!?)·»”…]/;
|
|
586
|
+
const textWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
587
|
+
let previousCharRect = null;
|
|
588
|
+
|
|
589
|
+
while (textWalker.nextNode() && orphanPunctuation.length < 10) {
|
|
590
|
+
const textNode = textWalker.currentNode;
|
|
591
|
+
const parent = textNode.parentElement;
|
|
592
|
+
// Không bỏ qua aria-hidden: dấu " · " ngăn cách thường aria-hidden mà vẫn nhìn thấy, rơi đầu dòng là
|
|
593
|
+
// lỗi hình ("· Huỷ", đã lọt 27/09/2026 ở /dashboard/profile/states 375px).
|
|
594
|
+
if (!parent || !isVisible(parent) || parent.closest("script, style")) continue;
|
|
595
|
+
// Chữ trong code / pre không xét, nhưng vẫn là "chữ đứng trước" của dấu theo sau: bỏ qua hẳn thì dấu
|
|
596
|
+
// phẩy sau `DH-2026-004821` bị so với dòng trên, báo nhầm (27/09/2026, trợ lý AI 375px).
|
|
597
|
+
if (parent.closest("code, pre")) {
|
|
598
|
+
const lastIndex = textNode.data.trimEnd().length - 1;
|
|
599
|
+
if (lastIndex >= 0) {
|
|
600
|
+
const lastRange = document.createRange();
|
|
601
|
+
lastRange.setStart(textNode, lastIndex);
|
|
602
|
+
lastRange.setEnd(textNode, lastIndex + 1);
|
|
603
|
+
const lastRect = lastRange.getBoundingClientRect();
|
|
604
|
+
if (lastRect.width) previousCharRect = lastRect;
|
|
605
|
+
}
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
// Ký tự đứng một mình trong khối riêng (vòng "!" của bước lỗi, `flex size-8`) là hình, không phải dấu
|
|
609
|
+
// câu của chữ trước (báo nhầm 27/09/2026, bộ bước ở /components). Dấu " · " inline vẫn xét.
|
|
610
|
+
if (/^[.,;:!?)·»”…]+$/.test(parent.textContent.trim()) && /^(block|flex|grid)$/.test(getComputedStyle(parent).display)) continue;
|
|
611
|
+
|
|
612
|
+
for (let index = 0; index < textNode.length; index++) {
|
|
613
|
+
const character = textNode.data[index];
|
|
614
|
+
if (/\s/.test(character)) continue;
|
|
615
|
+
|
|
616
|
+
const charRange = document.createRange();
|
|
617
|
+
charRange.setStart(textNode, index);
|
|
618
|
+
charRange.setEnd(textNode, index + 1);
|
|
619
|
+
const charRect = charRange.getBoundingClientRect();
|
|
620
|
+
if (!charRect.width) continue;
|
|
621
|
+
|
|
622
|
+
const isOnNewLine = previousCharRect && charRect.top >= previousCharRect.bottom - 2 && charRect.left < previousCharRect.left;
|
|
623
|
+
// Dấu nằm giữa một chuỗi liền ("…toan" / ".tong@" của email, "1.284") là chỗ ngắt cố ý, không
|
|
624
|
+
// phải dấu câu: chỉ tính dấu đứng cuối chữ (sau nó là khoảng trắng hoặc hết đoạn).
|
|
625
|
+
// Dấu "." của EmailText là text node riêng: ký tự sau nó nằm ở node kế tiếp.
|
|
626
|
+
let nextCharacter = textNode.data[index + 1];
|
|
627
|
+
if (nextCharacter === undefined) {
|
|
628
|
+
const peekWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
629
|
+
peekWalker.currentNode = textNode;
|
|
630
|
+
nextCharacter = peekWalker.nextNode()?.data[0];
|
|
631
|
+
}
|
|
632
|
+
const isInsideToken = /[.,:]/.test(character) && nextCharacter !== undefined && !/\s/.test(nextCharacter);
|
|
633
|
+
if (punctuationPattern.test(character) && isOnNewLine && !isInsideToken) {
|
|
634
|
+
const lineText = textNode.data.slice(index, index + 30).trim();
|
|
635
|
+
orphanPunctuation.push({ lineStart: lineText, element: describe(parent.closest("p, li, div, span") || parent) });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
previousCharRect = charRect;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 6b. Chữ đơn côi ở dòng cuối (`T10`): khối chữ hai, ba dòng mà dòng cuối chỉ còn một chữ ("CRM", "mới").
|
|
643
|
+
// Chỉ xét khối chữ lá (không có thẻ con), chưa `text-pretty` / `text-balance`, không bị `line-clamp` cắt.
|
|
644
|
+
// Đã dính 01/10/2026: bảng công việc ở 375px, tên việc `line-clamp-2` theo spec danh sách dòng mà spec quên
|
|
645
|
+
// `text-pretty`, 6/11 tên trơ một chữ ở dòng hai.
|
|
646
|
+
const orphanWords = [];
|
|
647
|
+
for (const block of document.querySelectorAll("p, h1, h2, h3, h4, h5, li, span, a, label, dd, dt, td")) {
|
|
648
|
+
if (orphanWords.length >= 10 || block.children.length > 0 || !isVisible(block)) continue;
|
|
649
|
+
const text = block.textContent.replace(/\s+/g, " ").trim();
|
|
650
|
+
const lastSpace = text.lastIndexOf(" ");
|
|
651
|
+
if (text.length < 12 || lastSpace < 0) continue;
|
|
652
|
+
const style = getComputedStyle(block);
|
|
653
|
+
if (/pretty|balance/.test(`${style.textWrapStyle || ""} ${style.textWrap || ""}`) || style.whiteSpace === "nowrap") continue;
|
|
654
|
+
if (block.scrollHeight > block.clientHeight + 1 && style.overflow !== "visible") continue;
|
|
655
|
+
const textNode = block.firstChild;
|
|
656
|
+
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue;
|
|
657
|
+
const range = document.createRange();
|
|
658
|
+
range.selectNodeContents(textNode);
|
|
659
|
+
const lineTops = new Set([...range.getClientRects()].filter((rect) => rect.width > 2).map((rect) => Math.round(rect.top / 4)));
|
|
660
|
+
if (lineTops.size < 2 || lineTops.size > 3) continue;
|
|
661
|
+
const rawText = textNode.data;
|
|
662
|
+
const rawLastSpace = rawText.trimEnd().lastIndexOf(" ");
|
|
663
|
+
const wordRange = document.createRange();
|
|
664
|
+
wordRange.setStart(textNode, rawLastSpace + 1);
|
|
665
|
+
wordRange.setEnd(textNode, rawText.trimEnd().length);
|
|
666
|
+
const beforeRange = document.createRange();
|
|
667
|
+
beforeRange.setStart(textNode, Math.max(0, rawLastSpace - 1));
|
|
668
|
+
beforeRange.setEnd(textNode, rawLastSpace);
|
|
669
|
+
const wordRect = wordRange.getBoundingClientRect();
|
|
670
|
+
const beforeRect = beforeRange.getBoundingClientRect();
|
|
671
|
+
if (wordRect.width && beforeRect.width && wordRect.top >= beforeRect.bottom - 2 && wordRect.width < block.getBoundingClientRect().width * 0.25) {
|
|
672
|
+
orphanWords.push(`"${text.slice(0, 40)}${text.length > 40 ? "…" : ""}" trơ "${text.slice(lastSpace + 1)}" ở dòng cuối: ${describe(block)}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// 7. Ô nhập lệch mép với nút rộng hết khung trong cùng form / hộp thoại: màn hẹp nút xếp dọc
|
|
677
|
+
// rộng hết, còn ô nằm trong cột chữ thụt sau icon (hộp xác nhận có ô gõ lại tên: ô 239px ở
|
|
678
|
+
// x=96, nút 295px ở x=40, đo 26/09/2026). Nút tự co theo chữ (màn rộng) thì không so.
|
|
679
|
+
const misalignedFields = [];
|
|
680
|
+
const fieldContainers = document.querySelectorAll("form, dialog, [role='dialog'], [role='alertdialog']");
|
|
681
|
+
|
|
682
|
+
for (const container of fieldContainers) {
|
|
683
|
+
if (!isVisible(container)) continue;
|
|
684
|
+
|
|
685
|
+
const containerWidth = container.getBoundingClientRect().width;
|
|
686
|
+
const wideButtons = [...container.querySelectorAll("button")].filter(
|
|
687
|
+
(button) => isVisible(button) && button.getBoundingClientRect().width >= containerWidth * 0.6,
|
|
688
|
+
);
|
|
689
|
+
if (wideButtons.length === 0) continue;
|
|
690
|
+
|
|
691
|
+
const buttonRect = wideButtons[0].getBoundingClientRect();
|
|
692
|
+
const fields = [...container.querySelectorAll("input:not([type='hidden']):not([type='checkbox']):not([type='radio']), textarea, select")];
|
|
693
|
+
|
|
694
|
+
for (const field of fields) {
|
|
695
|
+
if (!isVisible(field)) continue;
|
|
696
|
+
// Hàng nhiều ô cùng cỡ (OTP sáu ô 40–57px) không cần khớp mép nút (báo nhầm 26/09/2026).
|
|
697
|
+
const siblingFields = [...(field.parentElement?.parentElement || field.parentElement).querySelectorAll("input")].filter(isVisible);
|
|
698
|
+
if (siblingFields.length >= 3 && siblingFields.every((sibling) => Math.abs(sibling.getBoundingClientRect().width - field.getBoundingClientRect().width) <= 2)) continue;
|
|
699
|
+
|
|
700
|
+
// Ô không viền nằm trong một khung có viền (ô nhập nhiều email: chip + input trong một khung) thì
|
|
701
|
+
// mép người dùng thấy là mép khung (báo nhầm 27/09/2026, /components).
|
|
702
|
+
let visualBox = field;
|
|
703
|
+
for (let node = field; node && node !== container; node = node.parentElement) {
|
|
704
|
+
if ((parseFloat(getComputedStyle(node).borderLeftWidth) || 0) >= 1) {
|
|
705
|
+
visualBox = node;
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const fieldRect = visualBox.getBoundingClientRect();
|
|
710
|
+
const leftGap = Math.round(Math.abs(fieldRect.left - buttonRect.left));
|
|
711
|
+
const rightGap = Math.round(Math.abs(fieldRect.right - buttonRect.right));
|
|
712
|
+
if (leftGap <= 4 && rightGap <= 4) continue;
|
|
713
|
+
|
|
714
|
+
misalignedFields.push({
|
|
715
|
+
field: `${Math.round(fieldRect.width)}px ở x=${Math.round(fieldRect.left)}`,
|
|
716
|
+
button: `${Math.round(buttonRect.width)}px ở x=${Math.round(buttonRect.left)}`,
|
|
717
|
+
element: describe(container),
|
|
718
|
+
});
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// 8. Dấu ngăn (›, /) không cách đều hai bên: đo từ nét của dấu tới nét chữ hay icon kế bên, không
|
|
724
|
+
// đo hộp. Nút "…" size-8 giữa đường dẫn để trống 22px mỗi bên trong khi chữ cách dấu 12px
|
|
725
|
+
// (đo 26/09/2026). Dấu ngăn là svg aria-hidden đứng ngoài link, nút; icon trong nút phân trang
|
|
726
|
+
// không tính.
|
|
727
|
+
function getSvgInkRect(svg) {
|
|
728
|
+
const rect = svg.getBoundingClientRect();
|
|
729
|
+
const box = svg.getBBox();
|
|
730
|
+
const scale = rect.width / (svg.viewBox.baseVal?.width || rect.width);
|
|
731
|
+
|
|
732
|
+
return { left: rect.left + box.x * scale, right: rect.left + (box.x + box.width) * scale, top: rect.top };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function getItemInkRect(item) {
|
|
736
|
+
const textNodes = [];
|
|
737
|
+
const walker = document.createTreeWalker(item, NodeFilter.SHOW_TEXT);
|
|
738
|
+
while (walker.nextNode()) if (walker.currentNode.textContent.trim()) textNodes.push(walker.currentNode);
|
|
739
|
+
|
|
740
|
+
if (textNodes.length === 0) {
|
|
741
|
+
const icon = item.querySelector("svg");
|
|
742
|
+
|
|
743
|
+
return icon ? getSvgInkRect(icon) : null;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const range = document.createRange();
|
|
747
|
+
range.setStartBefore(textNodes[0]);
|
|
748
|
+
range.setEndAfter(textNodes[textNodes.length - 1]);
|
|
749
|
+
const textRect = range.getBoundingClientRect();
|
|
750
|
+
// Chữ bị cắt (truncate): nét chữ dừng ở mép hộp, không ở cuối chuỗi đầy đủ.
|
|
751
|
+
const itemRect = item.getBoundingClientRect();
|
|
752
|
+
|
|
753
|
+
return { left: Math.max(textRect.left, itemRect.left), right: Math.min(textRect.right, itemRect.right), top: itemRect.top };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Chỉ dấu ngăn cách thật (›, ‹, /). Icon ưu tiên trong thẻ kanban cũng là svg aria-hidden trong một
|
|
757
|
+
// <ul>, đo như dấu › thì ra "khe 150–178px" (báo nhầm 27/09/2026, /dashboard/tasks/states ở 375px).
|
|
758
|
+
const isSeparatorIcon = (svg) => /lucide-(chevron-(right|left)|slash)\b/.test(svg.getAttribute("class") || "");
|
|
759
|
+
const separatorRows = new Set();
|
|
760
|
+
for (const svg of document.querySelectorAll("svg[aria-hidden='true']")) {
|
|
761
|
+
if (svg.closest("a, button, [role='button']") || !isVisible(svg) || !isSeparatorIcon(svg)) continue;
|
|
762
|
+
const row = svg.closest("ol, ul, nav");
|
|
763
|
+
if (row) separatorRows.add(row);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const unevenSeparatorRows = [];
|
|
767
|
+
for (const row of separatorRows) {
|
|
768
|
+
const units = [...row.querySelectorAll("svg[aria-hidden='true'], a, button")]
|
|
769
|
+
.filter((element) => isVisible(element) && !element.parentElement.closest("a, button"))
|
|
770
|
+
.filter((element) => element.tagName.toLowerCase() !== "svg" || isSeparatorIcon(element))
|
|
771
|
+
.map((element) => {
|
|
772
|
+
const isSeparator = element.tagName.toLowerCase() === "svg";
|
|
773
|
+
|
|
774
|
+
return { isSeparator, ink: isSeparator ? getSvgInkRect(element) : getItemInkRect(element) };
|
|
775
|
+
})
|
|
776
|
+
.filter((unit) => unit.ink);
|
|
777
|
+
|
|
778
|
+
const gaps = [];
|
|
779
|
+
for (let index = 1; index < units.length; index++) {
|
|
780
|
+
const previous = units[index - 1];
|
|
781
|
+
const current = units[index];
|
|
782
|
+
const isSameLine = Math.abs(current.ink.top - previous.ink.top) < 12;
|
|
783
|
+
if (isSameLine && (previous.isSeparator || current.isSeparator)) gaps.push(current.ink.left - previous.ink.right);
|
|
784
|
+
}
|
|
785
|
+
if (gaps.length < 3) continue;
|
|
786
|
+
|
|
787
|
+
const smallestGap = Math.min(...gaps);
|
|
788
|
+
const largestGap = Math.max(...gaps);
|
|
789
|
+
if (largestGap - smallestGap > 4) {
|
|
790
|
+
unevenSeparatorRows.push({
|
|
791
|
+
element: describe(row),
|
|
792
|
+
gaps: `${smallestGap.toFixed(1)}–${largestGap.toFixed(1)}px`,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
// 10. Nhãn số đè lên đường biểu đồ: số ghi cạnh chấm mà đường đi xuyên qua chữ (đo 27/09/2026,
|
|
799
|
+
// báo cáo doanh thu: điểm cuối thấp hơn điểm kề, số đặt trên chấm nằm đúng trên đoạn nối).
|
|
800
|
+
// Lấy mẫu dọc từng đường theo toạ độ màn, rồi xem có mẫu nào lọt vào khung chữ của nhãn
|
|
801
|
+
// nằm cùng khung vẽ (chữ HTML đặt đè lên svg, hoặc <text> trong svg của thư viện).
|
|
802
|
+
const overlappedChartLabels = [];
|
|
803
|
+
const outsideChartLabels = [];
|
|
804
|
+
for (const svg of document.querySelectorAll("svg")) {
|
|
805
|
+
const svgRect = svg.getBoundingClientRect();
|
|
806
|
+
if (svgRect.width < 120 || svgRect.height < 60 || !isVisible(svg)) continue;
|
|
807
|
+
|
|
808
|
+
const lines = [...svg.querySelectorAll("polyline, path, line")].filter((shape) => {
|
|
809
|
+
const style = getComputedStyle(shape);
|
|
810
|
+
|
|
811
|
+
return style.stroke !== "none" && parseFloat(style.strokeWidth) > 0 && (style.fill === "none" || shape.tagName === "polyline");
|
|
812
|
+
});
|
|
813
|
+
if (lines.length === 0) continue;
|
|
814
|
+
|
|
815
|
+
const samplePoints = [];
|
|
816
|
+
for (const shape of lines) {
|
|
817
|
+
const matrix = shape.getScreenCTM();
|
|
818
|
+
const totalLength = shape.getTotalLength?.() ?? 0;
|
|
819
|
+
if (!matrix || totalLength === 0) continue;
|
|
820
|
+
|
|
821
|
+
for (let step = 0; step <= 400; step++) {
|
|
822
|
+
const point = shape.getPointAtLength((totalLength * step) / 400).matrixTransform(matrix);
|
|
823
|
+
samplePoints.push(point);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const container = svg.parentElement;
|
|
828
|
+
const labelNodes = [...container.querySelectorAll("*")].filter((node) => {
|
|
829
|
+
if (node === svg || (svg.contains(node) && node.tagName.toLowerCase() !== "text")) return false;
|
|
830
|
+
const ownText = [...node.childNodes].some((child) => child.nodeType === Node.TEXT_NODE && child.textContent.trim());
|
|
831
|
+
|
|
832
|
+
return ownText && isVisible(node);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// Chữ lồng trong nhãn ("Hôm nay ·" trong "Hôm nay · 6,8 tr đ") tính theo nhãn ngoài cùng.
|
|
836
|
+
const outerLabelNodes = labelNodes.filter((node) => !labelNodes.some((other) => other !== node && other.contains(node)));
|
|
837
|
+
|
|
838
|
+
for (const labelNode of outerLabelNodes) {
|
|
839
|
+
const range = document.createRange();
|
|
840
|
+
range.selectNodeContents(labelNode);
|
|
841
|
+
const textRect = range.getBoundingClientRect();
|
|
842
|
+
const isInsidePlot = textRect.bottom > svgRect.top && textRect.top < svgRect.bottom;
|
|
843
|
+
if (!isInsidePlot) continue;
|
|
844
|
+
|
|
845
|
+
// Nhãn lọt ra ngoài vùng vẽ: dưới đường 0 là chỗ của nhãn trục, số rơi xuống đó đọc ra một
|
|
846
|
+
// nhãn trục thứ hai (đo 27/09/2026: "Hôm nay · 6,8 tr đ" dưới đáy 16px, cách "27/09" 9px).
|
|
847
|
+
const outsideBy = Math.max(svgRect.top - textRect.top, textRect.bottom - svgRect.bottom);
|
|
848
|
+
if (outsideBy > 2) {
|
|
849
|
+
outsideChartLabels.push(`"${labelNode.textContent.trim().slice(0, 24)}" lòi ${Math.round(outsideBy)}px: ${describe(labelNode)}`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const hitPoint = samplePoints.find(
|
|
853
|
+
(point) => point.x > textRect.left + 1 && point.x < textRect.right - 1 && point.y > textRect.top + 1 && point.y < textRect.bottom - 1,
|
|
854
|
+
);
|
|
855
|
+
if (hitPoint) overlappedChartLabels.push(`"${labelNode.textContent.trim().slice(0, 24)}": ${describe(labelNode)}`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// 10b. Badge đếm đè mất icon: chấm hay số `absolute` trên nút chỉ có icon (chuông, giỏ hàng) phủ từ 40%
|
|
860
|
+
// icon trở lên thì icon không còn nhận ra (sót 30/09/2026, lịch khám: số "3" size-4 đặt top-1.5
|
|
861
|
+
// right-1.5 phủ gần nửa chuông 20px, chỉ còn thấy quả lắc; lượt chỉ đưa ảnh bắt được bằng mắt).
|
|
862
|
+
const iconCoveringBadges = [];
|
|
863
|
+
for (const button of document.querySelectorAll("button, a[href], [role='button']")) {
|
|
864
|
+
if (iconCoveringBadges.length >= 4 || !isVisible(button)) continue;
|
|
865
|
+
const icon = [...button.querySelectorAll("svg")].find((svg) => svg.getBoundingClientRect().width >= 12);
|
|
866
|
+
if (!icon) continue;
|
|
867
|
+
const iconRect = icon.getBoundingClientRect();
|
|
868
|
+
const badge = [...button.querySelectorAll("span, div")].find((node) => {
|
|
869
|
+
const rect = node.getBoundingClientRect();
|
|
870
|
+
|
|
871
|
+
return getComputedStyle(node).position === "absolute" && !node.contains(icon) && rect.width > 0 && rect.width <= 28 && rect.height <= 28 && isVisible(node);
|
|
872
|
+
});
|
|
873
|
+
if (!badge) continue;
|
|
874
|
+
const badgeRect = badge.getBoundingClientRect();
|
|
875
|
+
const overlapWidth = Math.max(0, Math.min(iconRect.right, badgeRect.right) - Math.max(iconRect.left, badgeRect.left));
|
|
876
|
+
const overlapHeight = Math.max(0, Math.min(iconRect.bottom, badgeRect.bottom) - Math.max(iconRect.top, badgeRect.top));
|
|
877
|
+
const coveredRatio = (overlapWidth * overlapHeight) / (iconRect.width * iconRect.height);
|
|
878
|
+
if (coveredRatio >= 0.4) {
|
|
879
|
+
iconCoveringBadges.push(`badge ${Math.round(badgeRect.width)}×${Math.round(badgeRect.height)}px phủ ${Math.round(coveredRatio * 100)}% icon ${Math.round(iconRect.width)}px: ${describe(button)}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// 11. Bảng cuộn ngang mà cột nhận diện trôi theo (R9): cuộn một nhịp là mất tên, các ô còn lại
|
|
884
|
+
// không biết của ai. Cột đầu phải `sticky` và không quá ~40% khung; dưới `sm` bảng quản lý
|
|
885
|
+
// thành danh sách dòng (đã dính 27/09/2026: bảng nhóm công việc 832px trong khung 341px ở
|
|
886
|
+
// 375px, không ghim, cuộn sang thì cả tên nhóm lẫn tên việc trôi mất).
|
|
887
|
+
const unpinnedScrollTables = [];
|
|
888
|
+
for (const table of document.querySelectorAll("table")) {
|
|
889
|
+
if (!isVisible(table)) continue;
|
|
890
|
+
let scroller = table.parentElement;
|
|
891
|
+
while (scroller && scroller !== document.body && !["auto", "scroll"].includes(getComputedStyle(scroller).overflowX)) scroller = scroller.parentElement;
|
|
892
|
+
if (!scroller || scroller === document.body || scroller.scrollWidth <= scroller.clientWidth + 1) continue;
|
|
893
|
+
|
|
894
|
+
const firstBodyCell = [...table.querySelectorAll("tbody tr")]
|
|
895
|
+
.map((row) => row.cells[0])
|
|
896
|
+
.find((cell) => cell && cell.colSpan === 1 && isVisible(cell));
|
|
897
|
+
if (!firstBodyCell) continue;
|
|
898
|
+
|
|
899
|
+
const isPinned = getComputedStyle(firstBodyCell).position === "sticky";
|
|
900
|
+
const pinnedShare = firstBodyCell.getBoundingClientRect().width / scroller.clientWidth;
|
|
901
|
+
const size = `bảng ${table.scrollWidth}px trong khung ${scroller.clientWidth}px`;
|
|
902
|
+
|
|
903
|
+
if (!isPinned) unpinnedScrollTables.push(`${size}, cột đầu không ghim${isMobile ? " (dưới sm: thành danh sách dòng)" : ""}: ${describe(table)}`);
|
|
904
|
+
else if (pinnedShare > 0.4) unpinnedScrollTables.push(`${size}, cột ghim chiếm ${Math.round(pinnedShare * 100)}% khung: ${describe(table)}`);
|
|
905
|
+
// Ghim rồi mà cột cuối (nút ⋯ của dòng) vẫn nằm ngoài khung tới khi cuộn: khung vừa phải ẩn cột
|
|
906
|
+
// phụ trước (đã dính 27/09/2026, bảng khách hàng 960px trong khung 718px ở 768 và 1024px).
|
|
907
|
+
const lastCell = firstBodyCell.parentElement.cells[firstBodyCell.parentElement.cells.length - 1];
|
|
908
|
+
if (isPinned && lastCell.getBoundingClientRect().left >= scroller.getBoundingClientRect().right) {
|
|
909
|
+
unpinnedScrollTables.push(`${size}, cột cuối ("${(lastCell.textContent.trim() || lastCell.querySelector("[aria-label]")?.getAttribute("aria-label") || "").slice(0, 24)}") nằm ngoài khung tới khi cuộn, ẩn cột phụ: ${describe(table)}`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Số dòng chữ thật của một khối: đếm đỉnh các hộp dòng của chữ (Range), không suy từ chiều cao hay số thẻ.
|
|
914
|
+
// Khai báo function để 11b, 11c và 18j cùng dùng.
|
|
915
|
+
function countTextLines(element) {
|
|
916
|
+
const tops = new Set();
|
|
917
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
918
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
919
|
+
if (!node.textContent.trim() || isScreenReaderOnly(node.parentElement, element)) continue;
|
|
920
|
+
const range = document.createRange();
|
|
921
|
+
range.selectNodeContents(node);
|
|
922
|
+
for (const rect of range.getClientRects()) if (rect.width > 2 && rect.height > 6) tops.add(Math.round(rect.top / 4));
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return tops.size;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Chữ `sr-only` (khung 1px, overflow hidden) không phải một dòng nhìn thấy: tên người cạnh avatar ẩn ở
|
|
929
|
+
// khung vừa nằm lệch tâm avatar, đếm vào thì ra hai dòng (báo nhầm 01/10/2026, cột Phụ trách ở 768px).
|
|
930
|
+
function isScreenReaderOnly(node, root) {
|
|
931
|
+
for (let current = node; current && current !== root.parentElement; current = current.parentElement) {
|
|
932
|
+
const box = current.getBoundingClientRect();
|
|
933
|
+
if (box.width <= 1 && box.height <= 1 && getComputedStyle(current).overflow !== "visible") return true;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// 11b. Cột chữ của bảng bị ép xuống dòng trong khi bảng không cuộn: các cột khác `nowrap` giữ chỗ, cột
|
|
940
|
+
// tên / dịch vụ còn ~100px, nửa số dòng thành hai dòng, dòng cao thấp lởm chởm. Khung vừa phải ẩn cột
|
|
941
|
+
// phụ theo `@container` của card (`layouts/app.md`, "Khung vừa thì ẩn cột phụ"), không theo viewport
|
|
942
|
+
// (đã dính 30/09/2026, bảng Điều trị ở hồ sơ bệnh nhân 1280px: cột Điều trị 101px, 4/6 dòng hai dòng,
|
|
943
|
+
// vì cột Bác sĩ ẩn theo `lg:` mà cột phải 22rem đã lấy mất chỗ).
|
|
944
|
+
const squeezedTableColumns = [];
|
|
945
|
+
for (const table of document.querySelectorAll("table")) {
|
|
946
|
+
if (!isVisible(table)) continue;
|
|
947
|
+
const bodyRows = [...table.querySelectorAll("tbody tr")].filter((row) => isVisible(row) && row.cells.length > 1);
|
|
948
|
+
if (bodyRows.length < 3) continue;
|
|
949
|
+
const visibleCellsPerRow = bodyRows.map((row) => [...row.cells].filter(isVisible));
|
|
950
|
+
const columnCount = visibleCellsPerRow[0].length;
|
|
951
|
+
if (columnCount < 4 || visibleCellsPerRow.some((cells) => cells.length !== columnCount)) continue;
|
|
952
|
+
|
|
953
|
+
for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {
|
|
954
|
+
const cells = visibleCellsPerRow.map((cells) => cells[columnIndex]);
|
|
955
|
+
if (!cells[0].textContent.trim() || getComputedStyle(cells[0]).whiteSpace === "nowrap") continue;
|
|
956
|
+
const wrappedCount = cells.filter((cell) => countTextLines(cell) > 1).length;
|
|
957
|
+
const columnWidth = Math.round(cells[0].getBoundingClientRect().width);
|
|
958
|
+
if (wrappedCount >= 2 && wrappedCount * 2 >= cells.length && columnWidth < 200) {
|
|
959
|
+
const header = table.querySelectorAll("thead th")[columnIndex]?.textContent.trim() || `cột ${columnIndex + 1}`;
|
|
960
|
+
squeezedTableColumns.push(`cột "${header.slice(0, 24)}" rộng ${columnWidth}px, ${wrappedCount}/${cells.length} dòng xuống hai dòng, bảng ${columnCount} cột: ${describe(table)}`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// 11c. Cặp nhãn–giá trị đứng hai cột trong khối hẹp: cột nhãn ~7rem ăn mất một phần ba, email vỡ ba
|
|
966
|
+
// dòng, địa chỉ bốn dòng. Khối dưới 384px thì nhãn trên, giá trị dưới; chuyển khuôn theo bề rộng
|
|
967
|
+
// `<dl>` (`@container`), không theo viewport (`components/description-list.md`). Đã dính 30/09/2026,
|
|
968
|
+
// card Liên hệ cột phải hồ sơ bệnh nhân: `<dl>` 310px, `sm:grid-cols-[7rem_…]` bật vì màn 1440px.
|
|
969
|
+
const crampedDescriptionLists = [];
|
|
970
|
+
for (const list of document.querySelectorAll("dl")) {
|
|
971
|
+
const listWidth = list.getBoundingClientRect().width;
|
|
972
|
+
if (!isVisible(list) || listWidth >= 384) continue;
|
|
973
|
+
const pairs = [...list.querySelectorAll("dt")].map((term) => [term, term.nextElementSibling]).filter(([, value]) => value?.tagName === "DD" && isVisible(value));
|
|
974
|
+
const sideBySidePairs = pairs.filter(([term, value]) => value.getBoundingClientRect().left >= term.getBoundingClientRect().right - 1 && Math.abs(value.getBoundingClientRect().top - term.getBoundingClientRect().top) < 8);
|
|
975
|
+
if (sideBySidePairs.length < 2) continue;
|
|
976
|
+
const longest = Math.max(...sideBySidePairs.map(([, value]) => countTextLines(value)));
|
|
977
|
+
if (longest >= 3) crampedDescriptionLists.push(`<dl> ${Math.round(listWidth)}px, ${sideBySidePairs.length} cặp nhãn cạnh giá trị, giá trị dài nhất ${longest} dòng: ${describe(list)}`);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// 12. Nhóm radio / checkbox xếp lưới (vừa nhiều cột vừa nhiều hàng): đọc thành chữ Z, thang có thứ
|
|
981
|
+
// tự như mức ưu tiên ra "Thấp, Trung bình / Cao, Khẩn cấp" (đã dính 27/09/2026, form tạo công
|
|
982
|
+
// việc 375px). Một hàng hoặc một cột thì đúng.
|
|
983
|
+
const gridChoiceGroups = [];
|
|
984
|
+
for (const group of document.querySelectorAll("fieldset, [role='radiogroup'], [role='group']")) {
|
|
985
|
+
const choices = [...group.querySelectorAll("input[type='radio'], input[type='checkbox'], [role='radio']")].filter(isVisible);
|
|
986
|
+
if (choices.length < 3) continue;
|
|
987
|
+
|
|
988
|
+
const lefts = new Set(choices.map((choice) => Math.round(choice.getBoundingClientRect().left / 4)));
|
|
989
|
+
const tops = new Set(choices.map((choice) => Math.round(choice.getBoundingClientRect().top / 4)));
|
|
990
|
+
if (lefts.size > 1 && tops.size > 1) {
|
|
991
|
+
const legend = group.querySelector("legend")?.textContent.trim() || describe(group);
|
|
992
|
+
gridChoiceGroups.push(`${choices.length} lựa chọn thành ${lefts.size} cột × ${tops.size} hàng: "${legend.slice(0, 40)}"`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// 13. Hàng ô số liệu (mỗi ô một cặp dt/dd) mà số không thẳng một đường: một nhãn xuống dòng đẩy riêng
|
|
997
|
+
// số của ô đó (đã dính 27/09/2026, trang chi tiết khách 1280px, "3" thấp hơn "12,3 tr đ" 16px).
|
|
998
|
+
const unevenStatRows = [];
|
|
999
|
+
for (const statGroup of document.querySelectorAll("dl")) {
|
|
1000
|
+
const tiles = [...statGroup.children].filter((tile) => isVisible(tile) && tile.querySelector(":scope > dt") && tile.querySelector(":scope > dd"));
|
|
1001
|
+
if (tiles.length < 2) continue;
|
|
1002
|
+
|
|
1003
|
+
const tilesByRow = new Map();
|
|
1004
|
+
for (const tile of tiles) {
|
|
1005
|
+
const rowKey = Math.round(tile.getBoundingClientRect().top);
|
|
1006
|
+
if (!tilesByRow.has(rowKey)) tilesByRow.set(rowKey, []);
|
|
1007
|
+
tilesByRow.get(rowKey).push(tile);
|
|
1008
|
+
}
|
|
1009
|
+
for (const rowTiles of tilesByRow.values()) {
|
|
1010
|
+
if (rowTiles.length < 2) continue;
|
|
1011
|
+
const valueTops = rowTiles.map((tile) => tile.querySelector(":scope > dd").getBoundingClientRect().top);
|
|
1012
|
+
const spread = Math.max(...valueTops) - Math.min(...valueTops);
|
|
1013
|
+
if (spread > 2) {
|
|
1014
|
+
unevenStatRows.push(`số lệch ${Math.round(spread)}px giữa ${rowTiles.length} ô cùng hàng: ${describe(statGroup)}`);
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// 14. Số tiền kèm đơn vị bị ngắt dòng ("128.900.000" / "đ"): đo trên khối chứa cả số lẫn đơn vị, xem
|
|
1021
|
+
// chuỗi tiền có nằm trên hai dòng không (đã dính 27/09/2026, modal đơn hàng 375px, `T16`).
|
|
1022
|
+
const brokenMoney = [];
|
|
1023
|
+
// Chỉ đo phần tử mà cả nội dung là một số tiền ("128.900.000 đ", "0 ₫", có thể kèm "/tháng"); khối
|
|
1024
|
+
// chứa cả nhãn lẫn tiền thì nhiều dòng là đúng thiết kế.
|
|
1025
|
+
const moneyOnlyPattern = /^\s*-?\d[\d.,]*\s*(đ|₫|VND)(\s*\/\s*[\p{L}]+)?\s*$/u;
|
|
1026
|
+
for (const holder of document.querySelectorAll("dd, td, p, span, div, strong")) {
|
|
1027
|
+
if (!isVisible(holder) || !moneyOnlyPattern.test(holder.textContent)) continue;
|
|
1028
|
+
if (holder.parentElement && moneyOnlyPattern.test(holder.parentElement.textContent)) continue;
|
|
1029
|
+
const range = document.createRange();
|
|
1030
|
+
range.selectNodeContents(holder);
|
|
1031
|
+
// Hai mảnh cùng dòng khi khoảng dọc chồng nhau: số 30px và "/tháng" 14px chung baseline có đáy
|
|
1032
|
+
// lệch nhau, gom theo đáy thì báo nhầm (27/09/2026, trang giá 375px).
|
|
1033
|
+
const pieces = [...range.getClientRects()].filter((rect) => rect.width > 0).sort((first, second) => first.top - second.top);
|
|
1034
|
+
let lineCount = pieces.length > 0 ? 1 : 0;
|
|
1035
|
+
let lineBottom = pieces[0]?.bottom ?? 0;
|
|
1036
|
+
for (const piece of pieces.slice(1)) {
|
|
1037
|
+
if (piece.top >= lineBottom - 2) lineCount++;
|
|
1038
|
+
lineBottom = Math.max(lineBottom, piece.bottom);
|
|
1039
|
+
}
|
|
1040
|
+
if (lineCount > 1) brokenMoney.push(`"${holder.textContent.trim().slice(0, 24)}": ${describe(holder)}`);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// 15. Tương phản chữ: màu chữ trộn lên nền thật phía sau nó. Màu đọc qua canvas nên oklch của
|
|
1044
|
+
// Tailwind v4 cũng ra rgb. Nền lấy ở lớp nằm ngay dưới chữ (elementsFromPoint), rồi đi ngược lên
|
|
1045
|
+
// các cha tới lớp nền đặc. Cha có gradient thì chấm theo điểm dừng tệ nhất; chữ trên ảnh, video,
|
|
1046
|
+
// hay lớp phủ gradient không phải cha thì không đo được, chỉ đếm. Chữ trong
|
|
1047
|
+
// control đang khoá thì WCAG không tính, bỏ qua.
|
|
1048
|
+
const colorCanvas = document.createElement("canvas");
|
|
1049
|
+
colorCanvas.width = 1;
|
|
1050
|
+
colorCanvas.height = 1;
|
|
1051
|
+
const colorContext = colorCanvas.getContext("2d", { willReadFrequently: true });
|
|
1052
|
+
|
|
1053
|
+
function readColor(cssColor) {
|
|
1054
|
+
colorContext.clearRect(0, 0, 1, 1);
|
|
1055
|
+
colorContext.fillStyle = "rgba(0, 0, 0, 0)";
|
|
1056
|
+
colorContext.fillStyle = cssColor;
|
|
1057
|
+
colorContext.fillRect(0, 0, 1, 1);
|
|
1058
|
+
const [red, green, blue, alpha] = colorContext.getImageData(0, 0, 1, 1).data;
|
|
1059
|
+
|
|
1060
|
+
return { red, green, blue, alpha: alpha / 255 };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function blendColors(top, bottom) {
|
|
1064
|
+
const alpha = top.alpha + bottom.alpha * (1 - top.alpha);
|
|
1065
|
+
if (alpha === 0) return { red: 0, green: 0, blue: 0, alpha: 0 };
|
|
1066
|
+
const mixChannel = (channel) => (top[channel] * top.alpha + bottom[channel] * bottom.alpha * (1 - top.alpha)) / alpha;
|
|
1067
|
+
|
|
1068
|
+
return { red: mixChannel("red"), green: mixChannel("green"), blue: mixChannel("blue"), alpha };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function readLuminance(color) {
|
|
1072
|
+
const toLinear = (channel) => {
|
|
1073
|
+
const value = channel / 255;
|
|
1074
|
+
|
|
1075
|
+
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
return 0.2126 * toLinear(color.red) + 0.7152 * toLinear(color.green) + 0.0722 * toLinear(color.blue);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function readContrastRatio(first, second) {
|
|
1082
|
+
const [lighter, darker] = [readLuminance(first), readLuminance(second)].sort((first, second) => second - first);
|
|
1083
|
+
|
|
1084
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function toHex(color) {
|
|
1088
|
+
return `#${[color.red, color.green, color.blue].map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}`;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const whiteCanvas = { red: 255, green: 255, blue: 255, alpha: 1 };
|
|
1092
|
+
|
|
1093
|
+
// Gom các lớp nền từ một phần tử lên tới lớp đặc đầu tiên. Gặp ảnh hay gradient thì trả null.
|
|
1094
|
+
function readAncestorBackdrop(element) {
|
|
1095
|
+
const layers = [];
|
|
1096
|
+
|
|
1097
|
+
for (let node = element; node; node = node.parentElement) {
|
|
1098
|
+
const style = getComputedStyle(node);
|
|
1099
|
+
if (style.backgroundImage !== "none") return null;
|
|
1100
|
+
const color = readColor(style.backgroundColor);
|
|
1101
|
+
if (color.alpha > 0) layers.push(color);
|
|
1102
|
+
if (color.alpha >= 0.99) break;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return layers.reverse().reduce((bottom, top) => blendColors(top, bottom), whiteCanvas);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Màu các điểm dừng của nền chỉ có gradient; nền có ảnh thì null. Computed style đã đổi màu sang rgb().
|
|
1109
|
+
function readGradientStops(backgroundImage) {
|
|
1110
|
+
if (backgroundImage === "none") return [];
|
|
1111
|
+
if (/url\(|image-set\(|element\(|cross-fade\(/.test(backgroundImage)) return null;
|
|
1112
|
+
|
|
1113
|
+
return [...backgroundImage.matchAll(/(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^()]*\)/g)].map((match) => readColor(match[0]));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Như `readAncestorBackdrop` nhưng đi qua lớp cha nền gradient: mỗi điểm dừng trộn lên màu nền của
|
|
1117
|
+
// chính lớp đó là một nền có thể nằm dưới chữ, trả hết để chấm theo nền tệ nhất. Trước đây gặp
|
|
1118
|
+
// gradient là bỏ đo: app nền tối có quầng sáng trên `body` thì không chữ nào được đo, sót chữ giờ
|
|
1119
|
+
// 2.91:1 (30/09/2026, dự án mồi kho hàng, lượt tự mở trang; lượt chỉ đưa ảnh lại bắt được).
|
|
1120
|
+
function readAncestorBackdrops(element) {
|
|
1121
|
+
const layers = [];
|
|
1122
|
+
|
|
1123
|
+
for (let node = element; node; node = node.parentElement) {
|
|
1124
|
+
const style = getComputedStyle(node);
|
|
1125
|
+
const stops = readGradientStops(style.backgroundImage);
|
|
1126
|
+
if (!stops) return null;
|
|
1127
|
+
const color = readColor(style.backgroundColor);
|
|
1128
|
+
const variants = stops.length > 0 ? [color, ...stops.map((stop) => blendColors(stop, color))] : [color];
|
|
1129
|
+
if (variants.some((variant) => variant.alpha > 0)) layers.push(variants);
|
|
1130
|
+
if (variants.every((variant) => variant.alpha >= 0.99)) break;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
let backdrops = [whiteCanvas];
|
|
1134
|
+
for (const variants of layers.reverse()) {
|
|
1135
|
+
const blended = backdrops.flatMap((bottom) => variants.map((top) => blendColors(top, bottom)));
|
|
1136
|
+
backdrops = [...new Map(blended.map((backdrop) => [toHex(backdrop), backdrop])).values()].slice(0, 24);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
return backdrops;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Nền thật dưới chữ: lớp phủ định vị tuyệt đối (panel, ảnh bìa) không phải cha trong DOM của chữ.
|
|
1143
|
+
// Trả danh sách nền có thể có (nhiều hơn một khi cha có gradient), hoặc null khi không đo được.
|
|
1144
|
+
function readBackdrop(element) {
|
|
1145
|
+
const rect = element.getBoundingClientRect();
|
|
1146
|
+
const centerX = rect.left + rect.width / 2;
|
|
1147
|
+
const centerY = rect.top + rect.height / 2;
|
|
1148
|
+
const isInViewport = centerX >= 0 && centerY >= 0 && centerX < innerWidth && centerY < innerHeight;
|
|
1149
|
+
if (!isInViewport) return readAncestorBackdrops(element);
|
|
1150
|
+
|
|
1151
|
+
// Lớp đứng TRÊN chữ (thanh điều hướng cố định che mất chữ lúc chụp) không phải nền của chữ: chỉ
|
|
1152
|
+
// xét các lớp nằm sau chữ trong chồng. Đã đo nhầm một nút nền xanh ra 1:1 vì thanh dưới đáy màu trắng che nút
|
|
1153
|
+
// (27/09/2026, dự án mồi phase 2). Chữ bị che hẳn thì đi theo các cha trong DOM.
|
|
1154
|
+
const stack = document.elementsFromPoint(centerX, centerY);
|
|
1155
|
+
const textIndex = stack.findIndex((layer) => layer === element || element.contains(layer));
|
|
1156
|
+
if (textIndex === -1) return readAncestorBackdrops(element);
|
|
1157
|
+
|
|
1158
|
+
// Lớp không phải cha mà có gradient (lớp phủ trên ảnh bìa) vẫn bỏ đo: ảnh nằm cạnh nó, không phải cha.
|
|
1159
|
+
for (const layer of stack.slice(textIndex)) {
|
|
1160
|
+
if (layer === element || element.contains(layer)) continue;
|
|
1161
|
+
if (layer.contains(element)) break;
|
|
1162
|
+
if (["IMG", "VIDEO", "CANVAS", "svg"].includes(layer.tagName) || getComputedStyle(layer).backgroundImage !== "none") return null;
|
|
1163
|
+
if (readColor(getComputedStyle(layer).backgroundColor).alpha > 0) return readAncestorBackdrops(layer);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
return readAncestorBackdrops(element);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function readOpacityChain(element) {
|
|
1170
|
+
let opacity = 1;
|
|
1171
|
+
for (let node = element; node; node = node.parentElement) opacity *= Number(getComputedStyle(node).opacity);
|
|
1172
|
+
|
|
1173
|
+
return opacity;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const lowContrastTexts = new Map();
|
|
1177
|
+
let unmeasuredContrastCount = 0;
|
|
1178
|
+
|
|
1179
|
+
function checkContrast(element, cssColor, sample) {
|
|
1180
|
+
const backdrops = readBackdrop(element);
|
|
1181
|
+
if (!backdrops) {
|
|
1182
|
+
unmeasuredContrastCount++;
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const style = getComputedStyle(element);
|
|
1187
|
+
const textColor = readColor(cssColor);
|
|
1188
|
+
textColor.alpha *= readOpacityChain(element);
|
|
1189
|
+
const fontSize = parseFloat(style.fontSize);
|
|
1190
|
+
const isLargeText = fontSize >= 24 || (fontSize >= 18.66 && Number(style.fontWeight) >= 700);
|
|
1191
|
+
const requiredRatio = isLargeText ? 3 : 4.5;
|
|
1192
|
+
// Nền gradient cho nhiều nền có thể có: chấm theo nền tệ nhất.
|
|
1193
|
+
const [{ backdrop, shownColor, ratio }] = backdrops
|
|
1194
|
+
.map((candidate) => {
|
|
1195
|
+
const blendedText = blendColors(textColor, candidate);
|
|
1196
|
+
|
|
1197
|
+
return { backdrop: candidate, shownColor: blendedText, ratio: readContrastRatio(blendedText, candidate) };
|
|
1198
|
+
})
|
|
1199
|
+
.sort((first, second) => first.ratio - second.ratio);
|
|
1200
|
+
if (ratio >= requiredRatio) return;
|
|
1201
|
+
|
|
1202
|
+
const key = `${toHex(shownColor)}|${toHex(backdrop)}|${element.tagName}.${element.getAttribute("class") || ""}`;
|
|
1203
|
+
if (!lowContrastTexts.has(key)) {
|
|
1204
|
+
lowContrastTexts.set(key, {
|
|
1205
|
+
ratio,
|
|
1206
|
+
line: `${ratio.toFixed(2)}:1, cần ${requiredRatio}:1, chữ ${toHex(shownColor)} trên nền ${toHex(backdrop)} "${sample}": ${describe(element)}`,
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Nhãn của ô đang khoá mờ theo ô là cố ý (choice-controls.md, bảng Khoá) và WCAG miễn chữ của control khoá.
|
|
1212
|
+
function isLabelOfDisabledControl(element) {
|
|
1213
|
+
const labelControl = element.closest("label")?.control;
|
|
1214
|
+
|
|
1215
|
+
return Boolean(labelControl?.matches(":disabled, [aria-disabled='true']"));
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
for (const element of allElements) {
|
|
1219
|
+
if (element.closest(":disabled, [aria-disabled='true']") || isLabelOfDisabledControl(element) || !isVisible(element)) continue;
|
|
1220
|
+
const ownText = [...element.childNodes].filter((node) => node.nodeType === Node.TEXT_NODE).map((node) => node.textContent.trim()).join(" ").trim();
|
|
1221
|
+
if (ownText) checkContrast(element, getComputedStyle(element).color, ownText.slice(0, 24));
|
|
1222
|
+
|
|
1223
|
+
const isEmptyField = (element.tagName === "INPUT" || element.tagName === "TEXTAREA") && element.placeholder && !element.value;
|
|
1224
|
+
if (isEmptyField) checkContrast(element, getComputedStyle(element, "::placeholder").color, `placeholder: ${element.placeholder.slice(0, 20)}`);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const sortedLowContrast = [...lowContrastTexts.values()].sort((first, second) => first.ratio - second.ratio);
|
|
1228
|
+
|
|
1229
|
+
// 16. Khung khai viền mà viền không thấy: nền trong khung trùng nền ngoài, viền cũng trùng cả hai, nên cả
|
|
1230
|
+
// khung tan vào nền (khung chat nền trang + viền nhạt hơn nền, 27/09/2026, bản sửa dự án mồi).
|
|
1231
|
+
const channelDistance = (first, second) => Math.max(Math.abs(first.red - second.red), Math.abs(first.green - second.green), Math.abs(first.blue - second.blue));
|
|
1232
|
+
const invisibleFrames = [];
|
|
1233
|
+
for (const element of allElements) {
|
|
1234
|
+
if (invisibleFrames.length >= 6) break;
|
|
1235
|
+
const style = getComputedStyle(element);
|
|
1236
|
+
if (!(parseFloat(style.borderTopWidth) > 0) || style.borderTopStyle === "none" || style.boxShadow !== "none" || !isVisible(element)) continue;
|
|
1237
|
+
const rect = element.getBoundingClientRect();
|
|
1238
|
+
if (rect.width * rect.height < 20000) continue;
|
|
1239
|
+
const outside = element.parentElement ? readAncestorBackdrop(element.parentElement) : whiteCanvas;
|
|
1240
|
+
const inside = readAncestorBackdrop(element);
|
|
1241
|
+
if (!outside || !inside) continue;
|
|
1242
|
+
const border = blendColors(readColor(style.borderTopColor), outside);
|
|
1243
|
+
if (channelDistance(inside, outside) <= 4 && channelDistance(border, outside) <= 4 && channelDistance(border, inside) <= 4) {
|
|
1244
|
+
invisibleFrames.push(`viền ${toHex(border)}, nền trong ${toHex(inside)}, nền ngoài ${toHex(outside)}: ${describe(element)}`);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// 16b. Khối cùng component mà bo góc khác nhau (Lệch hệ, `V1` "cùng vai"): gom khối có nền / viền / bóng theo
|
|
1249
|
+
// component, nhận ra bằng `data-slot` (shadcn) hoặc class CSS Module có hash (`_card_x1y2z`,
|
|
1250
|
+
// `glass-card-module__card__AbC12`). Một khối bo khác số đông của chính component đó là bị đè riêng.
|
|
1251
|
+
// Card "Ngưỡng cảnh báo" bo 8px giữa các card kính 20px, lượt tự mở trang sót, lượt chỉ đưa ảnh bắt
|
|
1252
|
+
// bằng mắt (30/09/2026, dự án mồi kho hàng).
|
|
1253
|
+
const isModuleClass = (token) => /__/.test(token) || /^_[A-Za-z][\w-]*_[A-Za-z0-9-]{5}(_\d+)?$/.test(token);
|
|
1254
|
+
const surfacesByComponent = new Map();
|
|
1255
|
+
for (const element of allElements) {
|
|
1256
|
+
const style = getComputedStyle(element);
|
|
1257
|
+
const hasSurface = readColor(style.backgroundColor).alpha > 0 || parseFloat(style.borderTopWidth) > 0 || style.boxShadow !== "none";
|
|
1258
|
+
if (!hasSurface || !isVisible(element)) continue;
|
|
1259
|
+
const rect = element.getBoundingClientRect();
|
|
1260
|
+
if (rect.width * rect.height < 20000) continue;
|
|
1261
|
+
const keys = [element.dataset.slot && `slot:${element.dataset.slot}`, ...[...element.classList].filter(isModuleClass)].filter(Boolean);
|
|
1262
|
+
for (const key of keys) {
|
|
1263
|
+
if (!surfacesByComponent.has(key)) surfacesByComponent.set(key, []);
|
|
1264
|
+
surfacesByComponent.get(key).push({ element, radius: Math.round(parseFloat(style.borderTopLeftRadius) || 0) });
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
const mismatchedRadii = [];
|
|
1268
|
+
const reportedRadiusElements = new Set();
|
|
1269
|
+
for (const [key, surfaces] of surfacesByComponent) {
|
|
1270
|
+
if (surfaces.length < 3 || mismatchedRadii.length >= 6) continue;
|
|
1271
|
+
const radiusCounts = new Map();
|
|
1272
|
+
for (const surface of surfaces) radiusCounts.set(surface.radius, (radiusCounts.get(surface.radius) ?? 0) + 1);
|
|
1273
|
+
const [commonRadius, commonCount] = [...radiusCounts].sort((first, second) => second[1] - first[1])[0];
|
|
1274
|
+
if (commonCount < 2 || commonCount === surfaces.length) continue;
|
|
1275
|
+
for (const surface of surfaces) {
|
|
1276
|
+
if (Math.abs(surface.radius - commonRadius) < 4 || reportedRadiusElements.has(surface.element)) continue;
|
|
1277
|
+
reportedRadiusElements.add(surface.element);
|
|
1278
|
+
mismatchedRadii.push(`bo ${surface.radius}px, ${commonCount} khối cùng component (${key.replace(/^slot:/, "data-slot ")}) bo ${commonRadius}px: ${describe(surface.element)}`);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// 17. Ô nhập, nút, select còn kiểu mặc định của trình duyệt: dự án không nạp preflight (reset) của
|
|
1283
|
+
// Tailwind mà control chưa tự reset (viền inset / outset, viền xám #767676, select `appearance: auto`).
|
|
1284
|
+
const browserDefaultControls = [];
|
|
1285
|
+
for (const control of document.querySelectorAll("input:not([type='checkbox']):not([type='radio']):not([type='hidden']), textarea, select, button")) {
|
|
1286
|
+
if (browserDefaultControls.length >= 8 || !isVisible(control)) continue;
|
|
1287
|
+
const style = getComputedStyle(control);
|
|
1288
|
+
const hasDefaultBorder = ["inset", "outset"].includes(style.borderTopStyle) || (parseFloat(style.borderTopWidth) > 0 && style.borderTopColor === "rgb(118, 118, 118)");
|
|
1289
|
+
// Select gốc chưa tô: còn `appearance: auto` VÀ còn góc vuông hay viền xám của trình duyệt. Select gốc đã
|
|
1290
|
+
// bo góc, viền token vẫn giữ mũi tên trình duyệt là cách làm được, không báo.
|
|
1291
|
+
const isNativeSelect = control.tagName === "SELECT" && ["auto", "menulist"].includes(style.appearance)
|
|
1292
|
+
&& (style.borderTopLeftRadius === "0px" || style.borderTopColor === "rgb(118, 118, 118)" || style.borderTopColor === "rgb(0, 0, 0)");
|
|
1293
|
+
// Thanh trượt gốc: `appearance: auto` mà trang không tự vẽ thanh (thanh trượt hai đầu tự dựng thì input nằm
|
|
1294
|
+
// dưới, `pointer-events-none` hoặc trong suốt).
|
|
1295
|
+
const isNativeRange = control.type === "range" && style.appearance === "auto" && style.pointerEvents !== "none" && Number(style.opacity) > 0.1;
|
|
1296
|
+
if (isNativeRange) {
|
|
1297
|
+
browserDefaultControls.push(`thanh trượt gốc trình duyệt: ${describe(control)}`);
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
if (control.type === "range") continue;
|
|
1301
|
+
if (hasDefaultBorder || isNativeSelect) browserDefaultControls.push(`${isNativeSelect ? "select gốc trình duyệt" : `viền ${style.borderTopWidth} ${style.borderTopStyle} ${style.borderTopColor}`}: ${describe(control)}`);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// 17a. Checkbox, radio gốc, kể cả khi chỉ tô `accent-color`: skill dựng trên `appearance-none`
|
|
1305
|
+
// (components/choice-controls.md). Ô `sr-only` hay trong suốt nằm dưới ô tự vẽ thì không tính.
|
|
1306
|
+
for (const choice of document.querySelectorAll("input[type='checkbox'], input[type='radio']")) {
|
|
1307
|
+
if (browserDefaultControls.length >= 8 || !isVisible(choice)) continue;
|
|
1308
|
+
const style = getComputedStyle(choice);
|
|
1309
|
+
if (style.appearance === "none" || Number(style.opacity) <= 0.1) continue;
|
|
1310
|
+
browserDefaultControls.push(`${choice.type} gốc trình duyệt${style.accentColor !== "auto" ? " (chỉ tô accent-color)" : ""}: ${describe(choice)}`);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// 17b. Select gốc đã tô ở khổ desktop: lúc đóng khớp app, bấm vào vẫn bung menu của hệ điều hành. Chế độ
|
|
1314
|
+
// soi bỏ qua, hai chế độ dựng lại thay bằng Select dựng (review.md V1, 28/09/2026).
|
|
1315
|
+
// Ô ngày, giờ gốc cùng lý do: bấm vào ra lịch của hệ điều hành. Control trong lớp nổi đang ẩn
|
|
1316
|
+
// thì `findNativeControls` đo riêng.
|
|
1317
|
+
const styledNativeSelects = isMobile ? [] : [...document.querySelectorAll("select, input[type='date'], input[type='time'], input[type='datetime-local'], input[type='month'], input[type='week']")]
|
|
1318
|
+
.filter((control) => isVisible(control) && (control.tagName !== "SELECT" || !["auto", "menulist"].includes(getComputedStyle(control).appearance)))
|
|
1319
|
+
.slice(0, 6)
|
|
1320
|
+
.map((control) => `${control.tagName === "SELECT" ? `${control.options.length} mục` : `ô ${control.type} gốc`}: ${describe(control)}`);
|
|
1321
|
+
|
|
1322
|
+
// 18. Đường ngăn ngang của hai cột kề nhau lệch vài px: vạch dưới khối logo ở sidebar với vạch dưới
|
|
1323
|
+
// header, nhìn thành một đường gãy (28/09/2026). Lệch lớn hơn 16px là hai tầng khác nhau, bỏ qua.
|
|
1324
|
+
const horizontalRules = [];
|
|
1325
|
+
for (const element of allElements) {
|
|
1326
|
+
const style = getComputedStyle(element);
|
|
1327
|
+
if (!isVisible(element)) continue;
|
|
1328
|
+
const rect = element.getBoundingClientRect();
|
|
1329
|
+
if (rect.width < 120) continue;
|
|
1330
|
+
for (const [side, edgeY] of [["Bottom", rect.bottom], ["Top", rect.top]]) {
|
|
1331
|
+
const isDrawn = parseFloat(style[`border${side}Width`]) > 0 && style[`border${side}Style`] !== "none" && readColor(style[`border${side}Color`]).alpha > 0.05;
|
|
1332
|
+
if (isDrawn) horizontalRules.push({ element, edgeY, left: rect.left, right: rect.right, color: style[`border${side}Color`] });
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
const brokenRules = [];
|
|
1336
|
+
const mismatchedRuleColors = [];
|
|
1337
|
+
for (const first of horizontalRules) {
|
|
1338
|
+
for (const second of horizontalRules) {
|
|
1339
|
+
if (brokenRules.length >= 6) break;
|
|
1340
|
+
const isSideBySide = Math.abs(first.right - second.left) <= 4;
|
|
1341
|
+
const offset = Math.abs(first.edgeY - second.edgeY);
|
|
1342
|
+
// Thẳng hàng mà khác màu cũng là gãy: vạch dưới logo `border-light` #f1f5f9 nối vào vạch dưới
|
|
1343
|
+
// header `border` #e2e8f0, nhìn như sidebar nhạt còn phần ngoài đậm (28/09/2026, chủ dự án tự thấy).
|
|
1344
|
+
if (isSideBySide && offset < 0.75 && first.color !== second.color && mismatchedRuleColors.length < 4) {
|
|
1345
|
+
mismatchedRuleColors.push(`${first.color} nối vào ${second.color}: ${describe(first.element)} | ${describe(second.element)}`);
|
|
1346
|
+
}
|
|
1347
|
+
// Hai khối cùng bắt đầu ở đỉnh trang (khối logo sidebar và header) là cùng một tầng, lệch bao nhiêu
|
|
1348
|
+
// cũng là gãy: header bị bóp còn 35px lệch 46px với vạch dưới logo (28/09/2026).
|
|
1349
|
+
const isTopBand = first.element.getBoundingClientRect().top <= 1 && second.element.getBoundingClientRect().top <= 1;
|
|
1350
|
+
if (isSideBySide && offset >= 0.75 && (offset <= 16 || (isTopBand && offset <= 120))) {
|
|
1351
|
+
brokenRules.push(`lệch ${offset.toFixed(1)}px: ${describe(first.element)} | ${describe(second.element)}`);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// 18b. Khối khai chiều cao cố định (`h-[70px]`, `h-16`) mà hiện ra thấp hơn: con của khung flex dọc
|
|
1357
|
+
// thiếu `shrink-0` bị nội dung dài bóp lại (header 70px còn 35px, 28/09/2026). Khối có biến thể
|
|
1358
|
+
// `md:h-…` hay `max-h-…` thì chiều cao đổi theo khổ là cố ý, bỏ qua.
|
|
1359
|
+
const squeezedBlocks = [];
|
|
1360
|
+
for (const element of allElements) {
|
|
1361
|
+
if (squeezedBlocks.length >= 6) break;
|
|
1362
|
+
const classNames = (element.getAttribute("class") || "").split(/\s+/);
|
|
1363
|
+
if (classNames.some((className) => /:h-|^max-h-/.test(className))) continue;
|
|
1364
|
+
const heightClass = classNames.map((className) => className.match(/^h-(?:\[(\d+(?:\.\d+)?)px\]|(\d+(?:\.\d+)?))$/)).find(Boolean);
|
|
1365
|
+
if (!heightClass || !isVisible(element)) continue;
|
|
1366
|
+
const declaredHeight = heightClass[1] ? Number(heightClass[1]) : Number(heightClass[2]) * 4;
|
|
1367
|
+
const renderedHeight = element.getBoundingClientRect().height;
|
|
1368
|
+
if (declaredHeight >= 24 && renderedHeight < declaredHeight - 2) {
|
|
1369
|
+
squeezedBlocks.push(`khai ${declaredHeight}px, hiện ${Math.round(renderedHeight)}px: ${describe(element)}`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// 18d. Hàng nút trên thanh đầu trang (khối dính đỉnh, cao 48–88px) không đồng cỡ: nút đặc cao 30px
|
|
1374
|
+
// chữ 13px đứng giữa các nút ghost cao 34px chữ 14px, bo tròn hẳn giữa các nút bo 12px
|
|
1375
|
+
// (28/09/2026, tim-phong-sua: header giữ nguyên bản cũ trong khi wireframe đã vẽ lại).
|
|
1376
|
+
const unevenHeaderActions = [];
|
|
1377
|
+
for (const bar of allElements) {
|
|
1378
|
+
const barRect = bar.getBoundingClientRect();
|
|
1379
|
+
if (barRect.top > 1 || barRect.height < 48 || barRect.height > 88 || barRect.width < 400 || !isVisible(bar)) continue;
|
|
1380
|
+
const actions = [...bar.querySelectorAll("button, a")].filter((action) => {
|
|
1381
|
+
const actionRect = action.getBoundingClientRect();
|
|
1382
|
+
return isVisible(action) && actionRect.height >= 24 && actionRect.left > barRect.left + barRect.width / 2 && !action.parentElement.closest("button, a");
|
|
1383
|
+
});
|
|
1384
|
+
if (actions.length < 3) continue;
|
|
1385
|
+
const heights = actions.map((action) => Math.round(action.getBoundingClientRect().height));
|
|
1386
|
+
const fontSizes = new Set(actions.map((action) => getComputedStyle(action).fontSize));
|
|
1387
|
+
const isFullRadius = (action) => parseFloat(getComputedStyle(action).borderTopLeftRadius) >= action.getBoundingClientRect().height / 2;
|
|
1388
|
+
// Nút avatar (vuông, bo tròn hẳn, chỉ ảnh hoặc 1–2 chữ cái) tròn cạnh nút icon bo góc là quy ước chung, không
|
|
1389
|
+
// tính vào phép so hình (báo nhầm 01/10/2026, header khung app: trăng, chuông, avatar "T").
|
|
1390
|
+
const isAvatarAction = (action) => {
|
|
1391
|
+
const actionRect = action.getBoundingClientRect();
|
|
1392
|
+
|
|
1393
|
+
return isFullRadius(action) && Math.abs(actionRect.width - actionRect.height) <= 2 && (Boolean(action.querySelector("img")) || action.textContent.trim().length <= 2);
|
|
1394
|
+
};
|
|
1395
|
+
const shapeCount = new Set(actions.filter((action) => !isAvatarAction(action)).map(isFullRadius)).size;
|
|
1396
|
+
const heightSpread = Math.max(...heights) - Math.min(...heights);
|
|
1397
|
+
// Khoảng giữa hai nút kề nhau: dưới 6px thì nền rê dính nhau, hàng thành một cục (gap-2, 30/09/2026).
|
|
1398
|
+
const sortedActions = [...actions].sort((first, second) => first.getBoundingClientRect().left - second.getBoundingClientRect().left);
|
|
1399
|
+
const actionGaps = sortedActions.slice(1).map((action, index) => action.getBoundingClientRect().left - sortedActions[index].getBoundingClientRect().right);
|
|
1400
|
+
const tightestGap = Math.min(...actionGaps);
|
|
1401
|
+
if (heightSpread >= 3 || fontSizes.size > 1 || shapeCount > 1 || tightestGap < 6) {
|
|
1402
|
+
unevenHeaderActions.push(`${actions.length} nút, cao ${Math.min(...heights)}–${Math.max(...heights)}px, chữ ${[...fontSizes].join(" / ")}${shapeCount > 1 ? ", lẫn bo tròn hẳn với bo góc" : ""}${tightestGap < 6 ? `, cách nhau ${Math.round(tightestGap)}px (gap-2)` : ""}: ${describe(bar)}`);
|
|
1403
|
+
break;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// 18c. Cột dính mà cuộn riêng: `sticky` kèm `max-h-[calc(100vh-…)] overflow-y-auto` (cột lọc, mục lục).
|
|
1408
|
+
// Thanh cuộn riêng hiện thường trực, dính sát viền, màn càng cao thì càng dài gần hết cột
|
|
1409
|
+
// (28/09/2026). Cột dài hơn màn thì để cuộn theo trang. Khung cuộn chính của app không dính nên
|
|
1410
|
+
// không tính; danh sách trong lớp nổi là `fixed` / `absolute`, cũng không tính.
|
|
1411
|
+
const stickyScrollColumns = [];
|
|
1412
|
+
for (const element of allElements) {
|
|
1413
|
+
if (stickyScrollColumns.length >= 4) break;
|
|
1414
|
+
const style = getComputedStyle(element);
|
|
1415
|
+
if (style.position !== "sticky" || !["auto", "scroll"].includes(style.overflowY) || !isVisible(element)) continue;
|
|
1416
|
+
const { clientHeight, scrollHeight } = element;
|
|
1417
|
+
if (clientHeight < 120 || scrollHeight <= clientHeight + 4) continue;
|
|
1418
|
+
stickyScrollColumns.push(`khung cao ${clientHeight}px, nội dung ${scrollHeight}px: ${describe(element)}`);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// 18k. Hàng cuộn ngang ẩn thanh cuộn mà không có nút mũi tên, ở desktop: chuột thường chỉ cuộn dọc,
|
|
1422
|
+
// nên mục phía sau (thường có cả "Xoá lọc") không tới được. Máy trackpad kéo được nên người làm
|
|
1423
|
+
// không thấy (hàng chip đang lọc, 28/09/2026). Nút mũi tên tìm trong hai tầng cha, ngoài khung cuộn.
|
|
1424
|
+
const mouseUnreachableScrollers = [];
|
|
1425
|
+
for (const element of isMobile ? [] : allElements) {
|
|
1426
|
+
if (mouseUnreachableScrollers.length >= 4) break;
|
|
1427
|
+
const style = getComputedStyle(element);
|
|
1428
|
+
if (!["auto", "scroll"].includes(style.overflowX) || style.scrollbarWidth !== "none" || !isVisible(element)) continue;
|
|
1429
|
+
if (element.scrollWidth <= element.clientWidth + 4) continue;
|
|
1430
|
+
const scope = element.parentElement?.parentElement || element.parentElement;
|
|
1431
|
+
const hasArrowButton = [...(scope?.querySelectorAll("button, [role='button']") || [])]
|
|
1432
|
+
.some((button) => !element.contains(button) && isVisible(button) && button.getBoundingClientRect().width < 64);
|
|
1433
|
+
if (hasArrowButton) continue;
|
|
1434
|
+
mouseUnreachableScrollers.push(`khung ${element.clientWidth}px, nội dung ${element.scrollWidth}px, khuất ${element.scrollWidth - element.clientWidth}px: ${describe(element)}`);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// 18e. Nội dung trôi giữa màn rộng: khối nội dung chính có trần bề rộng và căn giữa, hở hai bên từ
|
|
1438
|
+
// 120px. Cạnh sidebar thì thành khoảng trống giữa sidebar và nội dung (`mx-auto max-w-300`, 28/09/2026).
|
|
1439
|
+
// Chỉ xét khối rộng từ 900px: cột form, cài đặt hẹp căn giữa là mẫu riêng của từng trang.
|
|
1440
|
+
const floatingContent = [];
|
|
1441
|
+
if (viewportWidth >= 1600) {
|
|
1442
|
+
for (const element of allElements) {
|
|
1443
|
+
if (floatingContent.length >= 2) break;
|
|
1444
|
+
const style = getComputedStyle(element);
|
|
1445
|
+
if (style.maxWidth === "none" || !isVisible(element)) continue;
|
|
1446
|
+
const marginLeft = parseFloat(style.marginLeft);
|
|
1447
|
+
const marginRight = parseFloat(style.marginRight);
|
|
1448
|
+
const width = element.getBoundingClientRect().width;
|
|
1449
|
+
if (width >= 900 && marginLeft >= 120 && Math.abs(marginLeft - marginRight) <= 2) {
|
|
1450
|
+
floatingContent.push(`rộng ${Math.round(width)}px, hở ${Math.round(marginLeft)}px mỗi bên: ${describe(element)}`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// 18f. Khung hộp thoại nằm trong lớp nền mờ (scrim) mà cả hai cùng chuyển `opacity`: độ mờ nhân nhau, khung
|
|
1456
|
+
// tan nhanh hơn lớp nền lúc đóng, nhìn giật (28/09/2026). Chỉ đo được khi hộp thoại luôn nằm trong DOM.
|
|
1457
|
+
const nestedFadeDialogs = [];
|
|
1458
|
+
for (const dialog of document.querySelectorAll("[role='dialog'], dialog")) {
|
|
1459
|
+
if (nestedFadeDialogs.length >= 3) break;
|
|
1460
|
+
const isFading = (element) => /opacity|all/.test(element.dataset.evonTransition || "");
|
|
1461
|
+
if (!isFading(dialog)) continue;
|
|
1462
|
+
for (let node = dialog.parentElement; node && node !== document.body; node = node.parentElement) {
|
|
1463
|
+
const style = getComputedStyle(node);
|
|
1464
|
+
if (style.position === "fixed" && readColor(style.backgroundColor).alpha > 0.05 && isFading(node)) {
|
|
1465
|
+
nestedFadeDialogs.push(`${describe(dialog)} nằm trong ${describe(node)}`);
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// 18g. Bẫy Tailwind v4: `scale-*`, `translate-*`, `rotate-*` ghi vào thuộc tính `scale`, `translate`, `rotate`
|
|
1472
|
+
// riêng, còn `transition-[transform]` / `transition-[opacity,transform]` chỉ chuyển `transform`. Khung
|
|
1473
|
+
// nhảy cỡ một phát rồi mới mờ, mũi tên nhảy ngược không xoay (28/09/2026, menu tài khoản và mẫu
|
|
1474
|
+
// accordion của skill). Xét cả phần tử đang ẩn: menu đóng vẫn nằm trong DOM. `transition-transform`
|
|
1475
|
+
// của v4 đã gồm cả ba nên không báo.
|
|
1476
|
+
const untransitionedMotion = [];
|
|
1477
|
+
for (const element of document.body.querySelectorAll("*")) {
|
|
1478
|
+
if (untransitionedMotion.length >= 6) break;
|
|
1479
|
+
const classNames = element.getAttribute("class") || "";
|
|
1480
|
+
if (!/(^|[\s:])-?(scale|translate|rotate)-/.test(classNames)) continue;
|
|
1481
|
+
const property = element.dataset.evonTransition || "";
|
|
1482
|
+
if (!/\btransform\b/.test(property) || /\b(all|scale|translate|rotate)\b/.test(property)) continue;
|
|
1483
|
+
untransitionedMotion.push(`transition: ${property}: ${describe(element)}`);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// 18h. Vạch chia trong menu đậm hơn viền khung: menu tách bằng đường tóc, vạch giữa các nhóm cùng token với
|
|
1487
|
+
// viền (`border-border`), không `border-strong` (28/09/2026).
|
|
1488
|
+
const relativeLuminance = (color) => (0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue) / 255;
|
|
1489
|
+
const heavySeparators = [];
|
|
1490
|
+
for (const menu of document.querySelectorAll("[role='menu'], [role='listbox']")) {
|
|
1491
|
+
if (heavySeparators.length >= 3) break;
|
|
1492
|
+
const menuStyle = getComputedStyle(menu);
|
|
1493
|
+
if (!(parseFloat(menuStyle.borderTopWidth) > 0)) continue;
|
|
1494
|
+
const frameLuminance = relativeLuminance(readColor(menuStyle.borderTopColor));
|
|
1495
|
+
for (const separator of menu.querySelectorAll("hr, [role='separator']")) {
|
|
1496
|
+
const separatorStyle = getComputedStyle(separator);
|
|
1497
|
+
const color = parseFloat(separatorStyle.borderTopWidth) > 0 ? separatorStyle.borderTopColor : separatorStyle.backgroundColor;
|
|
1498
|
+
if (frameLuminance - relativeLuminance(readColor(color)) > 0.02) {
|
|
1499
|
+
heavySeparators.push(`vạch ${color}, viền khung ${menuStyle.borderTopColor}: ${describe(separator)}`);
|
|
1500
|
+
break;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// 18i. Vạch trái của mục đang chọn (viền trái hay bóng inset) bị bo góc của khung `overflow-hidden` cắt cong
|
|
1506
|
+
// ở dòng đầu, dòng cuối (28/09/2026, danh sách việc làm).
|
|
1507
|
+
const clippedBars = [];
|
|
1508
|
+
for (const element of allElements) {
|
|
1509
|
+
if (clippedBars.length >= 3) break;
|
|
1510
|
+
const style = getComputedStyle(element);
|
|
1511
|
+
const hasInsetBar = /inset/.test(style.boxShadow) && /(^|\s)[2-6]px 0px 0px/.test(style.boxShadow);
|
|
1512
|
+
const hasBorderBar = parseFloat(style.borderLeftWidth) >= 2 && readColor(style.borderLeftColor).alpha > 0.1 && !(parseFloat(style.borderTopWidth) > 0);
|
|
1513
|
+
if ((!hasInsetBar && !hasBorderBar) || !isVisible(element)) continue;
|
|
1514
|
+
const rect = element.getBoundingClientRect();
|
|
1515
|
+
for (let node = element.parentElement; node && node !== document.body; node = node.parentElement) {
|
|
1516
|
+
const nodeStyle = getComputedStyle(node);
|
|
1517
|
+
if (!["hidden", "clip"].includes(nodeStyle.overflowX)) continue;
|
|
1518
|
+
const radius = parseFloat(nodeStyle.borderTopLeftRadius) || 0;
|
|
1519
|
+
const box = node.getBoundingClientRect();
|
|
1520
|
+
const isAtCorner = Math.abs(rect.left - box.left) <= 2 && (Math.abs(rect.top - box.top) <= radius || Math.abs(rect.bottom - box.bottom) <= radius);
|
|
1521
|
+
if (radius >= 6 && isAtCorner) clippedBars.push(`bo ${radius}px của ${describe(node)} cắt vạch trái ${describe(element)}`);
|
|
1522
|
+
break;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// 18j. Mục lặp dày chữ: card hay dòng lặp từ ba cái trở lên mà một cái có từ năm dòng chữ. Người dùng không tự
|
|
1527
|
+
// thấy "card chữ quá trời" (28/09/2026, wireframe danh sách + chi tiết năm dòng mỗi mục); đặt ra số dòng
|
|
1528
|
+
// thì thấy. Đếm dòng bằng các hộp dòng của chữ (Range), không bằng số thẻ.
|
|
1529
|
+
const denseItems = [];
|
|
1530
|
+
for (const parent of allElements) {
|
|
1531
|
+
if (denseItems.length >= 3) break;
|
|
1532
|
+
const items = [...parent.children].filter((child) => isVisible(child) && child.getBoundingClientRect().height >= 48);
|
|
1533
|
+
if (items.length < 3) continue;
|
|
1534
|
+
// Cùng loại = cùng thẻ và cùng class đầu: mục đang chọn thêm class trạng thái (`sel`, `lg:bg-…`) vẫn cùng loại.
|
|
1535
|
+
const signature = (child) => `${child.tagName}.${(child.getAttribute("class") || "").split(/\s+/)[0]}`;
|
|
1536
|
+
const sameKind = items.filter((child) => signature(child) === signature(items[0]));
|
|
1537
|
+
if (sameKind.length < 3) continue;
|
|
1538
|
+
const lineCounts = sameKind.slice(0, 6).map(countTextLines);
|
|
1539
|
+
const maxLines = Math.max(...lineCounts);
|
|
1540
|
+
const densest = sameKind[lineCounts.indexOf(maxLines)];
|
|
1541
|
+
// Nhóm link điều hướng (gần như mỗi dòng một link, như nhóm mục sidebar) không phải mục dày chữ
|
|
1542
|
+
// (báo nhầm 29/09/2026, sidebar của wireframe).
|
|
1543
|
+
if (densest.querySelectorAll("a[href], button").length >= maxLines - 1) continue;
|
|
1544
|
+
// Cột hay nhóm chứa mục (cột bác sĩ của lưới giờ, nhóm "Sắp tới" của hàng chờ, cột kanban): bên trong có từ
|
|
1545
|
+
// hai mục cùng loại cao từ 40px, số dòng là của cả nhóm. Mục thật được xét riêng (báo nhầm 30/09/2026,
|
|
1546
|
+
// wireframe lịch hẹn: cột 24 dòng, nhóm 25 dòng).
|
|
1547
|
+
const hasNestedItems = [densest, ...densest.querySelectorAll("*")].some((node) => {
|
|
1548
|
+
const tallSignatures = [...node.children].filter((child) => child.getBoundingClientRect().height >= 40).map(signature);
|
|
1549
|
+
return tallSignatures.some((childSignature, index) => tallSignatures.indexOf(childSignature) !== index);
|
|
1550
|
+
});
|
|
1551
|
+
if (hasNestedItems) continue;
|
|
1552
|
+
if (maxLines >= 5) denseItems.push(`${sameKind.length} mục, nhiều nhất ${maxLines} dòng chữ: ${describe(densest)}`);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// 18j2. Khối lặp có ảnh (card tin đăng, sản phẩm, việc làm): ba câu của `N12`. Chữ to nhất so với tên mục
|
|
1556
|
+
// (tên = dòng chữ dài nhất), khoảng giữa các dòng chữ (không có khoảng nào từ 6px là không tách nhóm),
|
|
1557
|
+
// tên cắt một dòng. Card số liệu không có ảnh nên không vào đây (con số to là cả khối).
|
|
1558
|
+
const repeatedCardIssues = [];
|
|
1559
|
+
const checkedCardGroups = new Set();
|
|
1560
|
+
for (const parent of allElements) {
|
|
1561
|
+
if (repeatedCardIssues.length >= 4) break;
|
|
1562
|
+
const items = [...parent.children].filter((child) => isVisible(child) && child.getBoundingClientRect().height >= 120);
|
|
1563
|
+
if (items.length < 3) continue;
|
|
1564
|
+
const signature = (child) => `${child.tagName}.${(child.getAttribute("class") || "").split(/\s+/)[0]}`;
|
|
1565
|
+
const sameKind = items.filter((child) => signature(child) === signature(items[0]));
|
|
1566
|
+
const card = sameKind[0];
|
|
1567
|
+
const image = card?.querySelector("img");
|
|
1568
|
+
if (sameKind.length < 3 || checkedCardGroups.has(signature(card)) || !image || image.getBoundingClientRect().height < 60) continue;
|
|
1569
|
+
checkedCardGroups.add(signature(card));
|
|
1570
|
+
const lines = [];
|
|
1571
|
+
const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT);
|
|
1572
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
1573
|
+
const text = node.textContent.trim();
|
|
1574
|
+
if (!text || !isVisible(node.parentElement)) continue;
|
|
1575
|
+
const range = document.createRange();
|
|
1576
|
+
range.selectNodeContents(node);
|
|
1577
|
+
const style = getComputedStyle(node.parentElement);
|
|
1578
|
+
for (const rect of range.getClientRects()) {
|
|
1579
|
+
// Chữ đè trên ảnh (badge, số ảnh) không phải phần chữ của card.
|
|
1580
|
+
if (rect.width > 2 && rect.top >= image.getBoundingClientRect().bottom - 1) lines.push({ top: rect.top, bottom: rect.bottom, size: parseFloat(style.fontSize), text, element: node.parentElement });
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
if (lines.length < 2) continue;
|
|
1584
|
+
const title = lines.reduce((longest, line) => (line.text.length > longest.text.length ? line : longest));
|
|
1585
|
+
const largest = lines.reduce((biggest, line) => (line.size > biggest.size ? line : biggest));
|
|
1586
|
+
const problems = [];
|
|
1587
|
+
if (title.text.length >= 15 && largest.size / title.size >= 1.25) problems.push(`"${largest.text.slice(0, 16)}" ${largest.size}px trên tên ${title.size}px, chênh quá một bậc`);
|
|
1588
|
+
const rows = [...lines].sort((first, second) => first.top - second.top)
|
|
1589
|
+
.filter((line, index, sorted) => index === 0 || line.top - sorted[index - 1].top > 3);
|
|
1590
|
+
const gaps = rows.slice(1).map((row, index) => row.top - rows[index].bottom);
|
|
1591
|
+
if (rows.length >= 3 && Math.max(...gaps) < 6) problems.push(`${rows.length} dòng chữ cách đều ${Math.round(Math.min(...gaps))}–${Math.round(Math.max(...gaps))}px, không tách nhóm`);
|
|
1592
|
+
const titleStyle = getComputedStyle(title.element);
|
|
1593
|
+
if (titleStyle.textOverflow === "ellipsis" && titleStyle.whiteSpace === "nowrap") {
|
|
1594
|
+
const titleRange = document.createRange();
|
|
1595
|
+
titleRange.selectNodeContents(title.element);
|
|
1596
|
+
if (titleRange.getBoundingClientRect().width > title.element.getBoundingClientRect().width + 1) problems.push(`tên "${title.text.slice(0, 24)}…" cắt một dòng`);
|
|
1597
|
+
}
|
|
1598
|
+
if (problems.length > 0) repeatedCardIssues.push(`${sameKind.length} card: ${problems.join("; ")}: ${describe(card)}`);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// 18l. Hàng control lệch trên dưới: hàng flex ngang chứa nút / ô nhập khác chiều cao mà không `items-center`,
|
|
1602
|
+
// control thấp hơn dính lên đỉnh hàng. Nút "View companies" 32px cạnh nút ⋯ 36px trong ô cuối dòng
|
|
1603
|
+
// bảng: trên 8px, dưới 12px (29/09/2026, wireframe báo cáo). Chỉ xét hàng thấp (một hàng control).
|
|
1604
|
+
const misalignedControlRows = [];
|
|
1605
|
+
const controlSelector = "button, a[href], input, select, textarea, [role='button'], [role='combobox']";
|
|
1606
|
+
const seenControlRows = new Set();
|
|
1607
|
+
for (const row of allElements) {
|
|
1608
|
+
if (misalignedControlRows.length >= 4) break;
|
|
1609
|
+
const rowKind = `${row.tagName}|${row.getAttribute("class") || ""}`;
|
|
1610
|
+
if (seenControlRows.has(rowKind)) continue;
|
|
1611
|
+
const style = getComputedStyle(row);
|
|
1612
|
+
if (!/flex/.test(style.display) || !style.flexDirection.startsWith("row") || /center|baseline/.test(style.alignItems)) continue;
|
|
1613
|
+
const rowRect = row.getBoundingClientRect();
|
|
1614
|
+
if (rowRect.height > 72 || !isVisible(row)) continue;
|
|
1615
|
+
const children = [...row.children].filter((child) => isVisible(child) && getComputedStyle(child).position !== "absolute");
|
|
1616
|
+
if (children.length < 2 || !children.some((child) => child.matches(controlSelector))) continue;
|
|
1617
|
+
// Khối chữ nhiều dòng (tiêu đề + mô tả) cao hơn control: control bám dòng đầu là đúng (nút ✕
|
|
1618
|
+
// header modal, `items-start`), không phải hàng control. Báo nhầm 30/09/2026, design system phòng khám.
|
|
1619
|
+
const tallestControl = Math.max(...children.filter((child) => child.matches(controlSelector)).map((child) => child.getBoundingClientRect().height));
|
|
1620
|
+
if (children.some((child) => !child.matches(controlSelector) && child.getBoundingClientRect().height > tallestControl + 8)) continue;
|
|
1621
|
+
const contentTop = rowRect.top + parseFloat(style.paddingTop) + parseFloat(style.borderTopWidth);
|
|
1622
|
+
const contentBottom = rowRect.bottom - parseFloat(style.paddingBottom) - parseFloat(style.borderBottomWidth);
|
|
1623
|
+
const contentCenter = (contentTop + contentBottom) / 2;
|
|
1624
|
+
const offsets = children
|
|
1625
|
+
.filter((child) => child.matches(controlSelector) && !/center|baseline/.test(getComputedStyle(child).alignSelf))
|
|
1626
|
+
.map((child) => {
|
|
1627
|
+
const rect = child.getBoundingClientRect();
|
|
1628
|
+
|
|
1629
|
+
return { child, offset: (rect.top + rect.bottom) / 2 - contentCenter, height: rect.height };
|
|
1630
|
+
});
|
|
1631
|
+
const worst = offsets.sort((first, second) => Math.abs(second.offset) - Math.abs(first.offset))[0];
|
|
1632
|
+
if (!worst || Math.abs(worst.offset) < 2) continue;
|
|
1633
|
+
seenControlRows.add(rowKind);
|
|
1634
|
+
misalignedControlRows.push(`${describe(worst.child)} cao ${Math.round(worst.height)}px trong hàng ${Math.round(contentBottom - contentTop)}px, lệch ${Math.round(Math.abs(worst.offset) * 2)}px trên dưới: ${describe(row)}`);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// 18n. Khối không theo mẫu control của skill (29/09/2026, wireframe quản lý chi phí): placeholder dài hơn ô
|
|
1638
|
+
// (ô thật cắt mất đuôi), khối trông như ô nhập mà là `div` nên chữ xuống dòng, phân trang chỉ có nút
|
|
1639
|
+
// chữ "Trước / Sau" không số trang (`components/small-controls.md`).
|
|
1640
|
+
const textMeasurer = document.createElement("canvas").getContext("2d");
|
|
1641
|
+
const overlongPlaceholders = [];
|
|
1642
|
+
for (const input of document.querySelectorAll("input[placeholder]")) {
|
|
1643
|
+
if (overlongPlaceholders.length >= 4 || !isVisible(input) || /hidden|checkbox|radio|range|file/.test(input.type)) continue;
|
|
1644
|
+
const style = getComputedStyle(input);
|
|
1645
|
+
textMeasurer.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
1646
|
+
const available = input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
|
1647
|
+
const needed = textMeasurer.measureText(input.placeholder).width;
|
|
1648
|
+
if (needed > available + 1) overlongPlaceholders.push(`"${input.placeholder}" cần ${Math.round(needed)}px, ô còn ${Math.round(available)}px: ${describe(input)}`);
|
|
1649
|
+
}
|
|
1650
|
+
const fakeFieldWraps = [];
|
|
1651
|
+
for (const element of allElements) {
|
|
1652
|
+
if (fakeFieldWraps.length >= 4) break;
|
|
1653
|
+
if (element.matches("input, textarea, select, [contenteditable='true']") || !isVisible(element)) continue;
|
|
1654
|
+
if (!/(^|[-_\s])(input|search|field|searchbox)([-_\s]|$)/i.test(element.getAttribute("class") || "")) continue;
|
|
1655
|
+
const style = getComputedStyle(element);
|
|
1656
|
+
const rect = element.getBoundingClientRect();
|
|
1657
|
+
if (!(parseFloat(style.borderTopWidth) > 0) || rect.height > 72 || element.querySelector("input, textarea")) continue;
|
|
1658
|
+
if (countTextLines(element) >= 2) fakeFieldWraps.push(`cao ${Math.round(rect.height)}px, chữ ${countTextLines(element)} dòng: ${describe(element)}`);
|
|
1659
|
+
}
|
|
1660
|
+
const textOnlyPagers = [];
|
|
1661
|
+
const pagerWords = /^(‹\s*)?(trước|sau|previous|prev|next|trang trước|trang sau)(\s*›)?$/i;
|
|
1662
|
+
for (const group of document.querySelectorAll("nav, div, footer")) {
|
|
1663
|
+
if (textOnlyPagers.length >= 2 || !isVisible(group)) continue;
|
|
1664
|
+
const controls = [...group.children].filter((child) => child.matches("a, button"));
|
|
1665
|
+
const wordControls = controls.filter((control) => pagerWords.test(control.textContent.trim()));
|
|
1666
|
+
if (wordControls.length < 2 || controls.some((control) => /^\d+$/.test(control.textContent.trim()))) continue;
|
|
1667
|
+
textOnlyPagers.push(`${wordControls.map((control) => `"${control.textContent.trim()}" ${Math.round(control.getBoundingClientRect().width)}px`).join(", ")}: ${describe(group)}`);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// 18o. Thanh header trong suốt nằm trên nền trang xám: các app để header nền trắng; trong suốt thì trông như
|
|
1671
|
+
// chưa xong, dính đỉnh thì nội dung cuộn lên lộ sau chữ (29/09/2026, wireframe khách truy cập).
|
|
1672
|
+
const transparentHeaders = [];
|
|
1673
|
+
for (const bar of document.querySelectorAll("header, [role='banner']")) {
|
|
1674
|
+
if (transparentHeaders.length >= 2 || !isVisible(bar)) continue;
|
|
1675
|
+
const rect = bar.getBoundingClientRect();
|
|
1676
|
+
if (rect.height < 44 || rect.height > 96 || rect.width < window.innerWidth * 0.4 || rect.top > 8) continue;
|
|
1677
|
+
if (readColor(getComputedStyle(bar).backgroundColor).alpha > 0.1) continue;
|
|
1678
|
+
let behind = bar.parentElement;
|
|
1679
|
+
while (behind && readColor(getComputedStyle(behind).backgroundColor).alpha < 0.9) behind = behind.parentElement;
|
|
1680
|
+
const behindColor = readColor(getComputedStyle(behind || document.body).backgroundColor);
|
|
1681
|
+
const isGrayBehind = behindColor.alpha > 0.9 && Math.min(behindColor.red, behindColor.green, behindColor.blue) < 250;
|
|
1682
|
+
if (isGrayBehind) transparentHeaders.push(`nền sau rgb(${behindColor.red}, ${behindColor.green}, ${behindColor.blue}): ${describe(bar)}`);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// 18m. Trang wireframe thiếu phần nào của thanh công cụ (`design-process.md` U3): phương án, nút Màu, Khổ,
|
|
1686
|
+
// Trạng thái, khung lý do, số khối. Có lượt wireframe có thanh, có lượt không (29/09/2026). Trong khung
|
|
1687
|
+
// mobile (`frame=1`) thanh ẩn là đúng, không tính.
|
|
1688
|
+
const wireframeParams = new URLSearchParams(location.search);
|
|
1689
|
+
const countLinksWith = (param) => [...document.querySelectorAll("a[href*='?']")]
|
|
1690
|
+
.filter((link) => new URL(link.href, location.href).searchParams.has(param)).length;
|
|
1691
|
+
const missingWireframeParts = !/wireframe/i.test(location.pathname) || wireframeParams.has("frame") ? [] : [
|
|
1692
|
+
countLinksWith("v") < 2 && "thanh phương án (?v=)",
|
|
1693
|
+
countLinksWith("mau") < 1 && "công tắc Màu (mau=)",
|
|
1694
|
+
countLinksWith("kho") < 2 && "nút Khổ desktop / mobile (kho=)",
|
|
1695
|
+
countLinksWith("tt") < 2 && "nút Trạng thái (tt=)",
|
|
1696
|
+
!document.querySelector("[data-wf-reason]") && "khung lý do (data-wf-reason)",
|
|
1697
|
+
document.querySelectorAll("[data-wf-block]").length < 2 && "số khối (data-wf-block)",
|
|
1698
|
+
].filter(Boolean);
|
|
1699
|
+
|
|
1700
|
+
// 18m1. Vạch "bây giờ" của lưới giờ (`data-now`) bị ô hẹn đè: ô nằm trên vạch thì lúc đông lịch vạch chỉ lộ ở
|
|
1701
|
+
// khe giữa các ô (đo 30/09/2026, lịch hẹn nha khoa 10:40: hiện 2% bề ngang), không còn là mốc để đọc
|
|
1702
|
+
// "ai đang trễ, ai sắp tới". Đo bằng hit-test dọc vạch, tạm bật pointer-events.
|
|
1703
|
+
const coveredNowLines = [];
|
|
1704
|
+
for (const marker of document.querySelectorAll("[data-now]")) {
|
|
1705
|
+
if (!isVisible(marker) && marker.getBoundingClientRect().width < 40) continue;
|
|
1706
|
+
const markerRect = marker.getBoundingClientRect();
|
|
1707
|
+
if (markerRect.width < 40 || markerRect.bottom < 0 || markerRect.top > window.innerHeight) continue;
|
|
1708
|
+
const touched = [marker, ...marker.querySelectorAll("*")].map((node) => [node, node.style.pointerEvents]);
|
|
1709
|
+
for (const [node] of touched) node.style.pointerEvents = "auto";
|
|
1710
|
+
const sampleY = markerRect.top + markerRect.height / 2;
|
|
1711
|
+
let visibleCount = 0;
|
|
1712
|
+
let sampleCount = 0;
|
|
1713
|
+
for (let sampleX = markerRect.left + 2; sampleX < markerRect.right - 2; sampleX += 4) {
|
|
1714
|
+
sampleCount++;
|
|
1715
|
+
if (marker.contains(document.elementFromPoint(sampleX, sampleY))) visibleCount++;
|
|
1716
|
+
}
|
|
1717
|
+
for (const [node, value] of touched) node.style.pointerEvents = value;
|
|
1718
|
+
const visiblePercent = sampleCount ? Math.round((visibleCount / sampleCount) * 100) : 100;
|
|
1719
|
+
if (visiblePercent < 60) coveredNowLines.push(`chỉ thấy ${visiblePercent}% bề ngang: ${describe(marker)}`);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// 18m2. Khung wireframe làm hỏng bản thiết kế (U3, 30/09/2026, wireframe lịch hẹn nha khoa):
|
|
1723
|
+
// - thanh công cụ tràn ngang ở desktop (thêm nhóm Màn, tên nhóm dài): "Trạng thái" bị cắt ở 1280;
|
|
1724
|
+
// - số khối đè chữ hay icon của khối không có padding ("Thứ Ba" thành "ThBa"), hoặc bị khung cuộn cắt mất;
|
|
1725
|
+
// - `[data-wf-block] { position: relative }` không nằm trong layer đè `sticky` của sidebar: sidebar trôi khi cuộn.
|
|
1726
|
+
const wireframeChromeProblems = [];
|
|
1727
|
+
const wireframeBar = document.querySelector(".wf-bar");
|
|
1728
|
+
if (wireframeBar && viewportWidth >= 1280 && wireframeBar.scrollWidth > wireframeBar.clientWidth + 1) {
|
|
1729
|
+
wireframeChromeProblems.push(`thanh công cụ tràn ${wireframeBar.scrollWidth - wireframeBar.clientWidth}px ở ${viewportWidth}px: rút nhãn (Màn một hai chữ, Phương án chỉ chữ cái)`);
|
|
1730
|
+
}
|
|
1731
|
+
// Token focus theo màu nhấn đang áp (design-process.md, U3): đổi --primary trên body mà --border-focus khai
|
|
1732
|
+
// `var(--primary)` ở :root thì đã tính xong ở :root, viền focus giữ màu cũ; --ring-focus viết cứng cũng vậy (đã dính
|
|
1733
|
+
// 01/10/2026, wireframe lớp học: chọn xanh ngọc mà ô tìm focus viền chàm ở mọi nấc). Đo ở khối thiết kế, nền sáng.
|
|
1734
|
+
if (wireframeBar && !document.documentElement.classList.contains("dark")) {
|
|
1735
|
+
const designRoot = document.getElementById("wf-design") || document.body;
|
|
1736
|
+
const sample = document.createElement("span");
|
|
1737
|
+
designRoot.appendChild(sample);
|
|
1738
|
+
const readRgb = (value) => {
|
|
1739
|
+
sample.style.color = "";
|
|
1740
|
+
sample.style.color = value;
|
|
1741
|
+
const color = getComputedStyle(sample).color;
|
|
1742
|
+
// color-mix trả `color(srgb 0.05 0.58 0.53 / 0.1)`, kênh 0–1.
|
|
1743
|
+
const scale = color.startsWith("color(") ? 255 : 1;
|
|
1744
|
+
return (color.replace(/^color\(srgb/, "").match(/[\d.]+/g) || []).slice(0, 3).map((channel) => Math.round(Number(channel) * scale));
|
|
1745
|
+
};
|
|
1746
|
+
const primaryRgb = readRgb("var(--primary)");
|
|
1747
|
+
for (const token of ["--border-focus", "--ring-focus"]) {
|
|
1748
|
+
if (!getComputedStyle(designRoot).getPropertyValue(token).trim()) continue;
|
|
1749
|
+
const tokenRgb = readRgb(`var(${token})`);
|
|
1750
|
+
if (tokenRgb.length === 3 && primaryRgb.length === 3 && tokenRgb.some((channel, index) => Math.abs(channel - primaryRgb[index]) > 6)) {
|
|
1751
|
+
wireframeChromeProblems.push(`token focus không theo màu nhấn: ${token} rgb(${tokenRgb.join(", ")}) trong khi --primary rgb(${primaryRgb.join(", ")}); đổi màu nhấn trên :root và suy lại token (design-process.md)`);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
sample.remove();
|
|
1755
|
+
}
|
|
1756
|
+
const breakpointQueries = { sm: "(min-width: 40rem)", md: "(min-width: 48rem)", lg: "(min-width: 64rem)", xl: "(min-width: 80rem)", "2xl": "(min-width: 96rem)" };
|
|
1757
|
+
const isRectOverlap = (first, second) => first.left < second.right && first.right > second.left && first.top < second.bottom && first.bottom > second.top;
|
|
1758
|
+
for (const block of document.querySelectorAll("[data-wf-block]")) {
|
|
1759
|
+
if (!isVisible(block)) continue;
|
|
1760
|
+
const blockStyle = getComputedStyle(block);
|
|
1761
|
+
const wantedPosition = (block.getAttribute("class") || "").split(/\s+/)
|
|
1762
|
+
.map((token) => token.match(/^(?:(sm|md|lg|xl|2xl):)?(sticky|fixed|absolute)$/))
|
|
1763
|
+
.filter((match) => match && (!match[1] || matchMedia(breakpointQueries[match[1]]).matches))
|
|
1764
|
+
.map((match) => match[2]).pop();
|
|
1765
|
+
if (wantedPosition && blockStyle.position !== wantedPosition) {
|
|
1766
|
+
wireframeChromeProblems.push(`khối ${block.dataset.wfBlock} có class ${wantedPosition} mà ra ${blockStyle.position}: luật [data-wf-block] đè mất, đặt nó trong @layer base`);
|
|
1767
|
+
}
|
|
1768
|
+
const badge = getComputedStyle(block, "::before");
|
|
1769
|
+
if (badge.content === "none" || badge.display === "none") continue;
|
|
1770
|
+
const blockRect = block.getBoundingClientRect();
|
|
1771
|
+
const badgeWidth = parseFloat(badge.width) || 18;
|
|
1772
|
+
const badgeHeight = parseFloat(badge.height) || 18;
|
|
1773
|
+
const badgeLeft = blockRect.left + (parseFloat(badge.left) || 0);
|
|
1774
|
+
// Phần tử định vị trả `top` đã tính ra px kể cả khi CSS ghi `top: auto; bottom: 100%` (ra số âm).
|
|
1775
|
+
const badgeTop = blockRect.top + (parseFloat(badge.top) || 0);
|
|
1776
|
+
const badgeRect = { left: badgeLeft, top: badgeTop, right: badgeLeft + badgeWidth, bottom: badgeTop + badgeHeight };
|
|
1777
|
+
const isOutside = badgeRect.top < blockRect.top - 1 || badgeRect.left < blockRect.left - 1;
|
|
1778
|
+
if (isOutside && (blockStyle.overflowX !== "visible" || blockStyle.overflowY !== "visible")) {
|
|
1779
|
+
wireframeChromeProblems.push(`số khối ${block.dataset.wfBlock} nằm ngoài khối mà khối cắt tràn (overflow ${blockStyle.overflowX}): số bị mất, đặt data-wf-block lên khối bọc không cuộn`);
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
const contentRects = [...block.querySelectorAll("svg, img")].map((node) => node.getBoundingClientRect());
|
|
1783
|
+
const textWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
1784
|
+
for (let node = textWalker.nextNode(); node; node = textWalker.nextNode()) {
|
|
1785
|
+
if (!node.textContent.trim()) continue;
|
|
1786
|
+
const range = document.createRange();
|
|
1787
|
+
range.selectNodeContents(node);
|
|
1788
|
+
contentRects.push(...range.getClientRects());
|
|
1789
|
+
}
|
|
1790
|
+
const coveredRect = contentRects.find((rect) => rect.width > 0 && isRectOverlap(rect, badgeRect));
|
|
1791
|
+
if (coveredRect) wireframeChromeProblems.push(`số khối ${block.dataset.wfBlock} đè chữ hay icon của khối: dời số lên trên mép khối (data-wf-block-out)`);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// 19. Chữ dưới 12px: đọc khó ở mọi brand, hay gặp ở dòng phụ trong card và cột bên. Chữ trong biểu đồ
|
|
1795
|
+
// (svg) và nhãn ngắn từ ba ký tự trở xuống ("Mới", "VIP") không tính.
|
|
1796
|
+
const tinyTexts = [];
|
|
1797
|
+
let tinyTextCount = 0;
|
|
1798
|
+
for (const element of allElements) {
|
|
1799
|
+
const ownText = [...element.childNodes].filter((node) => node.nodeType === Node.TEXT_NODE).map((node) => node.textContent).join("").trim();
|
|
1800
|
+
if (ownText.length <= 3 || element.closest("svg") || !isVisible(element)) continue;
|
|
1801
|
+
const fontSize = parseFloat(getComputedStyle(element).fontSize);
|
|
1802
|
+
if (fontSize >= 12) continue;
|
|
1803
|
+
tinyTextCount += 1;
|
|
1804
|
+
if (tinyTexts.length < 10) tinyTexts.push(`${fontSize}px "${ownText.slice(0, 32)}": ${describe(element)}`);
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
return {
|
|
1808
|
+
orphanWords,
|
|
1809
|
+
invisibleFrames,
|
|
1810
|
+
mismatchedRadii,
|
|
1811
|
+
browserDefaultControls,
|
|
1812
|
+
brokenRules,
|
|
1813
|
+
mismatchedRuleColors,
|
|
1814
|
+
unevenHeaderActions,
|
|
1815
|
+
squeezedBlocks,
|
|
1816
|
+
styledNativeSelects,
|
|
1817
|
+
stickyScrollColumns,
|
|
1818
|
+
mouseUnreachableScrollers,
|
|
1819
|
+
nestedFadeDialogs,
|
|
1820
|
+
untransitionedMotion,
|
|
1821
|
+
heavySeparators,
|
|
1822
|
+
clippedBars,
|
|
1823
|
+
denseItems,
|
|
1824
|
+
repeatedCardIssues,
|
|
1825
|
+
misalignedControlRows,
|
|
1826
|
+
missingWireframeParts,
|
|
1827
|
+
wireframeChromeProblems,
|
|
1828
|
+
coveredNowLines,
|
|
1829
|
+
overlongPlaceholders,
|
|
1830
|
+
fakeFieldWraps,
|
|
1831
|
+
textOnlyPagers,
|
|
1832
|
+
transparentHeaders,
|
|
1833
|
+
swallowedNumbers,
|
|
1834
|
+
floatingContent,
|
|
1835
|
+
tinyTexts,
|
|
1836
|
+
tinyTextCount,
|
|
1837
|
+
autoScrolledAreas,
|
|
1838
|
+
clippedBlocks,
|
|
1839
|
+
wrappedControls,
|
|
1840
|
+
wrappedRows,
|
|
1841
|
+
lowContrastTexts: sortedLowContrast.slice(0, 12).map((item) => item.line),
|
|
1842
|
+
lowContrastCount: sortedLowContrast.length,
|
|
1843
|
+
unmeasuredContrastCount,
|
|
1844
|
+
viewportWidth,
|
|
1845
|
+
pageScrollWidth,
|
|
1846
|
+
unpinnedScrollTables,
|
|
1847
|
+
squeezedTableColumns,
|
|
1848
|
+
crampedDescriptionLists,
|
|
1849
|
+
unevenStatRows,
|
|
1850
|
+
brokenMoney: [...new Set(brokenMoney)].slice(0, 10),
|
|
1851
|
+
gridChoiceGroups,
|
|
1852
|
+
hasHorizontalScroll: pageScrollWidth > viewportWidth + 1,
|
|
1853
|
+
overflowingElements,
|
|
1854
|
+
truncatedCount: truncatedTexts.length,
|
|
1855
|
+
tooShortTexts: tooShortTexts.slice(0, 10),
|
|
1856
|
+
tooShortCount: tooShortTexts.length,
|
|
1857
|
+
unevenSiblingGroups: unevenSiblingGroups.slice(0, 10),
|
|
1858
|
+
misalignedColumns: misalignedColumns.slice(0, 10),
|
|
1859
|
+
// Chỗ dưới 24px lên đầu: đó là mức hỏng với mọi brand, 24 tới 31px chỉ là sàn của skill.
|
|
1860
|
+
smallTapTargets: smallTapTargets
|
|
1861
|
+
.map((item) => ({ ...item, isBelowFloor: Math.min(...item.size.split("×").map(Number)) < 24 }))
|
|
1862
|
+
.sort((first, second) => Number(second.isBelowFloor) - Number(first.isBelowFloor))
|
|
1863
|
+
.slice(0, 15),
|
|
1864
|
+
smallTapCount: smallTapTargets.length,
|
|
1865
|
+
orphanPunctuation,
|
|
1866
|
+
misalignedFields: misalignedFields.slice(0, 10),
|
|
1867
|
+
unevenSeparatorRows: unevenSeparatorRows.slice(0, 10),
|
|
1868
|
+
overlappedChartLabels: [...new Set(overlappedChartLabels)].slice(0, 10),
|
|
1869
|
+
iconCoveringBadges,
|
|
1870
|
+
outsideChartLabels: [...new Set(outsideChartLabels)].slice(0, 10),
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// Vòng focus vẽ trên phần tử và các con (chữ bọc `<span>` mang `group-focus-visible:ring`): outline thấy được,
|
|
1875
|
+
// hay bóng dạng vòng `0 0 0 Npx` có màu. Trả chuỗi để so lúc có và không có focus.
|
|
1876
|
+
function readFocusRingSignature(probeId) {
|
|
1877
|
+
const element = document.querySelector(`[data-evon-probe-id="${probeId}"]`);
|
|
1878
|
+
if (!element) return null;
|
|
1879
|
+
const isVisibleColor = (color) => Boolean(color) && !/rgba\([^)]*,\s*0\)|\/\s*0\)|transparent/.test(color);
|
|
1880
|
+
const nodes = [element, ...element.querySelectorAll("span, div, svg")].slice(0, 20);
|
|
1881
|
+
|
|
1882
|
+
return nodes
|
|
1883
|
+
.map((node) => {
|
|
1884
|
+
const style = getComputedStyle(node);
|
|
1885
|
+
// `outline-hidden` của Tailwind v4 là viền 2px trong suốt: không tính.
|
|
1886
|
+
const outline = style.outlineStyle !== "none" && parseFloat(style.outlineWidth) > 0 && isVisibleColor(style.outlineColor)
|
|
1887
|
+
? `outline ${style.outlineWidth} ${style.outlineColor}`
|
|
1888
|
+
: "";
|
|
1889
|
+
// Tailwind v4 trả màu vòng dạng `oklab(… / 0.5)`: chỉ đọc `rgba()` thì không thấy vòng nào, probe tưởng dự
|
|
1890
|
+
// án không vẽ vòng ở đâu nên bỏ luôn phép "Tab tới không thấy gì" (sót 30/09/2026, lịch khám: link sidebar).
|
|
1891
|
+
const rings = [...style.boxShadow.matchAll(/((?:rgba?|hsla?|oklab|oklch|lab|lch|color)\([^)]*\))\s+0px\s+0px\s+0px\s+([\d.]+)px/g)]
|
|
1892
|
+
.filter((match) => parseFloat(match[2]) > 0 && isVisibleColor(match[1]))
|
|
1893
|
+
.map((match) => `ring ${match[2]}px ${match[1]}`);
|
|
1894
|
+
|
|
1895
|
+
return [outline, ...rings].filter(Boolean).join(" ");
|
|
1896
|
+
})
|
|
1897
|
+
.join("|");
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// Cả dáng thấy được của phần tử lúc đó: vòng, nền, viền, màu chữ, gạch chân của nó và vài con. Lúc Tab
|
|
1901
|
+
// tới mà chuỗi này y như lúc không focus thì Tab tới không thấy gì.
|
|
1902
|
+
function readFocusLookSignature(probeId) {
|
|
1903
|
+
const element = document.querySelector(`[data-evon-probe-id="${probeId}"]`);
|
|
1904
|
+
if (!element) return null;
|
|
1905
|
+
const nodes = [element, ...element.querySelectorAll("span, div, svg")].slice(0, 20);
|
|
1906
|
+
|
|
1907
|
+
return nodes
|
|
1908
|
+
.map((node) => {
|
|
1909
|
+
const style = getComputedStyle(node);
|
|
1910
|
+
|
|
1911
|
+
const after = getComputedStyle(node, "::after");
|
|
1912
|
+
|
|
1913
|
+
// `outline-none` vẫn để `outline-width` đổi theo `focus-visible:` (1px → 3px) mà không vẽ gì: chỉ so outline
|
|
1914
|
+
// khi có vẽ (sót 30/09/2026, lịch khám: link sidebar Tab tới không thấy gì mà probe tưởng có đổi).
|
|
1915
|
+
const drawnOutline = style.outlineStyle === "none" ? "none" : `${style.outlineStyle} ${style.outlineWidth} ${style.outlineColor}`;
|
|
1916
|
+
|
|
1917
|
+
return [drawnOutline, style.boxShadow, style.backgroundColor, style.borderColor, style.color, style.textDecorationLine, after.boxShadow, after.outlineStyle, after.opacity, after.borderColor].join(" ");
|
|
1918
|
+
})
|
|
1919
|
+
.join("|");
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// Tab qua trang, ghi phần tử còn vẽ vòng focus lúc Tab tới. Skill không vẽ vòng focus (`I13`, chủ dự án chốt
|
|
1923
|
+
// 28/09/2026). Ô nhập, textarea, select, combobox được viền + ring mờ; mục menu tô nền, không phải vòng.
|
|
1924
|
+
async function findDrawnFocusRings(page) {
|
|
1925
|
+
const candidates = [];
|
|
1926
|
+
const checksByKind = new Map();
|
|
1927
|
+
let firstFocusedId = null;
|
|
1928
|
+
let bodyStreak = 0;
|
|
1929
|
+
|
|
1930
|
+
for (let tabIndex = 0; tabIndex < maxTabStops; tabIndex += 1) {
|
|
1931
|
+
await page.keyboard.press("Tab");
|
|
1932
|
+
const current = await page.evaluate(() => {
|
|
1933
|
+
const element = document.activeElement;
|
|
1934
|
+
// Lớp báo lỗi của Next lúc dev (`nextjs-portal`) không phải của trang (báo nhầm 28/09/2026).
|
|
1935
|
+
if (!element || element === document.body || element.tagName.startsWith("NEXTJS")) return null;
|
|
1936
|
+
if (!element.dataset.evonProbeId) element.dataset.evonProbeId = String(Math.random()).slice(2);
|
|
1937
|
+
const label = (element.getAttribute("aria-label") || element.textContent.trim() || element.getAttribute("title") || "").trim().replace(/\s+/g, " ").slice(0, 40);
|
|
1938
|
+
const isField = element.matches("input:not([type='checkbox']):not([type='radio']):not([type='range']), textarea, select, [contenteditable='true'], [role='combobox']");
|
|
1939
|
+
|
|
1940
|
+
return { id: element.dataset.evonProbeId, kind: `${element.tagName}|${element.getAttribute("class") || ""}`, element: `${element.tagName.toLowerCase()} "${label}"`, isField };
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
// Focus rơi về body: đi hết cuối trang, Tab tiếp sẽ vòng lại đầu. Trang tự focus ô chat lúc tải thì vòng
|
|
1944
|
+
// Tab bắt đầu giữa trang; dừng ở body là không bao giờ tới header, sidebar (sót 27/09/2026, dự án mồi).
|
|
1945
|
+
if (!current) {
|
|
1946
|
+
bodyStreak += 1;
|
|
1947
|
+
if (bodyStreak >= 3) break;
|
|
1948
|
+
continue;
|
|
1949
|
+
}
|
|
1950
|
+
bodyStreak = 0;
|
|
1951
|
+
if (current.id === firstFocusedId) break;
|
|
1952
|
+
if (!firstFocusedId) firstFocusedId = current.id;
|
|
1953
|
+
if (current.isField) continue;
|
|
1954
|
+
|
|
1955
|
+
const checkCount = checksByKind.get(current.kind) ?? 0;
|
|
1956
|
+
checksByKind.set(current.kind, checkCount + 1);
|
|
1957
|
+
if (checkCount >= maxFocusChecksPerKind) continue;
|
|
1958
|
+
const focusedSignature = await page.evaluate(readFocusRingSignature, current.id);
|
|
1959
|
+
const focusedLook = await page.evaluate(readFocusLookSignature, current.id);
|
|
1960
|
+
candidates.push({ ...current, focusedSignature, focusedLook });
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// So với lúc không focus: viền "đang chọn" vẽ bằng ring (card chọn, chip) có sẵn cả lúc thường, không tính.
|
|
1964
|
+
const drawnRings = [];
|
|
1965
|
+
const unmarkedStops = [];
|
|
1966
|
+
for (const candidate of candidates) {
|
|
1967
|
+
await page.evaluate(() => document.activeElement?.blur());
|
|
1968
|
+
const blurredSignature = await page.evaluate(readFocusRingSignature, candidate.id);
|
|
1969
|
+
const blurredLook = await page.evaluate(readFocusLookSignature, candidate.id);
|
|
1970
|
+
if (candidate.focusedSignature?.replace(/\|/g, "") && blurredSignature !== candidate.focusedSignature) drawnRings.push(candidate.element);
|
|
1971
|
+
else if (blurredLook === candidate.focusedLook) unmarkedStops.push(candidate);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// Vòng có chuyển động thì lúc vừa Tab tới có thể chưa kịp hiện: focus lại (vẫn đang ở chế độ bàn phím nên
|
|
1975
|
+
// khớp `:focus-visible`), chờ hết chuyển động rồi so lần nữa.
|
|
1976
|
+
const confirmedUnmarked = [];
|
|
1977
|
+
for (const candidate of drawnRings.length >= 2 ? unmarkedStops : []) {
|
|
1978
|
+
await page.evaluate((probeId) => document.querySelector(`[data-evon-probe-id="${probeId}"]`)?.focus({ focusVisible: true }), candidate.id);
|
|
1979
|
+
await page.waitForTimeout(350);
|
|
1980
|
+
const settledLook = await page.evaluate(readFocusLookSignature, candidate.id);
|
|
1981
|
+
await page.evaluate(() => document.activeElement?.blur());
|
|
1982
|
+
const blurredLook = await page.evaluate(readFocusLookSignature, candidate.id);
|
|
1983
|
+
if (settledLook === blurredLook) confirmedUnmarked.push(candidate.element);
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// Ngoại lệ của `I13` (chủ dự án chốt 30/09/2026): dự án tự vẽ vòng ở từ hai chỗ thì chỗ Tab tới không thấy
|
|
1987
|
+
// gì là bị đè mất trong hệ của họ (tab đang chọn đặt `box-shadow` viền trong, đè vòng của Button). Dự án
|
|
1988
|
+
// không vẽ vòng ở đâu thì theo `I13`, không báo.
|
|
1989
|
+
return { drawnRings, unmarkedFocusStops: confirmedUnmarked };
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// ---------- Trạng thái động: rê chuột, lớp nổi, khối đang đóng ----------
|
|
1993
|
+
// Ba lỗi 26–27/09/2026 lọt vì chỉ lộ khi rê hoặc chạm: nền rê nút viền #fff → #f8f8fa gần như không
|
|
1994
|
+
// thấy, tooltip tên tệp 765px tràn khỏi màn 375px, lỗi nằm trong khối accordion đang đóng.
|
|
1995
|
+
|
|
1996
|
+
const maxHoverTargets = 80;
|
|
1997
|
+
const maxPopupTriggers = 20;
|
|
1998
|
+
const maxTruncatedTaps = 10;
|
|
1999
|
+
|
|
2000
|
+
// Đọc màu thật trên màn của một phần tử: nền của nó phủ lên nền đặc gần nhất phía sau. Màu oklab /
|
|
2001
|
+
// oklch của Tailwind v4 đổi sang rgb qua canvas.
|
|
2002
|
+
function readHoverState(probeId) {
|
|
2003
|
+
const element = document.querySelector(`[data-evon-hover-id="${probeId}"]`);
|
|
2004
|
+
if (!element) return null;
|
|
2005
|
+
|
|
2006
|
+
const canvas = document.createElement("canvas");
|
|
2007
|
+
canvas.width = 1;
|
|
2008
|
+
canvas.height = 1;
|
|
2009
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
2010
|
+
const toRgba = (cssColor) => {
|
|
2011
|
+
context.clearRect(0, 0, 1, 1);
|
|
2012
|
+
context.fillStyle = "rgba(0,0,0,0)";
|
|
2013
|
+
context.fillStyle = cssColor;
|
|
2014
|
+
context.fillRect(0, 0, 1, 1);
|
|
2015
|
+
const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
|
|
2016
|
+
|
|
2017
|
+
return { red, green, blue, alpha: alpha / 255 };
|
|
2018
|
+
};
|
|
2019
|
+
const blend = (top, bottom) => ({
|
|
2020
|
+
red: Math.round(top.red * top.alpha + bottom.red * (1 - top.alpha)),
|
|
2021
|
+
green: Math.round(top.green * top.alpha + bottom.green * (1 - top.alpha)),
|
|
2022
|
+
blue: Math.round(top.blue * top.alpha + bottom.blue * (1 - top.alpha)),
|
|
2023
|
+
alpha: 1,
|
|
2024
|
+
});
|
|
2025
|
+
const findOpaqueAncestor = (start) => {
|
|
2026
|
+
for (let node = start; node; node = node.parentElement) {
|
|
2027
|
+
const color = toRgba(getComputedStyle(node).backgroundColor);
|
|
2028
|
+
if (color.alpha > 0.9) return { node, color };
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
return { node: document.documentElement, color: { red: 255, green: 255, blue: 255, alpha: 1 } };
|
|
2032
|
+
};
|
|
2033
|
+
|
|
2034
|
+
const style = getComputedStyle(element);
|
|
2035
|
+
const behind = findOpaqueAncestor(element.parentElement);
|
|
2036
|
+
const outside = findOpaqueAncestor(behind.node.parentElement);
|
|
2037
|
+
const ownColor = toRgba(style.backgroundColor);
|
|
2038
|
+
const rect = element.getBoundingClientRect();
|
|
2039
|
+
const cardRect = behind.node.getBoundingClientRect();
|
|
2040
|
+
const borderWidth = parseFloat(style.borderTopWidth) || 0;
|
|
2041
|
+
|
|
2042
|
+
// Nền của các khối con (ô icon, badge) để so lúc rê: dòng rê `bg-background` chứa ô icon `bg-background`
|
|
2043
|
+
// thì ô icon biến mất lúc rê (đã dính 27/09/2026, bản sửa của dự án mồi phase 2).
|
|
2044
|
+
const ownFill = blend(ownColor, behind.color);
|
|
2045
|
+
const childFills = [...element.querySelectorAll("*")]
|
|
2046
|
+
.filter((child) => {
|
|
2047
|
+
const childRect = child.getBoundingClientRect();
|
|
2048
|
+
|
|
2049
|
+
return childRect.width * childRect.height >= 144 && toRgba(getComputedStyle(child).backgroundColor).alpha > 0.9;
|
|
2050
|
+
})
|
|
2051
|
+
.slice(0, 30)
|
|
2052
|
+
.map((child) => toRgba(getComputedStyle(child).backgroundColor));
|
|
2053
|
+
|
|
2054
|
+
return {
|
|
2055
|
+
childFills,
|
|
2056
|
+
color: ownFill,
|
|
2057
|
+
borderColor: borderWidth > 0 ? blend(toRgba(style.borderTopColor), behind.color) : null,
|
|
2058
|
+
isBorderTransparent: borderWidth > 0 && toRgba(style.borderTopColor).alpha === 0,
|
|
2059
|
+
outsideColor: outside.color,
|
|
2060
|
+
behindColor: behind.color,
|
|
2061
|
+
// Chạm mép khung đặc phía sau (dòng bảng tràn hai mép card): nền rê gần màu nền ngoài khung là
|
|
2062
|
+
// card như bị khuyết một mảng (đã dính 26/09/2026, FAQ trang giá).
|
|
2063
|
+
width: rect.width,
|
|
2064
|
+
height: rect.height,
|
|
2065
|
+
isTableRow: element.tagName === "TR",
|
|
2066
|
+
// Khai nền rê (kể cả sau biến thể như `not-checked:hover:bg-`) mà rê vào màu không đổi là nền rê trùng
|
|
2067
|
+
// nền phía sau: rê không thấy gì (đã dính 28/09/2026, dòng danh sách rê `bg-muted` nằm trên khung
|
|
2068
|
+
// `bg-muted`). Mục đang chọn thì bỏ qua: rê cùng nền đang chọn là đúng.
|
|
2069
|
+
// `dark:hover:bg-` chỉ chạy khi trang đang tối: ô Select của shadcn chỉ khai nền rê cho dark, ở light rê
|
|
2070
|
+
// không đổi là đúng mẫu (luật khoá 6, báo nhầm 30/09/2026, lịch khám: mọi Select lên danh sách P).
|
|
2071
|
+
// `hover:bg-surface` (hay `disabled:hover:bg-surface`) cạnh `bg-surface` là cố ý giữ nền lúc thường, tắt nền
|
|
2072
|
+
// rê kế thừa từ variant (nút lọc dạng dropdown không hover, chủ dự án chốt, `components/choice-controls.md`),
|
|
2073
|
+
// không phải khai nền rê (báo nhầm 01/10/2026,
|
|
2074
|
+
// nút Lọc ở bảng công việc tối).
|
|
2075
|
+
declaresHoverFill: (element.getAttribute("class") || "").split(/\s+/).some((token, index, tokens) => {
|
|
2076
|
+
const variants = token.split(":");
|
|
2077
|
+
const utility = variants.pop();
|
|
2078
|
+
const isCancellingInheritedHover = tokens.includes(utility);
|
|
2079
|
+
|
|
2080
|
+
return variants.includes("hover") && /^bg-(?!transparent)/.test(utility) && !isCancellingInheritedHover && (!variants.includes("dark") || document.documentElement.classList.contains("dark"));
|
|
2081
|
+
}),
|
|
2082
|
+
isSelected: element.matches("[aria-current]:not([aria-current='false']), [aria-selected='true'], [aria-pressed='true'], [data-state='active'], [data-state='on'], [data-state='checked']"),
|
|
2083
|
+
touchesCardEdge: behind.node !== document.documentElement && (Math.abs(rect.left - cardRect.left) <= 1 || Math.abs(rect.right - cardRect.right) <= 1),
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// Lớp nổi đang hiện (tooltip, menu, listbox, popover, khung fixed nhỏ) mà lòi khỏi viewport.
|
|
2088
|
+
function findOverflowingLayers() {
|
|
2089
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
2090
|
+
const viewportHeight = window.innerHeight;
|
|
2091
|
+
const layers = [...document.querySelectorAll("[role='tooltip'], [role='menu'], [role='listbox'], [role='dialog'], dialog[open], body *")].filter((node) => {
|
|
2092
|
+
const style = getComputedStyle(node);
|
|
2093
|
+
if (style.visibility === "hidden" || style.display === "none" || parseFloat(style.opacity) === 0) return false;
|
|
2094
|
+
// Ví dụ ép trạng thái trên trang design system (select, modal mở sẵn trong khung tĩnh, `D9`): không phải
|
|
2095
|
+
// lớp nổi thật (báo nhầm 30/09/2026, design system phòng khám bản shadcn).
|
|
2096
|
+
if (node.closest("[inert], [data-demo-state]")) return false;
|
|
2097
|
+
// Lớp nổi phải tự định vị (fixed / absolute): listbox của bảng lệnh dựng tĩnh làm mẫu trong trang
|
|
2098
|
+
// nằm trong dòng chảy, kéo xuống dưới mép màn là chuyện cuộn trang (báo nhầm 27/09/2026, /components).
|
|
2099
|
+
const isPositioned = style.position === "fixed" || style.position === "absolute";
|
|
2100
|
+
const isLayer = isPositioned && (["tooltip", "menu", "listbox", "dialog"].includes(node.getAttribute("role")) || node.tagName === "DIALOG" || (parseInt(style.zIndex, 10) || 0) >= 20);
|
|
2101
|
+
if (!isLayer) return false;
|
|
2102
|
+
const rect = node.getBoundingClientRect();
|
|
2103
|
+
|
|
2104
|
+
// Lớp phủ toàn màn (phủ kín cả hai chiều, nằm gọn trong màn) không phải lớp nổi cần đo. Tooltip rộng hơn
|
|
2105
|
+
// màn thì vẫn đo: đó chính là lỗi (tooltip tên tệp 765px ở 375px, 27/09/2026). Sheet cao hết màn mà rộng
|
|
2106
|
+
// hơn màn cũng vậy: `min-w-[400px]` ở 375px lòi 25px mép trái (sót 30/09/2026, lịch khám, sheet bộ lọc).
|
|
2107
|
+
const isFullScreen = rect.left >= -1 && rect.right <= viewportWidth + 1 && rect.width >= viewportWidth - 1 && rect.height >= viewportHeight - 1;
|
|
2108
|
+
// Khung nằm hẳn ngoài màn (sidebar đang đóng chờ trượt vào) không phải lớp nổi đang mở: lớp nổi
|
|
2109
|
+
// tràn thật luôn còn một phần trong màn (báo nhầm 27/09/2026, sidebar ở 375px).
|
|
2110
|
+
const isPartlyVisible = rect.right > 0 && rect.left < viewportWidth && rect.bottom > 0 && rect.top < viewportHeight;
|
|
2111
|
+
return rect.width > 0 && rect.height > 0 && !isFullScreen && isPartlyVisible;
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
return layers
|
|
2115
|
+
.map((node) => {
|
|
2116
|
+
const rect = node.getBoundingClientRect();
|
|
2117
|
+
const overflowLeft = Math.max(0, -rect.left);
|
|
2118
|
+
const overflowRight = Math.max(0, rect.right - viewportWidth);
|
|
2119
|
+
// Tràn đáy chỉ tính với khung fixed: khung absolute cuộn theo trang, kéo xuống là thấy.
|
|
2120
|
+
const overflowBottom = getComputedStyle(node).position === "fixed" ? Math.max(0, rect.bottom - viewportHeight) : 0;
|
|
2121
|
+
const worst = Math.max(overflowLeft, overflowRight, overflowBottom);
|
|
2122
|
+
if (worst <= 1) return null;
|
|
2123
|
+
const label = (node.getAttribute("role") || node.tagName.toLowerCase()) + ` "${node.textContent.trim().replace(/\s+/g, " ").slice(0, 30)}"`;
|
|
2124
|
+
|
|
2125
|
+
return `${label} rộng ${Math.round(rect.width)}px, lòi ${Math.round(worst)}px khỏi màn`;
|
|
2126
|
+
})
|
|
2127
|
+
.filter(Boolean);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function colorDistance(first, second) {
|
|
2131
|
+
return Math.max(Math.abs(first.red - second.red), Math.abs(first.green - second.green), Math.abs(first.blue - second.blue));
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
function formatColor(color) {
|
|
2135
|
+
return `#${[color.red, color.green, color.blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// Vị trí các khối đứng sau phần tử (anh em kế tiếp của nó và của bốn cấp cha): rê vào mà mấy khối này
|
|
2139
|
+
// dời đi là hover đang thêm hay nở phần tử, cả hàng card bên dưới nhảy theo.
|
|
2140
|
+
function readFollowerTops(probeId) {
|
|
2141
|
+
const element = document.querySelector(`[data-evon-hover-id="${probeId}"]`);
|
|
2142
|
+
const tops = [];
|
|
2143
|
+
|
|
2144
|
+
for (let node = element, level = 0; node && node !== document.body && level < 5; node = node.parentElement, level++) {
|
|
2145
|
+
let follower = node.nextElementSibling;
|
|
2146
|
+
for (let count = 0; follower && count < 3; follower = follower.nextElementSibling, count++) {
|
|
2147
|
+
const rect = follower.getBoundingClientRect();
|
|
2148
|
+
if (rect.height > 0) tops.push(rect.top);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
return tops;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
async function probeHoverStates(page) {
|
|
2156
|
+
const layoutShifts = [];
|
|
2157
|
+
const vanishedChildren = [];
|
|
2158
|
+
const weakHovers = [];
|
|
2159
|
+
const blendedHovers = [];
|
|
2160
|
+
const borderHovers = [];
|
|
2161
|
+
const overflowingLayers = new Set();
|
|
2162
|
+
|
|
2163
|
+
const probeIds = await page.evaluate((limit) => {
|
|
2164
|
+
const seenSignatures = new Set();
|
|
2165
|
+
const ids = [];
|
|
2166
|
+
// `.group` và `cursor-pointer`: card bấm được dựng bằng div (onClick) vẫn có hover, hay gặp nhất là
|
|
2167
|
+
// `group-hover:` làm hiện thêm dòng trong card. Nhóm này chỉ đo nhảy bố cục, không đo màu rê: checkbox
|
|
2168
|
+
// `cursor-pointer` đậm viền lúc rê là kiểu đã duyệt (báo nhầm 27/09/2026, /dashboard).
|
|
2169
|
+
const colorProbeSelector = "button, a[href], [role='button'], [role='tab'], [role='menuitem'], [role='option'], tbody tr, summary";
|
|
2170
|
+
const candidates = document.querySelectorAll(`${colorProbeSelector}, .group, [class*='cursor-pointer']`);
|
|
2171
|
+
|
|
2172
|
+
for (const element of candidates) {
|
|
2173
|
+
const rect = element.getBoundingClientRect();
|
|
2174
|
+
if (rect.width < 8 || rect.height < 8 || element.closest("[inert], [aria-hidden='true'], [data-demo-state]")) continue;
|
|
2175
|
+
if (getComputedStyle(element).visibility === "hidden") continue;
|
|
2176
|
+
// Nút đang vô hiệu ("Trang trước" ở trang 1) không rê được: không đổi nền là đúng (báo nhầm 30/09/2026).
|
|
2177
|
+
if (element.matches(":disabled, [aria-disabled='true']")) continue;
|
|
2178
|
+
// Gộp theo loại: cùng thẻ + cùng class là cùng một kiểu hover, đo một cái là đủ.
|
|
2179
|
+
const signature = `${element.tagName}|${element.getAttribute("class") || ""}`;
|
|
2180
|
+
if (seenSignatures.has(signature)) continue;
|
|
2181
|
+
seenSignatures.add(signature);
|
|
2182
|
+
element.dataset.evonHoverId = String(ids.length);
|
|
2183
|
+
if (!element.matches(colorProbeSelector)) element.dataset.evonLayoutOnly = "1";
|
|
2184
|
+
ids.push(element.dataset.evonHoverId);
|
|
2185
|
+
if (ids.length >= limit) break;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
return ids;
|
|
2189
|
+
}, maxHoverTargets);
|
|
2190
|
+
|
|
2191
|
+
for (const probeId of probeIds) {
|
|
2192
|
+
const locator = page.locator(`[data-evon-hover-id="${probeId}"]`);
|
|
2193
|
+
await page.mouse.move(1, 1);
|
|
2194
|
+
const isReady = await locator.scrollIntoViewIfNeeded({ timeout: 800 }).then(() => true, () => false);
|
|
2195
|
+
if (!isReady) continue;
|
|
2196
|
+
const before = await page.evaluate(readHoverState, probeId);
|
|
2197
|
+
const followerTopsBefore = await page.evaluate(readFollowerTops, probeId);
|
|
2198
|
+
const isHovered = await locator.hover({ timeout: 800, force: true }).then(() => true, () => false);
|
|
2199
|
+
if (!before || !isHovered) continue;
|
|
2200
|
+
await page.waitForTimeout(60);
|
|
2201
|
+
const after = await page.evaluate(readHoverState, probeId);
|
|
2202
|
+
const followerTopsAfter = await page.evaluate(readFollowerTops, probeId);
|
|
2203
|
+
if (!after) continue;
|
|
2204
|
+
|
|
2205
|
+
for (const layer of await page.evaluate(findOverflowingLayers)) overflowingLayers.add(`rê: ${layer}`);
|
|
2206
|
+
|
|
2207
|
+
const label = await locator.evaluate((element) => {
|
|
2208
|
+
const text = (element.getAttribute("aria-label") || element.textContent || "").trim().replace(/\s+/g, " ").slice(0, 32);
|
|
2209
|
+
|
|
2210
|
+
return `${element.tagName.toLowerCase()} "${text}"`;
|
|
2211
|
+
});
|
|
2212
|
+
const largestShift = followerTopsBefore.length === followerTopsAfter.length
|
|
2213
|
+
? Math.max(0, ...followerTopsBefore.map((top, index) => Math.abs(followerTopsAfter[index] - top)))
|
|
2214
|
+
: 0;
|
|
2215
|
+
if (largestShift > 2) layoutShifts.push(`${label}: rê vào thì khối phía sau dời ${Math.round(largestShift)}px`);
|
|
2216
|
+
const vanishedIndex = before.childFills.findIndex((fill, index) => {
|
|
2217
|
+
const afterFill = after.childFills[index];
|
|
2218
|
+
|
|
2219
|
+
return afterFill && colorDistance(before.color, fill) > 3 && colorDistance(after.color, afterFill) <= 3;
|
|
2220
|
+
});
|
|
2221
|
+
if (vanishedIndex !== -1) {
|
|
2222
|
+
vanishedChildren.push(`${label}: nền rê ${formatColor(after.color)} trùng nền khối con bên trong (ô icon, badge), khối con biến mất lúc rê`);
|
|
2223
|
+
}
|
|
2224
|
+
if (await locator.evaluate((element) => element.dataset.evonLayoutOnly === "1")) continue;
|
|
2225
|
+
|
|
2226
|
+
// Viền xét trước: nút viền đổi màu viền mà nền đứng yên vẫn là ca cần báo.
|
|
2227
|
+
// Viền tan hẳn mà nền đổi là nút lặp trên dòng của I4 (rê vào thì viền trong suốt, nền đỏ nhạt,
|
|
2228
|
+
// rules-state.md): không báo. Đã báo nhầm 27/09/2026, "Đăng xuất" mỗi dòng ở trang bảo mật.
|
|
2229
|
+
const isBorderSwappedForFill = after.isBorderTransparent && colorDistance(before.color, after.color) > 8;
|
|
2230
|
+
if (before.borderColor && after.borderColor && !isBorderSwappedForFill && colorDistance(before.borderColor, after.borderColor) > 8) {
|
|
2231
|
+
borderHovers.push(`${label}: viền ${formatColor(before.borderColor)} → ${formatColor(after.borderColor)}`);
|
|
2232
|
+
}
|
|
2233
|
+
const change = colorDistance(before.color, after.color);
|
|
2234
|
+
if (change === 0 && before.declaresHoverFill && !before.isSelected) {
|
|
2235
|
+
blendedHovers.push(`${label}: khai nền rê mà nền rê trùng nền phía sau ${formatColor(after.color)}, rê không thấy gì`);
|
|
2236
|
+
}
|
|
2237
|
+
if (change === 0) continue;
|
|
2238
|
+
|
|
2239
|
+
// Dòng bảng và dòng tràn mép khung được nền rê nhạt (#f8f8fa trên card trắng, bảng trong `I10`) vì phủ
|
|
2240
|
+
// cả một dải dài. Nút, mục menu, dòng thụt vào thì chênh dưới 8 mức là rê gần như không thấy.
|
|
2241
|
+
const isButtonSized = before.width < 320 && before.height < 80;
|
|
2242
|
+
const isFullBleedRow = before.isTableRow || before.touchesCardEdge;
|
|
2243
|
+
if (change < 8 && (isButtonSized || !isFullBleedRow)) weakHovers.push(`${label}: ${formatColor(before.color)} → ${formatColor(after.color)} (chênh ${change} mức)`);
|
|
2244
|
+
if (after.touchesCardEdge && colorDistance(after.color, after.outsideColor) <= 3) {
|
|
2245
|
+
blendedHovers.push(`${label}: nền rê ${formatColor(after.color)} gần như bằng nền ngoài khung ${formatColor(after.outsideColor)}, card như bị khuyết`);
|
|
2246
|
+
}
|
|
2247
|
+
// Nút nổi khỏi nền phía sau lúc đứng yên (nút viền trắng trên nền trang xám) mà rê vào lại về gần đúng
|
|
2248
|
+
// màu nền đó: nút như tan vào trang. Đã dính 30/09/2026, nút ‹ Hôm nay › #f1f1f3 trên nền trang #f4f4f6
|
|
2249
|
+
// (chênh 3): phép "rê gần như không thấy" không bắt vì trắng → #f1f1f3 vẫn đổi 14 mức. So với nền phía
|
|
2250
|
+
// sau LÚC ĐANG RÊ: nút trong dòng thì rê vào dòng cũng chìm về #f4f4f6 (nút "Tiếp đón" cùng màn).
|
|
2251
|
+
if (colorDistance(before.color, after.behindColor) > 8 && colorDistance(after.color, after.behindColor) <= 4) {
|
|
2252
|
+
blendedHovers.push(`${label}: nền rê ${formatColor(after.color)} gần bằng nền ngay sau nút ${formatColor(after.behindColor)}, nút tan vào nền`);
|
|
2253
|
+
}
|
|
2254
|
+
if (before.borderColor && colorDistance(after.color, before.borderColor) <= 3) {
|
|
2255
|
+
blendedHovers.push(`${label}: nền rê ${formatColor(after.color)} trùng màu viền ${formatColor(before.borderColor)}, nút thành mảng không viền`);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
await page.mouse.move(1, 1);
|
|
2259
|
+
|
|
2260
|
+
return { layoutShifts, vanishedChildren, weakHovers, blendedHovers, borderHovers, overflowingLayers: [...overflowingLayers] };
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// Mở từng nút có popup (menu, listbox, lịch) và, ở màn chạm, chạm vào chữ bị cắt (nơi hay gắn
|
|
2264
|
+
// tooltip tên đầy đủ), xem lớp nổi vừa hiện có nằm trong màn không.
|
|
2265
|
+
// Lớp nổi mà nội dung bên trong hẹp hơn khung: khung rộng bằng nút mở, nội dung bị chặn `max-w` nên bên
|
|
2266
|
+
// phải còn một dải trống, thanh cuộn nằm lọt giữa (đã dính 27/09/2026, Select dựng lại ở dự án mồi).
|
|
2267
|
+
function findHollowLayers() {
|
|
2268
|
+
const hollowLayers = [];
|
|
2269
|
+
|
|
2270
|
+
for (const node of document.querySelectorAll("body *")) {
|
|
2271
|
+
const style = getComputedStyle(node);
|
|
2272
|
+
const role = node.getAttribute("role") || "";
|
|
2273
|
+
const isPositioned = style.position === "fixed" || style.position === "absolute";
|
|
2274
|
+
const isLayer = ["listbox", "menu", "dialog"].includes(role) || (parseInt(style.zIndex, 10) || 0) >= 20;
|
|
2275
|
+
if (!isPositioned || !isLayer || style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) continue;
|
|
2276
|
+
const rect = node.getBoundingClientRect();
|
|
2277
|
+
if (rect.width < 120 || rect.width > 640 || rect.height < 60) continue;
|
|
2278
|
+
|
|
2279
|
+
const children = [...node.children].filter((child) => child.getBoundingClientRect().width > 0);
|
|
2280
|
+
if (children.length === 0) continue;
|
|
2281
|
+
const innerRight = rect.right - parseFloat(style.paddingRight) - parseFloat(style.borderRightWidth);
|
|
2282
|
+
const contentRight = Math.max(...children.map((child) => child.getBoundingClientRect().right));
|
|
2283
|
+
const emptyWidth = innerRight - contentRight;
|
|
2284
|
+
if (emptyWidth > 24) {
|
|
2285
|
+
const label = `${role || node.tagName.toLowerCase()} "${node.textContent.trim().replace(/\s+/g, " ").slice(0, 30)}"`;
|
|
2286
|
+
hollowLayers.push(`${label} rộng ${Math.round(rect.width)}px mà nội dung chừa trống ${Math.round(emptyWidth)}px bên phải`);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
return hollowLayers;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
// Khung và vạch chia của lớp nổi đang mở đậm hơn token `--border` của dự án: menu dựng bằng `border-strong`
|
|
2294
|
+
// cho cả khung lẫn vạch thì 18h (so vạch với khung) không thấy gì (đã dính 28/09/2026, menu tài khoản:
|
|
2295
|
+
// khung và hai vạch cùng #eaeaea, mẫu của skill là `border-border` #f7f7f8). Không có `--border` thì bỏ qua.
|
|
2296
|
+
function findHeavyLayerLines() {
|
|
2297
|
+
const canvas = document.createElement("canvas");
|
|
2298
|
+
canvas.width = 1;
|
|
2299
|
+
canvas.height = 1;
|
|
2300
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
2301
|
+
const toLuminance = (cssColor) => {
|
|
2302
|
+
context.clearRect(0, 0, 1, 1);
|
|
2303
|
+
context.fillStyle = "rgba(0,0,0,0)";
|
|
2304
|
+
context.fillStyle = cssColor;
|
|
2305
|
+
context.fillRect(0, 0, 1, 1);
|
|
2306
|
+
const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
|
|
2307
|
+
if (alpha === 0) return null;
|
|
2308
|
+
const opacity = alpha / 255;
|
|
2309
|
+
|
|
2310
|
+
// Viền nửa trong suốt vẽ lên nền trắng của khung.
|
|
2311
|
+
return (0.2126 * (red * opacity + 255 * (1 - opacity)) + 0.7152 * (green * opacity + 255 * (1 - opacity)) + 0.0722 * (blue * opacity + 255 * (1 - opacity))) / 255;
|
|
2312
|
+
};
|
|
2313
|
+
// Tương phản của đường kẻ trên nền trắng. Viền trang trí của skill ~1.07:1; token viền của dự án cỡ
|
|
2314
|
+
// `#e2e8f0` (~1.23:1) cũng là đậm (M14), nên so thêm ngưỡng tuyệt đối, không chỉ so với token.
|
|
2315
|
+
const toContrastOnWhite = (cssColor) => {
|
|
2316
|
+
context.clearRect(0, 0, 1, 1);
|
|
2317
|
+
context.fillStyle = "rgba(0,0,0,0)";
|
|
2318
|
+
context.fillStyle = cssColor;
|
|
2319
|
+
context.fillRect(0, 0, 1, 1);
|
|
2320
|
+
const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
|
|
2321
|
+
if (alpha === 0) return null;
|
|
2322
|
+
const opacity = alpha / 255;
|
|
2323
|
+
const toLinear = (channel) => {
|
|
2324
|
+
const value = (channel * opacity + 255 * (1 - opacity)) / 255;
|
|
2325
|
+
|
|
2326
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
2327
|
+
};
|
|
2328
|
+
const luminance = 0.2126 * toLinear(red) + 0.7152 * toLinear(green) + 0.0722 * toLinear(blue);
|
|
2329
|
+
|
|
2330
|
+
return 1.05 / (luminance + 0.05);
|
|
2331
|
+
};
|
|
2332
|
+
const rootStyle = getComputedStyle(document.documentElement);
|
|
2333
|
+
const tokenColor = rootStyle.getPropertyValue("--border").trim() || rootStyle.getPropertyValue("--color-border").trim();
|
|
2334
|
+
const tokenLuminance = tokenColor ? toLuminance(tokenColor) : null;
|
|
2335
|
+
|
|
2336
|
+
const describeLayer = (element) => `${element.tagName.toLowerCase()}${element.getAttribute("role") ? `[role=${element.getAttribute("role")}]` : ""} "${(element.textContent || "").trim().replace(/\s+/g, " ").slice(0, 30)}"`;
|
|
2337
|
+
const findings = [];
|
|
2338
|
+
for (const layer of document.querySelectorAll("[role='menu'], [role='listbox'], [data-radix-popper-content-wrapper] > *, [popover]")) {
|
|
2339
|
+
const rect = layer.getBoundingClientRect();
|
|
2340
|
+
if (rect.width === 0 || getComputedStyle(layer).visibility === "hidden") continue;
|
|
2341
|
+
const lines = [];
|
|
2342
|
+
const layerStyle = getComputedStyle(layer);
|
|
2343
|
+
if (parseFloat(layerStyle.borderTopWidth) > 0) lines.push({ what: "viền khung", color: layerStyle.borderTopColor });
|
|
2344
|
+
const separator = layer.querySelector("hr, [role='separator']");
|
|
2345
|
+
if (separator) {
|
|
2346
|
+
const separatorStyle = getComputedStyle(separator);
|
|
2347
|
+
lines.push({ what: "vạch chia", color: parseFloat(separatorStyle.borderTopWidth) > 0 ? separatorStyle.borderTopColor : separatorStyle.backgroundColor });
|
|
2348
|
+
}
|
|
2349
|
+
const heavy = lines.filter((line) => {
|
|
2350
|
+
const luminance = toLuminance(line.color);
|
|
2351
|
+
const contrast = toContrastOnWhite(line.color);
|
|
2352
|
+
const isDarkerThanToken = luminance !== null && tokenLuminance !== null && tokenLuminance - luminance > 0.02;
|
|
2353
|
+
|
|
2354
|
+
return isDarkerThanToken || (contrast !== null && contrast >= 1.15);
|
|
2355
|
+
});
|
|
2356
|
+
if (heavy.length > 0) findings.push(`${heavy.map((line) => `${line.what} ${line.color} (~${toContrastOnWhite(line.color).toFixed(2)}:1 trên trắng)`).join(", ")}, viền trang trí nên ~1.1:1${tokenColor ? `, token --border ${tokenColor}` : ""}: ${describeLayer(layer)}`);
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
return findings;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// Control gốc của trình duyệt nằm trong lớp nổi: select, ô ngày / giờ, checkbox, radio, thanh trượt, ô chọn
|
|
2363
|
+
// tệp, ô chọn màu. Phép đo ở trang chỉ thấy thứ đang hiện, nên lớp nổi phải đo riêng (đã dính 29/09/2026,
|
|
2364
|
+
// checkbox gốc trong menu "Khu khác"; 30/09/2026, select và ô ngày gốc trong dialog "Tạo phiếu" của wireframe
|
|
2365
|
+
// kho hàng). Hai cách gọi:
|
|
2366
|
+
// - "hidden": lớp đang ẩn mà vẫn nằm trong DOM (`<dialog>` đóng, popover, `[hidden]`, role dialog / menu /
|
|
2367
|
+
// listbox bị ẩn). Style vẫn đọc được dù đang `display: none`.
|
|
2368
|
+
// - "opened": lớp vừa mở sau cú bấm (modal gắn vào DOM lúc mở). Lớp đã hiện trước khi bấm mang
|
|
2369
|
+
// `data-evon-seen-layer` hoặc `data-evon-before`, không tính.
|
|
2370
|
+
function findNativeControls(mode) {
|
|
2371
|
+
const layerSelector = "dialog, [popover], [role='dialog'], [role='menu'], [role='listbox'], [data-radix-popper-content-wrapper], [hidden]";
|
|
2372
|
+
const describeLayer = (layer) => `${layer.tagName.toLowerCase()}${layer.getAttribute("role") ? `[role=${layer.getAttribute("role")}]` : ""} "${(layer.textContent || "").trim().replace(/\s+/g, " ").slice(0, 30)}"`;
|
|
2373
|
+
const isShown = (element) => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden";
|
|
2374
|
+
|
|
2375
|
+
function kindOf(control) {
|
|
2376
|
+
const style = getComputedStyle(control);
|
|
2377
|
+
// Input tự vẽ đè lên (sr-only, trong suốt, pointer-events-none) thì không phải control gốc đang lộ.
|
|
2378
|
+
if (Number(style.opacity) <= 0.1 || parseFloat(style.width) <= 2 || style.clip === "rect(0px, 0px, 0px, 0px)") return null;
|
|
2379
|
+
if (control.tagName === "SELECT") return ["auto", "menulist"].includes(style.appearance) ? "select gốc chưa tô" : `select gốc đã tô (${control.options.length} mục)`;
|
|
2380
|
+
if (["date", "time", "datetime-local", "month", "week"].includes(control.type)) return `ô ${control.type} gốc`;
|
|
2381
|
+
if (["checkbox", "radio"].includes(control.type)) return style.appearance === "none" ? null : `${control.type} gốc${style.accentColor !== "auto" ? " (chỉ tô accent-color)" : ""}`;
|
|
2382
|
+
if (control.type === "range") return style.appearance === "none" || style.pointerEvents === "none" ? null : "thanh trượt gốc";
|
|
2383
|
+
if (control.type === "file") return "ô chọn tệp gốc";
|
|
2384
|
+
if (control.type === "color") return "ô chọn màu gốc";
|
|
2385
|
+
|
|
2386
|
+
return null;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
const found = new Map();
|
|
2390
|
+
for (const control of document.querySelectorAll("select, input")) {
|
|
2391
|
+
const layer = control.closest(layerSelector);
|
|
2392
|
+
if (!layer) continue;
|
|
2393
|
+
const outerLayer = [...document.querySelectorAll(layerSelector)].find((candidate) => candidate.contains(layer) && !candidate.parentElement?.closest(layerSelector)) || layer;
|
|
2394
|
+
if (mode === "hidden" && isShown(control)) continue;
|
|
2395
|
+
if (mode === "opened" && (!isShown(control) || outerLayer.dataset.evonSeenLayer || outerLayer.dataset.evonBefore)) continue;
|
|
2396
|
+
const kind = kindOf(control);
|
|
2397
|
+
if (!kind) continue;
|
|
2398
|
+
const key = describeLayer(outerLayer);
|
|
2399
|
+
found.set(key, [...new Set([...(found.get(key) || []), kind])]);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
return [...found].slice(0, 6).map(([layer, kinds]) => `${kinds.join(", ")} trong ${layer}${mode === "hidden" ? " (đang ẩn)" : ""}`);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// Lớp nổi vừa mở: có chuyển động mở không (overlay.md, "Chuyển động"). Menu bật tắt bằng `{isOpen && …}`
|
|
2406
|
+
// hay `display` hiện ra tức thì. Control gốc bên trong thì `findNativeControls` đo.
|
|
2407
|
+
function findPopupDetails(freezeCss) {
|
|
2408
|
+
// Probe tắt mọi transition để chụp ổn định (freezeMotionCss); lớp nổi gắn vào DOM sau lúc ghi
|
|
2409
|
+
// `data-evon-transition`, nên tạm tắt khối đóng băng để đọc style thật, đọc xong bật lại.
|
|
2410
|
+
const freezeTags = [...document.querySelectorAll("style")].filter((tag) => tag.textContent === freezeCss);
|
|
2411
|
+
for (const tag of freezeTags) tag.media = "not all";
|
|
2412
|
+
const describeLayer = (element) => `${element.tagName.toLowerCase()}${element.getAttribute("role") ? `[role=${element.getAttribute("role")}]` : ""} "${(element.textContent || "").trim().replace(/\s+/g, " ").slice(0, 30)}"`;
|
|
2413
|
+
const motionProperties = ["all", "opacity", "transform", "scale", "translate"];
|
|
2414
|
+
const motionless = [];
|
|
2415
|
+
const scrollyLayers = [];
|
|
2416
|
+
for (const layer of document.querySelectorAll("[role='menu'], [role='listbox'], [role='dialog'], [data-radix-popper-content-wrapper] > *, [popover]")) {
|
|
2417
|
+
const rect = layer.getBoundingClientRect();
|
|
2418
|
+
// Lớp đã hiện sẵn trước khi bấm (listbox nằm trong trang, không phải lớp nổi) không tính.
|
|
2419
|
+
if (rect.width === 0 || layer.dataset.evonSeenLayer || getComputedStyle(layer).visibility === "hidden") continue;
|
|
2420
|
+
// Lớp nằm trong một lớp khác (listbox trong khung popover của Select) thì khung ngoài mới là lớp nổi.
|
|
2421
|
+
if (layer.parentElement?.closest("[role='menu'], [role='listbox'], [role='dialog'], [popover]")) continue;
|
|
2422
|
+
// Chuyển động có thể nằm ở khung bọc ngoài: đi ngược lên tới khung nổi (fixed / absolute) gần nhất.
|
|
2423
|
+
let hasMotion = false;
|
|
2424
|
+
for (let node = layer, depth = 0; node && node !== document.body && depth < 8; node = node.parentElement, depth += 1) {
|
|
2425
|
+
const style = getComputedStyle(node);
|
|
2426
|
+
const durations = style.transitionDuration.split(",").map((duration) => parseFloat(duration));
|
|
2427
|
+
const hasTransition = style.transitionProperty.split(",").some((property, index) => motionProperties.includes(property.trim()) && (durations[index] ?? durations[0]) > 0);
|
|
2428
|
+
if (style.animationName !== "none" || hasTransition) {
|
|
2429
|
+
hasMotion = true;
|
|
2430
|
+
break;
|
|
2431
|
+
}
|
|
2432
|
+
if (["fixed", "absolute"].includes(style.position)) break;
|
|
2433
|
+
}
|
|
2434
|
+
if (!hasMotion) motionless.push(describeLayer(layer));
|
|
2435
|
+
// Thanh cuộn thừa: lớp nổi cuộn ngang, hay cuộn dọc mà chỉ hụt dưới một hàng (khung gõ tay nhỏ hơn
|
|
2436
|
+
// nội dung; `[popover]` gốc có sẵn `overflow: auto`). Listbox dài hụt nhiều hàng thì cuộn là đúng.
|
|
2437
|
+
for (const box of [layer, ...layer.querySelectorAll("*")]) {
|
|
2438
|
+
const style = getComputedStyle(box);
|
|
2439
|
+
const overflowX = box.scrollWidth - box.clientWidth;
|
|
2440
|
+
const overflowY = box.scrollHeight - box.clientHeight;
|
|
2441
|
+
const isScrollableX = ["auto", "scroll"].includes(style.overflowX) && overflowX > 1;
|
|
2442
|
+
const isScrollableY = ["auto", "scroll"].includes(style.overflowY) && overflowY > 1 && overflowY < 40;
|
|
2443
|
+
if (!isScrollableX && !isScrollableY) continue;
|
|
2444
|
+
scrollyLayers.push(`${describeLayer(layer)}: ${isScrollableX ? `cuộn ngang hụt ${overflowX}px` : ""}${isScrollableX && isScrollableY ? ", " : ""}${isScrollableY ? `cuộn dọc hụt ${overflowY}px` : ""}`);
|
|
2445
|
+
break;
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
for (const tag of freezeTags) tag.media = "all";
|
|
2449
|
+
|
|
2450
|
+
return { motionless, scrollyLayers };
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
async function probePopupLayers(page, isMobile, isDark = false) {
|
|
2454
|
+
const overflowingLayers = new Set();
|
|
2455
|
+
const sunkenSelections = new Set();
|
|
2456
|
+
const hollowLayers = new Set();
|
|
2457
|
+
const checkedHoverChanges = new Set();
|
|
2458
|
+
const heavyLayerLines = new Set();
|
|
2459
|
+
const motionlessLayers = new Set();
|
|
2460
|
+
const nativeChoices = new Set();
|
|
2461
|
+
const scrollyLayers = new Set();
|
|
2462
|
+
const lostTriggerIcons = new Set();
|
|
2463
|
+
const hollowBefore = new Set(await page.evaluate(findHollowLayers));
|
|
2464
|
+
const triggerIds = await page.evaluate(({ popupLimit, tapLimit, isTouch }) => {
|
|
2465
|
+
const ids = [];
|
|
2466
|
+
const popupTriggers = [...document.querySelectorAll("[aria-haspopup]:not([aria-haspopup='false'])")].filter((element) => element.getBoundingClientRect().width > 0).slice(0, popupLimit);
|
|
2467
|
+
for (const element of popupTriggers) {
|
|
2468
|
+
element.dataset.evonPopupId = `popup-${ids.length}`;
|
|
2469
|
+
ids.push(element.dataset.evonPopupId);
|
|
2470
|
+
}
|
|
2471
|
+
if (isTouch) {
|
|
2472
|
+
const truncated = [...document.querySelectorAll("body *")]
|
|
2473
|
+
.filter((element) => element.children.length === 0 && element.scrollWidth > element.clientWidth + 1 && getComputedStyle(element).textOverflow === "ellipsis" && !element.closest("a[href]"))
|
|
2474
|
+
.slice(0, tapLimit);
|
|
2475
|
+
for (const element of truncated) {
|
|
2476
|
+
element.dataset.evonPopupId = `tap-${ids.length}`;
|
|
2477
|
+
ids.push(element.dataset.evonPopupId);
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
return ids;
|
|
2482
|
+
}, { popupLimit: maxPopupTriggers, tapLimit: maxTruncatedTaps, isTouch: isMobile });
|
|
2483
|
+
// Nút mở nằm trong `<dialog>` đang đóng (select, ô ngày của form tạo mới) thì mở dialog rồi thử luôn:
|
|
2484
|
+
// lịch có thanh cuộn, chọn xong mất icon chỉ lộ ở đó (đã dính 30/09/2026, wireframe kho hàng).
|
|
2485
|
+
const dialogTriggerIds = await page.evaluate((limit) => {
|
|
2486
|
+
const ids = [];
|
|
2487
|
+
[...document.querySelectorAll("dialog:not([open])")].slice(0, 2).forEach((dialog, dialogIndex) => {
|
|
2488
|
+
for (const element of [...dialog.querySelectorAll("[aria-haspopup]:not([aria-haspopup='false'])")].slice(0, limit)) {
|
|
2489
|
+
element.dataset.evonPopupId = `dialog${dialogIndex}-${ids.length}`;
|
|
2490
|
+
ids.push(element.dataset.evonPopupId);
|
|
2491
|
+
}
|
|
2492
|
+
});
|
|
2493
|
+
|
|
2494
|
+
return ids;
|
|
2495
|
+
}, maxPopupTriggers);
|
|
2496
|
+
|
|
2497
|
+
for (const triggerId of [...triggerIds, ...dialogTriggerIds]) {
|
|
2498
|
+
const locator = page.locator(`[data-evon-popup-id="${triggerId}"]`);
|
|
2499
|
+
const isTap = triggerId.startsWith("tap-");
|
|
2500
|
+
if (triggerId.startsWith("dialog")) {
|
|
2501
|
+
await page.evaluate((id) => {
|
|
2502
|
+
const dialog = document.querySelector(`[data-evon-popup-id="${id}"]`)?.closest("dialog");
|
|
2503
|
+
if (!dialog || dialog.open) return;
|
|
2504
|
+
dialog.showModal();
|
|
2505
|
+
dialog.dataset.evonSeenLayer = "1";
|
|
2506
|
+
dialog.dataset.evonProbeOpened = "1";
|
|
2507
|
+
}, triggerId);
|
|
2508
|
+
await page.waitForTimeout(200);
|
|
2509
|
+
}
|
|
2510
|
+
await page.evaluate(() => {
|
|
2511
|
+
for (const layer of document.querySelectorAll("[role='menu'], [role='listbox'], [role='dialog'], [popover]")) {
|
|
2512
|
+
if (layer.getBoundingClientRect().width > 0 && Number(getComputedStyle(layer).opacity) > 0.5) layer.dataset.evonSeenLayer = "1";
|
|
2513
|
+
}
|
|
2514
|
+
});
|
|
2515
|
+
const isDone = await (isTap ? locator.tap({ timeout: 800, force: true }) : locator.click({ timeout: 800, force: true })).then(() => true, () => false);
|
|
2516
|
+
if (!isDone) continue;
|
|
2517
|
+
await page.waitForTimeout(250);
|
|
2518
|
+
for (const layer of await page.evaluate(findOverflowingLayers)) overflowingLayers.add(`${isTap ? "chạm chữ bị cắt" : "mở"}: ${layer}`);
|
|
2519
|
+
for (const layer of await page.evaluate(findHollowLayers)) if (!hollowBefore.has(layer)) hollowLayers.add(layer);
|
|
2520
|
+
// Ô chọn trong lớp nổi (bộ lọc dạng popover) chỉ hiện lúc mở, nên đo rê vào ô đã chọn ở đây nữa.
|
|
2521
|
+
if (!isTap && !isMobile) for (const change of await findCheckedHoverChanges(page)) checkedHoverChanges.add(change);
|
|
2522
|
+
if (!isTap) for (const line of await page.evaluate(findHeavyLayerLines)) heavyLayerLines.add(line);
|
|
2523
|
+
// Lớp nổi vừa mở thường tô sẵn mục đầu (đang trỏ): đo mục đó có khoét lỗ ở nền tối không.
|
|
2524
|
+
if (!isTap && isDark) for (const line of await page.evaluate(findSunkenSelections)) sunkenSelections.add(line);
|
|
2525
|
+
if (!isTap) {
|
|
2526
|
+
const details = await page.evaluate(findPopupDetails, freezeMotionCss);
|
|
2527
|
+
for (const layer of details.motionless) motionlessLayers.add(layer);
|
|
2528
|
+
for (const control of await page.evaluate(findNativeControls, "opened")) nativeChoices.add(control);
|
|
2529
|
+
for (const layer of details.scrollyLayers) scrollyLayers.add(layer);
|
|
2530
|
+
}
|
|
2531
|
+
// Ô chọn (select, ô ngày): chọn thử một mục hay một ngày rồi xem nút mở còn icon không. Vẽ lại nút
|
|
2532
|
+
// sau khi chọn mà quên vẽ icon (wireframe ghi lại `innerHTML` mà không gọi `lucide.createIcons()`) thì
|
|
2533
|
+
// chevron / icon lịch mất tới lần mở sau (đã dính 30/09/2026, wireframe kho hàng, cả select lẫn ô ngày).
|
|
2534
|
+
if (!isTap) {
|
|
2535
|
+
const pickedTrigger = await page.evaluate((id) => {
|
|
2536
|
+
const trigger = document.querySelector(`[data-evon-popup-id="${id}"]`);
|
|
2537
|
+
const isPicker = trigger?.getAttribute("role") === "combobox" || ["listbox", "dialog", "grid"].includes(trigger?.getAttribute("aria-haspopup"));
|
|
2538
|
+
if (!isPicker) return null;
|
|
2539
|
+
const isInOpenedLayer = (element) => {
|
|
2540
|
+
const layer = element.closest("[role='listbox'], [role='dialog'], [role='grid'], [popover], [data-radix-popper-content-wrapper]");
|
|
2541
|
+
|
|
2542
|
+
return Boolean(layer) && !layer.dataset.evonSeenLayer && element.getClientRects().length > 0;
|
|
2543
|
+
};
|
|
2544
|
+
const options = [...document.querySelectorAll("[role='option']:not([aria-selected='true']):not([aria-disabled='true'])")].filter(isInOpenedLayer);
|
|
2545
|
+
// Ô ngày: nút chỉ có một con số 1–31, chưa chọn, không thuộc tháng khác.
|
|
2546
|
+
const days = [...document.querySelectorAll("button:not([disabled])")]
|
|
2547
|
+
.filter((button) => isInOpenedLayer(button) && /^\d{1,2}$/.test((button.textContent || "").trim()) && button.getAttribute("aria-selected") !== "true" && !button.closest("[aria-selected='true']") && !/outside/.test(`${button.className} ${button.parentElement?.className || ""}`));
|
|
2548
|
+
const pick = options[0] || days[days.length > 15 ? 15 : 0];
|
|
2549
|
+
if (!pick) return null;
|
|
2550
|
+
pick.click();
|
|
2551
|
+
|
|
2552
|
+
// Ô trống `—` của bảng có icon `calendar-plus` + "Đặt hạn" chỉ cho lúc trống (`layouts/app.md`, "Ô trống
|
|
2553
|
+
// một kiểu"): có ngày rồi thì hết icon là đúng mẫu (báo nhầm 01/10/2026, cột hạn chót bảng công việc).
|
|
2554
|
+
const isEmptyCellTrigger = (trigger.textContent || "").trim().startsWith("—");
|
|
2555
|
+
|
|
2556
|
+
return { label: `${trigger.tagName.toLowerCase()} "${(trigger.textContent || "").trim().slice(0, 30)}"`, iconCount: isEmptyCellTrigger ? 0 : trigger.querySelectorAll("svg").length };
|
|
2557
|
+
}, triggerId);
|
|
2558
|
+
if (pickedTrigger) {
|
|
2559
|
+
await page.waitForTimeout(250);
|
|
2560
|
+
// Mất icon: trước có svg mà giờ không còn, hay còn `<i data-lucide>` chưa vẽ (dấu của wireframe
|
|
2561
|
+
// ghi lại innerHTML mà quên `createIcons()`).
|
|
2562
|
+
const after = await locator.evaluate((trigger) => ({ iconCount: trigger.querySelectorAll("svg").length, placeholderCount: trigger.querySelectorAll("i[data-lucide]").length })).catch(() => ({ iconCount: 1, placeholderCount: 0 }));
|
|
2563
|
+
if (after.placeholderCount > 0 || (pickedTrigger.iconCount > 0 && after.iconCount === 0)) lostTriggerIcons.add(`chọn xong ở ${pickedTrigger.label} thì nút mở mất icon (chevron, lịch) tới lần mở sau${after.placeholderCount > 0 ? ", còn thẻ <i data-lucide> chưa vẽ" : ""}`);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
await page.keyboard.press("Escape");
|
|
2567
|
+
await page.waitForTimeout(120);
|
|
2568
|
+
}
|
|
2569
|
+
if (dialogTriggerIds.length > 0) {
|
|
2570
|
+
await page.evaluate(() => {
|
|
2571
|
+
for (const dialog of document.querySelectorAll("dialog[data-evon-probe-opened]")) if (dialog.open) dialog.close();
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
return {
|
|
2576
|
+
overflowing: [...overflowingLayers],
|
|
2577
|
+
hollow: [...hollowLayers],
|
|
2578
|
+
checkedHoverChanges: [...checkedHoverChanges],
|
|
2579
|
+
heavyLayerLines: [...heavyLayerLines],
|
|
2580
|
+
motionless: [...motionlessLayers],
|
|
2581
|
+
nativeChoices: [...nativeChoices],
|
|
2582
|
+
scrollyLayers: [...scrollyLayers],
|
|
2583
|
+
lostTriggerIcons: [...lostTriggerIcons],
|
|
2584
|
+
sunkenSelections: [...sunkenSelections],
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
// Mở các khối đang đóng (accordion, mục thu gọn) để đo lại phần bên trong. Bỏ nút có popup (menu,
|
|
2589
|
+
// lịch) và nút mở sidebar / menu ở màn hẹp: mở ra là che cả trang (đã dính 26/09/2026: lỗi khe
|
|
2590
|
+
// quanh nút "…" của đường dẫn nằm trong mục accordion đóng sẵn ở /components).
|
|
2591
|
+
async function expandCollapsedBlocks(page) {
|
|
2592
|
+
const count = await page.evaluate(() => {
|
|
2593
|
+
let expandedCount = 0;
|
|
2594
|
+
for (const button of document.querySelectorAll("[aria-expanded='false'][aria-controls]:not([aria-haspopup])")) {
|
|
2595
|
+
const label = `${button.getAttribute("aria-label") || ""} ${button.textContent || ""}`.toLowerCase();
|
|
2596
|
+
if (/sidebar|menu|điều hướng|thanh bên/.test(label) || button.getBoundingClientRect().width === 0) continue;
|
|
2597
|
+
button.click();
|
|
2598
|
+
expandedCount += 1;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
return expandedCount;
|
|
2602
|
+
});
|
|
2603
|
+
if (count > 0) await page.waitForTimeout(300);
|
|
2604
|
+
|
|
2605
|
+
return count;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// ---------- Hình của các trạng thái trên cùng một phần tử ----------
|
|
2609
|
+
// Lịch gọn 26/09/2026 (lượt hai): rê ra nền ô vuông 46×48 cạnh vòng chọn tròn 32px, Tab tới thì vòng
|
|
2610
|
+
// focus vuông quanh vòng tròn, bấm chuột xong ô vừa chọn giữ nền vuông chồng lên vòng đen: ba hình
|
|
2611
|
+
// cho một ô ngày. Mỗi nhóm có mục đang chọn: lấy một mục chưa chọn cùng loại, rê, Tab tới, bấm rồi
|
|
2612
|
+
// để chuột đứng yên, so hình vẽ ra với hình của mục đang chọn.
|
|
2613
|
+
|
|
2614
|
+
const maxStateGroups = 15;
|
|
2615
|
+
|
|
2616
|
+
// Những gì phần tử và con cháu (hai tầng) đang vẽ: nền và vòng (outline / box-shadow), kèm kích
|
|
2617
|
+
// thước, có tròn không, và đường dẫn con (`0` là chính nó) để so trước với sau.
|
|
2618
|
+
function readStatePaints(probeId) {
|
|
2619
|
+
const element = document.querySelector(`[data-evon-state-id="${probeId}"]`);
|
|
2620
|
+
if (!element) return null;
|
|
2621
|
+
|
|
2622
|
+
const canvas = document.createElement("canvas");
|
|
2623
|
+
canvas.width = 1;
|
|
2624
|
+
canvas.height = 1;
|
|
2625
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
2626
|
+
const readAlpha = (cssColor) => {
|
|
2627
|
+
context.clearRect(0, 0, 1, 1);
|
|
2628
|
+
context.fillStyle = "rgba(0,0,0,0)";
|
|
2629
|
+
context.fillStyle = cssColor;
|
|
2630
|
+
context.fillRect(0, 0, 1, 1);
|
|
2631
|
+
|
|
2632
|
+
return context.getImageData(0, 0, 1, 1).data[3] / 255;
|
|
2633
|
+
};
|
|
2634
|
+
const paints = [];
|
|
2635
|
+
const hostRect = element.getBoundingClientRect();
|
|
2636
|
+
const visit = (node, path, depth) => {
|
|
2637
|
+
const style = getComputedStyle(node);
|
|
2638
|
+
const rect = node.getBoundingClientRect();
|
|
2639
|
+
if (rect.width >= 6 && rect.height >= 6) {
|
|
2640
|
+
const radiusText = style.borderTopLeftRadius;
|
|
2641
|
+
const radius = radiusText.endsWith("%") ? (parseFloat(radiusText) / 100) * rect.width : parseFloat(radiusText) || 0;
|
|
2642
|
+
const shape = {
|
|
2643
|
+
path,
|
|
2644
|
+
width: Math.round(rect.width),
|
|
2645
|
+
height: Math.round(rect.height),
|
|
2646
|
+
// Phần nền phủ trên phần tử: hai tab chữ dài ngắn khác nhau thì rộng khác nhau nhưng cùng phủ
|
|
2647
|
+
// kín, không phải khác hình (báo nhầm khi thử, hàng tab Tuần / Tháng).
|
|
2648
|
+
coverWidth: rect.width / hostRect.width,
|
|
2649
|
+
coverHeight: rect.height / hostRect.height,
|
|
2650
|
+
isRound: radius >= Math.min(rect.width, rect.height) / 2 - 1,
|
|
2651
|
+
};
|
|
2652
|
+
if (readAlpha(style.backgroundColor) > 0.05) paints.push({ kind: "bg", ...shape, paint: style.backgroundColor });
|
|
2653
|
+
const hasOutline = style.outlineStyle !== "none" && parseFloat(style.outlineWidth) > 0;
|
|
2654
|
+
if (hasOutline || style.boxShadow !== "none") paints.push({ kind: "ring", ...shape, paint: hasOutline ? `outline ${style.outlineWidth} ${style.outlineColor}` : style.boxShadow });
|
|
2655
|
+
}
|
|
2656
|
+
if (depth < 2) [...node.children].forEach((child, index) => visit(child, `${path}.${index}`, depth + 1));
|
|
2657
|
+
};
|
|
2658
|
+
visit(element, "0", 0);
|
|
2659
|
+
|
|
2660
|
+
return paints;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
function findNewPaints(after, before, kind) {
|
|
2664
|
+
const seen = new Set(before.filter((paint) => paint.kind === kind).map((paint) => `${paint.path}|${paint.paint}`));
|
|
2665
|
+
|
|
2666
|
+
return after.filter((paint) => paint.kind === kind && !seen.has(`${paint.path}|${paint.paint}`));
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
function pickLargestPaint(paints) {
|
|
2670
|
+
return paints.reduce((largest, paint) => (!largest || paint.width * paint.height > largest.width * largest.height ? paint : largest), null);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
// Hai phần tử khác nhau (mục chưa chọn với mục đang chọn): so phần phủ. Cùng một phần tử (nền ngoài
|
|
2674
|
+
// với nền con): so kích thước.
|
|
2675
|
+
function isDifferentCover(first, second) {
|
|
2676
|
+
return first.isRound !== second.isRound || Math.abs(first.coverWidth - second.coverWidth) > 0.1 || Math.abs(first.coverHeight - second.coverHeight) > 0.1;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
function isDifferentShape(first, second) {
|
|
2680
|
+
return first.isRound !== second.isRound || Math.abs(first.width - second.width) > 4 || Math.abs(first.height - second.height) > 4;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
function describePaint(paint) {
|
|
2684
|
+
return `${paint.width}×${paint.height} ${paint.isRound ? "tròn" : "vuông"} ở ${paint.path === "0" ? "cả phần tử" : "con bên trong"}`;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
async function probeStateShapes(page) {
|
|
2688
|
+
const shapeMismatches = [];
|
|
2689
|
+
const stuckStates = [];
|
|
2690
|
+
const hoverLikeSelected = [];
|
|
2691
|
+
|
|
2692
|
+
const groups = await page.evaluate((limit) => {
|
|
2693
|
+
// Ngày hôm nay (`aria-current="date"`) không phải lựa chọn: nó được vẽ khác ngày đang chọn là đúng.
|
|
2694
|
+
const selectedSelector = "[aria-pressed='true'], [aria-selected='true'], [aria-current]:not([aria-current='false']):not([aria-current='date'])";
|
|
2695
|
+
const isVisible = (element) => {
|
|
2696
|
+
const rect = element.getBoundingClientRect();
|
|
2697
|
+
|
|
2698
|
+
return rect.width > 0 && rect.height > 0 && getComputedStyle(element).visibility !== "hidden" && !element.closest("[inert], [aria-hidden='true']");
|
|
2699
|
+
};
|
|
2700
|
+
const seenGroups = new Set();
|
|
2701
|
+
const found = [];
|
|
2702
|
+
|
|
2703
|
+
for (const selected of document.querySelectorAll(selectedSelector)) {
|
|
2704
|
+
if (found.length >= limit || !isVisible(selected)) continue;
|
|
2705
|
+
const group = selected.closest("[role='group'], [role='grid'], [role='tablist'], [role='listbox'], [role='radiogroup'], nav, ul, ol, table") || selected.parentElement?.parentElement;
|
|
2706
|
+
if (!group || seenGroups.has(group)) continue;
|
|
2707
|
+
// Mục cùng loại mang cùng thuộc tính trạng thái: chip `aria-pressed` so với chip, không với nút "Hôm nay"
|
|
2708
|
+
// cạnh đó (báo nhầm 30/09/2026, hàng chip bác sĩ trong wireframe lịch hẹn, nhóm rơi về ông của chip).
|
|
2709
|
+
const stateAttribute = ["aria-pressed", "aria-selected"].find((attribute) => selected.hasAttribute(attribute));
|
|
2710
|
+
const sibling = [...group.querySelectorAll(selected.tagName)].find(
|
|
2711
|
+
(candidate) =>
|
|
2712
|
+
candidate !== selected &&
|
|
2713
|
+
candidate.getAttribute("role") === selected.getAttribute("role") &&
|
|
2714
|
+
(!stateAttribute || candidate.hasAttribute(stateAttribute)) &&
|
|
2715
|
+
!candidate.matches(selectedSelector) &&
|
|
2716
|
+
!candidate.matches(":disabled, [aria-disabled='true']") &&
|
|
2717
|
+
!candidate.contains(selected) &&
|
|
2718
|
+
!selected.contains(candidate) &&
|
|
2719
|
+
isVisible(candidate),
|
|
2720
|
+
);
|
|
2721
|
+
if (!sibling) continue;
|
|
2722
|
+
seenGroups.add(group);
|
|
2723
|
+
// Link phủ cả dòng (`::after` inset-0) nằm trong tiêu đề: nền đang chọn và nền rê vẽ trên `<li>` bọc
|
|
2724
|
+
// ngoài, không trên link. Đo trên dòng (báo sót 28/09/2026, danh sách việc làm).
|
|
2725
|
+
const selectedRow = selected.tagName === "A" ? selected.closest("li") : null;
|
|
2726
|
+
const siblingRow = selectedRow && group.contains(selectedRow) ? sibling.closest("li") : null;
|
|
2727
|
+
(siblingRow ? selectedRow : selected).dataset.evonStateId = `selected-${found.length}`;
|
|
2728
|
+
(siblingRow || sibling).dataset.evonStateId = `sibling-${found.length}`;
|
|
2729
|
+
// Bấm thử chỉ với nút đổi lựa chọn tại chỗ: link thì chuyển trang, nút submit thì gửi form.
|
|
2730
|
+
const isSubmit = sibling.tagName === "BUTTON" && sibling.type === "submit" && sibling.form;
|
|
2731
|
+
const canClick = !sibling.closest("a[href]") && !isSubmit;
|
|
2732
|
+
const groupName = group.getAttribute("aria-label") || "";
|
|
2733
|
+
const text = (selected.getAttribute("aria-label") || selected.textContent || "").trim().replace(/\s+/g, " ").slice(0, 24);
|
|
2734
|
+
found.push({ index: found.length, canClick, isTableRow: selected.tagName === "TR", label: `${selected.tagName.toLowerCase()} "${text}"${groupName ? ` trong "${groupName}"` : ""}` });
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
return found;
|
|
2738
|
+
}, maxStateGroups);
|
|
2739
|
+
|
|
2740
|
+
for (const group of groups) {
|
|
2741
|
+
const siblingId = `sibling-${group.index}`;
|
|
2742
|
+
const sibling = page.locator(`[data-evon-state-id="${siblingId}"]`);
|
|
2743
|
+
await page.mouse.move(1, 1);
|
|
2744
|
+
await page.evaluate(() => document.activeElement?.blur());
|
|
2745
|
+
const isReady = await sibling.scrollIntoViewIfNeeded({ timeout: 800 }).then(() => true, () => false);
|
|
2746
|
+
if (!isReady) continue;
|
|
2747
|
+
|
|
2748
|
+
const selectedPaints = await page.evaluate(readStatePaints, `selected-${group.index}`);
|
|
2749
|
+
const selectedShape = pickLargestPaint((selectedPaints || []).filter((paint) => paint.kind === "bg"));
|
|
2750
|
+
const restPaints = await page.evaluate(readStatePaints, siblingId);
|
|
2751
|
+
if (!selectedPaints || !restPaints) continue;
|
|
2752
|
+
|
|
2753
|
+
// Rê: nền mới hiện ra phải cùng hình với nền của mục đang chọn.
|
|
2754
|
+
const isHovered = await sibling.hover({ timeout: 800, force: true }).then(() => true, () => false);
|
|
2755
|
+
await page.waitForTimeout(60);
|
|
2756
|
+
const hoverPaints = isHovered ? await page.evaluate(readStatePaints, siblingId) : null;
|
|
2757
|
+
const hoverShape = hoverPaints && pickLargestPaint(findNewPaints(hoverPaints, restPaints, "bg"));
|
|
2758
|
+
if (selectedShape && hoverShape && isDifferentCover(hoverShape, selectedShape)) {
|
|
2759
|
+
shapeMismatches.push(`${group.label}: rê ra nền ${describePaint(hoverShape)}, đang chọn là nền ${describePaint(selectedShape)}`);
|
|
2760
|
+
}
|
|
2761
|
+
// Rê ra đúng màu nền của mục đang chọn: rê qua mục nào cũng trông như vừa chọn nó (danh sách bên trái
|
|
2762
|
+
// của bố cục danh sách + chi tiết, 28/09/2026). Dòng bảng tick checkbox cùng nền rê là luật đã chốt
|
|
2763
|
+
// (`I10`), không tính.
|
|
2764
|
+
if (selectedShape && hoverShape && !group.isTableRow && hoverShape.paint === selectedShape.paint) {
|
|
2765
|
+
hoverLikeSelected.push(`${group.label}: rê ra nền ${hoverShape.paint}, trùng nền mục đang chọn`);
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
|
|
2769
|
+
if (!group.canClick) continue;
|
|
2770
|
+
// Bấm chuột rồi để chuột đứng yên trên mục vừa chọn: nền rê không được chồng lên nền chọn, và
|
|
2771
|
+
// không hiện vòng nào (`I13`).
|
|
2772
|
+
const isClicked = await sibling.click({ timeout: 800 }).then(() => true, () => false);
|
|
2773
|
+
if (!isClicked) continue;
|
|
2774
|
+
await page.waitForTimeout(150);
|
|
2775
|
+
const clickedPaints = await page.evaluate(readStatePaints, siblingId);
|
|
2776
|
+
if (clickedPaints) {
|
|
2777
|
+
const backgrounds = clickedPaints.filter((paint) => paint.kind === "bg");
|
|
2778
|
+
for (const outer of backgrounds) {
|
|
2779
|
+
const inner = backgrounds.find((paint) => paint.path.startsWith(`${outer.path}.`) && isDifferentShape(paint, outer) && paint.width * paint.height > 0.3 * outer.width * outer.height);
|
|
2780
|
+
if (inner) {
|
|
2781
|
+
stuckStates.push(`${group.label}: bấm xong đứng yên, nền ${describePaint(outer)} chồng lên nền ${describePaint(inner)}`);
|
|
2782
|
+
break;
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
const clickRing = pickLargestPaint(findNewPaints(clickedPaints, selectedPaints, "ring").filter((paint) => !restPaints.some((rest) => rest.kind === "ring" && rest.path === paint.path && rest.paint === paint.paint)));
|
|
2786
|
+
if (clickRing) stuckStates.push(`${group.label}: bấm chuột mà hiện vòng ${describePaint(clickRing)} (skill không vẽ vòng focus, I13)`);
|
|
2787
|
+
}
|
|
2788
|
+
await page.keyboard.press("Escape");
|
|
2789
|
+
}
|
|
2790
|
+
await page.mouse.move(1, 1);
|
|
2791
|
+
|
|
2792
|
+
return { shapeMismatches, stuckStates, hoverLikeSelected, groupCount: groups.length };
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// Rê vào ô đã chọn (checkbox, radio) mà màu nhấn đổi sang xám: `hover:border-*` đứng sau `checked:` trong CSS
|
|
2796
|
+
// Tailwind v4 nên đè màu đã chọn (28/09/2026, radio lọc giá). Đo màu viền và nền trước, sau khi rê; màu trước
|
|
2797
|
+
// có sắc mà sau thành xám, hoặc đổi hẳn sắc, là lỗi.
|
|
2798
|
+
async function findCheckedHoverChanges(page) {
|
|
2799
|
+
const ids = await page.evaluate(() => {
|
|
2800
|
+
const controls = [...document.querySelectorAll("input[type='checkbox']:checked, input[type='radio']:checked, [role='checkbox'][aria-checked='true'], [role='radio'][aria-checked='true']")]
|
|
2801
|
+
.filter((control) => {
|
|
2802
|
+
const rect = control.getBoundingClientRect();
|
|
2803
|
+
|
|
2804
|
+
return rect.width > 0 && rect.height > 0 && !control.closest("[inert], [aria-hidden='true']") && getComputedStyle(control).visibility !== "hidden";
|
|
2805
|
+
})
|
|
2806
|
+
.slice(0, 4);
|
|
2807
|
+
controls.forEach((control, index) => { control.dataset.evonCheckedId = String(index); });
|
|
2808
|
+
|
|
2809
|
+
return controls.map((_, index) => String(index));
|
|
2810
|
+
});
|
|
2811
|
+
const readPaint = (id) => page.evaluate((checkedId) => {
|
|
2812
|
+
const control = document.querySelector(`[data-evon-checked-id="${checkedId}"]`);
|
|
2813
|
+
const style = getComputedStyle(control);
|
|
2814
|
+
const label = (control.closest("label")?.textContent || control.getAttribute("aria-label") || "").trim().replace(/\s+/g, " ").slice(0, 24);
|
|
2815
|
+
|
|
2816
|
+
return { border: style.borderTopColor, background: style.backgroundColor, label: `${control.getAttribute("type") || control.getAttribute("role")} "${label}"` };
|
|
2817
|
+
}, id);
|
|
2818
|
+
const parseRgb = (color) => (color.match(/[\d.]+/g) || []).slice(0, 3).map(Number);
|
|
2819
|
+
const saturation = (rgb) => (rgb.length < 3 ? 0 : Math.max(...rgb) - Math.min(...rgb));
|
|
2820
|
+
const changes = [];
|
|
2821
|
+
for (const id of ids) {
|
|
2822
|
+
const before = await readPaint(id);
|
|
2823
|
+
const isHovered = await page.locator(`[data-evon-checked-id="${id}"]`).hover({ timeout: 800, force: true }).then(() => true, () => false);
|
|
2824
|
+
if (!isHovered) continue;
|
|
2825
|
+
await page.waitForTimeout(60);
|
|
2826
|
+
const after = await readPaint(id);
|
|
2827
|
+
for (const key of ["border", "background"]) {
|
|
2828
|
+
const beforeRgb = parseRgb(before[key]);
|
|
2829
|
+
const afterRgb = parseRgb(after[key]);
|
|
2830
|
+
// Xám ngả xanh (`slate`) vẫn lệch kênh ~40, nên so tương đối: sắc tụt dưới 40% của lúc trước.
|
|
2831
|
+
if (saturation(beforeRgb) >= 40 && saturation(afterRgb) < saturation(beforeRgb) * 0.4 && before[key] !== after[key]) {
|
|
2832
|
+
changes.push(`${before.label}: rê vào ${key === "border" ? "viền" : "nền"} ${before[key]} → ${after[key]}`);
|
|
2833
|
+
break;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
await page.mouse.move(1, 1);
|
|
2838
|
+
|
|
2839
|
+
return changes;
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
function mergeMeasurements(base, extra) {
|
|
2843
|
+
const merged = { ...base };
|
|
2844
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
2845
|
+
if (!Array.isArray(value) || !Array.isArray(base[key])) continue;
|
|
2846
|
+
const seen = new Set(base[key].map((item) => JSON.stringify(item)));
|
|
2847
|
+
merged[key] = [...base[key], ...value.filter((item) => !seen.has(JSON.stringify(item)))];
|
|
2848
|
+
}
|
|
2849
|
+
// Tự cuộn chỉ đo được lúc chưa ai chạm trang: lần đo lại sau khi mở khối đóng thì trang đã bị cuộn tới
|
|
2850
|
+
// khối đó (báo nhầm 27/09/2026, /dashboard 1280).
|
|
2851
|
+
merged.autoScrolledAreas = base.autoScrolledAreas;
|
|
2852
|
+
merged.hasHorizontalScroll = base.hasHorizontalScroll || extra.hasHorizontalScroll;
|
|
2853
|
+
merged.pageScrollWidth = Math.max(base.pageScrollWidth, extra.pageScrollWidth);
|
|
2854
|
+
|
|
2855
|
+
return merged;
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// ---------- Chạy ----------
|
|
2859
|
+
|
|
2860
|
+
// Khung app `h-screen` + cột nội dung `overflow-y-auto`: ảnh fullPage chỉ ra đúng một màn, phần dưới mép cột
|
|
2861
|
+
// cuộn không bao giờ lên ảnh (ô nhập dính viền mặc định ở cuối trang bị sót, 27/09/2026). Kéo cửa sổ cao
|
|
2862
|
+
// thêm bằng phần đang khuất của khung cuộn lớn nhất rồi mới chụp, chụp xong trả lại.
|
|
2863
|
+
function measureHiddenScrollHeight() {
|
|
2864
|
+
let hiddenHeight = 0;
|
|
2865
|
+
for (const container of document.querySelectorAll("body *")) {
|
|
2866
|
+
const overflowY = getComputedStyle(container).overflowY;
|
|
2867
|
+
if (!["auto", "scroll"].includes(overflowY) || container.clientHeight < window.innerHeight * 0.6) continue;
|
|
2868
|
+
hiddenHeight = Math.max(hiddenHeight, container.scrollHeight - container.clientHeight);
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
return hiddenHeight;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
async function takeFullScreenshot(page, path) {
|
|
2875
|
+
const viewport = page.viewportSize();
|
|
2876
|
+
const hiddenHeight = await page.evaluate(measureHiddenScrollHeight);
|
|
2877
|
+
if (hiddenHeight > 1) {
|
|
2878
|
+
await page.setViewportSize({ width: viewport.width, height: Math.min(viewport.height + hiddenHeight, 12000) });
|
|
2879
|
+
await page.waitForTimeout(250);
|
|
2880
|
+
}
|
|
2881
|
+
await page.screenshot({ path, fullPage: true });
|
|
2882
|
+
if (hiddenHeight > 1) {
|
|
2883
|
+
await page.setViewportSize(viewport);
|
|
2884
|
+
await page.waitForTimeout(150);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
async function probeWidth(browser, options, width) {
|
|
2889
|
+
const isMobile = width < mobileWidthLimit;
|
|
2890
|
+
const context = await browser.newContext({
|
|
2891
|
+
viewport: { width, height: isMobile ? 812 : 900 },
|
|
2892
|
+
deviceScaleFactor: options.dpr,
|
|
2893
|
+
isMobile,
|
|
2894
|
+
hasTouch: isMobile,
|
|
2895
|
+
colorScheme: options.isDark ? "dark" : "light",
|
|
2896
|
+
});
|
|
2897
|
+
const page = await context.newPage();
|
|
2898
|
+
const consoleErrors = [];
|
|
2899
|
+
|
|
2900
|
+
page.on("console", (message) => {
|
|
2901
|
+
if (message.type() === "error") consoleErrors.push(message.text().slice(0, 200));
|
|
2902
|
+
});
|
|
2903
|
+
page.on("pageerror", (error) => consoleErrors.push(String(error).slice(0, 200)));
|
|
2904
|
+
|
|
2905
|
+
await page.goto(options.url, { waitUntil: "load" });
|
|
2906
|
+
// Ghi `transition` sau khi trang đã hydrate: gắn thuộc tính trước đó thì React báo lệch HTML (báo nhầm
|
|
2907
|
+
// lỗi console 28/09/2026, Next).
|
|
2908
|
+
await page.waitForTimeout(options.waitMs);
|
|
2909
|
+
await page.evaluate(stampTransitions);
|
|
2910
|
+
await page.addStyleTag({ content: freezeMotionCss });
|
|
2911
|
+
if (options.isDark) await page.evaluate(() => document.documentElement.classList.add("dark"));
|
|
2912
|
+
await page.waitForTimeout(150);
|
|
2913
|
+
|
|
2914
|
+
// Đo trước khi chụp: chụp fullPage ở khổ mobile làm trang mất `(pointer: coarse)`, nút
|
|
2915
|
+
// `pointer-coarse:size-10` co về 28px và bị báo nhầm là chỗ bấm nhỏ (đã dính 26/09/2026).
|
|
2916
|
+
const measurements = await page.evaluate(measureInPage, { minTapSize, isMobile });
|
|
2917
|
+
|
|
2918
|
+
const screenshotPath = join(options.out, `${width}${options.isDark ? "-dark" : ""}.png`);
|
|
2919
|
+
await takeFullScreenshot(page, screenshotPath);
|
|
2920
|
+
// Đo dark mode ngay sau khi chụp, lúc trang còn đúng như ảnh: các bước rê, bấm, mở lớp nổi bên dưới
|
|
2921
|
+
// có thể đổi trang (đã dính 01/10/2026: tới lượt đo thì biểu đồ tổng quan còn 2/10 khối màu nhấn).
|
|
2922
|
+
const darkModeProblemSet = new Set(options.isDark ? [...(await page.evaluate(findDarkModeProblems)), ...(await page.evaluate(findSunkenSelections)), ...(await page.evaluate(findHueDrifts))] : []);
|
|
2923
|
+
|
|
2924
|
+
const { drawnRings: drawnFocusRings, unmarkedFocusStops } = isMobile ? { drawnRings: [], unmarkedFocusStops: [] } : await findDrawnFocusRings(page);
|
|
2925
|
+
|
|
2926
|
+
// Mở khối đang đóng trước khi rê và chạm: cây thư mục nằm trong accordion đóng ở /components thì
|
|
2927
|
+
// tooltip tên tệp tràn màn chỉ lộ khi khối đã mở (27/09/2026).
|
|
2928
|
+
const expandedCount = await expandCollapsedBlocks(page);
|
|
2929
|
+
const allMeasurements = expandedCount > 0 ? mergeMeasurements(measurements, await page.evaluate(measureInPage, { minTapSize, isMobile })) : measurements;
|
|
2930
|
+
// Khối vừa mở có thể chứa bản mẫu lớp nổi (hộp xác nhận bày tĩnh có lớp phủ): đo dark mode thêm một lần.
|
|
2931
|
+
if (options.isDark && expandedCount > 0) for (const line of [...(await page.evaluate(findDarkModeProblems)), ...(await page.evaluate(findSunkenSelections))]) darkModeProblemSet.add(line);
|
|
2932
|
+
|
|
2933
|
+
// Màn chạm không có rê chuột: chỉ đo nền rê ở khổ desktop.
|
|
2934
|
+
const hoverStates = isMobile ? { layoutShifts: [], vanishedChildren: [], weakHovers: [], blendedHovers: [], borderHovers: [], overflowingLayers: [] } : await probeHoverStates(page);
|
|
2935
|
+
const popupLayers = await probePopupLayers(page, isMobile, options.isDark);
|
|
2936
|
+
for (const line of popupLayers.sunkenSelections) darkModeProblemSet.add(line);
|
|
2937
|
+
const darkModeProblems = [...darkModeProblemSet];
|
|
2938
|
+
const heavyDecorativeBorders = options.isDark ? [] : await page.evaluate(findHeavyDecorativeBorders);
|
|
2939
|
+
const scrollbarStyles = await page.evaluate(findScrollbarStyles);
|
|
2940
|
+
const heavyNavLinks = await page.evaluate(findHeavyNavLinks);
|
|
2941
|
+
const staticHoverBlocks = isMobile ? [] : await page.evaluate(findStaticHoverBlocks);
|
|
2942
|
+
const brokenImages = await page.evaluate(findBrokenImages);
|
|
2943
|
+
const misformattedNumbers = await page.evaluate(findMisformattedNumbers);
|
|
2944
|
+
const overflowingLayers = [...new Set([...hoverStates.overflowingLayers, ...popupLayers.overflowing])];
|
|
2945
|
+
// Chạy sau cùng: bấm thử đổi lựa chọn trên trang (ngày, tab), các phép đo khác phải xong trước.
|
|
2946
|
+
const pageCheckedHoverChanges = isMobile ? [] : await findCheckedHoverChanges(page);
|
|
2947
|
+
const stateShapes = isMobile ? { shapeMismatches: [], stuckStates: [], hoverLikeSelected: [], groupCount: 0 } : await probeStateShapes(page);
|
|
2948
|
+
// Sau cùng thật sự: mỗi lần bấm là một lần tải lại trang.
|
|
2949
|
+
const hiddenNativeControls = isMobile ? [] : await page.evaluate(findNativeControls, "hidden");
|
|
2950
|
+
const openerLayers = isMobile ? await probeOpenerLayers(page, options, width) : { problems: [], flatLayers: [], openedShots: [], nativeControls: [] };
|
|
2951
|
+
|
|
2952
|
+
await context.close();
|
|
2953
|
+
|
|
2954
|
+
return {
|
|
2955
|
+
width,
|
|
2956
|
+
screenshotPath,
|
|
2957
|
+
consoleErrors: [...new Set(consoleErrors)],
|
|
2958
|
+
...allMeasurements,
|
|
2959
|
+
expandedCount,
|
|
2960
|
+
drawnFocusRings,
|
|
2961
|
+
unmarkedFocusStops,
|
|
2962
|
+
hollowLayers: popupLayers.hollow,
|
|
2963
|
+
openerLayerProblems: openerLayers.problems,
|
|
2964
|
+
flatOpenedLayers: openerLayers.flatLayers,
|
|
2965
|
+
openedLayerShots: openerLayers.openedShots,
|
|
2966
|
+
layoutShifts: hoverStates.layoutShifts,
|
|
2967
|
+
vanishedChildren: hoverStates.vanishedChildren,
|
|
2968
|
+
weakHovers: hoverStates.weakHovers,
|
|
2969
|
+
blendedHovers: hoverStates.blendedHovers,
|
|
2970
|
+
borderHovers: hoverStates.borderHovers,
|
|
2971
|
+
overflowingLayers,
|
|
2972
|
+
shapeMismatches: stateShapes.shapeMismatches,
|
|
2973
|
+
hoverLikeSelected: stateShapes.hoverLikeSelected,
|
|
2974
|
+
checkedHoverChanges: [...new Set([...pageCheckedHoverChanges, ...popupLayers.checkedHoverChanges])],
|
|
2975
|
+
heavyLayerLines: popupLayers.heavyLayerLines,
|
|
2976
|
+
motionlessLayers: popupLayers.motionless,
|
|
2977
|
+
popupNativeChoices: [...new Set([...popupLayers.nativeChoices, ...hiddenNativeControls, ...openerLayers.nativeControls])],
|
|
2978
|
+
scrollyLayers: popupLayers.scrollyLayers,
|
|
2979
|
+
lostTriggerIcons: popupLayers.lostTriggerIcons,
|
|
2980
|
+
heavyDecorativeBorders,
|
|
2981
|
+
scrollbarStyles,
|
|
2982
|
+
heavyNavLinks,
|
|
2983
|
+
staticHoverBlocks,
|
|
2984
|
+
brokenImages,
|
|
2985
|
+
misformattedNumbers,
|
|
2986
|
+
darkModeProblems,
|
|
2987
|
+
stuckStates: stateShapes.stuckStates,
|
|
2988
|
+
stateGroupCount: stateShapes.groupCount,
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
// Viền trang trí đậm (M14): card, khung bo góc có viền 1px mà đường viền ≥ 1.21:1 trên trắng. Skill ~1.07:1,
|
|
2993
|
+
// `--border-strong` #eaeaea ~1.20:1 (khung bảng ở dự án mồi, chủ dự án duyệt); token viền kiểu `#e2e8f0`
|
|
2994
|
+
// (~1.23:1) là mức đã chê đậm, chỉ cho ô nhập, nút viền. Khung bọc ô nhập (ô tìm có icon) không tính.
|
|
2995
|
+
function findHeavyDecorativeBorders() {
|
|
2996
|
+
const canvas = document.createElement("canvas");
|
|
2997
|
+
canvas.width = 1;
|
|
2998
|
+
canvas.height = 1;
|
|
2999
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
3000
|
+
const toContrastOnWhite = (cssColor) => {
|
|
3001
|
+
context.clearRect(0, 0, 1, 1);
|
|
3002
|
+
context.fillStyle = "rgba(0,0,0,0)";
|
|
3003
|
+
context.fillStyle = cssColor;
|
|
3004
|
+
context.fillRect(0, 0, 1, 1);
|
|
3005
|
+
const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
|
|
3006
|
+
if (alpha === 0) return null;
|
|
3007
|
+
// Viền có sắc (đỏ nhạt của banner lỗi, hổ phách của banner chú ý: `components/banner.md`) là viền mang
|
|
3008
|
+
// nghĩa, không phải viền trang trí xám. Đã báo nhầm 30/09/2026: `border-red-200` của khung cảnh báo y khoa.
|
|
3009
|
+
if (Math.max(red, green, blue) - Math.min(red, green, blue) > 24) return null;
|
|
3010
|
+
const opacity = alpha / 255;
|
|
3011
|
+
const toLinear = (channel) => {
|
|
3012
|
+
const value = (channel * opacity + 255 * (1 - opacity)) / 255;
|
|
3013
|
+
|
|
3014
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
3015
|
+
};
|
|
3016
|
+
|
|
3017
|
+
return 1.05 / (0.2126 * toLinear(red) + 0.7152 * toLinear(green) + 0.0722 * toLinear(blue) + 0.05);
|
|
3018
|
+
};
|
|
3019
|
+
const findings = new Map();
|
|
3020
|
+
for (const element of document.querySelectorAll("body *")) {
|
|
3021
|
+
if (findings.size >= 6) break;
|
|
3022
|
+
if (element.matches("input, textarea, select, button, a, label, [role='button'], [role='textbox'], [role='combobox']")) continue;
|
|
3023
|
+
// Ô bày viền focus trên trang design system là ví dụ trạng thái, không phải viền trang trí (`D9`, 30/09/2026).
|
|
3024
|
+
if (element.closest("[data-demo-state]")) continue;
|
|
3025
|
+
const style = getComputedStyle(element);
|
|
3026
|
+
const widths = [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth];
|
|
3027
|
+
if (style.borderTopStyle !== "solid" || widths.some((borderWidth) => borderWidth !== "1px") || parseFloat(style.borderTopLeftRadius) < 6) continue;
|
|
3028
|
+
const rect = element.getBoundingClientRect();
|
|
3029
|
+
if (rect.width < 120 || rect.height < 60 || style.visibility === "hidden") continue;
|
|
3030
|
+
const field = element.querySelector("input:not([type='checkbox']):not([type='radio']):not([type='hidden']), textarea, select");
|
|
3031
|
+
if (field && field.getBoundingClientRect().height >= rect.height * 0.6) continue;
|
|
3032
|
+
const contrast = toContrastOnWhite(style.borderTopColor);
|
|
3033
|
+
if (contrast === null || contrast < 1.21 || findings.has(style.borderTopColor)) continue;
|
|
3034
|
+
const label = `${element.tagName.toLowerCase()}.${String(element.className).trim().split(/\s+/).slice(0, 4).join(".")}`;
|
|
3035
|
+
findings.set(style.borderTopColor, `${style.borderTopColor} (~${contrast.toFixed(2)}:1 trên trắng): ${label} "${(element.textContent || "").trim().replace(/\s+/g, " ").slice(0, 30)}"`);
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
return [...findings.values()];
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
// Mục đang chọn / đang trỏ tô đúng nền trang (`isHighlighted && "bg-background"`, `aria-current` + `bg-background`)
|
|
3042
|
+
// mà khung chứa nó sáng hơn: ở nền tối nền trang tối hơn card và lớp nổi, mục chọn thành lỗ khoét (M21). Class
|
|
3043
|
+
// viết trong điều kiện JS nên không grep được `hover:bg-background`; đo màu. Đã dính 01/10/2026, ô chọn và bảng
|
|
3044
|
+
// lệnh ở /components. Chỉ gọi ở nền tối: nền sáng thì nền trang tối hơn card là đúng chiều.
|
|
3045
|
+
function findSunkenSelections() {
|
|
3046
|
+
const canvas = document.createElement("canvas");
|
|
3047
|
+
canvas.width = 1;
|
|
3048
|
+
canvas.height = 1;
|
|
3049
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
3050
|
+
const readHex = (cssColor) => {
|
|
3051
|
+
context.clearRect(0, 0, 1, 1);
|
|
3052
|
+
context.fillStyle = "rgba(0,0,0,0)";
|
|
3053
|
+
context.fillStyle = cssColor;
|
|
3054
|
+
context.fillRect(0, 0, 1, 1);
|
|
3055
|
+
const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
|
|
3056
|
+
|
|
3057
|
+
return alpha < 240 ? null : `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
3058
|
+
};
|
|
3059
|
+
const pageHex = readHex(getComputedStyle(document.documentElement).getPropertyValue("--background").trim());
|
|
3060
|
+
if (!pageHex) return [];
|
|
3061
|
+
const findings = new Set();
|
|
3062
|
+
// Thêm vệt khung chờ `animate-pulse`: mẫu cũ tô `bg-background`, ở nền tối thành dãy lỗ đen (01/10/2026).
|
|
3063
|
+
const selector = "[role=option], [role=menuitem], [role=menuitemradio], [role=tab], [aria-selected=true], [aria-current]:not([aria-current=false]), [data-highlighted], [data-selected=true], .animate-pulse, [role=progressbar]";
|
|
3064
|
+
// Rãnh không có role (hay gặp: `div.h-2.rounded-full` bọc một thanh tô màu): nhận theo hình. Thấp 2–12px, bo
|
|
3065
|
+
// tròn đầu, rộng ≥ 60px, có con tô màu khác. Đã dính 01/10/2026, "Doanh thu theo kênh" ở /dashboard/revenue.
|
|
3066
|
+
const shapedTracks = [...document.querySelectorAll("div, span")].filter((element) => {
|
|
3067
|
+
const rect = element.getBoundingClientRect();
|
|
3068
|
+
if (rect.height < 2 || rect.height > 12 || rect.width < 60) return false;
|
|
3069
|
+
if (parseFloat(getComputedStyle(element).borderTopLeftRadius) < rect.height / 2 - 0.5) return false;
|
|
3070
|
+
const fill = element.firstElementChild;
|
|
3071
|
+
|
|
3072
|
+
return Boolean(fill) && readHex(getComputedStyle(fill).backgroundColor) !== null && fill.getBoundingClientRect().width < rect.width;
|
|
3073
|
+
});
|
|
3074
|
+
for (const element of shapedTracks) element.dataset.evonTrack = "1";
|
|
3075
|
+
for (const element of document.querySelectorAll(`${selector}, [data-evon-track]`)) {
|
|
3076
|
+
if (findings.size >= 3) break;
|
|
3077
|
+
const rect = element.getBoundingClientRect();
|
|
3078
|
+
// Rãnh `h-1`, `h-2` và vệt chờ `h-3` thấp hơn mục chọn: hạ ngưỡng cao riêng cho hai loại đó.
|
|
3079
|
+
const minHeight = element.matches(".animate-pulse, [role=progressbar], [data-evon-track]") ? 2 : 16;
|
|
3080
|
+
if (rect.width < 24 || rect.height < minHeight || readHex(getComputedStyle(element).backgroundColor) !== pageHex) continue;
|
|
3081
|
+
let ancestor = element.parentElement;
|
|
3082
|
+
let ancestorHex = null;
|
|
3083
|
+
while (ancestor && !ancestorHex) {
|
|
3084
|
+
ancestorHex = readHex(getComputedStyle(ancestor).backgroundColor);
|
|
3085
|
+
ancestor = ancestor.parentElement;
|
|
3086
|
+
}
|
|
3087
|
+
if (!ancestorHex || ancestorHex === pageHex) continue;
|
|
3088
|
+
const label = (element.textContent || element.getAttribute("aria-label") || "").trim().replace(/\s+/g, " ").slice(0, 30);
|
|
3089
|
+
const isSkeleton = element.classList.contains("animate-pulse");
|
|
3090
|
+
const isTrack = element.getAttribute("role") === "progressbar" || element.dataset.evonTrack === "1";
|
|
3091
|
+
findings.add(isSkeleton || isTrack
|
|
3092
|
+
? `${isTrack ? "rãnh thanh tiến độ" : "vệt khung chờ"} tô nền trang ${pageHex}, tối hơn khung ${ancestorHex}, thành vệt đen: dùng bg-foreground/5 (M21${isTrack ? ", components/charts.md" : ", components/empty-state.md"})`
|
|
3093
|
+
: `mục đang chọn / đang trỏ tô nền trang ${pageHex}, tối hơn khung ${ancestorHex}, như lỗ khoét: dùng --item-hover (trỏ, rê) hoặc --secondary (đang chọn) (M21): ${element.getAttribute("role") || element.tagName.toLowerCase()} "${label}"`);
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
return [...findings];
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
// Cùng một chữ có màu mà sắc hai theme lệch nhau (M7, "Bản tối chọn theo sắc"): amber-700 cam ở bản sáng, amber-400
|
|
3100
|
+
// vàng ở bản tối, đọc ra hai màu (chủ dự án thấy 01/10/2026, bảng công việc). Đang tối thì gỡ `.dark` một nhịp, đọc
|
|
3101
|
+
// lại màu chữ rồi gắn lại; chuyển động đã tắt nên màu đổi ngay. So sắc OKLCH, chỉ chữ đủ đậm sắc (chroma > 0.08).
|
|
3102
|
+
function findHueDrifts() {
|
|
3103
|
+
const root = document.documentElement;
|
|
3104
|
+
if (!root.classList.contains("dark")) return [];
|
|
3105
|
+
const canvas = document.createElement("canvas");
|
|
3106
|
+
canvas.width = 1;
|
|
3107
|
+
canvas.height = 1;
|
|
3108
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
3109
|
+
const toOklch = (cssColor) => {
|
|
3110
|
+
context.clearRect(0, 0, 1, 1);
|
|
3111
|
+
context.fillStyle = "rgba(0,0,0,0)";
|
|
3112
|
+
context.fillStyle = cssColor;
|
|
3113
|
+
context.fillRect(0, 0, 1, 1);
|
|
3114
|
+
const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
|
|
3115
|
+
if (alpha < 200) return null;
|
|
3116
|
+
const [linearRed, linearGreen, linearBlue] = [red, green, blue].map((channel) => {
|
|
3117
|
+
const value = channel / 255;
|
|
3118
|
+
|
|
3119
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
3120
|
+
});
|
|
3121
|
+
const long = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue);
|
|
3122
|
+
const medium = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue);
|
|
3123
|
+
const short = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue);
|
|
3124
|
+
const axisA = 1.9779984951 * long - 2.428592205 * medium + 0.4505937099 * short;
|
|
3125
|
+
const axisB = 0.0259040371 * long + 0.7827717662 * medium - 0.808477439 * short;
|
|
3126
|
+
const hex = `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
3127
|
+
|
|
3128
|
+
return { chroma: Math.hypot(axisA, axisB), hue: ((Math.atan2(axisB, axisA) * 180) / Math.PI + 360) % 360, hex };
|
|
3129
|
+
};
|
|
3130
|
+
const texts = [...document.querySelectorAll("body *")]
|
|
3131
|
+
.filter((element) => element.children.length === 0 && (element.textContent || "").trim() && element.getBoundingClientRect().width > 0)
|
|
3132
|
+
.slice(0, 600);
|
|
3133
|
+
const darkColors = texts.map((element) => toOklch(getComputedStyle(element).color));
|
|
3134
|
+
root.classList.remove("dark");
|
|
3135
|
+
const lightColors = texts.map((element) => toOklch(getComputedStyle(element).color));
|
|
3136
|
+
root.classList.add("dark");
|
|
3137
|
+
const findings = new Map();
|
|
3138
|
+
texts.forEach((element, index) => {
|
|
3139
|
+
const dark = darkColors[index];
|
|
3140
|
+
const light = lightColors[index];
|
|
3141
|
+
if (!dark || !light || dark.chroma < 0.08 || light.chroma < 0.08) return;
|
|
3142
|
+
const drift = Math.min(Math.abs(dark.hue - light.hue), 360 - Math.abs(dark.hue - light.hue));
|
|
3143
|
+
const key = `${light.hex}→${dark.hex}`;
|
|
3144
|
+
// 20°: sky-700 → sky-300 của avatar lệch 12° ở thang gốc, đo qua hex ra 16°, mắt vẫn đọc là một màu.
|
|
3145
|
+
if (drift <= 20 || findings.has(key) || findings.size >= 4) return;
|
|
3146
|
+
findings.set(key, `chữ màu đổi sắc giữa hai theme: sáng ${light.hex} (${Math.round(light.hue)}°) → tối ${dark.hex} (${Math.round(dark.hue)}°), lệch ${Math.round(drift)}°, đọc ra hai màu; chọn bậc tối theo sắc, ví dụ amber-700 → orange-400 (M7): "${element.textContent.trim().slice(0, 30)}"`);
|
|
3147
|
+
});
|
|
3148
|
+
|
|
3149
|
+
return [...findings.values()];
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
// Chỉ chạy với `--dark` (M21, M31, M32, V4). Bốn kiểu lỗi chỉ người bật tối mới thấy:
|
|
3153
|
+
// - trang vẫn sáng: dark mode chỉ có khai báo, hoặc `dark:` chạy theo máy mà token theo class;
|
|
3154
|
+
// - mảng nền sáng giữa màn tối: badge, avatar, banner `-50`/`-100`, toast của thư viện chưa đọc token;
|
|
3155
|
+
// - ô nhập không viền: `dark:border-transparent`, chỉ còn nền mờ báo vùng gõ;
|
|
3156
|
+
// - nền rê `bg-background`: ở nền tối nền trang tối hơn card, rê vào chìm xuống gần như không thấy.
|
|
3157
|
+
// Thêm `color-scheme` không phải dark: thanh cuộn gốc, ô ngày, autofill vẫn vẽ bản sáng.
|
|
3158
|
+
// Màu nhấn, chữ chính (nút chính, tooltip đảo màu) được phép sáng, không tính.
|
|
3159
|
+
function findDarkModeProblems() {
|
|
3160
|
+
const canvas = document.createElement("canvas");
|
|
3161
|
+
canvas.width = 1;
|
|
3162
|
+
canvas.height = 1;
|
|
3163
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
3164
|
+
const readRgba = (cssColor) => {
|
|
3165
|
+
if (!cssColor) return null;
|
|
3166
|
+
context.clearRect(0, 0, 1, 1);
|
|
3167
|
+
context.fillStyle = "rgba(0,0,0,0)";
|
|
3168
|
+
context.fillStyle = cssColor;
|
|
3169
|
+
context.fillRect(0, 0, 1, 1);
|
|
3170
|
+
const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
|
|
3171
|
+
|
|
3172
|
+
return { red, green, blue, alpha: alpha / 255 };
|
|
3173
|
+
};
|
|
3174
|
+
const readLuminance = ({ red, green, blue }) => {
|
|
3175
|
+
const toLinear = (channel) => {
|
|
3176
|
+
const value = channel / 255;
|
|
3177
|
+
|
|
3178
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
3179
|
+
};
|
|
3180
|
+
|
|
3181
|
+
return 0.2126 * toLinear(red) + 0.7152 * toLinear(green) + 0.0722 * toLinear(blue);
|
|
3182
|
+
};
|
|
3183
|
+
const toHex = ({ red, green, blue }) => `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
3184
|
+
const describe = (element) => {
|
|
3185
|
+
const classNames = String(element.className?.baseVal ?? element.className ?? "").trim().split(/\s+/).filter(Boolean).slice(0, 4);
|
|
3186
|
+
const text = (element.textContent || "").trim().replace(/\s+/g, " ").slice(0, 30);
|
|
3187
|
+
|
|
3188
|
+
return `${element.tagName.toLowerCase()}${classNames.length > 0 ? `.${classNames.join(".")}` : ""}${text ? ` "${text}"` : ""}`;
|
|
3189
|
+
};
|
|
3190
|
+
const findings = [];
|
|
3191
|
+
|
|
3192
|
+
const pageBackground = [document.body, document.documentElement]
|
|
3193
|
+
.map((element) => readRgba(getComputedStyle(element).backgroundColor))
|
|
3194
|
+
.find((color) => color && color.alpha > 0.5);
|
|
3195
|
+
if (!pageBackground || readLuminance(pageBackground) > 0.4) {
|
|
3196
|
+
return [`trang không tối khi bật --dark (nền ${pageBackground ? toHex(pageBackground) : "trong suốt, tức trắng"}): dark mode chỉ có khai báo, coi như chưa có, bỏ lượt tối (V4). Dự án tự dựng dark mode mà ra dòng này thì token không lật theo class \`dark\` trên <html>`];
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
const colorScheme = getComputedStyle(document.documentElement).colorScheme;
|
|
3200
|
+
if (!/dark/.test(colorScheme)) findings.push(`color-scheme của <html> là "${colorScheme}", không phải dark: thanh cuộn gốc, ô ngày, autofill, <select> vẫn vẽ bản sáng (M31)`);
|
|
3201
|
+
|
|
3202
|
+
const rootStyle = getComputedStyle(document.documentElement);
|
|
3203
|
+
// Nền rê nút chính trắng tuyệt đối: khối chói nhất màn tối (M23, đã dính 01/10/2026, ui-ux-dashboard).
|
|
3204
|
+
const primaryHover = readRgba(rootStyle.getPropertyValue("--primary-hover").trim());
|
|
3205
|
+
if (primaryHover && primaryHover.alpha > 0.5 && readLuminance(primaryHover) > 0.95) {
|
|
3206
|
+
findings.push(`--primary-hover là ${toHex(primaryHover)}, nút chính rê vào thành khối trắng tuyệt đối: lệch về phía nền một bậc, ví dụ #cfd5e0 (M23)`);
|
|
3207
|
+
}
|
|
3208
|
+
const allowedBrights = ["--primary", "--primary-hover", "--foreground"]
|
|
3209
|
+
.map((name) => readRgba(rootStyle.getPropertyValue(name).trim()))
|
|
3210
|
+
.filter(Boolean)
|
|
3211
|
+
.map(toHex);
|
|
3212
|
+
const brightColors = new Map();
|
|
3213
|
+
const primaryHex = allowedBrights[0];
|
|
3214
|
+
const solidAccentBars = [];
|
|
3215
|
+
let borderlessFieldCount = 0;
|
|
3216
|
+
let sunkenHoverCount = 0;
|
|
3217
|
+
|
|
3218
|
+
for (const element of document.querySelectorAll("body *")) {
|
|
3219
|
+
if (element.closest(".force-light, [data-demo-state]")) continue;
|
|
3220
|
+
if (element.matches("img, video, canvas, svg, svg *, picture, iframe")) continue;
|
|
3221
|
+
const rect = element.getBoundingClientRect();
|
|
3222
|
+
if (rect.width < 12 || rect.height < 12) continue;
|
|
3223
|
+
const style = getComputedStyle(element);
|
|
3224
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) continue;
|
|
3225
|
+
|
|
3226
|
+
const className = String(element.className?.baseVal ?? element.className ?? "");
|
|
3227
|
+
if (sunkenHoverCount < 3 && /(^|\s)(hover|focus-visible|data-\[highlighted\]|data-\[selected=true\]):bg-background(\s|$)/.test(className)) {
|
|
3228
|
+
sunkenHoverCount += 1;
|
|
3229
|
+
findings.push(`nền rê \`bg-background\` chìm xuống, ở nền tối gần như không thấy, dùng \`bg-item-hover\` (M21, I10): ${describe(element)}`);
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
const background = readRgba(style.backgroundColor);
|
|
3233
|
+
// Cột biểu đồ màu nhấn 100%: cao hơn nút (≥ 48px), hẹp như cột. Nút chính cao 40px nên không tính.
|
|
3234
|
+
if (background && background.alpha > 0.95 && toHex(background) === primaryHex && rect.height >= 48 && rect.width <= 64) solidAccentBars.push(element);
|
|
3235
|
+
// Lớp phủ sáng mờ phủ vùng lớn: `bg-foreground/40` sau modal, ở nền tối foreground gần trắng nên thành màn
|
|
3236
|
+
// sương sáng (đã dính 01/10/2026, bản mẫu hộp xác nhận ở /components). Lớp phủ phải là `bg-black/…` (M32).
|
|
3237
|
+
if (background && background.alpha >= 0.2 && background.alpha < 0.5 && readLuminance(background) > 0.6 && rect.width >= 300 && rect.height >= 150 && findings.length < 8) {
|
|
3238
|
+
findings.push(`lớp phủ sáng mờ ${toHex(background)} ${Math.round(background.alpha * 100)}% phủ ${Math.round(rect.width)}×${Math.round(rect.height)}px: lớp phủ sau modal, panel phải là bg-black/…, không bg-foreground/… (M32): ${describe(element)}`);
|
|
3239
|
+
}
|
|
3240
|
+
if (background && background.alpha >= 0.5 && readLuminance(background) > 0.6) {
|
|
3241
|
+
const hex = toHex(background);
|
|
3242
|
+
if (!allowedBrights.includes(hex) && !brightColors.has(hex) && brightColors.size < 5) {
|
|
3243
|
+
brightColors.set(hex, `mảng nền sáng ${hex} giữa màn tối, ${Math.round(rect.width)}×${Math.round(rect.height)}px (M32): ${describe(element)}`);
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
if (borderlessFieldCount < 3 && element.matches("input:not([type='checkbox']):not([type='radio']):not([type='hidden']):not([type='range']):not([type='file']):not([type='color']), textarea, select") && rect.width >= 60) {
|
|
3248
|
+
const border = readRgba(style.borderBottomColor);
|
|
3249
|
+
const hasBorder = parseFloat(style.borderBottomWidth) > 0 && border && border.alpha > 0.02;
|
|
3250
|
+
if (!hasBorder && background && background.alpha > 0) {
|
|
3251
|
+
borderlessFieldCount += 1;
|
|
3252
|
+
findings.push(`ô nhập không viền ở nền tối, chỉ còn nền mờ báo vùng gõ: giữ viền --border-strong (M32): ${describe(element)}`);
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
// Đã dính 01/10/2026, "Việc xong mỗi tuần" ở ui-ux-dashboard: bảy cột trắng đặc là khối chói nhất màn.
|
|
3258
|
+
if (solidAccentBars.length >= 3) {
|
|
3259
|
+
findings.push(`${solidAccentBars.length} cột biểu đồ tô màu nhấn 100% (gần trắng), chói nhất màn tối: dùng --chart-fill, nền tối 70% (M32, components/charts.md): ${describe(solidAccentBars[0])}`);
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
return [...brightColors.values(), ...findings];
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
// Mục điều hướng dọc (sidebar) chữ đậm: mẫu của skill là chữ thường 400 cho mục thường, chỉ mục đang chọn
|
|
3266
|
+
// `font-medium` (layouts/app.md). Cả cột 600 thì mục đang chọn không còn khác gì ngoài nền, cột nặng hơn nội
|
|
3267
|
+
// dung (đã dính 29/09/2026, tim-phong-sua: dựng lại theo nhánh U mà sidebar giữ chữ 600 của CSS cũ).
|
|
3268
|
+
// Nền rê trên khối không bấm được (I9 vế ngược): dòng chỉ để đọc mà rê vào tô nền thì trông như bấm được, bấm
|
|
3269
|
+
// không ra gì (sót 01/10/2026, lop-hoc: dòng "Lớp hôm nay" `hover:bg-background`, không link, con trỏ thường).
|
|
3270
|
+
// Nhận theo class Tailwind `hover:bg-*`; bỏ qua khối có nút ẩn hiện lúc rê (`group-hover:`, list-row.md) hoặc
|
|
3271
|
+
// con trỏ bàn tay (div gắn onClick).
|
|
3272
|
+
function findStaticHoverBlocks() {
|
|
3273
|
+
const interactiveSelector = "a[href], button, input, select, textarea, label, summary, [role], [tabindex], [onclick], [contenteditable]";
|
|
3274
|
+
const findings = [];
|
|
3275
|
+
for (const node of document.querySelectorAll("[class*='hover:bg-']")) {
|
|
3276
|
+
const classes = (node.getAttribute("class") || "").split(/\s+/);
|
|
3277
|
+
if (!classes.some((name) => /^hover:bg-/.test(name))) continue;
|
|
3278
|
+
const rect = node.getBoundingClientRect();
|
|
3279
|
+
if (rect.width * rect.height < 400 || node.closest(interactiveSelector)) continue;
|
|
3280
|
+
if (getComputedStyle(node).cursor === "pointer" || node.querySelector("[class*='group-hover:']")) continue;
|
|
3281
|
+
const text = (node.textContent || "").replace(/\s+/g, " ").trim().slice(0, 30);
|
|
3282
|
+
findings.push(`${node.tagName.toLowerCase()}.${classes.find((name) => /^hover:bg-/.test(name))} "${text}"`);
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
return [...new Map(findings.map((item) => [item.replace(/ ".*$/, ""), item])).values()].slice(0, 4).map((item) => `${item} (cùng kiểu: ${findings.filter((other) => other.replace(/ ".*$/, "") === item.replace(/ ".*$/, "")).length})`);
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
function findHeavyNavLinks() {
|
|
3289
|
+
// Cột điều hướng nhận bằng hình, không bằng thẻ (sidebar hay dựng bằng div): từ 4 link cùng mép trái, cùng
|
|
3290
|
+
// bề rộng 150–360px, xếp dọc.
|
|
3291
|
+
const columns = new Map();
|
|
3292
|
+
for (const link of document.querySelectorAll("a[href], button")) {
|
|
3293
|
+
const rect = link.getBoundingClientRect();
|
|
3294
|
+
if (rect.width < 150 || rect.width > 360 || rect.height === 0 || rect.height > 64 || (link.textContent || "").trim().length < 2) continue;
|
|
3295
|
+
const key = `${Math.round(rect.left / 2)}-${Math.round(rect.width / 2)}`;
|
|
3296
|
+
if (!columns.has(key)) columns.set(key, []);
|
|
3297
|
+
columns.get(key).push(link);
|
|
3298
|
+
}
|
|
3299
|
+
const findings = [];
|
|
3300
|
+
for (const links of columns.values()) {
|
|
3301
|
+
const plainLinks = links.filter((link) => link.getAttribute("aria-current") !== "page");
|
|
3302
|
+
if (plainLinks.length < 4) continue;
|
|
3303
|
+
// Mục sát nhau: khoảng giữa hai mục kề nhau (cùng nhóm, dưới 12px) nhỏ hơn 3px, nền rê gần dính (gap-1, 30/09/2026).
|
|
3304
|
+
const sortedLinks = [...links].sort((first, second) => first.getBoundingClientRect().top - second.getBoundingClientRect().top);
|
|
3305
|
+
const linkGaps = sortedLinks.slice(1).map((link, index) => link.getBoundingClientRect().top - sortedLinks[index].getBoundingClientRect().bottom).filter((gap) => gap >= 0 && gap < 12);
|
|
3306
|
+
if (linkGaps.length >= 3 && Math.max(...linkGaps) < 3) findings.push(`${links.length} mục cách nhau ${Math.round(Math.max(...linkGaps))}px, cần gap-1 (4px): "${links[0].textContent.trim()}"…`);
|
|
3307
|
+
const heavyLinks = plainLinks.filter((link) => parseFloat(getComputedStyle(link).fontWeight) >= 600);
|
|
3308
|
+
if (heavyLinks.length / plainLinks.length < 0.6) continue;
|
|
3309
|
+
findings.push(`${heavyLinks.length}/${plainLinks.length} mục chữ ${getComputedStyle(heavyLinks[0]).fontWeight}: "${heavyLinks.slice(0, 3).map((link) => link.textContent.trim()).join('", "')}"`);
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
return findings.slice(0, 3);
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
// Ảnh không tải được: link Unsplash hay avatar bịa `id`, host chưa khai trong `next.config` (SKILL.md `S16`).
|
|
3316
|
+
// Chỉ tính ảnh đã tải xong (`complete`), ảnh `loading="lazy"` ngoài màn thì chưa tải, không tính.
|
|
3317
|
+
function findBrokenImages() {
|
|
3318
|
+
return [...document.images]
|
|
3319
|
+
.filter((image) => image.complete && image.naturalWidth === 0 && (image.currentSrc || image.src))
|
|
3320
|
+
.slice(0, 6)
|
|
3321
|
+
.map((image) => (image.currentSrc || image.src).slice(0, 120));
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
// Số viết sai kiểu tiếng Việt (T28): dấu chấm làm dấu thập phân trước đơn vị ("4.5 triệu"), hay số chưa làm
|
|
3325
|
+
// tròn ("3.333333 triệu"). Chỉ đo trang tiếng Việt (có chữ có dấu). Đã dính 30/09/2026, tim-phong-sua.
|
|
3326
|
+
function findMisformattedNumbers() {
|
|
3327
|
+
const bodyText = document.body.innerText;
|
|
3328
|
+
if (!/[ăâđêôơưạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ]/i.test(bodyText)) return [];
|
|
3329
|
+
const findings = new Set();
|
|
3330
|
+
for (const match of bodyText.matchAll(/\d+[.,]\d{4,}(?:\s*(?:triệu|tỷ|nghìn|%|đ|₫))?/g)) findings.add(`chưa làm tròn: "${match[0]}"`);
|
|
3331
|
+
for (const match of bodyText.matchAll(/\b\d{1,3}\.\d{1,2}\s*(?:triệu|tỷ|nghìn|tr\b|%)/g)) findings.add(`dấu chấm thập phân: "${match[0]}" (tiếng Việt viết "${match[0].replace(".", ",")}")`);
|
|
3332
|
+
|
|
3333
|
+
return [...findings].slice(0, 5);
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
// Thanh cuộn khác khối scrollbar của tokens.css: rộng quá 4px, hoặc thumb tô màu đặc lúc đứng yên (luôn
|
|
3337
|
+
// hiện). Đọc luật CSS vì pseudo-element `::-webkit-scrollbar` không đọc được bằng getComputedStyle.
|
|
3338
|
+
function findScrollbarStyles() {
|
|
3339
|
+
const findings = new Set();
|
|
3340
|
+
const isOpaque = (value) => Boolean(value) && !/transparent|var\(|rgba\([^)]*,\s*0\)|0 0/.test(value);
|
|
3341
|
+
const walk = (rules) => {
|
|
3342
|
+
for (const rule of rules) {
|
|
3343
|
+
const selector = rule.selectorText || "";
|
|
3344
|
+
if (selector.includes("::-webkit-scrollbar")) {
|
|
3345
|
+
const isBar = /::-webkit-scrollbar(?![-\w])/.test(selector);
|
|
3346
|
+
const width = parseFloat(rule.style.getPropertyValue("width"));
|
|
3347
|
+
if (isBar && width > 4) findings.add(`thanh rộng ${width}px (\`${selector}\`)`);
|
|
3348
|
+
const background = rule.style.getPropertyValue("background") || rule.style.getPropertyValue("background-color");
|
|
3349
|
+
if (selector.includes("-thumb") && !selector.includes(":hover") && isOpaque(background)) findings.add(`thumb luôn hiện màu ${background} (\`${selector}\`)`);
|
|
3350
|
+
}
|
|
3351
|
+
if (rule.cssRules) walk(rule.cssRules);
|
|
3352
|
+
}
|
|
3353
|
+
};
|
|
3354
|
+
for (const sheet of document.styleSheets) {
|
|
3355
|
+
try {
|
|
3356
|
+
walk(sheet.cssRules);
|
|
3357
|
+
} catch {
|
|
3358
|
+
// Stylesheet khác origin không đọc được luật, bỏ qua.
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
return [...findings].slice(0, 4);
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
// ---------- Lớp nổi mở bằng nút thường, ở màn hẹp ----------
|
|
3366
|
+
|
|
3367
|
+
// Hộp chọn, menu tự dựng thường không có aria-haspopup nên mục trên không mở tới (sót menu tràn mép và
|
|
3368
|
+
// hộp chọn cao quá màn, 27/09/2026, dự án mồi phase 2). Chỉ bấm thứ TRÔNG NHƯ nút mở: có aria-expanded /
|
|
3369
|
+
// aria-controls, nhãn kiểu "menu", "lọc", icon kiểu chuông, ba chấm, chevron. Nút chỉ có icon không nhãn
|
|
3370
|
+
// và dòng `div` bấm được (onClick) cũng tính, vì app thật hay viết vậy (nút chỉ có icon, dòng chọn có dấu ">" ở
|
|
3371
|
+
// vòng 2 dự án mồi). Bỏ mọi thứ có nhãn hay icon hành động để không lỡ tay xoá, lưu, gửi trên app thật. Link thì
|
|
3372
|
+
// không bấm. Bấm xong mỗi thứ thì tải lại trang cho sạch.
|
|
3373
|
+
// Nút tạo mới ("Tạo phiếu", "Thêm sản phẩm", icon dấu cộng) gần như luôn mở form trong dialog hay sheet, và
|
|
3374
|
+
// form chỉ ghi khi bấm nút gửi bên trong, nên cũng bấm (sót dialog tràn mép ở 375, 30/09/2026, dự án mồi kho
|
|
3375
|
+
// hàng vòng 2). Nút dấu cộng nằm trong hàng số lượng, giỏ hàng thì vẫn không bấm.
|
|
3376
|
+
const openerLabelSource = "menu|lọc|filter|thông báo|notification|chọn|select|sắp xếp|sort|tuỳ chọn|tùy chọn|option|more|tài khoản|account|ngôn ngữ|language";
|
|
3377
|
+
const createLabelSource = "^(tạo|thêm|new|create|add)\\b";
|
|
3378
|
+
const createIconSource = "lucide-(plus|circle-plus|square-plus)\\b";
|
|
3379
|
+
const skipCreateLabelSource = "giỏ|cart|số lượng|quantity|tăng|increase";
|
|
3380
|
+
const actionLabelSource = "xoá|xóa|delete|remove|huỷ|hủy|cancel|đăng xuất|logout|sign out|gửi|send|submit|thanh toán|pay|mua|buy|lưu|save|đặt|thích|like|theo dõi|follow";
|
|
3381
|
+
// Tên icon lucide (class `lucide-<tên>`): nhóm mở lớp nổi, và nhóm hành động không được bấm.
|
|
3382
|
+
const openerIconSource = "lucide-(bell|menu|ellipsis|more-|filter|list-filter|sliders|chevron-down|chevron-right|chevrons-up-down|circle-user|user-round|user\\b|settings|globe|languages|calendar)";
|
|
3383
|
+
const actionIconSource = "lucide-(trash|heart|star|bookmark|send|save|check|plus|x\\b|log-out|share|copy|download|upload|thumbs)";
|
|
3384
|
+
const maxOpenerButtons = 10;
|
|
3385
|
+
|
|
3386
|
+
function markOpenerButtons({ limit, openerSource, actionSource, openerIconPattern, actionIconPattern, createSource, createIconPattern, skipCreateSource }) {
|
|
3387
|
+
const openerPattern = new RegExp(openerSource, "i");
|
|
3388
|
+
const actionPattern = new RegExp(actionSource, "i");
|
|
3389
|
+
const openerIcon = new RegExp(openerIconPattern);
|
|
3390
|
+
const actionIcon = new RegExp(actionIconPattern);
|
|
3391
|
+
const createPattern = new RegExp(createSource, "i");
|
|
3392
|
+
const createIcon = new RegExp(createIconPattern);
|
|
3393
|
+
const skipCreatePattern = new RegExp(skipCreateSource, "i");
|
|
3394
|
+
const openers = [];
|
|
3395
|
+
const seenKeys = new Set();
|
|
3396
|
+
|
|
3397
|
+
const candidates = document.querySelectorAll("button, [role='button'], div[class*='cursor-pointer'], li[class*='cursor-pointer']");
|
|
3398
|
+
for (const candidate of candidates) {
|
|
3399
|
+
if (openers.length >= limit) break;
|
|
3400
|
+
if (candidate.closest("a[href]") || candidate.getBoundingClientRect().width === 0) continue;
|
|
3401
|
+
if (candidate.disabled || (candidate.type === "submit" && candidate.form) || candidate.hasAttribute("aria-haspopup")) continue;
|
|
3402
|
+
// Khối bấm được mà bọc cả nút khác bên trong (card) thì không phải một nút mở.
|
|
3403
|
+
if (candidate.tagName !== "BUTTON" && candidate.querySelector("button, a[href]")) continue;
|
|
3404
|
+
|
|
3405
|
+
const label = `${candidate.getAttribute("aria-label") || ""} ${candidate.getAttribute("title") || ""} ${candidate.textContent || ""}`.replace(/\s+/g, " ").trim();
|
|
3406
|
+
const iconNames = [...candidate.querySelectorAll("svg")].map((icon) => icon.getAttribute("class") || "").join(" ");
|
|
3407
|
+
const isCreateButton = (createPattern.test(label) || (createIcon.test(iconNames) && label.length > 0)) && !actionPattern.test(label) && !skipCreatePattern.test(label);
|
|
3408
|
+
if (!isCreateButton && (actionPattern.test(label) || actionIcon.test(iconNames))) continue;
|
|
3409
|
+
|
|
3410
|
+
const hasOpenerIcon = openerIcon.test(iconNames) || /[▾▼⌄›>]\s*$/.test(label);
|
|
3411
|
+
const looksLikeOpener = isCreateButton || candidate.hasAttribute("aria-expanded") || candidate.hasAttribute("aria-controls") || hasOpenerIcon || openerPattern.test(label);
|
|
3412
|
+
if (!looksLikeOpener) continue;
|
|
3413
|
+
|
|
3414
|
+
const key = `${candidate.tagName}|${candidate.getAttribute("class") || ""}|${label.slice(0, 12)}`;
|
|
3415
|
+
if (seenKeys.has(key)) continue;
|
|
3416
|
+
seenKeys.add(key);
|
|
3417
|
+
|
|
3418
|
+
candidate.dataset.evonOpenerId = String(openers.length);
|
|
3419
|
+
openers.push(label.slice(0, 30) || `${candidate.tagName.toLowerCase()} chỉ có icon (${(iconNames.match(/lucide-[a-z-]+/) || ["?"])[0]})`);
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
return openers;
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
// Panel nằm sẵn ngoài màn (`-translate-x-full`) hay lớp phủ `opacity-0` chưa tính là đang hiện: bấm ☰ thì
|
|
3426
|
+
// sidebar trượt vào là lớp mới (sót 01/10/2026, lop-hoc: probe bấm ☰ mà không thấy panel nào mở).
|
|
3427
|
+
function markVisibleLayers() {
|
|
3428
|
+
for (const node of document.querySelectorAll("body *")) {
|
|
3429
|
+
const style = getComputedStyle(node);
|
|
3430
|
+
const isPositioned = style.position === "fixed" || style.position === "absolute";
|
|
3431
|
+
const rect = node.getBoundingClientRect();
|
|
3432
|
+
const isOnScreen = rect.right > 0 && rect.left < window.innerWidth && rect.bottom > 0 && rect.top < window.innerHeight;
|
|
3433
|
+
if (isPositioned && rect.width * rect.height > 0 && isOnScreen && Number(style.opacity) > 0 && style.visibility !== "hidden" && style.display !== "none") node.dataset.evonBefore = "1";
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
// Lớp mới hiện sau khi bấm: lòi khỏi mép trái / phải, hay (với lớp fixed) cao quá màn mà không có khung
|
|
3438
|
+
// nào cuộn được để kéo phần bị mất vào.
|
|
3439
|
+
function findOpenedLayerProblems(triggerLabel) {
|
|
3440
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
3441
|
+
const viewportHeight = window.innerHeight;
|
|
3442
|
+
const problems = [];
|
|
3443
|
+
|
|
3444
|
+
function isShown(node) {
|
|
3445
|
+
const style = getComputedStyle(node);
|
|
3446
|
+
const rect = node.getBoundingClientRect();
|
|
3447
|
+
|
|
3448
|
+
return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity) > 0 && rect.width * rect.height >= 5000;
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
function hasScrollerBetween(node, root, axis) {
|
|
3452
|
+
for (let current = node; current; current = current.parentElement) {
|
|
3453
|
+
const style = getComputedStyle(current);
|
|
3454
|
+
const overflow = axis === "x" ? style.overflowX : style.overflowY;
|
|
3455
|
+
const canScroll = axis === "x" ? current.scrollWidth > current.clientWidth + 1 : current.scrollHeight > current.clientHeight + 1;
|
|
3456
|
+
if (["auto", "scroll"].includes(overflow) && canScroll) return true;
|
|
3457
|
+
if (axis === "x" && ["hidden", "clip"].includes(overflow) && current !== node) return true;
|
|
3458
|
+
if (current === root) break;
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
return false;
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
const newLayers = [...document.querySelectorAll("body *")].filter((node) => {
|
|
3465
|
+
const style = getComputedStyle(node);
|
|
3466
|
+
|
|
3467
|
+
const rect = node.getBoundingClientRect();
|
|
3468
|
+
// Panel vẫn nằm ngoài màn sau khi bấm (sidebar `-translate-x-full` lúc bấm nút khác) chưa phải lớp vừa mở.
|
|
3469
|
+
const isOnScreen = rect.right > 0 && rect.left < viewportWidth && rect.bottom > 0 && rect.top < viewportHeight;
|
|
3470
|
+
|
|
3471
|
+
return !node.dataset.evonBefore && (style.position === "fixed" || style.position === "absolute") && isOnScreen && isShown(node);
|
|
3472
|
+
});
|
|
3473
|
+
const roots = newLayers.filter((node) => !newLayers.some((other) => other !== node && other.contains(node)));
|
|
3474
|
+
const flatLayers = [];
|
|
3475
|
+
|
|
3476
|
+
// Tầm bóng: |y| + blur + spread của lớp xa nhất còn màu. So thang bóng (P7): modal, panel > dropdown > card.
|
|
3477
|
+
function readShadowReach(node) {
|
|
3478
|
+
let reach = 0;
|
|
3479
|
+
for (const layer of getComputedStyle(node).boxShadow.split(/,(?![^(]*\))/)) {
|
|
3480
|
+
if (layer.trim() === "none" || layer.includes("inset")) continue;
|
|
3481
|
+
const color = layer.match(/rgba?\(([^)]+)\)/);
|
|
3482
|
+
const alpha = color ? Number(color[1].split(/[ ,/]+/).filter(Boolean)[3] ?? 1) : 1;
|
|
3483
|
+
if (alpha === 0) continue;
|
|
3484
|
+
const lengths = (layer.replace(/rgba?\([^)]+\)/, "").match(/-?[\d.]+px/g) || []).map((value) => Number.parseFloat(value));
|
|
3485
|
+
reach = Math.max(reach, Math.abs(lengths[1] || 0) + (lengths[2] || 0) + (lengths[3] || 0));
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
return reach;
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
function isOpaque(node) {
|
|
3492
|
+
const color = getComputedStyle(node).backgroundColor.match(/rgba?\(([^)]+)\)/);
|
|
3493
|
+
if (!color) return false;
|
|
3494
|
+
const alpha = Number(color[1].split(/[ ,/]+/).filter(Boolean)[3] ?? 1);
|
|
3495
|
+
|
|
3496
|
+
return alpha >= 0.9;
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
// Bóng lớn nhất của khối nằm trong trang (card), để biết dự án có dùng bóng làm thứ bậc không.
|
|
3500
|
+
let pageReach = 0;
|
|
3501
|
+
for (const node of document.querySelectorAll("body *")) {
|
|
3502
|
+
if (newLayers.some((layer) => layer.contains(node))) continue;
|
|
3503
|
+
const rect = node.getBoundingClientRect();
|
|
3504
|
+
if (rect.width * rect.height < 10000 || !isOpaque(node)) continue;
|
|
3505
|
+
// Chỉ khối nằm trong luồng trang: panel `fixed` đang ẩn ngoài màn không phải card.
|
|
3506
|
+
let isInLayer = false;
|
|
3507
|
+
for (let current = node; current && !isInLayer; current = current.parentElement) isInLayer = getComputedStyle(current).position === "fixed";
|
|
3508
|
+
if (isInLayer) continue;
|
|
3509
|
+
pageReach = Math.max(pageReach, readShadowReach(node));
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
for (const root of roots) {
|
|
3513
|
+
// Panel trượt, modal: khối nền đặc cao từ 60% màn hoặc role dialog. Phủ kín cả màn (sheet toàn màn) thì
|
|
3514
|
+
// không có gì phía sau để tách, bỏ qua. Lớp phủ bán trong suốt không phải khối nền đặc.
|
|
3515
|
+
const panel = [root, ...root.querySelectorAll("*")].find((box) => {
|
|
3516
|
+
const rect = box.getBoundingClientRect();
|
|
3517
|
+
const isTall = rect.height >= viewportHeight * 0.6 || ["dialog", "alertdialog"].includes(box.getAttribute("role")) || box.getAttribute("aria-modal") === "true";
|
|
3518
|
+
const isFullScreen = rect.width >= viewportWidth * 0.98 && rect.height >= viewportHeight * 0.98;
|
|
3519
|
+
|
|
3520
|
+
return isShown(box) && isOpaque(box) && isTall && !isFullScreen;
|
|
3521
|
+
});
|
|
3522
|
+
if (panel) {
|
|
3523
|
+
const panelReach = Math.max(readShadowReach(panel), readShadowReach(root));
|
|
3524
|
+
const name = `${panel.tagName.toLowerCase()}${panel.getAttribute("aria-label") ? ` "${panel.getAttribute("aria-label")}"` : ""}`;
|
|
3525
|
+
if (panelReach === 0) flatLayers.push(`bấm "${triggerLabel}": ${name} không có bóng (panel trượt, modal: shadow-modal, layouts/overlay.md)`);
|
|
3526
|
+
else if (pageReach > 0 && panelReach <= pageReach) flatLayers.push(`bấm "${triggerLabel}": bóng ${name} tầm ${Math.round(panelReach)}px không hơn bóng card ${Math.round(pageReach)}px (thang bóng P7)`);
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
for (const root of roots) {
|
|
3531
|
+
const isFixed = getComputedStyle(root).position === "fixed";
|
|
3532
|
+
const boxes = [root, ...root.querySelectorAll("*")].filter(isShown);
|
|
3533
|
+
|
|
3534
|
+
const sideOverflow = boxes.find((box) => {
|
|
3535
|
+
const rect = box.getBoundingClientRect();
|
|
3536
|
+
|
|
3537
|
+
const isInsideScroller = box !== root && hasScrollerBetween(box.parentElement, root, "x");
|
|
3538
|
+
|
|
3539
|
+
return (rect.left < -1 || rect.right > viewportWidth + 1) && !isInsideScroller;
|
|
3540
|
+
});
|
|
3541
|
+
if (sideOverflow) {
|
|
3542
|
+
const rect = sideOverflow.getBoundingClientRect();
|
|
3543
|
+
const overflow = Math.round(Math.max(-rect.left, rect.right - viewportWidth));
|
|
3544
|
+
problems.push(`bấm "${triggerLabel}": lớp nổi rộng ${Math.round(rect.width)}px lòi ${overflow}px khỏi mép màn`);
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
if (!isFixed) continue;
|
|
3548
|
+
const tallBox = boxes.find((box) => {
|
|
3549
|
+
const rect = box.getBoundingClientRect();
|
|
3550
|
+
|
|
3551
|
+
return (rect.top < -1 || rect.bottom > viewportHeight + 1) && !hasScrollerBetween(box, root, "y");
|
|
3552
|
+
});
|
|
3553
|
+
if (tallBox) {
|
|
3554
|
+
const rect = tallBox.getBoundingClientRect();
|
|
3555
|
+
const hidden = Math.round(Math.max(0, -rect.top) + Math.max(0, rect.bottom - viewportHeight));
|
|
3556
|
+
problems.push(`bấm "${triggerLabel}": hộp cao ${Math.round(rect.height)}px trên màn ${viewportHeight}px, mất ${hidden}px mà không cuộn được`);
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
return { problems, flatLayers, openedCount: roots.length };
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
async function reloadForProbe(page, options) {
|
|
3564
|
+
await page.goto(options.url, { waitUntil: "load" });
|
|
3565
|
+
await page.addStyleTag({ content: freezeMotionCss });
|
|
3566
|
+
if (options.isDark) await page.evaluate(() => document.documentElement.classList.add("dark"));
|
|
3567
|
+
await page.waitForTimeout(options.waitMs);
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
async function probeOpenerLayers(page, options, width) {
|
|
3571
|
+
const markArgs = {
|
|
3572
|
+
limit: maxOpenerButtons,
|
|
3573
|
+
openerSource: openerLabelSource,
|
|
3574
|
+
actionSource: actionLabelSource,
|
|
3575
|
+
openerIconPattern: openerIconSource,
|
|
3576
|
+
actionIconPattern: actionIconSource,
|
|
3577
|
+
createSource: createLabelSource,
|
|
3578
|
+
createIconPattern: createIconSource,
|
|
3579
|
+
skipCreateSource: skipCreateLabelSource,
|
|
3580
|
+
};
|
|
3581
|
+
const openerLabels = await page.evaluate(markOpenerButtons, markArgs);
|
|
3582
|
+
const problems = [];
|
|
3583
|
+
const flatLayers = [];
|
|
3584
|
+
const openedShots = [];
|
|
3585
|
+
const nativeControls = new Set();
|
|
3586
|
+
|
|
3587
|
+
for (const [index, triggerLabel] of openerLabels.entries()) {
|
|
3588
|
+
await reloadForProbe(page, options);
|
|
3589
|
+
await page.evaluate(markOpenerButtons, markArgs);
|
|
3590
|
+
await page.evaluate(markVisibleLayers);
|
|
3591
|
+
const isClicked = await page.locator(`[data-evon-opener-id="${index}"]`).click({ timeout: 800, force: true }).then(() => true, () => false);
|
|
3592
|
+
if (!isClicked) continue;
|
|
3593
|
+
await page.waitForTimeout(300);
|
|
3594
|
+
|
|
3595
|
+
const opened = await page.evaluate(findOpenedLayerProblems, triggerLabel);
|
|
3596
|
+
if (opened.openedCount === 0) continue;
|
|
3597
|
+
for (const control of await page.evaluate(findNativeControls, "opened")) nativeControls.add(`bấm "${triggerLabel}": ${control}`);
|
|
3598
|
+
const shotPath = join(options.out, `${width}${options.isDark ? "-dark" : ""}-mo-${index}.png`);
|
|
3599
|
+
await page.screenshot({ path: shotPath });
|
|
3600
|
+
openedShots.push(`"${triggerLabel}": ${shotPath}`);
|
|
3601
|
+
problems.push(...opened.problems);
|
|
3602
|
+
flatLayers.push(...opened.flatLayers);
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
if (openerLabels.length > 0) await reloadForProbe(page, options);
|
|
3606
|
+
|
|
3607
|
+
return { problems, flatLayers, openedShots, nativeControls: [...nativeControls] };
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
// Kéo bề rộng từ lớn xuống nhỏ trên cùng một trang, đo nhẹ và chụp ở từng bước. Cửa sổ desktop suốt
|
|
3611
|
+
// lượt quét (không giả lập màn chạm): lượt này chỉ tìm chỗ vỡ bố cục, cỡ bấm đã đo ở các khổ cố định.
|
|
3612
|
+
async function sweepWidths(browser, options) {
|
|
3613
|
+
const { from, to, step } = options.sweep;
|
|
3614
|
+
const context = await browser.newContext({
|
|
3615
|
+
viewport: { width: from, height: 900 },
|
|
3616
|
+
deviceScaleFactor: 1,
|
|
3617
|
+
colorScheme: options.isDark ? "dark" : "light",
|
|
3618
|
+
});
|
|
3619
|
+
const page = await context.newPage();
|
|
3620
|
+
const sweepDir = join(options.out, options.isDark ? "sweep-dark" : "sweep");
|
|
3621
|
+
mkdirSync(sweepDir, { recursive: true });
|
|
3622
|
+
|
|
3623
|
+
await page.goto(options.url, { waitUntil: "load" });
|
|
3624
|
+
await page.addStyleTag({ content: freezeMotionCss });
|
|
3625
|
+
if (options.isDark) await page.evaluate(() => document.documentElement.classList.add("dark"));
|
|
3626
|
+
await page.waitForTimeout(options.waitMs);
|
|
3627
|
+
|
|
3628
|
+
const sweepWidthList = [];
|
|
3629
|
+
for (let width = from; width > to; width -= step) sweepWidthList.push(width);
|
|
3630
|
+
sweepWidthList.push(to);
|
|
3631
|
+
|
|
3632
|
+
const steps = [];
|
|
3633
|
+
for (const width of sweepWidthList) {
|
|
3634
|
+
await page.setViewportSize({ width, height: 900 });
|
|
3635
|
+
await page.waitForTimeout(150);
|
|
3636
|
+
const measurements = await page.evaluate(measureInPage, { minTapSize, isMobile: false, isSweep: true });
|
|
3637
|
+
const screenshotPath = join(sweepDir, `${width}.png`);
|
|
3638
|
+
await takeFullScreenshot(page, screenshotPath);
|
|
3639
|
+
steps.push({ width, screenshotPath, ...measurements });
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
await context.close();
|
|
3643
|
+
|
|
3644
|
+
return steps;
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
// Gom bề rộng liền bước thành khoảng: [1000, 980, 960, 700] → "960–1000px, 700px".
|
|
3648
|
+
function formatWidthRanges(widths, step) {
|
|
3649
|
+
const sortedWidths = [...new Set(widths)].sort((first, second) => second - first);
|
|
3650
|
+
const ranges = [];
|
|
3651
|
+
for (const width of sortedWidths) {
|
|
3652
|
+
const lastRange = ranges.at(-1);
|
|
3653
|
+
if (lastRange && lastRange.low - width <= step) lastRange.low = width;
|
|
3654
|
+
else ranges.push({ low: width, high: width });
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
return ranges.map((range) => (range.low === range.high ? `${range.low}px` : `${range.low}–${range.high}px`)).join(", ");
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
// Mọi thứ probe đo ra mà V1 (references/review.md) xếp Hỏng, gộp theo phần tử qua các khổ và lượt quét,
|
|
3661
|
+
// đánh mã P1, P2… để bảng giao đối chiếu. Vòng 1 và 2 của dự án mồi phase 2: nhiều mục probe đã đo ra mà
|
|
3662
|
+
// bảng giao không có, vì các mục nằm rải trong báo cáo dài.
|
|
3663
|
+
function listMustReportItems(results, sweepSteps) {
|
|
3664
|
+
const itemsByKey = new Map();
|
|
3665
|
+
function addItem(width, text) {
|
|
3666
|
+
if (!itemsByKey.has(text)) itemsByKey.set(text, []);
|
|
3667
|
+
itemsByKey.get(text).push(width);
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
for (const result of results) {
|
|
3671
|
+
const width = result.width;
|
|
3672
|
+
for (const area of result.autoScrolledAreas) addItem(width, `trang tự cuộn khi vừa tải: ${area.replace(/ đã cuộn \d+px$/, "")}`);
|
|
3673
|
+
if (result.hasHorizontalScroll) addItem(width, `cuộn ngang, lòi ra: ${result.overflowingElements[0]?.element ?? "(không rõ phần tử)"}`);
|
|
3674
|
+
for (const layer of result.overflowingLayers) addItem(width, `lớp nổi lòi khỏi màn: ${layer.replace(/ lòi \d+px khỏi màn$/, "")}`);
|
|
3675
|
+
for (const problem of result.openerLayerProblems) addItem(width, `lớp nổi mở bằng nút bị vỡ: ${problem}`);
|
|
3676
|
+
for (const shift of result.layoutShifts) addItem(width, `rê chuột làm nhảy bố cục: ${shift.replace(/ dời \d+px$/, "")}`);
|
|
3677
|
+
for (const line of result.lowContrastTexts) addItem(width, `tương phản thấp: ${line}`);
|
|
3678
|
+
for (const item of result.clippedBlocks) addItem(width, `khung giấu mất chữ: ${item.element}`);
|
|
3679
|
+
for (const item of result.tooShortTexts) addItem(width, `chữ cắt còn quá ngắn: ${item.element}`);
|
|
3680
|
+
for (const element of result.wrappedControls) addItem(width, `chữ trong nút xuống dòng: ${element}`);
|
|
3681
|
+
for (const element of result.wrappedRows) addItem(width, `hàng rớt dòng (xem ảnh để xếp hạng): ${element}`);
|
|
3682
|
+
for (const item of result.overlappedChartLabels) addItem(width, `nhãn số đè lên đường biểu đồ: ${item}`);
|
|
3683
|
+
for (const item of result.iconCoveringBadges || []) addItem(width, `badge đè mất icon: ${item}`);
|
|
3684
|
+
for (const item of result.squeezedBlocks) addItem(width, `khối bị bóp chiều cao: ${item}`);
|
|
3685
|
+
for (const item of result.squeezedTableColumns || []) addItem(width, `cột chữ của bảng bị ép: ${item.replace(/ rộng \d+px, \d+\/\d+ dòng/, "")}`);
|
|
3686
|
+
for (const item of result.crampedDescriptionLists || []) addItem(width, `nhãn–giá trị hai cột trong khối hẹp: ${item.replace(/^<dl> \d+px, .*?: /, "")}`);
|
|
3687
|
+
for (const item of result.mismatchedRuleColors) addItem(width, `đường ngăn thẳng hàng mà khác màu: ${item}`);
|
|
3688
|
+
// Khoá theo phần tử: hẹp dần thì phần bị giấu đổi, ô vẫn là một.
|
|
3689
|
+
for (const item of result.swallowedNumbers) addItem(width, `chữ cắt nuốt mất số: ${item.replace(/^giấu ".*?" của /, "")}`);
|
|
3690
|
+
for (const item of result.hoverLikeSelected || []) addItem(width, `rê ra đúng màu mục đang chọn: ${item}`);
|
|
3691
|
+
for (const item of result.checkedHoverChanges || []) addItem(width, `rê vào ô đã chọn làm mất màu nhấn: ${item}`);
|
|
3692
|
+
for (const item of result.mouseUnreachableScrollers || []) addItem(width, `hàng cuộn ngang chuột không tới được: ${item.replace(/, nội dung .*?: /, ": ")}`);
|
|
3693
|
+
// Nền rê yếu: cổng 3 lúc dựng phải sửa, bảng soi thì xếp Gu (review.md, sau bảng V1, 30/09/2026).
|
|
3694
|
+
for (const item of result.weakHovers || []) addItem(width, `nền rê gần như không thấy (soi: Gu): ${item}`);
|
|
3695
|
+
// Nút viền rê thành nút đặc cùng màu viền là kiểu hay gặp, không vỡ gì: giữ ở Gu.
|
|
3696
|
+
for (const item of (result.blendedHovers || []).filter((line) => !line.includes("trùng màu viền"))) addItem(width, `nền rê tan vào nền khác (soi: Gu): ${item}`);
|
|
3697
|
+
// Dark mode làm dở là Hỏng (V4): mảng sáng giữa màn tối. Trang không lật thì dark mode chỉ có khai
|
|
3698
|
+
// báo, V4 bảo bỏ lượt tối chứ không báo lỗi, nên không vào đây.
|
|
3699
|
+
for (const item of (result.darkModeProblems || []).filter((line) => /^(mảng nền sáng|lớp phủ sáng mờ)/.test(line))) addItem(width, `dark mode làm dở: ${item.replace(/, \d+×\d+px/, "")}`);
|
|
3700
|
+
for (const item of result.untransitionedMotion || []) addItem(width, `scale / translate / rotate không chạy chuyển động: ${item}`);
|
|
3701
|
+
for (const item of result.smallTapTargets.filter((target) => target.isBelowFloor)) addItem(width, `chỗ bấm dưới 24px: ${item.element}`);
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
for (const step of sweepSteps) {
|
|
3705
|
+
if (step.hasHorizontalScroll) addItem(step.width, `cuộn ngang, lòi ra: ${step.overflowingElements[0]?.element ?? "(không rõ phần tử)"}`);
|
|
3706
|
+
for (const item of step.clippedBlocks) addItem(step.width, `khung giấu mất chữ: ${item.element}`);
|
|
3707
|
+
for (const element of step.wrappedControls) addItem(step.width, `chữ trong nút xuống dòng: ${element}`);
|
|
3708
|
+
for (const element of step.wrappedRows) addItem(step.width, `hàng rớt dòng (xem ảnh để xếp hạng): ${element}`);
|
|
3709
|
+
for (const item of step.tooShortTexts) addItem(step.width, `chữ cắt còn quá ngắn: ${item.element}`);
|
|
3710
|
+
for (const item of step.swallowedNumbers) addItem(step.width, `chữ cắt nuốt mất số: ${item.replace(/^giấu ".*?" của /, "")}`);
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
return [...itemsByKey.entries()].map(([text, widths]) => ({ text, widths }));
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
function formatMustReportList(items, step) {
|
|
3717
|
+
const lines = [`\n# Việc phải đối chiếu: ${items.length} mục probe xếp Hỏng`];
|
|
3718
|
+
if (items.length === 0) return `${lines[0]}\nKhông có mục nào.`;
|
|
3719
|
+
lines.push("Mỗi mục thành một dòng trong bảng giao (cột Nguồn ghi mã, ví dụ `đo P3`; cùng gốc thì gộp nhiều mã một dòng),");
|
|
3720
|
+
lines.push("hoặc một dòng dưới bảng nói vì sao loại. Không mục nào được biến mất im lặng (V5 trong review.md).");
|
|
3721
|
+
items.slice(0, 50).forEach((item, index) => lines.push(`P${index + 1} [${formatWidthRanges(item.widths, step)}] ${item.text}`));
|
|
3722
|
+
if (items.length > 50) lines.push(`(Còn ${items.length - 50} mục, xem report.json.)`);
|
|
3723
|
+
|
|
3724
|
+
return lines.join("\n");
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
function listSweepSignals(step) {
|
|
3728
|
+
const signals = [];
|
|
3729
|
+
|
|
3730
|
+
if (step.hasHorizontalScroll) signals.push(`cuộn ngang, lòi ra: ${step.overflowingElements[0]?.element ?? "(không rõ phần tử)"}`);
|
|
3731
|
+
// Khoá theo khung, không theo chữ bị giấu: hẹp dần thì chữ bị giấu đầu tiên đổi, khung vẫn là một.
|
|
3732
|
+
for (const item of step.clippedBlocks) signals.push(`khung giấu mất chữ: ${item.element}`);
|
|
3733
|
+
for (const item of step.wrappedControls) signals.push(`chữ trong nút xuống dòng: ${item}`);
|
|
3734
|
+
for (const item of step.wrappedRows) signals.push(`hàng rớt dòng: ${item}`);
|
|
3735
|
+
for (const item of step.tooShortTexts) signals.push(`chữ cắt còn quá ngắn: ${item.element}`);
|
|
3736
|
+
for (const item of step.swallowedNumbers) signals.push(`chữ cắt nuốt mất số: ${item.replace(/^giấu ".*?" của /, "")}`);
|
|
3737
|
+
|
|
3738
|
+
return signals;
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
function formatSweepReport(steps, step) {
|
|
3742
|
+
const widthsBySignal = new Map();
|
|
3743
|
+
const changedFrames = [];
|
|
3744
|
+
let previousKey = null;
|
|
3745
|
+
|
|
3746
|
+
for (const sweepStep of steps) {
|
|
3747
|
+
const signals = listSweepSignals(sweepStep);
|
|
3748
|
+
for (const signal of signals) {
|
|
3749
|
+
if (!widthsBySignal.has(signal)) widthsBySignal.set(signal, []);
|
|
3750
|
+
widthsBySignal.get(signal).push(sweepStep.width);
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3753
|
+
const signalKey = signals.join("|");
|
|
3754
|
+
if (previousKey !== null && signalKey !== previousKey) changedFrames.push(sweepStep.screenshotPath);
|
|
3755
|
+
previousKey = signalKey;
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
const lines = [`\n# Quét bề rộng ${steps[0].width} → ${steps.at(-1).width}px, bước ${step}px (${steps.length} ảnh ở ${dirname(steps[0].screenshotPath)})`];
|
|
3759
|
+
if (widthsBySignal.size === 0) lines.push("Không đo ra chỗ vỡ ở bề rộng nào. Vẫn mở vài ảnh ở giữa hai khổ cố định mà xem.");
|
|
3760
|
+
for (const [signal, widths] of widthsBySignal) lines.push(`${formatWidthRanges(widths, step)}: ${signal}`);
|
|
3761
|
+
if (changedFrames.length > 0) {
|
|
3762
|
+
lines.push("Khung đáng xem (tín hiệu đổi so với bước trước):");
|
|
3763
|
+
for (const framePath of changedFrames.slice(0, 12)) lines.push(` ${framePath}`);
|
|
3764
|
+
}
|
|
3765
|
+
lines.push("Máy không thấy chồng lấn, lệch hàng, khoảng trắng vô lý. Xem thêm các ảnh quanh ngưỡng sidebar thu và ngưỡng lưới đổi cột.");
|
|
3766
|
+
|
|
3767
|
+
return lines.join("\n");
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
function formatReport(results) {
|
|
3771
|
+
const lines = [];
|
|
3772
|
+
let problemCount = 0;
|
|
3773
|
+
|
|
3774
|
+
for (const result of results) {
|
|
3775
|
+
const problems = [];
|
|
3776
|
+
|
|
3777
|
+
if (result.hasHorizontalScroll) {
|
|
3778
|
+
problems.push(`CUỘN NGANG: trang rộng ${result.pageScrollWidth}px trên màn ${result.viewportWidth}px.`);
|
|
3779
|
+
for (const item of result.overflowingElements) problems.push(` lòi ra tới ${item.right}px: ${item.element}`);
|
|
3780
|
+
}
|
|
3781
|
+
if (result.unpinnedScrollTables.length > 0) {
|
|
3782
|
+
problems.push(`BẢNG CUỘN NGANG MẤT CỘT (${result.unpinnedScrollTables.length} bảng, R9):`);
|
|
3783
|
+
for (const item of result.unpinnedScrollTables.slice(0, 5)) problems.push(` ${item}`);
|
|
3784
|
+
}
|
|
3785
|
+
if (result.squeezedTableColumns.length > 0) {
|
|
3786
|
+
problems.push(`CỘT CHỮ CỦA BẢNG BỊ ÉP XUỐNG DÒNG (${result.squeezedTableColumns.length} cột; ẩn cột phụ theo @container của card, layouts/app.md "Bảng dữ liệu"):`);
|
|
3787
|
+
for (const item of result.squeezedTableColumns.slice(0, 5)) problems.push(` ${item}`);
|
|
3788
|
+
}
|
|
3789
|
+
if (result.crampedDescriptionLists.length > 0) {
|
|
3790
|
+
problems.push(`NHÃN–GIÁ TRỊ HAI CỘT TRONG KHỐI HẸP (${result.crampedDescriptionLists.length} khối; dưới 384px nhãn trên giá trị dưới, @container, description-list.md):`);
|
|
3791
|
+
for (const item of result.crampedDescriptionLists.slice(0, 5)) problems.push(` ${item}`);
|
|
3792
|
+
}
|
|
3793
|
+
if (result.brokenMoney.length > 0) {
|
|
3794
|
+
problems.push(`SỐ TIỀN NGẮT DÒNG (${result.brokenMoney.length} chỗ, số + đơn vị phải nowrap, nhãn bên cạnh co lại, T16):`);
|
|
3795
|
+
for (const item of result.brokenMoney.slice(0, 5)) problems.push(` ${item}`);
|
|
3796
|
+
}
|
|
3797
|
+
if (result.unevenStatRows.length > 0) {
|
|
3798
|
+
problems.push(`SỐ TRONG HÀNG Ô SỐ LIỆU KHÔNG THẲNG (${result.unevenStatRows.length} hàng, ô dùng grid-rows-subgrid):`);
|
|
3799
|
+
for (const item of result.unevenStatRows.slice(0, 5)) problems.push(` ${item}`);
|
|
3800
|
+
}
|
|
3801
|
+
if (result.gridChoiceGroups.length > 0) {
|
|
3802
|
+
problems.push(`NHÓM LỰA CHỌN XẾP LƯỚI (${result.gridChoiceGroups.length} nhóm, đọc chữ Z; một hàng hoặc một cột):`);
|
|
3803
|
+
for (const item of result.gridChoiceGroups.slice(0, 5)) problems.push(` ${item}`);
|
|
3804
|
+
}
|
|
3805
|
+
if (result.consoleErrors.length > 0) {
|
|
3806
|
+
problems.push(`LỖI CONSOLE (${result.consoleErrors.length}):`);
|
|
3807
|
+
for (const message of result.consoleErrors.slice(0, 5)) problems.push(` ${message}`);
|
|
3808
|
+
}
|
|
3809
|
+
if (result.tooShortCount > 0) {
|
|
3810
|
+
problems.push(`CHỮ CẮT CÒN QUÁ NGẮN (${result.tooShortCount} chỗ, dưới 10 ký tự đọc được):`);
|
|
3811
|
+
for (const item of result.tooShortTexts.slice(0, 5)) problems.push(` rộng ${item.width}px, đọc được ~${item.visibleChars}/${item.fullText.length} ký tự "${item.fullText.slice(0, 40)}": ${item.element}`);
|
|
3812
|
+
}
|
|
3813
|
+
if (result.unevenSiblingGroups.length > 0) {
|
|
3814
|
+
problems.push(`CAO GẦN BẰNG MÀ KHÔNG BẰNG (lệch 1-4px, thường là khe baseline hoặc padding lệch):`);
|
|
3815
|
+
for (const item of result.unevenSiblingGroups.slice(0, 5)) problems.push(` ${item.count} phần tử, đa số cao ${item.commonHeight}px, có cái ${item.otherHeights.join(", ")}px: ${item.element}`);
|
|
3816
|
+
}
|
|
3817
|
+
if (result.misalignedColumns.length > 0) {
|
|
3818
|
+
problems.push(`CHỮ CÙNG CỘT LỆCH MÉP (hai kiểu căn trộn nhau):`);
|
|
3819
|
+
for (const item of result.misalignedColumns.slice(0, 5)) problems.push(` lệch ${item.leftSpread}px, ${item.example}: ${item.element}`);
|
|
3820
|
+
}
|
|
3821
|
+
if (result.smallTapCount > 0) {
|
|
3822
|
+
problems.push(`CHỖ BẤM DƯỚI ${minTapSize}px (${result.smallTapCount} chỗ, không có vùng bấm nới ra):`);
|
|
3823
|
+
for (const item of result.smallTapTargets.slice(0, 10)) problems.push(` ${item.size}${item.isBelowFloor ? " (dưới 24px)" : ""}: ${item.element}`);
|
|
3824
|
+
}
|
|
3825
|
+
if (result.orphanPunctuation.length > 0) {
|
|
3826
|
+
problems.push(`DẤU CÂU RƠI XUỐNG ĐẦU DÒNG (${result.orphanPunctuation.length} chỗ, dấu phải dính chữ đứng trước):`);
|
|
3827
|
+
for (const item of result.orphanPunctuation.slice(0, 5)) problems.push(` dòng mở đầu "${item.lineStart}": ${item.element}`);
|
|
3828
|
+
}
|
|
3829
|
+
if (result.orphanWords?.length > 0) {
|
|
3830
|
+
problems.push(`CHỮ ĐƠN CÔI Ở DÒNG CUỐI (${result.orphanWords.length} chỗ, thêm text-pretty, tiêu đề ngắn text-balance, T10):`);
|
|
3831
|
+
for (const item of result.orphanWords.slice(0, 5)) problems.push(` ${item}`);
|
|
3832
|
+
}
|
|
3833
|
+
if (result.misalignedFields.length > 0) {
|
|
3834
|
+
problems.push(`Ô NHẬP LỆCH MÉP VỚI NÚT RỘNG HẾT KHUNG (${result.misalignedFields.length} khung, ô và nút phải cùng mép trái phải):`);
|
|
3835
|
+
for (const item of result.misalignedFields.slice(0, 5)) problems.push(` ô ${item.field}, nút ${item.button}: ${item.element}`);
|
|
3836
|
+
}
|
|
3837
|
+
if (result.unevenSeparatorRows.length > 0) {
|
|
3838
|
+
problems.push(`DẤU NGĂN CÁCH KHÔNG ĐỀU (${result.unevenSeparatorRows.length} hàng, nét dấu › tới nét chữ hay icon kế bên phải bằng nhau):`);
|
|
3839
|
+
for (const item of result.unevenSeparatorRows.slice(0, 5)) problems.push(` khe ${item.gaps}: ${item.element}`);
|
|
3840
|
+
}
|
|
3841
|
+
if ((result.iconCoveringBadges || []).length > 0) {
|
|
3842
|
+
problems.push(`BADGE ĐÈ MẤT ICON (${result.iconCoveringBadges.length} chỗ, dời badge ra góc, cỡ nhỏ lại, hoặc dùng icon đã khoét chỗ như BellDot):`);
|
|
3843
|
+
for (const item of result.iconCoveringBadges) problems.push(` ${item}`);
|
|
3844
|
+
}
|
|
3845
|
+
if (result.overlappedChartLabels.length > 0) {
|
|
3846
|
+
problems.push(`NHÃN SỐ ĐÈ LÊN ĐƯỜNG BIỂU ĐỒ (${result.overlappedChartLabels.length} chỗ, đặt nhãn về phía không có đường):`);
|
|
3847
|
+
for (const element of result.overlappedChartLabels.slice(0, 5)) problems.push(` ${element}`);
|
|
3848
|
+
}
|
|
3849
|
+
if (result.outsideChartLabels.length > 0) {
|
|
3850
|
+
problems.push(`NHÃN SỐ LÒI RA NGOÀI VÙNG VẼ (${result.outsideChartLabels.length} chỗ, rơi vào hàng nhãn trục hoặc trên đỉnh):`);
|
|
3851
|
+
for (const element of result.outsideChartLabels.slice(0, 5)) problems.push(` ${element}`);
|
|
3852
|
+
}
|
|
3853
|
+
if (result.weakHovers.length > 0) {
|
|
3854
|
+
problems.push(`NỀN RÊ GẦN NHƯ KHÔNG THẤY (${result.weakHovers.length} loại, chênh dưới 8 mức với lúc thường):`);
|
|
3855
|
+
for (const item of result.weakHovers.slice(0, 6)) problems.push(` ${item}`);
|
|
3856
|
+
}
|
|
3857
|
+
if (result.blendedHovers.length > 0) {
|
|
3858
|
+
problems.push(`NỀN RÊ TAN VÀO NỀN KHÁC (${result.blendedHovers.length} chỗ):`);
|
|
3859
|
+
for (const item of result.blendedHovers.slice(0, 6)) problems.push(` ${item}`);
|
|
3860
|
+
}
|
|
3861
|
+
if (result.borderHovers.length > 0) {
|
|
3862
|
+
problems.push(`VIỀN ĐỔI MÀU LÚC RÊ (${result.borderHovers.length} loại, skill chỉ đổi nền, xem button.md):`);
|
|
3863
|
+
for (const item of result.borderHovers.slice(0, 6)) problems.push(` ${item}`);
|
|
3864
|
+
}
|
|
3865
|
+
if (result.overflowingLayers.length > 0) {
|
|
3866
|
+
problems.push(`LỚP NỔI LÒI KHỎI MÀN (${result.overflowingLayers.length} chỗ, tooltip / menu / popover mở ra phải nằm trong màn):`);
|
|
3867
|
+
for (const item of result.overflowingLayers.slice(0, 6)) problems.push(` ${item}`);
|
|
3868
|
+
}
|
|
3869
|
+
if (result.checkedHoverChanges?.length > 0) {
|
|
3870
|
+
problems.push(`RÊ VÀO Ô ĐÃ CHỌN LÀM MẤT MÀU NHẤN (${result.checkedHoverChanges.length} ô, hover: đè checked:, dùng not-checked:hover:):`);
|
|
3871
|
+
for (const item of result.checkedHoverChanges) problems.push(` ${item}`);
|
|
3872
|
+
}
|
|
3873
|
+
if (result.hoverLikeSelected?.length > 0) {
|
|
3874
|
+
problems.push(`RÊ RA ĐÚNG MÀU MỤC ĐANG CHỌN (${result.hoverLikeSelected.length} nhóm, rê qua mục nào cũng trông như đã chọn nó):`);
|
|
3875
|
+
for (const item of result.hoverLikeSelected) problems.push(` ${item}`);
|
|
3876
|
+
}
|
|
3877
|
+
if (result.shapeMismatches.length > 0) {
|
|
3878
|
+
problems.push(`RÊ / FOCUS KHÁC HÌNH MỤC ĐANG CHỌN (${result.shapeMismatches.length} chỗ, mọi trạng thái vẽ trên cùng một hình):`);
|
|
3879
|
+
for (const item of result.shapeMismatches.slice(0, 6)) problems.push(` ${item}`);
|
|
3880
|
+
}
|
|
3881
|
+
if (result.stuckStates.length > 0) {
|
|
3882
|
+
problems.push(`BẤM XONG CÒN DẤU THỪA (${result.stuckStates.length} chỗ, chuột đứng yên trên mục vừa chọn):`);
|
|
3883
|
+
for (const item of result.stuckStates.slice(0, 6)) problems.push(` ${item}`);
|
|
3884
|
+
}
|
|
3885
|
+
if (result.unmarkedFocusStops?.length > 0) {
|
|
3886
|
+
problems.push(`TAB TỚI KHÔNG THẤY GÌ (${result.unmarkedFocusStops.length} chỗ, trong khi dự án vẽ vòng focus ở ${result.drawnFocusRings.length} chỗ khác; Lệch hệ, ngoại lệ của I13):`);
|
|
3887
|
+
for (const element of result.unmarkedFocusStops.slice(0, 8)) problems.push(` ${element}`);
|
|
3888
|
+
}
|
|
3889
|
+
if (result.drawnFocusRings?.length > 0) {
|
|
3890
|
+
problems.push(`TAB TỚI CÒN VẼ VÒNG FOCUS (${result.drawnFocusRings.length} chỗ, skill không vẽ vòng focus, I13):`);
|
|
3891
|
+
for (const element of result.drawnFocusRings.slice(0, 8)) problems.push(` ${element}`);
|
|
3892
|
+
}
|
|
3893
|
+
if (result.mismatchedRuleColors.length > 0) {
|
|
3894
|
+
problems.push(`ĐƯỜNG NGĂN HAI CỘT THẲNG HÀNG MÀ KHÁC MÀU (${result.mismatchedRuleColors.length} cặp, một đường mà nửa nhạt nửa đậm):`);
|
|
3895
|
+
for (const item of result.mismatchedRuleColors) problems.push(` ${item}`);
|
|
3896
|
+
}
|
|
3897
|
+
if (result.unevenHeaderActions.length > 0) {
|
|
3898
|
+
problems.push(`HÀNG NÚT TRÊN HEADER KHÔNG ĐỒNG CỠ (layouts/app.md, "Nhóm nút bên phải thanh header"):`);
|
|
3899
|
+
for (const item of result.unevenHeaderActions) problems.push(` ${item}`);
|
|
3900
|
+
}
|
|
3901
|
+
if (result.brokenRules.length > 0) {
|
|
3902
|
+
problems.push(`ĐƯỜNG NGĂN HAI CỘT KỀ NHAU LỆCH (${result.brokenRules.length} cặp, nhìn thành một đường gãy):`);
|
|
3903
|
+
for (const item of result.brokenRules) problems.push(` ${item}`);
|
|
3904
|
+
}
|
|
3905
|
+
if (result.untransitionedMotion?.length > 0) {
|
|
3906
|
+
problems.push(`SCALE / TRANSLATE / ROTATE KHÔNG CHẠY CHUYỂN ĐỘNG (${result.untransitionedMotion.length} chỗ, Tailwind v4: dùng transition-transform hoặc transition-[opacity,scale,translate], W10):`);
|
|
3907
|
+
for (const item of result.untransitionedMotion) problems.push(` ${item}`);
|
|
3908
|
+
}
|
|
3909
|
+
if (result.heavyLayerLines?.length > 0) {
|
|
3910
|
+
problems.push(`KHUNG / VẠCH CỦA LỚP NỔI ĐẬM HƠN TOKEN VIỀN (${result.heavyLayerLines.length} lớp, dùng border-border như mẫu overlay.md):`);
|
|
3911
|
+
for (const item of result.heavyLayerLines) problems.push(` ${item}`);
|
|
3912
|
+
}
|
|
3913
|
+
if (result.motionlessLayers?.length > 0) {
|
|
3914
|
+
problems.push(`LỚP NỔI BẬT TẮT KHÔNG CHUYỂN ĐỘNG (${result.motionlessLayers.length} lớp, \`{isOpen && …}\` hay \`display\` thì hiện tức thì; nhịp theo "Chuyển động" ở layouts/overlay.md):`);
|
|
3915
|
+
for (const item of result.motionlessLayers) problems.push(` ${item}`);
|
|
3916
|
+
}
|
|
3917
|
+
if (result.scrollyLayers?.length > 0) {
|
|
3918
|
+
problems.push(`LỚP NỔI CÓ THANH CUỘN THỪA (${result.scrollyLayers.length} lớp, khung nhỏ hơn nội dung; co theo nội dung, layouts/overlay.md):`);
|
|
3919
|
+
for (const item of result.scrollyLayers) problems.push(` ${item}`);
|
|
3920
|
+
}
|
|
3921
|
+
if (result.lostTriggerIcons?.length > 0) {
|
|
3922
|
+
problems.push(`CHỌN XONG MẤT ICON (${result.lostTriggerIcons.length} ô, vẽ lại nút phải vẽ lại icon):`);
|
|
3923
|
+
for (const item of result.lostTriggerIcons) problems.push(` ${item}`);
|
|
3924
|
+
}
|
|
3925
|
+
if (result.popupNativeChoices?.length > 0) {
|
|
3926
|
+
problems.push(`CONTROL GỐC TRONG LỚP NỔI (${result.popupNativeChoices.length} lớp: select, ô ngày, checkbox, thanh trượt…; chế độ soi: select, ô ngày gốc đã tô thì bỏ qua như trên trang; wireframe và dựng lại: dựng theo components/choice-controls.md, select gốc chỉ giữ khi lớp đó chỉ có trên mobile):`);
|
|
3927
|
+
for (const item of result.popupNativeChoices) problems.push(` ${item}`);
|
|
3928
|
+
}
|
|
3929
|
+
if (result.heavyDecorativeBorders?.length > 0) {
|
|
3930
|
+
problems.push(`VIỀN TRANG TRÍ ĐẬM (${result.heavyDecorativeBorders.length} màu, card và khung nhạt hơn #e4e4e7, bậc xám nhạt nhất của dự án, M14):`);
|
|
3931
|
+
for (const item of result.heavyDecorativeBorders) problems.push(` ${item}`);
|
|
3932
|
+
}
|
|
3933
|
+
if (result.scrollbarStyles?.length > 0) {
|
|
3934
|
+
problems.push(`THANH CUỘN KHÁC MẪU (${result.scrollbarStyles.length} luật, dùng khối scrollbar của tokens.css: 4px, ẩn tới khi rê hay cuộn):`);
|
|
3935
|
+
for (const item of result.scrollbarStyles) problems.push(` ${item}`);
|
|
3936
|
+
}
|
|
3937
|
+
if (result.misformattedNumbers?.length > 0) {
|
|
3938
|
+
problems.push(`SỐ VIẾT SAI KIỂU TIẾNG VIỆT (${result.misformattedNumbers.length} chỗ, dấu phẩy thập phân, làm tròn, T28 trong rules-type.md):`);
|
|
3939
|
+
for (const item of result.misformattedNumbers) problems.push(` ${item}`);
|
|
3940
|
+
}
|
|
3941
|
+
if (result.darkModeProblems?.length > 0) {
|
|
3942
|
+
problems.push(`DARK MODE (${result.darkModeProblems.length} chỗ, rules-color.md M21, M31–M33):`);
|
|
3943
|
+
for (const item of result.darkModeProblems) problems.push(` ${item}`);
|
|
3944
|
+
}
|
|
3945
|
+
if (result.brokenImages?.length > 0) {
|
|
3946
|
+
problems.push(`ẢNH KHÔNG TẢI ĐƯỢC (${result.brokenImages.length} ảnh, thay link khác hoặc khai host trong next.config, SKILL.md S16):`);
|
|
3947
|
+
for (const item of result.brokenImages) problems.push(` ${item}`);
|
|
3948
|
+
}
|
|
3949
|
+
if (result.staticHoverBlocks?.length > 0) {
|
|
3950
|
+
problems.push(`NỀN RÊ TRÊN KHỐI KHÔNG BẤM ĐƯỢC (${result.staticHoverBlocks.length} kiểu, trông như bấm được mà bấm không ra gì; bỏ hover hoặc cho cả khối là link, I9):`);
|
|
3951
|
+
for (const item of result.staticHoverBlocks) problems.push(` ${item}`);
|
|
3952
|
+
}
|
|
3953
|
+
if (result.heavyNavLinks?.length > 0) {
|
|
3954
|
+
problems.push(`SIDEBAR CHỮ ĐẬM HAY MỤC SÁT NHAU (${result.heavyNavLinks.length} chỗ, mục thường chữ 400 text-foreground/70, chỉ mục đang chọn font-medium, các mục cách gap-1, layouts/app.md):`);
|
|
3955
|
+
for (const item of result.heavyNavLinks) problems.push(` ${item}`);
|
|
3956
|
+
}
|
|
3957
|
+
if (result.heavySeparators?.length > 0) {
|
|
3958
|
+
problems.push(`VẠCH CHIA TRONG MENU ĐẬM HƠN VIỀN KHUNG (${result.heavySeparators.length} menu):`);
|
|
3959
|
+
for (const item of result.heavySeparators) problems.push(` ${item}`);
|
|
3960
|
+
}
|
|
3961
|
+
if (result.overlongPlaceholders?.length > 0) {
|
|
3962
|
+
problems.push(`PLACEHOLDER DÀI HƠN Ô (${result.overlongPlaceholders.length} ô, rút chữ cho vừa, ô thật cắt mất đuôi):`);
|
|
3963
|
+
for (const item of result.overlongPlaceholders) problems.push(` ${item}`);
|
|
3964
|
+
}
|
|
3965
|
+
if (result.fakeFieldWraps?.length > 0) {
|
|
3966
|
+
problems.push(`KHỐI TRÔNG NHƯ Ô NHẬP MÀ CHỮ XUỐNG DÒNG (${result.fakeFieldWraps.length} khối, dùng <input> thật theo components/input.md):`);
|
|
3967
|
+
for (const item of result.fakeFieldWraps) problems.push(` ${item}`);
|
|
3968
|
+
}
|
|
3969
|
+
if (result.transparentHeaders?.length > 0) {
|
|
3970
|
+
problems.push(`THANH HEADER TRONG SUỐT TRÊN NỀN XÁM (${result.transparentHeaders.length} thanh, nền --surface theo layouts/app.md):`);
|
|
3971
|
+
for (const item of result.transparentHeaders) problems.push(` ${item}`);
|
|
3972
|
+
}
|
|
3973
|
+
if (result.textOnlyPagers?.length > 0) {
|
|
3974
|
+
problems.push(`PHÂN TRANG CHỈ CÓ NÚT CHỮ (${result.textOnlyPagers.length} chỗ, dựng ‹ 1 2 3 … › theo components/small-controls.md):`);
|
|
3975
|
+
for (const item of result.textOnlyPagers) problems.push(` ${item}`);
|
|
3976
|
+
}
|
|
3977
|
+
if (result.missingWireframeParts?.length > 0) {
|
|
3978
|
+
problems.push(`WIREFRAME THIẾU PHẦN CỦA THANH CÔNG CỤ (design-process.md, U3): ${result.missingWireframeParts.join(", ")}`);
|
|
3979
|
+
}
|
|
3980
|
+
if (result.coveredNowLines?.length > 0) {
|
|
3981
|
+
problems.push(`VẠCH "BÂY GIỜ" BỊ Ô ĐÈ (layouts/app.md, "Lưới giờ trong ngày": vạch vẽ trên ô):`);
|
|
3982
|
+
for (const item of result.coveredNowLines) problems.push(` ${item}`);
|
|
3983
|
+
}
|
|
3984
|
+
if (result.wireframeChromeProblems?.length > 0) {
|
|
3985
|
+
problems.push(`KHUNG WIREFRAME LÀM HỎNG BẢN THIẾT KẾ (design-process.md, U3):`);
|
|
3986
|
+
for (const item of result.wireframeChromeProblems.slice(0, 8)) problems.push(` ${item}`);
|
|
3987
|
+
}
|
|
3988
|
+
if (result.misalignedControlRows?.length > 0) {
|
|
3989
|
+
problems.push(`HÀNG CONTROL LỆCH TRÊN DƯỚI (${result.misalignedControlRows.length} hàng, thêm items-center; nút cạnh nhau cùng chiều cao):`);
|
|
3990
|
+
for (const item of result.misalignedControlRows) problems.push(` ${item}`);
|
|
3991
|
+
}
|
|
3992
|
+
if (result.repeatedCardIssues?.length > 0) {
|
|
3993
|
+
problems.push(`KHỐI LẶP: THỨ BẬC, NHỊP, TÊN BỊ CẮT (${result.repeatedCardIssues.length} nhóm, N12 trong principles.md):`);
|
|
3994
|
+
for (const item of result.repeatedCardIssues) problems.push(` ${item}`);
|
|
3995
|
+
}
|
|
3996
|
+
if (result.denseItems?.length > 0) {
|
|
3997
|
+
problems.push(`MỤC LẶP DÀY CHỮ (từ 5 dòng mỗi mục; danh sách + chi tiết thì mục trái tối đa 3 dòng, layouts/app.md):`);
|
|
3998
|
+
for (const item of result.denseItems) problems.push(` ${item}`);
|
|
3999
|
+
}
|
|
4000
|
+
if (result.clippedBars?.length > 0) {
|
|
4001
|
+
problems.push(`VẠCH TRÁI BỊ BO GÓC KHUNG CẮT (${result.clippedBars.length} chỗ):`);
|
|
4002
|
+
for (const item of result.clippedBars) problems.push(` ${item}`);
|
|
4003
|
+
}
|
|
4004
|
+
if (result.nestedFadeDialogs?.length > 0) {
|
|
4005
|
+
problems.push(`KHUNG HỘP THOẠI MỜ LỒNG TRONG LỚP NỀN MỜ (${result.nestedFadeDialogs.length} chỗ, độ mờ nhân nhau, khung tan nhanh hơn nền):`);
|
|
4006
|
+
for (const item of result.nestedFadeDialogs) problems.push(` ${item}`);
|
|
4007
|
+
}
|
|
4008
|
+
if (result.mouseUnreachableScrollers?.length > 0) {
|
|
4009
|
+
problems.push(`HÀNG CUỘN NGANG CHUỘT KHÔNG TỚI ĐƯỢC (${result.mouseUnreachableScrollers.length} hàng, thanh cuộn ẩn, không nút mũi tên; responsive.md sau R10):`);
|
|
4010
|
+
for (const item of result.mouseUnreachableScrollers) problems.push(` ${item}`);
|
|
4011
|
+
}
|
|
4012
|
+
if (result.stickyScrollColumns.length > 0) {
|
|
4013
|
+
problems.push(`CỘT DÍNH MÀ CUỘN RIÊNG (${result.stickyScrollColumns.length} cột, thanh cuộn riêng hiện thường trực; cột dài hơn màn thì để cuộn theo trang):`);
|
|
4014
|
+
for (const item of result.stickyScrollColumns) problems.push(` ${item}`);
|
|
4015
|
+
}
|
|
4016
|
+
if (result.swallowedNumbers.length > 0) {
|
|
4017
|
+
problems.push(`CHỮ CẮT NUỐT MẤT SỐ (${result.swallowedNumbers.length} chỗ, số kèm đơn vị nằm sau dấu …):`);
|
|
4018
|
+
for (const item of result.swallowedNumbers) problems.push(` ${item}`);
|
|
4019
|
+
}
|
|
4020
|
+
if (result.floatingContent.length > 0) {
|
|
4021
|
+
problems.push(`NỘI DUNG TRÔI GIỮA MÀN RỘNG (khối chính căn giữa, hở hai bên):`);
|
|
4022
|
+
for (const item of result.floatingContent) problems.push(` ${item}`);
|
|
4023
|
+
}
|
|
4024
|
+
if (result.styledNativeSelects.length > 0) {
|
|
4025
|
+
problems.push(`SELECT, Ô NGÀY GỐC TRÊN DESKTOP (${result.styledNativeSelects.length} ô; chế độ soi bỏ qua, wireframe và dựng lại thì thay bằng mẫu choice-controls.md):`);
|
|
4026
|
+
for (const item of result.styledNativeSelects) problems.push(` ${item}`);
|
|
4027
|
+
}
|
|
4028
|
+
if (result.squeezedBlocks.length > 0) {
|
|
4029
|
+
problems.push(`KHỐI BỊ BÓP CHIỀU CAO (${result.squeezedBlocks.length} khối, thường thiếu shrink-0 trong khung flex dọc):`);
|
|
4030
|
+
for (const item of result.squeezedBlocks) problems.push(` ${item}`);
|
|
4031
|
+
}
|
|
4032
|
+
if (result.tinyTextCount > 0) {
|
|
4033
|
+
problems.push(`CHỮ DƯỚI 12px (${result.tinyTextCount} chỗ):`);
|
|
4034
|
+
for (const item of result.tinyTexts) problems.push(` ${item}`);
|
|
4035
|
+
}
|
|
4036
|
+
if (result.browserDefaultControls.length > 0) {
|
|
4037
|
+
problems.push(`CONTROL CÒN KIỂU MẶC ĐỊNH CỦA TRÌNH DUYỆT (${result.browserDefaultControls.length} chỗ, dự án thiếu reset hay control chưa tự reset):`);
|
|
4038
|
+
for (const item of result.browserDefaultControls) problems.push(` ${item}`);
|
|
4039
|
+
}
|
|
4040
|
+
if (result.mismatchedRadii?.length > 0) {
|
|
4041
|
+
problems.push(`KHỐI CÙNG COMPONENT BO GÓC KHÁC NHAU (${result.mismatchedRadii.length} khối, Lệch hệ: bị đè bo góc riêng):`);
|
|
4042
|
+
for (const item of result.mismatchedRadii) problems.push(` ${item}`);
|
|
4043
|
+
}
|
|
4044
|
+
if (result.invisibleFrames.length > 0) {
|
|
4045
|
+
problems.push(`KHUNG KHAI VIỀN MÀ VIỀN KHÔNG THẤY (${result.invisibleFrames.length} khung, nền trong, viền, nền ngoài gần như một màu):`);
|
|
4046
|
+
for (const item of result.invisibleFrames) problems.push(` ${item}`);
|
|
4047
|
+
}
|
|
4048
|
+
if (result.hollowLayers.length > 0) {
|
|
4049
|
+
problems.push(`LỚP NỔI CÓ DẢI TRỐNG (${result.hollowLayers.length} chỗ, khung rộng hơn nội dung bên trong, thường do \`max-w\` chặn nội dung):`);
|
|
4050
|
+
for (const item of result.hollowLayers) problems.push(` ${item}`);
|
|
4051
|
+
}
|
|
4052
|
+
if (result.vanishedChildren.length > 0) {
|
|
4053
|
+
problems.push(`KHỐI CON BIẾN MẤT LÚC RÊ (${result.vanishedChildren.length} chỗ):`);
|
|
4054
|
+
for (const item of result.vanishedChildren.slice(0, 6)) problems.push(` ${item}`);
|
|
4055
|
+
}
|
|
4056
|
+
if (result.autoScrolledAreas.length > 0) {
|
|
4057
|
+
problems.push(`TRANG TỰ CUỘN KHI VỪA TẢI (${result.autoScrolledAreas.length} chỗ, người dùng chưa chạm mà đầu trang đã khuất):`);
|
|
4058
|
+
for (const item of result.autoScrolledAreas) problems.push(` ${item}`);
|
|
4059
|
+
}
|
|
4060
|
+
if (result.openerLayerProblems.length > 0) {
|
|
4061
|
+
problems.push(`LỚP NỔI MỞ BẰNG NÚT BỊ VỠ (${result.openerLayerProblems.length} chỗ):`);
|
|
4062
|
+
for (const item of result.openerLayerProblems.slice(0, 6)) problems.push(` ${item}`);
|
|
4063
|
+
}
|
|
4064
|
+
if (result.flatOpenedLayers.length > 0) {
|
|
4065
|
+
problems.push(`PANEL, MODAL MỞ RA KHÔNG NỔI HƠN TRANG (${result.flatOpenedLayers.length} chỗ, Lệch hệ):`);
|
|
4066
|
+
for (const item of result.flatOpenedLayers.slice(0, 6)) problems.push(` ${item}`);
|
|
4067
|
+
}
|
|
4068
|
+
if (result.layoutShifts.length > 0) {
|
|
4069
|
+
problems.push(`RÊ CHUỘT LÀM NHẢY BỐ CỤC (${result.layoutShifts.length} chỗ, hover thêm hay nở phần tử, khối bên dưới dời theo):`);
|
|
4070
|
+
for (const item of result.layoutShifts.slice(0, 6)) problems.push(` ${item}`);
|
|
4071
|
+
}
|
|
4072
|
+
if (result.lowContrastCount > 0) {
|
|
4073
|
+
problems.push(`TƯƠNG PHẢN CHỮ DƯỚI NGƯỠNG (${result.lowContrastCount} cặp màu, chữ thường 4.5:1, chữ lớn 3:1):`);
|
|
4074
|
+
for (const item of result.lowContrastTexts) problems.push(` ${item}`);
|
|
4075
|
+
}
|
|
4076
|
+
if (result.clippedBlocks.length > 0) {
|
|
4077
|
+
problems.push(`KHUNG GIẤU MẤT CHỮ (${result.clippedBlocks.length} khung overflow hidden, chữ nằm ngoài khung; xem ảnh xác nhận):`);
|
|
4078
|
+
for (const item of result.clippedBlocks) problems.push(` giấu "${item.hiddenText}": ${item.element}`);
|
|
4079
|
+
}
|
|
4080
|
+
if (result.wrappedControls.length > 0) {
|
|
4081
|
+
problems.push(`CHỮ TRONG NÚT / LINK / TAB XUỐNG DÒNG (${result.wrappedControls.length} chỗ, nút bị bóp):`);
|
|
4082
|
+
for (const item of result.wrappedControls) problems.push(` ${item}`);
|
|
4083
|
+
}
|
|
4084
|
+
if (result.wrappedRows.length > 0) {
|
|
4085
|
+
problems.push(`HÀNG RỚT DÒNG (header, nav, thanh tab, hàng nút) (${result.wrappedRows.length} hàng):`);
|
|
4086
|
+
for (const item of result.wrappedRows) problems.push(` ${item}`);
|
|
4087
|
+
}
|
|
4088
|
+
|
|
4089
|
+
problemCount += problems.filter((line) => !line.startsWith(" ")).length;
|
|
4090
|
+
lines.push(`\n## ${result.width}px (ảnh: ${result.screenshotPath})`);
|
|
4091
|
+
lines.push(problems.length > 0 ? problems.join("\n") : "Không đo ra lỗi.");
|
|
4092
|
+
if (result.expandedCount > 0) lines.push(`(Đã mở ${result.expandedCount} khối đang đóng rồi đo lại phần bên trong.)`);
|
|
4093
|
+
if (result.stateGroupCount > 0) lines.push(`(Đã thử rê, Tab, bấm ${result.stateGroupCount} nhóm có mục đang chọn.)`);
|
|
4094
|
+
if (result.truncatedCount > 0) lines.push(`(Có ${result.truncatedCount} chỗ chữ bị cắt có dấu …: xem ảnh xem có chỗ nào cắt mất ý không.)`);
|
|
4095
|
+
if (result.openedLayerShots.length > 0) {
|
|
4096
|
+
lines.push(`(Đã bấm mở ${result.openedLayerShots.length} lớp nổi, ảnh từng lớp — mở ra xem:)`);
|
|
4097
|
+
for (const shot of result.openedLayerShots) lines.push(` ${shot}`);
|
|
4098
|
+
}
|
|
4099
|
+
if (result.unmeasuredContrastCount > 0) lines.push(`(Có ${result.unmeasuredContrastCount} chỗ chữ trên ảnh / gradient, máy không đo được tương phản: xem ảnh.)`);
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4102
|
+
lines.unshift(problemCount > 0 ? `# Probe: ${problemCount} nhóm lỗi đo được` : "# Probe: không đo ra lỗi");
|
|
4103
|
+
lines.push(
|
|
4104
|
+
"\nMáy chỉ đo được lỗi đo được. Mở từng ảnh ra xem: thứ nặng nhất có đáng nặng không, việc chính của trang có thấy ngay không, chỗ nào chật dồn cục.",
|
|
4105
|
+
);
|
|
4106
|
+
|
|
4107
|
+
return lines.join("\n");
|
|
4108
|
+
}
|
|
4109
|
+
|
|
4110
|
+
// ---------- So bản dựng với wireframe đã chọn (--wireframe) ----------
|
|
4111
|
+
// Wireframe là bản đặc tả tới từng px (design-process.md, U4): khoảng cách, cỡ, chữ. Neo theo chữ: mỗi đoạn chữ và
|
|
4112
|
+
// placeholder có ở cả hai trang là một điểm neo. Một khoảng phía trên sai thì mọi neo phía dưới lệch cùng một số, nên
|
|
4113
|
+
// không so toạ độ tuyệt đối mà so khoảng giữa mỗi neo với neo gần nhất phía trên cùng cột (bên trái cùng dòng): khoảng
|
|
4114
|
+
// nào khác là đúng chỗ phải sửa. Đã dính 30/09/2026, tìm phòng: ô tìm của bản dựng thấp hơn wireframe 17px, hàng chip
|
|
4115
|
+
// thêm 3px, lưới card thêm 1px; kéo thanh so sánh qua lại thì mọi khối nhảy.
|
|
4116
|
+
const wireframeCompareWidths = [1440, 375];
|
|
4117
|
+
const wireframeTolerancePx = 2;
|
|
4118
|
+
const maxWireframeDiffLines = 15;
|
|
4119
|
+
|
|
4120
|
+
async function collectLayoutAnchors(page) {
|
|
4121
|
+
return page.evaluate(() => {
|
|
4122
|
+
// Thanh công cụ và khung lý do của trang wireframe nằm ngoài bản thiết kế.
|
|
4123
|
+
for (const chrome of document.querySelectorAll(".wf-bar, .wf-reason, [data-wf-reason]")) chrome.remove();
|
|
4124
|
+
|
|
4125
|
+
const anchors = [];
|
|
4126
|
+
const countsByText = new Map();
|
|
4127
|
+
// Màu đọc qua canvas để oklch, hex, rgb cùng ra một dạng so được.
|
|
4128
|
+
const colorContext = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
|
4129
|
+
function normalizeColor(color) {
|
|
4130
|
+
colorContext.clearRect(0, 0, 1, 1);
|
|
4131
|
+
colorContext.fillStyle = "#000";
|
|
4132
|
+
colorContext.fillStyle = color;
|
|
4133
|
+
colorContext.fillRect(0, 0, 1, 1);
|
|
4134
|
+
const [red, green, blue, alpha] = colorContext.getImageData(0, 0, 1, 1).data;
|
|
4135
|
+
|
|
4136
|
+
return alpha === 0 ? "transparent" : `rgb(${red} ${green} ${blue}${alpha < 255 ? ` / ${(alpha / 255).toFixed(2)}` : ""})`;
|
|
4137
|
+
}
|
|
4138
|
+
function findBackground(element) {
|
|
4139
|
+
for (let current = element; current; current = current.parentElement) {
|
|
4140
|
+
const background = normalizeColor(getComputedStyle(current).backgroundColor);
|
|
4141
|
+
if (background !== "transparent") return background;
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
return "transparent";
|
|
4145
|
+
}
|
|
4146
|
+
|
|
4147
|
+
function addAnchor(text, rect, fontSize, style = {}) {
|
|
4148
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
4149
|
+
|
|
4150
|
+
const occurrence = (countsByText.get(text) ?? 0) + 1;
|
|
4151
|
+
countsByText.set(text, occurrence);
|
|
4152
|
+
anchors.push({
|
|
4153
|
+
key: `${text}#${occurrence}`,
|
|
4154
|
+
text,
|
|
4155
|
+
x: Math.round(rect.left + scrollX),
|
|
4156
|
+
y: Math.round(rect.top + scrollY),
|
|
4157
|
+
width: Math.round(rect.width),
|
|
4158
|
+
height: Math.round(rect.height),
|
|
4159
|
+
fontSize,
|
|
4160
|
+
...style,
|
|
4161
|
+
});
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
4165
|
+
const range = document.createRange();
|
|
4166
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
4167
|
+
const text = node.textContent.replace(/\s+/g, " ").trim();
|
|
4168
|
+
const parent = node.parentElement;
|
|
4169
|
+
if (!text || !parent || parent.closest("script, style, noscript, template")) continue;
|
|
4170
|
+
if (getComputedStyle(parent).visibility === "hidden") continue;
|
|
4171
|
+
|
|
4172
|
+
range.selectNodeContents(node);
|
|
4173
|
+
const parentStyle = getComputedStyle(parent);
|
|
4174
|
+
addAnchor(text, range.getBoundingClientRect(), parentStyle.fontSize, {
|
|
4175
|
+
color: normalizeColor(parentStyle.color),
|
|
4176
|
+
fontWeight: parentStyle.fontWeight,
|
|
4177
|
+
background: findBackground(parent),
|
|
4178
|
+
});
|
|
4179
|
+
}
|
|
4180
|
+
for (const field of document.querySelectorAll("input[placeholder], textarea[placeholder]")) {
|
|
4181
|
+
addAnchor(`placeholder "${field.placeholder.trim()}"`, field.getBoundingClientRect(), getComputedStyle(field).fontSize, {
|
|
4182
|
+
color: normalizeColor(getComputedStyle(field, "::placeholder").color),
|
|
4183
|
+
background: findBackground(field),
|
|
4184
|
+
});
|
|
4185
|
+
}
|
|
4186
|
+
// Icon không có chữ nên neo theo tên lucide (cả lucide CDN lẫn lucide-react gắn class lucide-<tên>). Bắt icon
|
|
4187
|
+
// wireframe có mà bản dựng bỏ, như nút tim trên header (30/09/2026). Cỡ icon so bằng bề rộng, không bằng cỡ chữ.
|
|
4188
|
+
for (const icon of document.querySelectorAll("svg[class*='lucide-']")) {
|
|
4189
|
+
const iconName = [...icon.classList].find((className) => className.startsWith("lucide-") && className !== "lucide-icon");
|
|
4190
|
+
if (!iconName) continue;
|
|
4191
|
+
|
|
4192
|
+
const iconRect = icon.getBoundingClientRect();
|
|
4193
|
+
addAnchor(`icon ${iconName.replace(/^lucide-|-icon$/g, "")}`, iconRect, `${Math.round(iconRect.width)}px icon`, {
|
|
4194
|
+
color: normalizeColor(getComputedStyle(icon).color),
|
|
4195
|
+
});
|
|
4196
|
+
}
|
|
4197
|
+
|
|
4198
|
+
return anchors;
|
|
4199
|
+
});
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
function isSameColumn(first, second) {
|
|
4203
|
+
return first.x < second.x + second.width && second.x < first.x + first.width;
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
function isSameLine(first, second) {
|
|
4207
|
+
return first.y < second.y + second.height && second.y < first.y + first.height;
|
|
4208
|
+
}
|
|
4209
|
+
|
|
4210
|
+
function quoteAnchor(text) {
|
|
4211
|
+
return `«${text.length > 40 ? `${text.slice(0, 39)}…` : text}»`;
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4214
|
+
function parseRgb(color) {
|
|
4215
|
+
const channels = color.match(/[\d.]+/g)?.map(Number) ?? [];
|
|
4216
|
+
|
|
4217
|
+
return color === "transparent" ? [0, 0, 0, 0] : [channels[0], channels[1], channels[2], channels[3] ?? 1];
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4220
|
+
// Lệch vài đơn vị mỗi kênh là làm tròn khi đổi hệ màu, không phải hai màu khác nhau.
|
|
4221
|
+
function isSameColor(firstColor, secondColor) {
|
|
4222
|
+
const firstChannels = parseRgb(firstColor);
|
|
4223
|
+
const secondChannels = parseRgb(secondColor);
|
|
4224
|
+
|
|
4225
|
+
return firstChannels.slice(0, 3).every((channel, index) => Math.abs(channel - secondChannels[index]) <= 6)
|
|
4226
|
+
&& Math.abs(firstChannels[3] - secondChannels[3]) <= 0.05;
|
|
4227
|
+
}
|
|
4228
|
+
|
|
4229
|
+
function diffLayoutAnchors(wireframeAnchors, buildAnchors, shouldCompareColors) {
|
|
4230
|
+
const buildByKey = new Map(buildAnchors.map((anchor) => [anchor.key, anchor]));
|
|
4231
|
+
const wireframeKeys = new Set(wireframeAnchors.map((anchor) => anchor.key));
|
|
4232
|
+
const pairs = wireframeAnchors
|
|
4233
|
+
.filter((anchor) => buildByKey.has(anchor.key))
|
|
4234
|
+
.map((anchor) => ({ wireframe: anchor, build: buildByKey.get(anchor.key) }))
|
|
4235
|
+
.sort((first, second) => first.wireframe.y - second.wireframe.y || first.wireframe.x - second.wireframe.x);
|
|
4236
|
+
|
|
4237
|
+
// Neo gần nhất phía trên cùng cột (bên trái cùng dòng) tìm trong mọi neo của wireframe. Neo đó không có ở bản dựng
|
|
4238
|
+
// (chữ đổi, khối bị bỏ) thì bỏ qua khoảng này: lỗi đã nằm ở dòng "chữ wireframe có mà bản dựng không có", khoảng lệch
|
|
4239
|
+
// chỉ là hệ quả (chữ mới ngắn hơn nên ít dòng hơn).
|
|
4240
|
+
const pairByKey = new Map(pairs.map((pair) => [pair.wireframe.key, pair]));
|
|
4241
|
+
function findNeighborPair(anchor, isNeighbor, farEdge) {
|
|
4242
|
+
const neighbor = wireframeAnchors
|
|
4243
|
+
.filter((other) => other !== anchor && isNeighbor(other, anchor))
|
|
4244
|
+
.sort((first, second) => farEdge(second) - farEdge(first))[0];
|
|
4245
|
+
if (!neighbor) return { isEdge: true };
|
|
4246
|
+
|
|
4247
|
+
return pairByKey.get(neighbor.key) ?? null;
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
const gapDiffs = [];
|
|
4251
|
+
// Một token sai thì hàng chục chữ sai cùng một cặp màu: gom theo cặp, kèm vài chữ làm ví dụ.
|
|
4252
|
+
const styleDiffsByKey = new Map();
|
|
4253
|
+
function addStyleDiff(label, wireframeValue, buildValue, text) {
|
|
4254
|
+
const key = `${label}|${wireframeValue}|${buildValue}`;
|
|
4255
|
+
if (!styleDiffsByKey.has(key)) styleDiffsByKey.set(key, { label, wireframeValue, buildValue, texts: [] });
|
|
4256
|
+
styleDiffsByKey.get(key).texts.push(text);
|
|
4257
|
+
}
|
|
4258
|
+
function checkVerticalGap(pair, abovePair) {
|
|
4259
|
+
const wireframeGap = abovePair ? pair.wireframe.y - (abovePair.wireframe.y + abovePair.wireframe.height) : pair.wireframe.y;
|
|
4260
|
+
const buildGap = abovePair ? pair.build.y - (abovePair.build.y + abovePair.build.height) : pair.build.y;
|
|
4261
|
+
if (Math.abs(buildGap - wireframeGap) > wireframeTolerancePx) {
|
|
4262
|
+
const fromLabel = abovePair ? quoteAnchor(abovePair.wireframe.text) : "mép trên trang";
|
|
4263
|
+
gapDiffs.push(`dọc: ${fromLabel} → ${quoteAnchor(pair.wireframe.text)} bản dựng ${buildGap}px, wireframe ${wireframeGap}px (${buildGap > wireframeGap ? "+" : ""}${buildGap - wireframeGap})`);
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
function checkHorizontalGap(pair, leftPair) {
|
|
4268
|
+
const wireframeLeftGap = leftPair ? pair.wireframe.x - (leftPair.wireframe.x + leftPair.wireframe.width) : pair.wireframe.x;
|
|
4269
|
+
const buildLeftGap = leftPair ? pair.build.x - (leftPair.build.x + leftPair.build.width) : pair.build.x;
|
|
4270
|
+
if (Math.abs(buildLeftGap - wireframeLeftGap) > wireframeTolerancePx) {
|
|
4271
|
+
const fromLabel = leftPair ? quoteAnchor(leftPair.wireframe.text) : "mép trái trang";
|
|
4272
|
+
gapDiffs.push(`ngang: ${fromLabel} → ${quoteAnchor(pair.wireframe.text)} bản dựng ${buildLeftGap}px, wireframe ${wireframeLeftGap}px (${buildLeftGap > wireframeLeftGap ? "+" : ""}${buildLeftGap - wireframeLeftGap})`);
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
4275
|
+
|
|
4276
|
+
for (const pair of pairs) {
|
|
4277
|
+
const abovePair = findNeighborPair(
|
|
4278
|
+
pair.wireframe,
|
|
4279
|
+
(other, anchor) => other.y + other.height <= anchor.y + 1 && isSameColumn(other, anchor),
|
|
4280
|
+
(other) => other.y + other.height,
|
|
4281
|
+
);
|
|
4282
|
+
if (abovePair) checkVerticalGap(pair, abovePair.isEdge ? null : abovePair);
|
|
4283
|
+
|
|
4284
|
+
const leftPair = findNeighborPair(
|
|
4285
|
+
pair.wireframe,
|
|
4286
|
+
(other, anchor) => other.x + other.width <= anchor.x + 1 && isSameLine(other, anchor),
|
|
4287
|
+
(other) => other.x + other.width,
|
|
4288
|
+
);
|
|
4289
|
+
if (leftPair) checkHorizontalGap(pair, leftPair.isEdge ? null : leftPair);
|
|
4290
|
+
|
|
4291
|
+
if (pair.wireframe.fontWeight && pair.build.fontWeight !== pair.wireframe.fontWeight) {
|
|
4292
|
+
addStyleDiff("độ đậm", pair.wireframe.fontWeight, pair.build.fontWeight, pair.wireframe.text);
|
|
4293
|
+
}
|
|
4294
|
+
if (shouldCompareColors && pair.wireframe.color && !isSameColor(pair.wireframe.color, pair.build.color)) {
|
|
4295
|
+
addStyleDiff(pair.wireframe.text.startsWith("icon ") ? "màu icon" : "màu chữ", pair.wireframe.color, pair.build.color, pair.wireframe.text);
|
|
4296
|
+
}
|
|
4297
|
+
if (shouldCompareColors && pair.wireframe.background && !isSameColor(pair.wireframe.background, pair.build.background)) {
|
|
4298
|
+
addStyleDiff("nền dưới chữ", pair.wireframe.background, pair.build.background, pair.wireframe.text);
|
|
4299
|
+
}
|
|
4300
|
+
|
|
4301
|
+
if (pair.build.fontSize !== pair.wireframe.fontSize) {
|
|
4302
|
+
const sizeLabel = pair.wireframe.text.startsWith("icon ") ? "cỡ icon" : "cỡ chữ";
|
|
4303
|
+
gapDiffs.push(`${sizeLabel}: ${quoteAnchor(pair.wireframe.text)} bản dựng ${pair.build.fontSize.replace(" icon", "")}, wireframe ${pair.wireframe.fontSize.replace(" icon", "")}`);
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4307
|
+
const uniqueTexts = (anchors) => [...new Set(anchors.map((anchor) => anchor.text))];
|
|
4308
|
+
|
|
4309
|
+
return {
|
|
4310
|
+
matchedCount: pairs.length,
|
|
4311
|
+
wireframeCount: wireframeAnchors.length,
|
|
4312
|
+
gapDiffs,
|
|
4313
|
+
styleDiffs: [...styleDiffsByKey.values()].map(({ label, wireframeValue, buildValue, texts }) => {
|
|
4314
|
+
const examples = [...new Set(texts)].slice(0, 3).map(quoteAnchor).join(", ");
|
|
4315
|
+
return `${label}: bản dựng ${buildValue}, wireframe ${wireframeValue}, ${texts.length} chỗ (${examples})`;
|
|
4316
|
+
}),
|
|
4317
|
+
missingTexts: uniqueTexts(wireframeAnchors.filter((anchor) => !buildByKey.has(anchor.key))),
|
|
4318
|
+
extraTexts: uniqueTexts(buildAnchors.filter((anchor) => !wireframeKeys.has(anchor.key))),
|
|
4319
|
+
};
|
|
4320
|
+
}
|
|
4321
|
+
|
|
4322
|
+
async function openForAnchors(browser, options, url, width, screenshotPath) {
|
|
4323
|
+
const context = await browser.newContext({
|
|
4324
|
+
viewport: { width, height: 900 },
|
|
4325
|
+
deviceScaleFactor: 1,
|
|
4326
|
+
colorScheme: options.isDark ? "dark" : "light",
|
|
4327
|
+
});
|
|
4328
|
+
const page = await context.newPage();
|
|
4329
|
+
|
|
4330
|
+
await page.goto(url, { waitUntil: "load" });
|
|
4331
|
+
await page.addStyleTag({ content: freezeMotionCss });
|
|
4332
|
+
await page.waitForTimeout(options.waitMs);
|
|
4333
|
+
const anchors = await collectLayoutAnchors(page);
|
|
4334
|
+
await takeFullScreenshot(page, screenshotPath);
|
|
4335
|
+
await context.close();
|
|
4336
|
+
|
|
4337
|
+
return anchors;
|
|
4338
|
+
}
|
|
4339
|
+
|
|
4340
|
+
async function compareWithWireframe(browser, options) {
|
|
4341
|
+
const comparisons = [];
|
|
4342
|
+
// Nấc Xám cố ý bỏ màu nhấn nên chỉ so màu khi link wireframe mở ở nấc Màu (U3, công tắc Màu).
|
|
4343
|
+
const shouldCompareColors = new URL(options.wireframeUrl).searchParams.get("mau") === "mau";
|
|
4344
|
+
|
|
4345
|
+
for (const width of wireframeCompareWidths) {
|
|
4346
|
+
const wireframeAnchors = await openForAnchors(browser, options, options.wireframeUrl, width, join(options.out, `wireframe-${width}.png`));
|
|
4347
|
+
const buildAnchors = await openForAnchors(browser, options, options.url, width, join(options.out, `ban-dung-${width}.png`));
|
|
4348
|
+
comparisons.push({ width, shouldCompareColors, ...diffLayoutAnchors(wireframeAnchors, buildAnchors, shouldCompareColors) });
|
|
4349
|
+
}
|
|
4350
|
+
|
|
4351
|
+
return comparisons;
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
function formatWireframeReport(comparisons) {
|
|
4355
|
+
const lines = ["\n# So với wireframe (design-process.md, U4: khoảng cách, cỡ, chữ chép nguyên từ wireframe)"];
|
|
4356
|
+
|
|
4357
|
+
for (const comparison of comparisons) {
|
|
4358
|
+
lines.push(`\n## ${comparison.width}px: khớp ${comparison.matchedCount}/${comparison.wireframeCount} neo (chữ, placeholder, icon)`);
|
|
4359
|
+
if (!comparison.shouldCompareColors) lines.push("Chưa so màu: link wireframe không có mau=mau (nấc Màu).");
|
|
4360
|
+
if (comparison.gapDiffs.length === 0 && comparison.styleDiffs.length === 0 && comparison.missingTexts.length === 0 && comparison.extraTexts.length === 0) {
|
|
4361
|
+
lines.push("Khớp wireframe.");
|
|
4362
|
+
continue;
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
if (comparison.gapDiffs.length > 0) {
|
|
4366
|
+
lines.push(`KHOẢNG KHÁC WIREFRAME (${comparison.gapDiffs.length} chỗ, lấy đúng class spacing của khối đó trong wireframe):`);
|
|
4367
|
+
for (const item of comparison.gapDiffs.slice(0, maxWireframeDiffLines)) lines.push(` ${item}`);
|
|
4368
|
+
if (comparison.gapDiffs.length > maxWireframeDiffLines) lines.push(` (còn ${comparison.gapDiffs.length - maxWireframeDiffLines} chỗ, thường do chỗ đầu tiên, sửa rồi chạy lại)`);
|
|
4369
|
+
}
|
|
4370
|
+
if (comparison.styleDiffs.length > 0) {
|
|
4371
|
+
lines.push(`MÀU, ĐỘ ĐẬM KHÁC WIREFRAME (${comparison.styleDiffs.length} cặp, lấy đúng token và class của wireframe):`);
|
|
4372
|
+
for (const item of comparison.styleDiffs.slice(0, maxWireframeDiffLines)) lines.push(` ${item}`);
|
|
4373
|
+
}
|
|
4374
|
+
if (comparison.missingTexts.length > 0) {
|
|
4375
|
+
lines.push(`CHỮ, ICON WIREFRAME CÓ MÀ BẢN DỰNG KHÔNG CÓ (${comparison.missingTexts.length}, chép nguyên chữ, hoặc ghi lý do lúc giao):`);
|
|
4376
|
+
for (const text of comparison.missingTexts.slice(0, maxWireframeDiffLines)) lines.push(` ${quoteAnchor(text)}`);
|
|
4377
|
+
}
|
|
4378
|
+
if (comparison.extraTexts.length > 0) {
|
|
4379
|
+
lines.push(`CHỮ, ICON BẢN DỰNG CÓ MÀ WIREFRAME KHÔNG CÓ (${comparison.extraTexts.length}):`);
|
|
4380
|
+
for (const text of comparison.extraTexts.slice(0, maxWireframeDiffLines)) lines.push(` ${quoteAnchor(text)}`);
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
return lines.join("\n");
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
function listWireframeMustReportItems(comparisons) {
|
|
4388
|
+
const items = [];
|
|
4389
|
+
|
|
4390
|
+
for (const comparison of comparisons) {
|
|
4391
|
+
for (const item of comparison.gapDiffs.slice(0, maxWireframeDiffLines)) items.push({ widths: [comparison.width], text: `khác wireframe, ${item}` });
|
|
4392
|
+
for (const item of comparison.styleDiffs.slice(0, maxWireframeDiffLines)) items.push({ widths: [comparison.width], text: `khác wireframe, ${item}` });
|
|
4393
|
+
for (const text of comparison.missingTexts.slice(0, maxWireframeDiffLines)) items.push({ widths: [comparison.width], text: `wireframe có mà bản dựng không có: ${quoteAnchor(text)}` });
|
|
4394
|
+
for (const text of comparison.extraTexts.slice(0, maxWireframeDiffLines)) items.push({ widths: [comparison.width], text: `bản dựng thêm thứ wireframe không có: ${quoteAnchor(text)}` });
|
|
4395
|
+
}
|
|
4396
|
+
|
|
4397
|
+
return items;
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
async function main() {
|
|
4401
|
+
const options = parseArgs(process.argv.slice(2));
|
|
4402
|
+
|
|
4403
|
+
if (!options.url) {
|
|
4404
|
+
console.error("Thiếu URL. Ví dụ: node probe.mjs http://localhost:5173/dashboard");
|
|
4405
|
+
process.exit(2);
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
const playwright = loadPlaywright(options.playwrightDir);
|
|
4409
|
+
if (!playwright) {
|
|
4410
|
+
console.error('Chưa có playwright. Cài vào thư mục tạm, không vào dự án:\n npm i --prefix "$TMPDIR/evon-probe" playwright\nrồi chạy lại với --pw "$TMPDIR/evon-probe".');
|
|
4411
|
+
process.exit(2);
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
let browser;
|
|
4415
|
+
try {
|
|
4416
|
+
browser = await launchBrowser(playwright.chromium);
|
|
4417
|
+
} catch {
|
|
4418
|
+
console.error("Không mở được trình duyệt. Chạy: npx playwright install chromium (trong thư mục có playwright).");
|
|
4419
|
+
process.exit(2);
|
|
4420
|
+
}
|
|
4421
|
+
|
|
4422
|
+
mkdirSync(options.out, { recursive: true });
|
|
4423
|
+
const results = [];
|
|
4424
|
+
let sweepSteps = [];
|
|
4425
|
+
let wireframeComparisons = [];
|
|
4426
|
+
|
|
4427
|
+
try {
|
|
4428
|
+
for (const width of options.widths) results.push(await probeWidth(browser, options, width));
|
|
4429
|
+
if (options.sweep) sweepSteps = await sweepWidths(browser, options);
|
|
4430
|
+
if (options.wireframeUrl) wireframeComparisons = await compareWithWireframe(browser, options);
|
|
4431
|
+
} catch (error) {
|
|
4432
|
+
console.error(`Không mở được ${options.url}: ${error.message.split("\n")[0]}. Dev server đã chạy chưa?`);
|
|
4433
|
+
process.exit(2);
|
|
4434
|
+
} finally {
|
|
4435
|
+
await browser.close();
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
writeFileSync(join(options.out, "report.json"), JSON.stringify({ widths: results, sweep: sweepSteps, wireframe: wireframeComparisons }, null, 2));
|
|
4439
|
+
console.log(formatReport(results));
|
|
4440
|
+
if (sweepSteps.length > 0) console.log(formatSweepReport(sweepSteps, options.sweep.step));
|
|
4441
|
+
if (wireframeComparisons.length > 0) console.log(formatWireframeReport(wireframeComparisons));
|
|
4442
|
+
const mustReportItems = [...listMustReportItems(results, sweepSteps), ...listWireframeMustReportItems(wireframeComparisons)];
|
|
4443
|
+
console.log(formatMustReportList(mustReportItems, options.sweep?.step ?? 20));
|
|
4444
|
+
console.log(`\nChi tiết: ${join(options.out, "report.json")}`);
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
main();
|