Compare commits

...
4 Commits
Author SHA256 Message Date
AidarKC 23969121e5 cripto utils 2026-09-20 11:20:32 +03:00
AidarKC 4da2d7129a 1 2026-09-19 10:18:45 +03:00
AidarKC 2be5ad0b7d Выровнять верхние панели UI 2026-09-18 00:00:42 +03:00
AidarKC f44ab75c64 Улучшить аватары и граф связей 2026-09-17 23:35:53 +03:00
21 changed files with 245 additions and 50 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.12.17 client.version=1.12.19
server.version=1.10.7 server.version=1.10.7
-1
View File
@@ -218,7 +218,6 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
'chat-view', 'chat-view',
'channel-view', 'channel-view',
'channel-thread-view', 'channel-thread-view',
'notifications-view',
]); ]);
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([ const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
+12
View File
@@ -36,6 +36,7 @@ export function renderAvatar({
title = '', title = '',
alt = 'Аватар', alt = 'Аватар',
glow = false, glow = false,
official = false,
} = {}) { } = {}) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']); const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
@@ -52,6 +53,15 @@ export function renderAvatar({
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?'; fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
wrap.append(fallback); wrap.append(fallback);
if (official) {
const badge = document.createElement('img');
badge.className = 'avatar-official-badge';
badge.src = '/assets/shine-official-badge.svg?v=2';
badge.alt = '';
badge.setAttribute('aria-hidden', 'true');
wrap.append(badge);
}
const txId = String(avatar?.ar || '').trim(); const txId = String(avatar?.ar || '').trim();
if (!validateArweaveTxId(txId)) { if (!validateArweaveTxId(txId)) {
return wrap; return wrap;
@@ -146,6 +156,7 @@ export function renderUserAvatar({
className = '', className = '',
title = '', title = '',
glow = false, glow = false,
official = false,
} = {}) { } = {}) {
return renderAvatar({ return renderAvatar({
initials: buildAvatarInitials({ login, firstName, lastName }), initials: buildAvatarInitials({ login, firstName, lastName }),
@@ -155,5 +166,6 @@ export function renderUserAvatar({
title, title,
alt: 'Аватар', alt: 'Аватар',
glow, glow,
official,
}); });
} }
+1 -1
View File
@@ -1426,7 +1426,7 @@ export function render({ navigate, route, chrome }) {
iconNode: createOverflowDots(), iconNode: createOverflowDots(),
title: 'Действия чата', title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата', ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn', className: 'chat-header-icon-btn chat-header-menu-btn topbar-overflow-action--raised',
menu: { menu: {
minWidth: 230, minWidth: 230,
items: () => [ items: () => [
+1 -1
View File
@@ -251,7 +251,7 @@ export function render({ navigate, chrome }) {
iconNode: createOverflowDots(), iconNode: createOverflowDots(),
title: 'Меню чатов', title: 'Меню чатов',
ariaLabel: 'Меню чатов', ariaLabel: 'Меню чатов',
className: 'messages-topbar-menu-btn', className: 'messages-topbar-menu-btn topbar-overflow-action--raised',
menu: { menu: {
minWidth: 210, minWidth: 210,
items: [ items: [
+44 -14
View File
@@ -709,14 +709,23 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI
const centerKey = normKey(snap?.centerLogin); const centerKey = normKey(snap?.centerLogin);
const snapNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : []; const snapNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : [];
const ownCenter = snapNodes.find((node) => normKey(node?.id) === centerKey); const ownCenter = snapNodes.find((node) => normKey(node?.id) === centerKey);
if (ownCenter) centerNodeByKey.set(centerKey, { ...ownCenter, isHistoryCenter: true, keepVisible: true, tier: 1 }); if (ownCenter) centerNodeByKey.set(centerKey, {
...ownCenter,
isHistoryCenter: true,
clusterOwnerId: centerKey,
keepVisible: true,
tier: 1,
});
snapNodes.forEach((rawNode) => { snapNodes.forEach((rawNode) => {
const key = normKey(rawNode?.id); const key = normKey(rawNode?.id);
if (!key) return; if (!key) return;
// Исторические центры сохраняют позицию собственного кластера. Все остальные общие узлы // Исторические центры сохраняют позицию собственного кластера. Все остальные общие узлы
// принадлежат самому свежему кластеру, где встретились, и поэтому «переезжают» туда без дубля. // принадлежат самому свежему кластеру, где встретились, и поэтому «переезжают» туда без дубля.
if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) nodeMap.set(key, { ...rawNode, id: key }); if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) {
// Общий узел принадлежит последнему кластеру, возле которого он был перерисован.
nodeMap.set(key, { ...rawNode, id: key, clusterOwnerId: centerKey });
}
if (key === centerKey) return; if (key === centerKey) return;
let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : []; let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : [];
@@ -755,9 +764,11 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI
id: key, id: key,
login: node?.login || node?.id || key, login: node?.login || node?.id || key,
tier: centerKeys.has(key) ? 1 : Math.max(1, Number(node?.tier) || 1), tier: centerKeys.has(key) ? 1 : Math.max(1, Number(node?.tier) || 1),
isHistoryCenter: centerKeys.has(key),
fixedLayout: true, fixedLayout: true,
keepVisible: centerKeys.has(key) || Boolean(node?.keepVisible), keepVisible: centerKeys.has(key) || Boolean(node?.keepVisible),
alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible), alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible),
clusterOwnerId: normKey(node?.clusterOwnerId || key),
edgeParents: edgeParentsByChild.get(key) || [], edgeParents: edgeParentsByChild.get(key) || [],
})); }));
@@ -1098,11 +1109,15 @@ export function render({ navigate, route, chrome } = {}) {
window.setTimeout(() => inputEl.focus(), 0); window.setTimeout(() => inputEl.focus(), 0);
} }
function persistManualNodePosition(nodeId, point) { function persistManualNodePositions(rows) {
const key = normKey(nodeId); const positions = new Map();
const x = Number(point?.x); (Array.isArray(rows) ? rows : []).forEach((row) => {
const y = Number(point?.y); const key = normKey(row?.id || row?.login);
if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return; const x = Number(row?.x);
const y = Number(row?.y);
if (key && Number.isFinite(x) && Number.isFinite(y)) positions.set(key, { x, y });
});
if (!positions.size) return;
// Координаты исторических snapshot'ов уже находятся в общей world-системе. Обновляем все // Координаты исторических snapshot'ов уже находятся в общей world-системе. Обновляем все
// упоминания пользователя, чтобы следующий setModel/filter/history render не откатил ручной drag. // упоминания пользователя, чтобы следующий setModel/filter/history render не откатил ручной drag.
@@ -1110,12 +1125,13 @@ export function render({ navigate, route, chrome } = {}) {
const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] }); const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] });
let changed = false; let changed = false;
model.nodes = model.nodes.map((node) => { model.nodes = model.nodes.map((node) => {
if (normKey(node?.id) !== key) return node; const point = positions.get(normKey(node?.id));
if (!point) return node;
changed = true; changed = true;
return { return {
...node, ...node,
layoutX: x, layoutX: point.x,
layoutY: y, layoutY: point.y,
fixedLayout: true, fixedLayout: true,
}; };
}); });
@@ -1149,10 +1165,19 @@ export function render({ navigate, route, chrome } = {}) {
} }
void load(node.login, { pushHistory: true, transitionAngle, transitionX, transitionY }); void load(node.login, { pushHistory: true, transitionAngle, transitionX, transitionY });
}, },
// тап по центру — полноценный профиль // Короткий тап по текущему центру сразу открывает его меню.
onCenterTap: (node) => { onCenterTap: (node) => {
const routeTo = profileInfoRoute(node.login); const rect = node?.el?.getBoundingClientRect?.();
if (routeTo) navigate(routeTo); openNodeMenu({
login: normalizeLogin(node?.login),
displayName: String(node?.name || '').trim(),
relationType: node?.relationType,
point: rect ? { x: rect.left + rect.width / 2, y: rect.top, rect } : undefined,
actions: [
{ label: 'Профиль', onClick: () => { const r = profileInfoRoute(node?.login); if (r) navigate(r); } },
{ label: 'Написать', onClick: () => navigate(`chat/${encodeURIComponent(normalizeLogin(node?.login))}`) },
],
});
}, },
// долгое нажатие — контекстное меню (вне масштабируемого холста) // долгое нажатие — контекстное меню (вне масштабируемого холста)
onNodeLongPress: (node, point) => { onNodeLongPress: (node, point) => {
@@ -1171,7 +1196,12 @@ export function render({ navigate, route, chrome } = {}) {
// Drag периферийного аватара — ручная правка текущей карты. Движок уже двигает DOM/рёбра // Drag периферийного аватара — ручная правка текущей карты. Движок уже двигает DOM/рёбра
// в реальном времени; здесь только сохраняем итоговую world-позицию в историю/X2 snapshot. // в реальном времени; здесь только сохраняем итоговую world-позицию в историю/X2 snapshot.
onNodeMoveEnd: (node, point) => { onNodeMoveEnd: (node, point) => {
persistManualNodePosition(node?.id || node?.login, point); persistManualNodePositions([{ id: node?.id || node?.login, ...point }]);
},
// Центр и узлы, которые в последний раз были нарисованы возле него,
// сохраняются одной пачкой, чтобы общий узел не вернулся в старый кластер при rebuild.
onGroupMoveEnd: (movedNodes) => {
persistManualNodePositions(movedNodes);
}, },
}); });
} }
+48 -7
View File
@@ -219,13 +219,14 @@ function hash01(str) {
* @param {Object} opts * @param {Object} opts
* @param {HTMLElement} opts.stage - контейнер сцены (position: relative/absolute, overflow hidden) * @param {HTMLElement} opts.stage - контейнер сцены (position: relative/absolute, overflow hidden)
* @param {Object} opts.model - нормализованная модель { focusId, nodes[] } * @param {Object} opts.model - нормализованная модель { focusId, nodes[] }
* @param {Function} [opts.onCenterTap] - тап по центральному узлу (node) => void * @param {Function} [opts.onCenterTap] - тап по текущему центральному узлу (node) => void
* @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования) * @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования)
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void * @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
* @param {Function} [opts.onNodeMoveEnd] - ручное перемещение периферийного узла (node, {x,y}) => void * @param {Function} [opts.onNodeMoveEnd] - ручное перемещение периферийного узла (node, {x,y}) => void
* @param {Function} [opts.onGroupMoveEnd] - перемещение исторического кластера (nodes[]) => void
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }} * @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
*/ */
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onNodeHover, onDiveChange } = {}) { export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onGroupMoveEnd, onNodeHover, onDiveChange } = {}) {
// Слои DOM // Слои DOM
const edgesSvg = document.createElementNS(SVGNS, 'svg'); const edgesSvg = document.createElementNS(SVGNS, 'svg');
edgesSvg.setAttribute('class', 'fg-edges'); edgesSvg.setAttribute('class', 'fg-edges');
@@ -658,6 +659,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.relationType = src.relationType; node.relationType = src.relationType;
node.shining = Boolean(src.shining); node.shining = Boolean(src.shining);
node.official = Boolean(src.official); node.official = Boolean(src.official);
node.isHistoryCenter = Boolean(src.isHistoryCenter);
node.clusterOwnerId = String(src.clusterOwnerId || src.id || '');
node.keepVisible = Boolean(src.keepVisible); node.keepVisible = Boolean(src.keepVisible);
node.alwaysVisible = Boolean(src.alwaysVisible); node.alwaysVisible = Boolean(src.alwaysVisible);
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : []; node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
@@ -1472,6 +1475,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let nodeDragActive = false; let nodeDragActive = false;
let nodeDragStartX = 0; let nodeDragStartX = 0;
let nodeDragStartY = 0; let nodeDragStartY = 0;
let groupDragActive = false;
let groupDragNodes = [];
let groupDragStarts = new Map();
let longTimer = 0; let longTimer = 0;
let longFired = false; let longFired = false;
const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами
@@ -1602,8 +1608,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
downNode = nodeFromEvent(ev); downNode = nodeFromEvent(ev);
nodeDragActive = false; nodeDragActive = false;
groupDragActive = false;
groupDragNodes = [];
groupDragStarts = new Map();
nodeDragStartX = Number(downNode?.x) || 0; nodeDragStartX = Number(downNode?.x) || 0;
nodeDragStartY = Number(downNode?.y) || 0; nodeDragStartY = Number(downNode?.y) || 0;
if (downNode?.isHistoryCenter) {
const ownerId = String(downNode.clusterOwnerId || downNode.id);
groupDragNodes = nodes.filter((node) => String(node.clusterOwnerId || node.id) === ownerId);
groupDragStarts = new Map(groupDragNodes.map((node) => [String(node.id), { x: Number(node.x) || 0, y: Number(node.y) || 0 }]));
}
// касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out // касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out
if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true); if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true);
if (downNode && typeof onNodeLongPress === 'function') { if (downNode && typeof onNodeLongPress === 'function') {
@@ -1655,15 +1669,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
moved = true; moved = true;
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; } if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это drag/pan, а не нажатие if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это drag/pan, а не нажатие
// Не центральный аватар перетаскивается сам. Пустой фон или центральный узел продолжают панорамировать карту. // Исторический центр тащит свой кластер; обычный узел — только себя; пустой фон — всю карту.
nodeDragActive = Boolean(downNode && !downNode.isFocus); groupDragActive = Boolean(downNode?.isHistoryCenter && groupDragNodes.length);
if (nodeDragActive && cssBloom) endCssBloom(); nodeDragActive = Boolean(downNode && !groupDragActive);
if ((nodeDragActive || groupDragActive) && cssBloom) endCssBloom();
// палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был // палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false); if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста) camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста)
cancelTween(); // жест прерывает анимацию центрирования cancelTween(); // жест прерывает анимацию центрирования
dragging = true; dragging = true;
} }
if (moved && groupDragActive) {
const zx = dx / Math.max(0.001, zoom);
const zy = dy / Math.max(0.001, zoom);
groupDragNodes.forEach((node) => {
const start = groupDragStarts.get(String(node.id));
if (!start) return;
const nx = start.x + zx;
const ny = start.y + zy;
node.x = nx; node.y = ny;
node.tx = nx; node.ty = ny;
node.bfx = nx; node.bfy = ny;
node.vx = 0; node.vy = 0;
node.fixedLayout = true;
});
renderNodes();
renderEdges();
return;
}
if (moved && nodeDragActive && downNode) { if (moved && nodeDragActive && downNode) {
// dx/dy приходят в экранных пикселях, координаты узла живут в world-space — делим на текущий zoom. // dx/dy приходят в экранных пикселях, координаты узла живут в world-space — делим на текущий zoom.
const nx = nodeDragStartX + dx / Math.max(0.001, zoom); const nx = nodeDragStartX + dx / Math.max(0.001, zoom);
@@ -1708,9 +1741,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const wasMoved = moved; const wasMoved = moved;
const wasLong = longFired; const wasLong = longFired;
const movedNode = nodeDragActive ? downNode : null; const movedNode = nodeDragActive ? downNode : null;
const movedGroup = groupDragActive ? [...groupDragNodes] : [];
pointerId = null; pointerId = null;
dragging = false; dragging = false;
nodeDragActive = false; nodeDragActive = false;
groupDragActive = false;
// касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap) // касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap)
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false); if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
@@ -1719,6 +1754,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Передаём окончательную world-позицию наружу, чтобы ручная правка пережила следующий setModel/history render. // Передаём окончательную world-позицию наружу, чтобы ручная правка пережила следующий setModel/history render.
if (typeof onNodeMoveEnd === 'function') onNodeMoveEnd(movedNode, { x: movedNode.x, y: movedNode.y }); if (typeof onNodeMoveEnd === 'function') onNodeMoveEnd(movedNode, { x: movedNode.x, y: movedNode.y });
renderEdges(); renderEdges();
} else if (wasMoved && movedGroup.length) {
if (typeof onGroupMoveEnd === 'function') {
onGroupMoveEnd(movedGroup.map((node) => ({ id: node.id, login: node.login, x: node.x, y: node.y })));
}
renderEdges();
} else if (wasMoved) { } else if (wasMoved) {
// после pan даём физике чуть устаканиться и уснуть // после pan даём физике чуть устаканиться и уснуть
wake(); wake();
@@ -1866,8 +1906,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
} }
// финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty) // финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty)
const finalX = node.isFocus ? 0 : node.tx; // Ручно сдвинутый текущий центр не должен прыгать обратно в (0,0) при setModel.
const finalY = node.isFocus ? 0 : node.ty; const finalX = node.tx;
const finalY = node.ty;
const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка) const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка)
const finalOp = node.targetOpacity; // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1) const finalOp = node.targetOpacity; // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1)
+6 -2
View File
@@ -320,11 +320,15 @@ function renderItem(item, activeTab, navigate) {
export function render({ navigate, chrome } = {}) { export function render({ navigate, chrome } = {}) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack notifications-screen'; screen.className = 'stack notifications-screen';
chrome?.setTopbar(createTopBar({ title: 'Уведомления' }));
const tabs = document.createElement('div'); const tabs = document.createElement('div');
tabs.className = 'notification-feed-tabs app-top-tabs'; tabs.className = 'notification-feed-tabs app-top-tabs';
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']]; const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
chrome?.setTopbar(createTopBar({
center: tabs,
className: 'notifications-topbar',
}));
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'stack notifications-list'; list.className = 'stack notifications-list';
let payloadCache = null; let payloadCache = null;
@@ -415,7 +419,7 @@ export function render({ navigate, chrome } = {}) {
observer?.disconnect(); observer?.disconnect();
Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer)); Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer));
}; };
screen.append(tabs,list); screen.append(list);
void load(); void load();
return screen; return screen;
} }
+2 -1
View File
@@ -84,7 +84,7 @@ export function render({ navigate, chrome }) {
iconNode: createOverflowDots(), iconNode: createOverflowDots(),
title: 'Меню профиля', title: 'Меню профиля',
ariaLabel: 'Меню профиля', ariaLabel: 'Меню профиля',
className: 'profile-head-menu-btn', className: 'profile-head-menu-btn topbar-overflow-action--raised',
menu: { menu: {
className: 'profile-head-menu', className: 'profile-head-menu',
minWidth: 250, minWidth: 250,
@@ -176,6 +176,7 @@ export function render({ navigate, chrome }) {
size: 'xl', size: 'xl',
className: 'user-profile-hero-avatar', className: 'user-profile-hero-avatar',
glow: shining, glow: shining,
official,
})); }));
status.textContent = ''; status.textContent = '';
+1
View File
@@ -250,6 +250,7 @@ export function render({ navigate, route, chrome }) {
size: 'xl', size: 'xl',
className: 'user-profile-hero-avatar', className: 'user-profile-hero-avatar',
glow: shining, glow: shining,
official,
})); }));
addMenu = body.querySelector('.user-profile-add-menu'); addMenu = body.querySelector('.user-profile-add-menu');
+2 -2
View File
@@ -1,7 +1,7 @@
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.'; const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js'; import { argon2idAsync } from '../vendor/noble/hashes-argon2-1.8.0.bundle.mjs';
import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from 'https://esm.sh/@noble/curves@1.8.1/ed25519'; import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from '../vendor/noble/curves-ed25519-1.8.1.bundle.mjs';
const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key'; const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key';
function getCryptoApi() { function getCryptoApi() {
@@ -12,7 +12,7 @@ import {
edwardsToMontgomeryPriv, edwardsToMontgomeryPriv,
edwardsToMontgomeryPub, edwardsToMontgomeryPub,
x25519, x25519,
} from 'https://esm.sh/@noble/curves@1.5.0/ed25519'; } from '../vendor/noble/curves-ed25519-1.5.0.bundle.mjs';
const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:'; const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:';
const PAIRING_HASH_PREFIX = 'sha256$'; const PAIRING_HASH_PREFIX = 'sha256$';
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
View File
@@ -269,3 +269,23 @@
pointer-events: none; pointer-events: none;
z-index: -1; z-index: -1;
} }
/* Единый знак официального пользователя. Левый и нижний края зафиксированы,
а сам знак растёт вправо и вверх вместе с аватаром. */
.avatar.avatar-image.avatar-framed > .avatar-official-badge {
position: absolute;
left: -2%;
bottom: -1%;
width: 32%;
height: 32%;
min-width: 18px;
min-height: 18px;
object-fit: contain;
object-position: center;
display: block;
opacity: 1;
border-radius: 0;
pointer-events: none;
z-index: 4;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42));
}
@@ -26,3 +26,12 @@
0 0 5px rgba(92, 190, 255, 0.72), 0 0 5px rgba(92, 190, 255, 0.72),
0 0 10px rgba(72, 145, 255, 0.34); 0 0 10px rgba(72, 145, 255, 0.34);
} }
/* Явный вариант для шапок, где overflow-кнопку нужно выровнять по эталонной
* позиции экрана «Связи». Поднимаем кнопку вместе с зоной нажатия и якорем
* выпадающего меню, не меняя базовый TopBar для остальных экранов. */
.topbar-overflow-action--raised {
position: relative;
top: -2px;
}
+8
View File
@@ -97,6 +97,14 @@
text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 12px var(--app-topbar-blue-glow-soft); text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 12px var(--app-topbar-blue-glow-soft);
} }
.topbar-slot .topbar__action {
min-width: 40px;
padding-block: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.topbar-slot .topbar__action svg, .topbar-slot .topbar__action svg,
.topbar-slot .topbar__back svg { .topbar-slot .topbar__back svg {
filter: drop-shadow(0 0 3px var(--app-topbar-blue-glow)) drop-shadow(0 0 7px var(--app-topbar-blue-glow-soft)); filter: drop-shadow(0 0 3px var(--app-topbar-blue-glow)) drop-shadow(0 0 7px var(--app-topbar-blue-glow-soft));
+28 -13
View File
@@ -186,14 +186,32 @@
/* Уведомления: переключатели лент повторяют стеклянные чипы экрана «Связи». */ /* Уведомления: переключатели лент занимают место заголовка в закреплённом
.notifications-screen .notification-feed-tabs { * TopBar и повторяют стеклянные чипы экрана «Связи». */
.notifications-topbar {
grid-template-columns: minmax(0, 1fr);
}
.notifications-topbar .topbar__left,
.notifications-topbar .topbar__right {
display: none;
}
.notifications-topbar .topbar__center {
width: 100%;
}
.notifications-topbar .notification-feed-tabs {
width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 8px; gap: 8px;
padding: 8px 12px 4px; padding: 0;
margin: 0; margin: 0;
background: transparent; background: transparent;
border: 0; border: 0;
@@ -201,25 +219,22 @@
} }
.notifications-screen .notification-feed-tabs .notification-tab-btn { .notifications-topbar .notification-feed-tabs .notification-tab-btn {
min-width: 92px; flex: 1 1 0;
min-width: 0;
padding-inline: 8px;
text-align: center; text-align: center;
} }
/* На обычных экранах отступ создаёт стандартный padding screen-content (14px). */ /* Высота панели остаётся стандартной: перенос фильтров не увеличивает TopBar. */
.notifications-screen .notification-feed-tabs.app-top-tabs { .notifications-topbar .notification-feed-tabs.app-top-tabs {
min-height: var(--app-primary-tab-min-height); min-height: var(--app-primary-tab-min-height);
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.notifications-screen .notification-feed-tabs .notification-tab-btn {
min-width: 92px;
}
/* Уведомления: информационные блоки без рамок. Hover/focus не возвращает /* Уведомления: информационные блоки без рамок. Hover/focus не возвращает
* обводку — различение сохраняется фоном и лёгкой реакцией на нажатие. */ * обводку — различение сохраняется фоном и лёгкой реакцией на нажатие. */
.notifications-screen .notifications-list > .notification-card, .notifications-screen .notifications-list > .notification-card,
+20 -2
View File
@@ -799,8 +799,26 @@
.user-profile-screen .user-profile-hero-avatar.avatar-glow::before { .user-profile-screen .user-profile-hero-avatar.avatar-glow::before {
inset: -12%; inset: -7%;
opacity: 0.5; border: 2px solid rgba(145, 239, 255, 0.94);
background: transparent;
box-shadow:
0 0 3px rgba(155, 244, 255, 0.96),
0 0 9px rgba(112, 222, 255, 0.74),
0 0 17px rgba(86, 190, 255, 0.38),
inset 0 0 4px rgba(176, 248, 255, 0.72);
filter: none;
transform: none;
opacity: 1;
}
/* На крупном аватаре профиля официальный знак на 20% компактнее базового.
* Левый и нижний края остаются на месте, поэтому сокращение приходится на
* верхнюю и правую стороны знака. */
.user-profile-screen .user-profile-hero-avatar.avatar-framed > .avatar-official-badge {
width: 25.6%;
height: 25.6%;
} }
+4 -4
View File
@@ -210,10 +210,10 @@
position: absolute; position: absolute;
left: -2%; left: -2%;
bottom: -1%; bottom: -1%;
width: 16%; width: 32%;
height: 16%; height: 32%;
min-width: 9px; min-width: 18px;
min-height: 9px; min-height: 18px;
object-fit: contain; object-fit: contain;
object-position: center; object-position: center;
display: block; display: block;