f90ddb09c1
Утверждённый дизайн: 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>
252 lines
16 KiB
Markdown
252 lines
16 KiB
Markdown
# 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. Открытые вопросы (для плана)
|
||
|
||
Нет открытых вопросов на момент утверждения дизайна. Дизайн утверждён в полной форме.
|