@preset.nz/facets 0.1.2 → 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 CHANGED
@@ -44,8 +44,31 @@ the conventional shadcn path, and your bundler's `@` alias resolves it:
44
44
  | `@/components/ui/checkbox` | `Checkbox` |
45
45
  | `@/components/ui/separator` | `Separator` |
46
46
  | `@/components/ui/select` | `Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem` |
47
-
48
- So you need an `@/*` alias pointing at your `src/`, those five modules present,
47
+ | `@/components/ui/number-field` | `NumberField` |
48
+ | `@/components/ui/slider` | `Slider` |
49
+ | `@/components/ui/color-field` | `ColorField` |
50
+
51
+ The last three are what make a panel feel like the rest of your app: a number
52
+ you drag, a square slider, your colour picker. They are the **minimum** facets
53
+ reads (typed in `dev/ui/` in this repo):
54
+
55
+ - `NumberField`: `value: number`, `onValueChange(value, { reason })`, `min`,
56
+ `max`, `step`, `integer`, `precision`, `label` (the scrub handle: the field
57
+ draws its own label, so the panel adds none), `disabled`, and
58
+ `onScrubStart` / `onScrubEnd` / `onScrubCancel`. Facets uses it for `number`
59
+ and for each slot of `vector`. A vector slot's `suffix` shows in read-only
60
+ mode only, unless the panel sets `labelLayout` (below). Two **optional** props
61
+ serve that: `labelPlacement` (`"inside"` default, `"column"`, `"above"`) and
62
+ `suffix` (a muted unit inside the box). A host that ignores them keeps the
63
+ label inside the box, so nothing doubles.
64
+ - `Slider`: `value: number`, `onValueChange(value)` (a number or an array, facets
65
+ takes the first), `onValueCommitted()`, `min`, `max`, `step`, `disabled`,
66
+ `aria-label`. One thumb.
67
+ - `ColorField`: `value: string | null`, `onChange(value | null)`, `label`,
68
+ `presets`, `disabled`, `onPickStart` / `onPickEnd`. No `onChange` means
69
+ read-only.
70
+
71
+ So you need an `@/*` alias pointing at your `src/`, those eight modules present,
49
72
  and Tailwind with shadcn's theme tokens (`muted-foreground`, `border` and
50
73
  friends are used throughout).
51
74
 
@@ -61,6 +84,18 @@ package in your CSS. From `src/index.css`:
61
84
  Nothing fails when this line is missing. The build succeeds and the panel
62
85
  renders unstyled.
63
86
 
87
+ Tell Vite to resolve one copy of React and of this package, so the package and
88
+ your app share one registry. In `vite.config.ts`:
89
+
90
+ ```ts
91
+ resolve: {
92
+ dedupe: ["react", "react-dom", "@preset.nz/facets"],
93
+ },
94
+ ```
95
+
96
+ Without it, a linked or nested install can register renderers in one copy and
97
+ look them up in another.
98
+
64
99
  The upside is that `facets` inherits whatever primitive library you already
65
100
  chose — Base UI, Radix, or your own — instead of dragging in a second one and
66
101
  making your panels look foreign inside your own app.
@@ -122,7 +157,29 @@ Select a blur and the blur panel appears. Add a new filter type later by
122
157
  registering another scope — the panel does not change.
123
158
 
124
159
  A row holding an array renders as a two-column grid. Everything else is full
125
- width. That is the whole layout system, on purpose.
160
+ width. That is the whole layout system, on purpose, with one switch: where the
161
+ labels go.
162
+
163
+ ### Label layout
164
+
165
+ `labelLayout` on `PropertyPanel` (inspector and card views; collapsed ignores it):
166
+
167
+ | Value | Labels | Numbers |
168
+ |---|---|---|
169
+ | `"auto"` (default) | Above their control; a checkbox's beside it | The label is the scrub handle, inside the box |
170
+ | `"column"` | One shared column to the left, truncated with the full text as a tooltip; controls start on one edge | Label in the column (still the scrub handle), number right-aligned, `suffix` as a muted unit |
171
+ | `"stacked"` | All above their control | Label above the box, `suffix` as a unit |
172
+
173
+ In `"column"` the label column's width is one CSS variable on the panel root
174
+ (`--facets-label-w`), set from the longest label (8ch to 16ch) or from the
175
+ `labelWidth` prop. A vector keeps its axis labels inside its slots, so its
176
+ fields share the column the scalars start on. A paired row (`[a, b]`) draws one
177
+ field per row, and a slider shows its value to the right. A custom renderer
178
+ follows the layout by wrapping its control in `FieldShell` (the built-ins'
179
+ shell; `top` aligns the label with the top of a tall control, `above` keeps it
180
+ above in every layout) and a read-only value in `ReadOnlyText`. For anything
181
+ else, `useLabelLayout()` and `COLUMN_ROW` give the parts. A renderer that draws
182
+ its own label ignores the layout.
126
183
 
