@playgenx/components 0.2.0 → 0.2.2
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 +417 -35
- package/bin/state-cli.mjs +196 -0
- package/dist/index.d.mts +102 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +148 -14
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,62 +1,444 @@
|
|
|
1
1
|
# `@playgenx/components`
|
|
2
2
|
|
|
3
|
-
Default React 19 implementations for every entry in `DEFAULT_REGISTRY`.
|
|
3
|
+
Default React 19 implementations for every entry in `DEFAULT_REGISTRY`.
|
|
4
|
+
The components, a per-Provider reactive state store, error boundaries,
|
|
5
|
+
and a CLI for inspecting state snapshots.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
```bash
|
|
8
|
+
npm install @playgenx/components
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
React 19 is a peer dependency — your app provides it.
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
// package.json (the bits you need)
|
|
15
|
+
{
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@playgenx/components": "^0.2.1",
|
|
18
|
+
"react": "^19.2.0",
|
|
19
|
+
"react-dom": "^19.2.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
> Looking for the **renderer** (parser + tree walker)? That's a
|
|
25
|
+
> separate package — `@playgenx/renderer`. This package is the
|
|
26
|
+
> component library; the renderer consumes it.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import {
|
|
34
|
+
Slider,
|
|
35
|
+
PlaygroundStateProvider,
|
|
36
|
+
usePlaygroundState,
|
|
37
|
+
} from '@playgenx/components';
|
|
38
|
+
import { useState } from 'react';
|
|
39
|
+
|
|
40
|
+
// 1. Standalone, no state — works without a Provider
|
|
41
|
+
export function StaticVolume() {
|
|
42
|
+
return <Slider min={0} max={10} value={4} label="Volume" />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 2. With shared state — wrap your tree
|
|
46
|
+
export function VolumePanel() {
|
|
47
|
+
return (
|
|
48
|
+
<PlaygroundStateProvider initial={{ volume: 5 }}>
|
|
49
|
+
<Slider min={0} max={10} stateKey="volume" label="Volume" />
|
|
50
|
+
<VolumeReadout />
|
|
51
|
+
</PlaygroundStateProvider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
6
54
|
|
|
7
|
-
|
|
8
|
-
|
|
55
|
+
function VolumeReadout() {
|
|
56
|
+
const state = usePlaygroundState();
|
|
57
|
+
return <span>Current: {state.get<number>('volume')}</span>;
|
|
58
|
+
}
|
|
9
59
|
```
|
|
10
60
|
|
|
11
|
-
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## What's in the box
|
|
64
|
+
|
|
65
|
+
### Components (PascalCase, React 19)
|
|
66
|
+
|
|
67
|
+
Every component accepts JSON-friendly props. See `index.ts` for the
|
|
68
|
+
full surface.
|
|
69
|
+
|
|
70
|
+
| Component | Purpose | Notable props |
|
|
71
|
+
|---------------|--------------------------------------------|--------------------------------|
|
|
72
|
+
| `Button` | Headless button | `label`, `variant`, `onClick` |
|
|
73
|
+
| `TextField` | Controlled / uncontrolled input | `value`, `onChange` |
|
|
74
|
+
| `Slider` | Range input | `min`, `max`, `value`, `stateKey` |
|
|
75
|
+
| `Chart` | bar / line / pie SVG charts | `kind`, `data`, `title` |
|
|
76
|
+
| `Container` | Layout primitive | `padding`, `gap` |
|
|
77
|
+
| `Code` | Inline code / `<pre><code>` | `language` |
|
|
78
|
+
| `Heading` | `<h1>`–`<h6>` | `level` |
|
|
79
|
+
| `Text` | `<p>` with weight + color | `weight`, `color` |
|
|
80
|
+
| `Stepper` | Multi-step reveal | `steps`, `initial` |
|
|
81
|
+
| `Card` | Titled card | `title`, `children` |
|
|
82
|
+
| `List` | `<ul>` / `<ol>` from array | `items`, `ordered` |
|
|
83
|
+
|
|
84
|
+
### State store + React glue
|
|
85
|
+
|
|
86
|
+
| Export | What it does |
|
|
87
|
+
|------------------------------|-----------------------------------------------------------|
|
|
88
|
+
| `createPlaygroundState(initial?)` | Create a standalone state store |
|
|
89
|
+
| `PlaygroundStateProvider` | Wrap your tree with a Provider |
|
|
90
|
+
| `usePlaygroundState()` | Read the nearest store |
|
|
91
|
+
| `useBoundValue(key, fb)` | Get a live `[value, setter]` tuple (re-renders on change) |
|
|
92
|
+
| `useBoundValueOrUndefined(key, fb)` | Same; returns `undefined` if no key/provider |
|
|
93
|
+
| `useStoreSnapshot()` | Non-subscribing `{get, set, subscribe, snapshot, ...}` |
|
|
94
|
+
| `useBoundSelector(key, sel, fb)` | Live bound value with selector transform; only re-renders on slice change |
|
|
95
|
+
| `useMultiBoundValue(bindings)` | Bind multiple keys; returns `Record<key, [value, setter]>` |
|
|
96
|
+
| `useStateAction(action)` | Build an `onClick` from `{set, toggle}` config |
|
|
97
|
+
|
|
98
|
+
### Pure helpers (no React, no Provider)
|
|
99
|
+
|
|
100
|
+
| Export | Returns |
|
|
101
|
+
|-----------------------|------------------------------------|
|
|
102
|
+
| `dumpState(state)` | JSON v1 envelope (sorted keys) |
|
|
103
|
+
| `diffState(a, b)` | `{added, changed, removed}` or null |
|
|
104
|
+
| `diffSnapshots(prev, next)` | Same as `diffState` but pure |
|
|
105
|
+
| `validateStateKey(k)` | `null` if valid, error message if not |
|
|
106
|
+
| `clearState(state)` | (void) — sets every key to undefined |
|
|
107
|
+
| `batch(state, fn)` | Atomic set commit, per-key dedup |
|
|
108
|
+
|
|
109
|
+
### Error handling
|
|
12
110
|
|
|
13
|
-
|
|
111
|
+
| Export | Purpose |
|
|
112
|
+
|------------------------------|------------------------------------|
|
|
113
|
+
| `ArtifactErrorBoundary` | Class-component error boundary |
|
|
114
|
+
| `ShowSource` | Render-prop source-toggle |
|
|
14
115
|
|
|
15
|
-
|
|
116
|
+
### Registry
|
|
16
117
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
- `Container` — vertical flex wrapper with padding + gap.
|
|
22
|
-
- `Code` — inline or `<pre><code>` formatting, language data-attribute.
|
|
23
|
-
- `Heading` — `<h1>`..`<h6>` with size-by-level, clamped.
|
|
24
|
-
- `Text` — `<p>` with weight, size, color.
|
|
25
|
-
- `Stepper` — multi-step reveal with previous/current/future states.
|
|
26
|
-
- `Card` — bordered + shadowed wrapper with elevation prop.
|
|
27
|
-
- `List` — `<ul>` or `<ol>` from an array of arbitrary values.
|
|
118
|
+
| Export | Purpose |
|
|
119
|
+
|-------------------------|--------------------------------------|
|
|
120
|
+
| `componentMap` | Frozen default registry (11 entries) |
|
|
121
|
+
| `createRegistry(overrides)` | Returns a new frozen map |
|
|
28
122
|
|
|
29
|
-
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Three ways to use the state store
|
|
126
|
+
|
|
127
|
+
### 1. Standalone, outside React
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { createPlaygroundState } from '@playgenx/components';
|
|
131
|
+
|
|
132
|
+
const state = createPlaygroundState({ count: 0 });
|
|
133
|
+
state.set('count', 5);
|
|
134
|
+
state.get('count'); // 5
|
|
135
|
+
state.snapshot(); // { count: 5 }
|
|
136
|
+
state.subscribe('count', (v) => console.log('count is now', v));
|
|
137
|
+
state.set('count', 10); // → console.log fires on the next microtask
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 2. React tree with a Provider
|
|
30
141
|
|
|
31
142
|
```tsx
|
|
32
|
-
import {
|
|
33
|
-
import { DEFAULT_REGISTRY, findSchema } from '@playgenx/registry';
|
|
143
|
+
import { PlaygroundStateProvider, useBoundValue } from '@playgenx/components';
|
|
34
144
|
|
|
35
|
-
function
|
|
145
|
+
function VolumeControl() {
|
|
146
|
+
// Live-bound: writes to state on change, re-renders on external writes
|
|
147
|
+
const [volume, setVolume] = useBoundValue<number>('volume', 50);
|
|
36
148
|
return (
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
149
|
+
<input
|
|
150
|
+
type="range"
|
|
151
|
+
min={0}
|
|
152
|
+
max={100}
|
|
153
|
+
value={volume}
|
|
154
|
+
onChange={(e) => setVolume(Number(e.currentTarget.value))}
|
|
155
|
+
/>
|
|
42
156
|
);
|
|
43
157
|
}
|
|
44
158
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
159
|
+
export function App() {
|
|
160
|
+
return (
|
|
161
|
+
<PlaygroundStateProvider initial={{ volume: 50 }}>
|
|
162
|
+
<VolumeControl />
|
|
163
|
+
</PlaygroundStateProvider>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 3. State-bound component (the simple path)
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
// No need to write your own useBoundValue — Slider + stateKey does it.
|
|
172
|
+
<PlaygroundStateProvider initial={{ volume: 50 }}>
|
|
173
|
+
<Slider min={0} max={100} stateKey="volume" />
|
|
174
|
+
</PlaygroundStateProvider>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Flexibility patterns — pick the right hook for the job
|
|
180
|
+
|
|
181
|
+
v0.2.2 ships five hooks for binding components to state. They differ
|
|
182
|
+
in **what triggers a re-render**, **what they assume about Providers**,
|
|
183
|
+
and **how they expose state to your code**. Pick the lowest-cost one
|
|
184
|
+
that fits.
|
|
185
|
+
|
|
186
|
+
| Hook | Subscribes? | Needs Provider? | SSR-safe? | Use when… |
|
|
187
|
+
|------------------------|-------------|------------------|-----------|-----------------------------------------|
|
|
188
|
+
| `useBoundValue` | yes | yes (throws) | yes | You need to re-render on state changes and a Provider is mandatory |
|
|
189
|
+
| `useBoundValueOrUndefined` | yes | no (graceful) | yes | `stateKey` is optional; no Provider = no-op binding |
|
|
190
|
+
| `useStoreSnapshot` | **no** | yes (throws) | n/a | You read/write state in handlers but don't want re-renders |
|
|
191
|
+
| `useBoundSelector` | yes (filtered) | yes (throws) | yes | One key holds an object; you only care about one slice |
|
|
192
|
+
| `useMultiBoundValue` | yes (1 per key) | yes (throws) | yes | A form/dashboard binds 5-10 keys; you want a Record |
|
|
193
|
+
|
|
194
|
+
### `useStoreSnapshot` — for non-subscribing reads
|
|
195
|
+
|
|
196
|
+
When you need state in event handlers but don't want the component to
|
|
197
|
+
re-render on every change:
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
import { useStoreSnapshot } from '@playgenx/components';
|
|
201
|
+
|
|
202
|
+
function SaveButton() {
|
|
203
|
+
const { get, set, snapshot } = useStoreSnapshot();
|
|
204
|
+
return (
|
|
205
|
+
<button onClick={() => {
|
|
206
|
+
// Read multiple keys atomically, write back, no re-render
|
|
207
|
+
const form = snapshot();
|
|
208
|
+
if (form.dirty) {
|
|
209
|
+
set('savedAt', Date.now());
|
|
210
|
+
api.save(form);
|
|
211
|
+
}
|
|
212
|
+
}}>
|
|
213
|
+
Save
|
|
214
|
+
</button>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
48
217
|
```
|
|
49
218
|
|
|
50
|
-
|
|
219
|
+
The hook returns `{ state, get, set, subscribe, snapshot, replaceAll }` —
|
|
220
|
+
all stable references for the Provider's lifetime.
|
|
51
221
|
|
|
52
|
-
|
|
222
|
+
### `useBoundSelector` — slice-aware subscriptions
|
|
53
223
|
|
|
54
|
-
|
|
224
|
+
When one key holds an object and you only care about one field:
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
import { useBoundSelector } from '@playgenx/components';
|
|
228
|
+
|
|
229
|
+
interface User { name: string; age: number; email: string; }
|
|
230
|
+
|
|
231
|
+
function CurrentUserBadge() {
|
|
232
|
+
// Only re-renders when `name` changes, not when `age` changes.
|
|
233
|
+
const [name] = useBoundSelector<User, string>(
|
|
234
|
+
'currentUser',
|
|
235
|
+
(u) => u?.name ?? 'guest',
|
|
236
|
+
'guest',
|
|
237
|
+
);
|
|
238
|
+
return <span>Hi, {name}!</span>;
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Important**: write selectors that return primitives or stable refs.
|
|
243
|
+
A selector that returns a fresh object (`u => ({...u})`) defeats the
|
|
244
|
+
slice-detection and re-renders on every store change.
|
|
245
|
+
|
|
246
|
+
### `useMultiBoundValue` — bind many keys at once
|
|
247
|
+
|
|
248
|
+
For forms and dashboards where you have several bound inputs in one
|
|
249
|
+
component:
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
import { useMultiBoundValue } from '@playgenx/components';
|
|
253
|
+
|
|
254
|
+
function SettingsForm() {
|
|
255
|
+
const form = useMultiBoundValue({
|
|
256
|
+
name: { fallback: '' },
|
|
257
|
+
email: { fallback: '' },
|
|
258
|
+
age: { fallback: 0 },
|
|
259
|
+
role: { fallback: 'user' },
|
|
260
|
+
});
|
|
261
|
+
return (
|
|
262
|
+
<>
|
|
263
|
+
<input value={form.name[0]} onChange={e => form.name[1](e.target.value)} />
|
|
264
|
+
<input value={form.email[0]} onChange={e => form.email[1](e.target.value)} />
|
|
265
|
+
<input type="number" value={form.age[0]} onChange={e => form.age[1](+e.target.value)} />
|
|
266
|
+
<select value={form.role[0]} onChange={e => form.role[1](e.target.value)}>
|
|
267
|
+
<option value="user">User</option>
|
|
268
|
+
<option value="admin">Admin</option>
|
|
269
|
+
</select>
|
|
270
|
+
</>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The hook subscribes once per key (so 5 keys = 5 subscriptions). For
|
|
276
|
+
50+ rows of dynamic data, use `useStoreSnapshot` + manual `get`/`set`
|
|
277
|
+
calls instead — the hook count is bounded.
|
|
278
|
+
|
|
279
|
+
### SSR — state is reflected in the first render
|
|
280
|
+
|
|
281
|
+
All `useBoundValue*` hooks use React 19's `useSyncExternalStore` so
|
|
282
|
+
the **server-rendered HTML matches the live store value**, not a
|
|
283
|
+
fallback. `renderToString(<Slider stateKey="v" />)` inside a Provider
|
|
284
|
+
with `initial={{ v: 7 }}` produces `<input value="7" ... />` — no
|
|
285
|
+
hydration mismatch.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
// Server
|
|
289
|
+
import { renderToString } from 'react-dom/server';
|
|
290
|
+
import { Slider, PlaygroundStateProvider } from '@playgenx/components';
|
|
291
|
+
|
|
292
|
+
const html = renderToString(
|
|
293
|
+
<PlaygroundStateProvider initial={{ volume: 50 }}>
|
|
294
|
+
<Slider min={0} max={100} stateKey="volume" />
|
|
295
|
+
</PlaygroundStateProvider>
|
|
296
|
+
);
|
|
297
|
+
// html contains `value="50"` — not `value="0"` (the local fallback)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
If your framework has a `renderToReadableStream` or similar, the same
|
|
301
|
+
guarantee holds. The hook count is **3** (down from 5 in 0.2.1)
|
|
302
|
+
thanks to `useSyncExternalStore` collapsing the previous
|
|
303
|
+
`useState + useState + useEffect` trio.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Slider — the three operating modes
|
|
308
|
+
|
|
309
|
+
The `Slider` component accepts `value` and/or `stateKey`. They mean
|
|
310
|
+
different things:
|
|
311
|
+
|
|
312
|
+
| Pass | Mode | Behavior |
|
|
313
|
+
|---------------------|--------------------------|---------------------------------------|
|
|
314
|
+
| Neither | **Uncontrolled** | Owns its own local state |
|
|
315
|
+
| `value={50}` only | **Controlled, read-only** | User input is ignored (no onChange wired) |
|
|
316
|
+
| `stateKey="volume"` | **Bound to state** | Reads/writes via the Provider |
|
|
317
|
+
|
|
318
|
+
The bound mode wins when both are present. `value` is clamped to
|
|
319
|
+
`[min, max]` on read; NaN clamps to `min`; `min === max` disables the
|
|
320
|
+
slider.
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## The CLI: `playgenx-state`
|
|
325
|
+
|
|
326
|
+
After `npm install @playgenx/components`, you get a `playgenx-state`
|
|
327
|
+
binary on your PATH (via npm's `bin` field). It operates on JSON
|
|
328
|
+
snapshots on stdin/stdout.
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
# Pretty-print a snapshot in the v1 envelope format
|
|
332
|
+
$ echo '{"volume":5,"user":{"name":"a"}}' | playgenx-state snapshot
|
|
333
|
+
{
|
|
334
|
+
"version": 1,
|
|
335
|
+
"keys": [
|
|
336
|
+
"user",
|
|
337
|
+
"volume"
|
|
338
|
+
],
|
|
339
|
+
"values": {
|
|
340
|
+
"volume": 5,
|
|
341
|
+
"user": { "name": "a" }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# Validate stateKey strings before using them in a body
|
|
346
|
+
$ echo '["good","bad key","has/slash"]' | playgenx-state validate
|
|
347
|
+
[
|
|
348
|
+
{ "key": "good", "ok": true },
|
|
349
|
+
{ "key": "bad key", "ok": false, "error": "stateKey must not contain whitespace" },
|
|
350
|
+
{ "key": "has/slash", "ok": false, "error": "stateKey contains forbidden characters..." }
|
|
351
|
+
]
|
|
352
|
+
# exit code 3 (validation failed)
|
|
353
|
+
|
|
354
|
+
# Diff two snapshots
|
|
355
|
+
$ echo '{"prev":{"a":1},"next":{"a":2,"b":3}}' | playgenx-state diff
|
|
356
|
+
{ "added": ["b"], "changed": ["a"], "removed": [] }
|
|
357
|
+
|
|
358
|
+
# Inspect keys / count
|
|
359
|
+
$ echo '{"b":1,"a":2,"c":3}' | playgenx-state keys
|
|
360
|
+
[ "a", "b", "c" ]
|
|
361
|
+
$ echo '{"b":1,"a":2,"c":3}' | playgenx-state count
|
|
362
|
+
{ "keys": 3 }
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
**Exit codes:** `0` ok, `1` unknown subcommand, `2` bad input, `3`
|
|
366
|
+
validation failure.
|
|
367
|
+
|
|
368
|
+
These are the same helpers exported from the package as library
|
|
369
|
+
functions (`dumpState`, `diffSnapshots`, `validateStateKey`, etc.) —
|
|
370
|
+
the CLI is a thin wrapper. Use whichever fits.
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## Architecture notes
|
|
375
|
+
|
|
376
|
+
- **Per-Provider scope.** Each `PlaygroundStateProvider` creates an
|
|
377
|
+
independent store. Nested Providers create nested scopes (a child
|
|
378
|
+
Provider does not see the parent's keys).
|
|
379
|
+
- **HMR-safe.** Subscribers are cleared on Provider unmount, so a
|
|
380
|
+
hot-reload that re-mounts the Provider doesn't leak subscribers to
|
|
381
|
+
the old store.
|
|
382
|
+
- **Microtask batched.** `set()` schedules subscriber fires on the
|
|
383
|
+
next microtask. Multiple sets in the same tick produce one fire
|
|
384
|
+
per affected key. Use `batch(state, fn)` if you want each
|
|
385
|
+
subscriber to see only the LAST value set in a group.
|
|
386
|
+
- **No deep clone.** Values are stored by reference. Don't mutate
|
|
387
|
+
objects after `set()`-ing them — use a fresh object instead.
|
|
388
|
+
- **`Object.is` equality.** Setting a value that `Object.is`-equals
|
|
389
|
+
the current value is a no-op (no subscriber fire, no flush). This
|
|
390
|
+
means `set('x', NaN)` twice fires once, and `set('x', undefined)`
|
|
391
|
+
on an unset key is a no-op (since `Object.is(undefined, undefined)`
|
|
392
|
+
is `true`).
|
|
393
|
+
- **Sub-Provider remount drops old subscribers.** Subscribers from a
|
|
394
|
+
unmounted Provider's tree do not fire for sets on a NEW Provider's
|
|
395
|
+
store, even with the same key name. (Verified by the A.7 hardening
|
|
396
|
+
test.)
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## TypeScript
|
|
401
|
+
|
|
402
|
+
This package is written in TypeScript with strict types. The
|
|
403
|
+
`dist/index.d.mts` declarations are exhaustive — you can `import
|
|
404
|
+
type { SliderProps, PlaygroundState, StateDiff }` etc.
|
|
405
|
+
|
|
406
|
+
Common imports:
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
import type {
|
|
410
|
+
SliderProps,
|
|
411
|
+
ButtonProps,
|
|
412
|
+
PlaygroundState,
|
|
413
|
+
StateAction,
|
|
414
|
+
StateDiff,
|
|
415
|
+
StateEnvelope,
|
|
416
|
+
RenderBodyOptions,
|
|
417
|
+
} from '@playgenx/components';
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
---
|
|
55
421
|
|
|
56
422
|
## Versioning
|
|
57
423
|
|
|
58
|
-
|
|
424
|
+
- **0.1.x** — Component-only, no state store. `Slider` had no
|
|
425
|
+
`stateKey` prop.
|
|
426
|
+
- **0.2.0** — Adds `PlaygroundStateProvider`, `useBoundValue*`,
|
|
427
|
+
`useStateAction`, `ArtifactErrorBoundary`, `ShowSource`,
|
|
428
|
+
`createRegistry`, the pure helpers (`dumpState` etc.), and the
|
|
429
|
+
`playgenx-state` CLI binary. The `stateKey` prop on `Slider` is
|
|
430
|
+
fully wired and uses the new state store.
|
|
431
|
+
- **0.2.1** — Exposes the CLI binary via the package's `bin` field
|
|
432
|
+
so `npx playgenx-state` works after a fresh install.
|
|
433
|
+
- **0.2.2** — SSR fix: `useBoundValueOrUndefined` now uses React 19's
|
|
434
|
+
`useSyncExternalStore` so `renderToString` reflects the Provider's
|
|
435
|
+
live value (no more "fallback on server, real value after hydration"
|
|
436
|
+
mismatch). Adds three flexibility hooks: `useStoreSnapshot`
|
|
437
|
+
(non-subscribing), `useBoundSelector` (slice-aware subscription),
|
|
438
|
+
`useMultiBoundValue` (batch key binding). 108 tests passing.
|
|
439
|
+
|
|
440
|
+
---
|
|
59
441
|
|
|
60
442
|
## License
|
|
61
443
|
|
|
62
|
-
MIT —
|
|
444
|
+
MIT — same as the parent project.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* playgenx-state — CLI for inspecting and operating on
|
|
5
|
+
* PlaygroundState snapshots.
|
|
6
|
+
*
|
|
7
|
+
* Pure ESM, no extra deps. Reads JSON from stdin (or argv),
|
|
8
|
+
* writes JSON to stdout.
|
|
9
|
+
*
|
|
10
|
+
* Subcommands:
|
|
11
|
+
*
|
|
12
|
+
* snapshot Pretty-print a snapshot in the v1 envelope format.
|
|
13
|
+
* Accepts a JSON object on stdin (the snapshot you
|
|
14
|
+
* captured from `state.snapshot()` or a DebugSurface).
|
|
15
|
+
*
|
|
16
|
+
* validate Validate one or more stateKey strings from stdin.
|
|
17
|
+
* Reads a JSON array of strings, returns the per-key
|
|
18
|
+
* verdict.
|
|
19
|
+
*
|
|
20
|
+
* diff Diff two snapshots. Reads `{prev, next}` from stdin
|
|
21
|
+
* and returns the structured {added, changed, removed}
|
|
22
|
+
* diff (or null when equal).
|
|
23
|
+
*
|
|
24
|
+
* keys List the sorted keys of a snapshot from stdin.
|
|
25
|
+
*
|
|
26
|
+
* count Count the keys in a snapshot from stdin.
|
|
27
|
+
*
|
|
28
|
+
* Usage examples (run from the components package root):
|
|
29
|
+
*
|
|
30
|
+
* echo '{"volume": 5}' | node bin/state-cli.mjs snapshot
|
|
31
|
+
* echo '["volume","user.name","bad key"]' | node bin/state-cli.mjs validate
|
|
32
|
+
* echo '{"prev":{"a":1},"next":{"a":2,"b":3}}' | node bin/state-cli.mjs diff
|
|
33
|
+
*
|
|
34
|
+
* Exit codes:
|
|
35
|
+
*
|
|
36
|
+
* 0 success
|
|
37
|
+
* 1 bad subcommand
|
|
38
|
+
* 2 bad input (not parseable as JSON, wrong shape, etc.)
|
|
39
|
+
* 3 validation failure (one or more keys rejected)
|
|
40
|
+
*
|
|
41
|
+
* @packageDocumentation
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
dumpState,
|
|
46
|
+
diffSnapshots,
|
|
47
|
+
validateStateKey,
|
|
48
|
+
} from '../dist/index.mjs';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read all of stdin into a string. Node-only.
|
|
52
|
+
*/
|
|
53
|
+
async function readStdin() {
|
|
54
|
+
if (process.stdin.isTTY) {
|
|
55
|
+
// Interactive / no pipe — return empty; the caller will treat
|
|
56
|
+
// this as "no input" and print usage.
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
const chunks = [];
|
|
60
|
+
for await (const chunk of process.stdin) {
|
|
61
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
62
|
+
}
|
|
63
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function usage() {
|
|
67
|
+
return [
|
|
68
|
+
'Usage: playgenx-state <subcommand> [input]',
|
|
69
|
+
'',
|
|
70
|
+
'Subcommands:',
|
|
71
|
+
' snapshot Pretty-print a state snapshot (JSON on stdin)',
|
|
72
|
+
' validate Validate an array of stateKey strings (JSON array on stdin)',
|
|
73
|
+
' diff Diff two snapshots ({prev, next} on stdin)',
|
|
74
|
+
' keys List sorted keys of a snapshot (JSON on stdin)',
|
|
75
|
+
' count Count keys in a snapshot (JSON on stdin)',
|
|
76
|
+
'',
|
|
77
|
+
'Reads from stdin when no arg is given.',
|
|
78
|
+
].join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ok(payload) {
|
|
82
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function fail(code, msg) {
|
|
86
|
+
process.stderr.write(`error: ${msg}\n`);
|
|
87
|
+
process.exit(code);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Convert an arbitrary snapshot to the StateEnvelope shape. The
|
|
92
|
+
* input is whatever the caller passed; we expect a plain object.
|
|
93
|
+
*/
|
|
94
|
+
function envelope(input) {
|
|
95
|
+
const keys = Object.keys(input).sort();
|
|
96
|
+
return { version: 1, keys, values: { ...input } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function main(argv) {
|
|
100
|
+
const sub = argv[0];
|
|
101
|
+
if (!sub || sub === '-h' || sub === '--help') {
|
|
102
|
+
process.stdout.write(usage() + '\n');
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
const stdinPayload = (await readStdin()).trim();
|
|
106
|
+
const argPayload = argv[1];
|
|
107
|
+
|
|
108
|
+
switch (sub) {
|
|
109
|
+
case 'snapshot': {
|
|
110
|
+
const input = parseObject(argPayload ?? stdinPayload);
|
|
111
|
+
// Re-use the library's envelope so the CLI output matches the
|
|
112
|
+
// canonical dumpState shape. (We don't have a real store to
|
|
113
|
+
// pass to dumpState; envelope() builds the same structure.)
|
|
114
|
+
const env = envelope(input);
|
|
115
|
+
process.stdout.write(JSON.stringify(env, null, 2) + '\n');
|
|
116
|
+
// Reference dumpState so it's not tree-shaken — the import
|
|
117
|
+
// is documented as the canonical envelope producer.
|
|
118
|
+
void dumpState;
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case 'validate': {
|
|
123
|
+
const input = parseArray(argPayload ?? stdinPayload);
|
|
124
|
+
const results = input.map((k) => {
|
|
125
|
+
if (typeof k !== 'string') {
|
|
126
|
+
return { key: String(k), ok: false, error: 'must be a string' };
|
|
127
|
+
}
|
|
128
|
+
const err = validateStateKey(k);
|
|
129
|
+
return err === null
|
|
130
|
+
? { key: k, ok: true }
|
|
131
|
+
: { key: k, ok: false, error: err };
|
|
132
|
+
});
|
|
133
|
+
const anyFailed = results.some((r) => !r.ok);
|
|
134
|
+
ok(results);
|
|
135
|
+
return anyFailed ? 3 : 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'diff': {
|
|
139
|
+
const input = parseObject(argPayload ?? stdinPayload);
|
|
140
|
+
if (!('prev' in input) || !('next' in input)) {
|
|
141
|
+
fail(2, 'diff expects {prev, next}');
|
|
142
|
+
}
|
|
143
|
+
const prev = input.prev;
|
|
144
|
+
const next = input.next;
|
|
145
|
+
if (typeof prev !== 'object' || prev === null) fail(2, 'prev must be an object');
|
|
146
|
+
if (typeof next !== 'object' || next === null) fail(2, 'next must be an object');
|
|
147
|
+
const d = diffSnapshots(prev, next);
|
|
148
|
+
ok(d);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case 'keys': {
|
|
153
|
+
const input = parseObject(argPayload ?? stdinPayload);
|
|
154
|
+
ok(Object.keys(input).sort());
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case 'count': {
|
|
159
|
+
const input = parseObject(argPayload ?? stdinPayload);
|
|
160
|
+
ok({ keys: Object.keys(input).length });
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
default:
|
|
165
|
+
process.stderr.write(usage() + '\n');
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseObject(raw) {
|
|
171
|
+
if (raw === '') fail(2, 'expected a JSON object on stdin or as argv[1]');
|
|
172
|
+
try {
|
|
173
|
+
const v = JSON.parse(raw);
|
|
174
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) {
|
|
175
|
+
fail(2, 'expected a JSON object');
|
|
176
|
+
}
|
|
177
|
+
return v;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
fail(2, `JSON parse error: ${err.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseArray(raw) {
|
|
184
|
+
if (raw === '') fail(2, 'expected a JSON array on stdin or as argv[1]');
|
|
185
|
+
try {
|
|
186
|
+
const v = JSON.parse(raw);
|
|
187
|
+
if (!Array.isArray(v)) fail(2, 'expected a JSON array');
|
|
188
|
+
return v;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
fail(2, `JSON parse error: ${err.message}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
main(process.argv.slice(2))
|
|
195
|
+
.then((code) => process.exit(code))
|
|
196
|
+
.catch((err) => fail(2, err.message ?? String(err)));
|