@tai42/studio-sdk 17.1.5 → 18.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.
Files changed (55) hide show
  1. package/dist/components/execution-key-picker.d.ts +37 -0
  2. package/dist/components/execution-key-picker.d.ts.map +1 -0
  3. package/dist/components/execution-key-picker.js +49 -0
  4. package/dist/components/execution-key-picker.js.map +1 -0
  5. package/dist/components/json-tree-nodes.d.ts.map +1 -1
  6. package/dist/components/json-tree-nodes.js +24 -9
  7. package/dist/components/json-tree-nodes.js.map +1 -1
  8. package/dist/components/overflow-measure.d.ts +11 -3
  9. package/dist/components/overflow-measure.d.ts.map +1 -1
  10. package/dist/components/overflow-measure.js +8 -5
  11. package/dist/components/overflow-measure.js.map +1 -1
  12. package/dist/components/scroll-region.d.ts +12 -2
  13. package/dist/components/scroll-region.d.ts.map +1 -1
  14. package/dist/components/scroll-region.js +7 -5
  15. package/dist/components/scroll-region.js.map +1 -1
  16. package/dist/components/subject-section.d.ts +78 -0
  17. package/dist/components/subject-section.d.ts.map +1 -0
  18. package/dist/components/subject-section.js +66 -0
  19. package/dist/components/subject-section.js.map +1 -0
  20. package/dist/components/tool-picker.d.ts.map +1 -1
  21. package/dist/components/tool-picker.js +11 -1
  22. package/dist/components/tool-picker.js.map +1 -1
  23. package/dist/hooks/useApi.d.ts +96 -45
  24. package/dist/hooks/useApi.d.ts.map +1 -1
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/schema-form/context.d.ts +19 -0
  30. package/dist/schema-form/context.d.ts.map +1 -1
  31. package/dist/schema-form/context.js.map +1 -1
  32. package/dist/schema-form/field-model.d.ts +19 -0
  33. package/dist/schema-form/field-model.d.ts.map +1 -1
  34. package/dist/schema-form/string-annotations.d.ts.map +1 -1
  35. package/dist/schema-form/string-annotations.js +26 -0
  36. package/dist/schema-form/string-annotations.js.map +1 -1
  37. package/dist/schema-form/string-field.js +22 -6
  38. package/dist/schema-form/string-field.js.map +1 -1
  39. package/dist/testing.d.ts +9 -7
  40. package/dist/testing.d.ts.map +1 -1
  41. package/dist/testing.js +16 -13
  42. package/dist/testing.js.map +1 -1
  43. package/package.json +9 -9
  44. package/src/components/execution-key-picker.tsx +116 -0
  45. package/src/components/json-tree-nodes.tsx +36 -18
  46. package/src/components/overflow-measure.ts +16 -5
  47. package/src/components/scroll-region.tsx +16 -3
  48. package/src/components/subject-section.tsx +177 -0
  49. package/src/components/tool-picker.tsx +14 -2
  50. package/src/index.ts +11 -0
  51. package/src/schema-form/context.ts +20 -0
  52. package/src/schema-form/field-model.ts +20 -0
  53. package/src/schema-form/string-annotations.ts +32 -1
  54. package/src/schema-form/string-field.tsx +25 -6
  55. package/src/testing.ts +21 -13
@@ -7,12 +7,23 @@
7
7
 
8
8
  export const SCROLL_REGION_CLASS = 'tai-scroll-region';
9
9
 
