dnd-kit's screen-reader text is hard-coded English and names an item by its id ("Draggable item
10357 was dropped", a role of "sortable"). Only the YouTube playlist replaced it; the Plex playlist,
the saved-views list and the side-panel groups read it out under a Hungarian UI — found while
checking the Plex playlist's keyboard reorder on localdev.
`ui/sortableA11y` is now the one place: `useSortableA11y` gives a DndContext its instructions and
announcements (HU/EN, `common.dnd.*`), `useSortableItem` announces the role. The YouTube playlist's
own announcements move onto it, which also fixes them: its lists reorder on drop, so reading the
dragged row's own index said "moved to position 1" at every step and "dropped at position 1"
wherever the row went. The place announced is now the slot the row would land in.
A `local/sortable-a11y` lint rule (with its contract test) refuses a DndContext without
`accessibility` and a direct `useSortable`.
327 lines
15 KiB
JavaScript
327 lines
15 KiB
JavaScript
import js from "@eslint/js";
|
|
import tseslint from "typescript-eslint";
|
|
import reactHooks from "eslint-plugin-react-hooks";
|
|
import reactRefresh from "eslint-plugin-react-refresh";
|
|
import prettier from "eslint-config-prettier";
|
|
import globals from "globals";
|
|
|
|
// Flat config (ESLint 9). Deliberately NARROW: the high-value rule here is
|
|
// react-hooks/exhaustive-deps — 33 suppressions across the app exist for it, and until this
|
|
// sprint nothing ran to make them mean anything. tsc already owns unused vars, undefined names
|
|
// and types (noUnusedLocals/Parameters + strict), so typescript-eslint's non-type-checked
|
|
// `recommended` is layered on but its overlap with tsc is turned off below to avoid double-reporting
|
|
// and a churny diff. `eslint-config-prettier` is LAST so formatting rules never fight Prettier.
|
|
//
|
|
// reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself
|
|
// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate).
|
|
|
|
// E22 S4: every way out of the browser goes through the one module that LOGS it — a request line
|
|
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts), the messages socket
|
|
// (lib/messagesSocket.ts). A bare `fetch` elsewhere is a request the client log never sees, which is
|
|
// exactly how the baseline survey found four. Each owner is exempt from its OWN global only.
|
|
const NETWORK_OWNERS = {
|
|
fetch: "src/lib/http.ts",
|
|
XMLHttpRequest: "src/lib/api/core.ts",
|
|
WebSocket: "src/lib/messagesSocket.ts",
|
|
EventSource: null,
|
|
};
|
|
const networkGlobals = (exempt) =>
|
|
Object.entries(NETWORK_OWNERS)
|
|
.filter(([name]) => name !== exempt)
|
|
.map(([name, owner]) => ({
|
|
name,
|
|
message: owner
|
|
? `Use the logged request layer: \`${name}\` belongs to ${owner} only (E22 S4 — a request outside it never reaches the client log).`
|
|
: `No \`${name}\` without a logged owner module (E22 S4).`,
|
|
}));
|
|
const networkProperties = [
|
|
{ object: "window", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
|
|
{ object: "globalThis", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
|
|
{
|
|
object: "navigator",
|
|
property: "sendBeacon",
|
|
message: "Use lib/api/core.ts `beacon` (E22 S4).",
|
|
},
|
|
];
|
|
|
|
// A `//` or `/* */` inside JSX children is not a comment — it is TEXT, rendered on the page. One
|
|
// slipped in when a return was wrapped in a fragment (Playlists.tsx) and only a visual check caught
|
|
// it: tsc, eslint and every test stayed green. `eslint-plugin-react` has this rule, but it would bring
|
|
// ~1800 lockfile lines of dependencies for ten lines of logic, so it lives here.
|
|
const jsxCommentText = {
|
|
meta: {
|
|
type: "problem",
|
|
messages: { text: "A comment inside JSX children renders as text. Wrap it in {/* */}." },
|
|
},
|
|
create(context) {
|
|
return {
|
|
JSXText(node) {
|
|
if (/^\s*(\/\/|\/\*)/m.test(node.value)) context.report({ node, messageId: "text" });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
// A raw `<button disabled>` drops a focused keyboard user to <body> the moment its caller disables it
|
|
// (a mutation pending, a list at its end, a form gone clean) — measured in Chrome, and ~60 buttons in
|
|
// this app are disabled by their own click. `ui/FocusSafeButton` (and `ui/Button` on top of it)
|
|
// keeps the focus; this is what stops a new raw one from reopening the hole.
|
|
const focusSafeDisabled = {
|
|
meta: {
|
|
type: "problem",
|
|
messages: {
|
|
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Use ui/FocusSafeButton (or ui/Button).",
|
|
},
|
|
},
|
|
create(context) {
|
|
return {
|
|
JSXOpeningElement(node) {
|
|
if (node.name.type !== "JSXIdentifier" || node.name.name !== "button") return;
|
|
if (node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "disabled"))
|
|
context.report({ node, messageId: "raw" });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
|
|
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
|
|
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
|
|
// nothing looks at an attribute. A URL-shaped placeholder is not a word, so it passes.
|
|
const A11Y_TEXT = new Set([
|
|
"aria-label",
|
|
"aria-description",
|
|
"aria-placeholder",
|
|
"aria-roledescription",
|
|
"aria-valuetext",
|
|
"title",
|
|
"placeholder",
|
|
"alt",
|
|
]);
|
|
const literalText = (v) => {
|
|
if (!v) return null;
|
|
if (v.type === "Literal") return typeof v.value === "string" ? v.value : null;
|
|
if (v.type !== "JSXExpressionContainer") return null;
|
|
const e = v.expression;
|
|
if (e.type === "Literal") return typeof e.value === "string" ? e.value : null;
|
|
if (e.type === "TemplateLiteral" && e.expressions.length === 0) return e.quasis[0].value.cooked;
|
|
return null;
|
|
};
|
|
const literalA11yText = {
|
|
meta: {
|
|
type: "problem",
|
|
messages: { literal: "`{{name}}` is read or shown to the user: translate it with t()." },
|
|
},
|
|
create(context) {
|
|
return {
|
|
JSXAttribute(node) {
|
|
const name = node.name.type === "JSXIdentifier" ? node.name.name : null;
|
|
if (!name || !A11Y_TEXT.has(name)) return;
|
|
const text = literalText(node.value);
|
|
if (text && /[A-Za-z]{2,}/.test(text) && !/^https?:/.test(text))
|
|
context.report({ node, messageId: "literal", data: { name } });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
// dnd-kit's screen-reader text is hard-coded English and names an item by its id; three of the four
|
|
// sortable lists shipped it that way under a Hungarian UI. `ui/sortableA11y` is the one place that
|
|
// replaces it, and this is what keeps the next sortable list from skipping it.
|
|
const sortableA11y = {
|
|
meta: {
|
|
type: "problem",
|
|
messages: {
|
|
context: "A <DndContext> needs `accessibility={useSortableA11y(...)}` (ui/sortableA11y).",
|
|
item: "Use `useSortableItem` from ui/sortableA11y: it announces the role in the user's language.",
|
|
},
|
|
},
|
|
create(context) {
|
|
const own = context.filename.split("\\").join("/").endsWith("components/ui/sortableA11y.ts");
|
|
return {
|
|
JSXOpeningElement(node) {
|
|
if (node.name.type !== "JSXIdentifier" || node.name.name !== "DndContext") return;
|
|
if (
|
|
!node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "accessibility")
|
|
)
|
|
context.report({ node, messageId: "context" });
|
|
},
|
|
ImportDeclaration(node) {
|
|
if (own || node.source.value !== "@dnd-kit/sortable") return;
|
|
for (const sp of node.specifiers)
|
|
if (sp.type === "ImportSpecifier" && sp.imported.name === "useSortable")
|
|
context.report({ node: sp, messageId: "item" });
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const localRules = {
|
|
rules: {
|
|
"sortable-a11y": sortableA11y,
|
|
"jsx-comment-text": jsxCommentText,
|
|
"focus-safe-disabled": focusSafeDisabled,
|
|
"literal-a11y-text": literalA11yText,
|
|
},
|
|
};
|
|
|
|
// A px-frozen BOX utility (the no-restricted-syntax block below): an esquery regex, as a string.
|
|
const BOX_PX =
|
|
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[\\d+px\\]/";
|
|
const BOX_PX_MESSAGE =
|
|
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
|
|
|
|
export default tseslint.config(
|
|
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
|
{
|
|
files: ["src/**/*.{ts,tsx}"],
|
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
languageOptions: {
|
|
ecmaVersion: 2022,
|
|
globals: { ...globals.browser },
|
|
},
|
|
plugins: {
|
|
"react-hooks": reactHooks,
|
|
"react-refresh": reactRefresh,
|
|
local: localRules,
|
|
},
|
|
linterOptions: {
|
|
reportUnusedDisableDirectives: "error",
|
|
},
|
|
rules: {
|
|
...reactHooks.configs.recommended.rules,
|
|
"local/jsx-comment-text": "error",
|
|
"local/focus-safe-disabled": "error",
|
|
"local/literal-a11y-text": "error",
|
|
"local/sortable-a11y": "error",
|
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
|
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
|
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
|
"@typescript-eslint/no-unused-vars": "off",
|
|
"no-undef": "off",
|
|
// R7 S3a eliminated every explicit `any` (api.ts prefs/data bags → Record<string, unknown>;
|
|
// catch clauses → unknown + HttpError narrowing; the YouTube IFrame boundary → lib/youtube.ts
|
|
// types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from
|
|
// creeping back in. Prefer `unknown` + narrowing, or a typed boundary, over `any`.
|
|
"@typescript-eslint/no-explicit-any": "error",
|
|
// E22 S4: the client log (lib/log.ts) is the one place diagnostics go — redacted, levelled,
|
|
// bounded. A `console.*` call is none of those, and it is invisible to the user's own log.
|
|
"no-console": "error",
|
|
"no-restricted-globals": ["error", ...networkGlobals(null)],
|
|
"no-restricted-properties": ["error", ...networkProperties],
|
|
// Side-effect ternaries/short-circuits are an established idiom here (`cond ? a() : b()`,
|
|
// `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else.
|
|
"@typescript-eslint/no-unused-expressions": [
|
|
"error",
|
|
{ allowShortCircuit: true, allowTernary: true },
|
|
],
|
|
// R10 S2: keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT scale
|
|
// with the Settings --font-scale slider while its rem neighbours do; use `text-2xs`/`text-3xs`
|
|
// or a `text-[Nrem]` arbitrary instead. Matches both plain strings and template-literal chunks;
|
|
// rem arbitraries (text-[0.75rem]) don't match (no "px").
|
|
"no-restricted-syntax": [
|
|
"error",
|
|
{
|
|
selector: "Literal[value=/text-\\[\\d+px\\]/]",
|
|
message:
|
|
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
|
},
|
|
{
|
|
selector: "TemplateElement[value.raw=/text-\\[\\d+px\\]/]",
|
|
message:
|
|
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
|
},
|
|
// …and the same for a BOX: a `w-[150px]` stays 150px while the text inside it grows with the
|
|
// slider, so the label re-wraps or clips at large sizes. Use the spacing scale (`w-37.5` is
|
|
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
|
|
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
|
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
// E10 S1: `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
|
|
// a folder is enforced here, not in prose (see components/ui/README.md). A platform component is
|
|
// domain-free: it may import sibling primitives and generic `lib/` helpers, never a feature
|
|
// component (`../Feed`) and never a domain API slice. Without this rule the boundary erodes on
|
|
// the first "just this once" import and the directory silently becomes another components/.
|
|
files: ["src/components/ui/**/*.{ts,tsx}"],
|
|
rules: {
|
|
"no-restricted-imports": [
|
|
"error",
|
|
{
|
|
// NOTE the String.raw: these are REGEX sources, and in a plain "..." literal `\.` is an
|
|
// unknown escape that collapses to a bare `.` — the any-char wildcard. The first cut of
|
|
// this rule shipped that way and matched far less than it read like (E10 review round 1).
|
|
patterns: [
|
|
{
|
|
// `../X` (exactly one level up = a feature component). A `regex` rather than a glob:
|
|
// eslint matches `patterns` gitignore-style, where `../*` also swallows `../../lib/x`.
|
|
regex: String.raw`^\.\./(?!\.\./)`,
|
|
message:
|
|
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
|
},
|
|
{
|
|
// The same file by its OTHER spelling. `../../components/Feed` resolves exactly where
|
|
// `../Feed` does, so the rule above is only half the boundary; `ui/` is carved out
|
|
// because a sibling primitive is the one thing this path may legitimately name.
|
|
regex: String.raw`^\.\./\.\./components/(?!ui/)`,
|
|
message:
|
|
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
|
|
},
|
|
{
|
|
// `(/|$)` so the BARREL counts too: `../../lib/api` is the spelling every feature
|
|
// component uses, and a `/`-anchored pattern let exactly that one through.
|
|
regex: String.raw`^\.\./\.\./lib/api(/|$)`,
|
|
message:
|
|
"components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
// E11 S3: the same one-way rule one layer down. `lib/` is what components are built ON (hooks,
|
|
// the API slices, pure helpers), so it may not reach back UP into `components/` — not even for a
|
|
// type. That is how the `Column` contract came to be owned by a ui component that three lib
|
|
// helpers had to import from. A piece both layers need moves down into `lib/`.
|
|
files: ["src/lib/**/*.{ts,tsx}"],
|
|
rules: {
|
|
"no-restricted-imports": [
|
|
"error",
|
|
{
|
|
patterns: [
|
|
{
|
|
// Any depth (`lib/api/*` is one level deeper), and `(/|$)` so a bare barrel counts too.
|
|
regex: String.raw`^(\.\./)+components(/|$)`,
|
|
message:
|
|
"lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
// The network owners (see NETWORK_OWNERS), each exempt from its own global only.
|
|
...Object.entries(NETWORK_OWNERS)
|
|
.filter(([, owner]) => owner)
|
|
.map(([name, owner]) => ({
|
|
files: [owner],
|
|
rules: { "no-restricted-globals": ["error", ...networkGlobals(name)] },
|
|
})),
|
|
{
|
|
// The two that ARE the raw button: the primitive itself, and the popover's menu row, whose
|
|
// native `disabled` is what floating-ui's roving focus reads to skip it (see ActionMenu).
|
|
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
|
|
rules: { "local/focus-safe-disabled": "off" },
|
|
},
|
|
{
|
|
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
|
|
// …and render throwaway markup whose labels no user ever hears.
|
|
files: ["src/**/*.test.{ts,tsx}"],
|
|
rules: {
|
|
"no-restricted-globals": "off",
|
|
"no-restricted-properties": "off",
|
|
"local/literal-a11y-text": "off",
|
|
},
|
|
},
|
|
prettier,
|
|
);
|