stablekit.ts 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +207 -0
- package/dist/eslint.cjs +157 -0
- package/dist/eslint.d.cts +55 -0
- package/dist/eslint.d.ts +55 -0
- package/dist/eslint.js +132 -0
- package/dist/index.cjs +823 -0
- package/dist/index.d.cts +473 -0
- package/dist/index.d.ts +473 -0
- package/dist/index.js +816 -0
- package/dist/stylelint.cjs +117 -0
- package/dist/stylelint.d.cts +47 -0
- package/dist/stylelint.d.ts +47 -0
- package/dist/stylelint.js +92 -0
- package/dist/styles.css +178 -0
- package/llms.txt +463 -0
- package/package.json +92 -0
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# StableKit
|
|
2
|
+
|
|
3
|
+
React components that make layout shift structurally impossible.
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
|
|
7
|
+
React couples two things that should be independent: **what a component looks like** (paint) and **how much space it takes up** (geometry). When state changes, both change at once, and the browser reflows the page.
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
// When isLoading flips, the spinner is destroyed and replaced by a table.
|
|
11
|
+
// The browser reflows the entire page.
|
|
12
|
+
{isLoading ? <Spinner /> : <DataTable rows={rows} />}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Every ternary, every conditional render, every `{data && <Component />}` is a geometry mutation disguised as a paint change. This is not a bug — it's the default rendering model.
|
|
16
|
+
|
|
17
|
+
## The Fix
|
|
18
|
+
|
|
19
|
+
One rule:
|
|
20
|
+
|
|
21
|
+
> **A container's dimensions must be a function of its maximum possible future state, not its current instantaneous state.**
|
|
22
|
+
|
|
23
|
+
Geometry is pre-allocated before data arrives, before the user clicks, before the state changes. Paint changes freely. Geometry never moves.
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
// One tree describes both loading and loaded states.
|
|
27
|
+
// The geometry is identical in both. Only the paint changes.
|
|
28
|
+
<LoadingBoundary loading={isLoading} exitDuration={150}>
|
|
29
|
+
<MediaSkeleton aspectRatio={1} className="w-16 rounded-full">
|
|
30
|
+
<img src={user.avatar} alt={user.name} />
|
|
31
|
+
</MediaSkeleton>
|
|
32
|
+
<StableText as="h2" className="text-xl font-bold">{user.name}</StableText>
|
|
33
|
+
<StableText as="p" className="text-sm text-muted">{user.email}</StableText>
|
|
34
|
+
</LoadingBoundary>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install stablekit
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Three Kinds of Stability
|
|
44
|
+
|
|
45
|
+
### Temporal Pre-allocation
|
|
46
|
+
|
|
47
|
+
If a component depends on async data, its bounding box is declared synchronously before the data arrives. `MediaSkeleton` forces an `aspectRatio`. `CollectionSkeleton` forces a `stubCount`. `StableText` reserves space at the exact line-height of the text it will display.
|
|
48
|
+
|
|
49
|
+
### Spatial Pre-allocation
|
|
50
|
+
|
|
51
|
+
If a UI region has multiple states, all states render simultaneously in a CSS grid overlap. The container sizes to the largest. `LayoutMap` renders a dictionary of views, toggles visibility with `[inert]` + `data-state`, and never changes dimensions. `StateSwap` does the same for boolean content inside buttons and labels.
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
<LayoutMap value={activeTab} map={{
|
|
55
|
+
profile: <Profile />,
|
|
56
|
+
invoices: <Invoices />,
|
|
57
|
+
settings: <Settings />,
|
|
58
|
+
}} />
|
|
59
|
+
|
|
60
|
+
<button onClick={toggle}>
|
|
61
|
+
<StateSwap state={expanded} true="Close" false="View Details" />
|
|
62
|
+
</button>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Monotonic Geometry
|
|
66
|
+
|
|
67
|
+
Once a container expands, it cannot shrink unless explicitly reset. `SizeRatchet` tracks the maximum size ever observed and applies `min-width`/`min-height` that only grows. `resetKey` resets the floor when the context changes.
|
|
68
|
+
|
|
69
|
+
## Components
|
|
70
|
+
|
|
71
|
+
| Component | What it does |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `LoadingBoundary` | Loading orchestrator — composes shimmer + ratchet + exit transition |
|
|
74
|
+
| `StableText` | Typography + skeleton in one tag |
|
|
75
|
+
| `MediaSkeleton` | Aspect-ratio placeholder that constrains its child |
|
|
76
|
+
| `CollectionSkeleton` | Loading-aware list with forced stub count |
|
|
77
|
+
| `LayoutMap` | Type-safe dictionary of views with stable dimensions |
|
|
78
|
+
| `LayoutGroup` + `LayoutView` | Multi-state spatial container (use LayoutMap when possible) |
|
|
79
|
+
| `StateSwap` | Boolean content swap — both options rendered, zero shift |
|
|
80
|
+
| `StableCounter` | Numeric/text width pre-allocation via ghost reserve |
|
|
81
|
+
| `StableField` | Form error height pre-allocation via ghost reserve |
|
|
82
|
+
| `SizeRatchet` | Container that never shrinks (ResizeObserver ratchet) |
|
|
83
|
+
| `FadeTransition` | Enter/exit animation wrapper, geometry untouched |
|
|
84
|
+
| `createPrimitive` | Factory for UI primitives with architectural enforcement |
|
|
85
|
+
|
|
86
|
+
## Keeping Visual Decisions in CSS
|
|
87
|
+
|
|
88
|
+
There's a problem adjacent to layout stability: **visual decisions leaking into JavaScript.**
|
|
89
|
+
|
|
90
|
+
A component's appearance can change for two reasons. **Identity** — a brand button is always indigo because that's the brand. **Data** — a badge is green when active and red when churned. Identity is fixed and belongs in a className. Data-dependent appearance changes at runtime based on values the component receives.
|
|
91
|
+
|
|
92
|
+
When a component picks its own visuals based on data — `className={status === "paid" ? "text-green-500" : "text-red-500"}` — the visual decision lives in JavaScript. Changing a color means editing a `.tsx` file. A designer can't update the palette without a developer. A developer can't refactor the component without understanding the color system.
|
|
93
|
+
|
|
94
|
+
The fix is a hard boundary: **components declare what state they're in, and CSS decides what that looks like.** A `<Badge>` says `data-variant="active"`. CSS says `.sk-badge[data-variant="active"] { color: green }`. The component never knows its own color, font weight, border, opacity, or any other visual property that depends on data. It only knows its state.
|
|
95
|
+
|
|
96
|
+
### `createPrimitive`
|
|
97
|
+
|
|
98
|
+
`createPrimitive` makes this boundary automatic. It builds UI primitives where `className` and `style` are blocked at the type level, and variant props are mapped to `data-*` attributes:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
import { createPrimitive } from "stablekit";
|
|
102
|
+
|
|
103
|
+
const Badge = createPrimitive("span", "sk-badge", {
|
|
104
|
+
variant: ["active", "trial", "churned"],
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Consumers get type-checked variants and a locked-down surface:
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
<Badge variant="active">Paid</Badge> // renders data-variant="active"
|
|
112
|
+
<Badge variant="bogus">Paid</Badge> // TypeScript error
|
|
113
|
+
<Badge className="text-red-500">Paid</Badge> // TypeScript error
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
CSS owns the visuals:
|
|
117
|
+
|
|
118
|
+
```css
|
|
119
|
+
.sk-badge[data-variant="active"] { color: var(--color-success); }
|
|
120
|
+
.sk-badge[data-variant="trial"] { color: var(--color-warning); }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Changing a color means editing CSS. Never a component file.
|
|
124
|
+
|
|
125
|
+
### Architecture Linters
|
|
126
|
+
|
|
127
|
+
StableKit ships two linter factories that enforce the Structure → Presentation boundary on both sides:
|
|
128
|
+
|
|
129
|
+
**ESLint** (`stablekit/eslint`) — catches visual decisions leaking into JS:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
// eslint.config.js
|
|
133
|
+
import { createArchitectureLint } from "stablekit/eslint";
|
|
134
|
+
|
|
135
|
+
export default [
|
|
136
|
+
createArchitectureLint({
|
|
137
|
+
stateTokens: ["success", "warning", "destructive"],
|
|
138
|
+
variantProps: ["variant", "intent"],
|
|
139
|
+
}),
|
|
140
|
+
];
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`stateTokens` declares your project's functional color vocabulary — the token names that represent data-dependent state. The linter flags `bg-success`, `text-warning`, etc. in className strings (these should use `data-*` attributes and CSS).
|
|
144
|
+
|
|
145
|
+
`variantProps` declares the prop names from your `createPrimitive` calls. The linter flags ternaries on these props — `intent={x ? "primary" : "outline"}` is a visual decision in JS. If a variant changes based on data, the component should use a data-attribute and CSS should handle the visual difference.
|
|
146
|
+
|
|
147
|
+
It also catches universally: bare hex color literals (`"#f0c040"`), color functions (`rgba()`, `hsl()`, `oklch()`), color properties in style props (`style={{ color: x }}`), className ternaries (`className={x ? "a" : "b"}`), and conditional style ternaries.
|
|
148
|
+
|
|
149
|
+
**Stylelint** (`stablekit/stylelint`) — catches CSS targeting child elements by tag name:
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
// stylelint.config.js
|
|
153
|
+
import { createStyleLint } from "stablekit/stylelint";
|
|
154
|
+
|
|
155
|
+
export default createStyleLint({
|
|
156
|
+
functionalTokens: ["--color-status-", "--color-danger"],
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
This bans element selectors like `& svg { color: green }` — set color on the container and let `currentColor` inherit. Bans `!important`. And with `functionalTokens`, bans functional color tokens inside `@utility` blocks — `@utility text-status-success { color: var(--color-status-active) }` is an error because it launders a functional color into a reusable className, crossing back from Presentation into Structure.
|
|
161
|
+
|
|
162
|
+
## How It Works
|
|
163
|
+
|
|
164
|
+
**Spatial stability** uses CSS grid overlap (`grid-area: 1/1`). All views render in the DOM simultaneously. The container auto-sizes to the largest child. Inactive views are hidden with `[inert]` + `data-state="inactive"` (CSS-driven opacity/visibility). Consumers can add CSS transitions to `.sk-layout-view[data-state]` for custom animations.
|
|
165
|
+
|
|
166
|
+
**Loading skeletons** use `1lh` CSS units to match line-height exactly. Shimmer width comes from inert ghost content — the skeleton is exactly as wide as the text it replaces.
|
|
167
|
+
|
|
168
|
+
**`MediaSkeleton`** constrains its child via `cloneElement` inline styles (`position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover`). No CSS `!important`.
|
|
169
|
+
|
|
170
|
+
**`SizeRatchet`** uses a one-way ResizeObserver. It tracks the maximum border-box size ever observed and applies `min-width`/`min-height` that only grows.
|
|
171
|
+
|
|
172
|
+
## CSS Custom Properties
|
|
173
|
+
|
|
174
|
+
```css
|
|
175
|
+
--sk-shimmer-color: #e5e7eb;
|
|
176
|
+
--sk-shimmer-highlight: #f3f4f6;
|
|
177
|
+
--sk-shimmer-radius: 0.125rem;
|
|
178
|
+
--sk-shimmer-duration: 1.5s;
|
|
179
|
+
--sk-skeleton-gap: 0.75rem;
|
|
180
|
+
--sk-skeleton-bone-gap: 0.125rem;
|
|
181
|
+
--sk-skeleton-bone-padding: 0.375rem 0.5rem;
|
|
182
|
+
--sk-fade-duration: 200ms;
|
|
183
|
+
--sk-fade-offset-y: -12px;
|
|
184
|
+
--sk-fade-offset-scale: 0.98;
|
|
185
|
+
--sk-loading-exit-duration: 150ms;
|
|
186
|
+
--sk-ease-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1.0);
|
|
187
|
+
--sk-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
|
188
|
+
--sk-ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Style Injection
|
|
192
|
+
|
|
193
|
+
Styles are auto-injected via a `<style data-stablekit>` tag on first import. To opt out (e.g. if you import `stablekit/styles.css` manually):
|
|
194
|
+
|
|
195
|
+
```html
|
|
196
|
+
<meta name="stablekit-disable-injection" />
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
For CSP nonce support:
|
|
200
|
+
|
|
201
|
+
```html
|
|
202
|
+
<meta name="stablekit-nonce" content="your-nonce-here" />
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT
|
package/dist/eslint.cjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/eslint.ts
|
|
21
|
+
var eslint_exports = {};
|
|
22
|
+
__export(eslint_exports, {
|
|
23
|
+
createArchitectureLint: () => createArchitectureLint
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(eslint_exports);
|
|
26
|
+
function createArchitectureLint(options) {
|
|
27
|
+
const {
|
|
28
|
+
stateTokens,
|
|
29
|
+
variantProps = [],
|
|
30
|
+
banColorUtilities = true,
|
|
31
|
+
files = ["src/components/**/*.{tsx,jsx}"]
|
|
32
|
+
} = options;
|
|
33
|
+
const tokenPattern = stateTokens.join("|");
|
|
34
|
+
const twColors = "red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone|white|black";
|
|
35
|
+
const twPrefixes = "text|bg|border|ring|shadow|outline|accent|fill|stroke|from|to|via|divide|decoration";
|
|
36
|
+
return {
|
|
37
|
+
files,
|
|
38
|
+
rules: {
|
|
39
|
+
"no-restricted-syntax": [
|
|
40
|
+
"error",
|
|
41
|
+
// --- 1. Hardcoded design tokens (universal) ---
|
|
42
|
+
{
|
|
43
|
+
selector: "Literal[value=/text-\\[\\d+/]",
|
|
44
|
+
message: "Hardcoded font size. Define a named token in @theme."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
selector: "Literal[value=/\\[.*(?:#[0-9a-fA-F]|rgba?).*\\]/]",
|
|
48
|
+
message: "Hardcoded color value. Define a CSS custom property."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
selector: "JSXAttribute[name.name='style'] Property > Literal[value=/(?:#[0-9a-fA-F]{3,8}|rgba?\\()/]",
|
|
52
|
+
message: "Hardcoded color value in style object. Define a CSS custom property."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
selector: "Literal[value=/^#[0-9a-fA-F]{3}([0-9a-fA-F]([0-9a-fA-F]{2}([0-9a-fA-F]{2})?)?)?$/]",
|
|
56
|
+
message: "Hardcoded hex color. Define a CSS custom property."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
selector: "Literal[value=/(?:^|[^a-zA-Z])(?:rgba?|hsla?|oklch|lab|lch)\\(/]",
|
|
60
|
+
message: "Hardcoded color function. Define a CSS custom property."
|
|
61
|
+
},
|
|
62
|
+
// --- 1b. Color properties in style props ---
|
|
63
|
+
{
|
|
64
|
+
selector: "JSXAttribute[name.name='style'] Property[key.name=/^(color|backgroundColor|background|borderColor|outlineColor|fill|stroke|accentColor|caretColor)$/]",
|
|
65
|
+
message: "Visual color property in style prop. Use a data-attribute and CSS selector."
|
|
66
|
+
},
|
|
67
|
+
// --- 1c. Visual state properties in style props ---
|
|
68
|
+
{
|
|
69
|
+
selector: "JSXAttribute[name.name='style'] Property[key.name=/^(opacity|visibility|transition|pointerEvents)$/]",
|
|
70
|
+
message: "Visual state property in style prop. Use a data-attribute and CSS selector."
|
|
71
|
+
},
|
|
72
|
+
// --- 1d. Hardcoded magic numbers ---
|
|
73
|
+
{
|
|
74
|
+
selector: "Literal[value=/z-\\[\\d/]",
|
|
75
|
+
message: "Hardcoded z-index. Define a named z-index token in @theme."
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
selector: "Literal[value=/-m\\w?-\\[|m\\w?-\\[-/]",
|
|
79
|
+
message: "Negative margin with magic number. This usually fights the layout \u2014 fix the spacing structure instead."
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
selector: "Literal[value=/(?:min-|max-)?(?:w|h)-\\[\\d+px\\]/]",
|
|
83
|
+
message: "Hardcoded pixel dimension. Define a named size token in @theme or use a relative unit."
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
selector: "Literal[value=/\\w-\\[(?!calc).*?\\d+(?![\\d%]|[dsl]?v[hw]|fr)/]",
|
|
87
|
+
message: "Hardcoded magic number in arbitrary value. Define a named token in @theme or use a standard utility."
|
|
88
|
+
},
|
|
89
|
+
// --- 2. Data-dependent visual decisions (project-specific) ---
|
|
90
|
+
...tokenPattern ? [
|
|
91
|
+
{
|
|
92
|
+
selector: `Literal[value=/\\b(bg|text|border)-(${tokenPattern})/]`,
|
|
93
|
+
message: "Data-dependent visual property. Use a data-attribute and CSS selector."
|
|
94
|
+
}
|
|
95
|
+
] : [],
|
|
96
|
+
{
|
|
97
|
+
selector: "JSXAttribute[name.name='style'] ConditionalExpression",
|
|
98
|
+
message: "Conditional style object. Use a data-state attribute and CSS selector."
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
selector: "JSXAttribute[name.name='className'] ConditionalExpression",
|
|
102
|
+
message: "Conditional className. Use a data-attribute and CSS selector instead of switching classes with a ternary."
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
selector: "JSXAttribute[name.name='className'] LogicalExpression[operator='&&']",
|
|
106
|
+
message: "Conditional className. Use a data-attribute and CSS selector instead of conditionally applying classes."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
selector: "JSXAttribute[name.name='className'] ObjectExpression",
|
|
110
|
+
message: "Conditional className via object syntax. Use a data-attribute and CSS selector instead of cx/cn({ class: condition })."
|
|
111
|
+
},
|
|
112
|
+
// --- 2c. !important in className ---
|
|
113
|
+
{
|
|
114
|
+
selector: "JSXAttribute[name.name='className'] Literal[value=/(?:^|\\s)![a-z]/]",
|
|
115
|
+
message: "Tailwind !important modifier in className. !important breaks the cascade \u2014 use specificity or data-attributes."
|
|
116
|
+
},
|
|
117
|
+
// --- 2d. Tailwind color utilities in className ---
|
|
118
|
+
...banColorUtilities ? [
|
|
119
|
+
{
|
|
120
|
+
selector: `Literal[value=/(?:^|\\s)(?:${twPrefixes})-(?:${twColors})(?:-\\d+)?(?:\\/\\d+)?(?:\\s|$)/]`,
|
|
121
|
+
message: "Tailwind color utility in className. Colors belong in CSS \u2014 use a CSS class with a custom property or data-attribute selector."
|
|
122
|
+
}
|
|
123
|
+
] : [],
|
|
124
|
+
// --- 3. Ternaries on variant props (from createPrimitive) ---
|
|
125
|
+
...variantProps.map((prop) => ({
|
|
126
|
+
selector: `JSXAttribute[name.name='${prop}'] ConditionalExpression`,
|
|
127
|
+
message: `Data-dependent ${prop}. Use a data-attribute and CSS selector instead of switching ${prop} with a ternary.`
|
|
128
|
+
})),
|
|
129
|
+
// --- 4. Geometric instability (conditional content) ---
|
|
130
|
+
{
|
|
131
|
+
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > ConditionalExpression",
|
|
132
|
+
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, <LayoutMap> for keyed views, or <LoadingBoundary> for async states."
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='&&']",
|
|
136
|
+
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."
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='||']",
|
|
140
|
+
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."
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='??']",
|
|
144
|
+
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."
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
|
|
148
|
+
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."
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
155
|
+
0 && (module.exports = {
|
|
156
|
+
createArchitectureLint
|
|
157
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Architecture Linter Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates an ESLint flat config that enforces the Structure → Presentation
|
|
5
|
+
* boundary. Rules organized by category:
|
|
6
|
+
*
|
|
7
|
+
* 1. Hardcoded visual values — bare hex colors (#f0c040), color functions
|
|
8
|
+
* (rgba, hsl, oklch), font sizes in Tailwind arbitrary values,
|
|
9
|
+
* color properties in style props, visual state properties (opacity,
|
|
10
|
+
* visibility, transition, pointerEvents) in style props.
|
|
11
|
+
*
|
|
12
|
+
* 1d. Hardcoded magic numbers — arbitrary z-index (z-[999]), negative
|
|
13
|
+
* margins with arbitrary values (m-[-4px]), and arbitrary pixel
|
|
14
|
+
* dimensions (w-[347px], h-[200px]).
|
|
15
|
+
*
|
|
16
|
+
* 2. Data-dependent visual decisions — state color tokens in className,
|
|
17
|
+
* conditional style ternaries, className ternaries, className logical
|
|
18
|
+
* AND, cx/cn object syntax, and !important in className.
|
|
19
|
+
*
|
|
20
|
+
* 3. Ternaries on variant props — if a variant prop created by
|
|
21
|
+
* createPrimitive has a ternary, the visual decision is in JS.
|
|
22
|
+
*
|
|
23
|
+
* 4. Geometric instability — conditional content in JSX children that
|
|
24
|
+
* causes layout shift: ternary swaps, && mounts, || / ?? fallbacks,
|
|
25
|
+
* and interpolated template literals. Each message guides toward
|
|
26
|
+
* extracting the expression to a variable (for data transforms) or
|
|
27
|
+
* using a StableKit component (for state-driven swaps). Always on.
|
|
28
|
+
*/
|
|
29
|
+
interface ArchitectureLintOptions {
|
|
30
|
+
/** State token names that should never appear in JS as Tailwind classes.
|
|
31
|
+
* e.g. ["success", "warning", "destructive", "canceled"] */
|
|
32
|
+
stateTokens: string[];
|
|
33
|
+
/** Variant prop names from createPrimitive that should never have ternaries.
|
|
34
|
+
* e.g. ["intent", "variant"] — bans intent={x ? "a" : "b"} */
|
|
35
|
+
variantProps?: string[];
|
|
36
|
+
/** Ban all Tailwind color palette utilities in className.
|
|
37
|
+
* Catches bg-red-500, text-green-600, border-cyan-400, etc.
|
|
38
|
+
* Colors must live in CSS — not in component classNames.
|
|
39
|
+
* @default true */
|
|
40
|
+
banColorUtilities?: boolean;
|
|
41
|
+
/** Glob patterns for files to lint.
|
|
42
|
+
* @default ["src/components/**\/*.{tsx,jsx}"] */
|
|
43
|
+
files?: string[];
|
|
44
|
+
}
|
|
45
|
+
declare function createArchitectureLint(options: ArchitectureLintOptions): {
|
|
46
|
+
files: string[];
|
|
47
|
+
rules: {
|
|
48
|
+
"no-restricted-syntax": (string | {
|
|
49
|
+
selector: string;
|
|
50
|
+
message: string;
|
|
51
|
+
})[];
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export { type ArchitectureLintOptions, createArchitectureLint };
|
package/dist/eslint.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Architecture Linter Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates an ESLint flat config that enforces the Structure → Presentation
|
|
5
|
+
* boundary. Rules organized by category:
|
|
6
|
+
*
|
|
7
|
+
* 1. Hardcoded visual values — bare hex colors (#f0c040), color functions
|
|
8
|
+
* (rgba, hsl, oklch), font sizes in Tailwind arbitrary values,
|
|
9
|
+
* color properties in style props, visual state properties (opacity,
|
|
10
|
+
* visibility, transition, pointerEvents) in style props.
|
|
11
|
+
*
|
|
12
|
+
* 1d. Hardcoded magic numbers — arbitrary z-index (z-[999]), negative
|
|
13
|
+
* margins with arbitrary values (m-[-4px]), and arbitrary pixel
|
|
14
|
+
* dimensions (w-[347px], h-[200px]).
|
|
15
|
+
*
|
|
16
|
+
* 2. Data-dependent visual decisions — state color tokens in className,
|
|
17
|
+
* conditional style ternaries, className ternaries, className logical
|
|
18
|
+
* AND, cx/cn object syntax, and !important in className.
|
|
19
|
+
*
|
|
20
|
+
* 3. Ternaries on variant props — if a variant prop created by
|
|
21
|
+
* createPrimitive has a ternary, the visual decision is in JS.
|
|
22
|
+
*
|
|
23
|
+
* 4. Geometric instability — conditional content in JSX children that
|
|
24
|
+
* causes layout shift: ternary swaps, && mounts, || / ?? fallbacks,
|
|
25
|
+
* and interpolated template literals. Each message guides toward
|
|
26
|
+
* extracting the expression to a variable (for data transforms) or
|
|
27
|
+
* using a StableKit component (for state-driven swaps). Always on.
|
|
28
|
+
*/
|
|
29
|
+
interface ArchitectureLintOptions {
|
|
30
|
+
/** State token names that should never appear in JS as Tailwind classes.
|
|
31
|
+
* e.g. ["success", "warning", "destructive", "canceled"] */
|
|
32
|
+
stateTokens: string[];
|
|
33
|
+
/** Variant prop names from createPrimitive that should never have ternaries.
|
|
34
|
+
* e.g. ["intent", "variant"] — bans intent={x ? "a" : "b"} */
|
|
35
|
+
variantProps?: string[];
|
|
36
|
+
/** Ban all Tailwind color palette utilities in className.
|
|
37
|
+
* Catches bg-red-500, text-green-600, border-cyan-400, etc.
|
|
38
|
+
* Colors must live in CSS — not in component classNames.
|
|
39
|
+
* @default true */
|
|
40
|
+
banColorUtilities?: boolean;
|
|
41
|
+
/** Glob patterns for files to lint.
|
|
42
|
+
* @default ["src/components/**\/*.{tsx,jsx}"] */
|
|
43
|
+
files?: string[];
|
|
44
|
+
}
|
|
45
|
+
declare function createArchitectureLint(options: ArchitectureLintOptions): {
|
|
46
|
+
files: string[];
|
|
47
|
+
rules: {
|
|
48
|
+
"no-restricted-syntax": (string | {
|
|
49
|
+
selector: string;
|
|
50
|
+
message: string;
|
|
51
|
+
})[];
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export { type ArchitectureLintOptions, createArchitectureLint };
|
package/dist/eslint.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// src/eslint.ts
|
|
2
|
+
function createArchitectureLint(options) {
|
|
3
|
+
const {
|
|
4
|
+
stateTokens,
|
|
5
|
+
variantProps = [],
|
|
6
|
+
banColorUtilities = true,
|
|
7
|
+
files = ["src/components/**/*.{tsx,jsx}"]
|
|
8
|
+
} = options;
|
|
9
|
+
const tokenPattern = stateTokens.join("|");
|
|
10
|
+
const twColors = "red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone|white|black";
|
|
11
|
+
const twPrefixes = "text|bg|border|ring|shadow|outline|accent|fill|stroke|from|to|via|divide|decoration";
|
|
12
|
+
return {
|
|
13
|
+
files,
|
|
14
|
+
rules: {
|
|
15
|
+
"no-restricted-syntax": [
|
|
16
|
+
"error",
|
|
17
|
+
// --- 1. Hardcoded design tokens (universal) ---
|
|
18
|
+
{
|
|
19
|
+
selector: "Literal[value=/text-\\[\\d+/]",
|
|
20
|
+
message: "Hardcoded font size. Define a named token in @theme."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
selector: "Literal[value=/\\[.*(?:#[0-9a-fA-F]|rgba?).*\\]/]",
|
|
24
|
+
message: "Hardcoded color value. Define a CSS custom property."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
selector: "JSXAttribute[name.name='style'] Property > Literal[value=/(?:#[0-9a-fA-F]{3,8}|rgba?\\()/]",
|
|
28
|
+
message: "Hardcoded color value in style object. Define a CSS custom property."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
selector: "Literal[value=/^#[0-9a-fA-F]{3}([0-9a-fA-F]([0-9a-fA-F]{2}([0-9a-fA-F]{2})?)?)?$/]",
|
|
32
|
+
message: "Hardcoded hex color. Define a CSS custom property."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
selector: "Literal[value=/(?:^|[^a-zA-Z])(?:rgba?|hsla?|oklch|lab|lch)\\(/]",
|
|
36
|
+
message: "Hardcoded color function. Define a CSS custom property."
|
|
37
|
+
},
|
|
38
|
+
// --- 1b. Color properties in style props ---
|
|
39
|
+
{
|
|
40
|
+
selector: "JSXAttribute[name.name='style'] Property[key.name=/^(color|backgroundColor|background|borderColor|outlineColor|fill|stroke|accentColor|caretColor)$/]",
|
|
41
|
+
message: "Visual color property in style prop. Use a data-attribute and CSS selector."
|
|
42
|
+
},
|
|
43
|
+
// --- 1c. Visual state properties in style props ---
|
|
44
|
+
{
|
|
45
|
+
selector: "JSXAttribute[name.name='style'] Property[key.name=/^(opacity|visibility|transition|pointerEvents)$/]",
|
|
46
|
+
message: "Visual state property in style prop. Use a data-attribute and CSS selector."
|
|
47
|
+
},
|
|
48
|
+
// --- 1d. Hardcoded magic numbers ---
|
|
49
|
+
{
|
|
50
|
+
selector: "Literal[value=/z-\\[\\d/]",
|
|
51
|
+
message: "Hardcoded z-index. Define a named z-index token in @theme."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
selector: "Literal[value=/-m\\w?-\\[|m\\w?-\\[-/]",
|
|
55
|
+
message: "Negative margin with magic number. This usually fights the layout \u2014 fix the spacing structure instead."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
selector: "Literal[value=/(?:min-|max-)?(?:w|h)-\\[\\d+px\\]/]",
|
|
59
|
+
message: "Hardcoded pixel dimension. Define a named size token in @theme or use a relative unit."
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
selector: "Literal[value=/\\w-\\[(?!calc).*?\\d+(?![\\d%]|[dsl]?v[hw]|fr)/]",
|
|
63
|
+
message: "Hardcoded magic number in arbitrary value. Define a named token in @theme or use a standard utility."
|
|
64
|
+
},
|
|
65
|
+
// --- 2. Data-dependent visual decisions (project-specific) ---
|
|
66
|
+
...tokenPattern ? [
|
|
67
|
+
{
|
|
68
|
+
selector: `Literal[value=/\\b(bg|text|border)-(${tokenPattern})/]`,
|
|
69
|
+
message: "Data-dependent visual property. Use a data-attribute and CSS selector."
|
|
70
|
+
}
|
|
71
|
+
] : [],
|
|
72
|
+
{
|
|
73
|
+
selector: "JSXAttribute[name.name='style'] ConditionalExpression",
|
|
74
|
+
message: "Conditional style object. Use a data-state attribute and CSS selector."
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
selector: "JSXAttribute[name.name='className'] ConditionalExpression",
|
|
78
|
+
message: "Conditional className. Use a data-attribute and CSS selector instead of switching classes with a ternary."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
selector: "JSXAttribute[name.name='className'] LogicalExpression[operator='&&']",
|
|
82
|
+
message: "Conditional className. Use a data-attribute and CSS selector instead of conditionally applying classes."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
selector: "JSXAttribute[name.name='className'] ObjectExpression",
|
|
86
|
+
message: "Conditional className via object syntax. Use a data-attribute and CSS selector instead of cx/cn({ class: condition })."
|
|
87
|
+
},
|
|
88
|
+
// --- 2c. !important in className ---
|
|
89
|
+
{
|
|
90
|
+
selector: "JSXAttribute[name.name='className'] Literal[value=/(?:^|\\s)![a-z]/]",
|
|
91
|
+
message: "Tailwind !important modifier in className. !important breaks the cascade \u2014 use specificity or data-attributes."
|
|
92
|
+
},
|
|
93
|
+
// --- 2d. Tailwind color utilities in className ---
|
|
94
|
+
...banColorUtilities ? [
|
|
95
|
+
{
|
|
96
|
+
selector: `Literal[value=/(?:^|\\s)(?:${twPrefixes})-(?:${twColors})(?:-\\d+)?(?:\\/\\d+)?(?:\\s|$)/]`,
|
|
97
|
+
message: "Tailwind color utility in className. Colors belong in CSS \u2014 use a CSS class with a custom property or data-attribute selector."
|
|
98
|
+
}
|
|
99
|
+
] : [],
|
|
100
|
+
// --- 3. Ternaries on variant props (from createPrimitive) ---
|
|
101
|
+
...variantProps.map((prop) => ({
|
|
102
|
+
selector: `JSXAttribute[name.name='${prop}'] ConditionalExpression`,
|
|
103
|
+
message: `Data-dependent ${prop}. Use a data-attribute and CSS selector instead of switching ${prop} with a ternary.`
|
|
104
|
+
})),
|
|
105
|
+
// --- 4. Geometric instability (conditional content) ---
|
|
106
|
+
{
|
|
107
|
+
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > ConditionalExpression",
|
|
108
|
+
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, <LayoutMap> for keyed views, or <LoadingBoundary> for async states."
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='&&']",
|
|
112
|
+
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."
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='||']",
|
|
116
|
+
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."
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > LogicalExpression[operator='??']",
|
|
120
|
+
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."
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
|
|
124
|
+
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."
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export {
|
|
131
|
+
createArchitectureLint
|
|
132
|
+
};
|