10
- export function overflows(element: HTMLElement): boolean {
11
- return element.scrollWidth > element.clientWidth;
10
+ /**
11
+ * The axis a scroll region is measured on. A prose table or a code line scrolls
12
+ * SIDEWAYS (`horizontal`, the default); a bounded, capped-height list scrolls
13
+ * DOWN (`vertical`). Both wear the same conditional region attributes — only the
14
+ * dimension read to decide whether they overflow differs.
15
+ */
16
+ export type OverflowAxis = 'horizontal' | 'vertical';
17
+
18
+ export function overflows(element: HTMLElement, axis: OverflowAxis = 'horizontal'): boolean {
19
+ return axis === 'vertical'
20
+ ? element.scrollHeight > element.clientHeight
21
+ : element.scrollWidth > element.clientWidth;
12
22
  }
13
23
 
14
24
  /**
15
- * Whether the element should carry the region attributes right now.
25
+ * Whether the element should carry the region attributes right now, measured on
26
+ * `axis`.
16
27
  *
17
28
  * A region that stops overflowing keeps its tab stop for as long as it holds
18
29
  * focus: taking `tabindex` off the focused element drops the reader onto the
@@ -20,8 +31,8 @@ export function overflows(element: HTMLElement): boolean {
20
31
  * already has the stop can be the active element, so this can hold a stop open
21
32
  * but never invent one.
22
33
  */
23
- export function needsRegion(element: HTMLElement): boolean {
24
- return overflows(element) || element.ownerDocument.activeElement === element;
34
+ export function needsRegion(element: HTMLElement, axis: OverflowAxis = 'horizontal'): boolean {
35
+ return overflows(element, axis) || element.ownerDocument.activeElement === element;
25
36
  }
26
37
 
27
38
  /** A tracked scrollable surface, with the name it wears while it scrolls. */
@@ -34,6 +34,7 @@ import {
34
34
  import {
35
35
  clearScrollRegionAttributes,
36
36
  needsRegion,
37
+ type OverflowAxis,
37
38
  refreshRegions,
38
39
  SCROLL_REGION_CLASS,
39
40
  type TrackedSurface,
@@ -46,12 +47,20 @@ import {
46
47
  uniquelyNamed,
47
48
  } from './prose-regions';
48
49
 
50
+ export type { OverflowAxis } from './overflow-measure';
51
+
49
52
  export interface ScrollRegionProps {
50
53
  /** The region's accessible name, applied only while it actually scrolls. */
51
54
  readonly label: string;
52
55
  readonly children: ReactNode;
53
56
  readonly className?: string;
54
57
  readonly style?: CSSProperties;
58
+ /**
59
+ * The dimension overflow is measured on. `horizontal` (the default) fits a pane
60
+ * that scrolls sideways; `vertical` fits a bounded, capped-height box that scrolls
61
+ * down. The caller styles the box's own overflow to match.
62
+ */
63
+ readonly axis?: OverflowAxis;
55
64
  /**
56
65
  * The one arbitrary attribute this component forwards. The measured attributes
57
66
  * — `tabindex`, `role` and `aria-label` — are the component's own and appear
@@ -118,10 +127,13 @@ export interface OverflowRegionAttributes {
118
127
  *
119
128
  * @param ref - a consumer ref that wants the same element, or `undefined`.
120
129
  * @param label - its accessible name, applied only while it actually scrolls.
130
+ * @param axis - the dimension to measure overflow on (`horizontal` by default;
131
+ * `vertical` for a bounded, capped-height box that scrolls down).
121
132
  */
122
133
  export function useOverflowRegion(
123
134
  ref: Ref<HTMLElement> | undefined,
124
135
  label: string,
136
+ axis: OverflowAxis = 'horizontal',
125
137
  ): OverflowRegionAttributes {
126
138
  const [scrollable, setScrollable] = useState(false);
127
139
 
@@ -132,7 +144,7 @@ export function useOverflowRegion(
132
144
  const detachConsumer = attachRef(ref, box);
133
145
 
134
146
  const measure = (): void => {
135
- setScrollable(needsRegion(box));
147
+ setScrollable(needsRegion(box, axis));
136
148
  };
137
149
 
138
150
  // The box gives resize; its children give the overflowing width. The
@@ -177,7 +189,7 @@ export function useOverflowRegion(
177
189
  detachConsumer();
178
190
  };
179
191
  },
180
- [ref],
192
+ [ref, axis],
181
193
  );
182
194
 
183
195
  if (!scrollable) return { ref: measuredRef };
@@ -189,10 +201,11 @@ export function ScrollRegion({
189
201
  children,
190
202
  className,
191
203
  style,
204
+ axis,
192
205
  'data-testid': testId,
193
206
  ref,
194
207
  }: ScrollRegionProps) {
195
- const region = useOverflowRegion(ref, label);
208
+ const region = useOverflowRegion(ref, label, axis);
196
209
 
197
210
  return (
198
211
  <div
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The optional-subject sub-form shared by every form that keys a run's state to a
3
+ * conversation subject (the add-schedule dialog and the tool run panel). A collapsible
4
+ * block whose target / kind / key fields render only while expanded, so a form's default
5
+ * (collapsed) shape sends no subject.
6
+ *
7
+ * Presentational and prop-driven, like {@link ExecutionKeyPicker}: the host feature owns
8
+ * the conversation-targets query (the SDK holds no data-fetching library) and hands the
9
+ * resolved `targetOptions` in. {@link useSubjectFields} owns the field state; the host
10
+ * feature wraps it with its own targets query under its own query-key namespace. The
11
+ * caption and the two placeholders are the only copy that differs between hosts, so they
12
+ * are props.
13
+ */
14
+ import type { StateSubject } from '@tai42/api-client';
15
+ import { type ReactNode, useState } from 'react';
16
+
17
+ import { Field } from './field';
18
+ import { TextInput } from './inputs';
19
+ import { Select } from './select';
20
+
21
+ /** One conversation-target option: `target_kind:target_name` value, `kind · name` label. */
22
+ export interface SubjectTargetOption {
23
+ readonly value: string;
24
+ readonly label: string;
25
+ }
26
+
27
+ /** The message a partially-filled subject is refused with (all three fields or none). */
28
+ export const SUBJECT_INCOMPLETE_MESSAGE = 'A subject needs a target, a kind, and a key.';
29
+
30
+ /** Map the conversation routes read to the target select's options. */
31
+ export function toSubjectTargetOptions(
32
+ routes: readonly { readonly target_kind: string; readonly target_name: string }[],
33
+ ): SubjectTargetOption[] {
34
+ return routes.map((route) => ({
35
+ value: `${route.target_kind}:${route.target_name}`,
36
+ label: `${route.target_kind} · ${route.target_name}`,
37
+ }));
38
+ }
39
+
40
+ /**
41
+ * Build a {@link StateSubject} from the sub-form values: `null` when untouched (no
42
+ * subject), a subject when target, kind and key are all set, or a loud failure when only
43
+ * some are — never a silently dropped partial subject. The target splits on its FIRST
44
+ * colon, so a target name may itself contain one.
45
+ */
46
+ export function buildSubject(v: {
47
+ readonly target: string;
48
+ readonly kind: string;
49
+ readonly key: string;
50
+ }): { ok: true; subject: StateSubject | null } | { ok: false; message: string } {
51
+ const touched = v.target !== '' || v.kind.trim() !== '' || v.key.trim() !== '';
52
+ if (!touched) return { ok: true, subject: null };
53
+ const separator = v.target.indexOf(':');
54
+ const targetKind = separator < 0 ? '' : v.target.slice(0, separator);
55
+ const targetName = separator < 0 ? '' : v.target.slice(separator + 1);
56
+ if (targetKind === '' || targetName === '' || v.kind.trim() === '' || v.key.trim() === '') {
57
+ return { ok: false, message: SUBJECT_INCOMPLETE_MESSAGE };
58
+ }
59
+ // The target is chosen from the conversation-routes options, so its kind is one of the
60
+ // route target kinds; the value carries it as text and this narrows it to the subject's
61
+ // union without a runtime schema (the SDK holds no api-client value import).
62
+ return {
63
+ ok: true,
64
+ subject: {
65
+ target_kind: targetKind as StateSubject['target_kind'],
66
+ target_name: targetName,
67
+ kind: v.kind.trim(),
68
+ key: v.key.trim(),
69
+ },
70
+ };
71
+ }
72
+
73
+ /** Subject field state + setters + the collapse toggle. The host feature owns the
74
+ * open-gated conversation-targets query and derives `targetOptions` from it. */
75
+ export function useSubjectFields() {
76
+ const [target, setTarget] = useState('');
77
+ const [kind, setKind] = useState('');
78
+ const [key, setKey] = useState('');
79
+ const [error, setError] = useState<string | null>(null);
80
+ const [open, setOpen] = useState(false);
81
+ return { target, setTarget, kind, setKind, key, setKey, error, setError, open, setOpen };
82
+ }
83
+
84
+ export interface SubjectSectionProps {
85
+ readonly open: boolean;
86
+ readonly onToggle: () => void;
87
+ readonly target: string;
88
+ readonly onTargetChange: (value: string) => void;
89
+ readonly kind: string;
90
+ readonly onKindChange: (value: string) => void;
91
+ readonly subjectKey: string;
92
+ readonly onKeyChange: (value: string) => void;
93
+ readonly error: string | null;
94
+ readonly targetOptions: readonly SubjectTargetOption[];
95
+ /** The block's descriptive caption, shown under the expanded header. */
96
+ readonly caption: string;
97
+ /** The target select's empty-choice placeholder. */
98
+ readonly targetPlaceholder: string;
99
+ /** The Subject key field's helper description. */
100
+ readonly subjectKeyDescription: string;
101
+ }
102
+
103
+ export function SubjectSection({
104
+ open,
105
+ onToggle,
106
+ target,
107
+ onTargetChange,
108
+ kind,
109
+ onKindChange,
110
+ subjectKey,
111
+ onKeyChange,
112
+ error,
113
+ targetOptions,
114
+ caption,
115
+ targetPlaceholder,
116
+ subjectKeyDescription,
117
+ }: SubjectSectionProps): ReactNode {
118
+ return (
119
+ <div>
120
+ <button
121
+ type="button"
122
+ className="tai-btn tai-btn-ghost"
123
+ aria-expanded={open}
124
+ onClick={onToggle}
125
+ >
126
+ Subject (optional)
127
+ </button>
128
+ {open ? (
129
+ <div
130
+ style={{
131
+ display: 'flex',
132
+ flexDirection: 'column',
133
+ gap: 'var(--tai-space-3)',
134
+ marginTop: 'var(--tai-space-3)',
135
+ }}
136
+ >
137
+ <p style={{ margin: 0, color: 'var(--tai-color-text-muted)' }}>{caption}</p>
138
+ <Field label="Target">
139
+ <Select
140
+ value={target}
141
+ onValueChange={onTargetChange}
142
+ aria-label="Subject target"
143
+ placeholder={targetPlaceholder}
144
+ options={targetOptions}
145
+ />
146
+ </Field>
147
+ <Field
148
+ label="Subject kind"
149
+ description="The subject family the state declares (e.g. person)."
150
+ >
151
+ <TextInput
152
+ value={kind}
153
+ placeholder="e.g. person"
154
+ onChange={(event) => {
155
+ onKindChange(event.target.value);
156
+ }}
157
+ />
158
+ </Field>
159
+ <Field label="Subject key" description={subjectKeyDescription}>
160
+ <TextInput
161
+ value={subjectKey}
162
+ placeholder="e.g. a-42"
163
+ onChange={(event) => {
164
+ onKeyChange(event.target.value);
165
+ }}
166
+ />
167
+ </Field>
168
+ {error !== null ? (
169
+ <p role="alert" style={{ margin: 0, color: 'var(--tai-color-err-text)' }}>
170
+ {error}
171
+ </p>
172
+ ) : null}
173
+ </div>
174
+ ) : null}
175
+ </div>
176
+ );
177
+ }
@@ -38,6 +38,11 @@ import { ALL_TAGS, SelectedToolBadges, TagFilterField, ToolSelect } from './tool
38
38
 
39
39
  export { BADGES_NOTE } from './tool-picker-parts';
40
40
 
41
+ /** The trigger copy when the picker has no option to offer at all (after exclusions).
42
+ * Shown only once the list has settled — a still-loading picker keeps its own
43
+ * placeholder — so it never misreads a pending fetch as "nothing to pick". */
44
+ const NO_TOOLS_AVAILABLE = 'No tools available';
45
+
41
46
  export interface ToolPickerProps {
42
47
  readonly toolNames: readonly string[];
43
48
  readonly value: string | null;
@@ -94,6 +99,13 @@ export function ToolPicker({
94
99
  const excluded = new Set(excludeNames ?? []);
95
100
  const available = toolNames.filter((name) => !excluded.has(name));
96
101
 
102
+ // A settled, empty option set (not a load in progress) is a "nothing to pick"
103
+ // state: the trigger reads the empty copy and cannot open onto an empty popup. The
104
+ // caller's `disabled` still marks a loading picker, which keeps its own placeholder.
105
+ const noneAvailable = available.length === 0;
106
+ const pickerDisabled = disabled === true || noneAvailable;
107
+ const pickerPlaceholder = noneAvailable && disabled !== true ? NO_TOOLS_AVAILABLE : placeholder;
108
+
97
109
  // Distinct tags across the available tools, sorted, feeding the filter control.
98
110
  const allTags =
99
111
  tagsByTool === undefined
@@ -113,9 +125,9 @@ export function ToolPicker({
113
125
  filtered={filtered}
114
126
  value={value}
115
127
  onChange={onChange}
116
- placeholder={placeholder}
128
+ placeholder={pickerPlaceholder}
117
129
  ariaLabel={ariaLabel}
118
- disabled={disabled}
130
+ disabled={pickerDisabled}
119
131
  displayNames={displayNames}
120
132
  agentToolNames={agentToolNames}
121
133
  />
package/src/index.ts CHANGED
@@ -221,6 +221,7 @@ export { Button, Card, EmptyState, ErrorState, Skeleton, Spinner } from './compo
221
221
  export type { RadioGroupProps, RadioOption } from './components/radio-group';
222
222
  export { RadioGroup } from './components/radio-group';
223
223
  export type {
224
+ OverflowAxis,
224
225
  OverflowRegionAttributes,
225
226
  ProseScrollLabels,
226
227
  ScrollRegionProps,
@@ -295,6 +296,8 @@ export { openTargetProps } from './components/open-target';
295
296
  // hands back the parsed URL, so a caller that needs the URL does not re-parse it.
296
297
  export type { CopyFieldProps } from './components/copy-field';
297
298
  export { CopyField } from './components/copy-field';
299
+ export type { ExecutionKeyPickerProps, ExecutionKeyQuery } from './components/execution-key-picker';
300
+ export { ExecutionKeyPicker, isExecutionKeyListEmpty } from './components/execution-key-picker';
298
301
  export type { ExtensionComboBuilderProps } from './components/extension-combo-builder';
299
302
  export { ExtensionComboBuilder } from './components/extension-combo-builder';
300
303
  export type { ExtensionFamily, ExtensionKindGroup } from './components/extension-grouping';
@@ -311,6 +314,14 @@ export type { ExternalLinkButtonProps } from './components/external-link';
311
314
  export { safeHttpUrl } from './components/primitives';
312
315
  export type { RevealInputProps } from './components/reveal-input';
313
316
  export { RevealInput } from './components/reveal-input';
317
+ export type { SubjectSectionProps, SubjectTargetOption } from './components/subject-section';
318
+ export {
319
+ buildSubject,
320
+ SUBJECT_INCOMPLETE_MESSAGE,
321
+ SubjectSection,
322
+ toSubjectTargetOptions,
323
+ useSubjectFields,
324
+ } from './components/subject-section';
314
325
  export type { TokenName } from './components/tokens';
315
326
  export { TOKEN_NAMES } from './components/tokens';
316
327
  export type { ToolPickerProps } from './components/tool-picker';
@@ -52,6 +52,23 @@ export interface ExpressionInputKey {
52
52
  readonly gloss: string;
53
53
  }
54
54
 
55
+ /**
56
+ * One named jq variable the form hands the door beside `.`, mirroring jq-studio's
57
+ * variable descriptor without importing it. The door lists it in the legend, offers
58
+ * `$name` as a path root, and seeds the Test panel with its `sample`.
59
+ */
60
+ export interface ExpressionInputVariable {
61
+ /** The variable name WITHOUT the leading `$` (referenced as `$name`). */
62
+ readonly name: string;
63
+ /** One sentence: what this variable holds. */
64
+ readonly blurb: string;
65
+ /** The top-level keys of the variable's value; empty when it is a scalar or
66
+ * the annotation glosses none. */
67
+ readonly keys: readonly ExpressionInputKey[];
68
+ /** A static skeleton of the variable's value for the door's Test panel. */
69
+ readonly sample?: unknown;
70
+ }
71
+
55
72
  /**
56
73
  * What `.` IS for an expression-annotated field, as the form hands it to the
57
74
  * injected door. Built by the renderer from the schema's `x-tai42-expression`
@@ -77,6 +94,9 @@ export interface ExpressionInputShape {
77
94
  readonly caveats?: readonly string[];
78
95
  /** A static skeleton of `.` for the door's test surface. */
79
96
  readonly sample?: unknown;
97
+ /** The named variables the expression reads as `$name` beside `.`; omitted when
98
+ * the field declares none. */
99
+ readonly variables?: readonly ExpressionInputVariable[];
80
100
  }
81
101
 
82
102
  /**
@@ -34,6 +34,23 @@ export interface ExpressionAnnotationKey {
34
34
  readonly gloss: string;
35
35
  }
36
36
 
37
+ /**
38
+ * One named jq variable the expression may read as `$name` beside its `.` input:
39
+ * its blurb and a representative sample the editor's Test panel binds it to. The
40
+ * `x-tai42-expression` annotation carries these under `variables`; a field whose
41
+ * `.` is its only input carries none.
42
+ */
43
+ export interface ExpressionAnnotationVariable {
44
+ readonly name: string;
45
+ readonly blurb: string;
46
+ /** True when the annotation carries a `sample` for this variable — tracked
47
+ * separately because a legitimate sample may be JSON `null`. */
48
+ readonly hasSample: boolean;
49
+ /** The sample value the Test panel binds `$name` to. Meaningful only when
50
+ * {@link hasSample} is true. */
51
+ readonly sample: unknown;
52
+ }
53
+
37
54
  /**
38
55
  * The `x-tai42-expression` annotation on a string schema: the server declares
39
56
  * that the field's value is authored in a pipeline expression language (only jq
@@ -53,6 +70,9 @@ export interface ExpressionAnnotation {
53
70
  readonly blurb: string | undefined;
54
71
  /** The top-level keys of `.`, each with a one-liner. */
55
72
  readonly keys: readonly ExpressionAnnotationKey[] | undefined;
73
+ /** The named variables the expression reads as `$name` beside `.`. Absent when
74
+ * the annotation declares none (the field's `.` is its only input). */
75
+ readonly variables: readonly ExpressionAnnotationVariable[] | undefined;
56
76
  /** What the expression must return (e.g. "true or false"). */
57
77
  readonly returns: string | undefined;
58
78
  /** Per-field caveats. */
@@ -6,7 +6,12 @@
6
6
  * renders differently from the plain string it falls back to.
7
7
  */
8
8
  import { isRecord } from '../guards';
9
- import type { ExpressionAnnotation, ExpressionAnnotationKey, MediaUpload } from './field-model';
9
+ import type {
10
+ ExpressionAnnotation,
11
+ ExpressionAnnotationKey,
12
+ ExpressionAnnotationVariable,
13
+ MediaUpload,
14
+ } from './field-model';
10
15
  import type { JsonSchema } from './types';
11
16
 
12
17
  const EXPRESSION_KEYWORD = 'x-tai42-expression';
@@ -58,6 +63,29 @@ function parseAnnotationKeys(
58
63
  return keys;
59
64
  }
60
65
 
66
+ /** Parse the optional `variables` array: absent → `undefined`, malformed → `null`
67
+ * (reject). Each entry needs a string `name` and `blurb`; `sample` is any JSON
68
+ * (including `null`), so its presence is tracked rather than typed. */
69
+ function parseAnnotationVariables(
70
+ value: unknown,
71
+ ): readonly ExpressionAnnotationVariable[] | undefined | null {
72
+ if (value === undefined) return undefined;
73
+ if (!Array.isArray(value)) return null;
74
+ const variables: ExpressionAnnotationVariable[] = [];
75
+ for (const entry of value as readonly unknown[]) {
76
+ if (!isRecord(entry) || typeof entry.name !== 'string' || typeof entry.blurb !== 'string') {
77
+ return null;
78
+ }
79
+ variables.push({
80
+ name: entry.name,
81
+ blurb: entry.blurb,
82
+ hasSample: 'sample' in entry,
83
+ sample: entry.sample,
84
+ });
85
+ }
86
+ return variables;
87
+ }
88
+
61
89
  /** Parse the optional `caveats` array: absent → `undefined`, malformed → `null` (reject). */
62
90
  function parseCaveatList(value: unknown): readonly string[] | undefined | null {
63
91
  if (value === undefined) return undefined;
@@ -94,6 +122,8 @@ export function expressionAnnotation(schema: JsonSchema): ExpressionAnnotation |
94
122
 
95
123
  const keys = parseAnnotationKeys(raw.keys);
96
124
  if (keys === null) return undefined;
125
+ const variables = parseAnnotationVariables(raw.variables);
126
+ if (variables === null) return undefined;
97
127
  const caveats = parseCaveatList(raw.caveats);
98
128
  if (caveats === null) return undefined;
99
129
 
@@ -103,6 +133,7 @@ export function expressionAnnotation(schema: JsonSchema): ExpressionAnnotation |
103
133
  label: label.value,
104
134
  blurb: blurb.value,
105
135
  keys,
136
+ variables,
106
137
  returns: returns.value,
107
138
  caveats,
108
139
  hasSample: 'sample' in raw,
@@ -296,12 +296,14 @@ function expressionShape(
296
296
  argName: string,
297
297
  ): ExpressionInputShape | undefined {
298
298
  const declared =
299
- expression.label !== undefined ||
300
- expression.blurb !== undefined ||
301
- expression.keys !== undefined ||
302
- expression.returns !== undefined ||
303
- expression.caveats !== undefined ||
304
- expression.hasSample;
299
+ [
300
+ expression.label,
301
+ expression.blurb,
302
+ expression.keys,
303
+ expression.variables,
304
+ expression.returns,
305
+ expression.caveats,
306
+ ].some((field) => field !== undefined) || expression.hasSample;
305
307
  if (!declared) return undefined;
306
308
  return {
307
309
  // Opaque, host-namespaced, stable per field (memoisation/telemetry only).
@@ -312,6 +314,23 @@ function expressionShape(
312
314
  returns: expression.returns ?? '',
313
315
  ...(expression.caveats === undefined ? {} : { caveats: expression.caveats }),
314
316
  ...(expression.hasSample ? { sample: expression.sample } : {}),
317
+ ...(expression.variables === undefined
318
+ ? {}
319
+ : { variables: expression.variables.map(expressionVariable) }),
320
+ };
321
+ }
322
+
323
+ /** Map one classified annotation variable onto the door's variable descriptor. */
324
+ function expressionVariable(
325
+ variable: NonNullable<ExpressionAnnotation['variables']>[number],
326
+ ): NonNullable<ExpressionInputShape['variables']>[number] {
327
+ return {
328
+ name: variable.name,
329
+ blurb: variable.blurb,
330
+ // The annotation glosses no sub-keys of a variable's value, so the door gets
331
+ // the variable's shape as untyped (empty keys).
332
+ keys: [],
333
+ ...(variable.hasSample ? { sample: variable.sample } : {}),
315
334
  };
316
335
  }
317
336
 
package/src/testing.ts CHANGED
@@ -109,25 +109,33 @@ export function flushResizeObserversFor(target: Element): void {
109
109
  }
110
110
  }
111
111
 
112
- /** The faked widths: equal reads as "fits", unequal as "scrolls". */
113
- const FITTING_WIDTH = 100;
114
- const OVERFLOWING_SCROLL_WIDTH = 400;
112
+ /** The faked extents: equal reads as "fits", unequal as "scrolls". */
113
+ const FITTING_EXTENT = 100;
114
+ const OVERFLOWING_SCROLL_EXTENT = 400;
115
115
 
116
116
  /**
117
- * Makes `element` report itself as horizontally overflowing (or not). jsdom runs
118
- * no layout, so `scrollWidth` and `clientWidth` are both 0 and every
119
- * overflow test reads as "fits"; this shadows the prototype getters with own,
120
- * configurable values so a test can drive either state. Call it again with the
121
- * opposite flag to flip the element back.
117
+ * Makes `element` report itself as overflowing (or not) on `axis`. jsdom runs no
118
+ * layout, so `scroll*` and `client*` are both 0 and every overflow test reads as
119
+ * "fits"; this shadows the prototype getters with own, configurable values so a
120
+ * test can drive either state. Call it again with the opposite flag to flip the
121
+ * element back.
122
122
  *
123
123
  * @param element - the element whose scroll metrics to fake.
124
- * @param overflowing - true to report content wider than the box.
124
+ * @param overflowing - true to report content larger than the box on `axis`.
125
+ * @param axis - the dimension to fake: `horizontal` (width, the default) or
126
+ * `vertical` (height).
125
127
  */
126
- export function setElementOverflow(element: HTMLElement, overflowing: boolean): void {
127
- Object.defineProperty(element, 'clientWidth', { configurable: true, value: FITTING_WIDTH });
128
- Object.defineProperty(element, 'scrollWidth', {
128
+ export function setElementOverflow(
129
+ element: HTMLElement,
130
+ overflowing: boolean,
131
+ axis: 'horizontal' | 'vertical' = 'horizontal',
132
+ ): void {
133
+ const [client, scroll] =
134
+ axis === 'vertical' ? ['clientHeight', 'scrollHeight'] : ['clientWidth', 'scrollWidth'];
135
+ Object.defineProperty(element, client, { configurable: true, value: FITTING_EXTENT });
136
+ Object.defineProperty(element, scroll, {
129
137
  configurable: true,
130
- value: overflowing ? OVERFLOWING_SCROLL_WIDTH : FITTING_WIDTH,
138
+ value: overflowing ? OVERFLOWING_SCROLL_EXTENT : FITTING_EXTENT,
131
139
  });
132
140
  }
133
141