Files
portal/docs/superpowers/specs/2026-05-12-dev-element-indices-design.md
T
Дмитрий f90ddb09c1 docs(specs): design — dev element indices (per-element data-dx + manifest)
Утверждённый дизайн: Vite plugin инжектирует data-dx на каждый element
+ persistent dev-indices.json (commit'ится) + DevIndexOverlay
(hover/Alt-keys/Alt+Shift+I toggle/click-to-copy).

Cтабильность через structural signature (file + ancestor chain + tag +
static attrs + text snippet), tombstones для удалённых ID, escape-hatch
через data-dev-name на важных местах. Production: tree-shake'ится через
import.meta.env.DEV.

+3 слова в cspell-words.txt (реордере/реорден/hmr).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:22:30 +03:00

252 lines
16 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.
# Dev Element Indices — design
**Дата:** 2026-05-12
**Статус:** утверждено заказчиком в обсуждении (12.05.2026 ночь)
**Контекст:** Расширение существующего `DevIndexBadge` (route-level, 28 индексов) до per-element покрытия по всему порталу. Цель — чтобы заказчик в feedback ссылался на конкретный элемент по числу («1030 измени цвет», «543 поменяй текст», «2386 удали»), а Claude однозначно знал, на что указывают.
## 1. Требования
### 1.1. Функциональные
- На **каждом** DOM-элементе в `.vue` шаблонах есть атрибут `data-dx="<N>"` в dev/staging.
- При hover мыши показывается badge с номером самого глубокого элемента под курсором + краткой меткой (tag + text snippet).
- Клавиши `Alt+↑` / `Alt+↓` переключают badge на родителя / первого ребёнка с `data-dx`.
- `Alt+Shift+I` — toggle режим: все индексы видны overlay'ем одновременно (мини-badges 8px в углу каждого элемента).
- Click по badge — копирует `#<N>` в clipboard.
- `Esc` — скрывает текущий badge и выключает overlay-режим.
### 1.2. Стабильность ID
- ID, присвоенный элементу, **не меняется** при:
- Реордере соседей **другого** тега
- Добавлении / удалении соседей **другого** тега
- Изменении классов / стилей / атрибутов, не входящих в signature
- Минорных правках текста (signature использует первые 24 символа)
- ID **может** измениться при:
- Перемещении элемента в другой parent / другой файл
- Реордере элементов **того же** тега в одном parent (изменение ordinal-among-same-tag)
- Полной переписке текста, если текст был единственным distinctive признаком
- **Удалённые ID не переиспользуются** — заносятся в tombstone-секцию manifest'а.
### 1.3. Production safety
- Plugin не запускается при `NODE_ENV === 'production'`.
- `<DevIndexOverlay>` обёрнут в `v-if="import.meta.env.DEV"` → tree-shake'ится production-bundle'ом.
- Ноль `data-dx` атрибутов в production HTML. Ноль bytes overhead в production JS.
### 1.4. Доступность manifest для Claude
- `dev-indices.json` commit'ится в репо.
- Claude читает manifest через `Read` / `Grep` tools — мгновенный lookup `1030 → file:line:tag:text`.
- Дополнительно: CLI `npm run dx <id>` для CLI-lookup.
## 2. Архитектура
Две независимые подсистемы:
### 2.1. Build-time: Vite plugin
**Файлы:** `app/vite-plugins/dev-indices/{index,signature,manifest}.ts`
**Hook:** `transform()` для `*.vue` источников. Использует `@vue/compiler-sfc` для извлечения `<template>` и `@vue/compiler-dom` для AST-walk'а.
**Алгоритм на каждый build / HMR-update:**
1. При старте Vite — однократно загрузить `dev-indices.json` в память (cached map: `signature → id` + `id → entry`).
2. Для каждого `.vue` файла, для каждого element-node в template:
- Вычислить structural signature (см. §3).
- Если signature есть в cache → переиспользовать ID.
- Иначе → assign `manifest.lastId + 1`, добавить entry в cache.
- Инжектировать `data-dx="<N>"` как обычный атрибут в node.
3. По окончании transform'а — atomic write обновлённого manifest на диск (через temp-file + rename).
**Активация:**
```
plugin enabled ⇔ (NODE_ENV !== 'production') && (VITE_DEV_INDICES !== '0')
```
`VITE_DEV_INDICES=0` — явное отключение (для случаев когда нужен «чистый» dev-build без attributes).
### 2.2. Runtime: DevIndexOverlay
**Файлы:** `app/resources/js/components/DevIndexOverlay.vue`, `app/resources/js/composables/useDevIndices.ts`
**Mount:** в `App.vue` под `v-if="import.meta.env.DEV"`.
**Состояние (composable `useDevIndices`):**
- `currentTarget: HTMLElement | null` — текущий выделенный элемент
- `currentId: number | null` — его `data-dx`
- `overlayMode: boolean` — режим «show all»
- `hoverEnabled: boolean` — выключается на время Alt+стрелка / клик
**События:**
- `mousemove` (throttle 16ms) → `document.elementFromPoint(x, y)` → ближайший ancestor с `data-dx``currentTarget`.
- `keydown Alt+ArrowUp` → walk parent chain, найти ближайший ancestor с `data-dx``currentTarget`. Пауза hover'а на 800ms (чтобы случайное движение мыши не сбросило выбор).
- `keydown Alt+ArrowDown` → walk first-descendant chain (DFS), найти ближайший потомок с `data-dx`.
- `keydown Alt+Shift+I``overlayMode = !overlayMode`.
- `click` по badge → `navigator.clipboard.writeText('#' + currentId)` + кратко мигнуть зелёным.
- `keydown Escape``currentTarget = null; overlayMode = false`.
**Рендер:**
- Default mode: floating badge возле курсора (или возле currentTarget при Alt-navigation). Содержание: `#1030 · v-btn · "Создать"` (truncate text до 24 char).
- Overlay mode: для каждого `[data-dx]` элемента — мини-badge 8px в верхнем-левом углу через absolute positioning.
**Стили:** Forest palette (`--ld-teal: #0F6E56` фон, `#fff` текст, JetBrains Mono 11px). Консистентно с существующим `DevIndexBadge`.
## 3. Signature
```
signature = `${normalizedPath}::${ancestorTagChain}::${tagName}[${distinctiveAttrs}]::${textSnippet24}::${ordinalAmongSameTag}`
```
**Компоненты:**
- `normalizedPath` — относительный путь от `app/resources/js/`, без расширения. Пример: `components/AppSidebar`.
- `ancestorTagChain` — chain тегов родителей в шаблоне, не включая корень компонента. Пример: `nav>v-list-item`. Slot-content считается принадлежащим объявляющему компоненту (chain строится от template-root объявляющего файла, не от `<slot>`-сайта потребителя) — это держит signature стабильным при перемещении компонента между потребителями.
- `tagName` — имя тега: `v-btn`, `div`, `span`, etc.
- `distinctiveAttrs``key=plus,role=button` (только атрибуты из allowlist: `key`, `id`, `name`, `type`, `icon`, `role`, `data-dev-name`). Style/class не входят. **Только статические значения** (string-literal); связанные `:icon="x"` / `v-bind` игнорируются (значение неизвестно на build-time).
- `textSnippet24` — первые 24 символа **статического** текстового содержимого элемента, trim'нутые, lowercase. Mustache-выражения `{{ count }}` и v-text атрибуты игнорируются (значение runtime). Пустая строка если все text-children динамические или их нет.
- `ordinalAmongSameTag` — порядковый номер среди соседей **того же тега** в том же parent'е. 0-based.
**Escape-hatch:** атрибут `data-dev-name="<unique-slug>"` в исходнике делает signature = `${normalizedPath}::data-dev-name::${slug}`. ID привязывается к slug, переживает любые структурные изменения внутри файла.
## 4. Manifest schema
**Файл:** `app/dev-indices.json` (commit'ится в репо, ~3.5 МБ при 30k entries).
```json
{
"$schema": "./dev-indices.schema.json",
"version": 1,
"lastId": 5832,
"entries": {
"1030": {
"file": "resources/js/components/AppSidebar.vue",
"line": 42,
"tag": "v-btn",
"parentChain": ["AppSidebar", "nav", "v-list-item"],
"signature": "components/AppSidebar::nav>v-list-item::v-btn[icon=plus]::создать::0",
"text": "Создать проект",
"key": null,
"ref": null,
"createdAt": "2026-05-12T19:00:00Z"
}
},
"deleted": {
"1029": {
"lastSignature": "components/AppSidebar::nav::v-btn[icon=user]::профиль::0",
"lastFile": "resources/js/components/AppSidebar.vue",
"deletedAt": "2026-05-13T10:00:00Z"
}
}
}
```
**`dev-indices.schema.json`** — JSON Schema для валидации manifest'а на pre-commit.
**Tombstones:** удалённые ID не переиспользуются. Если элемент удалён → entry перемещается в `deleted`. `lastId` продолжает расти монотонно.
## 5. CLI: `npm run dx <id>`
**Файл:** `app/scripts/dev-indices-lookup.mjs`
Читает `dev-indices.json`, печатает entry в человекочитаемом виде:
```
$ npm run dx 1030
#1030 → resources/js/components/AppSidebar.vue:42
v-btn[icon=plus] "Создать проект"
parent: AppSidebar > nav > v-list-item
created: 2026-05-12
```
Если ID в tombstones — печатает `deleted at <date>, last seen in <file>` с подстановкой реальных значений.
## 6. Изменения файлов
**Новые:**
- `app/vite-plugins/dev-indices/index.ts` — plugin core (~300 строк)
- `app/vite-plugins/dev-indices/signature.ts` — signature computation (~100 строк)
- `app/vite-plugins/dev-indices/manifest.ts` — JSON IO + tombstones (~150 строк)
- `app/vite-plugins/dev-indices/__tests__/signature.test.ts` — unit tests
- `app/vite-plugins/dev-indices/__tests__/manifest.test.ts` — unit tests
- `app/vite-plugins/dev-indices/__tests__/integration.test.ts` — fixture-based
- `app/dev-indices.json` — manifest (commit, ~3.5 МБ финально)
- `app/dev-indices.schema.json` — JSON Schema
- `app/resources/js/components/DevIndexOverlay.vue` — runtime UI (~250 строк)
- `app/resources/js/composables/useDevIndices.ts` — state bus (~80 строк)
- `app/scripts/dev-indices-lookup.mjs` — CLI
**Изменённые:**
- `app/vite.config.ts` — регистрация плагина с dev-guard
- `app/resources/js/App.vue``<DevIndexOverlay v-if="$dev" />`
- `app/package.json``"dx": "node scripts/dev-indices-lookup.mjs"`
**Без изменений:**
- Существующий `app/resources/js/components/DevIndexBadge.vue` — остаётся как есть (route-level overlay в углу страницы, дополняет per-element overlay, не дублирует).
- Существующий `route.meta.devIndex` (1-28) — остаётся, не конфликтует (separate namespace).
## 7. Testing strategy
### 7.1. Unit (Vitest)
- `signature.test.ts`:
- Same template → same signature (idempotent).
- Реордер разных тегов → signature не меняется.
- Реордер одинаковых тегов → ordinal меняется.
- `data-dev-name` overrides structural signature.
- Минорные правки текста (внутри первых 24 char) не меняют signature.
- `manifest.test.ts`:
- Read / write roundtrip.
- `lastId` инкрементируется монотонно.
- Tombstones: удалённый entry не переиспользует ID.
- Schema validation на изменённом manifest'е.
### 7.2. Plugin integration
- `integration.test.ts`:
- Fixture `.vue` файл → run plugin → assert injected `data-dx` атрибуты.
- Assert manifest entries матчат ожидаемые signature.
- Повторный run на том же файле → ID идентичны.
### 7.3. E2E (Pest browser / Playwright — на усмотрение в плане)
- Hover element → badge появляется.
- `Alt+↑` → переход к parent badge.
- Click → clipboard содержит `#<N>`.
- `Alt+Shift+I` → все элементы получают мини-badges.
- `Esc` → overlay скрывается.
## 8. Производительность
- **Plugin run time:** ~500ms-2s на full build для ~80 `.vue` файлов (один раз, при старте Vite). HMR incremental — ~20ms на изменение одного файла.
- **Manifest size:** ~3.5 МБ JSON при ~30k entries. Парсится один раз при старте plugin'а, держится в памяти.
- **Browser overlay:** mousemove throttled 16ms; `elementFromPoint` O(1); re-render только при смене целевого элемента (не каждое движение мыши).
- **Production bundle:** ноль overhead — `import.meta.env.DEV` гарантирует tree-shake.
## 9. Известные ограничения (осознанно принимаем)
1. **Реордер одинаковых тегов сдвигает ID.** 3 `<v-btn>` подряд → IDs 1030, 1031, 1032. Поменял местами 1031↔1032 → ID меняются местами. **Mitigation:** добавлять `data-dev-name` на важные кнопки.
2. **Manifest merge-конфликты при параллельных PR.** Оба бранча добавляют новые IDs → конфликт в `entries` + `lastId`. **Решение на v1:** manual resolve через `npm run dev-indices:rebuild` (опционально lefthook hook). На v2 — auto-rebase pre-merge.
3. **Динамические v-for с переменным количеством детей.** Plugin видит только статический template — runtime-добавленные элементы получают ID того template-узла, который их генерирует. Все 20 строк таблицы будут иметь ОДИН `data-dx` на `<v-data-table>` элемент. Это известное ограничение build-time подхода. **Mitigation в overlay:** badge показывает «#1030 · v-data-table-row [item 5/20]» с индексом ряда из dataset.
## 10. Out-of-scope (YAGNI v1)
- Filter overlay по типу тега («показать только кнопки»).
- Diff manifest между ветками.
- Auto-suggest ID при добавлении элемента до commit.
- UI для bulk-rename / migration ID при крупном рефакторинге (решается через `data-dev-name`).
- Дублирующий overlay для Histoire (plugin сработает автоматически на её dev-server, отдельный UI не нужен).
- Включение plugin в staging-build по умолчанию (явный opt-in через `VITE_DEV_INDICES=1` при необходимости).
## 11. Открытые вопросы (для плана)
Нет открытых вопросов на момент утверждения дизайна. Дизайн утверждён в полной форме.