@preset.nz/facets 0.1.1 → 0.1.3

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
@@ -14,6 +14,10 @@ switch statement over your types.
14
14
  Built for image and map editors, where the same panel has to render a layer, a
15
15
  filter, a document or a tool depending on what is selected.
16
16
 
17
+ The playground at [facets.preset.nz](https://facets.preset.nz) shows the field
18
+ kinds, the schema as JSON, and the panel it draws, side by side. Its source is
19
+ in `playground/`.
20
+
17
21
  ---
18
22
 
19
23
  ## Installing
@@ -159,7 +163,7 @@ keep the mapping inside `read`, where your domain types are still in scope.
159
163
 
160
164
  ## Field kinds
161
165
 
162
- Nine ship built in.
166
+ Eleven ship built in: nine that hold a value, and two that only show something.
163
167
 
164
168
  | Kind | Value | Notes |
165
169
  |---|---|---|
@@ -172,6 +176,36 @@ Nine ship built in.
172
176
  | `color` | hex string | `presets`; shows a swatch |
173
177
  | `vector` | number array | N scalars in one row — Houdini's `float3` / `int2`. `components` sets arity and per-slot label, suffix and range |
174
178
  | `file` | `{ name }` | display only |
179
+ | `separator` | none | a horizontal rule, with `label` as an optional caption. No `path` |
180
+ | `label` | none | static text: a hint or a sub-heading. The text is `label`. No `path` |
181
+
182
+ Groups take `collapsible: true` to fold under their title, and
183
+ `defaultCollapsed: true` to start folded. The open or closed state belongs to
184
+ the group, so it resets when the panel remounts.
185
+
186
+ ---
187
+
188
+ ## JSON Schema
189
+
190
+ A schema for editors and agents is published at
191
+ `https://facets.preset.nz/schema/v0.1.json`, and ships in the package as
192
+ `@preset.nz/facets/schema/v0.1.json`. Point a JSON file at it:
193
+
194
+ ```json
195
+ {
196
+ "$schema": "https://facets.preset.nz/schema/v0.1.json",
197
+ "version": 1,
198
+ "groups": []
199
+ }
200
+ ```
201
+
202
+ Built-in kinds are checked strictly: a misspelt prop or a missing `path` is an
203
+ error. Any other kind passes as a custom kind, so a misspelt *kind* is not
204
+ caught here; the panel logs a warning and skips the field. `optionsProvider` is
205
+ code, so it has no JSON form.
206
+
207
+ One file covers every 0.1.x release. Patches may add kinds and props to it,
208
+ never remove or tighten them.
175
209
 
176
210
  ---
177
211
 
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@preset.nz/facets",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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
+ "homepage": "https://facets.preset.nz",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/preset-nz/facets.git"
@@ -10,10 +11,12 @@
10
11
  "type": "module",
11
12
  "sideEffects": false,
12
13
  "exports": {
13
- ".": "./src/index.ts"
14
+ ".": "./src/index.ts",
15
+ "./schema/v0.1.json": "./schema/v0.1.json"
14
16
  },
15
17
  "files": [
16
- "src"
18
+ "src",
19
+ "schema"
17
20
  ],
18
21
  "publishConfig": {
19
22
  "access": "public"
@@ -23,6 +26,7 @@
23
26
  },
24
27
  "devDependencies": {
25
28
  "@types/react": "^19",
29
+ "ajv": "^8.20.0",
26
30
  "typescript": "^5.8.3"
27
31
  }
28
32
  }
