diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 89bd6eb..ef05db8 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -19,7 +19,7 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- - run: npx playwright install --with-deps chromium
+ - run: npx playwright install --with-deps chromium firefox
- run: npm run check
- run: npm test
- run: npm run build
diff --git a/PROJECT.md b/PROJECT.md
index 796a985..ee9fd2d 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -63,6 +63,10 @@ Erster realer Adapter ist das Project-Listing in `astro-foundation` mit Controls
- visuelle Website-Komponenten oder Design Tokens;
- ein eigenes UI-Framework.
+## V1 DOM-Inspector und Layouting
+
+Das Tool bietet drei getrennte Ansichten (`Inspect`, `Compose`, `Changes`). Inspect erlaubt einen freien, sicheren DOM-Picker mit Breadcrumb-/Parent-/Child-Navigation, registriertem Component-/Scope-Kontext und read-only Layout-Fakten. Compose verwendet ausschließlich explizit registrierte Website-Recipes. Changes exportiert nur Design-Diffs und begrenzte Annotationen/Intent als Agent-Brief. Auswahl- und Panel-Zustand bleiben aus Design-State und Recipes ausgeschlossen. Picker, Annotationen, Freeze Motion, Undo/Redo und Handoff schreiben keine Quelldateien.
+
## Aktueller Fokus
1. Das öffentliche Paket und seine kleine Konfigurationsschnittstelle liefern.
diff --git a/README.md b/README.md
index b15162e..1b058d3 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,10 @@ Website-CSS kann Tailwind über semantische Layer verwenden. Dynamische Reglerwe
Das Panel startet platzsparend eingeklappt. Es bietet Reset, Permalink-, JSON- und TypeScript-Copy sowie JSON-Download. Es ist vollständig ausblendbar und per Cmd/Ctrl+Shift+D wiederherstellbar beziehungsweise umschaltbar. Tastaturfokus und Reduced Motion werden berücksichtigt. URL-State verwendet nur den Parameter `fd` und erhält vorhandene Parameter.
+## DOM-Inspector (V1)
+
+Im Development-Modus stehen `Inspect`, `Compose` und `Changes` zur Verfügung. `Pick DOM` wählt beliebige Elemente; registrierte Scopes/Targets liefern Kontext, während Layout-Fakten (Bounding Box, Display, Grid, Gap, Position und Overflow) read-only bleiben. Bis zu acht begrenzte Annotationen und ein optionaler Intent werden zusammen mit dem changes-only Diff als `Copy agent brief` exportiert. `Freeze motion`, Undo/Redo, Tastaturkürzel und Drag/Snap unterstützen die Arbeit auf der echten Seite. Beim Auftauen kann eine laufende CSS-Transition browserbedingt nicht an ihrer exakten Zwischenposition fortgesetzt werden; fremde Inline-Styles werden dabei nicht verändert. Es gibt keine Source-Writes, keine Agent-Bridge und keinen visuellen Recipe-Generator.
+
## Entwicklung
```bash
diff --git a/fixture/smoke.html b/fixture/smoke.html
index 5050a17..a78db43 100644
--- a/fixture/smoke.html
+++ b/fixture/smoke.html
@@ -4,14 +4,16 @@
Card Card
Second registered hero target
-
+
diff --git a/src/core.ts b/src/core.ts
index 6fd9811..695b767 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -70,11 +70,21 @@ export type Family = {
effect?: Effect;
target?: string;
};
+export type Recipe = { key: string; label: string; state: Partial; description?: string };
+export type ComposeRegistration = {
+ recipes?: readonly string[];
+ families?: readonly string[];
+ controls?: readonly string[];
+};
export type DevtoolsConfig = {
project: string;
families: readonly Family[];
controls?: readonly Control[];
targets?: readonly Target[];
+ registrations?: readonly DomRegistration[];
+ recipes?: readonly Recipe[];
+ /** Explicit allow-list for the Compose view; omitted means nothing is composed. */
+ compose?: ComposeRegistration;
metadata?: Metadata;
queryKey?: string;
};
@@ -97,6 +107,14 @@ function fail(message: string): never {
throw new Error(`Invalid devtools config: ${message}`);
}
+function validateCompose(value: unknown): asserts value is ComposeRegistration {
+ if (!isRecord(value) || Object.keys(value).some((key) => !['recipes', 'families', 'controls'].includes(key))) fail('compose');
+ for (const key of ['recipes', 'families', 'controls'] as const) {
+ const values = value[key];
+ if (values !== undefined && (!Array.isArray(values) || values.some((item) => typeof item !== 'string' || !keyPattern.test(item)))) fail('compose');
+ }
+}
+
function validateEffect(effect: unknown): asserts effect is Effect {
if (!isRecord(effect) || typeof effect.scope !== 'string' || !scopePattern.test(effect.scope)) {
fail('effect scope');
@@ -119,6 +137,9 @@ export function validateConfig(input: unknown): DevtoolsConfig {
fail('query key');
}
if (input.targets !== undefined && !Array.isArray(input.targets)) fail('targets');
+ if (input.registrations !== undefined && (!Array.isArray(input.registrations) || input.registrations.some((item) => !isRecord(item) || typeof item.key !== 'string' || !keyPattern.test(item.key) || (item.label !== undefined && typeof item.label !== 'string') || (item.scope !== undefined && (typeof item.scope !== 'string' || !scopePattern.test(item.scope))) || (item.target !== undefined && typeof item.target !== 'string')))) fail('registrations');
+ if (input.recipes !== undefined && (!Array.isArray(input.recipes) || input.recipes.some((item) => !isRecord(item) || typeof item.key !== 'string' || !keyPattern.test(item.key) || typeof item.label !== 'string' || !isRecord(item.state)))) fail('recipes');
+ if (input.compose !== undefined) validateCompose(input.compose);
const targets: Target[] = [];
const targetKeys = new Set();
for (const targetValue of (Array.isArray(input.targets) ? input.targets : [])) {
@@ -135,6 +156,12 @@ export function validateConfig(input: unknown): DevtoolsConfig {
const names = new Set();
const families: Family[] = [];
+ const registrationKeys = new Set();
+ for (const registration of (Array.isArray(input.registrations) ? input.registrations : [])) {
+ if (registrationKeys.has(registration.key as string) || (registration.target !== undefined && !targetKeys.has(registration.target as string))) fail('registration');
+ registrationKeys.add(registration.key as string);
+ }
+
for (const familyValue of input.families) {
if (!isRecord(familyValue) || typeof familyValue.key !== 'string' ||
!keyPattern.test(familyValue.key) || names.has(familyValue.key) ||
@@ -208,6 +235,14 @@ export function validateConfig(input: unknown): DevtoolsConfig {
}
const config = { ...input, families, controls } as unknown as DevtoolsConfig;
+ if (config.compose) {
+ const recipeKeys = new Set((config.recipes ?? []).map((item) => item.key));
+ const familyKeys = new Set(config.families.map((item) => item.key));
+ const controlKeys = new Set((config.controls ?? []).map((item) => item.key));
+ for (const key of config.compose.recipes ?? []) if (!recipeKeys.has(key)) fail('compose recipe');
+ for (const key of config.compose.families ?? []) if (!familyKeys.has(key)) fail('compose family');
+ for (const key of config.compose.controls ?? []) if (!controlKeys.has(key)) fail('compose control');
+ }
for (const family of config.families) {
for (const variant of family.variants) {
for (const controlKey of Object.keys(variant.defaults ?? {})) {
@@ -292,6 +327,11 @@ export function stateUrl(config: DevtoolsConfig, state: DevtoolsState, url?: str
return result.toString();
}
+function redactedMetadata(metadata?: Metadata): Metadata | undefined {
+ if (!metadata) return undefined;
+ return { ...metadata, ...(metadata.route !== undefined ? { route: safeRoute(metadata.route) } : {}) };
+}
+
function kebab(value: string): string {
return value.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
}
@@ -375,7 +415,8 @@ export function changes(config: DevtoolsConfig, state: DevtoolsState, baseline:
if (from !== to) { const target = control.target ? targetMap.get(control.target) : undefined;
result.push({ key: control.key, label: control.label, kind: 'control', from, to, ...(control.target ? { target: control.target, targetKind: target?.kind } : {}), ...(control.classification ? { classification: control.classification } : {}) }); }
}
- return { project: config.project, ...(config.metadata ? { metadata: config.metadata } : {}), changes: result, count: result.length };
+ const metadata = redactedMetadata(config.metadata);
+ return { project: config.project, ...(metadata ? { metadata } : {}), changes: result, count: result.length };
}
export function changesJson(config: DevtoolsConfig, state: DevtoolsState, baseline?: DevtoolsState): string {
@@ -384,9 +425,10 @@ export function changesJson(config: DevtoolsConfig, state: DevtoolsState, baseli
export function agentBrief(config: DevtoolsConfig, state: DevtoolsState, baseline?: DevtoolsState): string {
const diff = changes(config, state, baseline);
- if (!diff.count) return `# ${config.project} changes\n\nNo changes.`;
+ const metadata = diff.metadata ? `\n\n## Metadata\n\n\`${JSON.stringify(diff.metadata)}\`` : '';
+ if (!diff.count) return `# ${config.project} changes${metadata}\n\nNo changes.`;
const lines = diff.changes.map((item) => `- **${item.label}** (${item.kind}${item.target ? `, target: ${item.target}` : ''}${item.classification ? `, ${item.classification}` : ''}): \`${String(item.from)}\` → \`${String(item.to)}\``);
- return `# ${config.project} changes\n\n${lines.join('\n')}`;
+ return `# ${config.project} changes${metadata}\n\n${lines.join('\n')}`;
}
/** Return a fresh state baseline; useful for scoped reset controls. */
@@ -416,6 +458,156 @@ export function typescriptRecipe(config: DevtoolsConfig, state: DevtoolsState):
return `import { defineDevtoolsConfig, validateState } from '@cray-com/foundation-devtools';\n\nconst config = defineDevtoolsConfig(${JSON.stringify(config, null, 2)});\nconst state = validateState(config, ${JSON.stringify(validateState(config, state), null, 2)});\n\nexport { config, state };\n`;
}
+/** A safe, explicit DOM registration. Registrations never infer controls from classes. */
+export type DomRegistration = {
+ key: string;
+ label?: string;
+ scope?: string;
+ target?: string;
+};
+export type LayoutFacts = {
+ rect: { x: number; y: number; width: number; height: number };
+ display: string;
+ gridColumns: string;
+ gap: string;
+ position: string;
+ overflow: string;
+ container?: string;
+ path: string;
+};
+export type Annotation = {
+ route: string;
+ locale?: string;
+ target?: string;
+ component?: string;
+ scope?: string;
+ selector: string;
+ rect: LayoutFacts['rect'];
+ comment?: string;
+};
+
+/** Return only the route path; query strings, fragments, and credentials never enter exports. */
+export function safeRoute(value: string): string {
+ if (typeof value !== 'string') return '/';
+ try { const url = new URL(value, 'http://localhost'); return url.pathname || '/'; } catch { return '/'; }
+}
+function cssIdent(value: string): string {
+ return value.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char.charCodeAt(0).toString(16)} `);
+}
+const safeDomId = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
+function rootOf(element: Element): Document | ShadowRoot { return element.getRootNode() as Document | ShadowRoot; }
+function uniqueId(element: Element, id: string): boolean {
+ if (!safeDomId.test(id)) return false;
+ try { return rootOf(element).querySelectorAll(`#${cssIdent(id)}`).length === 1; } catch { return false; }
+}
+function elementSegment(element: Element, root: Document | ShadowRoot): string {
+ const tag = element.tagName.toLowerCase(); const id = element.getAttribute('id');
+ if (id && uniqueId(element, id)) return `${tag}#${cssIdent(id)}`;
+ const parent: Element | null = element.parentElement;
+ const siblings = parent
+ ? Array.from(parent.children).filter((child) => child.tagName === element.tagName)
+ : Array.from(root.children).filter((child) => child.tagName === element.tagName);
+ return `${tag}:nth-of-type(${Math.max(1, siblings.indexOf(element) + 1)})`;
+}
+/** Element-exact, bounded path. ` >>> ` is an explicit open ShadowRoot boundary. */
+export function domPath(element: Element, limit = 32): string {
+ const parts: string[] = []; let current: Element | null = element; let root = rootOf(element);
+ while (current && parts.length < Math.max(1, limit) && current.nodeType === 1) {
+ parts.unshift(elementSegment(current, root));
+ const parent: Element | null = current.parentElement;
+ if (parent) current = parent;
+ else {
+ const shadow = root as ShadowRoot;
+ const host = shadow.host;
+ if (host) { parts.unshift('>>>'); current = host; root = rootOf(host); }
+ else current = null;
+ }
+ }
+ return parts.join(' > ').replace(/ > >>> > /g, ' >>> ').slice(0, 512);
+}
+/** Resolve a domPath, including its explicit open-shadow boundaries. */
+export function resolvePath(path: string, root: Document | ShadowRoot = document): Element | undefined {
+ if (typeof path !== 'string' || path.length === 0 || path.length > 512) return undefined;
+ let currentRoot: Document | ShadowRoot = root; let value = path;
+ try {
+ const chunks = value.split(' >>> ');
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
+ const segments = chunks[chunkIndex].split(' > ');
+ let current: Element | null = null;
+ for (const segment of segments) {
+ if (!segment || segment === '>>>') return undefined;
+ current = current ? current.querySelector(segment) : currentRoot.querySelector(segment);
+ if (!current) return undefined;
+ }
+ if (chunkIndex < chunks.length - 1) {
+ if (!current?.shadowRoot) return undefined;
+ currentRoot = current.shadowRoot;
+ } else return current ?? undefined;
+ }
+ } catch { return undefined; }
+ return undefined;
+}
+function dataAncestor(element: Element, name: string): string | undefined {
+ let current: Element | null = element;
+ while (current) { const value = current.getAttribute(name); if (value) return value; current = current.parentElement ?? ((current.getRootNode() as ShadowRoot).host ?? null); }
+ return undefined;
+}
+export function layoutFacts(element: Element): LayoutFacts {
+ const rect = element.getBoundingClientRect();
+ const style = getComputedStyle(element);
+ const parent = element.parentElement;
+ return { rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, display: style.display, gridColumns: style.gridTemplateColumns, gap: style.gap, position: style.position, overflow: style.overflow, ...(parent ? { container: parent.tagName.toLowerCase() } : {}), path: domPath(element) };
+}
+export function annotationFor(element: Element, config?: DevtoolsConfig, comment?: string): Annotation {
+ const scopeValue = dataAncestor(element, 'data-fd-scope');
+ const targetValue = dataAncestor(element, 'data-fd-target');
+ const registration = config?.registrations?.find((item) => (item.scope ?? item.key) === scopeValue);
+ const target = config?.targets?.some((item) => item.key === targetValue) ? targetValue : undefined;
+ const registrationTarget = registration?.target;
+ const scope = registration ? (registration.scope ?? scopeValue) : undefined;
+ const rect = element.getBoundingClientRect();
+ return { route: safeRoute(typeof location === 'undefined' ? '/' : location.href), ...(config?.metadata?.locale ? { locale: config.metadata.locale } : {}), ...(target || registrationTarget ? { target: target ?? registrationTarget } : {}), ...(registration ? { component: registration.label ?? registration.key } : {}), ...(scope ? { scope } : {}), selector: domPath(element), rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, ...(comment ? { comment: comment.slice(0, 500) } : {}) };
+}
+export function recipeRegistry(config: DevtoolsConfig): readonly Recipe[] { return (config.recipes ?? []).map((item) => ({ ...item, state: validateState(config, item.state) })); }
+
+const ANNOTATION_LIMITS = { route: 256, locale: 64, target: 96, component: 128, scope: 96, selector: 512, comment: 500 } as const;
+const HANDOFF_MAX = 16_000;
+function cleanString(value: unknown, limit: number): string | undefined {
+ if (typeof value !== 'string') return undefined;
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, limit);
+}
+function finiteBox(value: unknown): Annotation['rect'] {
+ const input = value && typeof value === 'object' ? value as Record : {};
+ const number = (key: string, minimum: number, maximum: number) => {
+ const item = input[key];
+ return typeof item === 'number' && Number.isFinite(item) ? Math.min(maximum, Math.max(minimum, item)) : 0;
+ };
+ return { x: number('x', -100_000, 100_000), y: number('y', -100_000, 100_000), width: number('width', 0, 100_000), height: number('height', 0, 100_000) };
+}
+/** Copy only the annotation schema. This deliberately drops every unknown field and never mutates input. */
+function safeAnnotation(value: unknown): Annotation | undefined {
+ if (!value || typeof value !== 'object') return undefined;
+ const input = value as Record;
+ const route = cleanString(input.route, ANNOTATION_LIMITS.route);
+ const selector = cleanString(input.selector, ANNOTATION_LIMITS.selector);
+ if (!route || !selector) return undefined;
+ const result: Annotation = { route: safeRoute(route), selector, rect: finiteBox(input.rect) };
+ for (const key of ['locale', 'target', 'component', 'scope', 'comment'] as const) {
+ const item = cleanString(input[key], ANNOTATION_LIMITS[key]);
+ if (item) result[key] = item;
+ }
+ return result;
+}
+export function handoff(config: DevtoolsConfig, state: DevtoolsState, selected?: Annotation, annotations: readonly Annotation[] = [], intent?: string): string {
+ const diff = changes(config, state);
+ const safeSelected = safeAnnotation(selected);
+ const safeAnnotations = Array.isArray(annotations) ? annotations.map(safeAnnotation).filter((item): item is Annotation => Boolean(item)).slice(0, 8) : [];
+ const safeIntent = cleanString(intent, 500);
+ const payload = { ...(safeSelected ? { selected: safeSelected } : {}), annotations: safeAnnotations, ...(safeIntent ? { intent: safeIntent } : {}), changes: diff };
+ const output = agentBrief(config, state) + '\n\n## Selection context\n\n```json\n' + JSON.stringify(payload) + '\n```';
+ return output.slice(0, HANDOFF_MAX);
+}
+
export function defineDevtoolsConfig(config: DevtoolsConfig): DevtoolsConfig {
return validateConfig(config);
}
diff --git a/src/element.ts b/src/element.ts
index db75caf..cec4f4e 100644
--- a/src/element.ts
+++ b/src/element.ts
@@ -10,6 +10,13 @@ import {
stateUrl,
typescriptRecipe,
validateConfig,
+ validateState,
+ annotationFor,
+ layoutFacts,
+ resolvePath,
+ handoff,
+ recipeRegistry,
+ type Annotation,
type Control,
type DevtoolsConfig,
type DevtoolsState,
@@ -19,8 +26,8 @@ import {
const styles = `
:host { all: initial; position: fixed; inset: auto 12px 12px auto; z-index: 2147483647; color: #e9edf2; font: 12px/1.3 ui-monospace, SFMono-Regular, monospace; }
* { box-sizing: border-box; }
-.panel { width: min(360px, calc(100vw - 24px)); max-height: min(760px, calc(100vh - 24px)); overflow: auto; background: #17191c; border: 1px solid #454a51; border-radius: 6px; box-shadow: 0 5px 24px #0008; }
-.bar { display: flex; align-items: center; gap: 6px; padding: 5px 7px; background: #22262a; position: sticky; top: 0; }
+.panel { width: min(360px, calc(100vw - 24px)); max-height: min(760px, 70vh, calc(100vh - 24px)); overflow: auto; background: #17191c; border: 1px solid #454a51; border-radius: 6px; box-shadow: 0 5px 24px #0008; }
+.bar { display: flex; align-items: center; gap: 6px; padding: 5px 7px; background: #22262a; position: sticky; top: 0; } .tabs { display:flex; gap:2px; padding:4px 7px; border-bottom:1px solid #353a40; } .tabs button { flex:1; }
.title { flex: 1; font-weight: 700; } button { border: 0; border-radius: 3px; padding: 4px 6px; color: inherit; background: #292e34; cursor: pointer; } .icon { background: transparent; font-size: 15px; }
button:focus, select:focus, input:focus { outline: 2px solid #74b9ff; outline-offset: 1px; } button[aria-pressed="true"] { background: #4a5868; color: #fff; }
.body { display: grid; gap: 8px; padding: 9px; } .compare { display: flex; gap: 3px; } .map { display: grid; gap: 2px; padding: 5px; border: 1px solid #353a40; border-radius: 4px; } .map::before { content: 'Page'; padding: 1px 3px 3px; color: #9ba3ad; text-transform: uppercase; letter-spacing: .08em; } .map button { display: flex; justify-content: space-between; gap: 8px; text-align: left; } .count { color: #9ba3ad; font-variant-numeric: tabular-nums; }
@@ -28,8 +35,8 @@ button:focus, select:focus, input:focus { outline: 2px solid #74b9ff; outline-of
label { display: flex; justify-content: space-between; gap: 8px; color: #c8cdd3; } output { margin-left: auto; color: #fff; }
input, select { width: 100%; min-width: 0; color: #fff; background: #292e34; border: 1px solid #555b64; border-radius: 3px; padding: 3px; } input[type=checkbox] { width: auto; justify-self: start; }
.meta, .status { padding: 7px 9px; color: #9ba3ad; border-top: 1px solid #353a40; } .status:empty { display: none; } footer { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 8px; border-top: 1px solid #353a40; } .collapsed .body, .collapsed footer, .collapsed .meta, .collapsed .status { display: none; } .hidden { display: none; }
-@media (max-width: 420px) { :host { inset: auto 6px 6px auto; } .panel { width: min(320px, calc(100vw - 12px)); max-height: calc(100vh - 12px); } }
-@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } }
+@media (max-width: 420px) { :host { inset: auto 6px 6px auto; } .panel { width: min(320px, calc(100vw - 12px)); max-height: min(760px, 70vh, calc(100vh - 24px)); } }
+@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } } :global(.fd-freeze-motion), :global(.fd-freeze-motion) * { animation-play-state: paused !important; transition: none !important; }
`;
export class FoundationDevtoolsElement extends HTMLElement {
@@ -42,16 +49,35 @@ export class FoundationDevtoolsElement extends HTMLElement {
private selectedTarget: string | undefined;
private compareMode: 'modified' | 'original' = 'modified';
private pickerCleanup?: () => void;
+ private activeTab: 'inspect' | 'compose' | 'changes' = 'compose';
+ private selected?: HTMLElement;
+ private annotations: Annotation[] = [];
+ private intent = '';
+ private history: DevtoolsState[] = [];
+ private future: DevtoolsState[] = [];
+ private editingControl = false;
+ private controlInteractionStart?: DevtoolsState;
+ private pickerSuspended = false;
+ private pickerClearMark?: () => void;
+ private frozen = false;
+ private frozenVideos: Array<{ video: HTMLVideoElement; currentTime: number; paused: boolean }> = [];
+ private freezeStyles: HTMLStyleElement[] = [];
+ private restoredSelection?: Annotation;
connectedCallback(): void {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
if (!this.ready) this.build();
document.addEventListener('keydown', this.recover);
+ document.addEventListener('keydown', this.toolKeys);
}
disconnectedCallback(): void {
document.removeEventListener('keydown', this.recover);
+ document.removeEventListener('keydown', this.toolKeys);
+ window.removeEventListener('resize', this.clampPanel);
this.pickerCleanup?.(); this.pickerCleanup = undefined;
+ this.endControlInteraction();
+ this.unfreezeMotion();
}
configure(config: DevtoolsConfig): void {
@@ -59,7 +85,10 @@ export class FoundationDevtoolsElement extends HTMLElement {
this.storageKey = `foundation-devtools:${this.config.project}:panel`;
const encoded = new URL(location.href).searchParams.get(this.config.queryKey ?? 'fd');
this.state = decodeState(this.config, encoded);
+ this.restoreSession();
this.restorePanelMode();
+ this.restorePosition();
+ try { const tab = localStorage.getItem(`${this.storageKey}:tab`); if (tab === 'inspect' || tab === 'compose' || tab === 'changes') this.activeTab = tab; } catch {}
this.render();
this.apply();
}
@@ -71,18 +100,65 @@ export class FoundationDevtoolsElement extends HTMLElement {
- Reset Pick section Copy changes Copy agent brief Permalink
+ Inspect Compose Changes
+ Reset Pick DOM Freeze motion Undo Redo Copy changes Copy agent brief Permalink
JSON TypeScript Download
`;
this.panel = root.querySelector('.panel')!;
this.status = root.querySelector('.status')!;
+ this.panel.addEventListener('pointerdown', (event) => {
+ if ((event.target as HTMLElement).closest('button,input,select')) return;
+ try { this.panel!.setPointerCapture((event as PointerEvent).pointerId); } catch { /* Synthetic or cancelled pointers need no capture. */ }
+ const start = event as PointerEvent; const rect = this.panel!.getBoundingClientRect();
+ const move = (e: PointerEvent) => this.setPanelPosition(rect.left + e.clientX - start.clientX, rect.top + e.clientY - start.clientY);
+ const end = (endEvent?: PointerEvent) => {
+ this.panel!.removeEventListener('pointermove', move); this.panel!.removeEventListener('pointerup', end); this.panel!.removeEventListener('pointercancel', end);
+ window.removeEventListener('pointermove', move, true); window.removeEventListener('pointerup', end, true); window.removeEventListener('pointercancel', end, true);
+ const current = this.panel!.getBoundingClientRect(); const edge = 24;
+ const proposedLeft = endEvent ? rect.left + endEvent.clientX - start.clientX : current.left;
+ const proposedTop = endEvent ? rect.top + endEvent.clientY - start.clientY : current.top;
+ const left = Math.abs(proposedLeft) <= edge ? 0 : Math.abs(innerWidth - (proposedLeft + current.width)) <= edge ? innerWidth - current.width : proposedLeft;
+ const top = Math.abs(proposedTop) <= edge ? 0 : Math.abs(innerHeight - (proposedTop + current.height)) <= edge ? innerHeight - current.height : proposedTop;
+ this.setPanelPosition(left, top); this.savePanelPosition(left, top);
+ };
+ this.panel!.addEventListener('pointermove', move); this.panel!.addEventListener('pointerup', end); this.panel!.addEventListener('pointercancel', end);
+ window.addEventListener('pointermove', move, true); window.addEventListener('pointerup', end, { once: true, capture: true }); window.addEventListener('pointercancel', end, { once: true, capture: true });
+ });
+ window.addEventListener('resize', this.clampPanel);
+
root.addEventListener('click', (event) => { const target = event.target as HTMLElement; this.action(target.dataset.action, target.dataset.control); });
this.ready = true;
}
+ private toolKeys = (event: KeyboardEvent): void => {
+ const focused = this.shadowRoot?.activeElement || document.activeElement;
+ const inTool = focused === (this as Element) || Boolean(focused && this.shadowRoot?.contains(focused));
+ if (!inTool && !this.pickerCleanup) return;
+ const noModifiers = !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey;
+ const command = event.metaKey || event.ctrlKey;
+ if (event.shiftKey && event.key === '2' && !event.ctrlKey && !event.metaKey && !event.altKey && this.selected) {
+ event.preventDefault();
+ this.selected.scrollIntoView({ block: 'nearest', behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
+ if (!this.selected.hasAttribute('tabindex')) this.selected.tabIndex = -1;
+ this.selected.focus({ preventScroll: true });
+ this.markSelected();
+ } else if (noModifiers && event.key === ' ' && this.pickerCleanup) {
+ event.preventDefault();
+ this.pickerSuspended = true;
+ this.pickerClearMark?.();
+ } else if (noModifiers && (event.key === '1' || event.key === '2' || event.key === '3')) { this.setActiveTab(({ '1': 'inspect', '2': 'compose', '3': 'changes' } as const)[event.key]); }
+ else if (noModifiers && event.key.toLowerCase() === 'i') this.startPicker();
+ else if (noModifiers && event.key.toLowerCase() === 'v') this.pickerCleanup?.();
+ else if (noModifiers && event.key === '[' && this.selected?.parentElement) { this.selected = this.selected.parentElement; this.render(); }
+ else if (noModifiers && event.key === ']' && this.selected?.firstElementChild) { this.selected = this.selected.firstElementChild as HTMLElement; this.render(); }
+ else if (noModifiers && event.key.toLowerCase() === 'a' && this.selected && this.annotations.length < 8) { this.annotations.push(annotationFor(this.selected, this.config, this.intent)); this.render(); }
+ else if (noModifiers && event.key === '?' ) this.feedback('V Picker · I Inspect · 1/2/3 Views · A Annotate · Esc Close');
+ else if (command && !event.altKey && event.key.toLowerCase() === 'z') { event.preventDefault(); event.shiftKey ? this.redo() : this.undo(); }
+ else if (command && !event.altKey && !event.shiftKey && event.key === 'Enter' && this.config && this.state) { void this.copy(handoff(this.config, this.state, this.selected ? annotationFor(this.selected, this.config, this.intent) : undefined, this.annotations, this.intent)); }
+ };
+
private recover = (event: KeyboardEvent): void => {
- if (event.shiftKey && event.key.toLowerCase() === 'd' && (event.metaKey || event.ctrlKey)) {
+ if (event.shiftKey && event.key.toLowerCase() === 'd' && (event.metaKey || event.ctrlKey) && !event.altKey) {
event.preventDefault();
if (this.panel?.classList.contains('hidden')) this.setPanelMode('collapsed');
else this.setPanelMode(this.panel?.classList.contains('collapsed') ? 'open' : 'collapsed');
@@ -91,8 +167,14 @@ export class FoundationDevtoolsElement extends HTMLElement {
private render(): void {
if (!this.config || !this.state) return;
- const body = this.shadowRoot!.querySelector('.body')!;
+ this.persistSession();
+ const body = this.shadowRoot!.querySelector('.body')!;
body.replaceChildren();
+ if (this.activeTab === 'inspect') { this.renderInspect(body); return; }
+ if (this.activeTab === 'changes') { this.renderChanges(body); return; }
+ const compose = this.config.compose;
+ const recipes = recipeRegistry(this.config).filter((item) => compose?.recipes?.includes(item.key));
+ if (recipes.length) { const chooser = document.createElement('div'); chooser.className = 'recipes'; for (const item of recipes) { const button = document.createElement('button'); button.textContent = item.label; button.title = item.description ?? ''; button.onclick = () => { const next = validateState(this.config!, item.state); this.pushHistory(); this.state = next; this.render(); this.update(); }; chooser.append(button); } body.append(chooser); }
const compare = document.createElement('div'); compare.className = 'compare';
compare.innerHTML = 'Original Modified ';
compare.querySelector('[data-action="compare-original"]')!.setAttribute('aria-pressed', String(this.compareMode === 'original'));
@@ -121,13 +203,14 @@ export class FoundationDevtoolsElement extends HTMLElement {
}
let rendered = 0;
for (const family of this.config.families) {
- if (this.selectedTarget && family.target !== this.selectedTarget) continue;
+ if (!compose?.families?.includes(family.key) || (this.selectedTarget && family.target !== this.selectedTarget)) continue;
rendered++;
const select = document.createElement('select');
select.id = `fd-family-${family.key}`;
for (const variant of family.variants) select.add(new Option(variant.label ?? variant.name, variant.name));
select.value = this.state.families[family.key];
select.addEventListener('change', () => {
+ this.pushHistory();
this.state!.families[family.key] = select.value;
const chosen = family.variants.find((variant) => variant.name === select.value);
Object.assign(this.state!.values, chosen?.defaults ?? {});
@@ -138,7 +221,7 @@ export class FoundationDevtoolsElement extends HTMLElement {
row.dataset.changeKey = family.key; row.classList.toggle('changed', changedKeys.has(family.key));
body.append(row);
}
- for (const control of this.config.controls ?? []) { if (!this.selectedTarget || control.target === this.selectedTarget) { rendered++; body.append(this.control(control)); } }
+ for (const control of this.config.controls ?? []) { if (compose?.controls?.includes(control.key) && (!this.selectedTarget || control.target === this.selectedTarget)) { rendered++; body.append(this.control(control)); } }
if (this.selectedTarget && rendered === 0) { const empty = document.createElement('p'); empty.textContent = 'Keine Controls für dieses Target.'; body.append(empty); }
const metadata = Object.entries(this.config.metadata ?? {}).map(([key, value]) => `${key}: ${value}`).join(' · ');
this.shadowRoot!.querySelector('.meta')!.textContent = `${this.config.project}${metadata ? ` · ${metadata}` : ''}`;
@@ -169,16 +252,24 @@ export class FoundationDevtoolsElement extends HTMLElement {
range.step = String(control.step ?? 1);
range.value = String(this.state!.values[control.key]);
output.textContent = this.rangeText(control, range.value);
+ range.addEventListener('pointerdown', () => this.beginControlInteraction());
+ range.addEventListener('pointerup', () => this.endControlInteraction());
+ range.addEventListener('pointercancel', () => this.endControlInteraction());
+ range.addEventListener('lostpointercapture', () => this.endControlInteraction());
+ range.addEventListener('blur', () => this.endControlInteraction());
+ range.addEventListener('keydown', (event) => { if (event.key.startsWith('Arrow') || event.key === 'Home' || event.key === 'End') this.beginControlInteraction(); });
+ range.addEventListener('keyup', (event) => { if (event.key.startsWith('Arrow') || event.key === 'Home' || event.key === 'End') this.endControlInteraction(); });
range.addEventListener('input', () => {
- this.state!.values[control.key] = Number(range.value);
- output.textContent = this.rangeText(control, range.value);
- this.update();
+ this.beginControlInteraction(); this.state!.values[control.key] = Number(range.value);
+ output.textContent = this.rangeText(control, range.value); this.update();
});
+ range.addEventListener('change', () => this.endControlInteraction());
} else if (control.type === 'toggle') {
const toggle = input as HTMLInputElement;
toggle.type = 'checkbox';
toggle.checked = Boolean(this.state!.values[control.key]);
toggle.addEventListener('change', () => {
+ this.pushHistory();
this.state!.values[control.key] = toggle.checked;
this.update();
});
@@ -187,6 +278,7 @@ export class FoundationDevtoolsElement extends HTMLElement {
for (const option of control.options) select.add(new Option(typeof option === 'string' ? option : option.label, typeof option === 'string' ? option : option.value));
select.value = String(this.state!.values[control.key]);
select.addEventListener('change', () => {
+ this.pushHistory();
this.state!.values[control.key] = select.value;
this.update();
});
@@ -204,9 +296,11 @@ export class FoundationDevtoolsElement extends HTMLElement {
private availableForTarget(target: string): number {
if (!this.config) return 0;
- if (target === 'all') return this.config.families.length + (this.config.controls ?? []).length;
- return this.config.families.filter((item) => item.target === target).length
- + (this.config.controls ?? []).filter((item) => item.target === target).length;
+ const compose = this.config.compose;
+ const families = this.config.families.filter((item) => compose?.families?.includes(item.key));
+ const controls = (this.config.controls ?? []).filter((item) => compose?.controls?.includes(item.key));
+ if (target === 'all') return families.length + controls.length;
+ return families.filter((item) => item.target === target).length + controls.filter((item) => item.target === target).length;
}
private changedForTarget(target: string): number {
@@ -224,7 +318,7 @@ export class FoundationDevtoolsElement extends HTMLElement {
}
private revealTarget(key: string): void {
- const section = document.querySelector(`[data-fd-target="${CSS.escape(key)}"]`);
+ const section = this.traverseOpenRoots(document).find((element) => element.getAttribute('data-fd-target') === key);
if (!section) return;
section.scrollIntoView({ block: 'nearest', behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
const previous = section.style.outline;
@@ -244,13 +338,25 @@ export class FoundationDevtoolsElement extends HTMLElement {
this.shadowRoot.querySelector('[data-action="compare-modified"]')?.setAttribute('aria-pressed', String(this.compareMode === 'modified'));
}
+ private setPanelPosition(left: number, top: number): void {
+ if (!this.panel) return;
+ const rect = this.panel.getBoundingClientRect();
+ const maxLeft = Math.max(0, innerWidth - rect.width); const maxTop = Math.max(0, innerHeight - rect.height);
+ this.panel.style.left = `${Math.max(0, Math.min(maxLeft, left))}px`; this.panel.style.top = `${Math.max(0, Math.min(maxTop, top))}px`;
+ this.panel.style.right = 'auto'; this.panel.style.bottom = 'auto';
+ }
+ private savePanelPosition(left: number, top: number): void { try { localStorage.setItem(`${this.storageKey}:position`, JSON.stringify({ left, top })); } catch {} }
+ private clampPanel = (): void => { if (!this.panel) return; const rect = this.panel.getBoundingClientRect(); this.setPanelPosition(rect.left, rect.top); };
+ private restorePosition(): void {
+ try { const value = JSON.parse(localStorage.getItem(`${this.storageKey}:position`) ?? 'null'); if (value && Number.isFinite(value.left) && Number.isFinite(value.top)) { this.panel!.style.left = `${Math.max(0, Math.min(innerWidth - this.panel!.offsetWidth, value.left))}px`; this.panel!.style.top = `${Math.max(0, Math.min(innerHeight - this.panel!.offsetHeight, value.top))}px`; this.panel!.style.right = 'auto'; this.panel!.style.bottom = 'auto'; } } catch {}
+ }
private restorePanelMode(): void {
- let mode: 'open' | 'collapsed' | 'hidden' = 'collapsed';
+ let mode: 'open' | 'collapsed' = 'open';
try {
const stored = localStorage.getItem(this.storageKey);
- if (stored === 'open' || stored === 'collapsed' || stored === 'hidden') mode = stored;
+ if (stored === 'open' || stored === 'collapsed') mode = stored;
} catch {
- // Storage is optional. The dense collapsed state remains the default.
+ // Storage is optional. An open panel remains the first-start default.
}
this.setPanelMode(mode, false);
}
@@ -266,11 +372,40 @@ export class FoundationDevtoolsElement extends HTMLElement {
button.ariaLabel = expanded ? 'Panel einklappen' : 'Panel öffnen';
button.ariaExpanded = String(expanded);
}
- if (persist) {
+ if (persist && mode !== 'hidden') {
try { localStorage.setItem(this.storageKey, mode); } catch { /* Storage is optional. */ }
}
}
+ private renderInspect(body: HTMLElement): void {
+ const map = document.createElement('div'); map.className = 'map'; map.setAttribute('aria-label', 'Page map');
+ for (const target of this.config?.targets ?? []) { const button = document.createElement('button'); button.textContent = `${target.label} (${target.kind})`; button.onclick = () => this.revealTarget(target.key); map.append(button); }
+ body.append(map);
+ const selected = this.selected;
+ const heading = document.createElement('h2'); heading.textContent = selected ? `Selected: ${selected.tagName.toLowerCase()}` : 'Select any DOM element'; body.append(heading);
+ if (!selected) {
+ if (this.restoredSelection) { const saved = document.createElement('p'); saved.textContent = `Saved selection: ${this.restoredSelection.selector}`; body.append(saved); }
+ const hint = document.createElement('p'); hint.textContent = 'Use Pick DOM, then click an element on the page.'; body.append(hint); return;
+ }
+ const breadcrumb = document.createElement('nav'); breadcrumb.setAttribute('aria-label', 'DOM breadcrumb');
+ this.ancestors(selected).forEach((element, index, all) => { const button = document.createElement('button'); button.textContent = element.tagName.toLowerCase(); button.setAttribute('aria-label', `Select ${element.tagName.toLowerCase()} in breadcrumb`); button.onclick = () => { this.selected = element; this.render(); this.markSelected(); }; breadcrumb.append(button); if (index < all.length - 1) breadcrumb.append(document.createTextNode(' › ')); }); body.append(breadcrumb);
+ const context = this.contextFor(selected); const contextText = document.createElement('p'); contextText.setAttribute('aria-label', 'Registered context'); contextText.textContent = `Target: ${context.target ?? '—'} · Component: ${context.component ?? '—'} · Scope: ${context.scope ?? '—'}`; body.append(contextText);
+ const facts = layoutFacts(selected); const list = document.createElement('dl');
+ for (const [key, value] of Object.entries(facts)) { const d = document.createElement('div'); d.textContent = `${key}: ${typeof value === 'object' ? `${value.width} × ${value.height}` : value}`; list.append(d); } body.append(list);
+ const nav = document.createElement('div');
+ for (const [label, element] of [['Parent', this.parentOf(selected)], ['Child', this.childOf(selected)]] as const) { const button = document.createElement('button'); button.textContent = label; button.disabled = !element; button.onclick = () => { if (element) { this.selected = element; this.render(); this.markSelected(); } }; nav.append(button); } body.append(nav);
+ const inspectActions = document.createElement('div');
+ for (const [label, action] of [['Copy selector', 'copy-selector'], ['Copy DOM path', 'copy-dom-path'], ['Copy context', 'copy-context']] as const) { const button = document.createElement('button'); button.textContent = label; button.dataset.action = action; inspectActions.append(button); }
+ body.append(inspectActions);
+ const annotate = document.createElement('button'); annotate.textContent = 'Pin annotation'; annotate.dataset.action = 'annotate'; body.append(annotate);
+ }
+ private renderChanges(body: HTMLElement): void {
+ const diff = changes(this.config!, this.state!); const pre = document.createElement('pre'); pre.textContent = changesJson(this.config!, this.state!); body.append(pre);
+ const input = document.createElement('input'); input.placeholder = 'Intent (optional)'; input.value = this.intent; input.oninput = () => { this.intent = input.value.slice(0, 500); this.persistSession(); }; body.append(input);
+ const brief = document.createElement('button'); brief.textContent = `Copy agent brief (${diff.count})`; brief.dataset.action = 'brief'; body.append(brief);
+ if (this.annotations.length) { const note = document.createElement('p'); note.textContent = `${this.annotations.length} annotation(s) pinned`; body.append(note); }
+ }
+ private markSelected(): void { if (!this.selected) return; const old = this.selected.style.outline; this.selected.style.outline = '2px solid #74b9ff'; window.setTimeout(() => { if (this.selected?.style.outline === '2px solid rgb(116, 185, 255)') this.selected.style.outline = old; }, 1000); }
private apply(): void { if (this.config && this.state) applyEffects(this.config, this.compareMode === 'modified' ? this.state : initialState(this.config)); }
private update(): void {
@@ -278,6 +413,7 @@ export class FoundationDevtoolsElement extends HTMLElement {
this.compareMode = 'modified';
this.apply();
this.refreshIndicators();
+ this.persistSession();
try {
history.replaceState(null, '', stateUrl(this.config, this.state));
} catch {
@@ -285,18 +421,35 @@ export class FoundationDevtoolsElement extends HTMLElement {
}
}
+ private setActiveTab(tab: typeof this.activeTab): void {
+ this.activeTab = tab;
+ try { localStorage.setItem(`${this.storageKey}:tab`, tab); } catch { /* Storage is optional. */ }
+ this.render();
+ }
+
private async action(action?: string, controlKey?: string): Promise {
if (!action || !this.config || !this.state) return;
if (action === 'collapse') this.setPanelMode(this.panel?.classList.contains('collapsed') ? 'open' : 'collapsed');
if (action === 'hide') this.setPanelMode('hidden');
- if (action === 'reset') { this.state = initialState(this.config); this.render(); this.update(); }
+ if (action === 'reset') { const next = initialState(this.config); if (JSON.stringify(next) !== JSON.stringify(this.state)) { this.pushHistory(); this.state = next; this.render(); this.update(); } }
+ if (action === 'tab-inspect' || action === 'tab-compose' || action === 'tab-changes') this.setActiveTab(action.slice(4) as typeof this.activeTab);
+ if (action === 'annotate' && this.selected) { if (this.annotations.length < 8) this.annotations.push(annotationFor(this.selected, this.config, this.intent)); this.render(); }
+ if (action === 'freeze') this.toggleFreeze();
+ if (action === 'undo') this.undo();
+ if (action === 'redo') this.redo();
if (action === 'reset-control' && controlKey) {
const control = this.config.controls?.find((item) => item.key === controlKey);
- if (control) { this.state.values[controlKey] = initialState(this.config).values[controlKey]; this.render(); this.update(); }
+ if (control && this.state.values[controlKey] !== initialState(this.config).values[controlKey]) { this.pushHistory(); this.state.values[controlKey] = initialState(this.config).values[controlKey]; this.render(); this.update(); }
+ }
+ if (action === 'reset-target' && controlKey) { const next = resetBaseline(this.config, this.state, controlKey); if (JSON.stringify(next) !== JSON.stringify(this.state)) { this.pushHistory(); this.state = next; this.render(); this.update(); } }
+ if (action === 'copy-selector' && this.selected) await this.copy(annotationFor(this.selected, this.config, this.intent).selector);
+ if (action === 'copy-dom-path' && this.selected) await this.copy(layoutFacts(this.selected).path);
+ if (action === 'copy-context' && this.selected) {
+ const annotation = annotationFor(this.selected, this.config, this.intent); const facts = layoutFacts(this.selected);
+ await this.copy(JSON.stringify({ selector: annotation.selector, domPath: facts.path, layout: { rect: facts.rect, display: facts.display.slice(0, 64), gridColumns: facts.gridColumns.slice(0, 128), gap: facts.gap.slice(0, 64), position: facts.position.slice(0, 32), overflow: facts.overflow.slice(0, 64), container: facts.container } }));
}
- if (action === 'reset-target' && controlKey) { this.state = resetBaseline(this.config, this.state, controlKey); this.render(); this.update(); }
if (action === 'changes') await this.copy(changesJson(this.config, this.state));
- if (action === 'brief') await this.copy(agentBrief(this.config, this.state));
+ if (action === 'brief') await this.copy(handoff(this.config, this.state, this.selected ? annotationFor(this.selected, this.config, this.intent) : undefined, this.annotations, this.intent));
if (action === 'pick') { this.feedback('Pick a registered section or press Escape'); this.startPicker(); }
if (action === 'compare-original') { this.compareMode = 'original'; this.render(); this.apply(); }
if (action === 'compare-modified') { this.compareMode = 'modified'; this.render(); this.apply(); }
@@ -308,17 +461,35 @@ export class FoundationDevtoolsElement extends HTMLElement {
private startPicker(): void {
this.pickerCleanup?.();
- if (!this.config?.targets) return;
- const keys = this.config.targets.filter((target) => target.kind === 'section').map((target) => target.key);
- const elements = keys.flatMap((key) => Array.from(document.querySelectorAll(`[data-fd-target="${CSS.escape(key)}"]`)));
+ const elements = this.traverseOpenRoots(document).filter((element) => !this.isToolElement(element));
const oldOutline = new Map();
const oldTabIndex = new Map();
let hovered: HTMLElement | undefined;
+ let label: HTMLDivElement | undefined;
const listeners = new Map void; leave: () => void; focus: () => void; key: (event: KeyboardEvent) => void }>();
- const clearMark = () => { if (hovered) { hovered.style.outline = oldOutline.get(hovered) ?? ''; hovered = undefined; } };
- const mark = (element: HTMLElement) => { clearMark(); oldOutline.set(element, element.style.outline); hovered = element; element.style.outline = '2px solid #8f98a3'; };
- const select = (element: HTMLElement, event: Event) => { event.preventDefault(); event.stopPropagation(); this.selectedTarget = element.dataset.fdTarget; cleanup(); this.render(); };
+ const clearLabel = () => { label?.remove(); label = undefined; };
+ const clearMark = () => { if (hovered) { hovered.style.outline = oldOutline.get(hovered) ?? ''; hovered = undefined; } clearLabel(); };
+ this.pickerClearMark = clearMark;
+ const mark = (element: HTMLElement) => {
+ if (this.pickerSuspended) return;
+ clearMark(); oldOutline.set(element, element.style.outline); hovered = element; element.style.outline = '2px solid #8f98a3';
+ const context = this.contextFor(element); const contextText = context.component ?? context.scope ?? context.target;
+ label = document.createElement('div'); label.dataset.fdPickerLabel = 'true'; label.setAttribute('aria-hidden', 'true');
+ label.textContent = `<${element.tagName.toLowerCase()}>${contextText ? ` · ${contextText.slice(0, 64)}` : ''}`;
+ Object.assign(label.style, { position: 'fixed', zIndex: '2147483646', pointerEvents: 'none', padding: '2px 5px', border: '1px solid #8f98a3', borderRadius: '3px', background: '#17191c', color: '#fff', font: '11px/1.2 ui-monospace, monospace', whiteSpace: 'nowrap', maxWidth: 'min(280px, calc(100vw - 12px))', overflow: 'hidden', textOverflow: 'ellipsis' });
+ (document.body ?? document.documentElement).append(label);
+ const rect = element.getBoundingClientRect(); const labelRect = label.getBoundingClientRect();
+ const left = Math.max(6, Math.min(rect.left, innerWidth - labelRect.width - 6));
+ const top = Math.max(6, Math.min(rect.top - labelRect.height - 4, innerHeight - labelRect.height - 6));
+ label.style.left = `${left}px`; label.style.top = `${top}px`;
+ };
+ const select = (element: HTMLElement, event: Event) => {
+ if (this.pickerSuspended) return;
+ event.preventDefault(); event.stopImmediatePropagation(); this.selected = element; this.restoredSelection = annotationFor(element, this.config, this.intent); this.selectedTarget = this.dataAncestor(element, 'data-fd-target'); this.persistSession(); if ((event as MouseEvent).shiftKey && this.annotations.length < 8) this.annotations.push(annotationFor(element, this.config, this.intent)); else { cleanup(); this.render(); } };
const cleanup = () => {
+ this.pickerSuspended = false;
+ document.removeEventListener('keyup', onDocumentKeyup);
+ window.removeEventListener('blur', onBlur);
clearMark();
elements.forEach((element) => {
element.removeEventListener('click', onClick);
@@ -329,24 +500,140 @@ export class FoundationDevtoolsElement extends HTMLElement {
});
document.removeEventListener('keydown', onDocumentKey);
this.pickerCleanup = undefined;
+ this.pickerClearMark = undefined;
};
const onClick = (event: Event) => select(event.currentTarget as HTMLElement, event);
- const onDocumentKey = (event: KeyboardEvent) => { if (event.key === 'Escape') cleanup(); };
+ const onDocumentKey = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') cleanup();
+ else if (event.key === ' ') { event.preventDefault(); this.pickerSuspended = true; clearMark(); }
+ };
+ const onDocumentKeyup = (event: KeyboardEvent) => { if (event.key === ' ') { event.preventDefault(); this.pickerSuspended = false; } };
+ const onBlur = () => { this.pickerSuspended = false; cleanup(); };
elements.forEach((element) => {
const enter = () => mark(element);
const leave = () => { if (hovered === element) clearMark(); };
const focus = () => mark(element);
- const key = (event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') select(element, event); };
+ const key = (event: KeyboardEvent) => {
+ if (event.key === ' ') { event.preventDefault(); this.pickerSuspended = true; clearMark(); return; }
+ if (event.key === 'Enter') select(element, event);
+ };
listeners.set(element, { enter, leave, focus, key });
oldTabIndex.set(element, element.getAttribute('tabindex'));
element.tabIndex = 0;
element.addEventListener('pointerenter', enter); element.addEventListener('pointerleave', leave); element.addEventListener('focus', focus); element.addEventListener('keydown', key); element.addEventListener('click', onClick);
});
document.addEventListener('keydown', onDocumentKey);
+ document.addEventListener('keyup', onDocumentKeyup);
+ window.addEventListener('blur', onBlur);
this.pickerCleanup = cleanup;
elements[0]?.focus({ preventScroll: true });
}
+ private isToolElement(element: Element): boolean { return element === (this as Element) || Boolean(this.shadowRoot?.contains(element)); }
+ /** Walk only same-document nodes and open shadow roots; iframe documents are deliberately not entered. */
+ private traverseOpenRoots(root: Document | ShadowRoot, depth = 0): HTMLElement[] {
+ if (depth > 20) return [];
+ const result: HTMLElement[] = [];
+ const visit = (node: Element): void => {
+ if (node instanceof HTMLElement && !['HTML', 'HEAD', 'BODY'].includes(node.tagName)) result.push(node);
+ if (node instanceof HTMLIFrameElement || node instanceof HTMLObjectElement) return;
+ for (const child of Array.from(node.children)) visit(child);
+ if (node.shadowRoot) for (const child of Array.from(node.shadowRoot.children)) visit(child);
+ };
+ for (const child of Array.from(root.children)) visit(child);
+ return result;
+ }
+ private parentOf(element: HTMLElement): HTMLElement | undefined {
+ return (element.parentElement ?? ((element.getRootNode() as ShadowRoot).host as HTMLElement | undefined));
+ }
+ private childOf(element: HTMLElement): HTMLElement | undefined {
+ return (element.shadowRoot?.firstElementChild ?? element.firstElementChild) as HTMLElement | undefined;
+ }
+ private ancestors(element: HTMLElement): HTMLElement[] { const result: HTMLElement[] = []; let current: HTMLElement | undefined = element; while (current && result.length < 12) { result.unshift(current); current = this.parentOf(current); } return result; }
+ private dataAncestor(element: HTMLElement, attribute: string): string | undefined { let current: HTMLElement | undefined = element; while (current) { const value = current.getAttribute(attribute); if (value) return value; current = this.parentOf(current); } return undefined; }
+ private contextFor(element: HTMLElement): { target?: string; component?: string; scope?: string } {
+ const scopeValue = this.dataAncestor(element, 'data-fd-scope');
+ const registration = this.config?.registrations?.find((item) => (item.scope ?? item.key) === scopeValue);
+ const targetValue = this.dataAncestor(element, 'data-fd-target');
+ const target = this.config?.targets?.some((item) => item.key === targetValue) ? targetValue : registration?.target;
+ return { target, component: registration ? (registration.label ?? registration.key) : undefined, scope: registration ? (registration.scope ?? scopeValue) : undefined };
+ }
+ private pushHistory(snapshot: DevtoolsState = this.state!): void { this.history.push(structuredClone(snapshot)); if (this.history.length > 30) this.history.shift(); this.future = []; }
+ private beginControlInteraction(): void {
+ if (!this.state || this.editingControl) return;
+ this.controlInteractionStart = structuredClone(this.state);
+ this.editingControl = true;
+ }
+ private endControlInteraction(): void {
+ if (this.editingControl && this.state && this.controlInteractionStart && JSON.stringify(this.controlInteractionStart) !== JSON.stringify(this.state)) this.pushHistory(this.controlInteractionStart);
+ this.editingControl = false;
+ this.controlInteractionStart = undefined;
+ }
+ private undo(): void { if (!this.state || !this.history.length) return; this.future.push(structuredClone(this.state)); this.state = this.history.pop()!; this.render(); this.update(); }
+ private redo(): void { if (!this.state || !this.future.length) return; this.history.push(structuredClone(this.state)); this.state = this.future.pop()!; this.render(); this.update(); }
+ private resumeVideo(entry: { video: HTMLVideoElement; currentTime: number; paused: boolean }): void {
+ const { video } = entry;
+ if (!video.isConnected) return;
+ try { video.currentTime = entry.currentTime; } catch { /* Media may have been detached or failed. */ }
+ if (!entry.paused) void video.play().catch(() => { /* Autoplay/media failures must not break unfreeze. */ });
+ }
+ private toggleFreeze(): void { this.frozen ? this.unfreezeMotion() : this.freezeMotion(); }
+ private freezeMotion(): void {
+ this.frozen = true;
+ this.frozenVideos = this.traverseOpenRoots(document).flatMap((element) => {
+ if (!(element instanceof HTMLVideoElement) || this.isToolElement(element)) return [];
+ try { const paused = element.paused; const currentTime = Number.isFinite(element.currentTime) ? element.currentTime : 0; if (!paused) element.pause(); return [{ video: element, currentTime, paused }]; } catch { return []; }
+ });
+ this.freezeStyles = [];
+ const css = '*,*::before,*::after{animation-play-state:paused!important;transition:none!important}';
+ const documentStyle = document.createElement('style'); documentStyle.dataset.fdFreeze = 'true'; documentStyle.textContent = css; (document.head ?? document.documentElement).append(documentStyle); this.freezeStyles.push(documentStyle);
+ for (const root of this.openShadowRoots(document)) { const style = document.createElement('style'); style.dataset.fdFreeze = 'true'; style.textContent = css; root.append(style); this.freezeStyles.push(style); }
+ this.feedback('Motion frozen');
+ }
+ private unfreezeMotion(): void {
+ if (!this.frozen && !this.freezeStyles.length) return;
+ this.frozen = false; for (const style of this.freezeStyles) style.remove(); this.freezeStyles = [];
+ for (const entry of this.frozenVideos) this.resumeVideo(entry); this.frozenVideos = []; this.feedback('Motion resumed');
+ }
+ private openShadowRoots(root: Document | ShadowRoot, depth = 0): ShadowRoot[] {
+ if (depth > 20) return []; const result: ShadowRoot[] = [];
+ const visit = (element: Element, level: number): void => { if (level > 20) return; if (element.shadowRoot) { result.push(element.shadowRoot); for (const child of Array.from(element.shadowRoot.children)) visit(child, level + 1); } if (!(element instanceof HTMLIFrameElement)) for (const child of Array.from(element.children)) visit(child, level + 1); };
+ for (const child of Array.from(root.children)) visit(child, depth); return result;
+ }
+ private persistSession(): void {
+ if (!this.config) return;
+ try {
+ const selected = this.selected ? annotationFor(this.selected, this.config, this.intent) : this.restoredSelection;
+ sessionStorage.setItem(`${this.storageKey}:session`, JSON.stringify({ selected, annotations: this.annotations.slice(0, 8), intent: this.intent.slice(0, 500) }));
+ } catch { /* Session storage is optional. */ }
+ }
+ private restoreSession(): void {
+ this.selected = undefined;
+ this.restoredSelection = undefined;
+ try {
+ const value: unknown = JSON.parse(sessionStorage.getItem(`${this.storageKey}:session`) ?? 'null');
+ if (!value || typeof value !== 'object') return;
+ const record = value as Record;
+ const valid = (item: unknown): item is Annotation => {
+ if (!item || typeof item !== 'object') return false; const candidate = item as Record;
+ const rect = candidate.rect;
+ const validRect = Boolean(rect && typeof rect === 'object' && ['x', 'y', 'width', 'height'].every((key) => Number.isFinite((rect as Record)[key])));
+ return typeof candidate.route === 'string' && candidate.route.length <= 2048 && typeof candidate.selector === 'string' && candidate.selector.length <= 512 && validRect &&
+ (candidate.locale === undefined || (typeof candidate.locale === 'string' && candidate.locale.length <= 128)) &&
+ (candidate.target === undefined || (typeof candidate.target === 'string' && candidate.target.length <= 128)) &&
+ (candidate.component === undefined || (typeof candidate.component === 'string' && candidate.component.length <= 128)) &&
+ (candidate.scope === undefined || (typeof candidate.scope === 'string' && candidate.scope.length <= 128)) &&
+ (candidate.comment === undefined || (typeof candidate.comment === 'string' && candidate.comment.length <= 500));
+ };
+ if (valid(record.selected)) this.restoredSelection = record.selected;
+ if (Array.isArray(record.annotations)) this.annotations = record.annotations.filter(valid).slice(0, 8);
+ if (typeof record.intent === 'string') this.intent = record.intent.slice(0, 500);
+ if (this.restoredSelection?.selector) {
+ const candidate = resolvePath(this.restoredSelection.selector);
+ if (candidate instanceof HTMLElement && !this.isToolElement(candidate)) this.selected = candidate;
+ }
+ } catch { /* Corrupt or unavailable storage is ignored. */ }
+ }
private async copy(value: string): Promise {
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable');
diff --git a/test/browser-smoke.test.mjs b/test/browser-smoke.test.mjs
index 3188f93..1f3fad2 100644
--- a/test/browser-smoke.test.mjs
+++ b/test/browser-smoke.test.mjs
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
-import { chromium } from '@playwright/test';
+import { chromium, firefox } from '@playwright/test';
test('Astro fixture covers production and browser contracts', async () => {
const html = await readFile('fixture/index.html', 'utf8');
@@ -12,15 +12,26 @@ test('Astro fixture covers production and browser contracts', async () => {
const production = await readFile('fixture/astro/dist/index.html', 'utf8');
assert.doesNotMatch(production, /foundation-devtools|data-fd-config/);
- const browser = await chromium.launch({ args: ['--allow-file-access-from-files'], headless: true });
- try {
- const context = await browser.newContext({ reducedMotion: 'reduce' });
- const page = await context.newPage();
- await page.goto(pathToFileURL(`${process.cwd()}/fixture/smoke.html`).href, { waitUntil: 'load' });
- await page.waitForFunction(() => document.title === 'FD browser PASS', undefined, { timeout: 10_000 });
- assert.equal(await page.title(), 'FD browser PASS');
- await context.close();
- } finally {
- await browser.close();
+ for (const [name, launcher, options] of [['Chromium', chromium, { args: ['--allow-file-access-from-files'] }], ['Firefox', firefox, {}]]) {
+ let browser;
+ try { browser = await launcher.launch({ ...options, headless: true }); } catch (error) { throw new Error(`${name} smoke could not start`, { cause: error }); }
+ try {
+ const context = await browser.newContext({ reducedMotion: 'no-preference' });
+ const page = await context.newPage();
+ let navigations = 0;
+ page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) navigations += 1; });
+ await page.goto(pathToFileURL(`${process.cwd()}/fixture/smoke.html`).href, { waitUntil: 'load' });
+ await page.waitForFunction(() => document.title.startsWith('FD browser '), undefined, { timeout: 20_000 });
+ assert.equal(await page.title(), 'FD browser PASS');
+ assert.ok(navigations >= 3, `${name} restore test did not reload through both selections`);
+ const panel = page.locator('foundation-devtools').locator('.panel');
+ assert.ok(await panel.boundingBox());
+ await page.setViewportSize({ width: 480, height: 320 });
+ assert.ok(await panel.evaluate((node) => {
+ const maxHeight = parseFloat(getComputedStyle(node).maxHeight);
+ return maxHeight <= 224 && node.getBoundingClientRect().right <= innerWidth && node.getBoundingClientRect().bottom <= innerHeight;
+ }));
+ await context.close();
+ } finally { await browser.close(); }
}
});
diff --git a/test/core.test.mjs b/test/core.test.mjs
index f8175ee..24bd6cf 100644
--- a/test/core.test.mjs
+++ b/test/core.test.mjs
@@ -1,10 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { defineDevtoolsConfig, initialState, validateState, encodeState, decodeState, stateUrl, recipe, changes, changesJson, agentBrief, resetBaseline } from '../dist/core.js';
+import { defineDevtoolsConfig, initialState, validateState, encodeState, decodeState, stateUrl, recipe, changes, changesJson, agentBrief, resetBaseline, recipeRegistry, safeRoute, handoff } from '../dist/core.js';
const config=defineDevtoolsConfig({project:'fixture',metadata:{route:'/x'},families:[{key:'card',label:'Card',variants:[{name:'a'},{name:'b'}]}],controls:[{type:'range',key:'gap',label:'Gap',min:0,max:20,default:4,effect:{scope:'grid',variable:'--fd-gap'}},{type:'toggle',key:'featured',label:'Featured',default:false,effect:{scope:'card',attribute:'featured'}}]});
test('defaults and fail-closed state',()=>{const s=initialState(config);assert.equal(s.values.gap,4);assert.equal(validateState(config,{values:{gap:999,featured:'yes'},families:{card:'unknown'}}).values.gap,4);});
test('URL codec is reproducible and preserves params',()=>{const s={families:{card:'b'},values:{gap:8,featured:true}};const encoded=encodeState(config,s);assert.deepEqual(decodeState(config,encoded),s);const u=stateUrl(config,s,'https://example.test/?utm=x');assert.equal(new URL(u).searchParams.get('utm'),'x');assert.ok(u.includes('fd='));});
test('recipe is JSON',()=>{assert.equal(JSON.parse(recipe(config,initialState(config))).project,'fixture');});
+test('safe routes exclude query, fragment, and credentials from every handoff output', () => {
+ assert.equal(safeRoute('https://user:secret@example.test/design?fd=secret#changes'), '/design');
+ assert.equal(safeRoute('/design#inspect?x=y'), '/design');
+ const routeConfig = defineDevtoolsConfig({ project: 'routes', metadata: { route: 'https://user:secret@example.test/design?token=private#changes', locale: 'en' }, families: [{ key: 'card', label: 'Card', variants: [{ name: 'a' }, { name: 'b' }] }] });
+ const changed = validateState(routeConfig, { families: { card: 'b' } });
+ for (const output of [changes(routeConfig, changed), JSON.parse(changesJson(routeConfig, changed)), agentBrief(routeConfig, changed)]) {
+ const text = typeof output === 'string' ? output : JSON.stringify(output);
+ assert.equal(text.includes('secret'), false);
+ assert.equal(text.includes('token='), false);
+ if (typeof output !== 'string') assert.equal(output.metadata?.route, '/design');
+ }
+ assert.equal(routeConfig.metadata?.route, 'https://user:secret@example.test/design?token=private#changes');
+ assert.deepEqual(changed, { families: { card: 'b' }, values: {} });
+});
+test('handoff sanitizes hostile external annotations without mutation', () => {
+ const hostile = { route: 'https://user:pw@example.test/private?token=secret#hash', locale: 'en' + String.fromCharCode(0, 10) + 'L'.repeat(500), selector: 'main > '.repeat(200), rect: { x: Infinity, y: -Infinity, width: 999999999, height: -4 }, comment: '