@tai42/studio-sdk 20.0.0 → 22.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 (143) hide show
  1. package/dist/components/chip-group.d.ts +42 -0
  2. package/dist/components/chip-group.d.ts.map +1 -0
  3. package/dist/components/chip-group.js +85 -0
  4. package/dist/components/chip-group.js.map +1 -0
  5. package/dist/components/components.css +16 -3
  6. package/dist/components/execution-key-picker.js.map +1 -1
  7. package/dist/components/extension-grouping.d.ts +8 -12
  8. package/dist/components/extension-grouping.d.ts.map +1 -1
  9. package/dist/components/extension-grouping.js +12 -15
  10. package/dist/components/extension-grouping.js.map +1 -1
  11. package/dist/components/extension-picker.d.ts +3 -4
  12. package/dist/components/extension-picker.d.ts.map +1 -1
  13. package/dist/components/extension-picker.js +10 -10
  14. package/dist/components/extension-picker.js.map +1 -1
  15. package/dist/components/fleet-report.d.ts +10 -5
  16. package/dist/components/fleet-report.d.ts.map +1 -1
  17. package/dist/components/fleet-report.js +20 -3
  18. package/dist/components/fleet-report.js.map +1 -1
  19. package/dist/components/json-tree-model.d.ts +19 -7
  20. package/dist/components/json-tree-model.d.ts.map +1 -1
  21. package/dist/components/json-tree-model.js +32 -8
  22. package/dist/components/json-tree-model.js.map +1 -1
  23. package/dist/components/json-tree.d.ts +10 -1
  24. package/dist/components/json-tree.d.ts.map +1 -1
  25. package/dist/components/json-tree.js +7 -4
  26. package/dist/components/json-tree.js.map +1 -1
  27. package/dist/components/select.d.ts +6 -0
  28. package/dist/components/select.d.ts.map +1 -1
  29. package/dist/components/select.js +3 -2
  30. package/dist/components/select.js.map +1 -1
  31. package/dist/components/served-media.d.ts +50 -4
  32. package/dist/components/served-media.d.ts.map +1 -1
  33. package/dist/components/served-media.js +69 -19
  34. package/dist/components/served-media.js.map +1 -1
  35. package/dist/env-markers.d.ts +25 -0
  36. package/dist/env-markers.d.ts.map +1 -0
  37. package/dist/env-markers.js +63 -0
  38. package/dist/env-markers.js.map +1 -0
  39. package/dist/hooks/interactions-stream.d.ts.map +1 -1
  40. package/dist/hooks/interactions-stream.js +9 -7
  41. package/dist/hooks/interactions-stream.js.map +1 -1
  42. package/dist/hooks/useApi.d.ts +2264 -912
  43. package/dist/hooks/useApi.d.ts.map +1 -1
  44. package/dist/hooks/useCapabilities.d.ts +5 -9
  45. package/dist/hooks/useCapabilities.d.ts.map +1 -1
  46. package/dist/hooks/useCapabilities.js +8 -33
  47. package/dist/hooks/useCapabilities.js.map +1 -1
  48. package/dist/host.d.ts +2 -0
  49. package/dist/host.d.ts.map +1 -1
  50. package/dist/host.js +8 -0
  51. package/dist/host.js.map +1 -1
  52. package/dist/index.d.ts +12 -6
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +5 -3
  55. package/dist/index.js.map +1 -1
  56. package/dist/plugin/coverage.d.ts +14 -0
  57. package/dist/plugin/coverage.d.ts.map +1 -0
  58. package/dist/plugin/coverage.js +14 -0
  59. package/dist/plugin/coverage.js.map +1 -0
  60. package/dist/plugin/registry.d.ts +11 -1
  61. package/dist/plugin/registry.d.ts.map +1 -1
  62. package/dist/plugin/registry.js +33 -4
  63. package/dist/plugin/registry.js.map +1 -1
  64. package/dist/plugin/types.d.ts +43 -1
  65. package/dist/plugin/types.d.ts.map +1 -1
  66. package/dist/schema-form/SchemaForm.d.ts +11 -1
  67. package/dist/schema-form/SchemaForm.d.ts.map +1 -1
  68. package/dist/schema-form/SchemaForm.js +2 -2
  69. package/dist/schema-form/SchemaForm.js.map +1 -1
  70. package/dist/schema-form/classify.d.ts.map +1 -1
  71. package/dist/schema-form/classify.js +54 -2
  72. package/dist/schema-form/classify.js.map +1 -1
  73. package/dist/schema-form/date-range-field.d.ts +44 -0
  74. package/dist/schema-form/date-range-field.d.ts.map +1 -0
  75. package/dist/schema-form/date-range-field.js +72 -0
  76. package/dist/schema-form/date-range-field.js.map +1 -0
  77. package/dist/schema-form/enum-field.d.ts +3 -1
  78. package/dist/schema-form/enum-field.d.ts.map +1 -1
  79. package/dist/schema-form/enum-field.js +8 -2
  80. package/dist/schema-form/enum-field.js.map +1 -1
  81. package/dist/schema-form/field-model.d.ts +20 -1
  82. package/dist/schema-form/field-model.d.ts.map +1 -1
  83. package/dist/schema-form/field-node.d.ts +10 -0
  84. package/dist/schema-form/field-node.d.ts.map +1 -1
  85. package/dist/schema-form/field-node.js +6 -3
  86. package/dist/schema-form/field-node.js.map +1 -1
  87. package/dist/schema-form/index.d.ts +3 -1
  88. package/dist/schema-form/index.d.ts.map +1 -1
  89. package/dist/schema-form/index.js +1 -0
  90. package/dist/schema-form/index.js.map +1 -1
  91. package/dist/schema-form/multiselect-field.d.ts.map +1 -1
  92. package/dist/schema-form/multiselect-field.js +11 -0
  93. package/dist/schema-form/multiselect-field.js.map +1 -1
  94. package/dist/schema-form/object-fields.d.ts +9 -1
  95. package/dist/schema-form/object-fields.d.ts.map +1 -1
  96. package/dist/schema-form/object-fields.js +16 -3
  97. package/dist/schema-form/object-fields.js.map +1 -1
  98. package/dist/schema-form/string-field.d.ts +6 -1
  99. package/dist/schema-form/string-field.d.ts.map +1 -1
  100. package/dist/schema-form/string-field.js +42 -8
  101. package/dist/schema-form/string-field.js.map +1 -1
  102. package/dist/schema-form/types.d.ts +36 -0
  103. package/dist/schema-form/types.d.ts.map +1 -1
  104. package/dist/state-binding/UpdateRow.d.ts +2 -2
  105. package/dist/state-binding/UpdateRow.js +1 -1
  106. package/dist/state-binding/UpdateRow.js.map +1 -1
  107. package/dist/state-binding/adapter.d.ts +12 -8
  108. package/dist/state-binding/adapter.d.ts.map +1 -1
  109. package/dist/state-binding/adapter.js +30 -13
  110. package/dist/state-binding/adapter.js.map +1 -1
  111. package/package.json +5 -4
  112. package/src/components/chip-group.tsx +157 -0
  113. package/src/components/components.css +16 -3
  114. package/src/components/execution-key-picker.tsx +1 -1
  115. package/src/components/extension-grouping.ts +22 -22
  116. package/src/components/extension-picker.tsx +13 -14
  117. package/src/components/fleet-report.tsx +66 -7
  118. package/src/components/json-tree-model.ts +36 -8
  119. package/src/components/json-tree.tsx +24 -4
  120. package/src/components/select.tsx +11 -1
  121. package/src/components/served-media.tsx +150 -16
  122. package/src/env-markers.ts +70 -0
  123. package/src/hooks/interactions-stream.ts +9 -7
  124. package/src/hooks/useCapabilities.ts +8 -31
  125. package/src/host.ts +2 -0
  126. package/src/index.ts +22 -2
  127. package/src/plugin/coverage.ts +23 -0
  128. package/src/plugin/registry.ts +58 -3
  129. package/src/plugin/types.ts +45 -1
  130. package/src/schema-form/SchemaForm.tsx +10 -0
  131. package/src/schema-form/classify.ts +59 -3
  132. package/src/schema-form/date-range-field.tsx +167 -0
  133. package/src/schema-form/enum-field.tsx +20 -0
  134. package/src/schema-form/field-model.ts +24 -2
  135. package/src/schema-form/field-node.tsx +19 -0
  136. package/src/schema-form/index.ts +10 -1
  137. package/src/schema-form/multiselect-field.tsx +21 -0
  138. package/src/schema-form/object-fields.tsx +36 -2
  139. package/src/schema-form/string-field.tsx +72 -1
  140. package/src/schema-form/types.ts +38 -0
  141. package/src/state-binding/README.md +10 -7
  142. package/src/state-binding/UpdateRow.tsx +3 -3
  143. package/src/state-binding/adapter.ts +31 -16
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import './components/components.css';
41
41
 
