kingkont 0.20.9 → 0.20.11

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/index.html CHANGED
@@ -134,9 +134,7 @@
134
134
  <button id="genSfx" class="kk-edit-only" disabled title="Сгенерировать звуковой эффект (ElevenLabs)">💥 SFX</button>
135
135
  <button id="genMusic" class="kk-edit-only" disabled title="Сгенерировать музыку (ElevenLabs)">🎵 Музыка</button>
136
136
  <span class="toolbar-sep kk-edit-only"></span>
137
- <button data-draw-tool="pencil" class="kk-edit-only" title="Карандаштонкая стрелка (Esc выход, удаление через Backspace или ×)">✏</button>
138
- <button data-draw-tool="lead" class="kk-edit-only" title="Грифель — средняя стрелка">𓇼</button>
139
- <button data-draw-tool="paint" class="kk-edit-only" title="Краска — толстая стрелка">🖌</button>
137
+ <button data-draw-tool="paint" class="kk-edit-only" title="Рисование краской нарисуй линию на холсте. Esc отмена. Удаление: Backspace или ×.">🖌</button>
140
138
  <!-- View-only banner: in-place в toolbar'е (вместо edit-кнопок).
141
139
  Видна только в body.view-only-mode (см. styles.css .kk-view-only-banner). -->
142
140
  <div id="viewOnlyBanner" class="kk-view-only-banner">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kingkont",
3
- "version": "0.20.9",
3
+ "version": "0.20.11",
4
4
  "description": "KingKont \u00b7 Chatium \u2014 \u043d\u043e\u0434-\u0440\u0435\u0434\u0430\u043a\u0442\u043e\u0440 \u0441\u0446\u0435\u043d \u0441 AI-\u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0435\u0439 (\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438/\u0432\u0438\u0434\u0435\u043e/\u0433\u043e\u043b\u043e\u0441/SFX/\u043c\u0443\u0437\u044b\u043a\u0430/\u0442\u0435\u043a\u0441\u0442)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/board.js CHANGED
@@ -227,6 +227,16 @@ window.addEventListener('DOMContentLoaded', async () => {
227
227
  brandLogo._dblTimer = null;
228
228
  if (state.filmHandle && typeof closeProject === 'function')
229
229
  closeProject();
230
+ try {
231
+ if (location.hash)
232
+ history.replaceState(null, '', location.pathname + location.search);
233
+ } catch {}
234
+ // Аноним пришёл по #template= → нажал на логотип → попал бы
235
+ // на welcome без auth. Принудительно отправляем на signin
236
+ // (welcome бесполезен для анона: cloudProjects.list → 401).
237
+ if (window.__KINGKONT_WEB__ && !window.__KINGKONT_USER__) {
238
+ location.replace('/app/spaces/client/signin');
239
+ }
230
240
  }, 250);
231
241
  });
