611506faa1
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>
2000 lines
65 KiB
Markdown
2000 lines
65 KiB
Markdown
# 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 |
|