stablekit.ts 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/eslint.cjs CHANGED
@@ -208,10 +208,14 @@ function createArchitectureLint(options) {
208
208
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
209
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
210
  },
211
- // --- 4b. Conditional hidden prop (geometric instability) ---
212
- {
213
- selector: "JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal)",
214
- message: "Conditional hidden prop causes layout shift \u2014 the element occupies zero space when hidden, then expands when shown. Use <StateSwap> to pre-allocate geometry for all visual states."
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."
215
219
  },
216
220
  // --- 5. className on firewalled components ---
217
221
  ...classNameBlocked.length ? [
package/dist/eslint.d.cts CHANGED
@@ -22,10 +22,10 @@
22
22
  *
23
23
  * 4. Geometric instability — conditional content in JSX children that
24
24
  * causes layout shift: ternary swaps, && mounts, || / ?? fallbacks,
25
- * interpolated template literals, and conditional hidden props.
26
- * Each message guides toward extracting the expression to a variable
27
- * (for data transforms) or using a StableKit component (for
28
- * state-driven swaps). Always on.
25
+ * interpolated template literals, and sibling hidden swaps (two+
26
+ * siblings using conditional hidden to toggle visibility without
27
+ * reserving space for each other). A single hidden={condition} is
28
+ * fine and not flagged. Always on.
29
29
  *
30
30
  * 5. className on custom components — passing className to a PascalCase
31
31
  * component is a presentation leak across the Structure boundary.
package/dist/eslint.d.ts CHANGED
@@ -22,10 +22,10 @@
22
22
  *
23
23
  * 4. Geometric instability — conditional content in JSX children that
24
24
  * causes layout shift: ternary swaps, && mounts, || / ?? fallbacks,
25
- * interpolated template literals, and conditional hidden props.
26
- * Each message guides toward extracting the expression to a variable
27
- * (for data transforms) or using a StableKit component (for
28
- * state-driven swaps). Always on.
25
+ * interpolated template literals, and sibling hidden swaps (two+
26
+ * siblings using conditional hidden to toggle visibility without
27
+ * reserving space for each other). A single hidden={condition} is
28
+ * fine and not flagged. Always on.
29
29
  *
30
30
  * 5. className on custom components — passing className to a PascalCase
31
31
  * component is a presentation leak across the Structure boundary.
package/dist/eslint.js CHANGED
@@ -184,10 +184,14 @@ function createArchitectureLint(options) {
184
184
  selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
185
185
  message: "Interpolated text in JSX children. Extract to a const above the return (the linter won't flag a plain variable). For state-driven values, use <StableCounter> for numbers or <StateSwap> for text variants."
186
186
  },
187
- // --- 4b. Conditional hidden prop (geometric instability) ---
188
- {
189
- selector: "JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal)",
190
- message: "Conditional hidden prop causes layout shift \u2014 the element occupies zero space when hidden, then expands when shown. Use <StateSwap> to pre-allocate geometry for all visual states."
187
+ // --- 4b. Sibling hidden swap (geometric instability) ---
188
+ // Two sibling elements both using conditional hidden to swap
189
+ // visibility neither reserves space for the other.
190
+ // A single hidden={condition} (e.g. error message at bottom of
191
+ // layout) is fine and intentionally not flagged.
192
+ {
193
+ selector: "JSXElement:has(JSXOpeningElement JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal)) ~ JSXElement:has(JSXOpeningElement JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal))",
194
+ 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."
191
195
  },
192
196
  // --- 5. className on firewalled components ---
193
197
  ...classNameBlocked.length ? [
@@ -25,6 +25,7 @@ __export(stylelint_exports, {
25
25
  module.exports = __toCommonJS(stylelint_exports);
26
26
  var pluginRuleName = "stablekit/no-functional-in-utility";
27
27
  var descendantColorRuleName = "stablekit/no-descendant-color-in-state";
28
+ var duplicateRulesetRuleName = "stablekit/no-duplicate-ruleset";
28
29
  function createFunctionalTokenPlugin(prefixes) {
29
30
  const rule = (enabled) => {
30
31
  return (root, result) => {
@@ -51,14 +52,17 @@ function createFunctionalTokenPlugin(prefixes) {
51
52
  function createDescendantColorPlugin() {
52
53
  const dataAttrWithDescendant = /\[data-[^\]]+\]\s+[.#\w]/;
53
54
  const colorApplyPattern = /\btext-(?!xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl|left|right|center|justify|wrap|nowrap|ellipsis|clip|truncate)\w/;
54
- const message = `Setting color on a descendant inside a data-attribute selector. Set color on the [data-*] container and let children inherit via currentColor.`;
55
+ const message = `Visual property on a descendant inside a data-attribute selector. Set visual properties on the [data-*] container and let children inherit via currentColor.`;
55
56
  const rule = (enabled) => {
56
57
  return (root, result) => {
57
58
  if (!enabled) return;
58
59
  root.walkRules((ruleNode) => {
59
60
  if (!dataAttrWithDescendant.test(ruleNode.selector)) return;
60
- ruleNode.walkDecls("color", (decl) => {
61
- result.warn(message, { node: decl });
61
+ const visualProps = /^(color|background|background-color|border-color|outline-color|fill|stroke|opacity|box-shadow|text-shadow)$/;
62
+ ruleNode.walkDecls((decl) => {
63
+ if (visualProps.test(decl.prop)) {
64
+ result.warn(message, { node: decl });
65
+ }
62
66
  });
63
67
  ruleNode.walkAtRules("apply", (atRule) => {
64
68
  if (colorApplyPattern.test(atRule.params)) {
@@ -73,13 +77,54 @@ function createDescendantColorPlugin() {
73
77
  rule
74
78
  };
75
79
  }
80
+ function createDuplicateRulesetPlugin() {
81
+ function directFingerprint(ruleNode) {
82
+ const parts = [];
83
+ for (const child of ruleNode.nodes ?? []) {
84
+ if (child.type === "decl") {
85
+ parts.push(`${child.prop}: ${child.value}`);
86
+ } else if (child.type === "atrule" && child.name === "apply") {
87
+ parts.push(`@apply ${child.params}`);
88
+ }
89
+ }
90
+ return parts;
91
+ }
92
+ const rule = (enabled) => {
93
+ return (root, result) => {
94
+ if (!enabled) return;
95
+ const seen = /* @__PURE__ */ new Map();
96
+ root.walkRules((ruleNode) => {
97
+ if (ruleNode.parent?.type === "atrule" && ruleNode.parent.name === "keyframes") return;
98
+ const parts = directFingerprint(ruleNode);
99
+ if (parts.length < 2) return;
100
+ const fingerprint = parts.sort().join("; ");
101
+ const existing = seen.get(fingerprint);
102
+ if (existing) {
103
+ const sameSelector = existing === ruleNode.selector;
104
+ result.warn(
105
+ sameSelector ? `Redundant rule \u2014 "${ruleNode.selector}" is defined multiple times with identical declarations. Remove the duplicate.
106
+ Declarations: ${fingerprint}` : `Duplicate ruleset \u2014 "${ruleNode.selector}" has identical declarations to "${existing}". Consolidate under a shared class name.
107
+ Declarations: ${fingerprint}`,
108
+ { node: ruleNode }
109
+ );
110
+ } else {
111
+ seen.set(fingerprint, ruleNode.selector);
112
+ }
113
+ });
114
+ };
115
+ };
116
+ return {
117
+ ruleName: duplicateRulesetRuleName,
118
+ rule
119
+ };
120
+ }
76
121
  function createStyleLint(options = {}) {
77
122
  const {
78
123
  ignoreTypes = ["html", "body"],
79
124
  functionalTokens = [],
80
125
  files = ["src/**/*.css"]
81
126
  } = options;
82
- const plugins = [createDescendantColorPlugin()];
127
+ const plugins = [createDescendantColorPlugin(), createDuplicateRulesetPlugin()];
83
128
  if (functionalTokens.length > 0) {
84
129
  plugins.push(createFunctionalTokenPlugin(functionalTokens));
85
130
  }
@@ -95,6 +140,8 @@ function createStyleLint(options = {}) {
95
140
  "declaration-no-important": true,
96
141
  // Ban color on descendants inside data-attribute selectors.
97
142
  [descendantColorRuleName]: true,
143
+ // Flag selectors with identical declarations for consolidation.
144
+ [duplicateRulesetRuleName]: true,
98
145
  // Ban animating layout properties — causes reflow on every frame.
99
146
  // Use transform (scaleY, translateY) or opacity instead.
100
147
  "declaration-property-value-disallowed-list": [
@@ -18,13 +18,19 @@
18
18
  * in scoped selectors like `.badge[data-status="paid"]`, not in
19
19
  * utilities that can spread via @apply or className.
20
20
  *
21
- * 4. Don't set color on descendants inside data-attribute selectors.
21
+ * 4. Don't set visual properties on descendants inside data-attribute selectors.
22
22
  * `.card[data-status="error"] .icon { color: red }` is wrong —
23
23
  * set color on the container and let children inherit via currentColor.
24
+ * Also catches background, border-color, opacity, box-shadow, etc.
24
25
  *
25
26
  * 5. Don't animate layout properties (width, height, margin, padding,
26
27
  * top/right/bottom/left). These trigger reflow on every frame.
27
28
  * Use transform (scaleY, translateY) or opacity instead.
29
+ *
30
+ * 6. Don't duplicate rulesets — two selectors with byte-identical
31
+ * declarations (after sorting) should be consolidated under one
32
+ * shared class name. The warning includes the matched declarations
33
+ * so the developer can see why they were flagged.
28
34
  */
29
35
  interface StyleLintOptions {
30
36
  /** Element selectors to allow (e.g. in resets).
@@ -18,13 +18,19 @@
18
18
  * in scoped selectors like `.badge[data-status="paid"]`, not in
19
19
  * utilities that can spread via @apply or className.
20
20
  *
21
- * 4. Don't set color on descendants inside data-attribute selectors.
21
+ * 4. Don't set visual properties on descendants inside data-attribute selectors.
22
22
  * `.card[data-status="error"] .icon { color: red }` is wrong —
23
23
  * set color on the container and let children inherit via currentColor.
24
+ * Also catches background, border-color, opacity, box-shadow, etc.
24
25
  *
25
26
  * 5. Don't animate layout properties (width, height, margin, padding,
26
27
  * top/right/bottom/left). These trigger reflow on every frame.
27
28
  * Use transform (scaleY, translateY) or opacity instead.
29
+ *
30
+ * 6. Don't duplicate rulesets — two selectors with byte-identical
31
+ * declarations (after sorting) should be consolidated under one
32
+ * shared class name. The warning includes the matched declarations
33
+ * so the developer can see why they were flagged.
28
34
  */
29
35
  interface StyleLintOptions {
30
36
  /** Element selectors to allow (e.g. in resets).
package/dist/stylelint.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/stylelint.ts
2
2
  var pluginRuleName = "stablekit/no-functional-in-utility";
3
3
  var descendantColorRuleName = "stablekit/no-descendant-color-in-state";
4
+ var duplicateRulesetRuleName = "stablekit/no-duplicate-ruleset";
4
5
  function createFunctionalTokenPlugin(prefixes) {
5
6
  const rule = (enabled) => {
6
7
  return (root, result) => {
@@ -27,14 +28,17 @@ function createFunctionalTokenPlugin(prefixes) {
27
28
  function createDescendantColorPlugin() {
28
29
  const dataAttrWithDescendant = /\[data-[^\]]+\]\s+[.#\w]/;
29
30
  const colorApplyPattern = /\btext-(?!xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl|left|right|center|justify|wrap|nowrap|ellipsis|clip|truncate)\w/;
30
- const message = `Setting color on a descendant inside a data-attribute selector. Set color on the [data-*] container and let children inherit via currentColor.`;
31
+ const message = `Visual property on a descendant inside a data-attribute selector. Set visual properties on the [data-*] container and let children inherit via currentColor.`;
31
32
  const rule = (enabled) => {
32
33
  return (root, result) => {
33
34
  if (!enabled) return;
34
35
  root.walkRules((ruleNode) => {
35
36
  if (!dataAttrWithDescendant.test(ruleNode.selector)) return;
36
- ruleNode.walkDecls("color", (decl) => {
37
- result.warn(message, { node: decl });
37
+ const visualProps = /^(color|background|background-color|border-color|outline-color|fill|stroke|opacity|box-shadow|text-shadow)$/;
38
+ ruleNode.walkDecls((decl) => {
39
+ if (visualProps.test(decl.prop)) {
40
+ result.warn(message, { node: decl });
41
+ }
38
42
  });
39
43
  ruleNode.walkAtRules("apply", (atRule) => {
40
44
  if (colorApplyPattern.test(atRule.params)) {
@@ -49,13 +53,54 @@ function createDescendantColorPlugin() {
49
53
  rule
50
54
  };
51
55
  }
56
+ function createDuplicateRulesetPlugin() {
57
+ function directFingerprint(ruleNode) {
58
+ const parts = [];
59
+ for (const child of ruleNode.nodes ?? []) {
60
+ if (child.type === "decl") {
61
+ parts.push(`${child.prop}: ${child.value}`);
62
+ } else if (child.type === "atrule" && child.name === "apply") {
63
+ parts.push(`@apply ${child.params}`);
64
+ }
65
+ }
66
+ return parts;
67
+ }
68
+ const rule = (enabled) => {
69
+ return (root, result) => {
70
+ if (!enabled) return;
71
+ const seen = /* @__PURE__ */ new Map();
72
+ root.walkRules((ruleNode) => {
73
+ if (ruleNode.parent?.type === "atrule" && ruleNode.parent.name === "keyframes") return;
74
+ const parts = directFingerprint(ruleNode);
75
+ if (parts.length < 2) return;
76
+ const fingerprint = parts.sort().join("; ");
77
+ const existing = seen.get(fingerprint);
78
+ if (existing) {
79
+ const sameSelector = existing === ruleNode.selector;
80
+ result.warn(
81
+ sameSelector ? `Redundant rule \u2014 "${ruleNode.selector}" is defined multiple times with identical declarations. Remove the duplicate.
82
+ Declarations: ${fingerprint}` : `Duplicate ruleset \u2014 "${ruleNode.selector}" has identical declarations to "${existing}". Consolidate under a shared class name.
83
+ Declarations: ${fingerprint}`,
84
+ { node: ruleNode }
85
+ );
86
+ } else {
87
+ seen.set(fingerprint, ruleNode.selector);
88
+ }
89
+ });
90
+ };
91
+ };
92
+ return {
93
+ ruleName: duplicateRulesetRuleName,
94
+ rule
95
+ };
96
+ }
52
97
  function createStyleLint(options = {}) {
53
98
  const {
54
99
  ignoreTypes = ["html", "body"],
55
100
  functionalTokens = [],
56
101
  files = ["src/**/*.css"]
57
102
  } = options;
58
- const plugins = [createDescendantColorPlugin()];
103
+ const plugins = [createDescendantColorPlugin(), createDuplicateRulesetPlugin()];
59
104
  if (functionalTokens.length > 0) {
60
105
  plugins.push(createFunctionalTokenPlugin(functionalTokens));
61
106
  }
@@ -71,6 +116,8 @@ function createStyleLint(options = {}) {
71
116
  "declaration-no-important": true,
72
117
  // Ban color on descendants inside data-attribute selectors.
73
118
  [descendantColorRuleName]: true,
119
+ // Flag selectors with identical declarations for consolidation.
120
+ [duplicateRulesetRuleName]: true,
74
121
  // Ban animating layout properties — causes reflow on every frame.
75
122
  // Use transform (scaleY, translateY) or opacity instead.
76
123
  "declaration-property-value-disallowed-list": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stablekit.ts",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "React toolkit for layout stability — zero-shift components for loading states, content swaps, and spatial containers.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",