SHA256
Смержить production-структуру документации
# Conflicts: # VERSION.properties # shine-UI/js/pages/registration-payment-view.js # shine-UI/styles/components.css
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,183 +0,0 @@
|
||||
# Интерактивная карта связей (force-directed graph)
|
||||
|
||||
Экран **«Связи»** (`network-view`) — интерактивная нод-граф карта вместо статичного списка:
|
||||
фокусный пользователь в центре, связи на орбите, навигация тапом/свайпом, премиальные
|
||||
переходы в духе нативного iOS.
|
||||
|
||||
## Где код
|
||||
- `js/pages/network/force-graph.js` — **движок** (физика, рендер, жизненный цикл узлов, жесты).
|
||||
- `js/pages/network/adapter.js` — реальные данные → нейтральная модель движка.
|
||||
- `js/pages/network/node-menu.js` — общее контекстное меню узла.
|
||||
- `js/pages/network/lab.js` — лаборатория (`network-view/lab`) на мок-данных, без бэкенда.
|
||||
- `js/pages/network-view.js` — страница: шапка, поиск, фильтры, история, склейка с движком.
|
||||
- `js/mock-data.js` — `networkGraphUsers` (связанный мульти-граф из 10 человек для лаборатории).
|
||||
- `styles/network-graph.css` — все стили `.fg-*`.
|
||||
|
||||
## Данные (read-only, сервер не трогаем)
|
||||
Единый источник — `authService.getUserConnectionsGraph(login)` (один запрос: логин → прямые связи).
|
||||
`network-view.js` → `buildGraphModel()` нормализует роли (parent/child/sibling/spouse/friend/contact),
|
||||
направление и метки; `adapter.engineModelFromGraphModel()` превращает это в модель движка:
|
||||
`{ focusId, nodes:[{ id, login, name, avatar, relationType, strength, shining, tier }] }`.
|
||||
|
||||
## Модель движка и API
|
||||
`createForceGraph({ stage, model, onNodeTap, onCenterTap, onNodeLongPress })` →
|
||||
`{ setModel(model), setFilter(pred), recenter(id), getFocusNode(), destroy() }`.
|
||||
|
||||
## Ключевые механики
|
||||
- **Diffing-переходы (непрерывность состояний):** при смене фокуса общие узлы (тот же `id`) не
|
||||
пересоздаются, а перелетают на новые места; новые «расцветают» (bloom) каскадом из центра;
|
||||
исчезнувшие уходят в Ghost-слой.
|
||||
- **CSS-bloom (разлёт без тряски):** разлёт/перелёт узлов делают нативные CSS-переходы на
|
||||
`transform` (компоновщик, `cubic-bezier(0.16,1,0.3,1)`, `BLOOM_MS` со ступенчатой задержкой
|
||||
`order × 40мс`), а НЕ JS-физика. Работает даже при троттлинге rAF; цикл лишь ведёт лучи за узлами
|
||||
(`syncPositionsFromDOM`). Завершение — гарантированно по таймеру (`endCssBloom`).
|
||||
- **Ghost-слой:** снимок только **аватарок** старого графа (без линий — иначе старые связи висят
|
||||
«ошмётками»). Полноэкранный overlay, застывает на месте, `scale 1→0.7` + `opacity 0.5→0` за
|
||||
**1000мс**, затем удаляется (мягкий породистый шлейф истории).
|
||||
- **Прорастание линий (Edge Growth):** новая линия тянется к ФИНАЛЬНОЙ точке узла и раскрывается
|
||||
`stroke-dasharray`(=длина пути) + `stroke-dashoffset`(длина→0), синхронно с разлётом узла
|
||||
(`growP = текущая дистанция / финальная`) → кончик «вытягивается» из центра вслед за аватаркой.
|
||||
Старые линии при этом исчезают мгновенно. Только для новых узлов; переезжающие — линия следует за ними.
|
||||
- **Физика (только до-settle):** после CSS-разлёта — лёгкая радиальная пружина + отталкивание для
|
||||
органичного покачивания; после фильтра физика НЕ включается (фиксация на равномерных углах).
|
||||
- **Жёсткая заморозка (kill-switch):** когда сумма |vx|+|vy| < 0.03 — скорости обнуляются, координаты
|
||||
округляются, `cancelAnimationFrame` (sleep). Нет «треска», батарея не страдает.
|
||||
- **Обычные линии:** SVG `<path> Q` (квадратичные Безье) — тонкие матовые дуги (`stroke-width ~1.0–1.2`),
|
||||
градиент с глубоким уходом в прозрачность: неон-центр `0.42` → цвет роли `0.07` у аватарки (растворяются
|
||||
в фоне, не спорят с сияющими). Изгиб реагирует на скорость.
|
||||
- **Сияющие связи (двухслойный «световод», Neon Layering):** два пути на одну связь — широкий размытый
|
||||
GLOW (`stroke-width 4`, неон, `filter: blur(2px)`, `opacity 0.4`) + тонкий чёткий CORE (`1.5px`,
|
||||
`#e0f7fc`). Линия остаётся изящной, но обретает объёмное OLED-свечение; растут оба синхронно (общий
|
||||
dashoffset). Никаких бегущих импульсов.
|
||||
- **Жесты:** свайп-pan с инерцией (новое касание прерывает); короткий тап — центрирование + нижний
|
||||
сниппет; долгий тап — контекстное меню (в `#modal-root`, позиция по `getBoundingClientRect`); тап
|
||||
по центру — профиль. Нажатия на чипы фильтров гасят `pointerdown` (stopPropagation), чтобы сцена не
|
||||
перехватила указатель (`setPointerCapture`) и не «съела» click кнопки.
|
||||
- **Фильтры слоёв (Все / Семья / Друзья / Сияющие):** CSS-переходы 300мс — несоответствующие узлы и их
|
||||
линии гаснут НА МЕСТЕ (`opacity 0` + `scale 0.8`), оставшиеся плавно переплывают на равномерные углы,
|
||||
затем жёсткая фиксация без физики (ноль тряски, мгновенный sleep).
|
||||
- **Живой фон (Nebula):** под центром — глубокое размытое сине-голубое облако (`.fg-stage::before`,
|
||||
`blur 80→96px`), бесконечная анимация 7с: «дышит» радиусом/яркостью и переливается индиго↔ультрамарин
|
||||
(`hue-rotate`). На компоновщике (GPU), не будит rAF; подписи имён остаются контрастными.
|
||||
- **Стеклянные чипы фильтров (frosted glass):** `background: rgba(255,255,255,0.03)`,
|
||||
`backdrop-filter: blur(12px)`, граница `0.5px solid rgba(255,255,255,0.1)`; активный — подсвечен сине-голубым.
|
||||
- **Поллиш:** «дыхание» фокуса (бесконечная CSS-анимация
|
||||
размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной
|
||||
`box-shadow` + размытый ореол через SVG-фильтр `#fg-shine-glow`; тестовые фото-аватарки (`NETWORK_PHOTOS`);
|
||||
хард-лимит ~90 DOM-аватарок (остальное — SVG-точки).
|
||||
|
||||
## Параметры тюнинга (константы в начале `force-graph.js`)
|
||||
| Константа | Значение | Назначение |
|
||||
|---|---|---|
|
||||
| `ORBIT_MIN / ORBIT_MAX` | 150 / 240 | радиус орбиты (защитный отступ от центра — подписи не наезжают) |
|
||||
| `K_RADIAL` | 0.035 | жёсткость орбитальной пружины (мягко) |
|
||||
| `K_FOCUS` | 0.12 | жёсткость пружины фокуса к центру |
|
||||
| `CHARGE` / `CHARGE_START_FACTOR` | 1400 / 0.45 | отталкивание (на старте ослаблено) |
|
||||
| `FRICTION` / `FRICTION_BOOST` / `BOOST_FRAMES` | 0.80 / 0.94 / 42 | базовое трение / стартовая вязкость / длительность (~700мс) |
|
||||
| `BLOOM_MS` / `BLOOM_STAGGER` | 900 / 40 | длительность CSS-разлёта / задержка между узлами (каскад) |
|
||||
| `SLEEP_V` | 0.03 | порог суммарной |v| для заморозки |
|
||||
| `FOCUS_SCALE` | 1.5 | базовый масштаб фокуса |
|
||||
| `MAX_FULL_NODES` | 90 | хард-лимит полных аватарок (далее — точки) |
|
||||
|
||||
Прочее (вшито в код): Ghost-слой — 1000мс; CSS-переход фильтра — 300мс; пульсация сияния — 3.6с;
|
||||
прорастание линий привязано к прогрессу разлёта узла (а не к отдельному таймеру).
|
||||
|
||||
## Локальный запуск / проверка
|
||||
- Dev-сервер: `.claude/shine-ui-dev-server.cjs` (Node, порт 7321, SPA-fallback + инжект `<base href="/">`).
|
||||
- Лаборатория (без бэкенда): `http://localhost:7321/network-view/lab` — мок `networkGraphUsers`,
|
||||
тап по узлам переключает сети.
|
||||
- Реальный путь (`/network-view`) требует живого `wss://shineup.me/ws` (локально — `ws_open_error`, это норма).
|
||||
|
||||
## Режим «Интерактивная паутина» (ветка `pixel-web`, эксперимент, только лаборатория)
|
||||
Включается чипом «🌌 Вселенная». Дальние уровни (2-3) по умолчанию скрыты и раскрываются локально:
|
||||
- **Hover-превью (наведение):** навёл мышь/палец на узел — его ветка временно выплывает; убрал —
|
||||
втягивается. Реализация: `pointerover/out` (мышь) и `pointerdown/up` (палец) → `onNodeHover` →
|
||||
`graph.setHover(node|null)`; узел получает флаг `hovered`.
|
||||
- **Фиксация кликом (pin):** тап/клик по узлу → `graph.toggleExpand` ставит флаг `pinned` — ветка
|
||||
остаётся раскрытой и после ухода курсора. Повторный клик по раскрытому узлу **сворачивает** его
|
||||
(надёжный toggle: `isOpen = pinned || expandP>0.5` → сброс `pinned`+`hovered`).
|
||||
- Эффективное раскрытие = `pinned || hovered` (см. `expandTargetOf`), прогресс `expandP` (~400мс).
|
||||
- **Spotlight-затемнение:** пока есть закреплённая ветка, остальные тускнеют до `SPOTLIGHT_DIM=0.25`
|
||||
(узлы и их линии), фокус и закреплённая/наведённая ветка — 100%. Плавно через `spotCur` (lerp).
|
||||
- **Узлы 2-го уровня — полноценные аватарки:** фото-лицо (pravatar) + имя, `DEEP2_SCALE=0.62`
|
||||
(≈radius 16px), `DEEP2_OPACITY=0.85`. Не «пустые кружки», а видимые друзья друзей.
|
||||
- **Глобальный сброс:** тап по корню (Иван) → `collapseAll()` снимает `pinned`/`hovered` → 100% яркость.
|
||||
- **Адаптивное расталкивание (collision):** раскрытая ветка усиливает отталкивание соседних узлов
|
||||
1-го уровня пропорционально `expandP` (`EXPAND_REPULSION=2.4`) — кластеры разъезжаются, не накладываясь.
|
||||
- **Камера-доводчик:** при фиксации ветки, если её «веер» упирается в край экрана, камера мягко
|
||||
дотягивается (`glideCameraTo` → `camTargetX/Y`, lerp `CAM_GLIDE_K` в tick). Любой жест отменяет доводчик.
|
||||
- **Свободный зум:** колесо мыши (`onWheel`) и щипок двумя пальцами (`activePointers`/`pinching`) —
|
||||
масштаб `zoom` (0.55–2.6), «к точке» под курсором/центром щипка; мир масштабируется CSS-`scale`,
|
||||
линии (отдельный SVG) пересчитываются в экранных координатах (× `zoom`).
|
||||
- **Синхро-пульс линий:** сияющие/трековые «световоды» (`.fg-edge-glow`/`.fg-edge-core`) «дышат»
|
||||
толщиной/размытием 3.6с — в такт ободку сияющего узла (в покое SVG не перерисовывается → синхронно).
|
||||
- Мерцающие микрозвёзды 3-го уровня (`fg-star-twinkle`), хаптика (`navigator.vibrate`) на нажатие/раскрытие/натяжение.
|
||||
|
||||
### Умный фокус (Smart Zoom / «аквариум») — ветка `pixel-aquarium`
|
||||
**Наведение** (hover/палец) на узел — лёгкое превью ветки (раскрытие на месте, без камеры).
|
||||
**Клик/тап по ЛЮБОМУ узлу** — **погружение (dive)** с кинематографичным наездом:
|
||||
- **Камера-полёт + зум** (`diveTo` → `diveTargetId`/`diveZoom=1.7`, лёт в `tick` с `DIVE_FLY_K` ≈600мс):
|
||||
узел плавно центрируется (offset ~0) и **вырастает до единого видимого размера** `HERO_VISUAL=1.4`
|
||||
независимо от уровня (`depthScale = HERO_VISUAL / baseScaleOf`); его прямые дети — до `DIVE_CHILD_VISUAL`.
|
||||
- **Адаптивный радиус орбиты (фикс слипания):** дети раскладываются на кольце
|
||||
`ringR = max(baseR + радиус_родителя, число_детей × 13)` — НЕ лезут на (увеличенный зумом) родитель
|
||||
и друг на друга (проверено: мин. дистанция 125px, 0 наложений). Радиус растёт вместе с зумом родителя.
|
||||
- **Глубина «аквариума»** (`contextTargetOf` → `depthScale`/`depthBlur`/`spotCur`, лерп): Иван и боковые
|
||||
ветки **уменьшаются** (root ×0.55, фон ×0.55) + уходят в **blur 3px** + тускнеют до 0.25 → задний план.
|
||||
- **Железный Spotlight (единый активный путь):** `diveTo` сначала гасит ВСЕ прежние pin/hover, затем
|
||||
раскрывает только путь к новой цели. Открыто → путь Иван→…→узел = 1.0, остальное = 0.25; переключение
|
||||
веток сбрасывает прежнюю; **выход/`exitDive`/тап по Ивану → ВСЕ узлы гарантированно 1.0 + камера отъезжает**.
|
||||
- **Нить-крошка**: путь (`divePathSet`/`onPath`) горит ярким «световодом» — виден путь назад к Ивану.
|
||||
- **Pinch-to-Zoom + LOD**: щипок/колесо меняют `zoom`; при `zoom ≥ LOD_ZOOM (1.55)` видимые точки 3-го
|
||||
уровня **дорисовываются как аватарки** (`updateLod`/`setNodeLod`), при отдалении — обратно в точки.
|
||||
- Глубина — фейк-3D через масштаб + CSS-`blur` (GPU), без WebGL.
|
||||
|
||||
**Полиш (партия 1):** веер детей раскрывается **полукругом «наружу»** (от пути назад, `DEEP_FAN`,
|
||||
по `sibIndex`) — не перекрывает нить-крошку; **LOD с гистерезисом** (`LOD_ZOOM_UP=1.6`/`DOWN=1.4` — без
|
||||
мигания у порога); **двойной тап по фону** и **сильный pinch-out на мин. зуме** = быстрый выход;
|
||||
**префетч аватарок** детей при наведении/нырке.
|
||||
|
||||
**Фишки (партия 2, лаборатория):**
|
||||
- **Поиск + телепорт** — строка `.fg-search`; Enter → `graph.findNode(имя)` → камера летит к узлу (dive в
|
||||
«Вселенной», иначе перецентр).
|
||||
- **Хлебные крошки** — `.fg-breadcrumb` «Иван › Нина › Ада» (движок шлёт `onDiveChange(path)`,
|
||||
API `getDivePath()`); клик по корню — полный сброс, по предку — навигация на тот уровень.
|
||||
- **Бейдж числа связей** — `.fg-node-badge` (число из `degreeById`, обновляется в `updateBadges`).
|
||||
- **Цветовые кластеры** — мягкая аура узла по типу связи (CSS `is-family/friend/business/contact`).
|
||||
|
||||
**Линии-«жгуты» (партия 4, по референсу — плазменный композитинг):**
|
||||
- **Сияющие** — ОДИН центральный S-путь (cubic Bézier) + ТРИ наложенных слоя с ОДИНАКОВЫМ `d` (объём
|
||||
из толщины+размытия, НЕ из геометрии — никаких расходящихся линий):
|
||||
Настоящий НЕОН (видимый ореол вокруг яркого ядра; поле/трубка в `mix-blend-mode: screen` — свет
|
||||
складывается аддитивно с тёмным фоном, а в пересечениях у центра ярче — энергохаб):
|
||||
- `.fg-plasma-flare` — плазменное облако: 16px, `#00bfff`, opacity 0.42, **`feGaussianBlur` stdDev=6**, screen (+ «дыхание» 3.6с);
|
||||
- `.fg-plasma-tube` — направляющий свет: 6px, `#00e5ff`, opacity 0.85, **`feGaussianBlur` stdDev=2**, screen;
|
||||
- `.fg-plasma-core` — ядро: 2px, `#dffaff` (светло-голубо-белое), opacity 1, без размытия.
|
||||
Толщина/насыщенность подогнаны под референс (толстая яркая голубая плазма, гладкие края).
|
||||
S-волна спокойная/изящная (amp до 13px). Размытие — именно SVG-фильтры (`#fg-plasma-blur6/2`), т.к.
|
||||
CSS-`filter` на `<path>` в части мобильных WebView не применяется (отсюда был «плоский»/«канатный» вид).
|
||||
⚠️ Это НЕ Canvas-движок (не библиотека force-graph): связи — реальные SVG `<path>`, фильтры применяются.
|
||||
Прозрачность слоёв inline (× spotlight/глубину). Тяжёлый blur только у сияющих (их мало) — перф.
|
||||
- **Не-сияущие** — мягкое свечение **в цвете связи** (семья/друзья/бизнес/контакт): широкая
|
||||
полупрозрачная подложка + тонкое ядро, без SVG-blur (дёшево). «Похоже, но тише».
|
||||
|
||||
**Фишки (партия 3, лаборатория):**
|
||||
- **Общие связи** — среди друзей человека один помечен как «общий» (он и твой друг тоже): золотой
|
||||
ободок + ★ (CSS `is-common`; в лаб-генерации `addDeepLevels` подставляет узнаваемого друга Ивана).
|
||||
- **Доступность** — визуально скрытый (`sr-only`) текстовый список графа `.fg-a11y` (центр + связи
|
||||
1-го уровня) для скринридеров; обновляется в `updateA11y` при перестроении. Полезно и для реального пути.
|
||||
|
||||
**Автопроверки (`?fgtest`):** `js/pages/network/selftest.js` автозапускается в лаборатории при `?fgtest`,
|
||||
прогоняет 17 ассертов (центровка/collision/полукруг/spotlight/переключение/LOD/поиск/крошки/бейдж/выход) через
|
||||
детерминированные dev-хелперы движка `graph.debugState()` и `graph.pumpForTest()` (синхронно докручивают кадры
|
||||
до покоя — не зависят от троттлинга rAF). Результат → консоль и `window.__fgTestResults`. В обычной работе не активны.
|
||||
|
||||
> ⚠️ Эксперименты на ветках `pixel-web` (паутина) и `pixel-aquarium` (Smart Zoom) — для отката.
|
||||
> Реальный путь `/network-view` не затронут: deep-код под `tier ≥ 2` / `hasDeep`, dive — только tier≥2
|
||||
> (в реальном пути их нет), depthScale/Blur по умолчанию нейтральны, `updateLod` выходит при `!hasDeep`.
|
||||
|
||||
## Ограничения / на будущее
|
||||
- Многоуровневая глубина (друзья друзей мельче, 3-й уровень — точки), кластеры, «общие связи»
|
||||
упираются в API (отдаёт только прямые связи) — требуют доработки сервера.
|
||||
- Превью в простое троттлит `requestAnimationFrame` (физика не идёт между вызовами) — для замеров
|
||||
прокачивать кадры; в активном табе всё работает на 60 FPS.
|
||||
@@ -1,74 +0,0 @@
|
||||
# Личные сообщения (messages-list) — дизайн v2
|
||||
|
||||
Экран ЛС — **списочная форма экрана «Связи»**: тип отношения читается через цвет
|
||||
обода/ауры аватара и один правый статус. Тёмный космический фон + золотой header.
|
||||
|
||||
> Reference source: owner-approved chat visual reference; image asset is not yet stored in repository.
|
||||
|
||||
## Источник данных
|
||||
- **Demo/lab:** мок `js/mock-data.js` → `directMessages` (семантические поля, цвет не хранится).
|
||||
- **Прод:** те же поля придут из реальных relations (`relationFlagsForTarget` / `shineConfirmed` / `shine`).
|
||||
- **Маршруты:**
|
||||
- `/messages-list` — защищённый (требует сессии).
|
||||
- `/messages-list/lab` — гость-демо (мок, без сети/WS, пригоден для скриншотов).
|
||||
|
||||
## Семантика → визуал
|
||||
Решает **только** `js/pages/messages/dm-visual-resolver.js`. В данных цвет НЕ хранится.
|
||||
|
||||
Поля сообщения: `relationType` (contact|friend|family), `relationRole`, `isShining`,
|
||||
`isConfirmed`, `hasActiveLink`, `unreadCount`, `preview`. (`toneOverride` — только для теста.)
|
||||
|
||||
### Цвета (значение)
|
||||
| Цвет | Токен | Значение |
|
||||
|------|-------|----------|
|
||||
| violet | `--rel-contact` `#8C63FF` | обычный контакт (дефолт) |
|
||||
| gold | `--rel-family` `#F0B82E` | семья / близкий круг / важная связь |
|
||||
| celestial | `--rel-shining` `#68D8FF` | сияющий |
|
||||
| emerald | `--rel-link` `#19E58A` | ТОЛЬКО активный статус «Связь» |
|
||||
|
||||
Обод аватара: `isShining → celestial; иначе family → gold; иначе → violet`.
|
||||
**«Подтверждён» НЕ красит обод золотым** (золото = семья; подтверждение — правый статус).
|
||||
|
||||
### Приоритет правого статуса
|
||||
`hasActiveLink → «Связь» (emerald)` > `isConfirmed → «Подтверждён» (gold shield)` > ничего.
|
||||
На карточке максимум ОДИН главный статус.
|
||||
|
||||
### Unread
|
||||
Отдельная **violet/cool сфера** (НЕ изумруд). Только при `>0`; `1–99`, далее `99+`.
|
||||
Идёт после статуса, перед chevron.
|
||||
|
||||
## Матрица состояний (demo-мок покрывает все)
|
||||
| # | relationType | shining | confirmed | link | unread | Обод | Правый статус | Бейдж |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| M01 | contact | – | ✓ | – | 0 | violet | 🛡 Подтверждён | – |
|
||||
| M02 | contact | – | – | ✓ | 2 | violet | 🔗 Связь | 2 |
|
||||
| M03 | contact | ✓ | – | ✓ | 5 | celestial | 🔗 Связь | 5 |
|
||||
| M04 | contact | – | – | – | 0 | violet | — | – |
|
||||
| M05 | family | – | ✓ | – | 0 | gold | 🛡 Подтверждён | – |
|
||||
| M06 | family | – | ✓ | ✓ | 1 | gold | 🔗 Связь (приоритет link>confirmed) | 1 |
|
||||
|
||||
## Размеры
|
||||
- Карточка: `min-height 92px`, `radius 26px`.
|
||||
- Зазор списка: `8px` (flex-column).
|
||||
- Аватар-обод `.dm-av`: `56px` (фото/инициалы — 50px внутри).
|
||||
- Капсула «Связь»: высота `32px`, radius `16px`, изумрудный бордер, почти прозрачный fill.
|
||||
- Header: grid `1fr auto 1fr` (бренд слева / title строго по центру / «+» справа), title `18px`.
|
||||
|
||||
## Сияющая сфера — связь с «Связями» (обязательно)
|
||||
DM-сияние НЕ изобретает свой эффект, а **повторяет язык сияющего узла графа**:
|
||||
- те же общие keyframes из `styles/network-graph.css`: `fg-shine-glow` (пульс box-shadow)
|
||||
+ `fg-shine-halo` (дыхание ореола: scale/opacity);
|
||||
- та же небесная палитра и тот же rim `rgba(150,240,255,0.62)`;
|
||||
- тот же радиальный ореол (те же стопы градиента), `inset: -12px` — как у узла графа
|
||||
(узел 58px ↔ аватар 56px, scale ≈ 1, отдельный масштабный коэффициент не нужен);
|
||||
- `filter: blur(3.4px)` ≡ `feGaussianBlur stdDeviation="3.4"` SVG-фильтра `#fg-shine-glow`
|
||||
графа. CSS-blur используется потому, что SVG-фильтр объявлен только на странице «Связи»;
|
||||
- `prefers-reduced-motion` → анимации выключаются.
|
||||
|
||||
Разрешён только controlled scale factor; никаких отдельных hardcoded-параметров,
|
||||
если они уже существуют в визуальном языке «Связей».
|
||||
|
||||
## Фон
|
||||
Фон `.dm-screen` (`#05070A` + орбы `dm-orbs-drift`) — утверждённая база, **НЕ меняется**.
|
||||
Все эффекты редизайна ограничены `.dm-*` (карточка, обод аватара, статус, бейдж, header, «+»).
|
||||
Критерий: если скрыть карточки/аватары/header/nav/статусы — фон остаётся прежним.
|
||||
@@ -72,6 +72,7 @@ import * as languageView from './pages/language-view.js';
|
||||
import * as appLogView from './pages/app-log-view.js';
|
||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
||||
import * as messagesList from './pages/messages-list.js';
|
||||
import * as contactSearchView from './pages/contact-search-view.js';
|
||||
import * as chatView from './pages/chat-view.js?v=202607152145';
|
||||
@@ -132,6 +133,7 @@ const routes = {
|
||||
'app-log-view': appLogView,
|
||||
'pwa-diagnostics-view': pwaDiagnosticsView,
|
||||
'solana-users-init-view': solanaUsersInitView,
|
||||
'solana-rpc-check-view': solanaRpcCheckView,
|
||||
'messages-list': messagesList,
|
||||
'contact-search-view': contactSearchView,
|
||||
'chat-view': chatView,
|
||||
|
||||
@@ -714,7 +714,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
});
|
||||
|
||||
// Репосты временно отключены до будущей реализации.
|
||||
// Точка возврата: Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
actions.append(likeButton, replyButton, shareButton);
|
||||
if (repostTarget) {
|
||||
const originalButton = document.createElement('button');
|
||||
|
||||
@@ -1004,7 +1004,7 @@ function renderPostCard(post, {
|
||||
});
|
||||
});
|
||||
// Репосты временно отключены до будущей реализации.
|
||||
// Точка возврата: Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
actions.append(likeButton, replyButton);
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
|
||||
@@ -264,6 +264,7 @@ export function render({ navigate }) {
|
||||
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||
<button class="text-btn" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||
<button class="text-btn" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
||||
<button class="text-btn" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
||||
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
|
||||
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||
<button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</button>
|
||||
@@ -278,9 +279,11 @@ export function render({ navigate }) {
|
||||
const forceUpdateHelpBtn = card.querySelector('#settings-force-update-help');
|
||||
const uiErrorReportingBtn = card.querySelector('#settings-ui-error-reporting');
|
||||
const solanaUsersInitBtn = card.querySelector('#settings-solana-users-init');
|
||||
const solanaRpcCheckBtn = card.querySelector('#settings-solana-rpc-check');
|
||||
|
||||
appLogBtn?.addEventListener('click', () => navigate('app-log-view'));
|
||||
solanaUsersInitBtn?.addEventListener('click', () => navigate('solana-users-init-view'));
|
||||
solanaRpcCheckBtn?.addEventListener('click', () => navigate('solana-rpc-check-view'));
|
||||
diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view'));
|
||||
uploadAvatarBtn?.addEventListener('click', () => {
|
||||
openDeveloperAvatarUploadModal({
|
||||
|
||||
@@ -162,6 +162,9 @@ export function render({ navigate }) {
|
||||
state.registrationDraft.sessionId = '';
|
||||
state.registrationDraft.pendingKeyBundle = null;
|
||||
state.registrationDraft.pendingSessionMaterial = null;
|
||||
state.registrationDraft.preGeneratedKeyBundle = null;
|
||||
state.registrationPayment.walletAddress = '';
|
||||
state.registrationPayment.balanceSOL = '0.0000';
|
||||
|
||||
await refreshSessions();
|
||||
setAuthInfo(isLoginFlow
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
|
||||
export const pageMeta = { id: 'solana-rpc-check-view', title: 'Проверка Solana RPC' };
|
||||
|
||||
const MAINNET_RPC_ENDPOINTS = [
|
||||
{ name: 'GetBlock', url: 'https://go.getblock.us/86aac42ad4484f3c813079afc201451c' },
|
||||
{ name: 'PublicNode', url: 'https://solana-rpc.publicnode.com' },
|
||||
{ name: 'BlockEden', url: 'https://api.blockeden.xyz/solana/KeCh6p22EX5AeRHxMSmc' },
|
||||
{ name: 'dRPC', url: 'https://solana.drpc.org/' },
|
||||
{ name: 'Grove', url: 'https://solana.rpc.grove.city/v1/01fdb492' },
|
||||
{ name: 'LeoRPC', url: 'https://solana.leorpc.com/?api_key=FREE' },
|
||||
{ name: 'OnFinality', url: 'https://solana.api.onfinality.io/public' },
|
||||
{ name: 'Solana Foundation', url: 'https://api.mainnet-beta.solana.com' },
|
||||
{ name: 'Solana Vibe Station', url: 'https://public.rpc.solanavibestation.com/' },
|
||||
{ name: 'TheRPC', url: 'https://solana.therpc.io' },
|
||||
];
|
||||
|
||||
const RPC_PAYLOAD = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getVersion',
|
||||
params: [],
|
||||
};
|
||||
|
||||
function classifyResult(result) {
|
||||
if (!result) return { label: 'Нет данных', tone: 'neutral' };
|
||||
if (result.ok) return { label: 'Работает в браузере', tone: 'ok' };
|
||||
if (result.httpStatus === 402) return { label: 'Требует платный план', tone: 'bad' };
|
||||
if (result.httpStatus === 403) return { label: 'Доступ запрещён', tone: 'bad' };
|
||||
if (result.httpStatus === 429) return { label: 'Упёрлась в rate limit', tone: 'warn' };
|
||||
if (result.httpStatus >= 500) return { label: 'Ошибка на стороне RPC', tone: 'bad' };
|
||||
if (result.errorCode === 35) return { label: 'Free tier не даёт mainnet', tone: 'bad' };
|
||||
if (result.networkError) return { label: 'Не проходит из браузера', tone: 'bad' };
|
||||
return { label: 'Отвечает, но не подходит', tone: 'warn' };
|
||||
}
|
||||
|
||||
function shortenText(value, max = 140) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return '—';
|
||||
return text.length > max ? `${text.slice(0, max)}...` : text;
|
||||
}
|
||||
|
||||
async function testEndpoint(url, timeoutMs = 12000) {
|
||||
const startedAt = performance.now();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(RPC_PAYLOAD),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const elapsedMs = Math.round(performance.now() - startedAt);
|
||||
let data = null;
|
||||
let raw = '';
|
||||
try {
|
||||
raw = await response.text();
|
||||
data = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
return {
|
||||
url,
|
||||
ok: response.ok && !!data?.result,
|
||||
httpStatus: response.status,
|
||||
elapsedMs,
|
||||
result: data?.result || null,
|
||||
errorCode: data?.error?.code ?? null,
|
||||
errorMessage: data?.error?.message || '',
|
||||
raw: shortenText(raw, 180),
|
||||
networkError: '',
|
||||
};
|
||||
} catch (error) {
|
||||
const elapsedMs = Math.round(performance.now() - startedAt);
|
||||
return {
|
||||
url,
|
||||
ok: false,
|
||||
httpStatus: 0,
|
||||
elapsedMs,
|
||||
result: null,
|
||||
errorCode: null,
|
||||
errorMessage: '',
|
||||
raw: '',
|
||||
networkError: error?.name === 'AbortError' ? 'Timeout' : (error?.message || 'Network error'),
|
||||
};
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function makeResultCard(endpoint) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack solana-rpc-card';
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'solana-rpc-card-head';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = endpoint.name;
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge alt solana-rpc-badge';
|
||||
badge.textContent = 'Ожидает проверки';
|
||||
|
||||
head.append(title, badge);
|
||||
|
||||
const endpointLine = document.createElement('a');
|
||||
endpointLine.className = 'solana-rpc-url';
|
||||
endpointLine.href = endpoint.url;
|
||||
endpointLine.target = '_blank';
|
||||
endpointLine.rel = 'noopener noreferrer';
|
||||
endpointLine.textContent = endpoint.url;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'solana-rpc-meta';
|
||||
|
||||
const statusLine = document.createElement('div');
|
||||
statusLine.className = 'meta-muted';
|
||||
statusLine.textContent = 'Ещё не проверяли.';
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'meta-muted solana-rpc-details';
|
||||
details.textContent = '—';
|
||||
|
||||
meta.append(statusLine, details);
|
||||
card.append(head, endpointLine, meta);
|
||||
|
||||
return { card, badge, statusLine, details };
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const intro = document.createElement('div');
|
||||
intro.className = 'card stack';
|
||||
intro.innerHTML = `
|
||||
<strong>Проверка mainnet RPC из браузера</strong>
|
||||
<p class="meta-muted">
|
||||
Этот экран делает реальный browser fetch к публичным Solana mainnet RPC.
|
||||
Если ответ приходит прямо здесь, значит нода годится для клиентского UI без промежуточного сервера.
|
||||
</p>
|
||||
`;
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'auth-footer-actions';
|
||||
|
||||
const runBtn = document.createElement('button');
|
||||
runBtn.className = 'primary-btn';
|
||||
runBtn.type = 'button';
|
||||
runBtn.textContent = 'Проверить все ноды';
|
||||
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.className = 'ghost-btn';
|
||||
resetBtn.type = 'button';
|
||||
resetBtn.textContent = 'Сбросить';
|
||||
|
||||
controls.append(runBtn, resetBtn);
|
||||
intro.append(controls);
|
||||
|
||||
const summary = document.createElement('p');
|
||||
summary.className = 'status-line';
|
||||
summary.textContent = 'Пока проверки не запускались.';
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'stack';
|
||||
|
||||
const cards = MAINNET_RPC_ENDPOINTS.map((endpoint) => {
|
||||
const ui = makeResultCard(endpoint);
|
||||
grid.append(ui.card);
|
||||
return { endpoint, ui };
|
||||
});
|
||||
|
||||
let runToken = 0;
|
||||
|
||||
const resetState = () => {
|
||||
runToken += 1;
|
||||
summary.className = 'status-line';
|
||||
summary.textContent = 'Пока проверки не запускались.';
|
||||
cards.forEach(({ ui }) => {
|
||||
ui.badge.className = 'badge alt solana-rpc-badge';
|
||||
ui.badge.textContent = 'Ожидает проверки';
|
||||
ui.statusLine.textContent = 'Ещё не проверяли.';
|
||||
ui.details.textContent = '—';
|
||||
});
|
||||
};
|
||||
|
||||
const runChecks = async () => {
|
||||
const token = ++runToken;
|
||||
runBtn.disabled = true;
|
||||
resetBtn.disabled = true;
|
||||
summary.className = 'status-line';
|
||||
summary.textContent = 'Проверяем mainnet RPC из браузера...';
|
||||
|
||||
let okCount = 0;
|
||||
let warnCount = 0;
|
||||
let badCount = 0;
|
||||
|
||||
const tasks = cards.map(async ({ endpoint, ui }) => {
|
||||
ui.badge.className = 'badge alt solana-rpc-badge';
|
||||
ui.badge.textContent = 'Проверяем...';
|
||||
ui.statusLine.textContent = 'Отправляем JSON-RPC запрос getVersion...';
|
||||
ui.details.textContent = 'Ждём ответ.';
|
||||
|
||||
const result = await testEndpoint(endpoint.url);
|
||||
if (token !== runToken) return;
|
||||
|
||||
const verdict = classifyResult(result);
|
||||
if (verdict.tone === 'ok') okCount += 1;
|
||||
else if (verdict.tone === 'warn') warnCount += 1;
|
||||
else if (verdict.tone === 'bad') badCount += 1;
|
||||
|
||||
ui.badge.className = `badge solana-rpc-badge is-${verdict.tone}`;
|
||||
ui.badge.textContent = verdict.label;
|
||||
ui.statusLine.textContent = `HTTP ${result.httpStatus || 'ERR'} • ${result.elapsedMs} мс`;
|
||||
if (result.ok) {
|
||||
ui.details.textContent = `solana-core: ${result.result?.['solana-core'] || 'unknown'} • feature-set: ${result.result?.['feature-set'] || 'unknown'}`;
|
||||
} else if (result.networkError) {
|
||||
ui.details.textContent = `Browser error: ${result.networkError}`;
|
||||
} else if (result.errorMessage) {
|
||||
ui.details.textContent = shortenText(result.errorMessage, 180);
|
||||
} else {
|
||||
ui.details.textContent = result.raw || 'Нода ответила без пригодного JSON-RPC result.';
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(tasks);
|
||||
if (token !== runToken) return;
|
||||
|
||||
summary.className = 'status-line is-available';
|
||||
summary.textContent = `Готово. Для браузера подходят: ${okCount}. С оговорками: ${warnCount}. Не подходят: ${badCount}.`;
|
||||
runBtn.disabled = false;
|
||||
resetBtn.disabled = false;
|
||||
};
|
||||
|
||||
runBtn.addEventListener('click', () => {
|
||||
void runChecks();
|
||||
});
|
||||
resetBtn.addEventListener('click', resetState);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Проверка Solana RPC',
|
||||
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
}),
|
||||
intro,
|
||||
summary,
|
||||
grid,
|
||||
);
|
||||
|
||||
void runChecks();
|
||||
|
||||
return screen;
|
||||
}
|
||||
Reference in New Issue
Block a user