127
184
  ---
128
185
 
@@ -148,11 +205,38 @@ Built-ins register through the same call you would use, with no special casing.
148
205
 
149
206
  **`PropertyPanel`** — looks up the scope, calls `read`, walks the groups, hands
150
207
  each field to its renderer. Props: `scopeKey`, `selection`, `ctx`, plus optional
151
- `readOnly` and `emptyState`.
208
+ `readOnly`, `emptyState`, `view`, `title` (see [Views](#views-inspector-card-collapsed)),
209
+ `labelLayout` and `labelWidth` (see [Label layout](#label-layout)).
152
210
 
153
211
  `ctx` is yours. The package treats it as opaque and passes it through to every
154
212
  `read`, `write`, renderer and options provider. Put your app's API in it.
155
213
 
214
+ ### Drags as one undo step
215
+
216
+ A scrubbed number, a dragged slider and an open colour picker produce many
217
+ `write`s. Say where they begin and end and your undo stack can fold them into
218
+ one step: add `gesture` to the scope.
219
+
220
+ ```ts
221
+ registerScope("blur", {
222
+ schema, read, write,
223
+ gesture: {
224
+ begin: (path, sel, ctx) => ctx.beginGesture(sel.id, path),
225
+ end: (path, sel, ctx) => ctx.endGesture(),
226
+ cancel: (path, sel, ctx) => ctx.cancelGesture(), // optional: Escape during a scrub
227
+ },
228
+ })
229
+ ```
230
+
231
+ Every `write` between `begin` and `end` belongs to the gesture (apply them
232
+ live, close one undo step at `end`). `cancel` fires instead of `end` when the
233
+ user abandons a scrub; put the value back. Typed values and arrow-key steps are
234
+ single writes outside any gesture. Omit `gesture` and drags are plain repeated
235
+ `write`s, as before. The renderers receive the same hooks as optional
236
+ `onGestureBegin` / `onGestureEnd` / `onGestureCancel` props, set only when the
237
+ scope declares `gesture`; a custom renderer with a continuous control can call
238
+ them too.
239
+
156
240
  ### How `path` resolves
157
241
 
158
242
  `read()` returns a record, and `path` is a **key into that record** — a flat
@@ -169,11 +253,11 @@ Eleven ship built in: nine that hold a value, and two that only show something.
169
253
  |---|---|---|
170
254
  | `text` | string | `placeholder` |
171
255
  | `textarea` | string | `placeholder`, `rows` |
172
- | `number` | number | `min`, `max`, `step` |
173
- | `slider` | number | same, rendered as a slider |
256
+ | `number` | number | `min`, `max`, `step`, `integer`, `suffix` (a unit, with `labelLayout`); drag the label to scrub (host `NumberField`) |
257
+ | `slider` | number | same, drawn by the host's `Slider` |
174
258
  | `checkbox` | boolean | |
175
259
  | `select` | string | `options`, or `optionsProvider(ctx)` for dynamic lists |
176
- | `color` | hex string | `presets`; shows a swatch |
260
+ | `color` | hex string | `presets`; the host's `ColorField` |
177
261
  | `vector` | number array | N scalars in one row — Houdini's `float3` / `int2`. `components` sets arity and per-slot label, suffix and range |
178
262
  | `file` | `{ name }` | display only |
179
263
  | `separator` | none | a horizontal rule, with `label` as an optional caption. No `path` |
@@ -185,6 +269,94 @@ the group, so it resets when the panel remounts.
185
269
 
186
270
  ---
187
271
 
272
+ ## Views: inspector, card, collapsed
273
+
274
+ One scope draws three ways. `view` on `PropertyPanel` picks one:
275
+
276
+ - **`"inspector"`**, the default: every group and field.
277
+ - **`"card"`**: the fields promoted to the card, with no group chrome. The
278
+ schema's `title` heads it. The panel's `title` prop overrides that, for an
279
+ instance name such as "Filter 2".
280
+ - **`"collapsed"`**: the fields promoted to collapsed, one compact row each.
281
+ This is a scope folded shut. The host draws the title bar that folds it and
282
+ holds the open or closed state.
283
+
284
+ A field lists the views it shows in besides the inspector, the way a Houdini
285
+ asset promotes a parm to its interface:
286
+
287
+ ```ts
288
+ const FILTER: PropertySchema = {
289
+ version: 1,
290
+ title: "Filter",
291
+ groups: [
292
+ {
293
+ id: "filter",
294
+ title: "Filter",
295
+ collapsible: true,
296
+ rows: [
297
+ { kind: "select", id: "type", path: "type", options: TYPES, promote: ["collapsed"] },
298
+ { kind: "slider", id: "master", path: "master", promote: ["card", "collapsed"] },
299
+ { kind: "slider", id: "resonance", path: "resonance" },
300
+ { kind: "slider", id: "pan", path: "pan", promote: ["card"] },
301
+ ],
302
+ },
303
+ ],
304
+ };
305
+ ```
306
+
307
+ ```tsx
308
+ <PropertyPanel scopeKey="filter" selection={node} ctx={ctx} view="card" title={node.name} />
309
+ ```
310
+
311
+ The card shows master and pan. Folded, the scope shows type and master.
312
+ Resonance is inspector-only. The views are independent: `["collapsed"]` is not
313
+ on the card.
314
+
315
+ A closed collapsible group shows its `collapsed` fields under its title, so
316
+ folding a group still leaves its main value in reach.
317
+
318
+ Renderers receive the view as an optional `view` prop. The built-ins draw a
319
+ compact row in `"collapsed"`. A custom renderer may ignore it.
320
+ `promotedFields(schema, view)` returns the fields a view draws, in schema
321
+ order, for a host that wants to skip an empty card.
322
+
323
+ ---
324
+
325
+ ## JSON Schema
326
+
327
+ A schema for editors and agents is published at
328
+ `https://facets.preset.nz/schema/v0.1.json`, and ships in the package as
329
+ `@preset.nz/facets/schema/v0.1.json`. Point a JSON file at it:
330
+
331
+ ```json
332
+ {
333
+ "$schema": "https://facets.preset.nz/schema/v0.1.json",
334
+ "version": 1,
335
+ "groups": []
336
+ }
337
+ ```
338
+
339
+ Built-in kinds are checked strictly: a misspelt prop or a missing `path` is an
340
+ error. Any other kind passes as a custom kind, so a misspelt *kind* is not
341
+ caught here; the panel logs a warning and skips the field. `optionsProvider` is
342
+ code, so it has no JSON form.
343
+
344
+ One file covers every 0.1.x release. Patches may add kinds and props to it,
345
+ never remove or tighten them.
346
+
347
+ ### For agents
348
+
349
+ `https://facets.preset.nz/llms.txt` is the contract in brief. A Claude Code
350
+ plugin with a skill for writing schemas, scopes and custom renderers lives in
351
+ this repo:
352
+
353
+ ```sh
354
+ claude plugin marketplace add preset-nz/facets
355
+ claude plugin install facets@preset-nz-facets
356
+ ```
357
+
358
+ ---
359
+
188
360
  ## Read-only
189
361
 
190
362
  Omit `write`, or pass `readOnly`, and no change handler reaches the renderers.
@@ -222,6 +394,10 @@ registerFieldRenderer("palette-swatches", ({ field, value, onChange, ctx }) => (
222
394
  ));
223
395
  ```
224
396
 
397
+ A renderer receives `field`, `value`, `disabled`, `onChange`, `ctx` and `view`.
398
+ Pick what it uses rather than spreading the props onto a DOM element, where
399
+ React warns about the unknown ones.
400
+
225
401
  A field whose kind is not built in may carry any extra props it likes; the
226
402
  renderer reads what it needs. **The type system will not check those props**, so
227
403
  document them next to the renderer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preset.nz/facets",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Schema-as-data property/inspector panels for React: FieldDef schemas, a scope registry, an open field-renderer registry, and a generic PropertyPanel.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://facets.preset.nz",
@@ -11,10 +11,12 @@
11
11
  "type": "module",
12
12
  "sideEffects": false,
13
13
  "exports": {
14
- ".": "./src/index.ts"
14
+ ".": "./src/index.ts",
15
+ "./schema/v0.1.json": "./schema/v0.1.json"
15
16
  },
16
17
  "files": [
17
- "src"
18
+ "src",
19
+ "schema"
18
20
  ],
19
21
  "publishConfig": {
20
22
  "access": "public"
@@ -24,6 +26,7 @@
24
26
  },
25
27
  "devDependencies": {
26
28
  "@types/react": "^19",
29
+ "ajv": "^8.20.0",
27
30
  "typescript": "^5.8.3"
28
31
  }
29
32
  }