Typing "theme" put the channel "The Meltzone Podcast" (matched only through its handle `@themeltzone`) above every "Theme: …" row, and Enter opened the channel — the palette listed matches in pure group order and always highlighted the first one. - CommandPalette ranks each word: label starts with it > elsewhere in the label > only the hint, keywords or group name; an item ranks by its worst word, then the sum. The group order stays fixed, rows are ranked inside each group, and the best row overall starts highlighted. The highlight is held by id and follows the best match until the arrows or the pointer pick a row, so a channel list landing mid-typing no longer shifts it. - A11y: result groups are `role="group"` named by their heading; `aria-expanded` is false with nothing to choose; the listbox stays mounted (hidden) so `aria-controls` never names a missing element, and "Nothing matches" sits outside it; a polite status announces the count once the typing pauses. - `Column`/`FilterMap`/`filterRows`/`isActive` move from `ui/tableHeader` to `lib/tableColumns`, and the player's auto/loop modes from `playerModalControls` to `lib/playerModes` — three lib helpers imported the column contract from a ui component, and `playerModalHooks` its types from a feature component. - eslint: `lib/` may not import from `components/` at any depth, type-only and re-exports included; the boundary contract test proves it fires. CLAUDE.md and the ui README say so. - keymap: `dispatch`'s docblock sat stacked on `matchesShortcut`'s; moved onto `dispatch`.
136 lines
6.9 KiB
JavaScript
136 lines
6.9 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",
|
|
{
|
|
// 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/.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
prettier,
|
|
);
|