From 21d4b43dce0e524a426fcdbd0273b3f054298bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E5=B7=9D=E3=80=80=E4=BF=8A=E4=BB=8B?= Date: Wed, 14 Oct 2026 19:58:37 +0900 Subject: [PATCH 1/4] feat(skills): add responsive-layout-audit --- docs/README.skills.md | 1 + skills/responsive-layout-audit/SKILL.md | 70 +++++ .../scripts/layout-audit.mjs | 286 ++++++++++++++++++ .../tests/fixtures/intentional-scroll.html | 38 +++ .../tests/fixtures/page-overflow.html | 14 + .../tests/layout-audit.test.mjs | 68 +++++ 6 files changed, 477 insertions(+) create mode 100644 skills/responsive-layout-audit/SKILL.md create mode 100644 skills/responsive-layout-audit/scripts/layout-audit.mjs create mode 100644 skills/responsive-layout-audit/tests/fixtures/intentional-scroll.html create mode 100644 skills/responsive-layout-audit/tests/fixtures/page-overflow.html create mode 100644 skills/responsive-layout-audit/tests/layout-audit.test.mjs diff --git a/docs/README.skills.md b/docs/README.skills.md index 16f19e1fb..3c06df09f 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -374,6 +374,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [repo-standardizer](../skills/repo-standardizer/SKILL.md)
`gh skills install github/awesome-copilot repo-standardizer` | Polish any GitHub repository's surface — labels (emoji rating tiers, P0–P3 priority, impact severity), issue forms, PR template, CI workflows, CODEOWNERS, rulesets, docs. Repo meta & config only — no code logic touched. Use when creating a new repo or polishing an existing one. | `templates/AGENTS.md`
`templates/CHANGELOG.md`
`templates/CLAUDE.md`
`templates/CODEOWNERS`
`templates/CODE_OF_CONDUCT.md`
`templates/CONTRIBUTING.md`
`templates/LABELS.md`
`templates/PR_TEMPLATE.md`
`templates/README.md`
`templates/README.zh.md`
`templates/SECURITY.md`
`templates/VISION.md`
`templates/ci-go.yml`
`templates/ci-node.yml`
`templates/ci-python.yml`
`templates/ci-rust.yml`
`templates/config.yml`
`templates/issue-form-bug.yml`
`templates/issue-form-feature.yml`
`templates/issue-form-question.yml`
`templates/labels.json` | | [repo-story-time](../skills/repo-story-time/SKILL.md)
`gh skills install github/awesome-copilot repo-story-time` | Generate a comprehensive repository summary and narrative story from commit history | None | | [resemble-detect](../skills/resemble-detect/SKILL.md)
`gh skills install github/awesome-copilot resemble-detect` | Deepfake detection and media safety — detect AI-generated audio, images, video, and text, trace synthesis sources, apply watermarks, verify speaker identity, and analyze media intelligence using Resemble AI | `LICENSE`
`references/api-reference.md` | +| [responsive-layout-audit](../skills/responsive-layout-audit/SKILL.md)
`gh skills install github/awesome-copilot responsive-layout-audit` | Use this skill to audit responsive rendering for a web page or local HTML with Playwright. It checks page-level horizontal overflow, region-level horizontal scrolling, clipping candidates, and hit-test obstruction candidates, then outputs PNG and JSON evidence. | `scripts/layout-audit.mjs`
`tests/fixtures/intentional-scroll.html`
`tests/fixtures/page-overflow.html`
`tests/layout-audit.test.mjs` | | [resx-source-generator-migration](../skills/resx-source-generator-migration/SKILL.md)
`gh skills install github/awesome-copilot resx-source-generator-migration` | Migrates a project that uses checked-in .designer.cs files behind .resx to using a source-generator instead | None | | [review-and-refactor](../skills/review-and-refactor/SKILL.md)
`gh skills install github/awesome-copilot review-and-refactor` | Review and refactor code in your project according to defined instructions | None | | [reviewing-oracle-to-postgres-migration](../skills/reviewing-oracle-to-postgres-migration/SKILL.md)
`gh skills install github/awesome-copilot reviewing-oracle-to-postgres-migration` | Identifies Oracle-to-PostgreSQL migration risks by cross-referencing code against known behavioral differences (empty strings, refcursors, type coercion, sorting/collations, UNION ALL planner risks, materialized-view refresh requirements, timestamps, concurrent transactions, etc.). Use when planning a database migration, reviewing migration artifacts, or validating that integration tests cover Oracle/PostgreSQL differences. | `references/REFERENCE.md`
`references/empty-strings-handling.md`
`references/no-data-found-exceptions.md`
`references/oracle-nvl-decode-functions.md`
`references/oracle-parentheses-from-clause.md`
`references/oracle-rownum-pagination.md`
`references/oracle-sysdate-sequences-dual.md`
`references/oracle-to-postgres-sorting.md`
`references/oracle-to-postgres-timestamp-timezone.md`
`references/oracle-to-postgres-to-char-numeric.md`
`references/oracle-to-postgres-type-coercion.md`
`references/postgres-concurrent-transactions.md`
`references/postgres-materialized-view-refresh.md`
`references/postgres-refcursor-handling.md`
`references/postgres-union-all-planner.md` | diff --git a/skills/responsive-layout-audit/SKILL.md b/skills/responsive-layout-audit/SKILL.md new file mode 100644 index 000000000..b29826b27 --- /dev/null +++ b/skills/responsive-layout-audit/SKILL.md @@ -0,0 +1,70 @@ +--- +name: responsive-layout-audit +description: "Use this skill to audit responsive rendering for a web page or local HTML with Playwright. It checks page-level horizontal overflow, region-level horizontal scrolling, clipping candidates, and hit-test obstruction candidates, then outputs PNG and JSON evidence." +argument-hint: "[target URL or HTML path] [viewports] [scroll selectors] [output directory]" +--- + +# Responsive Layout Audit + +## Purpose + +Render the page at multiple viewport sizes and separate page-level overflow from intentional in-region horizontal scrolling (for example, data tables). + +This skill reports candidates for review. It does not make final defect decisions and does not replace product acceptance. + +## Inputs + +- Target URL (`http`, `https`, `file`) or local HTML path. +- Viewports. Default: `1920x1080`, `1392x900`, `950x900`, `696x900`, `390x844`. +- Optional CSS selectors for horizontally scrollable regions. +- Optional edge selector inside each region (for example `thead th:last-child`). +- Output directory (use an empty run-specific directory). +- Optional ready selector when SPA content appears late. + +If your project has its own viewport policy, use that policy and record the selected sizes. + +## Workflow + +1. Confirm target scope and avoid pages with sensitive production data. +2. Fix the viewport set, optional scroll selectors, optional edge selector, and output directory. +3. Reuse an existing Playwright installation when possible. +4. Run the script and inspect `layout-report.json` plus screenshots. + +```powershell +node .\scripts\layout-audit.mjs ` + --target .\path\to\page.html ` + --out-dir .\run\layout-audit\screen-id\ ` + --playwright-root .\run\playwright-runtime ` + --scroll-selector ".table-viewport" ` + --edge-selector "thead th:last-child" ` + --check-hit-testing +``` + +You can pass `--scroll-selector` multiple times. `--edge-selector` is evaluated inside each region. Use `--ready-selector` for late-loading UI. `--wait-ms` is extra settling time, not readiness proof. + +If Playwright is not available, install it in a dedicated local runtime directory. + +```powershell +npm install --prefix .\run\playwright-runtime playwright +``` + +Record target, viewport set, selectors, Playwright/browser version, output paths, candidate dispositions, and remaining unknowns. + +## How to read results + +- `page.horizontalOverflow`: compares document width and viewport width. +- `scrollRegions`: tracks region width, max scroll-left, and optional edge visibility. +- `text-clipping-candidate`: potential clipping based on CSS + width mismatch. +- `action-outside-viewport-candidate`: interactive elements outside viewport bounds. +- `hit-test-obstruction-candidate`: sampled-point obstruction candidates. +- `pageErrors`: uncaught JavaScript errors in the page context. +- `LAYOUT_AUDIT_DONE`: execution and evidence generation completed. + +## Gotchas + +- Keep style policy (colors/borders/visual language) separate from this mechanical audit. +- Do not treat page-level width and container-level horizontal scroll as the same signal. +- Do not treat `scrollWidth` mismatch alone as a defect. +- Hit-test checks samples only; this is not a full interaction test. +- The script redacts secret-like query values but is not a full anonymization layer. +- Screenshots may include sensitive information. Use safe test data and controlled sharing. \ No newline at end of file diff --git a/skills/responsive-layout-audit/scripts/layout-audit.mjs b/skills/responsive-layout-audit/scripts/layout-audit.mjs new file mode 100644 index 000000000..ecb34871b --- /dev/null +++ b/skills/responsive-layout-audit/scripts/layout-audit.mjs @@ -0,0 +1,286 @@ +import fs from "node:fs"; +import path from "node:path"; +import {createRequire} from "node:module"; +import {fileURLToPath, pathToFileURL} from "node:url"; + +const defaultViewports = ["1920x1080", "1392x900", "950x900", "696x900", "390x844"]; + +function usage() { + return `Usage: node layout-audit.mjs --target --out-dir [options] + +Options: + --playwright-root Directory whose node_modules contains Playwright + --browser Browser channel (default: msedge) + --viewports Viewports (default: ${defaultViewports.join(",")}) + --ready-selector Wait for a page element before capture + --scroll-selector Repeatable horizontal scroll region selector + --edge-selector Element that must be visible at each region's end + --check-hit-testing Check sampled points on visible buttons and links + --wait-ms Extra settling time after navigation (default: 250) + --force Allow overwriting files in a non-empty output directory + --help Show this help`; +} + +function parseArguments(args) { + const options = { + browser: "msedge", + viewports: defaultViewports, + scrollSelectors: [], + waitMs: 250, + force: false, + checkHitTesting: false, + }; + + for (let index = 0; index < args.length; index++) { + const name = args[index]; + if (name === "--help") { + options.help = true; + continue; + } + if (name === "--force") { + options.force = true; + continue; + } + if (name === "--check-hit-testing") { + options.checkHitTesting = true; + continue; + } + + const value = args[++index]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${name}`); + if (name === "--target") options.target = value; + else if (name === "--out-dir") options.outDir = value; + else if (name === "--playwright-root") options.playwrightRoot = value; + else if (name === "--browser") options.browser = value; + else if (name === "--ready-selector") options.readySelector = value; + else if (name === "--scroll-selector") options.scrollSelectors.push(value); + else if (name === "--edge-selector") options.edgeSelector = value; + else if (name === "--wait-ms") options.waitMs = Number(value); + else if (name === "--viewports") options.viewports = value.split(","); + else throw new Error(`Unknown option: ${name}`); + } + + if (options.help) return options; + if (!options.target || !options.outDir) throw new Error("--target and --out-dir are required"); + if (!["msedge", "chromium"].includes(options.browser)) throw new Error("--browser must be msedge or chromium"); + if (!Number.isFinite(options.waitMs) || options.waitMs < 0) throw new Error("--wait-ms must be a non-negative number"); + options.viewports = options.viewports.map(value => { + const match = /^(\d+)x(\d+)$/.exec(value); + if (!match || Number(match[1]) < 1 || Number(match[2]) < 1) throw new Error(`Invalid viewport: ${value}`); + return {name: value, width: Number(match[1]), height: Number(match[2])}; + }); + if (!options.viewports.length) throw new Error("At least one viewport is required"); + return options; +} + +function resolveTarget(target) { + if (/^[a-zA-Z]:[\\/]/.test(target)) return pathToFileURL(path.resolve(target)).href; + try { + const url = new URL(target); + if (["http:", "https:", "file:"].includes(url.protocol)) return url.href; + } catch { + return pathToFileURL(path.resolve(target)).href; + } + throw new Error("--target must be an http, https, file URL, or local HTML path"); +} + +function sanitizeTarget(target) { + const url = new URL(target); + url.username = ""; + url.password = ""; + for (const key of url.searchParams.keys()) { + if (/(token|secret|password|passwd|authorization|api[-_]?key|session)/i.test(key)) url.searchParams.set(key, "REDACTED"); + } + return url.href; +} + +function loadPlaywright(root) { + const base = path.resolve(root ?? process.cwd()); + const require = createRequire(path.join(base, "package.json")); + try { + return require("playwright"); + } catch (error) { + throw new Error(`Cannot load Playwright from ${base}. Install it in that directory or pass --playwright-root. ${error.message}`); + } +} + +function safeName(value) { + return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "region"; +} + +async function inspectPage(page, options) { + return page.evaluate(({checkHitTesting}) => { + function isVisible(element) { + if (!element.getClientRects().length) return false; + for (let current = element; current; current = current.parentElement) { + const style = getComputedStyle(current); + if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || Number(style.opacity) === 0 || current.getAttribute("aria-hidden") === "true") return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + function identify(element) { + if (element.id) return `#${CSS.escape(element.id)}`; + if (element.getAttribute("data-testid")) return `[data-testid="${CSS.escape(element.getAttribute("data-testid"))}"]`; + return element.tagName.toLowerCase(); + } + + function hasHorizontalScrollAncestor(element) { + for (let current = element.parentElement; current; current = current.parentElement) { + const style = getComputedStyle(current); + if (["auto", "scroll"].includes(style.overflowX) && current.scrollWidth > current.clientWidth + 1) return true; + } + return false; + } + + const viewportWidth = document.documentElement.clientWidth; + const pageWidth = document.documentElement.scrollWidth; + const interactive = [...document.querySelectorAll("button, a[href], input:not([type=hidden]), select, textarea, [role=button]")].filter(isVisible); + const elements = interactive.map(element => { + const rect = element.getBoundingClientRect(); + return { + selector: identify(element), + tag: element.tagName.toLowerCase(), + text: (element.innerText || element.value || element.getAttribute("aria-label") || "").trim().slice(0, 80), + rect: {left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height}, + inHorizontalScrollRegion: hasHorizontalScrollAncestor(element), + }; + }); + + const issues = []; + const textTargets = [...document.querySelectorAll("[data-audit-text], button, a[href], [role=button], label, h1, h2, h3, p, td, th")].filter(isVisible); + for (const element of textTargets) { + const style = getComputedStyle(element); + const clipsText = ["hidden", "clip"].includes(style.overflowX) || style.textOverflow === "ellipsis" || style.lineClamp !== "none"; + if (clipsText && element.scrollWidth > element.clientWidth + 2) { + issues.push({type: "text-clipping-candidate", selector: identify(element), text: (element.innerText || "").trim().slice(0, 80)}); + } + } + + for (const element of interactive) { + const rect = element.getBoundingClientRect(); + if ((rect.left < -1 || rect.right > viewportWidth + 1) && !hasHorizontalScrollAncestor(element)) { + issues.push({type: "action-outside-viewport-candidate", selector: identify(element), text: (element.innerText || element.value || "").trim().slice(0, 80)}); + } + if (!checkHitTesting || !["BUTTON", "A"].includes(element.tagName) && element.getAttribute("role") !== "button") continue; + + const insetX = Math.min(4, rect.width / 5); + const insetY = Math.min(4, rect.height / 5); + const points = [ + [rect.left + rect.width / 2, rect.top + rect.height / 2], + [rect.left + insetX, rect.top + insetY], + [rect.right - insetX, rect.top + insetY], + [rect.left + insetX, rect.bottom - insetY], + [rect.right - insetX, rect.bottom - insetY], + ].filter(([x, y]) => x >= 0 && y >= 0 && x < viewportWidth && y < innerHeight); + const blockedPoints = points.filter(([x, y]) => { + const top = document.elementFromPoint(x, y); + return top && top !== element && !element.contains(top); + }); + if (blockedPoints.length) issues.push({type: "hit-test-obstruction-candidate", selector: identify(element), sampledPoints: points.length, blockedPoints: blockedPoints.length}); + } + + return { + page: {viewportWidth, pageWidth, horizontalOverflow: pageWidth > viewportWidth + 1}, + elements, + issues, + }; + }, {checkHitTesting: options.checkHitTesting}); +} + +async function inspectScrollRegions(page, selectors, edgeSelector, outDir, viewportName) { + const regions = []; + let sequence = 0; + for (const selector of selectors) { + const locator = page.locator(selector); + const count = await locator.count(); + for (let index = 0; index < count; index++) { + const region = locator.nth(index); + const initial = await region.evaluate(element => ({ + label: element.getAttribute("aria-label") || element.id || element.className || "scroll-region", + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + initialScrollLeft: element.scrollLeft, + maxScrollLeft: Math.max(0, element.scrollWidth - element.clientWidth), + })); + const scrollable = initial.maxScrollLeft > 1; + if (scrollable) await region.evaluate(element => { element.scrollLeft = element.scrollWidth; }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + const endReached = await region.evaluate(element => Math.abs(element.scrollLeft - Math.max(0, element.scrollWidth - element.clientWidth)) <= 1); + let edgeVisible = null; + if (edgeSelector) { + const edge = region.locator(edgeSelector).last(); + if (await edge.count()) { + edgeVisible = await edge.evaluate((element, container) => { + const containerRect = container.getBoundingClientRect(); + const edgeRect = element.getBoundingClientRect(); + return edgeRect.left >= containerRect.left - 1 && edgeRect.right <= containerRect.right + 1; + }, await region.elementHandle()); + } else { + edgeVisible = false; + } + } + + sequence++; + const screenshot = `${viewportName}-scroll-${String(sequence).padStart(2, "0")}-${safeName(String(initial.label))}.png`; + if (scrollable) await region.screenshot({path: path.join(outDir, screenshot)}); + regions.push({...initial, selector, index, scrollable, endReached, edgeVisible, screenshot: scrollable ? screenshot : null}); + } + } + return regions; +} + +async function run() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const outDir = path.resolve(options.outDir); + fs.mkdirSync(outDir, {recursive: true}); + if (!options.force && fs.readdirSync(outDir).length) throw new Error(`Output directory is not empty; use --force to overwrite: ${outDir}`); + const target = resolveTarget(options.target); + const {chromium} = loadPlaywright(options.playwrightRoot); + const launchOptions = {headless: true}; + if (options.browser === "msedge") launchOptions.channel = "msedge"; + const browser = await chromium.launch(launchOptions); + const report = {target: sanitizeTarget(target), browser: options.browser, generatedAt: new Date().toISOString(), viewports: []}; + + try { + for (const viewport of options.viewports) { + const page = await browser.newPage({viewport: {width: viewport.width, height: viewport.height}}); + const pageErrors = []; + page.on("pageerror", error => pageErrors.push(error.message)); + try { + await page.goto(target, {waitUntil: "domcontentloaded", timeout: 60000}); + if (options.readySelector) await page.locator(options.readySelector).waitFor({state: "visible", timeout: 30000}); + if (options.waitMs) await page.waitForTimeout(options.waitMs); + const screenshot = `${viewport.name}.png`; + await page.screenshot({path: path.join(outDir, screenshot), fullPage: true}); + + const inspected = await inspectPage(page, options); + if (inspected.page.horizontalOverflow) inspected.issues.unshift({type: "page-horizontal-overflow", pageWidth: inspected.page.pageWidth, viewportWidth: inspected.page.viewportWidth}); + const scrollRegions = await inspectScrollRegions(page, options.scrollSelectors, options.edgeSelector, outDir, viewport.name); + report.viewports.push({...viewport, ...inspected, scrollRegions, pageErrors, screenshot}); + } finally { + await page.close(); + } + } + } finally { + await browser.close(); + } + + const reportPath = path.join(outDir, "layout-report.json"); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + console.log(`LAYOUT_AUDIT_DONE viewports=${report.viewports.length} report=${reportPath}`); +} + +try { + await run(); +} catch (error) { + console.error(`${error.name}: ${error.message}`); + if (process.argv.includes("--help")) console.error(usage()); + process.exitCode = 1; +} \ No newline at end of file diff --git a/skills/responsive-layout-audit/tests/fixtures/intentional-scroll.html b/skills/responsive-layout-audit/tests/fixtures/intentional-scroll.html new file mode 100644 index 000000000..76460af41 --- /dev/null +++ b/skills/responsive-layout-audit/tests/fixtures/intentional-scroll.html @@ -0,0 +1,38 @@ + + + + + + Intentional horizontal scroll fixture + + + +
+

