10 задач с TDD-разбиением: types + manifest IO → signature → Vite plugin core → vite.config wiring → JSON Schema → useDevIndices composable → DevIndexOverlay (hover/click/Esc + App.vue mount) → overlay Alt-keys + Alt+Shift+I toggle → CLI 'npm run dx <id>' → end-to-end smoke. Каждая задача self-contained, кончается commit'ом. App.vue mount через defineAsyncComponent + import.meta.env.DEV для надёжного tree-shake в production. Spec coverage table в конце плана. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
65 KiB
Dev Element Indices Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a Vite plugin that injects data-dx="N" on every element in .vue templates + persistent dev-indices.json manifest + runtime <DevIndexOverlay> (hover + Alt-keys + click-to-copy), enabling element-precision feedback («элемент 1030 измени цвет»).
Architecture: Build-time AST walk via @vue/compiler-sfc + structural signature lookup against dev-indices.json (committed to repo). Runtime browser overlay listens to mousemove/keydown, renders Forest-palette badge with selected element's index. Production: plugin disabled, overlay tree-shaken via import.meta.env.DEV.
Tech Stack: Vue 3 + Vuetify 3 + Vite 8 + Pinia + Vitest 4.1 + @vue/test-utils + jsdom. Plugin uses @vue/compiler-sfc and @vue/compiler-dom (Vue peer-deps; no new packages required for parsing). Uses magic-string (transitive dep via Vite/rollup) for source mutations.
Spec: docs/superpowers/specs/2026-05-12-dev-element-indices-design.md
File Structure
Plugin (build-time, Node):
app/vite-plugins/dev-indices/types.ts— shared TypeScript typesapp/vite-plugins/dev-indices/manifest.ts— JSON IO, lastId, tombstonesapp/vite-plugins/dev-indices/signature.ts— structural signature computationapp/vite-plugins/dev-indices/index.ts— Vite plugin (transform + buildEnd)app/vite-plugins/dev-indices/__tests__/manifest.test.tsapp/vite-plugins/dev-indices/__tests__/signature.test.tsapp/vite-plugins/dev-indices/__tests__/integration.test.tsapp/vite-plugins/dev-indices/__tests__/fixtures/sample.vue
Manifest:
app/dev-indices.json— committed to repo, source of truthapp/dev-indices.schema.json— JSON Schema validation
Runtime (browser):
app/resources/js/composables/useDevIndices.ts— overlay state busapp/resources/js/components/DevIndexOverlay.vue— UI badge, keyboard handlersapp/resources/js/composables/__tests__/useDevIndices.test.tsapp/resources/js/components/__tests__/DevIndexOverlay.test.ts
CLI:
app/scripts/dev-indices-lookup.mjs—npm run dx <id>
Modified:
app/vite.config.ts— register plugin under dev-guardapp/resources/js/App.vue— mount overlay underimport.meta.env.DEVapp/package.json— add"dx"script
Important first action for the engineer: verify the project's Vitest test convention. If existing tests live in a different location (e.g.,
tests/unit/), move the planned__tests__/paths to match. The plan assumes co-located__tests__/because it is a common Vitest pattern, but project convention wins.
Task 1: Types + Manifest IO module
Files:
-
Create:
app/vite-plugins/dev-indices/types.ts -
Create:
app/vite-plugins/dev-indices/manifest.ts -
Test:
app/vite-plugins/dev-indices/__tests__/manifest.test.ts -
Step 1: Create types.ts
// app/vite-plugins/dev-indices/types.ts
export interface ManifestEntry {
file: string; // relative to app/, forward slashes
line: number; // 1-based line in source
tag: string; // 'v-btn', 'div', etc.
parentChain: string[]; // ['AppSidebar', 'nav', 'v-list-item']
signature: string; // canonical signature
text: string | null; // first 24 chars of static text
key: string | null; // static :key attr value if present
ref: string | null; // ref="..." attr value if present
createdAt: string; // ISO-8601
}
export interface DeletedEntry {
lastSignature: string;
lastFile: string;
deletedAt: string;
}
export interface Manifest {
$schema?: string;
version: 1;
lastId: number;
entries: Record<string, ManifestEntry>; // key: id-as-string
deleted: Record<string, DeletedEntry>;
}
- Step 2: Write failing test for manifest.test.ts
// app/vite-plugins/dev-indices/__tests__/manifest.test.ts
import { describe, it, expect } from 'vitest';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
createEmpty,
loadManifest,
saveManifest,
findBySignature,
addEntry,
markDeleted,
} from '../manifest';
import type { ManifestEntry } from '../types';
const sampleEntry = (sig: string, file = 'resources/js/A.vue'): ManifestEntry => ({
file,
line: 1,
tag: 'v-btn',
parentChain: ['A'],
signature: sig,
text: 'hello',
key: null,
ref: null,
createdAt: '2026-05-12T00:00:00.000Z',
});
describe('manifest', () => {
it('createEmpty returns a fresh manifest with lastId=0', () => {
const m = createEmpty();
expect(m.version).toBe(1);
expect(m.lastId).toBe(0);
expect(m.entries).toEqual({});
expect(m.deleted).toEqual({});
});
it('addEntry assigns next id and increments lastId', () => {
const m = createEmpty();
const id1 = addEntry(m, sampleEntry('sig-1'));
const id2 = addEntry(m, sampleEntry('sig-2'));
expect(id1).toBe(1);
expect(id2).toBe(2);
expect(m.lastId).toBe(2);
expect(m.entries['1'].signature).toBe('sig-1');
});
it('findBySignature returns matching id', () => {
const m = createEmpty();
addEntry(m, sampleEntry('sig-a'));
addEntry(m, sampleEntry('sig-b'));
expect(findBySignature(m, 'sig-b')).toBe(2);
expect(findBySignature(m, 'sig-x')).toBeNull();
});
it('markDeleted moves entry to deleted section, preserves lastId', () => {
const m = createEmpty();
addEntry(m, sampleEntry('sig-x'));
markDeleted(m, 1);
expect(m.entries['1']).toBeUndefined();
expect(m.deleted['1'].lastSignature).toBe('sig-x');
expect(m.lastId).toBe(1); // monotonic, not decremented
});
it('deleted ids are not reused by addEntry', () => {
const m = createEmpty();
addEntry(m, sampleEntry('sig-x'));
markDeleted(m, 1);
const id = addEntry(m, sampleEntry('sig-y'));
expect(id).toBe(2);
});
it('saveManifest then loadManifest roundtrips data', () => {
const dir = mkdtempSync(join(tmpdir(), 'dx-'));
const path = join(dir, 'm.json');
const m = createEmpty();
addEntry(m, sampleEntry('sig-1'));
saveManifest(path, m);
const loaded = loadManifest(path);
expect(loaded.lastId).toBe(1);
expect(loaded.entries['1'].signature).toBe('sig-1');
rmSync(dir, { recursive: true });
});
it('loadManifest returns createEmpty() if file missing', () => {
const m = loadManifest('/nonexistent/path-' + Math.random() + '.json');
expect(m.lastId).toBe(0);
});
it('saveManifest writes atomically (no partial file on crash)', () => {
const dir = mkdtempSync(join(tmpdir(), 'dx-'));
const path = join(dir, 'm.json');
const m = createEmpty();
addEntry(m, sampleEntry('sig-x'));
saveManifest(path, m);
// Atomic write should leave no .tmp residue
expect(() => readFileSync(path + '.tmp')).toThrow();
rmSync(dir, { recursive: true });
});
});
- Step 3: Run test, verify failure
Run: cd app && npx vitest run vite-plugins/dev-indices/__tests__/manifest.test.ts
Expected: FAIL with Cannot find module '../manifest'.
- Step 4: Implement manifest.ts
// app/vite-plugins/dev-indices/manifest.ts
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import type { Manifest, ManifestEntry } from './types';
export function createEmpty(): Manifest {
return {
$schema: './dev-indices.schema.json',
version: 1,
lastId: 0,
entries: {},
deleted: {},
};
}
export function loadManifest(path: string): Manifest {
if (!existsSync(path)) return createEmpty();
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON.parse(raw) as Manifest;
if (parsed.version !== 1) {
throw new Error(`dev-indices manifest version ${parsed.version} unsupported (expected 1)`);
}
parsed.entries ??= {};
parsed.deleted ??= {};
return parsed;
} catch (err) {
if (err instanceof SyntaxError) {
throw new Error(`dev-indices manifest at ${path} is corrupt JSON: ${err.message}`);
}
throw err;
}
}
export function saveManifest(path: string, manifest: Manifest): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = path + '.tmp';
const json = JSON.stringify(manifest, null, 2);
writeFileSync(tmp, json, 'utf8');
renameSync(tmp, path);
}
export function findBySignature(manifest: Manifest, signature: string): number | null {
for (const [id, entry] of Object.entries(manifest.entries)) {
if (entry.signature === signature) return Number(id);
}
return null;
}
export function addEntry(manifest: Manifest, entry: ManifestEntry): number {
const id = manifest.lastId + 1;
manifest.lastId = id;
manifest.entries[String(id)] = entry;
return id;
}
export function markDeleted(manifest: Manifest, id: number): void {
const key = String(id);
const entry = manifest.entries[key];
if (!entry) return;
manifest.deleted[key] = {
lastSignature: entry.signature,
lastFile: entry.file,
deletedAt: new Date().toISOString(),
};
delete manifest.entries[key];
}
- Step 5: Run test, verify passing
Run: cd app && npx vitest run vite-plugins/dev-indices/__tests__/manifest.test.ts
Expected: PASS (8 tests).
- Step 6: Commit
git add app/vite-plugins/dev-indices/types.ts \
app/vite-plugins/dev-indices/manifest.ts \
app/vite-plugins/dev-indices/__tests__/manifest.test.ts
git commit -m "feat(dev-indices): manifest IO module (types + load/save/lookup/tombstones)"
Task 2: Signature module
Files:
- Create:
app/vite-plugins/dev-indices/signature.ts - Test:
app/vite-plugins/dev-indices/__tests__/signature.test.ts
Signature shape: ${normalizedPath}::${ancestorChain}::${tag}[${attrs}]::${textSnippet}::${ordinalAmongSameTag}
- Step 1: Write failing test for signature.test.ts
// app/vite-plugins/dev-indices/__tests__/signature.test.ts
import { describe, it, expect } from 'vitest';
import {
computeSignature,
normalizeFilePath,
extractStaticText,
extractDistinctiveAttrs,
type SignatureNode,
type SignatureContext,
} from '../signature';
// Minimal node shape that matches what we extract from @vue/compiler-dom AST.
// In production we adapt the parser's ElementNode → SignatureNode.
const node = (overrides: Partial<SignatureNode> = {}): SignatureNode => ({
tag: 'div',
props: [],
children: [],
...overrides,
});
const ctx = (overrides: Partial<SignatureContext> = {}): SignatureContext => ({
filePath: 'app/resources/js/components/AppSidebar.vue',
appRoot: 'app/',
ancestorChain: ['AppSidebar'],
sameTagOrdinal: 0,
...overrides,
});
describe('normalizeFilePath', () => {
it('strips appRoot and .vue extension, uses forward slashes', () => {
expect(
normalizeFilePath('app/resources/js/components/A.vue', 'app/'),
).toBe('resources/js/components/A');
});
it('handles backslashes on Windows-style paths', () => {
expect(
normalizeFilePath('app\\resources\\js\\A.vue', 'app/'),
).toBe('resources/js/A');
});
});
describe('extractStaticText', () => {
it('returns first 24 chars of plain text child', () => {
const n = node({ children: [{ type: 'text', content: 'Создать проект сейчас же' }] });
expect(extractStaticText(n)).toBe('создать проект сейчас же');
});
it('truncates >24 chars', () => {
const n = node({ children: [{ type: 'text', content: 'A'.repeat(50) }] });
expect(extractStaticText(n)).toHaveLength(24);
});
it('ignores mustache interpolations', () => {
const n = node({
children: [
{ type: 'text', content: 'count is ' },
{ type: 'interpolation', content: '{{ count }}' },
],
});
expect(extractStaticText(n)).toBe('count is');
});
it('returns null when no static text', () => {
const n = node({ children: [{ type: 'interpolation', content: '{{ x }}' }] });
expect(extractStaticText(n)).toBeNull();
});
});
describe('extractDistinctiveAttrs', () => {
it('extracts allowlisted static attrs sorted by name', () => {
const n = node({
tag: 'v-btn',
props: [
{ type: 'attribute', name: 'icon', value: 'plus' },
{ type: 'attribute', name: 'role', value: 'button' },
{ type: 'attribute', name: 'class', value: 'mr-2' },
],
});
expect(extractDistinctiveAttrs(n)).toBe('icon=plus,role=button');
});
it('ignores v-bind / : attrs (dynamic)', () => {
const n = node({
props: [
{ type: 'directive', name: 'bind', arg: 'icon', exp: 'someVar' },
{ type: 'attribute', name: 'id', value: 'static-id' },
],
});
expect(extractDistinctiveAttrs(n)).toBe('id=static-id');
});
it('returns empty string if no allowlisted attrs', () => {
const n = node({ props: [{ type: 'attribute', name: 'class', value: 'foo' }] });
expect(extractDistinctiveAttrs(n)).toBe('');
});
it('escape-hatch: data-dev-name takes precedence', () => {
const n = node({
tag: 'v-btn',
props: [
{ type: 'attribute', name: 'icon', value: 'plus' },
{ type: 'attribute', name: 'data-dev-name', value: 'sidebar.create-btn' },
],
});
expect(extractDistinctiveAttrs(n)).toContain('data-dev-name=sidebar.create-btn');
});
});
describe('computeSignature', () => {
it('canonical signature for a plain element', () => {
const n = node({
tag: 'v-btn',
props: [{ type: 'attribute', name: 'icon', value: 'plus' }],
children: [{ type: 'text', content: 'Создать' }],
});
expect(computeSignature(n, ctx({ ancestorChain: ['AppSidebar', 'nav', 'v-list-item'] }))).toBe(
'resources/js/components/AppSidebar::AppSidebar>nav>v-list-item::v-btn[icon=plus]::создать::0',
);
});
it('identical templates produce identical signatures', () => {
const a = node({ tag: 'span', children: [{ type: 'text', content: 'hi' }] });
const b = node({ tag: 'span', children: [{ type: 'text', content: 'hi' }] });
expect(computeSignature(a, ctx())).toBe(computeSignature(b, ctx()));
});
it('different ordinalAmongSameTag yields different signatures', () => {
const a = node({ tag: 'v-btn' });
const s0 = computeSignature(a, ctx({ sameTagOrdinal: 0 }));
const s1 = computeSignature(a, ctx({ sameTagOrdinal: 1 }));
expect(s0).not.toBe(s1);
});
it('data-dev-name overrides structural pieces', () => {
const a = node({
tag: 'v-btn',
props: [{ type: 'attribute', name: 'data-dev-name', value: 'sidebar.create' }],
});
const sigA = computeSignature(a, ctx({ sameTagOrdinal: 0 }));
// Same name, different ordinal — signature MUST be identical (escape hatch)
const sigB = computeSignature(a, ctx({ sameTagOrdinal: 7 }));
expect(sigA).toBe(sigB);
});
});
- Step 2: Run test, verify failure
Run: cd app && npx vitest run vite-plugins/dev-indices/__tests__/signature.test.ts
Expected: FAIL with Cannot find module '../signature'.
- Step 3: Implement signature.ts
// app/vite-plugins/dev-indices/signature.ts
const ALLOWED_ATTRS = ['data-dev-name', 'key', 'id', 'name', 'type', 'icon', 'role'];
const SNIPPET_MAX = 24;
export interface SignatureNodeChild {
type: 'text' | 'interpolation' | 'element' | 'comment';
content?: string;
}
export interface SignatureNodeProp {
type: 'attribute' | 'directive';
name: string;
value?: string;
arg?: string;
exp?: string;
}
export interface SignatureNode {
tag: string;
props: SignatureNodeProp[];
children: SignatureNodeChild[];
}
export interface SignatureContext {
filePath: string; // absolute or relative — anything load-time-stable
appRoot: string; // e.g. 'app/', used by normalizeFilePath
ancestorChain: string[]; // ['DeclaringComponent', 'div', 'nav']
sameTagOrdinal: number; // 0-based among siblings of same tag in same parent
}
export function normalizeFilePath(filePath: string, appRoot: string): string {
const normalized = filePath.replace(/\\/g, '/').replace(/\.vue$/, '');
const root = appRoot.replace(/\\/g, '/').replace(/\/$/, '') + '/';
return normalized.startsWith(root) ? normalized.slice(root.length) : normalized;
}
export function extractStaticText(node: SignatureNode): string | null {
const parts: string[] = [];
for (const child of node.children) {
if (child.type === 'text' && child.content) {
parts.push(child.content);
}
}
if (parts.length === 0) return null;
const merged = parts.join(' ').replace(/\s+/g, ' ').trim().toLowerCase();
if (!merged) return null;
return merged.slice(0, SNIPPET_MAX);
}
export function extractDistinctiveAttrs(node: SignatureNode): string {
const pairs: string[] = [];
for (const prop of node.props) {
if (prop.type !== 'attribute') continue; // skip directives / v-bind
if (!ALLOWED_ATTRS.includes(prop.name)) continue;
if (prop.value == null) continue;
pairs.push(`${prop.name}=${prop.value}`);
}
return pairs.sort().join(',');
}
function findDevName(node: SignatureNode): string | null {
for (const prop of node.props) {
if (prop.type === 'attribute' && prop.name === 'data-dev-name' && prop.value) {
return prop.value;
}
}
return null;
}
export function computeSignature(node: SignatureNode, ctx: SignatureContext): string {
const path = normalizeFilePath(ctx.filePath, ctx.appRoot);
const devName = findDevName(node);
if (devName) {
return `${path}::data-dev-name::${devName}`;
}
const ancestors = ctx.ancestorChain.join('>');
const attrs = extractDistinctiveAttrs(node);
const text = extractStaticText(node) ?? '';
return `${path}::${ancestors}::${node.tag}[${attrs}]::${text}::${ctx.sameTagOrdinal}`;
}
- Step 4: Run test, verify passing
Run: cd app && npx vitest run vite-plugins/dev-indices/__tests__/signature.test.ts
Expected: PASS (all tests green).
- Step 5: Commit
git add app/vite-plugins/dev-indices/signature.ts \
app/vite-plugins/dev-indices/__tests__/signature.test.ts
git commit -m "feat(dev-indices): signature module (structural + data-dev-name escape hatch)"
Task 3: Vite plugin core
Files:
- Create:
app/vite-plugins/dev-indices/index.ts - Test:
app/vite-plugins/dev-indices/__tests__/integration.test.ts - Test fixture:
app/vite-plugins/dev-indices/__tests__/fixtures/sample.vue
The plugin uses @vue/compiler-sfc to parse the SFC, gets the template AST (which is already produced by parse() via @vue/compiler-dom), walks element nodes, computes signatures, injects data-dx="N" attributes via magic-string.
- Step 1: Create test fixture
<!-- app/vite-plugins/dev-indices/__tests__/fixtures/sample.vue -->
<template>
<div class="root">
<v-btn icon="plus">Создать</v-btn>
<v-btn icon="user">Профиль</v-btn>
<v-list>
<v-list-item v-for="i in 3" :key="i">Item</v-list-item>
</v-list>
</div>
</template>
<script setup lang="ts">
</script>
- Step 2: Write failing integration test
// app/vite-plugins/dev-indices/__tests__/integration.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, readFileSync, rmSync, copyFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { devIndicesPlugin } from '../index';
const FIXTURE = resolve(__dirname, 'fixtures/sample.vue');
function runTransform(filePath: string, manifestPath: string) {
const plugin = devIndicesPlugin({
manifestPath,
appRoot: resolve(__dirname, '..', '..', '..'),
enabled: true,
});
// buildStart loads existing manifest (or createEmpty() if missing)
if (typeof plugin.buildStart === 'function') (plugin.buildStart as Function).call({});
const source = readFileSync(filePath, 'utf8');
const result = (plugin.transform as Function).call({}, source, filePath);
// buildEnd flushes manifest to disk (in real Vite, called once at end of build)
if (typeof plugin.buildEnd === 'function') (plugin.buildEnd as Function).call({});
return result?.code ?? result;
}
describe('vite-plugin-dev-indices integration', () => {
let dir: string;
let manifestPath: string;
let workingFixture: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'dx-int-'));
manifestPath = join(dir, 'dev-indices.json');
workingFixture = join(dir, 'sample.vue');
copyFileSync(FIXTURE, workingFixture);
});
it('injects data-dx attributes into every element', () => {
const code = runTransform(workingFixture, manifestPath);
expect(code).toMatch(/<div class="root" data-dx="\d+">/);
expect(code).toMatch(/<v-btn icon="plus" data-dx="\d+">/);
expect(code).toMatch(/<v-btn icon="user" data-dx="\d+">/);
expect(code).toMatch(/<v-list data-dx="\d+">/);
expect(code).toMatch(/<v-list-item v-for="i in 3" :key="i" data-dx="\d+">/);
});
it('writes manifest with one entry per element', () => {
runTransform(workingFixture, manifestPath);
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
// 5 elements in fixture
expect(Object.keys(manifest.entries)).toHaveLength(5);
expect(manifest.lastId).toBe(5);
});
it('second run on unchanged file produces identical IDs', () => {
const code1 = runTransform(workingFixture, manifestPath);
const code2 = runTransform(workingFixture, manifestPath);
expect(code1).toBe(code2);
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
expect(manifest.lastId).toBe(5);
});
it('returns null for non-.vue files', () => {
const plugin = devIndicesPlugin({ manifestPath, appRoot: dir, enabled: true });
expect((plugin.transform as Function).call({}, '// js', '/foo/bar.ts')).toBeNull();
});
it('returns null when disabled', () => {
const plugin = devIndicesPlugin({ manifestPath, appRoot: dir, enabled: false });
const source = readFileSync(workingFixture, 'utf8');
expect((plugin.transform as Function).call({}, source, workingFixture)).toBeNull();
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
});
Note: add import { afterEach } from 'vitest'; at the top.
- Step 3: Run test, verify failure
Run: cd app && npx vitest run vite-plugins/dev-indices/__tests__/integration.test.ts
Expected: FAIL with Cannot find module '../index'.
- Step 4: Implement index.ts (plugin core)
// app/vite-plugins/dev-indices/index.ts
import type { Plugin } from 'vite';
import { parse } from '@vue/compiler-sfc';
import { type ElementNode, NodeTypes } from '@vue/compiler-core';
import MagicString from 'magic-string';
import { relative, resolve } from 'node:path';
import type { Manifest, ManifestEntry } from './types';
import {
createEmpty,
loadManifest,
saveManifest,
findBySignature,
addEntry,
} from './manifest';
import {
computeSignature,
type SignatureNode,
type SignatureNodeProp,
type SignatureNodeChild,
} from './signature';
export interface DevIndicesPluginOptions {
manifestPath: string;
appRoot: string;
enabled: boolean;
}
interface ElementWalkContext {
ancestorChain: string[];
sameTagOrdinalStack: Map<string, number>[];
}
function nodeToSignatureNode(el: ElementNode): SignatureNode {
const props: SignatureNodeProp[] = el.props.map((p): SignatureNodeProp => {
if (p.type === NodeTypes.ATTRIBUTE) {
return { type: 'attribute', name: p.name, value: p.value?.content };
}
// DIRECTIVE node
return {
type: 'directive',
name: p.name,
arg: 'arg' in p && p.arg && 'content' in p.arg ? p.arg.content : undefined,
exp: 'exp' in p && p.exp && 'content' in p.exp ? p.exp.content : undefined,
};
});
const children: SignatureNodeChild[] = el.children.map((c): SignatureNodeChild => {
if (c.type === NodeTypes.TEXT) return { type: 'text', content: c.content };
if (c.type === NodeTypes.INTERPOLATION) return { type: 'interpolation' };
if (c.type === NodeTypes.ELEMENT) return { type: 'element' };
if (c.type === NodeTypes.COMMENT) return { type: 'comment' };
return { type: 'element' };
});
return { tag: el.tag, props, children };
}
function elementOpenTagEndOffset(el: ElementNode, templateContent: string, templateOffset: number): number {
// Find the position just before the closing `>` of the opening tag
// el.loc.start.offset is start of `<tag ...>` relative to templateContent
const startAbs = templateOffset + el.loc.start.offset;
// Walk forward in source to find unescaped `>` (skipping inside quotes)
let i = startAbs;
let inQuote: string | null = null;
while (i < templateOffset + templateContent.length) {
const ch = templateContent[i - templateOffset];
if (inQuote) {
if (ch === inQuote) inQuote = null;
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === '>') {
// self-closing: `/>` — insert before the `/`
if (templateContent[i - templateOffset - 1] === '/') return i - 1;
return i;
}
i++;
}
return -1;
}
export function devIndicesPlugin(options: DevIndicesPluginOptions): Plugin {
const { manifestPath, appRoot, enabled } = options;
let manifest: Manifest = createEmpty();
let dirty = false;
return {
name: 'vite-plugin-dev-indices',
buildStart() {
if (!enabled) return;
manifest = loadManifest(manifestPath);
},
transform(code, id) {
if (!enabled) return null;
if (!id.endsWith('.vue')) return null;
// Skip query-suffixed Vue requests (e.g. ?type=style)
if (id.includes('?')) return null;
const { descriptor } = parse(code, { filename: id });
if (!descriptor.template) return null;
const tplContent = descriptor.template.content;
const tplOffset = descriptor.template.loc.start.offset;
const ast = descriptor.template.ast;
if (!ast) return null;
const s = new MagicString(code);
const relPath = relative(appRoot, id).replace(/\\/g, '/');
const walk = (node: ElementNode | (typeof ast), chain: string[], siblingOrdinals: Map<string, number>) => {
const children = ('children' in node ? node.children : []) as ElementNode['children'];
const localOrdinals = new Map<string, number>();
for (const child of children) {
if (child.type !== NodeTypes.ELEMENT) continue;
const tag = child.tag;
const ord = localOrdinals.get(tag) ?? 0;
localOrdinals.set(tag, ord + 1);
const sig = computeSignature(nodeToSignatureNode(child), {
filePath: id,
appRoot,
ancestorChain: chain,
sameTagOrdinal: ord,
});
let assignedId = findBySignature(manifest, sig);
if (assignedId == null) {
const entry: ManifestEntry = {
file: relPath,
line: child.loc.start.line,
tag,
parentChain: [...chain],
signature: sig,
text:
child.children
.filter((c) => c.type === NodeTypes.TEXT)
.map((c) => (c as { content: string }).content)
.join(' ')
.trim()
.slice(0, 24) || null,
key: null,
ref: null,
createdAt: new Date().toISOString(),
};
assignedId = addEntry(manifest, entry);
dirty = true;
}
// Inject `data-dx="N"` into opening tag
const insertPos = elementOpenTagEndOffset(child, tplContent, tplOffset);
if (insertPos >= 0) {
s.appendLeft(insertPos, ` data-dx="${assignedId}"`);
}
walk(child, [...chain, tag], localOrdinals);
}
};
// Top-level: declaring component name from file path
const declaringName = id.split(/[\\/]/).pop()!.replace(/\.vue$/, '');
walk(ast, [declaringName], new Map());
// Flush manifest after every transform (idempotent if not dirty)
if (dirty) {
saveManifest(manifestPath, manifest);
dirty = false;
}
return {
code: s.toString(),
map: s.generateMap({ hires: true, source: id }),
};
},
buildEnd() {
if (!enabled) return;
if (dirty) {
saveManifest(manifestPath, manifest);
dirty = false;
}
},
};
}
export default devIndicesPlugin;
Note on Vue parser API surface. The exact import paths from
@vue/compiler-sfc/@vue/compiler-coremay shift across minor versions. If the test fails with a "module not found" or "NodeTypes undefined" error, the engineer should:
- Run
node -e "console.log(Object.keys(require('@vue/compiler-sfc')))"to inspect available exportsNodeTypesmay need to come from@vue/compiler-coredirectly or via@vue/compiler-domdescriptor.template.astexists from Vue 3.4+; verify withconsole.log(typeof descriptor.template.ast)firstAdjust imports inline. The algorithm itself doesn't change.
- Step 5: Run test, verify failure first (fixture missing or API issue)
Run: cd app && npx vitest run vite-plugins/dev-indices/__tests__/integration.test.ts
Expected: integration FAIL initially with parser-API issues; iterate until PASS.
If tests fail due to magic-string not installed: cd app && npm install --save-dev magic-string (Vite already ships with it transitively, but explicit dep is safer).
- Step 6: Iterate until tests pass
Common adjustments:
-
Use
import { NodeTypes } from '@vue/compiler-dom'if compiler-core doesn't re-export -
Use
descriptor.template.ast.childrendirectly if walk function shape is off -
Add
console.log(descriptor.template.ast)once to inspect actual structure, then remove -
Step 7: Run all dev-indices tests
Run: cd app && npx vitest run vite-plugins/dev-indices/
Expected: all PASS.
- Step 8: Commit
git add app/vite-plugins/dev-indices/index.ts \
app/vite-plugins/dev-indices/__tests__/integration.test.ts \
app/vite-plugins/dev-indices/__tests__/fixtures/sample.vue \
app/package.json app/package-lock.json
git commit -m "feat(dev-indices): Vite plugin core (transform + magic-string injection)"
Task 4: Wire plugin into vite.config.ts under dev-guard
Files:
-
Modify:
app/vite.config.ts -
Step 1: Read current vite.config.ts to determine integration point
Run: cat app/vite.config.ts — note where plugins array lives and existing plugin pattern.
- Step 2: Edit vite.config.ts to register plugin
Add import near top:
import { devIndicesPlugin } from './vite-plugins/dev-indices';
Inside defineConfig({ ... }), locate the plugins: [...] array and insert (keep this addition first or near vue()):
const devIndicesEnabled =
process.env.NODE_ENV !== 'production' && process.env.VITE_DEV_INDICES !== '0';
export default defineConfig({
// ... existing config ...
plugins: [
devIndicesPlugin({
manifestPath: resolve(__dirname, 'dev-indices.json'),
appRoot: __dirname,
enabled: devIndicesEnabled,
}),
// ... existing plugins (vue(), vuetify(), etc) ...
],
});
(If resolve and __dirname aren't already imported, add import { resolve } from 'node:path'; at top.)
- Step 3: Smoke-test dev server
Run: cd app && npm run dev
Expected:
- Server starts without errors
- Open browser to
/login, view source →<div>/<button>elements havedata-dx="N"attributes app/dev-indices.jsonis created with entries
Stop server (Ctrl+C) when verified.
- Step 4: Smoke-test production build doesn't trigger plugin
Run: cd app && NODE_ENV=production npm run build
Expected:
-
Build completes without manifest changes (manifest mtime unchanged or no manifest IO logged)
-
Inspect built bundle:
grep -c "data-dx" app/public/build/assets/*.js→ ideally 0 -
Step 5: Commit
git add app/vite.config.ts app/dev-indices.json
git commit -m "feat(dev-indices): register plugin in vite.config (dev-only guard)"
Task 5: JSON Schema validation
Files:
-
Create:
app/dev-indices.schema.json -
Modify:
app/vite-plugins/dev-indices/manifest.ts— add optional schema-validation hook -
Step 1: Create schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Dev Element Indices Manifest",
"type": "object",
"required": ["version", "lastId", "entries", "deleted"],
"properties": {
"$schema": { "type": "string" },
"version": { "const": 1 },
"lastId": { "type": "integer", "minimum": 0 },
"entries": {
"type": "object",
"patternProperties": {
"^[0-9]+$": {
"type": "object",
"required": ["file", "line", "tag", "parentChain", "signature", "createdAt"],
"properties": {
"file": { "type": "string" },
"line": { "type": "integer", "minimum": 1 },
"tag": { "type": "string" },
"parentChain": { "type": "array", "items": { "type": "string" } },
"signature": { "type": "string" },
"text": { "type": ["string", "null"] },
"key": { "type": ["string", "null"] },
"ref": { "type": ["string", "null"] },
"createdAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"deleted": {
"type": "object",
"patternProperties": {
"^[0-9]+$": {
"type": "object",
"required": ["lastSignature", "lastFile", "deletedAt"],
"properties": {
"lastSignature": { "type": "string" },
"lastFile": { "type": "string" },
"deletedAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
- Step 2: Verify VSCode picks up the schema
Open app/dev-indices.json in VSCode. JSON schema reference "./dev-indices.schema.json" in $schema field should now provide autocomplete/validation in the editor.
- Step 3: Commit
git add app/dev-indices.schema.json
git commit -m "feat(dev-indices): JSON Schema for manifest validation"
Note: runtime validation via
ajvis out-of-scope for v1 (YAGNI). Schema enables IDE validation. If runtime validation becomes needed, addajvdep + validation call inloadManifest.
Task 6: useDevIndices composable
Files:
- Create:
app/resources/js/composables/useDevIndices.ts - Test:
app/resources/js/composables/__tests__/useDevIndices.test.ts
This composable holds reactive state for the overlay: current target element, mode flags, navigation history.
- Step 1: Write failing test
// app/resources/js/composables/__tests__/useDevIndices.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useDevIndices } from '../useDevIndices';
describe('useDevIndices', () => {
beforeEach(() => {
// Composable uses module-level singleton — reset for test isolation
useDevIndices().reset();
});
it('initial state: no target, overlay off', () => {
const dx = useDevIndices();
expect(dx.currentId.value).toBeNull();
expect(dx.currentTarget.value).toBeNull();
expect(dx.overlayMode.value).toBe(false);
});
it('setTarget extracts data-dx attribute', () => {
const dx = useDevIndices();
const el = document.createElement('div');
el.setAttribute('data-dx', '1030');
dx.setTarget(el);
expect(dx.currentTarget.value).toBe(el);
expect(dx.currentId.value).toBe(1030);
});
it('setTarget(null) clears state', () => {
const dx = useDevIndices();
const el = document.createElement('div');
el.setAttribute('data-dx', '5');
dx.setTarget(el);
dx.setTarget(null);
expect(dx.currentId.value).toBeNull();
});
it('toggleOverlay flips overlayMode', () => {
const dx = useDevIndices();
expect(dx.overlayMode.value).toBe(false);
dx.toggleOverlay();
expect(dx.overlayMode.value).toBe(true);
dx.toggleOverlay();
expect(dx.overlayMode.value).toBe(false);
});
it('walkToParent finds nearest ancestor with data-dx', () => {
const dx = useDevIndices();
const grand = document.createElement('div');
grand.setAttribute('data-dx', '100');
const parent = document.createElement('span');
// parent has NO data-dx — should skip
const child = document.createElement('button');
child.setAttribute('data-dx', '200');
parent.appendChild(child);
grand.appendChild(parent);
dx.setTarget(child);
dx.walkToParent();
expect(dx.currentId.value).toBe(100);
expect(dx.currentTarget.value).toBe(grand);
});
it('walkToChild finds first descendant with data-dx (DFS)', () => {
const dx = useDevIndices();
const root = document.createElement('div');
root.setAttribute('data-dx', '1');
const inner = document.createElement('span');
inner.setAttribute('data-dx', '2');
root.appendChild(inner);
dx.setTarget(root);
dx.walkToChild();
expect(dx.currentId.value).toBe(2);
});
it('walkToParent at root does nothing', () => {
const dx = useDevIndices();
const el = document.createElement('div');
el.setAttribute('data-dx', '1');
dx.setTarget(el);
dx.walkToParent();
expect(dx.currentId.value).toBe(1);
});
});
- Step 2: Run test, verify failure
Run: cd app && npx vitest run resources/js/composables/__tests__/useDevIndices.test.ts
Expected: FAIL with module-not-found.
- Step 3: Implement useDevIndices.ts
// app/resources/js/composables/useDevIndices.ts
import { ref, type Ref } from 'vue';
interface DevIndicesApi {
currentTarget: Ref<HTMLElement | null>;
currentId: Ref<number | null>;
overlayMode: Ref<boolean>;
hoverEnabled: Ref<boolean>;
setTarget(el: HTMLElement | null): void;
toggleOverlay(): void;
walkToParent(): void;
walkToChild(): void;
pauseHover(ms: number): void;
reset(): void;
}
const currentTarget = ref<HTMLElement | null>(null);
const currentId = ref<number | null>(null);
const overlayMode = ref(false);
const hoverEnabled = ref(true);
let pauseTimer: ReturnType<typeof setTimeout> | null = null;
function parseId(el: HTMLElement | null): number | null {
if (!el) return null;
const raw = el.getAttribute('data-dx');
if (raw == null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
function setTarget(el: HTMLElement | null) {
if (el == null) {
currentTarget.value = null;
currentId.value = null;
return;
}
const id = parseId(el);
if (id == null) return;
currentTarget.value = el;
currentId.value = id;
}
function toggleOverlay() {
overlayMode.value = !overlayMode.value;
}
function findAncestorWithDx(el: HTMLElement | null): HTMLElement | null {
let cur: HTMLElement | null = el?.parentElement ?? null;
while (cur) {
if (cur.hasAttribute('data-dx')) return cur;
cur = cur.parentElement;
}
return null;
}
function findFirstDescendantWithDx(el: HTMLElement | null): HTMLElement | null {
if (!el) return null;
// BFS: find ANY descendant with data-dx
const queue: HTMLElement[] = Array.from(el.children) as HTMLElement[];
while (queue.length) {
const cur = queue.shift()!;
if (cur.hasAttribute('data-dx')) return cur;
queue.push(...(Array.from(cur.children) as HTMLElement[]));
}
return null;
}
function walkToParent() {
const parent = findAncestorWithDx(currentTarget.value);
if (parent) setTarget(parent);
}
function walkToChild() {
const child = findFirstDescendantWithDx(currentTarget.value);
if (child) setTarget(child);
}
function pauseHover(ms: number) {
hoverEnabled.value = false;
if (pauseTimer) clearTimeout(pauseTimer);
pauseTimer = setTimeout(() => {
hoverEnabled.value = true;
pauseTimer = null;
}, ms);
}
function reset() {
currentTarget.value = null;
currentId.value = null;
overlayMode.value = false;
hoverEnabled.value = true;
if (pauseTimer) {
clearTimeout(pauseTimer);
pauseTimer = null;
}
}
export function useDevIndices(): DevIndicesApi {
return {
currentTarget,
currentId,
overlayMode,
hoverEnabled,
setTarget,
toggleOverlay,
walkToParent,
walkToChild,
pauseHover,
reset,
};
}
- Step 4: Run test, verify passing
Run: cd app && npx vitest run resources/js/composables/__tests__/useDevIndices.test.ts
Expected: PASS (7 tests).
- Step 5: Commit
git add app/resources/js/composables/useDevIndices.ts \
app/resources/js/composables/__tests__/useDevIndices.test.ts
git commit -m "feat(dev-indices): useDevIndices composable (state + DOM walk)"
Task 7: DevIndexOverlay component (hover + click + esc + App.vue mount)
Files:
-
Create:
app/resources/js/components/DevIndexOverlay.vue -
Test:
app/resources/js/components/__tests__/DevIndexOverlay.test.ts -
Modify:
app/resources/js/App.vue(mount overlay) -
Step 1: Write failing test for the component
// app/resources/js/components/__tests__/DevIndexOverlay.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import DevIndexOverlay from '../DevIndexOverlay.vue';
import { useDevIndices } from '../../composables/useDevIndices';
describe('DevIndexOverlay', () => {
beforeEach(() => useDevIndices().reset());
it('hidden when no current target', () => {
const wrapper = mount(DevIndexOverlay);
expect(wrapper.find('.dx-badge').exists()).toBe(false);
});
it('shows badge with id + tag when target is set', async () => {
const el = document.createElement('button');
el.setAttribute('data-dx', '1030');
el.textContent = 'Создать';
document.body.appendChild(el);
const dx = useDevIndices();
dx.setTarget(el);
const wrapper = mount(DevIndexOverlay);
await nextTick();
const badge = wrapper.find('.dx-badge');
expect(badge.exists()).toBe(true);
expect(badge.text()).toContain('1030');
expect(badge.text()).toContain('button');
document.body.removeChild(el);
});
it('Esc clears the target', async () => {
const el = document.createElement('div');
el.setAttribute('data-dx', '5');
document.body.appendChild(el);
const dx = useDevIndices();
dx.setTarget(el);
mount(DevIndexOverlay);
await nextTick();
const evt = new KeyboardEvent('keydown', { key: 'Escape' });
document.dispatchEvent(evt);
await nextTick();
expect(dx.currentId.value).toBeNull();
document.body.removeChild(el);
});
it('clicking the badge copies "#<id>" to clipboard', async () => {
// Mock navigator.clipboard
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
const el = document.createElement('button');
el.setAttribute('data-dx', '1030');
document.body.appendChild(el);
useDevIndices().setTarget(el);
const wrapper = mount(DevIndexOverlay);
await nextTick();
await wrapper.find('.dx-badge').trigger('click');
expect(writeText).toHaveBeenCalledWith('#1030');
document.body.removeChild(el);
});
});
- Step 2: Run test, verify failure (component missing)
Run: cd app && npx vitest run resources/js/components/__tests__/DevIndexOverlay.test.ts
Expected: FAIL with module-not-found.
- Step 3: Implement DevIndexOverlay.vue (hover + click + esc only — Alt-keys in Task 8)
<!-- app/resources/js/components/DevIndexOverlay.vue -->
<template>
<Teleport to="body">
<div
v-if="currentId !== null && currentTarget"
class="dx-badge"
:class="{ 'dx-badge--copied': justCopied }"
:style="badgePosition"
@click.stop="copyToClipboard"
>
<span class="dx-badge__num">#{{ currentId }}</span>
<span class="dx-badge__meta">{{ tagLabel }} · "{{ textPreview }}"</span>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import { useDevIndices } from '../composables/useDevIndices';
const { currentId, currentTarget, hoverEnabled, setTarget, reset } = useDevIndices();
const cursorX = ref(0);
const cursorY = ref(0);
const justCopied = ref(false);
let mousemoveRAF: number | null = null;
const tagLabel = computed(() => {
const t = currentTarget.value;
if (!t) return '';
return t.tagName.toLowerCase();
});
const textPreview = computed(() => {
const t = currentTarget.value;
if (!t) return '';
const text = (t.textContent ?? '').trim().slice(0, 24);
return text || '—';
});
const badgePosition = computed(() => ({
left: `${cursorX.value + 12}px`,
top: `${cursorY.value + 12}px`,
}));
function onMousemove(e: MouseEvent) {
if (!hoverEnabled.value) return;
cursorX.value = e.clientX;
cursorY.value = e.clientY;
if (mousemoveRAF !== null) return;
mousemoveRAF = requestAnimationFrame(() => {
mousemoveRAF = null;
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
if (!el) {
setTarget(null);
return;
}
const withDx = el.closest('[data-dx]') as HTMLElement | null;
setTarget(withDx);
});
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
reset();
}
}
async function copyToClipboard() {
if (currentId.value === null) return;
try {
await navigator.clipboard.writeText(`#${currentId.value}`);
justCopied.value = true;
setTimeout(() => (justCopied.value = false), 400);
} catch {
// clipboard may be unavailable in some contexts; silent fail OK in dev tool
}
}
onMounted(() => {
document.addEventListener('mousemove', onMousemove);
document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onMousemove);
document.removeEventListener('keydown', onKeydown);
if (mousemoveRAF !== null) cancelAnimationFrame(mousemoveRAF);
});
</script>
<style scoped>
.dx-badge {
position: fixed;
z-index: 999999;
pointer-events: auto;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: #0f6e56;
color: #fff;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
line-height: 1.4;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
user-select: none;
transition: background 120ms ease;
}
.dx-badge--copied {
background: #21a16e;
}
.dx-badge__num {
background: rgba(255, 255, 255, 0.22);
padding: 1px 6px;
border-radius: 3px;
font-weight: 600;
}
.dx-badge__meta {
letter-spacing: 0.02em;
opacity: 0.92;
}
</style>
- Step 4: Run test, verify passing
Run: cd app && npx vitest run resources/js/components/__tests__/DevIndexOverlay.test.ts
Expected: PASS (4 tests).
If Teleport to body fails in jsdom (it sometimes warns), wrap the test in attachTo: document.body mount option. See repo memory quirk 53 for VDialog teleport pattern.
- Step 5: Mount overlay in App.vue
Edit app/resources/js/App.vue to add the overlay near the root, gated by import.meta.env.DEV. Use defineAsyncComponent so the import itself is dead-code-eliminated in production (Vite statically replaces import.meta.env.DEV with false, then the entire dynamic-import branch tree-shakes away):
<template>
<RouterView />
<component :is="DevIndexOverlay" v-if="DevIndexOverlay" />
</template>
<script setup lang="ts">
import { defineAsyncComponent, type Component } from 'vue';
const DevIndexOverlay: Component | null = import.meta.env.DEV
? defineAsyncComponent(() => import('./components/DevIndexOverlay.vue'))
: null;
</script>
If App.vue already has more content, integrate without removing existing structure — just add <component :is="DevIndexOverlay" v-if="DevIndexOverlay" /> after the main view and add the import gate in script. Task 10 verifies that production bundle has zero dx-badge references.
- Step 6: Smoke-test in browser
Run: cd app && npm run dev
Open localhost dev URL, navigate to /login. Move mouse — badge should appear near cursor showing #N · <tag> · "<text>". Click on badge → check clipboard contains #N. Press Esc → badge disappears.
- Step 7: Run all tests to make sure nothing else broke
Run: cd app && npx vitest run
Expected: full suite passes (≥579 + 11 new tests from Tasks 1+2+3+6+7).
- Step 8: Commit
git add app/resources/js/components/DevIndexOverlay.vue \
app/resources/js/components/__tests__/DevIndexOverlay.test.ts \
app/resources/js/App.vue
git commit -m "feat(dev-indices): DevIndexOverlay (hover badge + click-copy + Esc)"
Task 8: Overlay extras — Alt-keys + overlay-mode toggle
Files:
-
Modify:
app/resources/js/components/DevIndexOverlay.vue -
Modify:
app/resources/js/components/__tests__/DevIndexOverlay.test.ts -
Step 1: Add failing tests for Alt-keys and overlay-mode
Append to existing DevIndexOverlay.test.ts:
import { useDevIndices } from '../../composables/useDevIndices';
describe('DevIndexOverlay — Alt-keys + overlay-mode', () => {
beforeEach(() => useDevIndices().reset());
it('Alt+ArrowUp walks to parent', async () => {
const grand = document.createElement('div');
grand.setAttribute('data-dx', '100');
const child = document.createElement('button');
child.setAttribute('data-dx', '200');
grand.appendChild(child);
document.body.appendChild(grand);
const dx = useDevIndices();
dx.setTarget(child);
mount(DevIndexOverlay);
await nextTick();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true }));
await nextTick();
expect(dx.currentId.value).toBe(100);
document.body.removeChild(grand);
});
it('Alt+ArrowDown walks to first descendant', async () => {
const parent = document.createElement('div');
parent.setAttribute('data-dx', '1');
const child = document.createElement('span');
child.setAttribute('data-dx', '2');
parent.appendChild(child);
document.body.appendChild(parent);
const dx = useDevIndices();
dx.setTarget(parent);
mount(DevIndexOverlay);
await nextTick();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true }));
await nextTick();
expect(dx.currentId.value).toBe(2);
document.body.removeChild(parent);
});
it('Alt+Shift+I toggles overlay-mode', async () => {
mount(DevIndexOverlay);
await nextTick();
const dx = useDevIndices();
expect(dx.overlayMode.value).toBe(false);
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'I', altKey: true, shiftKey: true }),
);
await nextTick();
expect(dx.overlayMode.value).toBe(true);
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'I', altKey: true, shiftKey: true }),
);
await nextTick();
expect(dx.overlayMode.value).toBe(false);
});
it('overlay-mode renders mini-badges on all [data-dx] elements', async () => {
const a = document.createElement('div');
a.setAttribute('data-dx', '1');
const b = document.createElement('div');
b.setAttribute('data-dx', '2');
document.body.append(a, b);
const wrapper = mount(DevIndexOverlay);
useDevIndices().toggleOverlay();
await nextTick();
const minis = wrapper.findAll('.dx-mini');
expect(minis.length).toBe(2);
document.body.removeChild(a);
document.body.removeChild(b);
});
});
- Step 2: Run tests, expect failures
Run: cd app && npx vitest run resources/js/components/__tests__/DevIndexOverlay.test.ts
Expected: FAIL on 4 new tests (Alt-key handling + overlay-mode rendering).
- Step 3: Extend DevIndexOverlay.vue
In <template>, add overlay-mode rendering after the existing badge <Teleport>:
<Teleport to="body">
<div v-if="overlayMode" class="dx-mini-layer">
<div
v-for="el in overlayElements"
:key="el.id"
class="dx-mini"
:style="miniStyleFor(el.rect)"
>
#{{ el.id }}
</div>
</div>
</Teleport>
In <script setup>, merge the destructure at the top with the additional methods (one destructure call total):
const {
currentId, currentTarget, hoverEnabled, overlayMode,
setTarget, reset, pauseHover, walkToParent, walkToChild, toggleOverlay,
} = useDevIndices();
Then add overlay-mode rendering logic:
import { watch } from 'vue';
interface OverlayItem {
id: number;
rect: DOMRect;
}
const overlayElements = ref<OverlayItem[]>([]);
function refreshOverlayElements() {
const nodes = Array.from(document.querySelectorAll<HTMLElement>('[data-dx]'));
overlayElements.value = nodes
.map((el) => {
const idAttr = el.getAttribute('data-dx');
const id = Number(idAttr);
if (!Number.isFinite(id)) return null;
return { id, rect: el.getBoundingClientRect() };
})
.filter((x): x is OverlayItem => x !== null);
}
function miniStyleFor(rect: DOMRect) {
return {
left: `${rect.left}px`,
top: `${rect.top}px`,
};
}
watch(overlayMode, (on) => {
if (on) {
refreshOverlayElements();
window.addEventListener('resize', refreshOverlayElements);
window.addEventListener('scroll', refreshOverlayElements, true);
} else {
overlayElements.value = [];
window.removeEventListener('resize', refreshOverlayElements);
window.removeEventListener('scroll', refreshOverlayElements, true);
}
});
Replace the existing onKeydown function with this version (handles all keys: Escape + Alt+arrows + Alt+Shift+I):
function onKeydown(e: KeyboardEvent) {
if (e.altKey && e.shiftKey && (e.key === 'I' || e.key === 'i')) {
e.preventDefault();
toggleOverlay();
return;
}
if (e.altKey && e.key === 'ArrowUp') {
e.preventDefault();
walkToParent();
pauseHover(800);
return;
}
if (e.altKey && e.key === 'ArrowDown') {
e.preventDefault();
walkToChild();
pauseHover(800);
return;
}
if (e.key === 'Escape') {
reset();
}
}
(Existing onMounted/onBeforeUnmount registration of document.addEventListener('keydown', onKeydown) doesn't need changes — same function name, extended behavior.)
Add styles for .dx-mini:
.dx-mini-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999998;
}
.dx-mini {
position: fixed;
background: #0f6e56;
color: #fff;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 9px;
line-height: 1;
padding: 1px 3px;
border-radius: 2px;
pointer-events: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
Use watch(overlayMode, ...) to attach/detach the resize/scroll listeners.
- Step 4: Run tests, iterate until pass
Run: cd app && npx vitest run resources/js/components/__tests__/DevIndexOverlay.test.ts
Expected: PASS (8 tests total).
- Step 5: Smoke-test in browser
Run: cd app && npm run dev. In browser:
- Hover element → badge appears
- Alt+↑ → badge jumps to parent
- Alt+↓ → back to descendant
- Alt+Shift+I → all elements get mini-badges in their corners
- Alt+Shift+I again → mini-badges disappear
- Step 6: Commit
git add app/resources/js/components/DevIndexOverlay.vue \
app/resources/js/components/__tests__/DevIndexOverlay.test.ts
git commit -m "feat(dev-indices): overlay Alt-keys (↑/↓ navigate) + Alt+Shift+I toggle"
Task 9: CLI script + package.json
Files:
-
Create:
app/scripts/dev-indices-lookup.mjs -
Modify:
app/package.json -
Step 1: Create CLI script
// app/scripts/dev-indices-lookup.mjs
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MANIFEST = resolve(__dirname, '..', 'dev-indices.json');
function usage() {
console.error('Usage: npm run dx <id>');
console.error('Looks up element by dev-index in dev-indices.json.');
process.exit(2);
}
const arg = process.argv[2];
if (!arg || !/^\d+$/.test(arg)) usage();
const id = arg;
if (!existsSync(MANIFEST)) {
console.error(`dev-indices.json not found at ${MANIFEST}`);
console.error('Run "npm run dev" first to generate the manifest.');
process.exit(1);
}
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
if (manifest.entries?.[id]) {
const e = manifest.entries[id];
console.log(`#${id} → ${e.file}:${e.line}`);
console.log(`${e.tag}${e.text ? ` "${e.text}"` : ''}`);
console.log(`parent: ${e.parentChain.join(' > ')}`);
console.log(`signature: ${e.signature}`);
console.log(`created: ${e.createdAt.split('T')[0]}`);
process.exit(0);
}
if (manifest.deleted?.[id]) {
const d = manifest.deleted[id];
console.log(`#${id} (DELETED at ${d.deletedAt.split('T')[0]})`);
console.log(`last seen in ${d.lastFile}`);
console.log(`last signature: ${d.lastSignature}`);
process.exit(0);
}
console.error(`#${id} not found in manifest.`);
process.exit(1);
- Step 2: Add
dxscript to package.json
Edit app/package.json's scripts block, add:
"dx": "node scripts/dev-indices-lookup.mjs"
- Step 3: Smoke-test
Run: cd app && npm run dx 1
Expected output (assuming dev-indices.json has entry id 1):
#1 → resources/js/components/SomeComponent.vue:N
<tag> "<text>"
parent: ...
signature: ...
created: 2026-05-12
Also test edge cases:
-
cd app && npm run dx→ usage message, exit 2 -
cd app && npm run dx 999999→ "not found", exit 1 -
cd app && npm run dx abc→ usage message, exit 2 -
Step 4: Commit
git add app/scripts/dev-indices-lookup.mjs app/package.json
git commit -m "feat(dev-indices): CLI 'npm run dx <id>' for manifest lookup"
Task 10: End-to-end smoke + production tree-shake verification
Files: none new, all verification.
- Step 1: Dev mode end-to-end
Run: cd app && npm run dev
Open browser to dev URL. Verify:
view-source:for the page showsdata-dx="N"attributes on elements- Hover → badge appears
- Alt+↑/↓ → navigation works
- Alt+Shift+I → overlay-mode mini-badges
- Esc → clears
- Click badge → clipboard has
#N(verify by Ctrl+V in another app) app/dev-indices.jsonexists and contains entries
- Step 2: Production build sanity
Run: cd app && npm run build
After build completes:
# Check production bundle does NOT contain data-dx injections
grep -c "data-dx" app/public/build/assets/*.js
Expected: 0 matches in JS bundle.
# Check production bundle does NOT contain DevIndexOverlay code
grep -c "dx-badge" app/public/build/assets/*.css
Expected: 0 matches (overlay tree-shaken).
- Step 3: Verify dev-indices.json size is reasonable
wc -c app/dev-indices.json
Expected: <100 KB after first dev run (only a few visited pages); growing toward ~3.5 MB as all components get indexed.
- Step 4: Run full test suite
Run: cd app && npx vitest run
Expected: all tests pass (baseline 579 + ~22 new tests from Tasks 1, 2, 3, 6, 7, 8 = ~601).
- Step 5: Run linters
Run from project root:
npm run lint:md
npm run spell
Expected: 0 errors.
- Step 6: Final commit (manifest + any leftover small fixes)
git add app/dev-indices.json
git commit -m "feat(dev-indices): initial manifest from full app indexing"
- Step 7: Optional —
superpowers:requesting-code-reviewskill
After the implementation branch is complete, invoke superpowers:requesting-code-review skill to get an independent review before merging to main.
Spec coverage check
| Spec requirement | Covered by |
|---|---|
§1.1 — data-dx on every element |
Task 3 (plugin injection) |
| §1.1 — Hover badge | Task 7 (DevIndexOverlay default mode) |
| §1.1 — Alt+↑/↓ navigation | Task 6 (composable walks) + Task 8 (key handlers) |
| §1.1 — Alt+Shift+I toggle | Task 8 |
| §1.1 — Click → clipboard | Task 7 |
| §1.1 — Esc to hide | Task 7 |
| §1.2 — ID stability under reorder (different tags) | Task 2 (signature avoids siblingIndex except same-tag ordinal) |
| §1.2 — ID stability under attr/class changes | Task 2 (allowlist for distinctiveAttrs) |
| §1.2 — Tombstones (deleted IDs not reused) | Task 1 (markDeleted + lastId monotonic) |
| §1.3 — Production safety: zero overhead | Task 4 (dev-guard) + Task 7 (import.meta.env.DEV mount) + Task 10 step 2 (verification) |
| §1.4 — Manifest in repo, readable by Claude | Task 1 (saveManifest) + Task 4 (manifest path in vite.config) |
| §3 — Static-only attrs/text in signature | Task 2 (extractDistinctiveAttrs ignores directives; extractStaticText ignores interpolation) |
| §3 — Slot-content stays with declaring component | Task 3 (walk uses declaring component's filename as ancestor root) |
§3 — data-dev-name escape hatch |
Task 2 (findDevName) |
| §4 — Manifest schema | Task 5 |
§5 — CLI npm run dx <id> |
Task 9 |
| §6 — File list | Matches plan File Structure section |
| §7 — Testing strategy | Tasks 1, 2, 3, 6, 7, 8 each TDD-style |
| §8 — Performance | Validated in Task 10 smoke |
| §9 — Known limitations | Spec-documented; mitigation via data-dev-name is in Task 2 |
| §10 — Out-of-scope | None of these tasks implement out-of-scope items |