stablekit.ts 0.2.3 → 0.3.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
@@ -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
@@ -28,6 +28,8 @@ function createArchitectureLint(options) {
28
28
  stateTokens,
29
29
  variantProps = [],
30
30
  banColorUtilities = true,
31
+ classNamePassthrough = [],
32
+ loadingPassthrough = ["LoadingBoundary"],
31
33
  files = ["src/components/**/*.{tsx,jsx}"]
32
34
  } = options;
33
35
  const tokenPattern = stateTokens.join("|");
@@ -146,6 +148,23 @@ function createArchitectureLint(options) {
146
148
  {
147
149
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
148
150
  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."
151
+ },
152
+ // --- 5. className on custom components ---
153
+ ...classNamePassthrough.length ? [
154
+ {
155
+ selector: `JSXOpeningElement[name.name=/^[A-Z]/]:not([name.name=/^(${classNamePassthrough.join("|")})$/]) > JSXAttribute[name.name='className']`,
156
+ 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."
157
+ }
158
+ ] : [
159
+ {
160
+ selector: "JSXOpeningElement[name.name=/^[A-Z]/] > JSXAttribute[name.name='className']",
161
+ 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."
162
+ }
163
+ ],
164
+ // --- 6. Dual-paradigm conflict (loading + variable children) ---
165
+ {
166
+ selector: `JSXElement:has(JSXOpeningElement[name.name=/^[A-Z]/]:not([name.name=/^(${loadingPassthrough.join("|")})$/]) > JSXAttribute[name.name='loading']) > JSXExpressionContainer > Identifier`,
167
+ message: "Variable children inside 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."
149
168
  }
150
169
  ]
151
170
  }
package/dist/eslint.d.cts CHANGED
@@ -25,6 +25,15 @@
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 — a component with a `loading` prop does
34
+ * internal content swapping (StateSwap). If children are also variable,
35
+ * both sides of the swap change simultaneously, defeating pre-allocation.
36
+ * Children of loading-swappable components must be static.
28
37
  */
29
38
  interface ArchitectureLintOptions {
30
39
  /** State token names that should never appear in JS as Tailwind classes.
@@ -38,6 +47,16 @@ interface ArchitectureLintOptions {
38
47
  * Colors must live in CSS — not in component classNames.
39
48
  * @default true */
40
49
  banColorUtilities?: boolean;
50
+ /** Components that transparently pass className to their root element.
51
+ * These are excluded from the className-on-component ban.
52
+ * e.g. ["StableText", "StableCounter", "MediaSkeleton", "ChevronDown"]
53
+ * @default [] */
54
+ classNamePassthrough?: string[];
55
+ /** Components where `loading` prop does NOT trigger a content swap
56
+ * (e.g. LoadingBoundary controls opacity, not geometry).
57
+ * These are excluded from the dual-paradigm conflict rule.
58
+ * @default ["LoadingBoundary"] */
59
+ loadingPassthrough?: string[];
41
60
  /** Glob patterns for files to lint.
42
61
  * @default ["src/components/**\/*.{tsx,jsx}"] */
43
62
  files?: string[];
package/dist/eslint.d.ts CHANGED
@@ -25,6 +25,15 @@
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 — a component with a `loading` prop does
34
+ * internal content swapping (StateSwap). If children are also variable,
35
+ * both sides of the swap change simultaneously, defeating pre-allocation.
36
+ * Children of loading-swappable components must be static.
28
37
  */
29
38
  interface ArchitectureLintOptions {
30
39
  /** State token names that should never appear in JS as Tailwind classes.
@@ -38,6 +47,16 @@ interface ArchitectureLintOptions {
38
47
  * Colors must live in CSS — not in component classNames.
39
48
  * @default true */
40
49
  banColorUtilities?: boolean;
50
+ /** Components that transparently pass className to their root element.
51
+ * These are excluded from the className-on-component ban.
52
+ * e.g. ["StableText", "StableCounter", "MediaSkeleton", "ChevronDown"]
53
+ * @default [] */
54
+ classNamePassthrough?: string[];
55
+ /** Components where `loading` prop does NOT trigger a content swap
56
+ * (e.g. LoadingBoundary controls opacity, not geometry).
57
+ * These are excluded from the dual-paradigm conflict rule.
58
+ * @default ["LoadingBoundary"] */
59
+ loadingPassthrough?: string[];
41
60
  /** Glob patterns for files to lint.
42
61
  * @default ["src/components/**\/*.{tsx,jsx}"] */
43
62
  files?: string[];
package/dist/eslint.js CHANGED
@@ -4,6 +4,8 @@ function createArchitectureLint(options) {
4
4
  stateTokens,
5
5
  variantProps = [],
6
6
  banColorUtilities = true,
7
+ classNamePassthrough = [],
8
+ loadingPassthrough = ["LoadingBoundary"],
7
9
  files = ["src/components/**/*.{tsx,jsx}"]
8
10
  } = options;
9
11
  const tokenPattern = stateTokens.join("|");
@@ -122,6 +124,23 @@ function createArchitectureLint(options) {
122
124
  {
123
125
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
124
126
  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."
127
+ },
128
+ // --- 5. className on custom components ---
129
+ ...classNamePassthrough.length ? [
130
+ {
131
+ selector: `JSXOpeningElement[name.name=/^[A-Z]/]:not([name.name=/^(${classNamePassthrough.join("|")})$/]) > JSXAttribute[name.name='className']`,
132
+ 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."
133
+ }
134
+ ] : [
135
+ {
136
+ selector: "JSXOpeningElement[name.name=/^[A-Z]/] > JSXAttribute[name.name='className']",
137
+ 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."
138
+ }
139
+ ],
140
+ // --- 6. Dual-paradigm conflict (loading + variable children) ---
141
+ {
142
+ selector: `JSXElement:has(JSXOpeningElement[name.name=/^[A-Z]/]:not([name.name=/^(${loadingPassthrough.join("|")})$/]) > JSXAttribute[name.name='loading']) > JSXExpressionContainer > Identifier`,
143
+ message: "Variable children inside 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."
125
144
  }
126
145
  ]
127
146
  }
package/llms.txt CHANGED
@@ -415,6 +415,16 @@ currently behaving.
415
415
  .sk-badge[data-variant="active"] { color: var(--color-success); }
416
416
  ```
417
417
 
418
+ ## Out of Scope: Font-Swap CLS
419
+
420
+ Do NOT build a component to solve font-swap layout shift. StableKit solves
421
+ structural CLS (conditional mounting, dynamic content sizing). Font-swap CLS
422
+ is a typographic metrics problem — no React component can pre-allocate
423
+ geometry for a font that hasn't been downloaded yet. The fix is CSS
424
+ `@font-face` metric overrides (`size-adjust`, `ascent-override`,
425
+ `descent-override`, `line-gap-override`) via tools like Capsize, Fontaine,
426
+ or `next/font`.
427
+
418
428
  ## Component selection guide
419
429
 
420
430
  | 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.0",
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",