@jskit-ai/workspaces-web 0.1.42 → 0.1.43

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/workspaces-web",
4
- version: "0.1.42",
4
+ version: "0.1.43",
5
5
  kind: "runtime",
6
6
  description: "Workspace web module: workspace selector, tools widget, workspace surfaces, and members/settings UI.",
7
7
  dependsOn: [
@@ -41,6 +41,10 @@ export default Object.freeze({
41
41
  subpath: "./client/components/AccountSettingsInvitesSection",
42
42
  summary: "Exports the default account invites section component used by multihoming installs."
43
43
  },
44
+ {
45
+ subpath: "./client/components/WorkspaceSettingsClientElement",
46
+ summary: "Exports the workspace settings client element used by the workspace admin settings starter page."
47
+ },
44
48
  {
45
49
  subpath: "./client/providers/WorkspacesWebClientProvider",
46
50
  summary: "Exports workspaces-web client provider class."
@@ -227,9 +231,8 @@ export default Object.freeze({
227
231
  mutations: {
228
232
  dependencies: {
229
233
  runtime: {
230
- "@jskit-ai/workspaces-core": "0.1.42",
231
- "@jskit-ai/users-web": "0.1.81",
232
- "vuetify": "^4.0.0"
234
+ "@jskit-ai/workspaces-core": "0.1.43",
235
+ "@jskit-ai/users-web": "0.1.82"
233
236
  },
234
237
  dev: {}
235
238
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/workspaces-web",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -10,21 +10,22 @@
10
10
  "./client/components/AccountSettingsInvitesSection": "./src/client/components/AccountSettingsInvitesSection.vue",
11
11
  "./client/providers/WorkspacesWebClientProvider": "./src/client/providers/WorkspacesWebClientProvider.js",
12
12
  "./client/components/WorkspaceMembersClientElement": "./src/client/components/WorkspaceMembersClientElement.vue",
13
+ "./client/components/WorkspaceSettingsClientElement": "./src/client/components/WorkspaceSettingsClientElement.vue",
13
14
  "./client/composables/useWorkspaceRouteContext": "./src/client/composables/useWorkspaceRouteContext.js"
14
15
  },
15
16
  "dependencies": {
16
- "@tanstack/vue-query": "5.92.12",
17
17
  "@mdi/js": "^7.4.47",
18
- "@jskit-ai/http-runtime": "0.1.65",
19
- "@jskit-ai/kernel": "0.1.66",
20
- "@jskit-ai/shell-web": "0.1.65",
21
- "@jskit-ai/users-core": "0.1.76",
22
- "@jskit-ai/users-web": "0.1.81",
23
- "@jskit-ai/workspaces-core": "0.1.42",
24
- "vuetify": "^4.0.0"
18
+ "@jskit-ai/http-runtime": "0.1.66",
19
+ "@jskit-ai/kernel": "0.1.67",
20
+ "@jskit-ai/shell-web": "0.1.66",
21
+ "@jskit-ai/users-core": "0.1.77",
22
+ "@jskit-ai/users-web": "0.1.82",
23
+ "@jskit-ai/workspaces-core": "0.1.43"
25
24
  },
26
25
  "peerDependencies": {
26
+ "@tanstack/vue-query": "^5.90.5",
27
27
  "vue": "^3.5.13",
28
- "vue-router": "^5.0.4"
28
+ "vue-router": "^5.0.4",
29
+ "vuetify": "^4.0.0"
29
30
  }
30
31
  }
@@ -5,13 +5,13 @@ const invites = useAccountSettingsInvitesSectionRuntime();
5
5
  </script>
6
6
 
7
7
  <template>
8
- <v-card rounded="lg" elevation="0" border>
9
- <v-card-item>
10
- <v-card-title class="text-subtitle-1">Invitations</v-card-title>
11
- <v-card-subtitle>Accept or refuse workspace invitations.</v-card-subtitle>
12
- </v-card-item>
13
- <v-divider />
14
- <v-card-text>
8
+ <v-sheet rounded="lg" border class="account-invites-section">
9
+ <header class="account-invites-section__header">
10
+ <h2 class="account-invites-section__title">Invitations</h2>
11
+ <p class="text-body-2 text-medium-emphasis mb-0">Accept or refuse workspace invitations.</p>
12
+ </header>
13
+
14
+ <div class="account-invites-section__body">
15
15
  <template v-if="invites.isLoading.value">
16
16
  <v-skeleton-loader type="text@2, list-item-two-line@3" />
17
17
  </template>
@@ -67,6 +67,33 @@ const invites = useAccountSettingsInvitesSectionRuntime();
67
67
  </v-list-item>
68
68
  </v-list>
69
69
  </template>
70
- </v-card-text>
71
- </v-card>
70
+ </div>
71
+ </v-sheet>
72
72
  </template>
73
+
74
+ <style scoped>
75
+ .account-invites-section {
76
+ overflow: hidden;
77
+ }
78
+
79
+ .account-invites-section__header {
80
+ padding: 1rem 1rem 0;
81
+ }
82
+
83
+ .account-invites-section__title {
84
+ font-size: 1rem;
85
+ font-weight: 650;
86
+ line-height: 1.2;
87
+ margin: 0 0 0.25rem;
88
+ }
89
+
90
+ .account-invites-section__body {
91
+ padding: 1rem;
92
+ }
93
+
94
+ @media (max-width: 640px) {
95
+ .account-invites-section__body :deep(.v-btn) {
96
+ min-height: 48px;
97
+ }
98
+ }
99
+ </style>
@@ -1,8 +1,17 @@
1
1
  <template>
2
2
  <section class="workspace-members-page">
3
- <p v-if="loadError" class="text-body-2 text-medium-emphasis mb-4">
4
- {{ loadError }}
5
- </p>
3
+ <div v-if="loadError" class="workspace-members-page__state">
4
+ <p class="text-body-2 text-medium-emphasis mb-4">{{ loadError }}</p>
5
+ <v-btn
6
+ v-if="canRetryLoad"
7
+ color="primary"
8
+ variant="tonal"
9
+ :loading="isRetryingLoad"
10
+ @click="refreshLoad"
11
+ >
12
+ Retry
13
+ </v-btn>
14
+ </div>
6
15
 
7
16
  <MembersAdminClientElement
8
17
  v-else
@@ -38,7 +47,6 @@ import { useView } from "@jskit-ai/users-web/client/composables/useView";
38
47
  import { usePaths } from "@jskit-ai/users-web/client/composables/usePaths";
39
48
  import { useAccess } from "@jskit-ai/users-web/client/composables/useAccess";
40
49
  import { useUiFeedback } from "@jskit-ai/users-web/client/composables/runtime/useUiFeedback";
41
- import { useShellWebErrorRuntime } from "@jskit-ai/shell-web/client/error";
42
50
  import { useWorkspaceRouteContext } from "../composables/useWorkspaceRouteContext.js";
43
51
  import { createWorkspaceRealtimeMatcher } from "../support/realtimeWorkspace.js";
44
52
  import { buildWorkspaceQueryKey } from "../support/workspaceQueryKeys.js";
@@ -75,7 +83,6 @@ const removeMemberUserId = ref("");
75
83
 
76
84
  const { route, currentSurfaceId, workspaceSlugFromRoute } = useWorkspaceRouteContext();
77
85
  const usersPaths = usePaths();
78
- const errorRuntime = useShellWebErrorRuntime();
79
86
  const OWNERSHIP_WORKSPACE = ROUTE_VISIBILITY_WORKSPACE;
80
87
 
81
88
  const hasRouteWorkspaceSlug = computed(() => Boolean(workspaceSlugFromRoute.value));
@@ -423,27 +430,44 @@ const loadError = computed(() => {
423
430
  return "Workspace slug is required in the URL.";
424
431
  }
425
432
 
426
- return access.bootstrapError.value;
433
+ return (
434
+ access.bootstrapError.value ||
435
+ workspaceSettingsView.loadError ||
436
+ workspaceRolesView.loadError ||
437
+ workspaceMembersList.loadError ||
438
+ workspaceInvitesList.loadError ||
439
+ ""
440
+ );
427
441
  });
428
-
429
- watch(
430
- loadError,
431
- (nextLoadError) => {
432
- if (!nextLoadError) {
433
- return;
434
- }
435
- errorRuntime.report({
436
- source: "users-web.workspace-members-view",
437
- severity: "error",
438
- channel: "banner",
439
- message: String(nextLoadError || "Unable to load workspace members."),
440
- dedupeKey: `users-web.workspace-members-view:bootstrap:${nextLoadError}`,
441
- dedupeWindowMs: 3000
442
- });
443
- },
444
- { immediate: true }
442
+ const canRetryLoad = computed(() => Boolean(hasRouteWorkspaceSlug.value && !access.bootstrapError.value));
443
+ const isRetryingLoad = computed(() =>
444
+ Boolean(
445
+ workspaceSettingsView.isFetching ||
446
+ workspaceRolesView.isFetching ||
447
+ workspaceMembersList.isFetching ||
448
+ workspaceInvitesList.isFetching
449
+ )
445
450
  );
446
451
 
452
+ async function refreshLoad() {
453
+ if (!canRetryLoad.value) {
454
+ return;
455
+ }
456
+
457
+ const jobs = [];
458
+ if (canInviteMembers.value) {
459
+ jobs.push(workspaceSettingsView.refresh());
460
+ }
461
+ if (canInviteMembers.value || canViewMembers.value) {
462
+ jobs.push(workspaceRolesView.refresh());
463
+ }
464
+ if (canViewMembers.value) {
465
+ jobs.push(workspaceMembersList.reload());
466
+ jobs.push(workspaceInvitesList.reload());
467
+ }
468
+ await Promise.all(jobs);
469
+ }
470
+
447
471
  const actions = Object.freeze({
448
472
  submitInvite,
449
473
  submitRevokeInvite,
@@ -522,17 +546,6 @@ watch(
522
546
  { immediate: true }
523
547
  );
524
548
 
525
- watch(
526
- () => workspaceMembersList.loadError,
527
- (nextLoadError) => {
528
- if (!nextLoadError) {
529
- membersFeedback.clear();
530
- return;
531
- }
532
- membersFeedback.error(null, nextLoadError);
533
- }
534
- );
535
-
536
549
  watch(
537
550
  () => workspaceInvitesList.items,
538
551
  (nextInvites) => {
@@ -553,17 +566,6 @@ watch(
553
566
  { immediate: true }
554
567
  );
555
568
 
556
- watch(
557
- () => workspaceInvitesList.loadError,
558
- (nextLoadError) => {
559
- if (!nextLoadError) {
560
- teamFeedback.clear();
561
- return;
562
- }
563
- teamFeedback.error(null, nextLoadError);
564
- }
565
- );
566
-
567
569
  watch(
568
570
  () => route.fullPath,
569
571
  () => {
@@ -671,3 +673,18 @@ async function submitRemoveMember(member) {
671
673
  }
672
674
  }
673
675
  </script>
676
+
677
+ <style scoped>
678
+ .workspace-members-page__state {
679
+ margin-inline: auto;
680
+ max-width: 30rem;
681
+ padding: 2rem 1rem;
682
+ text-align: center;
683
+ }
684
+
685
+ @media (max-width: 640px) {
686
+ .workspace-members-page__state :deep(.v-btn) {
687
+ min-height: 48px;
688
+ }
689
+ }
690
+ </style>
@@ -1,18 +1,27 @@
1
1
  <template>
2
- <v-card class="mb-4" rounded="lg" elevation="1" border>
3
- <v-card-item>
4
- <v-card-title class="text-h6">Workspace profile</v-card-title>
5
- <v-card-subtitle>Name and avatar used across the workspace.</v-card-subtitle>
6
- </v-card-item>
7
- <v-divider />
8
- <v-card-text class="pt-4">
2
+ <v-sheet rounded="lg" border class="workspace-profile-panel">
3
+ <header class="workspace-profile-panel__header">
4
+ <h2 class="workspace-profile-panel__title">Workspace profile</h2>
5
+ <p class="text-body-2 text-medium-emphasis mb-0">Name and avatar used across the workspace.</p>
6
+ </header>
7
+
8
+ <div class="workspace-profile-panel__body">
9
9
  <template v-if="showSkeleton">
10
10
  <v-skeleton-loader type="text@2, list-item-two-line@3, button" />
11
11
  </template>
12
12
 
13
- <p v-else-if="addEdit.loadError" class="text-body-2 text-medium-emphasis mb-4">
14
- {{ addEdit.loadError }}
15
- </p>
13
+ <div v-else-if="addEdit.loadError" class="workspace-profile-panel__state">
14
+ <p class="text-body-2 text-medium-emphasis mb-4">{{ addEdit.loadError }}</p>
15
+ <v-btn
16
+ v-if="addEdit.canRetryLoad"
17
+ color="primary"
18
+ variant="tonal"
19
+ :loading="addEdit.isFetching"
20
+ @click="addEdit.refresh"
21
+ >
22
+ Retry
23
+ </v-btn>
24
+ </div>
16
25
 
17
26
  <p v-else-if="!addEdit.canView" class="text-body-2 text-medium-emphasis mb-4">
18
27
  You do not have permission to view workspace profile.
@@ -62,8 +71,8 @@
62
71
  </v-row>
63
72
  </v-form>
64
73
  </template>
65
- </v-card-text>
66
- </v-card>
74
+ </div>
75
+ </v-sheet>
67
76
  </template>
68
77
 
69
78
  <script setup>
@@ -110,3 +119,37 @@ const addEdit = useAddEdit({
110
119
 
111
120
  const showSkeleton = computed(() => Boolean(addEdit.isInitialLoading));
112
121
  </script>
122
+
123
+ <style scoped>
124
+ .workspace-profile-panel {
125
+ overflow: hidden;
126
+ }
127
+
128
+ .workspace-profile-panel__header {
129
+ padding: 1rem 1rem 0;
130
+ }
131
+
132
+ .workspace-profile-panel__title {
133
+ font-size: 1rem;
134
+ font-weight: 650;
135
+ line-height: 1.2;
136
+ margin: 0 0 0.25rem;
137
+ }
138
+
139
+ .workspace-profile-panel__body {
140
+ padding: 1rem;
141
+ }
142
+
143
+ .workspace-profile-panel__state {
144
+ margin-inline: auto;
145
+ max-width: 30rem;
146
+ padding: 1.5rem 1rem;
147
+ text-align: center;
148
+ }
149
+
150
+ @media (max-width: 640px) {
151
+ .workspace-profile-panel__body :deep(.v-btn) {
152
+ min-height: 48px;
153
+ }
154
+ }
155
+ </style>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <section class="workspace-settings-client-element">
2
+ <section class="workspace-settings-client-element d-flex flex-column ga-4">
3
3
  <WorkspaceProfileClientElement @saved="handleFormSaved" />
4
4
  <WorkspaceSettingsFieldsClientElement @saved="handleFormSaved" />
5
5
  </section>
@@ -1,18 +1,27 @@
1
1
  <template>
2
- <v-card rounded="lg" elevation="1" border>
3
- <v-card-item>
4
- <v-card-title class="text-h6">Workspace settings</v-card-title>
5
- <v-card-subtitle>These values apply to everyone in this workspace.</v-card-subtitle>
6
- </v-card-item>
7
- <v-divider />
8
- <v-card-text class="pt-4">
2
+ <v-sheet rounded="lg" border class="workspace-settings-panel">
3
+ <header class="workspace-settings-panel__header">
4
+ <h2 class="workspace-settings-panel__title">Workspace settings</h2>
5
+ <p class="text-body-2 text-medium-emphasis mb-0">These values apply to everyone in this workspace.</p>
6
+ </header>
7
+
8
+ <div class="workspace-settings-panel__body">
9
9
  <template v-if="showSkeleton">
10
10
  <v-skeleton-loader type="text@2, list-item-two-line@4, button" />
11
11
  </template>
12
12
 
13
- <p v-else-if="addEdit.loadError" class="text-body-2 text-medium-emphasis mb-4">
14
- {{ addEdit.loadError }}
15
- </p>
13
+ <div v-else-if="addEdit.loadError" class="workspace-settings-panel__state">
14
+ <p class="text-body-2 text-medium-emphasis mb-4">{{ addEdit.loadError }}</p>
15
+ <v-btn
16
+ v-if="addEdit.canRetryLoad"
17
+ color="primary"
18
+ variant="tonal"
19
+ :loading="addEdit.isFetching"
20
+ @click="addEdit.refresh"
21
+ >
22
+ Retry
23
+ </v-btn>
24
+ </div>
16
25
 
17
26
  <p v-else-if="!addEdit.canView" class="text-body-2 text-medium-emphasis mb-4">
18
27
  You do not have permission to view workspace settings.
@@ -162,8 +171,8 @@
162
171
  </v-row>
163
172
  </v-form>
164
173
  </template>
165
- </v-card-text>
166
- </v-card>
174
+ </div>
175
+ </v-sheet>
167
176
  </template>
168
177
 
169
178
  <script setup>
@@ -259,3 +268,37 @@ const addEdit = useAddEdit({
259
268
 
260
269
  const showSkeleton = computed(() => Boolean(addEdit.isInitialLoading));
261
270
  </script>
271
+
272
+ <style scoped>
273
+ .workspace-settings-panel {
274
+ overflow: hidden;
275
+ }
276
+
277
+ .workspace-settings-panel__header {
278
+ padding: 1rem 1rem 0;
279
+ }
280
+
281
+ .workspace-settings-panel__title {
282
+ font-size: 1rem;
283
+ font-weight: 650;
284
+ line-height: 1.2;
285
+ margin: 0 0 0.25rem;
286
+ }
287
+
288
+ .workspace-settings-panel__body {
289
+ padding: 1rem;
290
+ }
291
+
292
+ .workspace-settings-panel__state {
293
+ margin-inline: auto;
294
+ max-width: 30rem;
295
+ padding: 1.5rem 1rem;
296
+ text-align: center;
297
+ }
298
+
299
+ @media (max-width: 640px) {
300
+ .workspace-settings-panel__body :deep(.v-btn) {
301
+ min-height: 48px;
302
+ }
303
+ }
304
+ </style>
@@ -163,13 +163,14 @@ const workspaceInvitesEnabled = computed(() => bootstrapModel.workspaceInvitesEn
163
163
 
164
164
  const isBootstrapping = computed(() => Boolean(bootstrapView.isLoading));
165
165
  const isRefreshingBootstrap = computed(() => Boolean(bootstrapView.isRefetching));
166
+ const bootstrapLoadError = computed(() => String(bootstrapView.loadError || "").trim());
166
167
  const canCreateWorkspace = computed(() => bootstrapModel.workspaceAllowSelfCreate === true);
167
168
  const isCreatingWorkspace = computed(() => Boolean(createWorkspaceCommand.isRunning.value));
168
169
 
169
170
  function reportFeedback({
170
171
  message,
171
172
  severity = "error",
172
- channel = "banner",
173
+ channel = "",
173
174
  dedupeKey = ""
174
175
  } = {}) {
175
176
  const normalizedMessage = String(message || "").trim();
@@ -180,6 +181,7 @@ function reportFeedback({
180
181
  errorRuntime.report({
181
182
  source: "users-web.workspaces-view",
182
183
  message: normalizedMessage,
184
+ intent: "action-feedback",
183
185
  severity,
184
186
  channel,
185
187
  dedupeKey: dedupeKey || `users-web.workspaces-view:${severity}:${normalizedMessage}`,
@@ -233,7 +235,6 @@ async function openWorkspace(workspaceSlug) {
233
235
  reportFeedback({
234
236
  message: "Workspace surface is not configured.",
235
237
  severity: "error",
236
- channel: "banner",
237
238
  dedupeKey: "users-web.workspaces-view:workspace-surface-missing"
238
239
  });
239
240
  return;
@@ -258,7 +259,6 @@ async function openWorkspace(workspaceSlug) {
258
259
  reportFeedback({
259
260
  message: String(error?.message || "Unable to open workspace."),
260
261
  severity: "error",
261
- channel: "banner",
262
262
  dedupeKey: `users-web.workspaces-view:open-workspace:${normalizedSlug}`
263
263
  });
264
264
  } finally {
@@ -301,7 +301,6 @@ async function respondToInvite(invite, decision) {
301
301
  reportFeedback({
302
302
  message: "Invitation refused.",
303
303
  severity: "success",
304
- channel: "snackbar",
305
304
  dedupeKey: `users-web.workspaces-view:invite-refused:${token}`
306
305
  });
307
306
  } catch (error) {
@@ -310,7 +309,6 @@ async function respondToInvite(invite, decision) {
310
309
  error?.message || (normalizedDecision === "accept" ? "Unable to accept invite." : "Unable to refuse invite.")
311
310
  ),
312
311
  severity: "error",
313
- channel: "banner",
314
312
  dedupeKey: `users-web.workspaces-view:invite-${normalizedDecision}:${token}`
315
313
  });
316
314
  } finally {
@@ -341,7 +339,6 @@ async function createWorkspace() {
341
339
  reportFeedback({
342
340
  message: "Workspace name is required.",
343
341
  severity: "error",
344
- channel: "banner",
345
342
  dedupeKey: "users-web.workspaces-view:create-workspace-name-required"
346
343
  });
347
344
  return;
@@ -361,12 +358,15 @@ async function createWorkspace() {
361
358
  reportFeedback({
362
359
  message: String(error?.message || "Unable to create workspace."),
363
360
  severity: "error",
364
- channel: "banner",
365
361
  dedupeKey: "users-web.workspaces-view:create-workspace-error"
366
362
  });
367
363
  }
368
364
  }
369
365
 
366
+ async function refreshBootstrap() {
367
+ return bootstrapView.refresh();
368
+ }
369
+
370
370
  watch(
371
371
  () => bootstrapView.resource.data.value,
372
372
  async (payload) => {
@@ -401,20 +401,31 @@ watch(
401
401
  <template>
402
402
  <section class="workspaces-view py-6">
403
403
  <v-container class="mx-auto" max-width="860">
404
- <v-card rounded="lg" border elevation="1">
405
- <v-card-item>
406
- <v-card-title class="text-h6">You are logged in</v-card-title>
407
- <v-card-subtitle>Select a workspace or respond to invitations.</v-card-subtitle>
408
- </v-card-item>
409
- <v-divider />
410
-
411
- <v-card-text class="pt-4">
404
+ <v-sheet rounded="lg" border class="workspaces-view__panel">
405
+ <header class="workspaces-view__header">
406
+ <h1 class="workspaces-view__title">You are logged in</h1>
407
+ <p class="text-body-2 text-medium-emphasis mb-0">Select a workspace or respond to invitations.</p>
408
+ </header>
409
+
410
+ <div class="workspaces-view__body">
412
411
  <v-progress-linear v-if="!isBootstrapping && isRefreshingBootstrap" indeterminate class="mb-4" />
413
412
  <v-row>
414
413
  <v-col cols="12" :md="workspaceInvitesEnabled ? 6 : 12">
415
414
  <template v-if="isBootstrapping">
416
415
  <v-skeleton-loader type="text, list-item-avatar-two-line@3" />
417
416
  </template>
417
+ <div v-else-if="bootstrapLoadError" class="workspaces-view__state">
418
+ <h2 class="text-h6 mb-2">Unable to load workspaces</h2>
419
+ <p class="text-body-2 text-medium-emphasis mb-4">{{ bootstrapLoadError }}</p>
420
+ <v-btn
421
+ color="primary"
422
+ variant="tonal"
423
+ :loading="isRefreshingBootstrap"
424
+ @click="refreshBootstrap"
425
+ >
426
+ Retry
427
+ </v-btn>
428
+ </div>
418
429
  <template v-else>
419
430
  <div class="text-subtitle-2 mb-2">Your workspaces</div>
420
431
  <template v-if="workspaceItems.length === 0">
@@ -539,8 +550,47 @@ watch(
539
550
  </template>
540
551
  </v-col>
541
552
  </v-row>
542
- </v-card-text>
543
- </v-card>
553
+ </div>
554
+ </v-sheet>
544
555
  </v-container>
545
556
  </section>
546
557
  </template>
558
+
559
+ <style scoped>
560
+ .workspaces-view__panel {
561
+ overflow: hidden;
562
+ }
563
+
564
+ .workspaces-view__header {
565
+ padding: 1rem 1rem 0;
566
+ }
567
+
568
+ .workspaces-view__title {
569
+ font-size: clamp(1.35rem, 2vw, 1.85rem);
570
+ font-weight: 650;
571
+ letter-spacing: -0.02em;
572
+ line-height: 1.15;
573
+ margin: 0 0 0.35rem;
574
+ }
575
+
576
+ .workspaces-view__body {
577
+ padding: 1rem;
578
+ }
579
+
580
+ .workspaces-view__state {
581
+ margin-inline: auto;
582
+ max-width: 30rem;
583
+ padding: 2rem 1rem;
584
+ text-align: center;
585
+ }
586
+
587
+ @media (max-width: 640px) {
588
+ .workspaces-view {
589
+ padding-block: 0.75rem !important;
590
+ }
591
+
592
+ .workspaces-view__body :deep(.v-btn) {
593
+ min-height: 48px;
594
+ }
595
+ }
596
+ </style>
@@ -18,17 +18,36 @@ const normalizedMessage = computed(() => String(props.message || "").trim() || "
18
18
  </script>
19
19
 
20
20
  <template>
21
- <v-card rounded="lg" elevation="1" border>
22
- <v-card-item>
23
- <template #prepend>
24
- <v-icon :icon="mdiAlertCircleOutline" color="error" />
25
- </template>
26
- <v-card-title class="text-h5">Unavailable</v-card-title>
27
- <v-card-subtitle>{{ normalizedSurfaceLabel }} surface.</v-card-subtitle>
28
- </v-card-item>
29
- <v-divider />
30
- <v-card-text class="d-flex flex-column ga-4">
31
- <p class="text-medium-emphasis mb-0">{{ normalizedMessage }}</p>
32
- </v-card-text>
33
- </v-card>
21
+ <v-sheet rounded="lg" border class="workspace-unavailable-panel">
22
+ <div class="workspace-unavailable-panel__header">
23
+ <v-icon :icon="mdiAlertCircleOutline" color="error" />
24
+ <div>
25
+ <h1 class="workspace-unavailable-panel__title">Unavailable</h1>
26
+ <p class="text-body-2 text-medium-emphasis mb-0">{{ normalizedSurfaceLabel }} surface.</p>
27
+ </div>
28
+ </div>
29
+ <p class="text-body-2 text-medium-emphasis mb-0">{{ normalizedMessage }}</p>
30
+ </v-sheet>
34
31
  </template>
32
+
33
+ <style scoped>
34
+ .workspace-unavailable-panel {
35
+ display: grid;
36
+ gap: 1rem;
37
+ padding: 1rem;
38
+ }
39
+
40
+ .workspace-unavailable-panel__header {
41
+ align-items: flex-start;
42
+ display: flex;
43
+ gap: 0.75rem;
44
+ }
45
+
46
+ .workspace-unavailable-panel__title {
47
+ font-size: clamp(1.35rem, 2vw, 1.85rem);
48
+ font-weight: 650;
49
+ letter-spacing: -0.02em;
50
+ line-height: 1.15;
51
+ margin: 0 0 0.25rem;
52
+ }
53
+ </style>
@@ -1,11 +1,7 @@
1
1
  <script setup>
2
- // To redirect this settings shell to a child page, uncomment and edit the example below.
3
- // import { redirectToChild } from "@jskit-ai/kernel/client/pageRedirects";
4
- // definePage({
5
- // redirect: redirectToChild("your_child_segment")
6
- // });
2
+ import WorkspaceSettingsClientElement from "@jskit-ai/workspaces-web/client/components/WorkspaceSettingsClientElement";
7
3
  </script>
8
4
 
9
5
  <template>
10
- <div />
6
+ <WorkspaceSettingsClientElement />
11
7
  </template>
@@ -5,25 +5,66 @@ import { RouterView } from "vue-router";
5
5
 
6
6
  <template>
7
7
  <section class="settings-shell d-flex flex-column ga-4">
8
- <v-card rounded="lg" elevation="1" border>
9
- <v-card-item>
10
- <v-card-title>Workspace settings</v-card-title>
11
- <v-card-subtitle>Configure the current workspace and its members.</v-card-subtitle>
12
- </v-card-item>
13
- <v-divider />
14
- <v-card-text class="pt-4">
15
- <v-row no-gutters>
16
- <v-col cols="12" md="3" lg="2" class="pr-md-4 mb-4 mb-md-0">
17
- <v-list nav density="comfortable" rounded="lg" border>
18
- <ShellOutlet target="admin-settings:primary-menu" />
19
- </v-list>
20
- </v-col>
21
-
22
- <v-col cols="12" md="9" lg="10">
23
- <RouterView />
24
- </v-col>
25
- </v-row>
26
- </v-card-text>
27
- </v-card>
8
+ <header>
9
+ <p class="text-overline text-medium-emphasis mb-1">Settings</p>
10
+ <h1 class="settings-shell__title">Workspace settings</h1>
11
+ <p class="text-body-2 text-medium-emphasis mb-0">Configure the current workspace and its members.</p>
12
+ </header>
13
+
14
+ <v-sheet rounded="lg" border class="settings-shell__panel">
15
+ <nav class="settings-shell__nav" aria-label="Workspace settings sections">
16
+ <v-list nav density="comfortable" class="settings-shell__nav-list">
17
+ <ShellOutlet target="admin-settings:primary-menu" />
18
+ </v-list>
19
+ </nav>
20
+ <main class="settings-shell__content">
21
+ <RouterView />
22
+ </main>
23
+ </v-sheet>
28
24
  </section>
29
25
  </template>
26
+
27
+ <style scoped>
28
+ .settings-shell__title {
29
+ font-size: clamp(1.35rem, 2vw, 1.85rem);
30
+ font-weight: 650;
31
+ letter-spacing: -0.02em;
32
+ line-height: 1.15;
33
+ margin: 0 0 0.35rem;
34
+ }
35
+
36
+ .settings-shell__panel {
37
+ display: grid;
38
+ gap: 1rem;
39
+ grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr);
40
+ padding: 1rem;
41
+ }
42
+
43
+ .settings-shell__nav-list {
44
+ padding: 0;
45
+ }
46
+
47
+ .settings-shell__content {
48
+ min-width: 0;
49
+ }
50
+
51
+ @media (max-width: 960px) {
52
+ .settings-shell__panel {
53
+ grid-template-columns: 1fr;
54
+ }
55
+
56
+ .settings-shell__nav {
57
+ overflow-x: auto;
58
+ }
59
+
60
+ .settings-shell__nav-list {
61
+ display: flex;
62
+ gap: 0.5rem;
63
+ min-width: max-content;
64
+ }
65
+
66
+ .settings-shell__nav-list :deep(.v-list-item) {
67
+ min-height: 48px;
68
+ }
69
+ }
70
+ </style>
@@ -11,19 +11,67 @@ const { workspaceUnavailable, workspaceUnavailableMessage } = useWorkspaceNotFou
11
11
  :message="workspaceUnavailableMessage"
12
12
  surface-label="Admin"
13
13
  />
14
- <v-card v-else rounded="lg" elevation="1" border>
15
- <v-card-item>
16
- <template #prepend>
17
- <v-chip color="primary" size="small" label>Admin</v-chip>
18
- </template>
19
- <v-card-title class="text-h5">Workspace Admin</v-card-title>
20
- <v-card-subtitle>Privileged workspace workflows.</v-card-subtitle>
21
- </v-card-item>
22
- <v-divider />
23
- <v-card-text class="d-flex flex-column ga-4">
24
- <p class="text-medium-emphasis mb-0">
25
- Use this area for workspace administration modules.
14
+ <section v-else class="workspace-admin-screen d-flex flex-column ga-4">
15
+ <header class="workspace-admin-screen__header">
16
+ <div>
17
+ <p class="text-overline text-medium-emphasis mb-1">Admin</p>
18
+ <h1 class="workspace-admin-screen__title">Workspace Admin</h1>
19
+ <p class="text-body-2 text-medium-emphasis mb-0">Manage members and workspace settings.</p>
20
+ </div>
21
+ <div class="workspace-admin-screen__actions">
22
+ <v-btn color="primary" variant="flat" to="./members">Members</v-btn>
23
+ <v-btn color="primary" variant="tonal" to="./workspace/settings">Settings</v-btn>
24
+ </div>
25
+ </header>
26
+
27
+ <v-sheet rounded="lg" border class="workspace-admin-screen__panel">
28
+ <h2 class="text-h6 mb-2">Admin tasks</h2>
29
+ <p class="text-body-2 text-medium-emphasis mb-0">
30
+ Review workspace access, members, and operational settings from this surface.
26
31
  </p>
27
- </v-card-text>
28
- </v-card>
32
+ </v-sheet>
33
+ </section>
29
34
  </template>
35
+
36
+ <style scoped>
37
+ .workspace-admin-screen__header {
38
+ align-items: flex-start;
39
+ display: flex;
40
+ gap: 1rem;
41
+ justify-content: space-between;
42
+ }
43
+
44
+ .workspace-admin-screen__title {
45
+ font-size: clamp(1.5rem, 2.5vw, 2.25rem);
46
+ font-weight: 700;
47
+ letter-spacing: -0.03em;
48
+ line-height: 1.1;
49
+ margin: 0 0 0.4rem;
50
+ }
51
+
52
+ .workspace-admin-screen__actions {
53
+ display: flex;
54
+ flex-wrap: wrap;
55
+ gap: 0.5rem;
56
+ justify-content: flex-end;
57
+ }
58
+
59
+ .workspace-admin-screen__panel {
60
+ padding: 1rem;
61
+ }
62
+
63
+ @media (max-width: 640px) {
64
+ .workspace-admin-screen__header {
65
+ flex-direction: column;
66
+ }
67
+
68
+ .workspace-admin-screen__actions {
69
+ width: 100%;
70
+ }
71
+
72
+ .workspace-admin-screen__actions :deep(.v-btn) {
73
+ min-height: 48px;
74
+ flex: 1 1 10rem;
75
+ }
76
+ }
77
+ </style>
@@ -11,17 +11,36 @@ const { workspaceUnavailable, workspaceUnavailableMessage } = useWorkspaceNotFou
11
11
  :message="workspaceUnavailableMessage"
12
12
  surface-label="App"
13
13
  />
14
- <v-card v-else rounded="lg" elevation="1" border>
15
- <v-card-item>
16
- <template #prepend>
17
- <v-chip color="primary" size="small" label>App</v-chip>
18
- </template>
19
- <v-card-title class="text-h5">Workspace Home</v-card-title>
20
- <v-card-subtitle>Primary in-workspace surface.</v-card-subtitle>
21
- </v-card-item>
22
- <v-divider />
23
- <v-card-text class="d-flex flex-column ga-4">
24
- <p class="text-medium-emphasis mb-0">Replace this page with your workspace dashboard modules.</p>
25
- </v-card-text>
26
- </v-card>
14
+ <section v-else class="workspace-home-screen d-flex flex-column ga-4">
15
+ <header>
16
+ <p class="text-overline text-medium-emphasis mb-1">Workspace</p>
17
+ <h1 class="workspace-home-screen__title">Workspace Home</h1>
18
+ <p class="text-body-2 text-medium-emphasis mb-0">The current workspace is active.</p>
19
+ </header>
20
+
21
+ <v-sheet rounded="lg" border class="workspace-home-screen__panel">
22
+ <h2 class="text-h6 mb-2">No workspace activity yet</h2>
23
+ <p class="text-body-2 text-medium-emphasis mb-0">
24
+ Activity from workspace workflows will appear here.
25
+ </p>
26
+ </v-sheet>
27
+ </section>
27
28
  </template>
29
+
30
+ <style scoped>
31
+ .workspace-home-screen__title {
32
+ font-size: clamp(1.5rem, 2.5vw, 2.25rem);
33
+ font-weight: 700;
34
+ letter-spacing: -0.03em;
35
+ line-height: 1.1;
36
+ margin: 0 0 0.4rem;
37
+ }
38
+
39
+ .workspace-home-screen__panel {
40
+ margin-inline: auto;
41
+ max-width: 34rem;
42
+ padding: 2rem 1.25rem;
43
+ text-align: center;
44
+ width: 100%;
45
+ }
46
+ </style>
@@ -16,6 +16,7 @@ test("workspaces-web exports are explicit and aligned with production usage", ()
16
16
  requiredExports: [
17
17
  "./client",
18
18
  "./client/components/AccountSettingsInvitesSection",
19
+ "./client/components/WorkspaceSettingsClientElement",
19
20
  "./client/providers/WorkspacesWebClientProvider",
20
21
  "./client/composables/useWorkspaceRouteContext"
21
22
  ]
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import test from "node:test";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { assertGeneratedUiSourceContract } from "@jskit-ai/kernel/shared/support/generatedUiContract";
6
7
  import descriptor from "../package.descriptor.mjs";
7
8
 
8
9
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
@@ -56,8 +57,25 @@ test("workspaces-web admin settings template exposes surface-derived settings ou
56
57
  "utf8"
57
58
  );
58
59
 
60
+ assertGeneratedUiSourceContract(source, {
61
+ forbidCardShell: true,
62
+ sourceName: "admin/workspace/settings.vue",
63
+ requiredPatterns: [
64
+ {
65
+ id: "admin-settings-outlet",
66
+ pattern: /target="admin-settings:primary-menu"/,
67
+ message: "Admin settings shell needs the semantic settings outlet host."
68
+ },
69
+ {
70
+ id: "admin-settings-router-view",
71
+ pattern: /<RouterView \/>/,
72
+ message: "Admin settings shell needs to host child settings routes."
73
+ }
74
+ ]
75
+ });
59
76
  assert.match(source, /target="admin-settings:primary-menu"/);
60
77
  assert.doesNotMatch(source, /default-link-component-token/);
78
+ assert.doesNotMatch(source, /<v-card\b/);
61
79
  assert.match(source, /<RouterView \/>/);
62
80
  });
63
81
 
@@ -80,6 +98,38 @@ test("workspaces-web installs an app-owned account invites section wrapper", asy
80
98
  });
81
99
  });
82
100
 
101
+ test("workspaces-web settings components use direct panels instead of card scaffolds", async () => {
102
+ for (const relativePath of [
103
+ path.join("templates", "src", "components", "WorkspaceNotFoundCard.vue"),
104
+ path.join("src", "client", "components", "WorkspaceProfileClientElement.vue"),
105
+ path.join("src", "client", "components", "WorkspaceSettingsFieldsClientElement.vue"),
106
+ path.join("src", "client", "components", "WorkspacesClientElement.vue"),
107
+ path.join("src", "client", "components", "AccountSettingsInvitesSection.vue")
108
+ ]) {
109
+ const source = await readFile(path.join(PACKAGE_DIR, relativePath), "utf8");
110
+
111
+ assertGeneratedUiSourceContract(source, {
112
+ forbidCardShell: true,
113
+ sourceName: relativePath
114
+ });
115
+ assert.doesNotMatch(source, /<v-card\b|v-card-title|v-card-subtitle/);
116
+ }
117
+ });
118
+
119
+ test("workspaces-web resource load states expose local retry actions", async () => {
120
+ const expectations = new Map([
121
+ ["src/client/components/WorkspaceProfileClientElement.vue", /addEdit\.canRetryLoad[\s\S]*@click="addEdit\.refresh"/],
122
+ ["src/client/components/WorkspaceSettingsFieldsClientElement.vue", /addEdit\.canRetryLoad[\s\S]*@click="addEdit\.refresh"/],
123
+ ["src/client/components/WorkspacesClientElement.vue", /bootstrapLoadError[\s\S]*@click="refreshBootstrap"/],
124
+ ["src/client/components/WorkspaceMembersClientElement.vue", /canRetryLoad[\s\S]*@click="refreshLoad"/]
125
+ ]);
126
+
127
+ for (const [relativePath, pattern] of expectations) {
128
+ const source = await readFile(path.join(PACKAGE_DIR, relativePath), "utf8");
129
+ assert.match(source, pattern, `${relativePath} must expose a local retry action for load errors.`);
130
+ }
131
+ });
132
+
83
133
  test("workspaces-web installs an account invites cue scaffold that reads placement runtime state", async () => {
84
134
  const source = await readFile(
85
135
  path.join(PACKAGE_DIR, "templates", "packages", "main", "src", "client", "components", "AccountPendingInvitesCue.vue"),
@@ -99,14 +149,60 @@ test("workspaces-web installs an account invites cue scaffold that reads placeme
99
149
  });
100
150
  });
101
151
 
102
- test("workspaces-web admin settings index template is a simple developer-owned stub", async () => {
152
+ test("workspaces-web admin settings index template renders real workspace settings controls", async () => {
103
153
  const source = await readFile(
104
154
  path.join(PACKAGE_DIR, "templates", "src", "pages", "admin", "workspace", "settings", "index.vue"),
105
155
  "utf8"
106
156
  );
107
157
 
108
- assert.match(source, /definePage/);
109
- assert.match(source, /your_child_segment/);
158
+ assert.match(source, /@jskit-ai\/workspaces-web\/client\/components\/WorkspaceSettingsClientElement/);
159
+ assert.match(source, /<WorkspaceSettingsClientElement \/>/);
160
+ assert.doesNotMatch(source, /your_child_segment|To redirect this settings shell/);
161
+ });
162
+
163
+ test("workspaces-web starter surfaces avoid instructional placeholder copy", async () => {
164
+ const appSource = await readFile(
165
+ path.join(PACKAGE_DIR, "templates", "src", "surfaces", "app", "index.vue"),
166
+ "utf8"
167
+ );
168
+ const adminSource = await readFile(
169
+ path.join(PACKAGE_DIR, "templates", "src", "surfaces", "admin", "index.vue"),
170
+ "utf8"
171
+ );
172
+
173
+ assertGeneratedUiSourceContract(appSource, {
174
+ forbidCardShell: true,
175
+ sourceName: "workspaces app surface",
176
+ requiredPatterns: [
177
+ {
178
+ id: "workspace-app-empty-state",
179
+ pattern: /No workspace activity yet/,
180
+ message: "Workspace app surface needs a product-shaped empty state."
181
+ }
182
+ ]
183
+ });
184
+ assertGeneratedUiSourceContract(adminSource, {
185
+ forbidCardShell: true,
186
+ sourceName: "workspaces admin surface",
187
+ requiredPatterns: [
188
+ {
189
+ id: "workspace-admin-member-link",
190
+ pattern: /to="\.\/members"/,
191
+ message: "Workspace admin surface needs a direct members action."
192
+ },
193
+ {
194
+ id: "workspace-admin-settings-link",
195
+ pattern: /to="\.\/workspace\/settings"/,
196
+ message: "Workspace admin surface needs a direct settings action."
197
+ }
198
+ ]
199
+ });
200
+ assert.match(appSource, /No workspace activity yet/);
201
+ assert.match(adminSource, /Manage members and workspace settings/);
202
+ assert.match(adminSource, /to="\.\/members"/);
203
+ assert.match(adminSource, /to="\.\/workspace\/settings"/);
204
+ assert.doesNotMatch(appSource, /Replace this page|Primary in-workspace surface/);
205
+ assert.doesNotMatch(adminSource, /Use this area|Privileged workspace workflows/);
110
206
  });
111
207
 
112
208
  test("workspaces-web descriptor metadata advertises admin settings outlets", () => {