@speechos/client 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/index.cjs +651 -110
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.iife.js +684 -114
  6. package/dist/index.iife.js.map +1 -1
  7. package/dist/index.iife.min.js +122 -100
  8. package/dist/index.iife.min.js.map +1 -1
  9. package/dist/index.js +652 -112
  10. package/dist/index.js.map +1 -1
  11. package/dist/settings-sync.d.ts +68 -0
  12. package/dist/settings-sync.d.ts.map +1 -0
  13. package/dist/settings-sync.test.d.ts +5 -0
  14. package/dist/settings-sync.test.d.ts.map +1 -0
  15. package/dist/speechos.d.ts.map +1 -1
  16. package/dist/stores/language-settings.d.ts +12 -1
  17. package/dist/stores/language-settings.d.ts.map +1 -1
  18. package/dist/stores/language-settings.test.d.ts +5 -0
  19. package/dist/stores/language-settings.test.d.ts.map +1 -0
  20. package/dist/stores/snippets-store.d.ts +12 -1
  21. package/dist/stores/snippets-store.d.ts.map +1 -1
  22. package/dist/stores/transcript-store.d.ts +13 -2
  23. package/dist/stores/transcript-store.d.ts.map +1 -1
  24. package/dist/stores/vocabulary-store.d.ts +12 -1
  25. package/dist/stores/vocabulary-store.d.ts.map +1 -1
  26. package/dist/ui/index.d.ts.map +1 -1
  27. package/dist/ui/styles/modal-styles.d.ts.map +1 -1
  28. package/dist/ui/styles/theme.d.ts.map +1 -1
  29. package/dist/ui/tabs/history-tab.d.ts +2 -0
  30. package/dist/ui/tabs/history-tab.d.ts.map +1 -1
  31. package/dist/ui/tabs/settings-tab.d.ts +1 -0
  32. package/dist/ui/tabs/settings-tab.d.ts.map +1 -1
  33. package/dist/ui/tabs/snippets-tab.d.ts +2 -0
  34. package/dist/ui/tabs/snippets-tab.d.ts.map +1 -1
  35. package/dist/ui/tabs/vocabulary-tab.d.ts +2 -0
  36. package/dist/ui/tabs/vocabulary-tab.d.ts.map +1 -1
  37. package/dist/ui/widget.d.ts.map +1 -1
  38. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -484,6 +484,11 @@ function resetTextInputHandler() {
484
484
  * Persists input language preferences to localStorage
485
485
  */
486
486
  const STORAGE_KEY$4 = "speechos_language_settings";
487
+ /**
488
+ * In-memory cache for language settings. When server sync is enabled, this is the
489
+ * source of truth. localStorage is only used when server sync is disabled.
490
+ */
491
+ let memoryCache$3 = null;
487
492
  /**
488
493
  * Supported input languages for speech recognition
489
494
  * Each language has a name, primary code, and available variants
@@ -535,9 +540,13 @@ const defaultSettings$1 = {
535
540
  smartFormat: true,
536
541
  };
537
542
  /**
538
- * Get current language settings from localStorage
543
+ * Get current language settings. Prefers in-memory cache (from server sync),
544
+ * then falls back to localStorage.
539
545
  */
540
546
  function getLanguageSettings() {
547
+ if (memoryCache$3 !== null) {
548
+ return { ...memoryCache$3 };
549
+ }
541
550
  try {
542
551
  const stored = localStorage.getItem(STORAGE_KEY$4);
543
552
  if (!stored)
@@ -549,14 +558,27 @@ function getLanguageSettings() {
549
558
  }
550
559
  }
551
560
  /**
552
- * Save language settings to localStorage
561
+ * Set language settings directly (used by settings sync from server data).
562
+ */
563
+ function setLanguageSettings(settings) {
564
+ memoryCache$3 = { ...defaultSettings$1, ...settings };
565
+ }
566
+ /**
567
+ * Reset memory cache (for testing only)
568
+ */
569
+ function resetMemoryCache$2() {
570
+ memoryCache$3 = null;
571
+ }
572
+ /**
573
+ * Save language settings (updates memory cache and tries localStorage)
553
574
  */
554
575
  function saveLanguageSettings(settings) {
576
+ memoryCache$3 = settings;
555
577
  try {
556
578
  localStorage.setItem(STORAGE_KEY$4, JSON.stringify(settings));
557
579
  }
558
580
  catch {
559
- // localStorage full or unavailable - silently fail
581
+ // localStorage full or unavailable - memory cache still updated
560
582
  }
561
583
  }
562
584
  /**
@@ -660,6 +682,7 @@ function getLanguageName() {
660
682
  * Reset language settings to defaults
661
683
  */
662
684
  function resetLanguageSettings() {
685
+ memoryCache$3 = null;
663
686
  try {
664
687
  localStorage.removeItem(STORAGE_KEY$4);
665
688
  }
@@ -669,6 +692,7 @@ function resetLanguageSettings() {
669
692
  }
670
693
  const languageSettings = {
671
694
  getLanguageSettings,
695
+ setLanguageSettings,
672
696
  getInputLanguageCode,
673
697
  setInputLanguageCode,
674
698
  getOutputLanguageCode,
@@ -679,6 +703,7 @@ const languageSettings = {
679
703
  getSmartFormatEnabled,
680
704
  setSmartFormatEnabled,
681
705
  resetLanguageSettings,
706
+ resetMemoryCache: resetMemoryCache$2,
682
707
  SUPPORTED_LANGUAGES,
683
708
  // Legacy aliases
684
709
  getLanguageCode,
@@ -694,6 +719,11 @@ const STORAGE_KEY$3 = "speechos_snippets";
694
719
  const MAX_SNIPPETS = 25;
695
720
  const MAX_TRIGGER_LENGTH = 30;
696
721
  const MAX_EXPANSION_LENGTH = 300;
722
+ /**
723
+ * In-memory cache for snippets. When server sync is enabled, this is the
724
+ * source of truth. localStorage is only used when server sync is disabled.
725
+ */
726
+ let memoryCache$2 = null;
697
727
  /**
698
728
  * Generate a unique ID for snippet entries
699
729
  */
@@ -701,15 +731,18 @@ function generateId$2() {
701
731
  return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
702
732
  }
703
733
  /**
704
- * Get all snippets from localStorage
734
+ * Get all snippets. Prefers in-memory cache (from server sync),
735
+ * then falls back to localStorage.
705
736
  */
706
737
  function getSnippets() {
738
+ if (memoryCache$2 !== null) {
739
+ return [...memoryCache$2].sort((a, b) => b.createdAt - a.createdAt);
740
+ }
707
741
  try {
708
742
  const stored = localStorage.getItem(STORAGE_KEY$3);
709
743
  if (!stored)
710
744
  return [];
711
745
  const entries = JSON.parse(stored);
712
- // Return newest first
713
746
  return entries.sort((a, b) => b.createdAt - a.createdAt);
714
747
  }
715
748
  catch {
@@ -717,14 +750,27 @@ function getSnippets() {
717
750
  }
718
751
  }
719
752
  /**
720
- * Save snippets to localStorage
753
+ * Set snippets directly (used by settings sync from server data).
754
+ */
755
+ function setSnippets(snippets) {
756
+ memoryCache$2 = snippets.slice(0, MAX_SNIPPETS);
757
+ }
758
+ /**
759
+ * Reset memory cache (for testing only)
760
+ */
761
+ function resetMemoryCache$1() {
762
+ memoryCache$2 = null;
763
+ }
764
+ /**
765
+ * Save snippets (updates memory cache and tries localStorage)
721
766
  */
722
767
  function saveSnippets(snippets) {
768
+ memoryCache$2 = snippets;
723
769
  try {
724
770
  localStorage.setItem(STORAGE_KEY$3, JSON.stringify(snippets));
725
771
  }
726
772
  catch {
727
- // localStorage full or unavailable - silently fail
773
+ // localStorage full or unavailable - memory cache still updated
728
774
  }
729
775
  }
730
776
  /**
@@ -842,13 +888,14 @@ function deleteSnippet(id) {
842
888
  * Clear all snippets
843
889
  */
844
890
  function clearSnippets() {
891
+ memoryCache$2 = [];
845
892
  try {
846
893
  localStorage.removeItem(STORAGE_KEY$3);
847
- core.events.emit("settings:changed", { setting: "snippets" });
848
894
  }
849
895
  catch {
850
896
  // Silently fail
851
897
  }
898
+ core.events.emit("settings:changed", { setting: "snippets" });
852
899
  }
853
900
  /**
854
901
  * Get snippet count info
@@ -864,12 +911,14 @@ function isAtSnippetLimit() {
864
911
  }
865
912
  const snippetsStore = {
866
913
  getSnippets,
914
+ setSnippets,
867
915
  addSnippet,
868
916
  updateSnippet,
869
917
  deleteSnippet,
870
918
  clearSnippets,
871
919
  getSnippetCount,
872
920
  isAtSnippetLimit,
921
+ resetMemoryCache: resetMemoryCache$1,
873
922
  MAX_SNIPPETS,
874
923
  MAX_TRIGGER_LENGTH,
875
924
  MAX_EXPANSION_LENGTH,
@@ -882,6 +931,11 @@ const snippetsStore = {
882
931
  const STORAGE_KEY$2 = "speechos_vocabulary";
883
932
  const MAX_TERMS = 50;
884
933
  const MAX_TERM_LENGTH = 50;
934
+ /**
935
+ * In-memory cache for vocabulary. When server sync is enabled, this is the
936
+ * source of truth. localStorage is only used when server sync is disabled.
937
+ */
938
+ let memoryCache$1 = null;
885
939
  /**
886
940
  * Generate a unique ID for vocabulary entries
887
941
  */
@@ -889,15 +943,18 @@ function generateId$1() {
889
943
  return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
890
944
  }
891
945
  /**
892
- * Get all vocabulary terms from localStorage
946
+ * Get all vocabulary terms. Prefers in-memory cache (from server sync),
947
+ * then falls back to localStorage.
893
948
  */
894
949
  function getVocabulary() {
950
+ if (memoryCache$1 !== null) {
951
+ return [...memoryCache$1].sort((a, b) => b.createdAt - a.createdAt);
952
+ }
895
953
  try {
896
954
  const stored = localStorage.getItem(STORAGE_KEY$2);
897
955
  if (!stored)
898
956
  return [];
899
957
  const entries = JSON.parse(stored);
900
- // Return newest first
901
958
  return entries.sort((a, b) => b.createdAt - a.createdAt);
902
959
  }
903
960
  catch {
@@ -905,14 +962,27 @@ function getVocabulary() {
905
962
  }
906
963
  }
907
964
  /**
908
- * Save vocabulary to localStorage
965
+ * Set vocabulary directly (used by settings sync from server data).
966
+ */
967
+ function setVocabulary(terms) {
968
+ memoryCache$1 = terms.slice(0, MAX_TERMS);
969
+ }
970
+ /**
971
+ * Reset memory cache (for testing only)
972
+ */
973
+ function resetMemoryCache() {
974
+ memoryCache$1 = null;
975
+ }
976
+ /**
977
+ * Save vocabulary (updates memory cache and tries localStorage)
909
978
  */
910
979
  function saveVocabulary(terms) {
980
+ memoryCache$1 = terms;
911
981
  try {
912
982
  localStorage.setItem(STORAGE_KEY$2, JSON.stringify(terms));
913
983
  }
914
984
  catch {
915
- // localStorage full or unavailable - silently fail
985
+ // localStorage full or unavailable - memory cache still updated
916
986
  }
917
987
  }
918
988
  /**
@@ -980,13 +1050,14 @@ function deleteTerm(id) {
980
1050
  * Clear all vocabulary
981
1051
  */
982
1052
  function clearVocabulary() {
1053
+ memoryCache$1 = [];
983
1054
  try {
984
1055
  localStorage.removeItem(STORAGE_KEY$2);
985
- core.events.emit("settings:changed", { setting: "vocabulary" });
986
1056
  }
987
1057
  catch {
988
1058
  // Silently fail
989
1059
  }
1060
+ core.events.emit("settings:changed", { setting: "vocabulary" });
990
1061
  }
991
1062
  /**
992
1063
  * Get vocabulary count info
@@ -1002,11 +1073,13 @@ function isAtVocabularyLimit() {
1002
1073
  }
1003
1074
  const vocabularyStore = {
1004
1075
  getVocabulary,
1076
+ setVocabulary,
1005
1077
  addTerm,
1006
1078
  deleteTerm,
1007
1079
  clearVocabulary,
1008
1080
  getVocabularyCount,
1009
1081
  isAtVocabularyLimit,
1082
+ resetMemoryCache,
1010
1083
  MAX_TERMS,
1011
1084
  MAX_TERM_LENGTH,
1012
1085
  };
@@ -1104,6 +1177,462 @@ const audioSettings = {
1104
1177
  resetAudioSettings,
1105
1178
  };
1106
1179
 
1180
+ /**
1181
+ * Transcript history store
1182
+ * Persists transcripts to localStorage for viewing in the settings modal
1183
+ */
1184
+ const STORAGE_KEY = "speechos_transcripts";
1185
+ const MAX_ENTRIES = 50;
1186
+ /**
1187
+ * In-memory cache for transcripts. When server sync is enabled, this is the
1188
+ * source of truth. localStorage is only used when server sync is disabled.
1189
+ */
1190
+ let memoryCache = null;
1191
+ /**
1192
+ * Generate a unique ID for transcript entries
1193
+ */
1194
+ function generateId() {
1195
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
1196
+ }
1197
+ /**
1198
+ * Get all transcripts. Prefers in-memory cache (from server sync),
1199
+ * then falls back to localStorage.
1200
+ */
1201
+ function getTranscripts() {
1202
+ // If we have in-memory data (from server sync), use it
1203
+ if (memoryCache !== null) {
1204
+ return [...memoryCache].sort((a, b) => b.timestamp - a.timestamp);
1205
+ }
1206
+ // Fall back to localStorage (when server sync is disabled)
1207
+ try {
1208
+ const stored = localStorage.getItem(STORAGE_KEY);
1209
+ if (!stored)
1210
+ return [];
1211
+ const entries = JSON.parse(stored);
1212
+ return entries.sort((a, b) => b.timestamp - a.timestamp);
1213
+ }
1214
+ catch {
1215
+ return [];
1216
+ }
1217
+ }
1218
+ /**
1219
+ * Set transcripts directly (used by settings sync from server data).
1220
+ * Server data is the source of truth - just update memory cache.
1221
+ */
1222
+ function setTranscripts(entries) {
1223
+ memoryCache = entries.slice(0, MAX_ENTRIES);
1224
+ }
1225
+ /**
1226
+ * Save a new transcript entry
1227
+ */
1228
+ function saveTranscript(text, action, originalTextOrOptions) {
1229
+ const entry = {
1230
+ id: generateId(),
1231
+ text,
1232
+ timestamp: Date.now(),
1233
+ action,
1234
+ };
1235
+ // Handle edit action with originalText string
1236
+ if (action === "edit" && typeof originalTextOrOptions === "string") {
1237
+ entry.originalText = originalTextOrOptions;
1238
+ }
1239
+ // Handle command action with options object
1240
+ if (action === "command" && typeof originalTextOrOptions === "object") {
1241
+ const options = originalTextOrOptions;
1242
+ if (options.inputText !== undefined)
1243
+ entry.inputText = options.inputText;
1244
+ if (options.commandResult !== undefined)
1245
+ entry.commandResult = options.commandResult;
1246
+ if (options.commandConfig !== undefined)
1247
+ entry.commandConfig = options.commandConfig;
1248
+ }
1249
+ const entries = getTranscripts();
1250
+ entries.unshift(entry);
1251
+ // Prune to max entries
1252
+ const pruned = entries.slice(0, MAX_ENTRIES);
1253
+ // Update memory cache (always)
1254
+ memoryCache = pruned;
1255
+ // Try to persist to localStorage (for when server sync is disabled)
1256
+ try {
1257
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned));
1258
+ }
1259
+ catch {
1260
+ // Quota exceeded - memory cache is still updated
1261
+ }
1262
+ // Emit settings change event to trigger sync
1263
+ core.events.emit("settings:changed", { setting: "history" });
1264
+ return entry;
1265
+ }
1266
+ /**
1267
+ * Clear all transcript history
1268
+ */
1269
+ function clearTranscripts() {
1270
+ memoryCache = [];
1271
+ try {
1272
+ localStorage.removeItem(STORAGE_KEY);
1273
+ }
1274
+ catch {
1275
+ // Silently fail
1276
+ }
1277
+ core.events.emit("settings:changed", { setting: "history" });
1278
+ }
1279
+ /**
1280
+ * Delete a single transcript by ID
1281
+ */
1282
+ function deleteTranscript(id) {
1283
+ const entries = getTranscripts().filter((e) => e.id !== id);
1284
+ memoryCache = entries;
1285
+ try {
1286
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
1287
+ }
1288
+ catch {
1289
+ // Silently fail
1290
+ }
1291
+ core.events.emit("settings:changed", { setting: "history" });
1292
+ }
1293
+ const transcriptStore = {
1294
+ getTranscripts,
1295
+ setTranscripts,
1296
+ saveTranscript,
1297
+ clearTranscripts,
1298
+ deleteTranscript,
1299
+ };
1300
+
1301
+ /**
1302
+ * Settings sync manager
1303
+ * Syncs user settings (language, vocabulary, snippets, history) with the server
1304
+ */
1305
+ // Sync debounce delay in milliseconds
1306
+ const SYNC_DEBOUNCE_MS = 2000;
1307
+ // Maximum retry attempts
1308
+ const MAX_RETRIES = 3;
1309
+ // Base retry delay in milliseconds (exponential backoff)
1310
+ const BASE_RETRY_DELAY_MS = 2000;
1311
+ /**
1312
+ * Settings sync manager singleton
1313
+ */
1314
+ class SettingsSync {
1315
+ constructor() {
1316
+ this.syncTimer = null;
1317
+ this.isSyncing = false;
1318
+ this.retryCount = 0;
1319
+ this.isInitialized = false;
1320
+ this.unsubscribe = null;
1321
+ /** When true, sync is disabled due to CSP or network restrictions */
1322
+ this.syncDisabled = false;
1323
+ }
1324
+ /**
1325
+ * Make a fetch request using custom fetchHandler if configured, otherwise native fetch.
1326
+ * This allows the Chrome extension to route fetch traffic through the service worker
1327
+ * to bypass page CSP restrictions.
1328
+ */
1329
+ async doFetch(url, options) {
1330
+ const config = core.getConfig();
1331
+ const customHandler = core.getFetchHandler();
1332
+ if (customHandler) {
1333
+ if (config.debug) {
1334
+ console.log("[SpeechOS] Using custom fetch handler (extension proxy)", options.method, url);
1335
+ }
1336
+ return customHandler(url, options);
1337
+ }
1338
+ if (config.debug) {
1339
+ console.log("[SpeechOS] Using native fetch", options.method, url);
1340
+ }
1341
+ // Use native fetch and wrap response to match FetchResponse interface
1342
+ const response = await fetch(url, options);
1343
+ return response;
1344
+ }
1345
+ /**
1346
+ * Initialize the settings sync manager
1347
+ * If a settingsToken is configured, loads settings from server
1348
+ */
1349
+ async init() {
1350
+ const token = core.getSettingsToken();
1351
+ if (!token) {
1352
+ // No token configured, sync is disabled
1353
+ return;
1354
+ }
1355
+ if (this.isInitialized) {
1356
+ return;
1357
+ }
1358
+ this.isInitialized = true;
1359
+ // Subscribe to settings changes
1360
+ this.unsubscribe = core.events.on("settings:changed", () => {
1361
+ this.scheduleSyncToServer();
1362
+ });
1363
+ // Load settings from server
1364
+ await this.loadFromServer();
1365
+ }
1366
+ /**
1367
+ * Stop the sync manager and clean up
1368
+ */
1369
+ destroy() {
1370
+ if (this.syncTimer) {
1371
+ clearTimeout(this.syncTimer);
1372
+ this.syncTimer = null;
1373
+ }
1374
+ if (this.unsubscribe) {
1375
+ this.unsubscribe();
1376
+ this.unsubscribe = null;
1377
+ }
1378
+ this.isInitialized = false;
1379
+ this.retryCount = 0;
1380
+ this.syncDisabled = false;
1381
+ }
1382
+ /**
1383
+ * Load settings from the server and merge with local
1384
+ */
1385
+ async loadFromServer() {
1386
+ const token = core.getSettingsToken();
1387
+ if (!token) {
1388
+ return;
1389
+ }
1390
+ const config = core.getConfig();
1391
+ try {
1392
+ const response = await this.doFetch(`${config.host}/api/user-settings/`, {
1393
+ method: "GET",
1394
+ headers: {
1395
+ Authorization: `Bearer ${token}`,
1396
+ "Content-Type": "application/json",
1397
+ },
1398
+ });
1399
+ if (config.debug) {
1400
+ console.log("[SpeechOS] Settings fetch response:", response.status, response.ok ? "OK" : response.statusText);
1401
+ }
1402
+ if (response.status === 404) {
1403
+ // No settings on server yet (new user) - sync local settings to server
1404
+ if (config.debug) {
1405
+ console.log("[SpeechOS] No server settings found, syncing local to server");
1406
+ }
1407
+ await this.syncToServer();
1408
+ return;
1409
+ }
1410
+ if (response.status === 401 || response.status === 403) {
1411
+ // Token expired or invalid
1412
+ this.handleTokenExpired();
1413
+ return;
1414
+ }
1415
+ if (!response.ok) {
1416
+ throw new Error(`Server returned ${response.status}`);
1417
+ }
1418
+ const serverSettings = (await response.json());
1419
+ if (config.debug) {
1420
+ console.log("[SpeechOS] Settings received from server:", {
1421
+ language: serverSettings.language,
1422
+ vocabularyCount: serverSettings.vocabulary?.length ?? 0,
1423
+ snippetsCount: serverSettings.snippets?.length ?? 0,
1424
+ historyCount: serverSettings.history?.length ?? 0,
1425
+ lastSyncedAt: serverSettings.lastSyncedAt,
1426
+ });
1427
+ if (serverSettings.history?.length > 0) {
1428
+ console.log("[SpeechOS] History entries:", serverSettings.history);
1429
+ }
1430
+ }
1431
+ this.mergeSettings(serverSettings);
1432
+ core.events.emit("settings:loaded", undefined);
1433
+ if (config.debug) {
1434
+ console.log("[SpeechOS] Settings merged and loaded");
1435
+ }
1436
+ }
1437
+ catch (error) {
1438
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
1439
+ // Check if this is a CSP/network restriction - disable sync permanently for this session
1440
+ if (this.isNetworkRestrictionError(error)) {
1441
+ this.syncDisabled = true;
1442
+ if (config.debug) {
1443
+ console.log("[SpeechOS] Settings sync disabled (CSP/network restriction), using localStorage only");
1444
+ }
1445
+ }
1446
+ else if (config.debug) {
1447
+ console.warn("[SpeechOS] Failed to load settings from server:", errorMessage);
1448
+ }
1449
+ core.events.emit("settings:syncFailed", { error: errorMessage });
1450
+ // Continue with local settings on error
1451
+ }
1452
+ }
1453
+ /**
1454
+ * Merge server settings with local (server wins).
1455
+ * Uses store setters to update memory cache - localStorage is a fallback.
1456
+ */
1457
+ mergeSettings(serverSettings) {
1458
+ // Language settings - server wins
1459
+ if (serverSettings.language) {
1460
+ setLanguageSettings(serverSettings.language);
1461
+ }
1462
+ // Vocabulary - server wins
1463
+ if (serverSettings.vocabulary) {
1464
+ setVocabulary(serverSettings.vocabulary);
1465
+ }
1466
+ // Snippets - server wins
1467
+ if (serverSettings.snippets) {
1468
+ setSnippets(serverSettings.snippets);
1469
+ }
1470
+ // History - server wins
1471
+ if (serverSettings.history) {
1472
+ setTranscripts(serverSettings.history);
1473
+ }
1474
+ }
1475
+ /**
1476
+ * Schedule a debounced sync to server
1477
+ */
1478
+ scheduleSyncToServer() {
1479
+ const token = core.getSettingsToken();
1480
+ if (!token || this.syncDisabled) {
1481
+ return;
1482
+ }
1483
+ // Cancel any pending sync
1484
+ if (this.syncTimer) {
1485
+ clearTimeout(this.syncTimer);
1486
+ }
1487
+ // Schedule new sync
1488
+ this.syncTimer = setTimeout(() => {
1489
+ this.syncToServer();
1490
+ }, SYNC_DEBOUNCE_MS);
1491
+ }
1492
+ /**
1493
+ * Sync current settings to server
1494
+ */
1495
+ async syncToServer() {
1496
+ const token = core.getSettingsToken();
1497
+ if (!token || this.isSyncing || this.syncDisabled) {
1498
+ return;
1499
+ }
1500
+ this.isSyncing = true;
1501
+ const config = core.getConfig();
1502
+ try {
1503
+ const languageSettings = getLanguageSettings();
1504
+ const vocabulary = getVocabulary();
1505
+ const snippets = getSnippets();
1506
+ const transcripts = getTranscripts();
1507
+ const payload = {
1508
+ language: {
1509
+ inputLanguageCode: languageSettings.inputLanguageCode,
1510
+ outputLanguageCode: languageSettings.outputLanguageCode,
1511
+ smartFormat: languageSettings.smartFormat,
1512
+ },
1513
+ vocabulary: vocabulary.map((v) => ({
1514
+ id: v.id,
1515
+ term: v.term,
1516
+ createdAt: v.createdAt,
1517
+ })),
1518
+ snippets: snippets.map((s) => ({
1519
+ id: s.id,
1520
+ trigger: s.trigger,
1521
+ expansion: s.expansion,
1522
+ createdAt: s.createdAt,
1523
+ })),
1524
+ // Sync history (excluding commandConfig to reduce payload size)
1525
+ history: transcripts.map((t) => ({
1526
+ id: t.id,
1527
+ text: t.text,
1528
+ timestamp: t.timestamp,
1529
+ action: t.action,
1530
+ ...(t.originalText && { originalText: t.originalText }),
1531
+ ...(t.inputText && { inputText: t.inputText }),
1532
+ ...(t.commandResult !== undefined && { commandResult: t.commandResult }),
1533
+ })),
1534
+ };
1535
+ const response = await this.doFetch(`${config.host}/api/user-settings/`, {
1536
+ method: "PUT",
1537
+ headers: {
1538
+ Authorization: `Bearer ${token}`,
1539
+ "Content-Type": "application/json",
1540
+ },
1541
+ body: JSON.stringify(payload),
1542
+ });
1543
+ if (response.status === 401 || response.status === 403) {
1544
+ this.handleTokenExpired();
1545
+ return;
1546
+ }
1547
+ if (!response.ok) {
1548
+ throw new Error(`Server returned ${response.status}`);
1549
+ }
1550
+ // Reset retry count on success
1551
+ this.retryCount = 0;
1552
+ core.events.emit("settings:synced", undefined);
1553
+ if (config.debug) {
1554
+ console.log("[SpeechOS] Settings synced to server");
1555
+ }
1556
+ }
1557
+ catch (error) {
1558
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
1559
+ // Check if this is a CSP/network restriction - disable sync permanently for this session
1560
+ if (this.isNetworkRestrictionError(error)) {
1561
+ this.syncDisabled = true;
1562
+ if (config.debug) {
1563
+ console.log("[SpeechOS] Settings sync disabled (CSP/network restriction), using localStorage only");
1564
+ }
1565
+ core.events.emit("settings:syncFailed", { error: errorMessage });
1566
+ // Don't retry - CSP errors are permanent
1567
+ }
1568
+ else {
1569
+ if (config.debug) {
1570
+ console.warn("[SpeechOS] Failed to sync settings to server:", errorMessage);
1571
+ }
1572
+ core.events.emit("settings:syncFailed", { error: errorMessage });
1573
+ // Retry with exponential backoff (only for transient errors)
1574
+ this.scheduleRetry();
1575
+ }
1576
+ }
1577
+ finally {
1578
+ this.isSyncing = false;
1579
+ }
1580
+ }
1581
+ /**
1582
+ * Schedule a retry with exponential backoff
1583
+ */
1584
+ scheduleRetry() {
1585
+ if (this.retryCount >= MAX_RETRIES) {
1586
+ const config = core.getConfig();
1587
+ if (config.debug) {
1588
+ console.warn("[SpeechOS] Max retries reached, giving up sync");
1589
+ }
1590
+ this.retryCount = 0;
1591
+ return;
1592
+ }
1593
+ this.retryCount++;
1594
+ const delay = BASE_RETRY_DELAY_MS * Math.pow(2, this.retryCount - 1);
1595
+ this.syncTimer = setTimeout(() => {
1596
+ this.syncToServer();
1597
+ }, delay);
1598
+ }
1599
+ /**
1600
+ * Check if an error is a CSP or network restriction error
1601
+ * These errors are permanent and shouldn't trigger retries
1602
+ */
1603
+ isNetworkRestrictionError(error) {
1604
+ if (error instanceof TypeError) {
1605
+ const message = error.message.toLowerCase();
1606
+ // Common CSP/network error messages
1607
+ return (message.includes("failed to fetch") ||
1608
+ message.includes("network request failed") ||
1609
+ message.includes("content security policy") ||
1610
+ message.includes("csp") ||
1611
+ message.includes("blocked"));
1612
+ }
1613
+ return false;
1614
+ }
1615
+ /**
1616
+ * Handle token expiration
1617
+ */
1618
+ handleTokenExpired() {
1619
+ core.clearSettingsToken();
1620
+ core.events.emit("settings:tokenExpired", undefined);
1621
+ const config = core.getConfig();
1622
+ if (config.debug) {
1623
+ console.warn("[SpeechOS] Settings token expired");
1624
+ }
1625
+ }
1626
+ /**
1627
+ * Check if sync is enabled (token is configured)
1628
+ */
1629
+ isEnabled() {
1630
+ return !!core.getSettingsToken();
1631
+ }
1632
+ }
1633
+ // Singleton instance
1634
+ const settingsSync = new SettingsSync();
1635
+
1107
1636
  /******************************************************************************
1108
1637
  Copyright (c) Microsoft Corporation.
1109
1638
 
@@ -1187,6 +1716,9 @@ const t$1=t=>(e,o)=>{ void 0!==o?o.addInitializer(()=>{customElements.define(t,e
1187
1716
  */
1188
1717
  const themeStyles = i$4 `
1189
1718
  :host {
1719
+ /* Font stack - system fonts for consistent rendering across sites */
1720
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
1721
+
1190
1722
  /* Color tokens */
1191
1723
  --speechos-primary: #10B981;
1192
1724
  --speechos-primary-hover: #059669;
@@ -1310,100 +1842,6 @@ i$4 `
1310
1842
  }
1311
1843
  `;
1312
1844
 
1313
- /**
1314
- * Transcript history store
1315
- * Persists transcripts to localStorage for viewing in the settings modal
1316
- */
1317
- const STORAGE_KEY = "speechos_transcripts";
1318
- const MAX_ENTRIES = 50;
1319
- /**
1320
- * Generate a unique ID for transcript entries
1321
- */
1322
- function generateId() {
1323
- return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
1324
- }
1325
- /**
1326
- * Get all transcripts from localStorage
1327
- */
1328
- function getTranscripts() {
1329
- try {
1330
- const stored = localStorage.getItem(STORAGE_KEY);
1331
- if (!stored)
1332
- return [];
1333
- const entries = JSON.parse(stored);
1334
- // Return newest first
1335
- return entries.sort((a, b) => b.timestamp - a.timestamp);
1336
- }
1337
- catch {
1338
- return [];
1339
- }
1340
- }
1341
- /**
1342
- * Save a new transcript entry
1343
- */
1344
- function saveTranscript(text, action, originalTextOrOptions) {
1345
- const entry = {
1346
- id: generateId(),
1347
- text,
1348
- timestamp: Date.now(),
1349
- action,
1350
- };
1351
- // Handle edit action with originalText string
1352
- if (action === "edit" && typeof originalTextOrOptions === "string") {
1353
- entry.originalText = originalTextOrOptions;
1354
- }
1355
- // Handle command action with options object
1356
- if (action === "command" && typeof originalTextOrOptions === "object") {
1357
- const options = originalTextOrOptions;
1358
- if (options.inputText !== undefined)
1359
- entry.inputText = options.inputText;
1360
- if (options.commandResult !== undefined)
1361
- entry.commandResult = options.commandResult;
1362
- if (options.commandConfig !== undefined)
1363
- entry.commandConfig = options.commandConfig;
1364
- }
1365
- const entries = getTranscripts();
1366
- entries.unshift(entry);
1367
- // Prune to max entries
1368
- const pruned = entries.slice(0, MAX_ENTRIES);
1369
- try {
1370
- localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned));
1371
- }
1372
- catch {
1373
- // localStorage full or unavailable - silently fail
1374
- }
1375
- return entry;
1376
- }
1377
- /**
1378
- * Clear all transcript history
1379
- */
1380
- function clearTranscripts() {
1381
- try {
1382
- localStorage.removeItem(STORAGE_KEY);
1383
- }
1384
- catch {
1385
- // Silently fail
1386
- }
1387
- }
1388
- /**
1389
- * Delete a single transcript by ID
1390
- */
1391
- function deleteTranscript(id) {
1392
- const entries = getTranscripts().filter((e) => e.id !== id);
1393
- try {
1394
- localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
1395
- }
1396
- catch {
1397
- // Silently fail
1398
- }
1399
- }
1400
- const transcriptStore = {
1401
- getTranscripts: getTranscripts,
1402
- saveTranscript: saveTranscript,
1403
- clearTranscripts: clearTranscripts,
1404
- deleteTranscript: deleteTranscript,
1405
- };
1406
-
1407
1845
  function isNativeField(field) {
1408
1846
  return field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement;
1409
1847
  }
@@ -3401,6 +3839,8 @@ const modalLayoutStyles = i$4 `
3401
3839
  inset: 0;
3402
3840
  pointer-events: none;
3403
3841
  z-index: calc(var(--speechos-z-base) + 100);
3842
+ /* Ensure consistent font rendering across all sites */
3843
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
3404
3844
  }
3405
3845
 
3406
3846
  .modal-overlay {
@@ -3889,6 +4329,7 @@ let SpeechOSHistoryTab = class SpeechOSHistoryTab extends i$1 {
3889
4329
  constructor() {
3890
4330
  super(...arguments);
3891
4331
  this.transcripts = [];
4332
+ this.unsubscribeSettingsLoaded = null;
3892
4333
  }
3893
4334
  static { this.styles = [
3894
4335
  themeStyles,
@@ -4025,11 +4466,36 @@ let SpeechOSHistoryTab = class SpeechOSHistoryTab extends i$1 {
4025
4466
  background: rgba(239, 68, 68, 0.18);
4026
4467
  border-color: rgba(239, 68, 68, 0.25);
4027
4468
  }
4469
+
4470
+ .command-matched {
4471
+ font-size: 12px;
4472
+ color: rgba(255, 255, 255, 0.5);
4473
+ margin-top: 6px;
4474
+ }
4475
+
4476
+ .command-matched code {
4477
+ background: rgba(245, 158, 11, 0.15);
4478
+ color: #fbbf24;
4479
+ padding: 2px 6px;
4480
+ border-radius: 4px;
4481
+ font-family: monospace;
4482
+ }
4028
4483
  `,
4029
4484
  ]; }
4030
4485
  connectedCallback() {
4031
4486
  super.connectedCallback();
4032
4487
  this.loadTranscripts();
4488
+ // Refresh when settings are loaded from the server (history may have changed)
4489
+ this.unsubscribeSettingsLoaded = core.events.on("settings:loaded", () => {
4490
+ this.loadTranscripts();
4491
+ });
4492
+ }
4493
+ disconnectedCallback() {
4494
+ super.disconnectedCallback();
4495
+ if (this.unsubscribeSettingsLoaded) {
4496
+ this.unsubscribeSettingsLoaded();
4497
+ this.unsubscribeSettingsLoaded = null;
4498
+ }
4033
4499
  }
4034
4500
  /** Reload transcripts from store */
4035
4501
  refresh() {
@@ -4100,7 +4566,13 @@ let SpeechOSHistoryTab = class SpeechOSHistoryTab extends i$1 {
4100
4566
  renderCommandDetails(entry) {
4101
4567
  // Show the transcript text (what the user said)
4102
4568
  const displayText = entry.inputText || entry.text;
4103
- return b `<div class="transcript-text">${displayText}</div>`;
4569
+ const commandName = entry.commandResult?.name;
4570
+ return b `
4571
+ <div class="transcript-text">${displayText}</div>
4572
+ ${commandName
4573
+ ? b `<div class="command-matched">Matched: <code>${commandName}</code></div>`
4574
+ : null}
4575
+ `;
4104
4576
  }
4105
4577
  getCopyText(entry) {
4106
4578
  if (entry.action === "command") {
@@ -4650,6 +5122,7 @@ let SpeechOSSettingsTab = class SpeechOSSettingsTab extends i$1 {
4650
5122
  this.permissionGranted = false;
4651
5123
  this.smartFormatEnabled = true;
4652
5124
  this.savedIndicatorTimeout = null;
5125
+ this.unsubscribeSettingsLoaded = null;
4653
5126
  }
4654
5127
  static { this.styles = [
4655
5128
  themeStyles,
@@ -4824,12 +5297,20 @@ let SpeechOSSettingsTab = class SpeechOSSettingsTab extends i$1 {
4824
5297
  connectedCallback() {
4825
5298
  super.connectedCallback();
4826
5299
  this.loadSettings();
5300
+ // Refresh when settings are loaded from the server
5301
+ this.unsubscribeSettingsLoaded = core.events.on("settings:loaded", () => {
5302
+ this.loadSettings();
5303
+ });
4827
5304
  }
4828
5305
  disconnectedCallback() {
4829
5306
  super.disconnectedCallback();
4830
5307
  if (this.savedIndicatorTimeout) {
4831
5308
  clearTimeout(this.savedIndicatorTimeout);
4832
5309
  }
5310
+ if (this.unsubscribeSettingsLoaded) {
5311
+ this.unsubscribeSettingsLoaded();
5312
+ this.unsubscribeSettingsLoaded = null;
5313
+ }
4833
5314
  this.isTestingMic = false;
4834
5315
  }
4835
5316
  /** Called when tab becomes active */
@@ -5128,6 +5609,7 @@ let SpeechOSSnippetsTab = class SpeechOSSnippetsTab extends i$1 {
5128
5609
  this.trigger = "";
5129
5610
  this.expansion = "";
5130
5611
  this.error = "";
5612
+ this.unsubscribeSettingsLoaded = null;
5131
5613
  }
5132
5614
  static { this.styles = [
5133
5615
  themeStyles,
@@ -5233,6 +5715,17 @@ let SpeechOSSnippetsTab = class SpeechOSSnippetsTab extends i$1 {
5233
5715
  connectedCallback() {
5234
5716
  super.connectedCallback();
5235
5717
  this.loadSnippets();
5718
+ // Refresh when settings are loaded from the server
5719
+ this.unsubscribeSettingsLoaded = core.events.on("settings:loaded", () => {
5720
+ this.loadSnippets();
5721
+ });
5722
+ }
5723
+ disconnectedCallback() {
5724
+ super.disconnectedCallback();
5725
+ if (this.unsubscribeSettingsLoaded) {
5726
+ this.unsubscribeSettingsLoaded();
5727
+ this.unsubscribeSettingsLoaded = null;
5728
+ }
5236
5729
  }
5237
5730
  /** Reload snippets from store */
5238
5731
  refresh() {
@@ -5475,6 +5968,7 @@ let SpeechOSVocabularyTab = class SpeechOSVocabularyTab extends i$1 {
5475
5968
  this.showForm = false;
5476
5969
  this.term = "";
5477
5970
  this.error = "";
5971
+ this.unsubscribeSettingsLoaded = null;
5478
5972
  }
5479
5973
  static { this.styles = [
5480
5974
  themeStyles,
@@ -5527,6 +6021,17 @@ let SpeechOSVocabularyTab = class SpeechOSVocabularyTab extends i$1 {
5527
6021
  connectedCallback() {
5528
6022
  super.connectedCallback();
5529
6023
  this.loadVocabulary();
6024
+ // Refresh when settings are loaded from the server
6025
+ this.unsubscribeSettingsLoaded = core.events.on("settings:loaded", () => {
6026
+ this.loadVocabulary();
6027
+ });
6028
+ }
6029
+ disconnectedCallback() {
6030
+ super.disconnectedCallback();
6031
+ if (this.unsubscribeSettingsLoaded) {
6032
+ this.unsubscribeSettingsLoaded();
6033
+ this.unsubscribeSettingsLoaded = null;
6034
+ }
5530
6035
  }
5531
6036
  /** Reload vocabulary from store */
5532
6037
  refresh() {
@@ -7140,6 +7645,8 @@ let SpeechOSWidget = class SpeechOSWidget extends i$1 {
7140
7645
  const originalContent = this.getElementContent(target) || "";
7141
7646
  if (tagName === "input" || tagName === "textarea") {
7142
7647
  const inputEl = target;
7648
+ // Ensure DOM focus is on the input before inserting
7649
+ inputEl.focus();
7143
7650
  // Restore cursor position before inserting
7144
7651
  const start = this.dictationCursorStart ?? inputEl.value.length;
7145
7652
  const end = this.dictationCursorEnd ?? inputEl.value.length;
@@ -7476,8 +7983,6 @@ let SpeechOSWidget = class SpeechOSWidget extends i$1 {
7476
7983
  // Note: command:complete event is already emitted by the backend
7477
7984
  // when the command_result message is received, so we don't emit here
7478
7985
  core.state.completeRecording();
7479
- // Keep widget visible but collapsed (just mic button, no action bubbles)
7480
- core.state.setState({ isExpanded: false });
7481
7986
  // Show command feedback
7482
7987
  this.showActionFeedback(result ? "command-success" : "command-none");
7483
7988
  backend.disconnect().catch(() => { });
@@ -7635,6 +8140,8 @@ let SpeechOSWidget = class SpeechOSWidget extends i$1 {
7635
8140
  if (tagName === "input" || tagName === "textarea") {
7636
8141
  const inputEl = target;
7637
8142
  originalContent = inputEl.value;
8143
+ // Ensure DOM focus is on the input before editing
8144
+ inputEl.focus();
7638
8145
  // Restore the original selection/cursor position
7639
8146
  const selectionStart = this.editSelectionStart ?? 0;
7640
8147
  const selectionEnd = this.editSelectionEnd ?? inputEl.value.length;
@@ -7802,6 +8309,30 @@ SpeechOSWidget = SpeechOSWidget_1 = __decorate([
7802
8309
  t$1("speechos-widget")
7803
8310
  ], SpeechOSWidget);
7804
8311
 
8312
+ /**
8313
+ * UI module exports
8314
+ * Lit-based Shadow DOM components
8315
+ */
8316
+ // Patch customElements.define to silently ignore duplicate registrations for speechos-* elements.
8317
+ // This prevents errors when the extension loads on a page that already has SpeechOS.
8318
+ // The patch is scoped to only affect speechos-* tags to avoid unintended effects on host pages.
8319
+ const originalDefine = customElements.define.bind(customElements);
8320
+ customElements.define = (name, constructor, options) => {
8321
+ // Only intercept speechos-* elements
8322
+ if (name.startsWith("speechos-")) {
8323
+ if (customElements.get(name) === undefined) {
8324
+ originalDefine(name, constructor, options);
8325
+ }
8326
+ // Skip silently if already registered
8327
+ }
8328
+ else {
8329
+ // Pass through for non-speechos elements
8330
+ originalDefine(name, constructor, options);
8331
+ }
8332
+ };
8333
+ // Restore original customElements.define after our components are registered
8334
+ customElements.define = originalDefine;
8335
+
7805
8336
  /**
7806
8337
  * Main SpeechOS Client SDK class
7807
8338
  * Composes core logic with UI components
@@ -7883,6 +8414,13 @@ class SpeechOS {
7883
8414
  core.state.show();
7884
8415
  }
7885
8416
  this.isInitialized = true;
8417
+ // Initialize settings sync if token is configured
8418
+ // This loads settings from server and subscribes to changes
8419
+ settingsSync.init().catch((error) => {
8420
+ if (finalConfig.debug) {
8421
+ console.warn("[SpeechOS] Settings sync initialization failed:", error);
8422
+ }
8423
+ });
7886
8424
  // Log initialization in debug mode
7887
8425
  if (finalConfig.debug) {
7888
8426
  console.log("[SpeechOS] Initialized with config:", finalConfig);
@@ -7913,6 +8451,8 @@ class SpeechOS {
7913
8451
  resetClientConfig();
7914
8452
  // Reset text input handler to default
7915
8453
  resetTextInputHandler();
8454
+ // Stop settings sync
8455
+ settingsSync.destroy();
7916
8456
  // Clear instance
7917
8457
  this.instance = null;
7918
8458
  this.isInitialized = false;
@@ -8158,6 +8698,7 @@ exports.setLanguageCode = setLanguageCode;
8158
8698
  exports.setOutputLanguageCode = setOutputLanguageCode;
8159
8699
  exports.setSmartFormatEnabled = setSmartFormatEnabled;
8160
8700
  exports.setTextInputHandler = setTextInputHandler;
8701
+ exports.settingsSync = settingsSync;
8161
8702
  exports.snippetsStore = snippetsStore;
8162
8703
  exports.transcriptStore = transcriptStore;
8163
8704
  exports.updateSnippet = updateSnippet;