監査fixture

+

長い表示文字列が省略される状態

+
+ + + +
先頭列中間列最終列
値1値2値3
+
+
+ + +
+ +
+ + \ No newline at end of file diff --git a/skills/responsive-layout-audit/tests/fixtures/page-overflow.html b/skills/responsive-layout-audit/tests/fixtures/page-overflow.html new file mode 100644 index 000000000..71960721f --- /dev/null +++ b/skills/responsive-layout-audit/tests/fixtures/page-overflow.html @@ -0,0 +1,14 @@ + + + + + + Page overflow fixture + + +
ページ幅を超える要素
+ \ No newline at end of file diff --git a/skills/responsive-layout-audit/tests/layout-audit.test.mjs b/skills/responsive-layout-audit/tests/layout-audit.test.mjs new file mode 100644 index 000000000..b39ccf889 --- /dev/null +++ b/skills/responsive-layout-audit/tests/layout-audit.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import {spawnSync} from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import {fileURLToPath, pathToFileURL} from "node:url"; +import test from "node:test"; + +const skillRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); +const auditScript = path.join(skillRoot, "scripts", "layout-audit.mjs"); +const playwrightRoot = process.env.PLAYWRIGHT_ROOT; + +function runAudit(fixture, outDir, additionalArgs = [], target = path.join(fixtures, fixture)) { + assert.ok(playwrightRoot, "PLAYWRIGHT_ROOT must point to a directory that can resolve Playwright"); + const result = spawnSync(process.execPath, [ + auditScript, + "--target", target, + "--out-dir", outDir, + "--playwright-root", playwrightRoot, + "--browser", "msedge", + "--viewports", "390x844", + ...additionalArgs, + ], {encoding: "utf8", timeout: 60000}); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + return JSON.parse(fs.readFileSync(path.join(outDir, "layout-report.json"), "utf8")); +} + +test("keeps intentional table scrolling separate and reports review candidates", t => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "layout-audit-scroll-")); + t.after(() => fs.rmSync(outDir, {recursive: true, force: true})); + const report = runAudit("intentional-scroll.html", outDir, [ + "--scroll-selector", ".table-viewport", + "--edge-selector", "thead th:last-child", + "--check-hit-testing", + ]); + const viewport = report.viewports[0]; + + assert.equal(viewport.page.horizontalOverflow, false); + assert.equal(viewport.scrollRegions[0].endReached, true); + assert.equal(viewport.scrollRegions[0].edgeVisible, true); + assert.ok(viewport.issues.some(issue => issue.type === "text-clipping-candidate" && issue.selector === "#clipped")); + assert.ok(viewport.issues.some(issue => issue.type === "hit-test-obstruction-candidate" && issue.selector === "#covered")); + assert.ok(!viewport.elements.some(element => element.selector === "#hidden")); + assert.ok(fs.statSync(path.join(outDir, viewport.screenshot)).size > 0); + assert.ok(fs.statSync(path.join(outDir, viewport.scrollRegions[0].screenshot)).size > 0); +}); + +test("reports page-level horizontal overflow", t => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "layout-audit-overflow-")); + t.after(() => fs.rmSync(outDir, {recursive: true, force: true})); + const report = runAudit("page-overflow.html", outDir); + const viewport = report.viewports[0]; + + assert.equal(viewport.page.horizontalOverflow, true); + assert.ok(viewport.issues.some(issue => issue.type === "page-horizontal-overflow")); +}); + +test("redacts secret-like query values in the report target", t => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "layout-audit-redaction-")); + t.after(() => fs.rmSync(outDir, {recursive: true, force: true})); + const target = pathToFileURL(path.join(fixtures, "intentional-scroll.html")); + target.searchParams.set("access_token", "secret-value"); + const report = runAudit("intentional-scroll.html", outDir, [], target.href); + + assert.match(report.target, /access_token=REDACTED/); + assert.doesNotMatch(JSON.stringify(report), /secret-value/); +}); \ No newline at end of file From 4ac41db6c879e5a3a738a9ef46a6ea0083a28f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E5=B7=9D=E3=80=80=E4=BF=8A=E4=BB=8B?= Date: Wed, 14 Oct 2026 22:08:04 +0900 Subject: [PATCH 2/4] fix(responsive-layout-audit): address review findings --- skills/responsive-layout-audit/SKILL.md | 25 ++++++++-- .../scripts/layout-audit.mjs | 46 ++++++++++++------ .../tests/layout-audit.test.mjs | 48 ++++++++++++++++++- 3 files changed, 99 insertions(+), 20 deletions(-) diff --git a/skills/responsive-layout-audit/SKILL.md b/skills/responsive-layout-audit/SKILL.md index b29826b27..e1610d50a 100644 --- a/skills/responsive-layout-audit/SKILL.md +++ b/skills/responsive-layout-audit/SKILL.md @@ -1,6 +1,6 @@ --- name: responsive-layout-audit -description: "Use this skill to audit responsive rendering for a web page or local HTML with Playwright. It checks page-level horizontal overflow, region-level horizontal scrolling, clipping candidates, and hit-test obstruction candidates, then outputs PNG and JSON evidence." +description: 'Use this skill to audit responsive rendering for a web page or local HTML with Playwright. It checks page-level horizontal overflow, region-level horizontal scrolling, clipping candidates, and hit-test obstruction candidates, then outputs PNG and JSON evidence.' argument-hint: "[target URL or HTML path] [viewports] [scroll selectors] [output directory]" --- @@ -28,7 +28,7 @@ If your project has its own viewport policy, use that policy and record the sele 1. Confirm target scope and avoid pages with sensitive production data. 2. Fix the viewport set, optional scroll selectors, optional edge selector, and output directory. 3. Reuse an existing Playwright installation when possible. -4. Run the script and inspect `layout-report.json` plus screenshots. +4. Run [scripts/layout-audit.mjs](scripts/layout-audit.mjs) from the skill directory and inspect `layout-report.json` plus screenshots. ```powershell node .\scripts\layout-audit.mjs ` @@ -46,15 +46,31 @@ If Playwright is not available, install it in a dedicated local runtime director ```powershell npm install --prefix .\run\playwright-runtime playwright +node .\run\playwright-runtime\node_modules\playwright\cli.js install chromium ``` +Chromium is the default. Its browser binary must be installed separately with the command above. Reuse an existing compatible runtime where available; obtain permission before installing dependencies or browsers. Use `--browser msedge` only when Microsoft Edge is installed on the host. + Record target, viewport set, selectors, Playwright/browser version, output paths, candidate dispositions, and remaining unknowns. +## Verification + +Run [tests/layout-audit.test.mjs](tests/layout-audit.test.mjs) with `PLAYWRIGHT_ROOT` set to the directory containing `node_modules/playwright` and managed Chromium installed: + +```powershell +$env:PLAYWRIGHT_ROOT = (Resolve-Path .\run\playwright-runtime).Path +node --test tests/layout-audit.test.mjs +``` + +The suite uses synthetic fixtures, including [intentional scrolling](tests/fixtures/intentional-scroll.html) and [page overflow](tests/fixtures/page-overflow.html), plus a generated regression page. Tests cover the default Chromium launch, clipping, rendered visibility, selector uniqueness, below-fold obstruction, RTL scrolling, bounded filenames, and URL query redaction. + +For a separate opt-in Edge run, set `LAYOUT_AUDIT_BROWSER=msedge` before running the suite. This does not replace managed-Chromium coverage; report the browser actually tested. + ## How to read results - `page.horizontalOverflow`: compares document width and viewport width. - `scrollRegions`: tracks region width, max scroll-left, and optional edge visibility. -- `text-clipping-candidate`: potential clipping based on CSS + width mismatch. +- `text-clipping-candidate`: horizontal clipping or vertical line-clamp overflow based on rendered CSS and geometry. - `action-outside-viewport-candidate`: interactive elements outside viewport bounds. - `hit-test-obstruction-candidate`: sampled-point obstruction candidates. - `pageErrors`: uncaught JavaScript errors in the page context. @@ -65,6 +81,7 @@ Record target, viewport set, selectors, Playwright/browser version, output paths - Keep style policy (colors/borders/visual language) separate from this mechanical audit. - Do not treat page-level width and container-level horizontal scroll as the same signal. - Do not treat `scrollWidth` mismatch alone as a defect. -- Hit-test checks samples only; this is not a full interaction test. +- Hit-test scrolls rendered buttons and links into view before sampling and restores the original scroll offsets afterward. Scrolling can trigger lazy loading or page event handlers. This is not a full interaction test and does not cover frames or shadow roots. +- `endReached` checks the scroll offset, including negative RTL offsets. `edgeVisible` checks geometry only; neither proves that text is readable or unobstructed. Inspect the PNGs as well. - The script redacts secret-like query values but is not a full anonymization layer. - Screenshots may include sensitive information. Use safe test data and controlled sharing. \ No newline at end of file diff --git a/skills/responsive-layout-audit/scripts/layout-audit.mjs b/skills/responsive-layout-audit/scripts/layout-audit.mjs index ecb34871b..6b2357e21 100644 --- a/skills/responsive-layout-audit/scripts/layout-audit.mjs +++ b/skills/responsive-layout-audit/scripts/layout-audit.mjs @@ -10,7 +10,7 @@ function usage() { Options: --playwright-root Directory whose node_modules contains Playwright - --browser Browser channel (default: msedge) + --browser Browser channel (default: chromium) --viewports Viewports (default: ${defaultViewports.join(",")}) --ready-selector Wait for a page element before capture --scroll-selector Repeatable horizontal scroll region selector @@ -23,7 +23,7 @@ Options: function parseArguments(args) { const options = { - browser: "msedge", + browser: "chromium", viewports: defaultViewports, scrollSelectors: [], waitMs: 250, @@ -105,25 +105,33 @@ function loadPlaywright(root) { } function safeName(value) { - return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "region"; + return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80).replace(/-+$/g, "") || "region"; } async function inspectPage(page, options) { - return page.evaluate(({checkHitTesting}) => { + return page.evaluate(async ({checkHitTesting}) => { function isVisible(element) { if (!element.getClientRects().length) return false; for (let current = element; current; current = current.parentElement) { const style = getComputedStyle(current); - if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || Number(style.opacity) === 0 || current.getAttribute("aria-hidden") === "true") return false; + if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || Number(style.opacity) === 0) return false; } const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function identify(element) { - if (element.id) return `#${CSS.escape(element.id)}`; - if (element.getAttribute("data-testid")) return `[data-testid="${CSS.escape(element.getAttribute("data-testid"))}"]`; - return element.tagName.toLowerCase(); + if (element.id && document.querySelectorAll(`#${CSS.escape(element.id)}`).length === 1) return `#${CSS.escape(element.id)}`; + if (element.getAttribute("data-testid")) { + const selector = `[data-testid="${CSS.escape(element.getAttribute("data-testid"))}"]`; + if (document.querySelectorAll(selector).length === 1) return selector; + } + const parts = []; + for (let current = element; current; current = current.parentElement) { + const siblings = current.parentElement ? [...current.parentElement.children].filter(sibling => sibling.tagName === current.tagName) : [current]; + parts.unshift(`${CSS.escape(current.localName)}:nth-of-type(${siblings.indexOf(current) + 1})`); + } + return parts.join(" > "); } function hasHorizontalScrollAncestor(element) { @@ -152,19 +160,25 @@ async function inspectPage(page, options) { const textTargets = [...document.querySelectorAll("[data-audit-text], button, a[href], [role=button], label, h1, h2, h3, p, td, th")].filter(isVisible); for (const element of textTargets) { const style = getComputedStyle(element); - const clipsText = ["hidden", "clip"].includes(style.overflowX) || style.textOverflow === "ellipsis" || style.lineClamp !== "none"; - if (clipsText && element.scrollWidth > element.clientWidth + 2) { + const lineClamp = style.lineClamp || style.webkitLineClamp; + const horizontallyClipped = (["hidden", "clip"].includes(style.overflowX) || style.textOverflow === "ellipsis") && element.scrollWidth > element.clientWidth + 2; + const verticallyClamped = Number.parseInt(lineClamp, 10) > 0 && ["hidden", "clip"].includes(style.overflowY) && element.scrollHeight > element.clientHeight + 2; + if (horizontallyClipped || verticallyClamped) { issues.push({type: "text-clipping-candidate", selector: identify(element), text: (element.innerText || "").trim().slice(0, 80)}); } } - for (const element of interactive) { - const rect = element.getBoundingClientRect(); - if ((rect.left < -1 || rect.right > viewportWidth + 1) && !hasHorizontalScrollAncestor(element)) { + const originalScroll = [...document.querySelectorAll("*")].filter(element => element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth).map(element => ({element, left: element.scrollLeft, top: element.scrollTop})); + for (const [index, element] of interactive.entries()) { + const originalRect = elements[index].rect; + if ((originalRect.left < -1 || originalRect.right > viewportWidth + 1) && !hasHorizontalScrollAncestor(element)) { issues.push({type: "action-outside-viewport-candidate", selector: identify(element), text: (element.innerText || element.value || "").trim().slice(0, 80)}); } if (!checkHitTesting || !["BUTTON", "A"].includes(element.tagName) && element.getAttribute("role") !== "button") continue; + element.scrollIntoView({block: "center", inline: "nearest", behavior: "instant"}); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const rect = element.getBoundingClientRect(); const insetX = Math.min(4, rect.width / 5); const insetY = Math.min(4, rect.height / 5); const points = [ @@ -180,6 +194,8 @@ async function inspectPage(page, options) { }); if (blockedPoints.length) issues.push({type: "hit-test-obstruction-candidate", selector: identify(element), sampledPoints: points.length, blockedPoints: blockedPoints.length}); } + for (const {element, left, top} of originalScroll) element.scrollTo({left, top, behavior: "instant"}); + if (checkHitTesting) await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); return { page: {viewportWidth, pageWidth, horizontalOverflow: pageWidth > viewportWidth + 1}, @@ -205,9 +221,9 @@ async function inspectScrollRegions(page, selectors, edgeSelector, outDir, viewp maxScrollLeft: Math.max(0, element.scrollWidth - element.clientWidth), })); const scrollable = initial.maxScrollLeft > 1; - if (scrollable) await region.evaluate(element => { element.scrollLeft = element.scrollWidth; }); + if (scrollable) await region.evaluate(element => { element.scrollLeft = getComputedStyle(element).direction === "rtl" ? -element.scrollWidth : element.scrollWidth; }); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); - const endReached = await region.evaluate(element => Math.abs(element.scrollLeft - Math.max(0, element.scrollWidth - element.clientWidth)) <= 1); + const endReached = await region.evaluate(element => Math.abs(Math.abs(element.scrollLeft) - Math.max(0, element.scrollWidth - element.clientWidth)) <= 1); let edgeVisible = null; if (edgeSelector) { const edge = region.locator(edgeSelector).last(); diff --git a/skills/responsive-layout-audit/tests/layout-audit.test.mjs b/skills/responsive-layout-audit/tests/layout-audit.test.mjs index b39ccf889..c08428de6 100644 --- a/skills/responsive-layout-audit/tests/layout-audit.test.mjs +++ b/skills/responsive-layout-audit/tests/layout-audit.test.mjs @@ -10,6 +10,7 @@ const skillRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".. const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); const auditScript = path.join(skillRoot, "scripts", "layout-audit.mjs"); const playwrightRoot = process.env.PLAYWRIGHT_ROOT; +const testBrowser = process.env.LAYOUT_AUDIT_BROWSER; function runAudit(fixture, outDir, additionalArgs = [], target = path.join(fixtures, fixture)) { assert.ok(playwrightRoot, "PLAYWRIGHT_ROOT must point to a directory that can resolve Playwright"); @@ -18,7 +19,7 @@ function runAudit(fixture, outDir, additionalArgs = [], target = path.join(fixtu "--target", target, "--out-dir", outDir, "--playwright-root", playwrightRoot, - "--browser", "msedge", + ...(testBrowser ? ["--browser", testBrowser] : []), "--viewports", "390x844", ...additionalArgs, ], {encoding: "utf8", timeout: 60000}); @@ -36,6 +37,7 @@ test("keeps intentional table scrolling separate and reports review candidates", ]); const viewport = report.viewports[0]; + assert.equal(report.browser, testBrowser || "chromium"); assert.equal(viewport.page.horizontalOverflow, false); assert.equal(viewport.scrollRegions[0].endReached, true); assert.equal(viewport.scrollRegions[0].edgeVisible, true); @@ -56,6 +58,12 @@ test("reports page-level horizontal overflow", t => { assert.ok(viewport.issues.some(issue => issue.type === "page-horizontal-overflow")); }); +test("documents Chromium as the default browser", () => { + const result = spawnSync(process.execPath, [auditScript, "--help"], {encoding: "utf8", timeout: 10000}); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Browser channel \(default: chromium\)/); +}); + test("redacts secret-like query values in the report target", t => { const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "layout-audit-redaction-")); t.after(() => fs.rmSync(outDir, {recursive: true, force: true})); @@ -65,4 +73,42 @@ test("redacts secret-like query values in the report target", t => { assert.match(report.target, /access_token=REDACTED/); assert.doesNotMatch(JSON.stringify(report), /secret-value/); +}); + +test("handles clipping, rendered visibility, unique selectors, offscreen controls and RTL", t => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "layout-audit-regression-")); + const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "layout-audit-source-")); + t.after(() => fs.rmSync(outDir, {recursive: true, force: true})); + t.after(() => fs.rmSync(sourceDir, {recursive: true, force: true})); + const target = path.join(sourceDir, "regression.html"); + fs.writeFileSync(target, ` +

