clay-server 3.7.0 → 3.8.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/lib/builtin-mates.js +14 -14
  2. package/lib/mates.js +2 -6
  3. package/lib/os-users.js +17 -1
  4. package/lib/project-http.js +34 -1
  5. package/lib/project-image.js +87 -1
  6. package/lib/project-mate-interaction.js +7 -7
  7. package/lib/project-sessions.js +1 -1
  8. package/lib/project-user-mention.js +4 -4
  9. package/lib/project.js +6 -4
  10. package/lib/public/css/avatar-imprints.css +46 -0
  11. package/lib/public/css/generated-images.css +242 -0
  12. package/lib/public/css/icon-strip.css +3 -3
  13. package/lib/public/css/profile.css +123 -95
  14. package/lib/public/css/session-actions.css +40 -9
  15. package/lib/public/css/user-settings.css +1 -1
  16. package/lib/public/index.html +3 -3
  17. package/lib/public/modules/app-cursors.js +2 -2
  18. package/lib/public/modules/app-dm.js +1 -8
  19. package/lib/public/modules/app-messages.js +2 -2
  20. package/lib/public/modules/app-projects.js +7 -6
  21. package/lib/public/modules/app-rate-limit.js +0 -1
  22. package/lib/public/modules/app-rendering.js +0 -3
  23. package/lib/public/modules/avatar-imprint.js +134 -0
  24. package/lib/public/modules/avatar.js +25 -19
  25. package/lib/public/modules/debate.js +10 -5
  26. package/lib/public/modules/generated-images.js +211 -2
  27. package/lib/public/modules/mate-sidebar.js +2 -2
  28. package/lib/public/modules/mention.js +26 -16
  29. package/lib/public/modules/profile.js +73 -190
  30. package/lib/public/modules/session-actions.js +5 -3
  31. package/lib/public/modules/sidebar-mates.js +5 -4
  32. package/lib/public/modules/sidebar-sessions.js +1 -1
  33. package/lib/public/style.css +2 -1
  34. package/lib/server-auth.js +2 -2
  35. package/lib/server-dm.js +8 -8
  36. package/lib/server-mates.js +3 -3
  37. package/lib/server-settings.js +2 -2
  38. package/lib/server.js +5 -4
  39. package/lib/users.js +4 -4
  40. package/package.json +1 -1
