# 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](../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.mjs` — `npm 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**

```typescript
// 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**

```typescript
// 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**

```typescript
// 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**

```bash
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**

```typescript
// 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**

```typescript
// 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**

```bash
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**

```vue
<!-- 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**

```typescript
// 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)**

```typescript
// 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**

```bash
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:

```typescript
import { devIndicesPlugin } from './vite-plugins/dev-indices';
```

Inside `defineConfig({ ... })`, locate the `plugins: [...]` array and insert (keep this addition first or near `vue()`):

```typescript
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**

```bash
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**

```json
{
    "$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**

```bash
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**

```typescript
// 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**

```typescript
// 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**

```bash
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**

```typescript
// 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)**

```vue
<!-- 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):

```vue
<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**

```bash
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`:

```typescript
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>`:

```vue
<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):

```typescript
const {
    currentId, currentTarget, hoverEnabled, overlayMode,
    setTarget, reset, pauseHover, walkToParent, walkToChild, toggleOverlay,
} = useDevIndices();
```

Then add overlay-mode rendering logic:

```typescript
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):

```typescript
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`:

```css
.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**

```bash
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**

```javascript
// 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:

```json
"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**

```bash
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:

```bash
# Check production bundle does NOT contain data-dx injections
grep -c "data-dx" app/public/build/assets/*.js
```

Expected: 0 matches in JS bundle.

```bash
# 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**

```bash
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:

```bash
npm run lint:md
npm run spell
```

Expected: 0 errors.

- [ ] **Step 6: Final commit (manifest + any leftover small fixes)**

```bash
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 |
