@zodal/dials-ui 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.
@@ -0,0 +1,386 @@
1
+ import { SettingKey, Sensitivity, MergeStrategy, DialsDefinition, EffectiveResult, JsonPatchOp, Layer, ScopedLayer, KeyProvenance, ConstraintResult, SerializedLayer } from '@zodal/dials-core';
2
+ import { RendererTester, RendererRegistry } from '@zodal/ui';
3
+ export { PRIORITY, RendererRegistry, RendererTester, and, createRendererRegistry, editWidgetIs, fieldNameMatches, hasRefinement, metaMatches, or, zodTypeIs } from '@zodal/ui';
4
+ import { z } from 'zod';
5
+
6
+ /**
7
+ * Headless output types for the settings UI layer. These are plain configuration objects (never
8
+ * DOM/React) that any concrete renderer (vanilla, shadcn, …) turns into a settings panel.
9
+ */
10
+
11
+ /** The widget kind chosen for a setting's value type. A `secret` setting always maps to `secret`;
12
+ * an irreducible nested value maps to `object`/`array`, with `rawJson` as the terminal fallback. */
13
+ type WidgetKind = 'switch' | 'select' | 'radio' | 'slider' | 'number' | 'text' | 'textarea' | 'secret' | 'color' | 'date' | 'path' | 'object' | 'array' | 'rawJson';
14
+ /** A headless field configuration for one setting (the static, value-independent shape). */
15
+ interface SettingFieldConfig {
16
+ key: SettingKey;
17
+ label: string;
18
+ description?: string;
19
+ widget: WidgetKind;
20
+ /** The setting's base Zod type ('string'/'number'/'boolean'/'enum'/'object'/'array'/…). */
21
+ zodType: string;
22
+ required: boolean;
23
+ readOnly: boolean;
24
+ hidden: boolean;
25
+ sensitivity: Sensitivity;
26
+ mergeStrategy: MergeStrategy;
27
+ defaultValue?: unknown;
28
+ enumValues?: string[];
29
+ bounds?: {
30
+ min?: number;
31
+ max?: number;
32
+ };
33
+ /** Facet ids this setting belongs to (multi-membership). */
34
+ facets: string[];
35
+ /** Advanced-disclosure flag (membership in the `advanced` facet). */
36
+ advanced: boolean;
37
+ order?: number;
38
+ /** The value is an irreducible nested object/array (use a sub-editor or the rawJson fallback). */
39
+ isStructured: boolean;
40
+ }
41
+ /** The value-dependent state of a setting field, derived from cascade resolution. */
42
+ interface SettingFieldState {
43
+ /** The current effective value (a masked `SecretRef` for secrets). */
44
+ value: unknown;
45
+ /** The winning scope (provenance). */
46
+ source?: string;
47
+ /** True if the effective value comes from a managed/policy scope (lock the control). */
48
+ managed: boolean;
49
+ /** True if another scope also set this key (shadowed). */
50
+ shadowed: boolean;
51
+ /** True if the active layer overrides the baseline (dirty). */
52
+ dirty: boolean;
53
+ }
54
+ /** A group projected from a facet (or a computed/"smart" facet). The gesture (open a panel vs.
55
+ * expand in place) is NOT encoded here — both are driven by this one model. */
56
+ interface SettingsGroup {
57
+ id: string;
58
+ title: string;
59
+ order: number;
60
+ settingKeys: SettingKey[];
61
+ /** True for a computed/"smart" group (e.g. `@modified`, `@secret`) vs. a declared facet. */
62
+ computed: boolean;
63
+ }
64
+ /** The full headless settings form: field configs + facet-projected groups. */
65
+ interface SettingsForm {
66
+ fields: SettingFieldConfig[];
67
+ groups: SettingsGroup[];
68
+ }
69
+ /** The engine-agnostic, indexable projection of a setting for search providers. */
70
+ interface IndexableSetting {
71
+ key: SettingKey;
72
+ title: string;
73
+ description: string;
74
+ enumLabels: string[];
75
+ facets: string[];
76
+ keywords: string[];
77
+ }
78
+ /** A pluggable search provider over the indexable surface. */
79
+ interface SearchProvider {
80
+ /** Return the matching setting keys for a free-text query (best match first). */
81
+ search(query: string): SettingKey[];
82
+ }
83
+
84
+ /**
85
+ * Type -> widget classification for settings. Pure. Distinguishes a scalar leaf (switch/select/
86
+ * slider/text/…) from an irreducible nested value (object/array), with `rawJson` as the terminal
87
+ * fallback for anything unhandled — so coverage is total and degradation is honest. A `secret`
88
+ * setting always maps to the `secret` widget regardless of its underlying type.
89
+ */
90
+
91
+ interface WidgetInput {
92
+ zodType: string;
93
+ sensitivity: Sensitivity;
94
+ bounds?: {
95
+ min?: number;
96
+ max?: number;
97
+ };
98
+ enumValues?: string[];
99
+ /** An explicit `.meta({ editWidget })` override (wins over inference). */
100
+ metaWidget?: unknown;
101
+ }
102
+ /** Choose the widget kind for a setting. */
103
+ declare function widgetKindFor(input: WidgetInput): WidgetKind;
104
+
105
+ /**
106
+ * The settings renderer registry — a thin specialization of zodal's capability-ranked
107
+ * `RendererRegistry`. Concrete renderer packages (vanilla, shadcn, …) register `(tester, renderer)`
108
+ * entries against the same open-closed API; selection is by PRIORITY band + composable testers, with
109
+ * a terminal `alwaysMatch` entry (the rawJson fallback) guaranteeing total coverage and honest
110
+ * degradation. Settings-specific testers read `sensitivity`/`structured` from the render context.
111
+ */
112
+
113
+ /** Match a secret setting (its `sensitivity` is supplied via the render context). High priority so a
114
+ * secret is always rendered with the masked widget regardless of its underlying type. */
115
+ declare function secretRoleIs(): RendererTester;
116
+ /** Match an irreducible nested value (`structured: true` in context, or an object/array Zod type). */
117
+ declare function isStructuredValue(): RendererTester;
118
+ declare function isBoolean(): RendererTester;
119
+ declare function isEnum(): RendererTester;
120
+ declare function isNumber(): RendererTester;
121
+ declare function isString(): RendererTester;
122
+ /**
123
+ * Terminal renderer tester: ALWAYS matches, at the given priority (default FALLBACK). Pairing this
124
+ * with a raw-JSON renderer guarantees every setting resolves to something (the honest-degradation
125
+ * fallback).
126
+ */
127
+ declare function alwaysMatch(priority?: number): RendererTester;
128
+ /** Create a settings renderer registry (a zodal `RendererRegistry`). Concrete renderer packages
129
+ * populate it; remember to register a terminal `alwaysMatch` rawJson renderer for full coverage. */
130
+ declare function createSettingsRendererRegistry<TComponent = unknown>(): RendererRegistry<TComponent>;
131
+
132
+ /**
133
+ * Build the static, value-independent `SettingFieldConfig[]` from a `DialsDefinition`, combining the
134
+ * schema (via dials-core introspection helpers + `@zodal/core` enum/bounds helpers), the dials
135
+ * classification (sensitivity, merge strategy, defaults), and `.meta()` annotations + an optional
136
+ * external facet-assignment map. Pure.
137
+ */
138
+
139
+ interface DescribeOptions {
140
+ /** Extra facet membership, merged with each setting's `.meta({ facets })`: key -> facet ids. */
141
+ facets?: Record<string, string[]>;
142
+ }
143
+ /** Describe every setting in a dials definition as a headless field config. */
144
+ declare function describeSettings<T extends z.ZodObject<z.ZodRawShape>>(dials: DialsDefinition<T>, options?: DescribeOptions): SettingFieldConfig[];
145
+
146
+ /**
147
+ * Faceted organization: project the flat settings surface into groups via a separate grouping layer
148
+ * (facets are canonical; a tree is one projection). The forward index (facet -> keys) drives panels,
149
+ * accordions, and bulk-operation scopes — the same model serves both the open-a-panel and the
150
+ * expand-in-place gesture (the gesture is the renderer's choice, never encoded here). Computed
151
+ * ("smart") groups are predicates over field config + resolution state. Pure.
152
+ */
153
+
154
+ interface FacetDef {
155
+ id: string;
156
+ title?: string;
157
+ order?: number;
158
+ }
159
+ interface GroupingOptions {
160
+ /** Declared facet titles/order. Facets used by fields but not declared get a humanized default. */
161
+ facetDefs?: FacetDef[];
162
+ /** Include computed/"smart" groups (@secret, @advanced, @modified, @managed). Default: true. */
163
+ computedGroups?: boolean;
164
+ /** Title for the catch-all group of settings with no facet. Default: 'Other'. */
165
+ ungroupedTitle?: string;
166
+ }
167
+ /** Project field configs (in their incoming order) into facet groups, sorted by order then title. */
168
+ declare function toGroups(fields: SettingFieldConfig[], result?: EffectiveResult, options?: GroupingOptions): SettingsGroup[];
169
+
170
+ /**
171
+ * Search over the settings surface: a declared, engine-agnostic `IndexableSetting[]` projection, a
172
+ * pluggable `SearchProvider` (zero-dependency substring default; richer engines like MiniSearch or
173
+ * a semantic provider plug in behind the same interface), and an engine-independent scoped `@`-filter
174
+ * parser (`@modified`/`@managed`/`@secret`/`@advanced`/`@facet:<id>`/`@scope:<id>`) that narrows by
175
+ * effective-value/provenance state BEFORE free text reaches the provider. Pure.
176
+ */
177
+
178
+ /** Project field configs into the engine-agnostic indexable surface. */
179
+ declare function toIndexableSettings(fields: SettingFieldConfig[]): IndexableSetting[];
180
+ /** Which indexable text fields the substring provider searches (and their relative weight). */
181
+ type IndexField = 'title' | 'description' | 'enumLabels' | 'facets' | 'keywords';
182
+ interface SubstringSearchOptions {
183
+ /** Which fields to match (default: all). */
184
+ fields?: IndexField[];
185
+ }
186
+ /**
187
+ * Zero-dependency substring search provider (the default). Lowercased-substring match over the
188
+ * selected fields; title/keyword hits rank above description/facet/enum hits. Empty query returns all.
189
+ */
190
+ declare function createSubstringSearchProvider(settings: IndexableSetting[], options?: SubstringSearchOptions): SearchProvider;
191
+ type ScopeFilter = {
192
+ type: 'modified';
193
+ } | {
194
+ type: 'managed';
195
+ } | {
196
+ type: 'secret';
197
+ } | {
198
+ type: 'advanced';
199
+ } | {
200
+ type: 'facet';
201
+ value: string;
202
+ } | {
203
+ type: 'scope';
204
+ value: string;
205
+ };
206
+ interface ParsedQuery {
207
+ filters: ScopeFilter[];
208
+ text: string;
209
+ }
210
+ /** Parse a settings query into scoped `@`-filters + the residual free text. Unrecognized `@tokens`
211
+ * fall back to free text. */
212
+ declare function parseScopedQuery(query: string): ParsedQuery;
213
+ interface FilterContext {
214
+ fields: SettingFieldConfig[];
215
+ result?: EffectiveResult;
216
+ }
217
+ /** Apply scoped filters to a key set (keys must satisfy ALL filters), using field config + state. */
218
+ declare function applyScopedFilters(keys: SettingKey[], filters: ScopeFilter[], context: FilterContext): SettingKey[];
219
+ /** End-to-end: parse a query, apply scoped filters, then free-text search the survivors (keeping the
220
+ * provider's ranking order). */
221
+ declare function searchSettings(query: string, provider: SearchProvider, context: FilterContext): SettingKey[];
222
+
223
+ /**
224
+ * Change-lifecycle helpers (headless): compute the dirty set, reset a key (remove it so a lower
225
+ * scope re-wins) or explicitly UNSET it, and record/undo edits as reversible RFC 6902 patches over
226
+ * the SERIALIZED layer (so the UNSET sentinel survives — `diffJsonPatch` only sees plain JSON). These
227
+ * are thin wrappers over dials-core; consumers wire them to toasts/guards/undo stacks themselves.
228
+ */
229
+
230
+ /** Keys whose value in `current` differs from `baseline` (the dirty set). Absence, UNSET, `undefined`,
231
+ * `null`, and a literal value are all distinguished. */
232
+ declare function dirtyKeys(current: Layer, baseline: Layer): SettingKey[];
233
+ /** True if any key is dirty. */
234
+ declare function isDirty(current: Layer, baseline: Layer): boolean;
235
+ /** Reset a key by removing it from the layer (a lower scope re-wins). Returns a new layer. */
236
+ declare function resetToDefault(layer: Layer, key: SettingKey): Layer;
237
+ /** Explicitly UNSET a key (records an intentional reset, distinct from never-set). Returns a new layer. */
238
+ declare function unsetKey(layer: Layer, key: SettingKey): Layer;
239
+ /** A reversible record of an edit (forward + inverse RFC 6902 patches over the serialized layer). */
240
+ interface ChangeRecord {
241
+ forward: JsonPatchOp[];
242
+ inverse: JsonPatchOp[];
243
+ }
244
+ /** Record an edit from `before` to `after` as a reversible change over the serialized layers. */
245
+ declare function recordLayerChange(before: Layer, after: Layer): ChangeRecord;
246
+ /** Apply a change's `forward` (redo) or `inverse` (undo) patch to a layer, returning a new layer. */
247
+ declare function applyLayerPatch(layer: Layer, ops: JsonPatchOp[]): Layer;
248
+
249
+ /**
250
+ * `toSettingsForm` — the top-level headless generator: describe every (non-hidden) setting as a
251
+ * field config, order them, and project them into facet groups. `toFieldStates` derives the
252
+ * value-dependent state (effective value, provenance source, managed/shadowed flags, dirty) from a
253
+ * cascade resolution — the input to provenance badges, locks, and reset affordances. Pure; emits
254
+ * configuration objects only (never DOM).
255
+ */
256
+
257
+ interface ToSettingsFormOptions extends DescribeOptions, GroupingOptions {
258
+ /** A resolution result, used for computed groups (@modified/@managed). */
259
+ result?: EffectiveResult;
260
+ /** Include hidden settings in the form. Default: false. */
261
+ includeHidden?: boolean;
262
+ }
263
+ /** Build the full headless settings form (ordered field configs + facet groups). */
264
+ declare function toSettingsForm<T extends z.ZodObject<z.ZodRawShape>>(dials: DialsDefinition<T>, options?: ToSettingsFormOptions): SettingsForm;
265
+ /** Derive value-dependent field state (value, provenance source, managed/shadowed, dirty) for each
266
+ * field from a cascade resolution and an optional dirty set. */
267
+ declare function toFieldStates(fields: SettingFieldConfig[], result: EffectiveResult, dirty?: Iterable<SettingKey>): Record<SettingKey, SettingFieldState>;
268
+
269
+ /**
270
+ * `createSettingsStore` — a framework-agnostic reactive store of effective settings. It holds the
271
+ * ordered lower-scope stack + the editable (user) layer; every mutation re-resolves the cascade
272
+ * (effective + provenance + conflicts), recomputes the dirty set and validation, masks secrets, and
273
+ * notifies subscribers. No framework dependency: `subscribe`/`getState` plug into React via
274
+ * `useSyncExternalStore`, or into anything via the listener. Constraints/secret-masking are honored
275
+ * by reusing dials-core (`resolve`, `validate`, `maskEffectiveResult`).
276
+ */
277
+
278
+ interface SettingsState {
279
+ /** Effective value per key (secrets masked as `SecretRef`). */
280
+ effective: Record<SettingKey, unknown>;
281
+ /** Provenance per key (also masked for secrets). */
282
+ provenance: Record<SettingKey, KeyProvenance>;
283
+ /** Keys set by multiple layers to differing values. */
284
+ conflicts: EffectiveResult['conflicts'];
285
+ /** The editable (user) layer — holds RAW values (including secrets), as the source to persist.
286
+ * Split secrets out (dials-core `splitBySensitivity`) before saving to a config store. The
287
+ * display surfaces (`effective`/`provenance`) mask secrets; this does not. */
288
+ layer: Layer;
289
+ /** The ordered lower-scope stack. */
290
+ scopes: ScopedLayer[];
291
+ /** Keys whose editable-layer value differs from the last saved baseline. */
292
+ dirty: SettingKey[];
293
+ /** Validation of the (unmasked) effective values. */
294
+ validation: ConstraintResult;
295
+ }
296
+ interface CreateSettingsStoreOptions {
297
+ /** Initial lower-scope stack (defaults are prepended by resolve). */
298
+ scopes?: ScopedLayer[];
299
+ /** Initial editable layer. */
300
+ layer?: Layer;
301
+ /** Scope id of the editable layer. Default: 'user'. */
302
+ scope?: string;
303
+ /** Mask secret effective values + provenance. Default: true. */
304
+ maskSecrets?: boolean;
305
+ /** Called if a subscriber throws during notification (so one bad listener can't break the others
306
+ * or escape a mutation). Default: rethrow asynchronously is avoided — errors are reported here. */
307
+ onListenerError?: (error: unknown) => void;
308
+ }
309
+ interface SettingsStore {
310
+ getState(): SettingsState;
311
+ /** Subscribe to state changes; returns an unsubscribe function. */
312
+ subscribe(listener: () => void): () => void;
313
+ /** Set a key in the editable layer. */
314
+ set(key: SettingKey, value: unknown): void;
315
+ /** Explicitly UNSET a key in the editable layer (re-exposes a lower scope). */
316
+ unset(key: SettingKey): void;
317
+ /** Remove a key from the editable layer (reset — a lower scope re-wins). */
318
+ reset(key: SettingKey): void;
319
+ /** Replace the whole editable layer. */
320
+ setLayer(layer: Layer): void;
321
+ /** Replace the lower-scope stack. */
322
+ setScopes(scopes: ScopedLayer[]): void;
323
+ /** Mark the current editable layer as saved (clears the dirty set). */
324
+ markSaved(): void;
325
+ /** Current effective value for a key (masked for secrets). */
326
+ get(key: SettingKey): unknown;
327
+ /** Provenance for a key. */
328
+ explain(key: SettingKey): KeyProvenance | undefined;
329
+ }
330
+ /** Create a reactive settings store over a dials definition. */
331
+ declare function createSettingsStore<T extends z.ZodObject<z.ZodRawShape>>(dials: DialsDefinition<T>, options?: CreateSettingsStoreOptions): SettingsStore;
332
+
333
+ /**
334
+ * Named profile management — the "save / load / list named settings bundles" capability (an app may
335
+ * call these "presets", "schemes", or — for thoremin — "instruments"). A profile is a NAME + a
336
+ * sparse `Layer` (persisted losslessly via `serializeLayer`, so `UNSET` survives) + optional metadata.
337
+ * Persistence is pluggable (`ProfileStorage`): an in-memory default and a `localStorage` adapter are
338
+ * provided. To apply a profile, put its layer into the cascade scope stack (e.g.
339
+ * `store.setScopes([{ scope: 'profile', layer }])`) or replace the editable layer.
340
+ *
341
+ * SECURITY: profiles are plaintext at rest. Pass `sensitivityFor` so `secret` keys are REDACTED on
342
+ * save (never persisted) — fail-closed, mirroring the jsonc store; otherwise split secrets out first.
343
+ * Mutations are serialized per store so concurrent saves cannot lose updates.
344
+ */
345
+
346
+ /** Lightweight profile descriptor (no layer payload) — for listing. */
347
+ interface ProfileMeta {
348
+ name: string;
349
+ meta?: Record<string, unknown>;
350
+ }
351
+ /** A persisted named profile (a serialized sparse layer + metadata). */
352
+ interface NamedProfile extends ProfileMeta {
353
+ layer: SerializedLayer;
354
+ }
355
+ /** Pluggable persistence for the profile collection (reads/writes the whole list as JSON). */
356
+ interface ProfileStorage {
357
+ read(): Promise<NamedProfile[]>;
358
+ write(profiles: NamedProfile[]): Promise<void>;
359
+ }
360
+ interface ProfileStoreOptions {
361
+ /** Classify a setting's sensitivity. When provided, `secret` keys are REDACTED on save. */
362
+ sensitivityFor?: (key: SettingKey) => Sensitivity;
363
+ }
364
+ /** An in-memory `ProfileStorage` (default; tests / ephemeral use). */
365
+ declare function createMemoryProfileStorage(initial?: NamedProfile[]): ProfileStorage;
366
+ /** A `localStorage`-backed `ProfileStorage` (browser). Throws if `localStorage` is unavailable. A
367
+ * corrupt stored value degrades to an empty list rather than throwing on every read. */
368
+ declare function createLocalStorageProfileStorage(storageKey?: string): ProfileStorage;
369
+ interface ProfileStore {
370
+ /** All saved profiles (name + metadata only). */
371
+ list(): Promise<ProfileMeta[]>;
372
+ /** Save (or overwrite) a profile from a sparse layer. Rejects an empty/whitespace name. */
373
+ save(name: string, layer: Layer, meta?: Record<string, unknown>): Promise<void>;
374
+ /** Load a profile's layer, or undefined if absent. */
375
+ load(name: string): Promise<Layer | undefined>;
376
+ /** Remove a profile. */
377
+ remove(name: string): Promise<void>;
378
+ /** Rename a profile (no-op if `from` is absent or equals `to`; throws if `to` already exists). */
379
+ rename(from: string, to: string): Promise<void>;
380
+ /** Whether a profile exists. */
381
+ has(name: string): Promise<boolean>;
382
+ }
383
+ /** Create a profile store over a pluggable storage backend. */
384
+ declare function createProfileStore(storage: ProfileStorage, options?: ProfileStoreOptions): ProfileStore;
385
+
386
+ export { type ChangeRecord, type CreateSettingsStoreOptions, type DescribeOptions, type FacetDef, type FilterContext, type GroupingOptions, type IndexField, type IndexableSetting, type NamedProfile, type ParsedQuery, type ProfileMeta, type ProfileStorage, type ProfileStore, type ProfileStoreOptions, type ScopeFilter, type SearchProvider, type SettingFieldConfig, type SettingFieldState, type SettingsForm, type SettingsGroup, type SettingsState, type SettingsStore, type SubstringSearchOptions, type ToSettingsFormOptions, type WidgetInput, type WidgetKind, alwaysMatch, applyLayerPatch, applyScopedFilters, createLocalStorageProfileStorage, createMemoryProfileStorage, createProfileStore, createSettingsRendererRegistry, createSettingsStore, createSubstringSearchProvider, describeSettings, dirtyKeys, isBoolean, isDirty, isEnum, isNumber, isString, isStructuredValue, parseScopedQuery, recordLayerChange, resetToDefault, searchSettings, secretRoleIs, toFieldStates, toGroups, toIndexableSettings, toSettingsForm, unsetKey, widgetKindFor };