@morscherlab/mint-sdk 1.0.0-beta.7 → 1.0.0-rc.2

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 (163) hide show
  1. package/README.md +9 -1
  2. package/dist/__tests__/components/LcmsSequenceTable.test.d.ts +1 -0
  3. package/dist/__tests__/components/ProgressBar.test.d.ts +1 -0
  4. package/dist/__tests__/components/RackEditor.test.d.ts +1 -0
  5. package/dist/__tests__/components/SequenceProgressBar.test.d.ts +1 -0
  6. package/dist/__tests__/composables/useExperimentSamples.test.d.ts +1 -0
  7. package/dist/__tests__/composables/useProtocolTemplates.test.d.ts +1 -0
  8. package/dist/__tests__/stores/settings.test.d.ts +1 -0
  9. package/dist/__tests__/utils/instrument.test.d.ts +1 -0
  10. package/dist/__tests__/utils/lcms.test.d.ts +1 -0
  11. package/dist/__tests__/utils/permissions.test.d.ts +1 -0
  12. package/dist/__tests__/utils/rack.test.d.ts +1 -0
  13. package/dist/{auth-QQj2kkze.js → auth-B7g4J4ZF.js} +148 -24
  14. package/dist/auth-B7g4J4ZF.js.map +1 -0
  15. package/dist/components/AutoGroupModal.vue.d.ts +1 -1
  16. package/dist/components/BaseCheckbox.vue.d.ts +1 -1
  17. package/dist/components/BaseToggle.vue.d.ts +2 -2
  18. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
  19. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +1 -1
  20. package/dist/components/BioTemplatePresetWorkspaceView.vue.d.ts +1 -1
  21. package/dist/components/DoseDesignWorkspaceView.vue.d.ts +1 -1
  22. package/dist/components/FormulaInput.vue.d.ts +1 -1
  23. package/dist/components/InstrumentAlertLog.vue.d.ts +22 -0
  24. package/dist/components/InstrumentStateBadge.vue.d.ts +11 -0
  25. package/dist/components/InstrumentStatusCard.vue.d.ts +13 -0
  26. package/dist/components/LcmsSequenceTable.vue.d.ts +26 -0
  27. package/dist/components/ProgressBar.vue.d.ts +1 -0
  28. package/dist/components/RackEditor.vue.d.ts +41 -3
  29. package/dist/components/ReagentList.vue.d.ts +1 -1
  30. package/dist/components/SampleSelector.vue.d.ts +5 -2
  31. package/dist/components/SegmentedControl.vue.d.ts +2 -0
  32. package/dist/components/SequenceInput.vue.d.ts +1 -1
  33. package/dist/components/SequenceProgressBar.vue.d.ts +15 -0
  34. package/dist/components/SettingsModal.vue.d.ts +8 -1
  35. package/dist/components/TagsInput.vue.d.ts +1 -1
  36. package/dist/components/WellPlate.vue.d.ts +42 -3
  37. package/dist/components/index.d.ts +5 -0
  38. package/dist/components/index.js +3 -3
  39. package/dist/{components-DihbSJjU.js → components-BhK-dW99.js} +2135 -1075
  40. package/dist/components-BhK-dW99.js.map +1 -0
  41. package/dist/composables/experimentDesignData.d.ts +17 -0
  42. package/dist/composables/index.d.ts +2 -0
  43. package/dist/composables/index.js +4 -4
  44. package/dist/composables/useControlSchema.d.ts +11 -0
  45. package/dist/composables/useExperimentData.d.ts +11 -3
  46. package/dist/composables/useExperimentSamples.d.ts +42 -0
  47. package/dist/composables/usePlatformContext.d.ts +54 -0
  48. package/dist/{composables-BcgZ6diz.js → composables-Bg7CFuNz.js} +5 -3
  49. package/dist/composables-Bg7CFuNz.js.map +1 -0
  50. package/dist/index.d.ts +4 -0
  51. package/dist/index.js +168 -6
  52. package/dist/index.js.map +1 -0
  53. package/dist/install.js +2 -2
  54. package/dist/instrument.d.ts +7 -0
  55. package/dist/lcms.d.ts +27 -0
  56. package/dist/permissions.d.ts +46 -0
  57. package/dist/stores/auth.d.ts +74 -2
  58. package/dist/stores/index.js +1 -1
  59. package/dist/styles.css +3186 -1070
  60. package/dist/templates/builders.d.ts +7 -3
  61. package/dist/templates/index.d.ts +2 -2
  62. package/dist/templates/index.js +2 -2
  63. package/dist/templates/presets.d.ts +12 -0
  64. package/dist/templates/types.d.ts +16 -1
  65. package/dist/{templates-Cyt0Suwf.js → templates-BorLR_7p.js} +324 -10
  66. package/dist/templates-BorLR_7p.js.map +1 -0
  67. package/dist/types/auth.d.ts +2 -0
  68. package/dist/types/components.d.ts +32 -3
  69. package/dist/types/form-builder.d.ts +2 -1
  70. package/dist/types/index.d.ts +4 -1
  71. package/dist/types/instrument.d.ts +56 -0
  72. package/dist/types/platform.d.ts +3 -0
  73. package/dist/{useExperimentData-CM6Y0u5L.js → useProtocolTemplates-n6AJqSqv.js} +627 -380
  74. package/dist/useProtocolTemplates-n6AJqSqv.js.map +1 -0
  75. package/dist/utils/rack.d.ts +47 -0
  76. package/package.json +1 -1
  77. package/src/__tests__/components/AppTopBar.test.ts +15 -0
  78. package/src/__tests__/components/BaseTabs.test.ts +15 -0
  79. package/src/__tests__/components/GroupAssigner.test.ts +18 -0
  80. package/src/__tests__/components/LcmsSequenceTable.test.ts +57 -0
  81. package/src/__tests__/components/ProgressBar.test.ts +18 -0
  82. package/src/__tests__/components/RackEditor.test.ts +125 -0
  83. package/src/__tests__/components/SampleSelector.test.ts +25 -0
  84. package/src/__tests__/components/SegmentedControl.test.ts +45 -0
  85. package/src/__tests__/components/SequenceProgressBar.test.ts +39 -0
  86. package/src/__tests__/components/SettingsModal.test.ts +83 -2
  87. package/src/__tests__/composables/useApi.test.ts +45 -0
  88. package/src/__tests__/composables/useAuth.test.ts +20 -0
  89. package/src/__tests__/composables/useControlSchema.test.ts +4 -0
  90. package/src/__tests__/composables/useExperimentData.test.ts +23 -0
  91. package/src/__tests__/composables/useExperimentSamples.test.ts +91 -0
  92. package/src/__tests__/composables/useProtocolTemplates.test.ts +64 -0
  93. package/src/__tests__/stores/settings.test.ts +78 -0
  94. package/src/__tests__/templates/templates.test.ts +86 -0
  95. package/src/__tests__/utils/instrument.test.ts +47 -0
  96. package/src/__tests__/utils/lcms.test.ts +73 -0
  97. package/src/__tests__/utils/permissions.test.ts +50 -0
  98. package/src/__tests__/utils/rack.test.ts +120 -0
  99. package/src/components/AppAvatarMenu.vue +6 -3
  100. package/src/components/AppTopBar.vue +16 -10
  101. package/src/components/AuditTrail.vue +1 -1
  102. package/src/components/BaseTabs.vue +22 -1
  103. package/src/components/Calendar.vue +6 -2
  104. package/src/components/ConcentrationInput.vue +3 -2
  105. package/src/components/GroupAssigner.vue +8 -3
  106. package/src/components/InstrumentAlertLog.vue +191 -0
  107. package/src/components/InstrumentStateBadge.vue +50 -0
  108. package/src/components/InstrumentStatusCard.vue +188 -0
  109. package/src/components/LcmsSequenceTable.vue +191 -0
  110. package/src/components/NumberInput.vue +5 -3
  111. package/src/components/ProgressBar.vue +3 -0
  112. package/src/components/RackEditor.vue +73 -2
  113. package/src/components/SampleHierarchyTree.vue +3 -2
  114. package/src/components/SampleSelector.vue +28 -9
  115. package/src/components/SegmentedControl.story.vue +17 -0
  116. package/src/components/SegmentedControl.vue +14 -3
  117. package/src/components/SequenceProgressBar.vue +71 -0
  118. package/src/components/SettingsModal.vue +49 -2
  119. package/src/components/UnitInput.vue +6 -2
  120. package/src/components/WellPlate.vue +145 -24
  121. package/src/components/index.ts +5 -0
  122. package/src/components/internal/WellEditPopupInternal.vue +1 -0
  123. package/src/composables/experimentDesignData.ts +182 -0
  124. package/src/composables/index.ts +14 -0
  125. package/src/composables/useApi.ts +113 -16
  126. package/src/composables/useAuth.ts +4 -0
  127. package/src/composables/useAutoGroup.ts +18 -9
  128. package/src/composables/useControlSchema.ts +21 -0
  129. package/src/composables/useExperimentData.ts +57 -16
  130. package/src/composables/useExperimentSamples.ts +142 -0
  131. package/src/composables/useProtocolTemplates.ts +13 -1
  132. package/src/composables/useRackEditor.ts +3 -2
  133. package/src/index.ts +27 -0
  134. package/src/instrument.ts +90 -0
  135. package/src/lcms.ts +108 -0
  136. package/src/permissions.ts +143 -0
  137. package/src/stores/auth.ts +79 -26
  138. package/src/stores/settings.ts +10 -0
  139. package/src/styles/components/instrument-monitor.css +478 -0
  140. package/src/styles/components/lcms-sequence-table.css +189 -0
  141. package/src/styles/components/sequence-progress-bar.css +63 -0
  142. package/src/styles/components/settings-modal.css +9 -0
  143. package/src/styles/components/tabs.css +9 -0
  144. package/src/styles/components/well-edit-popup.css +7 -1
  145. package/src/styles/components/well-plate.css +5 -0
  146. package/src/styles/index.css +3 -0
  147. package/src/templates/builders.ts +201 -0
  148. package/src/templates/controlSchemas.ts +68 -0
  149. package/src/templates/index.ts +2 -0
  150. package/src/templates/presets.ts +23 -0
  151. package/src/templates/types.ts +17 -0
  152. package/src/types/auth.ts +3 -0
  153. package/src/types/components.ts +45 -3
  154. package/src/types/form-builder.ts +2 -1
  155. package/src/types/index.ts +35 -0
  156. package/src/types/instrument.ts +61 -0
  157. package/src/types/platform.ts +4 -0
  158. package/src/utils/rack.ts +209 -0
  159. package/dist/auth-QQj2kkze.js.map +0 -1
  160. package/dist/components-DihbSJjU.js.map +0 -1
  161. package/dist/composables-BcgZ6diz.js.map +0 -1
  162. package/dist/templates-Cyt0Suwf.js.map +0 -1
  163. package/dist/useExperimentData-CM6Y0u5L.js.map +0 -1
