@tai42/studio-sdk 9.3.0 → 11.0.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.
Files changed (92) hide show
  1. package/README.md +45 -6
  2. package/SECURITY.md +10 -2
  3. package/dist/components/components.css +18 -0
  4. package/dist/components/dirty-guard.d.ts +23 -10
  5. package/dist/components/dirty-guard.d.ts.map +1 -1
  6. package/dist/components/dirty-guard.js +22 -12
  7. package/dist/components/dirty-guard.js.map +1 -1
  8. package/dist/components/field.d.ts.map +1 -1
  9. package/dist/components/field.js +1 -1
  10. package/dist/components/field.js.map +1 -1
  11. package/dist/components/fleet-report.d.ts +12 -3
  12. package/dist/components/fleet-report.d.ts.map +1 -1
  13. package/dist/components/fleet-report.js +28 -9
  14. package/dist/components/fleet-report.js.map +1 -1
  15. package/dist/components/icons.d.ts +4 -0
  16. package/dist/components/icons.d.ts.map +1 -1
  17. package/dist/components/icons.js +5 -0
  18. package/dist/components/icons.js.map +1 -1
  19. package/dist/hooks/useApi.d.ts +348 -2
  20. package/dist/hooks/useApi.d.ts.map +1 -1
  21. package/dist/host.d.ts +1 -1
  22. package/dist/host.d.ts.map +1 -1
  23. package/dist/index.d.ts +18 -7
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +23 -11
  26. package/dist/index.js.map +1 -1
  27. package/dist/navigation/context.d.ts +6 -2
  28. package/dist/navigation/context.d.ts.map +1 -1
  29. package/dist/navigation/context.js +10 -2
  30. package/dist/navigation/context.js.map +1 -1
  31. package/dist/navigation/types.d.ts +1 -0
  32. package/dist/navigation/types.d.ts.map +1 -1
  33. package/dist/plugin/host-state.d.ts +16 -0
  34. package/dist/plugin/host-state.d.ts.map +1 -1
  35. package/dist/plugin/host-state.js +1 -0
  36. package/dist/plugin/host-state.js.map +1 -1
  37. package/dist/plugin/types.d.ts +17 -7
  38. package/dist/plugin/types.d.ts.map +1 -1
  39. package/dist/schema-form/SchemaForm.d.ts +4 -3
  40. package/dist/schema-form/SchemaForm.d.ts.map +1 -1
  41. package/dist/schema-form/SchemaForm.js.map +1 -1
  42. package/dist/schema-form/SecretRefField.d.ts.map +1 -1
  43. package/dist/schema-form/SecretRefField.js +3 -2
  44. package/dist/schema-form/SecretRefField.js.map +1 -1
  45. package/dist/schema-form/context.d.ts +2 -2
  46. package/dist/schema-form/default-value.d.ts +12 -0
  47. package/dist/schema-form/default-value.d.ts.map +1 -1
  48. package/dist/schema-form/default-value.js +34 -3
  49. package/dist/schema-form/default-value.js.map +1 -1
  50. package/dist/schema-form/field-node.d.ts +7 -0
  51. package/dist/schema-form/field-node.d.ts.map +1 -1
  52. package/dist/schema-form/field-node.js +3 -2
  53. package/dist/schema-form/field-node.js.map +1 -1
  54. package/dist/schema-form/index.d.ts +11 -11
  55. package/dist/schema-form/index.js +11 -11
  56. package/dist/schema-form/object-fields.d.ts +3 -1
  57. package/dist/schema-form/object-fields.d.ts.map +1 -1
  58. package/dist/schema-form/object-fields.js +2 -2
  59. package/dist/schema-form/object-fields.js.map +1 -1
  60. package/dist/schema-form/string-field.d.ts +3 -2
  61. package/dist/schema-form/string-field.d.ts.map +1 -1
  62. package/dist/schema-form/string-field.js.map +1 -1
  63. package/dist/schema-form/union-field.d.ts +5 -2
  64. package/dist/schema-form/union-field.d.ts.map +1 -1
  65. package/dist/schema-form/union-field.js +68 -15
  66. package/dist/schema-form/union-field.js.map +1 -1
  67. package/package.json +3 -3
  68. package/src/components/components.css +18 -0
  69. package/src/components/dirty-guard.tsx +23 -13
  70. package/src/components/field.tsx +10 -5
  71. package/src/components/fleet-report.tsx +44 -13
  72. package/src/components/icons.tsx +19 -0
  73. package/src/host.ts +5 -1
  74. package/src/index.ts +24 -31
  75. package/src/navigation/context.tsx +16 -1
  76. package/src/navigation/types.ts +3 -0
  77. package/src/plugin/host-state.ts +18 -0
  78. package/src/plugin/types.ts +17 -7
  79. package/src/schema-form/SchemaForm.tsx +4 -3
  80. package/src/schema-form/SecretRefField.tsx +3 -2
  81. package/src/schema-form/context.ts +2 -2
  82. package/src/schema-form/default-value.ts +36 -3
  83. package/src/schema-form/field-node.tsx +21 -2
  84. package/src/schema-form/index.ts +11 -11
  85. package/src/schema-form/object-fields.tsx +4 -0
  86. package/src/schema-form/string-field.tsx +3 -2
  87. package/src/schema-form/union-field.tsx +85 -19
  88. package/dist/jq.d.ts +0 -31
  89. package/dist/jq.d.ts.map +0 -1
  90. package/dist/jq.js +0 -41
  91. package/dist/jq.js.map +0 -1
  92. package/src/jq.ts +0 -61
