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 +25 -0
- package/dist/eslint.cjs +19 -0
- package/dist/eslint.d.cts +19 -0
- package/dist/eslint.d.ts +19 -0
- package/dist/eslint.js +19 -0
- package/llms.txt +10 -0
- package/package.json +1 -1
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