${"Several words wrap onto many lines. ".repeat(12)}

+

Visible overflowing text without clipping

+ + +
StartEnd
+
+ `, "utf8"); + const report = runAudit("", outDir, ["--check-hit-testing", "--scroll-selector", "#rtl", "--edge-selector", ".end", "--viewports", "1392x900,390x844"], target); + assert.equal(report.viewports.length, 2); + for (const viewport of report.viewports) { + assert.ok(viewport.issues.some(issue => issue.type === "text-clipping-candidate" && issue.selector === "#clamped")); + assert.ok(!viewport.issues.some(issue => issue.type === "text-clipping-candidate" && issue.selector === "#unclipped")); + assert.ok(viewport.elements.some(element => element.selector === "#rendered")); + const selectors = viewport.elements.map(element => element.selector); + assert.equal(new Set(selectors).size, selectors.length); + assert.ok(viewport.issues.some(issue => issue.type === "hit-test-obstruction-candidate" && issue.selector === "#below")); + assert.equal(viewport.scrollRegions[0].endReached, true); + assert.equal(viewport.scrollRegions[0].edgeVisible, true); + assert.ok(viewport.scrollRegions[0].screenshot.length < 130); + } }); \ No newline at end of file From 6f25aed136445c66711f3306a4c9b476369b2f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E5=B7=9D=E3=80=80=E4=BF=8A=E4=BB=8B?= Date: Wed, 14 Oct 2026 22:40:29 +0900 Subject: [PATCH 3/4] fix(responsive-layout-audit): redact form values --- .../scripts/layout-audit.mjs | 4 ++-- .../tests/layout-audit.test.mjs | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/skills/responsive-layout-audit/scripts/layout-audit.mjs b/skills/responsive-layout-audit/scripts/layout-audit.mjs index 6b2357e21..597db81fa 100644 --- a/skills/responsive-layout-audit/scripts/layout-audit.mjs +++ b/skills/responsive-layout-audit/scripts/layout-audit.mjs @@ -150,7 +150,7 @@ async function inspectPage(page, options) { return { selector: identify(element), tag: element.tagName.toLowerCase(), - text: (element.innerText || element.value || element.getAttribute("aria-label") || "").trim().slice(0, 80), + text: (element.innerText || element.getAttribute("aria-label") || "").trim().slice(0, 80), rect: {left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height}, inHorizontalScrollRegion: hasHorizontalScrollAncestor(element), }; @@ -172,7 +172,7 @@ async function inspectPage(page, options) { for (const [index, element] of interactive.entries()) { const originalRect = elements[index].rect; if ((originalRect.left < -1 || originalRect.right > viewportWidth + 1) && !hasHorizontalScrollAncestor(element)) { - issues.push({type: "action-outside-viewport-candidate", selector: identify(element), text: (element.innerText || element.value || "").trim().slice(0, 80)}); + issues.push({type: "action-outside-viewport-candidate", selector: identify(element), text: (element.innerText || element.getAttribute("aria-label") || "").trim().slice(0, 80)}); } if (!checkHitTesting || !["BUTTON", "A"].includes(element.tagName) && element.getAttribute("role") !== "button") continue; diff --git a/skills/responsive-layout-audit/tests/layout-audit.test.mjs b/skills/responsive-layout-audit/tests/layout-audit.test.mjs index c08428de6..04f0cacbf 100644 --- a/skills/responsive-layout-audit/tests/layout-audit.test.mjs +++ b/skills/responsive-layout-audit/tests/layout-audit.test.mjs @@ -87,7 +87,9 @@ test("handles clipping, rendered visibility, unique selectors, offscreen control -webkit-line-clamp: 2; overflow: hidden; line-height: 20px; } #unclipped { width: 60px; white-space: nowrap; overflow: visible; } #rtl { direction: rtl; width: 250px; overflow-x: auto; } - #rtl > div { width: 700px; display: flex; justify-content: space-between; } + #rtl > div { position: relative; width: 700px; display: flex; justify-content: space-between; } + #inside-scroll-action { position: absolute; left: 0; } + #outside-action { position: absolute; left: calc(100vw + 10px); top: 20px; } .below { margin-top: 1200px; position: relative; height: 80px; } .cover { position: absolute; inset: 0; z-index: 2; } @@ -95,7 +97,9 @@ test("handles clipping, rendered visibility, unique selectors, offscreen control

Visible overflowing text without clipping

-
StartEnd
+
StartEnd
+ +
`, "utf8"); const report = runAudit("", outDir, ["--check-hit-testing", "--scroll-selector", "#rtl", "--edge-selector", ".end", "--viewports", "1392x900,390x844"], target); @@ -106,6 +110,13 @@ test("handles clipping, rendered visibility, unique selectors, offscreen control assert.ok(viewport.elements.some(element => element.selector === "#rendered")); const selectors = viewport.elements.map(element => element.selector); assert.equal(new Set(selectors).size, selectors.length); + assert.ok(viewport.issues.some(issue => issue.type === "action-outside-viewport-candidate" && issue.selector === "#outside-action")); + assert.ok(!viewport.issues.some(issue => issue.type === "action-outside-viewport-candidate" && issue.selector === "#inside-scroll-action")); + if (viewport.width === 390) { + const insideScrollAction = viewport.elements.find(element => element.selector === "#inside-scroll-action"); + assert.ok(insideScrollAction.rect.right < 0 || insideScrollAction.rect.left > viewport.width); + } + assert.doesNotMatch(JSON.stringify(report), /audit-secret-value/); assert.ok(viewport.issues.some(issue => issue.type === "hit-test-obstruction-candidate" && issue.selector === "#below")); assert.equal(viewport.scrollRegions[0].endReached, true); assert.equal(viewport.scrollRegions[0].edgeVisible, true); From ba35580a79f78fbac86f81ba99608b1d0b6a5785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E5=B7=9D=E3=80=80=E4=BF=8A=E4=BB=8B?= Date: Wed, 14 Oct 2026 23:00:35 +0900 Subject: [PATCH 4/4] fix(responsive-layout-audit): avoid ellipsis false positives --- skills/responsive-layout-audit/scripts/layout-audit.mjs | 2 +- skills/responsive-layout-audit/tests/layout-audit.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/responsive-layout-audit/scripts/layout-audit.mjs b/skills/responsive-layout-audit/scripts/layout-audit.mjs index 597db81fa..090646942 100644 --- a/skills/responsive-layout-audit/scripts/layout-audit.mjs +++ b/skills/responsive-layout-audit/scripts/layout-audit.mjs @@ -161,7 +161,7 @@ async function inspectPage(page, options) { for (const element of textTargets) { const style = getComputedStyle(element); const lineClamp = style.lineClamp || style.webkitLineClamp; - const horizontallyClipped = (["hidden", "clip"].includes(style.overflowX) || style.textOverflow === "ellipsis") && element.scrollWidth > element.clientWidth + 2; + const horizontallyClipped = ["hidden", "clip"].includes(style.overflowX) && element.scrollWidth > element.clientWidth + 2; const verticallyClamped = Number.parseInt(lineClamp, 10) > 0 && ["hidden", "clip"].includes(style.overflowY) && element.scrollHeight > element.clientHeight + 2; if (horizontallyClipped || verticallyClamped) { issues.push({type: "text-clipping-candidate", selector: identify(element), text: (element.innerText || "").trim().slice(0, 80)}); diff --git a/skills/responsive-layout-audit/tests/layout-audit.test.mjs b/skills/responsive-layout-audit/tests/layout-audit.test.mjs index 04f0cacbf..8c20a9707 100644 --- a/skills/responsive-layout-audit/tests/layout-audit.test.mjs +++ b/skills/responsive-layout-audit/tests/layout-audit.test.mjs @@ -85,7 +85,7 @@ test("handles clipping, rendered visibility, unique selectors, offscreen control body { margin: 8px; } #clamped { width: 140px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; line-height: 20px; } - #unclipped { width: 60px; white-space: nowrap; overflow: visible; } + #unclipped { width: 60px; white-space: nowrap; overflow: visible; text-overflow: ellipsis; } #rtl { direction: rtl; width: 250px; overflow-x: auto; } #rtl > div { position: relative; width: 700px; display: flex; justify-content: space-between; } #inside-scroll-action { position: absolute; left: 0; }