@@ -8,6 +8,7 @@ import {
8
8
  useRef,
9
9
  useState,
10
10
  type AriaAttributes,
11
+ type CSSProperties,
11
12
  type MouseEvent,
12
13
  type ReactNode,
13
14
  } from 'react';
@@ -453,6 +454,10 @@ export interface AppLinkProps<T extends RouteToken> {
453
454
  className?: string;
454
455
  'aria-label'?: string;
455
456
  'aria-current'?: AriaAttributes['aria-current'];
457
+ /** Native tooltip text (also a sensible non-AT hover hint). */
458
+ title?: string;
459
+ /** Inline styles for the anchor. */
460
+ style?: CSSProperties;
456
461
  }
457
462
 
458
463
  /**
@@ -467,6 +472,8 @@ export function AppLink<T extends RouteToken>({
467
472
  className,
468
473
  'aria-label': ariaLabel,
469
474
  'aria-current': ariaCurrent,
475
+ title,
476
+ style,
470
477
  }: AppLinkProps<T>): ReactNode {
471
478
  const { navigate, resolvePath } = useNavigation();
472
479
  const href = resolvePath(to, search);
@@ -482,7 +489,15 @@ export function AppLink<T extends RouteToken>({
482
489
  );
483
490
  return createElement(
484
491
  'a',
485
- { href, className, 'aria-label': ariaLabel, 'aria-current': ariaCurrent, onClick },
492
+ {
493
+ href,
494
+ className,
495
+ 'aria-label': ariaLabel,
496
+ 'aria-current': ariaCurrent,
497
+ title,
498
+ style,
499
+ onClick,
500
+ },
486
501
  children,
487
502
  );
488
503
  }
@@ -41,6 +41,9 @@ export interface RouteSearchByToken {
41
41
  q?: string;
42
42
  };
43
43
  connectors: { connection?: string };
44
+ // The Served Endpoints surface (the promoted sub-MCP servers): a curated subset
45
+ // of tools served on a transport. No search parameters — a flat list + create form.
46
+ servedEndpoints: Record<string, never>;
44
47
  hooks: Record<string, never>;
45
48
  templates: { template?: string; q?: string };
46
49
  storage: { q?: string };
@@ -16,6 +16,17 @@ import { useSyncExternalStore } from 'react';
16
16
  import type { PluginContributions } from './types';
17
17
  import { getContributions } from './registry';
18
18
 
19
+ /**
20
+ * Identity of a successfully-loaded plugin, for host chrome that must attribute a
21
+ * contribution to its origin (e.g. the shell's provenance badge on plugin nav). The
22
+ * `name` is the same id the registry stamps on every contribution (`pluginId`); the
23
+ * `version` comes from the plugin's manifest.
24
+ */
25
+ export interface LoadedPlugin {
26
+ readonly name: string;
27
+ readonly version: string;
28
+ }
29
+
19
30
  /**
20
31
  * The plugin load pass's public state, mirrored for feature pages to read. It is
21
32
  * the shape the app shell's loader store already exposes: `status` gates when the
@@ -26,6 +37,12 @@ export interface PluginLoaderState {
26
37
  readonly status: 'idle' | 'loading' | 'ready';
27
38
  /** Names of plugins whose bundle imported and registered successfully. */
28
39
  readonly loaded: readonly string[];
40
+ /**
41
+ * Identity (name + version) of every successfully-loaded plugin, parallel to
42
+ * `loaded`. The host reads it to attribute a contribution to its plugin — e.g. the
43
+ * nav provenance badge's "Plugin: name version". Empty before any plugin loads.
44
+ */
45
+ readonly plugins: readonly LoadedPlugin[];
29
46
  /** Plugin name → loud error message (version mismatch / load failure). */
30
47
  readonly errors: Readonly<Record<string, string>>;
31
48
  /** A registry-listing failure that is NOT a 401 (surfaced loudly, never hidden). */
