Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
a7edc7c
docs: анализ совместной работы и паритета с Miro + воспроизводимая CR…
Sep 20, 2026
8220d45
feat(collab): явные origins транзакций и allow-list для UndoManager
Sep 20, 2026
5712ba9
feat(collab): Этап 0.1-0.2 — профиль участника и множественное выделение
Sep 20, 2026
a4edafb
ci: publish which Playwright tests failed
xodapi Sep 20, 2026
d398df5
ci: generate the HTML report so failures can be named
xodapi Sep 20, 2026
644295c
style: keep the repo's formatting in the files CI diagnostics touched
xodapi Sep 20, 2026
2016a29
test: turn on React's act environment
xodapi Sep 20, 2026
34b7fe4
test(e2e): boot the multi-select suite like every other spec
xodapi Sep 20, 2026
8aa89c5
feat(collab): Этап 0.3 — буфер обмена Ctrl+C/V/X
xodapi Sep 20, 2026
3d8770f
ci: name failing e2e tests through annotations
xodapi Sep 21, 2026
2d8989a
fix(e2e): repair the spec that has been failing CI on main
xodapi Sep 21, 2026
ee829ab
fix(e2e): wait out the undo capture window before pasting
xodapi Sep 21, 2026
efe6978
ci: page the annotations and add a temporary baseline job
xodapi Sep 21, 2026
c4d476b
ci: bisect which commit broke 'Сохранено' after a save
xodapi Sep 21, 2026
98d441c
ci: report build failures and the page state at each e2e failure
xodapi Sep 21, 2026
ee6715e
fix(persistence): opening a document no longer reports unsaved changes
xodapi Sep 21, 2026
2e5cebc
test(e2e): drive multi-selection and clipboard with trusted mouse input
xodapi Sep 21, 2026
04688a7
ci: retire the bisect job
xodapi Sep 21, 2026
d056b72
docs: record the Stage 0 status honestly and the origins regression l…
xodapi Sep 21, 2026
8366079
build: update dist for the dirty-tracker fix
xodapi Sep 21, 2026
9a76785
fix(board): finish a gesture where the pointer was released
xodapi Sep 21, 2026
2a7ad3e
ci: temporary diagnostic for the 'Сохранено' failures
xodapi Sep 21, 2026
300c41a
ci: extend the diagnostic to the marquee selection
xodapi Sep 21, 2026
ed24cbd
fix: keep a saved document clean and let the marquee start anywhere
xodapi Sep 21, 2026
21dc78f
docs: record what actually broke CI in Stage 0.4
xodapi Sep 21, 2026
5115e19
refactor(0.5): extract the modals and the selection property panels
xodapi Sep 21, 2026
70edb6d
refactor(0.5): extract the overflow menu and the context menu
xodapi Sep 21, 2026
7ea0583
refactor(0.5): extract the bottom toolbar and delete leftover debug l…
xodapi Sep 21, 2026
5d132f9
refactor(0.5): extract the profile panel, the tour and the toast
xodapi Sep 21, 2026
ca27265
refactor(0.5): extract the board header
xodapi Sep 21, 2026
296d435
refactor(0.5): extract the zoom controls and the element count
xodapi Sep 21, 2026
92f5a40
refactor(0.5): extract the canvas background
xodapi Sep 21, 2026
a2f08e8
docs: record the state of the 0.5 decomposition
xodapi Sep 21, 2026
cea3ebb
refactor(0.5): hold the simulation inputs as one value
xodapi Sep 21, 2026
f0c35d1
refactor(0.5): move the viewport maths out of the component
xodapi Sep 21, 2026
29ab496
refactor(0.5): give the element decorations one definition each
xodapi Sep 21, 2026
ec9d0af
docs: refresh the 0.5 status after the viewport and settings extractions
xodapi Sep 21, 2026
851981e
feat(0.6): route every document write through a command layer
xodapi Sep 21, 2026
f28168f
refactor(0.6): drop replaceAll, which had no callers
xodapi Sep 21, 2026
21e227e
docs: mark 0.6 done and restate what 0.5 can still reach
xodapi Sep 21, 2026
1382c3c
fix(bpmn): stop unparseable input from zeroing simulation numbers
xodapi Sep 21, 2026
d1dab69
fix(preview): stop read-only boards from offering a text editor
xodapi Sep 21, 2026
3854906
fix(preview): stop Ctrl+A from selecting the hidden live document
xodapi Sep 21, 2026
37b1c37
docs: record the read-only-UI defect class found in preview mode
xodapi Sep 21, 2026
1c2c6fd
fix(preview): stop the empty-board prompt from covering a snapshot
xodapi Sep 21, 2026
be0e1ca
fix(preview): hide the BPMN validity badge while previewing history
xodapi Sep 21, 2026
cef2b2d
docs: extend the read-only-UI section with the two further cases
xodapi Sep 21, 2026
c67ac70
fix(collab): credit a duplicate to whoever duplicated it
xodapi Sep 21, 2026
688f5ca
docs: record the createdBy attribution gap and the debug-hook testing…
xodapi Sep 21, 2026
1b0dc3e
docs(origins): correct the NON_EDIT_ORIGINS comment and pin the null …
xodapi Sep 21, 2026
d2df908
fix(history): stop opening a document from arming an auto checkpoint
xodapi Sep 21, 2026
999d2a9
docs: record the origins lesson — the rule belongs in one place
xodapi Sep 21, 2026
308a680
fix(selection): close a context menu whose element has vanished
xodapi Sep 21, 2026
8415837
docs: record the vanishing-element audit of id-holding state
xodapi Sep 21, 2026
ed9fb8a
docs: complete the id-holding audit — it listed five states, not seven
xodapi Sep 21, 2026
7724e27
fix(save): commit an in-flight drag before serialising the document
xodapi Sep 21, 2026
68da501
docs: record the in-flight gesture window and what it means for sync
xodapi Sep 21, 2026
f9411ed
fix(gesture): flush an in-flight drag before any keyboard command
xodapi Sep 21, 2026
b3b7818
docs: complete the in-flight gesture reader audit
xodapi Sep 21, 2026
13ce33a
fix(format): validate bpmnNodeType from the clipboard and from opened…
xodapi Sep 21, 2026
8e7de04
docs: record the trust-boundary lesson about casting instead of checking
xodapi Sep 21, 2026
70ccde5
fix(format): stop the bpmnNodeType guard from deleting data it rejects
xodapi Sep 21, 2026
7fb91a7
docs: record the regression the trust-boundary fix introduced
xodapi Sep 21, 2026
338b610
fix(viewport): keep undrawable elements out of fit-to-content
xodapi Sep 21, 2026
232bcc5
docs: contrast the two unknown-value decisions and the rule behind them
xodapi Sep 21, 2026
a0ccef4
ci: run the unit suite, and make npm test work off Windows
xodapi Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
30 changes: 15 additions & 15 deletions dist/index.html

Large diffs are not rendered by default.

1,034 changes: 1,034 additions & 0 deletions docs/COLLABORATION_ANALYSIS.md

Large diffs are not rendered by default.

251 changes: 251 additions & 0 deletions docs/MIRO_FEATURE_GAP_ANALYSIS.md

Large diffs are not rendered by default.

25 changes: 22 additions & 3 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 МБ).
31 changes: 31 additions & 0 deletions docs/experiments/README.md
Original file line number Diff line number Diff line change
@@ -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<BoardElement>` + замена элемента
целиком в `src/persistence/updates.ts`) поведёт себя при реальном
многопользовательском редактировании, и сравнивает её со схемой
`Y.Map<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`, и контрольное
поведение целевой схемы.
96 changes: 96 additions & 0 deletions docs/experiments/crdt-collaboration-probe.test.ts
Original file line number Diff line number Diff line change
@@ -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<El>('elements'); const yb = b.getArray<El>('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<El>('elements'); const yb = b.getArray<El>('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<El>('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<El>('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<El>('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<Y.Map<unknown>>('nodes')
const n = new Y.Map<unknown>(); 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)
})
})
16 changes: 16 additions & 0 deletions docs/experiments/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
})
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 : {
Expand Down
Loading
Loading