Утверждённый дизайн: 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>
16 KiB
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.jsoncommit'ится в репо.- Claude читает manifest через
Read/Greptools — мгновенный lookup1030 → 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:
- При старте Vite — однократно загрузить
dev-indices.jsonв память (cached map:signature → id+id → entry). - Для каждого
.vueфайла, для каждого element-node в template:- Вычислить structural signature (см. §3).
- Если signature есть в cache → переиспользовать ID.
- Иначе → assign
manifest.lastId + 1, добавить entry в cache. - Инжектировать
data-dx="<N>"как обычный атрибут в node.
- По окончании 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-dxoverlayMode: 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).
{
"$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 testsapp/vite-plugins/dev-indices/__tests__/manifest.test.ts— unit testsapp/vite-plugins/dev-indices/__tests__/integration.test.ts— fixture-basedapp/dev-indices.json— manifest (commit, ~3.5 МБ финально)app/dev-indices.schema.json— JSON Schemaapp/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-guardapp/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-nameoverrides 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 injecteddata-dxатрибуты. - Assert manifest entries матчат ожидаемые signature.
- Повторный run на том же файле → ID идентичны.
- Fixture
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;
elementFromPointO(1); re-render только при смене целевого элемента (не каждое движение мыши). - Production bundle: ноль overhead —
import.meta.env.DEVгарантирует tree-shake.
9. Известные ограничения (осознанно принимаем)
- Реордер одинаковых тегов сдвигает ID. 3
<v-btn>подряд → IDs 1030, 1031, 1032. Поменял местами 1031↔1032 → ID меняются местами. Mitigation: добавлятьdata-dev-nameна важные кнопки. - Manifest merge-конфликты при параллельных PR. Оба бранча добавляют новые IDs → конфликт в
entries+lastId. Решение на v1: manual resolve черезnpm run dev-indices:rebuild(опционально lefthook hook). На v2 — auto-rebase pre-merge. - Динамические 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. Открытые вопросы (для плана)
Нет открытых вопросов на момент утверждения дизайна. Дизайн утверждён в полной форме.