42
42
  // -- Plugin API --------------------------------------------------------------
43
43
  export type {
44
+ ChannelConversationRoute,
44
45
  NavEntryContribution,
45
46
  NavEntrySection,
46
47
  PageContribution,
@@ -51,8 +52,11 @@ export type {
51
52
  PluginPageProps,
52
53
  RegisteredNavEntry,
53
54
  RegisteredPage,
55
+ RegisteredRoutePanel,
54
56
  RegisteredSettingsTab,
55
57
  RequiredCapabilities,
58
+ RoutePanelContribution,
59
+ RoutePanelProps,
56
60
  SettingsTabContribution,
57
61
  SettingsTabProps,
58
62
  ToolPanelContribution,
@@ -60,6 +64,8 @@ export type {
60
64
  } from './plugin/types';
61
65
  export type { VersionGateResult } from './plugin/version';
62
66
  export { checkPluginApiVersion, STUDIO_PLUGIN_API_VERSION } from './plugin/version';
67
+ /** What `useApi().runTool` resolves with: the run-tool door's answer, read by its `kind`. */
68
+ export type { RunAnswer } from '@tai42/api-client';
63
69
 
64
70
  // -- Hooks -------------------------------------------------------------------
65
71
  export type { ApiClient } from './hooks/useApi';
@@ -133,6 +139,8 @@ export {
133
139
 
134
140
  // -- Utilities ---------------------------------------------------------------
135
141
  export { downloadBlob } from './download';
142
+ export type { EnvRef } from './env-markers';
143
+ export { collectEnvRefs, ENV_MARKER_PREFIX, formatEnvMarker, parseEnvMarker } from './env-markers';
136
144
  export { errorMessage } from './errors';
137
145
 
138
146
  // -- Disabled-feature idiom (one helper + component, every consumer) ----------
@@ -163,6 +171,8 @@ export type { BadgeProps } from './components/badge';
163
171
  export { Badge } from './components/badge';
164
172
  export type { CheckboxProps } from './components/checkbox';
165
173
  export { Checkbox } from './components/checkbox';
174
+ export type { ChipGroupProps, ChipOption } from './components/chip-group';
175
+ export { ChipGroup } from './components/chip-group';
166
176
  export type { ConfirmDialogProps } from './components/confirm-dialog';
167
177
  export { ConfirmDialog } from './components/confirm-dialog';
168
178
  export type { DialogProps } from './components/dialog';
@@ -292,11 +302,18 @@ export type { HeadingLevel, MarkdownBlock, MarkdownProps } from './components/ma
292
302
  export { Markdown, parseMarkdown } from './components/markdown';
293
303
  export type { OpenTargetOptions, OpenTargetProps } from './components/open-target';
294
304
  export { openTargetProps } from './components/open-target';
295
- export type { MediaImageProps } from './components/served-media';
305
+ export type {
306
+ MediaDocumentProps,
307
+ MediaImageProps,
308
+ MediaPlayerProps,
309
+ ServedMediaGrammar,
310
+ } from './components/served-media';
296
311
  export {
297
312
  isRenderableMediaSrc,
298
313
  isServedMediaUrl,
314
+ MediaDocument,
299
315
  MediaImage,
316
+ MediaPlayer,
300
317
  resolveMediaSrc,
301
318
  } from './components/served-media';
302
319
  // The URL half of the link-safety pair: `isSafeHttpUrl` answers yes/no, this one
@@ -313,7 +330,6 @@ export {
313
330
  groupByKind,
314
331
  groupIntoFamilies,
315
332
  kindVariant,
316
- NON_STACKABLE_KIND,
317
333
  } from './components/extension-grouping';
318
334
  export type { ExtensionPickerProps } from './components/extension-picker';
319
335
  export { ExtensionPicker } from './components/extension-picker';
@@ -399,11 +415,14 @@ export {
399
415
  // -- Schema-driven forms -----------------------------------------------------
400
416
  export type {
401
417
  CompletionProvider,
418
+ DateRangePair,
402
419
  Discriminator,
403
420
  ExpressionFieldComponent,
404
421
  ExpressionFieldProps,
405
422
  ExpressionInputKey,
406
423
  ExpressionInputShape,
424
+ FormControl,
425
+ FormInputMode,
407
426
  JsonSchema,
408
427
  JsonSchemaType,
409
428
  RecordEntryContext,
@@ -414,6 +433,7 @@ export type {
414
433
  SecretRefFieldProps,
415
434
  } from './schema-form';
416
435
  export {
436
+ dateRangePairs,
417
437
  defaultValueForSchema,
418
438
  // The expression-door injection point: a host that wants `x-tai42-expression`
419
439
  // fields to author through the visual editor imports `JqField` from
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Whether a plugin contribution is visible under a capability projection — the one
3
+ * evaluator every host surface applies to a contribution's optional
4
+ * `requiredCapabilities`. Host-only (`@tai42/studio-sdk/host`).
5
+ */
6
+ import type { MeProjection } from '@tai42/api-client';
7
+
8
+ import { coversAnyRoute, isFullProjection } from '../hooks/useCapabilities';
9
+ import type { RequiredCapabilities } from './types';
10
+
11
+ /**
12
+ * A full projection shows every contribution; an ABSENT requirement renders only for a
13
+ * full projection (safe-by-default); a declared requirement renders when the projection
14
+ * reaches at least one of its routes.
15
+ */
16
+ export function contributionCovered(
17
+ projection: MeProjection,
18
+ required: RequiredCapabilities | undefined,
19
+ ): boolean {
20
+ if (isFullProjection(projection)) return true;
21
+ if (required === undefined) return false;
22
+ return coversAnyRoute(projection, required.routes);
23
+ }
@@ -14,7 +14,9 @@ import type {
14
14
  PluginEntry,
15
15
  RegisteredNavEntry,
16
16
  RegisteredPage,
17
+ RegisteredRoutePanel,
17
18
  RegisteredSettingsTab,
19
+ RoutePanelContribution,
18
20
  SettingsTabContribution,
19
21
  ToolPanelContribution,
20
22
  } from './types';
@@ -23,6 +25,18 @@ const toolPanels = new Map<string, ToolPanelContribution>();
23
25
  const pages: RegisteredPage[] = [];
24
26
  const settingsTabs: RegisteredSettingsTab[] = [];
25
27
  const navEntries: RegisteredNavEntry[] = [];
28
+ const routePanels = new Map<string, RegisteredRoutePanel>();
29
+
30
+ /**
31
+ * What the host knows about a plugin from its registry listing entry: `mounts`, the
32
+ * plugin's own route items and their resolved absolute bases (`{item: "/api/<base>"}`),
33
+ * and `declaredRoutePanels`, the channels its manifest lists under
34
+ * `contributions.route_panels`.
35
+ */
36
+ export interface PluginLoadInputs {
37
+ readonly mounts: Readonly<Record<string, string>>;
38
+ readonly declaredRoutePanels: readonly string[];
39
+ }
26
40
 
27
41
  /** The contributions one plugin stages before they commit as a batch. */
28
42
  interface StagedContributions {
@@ -31,6 +45,7 @@ interface StagedContributions {
31
45
  readonly settingsTabs: RegisteredSettingsTab[];
32
46
  readonly tabIds: Set<string>;
33
47
  readonly navEntries: RegisteredNavEntry[];
48
+ readonly routePanels: Map<string, RegisteredRoutePanel>;
34
49
  }
35
50
 
36
51
  /**
@@ -48,6 +63,7 @@ function emptyStaged(): StagedContributions {
48
63
  settingsTabs: [],
49
64
  tabIds: new Set(),
50
65
  navEntries: [],
66
+ routePanels: new Map(),
51
67
  };
52
68
  }
53
69
 
@@ -63,6 +79,7 @@ function makeStagingContext(
63
79
  pluginId: string,
64
80
  staged: StagedContributions,
65
81
  seal: Seal,
82
+ host: PluginLoadInputs,
66
83
  ): PluginContext {
67
84
  const assertOpen = (): void => {
68
85
  if (seal.closed) {
@@ -120,6 +137,16 @@ function makeStagingContext(
120
137
  }
121
138
  staged.navEntries.push({ ...contribution, pluginId });
122
139
  },
140
+ registerRoutePanel(contribution: RoutePanelContribution): void {
141
+ assertOpen();
142
+ if (staged.routePanels.has(contribution.channel) || routePanels.has(contribution.channel)) {
143
+ throw new Error(`a route panel is already registered for channel ${contribution.channel}`);
144
+ }
145
+ staged.routePanels.set(contribution.channel, { ...contribution, pluginId });
146
+ },
147
+ routeBase(itemName: string): string | null {
148
+ return Object.hasOwn(host.mounts, itemName) ? (host.mounts[itemName] ?? null) : null;
149
+ },
123
150
  };
124
151
  }
125
152
 
@@ -142,6 +169,27 @@ function assertNavEntriesHavePages(pluginId: string, staged: StagedContributions
142
169
  }
143
170
  }
144
171
 
172
+ /**
173
+ * The channels a plugin registered route panels for must be exactly the channels its
174
+ * manifest declares: a bundle and its manifest are built together, so a mismatch is a
175
+ * build fault. Throwing BEFORE any commit keeps atomicity.
176
+ */
177
+ function assertRoutePanelsDeclared(
178
+ pluginId: string,
179
+ staged: StagedContributions,
180
+ declared: readonly string[],
181
+ ): void {
182
+ const registered = [...staged.routePanels.keys()].sort();
183
+ const manifest = [...new Set(declared)].sort();
184
+ const same =
185
+ registered.length === manifest.length && registered.every((c, i) => c === manifest[i]);
186
+ if (!same) {
187
+ throw new Error(
188
+ `plugin “${pluginId}” registered route panels [${registered.join(', ')}] but its manifest declares [${manifest.join(', ')}]`,
189
+ );
190
+ }
191
+ }
192
+
145
193
  /**
146
194
  * Commit the staged contributions as one batch, so the registry only ever holds a
147
195
  * plugin's complete set — never a partial set left behind by a mid-registration failure.
@@ -151,6 +199,7 @@ function commitStaged(staged: StagedContributions): void {
151
199
  for (const [toolName, contribution] of staged.toolPanels) toolPanels.set(toolName, contribution);
152
200
  for (const tab of staged.settingsTabs) settingsTabs.push(tab);
153
201
  for (const navEntry of staged.navEntries) navEntries.push(navEntry);
202
+ for (const [channel, panel] of staged.routePanels) routePanels.set(channel, panel);
154
203
  }
155
204
 
156
205
  /**
@@ -164,10 +213,14 @@ function commitStaged(staged: StagedContributions): void {
164
213
  * during `entry`'s synchronous body — including a microtask it enqueues then — are
165
214
  * still captured and committed. A `register` that throws commits nothing.
166
215
  */
167
- export async function loadPlugin(pluginId: string, entry: PluginEntry): Promise<void> {
216
+ export async function loadPlugin(
217
+ pluginId: string,
218
+ entry: PluginEntry,
219
+ host: PluginLoadInputs,
220
+ ): Promise<void> {
168
221
  const staged = emptyStaged();
169
222
  const seal: Seal = { closed: false };
170
- const context = makeStagingContext(pluginId, staged, seal);
223
+ const context = makeStagingContext(pluginId, staged, seal, host);
171
224
 
172
225
  try {
173
226
  await entry(context);
@@ -178,12 +231,13 @@ export async function loadPlugin(pluginId: string, entry: PluginEntry): Promise<
178
231
  }
179
232
 
180
233
  assertNavEntriesHavePages(pluginId, staged);
234
+ assertRoutePanelsDeclared(pluginId, staged, host.declaredRoutePanels);
181
235
  commitStaged(staged);
182
236
  }
183
237
 
184
238
  /** The shell reads this after loading every installed plugin bundle. */
185
239
  export function getContributions(): PluginContributions {
186
- return { toolPanels, pages, settingsTabs, navEntries };
240
+ return { toolPanels, pages, settingsTabs, navEntries, routePanels };
187
241
  }
188
242
 
189
243
  /** Tests reset the module-level registry between cases. */
@@ -192,4 +246,5 @@ export function __resetContributions(): void {
192
246
  pages.length = 0;
193
247
  settingsTabs.length = 0;
194
248
  navEntries.length = 0;
249
+ routePanels.clear();
195
250
  }
@@ -2,7 +2,7 @@
2
2
  * The Studio-plugin authoring contract. A platform plugin ships an ESM bundle
3
3
  * that exports a `register(context)` entry ({@link PluginEntry}); the host calls
4
4
  * it with a {@link PluginContext} through which the plugin contributes tool
5
- * panels, pages, settings tabs, and sidebar nav entries.
5
+ * panels, pages, settings tabs, sidebar nav entries and conversation route panels.
6
6
  *
7
7
  * Two styling paths are sanctioned, and only these two:
8
8
  * 1. SDK components plus inline styles that read the design-system tokens
@@ -20,6 +20,7 @@
20
20
  * its own. Bundled third-party base CSS is exempt from rule (c) but rules
21
21
  * (a) and (b) still bind it.
22
22
  */
23
+ import type { ConversationRouteView } from '@tai42/api-client';
23
24
  import type { ComponentType } from 'react';
24
25
 
25
26
  /**
@@ -219,12 +220,43 @@ export interface RegisteredSettingsTab extends SettingsTabContribution {
219
220
  readonly pluginId: string;
220
221
  }
221
222
 
223
+ /** A conversation route row as `GET /api/conversations` lists it, on the `channel` door. */
224
+ export type ChannelConversationRoute = Extract<ConversationRouteView, { readonly door: 'channel' }>;
225
+
226
+ /** Props a contributed route panel receives. */
227
+ export interface RoutePanelProps {
228
+ readonly pluginId: string;
229
+ /** The route row the panel decorates; its `channel` is the contribution's channel. */
230
+ readonly route: ChannelConversationRoute;
231
+ }
232
+
233
+ /**
234
+ * A panel the conversations drill-in renders above the thread list of every route on
235
+ * one channel. One panel per channel deployment-wide.
236
+ */
237
+ export interface RoutePanelContribution {
238
+ /** The channel name whose routes this panel decorates (`ConversationRouteView.channel`). */
239
+ readonly channel: string;
240
+ /** Names the panel region (its aria-label and the error card label). */
241
+ readonly title: string;
242
+ readonly component: ComponentType<RoutePanelProps>;
243
+ /** Capability gate (see {@link RequiredCapabilities}); absent ⇒ full-only. */
244
+ readonly requiredCapabilities?: RequiredCapabilities;
245
+ }
246
+
247
+ /** A route panel as stored in the registry, stamped with the plugin that registered it. */
248
+ export interface RegisteredRoutePanel extends RoutePanelContribution {
249
+ readonly pluginId: string;
250
+ }
251
+
222
252
  /** Everything a single plugin bundle has registered at import time. */
223
253
  export interface PluginContributions {
224
254
  readonly toolPanels: ReadonlyMap<string, ToolPanelContribution>;
225
255
  readonly pages: readonly RegisteredPage[];
226
256
  readonly settingsTabs: readonly RegisteredSettingsTab[];
227
257
  readonly navEntries: readonly RegisteredNavEntry[];
258
+ /** Route panels keyed by the channel they decorate. */
259
+ readonly routePanels: ReadonlyMap<string, RegisteredRoutePanel>;
228
260
  }
229
261
 
230
262
  /**
@@ -250,6 +282,18 @@ export interface PluginContext {
250
282
  * registers (a nav entry with no page is a dead link and is rejected loudly).
251
283
  */
252
284
  registerNavEntry(contribution: NavEntryContribution): void;
285
+ /**
286
+ * Register a panel for the routes of one channel. The plugin's manifest must list the
287
+ * same channels under `contributions.route_panels`, or the plugin fails to load.
288
+ */
289
+ registerRoutePanel(contribution: RoutePanelContribution): void;
290
+ /**
291
+ * The resolved absolute base of one of this plugin's route items, e.g. `/api/channels/web`,
292
+ * or `null` when the item has no mount on this deployment (an optional router not listed, a
293
+ * channel not loaded). Never throws; a feature that needs the base shows its own unavailable
294
+ * state for `null`. Call it during `register` and keep the value.
295
+ */
296
+ routeBase(itemName: string): string | null;
253
297
  }
254
298
 
255
299
  /**
@@ -95,6 +95,14 @@ export interface SchemaFormProps {
95
95
  * renders the plain string input.
96
96
  */
97
97
  readonly expressionField?: ExpressionFieldComponent;
98
+ /**
99
+ * Optional. Per-field picker placeholders, keyed by the root object's property
100
+ * name. A top-level `enum` field that draws as a select shows its entry's
101
+ * `choiceLabel` as the select placeholder (in place of the default `Select…`);
102
+ * other controls and nested fields are unaffected. A field with no entry draws
103
+ * its own default.
104
+ */
105
+ readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string }>>;
98
106
  }
99
107
 
100
108
  /**
@@ -110,6 +118,7 @@ export function SchemaForm({
110
118
  completionProvider,
111
119
  maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES,
112
120
  expressionField,
121
+ fieldWording,
113
122
  }: SchemaFormProps): ReactNode {
114
123
  // The prop overrides the ambient door for this form only; omitted, the
115
124
  // enclosing one flows through unchanged (providing `undefined` would BLIND the
@@ -130,6 +139,7 @@ export function SchemaForm({
130
139
  required
131
140
  errors={errors}
132
141
  idPrefix={idPrefix}
142
+ fieldWording={fieldWording}
133
143
  />
134
144
  </div>
135
145
  </ExpressionFieldContext.Provider>
@@ -19,7 +19,7 @@ import {
19
19
  variantTag,
20
20
  } from './resolve';
21
21
  import { expressionAnnotation, mediaUpload } from './string-annotations';
22
- import type { JsonSchema } from './types';
22
+ import type { FormControl, FormInputMode, JsonSchema } from './types';
23
23
 
24
24
  function firstString(...values: readonly unknown[]): string | undefined {
25
25
  for (const value of values) {
@@ -64,6 +64,27 @@ function classifyConst(
64
64
  };
65
65
  }
66
66
 
67
+ // The supported `control` values, as a record so the type and the runtime set
68
+ // stay coupled: a value added to FormControl is a compile error here until it is
69
+ // listed.
70
+ const FORM_CONTROLS: Record<FormControl, true> = {
71
+ chips: true,
72
+ textarea: true,
73
+ };
74
+
75
+ /**
76
+ * The `control` keyword off a property, narrowed to a supported value, or
77
+ * `undefined` for an absent or unrecognized one. Read defensively (the schema is
78
+ * permissive): a value outside the set reads as absent, so the field renders its
79
+ * default control — the server ask door is the authority that refuses it.
80
+ */
81
+ function controlOf(resolved: JsonSchema): FormControl | undefined {
82
+ const raw: unknown = resolved.control;
83
+ return typeof raw === 'string' && Object.hasOwn(FORM_CONTROLS, raw)
84
+ ? (raw as FormControl)
85
+ : undefined;
86
+ }
87
+
67
88
  // enum → a fixed choice list.
68
89
  function classifyEnum(
69
90
  resolved: JsonSchema,
@@ -73,7 +94,13 @@ function classifyEnum(
73
94
  const enumValues = resolved.enum ?? [];
74
95
  const options: EnumOption[] = enumValues.map((value) => ({ value, label: scalarLabel(value) }));
75
96
  const nullable = enumValues.some((value) => value === null);
76
- return { model: { kind: 'enum', options }, nullable, schema: resolved, title, description };
97
+ return {
98
+ model: { kind: 'enum', options, control: controlOf(resolved) },
99
+ nullable,
100
+ schema: resolved,
101
+ title,
102
+ description,
103
+ };
77
104
  }
78
105
 
79
106
  // anyOf/oneOf union normalization.
@@ -214,6 +241,31 @@ function dateConstraints(resolved: JsonSchema): DateConstraints | undefined {
214
241
  return { min, max, unavailable, rangeStart, minDays, maxDays };
215
242
  }
216
243
 
244
+ // The supported `inputMode` values, as a record so the type and the runtime set
245
+ // stay coupled: a value added to FormInputMode is a compile error here until it
246
+ // is listed.
247
+ const INPUT_MODES: Record<FormInputMode, true> = {
248
+ text: true,
249
+ numeric: true,
250
+ decimal: true,
251
+ tel: true,
252
+ email: true,
253
+ url: true,
254
+ };
255
+
256
+ /**
257
+ * The `inputMode` keyword off a string property, narrowed to a supported value, or
258
+ * `undefined` for an absent or unrecognized one. Read defensively (the schema is
259
+ * permissive): a value outside the set reads as absent, so the field renders as a
260
+ * plain text box — the server ask door is the authority that refuses it.
261
+ */
262
+ function inputModeOf(resolved: JsonSchema): FormInputMode | undefined {
263
+ const raw: unknown = resolved.inputMode;
264
+ return typeof raw === 'string' && Object.hasOwn(INPUT_MODES, raw)
265
+ ? (raw as FormInputMode)
266
+ : undefined;
267
+ }
268
+
217
269
  function classifyString(
218
270
  resolved: JsonSchema,
219
271
  title: string | undefined,
@@ -224,6 +276,10 @@ function classifyString(
224
276
  model: {
225
277
  kind: 'string',
226
278
  format: resolved.format,
279
+ inputMode: inputModeOf(resolved),
280
+ multiline: controlOf(resolved) === 'textarea',
281
+ maxLength: typeof resolved.maxLength === 'number' ? resolved.maxLength : undefined,
282
+ minLength: typeof resolved.minLength === 'number' ? resolved.minLength : undefined,
227
283
  media: mediaUpload(resolved),
228
284
  expression: expressionAnnotation(resolved),
229
285
  date: dateConstraints(resolved),
@@ -264,7 +320,7 @@ function classifyArray(
264
320
  if (isStringItems && enumValues !== undefined && enumValues.length > 0) {
265
321
  const options = enumValues.map((value) => ({ value, label: scalarLabel(value) }));
266
322
  return {
267
- model: { kind: 'multiselect', options },
323
+ model: { kind: 'multiselect', options, control: controlOf(resolved) },
268
324
  nullable,
269
325
  schema: resolved,
270
326
  title,
@@ -0,0 +1,167 @@
1
+ /**
2
+ * A date RANGE drawn as ONE control. The platform declares a range as two
3
+ * `string`/`format: date` properties where the END names its START in `rangeStart`;
4
+ * the answer stays the two date strings, nothing is recombined. Where a pair is
5
+ * drawable as one control (below), `ObjectFields` renders this {@link DateRangeField}
6
+ * — a group with the two ends' labels over two native date inputs — at the START's
7
+ * slot and skips the END; otherwise the two properties draw as two ordinary date
8
+ * fields (a documented, valid layout).
9
+ *
10
+ * {@link dateRangePairs} is the ONE definition of "drawable as one control", reused
11
+ * by every surface so each surface's merge decision agrees.
12
+ */
13
+ import type { ReactNode } from 'react';
14
+
15
+ import { Field } from '../components/field';
16
+ import { TextInput } from '../components/inputs';
17
+ import type { JsonSchema } from './types';
18
+
19
+ /** A START/END pair drawable as one range control, resolved off a page's properties. */
20
+ export interface DateRangePair {
21
+ readonly startName: string;
22
+ readonly startSchema: JsonSchema;
23
+ readonly endName: string;
24
+ readonly endSchema: JsonSchema;
25
+ }
26
+
27
+ /** A property's `visibleWhen` as a stable key for deep-equality (absent ⇒ `''`). */
28
+ function visibilityKey(schema: JsonSchema): string {
29
+ return schema.visibleWhen === undefined ? '' : JSON.stringify(schema.visibleWhen);
30
+ }
31
+
32
+ function isDateString(schema: JsonSchema): boolean {
33
+ return schema.format === 'date';
34
+ }
35
+
36
+ /**
37
+ * The date-range pairs among `properties` that are drawable as ONE control. A pair
38
+ * qualifies when: the END names an existing sibling START in `rangeStart`; BOTH are
39
+ * `format: date`; both carry the SAME `visibleWhen` (deep-equal, or both absent); AND
40
+ * no OTHER field's `visibleWhen` names either end — otherwise merging the pair would
41
+ * leave that field's predicate pointing at a control that no longer draws on its own,
42
+ * so the pair stays two date fields (the documented degrade). `properties` is ONE
43
+ * page's ordered list; a field is used in at most one pair.
44
+ */
45
+ export function dateRangePairs(
46
+ properties: readonly (readonly [string, JsonSchema])[],
47
+ ): readonly DateRangePair[] {
48
+ const byName = new Map<string, JsonSchema>(properties.map(([name, schema]) => [name, schema]));
49
+ const pairs: DateRangePair[] = [];
50
+ const paired = new Set<string>();
51
+
52
+ for (const [endName, endSchema] of properties) {
53
+ if (paired.has(endName)) continue;
54
+ const startName = endSchema.rangeStart;
55
+ if (typeof startName !== 'string' || startName === endName || paired.has(startName)) continue;
56
+ const startSchema = byName.get(startName);
57
+ if (startSchema === undefined) continue;
58
+ if (!isDateString(startSchema) || !isDateString(endSchema)) continue;
59
+ if (visibilityKey(startSchema) !== visibilityKey(endSchema)) continue;
60
+ const dependedOn = properties.some(
61
+ ([name, schema]) =>
62
+ name !== startName &&
63
+ name !== endName &&
64
+ typeof schema.visibleWhen?.field === 'string' &&
65
+ (schema.visibleWhen.field === startName || schema.visibleWhen.field === endName),
66
+ );
67
+ if (dependedOn) continue;
68
+ pairs.push({ startName, startSchema, endName, endSchema });
69
+ paired.add(startName);
70
+ paired.add(endName);
71
+ }
72
+ return pairs;
73
+ }
74
+
75
+ /** The field's own `title`, else its property name. */
76
+ function titleOf(schema: JsonSchema, name: string): string {
77
+ return typeof schema.title === 'string' && schema.title.length > 0 ? schema.title : name;
78
+ }
79
+
80
+ /** One end of the range: a labelled native date input emitting the date string. */
81
+ function RangeEnd({
82
+ heading,
83
+ schema,
84
+ value,
85
+ required,
86
+ error,
87
+ onChange,
88
+ }: {
89
+ heading: string;
90
+ schema: JsonSchema;
91
+ value: unknown;
92
+ required: boolean;
93
+ error: string | undefined;
94
+ onChange: (value: unknown) => void;
95
+ }): ReactNode {
96
+ const current = typeof value === 'string' ? value : '';
97
+ return (
98
+ <Field label={heading} error={error}>
99
+ <TextInput
100
+ type="date"
101
+ // The native control enforces its inclusive bounds; the validator enforces
102
+ // the rest (unavailable days, the range span) that the control cannot draw.
103
+ min={typeof schema.minDate === 'string' ? schema.minDate : undefined}
104
+ max={typeof schema.maxDate === 'string' ? schema.maxDate : undefined}
105
+ required={required}
106
+ value={current}
107
+ onChange={(event) => {
108
+ const next = event.target.value;
109
+ // An empty optional end drops its key; a required one keeps the empty
110
+ // string so the validator can flag it.
111
+ onChange(next === '' && !required ? undefined : next);
112
+ }}
113
+ />
114
+ </Field>
115
+ );
116
+ }
117
+
118
+ export function DateRangeField({
119
+ pair,
120
+ startValue,
121
+ endValue,
122
+ startRequired,
123
+ endRequired,
124
+ markRequired,
125
+ startError,
126
+ endError,
127
+ onChangeStart,
128
+ onChangeEnd,
129
+ }: {
130
+ readonly pair: DateRangePair;
131
+ readonly startValue: unknown;
132
+ readonly endValue: unknown;
133
+ readonly startRequired: boolean;
134
+ readonly endRequired: boolean;
135
+ readonly markRequired: boolean;
136
+ readonly startError: string | undefined;
137
+ readonly endError: string | undefined;
138
+ readonly onChangeStart: (value: unknown) => void;
139
+ readonly onChangeEnd: (value: unknown) => void;
140
+ }): ReactNode {
141
+ const startTitle = titleOf(pair.startSchema, pair.startName);
142
+ const endTitle = titleOf(pair.endSchema, pair.endName);
143
+ const mark = (title: string, required: boolean): string =>
144
+ markRequired && required ? `${title} *` : title;
145
+ return (
146
+ <Field label={`${startTitle} – ${endTitle}`} group>
147
+ <div className="tai-row">
148
+ <RangeEnd
149
+ heading={mark(startTitle, startRequired)}
150
+ schema={pair.startSchema}
151
+ value={startValue}
152
+ required={startRequired}
153
+ error={startError}
154
+ onChange={onChangeStart}
155
+ />
156
+ <RangeEnd
157
+ heading={mark(endTitle, endRequired)}
158
+ schema={pair.endSchema}
159
+ value={endValue}
160
+ required={endRequired}
161
+ error={endError}
162
+ onChange={onChangeEnd}
163
+ />
164
+ </div>
165
+ </Field>
166
+ );
167
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { ReactNode } from 'react';
8
8
 
9
+ import { ChipGroup } from '../components/chip-group';
9
10
  import { Field } from '../components/field';
10
11
  import { RadioGroup } from '../components/radio-group';
11
12
  import { Select } from '../components/select';
@@ -22,6 +23,7 @@ export function EnumField({
22
23
  model,
23
24
  value,
24
25
  onChange,
26
+ choiceLabel,
25
27
  }: {
26
28
  heading: string;
27
29
  description: string | undefined;
@@ -29,6 +31,8 @@ export function EnumField({
29
31
  model: Extract<FieldModel, { kind: 'enum' }>;
30
32
  value: unknown;
31
33
  onChange: (value: unknown) => void;
34
+ /** The select's placeholder when the field draws as a select; absent shows `Select…`. */
35
+ choiceLabel?: string;
32
36
  }): ReactNode {
33
37
  const options = model.options.map((option, index) => ({
34
38
  key: String(index),
@@ -44,6 +48,21 @@ export function EnumField({
44
48
  if (match !== undefined) onChange(match.value);
45
49
  };
46
50
 
51
+ // `control: chips` draws the choice as a chip grid in place of the radio/select,
52
+ // at any option count.
53
+ if (model.control === 'chips') {
54
+ return (
55
+ <Field label={heading} description={description} error={error} group>
56
+ <ChipGroup
57
+ mode="single"
58
+ options={options.map((option) => ({ value: option.key, label: option.label }))}
59
+ value={selectedKey}
60
+ onValueChange={emit}
61
+ />
62
+ </Field>
63
+ );
64
+ }
65
+
47
66
  if (options.length <= RADIO_MAX_OPTIONS) {
48
67
  return (
49
68
  <Field label={heading} description={description} error={error} group>
@@ -61,6 +80,7 @@ export function EnumField({
61
80
  options={options.map((option) => ({ value: option.key, label: option.label }))}
62
81
  value={selectedKey}
63
82
  onValueChange={emit}
83
+ placeholder={choiceLabel}
64
84
  />
65
85
  </Field>
66
86
  );