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; } -
Card
Card
+
Card
Card
+ +
Notes
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 {
-