Compare commits

...
10 changed files with 280 additions and 154 deletions
Binary file not shown.
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.7.6
server.version=1.6.2
client.version=1.10.0
server.version=1.8.0
+2
View File
@@ -1,3 +1,5 @@
import org.bouncycastle.util.Properties
plugins {
id 'java'
id 'application'
+13 -4
View File
@@ -952,6 +952,9 @@ function closeTopChannelsMenu(listState) {
listState.topMenuCleanup();
}
listState.topMenuCleanup = null;
listState.topMenuAnchor?.setAttribute('aria-expanded', 'false');
listState.topMenuAnchor?.classList.remove('menu-open-pressed');
listState.topMenuAnchor = null;
const root = document.getElementById('modal-root');
if (root) {
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
@@ -1002,9 +1005,6 @@ function openTopChannelsMenu({
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'channel-menu-divider';
divider.style.height = '1px';
divider.style.background = 'rgba(255,255,255,0.08)';
divider.style.margin = '6px 0';
menu.append(divider);
return;
}
@@ -1021,6 +1021,9 @@ function openTopChannelsMenu({
overlay.append(menu);
root.append(overlay);
anchorEl.setAttribute('aria-expanded', 'true');
anchorEl.classList.add('menu-open-pressed');
listState.topMenuAnchor = anchorEl;
const onOverlayClick = (event) => {
if (event.target === overlay) closeTopChannelsMenu(listState);
@@ -1354,6 +1357,7 @@ export function render({ navigate, route, chrome }) {
const listState = {
openMenuId: null,
topMenuCleanup: null,
topMenuAnchor: null,
notificationsState,
revealedCounters: new Set(),
channels: [],
@@ -1393,11 +1397,16 @@ export function render({ navigate, route, chrome }) {
topMenuBtn.type = 'button';
topMenuBtn.className = 'icon-btn channels-top-more-btn';
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
topMenuBtn.setAttribute('aria-haspopup', 'menu');
topMenuBtn.setAttribute('aria-expanded', 'false');
topMenuBtn.title = 'Ещё действия';
topMenuBtn.append(createOverflowDots());
topMenuBtn.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(topMenuBtn);
if (listState.topMenuAnchor === topMenuBtn) {
closeTopChannelsMenu(listState);
return;
}
openTopChannelsMenu({
listState,
anchorEl: topMenuBtn,
+6
View File
@@ -17,6 +17,11 @@ import { formatRelativeTime } from '../services/channels-ux.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const PREVIEW_MAX_LEN = 200;
const SVG_CHEVRON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l6 6-6 6"></path>
</svg>
`;
const DM_BLOB_PREVIEW_CACHE = new Map();
const DM_BLOB_PREVIEW_PENDING = new Map();
const dmAvatarSnapshotCache = new Map();
@@ -187,6 +192,7 @@ function compareChatRows(a, b) {
}
export function render({ navigate, chrome }) {
const login = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen';
const head = document.createElement('header');
+100 -40
View File
@@ -1,10 +1,11 @@
import { renderHeader } from '../components/header.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { authService, state } from '../state.js';
import { makeProfileRoute } from '../services/shine-routes.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
import { createForceGraph } from './network/force-graph.js';
import { engineModelFromGraphModel } from './network/adapter.js';
import { openNodeMenu, relationLabelRu } from './network/node-menu.js';
import { openNodeMenu } from './network/node-menu.js';
export const pageMeta = { id: 'network-view', title: 'Связи' };
@@ -237,7 +238,6 @@ export function render({ navigate, route, chrome } = {}) {
let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
let engine = null;
let sheetEl = null;
let loadSeq = 0;
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
@@ -400,41 +400,6 @@ export function render({ navigate, route, chrome } = {}) {
window.setTimeout(() => inputEl.focus(), 0);
}
// Нижний сниппет (bottom sheet) с краткой сводкой об узле; не блокирует карту.
function showNodeSheet(node) {
if (!sheetEl) {
sheetEl = document.createElement('div');
sheetEl.className = 'fg-sheet';
stage.append(sheetEl);
}
const login = normalizeLogin(node.login);
const shineBadge = node.shining ? '<span class="fg-sheet-badge">сияющий</span>' : '';
sheetEl.innerHTML = `
<button class="fg-sheet-close" type="button" data-act="close" aria-label="Закрыть">✕</button>
<div class="fg-sheet-body">
<div class="fg-sheet-title">${escapeHtml(login)} ${shineBadge}</div>
<div class="fg-sheet-rel">${escapeHtml(relationLabelRu(node.relationType))}</div>
</div>
<div class="fg-sheet-actions">
<button class="ghost-btn" type="button" data-act="profile">Профиль</button>
<button class="primary-btn" type="button" data-act="write">Написать</button>
</div>
`;
sheetEl.classList.add('is-open');
sheetEl.onclick = (e) => {
const btn = e.target instanceof HTMLElement ? e.target.closest('[data-act]') : null;
if (!(btn instanceof HTMLElement)) return;
const act = btn.dataset.act;
if (act === 'close') hideNodeSheet();
else if (act === 'profile') { const r = profileInfoRoute(login); if (r) navigate(r); }
else if (act === 'write') navigate(`chat/${encodeURIComponent(login)}`);
};
}
function hideNodeSheet() {
if (sheetEl) sheetEl.classList.remove('is-open');
}
function ensureEngine(model) {
if (engine) {
engine.setModel(model);
@@ -443,8 +408,8 @@ export function render({ navigate, route, chrome } = {}) {
engine = createForceGraph({
stage: board,
model,
// тап по периферийному узлу — центрируем (грузим его граф) и показываем нижний сниппет
onNodeTap: (node) => { showNodeSheet(node); void load(node.login, { pushHistory: true }); },
// тап по периферийному узлу — только центрируем и загружаем его граф; нижней карточки больше нет
onNodeTap: (node) => { void load(node.login, { pushHistory: true }); },
// тап по центру — полноценный профиль
onCenterTap: (node) => {
const routeTo = profileInfoRoute(node.login);
@@ -493,18 +458,113 @@ export function render({ navigate, route, chrome } = {}) {
}
}
let topMenuPortal = null;
let topMenuBackdrop = null;
let topMenuButton = null;
const closeTopMenu = () => {
topMenuPortal?.remove();
topMenuBackdrop?.remove();
topMenuPortal = null;
topMenuBackdrop = null;
topMenuButton?.setAttribute('aria-expanded', 'false');
topMenuButton?.classList.remove('menu-open-pressed');
};
const positionTopMenu = () => {
if (!topMenuPortal || !topMenuButton) return;
const rect = topMenuButton.getBoundingClientRect();
const margin = 10;
const menuRect = topMenuPortal.getBoundingClientRect();
const menuWidth = menuRect.width || topMenuPortal.offsetWidth || 190;
const menuHeight = menuRect.height || topMenuPortal.offsetHeight || 56;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
const below = rect.bottom + 7;
const top = below + menuHeight <= window.innerHeight - margin
? below
: Math.max(margin, rect.top - menuHeight - 7);
topMenuPortal.style.left = `${Math.round(left)}px`;
topMenuPortal.style.top = `${Math.round(top)}px`;
};
const openTopMenu = () => {
if (!topMenuButton || topMenuPortal) return;
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal network-head-menu';
portal.setAttribute('role', 'menu');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item network-head-menu-item" role="menuitem" data-action="find-person">
<span>Найти человека</span>
</button>
`;
portal.querySelector('[data-action="find-person"]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
closeTopMenu();
openSearchModal();
});
portal.addEventListener('click', (event) => event.stopPropagation());
const backdrop = document.createElement('div');
backdrop.className = 'ui-menu-dim-layer';
backdrop.addEventListener('click', closeTopMenu);
document.body.append(backdrop, portal);
topMenuBackdrop = backdrop;
topMenuPortal = portal;
topMenuButton.setAttribute('aria-expanded', 'true');
topMenuButton.classList.add('menu-open-pressed');
positionTopMenu();
requestAnimationFrame(positionTopMenu);
};
const toggleTopMenu = (event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
if (topMenuPortal) closeTopMenu();
else openTopMenu();
};
const header = renderHeader({
title: 'Связи',
rightActions: [
{ label: 'Найти', onClick: openSearchModal },
{
iconNode: createOverflowDots(),
className: 'network-top-more-btn',
title: 'Ещё действия',
ariaLabel: 'Меню связей',
onClick: toggleTopMenu,
},
],
});
// «Связи» используют тот же общий topbar, что и остальные страницы.
// Отдельный класс нужен только для page-specific fade графа, не для геометрии header.
header.classList.add('network-topbar');
topMenuButton = header.querySelector('.network-top-more-btn');
topMenuButton?.setAttribute('aria-haspopup', 'menu');
topMenuButton?.setAttribute('aria-expanded', 'false');
const onTopMenuOutsideClick = (event) => {
if (!topMenuPortal) return;
if (topMenuPortal.contains(event.target) || topMenuButton?.contains(event.target)) return;
closeTopMenu();
};
const onTopMenuKeydown = (event) => {
if (event.key !== 'Escape' || !topMenuPortal) return;
closeTopMenu();
topMenuButton?.focus();
};
const onTopMenuViewportChange = () => positionTopMenu();
document.addEventListener('click', onTopMenuOutsideClick);
document.addEventListener('keydown', onTopMenuKeydown);
window.addEventListener('resize', onTopMenuViewportChange, { passive: true });
window.addEventListener('scroll', onTopMenuViewportChange, { passive: true, capture: true });
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
screen.cleanup = () => {
closeTopMenu();
document.removeEventListener('click', onTopMenuOutsideClick);
document.removeEventListener('keydown', onTopMenuKeydown);
window.removeEventListener('resize', onTopMenuViewportChange);
window.removeEventListener('scroll', onTopMenuViewportChange, true);
if (engine) engine.destroy();
engine = null;
appScreenEl?.classList.remove('network-scroll-lock');
+72 -33
View File
@@ -21,8 +21,9 @@ import { buildArweaveDataUrl } from '../../services/arweave-file-service.js';
const SVGNS = 'http://www.w3.org/2000/svg';
// --- Параметры физики и анимации ---------------------------------------------
const ORBIT_MIN = 150; // минимальный радиус орбиты (защитный отступ от центра), px
const ORBIT_MAX = 240; // максимальный радиус орбиты (слабая связь — дальше), px
const ORBIT_FIRST_R = 100; // первый круг связей: был ~150px, теперь на треть ближе к центру
const ORBIT_GAP = 87; // между соседними кругами >= 1.5 диаметра аватарки (58px * 1.5)
const ORBIT_NODE_GAP = 87; // минимальная дистанция между центрами соседних аватарок на одном круге
const K_RADIAL = 0.035; // очень мягкая пружина пера к орбите — узлы выходят «как резина»
const K_FOCUS = 0.12; // мягкая пружина фокуса к центру
const CHARGE = 1400; // базовое отталкивание (на старте перестроения временно ослабляется)
@@ -146,11 +147,57 @@ function ensureShineFilter() {
document.body.appendChild(svg);
}
// Равномерный угол по кругу — гарантирует отсутствие угловых наложений при любом N.
// Небольшой постоянный сдвиг, чтобы первый узел не «прилипал» к горизонтали.
function spreadAngle(index, total) {
// Равномерный угол по кругу. Небольшой постоянный сдвиг, чтобы первый узел
// не «прилипал» к горизонтали.
function spreadAngle(index, total, phase = 0.52) {
if (total <= 0) return 0;
return ((index / total) * Math.PI * 2 + 0.52) % (Math.PI * 2);
return ((index / total) * Math.PI * 2 + phase) % (Math.PI * 2);
}
// Сколько аватарок гарантированно помещается на круге радиуса r, если расстояние
// между ЦЕНТРАМИ соседних аватарок должно быть не меньше ORBIT_NODE_GAP.
// Используем хорду, а не длину дуги — поэтому условие выполняется геометрически точно.
function orbitCapacity(radius) {
const r = Math.max(radius, ORBIT_NODE_GAP / 2 + 1);
const minAngle = 2 * Math.asin(Math.min(1, ORBIT_NODE_GAP / (2 * r)));
return Math.max(1, Math.floor((Math.PI * 2) / minAngle));
}
// Детерминированная раскладка первого уровня по концентрическим кругам.
// Сначала максимально заполняем внутренний круг, затем второй, третий и т.д.
// Соседние кольца разнесены минимум на 87px, а каждое следующее чуть повёрнуто,
// чтобы линии второго/третьего круга естественно проходили под аватарками внутренних кругов.
function buildOrbitPlacements(total) {
const count = Math.max(0, Number(total) || 0);
const out = new Array(count);
let start = 0;
let ring = 0;
while (start < count) {
const radius = ORBIT_FIRST_R + ring * ORBIT_GAP;
const capacity = orbitCapacity(radius);
const ringCount = Math.min(capacity, count - start);
const phase = 0.52 + (ring % 2 ? Math.PI / Math.max(6, ringCount) : 0);
for (let i = 0; i < ringCount; i += 1) {
const angle = spreadAngle(i, ringCount, phase);
out[start + i] = { radius, angle, ring };
}
start += ringCount;
ring += 1;
}
return out;
}
function applyOrbitTargets(peers) {
const list = Array.isArray(peers) ? peers : [];
const placements = buildOrbitPlacements(list.length);
list.forEach((n, i) => {
const p = placements[i] || { radius: ORBIT_FIRST_R, angle: spreadAngle(i, list.length), ring: 0 };
n.targetR = p.radius;
n.angle = p.angle;
n.orbitRing = p.ring;
n.tx = Math.cos(n.angle) * n.targetR;
n.ty = Math.sin(n.angle) * n.targetR;
});
}
// Детерминированный «джиттер» по id (0..1) — чтобы орбита была органически неровной,
@@ -360,8 +407,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
const specs = [];
const focusSrc = list.find((n) => String(n.id) === fId) || list[0];
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false });
tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES }));
const tier1Orbit = buildOrbitPlacements(tier1.length);
if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false, orbit: null });
tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES, orbit: tier1Orbit[i] }));
// 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками
deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 }));
return { focusId: fId, specs };
@@ -370,17 +418,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
function buildNodes(srcModel) {
const { focusId: fId, specs } = computeSpecs(srcModel);
focusId = fId;
return specs.map((s) => makeNodeState(s.src, s.isFocus, s.index, s.total, s.dotOnly));
return specs.map((s) => makeNodeState(s.src, s.isFocus, s.index, s.total, s.dotOnly, s.orbit));
}
function makeNodeState(src, isFocus, index, total, dotOnly = false) {
function makeNodeState(src, isFocus, index, total, dotOnly = false, orbit = null) {
const strength = Math.max(0, Math.min(1, Number(src.strength) || 0.5));
const tier = Number(src.tier) || 1;
// органическая неровность: детерминированный джиттер радиуса (±9px) и угла (±0.2 рад)
const jr = (hash01(src.id) - 0.5) * 18;
const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
const targetR = isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
const angle = isFocus ? 0 : spreadAngle(index, total) + ja;
// Первый уровень теперь раскладывается по строгим концентрическим кругам без джиттера:
// иначе случайное смещение могло бы снова нарушить минимальный зазор между аватарками.
const targetR = isFocus ? 0 : (orbit?.radius || ORBIT_FIRST_R);
const angle = isFocus ? 0 : (orbit?.angle ?? spreadAngle(index, total));
// масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда.
const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1);
@@ -404,6 +451,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
strength,
targetR,
angle,
orbitRing: orbit?.ring || 0,
tx,
ty,
x: tx * INTRO_FACTOR,
@@ -567,10 +615,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.strength = strength;
node.relationType = src.relationType;
node.shining = Boolean(src.shining);
const jr = (hash01(src.id) - 0.5) * 18;
const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4;
node.targetR = spec.isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr;
node.angle = spec.isFocus ? 0 : spreadAngle(spec.index, spec.total) + ja;
node.targetR = spec.isFocus ? 0 : (spec.orbit?.radius || ORBIT_FIRST_R);
node.angle = spec.isFocus ? 0 : (spec.orbit?.angle ?? spreadAngle(spec.index, spec.total));
node.orbitRing = spec.orbit?.ring || 0;
node.tx = Math.cos(node.angle) * node.targetR;
node.ty = Math.sin(node.angle) * node.targetR;
node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE));
@@ -990,17 +1037,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
target.vy = 0;
to.set(target.id, { x: 0, y: 0, scale: FOCUS_SCALE });
peers.forEach((n, i) => {
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
n.angle = spreadAngle(i, peers.length);
applyOrbitTargets(peers);
peers.forEach((n) => {
n.vx = 0;
n.vy = 0;
const tx = Math.cos(n.angle) * n.targetR;
const ty = Math.sin(n.angle) * n.targetR;
n.tx = tx; // обновляем целевую точку, иначе физика после твина утянет узел назад
n.ty = ty;
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
to.set(n.id, { x: tx, y: ty, scale: sc });
to.set(n.id, { x: n.tx, y: n.ty, scale: sc });
});
tween = {
@@ -1091,11 +1133,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const focus = nodes.find((n) => n.isFocus);
if (focus) apply(focus, 0, 0, FOCUS_SCALE, 1);
visiblePeers.forEach((n, i) => {
n.targetR = ORBIT_MIN + (1 - n.strength) * (ORBIT_MAX - ORBIT_MIN);
n.angle = spreadAngle(i, visiblePeers.length);
n.tx = Math.cos(n.angle) * n.targetR;
n.ty = Math.sin(n.angle) * n.targetR;
applyOrbitTargets(visiblePeers);
visiblePeers.forEach((n) => {
const sc = n.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE;
apply(n, n.tx, n.ty, sc, 1);
});
@@ -1598,7 +1637,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node = oldNode; isNew = false;
} else {
if (oldNode) oldNode.el.remove(); // сменился тип (точка↔аватар) — заменяем элемент
node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly);
node = makeNodeState(spec.src, spec.isFocus, spec.index, spec.total, spec.dotOnly, spec.orbit);
isNew = true;
}
+83
View File
@@ -10485,3 +10485,86 @@ body.chat-topbar-overlay .composer-slot {
.channels-screen--list .channel-row-owner {
display: none !important;
}
/* ===== Channel list alignment with Personal chats (2026-08-29) ===== */
/* Match the Personal tab width model exactly: only the list itself grows beyond
* the content gutter, rather than widening several nested channel containers. */
.channels-screen--list .channels-list-content,
.channels-screen--list .channels-groups {
width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.channels-screen--list .stack:has(> .channel-row) {
width: calc(100% + 16px) !important;
margin-inline: -8px !important;
gap: 4px !important;
}
/* Slightly taller than Personal (~12%) to comfortably fit title + technical id
* + last-message preview, while keeping the compact list rhythm. */
.channels-screen--list .channel-row {
width: 100% !important;
min-height: 61px !important;
padding: 6px 7px 6px 5px !important;
grid-template-columns: 50px minmax(0, 1fr) !important;
gap: 8px !important;
}
.channels-screen--list .channel-row-main {
display: grid !important;
align-content: center !important;
gap: 1px !important;
min-width: 0 !important;
overflow: hidden !important;
}
/* Restore the compact technical channel id: owner login / channel name. */
.channels-screen--list .channel-row-technical {
display: block !important;
min-width: 0 !important;
margin: 0 !important;
font-size: 10.5px !important;
line-height: 1.05 !important;
font-weight: 500 !important;
opacity: .72;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
/* Keep only the requested two supporting lines; description/counters stay out of
* the compact channel tile. */
.channels-screen--list .channel-row-description,
.channels-screen--list .channel-row-owner {
display: none !important;
}
/* Last message and its timestamp form one single line. The preview yields space
* to the timestamp and truncates with an ellipsis when necessary. */
.channels-screen--list .channel-row-preview-line {
display: flex !important;
align-items: baseline !important;
width: 100% !important;
min-width: 0 !important;
gap: 7px !important;
margin: 0 !important;
}
.channels-screen--list .channel-row-preview-line .channel-row-message {
flex: 1 1 auto !important;
min-width: 0 !important;
margin: 0 !important;
font-size: 12px !important;
line-height: 1.1 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.channels-screen--list .channel-row-preview-line .channel-row-time {
flex: 0 0 auto !important;
align-self: baseline !important;
margin: 0 !important;
font-size: 11px !important;
line-height: 1.1 !important;
white-space: nowrap !important;
}
+2 -2
View File
@@ -34,8 +34,8 @@ body::before {
--toolbar-height: 78px;
--keyboard-offset: 0px;
background: transparent;
border-left: 1px solid rgba(211, 170, 86, 0.2);
border-right: 1px solid rgba(211, 170, 86, 0.2);
border-left: 0;
border-right: 0;
box-shadow: var(--shadow);
overflow: hidden;
}
-73
View File
@@ -531,79 +531,6 @@
background: transparent;
}
/* Нижний сниппет (bottom sheet) */
.fg-sheet {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(12px + env(safe-area-inset-bottom));
z-index: 13;
display: none;
padding: 12px 14px 14px;
background: rgba(16, 24, 40, 0.95);
border: 1px solid rgba(166, 196, 245, 0.26);
border-radius: 16px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.fg-sheet.is-open {
display: block;
animation: fg-sheet-in 200ms ease;
}
@keyframes fg-sheet-in {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.fg-sheet-close {
position: absolute;
top: 8px;
right: 10px;
background: transparent;
border: 0;
color: #9fb6e0;
font-size: 16px;
cursor: pointer;
line-height: 1;
}
.fg-sheet-title {
font-weight: 700;
font-size: 15px;
color: #eaf1ff;
display: flex;
gap: 8px;
align-items: center;
}
.fg-sheet-badge {
font-size: 10px;
background: rgba(130, 235, 255, 0.2);
color: #bff0ff;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
}
.fg-sheet-rel {
font-size: 12px;
color: #9fb6e0;
margin-top: 3px;
}
.fg-sheet-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.fg-sheet-actions > button {
flex: 1;
}
/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */
/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */