Add biome-ignore backpressure script, convert modals to native <dialog>

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 <dialog> 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 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-15 16:21:46 +01:00
parent d64e1f5e4a
commit e68145319f
5 changed files with 313 additions and 193 deletions

View File

@@ -527,8 +527,8 @@ export function CombatantRow({
</button> </button>
{/* Initiative */} {/* Initiative */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */} {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */} {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
@@ -587,8 +587,8 @@ export function CombatantRow({
</div> </div>
{/* AC */} {/* AC */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */} {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */} {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
<div <div
className={cn(dimmed && "opacity-50")} className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -598,8 +598,8 @@ export function CombatantRow({
</div> </div>
{/* HP */} {/* HP */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */} {/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper — no semantic role fits */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper for interactive children */} {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: stopPropagation wrapper — no semantic role fits */}
<div <div
className="flex items-center gap-1" className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}

View File

@@ -1,6 +1,6 @@
import type { PlayerCharacter } from "@initiative/domain"; import type { PlayerCharacter } from "@initiative/domain";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ColorPalette } from "./color-palette"; import { ColorPalette } from "./color-palette";
import { IconGrid } from "./icon-grid"; import { IconGrid } from "./icon-grid";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
@@ -25,6 +25,7 @@ export function CreatePlayerModal({
onSave, onSave,
playerCharacter, playerCharacter,
}: Readonly<CreatePlayerModalProps>) { }: Readonly<CreatePlayerModalProps>) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [ac, setAc] = useState("10"); const [ac, setAc] = useState("10");
const [maxHp, setMaxHp] = useState("10"); const [maxHp, setMaxHp] = useState("10");
@@ -54,15 +55,32 @@ export function CreatePlayerModal({
}, [open, playerCharacter]); }, [open, playerCharacter]);
useEffect(() => { useEffect(() => {
if (!open) return; const dialog = dialogRef.current;
function handleKeyDown(e: KeyboardEvent) { if (!dialog) return;
if (e.key === "Escape") onClose(); if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
} }
document.addEventListener("keydown", handleKeyDown); }, [open]);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
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<HTMLFormElement>) => { const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -86,17 +104,9 @@ export function CreatePlayerModal({
}; };
return ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close <dialog
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close ref={dialogRef}
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg"> <h2 className="font-semibold text-foreground text-lg">
@@ -114,9 +124,7 @@ export function CreatePlayerModal({
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div> <div>
<span className="mb-1 block text-muted-foreground text-sm"> <span className="mb-1 block text-muted-foreground text-sm">Name</span>
Name
</span>
<Input <Input
type="text" type="text"
value={name} value={name}
@@ -128,16 +136,12 @@ export function CreatePlayerModal({
aria-label="Name" aria-label="Name"
autoFocus autoFocus
/> />
{!!error && ( {!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
<p className="mt-1 text-destructive text-sm">{error}</p>
)}
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex-1"> <div className="flex-1">
<span className="mb-1 block text-muted-foreground text-sm"> <span className="mb-1 block text-muted-foreground text-sm">AC</span>
AC
</span>
<Input <Input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@@ -172,9 +176,7 @@ export function CreatePlayerModal({
</div> </div>
<div> <div>
<span className="mb-2 block text-muted-foreground text-sm"> <span className="mb-2 block text-muted-foreground text-sm">Icon</span>
Icon
</span>
<IconGrid value={icon} onChange={setIcon} /> <IconGrid value={icon} onChange={setIcon} />
</div> </div>
@@ -185,7 +187,6 @@ export function CreatePlayerModal({
<Button type="submit">{isEdit ? "Save" : "Create"}</Button> <Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div> </div>
</form> </form>
</div> </dialog>
</div>
); );
} }

View File

@@ -1,6 +1,6 @@
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain"; import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
import { Pencil, Plus, Trash2, X } from "lucide-react"; 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 { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ConfirmButton } from "./ui/confirm-button"; import { ConfirmButton } from "./ui/confirm-button";
@@ -22,29 +22,40 @@ export function PlayerManagement({
onDelete, onDelete,
onCreate, onCreate,
}: Readonly<PlayerManagementProps>) { }: Readonly<PlayerManagementProps>) {
useEffect(() => { const dialogRef = useRef<HTMLDialogElement>(null);
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
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 ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close <dialog
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop click to close ref={dialogRef}
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl backdrop:bg-black/50"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={onClose}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */}
<div
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
onMouseDown={(e) => e.stopPropagation()}
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg"> <h2 className="font-semibold text-foreground text-lg">
@@ -117,7 +128,6 @@ export function PlayerManagement({
</div> </div>
</div> </div>
)} )}
</div> </dialog>
</div>
); );
} }

View File

@@ -29,6 +29,7 @@
"knip": "knip", "knip": "knip",
"jscpd": "jscpd", "jscpd": "jscpd",
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware", "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"
} }
} }

View File

@@ -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.");
}