@krizaka/config 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -151,6 +151,7 @@ npx krizaka-ratchet --json # in CI: the same check, machine-readable
151
151
  | `directTanstackImport` | `from` / `import` / `import(` / `require(` of `"@tanstack/…"` |
152
152
  | `localTableOrForm` | `<table`, and `<form` … `</form>` holding more fields than `maxFormFields` (default 1) |
153
153
  | `retiredImport` | an import of a dependency listed in `retire` (e.g. `["framer-motion"]`): the way out of a dependency |
154
+ | `routeStateSelection` | a Tabs / chip group / choice grid / segmented switch (incl. `RadioGroup.Root variant="segmented"`) / toggle group of a view driven by local state (`value`, `defaultValue`, `onValueChange`) without `routeKey`, and without `// @ephemeral: <reason>`: the URL is the state |
154
155
 
155
156
  The UI counters scan `app/**/*.tsx`, `components/**/*.tsx` and `src/**/*.tsx` by default (`node_modules`, `dist`,
156
157
  `.next` never), or the globs given on the command line; the stack counters scan every source file (`js`, `jsx`, `mjs`,
@@ -27,6 +27,7 @@ const LABELS = {
27
27
  directTanstackImport: "direct @tanstack/* imports",
28
28
  localTableOrForm: "hand-written tables / forms",
29
29
  retiredImport: "imports of retired deps",
30
+ routeStateSelection: "selections in local state (not the URL)",
30
31
  };
31
32
 
32
33
  let args;
package/eslint/plugin.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The `krizaka` ESLint plugin: one object carrying every rule, so that configs registering it compose (flat config
2
2
  // refuses two different objects under the same plugin name).
3
- import { envName, noDirectTanstackImport, noFetchInClientEffect, noLightVariant, noLocaleTernaryRule, noLocalTableOrForm, noRawPalette } from "./rules.js";
3
+ import { envName, noDirectTanstackImport, noFamilySwitcher, noFetchInClientEffect, noLightVariant, noLocaleTernaryRule, noLocalTableOrForm, noRawPalette,routeStateSelection } from "./rules.js";
4
4
 
5
5
  /** @type {import("eslint").ESLint.Plugin} */
6
6
  export const krizakaPlugin = {
@@ -13,6 +13,8 @@ export const krizakaPlugin = {
13
13
  "no-light-variant": noLightVariant,
14
14
  "no-raw-palette": noRawPalette,
15
15
  "env-name": envName,
16
+ "no-family-switcher": noFamilySwitcher,
17
+ "route-state-selection": routeStateSelection,
16
18
  },
17
19
  };
18
20
 
package/eslint/rules.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
- import { countFormFields, EFFECT_NAMES_SOURCE, FETCH_NAMES_SOURCE, LIGHT_VARIANT_SOURCE, paletteSource, TANSTACK_SCOPE } from "../patterns.js";
6
+ import { countFormFields, EFFECT_NAMES_SOURCE, FETCH_NAMES_SOURCE, isLocalSelection, LIGHT_VARIANT_SOURCE, paletteSource, TANSTACK_SCOPE } from "../patterns.js";
7
7
 
8
8
  /** @typedef {import("eslint").Rule.RuleModule} RuleModule */
9
9
  /** @typedef {import("estree").Node} Node */
@@ -100,6 +100,51 @@ export const noDirectTanstackImport = {
100
100
  },
101
101
  };
102
102
 
103
+ /** Oussama, 2026-10-22: « changer les tabs doit changer les routes » — a selection of a page lives in the URL. */
104
+ /** @type {RuleModule} */
105
+ export const routeStateSelection = {
106
+ meta: {
107
+ type: "problem",
108
+ docs: { description: "A Tabs / segmented / toggle / chip-group selection in a page keeps its state in the URL (routeKey), not in local state." },
109
+ schema: [],
110
+ messages: {
111
+ local:
112
+ "This selection is local state: changing it does not change the URL. Bind it to the route with `routeKey=\"…\"` (and `routeValues`, see `useRouteState` in @krizaka/ui/router), or justify it with `// @ephemeral: <reason>` on this line or the one above.",
113
+ },
114
+ },
115
+ create(context) {
116
+ const source = context.sourceCode;
117
+ return {
118
+ JSXOpeningElement: (/** @type {Node} */ node) => {
119
+ const lines = source.lines;
120
+ const line = /** @type {import("estree").SourceLocation} */ (node.loc).start.line;
121
+ if (isLocalSelection(source.getText(node), lines[line - 2] ?? "")) context.report({ node, messageId: "local" });
122
+ },
123
+ };
124
+ },
125
+ };
126
+
127
+ /** Oussama, 2026-10-22: krizaka.com is the only way back between the sites; no site carries the family switcher. */
128
+ /** @type {RuleModule} */
129
+ export const noFamilySwitcher = {
130
+ meta: {
131
+ type: "problem",
132
+ docs: { description: "Forbid the family switcher (NavBar.Family): krizaka.com is the only link back between the Krizaka sites." },
133
+ schema: [],
134
+ messages: {
135
+ family:
136
+ "No family switcher (NavBar.Family) on any site: krizaka.com is the only way back. The footer carries one quiet « A Krizaka project » link to https://www.krizaka.com (SiteFooter).",
137
+ },
138
+ },
139
+ create(context) {
140
+ return {
141
+ "JSXMemberExpression[object.name='NavBar'][property.name='Family']": (/** @type {Node} */ node) => context.report({ node, messageId: "family" }),
142
+ "JSXIdentifier[name='NavBarFamily']": (/** @type {Node} */ node) => context.report({ node, messageId: "family" }),
143
+ "ImportSpecifier[imported.name='NavBarFamily']": (/** @type {Node} */ node) => context.report({ node, messageId: "family" }),
144
+ };
145
+ },
146
+ };
147
+
103
148
  /** S2, S3: a table is `@krizaka/ui/data-table`, a form is `@krizaka/ui/form` — except a form of a single field. */
104
149
  /** @type {RuleModule} */
105
150
  export const noLocalTableOrForm = {
package/eslint/stack.js CHANGED
@@ -8,6 +8,8 @@ export const STACK_RULES = Object.freeze([
8
8
  "krizaka/no-fetch-in-client-effect",
9
9
  "krizaka/no-direct-tanstack-import",
10
10
  "krizaka/no-local-table-or-form",
11
+ "krizaka/no-family-switcher",
12
+ "krizaka/route-state-selection",
11
13
  ]);
12
14
 
13
15
  /**
@@ -32,6 +34,10 @@ export function krizakaStack({
32
34
  "krizaka/no-fetch-in-client-effect": severity,
33
35
  "krizaka/no-direct-tanstack-import": severity,
34
36
  "krizaka/no-local-table-or-form": [severity, { maxFormFields }],
37
+ // Never a warning: the family switcher is gone from every site (krizaka.com is the only way back).
38
+ "krizaka/no-family-switcher": "error",
39
+ // A selection of a view (tabs, segmented, toggles, chip groups) lives in the URL, not in local state.
40
+ "krizaka/route-state-selection": severity,
35
41
  },
36
42
  },
37
43
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/config",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "The shared configuration of every Krizaka JavaScript repository: ESLint flat configs (base, Next.js, the four UI rules, the three stack rules, no locale ternary), the dependency-cruiser boundary, tsconfig bases, Prettier, EditorConfig, and the krizaka-ratchet debt counter.",
5
5
  "keywords": [
6
6
  "krizaka",
@@ -108,7 +108,7 @@
108
108
  "ratchet/ratchet.js",
109
109
  "patterns.js"
110
110
  ],
111
- "limit": "8 kB",
111
+ "limit": "9 kB",
112
112
  "gzip": true
113
113
  }
114
114
  ],
package/patterns.js CHANGED
@@ -115,3 +115,57 @@ export function balancedArguments(source, open) {
115
115
  }
116
116
  return source.slice(open + 1);
117
117
  }
118
+
119
+ // ── Selections live in the URL (Oussama, 2026-10-22: « changer les tabs doit changer les routes ») ──────────────────────
120
+ // Read by the ESLint rule krizaka/route-state-selection and by the ratchet.
121
+
122
+ /** The selection components whose choice belongs in the URL: tabs, a segmented switch, toggle groups, chip groups, choice tiles. */
123
+ export const SELECTION_COMPONENTS = Object.freeze(["Tabs.Root", "TabsRoot", "Chip.Group", "ChipGroup", "ChoiceGrid", "SegmentedControl", "ToggleGroup", "ToggleGroup.Root", "RadioGroup.Root"]);
124
+
125
+ /** The props that make a selection local: its own value, its starting value, its change handler. */
126
+ export const LOCAL_SELECTION_PROPS = Object.freeze(["value", "defaultValue", "onValueChange"]);
127
+
128
+ /** The prop that binds it to the URL. */
129
+ export const ROUTE_KEY_PROP = "routeKey";
130
+
131
+ /** The justification that keeps a local selection: `// @ephemeral: a hover card's tab` on the line or the line above. */
132
+ export const EPHEMERAL_SOURCE = String.raw`@ephemeral:\s*\S`;
133
+
134
+
135
+ /** The opening tags (`<Tabs.Root … >`) of the selection components in a source text, each with the line above it. */
136
+ export function selectionTags(/** @type {string} */ source) {
137
+ /** @type {{ tag: string, before: string }[]} */
138
+ const found = [];
139
+ const names = SELECTION_COMPONENTS.map((n) => n.replace(".", "\\.")).join("|");
140
+ for (const open of source.matchAll(new RegExp(`<(?:${names})(?![\\w.])`, "g"))) {
141
+ let depth = 0;
142
+ let end = open.index;
143
+ for (; end < source.length; end += 1) {
144
+ const c = source[end];
145
+ if (c === "{") depth += 1;
146
+ else if (c === "}") depth -= 1;
147
+ else if (c === ">" && depth === 0) break;
148
+ }
149
+ const lineStart = source.lastIndexOf("\n", open.index - 1);
150
+ const before = lineStart < 0 ? "" : source.slice(source.lastIndexOf("\n", lineStart - 1) + 1, lineStart);
151
+ found.push({ tag: source.slice(open.index, end + 1), before });
152
+ }
153
+ return found;
154
+ }
155
+
156
+ /**
157
+ * Is this opening tag a selection driven by local state? `tag` is its source (`<Tabs.Root value={x} onValueChange={set}>`),
158
+ * `before` the text of the line above it.
159
+ * @param {string} tag
160
+ * @param {string} [before]
161
+ */
162
+ export function isLocalSelection(tag, before = "") {
163
+ const name = /^<([A-Za-z][\w.]*)/.exec(tag)?.[1];
164
+ if (!name || !SELECTION_COMPONENTS.includes(name)) return false;
165
+ // A radio group is a form field unless it is drawn as a segmented switch (`variant="segmented"`): that one is a view's selection.
166
+ if (name === "RadioGroup.Root" && !/\bvariant\s*=\s*["']segmented["']/.test(tag)) return false;
167
+ const hasProp = (/** @type {string} */ prop) => new RegExp(`\\b${prop}\\s*(=|\\{|\\s|/?>)`).test(tag);
168
+ if (hasProp(ROUTE_KEY_PROP)) return false;
169
+ if (new RegExp(EPHEMERAL_SOURCE).test(tag) || new RegExp(EPHEMERAL_SOURCE).test(before)) return false;
170
+ return LOCAL_SELECTION_PROPS.some((prop) => new RegExp(`\\b${prop}\\s*=`).test(tag));
171
+ }
@@ -14,8 +14,10 @@ import {
14
14
  EFFECT_CALL_SOURCE,
15
15
  FETCH_CALL_SOURCE,
16
16
  importSource,
17
+ isLocalSelection,
17
18
  LIGHT_VARIANT_SOURCE,
18
19
  paletteSource,
20
+ selectionTags,
19
21
  TABLE_OR_FORM_SOURCE,
20
22
  TANSTACK_SCOPE,
21
23
  USE_CLIENT_SOURCE,
@@ -30,7 +32,7 @@ export const DEFAULT_STACK_GLOBS = Object.freeze(
30
32
  const IGNORE = ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/storybook-static/**", "**/coverage/**"];
31
33
 
32
34
  /** @typedef {"palette" | "light" | "arbitraryVar" | "classNameTemplate"} UiCounterName */
33
- /** @typedef {"fetchInClientEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport"} StackCounterName */
35
+ /** @typedef {"fetchInClientEffect" | "directTanstackImport" | "localTableOrForm" | "retiredImport" | "routeStateSelection"} StackCounterName */
34
36
  /** @typedef {UiCounterName | StackCounterName} CounterName */
35
37
  /** @typedef {Record<CounterName, number>} Counters */
36
38
  /**
@@ -51,6 +53,7 @@ export const STACK_COUNTERS = /** @type {const} */ ([
51
53
  "directTanstackImport",
52
54
  "localTableOrForm",
53
55
  "retiredImport",
56
+ "routeStateSelection",
54
57
  ]);
55
58
  /** Every counter, in report order. */
56
59
  export const COUNTERS = /** @type {const} */ ([...UI_COUNTERS, ...STACK_COUNTERS]);
@@ -111,6 +114,7 @@ export function countStackSource(source, { retire = [], maxFormFields = 1 } = {}
111
114
  directTanstackImport: source.match(new RegExp(importSource([TANSTACK_SCOPE]), "g"))?.length ?? 0,
112
115
  localTableOrForm,
113
116
  retiredImport: source.match(new RegExp(importSource(retire), "g"))?.length ?? 0,
117
+ routeStateSelection: selectionTags(source).filter(({ tag, before }) => isLocalSelection(tag, before)).length,
114
118
  };
115
119
  }
116
120