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

16 KiB
Raw Blame History

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-dxcurrentTarget.
  • keydown Alt+ArrowUp → walk parent chain, найти ближайший ancestor с data-dxcurrentTarget. Пауза hover'а на 800ms (чтобы случайное движение мыши не сбросило выбор).
  • keydown Alt+ArrowDown → walk first-descendant chain (DFS), найти ближайший потомок с data-dx.
  • keydown Alt+Shift+IoverlayMode = !overlayMode.
  • click по badge → navigator.clipboard.writeText('#' + currentId) + кратко мигнуть зелёным.
  • keydown EscapecurrentTarget = 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.
  • distinctiveAttrskey=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 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. Открытые вопросы (для плана)

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