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/llms.txt
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# stablekit — Instructions for AI Code Generators
|
|
2
|
+
|
|
3
|
+
You are working with `stablekit`, a React library that enforces a strict
|
|
4
|
+
rendering paradigm: **Deterministic Geometric Pre-allocation.**
|
|
5
|
+
|
|
6
|
+
Read these instructions before generating any code that imports from `stablekit`.
|
|
7
|
+
They override your default React patterns.
|
|
8
|
+
|
|
9
|
+
## The Paradigm
|
|
10
|
+
|
|
11
|
+
Standard React couples paint and geometry — when state changes, the DOM
|
|
12
|
+
reflows. StableKit decouples them. The rule:
|
|
13
|
+
|
|
14
|
+
> A container's dimensions must be a function of its maximum possible
|
|
15
|
+
> future state, not its current instantaneous state.
|
|
16
|
+
|
|
17
|
+
This means:
|
|
18
|
+
- **Temporal pre-allocation:** If a component depends on async data, its
|
|
19
|
+
bounding box must be declared synchronously before the data arrives.
|
|
20
|
+
(`MediaSkeleton` forces `aspectRatio`, `CollectionSkeleton` forces
|
|
21
|
+
`stubCount`, `StableText` reserves space at line-height.)
|
|
22
|
+
- **Spatial pre-allocation:** If a UI region has multiple states, all
|
|
23
|
+
states must render simultaneously in a CSS grid overlap. The container
|
|
24
|
+
sizes to the largest. (`LayoutMap`, `LayoutGroup`, `StateSwap`)
|
|
25
|
+
- **Monotonic geometry:** Once a container expands, it cannot shrink
|
|
26
|
+
unless explicitly reset. (`SizeRatchet` with optional `resetKey`)
|
|
27
|
+
- **Structural Integrity over Temporal Hacks:** Never unmount a state
|
|
28
|
+
to show another. Stack all possible states in the DOM and use CSS
|
|
29
|
+
`opacity`/`visibility` to transition between them. Conditional
|
|
30
|
+
mounting (`{loading ? <A/> : <B/>}`) is a fatal architectural error
|
|
31
|
+
in StableKit. All layers coexist permanently; only opacity and
|
|
32
|
+
interactivity (`inert`, `pointer-events`) change. LayoutView exposes
|
|
33
|
+
a `data-state="active"|"inactive"` attribute — consumers can target
|
|
34
|
+
`.sk-layout-view[data-state]` selectors to add CSS transitions for
|
|
35
|
+
custom enter/exit animations without specificity fights.
|
|
36
|
+
|
|
37
|
+
When you generate code with StableKit, you are not adding progressive
|
|
38
|
+
enhancement to existing React patterns. You are replacing the rendering
|
|
39
|
+
model. Do not mix StableKit components with ternary-based conditional
|
|
40
|
+
rendering for the same UI region.
|
|
41
|
+
|
|
42
|
+
## Component API
|
|
43
|
+
|
|
44
|
+
| Component | Paradigm | Purpose |
|
|
45
|
+
|----------------------|------------------------|----------------------------------------------|
|
|
46
|
+
| `StateSwap` | Spatial | Boolean content swap, zero shift |
|
|
47
|
+
| `LayoutGroup` | Spatial | Multi-state spatial stability container |
|
|
48
|
+
| `LayoutView` | Spatial | Single view inside a LayoutGroup |
|
|
49
|
+
| `LayoutMap` | Spatial | Dictionary-based state mapping (typo-proof) |
|
|
50
|
+
| `StableCounter` | Spatial | Numeric/text width pre-allocation |
|
|
51
|
+
| `StableField` | Spatial | Form error height pre-allocation |
|
|
52
|
+
| `SizeRatchet` | Monotonic | Container that never shrinks |
|
|
53
|
+
| `LoadingBoundary` | All three | Loading orchestrator (shimmer + ratchet) |
|
|
54
|
+
| `LoadingContext` | Temporal | Ambient loading provider |
|
|
55
|
+
| `StableText` | Temporal | Typography + skeleton in one tag |
|
|
56
|
+
| `TextSkeleton` | Temporal | Inline loading shimmer for text |
|
|
57
|
+
| `MediaSkeleton` | Temporal | Aspect-ratio media placeholder |
|
|
58
|
+
| `CollectionSkeleton` | Temporal + Monotonic | Loading-aware list |
|
|
59
|
+
| `FadeTransition` | Animation | Enter/exit animation wrapper |
|
|
60
|
+
| `createPrimitive` | Enforcement | Factory for firewalled UI primitives |
|
|
61
|
+
|
|
62
|
+
## Anti-patterns you must not generate
|
|
63
|
+
|
|
64
|
+
### 1. Do not use ternaries to swap content when layout stability matters
|
|
65
|
+
|
|
66
|
+
WRONG — geometry changes when state changes:
|
|
67
|
+
```tsx
|
|
68
|
+
{isLoading ? <Spinner /> : <Profile user={user} />}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
CORRECT — one tree, geometry pre-allocated:
|
|
72
|
+
```tsx
|
|
73
|
+
<LoadingBoundary loading={isLoading} exitDuration={150}>
|
|
74
|
+
<StableText as="h2">{user.name}</StableText>
|
|
75
|
+
</LoadingBoundary>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 2. Do not use fixed CSS widths to prevent layout shift
|
|
79
|
+
|
|
80
|
+
WRONG — using Tailwind fixed width to stabilize a button:
|
|
81
|
+
```tsx
|
|
82
|
+
<button className="w-28">{expanded ? "Close" : "View Details"}</button>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
CORRECT — use StateSwap inside the button:
|
|
86
|
+
```tsx
|
|
87
|
+
<button onClick={toggle}>
|
|
88
|
+
<StateSwap state={expanded} true="Close" false="View Details" />
|
|
89
|
+
</button>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
StateSwap renders as an inline `<span>` by default. It is safe inside
|
|
93
|
+
buttons, table cells, and any inline context.
|
|
94
|
+
|
|
95
|
+
### 3. Do not swap text with a ternary when layout stability matters
|
|
96
|
+
|
|
97
|
+
WRONG — conditional text causes the container to resize:
|
|
98
|
+
```tsx
|
|
99
|
+
<span>{isOpen ? "Collapse" : "Expand"}</span>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
CORRECT — StateSwap reserves the width of the wider option:
|
|
103
|
+
```tsx
|
|
104
|
+
<StateSwap state={isOpen} true="Collapse" false="Expand" />
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 4. Do not duplicate entire components to swap between states
|
|
108
|
+
|
|
109
|
+
WRONG — two separate buttons wrapped in LayoutView:
|
|
110
|
+
```tsx
|
|
111
|
+
<LayoutGroup value={expanded ? "close" : "view"}>
|
|
112
|
+
<LayoutView name="view"><button>View Details</button></LayoutView>
|
|
113
|
+
<LayoutView name="close"><button>Close</button></LayoutView>
|
|
114
|
+
</LayoutGroup>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
CORRECT — one button, swap content inside it:
|
|
118
|
+
```tsx
|
|
119
|
+
<button onClick={toggle}>
|
|
120
|
+
<StateSwap state={expanded} true="Close" false="View Details" />
|
|
121
|
+
<StateSwap state={expanded} true={<ChevronUp />} false={<ChevronDown />} />
|
|
122
|
+
</button>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Use LayoutGroup/LayoutView only when swapping structurally different
|
|
126
|
+
components (e.g. tab panels, multi-step forms).
|
|
127
|
+
|
|
128
|
+
### 5. Do not use LayoutGroup/LayoutView for tab content — use LayoutMap
|
|
129
|
+
|
|
130
|
+
WRONG — string typos silently break rendering:
|
|
131
|
+
```tsx
|
|
132
|
+
<LayoutGroup value={activeTab}>
|
|
133
|
+
<LayoutView name="profile"><Profile /></LayoutView>
|
|
134
|
+
<LayoutView name="inovices"><Invoices /></LayoutView>
|
|
135
|
+
</LayoutGroup>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
CORRECT — dictionary keys are checked by TypeScript:
|
|
139
|
+
```tsx
|
|
140
|
+
<LayoutMap value={activeTab} map={{
|
|
141
|
+
profile: <Profile />,
|
|
142
|
+
invoices: <Invoices />,
|
|
143
|
+
}} />
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 6. Do not wrap text in TextSkeleton inside HTML tags — use StableText
|
|
147
|
+
|
|
148
|
+
WRONG — easy to forget the skeleton wrapper:
|
|
149
|
+
```tsx
|
|
150
|
+
<p className="text-xl font-semibold">
|
|
151
|
+
<TextSkeleton>{user.name}</TextSkeleton>
|
|
152
|
+
</p>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
CORRECT — the tag IS the skeleton:
|
|
156
|
+
```tsx
|
|
157
|
+
<StableText as="p" className="text-xl font-semibold">{user.name}</StableText>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### 7. Do not add dimension classes to children inside MediaSkeleton
|
|
161
|
+
|
|
162
|
+
WRONG — relying on developer to constrain the image:
|
|
163
|
+
```tsx
|
|
164
|
+
<MediaSkeleton aspectRatio={1}>
|
|
165
|
+
<img src={url} className="w-full h-full object-cover" />
|
|
166
|
+
</MediaSkeleton>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
CORRECT — MediaSkeleton enforces child constraints automatically:
|
|
170
|
+
```tsx
|
|
171
|
+
<MediaSkeleton aspectRatio={1}>
|
|
172
|
+
<img src={url} alt={name} />
|
|
173
|
+
</MediaSkeleton>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
MediaSkeleton uses React.cloneElement to apply position, size, and
|
|
177
|
+
object-fit as inline styles. The child cannot break out of the frame.
|
|
178
|
+
|
|
179
|
+
### 8. Do not use fixed widths to stabilize changing numbers — use StableCounter
|
|
180
|
+
|
|
181
|
+
WRONG — hardcoded width that breaks with different values:
|
|
182
|
+
```tsx
|
|
183
|
+
<span className="w-16 text-right">{cartCount}</span>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
CORRECT — ghost reserve pre-allocates the width:
|
|
187
|
+
```tsx
|
|
188
|
+
<StableCounter value={cartCount} reserve="999" />
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
StableCounter uses CSS Grid overlap: a hidden `reserve` node props open
|
|
192
|
+
the bounding box, and the visible `value` renders on top. Both require a
|
|
193
|
+
`reserve` prop — there is no auto-detection. The developer must declare
|
|
194
|
+
the maximum expected content.
|
|
195
|
+
|
|
196
|
+
### 9. Do not let form validation errors cause vertical layout shift — use StableField
|
|
197
|
+
|
|
198
|
+
WRONG — error message pops in and pushes fields down:
|
|
199
|
+
```tsx
|
|
200
|
+
<div>
|
|
201
|
+
<input type="email" />
|
|
202
|
+
{errors.email && <p className="text-red-500">{errors.email}</p>}
|
|
203
|
+
</div>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
CORRECT — error slot height is pre-allocated:
|
|
207
|
+
```tsx
|
|
208
|
+
<StableField error={errors.email} reserve="Please enter a valid email address">
|
|
209
|
+
<input type="email" />
|
|
210
|
+
</StableField>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
StableField uses the same CSS Grid overlap physics as StableCounter,
|
|
214
|
+
applied vertically. A hidden `reserve` node permanently holds the error
|
|
215
|
+
slot open. The actual error message renders on top when present. The
|
|
216
|
+
field container never changes height. Both require a `reserve` prop.
|
|
217
|
+
|
|
218
|
+
### 10. Do not use CSS !important to enforce layout constraints
|
|
219
|
+
|
|
220
|
+
WRONG — global CSS !important in a library pollutes the consumer's cascade:
|
|
221
|
+
```css
|
|
222
|
+
.my-frame > * { width: 100% !important; height: 100% !important; }
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
CORRECT — enforce constraints at the React component layer:
|
|
226
|
+
```tsx
|
|
227
|
+
React.cloneElement(child, {
|
|
228
|
+
style: { position: "absolute", inset: 0, width: "100%", height: "100%" }
|
|
229
|
+
})
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
When you need to enforce rigid layout physics, always operate at the React
|
|
233
|
+
layer (cloneElement, inline styles, context) — never at the CSS layer.
|
|
234
|
+
|
|
235
|
+
### 11. Do not hand-write UI primitives — use createPrimitive
|
|
236
|
+
|
|
237
|
+
WRONG — manual boilerplate, easy to forget the Omit firewall:
|
|
238
|
+
```tsx
|
|
239
|
+
interface BadgeProps extends Omit<HTMLAttributes<HTMLSpanElement>, "className" | "style"> {
|
|
240
|
+
variant: Status;
|
|
241
|
+
}
|
|
242
|
+
export function Badge({ variant, children, ...props }: BadgeProps) {
|
|
243
|
+
return <span className="sk-badge" data-variant={variant} {...props}>{children}</span>;
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
CORRECT — factory handles the firewall automatically:
|
|
248
|
+
```tsx
|
|
249
|
+
import { createPrimitive } from "stablekit";
|
|
250
|
+
export const Badge = createPrimitive("span", "sk-badge", {
|
|
251
|
+
variant: ["active", "trial", "churned"],
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`createPrimitive` blocks `className` and `style` at the type level, maps
|
|
256
|
+
variant props to `data-*` attributes, and type-checks variant values. The
|
|
257
|
+
consumer writes `variant="active"`, the DOM gets `data-variant="active"`,
|
|
258
|
+
and CSS selects on it. The firewall is automatic — you cannot forget it.
|
|
259
|
+
|
|
260
|
+
## The StableKit Separation of Concerns
|
|
261
|
+
|
|
262
|
+
The codebase enforces four layers with a strict one-directional dependency
|
|
263
|
+
flow. Each layer answers exactly one question.
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
Data → Contract → Structure → Presentation
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
| # | Layer | Question | Knows about | Never knows about |
|
|
270
|
+
|---|-------|----------|-------------|-------------------|
|
|
271
|
+
| 1 | Data | What is the value? | Nothing — raw payload | Structure, appearance, types |
|
|
272
|
+
| 2 | Contract | What values are valid? | Allowed value sets | Structure, appearance, data source |
|
|
273
|
+
| 3 | Structure | What is the DOM? | Contract (via props), class names, data-attributes | Appearance, data source |
|
|
274
|
+
| 4 | Presentation | What does it look like? | Class names, data-attributes | Data source, React, TypeScript |
|
|
275
|
+
|
|
276
|
+
Each layer depends only on the one to its left. Presentation never imports
|
|
277
|
+
TypeScript. Structure never fetches data. Contract never references a CSS class.
|
|
278
|
+
|
|
279
|
+
**Litmus test:** if a change to appearance requires editing a `.tsx` file, or a
|
|
280
|
+
change to structure requires editing `.css`, a boundary has leaked.
|
|
281
|
+
|
|
282
|
+
### Enforcement
|
|
283
|
+
|
|
284
|
+
- **Data → Contract:** TypeScript compiler. If the API returns a value outside
|
|
285
|
+
the union type, it is a compile error.
|
|
286
|
+
- **Contract → Structure:** TypeScript compiler. Component props reference the
|
|
287
|
+
contract type (`variant: Status`), so invalid values are compile errors.
|
|
288
|
+
- **Structure → Presentation:** Four mechanisms:
|
|
289
|
+
1. `createPrimitive` blocks `className` and `style` at the type level,
|
|
290
|
+
preventing consumers from injecting appearance at the Structure layer.
|
|
291
|
+
2. CSS uses `[data-variant="..."]` attribute selectors to map contract values
|
|
292
|
+
to visual styles.
|
|
293
|
+
3. **ESLint** (`stablekit/eslint`) — `createArchitectureLint({ stateTokens, variantProps, banColorUtilities })`
|
|
294
|
+
bans data-dependent visual properties in JS (state tokens like
|
|
295
|
+
`text-success`, conditional style ternaries), hardcoded visual values
|
|
296
|
+
(bare hex colors like `"#f0c040"`, color functions like `rgba()`, `hsl()`,
|
|
297
|
+
`oklch()`), color properties in style props (`style={{ color: x }}`),
|
|
298
|
+
visual state properties in style props (`style={{ opacity, visibility,
|
|
299
|
+
transition, pointerEvents }}`), className ternaries
|
|
300
|
+
(`className={x ? "a" : "b"}`), className logical AND
|
|
301
|
+
(`className={cn("base", x && "bold")}`), className object syntax
|
|
302
|
+
(`cx({ class: condition })`), `!important` in className, hardcoded
|
|
303
|
+
z-index (`z-[999]`), negative margins (`m-[-4px]`), hardcoded pixel
|
|
304
|
+
dimensions (`w-[347px]`), and all arbitrary magic numbers in Tailwind
|
|
305
|
+
bracket syntax. `banColorUtilities` (default: true) bans ALL Tailwind
|
|
306
|
+
palette color utilities in className (`bg-red-500`, `text-green-600`,
|
|
307
|
+
`border-cyan-400`, etc.) — colors must live in CSS, not in component
|
|
308
|
+
classNames. `stateTokens` declares your project's functional color
|
|
309
|
+
vocabulary. `variantProps` declares prop names from `createPrimitive` —
|
|
310
|
+
bans ternaries like `intent={x ? "primary" : "outline"}`.
|
|
311
|
+
The linter also enforces geometric stability by banning all conditional
|
|
312
|
+
content in JSX children: ternary swaps (`{x ? <A/> : <B/>}`), conditional
|
|
313
|
+
mounting (`{x && <Panel/>}`), fallback content (`{x || "default"}`),
|
|
314
|
+
nullish fallbacks (`{x ?? "loading"}`), and interpolated template literals
|
|
315
|
+
(`` {`text ${var}`} ``). Each error message guides toward the right fix:
|
|
316
|
+
extract the expression to a variable above the JSX (for data transforms),
|
|
317
|
+
or use a StableKit component (for state-driven swaps — StateSwap,
|
|
318
|
+
LayoutMap, LoadingBoundary, FadeTransition, StableField, StableCounter,
|
|
319
|
+
LayoutGroup). These rules are always on.
|
|
320
|
+
4. **Stylelint** (`stablekit/stylelint`) — `createStyleLint({ functionalTokens })`
|
|
321
|
+
bans element selectors in CSS (`& svg`, `& span`), bans `!important`, and
|
|
322
|
+
bans functional color tokens inside `@utility` blocks. `functionalTokens`
|
|
323
|
+
declares CSS custom property prefixes (e.g. `["--color-status-"]`) that must
|
|
324
|
+
not appear in `@utility` — they belong in scoped selectors only.
|
|
325
|
+
Run `npx eslint src/components/` and `npx stylelint "src/**/*.css"` before
|
|
326
|
+
committing. Zero errors is the requirement — do not add disable comments.
|
|
327
|
+
|
|
328
|
+
### Example: Badge
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
// Layer 2 — Contract (types.ts)
|
|
332
|
+
export type Status = "active" | "trial" | "churned";
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
```tsx
|
|
336
|
+
// Layer 3 — Structure (badge.tsx)
|
|
337
|
+
// className and style are Omit'd — consumers cannot inject appearance
|
|
338
|
+
<span className="sk-badge" data-variant={variant}>{children}</span>
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
```css
|
|
342
|
+
/* Layer 4 — Presentation (index.css) */
|
|
343
|
+
.sk-badge[data-variant="active"] { color: var(--color-success); }
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Internal Contribution Rules
|
|
347
|
+
|
|
348
|
+
Never write raw `process.env.NODE_ENV` checks in components. Always import
|
|
349
|
+
and use `invariant` or `warning` from `src/internal/invariant.ts`.
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
// WRONG — raw environment check in a component
|
|
353
|
+
if (process.env.NODE_ENV !== "production") {
|
|
354
|
+
throw new Error("bad usage");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// CORRECT — declarative assertion
|
|
358
|
+
import { invariant } from "../internal/invariant";
|
|
359
|
+
invariant(someCondition, "Explanation of what went wrong.");
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
`invariant(condition, message)` throws a fatal error in development and is
|
|
363
|
+
stripped entirely in production. `warning(condition, message)` emits a
|
|
364
|
+
console.warn in development and is stripped entirely in production.
|
|
365
|
+
|
|
366
|
+
### Banned Pattern: Logic Leakage
|
|
367
|
+
|
|
368
|
+
Never store CSS classes, Tailwind strings, or styling logic in data files or
|
|
369
|
+
non-UI modules. Data exports raw values only. Visual mapping belongs in CSS
|
|
370
|
+
(via class or attribute selectors) or in dumb UI primitives that accept a
|
|
371
|
+
semantic `variant` prop and map it to a class name.
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
// WRONG — data file contains Tailwind classes
|
|
375
|
+
export const statusColors = {
|
|
376
|
+
active: "bg-green-100 text-green-800 border-green-300",
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// WRONG — component maps data to inline Tailwind
|
|
380
|
+
<span className={statusColors[status]}>{status}</span>
|
|
381
|
+
|
|
382
|
+
// CORRECT — dumb component, semantic variant
|
|
383
|
+
<Badge variant={status}>{status}</Badge>
|
|
384
|
+
|
|
385
|
+
// CORRECT — CSS owns the visual mapping
|
|
386
|
+
.sk-badge[data-variant="active"] { color: var(--color-success); ... }
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
If changing a color requires editing a `.ts` file, the architecture is broken.
|
|
390
|
+
|
|
391
|
+
### Banned Pattern: Class Concatenation for Mutually Exclusive States
|
|
392
|
+
|
|
393
|
+
Never use CSS class concatenation for mutually exclusive component states or
|
|
394
|
+
variants (e.g., `primary`/`secondary`, `active`/`trial`/`churned`). A DOM node
|
|
395
|
+
can accidentally receive two conflicting classes
|
|
396
|
+
(`class="sk-badge-active sk-badge-churned"`), but it can only ever have one
|
|
397
|
+
value for a given data-attribute (`data-variant="active"`).
|
|
398
|
+
|
|
399
|
+
Classes define **what** a component is. Data-attributes define **how** it is
|
|
400
|
+
currently behaving.
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
// WRONG — class concatenation for variants
|
|
404
|
+
<span className={`sk-badge sk-badge-${variant}`}>{children}</span>
|
|
405
|
+
|
|
406
|
+
// CORRECT — base class + data-attribute
|
|
407
|
+
<span className="sk-badge" data-variant={variant}>{children}</span>
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
```css
|
|
411
|
+
/* WRONG — modifier class */
|
|
412
|
+
.sk-badge-active { color: var(--color-success); }
|
|
413
|
+
|
|
414
|
+
/* CORRECT — attribute selector on the base class */
|
|
415
|
+
.sk-badge[data-variant="active"] { color: var(--color-success); }
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
## Component selection guide
|
|
419
|
+
|
|
420
|
+
| Problem | Component |
|
|
421
|
+
|--------------------------------------------|----------------------|
|
|
422
|
+
| Button text changes on toggle | `StateSwap` |
|
|
423
|
+
| Icon changes on toggle | `StateSwap` |
|
|
424
|
+
| Tab panels with stable height | `LayoutMap` |
|
|
425
|
+
| Multi-step wizard with stable dimensions | `LayoutGroup` + `LayoutView` |
|
|
426
|
+
| Text loading from API | `StableText` inside `LoadingBoundary` |
|
|
427
|
+
| Image/video loading causes shift | `MediaSkeleton` |
|
|
428
|
+
| List loading from API | `CollectionSkeleton` |
|
|
429
|
+
| Card/form loading from API | `LoadingBoundary` + `StableText` |
|
|
430
|
+
| Panel enters/exits with animation | `FadeTransition` |
|
|
431
|
+
| Container should never shrink | `SizeRatchet` |
|
|
432
|
+
| Number/price changes digit count | `StableCounter` |
|
|
433
|
+
| Cart badge, notification count | `StableCounter` |
|
|
434
|
+
| Form validation error appears/disappears | `StableField` |
|
|
435
|
+
| Need a UI primitive (badge, button, card) | `createPrimitive` |
|
|
436
|
+
|
|
437
|
+
## Quick start pattern
|
|
438
|
+
|
|
439
|
+
```tsx
|
|
440
|
+
import {
|
|
441
|
+
LoadingBoundary,
|
|
442
|
+
StableText,
|
|
443
|
+
MediaSkeleton,
|
|
444
|
+
StateSwap,
|
|
445
|
+
LayoutMap,
|
|
446
|
+
SizeRatchet,
|
|
447
|
+
StableCounter,
|
|
448
|
+
StableField,
|
|
449
|
+
FadeTransition,
|
|
450
|
+
createPrimitive,
|
|
451
|
+
} from "stablekit";
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
Loading a user profile with zero layout shift:
|
|
455
|
+
```tsx
|
|
456
|
+
<LoadingBoundary loading={isLoading} exitDuration={150}>
|
|
457
|
+
<MediaSkeleton aspectRatio={1} className="w-16 rounded-full">
|
|
458
|
+
<img src={user.avatar} alt={user.name} />
|
|
459
|
+
</MediaSkeleton>
|
|
460
|
+
<StableText as="h2" className="text-xl font-bold">{user.name}</StableText>
|
|
461
|
+
<StableText as="p" className="text-sm text-muted">{user.email}</StableText>
|
|
462
|
+
</LoadingBoundary>
|
|
463
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stablekit.ts",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "React toolkit for layout stability — zero-shift components for loading states, content swaps, and spatial containers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"./styles.css": "./dist/styles.css",
|
|
21
|
+
"./eslint": {
|
|
22
|
+
"import": {
|
|
23
|
+
"types": "./dist/eslint.d.ts",
|
|
24
|
+
"default": "./dist/eslint.js"
|
|
25
|
+
},
|
|
26
|
+
"require": {
|
|
27
|
+
"types": "./dist/eslint.d.cts",
|
|
28
|
+
"default": "./dist/eslint.cjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"./stylelint": {
|
|
32
|
+
"import": {
|
|
33
|
+
"types": "./dist/stylelint.d.ts",
|
|
34
|
+
"default": "./dist/stylelint.js"
|
|
35
|
+
},
|
|
36
|
+
"require": {
|
|
37
|
+
"types": "./dist/stylelint.d.cts",
|
|
38
|
+
"default": "./dist/stylelint.cjs"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"llms.txt"
|
|
45
|
+
],
|
|
46
|
+
"sideEffects": [
|
|
47
|
+
"./dist/styles.css"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup && cp src/styles.css dist/styles.css",
|
|
51
|
+
"prepare": "npm run build",
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"test:watch": "vitest",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"prepublishOnly": "npm run build"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"react": ">=18.0.0",
|
|
59
|
+
"react-dom": ">=18.0.0"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
63
|
+
"@testing-library/react": "^16.3.2",
|
|
64
|
+
"@types/node": "^25.3.5",
|
|
65
|
+
"@types/react": "^19.0.0",
|
|
66
|
+
"eslint": "^10.0.3",
|
|
67
|
+
"jsdom": "^28.1.0",
|
|
68
|
+
"react": "^19.0.0",
|
|
69
|
+
"react-dom": "^19.0.0",
|
|
70
|
+
"tsup": "^8.0.0",
|
|
71
|
+
"typescript": "^5.0.0",
|
|
72
|
+
"vitest": "^4.0.18"
|
|
73
|
+
},
|
|
74
|
+
"keywords": [
|
|
75
|
+
"layout-stability",
|
|
76
|
+
"loading-skeleton",
|
|
77
|
+
"state-swap",
|
|
78
|
+
"layout-shift",
|
|
79
|
+
"react",
|
|
80
|
+
"resize-observer",
|
|
81
|
+
"skeleton",
|
|
82
|
+
"shimmer",
|
|
83
|
+
"stable-layout",
|
|
84
|
+
"zero-cls"
|
|
85
|
+
],
|
|
86
|
+
"license": "MIT",
|
|
87
|
+
"repository": {
|
|
88
|
+
"type": "git",
|
|
89
|
+
"url": "https://github.com/ryandward/stablekit"
|
|
90
|
+
},
|
|
91
|
+
"author": "Ryan Ward"
|
|
92
|
+
}
|