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,106 +104,89 @@ 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 */} <div className="mb-4 flex items-center justify-between">
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */} <h2 className="font-semibold text-foreground text-lg">
<div {isEdit ? "Edit Player" : "Create Player"}
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl" </h2>
onMouseDown={(e) => e.stopPropagation()} <Button
> variant="ghost"
<div className="mb-4 flex items-center justify-between"> size="icon"
<h2 className="font-semibold text-foreground text-lg"> onClick={onClose}
{isEdit ? "Edit Player" : "Create Player"} className="text-muted-foreground"
</h2> >
<Button <X size={20} />
variant="ghost" </Button>
size="icon" </div>
onClick={onClose}
className="text-muted-foreground" <form onSubmit={handleSubmit} className="flex flex-col gap-4">
> <div>
<X size={20} /> <span className="mb-1 block text-muted-foreground text-sm">Name</span>
</Button> <Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setError("");
}}
placeholder="Character name"
aria-label="Name"
autoFocus
/>
{!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
</div> </div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <div className="flex gap-3">
<div> <div className="flex-1">
<span className="mb-1 block text-muted-foreground text-sm">AC</span>
<Input
type="text"
inputMode="numeric"
value={ac}
onChange={(e) => setAc(e.target.value)}
placeholder="AC"
aria-label="AC"
className="text-center"
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-muted-foreground text-sm"> <span className="mb-1 block text-muted-foreground text-sm">
Name Max HP
</span> </span>
<Input <Input
type="text" type="text"
value={name} inputMode="numeric"
onChange={(e) => { value={maxHp}
setName(e.target.value); onChange={(e) => setMaxHp(e.target.value)}
setError(""); placeholder="Max HP"
}} aria-label="Max HP"
placeholder="Character name" className="text-center"
aria-label="Name"
autoFocus
/> />
{!!error && (
<p className="mt-1 text-destructive text-sm">{error}</p>
)}
</div> </div>
</div>
<div className="flex gap-3"> <div>
<div className="flex-1"> <span className="mb-2 block text-muted-foreground text-sm">
<span className="mb-1 block text-muted-foreground text-sm"> Color
AC </span>
</span> <ColorPalette value={color} onChange={setColor} />
<Input </div>
type="text"
inputMode="numeric"
value={ac}
onChange={(e) => setAc(e.target.value)}
placeholder="AC"
aria-label="AC"
className="text-center"
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-muted-foreground text-sm">
Max HP
</span>
<Input
type="text"
inputMode="numeric"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
placeholder="Max HP"
aria-label="Max HP"
className="text-center"
/>
</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>
Color <IconGrid value={icon} onChange={setIcon} />
</span> </div>
<ColorPalette value={color} onChange={setColor} />
</div>
<div> <div className="flex justify-end gap-2 pt-2">
<span className="mb-2 block text-muted-foreground text-sm"> <Button type="button" variant="ghost" onClick={onClose}>
Icon Cancel
</span> </Button>
<IconGrid value={icon} onChange={setIcon} /> <Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div> </div>
</form>
<div className="flex justify-end gap-2 pt-2"> </dialog>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div>
</form>
</div>
</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,102 +22,112 @@ 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 */} <div className="mb-4 flex items-center justify-between">
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: prevent close when clicking modal content */} <h2 className="font-semibold text-foreground text-lg">
<div Player Characters
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl" </h2>
onMouseDown={(e) => e.stopPropagation()} <Button
> variant="ghost"
<div className="mb-4 flex items-center justify-between"> size="icon"
<h2 className="font-semibold text-foreground text-lg"> onClick={onClose}
Player Characters className="text-muted-foreground"
</h2> >
<Button <X size={20} />
variant="ghost" </Button>
size="icon" </div>
onClick={onClose}
className="text-muted-foreground" {characters.length === 0 ? (
> <div className="flex flex-col items-center gap-3 py-8 text-center">
<X size={20} /> <p className="text-muted-foreground">No player characters yet</p>
<Button onClick={onCreate}>
<Plus size={16} />
Create your first player character
</Button> </Button>
</div> </div>
) : (
{characters.length === 0 ? ( <div className="flex flex-col gap-1">
<div className="flex flex-col items-center gap-3 py-8 text-center"> {characters.map((pc) => {
<p className="text-muted-foreground">No player characters yet</p> const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
<Button onClick={onCreate}> const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
return (
<div
key={pc.id}
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
>
{!!Icon && (
<Icon size={18} style={{ color }} className="shrink-0" />
)}
<span className="flex-1 truncate text-foreground text-sm">
{pc.name}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
AC {pc.ac}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
HP {pc.maxHp}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onEdit(pc)}
className="text-muted-foreground"
title="Edit"
>
<Pencil size={14} />
</Button>
<ConfirmButton
icon={<Trash2 size={14} />}
label="Delete player character"
onConfirm={() => onDelete(pc.id)}
size="icon-sm"
className="text-muted-foreground"
/>
</div>
);
})}
<div className="mt-2 flex justify-end">
<Button onClick={onCreate} variant="ghost">
<Plus size={16} /> <Plus size={16} />
Create your first player character Add
</Button> </Button>
</div> </div>
) : ( </div>
<div className="flex flex-col gap-1"> )}
{characters.map((pc) => { </dialog>
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
return (
<div
key={pc.id}
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
>
{!!Icon && (
<Icon size={18} style={{ color }} className="shrink-0" />
)}
<span className="flex-1 truncate text-foreground text-sm">
{pc.name}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
AC {pc.ac}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
HP {pc.maxHp}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onEdit(pc)}
className="text-muted-foreground"
title="Edit"
>
<Pencil size={14} />
</Button>
<ConfirmButton
icon={<Trash2 size={14} />}
label="Delete player character"
onConfirm={() => onDelete(pc.id)}
size="icon-sm"
className="text-muted-foreground"
/>
</div>
);
})}
<div className="mt-2 flex justify-end">
<Button onClick={onCreate} variant="ghost">
<Plus size={16} />
Add
</Button>
</div>
</div>
)}
</div>
</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.");
}