From b1f801b816fe4a19b226adbb626f7bc4323c2863 Mon Sep 17 00:00:00 2001 From: Clifford <33897029+cray-com@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:03:13 +0200 Subject: [PATCH 1/7] feat: add targets and changes exports to devtools --- PROJECT.md | 3 +- README.md | 18 ++++++++- src/core.ts | 106 ++++++++++++++++++++++++++++++++++++++++++++----- src/element.ts | 52 ++++++++++++++++++++---- 4 files changed, 161 insertions(+), 18 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index aac79b0..316f142 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -40,7 +40,8 @@ Der Astro-Adapter montiert ein browserseitiges Custom Element. Das Panel verwend V1 liefert: -- Range-, Select- und Toggle-Controls; +- Range-, Select- und Toggle-Controls; optionale Target/Page-Map-Zuordnung (`global`/`section`) und Token-/Local-Klassifikation; labeled Select-Optionen; +- stabile Changes-/JSON-/Agent-Brief-Exporte gegenüber `initialState` (reiner Design-State, ohne Panel-UI-State); - benannte Variant Families mit Defaults; - URL-persistierten State und kopierbare Permalinks; - Reset, JSON-/TypeScript-Rezept, Copy und Download; diff --git a/README.md b/README.md index 7f73c56..f5049bf 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,23 @@ Website-Markup deklariert Scopes mit `data-fd-scope`. Effects setzen ausschließ
``` -Ohne eigenen Effect werden Familien als `data-fd-variant-card="default"` auf dem gleichnamigen Scope markiert. Ein Family-Effect kann stattdessen ein vorhandenes Website-Attribut wie `data-card-variant` setzen. Ein Control-Effect mit `attribute: 'card-density'` setzt entsprechend `data-card-density`. +Ohne eigenen Effect werden Familien als `data-fd-variant-card="default"` auf dem gleichnamigen Scope markiert. + +### Targets und Changes + +Optional ordnet `targets` Controls und Families einer Seite bzw. einem Bereich zu. Sections werden im Markup mit `data-fd-target` registriert; ungültige Zuordnungen werden verworfen. `kind: 'global'` bleibt seitenweit, `kind: 'section'` scoped auf das registrierte Element: + +```ts +const config = defineDevtoolsConfig({ project: 'site', targets: [ + { key: 'hero', label: 'Hero', kind: 'section' }, +], families: [{ key: 'card', label: 'Card', target: 'hero', variants: [{ name: 'default' }] }], controls: [{ + type: 'select', key: 'density', label: 'Density', classification: 'token', + options: [{ value: 'comfortable', label: 'Comfortable' }, 'compact'], default: 'comfortable', + target: 'hero', effect: { scope: 'card', attribute: 'density' }, +}] }); +``` + +`changes(config, state)` und `changesJson` liefern ausschließlich geänderte Werte gegenüber `initialState`; `agentBrief` erzeugt eine knappe Markdown-Zusammenfassung. Panel-Auswahl und Vergleichsmodus sind UI-State und werden nicht exportiert. Ein Family-Effect kann stattdessen ein vorhandenes Website-Attribut wie `data-card-variant` setzen. Ein Control-Effect mit `attribute: 'card-density'` setzt entsprechend `data-card-density`. Website-CSS kann Tailwind über semantische Layer verwenden. Dynamische Reglerwerte bleiben CSS Custom Properties: diff --git a/src/core.ts b/src/core.ts index bfad7ae..ceb005b 100644 --- a/src/core.ts +++ b/src/core.ts @@ -12,6 +12,14 @@ export type Effect = { attribute?: string; }; +export type Target = { + key: string; + label: string; + kind: 'global' | 'section'; +}; + +export type SelectOption = string | { value: string; label: string }; + export type Range = { type: 'range'; key: string; @@ -22,15 +30,19 @@ export type Range = { unit?: string; default: number; effect: Effect; + target?: string; + classification?: 'token' | 'local'; }; export type Select = { type: 'select'; key: string; label: string; - options: readonly string[]; + options: readonly SelectOption[]; default: string; effect: Effect; + target?: string; + classification?: 'token' | 'local'; }; export type Toggle = { @@ -39,6 +51,8 @@ export type Toggle = { label: string; default: boolean; effect: Effect; + target?: string; + classification?: 'token' | 'local'; }; export type Control = Range | Select | Toggle; @@ -54,11 +68,13 @@ export type Family = { variants: readonly Variant[]; default?: string; effect?: Effect; + target?: string; }; export type DevtoolsConfig = { project: string; families: readonly Family[]; controls?: readonly Control[]; + targets?: readonly Target[]; metadata?: Metadata; queryKey?: string; }; @@ -102,6 +118,15 @@ export function validateConfig(input: unknown): DevtoolsConfig { if (input.queryKey !== undefined && (typeof input.queryKey !== 'string' || !keyPattern.test(input.queryKey))) { fail('query key'); } + if (input.targets !== undefined && !Array.isArray(input.targets)) fail('targets'); + const targets: Target[] = []; + const targetKeys = new Set(); + for (const targetValue of (Array.isArray(input.targets) ? input.targets : [])) { + if (!isRecord(targetValue) || typeof targetValue.key !== 'string' || !keyPattern.test(targetValue.key) || + targetKeys.has(targetValue.key) || typeof targetValue.label !== 'string' || + (targetValue.kind !== 'global' && targetValue.kind !== 'section')) fail('target'); + targetKeys.add(targetValue.key); targets.push(targetValue as unknown as Target); + } if (input.metadata !== undefined) { if (!isRecord(input.metadata) || Object.values(input.metadata).some((value) => value !== undefined && typeof value !== 'string')) { fail('metadata'); @@ -136,6 +161,7 @@ export function validateConfig(input: unknown): DevtoolsConfig { fail('family default'); } if (familyValue.effect !== undefined) validateEffect(familyValue.effect); + if (familyValue.target !== undefined && (typeof familyValue.target !== 'string' || !targetKeys.has(familyValue.target))) fail('family target'); families.push(familyValue as unknown as Family); } @@ -148,6 +174,8 @@ export function validateConfig(input: unknown): DevtoolsConfig { } names.add(controlValue.key); validateEffect(controlValue.effect); + if (controlValue.target !== undefined && (typeof controlValue.target !== 'string' || !targetKeys.has(controlValue.target))) fail('control target'); + if (controlValue.classification !== undefined && controlValue.classification !== 'token' && controlValue.classification !== 'local') fail('control classification'); if (controlValue.type === 'range') { const { min, max, step = 1, unit = '' } = controlValue as { min: unknown; @@ -164,9 +192,10 @@ export function validateConfig(input: unknown): DevtoolsConfig { } } else if (controlValue.type === 'select') { if (!Array.isArray(controlValue.options) || controlValue.options.length === 0 || - controlValue.options.some((option) => typeof option !== 'string' || option.length === 0) || - new Set(controlValue.options).size !== controlValue.options.length || - typeof controlValue.default !== 'string' || !controlValue.options.includes(controlValue.default)) { + controlValue.options.some((option) => (typeof option !== 'string' && !isRecord(option)) || + optionValue(option as SelectOption).length === 0 || (typeof option !== 'string' && typeof option.label !== 'string')) || + new Set(controlValue.options.map((option) => optionValue(option))).size !== controlValue.options.length || + typeof controlValue.default !== 'string' || !controlValue.options.some((option) => optionValue(option) === controlValue.default)) { fail('select'); } } else if (controlValue.type === 'toggle') { @@ -190,9 +219,11 @@ export function validateConfig(input: unknown): DevtoolsConfig { return config; } +function optionValue(option: SelectOption): string { return typeof option === 'string' ? option : option.value; } + function isValidValue(control: Control, value: unknown): value is ControlValue { if (control.type === 'range') return typeof value === 'number' && Number.isFinite(value) && value >= control.min && value <= control.max; - if (control.type === 'select') return typeof value === 'string' && control.options.includes(value); + if (control.type === 'select') return typeof value === 'string' && control.options.some((option) => optionValue(option) === value); return typeof value === 'boolean'; } @@ -261,10 +292,17 @@ export function formatCssValue(control: Range, value: number): string { return `${number}${control.unit ?? ''}`; } +function scopedElements(root: ParentNode, scope: string, target?: string, targets?: readonly Target[]): HTMLElement[] { + const targetDefinition = target ? targets?.find((item) => item.key === target) : undefined; + const base = target && targetDefinition?.kind === 'section' ? root.querySelector(`[data-fd-target="${CSS.escape(target)}"]`) : root; + if (!base) return []; + const selector = `[data-fd-scope="${CSS.escape(scope)}"]`; + return Array.from(base.querySelectorAll(selector)); +} + export function applyEffects(config: DevtoolsConfig, state: DevtoolsState, root: ParentNode = document): void { for (const control of config.controls ?? []) { - const selector = `[data-fd-scope="${CSS.escape(control.effect.scope)}"]`; - for (const element of Array.from(root.querySelectorAll(selector))) { + for (const element of scopedElements(root, control.effect.scope, control.target, config.targets)) { const value = state.values[control.key]; if (control.effect.variable) { const cssValue = control.type === 'range' ? formatCssValue(control, value as number) : String(value); @@ -276,8 +314,7 @@ export function applyEffects(config: DevtoolsConfig, state: DevtoolsState, root: } for (const family of config.families) { const scope = family.effect?.scope ?? family.key; - const selector = `[data-fd-scope="${CSS.escape(scope)}"]`; - for (const element of Array.from(root.querySelectorAll(selector))) { + for (const element of scopedElements(root, scope, family.target, config.targets)) { if (family.effect?.variable) element.style.setProperty(family.effect.variable, state.families[family.key]); else if (family.effect?.attribute) element.setAttribute(`data-${family.effect.attribute}`, state.families[family.key]); else element.setAttribute(`data-fd-variant-${kebab(family.key)}`, state.families[family.key]); @@ -285,6 +322,57 @@ export function applyEffects(config: DevtoolsConfig, state: DevtoolsState, root: } } +export type ChangeEntry = { + key: string; + label: string; + kind: 'family' | 'control'; + from: ControlValue; + to: ControlValue; + target?: string; + targetKind?: 'global' | 'section'; + classification?: 'token' | 'local'; +}; +export type Changes = { + project: string; + metadata?: Metadata; + changes: ChangeEntry[]; + count: number; +}; + +/** Pure, deterministic comparison against the initial (or supplied) baseline. */ +export function changes(config: DevtoolsConfig, state: DevtoolsState, baseline: DevtoolsState = initialState(config)): Changes { + const current = validateState(config, state); + const base = validateState(config, baseline); + const targetMap = new Map((config.targets ?? []).map((target) => [target.key, target])); + const result: ChangeEntry[] = []; + for (const family of config.families) { + const from = base.families[family.key], to = current.families[family.key]; + if (from !== to) { const target = family.target ? targetMap.get(family.target) : undefined; + result.push({ key: family.key, label: family.label, kind: 'family', from, to, ...(family.target ? { target: family.target, targetKind: target?.kind } : {}) }); } + } + for (const control of config.controls ?? []) { + const from = base.values[control.key], to = current.values[control.key]; + 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 }; +} + +export function changesJson(config: DevtoolsConfig, state: DevtoolsState, baseline?: DevtoolsState): string { + return JSON.stringify(changes(config, state, baseline), null, 2); +} + +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 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')}`; +} + +export const changesRecipe = changes; +export const diff = changes; +export const changesJSON = changesJson; + export function recipe(config: DevtoolsConfig, state: DevtoolsState): string { return JSON.stringify({ project: config.project, diff --git a/src/element.ts b/src/element.ts index 08fcdce..3d58cae 100644 --- a/src/element.ts +++ b/src/element.ts @@ -2,6 +2,9 @@ import { applyEffects, decodeState, recipe, + changesJson, + agentBrief, + initialState, stateUrl, typescriptRecipe, validateConfig, @@ -32,6 +35,8 @@ export class FoundationDevtoolsElement extends HTMLElement { private status?: HTMLElement; private storageKey = 'foundation-devtools:panel'; private ready = false; + private selectedTarget: string | undefined; + private compareMode: 'modified' | 'original' = 'modified'; connectedCallback(): void { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); @@ -61,12 +66,12 @@ export class FoundationDevtoolsElement extends HTMLElement {
-