# 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. Открытые вопросы (для плана)

Нет открытых вопросов на момент утверждения дизайна. Дизайн утверждён в полной форме.
