DFiP_Budget_planing/docs/superpowers/plans/2026-10-28-aurora-1839-cell-memo.md
Andrey Epifancev a87cb57728 docs(plan): aurora-1839 — план реализации
План: 6 задач исполнителей (haiku: 1–2, sonnet: 3–6), финальное ревью opus,
PR в feat/226-aurora-1837-cell-styles-css и точка ожидания замеров.
Решения стадии плана дописаны в design.md (13–17).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:00:07 +04:00

841 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AURORA-1839 — мемоизация ячеек таблицы листа: план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Ветка:** `feat/234-aurora-1839-cell-memo` (Gitea issue #234, Jira AURORA-1839).
**База стека:** `feat/226-aurora-1837-cell-styles-css` (AURORA-1837, PR #230), которая стоит на `feat/211-aurora-1836-row-measure` (AURORA-1836, PR #222). PR этой задачи — в `feat/226-aurora-1837-cell-styles-css`. После слияния #230 в `test` PR перенацеливается на `test` с rebase.
**Worktree:** `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1839` (индекс codegraph `.codegraph/` уже есть).
**Goal:** включить `memoMode: 'cells'` в `RealtimeTable` с явным сбросом мемоизации по раскладке закреплённых колонок и фильтрам, чтобы прокрутка не перерисовывала оставшиеся на экране ячейки.
**Architecture:** чистая функция ключа сброса (`utils/cellMemoReset.js`) превращает раскладку закреплённых колонок и фильтры в строку. `RealtimeTable` строит из неё новый массив `tableColumns = [...columns]` и передаёт его в MRT. Новый массив даёт новые объекты `cell`, и мемоизированные ячейки перерисовываются. Перетаскивание ширины в собственном `ColumnResizer` сообщает MRT `columnSizingInfo.isResizingColumn`. Пока флаг стоит, MRT замораживает тело таблицы, а ширины закреплённых колонок попадают в ключ один раз, по отпусканию мыши. `columnFilters` и `columnSizingInfo` становятся управляемым состоянием `RealtimeTable`.
**Tech Stack:** React 18 (JSX), material-react-table 3.2.1, @tanstack/table-core 8.20.5, `node --test`, Biome 1.9.4; стенд `perf/e2e` — Playwright 1.64.1 (TypeScript).
**Spec:** `openspec/changes/aurora-1839-cell-memo/` (`proposal.md`, `design.md`, `specs/sheet-table/spec.md`). Исполнитель каждой задачи читает `design.md` (разделы «Контекст», «Решения дизайна», «Контракт») и требования spec, на которые ссылается задача.
## Global Constraints
- Зависимости не меняются: `material-react-table` 3.2.1, `@tanstack/table-core` 8.20.5 из `web/package-lock.json`. `npm install` не запускать: `web/node_modules` — симлинк.
- Код фронта — JavaScript (JSX), отступ табами, Biome (`web/biome.json`): `noNestedTernary` = error, `noUnusedVariables`/`noUnusedImports` = error, `useExhaustiveDependencies` = info.
- Правки только в `web/src/components/RealtimeTable/**`, `perf/e2e/**`, `perf/README.md` и `openspec/changes/aurora-1839-cell-memo/design.md`. Не трогать: `Cell/**` (это AURORA-1890, 1892, 1894), `tableColumns.jsx` (D5), `SummaryTable.jsx`, `Svod2Page.jsx`, `api/**`.
- Ключи localStorage настроек колонок (`useColumnSettings.js`) не меняются.
- Ключ сброса MUST меняться: закрепление и открепление колонки; скрытие и показ закреплённой; ширина закреплённой (по отпусканию мыши); `globalFilter`; значение фильтра колонки. Ключ MUST NOT меняться: ширина незакреплённой; скрытие и показ незакреплённой; изменение данных; пока ширину тянут.
- Пороги производительности (≤ 25 мс на шаг по `y` и др.) — цель, а не условие слияния. Корректность (контракт, закреплённые колонки, подсветки, состояние ячеек) — жёсткое условие.
- Контракт (spec, требование «Контракт для правок таблицы листа»): рендер ячейки зависит только от `cell`. Изменяемое состояние не замыкается в `muiTableBodyCellProps`, `col.Cell`, `col.Edit`. Прокрутка не вызывает `setState` в `RealtimeTable`.
- Коммиты — в стиле репозитория (`perf(table): …`, `perf(e2e): …`, `docs(…): …`), по-русски, `git add` конкретных файлов. Последняя строка сообщения: `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Правила исполнения волны 2 (обязательны для контроллера и всех сабагентов)
Контроллер передаёт этот раздел целиком в бриф каждого исполнителя и ревьюера.
**Замок ночного прогона.** С ~01:00 на машине идут замеры производительности, любая нагрузка на CPU их портит.
- Замок: `/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK`.
- Перед каждым тяжёлым шагом нужно дождаться снятия замка: `node --test`, `npm run build`, biome по каталогу, запуск фронта или API, docker, pytest, alembic. Команда ожидания (запускать с большим timeout или в фоне с уведомлением, при коротком timeout повторять):
```bash
L=/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK; while [ -e "$L" ]; do sleep 60; done
```
Ниже в шагах это обозначено как **«[замок]»**.
- При замке можно: читать код, писать код, `npx biome check` по 1–3 файлам, `node --check` по одному файлу, git-коммиты и push.
- Замеры стенда `perf/` не запускать вообще: БД `perf` в perf-pg, порты 8000/8080/8081/8082, любые `npx playwright test` в `perf/e2e`. Их делает главный контроллер. `globalSetup` Playwright в `perf/e2e` сбрасывает `pg_stat_statements` на БД стенда, поэтому даже самопроверки `perf/e2e/tests/*.spec.ts` не запускать.
**Окружение.**
- Фронт: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `npx biome check <файлы>`, `npm run build`.
- Карта кода: перед правкой исполнитель вызывает `mcp__codegraph__codegraph_explore` по своей области с `projectPath` = `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1839`. Если ответ пуст, это один раз отмечается в ledger как `O-n`, дальше используется Read.
- Backend не меняется. Полный pytest (PG16, perf-pg:55432, БД `dfip_1839`) и проверка на PG15 (dfip-pg15:55434, делает главный контроллер) нужны, только если `git diff --name-only origin/feat/226-aurora-1837-cell-styles-css...HEAD -- api/` не пуст. Это проверяет задача 8. Python для pytest: `/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/`, свой venv не создавать. Исходные падения `test` (не наши): `test_backend_calls_form3_add_project_and_add_line_functions` и два `test_export_service_query_params`.
**Git и PR.**
- Не мержить. Не пушить в `test`/`main`. `git stash` без сообщения не использовать. `.bounded` не создавать: правки кода разрешает этот план.
- Push в `origin feat/234-aurora-1839-cell-memo` после каждой задачи.
- Решения, принятые без человека (контроллер, исполнитель), дописываются в `openspec/changes/aurora-1839-cell-memo/design.md`, раздел `## Decisions made without discussion`, со сквозной нумерацией (сейчас последний — 17).
**Маркировка находок.** У каждой находки и каждого решения — маркер источника, он не меняется при переносе между документами:
- `T<n>.<k>` — minor-находка ревьюера задачи n;
- `C-n` / `I-n` / `M-n` — critical / important / minor финального ревью;
- `R-n` — рулинг контроллера, рядом номер в design.md: `R-1 (design #18)`;
- `O-n` — наблюдение контроллера (неточные отчёты, инструменты, окружение);
- `H-n` — замечание человека из PR.
Статус каждой находки — `fixed <commit>`, `deferred` или `rejected <причина>`. Контроллер ведёт в конце ledger раздел `## Findings` (`| Маркер | Суть | Статус |`) и обновляет его при каждой правке.
## Review Focus
Пять случаев, которые spec подразумевает, но тесты задач не покрывают целиком. Самые вероятные — выше.
1. **Потерянный `mouseup`:** Alt+Tab посреди перетаскивания, отпускание за пределами окна, размонтирование заголовка. Ожидается, что флаг `isResizingColumn` снимется (`blur`, очистка при размонтировании) и таблица снова реагирует на поиск и закрепление. Код — задача 3, идемпотентность `endColumnResize` — тест задачи 2, ручная проверка — пункт чек-листа задачи 8.
2. **Неполное или старое состояние из localStorage:** `columnPinning` без `right`, `columnVisibility` без id колонки, `undefined` вместо объектов. Ожидается, что ключ не падает, отсутствующий id видимости считается видимой колонкой, а `{ left }` равен `{ left, right: [] }`. Тесты — задача 1.
3. **Очистка фильтров:** `globalFilter` `null`/`undefined`, фильтр колонки, удалённый TanStack при пустом значении, диапазон `['', '']`. Ожидается: подсветка `<mark>` снимается во всех видимых ячейках, ключ возвращается к значению «без фильтров». Тесты — задача 1.
4. **Открытый редактор во время сброса:** закрепление или смена поиска при открытой ячейке. Ожидается: редактор остаётся открытым в той же ячейке, сохранение по Enter работает (MRT сравнивает `editingCell` по `id`). Ручная проверка — чек-лист задачи 8, ревью — задачи 5 и 7.
5. **Сильное сужение колонки при замороженном теле:** сужение на 400 px. Ожидается: пустая полоса справа, если она появилась, исчезает по отпусканию мыши (design.md, решение 11). Ручная проверка — чек-лист задачи 8.
---
## Карта файлов
| Файл | Ответственность | Задача |
|---|---|---|
| `web/src/components/RealtimeTable/utils/cellMemoReset.js` (новый) | ключ сброса: `getPinnedLayoutKey`, `getFilterResetKey`, `getCellMemoResetKey`, `pickSettledSizing` | 1 |
| `web/src/components/RealtimeTable/utils/cellMemoReset.test.js` (новый) | тесты ключа и тест-допущение `@tanstack/table-core` | 1 |
| `web/src/components/RealtimeTable/utils/columnResizeInfo.js` (новый) | `DEFAULT_COLUMN_SIZING_INFO`, `startColumnResize`, `endColumnResize` | 2 |
| `web/src/components/RealtimeTable/utils/columnResizeInfo.test.js` (новый) | тесты помощников против `table-core` | 2 |
| `web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx` | `onDragStart`/`onDragEnd`, `blur`, размонтирование, `data-col-resize-id` | 3 |
| `web/src/components/RealtimeTable/TableHead/TableHead.jsx` | `HeaderCell` выставляет и снимает `isResizingColumn` | 3 |
| `web/src/components/RealtimeTable/RealtimeTable.jsx` | управляемые `columnFilters`/`columnSizingInfo` (4); ключ, `tableColumns`, `memoMode` (5) | 4, 5 |
| `perf/e2e/lib/pinnedGeometry.ts` (новый) | чистая проверка «закреплённые примыкают» | 6 |
| `perf/e2e/tests/pinned-geometry.test.ts` (новый) | `node --test` для проверки геометрии | 6 |
| `perf/e2e/scenarios/pinned-resize.spec.ts` (новый) | сценарий замера перетаскивания ширины закреплённой колонки | 6 |
| `perf/README.md` | абзац о новом сценарии | 6 |
| `openspec/changes/aurora-1839-cell-memo/design.md` | новые записи «Decisions made without discussion» по ходу исполнения | все |
---
### Task 1: Ключ сброса мемоизации и тест-допущение table-core
**Модель исполнителя:** haiku (код готов в плане).
**Требования spec:** «Явный сброс мемоизации проверяется автотестом»; design.md D1, D2, раздел «Тестирование».
**Files:**
- Create: `web/src/components/RealtimeTable/utils/cellMemoReset.js`
- Test: `web/src/components/RealtimeTable/utils/cellMemoReset.test.js`
**Interfaces:**
- Consumes: ничего.
- Produces (для задачи 5):
- `getPinnedLayoutKey({ columnPinning, columnVisibility, columnSizing }) -> string`
- `getFilterResetKey({ globalFilter, columnFilters }) -> string`
- `getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing, globalFilter, columnFilters }) -> string` (лишние поля игнорируются)
- `pickSettledSizing({ previous, current, isResizing }) -> object` — пока тянут, возвращает `previous` (та же ссылка), иначе `current`.
- [ ] **Step 1: Написать падающий тест** `web/src/components/RealtimeTable/utils/cellMemoReset.test.js`
```js
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTable, getCoreRowModel } from '@tanstack/table-core';
import { getCellMemoResetKey, getPinnedLayoutKey, pickSettledSizing } from './cellMemoReset.js';
const base = {
columnPinning: { left: ['mrt-row-expand', 'sort_order'], right: [] },
columnVisibility: {},
columnSizing: {},
globalFilter: '',
columnFilters: [],
};
const key = (patch = {}) => getCellMemoResetKey({ ...base, ...patch });
test('закрепление колонки слева и справа меняет ключ', () => {
const k0 = key();
assert.notEqual(key({ columnPinning: { left: [...base.columnPinning.left, 'data.q1'], right: [] } }), k0);
assert.notEqual(key({ columnPinning: { left: base.columnPinning.left, right: ['data.q4'] } }), k0);
});
test('открепление колонки меняет ключ', () => {
const pinned = { left: ['mrt-row-expand', 'sort_order', 'data.q1'], right: [] };
assert.notEqual(key({ columnPinning: pinned }), key({ columnPinning: base.columnPinning }));
});
test('скрытие и показ закреплённой колонки меняют ключ', () => {
const visible = key({ columnVisibility: { sort_order: true } });
const hidden = key({ columnVisibility: { sort_order: false } });
assert.notEqual(hidden, visible);
assert.equal(key({ columnVisibility: { sort_order: true } }), visible);
});
test('ширина закреплённой колонки меняет ключ', () => {
assert.notEqual(key({ columnSizing: { sort_order: 120 } }), key({ columnSizing: { sort_order: 160 } }));
assert.notEqual(key({ columnSizing: {} }), key({ columnSizing: { sort_order: 160 } }));
});
test('ширина, скрытие и показ незакреплённой колонки не меняют ключ', () => {
const k0 = key();
assert.equal(key({ columnSizing: { 'data.q1': 300 } }), k0);
assert.equal(key({ columnVisibility: { 'data.q1': false } }), k0);
assert.equal(key({ columnVisibility: { 'data.q1': true } }), k0);
});
test('id без записи в columnVisibility считается видимой колонкой', () => {
assert.equal(key({ columnVisibility: {} }), key({ columnVisibility: { sort_order: true, 'mrt-row-expand': true } }));
});
test('данные не входят в ключ', () => {
assert.equal(key({ data: [{ id: 1 }] }), key({ data: [{ id: 2 }] }));
});
test('globalFilter: "" -> "abc" -> "abd" -> ""', () => {
const [k0, k1, k2, k3] = ['', 'abc', 'abd', ''].map((globalFilter) => key({ globalFilter }));
assert.notEqual(k1, k0);
assert.notEqual(k2, k1);
assert.equal(k3, k0);
});
test('globalFilter null и undefined равны пустой строке', () => {
assert.equal(key({ globalFilter: null }), key());
assert.equal(key({ globalFilter: undefined }), key());
});
test('фильтр колонки: значение, диапазон [min, max] и очистка меняют ключ', () => {
const none = key();
const keys = [
none,
key({ columnFilters: [{ id: 'name', value: 'ab' }] }),
key({ columnFilters: [{ id: 'name', value: 'abc' }] }),
key({ columnFilters: [{ id: 'data.q1', value: ['1', ''] }] }),
key({ columnFilters: [{ id: 'data.q1', value: ['1', '5'] }] }),
];
assert.equal(new Set(keys).size, keys.length);
assert.equal(key({ columnFilters: [] }), none);
});
test('новый массив columnFilters с теми же значениями в другом порядке не меняет ключ', () => {
const a = { id: 'name', value: 'ab' };
const b = { id: 'data.q1', value: ['1', '5'] };
assert.equal(key({ columnFilters: [a, b] }), key({ columnFilters: [{ ...b }, { ...a }] }));
});
test('неполный columnPinning из localStorage не ломает ключ', () => {
const layout = { columnVisibility: undefined, columnSizing: undefined };
assert.equal(getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'] } }), getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'], right: [] } }));
assert.doesNotThrow(() => getPinnedLayoutKey({ ...layout, columnPinning: undefined }));
assert.doesNotThrow(() => getCellMemoResetKey({}));
});
test('пока ширину тянут, ключ прежний; после отпускания — по новым ширинам', () => {
const s0 = { sort_order: 120 };
const s1 = { sort_order: 150 };
const s2 = { sort_order: 180 };
const settled = pickSettledSizing({ previous: s0, current: s0, isResizing: false });
const k0 = key({ columnSizing: settled });
const during = pickSettledSizing({ previous: settled, current: s1, isResizing: true });
assert.equal(during, s0);
assert.equal(key({ columnSizing: during }), k0);
const after = pickSettledSizing({ previous: during, current: s2, isResizing: false });
assert.equal(after, s2);
assert.notEqual(key({ columnSizing: after }), k0);
assert.equal(key({ columnSizing: after }), key({ columnSizing: s2 }));
});
// Допущение @tanstack/table-core 8.20.5, на котором стоит явный сброс
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст», п. 3; D1).
// Третий тест показывает, зачем нужен явный сброс: смена закрепления сама ячейки не пересоздаёт.
const createFixture = () =>
createTable({
data: [{ a: 1, b: 2, c: 3 }],
columns: [
{ accessorKey: 'a', size: 50 },
{ accessorKey: 'b', size: 100 },
{ accessorKey: 'c', size: 150 },
],
state: { columnPinning: { left: [], right: [] }, columnSizing: {}, columnOrder: [], columnVisibility: {} },
getCoreRowModel: getCoreRowModel(),
});
const updateState = (table, state) => table.setOptions((options) => ({ ...options, state: { ...options.state, ...state } }));
const cellsById = (table) => new Map(table.getRowModel().rows[0].getVisibleCells().map((cell) => [cell.id, cell]));
test('table-core: при прежних columns и data объекты ячеек те же', () => {
const table = createFixture();
const before = cellsById(table);
updateState(table, { columnSizing: { a: 80 } });
for (const [id, cell] of cellsById(table)) assert.equal(cell, before.get(id));
});
test('table-core: новый массив columns с теми же определениями даёт новые ячейки', () => {
const table = createFixture();
const before = cellsById(table);
table.setOptions((options) => ({ ...options, columns: [...options.columns] }));
const after = cellsById(table);
assert.equal(after.size, before.size);
for (const [id, cell] of after) assert.notEqual(cell, before.get(id));
});
test('table-core: смена columnPinning при прежнем массиве columns ячейки не пересоздаёт', () => {
const table = createFixture();
const before = cellsById(table);
updateState(table, { columnPinning: { left: ['c'], right: [] } });
const after = cellsById(table);
assert.equal(after.size, before.size);
for (const [id, cell] of after) assert.equal(cell, before.get(id));
});
```
- [ ] **Step 2: Убедиться, что тест падает.** [замок]
Run: `cd web && node --test src/components/RealtimeTable/utils/cellMemoReset.test.js`
Expected: FAIL, `Cannot find module '.../cellMemoReset.js'`.
- [ ] **Step 3: Реализовать** `web/src/components/RealtimeTable/utils/cellMemoReset.js`
```js
// Ключ явного сброса мемоизации ячеек MRT (memoMode: 'cells') в таблице листа.
// Обоснование и контракт: openspec/changes/aurora-1839-cell-memo/ (design.md D1, D2; specs/sheet-table).
// Новое состояние, которое меняет определения колонок или стили закрепления, добавляется сюда и в cellMemoReset.test.js.
const getPinnedSide = (ids, columnVisibility, columnSizing) =>
(ids ?? []).filter((id) => columnVisibility?.[id] !== false).map((id) => [id, columnSizing?.[id] ?? null]);
// left закреплённой колонки — сумма ширин закреплённых левее, поэтому в ключ входят только
// видимые закреплённые колонки и их ширины. Незакреплённые колонки меняют ширину через CSS-переменные MRT.
export const getPinnedLayoutKey = ({ columnPinning, columnVisibility, columnSizing }) =>
JSON.stringify([
getPinnedSide(columnPinning?.left, columnVisibility, columnSizing),
getPinnedSide(columnPinning?.right, columnVisibility, columnSizing),
]);
// Порядок фильтров в массиве не важен: пары сортируются по id. Значение диапазона — массив [min, max].
export const getFilterResetKey = ({ globalFilter, columnFilters }) => {
const filters = (columnFilters ?? []).map(({ id, value }) => [String(id), value ?? null]).sort(([a], [b]) => a.localeCompare(b));
return JSON.stringify([globalFilter ?? '', filters]);
};
export const getCellMemoResetKey = (state) => JSON.stringify([getPinnedLayoutKey(state), getFilterResetKey(state)]);
// Пока ширину тянут, ключ раскладки строится по ширинам на момент нажатия мыши:
// сброс по ширине закреплённой колонки происходит один раз, по отпусканию (design.md, решение 3).
export const pickSettledSizing = ({ previous, current, isResizing }) => (isResizing ? previous : current);
```
Если Biome предложит другой формат строк, применить `npx biome check --write` к этим двум файлам.
- [ ] **Step 4: Убедиться, что тест проходит и остальные тесты не сломаны.** [замок]
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`
Expected: PASS, `fail 0`. Все 16 тестов `cellMemoReset.test.js` проходят. Если падает один из трёх тестов `table-core:`, значит не выполняется допущение дизайна: исполнитель останавливается и докладывает контроллеру со статусом BLOCKED, тест не правит.
Run: `cd web && npx biome check src/components/RealtimeTable/utils/cellMemoReset.js src/components/RealtimeTable/utils/cellMemoReset.test.js`
Expected: без ошибок.
- [ ] **Step 5: Коммит**
```bash
git add web/src/components/RealtimeTable/utils/cellMemoReset.js web/src/components/RealtimeTable/utils/cellMemoReset.test.js
git commit -F - <<'EOF'
perf(table): ключ сброса мемоизации ячеек и тест допущения table-core
Ключ из раскладки закреплённых колонок и фильтров (AURORA-1839, design.md D2),
pickSettledSizing замораживает ширины, пока колонку тянут.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 2: Помощники `columnSizingInfo` для перетаскивания ширины
**Модель исполнителя:** haiku (код готов в плане).
**Требования spec:** «Закреплённые колонки остаются на своих местах…» (сценарий «Смена ширины закреплённой колонки»); design.md D3.
**Files:**
- Create: `web/src/components/RealtimeTable/utils/columnResizeInfo.js`
- Test: `web/src/components/RealtimeTable/utils/columnResizeInfo.test.js`
**Interfaces:**
- Consumes: ничего.
- Produces (для задач 3 и 4):
- `DEFAULT_COLUMN_SIZING_INFO` — объект по умолчанию TanStack `{ startOffset: null, startSize: null, deltaOffset: null, deltaPercentage: null, isResizingColumn: false, columnSizingStart: [] }`;
- `startColumnResize(columnId: string) -> (old) => newInfo` — апдейтер для `table.setColumnSizingInfo`;
- `endColumnResize(old) -> newInfo` — апдейтер; если флаг уже снят, возвращает `old` (та же ссылка, без лишнего рендера).
- [ ] **Step 1: Написать падающий тест** `web/src/components/RealtimeTable/utils/columnResizeInfo.test.js`
```js
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTable, getCoreRowModel } from '@tanstack/table-core';
import { DEFAULT_COLUMN_SIZING_INFO, endColumnResize, startColumnResize } from './columnResizeInfo.js';
test('значение по умолчанию совпадает с table-core', () => {
const table = createTable({ data: [], columns: [{ accessorKey: 'a' }], state: {}, getCoreRowModel: getCoreRowModel() });
assert.deepEqual(DEFAULT_COLUMN_SIZING_INFO, table.initialState.columnSizingInfo);
});
test('startColumnResize ставит id колонки и не трогает остальные поля', () => {
const next = startColumnResize('data.q1')(DEFAULT_COLUMN_SIZING_INFO);
assert.equal(next.isResizingColumn, 'data.q1');
assert.deepEqual({ ...next, isResizingColumn: false }, DEFAULT_COLUMN_SIZING_INFO);
assert.equal(DEFAULT_COLUMN_SIZING_INFO.isResizingColumn, false);
});
test('endColumnResize снимает флаг, повторный вызов возвращает тот же объект', () => {
const ended = endColumnResize(startColumnResize('a')(DEFAULT_COLUMN_SIZING_INFO));
assert.equal(ended.isResizingColumn, false);
assert.equal(endColumnResize(ended), ended);
assert.equal(endColumnResize(DEFAULT_COLUMN_SIZING_INFO), DEFAULT_COLUMN_SIZING_INFO);
});
test('table.setColumnSizingInfo проводит флаг через управляемое состояние', () => {
let info = DEFAULT_COLUMN_SIZING_INFO;
const table = createTable({
data: [{ a: 1 }],
columns: [{ accessorKey: 'a' }],
state: { columnSizingInfo: info },
onColumnSizingInfoChange: (updater) => {
info = typeof updater === 'function' ? updater(info) : updater;
},
getCoreRowModel: getCoreRowModel(),
});
table.setColumnSizingInfo(startColumnResize('a'));
assert.equal(info.isResizingColumn, 'a');
table.setColumnSizingInfo(endColumnResize);
assert.equal(info.isResizingColumn, false);
});
```
- [ ] **Step 2: Убедиться, что тест падает.** [замок]
Run: `cd web && node --test src/components/RealtimeTable/utils/columnResizeInfo.test.js`
Expected: FAIL, `Cannot find module '.../columnResizeInfo.js'`.
- [ ] **Step 3: Реализовать** `web/src/components/RealtimeTable/utils/columnResizeInfo.js`
```js
// columnSizingInfo для MRT/TanStack при собственном перетаскивании ширины (TableHead/ColumnResizer).
// Пока isResizingColumn !== false, MRT 3.2.1 не пересоздаёт колонки и замораживает тело таблицы
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст» п. 3–4, D3).
export const DEFAULT_COLUMN_SIZING_INFO = {
startOffset: null,
startSize: null,
deltaOffset: null,
deltaPercentage: null,
isResizingColumn: false,
columnSizingStart: [],
};
export const startColumnResize = (columnId) => (old) => ({ ...old, isResizingColumn: columnId });
// Идемпотентно: конец перетаскивания приходит из mouseup, blur окна и размонтирования.
export const endColumnResize = (old) => (old.isResizingColumn === false ? old : { ...old, isResizingColumn: false });
```
- [ ] **Step 4: Убедиться, что тесты проходят.** [замок]
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`
Expected: PASS, `fail 0`.
Run: `cd web && npx biome check src/components/RealtimeTable/utils/columnResizeInfo.js src/components/RealtimeTable/utils/columnResizeInfo.test.js`
Expected: без ошибок.
- [ ] **Step 5: Коммит**
```bash
git add web/src/components/RealtimeTable/utils/columnResizeInfo.js web/src/components/RealtimeTable/utils/columnResizeInfo.test.js
git commit -F - <<'EOF'
perf(table): помощники columnSizingInfo для перетаскивания ширины
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 3: `ColumnResizer` сообщает MRT начало и конец перетаскивания
**Модель исполнителя:** sonnet (интеграция с событиями окна и жизненным циклом React).
**Требования spec:** «Закреплённые колонки остаются на своих местах…»; design.md D3 и «Риски» (потерянный `mouseup`).
**Files:**
- Modify: `web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx` (весь файл, 61 строка)
- Modify: `web/src/components/RealtimeTable/TableHead/TableHead.jsx:105-127` (`HeaderCell`), `:177` (рендер `ColumnResizer`)
**Interfaces:**
- Consumes: `startColumnResize`, `endColumnResize` из `../utils/columnResizeInfo` (задача 2); `table.setColumnSizingInfo(updater)` (TanStack).
- Produces:
- `ColumnResizer({ columnId, reportDragDeltaX, onDragStart, onDragEnd })`;
- на корневом `div` атрибут `data-col-resize-id={columnId}`, его использует сценарий задачи 6.
Автотеста у этой задачи нет: в проекте нет тестов React-компонентов. Логика флага покрыта тестами задачи 2, поведение — ручной регресс (задача 8) и ревью.
- [ ] **Step 1: Изучить область.** `codegraph_explore` по `ColumnResizer`, `HeaderCell`, `handleSetColumnWidth` с `projectPath` worktree. Проверить, что `ColumnResizer` используется только в `TableHead.jsx:177`.
- [ ] **Step 2: Переписать `ColumnResizer.jsx`.** Требования:
- `handleMouseDown` (только левая кнопка): `isDraggingRef.current = true`, `setIsDragging(true)`, запомнить `clientX`, вызвать `onDragStart?.()`.
- Единая идемпотентная функция `finishDrag` (`useCallback([])`): если `isDraggingRef.current` ложно — выход; иначе сбросить ref, `setIsDragging(false)`, вызвать `onDragEndRef.current?.()`. Последний `onDragEnd` хранится в ref, потому что `HeaderCell` создаёт колбэки на каждый рендер.
- Подписки на `window` в эффекте по `isDragging`: `mousemove` → прежний `handleMouseMove`, `mouseup` → `finishDrag`, `blur` → `finishDrag`.
- Отдельный эффект с пустыми зависимостями: очистка при размонтировании вызывает `finishDrag()`. Очистка эффекта подписок срабатывает при каждой смене `handleMouseMove`, поэтому `onDragEnd` в неё класть нельзя.
- Корневой `div` получает `data-col-resize-id={columnId}`, стили не меняются.
Образец формы (исполнитель может упростить, но не менять поведение):
```jsx
const ColumnResizer = ({ columnId, reportDragDeltaX, onDragStart, onDragEnd }) => {
const [isDragging, setIsDragging] = useState(false);
const isDraggingRef = useRef(false);
const lastXRef = useRef(0);
const onDragEndRef = useRef(onDragEnd);
onDragEndRef.current = onDragEnd;
const finishDrag = useCallback(() => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
setIsDragging(false);
onDragEndRef.current?.();
}, []);
const handleMouseDown = (e) => {
if (e.button !== 0) return;
e.preventDefault();
isDraggingRef.current = true;
setIsDragging(true);
lastXRef.current = e.clientX;
onDragStart?.();
};
// handleMouseMove — без изменений
useEffect(() => {
if (!isDragging) return;
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', finishDrag);
window.addEventListener('blur', finishDrag);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', finishDrag);
window.removeEventListener('blur', finishDrag);
};
}, [isDragging, handleMouseMove, finishDrag]);
// Размонтирование посреди перетаскивания не должно оставить MRT с isResizingColumn.
useEffect(() => () => finishDrag(), [finishDrag]);
return <div data-col-resize-id={columnId} onMouseDown={handleMouseDown} style={/* как сейчас */} />;
};
```
- [ ] **Step 3: Подключить в `HeaderCell` (`TableHead.jsx`).** Импорт `startColumnResize`, `endColumnResize` из `../utils/columnResizeInfo`. Рендер на строке 177 заменить на:
```jsx
<ColumnResizer
columnId={column.id}
reportDragDeltaX={handleChangeWidth}
onDragStart={() => table.setColumnSizingInfo(startColumnResize(column.id))}
onDragEnd={() => table.setColumnSizingInfo(endColumnResize)}
/>
```
`handleChangeWidth` и `onChangeWidth` не меняются: ширина по-прежнему идёт через `handleSetColumnWidth` → `setColumnSizing` на каждое движение.
- [ ] **Step 4: Проверка.** [замок]
Run: `cd web && npx biome check src/components/RealtimeTable/TableHead/ColumnResizer.jsx src/components/RealtimeTable/TableHead/TableHead.jsx`
Expected: нет новых ошибок относительно базы. Базу снять до правки той же командой и записать в отчёт.
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build`
Expected: тесты `fail 0`, сборка завершается без ошибок.
- [ ] **Step 5: Коммит**
```bash
git add web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx web/src/components/RealtimeTable/TableHead/TableHead.jsx
git commit -F - <<'EOF'
perf(table): ColumnResizer сообщает MRT начало и конец перетаскивания ширины
isResizingColumn выставляется на mousedown и снимается по mouseup, blur окна
и размонтированию: пока тянут, MRT замораживает тело таблицы (design.md D3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 4: Управляемые `columnFilters` и `columnSizingInfo` в `RealtimeTable`
**Модель исполнителя:** sonnet (интеграция).
**Требования spec:** «Подсветка поиска и фильтров соответствует текущему запросу» (фильтры работают как раньше); design.md D3, D4.
**Files:**
- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx`: импорты (`:19-29`), состояние (`:89-101`), `createTableConfig` (`:325-414`), `tableConfig` (`:416-456`), проп `TableHead` (`:812`).
**Interfaces:**
- Consumes: `DEFAULT_COLUMN_SIZING_INFO` (задача 2).
- Produces (для задачи 5): в теле `RealtimeTable` переменные `columnFilters` (массив `{ id, value }`) и `columnSizingInfo` (объект TanStack) объявлены до `const columns = useMemo(...)`.
- [ ] **Step 1: Изучить область.** `codegraph_explore` по `RealtimeTable`, `createTableConfig`, `TableHead` (проп `columnFilters`), `FilterCell` с `projectPath` worktree. Снять базу biome по `RealtimeTable.jsx`.
- [ ] **Step 2: Состояние.** Рядом с `rowSelection` (`:95`):
- `const [columnFilters, setColumnFilters] = useState([]);`
- `const [columnSizingInfo, setColumnSizingInfo] = useState(DEFAULT_COLUMN_SIZING_INFO);`
- [ ] **Step 3: Подключить к таблице.** В `createTableConfig`:
- принять `columnFilters` и `columnSizingInfo` параметрами;
- добавить `onColumnFiltersChange: setColumnFilters`, `onColumnSizingInfoChange: setColumnSizingInfo` (сеттеры замыкаются напрямую, как `setRowSelection`);
- в `state` добавить `columnFilters` и `columnSizingInfo`.
В вызов `createTableConfig` и в зависимости `useMemo` `tableConfig` добавить `columnFilters` и `columnSizingInfo`.
- [ ] **Step 4: `TableHead`.** `columnFilters={table.getState().columnFilters}` (`:812`) заменить на `columnFilters={columnFilters}`.
- [ ] **Step 5: Проверка.** [замок]
Run: `cd web && npx biome check src/components/RealtimeTable/RealtimeTable.jsx`
Expected: нет новых ошибок относительно базы.
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build`
Expected: `fail 0`, сборка без ошибок.
Самопроверка по коду (записать в отчёт): `grep -n "columnFilters\|columnSizingInfo" web/src/components/RealtimeTable/RealtimeTable.jsx` показывает `useState`, `state`, `on…Change`, зависимости `tableConfig` и проп `TableHead`. Других чтений `table.getState().columnFilters` в `RealtimeTable.jsx` нет.
- [ ] **Step 6: Коммит**
```bash
git add web/src/components/RealtimeTable/RealtimeTable.jsx
git commit -F - <<'EOF'
perf(table): управляемые columnFilters и columnSizingInfo в RealtimeTable
Значения нужны до создания таблицы, чтобы войти в ключ сброса мемоизации (design.md D3, D4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 5: `memoMode: 'cells'` с явным сбросом через `tableColumns`
**Модель исполнителя:** sonnet (интеграция; ядро логики уже покрыто тестами задачи 1).
**Требования spec:** «Прокрутка не перерисовывает ячейки, оставшиеся на экране», «Закреплённые колонки остаются на своих местах…», «Подсветка поиска…», «Контракт…»; design.md решения 1–4, D1, D2, «Контракт».
**Files:**
- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx`: импорты; блок сразу после `const columns = useMemo(...)` (`:255-273`); `createTableConfig` (`:344-347`); вызов и зависимости `tableConfig` (`:416-456`).
**Interfaces:**
- Consumes: `getCellMemoResetKey`, `pickSettledSizing` (задача 1); `columnFilters`, `columnSizingInfo` (задача 4); `columnPinning`, `columnVisibility`, `columnSizing` (`useColumnSettings`); `globalFilter`.
- Produces: `tableColumns` — массив, который уходит в MRT. `SettingsPanel` и `TableHead` продолжают получать `columns`.
- [ ] **Step 1: Изучить область.** `codegraph_explore` по `RealtimeTable` (`columns`, `tableConfig`, `useMaterialReactTable`), `getCachedCellProps` в `tableColumns.jsx` (только чтение, файл не меняется) с `projectPath` worktree.
- [ ] **Step 2: Ключ и `tableColumns`.** Сразу после `const columns = useMemo(...)`:
```js
// Мемоизация ячеек MRT (memoMode: 'cells') сравнивает только объект cell. Состояние вне данных строки,
// от которого зависят стили закрепления и подсветка фильтров, сбрасывает её новым массивом колонок.
// Контракт для правок таблицы: openspec/changes/aurora-1839-cell-memo/specs/sheet-table/spec.md
// (после архивации — openspec/specs/sheet-table/spec.md), чек-лист ревью — design.md, «Контракт».
const isResizingColumn = Boolean(columnSizingInfo.isResizingColumn);
const settledColumnSizingRef = useRef(columnSizing);
const settledColumnSizing = pickSettledSizing({
previous: settledColumnSizingRef.current,
current: columnSizing,
isResizing: isResizingColumn,
});
useEffect(() => {
settledColumnSizingRef.current = settledColumnSizing;
}, [settledColumnSizing]);
const cellMemoResetKey = useMemo(
() => getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing: settledColumnSizing, globalFilter, columnFilters }),
[columnPinning, columnVisibility, settledColumnSizing, globalFilter, columnFilters],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: cellMemoResetKey намеренно в зависимостях — новый массив сбрасывает мемоизацию ячеек MRT
const tableColumns = useMemo(() => [...columns], [columns, cellMemoResetKey]);
```
Ref пишется в эффекте (фаза commit), а не в рендере. На `mousedown` в `previous` лежат ширины последнего зафиксированного рендера, то есть до перетаскивания.
- [ ] **Step 3: Передать в таблицу.**
- В `createTableConfig` сразу после `...BASE_TABLE_CONFIG` добавить `memoMode: 'cells',`.
- В вызове `createTableConfig` передать `columns: tableColumns`.
- В зависимостях `tableConfig` заменить `columns` на `tableColumns`.
- Остальные использования `columns` (`SettingsPanel`, `TableHead`, `isLoadingData`, эффект `measure` на `:287-295`) не трогать.
- [ ] **Step 4: Проверка контракта по коду.** Исполнитель проходит чек-лист ревью из design.md («Контракт»), результат по каждому пункту — в отчёт:
- в `muiTableBodyCellProps`, `muiTableBodyRowProps`, `col.Cell`, `col.Edit` нет новых замыканий на изменяемые переменные рендера;
- нет `setState` по `scroll` в `RealtimeTable` и его хуках;
- `tableColumns` пересоздаётся только по `columns` и `cellMemoResetKey`.
- [ ] **Step 5: Проверка.** [замок]
Run: `cd web && npx biome check src/components/RealtimeTable/RealtimeTable.jsx`
Expected: нет новых ошибок и предупреждений относительно базы задачи 4. `biome-ignore` принят: Biome не сообщает о неиспользуемом подавлении.
Run: `cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build`
Expected: `fail 0`, сборка без ошибок.
- [ ] **Step 6: Коммит**
```bash
git add web/src/components/RealtimeTable/RealtimeTable.jsx
git commit -F - <<'EOF'
perf(table): memoMode 'cells' с явным сбросом по раскладке закреплённых и фильтрам
tableColumns = [...columns] пересоздаётся по ключу сброса (utils/cellMemoReset.js);
ширины закреплённых колонок входят в ключ по отпусканию мыши (design.md D1, D2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 6: Сценарий `perf/e2e/scenarios/pinned-resize.spec.ts` (отдельный коммит, без запуска замеров)
**Модель исполнителя:** sonnet (Playwright и CDP по образцу `scroll.spec.ts`; чистая проверка геометрии — готовый код).
**Требования spec:** «Перетаскивание ширины закреплённой колонки не медленнее базы»; design.md D6 и «Замер до и после» → «Сценарий … (добавить)», «Атрибут ресайзера в базе».
Сценарий **не запускается** ни исполнителем, ни контроллером SDD: замеры делает главный контроллер (см. задачу 8). Коммит — только файлы `perf/`, отдельно от кода фронта.
**Files:**
- Create: `perf/e2e/lib/pinnedGeometry.ts`
- Create: `perf/e2e/tests/pinned-geometry.test.ts` (`node --test`; в Playwright не попадает, его `testMatch` — `*.spec.ts`)
- Create: `perf/e2e/scenarios/pinned-resize.spec.ts`
- Modify: `perf/README.md` — короткий раздел рядом с описанием `scroll.spec.ts`/`hotspots.spec.ts`
**Interfaces:**
- Consumes: `loginAs`, `WEB` (`lib/auth`); `PROBE_SCRIPT`, `startProbe`, `perStep` (`lib/probe`); `throttle` (`lib/cdp`); `attachNetlog` (`lib/netlog`); `append` (`lib/results`); `loadManifest`, `scale` (`lib/manifest`); атрибуты фронта `data-col-select-id`, `data-col-resize-id` (задача 3), `[data-pin-panel] button[aria-label="Закрепить область"]` с `data-active`, `td[data-pinned="true"]`.
- Produces: записи `results/<run_id>/pinned_resize.jsonl`: `scenario: 'pinned_resize'`, `target: '<FORM>/<SHEET>/<direction>@pinned'`, `role: 'rf'`, метрики `...probe, ...perStep(probe, steps), steps`.
- `findPinnedGap(cells: Array<{ left: number; width: number }>, tolerancePx = 1): number` — индекс `i` первой пары, где `|left[i+1] − (left[i] + width[i])| > tolerancePx`; если пар нет или все примыкают, `-1`.
- [ ] **Step 1: Написать падающий тест** `perf/e2e/tests/pinned-geometry.test.ts`
```ts
import assert from 'node:assert/strict';
import test from 'node:test';
import { findPinnedGap } from '../lib/pinnedGeometry.ts';
test('примыкающие закреплённые колонки: -1', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 120 }, { left: 160, width: 300 }]), -1);
});
test('наложение после смены ширины: индекс левой колонки пары', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 200 }, { left: 160, width: 300 }]), 1);
});
test('зазор: индекс левой колонки пары', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 60, width: 120 }]), 0);
});
test('дробные пиксели в пределах допуска 1 px: -1', () => {
assert.equal(findPinnedGap([{ left: 0, width: 40.4 }, { left: 41.2, width: 10 }]), -1);
});
test('меньше двух колонок: -1', () => {
assert.equal(findPinnedGap([]), -1);
assert.equal(findPinnedGap([{ left: 0, width: 40 }]), -1);
});
```
- [ ] **Step 2: Убедиться, что тест падает.** [замок]
Run: `node --test perf/e2e/tests/pinned-geometry.test.ts`
Expected: FAIL, модуль `../lib/pinnedGeometry.ts` не найден.
- [ ] **Step 3: Реализовать `perf/e2e/lib/pinnedGeometry.ts`** — без импортов, чтобы его загружал и `node --test`, и Playwright:
```ts
/** Проверка раскладки закреплённых колонок (spec sheet-table): ячейки td[data-pinned] одной строки по порядку примыкают. */
export interface PinnedCellRect { left: number; width: number }
export function findPinnedGap(cells: PinnedCellRect[], tolerancePx = 1): number {
for (let i = 0; i + 1 < cells.length; i++) {
if (Math.abs(cells[i + 1].left - (cells[i].left + cells[i].width)) > tolerancePx) return i;
}
return -1;
}
```
Run: `node --test perf/e2e/tests/pinned-geometry.test.ts` [замок]
Expected: PASS, 5 тестов.
- [ ] **Step 4: Написать сценарий `perf/e2e/scenarios/pinned-resize.spec.ts`** по образцу `scroll.spec.ts`. Шапка — комментарий с параметрами env, как у `scroll.spec.ts`. Решения, которые исполнитель не меняет:
- **Параметры:** `PERF_TARGETS` (glob, по умолчанию `'FORM_2/AHR/*'`), `PERF_REPEATS` (по умолчанию 3), `PERF_CPU_RATES` (по умолчанию `'1'`), `PERF_LOAD_TIMEOUT_MS`. Роль только `rf`. Цели — листы манифеста с `owner === 'rf'`, имя `${form_type}/${sheet}/${direction ?? 'null'}`; цель берётся, если glob совпадает с именем или с `${имя}@pinned`.
- **Контекст:** `browser.newContext({ viewport: { width: 1600, height: 900 } })`, `attachNetlog` (`scenario: 'pinned_resize'`) и `nl.flush()` в `finally`, `loginAs`, `addInitScript(PROBE_SCRIPT)`.
- **Раскладка по умолчанию:** до навигации `ctx.addInitScript` удаляет из localStorage все ключи, которые начинаются с `${form_id}_${form_type}_${sheet}_` и оканчиваются на `_columnPinning`, `_columnSizing` или `_columnVisibility`. По префиксу, потому что `direction` в ключе бывает строкой `null`/`undefined`.
- **Загрузка:** `page.goto(WEB + sheet.url)`, ожидание ≥ 20 строк `.MuiTableContainer-root tbody tr` с таймаутом `PERF_LOAD_TIMEOUT_MS`, затем `waitForTimeout(500)` и `throttle(page, cpu)`. Если строк не дождались — запись `incomplete: true` с `skip_reason`, как в `scroll.spec.ts`.
- **Колонка:** первый `[data-col-select-id]` в DOM-порядке, id которого начинается с `data.`. Если такого нет — первый, чей id не `mrt-row-expand` и не `sort_order`. Клик по нему, затем клик `[data-pin-panel] button[aria-label="Закрепить область"]`, ожидание `data-active="true"` на этой кнопке.
- **Ресайзер:** `[data-col-resize-id="<id>"]`. Если его нет (сборка базы AURORA-1837), запасной путь в `page.evaluate`: среди `div` с `style.cursor === 'col-resize'` взять те, чей `getBoundingClientRect().right` отличается от `right` у `[data-col-select-id="<id>"]` не больше чем на 2 px и чей `top` больше `top` строки букв; из них — с наибольшим `top` (строка листовых заголовков). Если не нашли — `incomplete: true, skip_reason: 'resizer_not_found'`.
- **Перетаскивание:** `page.mouse.move` в центр ресайзера, `page.mouse.down()`, `const pr = await startProbe(page)`. Затем 50 шагов `+4 px` и 50 шагов `−4 px` по `x`: `page.mouse.move(x, y)` и после каждого шага ожидание кадра `page.evaluate(() => new Promise<void>((r) => requestAnimationFrame(() => r())))`. Потом `page.mouse.up()`, ещё два кадра, `const probe = await pr.stop()`. `steps = 100`.
- **Геометрия:** в `page.evaluate` найти первую `tbody tr`, в которой не меньше двух `td[data-pinned="true"]`, и вернуть `{ left, width }` из `getBoundingClientRect()` по DOM-порядку. На стороне Node проверить `findPinnedGap(rects)`. Результат `≥ 0` → `incomplete: true, skip_reason: 'pinned_overlap'` и в метрики `pinned_gap_index`.
- **Запись:** `append({ scenario: 'pinned_resize', target: \`${name}@pinned\`, role: 'rf', scale: scale(), cpu_rate: cpu, repeat, metrics: { ...probe, ...perStep(probe, steps), steps }, artifacts: [], incomplete, skip_reason })`. Режим `PERF_HOT_MODE=react` не делается.
- **Тесты Playwright:** `test.describe('pinned_resize', …)`: на каждую цель × CPU × повтор — `test(\`${name} rf pinned cpu${cpu} #${repeat}\`)`, `test.setTimeout(PERF_TEST_TIMEOUT_MS ?? 45 мин)`.
- [ ] **Step 5: Проверка без запуска замеров.**
Run: `node --check perf/e2e/scenarios/pinned-resize.spec.ts && node --check perf/e2e/lib/pinnedGeometry.ts`
Expected: код возврата 0. Это синтаксис TS через strip-types Node 24. Типы не проверяются: в worktree нет `perf/e2e/node_modules`.
Run: `node --test perf/e2e/tests/pinned-geometry.test.ts` [замок]
Expected: PASS.
`npx playwright test` не запускать: правило волны и `globalSetup` против БД стенда.
- [ ] **Step 6: `perf/README.md`.** Раздел из трёх-пяти строк: что делает сценарий, команда из design.md («Замер до и после», строка «Перетаскивание ширины закреплённой»), метрики (`main_thread_ms_per_step` на шаг мыши, `frame_p95_ms`, `heavy_frame_n`), `skip_reason: pinned_overlap`, запасной поиск ресайзера для сборки базы, самопроверка `node --test perf/e2e/tests/pinned-geometry.test.ts`.
- [ ] **Step 7: Коммит (только `perf/`)**
```bash
git add perf/e2e/lib/pinnedGeometry.ts perf/e2e/tests/pinned-geometry.test.ts perf/e2e/scenarios/pinned-resize.spec.ts perf/README.md
git commit -F - <<'EOF'
perf(e2e): сценарий pinned-resize — перетаскивание ширины закреплённой колонки
Закрепляет первую колонку данных FORM_2/AHR (rf), тянет её границу 50×(+4 px) и 50×(−4 px)
с ожиданием кадра, после отпускания проверяет примыкание td[data-pinned].
Для сборки базы (без data-col-resize-id) — запасной поиск ресайзера. Замеры не запускались.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo
```
---
### Task 7: Финальное ревью всей ветки
**Модель ревьюера:** opus. Контроллер SDD передаёт в бриф раздел «Правила исполнения волны 2», путь spec и этот план.
- [ ] **Step 1: Диапазон.** Ревью — `git diff origin/feat/226-aurora-1837-cell-styles-css...HEAD` (только коммиты этой задачи, без 1836/1837).
- [ ] **Step 2: Что проверяет ревьюер.**
- Каждое требование `specs/sheet-table/spec.md` и пункт «Global Constraints» соотнесены с кодом.
- Контракт по чек-листу design.md («Контракт»).
- Пять пунктов «Review Focus» этого плана.
- Сценарий задачи 6 соответствует design.md («Сценарий … (добавить)») и не ломает агрегацию `perfkit.aggregate`: поля `scenario/target/role/scale/cpu_rate` на месте.
- Не тронуты `Cell/**`, `tableColumns.jsx`, своды, `api/**`.
- [ ] **Step 3: Проверки.** [замок] Ревьюер запускает `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `node --test perf/e2e/tests/pinned-geometry.test.ts`, `cd web && npx biome check src/components/RealtimeTable` и `npm run build`.
- [ ] **Step 4: Находки.** Находки — `C-n`/`I-n`/`M-n`. Critical и important исправляет исполнитель sonnet в той же ветке, отдельными коммитами `fix(table): …` с Co-Authored-By. Minor исправляются или получают `deferred`/`rejected <причина>`. После исправлений — повторная проверка шага 3. Таблица находок — в ledger, раздел `## Findings`.
---
### Task 8: PR, точка ожидания замеров
**Исполнитель:** контроллер SDD (sonnet).
- [ ] **Step 1: Backend.** `git diff --name-only origin/feat/226-aurora-1837-cell-styles-css...HEAD -- api/` должен быть пуст. Тогда pytest и PG15 не нужны, в PR пишется «backend не менялся». Если не пуст, значит нарушены Global Constraints: остановиться и доложить.
- [ ] **Step 2: Описание PR** (файл в scratchpad, например `/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/pr-234.md`). Состав:
- `Closes #234`, Jira `AURORA-1839`. Стек: база `feat/226-aurora-1837-cell-styles-css` (PR #230), после его слияния — rebase на `test`.
- Что сделано: по одному пункту на задачи 1–6.
- Тесты: команды и результаты `node --test` (web и `perf/e2e/tests/pinned-geometry.test.ts`), `biome`, `npm run build`. Backend не менялся.
- **Чек-лист ручного регресса** — design.md «Тестирование» → «Ручной регресс», дословно, с `- [ ]`, плюс:
- [ ] Alt+Tab посреди перетаскивания ширины — после возврата таблица реагирует на поиск и закрепление (Review Focus 1);
- [ ] закрепление и смена поиска при открытом редакторе — редактор на месте, Enter сохраняет (Review Focus 4);
- [ ] сужение широкой колонки на 400 px — полоса исчезает по отпусканию (Review Focus 5);
- [ ] уже отрисованные ячейки обновляются без прокрутки при поиске, фильтре, закреплении, скрытии закреплённой, ширине закреплённой.
- «Замер до/после — добавит контроллер после прогона на стенде»: таблица design.md «Замер до и после» с пустыми колонками «до»/«после». Пороги — цель, а не условие слияния.
- «Rulings I made» — список `R-n (design #N)` из финального отчёта контроллера.
- Таблица всех находок: маркер, суть, статус. `deferred` перечислены отдельно.
- Последняя строка: `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- [ ] **Step 3: Создать PR.**
```bash
tea pulls create --base feat/226-aurora-1837-cell-styles-css --head feat/234-aurora-1839-cell-memo \
--title "AURORA-1839: мемоизация ячеек таблицы листа" --description "$(cat <путь pr-234.md>)"
```
Expected: номер PR. Не мержить.
- [ ] **Step 4: ТОЧКА ОЖИДАНИЯ — замеры.** Контроллер SDD на этом останавливается и отдаёт финальный отчёт главной сессии: номер PR, SHA HEAD ветки и базы, таблица находок, рулинги. Дальше работает главный контроллер, вне SDD:
1. После основных ночных замеров прогоняет все строки таблицы design.md «Замер до и после», включая `scenarios/pinned-resize.spec.ts`, на сборках «до» (HEAD `feat/226-aurora-1837-cell-styles-css`) и «после» (HEAD этой ветки). Соблюдает ловушки стенда из того же раздела.
2. Дописывает цифры в PR (комментарий `tea comment <PR> …` или правка описания).
3. Если цель не достигнута (шаг `y` > 25 мс, регрессия `x`, соавтора или `pinned-resize` относительно базы), заводит follow-up issue с разбором, что съедает остаток. Это не блокирует PR.
4. Если `pinned-resize` дал `skip_reason: 'pinned_overlap'`, это нарушение корректности, и оно блокирует слияние. Исправление идёт в этой ветке.
- [ ] **Step 5: Ручной регресс и слияние.** Отметки чек-листа в PR — до слияния. Корректность — жёсткое условие. Замечания человека из PR (`H-n`) обрабатываются через superpowers:receiving-code-review в этой ветке. Merge — только по явной команде человека.