scripts/focus-walk.mjs
A supporting file of the a11y-check-page skill, shown as source.
#!/usr/bin/env node
// キーボードで Tab / Shift+Tab を繰り返し、各ステップのフォーカス位置とフォーカスインジケーターを
// 実際の :focus-visible 適用状態で記録して JSON 出力する (KBD-07 フォーカス順序 /
// KBD-08 フォーカスの可視性 / KBD-09 フォーカスの隠蔽の材料)。
//
// Playwright MCP でも browser_press_key + browser_evaluate で同じことはできるが、要素数が多いと
// 1 ステップごとに往復が発生する。多数の要素をまとめて巡回したいときにこのスクリプトが有効。
// URL だけで再現できる状態向け。モーダルを開いた状態などは MCP 側で確認する。
//
// 使い方:
// node focus-walk.mjs <url> [オプション]
//
// オプション:
// --max <n> Tab を押す最大回数 (既定: 60)
// --reverse Shift+Tab で逆方向に巡回する (既定: 順方向)
// --width <px> ビューポート幅 (既定: 1280)
// --height <px> ビューポート高さ (既定: 900)
// --wait <state> load|domcontentloaded|networkidle (既定: networkidle)
// --out <file> 結果の出力先ファイル (既定: 標準出力)
// --help このヘルプを表示
//
// 出力の各ステップ: tag, role, type, name, selector, tabIndex, rect, visible,
// outline, boxShadow, focusIndicator (見た目の指標があるか)
// focusIndicator は outline か box-shadow のどちらかがあるかの近似。最終的な視認性は
// スクリーンショットでも確認すること。KBD-08 は「見えるか」が本質。
import { writeFile } from "node:fs/promises";
import { launchChromium } from "./browser.mjs";
const USAGE = `使い方:
node focus-walk.mjs <url> [オプション]
オプション:
--max <n> Tab を押す最大回数 (既定: 60)
--reverse Shift+Tab で逆方向に巡回する
--width <px> ビューポート幅 (既定: 1280)
--height <px> ビューポート高さ (既定: 900)
--wait <state> load|domcontentloaded|networkidle (既定: networkidle)
--out <file> 結果の出力先ファイル (既定: 標準出力)
--help このヘルプを表示
`;
/** ページ内で現在のフォーカス要素の情報を返す。playwright-workflow.md のスニペット相当。 */
function inspectActive() {
const el = document.activeElement;
if (!el || el === document.body || el === document.documentElement) {
return { none: true, tag: el ? el.tagName.toLowerCase() : null };
}
const s = getComputedStyle(el);
const r = el.getBoundingClientRect();
const hasOutline = s.outlineStyle !== "none" && parseFloat(s.outlineWidth) > 0;
const hasShadow = !!s.boxShadow && s.boxShadow !== "none";
const cls = typeof el.className === "string" ? el.className.split(" ")[0] : "";
return {
tag: el.tagName.toLowerCase(),
role: el.getAttribute("role"),
type: el.getAttribute("type"),
name: (el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 80),
selector: el.id ? `#${el.id}` : cls ? `${el.tagName.toLowerCase()}.${cls}` : el.tagName.toLowerCase(),
tabIndex: el.tabIndex,
rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
visible:
r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < innerHeight && s.visibility !== "hidden" && s.opacity !== "0",
outline: `${s.outlineStyle} ${s.outlineWidth} ${s.outlineColor}`,
boxShadow: (s.boxShadow || "none").slice(0, 60),
focusIndicator: hasOutline || hasShadow,
};
}
function parseArgs(argv) {
const opts = { max: 60, reverse: false, width: 1280, height: 900, wait: "networkidle", out: null, url: null };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--help" || a === "-h") return { help: true };
else if (a === "--max") opts.max = parseInt(argv[++i], 10);
else if (a === "--reverse") opts.reverse = true;
else if (a === "--width") opts.width = parseInt(argv[++i], 10);
else if (a === "--height") opts.height = parseInt(argv[++i], 10);
else if (a === "--wait") opts.wait = argv[++i];
else if (a === "--out") opts.out = argv[++i];
else if (a.startsWith("--")) throw new Error(`不明なオプション: ${a}`);
else if (!opts.url) opts.url = a;
else throw new Error(`余分な引数: ${a}`);
}
return opts;
}
async function main(argv) {
let opts;
try {
opts = parseArgs(argv);
} catch (e) {
process.stderr.write(`${e.message}\n\n${USAGE}`);
return 1;
}
if (opts.help || !opts.url) {
process.stdout.write(USAGE);
return opts.help ? 0 : 1;
}
const key = opts.reverse ? "Shift+Tab" : "Tab";
const browser = await launchChromium();
try {
const page = await browser.newPage({ viewport: { width: opts.width, height: opts.height } });
await page.goto(opts.url, { waitUntil: opts.wait });
const steps = [];
for (let i = 0; i < opts.max; i++) {
await page.keyboard.press(key);
const info = await page.evaluate(inspectActive);
steps.push({ i, ...info });
}
const result = {
url: opts.url,
direction: opts.reverse ? "reverse (Shift+Tab)" : "forward (Tab)",
viewport: { width: opts.width, height: opts.height },
steps,
};
const json = JSON.stringify(result, null, 2);
if (opts.out) {
await writeFile(opts.out, json + "\n", "utf8");
const noInd = steps.filter((s) => !s.none && !s.focusIndicator).length;
process.stderr.write(`${opts.out} に書き出しました (${steps.length} ステップ, インジケーター無し ${noInd} 件)\n`);
} else {
process.stdout.write(json + "\n");
}
return 0;
} finally {
await browser.close();
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main(process.argv.slice(2)).then(
(code) => {
process.exitCode = code;
},
(e) => {
process.stderr.write(`${e.message}\n`);
process.exitCode = 1;
}
);
}