From e68145319fc24d675642b0a534c28e394d93b599 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 15 Mar 2026 16:21:46 +0100 Subject: [PATCH] Add biome-ignore backpressure script, convert modals to native Adds scripts/check-lint-ignores.mjs with four enforcement mechanisms: ratcheting count cap (12 source / 3 test), banned rule prefixes, required justification, and separate test thresholds. Wired into pnpm check. Converts player-management and create-player-modal from div-based modals to native with showModal()/close(), removing 8 biome-ignore comments. Remaining ignores are legitimate (Biome false positives or stopPropagation wrappers with no fitting role). Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/combatant-row.tsx | 12 +- .../src/components/create-player-modal.tsx | 195 +++++++++--------- apps/web/src/components/player-management.tsx | 188 +++++++++-------- package.json | 3 +- scripts/check-lint-ignores.mjs | 108 ++++++++++ 5 files changed, 313 insertions(+), 193 deletions(-) create mode 100644 scripts/check-lint-ignores.mjs diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx index 7898f9a..80b7f6f 100644 --- a/apps/web/src/components/combatant-row.tsx +++ b/apps/web/src/components/combatant-row.tsx @@ -527,8 +527,8 @@ export function CombatantRow({ {/* Initiative */} - {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */} - {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */} + {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} @@ -587,8 +587,8 @@ export function CombatantRow({
{/* AC */} - {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */} - {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */} + {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
e.stopPropagation()} @@ -598,8 +598,8 @@ export function CombatantRow({
{/* HP */} - {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */} - {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */} + {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
e.stopPropagation()} diff --git a/apps/web/src/components/create-player-modal.tsx b/apps/web/src/components/create-player-modal.tsx index 972ccda..ad54e7c 100644 --- a/apps/web/src/components/create-player-modal.tsx +++ b/apps/web/src/components/create-player-modal.tsx @@ -1,6 +1,6 @@ import type { PlayerCharacter } from "@initiative/domain"; import { X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ColorPalette } from "./color-palette"; import { IconGrid } from "./icon-grid"; import { Button } from "./ui/button"; @@ -25,6 +25,7 @@ export function CreatePlayerModal({ onSave, playerCharacter, }: Readonly) { + const dialogRef = useRef(null); const [name, setName] = useState(""); const [ac, setAc] = useState("10"); const [maxHp, setMaxHp] = useState("10"); @@ -54,15 +55,32 @@ export function CreatePlayerModal({ }, [open, playerCharacter]); useEffect(() => { - if (!open) return; - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) { + dialog.showModal(); + } else if (!open && dialog.open) { + dialog.close(); } - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [open, onClose]); + }, [open]); - if (!open) return null; + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + function handleCancel(e: Event) { + e.preventDefault(); + onClose(); + } + function handleBackdropClick(e: MouseEvent) { + if (e.target === dialog) onClose(); + } + dialog.addEventListener("cancel", handleCancel); + dialog.addEventListener("mousedown", handleBackdropClick); + return () => { + dialog.removeEventListener("cancel", handleCancel); + dialog.removeEventListener("mousedown", handleBackdropClick); + }; + }, [onClose]); const handleSubmit = (e: React.SubmitEvent) => { e.preventDefault(); @@ -86,106 +104,89 @@ export function CreatePlayerModal({ }; return ( - // biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close - // biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close -
- {/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */} - {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */} -
e.stopPropagation()} - > -
-

- {isEdit ? "Edit Player" : "Create Player"} -

- +
+

+ {isEdit ? "Edit Player" : "Create Player"} +

+ +
+ +
+
+ Name + { + setName(e.target.value); + setError(""); + }} + placeholder="Character name" + aria-label="Name" + autoFocus + /> + {!!error &&

{error}

}
- -
+
+
+ AC + setAc(e.target.value)} + placeholder="AC" + aria-label="AC" + className="text-center" + /> +
+
- Name + Max HP { - setName(e.target.value); - setError(""); - }} - placeholder="Character name" - aria-label="Name" - autoFocus + inputMode="numeric" + value={maxHp} + onChange={(e) => setMaxHp(e.target.value)} + placeholder="Max HP" + aria-label="Max HP" + className="text-center" /> - {!!error && ( -

{error}

- )}
+
-
-
- - AC - - setAc(e.target.value)} - placeholder="AC" - aria-label="AC" - className="text-center" - /> -
-
- - Max HP - - setMaxHp(e.target.value)} - placeholder="Max HP" - aria-label="Max HP" - className="text-center" - /> -
-
+
+ + Color + + +
-
- - Color - - -
+
+ Icon + +
-
- - Icon - - -
- -
- - -
- -
-
+
+ + +
+ +
); } diff --git a/apps/web/src/components/player-management.tsx b/apps/web/src/components/player-management.tsx index f97e949..130c2b7 100644 --- a/apps/web/src/components/player-management.tsx +++ b/apps/web/src/components/player-management.tsx @@ -1,6 +1,6 @@ import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain"; import { Pencil, Plus, Trash2, X } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { Button } from "./ui/button"; import { ConfirmButton } from "./ui/confirm-button"; @@ -22,102 +22,112 @@ export function PlayerManagement({ onDelete, onCreate, }: Readonly) { - useEffect(() => { - if (!open) return; - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); - } - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [open, onClose]); + const dialogRef = useRef(null); - if (!open) return null; + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) { + dialog.showModal(); + } else if (!open && dialog.open) { + dialog.close(); + } + }, [open]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + function handleCancel(e: Event) { + e.preventDefault(); + onClose(); + } + function handleBackdropClick(e: MouseEvent) { + if (e.target === dialog) onClose(); + } + dialog.addEventListener("cancel", handleCancel); + dialog.addEventListener("mousedown", handleBackdropClick); + return () => { + dialog.removeEventListener("cancel", handleCancel); + dialog.removeEventListener("mousedown", handleBackdropClick); + }; + }, [onClose]); return ( - // biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close - // biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close -
- {/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */} - {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */} -
e.stopPropagation()} - > -
-

