@fiodos/web-core 0.1.2 → 0.1.4

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.
@@ -0,0 +1,2 @@
1
+ /** User-visible chat error — shown IN the text bubble, not only in the console. */
2
+ export declare function formatAgentTurnError(err: unknown, locale: string): string;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatAgentTurnError = formatAgentTurnError;
4
+ const errors_1 = require("./errors");
5
+ /** User-visible chat error — shown IN the text bubble, not only in the console. */
6
+ function formatAgentTurnError(err, locale) {
7
+ const es = locale.startsWith('es');
8
+ if (err instanceof errors_1.AgentApiError) {
9
+ switch (err.code) {
10
+ case 'timeout':
11
+ return es
12
+ ? 'La respuesta tardó demasiado. Comprueba tu conexión e inténtalo de nuevo.'
13
+ : 'The response took too long. Check your connection and try again.';
14
+ case 'network':
15
+ return es
16
+ ? 'No pude conectar con el servidor de Fiodos. ¿Está el backend en marcha y la URL correcta?'
17
+ : 'Could not reach the Fiodos server. Is the backend running and the URL correct?';
18
+ case 'unauthorized':
19
+ return es
20
+ ? 'La API key fue rechazada. Comprueba que coincide con la del proyecto en el dashboard.'
21
+ : 'The API key was rejected. Check it matches the project key in your dashboard.';
22
+ case 'quota_exceeded':
23
+ return es
24
+ ? 'Has alcanzado el límite de uso de IA de tu plan. Mejóralo en el dashboard para seguir.'
25
+ : 'You reached your plan\u2019s AI usage limit. Upgrade in the dashboard to continue.';
26
+ case 'rate_limited':
27
+ return es
28
+ ? 'Demasiadas peticiones seguidas. Espera un momento e inténtalo de nuevo.'
29
+ : 'Too many requests. Wait a moment and try again.';
30
+ case 'server_error':
31
+ return es
32
+ ? 'El servidor no pudo responder. Revisa que la IA esté configurada en el backend.'
33
+ : 'The server could not respond. Check that the AI is configured on the backend.';
34
+ case 'cancelled':
35
+ return es ? 'Cancelado.' : 'Cancelled.';
36
+ default:
37
+ break;
38
+ }
39
+ if (err.message && err.message !== err.code)
40
+ return err.message;
41
+ }
42
+ if (err instanceof Error && err.message)
43
+ return err.message;
44
+ return es ? 'No pude responder. Inténtalo de nuevo.' : 'I could not reply. Please try again.';
45
+ }
@@ -58,6 +58,8 @@ export declare class AgentController {
58
58
  private enabled;
59
59
  private phase;
60
60
  private lastExchange;
61
+ /** User text for the turn currently in flight (for error/timeout display). */
62
+ private pendingTurnText;
61
63
  private confirmationMessage;
62
64
  private confirmationVoiceHint;
63
65
  private consentModalVisible;
@@ -20,6 +20,7 @@ exports.AgentController = void 0;
20
20
  */
21
21
  const core_1 = require("@fiodos/core");
22
22
  const errors_1 = require("../api/errors");
23
+ const formatTurnError_1 = require("../api/formatTurnError");
23
24
  const backendClient_1 = require("../api/backendClient");
24
25
  const screenContextStore_1 = require("../context/screenContextStore");
25
26
  const speechSession_1 = require("../speech/speechSession");
@@ -34,6 +35,8 @@ class AgentController {
34
35
  this.enabled = true;
35
36
  this.phase = 'idle';
36
37
  this.lastExchange = null;
38
+ /** User text for the turn currently in flight (for error/timeout display). */
39
+ this.pendingTurnText = null;
37
40
  this.confirmationMessage = null;
38
41
  this.confirmationVoiceHint = null;
39
42
  this.consentModalVisible = false;
@@ -323,11 +326,21 @@ class AgentController {
323
326
  return;
324
327
  }
325
328
  this.setPhase('thinking');
329
+ this.pendingTurnText = text;
330
+ this.lastExchange = { userText: text, replyText: '' };
331
+ this.emit();
326
332
  this.clearWatchdog();
327
333
  this.watchdog = setTimeout(() => {
328
334
  if (this.phase === 'thinking') {
329
335
  this.telemetry.logEvent({ eventType: 'agent_watchdog_reset', sessionId: this.sessionId });
336
+ const userText = this.pendingTurnText ?? text;
337
+ const replyText = this.config.locale.startsWith('es')
338
+ ? 'La respuesta tardó demasiado. Inténtalo de nuevo.'
339
+ : 'The response took too long. Please try again.';
340
+ this.lastExchange = { userText, replyText };
341
+ this.pendingTurnText = null;
330
342
  this.setPhase('idle');
343
+ this.emit();
331
344
  }
332
345
  }, this.timings.thinkingWatchdogMs);
333
346
  const currentRoute = this.config.navigation.getCurrentRoute();
@@ -435,23 +448,36 @@ class AgentController {
435
448
  if (e instanceof errors_1.AgentApiError && e.code === 'cancelled') {
436
449
  if (this.phase === 'thinking')
437
450
  this.setPhase('idle');
451
+ this.pendingTurnText = null;
438
452
  return;
439
453
  }
440
454
  if (e instanceof errors_1.AgentApiError && e.code === 'quota_exceeded') {
441
455
  this.config.onQuotaExceeded?.();
442
456
  }
457
+ const errMsg = (0, formatTurnError_1.formatAgentTurnError)(e, this.config.locale);
458
+ this.lastExchange = { userText: text, replyText: errMsg };
459
+ this.pendingTurnText = null;
443
460
  if (this.phase === 'thinking')
444
461
  this.setPhase('idle');
445
- this.notifyError(reply || (e instanceof Error ? e.message : 'error'));
462
+ this.emit();
463
+ this.notifyError(errMsg);
446
464
  return;
447
465
  }
448
466
  this.clearWatchdog();
449
467
  this.abort = null;
468
+ this.pendingTurnText = null;
450
469
  if (reply) {
451
470
  this.lastExchange = { userText: text, replyText: reply };
452
471
  this.emit();
453
472
  (0, core_1.recordConversationExchange)(this.conversation, text, reply);
454
473
  }
474
+ else {
475
+ const fallback = this.config.locale.startsWith('es')
476
+ ? 'No recibí respuesta del servidor. Inténtalo de nuevo.'
477
+ : 'No reply from the server. Please try again.';
478
+ this.lastExchange = { userText: text, replyText: fallback };
479
+ this.emit();
480
+ }
455
481
  const hasPending = this.pending != null;
456
482
  await this.speakReply(reply, audioBase64);
457
483
  if (hasPending) {
@@ -65,6 +65,18 @@ function ensureStyles() {
65
65
  el.textContent = CSS;
66
66
  document.head.appendChild(el);
67
67
  }
68
+ // Content box of the layout viewport. `documentElement.clientWidth/Height`
69
+ // EXCLUDE a classic scrollbar that occupies real width/height (Windows, some
70
+ // Linux/Chromium); overlay scrollbars (macOS, mobile) report the same value as
71
+ // the layout viewport. Anchoring against this — not `innerWidth` — keeps a
72
+ // right/bottom-edge orb just INSIDE the scrollbar instead of under/over it.
73
+ function viewportContentSize() {
74
+ const de = typeof document !== 'undefined' ? document.documentElement : null;
75
+ return {
76
+ width: de?.clientWidth || window.innerWidth,
77
+ height: de?.clientHeight || window.innerHeight,
78
+ };
79
+ }
68
80
  function cornerToPosition(corner) {
69
81
  switch (corner) {
70
82
  case 'bottom-left':
@@ -161,14 +173,15 @@ function mountOrb(controller, options = {}) {
161
173
  }
162
174
  function reposition() {
163
175
  const size = orbDiameterPx();
164
- const insets = (0, core_1.resolveOrbLayerInsets)({
176
+ const content = viewportContentSize();
177
+ const { left, top } = (0, core_1.resolveOrbAnchorPx)({
165
178
  viewportWidth: window.innerWidth,
166
179
  viewportHeight: window.innerHeight,
180
+ contentWidth: content.width,
181
+ contentHeight: content.height,
167
182
  orbDiameter: size,
168
183
  position: effectivePosition(),
169
184
  });
170
- const left = Math.max(8, window.innerWidth - insets.right - size);
171
- const top = Math.max(8, window.innerHeight - insets.bottom - size);
172
185
  root.style.left = `${left}px`;
173
186
  root.style.top = `${top}px`;
174
187
  root.style.right = '';
@@ -197,6 +210,14 @@ function mountOrb(controller, options = {}) {
197
210
  }
198
211
  }
199
212
  window.addEventListener('resize', reposition);
213
+ // Recalc when a scrollbar appears/disappears as the page content changes (the
214
+ // content box shrinks/grows even when the window size doesn't), so the orb
215
+ // never ends up overlapped by — or detached from — the scrollbar edge.
216
+ let contentObserver;
217
+ if (typeof ResizeObserver !== 'undefined' && document.documentElement) {
218
+ contentObserver = new ResizeObserver(() => reposition());
219
+ contentObserver.observe(document.documentElement);
220
+ }
200
221
  // Keep the conversation bar above the keyboard as the visual viewport resizes
201
222
  // (keyboard open/close) or scrolls on mobile browsers.
202
223
  const visualViewport = window.visualViewport;
@@ -240,9 +261,10 @@ function mountOrb(controller, options = {}) {
240
261
  dragging = true;
241
262
  e.preventDefault();
242
263
  const size = orbDiameterPx();
264
+ const content = viewportContentSize();
243
265
  const margin = 8;
244
- const maxLeft = window.innerWidth - margin - size;
245
- const maxTop = window.innerHeight - margin - size;
266
+ const maxLeft = content.width - margin - size;
267
+ const maxTop = content.height - margin - size;
246
268
  const left = Math.min(maxLeft, Math.max(margin, originLeft + dx));
247
269
  const top = Math.min(maxTop, Math.max(margin, originTop + dy));
248
270
  root.style.left = `${left}px`;
@@ -252,8 +274,8 @@ function mountOrb(controller, options = {}) {
252
274
  draggedPosition = (0, core_1.viewportPixelsToOrbScreenPosition)({
253
275
  centerX: left + size / 2,
254
276
  centerY: top + size / 2,
255
- viewportWidth: window.innerWidth,
256
- viewportHeight: window.innerHeight,
277
+ viewportWidth: content.width,
278
+ viewportHeight: content.height,
257
279
  orbDiameter: size,
258
280
  });
259
281
  applyDeployment();
@@ -661,6 +683,10 @@ function mountOrb(controller, options = {}) {
661
683
  const stopWatch = (0, publishedConfig_1.watchPublishedConfig)(controller, {
662
684
  pollMs: options.appearancePollMs,
663
685
  onChange: (config) => {
686
+ // Dashboard master switch: hide the whole orb (button + chip + bubble live
687
+ // inside `root`) when explicitly turned off, show it otherwise. No host
688
+ // code change — it flips on the next poll and reappears when re-enabled.
689
+ root.style.display = config && config.enabled === false ? 'none' : '';
664
690
  if (config) {
665
691
  publishedAppearance = config.appearance;
666
692
  chipTheme = config.keyboardChip;
@@ -684,6 +710,7 @@ function mountOrb(controller, options = {}) {
684
710
  unsubscribe();
685
711
  stopWatch();
686
712
  window.removeEventListener('resize', reposition);
713
+ contentObserver?.disconnect();
687
714
  visualViewport?.removeEventListener('resize', onViewportChange);
688
715
  visualViewport?.removeEventListener('scroll', onViewportChange);
689
716
  clearOverlay();
@@ -17,6 +17,12 @@ export interface ResolvedOrbConfig {
17
17
  keyboardChip: ChipTheme | null;
18
18
  modalTheme: PublishedModalTheme | undefined;
19
19
  screenPosition: OrbScreenPosition | undefined;
20
+ /**
21
+ * Dashboard master visibility switch (`orbEnabled`). False ONLY when the
22
+ * developer explicitly turned the orb off. The orb is hidden when false and
23
+ * shown otherwise (including when nothing is published / on error).
24
+ */
25
+ enabled: boolean;
20
26
  }
21
27
  export interface WatchPublishedConfigOptions {
22
28
  /** Poll interval (ms) to pick up dashboard changes in real time. Default 12000. */
@@ -14,7 +14,7 @@ exports.watchPublishedConfig = watchPublishedConfig;
14
14
  */
15
15
  const core_1 = require("@fiodos/core");
16
16
  const orbView_1 = require("./orbView");
17
- function toResolved(props) {
17
+ function toResolved(props, enabled) {
18
18
  const theme = props.theme;
19
19
  const appearance = {
20
20
  accentColor: theme.accentColor ?? orbView_1.DEFAULT_ORB_APPEARANCE.accentColor,
@@ -33,6 +33,7 @@ function toResolved(props) {
33
33
  keyboardChip: theme.keyboardChip ?? null,
34
34
  modalTheme: props.modalTheme,
35
35
  screenPosition: props.screenPosition,
36
+ enabled,
36
37
  };
37
38
  }
38
39
  /**
@@ -52,7 +53,9 @@ function watchPublishedConfig(controller, options) {
52
53
  const res = await fetcher.call(controller.config.backend);
53
54
  if (cancelled)
54
55
  return;
55
- options.onChange(res.published ? toResolved((0, core_1.mapPublishedConfigToOrbProps)(res.published)) : null);
56
+ options.onChange(res.published
57
+ ? toResolved((0, core_1.mapPublishedConfigToOrbProps)(res.published), res.published.orbEnabled !== false)
58
+ : null);
56
59
  }
57
60
  catch {
58
61
  if (!cancelled)
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export declare const SDK_VERSION = "0.1.2";
8
+ export declare const SDK_VERSION = "0.1.3";
9
9
  export declare const SDK_PACKAGE = "@fiodos/web-core";
@@ -8,5 +8,5 @@ exports.SDK_PACKAGE = exports.SDK_VERSION = void 0;
8
8
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
9
9
  * by hand — change package.json `version` and re-run the sync/release script.
10
10
  */
11
- exports.SDK_VERSION = '0.1.2';
11
+ exports.SDK_VERSION = '0.1.3';
12
12
  exports.SDK_PACKAGE = '@fiodos/web-core';
@@ -0,0 +1,2 @@
1
+ /** User-visible chat error — shown IN the text bubble, not only in the console. */
2
+ export declare function formatAgentTurnError(err: unknown, locale: string): string;
@@ -0,0 +1,42 @@
1
+ import { AgentApiError } from './errors.js';
2
+ /** User-visible chat error — shown IN the text bubble, not only in the console. */
3
+ export function formatAgentTurnError(err, locale) {
4
+ const es = locale.startsWith('es');
5
+ if (err instanceof AgentApiError) {
6
+ switch (err.code) {
7
+ case 'timeout':
8
+ return es
9
+ ? 'La respuesta tardó demasiado. Comprueba tu conexión e inténtalo de nuevo.'
10
+ : 'The response took too long. Check your connection and try again.';
11
+ case 'network':
12
+ return es
13
+ ? 'No pude conectar con el servidor de Fiodos. ¿Está el backend en marcha y la URL correcta?'
14
+ : 'Could not reach the Fiodos server. Is the backend running and the URL correct?';
15
+ case 'unauthorized':
16
+ return es
17
+ ? 'La API key fue rechazada. Comprueba que coincide con la del proyecto en el dashboard.'
18
+ : 'The API key was rejected. Check it matches the project key in your dashboard.';
19
+ case 'quota_exceeded':
20
+ return es
21
+ ? 'Has alcanzado el límite de uso de IA de tu plan. Mejóralo en el dashboard para seguir.'
22
+ : 'You reached your plan\u2019s AI usage limit. Upgrade in the dashboard to continue.';
23
+ case 'rate_limited':
24
+ return es
25
+ ? 'Demasiadas peticiones seguidas. Espera un momento e inténtalo de nuevo.'
26
+ : 'Too many requests. Wait a moment and try again.';
27
+ case 'server_error':
28
+ return es
29
+ ? 'El servidor no pudo responder. Revisa que la IA esté configurada en el backend.'
30
+ : 'The server could not respond. Check that the AI is configured on the backend.';
31
+ case 'cancelled':
32
+ return es ? 'Cancelado.' : 'Cancelled.';
33
+ default:
34
+ break;
35
+ }
36
+ if (err.message && err.message !== err.code)
37
+ return err.message;
38
+ }
39
+ if (err instanceof Error && err.message)
40
+ return err.message;
41
+ return es ? 'No pude responder. Inténtalo de nuevo.' : 'I could not reply. Please try again.';
42
+ }
@@ -58,6 +58,8 @@ export declare class AgentController {
58
58
  private enabled;
59
59
  private phase;
60
60
  private lastExchange;
61
+ /** User text for the turn currently in flight (for error/timeout display). */
62
+ private pendingTurnText;
61
63
  private confirmationMessage;
62
64
  private confirmationVoiceHint;
63
65
  private consentModalVisible;
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { combineContexts, createActionExecutor, createConsentManager, createSafeTelemetry, createSanitizer, createConversationSession, formatMessage, prepareConversationForTurn, recordConversationExchange, resolveConfirmationLexicon, resolveMessages, validateManifest, } from '@fiodos/core';
19
19
  import { AgentApiError } from '../api/errors.js';
20
+ import { formatAgentTurnError } from '../api/formatTurnError.js';
20
21
  import { buildManifestPayload } from '../api/backendClient.js';
21
22
  import { createScreenContextStore, } from '../context/screenContextStore.js';
22
23
  import { createSpeechSession } from '../speech/speechSession.js';
@@ -31,6 +32,8 @@ export class AgentController {
31
32
  this.enabled = true;
32
33
  this.phase = 'idle';
33
34
  this.lastExchange = null;
35
+ /** User text for the turn currently in flight (for error/timeout display). */
36
+ this.pendingTurnText = null;
34
37
  this.confirmationMessage = null;
35
38
  this.confirmationVoiceHint = null;
36
39
  this.consentModalVisible = false;
@@ -320,11 +323,21 @@ export class AgentController {
320
323
  return;
321
324
  }
322
325
  this.setPhase('thinking');
326
+ this.pendingTurnText = text;
327
+ this.lastExchange = { userText: text, replyText: '' };
328
+ this.emit();
323
329
  this.clearWatchdog();
324
330
  this.watchdog = setTimeout(() => {
325
331
  if (this.phase === 'thinking') {
326
332
  this.telemetry.logEvent({ eventType: 'agent_watchdog_reset', sessionId: this.sessionId });
333
+ const userText = this.pendingTurnText ?? text;
334
+ const replyText = this.config.locale.startsWith('es')
335
+ ? 'La respuesta tardó demasiado. Inténtalo de nuevo.'
336
+ : 'The response took too long. Please try again.';
337
+ this.lastExchange = { userText, replyText };
338
+ this.pendingTurnText = null;
327
339
  this.setPhase('idle');
340
+ this.emit();
328
341
  }
329
342
  }, this.timings.thinkingWatchdogMs);
330
343
  const currentRoute = this.config.navigation.getCurrentRoute();
@@ -432,23 +445,36 @@ export class AgentController {
432
445
  if (e instanceof AgentApiError && e.code === 'cancelled') {
433
446
  if (this.phase === 'thinking')
434
447
  this.setPhase('idle');
448
+ this.pendingTurnText = null;
435
449
  return;
436
450
  }
437
451
  if (e instanceof AgentApiError && e.code === 'quota_exceeded') {
438
452
  this.config.onQuotaExceeded?.();
439
453
  }
454
+ const errMsg = formatAgentTurnError(e, this.config.locale);
455
+ this.lastExchange = { userText: text, replyText: errMsg };
456
+ this.pendingTurnText = null;
440
457
  if (this.phase === 'thinking')
441
458
  this.setPhase('idle');
442
- this.notifyError(reply || (e instanceof Error ? e.message : 'error'));
459
+ this.emit();
460
+ this.notifyError(errMsg);
443
461
  return;
444
462
  }
445
463
  this.clearWatchdog();
446
464
  this.abort = null;
465
+ this.pendingTurnText = null;
447
466
  if (reply) {
448
467
  this.lastExchange = { userText: text, replyText: reply };
449
468
  this.emit();
450
469
  recordConversationExchange(this.conversation, text, reply);
451
470
  }
471
+ else {
472
+ const fallback = this.config.locale.startsWith('es')
473
+ ? 'No recibí respuesta del servidor. Inténtalo de nuevo.'
474
+ : 'No reply from the server. Please try again.';
475
+ this.lastExchange = { userText: text, replyText: fallback };
476
+ this.emit();
477
+ }
452
478
  const hasPending = this.pending != null;
453
479
  await this.speakReply(reply, audioBase64);
454
480
  if (hasPending) {
@@ -16,7 +16,7 @@
16
16
  * - CONFIRMATIONS: the same security model (manifest decides; strict phrase /
17
17
  * deliberate tap for payment-grade; loose "yes" never resolves strict).
18
18
  */
19
- import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, resolveOrbLayerInsets, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, } from '@fiodos/core';
19
+ import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, } from '@fiodos/core';
20
20
  import { buildKeyboardChip, createOrbVisual, DEFAULT_ORB_APPEARANCE, } from './orbView.js';
21
21
  import { watchPublishedConfig } from './publishedConfig.js';
22
22
  const STYLE_ID = 'fyodos-orb-styles';
@@ -62,6 +62,18 @@ function ensureStyles() {
62
62
  el.textContent = CSS;
63
63
  document.head.appendChild(el);
64
64
  }
65
+ // Content box of the layout viewport. `documentElement.clientWidth/Height`
66
+ // EXCLUDE a classic scrollbar that occupies real width/height (Windows, some
67
+ // Linux/Chromium); overlay scrollbars (macOS, mobile) report the same value as
68
+ // the layout viewport. Anchoring against this — not `innerWidth` — keeps a
69
+ // right/bottom-edge orb just INSIDE the scrollbar instead of under/over it.
70
+ function viewportContentSize() {
71
+ const de = typeof document !== 'undefined' ? document.documentElement : null;
72
+ return {
73
+ width: de?.clientWidth || window.innerWidth,
74
+ height: de?.clientHeight || window.innerHeight,
75
+ };
76
+ }
65
77
  function cornerToPosition(corner) {
66
78
  switch (corner) {
67
79
  case 'bottom-left':
@@ -158,14 +170,15 @@ export function mountOrb(controller, options = {}) {
158
170
  }
159
171
  function reposition() {
160
172
  const size = orbDiameterPx();
161
- const insets = resolveOrbLayerInsets({
173
+ const content = viewportContentSize();
174
+ const { left, top } = resolveOrbAnchorPx({
162
175
  viewportWidth: window.innerWidth,
163
176
  viewportHeight: window.innerHeight,
177
+ contentWidth: content.width,
178
+ contentHeight: content.height,
164
179
  orbDiameter: size,
165
180
  position: effectivePosition(),
166
181
  });
167
- const left = Math.max(8, window.innerWidth - insets.right - size);
168
- const top = Math.max(8, window.innerHeight - insets.bottom - size);
169
182
  root.style.left = `${left}px`;
170
183
  root.style.top = `${top}px`;
171
184
  root.style.right = '';
@@ -194,6 +207,14 @@ export function mountOrb(controller, options = {}) {
194
207
  }
195
208
  }
196
209
  window.addEventListener('resize', reposition);
210
+ // Recalc when a scrollbar appears/disappears as the page content changes (the
211
+ // content box shrinks/grows even when the window size doesn't), so the orb
212
+ // never ends up overlapped by — or detached from — the scrollbar edge.
213
+ let contentObserver;
214
+ if (typeof ResizeObserver !== 'undefined' && document.documentElement) {
215
+ contentObserver = new ResizeObserver(() => reposition());
216
+ contentObserver.observe(document.documentElement);
217
+ }
197
218
  // Keep the conversation bar above the keyboard as the visual viewport resizes
198
219
  // (keyboard open/close) or scrolls on mobile browsers.
199
220
  const visualViewport = window.visualViewport;
@@ -237,9 +258,10 @@ export function mountOrb(controller, options = {}) {
237
258
  dragging = true;
238
259
  e.preventDefault();
239
260
  const size = orbDiameterPx();
261
+ const content = viewportContentSize();
240
262
  const margin = 8;
241
- const maxLeft = window.innerWidth - margin - size;
242
- const maxTop = window.innerHeight - margin - size;
263
+ const maxLeft = content.width - margin - size;
264
+ const maxTop = content.height - margin - size;
243
265
  const left = Math.min(maxLeft, Math.max(margin, originLeft + dx));
244
266
  const top = Math.min(maxTop, Math.max(margin, originTop + dy));
245
267
  root.style.left = `${left}px`;
@@ -249,8 +271,8 @@ export function mountOrb(controller, options = {}) {
249
271
  draggedPosition = viewportPixelsToOrbScreenPosition({
250
272
  centerX: left + size / 2,
251
273
  centerY: top + size / 2,
252
- viewportWidth: window.innerWidth,
253
- viewportHeight: window.innerHeight,
274
+ viewportWidth: content.width,
275
+ viewportHeight: content.height,
254
276
  orbDiameter: size,
255
277
  });
256
278
  applyDeployment();
@@ -658,6 +680,10 @@ export function mountOrb(controller, options = {}) {
658
680
  const stopWatch = watchPublishedConfig(controller, {
659
681
  pollMs: options.appearancePollMs,
660
682
  onChange: (config) => {
683
+ // Dashboard master switch: hide the whole orb (button + chip + bubble live
684
+ // inside `root`) when explicitly turned off, show it otherwise. No host
685
+ // code change — it flips on the next poll and reappears when re-enabled.
686
+ root.style.display = config && config.enabled === false ? 'none' : '';
661
687
  if (config) {
662
688
  publishedAppearance = config.appearance;
663
689
  chipTheme = config.keyboardChip;
@@ -681,6 +707,7 @@ export function mountOrb(controller, options = {}) {
681
707
  unsubscribe();
682
708
  stopWatch();
683
709
  window.removeEventListener('resize', reposition);
710
+ contentObserver?.disconnect();
684
711
  visualViewport?.removeEventListener('resize', onViewportChange);
685
712
  visualViewport?.removeEventListener('scroll', onViewportChange);
686
713
  clearOverlay();
@@ -17,6 +17,12 @@ export interface ResolvedOrbConfig {
17
17
  keyboardChip: ChipTheme | null;
18
18
  modalTheme: PublishedModalTheme | undefined;
19
19
  screenPosition: OrbScreenPosition | undefined;
20
+ /**
21
+ * Dashboard master visibility switch (`orbEnabled`). False ONLY when the
22
+ * developer explicitly turned the orb off. The orb is hidden when false and
23
+ * shown otherwise (including when nothing is published / on error).
24
+ */
25
+ enabled: boolean;
20
26
  }
21
27
  export interface WatchPublishedConfigOptions {
22
28
  /** Poll interval (ms) to pick up dashboard changes in real time. Default 12000. */
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { mapPublishedConfigToOrbProps, } from '@fiodos/core';
13
13
  import { DEFAULT_ORB_APPEARANCE } from './orbView.js';
14
- function toResolved(props) {
14
+ function toResolved(props, enabled) {
15
15
  const theme = props.theme;
16
16
  const appearance = {
17
17
  accentColor: theme.accentColor ?? DEFAULT_ORB_APPEARANCE.accentColor,
@@ -30,6 +30,7 @@ function toResolved(props) {
30
30
  keyboardChip: theme.keyboardChip ?? null,
31
31
  modalTheme: props.modalTheme,
32
32
  screenPosition: props.screenPosition,
33
+ enabled,
33
34
  };
34
35
  }
35
36
  /**
@@ -49,7 +50,9 @@ export function watchPublishedConfig(controller, options) {
49
50
  const res = await fetcher.call(controller.config.backend);
50
51
  if (cancelled)
51
52
  return;
52
- options.onChange(res.published ? toResolved(mapPublishedConfigToOrbProps(res.published)) : null);
53
+ options.onChange(res.published
54
+ ? toResolved(mapPublishedConfigToOrbProps(res.published), res.published.orbEnabled !== false)
55
+ : null);
53
56
  }
54
57
  catch {
55
58
  if (!cancelled)
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export declare const SDK_VERSION = "0.1.2";
8
+ export declare const SDK_VERSION = "0.1.3";
9
9
  export declare const SDK_PACKAGE = "@fiodos/web-core";
@@ -5,5 +5,5 @@
5
5
  * Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
6
6
  * by hand — change package.json `version` and re-run the sync/release script.
7
7
  */
8
- export const SDK_VERSION = '0.1.2';
8
+ export const SDK_VERSION = '0.1.3';
9
9
  export const SDK_PACKAGE = '@fiodos/web-core';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/web-core",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Framework-agnostic browser layer for the Fiodos agent: a vanilla AgentController (orchestrator), DOM adapters (navigation/voice/storage), HTTP client and decision engine over @fiodos/core. Shared base for @fiodos/vue, @fiodos/svelte and @fiodos/angular (no framework imports).",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": {