@jskit-ai/users-core 0.1.11 → 0.1.13

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-core",
4
- version: "0.1.11",
4
+ version: "0.1.13",
5
5
  description: "Users/workspace domain runtime plus HTTP routes for workspace, account, and console features.",
6
6
  dependsOn: [
7
7
  "@jskit-ai/auth-core",
@@ -214,7 +214,14 @@ export default Object.freeze({
214
214
  dev: {}
215
215
  },
216
216
  packageJson: {
217
- scripts: {}
217
+ scripts: {
218
+ "server:app": "SERVER_SURFACE=app node ./bin/server.js",
219
+ "server:admin": "SERVER_SURFACE=admin node ./bin/server.js",
220
+ "dev:app": "VITE_SURFACE=app vite",
221
+ "dev:admin": "VITE_SURFACE=admin vite",
222
+ "build:app": "VITE_SURFACE=app vite build",
223
+ "build:admin": "VITE_SURFACE=admin vite build"
224
+ }
218
225
  },
219
226
  procfile: {},
220
227
  files: [
@@ -222,7 +229,6 @@ export default Object.freeze({
222
229
  op: "install-migration",
223
230
  from: "templates/migrations/users_core_initial.cjs",
224
231
  toDir: "migrations",
225
- slug: "users_core_initial",
226
232
  extension: ".cjs",
227
233
  reason: "Install users/workspace core schema migration.",
228
234
  category: "migration",
@@ -232,7 +238,6 @@ export default Object.freeze({
232
238
  op: "install-migration",
233
239
  from: "templates/migrations/users_core_profile_username.cjs",
234
240
  toDir: "migrations",
235
- slug: "users_core_profile_username",
236
241
  extension: ".cjs",
237
242
  reason: "Install users profile username migration.",
238
243
  category: "migration",
@@ -242,7 +247,6 @@ export default Object.freeze({
242
247
  op: "install-migration",
243
248
  from: "templates/migrations/users_core_console_owner.cjs",
244
249
  toDir: "migrations",
245
- slug: "users_core_console_owner",
246
250
  extension: ".cjs",
247
251
  reason: "Install users/workspace console owner migration.",
248
252
  category: "migration",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-core",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -1,12 +1,21 @@
1
- import { coerceWorkspaceColor } from "../../../shared/settings.js";
1
+ import {
2
+ coerceWorkspaceColor,
3
+ coerceWorkspaceSecondaryColor,
4
+ coerceWorkspaceSurfaceColor,
5
+ coerceWorkspaceSurfaceVariantColor
6
+ } from "../../../shared/settings.js";
2
7
  import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
3
8
 
4
9
  function mapWorkspaceSummary(workspace, membership) {
10
+ const color = coerceWorkspaceColor(workspace.color);
5
11
  return {
6
12
  id: Number(workspace.id),
7
13
  slug: normalizeText(workspace.slug),
8
14
  name: normalizeText(workspace.name),
9
- color: coerceWorkspaceColor(workspace.color),
15
+ color,
16
+ secondaryColor: coerceWorkspaceSecondaryColor(workspace.secondaryColor, { color }),
17
+ surfaceColor: coerceWorkspaceSurfaceColor(workspace.surfaceColor, { color }),
18
+ surfaceVariantColor: coerceWorkspaceSurfaceVariantColor(workspace.surfaceVariantColor, { color }),
10
19
  avatarUrl: normalizeText(workspace.avatarUrl),
11
20
  roleId: normalizeLowerText(membership?.roleId || "member") || "member",
12
21
  isAccessible: normalizeLowerText(membership?.status || "active") === "active"
@@ -17,9 +26,13 @@ function mapWorkspaceSettingsPublic(workspaceSettings, { workspaceInvitationsEna
17
26
  const source = workspaceSettings && typeof workspaceSettings === "object" ? workspaceSettings : {};
18
27
  const invitesAvailable = workspaceInvitationsEnabled === true;
19
28
  const invitesEnabled = invitesAvailable && source.invitesEnabled !== false;
29
+ const color = coerceWorkspaceColor(source.color);
20
30
  return {
21
31
  name: normalizeText(source.name),
22
- color: coerceWorkspaceColor(source.color),
32
+ color,
33
+ secondaryColor: coerceWorkspaceSecondaryColor(source.secondaryColor, { color }),
34
+ surfaceColor: coerceWorkspaceSurfaceColor(source.surfaceColor, { color }),
35
+ surfaceVariantColor: coerceWorkspaceSurfaceVariantColor(source.surfaceVariantColor, { color }),
23
36
  avatarUrl: normalizeText(source.avatarUrl),
24
37
  invitesEnabled,
25
38
  invitesAvailable,
@@ -6,12 +6,18 @@ import {
6
6
  nowDb,
7
7
  isDuplicateEntryError
8
8
  } from "./repositoryUtils.js";
9
- import { coerceWorkspaceColor } from "../../../shared/settings.js";
9
+ import {
10
+ coerceWorkspaceColor,
11
+ coerceWorkspaceSecondaryColor,
12
+ coerceWorkspaceSurfaceColor,
13
+ coerceWorkspaceSurfaceVariantColor
14
+ } from "../../../shared/settings.js";
10
15
 
11
16
  function mapRow(row) {
12
17
  if (!row) {
13
18
  return null;
14
19
  }
20
+ const color = coerceWorkspaceColor(row.color);
15
21
 
16
22
  return {
17
23
  id: Number(row.id),
@@ -20,7 +26,16 @@ function mapRow(row) {
20
26
  ownerUserId: Number(row.owner_user_id),
21
27
  isPersonal: Boolean(row.is_personal),
22
28
  avatarUrl: row.avatar_url ? normalizeText(row.avatar_url) : "",
23
- color: coerceWorkspaceColor(row.color),
29
+ color,
30
+ secondaryColor: coerceWorkspaceSecondaryColor(row.secondary_color, {
31
+ color
32
+ }),
33
+ surfaceColor: coerceWorkspaceSurfaceColor(row.surface_color, {
34
+ color
35
+ }),
36
+ surfaceVariantColor: coerceWorkspaceSurfaceVariantColor(row.surface_variant_color, {
37
+ color
38
+ }),
24
39
  createdAt: toIsoString(row.created_at),
25
40
  updatedAt: toIsoString(row.updated_at),
26
41
  deletedAt: toNullableIso(row.deleted_at)
@@ -53,6 +68,9 @@ function createRepository(knex) {
53
68
  "w.is_personal",
54
69
  client.raw("COALESCE(ws.avatar_url, w.avatar_url) as avatar_url"),
55
70
  client.raw("COALESCE(ws.color, w.color) as color"),
71
+ client.raw("COALESCE(ws.secondary_color, ws.color, w.color) as secondary_color"),
72
+ client.raw("COALESCE(ws.surface_color, ws.color, w.color) as surface_color"),
73
+ client.raw("COALESCE(ws.surface_variant_color, ws.color, w.color) as surface_variant_color"),
56
74
  "w.created_at",
57
75
  "w.updated_at",
58
76
  "w.deleted_at"
@@ -1,17 +1 @@
1
- function deepFreeze(value, seen = new WeakSet()) {
2
- if (!value || typeof value !== "object") {
3
- return value;
4
- }
5
- if (seen.has(value)) {
6
- return value;
7
- }
8
-
9
- seen.add(value);
10
- for (const key of Object.keys(value)) {
11
- deepFreeze(value[key], seen);
12
- }
13
-
14
- return Object.freeze(value);
15
- }
16
-
17
- export { deepFreeze };
1
+ export { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
@@ -1,6 +1,4 @@
1
- function normalizeObject(value) {
2
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
3
- }
1
+ import { normalizeObject } from "@jskit-ai/kernel/shared/support/normalize";
4
2
 
5
3
  function resolveActionUser(context, input) {
6
4
  const payload = normalizeObject(input);
@@ -8,9 +8,20 @@ import {
8
8
  } from "./roles.js";
9
9
 
10
10
  import {
11
+ coerceWorkspaceBackgroundColor,
11
12
  DEFAULT_WORKSPACE_COLOR,
12
13
  DEFAULT_USER_SETTINGS,
13
- coerceWorkspaceColor
14
+ coerceWorkspaceColor,
15
+ coerceWorkspaceSecondaryColor,
16
+ coerceWorkspaceSurfaceColor,
17
+ coerceWorkspaceSurfaceVariantColor,
18
+ deriveWorkspaceBackgroundColor,
19
+ deriveWorkspaceSecondaryColor,
20
+ deriveWorkspaceSurfaceColor,
21
+ deriveWorkspaceSurfaceVariantColor,
22
+ mixHexColors,
23
+ normalizeWorkspaceHexColor,
24
+ resolveWorkspaceThemePalette
14
25
  } from "./settings.js";
15
26
  import {
16
27
  TENANCY_MODE_NONE,
@@ -43,6 +54,17 @@ const USERS_SHARED_API = Object.freeze({
43
54
  DEFAULT_WORKSPACE_COLOR,
44
55
  DEFAULT_USER_SETTINGS,
45
56
  coerceWorkspaceColor,
57
+ coerceWorkspaceSecondaryColor,
58
+ coerceWorkspaceSurfaceColor,
59
+ coerceWorkspaceSurfaceVariantColor,
60
+ coerceWorkspaceBackgroundColor,
61
+ deriveWorkspaceSecondaryColor,
62
+ deriveWorkspaceSurfaceColor,
63
+ deriveWorkspaceSurfaceVariantColor,
64
+ deriveWorkspaceBackgroundColor,
65
+ normalizeWorkspaceHexColor,
66
+ mixHexColors,
67
+ resolveWorkspaceThemePalette,
46
68
  TENANCY_MODE_NONE,
47
69
  TENANCY_MODE_PERSONAL,
48
70
  TENANCY_MODE_WORKSPACE,
@@ -71,6 +93,17 @@ export {
71
93
  DEFAULT_WORKSPACE_COLOR,
72
94
  DEFAULT_USER_SETTINGS,
73
95
  coerceWorkspaceColor,
96
+ coerceWorkspaceSecondaryColor,
97
+ coerceWorkspaceSurfaceColor,
98
+ coerceWorkspaceSurfaceVariantColor,
99
+ coerceWorkspaceBackgroundColor,
100
+ deriveWorkspaceSecondaryColor,
101
+ deriveWorkspaceSurfaceColor,
102
+ deriveWorkspaceSurfaceVariantColor,
103
+ deriveWorkspaceBackgroundColor,
104
+ normalizeWorkspaceHexColor,
105
+ mixHexColors,
106
+ resolveWorkspaceThemePalette,
74
107
  TENANCY_MODE_NONE,
75
108
  TENANCY_MODE_PERSONAL,
76
109
  TENANCY_MODE_WORKSPACE,
@@ -2,9 +2,10 @@ import { Type } from "typebox";
2
2
  import { createOperationMessages } from "../operationMessages.js";
3
3
  import {
4
4
  createCursorListValidator,
5
- normalizeObjectInput
5
+ normalizeObjectInput,
6
+ normalizeSettingsFieldInput,
7
+ normalizeSettingsFieldOutput
6
8
  } from "@jskit-ai/kernel/shared/validators";
7
- import { normalizeSettingsFieldInput } from "./normalizeSettingsFieldInput.js";
8
9
  import { consoleSettingsFields } from "./consoleSettingsFields.js";
9
10
 
10
11
  function buildCreateSchema() {
@@ -57,21 +58,9 @@ const consoleSettingsOutputValidator = Object.freeze({
57
58
  normalize(payload = {}) {
58
59
  const source = normalizeObjectInput(payload);
59
60
  const settingsSource = normalizeObjectInput(source.settings);
60
- const settings = {};
61
-
62
- for (const field of consoleSettingsFields) {
63
- const rawValue = Object.hasOwn(settingsSource, field.key)
64
- ? settingsSource[field.key]
65
- : field.resolveDefault({
66
- settings: settingsSource
67
- });
68
- settings[field.key] = field.normalizeOutput(rawValue, {
69
- settings: settingsSource
70
- });
71
- }
72
61
 
73
62
  return {
74
- settings
63
+ settings: normalizeSettingsFieldOutput(settingsSource, consoleSettingsFields)
75
64
  };
76
65
  }
77
66
  });
@@ -1,11 +1,11 @@
1
1
  import { Type } from "typebox";
2
2
  import {
3
3
  createCursorListValidator,
4
- normalizeObjectInput
4
+ normalizeObjectInput,
5
+ normalizeSettingsFieldInput
5
6
  } from "@jskit-ai/kernel/shared/validators";
6
7
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
7
8
  import { createOperationMessages } from "../operationMessages.js";
8
- import { normalizeSettingsFieldInput } from "./normalizeSettingsFieldInput.js";
9
9
  import { userProfileResource } from "./userProfileResource.js";
10
10
  import {
11
11
  USER_SETTINGS_SECTIONS,
@@ -2,9 +2,9 @@ import { Type } from "typebox";
2
2
  import { normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
3
3
  import {
4
4
  normalizeObjectInput,
5
- createCursorListValidator
5
+ createCursorListValidator,
6
+ normalizeSettingsFieldInput
6
7
  } from "@jskit-ai/kernel/shared/validators";
7
- import { normalizeSettingsFieldInput } from "./normalizeSettingsFieldInput.js";
8
8
  import { workspaceSettingsFields } from "./workspaceSettingsFields.js";
9
9
  import { createWorkspaceRoleCatalog } from "../roles.js";
10
10
 
@@ -1,4 +1,5 @@
1
- const DEFAULT_WORKSPACE_COLOR = "#0F6B54";
1
+ const DEFAULT_WORKSPACE_COLOR = "#2F5D9E";
2
+ const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/;
2
3
 
3
4
  const DEFAULT_USER_SETTINGS = Object.freeze({
4
5
  theme: "system",
@@ -16,16 +17,132 @@ const DEFAULT_USER_SETTINGS = Object.freeze({
16
17
  lastActiveWorkspaceId: null
17
18
  });
18
19
 
19
- function coerceWorkspaceColor(value) {
20
+ function normalizeWorkspaceHexColor(value) {
20
21
  const normalized = String(value || "").trim();
21
- if (/^#[0-9A-Fa-f]{6}$/.test(normalized)) {
22
- return normalized.toUpperCase();
22
+ if (!HEX_COLOR_PATTERN.test(normalized)) {
23
+ return "";
24
+ }
25
+ return normalized.toUpperCase();
26
+ }
27
+
28
+ function clampChannel(value) {
29
+ if (!Number.isFinite(value)) {
30
+ return 0;
31
+ }
32
+ if (value < 0) {
33
+ return 0;
23
34
  }
24
- return DEFAULT_WORKSPACE_COLOR;
35
+ if (value > 255) {
36
+ return 255;
37
+ }
38
+ return Math.round(value);
39
+ }
40
+
41
+ function toHexChannel(value) {
42
+ return clampChannel(value).toString(16).padStart(2, "0").toUpperCase();
43
+ }
44
+
45
+ function parseHexColor(value) {
46
+ const normalized = normalizeWorkspaceHexColor(value);
47
+ if (!normalized) {
48
+ return null;
49
+ }
50
+ return {
51
+ red: Number.parseInt(normalized.slice(1, 3), 16),
52
+ green: Number.parseInt(normalized.slice(3, 5), 16),
53
+ blue: Number.parseInt(normalized.slice(5, 7), 16)
54
+ };
55
+ }
56
+
57
+ function mixHexColors(baseColor, mixColor, mixRatio = 0) {
58
+ const base = parseHexColor(baseColor);
59
+ const mixed = parseHexColor(mixColor);
60
+ if (!base || !mixed) {
61
+ return "";
62
+ }
63
+
64
+ const ratio = Math.min(1, Math.max(0, Number(mixRatio) || 0));
65
+ const keepRatio = 1 - ratio;
66
+ return `#${toHexChannel(base.red * keepRatio + mixed.red * ratio)}${toHexChannel(
67
+ base.green * keepRatio + mixed.green * ratio
68
+ )}${toHexChannel(base.blue * keepRatio + mixed.blue * ratio)}`;
69
+ }
70
+
71
+ function coerceWorkspaceColor(value) {
72
+ return normalizeWorkspaceHexColor(value) || DEFAULT_WORKSPACE_COLOR;
73
+ }
74
+
75
+ function deriveWorkspaceSecondaryColor(workspaceColor = DEFAULT_WORKSPACE_COLOR) {
76
+ return mixHexColors(coerceWorkspaceColor(workspaceColor), "#000000", 0.28) || "#224372";
77
+ }
78
+
79
+ function deriveWorkspaceSurfaceColor(workspaceColor = DEFAULT_WORKSPACE_COLOR) {
80
+ return mixHexColors(coerceWorkspaceColor(workspaceColor), "#FFFFFF", 0.93) || "#F0F4F8";
81
+ }
82
+
83
+ function deriveWorkspaceSurfaceVariantColor(workspaceColor = DEFAULT_WORKSPACE_COLOR) {
84
+ return mixHexColors(coerceWorkspaceColor(workspaceColor), "#FFFFFF", 0.86) || "#E2E8F1";
85
+ }
86
+
87
+ function deriveWorkspaceBackgroundColor(workspaceColor = DEFAULT_WORKSPACE_COLOR) {
88
+ const surfaceColor = deriveWorkspaceSurfaceColor(workspaceColor);
89
+ return mixHexColors(surfaceColor, "#FFFFFF", 0.45) || "#F4FAF8";
90
+ }
91
+
92
+ function coerceWorkspaceSecondaryColor(value, { color = DEFAULT_WORKSPACE_COLOR } = {}) {
93
+ return normalizeWorkspaceHexColor(value) || deriveWorkspaceSecondaryColor(color);
94
+ }
95
+
96
+ function coerceWorkspaceSurfaceColor(value, { color = DEFAULT_WORKSPACE_COLOR } = {}) {
97
+ return normalizeWorkspaceHexColor(value) || deriveWorkspaceSurfaceColor(color);
98
+ }
99
+
100
+ function coerceWorkspaceSurfaceVariantColor(value, { color = DEFAULT_WORKSPACE_COLOR } = {}) {
101
+ return normalizeWorkspaceHexColor(value) || deriveWorkspaceSurfaceVariantColor(color);
102
+ }
103
+
104
+ function coerceWorkspaceBackgroundColor(value, { color = DEFAULT_WORKSPACE_COLOR } = {}) {
105
+ return normalizeWorkspaceHexColor(value) || deriveWorkspaceBackgroundColor(color);
106
+ }
107
+
108
+ function resolveWorkspaceThemePalette(input = {}) {
109
+ const source = input && typeof input === "object" ? input : {};
110
+ const color = coerceWorkspaceColor(source.color);
111
+ const secondaryColor = coerceWorkspaceSecondaryColor(source.secondaryColor, {
112
+ color
113
+ });
114
+ const surfaceColor = coerceWorkspaceSurfaceColor(source.surfaceColor, {
115
+ color
116
+ });
117
+ const surfaceVariantColor = coerceWorkspaceSurfaceVariantColor(source.surfaceVariantColor, {
118
+ color
119
+ });
120
+ const backgroundColor = coerceWorkspaceBackgroundColor(source.backgroundColor, {
121
+ color
122
+ });
123
+
124
+ return Object.freeze({
125
+ color,
126
+ secondaryColor,
127
+ surfaceColor,
128
+ surfaceVariantColor,
129
+ backgroundColor
130
+ });
25
131
  }
26
132
 
27
133
  export {
28
134
  DEFAULT_WORKSPACE_COLOR,
29
135
  DEFAULT_USER_SETTINGS,
30
- coerceWorkspaceColor
136
+ coerceWorkspaceBackgroundColor,
137
+ coerceWorkspaceColor,
138
+ coerceWorkspaceSecondaryColor,
139
+ coerceWorkspaceSurfaceColor,
140
+ coerceWorkspaceSurfaceVariantColor,
141
+ deriveWorkspaceBackgroundColor,
142
+ deriveWorkspaceSecondaryColor,
143
+ deriveWorkspaceSurfaceColor,
144
+ deriveWorkspaceSurfaceVariantColor,
145
+ mixHexColors,
146
+ normalizeWorkspaceHexColor,
147
+ resolveWorkspaceThemePalette
31
148
  };
@@ -1,5 +1,3 @@
1
- // JSKIT_MIGRATION_ID: users-core-console-owner
2
-
3
1
  /**
4
2
  * @param {import('knex').Knex} knex
5
3
  */
@@ -1,5 +1,3 @@
1
- // JSKIT_MIGRATION_ID: users-core-initial-schema
2
-
3
1
  /**
4
2
  * @param {import('knex').Knex} knex
5
3
  */
@@ -27,7 +25,7 @@ exports.up = async function up(knex) {
27
25
  table.integer("owner_user_id").unsigned().notNullable().references("id").inTable("user_profiles").onDelete("CASCADE");
28
26
  table.boolean("is_personal").notNullable().defaultTo(true);
29
27
  table.string("avatar_url", 512).notNullable().defaultTo("");
30
- table.string("color", 7).notNullable().defaultTo("#0F6B54");
28
+ table.string("color", 7).notNullable().defaultTo("#2F5D9E");
31
29
  table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
32
30
  table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
33
31
  table.timestamp("deleted_at", { useTz: false }).nullable();
@@ -48,7 +46,10 @@ exports.up = async function up(knex) {
48
46
  table.integer("workspace_id").unsigned().primary().references("id").inTable("workspaces").onDelete("CASCADE");
49
47
  table.string("name", 160).notNullable().defaultTo("Workspace");
50
48
  table.string("avatar_url", 512).notNullable().defaultTo("");
51
- table.string("color", 7).notNullable().defaultTo("#0F6B54");
49
+ table.string("color", 7).notNullable().defaultTo("#2F5D9E");
50
+ table.string("secondary_color", 7).notNullable().defaultTo("#224372");
51
+ table.string("surface_color", 7).notNullable().defaultTo("#F0F4F8");
52
+ table.string("surface_variant_color", 7).notNullable().defaultTo("#E2E8F1");
52
53
  table.boolean("invites_enabled").notNullable().defaultTo(true);
53
54
  table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
54
55
  table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
@@ -1,5 +1,3 @@
1
- // JSKIT_MIGRATION_ID: users-core-profile-username
2
-
3
1
  const USERNAME_MAX_LENGTH = 120;
4
2
 
5
3
  function normalizeUsername(value) {
@@ -3,7 +3,12 @@
3
3
 
4
4
  import { Type } from "typebox";
5
5
  import { normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
6
- import { coerceWorkspaceColor } from "@jskit-ai/users-core/shared/settings";
6
+ import {
7
+ coerceWorkspaceColor,
8
+ coerceWorkspaceSecondaryColor,
9
+ coerceWorkspaceSurfaceColor,
10
+ coerceWorkspaceSurfaceVariantColor
11
+ } from "@jskit-ai/users-core/shared/settings";
7
12
  import {
8
13
  defineField,
9
14
  resetWorkspaceSettingsFields
@@ -29,6 +34,10 @@ function normalizeHexColor(value) {
29
34
  return /^#[0-9A-Fa-f]{6}$/.test(color) ? color.toUpperCase() : null;
30
35
  }
31
36
 
37
+ function resolveThemeBaseColor({ workspace = {}, settings = {} } = {}) {
38
+ return normalizeText(settings.color || workspace.color);
39
+ }
40
+
32
41
  resetWorkspaceSettingsFields();
33
42
 
34
43
  defineField({
@@ -78,8 +87,8 @@ defineField({
78
87
  pattern: "^#[0-9A-Fa-f]{6}$",
79
88
  messages: {
80
89
  required: "Workspace color is required.",
81
- pattern: "Workspace color must be a hex color like #0F6B54.",
82
- default: "Workspace color must be a hex color like #0F6B54."
90
+ pattern: "Workspace color must be a hex color like #2F5D9E.",
91
+ default: "Workspace color must be a hex color like #2F5D9E."
83
92
  }
84
93
  }),
85
94
  outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
@@ -88,6 +97,84 @@ defineField({
88
97
  resolveDefault: ({ workspace = {} } = {}) => coerceWorkspaceColor(workspace.color)
89
98
  });
90
99
 
100
+ defineField({
101
+ key: "secondaryColor",
102
+ dbColumn: "secondary_color",
103
+ required: true,
104
+ inputSchema: Type.String({
105
+ minLength: 7,
106
+ maxLength: 7,
107
+ pattern: "^#[0-9A-Fa-f]{6}$",
108
+ messages: {
109
+ required: "Secondary color is required.",
110
+ pattern: "Secondary color must be a hex color like #224372.",
111
+ default: "Secondary color must be a hex color like #224372."
112
+ }
113
+ }),
114
+ outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
115
+ normalizeInput: normalizeHexColor,
116
+ normalizeOutput: (value, { workspace = {}, settings = {} } = {}) =>
117
+ coerceWorkspaceSecondaryColor(value, {
118
+ color: resolveThemeBaseColor({ workspace, settings })
119
+ }),
120
+ resolveDefault: ({ workspace = {}, settings = {} } = {}) =>
121
+ coerceWorkspaceSecondaryColor(workspace.secondaryColor, {
122
+ color: resolveThemeBaseColor({ workspace, settings })
123
+ })
124
+ });
125
+
126
+ defineField({
127
+ key: "surfaceColor",
128
+ dbColumn: "surface_color",
129
+ required: true,
130
+ inputSchema: Type.String({
131
+ minLength: 7,
132
+ maxLength: 7,
133
+ pattern: "^#[0-9A-Fa-f]{6}$",
134
+ messages: {
135
+ required: "Surface color is required.",
136
+ pattern: "Surface color must be a hex color like #F0F4F8.",
137
+ default: "Surface color must be a hex color like #F0F4F8."
138
+ }
139
+ }),
140
+ outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
141
+ normalizeInput: normalizeHexColor,
142
+ normalizeOutput: (value, { workspace = {}, settings = {} } = {}) =>
143
+ coerceWorkspaceSurfaceColor(value, {
144
+ color: resolveThemeBaseColor({ workspace, settings })
145
+ }),
146
+ resolveDefault: ({ workspace = {}, settings = {} } = {}) =>
147
+ coerceWorkspaceSurfaceColor(workspace.surfaceColor, {
148
+ color: resolveThemeBaseColor({ workspace, settings })
149
+ })
150
+ });
151
+
152
+ defineField({
153
+ key: "surfaceVariantColor",
154
+ dbColumn: "surface_variant_color",
155
+ required: true,
156
+ inputSchema: Type.String({
157
+ minLength: 7,
158
+ maxLength: 7,
159
+ pattern: "^#[0-9A-Fa-f]{6}$",
160
+ messages: {
161
+ required: "Surface variant color is required.",
162
+ pattern: "Surface variant color must be a hex color like #E2E8F1.",
163
+ default: "Surface variant color must be a hex color like #E2E8F1."
164
+ }
165
+ }),
166
+ outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
167
+ normalizeInput: normalizeHexColor,
168
+ normalizeOutput: (value, { workspace = {}, settings = {} } = {}) =>
169
+ coerceWorkspaceSurfaceVariantColor(value, {
170
+ color: resolveThemeBaseColor({ workspace, settings })
171
+ }),
172
+ resolveDefault: ({ workspace = {}, settings = {} } = {}) =>
173
+ coerceWorkspaceSurfaceVariantColor(workspace.surfaceVariantColor, {
174
+ color: resolveThemeBaseColor({ workspace, settings })
175
+ })
176
+ });
177
+
91
178
  defineField({
92
179
  key: "invitesEnabled",
93
180
  dbColumn: "invites_enabled",
@@ -1,12 +1,17 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import "../test-support/registerDefaultSettingsFields.js";
4
+ import { resolveWorkspaceThemePalette } from "../src/shared/settings.js";
4
5
  import { createRepository } from "../src/server/workspaceSettings/workspaceSettingsRepository.js";
5
6
 
6
7
  function createDefaultWorkspaceSettings() {
7
8
  return true;
8
9
  }
9
10
 
11
+ const DEFAULT_WORKSPACE_THEME = resolveWorkspaceThemePalette({
12
+ color: "#2F5D9E"
13
+ });
14
+
10
15
  function createKnexStub(rowOverrides = {}) {
11
16
  const state = {
12
17
  insertedRow: null,
@@ -15,7 +20,10 @@ function createKnexStub(rowOverrides = {}) {
15
20
  workspace_id: 1,
16
21
  name: "Workspace",
17
22
  avatar_url: "",
18
- color: "#0F6B54",
23
+ color: "#2F5D9E",
24
+ secondary_color: DEFAULT_WORKSPACE_THEME.secondaryColor,
25
+ surface_color: DEFAULT_WORKSPACE_THEME.surfaceColor,
26
+ surface_variant_color: DEFAULT_WORKSPACE_THEME.surfaceVariantColor,
19
27
  invites_enabled: 1,
20
28
  created_at: "2026-03-09 00:26:35.710",
21
29
  updated_at: "2026-03-09 00:26:35.710",
@@ -34,6 +42,9 @@ function createKnexStub(rowOverrides = {}) {
34
42
  name: payload.name,
35
43
  avatar_url: payload.avatar_url,
36
44
  color: payload.color,
45
+ secondary_color: payload.secondary_color,
46
+ surface_color: payload.surface_color,
47
+ surface_variant_color: payload.surface_variant_color,
37
48
  invites_enabled: payload.invites_enabled,
38
49
  created_at: "2026-03-10 00:00:00.000",
39
50
  updated_at: "2026-03-10 00:00:00.000"
@@ -61,6 +72,15 @@ function createKnexStub(rowOverrides = {}) {
61
72
  if (Object.hasOwn(payload, "color")) {
62
73
  state.row.color = payload.color;
63
74
  }
75
+ if (Object.hasOwn(payload, "secondary_color")) {
76
+ state.row.secondary_color = payload.secondary_color;
77
+ }
78
+ if (Object.hasOwn(payload, "surface_color")) {
79
+ state.row.surface_color = payload.surface_color;
80
+ }
81
+ if (Object.hasOwn(payload, "surface_variant_color")) {
82
+ state.row.surface_variant_color = payload.surface_variant_color;
83
+ }
64
84
  if (Object.hasOwn(payload, "updated_at")) {
65
85
  state.row.updated_at = payload.updated_at;
66
86
  }
@@ -86,7 +106,10 @@ test("workspaceSettingsRepository.findByWorkspaceId maps the stored row", async
86
106
  workspaceId: 1,
87
107
  name: "Workspace",
88
108
  avatarUrl: "",
89
- color: "#0F6B54",
109
+ color: "#2F5D9E",
110
+ secondaryColor: DEFAULT_WORKSPACE_THEME.secondaryColor,
111
+ surfaceColor: DEFAULT_WORKSPACE_THEME.surfaceColor,
112
+ surfaceVariantColor: DEFAULT_WORKSPACE_THEME.surfaceVariantColor,
90
113
  invitesEnabled: true,
91
114
  createdAt: "2026-03-08T16:26:35.710Z",
92
115
  updatedAt: "2026-03-08T16:26:35.710Z"
@@ -119,11 +142,17 @@ test("workspaceSettingsRepository.ensureForWorkspaceId inserts the injected defa
119
142
  assert.equal(state.insertedRow.workspace_id, 5);
120
143
  assert.equal(state.insertedRow.name, "Workspace");
121
144
  assert.equal(state.insertedRow.avatar_url, "");
122
- assert.equal(state.insertedRow.color, "#0F6B54");
145
+ assert.equal(state.insertedRow.color, "#2F5D9E");
146
+ assert.equal(state.insertedRow.secondary_color, DEFAULT_WORKSPACE_THEME.secondaryColor);
147
+ assert.equal(state.insertedRow.surface_color, DEFAULT_WORKSPACE_THEME.surfaceColor);
148
+ assert.equal(state.insertedRow.surface_variant_color, DEFAULT_WORKSPACE_THEME.surfaceVariantColor);
123
149
  assert.equal(state.insertedRow.invites_enabled, false);
124
150
  assert.equal(record.name, "Workspace");
125
151
  assert.equal(record.avatarUrl, "");
126
- assert.equal(record.color, "#0F6B54");
152
+ assert.equal(record.color, "#2F5D9E");
153
+ assert.equal(record.secondaryColor, DEFAULT_WORKSPACE_THEME.secondaryColor);
154
+ assert.equal(record.surfaceColor, DEFAULT_WORKSPACE_THEME.surfaceColor);
155
+ assert.equal(record.surfaceVariantColor, DEFAULT_WORKSPACE_THEME.surfaceVariantColor);
127
156
  assert.equal(record.invitesEnabled, false);
128
157
  });
129
158
 
@@ -2,6 +2,7 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { validateOperationSection } from "@jskit-ai/http-runtime/shared/validators/operationValidation";
4
4
  import "../test-support/registerDefaultSettingsFields.js";
5
+ import { resolveWorkspaceThemePalette } from "../src/shared/settings.js";
5
6
  import { workspaceSettingsResource } from "../src/shared/resources/workspaceSettingsResource.js";
6
7
  import { createWorkspaceRoleCatalog } from "../src/shared/roles.js";
7
8
 
@@ -48,6 +49,9 @@ test("workspace settings patch body normalizes valid payload before validation",
48
49
  name: " Team Mercury ",
49
50
  avatarUrl: "https://example.com/avatar.png",
50
51
  color: "#0f6b54",
52
+ secondaryColor: "#0b4d3c",
53
+ surfaceColor: "#eef5f3",
54
+ surfaceVariantColor: "#ddeae7",
51
55
  invitesEnabled: false
52
56
  });
53
57
 
@@ -57,6 +61,9 @@ test("workspace settings patch body normalizes valid payload before validation",
57
61
  name: "Team Mercury",
58
62
  avatarUrl: "https://example.com/avatar.png",
59
63
  color: "#0F6B54",
64
+ secondaryColor: "#0B4D3C",
65
+ surfaceColor: "#EEF5F3",
66
+ surfaceVariantColor: "#DDEAE7",
60
67
  invitesEnabled: false
61
68
  });
62
69
  });
@@ -89,10 +96,16 @@ test("workspace settings create body requires full-write fields", () => {
89
96
 
90
97
  assert.equal(parsed.ok, false);
91
98
  assert.equal(parsed.fieldErrors.color, "Workspace color is required.");
99
+ assert.equal(parsed.fieldErrors.secondaryColor, "Secondary color is required.");
100
+ assert.equal(parsed.fieldErrors.surfaceColor, "Surface color is required.");
101
+ assert.equal(parsed.fieldErrors.surfaceVariantColor, "Surface variant color is required.");
92
102
  assert.equal(parsed.fieldErrors.invitesEnabled, "invitesEnabled is required.");
93
103
  });
94
104
 
95
105
  test("workspace settings output normalizes raw service payloads", () => {
106
+ const expectedTheme = resolveWorkspaceThemePalette({
107
+ color: "#0F6B54"
108
+ });
96
109
  const normalized = workspaceSettingsResource.operations.view.outputValidator.normalize({
97
110
  workspace: {
98
111
  id: "7",
@@ -118,6 +131,9 @@ test("workspace settings output normalizes raw service payloads", () => {
118
131
  name: "Mercury Workspace",
119
132
  avatarUrl: "https://example.com/avatar.png",
120
133
  color: "#0F6B54",
134
+ secondaryColor: expectedTheme.secondaryColor,
135
+ surfaceColor: expectedTheme.surfaceColor,
136
+ surfaceVariantColor: expectedTheme.surfaceVariantColor,
121
137
  invitesEnabled: false,
122
138
  invitesAvailable: true,
123
139
  invitesEffective: false
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import "../test-support/registerDefaultSettingsFields.js";
4
+ import { resolveWorkspaceThemePalette } from "../src/shared/settings.js";
4
5
  import { createService } from "../src/server/workspaceSettings/workspaceSettingsService.js";
5
6
 
6
7
  function authorizedOptions(permissions = []) {
@@ -15,6 +16,9 @@ function authorizedOptions(permissions = []) {
15
16
  }
16
17
 
17
18
  function createFixture({ workspaceInvitationsEnabled = true } = {}) {
19
+ const defaultTheme = resolveWorkspaceThemePalette({
20
+ color: "#0F6B54"
21
+ });
18
22
  const state = {
19
23
  settingsPatch: null,
20
24
  workspace: {
@@ -23,12 +27,15 @@ function createFixture({ workspaceInvitationsEnabled = true } = {}) {
23
27
  name: "TonyMobily3",
24
28
  ownerUserId: 9,
25
29
  avatarUrl: "",
26
- color: "#0F6B54"
30
+ color: defaultTheme.color
27
31
  },
28
32
  settings: {
29
33
  name: "TonyMobily3",
30
34
  avatarUrl: "",
31
- color: "#0F6B54",
35
+ color: defaultTheme.color,
36
+ secondaryColor: defaultTheme.secondaryColor,
37
+ surfaceColor: defaultTheme.surfaceColor,
38
+ surfaceVariantColor: defaultTheme.surfaceVariantColor,
32
39
  invitesEnabled: true
33
40
  }
34
41
  };
@@ -67,6 +74,9 @@ test("workspaceSettingsService.getWorkspaceSettings returns the stored invitesEn
67
74
  name: "TonyMobily3",
68
75
  avatarUrl: "",
69
76
  color: "#0F6B54",
77
+ secondaryColor: "#0B4D3C",
78
+ surfaceColor: "#EEF5F3",
79
+ surfaceVariantColor: "#DDEAE7",
70
80
  invitesEnabled: true,
71
81
  invitesAvailable: true,
72
82
  invitesEffective: true
@@ -93,6 +103,9 @@ test("workspaceSettingsService.updateWorkspaceSettings writes editable fields th
93
103
  name: "New Name",
94
104
  avatarUrl: "",
95
105
  color: "#0F6B54",
106
+ secondaryColor: "#0B4D3C",
107
+ surfaceColor: "#EEF5F3",
108
+ surfaceVariantColor: "#DDEAE7",
96
109
  invitesEnabled: false,
97
110
  invitesAvailable: true,
98
111
  invitesEffective: false
@@ -113,6 +126,9 @@ test("workspaceSettingsService disables invite settings in output when app polic
113
126
  name: "TonyMobily3",
114
127
  avatarUrl: "",
115
128
  color: "#0F6B54",
129
+ secondaryColor: "#0B4D3C",
130
+ surfaceColor: "#EEF5F3",
131
+ surfaceVariantColor: "#DDEAE7",
116
132
  invitesEnabled: false,
117
133
  invitesAvailable: false,
118
134
  invitesEffective: false
@@ -1,19 +0,0 @@
1
- import { normalizeObjectInput } from "@jskit-ai/kernel/shared/validators";
2
-
3
- function normalizeSettingsFieldInput(payload = {}, fields = []) {
4
- const source = normalizeObjectInput(payload);
5
- const normalized = {};
6
-
7
- for (const field of fields) {
8
- if (!Object.hasOwn(source, field.key)) {
9
- continue;
10
- }
11
- normalized[field.key] = field.normalizeInput(source[field.key], {
12
- payload: source
13
- });
14
- }
15
-
16
- return normalized;
17
- }
18
-
19
- export { normalizeSettingsFieldInput };