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 +25 -0
- package/dist/eslint.cjs +82 -1
- package/dist/eslint.d.cts +31 -0
- package/dist/eslint.d.ts +31 -0
- package/dist/eslint.js +82 -1
- package/llms.txt +23 -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
|
@@ -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