@focus8/settings-registry 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -285,7 +285,7 @@ export const SETTINGS_GROUPS: readonly SettingsGroupDef[] = [
285
285
  id: 'calendarType',
286
286
  labelKey: 'Settings.CalendarType',
287
287
  icon: 'ArrowLeftRight',
288
- keys: ['calendarView.type', 'calendarView.view'],
288
+ keys: ['calendarView.type'],
289
289
  },
290
290
  {
291
291
  id: 'calendarView',
@@ -294,9 +294,9 @@ export const SETTINGS_GROUPS: readonly SettingsGroupDef[] = [
294
294
  keys: [
295
295
  'calendarView.showCalendarNames',
296
296
  'calendarView.splitView',
297
- 'calendarView.calendarColumns',
298
297
  'calendarView.dayViewZoom',
299
298
  'calendarView.weekViewZoom',
299
+ 'calendarView.calendarColumns',
300
300
  ],
301
301
  },
302
302
  {
@@ -310,9 +310,9 @@ export const SETTINGS_GROUPS: readonly SettingsGroupDef[] = [
310
310
  labelKey: 'Settings.DateWeather',
311
311
  icon: 'CloudSun',
312
312
  keys: [
313
- 'calendarView.showWeatherOnEvents',
314
313
  'calendarView.showWeatherOnTimeline',
315
314
  'calendarView.weatherLocation',
315
+ 'calendarView.showWeatherOnEvents',
316
316
  ],
317
317
  },
318
318
  {
@@ -322,13 +322,13 @@ export const SETTINGS_GROUPS: readonly SettingsGroupDef[] = [
322
322
  keys: [
323
323
  'sound.reminderVolume',
324
324
  'sound.mediaVolume',
325
- 'sound.alarmSound',
326
325
  'sound.reminderAlarmSound',
327
326
  'sound.reminderAlarmTimeout',
328
327
  'sound.allowCustomReminderSounds',
328
+ 'notification.',
329
329
  'sound.ttsEnabled',
330
330
  'sound.ttsRate',
331
- 'notification.',
331
+ 'sound.alarmSound',
332
332
  ],
333
333
  },
334
334
  {
@@ -336,10 +336,11 @@ export const SETTINGS_GROUPS: readonly SettingsGroupDef[] = [
336
336
  labelKey: 'Settings.TimerTitle',
337
337
  icon: 'Timer',
338
338
  keys: [
339
- 'sound.timerVolume',
339
+ 'chronological.timer.',
340
+ 'timer.',
340
341
  'sound.timerAlarmSound',
341
342
  'sound.timerAlarmTimeout',
342
- 'timer.',
343
+ 'sound.timerVolume',
343
344
  ],
344
345
  appMode: 'ENROLLED',
345
346
  },
@@ -389,6 +390,9 @@ export const EXCLUDED_DEVICE_SETTINGS: ReadonlySet<string> = new Set([
389
390
  'device.id',
390
391
  'device.devMenuEnabled',
391
392
  'device.authWarningDismissTtlDays',
393
+ 'notification.enabled',
394
+ 'notification.notifyAllCalendars',
395
+ 'notification.enabledCalendarIds',
392
396
  'notification.hasBeenPrompted',
393
397
  ]);
394
398
 
@@ -647,6 +651,33 @@ function matchesGroup(key: string, group: SettingsGroupDef): boolean {
647
651
  );
648
652
  }
649
653
 
654
+ /** Return the index of the first matching key pattern in the group, for sorting. */
655
+ function groupKeyIndex(key: string, keys: readonly string[]): number {
656
+ for (let i = 0; i < keys.length; i++) {
657
+ const matcher = keys[i];
658
+ if (key === matcher || (matcher.endsWith('.') && key.startsWith(matcher))) {
659
+ return i;
660
+ }
661
+ }
662
+ return keys.length;
663
+ }
664
+
665
+ /**
666
+ * Lazy-initialized map from setting key → declaration index in buildEntries.
667
+ * Used as a tiebreaker when multiple keys share the same prefix group index.
668
+ */
669
+ let _registryKeyOrder: Map<string, number> | undefined;
670
+ function registryKeyOrder(): Map<string, number> {
671
+ if (!_registryKeyOrder) {
672
+ _registryKeyOrder = new Map();
673
+ const keys = Object.keys(buildEntries(DEFAULT_REGISTRY_CONFIG));
674
+ for (let i = 0; i < keys.length; i++) {
675
+ _registryKeyOrder.set(keys[i], i);
676
+ }
677
+ }
678
+ return _registryKeyOrder;
679
+ }
680
+
650
681
  /**
651
682
  * Group parsed setting entries into user-facing groups matching the mobile
652
683
  * app's settings menu structure.
@@ -674,6 +705,14 @@ export function groupSettingsForDevice(
674
705
  if (!isChronological && s.key.startsWith('chronologicalEventForm.')) {
675
706
  return false;
676
707
  }
708
+ // Per-setting calendar type restriction
709
+ if (s.calendarType && s.calendarType !== calendarType) {
710
+ return false;
711
+ }
712
+ // Per-setting app mode restriction
713
+ if (s.appMode && s.appMode !== appMode) {
714
+ return false;
715
+ }
677
716
  return true;
678
717
  });
679
718
 
@@ -703,6 +742,18 @@ export function groupSettingsForDevice(
703
742
  continue;
704
743
  }
705
744
 
745
+ // Sort matched settings by the order defined in group.keys,
746
+ // with registry declaration order as tiebreaker for prefix matches
747
+ const order = registryKeyOrder();
748
+ matched.sort((a, b) => {
749
+ const aIdx = groupKeyIndex(a.key, group.keys);
750
+ const bIdx = groupKeyIndex(b.key, group.keys);
751
+ if (aIdx !== bIdx) {
752
+ return aIdx - bIdx;
753
+ }
754
+ return (order.get(a.key) ?? Infinity) - (order.get(b.key) ?? Infinity);
755
+ });
756
+
706
757
  for (const s of matched) {
707
758
  claimed.add(s.key);
708
759
  }
@@ -740,6 +791,10 @@ export type SettingDef<T = unknown> = {
740
791
  options?: readonly SettingOption<T>[];
741
792
  /** Slider configuration for SLIDER-type settings */
742
793
  sliderConfig?: SliderConfig;
794
+ /** Only show this setting for enrolled/kiosk devices (undefined = always) */
795
+ appMode?: 'ENROLLED';
796
+ /** Only show this setting for a specific calendar type (undefined = always) */
797
+ calendarType?: CalendarType;
743
798
  };
744
799
 
745
800
  // ---------------------------------------------------------------------------
@@ -790,7 +845,7 @@ const ALARM_TIMEOUT_OPTIONS: readonly SettingOption<AlarmTimeout>[] = [
790
845
  const DAY_VIEW_ZOOM_OPTIONS: readonly SettingOption<CalendarDayViewCellZoom>[] = [
791
846
  { value: 15, labelKey: 'Settings.Option.DayViewZoom.15min' },
792
847
  { value: 30, labelKey: 'Settings.Option.DayViewZoom.30min' },
793
- { value: 60, labelKey: 'Settings.Option.DayViewZoom.60min' },
848
+ { value: 60, labelKey: 'Settings.Option.DayViewZoom.1hour' },
794
849
  ];
795
850
 
796
851
  const TIME_OF_DAY_SLIDER: SliderConfig = { min: 0, max: 23, step: 1 };
@@ -808,6 +863,8 @@ function def<T>(
808
863
  sync?: boolean;
809
864
  options?: readonly SettingOption<T>[];
810
865
  sliderConfig?: SliderConfig;
866
+ appMode?: 'ENROLLED';
867
+ calendarType?: CalendarType;
811
868
  },
812
869
  ): SettingDef<T> {
813
870
  const result: SettingDef<T> = {
@@ -823,6 +880,12 @@ function def<T>(
823
880
  if (extra?.sliderConfig) {
824
881
  result.sliderConfig = extra.sliderConfig;
825
882
  }
883
+ if (extra?.appMode) {
884
+ result.appMode = extra.appMode;
885
+ }
886
+ if (extra?.calendarType) {
887
+ result.calendarType = extra.calendarType;
888
+ }
826
889
  return result;
827
890
  }
828
891
 
@@ -858,6 +921,7 @@ function buildEntries(config: RegistryConfig) {
858
921
  { value: 'digital', labelKey: 'Settings.Option.ClockType.Digital' },
859
922
  { value: 'analog', labelKey: 'Settings.Option.ClockType.Analog' },
860
923
  ],
924
+ appMode: 'ENROLLED',
861
925
  },
862
926
  ),
863
927
  'appearance.enableDayColors': def<boolean>(
@@ -865,6 +929,7 @@ function buildEntries(config: RegistryConfig) {
865
929
  'boolean',
866
930
  false,
867
931
  'TOGGLE',
932
+ { calendarType: 'chronological' },
868
933
  ),
869
934
 
870
935
  // ═══════════════════════════════════════════════════════════════════════
@@ -925,14 +990,14 @@ function buildEntries(config: RegistryConfig) {
925
990
  'number',
926
991
  60,
927
992
  'SELECT',
928
- { options: DAY_VIEW_ZOOM_OPTIONS },
993
+ { options: DAY_VIEW_ZOOM_OPTIONS, calendarType: 'time-based' },
929
994
  ),
930
995
  'calendarView.weekViewZoom': def<CalendarDayViewCellZoom>(
931
996
  'calendarView',
932
997
  'number',
933
998
  60,
934
999
  'SELECT',
935
- { options: DAY_VIEW_ZOOM_OPTIONS },
1000
+ { options: DAY_VIEW_ZOOM_OPTIONS, calendarType: 'time-based' },
936
1001
  ),
937
1002
  'calendarView.splitView': def<boolean>(
938
1003
  'calendarView',
@@ -988,10 +1053,10 @@ function buildEntries(config: RegistryConfig) {
988
1053
  // Event form field visibility (time-based)
989
1054
  // ═══════════════════════════════════════════════════════════════════════
990
1055
  'eventForm.recurrence': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
991
- 'eventForm.reminders': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
992
- 'eventForm.emailReminders': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
993
1056
  'eventForm.location': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
994
1057
  'eventForm.travelTime': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
1058
+ 'eventForm.reminders': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1059
+ 'eventForm.emailReminders': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
995
1060
  'eventForm.description': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
996
1061
  'eventForm.checklist': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
997
1062
  'eventForm.images': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
@@ -1008,8 +1073,8 @@ function buildEntries(config: RegistryConfig) {
1008
1073
  // Sound & alerts
1009
1074
  // ═══════════════════════════════════════════════════════════════════════
1010
1075
  'sound.timerVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER'),
1011
- 'sound.reminderVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER'),
1012
- 'sound.mediaVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER'),
1076
+ 'sound.reminderVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER', { appMode: 'ENROLLED' }),
1077
+ 'sound.mediaVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER', { appMode: 'ENROLLED' }),
1013
1078
  'sound.alarmSound': def<AlarmSound>(
1014
1079
  'sound',
1015
1080
  'string',
@@ -1056,27 +1121,27 @@ function buildEntries(config: RegistryConfig) {
1056
1121
  'boolean',
1057
1122
  config.isEnrolled,
1058
1123
  'TOGGLE',
1124
+ { appMode: 'ENROLLED' },
1059
1125
  ),
1060
1126
  'sound.ttsRate': def<number>(
1061
1127
  'sound',
1062
1128
  'number',
1063
1129
  1.0,
1064
1130
  'SLIDER',
1065
- { sliderConfig: { min: 0.5, max: 2, step: 0.1 } },
1131
+ { sliderConfig: { min: 0.5, max: 2, step: 0.1 }, appMode: 'ENROLLED' },
1066
1132
  ),
1067
1133
 
1068
1134
  // ═══════════════════════════════════════════════════════════════════════
1069
1135
  // Timer
1070
1136
  // ═══════════════════════════════════════════════════════════════════════
1071
1137
  'timer.showTimeRemaining': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1072
- 'timer.showEndTime': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1138
+ 'timer.showEndTime': def<boolean>('timer', 'boolean', true, 'TOGGLE', { calendarType: 'time-based' }),
1073
1139
  'timer.showRestartButton': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1074
- 'timer.showPauseButton': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1140
+ 'timer.showPauseButton': def<boolean>('timer', 'boolean', true, 'TOGGLE', { calendarType: 'time-based' }),
1075
1141
 
1076
1142
  // ═══════════════════════════════════════════════════════════════════════
1077
1143
  // Lock screen
1078
1144
  // ═══════════════════════════════════════════════════════════════════════
1079
- 'lockScreen.pin': def<string>('lockScreen', 'string', '', 'PIN_INPUT'),
1080
1145
  'lockScreen.inactivityLockEnabled': def<boolean>(
1081
1146
  'lockScreen',
1082
1147
  'boolean',
@@ -1099,6 +1164,7 @@ function buildEntries(config: RegistryConfig) {
1099
1164
  ],
1100
1165
  },
1101
1166
  ),
1167
+ 'lockScreen.pin': def<string>('lockScreen', 'string', '', 'PIN_INPUT'),
1102
1168
  'lockScreen.clockDisplay': def<LockScreenClockDisplay>(
1103
1169
  'lockScreen',
1104
1170
  'string',
@@ -1118,13 +1184,13 @@ function buildEntries(config: RegistryConfig) {
1118
1184
  ],
1119
1185
  },
1120
1186
  ),
