diff --git a/PROJECT.md b/PROJECT.md
index aac79b0..796a985 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -36,11 +36,12 @@ serialisierbare Website-Konfiguration
Der Astro-Adapter montiert ein browserseitiges Custom Element. Das Panel verwendet Shadow DOM, damit Website-CSS und Tool-CSS einander nicht beeinflussen.
-## V1
+## V1.1
-V1 liefert:
+V1.1 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..57a5fd0 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
## Installation
```bash
-npm install --save-dev https://github.com/cray-com/foundation-devtools/releases/download/v0.1.1/cray-com-foundation-devtools-0.1.1.tgz
+npm install --save-dev https://github.com/cray-com/foundation-devtools/releases/download/v0.2.0/cray-com-foundation-devtools-0.2.0.tgz
```
## Astro
@@ -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:
@@ -62,7 +78,7 @@ Website-CSS kann Tailwind über semantische Layer verwenden. Dynamische Reglerwe
## Öffentliche Schnittstelle
-`DevtoolsConfig`, `Metadata`, `Family`, `Variant`, `Range`, `Select`, `Toggle`, `DevtoolsState` sowie `defineDevtoolsConfig`, `validateConfig`, `initialState`, `validateState`, `encodeState`, `decodeState`, `stateUrl`, `applyEffects`, `recipe` und `typescriptRecipe` sind serialisierbar bzw. strict typisiert. Ungültige URL-Werte werden verworfen (fail-closed).
+`DevtoolsConfig`, `Metadata`, `Target`, `Family`, `Variant`, `Range`, `Select`, `SelectOption`, `Toggle`, `DevtoolsState` sowie `defineDevtoolsConfig`, `validateConfig`, `initialState`, `validateState`, `encodeState`, `decodeState`, `stateUrl`, `applyEffects`, `changes`, `changesJson`, `agentBrief`, `resetBaseline`, `recipe` und `typescriptRecipe` sind serialisierbar bzw. strict typisiert. Nicht-kanonische Alias-Exporte werden nicht angeboten. Ungültige URL-Werte werden verworfen (fail-closed).
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.
diff --git a/fixture/smoke.html b/fixture/smoke.html
index eef85b2..5050a17 100644
--- a/fixture/smoke.html
+++ b/fixture/smoke.html
@@ -5,23 +5,27 @@
.grid { display: grid; gap: var(--fd-gap, 1rem); }
[data-fd-scope="card"] { color: rebeccapurple; }
-CardCard
+CardCard
+
+
diff --git a/package-lock.json b/package-lock.json
index 01d589b..bb06d82 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@cray-com/foundation-devtools",
- "version": "0.1.1",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cray-com/foundation-devtools",
- "version": "0.1.1",
+ "version": "0.2.0",
"license": "MIT",
"devDependencies": {
"@playwright/test": "^1.61.1",
diff --git a/package.json b/package.json
index 4e91a2c..ac3300f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@cray-com/foundation-devtools",
- "version": "0.1.1",
+ "version": "0.2.0",
"description": "Dense, design-neutral development tooling for website variants",
"type": "module",
"sideEffects": false,
diff --git a/src/core.ts b/src/core.ts
index bfad7ae..6fd9811 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;
};
@@ -82,7 +98,7 @@ function fail(message: string): never {
}
function validateEffect(effect: unknown): asserts effect is Effect {
- if (!isRecord(effect) || !scopePattern.test(String(effect.scope))) {
+ if (!isRecord(effect) || typeof effect.scope !== 'string' || !scopePattern.test(effect.scope)) {
fail('effect scope');
}
const hasVariable = typeof effect.variable === '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;
@@ -159,14 +187,16 @@ export function validateConfig(input: unknown): DevtoolsConfig {
!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(step) ||
min >= max || step <= 0 || typeof controlValue.default !== 'number' ||
!Number.isFinite(controlValue.default) || controlValue.default < min || controlValue.default > max ||
+ !isOnStep(controlValue.default, min, step) ||
typeof unit !== 'string' || !unitPattern.test(unit)) {
fail('range');
}
} 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)) ||
+ !isValidOption(option) || optionValue(option as SelectOption).length === 0) ||
+ 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 +220,20 @@ export function validateConfig(input: unknown): DevtoolsConfig {
return config;
}
+function isValidOption(option: unknown): option is SelectOption {
+ return typeof option === 'string' ? option.length > 0 : isRecord(option) && typeof option.value === 'string' && option.value.length > 0 && typeof option.label === 'string' && option.label.length > 0;
+}
+
+function optionValue(option: SelectOption): string { return typeof option === 'string' ? option : option.value; }
+
+function isOnStep(value: number, min: number, step: number): boolean {
+ const steps = (value - min) / step;
+ return Math.abs(steps - Math.round(steps)) < 1e-9;
+}
+
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 === 'range') return typeof value === 'number' && Number.isFinite(value) && value >= control.min && value <= control.max && isOnStep(value, control.min, control.step ?? 1);
+ if (control.type === 'select') return typeof value === 'string' && control.options.some((option) => optionValue(option) === value);
return typeof value === 'boolean';
}
@@ -261,10 +302,27 @@ 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 targetSelector = target ? `[data-fd-target="${CSS.escape(target)}"]` : '';
+ const bases: ParentNode[] = target && targetDefinition?.kind === 'section'
+ ? [
+ ...(root instanceof HTMLElement && root.matches(targetSelector) ? [root] : []),
+ ...Array.from(root.querySelectorAll(targetSelector)),
+ ]
+ : [root];
+ const selector = `[data-fd-scope="${CSS.escape(scope)}"]`;
+ const elements = new Set();
+ for (const base of bases) {
+ if (base instanceof HTMLElement && base.matches(selector)) elements.add(base);
+ for (const descendant of base.querySelectorAll(selector)) elements.add(descendant);
+ }
+ return Array.from(elements);
+}
+
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 +334,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 +342,68 @@ 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')}`;
+}
+
+/** Return a fresh state baseline; useful for scoped reset controls. */
+export function resetBaseline(config: DevtoolsConfig, state: DevtoolsState, target?: string): DevtoolsState {
+ const result = validateState(config, state);
+ const baseline = initialState(config);
+ if (!target) return baseline;
+ for (const family of config.families) if (family.target === target) {
+ result.families[family.key] = baseline.families[family.key];
+ // A reset must also clear defaults from the previously selected variant.
+ const variantKeys = new Set(family.variants.flatMap((variant) => Object.keys(variant.defaults ?? {})));
+ for (const key of variantKeys) result.values[key] = baseline.values[key];
+ }
+ for (const control of config.controls ?? []) if (control.target === target) result.values[control.key] = baseline.values[control.key];
+ return result;
+}
+
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..f843eaa 100644
--- a/src/element.ts
+++ b/src/element.ts
@@ -2,6 +2,11 @@ import {
applyEffects,
decodeState,
recipe,
+ changes,
+ changesJson,
+ agentBrief,
+ initialState,
+ resetBaseline,
stateUrl,
typescriptRecipe,
validateConfig,
@@ -17,8 +22,9 @@ const styles = `
.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; }
.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; }
-.body { display: grid; gap: 8px; padding: 9px; } .control { display: grid; gap: 3px; } label { display: flex; justify-content: space-between; gap: 8px; color: #c8cdd3; } output { color: #fff; }
+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; }
+.control { display: grid; gap: 3px; } .control.changed label::after { content: 'modified'; color: #9dbde0; font-size: 10px; } 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); } }
@@ -32,6 +38,9 @@ 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';
+ private pickerCleanup?: () => void;
connectedCallback(): void {
if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
@@ -41,6 +50,7 @@ export class FoundationDevtoolsElement extends HTMLElement {
disconnectedCallback(): void {
document.removeEventListener('keydown', this.recover);
+ this.pickerCleanup?.(); this.pickerCleanup = undefined;
}
configure(config: DevtoolsConfig): void {
@@ -61,12 +71,12 @@ export class FoundationDevtoolsElement extends HTMLElement {
-