chore(frontend): real ESLint instead of a placeholder; fix hook order (FLAW-19)

eslint.config.js with the recommended JS/TypeScript rules and the React
hooks rules; npm run lint runs ESLint and is part of the image build.
Errors fail the build, the 24 exhaustive-deps findings stay warnings.

It found two real bugs: TaskDetailModal and ManageColumnsModal called
their hooks after 'if (!isOpen) return null', so the hook count changed
whenever the dialog opened or closed. Both are now a thin wrapper plus a
content component. Also removes unused imports and dead code (old task
list share handlers, an unused realtime status, an unused rollup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 15:02:52 +01:00
1 parent 6b56a33500
commit 4c50d15915
19 files changed
+1360 -140

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ RUN npm install --no-audit --no-fund
COPY frontend/ ./ COPY frontend/ ./
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev" # Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
ARG APP_VERSION="" ARG APP_VERSION=""
RUN npx tsc --noEmit && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build RUN npx tsc --noEmit && npm run lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
# ============================================================================== # ==============================================================================
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer) # Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
+30
View File
@@ -0,0 +1,30 @@
// Lint rules for the frontend (FLAW-19): real mistakes are errors, style is left to the code.
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'coverage'] },
{
files: ['src/**/*.{ts,tsx}'],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: { 'react-hooks': reactHooks },
rules: {
...reactHooks.configs.recommended.rules,
// Error payloads and browser APIs without types are handled as any on purpose
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }],
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
{
files: ['public/sw.js'],
languageOptions: { globals: globals.serviceworker },
extends: [js.configs.recommended],
},
);
+1301 -4
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -8,7 +8,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "echo 'Linting passed'", "lint": "eslint .",
"test": "vitest", "test": "vitest",
"test:run": "vitest run" "test:run": "vitest run"
}, },
@@ -22,17 +22,22 @@
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.17.0",
"@testing-library/dom": "^10.4.2", "@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3", "@testing-library/react": "^16.3.3",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "9.17.0",
"eslint-plugin-react-hooks": "5.1.0",
"fake-indexeddb": "^6.2.6", "fake-indexeddb": "^6.2.6",
"globals": "15.14.0",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"typescript-eslint": "8.18.2",
"vite": "^5.4.11", "vite": "^5.4.11",
"vitest": "^2.1.9" "vitest": "^2.1.9"
} }
+1 -1
View File
@@ -470,7 +470,7 @@ export const App: React.FC = () => {
.getThemes() .getThemes()
.then((themes) => handleCustomThemesChange(themes)) .then((themes) => handleCustomThemesChange(themes))
.catch(() => {}); .catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]); }, [user?.id]);
const refreshAll = useCallback(async () => { const refreshAll = useCallback(async () => {
@@ -6,7 +6,6 @@ import {
ShoppingCart, ShoppingCart,
X, X,
Loader2, Loader2,
ArrowRight,
CornerDownLeft, CornerDownLeft,
} from 'lucide-react'; } from 'lucide-react';
import { SearchHit } from '../lib/types'; import { SearchHit } from '../lib/types';
-5
View File
@@ -6,7 +6,6 @@ import {
CheckSquare, CheckSquare,
Keyboard, Keyboard,
Pin, Pin,
FolderTree,
Sparkles, Sparkles,
Command, Command,
Users, Users,
@@ -19,14 +18,10 @@ import {
Lock, Lock,
ArrowLeft, ArrowLeft,
X, X,
Smartphone,
RotateCcw,
Heading1, Heading1,
ListTodo, ListTodo,
Code, Code,
Download, Download,
Flame,
Check,
} from 'lucide-react'; } from 'lucide-react';
import { AboutModal } from '../../components/AboutModal'; import { AboutModal } from '../../components/AboutModal';
@@ -7,10 +7,9 @@ import {
Radio, Radio,
Sparkles, Sparkles,
X, X,
Hash,
ShoppingCart, ShoppingCart,
} from 'lucide-react'; } from 'lucide-react';
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types'; import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
@@ -502,7 +501,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
if (!sourceItem) return; if (!sourceItem) return;
const targetCatNormalized = targetCategory.trim(); const targetCatNormalized = targetCategory.trim();
let newCategory = targetCatNormalized; const newCategory = targetCatNormalized;
let newContent = sourceItem.content; let newContent = sourceItem.content;
if (!targetCatNormalized) { if (!targetCatNormalized) {
@@ -14,7 +14,6 @@ import {
AlertCircle, AlertCircle,
Palette, Palette,
Upload, Upload,
Camera,
Pencil, Pencil,
Copy, Copy,
} from 'lucide-react'; } from 'lucide-react';
@@ -8,7 +8,6 @@ import {
RotateCcw, RotateCcw,
Check, Check,
Columns, Columns,
Layers,
} from 'lucide-react'; } from 'lucide-react';
import { KanbanColumn } from '../../lib/types'; import { KanbanColumn } from '../../lib/types';
import { triggerHaptic } from '../../lib/api'; import { triggerHaptic } from '../../lib/api';
@@ -28,15 +27,17 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
{ id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' }, { id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' },
]; ];
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = ({ // Wrapper and content split so the hooks always run in the same number (see TaskDetailModal)
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = (props) =>
props.isOpen ? <ManageColumnsModalContent {...props} /> : null;
const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
isOpen, isOpen,
onClose, onClose,
initialColumns, initialColumns,
onSave, onSave,
listTitle, listTitle,
}) => { }) => {
if (!isOpen) return null;
const [columns, setColumns] = useState<KanbanColumn[]>(() => { const [columns, setColumns] = useState<KanbanColumn[]>(() => {
return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS; return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS;
}); });
@@ -7,19 +7,16 @@ import {
FileText, FileText,
Plus, Plus,
CheckSquare, CheckSquare,
Clock,
Layers,
Hash, Hash,
AlertTriangle, AlertTriangle,
Flame, Flame,
ChevronRight,
Folder, Folder,
Webhook, Webhook,
Lock, Lock,
UserPlus, UserPlus,
Repeat, Repeat,
} from 'lucide-react'; } from 'lucide-react';
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types'; import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar'; import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete'; import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api'; import { triggerHaptic } from '../../lib/api';
@@ -51,9 +48,13 @@ interface TaskDetailModalProps {
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
} }
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({ // The wrapper decides whether the dialog is shown; the content holds all hooks, so their number never
// changes between renders (hooks after an early return break React when isOpen flips)
export const TaskDetailModal: React.FC<TaskDetailModalProps> = (props) =>
props.isOpen && props.task ? <TaskDetailModalContent {...props} task={props.task} /> : null;
const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }> = ({
task, task,
isOpen,
onClose, onClose,
activeColumns, activeColumns,
taskLists, taskLists,
@@ -75,8 +76,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onDeleteTask, onDeleteTask,
readOnly = false, readOnly = false,
}) => { }) => {
if (!isOpen || !task) return null;
const [titleDraft, setTitleDraft] = useState(task.content); const [titleDraft, setTitleDraft] = useState(task.content);
const [isEditingTitle, setIsEditingTitle] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false);
@@ -144,7 +143,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
const handleAddTagSubmit = async (e: React.FormEvent) => { const handleAddTagSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
let cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase(); const cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
if (!cleaned) return; if (!cleaned) return;
const currentTags = Array.isArray(task.tags) ? [...task.tags] : []; const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
if (!currentTags.includes(cleaned)) { if (!currentTags.includes(cleaned)) {
@@ -177,11 +176,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onUpdateDueDate(task, joinDue(addDays(daysOffset), splitDue(task.dueDate).time)); onUpdateDueDate(task, joinDue(addDays(daysOffset), splitDue(task.dueDate).time));
}; };
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
(task.isCompleted
? activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1]
: activeColumns.find((c) => !c.isDone) || activeColumns[0]);
const subtasks = Array.isArray(task.subtasks) ? task.subtasks : []; const subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
const completedSubtasks = subtasks.filter((s) => s.isCompleted).length; const completedSubtasks = subtasks.filter((s) => s.isCompleted).length;
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0; const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
+2 -63
View File
@@ -15,19 +15,18 @@ import {
Layers, Layers,
Users, Users,
CheckSquare, CheckSquare,
User as UserIcon, User as
Lock, Lock,
Hash, Hash,
AlertTriangle, AlertTriangle,
Flame, Flame,
Settings, Settings,
ChevronDown,
Folder, Folder,
Filter, Filter,
Webhook, Webhook,
Repeat, Repeat,
} from 'lucide-react'; } from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types'; import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due'; import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
import { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
@@ -92,10 +91,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
// Share TaskList modal state // Share TaskList modal state
const [showShareListModal, setShowShareListModal] = useState(false); const [showShareListModal, setShowShareListModal] = useState(false);
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null); const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
const [shareLoading, setShareLoading] = useState(false);
// Detail modal state // Detail modal state
const [detailTaskId, setDetailTaskId] = useState<string | null>(null); const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
@@ -854,8 +849,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
const handleOpenShareModal = async (tl: TaskList) => { const handleOpenShareModal = async (tl: TaskList) => {
setSharingTaskList(tl); setSharingTaskList(tl);
setSelectedShareTargetId('');
setSelectedShareUser(null);
setShowShareListModal(true); setShowShareListModal(true);
try { try {
const [freshTeams, freshUsers, freshShares] = await Promise.all([ const [freshTeams, freshUsers, freshShares] = await Promise.all([
@@ -871,60 +864,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
} }
}; };
const handleAddShare = async () => {
if (!sharingTaskList) return;
const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id;
if (!targetId) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.addTaskListShare(sharingTaskList.id, {
targetType: addShareType,
targetId: targetId,
});
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
setSelectedShareTargetId('');
setSelectedShareUser(null);
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
} finally {
setShareLoading(false);
}
};
const handleRemoveShare = async (shareId: string) => {
if (!sharingTaskList) return;
if (!window.confirm('Freigabe wirklich aufheben?')) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.removeTaskListShare(sharingTaskList.id, shareId);
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Entfernen der Freigabe');
} finally {
setShareLoading(false);
}
};
const handleSaveColumns = async (newColumns: KanbanColumn[]) => { const handleSaveColumns = async (newColumns: KanbanColumn[]) => {
triggerHaptic(15); triggerHaptic(15);
if (currentSelectedList && selectedListId !== 'all') { if (currentSelectedList && selectedListId !== 'all') {
@@ -1,4 +1,4 @@
import React, { useState, useMemo, useRef, useEffect } from 'react'; import React, { useState, useMemo, useRef } from 'react';
import Prism from 'prismjs'; import Prism from 'prismjs';
import 'prismjs/components/prism-python'; import 'prismjs/components/prism-python';
import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-typescript';
@@ -14,7 +14,6 @@ import {
Minimize2, Minimize2,
Sparkles, Sparkles,
Plus, Plus,
Search,
Check, Check,
CloudOff, CloudOff,
AlertTriangle, AlertTriangle,
@@ -97,7 +97,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
block, block,
index, index,
isLast, isLast,
maxAllowedLevel,
hasChildren, hasChildren,
isFocused, isFocused,
canEdit, canEdit,
@@ -402,7 +402,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item // Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/); const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/); const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/);
if (bulletMatch || numMatch) { if (bulletMatch || numMatch) {
pushCurrentItem(); pushCurrentItem();
afterHeading = false; afterHeading = false;
@@ -18,15 +18,11 @@ import {
ExternalLink, ExternalLink,
Sparkles, Sparkles,
ArrowLeft, ArrowLeft,
CheckSquare,
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, PanelLeftOpen,
ChevronLeft,
Radio,
Clock, Clock,
AlertTriangle, AlertTriangle,
Loader2, Loader2,
Lock,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Document, Document,
@@ -44,10 +40,6 @@ import {
api, api,
onWebSocketMessage, onWebSocketMessage,
triggerHaptic, triggerHaptic,
onRealtimeStatus,
isRealtimeConnected,
getRealtimeTransport,
RealtimeTransport,
} from '../../lib/api'; } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { FormSheet } from '../../components/FormSheet'; import { FormSheet } from '../../components/FormSheet';
@@ -347,7 +339,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null); const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null);
const [newFolderParentPath, setNewFolderParentPath] = useState<string>(''); const [newFolderParentPath, setNewFolderParentPath] = useState<string>('');
const [newFolderName, setNewFolderName] = useState<string>(''); const [newFolderName, setNewFolderName] = useState<string>('');
const [newFolderIsShared, setNewFolderIsShared] = useState<boolean>(false);
const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => { const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => {
setNewFolderParentPath(parentPath); setNewFolderParentPath(parentPath);
@@ -381,19 +372,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}); });
}, []); }, []);
// Realtime connection status for top header indicator (identical to SharedListsView)
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved'); const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
const [docSaveError, setDocSaveError] = useState<string | null>(null); const [docSaveError, setDocSaveError] = useState<string | null>(null);
useEffect(() => {
const unsub = onRealtimeStatus((connected, transport) => {
setRealtimeConnected(connected);
setRealtimeTransport(transport);
});
return unsub;
}, []);
// Drag and Drop state // Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null); const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
@@ -960,22 +941,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return map; return map;
}, [allRollupTodos]); }, [allRollupTodos]);
const rollupTodosByNote = useMemo(() => {
const map = new Map<
string,
{ doc: Document; items: { doc: Document; blockId: string; content: string; checked: boolean }[] }
>();
for (const item of allRollupTodos) {
const existing = map.get(item.doc.id);
if (existing) {
existing.items.push(item);
} else {
map.set(item.doc.id, { doc: item.doc, items: [item] });
}
}
return map;
}, [allRollupTodos]);
const totalOpenTodos = useMemo(() => { const totalOpenTodos = useMemo(() => {
let count = 0; let count = 0;
for (const d of docs) { for (const d of docs) {
+1 -2
View File
@@ -17,7 +17,6 @@ import {
SharedFolder, SharedFolder,
FolderShare, FolderShare,
Space, Space,
SpaceMember,
SpaceShare, SpaceShare,
SystemSettings, SystemSettings,
SharingMode, SharingMode,
@@ -1544,7 +1543,7 @@ export async function connectWebSocket(): Promise<void> {
} }
}; };
ws.onerror = (e) => { ws.onerror = () => {
clearTimeout(wsConnectionWatchdog); clearTimeout(wsConnectionWatchdog);
switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)'); switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
}; };
@@ -49,7 +49,7 @@ describe('diffFlat', () => {
return Math.floor((seed / 2147483648) * n); return Math.floor((seed / 2147483648) * n);
}; };
let current: DocumentBlock[] = [B('x0', 0)]; let current: DocumentBlock[] = [B('x0', 0)];
let d = build(current); const d = build(current);
for (let step = 0; step < 300; step++) { for (let step = 0; step < 300; step++) {
const next = current.map((b) => ({ ...b })); const next = current.map((b) => ({ ...b }));
const i = rnd(next.length); const i = rnd(next.length);