1121
- 'lockScreen.showDate': def<boolean>(
1187
+ 'lockScreen.showHourNumbers': def<boolean>(
1122
1188
  'lockScreen',
1123
1189
  'boolean',
1124
1190
  true,
1125
1191
  'TOGGLE',
1126
1192
  ),
1127
- 'lockScreen.showHourNumbers': def<boolean>(
1193
+ 'lockScreen.showDate': def<boolean>(
1128
1194
  'lockScreen',
1129
1195
  'boolean',
1130
1196
  true,
@@ -1155,12 +1221,6 @@ function buildEntries(config: RegistryConfig) {
1155
1221
  null,
1156
1222
  'CUSTOM_IMAGE',
1157
1223
  ),
1158
- 'lockScreen.photoFrameImages': def<LockScreenImage[]>(
1159
- 'lockScreen',
1160
- 'json',
1161
- [],
1162
- 'CUSTOM_IMAGE_ARRAY',
1163
- ),
1164
1224
  'lockScreen.photoFrameIntervalSeconds': def<PhotoFrameIntervalSeconds>(
1165
1225
  'lockScreen',
1166
1226
  'number',
@@ -1176,6 +1236,13 @@ function buildEntries(config: RegistryConfig) {
1176
1236
  },
1177
1237
  ),
1178
1238
 
1239
+ 'lockScreen.photoFrameImages': def<LockScreenImage[]>(
1240
+ 'lockScreen',
1241
+ 'json',
1242
+ [],
1243
+ 'CUSTOM_IMAGE_ARRAY',
1244
+ ),
1245
+
1179
1246
  // ═══════════════════════════════════════════════════════════════════════
1180
1247
  // Touch / gestures
1181
1248
  // ═══════════════════════════════════════════════════════════════════════
@@ -1271,16 +1338,16 @@ function buildEntries(config: RegistryConfig) {
1271
1338
  true,
1272
1339
  'TOGGLE',
1273
1340
  ),
1274
- 'chronological.header.showClock': def<boolean>(
1341
+ 'chronological.header.showCurrentYearInDate': def<boolean>(
1275
1342
  'chronological',
1276
1343
  'boolean',
1277
- true,
1344
+ false,
1278
1345
  'TOGGLE',
1279
1346
  ),
1280
- 'chronological.header.showCurrentYearInDate': def<boolean>(
1347
+ 'chronological.header.showClock': def<boolean>(
1281
1348
  'chronological',
1282
1349
  'boolean',
1283
- false,
1350
+ true,
1284
1351
  'TOGGLE',
1285
1352
  ),
1286
1353
  'chronological.header.showTimeOfDay': def<boolean>(
@@ -1326,6 +1393,12 @@ function buildEntries(config: RegistryConfig) {
1326
1393
  true,
1327
1394
  'TOGGLE',
1328
1395
  ),
1396
+ 'chronological.footer.showNowButton': def<boolean>(
1397
+ 'chronological',
1398
+ 'boolean',
1399
+ true,
1400
+ 'TOGGLE',
1401
+ ),
1329
1402
  'chronological.footer.showSettingsButton': def<boolean>(
1330
1403
  'chronological',
1331
1404
  'boolean',
@@ -1345,19 +1418,19 @@ function buildEntries(config: RegistryConfig) {
1345
1418
  true,
1346
1419
  'TOGGLE',
1347
1420
  ),
1348
- 'chronological.timer.showEditTemplate': def<boolean>(
1421
+ 'chronological.timer.showAddTemplate': def<boolean>(
1349
1422
  'chronological',
1350
1423
  'boolean',
1351
1424
  true,
1352
1425
  'TOGGLE',
1353
1426
  ),
1354
- 'chronological.timer.showDeleteTemplate': def<boolean>(
1427
+ 'chronological.timer.showEditTemplate': def<boolean>(
1355
1428
  'chronological',
1356
1429
  'boolean',
1357
1430
  true,
1358
1431
  'TOGGLE',
1359
1432
  ),
1360
- 'chronological.timer.showAddTemplate': def<boolean>(
1433
+ 'chronological.timer.showDeleteTemplate': def<boolean>(
1361
1434
  'chronological',
1362
1435
  'boolean',
1363
1436
  true,
@@ -1441,63 +1514,63 @@ function buildEntries(config: RegistryConfig) {
1441
1514
  // ═══════════════════════════════════════════════════════════════════════
1442
1515
  // Chronological event form
1443
1516
  // ═══════════════════════════════════════════════════════════════════════
1444
- // Toggleable fields
1445
- 'chronologicalEventForm.field.description': def<boolean>(
1517
+ // Fixed fields
1518
+ 'chronologicalEventForm.fixedField.allDay': def<boolean>(
1446
1519
  'chronologicalEventForm',
1447
1520
  'boolean',
1448
1521
  true,
1449
1522
  'TOGGLE',
1450
1523
  ),
1451
- 'chronologicalEventForm.field.recurrence': def<boolean>(
1524
+ 'chronologicalEventForm.fixedField.endTime': def<boolean>(
1452
1525
  'chronologicalEventForm',
1453
1526
  'boolean',
1454
1527
  true,
1455
1528
  'TOGGLE',
1456
1529
  ),
1457
- 'chronologicalEventForm.field.acknowledge': def<boolean>(
1530
+ 'chronologicalEventForm.fixedField.visibility': def<boolean>(
1458
1531
  'chronologicalEventForm',
1459
1532
  'boolean',
1460
1533
  true,
1461
1534
  'TOGGLE',
1462
1535
  ),
1463
- 'chronologicalEventForm.field.checklist': def<boolean>(
1536
+ // Toggleable fields
1537
+ 'chronologicalEventForm.field.acknowledge': def<boolean>(
1464
1538
  'chronologicalEventForm',
1465
1539
  'boolean',
1466
1540
  true,
1467
1541
  'TOGGLE',
1468
1542
  ),
1469
- 'chronologicalEventForm.field.extraImages': def<boolean>(
1543
+ 'chronologicalEventForm.field.description': def<boolean>(
1470
1544
  'chronologicalEventForm',
1471
1545
  'boolean',
1472
1546
  true,
1473
1547
  'TOGGLE',
1474
1548
  ),
1475
- 'chronologicalEventForm.field.reminders': def<boolean>(
1549
+ 'chronologicalEventForm.field.recurrence': def<boolean>(
1476
1550
  'chronologicalEventForm',
1477
1551
  'boolean',
1478
1552
  true,
1479
1553
  'TOGGLE',
1480
1554
  ),
1481
- 'chronologicalEventForm.field.audioClips': def<boolean>(
1555
+ 'chronologicalEventForm.field.checklist': def<boolean>(
1482
1556
  'chronologicalEventForm',
1483
1557
  'boolean',
1484
1558
  true,
1485
1559
  'TOGGLE',
1486
1560
  ),
1487
- // Fixed fields
1488
- 'chronologicalEventForm.fixedField.allDay': def<boolean>(
1561
+ 'chronologicalEventForm.field.extraImages': def<boolean>(
1489
1562
  'chronologicalEventForm',
1490
1563
  'boolean',
1491
1564
  true,
1492
1565
  'TOGGLE',
1493
1566
  ),
1494
- 'chronologicalEventForm.fixedField.endTime': def<boolean>(
1567
+ 'chronologicalEventForm.field.reminders': def<boolean>(
1495
1568
  'chronologicalEventForm',
1496
1569
  'boolean',
1497
1570
  true,
1498
1571
  'TOGGLE',
1499
1572
  ),
1500
- 'chronologicalEventForm.fixedField.visibility': def<boolean>(
1573
+ 'chronologicalEventForm.field.audioClips': def<boolean>(
1501
1574
  'chronologicalEventForm',
1502
1575
  'boolean',
1503
1576
  true,
@@ -1641,8 +1714,21 @@ export class SettingsRegistry {
1641
1714
  case 'number':
1642
1715
  return String(value ?? 0);
1643
1716
  case 'boolean':
1717
+ if (typeof value === 'string') {
1718
+ return value === 'true' ? 'true' : 'false';
1719
+ }
1644
1720
  return value ? 'true' : 'false';
1645
1721
  case 'json':
1722
+ // If already a serialized JSON string, return as-is to avoid
1723
+ // double-wrapping (e.g. JSON.stringify("[]") → "\"[]\"")
1724
+ if (typeof value === 'string') {
1725
+ try {
1726
+ JSON.parse(value);
1727
+ return value;
1728
+ } catch {
1729
+ return JSON.stringify(value);
1730
+ }
1731
+ }
1646
1732
  return JSON.stringify(value);
1647
1733
  }
1648
1734
  }
@@ -1666,7 +1752,17 @@ export class SettingsRegistry {
1666
1752
  return (raw === 'true') as SettingValue<K>;
1667
1753
  case 'json':
1668
1754
  try {
1669
- return JSON.parse(raw) as SettingValue<K>;
1755
+ let result: unknown = JSON.parse(raw);
1756
+ // Unwrap multiply-escaped JSON strings caused by repeated
1757
+ // double-serialization (each push/pull cycle added a layer)
1758
+ while (typeof result === 'string') {
1759
+ try {
1760
+ result = JSON.parse(result);
1761
+ } catch {
1762
+ break;
1763
+ }
1764
+ }
1765
+ return result as SettingValue<K>;
1670
1766
  } catch {
1671
1767
  return this.getDefault(key);
1672
1768
  }
@@ -1731,6 +1827,10 @@ export type ParsedSettingEntry = {
1731
1827
  options?: readonly SettingOption[];
1732
1828
  /** Slider configuration for SLIDER-type settings */
1733
1829
  sliderConfig?: SliderConfig;
1830
+ /** Only show this setting for enrolled/kiosk devices (undefined = always) */
1831
+ appMode?: 'ENROLLED';
1832
+ /** Only show this setting for a specific calendar type (undefined = always) */
1833
+ calendarType?: CalendarType;
1734
1834
  };
1735
1835
 
1736
1836
  export type ParsedSettingsGroup = {
@@ -1802,6 +1902,8 @@ export function parseSettingsSnapshot(
1802
1902
  uiType: entryDef?.uiType ?? 'TEXT_INPUT',
1803
1903
  ...(entryDef?.options && { options: entryDef.options as readonly SettingOption[] }),
1804
1904
  ...(entryDef?.sliderConfig && { sliderConfig: entryDef.sliderConfig }),
1905
+ ...(entryDef?.appMode && { appMode: entryDef.appMode }),
1906
+ ...(entryDef?.calendarType && { calendarType: entryDef.calendarType }),
1805
1907
  });
1806
1908
  }
1807
1909
 
@@ -1845,6 +1947,51 @@ export function formatSettingValue(value: unknown): string {
1845
1947
  return String(value);
1846
1948
  }
1847
1949
 
1950
+ /**
1951
+ * Serialize a settings object (with mixed native types) into a
1952
+ * string-values-only snapshot suitable for pushing to a mobile device.
1953
+ *
1954
+ * Uses the registry's `serialize()` method for known keys so that
1955
+ * booleans become "true"/"false", numbers become digit strings, etc.
1956
+ * Unknown keys are converted with `String(value)`.
1957
+ */
1958
+ export function serializeSettingsSnapshot(
1959
+ settings: Record<string, unknown>,
1960
+ registry: SettingsRegistry,
1961
+ ): Record<string, string> {
1962
+ const result: Record<string, string> = {};
1963
+ for (const [key, value] of Object.entries(settings)) {
1964
+ if (key in registry.entries) {
1965
+ result[key] = registry.serialize(key as SettingKey, value);
1966
+ } else {
1967
+ result[key] = String(value ?? '');
1968
+ }
1969
+ }
1970
+ return result;
1971
+ }
1972
+
1973
+ /**
1974
+ * Deserialize a settings snapshot (string values from the server/DB) into
1975
+ * native-typed values using the registry.
1976
+ *
1977
+ * This ensures local state always contains native types (boolean, number, etc.)
1978
+ * so that subsequent `serializeSettingsSnapshot` calls produce correct results.
1979
+ */
1980
+ export function deserializeSettingsSnapshot(
1981
+ snapshot: Record<string, unknown>,
1982
+ registry: SettingsRegistry = defaultRegistry,
1983
+ ): Record<string, unknown> {
1984
+ const result: Record<string, unknown> = {};
1985
+ for (const [key, value] of Object.entries(snapshot)) {
1986
+ if (typeof value === 'string' && key in registry.entries) {
1987
+ result[key] = registry.deserialize(key as SettingKey, value);
1988
+ } else {
1989
+ result[key] = value;
1990
+ }
1991
+ }
1992
+ return result;
1993
+ }
1994
+
1848
1995
  // ---------------------------------------------------------------------------
1849
1996
  // Default registry instance (non-enrolled, system theme, nb locale)
1850
1997
  // ---------------------------------------------------------------------------