stablekit.ts 0.2.3 → 0.3.1

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
@@ -202,6 +202,31 @@ For CSP nonce support:
202
202
  <meta name="stablekit-nonce" content="your-nonce-here" />
203
203
  ```
204
204
 
205
+ ## Out of Scope: Font-Swap CLS
206
+
207
+ StableKit solves **structural** layout shifts — conditional mounting, dynamic content sizing, state-driven geometry changes. These are DOM structure problems that React components can fix.
208
+
209
+ Font-swap CLS is a different beast. When a web font loads and replaces the fallback font, the browser re-renders text with different metrics. This is a **typographic metrics** problem, not a DOM structure problem. No React component can pre-allocate geometry for a font that hasn't been downloaded yet — the browser doesn't know the dimensions until the font file arrives.
210
+
211
+ To fix font-swap CLS, use CSS `@font-face` metric overrides:
212
+
213
+ ```css
214
+ @font-face {
215
+ font-family: "Inter Fallback";
216
+ src: local("Arial");
217
+ size-adjust: 107%;
218
+ ascent-override: 90%;
219
+ descent-override: 22%;
220
+ line-gap-override: 0%;
221
+ }
222
+
223
+ body {
224
+ font-family: "Inter", "Inter Fallback", sans-serif;
225
+ }
226
+ ```
227
+
228
+ Tools like [Capsize](https://seek-oss.github.io/capsize/), [Fontaine](https://github.com/unjs/fontaine), and `next/font` generate these overrides automatically.
229
+
205
230
  ## License
206
231
 
207
232
  MIT
package/dist/eslint.cjs CHANGED
@@ -23,11 +23,72 @@ __export(eslint_exports, {
23
23
  createArchitectureLint: () => createArchitectureLint
24
24
  });
25
25
  module.exports = __toCommonJS(eslint_exports);
26
+ function findVariable(scope, name) {
27
+ let current = scope;
28
+ while (current) {
29
+ const variable = current.set.get(name);
30
+ if (variable) return variable;
31
+ current = current.upper;
32
+ }
33
+ return null;
34
+ }
35
+ var noLoadingConflictRule = {
36
+ meta: {
37
+ type: "problem",
38
+ schema: [
39
+ {
40
+ type: "object",
41
+ properties: {
42
+ passthrough: { type: "array", items: { type: "string" } }
43
+ },
44
+ additionalProperties: false
45
+ }
46
+ ],
47
+ messages: {
48
+ conflict: "Conditional variable as children of a component with a loading prop. The loading prop triggers an internal content swap \u2014 if children also change, both sides shrink simultaneously and pre-allocation is defeated. Use static children with the loading prop, or handle the entire swap explicitly with <StateSwap> in the caller."
49
+ }
50
+ },
51
+ create(context) {
52
+ const passthrough = context.options[0]?.passthrough ?? [];
53
+ return {
54
+ JSXElement(node) {
55
+ const opening = node.openingElement;
56
+ if (opening.name?.type !== "JSXIdentifier") return;
57
+ const name = opening.name.name;
58
+ if (!/^[A-Z]/.test(name)) return;
59
+ if (passthrough.includes(name)) return;
60
+ const hasLoading = opening.attributes.some(
61
+ (attr) => attr.type === "JSXAttribute" && attr.name?.name === "loading"
62
+ );
63
+ if (!hasLoading) return;
64
+ for (const child of node.children) {
65
+ if (child.type !== "JSXExpressionContainer") continue;
66
+ const expr = child.expression;
67
+ if (expr.type !== "Identifier") continue;
68
+ const scope = context.sourceCode ? context.sourceCode.getScope(node) : context.getScope();
69
+ const variable = findVariable(scope, expr.name);
70
+ if (!variable) continue;
71
+ for (const def of variable.defs) {
72
+ if (def.type === "Parameter") continue;
73
+ if (def.type === "Variable" && def.node.init) {
74
+ const initType = def.node.init.type;
75
+ if (initType === "ConditionalExpression" || initType === "LogicalExpression" || initType === "TemplateLiteral") {
76
+ context.report({ node: expr, messageId: "conflict" });
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ };
83
+ }
84
+ };
26
85
  function createArchitectureLint(options) {
27
86
  const {
28
87
  stateTokens,
29
88
  variantProps = [],
30
89
  banColorUtilities = true,
90
+ classNamePassthrough = [],
91
+ loadingPassthrough = ["LoadingBoundary"],
31
92
  files = ["src/components/**/*.{tsx,jsx}"]
32
93
  } = options;
33
94
  const tokenPattern = stateTokens.join("|");
@@ -146,8 +207,28 @@ function createArchitectureLint(options) {
146
207
  {
147
208
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
148
209
  message: "Interpolated text in JSX children. Extract to a const above the return (the linter won't flag a plain variable). For state-driven values, use <StableCounter> for numbers or <StateSwap> for text variants."
210
+ },
211
+ // --- 5. className on custom components ---
212
+ ...classNamePassthrough.length ? [
213
+ {
214
+ selector: `JSXOpeningElement[name.name=/^[A-Z]/]:not([name.name=/^(${classNamePassthrough.join("|")})$/]) > JSXAttribute[name.name='className']`,
215
+ message: "className on a custom component. Components own their own styling \u2014 use a data-attribute, variant prop, or CSS class internally instead of passing Tailwind utilities from the consumer."
216
+ }
217
+ ] : [
218
+ {
219
+ selector: "JSXOpeningElement[name.name=/^[A-Z]/] > JSXAttribute[name.name='className']",
220
+ message: "className on a custom component. Components own their own styling \u2014 use a data-attribute, variant prop, or CSS class internally instead of passing Tailwind utilities from the consumer."
221
+ }
222
+ ]
223
+ ],
224
+ "stablekit/no-loading-conflict": ["error", { passthrough: loadingPassthrough }]
225
+ },
226
+ plugins: {
227
+ stablekit: {
228
+ rules: {
229
+ "no-loading-conflict": noLoadingConflictRule
149
230
  }
150
- ]
231
+ }
151
232
  }
152
233
  };
153
234
  }
package/dist/eslint.d.cts CHANGED
@@ -25,6 +25,17 @@
25
25
  * and interpolated template literals. Each message guides toward
26
26
  * extracting the expression to a variable (for data transforms) or
27
27
  * using a StableKit component (for state-driven swaps). Always on.
28
+ *
29
+ * 5. className on custom components — passing className to a PascalCase
30
+ * component is a presentation leak across the Structure boundary.
31
+ * The component should own its own styling. Always on.
32
+ *
33
+ * 6. Dual-paradigm conflict (custom rule with scope analysis) — a component
34
+ * with a `loading` prop does internal content swapping (StateSwap). If
35
+ * children are a variable derived from a conditional expression, both
36
+ * sides of the swap change simultaneously, defeating pre-allocation.
37
+ * Only flags locals initialized with ternaries/logical expressions.
38
+ * Props (function parameters) are allowed — they're static per mount.
28
39
  */
29
40
  interface ArchitectureLintOptions {
30
41
  /** State token names that should never appear in JS as Tailwind classes.
@@ -38,6 +49,16 @@ interface ArchitectureLintOptions {
38
49
  * Colors must live in CSS — not in component classNames.
39
50
  * @default true */
40
51
  banColorUtilities?: boolean;
52
+ /** Components that transparently pass className to their root element.
53
+ * These are excluded from the className-on-component ban.
54
+ * e.g. ["StableText", "StableCounter", "MediaSkeleton", "ChevronDown"]
55
+ * @default [] */
56
+ classNamePassthrough?: string[];
57
+ /** Components where `loading` prop does NOT trigger a content swap
58
+ * (e.g. LoadingBoundary controls opacity, not geometry).
59
+ * These are excluded from the dual-paradigm conflict rule.
60
+ * @default ["LoadingBoundary"] */
61
+ loadingPassthrough?: string[];
41
62
  /** Glob patterns for files to lint.
42
63
  * @default ["src/components/**\/*.{tsx,jsx}"] */
43
64
  files?: string[];
@@ -49,6 +70,16 @@ declare function createArchitectureLint(options: ArchitectureLintOptions): {
49
70
  selector: string;
50
71
  message: string;
51
72
  })[];
73
+ "stablekit/no-loading-conflict": (string | {
74
+ passthrough: string[];
75
+ })[];
76
+ };
77
+ plugins: {
78
+ stablekit: {
79
+ rules: {
80
+ "no-loading-conflict": any;
81
+ };
82
+ };
52
83
  };
53
84
  };
54
85
 
package/dist/eslint.d.ts CHANGED
@@ -25,6 +25,17 @@
25
25
  * and interpolated template literals. Each message guides toward
26
26
  * extracting the expression to a variable (for data transforms) or
27
27
  * using a StableKit component (for state-driven swaps). Always on.
28
+ *
29
+ * 5. className on custom components — passing className to a PascalCase
30
+ * component is a presentation leak across the Structure boundary.
31
+ * The component should own its own styling. Always on.
32
+ *
33
+ * 6. Dual-paradigm conflict (custom rule with scope analysis) — a component
34
+ * with a `loading` prop does internal content swapping (StateSwap). If
35
+ * children are a variable derived from a conditional expression, both
36
+ * sides of the swap change simultaneously, defeating pre-allocation.
37
+ * Only flags locals initialized with ternaries/logical expressions.
38
+ * Props (function parameters) are allowed — they're static per mount.
28
39
  */
29
40
  interface ArchitectureLintOptions {
30
41
  /** State token names that should never appear in JS as Tailwind classes.
@@ -38,6 +49,16 @@ interface ArchitectureLintOptions {
38
49
  * Colors must live in CSS — not in component classNames.
39
50
  * @default true */
40
51
  banColorUtilities?: boolean;
52
+ /** Components that transparently pass className to their root element.
53
+ * These are excluded from the className-on-component ban.
54
+ * e.g. ["StableText", "StableCounter", "MediaSkeleton", "ChevronDown"]
55
+ * @default [] */
56
+ classNamePassthrough?: string[];
57
+ /** Components where `loading` prop does NOT trigger a content swap
58
+ * (e.g. LoadingBoundary controls opacity, not geometry).
59
+ * These are excluded from the dual-paradigm conflict rule.
60
+ * @default ["LoadingBoundary"] */
61
+ loadingPassthrough?: string[];
41
62
  /** Glob patterns for files to lint.
42
63
  * @default ["src/components/**\/*.{tsx,jsx}"] */
43
64
  files?: string[];
@@ -49,6 +70,16 @@ declare function createArchitectureLint(options: ArchitectureLintOptions): {
49
70
  selector: string;
50
71
  message: string;
51
72
  })[];
73
+ "stablekit/no-loading-conflict": (string | {
74
+ passthrough: string[];
75
+ })[];
76
+ };
77
+ plugins: {
78
+ stablekit: {
79
+ rules: {
80
+ "no-loading-conflict": any;
81
+ };
82
+ };
52
83
  };
53
84
  };
54
85
 
package/dist/eslint.js CHANGED
@@ -1,9 +1,70 @@
1
1
  // src/eslint.ts
2
+ function findVariable(scope, name) {
3
+ let current = scope;
4
+ while (current) {
5
+ const variable = current.set.get(name);
6
+ if (variable) return variable;
7
+ current = current.upper;
8
+ }
9
+ return null;
10
+ }
11
+ var noLoadingConflictRule = {
12
+ meta: {
13
+ type: "problem",
14
+ schema: [
15
+ {
16
+ type: "object",
17
+ properties: {
18
+ passthrough: { type: "array", items: { type: "string" } }
19
+ },
20
+ additionalProperties: false
21
+ }
22
+ ],
23
+ messages: {
24
+ conflict: "Conditional variable as children of a component with a loading prop. The loading prop triggers an internal content swap \u2014 if children also change, both sides shrink simultaneously and pre-allocation is defeated. Use static children with the loading prop, or handle the entire swap explicitly with <StateSwap> in the caller."
25
+ }
26
+ },
27
+ create(context) {
28
+ const passthrough = context.options[0]?.passthrough ?? [];
29
+ return {
30
+ JSXElement(node) {
31
+ const opening = node.openingElement;
32
+ if (opening.name?.type !== "JSXIdentifier") return;
33
+ const name = opening.name.name;
34
+ if (!/^[A-Z]/.test(name)) return;
35
+ if (passthrough.includes(name)) return;
36
+ const hasLoading = opening.attributes.some(
37
+ (attr) => attr.type === "JSXAttribute" && attr.name?.name === "loading"
38
+ );
39
+ if (!hasLoading) return;
40
+ for (const child of node.children) {
41
+ if (child.type !== "JSXExpressionContainer") continue;
42
+ const expr = child.expression;
43
+ if (expr.type !== "Identifier") continue;
44
+ const scope = context.sourceCode ? context.sourceCode.getScope(node) : context.getScope();
45
+ const variable = findVariable(scope, expr.name);
46
+ if (!variable) continue;
47
+ for (const def of variable.defs) {
48
+ if (def.type === "Parameter") continue;
49
+ if (def.type === "Variable" && def.node.init) {
50
+ const initType = def.node.init.type;
51
+ if (initType === "ConditionalExpression" || initType === "LogicalExpression" || initType === "TemplateLiteral") {
52
+ context.report({ node: expr, messageId: "conflict" });
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ };
59
+ }
60
+ };
2
61
  function createArchitectureLint(options) {
3
62
  const {
4
63
  stateTokens,
5
64
  variantProps = [],
6
65
  banColorUtilities = true,
66
+ classNamePassthrough = [],
67
+ loadingPassthrough = ["LoadingBoundary"],
7
68
  files = ["src/components/**/*.{tsx,jsx}"]
8
69
  } = options;
9
70
  const tokenPattern = stateTokens.join("|");
@@ -122,8 +183,28 @@ function createArchitectureLint(options) {
122
183
  {
123
184
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
124
185
  message: "Interpolated text in JSX children. Extract to a const above the return (the linter won't flag a plain variable). For state-driven values, use <StableCounter> for numbers or <StateSwap> for text variants."
186
+ },
187
+ // --- 5. className on custom components ---
188
+ ...classNamePassthrough.length ? [
189
+ {
190
+ selector: `JSXOpeningElement[name.name=/^[A-Z]/]:not([name.name=/^(${classNamePassthrough.join("|")})$/]) > JSXAttribute[name.name='className']`,
191
+ message: "className on a custom component. Components own their own styling \u2014 use a data-attribute, variant prop, or CSS class internally instead of passing Tailwind utilities from the consumer."
192
+ }
193
+ ] : [
194
+ {
195
+ selector: "JSXOpeningElement[name.name=/^[A-Z]/] > JSXAttribute[name.name='className']",
196
+ message: "className on a custom component. Components own their own styling \u2014 use a data-attribute, variant prop, or CSS class internally instead of passing Tailwind utilities from the consumer."
197
+ }
198
+ ]
199
+ ],
200
+ "stablekit/no-loading-conflict": ["error", { passthrough: loadingPassthrough }]
201
+ },
202
+ plugins: {
203
+ stablekit: {
204
+ rules: {
205
+ "no-loading-conflict": noLoadingConflictRule
125
206
  }
126
- ]
207
+ }
127
208
  }
128
209
  };
129
210
  }
package/llms.txt CHANGED
@@ -317,6 +317,19 @@ change to structure requires editing `.css`, a boundary has leaked.
317
317
  or use a StableKit component (for state-driven swaps — StateSwap,
318
318
  LayoutMap, LoadingBoundary, FadeTransition, StableField, StableCounter,
319
319
  LayoutGroup). These rules are always on.
320
+ `className` on PascalCase components is banned — components own their own
321
+ styling. Use `classNamePassthrough` to exempt transparent wrappers (e.g.
322
+ `StableText`, `MediaSkeleton`, Lucide icons) that forward className to
323
+ their root element.
324
+ A custom rule (`stablekit/no-loading-conflict`) catches dual-paradigm
325
+ conflicts: if a component has a `loading` prop (which triggers an internal
326
+ content swap via StateSwap), children must not be variables derived from
327
+ conditional expressions. The `loading` prop controls geometry — if children
328
+ also change, both sides shrink simultaneously and pre-allocation is
329
+ defeated. Props (function parameters) are allowed because they are static
330
+ per mount. Use `loadingPassthrough` to exempt components where `loading`
331
+ does not trigger a content swap (e.g. `LoadingBoundary` controls opacity,
332
+ not geometry). Default passthrough: `["LoadingBoundary"]`.
320
333
  4. **Stylelint** (`stablekit/stylelint`) — `createStyleLint({ functionalTokens })`
321
334
  bans element selectors in CSS (`& svg`, `& span`), bans `!important`, and
322
335
  bans functional color tokens inside `@utility` blocks. `functionalTokens`
@@ -415,6 +428,16 @@ currently behaving.
415
428
  .sk-badge[data-variant="active"] { color: var(--color-success); }
416
429
  ```
417
430
 
431
+ ## Out of Scope: Font-Swap CLS
432
+
433
+ Do NOT build a component to solve font-swap layout shift. StableKit solves
434
+ structural CLS (conditional mounting, dynamic content sizing). Font-swap CLS
435
+ is a typographic metrics problem — no React component can pre-allocate
436
+ geometry for a font that hasn't been downloaded yet. The fix is CSS
437
+ `@font-face` metric overrides (`size-adjust`, `ascent-override`,
438
+ `descent-override`, `line-gap-override`) via tools like Capsize, Fontaine,
439
+ or `next/font`.
440
+
418
441
  ## Component selection guide
419
442
 
420
443
  | Problem | Component |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stablekit.ts",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "React toolkit for layout stability — zero-shift components for loading states, content swaps, and spatial containers.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",