@@ -0,0 +1,801 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://facets.preset.nz/schema/v0.1.json",
4
+ "title": "@preset.nz/facets PropertySchema, 0.1",
5
+ "description": "An inspector panel as data: groups of rows of fields. Valid for every 0.1.x release; later 0.1 patches only add kinds and props. Playground: https://facets.preset.nz",
6
+ "type": "object",
7
+ "required": [
8
+ "version",
9
+ "groups"
10
+ ],
11
+ "additionalProperties": false,
12
+ "properties": {
13
+ "$schema": {
14
+ "type": "string",
15
+ "description": "This file's URL, for editors."
16
+ },
17
+ "version": {
18
+ "type": "number",
19
+ "description": "The host's own version of this schema. Bump it when the shape changes."
20
+ },
21
+ "groups": {
22
+ "type": "array",
23
+ "description": "Rendered top to bottom, with a rule between groups.",
24
+ "items": {
25
+ "$ref": "#/definitions/group"
26
+ }
27
+ }
28
+ },
29
+ "definitions": {
30
+ "group": {
31
+ "type": "object",
32
+ "required": [
33
+ "id",
34
+ "rows"
35
+ ],
36
+ "additionalProperties": false,
37
+ "properties": {
38
+ "id": {
39
+ "type": "string",
40
+ "description": "Unique within the schema."
41
+ },
42
+ "title": {
43
+ "type": "string",
44
+ "description": "The heading. Omit it to render the group without one."
45
+ },
46
+ "description": {
47
+ "type": "string",
48
+ "description": "A line under the title."
49
+ },
50
+ "collapsible": {
51
+ "type": "boolean",
52
+ "description": "Fold under the title. Needs a title."
53
+ },
54
+ "defaultCollapsed": {
55
+ "type": "boolean",
56
+ "description": "Start folded."
57
+ },
58
+ "rows": {
59
+ "type": "array",
60
+ "description": "A field is a full-width row; an array of fields is one row laid out as a grid (usually two).",
61
+ "items": {
62
+ "anyOf": [
63
+ {
64
+ "$ref": "#/definitions/field"
65
+ },
66
+ {
67
+ "type": "array",
68
+ "items": {
69
+ "$ref": "#/definitions/field"
70
+ }
71
+ }
72
+ ]
73
+ }
74
+ }
75
+ }
76
+ },
77
+ "disabledWhen": {
78
+ "type": "object",
79
+ "description": "Greys the field out depending on another field's value. Set one operator; if several are set, the first of equals, notEquals, in, notIn wins.",
80
+ "required": [
81
+ "path"
82
+ ],
83
+ "additionalProperties": false,
84
+ "properties": {
85
+ "path": {
86
+ "type": "string",
87
+ "description": "The field value to watch, by path."
88
+ },
89
+ "equals": {
90
+ "description": "Disabled when the value equals this."
91
+ },
92
+ "notEquals": {
93
+ "description": "Disabled unless the value equals this."
94
+ },
95
+ "in": {
96
+ "type": "array",
97
+ "description": "Disabled when the value is one of these."
98
+ },
99
+ "notIn": {
100
+ "type": "array",
101
+ "description": "Disabled unless the value is one of these."
102
+ }
103
+ }
104
+ },
105
+ "selectOption": {
106
+ "type": "object",
107
+ "required": [
108
+ "value",
109
+ "label"
110
+ ],
111
+ "additionalProperties": false,
112
+ "properties": {
113
+ "value": {
114
+ "type": "string",
115
+ "description": "Stored in the value record."
116
+ },
117
+ "label": {
118
+ "type": "string",
119
+ "description": "Shown to the user."
120
+ }
121
+ }
122
+ },
123
+ "vectorComponent": {
124
+ "type": "object",
125
+ "additionalProperties": false,
126
+ "properties": {
127
+ "label": {
128
+ "type": "string",
129
+ "description": "Shown above the slot."
130
+ },
131
+ "suffix": {
132
+ "type": "string",
133
+ "description": "Unit after the number, e.g. px or °."
134
+ },
135
+ "min": {
136
+ "type": "number",
137
+ "description": "Lower bound."
138
+ },
139
+ "max": {
140
+ "type": "number",
141
+ "description": "Upper bound."
142
+ },
143
+ "step": {
144
+ "type": "number",
145
+ "description": "Increment. Default 1."
146
+ }
147
+ }
148
+ },
149
+ "field": {
150
+ "type": "object",
151
+ "description": "One field. kind picks the renderer: a built-in kind is checked strictly; any other kind is a custom kind registered by the host, which may carry extra props.",
152
+ "required": [
153
+ "kind",
154
+ "id"
155
+ ],
156
+ "properties": {
157
+ "kind": {
158
+ "type": "string",
159
+ "description": "The renderer to use. Built in: text, textarea, number, slider, select, color, checkbox, file, vector, separator, label. Anything else must be registered by the host with registerFieldRenderer().",
160
+ "examples": [
161
+ "text",
162
+ "textarea",
163
+ "number",
164
+ "slider",
165
+ "select",
166
+ "color",
167
+ "checkbox",
168
+ "file",
169
+ "vector",
170
+ "separator",
171
+ "label"
172
+ ]
173
+ },
174
+ "id": {
175
+ "type": "string",
176
+ "description": "Unique within the schema. Keys the field in the panel."
177
+ }
178
+ },
179
+ "allOf": [
180
+ {
181
+ "if": {
182
+ "required": [
183
+ "kind"
184
+ ],
185
+ "properties": {
186
+ "kind": {
187
+ "const": "text"
188
+ }
189
+ }
190
+ },
191
+ "then": {
192
+ "$ref": "#/definitions/text"
193
+ }
194
+ },
195
+ {
196
+ "if": {
197
+ "required": [
198
+ "kind"
199
+ ],
200
+ "properties": {
201
+ "kind": {
202
+ "const": "textarea"
203
+ }
204
+ }
205
+ },
206
+ "then": {
207
+ "$ref": "#/definitions/textarea"
208
+ }
209
+ },
210
+ {
211
+ "if": {
212
+ "required": [
213
+ "kind"
214
+ ],
215
+ "properties": {
216
+ "kind": {
217
+ "const": "number"
218
+ }
219
+ }
220
+ },
221
+ "then": {
222
+ "$ref": "#/definitions/number"
223
+ }
224
+ },
225
+ {
226
+ "if": {
227
+ "required": [
228
+ "kind"
229
+ ],
230
+ "properties": {
231
+ "kind": {
232
+ "const": "slider"
233
+ }
234
+ }
235
+ },
236
+ "then": {
237
+ "$ref": "#/definitions/slider"
238
+ }
239
+ },
240
+ {
241
+ "if": {
242
+ "required": [
243
+ "kind"
244
+ ],
245
+ "properties": {
246
+ "kind": {
247
+ "const": "select"
248
+ }
249
+ }
250
+ },
251
+ "then": {
252
+ "$ref": "#/definitions/select"
253
+ }
254
+ },
255
+ {
256
+ "if": {
257
+ "required": [
258
+ "kind"
259
+ ],
260
+ "properties": {
261
+ "kind": {
262
+ "const": "color"
263
+ }
264
+ }
265
+ },
266
+ "then": {
267
+ "$ref": "#/definitions/color"
268
+ }
269
+ },
270
+ {
271
+ "if": {
272
+ "required": [
273
+ "kind"
274
+ ],
275
+ "properties": {
276
+ "kind": {
277
+ "const": "checkbox"
278
+ }
279
+ }
280
+ },
281
+ "then": {
282
+ "$ref": "#/definitions/checkbox"
283
+ }
284
+ },
285
+ {
286
+ "if": {
287
+ "required": [
288
+ "kind"
289
+ ],
290
+ "properties": {
291
+ "kind": {
292
+ "const": "file"
293
+ }
294
+ }
295
+ },
296
+ "then": {
297
+ "$ref": "#/definitions/file"
298
+ }
299
+ },
300
+ {
301
+ "if": {
302
+ "required": [
303
+ "kind"
304
+ ],
305
+ "properties": {
306
+ "kind": {
307
+ "const": "vector"
308
+ }
309
+ }
310
+ },
311
+ "then": {
312
+ "$ref": "#/definitions/vector"
313
+ }
314
+ },
315
+ {
316
+ "if": {
317
+ "required": [
318
+ "kind"
319
+ ],
320
+ "properties": {
321
+ "kind": {
322
+ "const": "separator"
323
+ }
324
+ }
325
+ },
326
+ "then": {
327
+ "$ref": "#/definitions/separator"
328
+ }
329
+ },
330
+ {
331
+ "if": {
332
+ "required": [
333
+ "kind"
334
+ ],
335
+ "properties": {
336
+ "kind": {
337
+ "const": "label"
338
+ }
339
+ }
340
+ },
341
+ "then": {
342
+ "$ref": "#/definitions/label"
343
+ }
344
+ },
345
+ {
346
+ "if": {
347
+ "required": [
348
+ "kind"
349
+ ],
350
+ "properties": {
351
+ "kind": {
352
+ "not": {
353
+ "enum": [
354
+ "text",
355
+ "textarea",
356
+ "number",
357
+ "slider",
358
+ "select",
359
+ "color",
360
+ "checkbox",
361
+ "file",
362
+ "vector",
363
+ "separator",
364
+ "label"
365
+ ]
366
+ }
367
+ }
368
+ }
369
+ },
370
+ "then": {
371
+ "$ref": "#/definitions/custom"
372
+ }
373
+ }
374
+ ]
375
+ },
376
+ "text": {
377
+ "type": "object",
378
+ "description": "A single line of text. Value: string.",
379
+ "required": [
380
+ "kind",
381
+ "id",
382
+ "path"
383
+ ],
384
+ "properties": {
385
+ "kind": {
386
+ "const": "text"
387
+ },
388
+ "id": {
389
+ "type": "string",
390
+ "description": "Unique within the schema. Keys the field in the panel."
391
+ },
392
+ "label": {
393
+ "type": "string",
394
+ "description": "Shown above the field. Defaults to id."
395
+ },
396
+ "path": {
397
+ "type": "string",
398
+ "description": "A key into the record the scope's read() returns. A flat lookup, not a nested traversal: \"transform.x\" is one key."
399
+ },
400
+ "disabledWhen": {
401
+ "$ref": "#/definitions/disabledWhen"
402
+ },
403
+ "placeholder": {
404
+ "type": "string",
405
+ "description": "Shown while empty."
406
+ }
407
+ },
408
+ "additionalProperties": false
409
+ },
410
+ "textarea": {
411
+ "type": "object",
412
+ "description": "Several lines of text. Value: string.",
413
+ "required": [
414
+ "kind",
415
+ "id",
416
+ "path"
417
+ ],
418
+ "properties": {
419
+ "kind": {
420
+ "const": "textarea"
421
+ },
422
+ "id": {
423
+ "type": "string",
424
+ "description": "Unique within the schema. Keys the field in the panel."
425
+ },
426
+ "label": {
427
+ "type": "string",
428
+ "description": "Shown above the field. Defaults to id."
429
+ },
430
+ "path": {
431
+ "type": "string",
432
+ "description": "A key into the record the scope's read() returns. A flat lookup, not a nested traversal: \"transform.x\" is one key."
433
+ },
434
+ "disabledWhen": {
435
+ "$ref": "#/definitions/disabledWhen"
436
+ },
437
+ "placeholder": {
438
+ "type": "string",
439
+ "description": "Shown while empty."
440
+ },
441
+ "rows": {
442
+ "type": "number",
443
+ "description": "Visible lines. Default 3."
444
+ }
445
+ },
446
+ "additionalProperties": false
447
+ },
448
+ "number": {
449
+ "type": "object",
450
+ "description": "A number typed into a box. Value: number.",
451
+ "required": [
452
+ "kind",
453
+ "id",
454
+ "path"
455
+ ],
456
+ "properties": {
457
+ "kind": {
458
+ "const": "number"
459
+ },
460
+ "id": {
461
+ "type": "string",
462
+ "description": "Unique within the schema. Keys the field in the panel."
463
+ },
464
+ "label": {
465
+ "type": "string",
466
+ "description": "Shown above the field. Defaults to id."
467
+ },
468
+ "path": {
469
+ "type": "string",
470
+ "description": "A key into the record the scope's read() returns. A flat lookup, not a nested traversal: \"transform.x\" is one key."
471
+ },
472
+ "disabledWhen": {
473
+ "$ref": "#/definitions/disabledWhen"
474
+ },
475
+ "min": {
476
+ "type": "number",
477
+ "description": "Lower bound."
478
+ },
479
+ "max": {
480
+ "type": "number",
481
+ "description": "Upper bound."
482
+ },
483
+ "step": {
484
+ "type": "number",
485
+ "description": "Increment. Default 1."
486
+ }
487
+ },
488
+ "additionalProperties": false
489
+ },
490
+ "slider": {
491
+ "type": "object",
492
+ "description": "A number dragged along a range. Value: number.",
493
+ "required": [
494
+ "kind",
495
+ "id",
496
+ "path"
497
+ ],
498
+ "properties": {
499
+ "kind": {
500
+ "const": "slider"
501
+ },
502
+ "id": {
503
+ "type": "string",
504
+ "description": "Unique within the schema. Keys the field in the panel."
505
+ },
506
+ "label": {
507
+ "type": "string",
508
+ "description": "Shown above the field. Defaults to id."
509
+ },
510
+ "path": {
511
+ "type": "string",
512
+ "description": "A key into the record the scope's read() returns. A flat lookup, not a nested traversal: \"transform.x\" is one key."
513
+ },
514
+ "disabledWhen": {
515
+ "$ref": "#/definitions/disabledWhen"
516
+ },
517
+ "min": {
518
+ "type": "number",
519
+ "description": "Lower bound."
520
+ },
521
+ "max": {
522
+ "type": "number",
523
+ "description": "Upper bound."
524
+ },
525
+ "step": {
526
+ "type": "number",
527
+ "description": "Increment. Default 1."
528
+ }
529
+ },
530
+ "additionalProperties": false
531
+ },
532
+ "select": {
533
+ "type": "object",
534
+ "description": "One choice from a list. Value: the chosen option's value (string). Options can also come from optionsProvider(ctx), a function, which is set in code and has no JSON form.",
535
+ "required": [
536
+ "kind",
537
+ "id",
538
+ "path"
539
+ ],
540
+ "properties": {
541
+ "kind": {
542
+ "const": "select"
543
+ },
544
+ "id": {
545
+ "type": "string",
546
+ "description": "Unique within the schema. Keys the field in the panel."
547
+ },
548
+ "label": {
549
+ "type": "string",
550
+ "description": "Shown above the field. Defaults to id."
551
+ },
552
+ "path": {
553
+ "type": "string",
554
+ "description": "A key into the record the scope's read() returns. A flat lookup, not a nested traversal: \"transform.x\" is one key."
555
+ },
556
+ "disabledWhen": {
557
+ "$ref": "#/definitions/disabledWhen"
558
+ },
559
+ "options": {
560
+ "type": "array",
561
+ "description": "The choices.",
562
+ "items": {
563
+ "$ref": "#/definitions/selectOption"
564
+ }
565
+ }
566
+ },
567
+ "additionalProperties": false
568
+ },
569
+ "color": {
570
+ "type": "object",
571
+ "description": "A colour as a hex string, e.g. \"#2b7c76\". Value: string.",
572
+ "required": [
573
+ "kind",
574
+ "id",
575
+ "path"
576
+ ],
577
+ "properties": {
578
+ "kind": {
579
+ "const": "color"
580
+ },
581
+ "id": {
582
+ "type": "string",
583
+ "description": "Unique within the schema. Keys the field in the panel."
584
+ },
585
+ "label": {
586
+ "type": "string",
587
+ "description": "Shown above the field. Defaults to id."
588
+ },
589
+ "path": {
590
+ "type": "string",
591
+ "description": "A key into the record the scope's read() returns. A flat lookup, not a nested traversal: \"transform.x\" is one key."
592
+ },
593
+ "disabledWhen": {
594
+ "$ref": "#/definitions/disabledWhen"
595
+ },
596
+ "presets": {
597
+ "type": "array",
598
+ "items": {
599
+ "type": "string"
600
+ },
601
+ "description": "Hex strings. Declared in 0.1; the built-in renderer does not draw them."
602
+ }
603
+ },
604
+ "additionalProperties": false
605
+ },
606
+ "checkbox": {
607
+ "type": "object",
608
+ "description": "On or off. Value: boolean.",
609
+ "required": [
610
+ "kind",
611
+ "id",
612
+ "path"
613
+ ],
614
+ "properties": {
615
+ "kind": {
616
+ "const": "checkbox"
617
+ },
618
+ "id": {
619
+ "type": "string",
620
+ "description": "Unique within the schema. Keys the field in the panel."
621
+ },
622
+ "label": {
623
+ "type": "string",
624
+ "description": "Shown above the field. Defaults to id."
625
+ },
626
+ "path": {
627
+ "type": "string",
628
+ "description": "A key into the record the scope's read() returns. A flat lookup, not a nested traversal: \"transform.x\" is one key."
629
+ },
630
+ "disabledWhen": {
631
+ "$ref": "#/definitions/disabledWhen"
632
+ }
633
+ },
634
+ "additionalProperties": false
635
+ },
636
+ "file": {
637
+ "type": "object",
638
+ "description": "Shows a file's name; always read-only. Value: { \"name\": string }.",
639
+ "required": [
640
+ "kind",
641
+ "id",
642
+ "path"
643
+ ],
644
+ "properties": {
645
+ "kind": {
646
+ "const": "file"
647
+ },
648
+ "id": {
649
+ "type": "string",
650
+ "description": "Unique within the schema. Keys the field in the panel."
651
+ },
652
+ "label": {
653
+ "type": "string",
654
+ "description": "Shown above the field. Defaults to id."
655
+ },
656
+ "path": {
657
+ "type": "string",
658
+ "description": "A key into the record the scope's read() returns. A flat lookup, not a nested traversal: \"transform.x\" is one key."
659
+ },
660
+ "disabledWhen": {
661
+ "$ref": "#/definitions/disabledWhen"
662
+ },
663
+ "accept": {
664
+ "type": "string",
665
+ "description": "File types, as on <input>. Declared in 0.1; the built-in renderer ignores it."
666
+ },
667
+ "helperText": {
668
+ "type": "string",
669
+ "description": "A hint. Declared in 0.1; the built-in renderer does not draw it."
670
+ }
671
+ },
672
+ "additionalProperties": false
673
+ },
674
+ "vector": {
675
+ "type": "object",
676
+ "description": "Several numbers in one row: position, size, RGB, LCh. Value: number[], one per component.",
677
+ "required": [
678
+ "kind",
679
+ "id",
680
+ "path",
681
+ "components"
682
+ ],
683
+ "properties": {
684
+ "kind": {
685
+ "const": "vector"
686
+ },
687
+ "id": {
688
+ "type": "string",
689
+ "description": "Unique within the schema. Keys the field in the panel."
690
+ },
691
+ "label": {
692
+ "type": "string",
693
+ "description": "Shown above the field. Defaults to id."
694
+ },
695
+ "path": {
696
+ "type": "string",
697
+ "description": "A key into the record the scope's read() returns. A flat lookup, not a nested traversal: \"transform.x\" is one key."
698
+ },
699
+ "disabledWhen": {
700
+ "$ref": "#/definitions/disabledWhen"
701
+ },
702
+ "components": {
703
+ "type": "array",
704
+ "description": "One entry per slot; sets the arity.",
705
+ "items": {
706
+ "$ref": "#/definitions/vectorComponent"
707
+ }
708
+ },
709
+ "integer": {
710
+ "type": "boolean",
711
+ "description": "Truncate to whole numbers."
712
+ },
713
+ "precision": {
714
+ "type": "number",
715
+ "description": "Decimals shown when read-only."
716
+ }
717
+ },
718
+ "additionalProperties": false
719
+ },
720
+ "separator": {
721
+ "type": "object",
722
+ "description": "A horizontal rule between rows, with label as an optional caption. Holds no value, so it has no path.",
723
+ "required": [
724
+ "kind",
725
+ "id"
726
+ ],
727
+ "properties": {
728
+ "kind": {
729
+ "const": "separator"
730
+ },
731
+ "id": {
732
+ "type": "string",
733
+ "description": "Unique within the schema. Keys the field in the panel."
734
+ },
735
+ "label": {
736
+ "type": "string",
737
+ "description": "Shown above the field. Defaults to id."
738
+ },
739
+ "disabledWhen": {
740
+ "$ref": "#/definitions/disabledWhen"
741
+ }
742
+ },
743
+ "additionalProperties": false
744
+ },
745
+ "label": {
746
+ "type": "object",
747
+ "description": "Static text: a hint, a note, a sub-heading. The text is label. Holds no value, so it has no path.",
748
+ "required": [
749
+ "kind",
750
+ "id",
751
+ "label"
752
+ ],
753
+ "properties": {
754
+ "kind": {
755
+ "const": "label"
756
+ },
757
+ "id": {
758
+ "type": "string",
759
+ "description": "Unique within the schema. Keys the field in the panel."
760
+ },
761
+ "label": {
762
+ "type": "string",
763
+ "description": "Shown above the field. Defaults to id."
764
+ },
765
+ "disabledWhen": {
766
+ "$ref": "#/definitions/disabledWhen"
767
+ }
768
+ },
769
+ "additionalProperties": false
770
+ },
771
+ "custom": {
772
+ "type": "object",
773
+ "description": "A custom kind. The host's renderer decides which extra props it reads.",
774
+ "required": [
775
+ "kind",
776
+ "id",
777
+ "path"
778
+ ],
779
+ "properties": {
780
+ "kind": {
781
+ "type": "string"
782
+ },
783
+ "id": {
784
+ "type": "string",
785
+ "description": "Unique within the schema. Keys the field in the panel."
786
+ },
787
+ "label": {
788
+ "type": "string",
789
+ "description": "Shown above the field. Defaults to id."
790
+ },
791
+ "path": {
792
+ "type": "string",
793
+ "description": "A key into the record the scope's read() returns. A flat lookup, not a nested traversal: \"transform.x\" is one key."
794
+ },
795
+ "disabledWhen": {
796
+ "$ref": "#/definitions/disabledWhen"
797
+ }
798
+ }
799
+ }
800
+ }
801
+ }
@@ -14,6 +14,7 @@ import {
14
14
  SelectValue,
15
15
  } from "@/components/ui/select"
