The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
101 lines
5.0 KiB
JavaScript
101 lines
5.0 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).
|
|
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,
|
|
},
|
|
linterOptions: {
|
|
reportUnusedDisableDirectives: "error",
|
|
},
|
|
rules: {
|
|
...reactHooks.configs.recommended.rules,
|
|
"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",
|
|
// 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"). Font-size `text-` only — not w-/h-/p-.
|
|
"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).",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
// 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",
|
|
{
|
|
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: "^\.\./(?!\.\./)",
|
|
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/.",
|
|
},
|
|
{
|
|
regex: "^\.\./\.\./lib/api/",
|
|
message:
|
|
"components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
prettier,
|
|
);
|