@jskit-ai/users-web 0.1.10 → 0.1.12

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.10",
4
+ version: "0.1.12",
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",
@@ -234,7 +234,7 @@ export default Object.freeze({
234
234
  mutations: {
235
235
  dependencies: {
236
236
  runtime: {
237
- "@tanstack/vue-query": "^5.90.5",
237
+ "@tanstack/vue-query": "5.92.12",
238
238
  "@mdi/js": "^7.4.47",
239
239
  "@uppy/compressor": "^3.1.0",
240
240
  "@uppy/core": "^5.2.0",
@@ -251,7 +251,11 @@ export default Object.freeze({
251
251
  dev: {}
252
252
  },
253
253
  packageJson: {
254
- scripts: {}
254
+ scripts: {
255
+ "server:account": "SERVER_SURFACE=account node ./bin/server.js",
256
+ "dev:account": "VITE_SURFACE=account vite",
257
+ "build:account": "VITE_SURFACE=account vite build"
258
+ }
255
259
  },
256
260
  procfile: {},
257
261
  files: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -19,7 +19,7 @@
19
19
  "./client/support/realtimeWorkspace": "./src/client/support/realtimeWorkspace.js"
20
20
  },
21
21
  "dependencies": {
22
- "@tanstack/vue-query": "^5.90.5",
22
+ "@tanstack/vue-query": "5.92.12",
23
23
  "@mdi/js": "^7.4.47",
24
24
  "@jskit-ai/users-core": "0.1.10",
25
25
  "@jskit-ai/realtime": "0.1.8",
@@ -167,6 +167,7 @@
167
167
 
168
168
  <script setup>
169
169
  import { computed, toRefs, unref } from "vue";
170
+ import { formatDateTime as formatKernelDateTime } from "@jskit-ai/kernel/shared/support";
170
171
  import { requireBoolean, requireFunction, requireRecord } from "../support/contractGuards.js";
171
172
 
172
173
  const props = defineProps({
@@ -327,13 +328,7 @@ function formatDateTime(value) {
327
328
  if (typeof options.value.formatDateTime === "function") {
328
329
  return options.value.formatDateTime(value);
329
330
  }
330
-
331
- const date = new Date(value);
332
- if (Number.isNaN(date.getTime())) {
333
- return "unknown";
334
- }
335
-
336
- return date.toLocaleString();
331
+ return formatKernelDateTime(value);
337
332
  }
338
333
 
339
334
  function showOwnerChip(member) {
@@ -191,6 +191,17 @@ const activeWorkspaceLabel = computed(() => {
191
191
  return "Workspace";
192
192
  });
193
193
 
194
+ function workspaceAvatarStyle(workspace) {
195
+ const color = String(workspace?.color || "").trim();
196
+ if (!/^#[0-9a-fA-F]{6}$/.test(color)) {
197
+ return {};
198
+ }
199
+
200
+ return {
201
+ backgroundColor: color
202
+ };
203
+ }
204
+
194
205
  </script>
195
206
 
196
207
  <template>
@@ -218,7 +229,7 @@ const activeWorkspaceLabel = computed(() => {
218
229
  @click="navigateToWorkspace(workspace.slug)"
219
230
  >
220
231
  <template #prepend>
221
- <v-avatar size="24" color="primary" variant="tonal">
232
+ <v-avatar size="24" color="primary" variant="tonal" :style="workspaceAvatarStyle(workspace)">
222
233
  <span class="text-caption">{{ String(workspace.name || "W").slice(0, 1).toUpperCase() }}</span>
223
234
  </v-avatar>
224
235
  </template>
@@ -20,6 +20,7 @@
20
20
 
21
21
  <script setup>
22
22
  import { computed, reactive, ref, watch } from "vue";
23
+ import { formatDateTime } from "@jskit-ai/kernel/shared/support";
23
24
  import MembersAdminClientElement from "./MembersAdminClientElement.vue";
24
25
  import { useCommand } from "../composables/useCommand.js";
25
26
  import { useList } from "../composables/useList.js";
@@ -29,7 +30,7 @@ import { useAccess } from "../composables/useAccess.js";
29
30
  import { useUiFeedback } from "../composables/useUiFeedback.js";
30
31
  import { useWorkspaceRouteContext } from "../composables/useWorkspaceRouteContext.js";
31
32
  import { useShellWebErrorRuntime } from "@jskit-ai/shell-web/client/error";
32
- import { matchesCurrentWorkspaceEvent } from "../support/realtimeWorkspace.js";
33
+ import { createWorkspaceRealtimeMatcher } from "../support/realtimeWorkspace.js";
33
34
  import { buildWorkspaceQueryKey } from "../support/workspaceQueryKeys.js";
34
35
  import {
35
36
  WORKSPACE_SETTINGS_CHANGED_EVENT,
@@ -53,11 +54,7 @@ const options = reactive({
53
54
  inviteRoleOptions: [],
54
55
  memberRoleOptions: [],
55
56
  formatDateTime(value) {
56
- const parsedDate = new Date(value);
57
- if (Number.isNaN(parsedDate.getTime())) {
58
- return "unknown";
59
- }
60
- return parsedDate.toLocaleString();
57
+ return formatDateTime(value);
61
58
  }
62
59
  });
63
60
 
@@ -106,9 +103,7 @@ const access = useAccess({
106
103
  placementSource: "users-web.workspace-members-view"
107
104
  });
108
105
 
109
- function isCurrentWorkspaceRealtimeEvent({ payload = {} } = {}) {
110
- return matchesCurrentWorkspaceEvent(payload, workspaceSlugFromRoute.value);
111
- }
106
+ const matchesWorkspaceRealtime = createWorkspaceRealtimeMatcher(workspaceSlugFromRoute);
112
107
 
113
108
  const canViewMembers = computed(() => {
114
109
  return access.canAny(["workspace.members.view", "workspace.members.manage"]);
@@ -280,7 +275,7 @@ const workspaceSettingsView = useView({
280
275
  viewPermissions: ["workspace.members.invite"],
281
276
  realtime: {
282
277
  event: WORKSPACE_SETTINGS_CHANGED_EVENT,
283
- matches: isCurrentWorkspaceRealtimeEvent
278
+ matches: matchesWorkspaceRealtime
284
279
  },
285
280
  fallbackLoadError: "Unable to load workspace settings."
286
281
  });
@@ -301,7 +296,7 @@ const workspaceMembersList = useList({
301
296
  viewPermissions: ["workspace.members.view", "workspace.members.manage"],
302
297
  realtime: {
303
298
  event: WORKSPACE_MEMBERS_CHANGED_EVENT,
304
- matches: isCurrentWorkspaceRealtimeEvent
299
+ matches: matchesWorkspaceRealtime
305
300
  },
306
301
  selectItems: (payload) => normalizeMembers(payload?.members),
307
302
  fallbackLoadError: "Unable to load workspace members."
@@ -315,7 +310,7 @@ const workspaceInvitesList = useList({
315
310
  viewPermissions: ["workspace.members.view", "workspace.members.manage"],
316
311
  realtime: {
317
312
  event: WORKSPACE_INVITES_CHANGED_EVENT,
318
- matches: isCurrentWorkspaceRealtimeEvent
313
+ matches: matchesWorkspaceRealtime
319
314
  },
320
315
  selectItems: (payload) => normalizeInvites(payload?.invites),
321
316
  fallbackLoadError: "Unable to load workspace invites."
@@ -23,7 +23,7 @@
23
23
  <v-form @submit.prevent="addEdit.submit" novalidate>
24
24
  <v-progress-linear v-if="addEdit.isRefetching" indeterminate class="mb-4" />
25
25
  <v-row>
26
- <v-col cols="12" md="5">
26
+ <v-col cols="12" md="6">
27
27
  <v-text-field
28
28
  v-model="workspaceForm.name"
29
29
  label="Workspace name"
@@ -34,19 +34,7 @@
34
34
  />
35
35
  </v-col>
36
36
 
37
- <v-col cols="12" md="2">
38
- <v-text-field
39
- v-model="workspaceForm.color"
40
- label="Workspace color"
41
- type="color"
42
- variant="outlined"
43
- density="comfortable"
44
- :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
45
- :error-messages="addEdit.fieldErrors.color ? [addEdit.fieldErrors.color] : []"
46
- />
47
- </v-col>
48
-
49
- <v-col cols="12" md="5">
37
+ <v-col cols="12" md="6">
50
38
  <v-text-field
51
39
  v-model="workspaceForm.avatarUrl"
52
40
  label="Workspace avatar URL"
@@ -60,6 +48,61 @@
60
48
  />
61
49
  </v-col>
62
50
 
51
+ <v-col cols="12">
52
+ <div class="text-subtitle-2 mb-2">Theme colors</div>
53
+ <v-row dense>
54
+ <v-col cols="12" md="3">
55
+ <v-text-field
56
+ v-model="workspaceForm.color"
57
+ label="Primary"
58
+ type="color"
59
+ variant="outlined"
60
+ density="comfortable"
61
+ :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
62
+ :error-messages="addEdit.fieldErrors.color ? [addEdit.fieldErrors.color] : []"
63
+ />
64
+ </v-col>
65
+
66
+ <v-col cols="12" md="3">
67
+ <v-text-field
68
+ v-model="workspaceForm.secondaryColor"
69
+ label="Secondary"
70
+ type="color"
71
+ variant="outlined"
72
+ density="comfortable"
73
+ :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
74
+ :error-messages="addEdit.fieldErrors.secondaryColor ? [addEdit.fieldErrors.secondaryColor] : []"
75
+ />
76
+ </v-col>
77
+
78
+ <v-col cols="12" md="3">
79
+ <v-text-field
80
+ v-model="workspaceForm.surfaceColor"
81
+ label="Surface"
82
+ type="color"
83
+ variant="outlined"
84
+ density="comfortable"
85
+ :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
86
+ :error-messages="addEdit.fieldErrors.surfaceColor ? [addEdit.fieldErrors.surfaceColor] : []"
87
+ />
88
+ </v-col>
89
+
90
+ <v-col cols="12" md="3">
91
+ <v-text-field
92
+ v-model="workspaceForm.surfaceVariantColor"
93
+ label="Surface variant"
94
+ type="color"
95
+ variant="outlined"
96
+ density="comfortable"
97
+ :readonly="!addEdit.canSave || addEdit.isSaving || addEdit.isRefetching"
98
+ :error-messages="
99
+ addEdit.fieldErrors.surfaceVariantColor ? [addEdit.fieldErrors.surfaceVariantColor] : []
100
+ "
101
+ />
102
+ </v-col>
103
+ </v-row>
104
+ </v-col>
105
+
63
106
  <v-col cols="12" md="6" class="d-flex align-center">
64
107
  <v-switch
65
108
  v-model="workspaceForm.invitesEnabled"
@@ -96,20 +139,29 @@ import { validateOperationSection } from "@jskit-ai/http-runtime/shared/validato
96
139
  import { workspaceSettingsResource } from "@jskit-ai/users-core/shared/resources/workspaceSettingsResource";
97
140
  import { WORKSPACE_SETTINGS_CHANGED_EVENT } from "@jskit-ai/users-core/shared/events/usersEvents";
98
141
  import { USERS_ROUTE_VISIBILITY_WORKSPACE } from "@jskit-ai/users-core/shared/support/usersVisibility";
142
+ import {
143
+ DEFAULT_WORKSPACE_COLOR,
144
+ resolveWorkspaceThemePalette
145
+ } from "@jskit-ai/users-core/shared/settings";
99
146
  import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
100
147
  import { useAddEdit } from "../composables/useAddEdit.js";
101
148
  import { useBootstrapQuery } from "../composables/useBootstrapQuery.js";
102
149
  import { useWorkspaceRouteContext } from "../composables/useWorkspaceRouteContext.js";
103
150
  import { findWorkspaceBySlug, normalizeWorkspaceList } from "../lib/bootstrap.js";
104
151
  import { arePermissionListsEqual, normalizePermissionList } from "../lib/permissions.js";
105
- import { matchesCurrentWorkspaceEvent } from "../support/realtimeWorkspace.js";
152
+ import { createWorkspaceRealtimeMatcher } from "../support/realtimeWorkspace.js";
106
153
  import { buildWorkspaceQueryKey } from "../support/workspaceQueryKeys.js";
107
154
 
108
- const DEFAULT_WORKSPACE_COLOR = "#0F6B54";
155
+ const DEFAULT_WORKSPACE_THEME = resolveWorkspaceThemePalette({
156
+ color: DEFAULT_WORKSPACE_COLOR
157
+ });
109
158
 
110
159
  const workspaceForm = reactive({
111
160
  name: "",
112
161
  color: DEFAULT_WORKSPACE_COLOR,
162
+ secondaryColor: DEFAULT_WORKSPACE_THEME.secondaryColor,
163
+ surfaceColor: DEFAULT_WORKSPACE_THEME.surfaceColor,
164
+ surfaceVariantColor: DEFAULT_WORKSPACE_THEME.surfaceVariantColor,
113
165
  avatarUrl: "",
114
166
  invitesEnabled: false,
115
167
  invitesAvailable: false
@@ -138,6 +190,22 @@ function toWorkspaceListSnapshot(list = []) {
138
190
  return JSON.stringify(normalizeWorkspaceList(list));
139
191
  }
140
192
 
193
+ function toWorkspaceSettingsSnapshot(settings = null) {
194
+ if (!settings || typeof settings !== "object") {
195
+ return "";
196
+ }
197
+
198
+ const normalized = resolveWorkspaceThemePalette(settings);
199
+ return JSON.stringify({
200
+ color: normalized.color,
201
+ secondaryColor: normalized.secondaryColor,
202
+ surfaceColor: normalized.surfaceColor,
203
+ surfaceVariantColor: normalized.surfaceVariantColor,
204
+ invitesEnabled: settings.invitesEnabled !== false,
205
+ invitesAvailable: settings.invitesAvailable !== false
206
+ });
207
+ }
208
+
141
209
  function applyShellWorkspaceContext(payload = {}) {
142
210
  const availableWorkspaces = normalizeWorkspaceList(payload?.workspaces);
143
211
  const currentWorkspace = findWorkspaceBySlug(
@@ -154,14 +222,21 @@ function applyShellWorkspaceContext(payload = {}) {
154
222
  const sameWorkspace = toWorkspaceEntrySnapshot(currentContext.workspace) === toWorkspaceEntrySnapshot(currentWorkspace);
155
223
  const sameWorkspaces =
156
224
  toWorkspaceListSnapshot(currentContext.workspaces) === toWorkspaceListSnapshot(availableWorkspaces);
225
+ const sameWorkspaceSettings =
226
+ toWorkspaceSettingsSnapshot(currentContext.workspaceSettings) ===
227
+ toWorkspaceSettingsSnapshot(payload?.workspaceSettings);
157
228
 
158
- if (samePermissions && sameWorkspace && sameWorkspaces) {
229
+ if (samePermissions && sameWorkspace && sameWorkspaces && sameWorkspaceSettings) {
159
230
  return;
160
231
  }
161
232
 
162
233
  mergePlacementContext(
163
234
  {
164
235
  workspace: currentWorkspace,
236
+ workspaceSettings:
237
+ payload?.workspaceSettings && typeof payload.workspaceSettings === "object"
238
+ ? payload.workspaceSettings
239
+ : null,
165
240
  workspaces: availableWorkspaces,
166
241
  permissions
167
242
  },
@@ -180,9 +255,7 @@ watch(
180
255
  { immediate: true }
181
256
  );
182
257
 
183
- function isCurrentWorkspaceRealtimeEvent({ payload = {} } = {}) {
184
- return matchesCurrentWorkspaceEvent(payload, routeContext.workspaceSlugFromRoute.value);
185
- }
258
+ const matchesWorkspaceRealtime = createWorkspaceRealtimeMatcher(routeContext.workspaceSlugFromRoute);
186
259
 
187
260
  const addEdit = useAddEdit({
188
261
  ownershipFilter: OWNERSHIP_WORKSPACE,
@@ -194,10 +267,10 @@ const addEdit = useAddEdit({
194
267
  savePermissions: ["workspace.settings.update"],
195
268
  placementSource: "users-web.workspace-settings-view",
196
269
  fallbackLoadError: "Unable to load workspace settings.",
197
- fieldErrorKeys: ["name", "avatarUrl", "color"],
270
+ fieldErrorKeys: ["name", "avatarUrl", "color", "secondaryColor", "surfaceColor", "surfaceVariantColor"],
198
271
  realtime: {
199
272
  event: WORKSPACE_SETTINGS_CHANGED_EVENT,
200
- matches: isCurrentWorkspaceRealtimeEvent
273
+ matches: matchesWorkspaceRealtime
201
274
  },
202
275
  model: workspaceForm,
203
276
  parseInput: (rawPayload) =>
@@ -208,9 +281,13 @@ const addEdit = useAddEdit({
208
281
  }),
209
282
  mapLoadedToModel: (model, payload = {}) => {
210
283
  const settings = payload?.settings && typeof payload.settings === "object" ? payload.settings : {};
284
+ const normalizedTheme = resolveWorkspaceThemePalette(settings);
211
285
 
212
286
  model.name = String(settings.name || "");
213
- model.color = String(settings.color || DEFAULT_WORKSPACE_COLOR);
287
+ model.color = normalizedTheme.color;
288
+ model.secondaryColor = normalizedTheme.secondaryColor;
289
+ model.surfaceColor = normalizedTheme.surfaceColor;
290
+ model.surfaceVariantColor = normalizedTheme.surfaceVariantColor;
214
291
  model.avatarUrl = String(settings.avatarUrl || "");
215
292
  model.invitesEnabled = settings.invitesEnabled !== false;
216
293
  model.invitesAvailable = settings.invitesAvailable !== false;
@@ -218,6 +295,9 @@ const addEdit = useAddEdit({
218
295
  buildRawPayload: (model) => ({
219
296
  name: model.name,
220
297
  color: model.color,
298
+ secondaryColor: model.secondaryColor,
299
+ surfaceColor: model.surfaceColor,
300
+ surfaceVariantColor: model.surfaceVariantColor,
221
301
  avatarUrl: model.avatarUrl,
222
302
  invitesEnabled: model.invitesEnabled
223
303
  }),
@@ -1,3 +1,5 @@
1
+ import { watch } from "vue";
2
+
1
3
  function isObjectLike(value) {
2
4
  return value !== null && typeof value === "object";
3
5
  }
@@ -43,7 +45,46 @@ function restoreModelSnapshot(model, snapshot) {
43
45
  }
44
46
  }
45
47
 
48
+ function watchResourceModelState({
49
+ resource,
50
+ model,
51
+ mapLoadedToModel,
52
+ resolveMapContext = null
53
+ } = {}) {
54
+ const modelSnapshot = captureModelSnapshot(model);
55
+
56
+ watch(
57
+ () => resource?.query?.isPending?.value,
58
+ (isPending) => {
59
+ if (!isPending || !modelSnapshot) {
60
+ return;
61
+ }
62
+
63
+ restoreModelSnapshot(model, modelSnapshot);
64
+ },
65
+ {
66
+ immediate: true
67
+ }
68
+ );
69
+
70
+ watch(
71
+ () => resource?.data?.value,
72
+ (payload) => {
73
+ if (!payload || typeof mapLoadedToModel !== "function") {
74
+ return;
75
+ }
76
+
77
+ const context = typeof resolveMapContext === "function" ? resolveMapContext() : {};
78
+ mapLoadedToModel(model, payload, context);
79
+ },
80
+ {
81
+ immediate: true
82
+ }
83
+ );
84
+ }
85
+
46
86
  export {
47
87
  captureModelSnapshot,
48
- restoreModelSnapshot
88
+ restoreModelSnapshot,
89
+ watchResourceModelState
49
90
  };
@@ -1,8 +1,7 @@
1
- import { watch } from "vue";
2
1
  import { useQueryClient } from "@tanstack/vue-query";
3
2
  import { resolveFieldErrors } from "@jskit-ai/http-runtime/client";
4
3
  import { validateOperationInput } from "./operationValidationHelpers.js";
5
- import { captureModelSnapshot, restoreModelSnapshot } from "./modelStateHelpers.js";
4
+ import { watchResourceModelState } from "./modelStateHelpers.js";
6
5
 
7
6
  function useAddEditCore({
8
7
  model,
@@ -19,37 +18,17 @@ function useAddEditCore({
19
18
  messages = {}
20
19
  } = {}) {
21
20
  const queryClient = useQueryClient();
22
- const modelSnapshot = captureModelSnapshot(model);
23
-
24
- watch(
25
- () => resource?.query?.isPending?.value,
26
- (isPending) => {
27
- if (!isPending || !modelSnapshot) {
28
- return;
29
- }
30
-
31
- restoreModelSnapshot(model, modelSnapshot);
32
- },
33
- {
34
- immediate: true
35
- }
36
- );
37
-
38
- watch(
39
- () => resource?.data?.value,
40
- (payload) => {
41
- if (!payload || typeof mapLoadedToModel !== "function") {
42
- return;
43
- }
44
- mapLoadedToModel(model, payload, {
21
+ watchResourceModelState({
22
+ resource,
23
+ model,
24
+ mapLoadedToModel,
25
+ resolveMapContext() {
26
+ return {
45
27
  queryClient,
46
28
  resource
47
- });
48
- },
49
- {
50
- immediate: true
29
+ };
51
30
  }
52
- );
31
+ });
53
32
 
54
33
  const saving = resource?.isSaving;
55
34
  const fieldErrors = fieldBag?.errors;
@@ -1,5 +1,5 @@
1
- import { computed, watch } from "vue";
2
- import { captureModelSnapshot, restoreModelSnapshot } from "./modelStateHelpers.js";
1
+ import { computed } from "vue";
2
+ import { watchResourceModelState } from "./modelStateHelpers.js";
3
3
 
4
4
  function normalizeStatusList(value) {
5
5
  if (Array.isArray(value)) {
@@ -18,37 +18,16 @@ function useViewCore({
18
18
  notFoundMessage = "Record not found."
19
19
  } = {}) {
20
20
  const statusList = normalizeStatusList(notFoundStatuses);
21
- const modelSnapshot = captureModelSnapshot(model);
22
-
23
- watch(
24
- () => resource?.query?.isPending?.value,
25
- (isPending) => {
26
- if (!isPending || !modelSnapshot) {
27
- return;
28
- }
29
-
30
- restoreModelSnapshot(model, modelSnapshot);
31
- },
32
- {
33
- immediate: true
34
- }
35
- );
36
-
37
- watch(
38
- () => resource?.data?.value,
39
- (payload) => {
40
- if (!payload || typeof mapLoadedToModel !== "function") {
41
- return;
42
- }
43
-
44
- mapLoadedToModel(model, payload, {
21
+ watchResourceModelState({
22
+ resource,
23
+ model,
24
+ mapLoadedToModel,
25
+ resolveMapContext() {
26
+ return {
45
27
  resource
46
- });
47
- },
48
- {
49
- immediate: true
28
+ };
50
29
  }
51
- );
30
+ });
52
31
 
53
32
  const data = resource?.data;
54
33
  const record = computed(() => (model !== undefined ? model : data?.value));
@@ -26,6 +26,9 @@ function normalizeWorkspaceEntry(entry) {
26
26
  slug,
27
27
  name: String(entry.name || slug).trim() || slug,
28
28
  color: String(entry.color || "").trim(),
29
+ secondaryColor: String(entry.secondaryColor || "").trim(),
30
+ surfaceColor: String(entry.surfaceColor || "").trim(),
31
+ surfaceVariantColor: String(entry.surfaceVariantColor || "").trim(),
29
32
  avatarUrl: String(entry.avatarUrl || "").trim(),
30
33
  roleId: String(entry.roleId || "member").trim().toLowerCase() || "member",
31
34
  isAccessible: entry.isAccessible !== false
@@ -16,6 +16,7 @@ import {
16
16
  mdiViewListOutline
17
17
  } from "@mdi/js";
18
18
  import { isExternalLinkTarget, splitPathQueryHash } from "@jskit-ai/kernel/shared/support/linkPath";
19
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
19
20
  import { normalizePathname as normalizeKernelPathname } from "@jskit-ai/kernel/shared/surface/paths";
20
21
 
21
22
  const SURFACE_SWITCH_ICON_BY_ID = Object.freeze({
@@ -25,10 +26,6 @@ const SURFACE_SWITCH_ICON_BY_ID = Object.freeze({
25
26
  console: mdiConsoleNetworkOutline
26
27
  });
27
28
 
28
- function normalizeText(value) {
29
- return String(value || "").trim();
30
- }
31
-
32
29
  function normalizePathname(value) {
33
30
  const normalizedValue = normalizeText(value);
34
31
  if (!normalizedValue) {
@@ -1,8 +1,12 @@
1
1
  import { ThemeSymbol } from "vuetify/lib/composables/theme.js";
2
+ import { resolveWorkspaceThemePalette } from "@jskit-ai/users-core/shared/settings";
2
3
 
3
4
  const THEME_PREFERENCE_LIGHT = "light";
4
5
  const THEME_PREFERENCE_DARK = "dark";
5
6
  const THEME_PREFERENCE_SYSTEM = "system";
7
+ const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/;
8
+ const WORKSPACE_THEME_NAME_LIGHT = "workspace-light";
9
+ const WORKSPACE_THEME_NAME_DARK = "workspace-dark";
6
10
 
7
11
  function normalizeThemePreference(value) {
8
12
  const normalized = String(value || "").trim().toLowerCase();
@@ -89,11 +93,171 @@ function setVuetifyThemeName(themeController, themeName) {
89
93
  return true;
90
94
  }
91
95
 
96
+ function normalizeHexColor(value = "") {
97
+ const normalized = String(value || "").trim();
98
+ if (!HEX_COLOR_PATTERN.test(normalized)) {
99
+ return "";
100
+ }
101
+ return normalized.toUpperCase();
102
+ }
103
+
104
+ function hexColorToRgb(value = "") {
105
+ const normalized = normalizeHexColor(value);
106
+ if (!normalized) {
107
+ return "";
108
+ }
109
+
110
+ const red = Number.parseInt(normalized.slice(1, 3), 16);
111
+ const green = Number.parseInt(normalized.slice(3, 5), 16);
112
+ const blue = Number.parseInt(normalized.slice(5, 7), 16);
113
+ return `${red},${green},${blue}`;
114
+ }
115
+
116
+ function resolveVuetifyThemeDefinitions(themeController) {
117
+ if (!themeController || typeof themeController !== "object") {
118
+ return null;
119
+ }
120
+ const themes = themeController.themes?.value;
121
+ if (!themes || typeof themes !== "object") {
122
+ return null;
123
+ }
124
+ return themes;
125
+ }
126
+
127
+ function normalizeThemeColors(colors) {
128
+ const source = colors && typeof colors === "object" ? colors : {};
129
+ const normalized = {};
130
+ for (const [key, value] of Object.entries(source)) {
131
+ normalized[String(key)] = String(value);
132
+ }
133
+ return normalized;
134
+ }
135
+
136
+ function normalizeWorkspaceBaseThemeName(themeName = "") {
137
+ const normalized = String(themeName || "").trim().toLowerCase();
138
+ if (normalized === WORKSPACE_THEME_NAME_LIGHT) {
139
+ return THEME_PREFERENCE_LIGHT;
140
+ }
141
+ if (normalized === WORKSPACE_THEME_NAME_DARK) {
142
+ return THEME_PREFERENCE_DARK;
143
+ }
144
+ if (normalized === THEME_PREFERENCE_DARK) {
145
+ return THEME_PREFERENCE_DARK;
146
+ }
147
+ return THEME_PREFERENCE_LIGHT;
148
+ }
149
+
150
+ function areThemeColorsEqual(leftColors = {}, rightColors = {}) {
151
+ const leftEntries = Object.entries(leftColors);
152
+ const rightEntries = Object.entries(rightColors);
153
+ if (leftEntries.length !== rightEntries.length) {
154
+ return false;
155
+ }
156
+
157
+ for (const [key, value] of leftEntries) {
158
+ if (!Object.hasOwn(rightColors, key)) {
159
+ return false;
160
+ }
161
+ if (String(rightColors[key]) !== String(value)) {
162
+ return false;
163
+ }
164
+ }
165
+ return true;
166
+ }
167
+
168
+ function composeWorkspaceThemeDefinition(baseThemeDefinition, palette) {
169
+ const baseTheme = baseThemeDefinition && typeof baseThemeDefinition === "object" ? baseThemeDefinition : {};
170
+ const baseColors = normalizeThemeColors(baseTheme.colors);
171
+ return {
172
+ ...baseTheme,
173
+ colors: {
174
+ ...baseColors,
175
+ primary: palette.color,
176
+ secondary: palette.secondaryColor,
177
+ surface: palette.surfaceColor,
178
+ "surface-variant": palette.surfaceVariantColor,
179
+ background: palette.backgroundColor
180
+ }
181
+ };
182
+ }
183
+
184
+ function upsertThemeDefinition(themeDefinitions, themeName, nextDefinition) {
185
+ const currentDefinition =
186
+ themeDefinitions[themeName] && typeof themeDefinitions[themeName] === "object" ? themeDefinitions[themeName] : null;
187
+ const currentColors = normalizeThemeColors(currentDefinition?.colors);
188
+ const nextColors = normalizeThemeColors(nextDefinition?.colors);
189
+ const sameDarkFlag = Boolean(currentDefinition?.dark) === Boolean(nextDefinition?.dark);
190
+ if (sameDarkFlag && areThemeColorsEqual(currentColors, nextColors)) {
191
+ return false;
192
+ }
193
+ themeDefinitions[themeName] = nextDefinition;
194
+ return true;
195
+ }
196
+
197
+ function setVuetifyPrimaryColorOverride(themeController, themeInput = null) {
198
+ if (
199
+ !themeController ||
200
+ typeof themeController !== "object" ||
201
+ !themeController.global ||
202
+ !themeController.global.name
203
+ ) {
204
+ return false;
205
+ }
206
+
207
+ const themeDefinitions = resolveVuetifyThemeDefinitions(themeController);
208
+ if (!themeDefinitions) {
209
+ return false;
210
+ }
211
+
212
+ const currentThemeName = String(themeController.global.name.value || "").trim();
213
+ const normalizedBaseThemeName = normalizeWorkspaceBaseThemeName(currentThemeName);
214
+ const normalizedThemeName =
215
+ normalizedBaseThemeName === THEME_PREFERENCE_DARK ? THEME_PREFERENCE_DARK : THEME_PREFERENCE_LIGHT;
216
+ const source = themeInput && typeof themeInput === "object" ? themeInput : null;
217
+
218
+ if (!source) {
219
+ if (currentThemeName === normalizedThemeName) {
220
+ return false;
221
+ }
222
+ themeController.global.name.value = normalizedThemeName;
223
+ return true;
224
+ }
225
+
226
+ const baseLightTheme =
227
+ themeDefinitions[THEME_PREFERENCE_LIGHT] && typeof themeDefinitions[THEME_PREFERENCE_LIGHT] === "object"
228
+ ? themeDefinitions[THEME_PREFERENCE_LIGHT]
229
+ : null;
230
+ const baseDarkTheme =
231
+ themeDefinitions[THEME_PREFERENCE_DARK] && typeof themeDefinitions[THEME_PREFERENCE_DARK] === "object"
232
+ ? themeDefinitions[THEME_PREFERENCE_DARK]
233
+ : null;
234
+ if (!baseLightTheme || !baseDarkTheme) {
235
+ return false;
236
+ }
237
+
238
+ const palette = resolveWorkspaceThemePalette(source);
239
+ const nextLightTheme = composeWorkspaceThemeDefinition(baseLightTheme, palette);
240
+ const nextDarkTheme = composeWorkspaceThemeDefinition(baseDarkTheme, palette);
241
+ const nextThemeName =
242
+ normalizedThemeName === THEME_PREFERENCE_DARK ? WORKSPACE_THEME_NAME_DARK : WORKSPACE_THEME_NAME_LIGHT;
243
+
244
+ let changed = false;
245
+ changed = upsertThemeDefinition(themeDefinitions, WORKSPACE_THEME_NAME_LIGHT, nextLightTheme) || changed;
246
+ changed = upsertThemeDefinition(themeDefinitions, WORKSPACE_THEME_NAME_DARK, nextDarkTheme) || changed;
247
+ if (themeController.global.name.value !== nextThemeName) {
248
+ themeController.global.name.value = nextThemeName;
249
+ changed = true;
250
+ }
251
+
252
+ return changed;
253
+ }
254
+
92
255
  export {
256
+ hexColorToRgb,
93
257
  normalizeThemePreference,
94
258
  resolveThemeNameForPreference,
95
259
  resolveBootstrapThemeName,
96
260
  resolveVuetifyThemeController,
261
+ setVuetifyPrimaryColorOverride,
97
262
  setVuetifyThemeName
98
263
  };
99
-
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  resolveSurfaceDefinitionFromPlacementContext,
3
3
  resolveSurfaceIdFromPlacementPathname,
4
- resolveSurfaceRootPathFromPlacementContext
4
+ resolveSurfaceRootPathFromPlacementContext,
5
+ resolveRuntimePathname
5
6
  } from "@jskit-ai/shell-web/client/placement";
6
7
  import {
7
8
  extractWorkspaceSlugFromSurfacePathname,
@@ -17,7 +18,6 @@ import {
17
18
  } from "./bootstrapPlacementRuntimeConstants.js";
18
19
  import {
19
20
  isGuardDenied,
20
- normalizeGuardPathname,
21
21
  normalizeSearch,
22
22
  normalizeWorkspaceBootstrapStatus,
23
23
  normalizeWorkspaceSlugKey,
@@ -36,7 +36,7 @@ function createBootstrapPlacementRouteGuards({
36
36
 
37
37
  function resolveNormalizedSurfaceState(pathname = "/", search = "") {
38
38
  const context = placementRuntime.getContext();
39
- const normalizedPathname = normalizeGuardPathname(pathname);
39
+ const normalizedPathname = resolveRuntimePathname(pathname);
40
40
  const normalizedSearch = normalizeSearch(search);
41
41
  const surfaceId = String(resolveSurfaceIdFromPlacementPathname(context, normalizedPathname) || "")
42
42
  .trim()
@@ -77,7 +77,7 @@ function createBootstrapPlacementRouteGuards({
77
77
  search: surfaceState.normalizedSearch,
78
78
  surfaceId: surfaceState.surfaceId,
79
79
  workspaceSlug,
80
- workspaceRootPath: normalizeGuardPathname(
80
+ workspaceRootPath: resolveRuntimePathname(
81
81
  resolveSurfaceWorkspacePathFromPlacementContext(surfaceState.context, surfaceState.surfaceId, workspaceSlug, "/")
82
82
  ),
83
83
  workspaceBootstrapStatus: String(getWorkspaceBootstrapStatus(workspaceSlug) || "")
@@ -126,12 +126,12 @@ function createBootstrapPlacementRouteGuards({
126
126
  if (!fallbackWorkspaceSlug) {
127
127
  return "/";
128
128
  }
129
- return normalizeGuardPathname(
129
+ return resolveRuntimePathname(
130
130
  resolveSurfaceWorkspacePathFromPlacementContext(context, fallbackSurfaceId, fallbackWorkspaceSlug, "/")
131
131
  );
132
132
  }
133
133
 
134
- const fallbackPath = normalizeGuardPathname(resolveSurfaceRootPathFromPlacementContext(context, fallbackSurfaceId));
134
+ const fallbackPath = resolveRuntimePathname(resolveSurfaceRootPathFromPlacementContext(context, fallbackSurfaceId));
135
135
  if (fallbackPath.includes(":")) {
136
136
  return "/";
137
137
  }
@@ -214,7 +214,7 @@ function createBootstrapPlacementRouteGuards({
214
214
 
215
215
  const currentRoute = router.currentRoute?.value || {};
216
216
  const currentFullPath = String(currentRoute.fullPath || "").trim();
217
- const currentPath = normalizeGuardPathname(currentRoute.path || "/");
217
+ const currentPath = resolveRuntimePathname(currentRoute.path || "/");
218
218
  const currentComparablePath = currentFullPath || currentPath;
219
219
  if (currentComparablePath === normalizedTargetPath) {
220
220
  return;
@@ -233,7 +233,7 @@ function createBootstrapPlacementRouteGuards({
233
233
  }
234
234
 
235
235
  const currentRoute = router.currentRoute?.value || {};
236
- const currentPath = normalizeGuardPathname(currentRoute.path || "/");
236
+ const currentPath = resolveRuntimePathname(currentRoute.path || "/");
237
237
  const currentSearch = resolveSearchFromFullPath(currentRoute.fullPath || "");
238
238
  const workspaceState = resolveWorkspaceRouteState(currentPath, currentSearch);
239
239
  if (!workspaceState || workspaceState.workspaceSlug !== normalizedWorkspaceSlug) {
@@ -260,7 +260,7 @@ function createBootstrapPlacementRouteGuards({
260
260
  }
261
261
 
262
262
  const currentRoute = router.currentRoute?.value || {};
263
- const currentPath = normalizeGuardPathname(currentRoute.path || "/");
263
+ const currentPath = resolveRuntimePathname(currentRoute.path || "/");
264
264
  const currentSearch = resolveSearchFromFullPath(currentRoute.fullPath || "");
265
265
  const surfaceDecision = resolveSurfaceAccessGuardDecision(currentPath, currentSearch, {
266
266
  allowOnUnknown: false
@@ -299,7 +299,7 @@ function createBootstrapPlacementRouteGuards({
299
299
  return baseOutcome;
300
300
  }
301
301
 
302
- const pathname = normalizeGuardPathname(context?.location?.pathname || context?.to?.path || "/");
302
+ const pathname = resolveRuntimePathname(context?.location?.pathname || context?.to?.path || "/");
303
303
  const search = normalizeSearch(context?.location?.search || resolveSearchFromFullPath(context?.to?.fullPath || ""));
304
304
  const workspaceDecision = resolveWorkspaceGuardDecision(pathname, search);
305
305
  if (workspaceDecision) {
@@ -13,6 +13,7 @@ import {
13
13
  import { normalizePermissionList } from "../lib/permissions.js";
14
14
  import {
15
15
  resolveBootstrapThemeName,
16
+ setVuetifyPrimaryColorOverride,
16
17
  resolveVuetifyThemeController,
17
18
  setVuetifyThemeName
18
19
  } from "../lib/theme.js";
@@ -132,6 +133,10 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
132
133
  function writePlacementContext(payload = {}, state = {}, source = BOOTSTRAP_PLACEMENT_SOURCE) {
133
134
  const availableWorkspaces = normalizeWorkspaceList(payload?.workspaces);
134
135
  const currentWorkspace = findWorkspaceBySlug(availableWorkspaces, state.workspaceSlug);
136
+ const workspaceSettings =
137
+ payload?.workspaceSettings && typeof payload.workspaceSettings === "object"
138
+ ? payload.workspaceSettings
139
+ : null;
135
140
  const permissions = normalizePermissionList(payload?.permissions);
136
141
  const user = resolvePlacementUserFromBootstrapPayload(payload, state.context?.user);
137
142
  const workspaceInvitesEnabled = payload?.app?.features?.workspaceInvites === true;
@@ -140,6 +145,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
140
145
  placementRuntime.setContext(
141
146
  {
142
147
  workspace: currentWorkspace,
148
+ workspaceSettings,
143
149
  workspaces: availableWorkspaces,
144
150
  permissions,
145
151
  user,
@@ -152,12 +158,14 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
152
158
  }
153
159
  );
154
160
  routeGuards.enforceSurfaceAccessForCurrentRoute();
161
+ applyWorkspaceColorFromPlacementContext("write");
155
162
  }
156
163
 
157
164
  function clearPlacementContext(source = BOOTSTRAP_PLACEMENT_SOURCE) {
158
165
  placementRuntime.setContext(
159
166
  {
160
167
  workspace: null,
168
+ workspaceSettings: null,
161
169
  workspaces: [],
162
170
  permissions: [],
163
171
  user: null,
@@ -170,6 +178,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
170
178
  }
171
179
  );
172
180
  routeGuards.enforceSurfaceAccessForCurrentRoute();
181
+ applyWorkspaceColorFromPlacementContext("clear");
173
182
  }
174
183
 
175
184
  function getVuetifyThemeController() {
@@ -204,6 +213,52 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
204
213
  }
205
214
  }
206
215
 
216
+ function resolveWorkspaceThemeForCurrentRoute() {
217
+ const routeState = resolveRouteState(placementRuntime, router);
218
+ if (!routeState.workspaceSlug) {
219
+ return null;
220
+ }
221
+
222
+ const context = placementRuntime.getContext();
223
+ const workspaceSettings =
224
+ context?.workspaceSettings && typeof context.workspaceSettings === "object"
225
+ ? context.workspaceSettings
226
+ : null;
227
+ if (workspaceSettings) {
228
+ return workspaceSettings;
229
+ }
230
+
231
+ const workspace = context?.workspace && typeof context.workspace === "object" ? context.workspace : null;
232
+ if (!workspace) {
233
+ return null;
234
+ }
235
+ const workspaceSlug = normalizeWorkspaceSlugKey(workspace.slug);
236
+ if (!workspaceSlug || workspaceSlug !== normalizeWorkspaceSlugKey(routeState.workspaceSlug)) {
237
+ return null;
238
+ }
239
+
240
+ return workspace;
241
+ }
242
+
243
+ function applyWorkspaceColorFromPlacementContext(reason = "manual") {
244
+ const themeController = getVuetifyThemeController();
245
+ if (!themeController) {
246
+ return;
247
+ }
248
+ try {
249
+ const workspaceTheme = resolveWorkspaceThemeForCurrentRoute();
250
+ setVuetifyPrimaryColorOverride(themeController, workspaceTheme);
251
+ } catch (error) {
252
+ runtimeLogger.warn(
253
+ {
254
+ reason,
255
+ error: String(error?.message || error || "unknown error")
256
+ },
257
+ "users-web bootstrap workspace color apply failed."
258
+ );
259
+ }
260
+ }
261
+
207
262
  async function refresh(reason = "manual") {
208
263
  if (shutdownRequested) {
209
264
  return;
@@ -220,6 +275,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
220
275
 
221
276
  writePlacementContext(payload, stateAtStart, source);
222
277
  applyThemeFromBootstrapPayload(payload, reason);
278
+ applyWorkspaceColorFromPlacementContext(reason);
223
279
  if (stateAtStart.workspaceSlug) {
224
280
  const sessionAuthenticated = payload?.session?.authenticated === true;
225
281
  if (!sessionAuthenticated) {
@@ -321,6 +377,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
321
377
  }
322
378
  }, "init");
323
379
  }
380
+ applyWorkspaceColorFromPlacementContext("init");
324
381
 
325
382
  if (typeof placementRuntime.subscribe === "function") {
326
383
  const unsubscribePlacement = placementRuntime.subscribe((event = {}) => {
@@ -329,6 +386,7 @@ function createBootstrapPlacementRuntime({ app, logger = null, fetchBootstrap =
329
386
  }
330
387
 
331
388
  const nextContext = placementRuntime.getContext();
389
+ applyWorkspaceColorFromPlacementContext("context");
332
390
  const nextSignature = resolveAuthSignature(nextContext);
333
391
  if (nextSignature === authSignature) {
334
392
  return;
@@ -1,3 +1,6 @@
1
+ import {
2
+ createProviderLogger as createSharedProviderLogger
3
+ } from "@jskit-ai/kernel/shared/support/providerLogger";
1
4
  import {
2
5
  resolveRuntimePathname,
3
6
  resolveSurfaceIdFromPlacementPathname
@@ -12,15 +15,7 @@ import {
12
15
  import { WORKSPACE_BOOTSTRAP_STATUSES } from "./bootstrapPlacementRuntimeConstants.js";
13
16
 
14
17
  function createProviderLogger(app) {
15
- return Object.freeze({
16
- warn: (...args) => {
17
- if (app && typeof app.warn === "function") {
18
- app.warn(...args);
19
- return;
20
- }
21
- console.warn(...args);
22
- }
23
- });
18
+ return createSharedProviderLogger(app);
24
19
  }
25
20
 
26
21
  function resolveRouteState(placementRuntime, router) {
@@ -60,10 +55,6 @@ function resolveSearchFromFullPath(fullPath = "") {
60
55
  return normalizeSearch(search);
61
56
  }
62
57
 
63
- function normalizeGuardPathname(pathname = "/") {
64
- return resolveRuntimePathname(pathname);
65
- }
66
-
67
58
  function isGuardDenied(outcome) {
68
59
  if (outcome === false) {
69
60
  return true;
@@ -146,7 +137,6 @@ export {
146
137
  createProviderLogger,
147
138
  fetchBootstrapPayload,
148
139
  isGuardDenied,
149
- normalizeGuardPathname,
150
140
  normalizeSearch,
151
141
  normalizeWorkspaceBootstrapStatus,
152
142
  normalizeWorkspaceSlugKey,
@@ -1,3 +1,5 @@
1
+ import { unref } from "vue";
2
+
1
3
  function matchesCurrentWorkspaceEvent(payload = {}, workspaceSlug = "") {
2
4
  const payloadWorkspaceSlug = String(payload?.workspaceSlug || "").trim();
3
5
  if (!payloadWorkspaceSlug) {
@@ -7,6 +9,13 @@ function matchesCurrentWorkspaceEvent(payload = {}, workspaceSlug = "") {
7
9
  return payloadWorkspaceSlug === String(workspaceSlug || "").trim();
8
10
  }
9
11
 
12
+ function createWorkspaceRealtimeMatcher(workspaceSlugRef) {
13
+ return function matchesWorkspaceRealtimeEvent({ payload = {} } = {}) {
14
+ return matchesCurrentWorkspaceEvent(payload, unref(workspaceSlugRef));
15
+ };
16
+ }
17
+
10
18
  export {
11
- matchesCurrentWorkspaceEvent
19
+ matchesCurrentWorkspaceEvent,
20
+ createWorkspaceRealtimeMatcher
12
21
  };
@@ -7,6 +7,7 @@ import {
7
7
  import { WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN } from "@jskit-ai/shell-web/client/placement";
8
8
  import { REALTIME_SOCKET_CLIENT_TOKEN } from "@jskit-ai/realtime/client/tokens";
9
9
  import { USERS_BOOTSTRAP_CHANGED_EVENT } from "@jskit-ai/users-core/shared/events/usersEvents";
10
+ import { resolveWorkspaceThemePalette } from "@jskit-ai/users-core/shared/settings";
10
11
  import { ThemeSymbol } from "vuetify/lib/composables/theme.js";
11
12
  import {
12
13
  WORKSPACE_BOOTSTRAP_STATUS_FORBIDDEN,
@@ -199,6 +200,30 @@ function createVuetifyThemeController(initial = "light") {
199
200
  name: {
200
201
  value: initial
201
202
  }
203
+ },
204
+ themes: {
205
+ value: {
206
+ light: {
207
+ dark: false,
208
+ colors: {
209
+ primary: "#0f6b54",
210
+ secondary: "#3f5150",
211
+ background: "#eef3ee",
212
+ surface: "#f7fbf6",
213
+ "surface-variant": "#dfe8df"
214
+ }
215
+ },
216
+ dark: {
217
+ dark: true,
218
+ colors: {
219
+ primary: "#6fd0b5",
220
+ secondary: "#9db2af",
221
+ background: "#0f1715",
222
+ surface: "#16211e",
223
+ "surface-variant": "#253430"
224
+ }
225
+ }
226
+ }
202
227
  }
203
228
  };
204
229
  }
@@ -515,10 +540,62 @@ test("bootstrap placement runtime reapplies theme when bootstrap payload changes
515
540
  });
516
541
 
517
542
  await runtime.initialize();
518
- assert.equal(themeController.global.name.value, "dark");
543
+ assert.equal(themeController.global.name.value, "workspace-dark");
519
544
 
520
545
  socket.emit(USERS_BOOTSTRAP_CHANGED_EVENT, {});
521
546
  await flushTasks();
547
+ assert.equal(themeController.global.name.value, "workspace-light");
548
+ });
549
+
550
+ test("bootstrap placement runtime applies workspace palette via Vuetify workspace themes and clears it off workspace routes", async () => {
551
+ const placementRuntime = createPlacementRuntimeStub();
552
+ const router = createRouterStub("/w/acme/dashboard");
553
+ const themeController = createVuetifyThemeController("light");
554
+ const runtime = createBootstrapPlacementRuntime({
555
+ app: createAppStub({
556
+ [WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN]: placementRuntime,
557
+ [CLIENT_MODULE_ROUTER_TOKEN]: router,
558
+ [CLIENT_MODULE_VUE_APP_TOKEN]: createVueAppWithThemeController(themeController)
559
+ }),
560
+ fetchBootstrap: async (workspaceSlug) => {
561
+ return {
562
+ session: {
563
+ authenticated: true,
564
+ userId: 1
565
+ },
566
+ workspaces: [
567
+ {
568
+ id: 1,
569
+ slug: "acme",
570
+ name: "Acme Workspace",
571
+ color: "#CC3344"
572
+ }
573
+ ],
574
+ permissions: []
575
+ };
576
+ }
577
+ });
578
+
579
+ await runtime.initialize();
580
+ const palette = resolveWorkspaceThemePalette({
581
+ color: "#CC3344"
582
+ });
583
+ assert.equal(themeController.global.name.value, "workspace-light");
584
+ assert.equal(themeController.themes.value["workspace-light"].colors.primary, palette.color);
585
+ assert.equal(themeController.themes.value["workspace-light"].colors.secondary, palette.secondaryColor);
586
+ assert.equal(themeController.themes.value["workspace-light"].colors.surface, palette.surfaceColor);
587
+ assert.equal(
588
+ themeController.themes.value["workspace-light"].colors["surface-variant"],
589
+ palette.surfaceVariantColor
590
+ );
591
+ assert.equal(themeController.themes.value["workspace-light"].colors.background, palette.backgroundColor);
592
+
593
+ router.currentRoute.value.path = "/home";
594
+ router.currentRoute.value.fullPath = "/home";
595
+ router.emitAfterEach();
596
+ await flushTasks();
597
+ await flushTasks();
598
+
522
599
  assert.equal(themeController.global.name.value, "light");
523
600
  });
524
601
 
@@ -1,14 +1,51 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { ThemeSymbol } from "vuetify/lib/composables/theme.js";
4
+ import { resolveWorkspaceThemePalette } from "@jskit-ai/users-core/shared/settings";
4
5
  import {
6
+ hexColorToRgb,
5
7
  normalizeThemePreference,
6
8
  resolveThemeNameForPreference,
7
9
  resolveBootstrapThemeName,
8
10
  resolveVuetifyThemeController,
11
+ setVuetifyPrimaryColorOverride,
9
12
  setVuetifyThemeName
10
13
  } from "../src/client/lib/theme.js";
11
14
 
15
+ function createVuetifyThemeController(initialTheme = "light") {
16
+ return {
17
+ global: {
18
+ name: {
19
+ value: initialTheme
20
+ }
21
+ },
22
+ themes: {
23
+ value: {
24
+ light: {
25
+ dark: false,
26
+ colors: {
27
+ primary: "#0f6b54",
28
+ secondary: "#3f5150",
29
+ background: "#eef3ee",
30
+ surface: "#f7fbf6",
31
+ "surface-variant": "#dfe8df"
32
+ }
33
+ },
34
+ dark: {
35
+ dark: true,
36
+ colors: {
37
+ primary: "#6fd0b5",
38
+ secondary: "#9db2af",
39
+ background: "#0f1715",
40
+ surface: "#16211e",
41
+ "surface-variant": "#253430"
42
+ }
43
+ }
44
+ }
45
+ }
46
+ };
47
+ }
48
+
12
49
  test("normalizeThemePreference accepts known preferences and falls back to system", () => {
13
50
  assert.equal(normalizeThemePreference("light"), "light");
14
51
  assert.equal(normalizeThemePreference(" DARK "), "dark");
@@ -60,13 +97,7 @@ test("resolveBootstrapThemeName uses authenticated user preference", () => {
60
97
  });
61
98
 
62
99
  test("resolveVuetifyThemeController reads theme controller from Vue app provides", () => {
63
- const themeController = {
64
- global: {
65
- name: {
66
- value: "light"
67
- }
68
- }
69
- };
100
+ const themeController = createVuetifyThemeController("light");
70
101
  const vueApp = {
71
102
  _context: {
72
103
  provides: {
@@ -80,16 +111,44 @@ test("resolveVuetifyThemeController reads theme controller from Vue app provides
80
111
  });
81
112
 
82
113
  test("setVuetifyThemeName updates only when the value changes", () => {
83
- const themeController = {
84
- global: {
85
- name: {
86
- value: "light"
87
- }
88
- }
89
- };
114
+ const themeController = createVuetifyThemeController("light");
90
115
 
91
116
  assert.equal(setVuetifyThemeName(themeController, "light"), false);
92
117
  assert.equal(setVuetifyThemeName(themeController, "dark"), true);
93
118
  assert.equal(themeController.global.name.value, "dark");
94
119
  });
95
120
 
121
+ test("hexColorToRgb returns Vuetify rgb tuple and rejects invalid values", () => {
122
+ assert.equal(hexColorToRgb("#0f6b54"), "15,107,84");
123
+ assert.equal(hexColorToRgb("#CC3344"), "204,51,68");
124
+ assert.equal(hexColorToRgb("invalid"), "");
125
+ });
126
+
127
+ test("setVuetifyPrimaryColorOverride mutates workspace themes and restores base theme names", () => {
128
+ const themeController = createVuetifyThemeController("light");
129
+ const expectedPalette = resolveWorkspaceThemePalette({
130
+ color: "#CC3344"
131
+ });
132
+
133
+ assert.equal(setVuetifyPrimaryColorOverride(themeController, { color: "#CC3344" }), true);
134
+ 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);
138
+ assert.equal(
139
+ themeController.themes.value["workspace-light"].colors["surface-variant"],
140
+ expectedPalette.surfaceVariantColor
141
+ );
142
+ assert.equal(themeController.themes.value["workspace-light"].colors.background, expectedPalette.backgroundColor);
143
+
144
+ assert.equal(setVuetifyPrimaryColorOverride(themeController, { color: "#CC3344" }), false);
145
+
146
+ assert.equal(setVuetifyThemeName(themeController, "dark"), true);
147
+ assert.equal(setVuetifyPrimaryColorOverride(themeController, { color: "#CC3344" }), true);
148
+ assert.equal(themeController.global.name.value, "workspace-dark");
149
+ assert.equal(themeController.themes.value["workspace-dark"].colors.primary, expectedPalette.color);
150
+
151
+ assert.equal(setVuetifyPrimaryColorOverride(themeController, null), true);
152
+ assert.equal(themeController.global.name.value, "dark");
153
+ assert.equal(setVuetifyPrimaryColorOverride(themeController, null), false);
154
+ });