16
16
  import { Checkbox } from "@/components/ui/checkbox"
17
+ import { Separator } from "@/components/ui/separator"
17
18
  import { registerFieldRenderer } from "./registry"
18
19
  import type {
19
20
  CheckboxFieldDef,
@@ -26,6 +27,8 @@ import type {
26
27
  TextFieldDef,
27
28
  TextareaFieldDef,
28
29
  FileFieldDef,
30
+ LabelFieldDef,
31
+ SeparatorFieldDef,
29
32
  VectorFieldDef,
30
33
  } from "./types"
31
34
 
@@ -226,7 +229,9 @@ const SelectRenderer: FieldRenderer<SelectFieldDef> = ({
226
229
  disabled={disabled}
227
230
  >
228
231
  <SelectTrigger>
229
- <SelectValue />
232
+ {/* Children, not the primitive's own lookup: Base UI's Value shows
233
+ the raw value unless it's told the label. */}
234
+ <SelectValue>{matched ? matched.label : current}</SelectValue>
230
235
  </SelectTrigger>
231
236
  <SelectContent>
232
237
  {options.map((o) => (
@@ -425,7 +430,39 @@ const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
425
430
  )
426
431
  }
427
432
 
433
+ const SeparatorRenderer: FieldRenderer<SeparatorFieldDef> = ({ field }) =>
434
+ field.label ? (
435
+ <div className="my-1 flex items-center gap-2">
436
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
437
+ {field.label}
438
+ </span>
439
+ <Separator className="flex-1" />
440
+ </div>
441
+ ) : (
442
+ <Separator className="my-1" />
443
+ )
444
+
445
+ // The same in every mode: there is nothing to edit. Greys out under
446
+ // disabledWhen so a hint can follow the field it describes.
447
+ const LabelRenderer: FieldRenderer<LabelFieldDef> = ({ field, disabled }) => (
448
+ <p
449
+ className={
450
+ "text-xs text-muted-foreground" + (disabled ? " opacity-50" : "")
451
+ }
452
+ >
453
+ {field.label}
454
+ </p>
455
+ )
456
+
428
457
  export function registerBuiltinRenderers(): void {
458
+ registerFieldRenderer(
459
+ "separator",
460
+ SeparatorRenderer as FieldRenderer<import("./types").FieldDef>,
461
+ )
462
+ registerFieldRenderer(
463
+ "label",
464
+ LabelRenderer as FieldRenderer<import("./types").FieldDef>,
465
+ )
429
466
  registerFieldRenderer(
430
467
  "text",
431
468
  TextRenderer as FieldRenderer<import("./types").FieldDef>,
package/src/panel.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useMemo } from "react"
1
+ import { useId, useMemo, useState } from "react"
2
2
  import { Separator } from "@/components/ui/separator"
3
3
  import { getFieldRenderer, getScope } from "./registry"
4
4
  import type {
@@ -69,23 +69,54 @@ function PropertyGroup({
69
69
  onChange?: (path: string, val: unknown) => void
70
70
  isLast: boolean
71
71
  }) {
72
+ // Collapsing needs a title to click. Open or closed is the group's own
73
+ // state; a host that wants it remembered across selections keys the panel.
74
+ const collapsible = Boolean(group.collapsible && group.title)
75
+ const [collapsed, setCollapsed] = useState(
76
+ collapsible && Boolean(group.defaultCollapsed),
77
+ )
78
+ const bodyId = useId()
79
+ const titleClass =
80
+ "text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
81
+
72
82
  return (
73
83
  <section className="flex flex-col gap-3 px-3 py-3">
74
84
  {(group.title || group.description) && (
75
85
  <header>
76
- {group.title && (
77
- <h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
78
- {group.title}
79
- </h3>
80
- )}
81
- {group.description && (
86
+ {group.title &&
87
+ (collapsible ? (
88
+ <h3 className={titleClass}>
89
+ <button
90
+ type="button"
91
+ aria-expanded={!collapsed}
92
+ aria-controls={bodyId}
93
+ onClick={() => setCollapsed((c) => !c)}
94
+ className="-mx-1 flex w-[calc(100%+0.5rem)] items-center gap-1 px-1 text-left uppercase hover:text-foreground"
95
+ >
96
+ <svg
97
+ aria-hidden
98
+ viewBox="0 0 16 16"
99
+ className={
100
+ "size-3 shrink-0 transition-transform " +
101
+ (collapsed ? "-rotate-90" : "")
102
+ }
103
+ >
104
+ <path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
105
+ </svg>
106
+ {group.title}
107
+ </button>
108
+ </h3>
109
+ ) : (
110
+ <h3 className={titleClass}>{group.title}</h3>
111
+ ))}
112
+ {group.description && !(collapsible && collapsed) && (
82
113
  <p className="mt-1 text-xs text-muted-foreground">
83
114
  {group.description}
84
115
  </p>
85
116
  )}
86
117
  </header>
87
118
  )}
88
- <div className="flex flex-col gap-3">
119
+ <div id={bodyId} hidden={collapsible && collapsed} className="flex flex-col gap-3">
89
120
  {group.rows.map((row, idx) => {
90
121
  const fields = Array.isArray(row) ? row : [row]
91
122
  return (
@@ -142,11 +173,14 @@ function FieldSlot({
142
173
  return false
143
174
  })()
144
175
 
176
+ // Display-only kinds (separator, label) have no path: no value, no writes.
177
+ const path = "path" in field ? field.path : undefined
145
178
  return renderer({
146
179
  field,
147
- value: values[field.path],
180
+ value: path === undefined ? undefined : values[path],
148
181
  disabled,
149
- onChange: onChange ? (val) => onChange(field.path, val) : undefined,
182
+ onChange:
183
+ onChange && path !== undefined ? (val) => onChange(path, val) : undefined,
150
184
  ctx,
151
185
  })
152
186
  }
package/src/types.ts CHANGED
@@ -9,6 +9,9 @@ export type BuiltinFieldKind =
9
9
  | "color"
10
10
  | "checkbox"
11
11
  | "file"
12
+ | "vector"
13
+ | "separator"
14
+ | "label"
12
15
 
13
16
  export interface BaseField {
14
17
  kind: string
@@ -109,6 +112,28 @@ export interface FileFieldDef extends BaseField {
109
112
  helperText?: string
110
113
  }
111
114
 
115
+ // Display-only rows: they show something but hold no value, so they have no
116
+ // `path`. Houdini's Separator and Label parameter types.
117
+
118
+ /**
119
+ * A horizontal rule between rows, with an optional caption. Every kind keeps
120
+ * `label` so hosts can read `field.label` on any FieldDef without narrowing.
121
+ */
122
+ export interface SeparatorFieldDef {
123
+ kind: "separator"
124
+ id: string
125
+ label?: string
126
+ disabledWhen?: DisabledWhen
127
+ }
128
+
129
+ /** Static text: a hint, a note, a sub-heading. The text is `label`. */
130
+ export interface LabelFieldDef {
131
+ kind: "label"
132
+ id: string
133
+ label: string
134
+ disabledWhen?: DisabledWhen
135
+ }
136
+
112
137
  export type BuiltinFieldDef =
113
138
  | SelectFieldDef
114
139
  | ColorFieldDef
@@ -119,6 +144,8 @@ export type BuiltinFieldDef =
119
144
  | CheckboxFieldDef
120
145
  | FileFieldDef
121
146
  | VectorFieldDef
147
+ | SeparatorFieldDef
148
+ | LabelFieldDef
122
149
 
123
150
  export type FieldDef = BuiltinFieldDef | CustomFieldDef
124
151