@sequent-org/moodboard 1.4.54 → 1.4.55

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.54",
3
+ "version": "1.4.55",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -58,6 +58,10 @@ export class ApiClient {
58
58
  async saveBoard(boardId, boardData, options = {}) {
59
59
  try {
60
60
  const actionType = options?.actionType || 'command_execute';
61
+ const baseVersion = options?.baseVersion ?? null;
62
+ const validBaseVersion = (baseVersion !== null && Number.isFinite(Number(baseVersion)) && Number(baseVersion) > 0)
63
+ ? Number(baseVersion)
64
+ : null;
61
65
  // Поддержка формата core.getBoardData(): { id, boardData, settings }
62
66
  const payloadBoardData = boardData && boardData.boardData ? boardData.boardData : boardData;
63
67
  const payloadSettings = boardData && boardData.settings ? boardData.settings : {};
@@ -130,11 +134,23 @@ export class ApiClient {
130
134
  body: JSON.stringify({
131
135
  moodboardId: boardId,
132
136
  state: cleanedData,
133
- actionType
137
+ actionType,
138
+ ...(validBaseVersion !== null ? { baseVersion: validBaseVersion } : {})
134
139
  })
135
140
  });
136
141
 
137
142
  if (!response.ok) {
143
+ // Конфликт версий: бэкенд отверг сохранение, т.к. экземпляр устарел
144
+ if (response.status === 409) {
145
+ let body = {};
146
+ try { body = await response.json(); } catch (_) {}
147
+ if (body?.code === 'stale_base_version') {
148
+ const err = new Error(body.message || 'Версия доски устарела');
149
+ err.code = 'stale_base_version';
150
+ err.currentVersion = body.currentVersion ?? null;
151
+ throw err;
152
+ }
153
+ }
138
154
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
139
155
  }
140
156
 
@@ -28,6 +28,10 @@ export class SaveManager {
28
28
 
29
29
  // Состояния сохранения
30
30
  this.saveStatus = 'idle'; // idle, saving, saved, error
31
+
32
+ // Коллбеки для получения актуальной версии и перезагрузки доски при конфликте версий
33
+ this._versionGetter = null;
34
+ this._reloadHandler = null;
31
35
 
32
36
  this.setupEventListeners();
33
37
  }
@@ -38,6 +42,25 @@ export class SaveManager {
38
42
  setApiClient(apiClient) {
39
43
  this.apiClient = apiClient;
40
44
  }
45
+
46
+ /**
47
+ * Регистрирует функцию, возвращающую актуальный номер версии доски (board.historyHeadVersion).
48
+ * Вызывается перед каждым сохранением и передаётся в payload как baseVersion.
49
+ * Если функция вернёт null/undefined/NaN — baseVersion не отправляется (новая доска).
50
+ * @param {() => number|null} fn
51
+ */
52
+ setVersionGetter(fn) {
53
+ this._versionGetter = fn;
54
+ }
55
+
56
+ /**
57
+ * Регистрирует async-функцию, перезагружающую доску с сервера (loadExistingBoard).
58
+ * Вызывается при 409 stale_base_version — перечитывает latest, не перезаписывает сервер.
59
+ * @param {() => Promise<void>} fn
60
+ */
61
+ setReloadHandler(fn) {
62
+ this._reloadHandler = fn;
63
+ }
41
64
 
42
65
  /**
43
66
  * Настройка обработчиков событий
@@ -156,6 +179,38 @@ export class SaveManager {
156
179
  console.warn('SaveManager: объект с data:/blob: URL пропущен при сохранении:', error.message);
157
180
  return;
158
181
  }
182
+
183
+ // 409 stale_base_version: экземпляр устарел — нельзя перезаписывать сервер.
184
+ // Перечитываем latest. Retry исключён — это не transient-ошибка.
185
+ if (error?.code === 'stale_base_version') {
186
+ console.warn(
187
+ 'SaveManager: 409 stale_base_version — версия доски устарела. Перечитываю latest с сервера...',
188
+ error.message
189
+ );
190
+ if (typeof this._reloadHandler === 'function') {
191
+ try {
192
+ await this._reloadHandler();
193
+ // После перезагрузки сбрасываем кеш: следующее изменение
194
+ // сохранится с актуальной версией без ложного дедупа.
195
+ this.lastSavedData = null;
196
+ this.hasUnsavedChanges = false;
197
+ this.updateSaveStatus('saved');
198
+ console.warn(
199
+ 'SaveManager: состояние доски перечитано с сервера. ' +
200
+ 'Локальное pending-изменение отменено во избежание затирания чужих данных. ' +
201
+ 'TODO: переприменить pending-изменение поверх свежего состояния.'
202
+ );
203
+ } catch (reloadErr) {
204
+ console.error('SaveManager: ошибка перечитывания после 409:', reloadErr);
205
+ this.updateSaveStatus('error', reloadErr.message);
206
+ }
207
+ } else {
208
+ console.warn('SaveManager: _reloadHandler не задан — состояние не синхронизировано.');
209
+ this.updateSaveStatus('error', error.message);
210
+ }
211
+ return; // НЕ вызываем handleSaveError — retry не нужен
212
+ }
213
+
159
214
  console.error('Ошибка автосохранения:', error);
160
215
  this.handleSaveError(error, data);
161
216
  } finally {
@@ -179,10 +234,19 @@ export class SaveManager {
179
234
  */
180
235
  async sendSaveRequest(data) {
181
236
  const boardId = data.id || 'default';
237
+
238
+ // Актуальная версия — baseVersion для обнаружения конфликтов на сервере.
239
+ // null означает «новая доска», бэкенд игнорирует поле.
240
+ const baseVersion = typeof this._versionGetter === 'function'
241
+ ? this._versionGetter()
242
+ : null;
243
+ const validBaseVersion = (baseVersion !== null && Number.isFinite(Number(baseVersion)) && Number(baseVersion) > 0)
244
+ ? Number(baseVersion)
245
+ : null;
182
246
 
183
247
  // Если есть ApiClient, используем его (он автоматически очистит данные изображений)
184
248
  if (this.apiClient) {
185
- return await this.apiClient.saveBoard(boardId, data);
249
+ return await this.apiClient.saveBoard(boardId, data, { baseVersion: validBaseVersion });
186
250
  }
187
251
 
188
252
  // Fallback к прямому запросу (без очистки изображений)
@@ -197,7 +261,8 @@ export class SaveManager {
197
261
  const requestBody = {
198
262
  moodboardId: boardId,
199
263
  state: data,
200
- actionType: 'command_execute'
264
+ actionType: 'command_execute',
265
+ ...(validBaseVersion !== null ? { baseVersion: validBaseVersion } : {})
201
266
  };
202
267
 
203
268
  const response = await fetch(this.options.saveEndpoint, {
@@ -213,6 +278,18 @@ export class SaveManager {
213
278
  });
214
279
 
215
280
  if (!response.ok) {
281
+ // Конфликт версий: бэкенд отверг сохранение, т.к. экземпляр устарел
282
+ if (response.status === 409) {
283
+ let body = {};
284
+ try { body = await response.json(); } catch (_) {}
285
+ if (body?.code === 'stale_base_version') {
286
+ const err = new Error(body.message || 'Версия доски устарела');
287
+ err.code = 'stale_base_version';
288
+ err.currentVersion = body.currentVersion ?? null;
289
+ throw err;
290
+ }
291
+ }
292
+
216
293
  let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
217
294
 
218
295
  try {
@@ -289,12 +366,13 @@ export class SaveManager {
289
366
  }
290
367
  }
291
368
 
292
- _buildSavePayload(boardId, data, csrfToken = undefined) {
369
+ _buildSavePayload(boardId, data, csrfToken = undefined, baseVersion = null) {
293
370
  return {
294
371
  moodboardId: boardId,
295
372
  state: data,
296
373
  actionType: 'command_execute',
297
- _token: csrfToken || undefined
374
+ _token: csrfToken || undefined,
375
+ ...(baseVersion !== null ? { baseVersion } : {})
298
376
  };
299
377
  }
300
378
 
@@ -1,5 +1,6 @@
1
1
  import { Events } from '../../core/events/Events.js';
2
2
  import { logMindmapCompoundDebug } from '../../mindmap/MindmapCompoundContract.js';
3
+ import { loadExistingBoard } from './MoodBoardLoadApi.js';
3
4
 
4
5
  function getSelectTool(board) {
5
6
  const toolManager = board?.coreMoodboard?.toolManager;
@@ -115,6 +116,21 @@ export function bindSaveCallbacks(board) {
115
116
  return;
116
117
  }
117
118
 
119
+ // Прокидываем актуальную версию экземпляра в SaveManager, чтобы payload
120
+ // содержал baseVersion и бэкенд мог отклонить сохранение устаревшего состояния (409).
121
+ const saveManager = board.coreMoodboard.saveManager;
122
+ if (saveManager) {
123
+ saveManager.setVersionGetter(() => {
124
+ const v = board.historyHeadVersion ?? board.currentLoadedVersion ?? null;
125
+ return (v !== null && Number.isFinite(Number(v)) && Number(v) > 0) ? Number(v) : null;
126
+ });
127
+
128
+ // На 409 stale_base_version — перечитываем latest с сервера, не затираем чужие данные.
129
+ saveManager.setReloadHandler(async () => {
130
+ await loadExistingBoard(board, null, { fallbackToSeedOnError: false });
131
+ });
132
+ }
133
+
118
134
  board.coreMoodboard.eventBus.on('save:success', (data) => {
119
135
  const savedVersion = Number(data?.response?.historyVersion);
120
136
  if (Number.isFinite(savedVersion) && savedVersion > 0) {
@@ -1,22 +1,27 @@
1
1
  /**
2
- * DOM-скелетон 3D-модели на полотне доски.
2
+ * DOM-скелетон генерируемого объекта на полотне доски.
3
3
  *
4
- * Одна ответственность: показывать анимированную заглушку в том месте,
5
- * где появится сгенерированная 3D-модель. Не знает про сессию и вьюпорт —
6
- * экранный прямоугольник ему задаёт ChatWindow (он же держит мировые координаты
7
- * и пересчитывает позицию при pan/zoom).
4
+ * Одна ответственность: показывать анимированную заглушку (белая карточка,
5
+ * бегущий блик, пульсирующая иконка) в том месте, где появится результат
6
+ * генерации. Не знает про сессию и вьюпорт экранный прямоугольник ему задаёт
7
+ * ChatWindow (он же держит мировые координаты и пересчитывает позицию при pan/zoom).
8
+ *
9
+ * Используется для 3D-моделей, изображений и видео — иконка и вариант задаются
10
+ * через конструктор.
8
11
  *
9
12
  * Lifecycle: attach(container) → setRect(rect)* → enter() → detach() → destroy()
10
13
  * attach/detach идемпотентны.
11
14
  */
12
- export class Model3dBoardSkeleton {
15
+ export class BoardSkeleton {
13
16
  /**
14
17
  * @param {object} [opts]
15
18
  * @param {string} [opts.iconSvg] HTML-строка иконки (например ICONS.cube)
19
+ * @param {string} [opts.variant] Модификатор вида ('3d' | 'image' | 'video')
16
20
  */
17
- constructor({ iconSvg = '' } = {}) {
21
+ constructor({ iconSvg = '', variant = '' } = {}) {
18
22
  this._el = null;
19
23
  this._iconSvg = iconSvg;
24
+ this._variant = variant;
20
25
  }
21
26
 
22
27
  /**
@@ -26,11 +31,14 @@ export class Model3dBoardSkeleton {
26
31
  if (this._el) return;
27
32
 
28
33
  const el = document.createElement('div');
29
- el.className = 'moodboard-chat__3d-skeleton moodboard-chat__3d-skeleton--enter';
34
+ el.className = 'moodboard-chat__board-skeleton moodboard-chat__board-skeleton--enter';
35
+ if (this._variant) {
36
+ el.classList.add(`moodboard-chat__board-skeleton--${this._variant}`);
37
+ }
30
38
 
31
39
  if (this._iconSvg) {
32
40
  const icon = document.createElement('span');
33
- icon.className = 'moodboard-chat__3d-skeleton-icon';
41
+ icon.className = 'moodboard-chat__board-skeleton-icon';
34
42
  icon.innerHTML = this._iconSvg;
35
43
  el.appendChild(icon);
36
44
  }
@@ -71,8 +79,8 @@ export class Model3dBoardSkeleton {
71
79
  /** Запускает анимацию появления. */
72
80
  enter() {
73
81
  if (!this._el) return;
74
- this._el.classList.remove('moodboard-chat__3d-skeleton--enter');
75
- this._el.classList.add('moodboard-chat__3d-skeleton--entered');
82
+ this._el.classList.remove('moodboard-chat__board-skeleton--enter');
83
+ this._el.classList.add('moodboard-chat__board-skeleton--entered');
76
84
  }
77
85
 
78
86
  /** @returns {boolean} */
@@ -16,7 +16,7 @@ import { ChatSettingsPopup } from './ChatSettingsPopup.js';
16
16
  import { ChatVideoToolbarPills } from './ChatVideoToolbarPills.js';
17
17
  import { ChatExtendedPromptModal } from './ChatExtendedPromptModal.js';
18
18
  import { Model3dProgressOverlay } from './Model3dProgressOverlay.js';
19
- import { Model3dBoardSkeleton } from './Model3dBoardSkeleton.js';
19
+ import { BoardSkeleton } from './BoardSkeleton.js';
20
20
  import { ICONS, RATIO_ICONS, COUNT_ICONS } from './icons.js';
21
21
 
22
22
  const CONTENT_TYPE_OPTIONS = [
@@ -237,6 +237,8 @@ export class ChatWindow {
237
237
  // Мировые координаты центра скелетона — модель приземлится ровно сюда,
238
238
  // независимо от пана/зума во время генерации.
239
239
  this._model3dSkeletonWorld = null;
240
+ this._videoSkeleton = null;
241
+ this._videoSkeletonWorld = null;
240
242
  this._boardImageMessageIds = new Set();
241
243
  this._shiftedForImageBatchKeys = new Set();
242
244
  this._boardImageShiftHistory = new Map();
@@ -538,6 +540,7 @@ export class ChatWindow {
538
540
  if (this._videoUnsubscribe) { this._videoUnsubscribe(); this._videoUnsubscribe = null; }
539
541
  this._videoSession?.abort?.();
540
542
  this._videoSession = null;
543
+ this._clearVideoSkeleton();
541
544
  this._videoDurationMenu?.destroy();
542
545
  this._videoDurationMenu = null;
543
546
  this._videoDurationWrapper?.remove();
@@ -851,7 +854,7 @@ export class ChatWindow {
851
854
  y: (screenCenterY - (world?.y || 0)) / s
852
855
  };
853
856
 
854
- this._model3dSkeleton = new Model3dBoardSkeleton({ iconSvg: ICONS.cube });
857
+ this._model3dSkeleton = new BoardSkeleton({ iconSvg: ICONS.cube, variant: '3d' });
855
858
  this._model3dSkeleton.attach(this._container ?? document.body);
856
859
  this._model3dSkeleton.setRect(this._compute3dSkeletonRect(this._model3dSkeletonWorld), { animate: false });
857
860
  this._scheduleAnimationFrame(() => this._model3dSkeleton?.enter());
@@ -871,6 +874,68 @@ export class ChatWindow {
871
874
  this._model3dSkeletonWorld = null;
872
875
  }
873
876
 
877
+ /**
878
+ * Экранный прямоугольник скелетона видео из мировых координат центра.
879
+ * Ширина фиксирована BOARD_IMAGE_WIDTH в world-единицах, высота — по соотношению сторон видео.
880
+ * @param {{x:number,y:number}} worldCenter
881
+ */
882
+ _computeVideoSkeletonRect(worldCenter) {
883
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
884
+ const s = world?.scale?.x || 1;
885
+ const [wr, hr] = parseFormatRatio(this._videoRatioId);
886
+ const width = Math.round(BOARD_IMAGE_WIDTH * s);
887
+ const height = Math.round(width / (wr / hr));
888
+ const screenX = Math.round(worldCenter.x * s + (world?.x || 0));
889
+ const screenY = Math.round(worldCenter.y * s + (world?.y || 0));
890
+ return {
891
+ left: Math.round(screenX - width / 2),
892
+ top: Math.round(screenY - height / 2),
893
+ width,
894
+ height,
895
+ radius: Math.max(2, Math.round(12 * s))
896
+ };
897
+ }
898
+
899
+ _showVideoSkeleton() {
900
+ const world = this._boardCore?.pixi?.worldLayer || this._boardCore?.pixi?.app?.stage;
901
+ if (!world) return;
902
+ const s = world?.scale?.x || 1;
903
+
904
+ const composerRect = this._refs?.composer?.getBoundingClientRect?.();
905
+ const screenCenterX = composerRect
906
+ ? Math.round(composerRect.left + composerRect.width / 2)
907
+ : 400;
908
+ const screenCenterY = composerRect
909
+ ? Math.round(composerRect.top - 200)
910
+ : 200;
911
+
912
+ this._clearVideoSkeleton();
913
+
914
+ this._videoSkeletonWorld = {
915
+ x: (screenCenterX - (world?.x || 0)) / s,
916
+ y: (screenCenterY - (world?.y || 0)) / s
917
+ };
918
+
919
+ this._videoSkeleton = new BoardSkeleton({ iconSvg: ICONS.video, variant: 'video' });
920
+ this._videoSkeleton.attach(this._container ?? document.body);
921
+ this._videoSkeleton.setRect(this._computeVideoSkeletonRect(this._videoSkeletonWorld), { animate: false });
922
+ this._scheduleAnimationFrame(() => this._videoSkeleton?.enter());
923
+ }
924
+
925
+ _syncVideoSkeletonToViewport({ disableTransition = false } = {}) {
926
+ if (!this._videoSkeleton?.isAttached() || !this._videoSkeletonWorld) return;
927
+ this._videoSkeleton.setRect(
928
+ this._computeVideoSkeletonRect(this._videoSkeletonWorld),
929
+ { animate: !disableTransition }
930
+ );
931
+ }
932
+
933
+ _clearVideoSkeleton() {
934
+ this._videoSkeleton?.destroy();
935
+ this._videoSkeleton = null;
936
+ this._videoSkeletonWorld = null;
937
+ }
938
+
874
939
  _place3dModelOnBoard(result) {
875
940
  if (!this._boardCore?.eventBus) return;
876
941
  const { previewBase64, mimeType, modelUrl, format } = result;
@@ -1145,6 +1210,13 @@ export class ChatWindow {
1145
1210
  this._videoUnsubscribe = null;
1146
1211
  }
1147
1212
  this._videoUnsubscribe = this._videoSession.subscribe((state) => this._onVideoState(state));
1213
+
1214
+ try {
1215
+ this._showVideoSkeleton();
1216
+ } catch (e) {
1217
+ console.warn('[ChatWindow] _showVideoSkeleton failed:', e);
1218
+ }
1219
+
1148
1220
  const opts = this._getVideoRequestOptions();
1149
1221
  void this._videoSession.start({ ...opts, prompt: text, referenceImages: attachments?.length ? attachments : undefined });
1150
1222
  }
@@ -1155,6 +1227,7 @@ export class ChatWindow {
1155
1227
  }
1156
1228
  if (state.status === 'done') {
1157
1229
  this._composer?.setStreaming(false);
1230
+ this._clearVideoSkeleton();
1158
1231
  // Тип объекта «видео» на доске не существует. Событие Events.UI.PasteImageAt
1159
1232
  // ожидает data URL картинки — видео-URL несовместим. Размещение на доске
1160
1233
  // выходит за рамки текущей фазы (нет объекта типа video).
@@ -1166,6 +1239,7 @@ export class ChatWindow {
1166
1239
  }
1167
1240
  if (state.status === 'error') {
1168
1241
  this._composer?.setStreaming(false);
1242
+ this._clearVideoSkeleton();
1169
1243
  if (this._refs?.errorBlock) {
1170
1244
  const clean = (state.error || 'Ошибка генерации видео').replace(/^AiClient\.\w+\s*\(\d+\):\s*/, '');
1171
1245
  this._refs.errorBlock.textContent = clean;
@@ -1174,6 +1248,7 @@ export class ChatWindow {
1174
1248
  }
1175
1249
  if (state.status === 'idle') {
1176
1250
  this._composer?.setStreaming(false);
1251
+ this._clearVideoSkeleton();
1177
1252
  }
1178
1253
  }
1179
1254
 
@@ -1317,20 +1392,13 @@ export class ChatWindow {
1317
1392
 
1318
1393
  overlay.style.borderRadius = `${Math.max(2, Math.round(12 * s))}px`;
1319
1394
 
1320
- const fontSize = Math.round(20 * s);
1321
- const labelLeft = 12 * Math.min(1, s);
1322
- const labelBottom = 10 * Math.min(1, s);
1323
- overlay.style.setProperty('--moodboard-chat-pending-label-font-size', `${fontSize}px`);
1324
- overlay.style.setProperty('--moodboard-chat-pending-label-left', `${labelLeft}px`);
1325
- overlay.style.setProperty('--moodboard-chat-pending-label-bottom', `${labelBottom}px`);
1326
-
1327
1395
  overlay.style.setProperty('--moodboard-chat-board-animation-ms', `${BOARD_IMAGE_REARRANGE_MS}ms`);
1328
1396
  overlay.style.setProperty('--moodboard-chat-pending-enter-x', `${enterDistance}px`);
1329
1397
 
1330
- const label = document.createElement('span');
1331
- label.className = 'moodboard-chat__pending-image-label';
1332
- label.textContent = 'В процессе...';
1333
- overlay.appendChild(label);
1398
+ const icon = document.createElement('span');
1399
+ icon.className = 'moodboard-chat__pending-overlay-icon';
1400
+ icon.innerHTML = ICONS.image;
1401
+ overlay.appendChild(icon);
1334
1402
 
1335
1403
  (this._container ?? document.body).appendChild(overlay);
1336
1404
 
@@ -1680,10 +1748,12 @@ export class ChatWindow {
1680
1748
  const onPanUpdate = () => {
1681
1749
  this._syncPendingOverlaysToViewport({ disableTransition: true });
1682
1750
  this._sync3dSkeletonToViewport({ disableTransition: true });
1751
+ this._syncVideoSkeletonToViewport({ disableTransition: true });
1683
1752
  };
1684
1753
  const onViewportChange = () => {
1685
1754
  this._syncPendingOverlaysToViewport({ disableTransition: true, recomputeWorld: false });
1686
1755
  this._sync3dSkeletonToViewport({ disableTransition: true });
1756
+ this._syncVideoSkeletonToViewport({ disableTransition: true });
1687
1757
  // Зум меняет проекцию композера в мир: после него существующее изображение может
1688
1758
  // оказаться под заглушкой. Перепроверяем инвариант сразу, не дожидаясь рендера сессии.
1689
1759
  this._ensureExistingImagesClearOfPending();
@@ -692,29 +692,25 @@
692
692
  overflow: hidden;
693
693
  }
694
694
 
695
- /* Overlay-заглушка на полотне доски во время генерации изображения */
696
- @keyframes moodboard-pending-shimmer {
697
- 0% { background-position: 200% 0; }
698
- 100% { background-position: -100% 0; }
699
- }
700
-
695
+ /* Overlay-заглушка (скелетон) на полотне доски во время генерации изображения.
696
+ Тот же вид, что у скелетона 3D/видео: белая карточка, бегущий блик,
697
+ пульсирующая иконка. Появление слайдом (нужно для раскладки батча). */
701
698
  .moodboard-chat__pending-overlay {
702
699
  position: fixed;
700
+ box-sizing: border-box;
703
701
  --moodboard-chat-board-animation-ms: 520ms;
704
702
  --moodboard-chat-pending-enter-x: 320px;
705
- background: linear-gradient(
706
- 90deg,
707
- #5F7179 0%,
708
- #7A8D96 50%,
709
- #5F7179 100%
710
- );
711
- background-size: 200% 100%;
712
- animation: moodboard-pending-shimmer 5.76s linear infinite;
703
+ background: #ffffff;
713
704
  border-radius: 12px;
714
- box-shadow: 8px 8px 24px rgba(0, 0, 0, 0.12);
705
+ box-shadow:
706
+ 0 0 0 1px rgba(17, 24, 39, 0.08),
707
+ 0 8px 24px rgba(15, 23, 42, 0.10);
715
708
  overflow: hidden;
716
709
  pointer-events: none;
717
710
  z-index: 10;
711
+ display: flex;
712
+ align-items: center;
713
+ justify-content: center;
718
714
  transition:
719
715
  left var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
720
716
  transform var(--moodboard-chat-board-animation-ms) cubic-bezier(0.22, 1, 0.36, 1),
@@ -722,6 +718,21 @@
722
718
  will-change: transform, opacity;
723
719
  }
724
720
 
721
+ /* Бегущий блик по поверхности скелетона */
722
+ .moodboard-chat__pending-overlay::after {
723
+ content: '';
724
+ position: absolute;
725
+ inset: 0;
726
+ background: linear-gradient(
727
+ 100deg,
728
+ transparent 30%,
729
+ rgba(17, 24, 39, 0.07) 50%,
730
+ transparent 70%
731
+ );
732
+ background-size: 220% 100%;
733
+ animation: moodboard-chat-skeleton-sweep 1.6s linear infinite;
734
+ }
735
+
725
736
  .moodboard-chat__pending-overlay--enter {
726
737
  opacity: 0;
727
738
  transform: translateX(var(--moodboard-chat-pending-enter-x));
@@ -732,17 +743,19 @@
732
743
  transform: translateX(0);
733
744
  }
734
745
 
735
- .moodboard-chat__pending-image-label {
736
- position: absolute;
737
- bottom: var(--moodboard-chat-pending-label-bottom, 10px);
738
- left: var(--moodboard-chat-pending-label-left, 12px);
739
- font-family: 'GeistSans', 'GeistSans Fallback', 'Roboto', Arial, sans-serif;
740
- font-size: var(--moodboard-chat-pending-label-font-size, 20px);
741
- font-weight: 400;
742
- color: #ffffff;
743
- pointer-events: none;
744
- white-space: nowrap;
745
- text-align: left;
746
+ .moodboard-chat__pending-overlay-icon {
747
+ position: relative;
748
+ z-index: 1;
749
+ width: 34%;
750
+ max-width: 72px;
751
+ color: #9CA3AF;
752
+ animation: moodboard-chat-skeleton-pulse 1.8s ease-in-out infinite;
753
+ }
754
+
755
+ .moodboard-chat__pending-overlay-icon svg {
756
+ width: 100%;
757
+ height: 100%;
758
+ display: block;
746
759
  }
747
760
 
748
761
  /* ── Прогресс генерации 3D-модели в композере ───────────────────────── */
@@ -845,8 +858,8 @@
845
858
  display: none;
846
859
  }
847
860
 
848
- /* ── Скелетон 3D-модели на доске ─────────────────────────────────────── */
849
- .moodboard-chat__3d-skeleton {
861
+ /* ── Скелетон генерируемого объекта на доске (3D / изображение / видео) ─── */
862
+ .moodboard-chat__board-skeleton {
850
863
  position: fixed;
851
864
  box-sizing: border-box;
852
865
  background: #ffffff;
@@ -869,7 +882,7 @@
869
882
  }
870
883
 
871
884
  /* Бегущий блик по поверхности скелетона */
872
- .moodboard-chat__3d-skeleton::after {
885
+ .moodboard-chat__board-skeleton::after {
873
886
  content: '';
874
887
  position: absolute;
875
888
  inset: 0;
@@ -883,7 +896,7 @@
883
896
  animation: moodboard-chat-skeleton-sweep 1.6s linear infinite;
884
897
  }
885
898
 
886
- .moodboard-chat__3d-skeleton-icon {
899
+ .moodboard-chat__board-skeleton-icon {
887
900
  position: relative;
888
901
  z-index: 1;
889
902
  width: 34%;
@@ -892,18 +905,18 @@
892
905
  animation: moodboard-chat-skeleton-pulse 1.8s ease-in-out infinite;
893
906
  }
894
907
 
895
- .moodboard-chat__3d-skeleton-icon svg {
908
+ .moodboard-chat__board-skeleton-icon svg {
896
909
  width: 100%;
897
910
  height: 100%;
898
911
  display: block;
899
912
  }
900
913
 
901
- .moodboard-chat__3d-skeleton--enter {
914
+ .moodboard-chat__board-skeleton--enter {
902
915
  opacity: 0;
903
916
  transform: scale(0.92);
904
917
  }
905
918
 
906
- .moodboard-chat__3d-skeleton--entered {
919
+ .moodboard-chat__board-skeleton--entered {
907
920
  opacity: 1;
908
921
  transform: scale(1);
909
922
  }