@jskit-ai/users-web 0.1.17 → 0.1.20

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/users-web",
4
- version: "0.1.17",
4
+ version: "0.1.20",
5
5
  description: "Users web module: workspace selector shell element plus workspace/profile/members UI elements.",
6
6
  dependsOn: [
7
7
  "@jskit-ai/http-runtime",
@@ -241,11 +241,11 @@ export default Object.freeze({
241
241
  "@uppy/dashboard": "^5.1.1",
242
242
  "@uppy/image-editor": "^4.2.0",
243
243
  "@uppy/xhr-upload": "^5.1.1",
244
- "@jskit-ai/http-runtime": "0.1.8",
245
- "@jskit-ai/realtime": "0.1.8",
246
- "@jskit-ai/kernel": "0.1.8",
247
- "@jskit-ai/shell-web": "0.1.8",
248
- "@jskit-ai/users-core": "0.1.13",
244
+ "@jskit-ai/http-runtime": "0.1.11",
245
+ "@jskit-ai/realtime": "0.1.11",
246
+ "@jskit-ai/kernel": "0.1.11",
247
+ "@jskit-ai/shell-web": "0.1.11",
248
+ "@jskit-ai/users-core": "0.1.16",
249
249
  "vuetify": "^4.0.0"
250
250
  },
251
251
  dev: {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.1.17",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -21,11 +21,11 @@
21
21
  "dependencies": {
22
22
  "@tanstack/vue-query": "5.92.12",
23
23
  "@mdi/js": "^7.4.47",
24
- "@jskit-ai/users-core": "0.1.13",
25
- "@jskit-ai/realtime": "0.1.8",
26
- "@jskit-ai/http-runtime": "0.1.8",
27
- "@jskit-ai/kernel": "0.1.8",
28
- "@jskit-ai/shell-web": "0.1.8",
24
+ "@jskit-ai/users-core": "0.1.16",
25
+ "@jskit-ai/realtime": "0.1.11",
26
+ "@jskit-ai/http-runtime": "0.1.11",
27
+ "@jskit-ai/kernel": "0.1.11",
28
+ "@jskit-ai/shell-web": "0.1.11",
29
29
  "vuetify": "^4.0.0"
30
30
  }
31
31
  }
@@ -50,53 +50,127 @@
50
50
 
51
51
  <v-col cols="12">
52
52
  <div class="text-subtitle-2 mb-2">Theme colors</div>
53
+ <v-row dense class="mb-2">
54
+ <v-col cols="12">
55
+ <div class="text-caption text-medium-emphasis mb-2">Light palette</div>
56
+ </v-col>
57
+ <v-col cols="12" md="3">
58
+ <v-text-field
59
+ v-model="workspaceForm.lightPrimaryColor"
60
+ label="Primary"
61
+ type="color"
62
+ variant="outlined"
63
+ density="comfortable"
64
+ :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
65
+ :error-messages="
66
+ addEdit.fieldErrors.lightPrimaryColor ? [addEdit.fieldErrors.lightPrimaryColor] : []
67
+ "
68
+ />
69
+ </v-col>
70
+
71
+ <v-col cols="12" md="3">
72
+ <v-text-field
73
+ v-model="workspaceForm.lightSecondaryColor"
74
+ label="Secondary"
75
+ type="color"
76
+ variant="outlined"
77
+ density="comfortable"
78
+ :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
79
+ :error-messages="
80
+ addEdit.fieldErrors.lightSecondaryColor ? [addEdit.fieldErrors.lightSecondaryColor] : []
81
+ "
82
+ />
83
+ </v-col>
84
+
85
+ <v-col cols="12" md="3">
86
+ <v-text-field
87
+ v-model="workspaceForm.lightSurfaceColor"
88
+ label="Surface"
89
+ type="color"
90
+ variant="outlined"
91
+ density="comfortable"
92
+ :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
93
+ :error-messages="
94
+ addEdit.fieldErrors.lightSurfaceColor ? [addEdit.fieldErrors.lightSurfaceColor] : []
95
+ "
96
+ />
97
+ </v-col>
98
+
99
+ <v-col cols="12" md="3">
100
+ <v-text-field
101
+ v-model="workspaceForm.lightSurfaceVariantColor"
102
+ label="Surface variant"
103
+ type="color"
104
+ variant="outlined"
105
+ density="comfortable"
106
+ :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
107
+ :error-messages="
108
+ addEdit.fieldErrors.lightSurfaceVariantColor
109
+ ? [addEdit.fieldErrors.lightSurfaceVariantColor]
110
+ : []
111
+ "
112
+ />
113
+ </v-col>
114
+ </v-row>
115
+
53
116
  <v-row dense>
117
+ <v-col cols="12">
118
+ <div class="text-caption text-medium-emphasis mb-2">Dark palette</div>
119
+ </v-col>
54
120
  <v-col cols="12" md="3">
55
121
  <v-text-field
56
- v-model="workspaceForm.color"
122
+ v-model="workspaceForm.darkPrimaryColor"
57
123
  label="Primary"
58
124
  type="color"
59
125
  variant="outlined"
60
126
  density="comfortable"
61
127
  :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
62
- :error-messages="addEdit.fieldErrors.color ? [addEdit.fieldErrors.color] : []"
128
+ :error-messages="
129
+ addEdit.fieldErrors.darkPrimaryColor ? [addEdit.fieldErrors.darkPrimaryColor] : []
130
+ "
63
131
  />
64
132
  </v-col>
65
133
 
66
134
  <v-col cols="12" md="3">
67
135
  <v-text-field
68
- v-model="workspaceForm.secondaryColor"
136
+ v-model="workspaceForm.darkSecondaryColor"
69
137
  label="Secondary"
70
138
  type="color"
71
139
  variant="outlined"
72
140
  density="comfortable"
73
141
  :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
74
- :error-messages="addEdit.fieldErrors.secondaryColor ? [addEdit.fieldErrors.secondaryColor] : []"
142
+ :error-messages="
143
+ addEdit.fieldErrors.darkSecondaryColor ? [addEdit.fieldErrors.darkSecondaryColor] : []
144
+ "
75
145
  />
76
146
  </v-col>
77
147
 
78
148
  <v-col cols="12" md="3">
79
149
  <v-text-field
80
- v-model="workspaceForm.surfaceColor"
150
+ v-model="workspaceForm.darkSurfaceColor"
81
151
  label="Surface"
82
152
  type="color"
83
153
  variant="outlined"
84
154
  density="comfortable"
85
155
  :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
86
- :error-messages="addEdit.fieldErrors.surfaceColor ? [addEdit.fieldErrors.surfaceColor] : []"
156
+ :error-messages="
157
+ addEdit.fieldErrors.darkSurfaceColor ? [addEdit.fieldErrors.darkSurfaceColor] : []
158
+ "
87
159
  />
88
160
  </v-col>
89
161
 
90
162
  <v-col cols="12" md="3">
91
163
  <v-text-field
92
- v-model="workspaceForm.surfaceVariantColor"
164
+ v-model="workspaceForm.darkSurfaceVariantColor"
93
165
  label="Surface variant"
94
166
  type="color"
95
167
  variant="outlined"
96
168
  density="comfortable"
97
169
  :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
98
170
  :error-messages="
99
- addEdit.fieldErrors.surfaceVariantColor ? [addEdit.fieldErrors.surfaceVariantColor] : []
171
+ addEdit.fieldErrors.darkSurfaceVariantColor
172
+ ? [addEdit.fieldErrors.darkSurfaceVariantColor]
173
+ : []
100
174
  "
101
175
  />
102
176
  </v-col>
@@ -140,8 +214,9 @@ import { workspaceSettingsResource } from "@jskit-ai/users-core/shared/resources
140
214
  import { WORKSPACE_SETTINGS_CHANGED_EVENT } from "@jskit-ai/users-core/shared/events/usersEvents";
141
215
  import { USERS_ROUTE_VISIBILITY_WORKSPACE } from "@jskit-ai/users-core/shared/support/usersVisibility";
142
216
  import {
143
- DEFAULT_WORKSPACE_COLOR,
144
- resolveWorkspaceThemePalette
217
+ DEFAULT_WORKSPACE_DARK_PALETTE,
218
+ DEFAULT_WORKSPACE_LIGHT_PALETTE,
219
+ resolveWorkspaceThemePalettes
145
220
  } from "@jskit-ai/users-core/shared/settings";
146
221
  import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
147
222
  import { useAddEdit } from "../composables/useAddEdit.js";
@@ -152,16 +227,16 @@ import { arePermissionListsEqual, normalizePermissionList } from "../lib/permiss
152
227
  import { createWorkspaceRealtimeMatcher } from "../support/realtimeWorkspace.js";
153
228
  import { buildWorkspaceQueryKey } from "../support/workspaceQueryKeys.js";
154
229
 
155
- const DEFAULT_WORKSPACE_THEME = resolveWorkspaceThemePalette({
156
- color: DEFAULT_WORKSPACE_COLOR
157
- });
158
-
159
230
  const workspaceForm = reactive({
160
231
  name: "",
161
- color: DEFAULT_WORKSPACE_COLOR,
162
- secondaryColor: DEFAULT_WORKSPACE_THEME.secondaryColor,
163
- surfaceColor: DEFAULT_WORKSPACE_THEME.surfaceColor,
164
- surfaceVariantColor: DEFAULT_WORKSPACE_THEME.surfaceVariantColor,
232
+ lightPrimaryColor: DEFAULT_WORKSPACE_LIGHT_PALETTE.color,
233
+ lightSecondaryColor: DEFAULT_WORKSPACE_LIGHT_PALETTE.secondaryColor,
234
+ lightSurfaceColor: DEFAULT_WORKSPACE_LIGHT_PALETTE.surfaceColor,
235
+ lightSurfaceVariantColor: DEFAULT_WORKSPACE_LIGHT_PALETTE.surfaceVariantColor,
236
+ darkPrimaryColor: DEFAULT_WORKSPACE_DARK_PALETTE.color,
237
+ darkSecondaryColor: DEFAULT_WORKSPACE_DARK_PALETTE.secondaryColor,
238
+ darkSurfaceColor: DEFAULT_WORKSPACE_DARK_PALETTE.surfaceColor,
239
+ darkSurfaceVariantColor: DEFAULT_WORKSPACE_DARK_PALETTE.surfaceVariantColor,
165
240
  avatarUrl: "",
166
241
  invitesEnabled: false,
167
242
  invitesAvailable: false
@@ -195,12 +270,16 @@ function toWorkspaceSettingsSnapshot(settings = null) {
195
270
  return "";
196
271
  }
197
272
 
198
- const normalized = resolveWorkspaceThemePalette(settings);
273
+ const normalized = resolveWorkspaceThemePalettes(settings);
199
274
  return JSON.stringify({
200
- color: normalized.color,
201
- secondaryColor: normalized.secondaryColor,
202
- surfaceColor: normalized.surfaceColor,
203
- surfaceVariantColor: normalized.surfaceVariantColor,
275
+ lightPrimaryColor: normalized.light.color,
276
+ lightSecondaryColor: normalized.light.secondaryColor,
277
+ lightSurfaceColor: normalized.light.surfaceColor,
278
+ lightSurfaceVariantColor: normalized.light.surfaceVariantColor,
279
+ darkPrimaryColor: normalized.dark.color,
280
+ darkSecondaryColor: normalized.dark.secondaryColor,
281
+ darkSurfaceColor: normalized.dark.surfaceColor,
282
+ darkSurfaceVariantColor: normalized.dark.surfaceVariantColor,
204
283
  invitesEnabled: settings.invitesEnabled !== false,
205
284
  invitesAvailable: settings.invitesAvailable !== false
206
285
  });
@@ -267,7 +346,18 @@ const addEdit = useAddEdit({
267
346
  savePermissions: ["workspace.settings.update"],
268
347
  placementSource: "users-web.workspace-settings-view",
269
348
  fallbackLoadError: "Unable to load workspace settings.",
270
- fieldErrorKeys: ["name", "avatarUrl", "color", "secondaryColor", "surfaceColor", "surfaceVariantColor"],
349
+ fieldErrorKeys: [
350
+ "name",
351
+ "avatarUrl",
352
+ "lightPrimaryColor",
353
+ "lightSecondaryColor",
354
+ "lightSurfaceColor",
355
+ "lightSurfaceVariantColor",
356
+ "darkPrimaryColor",
357
+ "darkSecondaryColor",
358
+ "darkSurfaceColor",
359
+ "darkSurfaceVariantColor"
360
+ ],
271
361
  realtime: {
272
362
  event: WORKSPACE_SETTINGS_CHANGED_EVENT,
273
363
  matches: matchesWorkspaceRealtime
@@ -281,23 +371,31 @@ const addEdit = useAddEdit({
281
371
  }),
282
372
  mapLoadedToModel: (model, payload = {}) => {
283
373
  const settings = payload?.settings && typeof payload.settings === "object" ? payload.settings : {};
284
- const normalizedTheme = resolveWorkspaceThemePalette(settings);
374
+ const normalizedTheme = resolveWorkspaceThemePalettes(settings);
285
375
 
286
376
  model.name = String(settings.name || "");
287
- model.color = normalizedTheme.color;
288
- model.secondaryColor = normalizedTheme.secondaryColor;
289
- model.surfaceColor = normalizedTheme.surfaceColor;
290
- model.surfaceVariantColor = normalizedTheme.surfaceVariantColor;
377
+ model.lightPrimaryColor = normalizedTheme.light.color;
378
+ model.lightSecondaryColor = normalizedTheme.light.secondaryColor;
379
+ model.lightSurfaceColor = normalizedTheme.light.surfaceColor;
380
+ model.lightSurfaceVariantColor = normalizedTheme.light.surfaceVariantColor;
381
+ model.darkPrimaryColor = normalizedTheme.dark.color;
382
+ model.darkSecondaryColor = normalizedTheme.dark.secondaryColor;
383
+ model.darkSurfaceColor = normalizedTheme.dark.surfaceColor;
384
+ model.darkSurfaceVariantColor = normalizedTheme.dark.surfaceVariantColor;
291
385
  model.avatarUrl = String(settings.avatarUrl || "");
292
386
  model.invitesEnabled = settings.invitesEnabled !== false;
293
387
  model.invitesAvailable = settings.invitesAvailable !== false;
294
388
  },
295
389
  buildRawPayload: (model) => ({
296
390
  name: model.name,
297
- color: model.color,
298
- secondaryColor: model.secondaryColor,
299
- surfaceColor: model.surfaceColor,
300
- surfaceVariantColor: model.surfaceVariantColor,
391
+ lightPrimaryColor: model.lightPrimaryColor,
392
+ lightSecondaryColor: model.lightSecondaryColor,
393
+ lightSurfaceColor: model.lightSurfaceColor,
394
+ lightSurfaceVariantColor: model.lightSurfaceVariantColor,
395
+ darkPrimaryColor: model.darkPrimaryColor,
396
+ darkSecondaryColor: model.darkSecondaryColor,
397
+ darkSurfaceColor: model.darkSurfaceColor,
398
+ darkSurfaceVariantColor: model.darkSurfaceVariantColor,
301
399
  avatarUrl: model.avatarUrl,
302
400
  invitesEnabled: model.invitesEnabled
303
401
  }),
@@ -12,6 +12,7 @@ import { userProfileResource } from "@jskit-ai/users-core/shared/resources/userP
12
12
  import { userSettingsResource } from "@jskit-ai/users-core/shared/resources/userSettingsResource";
13
13
  import { USERS_ROUTE_VISIBILITY_PUBLIC } from "@jskit-ai/users-core/shared/support/usersVisibility";
14
14
  import {
15
+ persistThemePreference,
15
16
  resolveThemeNameForPreference,
16
17
  setVuetifyThemeName
17
18
  } from "../lib/theme.js";
@@ -151,6 +152,7 @@ function useAccountSettingsRuntime() {
151
152
  function applyThemePreference(themePreference) {
152
153
  const themeName = resolveThemeNameForPreference(themePreference);
153
154
  setVuetifyThemeName(vuetifyTheme, themeName);
155
+ persistThemePreference(themePreference);
154
156
  }
155
157
 
156
158
  function applyAvatarData(avatar) {
@@ -4,6 +4,7 @@ import { resolveWorkspaceThemePalette } from "@jskit-ai/users-core/shared/settin
4
4
  const THEME_PREFERENCE_LIGHT = "light";
5
5
  const THEME_PREFERENCE_DARK = "dark";
6
6
  const THEME_PREFERENCE_SYSTEM = "system";
7
+ const THEME_PREFERENCE_STORAGE_KEY = "jskit.themePreference";
7
8
  const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/;
8
9
  const WORKSPACE_THEME_NAME_LIGHT = "workspace-light";
9
10
  const WORKSPACE_THEME_NAME_DARK = "workspace-dark";
@@ -41,15 +42,80 @@ function resolveThemeNameForPreference(themePreference, options = {}) {
41
42
  return resolveSystemThemeName(options);
42
43
  }
43
44
 
45
+ function resolveThemePreferenceStorage(options = {}) {
46
+ const customStorage = options && typeof options === "object" ? options.storage : null;
47
+ if (customStorage && typeof customStorage === "object") {
48
+ return customStorage;
49
+ }
50
+
51
+ if (typeof window !== "object" || !window) {
52
+ return null;
53
+ }
54
+
55
+ let storage = null;
56
+ try {
57
+ storage = window.localStorage;
58
+ } catch {
59
+ return null;
60
+ }
61
+ if (!storage || typeof storage !== "object") {
62
+ return null;
63
+ }
64
+ return storage;
65
+ }
66
+
67
+ function readPersistedThemePreference(options = {}) {
68
+ const storage = resolveThemePreferenceStorage(options);
69
+ if (!storage || typeof storage.getItem !== "function") {
70
+ return THEME_PREFERENCE_SYSTEM;
71
+ }
72
+
73
+ try {
74
+ const value = storage.getItem(THEME_PREFERENCE_STORAGE_KEY);
75
+ return normalizeThemePreference(value);
76
+ } catch {
77
+ return THEME_PREFERENCE_SYSTEM;
78
+ }
79
+ }
80
+
81
+ function persistThemePreference(themePreference, options = {}) {
82
+ const storage = resolveThemePreferenceStorage(options);
83
+ if (!storage || typeof storage.setItem !== "function") {
84
+ return false;
85
+ }
86
+
87
+ const normalizedPreference = normalizeThemePreference(themePreference);
88
+ try {
89
+ storage.setItem(THEME_PREFERENCE_STORAGE_KEY, normalizedPreference);
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ function resolveBootstrapThemePreference(payload = {}, options = {}) {
97
+ const source = payload && typeof payload === "object" ? payload : {};
98
+ const session = source.session && typeof source.session === "object" ? source.session : {};
99
+ if (session.authenticated === true) {
100
+ const userSettings = source.userSettings && typeof source.userSettings === "object" ? source.userSettings : {};
101
+ return normalizeThemePreference(userSettings.theme);
102
+ }
103
+
104
+ return readPersistedThemePreference(options);
105
+ }
106
+
44
107
  function resolveBootstrapThemeName(payload = {}, options = {}) {
108
+ return resolveThemeNameForPreference(resolveBootstrapThemePreference(payload, options), options);
109
+ }
110
+
111
+ function persistBootstrapThemePreference(payload = {}, options = {}) {
45
112
  const source = payload && typeof payload === "object" ? payload : {};
46
113
  const session = source.session && typeof source.session === "object" ? source.session : {};
47
114
  if (session.authenticated !== true) {
48
- return THEME_PREFERENCE_LIGHT;
115
+ return false;
49
116
  }
50
117
 
51
- const userSettings = source.userSettings && typeof source.userSettings === "object" ? source.userSettings : {};
52
- return resolveThemeNameForPreference(userSettings.theme, options);
118
+ return persistThemePreference(resolveBootstrapThemePreference(payload, options), options);
53
119
  }
54
120
 
55
121
  function resolveVuetifyThemeController(vueApp) {
@@ -175,8 +241,7 @@ function composeWorkspaceThemeDefinition(baseThemeDefinition, palette) {
175
241
  primary: palette.color,
176
242
  secondary: palette.secondaryColor,
177
243
  surface: palette.surfaceColor,
178
- "surface-variant": palette.surfaceVariantColor,
179
- background: palette.backgroundColor
244
+ "surface-variant": palette.surfaceVariantColor
180
245
  }
181
246
  };
182
247
  }
@@ -235,9 +300,10 @@ function setVuetifyPrimaryColorOverride(themeController, themeInput = null) {
235
300
  return false;
236
301
  }
237
302
 
238
- const palette = resolveWorkspaceThemePalette(source);
239
- const nextLightTheme = composeWorkspaceThemeDefinition(baseLightTheme, palette);
240
- const nextDarkTheme = composeWorkspaceThemeDefinition(baseDarkTheme, palette);
303
+ const lightPalette = resolveWorkspaceThemePalette(source, { mode: THEME_PREFERENCE_LIGHT });
304
+ const darkPalette = resolveWorkspaceThemePalette(source, { mode: THEME_PREFERENCE_DARK });
305
+ const nextLightTheme = composeWorkspaceThemeDefinition(baseLightTheme, lightPalette);
306
+ const nextDarkTheme = composeWorkspaceThemeDefinition(baseDarkTheme, darkPalette);
241
307
  const nextThemeName =
242
308
  normalizedThemeName === THEME_PREFERENCE_DARK ? WORKSPACE_THEME_NAME_DARK : WORKSPACE_THEME_NAME_LIGHT;
243
309
 
@@ -255,6 +321,10 @@ function setVuetifyPrimaryColorOverride(themeController, themeInput = null) {
255
321
  export {
256
322
  hexColorToRgb,
257
323
  normalizeThemePreference,
324
+ persistBootstrapThemePreference,
325
+ persistThemePreference,
326
+ readPersistedThemePreference,
327
+ resolveBootstrapThemePreference,
258
328
  resolveThemeNameForPreference,
259
329
  resolveBootstrapThemeName,
260
330
  resolveVuetifyThemeController,
@@ -12,6 +12,7 @@ import {
12
12
  } from "../lib/bootstrap.js";
13
13
  import { normalizePermissionList } from "../lib/permissions.js";
14
14
  import {
15
+ persistBootstrapThemePreference,
15
16
  resolveBootstrapThemeName,
16
17
  setVuetifyPrimaryColorOverride,
17
18
  resolveVuetifyThemeController,
@@ -202,6 +203,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
202
203
  try {
203
204
  const nextThemeName = resolveBootstrapThemeName(payload);
204
205
  setVuetifyThemeName(themeController, nextThemeName);
206
+ persistBootstrapThemePreference(payload);
205
207
  } catch (error) {
206
208
  runtimeLogger.warn(
207
209
  {
@@ -478,10 +478,23 @@ test("bootstrap placement runtime refetches when auth context changes", async ()
478
478
  assert.deepEqual(fetchCalls, ["acme", "acme"]);
479
479
  });
480
480
 
481
- test("bootstrap placement runtime forces light theme for unauthenticated bootstrap payloads", async () => {
481
+ test("bootstrap placement runtime applies persisted theme preference for unauthenticated bootstrap payloads", async () => {
482
482
  const placementRuntime = createPlacementRuntimeStub();
483
483
  const router = createRouterStub("/auth/login");
484
484
  const themeController = createVuetifyThemeController("dark");
485
+ const storage = new Map();
486
+ storage.set("jskit.themePreference", "dark");
487
+ const originalWindow = globalThis.window;
488
+ globalThis.window = {
489
+ localStorage: {
490
+ getItem(key) {
491
+ return storage.get(key) || null;
492
+ },
493
+ setItem(key, value) {
494
+ storage.set(key, value);
495
+ }
496
+ }
497
+ };
485
498
  const runtime = createBootstrapPlacementRuntime({
486
499
  app: createAppStub({
487
500
  [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
@@ -499,8 +512,16 @@ test("bootstrap placement runtime forces light theme for unauthenticated bootstr
499
512
  }
500
513
  });
501
514
 
502
- await runtime.initialize();
503
- assert.equal(themeController.global.name.value, "light");
515
+ try {
516
+ await runtime.initialize();
517
+ assert.equal(themeController.global.name.value, "dark");
518
+ } finally {
519
+ if (typeof originalWindow === "undefined") {
520
+ delete globalThis.window;
521
+ } else {
522
+ globalThis.window = originalWindow;
523
+ }
524
+ }
504
525
  });
505
526
 
506
527
  test("bootstrap placement runtime reapplies theme when bootstrap payload changes", async () => {
@@ -563,12 +584,21 @@ test("bootstrap placement runtime applies workspace palette via Vuetify workspac
563
584
  authenticated: true,
564
585
  userId: 1
565
586
  },
587
+ workspaceSettings: {
588
+ lightPrimaryColor: "#CC3344",
589
+ lightSecondaryColor: "#884455",
590
+ lightSurfaceColor: "#F4F4F4",
591
+ lightSurfaceVariantColor: "#444444",
592
+ darkPrimaryColor: "#BB2233",
593
+ darkSecondaryColor: "#557799",
594
+ darkSurfaceColor: "#202020",
595
+ darkSurfaceVariantColor: "#A0A0A0"
596
+ },
566
597
  workspaces: [
567
598
  {
568
599
  id: 1,
569
600
  slug: "acme",
570
- name: "Acme Workspace",
571
- color: "#CC3344"
601
+ name: "Acme Workspace"
572
602
  }
573
603
  ],
574
604
  permissions: []
@@ -578,7 +608,12 @@ test("bootstrap placement runtime applies workspace palette via Vuetify workspac
578
608
 
579
609
  await runtime.initialize();
580
610
  const palette = resolveWorkspaceThemePalette({
581
- color: "#CC3344"
611
+ lightPrimaryColor: "#CC3344",
612
+ lightSecondaryColor: "#884455",
613
+ lightSurfaceColor: "#F4F4F4",
614
+ lightSurfaceVariantColor: "#444444"
615
+ }, {
616
+ mode: "light"
582
617
  });
583
618
  assert.equal(themeController.global.name.value, "workspace-light");
584
619
  assert.equal(themeController.themes.value["workspace-light"].colors.primary, palette.color);
@@ -588,7 +623,6 @@ test("bootstrap placement runtime applies workspace palette via Vuetify workspac
588
623
  themeController.themes.value["workspace-light"].colors["surface-variant"],
589
624
  palette.surfaceVariantColor
590
625
  );
591
- assert.equal(themeController.themes.value["workspace-light"].colors.background, palette.backgroundColor);
592
626
 
593
627
  router.currentRoute.value.path = "/home";
594
628
  router.currentRoute.value.fullPath = "/home";
@@ -5,6 +5,10 @@ import { resolveWorkspaceThemePalette } from "@jskit-ai/users-core/shared/settin
5
5
  import {
6
6
  hexColorToRgb,
7
7
  normalizeThemePreference,
8
+ persistBootstrapThemePreference,
9
+ persistThemePreference,
10
+ readPersistedThemePreference,
11
+ resolveBootstrapThemePreference,
8
12
  resolveThemeNameForPreference,
9
13
  resolveBootstrapThemeName,
10
14
  resolveVuetifyThemeController,
@@ -60,16 +64,23 @@ test("resolveThemeNameForPreference resolves system using explicit prefersDark",
60
64
  assert.equal(resolveThemeNameForPreference("light", { prefersDark: true }), "light");
61
65
  });
62
66
 
63
- test("resolveBootstrapThemeName keeps unauthenticated payload in light theme", () => {
67
+ test("resolveBootstrapThemeName uses persisted preference for unauthenticated payloads", () => {
68
+ const storage = new Map();
69
+ storage.set("jskit.themePreference", "dark");
70
+
64
71
  assert.equal(
65
- resolveBootstrapThemeName(
66
- {
67
- session: { authenticated: false },
68
- userSettings: { theme: "dark" }
72
+ resolveBootstrapThemeName({
73
+ session: { authenticated: false },
74
+ userSettings: { theme: "light" }
75
+ }, {
76
+ storage: {
77
+ getItem(key) {
78
+ return storage.get(key) || null;
79
+ }
69
80
  },
70
- { prefersDark: true }
71
- ),
72
- "light"
81
+ prefersDark: false
82
+ }),
83
+ "dark"
73
84
  );
74
85
  });
75
86
 
@@ -96,6 +107,45 @@ test("resolveBootstrapThemeName uses authenticated user preference", () => {
96
107
  );
97
108
  });
98
109
 
110
+ test("theme preference persistence helpers normalize values", () => {
111
+ const storage = new Map();
112
+ const storageAdapter = {
113
+ getItem(key) {
114
+ return storage.get(key) || null;
115
+ },
116
+ setItem(key, value) {
117
+ storage.set(key, value);
118
+ }
119
+ };
120
+
121
+ assert.equal(readPersistedThemePreference({ storage: storageAdapter }), "system");
122
+ assert.equal(persistThemePreference(" DARK ", { storage: storageAdapter }), true);
123
+ assert.equal(readPersistedThemePreference({ storage: storageAdapter }), "dark");
124
+
125
+ assert.equal(
126
+ resolveBootstrapThemePreference(
127
+ {
128
+ session: { authenticated: false }
129
+ },
130
+ { storage: storageAdapter }
131
+ ),
132
+ "dark"
133
+ );
134
+ assert.equal(
135
+ persistBootstrapThemePreference(
136
+ {
137
+ session: { authenticated: true },
138
+ userSettings: {
139
+ theme: "light"
140
+ }
141
+ },
142
+ { storage: storageAdapter }
143
+ ),
144
+ true
145
+ );
146
+ assert.equal(readPersistedThemePreference({ storage: storageAdapter }), "light");
147
+ });
148
+
99
149
  test("resolveVuetifyThemeController reads theme controller from Vue app provides", () => {
100
150
  const themeController = createVuetifyThemeController("light");
101
151
  const vueApp = {
@@ -126,27 +176,45 @@ test("hexColorToRgb returns Vuetify rgb tuple and rejects invalid values", () =>
126
176
 
127
177
  test("setVuetifyPrimaryColorOverride mutates workspace themes and restores base theme names", () => {
128
178
  const themeController = createVuetifyThemeController("light");
129
- const expectedPalette = resolveWorkspaceThemePalette({
130
- color: "#CC3344"
179
+ const themeInput = {
180
+ lightPrimaryColor: "#CC3344",
181
+ lightSecondaryColor: "#884455",
182
+ lightSurfaceColor: "#F4F4F4",
183
+ lightSurfaceVariantColor: "#444444",
184
+ darkPrimaryColor: "#BB2233",
185
+ darkSecondaryColor: "#557799",
186
+ darkSurfaceColor: "#202020",
187
+ darkSurfaceVariantColor: "#A0A0A0"
188
+ };
189
+ const expectedLightPalette = resolveWorkspaceThemePalette(themeInput, {
190
+ mode: "light"
191
+ });
192
+ const expectedDarkPalette = resolveWorkspaceThemePalette(themeInput, {
193
+ mode: "dark"
131
194
  });
132
195
 
133
- assert.equal(setVuetifyPrimaryColorOverride(themeController, { color: "#CC3344" }), true);
196
+ assert.equal(setVuetifyPrimaryColorOverride(themeController, themeInput), true);
134
197
  assert.equal(themeController.global.name.value, "workspace-light");
135
- assert.equal(themeController.themes.value["workspace-light"].colors.primary, expectedPalette.color);
136
- assert.equal(themeController.themes.value["workspace-light"].colors.secondary, expectedPalette.secondaryColor);
137
- assert.equal(themeController.themes.value["workspace-light"].colors.surface, expectedPalette.surfaceColor);
198
+ assert.equal(themeController.themes.value["workspace-light"].colors.primary, expectedLightPalette.color);
199
+ assert.equal(themeController.themes.value["workspace-light"].colors.secondary, expectedLightPalette.secondaryColor);
200
+ assert.equal(themeController.themes.value["workspace-light"].colors.surface, expectedLightPalette.surfaceColor);
138
201
  assert.equal(
139
202
  themeController.themes.value["workspace-light"].colors["surface-variant"],
140
- expectedPalette.surfaceVariantColor
203
+ expectedLightPalette.surfaceVariantColor
141
204
  );
142
- assert.equal(themeController.themes.value["workspace-light"].colors.background, expectedPalette.backgroundColor);
143
205
 
144
- assert.equal(setVuetifyPrimaryColorOverride(themeController, { color: "#CC3344" }), false);
206
+ assert.equal(setVuetifyPrimaryColorOverride(themeController, themeInput), false);
145
207
 
146
208
  assert.equal(setVuetifyThemeName(themeController, "dark"), true);
147
- assert.equal(setVuetifyPrimaryColorOverride(themeController, { color: "#CC3344" }), true);
209
+ assert.equal(setVuetifyPrimaryColorOverride(themeController, themeInput), true);
148
210
  assert.equal(themeController.global.name.value, "workspace-dark");
149
- assert.equal(themeController.themes.value["workspace-dark"].colors.primary, expectedPalette.color);
211
+ assert.equal(themeController.themes.value["workspace-dark"].colors.primary, expectedDarkPalette.color);
212
+ assert.equal(themeController.themes.value["workspace-dark"].colors.secondary, expectedDarkPalette.secondaryColor);
213
+ assert.equal(themeController.themes.value["workspace-dark"].colors.surface, expectedDarkPalette.surfaceColor);
214
+ assert.equal(
215
+ themeController.themes.value["workspace-dark"].colors["surface-variant"],
216
+ expectedDarkPalette.surfaceVariantColor
217
+ );
150
218
 
151
219
  assert.equal(setVuetifyPrimaryColorOverride(themeController, null), true);
152
220
  assert.equal(themeController.global.name.value, "dark");