diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d517f8..c899fe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: - name: Type-check run: npm run typecheck + - name: Unit tests + run: npm test + - name: Test Rust core run: cargo test --manifest-path wasm/board-core/Cargo.toml @@ -53,3 +56,40 @@ jobs: - name: Run end-to-end tests run: npx playwright test + + # The e2e suite is red on `main` already (run 32238922648, same step), so a + # failing check on a PR carries no information on its own. Publish which + # tests failed — as a job summary readable without downloading logs, and + # as the HTML report artifact for local inspection. + - name: Summarize end-to-end results + if: always() + run: | + { + echo '### Playwright results' + echo '```' + node scripts/summarize-playwright-report.mjs playwright-report + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + # Run it again for the workflow commands: annotations are only parsed + # from a step's stdout, and they are the one CI channel readable + # without access to the log/artifact blob storage. + node scripts/summarize-playwright-report.mjs playwright-report --annotations + + # GitHub keeps 10 annotations per level per step, so a red suite with more + # than 10 failures needs a second step to stay fully visible. + - name: Annotate remaining end-to-end failures + if: always() + run: node scripts/summarize-playwright-report.mjs playwright-report --annotations --page=2 + + - name: Annotate the page state at each failure + if: always() + run: node scripts/summarize-playwright-report.mjs playwright-report --contexts + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + if-no-files-found: ignore diff --git a/dist/index.html b/dist/index.html index 91e3839..443a40c 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5,26 +5,26 @@ MiroBoard — Локальная доска | Undo, Шаблоны, Тёмная тема - - + `}),E.jsx(kf,{})]})}async function mf(){await Uy(),Bd.createRoot(document.getElementById("root")).render(E.jsx(U.StrictMode,{children:E.jsx(Ff,{})}))}mf().catch(C=>{console.error("Could not initialize the board WebAssembly core.",C)}); +
diff --git a/docs/COLLABORATION_ANALYSIS.md b/docs/COLLABORATION_ANALYSIS.md new file mode 100644 index 0000000..0273b85 --- /dev/null +++ b/docs/COLLABORATION_ANALYSIS.md @@ -0,0 +1,1034 @@ +# Совместная работа в MiroBoard: анализ и план + +> Дата анализа: 2026-09-20. Ветка: `arena/01a0c0ca-miroboard`, базовый коммит `6fe690d`. +> Все утверждения ниже проверены по исходникам и измерениям, а не взяты из документации. +> Ссылки вида `src/App.tsx:268` — на конкретные строки на момент анализа. +> +> Смежный документ: [`MIRO_FEATURE_GAP_ANALYSIS.md`](./MIRO_FEATURE_GAP_ANALYSIS.md) — +> функциональный паритет с Miro. Этот документ — про совместную работу. + +--- + +## 0. TL;DR + +**Совместной работы в продукте сейчас нет вообще.** Yjs присутствует, но используется +только как локальная машина времени: undo/redo, контрольные точки, crash-recovery. +Ни одного сетевого провайдера, ни awareness, ни presence, ни UI для входа в сессию — +всё это было сознательно удалено в Phase 1 (`docs/architecture.md:11`). + +**Хорошая новость:** фундамент выбран правильный. CRDT в основе — это ровно то, на чём +стоят Miro, Figma, tldraw, Excalidraw+. Обратной дороги строить не надо. + +**Плохая новость:** текущая *схема* CRDT для мультиплеера непригодна, и это проверено +экспериментом (`docs/experiments/crdt-collaboration-probe.test.ts`): + +| Что сделали два клиента одновременно | Что получилось | +| --- | --- | +| Один передвинул элемент, второй перекрасил его | **Два элемента с одинаковым `id`** в массиве: `[{"id":"n1","x":0,"color":"blue"},{"id":"n1","x":100,"color":"red"}]` | +| Оба правили текст одной заметки | Правка одного клиента **исчезла бесследно**: `"hello world (A)"` | + +То есть «добавить WebRTC-провайдер» — это не фича на вечер. Сначала придётся поменять +модель данных. Ниже — что именно, в каком порядке и зачем. + +**Стратегическая развилка** (раздел 8): гнаться за живым мультиплеером Miro — значит +играть на чужом поле с заведомо меньшими ресурсами. Асинхронная совместная работа +(обмен файлами, слияние, комментарии, атрибуция автора, воспроизводимая история) — +это то же самое ядро, но уникальное преимущество в офлайн-сегменте. Рекомендуется +гибрид: **async-first, live opt-in**. + +--- + +## 1. Что есть сейчас: проверенный baseline + +### 1.1 Сборка и качество + +Проверено локально (`npm ci && npx vitest run && npx tsc --noEmit`): + +- **148/148** Vitest-тестов проходят, 31 файл; +- **0** ошибок TypeScript; +- `dist/index.html` — **1.1 МБ** одним файлом (не 2.1 МБ, как указано в `ACHIEVEMENTS.md:13`); +- CI (`.github/workflows/ci.yml`) гоняет lint, typecheck, `cargo test`, clippy, + wasm-pack, build и Playwright. + +### 1.2 Слой CRDT + +| Аспект | Реальность | Где | +| --- | --- | --- | +| Документ | `new Y.Doc({ gc: false })` — один на приложение | `src/App.tsx:268` | +| Структура | `Y.Array` — **плоский массив JSON-объектов** | `src/App.tsx:453` | +| Правка поля | `delete(index, 1)` + `insert(index, [целый объект])` | `src/persistence/updates.ts:28-31` | +| Порядок поиска | `toArray().findIndex(...)` — O(n) на каждую правку | `src/persistence/updates.ts:24` | +| Undo | `Y.UndoManager(yarray, { captureTimeout: 500, trackedOrigins: {null, HISTORY_RESTORE_ORIGIN} })` | `src/App.tsx:489-491` | +| Профиль симуляции | `Y.Map('profileConfig')` — уже map, это хорошо | `src/App.tsx:452` | +| Мета | `Y.Map('meta')` c `id`, `createdAt` | `src/App.tsx:451, 461-465` | +| Индекс | `IndexeddbPersistence` как кэш аварийного восстановления | `src/persistence/indexeddb.ts` | + +### 1.3 История + +- Снимки = `Y.snapshot(ydoc)` → base64 **внутри** файла (`src/history/snapshots.ts:34-44`); +- чтение истории = `Y.createDocFromSnapshot` (`snapshots.ts:49-57`) — поэтому `gc:false` + и является обязательным: снимки указывают на удалённые элементы; +- восстановление = полное уничтожение массива и push исторических элементов + (`snapshots.ts:65-71`) — под мультиплеером это означает «отменить всё у всех»; +- `history.yjsState` = **весь** документ целиком в base64 при каждом сохранении + (`src/App.tsx:599`). + +### 1.4 Идентичность и присутствие + +- Есть только `user.id` — анонимный UUID из `localStorage['miro-author-id']` + (`src/App.tsx:442-448`). Ни имени, ни цвета, ни аватара. +- Поле `createdBy` пишется в элементы и сохраняется в файл + (`src/format/mboard.ts:118`, `src/format/types.ts:57`) — **но нигде не отображается**. + Это готовый, но не использованный крючок для атрибуции. +- Инструмент «Лазер» существует (`src/App.tsx:243`, тулбар `~2128`), но это + **локальная** указка: `laserPos` живёт в `useState`, в документ не попадает. + Для презентаций он уже полезен; для совместной работы — нет. + +### 1.5 Сеть + +Ноль. Ни `y-webrtc`, ни `y-websocket`, ни `fetch`, ни WebSocket. `y-indexeddb` — +единственный Yjs-провайдер в проекте, и он локальный. Документация это честно +фиксирует: «Collaboration, shared cursors, accounts, and remote synchronization are +not available in the air-gapped build» (`docs/OFFLINE_DEPLOYMENT.md:47`). + +### 1.6 Что из этого следует + +Продукт сегодня — **однопользовательский редактор с CRDT-движком внутри**. Это не +плохо: CRDT уже окупается офлайн-редактированием, историей и восстановлением после +сбоев. Но «добавить совместную работу» — это не подключить провайдер, а: + +1. поменять схему данных (иначе дубликаты и потерянные правки, см. раздел 2); +2. разделить «контент» и «эфемерику» (курсоры не должны попадать в файл и в историю); +3. решить вопрос с `gc:false` (иначе файл растёт линейно с каждым движением мыши); +4. сделать отмену **своей** правки, а не общей; +5. добавить транспорт, который по умолчанию выключен; +6. добавить UI: профиль, сессия, присутствие, права. + +Пункты 1-4 обязательны даже для асинхронной совместной работы через файлы. +Пункты 5-6 — только для живой. + +--- + +## 2. Блокеры совместной работы (с доказательствами) + +Эксперимент: `docs/experiments/crdt-collaboration-probe.test.ts`. +Запуск: `npm run probe` (скрипт добавлен в `package.json`, использует отдельный +конфиг и **не** влияет на `npm test` и CI). + +### Блокер 1. Правка элемента = замена элемента целиком → дубликаты + +`commitElementUpdate` делает `delete(index,1); insert(index,[...])` +(`src/persistence/updates.ts:28-31`). В Yjs это **две независимые операции над +позицией массива**, а не «изменение поля». При одновременной правке разных полей +одного элемента двумя клиентами массив расходится так: + +``` +A) merged element = [{"id":"n1","x":0,"color":"blue"},{"id":"n1","x":100,"color":"red"}] + count = 2 +``` + +Какой из двух дубликатов окажется первым, зависит от порядка слияния +(clientID и последовательность update'ов): при повторном запуске пробы порядок +менялся местами. То есть результат **недетерминирован** — это худший класс +дефекта для инструмента, который продаёт детерминированность. + +Последствия в UI: два `` с одинаковым ключом (дубликат ключа React, +`src/App.tsx:1498`), «призрачный» элемент, который нельзя удалить +(`deleteElement` находит только первый индекс, `src/App.tsx:747-748`), и расхождение +между клиентами, которое не лечится переподключением. + +Тот же эксперимент со схемой `Y.Map`: + +``` +E) merged = x: 100 color: blue (обе правки сохранены) +``` + +**Вывод:** элемент должен быть `Y.Map` с полями-примитивами, а коллекция — +`Y.Map` (или `Y.Array`), где удаление/вставка — редкое событие, +а правка поля — обычное. + +### Блокер 2. Текст — строка, а не `Y.Text` → потерянная работа + +``` +B) text after merge = "hello WORLD (B)" // правка клиента A исчезла + (при повторном запуске побеждает то A, то B — кто именно, решает порядок слияния) +``` + +Для схемы с `Y.Text`: + +``` +E) concurrent text merge = "Well, hello big world" // обе правки на месте +``` + +Любая совместная заметка/подпись сейчас — это «кто последний нажал, тот и прав». +В Miro одновременное редактирование текста — базовое ожидание пользователя. + +### Блокер 3. `gc: false` + presence = бесконечно растущий файл + +Измерено: перемещение **одного** элемента 500 раз (реалистичный drag за сессию): + +``` +C) update bytes after 125/250/375/500 moves = 4115 -> 8364 -> 12614 -> 16864 +C) same with gc:true = 4428 bytes +``` + +Линейный рост ≈ **34 байта на каждое изменение поля**, без потолка. Сегодня это +терпимо, потому что правки делает один человек и `transientFrame` гасит промежуточные +состояния drag'а (`src/App.tsx:260-262`). В мультиплеере 5 участников × курсоры × +drag'и × текст — это мегабайты в час, и всё это пишется в `history.yjsState` при +**каждом** сохранении (`src/App.tsx:599`). + +При этом `gc:false` нельзя просто выключить: на нём держится история +(`Y.createDocFromSnapshot`). Значит, нужно **разделение документов**: + +- `contentDoc` (`gc: true`) — живой контент, маленький, синхронизируемый; +- `historyDoc` (`gc: false`) — подложка истории, локальная, в файл кладётся отдельно + и с собственной политикой удержания (которая уже есть: `src/history/retention.ts`). + +### Блокер 4. Отмена не различает «своё» и «чужое» + +`trackedOrigins: new Set([null, HISTORY_RESTORE_ORIGIN])` (`src/App.tsx:491`). +`null` — это «локальная транзакция без origin». Удалённые правки приходят с origin +провайдера, поэтому в стек не попадут — это правильно. Но: + +- `restoreSnapshot` (`snapshots.ts:65`) стирает весь массив и пушит исторический. + В мультиплеере это отмена работы **всех** участников, а не «своего» шага; +- нет `Y.UndoManager` с `trackedOrigins: Set([localOrigin])` на транзакции + с явным origin — сейчас origin вообще не проставляется у пользовательских правок + (`ydoc.transact(() => ...)` без второго аргумента, `src/App.tsx:735, 742, 761`); +- нет понятий «мягкая блокировка» и «кто сейчас правит этот элемент». + +**Вывод:** каждой категории правок нужен свой origin (`LOCAL_EDIT`, `LOCAL_GESTURE`, +`REMOTE`, `RECOVERY`, `HISTORY_RESTORE`, `MERGE`), а undo — только для `LOCAL_*`. + +### Блокер 5. Одна выделенная ячейка, нет групп и рамок + +`selectedId: string | null` (`src/App.tsx:188`). Ни marquee-выделения, ни +Shift+клик, ни групп, ни `parentId` в памяти (в формате поле зарезервировано — +`src/format/types.ts:51`, при сериализации всегда `null` — `src/format/mboard.ts:113`). + +Для совместной работы это критично не из-за «удобства», а из-за того, что +**presence = «что сейчас выделено у другого»**. Без множественного выделения +невозможны ни общий фоллоу-режим, ни «не трогать чужую рамку», ни блокировки. + +### Блокер 6. Нет модели рёбер в памяти + +Связь — это элемент-стрелка с полем `bpmnFlow: { sourceId, targetId, ... }` +(`src/App.tsx:98`), у которого собственные `x/y/w/h`. В файле это уже нормальное +ребро (`DocEdge`, `src/format/types.ts:71`), а в памяти — костыль. Совместное +редактирование графа (один тянет узел, второй — связь) на такой модели даёт +рассинхрон геометрии. + +### Блокер 7. Монолит + +`src/App.tsx` — **2534 строки** (цель Phase 2 из `.cursorrules` — `<500`). Внутри: +типы, геометрия, машина инструментов, палитра BPMN, UI симуляции, экспорт, +онбординг и рендер. Любой из пунктов выше требует правки в 5-10 местах этого файла +одновременно. Это главный практический риск: без декомпозиции каждая фича +совместной работы будет стоить вдвое дороже и ломать 148 существующих тестов. + +--- + +## 3. Целевая архитектура совместной работы + +### 3.1 Схема CRDT (v2) + +``` +contentDoc (Y.Doc, gc: true) ← синхронизируется, маленький +├── meta: Y.Map { id, title, schemaVersion, createdAt, updatedAt } +├── nodes: Y.Map ← узлы +│ ├── kind, parentId, z (fractional index), createdAt, createdBy +│ ├── frame: Y.Map { x, y, w, h, rotation } ← LWW-регистр для drag +│ ├── style: Y.Map { color, fill, stroke } +│ ├── text: Y.Text ← совместимый текст +│ ├── points: Y.Array ← штрих: только append +│ └── profileData: Y.Map ← BPMN и др. профили +├── edges: Y.Map ← рёбра как сущности первого класса +│ ├── kind, source: Y.Map{nodeId, anchor}, target: Y.Map{...} +│ ├── waypoints: Y.Array, label: Y.Text, style: Y.Map +│ └── profileData: Y.Map +├── comments: Y.Map ← треды (см. 3.4) +├── assets: Y.Map ← дедуп по контент-хэшу +└── profileConfig: Y.Map ← уже есть, оставляем + +historyDoc (Y.Doc, gc: false) ← локальный, в файл кладётся отдельным блоком +└── подложка снимков (Y.snapshot / createDocFromSnapshot) + +awareness (протокол Yjs, НЕ документ) ← курсоры, выделения, следование, лазер +``` + +Ключевые решения и почему именно так: + +1. **`Y.Map` вместо `Y.Array`.** Убирает дубликаты (блокер 1) и + даёт поле-уровневое слияние. Побочный эффект: нужен отдельный порядок рендера — + см. п. 3. +2. **Порядок и z-order — fractional index** (строка вида `"a0"`, `"a0V"`, между + соседями вставляется лексикографически). Это убирает текущий + `delete+push` в `bringToFront` (`src/App.tsx:755-764`), который в мультиплеере + переставляет элементы у всех участников одновременно. Альтернатива — LWW-число, + но fractional index детерминирован и не требует разрешения коллизий. +3. **`frame` как `Y.Map` с LWW-семантикой.** Для координат одновременная правка + не должна сливаться посимвольно — достаточно «последний победил», но по каждому + полю независимо. Yjs даёт это бесплатно для примитивов внутри `Y.Map`. +4. **Штрихи (`points`) — только append.** Текущий `simplifyPath` (`src/App.tsx:159+`) + переписывает массив точек целиком — в мультиплеере два человека, рисующие одну + линию, сотрут друг друга. Решение: рисование пишет точки в `Y.Array` + аппендом, упрощение выполняется **один раз** локально при отпускании пера и + фиксируется как замена всего элемента-штриха (это редкая операция, её можно + делать через `Y.Map` целиком). +5. **Контент и история — разные документы.** Единственный способ одновременно + иметь маленький синхронизируемый doc и полную историю. Формат файла при этом + сохраняет оба блока, как и сегодня (`nodes/edges` + `history.yjsState`), но + `history.yjsState` становится состоянием `historyDoc`, а не всего приложения. +6. **`profileData` как `Y.Map`** — уже спроектировано в файле + (`src/format/types.ts:56`), в памяти надо просто привести к тому же виду. Это + же снимает 11 `bpmn*`-полей из базового типа (`src/App.tsx:81-91`). + +Миграция: `schemaVersion: 1 → 2` через уже существующий механизм +(`src/format/migrations.ts`), плюс конвертер `Y.Array → Y.Map` для +живых документов в IndexedDB. Фикстуры в `examples/legacy/` не правятся — они и +есть тест миграции. + +### 3.2 Разделение: контент / эфемерика / история + +| Слой | Что | Где живёт | В файл? | Синхронизируется? | +| --- | --- | --- | --- | --- | +| Контент | узлы, рёбра, комментарии, ассеты | `contentDoc` | да | да | +| Эфемерика | курсор, выделение, вьюпорт, лазер, «печатает…» | `Awareness` | **нет** | да (не сохраняется) | +| История | снимки, точки восстановления | `historyDoc` | да (отдельный блок, с лимитом) | опционально, по умолчанию нет | +| Идентичность | имя, цвет, аватар, права | `Awareness` + локальный профиль | нет (только `createdBy` в узлах) | да | + +Самое частое нарушение в самодельных мультиплеер-досках — курсоры в основном +документе. Это немедленно даёт: рост файла (блокер 3), мусор в истории, +ложные срабатывания dirty-трекера (`src/persistence/dirty.ts`) и «отмену» +чужого движения мыши. Awareness в Yjs существует ровно для того, чтобы этого не было. + +### 3.3 Транспорт: интерфейс и четыре реализации + +```ts +export interface CollabTransport { + readonly id: 'none' | 'broadcast' | 'webrtc' | 'websocket' | 'file' + connect(doc: Y.Doc, awareness: Awareness, room: RoomDescriptor): Promise + disconnect(): Promise + onStatus(cb: (s: TransportStatus) => void): () => void +} +``` + +| Транспорт | Сценарий | Сеть | Зависимости | Приоритет | +| --- | --- | --- | --- | --- | +| `none` | одиночная работа (текущий режим) | нет | — | уже есть | +| `broadcast` | два окна/вкладки на одной машине — **демо и тесты** | нет | `BroadcastChannel` (встроен в браузер) | **P0** | +| `file` | асинхрон: обмен `.mboard` / `.mboard-patch`, слияние | нет | — | **P0** | +| `websocket` | свой сервер в контуре предприятия / LAN | локальная сеть | `y-websocket` или `hocuspocus` | P1 | +| `webrtc` | P2P без сервера, но нужен signaling | интернет или LAN | `y-webrtc` + **свой** signaling | P2 | + +Почему такой порядок: + +- **`broadcast`** — почти бесплатен (нет зависимостей, ~60 строк), при этом даёт + полноценный мультиплеер для CI-тестов в Playwright и для демо «два окна рядом». + Это самый дешёвый способ проверить схему CRDT на реальном параллелизме. +- **`file`** — уникальное преимущество продукта: совместная работа **без единого + сервера**, через общий диск, флешку или почту. Для банков и госсектора + (целевая аудитория из `README.md`) это единственный разрешённый вариант. +- **`websocket`** — единственный честный способ дать «живой» мультиплеер в + закрытом контуре. Сервер — ~150 строк Node, ставится внутри периметра. +- **`webrtc`** — соблазнительно («без сервера»), но signaling всё равно нужен, а + NAT-traversal в корпоративных сетях обычно запрещён. Оставляем как опцию, + не как основной путь. Именно публичные signaling-серверы и были удалены в + Phase 1 — возвращать их по умолчанию нельзя (`docs/STRATEGIC_ROADMAP_2026_2027.md:321-343`). + +**Правило по умолчанию: `none`.** Никаких исходящих соединений при открытии +`dist/index.html`. Существующий CI-тест на офлайн-режим +(`tests/offline.spec.ts`) должен остаться зелёным и получить дополнение: +«включение совместной работы требует явного действия пользователя». + +### 3.4 Асинхронная совместная работа — главный козырь + +Это то, чего нет ни у Miro, ни у draw.io, и что идеально ложится на +офлайн-позиционирование. + +**Сценарий:** два аналитика в разных кабинетах, общего сервера нет, есть общая +папка или пересылка файлов. + +1. Алиса открывает `process.mboard`, правит, сохраняет. Файл содержит + `contentState` (state vector + update) и `history`. +2. Боб открывает **свою** копию того же документа (у него свой `meta.id`, тот же), + правит, сохраняет как `process-bob.mboard`. +3. Боб пересылает файл Алисе. Алиса делает «Объединить с файлом…». +4. Приложение сравнивает state vectors, считает, что изменилось у Боба, и + показывает **превью слияния**: добавленные узлы, изменённые, удалённые, с + авторством и подсветкой конфликтов (если оба правили одно поле — предложить + выбор, а не тихий LWW). +5. После подтверждения создаётся слияние, и в историю добавляется именованная + контрольная точка `merge: process-bob.mboard`. + +Технически это **уже почти готово**: `Y.encodeStateAsUpdate(doc, stateVector)` и +`Y.applyUpdate` — тот же механизм, который используется для восстановления из +файла (`src/App.tsx:660`). Не хватает: + +- UI диалога слияния и диффа (можно переиспользовать подсветку изменений из + превью истории — `src/history/HistoryPreviewBanner.tsx` уже красит изменённые + узлы оранжевым, `src/App.tsx:1499`); +- отдельного «тонкого» артефакта: `.mboard-patch` = только дельта относительно + state vector получателя (для больших документов пересылать мегабайты истории + каждый раз — расточительно); +- политики: что делать с `history` при слиянии (рекомендация: объединять + снимки обоих файлов, применять существующую `retention`). + +**Дополнительно:** `.mboard` — это JSON. Значит, его можно сливать и в Git +(текстовый merge по узлам при аккуратном форматировании). Это отдельная +демо-возможность: «документ, который живёт в репозитории и имеет историю и в +Git, и внутри себя». + +### 3.5 Живая сессия: присутствие и совместные действия + +Минимальный набор, без которого «живой» режим ощущается сломанным: + +| Фича | Механика | Оценка | +| --- | --- | --- | +| Профиль участника | имя + цвет (детерминированно из `clientID`), хранится локально | S | +| Курсоры с именем | `awareness.setLocalState({ cursor, viewport, selection })`, троттлинг 30-50 мс, интерполяция на приёмной стороне | M | +| Аватары онлайн | список из `awareness.getStates()`, таймаут 30 с | S | +| Общий лазер | тот же awareness-канал, `laserPos` уже есть (`src/App.tsx:243`) | S | +| Подсветка «кто правит элемент» | `selection` в awareness → рамка цвета участника; заменяет блокировки (мягкая блокировка) | M | +| Follow-режим | подписка на `viewport` лидера, плавный pan/zoom | M | +| Общая отмена | `Y.UndoManager` с `trackedOrigins: new Set([LOCAL_ORIGIN])` | S | +| Синхронный текст | `Y.Text` + `y-prosemirror` или собственный contenteditable-биндинг | L | +| Голос | WebRTC DataChannel/MediaStream — **вне скоупа**, требует сервера и отдельной работы с приватностью | — | + +Важно: `transientFrame` (`src/App.tsx:260-262`) уже реализует правильный паттерн — +локальная анимация без записи в CRDT, коммит на pointer-up. Его надо обобщить: +**любое непрерывное взаимодействие пишет в CRDT не чаще N раз в секунду**, а +промежуточные состояния публикуются в awareness. Иначе каждый drag — это десятки +обновлений на всех клиентов и десятки tombstone'ов. + +### 3.6 Права, приватность, соответствие требованиям + +Целевая аудитория — регулируемые организации. Здесь MiroBoard может быть сильнее +облачных аналогов, но только если не наделать ошибок: + +- **Секрет ≠ идентификатор комнаты.** В старой версии `collaborationSecret ?? roomId` + означал, что URL-параметр был и паролем (отмечено как дефект в + `docs/STRATEGIC_ROADMAP_2026_2027.md:335-338`). Новая схема: `roomId` открыт, + ключ — отдельно, передаётся вне канала. +- **E2EE для P2P.** Ключ в фрагменте URL (`#key=...`) — фрагмент не уходит на + сервер и не попадает в логи. Обновления шифруются до отправки в signaling. +- **Криптографическое стирание.** CRDT хранит удалённое содержимое в tombstone'ах. + «Удалил заметку» ≠ «заметки больше нет в файле». Если документ содержит + чувствительные данные, нужен явный режим «очистить историю» (уже есть: + `compactHistory`, `src/App.tsx:326`) + документированное предупреждение. + Это стоит вынести в `SECURITY.md`. +- **Права (view / comment / edit).** В P2P-модели принудительно обеспечить нельзя + (клиент всегда может отправить update). Честный вариант: права — это + **серверная** функция для `websocket`-транспорта, а в P2P-режиме они + декларативные (UI скрывает инструменты, но не гарантирует). Сказать об этом + прямо в документации лучше, чем создать иллюзию защиты. +- **Персональные данные.** `createdBy` уже пишется в файл. Как только появится + имя, файл начнёт содержать персональные данные участников. Нужна настройка + «обезличивать при экспорте» и явное уведомление в UI. + +--- + +## 4. Функционал «как в Miro»: честная матрица + +Подробная версия с оценками — в [`MIRO_FEATURE_GAP_ANALYSIS.md`](./MIRO_FEATURE_GAP_ANALYSIS.md). +Здесь — сводка, потому что часть пунктов является предусловием совместной работы. + +### Есть + +Перо, маркер, ластик (удаляет элемент целиком), стикеры, текст, прямоугольник, +круг, стрелка, линия, эмодзи, палитра цветов, привязка к сетке (по умолчанию +выключена, `src/App.tsx:237`), pan/zoom/fit, minimap, контекстное меню +(правка/дублировать/на передний план/на задний план/удалить), горячие клавиши +инструментов, шаблоны (BPMN-ориентированные), экспорт PNG/SVG/BPMN XML, импорт +BPMN XML, `.mboard` save/load, история с таймлайном, симуляция. + +### Нет (и это заметно сразу) + +| Категория | Отсутствует | Почему важно для совместной работы | +| --- | --- | --- | +| Выделение | множественное выделение, marquee, Shift+клик | presence, группы, блокировки невозможны | +| Буфер | копировать/вставить (Ctrl+C/V), дублировать несколько | совместная работа с рамками/группами | +| Структура | группы, фреймы, `parentId` в памяти | «рамка = область ответственности участника» | +| Связи | рёбра как сущности, привязка к узлу, подписи, waypoints | общий граф при параллельном редактировании | +| Контент | изображения, rich text, таблицы, чек-листы | 80% реальных досок | +| Навигация | поиск, outline, переход к элементу | доска >100 элементов нечитаема | +| Обсуждение | комментарии, реакции, голосование, таймер | ядро командной работы в Miro | +| Презентация | presentation mode, spotlight, follow | лазер уже есть, остальное нет | +| Данные | CSV/JSON импорт стикеров, экспорт в Markdown/PDF | интеграции и отчётность | +| Производительность | виртуализация/куллинг вьюпорта | 500+ узлов и 5+ клиентов одновременно | + +### Есть, но работает не как в Miro + +- **Ластик** стирает весь элемент (`src/App.tsx:1173-1176`), а не часть штриха. +- **Стрелка** — самостоятельный элемент с координатами, а не связь между узлами; + при перемещении узла стрелка не едет за ним (кроме BPMN-потоков, которые + пересчитываются по `bpmnFlow.sourceId/targetId`). +- **Текст** — плоская строка в `content.text`, без форматирования и без + совместимого редактирования. +- **Шаблоны** — 6 образовательных BPMN-примеров; ретро/канбан/майндмэп/свамм-плейн + отсутствуют, хотя именно они приводят людей в Miro. + +--- + +## 5. План: четыре этапа + +Оценка: S ≈ до 1 дня, M ≈ 2-5 дней, L ≈ 1-2 недели, XL ≈ 2-4 недели +(один разработчик, с тестами). + +### Этап 0 — Фундамент (обязателен для всего остального) · 2-3 недели + +Без этого этапа любая фича совместной работы стоит вдвое дороже и ломает тесты. + +| # | Задача | Оценка | Статус | +| --- | --- | --- | --- | +| 0.1 | Локальный профиль: имя + цвет вместо анонимного UUID | S | ✅ сделано: `src/collab/user-profile.ts`, кнопка-аватар и панель в топ-баре, миграция с `localStorage['miro-author-id']` | +| 0.2 | Множественное выделение: `selectedIds: Set`, marquee, Shift+клик | M | ✅ сделано: `src/collab/selection.ts`, `src/collab/marquee.ts`; marquee, Shift+клик, Shift+marquee, групповой drag, Delete/Ctrl+D/Ctrl+A/стрелки, счётчик выделения | +| 0.3 | Буфер обмена: Ctrl+C/V/X, JSON в `clipboard`, дублировать несколько | S | ✅ `src/collab/clipboard.ts`: формат `application/x-miroboard+json`, валидация чужого JSON, новые id + перепривязка `bpmnFlow`, вставка одной транзакцией с origin `LOCAL_CLIPBOARD`; стрелки теперь тоже двигают выделение одной транзакцией | +| 0.4 | Явные origins транзакций (`LOCAL_EDIT`, `LOCAL_GESTURE`, `RECOVERY`, …) | M | ✅ сделано: `src/collab/origins.ts`, UndoManager переведён на allow-list локальных origins | +| 0.5 | Декомпозиция `App.tsx`: canvas-render, tool-state-machine, selection, viewport, panels | XL | 🟡 в работе: в `src/board/` — типы, геометрия, id, палитра, примеры, тема, шаги тура, `useSimulationSettings`, математика вьюпорта и `commands.ts`; в `src/collab/` — selection, marquee, clipboard, origins, user-profile; в `src/components/` — 19 компонентов. `App.tsx` = 2104 строки при целевых 800, `useState` 63 → 54. Остаток — `renderElement` (~230 строк), обработчики указателя, BPMN/симуляция, файлы. Перенос разметки исчерпан; цель 800 достижима только через разделение состояния доски (отдельный этап, за рамками 0.x) | +| 0.6 | Командный слой: все мутации через `commands/*` с явной транзакцией | M | ✅ `src/board/commands.ts`: add/update/delete/deleteMany/updateMany/move/duplicate/bringToFront. Два инварианта в одном месте — одно действие = одна транзакция (один шаг undo, один чекпоинт, один update для пиров) и каждая запись с origin. `updateSelected` перестал быть циклом. 27 тестов против настоящих функций вместо 6 против копий кода в тест-файле | + +Покрытие Этапа 0: 263 unit-теста, включая jsdom-smoke всего приложения +(`src/app-smoke.test.tsx`) и два browser-level набора — `tests/multi-select.spec.ts` +(9 сценариев) и `tests/clipboard.spec.ts` (6 сценариев). Все 102 e2e-теста в CI +зелёные (прогон 35564158429). + +**Извлечённый урок (0.4): переименование origin — не локальное изменение.** +После введения origins покраснели 11 e2e-тестов, и причин оказалось две, обе +найдены только через CI. + +*Причина 1 — «Не сохранено» после сохранения (6 наборов).* Все они грузят +учебный пример перед сохранением. Загрузка вызывает `setArrivalClasses`/ +`setRolePolicies`, те попадают в `simulationProfile`, а он запускает эффект, +пишущий `profileConfig` обратно в документ. Против этого эха существует +`profileConfigHydratingRef`, и он определял «конфиг пришёл из документа» +проверкой `origin === RECOVERY_ORIGIN` — верной ровно до того, как открытие +файла было переименовано в `LOAD`. Эхо стало считаться правкой и приходило +*после* завершения сохранения. Исправлено проверкой `NON_EDIT_ORIGINS.has(...)`. + +Важно: первая гипотеза (грязный трекер не знает про `LOAD`) была **опровергнута +CI** — фикс не изменил число падений. Трекер всё равно стоит читать политику из +`src/collab/origins.ts`, но причиной был другой потребитель того же origin. +Вывод для 0.5/0.6: при переименовании origin нужно найти *всё*, что на нём +ветвится; на момент правки таких мест было два, и оба молчали при tsc и eslint. + +*Причина 2 — marquee не запускался (5 тестов).* Плашка «N элем.» — обычный div в +левом верхнем углу холста, ровно там, где начинается рамка выделения, — глотала +`pointerdown`. Она появляется только на непустой доске, поэтому падения +выглядели как проблема выделения. Исправлено `pointer-events-none`. + +Попутно найдена настоящая гонка в приложении: `handlePointerUp` достраивал жест +из React-состояния, записанного в `pointermove`, поэтому жест, отпущенный внутри +одного кадра (флик), завершался с геометрией момента нажатия. Отсюда и +«мигающие» тесты. Геометрия жестов вынесена в `src/board/gesture.ts` и считается +от координат самого события. + +**Методический вывод.** jsdom не воспроизводит ни одну из причин: обе требуют +реального браузера. Playwright в песочнице недоступен (CDN заблокирован), логи и +артефакты CI — на недоступном blob-хосте. Единственный работающий канал — текст +аннотаций, поэтому диагностика встраивалась в приложение, а спек выбрасывал +собранный лог в сообщение об ошибке. Этот приём стоит помнить: он дал ответ за +один прогон после того, как три раунда догадок ничего не дали. + +**Критерий готовности:** все unit- и e2e-тесты зелёные; `App.tsx < 800` строк; +выделение множественное; копирование/вставка работают; в консоли нет дубликатов +ключей React на доске из 500 элементов. + +### Этап 1 — CRDT v2 (данные, пригодные для слияния) · 2-3 недели + +| # | Задача | Оценка | Заметки | +| --- | --- | --- | --- | +| 1.1 | Новая схема: `nodes/edges: Y.Map`, `text: Y.Text` | L | ядро; см. 3.1 | +| 1.2 | Fractional index для z-order, убрать `delete+push` | M | | +| 1.3 | Разделение `contentDoc` (gc:true) / `historyDoc` (gc:false) | L | снимает блокер 3; требует пересмотра `snapshots.ts` и `state.ts` | +| 1.4 | `schemaVersion: 2` + миграция + legacy-фикстуры | M | механизм уже есть (`migrations.ts`) | +| 1.5 | Ребра в памяти как сущности (убрать `bpmnFlow` из `BoardElement`) | L | совпадает с Phase 3 стратегического роадмапа | +| 1.6 | Слияние двух `.mboard`: state-vector diff, превью, конфликтные поля | L | уникальная фича, см. 3.4 | +| 1.7 | Атрибуция: «кто изменил» из `createdBy` + история по автору | M | поле уже сохраняется, но не отображается | +| 1.8 | Property-based тесты слияния (случайные параллельные правки, N итераций) | M | ловит класс багов, который руками не воспроизводится | + +**Критерий готовности:** два независимых документа, правившие один файл +параллельно, сливаются без дубликатов `id` и без потери правок; одновременное +редактирование текста сливается посимвольно; размер файла после 1000 правок +не превышает задокументированного бюджета (например, ≤ 3× от текущего состояния — +политика уже заявлена в `docs/STRATEGIC_ROADMAP_2026_2027.md:316-318`). + +### Этап 2 — Асинхронная совместная работа (без сервера) · 2 недели + +| # | Задача | Оценка | +| --- | --- | --- | +| 2.1 | Комментарии: `Y.Map`, пины на узлах и на точке холста, resolve, панель со списком | L | +| 2.2 | UI слияния файлов: диалог, дифф по узлам, выбор в конфликтах, чекпоинт `merge:` | L | +| 2.3 | `.mboard-patch`: тонкая дельта для пересылки | M | +| 2.4 | Реакции/голоса на стикерах (`profileData.core.reactions`) + таймер фасилитации | M | +| 2.5 | Экспорт «итоги обсуждения»: Markdown/CSV с комментариями и голосами | S | + +**Демо-сценарий:** «Мы втроём правили один документ на разных машинах без единого +сервера, обменялись файлами, слили их, увидели кто что добавил, и получили единый +документ с историей и всеми комментариями». + +### Этап 3 — Живая сессия (опционально, по умолчанию выключена) · 3-4 недели + +| # | Задача | Оценка | +| --- | --- | --- | +| 3.1 | `CollabTransport` интерфейс + `BroadcastChannelTransport` | M | +| 3.2 | Awareness: курсоры, имена, выделения, вьюпорт, общий лазер | L | +| 3.3 | Undo с `trackedOrigins: {LOCAL_ORIGIN}` + «своя» отмена | S | +| 3.4 | UI сессии: создать/подключиться, список участников, статус соединения, выход | M | +| 3.5 | `WebSocketTransport` + минимальный self-hosted relay (отдельный пакет, ~150 строк) | L | +| 3.6 | E2EE-опция, ключ во фрагменте URL, предупреждение о tombstone'ах | M | +| 3.7 | Follow-режим и presentation/spotlight | M | +| 3.8 | E2E в CI: два браузера Playwright против локального relay, проверка слияния и отсутствия дубликатов | L | +| 3.9 | `WebRTCTransport` со **своим** signaling (опция, без публичных серверов) | L | + +**Жёсткое требование:** офлайн-тест (`tests/offline.spec.ts`) и проверка «ноль +внешних запросов в режиме по умолчанию» остаются зелёными. Совместная работа — +опт-ин, с явным действием пользователя и явным индикатором в UI. + +### Этап 4 — Паритет с Miro по инструментам · параллельно, 4-6 недель + +Фреймы и группы, изображения, rich text, библиотека фигур, коннекторы с +автомаршрутизацией, поиск/outline, шаблоны (ретро, канбан, майндмэп), +презентационный режим, экспорт PDF/Markdown/CSV. Детальный список с оценками — +в `MIRO_FEATURE_GAP_ANALYSIS.md`. + +--- + +## 6. Что сознательно НЕ делаем + +| Не делаем | Почему | +| --- | --- | +| Публичные signaling-серверы по умолчанию | Именно это и было удалено в Phase 1 как нарушение офлайн-обещания и утечка идентификаторов досок | +| Аккаунты, облачное хранение, серверная авторизация | Требует бэкенда и ломает «один HTML-файл с флешки» | +| Видео/аудио-звонки | Отдельный продукт, высокая стоимость, низкая дифференциация | +| Гонка за шириной библиотеки фигур Miro | Бесконечная работа без преимущества; см. риск «стать худшей версией Miro» в `docs/STRATEGIC_ROADMAP_2026_2027.md:794` | +| Мобильное приложение | Вне скоупа | +| Реалтайм-курсорики как главный маркетинг | Курсоры — гигиена, а не преимущество; преимущество — владение файлом и история | + +--- + +## 7. Риски + +| Риск | Вероятность | Влияние | Митигция | +| --- | --- | --- | --- | +| Миграция схемы CRDT сломает историю существующих `.mboard` | Средняя | Высокое | Двойная запись в переходный период; legacy-фикстуры в CI; `gc:false`-документ сохраняется как есть, конвертация только при явном открытии v1 | +| Этап 0 (декомпозиция) затянется и съест весь план | Высокая | Высокое | Делать strangler-fig батчами <150 строк с зелёными тестами между батчами; не смешивать рефакторинг и фичи в одном коммите | +| Размер файла выйдет из-под контроля после включения истории на отдельный doc | Средняя | Среднее | Бюджет размера в CI с первого коммита; `retention` уже реализован (`src/history/retention.ts`) | +| Живой мультиплеер окажется «как у Miro, но хуже» | Высокая | Среднее | Продавать async-first; живой режим — опция для LAN/своего сервера, не основной сценарий | +| Производительность SVG-рендера на 500+ узлах и 5 клиентах | Высокая | Высокое | Замерить **до** оптимизации (профиль в `docs/STRATEGIC_ROADMAP_2026_2027.md:452`); кульминг вьюпорта и `React.memo` первыми, canvas-слой — только если замер это оправдает | +| Приватность: имена и правки участников в файле, который уходит наружу | Средняя | Среднее | Настройка обезличивания при экспорте; предупреждение в UI; запись в `SECURITY.md` | +| Тесты на параллелизм появятся после кода | Высокая | Высокое | Этап 1 начинается с property-based тестов слияния и пробы `docs/experiments/`, переведённой в постоянный набор | + +--- + +## 8. Стратегическая развилка: стоит ли вообще делать живой мультиплеер + +`docs/STRATEGIC_ROADMAP_2026_2027.md:794` прямо предписывает: +> «Compete on offline, portability, determinism and history — never on real-time +> collaboration polish or breadth of shapes.» + +Запрос «сделать как в Miro» этому противоречит. Развилка решается так: + +**Аргументы за живой мультиплеер** + +- Без курсоров и presence продукт не воспринимается как «доска для команды» — + его не покажешь на воркшопе в реальном времени. +- CRDT уже в стеке; предельная стоимость awareness-слоя невелика (M, а не XL). +- Для портфолио «реализовал live-синхронизацию на Yjs с self-hosted relay и + E2EE» — сильный и проверяемый пункт. + +**Аргументы против** + +- Miro, Figma, tldraw, Excalidraw+ имеют команды и годы на polish. Догонять по + плавности курсоров — заведомо проигрышная гонка. +- Живой режим требует сервер (или signaling), что размывает главное обещание — + «один HTML-файл без сети». +- Основная аудитория (`README.md`: банки, госсектор, консалтинг) чаще работает + **асинхронно** и в закрытом контуре: общий диск, пересылка файла, ревью. + +**Рекомендация: гибрид, в таком порядке приоритетов** + +1. **Асинхронная совместная работа — это продукт.** Слияние файлов, комментарии, + атрибуция, голосование, воспроизводимая история. Уникально, соответствует + офлайн-обещанию, конкурентов нет. +2. **Живой мультиплеер — опция для своего контура.** `websocket`-транспорт + + self-hosted relay (~150 строк, ставится внутри периметра). Продаётся как + «совместная работа в закрытой сети без интернета». +3. **P2P/WebRTC — экспериментальная опция** с E2EE и явным предупреждением о + signaling-сервере. Не по умолчанию. + +Такая формулировка не противоречит стратегическому роадмапу: мы конкурируем +**владением документа и историей**, а живой режим добавляем там, где он не +требует чужой инфраструктуры. + +--- + +## 9. Метрики успеха + +| Метрика | Сейчас | Цель после Этапа 1 | Цель после Этапа 3 | +| --- | --- | --- | --- | +| Потерянные правки при параллельном редактировании | 100 % (последний победил) | 0 | 0 | +| Дубликаты `id` после слияния | возможны (доказано) | 0 | 0 | +| Размер `yjsState` после 500 правок одного узла | 16.9 КБ, растёт линейно | ≤ 5 КБ (gc:true) | ≤ 5 КБ | +| Доска из 500 узлов: pan/zoom | не замерено | ≥ 50 fps, замер в CI | ≥ 50 fps | +| Внешние сетевые запросы в режиме по умолчанию | 0 | 0 | 0 | +| Unit-тесты | 148 | 148 + ≥ 25 на слияние | то же | +| E2E на два клиента | 0 | 1 (BroadcastChannel) | ≥ 5 (relay, presence, undo, reconnect, merge) | +| `App.tsx`, строк | 2534 | < 800 | < 500 | + +--- + +## 10. Найденные расхождения в документации (попутно) + +Не относится напрямую к совместной работе, но портит доверие к проекту — а +доверие здесь и есть продукт. + +| Файл | Утверждение | Реальность | +| --- | --- | --- | +| `ACHIEVEMENTS.md:29` | «Canvas Rendering: HTML5 Canvas 2D API» | Рендер — **SVG** (``, ``, `` в `src/App.tsx:1498+`) | +| `ACHIEVEMENTS.md:28` | «Zustand (state management)» | Zustand **не установлен** (`package.json`), состояние — `useState`/`useRef` | +| `ACHIEVEMENTS.md:33` | «Custom binary format (.mboard), MessagePack-like» | `.mboard` — **JSON** (`src/format/mboard.ts`) | +| `ACHIEVEMENTS.md:13` | «2.1 MB single-file offline bundle» | `dist/index.html` = **1.1 МБ** | +| `docs/ROADMAP.md:30` | «Заменить публичные Yjs signaling-серверы управляемым собственным signaling» | Публичные серверы уже **удалены** в Phase 1; пункт устарел и вводит в заблуждение | +| `README.md` | «История версий встроена… всё в одном `.mboard`-файле» | Верно; но про совместную работу README молчит, а `docs/OFFLINE_DEPLOYMENT.md:47` сообщает, что её нет. Стоит добавить явную строку в README, чтобы не создавать ложного ожидания | + +Рекомендация: завести правило «документация проверяется скриптом» там, где это +возможно (размер бандла, наличие зависимостей, число тестов — всё это вычислимо +в CI), и переписать `ACHIEVEMENTS.md` под фактическое состояние. + +--- + +## 11. Следующий шаг + +Минимальный осмысленный первый шаг — **Этап 0, задачи 0.1-0.3** (профиль +участника, множественное выделение, буфер обмена): они не требуют миграции схемы, +заметны пользователю сразу и являются предусловием для всего остального. + +Параллельно — **1.8** (property-based тесты слияния) и перевод +`docs/experiments/crdt-collaboration-probe.test.ts` в постоянный набор: тест, +который сегодня документирует дефект, станет тестом, который не даст ему вернуться. + +--- + +## 12. Класс дефектов: «данные защищены, интерфейс — нет» + +Найден при ревизии режима просмотра истории, но относится напрямую к +совместной работе: там появится второй режим только для чтения (чужая +блокировка, отставшая реплика), и ошибка воспроизведётся. + +Защита от записи в превью реализована **в слое данных** — `updateElement`, +`addElement`, `deleteElement` и остальные мутаторы выходят по +`if (previewSnapshot) return`. Это верно и достаточно для целостности +документа. Но из этого **не следует**, что интерфейс не предложит действие, +которое будет молча отброшено. + +Два подтверждённых случая (оба исправлены, оба с регрессионными тестами): + +| Дефект | Что видел пользователь | Коммит | +|---|---|---| +| Редактор текста у `rect`/`circle` не проверял `isPreview` | Двойной клик открывал редактор, текст набирался и молча исчезал по blur | `d1dab69` | +| `Ctrl+A` читал `elements` (живой документ), а не `previewElements` | Выделялись невидимые объекты живого документа; `Ctrl+C` их копировал | `3854906` | +| Заглушка пустой доски проверяла `elements.length` | Опустошив доску и открыв ранний снимок, пользователь видел снимок, накрытый «Начните творить» и кнопкой шаблонов | `1c2c6fd` | +| Значок валидности BPMN описывал живой документ | В превью значок сообщал «BPMN OK» про доску, которой на экране нет | `be0e1ca` | + +Ревизия была сплошной: проверены все чтения `elements` в App.tsx на предмет +того, попадают ли они в отрисовку. Контекстное меню, панели свойств, +`MoreMenu` и остальные горячие клавиши защищены (pointerdown в превью выходит +рано, кнопка меню `disabled={isPreview}`, вход и выход сбрасывают выделение и +режим правки). + +Общее в обоих: запись действительно не происходила, страдала только правдивость +интерфейса. Такие дефекты не ловятся проверками целостности данных и не видны +в тестах, которые смотрят на состояние документа, — нужно утверждение о том, +что интерфейс **предлагает**. + +Практические выводы: + +1. **Выделение — не мутация, и защита мутаторов его не покрывает.** Всё, что + читает `elements` в обход `renderedElements`, обязано отдельно учитывать + превью. На момент правки такое место было одно (`Ctrl+A`); при добавлении + любого «выделить по признаку» проверять заново. +2. **Скрытый индикатор — плохой ориентир для теста.** Плашка с числом + выделенных намеренно скрыта в превью, поэтому тест «бейджа нет» проходил и + на сломанном коде. Утверждать нужно о наблюдаемом последствии — здесь + `Ctrl+C`, который перехватывает сочетание только когда есть что копировать. +3. **Обязательно проверять, что тест краснеет без фикса.** Из четырёх правок + две первые версии тестов оказались бесполезны, и обе поймал только явный + откат фикса: одна утверждала про намеренно скрытый в превью индикатор, + другая рисовала прямоугольник вместо BPMN-узла, так что проверяемый значок + отсутствовал по совершенно другой причине. Зелёный тест ничего не значит, + пока не видел красного. + +4. **Намерение, выраженное в одном месте, нужно проводить до конца.** + `visibleSimulationResult`/`Summary`/`BottleneckRole` гасились в превью + именно потому, что описывают живой документ, — а соседний значок BPMN + остался. Такие «почти согласованные» места стоит искать рядом с уже + написанной защитой. +5. **Дублирование кода прячет расхождение режимов.** Редактор текста был + скопирован по четырём фигурам, и проверка read-only потерялась в двух из + них. Сведение в `src/components/ElementTextEditor.tsx` делает такую потерю + невозможной. + +--- + +## 13. Атрибуция: `createdBy` пишется, но не проверяется + +`createdBy` — это весь нынешний механизм авторства. Панель профиля прямо +обещает пользователю: имя и цвет «используются для подписи созданных объектов». +При ревизии выяснилось: + +- поле проставляется в **21 месте** `App.tsx` (все пути создания его ставят — + проверено); +- оно **не читается нигде** в интерфейсе; +- **ни один тест** не утверждал, что приложение его действительно проставляет: + все упоминания в тестах были фикстурами, кроме одного — `preparePaste`. + +В этом зазоре и нашлось расхождение: `Ctrl+D` (`duplicateElements`) копировал +`createdBy` оригинала, тогда как `Ctrl+C`/`Ctrl+V` (`preparePaste`) переписывал +автора на того, кто вставляет. Для пользователя это одно и то же действие — +«сделать копию». Сценарий, где это видно: открыть чужой файл и нажать `Ctrl+D` — +новый объект оказывался подписан коллегой. Исправлено в `c67ac70`; автор стал +необязательным аргументом, прежнее поведение закреплено отдельным тестом. + +Отдельный урок про **границы тестируемости**. Первая попытка покрыть авторство +шла через `app-smoke.test.tsx` и отладочный мост `window.__MIROBOARD_DEBUG__`. +Мост объявлен за флагом сборки `__MIROBOARD_DEBUG_HOOK__` +(`MIROBOARD_DEBUG_HOOK=1`), которого юнит-тесты не ставят, поэтому все три +теста утверждали бы против пустого списка. Они были удалены, а покрытие +перенесено на слой команд, где значение и записывается. Вывод: прежде чем +писать тест через отладочный мост, нужно убедиться, что мост включён в том +окружении, где тест исполняется — иначе получается ещё один тест, который +никогда не краснеет. + +Что это значит для совместной работы: перед вводом любого UI с авторством +(аватары на объектах, «кто это сделал», фильтр по участнику) стоит помнить, что +поле сегодня пишется «вслепую». Читающий UI сразу обнажит все места, где оно +проставлено неверно, — поэтому такие расхождения дешевле искать сейчас. + +--- + +## 14. Origins: правило должно жить в одном месте + +Раздел 10 уже описывал, как переименование origin открытия файла в `LOAD` +сломало потребителя, который ветвился на `RECOVERY_ORIGIN`. Ревизия показала, +что это была не единичная ошибка, а закономерность: **каждый потребитель, +перечисляющий origins вручную, рано или поздно отстаёт от списка**. + +Найдено и исправлено: + +| Потребитель | Что было | Следствие | Коммит | +|---|---|---|---| +| `createCaptureTriggers` в `App.tsx` | Множество выписано вручную: `{RECOVERY_ORIGIN, HISTORY_RESTORE_ORIGIN}`, без `LOAD` | Открытие файла считалось правкой. Сразу ничего не происходило (одна загрузка — одно обновление при пороге в 50), но взводился таймер, и через 5 минут на нетронутой доске появлялась контрольная точка «Авто» | `d2df908` | + +Показательно, что двумя строками выше в том же `useEffect` трекеру «грязности» +передаётся общий `NON_EDIT_ORIGINS` — ровно для той же цели. Правило было в +одном месте, но этот вызов его не использовал. + +Отдельно исправлен **комментарий** к `NON_EDIT_ORIGINS` (`1b0dc3e`): он +утверждал, что множество содержит `null` «для ещё не мигрированных путей». Это +неверно, и миграция давно завершена — аудит всех записей в документ (20 вызовов +`transact` в `App.tsx`, `board/commands.ts`, `persistence/updates.ts` и +`history/`, плюс проверка записей вне транзакций) не нашёл ни одного +непомеченного пути. Ошибочный комментарий стоял в файле, чья единственная +задача — быть справкой о том, что означает origin. + +Заодно зафиксировано само поведение: непомеченная запись **считается правкой**. +Теперь это не случайность, а решение — раз непомеченная запись стала багом, +безопасное её прочтение состоит в том, что пользователь что-то изменил. Обратное +(считать `null` неправящим) молча теряло бы настоящие правки, тогда как цена +ошибки в текущую сторону — лишнее «Не сохранено». + +Вывод на будущее, особенно к моменту появления provider-origin удалённой +синхронизации: **не перечисляйте origins по месту**. Любой новый потребитель +должен импортировать множество из `src/collab/origins.ts`; ручной список — это +отложенный баг, который не заметят ни tsc, ни eslint. + +--- + +## 15. Исчезающие элементы: кто ещё держит id + +Прямое продолжение раздела 12, но причина другая. Там интерфейс предлагал +действие в режиме только для чтения; здесь — **элемент исчезает сам**, без +участия пользователя: undo/redo, загрузка файла, восстановление из истории и, +как только появится совместная работа, удаление коллегой. + +Наблюдатель `yarray.observe` уже чистил выделение (`retainExisting`) именно по +этой причине. Ревизия показала, что id элементов держат **семь** состояний, а +чистилось одно. Проверены все: + +| Состояние | Вывод | +|---|---| +| `selectedIds` | Чистилось и раньше | +| `contextMenu` | **Дефект.** Меню закреплено в мировых координатах: открыть долгим нажатием, нажать undo — меню висит над пустым холстом, и каждый его пункт молча ничего не делает. Исправлено в `308a680` | +| `selectedElementId` | Безопасно: производное от `selectedIds` | +| `editingText` | Безопасно: рендерится только внутри существующего элемента | +| `bpmnFlowSourceId` | Безопасно: элемент ищется заново при использовании, и при неудаче состояние сбрасывается | +| `anchorId` | Безопасно: отрисовка якоря выделения делает `find` и возвращает `null`, если элемент исчез; к записи не ведёт | +| `activeBpmnTokenId` | Безопасно: только сравнивается с id отрисовываемого элемента (`activeBpmnTokenId === el.id`), поэтому исчезнувший id просто ни с чем не совпадает | + +Первая редакция этой таблицы содержала пять строк вместо семи: `anchorId` и +`activeBpmnTokenId` попали в исходную выборку, но не были проверены, и таблица +утверждала полноту, которой не было. Обе строки добавлены после отдельной +проверки. Урок ровно тот же, что и с тестами: заявление о полноте ревизии стоит +ровно столько же, сколько заявление о зелёном тесте, — пока его не проверили. + +Данные и здесь не страдали: слой команд отклоняет операции с неизвестным id +(`deleteElement`, `bringToFront` и остальные возвращают `false`). Как и в +разделе 12, страдала правдивость интерфейса — и по той же причине проверки +целостности данных такие дефекты не ловят. + +Практический вывод к моменту включения совместной работы: **список мест, +держащих id элемента, должен быть явным**. Сегодня их пять, проверять их надо +вместе, и добавление шестого обязано сопровождаться ответом на вопрос «что +будет, когда этот элемент удалит кто-то другой». У удалённого удаления, в +отличие от undo, не будет даже той подсказки, что действие совершил сам +пользователь. + +--- + +## 16. Незавершённый жест — это состояние вне документа + +Перетаскивание и изменение размера живут в `transientFrame` (React-состояние) и +попадают в Y.Doc одной транзакцией только на `pointerup`. Так сделано намеренно: +трёхсекундный drag остаётся **одним** шагом отмены, а не 180. + +Цена этого решения — окно, в котором **экран и документ расходятся**. Всё, что +читает документ во время жеста, видит позицию «до». Сохранение — тот случай, +где это заметно: `Ctrl+S` во время перетаскивания записывал на диск старые +координаты, тогда как на экране были новые. Проверено тестом: отрисовано +`translate(280,280)`, сохранено `(100,100)`. Исправлено в `7724e27` — +`flushGesture()` фиксирует незавершённый жест, и `saveBoard` вызывает его первым. + +Отдельно стоит отметить **ложную тревогу по ходу**: я заподозрил, что фиксация +жеста стоила пользователю лишнего шага отмены, и написал тест на это. Оказалось, +что один `Ctrl+Z` убирает прямоугольник целиком **и с правкой, и без неё** — +`captureTimeout: 500` у `UndoManager` объединяет создание и перемещение в один +элемент стека. Проверка против непропатченного кода отличила реальный побочный +эффект от собственного домысла; тест переписан на то, что действительно важно — +что отпускание после сохранения не сдвигает элемент повторно. + +Сохранение оказалось не единственным читателем. Ревизия всех путей, достижимых +во время жеста, дала такую картину: + +| Читатель | Достижим во время жеста? | Итог | +|---|---|---| +| `saveBoard` (`Ctrl+S`) | Да | **Дефект**, исправлен в `7724e27` | +| Горячие клавиши: `Ctrl+D`, `Ctrl+C`, `Ctrl+X`, стрелки, `Delete` | Да | **Дефект**, исправлен в `f9411ed`. `Ctrl+D` при элементе на экране в `(280,280)` создавал копию от позиции «до» — она появлялась в `(120,120)`, рядом с прямоугольником, от которого пользователь уже уехал | +| Контекстное меню | Нет | Открывается долгим нажатием тем же указателем, что ведёт жест | +| Панели свойств, палитра цвета | Нет | Клик мышью, а кнопка удерживается жестом | +| Автоснимок по таймеру | Нет | Взводится только записью в документ, а во время жеста записей нет | +| Восстановление в IndexedDB | Нет | Обе точки вызова находятся внутри сохранения и открытия, уже после `flushGesture()` | +| BPMN-экспорт | Формально да | Координаты влияют только на раскладку, не на семантику модели | +| PNG-экспорт | — | Сериализует SVG, то есть отрисованное состояние, — расхождения нет по построению | + +Барьер поставлен **один раз** в начале клавиатурного обработчика, после ранних +выходов (ввод текста, панель симуляции, `Escape`), а не в каждой команде +отдельно: иначе следующая добавленная горячая клавиша унаследовала бы дефект. + +Вывод к совместной работе: как только появится сетевая синхронизация, читателей +станет больше, и «пока я тащу — коллеги видят старое положение» станет видимым +поведением. Варианта два: либо продолжать явно +фиксировать жест перед каждым чтением, либо транслировать промежуточные кадры +как presence-состояние (вне Y.Doc, без записи в историю). Второе ближе к тому, +как это делает Miro, и не ломает инвариант «один жест — один шаг отмены». + +--- + +## 17. Границы доверия: приведение типа вместо проверки + +У приложения два входа для чужих данных: системный буфер обмена и открываемый +`.mboard`-файл. Оба обязаны валидировать вход — и в основном валидируют: +`sanitiseElement` строга к `id`, `type`, координатам и проверяет по списку +`flowType`, `bpmnDurationDistribution`, тип элемента. + +Исключением оказался **`bpmnNodeType`**: в обоих местах он не проверялся, а +приводился (`as BpmnNodeType`). Приведение типа в TypeScript — это заявление +«я знаю, что здесь правильное значение», сделанное ровно там, где знать этого +нельзя. + +Почему это не косметика: Rust-движок десериализует поле в **строгий enum**. +Одно нераспознанное значение ломает разбор всей модели, поэтому один вставленный +или загруженный элемент оставлял доску с сообщением «Не удалось проверить +BPMN-модель.» — про **все** остальные узлы тоже. Проверено пробой до правки: +`sanitiseElement` возвращал `bpmnNodeType: 'totallyBogus'` без возражений. +Исправлено в `13ce33a`. + +Попутно: + +- Список допустимых значений существовал **в трёх копиях** (тип в + `board/types.ts`, приватная копия union в `format/mboard.ts`, и неявно — в + Rust). Теперь в TS он один, рядом с типом, с предикатом `isBpmnNodeType`; + `mboard.ts` импортирует его. Правило «format/ не импортирует App.tsx» + соблюдено: `board/types.ts` — модуль без зависимостей. +- Найдена ошибка в фикстуре теста: `bpmnNodeType: 'start'` вместо `'startEvent'`. + Она не падала только потому, что `preparePaste` не санитизирует, а приведение + типа в самом тесте затыкало компилятор. **Приведение в тестах прячет ошибки + ровно так же, как в коде.** +- JSON-схема `src/format/mboard.schema.json` **нигде не подключена** — это + документация, а не валидация. Полагаться на неё как на защиту нельзя. + +### Отдельно: как этот же фикс породил худший баг + +Первая версия правки (`13ce33a`) просто отбрасывала нераспознанный +`bpmnNodeType`. Движок она защитила, но **нарушила задокументированную гарантию +формата**: `docs/FORMAT.md` объявляет сохранение неизвестных данных свойством +v1 — файл, открытый более новой версией и сохранённый старой, не должен терять +то, чего старая не понимает. До правки `nodeType` от новой версии переживал +цикл load→save; после — молча удалялся из файла пользователя. + +Это хуже исходного дефекта. Отказ движка был **виден** (сообщение об ошибке +валидации), а потеря данных при сохранении — нет. + +Исправлено в `70ccde5`: значение не попадает в элемент в памяти, но хранится в +`elementExtras` и записывается обратно при сохранении. Попутно пришлось научить +`mergeUnknown` переносить сохранённый namespace `bpmn`, когда текущая версия не +произвела своих BPMN-полей, — иначе элемент, у которого единственным BPMN-полем +был непонятый `nodeType`, всё равно сохранялся как `profileData: {}`. + +Три вывода: + +1. **Отклонить значение и удалить значение — разные вещи.** Валидация на входе + защищает потребителя; она не даёт права стирать то, что принадлежит + пользователю. Для форматов с прямой совместимостью это означает «не + использовать, но сохранить». +2. **Регрессию нашло чтение `docs/FORMAT.md`, а не тест.** Ни один из 421 теста + не падал: гарантия была описана в документации, но не закреплена проверкой. + Теперь закреплена. +3. **Проба может лгать из-за неверного уровня вызова.** Первые попытки + проверить сохранение шли через `toDocElement` и показывали, что всё в + порядке. `elementExtras` применяются только в `serialise`, поэтому проверять + надо было полный цикл `deserialise`/`serialise`. + +### Правило: сначала выяснить, в чём именно вред + +Второй похожий случай — неизвестный `kind` (тип элемента) в загруженном +файле — решился **иначе**, и это различие важнее самих правок. + +| | `bpmnNodeType` | `kind` (тип элемента) | +|---|---|---| +| Что ломается | Rust-движок не десериализует enum → валидация всей модели падает | Ничего не падает | +| Прямая совместимость | **Терялась** после первой правки — пришлось восстанавливать | Работает: значение переживает load→save нетронутым | +| Реальный вред | Видимая ошибка валидации | Перекос вида: `fitTransform` читает `x`/`y` независимо от типа, и «призрак» в `(5000,5000)` рядом с реальным элементом в начале координат ронял масштаб с 8 до **0.12** — доска выглядит пустой, и причина не видна | +| Решение | Не пускать в память, но сохранить в `elementExtras` | Не трогать загрузку; исключить из `elementsInScope` | + +Отрисовка и `boundsOf` уже игнорируют неизвестный тип (оба возвращают `null`), +поэтому в выделении рамкой дефекта не было — только во «вписать в экран». Если +бы я по инерции применил к `kind` то же решение, что к `bpmnNodeType`, я бы +добавил отказ на загрузке там, где вреда от значения нет, и рисковал бы снова +испортить совместимость. + +**Правило:** прежде чем защищаться от недоверенного значения, установить, что +именно оно ломает. Ответ определяет уровень, на котором надо вмешаться: +валидация на входе, фильтрация у потребителя или ничего. + +Попутно закрыт долг: в `70ccde5` предикат `isElementType` был объявлен, но не +подключён, а `clipboard.ts` продолжал держать собственную копию списка — ровно +то дублирование, которое та правка устраняла. Исправлено в `338b610`. + +Вывод к совместной работе: удалённый участник — третий вход для недоверенных +данных, и он будет писать прямо в Y.Doc, минуя `sanitiseElement`. Значит +валидация должна применяться к входящим обновлениям, а не только к буферу и +файлу. Полезное правило для ревью: **`as` на данных из внешнего источника — +это пропущенная проверка**, и поиск по `as ` в путях загрузки/вставки стоит +сделать регулярным. diff --git a/docs/MIRO_FEATURE_GAP_ANALYSIS.md b/docs/MIRO_FEATURE_GAP_ANALYSIS.md new file mode 100644 index 0000000..04b37d3 --- /dev/null +++ b/docs/MIRO_FEATURE_GAP_ANALYSIS.md @@ -0,0 +1,251 @@ +# Функциональный паритет с Miro: разбор по категориям + +> Дата анализа: 2026-09-20. Все позиции проверены по исходникам (`src/App.tsx` и +> модули), ссылки на строки актуальны для коммита `6fe690d`. +> +> Смежный документ: [`COLLABORATION_ANALYSIS.md`](./COLLABORATION_ANALYSIS.md) — +> совместная работа, CRDT-схема, транспорты. Этот документ — про инструменты и +> возможности холста. + +Оценки: **S** ≤ 1 день · **M** 2-5 дней · **L** 1-2 недели · **XL** 2-4 недели. +Ценность: ★☆☆ приятно · ★★☆ заметно · ★★★ критично для восприятия «это доска». + +--- + +## 1. Сводка + +MiroBoard сегодня — это **BPMN-симулятор с прикрученным холстом**. Холст умеет +рисовать 8 типов объектов и сохранять их в файл. По инструментальному набору он +соответствует не Miro, а скорее раннему Excalidraw: достаточно для скетча, мало +для рабочей сессии команды. + +При этом три вещи у MiroBoard **сильнее**, чем у Miro, и их нельзя потерять в +погоне за паритетом: + +1. детерминированная симуляция процесса (Rust/WASM, Monte Carlo, SLA, очереди); +2. история изменений **внутри файла** с таймлайном и restore-as-append; +3. один HTML-файл, работающий с флешки без сети. + +Паритет нужен не «чтобы было как у Miro», а чтобы холст перестал быть узким +местом: сейчас пользователь упирается в отсутствие множественного выделения, +групп, поиска и изображений примерно на 30-й минуте работы. + +--- + +## 2. Матрица по категориям + +### 2.1 Инструменты рисования + +| Возможность | Miro | MiroBoard | Оценка | Ценность | +| --- | --- | --- | --- | --- | +| Перо (freehand) | ✅ | ✅ `pen` | — | — | +| Маркер (полупрозрачный толстый) | ✅ | ✅ `marker` (`stroke * 3`, `src/App.tsx:1356`) | — | — | +| Ластик | ✅ стирает часть штриха | ⚠️ удаляет **весь элемент** (`src/App.tsx:1173-1176`) | M | ★★☆ | +| Линия | ✅ | ✅ | — | — | +| Стрелка | ✅ с стилями/изгибом | ✅ прямая, один стиль наконечника | M | ★★☆ | +| Прямоугольник / круг | ✅ | ✅ | — | — | +| Треугольник, ромб, пятиугольник, звезда, «облако» | ✅ | ❌ | M | ★★☆ | +| Стили линии: пунктир, точка, толщина, изгиб | ✅ | ❌ (одна глобальная `strokeWidth`, `src/App.tsx:186`) | M | ★★★ | +| Текст | ✅ | ✅ `text` | — | — | +| Стикер | ✅ | ✅ `sticky`, 8 цветов | — | — | +| Эмодзи/реакции | ✅ | ✅ `emoji`, 20 штук | — | — | +| Указка/лазер | ✅ общий для всех | ⚠️ **локальный** (`laserPos` в useState, `src/App.tsx:243`) | M | ★★☆ | +| Пипетка (взять цвет с холста) | ✅ | ❌ | S | ★☆☆ | +| Заливка vs обводка раздельно | ✅ | ⚠️ один цвет красит и то и другое (`src/App.tsx:2342`) | M | ★★☆ | + +### 2.2 Объекты и контент + +| Возможность | Miro | MiroBoard | Оценка | Ценность | +| --- | --- | --- | --- | --- | +| Изображения (вставка, drop, resize, crop) | ✅ | ❌ (поле `assets` в формате пустое: `Record`, `src/format/types.ts:12`) | L | ★★★ | +| Иконки / библиотека стикеров | ✅ | ❌ (только 20 эмодзи) | M | ★☆☆ | +| Rich text: жирный, курсив, списки, ссылки | ✅ | ❌ плоская строка `content.text` | L | ★★★ | +| Размер/гарнитура шрифта на элемент | ✅ | ❌ | M | ★★☆ | +| Таблицы | ✅ | ❌ | L | ★☆☆ | +| Графики (bar/line/pie) | ✅ | ⚠️ только результат симуляции в модалке | M | ★☆☆ | +| Чек-листы / kanban-карточки | ✅ | ❌ (план есть: `docs/IDEON_INSPIRATION_PLAN.md:244,341`) | M | ★★☆ | +| Таймеры, прогресс-бары, кнопки голосования | ✅ | ❌ | M | ★★☆ | +| Видео/аудио/превью ссылок | ✅ | ❌ и **не надо** (требует сеть) | — | — | +| Поворот объекта | ✅ | ⚠️ поле `rotation` существует (`src/App.tsx:76`) и сохраняется в файл, но **UI-ручки нет** | S | ★★☆ | +| Прозрачность, тень, скругление | ✅ | ❌ | M | ★☆☆ | +| Формулы/LaTeX | ✅ | ❌ | M | ★☆☆ | + +### 2.3 Структура и организация + +| Возможность | Miro | MiroBoard | Оценка | Ценность | +| --- | --- | --- | --- | --- | +| Множественное выделение (marquee, Shift+клик) | ✅ | ❌ `selectedId: string \| null` (`src/App.tsx:188`) | M | ★★★ | +| Группы (Ctrl+G) | ✅ | ❌ | M | ★★★ | +| Фреймы/контейнеры, вложенность, коллапс | ✅ | ❌ (`parentId` зарезервирован в формате, всегда `null`: `src/format/mboard.ts:113`) | L | ★★★ | +| Выравнивание и распределение | ✅ | ❌ | M | ★★☆ | +| Умные направляющие (snap к краям/центрам) | ✅ | ⚠️ только сетка, и та выключена по умолчанию (`snapGrid = false`, `src/App.tsx:237`) | M | ★★★ | +| Порядок слоёв | ✅ | ✅ «на передний/задний план» (дискретно, через delete+push) | — | — | +| Блокировка объекта | ✅ | ❌ | S | ★★☆ | +| Копировать/вставить/дублировать | ✅ | ⚠️ только Ctrl+D для одного элемента (`src/App.tsx:1446`), буфера обмена нет | S | ★★★ | +| Поиск по холсту | ✅ | ❌ | M | ★★★ | +| Outline / дерево структуры | ✅ | ❌ | L | ★★☆ | +| Теги и фильтрация | ✅ | ❌ | M | ★☆☆ | +| Сохранённые виды (saved views) | ✅ | ❌ (запланировано на Phase 4) | M | ★☆☆ | + +### 2.4 Связи и граф + +| Возможность | Miro | MiroBoard | Оценка | Ценность | +| --- | --- | --- | --- | --- | +| Коннектор крепится к объекту и следует за ним | ✅ | ⚠️ **только** для BPMN-потоков: `bpmnEdgeAnchor` пересчитывает концы (`src/App.tsx:1657-1658`). Обычная стрелка — самостоятельный элемент с собственными `x/y/w/h` | L | ★★★ | +| Точки излома (waypoints) | ✅ | ⚠️ поле есть в формате (`src/format/types.ts:78`), в памяти и UI — нет | M | ★★☆ | +| Подписи на связях | ✅ | ⚠️ только для BPMN-условий/вероятностей (`src/App.tsx:1674-1682`) | S | ★★☆ | +| Типы связей (ассоциация, зависимость, причинность) | ✅ | ❌ (в формате `kind` есть, в UI один вид) | M | ★★☆ | +| Автомаршрутизация (обход препятствий) | ✅ | ❌ | L | ★☆☆ | +| Авто-раскладка (дерево, иерархия, radial) | ✅ | ❌ (запланировано в Phase 3, детерминированно в Rust) | L | ★★★ | + +**Замечание по производительности:** каждая стрелка делает +`renderedElements.find(...)` дважды за рендер (`src/App.tsx:1657-1658`) — это +O(n²) на доске с большим числом связей. Нужен индекс `Map` — правка +на пару строк, выигрыш заметный. + +### 2.5 Совместная работа + +Подробно — в `COLLABORATION_ANALYSIS.md`. Сводка: + +| Возможность | Miro | MiroBoard | Оценка | +| --- | --- | --- | --- | +| Живые курсоры и имена | ✅ | ❌ | M | +| Одновременное редактирование текста | ✅ | ❌ (последний победил — доказано экспериментом) | L | +| Комментарии и треды | ✅ | ❌ | L | +| Реакции/голоса на стикерах | ✅ | ❌ (эмодзи есть, но как отдельные элементы) | M | +| Follow / spotlight | ✅ | ❌ | M | +| Приватный режим / блокировки | ✅ | ❌ | M | +| История с авторством | ⚠️ платно, поверхностно | ✅ **сильнее**: снимки внутри файла, таймлайн, restore-as-append | — | +| Совместная работа без сервера | ❌ | 🎯 **возможность**, которой нет ни у кого: слияние `.mboard`-файлов | L | + +### 2.6 Презентация и фасилитация + +| Возможность | Miro | MiroBoard | Оценка | Ценность | +| --- | --- | --- | --- | --- | +| Presentation mode (пошаговый обход) | ✅ | ❌ | M | ★★☆ | +| Spotlight / «следите за мной» | ✅ | ❌ | M | ★☆☆ | +| Таймер для фасилитации | ✅ | ❌ | S | ★★☆ | +| Режим «не мешать» / приватный холст участника | ✅ | ❌ | L | ★☆☆ | +| Экспорт слайдов в PDF | ✅ | ❌ (есть PNG/SVG всего холста) | M | ★★☆ | + +### 2.7 Шаблоны, импорт, экспорт + +| Возможность | Miro | MiroBoard | Оценка | Ценность | +| --- | --- | --- | --- | --- | +| Галерея шаблонов | ✅ сотни | ⚠️ 6 образовательных BPMN-примеров (`src/App.tsx:63`, `examples/*.json`) + 2 стартовые доски | M | ★★★ | +| Ретро, канбан, customer journey, mind map, SWOT | ✅ | ❌ | M | ★★★ | +| Импорт изображений | ✅ | ❌ | L | ★★★ | +| Импорт CSV/JSON в стикеры | ✅ | ❌ | M | ★★☆ | +| Импорт BPMN 2.0 XML | ❌ | ✅ **уникально** (`import_bpmn_xml`, Rust) | — | — | +| Экспорт PNG / SVG | ✅ | ✅ (`src/App.tsx:871-888`) | — | — | +| Экспорт BPMN 2.0 XML с DI | ❌ | ✅ **уникально** | — | — | +| Экспорт Markdown / PDF / CSV | ✅ | ❌ | M | ★★☆ | +| Публикация ссылки / embed | ✅ | ❌ и **не надо** (требует сервер) | — | — | + +### 2.8 BPMN и симуляция — здесь MiroBoard выигрывает + +Не «паритет», а преимущество, которое надо сохранять и усиливать: + +- валидация семантики BPMN с **отказом** считать неподдерживаемые конструкции + (`validate_bpmn`, `wasm/board-core/src/lib.rs:563`); +- детерминированный token runner: XOR/AND split/join (`run_bpmn`, `lib.rs:1039`); +- seeded Monte Carlo: Min/Mean/σ/P50/P90/P95/Max, % SLA (`simulate_bpmn_seed_string`, `lib.rs:1162`); +- ресурсы: capacity, FIFO vs priority очереди, стоимость, utilization; +- рабочий календарь, arrival classes, визуализация токена и bottleneck на холсте; +- 6 учебных fixture'ов с объяснениями и проверками. + +У Miro ничего подобного нет. **Это и есть причина выбрать MiroBoard.** + +### 2.9 Платформа, производительность, доступность + +| Аспект | Состояние | Что делать | Оценка | +| --- | --- | --- | --- | +| Рендер | SVG, все элементы сразу, без кульминга вьюпорта | замерить на 500/1000/2000 узлах, потом viewport culling + `React.memo` | M | +| Bundle | 1.1 МБ один файл | бюджет в CI (сейчас не зафиксирован) | S | +| Настройки UI | тёмная тема, minimap, snap — **не сохраняются** между сессиями (`useState`, `src/App.tsx:198, 242, 237`); в localStorage только онбординг и author-id | сохранять в localStorage | S | +| Доступность | 4 `aria-*`/`role` атрибута на весь файл; клавиатурного создания объектов нет | фокус-менеджмент, ARIA на тулбаре, keyboard-complete editing (цель Phase 4) | L | +| Тач/планшет | pointer-события есть, но жестов (pinch-zoom двумя пальцами) не видно | проверить и доделать | M | +| Мобильная версия | нет | вне скоупа | — | +| Тесты | 148 unit + 88 e2e + 32 rust, все зелёные | держать планку, добавить тесты слияния и производительности | — | + +--- + +## 3. Быстрые победы (до 1 дня каждая, заметны сразу) + +1. **Сохранять настройки UI** (тема, minimap, snap, цвет, толщина) в + `localStorage` — сейчас при перезагрузке всё сбрасывается. +2. **Поворот объекта** — поле `rotation` уже есть в модели и в файле, не хватает + только ручки и применения `transform: rotate()` в рендере. +3. **Копировать/вставить** (Ctrl+C/V/X) через JSON в буфере обмена. +4. **Индекс элементов** `Map` для рендера стрелок — убирает O(n²). +5. **Включить умную привязку по умолчанию** (к краям/центрам соседей, не только + к сетке) — самое заметное улучшение «ощущения качества» холста. +6. **Раздельная заливка и обводка** в палитре (данные уже разделены в файле: + `style.color` и `style.fill`, `src/format/types.ts:34-38`). +7. **Персистентность стиля стрелки** (пунктир/толщина/наконечник) — в файле уже + есть `arrowHead` (`src/format/types.ts:68`). +8. **Поиск по тексту** (Ctrl+F) с переходом к элементу — даже без outline это + снимает главную боль на больших досках. +9. **Блокировка объекта** (защита от случайного перетаскивания). +10. **Пипетка** (EyeDropper API в Chromium). + +Суммарно ~неделя работы, и продукт ощущается заметно взрослее. + +--- + +## 4. Что НЕ гоним за Miro + +| Не делаем | Почему | +| --- | --- | +| Библиотека из сотен фигур и иконок | Бесконечная работа без дифференциации; 12-15 хорошо сделанных форм покрывают 95% задач | +| Видео/аудио, превью ссылок, web-embed | Требует сеть — прямое нарушение офлайн-обещания | +| Публикация по ссылке, embed-виджеты | Требует хостинг и аккаунты | +| Интеграции Jira/Slack/Notion/Google | Требуют серверной части и секретов; возможно позже как опциональные плагины с явным включением | +| Мобильные приложения | Вне скоупа | +| AI-генерация контента | Требует внешний API; возможна только как opt-in с ключом пользователя и явным предупреждением | +| Гонка за плавностью живых курсоров | См. раздел 8 `COLLABORATION_ANALYSIS.md` | + +--- + +## 5. Рекомендуемая последовательность + +``` +Неделя 1-2 Быстрые победы (раздел 3) ──────────────► продукт ощущается взрослее + │ +Неделя 2-5 Этап 0: множественное выделение, группы, буфер, origins, + декомпозиция App.tsx ─────────────────► предусловие для ВСЕГО + │ +Неделя 5-8 Этап 1: CRDT v2 (Y.Map/Y.Text, fractional index, + contentDoc/historyDoc) ────────────────► данные, пригодные к слиянию + │ + ├──────────────► Этап 2: async-коллаборация (комментарии, слияние файлов, + │ реакции, голоса) ──────────────► уникальное преимущество + │ + └──────────────► Этап 4: фреймы, изображения, rich text, коннекторы, + поиск/outline, шаблоны ────────► паритет по инструментам + +Неделя 8+ Этап 3: живой режим (BroadcastChannel → WebSocket relay → WebRTC) +``` + +Этап 0 и Этап 1 — не «скучная подготовка», а единственная последовательность, +при которой фреймы, группы и коннекторы не придётся переделывать дважды: все они +опираются на `parentId`, рёбра как сущности и множественное выделение. + +--- + +## 6. Критерии готовности по категориям + +- **Инструменты:** 15+ форм, стили линии (сплошная/пунктир/толщина), раздельные + заливка и обводка, поворот, ластик стирает часть штриха. +- **Структура:** множественное выделение, группы, фреймы с вложенностью и + коллапсом, выравнивание, умные направляющие, поиск, блокировка. +- **Связи:** коннектор крепится к объекту и следует за ним, waypoints, подписи, + 3+ типа связей, авто-раскладка (детерминированная, в Rust). +- **Контент:** изображения с дедупликацией по хэшу и предупреждением о размере, + rich text с совместимым редактированием, экспорт в Markdown. +- **Совместная работа:** слияние двух файлов без потери правок, комментарии, + атрибуция автора, живой режим через свой relay при нуле внешних запросов в + режиме по умолчанию. +- **Производительность:** 500 узлов ≥ 50 fps pan/zoom, замер в CI; бюджет размера + файла и бандла в CI. +- **Доступность:** создание и правка доски только с клавиатуры, подтверждено e2e. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb1dd9e..6ab3deb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -25,8 +25,27 @@ GitHub хранит опубликованную историю. Git и jj об undo. Rust/WASM содержит детерминированную доменную логику, React/Yjs — UI и совместную работу. Новые функции сначала получают учебный fixture и тест. +## Совместная работа и паритет с Miro + +Отдельный анализ (2026-09-20) с проверенными по исходникам фактами, оценками и +поэтапным планом: + +- [`COLLABORATION_ANALYSIS.md`](./COLLABORATION_ANALYSIS.md) — что мешает + совместной работе (включая экспериментальное доказательство потери правок в + текущей CRDT-схеме), целевая архитектура, транспорты, async-слияние файлов; +- [`MIRO_FEATURE_GAP_ANALYSIS.md`](./MIRO_FEATURE_GAP_ANALYSIS.md) — матрица + функционального паритета с Miro по категориям и быстрые победы; +- [`experiments/`](./experiments/) — воспроизводимые пробы, на которые ссылается анализ. + ## Production hardening -- Заменить публичные Yjs signaling-серверы управляемым собственным signaling; -- разделить большой UI-компонент и добавить browser-level тесты для Simulation; -- сохранять Git history в UI через build-time generated manifest, а не вручную. +- Публичные Yjs signaling-серверы уже удалены в Phase 1 (см. `docs/architecture.md`). + Актуальная задача — не «заменить» их, а ввести собственный opt-in транспорт + (`BroadcastChannel` → self-hosted WebSocket relay → WebRTC со своим signaling); +- разделить большой UI-компонент (`src/App.tsx`, 2534 строки) и добавить browser-level + тесты для Simulation; +- сохранять Git history в UI через build-time generated manifest, а не вручную; +- зафиксировать в CI бюджеты: размер `dist/index.html`, размер `.mboard` после + N правок, fps на доске из 500 узлов; +- привести `ACHIEVEMENTS.md` в соответствие с кодом (Canvas 2D → SVG, Zustand не + используется, «MessagePack-like» → JSON, размер бандла 2.1 МБ → 1.1 МБ). diff --git a/docs/experiments/README.md b/docs/experiments/README.md new file mode 100644 index 0000000..af438d9 --- /dev/null +++ b/docs/experiments/README.md @@ -0,0 +1,31 @@ +# Эксперименты / Probes + +Файлы здесь — **не часть тестового набора**. Корневой `vite.config.ts` включает в +Vitest только тесты из `src`, поэтому эти пробы не запускаются в CI и не влияют +на сборку и на `npm test`. + +Запуск вручную (нужен `npm ci`): + +```bash +npm run probe +``` + +Скрипт использует собственный конфиг `docs/experiments/vitest.config.ts`. + +## crdt-collaboration-probe.test.ts + +Измеряет, как текущая CRDT-схема (`Y.Array` + замена элемента +целиком в `src/persistence/updates.ts`) поведёт себя при реальном +многопользовательском редактировании, и сравнивает её со схемой +`Y.Map` + `Y.Text`. Результаты процитированы в +`docs/COLLABORATION_ANALYSIS.md`, раздел 2 «Блокеры». + +Тесты **A** и **B** намеренно падают: они документируют дефект, а не регрессию. +Поэтому `npm run probe` завершается с кодом 1 — это ожидаемо. Когда схема CRDT +будет переведена на `Y.Map`/`Y.Text` (Этап 1 плана), эти пробы надо перенести в +`src/` как постоянные тесты и обратить утверждения: дубликатов `id` быть не +должно, а параллельные правки текста обязаны сливаться. + +Тесты **C**, **D**, **E** проходят и дают метрики: рост размера update'а при +`gc:false`, стоимость поиска индекса в `commitElementUpdate`, и контрольное +поведение целевой схемы. diff --git a/docs/experiments/crdt-collaboration-probe.test.ts b/docs/experiments/crdt-collaboration-probe.test.ts new file mode 100644 index 0000000..56b547b --- /dev/null +++ b/docs/experiments/crdt-collaboration-probe.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import * as Y from 'yjs' +import { commitElementUpdate } from '../../src/persistence/updates' + +type El = { id: string; x: number; color: string; text?: string } + +function sync(a: Y.Doc, b: Y.Doc) { + Y.applyUpdate(b, Y.encodeStateAsUpdate(a, Y.encodeStateVector(b))) + Y.applyUpdate(a, Y.encodeStateAsUpdate(b, Y.encodeStateVector(a))) +} + +describe('probe: current CRDT shape under concurrency', () => { + it('A: two clients editing DIFFERENT fields of the same element', () => { + const a = new Y.Doc({ gc: false }); const b = new Y.Doc({ gc: false }) + const ya = a.getArray('elements'); const yb = b.getArray('elements') + a.transact(() => ya.push([{ id: 'n1', x: 0, color: 'red' }])) + sync(a, b) + // concurrent: A moves the node, B recolors it + commitElementUpdate(a, ya, 'n1', { x: 100 }) + commitElementUpdate(b, yb, 'n1', { color: 'blue' }) + sync(a, b) + const merged = ya.toArray() + console.log('A) merged element =', JSON.stringify(merged), 'count =', merged.length) + expect(merged.length).toBe(1) + }) + + it('B: concurrent text edits (plain string field vs Y.Text)', () => { + const a = new Y.Doc({ gc: false }); const b = new Y.Doc({ gc: false }) + const ya = a.getArray('elements'); const yb = b.getArray('elements') + a.transact(() => ya.push([{ id: 'n1', x: 0, color: 'red', text: 'hello world' }])) + sync(a, b) + commitElementUpdate(a, ya, 'n1', { text: 'hello world (A)' }) + commitElementUpdate(b, yb, 'n1', { text: 'hello WORLD (B)' }) + sync(a, b) + console.log('B) text after merge =', JSON.stringify(ya.toArray()[0].text), '| b sees:', JSON.stringify(yb.toArray()[0].text)) + expect(ya.toArray().length).toBe(1) + }) + + it('C: gc:false tombstone growth over 500 drag steps', () => { + const doc = new Y.Doc({ gc: false }) + const arr = doc.getArray('elements') + doc.transact(() => arr.push([{ id: 'n1', x: 0, color: 'red' }])) + const sizes: number[] = [] + for (let i = 1; i <= 500; i++) { + commitElementUpdate(doc, arr, 'n1', { x: i }) + if (i % 125 === 0) sizes.push(Y.encodeStateAsUpdate(doc).byteLength) + } + console.log('C) update bytes after 125/250/375/500 single-field moves =', sizes.join(' -> ')) + const gcDoc = new Y.Doc({ gc: true }) + const gcArr = gcDoc.getArray('elements') + gcDoc.transact(() => gcArr.push([{ id: 'n1', x: 0, color: 'red' }])) + for (let i = 1; i <= 500; i++) commitElementUpdate(gcDoc, gcArr, 'n1', { x: i }) + console.log('C) same with gc:true =', Y.encodeStateAsUpdate(gcDoc).byteLength, 'bytes') + expect(sizes.length).toBe(4) + }) + + it('D: cost of index lookup in commitElementUpdate at 2000 elements', () => { + const doc = new Y.Doc({ gc: false }) + const arr = doc.getArray('elements') + const many: El[] = Array.from({ length: 2000 }, (_, i) => ({ id: `n${i}`, x: i, color: 'red' })) + doc.transact(() => arr.push(many)) + const t0 = performance.now() + for (let i = 0; i < 60; i++) commitElementUpdate(doc, arr, `n${1000 + i}`, { x: i }) + const dt = performance.now() - t0 + console.log('D) 60 updates on a 2000-element board =', dt.toFixed(1), 'ms (', (dt / 60).toFixed(2), 'ms per update )') + expect(dt).toBeGreaterThanOrEqual(0) + }) + + it('E: what a Y.Map-per-element schema would do instead', () => { + const a = new Y.Doc(); const b = new Y.Doc() + const make = (d: Y.Doc) => { + const nodes = d.getMap>('nodes') + const n = new Y.Map(); n.set('id', 'n1'); n.set('x', 0); n.set('color', 'red') + const t = new Y.Text('hello world'); n.set('text', t) + d.transact(() => nodes.set('n1', n)) + return nodes + } + const na = make(a); const nb = make(b) + Y.applyUpdate(b, Y.encodeStateAsUpdate(a, Y.encodeStateVector(b))) + Y.applyUpdate(a, Y.encodeStateAsUpdate(b, Y.encodeStateVector(a))) + a.transact(() => { na.get('n1')!.set('x', 100) }) + b.transact(() => { nb.get('n1')!.set('color', 'blue') }) + sync(a, b) + const merged = na.get('n1')! + console.log('E) merged = x:', merged.get('x'), 'color:', merged.get('color'), '(both kept)') + // concurrent text + const a2 = new Y.Doc(); const b2 = new Y.Doc() + const m2a = make(a2); const m2b = make(b2) + sync(a2, b2) + a2.transact(() => { (m2a.get('n1')!.get('text') as Y.Text).insert(5, ' big') }) + b2.transact(() => { (m2b.get('n1')!.get('text') as Y.Text).insert(0, 'Well, ') }) + sync(a2, b2) + console.log('E) concurrent text merge =', JSON.stringify((m2a.get('n1')!.get('text') as Y.Text).toString())) + expect(true).toBe(true) + }) +}) diff --git a/docs/experiments/vitest.config.ts b/docs/experiments/vitest.config.ts new file mode 100644 index 0000000..976dbc9 --- /dev/null +++ b/docs/experiments/vitest.config.ts @@ -0,0 +1,16 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +// Standalone config for the probes in this folder. The root vite.config.ts only +// includes test files under src, so these experiments never run in CI — run them +// explicitly with `npm run probe`. +const repoRoot = path.resolve(__dirname, '../..') + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + root: repoRoot, + include: ['docs/experiments/**/*.test.ts'], + }, +}) diff --git a/package.json b/package.json index deffb50..f9834c3 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,12 @@ "preview": "vite preview", "typecheck": "tsc --noEmit", "lint": "eslint .", - "test": "node node_modules\\vitest\\vitest.mjs run", - "test:watch": "node node_modules\\vitest\\vitest.mjs watch", - "test:coverage": "node node_modules\\vitest\\vitest.mjs run --coverage", + "test": "vitest run", + "test:watch": "vitest watch", + "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", - "test:rust": "cargo test --manifest-path wasm/board-core/Cargo.toml" + "test:rust": "cargo test --manifest-path wasm/board-core/Cargo.toml", + "probe": "vitest run --config docs/experiments/vitest.config.ts --reporter=verbose" }, "dependencies": { "clsx": "2.1.1", diff --git a/playwright.config.ts b/playwright.config.ts index ef0b719..9a3c470 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,6 +5,10 @@ export default defineConfig({ testIgnore: ['**/baseline-capture.spec.ts', '**/bpmn-validation-regression-suite.spec.ts', '**/bpmn-token-execution-regression-suite.spec.ts', '**/bpmn-token-visibility.spec.ts', '**/bpmn-simulation-parameter-regression-suite.spec.ts', '**/bpmn-resource-metrics-regression-suite.spec.ts', '**/bpmn-topology-edge-case-suite.spec.ts', '**/bpmn-authoring-regression-suite.spec.ts', '**/bpmn-migration-invariance-gate.spec.ts', '**/cross-history-simulation-integration.spec.ts'], timeout: 60_000, workers: 1, + // The html report is self-contained, so CI can publish *which* tests failed + // without anyone having to open the job logs — see + // scripts/summarize-playwright-report.mjs. Both output directories are gitignored. + reporter: [['list'], ['html', { open: 'never' }]], // Playwright cannot reliably tear down its cmd.exe-owned Vite child on Windows. // Windows callers start and stop preview explicitly through services.yaml instead. webServer: process.platform === 'win32' ? undefined : { diff --git a/scripts/summarize-playwright-report.mjs b/scripts/summarize-playwright-report.mjs new file mode 100644 index 0000000..0c818dd --- /dev/null +++ b/scripts/summarize-playwright-report.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node +/** + * Prints a compact pass/fail summary of an HTML Playwright report. + * + * CI job logs live in blob storage that is not always reachable (for example + * from a sandbox), but the job *summary* page is. This script unpacks the + * self-contained `playwright-report/index.html` and prints which tests failed + * and why, so the summary answers the only question that matters after a red + * run. Used by the 'Summarize end-to-end results' step in + * `.github/workflows/ci.yml`. + * + * Usage: node scripts/summarize-playwright-report.mjs [reportDir] [--annotations] [--contexts] [--page=N] + * + * With --annotations it prints GitHub workflow commands (`::error::`) instead of + * prose. That is not a convenience: annotations are the only CI channel this + * repo can actually read back, because both job logs and run artifacts are + * served from blob storage that is unreachable from a sandbox, while + * annotations come from the ordinary REST API. + * + * Always exits 0 — summarising must never change the job conclusion. + */ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { inflateRawSync } from 'node:zlib' + +const args = process.argv.slice(2) +const annotationsOnly = args.includes('--annotations') +// --contexts prints the aria snapshot Playwright attaches to a failure, which is +// the closest thing to "what did the page look like" that survives without log +// access: it shows the toolbar state and any toast next to the failed expect. +const contextsOnly = args.includes('--contexts') +// GitHub keeps at most 10 annotations of a level per step, so CI runs this +// script once per page from a step of its own. +const pageArg = args.find(arg => arg.startsWith('--page=')) +const annotationPage = Math.max(1, Number(pageArg?.slice('--page='.length) ?? 1)) +const reportDir = args.find(arg => !arg.startsWith('--')) ?? 'playwright-report' +const indexPath = join(reportDir, 'index.html') + +function giveUp(reason) { + console.log(`could not summarise: ${reason}`) + process.exit(0) +} + +if (!existsSync(indexPath)) giveUp(`no ${indexPath} (is the html reporter enabled?)`) + +const html = readFileSync(indexPath, 'utf8') +// Playwright embeds the report as a zip inside