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/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
+ }