docs(план): реализация глобального фикса позиционирования меню - 4 TDD-задачи

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-27 10:15:09 +03:00
parent 935b56a5e4
commit 4e24e7e2d7
2 changed files with 388 additions and 9 deletions
+9 -9
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-06-27T04:18:13.211Z
Last updated: 2026-06-27T07:09:55.632Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -39,16 +39,16 @@ Last updated: 2026-06-27T04:18:13.211Z
| Время | Действие | Причина |
|---|---|---|
| 2026-06-27T07:03:56.852Z | bash:node -e "1" 2>/dev/null; for f in docs/secretary/*/protocol.md; do printf '%6s %s\n' "$(wc -l < "$f")" "$f"; done | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "1" 2>/dev/nul |
| 2026-06-27T05:45:19.915Z | bash:rm ~/.claude/runtime/secretary-mode-*.json | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:rm ~/.claude/runtime/s |
| 2026-06-27T04:18:53.490Z | powershell:Stop-Process -Id 4072 -Force | floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: powershell:Stop-Process |
| 2026-06-27T04:18:44.603Z | powershell:Stop-Process -Id 4072 -Force; if ($?) { "killed 4072" }; Start-Sleep -Milliseconds 300; Get-CimInstance Win32 | floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: powershell:Stop-Process |
| 2026-06-27T04:08:56.319Z | bash:cd "/c/Users/Administrator/.claude/runtime/" && for f in secretary-mode-*.json; do printf '%s => ' "$f"; cat "$f"; | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cd "/c/Users/Administr |
| 2026-06-27T04:07:22.527Z | bash:cd "c:/моя/проекты/claude-brain" && ls tools/ 2>&1 \| grep -i secret; echo "---grep config---"; grep -ril "secretar | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:cd "c:/моя/проекты/cla |
| 2026-06-27T03:41:52.937Z | bash:node -e "const fs=require('fs'),os=require('os'),p=require('path');const dir=p.join(os.homedir(),'.claude','runtime | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "const fs=requ |
| 2026-06-27T03:14:37.381Z | bash:find "c:\моя\проекты\claude-brain" -name "*sessionstart*" -type f 2>/dev/null | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:find "c:\моя\проекты\c |
| 2026-06-27T03:10:25.034Z | bash:grep -n "globals\\|include\\|exclude" vitest.config.tools.mjs 2>/dev/null \|\| echo "NO tools config at root" | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:grep -n "globals\\|inc |
| 2026-06-27T03:09:50.695Z | bash:git commit -F "C:/Users/ADMINI~1/AppData/Local/Temp/2/claude/c--------------claude-brain/2c98d47c-4f4d-48cc-98a8-2e | [verify-gate] расписка отклонена (stale-fingerprint) — пере-прогоните verify (staged-diff изменился / битая подпись) |
| 2026-06-27T03:07:30.844Z | bash:git commit -F "C:/Users/ADMINI~1/AppData/Local/Temp/2/claude/c--------------claude-brain/2c98d47c-4f4d-48cc-98a8-2e | [verify-gate] расписка отклонена (stale-fingerprint) — пере-прогоните verify (staged-diff изменился / битая подпись) |
| 2026-06-27T03:06:53.280Z | bash:git add tools/secretary-tap.mjs tools/secretary-tap.test.mjs tools/secretary-distill.mjs tools/secretary-distill.te | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:git add tools/secretar |
| 2026-06-27T02:34:28.483Z | bash:ls -la ~/.claude/runtime/standby-* 2>/dev/null; echo "---"; cat ~/.claude/runtime/standby-mode-2c98d47c-4f4d-48cc-9 | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:ls -la ~/.claude/runti |
| 2026-06-26T16:51:40.211Z | bash:git -C "c:/моя/проекты/claude-brain" add docs/superpowers/router-mentor-wall-GUIDE.md && git -C "c:/моя/проекты/cla | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:git -C "c:/моя/проекты |
## Метрики (информационные, не алерты)
@@ -132,9 +132,9 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3440 | MsMpEng | 15.45ч | 220520.3ч |
| 21928 | Code | 5.53ч | NaNч |
| 1212 | svchost | 3.89ч | 0.0ч |
| 3440 | MsMpEng | 15.63ч | 12285175.2ч |
| 21928 | Code | 5.80ч | NaNч |
| 1212 | svchost | 3.95ч | 0.0ч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
@@ -150,7 +150,7 @@ Episodes since last run: 542 / threshold: 10
## Целостность журналов действий
🔴 Битые цепочки (3 из 119):
🔴 Битые цепочки (3 из 120):
| session | broken at seq |
|---|---|
@@ -0,0 +1,379 @@
# Глобальный фикс позиционирования выпадающих меню — Implementation Plan
> **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.
**Goal:** Включать обход бага позиционирования меню Vuetify автоматически и глобально, чтобы выпадающие списки не уезжали за экран ни в одном окне, и убрать ручные пометки.
**Architecture:** Один `MutationObserver` (ставится при запуске SPA из `app.ts`) ловит появление любого `.v-overlay.v-menu` в DOM и один раз запускает уже существующую стабилизацию позиции (`scheduleStabilize` — ядро прежнего `repositionMenuAfterOpen`). Ручные `@update:menu`/`@update:model-value` снимаются — остаётся одно место правды.
**Tech Stack:** Vue 3, Vuetify 3, TypeScript, Vitest + jsdom (`app/tests/Frontend/**`), vue-tsc, Vite.
**Спека:** [docs/superpowers/specs/2026-06-27-menu-reposition-global-fix-design.md](../specs/2026-06-27-menu-reposition-global-fix-design.md)
---
## Структура файлов
| Файл | Ответственность | Действие |
|---|---|---|
| `app/resources/js/utils/menuRepositionFix.ts` | стабилизация позиции меню + глобальный установщик | Modify |
| `app/resources/js/app.ts` | точка запуска SPA — один вызов установщика | Modify |
| `app/tests/Frontend/menuRepositionFix.spec.ts` | тест механизма (observer → один resize) | Create |
| `app/resources/js/views/projects/NewProjectDialog.vue` | убрать ручной обход (регионы) + импорт | Modify |
| `app/resources/js/components/projects/RegionsBulkDialog.vue` | убрать ×2 обход + импорт | Modify |
| `app/resources/js/components/projects/ProjectDetailsDrawer.vue` | убрать ×1 обход + импорт | Modify |
| `app/resources/js/components/layout/AppTopbar.vue` | убрать ×2 обход + импорт | Modify |
Все команды запускаются из каталога `app/` (там `package.json`). В PowerShell: `cd app; npm run ...`.
---
## Task 1: Глобальный установщик `installMenuRepositionFix` (TDD)
На этом шаге старый экспорт `repositionMenuAfterOpen` НЕ удаляем — он останется до Task 4, чтобы сборка оставалась зелёной (его ещё импортируют 4 окна). Добавляем новую функцию рядом, общее ядро `scheduleStabilize`.
**Files:**
- Test: `app/tests/Frontend/menuRepositionFix.spec.ts` (создать)
- Modify: `app/resources/js/utils/menuRepositionFix.ts`
- [ ] **Step 1: Написать падающий тест**
Создать `app/tests/Frontend/menuRepositionFix.spec.ts`:
```ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installMenuRepositionFix } from '../../resources/js/utils/menuRepositionFix';
// Ручной requestAnimationFrame: кадры прогоняем детерминированно.
let rafQueue: FrameRequestCallback[] = [];
function flushFrames(n: number): void {
for (let i = 0; i < n; i++) {
const batch = rafQueue;
rafQueue = [];
batch.forEach((cb) => cb(0));
}
}
// Дать MutationObserver (микротаска jsdom) сработать.
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
function makeMenu(): HTMLElement {
const menu = document.createElement('div');
menu.className = 'v-overlay v-menu';
const content = document.createElement('div');
content.className = 'v-overlay__content';
menu.appendChild(content);
return menu;
}
let teardown: (() => void) | undefined;
let rectWidth = 200;
let rectLeft = 100;
let origRect: typeof HTMLElement.prototype.getBoundingClientRect;
let resizeSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
rafQueue = [];
rectWidth = 200;
rectLeft = 100;
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafQueue.push(cb);
return rafQueue.length;
});
origRect = HTMLElement.prototype.getBoundingClientRect;
HTMLElement.prototype.getBoundingClientRect = function (): DOMRect {
return { width: rectWidth, height: 10, left: rectLeft, top: 0, right: rectLeft + rectWidth, bottom: 10, x: rectLeft, y: 0, toJSON: () => ({}) } as DOMRect;
};
resizeSpy = vi.fn();
window.addEventListener('resize', resizeSpy);
});
afterEach(() => {
teardown?.();
teardown = undefined;
HTMLElement.prototype.getBoundingClientRect = origRect;
window.removeEventListener('resize', resizeSpy);
vi.unstubAllGlobals();
document.body.innerHTML = '';
});
describe('installMenuRepositionFix', () => {
it('при появлении меню стабилизирует позицию и шлёт один resize', async () => {
teardown = installMenuRepositionFix();
document.body.appendChild(makeMenu());
await tick();
flushFrames(6);
expect(resizeSpy).toHaveBeenCalledTimes(1);
});
it('на посторонний узел не реагирует', async () => {
teardown = installMenuRepositionFix();
const other = document.createElement('div');
other.className = 'some-card';
document.body.appendChild(other);
await tick();
flushFrames(6);
expect(resizeSpy).not.toHaveBeenCalled();
});
it('идемпотентна: двойная установка не даёт двойной resize', async () => {
teardown = installMenuRepositionFix();
installMenuRepositionFix(); // второй вызов — noop
document.body.appendChild(makeMenu());
await tick();
flushFrames(6);
expect(resizeSpy).toHaveBeenCalledTimes(1);
});
it('предохранитель: если геометрия не устаканилась — не виснет и не шлёт resize', async () => {
rectWidth = 0; // контент «нулевой» → условие стабилизации не выполняется
teardown = installMenuRepositionFix();
document.body.appendChild(makeMenu());
await tick();
flushFrames(120); // больше предохранителя (90 кадров)
expect(resizeSpy).not.toHaveBeenCalled();
expect(rafQueue.length).toBe(0); // цикл остановился, не зациклился
});
});
```
- [ ] **Step 2: Прогнать тест — убедиться, что падает**
Run (из `app/`): `npm run test:vue -- menuRepositionFix`
Expected: FAIL — `installMenuRepositionFix` не экспортируется из `menuRepositionFix.ts`.
- [ ] **Step 3: Реализовать установщик**
Переписать `app/resources/js/utils/menuRepositionFix.ts` так (шапку-комментарий сохранить, ниже — тело):
```ts
// Ядро: дождаться, пока геометрия последнего меню устаканится, и один раз
// послать resize — Vuetify пересчитает позицию по стабильной геометрии.
function scheduleStabilize(): void {
if (typeof window === 'undefined') return;
let prevLeft = Number.NaN;
let stableFrames = 0;
let totalFrames = 0;
const tick = (): void => {
const menus = document.querySelectorAll<HTMLElement>('.v-overlay.v-menu .v-overlay__content');
const el = menus[menus.length - 1];
if (el && el.getBoundingClientRect().width > 0) {
const left = Math.round(el.getBoundingClientRect().left);
stableFrames = left === prevLeft ? stableFrames + 1 : 0;
prevLeft = left;
if (stableFrames >= 3) {
window.dispatchEvent(new Event('resize'));
return;
}
}
if (++totalFrames < 90) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
/**
* @deprecated Ручной per-instance обход. Заменён глобальным
* installMenuRepositionFix(). Оставлен временно до снятия ручных пометок.
*/
export function repositionMenuAfterOpen(open: boolean): void {
if (!open) return;
scheduleStabilize();
}
let installed = false;
/**
* Глобально включает обход бага позиционирования меню Vuetify: один
* MutationObserver ловит появление любого `.v-overlay.v-menu` и запускает
* стабилизацию. Идемпотентна (повторный вызов — noop). SSR-safe.
* Возвращает teardown (для тестов / явной остановки).
*/
export function installMenuRepositionFix(): () => void {
const noop = (): void => {};
if (installed) return noop;
if (
typeof window === 'undefined' ||
typeof document === 'undefined' ||
typeof MutationObserver === 'undefined' ||
!document.body
) {
return noop;
}
installed = true;
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
if (node.matches('.v-overlay.v-menu') || node.querySelector('.v-overlay.v-menu')) {
scheduleStabilize();
return; // одного запуска на пачку мутаций достаточно
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => {
observer.disconnect();
installed = false;
};
}
```
> Примечание: блок JSDoc-шапки в начале файла (описание бага) сохранить как есть — он остаётся актуальным.
- [ ] **Step 4: Прогнать тест — зелёный**
Run (из `app/`): `npm run test:vue -- menuRepositionFix`
Expected: PASS — все 4 кейса.
- [ ] **Step 5: Коммит**
```bash
git add app/resources/js/utils/menuRepositionFix.ts app/tests/Frontend/menuRepositionFix.spec.ts
git commit -m "feat(fe): глобальный installMenuRepositionFix + тест механизма"
```
---
## Task 2: Подключить установщик в точке запуска
**Files:**
- Modify: `app/resources/js/app.ts`
- [ ] **Step 1: Добавить импорт и вызов после mount**
В `app/resources/js/app.ts` добавить импорт рядом с остальными:
```ts
import { installMenuRepositionFix } from './utils/menuRepositionFix';
```
И сразу после `app.mount('#app');` добавить строку:
```ts
app.mount('#app');
installMenuRepositionFix();
```
- [ ] **Step 2: Проверка типов и сборки**
Run (из `app/`): `npm run type-check`
Expected: PASS (0 ошибок).
- [ ] **Step 3: Коммит**
```bash
git add app/resources/js/app.ts
git commit -m "feat(fe): подключить installMenuRepositionFix при запуске SPA"
```
---
## Task 3: Снять ручные пометки в 4 окнах
Глобальный обход уже активен (Task 2). Ручные `@update:menu` / `@update:model-value="repositionMenuAfterOpen"` и их импорты теперь лишние — снимаем. Точные строки могли сдвинуться; ориентир — по атрибуту и импорту.
**Files:**
- Modify: `app/resources/js/views/projects/NewProjectDialog.vue`
- Modify: `app/resources/js/components/projects/RegionsBulkDialog.vue`
- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue`
- Modify: `app/resources/js/components/layout/AppTopbar.vue`
- [ ] **Step 1: Найти все вхождения**
Run (из корня репо): `grep -rn "repositionMenuAfterOpen" app/resources/js`
Expected: вхождения только в 4 файлах выше (по 1 импорту + использования: NewProjectDialog ×1, RegionsBulkDialog ×2, ProjectDetailsDrawer ×1, AppTopbar ×2).
- [ ] **Step 2: Удалить строки-атрибуты и импорты**
В каждом из 4 файлов:
1. Удалить строку(и) атрибута на компоненте: `@update:menu="repositionMenuAfterOpen"` (NewProjectDialog, RegionsBulkDialog ×2, ProjectDetailsDrawer) либо `@update:model-value="repositionMenuAfterOpen"` (AppTopbar ×2).
2. Удалить строку импорта: `import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';` (путь относительный — как в файле).
Ничего другого в этих строках не трогать (атрибут на отдельной строке — удаляется целиком).
- [ ] **Step 3: Убедиться, что ссылок не осталось**
Run (из корня репо): `grep -rn "repositionMenuAfterOpen" app/resources/js`
Expected: вхождения ТОЛЬКО в `app/resources/js/utils/menuRepositionFix.ts` (определение `@deprecated`-функции). В `.vue`-файлах — ничего.
- [ ] **Step 4: Тесты затронутых окон + типы**
Run (из `app/`): `npm run test:vue -- NewDealDialog RegionsBulkDialog ProjectDetailsDrawer DealsFilters TenantsFilters`
Expected: PASS (затронутые окна рендерятся, обработчик отсутствует — не ломает).
Run (из `app/`): `npm run type-check`
Expected: PASS — нет «неиспользуемый импорт».
- [ ] **Step 5: Коммит**
```bash
git add app/resources/js/views/projects/NewProjectDialog.vue app/resources/js/components/projects/RegionsBulkDialog.vue app/resources/js/components/projects/ProjectDetailsDrawer.vue app/resources/js/components/layout/AppTopbar.vue
git commit -m "refactor(fe): снять ручные обходы меню - заменены глобальным установщиком"
```
---
## Task 4: Удалить мёртвый экспорт `repositionMenuAfterOpen`
После Task 3 функция нигде не используется. Удаляем экспорт — ядро остаётся внутренним, наружу торчит только `installMenuRepositionFix`.
**Files:**
- Modify: `app/resources/js/utils/menuRepositionFix.ts`
- [ ] **Step 1: Удалить `@deprecated`-функцию**
В `app/resources/js/utils/menuRepositionFix.ts` удалить весь блок:
```ts
/**
* @deprecated Ручной per-instance обход. Заменён глобальным
* installMenuRepositionFix(). Оставлен временно до снятия ручных пометок.
*/
export function repositionMenuAfterOpen(open: boolean): void {
if (!open) return;
scheduleStabilize();
}
```
`scheduleStabilize` и `installMenuRepositionFix` остаются. (`scheduleStabilize` остаётся используемым из `installMenuRepositionFix`.)
- [ ] **Step 2: Полная верификация**
Run (из `app/`): `npm run test:vue -- menuRepositionFix`
Expected: PASS (4 кейса).
Run (из `app/`): `npm run type-check`
Expected: PASS — нет неиспользуемого `scheduleStabilize`/мёртвого кода.
Run (из `app/`): `npm run build`
Expected: сборка успешна.
- [ ] **Step 3: Коммит**
```bash
git add app/resources/js/utils/menuRepositionFix.ts
git commit -m "refactor(fe): убрать мёртвый repositionMenuAfterOpen - ядро внутреннее"
```
---
## Финальная проверка (после всех задач)
- [ ] Run (из `app/`): `npm run test:vue` — весь фронт-набор зелёный.
- [ ] Run (из `app/`): `npm run type-check` — 0 ошибок.
- [ ] Run (из `app/`): `npm run build` — сборка ок.
- [ ] Глазами (после выката на прод по «выкатывай»): открыть «Тип лица» в визарде нового проекта, списки в «Новой сделке» и в диалоге импорта статусов — меню встают на место (особенно под Windows / reduced-motion).
---
## Self-review (выполнено автором плана)
- **Покрытие спеки:** §4.1 ядро+установщик → Task 1; §4.2 точка запуска → Task 2; §4.3 уборка пометок → Task 3 + Task 4 (удаление экспорта); §6 тесты → Task 1 (4 кейса) + финальная проверка. Гэпов нет.
- **Плейсхолдеры:** нет TBD/TODO; весь код приведён целиком.
- **Согласованность имён:** `scheduleStabilize`, `installMenuRepositionFix`, `repositionMenuAfterOpen` — употреблены одинаково во всех задачах; экспорт `repositionMenuAfterOpen` живёт Task 1→3, удаляется в Task 4 (по тексту согласовано).
- **Зелёные коммиты:** старый экспорт держится до снятия всех ссылок (Task 3), только потом удаляется (Task 4) — сборка не ломается между коммитами.