@vortexm/vjt 0.1.12 → 0.1.14

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
@@ -4220,6 +4220,20 @@ function stringifyReferenceValue(value) {
4220
4220
  }
4221
4221
  return JSON.stringify(value);
4222
4222
  }
4223
+ function resolveInlineI18nValue(host, value) {
4224
+ if (typeof value !== "string") {
4225
+ return value;
4226
+ }
4227
+ if (value.startsWith("i18n:") && !value.slice(5).includes(" ")) {
4228
+ return host.resolveI18nValue(value);
4229
+ }
4230
+ if (!value.includes("i18n:")) {
4231
+ return value;
4232
+ }
4233
+ return value.replaceAll(/i18n:([A-Za-z0-9_.-]+)/g, (_match, key) => {
4234
+ return host.resolveI18nValue(`i18n:${key}`);
4235
+ });
4236
+ }
4223
4237
  function isReferenceValueEmpty(value) {
4224
4238
  if (value === null || value === void 0) {
4225
4239
  return true;
@@ -4274,30 +4288,93 @@ var ReferenceRuntime = class {
4274
4288
  constructor(host) {
4275
4289
  this.host = host;
4276
4290
  }
4277
- isEmptyReference(reference, currentValue, responseValue, inputValue) {
4278
- return isReferenceValueEmpty(this.resolveReference(reference, currentValue, responseValue, inputValue));
4291
+ getListSelectionIndex(listId) {
4292
+ const listState = this.host.stateByKey.get(listId) ?? this.host.stateById.get(listId);
4293
+ return listState?.selected ?? -1;
4279
4294
  }
4280
- resolveReference(reference, currentValue, responseValue, inputValue = currentValue) {
4281
- if (reference === "current") {
4282
- return currentValue;
4295
+ resolveScopedRootReference(scope, path, currentValue, responseValue, inputValue) {
4296
+ if (scope === "current") {
4297
+ if (path.length === 0) {
4298
+ return currentValue;
4299
+ }
4300
+ return this.resolveCurrentReference(path.join("."), currentValue);
4283
4301
  }
4284
- if (reference === "input") {
4285
- return inputValue;
4302
+ if (scope === "input") {
4303
+ return path.length === 0 ? inputValue : readPath(inputValue, path);
4286
4304
  }
4287
- if (reference === "message") {
4288
- return responseValue;
4305
+ if (scope === "message" || scope === "response") {
4306
+ return path.length === 0 ? responseValue : readPath(responseValue, path);
4289
4307
  }
4290
- if (reference.startsWith("message.")) {
4291
- return readPath(responseValue, reference.slice(8).split("."));
4308
+ if (path.length === 0) {
4309
+ return this.host.getVarsSnapshot();
4292
4310
  }
4293
- if (reference.startsWith("response.")) {
4294
- return readPath(responseValue, reference.slice(9).split("."));
4311
+ return this.readVarReference(path.join("."));
4312
+ }
4313
+ readVarReference(path) {
4314
+ const [rootKey, ...rest] = path.split(".").filter(Boolean);
4315
+ if (!rootKey) {
4316
+ return void 0;
4295
4317
  }
4296
- if (reference.startsWith("current.")) {
4297
- return this.resolveCurrentReference(reference.slice(8), currentValue);
4318
+ const rootValue = this.host.getVarValue(rootKey);
4319
+ if (rest.length === 0) {
4320
+ return rootValue;
4321
+ }
4322
+ return readPath(rootValue, rest);
4323
+ }
4324
+ writeVarReference(path, value) {
4325
+ const [rootKey, ...rest] = path.split(".").filter(Boolean);
4326
+ if (!rootKey) {
4327
+ return;
4298
4328
  }
4299
- if (reference.startsWith("input.")) {
4300
- return readPath(inputValue, reference.slice(6).split("."));
4329
+ if (rest.length === 0) {
4330
+ this.host.setVarValue(rootKey, value);
4331
+ return;
4332
+ }
4333
+ const currentRoot = this.host.getVarValue(rootKey);
4334
+ const nextRoot = isPlainObject2(currentRoot) ? structuredClone(currentRoot) : {};
4335
+ if (writePath(nextRoot, rest, value)) {
4336
+ this.host.setVarValue(rootKey, nextRoot);
4337
+ }
4338
+ }
4339
+ isEmptyReference(reference, currentValue, responseValue, inputValue) {
4340
+ return isReferenceValueEmpty(this.resolveReference(reference, currentValue, responseValue, inputValue));
4341
+ }
4342
+ resolveReference(reference, currentValue, responseValue, inputValue = currentValue) {
4343
+ if (reference === "current" || reference.startsWith("current.")) {
4344
+ return this.resolveScopedRootReference(
4345
+ "current",
4346
+ reference === "current" ? [] : reference.slice(8).split(".").filter(Boolean),
4347
+ currentValue,
4348
+ responseValue,
4349
+ inputValue
4350
+ );
4351
+ }
4352
+ if (reference === "input" || reference.startsWith("input.")) {
4353
+ return this.resolveScopedRootReference(
4354
+ "input",
4355
+ reference === "input" ? [] : reference.slice(6).split(".").filter(Boolean),
4356
+ currentValue,
4357
+ responseValue,
4358
+ inputValue
4359
+ );
4360
+ }
4361
+ if (reference === "message" || reference.startsWith("message.")) {
4362
+ return this.resolveScopedRootReference(
4363
+ "message",
4364
+ reference === "message" ? [] : reference.slice(8).split(".").filter(Boolean),
4365
+ currentValue,
4366
+ responseValue,
4367
+ inputValue
4368
+ );
4369
+ }
4370
+ if (reference === "response" || reference.startsWith("response.")) {
4371
+ return this.resolveScopedRootReference(
4372
+ "response",
4373
+ reference === "response" ? [] : reference.slice(9).split(".").filter(Boolean),
4374
+ currentValue,
4375
+ responseValue,
4376
+ inputValue
4377
+ );
4301
4378
  }
4302
4379
  if (reference.startsWith("cookies.")) {
4303
4380
  return getCookieValue(reference.slice(8));
@@ -4308,8 +4385,14 @@ var ReferenceRuntime = class {
4308
4385
  if (reference.startsWith("url.")) {
4309
4386
  return getUrlParamValue(reference.slice(4));
4310
4387
  }
4311
- if (reference.startsWith("vars.")) {
4312
- return this.host.getVarValue(reference.slice(5));
4388
+ if (reference === "vars" || reference.startsWith("vars.")) {
4389
+ return this.resolveScopedRootReference(
4390
+ "vars",
4391
+ reference === "vars" ? [] : reference.slice(5).split(".").filter(Boolean),
4392
+ currentValue,
4393
+ responseValue,
4394
+ inputValue
4395
+ );
4313
4396
  }
4314
4397
  const [widgetId, ...rest] = reference.split(".");
4315
4398
  const state = this.host.stateById.get(widgetId);
@@ -4323,6 +4406,9 @@ var ReferenceRuntime = class {
4323
4406
  }
4324
4407
  resolveCurrentReference(reference, currentValue) {
4325
4408
  if (this.isListElementContext(currentValue)) {
4409
+ if (reference === "isSelected") {
4410
+ return currentValue.index === this.getListSelectionIndex(currentValue.listId);
4411
+ }
4326
4412
  const resolvedDescriptor = this.resolveListDescriptorNode(currentValue.descriptor, currentValue);
4327
4413
  const firstPart = reference.split(".")[0];
4328
4414
  const namedNode = this.findNamedWidget(resolvedDescriptor, firstPart, currentValue);
@@ -4330,6 +4416,9 @@ var ReferenceRuntime = class {
4330
4416
  const widgetKey = this.host.resolveItemNodeKey(namedNode, currentValue.listId, currentValue.index, `named.${firstPart}`);
4331
4417
  const state = this.host.ensureWidgetState(namedNode, widgetKey);
4332
4418
  const rest = reference.split(".").slice(1).join(".");
4419
+ if (rest === "isSelected") {
4420
+ return currentValue.index === this.getListSelectionIndex(currentValue.listId);
4421
+ }
4333
4422
  return rest ? this.readWidgetField(state, rest) : state;
4334
4423
  }
4335
4424
  const directDescriptorValue = readPath(currentValue.descriptor, reference.split("."));
@@ -4355,9 +4444,9 @@ var ReferenceRuntime = class {
4355
4444
  readWidgetField(state, field) {
4356
4445
  switch (field) {
4357
4446
  case "text":
4358
- return state.text ?? "";
4447
+ return resolveInlineI18nValue(this.host, state.text ?? "");
4359
4448
  case "placeholder":
4360
- return state.placeholder ?? "";
4449
+ return resolveInlineI18nValue(this.host, state.placeholder ?? "");
4361
4450
  case "checked":
4362
4451
  return state.checked ?? false;
4363
4452
  case "enabled":
@@ -4368,6 +4457,8 @@ var ReferenceRuntime = class {
4368
4457
  return state.condition ?? false;
4369
4458
  case "selected":
4370
4459
  return state.selected ?? 0;
4460
+ case "item":
4461
+ return state.selected ?? (state.widget === "list" || state.widget === "grid-view" ? -1 : 0);
4371
4462
  case "value":
4372
4463
  if (state.widget === "combobox") {
4373
4464
  const index = state.selected ?? 0;
@@ -4389,9 +4480,9 @@ var ReferenceRuntime = class {
4389
4480
  case "isHidden":
4390
4481
  return state.isHidden ?? true;
4391
4482
  case "url":
4392
- return state.url ?? "";
4483
+ return resolveInlineI18nValue(this.host, state.url ?? "");
4393
4484
  case "base64":
4394
- return state.base64 ?? "";
4485
+ return resolveInlineI18nValue(this.host, state.base64 ?? "");
4395
4486
  case "elements":
4396
4487
  if (state.widget === "list") {
4397
4488
  return (state.elements ?? []).map((descriptor, index) => ({
@@ -4448,7 +4539,7 @@ var ReferenceRuntime = class {
4448
4539
  return;
4449
4540
  }
4450
4541
  if (reference.startsWith("vars.")) {
4451
- this.host.setVarValue(reference.slice(5), inputValue);
4542
+ this.writeVarReference(reference.slice(5), inputValue);
4452
4543
  return;
4453
4544
  }
4454
4545
  const [widgetId, ...rest] = reference.split(".");
@@ -4473,6 +4564,9 @@ var ReferenceRuntime = class {
4473
4564
  case "selected":
4474
4565
  state.selected = toFiniteNumber(inputValue) ?? 0;
4475
4566
  break;
4567
+ case "item":
4568
+ state.selected = toFiniteNumber(inputValue) ?? 0;
4569
+ break;
4476
4570
  case "isHidden":
4477
4571
  state.isHidden = Boolean(inputValue);
4478
4572
  break;
@@ -4539,15 +4633,25 @@ var ReferenceRuntime = class {
4539
4633
  }
4540
4634
  resolveMappedValue(template, currentValue, responseValue, inputValue = currentValue) {
4541
4635
  if (typeof template === "string") {
4542
- if (template.startsWith("$ref:")) {
4543
- return this.resolveReference(template.slice(5), currentValue, responseValue, inputValue);
4636
+ if (template.startsWith("i18n:") && !template.slice(5).includes(" ")) {
4637
+ return this.host.resolveI18nValue(template);
4638
+ }
4639
+ let resolvedTemplate = template;
4640
+ if (resolvedTemplate.includes("i18n:")) {
4641
+ resolvedTemplate = resolvedTemplate.replaceAll(/i18n:([A-Za-z0-9_.-]+)/g, (_match, key) => {
4642
+ return this.host.resolveI18nValue(`i18n:${key}`);
4643
+ });
4644
+ }
4645
+ if (resolvedTemplate.startsWith("$ref:")) {
4646
+ return this.resolveReference(resolvedTemplate.slice(5), currentValue, responseValue, inputValue);
4544
4647
  }
4545
- if (template.includes("$ref:")) {
4546
- return template.replaceAll(/\$ref:([A-Za-z0-9_.]+)/g, (_match, ref) => {
4648
+ if (resolvedTemplate.includes("$ref:")) {
4649
+ return resolvedTemplate.replaceAll(/\$ref:([A-Za-z0-9_.]+)/g, (_match, ref) => {
4547
4650
  const resolved = this.resolveReference(ref, currentValue, responseValue, inputValue);
4548
4651
  return stringifyReferenceValue(resolved);
4549
4652
  });
4550
4653
  }
4654
+ return resolvedTemplate;
4551
4655
  }
4552
4656
  if (Array.isArray(template)) {
4553
4657
  return template.map((entry) => this.resolveMappedValue(entry, currentValue, responseValue, inputValue));
@@ -4683,7 +4787,10 @@ var ActionRuntime = class {
4683
4787
  scrollWidgetToBottom;
4684
4788
  scrollWidgetToElementByIndex;
4685
4789
  playAudio;
4790
+ playStaticAudio;
4686
4791
  stopPlaying;
4792
+ showInfoToast;
4793
+ showErrorToast;
4687
4794
  copyToClipboard;
4688
4795
  selectFile;
4689
4796
  confirmModal;
@@ -4725,7 +4832,10 @@ var ActionRuntime = class {
4725
4832
  this.scrollWidgetToBottom = options.scrollWidgetToBottom;
4726
4833
  this.scrollWidgetToElementByIndex = options.scrollWidgetToElementByIndex;
4727
4834
  this.playAudio = options.playAudio;
4835
+ this.playStaticAudio = options.playStaticAudio;
4728
4836
  this.stopPlaying = options.stopPlaying;
4837
+ this.showInfoToast = options.showInfoToast;
4838
+ this.showErrorToast = options.showErrorToast;
4729
4839
  this.copyToClipboard = options.copyToClipboard;
4730
4840
  this.selectFile = options.selectFile;
4731
4841
  this.confirmModal = options.confirmModal;
@@ -4846,14 +4956,37 @@ var ActionRuntime = class {
4846
4956
  await this.navigateTo(name.slice(9));
4847
4957
  return null;
4848
4958
  }
4959
+ if (name.startsWith("pause ")) {
4960
+ const durationMs = Math.max(0, Math.trunc(this.toNumber(name.slice(6))));
4961
+ await new Promise((resolve) => {
4962
+ if (typeof window === "undefined") {
4963
+ setTimeout(resolve, durationMs);
4964
+ return;
4965
+ }
4966
+ window.setTimeout(resolve, durationMs);
4967
+ });
4968
+ return inputValue;
4969
+ }
4849
4970
  if (name.startsWith("play ")) {
4850
4971
  await this.playAudio(this.resolveReference(name.slice(5), context.currentValue, context.responseValue, inputValue));
4851
4972
  return null;
4852
4973
  }
4974
+ if (name.startsWith("playStatic ")) {
4975
+ await this.playStaticAudio(name.slice(11).trim());
4976
+ return null;
4977
+ }
4853
4978
  if (name === "copyToClipboard") {
4854
4979
  await this.copyToClipboard(inputValue);
4855
4980
  return inputValue;
4856
4981
  }
4982
+ if (name === "infoToast") {
4983
+ this.showInfoToast(this.stringifyValue(inputValue));
4984
+ return inputValue;
4985
+ }
4986
+ if (name === "errorToast") {
4987
+ this.showErrorToast(this.stringifyValue(inputValue));
4988
+ return inputValue;
4989
+ }
4857
4990
  if (name === "selectFile") {
4858
4991
  return this.selectFile(action.args);
4859
4992
  }
@@ -4937,6 +5070,18 @@ var ActionRuntime = class {
4937
5070
  this.moveCursorToEnd(name.slice(16));
4938
5071
  return null;
4939
5072
  }
5073
+ if (name.startsWith("setItem ")) {
5074
+ const reference = name.slice(8).trim();
5075
+ this.assignReference(`${reference}.item`, Math.max(-1, Math.trunc(this.toNumber(inputValue))), context.currentValue);
5076
+ return inputValue;
5077
+ }
5078
+ if (name === "setItemCurrent") {
5079
+ const current = context.currentValue;
5080
+ if (isListElementLike(current)) {
5081
+ this.assignReference(`${current.listId}.item`, Math.max(0, Math.trunc(current.index)), current);
5082
+ }
5083
+ return inputValue;
5084
+ }
4940
5085
  if (name.startsWith("scrollToTop ")) {
4941
5086
  this.scrollWidgetToTop(name.slice(12));
4942
5087
  return inputValue;
@@ -5044,7 +5189,8 @@ var ActionRuntime = class {
5044
5189
  if (name.startsWith("append ")) {
5045
5190
  const reference = name.slice(7);
5046
5191
  const currentText = this.resolveReference(reference, context.currentValue, context.responseValue, inputValue);
5047
- const nextValue = `${this.stringifyValue(currentText)}${this.stringifyValue(action.args)}`;
5192
+ const appendValue = this.resolveMappedValue(action.args, context.currentValue, context.responseValue, inputValue);
5193
+ const nextValue = `${this.stringifyValue(currentText)}${this.stringifyValue(appendValue)}`;
5048
5194
  this.assignReference(reference, nextValue, context.currentValue);
5049
5195
  return nextValue;
5050
5196
  }
@@ -5471,7 +5617,7 @@ function updatePointerFromMouseEvent(event) {
5471
5617
  };
5472
5618
  }
5473
5619
  function getListElementContextFromElement(element, stateByKey) {
5474
- const listElement = element.closest(".vjt-list-element");
5620
+ const listElement = element.closest(".vjt-list-element, .vjt-grid-cell");
5475
5621
  if (!(listElement instanceof HTMLElement)) {
5476
5622
  return null;
5477
5623
  }
@@ -5709,6 +5855,10 @@ var DEFAULT_STYLE_MAP = {
5709
5855
  spoilerFrameDark: "border: 1px solid rgba(109,143,179,0.2); background: rgba(255,255,255,0.035); border-radius: 10px;",
5710
5856
  clickableRowLight: "padding: 4px 8px; border-radius: 10px; border: 1px solid rgba(111, 143, 177, 0.16); background: rgba(255,255,255,0.32); cursor: pointer;",
5711
5857
  clickableRowDark: "padding: 4px 8px; border-radius: 10px; border: 1px solid rgba(109,143,179,0.14); background: rgba(255,255,255,0.03); cursor: pointer;",
5858
+ selectableTileLight: "padding: 12px 14px; border-radius: 12px; border: 1px solid rgba(111, 143, 177, 0.18); background: rgba(255,255,255,0.34); cursor: pointer; transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;",
5859
+ selectableTileDark: "padding: 12px 14px; border-radius: 12px; border: 1px solid rgba(109,143,179,0.16); background: rgba(255,255,255,0.03); cursor: pointer; transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;",
5860
+ selectableTileSelectedLight: "padding: 12px 14px; border-radius: 12px; border: 1px solid rgba(87, 136, 191, 0.52); background: rgba(120, 169, 221, 0.18); box-shadow: 0 0 0 4px rgba(120, 169, 221, 0.12); cursor: pointer;",
5861
+ selectableTileSelectedDark: "padding: 12px 14px; border-radius: 12px; border: 1px solid rgba(138, 186, 238, 0.48); background: rgba(83, 124, 168, 0.26); box-shadow: 0 0 0 4px rgba(83, 124, 168, 0.18); cursor: pointer;",
5712
5862
  employeeLinkLight: "display:flex; align-items:center; width:100%; height:100%; color:#54749a; font-weight:600;",
5713
5863
  employeeLinkDark: "display:flex; align-items:center; width:100%; height:100%; color:#b8d0ea; font-weight:600;"
5714
5864
  };
@@ -5719,6 +5869,8 @@ var MAX_RECORDING_MS = 10 * 60 * 1e3;
5719
5869
  var MAX_LISTENING_SILENCE_MS = 60 * 60 * 1e3;
5720
5870
  var SILENCE_STOP_MS = 2e3;
5721
5871
  var DEFAULT_SPEECH_THRESHOLD = 0.035;
5872
+ var LISTENING_PRE_ROLL_MS = 300;
5873
+ var LISTENING_RECORDER_TIMESLICE_MS = 100;
5722
5874
  var RECORDING_MIME_CANDIDATES = [
5723
5875
  "audio/webm;codecs=opus",
5724
5876
  "audio/webm",
@@ -5799,6 +5951,11 @@ var VoiceRuntime = class {
5799
5951
  listenAnalyser = null;
5800
5952
  listenSource = null;
5801
5953
  listenFrameId = null;
5954
+ listeningRecorder = null;
5955
+ listeningRecorderMimeType = "audio/webm";
5956
+ listeningPreRollChunks = [];
5957
+ listeningCaptureChunks = [];
5958
+ listeningCaptureActive = false;
5802
5959
  lastSpeechAt = 0;
5803
5960
  listeningStartedAt = 0;
5804
5961
  recordingStartedFromListening = false;
@@ -5833,15 +5990,37 @@ var VoiceRuntime = class {
5833
5990
  copy.set(bytes);
5834
5991
  const blob = new Blob([copy.buffer], { type: payload.mimeType });
5835
5992
  const url = URL.createObjectURL(blob);
5836
- const audio = new Audio(url);
5993
+ await this.playResolvedSource(url, payload, true, {
5994
+ mimeType: payload.mimeType,
5995
+ bytes: bytes.byteLength
5996
+ });
5997
+ }
5998
+ async playStatic(url) {
5999
+ if (typeof url !== "string" || !url.trim()) {
6000
+ return;
6001
+ }
6002
+ const safeUrl = sanitizeUrl(url.trim(), { allowRelative: true });
6003
+ if (!safeUrl) {
6004
+ logRuntimeError("voice.playStatic", new Error("Blocked unsafe audio url"), { url });
6005
+ return;
6006
+ }
6007
+ await this.playResolvedSource(safeUrl, {
6008
+ audioData: safeUrl,
6009
+ mimeType: "audio/url"
6010
+ }, false, {
6011
+ url: safeUrl
6012
+ });
6013
+ }
6014
+ async playResolvedSource(sourceUrl, payload, revokeUrl, debugDetails) {
6015
+ const audio = new Audio(sourceUrl);
5837
6016
  audio.preload = "auto";
5838
- const player = { audio, url, payload };
6017
+ const player = { audio, url: sourceUrl, revokeUrl, payload };
5839
6018
  audio.onended = () => {
5840
6019
  void this.finishPlayback(player, "finished");
5841
6020
  };
5842
6021
  audio.onerror = () => {
5843
6022
  this.detachPlayer(player);
5844
- logRuntimeError("voice.play", new Error("Audio playback failed"), { mimeType: payload.mimeType });
6023
+ logRuntimeError("voice.play", new Error("Audio playback failed"), debugDetails);
5845
6024
  };
5846
6025
  this.activePlayers.push(player);
5847
6026
  while (this.activePlayers.length > MAX_CONCURRENT_PLAYERS) {
@@ -5853,8 +6032,7 @@ var VoiceRuntime = class {
5853
6032
  void this.finishPlayback(oldest, "stopped");
5854
6033
  }
5855
6034
  logRuntimeDebug(this.debugLogging, "voice-play", {
5856
- mimeType: payload.mimeType,
5857
- bytes: bytes.byteLength,
6035
+ ...debugDetails,
5858
6036
  activePlayers: this.activePlayers.length
5859
6037
  });
5860
6038
  await audio.play();
@@ -5876,6 +6054,10 @@ var VoiceRuntime = class {
5876
6054
  }
5877
6055
  }
5878
6056
  stopRecording() {
6057
+ if (this.listeningCaptureActive) {
6058
+ void this.stopListeningCapture();
6059
+ return;
6060
+ }
5879
6061
  if (!this.mediaRecorder || this.mediaRecorder.state === "inactive") {
5880
6062
  return;
5881
6063
  }
@@ -5894,6 +6076,7 @@ var VoiceRuntime = class {
5894
6076
  analyser.fftSize = 2048;
5895
6077
  const source = context.createMediaStreamSource(stream);
5896
6078
  source.connect(analyser);
6079
+ this.startListeningRecorder(stream);
5897
6080
  this.listening = true;
5898
6081
  this.listenContext = context;
5899
6082
  this.listenAnalyser = analyser;
@@ -5921,14 +6104,14 @@ var VoiceRuntime = class {
5921
6104
  this.speechEventTriggered = true;
5922
6105
  await this.triggerSystemEvent("onSpeechDetected", null);
5923
6106
  }
5924
- if (!this.mediaRecorder || this.mediaRecorder.state === "inactive") {
5925
- this.startRecordingInternal(stream, true);
6107
+ if (!this.listeningCaptureActive) {
6108
+ this.beginListeningCapture();
5926
6109
  }
5927
- } else if (this.recordingStartedFromListening && this.mediaRecorder && this.mediaRecorder.state !== "inactive" && this.lastSpeechAt > 0 && now - this.lastSpeechAt >= SILENCE_STOP_MS) {
5928
- this.stopRecording();
6110
+ } else if (this.listeningCaptureActive && this.lastSpeechAt > 0 && now - this.lastSpeechAt >= SILENCE_STOP_MS) {
6111
+ await this.stopListeningCapture();
5929
6112
  this.lastSpeechAt = 0;
5930
6113
  this.speechEventTriggered = false;
5931
- } else if (!this.recordingStartedFromListening && now - this.listeningStartedAt >= MAX_LISTENING_SILENCE_MS) {
6114
+ } else if (!this.listeningCaptureActive && now - this.listeningStartedAt >= MAX_LISTENING_SILENCE_MS) {
5932
6115
  await this.stopListening();
5933
6116
  return;
5934
6117
  }
@@ -5961,9 +6144,13 @@ var VoiceRuntime = class {
5961
6144
  this.listenContext = null;
5962
6145
  this.listenAnalyser = null;
5963
6146
  this.listenSource = null;
5964
- if (this.recordingStartedFromListening && this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
5965
- this.stopRecording();
6147
+ if (this.listeningCaptureActive) {
6148
+ await this.stopListeningCapture();
5966
6149
  }
6150
+ this.stopListeningRecorder();
6151
+ this.listeningPreRollChunks = [];
6152
+ this.listeningCaptureChunks = [];
6153
+ this.listeningCaptureActive = false;
5967
6154
  this.recordingStartedFromListening = false;
5968
6155
  this.releaseInputStreamIfIdle();
5969
6156
  }
@@ -6043,6 +6230,9 @@ var VoiceRuntime = class {
6043
6230
  }
6044
6231
  async handleRecordingError(error) {
6045
6232
  logRuntimeError("voice.recording", error);
6233
+ this.listeningCaptureActive = false;
6234
+ this.listeningCaptureChunks = [];
6235
+ this.recordingStartedFromListening = false;
6046
6236
  await this.triggerSystemEvent("onRecordingError", this.normalizeErrorMessage(error));
6047
6237
  }
6048
6238
  async handleListeningError(error) {
@@ -6087,6 +6277,91 @@ var VoiceRuntime = class {
6087
6277
  this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
6088
6278
  return this.mediaStream;
6089
6279
  }
6280
+ startListeningRecorder(stream) {
6281
+ if (this.listeningRecorder && this.listeningRecorder.state !== "inactive") {
6282
+ return;
6283
+ }
6284
+ if (typeof MediaRecorder === "undefined") {
6285
+ throw new Error("MediaRecorder is not supported in this browser");
6286
+ }
6287
+ const preferredMimeType = this.getPreferredRecordingMimeType();
6288
+ const options = preferredMimeType ? { mimeType: preferredMimeType } : void 0;
6289
+ const recorder = options ? new MediaRecorder(stream, options) : new MediaRecorder(stream);
6290
+ this.listeningRecorder = recorder;
6291
+ this.listeningRecorderMimeType = recorder.mimeType || preferredMimeType || "audio/webm";
6292
+ this.listeningPreRollChunks = [];
6293
+ this.listeningCaptureChunks = [];
6294
+ recorder.ondataavailable = (event) => {
6295
+ if (!event.data || event.data.size === 0) {
6296
+ return;
6297
+ }
6298
+ const timestamp = performance.now();
6299
+ if (this.listeningCaptureActive) {
6300
+ this.listeningCaptureChunks.push(event.data);
6301
+ return;
6302
+ }
6303
+ this.listeningPreRollChunks.push({ data: event.data, timestamp });
6304
+ const cutoff = timestamp - LISTENING_PRE_ROLL_MS;
6305
+ while (this.listeningPreRollChunks.length > 0 && this.listeningPreRollChunks[0].timestamp < cutoff) {
6306
+ this.listeningPreRollChunks.shift();
6307
+ }
6308
+ };
6309
+ recorder.onerror = (event) => {
6310
+ void this.handleRecordingError(event.error ?? new Error("Unknown listening recorder error"));
6311
+ };
6312
+ recorder.start(LISTENING_RECORDER_TIMESLICE_MS);
6313
+ }
6314
+ stopListeningRecorder() {
6315
+ if (!this.listeningRecorder) {
6316
+ return;
6317
+ }
6318
+ const recorder = this.listeningRecorder;
6319
+ this.listeningRecorder = null;
6320
+ recorder.ondataavailable = null;
6321
+ recorder.onerror = null;
6322
+ if (recorder.state !== "inactive") {
6323
+ recorder.stop();
6324
+ }
6325
+ }
6326
+ beginListeningCapture() {
6327
+ this.listeningCaptureActive = true;
6328
+ this.recordingStartedFromListening = true;
6329
+ this.listeningCaptureChunks = this.listeningPreRollChunks.map((chunk) => chunk.data);
6330
+ this.listeningPreRollChunks = [];
6331
+ logRuntimeDebug(this.debugLogging, "voice-recording-started", {
6332
+ mimeType: this.listeningRecorderMimeType,
6333
+ fromListening: true,
6334
+ preRollMs: LISTENING_PRE_ROLL_MS,
6335
+ preRollChunks: this.listeningCaptureChunks.length
6336
+ });
6337
+ void this.triggerSystemEvent("onRecordingStarted", null);
6338
+ }
6339
+ async stopListeningCapture() {
6340
+ if (!this.listeningCaptureActive) {
6341
+ return;
6342
+ }
6343
+ this.listeningCaptureActive = false;
6344
+ const chunks = [...this.listeningCaptureChunks];
6345
+ this.listeningCaptureChunks = [];
6346
+ try {
6347
+ const blob = new Blob(chunks, { type: this.listeningRecorderMimeType });
6348
+ const audioData = await blobToBase64(blob);
6349
+ logRuntimeDebug(this.debugLogging, "voice-recording-stopped", {
6350
+ mimeType: this.listeningRecorderMimeType,
6351
+ size: blob.size,
6352
+ fromListening: true,
6353
+ preRollMs: LISTENING_PRE_ROLL_MS
6354
+ });
6355
+ await this.triggerSystemEvent("onRecordingStopped", {
6356
+ audioData,
6357
+ mimeType: this.listeningRecorderMimeType
6358
+ });
6359
+ } catch (error) {
6360
+ await this.handleRecordingError(error);
6361
+ } finally {
6362
+ this.recordingStartedFromListening = false;
6363
+ }
6364
+ }
6090
6365
  clearRecordingTimeout() {
6091
6366
  if (this.recordingTimeoutId !== null) {
6092
6367
  window.clearTimeout(this.recordingTimeoutId);
@@ -6097,6 +6372,9 @@ var VoiceRuntime = class {
6097
6372
  if (this.listening) {
6098
6373
  return;
6099
6374
  }
6375
+ if (this.listeningRecorder && this.listeningRecorder.state !== "inactive") {
6376
+ return;
6377
+ }
6100
6378
  if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
6101
6379
  return;
6102
6380
  }
@@ -6124,7 +6402,9 @@ var VoiceRuntime = class {
6124
6402
  }
6125
6403
  player.audio.onended = null;
6126
6404
  player.audio.onerror = null;
6127
- URL.revokeObjectURL(player.url);
6405
+ if (player.revokeUrl) {
6406
+ URL.revokeObjectURL(player.url);
6407
+ }
6128
6408
  }
6129
6409
  async finishPlayback(player, reason) {
6130
6410
  if (!this.activePlayers.includes(player)) {
@@ -6138,7 +6418,9 @@ var VoiceRuntime = class {
6138
6418
  await this.triggerSystemEvent(reason === "finished" ? "onPlayFinished" : "onPlayingStopped", payload);
6139
6419
  }
6140
6420
  async handlePageDeactivation() {
6141
- const hadActiveRecording = Boolean(this.mediaRecorder && this.mediaRecorder.state !== "inactive");
6421
+ const hadActiveRecording = Boolean(
6422
+ this.mediaRecorder && this.mediaRecorder.state !== "inactive" || this.listeningCaptureActive
6423
+ );
6142
6424
  const hadActiveListening = this.listening;
6143
6425
  if (!hadActiveRecording && !hadActiveListening) {
6144
6426
  return;
@@ -6287,7 +6569,7 @@ function renderGridLayout(env, node, cellsVisible, path, itemContext) {
6287
6569
 
6288
6570
  // src/lib/widgets/conditional-container.ts
6289
6571
  var renderConditionalContainer = (env, node, state, key, path, itemContext) => {
6290
- const condition = typeof node.condition === "object" && node.condition !== null ? env.evaluateConditionExpression(node.condition, itemContext?.descriptor ?? null) : state.condition ?? env.normalizeBoolean(node.condition, true);
6572
+ const condition = typeof node.condition === "object" && node.condition !== null ? env.evaluateConditionExpression(node.condition, itemContext ?? null) : state.condition ?? env.normalizeBoolean(node.condition, true);
6291
6573
  const chosen = condition ? node.default : node.alternative;
6292
6574
  const content = chosen ? env.renderNode(chosen, `${path}.${condition ? "default" : "alternative"}`, itemContext) : "";
6293
6575
  const hasOwnBox = Boolean(
@@ -6520,10 +6802,12 @@ var renderButton = (env, node, state, key, _path, itemContext) => {
6520
6802
  const enabled = state.enabled ?? env.normalizeBoolean(node.enabled, true);
6521
6803
  const disabledAttribute = enabled ? "" : " disabled";
6522
6804
  const text = env.escapeHtml(env.resolveTextValue(state.text ?? node.text, itemContext));
6805
+ const hintValue = typeof node.hint === "string" ? env.resolveTextValue(node.hint, itemContext) : "";
6806
+ const hintAttribute = hintValue ? ` title="${env.escapeHtml(hintValue)}"` : "";
6523
6807
  const styleString = env.buildStyleString(node);
6524
6808
  const styleAttribute = styleString ? ` style="${env.escapeHtml(styleString)}"` : "";
6525
6809
  const buttonType = env.escapeHtml(node.type === "submit" ? "submit" : "button");
6526
- return `<button${common}${env.buildWidgetDataAttributes(key, node)} class="vjt-button" data-widget="button" type="${buttonType}"${styleAttribute}${disabledAttribute}>${text}</button>`;
6810
+ return `<button${common}${env.buildWidgetDataAttributes(key, node)} class="vjt-button" data-widget="button" type="${buttonType}"${styleAttribute}${hintAttribute}${disabledAttribute}>${text}</button>`;
6527
6811
  };
6528
6812
 
6529
6813
  // src/lib/widgets/checkbox.ts
@@ -7402,6 +7686,9 @@ var RuntimeRenderer = class {
7402
7686
  activeModalId = null;
7403
7687
  activeContextMenu = null;
7404
7688
  activeConfirmModal = null;
7689
+ activeToasts = [];
7690
+ toastCounter = 0;
7691
+ toastTimeoutIds = /* @__PURE__ */ new Map();
7405
7692
  lastPointer = { x: 24, y: 24 };
7406
7693
  pendingResetModalIds = /* @__PURE__ */ new Set();
7407
7694
  pendingScrollAction = null;
@@ -7475,9 +7762,11 @@ var RuntimeRenderer = class {
7475
7762
  }
7476
7763
  },
7477
7764
  getVarValue: (name) => this.vars.get(name),
7765
+ getVarsSnapshot: () => Object.fromEntries(this.vars.entries()),
7478
7766
  setVarValue: (name, value) => {
7479
7767
  this.vars.set(name, value);
7480
7768
  },
7769
+ resolveI18nValue: (value) => this.resolveI18nValue(value),
7481
7770
  ensureWidgetState: (node, key) => this.ensureWidgetState(node, key),
7482
7771
  resolveItemNodeKey: (node, listKey, index, fallbackPath) => this.resolveItemNodeKey(node, listKey, index, fallbackPath),
7483
7772
  indexListElementNodes: (listNode, element, index) => this.indexListElementNodes(listNode, element, index)
@@ -7519,7 +7808,10 @@ var RuntimeRenderer = class {
7519
7808
  scrollWidgetToBottom: (reference) => this.scrollWidgetToBottom(reference),
7520
7809
  scrollWidgetToElementByIndex: (reference, index) => this.scrollWidgetToElementByIndex(reference, index),
7521
7810
  playAudio: (value) => this.voiceRuntime.play(value),
7811
+ playStaticAudio: (url) => this.voiceRuntime.playStatic(url),
7522
7812
  stopPlaying: () => this.voiceRuntime.stopPlaying(),
7813
+ showInfoToast: (message) => this.showToast("info", message),
7814
+ showErrorToast: (message) => this.showToast("error", message),
7523
7815
  copyToClipboard: (value) => this.copyToClipboard(value),
7524
7816
  selectFile: (args) => this.selectFile(args),
7525
7817
  confirmModal: (args, inputValue) => this.confirmModal(args, inputValue),
@@ -7639,6 +7931,10 @@ var RuntimeRenderer = class {
7639
7931
  source.close();
7640
7932
  }
7641
7933
  this.eventSources.length = 0;
7934
+ for (const timeoutId of this.toastTimeoutIds.values()) {
7935
+ window.clearTimeout(timeoutId);
7936
+ }
7937
+ this.toastTimeoutIds.clear();
7642
7938
  this.voiceRuntime.dispose();
7643
7939
  };
7644
7940
  }
@@ -7839,6 +8135,21 @@ var RuntimeRenderer = class {
7839
8135
  this.activeConfirmModal = null;
7840
8136
  activeModal.resolve(confirmed);
7841
8137
  }
8138
+ showToast(kind, message) {
8139
+ const text = message.trim();
8140
+ if (!text) {
8141
+ return;
8142
+ }
8143
+ const toastId = ++this.toastCounter;
8144
+ this.activeToasts.push({ id: toastId, kind, text });
8145
+ void this.rerenderRoot();
8146
+ const timeoutId = window.setTimeout(() => {
8147
+ this.toastTimeoutIds.delete(toastId);
8148
+ this.activeToasts = this.activeToasts.filter((entry) => entry.id !== toastId);
8149
+ void this.rerenderRoot();
8150
+ }, 3600);
8151
+ this.toastTimeoutIds.set(toastId, timeoutId);
8152
+ }
7842
8153
  reindexStaticTree() {
7843
8154
  this.nodeByKey.clear();
7844
8155
  this.nodeById.clear();
@@ -8259,7 +8570,8 @@ var RuntimeRenderer = class {
8259
8570
  name: node.name,
8260
8571
  enabled: normalizeBoolean(node.enabled, true),
8261
8572
  definitionSignature: buildDefinitionSignature(node.elements ?? []),
8262
- elements: deepClone(node.elements ?? [])
8573
+ elements: deepClone(node.elements ?? []),
8574
+ selected: toFiniteNumber3(node.item) ?? toFiniteNumber3(node.selected) ?? -1
8263
8575
  };
8264
8576
  break;
8265
8577
  case "listbox":
@@ -8357,6 +8669,7 @@ var RuntimeRenderer = class {
8357
8669
  if (state.widget === "list" || state.widget === "grid-view") {
8358
8670
  this.clearListElementState(state.key);
8359
8671
  state.elements = deepClone(node.elements ?? []);
8672
+ state.selected = toFiniteNumber3(node.item) ?? toFiniteNumber3(node.selected) ?? state.selected ?? -1;
8360
8673
  state.definitionSignature = nextSignature;
8361
8674
  }
8362
8675
  }
@@ -8536,7 +8849,18 @@ var RuntimeRenderer = class {
8536
8849
  const modalMarkup = this.activeModalId ? renderModalOverlay(env, this.activeModalId) : "";
8537
8850
  const menuMarkup = this.activeContextMenu ? renderContextMenuOverlay(env, this.activeContextMenu) : "";
8538
8851
  const confirmMarkup = this.activeConfirmModal ? renderConfirmModalOverlay(env, this.activeConfirmModal) : "";
8539
- return `${modalMarkup}${menuMarkup}${confirmMarkup}`;
8852
+ const toastMarkup = this.renderToastOverlay();
8853
+ return `${modalMarkup}${menuMarkup}${confirmMarkup}${toastMarkup}`;
8854
+ }
8855
+ renderToastOverlay() {
8856
+ if (this.activeToasts.length === 0) {
8857
+ return "";
8858
+ }
8859
+ const items = this.activeToasts.map((toast) => {
8860
+ const kindClass = toast.kind === "error" ? " vjt-toast--error" : " vjt-toast--info";
8861
+ return `<div class="vjt-toast${kindClass}" role="status" aria-live="polite">${escapeHtml(toast.text)}</div>`;
8862
+ }).join("");
8863
+ return `<div class="vjt-toast-stack">${items}</div>`;
8540
8864
  }
8541
8865
  updatePointerFromEvent(event) {
8542
8866
  this.lastPointer = updatePointerFromMouseEvent(event);
@@ -8646,6 +8970,7 @@ var RuntimeRenderer = class {
8646
8970
  case "grid-view":
8647
8971
  this.clearListElementState(state.key);
8648
8972
  state.elements = [];
8973
+ state.selected = -1;
8649
8974
  break;
8650
8975
  case "listbox":
8651
8976
  state.listboxElements = [];
@@ -38,7 +38,10 @@ type ActionRuntimeOptions = {
38
38
  scrollWidgetToBottom: (reference: string) => void;
39
39
  scrollWidgetToElementByIndex: (reference: string, index: unknown) => void;
40
40
  playAudio: (value: unknown) => Promise<void>;
41
+ playStaticAudio: (url: string) => Promise<void>;
41
42
  stopPlaying: () => Promise<void>;
43
+ showInfoToast: (message: string) => void;
44
+ showErrorToast: (message: string) => void;
42
45
  copyToClipboard: (value: unknown) => Promise<void>;
43
46
  selectFile: (options: unknown) => Promise<unknown>;
44
47
  confirmModal: (options: unknown, inputValue: unknown) => Promise<boolean>;
@@ -93,7 +96,10 @@ export declare class ActionRuntime {
93
96
  private readonly scrollWidgetToBottom;
94
97
  private readonly scrollWidgetToElementByIndex;
95
98
  private readonly playAudio;
99
+ private readonly playStaticAudio;
96
100
  private readonly stopPlaying;
101
+ private readonly showInfoToast;
102
+ private readonly showErrorToast;
97
103
  private readonly copyToClipboard;
98
104
  private readonly selectFile;
99
105
  private readonly confirmModal;
@@ -8,7 +8,9 @@ type ReferenceRuntimeHost = {
8
8
  getAppValue: (name: string) => unknown;
9
9
  setAppValue: (name: string, value: unknown) => void;
10
10
  getVarValue: (name: string) => unknown;
11
+ getVarsSnapshot: () => Record<string, unknown>;
11
12
  setVarValue: (name: string, value: unknown) => void;
13
+ resolveI18nValue: (value: string | undefined) => string;
12
14
  ensureWidgetState: (node: DescriptionNode, key: string) => WidgetState;
13
15
  resolveItemNodeKey: (node: DescriptionNode, listKey: string, index: number, fallbackPath: string) => string;
14
16
  indexListElementNodes: (listNode: ListNode | GridViewNode, element: DescriptionNode, index: number) => void;
@@ -16,6 +18,10 @@ type ReferenceRuntimeHost = {
16
18
  export declare class ReferenceRuntime {
17
19
  private readonly host;
18
20
  constructor(host: ReferenceRuntimeHost);
21
+ private getListSelectionIndex;
22
+ private resolveScopedRootReference;
23
+ private readVarReference;
24
+ private writeVarReference;
19
25
  isEmptyReference(reference: string, currentValue: unknown, responseValue: unknown, inputValue?: unknown): boolean;
20
26
  resolveReference(reference: string, currentValue: unknown, responseValue: unknown, inputValue?: unknown): unknown;
21
27
  resolveCurrentReference(reference: string, currentValue: unknown): unknown;
@@ -16,6 +16,11 @@ export declare class VoiceRuntime {
16
16
  private listenAnalyser;
17
17
  private listenSource;
18
18
  private listenFrameId;
19
+ private listeningRecorder;
20
+ private listeningRecorderMimeType;
21
+ private listeningPreRollChunks;
22
+ private listeningCaptureChunks;
23
+ private listeningCaptureActive;
19
24
  private lastSpeechAt;
20
25
  private listeningStartedAt;
21
26
  private recordingStartedFromListening;
@@ -24,6 +29,8 @@ export declare class VoiceRuntime {
24
29
  private readonly pageHideHandler;
25
30
  constructor(options: VoiceRuntimeOptions);
26
31
  play(value: unknown): Promise<void>;
32
+ playStatic(url: string): Promise<void>;
33
+ private playResolvedSource;
27
34
  stopPlaying(): Promise<void>;
28
35
  startRecording(): Promise<void>;
29
36
  stopRecording(): void;
@@ -37,6 +44,10 @@ export declare class VoiceRuntime {
37
44
  private normalizePlayablePayload;
38
45
  private getPreferredRecordingMimeType;
39
46
  private ensureInputStream;
47
+ private startListeningRecorder;
48
+ private stopListeningRecorder;
49
+ private beginListeningCapture;
50
+ private stopListeningCapture;
40
51
  private clearRecordingTimeout;
41
52
  private releaseInputStreamIfIdle;
42
53
  private stopStreamTracks;
@@ -3,6 +3,7 @@ import type { WidgetRenderer } from './context.js';
3
3
  export type ButtonNode = BaseNode & {
4
4
  widget: 'button';
5
5
  text?: string;
6
+ hint?: string;
6
7
  enabled?: boolean | string;
7
8
  type?: 'button' | 'submit';
8
9
  color?: 'bright' | 'regular';
@@ -4,6 +4,7 @@ export type GridViewNode = BaseNode & {
4
4
  widget: 'grid-view';
5
5
  rowSize?: number | string;
6
6
  size?: number;
7
+ item?: number | string;
7
8
  elements?: DescriptionNode[];
8
9
  };
9
10
  export declare const renderGridView: WidgetRenderer<GridViewNode>;
@@ -4,6 +4,7 @@ export type ListNode = BaseNode & {
4
4
  widget: 'list';
5
5
  orientation?: 'vertical' | 'horizontal';
6
6
  size?: number;
7
+ item?: number | string;
7
8
  elements?: DescriptionNode[];
8
9
  };
9
10
  export declare const renderList: WidgetRenderer<ListNode>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vortexm/vjt",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/vjt-styles.css CHANGED
@@ -862,3 +862,61 @@ body {
862
862
  .vjt-confirm-actions .vjt-button {
863
863
  min-width: 120px;
864
864
  }
865
+
866
+ .vjt-toast-stack {
867
+ position: fixed;
868
+ left: 50%;
869
+ bottom: 56px;
870
+ transform: translateX(-50%);
871
+ z-index: 1200;
872
+ display: flex;
873
+ flex-direction: column;
874
+ align-items: center;
875
+ gap: 12px;
876
+ pointer-events: none;
877
+ }
878
+
879
+ .vjt-toast {
880
+ min-width: 320px;
881
+ max-width: min(680px, calc(100vw - 28px));
882
+ padding: 16px 20px;
883
+ border-radius: 18px;
884
+ border: 1px solid var(--vjt-border);
885
+ background: var(--vjt-surface);
886
+ color: var(--vjt-text);
887
+ box-shadow: var(--vjt-shadow);
888
+ line-height: 1.5;
889
+ white-space: pre-wrap;
890
+ font-size: 17px;
891
+ pointer-events: auto;
892
+ user-select: text;
893
+ -webkit-user-select: text;
894
+ }
895
+
896
+ .vjt-toast--info {
897
+ background: color-mix(in srgb, var(--vjt-surface) 88%, var(--vjt-accent) 12%);
898
+ }
899
+
900
+ .vjt-toast--error {
901
+ border-color: color-mix(in srgb, #d84f4f 55%, var(--vjt-border));
902
+ background: color-mix(in srgb, var(--vjt-surface) 80%, #a01f1f 20%);
903
+ }
904
+
905
+ @media (max-width: 720px) {
906
+ .vjt-toast-stack {
907
+ left: 16px;
908
+ right: 16px;
909
+ bottom: 32px;
910
+ transform: none;
911
+ align-items: stretch;
912
+ }
913
+
914
+ .vjt-toast {
915
+ min-width: 0;
916
+ max-width: none;
917
+ width: 100%;
918
+ padding: 18px 20px;
919
+ border-radius: 20px;
920
+ font-size: 18px;
921
+ }
922
+ }