@preset.nz/facets 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 preset.nz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # @preset.nz/facets
2
+
3
+ Inspector panels for React, defined as data.
4
+
5
+ You describe a panel as a plain object — groups of fields, each with a kind and a
6
+ path. `facets` looks up a renderer per kind, reads the values, and draws the
7
+ panel. The panel itself knows nothing about your domain, so it never grows a
8
+ switch statement over your types.
9
+
10
+ ```tsx
11
+ <PropertyPanel scopeKey={selection.kind} selection={selection} ctx={ctx} />
12
+ ```
13
+
14
+ Built for image and map editors, where the same panel has to render a layer, a
15
+ filter, a document or a tool depending on what is selected.
16
+
17
+ ---
18
+
19
+ ## Installing
20
+
21
+ ```sh
22
+ pnpm add @preset.nz/facets
23
+ ```
24
+
25
+ React 19 is a peer dependency.
26
+
27
+ **This package ships unbuilt TypeScript.** There is no compiled output and no
28
+ build step — your bundler and `tsc` compile it along with your own source. That
29
+ is deliberate, and it is what makes the next section possible.
30
+
31
+ ### You supply the primitives
32
+
33
+ The built-in renderers do not implement form controls. They import yours, from
34
+ the conventional shadcn path, and your bundler's `@` alias resolves it:
35
+
36
+ | Import | Must export |
37
+ |---|---|
38
+ | `@/components/ui/input` | `Input` |
39
+ | `@/components/ui/label` | `Label` |
40
+ | `@/components/ui/checkbox` | `Checkbox` |
41
+ | `@/components/ui/separator` | `Separator` |
42
+ | `@/components/ui/select` | `Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem` |
43
+
44
+ So you need an `@/*` alias pointing at your `src/`, those five modules present,
45
+ and Tailwind with shadcn's theme tokens (`muted-foreground`, `border` and
46
+ friends are used throughout).
47
+
48
+ The upside is that `facets` inherits whatever primitive library you already
49
+ chose — Base UI, Radix, or your own — instead of dragging in a second one and
50
+ making your panels look foreign inside your own app.
51
+
52
+ ---
53
+
54
+ ## A worked example
55
+
56
+ Register the built-in renderers once at startup, then register a scope per kind
57
+ of thing you can select.
58
+
59
+ ```tsx
60
+ import {
61
+ PropertyPanel,
62
+ registerBuiltinRenderers,
63
+ registerScope,
64
+ type PropertySchema,
65
+ } from "@preset.nz/facets";
66
+
67
+ registerBuiltinRenderers();
68
+
69
+ const BLUR: PropertySchema = {
70
+ version: 1,
71
+ groups: [
72
+ {
73
+ id: "blur",
74
+ title: "Gaussian blur",
75
+ rows: [
76
+ { kind: "slider", id: "radius", label: "Radius", path: "radius", min: 0, max: 64, step: 1 },
77
+ { kind: "checkbox", id: "clampEdges", label: "Clamp edges", path: "clampEdges" },
78
+ [
79
+ { kind: "color", id: "tint", label: "Tint", path: "tint" },
80
+ { kind: "number", id: "amount", label: "Amount", path: "amount", min: 0, max: 1, step: 0.05 },
81
+ ],
82
+ ],
83
+ },
84
+ ],
85
+ };
86
+
87
+ registerScope("blur", {
88
+ schema: BLUR,
89
+ read: (sel: { id: string }, ctx) => ctx.getFilter(sel.id).params,
90
+ write: (path, value, sel, ctx) => ctx.setFilterParam(sel.id, path, value),
91
+ });
92
+ ```
93
+
94
+ Then mount one panel for everything:
95
+
96
+ ```tsx
97
+ <PropertyPanel
98
+ scopeKey={selection.kind}
99
+ selection={selection}
100
+ ctx={appApi}
101
+ emptyState={<p>Nothing selected</p>}
102
+ />
103
+ ```
104
+
105
+ Select a blur and the blur panel appears. Add a new filter type later by
106
+ registering another scope — the panel does not change.
107
+
108
+ A row holding an array renders as a two-column grid. Everything else is full
109
+ width. That is the whole layout system, on purpose.
110
+
111
+ ---
112
+
113
+ ## The four pieces
114
+
115
+ **`FieldDef`** — one field, as data. `kind` picks the renderer, `path` picks the
116
+ value, `label` defaults to `id`.
117
+
118
+ **`Scope`** — a schema bundled with its own `read` and, optionally, `write`.
119
+ This is the important one. The panel never learns how to fetch or store your
120
+ values; each scope brings its own. Omit `write` and the scope is read-only.
121
+
122
+ ```ts
123
+ interface Scope<S, V> {
124
+ schema: PropertySchema;
125
+ read: (selection: S, ctx: ScopeContext) => V;
126
+ write?: (path: string, value: unknown, selection: S, ctx: ScopeContext) => void;
127
+ }
128
+ ```
129
+
130
+ **The renderer registry** — `kind` to React component, open for extension.
131
+ Built-ins register through the same call you would use, with no special casing.
132
+
133
+ **`PropertyPanel`** — looks up the scope, calls `read`, walks the groups, hands
134
+ each field to its renderer. Props: `scopeKey`, `selection`, `ctx`, plus optional
135
+ `readOnly` and `emptyState`.
136
+
137
+ `ctx` is yours. The package treats it as opaque and passes it through to every
138
+ `read`, `write`, renderer and options provider. Put your app's API in it.
139
+
140
+ ### How `path` resolves
141
+
142
+ `read()` returns a record, and `path` is a **key into that record** — a flat
143
+ lookup, not a nested traversal. Return a flat object shaped for the panel and
144
+ keep the mapping inside `read`, where your domain types are still in scope.
145
+
146
+ ---
147
+
148
+ ## Field kinds
149
+
150
+ Nine ship built in.
151
+
152
+ | Kind | Value | Notes |
153
+ |---|---|---|
154
+ | `text` | string | `placeholder` |
155
+ | `textarea` | string | `placeholder`, `rows` |
156
+ | `number` | number | `min`, `max`, `step` |
157
+ | `slider` | number | same, rendered as a slider |
158
+ | `checkbox` | boolean | |
159
+ | `select` | string | `options`, or `optionsProvider(ctx)` for dynamic lists |
160
+ | `color` | hex string | `presets`; shows a swatch |
161
+ | `vector` | number array | N scalars in one row — Houdini's `float3` / `int2`. `components` sets arity and per-slot label, suffix and range |
162
+ | `file` | `{ name }` | display only |
163
+
164
+ ---
165
+
166
+ ## Read-only
167
+
168
+ Omit `write`, or pass `readOnly`, and no change handler reaches the renderers.
169
+ They then render the **formatted value as text**, not a disabled input. A
170
+ disabled `<input value="1024">` reads as broken; a plain `1024` reads as
171
+ information. Empty values render as an em dash.
172
+
173
+ Disabled controls are reserved for `disabledWhen`, which is a condition inside
174
+ an otherwise editable panel:
175
+
176
+ ```ts
177
+ { kind: "number", id: "radius", path: "radius",
178
+ disabledWhen: { path: "mode", equals: "auto" } }
179
+ ```
180
+
181
+ The condition reads one other field's value. Set one operator:
182
+
183
+ - `equals` / `notEquals` — compare against a single value.
184
+ - `in` / `notIn` — compare against a list, e.g. a param only some enum choices
185
+ use: `disabledWhen: { path: "pattern", notIn: ["dashes", "stipple"] }`.
186
+
187
+ If several are set, the first in that order wins.
188
+
189
+ ---
190
+
191
+ ## Custom field kinds
192
+
193
+ Register any `kind` string and the panel will route to it. This is how a panel
194
+ grows a palette strip, a histogram or a segmentation preview without the package
195
+ knowing those exist.
196
+
197
+ ```tsx
198
+ registerFieldRenderer("palette-swatches", ({ field, value, onChange, ctx }) => (
199
+ <SwatchStrip colours={value as string[]} onPick={onChange} />
200
+ ));
201
+ ```
202
+
203
+ A field whose kind is not built in may carry any extra props it likes; the
204
+ renderer reads what it needs. **The type system will not check those props**, so
205
+ document them next to the renderer.
206
+
207
+ An unregistered kind logs a warning and renders nothing. It does not throw, and
208
+ it does not take the rest of the panel down with it.
209
+
210
+ ---
211
+
212
+ ## Notes
213
+
214
+ - **Registration is explicit.** `registerBuiltinRenderers()` is a call, not an
215
+ import side effect, and the package declares `sideEffects: false`. Call it
216
+ before rendering a panel.
217
+ - **Failures stay local.** A throwing `read` is caught and logged; an unknown
218
+ scope renders `emptyState`; an unknown kind skips that field.
219
+ - **Schemas are serialisable.** Keep them that way — no closures in a
220
+ `FieldDef` beyond `optionsProvider`. It keeps schemas versionable and lets them
221
+ cross a plugin boundary later.
222
+
223
+ ## Licence
224
+
225
+ MIT.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@preset.nz/facets",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/preset-nz/facets.git"
9
+ },
10
+ "type": "module",
11
+ "sideEffects": false,
12
+ "exports": {
13
+ ".": "./src/index.ts"
14
+ },
15
+ "files": [
16
+ "src"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "peerDependencies": {
22
+ "react": "^19"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "^19",
26
+ "typescript": "^5.8.3"
27
+ }
28
+ }
@@ -0,0 +1,470 @@
1
+ /* eslint-disable react-refresh/only-export-components --
2
+ * This module ships the built-in field renderers as small co-located
3
+ * components plus a single `registerBuiltinRenderers` entry point. Splitting
4
+ * each renderer into its own file would be over-organisation for code that
5
+ * never hot-reloads in isolation. */
6
+ import { useMemo } from "react"
7
+ import { Input } from "@/components/ui/input"
8
+ import { Label } from "@/components/ui/label"
9
+ import {
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ } from "@/components/ui/select"
16
+ import { Checkbox } from "@/components/ui/checkbox"
17
+ import { registerFieldRenderer } from "./registry"
18
+ import type {
19
+ CheckboxFieldDef,
20
+ ColorFieldDef,
21
+ FieldRenderer,
22
+ FieldRendererProps,
23
+ NumberFieldDef,
24
+ SelectFieldDef,
25
+ SliderFieldDef,
26
+ TextFieldDef,
27
+ TextareaFieldDef,
28
+ FileFieldDef,
29
+ VectorFieldDef,
30
+ } from "./types"
31
+
32
+ function ReadOnlyText({ children }: { children: React.ReactNode }) {
33
+ return (
34
+ <div className="min-h-8 px-0 py-1 text-xs text-foreground tabular-nums break-all">
35
+ {children}
36
+ </div>
37
+ )
38
+ }
39
+
40
+ function Empty() {
41
+ return <span className="text-muted-foreground">—</span>
42
+ }
43
+
44
+ function FieldShell({
45
+ label,
46
+ children,
47
+ }: {
48
+ label?: string
49
+ children: React.ReactNode
50
+ }) {
51
+ return (
52
+ <div className="flex flex-col gap-1">
53
+ {label && (
54
+ <Label className="text-[11px] font-medium text-muted-foreground tracking-wide">
55
+ {label}
56
+ </Label>
57
+ )}
58
+ {children}
59
+ </div>
60
+ )
61
+ }
62
+
63
+ function formatPrimitive(value: unknown): string {
64
+ if (value == null || value === "") return ""
65
+ if (typeof value === "boolean") return value ? "Yes" : "No"
66
+ if (value instanceof Date) return value.toISOString()
67
+ if (typeof value === "object") return JSON.stringify(value)
68
+ return String(value)
69
+ }
70
+
71
+ const TextRenderer: FieldRenderer<TextFieldDef> = ({
72
+ field,
73
+ value,
74
+ disabled,
75
+ onChange,
76
+ }) => {
77
+ const display = formatPrimitive(value)
78
+ return (
79
+ <FieldShell label={field.label ?? field.id}>
80
+ {onChange ? (
81
+ <Input
82
+ aria-label={field.label ?? field.id}
83
+ value={display}
84
+ placeholder={field.placeholder}
85
+ disabled={disabled}
86
+ onChange={(e) => onChange(e.target.value)}
87
+ />
88
+ ) : display ? (
89
+ <ReadOnlyText>{display}</ReadOnlyText>
90
+ ) : (
91
+ <ReadOnlyText>
92
+ <Empty />
93
+ </ReadOnlyText>
94
+ )}
95
+ </FieldShell>
96
+ )
97
+ }
98
+
99
+ const TextareaRenderer: FieldRenderer<TextareaFieldDef> = ({
100
+ field,
101
+ value,
102
+ disabled,
103
+ onChange,
104
+ }) => {
105
+ const display = formatPrimitive(value)
106
+ return (
107
+ <FieldShell label={field.label ?? field.id}>
108
+ {onChange ? (
109
+ <textarea
110
+ aria-label={field.label ?? field.id}
111
+ value={display}
112
+ rows={field.rows ?? 3}
113
+ disabled={disabled}
114
+ placeholder={field.placeholder}
115
+ onChange={(e) => onChange(e.target.value)}
116
+ className="w-full rounded-none border border-input bg-transparent px-2.5 py-1 text-xs outline-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:opacity-50"
117
+ />
118
+ ) : display ? (
119
+ <ReadOnlyText>
120
+ <span className="whitespace-pre-wrap">{display}</span>
121
+ </ReadOnlyText>
122
+ ) : (
123
+ <ReadOnlyText>
124
+ <Empty />
125
+ </ReadOnlyText>
126
+ )}
127
+ </FieldShell>
128
+ )
129
+ }
130
+
131
+ const NumberRenderer: FieldRenderer<NumberFieldDef> = ({
132
+ field,
133
+ value,
134
+ disabled,
135
+ onChange,
136
+ }) => {
137
+ const num =
138
+ typeof value === "number"
139
+ ? value
140
+ : value != null && value !== ""
141
+ ? Number(value)
142
+ : null
143
+ const display =
144
+ num != null && Number.isFinite(num) ? num.toLocaleString() : ""
145
+ return (
146
+ <FieldShell label={field.label ?? field.id}>
147
+ {onChange ? (
148
+ <Input
149
+ type="number"
150
+ aria-label={field.label ?? field.id}
151
+ value={num != null && Number.isFinite(num) ? String(num) : ""}
152
+ min={field.min}
153
+ max={field.max}
154
+ step={field.step ?? 1}
155
+ disabled={disabled}
156
+ onChange={(e) => onChange(Number(e.target.value))}
157
+ />
158
+ ) : display ? (
159
+ <ReadOnlyText>{display}</ReadOnlyText>
160
+ ) : (
161
+ <ReadOnlyText>
162
+ <Empty />
163
+ </ReadOnlyText>
164
+ )}
165
+ </FieldShell>
166
+ )
167
+ }
168
+
169
+ const SliderRenderer: FieldRenderer<SliderFieldDef> = ({
170
+ field,
171
+ value,
172
+ disabled,
173
+ onChange,
174
+ }) => {
175
+ const num = typeof value === "number" ? value : Number(value ?? 0)
176
+ return (
177
+ <FieldShell label={field.label ?? field.id}>
178
+ {onChange ? (
179
+ <input
180
+ type="range"
181
+ aria-label={field.label ?? field.id}
182
+ value={Number.isFinite(num) ? num : (field.min ?? 0)}
183
+ min={field.min}
184
+ max={field.max}
185
+ step={field.step ?? 1}
186
+ disabled={disabled}
187
+ onChange={(e) => onChange(Number(e.target.value))}
188
+ className="w-full"
189
+ />
190
+ ) : (
191
+ <ReadOnlyText>
192
+ {Number.isFinite(num) ? num.toLocaleString() : <Empty />}
193
+ </ReadOnlyText>
194
+ )}
195
+ </FieldShell>
196
+ )
197
+ }
198
+
199
+ const SelectRenderer: FieldRenderer<SelectFieldDef> = ({
200
+ field,
201
+ value,
202
+ disabled,
203
+ onChange,
204
+ ctx,
205
+ }) => {
206
+ const options = useMemo(
207
+ () => (field.optionsProvider ? field.optionsProvider(ctx) : (field.options ?? [])),
208
+ [field, ctx],
209
+ )
210
+ const current = value == null ? "" : String(value)
211
+ const matched = options.find((o) => o.value === current)
212
+ if (!onChange) {
213
+ return (
214
+ <FieldShell label={field.label ?? field.id}>
215
+ <ReadOnlyText>
216
+ {matched ? matched.label : current ? current : <Empty />}
217
+ </ReadOnlyText>
218
+ </FieldShell>
219
+ )
220
+ }
221
+ return (
222
+ <FieldShell label={field.label ?? field.id}>
223
+ <Select
224
+ value={current}
225
+ onValueChange={(v) => onChange(v)}
226
+ disabled={disabled}
227
+ >
228
+ <SelectTrigger>
229
+ <SelectValue />
230
+ </SelectTrigger>
231
+ <SelectContent>
232
+ {options.map((o) => (
233
+ <SelectItem key={o.value} value={o.value}>
234
+ {o.label}
235
+ </SelectItem>
236
+ ))}
237
+ </SelectContent>
238
+ </Select>
239
+ </FieldShell>
240
+ )
241
+ }
242
+
243
+ const ColorRenderer: FieldRenderer<ColorFieldDef> = ({
244
+ field,
245
+ value,
246
+ disabled,
247
+ onChange,
248
+ }) => {
249
+ const hex = typeof value === "string" && value.length > 0 ? value : null
250
+ return (
251
+ <FieldShell label={field.label ?? field.id}>
252
+ <div className="flex items-center gap-2">
253
+ {hex ? (
254
+ <span
255
+ aria-hidden
256
+ className="inline-block size-4 shrink-0 border border-border"
257
+ style={{ backgroundColor: hex }}
258
+ />
259
+ ) : (
260
+ <span className="inline-block size-4 shrink-0 border border-dashed border-border" />
261
+ )}
262
+ {onChange ? (
263
+ <Input
264
+ type="text"
265
+ aria-label={field.label ?? field.id}
266
+ value={hex ?? ""}
267
+ disabled={disabled}
268
+ onChange={(e) => onChange(e.target.value)}
269
+ className="font-mono"
270
+ />
271
+ ) : (
272
+ <ReadOnlyText>
273
+ <span className="font-mono">{hex ?? <Empty />}</span>
274
+ </ReadOnlyText>
275
+ )}
276
+ </div>
277
+ </FieldShell>
278
+ )
279
+ }
280
+
281
+ const CheckboxRenderer: FieldRenderer<CheckboxFieldDef> = ({
282
+ field,
283
+ value,
284
+ disabled,
285
+ onChange,
286
+ }) => {
287
+ const checked = Boolean(value)
288
+ if (!onChange) {
289
+ return (
290
+ <FieldShell label={field.label ?? field.id}>
291
+ <ReadOnlyText>{checked ? "Yes" : "No"}</ReadOnlyText>
292
+ </FieldShell>
293
+ )
294
+ }
295
+ return (
296
+ <div className="flex items-center gap-2">
297
+ <Checkbox
298
+ checked={checked}
299
+ disabled={disabled}
300
+ onCheckedChange={(next) => onChange(Boolean(next))}
301
+ />
302
+ {field.label && (
303
+ <Label className="text-xs text-foreground">{field.label}</Label>
304
+ )}
305
+ </div>
306
+ )
307
+ }
308
+
309
+ const FileRenderer: FieldRenderer<FileFieldDef> = ({
310
+ field,
311
+ value,
312
+ }) => {
313
+ const asset = value as { name?: string } | null | undefined
314
+ return (
315
+ <FieldShell label={field.label ?? field.id}>
316
+ <ReadOnlyText>
317
+ {asset?.name ? asset.name : <Empty />}
318
+ </ReadOnlyText>
319
+ </FieldShell>
320
+ )
321
+ }
322
+
323
+ function toNumberArray(value: unknown, arity: number): Array<number | null> {
324
+ if (!Array.isArray(value)) return Array.from({ length: arity }, () => null)
325
+ return Array.from({ length: arity }, (_, i) => {
326
+ const v = value[i]
327
+ if (v == null || v === "") return null
328
+ const n = Number(v)
329
+ return Number.isFinite(n) ? n : null
330
+ })
331
+ }
332
+
333
+ function formatComponent(
334
+ n: number | null,
335
+ integer: boolean,
336
+ precision?: number,
337
+ ): string {
338
+ if (n == null) return ""
339
+ if (integer) return Math.trunc(n).toLocaleString()
340
+ if (precision != null) return n.toFixed(precision)
341
+ return n.toLocaleString()
342
+ }
343
+
344
+ const VectorRenderer: FieldRenderer<VectorFieldDef> = ({
345
+ field,
346
+ value,
347
+ disabled,
348
+ onChange,
349
+ }) => {
350
+ const arity = field.components.length
351
+ const values = toNumberArray(value, arity)
352
+ const isInt = Boolean(field.integer)
353
+
354
+ if (!onChange) {
355
+ return (
356
+ <FieldShell label={field.label ?? field.id}>
357
+ <div
358
+ className="grid gap-x-3 gap-y-1"
359
+ style={{ gridTemplateColumns: `repeat(${arity}, minmax(0, 1fr))` }}
360
+ >
361
+ {field.components.map((c, i) => (
362
+ <div key={i} className="flex flex-col gap-0.5 min-w-0">
363
+ {c.label && (
364
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
365
+ {c.label}
366
+ </span>
367
+ )}
368
+ <div className="text-xs text-foreground tabular-nums truncate">
369
+ {values[i] == null ? (
370
+ <Empty />
371
+ ) : (
372
+ <>
373
+ {formatComponent(values[i], isInt, field.precision)}
374
+ {c.suffix && (
375
+ <span className="text-muted-foreground">{c.suffix}</span>
376
+ )}
377
+ </>
378
+ )}
379
+ </div>
380
+ </div>
381
+ ))}
382
+ </div>
383
+ </FieldShell>
384
+ )
385
+ }
386
+
387
+ return (
388
+ <FieldShell label={field.label ?? field.id}>
389
+ <div
390
+ className="grid gap-x-2"
391
+ style={{ gridTemplateColumns: `repeat(${arity}, minmax(0, 1fr))` }}
392
+ >
393
+ {field.components.map((c, i) => (
394
+ <div key={i} className="flex flex-col gap-0.5 min-w-0">
395
+ {c.label && (
396
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
397
+ {c.label}
398
+ </span>
399
+ )}
400
+ <Input
401
+ type="number"
402
+ aria-label={c.label ?? `${field.id}[${i}]`}
403
+ value={values[i] != null ? String(values[i]) : ""}
404
+ min={c.min}
405
+ max={c.max}
406
+ step={c.step ?? (isInt ? 1 : 0.01)}
407
+ disabled={disabled}
408
+ onChange={(e) => {
409
+ const raw = e.target.value
410
+ const parsed = raw === "" ? null : Number(raw)
411
+ const next = values.slice()
412
+ next[i] =
413
+ parsed != null && Number.isFinite(parsed)
414
+ ? isInt
415
+ ? Math.trunc(parsed)
416
+ : parsed
417
+ : null
418
+ onChange(next)
419
+ }}
420
+ />
421
+ </div>
422
+ ))}
423
+ </div>
424
+ </FieldShell>
425
+ )
426
+ }
427
+
428
+ export function registerBuiltinRenderers(): void {
429
+ registerFieldRenderer(
430
+ "text",
431
+ TextRenderer as FieldRenderer<import("./types").FieldDef>,
432
+ )
433
+ registerFieldRenderer(
434
+ "textarea",
435
+ TextareaRenderer as FieldRenderer<import("./types").FieldDef>,
436
+ )
437
+ registerFieldRenderer(
438
+ "number",
439
+ NumberRenderer as FieldRenderer<import("./types").FieldDef>,
440
+ )
441
+ registerFieldRenderer(
442
+ "slider",
443
+ SliderRenderer as FieldRenderer<import("./types").FieldDef>,
444
+ )
445
+ registerFieldRenderer(
446
+ "select",
447
+ SelectRenderer as FieldRenderer<import("./types").FieldDef>,
448
+ )
449
+ registerFieldRenderer(
450
+ "color",
451
+ ColorRenderer as FieldRenderer<import("./types").FieldDef>,
452
+ )
453
+ registerFieldRenderer(
454
+ "checkbox",
455
+ CheckboxRenderer as FieldRenderer<import("./types").FieldDef>,
456
+ )
457
+ registerFieldRenderer(
458
+ "file",
459
+ FileRenderer as FieldRenderer<import("./types").FieldDef>,
460
+ )
461
+ registerFieldRenderer(
462
+ "vector",
463
+ VectorRenderer as FieldRenderer<import("./types").FieldDef>,
464
+ )
465
+ }
466
+
467
+ export type {
468
+ FieldRenderer,
469
+ FieldRendererProps,
470
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./types"
2
+ export {
3
+ registerScope,
4
+ unregisterScope,
5
+ getScope,
6
+ registerFieldRenderer,
7
+ getFieldRenderer,
8
+ } from "./registry"
9
+ export { PropertyPanel } from "./panel"
10
+ export { registerBuiltinRenderers } from "./field-renderers"
package/src/panel.tsx ADDED
@@ -0,0 +1,152 @@
1
+ import { useMemo } from "react"
2
+ import { Separator } from "@/components/ui/separator"
3
+ import { getFieldRenderer, getScope } from "./registry"
4
+ import type {
5
+ FieldDef,
6
+ PropertyGroupDef,
7
+ ScopeContext,
8
+ } from "./types"
9
+
10
+ interface PropertyPanelProps {
11
+ scopeKey: string
12
+ selection: unknown
13
+ ctx: ScopeContext
14
+ readOnly?: boolean
15
+ emptyState?: React.ReactNode
16
+ }
17
+
18
+ export function PropertyPanel({
19
+ scopeKey,
20
+ selection,
21
+ ctx,
22
+ readOnly = false,
23
+ emptyState,
24
+ }: PropertyPanelProps) {
25
+ const scope = getScope(scopeKey)
26
+
27
+ const values = useMemo(() => {
28
+ if (!scope) return {} as Record<string, unknown>
29
+ try {
30
+ return scope.read(selection, ctx) as Record<string, unknown>
31
+ } catch (e) {
32
+ console.error("[properties] scope read failed:", scopeKey, e)
33
+ return {} as Record<string, unknown>
34
+ }
35
+ }, [scope, scopeKey, selection, ctx])
36
+
37
+ if (!scope) return <>{emptyState ?? null}</>
38
+
39
+ const onChange = readOnly || !scope.write
40
+ ? undefined
41
+ : (path: string, val: unknown) => scope.write!(path, val, selection, ctx)
42
+
43
+ return (
44
+ <div className="flex flex-col">
45
+ {scope.schema.groups.map((group, idx) => (
46
+ <PropertyGroup
47
+ key={group.id}
48
+ group={group}
49
+ values={values}
50
+ ctx={ctx}
51
+ onChange={onChange}
52
+ isLast={idx === scope.schema.groups.length - 1}
53
+ />
54
+ ))}
55
+ </div>
56
+ )
57
+ }
58
+
59
+ function PropertyGroup({
60
+ group,
61
+ values,
62
+ ctx,
63
+ onChange,
64
+ isLast,
65
+ }: {
66
+ group: PropertyGroupDef
67
+ values: Record<string, unknown>
68
+ ctx: ScopeContext
69
+ onChange?: (path: string, val: unknown) => void
70
+ isLast: boolean
71
+ }) {
72
+ return (
73
+ <section className="flex flex-col gap-3 px-3 py-3">
74
+ {(group.title || group.description) && (
75
+ <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 && (
82
+ <p className="mt-1 text-xs text-muted-foreground">
83
+ {group.description}
84
+ </p>
85
+ )}
86
+ </header>
87
+ )}
88
+ <div className="flex flex-col gap-3">
89
+ {group.rows.map((row, idx) => {
90
+ const fields = Array.isArray(row) ? row : [row]
91
+ return (
92
+ <div
93
+ key={idx}
94
+ className={
95
+ fields.length > 1 ? "grid grid-cols-2 gap-2" : undefined
96
+ }
97
+ >
98
+ {fields.map((f) => (
99
+ <FieldSlot
100
+ key={f.id}
101
+ field={f}
102
+ values={values}
103
+ ctx={ctx}
104
+ onChange={onChange}
105
+ />
106
+ ))}
107
+ </div>
108
+ )
109
+ })}
110
+ </div>
111
+ {!isLast && <Separator className="mt-1" />}
112
+ </section>
113
+ )
114
+ }
115
+
116
+ function FieldSlot({
117
+ field,
118
+ values,
119
+ ctx,
120
+ onChange,
121
+ }: {
122
+ field: FieldDef
123
+ values: Record<string, unknown>
124
+ ctx: ScopeContext
125
+ onChange?: (path: string, val: unknown) => void
126
+ }) {
127
+ const renderer = getFieldRenderer(field.kind)
128
+ if (!renderer) {
129
+ console.warn("[properties] no renderer for kind:", field.kind)
130
+ return null
131
+ }
132
+ const disabled = (() => {
133
+ const cond = field.disabledWhen
134
+ if (!cond) return false
135
+ const v = values[cond.path]
136
+ if (Object.prototype.hasOwnProperty.call(cond, "equals"))
137
+ return v === cond.equals
138
+ if (Object.prototype.hasOwnProperty.call(cond, "notEquals"))
139
+ return v !== cond.notEquals
140
+ if (cond.in) return cond.in.includes(v)
141
+ if (cond.notIn) return !cond.notIn.includes(v)
142
+ return false
143
+ })()
144
+
145
+ return renderer({
146
+ field,
147
+ value: values[field.path],
148
+ disabled,
149
+ onChange: onChange ? (val) => onChange(field.path, val) : undefined,
150
+ ctx,
151
+ })
152
+ }
@@ -0,0 +1,32 @@
1
+ import type { FieldRenderer, Scope } from "./types"
2
+
3
+ const scopes = new Map<string, Scope<unknown, Record<string, unknown>>>()
4
+ const fieldRenderers = new Map<string, FieldRenderer>()
5
+
6
+ export function registerScope<S, V extends Record<string, unknown>>(
7
+ key: string,
8
+ scope: Scope<S, V>,
9
+ ): void {
10
+ scopes.set(key, scope as Scope<unknown, Record<string, unknown>>)
11
+ }
12
+
13
+ export function unregisterScope(key: string): void {
14
+ scopes.delete(key)
15
+ }
16
+
17
+ export function getScope(
18
+ key: string,
19
+ ): Scope<unknown, Record<string, unknown>> | undefined {
20
+ return scopes.get(key)
21
+ }
22
+
23
+ export function registerFieldRenderer(
24
+ kind: string,
25
+ Component: FieldRenderer,
26
+ ): void {
27
+ fieldRenderers.set(kind, Component)
28
+ }
29
+
30
+ export function getFieldRenderer(kind: string): FieldRenderer | undefined {
31
+ return fieldRenderers.get(kind)
32
+ }
package/src/types.ts ADDED
@@ -0,0 +1,167 @@
1
+ // Built-in kinds shipped with the package. Apps may register additional kinds
2
+ // via `registerFieldRenderer(kind, Component)` — the panel looks up by string.
3
+ export type BuiltinFieldKind =
4
+ | "text"
5
+ | "textarea"
6
+ | "number"
7
+ | "slider"
8
+ | "select"
9
+ | "color"
10
+ | "checkbox"
11
+ | "file"
12
+
13
+ export interface BaseField {
14
+ kind: string
15
+ id: string
16
+ label?: string
17
+ path: string
18
+ disabledWhen?: DisabledWhen
19
+ }
20
+
21
+ /**
22
+ * Disables a field based on one other field's value. Exactly one operator is
23
+ * meant to be set; if several are, the first present in the order
24
+ * `equals`, `notEquals`, `in`, `notIn` wins. `in`/`notIn` match a list of
25
+ * values (e.g. "only these enum choices use this param") and are still a
26
+ * single-path condition — composable AND/OR belongs in a later DSL.
27
+ */
28
+ export interface DisabledWhen {
29
+ path: string
30
+ equals?: unknown
31
+ notEquals?: unknown
32
+ in?: readonly unknown[]
33
+ notIn?: readonly unknown[]
34
+ }
35
+
36
+ // A custom field is any BaseField whose `kind` is not one of the built-ins.
37
+ // The host's registered renderer for that kind decides what extra props it reads.
38
+ export type CustomFieldDef = BaseField & {
39
+ [extra: string]: unknown
40
+ }
41
+
42
+ export interface SelectOption {
43
+ value: string
44
+ label: string
45
+ }
46
+
47
+ export interface SelectFieldDef extends BaseField {
48
+ kind: "select"
49
+ options?: SelectOption[]
50
+ optionsProvider?: (ctx: ScopeContext) => SelectOption[]
51
+ }
52
+
53
+ export interface ColorFieldDef extends BaseField {
54
+ kind: "color"
55
+ presets?: string[]
56
+ }
57
+
58
+ export interface TextFieldDef extends BaseField {
59
+ kind: "text"
60
+ placeholder?: string
61
+ }
62
+
63
+ export interface TextareaFieldDef extends BaseField {
64
+ kind: "textarea"
65
+ placeholder?: string
66
+ rows?: number
67
+ }
68
+
69
+ export interface NumberFieldDef extends BaseField {
70
+ kind: "number"
71
+ min?: number
72
+ max?: number
73
+ step?: number
74
+ }
75
+
76
+ export interface SliderFieldDef extends BaseField {
77
+ kind: "slider"
78
+ min?: number
79
+ max?: number
80
+ step?: number
81
+ }
82
+
83
+ export interface CheckboxFieldDef extends BaseField {
84
+ kind: "checkbox"
85
+ }
86
+
87
+ // A tuple of related scalars sharing one row. Houdini-style float3 / int2 /
88
+ // stringN — variable arity, components share semantic context (LCh, RGB,
89
+ // position, dimensions, …). The path resolves to an array; each component
90
+ // describes one slot.
91
+ export interface VectorComponentDef {
92
+ label?: string
93
+ suffix?: string
94
+ min?: number
95
+ max?: number
96
+ step?: number
97
+ }
98
+
99
+ export interface VectorFieldDef extends BaseField {
100
+ kind: "vector"
101
+ components: VectorComponentDef[]
102
+ integer?: boolean
103
+ precision?: number
104
+ }
105
+
106
+ export interface FileFieldDef extends BaseField {
107
+ kind: "file"
108
+ accept?: string
109
+ helperText?: string
110
+ }
111
+
112
+ export type BuiltinFieldDef =
113
+ | SelectFieldDef
114
+ | ColorFieldDef
115
+ | TextFieldDef
116
+ | TextareaFieldDef
117
+ | NumberFieldDef
118
+ | SliderFieldDef
119
+ | CheckboxFieldDef
120
+ | FileFieldDef
121
+ | VectorFieldDef
122
+
123
+ export type FieldDef = BuiltinFieldDef | CustomFieldDef
124
+
125
+ export interface PropertyGroupDef {
126
+ id: string
127
+ /** Omit to render the group without a header (e.g. when the host already labels it). */
128
+ title?: string
129
+ rows: Array<FieldDef | FieldDef[]>
130
+ description?: string
131
+ collapsible?: boolean
132
+ defaultCollapsed?: boolean
133
+ }
134
+
135
+ export interface PropertySchema {
136
+ version: number
137
+ groups: PropertyGroupDef[]
138
+ }
139
+
140
+ // Opaque to the package. Each host types its own context.
141
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
142
+ export type ScopeContext = any
143
+
144
+ export interface Scope<S = unknown, V = Record<string, unknown>> {
145
+ schema: PropertySchema
146
+ read: (selection: S, ctx: ScopeContext) => V
147
+ write?: (
148
+ path: string,
149
+ value: unknown,
150
+ selection: S,
151
+ ctx: ScopeContext,
152
+ ) => void
153
+ }
154
+
155
+ export interface FieldRendererProps<F extends FieldDef = FieldDef> {
156
+ field: F
157
+ value: unknown
158
+ disabled: boolean
159
+ onChange?: (next: unknown) => void
160
+ ctx: ScopeContext
161
+ }
162
+
163
+ export type FieldRenderer<F extends FieldDef = FieldDef> = React.FC<
164
+ FieldRendererProps<F>
165
+ >
166
+
167
+ import type React from "react"