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 {
-