@@ -35,6 +52,7 @@ export interface PluginLoaderState {
35
52
  const INITIAL_HOST_STATE: PluginLoaderState = {
36
53
  status: 'idle',
37
54
  loaded: [],
55
+ plugins: [],
38
56
  errors: {},
39
57
  registryError: null,
40
58
  };
@@ -142,13 +142,15 @@ export interface RegisteredPage extends PageContribution {
142
142
 
143
143
  /**
144
144
  * The sidebar section a nav entry renders in. A named core section groups the
145
- * entry there, after that section's core rows; `'Plugins'`, an absent field, or
146
- * any unrecognised value falls to the Plugins section. The shell tolerates an
147
- * unknown value at runtime (a bundle newer than this host), so the field is a
148
- * placement hint, never a hard contract.
145
+ * entry there, after that section's core rows. An absent field or any value that
146
+ * is not a live core section renders the entry as a FIRST-CLASS section of its own,
147
+ * named for the contributing plugin (grouped with that plugin's other such entries),
148
+ * placed after the core sections. The shell tolerates an unknown value at runtime
149
+ * (a bundle newer than this host), falling back to that self-named section, so the
150
+ * field is a placement hint, never a hard contract.
149
151
  */
150
152
  export type NavEntrySection =
151
- 'Capabilities' | 'Integrations' | 'Activity' | 'Administration' | 'Plugins';
153
+ 'Capabilities' | 'Connections' | 'Triggers' | 'Activity' | 'Administration';
152
154
 
153
155
  export interface NavEntryContribution {
154
156
  /**
@@ -160,10 +162,18 @@ export interface NavEntryContribution {
160
162
  readonly title: string;
161
163
  /**
162
164
  * Optional target sidebar section (see {@link NavEntrySection}); absent ⇒ the
163
- * Plugins section. Additive — an older bundle omits it and stays on the same
164
- * plugin API version.
165
+ * plugin's own self-named section. Additive — an older bundle omits it and stays
166
+ * on the same plugin API version.
165
167
  */
166
168
  readonly section?: NavEntrySection;
169
+ /**
170
+ * Optional sort weight WITHIN the entry's section (whether a core section or the
171
+ * plugin's own self-named one): entries render in ascending `order`, and every
172
+ * entry that omits it sorts AFTER the ordered ones, in registration order. Two
173
+ * entries with the same `order` keep registration order (a stable sort). Additive —
174
+ * an older bundle omits it and stays on the same plugin API version.
175
+ */
176
+ readonly order?: number;
167
177
  /**
168
178
  * Optional icon rendered before the title. It must be a square inline SVG that
169
179
  * fills its box and draws with `currentColor`; the host constrains the slot,
@@ -34,7 +34,8 @@
34
34
  * checks live in the sibling `media` module.
35
35
  *
36
36
  * EXPRESSION FIELDS ARE OPT-IN. A string field carrying `x-tai42-expression`
37
- * renders an expression-authoring door — the SDK's `JqField` — only when the host
37
+ * renders an expression-authoring door — `JqField` from `@tai42/jq-studio` — only
38
+ * when the host
38
39
  * INJECTS one, through {@link SchemaFormProps.expressionField} or the ambient
39
40
  * {@link ExpressionFieldContext}; with no door it renders the plain string input.
40
41
  * The form owns no edge to the door: importing it here, even dynamically behind
@@ -88,8 +89,8 @@ export interface SchemaFormProps {
88
89
  readonly maxUploadBytes?: number;
89
90
  /**
90
91
  * Optional. The expression-authoring door for `x-tai42-expression` fields —
91
- * the SDK's `JqField`, which the host imports directly and passes here (see
92
- * the module doc-comment). Omitted, the enclosing
92
+ * `JqField` from `@tai42/jq-studio`, which the host imports directly and passes
93
+ * here (see the module doc-comment). Omitted, the enclosing
93
94
  * {@link ExpressionFieldContext} supplies it; absent both, an annotated field
94
95
  * renders the plain string input.
95
96
  */
@@ -3,8 +3,9 @@
3
3
  * EITHER a reference to an existing env key OR a freshly pasted secret. It knows
4
4
  * nothing about the `!ENV ${KEY}` marker syntax or any wire format: it emits a
5
5
  * discriminated {@link SecretRef} and the host maps that to whatever it stores
6
- * (the consumer that mounts this — the McpTab editor — writes an `!ENV ${KEY}`
7
- * marker for a `key` ref and runs the combined store-then-mark op for a `paste`).
6
+ * (the consumer that mounts this — the connectors page's McpServersSection —
7
+ * writes an `!ENV ${KEY}` marker for a `key` ref and runs the combined
8
+ * store-then-mark op for a `paste`).
8
9
  *
9
10
  * WRITE-ONLY for a pasted secret: the plaintext lives only in local editor state
10
11
  * until the user commits it, at which point it leaves the DOM entirely (held only
@@ -98,8 +98,8 @@ export interface ExpressionFieldProps {
98
98
  }
99
99
 
100
100
  /**
101
- * The expression-authoring door a host INJECTS into the form — `JqField`, imported
102
- * by the host straight from the SDK's jq surface.
101
+ * The expression-authoring door a host INJECTS into the form — `JqField`, which the
102
+ * host imports straight from `@tai42/jq-studio`.
103
103
  *
104
104
  * It is a COMPONENT, rendered as an element, never called as a function: the door
105
105
  * owns hooks and state (editor open, worker handle), which only survive as its own
@@ -10,6 +10,10 @@
10
10
  * `default` — an optional object/array is NOT auto-materialized, so its own
11
11
  * required children can't raise spurious errors on a field the user never
12
12
  * opted into.
13
+ *
14
+ * `skeletonValueForSchema` is the sibling entry for programmatic reseeds (a
15
+ * union variant switch): same walk, but required scalars mint their type's
16
+ * empty value so the committed seed validates against its own schema.
13
17
  */
14
18
  import { classifySchema } from './classify';
15
19
  import type { JsonSchema } from './types';
@@ -30,6 +34,30 @@ function explicitDefault(
30
34
  * whole-tool-schema call).
31
35
  */
32
36
  export function defaultValueForSchema(schema: JsonSchema, root: JsonSchema = schema): unknown {
37
+ return seedValue(schema, root, false);
38
+ }
39
+
40
+ /**
41
+ * A SELF-CONSISTENT skeleton for a schema — the seed a variant switch (or any
42
+ * other programmatic reseed) commits as the field's new value. Where
43
+ * {@link defaultValueForSchema} deliberately leaves a required scalar absent so
44
+ * a fresh form flags it, a committed skeleton must validate against its own
45
+ * schema, so here EVERY required field with a known empty value is minted:
46
+ * required strings seed `''`, numbers `0`, booleans `false` — consistently with
47
+ * the `[]` / `{}` / nested-object seeds the default path already mints. Kinds
48
+ * with no empty value to name (enum, union, free-form JSON) stay absent either
49
+ * way: inventing a choice would be a lie the user never made.
50
+ */
51
+ export function skeletonValueForSchema(schema: JsonSchema, root: JsonSchema = schema): unknown {
52
+ return seedValue(schema, root, true);
53
+ }
54
+
55
+ /**
56
+ * The shared seeding walk. `mintScalars` distinguishes the two entry points
57
+ * above: `false` leaves value-less required scalars absent; `true` mints their
58
+ * type's empty value.
59
+ */
60
+ function seedValue(schema: JsonSchema, root: JsonSchema, mintScalars: boolean): unknown {
33
61
  const classified = classifySchema(schema, root);
34
62
  const explicit = explicitDefault(schema, classified.schema);
35
63
  if (explicit.present) return explicit.value;
@@ -42,7 +70,7 @@ export function defaultValueForSchema(schema: JsonSchema, root: JsonSchema = sch
42
70
  const result: Record<string, unknown> = {};
43
71
  for (const [name, propSchema] of model.properties) {
44
72
  if (model.required.has(name)) {
45
- const seeded = defaultValueForSchema(propSchema, root);
73
+ const seeded = seedValue(propSchema, root, mintScalars);
46
74
  if (seeded !== undefined) result[name] = seeded;
47
75
  continue;
48
76
  }
@@ -56,15 +84,20 @@ export function defaultValueForSchema(schema: JsonSchema, root: JsonSchema = sch
56
84
  return [];
57
85
  case 'record':
58
86
  return {};
59
- case 'enum':
60
87
  case 'string':
88
+ return mintScalars ? '' : undefined;
61
89
  case 'number':
90
+ return mintScalars ? 0 : undefined;
62
91
  case 'boolean':
92
+ return mintScalars ? false : undefined;
93
+ case 'enum':
63
94
  case 'union':
64
95
  case 'json':
65
96
  // A free-form JSON field starts ABSENT (its editor seeds an empty buffer),
66
97
  // like the other value-less kinds: an optional one never invents a value,
67
- // and a required one is left absent so validation flags it.
98
+ // and a required one is left absent so validation flags it. These kinds
99
+ // have no honest empty value even for a minted skeleton — an enum or a
100
+ // union would need a CHOICE invented on the user's behalf.
68
101
  return undefined;
69
102
  }
70
103
  }
@@ -30,6 +30,13 @@ interface FieldNodeProps {
30
30
  /** The label to show — a property key, array-item caption, or `undefined` at root. */
31
31
  readonly label: string | undefined;
32
32
  readonly required: boolean;
33
+ /**
34
+ * Append the design system's required marker (a trailing ` *`, the same
35
+ * convention host forms use on their own labels) to the heading when the
36
+ * field is required. Opt-in per face: the union variant surface sets it so
37
+ * the ACTIVE variant's obligations read at a glance.
38
+ */
39
+ readonly markRequired?: boolean;
33
40
  readonly errors: SchemaFormErrors | undefined;
34
41
  readonly idPrefix: string;
35
42
  }
@@ -40,9 +47,21 @@ function labelText(explicit: string | undefined, label: string | undefined): str
40
47
 
41
48
  /** A single schema node, dispatched on its classified model kind. */
42
49
  export function FieldNode(props: FieldNodeProps): ReactNode {
43
- const { schema, root, value, onChange, path, label, required, errors, idPrefix } = props;
50
+ const {
51
+ schema,
52
+ root,
53
+ value,
54
+ onChange,
55
+ path,
56
+ label,
57
+ required,
58
+ markRequired = false,
59
+ errors,
60
+ idPrefix,
61
+ } = props;
44
62
  const classified = classifySchema(schema, root);
45
- const heading = labelText(classified.title, label);
63
+ const base = labelText(classified.title, label);
64
+ const heading = markRequired && required ? `${base} *` : base;
46
65
  const description = classified.description;
47
66
  const error = errors?.[path];
48
67
  const model = classified.model;
@@ -4,17 +4,17 @@
4
4
  * component plus the value/validation helpers a caller needs to seed, validate,
5
5
  * and submit.
6
6
  *
7
- * THE JQ-FREE ENTRY POINT. This module is published as
8
- * `@tai42/studio-sdk/schema-form`, and a consumer that BUNDLES the SDK — a
9
- * standalone widget rather than a host serving the SDK as a shared module —
10
- * should import the form from here rather than from the package barrel. The
11
- * barrel re-exports `JqField` off `@tai42/jq-studio`, and a bundler emits that
12
- * package's Web Worker file and wasm engine from the mere presence of the
13
- * MODULE in the graph: those assets are emitted while the module is transformed,
14
- * before tree-shaking can drop the unused re-export. Reaching the form through
15
- * this entry point, and injecting the door only where it is wanted (see
16
- * `SchemaForm`'s `expressionField`), is what holds a form-only consumer at zero
17
- * jq bytes.
7
+ * THE FORM-ONLY ENTRY POINT. This module is published as
8
+ * `@tai42/studio-sdk/schema-form` for a consumer that BUNDLES the SDK — a
9
+ * standalone widget rather than a host serving the SDK as a shared module — and
10
+ * wants the form without the barrel: no CSS side effects, and no module graph
11
+ * beyond what the form itself renders.
12
+ *
13
+ * It is also jq-free, and so is the barrel: the visual editor lives in the
14
+ * separately published `@tai42/jq-studio`, which nothing under the SDK imports at
15
+ * runtime. Expression authoring reaches a form only by INJECTION (see
16
+ * `SchemaForm`'s `expressionField` and `ExpressionFieldContext`), so a consumer
17
+ * that never injects a door ships zero jq bytes through either entry point.
18
18
  *
19
19
  * The design system reaches this entry point through relative imports, so the
20
20
  * form renders complete; only the barrel's three CSS side-effect imports stay
@@ -23,6 +23,7 @@ export function ObjectFields({
23
23
  errors,
24
24
  idPrefix,
25
25
  skip,
26
+ markRequired = false,
26
27
  }: {
27
28
  properties: readonly (readonly [string, JsonSchema])[];
28
29
  requiredKeys: ReadonlySet<string>;
@@ -33,6 +34,8 @@ export function ObjectFields({
33
34
  errors: SchemaFormErrors | undefined;
34
35
  idPrefix: string;
35
36
  skip: string | undefined;
37
+ /** Mark each required property's label with the DS required marker. */
38
+ markRequired?: boolean;
36
39
  }): ReactNode {
37
40
  const obj = isPlainObject(value) ? value : {};
38
41
  return (
@@ -52,6 +55,7 @@ export function ObjectFields({
52
55
  path={childPath}
53
56
  label={name}
54
57
  required={requiredKeys.has(name)}
58
+ markRequired={markRequired}
55
59
  errors={errors}
56
60
  idPrefix={idPrefix}
57
61
  />
@@ -6,8 +6,9 @@
6
6
  * control instead; and when the form supplies a completion provider, the input is
7
7
  * backed by argument autocomplete.
8
8
  *
9
- * NO JQ EDGE. This module — and every module under `schema-form` — names no jq
10
- * type and imports the SDK's `../jq` re-export neither statically NOR dynamically.
9
+ * NO JQ EDGE. This module — and every module under `schema-form`, and the package
10
+ * barrel above them — names no jq type and imports `@tai42/jq-studio` neither
11
+ * statically NOR dynamically.
11
12
  * The jq authoring door drags a heavy subgraph (the xyflow visual editor, a Web
12
13
  * Worker entry, and a multi-megabyte wasm engine), and a bundler EMITS that whole
13
14
  * subgraph for a dynamic import just as it does for a static one — `lazy` defers
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * The union field renderer: a variant picker plus the active variant's fields.
3
3
  * An object variant renders its properties inline (skipping the discriminator
4
- * tag, which the picker owns); a scalar/enum variant renders as a single nested
5
- * {@link FieldNode}. Selecting a variant seeds a concrete value for it.
4
+ * tag, which the picker owns), with the variant's required fields carrying the
5
+ * design system's required marker; a scalar/enum variant renders as a single
6
+ * nested {@link FieldNode}. Selecting a variant seeds a concrete value for it,
7
+ * preserving the fields the outgoing value shares with the new variant (see
8
+ * {@link switchVariantValue}).
6
9
  */
7
10
  import type { ReactNode } from 'react';
8
11
 
9
12
  import { Field } from '../components/field';
10
13
  import { Select } from '../components/select';
11
- import { classifySchema, type FieldModel } from './classify';
12
- import { defaultValueForSchema } from './default-value';
14
+ import { classifySchema, type FieldModel, type UnionVariant } from './classify';
15
+ import { skeletonValueForSchema } from './default-value';
13
16
  import { FieldGroup } from './field-group';
14
17
  import { FieldNode } from './field-node';
15
18
  import { ObjectFields } from './object-fields';
@@ -18,28 +21,87 @@ import { groupClass } from './styles';
18
21
  import { activeVariantIndex } from './union';
19
22
  import type { JsonSchema, SchemaFormErrors } from './types';
20
23
 
24
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
26
+ }
27
+
21
28
  /**
22
- * A concrete seed for a chosen union variant. Object variants get their
23
- * required-field skeleton; a scalar/enum variant that has no explicit default
29
+ * A concrete seed for a chosen union variant. Object variants get their full
30
+ * skeleton (every required field minted, so the seed validates against the
31
+ * variant's own schema); a scalar/enum variant that has no explicit default
24
32
  * still needs a non-`undefined` value so the selection sticks (otherwise
25
33
  * `activeVariantIndex` reads it back as "no variant" and the picker snaps empty).
26
34
  */
27
35
  function seedVariantValue(schema: JsonSchema, root: JsonSchema): unknown {
28
- const seeded = defaultValueForSchema(schema, root);
36
+ const seeded = skeletonValueForSchema(schema, root);
29
37
  if (seeded !== undefined) return seeded;
30
38
  const resolved = resolveRef(schema, root);
31
39
  if (resolved.const !== undefined) return resolved.const;
32
40
  if (resolved.enum !== undefined && resolved.enum.length > 0) return resolved.enum[0];
33
- switch (classifySchema(schema, root).model.kind) {
34
- case 'string':
35
- return '';
36
- case 'number':
37
- return 0;
38
- case 'boolean':
39
- return false;
40
- default:
41
- return undefined;
41
+ return undefined;
42
+ }
43
+
44
+ /**
45
+ * The value committed when the user switches to `target`. Typed values SURVIVE
46
+ * the switch: the target's skeleton is minted first, then every top-level field
47
+ * of the current value whose NAME exists in the target variant's properties is
48
+ * carried over wholesale — the merge happens at the top level only, each carried
49
+ * field keeping its nested contents as-is — so fields both variants share are
50
+ * never silently discarded. Only fields absent from the current value keep
51
+ * their freshly minted seed. The discriminator tag is never carried: it always
52
+ * comes from the target's seed (the old tag would re-select the old variant).
53
+ * A non-object side (scalar variant, or a non-object current value) has no
54
+ * fields to carry, so it reseeds wholesale.
55
+ */
56
+ function switchVariantValue(
57
+ current: unknown,
58
+ target: UnionVariant,
59
+ discriminator: string | undefined,
60
+ root: JsonSchema,
61
+ ): unknown {
62
+ const seeded = seedVariantValue(target.schema, root);
63
+ if (!isPlainObject(current) || !isPlainObject(seeded)) return seeded;
64
+ const { model } = classifySchema(target.schema, root);
65
+ if (model.kind !== 'object') return seeded;
66
+ const targetNames = new Set(model.properties.map(([name]) => name));
67
+ const merged: Record<string, unknown> = { ...seeded };
68
+ for (const [name, held] of Object.entries(current)) {
69
+ if (name === discriminator) continue;
70
+ if (targetNames.has(name)) merged[name] = held;
71
+ }
72
+ return merged;
73
+ }
74
+
75
+ /** Title-case a property name: `kind` → "Kind", `content_type` → "Content Type". */
76
+ function titleCaseName(name: string): string {
77
+ return name
78
+ .split(/[_\-\s]+/)
79
+ .filter((part) => part.length > 0)
80
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
81
+ .join(' ');
82
+ }
83
+
84
+ /**
85
+ * The variant picker's label. A discriminated union names the picker after the
86
+ * discriminator property it sets — the property's schema `title` when a variant
87
+ * declares one, else the property name title-cased — so the picker reads as the
88
+ * field it is ("Kind", "Format"). Only a union without a discriminator falls
89
+ * back to the generic "Variant".
90
+ */
91
+ function pickerLabel(
92
+ discriminator: string | undefined,
93
+ variants: readonly UnionVariant[],
94
+ root: JsonSchema,
95
+ ): string {
96
+ if (discriminator === undefined) return 'Variant';
97
+ for (const variant of variants) {
98
+ const property = resolveRef(variant.schema, root).properties?.[discriminator];
99
+ if (property === undefined) continue;
100
+ const title = property.title ?? resolveRef(property, root).title;
101
+ if (typeof title === 'string' && title.length > 0) return title;
42
102
  }
103
+ const cased = titleCaseName(discriminator);
104
+ return cased.length > 0 ? cased : 'Variant';
43
105
  }
44
106
 
45
107
  export function UnionField({
@@ -74,15 +136,18 @@ export function UnionField({
74
136
  const position = Number(key);
75
137
  const variant = model.variants[position];
76
138
  if (variant === undefined) return;
77
- onChange(seedVariantValue(variant.schema, root));
139
+ onChange(switchVariantValue(value, variant, model.discriminator, root));
78
140
  };
79
141
 
80
142
  const active = index === -1 ? undefined : model.variants[index];
81
143
  const activeClassified = active === undefined ? undefined : classifySchema(active.schema, root);
82
144
 
83
145
  return (
84
- <FieldGroup heading={heading} description={description} error={error} atRoot={false}>
85
- <Field label="Variant">
146
+ // At the form root the union sits inside a field the HOST already labels
147
+ // and describes — a synthetic heading there is noise, so it is suppressed
148
+ // exactly as a root object's is; nested unions keep theirs.
149
+ <FieldGroup heading={heading} description={description} error={error} atRoot={path === ''}>
150
+ <Field label={pickerLabel(model.discriminator, model.variants, root)}>
86
151
  <Select
87
152
  options={options}
88
153
  value={index === -1 ? '' : String(index)}
@@ -102,6 +167,7 @@ export function UnionField({
102
167
  errors={errors}
103
168
  idPrefix={idPrefix}
104
169
  skip={model.discriminator}
170
+ markRequired
105
171
  />
106
172
  </div>
107
173
  ) : active !== undefined ? (
package/dist/jq.d.ts DELETED
@@ -1,31 +0,0 @@
1
- /**
2
- * The SDK's jq surface — a THIN re-export of `@tai42/jq-studio`, the standalone,
3
- * embeddable visual jq editor. There is no tai42-owned jq component: the platform
4
- * owns zero jq code. This module exists only so a feature package (which depends
5
- * on `@tai42/studio-sdk` alone, never on `@tai42/jq-studio` directly) can reach
6
- * the drop-in `JqField`, the lower-level `JqEditorDialog`, the agnostic
7
- * field-declaration contract, and the runtime loader/worker helpers.
8
- *
9
- * NOTHING IN THE SDK IMPORTS THIS MODULE. Every edge to it runs the other way: a
10
- * host imports `JqField` from here and hands it in — to a `SchemaForm` through its
11
- * `expressionField` prop or the ambient `ExpressionFieldContext`, or straight into
12
- * its own JSX. That is what keeps this heavy subgraph — the visual editor, its
13
- * worker file, and its wasm engine — OUT of the bundle of a consumer that imports
14
- * the SDK for anything else; a bundler emits a dynamic import's chunks as surely
15
- * as a static import's, so `lazy` inside the SDK would not have kept them out.
16
- *
17
- * BUNDLE + WORKER SHARING. A plugin bundle externalises `@tai42/studio-sdk`, so
18
- * the SDK — and the one copy of `@tai42/jq-studio` it carries — ships once for the
19
- * whole deployment. A plugin that renders `JqField` shares the host's single jq
20
- * runtime, its single primitives injection (see the host's `PrimitivesProvider`
21
- * wiring), and the single worker the host installs at boot
22
- * ({@link installDefaultJqWorker}); it never bundles its own.
23
- *
24
- * The host app injects the SDK's design-system primitives into jq-studio ONCE at
25
- * the root through jq-studio's `PrimitivesProvider` and installs the shared worker
26
- * there, so every `JqField` — host feature or plugin — paints in the SDK design
27
- * system and evaluates off the main thread with no per-site wiring.
28
- */
29
- export { JqField, JQEditorDialog, JqEditorDialog, preloadJq, installDefaultJqWorker, PrimitivesProvider, } from '@tai42/jq-studio';
30
- export type { JqFieldProps, JQEditorDialogProps, JqEditorDialogProps, ExpressionLanguage, JqInputKey, JqInputShapeDescriptor, SampleInputProvider, ServerValidationResult, ServerValidateHook, JqFieldDeclaration, Primitives, } from '@tai42/jq-studio';
31
- //# sourceMappingURL=jq.d.ts.map
package/dist/jq.d.ts.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"jq.d.ts","sourceRoot":"","sources":["../src/jq.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EACL,OAAO,EACP,cAAc,EACd,cAAc,EACd,SAAS,EACT,sBAAsB,EAWtB,kBAAkB,GACnB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,YAAY,EACZ,mBAAmB,EACnB,mBAAmB,EACnB,kBAAkB,EAClB,UAAU,EACV,sBAAsB,EACtB,mBAAmB,EACnB,sBAAsB,EACtB,kBAAkB,EAClB,kBAAkB,EAGlB,UAAU,GACX,MAAM,kBAAkB,CAAC"}
package/dist/jq.js DELETED
@@ -1,41 +0,0 @@
1
- /**
2
- * The SDK's jq surface — a THIN re-export of `@tai42/jq-studio`, the standalone,
3
- * embeddable visual jq editor. There is no tai42-owned jq component: the platform
4
- * owns zero jq code. This module exists only so a feature package (which depends
5
- * on `@tai42/studio-sdk` alone, never on `@tai42/jq-studio` directly) can reach
6
- * the drop-in `JqField`, the lower-level `JqEditorDialog`, the agnostic
7
- * field-declaration contract, and the runtime loader/worker helpers.
8
- *
9
- * NOTHING IN THE SDK IMPORTS THIS MODULE. Every edge to it runs the other way: a
10
- * host imports `JqField` from here and hands it in — to a `SchemaForm` through its
11
- * `expressionField` prop or the ambient `ExpressionFieldContext`, or straight into
12
- * its own JSX. That is what keeps this heavy subgraph — the visual editor, its
13
- * worker file, and its wasm engine — OUT of the bundle of a consumer that imports
14
- * the SDK for anything else; a bundler emits a dynamic import's chunks as surely
15
- * as a static import's, so `lazy` inside the SDK would not have kept them out.
16
- *
17
- * BUNDLE + WORKER SHARING. A plugin bundle externalises `@tai42/studio-sdk`, so
18
- * the SDK — and the one copy of `@tai42/jq-studio` it carries — ships once for the
19
- * whole deployment. A plugin that renders `JqField` shares the host's single jq
20
- * runtime, its single primitives injection (see the host's `PrimitivesProvider`
21
- * wiring), and the single worker the host installs at boot
22
- * ({@link installDefaultJqWorker}); it never bundles its own.
23
- *
24
- * The host app injects the SDK's design-system primitives into jq-studio ONCE at
25
- * the root through jq-studio's `PrimitivesProvider` and installs the shared worker
26
- * there, so every `JqField` — host feature or plugin — paints in the SDK design
27
- * system and evaluates off the main thread with no per-site wiring.
28
- */
29
- export { JqField, JQEditorDialog, JqEditorDialog, preloadJq, installDefaultJqWorker,
30
- // The design-system injection point. jq-studio renders its editor chrome
31
- // through primitives it reads from a module-scoped React context; the host
32
- // mounts `PrimitivesProvider` ONCE at the root to substitute the SDK design
33
- // system. It is re-exported HERE — off the same `@tai42/jq-studio` instance
34
- // `JqField` comes from — so the host reaches it through the ONE shared SDK the
35
- // import map serves, and the provider's context is the SAME object `JqField`
36
- // reads. A host that imported `PrimitivesProvider` from `@tai42/jq-studio`
37
- // directly would bundle a SECOND jq-studio copy: its `createContext` would be a
38
- // different object, the injection would silently fall back to the built-ins,
39
- // and the shell would ship a duplicate (orphan) jq worker + wasm.
40
- PrimitivesProvider, } from '@tai42/jq-studio';
41
- //# sourceMappingURL=jq.js.map
package/dist/jq.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"jq.js","sourceRoot":"","sources":["../src/jq.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EACL,OAAO,EACP,cAAc,EACd,cAAc,EACd,SAAS,EACT,sBAAsB;AACtB,yEAAyE;AACzE,2EAA2E;AAC3E,4EAA4E;AAC5E,4EAA4E;AAC5E,+EAA+E;AAC/E,6EAA6E;AAC7E,2EAA2E;AAC3E,gFAAgF;AAChF,6EAA6E;AAC7E,kEAAkE;AAClE,kBAAkB,GACnB,MAAM,kBAAkB,CAAC"}