- Player Characters -

- +
+ + {characters.length === 0 ? ( +
+

No player characters yet

+
- - {characters.length === 0 ? ( -
-

No player characters yet

- + } + label="Delete player character" + onConfirm={() => onDelete(pc.id)} + size="icon-sm" + className="text-muted-foreground" + /> +
+ ); + })} +
+
- ) : ( -
- {characters.map((pc) => { - const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined; - const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined; - return ( -
- {!!Icon && ( - - )} - - {pc.name} - - - AC {pc.ac} - - - HP {pc.maxHp} - - - } - label="Delete player character" - onConfirm={() => onDelete(pc.id)} - size="icon-sm" - className="text-muted-foreground" - /> -
- ); - })} -
- -
-
- )} -
-
+ + )} +
); } diff --git a/package.json b/package.json index 733e502..d3a3a77 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "knip": "knip", "jscpd": "jscpd", "oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware", - "check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && tsc --build && vitest run && jscpd" + "check:ignores": "node scripts/check-lint-ignores.mjs", + "check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && tsc --build && vitest run && jscpd" } } diff --git a/scripts/check-lint-ignores.mjs b/scripts/check-lint-ignores.mjs new file mode 100644 index 0000000..8e3edb9 --- /dev/null +++ b/scripts/check-lint-ignores.mjs @@ -0,0 +1,108 @@ +/** + * Backpressure check for biome-ignore comments. + * + * 1. Ratcheting cap — source and test files have separate max counts. + * Lower these numbers as you fix ignores; they can never go up silently. + * 2. Banned rules — ignoring certain rule categories is never allowed. + * 3. Justification — every ignore must have a non-empty explanation after + * the rule name. + */ + +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +// ── Configuration ────────────────────────────────────────────────────── +const MAX_SOURCE_IGNORES = 12; +const MAX_TEST_IGNORES = 3; + +/** Rule prefixes that must never be suppressed. */ +const BANNED_PREFIXES = [ + "lint/security/", + "lint/correctness/noGlobalObjectCalls", + "lint/correctness/noUnsafeFinally", +]; +// ─────────────────────────────────────────────────────────────────────── + +const IGNORE_PATTERN = /biome-ignore\s+([\w/]+)(?::\s*(.*))?/; + +function findFiles() { + return execSync("git ls-files -- '*.ts' '*.tsx'", { encoding: "utf-8" }) + .trim() + .split("\n") + .filter(Boolean); +} + +function isTestFile(path) { + return ( + path.includes("__tests__/") || + path.endsWith(".test.ts") || + path.endsWith(".test.tsx") + ); +} + +let errors = 0; +let sourceCount = 0; +let testCount = 0; + +for (const file of findFiles()) { + const lines = readFileSync(file, "utf-8").split("\n"); + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(IGNORE_PATTERN); + if (!match) continue; + + const rule = match[1]; + const justification = (match[2] ?? "").trim(); + const loc = `${file}:${i + 1}`; + + // Count by category + if (isTestFile(file)) { + testCount++; + } else { + sourceCount++; + } + + // Banned rules + for (const prefix of BANNED_PREFIXES) { + if (rule.startsWith(prefix)) { + console.error(`BANNED: ${loc} — ${rule} must not be suppressed`); + errors++; + } + } + + // Justification required + if (!justification) { + console.error( + `MISSING JUSTIFICATION: ${loc} — biome-ignore ${rule} needs an explanation after the colon`, + ); + errors++; + } + } +} + +// Ratcheting caps +if (sourceCount > MAX_SOURCE_IGNORES) { + console.error( + `SOURCE CAP EXCEEDED: ${sourceCount} biome-ignore comments in source (max ${MAX_SOURCE_IGNORES}). Fix issues and lower the cap.`, + ); + errors++; +} + +if (testCount > MAX_TEST_IGNORES) { + console.error( + `TEST CAP EXCEEDED: ${testCount} biome-ignore comments in tests (max ${MAX_TEST_IGNORES}). Fix issues and lower the cap.`, + ); + errors++; +} + +// Summary +console.log( + `biome-ignore: ${sourceCount} source (max ${MAX_SOURCE_IGNORES}), ${testCount} test (max ${MAX_TEST_IGNORES})`, +); + +if (errors > 0) { + console.error(`\n${errors} problem(s) found.`); + process.exit(1); +} else { + console.log("All checks passed."); +}