@@ -0,0 +1,143 @@
1
+ export const ADMIN_ROLE = 'admin'
2
+
3
+ export const ADMIN_PANEL_PERMISSIONS = [
4
+ 'users.view',
5
+ 'users.invite',
6
+ 'users.manage',
7
+ 'platform.configure',
8
+ 'platform.view_logs',
9
+ 'plugins.configure',
10
+ 'plugins.install',
11
+ ] as const
12
+
13
+ export type AccessAudience = 'all' | 'admin' | 'user'
14
+ export type AccessAudienceInput = AccessAudience | readonly AccessAudience[]
15
+
16
+ export interface RoleInfo {
17
+ id?: number
18
+ slug: string
19
+ name?: string
20
+ color?: string
21
+ permissions?: readonly string[] | null
22
+ project_scope?: 'all' | 'assigned' | string
23
+ plugin_access?: readonly string[] | 'all' | null
24
+ }
25
+
26
+ export interface PermissionUser {
27
+ role?: string | null
28
+ role_obj?: RoleInfo | null
29
+ roleObj?: RoleInfo | null
30
+ permissions?: readonly string[] | null
31
+ }
32
+
33
+ export interface AccessPolicy {
34
+ audience?: AccessAudienceInput
35
+ visibleFor?: AccessAudienceInput
36
+ requiresAuth?: boolean
37
+ requiresAdmin?: boolean
38
+ permissions?: readonly string[]
39
+ anyPermissions?: readonly string[]
40
+ plugin?: string
41
+ }
42
+
43
+ export interface AccessControlled {
44
+ access?: AccessPolicy
45
+ visibleFor?: AccessAudienceInput
46
+ requiresAdmin?: boolean
47
+ permissions?: readonly string[]
48
+ anyPermissions?: readonly string[]
49
+ }
50
+
51
+ export function getRoleInfo(user: PermissionUser | null | undefined): RoleInfo | null {
52
+ return user?.roleObj ?? user?.role_obj ?? null
53
+ }
54
+
55
+ export function isAdminRole(role: string | null | undefined): boolean {
56
+ return role === ADMIN_ROLE
57
+ }
58
+
59
+ export function isAdminUser(user: PermissionUser | null | undefined): boolean {
60
+ return isAdminRole(user?.role) || getRoleInfo(user)?.slug === ADMIN_ROLE
61
+ }
62
+
63
+ export function getAccessAudience(user: PermissionUser | null | undefined): Exclude<AccessAudience, 'all'> {
64
+ return isAdminUser(user) ? 'admin' : 'user'
65
+ }
66
+
67
+ export function getUserPermissions(user: PermissionUser | null | undefined): string[] {
68
+ const rolePermissions = getRoleInfo(user)?.permissions
69
+ if (rolePermissions?.length) return [...rolePermissions]
70
+ return [...(user?.permissions ?? [])]
71
+ }
72
+
73
+ export function hasAllPermissions(
74
+ user: PermissionUser | null | undefined,
75
+ permissions: readonly string[] = [],
76
+ ): boolean {
77
+ if (permissions.length === 0) return true
78
+ if (isAdminUser(user)) return true
79
+ const granted = new Set(getUserPermissions(user))
80
+ return permissions.every(permission => granted.has(permission))
81
+ }
82
+
83
+ export function hasAnyPermission(
84
+ user: PermissionUser | null | undefined,
85
+ permissions: readonly string[] = [],
86
+ ): boolean {
87
+ if (permissions.length === 0) return true
88
+ if (isAdminUser(user)) return true
89
+ const granted = new Set(getUserPermissions(user))
90
+ return permissions.some(permission => granted.has(permission))
91
+ }
92
+
93
+ export function canAccessAdmin(user: PermissionUser | null | undefined): boolean {
94
+ return isAdminUser(user) || hasAnyPermission(user, ADMIN_PANEL_PERMISSIONS)
95
+ }
96
+
97
+ export function canAccessPlugin(
98
+ user: PermissionUser | null | undefined,
99
+ pluginName: string | null | undefined,
100
+ ): boolean {
101
+ if (!pluginName) return true
102
+ const access = getRoleInfo(user)?.plugin_access
103
+ if (!access || access === 'all') return true
104
+ return Array.isArray(access) && access.includes(pluginName)
105
+ }
106
+
107
+ export function normalizeAccessPolicy(rule: AccessControlled | AccessPolicy | undefined): AccessPolicy {
108
+ if (!rule) return {}
109
+ const { access: nested, ...direct } = rule as AccessPolicy & { access?: AccessPolicy }
110
+ return {
111
+ ...(nested ?? {}),
112
+ ...direct,
113
+ ...('visibleFor' in rule && rule.visibleFor !== undefined ? { visibleFor: rule.visibleFor } : {}),
114
+ ...('requiresAdmin' in rule && rule.requiresAdmin !== undefined ? { requiresAdmin: rule.requiresAdmin } : {}),
115
+ ...('permissions' in rule && rule.permissions !== undefined ? { permissions: rule.permissions } : {}),
116
+ ...('anyPermissions' in rule && rule.anyPermissions !== undefined ? { anyPermissions: rule.anyPermissions } : {}),
117
+ }
118
+ }
119
+
120
+ export function canAccessByPolicy(
121
+ user: PermissionUser | null | undefined,
122
+ rule: AccessControlled | AccessPolicy | undefined,
123
+ isAuthenticated = user !== null && user !== undefined,
124
+ ): boolean {
125
+ const policy = normalizeAccessPolicy(rule)
126
+ if (policy.requiresAuth && !isAuthenticated) return false
127
+ if (policy.requiresAdmin && !isAdminUser(user)) return false
128
+ if (!audienceAllowsUser(policy.visibleFor ?? policy.audience, user)) return false
129
+ if (!hasAllPermissions(user, policy.permissions)) return false
130
+ if (!hasAnyPermission(user, policy.anyPermissions)) return false
131
+ if (policy.plugin && !canAccessPlugin(user, policy.plugin)) return false
132
+ return true
133
+ }
134
+
135
+ function audienceAllowsUser(
136
+ audience: AccessAudienceInput | undefined,
137
+ user: PermissionUser | null | undefined,
138
+ ): boolean {
139
+ if (audience === undefined) return true
140
+ const allowed = Array.isArray(audience) ? audience : [audience]
141
+ if (allowed.includes('all')) return true
142
+ return allowed.includes(getAccessAudience(user))
143
+ }
@@ -1,11 +1,51 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref, computed } from 'vue'
3
3
  import type { AuthConfig, UserInfo } from '../types'
