Files
portal/docs/superpowers/plans/2026-05-12-dev-element-indices-plan.md
T
Дмитрий 611506faa1 docs(plans): impl plan — dev element indices (10 tasks, TDD-bite-sized)
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>
2026-05-12 11:31:52 +03:00

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 types
  • app/vite-plugins/dev-indices/manifest.ts — JSON IO, lastId, tombstones
  • app/vite-plugins/dev-indices/signature.ts — structural signature computation
  • app/vite-plugins/dev-indices/index.ts — Vite plugin (transform + buildEnd)
  • app/vite-plugins/dev-indices/__tests__/manifest.test.ts
  • app/vite-plugins/dev-indices/__tests__/signature.test.ts
  • app/vite-plugins/dev-indices/__tests__/integration.test.ts
  • app/vite-plugins/dev-indices/__tests__/fixtures/sample.vue

Manifest:

  • app/dev-indices.json — committed to repo, source of truth
  • app/dev-indices.schema.json — JSON Schema validation

Runtime (browser):

  • app/resources/js/composables/useDevIndices.ts — overlay state bus
  • app/resources/js/components/DevIndexOverlay.vue — UI badge, keyboard handlers
  • app/resources/js/composables/__tests__/useDevIndices.test.ts
  • app/resources/js/components/__tests__/DevIndexOverlay.test.ts

CLI:

  • app/scripts/dev-indices-lookup.mjsnpm run dx <id>

Modified:

  • app/vite.config.ts — register plugin under dev-guard
  • app/resources/js/App.vue — mount overlay under import.meta.env.DEV
  • app/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-core may shift across minor versions. If the test fails with a "module not found" or "NodeTypes undefined" error, the engineer should:

  1. Run node -e "console.log(Object.keys(require('@vue/compiler-sfc')))" to inspect available exports
  2. NodeTypes may need to come from @vue/compiler-core directly or via @vue/compiler-dom
  3. descriptor.template.ast exists from Vue 3.4+; verify with console.log(typeof descriptor.template.ast) first

Adjust 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.children directly 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 have data-dx="N" attributes
  • app/dev-indices.json is 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 ajv is out-of-scope for v1 (YAGNI). Schema enables IDE validation. If runtime validation becomes needed, add ajv dep + validation call in loadManifest.


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:

  1. Hover element → badge appears
  2. Alt+↑ → badge jumps to parent
  3. Alt+↓ → back to descendant
  4. Alt+Shift+I → all elements get mini-badges in their corners
  5. 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 dx script 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:

  1. view-source: for the page shows data-dx="N" attributes on elements
  2. Hover → badge appears
  3. Alt+↑/↓ → navigation works
  4. Alt+Shift+I → overlay-mode mini-badges
  5. Esc → clears
  6. Click badge → clipboard has #N (verify by Ctrl+V in another app)
  7. app/dev-indices.json exists 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-review skill

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