@@ -215,7 +215,7 @@ export function enterDmMode(key, targetUser, messages) {
215
215
  var termBtn = document.getElementById("terminal-toggle-btn");
216
216
  if (termBtn) termBtn.style.display = "none";
217
217
  // Apply mate color to chat title bar and panels
218
- var mateColor = (targetUser.profile && targetUser.profile.avatarColor) || targetUser.avatarColor || "#7c3aed";
218
+ var mateColor = (targetUser.profile && targetUser.profile.avatarColor) || targetUser.avatarColor || "#5857fc";
219
219
  document.body.style.setProperty("--mate-color", mateColor);
220
220
  document.body.style.setProperty("--mate-color-tint", mateColor + "0a");
221
221
  document.body.classList.add("mate-dm-active");
@@ -223,19 +223,12 @@ export function enterDmMode(key, targetUser, messages) {
223
223
  var mp = targetUser.profile || {};
224
224
  var mateAvUrlDm = mateAvatarUrl(targetUser, 36);
225
225
  var myUser = store.get('cachedAllUsers').find(function (u) { return u.id === store.get('myUserId'); });
226
- if (!myUser) {
227
- try { var cached = JSON.parse(localStorage.getItem("clay_my_user") || "null"); if (cached) myUser = cached; } catch(e) {}
228
- }
229
226
  var myAvatarUrl = userAvatarUrl(myUser || { id: store.get('myUserId') }, 36);
230
227
  var myDisplayName = (myUser && myUser.displayName) || "";
231
228
  document.body.dataset.mateAvatarUrl = mateAvUrlDm;
232
229
  document.body.dataset.mateName = mp.displayName || targetUser.displayName || targetUser.name || "";
233
230
  document.body.dataset.myAvatarUrl = myAvatarUrl;
234
231
  document.body.dataset.myDisplayName = myDisplayName;
235
- // Cache my info for restore after hard refresh
236
- if (myUser) {
237
- try { localStorage.setItem("clay_my_user", JSON.stringify({ displayName: myUser.displayName, avatarStyle: myUser.avatarStyle, avatarSeed: myUser.avatarSeed, avatarCustom: myUser.avatarCustom, username: myUser.username })); } catch(e) {}
238
- }
239
232
  var titleBarContent = document.querySelector(".title-bar-content");
240
233
  if (titleBarContent) {
241
234
  titleBarContent.style.background = mateColor;
@@ -95,7 +95,7 @@ export function processMessage(msg) {
95
95
  if (headerTitleEl) headerTitleEl.textContent = _mdn;
96
96
  var _tbpn = document.getElementById("title-bar-project-name");
97
97
  if (_tbpn) _tbpn.textContent = _mdn;
98
- var _mc2 = (store.get('dmTargetUser').profile && store.get('dmTargetUser').profile.avatarColor) || store.get('dmTargetUser').avatarColor || "#7c3aed";
98
+ var _mc2 = (store.get('dmTargetUser').profile && store.get('dmTargetUser').profile.avatarColor) || store.get('dmTargetUser').avatarColor || "#5857fc";
99
99
  var _tbc2 = document.querySelector(".title-bar-content");
100
100
  if (_tbc2) { _tbc2.style.background = _mc2; _tbc2.classList.add("mate-dm-active"); }
101
101
  document.body.classList.add("mate-dm-active");
@@ -299,7 +299,7 @@ export function processMessage(msg) {
299
299
  var tbProjectName = document.getElementById("title-bar-project-name");
300
300
  if (tbProjectName) tbProjectName.textContent = _mateDN;
301
301
  // Re-apply mate title bar styling (may be lost during project switch)
302
- var _mc = (store.get('dmTargetUser').profile && store.get('dmTargetUser').profile.avatarColor) || store.get('dmTargetUser').avatarColor || "#7c3aed";
302
+ var _mc = (store.get('dmTargetUser').profile && store.get('dmTargetUser').profile.avatarColor) || store.get('dmTargetUser').avatarColor || "#5857fc";
303
303
  var _tbc = document.querySelector(".title-bar-content");
304
304
  if (_tbc) { _tbc.style.background = _mc; _tbc.classList.add("mate-dm-active"); }
305
305
  document.body.classList.add("mate-dm-active");
@@ -257,12 +257,13 @@ export function updateProjectList(msg) {
257
257
  if (msg.dmConversations) store.set({ cachedDmConversations: msg.dmConversations });
258
258
  // renderUserStrip is handled by the store subscriber
259
259
  var st2 = store.snap();
260
- if (document.body.classList.contains("mate-dm-active") || document.body.classList.contains("wide-view")) {
261
- var refreshedMyUser = st2.cachedAllUsers.find(function (u) { return u.id === st2.myUserId; });
262
- if (refreshedMyUser) {
263
- document.body.dataset.myDisplayName = refreshedMyUser.displayName || refreshedMyUser.username || "";
264
- document.body.dataset.myAvatarUrl = userAvatarUrl(refreshedMyUser, 36);
265
- try { localStorage.setItem("clay_my_user", JSON.stringify({ displayName: refreshedMyUser.displayName, username: refreshedMyUser.username, avatarStyle: refreshedMyUser.avatarStyle, avatarSeed: refreshedMyUser.avatarSeed, avatarCustom: refreshedMyUser.avatarCustom })); } catch(e) {}
260
+ var refreshedMyUser = st2.cachedAllUsers.find(function (u) { return u.id === st2.myUserId; });
261
+ if (refreshedMyUser) {
262
+ document.body.dataset.myDisplayName = refreshedMyUser.displayName || refreshedMyUser.username || "";
263
+ document.body.dataset.myAvatarUrl = userAvatarUrl(refreshedMyUser, 36);
264
+ var myMessageAvatars = document.querySelectorAll('.dm-bubble-avatar-me');
265
+ for (var avatarIndex = 0; avatarIndex < myMessageAvatars.length; avatarIndex++) {
266
+ myMessageAvatars[avatarIndex].src = document.body.dataset.myAvatarUrl;
266
267
  }
267
268
  }
268
269
  // Render my avatar (always present, hidden behind user-island)
@@ -311,7 +311,6 @@ export function addScheduledMessageBubble(text, resetsAt) {
311
311
  if (isChannel) {
312
312
  // Channel mode: avatar + header with scheduled badge + message
313
313
  var _me = store.get('cachedAllUsers').find(function (u) { return u.id === store.get('myUserId'); });
314
- if (!_me) { try { _me = JSON.parse(localStorage.getItem("clay_my_user") || "null"); } catch(e) {} }
315
314
  var _myName = document.body.dataset.myDisplayName || (_me && (_me.displayName || _me.username)) || "Me";
316
315
 
317
316
  var avi = document.createElement("img");
@@ -552,9 +552,6 @@ export function addUserMessage(text, images, pastes, fromUserId, fromUserName, d
552
552
  _displayName = fromUserName || (_targetUser && (_targetUser.displayName || _targetUser.username)) || "User";
553
553
  } else {
554
554
  _targetUser = cachedAllUsers.find(function (u) { return u.id === myUserId; });
555
- if (!_targetUser) {
556
- try { _targetUser = JSON.parse(localStorage.getItem("clay_my_user") || "null"); } catch(e) {}
557
- }
558
555
  _displayName = document.body.dataset.myDisplayName || "";
559
556
  if (!_displayName) {
560
557
  _displayName = (_targetUser && (_targetUser.displayName || _targetUser.username)) || "Me";
@@ -0,0 +1,134 @@
1
+ // Clay Imprints: deterministic SVG identities derived from a stable seed.
2
+
3
+ var PAPER = "#f1efe8";
4
+ var INK = "#1b1b1a";
5
+ var LIGHT_BASE = "#e7e5de";
6
+ var LIGHT_FIELD = "#333330";
7
+ var REGISTRATION_INDIGO = "#5857fc";
8
+ var BRAND_GREEN = "#07e5a3";
9
+
10
+ function hashString(value) {
11
+ var text = String(value || "anonymous");
12
+ var hash = 2166136261;
13
+ for (var index = 0; index < text.length; index++) {
14
+ hash ^= text.charCodeAt(index);
15
+ hash = Math.imul(hash, 16777619);
16
+ }
17
+ return hash >>> 0;
18
+ }
19
+
20
+ function makeRandom(seed) {
21
+ var state = seed >>> 0;
22
+ return function nextRandom() {
23
+ state += 0x6d2b79f5;
24
+ var value = state;
25
+ value = Math.imul(value ^ (value >>> 15), value | 1);
26
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
27
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
28
+ };
29
+ }
30
+
31
+ function between(random, min, max) {
32
+ return min + random() * (max - min);
33
+ }
34
+
35
+ function number(value) {
36
+ return Math.round(value * 100) / 100;
37
+ }
38
+
39
+ function xmlText(value) {
40
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
41
+ }
42
+
43
+ function legacyIdentity(style, seed) {
44
+ var sourceStyle = String(style || "imprint").toLowerCase();
45
+ var sourceSeed = String(seed || "anonymous");
46
+ if (sourceStyle === "imprint") return sourceSeed;
47
+ return "legacy:" + sourceStyle + ":" + sourceSeed;
48
+ }
49
+
50
+ function curvePath(y, bow, endOffset) {
51
+ return "M -28 " + number(y) +
52
+ " C 17 " + number(y + bow) +
53
+ ", 71 " + number(y - bow) +
54
+ ", 124 " + number(y + endOffset);
55
+ }
56
+
57
+ function cutPath(random) {
58
+ var start = between(random, 37, 52);
59
+ var first = between(random, 14, 84);
60
+ var second = between(random, 12, 86);
61
+ var end = between(random, 40, 64);
62
+ return "M -22 " + number(start) + " C 21 " + number(first) + ", 73 " + number(second) + ", 118 " + number(end);
63
+ }
64
+
65
+ export function imprintSvg(options) {
66
+ var settings = options || {};
67
+ var size = Math.max(12, Math.min(256, Number(settings.size) || 64));
68
+ var identity = legacyIdentity(settings.style, settings.seed);
69
+ var hash = hashString(identity);
70
+ var random = makeRandom(hash);
71
+ var darkBase = random() > 0.52;
72
+ var base = darkBase ? INK : LIGHT_BASE;
73
+ var field = darkBase ? PAPER : LIGHT_FIELD;
74
+ var accent = REGISTRATION_INDIGO;
75
+ var rotation = Math.floor(random() * 4) * 90;
76
+ var bandWidth = between(random, 37, 56);
77
+ var secondaryWidth = between(random, 14, 24);
78
+ var cutWidth = between(random, 15, 25);
79
+ var firstY = between(random, 24, 42);
80
+ var secondY = between(random, 58, 78);
81
+ var firstBow = between(random, -34, 34);
82
+ var secondBow = between(random, -32, 32);
83
+ var firstPath = curvePath(firstY, firstBow, 6);
84
+ var secondPath = curvePath(secondY, -secondBow, -5);
85
+ var contours = "";
86
+
87
+ if (size >= 40) {
88
+ for (var contour = -1; contour <= 1; contour++) {
89
+ var offset = contour * 5.2;
90
+ contours += '<path d="' + curvePath(firstY + offset, firstBow, 0) + '" fill="none" stroke="' +
91
+ (darkBase ? PAPER : INK) + '" stroke-opacity="0.2" stroke-width="0.8"/>';
92
+ }
93
+ }
94
+
95
+ var clipId = "imprint-" + hash.toString(16);
96
+ return '<svg xmlns="http://www.w3.org/2000/svg" width="' + size + '" height="' + size +
97
+ '" viewBox="0 0 96 96"><defs><clipPath id="' + clipId +
98
+ '"><rect width="96" height="96" rx="24"/></clipPath></defs><g clip-path="url(#' + clipId +
99
+ ')"><rect width="96" height="96" fill="' + base + '"/><g transform="rotate(' + rotation +
100
+ ' 48 48)"><path d="' + firstPath + '" fill="none" stroke="' + field + '" stroke-width="' +
101
+ number(bandWidth) + '" stroke-linecap="square"/><path d="' + secondPath + '" fill="none" stroke="' +
102
+ accent + '" stroke-width="' + number(secondaryWidth) + '" stroke-linecap="square"/><path d="' +
103
+ cutPath(random) + '" fill="none" stroke="' + base + '" stroke-width="' + number(cutWidth) +
104
+ '" stroke-linecap="square"/>' + contours + '</g></g><rect x="0.5" y="0.5" width="95" height="95" rx="23.5" fill="none" stroke="' +
105
+ (darkBase ? "#000000" : INK) + '" stroke-opacity="0.16"/></svg>';
106
+ }
107
+
108
+ export function imprintDataUrl(options) {
109
+ return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(imprintSvg(options));
110
+ }
111
+
112
+ export function mateMarkSvg(options) {
113
+ var settings = options || {};
114
+ var size = Math.max(12, Math.min(256, Number(settings.size) || 64));
115
+ var identity = String(settings.seed || "M").trim();
116
+ var initial = Array.from(identity || "M")[0].toUpperCase();
117
+ var palette = [
118
+ { background: REGISTRATION_INDIGO, foreground: PAPER },
119
+ { background: BRAND_GREEN, foreground: INK },
120
+ { background: PAPER, foreground: REGISTRATION_INDIGO },
121
+ ][initial.codePointAt(0) % 3];
122
+ var clipId = "mate-mark";
123
+
124
+ return '<svg xmlns="http://www.w3.org/2000/svg" width="' + size + '" height="' + size +
125
+ '" viewBox="0 0 96 96"><defs><clipPath id="' + clipId +
126
+ '"><rect width="96" height="96" rx="24"/></clipPath></defs><g clip-path="url(#' + clipId +
127
+ ')"><rect width="96" height="96" fill="' + palette.background + '"/><text x="48" y="50" fill="' + palette.foreground +
128
+ '" font-family="Arial, Helvetica, sans-serif" font-size="43" font-weight="600" text-anchor="middle" dominant-baseline="middle">' +
129
+ xmlText(initial) + '</text></g><rect x="0.5" y="0.5" width="95" height="95" rx="23.5" fill="none" stroke="#000000" stroke-opacity="0.18"/></svg>';
130
+ }
131
+
132
+ export function mateMarkDataUrl(options) {
133
+ return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(mateMarkSvg(options));
134
+ }
@@ -1,36 +1,42 @@
1
- // Centralized avatar URL builder and style definitions
2
- // All DiceBear avatar URLs should be constructed through this module.
1
+ // Centralized Clay Imprint URL builder for people and Mates.
2
+ import { imprintDataUrl, mateMarkDataUrl } from './avatar-imprint.js';
3
3
 
4
- export var AVATAR_STYLES = [
5
- { id: 'thumbs', name: 'Thumbs' },
6
- { id: 'bottts', name: 'Bots' },
7
- { id: 'pixel-art', name: 'Pixel' },
8
- { id: 'adventurer', name: 'Adventurer' },
9
- { id: 'micah', name: 'Micah' },
10
- { id: 'fun-emoji', name: 'Emoji' },
11
- { id: 'icons', name: 'Icons' },
12
- ];
4
+ var imprintCache = new Map();
5
+ var mateMarkCache = new Map();
13
6
 
14
- // Build a DiceBear avatar URL from style, seed, and optional size.
7
+ // Legacy style remains part of the identity hash so existing selections map
8
+ // deterministically into the new Clay Imprint system.
15
9
  export function avatarUrl(style, seed, size) {
16
- var s = encodeURIComponent(seed || 'anonymous');
17
- return 'https://api.dicebear.com/9.x/' + (style || 'thumbs') + '/svg?seed=' + s + '&size=' + (size || 64);
10
+ var cacheKey = [style || 'imprint', seed || 'anonymous', size || 64].join('|');
11
+ if (imprintCache.has(cacheKey)) return imprintCache.get(cacheKey);
12
+ var url = imprintDataUrl({ style: style || 'imprint', seed: seed || 'anonymous', size: size || 64 });
13
+ if (imprintCache.size >= 512) imprintCache.delete(imprintCache.keys().next().value);
14
+ imprintCache.set(cacheKey, url);
15
+ return url;
18
16
  }
19
17
 
20
18
  // Build avatar URL for a user object, preferring custom avatar if set.
21
19
  export function userAvatarUrl(user, size) {
22
20
  if (user && user.avatarCustom) return user.avatarCustom;
23
- var style = (user && user.avatarStyle) || 'thumbs';
21
+ var style = (user && user.avatarStyle) || 'imprint';
24
22
  var seed = (user && (user.avatarSeed || user.username || user.id)) || 'anonymous';
25
23
  return avatarUrl(style, seed, size);
26
24
  }
27
25
 
26
+ export function mateMarkUrl(seed, size) {
27
+ var cacheKey = [seed || 'mate', size || 64].join('|');
28
+ if (mateMarkCache.has(cacheKey)) return mateMarkCache.get(cacheKey);
29
+ var url = mateMarkDataUrl({ seed: seed || 'mate', size: size || 64 });
30
+ if (mateMarkCache.size >= 256) mateMarkCache.delete(mateMarkCache.keys().next().value);
31
+ mateMarkCache.set(cacheKey, url);
32
+ return url;
33
+ }
34
+
28
35
  // Build avatar URL for a mate object, preferring custom avatar if set.
29
36
  export function mateAvatarUrl(mate, size) {
30
- if (!mate) return avatarUrl('bottts', 'mate', size);
37
+ if (!mate) return mateMarkUrl('mate', size);
31
38
  var p = mate.profile || mate;
32
39
  if (p.avatarCustom || mate.avatarCustom) return p.avatarCustom || mate.avatarCustom;
33
- var style = p.avatarStyle || mate.avatarStyle || 'bottts';
34
- var seed = p.avatarSeed || mate.avatarSeed || mate.id || 'mate';
35
- return avatarUrl(style, seed, size);
40
+ var identity = p.displayName || mate.name || mate.displayName || 'Mate';
41
+ return mateMarkUrl(identity, size);
36
42
  }
@@ -53,7 +53,15 @@ export function resetDebateState() {
53
53
  }
54
54
 
55
55
  function buildAvatarUrl(meta) {
56
- return "https://api.dicebear.com/7.x/" + (meta.avatarStyle || "bottts") + "/svg?seed=" + encodeURIComponent(meta.avatarSeed || meta.mateId || "mate");
56
+ return mateAvatarUrl({
57
+ id: meta.mateId,
58
+ profile: {
59
+ avatarStyle: meta.avatarStyle || "imprint",
60
+ avatarSeed: meta.avatarSeed || meta.mateId || "mate",
61
+ avatarColor: meta.avatarColor || "#5857fc",
62
+ avatarCustom: meta.avatarCustom || "",
63
+ },
64
+ }, 32);
57
65
  }
58
66
 
59
67
  // --- Float info panel ---
@@ -943,12 +951,9 @@ function createPanelItem(mate) {
943
951
  cb.type = "checkbox";
944
952
  item.appendChild(cb);
945
953
 
946
- var avatarSrc = "https://api.dicebear.com/7.x/" +
947
- ((mate.profile && mate.profile.avatarStyle) || "bottts") +
948
- "/svg?seed=" + encodeURIComponent((mate.profile && mate.profile.avatarSeed) || mate.id);
949
954
  var avi = document.createElement("img");
950
955
  avi.className = "debate-panel-item-avatar";
951
- avi.src = avatarSrc;
956
+ avi.src = mateAvatarUrl(mate, 32);
952
957
  item.appendChild(avi);
953
958
 
954
959
  var info = document.createElement("div");
@@ -3,6 +3,11 @@
3
3
  import { refreshIcons, iconHtml } from './icons.js';
4
4
  import { showImageModal } from './app-misc.js';
5
5
  import { addToMessages, scrollToBottom } from './app-rendering.js';
6
+ import { store } from './store.js';
7
+ import { copyToClipboard, showToast } from './utils.js';
8
+
9
+ var detailsModal = null;
10
+ var detailsEscapeHandler = null;
6
11
 
7
12
  function fileNameFromImage(image) {
8
13
  if (image.fileName) return image.fileName;
@@ -24,6 +29,202 @@ function actionButton(icon, label) {
24
29
  return button;
25
30
  }
26
31
 
32
+ function closeImageDetails() {
33
+ if (detailsModal) detailsModal.remove();
34
+ if (detailsEscapeHandler) document.removeEventListener('keydown', detailsEscapeHandler);
35
+ detailsModal = null;
36
+ detailsEscapeHandler = null;
37
+ }
38
+
39
+ function setActionState(button, icon, label) {
40
+ if (!button) return;
41
+ button.innerHTML = iconHtml(icon) + '<span>' + label + '</span>';
42
+ button.title = label;
43
+ button.setAttribute('aria-label', label);
44
+ refreshIcons(button);
45
+ }
46
+
47
+ function saveGeneratedImage(image, targetPath, overwrite) {
48
+ var basePath = store.get('basePath') || '';
49
+ return fetch(basePath + 'api/generated-image/save', {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ credentials: 'same-origin',
53
+ body: JSON.stringify({
54
+ fileName: fileNameFromImage(image),
55
+ path: targetPath,
56
+ overwrite: !!overwrite,
57
+ }),
58
+ }).then(function (response) {
59
+ return response.text().then(function (text) {
60
+ var data = {};
61
+ try { data = JSON.parse(text); } catch (e) { data.error = text || 'Could not save image'; }
62
+ data.status = response.status;
63
+ data.ok = response.ok && data.ok !== false;
64
+ return data;
65
+ });
66
+ });
67
+ }
68
+
69
+ function showGeneratedImageDetails(image, promptText, sourceSaveButton, focusPath) {
70
+ closeImageDetails();
71
+ var prompt = promptText || '';
72
+ var fileName = fileNameFromImage(image);
73
+ var overwrite = false;
74
+
75
+ var backdrop = document.createElement('div');
76
+ backdrop.className = 'generated-image-details-backdrop';
77
+ var modal = document.createElement('section');
78
+ modal.className = 'generated-image-details';
79
+ modal.setAttribute('role', 'dialog');
80
+ modal.setAttribute('aria-modal', 'true');
81
+ modal.setAttribute('aria-labelledby', 'generated-image-details-title');
82
+
83
+ var header = document.createElement('header');
84
+ header.className = 'generated-image-details-header';
85
+ var heading = document.createElement('div');
86
+ var eyebrow = document.createElement('span');
87
+ eyebrow.className = 'generated-image-details-eyebrow';
88
+ eyebrow.textContent = 'IMAGE GENERATION';
89
+ var title = document.createElement('h2');
90
+ title.id = 'generated-image-details-title';
91
+ title.textContent = 'Image details';
92
+ heading.appendChild(eyebrow);
93
+ heading.appendChild(title);
94
+ var closeButton = actionButton('x', 'Close');
95
+ closeButton.classList.add('generated-image-details-close');
96
+ closeButton.addEventListener('click', closeImageDetails);
97
+ header.appendChild(heading);
98
+ header.appendChild(closeButton);
99
+ modal.appendChild(header);
100
+
101
+ var body = document.createElement('div');
102
+ body.className = 'generated-image-details-body';
103
+ var preview = document.createElement('div');
104
+ preview.className = 'generated-image-details-preview';
105
+ var previewImage = document.createElement('img');
106
+ previewImage.src = image.url;
107
+ previewImage.alt = prompt ? 'Generated image: ' + prompt : 'Generated image';
108
+ previewImage.addEventListener('click', function () { showImageModal(image.url); });
109
+ preview.appendChild(previewImage);
110
+ body.appendChild(preview);
111
+
112
+ var promptSection = document.createElement('section');
113
+ promptSection.className = 'generated-image-details-section';
114
+ var promptHeader = document.createElement('div');
115
+ promptHeader.className = 'generated-image-details-section-header';
116
+ var promptLabel = document.createElement('h3');
117
+ promptLabel.textContent = 'Prompt';
118
+ promptHeader.appendChild(promptLabel);
119
+ if (prompt) {
120
+ var copyPrompt = actionButton('copy', 'Copy prompt');
121
+ copyPrompt.addEventListener('click', function () { copyToClipboard(prompt); });
122
+ promptHeader.appendChild(copyPrompt);
123
+ }
124
+ var promptBody = document.createElement('div');
125
+ promptBody.className = 'generated-image-details-prompt';
126
+ promptBody.textContent = prompt || 'Prompt details are not available for this image.';
127
+ promptSection.appendChild(promptHeader);
128
+ promptSection.appendChild(promptBody);
129
+ body.appendChild(promptSection);
130
+
131
+ var saveSection = document.createElement('section');
132
+ saveSection.className = 'generated-image-details-section generated-image-save-section';
133
+ var saveLabel = document.createElement('label');
134
+ saveLabel.htmlFor = 'generated-image-project-path';
135
+ saveLabel.textContent = 'Project path';
136
+ var pathInput = document.createElement('input');
137
+ pathInput.id = 'generated-image-project-path';
138
+ pathInput.type = 'text';
139
+ pathInput.value = 'assets/generated/' + fileName;
140
+ pathInput.spellcheck = false;
141
+ pathInput.autocomplete = 'off';
142
+ var saveHint = document.createElement('p');
143
+ saveHint.className = 'generated-image-save-hint';
144
+ saveHint.textContent = 'The folder will be created inside this project if needed.';
145
+ var saveError = document.createElement('p');
146
+ saveError.className = 'generated-image-save-error hidden';
147
+ saveSection.appendChild(saveLabel);
148
+ saveSection.appendChild(pathInput);
149
+ saveSection.appendChild(saveHint);
150
+ saveSection.appendChild(saveError);
151
+ body.appendChild(saveSection);
152
+ modal.appendChild(body);
153
+
154
+ var footer = document.createElement('footer');
155
+ footer.className = 'generated-image-details-footer';
156
+ var cancelButton = document.createElement('button');
157
+ cancelButton.type = 'button';
158
+ cancelButton.className = 'generated-image-secondary-button';
159
+ cancelButton.textContent = 'Cancel';
160
+ cancelButton.addEventListener('click', closeImageDetails);
161
+ var saveButton = document.createElement('button');
162
+ saveButton.type = 'button';
163
+ saveButton.className = 'generated-image-primary-button';
164
+ saveButton.innerHTML = iconHtml('save') + '<span>Save to project</span>';
165
+ footer.appendChild(cancelButton);
166
+ footer.appendChild(saveButton);
167
+ modal.appendChild(footer);
168
+ backdrop.appendChild(modal);
169
+ document.body.appendChild(backdrop);
170
+ detailsModal = backdrop;
171
+
172
+ function resetOverwrite() {
173
+ overwrite = false;
174
+ saveError.classList.add('hidden');
175
+ saveError.textContent = '';
176
+ saveButton.classList.remove('generated-image-primary-button--replace');
177
+ saveButton.innerHTML = iconHtml('save') + '<span>Save to project</span>';
178
+ refreshIcons(saveButton);
179
+ }
180
+
181
+ pathInput.addEventListener('input', resetOverwrite);
182
+ saveButton.addEventListener('click', function () {
183
+ var targetPath = pathInput.value.trim();
184
+ if (!targetPath) {
185
+ saveError.textContent = 'Enter a project path.';
186
+ saveError.classList.remove('hidden');
187
+ pathInput.focus();
188
+ return;
189
+ }
190
+ saveButton.disabled = true;
191
+ saveError.classList.add('hidden');
192
+ saveGeneratedImage(image, targetPath, overwrite).then(function (result) {
193
+ saveButton.disabled = false;
194
+ if (result.ok) {
195
+ setActionState(sourceSaveButton, 'check', 'Saved');
196
+ if (sourceSaveButton) sourceSaveButton.disabled = true;
197
+ showToast('Saved to ' + result.path);
198
+ closeImageDetails();
199
+ return;
200
+ }
201
+ saveError.textContent = result.error || 'Could not save image.';
202
+ saveError.classList.remove('hidden');
203
+ if (result.status === 409) {
204
+ overwrite = true;
205
+ saveButton.classList.add('generated-image-primary-button--replace');
206
+ saveButton.innerHTML = iconHtml('replace') + '<span>Replace file</span>';
207
+ refreshIcons(saveButton);
208
+ }
209
+ }).catch(function () {
210
+ saveButton.disabled = false;
211
+ saveError.textContent = 'Could not save image.';
212
+ saveError.classList.remove('hidden');
213
+ });
214
+ });
215
+
216
+ backdrop.addEventListener('click', function (event) {
217
+ if (event.target === backdrop) closeImageDetails();
218
+ });
219
+ detailsEscapeHandler = function (event) {
220
+ if (event.key === 'Escape') closeImageDetails();
221
+ };
222
+ document.addEventListener('keydown', detailsEscapeHandler);
223
+ refreshIcons(backdrop);
224
+ if (focusPath) pathInput.focus();
225
+ else closeButton.focus();
226
+ }
227
+
27
228
  function findProgressRow(toolId) {
28
229
  var rows = document.querySelectorAll('.generated-image-row[data-image-tool-id]');
29
230
  for (var i = 0; i < rows.length; i++) {
@@ -99,10 +300,12 @@ export function renderGeneratedImage(msg) {
99
300
  label.innerHTML = iconHtml('sparkles') + '<span>Generated image</span>';
100
301
  meta.appendChild(label);
101
302
  if (msg.prompt) {
102
- var prompt = document.createElement('span');
303
+ var prompt = document.createElement('button');
304
+ prompt.type = 'button';
103
305
  prompt.className = 'generated-image-prompt';
104
306
  prompt.textContent = msg.prompt;
105
- prompt.title = msg.prompt;
307
+ prompt.title = 'View full prompt';
308
+ prompt.addEventListener('click', function () { showGeneratedImageDetails(image, msg.prompt, null, false); });
106
309
  meta.appendChild(prompt);
107
310
  }
108
311
  footer.appendChild(meta);
@@ -112,6 +315,12 @@ export function renderGeneratedImage(msg) {
112
315
  var openButton = actionButton('maximize-2', 'Open');
113
316
  openButton.addEventListener('click', function () { showImageModal(image.url); });
114
317
  actions.appendChild(openButton);
318
+ var saveButton = actionButton('save', 'Save');
319
+ saveButton.addEventListener('click', function () { showGeneratedImageDetails(image, msg.prompt, saveButton, true); });
320
+ var detailsButton = actionButton('info', 'Details');
321
+ detailsButton.addEventListener('click', function () { showGeneratedImageDetails(image, msg.prompt, saveButton, false); });
322
+ actions.appendChild(detailsButton);
323
+ actions.appendChild(saveButton);
115
324
  var downloadLink = document.createElement('a');
116
325
  downloadLink.className = 'generated-image-action';
117
326
  downloadLink.href = image.url;
@@ -139,7 +139,7 @@ export function showMateSidebar(mateId, mateData) {
139
139
  var profile = mateData.profile || mateData || {};
140
140
  var displayName = profile.displayName || mateData.displayName || mateData.name || "Mate";
141
141
 
142
- var mateColor = profile.avatarColor || mateData.avatarColor || "#7c3aed";
142
+ var mateColor = profile.avatarColor || mateData.avatarColor || "#5857fc";
143
143
 
144
144
  var mateAvUrl = mateAvatarUrl(mateData, 32);
145
145
  if (avatarEl) avatarEl.src = mateAvUrl;
@@ -267,7 +267,7 @@ export function updateMateSidebarProfile(mateData) {
267
267
  if (!columnEl || !mateData) return;
268
268
  var profile = mateData.profile || mateData || {};
269
269
  var displayName = profile.displayName || mateData.displayName || mateData.name || "Mate";
270
- var mateColor = profile.avatarColor || mateData.avatarColor || "#7c3aed";
270
+ var mateColor = profile.avatarColor || mateData.avatarColor || "#5857fc";
271
271
 
272
272
  if (avatarEl) {
273
273
  avatarEl.src = mateAvatarUrl(mateData, 32);