stablekit.ts 0.6.4 → 0.6.5
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/dist/eslint.cjs +106 -37
- package/dist/eslint.d.cts +2 -0
- package/dist/eslint.d.ts +2 -0
- package/dist/eslint.js +106 -37
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/styles.css +0 -2
- package/package.json +1 -1
package/dist/eslint.cjs
CHANGED
|
@@ -23,6 +23,81 @@ __export(eslint_exports, {
|
|
|
23
23
|
createArchitectureLint: () => createArchitectureLint
|
|
24
24
|
});
|
|
25
25
|
module.exports = __toCommonJS(eslint_exports);
|
|
26
|
+
var noHiddenSwapRule = {
|
|
27
|
+
meta: {
|
|
28
|
+
type: "problem",
|
|
29
|
+
schema: [],
|
|
30
|
+
messages: {
|
|
31
|
+
swap: "Sibling elements test the same variable in hidden to swap visibility \u2014 neither reserves space when hidden, causing layout shift. Use <StateSwap> for two states, <StateMap> for inline multi-state, or <LayoutMap> for block-level multi-state."
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
create(context) {
|
|
35
|
+
function getHiddenExpr(node) {
|
|
36
|
+
if (node.type !== "JSXElement") return null;
|
|
37
|
+
const attrs = node.openingElement?.attributes ?? [];
|
|
38
|
+
for (const attr of attrs) {
|
|
39
|
+
if (attr.type === "JSXAttribute" && attr.name?.name === "hidden" && attr.value?.type === "JSXExpressionContainer" && attr.value.expression?.type !== "Literal") {
|
|
40
|
+
return attr.value.expression;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function unwrapOrUndefined(expr) {
|
|
46
|
+
if (expr.type === "LogicalExpression" && expr.operator === "||" && expr.right.type === "Identifier" && expr.right.name === "undefined") {
|
|
47
|
+
return expr.left;
|
|
48
|
+
}
|
|
49
|
+
return expr;
|
|
50
|
+
}
|
|
51
|
+
function getSubject(rawExpr) {
|
|
52
|
+
const expr = unwrapOrUndefined(rawExpr);
|
|
53
|
+
if (expr.type === "Identifier") return expr.name;
|
|
54
|
+
if (expr.type === "UnaryExpression" && expr.operator === "!" && expr.argument.type === "Identifier") {
|
|
55
|
+
return expr.argument.name;
|
|
56
|
+
}
|
|
57
|
+
if (expr.type === "BinaryExpression" && (expr.operator === "!==" || expr.operator === "===")) {
|
|
58
|
+
if (expr.left.type === "Identifier") return expr.left.name;
|
|
59
|
+
if (expr.right.type === "Identifier") return expr.right.name;
|
|
60
|
+
}
|
|
61
|
+
if (expr.type === "BinaryExpression" && (expr.operator === "==" || expr.operator === "!=")) {
|
|
62
|
+
if (expr.left.type === "Identifier") return expr.left.name;
|
|
63
|
+
if (expr.right.type === "Identifier") return expr.right.name;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function checkChildren(children) {
|
|
68
|
+
const entries = [];
|
|
69
|
+
for (const child of children) {
|
|
70
|
+
const expr = getHiddenExpr(child);
|
|
71
|
+
if (expr) {
|
|
72
|
+
entries.push({ node: child, subject: getSubject(expr) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const bySubject = /* @__PURE__ */ new Map();
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (entry.subject) {
|
|
78
|
+
const group = bySubject.get(entry.subject) ?? [];
|
|
79
|
+
group.push(entry.node);
|
|
80
|
+
bySubject.set(entry.subject, group);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const [, group] of bySubject) {
|
|
84
|
+
if (group.length >= 2) {
|
|
85
|
+
for (let i = 1; i < group.length; i++) {
|
|
86
|
+
context.report({ node: group[i], messageId: "swap" });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
JSXElement(node) {
|
|
93
|
+
checkChildren(node.children ?? []);
|
|
94
|
+
},
|
|
95
|
+
JSXFragment(node) {
|
|
96
|
+
checkChildren(node.children ?? []);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
};
|
|
26
101
|
function findVariable(scope, name) {
|
|
27
102
|
let current = scope;
|
|
28
103
|
while (current) {
|
|
@@ -45,7 +120,7 @@ var noLoadingConflictRule = {
|
|
|
45
120
|
}
|
|
46
121
|
],
|
|
47
122
|
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.
|
|
123
|
+
conflict: "Conditional variable as children of a component with a loading prop (e.g. <Button loading={x}>{label}</Button> where label = x ? 'A' : 'B'). The loading prop triggers an internal content swap \u2014 if children also change, both sides shrink simultaneously and pre-allocation is defeated. Fix: use static children (e.g. 'Submit'), or move the entire swap to the caller with <StateSwap>/<StateMap>."
|
|
49
124
|
}
|
|
50
125
|
},
|
|
51
126
|
create(context) {
|
|
@@ -102,135 +177,129 @@ function createArchitectureLint(options) {
|
|
|
102
177
|
// --- 1. Hardcoded design tokens (universal) ---
|
|
103
178
|
{
|
|
104
179
|
selector: "Literal[value=/text-\\[\\d+/]",
|
|
105
|
-
message: "Hardcoded font size.
|
|
180
|
+
message: "Hardcoded font size in className (e.g. text-[14px]). Add a named size to @theme and use it instead \u2014 hardcoded sizes can't be updated globally."
|
|
106
181
|
},
|
|
107
182
|
{
|
|
108
183
|
selector: "Literal[value=/\\[.*(?:#[0-9a-fA-F]|rgba?).*\\]/]",
|
|
109
|
-
message: "Hardcoded color
|
|
184
|
+
message: "Hardcoded color in Tailwind bracket syntax (e.g. bg-[#f00]). Define a CSS custom property in your tokens and reference it \u2014 colors in JS can't be themed or audited."
|
|
110
185
|
},
|
|
111
186
|
{
|
|
112
187
|
selector: "JSXAttribute[name.name='style'] Property > Literal[value=/(?:#[0-9a-fA-F]{3,8}|rgba?\\()/]",
|
|
113
|
-
message: "Hardcoded color
|
|
188
|
+
message: "Hardcoded color in style prop. Move this to a CSS custom property \u2014 style={{ color: '#f00' }} can't be themed, overridden by cascade layers, or found by grep."
|
|
114
189
|
},
|
|
115
190
|
{
|
|
116
191
|
selector: "Literal[value=/^#[0-9a-fA-F]{3}([0-9a-fA-F]([0-9a-fA-F]{2}([0-9a-fA-F]{2})?)?)?$/]",
|
|
117
|
-
message: "Hardcoded hex color. Define a CSS custom property."
|
|
192
|
+
message: "Hardcoded hex color (e.g. '#5865F2'). Define a CSS custom property and reference it \u2014 colors in JS bypass the cascade and can't be themed."
|
|
118
193
|
},
|
|
119
194
|
{
|
|
120
195
|
selector: "Literal[value=/(?:^|[^a-zA-Z])(?:rgba?|hsla?|oklch|lab|lch)\\(/]",
|
|
121
|
-
message: "Hardcoded color function. Define a CSS custom property."
|
|
196
|
+
message: "Hardcoded color function (e.g. rgba(), hsl()). Define a CSS custom property and reference it \u2014 color functions in JS bypass the cascade and can't be themed."
|
|
122
197
|
},
|
|
123
198
|
// --- 1b. Color properties in style props ---
|
|
124
199
|
{
|
|
125
200
|
selector: "JSXAttribute[name.name='style'] Property[key.name=/^(color|backgroundColor|background|borderColor|outlineColor|fill|stroke|accentColor|caretColor)$/]",
|
|
126
|
-
message: "
|
|
201
|
+
message: "Color property in style prop (e.g. style={{ backgroundColor: x }}). CSS owns color \u2014 use a className or data-attribute with a CSS rule instead. If the color is data-driven, set a data-attribute and handle it in your exceptions CSS."
|
|
127
202
|
},
|
|
128
203
|
// --- 1c. Visual state properties in style props ---
|
|
129
204
|
{
|
|
130
205
|
selector: "JSXAttribute[name.name='style'] Property[key.name=/^(opacity|visibility|transition|pointerEvents)$/]",
|
|
131
|
-
message: "Visual state property in style prop.
|
|
206
|
+
message: "Visual state property in style prop (e.g. style={{ opacity }}). These are presentation concerns \u2014 use a data-attribute and CSS selector so the visual logic lives in CSS, not JS."
|
|
132
207
|
},
|
|
133
208
|
// --- 1d. Hardcoded magic numbers ---
|
|
134
209
|
{
|
|
135
210
|
selector: "Literal[value=/z-\\[\\d/]",
|
|
136
|
-
message: "Hardcoded z-index. Define a named z-index token in @theme."
|
|
211
|
+
message: "Hardcoded z-index (e.g. z-[999]). Define a named z-index token in @theme (e.g. --z-dropdown, --z-modal) \u2014 magic z-indices create stacking conflicts that are impossible to debug."
|
|
137
212
|
},
|
|
138
213
|
{
|
|
139
214
|
selector: "Literal[value=/-m\\w?-\\[|m\\w?-\\[-/]",
|
|
140
|
-
message: "Negative margin with magic number.
|
|
215
|
+
message: "Negative margin with magic number (e.g. -mt-[8px]). Negative margins fight the layout \u2014 fix the spacing structure (padding, gap) instead of compensating with negative offsets."
|
|
141
216
|
},
|
|
142
217
|
{
|
|
143
218
|
selector: "Literal[value=/(?:min-|max-)?(?:w|h)-\\[\\d+px\\]/]",
|
|
144
|
-
message: "Hardcoded pixel dimension. Define a named size token in @theme or use a relative unit."
|
|
219
|
+
message: "Hardcoded pixel dimension (e.g. w-[347px]). Define a named size token in @theme or use a relative unit \u2014 pixel dimensions break at different viewport sizes and can't be updated globally."
|
|
145
220
|
},
|
|
146
221
|
{
|
|
147
222
|
selector: "Literal[value=/\\w-\\[(?!calc).*?\\d+(?![\\d%]|[dsl]?v[hw]|fr)/]",
|
|
148
|
-
message: "Hardcoded magic number in arbitrary value. Define a named token in @theme
|
|
223
|
+
message: "Hardcoded magic number in arbitrary value (e.g. rounded-[3px], gap-[12px]). Define a named token in @theme \u2014 magic numbers scattered across components can't be updated globally."
|
|
149
224
|
},
|
|
150
225
|
// --- 2. Data-dependent visual decisions (project-specific) ---
|
|
151
226
|
...tokenPattern ? [
|
|
152
227
|
{
|
|
153
228
|
selector: `Literal[value=/\\b(bg|text|border)-(${tokenPattern})/]`,
|
|
154
|
-
message: "
|
|
229
|
+
message: "State color token in className (e.g. bg-success, text-warning). State-driven colors belong in CSS \u2014 set a data-attribute (data-status, data-variant) on the element and write a CSS selector that maps the attribute to the color."
|
|
155
230
|
}
|
|
156
231
|
] : [],
|
|
157
232
|
{
|
|
158
233
|
selector: "JSXAttribute[name.name='style'] ConditionalExpression",
|
|
159
|
-
message: "Conditional style
|
|
234
|
+
message: "Conditional style prop (e.g. style={x ? {...} : {...}}). JS is deciding what the component looks like \u2014 set a data-attribute and let CSS handle the visual change."
|
|
160
235
|
},
|
|
161
236
|
{
|
|
162
237
|
selector: "JSXAttribute[name.name='className'] ConditionalExpression",
|
|
163
|
-
message: "
|
|
238
|
+
message: "Ternary in className (e.g. className={x ? 'a' : 'b'}). JS is picking the visual treatment \u2014 set a data-attribute and write CSS selectors for each state instead."
|
|
164
239
|
},
|
|
165
240
|
{
|
|
166
241
|
selector: "JSXAttribute[name.name='className'] LogicalExpression[operator='&&']",
|
|
167
|
-
message: "Conditional className.
|
|
242
|
+
message: "Conditional className (e.g. isActive && 'bold'). JS is toggling visual properties \u2014 set a data-attribute and write a CSS selector instead."
|
|
168
243
|
},
|
|
169
244
|
{
|
|
170
245
|
selector: "JSXAttribute[name.name='className'] ObjectExpression",
|
|
171
|
-
message: "
|
|
246
|
+
message: "Object syntax in className (e.g. cx({ 'text-red': isError })). This is conditional visual logic in JS \u2014 set a data-attribute and write CSS selectors instead."
|
|
172
247
|
},
|
|
173
248
|
// --- 2c. !important in className ---
|
|
174
249
|
{
|
|
175
250
|
selector: "JSXAttribute[name.name='className'] Literal[value=/(?:^|\\s)![a-z]/]",
|
|
176
|
-
message: "
|
|
251
|
+
message: "!important modifier in className (e.g. !font-bold). !important breaks cascade layers and makes overrides unpredictable \u2014 use a more specific selector or data-attribute instead."
|
|
177
252
|
},
|
|
178
253
|
// --- 2d. Tailwind color utilities in className ---
|
|
179
254
|
...banColorUtilities ? [
|
|
180
255
|
{
|
|
181
256
|
selector: `Literal[value=/(?:^|\\s)(?:${twPrefixes})-(?:${twColors})(?:-\\d+)?(?:\\/\\d+)?(?:\\s|$)/]`,
|
|
182
|
-
message: "Tailwind color
|
|
257
|
+
message: "Tailwind palette color in className (e.g. bg-red-500, text-green-600). Colors belong in CSS \u2014 use a semantic CSS class or data-attribute selector. If you put colors in JSX, changing a color requires editing every component that uses it."
|
|
183
258
|
}
|
|
184
259
|
] : [],
|
|
185
260
|
// --- 3. Ternaries on variant props (from createPrimitive) ---
|
|
186
261
|
...variantProps.map((prop) => ({
|
|
187
262
|
selector: `JSXAttribute[name.name='${prop}'] ConditionalExpression`,
|
|
188
|
-
message: `
|
|
263
|
+
message: `Ternary on ${prop} prop (e.g. ${prop}={x ? 'a' : 'b'}). This component uses createPrimitive \u2014 set a data-attribute and let CSS map data to visual treatment instead of switching ${prop} in JS.`
|
|
189
264
|
})),
|
|
190
265
|
// --- 4. Geometric instability (conditional content) ---
|
|
191
266
|
{
|
|
192
267
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > ConditionalExpression",
|
|
193
|
-
message: "
|
|
268
|
+
message: "Ternary in JSX children (e.g. {x ? <A/> : <B/>}). This causes layout shift \u2014 the container resizes when the content swaps. Quick fix: extract to a const above the return. Proper fix: <StateSwap> for two states, <StateMap> for inline multi-state, <LayoutMap> for block-level multi-state, or <LoadingBoundary> for async data."
|
|
194
269
|
},
|
|
195
270
|
{
|
|
196
271
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='&&']",
|
|
197
|
-
message: "Conditional mount in JSX children
|
|
272
|
+
message: "Conditional mount in JSX children (e.g. {show && <Panel/>}). When this mounts/unmounts, the container resizes. Quick fix: extract to a const above the return. Proper fix: <FadeTransition> for enter/exit animation, <StableField> for form error messages, or <LayoutGroup>/<LayoutMap> for pre-rendered views. Do NOT replace with hidden \u2014 it renders children unconditionally and will crash on null data."
|
|
198
273
|
},
|
|
199
274
|
{
|
|
200
275
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='||']",
|
|
201
|
-
message: "Fallback content in JSX children.
|
|
276
|
+
message: "Fallback content in JSX children (e.g. {name || 'Unknown'}). When the value changes length, the container resizes. Quick fix: extract to a const above the return. Proper fix: <StateSwap> to pre-allocate space for both states."
|
|
202
277
|
},
|
|
203
278
|
{
|
|
204
279
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='??']",
|
|
205
|
-
message: "Nullish fallback in JSX children.
|
|
280
|
+
message: "Nullish fallback in JSX children (e.g. {title ?? 'Loading...'}). When the value arrives, the container resizes. Quick fix: extract to a const above the return. Proper fix: <StateSwap> to pre-allocate space for both states, or <LoadingBoundary> if waiting for async data."
|
|
206
281
|
},
|
|
207
282
|
{
|
|
208
283
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
|
|
209
|
-
message: "Interpolated text in JSX children.
|
|
210
|
-
},
|
|
211
|
-
// --- 4b. Sibling hidden swap (geometric instability) ---
|
|
212
|
-
// Two sibling elements both using conditional hidden to swap
|
|
213
|
-
// visibility — neither reserves space for the other.
|
|
214
|
-
// A single hidden={condition} (e.g. error message at bottom of
|
|
215
|
-
// layout) is fine and intentionally not flagged.
|
|
216
|
-
{
|
|
217
|
-
selector: "JSXElement:has(JSXOpeningElement JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal)) ~ JSXElement:has(JSXOpeningElement JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal))",
|
|
218
|
-
message: "Sibling elements swap visibility with conditional hidden \u2014 neither reserves space for the other, causing layout shift. Use <StateSwap> for two states or <LayoutMap> for multiple states."
|
|
284
|
+
message: "Interpolated text in JSX children (e.g. {`${count} items`}). When the value changes (especially digit count), the container resizes. Quick fix: extract to a const above the return. Proper fix: <StableCounter> for numbers that change digit count, or <StateSwap> for text that changes between known variants."
|
|
219
285
|
},
|
|
286
|
+
// --- 4b removed: sibling hidden swap is now a custom rule ---
|
|
220
287
|
// --- 5. className on firewalled components ---
|
|
221
288
|
...classNameBlocked.length ? [
|
|
222
289
|
{
|
|
223
290
|
selector: `JSXOpeningElement[name.name=/^(${classNameBlocked.join("|")})$/] > JSXAttribute[name.name='className']`,
|
|
224
|
-
message: "className
|
|
291
|
+
message: "className passed to a firewalled component (built with createPrimitive). This component owns its styling \u2014 pass a data-attribute or variant prop instead. The component maps those to visuals internally via CSS."
|
|
225
292
|
}
|
|
226
293
|
] : []
|
|
227
294
|
],
|
|
228
|
-
"stablekit/no-loading-conflict": ["error", { passthrough: loadingPassthrough }]
|
|
295
|
+
"stablekit/no-loading-conflict": ["error", { passthrough: loadingPassthrough }],
|
|
296
|
+
"stablekit/no-hidden-swap": "error"
|
|
229
297
|
},
|
|
230
298
|
plugins: {
|
|
231
299
|
stablekit: {
|
|
232
300
|
rules: {
|
|
233
|
-
"no-loading-conflict": noLoadingConflictRule
|
|
301
|
+
"no-loading-conflict": noLoadingConflictRule,
|
|
302
|
+
"no-hidden-swap": noHiddenSwapRule
|
|
234
303
|
}
|
|
235
304
|
}
|
|
236
305
|
}
|
package/dist/eslint.d.cts
CHANGED
|
@@ -74,11 +74,13 @@ declare function createArchitectureLint(options: ArchitectureLintOptions): {
|
|
|
74
74
|
"stablekit/no-loading-conflict": (string | {
|
|
75
75
|
passthrough: string[];
|
|
76
76
|
})[];
|
|
77
|
+
"stablekit/no-hidden-swap": string;
|
|
77
78
|
};
|
|
78
79
|
plugins: {
|
|
79
80
|
stablekit: {
|
|
80
81
|
rules: {
|
|
81
82
|
"no-loading-conflict": any;
|
|
83
|
+
"no-hidden-swap": any;
|
|
82
84
|
};
|
|
83
85
|
};
|
|
84
86
|
};
|
package/dist/eslint.d.ts
CHANGED
|
@@ -74,11 +74,13 @@ declare function createArchitectureLint(options: ArchitectureLintOptions): {
|
|
|
74
74
|
"stablekit/no-loading-conflict": (string | {
|
|
75
75
|
passthrough: string[];
|
|
76
76
|
})[];
|
|
77
|
+
"stablekit/no-hidden-swap": string;
|
|
77
78
|
};
|
|
78
79
|
plugins: {
|
|
79
80
|
stablekit: {
|
|
80
81
|
rules: {
|
|
81
82
|
"no-loading-conflict": any;
|
|
83
|
+
"no-hidden-swap": any;
|
|
82
84
|
};
|
|
83
85
|
};
|
|
84
86
|
};
|
package/dist/eslint.js
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
import "./chunk-MCKGQKYU.js";
|
|
2
2
|
|
|
3
3
|
// src/eslint.ts
|
|
4
|
+
var noHiddenSwapRule = {
|
|
5
|
+
meta: {
|
|
6
|
+
type: "problem",
|
|
7
|
+
schema: [],
|
|
8
|
+
messages: {
|
|
9
|
+
swap: "Sibling elements test the same variable in hidden to swap visibility \u2014 neither reserves space when hidden, causing layout shift. Use <StateSwap> for two states, <StateMap> for inline multi-state, or <LayoutMap> for block-level multi-state."
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
function getHiddenExpr(node) {
|
|
14
|
+
if (node.type !== "JSXElement") return null;
|
|
15
|
+
const attrs = node.openingElement?.attributes ?? [];
|
|
16
|
+
for (const attr of attrs) {
|
|
17
|
+
if (attr.type === "JSXAttribute" && attr.name?.name === "hidden" && attr.value?.type === "JSXExpressionContainer" && attr.value.expression?.type !== "Literal") {
|
|
18
|
+
return attr.value.expression;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
function unwrapOrUndefined(expr) {
|
|
24
|
+
if (expr.type === "LogicalExpression" && expr.operator === "||" && expr.right.type === "Identifier" && expr.right.name === "undefined") {
|
|
25
|
+
return expr.left;
|
|
26
|
+
}
|
|
27
|
+
return expr;
|
|
28
|
+
}
|
|
29
|
+
function getSubject(rawExpr) {
|
|
30
|
+
const expr = unwrapOrUndefined(rawExpr);
|
|
31
|
+
if (expr.type === "Identifier") return expr.name;
|
|
32
|
+
if (expr.type === "UnaryExpression" && expr.operator === "!" && expr.argument.type === "Identifier") {
|
|
33
|
+
return expr.argument.name;
|
|
34
|
+
}
|
|
35
|
+
if (expr.type === "BinaryExpression" && (expr.operator === "!==" || expr.operator === "===")) {
|
|
36
|
+
if (expr.left.type === "Identifier") return expr.left.name;
|
|
37
|
+
if (expr.right.type === "Identifier") return expr.right.name;
|
|
38
|
+
}
|
|
39
|
+
if (expr.type === "BinaryExpression" && (expr.operator === "==" || expr.operator === "!=")) {
|
|
40
|
+
if (expr.left.type === "Identifier") return expr.left.name;
|
|
41
|
+
if (expr.right.type === "Identifier") return expr.right.name;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function checkChildren(children) {
|
|
46
|
+
const entries = [];
|
|
47
|
+
for (const child of children) {
|
|
48
|
+
const expr = getHiddenExpr(child);
|
|
49
|
+
if (expr) {
|
|
50
|
+
entries.push({ node: child, subject: getSubject(expr) });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const bySubject = /* @__PURE__ */ new Map();
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (entry.subject) {
|
|
56
|
+
const group = bySubject.get(entry.subject) ?? [];
|
|
57
|
+
group.push(entry.node);
|
|
58
|
+
bySubject.set(entry.subject, group);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const [, group] of bySubject) {
|
|
62
|
+
if (group.length >= 2) {
|
|
63
|
+
for (let i = 1; i < group.length; i++) {
|
|
64
|
+
context.report({ node: group[i], messageId: "swap" });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
JSXElement(node) {
|
|
71
|
+
checkChildren(node.children ?? []);
|
|
72
|
+
},
|
|
73
|
+
JSXFragment(node) {
|
|
74
|
+
checkChildren(node.children ?? []);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
};
|
|
4
79
|
function findVariable(scope, name) {
|
|
5
80
|
let current = scope;
|
|
6
81
|
while (current) {
|
|
@@ -23,7 +98,7 @@ var noLoadingConflictRule = {
|
|
|
23
98
|
}
|
|
24
99
|
],
|
|
25
100
|
messages: {
|
|
26
|
-
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.
|
|
101
|
+
conflict: "Conditional variable as children of a component with a loading prop (e.g. <Button loading={x}>{label}</Button> where label = x ? 'A' : 'B'). The loading prop triggers an internal content swap \u2014 if children also change, both sides shrink simultaneously and pre-allocation is defeated. Fix: use static children (e.g. 'Submit'), or move the entire swap to the caller with <StateSwap>/<StateMap>."
|
|
27
102
|
}
|
|
28
103
|
},
|
|
29
104
|
create(context) {
|
|
@@ -80,135 +155,129 @@ function createArchitectureLint(options) {
|
|
|
80
155
|
// --- 1. Hardcoded design tokens (universal) ---
|
|
81
156
|
{
|
|
82
157
|
selector: "Literal[value=/text-\\[\\d+/]",
|
|
83
|
-
message: "Hardcoded font size.
|
|
158
|
+
message: "Hardcoded font size in className (e.g. text-[14px]). Add a named size to @theme and use it instead \u2014 hardcoded sizes can't be updated globally."
|
|
84
159
|
},
|
|
85
160
|
{
|
|
86
161
|
selector: "Literal[value=/\\[.*(?:#[0-9a-fA-F]|rgba?).*\\]/]",
|
|
87
|
-
message: "Hardcoded color
|
|
162
|
+
message: "Hardcoded color in Tailwind bracket syntax (e.g. bg-[#f00]). Define a CSS custom property in your tokens and reference it \u2014 colors in JS can't be themed or audited."
|
|
88
163
|
},
|
|
89
164
|
{
|
|
90
165
|
selector: "JSXAttribute[name.name='style'] Property > Literal[value=/(?:#[0-9a-fA-F]{3,8}|rgba?\\()/]",
|
|
91
|
-
message: "Hardcoded color
|
|
166
|
+
message: "Hardcoded color in style prop. Move this to a CSS custom property \u2014 style={{ color: '#f00' }} can't be themed, overridden by cascade layers, or found by grep."
|
|
92
167
|
},
|
|
93
168
|
{
|
|
94
169
|
selector: "Literal[value=/^#[0-9a-fA-F]{3}([0-9a-fA-F]([0-9a-fA-F]{2}([0-9a-fA-F]{2})?)?)?$/]",
|
|
95
|
-
message: "Hardcoded hex color. Define a CSS custom property."
|
|
170
|
+
message: "Hardcoded hex color (e.g. '#5865F2'). Define a CSS custom property and reference it \u2014 colors in JS bypass the cascade and can't be themed."
|
|
96
171
|
},
|
|
97
172
|
{
|
|
98
173
|
selector: "Literal[value=/(?:^|[^a-zA-Z])(?:rgba?|hsla?|oklch|lab|lch)\\(/]",
|
|
99
|
-
message: "Hardcoded color function. Define a CSS custom property."
|
|
174
|
+
message: "Hardcoded color function (e.g. rgba(), hsl()). Define a CSS custom property and reference it \u2014 color functions in JS bypass the cascade and can't be themed."
|
|
100
175
|
},
|
|
101
176
|
// --- 1b. Color properties in style props ---
|
|
102
177
|
{
|
|
103
178
|
selector: "JSXAttribute[name.name='style'] Property[key.name=/^(color|backgroundColor|background|borderColor|outlineColor|fill|stroke|accentColor|caretColor)$/]",
|
|
104
|
-
message: "
|
|
179
|
+
message: "Color property in style prop (e.g. style={{ backgroundColor: x }}). CSS owns color \u2014 use a className or data-attribute with a CSS rule instead. If the color is data-driven, set a data-attribute and handle it in your exceptions CSS."
|
|
105
180
|
},
|
|
106
181
|
// --- 1c. Visual state properties in style props ---
|
|
107
182
|
{
|
|
108
183
|
selector: "JSXAttribute[name.name='style'] Property[key.name=/^(opacity|visibility|transition|pointerEvents)$/]",
|
|
109
|
-
message: "Visual state property in style prop.
|
|
184
|
+
message: "Visual state property in style prop (e.g. style={{ opacity }}). These are presentation concerns \u2014 use a data-attribute and CSS selector so the visual logic lives in CSS, not JS."
|
|
110
185
|
},
|
|
111
186
|
// --- 1d. Hardcoded magic numbers ---
|
|
112
187
|
{
|
|
113
188
|
selector: "Literal[value=/z-\\[\\d/]",
|
|
114
|
-
message: "Hardcoded z-index. Define a named z-index token in @theme."
|
|
189
|
+
message: "Hardcoded z-index (e.g. z-[999]). Define a named z-index token in @theme (e.g. --z-dropdown, --z-modal) \u2014 magic z-indices create stacking conflicts that are impossible to debug."
|
|
115
190
|
},
|
|
116
191
|
{
|
|
117
192
|
selector: "Literal[value=/-m\\w?-\\[|m\\w?-\\[-/]",
|
|
118
|
-
message: "Negative margin with magic number.
|
|
193
|
+
message: "Negative margin with magic number (e.g. -mt-[8px]). Negative margins fight the layout \u2014 fix the spacing structure (padding, gap) instead of compensating with negative offsets."
|
|
119
194
|
},
|
|
120
195
|
{
|
|
121
196
|
selector: "Literal[value=/(?:min-|max-)?(?:w|h)-\\[\\d+px\\]/]",
|
|
122
|
-
message: "Hardcoded pixel dimension. Define a named size token in @theme or use a relative unit."
|
|
197
|
+
message: "Hardcoded pixel dimension (e.g. w-[347px]). Define a named size token in @theme or use a relative unit \u2014 pixel dimensions break at different viewport sizes and can't be updated globally."
|
|
123
198
|
},
|
|
124
199
|
{
|
|
125
200
|
selector: "Literal[value=/\\w-\\[(?!calc).*?\\d+(?![\\d%]|[dsl]?v[hw]|fr)/]",
|
|
126
|
-
message: "Hardcoded magic number in arbitrary value. Define a named token in @theme
|
|
201
|
+
message: "Hardcoded magic number in arbitrary value (e.g. rounded-[3px], gap-[12px]). Define a named token in @theme \u2014 magic numbers scattered across components can't be updated globally."
|
|
127
202
|
},
|
|
128
203
|
// --- 2. Data-dependent visual decisions (project-specific) ---
|
|
129
204
|
...tokenPattern ? [
|
|
130
205
|
{
|
|
131
206
|
selector: `Literal[value=/\\b(bg|text|border)-(${tokenPattern})/]`,
|
|
132
|
-
message: "
|
|
207
|
+
message: "State color token in className (e.g. bg-success, text-warning). State-driven colors belong in CSS \u2014 set a data-attribute (data-status, data-variant) on the element and write a CSS selector that maps the attribute to the color."
|
|
133
208
|
}
|
|
134
209
|
] : [],
|
|
135
210
|
{
|
|
136
211
|
selector: "JSXAttribute[name.name='style'] ConditionalExpression",
|
|
137
|
-
message: "Conditional style
|
|
212
|
+
message: "Conditional style prop (e.g. style={x ? {...} : {...}}). JS is deciding what the component looks like \u2014 set a data-attribute and let CSS handle the visual change."
|
|
138
213
|
},
|
|
139
214
|
{
|
|
140
215
|
selector: "JSXAttribute[name.name='className'] ConditionalExpression",
|
|
141
|
-
message: "
|
|
216
|
+
message: "Ternary in className (e.g. className={x ? 'a' : 'b'}). JS is picking the visual treatment \u2014 set a data-attribute and write CSS selectors for each state instead."
|
|
142
217
|
},
|
|
143
218
|
{
|
|
144
219
|
selector: "JSXAttribute[name.name='className'] LogicalExpression[operator='&&']",
|
|
145
|
-
message: "Conditional className.
|
|
220
|
+
message: "Conditional className (e.g. isActive && 'bold'). JS is toggling visual properties \u2014 set a data-attribute and write a CSS selector instead."
|
|
146
221
|
},
|
|
147
222
|
{
|
|
148
223
|
selector: "JSXAttribute[name.name='className'] ObjectExpression",
|
|
149
|
-
message: "
|
|
224
|
+
message: "Object syntax in className (e.g. cx({ 'text-red': isError })). This is conditional visual logic in JS \u2014 set a data-attribute and write CSS selectors instead."
|
|
150
225
|
},
|
|
151
226
|
// --- 2c. !important in className ---
|
|
152
227
|
{
|
|
153
228
|
selector: "JSXAttribute[name.name='className'] Literal[value=/(?:^|\\s)![a-z]/]",
|
|
154
|
-
message: "
|
|
229
|
+
message: "!important modifier in className (e.g. !font-bold). !important breaks cascade layers and makes overrides unpredictable \u2014 use a more specific selector or data-attribute instead."
|
|
155
230
|
},
|
|
156
231
|
// --- 2d. Tailwind color utilities in className ---
|
|
157
232
|
...banColorUtilities ? [
|
|
158
233
|
{
|
|
159
234
|
selector: `Literal[value=/(?:^|\\s)(?:${twPrefixes})-(?:${twColors})(?:-\\d+)?(?:\\/\\d+)?(?:\\s|$)/]`,
|
|
160
|
-
message: "Tailwind color
|
|
235
|
+
message: "Tailwind palette color in className (e.g. bg-red-500, text-green-600). Colors belong in CSS \u2014 use a semantic CSS class or data-attribute selector. If you put colors in JSX, changing a color requires editing every component that uses it."
|
|
161
236
|
}
|
|
162
237
|
] : [],
|
|
163
238
|
// --- 3. Ternaries on variant props (from createPrimitive) ---
|
|
164
239
|
...variantProps.map((prop) => ({
|
|
165
240
|
selector: `JSXAttribute[name.name='${prop}'] ConditionalExpression`,
|
|
166
|
-
message: `
|
|
241
|
+
message: `Ternary on ${prop} prop (e.g. ${prop}={x ? 'a' : 'b'}). This component uses createPrimitive \u2014 set a data-attribute and let CSS map data to visual treatment instead of switching ${prop} in JS.`
|
|
167
242
|
})),
|
|
168
243
|
// --- 4. Geometric instability (conditional content) ---
|
|
169
244
|
{
|
|
170
245
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > ConditionalExpression",
|
|
171
|
-
message: "
|
|
246
|
+
message: "Ternary in JSX children (e.g. {x ? <A/> : <B/>}). This causes layout shift \u2014 the container resizes when the content swaps. Quick fix: extract to a const above the return. Proper fix: <StateSwap> for two states, <StateMap> for inline multi-state, <LayoutMap> for block-level multi-state, or <LoadingBoundary> for async data."
|
|
172
247
|
},
|
|
173
248
|
{
|
|
174
249
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='&&']",
|
|
175
|
-
message: "Conditional mount in JSX children
|
|
250
|
+
message: "Conditional mount in JSX children (e.g. {show && <Panel/>}). When this mounts/unmounts, the container resizes. Quick fix: extract to a const above the return. Proper fix: <FadeTransition> for enter/exit animation, <StableField> for form error messages, or <LayoutGroup>/<LayoutMap> for pre-rendered views. Do NOT replace with hidden \u2014 it renders children unconditionally and will crash on null data."
|
|
176
251
|
},
|
|
177
252
|
{
|
|
178
253
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='||']",
|
|
179
|
-
message: "Fallback content in JSX children.
|
|
254
|
+
message: "Fallback content in JSX children (e.g. {name || 'Unknown'}). When the value changes length, the container resizes. Quick fix: extract to a const above the return. Proper fix: <StateSwap> to pre-allocate space for both states."
|
|
180
255
|
},
|
|
181
256
|
{
|
|
182
257
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='??']",
|
|
183
|
-
message: "Nullish fallback in JSX children.
|
|
258
|
+
message: "Nullish fallback in JSX children (e.g. {title ?? 'Loading...'}). When the value arrives, the container resizes. Quick fix: extract to a const above the return. Proper fix: <StateSwap> to pre-allocate space for both states, or <LoadingBoundary> if waiting for async data."
|
|
184
259
|
},
|
|
185
260
|
{
|
|
186
261
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
|
|
187
|
-
message: "Interpolated text in JSX children.
|
|
188
|
-
},
|
|
189
|
-
// --- 4b. Sibling hidden swap (geometric instability) ---
|
|
190
|
-
// Two sibling elements both using conditional hidden to swap
|
|
191
|
-
// visibility — neither reserves space for the other.
|
|
192
|
-
// A single hidden={condition} (e.g. error message at bottom of
|
|
193
|
-
// layout) is fine and intentionally not flagged.
|
|
194
|
-
{
|
|
195
|
-
selector: "JSXElement:has(JSXOpeningElement JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal)) ~ JSXElement:has(JSXOpeningElement JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal))",
|
|
196
|
-
message: "Sibling elements swap visibility with conditional hidden \u2014 neither reserves space for the other, causing layout shift. Use <StateSwap> for two states or <LayoutMap> for multiple states."
|
|
262
|
+
message: "Interpolated text in JSX children (e.g. {`${count} items`}). When the value changes (especially digit count), the container resizes. Quick fix: extract to a const above the return. Proper fix: <StableCounter> for numbers that change digit count, or <StateSwap> for text that changes between known variants."
|
|
197
263
|
},
|
|
264
|
+
// --- 4b removed: sibling hidden swap is now a custom rule ---
|
|
198
265
|
// --- 5. className on firewalled components ---
|
|
199
266
|
...classNameBlocked.length ? [
|
|
200
267
|
{
|
|
201
268
|
selector: `JSXOpeningElement[name.name=/^(${classNameBlocked.join("|")})$/] > JSXAttribute[name.name='className']`,
|
|
202
|
-
message: "className
|
|
269
|
+
message: "className passed to a firewalled component (built with createPrimitive). This component owns its styling \u2014 pass a data-attribute or variant prop instead. The component maps those to visuals internally via CSS."
|
|
203
270
|
}
|
|
204
271
|
] : []
|
|
205
272
|
],
|
|
206
|
-
"stablekit/no-loading-conflict": ["error", { passthrough: loadingPassthrough }]
|
|
273
|
+
"stablekit/no-loading-conflict": ["error", { passthrough: loadingPassthrough }],
|
|
274
|
+
"stablekit/no-hidden-swap": "error"
|
|
207
275
|
},
|
|
208
276
|
plugins: {
|
|
209
277
|
stablekit: {
|
|
210
278
|
rules: {
|
|
211
|
-
"no-loading-conflict": noLoadingConflictRule
|
|
279
|
+
"no-loading-conflict": noLoadingConflictRule,
|
|
280
|
+
"no-hidden-swap": noHiddenSwapRule
|
|
212
281
|
}
|
|
213
282
|
}
|
|
214
283
|
}
|
package/dist/index.cjs
CHANGED
|
@@ -44,7 +44,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
44
44
|
var import_react = require("react");
|
|
45
45
|
|
|
46
46
|
// src/styles.css
|
|
47
|
-
var styles_default = '/* stablekit \u2014 layout stability toolkit for React\n *\n * CSS class prefix: sk-\n * All animations use CSS custom properties for themability.\n * Styles are auto-injected at import time (opt-out via meta tag).\n */\n\n/* \u2500\u2500 LayoutGroup / LayoutView \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* All children overlap in the same grid cell.\n Grid auto-sizes to the largest child.\n Views use flex-column so their content stretches to fill\n the reserved width \u2014 the visual footprint is constant.\n Inline groups (StateSwap) use inline-grid for inline contexts. */\n.sk-layout-group {\n display: grid;\n}\n.sk-layout-group[data-inline] {\n display: inline-grid;\n}\n.sk-layout-group > * {\n grid-area: 1 / 1;\n display: flex;\n flex-direction: column;\n}\n.sk-layout-group[data-inline] > * {\n display: inline-flex;\n flex-direction: row;\n align-items: center;\n
|
|
47
|
+
var styles_default = '/* stablekit \u2014 layout stability toolkit for React\n *\n * CSS class prefix: sk-\n * All animations use CSS custom properties for themability.\n * Styles are auto-injected at import time (opt-out via meta tag).\n */\n\n/* \u2500\u2500 LayoutGroup / LayoutView \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* All children overlap in the same grid cell.\n Grid auto-sizes to the largest child.\n Views use flex-column so their content stretches to fill\n the reserved width \u2014 the visual footprint is constant.\n Inline groups (StateSwap) use inline-grid for inline contexts. */\n.sk-layout-group {\n display: grid;\n}\n.sk-layout-group[data-inline] {\n display: inline-grid;\n}\n.sk-layout-group > * {\n grid-area: 1 / 1;\n display: flex;\n flex-direction: column;\n}\n.sk-layout-group[data-inline] > * {\n display: inline-flex;\n flex-direction: row;\n align-items: center;\n width: 100%;\n}\n\n/* Inactive LayoutView hiding \u2014 CSS-driven via data-state attribute.\n LayoutView sets data-state="active"|"inactive" so consumers can\n override transitions on .sk-layout-view without specificity fights.\n [inert] handles accessibility (non-focusable, non-interactive). */\n.sk-layout-view[data-state="inactive"] {\n opacity: 0;\n visibility: hidden;\n}\n\n/* \u2500\u2500 SizeRatchet \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* contain isolates internal reflow from ancestors. */\n.sk-size-ratchet {\n contain: layout style;\n}\n\n/* \u2500\u2500 Shimmer / Skeleton \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.sk-skeleton-grid {\n display: grid;\n gap: var(--sk-skeleton-gap, 0.75rem);\n contain: layout style;\n}\n\n.sk-skeleton-bone {\n display: flex;\n flex-direction: column;\n gap: var(--sk-skeleton-bone-gap, 0.125rem);\n padding: var(--sk-skeleton-bone-padding, 0.375rem 0.5rem);\n}\n\n.sk-shimmer-line {\n height: 1lh;\n border-radius: var(--sk-shimmer-radius, 0.125rem);\n background: linear-gradient(\n 90deg,\n var(--sk-shimmer-color, #e5e7eb) 25%,\n var(--sk-shimmer-highlight, #f3f4f6) 50%,\n var(--sk-shimmer-color, #e5e7eb) 75%\n );\n background-size: 200% 100%;\n animation: sk-shimmer var(--sk-shimmer-duration, 1.5s) ease-in-out infinite;\n}\n\n/* Inert ghost inside a shimmer-line \u2014 sizes the shimmer to match\n content width exactly. Invisible and non-interactive via [inert]. */\n.sk-shimmer-line > [inert] {\n visibility: hidden;\n}\n\n@keyframes sk-shimmer {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n\n/* \u2500\u2500 MediaSkeleton \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* Loading-aware media container.\n Reserves space via aspect-ratio (set inline by the component).\n Child constraints enforced via React.cloneElement inline styles. */\n.sk-media {\n overflow: hidden;\n}\n.sk-media-shimmer {\n position: absolute;\n inset: 0;\n border-radius: var(--sk-shimmer-radius, 0.125rem);\n background: linear-gradient(\n 90deg,\n var(--sk-shimmer-color, #e5e7eb) 25%,\n var(--sk-shimmer-highlight, #f3f4f6) 50%,\n var(--sk-shimmer-color, #e5e7eb) 75%\n );\n background-size: 200% 100%;\n animation: sk-shimmer var(--sk-shimmer-duration, 1.5s) ease-in-out infinite;\n transition: opacity var(--sk-loading-exit-duration, 400ms) var(--sk-ease-decelerate);\n}\n\n/* \u2500\u2500 Shared easing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* Decelerate: fast start, gentle finish \u2014 for elements entering view. */\n/* Standard: balanced ease \u2014 for general-purpose transitions. */\n/* Accelerate: gentle start, fast finish \u2014 for elements leaving view. */\n:root {\n --sk-ease-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1.0);\n --sk-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);\n --sk-ease-accelerate: cubic-bezier(0.4, 0, 1, 1);\n}\n\n/* \u2500\u2500 FadeTransition \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.sk-fade {\n --sk-fade-duration: 400ms;\n}\n\n.sk-fade-entering {\n animation: sk-emerge var(--sk-fade-duration) var(--sk-ease-decelerate) forwards;\n}\n\n.sk-fade-exiting {\n animation: sk-collapse var(--sk-fade-duration) var(--sk-ease-accelerate) forwards;\n}\n\n@keyframes sk-emerge {\n from {\n opacity: 0;\n transform: translateY(var(--sk-fade-offset-y, -12px)) scale(var(--sk-fade-offset-scale, 0.98));\n }\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n@keyframes sk-collapse {\n from { opacity: 1; transform: scaleY(1); transform-origin: top; }\n to { opacity: 0; transform: scaleY(0); transform-origin: top; }\n}\n\n/* \u2500\u2500 Loading layers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* Shared by shimmer and content layers inside skeleton components.\n Both layers permanently occupy the same grid cell; only opacity\n and interactivity change. CSS transitions handle the crossfade. */\n.sk-loading-layer {\n grid-area: 1 / 1;\n transition: opacity var(--sk-loading-exit-duration, 400ms) var(--sk-ease-decelerate);\n}\n\n/* \u2500\u2500 Reduced motion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* Disables all animations \u2014 shimmer, fade, and loading exit.\n Layout changes still happen instantly so functionality is preserved. */\n@media (prefers-reduced-motion: reduce) {\n .sk-fade-entering,\n .sk-fade-exiting,\n .sk-shimmer-line,\n .sk-media-shimmer {\n animation-duration: 0s !important;\n }\n .sk-loading-layer,\n .sk-media-shimmer {\n transition-duration: 0s !important;\n }\n}\n';
|
|
48
48
|
|
|
49
49
|
// src/internal/inject-styles.ts
|
|
50
50
|
var injected = false;
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "react";
|
|
12
12
|
|
|
13
13
|
// src/styles.css
|
|
14
|
-
var styles_default = '/* stablekit \u2014 layout stability toolkit for React\n *\n * CSS class prefix: sk-\n * All animations use CSS custom properties for themability.\n * Styles are auto-injected at import time (opt-out via meta tag).\n */\n\n/* \u2500\u2500 LayoutGroup / LayoutView \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* All children overlap in the same grid cell.\n Grid auto-sizes to the largest child.\n Views use flex-column so their content stretches to fill\n the reserved width \u2014 the visual footprint is constant.\n Inline groups (StateSwap) use inline-grid for inline contexts. */\n.sk-layout-group {\n display: grid;\n}\n.sk-layout-group[data-inline] {\n display: inline-grid;\n}\n.sk-layout-group > * {\n grid-area: 1 / 1;\n display: flex;\n flex-direction: column;\n}\n.sk-layout-group[data-inline] > * {\n display: inline-flex;\n flex-direction: row;\n align-items: center;\n
|
|
14
|
+
var styles_default = '/* stablekit \u2014 layout stability toolkit for React\n *\n * CSS class prefix: sk-\n * All animations use CSS custom properties for themability.\n * Styles are auto-injected at import time (opt-out via meta tag).\n */\n\n/* \u2500\u2500 LayoutGroup / LayoutView \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* All children overlap in the same grid cell.\n Grid auto-sizes to the largest child.\n Views use flex-column so their content stretches to fill\n the reserved width \u2014 the visual footprint is constant.\n Inline groups (StateSwap) use inline-grid for inline contexts. */\n.sk-layout-group {\n display: grid;\n}\n.sk-layout-group[data-inline] {\n display: inline-grid;\n}\n.sk-layout-group > * {\n grid-area: 1 / 1;\n display: flex;\n flex-direction: column;\n}\n.sk-layout-group[data-inline] > * {\n display: inline-flex;\n flex-direction: row;\n align-items: center;\n width: 100%;\n}\n\n/* Inactive LayoutView hiding \u2014 CSS-driven via data-state attribute.\n LayoutView sets data-state="active"|"inactive" so consumers can\n override transitions on .sk-layout-view without specificity fights.\n [inert] handles accessibility (non-focusable, non-interactive). */\n.sk-layout-view[data-state="inactive"] {\n opacity: 0;\n visibility: hidden;\n}\n\n/* \u2500\u2500 SizeRatchet \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* contain isolates internal reflow from ancestors. */\n.sk-size-ratchet {\n contain: layout style;\n}\n\n/* \u2500\u2500 Shimmer / Skeleton \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.sk-skeleton-grid {\n display: grid;\n gap: var(--sk-skeleton-gap, 0.75rem);\n contain: layout style;\n}\n\n.sk-skeleton-bone {\n display: flex;\n flex-direction: column;\n gap: var(--sk-skeleton-bone-gap, 0.125rem);\n padding: var(--sk-skeleton-bone-padding, 0.375rem 0.5rem);\n}\n\n.sk-shimmer-line {\n height: 1lh;\n border-radius: var(--sk-shimmer-radius, 0.125rem);\n background: linear-gradient(\n 90deg,\n var(--sk-shimmer-color, #e5e7eb) 25%,\n var(--sk-shimmer-highlight, #f3f4f6) 50%,\n var(--sk-shimmer-color, #e5e7eb) 75%\n );\n background-size: 200% 100%;\n animation: sk-shimmer var(--sk-shimmer-duration, 1.5s) ease-in-out infinite;\n}\n\n/* Inert ghost inside a shimmer-line \u2014 sizes the shimmer to match\n content width exactly. Invisible and non-interactive via [inert]. */\n.sk-shimmer-line > [inert] {\n visibility: hidden;\n}\n\n@keyframes sk-shimmer {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n\n/* \u2500\u2500 MediaSkeleton \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* Loading-aware media container.\n Reserves space via aspect-ratio (set inline by the component).\n Child constraints enforced via React.cloneElement inline styles. */\n.sk-media {\n overflow: hidden;\n}\n.sk-media-shimmer {\n position: absolute;\n inset: 0;\n border-radius: var(--sk-shimmer-radius, 0.125rem);\n background: linear-gradient(\n 90deg,\n var(--sk-shimmer-color, #e5e7eb) 25%,\n var(--sk-shimmer-highlight, #f3f4f6) 50%,\n var(--sk-shimmer-color, #e5e7eb) 75%\n );\n background-size: 200% 100%;\n animation: sk-shimmer var(--sk-shimmer-duration, 1.5s) ease-in-out infinite;\n transition: opacity var(--sk-loading-exit-duration, 400ms) var(--sk-ease-decelerate);\n}\n\n/* \u2500\u2500 Shared easing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* Decelerate: fast start, gentle finish \u2014 for elements entering view. */\n/* Standard: balanced ease \u2014 for general-purpose transitions. */\n/* Accelerate: gentle start, fast finish \u2014 for elements leaving view. */\n:root {\n --sk-ease-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1.0);\n --sk-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);\n --sk-ease-accelerate: cubic-bezier(0.4, 0, 1, 1);\n}\n\n/* \u2500\u2500 FadeTransition \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n.sk-fade {\n --sk-fade-duration: 400ms;\n}\n\n.sk-fade-entering {\n animation: sk-emerge var(--sk-fade-duration) var(--sk-ease-decelerate) forwards;\n}\n\n.sk-fade-exiting {\n animation: sk-collapse var(--sk-fade-duration) var(--sk-ease-accelerate) forwards;\n}\n\n@keyframes sk-emerge {\n from {\n opacity: 0;\n transform: translateY(var(--sk-fade-offset-y, -12px)) scale(var(--sk-fade-offset-scale, 0.98));\n }\n to {\n opacity: 1;\n transform: translateY(0) scale(1);\n }\n}\n\n@keyframes sk-collapse {\n from { opacity: 1; transform: scaleY(1); transform-origin: top; }\n to { opacity: 0; transform: scaleY(0); transform-origin: top; }\n}\n\n/* \u2500\u2500 Loading layers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* Shared by shimmer and content layers inside skeleton components.\n Both layers permanently occupy the same grid cell; only opacity\n and interactivity change. CSS transitions handle the crossfade. */\n.sk-loading-layer {\n grid-area: 1 / 1;\n transition: opacity var(--sk-loading-exit-duration, 400ms) var(--sk-ease-decelerate);\n}\n\n/* \u2500\u2500 Reduced motion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n/* Disables all animations \u2014 shimmer, fade, and loading exit.\n Layout changes still happen instantly so functionality is preserved. */\n@media (prefers-reduced-motion: reduce) {\n .sk-fade-entering,\n .sk-fade-exiting,\n .sk-shimmer-line,\n .sk-media-shimmer {\n animation-duration: 0s !important;\n }\n .sk-loading-layer,\n .sk-media-shimmer {\n transition-duration: 0s !important;\n }\n}\n';
|
|
15
15
|
|
|
16
16
|
// src/internal/inject-styles.ts
|
|
17
17
|
var injected = false;
|
package/dist/styles.css
CHANGED
package/package.json
CHANGED