sanity-plugin-jev-fields 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,20 +5,20 @@ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffe
5
5
  import { RefreshIcon } from "@sanity/icons/Refresh";
6
6
  import { keyframes, styled } from "styled-components";
7
7
  /** Flattens a field's value into the plain text Jev evaluates. */
8
- function toText(value) {
8
+ function flattenToText(value) {
9
9
  if (value == null) return "";
10
10
  if (typeof value == "string") return value;
11
11
  if (typeof value == "number" || typeof value == "boolean") return String(value);
12
12
  if (Array.isArray(value)) {
13
- let separator = value.some(isBlock) ? "\n\n" : "\n";
14
- return value.map(toText).filter((text) => text !== "").join(separator);
13
+ let separator = value.some(isPortableTextBlock) ? "\n\n" : "\n";
14
+ return value.map(flattenToText).filter((text) => text !== "").join(separator);
15
15
  }
16
- return isRecord$1(value) ? isBlock(value) ? value.children.map((child) => child.text ?? "").join("") : value._type === "slug" ? toText(value.current) : Object.entries(value).filter(([key]) => !key.startsWith("_")).map(([, child]) => toText(child)).filter((text) => text !== "").join("\n") : "";
16
+ return isRecord(value) ? isPortableTextBlock(value) ? value.children.map((child) => child.text ?? "").join("") : value._type === "slug" ? flattenToText(value.current) : Object.entries(value).filter(([key]) => !key.startsWith("_")).map(([, child]) => flattenToText(child)).filter((text) => text !== "").join("\n") : "";
17
17
  }
18
- function isBlock(value) {
19
- return isRecord$1(value) && value._type === "block" && Array.isArray(value.children);
18
+ function isPortableTextBlock(value) {
19
+ return isRecord(value) && value._type === "block" && Array.isArray(value.children);
20
20
  }
21
- function isRecord$1(value) {
21
+ function isRecord(value) {
22
22
  return !!value && typeof value == "object" && !Array.isArray(value);
23
23
  }
24
24
  /** FNV-1a: a small, stable fingerprint; collisions only cost a skipped re-evaluation. */
@@ -28,17 +28,17 @@ function fingerprint(input) {
28
28
  return (hash >>> 0).toString(16).padStart(8, "0");
29
29
  }
30
30
  /** Jev reads at most this many tokens of state (plus the longest question) per request. */
31
- const estimateTokens = (text) => Math.ceil(text.length / 4), nearTokenLimit = (text) => estimateTokens(text) > 28e3, Context = createContext(null), pathKey = (path) => JSON.stringify(path);
31
+ const estimateTokenCount = (text) => Math.ceil(text.length / 4), isNearTokenLimit = (text) => estimateTokenCount(text) > 28e3, JevFormContext = createContext(null), toPathKey = (path) => JSON.stringify(path);
32
32
  /** Wraps a document form, so attached fields can reach the plugin config and answer fields. */
33
33
  function JevFormProvider({ config, children }) {
34
- let [writers] = useState(() => /* @__PURE__ */ new Map()), value = useMemo(() => ({
34
+ let [answerWriters] = useState(() => /* @__PURE__ */ new Map()), value = useMemo(() => ({
35
35
  config,
36
- writers
37
- }), [config, writers]);
38
- return <Context.Provider value={value}>{children}</Context.Provider>;
36
+ answerWriters
37
+ }), [config, answerWriters]);
38
+ return <JevFormContext.Provider value={value}>{children}</JevFormContext.Provider>;
39
39
  }
40
40
  function useJevForm() {
41
- let context = useContext(Context);
41
+ let context = useContext(JevFormContext);
42
42
  if (!context) throw Error("Jev questions need the jev() plugin in sanity.config.");
43
43
  return context;
44
44
  }
@@ -47,26 +47,26 @@ function useJevForm() {
47
47
  * registers the field's own `onChange`, so the attached field can store answers through it.
48
48
  */
49
49
  function AnswerField(props) {
50
- let { writers } = useJevForm(), key = pathKey(props.path), onChange = props.inputProps.onChange;
51
- return useEffect(() => (writers.set(key, (value) => onChange(set(value))), () => {
52
- writers.delete(key);
50
+ let { answerWriters } = useJevForm(), pathKey = toPathKey(props.path), onChange = props.inputProps.onChange;
51
+ return useEffect(() => (answerWriters.set(pathKey, (value) => onChange(set(value))), () => {
52
+ answerWriters.delete(pathKey);
53
53
  }), [
54
- writers,
55
- key,
54
+ answerWriters,
55
+ pathKey,
56
56
  onChange
57
57
  ]), null;
58
58
  }
59
- const NEUTRAL_COLOR = "hsl(230 80% 66%)", clamp = (value) => Math.min(1, Math.max(0, value)), capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
59
+ const NEUTRAL_COLOR = "hsl(230 80% 66%)", ERROR_COLOR = "#f03e2f", clampToFraction = (value) => Math.min(1, Math.max(0, value)), capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
60
60
  /** Red at 0, amber in the middle, green at 1. */
61
61
  function trafficColor(fraction) {
62
- return `hsl(${Math.round(clamp(fraction) * 120)} 70% 52%)`;
62
+ return `hsl(${Math.round(clampToFraction(fraction) * 120)} 70% 52%)`;
63
63
  }
64
64
  function trafficTone(fraction) {
65
65
  return fraction < 1 / 3 ? "critical" : fraction < 2 / 3 ? "caution" : "positive";
66
66
  }
67
67
  /** A thin rounded bar. `fraction` is 0–1. */
68
68
  function Bar({ fraction, color, label }) {
69
- let percent = Math.round(clamp(fraction) * 100);
69
+ let percent = Math.round(clampToFraction(fraction) * 100);
70
70
  return <Box flex={1} role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} aria-label={label} style={{
71
71
  height: 4,
72
72
  borderRadius: 4,
@@ -88,11 +88,11 @@ const TYPE_NAMES = {
88
88
  score: "jev.score",
89
89
  choice: "jev.choice",
90
90
  choiceProbability: "jev.choiceProbability"
91
- }, round = (value, digits = 4) => Math.round(value * 10 ** digits) / 10 ** digits;
92
- function mismatch() {
91
+ }, RULE_LEVELS = ["warn", "require"], roundToDigits = (value, digits = 4) => Math.round(value * 10 ** digits) / 10 ** digits;
92
+ function throwAnswerKindMismatch() {
93
93
  throw Error("Jev answered a different kind of question.");
94
94
  }
95
- const bookkeepingFields = [
95
+ const evaluationFields = [
96
96
  defineField({
97
97
  name: "evaluatedAt",
98
98
  type: "datetime",
@@ -109,44 +109,41 @@ const bookkeepingFields = [
109
109
  hidden: !0
110
110
  })
111
111
  ];
112
- /** The stored value as a record of the given `_type`, or nothing. */
113
- function storedOf(stored, typeName) {
114
- return isRecord$1(stored) && stored._type === typeName ? stored : void 0;
112
+ function asRecordOfType(stored, typeName) {
113
+ return isRecord(stored) && stored._type === typeName ? stored : void 0;
115
114
  }
116
- /** The bookkeeping fields a stored value has. */
117
- function bookkeepingOf(stored) {
115
+ function readEvaluationFields(stored) {
118
116
  return {
119
- evaluatedAt: text(stored.evaluatedAt),
120
- model: text(stored.model),
121
- sourceHash: text(stored.sourceHash)
117
+ evaluatedAt: stringOrUndefined(stored.evaluatedAt),
118
+ model: stringOrUndefined(stored.model),
119
+ sourceHash: stringOrUndefined(stored.sourceHash)
122
120
  };
123
121
  }
124
- const text = (value) => typeof value == "string" ? value : void 0, number = (value) => typeof value == "number" ? value : void 0;
125
- /** Why a range rule is invalid for values from `min` to `max`, if it is. */
126
- function rangeRuleProblem(rules, min, max) {
127
- for (let level of ["warn", "require"]) {
122
+ const stringOrUndefined = (value) => typeof value == "string" ? value : void 0, numberOrUndefined = (value) => typeof value == "number" ? value : void 0;
123
+ function findRangeRuleConfigError(rules, min, max) {
124
+ for (let level of RULE_LEVELS) {
128
125
  let rule = rules[level];
129
126
  if (rule) for (let bound of ["atLeast", "atMost"]) {
130
127
  let value = rule[bound];
131
- if (value !== void 0 && (typeof value != "number" || value < min || value > max)) return `\`${level}.${bound}\` must be a number from ${min} to ${max}.`;
128
+ if (value !== void 0 && !(typeof value == "number" && value >= min && value <= max)) return `\`${level}.${bound}\` must be a number from ${min} to ${max}.`;
132
129
  }
133
130
  }
134
131
  }
135
- /** How `value` breaks a range rule, e.g. `below 60%`, using `format` for both numbers. */
136
- function rangeViolation(value, rule, format) {
132
+ /** E.g. `below 60%`, using `format` for both numbers. */
133
+ function describeRangeViolation(value, rule, format) {
137
134
  if (rule?.atLeast !== void 0 && value < rule.atLeast) return `below ${format(rule.atLeast)}`;
138
135
  if (rule?.atMost !== void 0 && value > rule.atMost) return `above ${format(rule.atMost)}`;
139
136
  }
140
- /** Why a choice question can't be asked, if it can't. */
141
- function choiceProblem(question, options) {
137
+ function findChoiceConfigError(question) {
138
+ let options = Object.keys(question.criteria ?? {});
142
139
  if (options.length < 2) return "A choice question needs at least two options.";
143
140
  if (options.length > 255) return "A choice question allows at most 255 options.";
144
- for (let level of ["warn", "require"]) {
141
+ for (let level of RULE_LEVELS) {
145
142
  let rule = question[level];
146
143
  if (!rule) continue;
147
144
  if (!Array.isArray(rule.oneOf) || rule.oneOf.length === 0) return `\`${level}.oneOf\` must list at least one option.`;
148
- let unknown = rule.oneOf.find((option) => !options.includes(option));
149
- if (unknown !== void 0) return `\`${level}.oneOf\` names "${unknown}", which is not an option.`;
145
+ let unknownOption = rule.oneOf.find((option) => !options.includes(option));
146
+ if (unknownOption !== void 0) return `\`${level}.oneOf\` names "${unknownOption}", which is not an option.`;
150
147
  }
151
148
  }
152
149
  /** One option from a named set. @public */
@@ -186,47 +183,52 @@ const choice = (question) => ({
186
183
  readOnly: !0,
187
184
  of: [defineArrayMember({ type: TYPE_NAMES.choiceProbability })]
188
185
  }),
189
- ...bookkeepingFields
186
+ ...evaluationFields
190
187
  ]
191
188
  })];
189
+ function readStoredProbabilities(stored) {
190
+ return Array.isArray(stored) ? stored.filter(isRecord).map((entry) => ({
191
+ _key: String(entry._key),
192
+ _type: TYPE_NAMES.choiceProbability,
193
+ option: stringOrUndefined(entry.option),
194
+ probability: numberOrUndefined(entry.probability)
195
+ })) : [];
196
+ }
192
197
  function bindChoice(question) {
193
- let criteria = question.criteria ?? {}, problem = choiceProblem(question, Object.keys(criteria));
198
+ let configError = findChoiceConfigError(question), gatewayQuestion = {
199
+ type: "choice",
200
+ instructions: question.instructions,
201
+ criteria: question.criteria
202
+ };
194
203
  return {
195
204
  typeName: TYPE_NAMES.choice,
196
- gatewayQuestion: problem ? void 0 : {
197
- type: "choice",
198
- instructions: question.instructions,
199
- criteria: question.criteria
200
- },
201
- problem,
202
- toStored(answer) {
203
- return answer.type === "choice" ? {
205
+ gatewayQuestion: configError ? void 0 : gatewayQuestion,
206
+ configError,
207
+ toStoredValue(answer) {
208
+ if (answer.type !== "choice") return throwAnswerKindMismatch();
209
+ let confidence = answer.confidence ?? answer.probabilities[answer.choice] ?? 0;
210
+ return {
204
211
  _type: TYPE_NAMES.choice,
205
212
  choice: answer.choice,
206
- confidence: round(answer.confidence ?? answer.probabilities[answer.choice] ?? 0),
207
- probabilities: Object.keys(criteria).map((option, index) => ({
213
+ confidence: roundToDigits(confidence),
214
+ probabilities: Object.keys(question.criteria).map((option, index) => ({
208
215
  _key: `option-${index}`,
209
216
  _type: TYPE_NAMES.choiceProbability,
210
217
  option,
211
- probability: round(answer.probabilities[option] ?? 0)
218
+ probability: roundToDigits(answer.probabilities[option] ?? 0)
212
219
  }))
213
- } : mismatch();
220
+ };
214
221
  },
215
- read(stored) {
216
- let record = storedOf(stored, TYPE_NAMES.choice);
222
+ readStoredValue(stored) {
223
+ let record = asRecordOfType(stored, TYPE_NAMES.choice);
217
224
  if (!record || typeof record.choice != "string") return;
218
- let probabilities = Array.isArray(record.probabilities) ? record.probabilities.filter(isRecord$1).map((entry) => ({
219
- _key: String(entry._key),
220
- _type: TYPE_NAMES.choiceProbability,
221
- option: text(entry.option),
222
- probability: number(entry.probability)
223
- })) : [], meaning = criteria[record.choice];
225
+ let probabilities = readStoredProbabilities(record.probabilities), meaningOfChoice = question.criteria?.[record.choice];
224
226
  return {
225
227
  value: {
226
- ...bookkeepingOf(record),
228
+ ...readEvaluationFields(record),
227
229
  _type: TYPE_NAMES.choice,
228
230
  choice: record.choice,
229
- confidence: number(record.confidence),
231
+ confidence: numberOrUndefined(record.confidence),
230
232
  probabilities
231
233
  },
232
234
  chip: {
@@ -234,33 +236,35 @@ function bindChoice(question) {
234
236
  color: NEUTRAL_COLOR
235
237
  },
236
238
  tone: "default",
237
- aside: meaning ? { note: meaning } : void 0,
239
+ aside: meaningOfChoice ? { note: meaningOfChoice } : void 0,
238
240
  body: <ChoiceBody question={question} choice={record.choice} probabilities={probabilities} />
239
241
  };
240
242
  },
241
- check(level, stored, title) {
242
- let record = storedOf(stored, TYPE_NAMES.choice), rule = question[level];
243
- if (record && typeof record.choice == "string" && rule?.oneOf && !rule.oneOf.includes(record.choice)) return `${title} is "${record.choice}", not ${rule.oneOf.map((option) => `"${option}"`).join(" or ")}.`;
243
+ describeRuleViolation(level, stored, title) {
244
+ let record = asRecordOfType(stored, TYPE_NAMES.choice), rule = question[level];
245
+ if (!record || typeof record.choice != "string" || !rule?.oneOf || rule.oneOf.includes(record.choice)) return;
246
+ let allowedOptions = rule.oneOf.map((option) => `"${option}"`).join(" or ");
247
+ return `${title} is "${record.choice}", not ${allowedOptions}.`;
244
248
  }
245
249
  };
246
250
  }
247
251
  function ChoiceBody({ question, choice, probabilities }) {
248
- let options = Object.keys(question.criteria), nameWidth = `${Math.max(...options.map((name) => name.length), 4) + 2}ch`;
252
+ let options = Object.keys(question.criteria), optionColumnWidth = `${Math.max(...options.map((option) => option.length), 4) + 2}ch`;
249
253
  return <Stack gap={3}>
250
254
  {options.map((option) => {
251
- let probability = probabilities.find((entry) => entry.option === option)?.probability ?? 0, winner = option === choice, percent = Math.round(probability * 100);
255
+ let probability = probabilities.find((entry) => entry.option === option)?.probability ?? 0, isChosen = option === choice, percent = Math.round(probability * 100);
252
256
  return <Flex key={option} align="center" gap={3}>
253
- <Box style={{ width: nameWidth }}>
254
- <Text size={1} weight={winner ? "semibold" : "regular"} muted={!winner}>
257
+ <Box style={{ width: optionColumnWidth }}>
258
+ <Text size={1} weight={isChosen ? "semibold" : "regular"} muted={!isChosen}>
255
259
  {capitalize(option)}
256
260
  </Text>
257
261
  </Box>
258
- <Bar fraction={probability} color={winner ? NEUTRAL_COLOR : "hsl(215 12% 55%)"} label={`${option}: ${percent}%`} />
262
+ <Bar fraction={probability} color={isChosen ? NEUTRAL_COLOR : "hsl(215 12% 55%)"} label={`${option}: ${percent}%`} />
259
263
  <Box style={{
260
264
  minWidth: "4ch",
261
265
  textAlign: "right"
262
266
  }}>
263
- <Text size={1} muted={!winner}>
267
+ <Text size={1} muted={!isChosen}>
264
268
  {percent}%
265
269
  </Text>
266
270
  </Box>
@@ -342,73 +346,76 @@ const JEV_NAMESPACE = "jev", jevLocaleBundle = defineLocaleResourceBundle({
342
346
  name: "probability",
343
347
  type: "number",
344
348
  readOnly: !0
345
- }), ...bookkeepingFields]
346
- })], LEVELS$1 = {
349
+ }), ...evaluationFields]
350
+ })], LEVEL_BY_TONE = {
347
351
  critical: "low",
348
352
  caution: "medium",
349
353
  positive: "high"
350
- };
354
+ }, formatAsPercent = (probability) => `${Math.round(probability * 100)}%`;
355
+ function findNoulConfigError(question) {
356
+ return !question.true?.trim() || !question.false?.trim() ? "A noul question needs both `true` and `false` text." : findRangeRuleConfigError(question, 0, 1);
357
+ }
351
358
  function bindNoul(question) {
352
- let problem = !question.true?.trim() || !question.false?.trim() ? "A noul question needs both `true` and `false` text." : rangeRuleProblem(question, 0, 1);
359
+ let configError = findNoulConfigError(question), gatewayQuestion = {
360
+ type: "boolean",
361
+ instructions: question.instructions,
362
+ criteria: {
363
+ true: question.true,
364
+ false: question.false
365
+ }
366
+ };
353
367
  return {
354
368
  typeName: TYPE_NAMES.noul,
355
- gatewayQuestion: problem ? void 0 : {
356
- type: "boolean",
357
- instructions: question.instructions,
358
- criteria: {
359
- true: question.true,
360
- false: question.false
361
- }
362
- },
363
- problem,
364
- toStored(answer) {
369
+ gatewayQuestion: configError ? void 0 : gatewayQuestion,
370
+ configError,
371
+ toStoredValue(answer) {
365
372
  return answer.type === "boolean" ? {
366
373
  _type: TYPE_NAMES.noul,
367
- probability: round(answer.probability)
368
- } : mismatch();
374
+ probability: roundToDigits(answer.probability)
375
+ } : throwAnswerKindMismatch();
369
376
  },
370
- read(stored) {
371
- let record = storedOf(stored, TYPE_NAMES.noul);
377
+ readStoredValue(stored) {
378
+ let record = asRecordOfType(stored, TYPE_NAMES.noul);
372
379
  if (!record || typeof record.probability != "number") return;
373
380
  let probability = record.probability, tone = trafficTone(probability);
374
381
  return {
375
382
  value: {
376
- ...bookkeepingOf(record),
383
+ ...readEvaluationFields(record),
377
384
  _type: TYPE_NAMES.noul,
378
385
  probability
379
386
  },
380
387
  chip: {
381
- text: `${Math.round(probability * 100)}%`,
388
+ text: formatAsPercent(probability),
382
389
  color: trafficColor(probability)
383
390
  },
384
391
  tone: tone === "positive" ? "default" : tone,
385
392
  aside: {
386
- level: LEVELS$1[tone] ?? "medium",
393
+ level: LEVEL_BY_TONE[tone] ?? "medium",
387
394
  tone
388
395
  },
389
396
  body: <NoulBody question={question} probability={probability} />
390
397
  };
391
398
  },
392
- check(level, stored, title) {
393
- let record = storedOf(stored, TYPE_NAMES.noul);
399
+ describeRuleViolation(level, stored, title) {
400
+ let record = asRecordOfType(stored, TYPE_NAMES.noul);
394
401
  if (!record || typeof record.probability != "number") return;
395
- let percent = (value) => `${Math.round(value * 100)}%`, broken = rangeViolation(record.probability, question[level], percent);
396
- return broken && `${title} is ${percent(record.probability)}, ${broken}.`;
402
+ let violation = describeRangeViolation(record.probability, question[level], formatAsPercent);
403
+ return violation && `${title} is ${formatAsPercent(record.probability)}, ${violation}.`;
397
404
  }
398
405
  };
399
406
  }
400
407
  function NoulBody({ question, probability }) {
401
- let { t } = useTranslation("jev"), percent = Math.round(probability * 100);
408
+ let { t } = useTranslation("jev"), percent = formatAsPercent(probability);
402
409
  return <Stack gap={4}>
403
410
  <Flex align="baseline" gap={2}>
404
411
  <Text size={4} weight="medium">
405
- {percent}%
412
+ {percent}
406
413
  </Text>
407
414
  {question.label && <Text size={1} muted>
408
415
  {question.label}
409
416
  </Text>}
410
417
  </Flex>
411
- <Bar fraction={probability} color={trafficColor(probability)} label={`${question.title ?? t("noul.yes")}: ${percent}%`} />
418
+ <Bar fraction={probability} color={trafficColor(probability)} label={`${question.title ?? t("noul.yes")}: ${percent}`} />
412
419
  <Text size={1} muted>
413
420
  {probability >= .5 ? question.true : question.false}
414
421
  </Text>
@@ -444,109 +451,115 @@ const score = (question) => ({
444
451
  type: "number",
445
452
  readOnly: !0
446
453
  }),
447
- ...bookkeepingFields
454
+ ...evaluationFields
448
455
  ]
449
- })], shortLabel = (criterion) => criterion?.split(":")[0].trim() ?? "";
456
+ })], getCriterionShortLabel = (criterion) => criterion?.split(":")[0].trim() ?? "";
450
457
  /** `'anecdotal: personal experience'` → `'personal experience'`; without a colon, the whole text. */
451
- function meaningOf(criterion) {
458
+ function getCriterionMeaning(criterion) {
452
459
  if (!criterion) return "";
453
- let colon = criterion.indexOf(":");
454
- return (colon === -1 ? criterion : criterion.slice(colon + 1)).trim();
460
+ let colonIndex = criterion.indexOf(":");
461
+ return (colonIndex === -1 ? criterion : criterion.slice(colonIndex + 1)).trim();
455
462
  }
456
463
  /**
457
464
  * How full each segment of a score bar is, 0–1. Segment `i` stands for reaching criterion
458
465
  * `i`: a score of 0.8 fills the first segment and 80% of the second.
459
466
  */
460
- function segmentFills(score, count) {
461
- return Array.from({ length: count }, (_, index) => Math.min(1, Math.max(0, score - index + 1)));
467
+ function getSegmentFillFractions(score, segmentCount) {
468
+ return Array.from({ length: segmentCount }, (_, index) => Math.min(1, Math.max(0, score - index + 1)));
462
469
  }
463
- /** Colour and tone of a score, from the criterion it is nearest to. */
464
- function scoreLook(question, value, max) {
470
+ /** From the criterion the score is nearest to. */
471
+ function getScoreColorAndTone(question, score, max) {
465
472
  if (question.colors === "neutral") return {
466
473
  color: NEUTRAL_COLOR,
467
474
  tone: "primary"
468
475
  };
469
- let fraction = max > 0 ? Math.round(value) / max : 0, good = question.colors === "reverse" ? 1 - fraction : fraction;
476
+ let fractionOfMax = max > 0 ? Math.round(score) / max : 0, goodness = question.colors === "reverse" ? 1 - fractionOfMax : fractionOfMax;
470
477
  return {
471
- color: trafficColor(good),
472
- tone: trafficTone(good)
478
+ color: trafficColor(goodness),
479
+ tone: trafficTone(goodness)
473
480
  };
474
481
  }
482
+ function findScoreConfigError(question) {
483
+ let { criteria } = question;
484
+ return !Array.isArray(criteria) || criteria.length < 2 ? "A score question needs at least two criteria." : criteria.length > 10 ? "A score question allows at most ten criteria." : findRangeRuleConfigError(question, 0, criteria.length - 1);
485
+ }
475
486
  function bindScore(question) {
476
- let { criteria } = question, problem = !Array.isArray(criteria) || criteria.length < 2 ? "A score question needs at least two criteria." : criteria.length > 10 ? "A score question allows at most ten criteria." : rangeRuleProblem(question, 0, criteria.length - 1);
487
+ let configError = findScoreConfigError(question), gatewayQuestion = {
488
+ type: "score",
489
+ instructions: question.instructions,
490
+ criteria: question.criteria
491
+ };
492
+ /** E.g. `0.6 (anecdotal)`: a position on the scale and the criterion nearest to it. */
493
+ function describeScorePosition(position) {
494
+ let label = getCriterionShortLabel(question.criteria[Math.round(position)]), formattedPosition = Number.isInteger(position) ? String(position) : position.toFixed(1);
495
+ return label ? `${formattedPosition} (${label})` : formattedPosition;
496
+ }
477
497
  return {
478
498
  typeName: TYPE_NAMES.score,
479
- gatewayQuestion: problem ? void 0 : {
480
- type: "score",
481
- instructions: question.instructions,
482
- criteria
483
- },
484
- problem,
485
- toStored(answer) {
486
- if (answer.type !== "score") return mismatch();
487
- let max = criteria.length - 1, value = Math.min(max, Math.max(0, answer.score));
499
+ gatewayQuestion: configError ? void 0 : gatewayQuestion,
500
+ configError,
501
+ toStoredValue(answer) {
502
+ if (answer.type !== "score") return throwAnswerKindMismatch();
503
+ let max = question.criteria.length - 1, clampedScore = Math.min(max, Math.max(0, answer.score));
488
504
  return {
489
505
  _type: TYPE_NAMES.score,
490
- score: round(value),
506
+ score: roundToDigits(clampedScore),
491
507
  max,
492
- label: shortLabel(criteria[Math.round(value)]),
493
- ...typeof answer.confidence == "number" && { confidence: round(answer.confidence) }
508
+ label: getCriterionShortLabel(question.criteria[Math.round(clampedScore)]),
509
+ ...typeof answer.confidence == "number" && { confidence: roundToDigits(answer.confidence) }
494
510
  };
495
511
  },
496
- read(stored) {
497
- let record = storedOf(stored, TYPE_NAMES.score);
512
+ readStoredValue(stored) {
513
+ let record = asRecordOfType(stored, TYPE_NAMES.score);
498
514
  if (!record || typeof record.score != "number" || typeof record.max != "number") return;
499
- let { score: value, max } = record, label = text(record.label), look = scoreLook(question, value, max);
515
+ let { score, max } = record, label = stringOrUndefined(record.label), { color, tone } = getScoreColorAndTone(question, score, max), nearestCriterion = question.criteria?.[Math.round(score)];
500
516
  return {
501
517
  value: {
502
- ...bookkeepingOf(record),
518
+ ...readEvaluationFields(record),
503
519
  _type: TYPE_NAMES.score,
504
- score: value,
520
+ score,
505
521
  max,
506
522
  label,
507
- confidence: number(record.confidence)
523
+ confidence: numberOrUndefined(record.confidence)
508
524
  },
509
525
  chip: {
510
- text: `${value.toFixed(1)}/${max}`,
511
- color: look.color
526
+ text: `${score.toFixed(1)}/${max}`,
527
+ color
512
528
  },
513
- tone: look.tone === "critical" || look.tone === "caution" ? look.tone : "default",
529
+ tone: tone === "critical" || tone === "caution" ? tone : "default",
514
530
  aside: {
515
- badge: capitalize(label ?? shortLabel(criteria?.[Math.round(value)])),
516
- tone: look.tone
531
+ badge: capitalize(label ?? getCriterionShortLabel(nearestCriterion)),
532
+ tone
517
533
  },
518
- body: <ScoreBody question={question} score={value} max={max} color={look.color} />
534
+ body: <ScoreBody question={question} score={score} max={max} color={color} />
519
535
  };
520
536
  },
521
- check(level, stored, title) {
522
- let record = storedOf(stored, TYPE_NAMES.score);
537
+ describeRuleViolation(level, stored, title) {
538
+ let record = asRecordOfType(stored, TYPE_NAMES.score);
523
539
  if (!record || typeof record.score != "number") return;
524
- let describe = (position) => {
525
- let label = shortLabel(criteria[Math.round(position)]), number = Number.isInteger(position) ? String(position) : position.toFixed(1);
526
- return label ? `${number} (${label})` : number;
527
- }, broken = rangeViolation(record.score, question[level], describe);
528
- return broken && `${title} is ${describe(record.score)}, ${broken}.`;
540
+ let violation = describeRangeViolation(record.score, question[level], describeScorePosition);
541
+ return violation && `${title} is ${describeScorePosition(record.score)}, ${violation}.`;
529
542
  }
530
543
  };
531
544
  }
532
545
  function ScoreBody({ question, score, max, color }) {
533
- let { t } = useTranslation("jev"), nearest = Math.round(score), fills = segmentFills(score, question.criteria.length), next = question.criteria[nearest + 1];
546
+ let { t } = useTranslation("jev"), nearestIndex = Math.round(score), segmentFillFractions = getSegmentFillFractions(score, question.criteria.length), nextCriterion = question.criteria[nearestIndex + 1];
534
547
  return <Stack gap={4}>
535
548
  <Flex gap={1}>
536
549
  {question.criteria.map((criterion, index) => <Stack key={index} gap={2} flex={1}>
537
- <Bar fraction={fills[index]} color={color} label={`${index}: ${shortLabel(criterion)}`} />
538
- <Text size={1} weight={index === nearest ? "medium" : "regular"} muted={index > nearest} style={index < nearest ? { color } : void 0} textOverflow="ellipsis">
539
- {index} · {capitalize(shortLabel(criterion))}
550
+ <Bar fraction={segmentFillFractions[index]} color={color} label={`${index}: ${getCriterionShortLabel(criterion)}`} />
551
+ <Text size={1} weight={index === nearestIndex ? "medium" : "regular"} muted={index > nearestIndex} style={index < nearestIndex ? { color } : void 0} textOverflow="ellipsis">
552
+ {index} · {capitalize(getCriterionShortLabel(criterion))}
540
553
  </Text>
541
554
  </Stack>)}
542
555
  </Flex>
543
556
  <Text size={1} muted>
544
557
  {t("score.summary", {
545
- meaning: capitalize(meaningOf(question.criteria[nearest])),
558
+ meaning: capitalize(getCriterionMeaning(question.criteria[nearestIndex])),
546
559
  score: score.toFixed(1),
547
560
  max
548
561
  })}
549
- {next ? ` ${t("score.next", { next: meaningOf(next) })}` : ""}
562
+ {nextCriterion ? ` ${t("score.next", { next: getCriterionMeaning(nextCriterion) })}` : ""}
550
563
  </Text>
551
564
  </Stack>;
552
565
  }
@@ -556,37 +569,36 @@ const KIND_SCHEMA_TYPES = [
556
569
  ...scoreSchemaTypes,
557
570
  ...choiceSchemaTypes
558
571
  ];
559
- /** Binds a question to its kind. The one place that tells Noul, Score and Choice apart. */
560
- function kindOf(question) {
561
- let kind = bind(question);
572
+ /** The one place that tells Noul, Score and Choice apart. */
573
+ function bindQuestionToKind(question) {
574
+ let kind = bindQuestionByType(question);
562
575
  return question.instructions?.trim() ? kind : {
563
576
  ...kind,
564
577
  gatewayQuestion: void 0,
565
- problem: "Add instructions to this question."
578
+ configError: "Add instructions to this question."
566
579
  };
567
580
  }
568
- function bind(question) {
581
+ function bindQuestionByType(question) {
569
582
  switch (question.type) {
570
583
  case "noul": return bindNoul(question);
571
584
  case "score": return bindScore(question);
572
585
  case "choice": return bindChoice(question);
573
586
  default: return {
574
587
  typeName: "unknown",
575
- problem: "Unknown question type.",
576
- toStored: mismatch,
577
- read: () => void 0,
578
- check: () => void 0
588
+ configError: "Unknown question type.",
589
+ toStoredValue: throwAnswerKindMismatch,
590
+ readStoredValue: () => void 0,
591
+ describeRuleViolation: () => void 0
579
592
  };
580
593
  }
581
594
  }
582
- /** `readable` → `Readable`, `readingLevel` → `Reading level`. */
583
- function titleOf(key, question) {
595
+ /** The question's own `title`, or one made from its key: `readingLevel` → `Reading level`. */
596
+ function getQuestionTitle(key, question) {
584
597
  if (question.title) return question.title;
585
598
  let words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ");
586
599
  return words.charAt(0).toUpperCase() + words.slice(1).toLowerCase();
587
600
  }
588
- const isRecord = (value) => !!value && typeof value == "object" && !Array.isArray(value);
589
- function questionsOf(definition) {
601
+ function getQuestionsFromDefinition(definition) {
590
602
  if (!isRecord(definition) || !isRecord(definition.options)) return;
591
603
  let { jev } = definition.options;
592
604
  return isRecord(jev) ? jev : void 0;
@@ -601,19 +613,19 @@ function questionsOf(definition) {
601
613
  * @public
602
614
  */
603
615
  function withJevAnswers(types) {
604
- return types.map((type) => withAnswerFields(type));
616
+ return types.map((type) => addAnswerFieldsRecursively(type));
605
617
  }
606
- /** Rewrites `fields` (and inline object members) of a type or field definition, recursively. */
607
- function withAnswerFields(definition) {
618
+ /** Rewrites `fields` (and inline object members) of a type or field definition. */
619
+ function addAnswerFieldsRecursively(definition) {
608
620
  if (!isRecord(definition)) return definition;
609
621
  let result = definition;
610
622
  if (Array.isArray(definition.of) && (result = {
611
623
  ...result,
612
- of: definition.of.map(withAnswerFields)
624
+ of: definition.of.map(addAnswerFieldsRecursively)
613
625
  }), Array.isArray(definition.fields)) {
614
- let existing = new Map(definition.fields.filter(isRecord).map((field) => [field.name, field.type])), fields = definition.fields.flatMap((field) => {
615
- let rewritten = withAnswerFields(field), questions = questionsOf(field);
616
- return !questions || !isRecord(field) || !isRecord(rewritten) ? [rewritten] : [withRules(rewritten, questions), ...answerFieldsFor(field, questions, existing)];
626
+ let existingFieldTypesByName = new Map(definition.fields.filter(isRecord).map((field) => [field.name, field.type])), fields = definition.fields.flatMap((field) => {
627
+ let rewrittenField = addAnswerFieldsRecursively(field), questions = getQuestionsFromDefinition(field);
628
+ return !questions || !isRecord(field) || !isRecord(rewrittenField) ? [rewrittenField] : [addRuleValidation(rewrittenField, questions), ...createAnswerFields(field, questions, existingFieldTypesByName)];
617
629
  });
618
630
  result = {
619
631
  ...result,
@@ -622,61 +634,66 @@ function withAnswerFields(definition) {
622
634
  }
623
635
  return result;
624
636
  }
625
- function answerFieldsFor(field, questions, existing) {
637
+ function createAnswerFields(attachedField, questions, existingFieldTypesByName) {
626
638
  return Object.entries(questions).flatMap(([key, question]) => {
627
- let type = TYPE_NAMES[question.type];
628
- if (existing.get(key) === type) return [];
629
- if (existing.has(key)) throw Error(`Jev: the question "${key}" on "${String(field.name)}" needs a field called "${key}" to store its answer, but that name is already taken. Rename the question.`);
630
- return existing.set(key, type), [defineField({
639
+ let answerType = TYPE_NAMES[question.type];
640
+ if (existingFieldTypesByName.get(key) === answerType) return [];
641
+ if (existingFieldTypesByName.has(key)) throw Error(`Jev: the question "${key}" on "${String(attachedField.name)}" needs a field called "${key}" to store its answer, but that name is already taken. Rename the question.`);
642
+ existingFieldTypesByName.set(key, answerType);
643
+ let { group, fieldset } = attachedField;
644
+ return [defineField({
631
645
  name: key,
632
- title: titleOf(key, question),
633
- type,
634
- group: typeof field.group == "string" || Array.isArray(field.group) ? field.group : void 0,
635
- fieldset: typeof field.fieldset == "string" ? field.fieldset : void 0
646
+ title: getQuestionTitle(key, question),
647
+ type: answerType,
648
+ group: typeof group == "string" || Array.isArray(group) ? group : void 0,
649
+ fieldset: typeof fieldset == "string" ? fieldset : void 0
636
650
  })];
637
651
  });
638
652
  }
639
- const LEVELS = ["warn", "require"];
653
+ /** `undefined` → `[]`, a value → `[value]`, an array as it is. */
654
+ function toArray(value) {
655
+ return Array.isArray(value) ? value : value ? [value] : [];
656
+ }
640
657
  /**
641
658
  * Adds a validation rule to the attached field for each question's `warn` and `require`, after
642
659
  * the field's own validation. Rules judge the stored answer, so they say nothing until there is
643
660
  * one.
644
661
  */
645
- function withRules(field, questions) {
646
- let checks = Object.entries(questions).flatMap(([key, question]) => LEVELS.filter((level) => question[level]).map((level) => ({
662
+ function addRuleValidation(attachedField, questions) {
663
+ let rulesToValidate = Object.entries(questions).flatMap(([key, question]) => RULE_LEVELS.filter((level) => question[level]).map((level) => ({
647
664
  key,
648
665
  question,
649
666
  level
650
667
  })));
651
- if (checks.length === 0) return field;
652
- let own = field.validation, validation = (rule) => {
653
- let ownRules = (Array.isArray(own) ? own : own ? [own] : []).flatMap((entry) => {
654
- let result = typeof entry == "function" ? entry(rule) : entry;
655
- return Array.isArray(result) ? result : result ? [result] : [];
656
- }), jevRules = checks.map(({ key, question, level }) => {
657
- let kind = kindOf(question), title = titleOf(key, question), custom = rule.custom((_value, context) => {
668
+ if (rulesToValidate.length === 0) return attachedField;
669
+ let ownValidation = attachedField.validation, validation = (rule) => {
670
+ let ownRules = toArray(ownValidation).flatMap((entry) => toArray(typeof entry == "function" ? entry(rule) : entry)), jevRules = rulesToValidate.map(({ key, question, level }) => {
671
+ let kind = bindQuestionToKind(question), title = getQuestionTitle(key, question), customRule = rule.custom((_value, context) => {
658
672
  let stored = isRecord(context.parent) ? context.parent[key] : void 0;
659
- return kind.check(level, stored, title) ?? !0;
673
+ return kind.describeRuleViolation(level, stored, title) ?? !0;
660
674
  });
661
- return level === "require" ? custom.error() : custom.warning();
675
+ return level === "require" ? customRule.error() : customRule.warning();
662
676
  });
663
677
  return [...ownRules, ...jevRules];
664
678
  };
665
679
  return {
666
- ...field,
680
+ ...attachedField,
667
681
  validation
668
682
  };
669
683
  }
670
- const SECRETS_ID = "secrets.jev";
684
+ const SECRETS_DOCUMENT_ID = "secrets.jev";
685
+ function readApiKeyFromSecrets(secrets) {
686
+ let apiKey = secrets && typeof secrets == "object" && "gatewayApiKey" in secrets ? secrets.gatewayApiKey : void 0;
687
+ return typeof apiKey == "string" && apiKey ? apiKey : void 0;
688
+ }
671
689
  /** The AI Gateway key stored in `secrets.jev`, kept up to date as it changes. */
672
690
  function useStoredKey() {
673
691
  let documentPreviewStore = useDocumentPreviewStore(), [stored, setStored] = useState({ loading: !0 });
674
692
  return useEffect(() => {
675
- let subscription = documentPreviewStore.unstable_observeDocument(SECRETS_ID).subscribe((document) => {
676
- let secrets = document?.secrets, apiKey = secrets && typeof secrets == "object" && "gatewayApiKey" in secrets ? secrets.gatewayApiKey : void 0;
693
+ let subscription = documentPreviewStore.unstable_observeDocument(SECRETS_DOCUMENT_ID).subscribe((document) => {
677
694
  setStored({
678
695
  loading: !1,
679
- apiKey: typeof apiKey == "string" && apiKey ? apiKey : void 0,
696
+ apiKey: readApiKeyFromSecrets(document?.secrets),
680
697
  updatedAt: document?._updatedAt
681
698
  });
682
699
  });
@@ -688,15 +705,17 @@ function useSaveKey() {
688
705
  let client = useClient({ apiVersion: "2025-02-19" });
689
706
  return async (apiKey) => {
690
707
  await client.transaction().createIfNotExists({
691
- _id: SECRETS_ID,
708
+ _id: SECRETS_DOCUMENT_ID,
692
709
  _type: "pluginSecrets"
693
- }).patch(SECRETS_ID, (patch) => apiKey ? patch.set({ secrets: { gatewayApiKey: apiKey } }) : patch.unset(["secrets"])).commit({ tag: "jev.secrets" });
710
+ }).patch(SECRETS_DOCUMENT_ID, (patch) => apiKey ? patch.set({ secrets: { gatewayApiKey: apiKey } }) : patch.unset(["secrets"])).commit({ tag: "jev.secrets" });
694
711
  };
695
712
  }
713
+ /** With a `transport` or `apiKey` in the plugin config, the stored key is ignored. */
714
+ const isKeyFromConfig = (config) => !!(config.transport || config.apiKey);
696
715
  /** Where the API key comes from: plugin config wins over the stored key. */
697
716
  function useKeySource(config) {
698
717
  let stored = useStoredKey();
699
- return config.transport || config.apiKey ? {
718
+ return isKeyFromConfig(config) ? {
700
719
  from: "config",
701
720
  apiKey: config.apiKey
702
721
  } : {
@@ -743,8 +762,8 @@ function JevKeyDialog({ onClose }) {
743
762
  </Box>
744
763
  </Dialog>;
745
764
  }
746
- /** An evaluation error in the Studio's language; errors from outside the Gateway as they are. */
747
- function errorText(t, error) {
765
+ /** Errors from outside the Gateway are shown as they are. */
766
+ function translateQuestionError(t, error) {
748
767
  switch (error.kind) {
749
768
  case "auth": return t("error.auth");
750
769
  case "invalid": return t("error.invalid", { detail: error.detail ?? error.status });
@@ -760,7 +779,7 @@ function errorText(t, error) {
760
779
  const pulse = keyframes`
761
780
  0%, 100% { opacity: 1; }
762
781
  50% { opacity: 0.25; }
763
- `, Dot = styled.span`
782
+ `, ChipStatusDot = styled.span`
764
783
  flex: none;
765
784
  width: 6px;
766
785
  height: 6px;
@@ -768,11 +787,11 @@ const pulse = keyframes`
768
787
  background: ${({ $color }) => $color};
769
788
  animation: ${({ $pulse }) => $pulse ? pulse : "none"} 1s ease-in-out infinite;
770
789
  `;
771
- function Chip({ question, selected, onSelect }) {
790
+ function QuestionChip({ question, selected, onSelect }) {
772
791
  let { t } = useTranslation("jev"), { text, color } = question.reading?.chip ?? {
773
792
  text: t("chip.empty"),
774
793
  color: void 0
775
- }, failed = !!question.error || !!question.problem;
794
+ }, hasError = !!question.error || !!question.configError;
776
795
  return <Card as="button" type="button" radius={2} paddingX={2} paddingY={2} onClick={onSelect} aria-pressed={selected} title={question.stale ? t("chip.out-of-date") : void 0} style={{
777
796
  display: "inline-flex",
778
797
  flex: "none",
@@ -782,24 +801,24 @@ function Chip({ question, selected, onSelect }) {
782
801
  background: selected ? "rgba(128, 128, 128, 0.16)" : void 0
783
802
  }}>
784
803
  <Flex align="center" gap={2}>
785
- <Dot aria-hidden $color={failed ? "#f03e2f" : color ?? "hsl(215 12% 55%)"} $pulse={question.loading} />
804
+ <ChipStatusDot aria-hidden $color={hasError ? ERROR_COLOR : color ?? "hsl(215 12% 55%)"} $pulse={question.loading} />
786
805
  {}
787
806
  <Text size={1}>
788
807
  {question.title}
789
808
  <span style={{
790
809
  marginLeft: "0.6em",
791
810
  fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
792
- color: failed ? "#f03e2f" : color,
811
+ color: hasError ? ERROR_COLOR : color,
793
812
  opacity: question.stale ? .5 : 1
794
813
  }}>
795
- {failed ? t("chip.error") : text}
814
+ {hasError ? t("chip.error") : text}
796
815
  </span>
797
816
  </Text>
798
817
  </Flex>
799
818
  </Card>;
800
819
  }
801
820
  /** The row of question chips under an attached field. */
802
- function QuestionStrip({ questions, selected, onSelect, setup, canRun, loading, onRunAll, onSetUp }) {
821
+ function QuestionStrip({ questions, selectedKey, onSelect, setupStatus, canRun, loading, onEvaluateAll, onSetUp }) {
803
822
  let { t } = useTranslation("jev");
804
823
  return <Card border padding={1} style={{
805
824
  borderTop: 0,
@@ -809,27 +828,38 @@ function QuestionStrip({ questions, selected, onSelect, setup, canRun, loading,
809
828
  }}>
810
829
  <Flex align="center" gap={2}>
811
830
  <Flex flex={1} align="center" gap={1} wrap="wrap">
812
- {questions.map((question) => <Chip key={question.key} question={question} selected={selected === question.key} onSelect={() => onSelect(selected === question.key ? null : question.key)} />)}
831
+ {questions.map((question) => {
832
+ let isSelected = selectedKey === question.key;
833
+ return <QuestionChip key={question.key} question={question} selected={isSelected} onSelect={() => onSelect(isSelected ? null : question.key)} />;
834
+ })}
813
835
  </Flex>
814
- {setup === "missing" ? <Button text={t("strip.set-up")} mode="ghost" tone="primary" fontSize={1} padding={2} onClick={onSetUp} /> : <Flex align="center" gap={1}>
836
+ {setupStatus === "missing" ? <Button text={t("strip.set-up")} mode="ghost" tone="primary" fontSize={1} padding={2} onClick={onSetUp} /> : <Flex align="center" gap={1}>
815
837
  <Text size={0} muted>
816
838
  {t("strip.label")}
817
839
  </Text>
818
840
  {loading ? <Box padding={2}>
819
841
  <Spinner muted />
820
- </Box> : <Button icon={RefreshIcon} mode="bleed" fontSize={1} padding={2} title={t("strip.evaluate-all")} aria-label={t("strip.evaluate-all")} disabled={!canRun} onClick={onRunAll} />}
842
+ </Box> : <Button icon={RefreshIcon} mode="bleed" fontSize={1} padding={2} title={t("strip.evaluate-all")} aria-label={t("strip.evaluate-all")} disabled={!canRun} onClick={onEvaluateAll} />}
821
843
  </Flex>}
822
844
  </Flex>
823
845
  </Card>;
824
846
  }
847
+ function QuestionDetailBody({ question, empty }) {
848
+ let { t } = useTranslation("jev");
849
+ return question.configError ? <Text size={1}>{question.configError}</Text> : question.reading ? question.reading.body : <Text size={1} muted>
850
+ {t(empty ? "detail.empty-field" : "detail.not-evaluated")}
851
+ </Text>;
852
+ }
853
+ function QuestionDetailAside({ aside }) {
854
+ let { t } = useTranslation("jev");
855
+ return "badge" in aside ? <Badge tone={aside.tone}>{aside.badge}</Badge> : "level" in aside ? <Badge tone={aside.tone}>{t(`noul.level.${aside.level}`)}</Badge> : <Text size={1} muted>
856
+ {aside.note}
857
+ </Text>;
858
+ }
825
859
  /** Expanded view of the selected chip. */
826
860
  function QuestionDetail({ question, empty, onRetry, onUpdateKey }) {
827
- let { t } = useTranslation("jev"), { reading } = question, body = question.problem ? <Text size={1}>{question.problem}</Text> : reading ? reading.body : <Text size={1} muted>
828
- {t(empty ? "detail.empty-field" : "detail.not-evaluated")}
829
- </Text>, aside = reading?.aside ? "badge" in reading.aside ? <Badge tone={reading.aside.tone}>{reading.aside.badge}</Badge> : "level" in reading.aside ? <Badge tone={reading.aside.tone}>{t(`noul.level.${reading.aside.level}`)}</Badge> : <Text size={1} muted>
830
- {reading.aside.note}
831
- </Text> : null;
832
- return <Card padding={4} radius={2} border tone={question.problem ? "critical" : reading?.tone ?? "default"}>
861
+ let { t } = useTranslation("jev"), aside = question.reading?.aside, tone = question.configError ? "critical" : question.reading?.tone ?? "default";
862
+ return <Card padding={4} radius={2} border tone={tone}>
833
863
  <Stack gap={4}>
834
864
  <Flex align="center" gap={3}>
835
865
  <Box flex={1}>
@@ -837,16 +867,16 @@ function QuestionDetail({ question, empty, onRetry, onUpdateKey }) {
837
867
  {question.title}
838
868
  </Text>
839
869
  </Box>
840
- {aside}
870
+ {aside && <QuestionDetailAside aside={aside} />}
841
871
  </Flex>
842
- {body}
872
+ <QuestionDetailBody question={question} empty={empty} />
843
873
  {question.stale && !question.loading && <Text size={0} muted>
844
874
  {t("detail.out-of-date")}
845
875
  </Text>}
846
876
  {question.error && <Card padding={3} radius={2} tone="critical" border>
847
877
  <Flex align="center" gap={3} wrap="wrap">
848
878
  <Box flex={1}>
849
- <Text size={1}>{errorText(t, question.error)}</Text>
879
+ <Text size={1}>{translateQuestionError(t, question.error)}</Text>
850
880
  </Box>
851
881
  {question.keyRejected && onUpdateKey ? <Button text={t("detail.update-key")} mode="ghost" fontSize={1} onClick={onUpdateKey} /> : <Button text={t("detail.try-again")} mode="ghost" fontSize={1} onClick={onRetry} />}
852
882
  </Flex>
@@ -905,7 +935,7 @@ var JevError = class extends Error {
905
935
  }
906
936
  };
907
937
  /** The default transport: calls AI Gateway directly from the browser with the API key. */
908
- function gatewayTransport(apiKey, endpoint = "https://ai-gateway.vercel.sh/v1/evaluate") {
938
+ function createGatewayTransport(apiKey, endpoint = "https://ai-gateway.vercel.sh/v1/evaluate") {
909
939
  return (request, { signal }) => fetch(endpoint, {
910
940
  method: "POST",
911
941
  signal,
@@ -916,6 +946,11 @@ function gatewayTransport(apiKey, endpoint = "https://ai-gateway.vercel.sh/v1/ev
916
946
  body: JSON.stringify(request)
917
947
  });
918
948
  }
949
+ /** A `transport` in the plugin config wins; otherwise the API key, if there is one. */
950
+ function resolveTransport(config, apiKey) {
951
+ if (config.transport) return config.transport;
952
+ if (apiKey) return createGatewayTransport(apiKey, config.endpoint);
953
+ }
919
954
  /**
920
955
  * Asks the decision model a single typed question about the state. Resolves with the answer
921
956
  * and the model that gave it, as the Gateway names it.
@@ -926,7 +961,7 @@ async function evaluateQuestion({ transport, model, state, question, tags, signa
926
961
  state,
927
962
  questions: { q: question },
928
963
  providerOptions: { gateway: { tags: normalizeTags(tags) } }
929
- }, send = async (attempt) => {
964
+ }, sendWithRetries = async (attempt) => {
930
965
  let response = await transport(request, { signal }), body = await response.json().catch(() => ({}));
931
966
  if (response.ok) {
932
967
  let answer = body.answers?.q;
@@ -936,21 +971,21 @@ async function evaluateQuestion({ transport, model, state, question, tags, signa
936
971
  model: body.model ?? model
937
972
  };
938
973
  }
939
- if (RETRY_STATUSES.has(response.status) && attempt < retryDelays.length) return await sleep(retryDelay(response, retryDelays[attempt]), signal), send(attempt + 1);
940
- throw toError(response.status, errorMessage(body));
974
+ if (RETRY_STATUSES.has(response.status) && attempt < retryDelays.length) return await sleep(getRetryDelayMs(response, retryDelays[attempt]), signal), sendWithRetries(attempt + 1);
975
+ throw toJevError(response.status, readGatewayErrorMessage(body));
941
976
  };
942
- return send(0);
977
+ return sendWithRetries(0);
943
978
  }
944
- function toError(status, message) {
979
+ function toJevError(status, message) {
945
980
  return status === 401 || status === 403 ? new JevError("auth", "AI Gateway rejected the API key. Check the key in the Jev tool.", status, message) : status === 400 || status === 422 ? new JevError("invalid", `AI Gateway rejected the question: ${message ?? status}`, status, message) : RETRY_STATUSES.has(status) ? new JevError("busy", "AI Gateway is busy. Try again in a moment.", status, message) : new JevError("failed", `AI Gateway responded with ${status}${message ? `: ${message}` : ""}`, status, message);
946
981
  }
947
- function errorMessage(body) {
982
+ function readGatewayErrorMessage(body) {
948
983
  return (typeof body.error == "string" ? body.error : body.error?.message) || void 0;
949
984
  }
950
985
  /** Honours a short `Retry-After` (in seconds) when it asks for longer than our own backoff. */
951
- function retryDelay(response, backoff) {
952
- let seconds = Number(response.headers.get("retry-after"));
953
- return !Number.isFinite(seconds) || seconds <= 0 ? backoff : Math.min(Math.max(backoff, seconds * 1e3), 1e4);
986
+ function getRetryDelayMs(response, backoffMs) {
987
+ let retryAfterSeconds = Number(response.headers.get("retry-after"));
988
+ return !Number.isFinite(retryAfterSeconds) || retryAfterSeconds <= 0 ? backoffMs : Math.min(Math.max(backoffMs, retryAfterSeconds * 1e3), 1e4);
954
989
  }
955
990
  function sleep(ms, signal) {
956
991
  return new Promise((resolve, reject) => {
@@ -962,8 +997,8 @@ function sleep(ms, signal) {
962
997
  });
963
998
  }
964
999
  function normalizeTags(tags) {
965
- let cleaned = tags.map((tag) => tag.trim().slice(0, 64)).filter(Boolean);
966
- return [...new Set(cleaned)].slice(0, 10);
1000
+ let trimmedTags = tags.map((tag) => tag.trim().slice(0, 64)).filter(Boolean);
1001
+ return [...new Set(trimmedTags)].slice(0, 10);
967
1002
  }
968
1003
  const realClock = {
969
1004
  now: () => /* @__PURE__ */ new Date(),
@@ -971,7 +1006,16 @@ const realClock = {
971
1006
  let timeout = setTimeout(callback, ms);
972
1007
  return () => clearTimeout(timeout);
973
1008
  }
974
- }, EMPTY = {
1009
+ };
1010
+ function toQuestionError(error) {
1011
+ return error instanceof JevError ? {
1012
+ message: error.message,
1013
+ kind: error.kind,
1014
+ status: error.status,
1015
+ detail: error.detail
1016
+ } : { message: error instanceof Error ? error.message : String(error) };
1017
+ }
1018
+ const EMPTY_SNAPSHOT = {
975
1019
  questions: [],
976
1020
  canRun: !1,
977
1021
  empty: !0,
@@ -983,129 +1027,125 @@ const realClock = {
983
1027
  * a fingerprint that has since changed is cancelled and its answer dropped.
984
1028
  */
985
1029
  function createLifecycle(clock = realClock) {
986
- let inputs, entries = [], localState, expectLocal = !1, requests = /* @__PURE__ */ new Map(), statuses = /* @__PURE__ */ new Map(), attempted = /* @__PURE__ */ new Map(), scheduled, snapshot = EMPTY, dirty = !0, listeners = /* @__PURE__ */ new Set(), canRun = (current) => !!current.transport && !current.readOnly && current.state.trim() !== "";
1030
+ let inputs, boundQuestions = [], stateFromLastLocalEdit, nextStateIsFromLocalEdit = !1, requestsInFlight = /* @__PURE__ */ new Map(), evaluationStatuses = /* @__PURE__ */ new Map(), lastAttemptedHashes = /* @__PURE__ */ new Map(), scheduledEvaluation, snapshot = EMPTY_SNAPSHOT, snapshotIsOutdated = !0, listeners = /* @__PURE__ */ new Set(), canRun = (current) => !!current.transport && !current.readOnly && current.state.trim() !== "", getHashesByKey = () => new Map(boundQuestions.map(({ key, hash }) => [key, hash]));
987
1031
  function update(next) {
988
1032
  let previous = inputs;
989
- inputs = next, next.state !== previous?.state && (localState = expectLocal ? next.state : void 0, expectLocal = !1), (!previous || next.questions !== previous.questions || next.state !== previous.state || next.model !== previous.model) && (entries = Object.entries(next.questions).map(([key, question]) => {
990
- let kind = kindOf(question);
1033
+ inputs = next, next.state !== previous?.state && (stateFromLastLocalEdit = nextStateIsFromLocalEdit ? next.state : void 0, nextStateIsFromLocalEdit = !1), (!previous || next.questions !== previous.questions || next.state !== previous.state || next.model !== previous.model) && (boundQuestions = Object.entries(next.questions).map(([key, question]) => {
1034
+ let kind = bindQuestionToKind(question);
991
1035
  return {
992
1036
  key,
993
1037
  question,
994
1038
  kind,
995
- title: titleOf(key, question),
1039
+ title: getQuestionTitle(key, question),
996
1040
  hash: fingerprint([
997
1041
  next.state,
998
1042
  kind.gatewayQuestion ?? null,
999
1043
  next.model
1000
1044
  ])
1001
1045
  };
1002
- }), forgetObsoleteWork(), dirty = !0), previous && (canRun(next) !== canRun(previous) || entries.some(({ key }) => next.answers[key] !== previous.answers[key])) && (dirty = !0), refresh();
1046
+ }), cancelWorkForChangedHashes(), snapshotIsOutdated = !0);
1047
+ let canRunChanged = previous && canRun(next) !== canRun(previous), answersChanged = previous && boundQuestions.some(({ key }) => next.answers[key] !== previous.answers[key]);
1048
+ (canRunChanged || answersChanged) && (snapshotIsOutdated = !0), rebuildSnapshotIfOutdated();
1003
1049
  }
1004
- function forgetObsoleteWork() {
1005
- let hashes = new Map(entries.map(({ key, hash }) => [key, hash]));
1006
- for (let [key, request] of requests) hashes.get(key) !== request.hash && (request.controller.abort(), requests.delete(key));
1007
- for (let [key, status] of statuses) hashes.get(key) !== status.hash && statuses.delete(key);
1008
- for (let [key, hash] of attempted) hashes.get(key) !== hash && attempted.delete(key);
1050
+ function cancelWorkForChangedHashes() {
1051
+ let hashesByKey = getHashesByKey();
1052
+ for (let [key, request] of requestsInFlight) hashesByKey.get(key) !== request.hash && (request.controller.abort(), requestsInFlight.delete(key));
1053
+ for (let [key, status] of evaluationStatuses) hashesByKey.get(key) !== status.hash && evaluationStatuses.delete(key);
1054
+ for (let [key, hash] of lastAttemptedHashes) hashesByKey.get(key) !== hash && lastAttemptedHashes.delete(key);
1009
1055
  }
1010
- /** Questions that would evaluate on their own: unanswered or stale, and not tried yet. */
1011
- function pendingKeys(current, views) {
1012
- return !canRun(current) || localState !== current.state ? [] : entries.filter((entry, index) => {
1013
- let view = views[index];
1014
- return entry.kind.gatewayQuestion && (!view.reading || view.stale) && attempted.get(entry.key) !== entry.hash;
1056
+ /** Unanswered or stale, and not tried yet for the current fingerprint. */
1057
+ function getKeysToEvaluateAutomatically(current, views) {
1058
+ return !canRun(current) || stateFromLastLocalEdit !== current.state ? [] : boundQuestions.filter((boundQuestion, index) => {
1059
+ let view = views[index], needsAnswer = !view.reading || view.stale, alreadyAttempted = lastAttemptedHashes.get(boundQuestion.key) === boundQuestion.hash;
1060
+ return boundQuestion.kind.gatewayQuestion && needsAnswer && !alreadyAttempted;
1015
1061
  }).map(({ key }) => key);
1016
1062
  }
1017
- /** Rebuilds the snapshot and the debounce after anything they depend on changed. */
1018
- function refresh() {
1063
+ function rebuildSnapshotIfOutdated() {
1019
1064
  let current = inputs;
1020
- if (!current || !dirty) return;
1021
- dirty = !1;
1022
- let views = entries.map((entry) => {
1023
- let reading = entry.kind.read(current.answers[entry.key]), status = statuses.get(entry.key);
1065
+ if (!current || !snapshotIsOutdated) return;
1066
+ snapshotIsOutdated = !1;
1067
+ let views = boundQuestions.map((boundQuestion) => {
1068
+ let reading = boundQuestion.kind.readStoredValue(current.answers[boundQuestion.key]), status = evaluationStatuses.get(boundQuestion.key);
1024
1069
  return {
1025
- key: entry.key,
1026
- question: entry.question,
1027
- title: entry.title,
1028
- problem: entry.kind.problem,
1070
+ key: boundQuestion.key,
1071
+ question: boundQuestion.question,
1072
+ title: boundQuestion.title,
1073
+ configError: boundQuestion.kind.configError,
1029
1074
  reading,
1030
- stale: !!reading && reading.value.sourceHash !== entry.hash,
1075
+ stale: !!reading && reading.value.sourceHash !== boundQuestion.hash,
1031
1076
  loading: status?.state === "loading",
1032
1077
  error: status?.state === "error" ? status.error : null,
1033
1078
  keyRejected: status?.state === "error" && status.error.kind === "auth"
1034
1079
  };
1035
- }), pending = pendingKeys(current, views);
1036
- for (let view of views) pending.includes(view.key) && (view.loading = !0);
1080
+ }), keysToEvaluate = getKeysToEvaluateAutomatically(current, views);
1081
+ for (let view of views) keysToEvaluate.includes(view.key) && (view.loading = !0);
1037
1082
  snapshot = {
1038
1083
  questions: views,
1039
1084
  canRun: canRun(current),
1040
1085
  empty: current.state.trim() === "",
1041
1086
  loading: views.some((view) => view.loading)
1042
- }, schedule(pending, current.debounceMs);
1087
+ }, scheduleEvaluationAfterDebounce(keysToEvaluate, current.debounceMs);
1043
1088
  for (let listener of listeners) listener();
1044
1089
  }
1045
- function schedule(keys, debounceMs) {
1046
- let hashes = new Map(entries.map(({ key, hash }) => [key, hash])), signature = keys.map((key) => `${key}:${hashes.get(key)}`).join("\n");
1047
- signature !== scheduled?.signature && (scheduled?.cancel(), scheduled = void 0, signature && (scheduled = {
1090
+ function scheduleEvaluationAfterDebounce(keys, debounceMs) {
1091
+ let hashesByKey = getHashesByKey(), signature = keys.map((key) => `${key}:${hashesByKey.get(key)}`).join("\n");
1092
+ signature !== scheduledEvaluation?.signature && (scheduledEvaluation?.cancel(), scheduledEvaluation = void 0, signature && (scheduledEvaluation = {
1048
1093
  signature,
1049
1094
  cancel: clock.after(debounceMs, () => run(keys))
1050
1095
  }));
1051
1096
  }
1052
- function setStatus(key, status) {
1053
- status ? statuses.set(key, status) : statuses.delete(key), dirty = !0, refresh();
1097
+ function setEvaluationStatus(key, status) {
1098
+ status ? evaluationStatuses.set(key, status) : evaluationStatuses.delete(key), snapshotIsOutdated = !0, rebuildSnapshotIfOutdated();
1054
1099
  }
1055
1100
  function run(keys) {
1056
1101
  let current = inputs;
1057
1102
  if (!current?.transport || !canRun(current)) return;
1058
1103
  let { transport, model, state } = current;
1059
- for (let entry of entries) {
1060
- let gatewayQuestion = entry.kind.gatewayQuestion;
1061
- if (keys && !keys.includes(entry.key) || !gatewayQuestion) continue;
1062
- requests.get(entry.key)?.controller.abort();
1104
+ for (let boundQuestion of boundQuestions) {
1105
+ let { key, kind, hash } = boundQuestion, gatewayQuestion = kind.gatewayQuestion;
1106
+ if (keys && !keys.includes(key) || !gatewayQuestion) continue;
1107
+ requestsInFlight.get(key)?.controller.abort();
1063
1108
  let controller = new AbortController();
1064
- requests.set(entry.key, {
1109
+ requestsInFlight.set(key, {
1065
1110
  controller,
1066
- hash: entry.hash
1067
- }), attempted.set(entry.key, entry.hash), setStatus(entry.key, {
1111
+ hash
1112
+ }), lastAttemptedHashes.set(key, hash), setEvaluationStatus(key, {
1068
1113
  state: "loading",
1069
- hash: entry.hash
1114
+ hash
1070
1115
  });
1071
- let tag = `${entry.kind.typeName}:${[...current.tagPath, entry.key].filter(Boolean).join(".")}`, evaluate = async () => {
1116
+ let fieldPath = [...current.tagPath, key].filter(Boolean).join("."), questionTag = `${kind.typeName}:${fieldPath}`, evaluateAndStore = async () => {
1072
1117
  try {
1073
1118
  let result = await evaluateQuestion({
1074
1119
  transport,
1075
1120
  model,
1076
1121
  state,
1077
1122
  question: gatewayQuestion,
1078
- tags: [...current.tags, tag],
1123
+ tags: [...current.tags, questionTag],
1079
1124
  signal: controller.signal
1080
1125
  });
1081
1126
  if (controller.signal.aborted) return;
1082
- inputs?.store(entry.key, {
1083
- ...entry.kind.toStored(result.answer),
1127
+ inputs?.store(key, {
1128
+ ...kind.toStoredValue(result.answer),
1084
1129
  evaluatedAt: clock.now().toISOString(),
1085
1130
  model: result.model,
1086
- sourceHash: entry.hash
1087
- }), requests.delete(entry.key), setStatus(entry.key, void 0);
1131
+ sourceHash: hash
1132
+ }), requestsInFlight.delete(key), setEvaluationStatus(key, void 0);
1088
1133
  } catch (error) {
1089
1134
  if (controller.signal.aborted) return;
1090
- requests.delete(entry.key), setStatus(entry.key, {
1135
+ requestsInFlight.delete(key), setEvaluationStatus(key, {
1091
1136
  state: "error",
1092
- hash: entry.hash,
1093
- error: error instanceof JevError ? {
1094
- message: error.message,
1095
- kind: error.kind,
1096
- status: error.status,
1097
- detail: error.detail
1098
- } : { message: error instanceof Error ? error.message : String(error) }
1137
+ hash,
1138
+ error: toQuestionError(error)
1099
1139
  });
1100
1140
  }
1101
1141
  };
1102
- evaluate();
1142
+ evaluateAndStore();
1103
1143
  }
1104
1144
  }
1105
1145
  return {
1106
1146
  update,
1107
1147
  localEdit() {
1108
- expectLocal = !0;
1148
+ nextStateIsFromLocalEdit = !0;
1109
1149
  },
1110
1150
  run,
1111
1151
  subscribe(listener) {
@@ -1115,29 +1155,35 @@ function createLifecycle(clock = realClock) {
1115
1155
  },
1116
1156
  getSnapshot: () => snapshot,
1117
1157
  dispose() {
1118
- scheduled?.cancel(), scheduled = void 0;
1119
- for (let [key, request] of requests) request.controller.abort(), statuses.delete(key), attempted.delete(key);
1120
- requests.clear(), dirty = !0;
1158
+ scheduledEvaluation?.cancel(), scheduledEvaluation = void 0;
1159
+ for (let [key, request] of requestsInFlight) request.controller.abort(), evaluationStatuses.delete(key), lastAttemptedHashes.delete(key);
1160
+ requestsInFlight.clear(), snapshotIsOutdated = !0;
1121
1161
  }
1122
1162
  };
1123
1163
  }
1124
1164
  const NO_ANSWERS = {};
1165
+ function getSetupStatus(hasTransport, keySource) {
1166
+ return hasTransport ? "ready" : keySource.from === "secrets" && keySource.loading ? "loading" : "missing";
1167
+ }
1125
1168
  /** Connects the Evaluation lifecycle of one attached field to the Studio's form. */
1126
1169
  function useQuestions({ questions, path, state, readOnly }) {
1127
- let { config, writers } = useJevForm(), parentPath = useMemo(() => path.slice(0, -1), [path]), keySource = useKeySource(config), apiKey = keySource.apiKey, transport = useMemo(() => config.transport ?? (apiKey ? gatewayTransport(apiKey, config.endpoint) : void 0), [
1170
+ let { config, answerWriters } = useJevForm(), parentPath = useMemo(() => path.slice(0, -1), [path]), keySource = useKeySource(config), { apiKey } = keySource, transport = useMemo(() => resolveTransport({
1171
+ transport: config.transport,
1172
+ endpoint: config.endpoint
1173
+ }, apiKey), [
1128
1174
  config.transport,
1129
- apiKey,
1130
- config.endpoint
1131
- ]), setup = transport ? "ready" : keySource.from === "secrets" && keySource.loading ? "loading" : "missing", parent = useFormValue(parentPath), documentType = useFormValue(["_type"]), store = useCallback((key, value) => {
1132
- let write = writers.get(pathKey([...parentPath, key]));
1133
- if (!write) throw Error(`There is no "${key}" field to store this answer in. Wrap your schema types with withJevAnswers() in sanity.config.`);
1134
- write(value);
1135
- }, [writers, parentPath]), tagPath = useMemo(() => [typeof documentType == "string" ? documentType : "", ...parentPath.map((segment) => typeof segment == "string" ? segment : "[]")], [documentType, parentPath]), [lifecycle] = useState(() => createLifecycle());
1175
+ config.endpoint,
1176
+ apiKey
1177
+ ]), parent = useFormValue(parentPath), documentType = useFormValue(["_type"]), store = useCallback((key, value) => {
1178
+ let writeAnswer = answerWriters.get(toPathKey([...parentPath, key]));
1179
+ if (!writeAnswer) throw Error(`There is no "${key}" field to store this answer in. Wrap your schema types with withJevAnswers() in sanity.config.`);
1180
+ writeAnswer(value);
1181
+ }, [answerWriters, parentPath]), tagPath = useMemo(() => [typeof documentType == "string" ? documentType : "", ...parentPath.map((segment) => typeof segment == "string" ? segment : "[]")], [documentType, parentPath]), [lifecycle] = useState(() => createLifecycle());
1136
1182
  return useLayoutEffect(() => {
1137
1183
  lifecycle.update({
1138
1184
  questions,
1139
1185
  state,
1140
- answers: isRecord$1(parent) ? parent : NO_ANSWERS,
1186
+ answers: isRecord(parent) ? parent : NO_ANSWERS,
1141
1187
  transport,
1142
1188
  store,
1143
1189
  model: config.model ?? "typesafe-ai/jev",
@@ -1148,17 +1194,17 @@ function useQuestions({ questions, path, state, readOnly }) {
1148
1194
  });
1149
1195
  }), useEffect(() => () => lifecycle.dispose(), [lifecycle]), {
1150
1196
  ...useSyncExternalStore(lifecycle.subscribe, lifecycle.getSnapshot),
1151
- setup,
1152
- /** The key is stored with Studio secrets, so editors can set and change it here. */
1153
- keyInSecrets: keySource.from === "secrets",
1154
- run: (key) => lifecycle.run([key]),
1155
- runAll: () => lifecycle.run(),
1197
+ setupStatus: getSetupStatus(!!transport, keySource),
1198
+ /** Editors can set and change the key here, since it is stored with Studio secrets. */
1199
+ isKeyStoredInSecrets: keySource.from === "secrets",
1200
+ evaluateNow: (key) => lifecycle.run([key]),
1201
+ evaluateAllNow: () => lifecycle.run(),
1156
1202
  localEdit: lifecycle.localEdit
1157
1203
  };
1158
1204
  }
1159
1205
  /** Renders a field with `options.jev` as usual, with its question strip and details below. */
1160
1206
  function AttachedInput({ questions, ...props }) {
1161
- let { t } = useTranslation("jev"), parentOnChange = props.onChange, { value } = props, state = toText(value), jev = useQuestions({
1207
+ let { t } = useTranslation("jev"), parentOnChange = props.onChange, state = flattenToText(props.value), jev = useQuestions({
1162
1208
  questions,
1163
1209
  path: props.path,
1164
1210
  state,
@@ -1168,60 +1214,77 @@ function AttachedInput({ questions, ...props }) {
1168
1214
  }, inputProps = {
1169
1215
  ...props,
1170
1216
  onChange
1171
- }, [selected, setSelected] = useState(null), [keyDialogOpen, setKeyDialogOpen] = useState(!1), selectedQuestion = jev.questions.find((question) => question.key === selected);
1217
+ };
1218
+ if ("elementProps" in inputProps && "onChange" in inputProps.elementProps) {
1219
+ let elementProps = inputProps.elementProps;
1220
+ inputProps.elementProps = {
1221
+ ...elementProps,
1222
+ onChange: (event) => {
1223
+ jev.localEdit(), elementProps.onChange(event);
1224
+ }
1225
+ };
1226
+ }
1227
+ let [selectedKey, setSelectedKey] = useState(null), [keyDialogOpen, setKeyDialogOpen] = useState(!1), selectedQuestion = jev.questions.find((question) => question.key === selectedKey);
1172
1228
  return <Stack gap={2}>
1173
1229
  <div>
1174
1230
  {props.renderDefault(inputProps)}
1175
- <QuestionStrip questions={jev.questions} selected={selected} onSelect={setSelected} setup={jev.setup} canRun={jev.canRun} loading={jev.loading} onRunAll={jev.runAll} onSetUp={() => setKeyDialogOpen(!0)} />
1231
+ <QuestionStrip questions={jev.questions} selectedKey={selectedKey} onSelect={setSelectedKey} setupStatus={jev.setupStatus} canRun={jev.canRun} loading={jev.loading} onEvaluateAll={jev.evaluateAllNow} onSetUp={() => setKeyDialogOpen(!0)} />
1176
1232
  </div>
1177
- {nearTokenLimit(state) && <Card padding={3} radius={2} tone="caution" border>
1233
+ {isNearTokenLimit(state) && <Card padding={3} radius={2} tone="caution" border>
1178
1234
  <Text size={1}>
1179
1235
  {t("field.too-long", {
1180
- tokens: Math.round(estimateTokens(state) / 1e3),
1236
+ tokens: Math.round(estimateTokenCount(state) / 1e3),
1181
1237
  limit: 32
1182
1238
  })}
1183
1239
  </Text>
1184
1240
  </Card>}
1185
- {selectedQuestion && <QuestionDetail question={selectedQuestion} empty={jev.empty} onRetry={() => jev.run(selectedQuestion.key)} onUpdateKey={jev.keyInSecrets ? () => setKeyDialogOpen(!0) : void 0} />}
1241
+ {selectedQuestion && <QuestionDetail question={selectedQuestion} empty={jev.empty} onRetry={() => jev.evaluateNow(selectedQuestion.key)} onUpdateKey={jev.isKeyStoredInSecrets ? () => setKeyDialogOpen(!0) : void 0} />}
1186
1242
  {keyDialogOpen && <JevKeyDialog onClose={() => setKeyDialogOpen(!1)} />}
1187
1243
  </Stack>;
1188
1244
  }
1189
- const Code = ({ children }) => <code>{children}</code>, CONNECTION_TEST = kindOf(noul({
1245
+ const Code = ({ children }) => <code>{children}</code>, CONNECTION_TEST_QUESTION = bindQuestionToKind(noul({
1190
1246
  instructions: "Is this text a connection test?",
1191
1247
  true: "It is a test",
1192
1248
  false: "It is not a test"
1193
- })).gatewayQuestion, asQuestionError = (error) => error instanceof JevError ? {
1194
- message: error.message,
1195
- kind: error.kind,
1196
- status: error.status,
1197
- detail: error.detail
1198
- } : { message: error instanceof Error ? error.message : String(error) };
1249
+ })).gatewayQuestion;
1199
1250
  /** Studio tool for setting, checking and removing the stored AI Gateway key. */
1200
1251
  function JevTool({ config }) {
1201
- let { t } = useTranslation("jev"), stored = useStoredKey(), saveKey = useSaveKey(), [settingsOpen, setSettingsOpen] = useState(!1), [confirmRemove, setConfirmRemove] = useState(!1), [test, setTest] = useState({ state: "idle" }), fromConfig = !!(config.transport || config.apiKey), apiKey = config.apiKey ?? stored.apiKey, transport = config.transport ?? (apiKey ? gatewayTransport(apiKey, config.endpoint) : void 0);
1252
+ let { t } = useTranslation("jev"), stored = useStoredKey(), saveKey = useSaveKey(), [keyDialogOpen, setKeyDialogOpen] = useState(!1), [isConfirmingRemove, setIsConfirmingRemove] = useState(!1), [connectionTest, setConnectionTest] = useState({ state: "idle" }), keyIsFromConfig = isKeyFromConfig(config), transport = resolveTransport(config, config.apiKey ?? stored.apiKey);
1202
1253
  async function testConnection() {
1203
- if (transport && CONNECTION_TEST) {
1204
- setTest({ state: "running" });
1254
+ if (transport && CONNECTION_TEST_QUESTION) {
1255
+ setConnectionTest({ state: "running" });
1205
1256
  try {
1206
1257
  let result = await evaluateQuestion({
1207
1258
  transport,
1208
1259
  model: config.model ?? "typesafe-ai/jev",
1209
1260
  state: "This is a connection test.",
1210
- question: CONNECTION_TEST,
1261
+ question: CONNECTION_TEST_QUESTION,
1211
1262
  tags: [...config.tags ?? DEFAULT_TAGS, "jev:connection-test"]
1212
1263
  });
1213
- setTest({
1264
+ setConnectionTest({
1214
1265
  state: "passed",
1215
1266
  model: result.model
1216
1267
  });
1217
1268
  } catch (error) {
1218
- setTest({
1269
+ setConnectionTest({
1219
1270
  state: "failed",
1220
- error: asQuestionError(error)
1271
+ error: toQuestionError(error)
1221
1272
  });
1222
1273
  }
1223
1274
  }
1224
1275
  }
1276
+ async function removeStoredKey() {
1277
+ setIsConfirmingRemove(!1);
1278
+ try {
1279
+ await saveKey(void 0), setConnectionTest({ state: "idle" });
1280
+ } catch (error) {
1281
+ let message = error instanceof Error ? error.message : String(error);
1282
+ setConnectionTest({
1283
+ state: "failed",
1284
+ error: { message: t("tool.key.remove-failed", { error: message }) }
1285
+ });
1286
+ }
1287
+ }
1225
1288
  return <Container width={1} padding={4}>
1226
1289
  <Stack gap={5}>
1227
1290
  <Stack gap={3}>
@@ -1239,10 +1302,10 @@ function JevTool({ config }) {
1239
1302
  {t("tool.key.heading")}
1240
1303
  </Heading>
1241
1304
  </Box>
1242
- <KeyStatus fromConfig={fromConfig} loading={stored.loading} stored={stored.apiKey} />
1305
+ <KeyStatusBadge keyIsFromConfig={keyIsFromConfig} loading={stored.loading} storedKey={stored.apiKey} />
1243
1306
  </Flex>
1244
1307
 
1245
- {fromConfig ? <Text size={1} muted>
1308
+ {keyIsFromConfig ? <Text size={1} muted>
1246
1309
  {config.transport ? t("tool.key.from-transport") : t("tool.key.from-config")}
1247
1310
  </Text> : stored.apiKey ? <Stack gap={3}>
1248
1311
  <Text size={1}>
@@ -1256,37 +1319,26 @@ function JevTool({ config }) {
1256
1319
  </Text>}
1257
1320
 
1258
1321
  <Flex gap={2} wrap="wrap">
1259
- {!fromConfig && <Button text={stored.apiKey ? t("tool.key.change") : t("tool.key.set")} tone="primary" disabled={stored.loading} onClick={() => setSettingsOpen(!0)} />}
1260
- <Button text={test.state === "running" ? t("tool.test.running") : t("tool.test.run")} mode="ghost" disabled={!transport || test.state === "running"} onClick={testConnection} />
1261
- {!fromConfig && stored.apiKey && !confirmRemove && <Button text={t("tool.key.remove")} mode="bleed" tone="critical" onClick={() => setConfirmRemove(!0)} />}
1322
+ {!keyIsFromConfig && <Button text={stored.apiKey ? t("tool.key.change") : t("tool.key.set")} tone="primary" disabled={stored.loading} onClick={() => setKeyDialogOpen(!0)} />}
1323
+ <Button text={connectionTest.state === "running" ? t("tool.test.running") : t("tool.test.run")} mode="ghost" disabled={!transport || connectionTest.state === "running"} onClick={testConnection} />
1324
+ {!keyIsFromConfig && stored.apiKey && !isConfirmingRemove && <Button text={t("tool.key.remove")} mode="bleed" tone="critical" onClick={() => setIsConfirmingRemove(!0)} />}
1262
1325
  </Flex>
1263
1326
 
1264
- {confirmRemove && <Card padding={3} radius={2} tone="critical">
1327
+ {isConfirmingRemove && <Card padding={3} radius={2} tone="critical">
1265
1328
  <Flex align="center" gap={2} wrap="wrap">
1266
1329
  <Box flex={1}>
1267
1330
  <Text size={1}>{t("tool.key.remove-confirm")}</Text>
1268
1331
  </Box>
1269
- <Button text={t("tool.key.remove-cancel")} mode="bleed" onClick={() => setConfirmRemove(!1)} />
1270
- <Button text={t("tool.key.remove-confirm-button")} tone="critical" onClick={async () => {
1271
- setConfirmRemove(!1);
1272
- try {
1273
- await saveKey(void 0), setTest({ state: "idle" });
1274
- } catch (error) {
1275
- let message = error instanceof Error ? error.message : String(error);
1276
- setTest({
1277
- state: "failed",
1278
- error: { message: t("tool.key.remove-failed", { error: message }) }
1279
- });
1280
- }
1281
- }} />
1332
+ <Button text={t("tool.key.remove-cancel")} mode="bleed" onClick={() => setIsConfirmingRemove(!1)} />
1333
+ <Button text={t("tool.key.remove-confirm-button")} tone="critical" onClick={removeStoredKey} />
1282
1334
  </Flex>
1283
1335
  </Card>}
1284
1336
 
1285
- {test.state === "passed" && <Card padding={3} radius={2} tone="positive">
1286
- <Text size={1}>{t("tool.test.passed", { model: test.model })}</Text>
1337
+ {connectionTest.state === "passed" && <Card padding={3} radius={2} tone="positive">
1338
+ <Text size={1}>{t("tool.test.passed", { model: connectionTest.model })}</Text>
1287
1339
  </Card>}
1288
- {test.state === "failed" && <Card padding={3} radius={2} tone="critical">
1289
- <Text size={1}>{errorText(t, test.error)}</Text>
1340
+ {connectionTest.state === "failed" && <Card padding={3} radius={2} tone="critical">
1341
+ <Text size={1}>{translateQuestionError(t, connectionTest.error)}</Text>
1290
1342
  </Card>}
1291
1343
  </Stack>
1292
1344
  </Card>
@@ -1301,14 +1353,14 @@ function JevTool({ config }) {
1301
1353
  </Stack>
1302
1354
  </Stack>
1303
1355
 
1304
- {settingsOpen && <JevKeyDialog onClose={() => {
1305
- setSettingsOpen(!1), setTest({ state: "idle" });
1356
+ {keyDialogOpen && <JevKeyDialog onClose={() => {
1357
+ setKeyDialogOpen(!1), setConnectionTest({ state: "idle" });
1306
1358
  }} />}
1307
1359
  </Container>;
1308
1360
  }
1309
- function KeyStatus({ fromConfig, loading, stored }) {
1361
+ function KeyStatusBadge({ keyIsFromConfig, loading, storedKey }) {
1310
1362
  let { t } = useTranslation("jev");
1311
- return fromConfig ? <Badge tone="primary">{t("tool.key.status.config")}</Badge> : loading ? <Badge>{t("tool.key.status.checking")}</Badge> : stored ? <Badge tone="positive">{t("tool.key.status.set")}</Badge> : <Badge tone="caution">{t("tool.key.status.not-set")}</Badge>;
1363
+ return keyIsFromConfig ? <Badge tone="primary">{t("tool.key.status.config")}</Badge> : loading ? <Badge>{t("tool.key.status.checking")}</Badge> : storedKey ? <Badge tone="positive">{t("tool.key.status.set")}</Badge> : <Badge tone="caution">{t("tool.key.status.not-set")}</Badge>;
1312
1364
  }
1313
1365
  /**
1314
1366
  * Jev questions for Sanity Studio: yes/no, score and choice questions attached to a field with
@@ -1317,18 +1369,18 @@ function KeyStatus({ fromConfig, loading, stored }) {
1317
1369
  * @public
1318
1370
  */
1319
1371
  const jev = definePlugin((config) => {
1320
- let pluginConfig = config ?? {};
1372
+ let pluginConfig = config ?? {}, jevTool = {
1373
+ name: "jev",
1374
+ title: "Jev",
1375
+ icon: SparklesIcon,
1376
+ component: () => <JevTool config={pluginConfig} />
1377
+ };
1321
1378
  return {
1322
1379
  name: "jev",
1323
- tools: pluginConfig.tool === !1 ? [] : [{
1324
- name: "jev",
1325
- title: "Jev",
1326
- icon: SparklesIcon,
1327
- component: () => <JevTool config={pluginConfig} />
1328
- }],
1380
+ tools: pluginConfig.tool === !1 ? [] : [jevTool],
1329
1381
  form: { components: { input: (props) => {
1330
1382
  if (props.path.length === 0) return <JevFormProvider config={pluginConfig}>{props.renderDefault(props)}</JevFormProvider>;
1331
- let questions = questionsOf(props.schemaType);
1383
+ let questions = getQuestionsFromDefinition(props.schemaType);
1332
1384
  return questions ? <AttachedInput {...props} questions={questions} /> : props.renderDefault(props);
1333
1385
  } } },
1334
1386
  schema: { types: KIND_SCHEMA_TYPES },