stablekit.ts 0.6.3 → 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 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. Use static children with the loading prop, or handle the entire swap explicitly with <StateSwap> in the caller."
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. Define a named token in @theme."
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 value. Define a CSS custom property."
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 value in style object. Define a CSS custom property."
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: "Visual color property in style prop. Use a data-attribute and CSS selector."
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. Use a data-attribute and CSS selector."
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. This usually fights the layout \u2014 fix the spacing structure instead."
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 or use a standard utility."
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: "Data-dependent visual property. Use a data-attribute and CSS selector."
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 object. Use a data-state attribute and CSS selector."
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: "Conditional className. Use a data-attribute and CSS selector instead of switching classes with a ternary."
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. Use a data-attribute and CSS selector instead of conditionally applying classes."
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: "Conditional className via object syntax. Use a data-attribute and CSS selector instead of cx/cn({ class: condition })."
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: "Tailwind !important modifier in className. !important breaks the cascade \u2014 use specificity or data-attributes."
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 utility in className. Colors belong in CSS \u2014 use a CSS class with a custom property or data-attribute selector."
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: `Data-dependent ${prop}. Use a data-attribute and CSS selector instead of switching ${prop} with a ternary.`
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: "Conditional content in JSX children. Extract to a const above the return (the linter won't flag a plain variable). Do NOT replace with the hidden attribute. For state-driven swaps, use <StateSwap> for text, <StateMap> for keyed views, or <LoadingBoundary> for async states."
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. Extract to a const above the return (the linter won't flag a plain variable). Do NOT use the hidden attribute \u2014 it renders children unconditionally and will crash on null data. For state-driven mounts, use <FadeTransition> for enter/exit, <StableField> for form errors, or <LayoutGroup> for pre-rendered views."
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. Extract to a const above the return (the linter won't flag a plain variable). For state-driven fallbacks, use <StateSwap> to pre-allocate space for both states."
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. Extract to a const above the return (the linter won't flag a plain variable). For state-driven fallbacks, use <StateSwap> to pre-allocate space for both states."
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. 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
- // --- 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 on a firewalled component. This component owns its own styling \u2014 use a data-attribute, variant prop, or CSS class internally instead of passing Tailwind utilities from the consumer."
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. Use static children with the loading prop, or handle the entire swap explicitly with <StateSwap> in the caller."
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. Define a named token in @theme."
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 value. Define a CSS custom property."
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 value in style object. Define a CSS custom property."
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: "Visual color property in style prop. Use a data-attribute and CSS selector."
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. Use a data-attribute and CSS selector."
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. This usually fights the layout \u2014 fix the spacing structure instead."
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 or use a standard utility."
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: "Data-dependent visual property. Use a data-attribute and CSS selector."
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 object. Use a data-state attribute and CSS selector."
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: "Conditional className. Use a data-attribute and CSS selector instead of switching classes with a ternary."
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. Use a data-attribute and CSS selector instead of conditionally applying classes."
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: "Conditional className via object syntax. Use a data-attribute and CSS selector instead of cx/cn({ class: condition })."
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: "Tailwind !important modifier in className. !important breaks the cascade \u2014 use specificity or data-attributes."
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 utility in className. Colors belong in CSS \u2014 use a CSS class with a custom property or data-attribute selector."
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: `Data-dependent ${prop}. Use a data-attribute and CSS selector instead of switching ${prop} with a ternary.`
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: "Conditional content in JSX children. Extract to a const above the return (the linter won't flag a plain variable). Do NOT replace with the hidden attribute. For state-driven swaps, use <StateSwap> for text, <StateMap> for keyed views, or <LoadingBoundary> for async states."
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. Extract to a const above the return (the linter won't flag a plain variable). Do NOT use the hidden attribute \u2014 it renders children unconditionally and will crash on null data. For state-driven mounts, use <FadeTransition> for enter/exit, <StableField> for form errors, or <LayoutGroup> for pre-rendered views."
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. Extract to a const above the return (the linter won't flag a plain variable). For state-driven fallbacks, use <StateSwap> to pre-allocate space for both states."
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. Extract to a const above the return (the linter won't flag a plain variable). For state-driven fallbacks, use <StateSwap> to pre-allocate space for both states."
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. 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."
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 on a firewalled component. This component owns its own styling \u2014 use a data-attribute, variant prop, or CSS class internally instead of passing Tailwind utilities from the consumer."
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}\n.sk-layout-group[data-inline] > * > * {\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';
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}\n.sk-layout-group[data-inline] > * > * {\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';
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;
@@ -5856,7 +5856,6 @@ module.exports = __toCommonJS(stylelint_exports);
5856
5856
  var pluginRuleName = "stablekit/no-functional-in-utility";
5857
5857
  var descendantColorRuleName = "stablekit/no-descendant-color-in-state";
5858
5858
  var duplicateRulesetRuleName = "stablekit/no-duplicate-ruleset";
5859
- var nearDuplicateRuleName = "stablekit/no-near-duplicate-ruleset";
5860
5859
  var undefinedTokenRuleName = "stablekit/no-undefined-token";
5861
5860
  function createFunctionalTokenPlugin(prefixes) {
5862
5861
  const rule = (enabled) => {
@@ -5950,65 +5949,6 @@ Declarations: ${fingerprint}`,
5950
5949
  rule
5951
5950
  };
5952
5951
  }
5953
- function createNearDuplicatePlugin() {
5954
- function collectDirectDecls(ruleNode) {
5955
- const map = /* @__PURE__ */ new Map();
5956
- for (const child of ruleNode.nodes ?? []) {
5957
- if (child.type === "decl") {
5958
- map.set(child.prop, child.value);
5959
- } else if (child.type === "atrule" && child.name === "apply") {
5960
- map.set(`@apply`, child.params);
5961
- }
5962
- }
5963
- return map;
5964
- }
5965
- const rule = (enabled) => {
5966
- return (root, result) => {
5967
- if (!enabled) return;
5968
- const groups = /* @__PURE__ */ new Map();
5969
- root.walkRules((ruleNode) => {
5970
- if (ruleNode.parent?.type === "atrule" && ruleNode.parent.name === "keyframes") return;
5971
- const propMap = collectDirectDecls(ruleNode);
5972
- if (propMap.size < 2) return;
5973
- const propKey = [...propMap.keys()].sort().join(", ");
5974
- const entry = { selector: ruleNode.selector, propKey, propMap, node: ruleNode };
5975
- const group = groups.get(propKey);
5976
- if (group) {
5977
- for (const existing of group) {
5978
- let diffCount = 0;
5979
- let diffProp = "";
5980
- let diffOld = "";
5981
- let diffNew = "";
5982
- for (const [prop, value] of entry.propMap) {
5983
- const existingValue = existing.propMap.get(prop);
5984
- if (existingValue !== value) {
5985
- diffCount++;
5986
- diffProp = prop;
5987
- diffOld = existingValue ?? "(missing)";
5988
- diffNew = value;
5989
- }
5990
- }
5991
- if (diffCount === 0) continue;
5992
- if (diffCount === 1 && diffProp !== "@apply") {
5993
- result.warn(
5994
- `Near-duplicate ruleset \u2014 "${ruleNode.selector}" differs from "${existing.selector}" only in ${diffProp} (${diffOld} \u2192 ${diffNew}). Consider consolidating to a single class.`,
5995
- { node: ruleNode }
5996
- );
5997
- break;
5998
- }
5999
- }
6000
- group.push(entry);
6001
- } else {
6002
- groups.set(propKey, [entry]);
6003
- }
6004
- });
6005
- };
6006
- };
6007
- return {
6008
- ruleName: nearDuplicateRuleName,
6009
- rule
6010
- };
6011
- }
6012
5952
  function createUndefinedTokenPlugin(runtimePrefixes) {
6013
5953
  const varPattern = /var\(--([a-zA-Z0-9_-]+)/g;
6014
5954
  const rule = (enabled) => {
@@ -6055,7 +5995,7 @@ function createStyleLint(options = {}) {
6055
5995
  runtimeTokens = [],
6056
5996
  files = ["src/**/*.css"]
6057
5997
  } = options;
6058
- const plugins = [createDescendantColorPlugin(), createDuplicateRulesetPlugin(), createNearDuplicatePlugin(), createUndefinedTokenPlugin(runtimeTokens)];
5998
+ const plugins = [createDescendantColorPlugin(), createDuplicateRulesetPlugin(), createUndefinedTokenPlugin(runtimeTokens)];
6059
5999
  if (functionalTokens.length > 0) {
6060
6000
  plugins.push(createFunctionalTokenPlugin(functionalTokens));
6061
6001
  }
@@ -6073,8 +6013,6 @@ function createStyleLint(options = {}) {
6073
6013
  [descendantColorRuleName]: true,
6074
6014
  // Flag selectors with identical declarations for consolidation.
6075
6015
  [duplicateRulesetRuleName]: true,
6076
- // Flag near-duplicate rulesets (same props, differ by 1 value).
6077
- [nearDuplicateRuleName]: true,
6078
6016
  // Flag var() references to custom properties not defined in this file.
6079
6017
  [undefinedTokenRuleName]: true,
6080
6018
  // Ban animating layout properties — causes reflow on every frame.
@@ -27,7 +27,7 @@
27
27
  * top/right/bottom/left). These trigger reflow on every frame.
28
28
  * Use transform (scaleY, translateY) or opacity instead.
29
29
  *
30
- * 6. Don't duplicate rulesets — two selectors with byte-identical
30
+ * 6. Don't duplicate rulesets — two selectors with identical
31
31
  * declarations (after sorting) should be consolidated under one
32
32
  * shared class name. The warning includes the matched declarations
33
33
  * so the developer can see why they were flagged.
@@ -27,7 +27,7 @@
27
27
  * top/right/bottom/left). These trigger reflow on every frame.
28
28
  * Use transform (scaleY, translateY) or opacity instead.
29
29
  *
30
- * 6. Don't duplicate rulesets — two selectors with byte-identical
30
+ * 6. Don't duplicate rulesets — two selectors with identical
31
31
  * declarations (after sorting) should be consolidated under one
32
32
  * shared class name. The warning includes the matched declarations
33
33
  * so the developer can see why they were flagged.
package/dist/stylelint.js CHANGED
@@ -5833,7 +5833,6 @@ var require_postcss = __commonJS({
5833
5833
  var pluginRuleName = "stablekit/no-functional-in-utility";
5834
5834
  var descendantColorRuleName = "stablekit/no-descendant-color-in-state";
5835
5835
  var duplicateRulesetRuleName = "stablekit/no-duplicate-ruleset";
5836
- var nearDuplicateRuleName = "stablekit/no-near-duplicate-ruleset";
5837
5836
  var undefinedTokenRuleName = "stablekit/no-undefined-token";
5838
5837
  function createFunctionalTokenPlugin(prefixes) {
5839
5838
  const rule = (enabled) => {
@@ -5927,65 +5926,6 @@ Declarations: ${fingerprint}`,
5927
5926
  rule
5928
5927
  };
5929
5928
  }
5930
- function createNearDuplicatePlugin() {
5931
- function collectDirectDecls(ruleNode) {
5932
- const map = /* @__PURE__ */ new Map();
5933
- for (const child of ruleNode.nodes ?? []) {
5934
- if (child.type === "decl") {
5935
- map.set(child.prop, child.value);
5936
- } else if (child.type === "atrule" && child.name === "apply") {
5937
- map.set(`@apply`, child.params);
5938
- }
5939
- }
5940
- return map;
5941
- }
5942
- const rule = (enabled) => {
5943
- return (root, result) => {
5944
- if (!enabled) return;
5945
- const groups = /* @__PURE__ */ new Map();
5946
- root.walkRules((ruleNode) => {
5947
- if (ruleNode.parent?.type === "atrule" && ruleNode.parent.name === "keyframes") return;
5948
- const propMap = collectDirectDecls(ruleNode);
5949
- if (propMap.size < 2) return;
5950
- const propKey = [...propMap.keys()].sort().join(", ");
5951
- const entry = { selector: ruleNode.selector, propKey, propMap, node: ruleNode };
5952
- const group = groups.get(propKey);
5953
- if (group) {
5954
- for (const existing of group) {
5955
- let diffCount = 0;
5956
- let diffProp = "";
5957
- let diffOld = "";
5958
- let diffNew = "";
5959
- for (const [prop, value] of entry.propMap) {
5960
- const existingValue = existing.propMap.get(prop);
5961
- if (existingValue !== value) {
5962
- diffCount++;
5963
- diffProp = prop;
5964
- diffOld = existingValue ?? "(missing)";
5965
- diffNew = value;
5966
- }
5967
- }
5968
- if (diffCount === 0) continue;
5969
- if (diffCount === 1 && diffProp !== "@apply") {
5970
- result.warn(
5971
- `Near-duplicate ruleset \u2014 "${ruleNode.selector}" differs from "${existing.selector}" only in ${diffProp} (${diffOld} \u2192 ${diffNew}). Consider consolidating to a single class.`,
5972
- { node: ruleNode }
5973
- );
5974
- break;
5975
- }
5976
- }
5977
- group.push(entry);
5978
- } else {
5979
- groups.set(propKey, [entry]);
5980
- }
5981
- });
5982
- };
5983
- };
5984
- return {
5985
- ruleName: nearDuplicateRuleName,
5986
- rule
5987
- };
5988
- }
5989
5929
  function createUndefinedTokenPlugin(runtimePrefixes) {
5990
5930
  const varPattern = /var\(--([a-zA-Z0-9_-]+)/g;
5991
5931
  const rule = (enabled) => {
@@ -6032,7 +5972,7 @@ function createStyleLint(options = {}) {
6032
5972
  runtimeTokens = [],
6033
5973
  files = ["src/**/*.css"]
6034
5974
  } = options;
6035
- const plugins = [createDescendantColorPlugin(), createDuplicateRulesetPlugin(), createNearDuplicatePlugin(), createUndefinedTokenPlugin(runtimeTokens)];
5975
+ const plugins = [createDescendantColorPlugin(), createDuplicateRulesetPlugin(), createUndefinedTokenPlugin(runtimeTokens)];
6036
5976
  if (functionalTokens.length > 0) {
6037
5977
  plugins.push(createFunctionalTokenPlugin(functionalTokens));
6038
5978
  }
@@ -6050,8 +5990,6 @@ function createStyleLint(options = {}) {
6050
5990
  [descendantColorRuleName]: true,
6051
5991
  // Flag selectors with identical declarations for consolidation.
6052
5992
  [duplicateRulesetRuleName]: true,
6053
- // Flag near-duplicate rulesets (same props, differ by 1 value).
6054
- [nearDuplicateRuleName]: true,
6055
5993
  // Flag var() references to custom properties not defined in this file.
6056
5994
  [undefinedTokenRuleName]: true,
6057
5995
  // Ban animating layout properties — causes reflow on every frame.
package/dist/styles.css CHANGED
@@ -27,8 +27,6 @@
27
27
  display: inline-flex;
28
28
  flex-direction: row;
29
29
  align-items: center;
30
- }
31
- .sk-layout-group[data-inline] > * > * {
32
30
  width: 100%;
33
31
  }
34
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stablekit.ts",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
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",