From 6c988ca87c4bc91209e2b4f31499071be227f092 Mon Sep 17 00:00:00 2001 From: Clifford <33897029+cray-com@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:54:15 +0200 Subject: [PATCH 1/9] feat: add DOM inspector and layout handoff tooling --- PROJECT.md | 4 +++ README.md | 4 +++ src/core.ts | 67 +++++++++++++++++++++++++++++++++++++ src/element.ts | 89 +++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 153 insertions(+), 11 deletions(-) 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..d9cda31 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. Es gibt keine Source-Writes, keine Agent-Bridge und keinen visuellen Recipe-Generator. + ## Entwicklung ```bash diff --git a/src/core.ts b/src/core.ts index 6fd9811..0ea96bb 100644 --- a/src/core.ts +++ b/src/core.ts @@ -75,6 +75,7 @@ export type DevtoolsConfig = { families: readonly Family[]; controls?: readonly Control[]; targets?: readonly Target[]; + registrations?: readonly DomRegistration[]; metadata?: Metadata; queryKey?: string; }; @@ -119,6 +120,7 @@ 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.scope !== undefined && (typeof item.scope !== 'string' || !scopePattern.test(item.scope))) || (item.target !== undefined && typeof item.target !== 'string')))) fail('registrations'); const targets: Target[] = []; const targetKeys = new Set(); for (const targetValue of (Array.isArray(input.targets) ? input.targets : [])) { @@ -416,6 +418,71 @@ 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; +}; + +function safeRoute(value: string): string { + try { const url = new URL(value, 'http://localhost'); return `${url.pathname}${url.hash}`; } catch { return '/'; } +} +function cssIdent(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char.charCodeAt(0).toString(16)} `); +} +/** Stable, bounded selector used for handoff; it never includes text or form values. */ +export function domPath(element: Element, limit = 8): string { + const parts: string[] = []; let current: Element | null = element; + while (current && parts.length < limit && current.nodeType === 1) { + let part = current.tagName.toLowerCase(); + const id = current.getAttribute('id'); + if (id && /^[a-zA-Z][\\w-]{0,63}$/.test(id)) part += `#${cssIdent(id)}`; + else { const scope = current.getAttribute('data-fd-scope'); if (scope && scopePattern.test(scope)) part += `[data-fd-scope="${scope}"]`; } + parts.unshift(part); current = current.parentElement; + } + return parts.join(' > ').slice(0, 512); +} +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 scope = element.closest('[data-fd-scope]')?.getAttribute('data-fd-scope') ?? undefined; + const target = element.closest('[data-fd-target]')?.getAttribute('data-fd-target') ?? undefined; + const registered = config?.families.find((family) => (family.effect?.scope ?? family.key) === scope); + const rect = element.getBoundingClientRect(); + return { route: safeRoute(typeof location === 'undefined' ? '/' : location.href), ...(config?.metadata?.locale ? { locale: config.metadata.locale } : {}), ...(target ? { target } : {}), ...(registered ? { component: registered.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 handoff(config: DevtoolsConfig, state: DevtoolsState, selected?: Annotation, annotations: readonly Annotation[] = [], intent?: string): string { + const diff = changes(config, state); + const payload = { ...(selected ? { selected } : {}), annotations: annotations.slice(0, 8), ...(intent ? { intent: intent.slice(0, 500) } : {}), changes: diff }; + return agentBrief(config, state) + '\n\n## Selection context\n\n```json\n' + JSON.stringify(payload) + '\n```'; +} + export function defineDevtoolsConfig(config: DevtoolsConfig): DevtoolsConfig { return validateConfig(config); } diff --git a/src/element.ts b/src/element.ts index db75caf..14cb157 100644 --- a/src/element.ts +++ b/src/element.ts @@ -10,6 +10,10 @@ import { stateUrl, typescriptRecipe, validateConfig, + annotationFor, + layoutFacts, + handoff, + type Annotation, type Control, type DevtoolsConfig, type DevtoolsState, @@ -20,7 +24,7 @@ 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; } +.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; } @@ -29,7 +33,7 @@ label { display: flex; justify-content: space-between; gap: 8px; color: #c8cdd3; 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 (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,15 +46,25 @@ 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 frozen = false; + private frozenVideos: HTMLVideoElement[] = []; 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); this.pickerCleanup?.(); this.pickerCleanup = undefined; } @@ -60,6 +74,7 @@ export class FoundationDevtoolsElement extends HTMLElement { const encoded = new URL(location.href).searchParams.get(this.config.queryKey ?? 'fd'); this.state = decodeState(this.config, encoded); this.restorePanelMode(); + try { const tab = localStorage.getItem(`${this.storageKey}:tab`); if (tab === 'inspect' || tab === 'compose' || tab === 'changes') this.activeTab = tab; } catch {} this.render(); this.apply(); } @@ -71,16 +86,32 @@ export class FoundationDevtoolsElement extends HTMLElement {
FD · devtools -
-