232
242
  brandLogo.addEventListener('dblclick', () => {
@@ -310,6 +320,41 @@ async function refreshBalance() {
310
320
  _writeBalancesCache(data);
311
321
  _renderBalancesInto(data);
312
322
  }
323
+ // Параллельно обновляем identity (web-only) — без этого __KINGKONT_USER__
324
+ // оставался стейлом после logout в chatium, identity-pill показывала
325
+ // старого юзера до перезагрузки страницы. /me — единственный надёжный
326
+ // источник свежей сессии (cookie уже мог быть инвалидирован).
327
+ if (window.__KINGKONT_WEB__)
328
+ _refreshWebIdentity().catch(() => { });
329
+ }
330
+ async function _refreshWebIdentity() {
331
+ let fresh = null;
332
+ try {
333
+ const r = await fetch('/app/spaces/client/me', { credentials: 'include' });
334
+ if (r.ok) {
335
+ const j = await r.json();
336
+ fresh = j?.user || null;
337
+ }
338
+ }
339
+ catch { return; }
340
+ const prevId = window.__KINGKONT_USER__?.id || null;
341
+ const newId = fresh?.id || null;
342
+ window.__KINGKONT_USER__ = fresh;
343
+ if (prevId === newId) return;
344
+ // Identity сменилась → инвалидируем status-кэш и перерисовываем.
345
+ // Если юзер разлогинился (newId=null) — show «войти». Если зашёл
346
+ // как другой — переключаем display, но не делаем hard reload (welcome
347
+ // переотрисуется сам). welcome-list проектов протухнет тоже:
348
+ // localStorage.removeItem дает renderWelcomeRecents обновиться при
349
+ // следующем заходе.
350
+ try {
351
+ localStorage.removeItem(_STATUS_CACHE_KEY);
352
+ localStorage.removeItem('cloudProjectsCache');
353
+ }
354
+ catch { }
355
+ renderWelcomeIdentity({ force: true }).catch(() => { });
356
+ if (typeof renderWelcomeRecents === 'function')
357
+ renderWelcomeRecents().catch(() => { });
313
358
  }
314
359
  function _renderBalancesInto(data) {
315
360
  // Один pill на провайдер. Если у провайдера нет данных (выключен или
@@ -376,6 +421,12 @@ function _readStatusCache() {
376
421
  const obj = JSON.parse(raw);
377
422
  if (Date.now() - (obj.ts || 0) > _STATUS_HARD_TTL_MS)
378
423
  return null; // 24h hard max
424
+ // Никогда не доверяем cached «не залогинен» — пользователь мог
425
+ // войти в другом табе/окне chatium, а welcome всё ещё крутил
426
+ // stale `{connected: false}` до истечения 10-мин окна. Если кэш
427
+ // негативный, читаем как «нет кэша» → форсим fresh fetch.
428
+ if (obj.status && obj.status.connected === false)
429
+ return null;
379
430
  return { status: obj.status || null, ts: obj.ts || 0 };
380
431
  }
381
432
  catch {
@@ -384,6 +435,10 @@ function _readStatusCache() {
384
435
  }
385
436
  function _writeStatusCache(status) {
386
437
  try {
438
+ // Пишем кэш только для positive-state. Negative («не залогинен»)
439
+ // не кэшируем — он копеечен в пересчёте и часто врёт после login
440
+ // в другом табе (см. _readStatusCache комментарий).
441
+ if (!status || status.connected === false) return;
387
442
  localStorage.setItem(_STATUS_CACHE_KEY, JSON.stringify({ ts: Date.now(), status }));
388
443
  }
389
444
  catch { }
@@ -518,12 +573,26 @@ function _renderIdentity(wrap, status) {
518
573
  logoutBtn.addEventListener('click', async () => {
519
574
  if (!confirm('Выйти из KingKont?'))
520
575
  return;
521
- // Electron: preload IPC. Web: server endpoint.
576
+ // Electron: preload IPC сбрасывает settings.json. Web:
577
+ // POST /s/auth/sign-out — chatium-нативный endpoint, инвалидирует
578
+ // session-cookie. /api/auth/logout стаба в web-shim ничего не делал.
522
579
  try {
523
- if (window.appChatium?.logout)
580
+ if (window.appChatium?.logout) {
524
581
  await window.appChatium.logout();
525
- else
582
+ }
583
+ else if (window.__KINGKONT_WEB__) {
584
+ await fetch('/s/auth/sign-out', { method: 'POST', credentials: 'include' });
585
+ // Чистим всё клиентское состояние и редиректим на signin —
586
+ // welcome пытается отрисовать своё-чужое без auth и ломается.
587
+ try { localStorage.clear(); } catch {}
588
+ try { sessionStorage.clear(); } catch {}
589
+ window.__KINGKONT_USER__ = null;
590
+ location.replace('/app/spaces/client/signin');
591
+ return;
592
+ }
593
+ else {
526
594
  await fetch('/api/auth/logout', { method: 'POST' });
595
+ }
527
596
  }
528
597
  catch { }
529
598
  // Очистим status-кэш и форсим re-render.
@@ -839,30 +908,6 @@ async function _renderWelcomeRecentsInner() {
839
908
  }
840
909
  })().catch(() => { });
841
910
  const list = await getRecents();
842
- // Первой картой — «Открыть проект». Кликается → дёргает скрытый
843
- // #pickRoot button (тот же, что использует app menu).
844
- // Скрываем в браузерах без FSAH (Safari/Firefox) — там кнопка не сработает.
845
- if ('showDirectoryPicker' in window) {
846
- const openCard = document.createElement('div');
847
- openCard.className = 'welcome-card open-card';
848
- const openThumb = document.createElement('div');
849
- openThumb.className = 'welcome-card-thumb';
850
- openThumb.textContent = '+';
851
- const openMeta = document.createElement('div');
852
- openMeta.className = 'welcome-card-meta';
853
- const openName = document.createElement('div');
854
- openName.className = 'welcome-card-name';
855
- openName.textContent = 'Открыть проект';
856
- const openSub = document.createElement('div');
857
- openSub.className = 'welcome-card-ts';
858
- openSub.textContent = 'выбрать папку…';
859
- openMeta.append(openName, openSub);
860
- openCard.append(openThumb, openMeta);
861
- openCard.addEventListener('click', () => $('pickRoot').click());
862
- grid.appendChild(openCard);
863
- }
864
- // ☁ Облачные проекты — видна только если залогинен в Chatium.
865
- // Создаёт серверную запись и открывает её как новый проект.
866
911
  // Web fallback: если preload appSettings нет — спрашиваем сервер
867
912
  // через /api/auth/status (тот же chatium-токен из settings.json).
868
913
  let isLoggedIn = false;
@@ -878,17 +923,18 @@ async function _renderWelcomeRecentsInner() {
878
923
  }
879
924
  }
880
925
  catch { }
926
+ // 1) «Новый проект» (+ иконка) — облачный. Видна если залогинен.
881
927
  if (isLoggedIn) {
882
928
  const cloudCard = document.createElement('div');
883
929
  cloudCard.className = 'welcome-card open-card';
884
930
  const cloudThumb = document.createElement('div');
885
931
  cloudThumb.className = 'welcome-card-thumb';
886
- cloudThumb.textContent = '';
932
+ cloudThumb.textContent = '+';
887
933
  const cloudMeta = document.createElement('div');
888
934
  cloudMeta.className = 'welcome-card-meta';
889
935
  const cloudName = document.createElement('div');
890
936
  cloudName.className = 'welcome-card-name';
891
- cloudName.textContent = 'Новый проект (облако)';
937
+ cloudName.textContent = 'Новый проект';
892
938
  const cloudSub = document.createElement('div');
893
939
  cloudSub.className = 'welcome-card-ts';
894
940
  cloudSub.textContent = 'хранится на сервере';
@@ -899,8 +945,26 @@ async function _renderWelcomeRecentsInner() {
899
945
  window.cloudProjects.createNew();
900
946
  });
901
947
  grid.appendChild(cloudCard);
902
- // (отдельная карточка «Мои облачные проекты» убрана — облачные теперь
903
- // показываются inline в общем списке с ☁-бейджем.)
948
+ }
949
+ // 2) «Открыть папку» только в FSAH-средах (Electron/Chrome).
950
+ if ('showDirectoryPicker' in window) {
951
+ const openCard = document.createElement('div');
952
+ openCard.className = 'welcome-card open-card';
953
+ const openThumb = document.createElement('div');
954
+ openThumb.className = 'welcome-card-thumb';
955
+ openThumb.textContent = '📁';
956
+ const openMeta = document.createElement('div');
957
+ openMeta.className = 'welcome-card-meta';
958
+ const openName = document.createElement('div');
959
+ openName.className = 'welcome-card-name';
960
+ openName.textContent = 'Открыть папку';
961
+ const openSub = document.createElement('div');
962
+ openSub.className = 'welcome-card-ts';
963
+ openSub.textContent = 'выбрать с диска…';
964
+ openMeta.append(openName, openSub);
965
+ openCard.append(openThumb, openMeta);
966
+ openCard.addEventListener('click', () => $('pickRoot').click());
967
+ grid.appendChild(openCard);
904
968
  }
905
969
  // Сразу за «Открыть проект» — карточка «Шаблоны» (открывает библиотеку
906
970
  // шаблонов с серверa). Стиль такой же как у open-card — иконка + meta.
@@ -948,62 +1012,73 @@ async function _renderWelcomeRecentsInner() {
948
1012
  renderWelcomeRecents().catch(() => { });
949
1013
  }).catch(() => { });
950
1014
  }
951
- // ---- Объединённый список: cloud + folder, отсортированный по timestamp ----
952
- // Каждый элемент: { type: 'cloud'|'recent', sortTs, item }.
953
- // Для cloud берём максимум из (updatedAt | lastOpenedAt | createdAt)
954
- // юзер ожидает что недавно открытый проект (даже без сохранения) поднимется.
955
- // Расшаренные мне НЕ кладём в основной grid — отдельная секция «Расшарено мне» ниже.
956
- const merged = [];
957
- const sharedItems = [];
1015
+ // Юзер: «постоянные кнопки первый ряд, recent второй, shared — третий».
1016
+ // Главный grid выше (welcomeRecentGrid) теперь содержит ТОЛЬКО постоянные
1017
+ // кнопки (open / new cloud / templates), уже добавленные выше. Title
1018
+ // первого ряда меняем на «Действия».
1019
+ if (titleEl) titleEl.textContent = 'Действия';
1020
+
1021
+ // ---- Разделение проектов на «мои» и «расшарили мне» ----
1022
+ const myProjects = []; // cloud (mine) + folder recents, отсортированы
1023
+ const sharedItems = []; // cloud (shared, !mine)
958
1024
  for (const c of cloudItems) {
959
1025
  const ts = Math.max(c.lastOpenedAt || 0, c.updatedAt || 0, c.createdAt || 0);
960
1026
  if (c.shared && !c.mine) {
961
1027
  sharedItems.push({ ...c, sortTs: ts });
962
1028
  continue;
963
1029
  }
964
- merged.push({ type: 'cloud', sortTs: ts, item: c });
1030
+ myProjects.push({ type: 'cloud', sortTs: ts, item: c });
965
1031
  }
966
1032
  // Дедуп: cloud-проекты не должны показываться ещё и как «недавние папки».
967
- // Источники дубля:
968
- // 1) handle — cloud-shim (window.cloudFsShim.isCloudHandle) — фильтруем в touchRecent,
969
- // но старые записи могли остаться от предыдущих версий.
970
- // 2) имя совпадает с именем cloud-проекта — heuristic для legacy-записей.
971
1033
  const cloudNames = new Set(cloudItems.map(c => c.name).filter(Boolean));
972
1034
  for (const r of list) {
973
- if (window.cloudFsShim?.isCloudHandle?.(r.handle))
974
- continue; // shim в IDB
975
- if (cloudNames.has(r.name))
976
- continue; // имя дубля
977
- merged.push({ type: 'recent', sortTs: r.ts || 0, item: r });
978
- }
979
- merged.sort((a, b) => b.sortTs - a.sortTs);
980
- // Title меняем в зависимости от наличия проектов.
981
- if (titleEl)
982
- titleEl.textContent = merged.length ? 'Открыть проект · недавние' : 'Открыть проект';
983
- for (const m of merged) {
984
- if (m.type === 'cloud')
985
- grid.appendChild(makeCloudWelcomeCard(m.item));
986
- else
987
- grid.appendChild(makeRecentWelcomeCard(m.item));
1035
+ if (window.cloudFsShim?.isCloudHandle?.(r.handle)) continue;
1036
+ if (cloudNames.has(r.name)) continue;
1037
+ myProjects.push({ type: 'recent', sortTs: r.ts || 0, item: r });
1038
+ }
1039
+ myProjects.sort((a, b) => b.sortTs - a.sortTs);
1040
+
1041
+ // ---- Ряд 2: «Мои проекты» ----
1042
+ let myWrap = document.getElementById('welcomeMyProjectsRecent');
1043
+ if (!myWrap) {
1044
+ myWrap = document.createElement('div');
1045
+ myWrap.id = 'welcomeMyProjectsRecent';
1046
+ myWrap.className = 'welcome-recent';
1047
+ myWrap.innerHTML = '<div class="welcome-recent-title">Мои проекты</div><div class="welcome-recent-grid" id="welcomeMyProjectsGrid"></div>';
1048
+ wrap.parentNode?.insertBefore(myWrap, wrap.nextSibling);
1049
+ }
1050
+ const myGrid = myWrap.querySelector('#welcomeMyProjectsGrid');
1051
+ myGrid.innerHTML = '';
1052
+ if (!myProjects.length) {
1053
+ myWrap.style.display = 'none';
1054
+ } else {
1055
+ myWrap.style.display = '';
1056
+ for (const m of myProjects) {
1057
+ if (m.type === 'cloud') myGrid.appendChild(makeCloudWelcomeCard(m.item));
1058
+ else myGrid.appendChild(makeRecentWelcomeCard(m.item));
1059
+ }
988
1060
  }
989
- // ---- Расшарили мне — отдельной секцией под основным grid'ом. ----
990
- // Лежит как отдельный block #welcomeSharedRecent. Если есть items — показываем,
991
- // иначе скрываем.
1061
+
1062
+ // ---- Ряд 3: «Расшарено мне» ----
1063
+ // Вставляется ПОСЛЕ welcomeMyProjectsRecent (insertBefore по nextSibling
1064
+ // у myWrap, чтобы сохранить порядок при первом создании).
992
1065
  let sharedWrap = document.getElementById('welcomeSharedRecent');
993
1066
  if (!sharedWrap) {
994
1067
  sharedWrap = document.createElement('div');
995
1068
  sharedWrap.id = 'welcomeSharedRecent';
996
1069
  sharedWrap.className = 'welcome-recent';
997
- sharedWrap.style.marginTop = '32px';
998
1070
  sharedWrap.innerHTML = '<div class="welcome-recent-title">Расшарено мне</div><div class="welcome-recent-grid" id="welcomeSharedGrid"></div>';
999
- wrap.parentNode?.insertBefore(sharedWrap, wrap.nextSibling);
1071
+ myWrap.parentNode?.insertBefore(sharedWrap, myWrap.nextSibling);
1072
+ } else if (sharedWrap.previousElementSibling !== myWrap) {
1073
+ // Если порядок съехал (например myWrap создан позже sharedWrap),
1074
+ // переставляем sharedWrap после myWrap.
1075
+ myWrap.parentNode?.insertBefore(sharedWrap, myWrap.nextSibling);
1000
1076
  }
1001
1077
  const sharedGrid = sharedWrap.querySelector('#welcomeSharedGrid');
1002
1078
  sharedGrid.innerHTML = '';
1003
1079
  if (!sharedItems.length) {
1004
1080
  sharedWrap.style.display = 'none';
1005
- }
1006
- else {
1081
+ } else {
1007
1082
  sharedWrap.style.display = '';
1008
1083
  sharedItems.sort((a, b) => b.sortTs - a.sortTs);
1009
1084
  for (const it of sharedItems)
@@ -1473,8 +1548,13 @@ function renderTemplateOverlay() {
1473
1548
  // URL-forced: #template=<id> ВСЕГДА view-only, даже для владельца.
1474
1549
  // Это «preview-link» — отдельный URL который шарится наружу.
1475
1550
  // #project=<id> — обычный режим: view-only только если canModify=false.
1476
- const isTemplateUrl = window.__KINGKONT_VIEW_ONLY_URL__
1477
- || /^#template=/.test(location.hash || '');
1551
+ //
1552
+ // ВАЖНО: НЕ используем __KINGKONT_VIEW_ONLY_URL__ — оно ставится web-shim'ом
1553
+ // ОДИН раз по initial-URL и не обновляется при hash-navigation. Если юзер
1554
+ // зашёл по #template= shared-link, открыл welcome и потом свой проект
1555
+ // (#project=) — переменная остаётся true → renderTemplateOverlay включал
1556
+ // view-only на собственном проекте. Проверяем АКТУАЛЬНЫЙ hash.
1557
+ const isTemplateUrl = /^#template=/.test(location.hash || '');
1478
1558
  const isReadOnly = !isElectron && state.cloudProjectId
1479
1559
  && (state.cloudCanModify === false || isTemplateUrl);
1480
1560
  // edit-mode реveal'ит .kk-edit-only элементы (sidebar секции "Персонажи"/
@@ -1499,6 +1579,14 @@ function renderTemplateOverlay() {
1499
1579
  btn.dataset.wired = '1';
1500
1580
  btn.addEventListener('click', async () => {
1501
1581
  if (btn.disabled) return;
1582
+ // Аноним в вебе → отправляем через auth-gate. После auth chatium
1583
+ // вернёт юзера на /app/spaces/client/clone?id=, тот редиректнет
1584
+ // на /static/web.html?clone=, и init-код ниже завершит клон.
1585
+ // Electron здесь не задет — __KINGKONT_WEB__ только в web-shim.
1586
+ if (window.__KINGKONT_WEB__ && !window.__KINGKONT_USER__) {
1587
+ location.href = '/app/spaces/client/clone?id=' + encodeURIComponent(state.cloudProjectId);
1588
+ return;
1589
+ }
1502
1590
  btn.disabled = true;
1503
1591
  const orig = btn.textContent;
1504
1592
  btn.textContent = '… клонирую';
@@ -1555,12 +1643,11 @@ async function openShareModal(p) {
1555
1643
  // В Electron location.origin === http://localhost:17893 — кому
1556
1644
  // отправлять такую ссылку? Принимающий открывает в обычном браузере,
1557
1645
  // там приложение живёт на kingkont.ru. В web используем origin.
1558
- // Канонический путь — /static/client/index.html (быстрая отдача,
1559
- // CDN-like). Старый /app/spaces/client/index.html сохраняет
1560
- // backwards-compat через redirect-stub.
1646
+ // Канонический путь — /static/web.html. /app/spaces/client/index.html
1647
+ // остался как auth-aware redirect-stub (для backwards-compat).
1561
1648
  const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1)/.test(location.origin);
1562
1649
  const base = isLocal ? 'https://kingkont.ru' : location.origin;
1563
- return base + '/static/client/index.html#template=' + proj.id;
1650
+ return base + '/static/web.html#template=' + proj.id;
1564
1651
  }
1565
1652
  function render() {
1566
1653
  const isPub = !!proj.isPublic;
@@ -1769,9 +1856,9 @@ function showCloudCardContextMenu(p, clientX, clientY) {
1769
1856
  if (window.cloudProjects?.open)
1770
1857
  window.cloudProjects.open(p.id, p.name, { forceRefresh: true });
1771
1858
  });
1772
- // Расшаривание web-only (chatium endpoints proj_*). Public-toggle живёт
1773
- // внутри Share-модалки, отдельной кнопки нет.
1774
- if (window.__KINGKONT_WEB__ && p.mine !== false) {
1859
+ // Public-toggle живёт внутри Share-модалки, отдельной кнопки нет.
1860
+ // Electron-сторону /api/proj/* проксирует server.js (см. handleChatiumProjProxy).
1861
+ if (p.mine !== false) {
1775
1862
  add('🤝 Расшарить…', () => openShareModal(p));
1776
1863
  }
1777
1864
  add('💾 Сохранить как шаблон…', async () => {
@@ -2366,6 +2453,8 @@ function makeBoardItem(it, kind) {
2366
2453
  el.addEventListener('click', () => selectBoard({ kind, ...it }));
2367
2454
  el.addEventListener('contextmenu', e => {
2368
2455
  e.preventDefault();
2456
+ // View-only: меню (rename/delete) бесполезно, см. node-comment выше.
2457
+ if (document.body.classList.contains('view-only-mode')) return;
2369
2458
  e.stopPropagation();
2370
2459
  showBoardContextMenu(kind, it, e.clientX, e.clientY);
2371
2460
  });
@@ -3055,6 +3144,26 @@ $('newLocation').addEventListener('click', async () => {
3055
3144
  }
3056
3145
  });
3057
3146
  // =================== Board (универсально для серии и персонажа) ===================
3147
+ // Выделить ноду + проскроллить view так, чтобы она была в центре viewport'а.
3148
+ // Юзер: «после создания ноды (любой) нужно её выделять и перемещаться к ней».
3149
+ // Используется после addText/addLabel/genNode'а — иначе если spot оказался
3150
+ // за пределами текущего scroll'а, юзер не понимает что что-то создалось.
3151
+ function selectAndPanToNode(node) {
3152
+ if (!node || !state.currentBoard) return;
3153
+ try {
3154
+ clearSelection();
3155
+ state.selectedNodeIds.add(node.id);
3156
+ renderSelection();
3157
+ } catch {}
3158
+ const { padX, padY } = _getFramePadding();
3159
+ const z = state.zoom || 1;
3160
+ const w = node.width || 280, h = node.height || 220;
3161
+ const cx = node.x + w / 2;
3162
+ const cy = node.y + h / 2;
3163
+ canvasWrap.scrollLeft = cx * z + padX - canvasWrap.clientWidth / 2;
3164
+ canvasWrap.scrollTop = cy * z + padY - canvasWrap.clientHeight / 2;
3165
+ }
3166
+ window.selectAndPanToNode = selectAndPanToNode;
3058
3167
  // Если ни одна нода не попадает в видимую область canvas-wrap, скроллим
3059
3168
  // view на центр bbox всех нод. Используется после selectBoard / openFilm
3060
3169
  // — типичный кейс: юзер открывает старый проект, scroll был сохранён в
@@ -3680,6 +3789,15 @@ async function createNodeEl(node) {
3680
3789
  el.addEventListener('contextmenu', e => {
3681
3790
  if (e.target.closest('textarea, input, .anchor, .resize-handle, [contenteditable]'))
3682
3791
  return;
3792
+ // В view-only режиме (#template=) меню содержит destructive-actions
3793
+ // (удалить, переименовать, regen) — все они упадут на сервере по
3794
+ // canModify. Показывать их без возможности применить запутывает юзера;
3795
+ // просто блокируем ПКМ. Native-меню браузера тоже подавляем, чтобы не
3796
+ // путать с собственным.
3797
+ if (document.body.classList.contains('view-only-mode')) {
3798
+ e.preventDefault();
3799
+ return;
3800
+ }
3683
3801
  e.preventDefault();
3684
3802
  e.stopPropagation();
3685
3803
  showNodeContextMenu(node, e.clientX, e.clientY);
@@ -234,6 +234,18 @@
234
234
  setCloudButtonsVisibility();
235
235
  if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
236
236
  if (window.__KINGKONT_WEB__) location.hash = '#project=' + created.id;
237
+ // Auto-создание первой сцены: юзер ожидает что новый проект сразу
238
+ // открывает доску, а не показывает пустой sidebar с предложением
239
+ // «создать сцену». Имя «Сцена 1» — конвенция newEpisode'а из board.js.
240
+ try {
241
+ const sceneName = 'Сцена 1';
242
+ const sceneHandle = await handle.getDirectoryHandle(sceneName, { create: true });
243
+ if (typeof refreshEpisodes === 'function') await refreshEpisodes();
244
+ if (typeof selectBoard === 'function')
245
+ await selectBoard({ kind: 'episode', name: sceneName, handle: sceneHandle });
246
+ } catch (e) {
247
+ console.warn('[createNewCloudProject] auto-scene failed:', e?.message || e);
248
+ }
237
249
  } catch (e) {
238
250
  alert('Не удалось создать облачный проект: ' + (e?.message || e));
239
251
  }
@@ -917,8 +929,39 @@
917
929
  // Переинициализируем видимость кнопок раз в 5 сек — для случая когда юзер
918
930
  // login/logout в Chatium через настройки (нет других сигналов).
919
931
  setInterval(setCloudButtonsVisibility, 5000);
932
+ // ?clone=<sourceId> — приход от /app/spaces/client/clone после авторизации.
933
+ // Юзер был аноним, нажал «Скопировать себе», ушёл в auth, вернулся сюда.
934
+ // Делаем POST clone, чистим query-параметр и открываем новый проект.
935
+ autoCloneFromUrl().catch(e => console.warn('[autoClone] failed:', e?.message || e));
920
936
  });
921
937
 
938
+ async function autoCloneFromUrl() {
939
+ const m = location.search.match(/[?&]clone=([^&]+)/);
940
+ if (!m) return;
941
+ const sourceId = decodeURIComponent(m[1]);
942
+ // Сразу подчищаем URL чтобы reload не повторял клон.
943
+ try {
944
+ const params = new URLSearchParams(location.search);
945
+ params.delete('clone');
946
+ const search = params.toString();
947
+ history.replaceState(null, '', location.pathname + (search ? '?' + search : '') + location.hash);
948
+ } catch {}
949
+ const r = await fetch('/api/proj/clone?id=' + encodeURIComponent(sourceId), {
950
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
951
+ body: JSON.stringify({}),
952
+ });
953
+ if (!r.ok) {
954
+ const e = await r.json().catch(() => ({}));
955
+ alert('Не удалось клонировать: ' + (e.error || 'HTTP ' + r.status));
956
+ return;
957
+ }
958
+ const created = await r.json();
959
+ try { localStorage.removeItem('cloudProjectsCache'); } catch {}
960
+ // #project=<newId> — edit-mode для нового владельца.
961
+ history.replaceState(null, '', location.pathname + location.search + '#project=' + created.id);
962
+ await openCloudProject(created.id, created.name, { forceRefresh: true });
963
+ }
964
+
922
965
  // Вызывается из openFilm / closeProject (через хук в board.js).
923
966
  window.cloudProjects = {
924
967
  setVisibility: setCloudButtonsVisibility,
@@ -1,14 +1,22 @@
1
- // renderer/drawings.js — рисование стрелок на холсте.
1
+ // renderer/drawings.js — рисование краской на холсте.
2
+ //
3
+ // По просьбе юзера: «у стрелки убери наконечник. пусть будет просто
4
+ // рисование краской». arrowHead() и связанный код мёртвый, но оставлен
5
+ // для совместимости со старыми node-данными (node.arrow !== false теперь
6
+ // всегда трактуется как false при рендере).
7
+ //
8
+ // Доисторический комментарий ниже:
2
9
  //
3
10
  // Юзерская команда: «стрелка это нода». Поэтому drag по холсту создаёт
4
11
  // обычную ноду `type:'drawing'` со своим bbox + points. Дальше всё что
5
12
  // можно делать с нодой работает «бесплатно»: select, drag, Backspace/×,
6
13
  // Cmd-Z (через pushHistory ДО мутации), сохранение в scene.json.
7
14
  //
8
- // 3 инструмента (toolbar [data-draw-tool=""]):
9
- // • pencilтонкая серая линия
10
- // lead средне-чёрная
11
- // paint — толстая жёлтая
15
+ // 1 инструмент (toolbar [data-draw-tool="paint"]):
16
+ // • paintтолстая жёлтая стрелка
17
+ // (Раньше было 3 pencil/lead/paint; по просьбе юзера оставили только
18
+ // краску. toolStyle всё-равно содержит остальные стили на случай если
19
+ // старые ноды в манифесте имеют tool:'pencil'|'lead' — они отрендерятся.)
12
20
  //
13
21
  // Хранение в node:
14
22
  // { id, type:'drawing', tool, x, y, width, height, points: [[relX, relY],…] }
@@ -106,17 +114,6 @@
106
114
  line.setAttribute('stroke-linecap', 'round');
107
115
  line.setAttribute('stroke-linejoin', 'round');
108
116
  svg.appendChild(line);
109
- if (node.arrow !== false) {
110
- const head = arrowHead(node.points || [], st.headSize);
111
- if (head) {
112
- const tri = document.createElementNS(SVG_NS, 'polygon');
113
- tri.setAttribute('class', 'head');
114
- tri.setAttribute('points', head);
115
- tri.setAttribute('fill', color);
116
- tri.setAttribute('opacity', st.opacity);
117
- svg.appendChild(tri);
118
- }
119
- }
120
117
  el.appendChild(svg);
121
118
  return svg;
122
119
  }
@@ -152,14 +149,6 @@
152
149
  line.setAttribute('stroke-linecap', 'round');
153
150
  line.setAttribute('stroke-linejoin', 'round');
154
151
  g.appendChild(line);
155
- const head = arrowHead(points, st.headSize);
156
- if (head) {
157
- const tri = document.createElementNS(SVG_NS, 'polygon');
158
- tri.setAttribute('points', head);
159
- tri.setAttribute('fill', st.color);
160
- tri.setAttribute('opacity', st.opacity);
161
- g.appendChild(tri);
162
- }
163
152
  svg.appendChild(g);
164
153
  _previewEl = g;
165
154
  }
@@ -168,7 +157,10 @@
168
157
  }
169
158
 
170
159
  function setTool(tool) {
171
- if (_draw.tool === tool) tool = null;
160
+ // НЕ toggle (раньше клик той же кнопки → tool=null; ломалось когда
161
+ // _draw.tool оставался stuck после потерянного mouseup → следующий
162
+ // клик кнопки toggle'ил в null, юзер думал что активен).
163
+ // Деактивация: Esc, или автоматически после draw'а в _onMouseUp.
172
164
  _draw.tool = tool;
173
165
  const c = document.getElementById('canvas');
174
166
  if (c) c.style.cursor = tool ? 'crosshair' : '';
@@ -180,7 +172,13 @@
180
172
  function _onMouseDown(e) {
181
173
  if (!_draw.tool) return;
182
174
  if (e.button !== 0) return;
183
- if (e.target.closest('.node')) return;
175
+ // Раньше: `if (e.target.closest('.node')) return` — это блокировало
176
+ // draw'ы внутри bbox существующей drawing-ноды (вокруг нарисованного
177
+ // штриха PAD=22px пустого пространства, всё ловит mousedown drawing-
178
+ // ноды для drag). Юзер: «если нарисовал стрелку, а потом что-то сделал,
179
+ // клик по краске не даёт рисовать дальше». Теперь когда draw-tool
180
+ // активен — любой клик на canvas начинает draw, e.stopPropagation
181
+ // ниже гасит bubble в node.drag-handler.
184
182
  e.preventDefault();
185
183
  e.stopPropagation();
186
184
  const { x, y } = _canvasCoord(e);
@@ -206,6 +204,10 @@
206
204
  const pts = _draw.current.points;
207
205
  const tool = _draw.current.tool;
208
206
  _draw.current = null;
207
+ // Юзер: «после рисования стрелки отпускай кнопку рисования». Возврат
208
+ // в обычный select-mode после каждого draw — снимает active-стейт
209
+ // с toolbar-кнопки и убирает crosshair-курсор.
210
+ setTool(null);
209
211
  if (pts.length < 3) return; // случайный клик → игнор
210
212
  // BBox + relative-points + padding под линию и arrowhead.
211
213
  const PAD = 22;
@@ -228,9 +228,18 @@ document.querySelectorAll('#addMenu button').forEach(btn => {
228
228
 
229
229
  async function addTextAt(pos) {
230
230
  if (!state.currentBoard) return;
231
- const rect = canvas.getBoundingClientRect();
232
- const x = pos ? (pos.clientX - rect.left) / state.zoom : canvasWrap.scrollLeft / state.zoom + 80;
233
- const y = pos ? (pos.clientY - rect.top) / state.zoom : canvasWrap.scrollTop / state.zoom + 80;
231
+ let x, y;
232
+ if (pos) {
233
+ const rect = canvas.getBoundingClientRect();
234
+ x = (pos.clientX - rect.left) / state.zoom;
235
+ y = (pos.clientY - rect.top) / state.zoom;
236
+ } else {
237
+ // Без позиции (клик по «Написать» в toolbar) — кладём в свободное место
238
+ // ВИДИМОЙ зоны. Раньше использовали scrollLeft+80 без учёта canvas-padding
239
+ // → нода уезжала за вьюпорт (padX=2000 в canvas-frame).
240
+ const spot = findFreeSpot();
241
+ x = spot.x; y = spot.y;
242
+ }
234
243
  const dir = await getOrCreateBoardSubdir(state.currentBoard.handle, 'texts');
235
244
  const mdName = await uniqueName(dir, 'text.md');
236
245
  await writeFile(dir, mdName, '');
@@ -238,6 +247,7 @@ async function addTextAt(pos) {
238
247
  state.currentBoard.metadata.nodes.push(node);
239
248
  canvas.appendChild(await createNodeEl(node));
240
249
  scheduleSave();
250
+ if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
241
251
  }
242
252
 
243
253
  // Label-нода: лёгкая надпись поверх холста, без файла. Текст хранится
@@ -245,9 +255,15 @@ async function addTextAt(pos) {
245
255
  // удаляется при сериализации).
246
256
  async function addLabelAt(pos) {
247
257
  if (!state.currentBoard) return;
248
- const rect = canvas.getBoundingClientRect();
249
- const x = pos ? (pos.clientX - rect.left) / state.zoom : canvasWrap.scrollLeft / state.zoom + 80;
250
- const y = pos ? (pos.clientY - rect.top) / state.zoom : canvasWrap.scrollTop / state.zoom + 80;
258
+ let x, y;
259
+ if (pos) {
260
+ const rect = canvas.getBoundingClientRect();
261
+ x = (pos.clientX - rect.left) / state.zoom;
262
+ y = (pos.clientY - rect.top) / state.zoom;
263
+ } else {
264
+ const spot = findFreeSpot();
265
+ x = spot.x; y = spot.y;
266
+ }
251
267
  const node = {
252
268
  id: crypto.randomUUID(),
253
269
  type: 'label',
@@ -261,6 +277,7 @@ async function addLabelAt(pos) {
261
277
  const el = await createNodeEl(node);
262
278
  canvas.appendChild(el);
263
279
  scheduleSave();
280
+ if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
264
281
  // Сразу входим в режим редактирования и выделяем весь текст —
265
282
  // юзер начинает печатать, и «Подпись» заменяется на введённое.
266
283
  setTimeout(() => {
@@ -457,6 +474,7 @@ async function openGenModal(kind) {
457
474
  setTimeout(() => $('genPrompt').focus(), 50);
458
475
  }
459
476
 
477
+ $('addText').addEventListener('click', () => addTextAt(null));
460
478
  $('genImage').addEventListener('click', () => openGenModal('image'));
461
479
  $('genVideo').addEventListener('click', () => openGenModal('video'));
462
480
  $('genAudio').addEventListener('click', () => openGenModal('audio'));
@@ -523,6 +541,7 @@ $('textGenSubmit').addEventListener('click', async () => {
523
541
  canvas.appendChild(await createNodeEl(node));
524
542
  if (pendingFrom) addConnection(pendingFrom, node.id);
525
543
  scheduleSave();
544
+ if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
526
545
  $('textGenModal').classList.add('hidden');
527
546
  // Фоновая генерация — модалку уже закрыли.
528
547
  runTextJob(node, resolvedPrompt, model, state.currentBoard.handle, state.currentBoard.key, imageRefs);
@@ -730,6 +749,7 @@ $('sfxSubmit').addEventListener('click', async () => {
730
749
  state.currentBoard.metadata.nodes.push(node);
731
750
  canvas.appendChild(await createNodeEl(node));
732
751
  scheduleSave();
752
+ if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
733
753
  runSfxJob(node, text, durationSeconds, state.currentBoard.handle, state.currentBoard.key);
734
754
  });
735
755
 
@@ -862,6 +882,7 @@ $('musicSubmit').addEventListener('click', async () => {
862
882
  state.currentBoard.metadata.nodes.push(node);
863
883
  canvas.appendChild(await createNodeEl(node));
864
884
  scheduleSave();
885
+ if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
865
886
  runMusicJob(node, prompt, durationMs, state.currentBoard.handle, state.currentBoard.key);
866
887
  });
867
888
 
@@ -1632,6 +1653,7 @@ $('genSubmit').addEventListener('click', async () => {
1632
1653
  addConnection(state.pendingConnectionFrom, node.id);
1633
1654
  state.pendingConnectionFrom = null;
1634
1655
  }
1656
+ if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
1635
1657
  if (saveOnly) {
1636
1658
  scheduleSave();
1637
1659
  $('genModal').classList.add('hidden');
@@ -1784,6 +1806,7 @@ $('genSubmit').addEventListener('click', async () => {
1784
1806
  if (saveOnly) node.status = 'draft';
1785
1807
  state.currentBoard.metadata.nodes.push(node);
1786
1808
  canvas.appendChild(await createNodeEl(node));
1809
+ if (typeof selectAndPanToNode === 'function') selectAndPanToNode(node);
1787
1810
 
1788
1811
  // Async auto-name через LLM. НЕ блокируем — нода уже на холсте.
1789
1812
  // Когда LLM ответит — обновим node.name + DOM. Дедуп через uniqueNodeName.
package/renderer/media.js CHANGED
@@ -1035,7 +1035,10 @@ function getReferenceableNodes() {
1035
1035
  function getMentionSuggestions(kind) {
1036
1036
  if (!state.currentBoard) return [];
1037
1037
  // Текстовые ноды разрешены везде: resolveMentions инлайнит их .md прямо в промпт.
1038
- const allowed = kind === 'image' ? ['image', 'text']
1038
+ // Видео-рефы разрешены и для image-генерации (нек-рые модели принимают
1039
+ // видео-фреймы как референс; даже если модель проигнорирует — лучше дать
1040
+ // юзеру выбор, чем не пускать в попап).
1041
+ const allowed = kind === 'image' ? ['image', 'video', 'text']
1039
1042
  : kind === 'video' ? ['image', 'video', 'audio', 'text']
1040
1043
  : ['text', 'image', 'video', 'audio'];
1041
1044
  const out = [];
@@ -1287,7 +1290,8 @@ function updateMentionPopup() {
1287
1290
  const imgQuery = query.slice(dotIdx + 1);
1288
1291
  if (ownerName === '__scene') {
1289
1292
  // Drilldown в ноды ТЕКУЩЕЙ доски (тип фильтруем по genKind).
1290
- const allowed = state.genKind === 'image' ? ['image', 'text']
1293
+ // image-gen теперь принимает видео-рефы тоже.
1294
+ const allowed = state.genKind === 'image' ? ['image', 'video', 'text']
1291
1295
  : state.genKind === 'video' ? ['image', 'video', 'audio', 'text']
1292
1296
  : ['text', 'image', 'video', 'audio'];
1293
1297
  const seen = new Set();
@@ -1373,7 +1377,9 @@ function updateMentionPopup() {
1373
1377
  // лишний шум когда в проекте много персонажей/локаций. Click →
1374
1378
  // selectMention вставит [@__scene. и реоткроет popup в drilldown.
1375
1379
  // Если у текущей доски НЕТ нод вообще — пункт не показываем.
1376
- const allowed = state.genKind === 'image' ? ['image', 'text']
1380
+ // image-gen теперь тоже принимает видео-рефы (см. комментарий в
1381
+ // getMentionSuggestions). Держим в синхроне с тем filter'ом.
1382
+ const allowed = state.genKind === 'image' ? ['image', 'video', 'text']
1377
1383
  : state.genKind === 'video' ? ['image', 'video', 'audio', 'text']
1378
1384
  : ['text', 'image', 'video', 'audio'];
1379
1385
  const boardHasNodes = (state.currentBoard?.metadata?.nodes || []).some(n =>
@@ -273,13 +273,38 @@ async function renderNodeBody(node, body) {
273
273
  const wrap = document.createElement('div');
274
274
  wrap.className = 'gen-error';
275
275
  const errStr = String(node.error || '');
276
+ // Спец-кейс: «Недостаточно кредитов» от chatium-billing.
277
+ // Шапка ошибки от chatium громоздкая («Неизвестная ошибка при обращении
278
+ // к https://kingkont.ru/spaces/server/api/text … runUgcRequest …»).
279
+ // Заменяем на короткое «Не хватает баланса» + bright-green «Пополнить».
280
+ const isInsufficient = /Недостаточно кредитов|Insufficient (credits|balance)/i.test(errStr);
276
281
  const errBlock = document.createElement('div');
277
- // Делаем text selectable — для копирования при необходимости.
278
282
  errBlock.style.cssText = 'user-select: text; -webkit-user-select: text; cursor: text; word-break: break-word; margin-bottom: 8px;';
279
- errBlock.textContent = errStr || 'Ошибка генерации';
283
+ if (isInsufficient) {
284
+ errBlock.style.fontSize = '14px';
285
+ errBlock.style.fontWeight = '600';
286
+ errBlock.innerHTML = '💳 Не хватает баланса';
287
+ } else {
288
+ errBlock.textContent = errStr || 'Ошибка генерации';
289
+ }
280
290
  wrap.appendChild(errBlock);
281
- // Кнопка «Скопировать» — для длинных server-ошибок.
282
- if (errStr) {
291
+ if (isInsufficient) {
292
+ const topup = document.createElement('button');
293
+ topup.textContent = '💚 Пополнить';
294
+ topup.style.cssText = 'background:#16a34a;color:#fff;border:none;padding:8px 16px;border-radius:5px;font-weight:600;cursor:pointer;margin-right:6px;box-shadow:0 1px 4px rgba(0,0,0,0.2);';
295
+ topup.addEventListener('mouseenter', () => { topup.style.background = '#15803d'; });
296
+ topup.addEventListener('mouseleave', () => { topup.style.background = '#16a34a'; });
297
+ topup.addEventListener('click', e => {
298
+ e.stopPropagation();
299
+ // Web: новая вкладка с billing-страницей.
300
+ // Electron: setWindowOpenHandler в main.js перенаправит в shell.openExternal.
301
+ window.open('https://kingkont.ru/app/spaces/billing', '_blank', 'noopener');
302
+ });
303
+ wrap.appendChild(topup);
304
+ }
305
+ // Кнопка «Скопировать» — для длинных server-ошибок. Для insufficient-кейса
306
+ // не показываем (юзер не дебажит, ему нужно пополниться).
307
+ if (errStr && !isInsufficient) {
283
308
  const copyBtn = document.createElement('button');
284
309
  copyBtn.textContent = '📋 Скопировать';
285
310
  copyBtn.style.marginRight = '6px';
@@ -892,6 +917,9 @@ function attachDrag(el, node) {
892
917
  // шрифта/размера через ПКМ refreshNodeDOM пересоздаёт DOM-элемент
893
918
  // .label-text, и старый listener умирает вместе со старым элементом.
894
919
  if (header) header.addEventListener('mousedown', makeDragHandler(el, node));
920
+ // Drawing-нода (стрелка) — header нет, .node сам и есть hit-target.
921
+ // Без этого нода-стрелка не двигалась с момента создания.
922
+ else if (node.type === 'drawing') el.addEventListener('mousedown', makeDragHandler(el, node));
895
923
  }
896
924
 
897
925
  // Создаёт drag-handler для конкретной (el, node) пары. Вынесено отдельно
@@ -110,6 +110,12 @@
110
110
  width: 36px; height: 36px; flex-shrink: 0; object-fit: contain;
111
111
  background: #1a1a1a; border-radius: 8px; padding: 4px;
112
112
  }
113
+ /* Brand-logo и welcome-logo всегда кликабельны (на главной → ничего,
114
+ в проекте → возврат на welcome, dblclick → настройки). cursor:pointer
115
+ ставится и в HTML inline, но в web chatium-минифайер иногда срывает
116
+ inline-стили на тегах внутри минифицированного HTML — CSS-правило
117
+ надёжнее. */
118
+ #brandLogo, #welcomeLogo { cursor: pointer; }
113
119
  .sidebar-header .recent-film {
114
120
  display: block; font-size: 12px; color: #888; text-decoration: none;
115
121
  padding: 6px 10px; border-radius: 4px; word-break: break-all;
@@ -205,11 +211,11 @@
205
211
  .welcome { display: none; }
206
212
  body.no-project .welcome {
207
213
  display: flex; position: fixed; inset: 0; z-index: 50;
208
- flex-direction: column; align-items: center;
214
+ flex-direction: column;
209
215
  background: #1a1a1a;
210
216
  -webkit-app-region: drag;
211
- padding-top: 64px;
212
- overflow: hidden; /* recents скроллятся внутри своего блока */
217
+ padding-top: 48px;
218
+ overflow-y: auto; /* вся страница скроллится, не отдельные блоки */
213
219
  }
214
220
  body.no-project .sidebar, body.no-project .main, body.no-project .preview-panel { display: none !important; }
215
221
  /* Preview-панель видна ТОЛЬКО когда таймлайн открыт. Закрыли таймлайн —
@@ -217,28 +223,33 @@
217
223
  торчащий тёмный «остаток» справа, который мешает. */
218
224
  body:has(.timeline-panel.hidden) .preview-panel { display: none !important; }
219
225
  .welcome-inner {
220
- display: flex; flex-direction: column; align-items: center; gap: 12px;
226
+ display: flex; flex-direction: column;
221
227
  width: 100%; max-width: none; padding: 0;
222
- flex: 1; min-height: 0; /* нужно для child overflow */
223
228
  -webkit-app-region: no-drag;
224
229
  }
225
230
  .welcome-header {
226
- display: flex; flex-direction: column; align-items: center; gap: 12px;
227
- flex-shrink: 0; /* всегда сверху, не сжимается под recents */
228
- padding: 0 32px;
231
+ /* Юзер: «логотип выровняй по левой границе, название KingKont и версию —
232
+ справа от логотипа». Раньше column (logo сверху, title под ним по центру). */
233
+ display: flex; flex-direction: row; align-items: center; gap: 16px;
234
+ flex-shrink: 0;
235
+ padding: 0 52px;
236
+ margin-bottom: 24px;
229
237
  }
230
238
  .welcome-logo {
231
- width: 96px; height: 96px; object-fit: contain;
232
- background: #1f1f1f; border-radius: 16px; padding: 8px;
233
- box-shadow: 0 4px 32px rgba(227, 51, 119, 0.18);
239
+ /* Юзер: «убери тень и padding». Лого как простая иконка, без подложки. */
240
+ width: 56px; height: 56px; object-fit: contain;
241
+ background: transparent; border-radius: 0; padding: 0;
242
+ box-shadow: none;
234
243
  }
235
- .welcome-title { font-size: 28px; font-weight: 600; color: #e0e0e0; margin: 8px 0 0; }
244
+ .welcome-title { font-size: 24px; font-weight: 600; color: #e0e0e0; margin: 0; }
236
245
  .welcome-version {
237
246
  font-size: 13px; font-weight: 400; color: #666;
238
247
  font-family: ui-monospace, 'SF Mono', monospace;
239
248
  vertical-align: middle; margin-left: 6px;
240
249
  }
241
- .welcome-sub { font-size: 12px; color: #888; letter-spacing: 0.5px; text-transform: uppercase; }
250
+ /* В row-layout «Видео-редактор» рядом с title выглядит избыточно
251
+ KingKont сам по себе говорит достаточно. */
252
+ .welcome-sub { display: none; }
242
253
  /* Топ-правый блок welcome-экрана: identity + balances. Положение fixed,
243
254
  чтобы не зависеть от центрированной .welcome-inner колонки. */
244
255
  .welcome-status {
@@ -480,18 +491,22 @@
480
491
  }
481
492
  .welcome-open:hover { background: #4a6a9a; }
482
493
  .welcome-recent {
483
- margin-top: 36px; width: 100%;
484
- flex: 1; min-height: 0;
494
+ /* Расстояние между секциями (Действия / Мои проекты / Расшарено мне).
495
+ Сначала уменьшили с 36 до 18, юзер: «чуть больше» → 28px. */
496
+ margin-top: 28px; width: 100%;
485
497
  display: flex; flex-direction: column;
486
498
  }
487
499
  .welcome-recent-title {
500
+ /* Юзер: «заголовки перенеси налево, а не в центре». Padding-left
501
+ совпадает с padding'ом grid'а (52px) для визуального выравнивания
502
+ с первой карточкой. */
488
503
  font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 0.6px;
489
- margin-bottom: 14px; text-align: center;
504
+ margin-bottom: 8px; text-align: left;
505
+ padding: 0 52px;
490
506
  flex-shrink: 0;
491
507
  }
492
508
  /* Recents — плитка (responsive grid). Карточки фиксированной ширины,
493
- заполняют ряд слева направо, при нехватке места переносятся. Скроллится
494
- вертикально, если карточек много. */
509
+ заполняют ряд слева направо, при нехватке места переносятся. */
495
510
  .welcome-recent-grid {
496
511
  display: grid;
497
512
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -501,11 +516,9 @@
501
516
  визуально наезжают друг на друга — именно это юзер видел в Electron. */
502
517
  grid-auto-rows: max-content;
503
518
  gap: 16px;
504
- overflow-y: auto; overflow-x: hidden;
505
- padding: 8px 52px 24px;
519
+ padding: 0 52px 8px;
506
520
  align-content: start;
507
521
  }
508
- .welcome-recent-grid::-webkit-scrollbar { width: 8px; }
509
522
 
510
523
  /* === Глобальные dark-scrollbars === */
511
524
  /* Firefox */