4
+ import {
5
+ canAccessAdmin as canUserAccessAdmin,
6
+ canAccessPlugin as canUserAccessPlugin,
7
+ getAccessAudience,
8
+ getUserPermissions,
9
+ hasAllPermissions,
10
+ hasAnyPermission,
11
+ isAdminUser,
12
+ } from '../permissions'
4
13
 
5
14
  const AUTH_TOKEN_KEY = 'mint-auth-token'
6
15
  const AUTH_EXPIRES_KEY = 'mint-auth-expires'
7
16
 
8
- const hasLocalStorage = typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function'
17
+ function getLocalStorage(): Storage | null {
18
+ try {
19
+ const storage = globalThis.localStorage
20
+ return typeof storage?.getItem === 'function' ? storage : null
21
+ } catch {
22
+ return null
23
+ }
24
+ }
25
+
26
+ function readStoredItem(key: string): string | null {
27
+ try {
28
+ return getLocalStorage()?.getItem(key) ?? null
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ function writeStoredItem(key: string, value: string): void {
35
+ try {
36
+ getLocalStorage()?.setItem(key, value)
37
+ } catch {
38
+ // Keep auth usable in-memory when browser storage is blocked.
39
+ }
40
+ }
41
+
42
+ function removeStoredItem(key: string): void {
43
+ try {
44
+ getLocalStorage()?.removeItem(key)
45
+ } catch {
46
+ // Keep auth cleanup usable in-memory when browser storage is blocked.
47
+ }
48
+ }
9
49
 
10
50
  export const useAuthStore = defineStore('mint-auth', () => {
11
51
  // State
@@ -42,9 +82,10 @@ export const useAuthStore = defineStore('mint-auth', () => {
42
82
  return authConfig.value.authRequired && !isAuthenticated.value
43
83
  })
44
84
 
45
- const isAdmin = computed(() => {
46
- return userInfo.value?.role === 'admin'
47
- })
85
+ const isAdmin = computed(() => isAdminUser(userInfo.value))
86
+ const userType = computed(() => getAccessAudience(userInfo.value))
87
+ const permissions = computed(() => getUserPermissions(userInfo.value))
88
+ const canAccessAdmin = computed(() => canUserAccessAdmin(userInfo.value))
48
89
 
49
90
  const canRegister = computed(() => {
50
91
  return authConfig.value.registrationEnabled
@@ -52,20 +93,18 @@ export const useAuthStore = defineStore('mint-auth', () => {
52
93
 
53
94
  // Actions
54
95
  function initialize() {
55
- if (hasLocalStorage) {
56
- const storedToken = localStorage.getItem(AUTH_TOKEN_KEY)
57
- const storedExpires = localStorage.getItem(AUTH_EXPIRES_KEY)
58
-
59
- if (storedToken) {
60
- token.value = storedToken
61
-
62
- if (storedExpires) {
63
- const expires = new Date(storedExpires)
64
- if (expires > new Date()) {
65
- tokenExpires.value = expires
66
- } else {
67
- clearToken()
68
- }
96
+ const storedToken = readStoredItem(AUTH_TOKEN_KEY)
97
+ const storedExpires = readStoredItem(AUTH_EXPIRES_KEY)
98
+
99
+ if (storedToken) {
100
+ token.value = storedToken
101
+
102
+ if (storedExpires) {
103
+ const expires = new Date(storedExpires)
104
+ if (expires > new Date()) {
105
+ tokenExpires.value = expires
106
+ } else {
107
+ clearToken()
69
108
  }
70
109
  }
71
110
  }
@@ -78,10 +117,8 @@ export const useAuthStore = defineStore('mint-auth', () => {
78
117
  const expires = new Date(Date.now() + expiresIn * 1000)
79
118
  tokenExpires.value = expires
80
119
 
81
- if (hasLocalStorage) {
82
- localStorage.setItem(AUTH_TOKEN_KEY, accessToken)
83
- localStorage.setItem(AUTH_EXPIRES_KEY, expires.toISOString())
84
- }
120
+ writeStoredItem(AUTH_TOKEN_KEY, accessToken)
121
+ writeStoredItem(AUTH_EXPIRES_KEY, expires.toISOString())
85
122
  }
86
123
 
87
124
  function clearToken() {
@@ -90,10 +127,8 @@ export const useAuthStore = defineStore('mint-auth', () => {
90
127
  username.value = null
91
128
  userInfo.value = null
92
129
 
93
- if (hasLocalStorage) {
94
- localStorage.removeItem(AUTH_TOKEN_KEY)
95
- localStorage.removeItem(AUTH_EXPIRES_KEY)
96
- }
130
+ removeStoredItem(AUTH_TOKEN_KEY)
131
+ removeStoredItem(AUTH_EXPIRES_KEY)
97
132
  }
98
133
 
99
134
  function setUserInfo(info: UserInfo) {
@@ -101,6 +136,18 @@ export const useAuthStore = defineStore('mint-auth', () => {
101
136
  username.value = info.username
102
137
  }
103
138
 
139
+ function hasPermission(...perms: string[]): boolean {
140
+ return hasAllPermissions(userInfo.value, perms)
141
+ }
142
+
143
+ function hasAnyPermissionForUser(...perms: string[]): boolean {
144
+ return hasAnyPermission(userInfo.value, perms)
145
+ }
146
+
147
+ function canAccessPlugin(pluginName: string): boolean {
148
+ return canUserAccessPlugin(userInfo.value, pluginName)
149
+ }
150
+
104
151
  function setAuthConfig(config: AuthConfig) {
105
152
  authConfig.value = config
106
153
  }
@@ -136,6 +183,9 @@ export const useAuthStore = defineStore('mint-auth', () => {
136
183
  isAuthenticated,
137
184
  needsAuth,
138
185
  isAdmin,
186
+ userType,
187
+ permissions,
188
+ canAccessAdmin,
139
189
  canRegister,
140
190
 
141
191
  // Actions
@@ -147,6 +197,9 @@ export const useAuthStore = defineStore('mint-auth', () => {
147
197
  setUserInfo,
148
198
  setError,
149
199
  setLoading,
200
+ hasPermission,
201
+ hasAnyPermission: hasAnyPermissionForUser,
202
+ canAccessPlugin,
150
203
  logout,
151
204
  }
152
205
  })
@@ -24,6 +24,7 @@ export interface SettingsState {
24
24
  }
25
25
 
26
26
  const STORAGE_KEY = 'mint-settings'
27
+ const LEGACY_STORAGE_KEY = 'mld-settings'
27
28
 
28
29
  function getDefaultServerHost(): string {
29
30
  if (typeof window !== 'undefined' && window.location.hostname !== 'localhost') {
@@ -68,6 +69,15 @@ function loadSettings(): SettingsState {
68
69
  const parsed = JSON.parse(stored)
69
70
  return { ...defaultSettings, ...parsed }
70
71
  }
72
+
73
+ const legacyStored = localStorage.getItem(LEGACY_STORAGE_KEY)
74
+ if (legacyStored) {
75
+ const parsed = JSON.parse(legacyStored)
76
+ const migrated = { ...defaultSettings, ...parsed }
77
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(migrated))
78
+ localStorage.removeItem(LEGACY_STORAGE_KEY)
79
+ return migrated
80
+ }
71
81
  } catch (e) {
72
82
  console.warn('Failed to load settings from localStorage:', e)
73
83
  }