From d73144ab363e2a9a5080a2316468e12f007b0c5b Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 12:49:58 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Plan=20Etappe=204c=20=E2=80=93=20Farben?= =?UTF-8?q?=20und=20Variablen=20schreiben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../2026-10-19-etappe-4c-farben-variablen.md | 10157 ++++++++++++++++ 1 file changed, 10157 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-19-etappe-4c-farben-variablen.md diff --git a/docs/superpowers/plans/2026-10-19-etappe-4c-farben-variablen.md b/docs/superpowers/plans/2026-10-19-etappe-4c-farben-variablen.md new file mode 100644 index 0000000..d4292de --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-etappe-4c-farben-variablen.md @@ -0,0 +1,10157 @@ +# Etappe 4c – Designsystem: Farben und Variablen schreiben – Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Der KI-Assistent legt globale Farben und Variablen an und ändert sie (`lit/divi-colors-set`, `lit/divi-variables-set`), entfernt Verweise auf eine Variable überall, indem er sie durch ihren Wert ersetzt (`lit/divi-design-detach` mit `variable`), und stellt Snapshots der Bereiche `colors` und `variables` wieder her (`lit/divi-design-restore`) – alles mit Probelauf, `confirm`, `design_rev`, Snapshot vor jeder Änderung und Zurücklesen nach dem Schreiben. + +**Architecture:** Die 4a-Pipeline `DesignChange` bleibt unverändert; neu sind zwei `DesignArea`-Implementierungen (`ColorArea`, `VariableArea`) mit reinen Bestandsklassen (`ColorBook`, `VariableBook`, jeweils mit Diff und Wertprüfung) und zwei kleinen Divi-Adaptern (`GlobalColorsAdapter`, `GlobalVariablesAdapter`) mit eigenen Fähigkeiten (`color-write`, `variable-write`). Fundstellen von Farben/Variablen in Seiten liefert eine neue `ReferenceUsageQuery` für das vorhandene `GlobalUsages`; Variablen-Detach nutzt den vorhandenen `PageRewriter` mit einer neuen `NodeRewrite`-Strategie und den Preset-Schritt der 4b-Pipeline. `DiviDesignRestoreAbility` wird auf Planer je Bereich umgestellt (`RestorePlanner`), `DiviDesignDetachAbility` verwendet statt eines Arrays ein `DetachTarget`. + +**Tech Stack:** PHP ≥ 8.1, WordPress 7.x, Divi 5.15.1 (`GlobalData::get_global_colors/set_global_colors/sanitize_global_colors_data/get_global_variables/set_global_variables`, `et_pb_is_allowed` – nur über die zwei neuen Adapter), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes. + +**Spec:** `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` (§4, §5, §6.8 Variable, §7, §10, §11, §13, §14 Schritt 6), Entscheidungen `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` (G2, G5, G6, A4, A7, A8 – verbindlich), Datenformate `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` (§1 Farben, §2 Variablen, §5 Caches, §6 Rechte, §7 Abweichungen), Divi-Fähigkeiten `docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md`, Pläne 4a/4b (`2026-10-16-etappe-4a-presets.md`, `2026-10-18-etappe-4b-zuweisen.md`) als Vorbild; der Code ist maßgeblich, wo er von den Plänen abweicht. + +**Voraussetzung:** Branch `etappe-4`, Stand 63389f3 (4a, 4b und Divi-Fähigkeiten fertig), 2877 Unit-Tests grün (`composer test`), `composer lint` sauber. + +**Teil von Etappe 4:** 4a, 4b (fertig) → **4c (dieser Plan)** → 4d Theme-Optionen und Schriften → 4e Schleifen-Inhalte, Guides, Audit, Version. + +## Global Constraints + +- **Dateien nur im Plugin-Verzeichnis** `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und stellen im EXIT-Trap alles wieder her, was sie ändern (Farben, Variablen, Presets, Seiten). +- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9; Divi 5.15.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, höchstens 120 Zeichen je Zeile, `declare(strict_types=1);`. Nach jedem Task `composer test` und `composer lint` sauber (`composer test` = `php -d memory_limit=512M vendor/bin/phpunit`). +- **Divi-Grenze (Divi-Absicherung §3):** Divi-Aufrufe nur in Klassen aus `DiviAdapters::CLASSES` mit Manifest; jedes Divi-Symbol steht mit Fähigkeiten im Manifest; `DiviBoundaryTest` und `DiviFeatureAssignmentTest` bleiben grün (die angepasste Tabelle gehört in denselben Task wie die Manifest-Änderung). Alle übrigen neuen Klassen sind **reines PHP** ohne WordPress- und Divi-Aufrufe (Abhängigkeiten als Objekt oder `Closure` injiziert); Ausnahmen nennt der Task ausdrücklich (`ReferenceUsageQuery` darf wie `PresetUsageQuery` nur `$db->prepare()`/`$db->esc_like()` am übergebenen `\wpdb` nutzen). +- **Pipeline (Spec §4):** Jede Änderung eines Bereichs läuft durch `DesignChange::run()`: Rechte → Eingabe prüfen (alle Fehler gesammelt als `validation_failed` mit Feldpfaden) → neuen Bestand berechnen → Probelauf (`confirm_required` mit `diff`, `design_rev`, `affected`, `warnings`) → mit `confirm: true` und `design_rev` Sperre, Snapshot, Schreiben über Divi, Zurücklesen und `verify`, Protokoll. Keine Ausnahme für „harmlose“ Änderungen. Mehrere Einträge je Aufruf (A4, höchstens 50). +- **Werte (D7-Prinzip):** Farbwerte und Variablenwerte werden vom Plugin geprüft (`ColorValue`, `VariableValue`) und erst dann an Divi gegeben; Divis eigenes Sanitize (`sanitize_global_colors_data`, `sanitize_global_variables_data` innerhalb von `set_global_variables`) läuft zusätzlich. Referenzen auf andere Farben entstehen nur über `VariableRef::serialize('color', , )`, nie aus Client-Text. +- **Rechte (Spec §10):** Farben = `edit_theme_options` **und** Divi-Rolle „Visual Builder verwenden“ (strenger als Divi selbst); Variablen zusätzlich `et_pb_is_allowed('variables_manager')` (sonst täte Divi still nichts). Restore prüft die Rechte des Bereichs des Snapshots. Detach einer Variable = Rechte der Variablen **und** `edit_post` je Live-Fundstelle (Regeln P8/P8a des `PageRewriter`) und, weil Presets mitgeschrieben werden, `DesignRights::presets()`. +- **Exposition:** Neue Tools sind indirekt (über `mcp-adapter-execute-ability`), Gruppe `divi`, `Annotations::write`/`destructive` wie die Preset-Tools; Texte nennen indirekte Tools `lit/` (ToolNamingTest); `docs/guides/overview.md` „Direkt sichtbar:“ bleibt unverändert. Guides folgen gesammelt mit 4e (Spec §14 Schritt 9); die Tool-Beschreibungen tragen die Reihenfolge Probelauf → `confirm`. +- **Sprache:** Meldungen, Hinweise, Beschreibungen auf Deutsch mit echten Umlauten; neutral „KI-Assistent“, „MCP-Client“. +- **Tests:** Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery, `assertToolError`); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. Reine Klassen erben von `PHPUnit\Framework\TestCase`. TDD: Test zuerst, RED-Lauf mit erwarteter Fehlermeldung, dann Code, GREEN. Integration: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh` (läuft nur mit deaktivierten Plugins `postlos` und `asynclistry`). +- **Kein clean-code-review-Skill je Task** (die globale CLAUDE.md-Regel zieht sonst die Antwort des Implementierers an sich); der Gesamtreview folgt am Ende (Task 12). +- Commits: `git commit -m "" -m "Co-Authored-By: Claude "` mit dem Namen des ausführenden Modells; nur die Dateien des Tasks stagen. + +## Entscheidungen für 4c (Rulings, verbindlich) + +- **V1 Variablen werden nie gelöscht, nur archiviert.** Divi 5.15.1 lehnt in `GlobalData::set_global_variables()` jeden Bestand ab, dem eine gespeicherte `gvid-`-ID fehlt (`validate_global_variable_ids()` → `global_variable_ids_missing`, 400). Echtes Löschen (Spec §7.2 `delete: true`) ist damit nicht möglich und entfällt; der Builder archiviert ebenfalls (`status: archived`, `order: ""`). Folgen: (a) `lit/divi-variables-set` kennt `status: active|archived`, kein `delete`, kein `in_use`; Archivieren einer verwendeten Variablen ist erlaubt und meldet die Fundstellen als Warnung (wie Farben deaktivieren, Spec §7.1); (b) **Restore** eines Snapshots stellt jede Variable des Snapshots her; Variablen, die erst nach dem Snapshot entstanden, bleiben **archiviert** erhalten (`VariableBook::restoredFrom()`), der Probelauf nennt sie als Warnung; (c) `lit/divi-design-detach` mit `variable` ist der Weg, eine Variable aus den Inhalten zu lösen, bevor man sie archiviert. Spec §7.2 und Entscheidungsnotiz werden entsprechend nachgeführt (Task 11). +- **V2 Zwei Fähigkeiten statt einer:** `DiviFeature::ColorWrite` (`color-write`, „Farben schreiben“) und `DiviFeature::VariableWrite` (`variable-write`, „Variablen schreiben“). Ein Bruch von `set_global_variables` sperrt so nicht das Farbenschreiben. `DesignArea::feature()` liefert je Bereich die eigene Fähigkeit; beide sind Schreib-Fähigkeiten (`isWrite()`). Die Tools deklarieren zusätzlich `DesignRead` (und das Detach-Tool die Seiten-/Bibliotheks-/Theme-Builder-Fähigkeiten wie bisher). +- **V3 Farben: Hauptfarben und Cache.** `GlobalData::get_global_colors()` liefert die fünf Customizer-Farben (`folder: customizer`, IDs `gcid-primary-color` … `gcid-link-color`, Optionen `accent_color`, `secondary_accent_color`, `header_color`, `font_color`, `link_color`) **vor** den gespeicherten Farben mit `lastUpdated` = jetzt, und `get_customizer_colors()` hat einen `static`-Cache ohne Reset. `GlobalColorsAdapter::read()` legt deshalb die Werte der fünf Hauptfarben aus der Option `et_divi` über das Ergebnis (nur wenn dort ein nicht leerer String steht) – sonst läse das Zurücklesen nach dem Schreiben im selben Request den alten Wert. `ColorBook` setzt `lastUpdated` der Hauptfarben auf `""` (flüchtig; `design_rev` und Snapshots bleiben stabil). Geschrieben werden Hauptfarben nur, wenn sich ihr Wert ändert (die Builder-Sicht `#2ea3f2` für `secondary_accent_color` weicht vom Customizer-Standard `#8800FF` ab, Formate §4). Hauptfarben lassen sich nicht deaktivieren und nicht umbenennen oder in einen Ordner schieben; nur `color` ändert sich. +- **V4 Schreibweg Farben:** normales Schreiben = nur geänderte und neue Einträge (`ColorBook::changedSince()`), durch `GlobalData::sanitize_global_colors_data()` und dann `set_global_colors($data, true)` (mischt; `true` heißt „bereits bereinigt“); Restore (`mayRemove = true`) = gesamter Snapshot durch `set_global_colors($data, false)` (ersetzt alle gespeicherten Farben, bereinigt selbst, schreibt die Hauptfarben zurück). Neue IDs `gcid-` + 10 Zeichen `[0-9a-z]`; `lastUpdated` im Format `Y-m-d\TH:i:s.v\Z`; `usedInPosts` bleibt `[]` (nur der Builder pflegt sie, Formate §1). +- **V5 Farbwerte:** `color` = Hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`), `rgb()`/`rgba()`/`hsl()`/`hsla()`, ein Farbschlüsselwort (nur Buchstaben, 3–20 Zeichen) oder ein Platzhalter `{{color:}}` **mit** `relative: {hue?, saturation?, lightness?, opacity?}` (Zahlen, `hue` −360…360, die übrigen −100…100). Der Platzhalter wird gegen den **geplanten** Bestand aufgelöst (auch Farben desselben Aufrufs, in Reihenfolge der `items`) und als `VariableRef::serialize('color', , )` gespeichert. Zyklen und Tiefe über 10 (wie Divi) → `validation_failed`. `shortcut:` Wertebereiche der relativen Einstellungen sind die der Builder-Regler, nicht aus Divi-Code abgeleitet – im Code kommentieren. +- **V6 Variablenwerte (`VariableValue::check(type, value)`):** `numbers` = CSS-Ausdruck ohne `;`, `{`, `}`, `<`, `>`, Zeilenumbruch, höchstens 200 Zeichen, nicht leer; `strings` = beliebiger Text ohne Steuerzeichen außer Zeilenumbruch, höchstens 2000 Zeichen, nicht leer; `links` = `http(s)://`- oder `/`-URL, `mailto:`/`tel:` erlaubt, höchstens 2000 Zeichen; `images` = Anhang-ID (Ziffern) oder `http(s)://`-URL; `fonts` = Schriftname, 1–100 Zeichen ohne `;{}<>"'\\` (die Prüfung gegen Divis Schriftenliste folgt mit 4d). Aktive Variablen brauchen einen Wert (Divi: `global_variable_active_value_empty`). Der Typ `gradients` (neu in Divi 5.15) wird beim Lesen und Schreiben **unverändert durchgereicht**, das Tool legt ihn aber nicht an. +- **V7 Schreibweg Variablen:** `GlobalVariablesAdapter::read()` = `GlobalData::get_global_variables()` zurück in Speicherform (Gruppen und Einträge als Arrays, `allowedActions`, die Customizer-Schriften `--et_global_heading_font`/`--et_global_body_font` und die Gruppe `colors` entfernt, leere Gruppen weggelassen); `save($data, $expected)` ruft `GlobalData::set_global_variables($data, $expected)` mit dem Bestand, auf dem geplant wurde (Divis eigene Stale-Prüfung `global_variables_stale`), und macht aus einem `WP_Error` eine `RuntimeException` (die Pipeline rollt zurück). Ohne `variables_manager`/`edit_theme_options` tut Divi **still nichts** – darum prüft `DesignRights::variables()` vorher, und `VariableArea::verify()` fängt den Rest über das Zurücklesen. +- **V8 Fundstellen (`affected`):** Für geänderte, deaktivierte oder archivierte Einträge (nicht für neue) liefert `ReferenceUsageQuery` die Seiten, Bibliothekseinträge und Theme-Builder-Layouts, die die ID in einem `$variable(…)$`-Verweis nennen (über `GlobalUsages::reportFor()`, Rechte-Regeln wie `UsageReport`); Verwendungen in Presets und in relativen Farben stehen als `warnings`, nie als Sperre. Eine blockierte Live-Seite (ohne `edit_post`) verweigert das Schreiben wie bei Presets (`DesignChange::blocked()`, P8/P8a). +- **V9 Detach einer Variablen (A7, R10):** jeder ganze Wert `$variable({"type":"content","value":{"name":"",…}})$` in den Attributen eines lesbaren Blocks wird durch den aktuellen Wert der Variablen ersetzt (bei `strings` mit `before`/`after` aus den Einstellungen: `before . Wert . after`; sonst ohne). Eingebettete Verweise (nicht der ganze Wert) bleiben stehen und werden als Hinweis gemeldet. Verschachtelte Verwendung in Presets (`attrs`, `styleAttrs`, `renderAttrs`) entfernt der Aufruf im selben Schritt über die Pipeline (Snapshot `presets`), wie bei Gruppen-Presets (4b R10). Farben haben kein Detach (sie werden nur deaktiviert). +- **V10 Restore je Bereich:** `DiviDesignRestoreAbility` bekommt eine Liste von `RestorePlanner` (`PresetRestorePlanner` = der bisherige Code unverändert verschoben, `ColorRestorePlanner`, `VariableRestorePlanner`); der Bereich ergibt sich aus der Snapshot-ID. Rechte und Fähigkeit sind die des Bereichs. Die Meldung „Snapshots dieses Bereichs lassen sich noch nicht wiederherstellen“ gilt nur noch für `theme_options` (4d). + +## Schnittstellenvertrag (Namen und Signaturen, die spätere Tasks voraussetzen) + +Alle Klassen unter `src/Integrations/Divi/Design/` (Namespace `LIT\MCP\Integrations\Divi\Design`), Tests unter `tests/Unit/Integrations/Divi/Design/` (Namespace `LIT\MCP\Tests\Unit\Integrations\Divi\Design`), sofern nicht anders genannt. + +```php +// Release/DiviFeature.php (Task 1) +case ColorWrite = 'color-write'; // label 'Farben schreiben', isWrite() = true +case VariableWrite = 'variable-write'; // label 'Variablen schreiben', isWrite() = true + +// GlobalColorsAdapter.php (Task 2) – class (nicht final: Mockery-Ersatz in Tests) implements DiviAdapter, Manifest::DESIGN +public static function manifest(): Manifest; +/** @return array> get_global_colors() mit den Hauptfarben aus et_divi (V3) */ +public function read(): array; +/** @param array> $entries bereits geprüft; wirft RuntimeException, wenn Divi nichts speichert */ +public function save(array $entries, bool $replace): void; // replace=false: bereinigen + set_global_colors($d, true); true: set_global_colors($d, false) + +// GlobalVariablesAdapter.php (Task 2) +public static function manifest(): Manifest; +/** @return array>> Speicherform, Typ => ID => Eintrag (V7) */ +public function read(): array; +/** @param array>> $data @param array>> $expected */ +public function save(array $data, array $expected): void; // wirft RuntimeException bei WP_Error von Divi +public function canManage(): bool; // et_pb_is_allowed('variables_manager'), fail closed ohne Divi + +// DesignRights.php (Task 2) – zusätzlich zu presets() +public static function colors(DesignSystemWriter $writer): ?WP_Error; // edit_theme_options + Visual Builder +public static function variables(DesignSystemWriter $writer, GlobalVariablesAdapter $variables): ?WP_Error; // + variables_manager + +// ColorValue.php (Task 3) – reines PHP +public const TYPES_NOTE = '…'; +public static function check(string $color): ?string; // null = gültig, sonst deutsche Fehlermeldung (V5; ohne Platzhalter) +/** @param array $relative @return array|string settings oder Fehlermeldung */ +public static function relative(array $relative): array|string; // nur hue|saturation|lightness|opacity, Zahlen, Bereiche V5 + +// ColorBook.php (Task 3) – reines PHP, unveränderlich +public const CUSTOMIZER_IDS = ['gcid-primary-color' => 'accent_color', 'gcid-secondary-color' => 'secondary_accent_color', + 'gcid-heading-color' => 'header_color', 'gcid-body-color' => 'font_color', 'gcid-link-color' => 'link_color']; +public static function fromDivi(array $raw): self; // Eintrag => {color, folder, label, lastUpdated, status, usedInPosts}; Hauptfarben: lastUpdated '' +/** @return array}> */ +public function data(): array; +public function rev(): string; // Rev::of() wie PresetBook::rev() (Hash über den kanonischen Bestand) +public function entry(string $id): ?array; +public function label(string $id): string; // Label, sonst ID +public function isCustomizer(string $id): bool; // folder === 'customizer' oder ID in CUSTOMIZER_IDS +/** @throws InvalidArgumentException mehrdeutiges Label (ID-Liste, wie DesignSystem::byLabel) */ +public function find(string $needle): ?string; // ID | Alias primary/secondary/heading/body/link | Optionsname | Label (ohne Beachtung der Groß-/Kleinschreibung) +public function with(string $id, array $entry): self; // anlegen oder ersetzen +public function referencing(string $id): array; // list IDs von Farben, deren color ein Verweis auf $id ist +public function cycle(): ?array; // list Kette eines Zyklus oder einer Tiefe > 10, sonst null +public function changedSince(self $before): array; // array neue und geänderte Einträge (lastUpdated der Hauptfarben ignoriert) +public static function newId(?Closure $random = null): string; // 'gcid-' + 10 Zeichen [0-9a-z]; $random(int $n): string für Tests +public static function now(): string; // Y-m-d\TH:i:s.v\Z (UTC) + +// ColorDiff.php (Task 3) – reines PHP +/** @return list}> action: added|changed|deactivated|activated (der Restore-Planer ergänzt removed) */ +public static function between(ColorBook $before, ColorBook $after): array; +public static function summary(array $diff): string; // 'Farben: 1 angelegt, 2 geändert, 1 deaktiviert.' / 'Farben: keine Änderung.' + +// VariableValue.php (Task 4) – reines PHP +public const TYPES = ['numbers', 'strings', 'images', 'links', 'fonts']; // anlegbar (V6); 'gradients' nur durchgereicht +public static function check(string $type, string $value): ?string; // null = gültig, sonst deutsche Fehlermeldung + +// VariableBook.php (Task 4) – reines PHP, unveränderlich +public static function fromDivi(array $raw): self; // akzeptiert Speicherform und get_global_variables()-Form (V7): stdClass/Objekte → Arrays, allowedActions, Customizer-Schriften und 'colors' entfernt, leere Gruppen weg +/** @return array>> Typ => ID => {id, label, value, order, status, lastUpdated, variableType} */ +public function data(): array; +public function rev(): string; +public function entry(string $type, string $id): ?array; +public function typeOf(string $id): ?string; +/** @return array{type: string, id: string}|null @throws InvalidArgumentException mehrdeutiges Label (ID-Liste) */ +public function find(string $needle, ?string $type = null): ?array; // ID oder Label (ohne Beachtung der Groß-/Kleinschreibung), optional auf einen Typ beschränkt +public function with(string $type, string $id, array $entry): self; +public function nextOrder(string $type): string; // größte vorhandene order des Typs + 1 als String +public function restoredFrom(self $snapshot): self; // V1b: Snapshot ∪ nur-aktuelle Einträge als archiviert (order '') +/** @return list Einträge, die restoredFrom() archiviert hätte */ +public function onlyHere(self $snapshot): array; +public static function newId(?Closure $random = null): string; // 'gvid-' + 10 Zeichen [0-9a-z] +public static function now(): string; + +// VariableDiff.php (Task 4) – reines PHP +/** @return list}> action: added|changed|archived|activated */ +public static function between(VariableBook $before, VariableBook $after): array; +public static function summary(array $diff): string; // 'Variablen: 1 angelegt, 1 geändert, 1 archiviert.' + +// ReferenceUsageQuery.php (Task 5) – implements Library\UsageQuery +public function __construct(array $ids); // list gcid-/gvid-IDs, mindestens eine, sonst InvalidArgumentException +// matches(): Inhalt parsen (Parser/NodeIndex), true, wenn die Attribute irgendeines Blocks eine der IDs nennen; nicht parsbar → true (fail closed) + +// PresetBook.php (Task 5) – zusätzlich +/** @return list Presets, deren attrs/styleAttrs/renderAttrs die ID nennen */ +public function referencing(string $id): array; +public function withVariableResolved(string $id, string $value, bool $affixes = false): self; // V9: ganze Verweise im Preset durch $value ersetzen (attrs, styleAttrs, renderAttrs); $affixes: before/after der Verweis-Einstellungen (nur strings) + +// ColorArea.php / VariableArea.php (Tasks 6/7) – implements DesignArea +public function __construct(GlobalColorsAdapter $colors); // name() 'colors', label() 'Farben', feature() ColorWrite +public function __construct(GlobalVariablesAdapter $variables); // name() 'variables', label() 'Variablen', feature() VariableWrite + +// DiviColorsSetAbility.php / DiviVariablesSetAbility.php (Tasks 6/7) – implements Ability, RequiresDiviFeatures +// name() 'lit/divi-colors-set' bzw. 'lit/divi-variables-set'; Eingabe items[] + DesignChange::inputProperties() + +// DetachTarget.php (Task 8), VariableDetachRewrite.php (Task 8) – implements NodeRewrite +public function __construct(string $id, string $value, string $variableType); // VariableDetachRewrite; DetachTarget siehe Task 8 + +// RestorePlanner.php (Task 9) – Interface +public function area(): string; // 'presets' | 'colors' | 'variables' +public function designArea(): DesignArea; +public function rights(): ?WP_Error; +public function features(): array; // list +/** @param array $current @param array $snapshot */ +public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error; +``` + +## Dateistruktur + +**Neu (`src/Integrations/Divi/Design/`):** `GlobalColorsAdapter`, `GlobalVariablesAdapter`, `ColorValue`, `ColorBook`, `ColorDiff`, `ColorArea`, `DiviColorsSetAbility`, `VariableValue`, `VariableBook`, `VariableDiff`, `VariableArea`, `DiviVariablesSetAbility`, `ReferenceUsageQuery`, `DetachTarget`, `VariableDetachRewrite`, `RestorePlanner`, `PresetRestorePlanner`, `ColorRestorePlanner`, `VariableRestorePlanner`. + +**Geändert:** `Release/DiviFeature.php`, `Release/DiviAdapters.php`, `Release/fingerprints.json`, `Design/DesignRights.php`, `Design/PresetBook.php`, `Design/DiviDesignDetachAbility.php`, `Design/DiviDesignRestoreAbility.php`, `Design/DiviDesignSnapshotsAbility.php` (nur Beschreibung, falls nötig), `DiviIntegration.php`, `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` (Nachtrag 5.15.1), `docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md` (Offen-Liste), `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` (§7.2). + +**Neu (Tests):** je Klasse eine Testdatei; `tests/Integration/divi-colors-variables-smoke.sh`. + +**Geändert (Tests):** `DiviFeatureTest`, `DiviFeatureAssignmentTest`, `DiviToolFeaturesTest`, `DiviIntegrationTest`, `DiviDesignDetachAbilityTest`, `DiviDesignRestoreAbilityTest`, `DesignRightsTest`, `PresetBookTest` sowie – falls sie Fähigkeiten zählen oder aufzählen – `FeatureOverviewTest`, `FeatureStatusTest`, `DiviTabTest`, `DiviLockedTest`, `ReleaseGateTest`, `ReleaseResolverTest` (Task 1 findet sie mit `grep -rn "PresetWrite\|preset-write" tests`). + +## Task-Übersicht + +| # | Task | Ergebnis | +|---|---|---| +| 1 | Fähigkeiten `ColorWrite`/`VariableWrite` | Enum, Label, Tests (Commit gemeinsam mit Task 2) | +| 2 | Adapter und Rechte | `GlobalColorsAdapter`, `GlobalVariablesAdapter`, Manifest, Fingerabdrücke, `DesignRights` | +| 3 | `ColorValue`, `ColorBook`, `ColorDiff` | reiner Farbbestand | +| 4 | `VariableValue`, `VariableBook`, `VariableDiff` | reiner Variablenbestand | +| 5 | `ReferenceUsageQuery`, `PresetBook`-Erweiterungen | Fundstellen und Preset-Verweise | +| 6 | `ColorArea`, `lit/divi-colors-set` | Farben schreiben | +| 7 | `VariableArea`, `lit/divi-variables-set` | Variablen schreiben | +| 8 | `DetachTarget`, Variablen-Detach | `lit/divi-design-detach` mit `variable` | +| 9 | `RestorePlanner` je Bereich | Restore für Farben und Variablen | +| 10 | Verdrahtung | `DiviIntegration`, Tests der Fähigkeitszuordnung, Beschreibungen | +| 11 | Smoke, Notizen, Spec | `divi-colors-variables-smoke.sh`, Formate-Nachtrag, Spec §7.2, Memory-Hinweis | +| 12 | Gesamtverifikation | Suite, Lint, Smokes, Gesamtreview | + +--- + +### Task 1: Fähigkeiten `ColorWrite` und `VariableWrite` (V2) + +Zwei neue Schreib-Fähigkeiten, damit ein Bruch von `set_global_variables` nicht das Farbenschreiben sperrt. Geprüft mit `grep -rn "PresetWrite\|preset-write\|elf Fähigkeiten\|Eleven\|eleven" src tests docs` und einem Probelauf der ganzen Suite mit den zwei neuen Fällen (Stand 63389f3): nur drei Tests brechen – zwei in `DiviFeatureTest` (zählen und listen die Fälle exakt) und `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` (jede Fähigkeit braucht eine Manifest-Anforderung; die kommen erst mit den Adaptern in Task 2). `FeatureOverviewTest`, `FeatureStatusTest`, `DiviTabTest`, `DiviLockedTest`, `ReleaseGateTest`, `ReleaseResolverTest`, `CompatibilityMatrixTest`, `DiviCheckCommandTest` und `DiviEnvironmentComponentTest` rechnen mit `count(DiviFeature::cases())` oder nennen nur einzelne Fähigkeiten und bleiben unverändert grün. Außerhalb der Unit-Tests zählt nur `tests/Integration/divi-release-smoke.sh` die Fähigkeiten (Liste der IDs, „eleven“, `= "11"`). `docs/guides/overview.md` nennt Fähigkeiten nur als Beispiele (bleibt), die Fähigkeiten-Notiz folgt mit Task 11. + +**Wegen des Zuordnungstests hat Task 1 keinen eigenen Commit:** seine Dateien werden im letzten Schritt von Task 2 zusammen mit den Adaptern committet (siehe `## ABWEICHUNGEN`, A1). Bis dahin ist genau `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` rot. + +**Files:** +- Modify: `src/Integrations/Divi/Release/DiviFeature.php` (zwei Fälle, `isWrite()`, `label()`, Docblock von `isWrite()`) +- Modify: `tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php` +- Modify: `tests/Integration/divi-release-smoke.sh` (13 statt 11 Fähigkeiten; läuft erst nach Task 2 grün, weil vorher die Signatur-Referenzen der neuen Aufrufe fehlen) + +**Interfaces:** +- Consumes: `DiviFeature` (vorhanden). +- Produces: `DiviFeature::ColorWrite = 'color-write'` (Label „Farben schreiben“), `DiviFeature::VariableWrite = 'variable-write'` (Label „Variablen schreiben“), beide `isWrite() === true`. Reihenfolge der Fälle (bestimmt die Reihenfolge in Status-Tool, Admin-Reiter und `divi-check`): direkt nach `PresetWrite`. + +- [ ] **Step 1: `DiviFeatureTest` auf dreizehn Fähigkeiten umstellen** + +`tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php` vollständig ersetzen: + +```php + 'page-read', + 'PageWrite' => 'page-write', + 'Catalog' => 'catalog', + 'DynamicSources' => 'dynamic-sources', + 'DesignRead' => 'design-read', + 'PresetWrite' => 'preset-write', + 'ColorWrite' => 'color-write', + 'VariableWrite' => 'variable-write', + 'LibraryRead' => 'library-read', + 'LibraryWrite' => 'library-write', + 'Roles' => 'roles', + 'ThemeBuilderRead' => 'tb-read', + 'ThemeBuilderWrite' => 'tb-write', + ], + array_column(array_map( + static fn (DiviFeature $feature): array => [$feature->name, $feature->value], + DiviFeature::cases() + ), 1, 0) + ); + } + + public function testOnlyTheSixWriteFeaturesWrite(): void + { + $writing = array_values(array_map( + static fn (DiviFeature $feature): string => $feature->value, + array_filter(DiviFeature::cases(), static fn (DiviFeature $feature): bool => $feature->isWrite()) + )); + + self::assertSame( + ['page-write', 'preset-write', 'color-write', 'variable-write', 'library-write', 'tb-write'], + $writing + ); + } + + public function testEveryFeatureHasAUniqueGermanLabel(): void + { + $labels = array_map(static fn (DiviFeature $feature): string => $feature->label(), DiviFeature::cases()); + + self::assertCount(count(DiviFeature::cases()), array_unique($labels)); + self::assertSame('Seiten schreiben', DiviFeature::PageWrite->label()); + self::assertSame('Theme Builder lesen', DiviFeature::ThemeBuilderRead->label()); + self::assertSame('Farben schreiben', DiviFeature::ColorWrite->label()); + self::assertSame('Variablen schreiben', DiviFeature::VariableWrite->label()); + foreach ($labels as $label) { + self::assertNotSame('', trim($label)); + } + } + + public function testValuesResolveBackToTheFeature(): void + { + self::assertSame(DiviFeature::ThemeBuilderWrite, DiviFeature::from('tb-write')); + self::assertNull(DiviFeature::tryFrom('page')); + } +} +``` + +- [ ] **Step 2: RED** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviFeatureTest` +Expected: FAIL – `Tests: 4, Assertions: 7, Errors: 1, Failures: 2`: +- `testEveryFeatureHasAUniqueGermanLabel`: `Error: Undefined constant LIT\MCP\Integrations\Divi\Release\DiviFeature::ColorWrite` +- `testTheThirteenFeaturesAndTheirValues`: Array-Vergleich, es fehlen `'ColorWrite' => 'color-write'` und `'VariableWrite' => 'variable-write'` +- `testOnlyTheSixWriteFeaturesWrite`: es fehlen `color-write` und `variable-write` + +- [ ] **Step 3: Fälle, `isWrite()` und `label()` ergänzen** + +```diff +--- a/src/Integrations/Divi/Release/DiviFeature.php ++++ b/src/Integrations/Divi/Release/DiviFeature.php +@@ -21,17 +21,20 @@ + case DynamicSources = 'dynamic-sources'; + case DesignRead = 'design-read'; + case PresetWrite = 'preset-write'; ++ case ColorWrite = 'color-write'; ++ case VariableWrite = 'variable-write'; + case LibraryRead = 'library-read'; + case LibraryWrite = 'library-write'; + case Roles = 'roles'; + case ThemeBuilderRead = 'tb-read'; + case ThemeBuilderWrite = 'tb-write'; + +- /** Whether the feature changes data (the four *Write cases). */ ++ /** Whether the feature changes data (the six *Write cases). */ + public function isWrite(): bool + { + return match ($this) { +- self::PageWrite, self::PresetWrite, self::LibraryWrite, self::ThemeBuilderWrite => true, ++ self::PageWrite, self::PresetWrite, self::ColorWrite, self::VariableWrite, self::LibraryWrite, ++ self::ThemeBuilderWrite => true, + default => false, + }; + } +@@ -46,6 +49,8 @@ + self::DynamicSources => 'Dynamische Inhalte', + self::DesignRead => 'Designsystem lesen', + self::PresetWrite => 'Presets schreiben', ++ self::ColorWrite => 'Farben schreiben', ++ self::VariableWrite => 'Variablen schreiben', + self::LibraryRead => 'Bibliothek lesen', + self::LibraryWrite => 'Bibliothek schreiben', + self::Roles => 'Divi-Rollen', +``` + +- [ ] **Step 4: GREEN** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviFeatureTest` +Expected: `OK (4 tests, …)`. + +Run: `composer test` +Expected: genau ein Fehlschlag, `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` mit `Fähigkeiten ohne Anforderung in einem Manifest` und `+ 0 => 'color-write'` / `+ 1 => 'variable-write'`. Jeder andere Fehlschlag ist ein Befund (ein Test zählt Fähigkeiten doch fest) und wird hier mit angepasst. + +- [ ] **Step 5: Integrations-Smoke auf dreizehn Fähigkeiten umstellen** + +```diff +--- a/tests/Integration/divi-release-smoke.sh ++++ b/tests/Integration/divi-release-smoke.sh +@@ -27,8 +27,8 @@ + + check_pages() { wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count; } + +-# The ids of the eleven features (plan decision D1), sorted. +-FEATURE_IDS='["catalog","design-read","dynamic-sources","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write"]' ++# The ids of the thirteen features (plan decision D1, plan 4c V2), sorted. ++FEATURE_IDS='["catalog","color-write","design-read","dynamic-sources","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write","variable-write"]' + + # Adds a problem to one feature in the cached state, as if its mirror check had failed. Needs the cache entry of + # ReleaseResolver (format 2: ["format" => 2, "key" => stateKey, "problems" => list of Problem::toArray()]); the key +@@ -68,7 +68,7 @@ + .components[] | select(.id == "divi") | .features + | ([.[].id] | sort) == $ids + and all(.state == "released" and .source == "code" and .problems == [])' >/dev/null \ +- || fail "all eleven features must be released from code without problems: $STATUS" ++ || fail "all thirteen features must be released from code without problems: $STATUS" + echo "$STATUS" | jq -e ' + .components[] | select(.id == "divi") | .features + | (.[] | select(.id == "page-write") | .tools | any(. == "lit-divi-insert")) +@@ -76,7 +76,7 @@ + and (.[] | select(.id == "preset-write") | .tools | any(startswith("lit/divi-presets-"))) + and (.[] | select(.id == "page-read") | .label == "Seiten lesen")' >/dev/null \ + || fail "features must name their tools (direct lit-, indirect lit/): $STATUS" +-pass "lit-site-status: eleven features released (code), each with its tools" ++pass "lit-site-status: thirteen features released (code), each with its tools" + + # 2. Admin check through the REST route (application password instead of the cookie nonce). + # X-Forwarded-Proto: https is required here: without it the request to http://localhost with the +@@ -212,8 +212,8 @@ + CLI=$(wp --user="$ADMIN_LOGIN" lit-mcp divi-check 2>&1) || fail "wp lit-mcp divi-check must succeed (exit 0): $CLI" + echo "$CLI" | grep -q '^Fähigkeiten:' || fail "divi-check must print the matrix: $CLI" + echo "$CLI" | grep -q '✓ Seiten schreiben – freigegeben (code)' || fail "divi-check must show page-write released: $CLI" +-[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "11" ] || fail "divi-check must show eleven released features: $CLI" ++[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "13" ] || fail "divi-check must show thirteen released features: $CLI" + [ "$(check_pages)" = "0" ] || fail "the CLI check must delete its page" +-pass "wp lit-mcp divi-check prints eleven released features, recomputes the state and leaves no page" ++pass "wp lit-mcp divi-check prints thirteen released features, recomputes the state and leaves no page" + + finish +``` + +Run: `bash -n tests/Integration/divi-release-smoke.sh && ! grep -n eleven tests/Integration/divi-release-smoke.sh` +Expected: keine Ausgabe, Exit 0 (Syntax gültig, kein „eleven“ mehr). Der Smoke selbst läuft in Task 2, Step 12. + +- [ ] **Step 6: Lint, kein Commit** + +Run: `composer lint` +Expected: sauber. + +Nicht committen: die drei Dateien gehen mit dem Commit von Task 2 (Step 13) ins Repo, weil `composer test` erst mit den Adaptern grün ist. + +--- + +### Task 2: Adapter `GlobalColorsAdapter`/`GlobalVariablesAdapter`, Manifest, Fingerabdrücke, `DesignRights` (V3, V4, V7) + +Zwei kleine Adapter im Bereich „Designsystem“ – die einzigen Klassen, die Farben und Variablen über Divi schreiben – mit eigenem Manifest und der Fähigkeit des Bereichs als Vorgabe. Geteilte Divi-Aufrufe stehen wie bei `DesignSystemReader`/`DesignSystemWriter` (`GlobalPreset::get_data`) in **jedem** Manifest, das sie nutzt: `get_global_colors`/`get_global_variables` bleiben im Reader mit `design-read`/`page-read` und stehen hier zusätzlich mit `color-write` bzw. `variable-write`; `et_pb_is_allowed` bleibt in `DiviRoles` mit `roles` und steht hier mit `variable-write`. `DiviAdapters::calls()` führt jedes Symbol nur einmal (für die Signatur egal), `featuresOf()` gilt je Manifest. Kein Aufruf braucht eine eigene Fähigkeitsliste – die Vorgabe des Manifests reicht, weil nur die Schreibwege diese Adapter nutzen (das Designsystem-Tool liest weiter über `DesignSystemReader`). + +Geprüft an `themes/Divi/includes/builder-5/server/Packages/GlobalData/GlobalData.php` (5.15.1): `get_global_colors(): array` (0 Parameter; Hauptfarben aus `get_customizer_colors()` mit `static $formatted_colors` vor den gespeicherten, `lastUpdated` = jetzt), `sanitize_global_colors_data(array $data): array` (1; verwirft Einträge mit leerem `color` oder ohne `gcid-`-Präfix), `set_global_colors(array $data, $already_sanitized = false): void` (2; schreibt Hauptfarben über `et_update_option` in `et_divi`, mischt bei `true` mit den gespeicherten, ersetzt bei `false`), `get_global_variables(): array` (0; Gruppen als `stdClass`, Einträge mit `allowedActions`, Gruppe `colors`, Customizer-Schriften `--et_global_heading_font`/`--et_global_body_font`), `set_global_variables(array $data, ?array $expected_variables = null): ?WP_Error` (2; ohne `edit_theme_options` oder `et_pb_is_allowed('variables_manager')` still `null`; vergleicht `$expected` **bereinigt** mit dem bereinigten Bestand → `global_variables_stale`, dann `global_variable_ids_missing`, dann `validate_global_variable_changes`), `et_pb_is_allowed($capabilities, $role = '')` in `includes/builder/core.php` (1). `sanitize_global_variables_data()` behält nur `gvid-`-IDs der Typen `numbers|strings|images|links|fonts|gradients` – darum filtert `read()` nach `gvid-` (das entfernt die Customizer-Schriften und jeden Fremdeintrag genau wie Divi) und lässt `colors` weg. + +**Unit-Tests ohne Divi:** `GlobalData` fehlt in den Unit-Tests. Die Tests von `read()`/`save()` ersetzen die Klasse mit `Mockery::mock('alias:ET\Builder\Packages\GlobalData\GlobalData')` und laufen mit `@runInSeparateProcess` (ein Alias definiert die Klasse für den Rest des Prozesses; Vorbild für die eigene Prozess-Regel: `DiviPagesTest::testIsLinkableFollowsDivisPublicPostTypes`, `DiviThemeBuilderTest::testMissingDiviFunctionsAreRefusedBeforeWriting`). Ebenso der positive Fall von `canManage()` (Brain Monkey definiert `et_pb_is_allowed` sonst prozessweit, `DiviRolesTest`/`DiviIntegrationTest` verlassen sich auf ihr Fehlen). Beide Adapter sind – wie `DesignSystemReader`/`DesignSystemWriter` – **nicht `final`**, damit `DesignRightsTest` und die Bereiche (Tasks 6/7) sie mit Mockery ersetzen können (siehe `## ABWEICHUNGEN`, A2). + +**Files:** +- Create: `src/Integrations/Divi/Design/GlobalColorsAdapter.php` +- Create: `src/Integrations/Divi/Design/GlobalVariablesAdapter.php` +- Create: `tests/Unit/Integrations/Divi/Design/GlobalColorsAdapterTest.php` +- Create: `tests/Unit/Integrations/Divi/Design/GlobalVariablesAdapterTest.php` +- Modify: `src/Integrations/Divi/Release/DiviAdapters.php` (beide Klassen nach `DesignSystemWriter::class`) +- Modify: `tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php` (gepinnte Tabelle, „ten adapters“) +- Modify: `src/Integrations/Divi/Release/fingerprints.json` (generiert, drei neue `sig:`-Schlüssel) +- Modify: `src/Integrations/Divi/Design/DesignRights.php` (`colors()`, `variables()`, private `designer()`) +- Modify: `tests/Unit/Integrations/Divi/Design/DesignRightsTest.php` + +**Interfaces:** +- Consumes: `Manifest`, `Call`, `DiviAdapter`, `DiviAdapters::CLASSES`, `ReleaseGate::assertFeature()`, `DiviLocked`, `Errors::forbidden()`, `DesignSystemWriter::canUseVisualBuilder()` (alle vorhanden), `DiviFeature::ColorWrite`/`VariableWrite` (Task 1); Tests: `LIT\MCP\Tests\Unit\TestCase` (`assertToolError`), `ReleaseMatrices::uniform(ReleaseState, string)`, Brain Monkey `Functions\when()`. +- Produces: + - `class GlobalColorsAdapter implements DiviAdapter` – `manifest()`: Bereich `Manifest::DESIGN`, Aufrufe `GlobalData::get_global_colors` (0), `GlobalData::sanitize_global_colors_data` (1), `GlobalData::set_global_colors` (2), Schlüssel `et_divi`, Vorgabe `[DiviFeature::ColorWrite]`; `read(): array` = `get_global_colors()`, Hauptfarben mit dem Wert aus `et_divi` überlagert, wenn dort ein nicht leerer String steht (V3; kein Eintrag wird erfunden); `save(array $entries, bool $replace): void` – `ReleaseGate::assertFeature(ColorWrite)`, dann `sanitize_global_colors_data($entries)`; fehlt danach ein Eintrag → `RuntimeException('global colors refused: Divi dropped ')`, nichts geschrieben; sonst `set_global_colors($clean, ! $replace)` (V4: `false` → mischen, `true` → alles ersetzen). + - `class GlobalVariablesAdapter implements DiviAdapter` – `manifest()`: Aufrufe `GlobalData::get_global_variables` (0), `GlobalData::set_global_variables` (2), `et_pb_is_allowed` (1), keine Schlüssel, Vorgabe `[DiviFeature::VariableWrite]`; `read(): array` Speicherform (V7); `save(array $data, array $expected): void` – `ReleaseGate::assertFeature(VariableWrite)`, `set_global_variables($data, $expected)`, `WP_Error` → `RuntimeException('global variables refused: ')`; `canManage(): bool` = `function_exists('et_pb_is_allowed') && et_pb_is_allowed('variables_manager')`. + - `DesignRights::colors(DesignSystemWriter $writer): ?WP_Error` – dieselben Rechte wie `presets()`, Meldungen mit „Farben“. + - `DesignRights::variables(DesignSystemWriter $writer, GlobalVariablesAdapter $variables): ?WP_Error` – erst wie `colors()` (Meldungen mit „Variablen“), dann `canManage()`, sonst `forbidden` „… erlaubt den Variablen-Manager nicht …“ mit Hinweis auf den Rollen-Editor. + - `presets()` verhält sich unverändert (gleiche Texte, jetzt über `designer($writer, 'Presets')`). + +- [ ] **Step 1: Failing Test `GlobalColorsAdapterTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/GlobalColorsAdapterTest.php` anlegen: + +```php +area); + self::assertSame( + ['GlobalData::get_global_colors()', 'GlobalData::sanitize_global_colors_data()', + 'GlobalData::set_global_colors()'], + array_map(static fn (Call $call): string => $call->label(), $manifest->calls) + ); + self::assertSame([0, 1, 2], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls)); + self::assertSame(['et_divi'], $manifest->keys); + self::assertSame([DiviFeature::ColorWrite], $manifest->features); + } + + public function testWritesNeedAReleasedDivi(): void + { + ReleaseGate::useResolver( + static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0') + ); + + $this->expectException(DiviLocked::class); + + (new GlobalColorsAdapter())->save(['gcid-abc' => ['color' => '#fff']], false); + } + + /** @runInSeparateProcess */ + public function testReadLaysTheStoredCustomizerValuesOverDivisCache(): void + { + Mockery::mock('alias:' . self::GLOBAL_DATA)->allows('get_global_colors')->andReturn([ + 'gcid-primary-color' => ['color' => '#2ea3f2', 'folder' => 'customizer', 'status' => 'active'], + 'gcid-secondary-color' => ['color' => '#2ea3f2', 'folder' => 'customizer', 'status' => 'active'], + 'gcid-body-color' => ['color' => '#666666', 'folder' => 'customizer', 'status' => 'active'], + 'gcid-abc1234567' => ['color' => '#123456', 'folder' => '', 'status' => 'active'], + ]); + Functions\when('get_option')->alias(static fn (string $name): mixed => $name === 'et_divi' + ? ['accent_color' => '#ff0000', 'secondary_accent_color' => '', 'font_color' => 7, 'link_color' => '#00f'] + : false); + + $colors = (new GlobalColorsAdapter())->read(); + + self::assertSame('#ff0000', $colors['gcid-primary-color']['color'], 'stored option wins over the cache'); + self::assertSame('#2ea3f2', $colors['gcid-secondary-color']['color'], 'an empty option is not laid over'); + self::assertSame('#666666', $colors['gcid-body-color']['color'], 'a non-string option is not laid over'); + self::assertArrayNotHasKey('gcid-link-color', $colors, 'no entry is invented'); + self::assertSame('#123456', $colors['gcid-abc1234567']['color']); + } + + /** @runInSeparateProcess */ + public function testReadWithoutThemeOptionsIsDivisAnswer(): void + { + $divi = ['gcid-primary-color' => ['color' => '#2ea3f2', 'folder' => 'customizer']]; + Mockery::mock('alias:' . self::GLOBAL_DATA)->allows('get_global_colors')->andReturn($divi); + Functions\when('get_option')->justReturn(false); + + self::assertSame($divi, (new GlobalColorsAdapter())->read()); + } + + /** @runInSeparateProcess */ + public function testSaveSanitizesAndMergesOrReplaces(): void + { + $entries = ['gcid-abc1234567' => ['color' => '#123456', 'status' => 'active', 'usedInPosts' => []]]; + $clean = ['gcid-abc1234567' => ['color' => '#123456', 'status' => 'active', 'usedInPosts' => []]]; + $divi = Mockery::mock('alias:' . self::GLOBAL_DATA); + $divi->expects('sanitize_global_colors_data')->twice()->with($entries)->andReturn($clean); + // Normal write: merge ("already sanitized" = true); restore: replace all (false). + $divi->expects('set_global_colors')->once()->with($clean, true)->ordered(); + $divi->expects('set_global_colors')->once()->with($clean, false)->ordered(); + + (new GlobalColorsAdapter())->save($entries, false); + (new GlobalColorsAdapter())->save($entries, true); + } + + /** @runInSeparateProcess */ + public function testSaveRefusesWhenDiviDropsAnEntry(): void + { + $divi = Mockery::mock('alias:' . self::GLOBAL_DATA); + $divi->allows('sanitize_global_colors_data')->andReturn(['gcid-abc1234567' => ['color' => '#123456']]); + $divi->expects('set_global_colors')->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('global colors refused: Divi dropped gcid-empty00000'); + + (new GlobalColorsAdapter())->save([ + 'gcid-abc1234567' => ['color' => '#123456'], + 'gcid-empty00000' => ['color' => ''], + ], false); + } +} +``` + +- [ ] **Step 2: Failing Test `GlobalVariablesAdapterTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/GlobalVariablesAdapterTest.php` anlegen: + +```php +area); + self::assertSame( + ['GlobalData::get_global_variables()', 'GlobalData::set_global_variables()', 'et_pb_is_allowed()'], + array_map(static fn (Call $call): string => $call->label(), $manifest->calls) + ); + self::assertSame([0, 2, 1], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls)); + self::assertSame([], $manifest->keys); + self::assertSame([DiviFeature::VariableWrite], $manifest->features); + } + + public function testWritesNeedAReleasedDivi(): void + { + ReleaseGate::useResolver( + static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0') + ); + + $this->expectException(DiviLocked::class); + + (new GlobalVariablesAdapter())->save([], []); + } + + public function testWithoutDiviNobodyManagesVariables(): void + { + // Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed". + self::assertFalse((new GlobalVariablesAdapter())->canManage()); + } + + /** @runInSeparateProcess */ + public function testCanManageAsksTheVariablesManagerRight(): void + { + Functions\when('et_pb_is_allowed')->alias(static fn (string $cap): bool => $cap === 'variables_manager'); + + self::assertTrue((new GlobalVariablesAdapter())->canManage()); + } + + /** @runInSeparateProcess */ + public function testReadReturnsTheStoredForm(): void + { + $actions = ['allowedActions' => ['editLabel' => true, 'editValue' => true, 'remove' => true]]; + $space = ['label' => 'Abstand', 'value' => '24px', 'order' => '1', 'status' => 'active']; + $wave = ['label' => 'Welle', 'value' => 'linear-gradient(#fff,#000)', 'order' => '1', 'status' => 'active']; + Mockery::mock('alias:' . self::GLOBAL_DATA)->allows('get_global_variables')->andReturn([ + 'numbers' => (object) ['gvid-space00001' => $space + $actions], + 'strings' => (object) [], + 'colors' => (object) ['gcid-x' => ['label' => 'X'] + $actions], + 'fonts' => (object) [ + '--et_global_heading_font' => ['label' => 'Überschrift', 'value' => 'Open Sans'] + $actions, + 'gvid-font000001' => (object) (['label' => 'Fließtext', 'value' => 'Inter'] + $actions), + ], + 'gradients' => (object) ['gvid-wave000001' => $wave + $actions], + ]); + + self::assertSame( + [ + 'numbers' => ['gvid-space00001' => $space], + 'fonts' => ['gvid-font000001' => ['label' => 'Fließtext', 'value' => 'Inter']], + 'gradients' => ['gvid-wave000001' => $wave], + ], + (new GlobalVariablesAdapter())->read() + ); + } + + /** @runInSeparateProcess */ + public function testSavePassesThePlannedInventoryAndThrowsOnARefusal(): void + { + $data = ['numbers' => ['gvid-space00001' => ['label' => 'Abstand', 'value' => '32px']]]; + $expected = ['numbers' => ['gvid-space00001' => ['label' => 'Abstand', 'value' => '24px']]]; + Mockery::mock('alias:' . self::GLOBAL_DATA)->expects('set_global_variables')->twice()->with($data, $expected) + ->andReturn(null, new WP_Error('global_variables_stale', 'Global variables changed.')); + + (new GlobalVariablesAdapter())->save($data, $expected); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('global variables refused: global_variables_stale Global variables changed.'); + (new GlobalVariablesAdapter())->save($data, $expected); + } +} +``` + +- [ ] **Step 3: Gepinnte Tabelle in `DiviFeatureAssignmentTest` erweitern** + +```diff +--- a/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php ++++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php +@@ -6,6 +6,8 @@ + + use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; + use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; ++use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter; ++use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; + use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; + use LIT\MCP\Integrations\Divi\Library\DiviRoles; + use LIT\MCP\Integrations\Divi\Pages\DiviPages; +@@ -70,6 +72,16 @@ + 'UserRole::can_current_user_use_visual_builder()' => ['preset-write'], + 'Nachbau GlobalPresetController::sync()' => ['preset-write'], + ], ++ GlobalColorsAdapter::class => [ ++ 'GlobalData::get_global_colors()' => ['color-write'], ++ 'GlobalData::sanitize_global_colors_data()' => ['color-write'], ++ 'GlobalData::set_global_colors()' => ['color-write'], ++ ], ++ GlobalVariablesAdapter::class => [ ++ 'GlobalData::get_global_variables()' => ['variable-write'], ++ 'GlobalData::set_global_variables()' => ['variable-write'], ++ 'et_pb_is_allowed()' => ['variable-write'], ++ ], + DiviModules::class => [ + 'ModuleRegistration::get_all_core_modules_metadata()' => ['catalog'], + 'ModuleRegistration::get_default_attrs()' => ['catalog'], +@@ -131,7 +143,7 @@ + } + + self::assertSame(self::EXPECTED, $actual); +- self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all eight adapters, in report order'); ++ self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all ten adapters, in report order'); + } + + public function testReadingAdaptersCarryNoWriteFeature(): void +``` + +- [ ] **Step 4: RED** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'GlobalColorsAdapterTest|GlobalVariablesAdapterTest|DiviFeatureAssignmentTest'` +Expected: FAIL – `Tests: 17, Assertions: 24, Errors: 9, Failures: 5`: neun Mal `Error: Class "LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter" not found` bzw. `…GlobalVariablesAdapter" not found`, drei Mal `Failed asserting that exception of type "Error" matches expected exception …` (gleiche Ursache), dazu `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` (aus Task 1) und `::testTheAssignmentIsThePinnedTable`. + +- [ ] **Step 5: `GlobalColorsAdapter` implementieren** + +Datei `src/Integrations/Divi/Design/GlobalColorsAdapter.php` anlegen: + +```php + its option in et_divi (GlobalData::$customizer_colors). */ + private const CUSTOMIZER = [ + 'gcid-primary-color' => 'accent_color', + 'gcid-secondary-color' => 'secondary_accent_color', + 'gcid-heading-color' => 'header_color', + 'gcid-body-color' => 'font_color', + 'gcid-link-color' => 'link_color', + ]; + + public static function manifest(): Manifest + { + return new Manifest( + Manifest::DESIGN, + calls: [ + new Call(GlobalData::class . '::get_global_colors', 0), + new Call(GlobalData::class . '::sanitize_global_colors_data', 1), + new Call(GlobalData::class . '::set_global_colors', 2), + ], + keys: [self::OPTIONS], + features: [DiviFeature::ColorWrite] + ); + } + + /** @return array> get_global_colors() with the customizer values of et_divi. */ + public function read(): array + { + $colors = GlobalData::get_global_colors(); + $options = get_option(self::OPTIONS); + foreach (self::CUSTOMIZER as $id => $option) { + $value = is_array($options) ? ($options[$option] ?? null) : null; + if (is_array($colors[$id] ?? null) && is_string($value) && $value !== '') { + $colors[$id]['color'] = $value; + } + } + + return $colors; + } + + /** + * Saves checked entries: $replace false merges them into the stored colors (set_global_colors with + * "already sanitized"), $replace true replaces all stored colors (restore). Customizer colors among them go to + * the theme options (Divi does that itself). + * + * @param array> $entries + * @throws DiviLocked When color-write is not released – before anything is written. + * @throws RuntimeException When Divi's sanitizing drops an entry; nothing is written then. + */ + public function save(array $entries, bool $replace): void + { + ReleaseGate::assertFeature(DiviFeature::ColorWrite); + $clean = GlobalData::sanitize_global_colors_data($entries); + $dropped = array_diff(array_keys($entries), array_keys($clean)); + if ($dropped !== []) { + throw new RuntimeException('global colors refused: Divi dropped ' . implode(', ', $dropped)); + } + + GlobalData::set_global_colors($clean, ! $replace); + } +} +``` + +- [ ] **Step 6: `GlobalVariablesAdapter` implementieren** + +Datei `src/Integrations/Divi/Design/GlobalVariablesAdapter.php` anlegen: + +```php + ID => entry as arrays, without allowedActions, without the customizer fonts and the + * group "colors" (both are not stored there), empty groups left out. + * + * @return array>> + */ + public function read(): array + { + $data = []; + foreach (GlobalData::get_global_variables() as $type => $items) { + if ($type === 'colors' || (! is_array($items) && ! is_object($items))) { + continue; + } + foreach ((array) $items as $id => $item) { + // Divi's sanitizing keeps gvid- IDs only; the customizer fonts (--et_global_*_font) are not stored. + if (! str_starts_with((string) $id, 'gvid-') || (! is_array($item) && ! is_object($item))) { + continue; + } + $entry = (array) $item; + unset($entry['allowedActions']); + $data[(string) $type][(string) $id] = $entry; + } + } + + return $data; + } + + /** + * @param array>> $data The whole new inventory (stored form). + * @param array>> $expected The inventory the change was planned on. + * @throws DiviLocked When variable-write is not released – before anything is written. + * @throws RuntimeException When Divi refuses the inventory (stale, missing IDs, invalid entries). + */ + public function save(array $data, array $expected): void + { + ReleaseGate::assertFeature(DiviFeature::VariableWrite); + $refusal = GlobalData::set_global_variables($data, $expected); + if ($refusal instanceof WP_Error) { + throw new RuntimeException('global variables refused: ' . $refusal->get_error_code() . ' ' + . $refusal->get_error_message()); + } + } + + /** Divi's role right "Variables Manager"; fail closed without Divi. */ + public function canManage(): bool + { + return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed('variables_manager'); + } +} +``` + +- [ ] **Step 7: Adapter in `DiviAdapters::CLASSES` aufnehmen** + +```diff +--- a/src/Integrations/Divi/Release/DiviAdapters.php ++++ b/src/Integrations/Divi/Release/DiviAdapters.php +@@ -6,6 +6,8 @@ + + use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; + use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; ++use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter; ++use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; + use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; + use LIT\MCP\Integrations\Divi\Library\DiviRoles; + use LIT\MCP\Integrations\Divi\Pages\DiviPages; +@@ -27,6 +29,8 @@ + DiviThemeBuilder::class, + DesignSystemReader::class, + DesignSystemWriter::class, ++ GlobalColorsAdapter::class, ++ GlobalVariablesAdapter::class, + DiviModules::class, + DynamicContentMap::class, + ]; +``` + +- [ ] **Step 8: GREEN (ohne Fingerabdrücke)** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'GlobalColorsAdapterTest|GlobalVariablesAdapterTest|DiviFeatureAssignmentTest|DiviBoundaryTest|DiviFeatureTest'` +Expected: `OK` (u. a. `DiviBoundaryTest::testAdaptersDeclareExactlyTheDiviNamesTheyUse`: die Adapter nutzen genau `ET\Builder\Packages\GlobalData\GlobalData`, `et_divi` bzw. `et_pb_is_allowed` – `'variables_manager'`, `'gvid-'` und der Kommentar `--et_global_*_font` sind für `DiviTouches` keine Divi-Namen). + +Run: `composer test` +Expected: genau ein Fehlschlag, `DiviReferenceTest::testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource` – in `fingerprints.json` fehlen `sig:ET\Builder\Packages\GlobalData\GlobalData::sanitize_global_colors_data`, `…::set_global_colors` und `…::set_global_variables` (Step 11). + +- [ ] **Step 9: Failing Tests für `DesignRights::colors()`/`variables()` schreiben** + +`tests/Unit/Integrations/Divi/Design/DesignRightsTest.php` vollständig ersetzen (der bisherige Test bleibt unverändert als erster): + +```php +allows('canUseVisualBuilder')->andReturn(true, false); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options'); + + self::assertNull(DesignRights::presets($writer)); + $refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden'); + self::assertStringContainsString('Visual Builder', $refusal['message']); + + Functions\when('current_user_can')->justReturn(false); + $refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden'); + self::assertStringContainsString('edit_theme_options', $refusal['message']); + } + + public function testColorsNeedEditThemeOptionsAndTheVisualBuilder(): void + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn(true, false); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options'); + + self::assertNull(DesignRights::colors($writer)); + $refusal = self::assertToolError(DesignRights::colors($writer), 'forbidden'); + self::assertStringContainsString('keine Farben', $refusal['message']); + + Functions\when('current_user_can')->justReturn(false); + $refusal = self::assertToolError(DesignRights::colors($writer), 'forbidden'); + self::assertStringContainsString('Farben ändern erfordert', $refusal['message']); + self::assertStringContainsString('edit_theme_options', $refusal['message']); + } + + public function testVariablesAlsoNeedTheVariablesManager(): void + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $variables = Mockery::mock(GlobalVariablesAdapter::class); + $variables->allows('canManage')->andReturn(true, false); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options'); + + self::assertNull(DesignRights::variables($writer, $variables)); + $refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden'); + self::assertStringContainsString('Variablen-Manager', $refusal['message']); + self::assertStringContainsString('Rollen-Editor', $refusal['hint']); + } + + public function testVariablesAskTheThemeRightAndTheVisualBuilderFirst(): void + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn(false); + $variables = Mockery::mock(GlobalVariablesAdapter::class); + $variables->allows('canManage')->andReturn(true); + Functions\when('current_user_can')->justReturn(true); + + $refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden'); + self::assertStringContainsString('keine Variablen', $refusal['message']); + + Functions\when('current_user_can')->justReturn(false); + $refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden'); + self::assertStringContainsString('Variablen ändern erfordert', $refusal['message']); + } +} +``` + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DesignRightsTest` +Expected: FAIL – `Tests: 4, Assertions: 9, Errors: 3`: `Error: Call to undefined method LIT\MCP\Integrations\Divi\Design\DesignRights::colors()` (1×) und `…::variables()` (2×). + +- [ ] **Step 10: `DesignRights` erweitern** + +`src/Integrations/Divi/Design/DesignRights.php` vollständig ersetzen (`unfilteredHtml()` und `markup()` unverändert; `presets()` delegiert an `designer()` mit denselben Texten wie bisher): + +```php + current_user_can('unfiltered_html'); + } + + /** + * Refuses the write when the user lacks unfiltered_html and $markupIds (PresetBook::markupOutside()) is not empty. + * + * @param Closure(): bool $unfiltered + * @param list $markupIds + */ + public static function markup( + Closure $unfiltered, + array $markupIds, + string $message = self::OTHER_MARKUP + ): ?WP_Error { + if ($markupIds === [] || $unfiltered()) { + return null; + } + + return Errors::forbidden( + $message, + 'Einen Administrator (mit dem Recht unfiltered_html) die Änderung vornehmen lassen.' + ); + } + + public static function presets(DesignSystemWriter $writer): ?WP_Error + { + return self::designer($writer, 'Presets'); + } + + /** Colors: the same rights as presets (spec §10 – stricter than Divi, which asks edit_theme_options only). */ + public static function colors(DesignSystemWriter $writer): ?WP_Error + { + return self::designer($writer, 'Farben'); + } + + /** Variables: also Divi's role right "Variables Manager" – without it Divi silently saves nothing (plan 4c V7). */ + public static function variables(DesignSystemWriter $writer, GlobalVariablesAdapter $variables): ?WP_Error + { + $refusal = self::designer($writer, 'Variablen'); + if ($refusal !== null || $variables->canManage()) { + return $refusal; + } + + return Errors::forbidden( + 'Die Divi-Rolle dieses Kontos erlaubt den Variablen-Manager nicht; ohne ihn speichert Divi keine ' + . 'Variablen.', + 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.' + ); + } + + /** edit_theme_options and Divi's role right "Visual Builder verwenden"; $what names the area in the message. */ + private static function designer(DesignSystemWriter $writer, string $what): ?WP_Error + { + if (! current_user_can('edit_theme_options')) { + return Errors::forbidden(sprintf( + '%s ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).', + $what + )); + } + if (! $writer->canUseVisualBuilder()) { + return Errors::forbidden( + sprintf( + 'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine %s.', + $what + ), + 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.' + ); + } + + return null; + } +} +``` + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DesignRightsTest|DiviPresets|DiviDesign'` +Expected: `OK` (die Preset-Tools sehen dieselben Meldungen wie vorher). + +- [ ] **Step 11: Signatur-Referenzen in DDEV erzeugen** + +`wp lit-mcp divi-check --update-reference` (`src/Integrations/Divi/Cli/DiviCheckCommand.php`) verlangt einen Administrator (`manage_options`, der Schreibtest legt eine Wegwerfseite an), schreibt nur, wenn außer „Fingerabdrücke“ alle Prüfschritte grün sind (Manifeste: die neuen Aufrufe existieren mit passender Argumentzahl; die fehlende Signatur-Referenz ist kein Manifest-Fehler, nur ein `reference-missing`-Problem), und nur in einer Entwicklungskopie mit `tests/` (`DiviReference::sourceDir()`). Es schreibt die Referenzquellen der Nachbauten (unverändert, keine neuen Mirrors) und `fingerprints.json` (Nachbauten + `sig:` jedes Aufrufs aus `DiviAdapters::calls()`). + +Run: +```bash +cd /home/janniclas/Projekte/WordPress +ddev start +ddev exec bash -c 'wp --user="$(wp user list --role=administrator --field=user_login | head -1)" lit-mcp divi-check --update-reference' +``` +Expected: die Prüfschritte mit `✓` (`Fingerabdrücke nachgebauter Divi-Funktionen` darf `✓` oder `✗` sein), dann `Referenz aktualisiert (6 Fingerabdrücke, 32 Signaturen). Smoke-Skripte laufen lassen, dann committen.` und die Fähigkeiten-Matrix; Exit-Code 0. Bricht WP-CLI mit Monolog-/Deprecation-Ausgaben ab, wie für die Smokes `postlos` und `asynclistry` vorher deaktivieren (`ddev wp plugin deactivate postlos asynclistry`) und danach wieder aktivieren (`ddev wp plugin activate postlos asynclistry`). + +Ergebnis prüfen: +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git diff -U0 src/Integrations/Divi/Release/fingerprints.json | grep '^[-+] ' +git status --short tests/Fixtures/divi/reference +jq -r 'keys[]' src/Integrations/Divi/Release/fingerprints.json | grep -c '^sig:' +``` +Expected: genau drei `+`-Zeilen und keine `-`-Zeile – `"sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::sanitize_global_colors_data": ""`, `"…GlobalData::set_global_colors": ""`, `"…GlobalData::set_global_variables": ""`; keine Änderung unter `tests/Fixtures/divi/reference`; `32`. Ändert sich ein anderer Schlüssel, **nicht committen**, sondern prüfen (installiertes Divi ≠ 5.15.1 oder eine Divi-Signatur hat sich geändert). + +- [ ] **Step 12: Gesamtlauf, Lint, Freigabe-Smoke** + +Run: `composer test && composer lint` +Expected: OK (alle Tests grün, auch `DiviReferenceTest` und `DiviFeatureAssignmentTest`), Lint sauber. + +Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh` +Expected: `ALL CHECKS PASSED` – u. a. `ok - manifests and fingerprints match the installed Divi` (keine `reference-missing`-Probleme für die drei neuen Aufrufe) und `ok - lit-site-status: thirteen features released (code), each with its tools`. Läuft nur mit deaktivierten Plugins `postlos` und `asynclistry`. + +- [ ] **Step 13: Commit (Tasks 1 und 2)** + +```bash +cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP +git add src/Integrations/Divi/Release/DiviFeature.php tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php \ + tests/Integration/divi-release-smoke.sh \ + src/Integrations/Divi/Design/GlobalColorsAdapter.php src/Integrations/Divi/Design/GlobalVariablesAdapter.php \ + tests/Unit/Integrations/Divi/Design/GlobalColorsAdapterTest.php \ + tests/Unit/Integrations/Divi/Design/GlobalVariablesAdapterTest.php \ + src/Integrations/Divi/Release/DiviAdapters.php tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php \ + src/Integrations/Divi/Release/fingerprints.json \ + src/Integrations/Divi/Design/DesignRights.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php +git commit -m "feat: Fähigkeiten color-write/variable-write; GlobalColorsAdapter und GlobalVariablesAdapter (Manifest, Signatur-Referenzen) und DesignRights::colors()/variables()" -m "Co-Authored-By: Claude " +``` + +--- + + +### Task 3: `ColorValue`, `ColorBook`, `ColorDiff` – reiner Farbbestand (V3, V5) + +**Files:** +- Create: `src/Integrations/Divi/Design/ColorValue.php`, `ColorBook.php`, `ColorDiff.php` +- Test: `tests/Unit/Integrations/Divi/Design/ColorValueTest.php`, `ColorBookTest.php`, `ColorDiffTest.php` (alle neu, erben von `PHPUnit\Framework\TestCase`) + +**Interfaces:** +- Consumes: `LIT\MCP\Abilities\Rev::of(string): string` (sha256, 12 Hex-Zeichen – dasselbe Verfahren wie `PresetBook::rev()`); `LIT\MCP\Integrations\Divi\Compose\VariableRef::serialize()`, `::typeOf()`, `::MARKER`, `::END` (nur öffentliche, reine Mitglieder; `serialize()` nur in den Tests); `DesignSystem::COLOR_ALIASES` (öffentliche Konstante: `primary` → `gcid-primary-color` …). +- Produces (Signaturen wie im Schnittstellenvertrag, keine Abweichung): + - `ColorValue::check(string): ?string`, `ColorValue::relative(array): array|string`, `ColorValue::TYPES_NOTE`. + - `ColorBook`: `fromDivi`, `data`, `rev`, `entry`, `label`, `isCustomizer`, `find`, `with`, `referencing`, `cycle`, `changedSince`, `newId`, `now`, `CUSTOMIZER_IDS`. Die Testdatei `ColorBookTest` liefert die Hilfen `ColorBookTest::stored()` (Form von `get_global_colors()`: zwei Hauptfarben, „Holz dunkel“, eine relative Farbe „Holz hell“, zwei Farben mit Label „Doppelt“) und `ColorBookTest::ref($id, $settings)`; spätere Tests (`ColorAreaTest`, `ColorRestorePlannerTest`) dürfen sie benutzen. + - `ColorDiff::between(ColorBook, ColorBook): list`, `ColorDiff::summary(array): string`. + +**Regeln dieses Tasks (alle durch Tests belegt):** +- `ColorValue::check()` prüft nur Klartext-Werte (V5): Hex mit 3, 4, 6 oder 8 Stellen, `rgb()`/`rgba()`/`hsl()`/`hsla()` (nur Ziffern, Buchstaben, `. % + - / ,` und Leerraum, mindestens eine Ziffer, höchstens 100 Zeichen – nichts, womit man aus dem Wert ausbrechen könnte), ein Schlüsselwort aus 3–20 Buchstaben. Leerraum am Rand, `$variable(`, `{{` und alles andere gelten als ungültig; der Aufrufer löst Platzhalter vorher auf und trimmt Eingaben. `relative()` erlaubt nur `hue` (−360…360), `saturation`, `lightness`, `opacity` (je −100…100), nur int/float (kein Text, kein Bool, kein NaN), liefert sie in fester Reihenfolge und macht ganze Gleitkommazahlen zu `int`. +- `ColorBook` kennt die sechs Schlüssel `color, folder, label, lastUpdated, status, usedInPosts`; fehlende werden aufgefüllt (`''`, `''`, `''`, `''`, `'active'`, `[]`), fremde fallen weg. Eingabe als Array **oder** Objekt (auch verschachtelt `stdClass`) – `get_global_colors()` und ein Snapshot haben dieselbe Form. `lastUpdated` der Hauptfarben (ID in `CUSTOMIZER_IDS` oder `folder: customizer`) ist immer `''` (V3), auch nach `with()`. +- `find()`: ID, dann Alias (`primary` …), dann Optionsname (`accent_color` …), dann Label (getrimmt, ohne Beachtung der Groß-/Kleinschreibung); mehrere Treffer → `InvalidArgumentException` „Name der globalen Farbe "X" ist mehrdeutig; bitte die ID angeben: a, b.“ (Text wie `DesignSystem::byLabel`). +- `cycle()` folgt je Farbe der Kette ihrer Verweise (`VariableRef::typeOf()` = `color`, Ziel aus dem Payload) und meldet die erste Kette mit Schleife (`[a, b, c, b]`, Selbstverweis `[a, a]`) oder mit mehr als 10 Verweisen (12 IDs bei 11 Verweisen; Divi löst mit `$depth >= 10` nur bis zum 10. Verweis auf). Ein Verweis auf eine unbekannte Farbe beendet die Kette ohne Fund – das prüft `ColorArea` (Task 6). +- `ColorDiff`: Verglichen werden `color`, `folder`, `label`, `status` (nicht `lastUpdated`, `usedInPosts`). Statuswechsel nach `inactive` = `deactivated`, nach `active` = `activated`; jeder andere Statuswechsel (`temporary`) = `changed` mit `status` in `fields`. `fields` bei `deactivated`/`activated` nennt nur weitere Änderungen. Fehlt eine Farbe nachher, ist das `removed` (nur ein Restore mit Ersetzen kann das). + +#### Task 3a: `ColorValue` – Farbwerte und relative Einstellungen prüfen + +- [ ] **Step 3a.1: Failing Test `ColorValueTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/ColorValueTest.php`. Datenanbieter für gültige und ungültige Werte je Form, Grenzen der relativen Einstellungen inklusive. + +```php + */ + public static function validColors(): array + { + return [ + 'hex3' => ['#abc'], 'hex4' => ['#abcd'], 'hex6' => ['#5B3A29'], 'hex8' => ['#5b3a29cc'], + 'rgb' => ['rgb(10, 20, 30)'], 'rgba' => ['rgba(10,20,30,0.5)'], 'rgb modern' => ['rgb(10 20 30 / 50%)'], + 'hsl' => ['hsl(210, 50%, 40%)'], 'hsla' => ['hsla(210deg, 50%, 40%, .5)'], + 'keyword' => ['red'], 'keyword upper' => ['Transparent'], 'keyword long' => ['lightgoldenrodyellow'], + ]; + } + + /** @dataProvider validColors */ + public function testValidColors(string $color): void + { + self::assertNull(ColorValue::check($color)); + } + + /** @return array */ + public static function invalidColors(): array + { + return [ + 'empty' => [''], 'hex5' => ['#abcde'], 'hex7' => ['#abcdef1'], 'hex without hash' => ['abcdef12'], + 'hex digits' => ['#ggg'], 'surrounding space' => [' #fff'], 'function without number' => ['rgb()'], + 'function with var' => ['rgb(var(--x))'], 'function with semicolon' => ['rgb(1;2;3)'], + 'unknown function' => ['url(1)'], 'keyword too short' => ['re'], 'keyword with digit' => ['red1'], + 'keyword too long' => ['abcdefghijklmnopqrstu'], 'css injection' => ['red;}body{x:y'], + 'too long' => ['rgb(' . str_repeat('1,', 60) . '1)'], + 'reference' => ['$variable({"type":"color","value":{"name":"gcid-a","settings":{}}})$'], + 'placeholder' => ['{{color:primary}}'], + ]; + } + + /** @dataProvider invalidColors */ + public function testInvalidColorsGetAGermanMessage(string $color): void + { + $message = ColorValue::check($color); + + self::assertIsString($message); + self::assertNotSame('', $message); + } + + public function testMessageNamesTheAllowedForms(): void + { + self::assertStringContainsString(ColorValue::TYPES_NOTE, (string) ColorValue::check('#abcde')); + self::assertStringContainsString('Farbschlüsselwort', ColorValue::TYPES_NOTE); + self::assertStringContainsString('Platzhalter', (string) ColorValue::check('{{color:primary}}')); + } + + public function testRelativeKeepsTheOrderAndNormalizesWholeFloats(): void + { + self::assertSame( + ['hue' => 10, 'lightness' => -12.5, 'opacity' => 100], + ColorValue::relative(['opacity' => 100.0, 'lightness' => -12.5, 'hue' => 10]) + ); + self::assertSame(['saturation' => 0], ColorValue::relative(['saturation' => 0])); + } + + public function testRelativeBoundsAreInclusive(): void + { + self::assertSame(['hue' => -360], ColorValue::relative(['hue' => -360])); + self::assertSame(['hue' => 360], ColorValue::relative(['hue' => 360])); + self::assertSame(['opacity' => 100], ColorValue::relative(['opacity' => 100])); + self::assertSame(['opacity' => -100], ColorValue::relative(['opacity' => -100])); + } + + public function testRelativeRejectsRangesTypesAndUnknownKeys(): void + { + self::assertSame('relative.hue: Zahl zwischen -360 und 360 erwartet.', ColorValue::relative(['hue' => 361])); + self::assertSame( + 'relative.lightness: Zahl zwischen -100 und 100 erwartet.', + ColorValue::relative(['lightness' => -101]) + ); + self::assertSame( + 'relative.saturation: Zahl zwischen -100 und 100 erwartet.', + ColorValue::relative(['saturation' => '10']) + ); + self::assertSame( + 'relative.opacity: Zahl zwischen -100 und 100 erwartet.', + ColorValue::relative(['opacity' => true]) + ); + self::assertSame( + 'relative.hue: Zahl zwischen -360 und 360 erwartet.', + ColorValue::relative(['hue' => NAN]) + ); + self::assertSame( + 'relative: unbekannte Einstellung "alpha"; erlaubt: hue, saturation, lightness, opacity.', + ColorValue::relative(['alpha' => 1]) + ); + self::assertSame( + 'relative: mindestens eine Einstellung nötig (hue, saturation, lightness, opacity).', + ColorValue::relative([]) + ); + } +} +``` + +- [ ] **Step 3a.2: RED-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorValueTest` +Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ColorValue" not found`. + +- [ ] **Step 3a.3: `ColorValue` implementieren** + +Datei `src/Integrations/Divi/Design/ColorValue.php`. Reines PHP; nur Regexe und Vergleiche. + +```php +}} zusammen mit "relative".'; + + /** + * shortcut: these ranges are the sliders of the Visual Builder, not derived from Divi's PHP code; widen them if a + * Divi release accepts more. + */ + private const RANGES = [ + 'hue' => [-360, 360], + 'saturation' => [-100, 100], + 'lightness' => [-100, 100], + 'opacity' => [-100, 100], + ]; + + private const MAX_LENGTH = 100; + + private const HEX = '/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iD'; + + /** Only characters a color function needs; no "(", ")", ";" or quotes inside – nothing to break out of. */ + private const FUNCTION = '/^(?:rgb|rgba|hsl|hsla)\(\s*[0-9a-z.%+\-\/,\s]*\)$/iD'; + + private const KEYWORD = '/^[a-z]{3,20}$/iD'; + + /** @return string|null Null when $color is a valid plain color value, else a German message. */ + public static function check(string $color): ?string + { + if ($color === '') { + return 'Farbwert fehlt. ' . self::TYPES_NOTE; + } + if (str_contains($color, '$variable(') || str_contains($color, '{{')) { + return 'Verweise ($variable(…)$) und Platzhalter sind hier kein Farbwert; Platzhalter {{color:}} ' + . 'gehören allein in "color" und brauchen "relative".'; + } + + $valid = strlen($color) <= self::MAX_LENGTH && ( + preg_match(self::HEX, $color) === 1 + || preg_match(self::KEYWORD, $color) === 1 + || (preg_match(self::FUNCTION, $color) === 1 && preg_match('/\d/', $color) === 1) + ); + + return $valid ? null : sprintf('Ungültiger Farbwert "%s". %s', mb_substr($color, 0, 60), self::TYPES_NOTE); + } + + /** + * The settings for VariableRef::serialize('color', …): only hue, saturation, lightness, opacity, numbers within + * their ranges, in that order; whole floats become ints. + * + * @param array $relative + * @return array|string The settings, or a German message. + */ + public static function relative(array $relative): array|string + { + if ($relative === []) { + return 'relative: mindestens eine Einstellung nötig (' . implode(', ', array_keys(self::RANGES)) . ').'; + } + + $unknown = array_diff(array_map('strval', array_keys($relative)), array_keys(self::RANGES)); + if ($unknown !== []) { + return sprintf( + 'relative: unbekannte Einstellung "%s"; erlaubt: %s.', + implode('", "', $unknown), + implode(', ', array_keys(self::RANGES)) + ); + } + + $settings = []; + foreach (self::RANGES as $key => [$min, $max]) { + if (! array_key_exists($key, $relative)) { + continue; + } + $value = $relative[$key]; + $number = is_int($value) || is_float($value); + if (! $number || ! is_finite((float) $value) || $value < $min || $value > $max) { + return sprintf('relative.%s: Zahl zwischen %d und %d erwartet.', $key, $min, $max); + } + $settings[$key] = is_float($value) && floor($value) === $value ? (int) $value : $value; + } + + return $settings; + } +} +``` + +- [ ] **Step 3a.4: GREEN-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorValueTest` +Expected: `OK (33 tests, 62 assertions)`. + +- [ ] **Step 3a.5: Lint** + +Run: `composer lint` +Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen). + +- [ ] **Step 3a.6: Commit** + +```bash +git add src/Integrations/Divi/Design/ColorValue.php tests/Unit/Integrations/Divi/Design/ColorValueTest.php +git commit -m "feat: ColorValue prüft Farbwerte (Hex, rgb/hsl, Schlüsselwort) und die relativen Einstellungen von Farbverweisen" \ + -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +#### Task 3b: `ColorBook` – unveränderlicher Farbbestand + +- [ ] **Step 3b.1: Failing Test `ColorBookTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/ColorBookTest.php`. Deckt ab: Auffüllen fehlender Schlüssel, Objekte als Eingabe, Normalisierung von `lastUpdated` der Hauptfarben und stabile `rev()` (Reihenfolge egal), die echte Divi-Fixture `tests/Fixtures/divi/stage4/global-colors.json` (`stored` = Snapshot-Form, `get_global_colors` = Lese-Form), `isCustomizer`, `find` nach ID/Alias/Optionsname/Label inklusive Mehrdeutigkeit, `with`, `referencing`, `cycle` (Schleife, Selbstverweis, Tiefe 10 ok, Tiefe 11 Fehler, unbekanntes Ziel), `changedSince`, `newId`, `now`. + +```php +> The shape of GlobalData::get_global_colors() (formats §1). */ + public static function stored(string $customizerStamp = '2026-10-19T08:00:00.000Z'): array + { + $entry = static fn ( + string $color, + string $label, + string $folder = '', + string $stamp = '2026-09-27T22:21:39.000Z', + string $status = 'active' + ): array => [ + 'color' => $color, 'folder' => $folder, 'label' => $label, 'lastUpdated' => $stamp, + 'status' => $status, 'usedInPosts' => [], + ]; + + return [ + 'gcid-primary-color' => $entry('#2ea3f2', 'Primärfarbe', 'customizer', $customizerStamp), + 'gcid-secondary-color' => $entry('#2ea3f2', 'Sekundärfarbe', 'customizer', $customizerStamp), + 'gcid-q9swzy97df' => $entry('#5b3a29', 'Holz dunkel'), + 'gcid-989stx15zc' => $entry(self::ref('gcid-q9swzy97df', ['lightness' => 30]), 'Holz hell'), + 'gcid-aaaaaaaaaa' => $entry('#111111', 'Doppelt'), + 'gcid-bbbbbbbbbb' => $entry('#222222', 'doppelt', '', '2026-09-27T22:21:39.000Z', 'inactive'), + ]; + } + + /** @param array $settings */ + public static function ref(string $id, array $settings = []): string + { + return VariableRef::serialize('color', $id, $settings); + } + + public function testMissingKeysAreFilledAndOnlyTheSixKeysStay(): void + { + $book = ColorBook::fromDivi([ + 'gcid-x1' => ['color' => '#fff', 'extra' => 'dropped'], + 'gcid-x2' => (object) ['color' => '#000', 'label' => 'Schwarz', 'usedInPosts' => [4 => 12]], + 'gcid-x3' => 'not an entry', + ]); + + self::assertSame(['gcid-x1', 'gcid-x2'], array_keys($book->data())); + self::assertSame([ + 'color' => '#fff', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active', + 'usedInPosts' => [], + ], $book->entry('gcid-x1')); + self::assertSame([12], $book->entry('gcid-x2')['usedInPosts']); + self::assertSame('Schwarz', $book->label('gcid-x2')); + self::assertSame('gcid-x1', $book->label('gcid-x1'), 'a color without a label shows its ID'); + self::assertNull($book->entry('gcid-nope')); + } + + public function testObjectInputIsAcceptedLikeAnArray(): void + { + $raw = new stdClass(); + $raw->{'gcid-x1'} = (object) ['color' => '#fff', 'status' => 'inactive']; + + self::assertSame(ColorBook::fromDivi(['gcid-x1' => ['color' => '#fff', 'status' => 'inactive']])->data(), + ColorBook::fromDivi($raw)->data()); + } + + public function testCustomizerLastUpdatedIsNormalizedSoRevStaysStable(): void + { + $now = ColorBook::fromDivi(self::stored('2026-10-19T08:00:00.000Z')); + $later = ColorBook::fromDivi(self::stored('2026-10-19T09:30:00.000Z')); + + self::assertSame('', $now->entry('gcid-primary-color')['lastUpdated']); + self::assertSame('2026-09-27T22:21:39.000Z', $now->entry('gcid-q9swzy97df')['lastUpdated']); + self::assertSame($now->rev(), $later->rev()); + self::assertSame($now->data(), $later->data()); + self::assertSame([], $later->changedSince($now)); + } + + public function testRevIgnoresTheOrderOfEntriesAndChangesWithTheContent(): void + { + $stored = self::stored(); + $book = ColorBook::fromDivi($stored); + $reversed = ColorBook::fromDivi(array_reverse($stored, true)); + + self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $book->rev()); + self::assertSame($book->rev(), $reversed->rev()); + self::assertSame($book->rev(), ColorBook::fromDivi($stored)->rev()); + self::assertNotSame($book->rev(), $book->with('gcid-q9swzy97df', ['color' => '#000000'])->rev()); + } + + public function testRealDiviFixtureAndTheSnapshotOfItAreTheSame(): void + { + $json = (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/stage4/global-colors.json'); + $fixture = json_decode($json, true); + $read = ColorBook::fromDivi($fixture['get_global_colors']); + $snapshot = ColorBook::fromDivi($read->data()); + + self::assertCount(9, $read->data()); + self::assertSame($read->rev(), $snapshot->rev()); + self::assertTrue($read->isCustomizer('gcid-link-color')); + self::assertFalse($read->isCustomizer('gcid-q9swzy97df')); + self::assertSame(['gcid-989stx15zc'], $read->referencing('gcid-q9swzy97df')); + self::assertNull($read->cycle()); + } + + public function testIsCustomizerByIdOrFolder(): void + { + $book = ColorBook::fromDivi([ + 'gcid-body-color' => ['color' => '#666'], + 'gcid-legacy' => ['color' => '#666', 'folder' => 'customizer'], + 'gcid-x1' => ['color' => '#666'], + ]); + + self::assertTrue($book->isCustomizer('gcid-body-color')); + self::assertTrue($book->isCustomizer('gcid-legacy')); + self::assertFalse($book->isCustomizer('gcid-x1')); + self::assertTrue($book->isCustomizer('gcid-heading-color'), 'a customizer ID counts even if it is missing'); + self::assertSame('', $book->entry('gcid-legacy')['lastUpdated']); + } + + public function testFindByIdAliasOptionNameAndLabel(): void + { + $book = ColorBook::fromDivi(self::stored()); + + self::assertSame('gcid-q9swzy97df', $book->find('gcid-q9swzy97df')); + self::assertSame('gcid-primary-color', $book->find('primary')); + self::assertSame('gcid-primary-color', $book->find('PRIMARY')); + self::assertSame('gcid-secondary-color', $book->find('secondary_accent_color')); + self::assertSame('gcid-q9swzy97df', $book->find(' holz DUNKEL ')); + self::assertSame('gcid-primary-color', $book->find('Primärfarbe')); + self::assertNull($book->find('heading'), 'an alias whose color does not exist finds nothing'); + self::assertNull($book->find('Fehlt')); + self::assertNull($book->find('')); + } + + public function testAmbiguousLabelListsTheIds(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Name der globalen Farbe "Doppelt" ist mehrdeutig; bitte die ID angeben: gcid-aaaaaaaaaa, gcid-bbbbbbbbbb.' + ); + + ColorBook::fromDivi(self::stored())->find('Doppelt'); + } + + public function testWithAddsOrReplacesAndLeavesTheOriginalAlone(): void + { + $book = ColorBook::fromDivi(self::stored()); + $added = $book->with('gcid-new0000001', ['color' => '#abc', 'label' => 'Neu']); + $replaced = $book->with('gcid-q9swzy97df', ['color' => '#000000', 'label' => 'Holz', 'status' => 'inactive']); + + self::assertCount(6, $book->data()); + self::assertCount(7, $added->data()); + self::assertSame('active', $added->entry('gcid-new0000001')['status']); + self::assertSame('inactive', $replaced->entry('gcid-q9swzy97df')['status']); + self::assertSame('#5b3a29', $book->entry('gcid-q9swzy97df')['color']); + self::assertSame('', $book->with('gcid-primary-color', ['color' => '#fff', 'lastUpdated' => 'x']) + ->entry('gcid-primary-color')['lastUpdated']); + } + + public function testReferencingFindsRelativeColorsIncludingSelfReferences(): void + { + $book = ColorBook::fromDivi(self::stored()) + ->with('gcid-self000001', ['color' => self::ref('gcid-self000001')]) + ->with('gcid-text000001', ['color' => 'Hinweis: gcid-q9swzy97df']); + + self::assertSame(['gcid-989stx15zc'], $book->referencing('gcid-q9swzy97df')); + self::assertSame(['gcid-self000001'], $book->referencing('gcid-self000001')); + self::assertSame([], $book->referencing('gcid-989stx15zc')); + } + + public function testCycleFindsLoopsAndSelfReferences(): void + { + $loop = ColorBook::fromDivi([ + 'gcid-a' => ['color' => self::ref('gcid-b')], + 'gcid-b' => ['color' => self::ref('gcid-c', ['hue' => 10])], + 'gcid-c' => ['color' => self::ref('gcid-b')], + ]); + $self = ColorBook::fromDivi(['gcid-a' => ['color' => self::ref('gcid-a')]]); + + self::assertSame(['gcid-a', 'gcid-b', 'gcid-c', 'gcid-b'], $loop->cycle()); + self::assertSame(['gcid-a', 'gcid-a'], $self->cycle()); + self::assertNull(ColorBook::fromDivi(self::stored())->cycle()); + } + + public function testCycleStopsAtMoreThanTenReferences(): void + { + $chain = static function (int $references): ColorBook { + $raw = []; + for ($i = 0; $i < $references; ++$i) { + $raw['gcid-c' . $i] = ['color' => self::ref('gcid-c' . ($i + 1))]; + } + $raw['gcid-c' . $references] = ['color' => '#123456']; + + return ColorBook::fromDivi($raw); + }; + + self::assertNull($chain(10)->cycle(), 'ten references still resolve in Divi'); + $tooDeep = $chain(11)->cycle(); + self::assertCount(12, $tooDeep); + self::assertSame(['gcid-c0', 'gcid-c1'], array_slice($tooDeep, 0, 2)); + } + + public function testReferenceToAnUnknownColorIsNoCycle(): void + { + self::assertNull(ColorBook::fromDivi(['gcid-a' => ['color' => self::ref('gcid-missing')]])->cycle()); + } + + public function testChangedSinceListsNewAndChangedEntriesOnly(): void + { + $before = ColorBook::fromDivi(self::stored()); + $after = $before + ->with('gcid-q9swzy97df', ['color' => '#000000', 'label' => 'Holz dunkel', 'lastUpdated' => 'x']) + ->with('gcid-new0000001', ['color' => '#abc', 'label' => 'Neu']) + ->with('gcid-aaaaaaaaaa', $before->entry('gcid-aaaaaaaaaa') ?? []); + + self::assertSame(['gcid-q9swzy97df', 'gcid-new0000001'], array_keys($after->changedSince($before))); + self::assertSame([], $before->changedSince($before)); + self::assertSame(['gcid-primary-color'], array_keys( + $before->with('gcid-primary-color', ['color' => '#ff0000', 'folder' => 'customizer'])->changedSince($before) + )); + } + + public function testNewIdHasTheBuilderFormat(): void + { + self::assertMatchesRegularExpression('/^gcid-[0-9a-z]{10}$/', ColorBook::newId()); + self::assertNotSame(ColorBook::newId(), ColorBook::newId()); + self::assertSame( + 'gcid-abcdefghij', + ColorBook::newId(static fn (int $length): string => substr('abcdefghijkl', 0, $length)) + ); + } + + public function testNowHasDiviTimestampFormat(): void + { + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', ColorBook::now()); + } +} +``` + +- [ ] **Step 3b.2: RED-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorBookTest` +Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ColorBook" not found`. + +- [ ] **Step 3b.3: `ColorBook` implementieren** + +Datei `src/Integrations/Divi/Design/ColorBook.php`. Reines PHP. `rev()` hängt `colors` + NUL an den kanonischen JSON (Schlüssel rekursiv sortiert) und nutzt `Rev::of()` wie `PresetBook::rev()`. Das Ziel eines Farbverweises liest die Klasse selbst aus dem Payload (`VariableRef::typeOf()` prüft vorher, dass der ganze Wert genau ein Farbverweis ist), weil `VariableRef` nur für Inhaltsverweise eine öffentliche Namens-Lesefunktion (`nameOf`) hat. + +```php + {color, folder, label, + * lastUpdated, status, usedInPosts}. Takes the shape of get_global_colors() and of a snapshot alike (the same + * entries), fills missing keys and keeps only these six. The five customizer colors carry lastUpdated "" – Divi + * creates it fresh on every read, so design_rev and snapshots stay stable (ruling V3). Immutable – with() returns a + * new book. rev() is the design_rev of the area "colors". Pure PHP. + */ +final class ColorBook +{ + /** Customizer color ID => option name in et_divi (GlobalData::$customizer_colors). */ + public const CUSTOMIZER_IDS = [ + 'gcid-primary-color' => 'accent_color', + 'gcid-secondary-color' => 'secondary_accent_color', + 'gcid-heading-color' => 'header_color', + 'gcid-body-color' => 'font_color', + 'gcid-link-color' => 'link_color', + ]; + + /** A color that refers to another one resolves at most this many references deep (Divi: depth 10). */ + private const MAX_DEPTH = 10; + + private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; + + /** @param array}> $data */ + private function __construct(private array $data) + { + } + + /** @param array|object $raw get_global_colors() or a snapshot – entries may be objects. */ + public static function fromDivi(array|object $raw): self + { + $data = []; + foreach (self::plain($raw) as $id => $entry) { + if (is_array($entry)) { + $data[(string) $id] = self::normalize((string) $id, $entry); + } + } + + return new self($data); + } + + /** @return array}> */ + public function data(): array + { + return $this->data; + } + + /** Hash of the canonical JSON (keys sorted recursively), so the order of the entries does not matter. */ + public function rev(): string + { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). + $json = (string) json_encode(self::sorted($this->data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return Rev::of('colors' . "\0" . $json); + } + + /** @return array{color: string, folder: string, label: string, lastUpdated: string, status: string, + * usedInPosts: list}|null */ + public function entry(string $id): ?array + { + return $this->data[$id] ?? null; + } + + /** The label of $id, else $id itself. */ + public function label(string $id): string + { + $label = $this->data[$id]['label'] ?? ''; + + return $label === '' ? $id : $label; + } + + public function isCustomizer(string $id): bool + { + return isset(self::CUSTOMIZER_IDS[$id]) || ($this->data[$id]['folder'] ?? '') === 'customizer'; + } + + /** + * The ID of the color $needle names: an ID, else an alias (primary, secondary, heading, body, link), else the + * option name of a customizer color (accent_color …), else the one color of that label (case-insensitive). + * + * @throws InvalidArgumentException When several colors share the label (ruling S13a). + */ + public function find(string $needle): ?string + { + if (isset($this->data[$needle])) { + return $needle; + } + + $key = mb_strtolower(trim($needle)); + $alias = DesignSystem::COLOR_ALIASES[$key] ?? array_search($key, self::CUSTOMIZER_IDS, true); + if (is_string($alias) && isset($this->data[$alias])) { + return $alias; + } + + $ids = []; + foreach ($this->data as $id => $entry) { + if ($key !== '' && mb_strtolower(trim($entry['label'])) === $key) { + $ids[] = $id; + } + } + if (count($ids) > 1) { + throw new InvalidArgumentException(sprintf( + 'Name der globalen Farbe "%s" ist mehrdeutig; bitte die ID angeben: %s.', + trim($needle), + implode(', ', $ids) + )); + } + + return $ids[0] ?? null; + } + + /** @param array $entry Added or replaced; missing keys are filled like fromDivi() does. */ + public function with(string $id, array $entry): self + { + $data = $this->data; + $data[$id] = self::normalize($id, $entry); + + return new self($data); + } + + /** @return list IDs of the colors whose value is a reference to $id (relative colors). */ + public function referencing(string $id): array + { + return array_keys(array_filter( + $this->data, + static fn (array $entry): bool => self::targetOf($entry['color']) === $id + )); + } + + /** + * The first chain of references that loops or is deeper than Divi resolves (more than 10 references), as the + * IDs from its start to its end (a loop ends with the ID it began to repeat); null when there is none. + * + * @return list|null + */ + public function cycle(): ?array + { + foreach (array_keys($this->data) as $start) { + $chain = [$start]; + $id = $start; + while (isset($this->data[$id]) && ($next = self::targetOf($this->data[$id]['color'])) !== null) { + $chain[] = $next; + if (in_array($next, array_slice($chain, 0, -1), true) || count($chain) - 1 > self::MAX_DEPTH) { + return $chain; + } + $id = $next; + } + } + + return null; + } + + /** + * New and changed entries compared to $before – what the normal write path hands to Divi (ruling V4). The + * customizer colors' lastUpdated is "" in both books and never counts. + * + * @return array}> + */ + public function changedSince(self $before): array + { + return array_filter( + $this->data, + static fn (array $entry, string $id): bool => $before->entry($id) !== $entry, + ARRAY_FILTER_USE_BOTH + ); + } + + /** A new color ID like the Visual Builder's: "gcid-" and 10 characters of [0-9a-z] (formats §1). */ + public static function newId(?Closure $random = null): string + { + $random ??= static function (int $length): string { + $id = ''; + for ($i = 0; $i < $length; ++$i) { + $id .= self::ALPHABET[random_int(0, 35)]; + } + + return $id; + }; + + return 'gcid-' . $random(10); + } + + /** lastUpdated of a color written now: Y-m-d\TH:i:s.v\Z in UTC (formats §1). */ + public static function now(): string + { + return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.v\Z'); + } + + /** + * @param array $entry + * @return array{color: string, folder: string, label: string, lastUpdated: string, status: string, + * usedInPosts: list} + */ + private static function normalize(string $id, array $entry): array + { + $text = static fn (string $key, string $default): string => is_scalar($entry[$key] ?? null) + ? (string) $entry[$key] + : $default; + $folder = $text('folder', ''); + + return [ + 'color' => $text('color', ''), + 'folder' => $folder, + 'label' => $text('label', ''), + 'lastUpdated' => isset(self::CUSTOMIZER_IDS[$id]) || $folder === 'customizer' + ? '' + : $text('lastUpdated', ''), + 'status' => $text('status', 'active'), + 'usedInPosts' => is_array($entry['usedInPosts'] ?? null) ? array_values($entry['usedInPosts']) : [], + ]; + } + + /** The color ID $color refers to when it is exactly one $variable(…)$ color reference, else null. */ + private static function targetOf(string $color): ?string + { + if (VariableRef::typeOf($color) !== 'color') { + return null; + } + // typeOf() proved a whole reference: strip marker and end like Divi does, the JSON object is in between. + $payload = json_decode(substr($color, strlen(VariableRef::MARKER), -strlen(VariableRef::END)), true); + $name = is_array($payload) ? ($payload['value']['name'] ?? null) : null; + + return is_string($name) ? $name : null; + } + + private static function sorted(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + $value = array_map([self::class, 'sorted'], $value); + if (! array_is_list($value)) { + ksort($value); + } + + return $value; + } + + private static function plain(mixed $value): mixed + { + if (is_object($value)) { + $value = get_object_vars($value); + } + + return is_array($value) ? array_map([self::class, 'plain'], $value) : $value; + } +} +``` + +- [ ] **Step 3b.4: GREEN-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorBookTest` +Expected: `OK (16 tests, 61 assertions)`. + +- [ ] **Step 3b.5: Lint** + +Run: `composer lint` +Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen). + +- [ ] **Step 3b.6: Commit** + +```bash +git add src/Integrations/Divi/Design/ColorBook.php tests/Unit/Integrations/Divi/Design/ColorBookTest.php +git commit -m "feat: ColorBook als reiner Farbbestand mit Auflösung nach ID/Alias/Label, Verweisen, Zyklusprüfung und Änderungsmenge" \ + -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +#### Task 3c: `ColorDiff` – Änderungsliste und Zusammenfassung + +- [ ] **Step 3c.1: Failing Test `ColorDiffTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/ColorDiffTest.php`. Nutzt `ColorBookTest::stored()` aus 3b. + +```php +with('gcid-q9swzy97df', [ + 'color' => '#5b3a29', 'label' => 'Holz dunkel', 'lastUpdated' => '2030-01-01T00:00:00.000Z', + 'usedInPosts' => [7], + ]); + + self::assertSame([], ColorDiff::between($book, ColorBook::fromDivi(ColorBookTest::stored()))); + self::assertSame([], ColorDiff::between($book, $touched)); + } + + public function testAddedChangedDeactivatedActivatedAndRemoved(): void + { + $before = ColorBook::fromDivi(ColorBookTest::stored()); + $after = $before + ->with('gcid-new0000001', ['color' => '#abc', 'label' => 'Neu']) + ->with('gcid-q9swzy97df', ['color' => '#000000', 'label' => 'Holz dunkler']) + ->with('gcid-aaaaaaaaaa', ['color' => '#111111', 'label' => 'Doppelt', 'status' => 'inactive']) + ->with('gcid-bbbbbbbbbb', ['color' => '#222222', 'label' => 'doppelt', 'status' => 'active']) + ->with('gcid-989stx15zc', ['color' => '#333333', 'label' => 'Holz hell', 'status' => 'temporary']); + + self::assertSame([ + ['id' => 'gcid-989stx15zc', 'name' => 'Holz hell', 'action' => 'changed', 'fields' => ['color', 'status']], + ['id' => 'gcid-aaaaaaaaaa', 'name' => 'Doppelt', 'action' => 'deactivated'], + ['id' => 'gcid-bbbbbbbbbb', 'name' => 'doppelt', 'action' => 'activated'], + ['id' => 'gcid-new0000001', 'name' => 'Neu', 'action' => 'added'], + ['id' => 'gcid-q9swzy97df', 'name' => 'Holz dunkler', 'action' => 'changed', + 'fields' => ['color', 'label']], + ], ColorDiff::between($before, $after)); + + $restored = ColorBook::fromDivi(array_diff_key(ColorBookTest::stored(), ['gcid-aaaaaaaaaa' => 1])); + self::assertSame( + [['id' => 'gcid-aaaaaaaaaa', 'name' => 'Doppelt', 'action' => 'removed']], + ColorDiff::between($before, $restored) + ); + } + + public function testStatusSwitchWithFurtherChangesNamesTheOtherFields(): void + { + $before = ColorBook::fromDivi(ColorBookTest::stored()); + $after = $before->with('gcid-aaaaaaaaaa', [ + 'color' => '#999999', 'label' => 'Doppelt', 'folder' => 'Marke', 'status' => 'inactive', + ]); + + self::assertSame( + [['id' => 'gcid-aaaaaaaaaa', 'name' => 'Doppelt', 'action' => 'deactivated', + 'fields' => ['color', 'folder']]], + ColorDiff::between($before, $after) + ); + } + + public function testSummaryCountsTheActions(): void + { + self::assertSame( + 'Farben: 1 angelegt, 2 geändert, 1 deaktiviert.', + ColorDiff::summary([ + ['action' => 'added'], ['action' => 'changed'], ['action' => 'changed'], ['action' => 'deactivated'], + ]) + ); + self::assertSame( + 'Farben: 1 geändert, 1 aktiviert, 1 gelöscht.', + ColorDiff::summary([['action' => 'removed'], ['action' => 'activated'], ['action' => 'changed']]) + ); + self::assertSame('Farben: keine Änderung.', ColorDiff::summary([])); + } +} +``` + +- [ ] **Step 3c.2: RED-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorDiffTest` +Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ColorDiff" not found`. + +- [ ] **Step 3c.3: `ColorDiff` implementieren** + +Datei `src/Integrations/Divi/Design/ColorDiff.php`. Reines PHP; sortiert nach ID. + +```php +}> + * action: added|changed|deactivated|activated|removed + */ + public static function between(ColorBook $before, ColorBook $after): array + { + $ids = array_unique(array_merge(array_keys($before->data()), array_keys($after->data()))); + sort($ids); + $rows = []; + foreach ($ids as $id) { + $id = (string) $id; + $was = $before->entry($id); + $is = $after->entry($id); + if ($was === null || $is === null) { + $book = $is === null ? $before : $after; + $rows[] = ['id' => $id, 'name' => $book->label($id), 'action' => $is === null ? 'removed' : 'added']; + continue; + } + $action = self::action($was['status'], $is['status']); + // A status switch is the action itself; "fields" then names only the other changes. + $skip = $action === 'changed' ? [] : ['status']; + $fields = array_values(array_filter( + self::COMPARED, + static fn (string $key): bool => $was[$key] !== $is[$key] && ! in_array($key, $skip, true) + )); + $row = ['id' => $id, 'name' => $after->label($id), 'action' => $action]; + if ($fields !== []) { + $row['fields'] = $fields; + } + if ($fields !== [] || $action !== 'changed') { + $rows[] = $row; + } + } + + return $rows; + } + + /** + * One German sentence for the dry run and the change log (spec §4 step 6). + * + * @param list> $diff + */ + public static function summary(array $diff): string + { + $count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff)); + $words = [ + 'added' => 'angelegt', 'changed' => 'geändert', 'deactivated' => 'deaktiviert', + 'activated' => 'aktiviert', 'removed' => 'gelöscht', + ]; + $parts = []; + foreach ($words as $action => $word) { + if (isset($count[$action])) { + $parts[] = $count[$action] . ' ' . $word; + } + } + + return 'Farben: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.'; + } + + private static function action(string $was, string $is): string + { + return match (true) { + $was === $is => 'changed', + $is === 'inactive' => 'deactivated', + $is === 'active' => 'activated', + default => 'changed', + }; + } +} +``` + +- [ ] **Step 3c.4: GREEN-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorDiffTest` +Expected: `OK (4 tests, 8 assertions)`. + +- [ ] **Step 3c.5: Lint** + +Run: `composer lint` +Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen). + +- [ ] **Step 3c.6: Commit** + +```bash +git add src/Integrations/Divi/Design/ColorDiff.php tests/Unit/Integrations/Divi/Design/ColorDiffTest.php +git commit -m "feat: ColorDiff beschreibt Farbänderungen (angelegt, geändert, deaktiviert, aktiviert) samt Zusammenfassung" \ + -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +#### Task 3 – Abschluss + +- [ ] **Step 3.7: Gesamtlauf** + +Run: `composer test && composer lint` +Expected: alle Unit-Tests grün (2877 + 53 neue = 2930), Lint ohne Meldung. Schlägt ein Architektur-Test fehl (`CoreIndependenceTest`, `DiviBoundaryTest`), steht in den neuen Klassen ein WordPress-/Divi-Aufruf, der dort nicht hingehört – entfernen, nicht den Test anpassen. + +--- + +### Task 4: `VariableValue`, `VariableBook`, `VariableDiff` – reiner Variablenbestand (V1, V6, V7) + +**Files:** +- Create: `src/Integrations/Divi/Design/VariableValue.php`, `VariableBook.php`, `VariableDiff.php` +- Test: `tests/Unit/Integrations/Divi/Design/VariableValueTest.php`, `VariableBookTest.php`, `VariableDiffTest.php` (alle neu, erben von `PHPUnit\Framework\TestCase`) + +**Interfaces:** +- Consumes: `LIT\MCP\Abilities\Rev::of()`, `ColorBook::now()` (aus Task 3, für gleiches Zeitstempelformat). +- Produces (Signaturen wie im Schnittstellenvertrag, keine Abweichung): + - `VariableValue::TYPES`, `VariableValue::check(string $type, string $value): ?string`. + - `VariableBook`: `fromDivi`, `data`, `rev`, `entry`, `typeOf`, `find`, `with`, `nextOrder`, `restoredFrom`, `onlyHere`, `newId`, `now`. Die Testdatei `VariableBookTest` liefert `VariableBookTest::stored()` (Speicherform: `numbers` ×3 darunter eine archivierte, `strings` ×2 mit einem Label, das auch in `numbers` vorkommt, `fonts`, `gradients`), `::fromGetter()` (Form von `get_global_variables()` mit `stdClass`-Gruppen, `allowedActions`, Customizer-Schriften und Gruppe `colors`) und `::variable(...)`; spätere Tests (`VariableAreaTest`, `VariableRestorePlannerTest`) dürfen sie benutzen. + - `VariableDiff::between(VariableBook, VariableBook)`, `VariableDiff::summary(array)`. + +**Regeln dieses Tasks (alle durch Tests belegt):** +- `VariableValue::check()` setzt V6 um; `gradients` und alles außerhalb von `TYPES` liefert „Typ "…" ist nicht erlaubt; erlaubt: numbers, strings, images, links, fonts.“ (das Durchreichen von `gradients` ist Sache von `VariableBook`). Leerer Wert → „Wert fehlt (Typ "…").“; ungültiges UTF-8 wird abgelehnt. Links: `http(s)://…`, `/…` (auch `/kontakt/#formular`), `mailto:…`, `tel:…`, keine Leerzeichen, `<`, `>`, `"`. Bilder: nur Ziffern oder `http(s)://`-URL. +- `VariableBook` kennt die sieben Schlüssel `id, label, value, order, status, lastUpdated, variableType` (feste Reihenfolge, fremde Schlüssel wie `allowedActions` fallen weg); `id` und `variableType` stammen aus Schlüssel und Gruppe, nicht aus dem Eintrag; `order: null` (so archiviert der Builder) wird `''`. Typen in fester Reihenfolge `numbers, strings, images, links, fonts, gradients`; `colors`, unbekannte Typen, leere Gruppen und Einträge ohne `gvid-`-Präfix (Customizer-Schriften `--et_global_*_font`) entfallen. `gradients` bleibt unverändert erhalten. `with()` wirft `InvalidArgumentException` („Unbekannter Variablentyp "colors".“) für einen Typ, den Divi nicht speichert. +- `restoredFrom($snapshot)` (V1b): alle Einträge des Snapshots, dazu jeder Eintrag, den es nur im aktuellen Bestand gibt, mit `status: archived` und `order: ''` – jede aktuelle ID bleibt erhalten, wie `validate_global_variable_ids()` es verlangt. `onlyHere($snapshot)` nennt davon nur die bisher **aktiven** (für die Warnung im Probelauf). +- `VariableDiff`: Verglichen werden `label`, `value`, `order`, `status`. Statuswechsel nach `archived` = `archived`, von `archived` nach `active` = `activated`; dabei zählt `order` nicht (Archivieren leert sie) und `fields` nennt nur `label`/`value`. Ein Eintrag, der nachher fehlt, erzeugt keine Zeile (Variablen werden nie entfernt, V1). + +#### Task 4a: `VariableValue` – Variablenwerte je Typ prüfen + +- [ ] **Step 4a.1: Failing Test `VariableValueTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/VariableValueTest.php`. Datenanbieter für gültige und ungültige Werte je Typ, Grenzen inklusive, `gradients` und `colors` als nicht anlegbare Typen. + +```php + */ + public static function validValues(): array + { + return [ + 'numbers px' => ['numbers', '8px'], + 'numbers clamp' => ['numbers', 'clamp(1.5rem, 4vw, 2.5rem)'], + 'numbers calc' => ['numbers', 'calc(100% - var(--gvid-f6yq6mayfy))'], + 'strings' => ['strings', 'Handwerk aus Meisterhand – seit 1987'], + 'strings with line feed' => ['strings', "Zeile 1\nZeile 2"], + 'strings with quotes and markup chars' => ['strings', '"Hallo" & '], + 'links https' => ['links', 'https://wordpress.ddev.site:8443/kontakt/'], + 'links http' => ['links', 'http://example.com'], + 'links relative' => ['links', '/kontakt/#formular'], + 'links mailto' => ['links', 'mailto:info@example.com'], + 'links tel' => ['links', 'tel:+49221123'], + 'images id' => ['images', '1234'], + 'images url' => ['images', 'https://example.com/a/600.jpg'], + 'fonts' => ['fonts', 'Merriweather'], + 'fonts with space and digits' => ['fonts', 'Source Sans 3'], + ]; + } + + /** @dataProvider validValues */ + public function testValidValues(string $type, string $value): void + { + self::assertNull(VariableValue::check($type, $value)); + } + + /** @return array */ + public static function invalidValues(): array + { + return [ + 'numbers empty' => ['numbers', ''], + 'numbers semicolon' => ['numbers', '8px; color: red'], + 'numbers braces' => ['numbers', '8px}'], + 'numbers angle' => ['numbers', '<8px>'], + 'numbers line feed' => ['numbers', "8px\n9px"], + 'numbers too long' => ['numbers', str_repeat('1', 201)], + 'strings empty' => ['strings', ''], + 'strings control' => ['strings', "a\x07b"], + 'strings tab' => ['strings', "a\tb"], + 'strings carriage return' => ['strings', "a\r\nb"], + 'strings too long' => ['strings', str_repeat('ä', 2001)], + 'strings invalid utf8' => ['strings', "a\xC3\x28"], + 'links empty' => ['links', ''], + 'links javascript' => ['links', 'javascript:alert(1)'], + 'links data' => ['links', 'data:text/html,x'], + 'links anchor only' => ['links', '#kontakt'], + 'links relative without slash' => ['links', 'kontakt/'], + 'links with space' => ['links', 'https://example.com/a b'], + 'links mailto empty' => ['links', 'mailto:'], + 'links too long' => ['links', 'https://example.com/' . str_repeat('a', 2000)], + 'images negative' => ['images', '-5'], + 'images relative path' => ['images', '/wp-content/a.jpg'], + 'images text' => ['images', 'Werkstattbild'], + 'images javascript' => ['images', 'javascript:alert(1)'], + 'fonts empty' => ['fonts', ''], + 'fonts semicolon' => ['fonts', 'Lato; color: red'], + 'fonts quote' => ['fonts', "Lato'"], + 'fonts backslash' => ['fonts', 'Lato\\'], + 'fonts too long' => ['fonts', str_repeat('a', 101)], + ]; + } + + /** @dataProvider invalidValues */ + public function testInvalidValuesGetAGermanMessage(string $type, string $value): void + { + $message = VariableValue::check($type, $value); + + self::assertIsString($message); + self::assertNotSame('', $message); + } + + public function testBoundsAreInclusive(): void + { + self::assertNull(VariableValue::check('numbers', str_repeat('1', 200))); + self::assertNull(VariableValue::check('strings', str_repeat('ä', 2000))); + self::assertNull(VariableValue::check('fonts', str_repeat('a', 100))); + self::assertNull(VariableValue::check('links', 'https://example.com/' . str_repeat('a', 1980))); + } + + public function testGradientsAndUnknownTypesAreNotCreatable(): void + { + self::assertSame( + 'Typ "gradients" ist nicht erlaubt; erlaubt: numbers, strings, images, links, fonts.', + VariableValue::check('gradients', 'linear-gradient(red, blue)') + ); + self::assertSame( + 'Typ "colors" ist nicht erlaubt; erlaubt: numbers, strings, images, links, fonts.', + VariableValue::check('colors', '#fff') + ); + self::assertSame(['numbers', 'strings', 'images', 'links', 'fonts'], VariableValue::TYPES); + } + + public function testMessagesNameTheTypeOrTheRule(): void + { + self::assertSame('Wert fehlt (Typ "fonts").', VariableValue::check('fonts', '')); + self::assertStringStartsWith('Zahlenwert:', (string) VariableValue::check('numbers', '1;')); + self::assertStringStartsWith('Link:', (string) VariableValue::check('links', 'x')); + self::assertStringStartsWith('Bild:', (string) VariableValue::check('images', 'x')); + self::assertStringStartsWith('Schrift:', (string) VariableValue::check('fonts', 'a{')); + self::assertStringStartsWith('Textwert:', (string) VariableValue::check('strings', "a\x00")); + } +} +``` + +- [ ] **Step 4a.2: RED-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableValueTest` +Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\VariableValue" not found`. + +- [ ] **Step 4a.3: `VariableValue` implementieren** + +Datei `src/Integrations/Divi/Design/VariableValue.php`. Reines PHP; Regexe und Längen. + +```php +"]+|\/[^\s<>"]*|mailto:[^\s<>"]+|tel:[^\s<>"]+)$/iD'; + + private const WEB_URL = '/^https?:\/\/[^\s<>"]+$/iD'; + + /** @return string|null Null when $value is valid for $type, else a German message. */ + public static function check(string $type, string $value): ?string + { + if (! in_array($type, self::TYPES, true)) { + return sprintf('Typ "%s" ist nicht erlaubt; erlaubt: %s.', $type, implode(', ', self::TYPES)); + } + if ($value === '') { + return sprintf('Wert fehlt (Typ "%s").', $type); + } + if (! mb_check_encoding($value, 'UTF-8')) { + return 'Wert ist kein gültiges UTF-8.'; + } + + return match ($type) { + 'numbers' => self::numbers($value), + 'strings' => self::strings($value), + 'links' => self::links($value), + 'images' => self::images($value), + default => self::font($value), + }; + } + + private static function numbers(string $value): ?string + { + if (mb_strlen($value) > self::NUMBERS_MAX || preg_match('/[;{}<>\x00-\x1F\x7F]/', $value) === 1) { + return 'Zahlenwert: ein CSS-Ausdruck mit höchstens 200 Zeichen ohne ; { } < > und Zeilenumbruch ' + . '(z. B. 8px oder clamp(1.5rem, 4vw, 2.5rem)).'; + } + + return null; + } + + private static function strings(string $value): ?string + { + if (mb_strlen($value) > self::STRINGS_MAX || preg_match(self::CONTROL, $value) === 1) { + return 'Textwert: höchstens 2000 Zeichen, keine Steuerzeichen außer dem Zeilenumbruch.'; + } + + return null; + } + + private static function links(string $value): ?string + { + if (mb_strlen($value) > self::URL_MAX || preg_match(self::URL, $value) !== 1) { + return 'Link: eine http(s)://- oder /-URL, mailto: oder tel: mit höchstens 2000 Zeichen.'; + } + + return null; + } + + private static function images(string $value): ?string + { + $valid = preg_match('/^\d+$/D', $value) === 1 + || (mb_strlen($value) <= self::URL_MAX && preg_match(self::WEB_URL, $value) === 1); + + return $valid ? null : 'Bild: eine Anhang-ID (Ziffern) oder eine http(s)://-URL mit höchstens 2000 Zeichen.'; + } + + private static function font(string $value): ?string + { + if (mb_strlen($value) > self::FONT_MAX || preg_match('/[;{}<>"\'\\\\\x00-\x1F\x7F]/', $value) === 1) { + return 'Schrift: ein Schriftname mit höchstens 100 Zeichen ohne ; { } < > " \' \\.'; + } + + return null; + } +} +``` + +- [ ] **Step 4a.4: GREEN-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableValueTest` +Expected: `OK (47 tests, 86 assertions)`. + +- [ ] **Step 4a.5: Lint** + +Run: `composer lint` +Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen). + +- [ ] **Step 4a.6: Commit** + +```bash +git add src/Integrations/Divi/Design/VariableValue.php tests/Unit/Integrations/Divi/Design/VariableValueTest.php +git commit -m "feat: VariableValue prüft Variablenwerte je Typ (numbers, strings, images, links, fonts)" \ + -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +#### Task 4b: `VariableBook` – unveränderlicher Variablenbestand + +- [ ] **Step 4b.1: Failing Test `VariableBookTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/VariableBookTest.php`. Deckt ab: Speicherform und Lese-Form von `get_global_variables()` ergeben denselben Bestand und dieselbe `rev()`, die echte Divi-Fixture `tests/Fixtures/divi/stage4/global-variables.json` (`stored` und `get_global_variables`), Durchreichen von `gradients`, Auffüllen und Normalisieren, `find` nach ID/Label mit und ohne Typ inklusive Mehrdeutigkeit, `with`, `nextOrder`, `restoredFrom`/`onlyHere` (auch archivierte Einträge mit `order`), `newId`, `now`. + +```php + One stored variable (formats §2). */ + public static function variable( + string $type, + string $id, + string $label, + string $value, + string $order = '1', + string $status = 'active' + ): array { + return [ + 'id' => $id, 'label' => $label, 'value' => $value, 'order' => $order, 'status' => $status, + 'lastUpdated' => '2026-09-27T22:21:39.000Z', 'variableType' => $type, + ]; + } + + /** @return array>> The stored shape (et_divi_global_variables). */ + public static function stored(): array + { + return [ + 'numbers' => [ + 'gvid-f6yq6mayfy' => self::variable('numbers', 'gvid-f6yq6mayfy', 'Eckenradius', '8px', '1'), + 'gvid-5dec72fwsc' => self::variable('numbers', 'gvid-5dec72fwsc', 'Abstand Karte', '2rem', '2'), + 'gvid-old0000001' => self::variable('numbers', 'gvid-old0000001', 'Alt', '1px', '', 'archived'), + ], + 'strings' => [ + 'gvid-7uckqqhmrh' => self::variable('strings', 'gvid-7uckqqhmrh', 'Slogan', 'Handwerk', '1'), + 'gvid-dup0000001' => self::variable('strings', 'gvid-dup0000001', 'Eckenradius', 'Text', '2'), + ], + 'fonts' => [ + 'gvid-d4a5bsz2gt' => self::variable('fonts', 'gvid-d4a5bsz2gt', 'Akzentschrift', 'Merriweather'), + ], + 'gradients' => [ + 'gvid-grad000001' => self::variable( + 'gradients', + 'gvid-grad000001', + 'Verlauf', + 'linear-gradient(red, blue)' + ), + ], + ]; + } + + /** @return array The shape of GlobalData::get_global_variables() (formats §2, verified in DDEV). */ + public static function fromGetter(): array + { + $actions = ['editLabel' => true, 'editValue' => true, 'reorder' => true, 'remove' => true]; + $stored = self::stored(); + $withActions = static fn (array $group): array => array_map( + static fn (array $entry): array => $entry + ['allowedActions' => $actions], + $group + ); + + return [ + 'numbers' => (object) $withActions($stored['numbers']), + 'strings' => (object) $withActions($stored['strings']), + 'images' => new stdClass(), + 'links' => new stdClass(), + 'colors' => (object) ['gcid-x1' => ['id' => 'gcid-x1', 'label' => 'Farbe', 'value' => '#fff']], + 'fonts' => (object) ([ + '--et_global_heading_font' => ['id' => '--et_global_heading_font', 'label' => 'Überschrift', + 'value' => 'Open Sans', 'status' => 'active', 'allowedActions' => $actions], + '--et_global_body_font' => ['id' => '--et_global_body_font', 'label' => 'Fließtext', + 'value' => 'Open Sans', 'status' => 'active', 'allowedActions' => $actions], + ] + $withActions($stored['fonts'])), + 'gradients' => (object) $withActions($stored['gradients']), + ]; + } + + public function testStoredShapeIsKeptAsItIs(): void + { + self::assertSame(self::stored(), VariableBook::fromDivi(self::stored())->data()); + } + + public function testGetterShapeEqualsTheStoredShape(): void + { + $fromGetter = VariableBook::fromDivi(self::fromGetter()); + $fromStored = VariableBook::fromDivi(self::stored()); + + self::assertSame($fromStored->data(), $fromGetter->data()); + self::assertSame($fromStored->rev(), $fromGetter->rev()); + self::assertNull($fromGetter->typeOf('--et_global_body_font')); + self::assertNull($fromGetter->typeOf('gcid-x1')); + self::assertArrayNotHasKey('colors', $fromGetter->data()); + self::assertArrayNotHasKey('images', $fromGetter->data(), 'empty groups are dropped'); + self::assertArrayNotHasKey('allowedActions', $fromGetter->entry('numbers', 'gvid-f6yq6mayfy') ?? []); + } + + public function testGradientsPassThroughUnchanged(): void + { + $book = VariableBook::fromDivi(self::stored()); + + self::assertSame('linear-gradient(red, blue)', $book->entry('gradients', 'gvid-grad000001')['value']); + self::assertSame('gradients', $book->typeOf('gvid-grad000001')); + self::assertSame( + 'gradients', + $book->with('gradients', 'gvid-grad000001', ['label' => 'Verlauf 2', 'value' => 'x']) + ->entry('gradients', 'gvid-grad000001')['variableType'] + ); + } + + public function testMissingKeysAreFilledAndIdAndTypeComeFromTheSlot(): void + { + $book = VariableBook::fromDivi([ + 'numbers' => ['gvid-x1' => ['id' => 'other', 'label' => 'A', 'value' => 8, 'order' => null, + 'variableType' => 'strings', 'extra' => 'dropped']], + 'strings' => ['kein-gvid' => ['label' => 'B', 'value' => 'x']], + ]); + + self::assertSame([ + 'id' => 'gvid-x1', 'label' => 'A', 'value' => '8', 'order' => '', 'status' => 'active', + 'lastUpdated' => '', 'variableType' => 'numbers', + ], $book->entry('numbers', 'gvid-x1')); + self::assertArrayNotHasKey('strings', $book->data()); + } + + public function testRevIsStableAndChangesWithTheContent(): void + { + $book = VariableBook::fromDivi(self::stored()); + $reversed = VariableBook::fromDivi(array_map( + static fn (array $group): array => array_reverse($group, true), + array_reverse(self::stored(), true) + )); + + self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $book->rev()); + self::assertSame($book->rev(), $reversed->rev()); + self::assertNotSame( + $book->rev(), + $book->with('numbers', 'gvid-f6yq6mayfy', ['label' => 'X', 'value' => '9px'])->rev() + ); + } + + public function testRealDiviFixtureAndItsGetterShapeAreTheSame(): void + { + $json = (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/stage4/global-variables.json'); + $fixture = json_decode($json, true); + $stored = VariableBook::fromDivi($fixture['stored']); + $read = VariableBook::fromDivi($fixture['get_global_variables']); + + self::assertSame($stored->rev(), $read->rev()); + self::assertSame(['numbers', 'strings', 'images', 'links', 'fonts'], array_keys($read->data())); + self::assertSame('8px', $read->entry('numbers', 'gvid-f6yq6mayfy')['value']); + self::assertNull($read->typeOf('--et_global_heading_font')); + } + + public function testFindByIdAndLabel(): void + { + $book = VariableBook::fromDivi(self::stored()); + + self::assertSame(['type' => 'numbers', 'id' => 'gvid-f6yq6mayfy'], $book->find('gvid-f6yq6mayfy')); + self::assertSame(['type' => 'strings', 'id' => 'gvid-7uckqqhmrh'], $book->find(' SLOGAN ')); + self::assertSame(['type' => 'fonts', 'id' => 'gvid-d4a5bsz2gt'], $book->find('Akzentschrift', 'fonts')); + self::assertNull($book->find('Akzentschrift', 'numbers')); + self::assertNull($book->find('gvid-d4a5bsz2gt', 'numbers')); + self::assertNull($book->find('Fehlt')); + self::assertNull($book->find('')); + } + + public function testAmbiguousLabelListsTheIdsAndAnArgumentTypeResolvesIt(): void + { + $book = VariableBook::fromDivi(self::stored()); + + self::assertSame(['type' => 'numbers', 'id' => 'gvid-f6yq6mayfy'], $book->find('Eckenradius', 'numbers')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Name der globalen Variable "Eckenradius" ist mehrdeutig; bitte die ID angeben: ' + . 'gvid-f6yq6mayfy, gvid-dup0000001.' + ); + $book->find('Eckenradius'); + } + + public function testWithIsImmutableAndKeepsTheTypeOrder(): void + { + $book = VariableBook::fromDivi(self::stored()); + $added = $book->with('images', 'gvid-img0000001', ['label' => 'Bild', 'value' => '12', 'order' => '1']); + + self::assertCount(3, $book->data()['numbers']); + self::assertSame(['numbers', 'strings', 'images', 'fonts', 'gradients'], array_keys($added->data())); + self::assertSame('images', $added->entry('images', 'gvid-img0000001')['variableType']); + self::assertSame('active', $added->entry('images', 'gvid-img0000001')['status']); + self::assertNull($book->entry('images', 'gvid-img0000001')); + } + + public function testWithRejectsTypesDiviDoesNotStore(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unbekannter Variablentyp "colors".'); + + VariableBook::fromDivi([])->with('colors', 'gvid-x1', ['label' => 'A', 'value' => '#fff']); + } + + public function testNextOrderIsTheHighestOrderPlusOne(): void + { + $book = VariableBook::fromDivi(self::stored()); + + self::assertSame('3', $book->nextOrder('numbers'), 'the archived entry with order "" does not count'); + self::assertSame('2', $book->nextOrder('fonts')); + self::assertSame('1', $book->nextOrder('images')); + self::assertSame('1', $book->nextOrder('colors')); + self::assertSame('11', $book->with('links', 'gvid-l1', ['label' => 'L', 'value' => '/', 'order' => '10']) + ->nextOrder('links')); + } + + public function testRestoredFromKeepsCurrentOnlyVariablesArchived(): void + { + $current = VariableBook::fromDivi(self::stored()) + ->with('numbers', 'gvid-new0000001', ['label' => 'Neu', 'value' => '4px', 'order' => '3']) + ->with('images', 'gvid-img0000001', ['label' => 'Bild', 'value' => '12', 'order' => '1']) + ->with('strings', 'gvid-7uckqqhmrh', ['label' => 'Slogan neu', 'value' => 'Geändert', 'order' => '1']); + $snapshot = VariableBook::fromDivi(self::stored()); + + $restored = $current->restoredFrom($snapshot); + + self::assertSame('Slogan', $restored->entry('strings', 'gvid-7uckqqhmrh')['label'], 'snapshot wins'); + self::assertSame( + ['label' => 'Neu', 'value' => '4px', 'order' => '', 'status' => 'archived'], + array_intersect_key( + $restored->entry('numbers', 'gvid-new0000001') ?? [], + array_flip(['status', 'order', 'label', 'value']) + ) + ); + self::assertSame('archived', $restored->entry('images', 'gvid-img0000001')['status']); + self::assertSame('', $restored->entry('images', 'gvid-img0000001')['order']); + self::assertSame('archived', $restored->entry('numbers', 'gvid-old0000001')['status']); + // Every ID of the current inventory survives, as Divi demands. + foreach ($current->data() as $type => $entries) { + foreach (array_keys($entries) as $id) { + self::assertNotNull($restored->entry($type, (string) $id), $id); + } + } + self::assertSame($snapshot->rev(), $snapshot->restoredFrom($snapshot)->rev()); + } + + public function testRestoredArchivedEntryLosesItsOrder(): void + { + $current = VariableBook::fromDivi(self::stored()) + ->with('fonts', 'gvid-gone000001', [ + 'label' => 'Weg', 'value' => 'Lato', 'order' => '7', 'status' => 'archived', + ]); + + $restored = $current->restoredFrom(VariableBook::fromDivi(self::stored())); + + self::assertSame(['archived', ''], [ + $restored->entry('fonts', 'gvid-gone000001')['status'], + $restored->entry('fonts', 'gvid-gone000001')['order'], + ]); + } + + public function testOnlyHereListsTheActiveVariablesARestoreWouldArchive(): void + { + $current = VariableBook::fromDivi(self::stored()) + ->with('numbers', 'gvid-new0000001', ['label' => 'Neu', 'value' => '4px']) + ->with('fonts', 'gvid-gone000001', ['label' => 'Weg', 'value' => 'Lato', 'status' => 'archived']); + + self::assertSame( + [['type' => 'numbers', 'id' => 'gvid-new0000001', 'label' => 'Neu']], + $current->onlyHere(VariableBook::fromDivi(self::stored())) + ); + self::assertSame([], $current->onlyHere($current)); + } + + public function testNewIdHasTheBuilderFormat(): void + { + self::assertMatchesRegularExpression('/^gvid-[0-9a-z]{10}$/', VariableBook::newId()); + self::assertNotSame(VariableBook::newId(), VariableBook::newId()); + self::assertSame( + 'gvid-zzzzzzzzzz', + VariableBook::newId(static fn (int $length): string => str_repeat('z', $length)) + ); + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', VariableBook::now()); + } +} +``` + +- [ ] **Step 4b.2: RED-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableBookTest` +Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\VariableBook" not found`. + +- [ ] **Step 4b.3: `VariableBook` implementieren** + +Datei `src/Integrations/Divi/Design/VariableBook.php`. Reines PHP. `rev()` wie bei `ColorBook` (Präfix `variables`). `now()` ruft `ColorBook::now()` auf (ein Zeitstempelformat für beide Bereiche). + +```php + id => {id, label, value, order, status, + * lastUpdated, variableType}. Takes the stored shape and the shape of GlobalData::get_global_variables() alike: + * groups and entries may be objects, "allowedActions", the customizer fonts (--et_global_*_font), the group "colors" + * and empty groups are dropped. The type "gradients" (Divi 5.15) is kept unchanged (ruling V6). Variables are never + * removed, only archived (ruling V1): restoredFrom() keeps every current variable. Immutable. Pure PHP. + */ +final class VariableBook +{ + /** Types in the order of Divi's stored data; VariableValue::TYPES plus the pass-through "gradients". */ + private const TYPES = ['numbers', 'strings', 'images', 'links', 'fonts', 'gradients']; + + private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; + + /** @param array>> $data */ + private function __construct(private array $data) + { + } + + /** @param array|object $raw Stored data or get_global_variables(). */ + public static function fromDivi(array|object $raw): self + { + $raw = self::plain($raw); + $data = []; + foreach (self::TYPES as $type) { + foreach (is_array($raw[$type] ?? null) ? $raw[$type] : [] as $id => $entry) { + // Customizer fonts have IDs like "--et_global_body_font"; Divi stores only "gvid-" variables. + if (is_array($entry) && str_starts_with((string) $id, 'gvid-')) { + $data[$type][(string) $id] = self::normalize($type, (string) $id, $entry); + } + } + } + + return new self($data); + } + + /** @return array>> */ + public function data(): array + { + return $this->data; + } + + public function rev(): string + { + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). + $json = (string) json_encode(self::sorted($this->data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return Rev::of('variables' . "\0" . $json); + } + + /** @return array|null */ + public function entry(string $type, string $id): ?array + { + return $this->data[$type][$id] ?? null; + } + + public function typeOf(string $id): ?string + { + foreach ($this->data as $type => $entries) { + if (isset($entries[$id])) { + return $type; + } + } + + return null; + } + + /** + * The variable $needle names: an ID, else the one variable of that label (case-insensitive, trimmed) – + * optionally only within $type. + * + * @return array{type: string, id: string}|null + * @throws InvalidArgumentException When several variables share the label (ruling S13a). + */ + public function find(string $needle, ?string $type = null): ?array + { + $key = mb_strtolower(trim($needle)); + $hits = []; + foreach ($this->data as $kind => $entries) { + if ($type !== null && $kind !== $type) { + continue; + } + if (isset($entries[$needle])) { + return ['type' => $kind, 'id' => $needle]; + } + foreach ($entries as $id => $entry) { + if ($key !== '' && mb_strtolower(trim((string) $entry['label'])) === $key) { + $hits[] = ['type' => $kind, 'id' => (string) $id]; + } + } + } + if (count($hits) > 1) { + throw new InvalidArgumentException(sprintf( + 'Name der globalen Variable "%s" ist mehrdeutig; bitte die ID angeben: %s.', + trim($needle), + implode(', ', array_column($hits, 'id')) + )); + } + + return $hits[0] ?? null; + } + + /** + * @param array $entry Added or replaced; missing keys are filled, id and variableType are set. + * @throws InvalidArgumentException For a type Divi does not store. + */ + public function with(string $type, string $id, array $entry): self + { + if (! in_array($type, self::TYPES, true)) { + throw new InvalidArgumentException(sprintf('Unbekannter Variablentyp "%s".', $type)); + } + $data = $this->data; + $data[$type][$id] = self::normalize($type, $id, $entry); + + return new self(self::inTypeOrder($data)); + } + + /** The order for a variable appended to $type: the highest numeric order there plus one, as a string. */ + public function nextOrder(string $type): string + { + $highest = 0; + foreach ($this->data[$type] ?? [] as $entry) { + if (is_numeric($entry['order'])) { + $highest = max($highest, (int) $entry['order']); + } + } + + return (string) ($highest + 1); + } + + /** + * The inventory a restore writes (ruling V1b): every variable of $snapshot, and every variable that exists only + * here stays – archived, with order "" (Divi refuses a save that drops a stored ID). + */ + public function restoredFrom(self $snapshot): self + { + $data = $snapshot->data; + foreach ($this->data as $type => $entries) { + foreach ($entries as $id => $entry) { + if (! isset($snapshot->data[$type][$id])) { + $data[$type][$id] = array_merge($entry, ['status' => 'archived', 'order' => '']); + } + } + } + + return new self(self::inTypeOrder($data)); + } + + /** + * The variables restoredFrom() would archive: active ones that are not in $snapshot (already archived ones stay + * archived and need no warning). + * + * @return list + */ + public function onlyHere(self $snapshot): array + { + $rows = []; + foreach ($this->data as $type => $entries) { + foreach ($entries as $id => $entry) { + if (! isset($snapshot->data[$type][$id]) && $entry['status'] !== 'archived') { + $rows[] = ['type' => $type, 'id' => (string) $id, 'label' => (string) $entry['label']]; + } + } + } + + return $rows; + } + + /** A new variable ID like the Visual Builder's: "gvid-" and 10 characters of [0-9a-z] (formats §2). */ + public static function newId(?Closure $random = null): string + { + $random ??= static function (int $length): string { + $id = ''; + for ($i = 0; $i < $length; ++$i) { + $id .= self::ALPHABET[random_int(0, 35)]; + } + + return $id; + }; + + return 'gvid-' . $random(10); + } + + /** lastUpdated of a variable written now (same format as a color's). */ + public static function now(): string + { + return ColorBook::now(); + } + + /** + * @param array $entry + * @return array + */ + private static function normalize(string $type, string $id, array $entry): array + { + $text = static fn (string $key, string $default): string => is_scalar($entry[$key] ?? null) + ? (string) $entry[$key] + : $default; + + return [ + 'id' => $id, + 'label' => $text('label', ''), + 'value' => $text('value', ''), + 'order' => $text('order', ''), + 'status' => $text('status', 'active'), + 'lastUpdated' => $text('lastUpdated', ''), + 'variableType' => $type, + ]; + } + + /** + * @param array>> $data + * @return array>> + */ + private static function inTypeOrder(array $data): array + { + $ordered = []; + foreach (self::TYPES as $type) { + if (isset($data[$type])) { + $ordered[$type] = $data[$type]; + } + } + + return $ordered; + } + + private static function sorted(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + $value = array_map([self::class, 'sorted'], $value); + if (! array_is_list($value)) { + ksort($value); + } + + return $value; + } + + private static function plain(mixed $value): mixed + { + if (is_object($value)) { + $value = get_object_vars($value); + } + + return is_array($value) ? array_map([self::class, 'plain'], $value) : $value; + } +} +``` + +- [ ] **Step 4b.4: GREEN-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableBookTest` +Expected: `OK (15 tests, 64 assertions)`. + +- [ ] **Step 4b.5: Lint** + +Run: `composer lint` +Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen). + +- [ ] **Step 4b.6: Commit** + +```bash +git add src/Integrations/Divi/Design/VariableBook.php tests/Unit/Integrations/Divi/Design/VariableBookTest.php +git commit -m "feat: VariableBook als reiner Variablenbestand mit Lese-/Speicherform, Archivierung statt Löschen und Restore-Vereinigung" \ + -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +#### Task 4c: `VariableDiff` – Änderungsliste und Zusammenfassung + +- [ ] **Step 4c.1: Failing Test `VariableDiffTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/VariableDiffTest.php`. Nutzt `VariableBookTest::stored()` aus 4b. + +```php +with('numbers', 'gvid-f6yq6mayfy', [ + 'label' => 'Eckenradius', 'value' => '8px', 'order' => '1', 'lastUpdated' => '2030-01-01T00:00:00.000Z', + ]); + + self::assertSame([], VariableDiff::between($book, VariableBook::fromDivi(VariableBookTest::stored()))); + self::assertSame([], VariableDiff::between($book, $touched)); + } + + public function testAddedChangedArchivedAndActivated(): void + { + $before = VariableBook::fromDivi(VariableBookTest::stored()); + $after = $before + ->with('fonts', 'gvid-new0000001', ['label' => 'Neu', 'value' => 'Lato', 'order' => '2']) + ->with('numbers', 'gvid-f6yq6mayfy', ['label' => 'Radius', 'value' => '12px', 'order' => '1']) + ->with('numbers', 'gvid-5dec72fwsc', ['label' => 'Abstand Karte', 'value' => '2rem', 'order' => '2', + 'status' => 'archived']) + ->with('numbers', 'gvid-old0000001', ['label' => 'Alt', 'value' => '1px', 'order' => '3']); + + self::assertSame([ + ['type' => 'fonts', 'id' => 'gvid-new0000001', 'name' => 'Neu', 'action' => 'added'], + ['type' => 'numbers', 'id' => 'gvid-5dec72fwsc', 'name' => 'Abstand Karte', 'action' => 'archived'], + ['type' => 'numbers', 'id' => 'gvid-f6yq6mayfy', 'name' => 'Radius', 'action' => 'changed', + 'fields' => ['label', 'value']], + ['type' => 'numbers', 'id' => 'gvid-old0000001', 'name' => 'Alt', 'action' => 'activated'], + ], VariableDiff::between($before, $after)); + } + + public function testArchivingClearsTheOrderWithoutAFieldEntryButLabelAndValueChangesShow(): void + { + $before = VariableBook::fromDivi(VariableBookTest::stored()); + $archived = $before->with('numbers', 'gvid-5dec72fwsc', [ + 'label' => 'Abstand Karte', 'value' => '2rem', 'order' => '', 'status' => 'archived', + ]); + $archivedAndRenamed = $before->with('numbers', 'gvid-5dec72fwsc', [ + 'label' => 'Abstand', 'value' => '2rem', 'order' => '', 'status' => 'archived', + ]); + + self::assertSame( + [['type' => 'numbers', 'id' => 'gvid-5dec72fwsc', 'name' => 'Abstand Karte', 'action' => 'archived']], + VariableDiff::between($before, $archived) + ); + self::assertSame( + [['type' => 'numbers', 'id' => 'gvid-5dec72fwsc', 'name' => 'Abstand', 'action' => 'archived', + 'fields' => ['label']]], + VariableDiff::between($before, $archivedAndRenamed) + ); + } + + public function testReorderIsAChangeOfTheOrder(): void + { + $before = VariableBook::fromDivi(VariableBookTest::stored()); + $after = $before->with('numbers', 'gvid-5dec72fwsc', [ + 'label' => 'Abstand Karte', 'value' => '2rem', 'order' => '1', + ]); + + self::assertSame( + [['type' => 'numbers', 'id' => 'gvid-5dec72fwsc', 'name' => 'Abstand Karte', 'action' => 'changed', + 'fields' => ['order']]], + VariableDiff::between($before, $after) + ); + } + + public function testGradientsAreComparedLikeEveryOtherType(): void + { + $before = VariableBook::fromDivi(VariableBookTest::stored()); + $after = $before->with('gradients', 'gvid-grad000001', [ + 'label' => 'Verlauf', 'value' => 'linear-gradient(blue, red)', 'order' => '1', + ]); + + self::assertSame( + [['type' => 'gradients', 'id' => 'gvid-grad000001', 'name' => 'Verlauf', 'action' => 'changed', + 'fields' => ['value']]], + VariableDiff::between($before, $after) + ); + } + + public function testSummaryCountsTheActions(): void + { + self::assertSame( + 'Variablen: 1 angelegt, 1 geändert, 1 archiviert.', + VariableDiff::summary([['action' => 'added'], ['action' => 'changed'], ['action' => 'archived']]) + ); + self::assertSame( + 'Variablen: 2 geändert, 1 aktiviert.', + VariableDiff::summary([['action' => 'activated'], ['action' => 'changed'], ['action' => 'changed']]) + ); + self::assertSame('Variablen: keine Änderung.', VariableDiff::summary([])); + } +} +``` + +- [ ] **Step 4c.2: RED-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableDiffTest` +Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\VariableDiff" not found`. + +- [ ] **Step 4c.3: `VariableDiff` implementieren** + +Datei `src/Integrations/Divi/Design/VariableDiff.php`. Reines PHP; sortiert nach Typ, dann ID. + +```php +}> + * action: added|changed|archived|activated + */ + public static function between(VariableBook $before, VariableBook $after): array + { + $new = $after->data(); + ksort($new); + $rows = []; + foreach ($new as $type => $entries) { + ksort($entries); + foreach ($entries as $id => $is) { + $id = (string) $id; + $was = $before->entry($type, $id); + $row = ['type' => $type, 'id' => $id, 'name' => $is['label'] !== '' ? $is['label'] : $id]; + if ($was === null) { + $rows[] = $row + ['action' => 'added']; + continue; + } + $action = self::action((string) $was['status'], (string) $is['status']); + // A status switch is the action itself; archiving clears the order, which is no change of its own. + $compared = $action === 'changed' ? self::COMPARED : array_slice(self::COMPARED, 0, 2); + $fields = array_values(array_filter( + $compared, + static fn (string $key): bool => $was[$key] !== $is[$key] + )); + if ($fields !== [] || $action !== 'changed') { + $rows[] = $row + ['action' => $action] + ($fields === [] ? [] : ['fields' => $fields]); + } + } + } + + return $rows; + } + + /** + * One German sentence for the dry run and the change log (spec §4 step 6). + * + * @param list> $diff + */ + public static function summary(array $diff): string + { + $count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff)); + $words = [ + 'added' => 'angelegt', 'changed' => 'geändert', 'archived' => 'archiviert', 'activated' => 'aktiviert', + ]; + $parts = []; + foreach ($words as $action => $word) { + if (isset($count[$action])) { + $parts[] = $count[$action] . ' ' . $word; + } + } + + return 'Variablen: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.'; + } + + private static function action(string $was, string $is): string + { + return match (true) { + $was === $is => 'changed', + $is === 'archived' => 'archived', + $was === 'archived' && $is === 'active' => 'activated', + default => 'changed', + }; + } +} +``` + +- [ ] **Step 4c.4: GREEN-Lauf** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableDiffTest` +Expected: `OK (6 tests, 10 assertions)`. + +- [ ] **Step 4c.5: Lint** + +Run: `composer lint` +Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen). + +- [ ] **Step 4c.6: Commit** + +```bash +git add src/Integrations/Divi/Design/VariableDiff.php tests/Unit/Integrations/Divi/Design/VariableDiffTest.php +git commit -m "feat: VariableDiff beschreibt Variablenänderungen (angelegt, geändert, archiviert, aktiviert) samt Zusammenfassung" \ + -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +#### Task 4 – Abschluss + +- [ ] **Step 4.7: Gesamtlauf** + +Run: `composer test && composer lint` +Expected: alle Unit-Tests grün (2930 + 68 neue = 2998), Lint ohne Meldung. + +--- + + +### Task 5: `ReferenceUsageQuery`, `PresetBook::referencing()` und `PresetBook::withVariableResolved()` + +**Files:** +- Create: `src/Integrations/Divi/Design/ReferenceUsageQuery.php` +- Modify: `src/Integrations/Divi/Design/PresetBook.php` (Konstante `SETTINGS`, `referencing()`, `withVariableResolved()`) +- Test: `tests/Unit/Integrations/Divi/Design/ReferenceUsageQueryTest.php` (neu) +- Test: `tests/Unit/Integrations/Divi/Design/PresetBookTest.php` (ergänzt: `storedWithReferences()` und vier Tests) + +**Interfaces:** +- Consumes: `Library\UsageQuery` (`ESCAPED_LETTER_REGEX`), `BlockTree\Parser::parse()`, `BlockTree\Container::children()`, + `BlockTree\Block::attrs()/isOpaque()/rawOpen()`, `BlockTree\MalformedMarkup`, `Compose\VariableRef::nameOf()/settingsOf()/serialize()`. +- Produces: + - `final class ReferenceUsageQuery implements UsageQuery` mit + `public function __construct(array $ids, private Parser $parser = new Parser())` (`list` gcid-/gvid-IDs; leer → + `InvalidArgumentException`), `public const ESCAPED_HYPHEN_REGEX`, `public static function mentions(mixed $value, array $ids): bool` + (rekursiv: enthält ein String in `$value` eine der IDs). `matches()`: Rohinhalt nennt eine ID (auch Divi-4-Shortcodes) **oder** die + dekodierten Attribute irgendeines Blocks (auch in globalen Modulen, rekursiv über den ganzen Baum) nennen eine ID **oder** ein Block hat + unlesbare Attribute **oder** der Parser lehnt das Markup ab → `true` (fail closed). + - `PresetBook::referencing(string $id): array` → `list` (`scope` = Modul- bzw. + Gruppenname wie in `PresetDiff`). + - `PresetBook::withVariableResolved(string $id, string $value, bool $affixes = false): self` – ganze Verweise + `$variable({"type":"content","value":{"name":"",…}})$` in `attrs`/`styleAttrs`/`renderAttrs` → `$value`; mit `$affixes` + `before . $value . after` aus den Einstellungen des Verweises (Task 8 übergibt `$variableType === 'strings'`, V9). Eingebettete + Verweise bleiben stehen. + +**Warum kein `NodeIndex`:** `NodeIndex::walk()` steigt nicht in opake Blöcke (globale Module) und nicht in Nicht-Divi-Blöcke ab; die +Fundstellensuche muss fail closed jeden Block sehen. `ReferenceUsageQuery` läuft deshalb selbst rekursiv über `Container::children()` des +geparsten `Document`. Ein `\u002d` (JSON-kodierter Bindestrich) nach `gcid`/`gvid` findet `LIKE` nicht – dafür das zweite `REGEXP` +(Muster wie `ModuleUsageQuery` für den Schrägstrich). + +#### 5a – `ReferenceUsageQuery` + +- [ ] **Step 1: Failing test schreiben** + +`tests/Unit/Integrations/Divi/Design/ReferenceUsageQueryTest.php`: + +```php +allows('esc_like')->andReturnUsing(static fn (string $text): string => $text); + $db->expects('prepare') + ->with( + '(post_content LIKE %s OR post_content LIKE %s OR post_content REGEXP %s OR post_content REGEXP %s)', + '%gcid-holz000001%', + '%gvid-radius0001%', + 'g[cv]id\\\\u002[dD]', + '\\\\u00[4-7][0-9a-fA-F]' + ) + ->andReturn('(prepared)'); + + $query = new ReferenceUsageQuery(['gcid-holz000001', 'gvid-radius0001', 'gcid-holz000001']); + + self::assertSame('(prepared)', $query->candidateCondition($db)); + } + + public function testNeedsAnId(): void + { + $this->expectException(InvalidArgumentException::class); + + new ReferenceUsageQuery(['']); + } + + public function testFindsAReferenceInTheAttributesOfAnyBlock(): void + { + $query = new ReferenceUsageQuery(['gcid-holz000001', 'gvid-radius0001']); + + self::assertTrue($query->matches('')); + self::assertTrue($query->matches('')); + self::assertFalse($query->matches('')); + self::assertFalse($query->matches('')); + self::assertFalse($query->matches('

Keine Verweise

')); + } + + public function testFindsAnIdWrittenWithJsonEscapesAlsoInsideGlobalModules(): void + { + $query = new ReferenceUsageQuery(['gcid-holz000001']); + + self::assertTrue($query->matches('')); + self::assertTrue($query->matches('' + . '')); + self::assertFalse($query->matches('')); + } + + public function testUnreadableMarkupAndDivi4ContentCount(): void + { + $query = new ReferenceUsageQuery(['gcid-holz000001']); + + self::assertTrue($query->matches(''), 'never closed'); + self::assertTrue($query->matches(''), 'attrs no JSON'); + self::assertTrue($query->matches('[et_pb_text background_color="gcid-holz000001"]x[/et_pb_text]'), 'Divi 4'); + } + + public function testMentionsLooksIntoNestedStrings(): void + { + self::assertTrue(ReferenceUsageQuery::mentions(['a' => ['b' => 'x gvid-radius0001 y']], ['gvid-radius0001'])); + self::assertFalse(ReferenceUsageQuery::mentions(['gvid-radius0001' => 1], ['gvid-radius0001']), 'keys'); + self::assertFalse(ReferenceUsageQuery::mentions(12, ['gvid-radius0001'])); + } +} +``` + +- [ ] **Step 2: Test laufen lassen – er schlägt fehl** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ReferenceUsageQueryTest` +Expected: FAIL mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery" not found`. + +- [ ] **Step 3: `ReferenceUsageQuery` implementieren** + +`src/Integrations/Divi/Design/ReferenceUsageQuery.php`: + +```php +",…}})$ into block attributes, the + * quotes JSON-escaped in post_content (formats §1, §2). Fail closed (ruling P23): candidates contain an ID, a + * JSON-escaped hyphen after gcid/gvid or an escaped letter; a candidate matches when its raw content names an ID + * (also Divi 4 shortcodes), when the decoded attributes of any block do – the whole tree, also inside global modules, + * which NodeIndex does not descend into –, when a block's attributes cannot be read or when the parser rejects the + * markup. + */ +final class ReferenceUsageQuery implements UsageQuery +{ + /** MySQL REGEXP for "gcid"/"gvid" followed by a JSON-escaped hyphen ("\\u002d"), which LIKE does not find. */ + public const ESCAPED_HYPHEN_REGEX = 'g[cv]id\\\\u002[dD]'; + + /** @var list */ + private array $ids; + + /** + * @param list $ids gcid-/gvid- IDs. + * @throws InvalidArgumentException Without an ID. + */ + public function __construct(array $ids, private Parser $parser = new Parser()) + { + $ids = array_values(array_unique(array_filter( + $ids, + static fn (mixed $id): bool => is_string($id) && $id !== '' + ))); + if ($ids === []) { + throw new InvalidArgumentException('ReferenceUsageQuery needs at least one ID.'); + } + $this->ids = $ids; + } + + /** + * Whether a string inside $value (nested arrays included, keys not) contains one of $ids. + * + * @param list $ids + */ + public static function mentions(mixed $value, array $ids): bool + { + if (is_string($value)) { + foreach ($ids as $id) { + if (str_contains($value, $id)) { + return true; + } + } + + return false; + } + if (is_array($value)) { + foreach ($value as $inner) { + if (self::mentions($inner, $ids)) { + return true; + } + } + } + + return false; + } + + public function candidateCondition(\wpdb $db): string + { + $likes = array_map(static fn (string $id): string => '%' . $db->esc_like($id) . '%', $this->ids); + + return (string) $db->prepare( + '(' . str_repeat('post_content LIKE %s OR ', count($likes)) + . 'post_content REGEXP %s OR post_content REGEXP %s)', + ...array_merge($likes, [self::ESCAPED_HYPHEN_REGEX, self::ESCAPED_LETTER_REGEX]) + ); + } + + public function matches(string $content): bool + { + if (self::mentions($content, $this->ids)) { + return true; + } + + try { + return $this->inBlocks($this->parser->parse($content)); + } catch (MalformedMarkup) { + return true; + } + } + + private function inBlocks(Container $container): bool + { + foreach ($container->children() as $block) { + // The parser keeps attributes that are no JSON object as [] on an opaque block: unreadable, so it counts. + $unreadable = $block->isOpaque() && $block->attrs() === [] && str_contains((string) $block->rawOpen(), '{'); + if ($unreadable || self::mentions($block->attrs(), $this->ids) || $this->inBlocks($block)) { + return true; + } + } + + return false; + } +} +``` + +- [ ] **Step 4: Test laufen lassen – er besteht** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ReferenceUsageQueryTest` +Expected: PASS (6 Tests). + +- [ ] **Step 5: Lint und Commit** + +Run: `composer lint` +Expected: keine Meldungen. + +```bash +git add src/Integrations/Divi/Design/ReferenceUsageQuery.php tests/Unit/Integrations/Divi/Design/ReferenceUsageQueryTest.php +git commit -m "feat: ReferenceUsageQuery findet Seiten, die globale Farben oder Variablen nennen (fail closed)" -m "Co-Authored-By: Claude " +``` + +#### 5b – `PresetBook::referencing()` und `withVariableResolved()` + +- [ ] **Step 6: Failing tests schreiben** + +In `tests/Unit/Integrations/Divi/Design/PresetBookTest.php` den Import ergänzen (alphabetisch nach `InvalidArgumentException`): + +```php +use LIT\MCP\Integrations\Divi\Compose\VariableRef; +``` + +und direkt nach `storedWithMarkup()` einfügen: + +```php + /** + * stored() with references: CTA primär uses color gcid-holz000001, Doppelt variable gvid-slogan0001 (whole with + * before/after, and embedded in a longer value), Lesetext variable gvid-radius0001 in its styleAttrs. + * + * @return array + */ + public static function storedWithReferences(): array + { + $stored = self::stored(); + $stored['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = [ + 'button' => ['decoration' => ['background' => ['desktop' => ['value' => [ + 'color' => VariableRef::serialize('color', 'gcid-holz000001', []), + ]]]]], + ]; + $stored['module']['divi/button']['items']['bbbbbbbbbb']['attrs'] = [ + 'button' => ['innerContent' => ['desktop' => ['value' => [ + 'text' => VariableRef::serialize('content', 'gvid-slogan0001', ['before' => 'Ab ', 'after' => '!']), + ]]]], + 'module' => ['meta' => ['adminLabel' => ['desktop' => [ + 'value' => 'Vorher ' . VariableRef::serialize('content', 'gvid-slogan0001', []), + ]]]], + ]; + $stored['group']['divi/font-body']['items']['gggggggggg']['styleAttrs'] = [ + 'font' => ['size' => VariableRef::serialize('content', 'gvid-radius0001', [])], + ]; + + return $stored; + } + + public function testReferencingListsThePresetsThatNameAColorOrVariable(): void + { + $book = PresetBook::fromDivi(self::storedWithReferences()); + + self::assertSame( + [['id' => 'aaaaaaaaaa', 'name' => 'CTA primär', 'scope' => 'divi/button']], + $book->referencing('gcid-holz000001') + ); + self::assertSame( + [['id' => 'bbbbbbbbbb', 'name' => 'Doppelt', 'scope' => 'divi/button']], + $book->referencing('gvid-slogan0001') + ); + self::assertSame( + [['id' => 'gggggggggg', 'name' => 'Lesetext', 'scope' => 'divi/font-body']], + $book->referencing('gvid-radius0001') + ); + self::assertSame([], $book->referencing('gvid-fehlt00001')); + } + + public function testWithVariableResolvedReplacesWholeReferencesOnly(): void + { + $stored = self::storedWithReferences(); + $button = new PresetScope('module', 'divi/button'); + $book = PresetBook::fromDivi($stored)->withVariableResolved('gvid-slogan0001', 'Holz mit Herz'); + $attrs = $book->items($button)['bbbbbbbbbb']['attrs']; + + self::assertSame('Holz mit Herz', $attrs['button']['innerContent']['desktop']['value']['text']); + self::assertSame( + $stored['module']['divi/button']['items']['bbbbbbbbbb']['attrs']['module'], + $attrs['module'], + 'an embedded reference stays' + ); + self::assertSame($stored['module']['divi/button']['items']['aaaaaaaaaa'], $book->items($button)['aaaaaaaaaa']); + } + + /** Ruling V9: a strings variable keeps the before/after of the reference. */ + public function testWithVariableResolvedFramesTheValueWithBeforeAndAfter(): void + { + $book = PresetBook::fromDivi(self::storedWithReferences()) + ->withVariableResolved('gvid-slogan0001', 'Holz mit Herz', true); + + self::assertSame( + 'Ab Holz mit Herz!', + $book->items(new PresetScope('module', 'divi/button'))['bbbbbbbbbb']['attrs']['button']['innerContent'] + ['desktop']['value']['text'] + ); + } + + public function testWithVariableResolvedReachesStyleAttrsAndLeavesOtherPresetsAlone(): void + { + $stored = self::storedWithReferences(); + $book = PresetBook::fromDivi($stored)->withVariableResolved('gvid-radius0001', '8px'); + + self::assertSame( + ['font' => ['size' => '8px']], + $book->items(new PresetScope('group', 'divi/font-body'))['gggggggggg']['styleAttrs'] + ); + self::assertSame( + PresetBook::fromDivi($stored)->items(new PresetScope('module', 'divi/button')), + $book->items(new PresetScope('module', 'divi/button')) + ); + self::assertSame( + PresetBook::fromDivi($stored)->data(), + PresetBook::fromDivi($stored)->withVariableResolved('gvid-fehlt00001', 'x')->data() + ); + } +``` + +- [ ] **Step 7: Tests laufen lassen – sie schlagen fehl** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter PresetBookTest` +Expected: FAIL mit `Error: Call to undefined method LIT\MCP\Integrations\Divi\Design\PresetBook::referencing()` bzw. +`…PresetBook::withVariableResolved()` (4 Fehler); die übrigen PresetBook-Tests bestehen. + +- [ ] **Step 8: `PresetBook` erweitern** + +In `src/Integrations/Divi/Design/PresetBook.php`: + +1. Import ergänzen (nach `use InvalidArgumentException;`): + +```php +use LIT\MCP\Integrations\Divi\Compose\VariableRef; +``` + +2. Nach `private const ALPHABET = …;` einfügen: + +```php + + /** The keys of a preset that hold its settings (formats §3). */ + private const SETTINGS = ['attrs', 'styleAttrs', 'renderAttrs']; +``` + +3. Direkt nach der Methode `withoutNestedRef()` einfügen: + +```php + /** + * Presets whose settings (attrs, styleAttrs, renderAttrs) name the global color or variable $id – as a whole + * reference or inside a longer value (ruling V8: warnings of lit/divi-colors-set and lit/divi-variables-set). + * + * @return list scope = module or group name + */ + public function referencing(string $id): array + { + $users = []; + foreach ($this->data as $records) { + foreach ($records as $scope => $record) { + foreach ($record['items'] as $presetId => $item) { + if (ReferenceUsageQuery::mentions(array_intersect_key($item, array_flip(self::SETTINGS)), [$id])) { + $users[] = [ + 'id' => (string) $presetId, + 'name' => is_string($item['name'] ?? null) ? $item['name'] : (string) $presetId, + 'scope' => (string) $scope, + ]; + } + } + } + } + + return $users; + } + + /** + * The inventory with every whole reference to global variable $id in the presets' settings replaced by $value + * (ruling V9, lit/divi-design-detach with variable). With $affixes the reference's own before/after settings frame + * the value (variables of type strings). References inside a longer value stay. Presets without a reference stay + * exactly as they are. + */ + public function withVariableResolved(string $id, string $value, bool $affixes = false): self + { + $data = $this->data; + foreach ($this->data as $kind => $records) { + foreach ($records as $name => $record) { + foreach ($record['items'] as $presetId => $item) { + foreach (self::SETTINGS as $key) { + if (! is_array($item[$key] ?? null)) { + continue; + } + $resolved = self::resolved($item[$key], $id, $value, $affixes); + if ($resolved !== $item[$key]) { + $data[$kind][$name]['items'][$presetId][$key] = $resolved; + } + } + } + } + } + + return new self($data); + } +``` + +4. Direkt vor der Methode `hasMarkup()` einfügen: + +```php + /** + * @param array $settings + * @return array + */ + private static function resolved(array $settings, string $id, string $value, bool $affixes): array + { + foreach ($settings as $key => $inner) { + if (is_array($inner)) { + $settings[$key] = self::resolved($inner, $id, $value, $affixes); + } elseif (is_string($inner) && VariableRef::nameOf($inner) === $id) { + $own = $affixes ? VariableRef::settingsOf($inner) : []; + $settings[$key] = self::text($own['before'] ?? '') . $value . self::text($own['after'] ?? ''); + } + } + + return $settings; + } + + private static function text(mixed $value): string + { + return is_string($value) ? $value : ''; + } +``` + +- [ ] **Step 9: Tests laufen lassen – sie bestehen** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'PresetBookTest|ReferenceUsageQueryTest'` +Expected: PASS. + +- [ ] **Step 10: Lint und Commit** + +Run: `composer lint` +Expected: keine Meldungen. + +```bash +git add src/Integrations/Divi/Design/PresetBook.php tests/Unit/Integrations/Divi/Design/PresetBookTest.php +git commit -m "feat: PresetBook nennt Presets mit Verweisen auf Farben/Variablen und ersetzt Variablen-Verweise durch ihren Wert" -m "Co-Authored-By: Claude " +``` + +- [ ] **Step 11: Gesamtlauf** + +Run: `composer test && composer lint` +Expected: alle Tests grün, Lint sauber. + +--- + +### Task 6: `ColorArea` und `lit/divi-colors-set` + +**Files:** +- Create: `src/Integrations/Divi/Design/ColorArea.php` +- Create: `src/Integrations/Divi/Design/DiviColorsSetAbility.php` +- Test: `tests/Unit/Integrations/Divi/Design/ColorAreaTest.php` (neu, mit den Fixtures `stored()` und `adapter()`) +- Test: `tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php` (neu) + +**Interfaces:** +- Consumes: `GlobalColorsAdapter::read()/save(array $entries, bool $replace)` (Task 2; in Tests per Mockery gemockt – die Klasse darf + darum nicht `final` sein, siehe ABWEICHUNGEN), `DesignRights::colors(DesignSystemWriter)` (Task 2), `DiviFeature::ColorWrite` + (Task 1), `ColorBook::fromDivi()/data()/rev()/entry()/label()/isCustomizer()/find()/with()/referencing()/cycle()/changedSince()/ + newId(?Closure)/now()`, `ColorValue::check()/relative()`, `ColorDiff::between()/summary()` (Task 3), `ReferenceUsageQuery`, + `PresetBook::referencing()` (Task 5), `DesignChange::run()/inputProperties()/outputSchema()`, `DesignPlan`, `GlobalUsages::reportFor()`, + `UsageReport`, `DesignSystemWriter::presets()/canUseVisualBuilder()`, `VariableRef::serialize()`, `Errors`, `Annotations`. +- Produces: + - `final class ColorArea implements DesignArea { public function __construct(private GlobalColorsAdapter $colors) }` – `name()` + `'colors'`, `label()` `'Farben'`, `feature()` `DiviFeature::ColorWrite`; `write($raw, false)` speichert nur + `ColorBook::fromDivi($raw)->changedSince()` über `save($changed, false)` (nichts, wenn leer); `write($raw, true)` und + `restore($raw)` speichern den ganzen Bestand über `save($data, true)`; beide liefern den zurückgelesenen Bestand. + - `final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures` mit + `__construct(DesignSystemWriter $writer, GlobalColorsAdapter $colors, DesignChange $change, GlobalUsages $usages, ?Closure $random = null)` + (Task 10 verdrahtet ohne `$random`); `public const MAX_ITEMS = 50`. + +**Eingabe `items[]` von `lit/divi-colors-set`:** `id` (vorhandene Farbe: ID, Name, Alias `primary|secondary|heading|body|link` oder +Optionsname `accent_color …`, über `ColorBook::find()`), ohne `id` neue Farbe mit Pflichtfeldern `label` und `color`; `label` (1–100 +Zeichen, frei – nicht schon Name, ID oder Alias einer anderen Farbe), `color` (V5), `relative` (nur mit Platzhalter, dann Pflicht), +`folder` (bis 100 Zeichen, nicht `customizer`), `status` (`active|inactive`). Hauptfarben: nur `color`, kein Platzhalter (Divi speichert sie +als festen Wert in `et_divi`). Ein Eintrag ohne Wirkung ändert nichts (kein neues `lastUpdated`). + +#### 6a – `ColorArea` + +- [ ] **Step 1: Failing test schreiben** + +`tests/Unit/Integrations/Divi/Design/ColorAreaTest.php`: + +```php +> + */ + public static function stored(): array + { + return [ + 'gcid-primary-color' => ['color' => '#2ea3f2', 'folder' => 'customizer', 'label' => 'Primärfarbe', + 'lastUpdated' => '2026-10-19T08:00:00.000Z', 'status' => 'active', 'usedInPosts' => []], + 'gcid-holz000001' => ['color' => '#5b3a29', 'folder' => '', 'label' => 'Holz dunkel', + 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'status' => 'active', 'usedInPosts' => []], + 'gcid-sand000002' => ['color' => VariableRef::serialize('color', 'gcid-holz000001', ['lightness' => 40]), + 'folder' => '', 'label' => 'Sand', 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'status' => 'active', + 'usedInPosts' => []], + ]; + } + + /** + * A GlobalColorsAdapter over $stored: save() merges (replace false) or replaces (true) like set_global_colors() + * and notes each call in $saves. + * + * @param array> $stored + * @param list, 1: bool}> $saves + */ + public static function adapter(array &$stored, array &$saves): GlobalColorsAdapter + { + $colors = Mockery::mock(GlobalColorsAdapter::class); + $colors->allows('read')->andReturnUsing(static function () use (&$stored): array { + return $stored; + }); + $colors->allows('save')->andReturnUsing( + static function (array $entries, bool $replace) use (&$stored, &$saves): void { + $saves[] = [$entries, $replace]; + $stored = $replace ? $entries : array_merge($stored, $entries); + } + ); + + return $colors; + } + + /** + * @param array> $stored + * @param array $fields + * @return array> + */ + private static function changed(array $stored, string $id, array $fields): array + { + $stored[$id] = $fields + $stored[$id]; + + return $stored; + } + + /** @return array */ + private static function white(): array + { + return ['color' => '#ffffff', 'folder' => '', 'label' => 'Weiß', 'lastUpdated' => '2026-10-19T09:00:00.000Z', + 'status' => 'active', 'usedInPosts' => []]; + } + + public function testAreaReadsThroughTheAdapter(): void + { + $stored = self::stored(); + $saves = []; + $area = new ColorArea(self::adapter($stored, $saves)); + + self::assertSame(['colors', 'Farben'], [$area->name(), $area->label()]); + self::assertSame(DiviFeature::ColorWrite, $area->feature()); + self::assertSame(ColorBook::fromDivi($stored)->data(), $area->raw()); + self::assertSame(ColorBook::fromDivi($stored)->rev(), $area->rev($stored)); + } + + /** Ruling V4: only new and changed colors are merged; the unchanged main color is not written again. */ + public function testWriteSavesOnlyNewAndChangedColorsAndReadsBack(): void + { + $stored = self::stored(); + $saves = []; + $area = new ColorArea(self::adapter($stored, $saves)); + $planned = self::changed(self::stored(), 'gcid-holz000001', ['color' => '#000000']); + $planned['gcid-neu0000003'] = self::white(); + + $saved = $area->write($planned, false); + + self::assertCount(1, $saves); + self::assertFalse($saves[0][1], 'merge, never replace'); + $keys = array_keys($saves[0][0]); + sort($keys); + self::assertSame(['gcid-holz000001', 'gcid-neu0000003'], $keys); + self::assertSame('#000000', $saved['gcid-holz000001']['color']); + self::assertSame(ColorBook::fromDivi($stored)->data(), $saved); + } + + public function testWriteWithoutChangesSavesNothing(): void + { + $stored = self::stored(); + $saves = []; + + (new ColorArea(self::adapter($stored, $saves)))->write(self::stored(), false); + + self::assertSame([], $saves); + } + + /** Ruling V4: a restore (and a rollback) replaces all stored colors with the snapshot. */ + public function testRestoreReplacesAllColorsWithTheSnapshot(): void + { + $stored = self::changed(self::stored(), 'gcid-holz000001', ['color' => '#000000']); + $stored['gcid-neu0000003'] = self::white(); + $saves = []; + $area = new ColorArea(self::adapter($stored, $saves)); + $snapshot = ColorBook::fromDivi(self::stored())->data(); + + $saved = $area->restore($snapshot); + + self::assertSame([[$snapshot, true]], $saves); + self::assertSame($snapshot, $saved); + } + + public function testVerifyInsistsOnTheSameColorsWithTheSameStatus(): void + { + $stored = self::stored(); + $saves = []; + $area = new ColorArea(self::adapter($stored, $saves)); + $expected = self::stored(); + $missing = $expected; + unset($missing['gcid-sand000002']); + + self::assertTrue($area->verify($expected, self::changed($expected, 'gcid-holz000001', ['color' => '#5B3A29']))); + self::assertFalse($area->verify($expected, $missing)); + $inactive = self::changed($expected, 'gcid-holz000001', ['status' => 'inactive']); + self::assertFalse($area->verify($expected, $inactive)); + } + + public function testDifferencesNameTheColorsDiviChanged(): void + { + $stored = self::stored(); + $saves = []; + $area = new ColorArea(self::adapter($stored, $saves)); + $expected = self::stored(); + + self::assertSame([], $area->differences($expected, $expected)); + self::assertSame( + ['Divi hat die Farbe „Holz dunkel“ beim Speichern bereinigt.'], + $area->differences($expected, self::changed($expected, 'gcid-holz000001', ['color' => '#5B3A29'])) + ); + } +} +``` + +- [ ] **Step 2: Test laufen lassen – er schlägt fehl** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorAreaTest` +Expected: FAIL mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ColorArea" not found`. + +- [ ] **Step 3: `ColorArea` implementieren** + +`src/Integrations/Divi/Design/ColorArea.php`: + +```php +colors->read())->data(); + } + + public function rev(array $raw): string + { + return ColorBook::fromDivi($raw)->rev(); + } + + public function write(array $raw, bool $mayRemove): array + { + $planned = ColorBook::fromDivi($raw); + if ($mayRemove) { + $this->colors->save($planned->data(), true); + + return $this->raw(); + } + + $changed = $planned->changedSince(ColorBook::fromDivi($this->colors->read())); + if ($changed !== []) { + $this->colors->save($changed, false); + } + + return $this->raw(); + } + + public function restore(array $raw): array + { + return $this->write($raw, true); + } + + public function verify(array $expected, array $actual): bool + { + return self::statuses($expected) === self::statuses($actual); + } + + public function differences(array $expected, array $actual): array + { + $warnings = []; + foreach (ColorDiff::between(ColorBook::fromDivi($expected), ColorBook::fromDivi($actual)) as $row) { + if ($row['action'] === 'changed') { + $warnings[] = sprintf('Divi hat die Farbe „%s“ beim Speichern bereinigt.', $row['name']); + } + } + + return $warnings; + } + + /** + * @param array $raw + * @return array ID => status, sorted by ID + */ + private static function statuses(array $raw): array + { + $statuses = array_map( + static fn (array $entry): string => (string) $entry['status'], + ColorBook::fromDivi($raw)->data() + ); + ksort($statuses); + + return $statuses; + } +} +``` + +- [ ] **Step 4: Test laufen lassen – er besteht** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorAreaTest` +Expected: PASS (6 Tests). + +- [ ] **Step 5: Lint und Commit** + +Run: `composer lint` +Expected: keine Meldungen. + +```bash +git add src/Integrations/Divi/Design/ColorArea.php tests/Unit/Integrations/Divi/Design/ColorAreaTest.php +git commit -m "feat: ColorArea schreibt geänderte globale Farben gemischt und stellt Snapshots ersetzend wieder her" -m "Co-Authored-By: Claude " +``` + +#### 6b – `lit/divi-colors-set`: Hülle, Pipeline, feste Farbwerte, Hauptfarben + +- [ ] **Step 6: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php`: + +```php +> What the adapter stores (ColorAreaTest::adapter()). */ + private array $stored = []; + + /** @var list, 1: bool}> */ + private array $saves = []; + + protected function setUp(): void + { + parent::setUp(); + // DesignLock (final review I-5): the lock is always free here. + Functions\when('add_option')->justReturn(true); + Functions\when('delete_option')->justReturn(true); + Functions\when('current_user_can')->justReturn(true); + $this->stored = ColorAreaTest::stored(); + $this->saves = []; + } + + /** @param array $presets DesignSystemWriter::presets() ([] = PresetBookTest::stored()). */ + private function ability( + ?GlobalUsages $usages = null, + ?SnapshotStore $snapshots = null, + array $presets = [], + ?GlobalColorsAdapter $colors = null, + bool $visualBuilder = true + ): DiviColorsSetAbility { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn($visualBuilder); + $writer->allows('presets')->andReturn($presets === [] ? PresetBookTest::stored() : $presets); + $counter = 0; + + return new DiviColorsSetAbility( + $writer, + $colors ?? ColorAreaTest::adapter($this->stored, $this->saves), + new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)), + $usages ?? self::usages(self::unused()), + static function (int $length) use (&$counter): string { + return str_pad((string) ++$counter, $length, '0', STR_PAD_LEFT); + } + ); + } + + private static function usages(UsageReport $report): GlobalUsages + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn($report); + + return $usages; + } + + private static function unused(): UsageReport + { + return new UsageReport(0, [], 0, 0, [], 0, false); + } + + private static function used(): UsageReport + { + return new UsageReport(1, [['post_id' => 7, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']], + 0, 1, [], 0, false); + } + + /** A SnapshotStore that expects one snapshot of the stored colors and the log ID 3 attached to it. */ + private static function snapshots(): SnapshotStore + { + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save') + ->with('colors', ColorBook::fromDivi(ColorAreaTest::stored())->data(), 'lit/divi-colors-set') + ->andReturn('colors-1'); + $snapshots->expects('attachLog')->with('colors', 'colors-1', 3); + + return $snapshots; + } + + public function testSchemaAndMeta(): void + { + $ability = $this->ability(); + $properties = $ability->inputSchema()['properties']; + + self::assertSame('lit/divi-colors-set', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(Annotations::destructive(false), $ability->annotations()); + self::assertSame([DiviFeature::DesignRead, DiviFeature::ColorWrite], $ability->diviFeatures([])); + self::assertSame(DiviColorsSetAbility::MAX_ITEMS, $properties['items']['maxItems']); + self::assertSame(50, DiviColorsSetAbility::MAX_ITEMS); + self::assertArrayHasKey('design_rev', $properties); + self::assertArrayNotHasKey('delete', $properties['items']['items']['properties'], 'colors are deactivated'); + self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2)); + self::assertStringContainsString('confirm: true mit design_rev', $ability->description()); + self::assertStringContainsString('lit/divi-design-restore', $ability->description()); + } + + public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void + { + $input = ['items' => [['label' => 'Weiß', 'color' => '#ffffff']]]; + + self::assertToolError($this->ability(visualBuilder: false)->execute($input), 'forbidden'); + self::assertToolError($this->ability(visualBuilder: false)->permission($input), 'forbidden'); + + Functions\when('current_user_can')->justReturn(false); + self::assertToolError($this->ability()->execute($input), 'forbidden'); + self::assertToolError($this->ability()->permission($input), 'forbidden'); + self::assertSame([], $this->saves); + } + + public function testItemsMustBeAListOfOneToFifty(): void + { + $tooMany = array_fill(0, 51, ['label' => 'X', 'color' => '#fff']); + foreach ([[], ['items' => []], ['items' => ['a' => []]], ['items' => $tooMany]] as $input) { + self::assertToolError($this->ability()->execute($input), 'validation_failed'); + } + } + + public function testDryRunThenConfirmCreatesAColor(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor')->never(); // ruling V8: a new color has no usages + $ability = $this->ability($usages, self::snapshots()); + $input = ['items' => [['label' => 'Weiß', 'color' => '#ffffff', 'folder' => 'Basis']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + self::assertSame(['added'], array_column($dry['diff'], 'action')); + self::assertSame('Weiß', $dry['diff'][0]['name']); + self::assertSame(ColorBook::fromDivi(ColorAreaTest::stored())->rev(), $dry['design_rev']); + self::assertSame([], $dry['affected']); + self::assertSame(0, $dry['affected_count']); + self::assertSame([], $dry['warnings']); + self::assertStringContainsString(ColorDiff::summary($dry['diff']), $dry['message']); + self::assertSame([], $this->saves, 'the dry run saves nothing'); + + $result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + self::assertSame('colors-1', $result['snapshot_id']); + $created = array_diff_key($this->stored, ColorAreaTest::stored()); + self::assertCount(1, $created); + self::assertMatchesRegularExpression('/^gcid-[0-9a-z]{10}$/D', (string) array_key_first($created)); + $entry = reset($created); + self::assertSame( + ['#ffffff', 'Basis', 'Weiß', 'active', []], + [$entry['color'], $entry['folder'], $entry['label'], $entry['status'], $entry['usedInPosts']] + ); + self::assertMatchesRegularExpression('/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/D', $entry['lastUpdated']); + self::assertFalse($this->saves[0][1], 'merged'); + self::assertSame(ColorBook::fromDivi($this->stored)->rev(), $result['design_rev']); + } + + public function testConfirmNeedsTheDesignRevOfTheDryRun(): void + { + $input = ['items' => [['id' => 'Holz dunkel', 'color' => '#000000']], 'confirm' => true]; + + self::assertToolError($this->ability()->execute($input), 'validation_failed'); + self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch'); + self::assertSame([], $this->saves); + } + + public function testChangingAColorWritesOnlyThatColorAndListsItsUsages(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor') + ->twice() // dry run and confirm + ->with(Mockery::on(static fn (ReferenceUsageQuery $query): bool + => $query->matches('') + && ! $query->matches(''))) + ->andReturn(self::used()); + $ability = $this->ability($usages, self::snapshots()); + $input = ['items' => [['id' => 'holz DUNKEL', 'color' => '#000000', 'label' => 'Holz schwarz']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + self::assertSame(['changed'], array_column($dry['diff'], 'action')); + self::assertSame('gcid-holz000001', $dry['diff'][0]['id']); + self::assertSame(7, $dry['affected'][0]['post_id']); + self::assertSame(1, $dry['affected_count']); + + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + self::assertSame(['gcid-holz000001'], array_keys($this->saves[0][0])); + self::assertSame( + ['#000000', 'Holz schwarz'], + [$this->stored['gcid-holz000001']['color'], $this->stored['gcid-holz000001']['label']] + ); + } + + public function testDeactivatingIsHowAColorIsRemoved(): void + { + $dry = self::assertToolError( + $this->ability()->execute(['items' => [['id' => 'gcid-holz000001', 'status' => 'inactive']]]), + 'confirm_required' + ); + + self::assertSame(['deactivated'], array_column($dry['diff'], 'action')); + } + + public function testAnItemThatChangesNothingIsNoChange(): void + { + $result = $this->ability()->execute(['items' => [['id' => 'Holz dunkel', 'color' => '#5b3a29']]]); + + self::assertSame([], $result['diff']); + self::assertStringStartsWith('Keine Änderung', $result['summary']); + self::assertSame([], $this->saves); + } + + /** Ruling V3: a main color is found by its option name and only its value is written. */ + public function testMainColorsChangeOnlyTheirValue(): void + { + $ability = $this->ability(null, self::snapshots()); + $input = ['items' => [['id' => 'accent_color', 'color' => '#123456']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + self::assertSame('gcid-primary-color', $dry['diff'][0]['id']); + + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + self::assertSame(['gcid-primary-color'], array_keys($this->saves[0][0])); + self::assertSame('#123456', $this->stored['gcid-primary-color']['color']); + } + + public function testMainColorsCannotBeRenamedMovedOrDeactivated(): void + { + $error = $this->ability()->execute(['items' => [ + ['id' => 'primary', 'label' => 'Haupt', 'folder' => 'Basis', 'status' => 'inactive'], + ]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame( + ['items[0].label', 'items[0].folder', 'items[0].status'], + array_keys(self::errorFields($error)) + ); + } + + public function testAllProblemsOfAllItemsAreCollected(): void + { + $error = $this->ability()->execute(['items' => [ + ['id' => 'Fehlt', 'color' => '#ffffff'], + ['label' => '', 'color' => 'kein Wert!'], + ['color' => '#ffffff'], + ['label' => 'Holz dunkel', 'color' => '#ffffff'], + ['id' => 'gcid-holz000001', 'folder' => ['x'], 'status' => 'gelöscht'], + 'kein Objekt', + ]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame( + ['items[0].id', 'items[1].label', 'items[1].color', 'items[2].label', 'items[3].label', 'items[4].folder', + 'items[4].status', 'items[5]'], + array_keys(self::errorFields($error)) + ); + self::assertSame([], $this->saves); + } + + public function testAFailedWriteIsRolledBack(): void + { + $calls = []; + $colors = Mockery::mock(GlobalColorsAdapter::class); + $colors->allows('read')->andReturn(ColorAreaTest::stored()); + $colors->allows('save')->andReturnUsing(static function (array $entries, bool $replace) use (&$calls): void { + $calls[] = $replace; + if (count($calls) === 1) { + throw new RuntimeException('Divi hat nichts gespeichert'); + } + }); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('colors-1'); + $ability = $this->ability(null, $snapshots, [], $colors); + $input = ['items' => [['id' => 'Holz dunkel', 'color' => '#000000']]]; + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + try { + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + self::fail('expected WriteFailed'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame('colors-1', $e->details['snapshot_id']); + } + self::assertSame([false, true], $calls, 'the merge failed, the snapshot replaced all colors'); + } +} +``` + +- [ ] **Step 7: Tests laufen lassen – sie schlagen fehl** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviColorsSetAbilityTest` +Expected: FAIL mit `Error: Class "LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility" not found`. + +- [ ] **Step 8: `DiviColorsSetAbility` implementieren (feste Farbwerte)** + +`src/Integrations/Divi/Design/DiviColorsSetAbility.php`: + +```php + '', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active', + 'usedInPosts' => []]; + + /** @param (Closure(int): string)|null $random Random characters for new IDs (ColorBook::newId(); tests). */ + public function __construct( + private DesignSystemWriter $writer, + private GlobalColorsAdapter $colors, + private DesignChange $change, + private GlobalUsages $usages, + private ?Closure $random = null + ) { + } + + public function name(): string + { + return 'lit/divi-colors-set'; + } + + public function label(): string + { + return 'Globale Divi-Farben anlegen und ändern'; + } + + public function description(): string + { + return 'Legt globale Farben an oder ändert sie. items: id nennt eine vorhandene Farbe (ID, Name, Alias ' + . 'primary, secondary, heading, body, link oder Optionsname wie accent_color); ohne id entsteht eine neue ' + . 'Farbe aus ' + . 'label und color. color: Hex, rgb()/rgba()/hsl()/hsla(), Farbname oder {{color:}} mit relative ' + . '(hue, saturation, lightness, opacity). status inactive deaktiviert (Farben werden nie gelöscht); die ' + . 'fünf Hauptfarben ändern nur color. Wirkt auf alle Seiten: zuerst Probelauf (diff, betroffene Seiten, ' + . 'Warnungen, design_rev), nach Rückfrage beim Nutzer confirm: true mit design_rev. Vorher wird ein ' + . 'Snapshot angelegt (lit/divi-design-restore).'; + } + + public function inputSchema(): array + { + $number = ['type' => 'number']; + + return [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_ITEMS, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Vorhandene Farbe: ID, Name, Alias oder Optionsname.', + ], + 'label' => ['type' => 'string', 'description' => 'Name einer neuen Farbe oder umbenennen.'], + 'color' => ['type' => 'string'], + 'relative' => [ + 'type' => 'object', + 'properties' => [ + 'hue' => $number, + 'saturation' => $number, + 'lightness' => $number, + 'opacity' => $number, + ], + ], + 'folder' => ['type' => 'string'], + 'status' => ['type' => 'string', 'description' => 'active oder inactive.'], + ], + ], + ], + ] + DesignChange::inputProperties(), + 'required' => ['items'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return DesignChange::outputSchema(); + } + + public function annotations(): array + { + return Annotations::destructive(false); + } + + public function isDirect(): bool + { + return false; + } + + /** Reads and writes the global colors; the warnings read the presets (DesignRead). */ + public function diviFeatures(array $input): array + { + return [DiviFeature::DesignRead, DiviFeature::ColorWrite]; + } + + public function permission(array $input): bool|WP_Error + { + return DesignRights::colors($this->writer) ?? true; + } + + public function execute(array $input): mixed + { + $refusal = DesignRights::colors($this->writer); + if ($refusal !== null) { + return $refusal; + } + $items = $input['items'] ?? null; + if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) { + return Errors::invalidInput( + 'items fehlt oder ist zu lang; nichts wurde gespeichert.', + ['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)] + ); + } + + return $this->change->run( + $this->name(), + new ColorArea($this->colors), + $input, + fn (array $raw): DesignPlan|WP_Error => $this->plan(ColorBook::fromDivi($raw), $items) + ); + } + + /** @param list $items */ + private function plan(ColorBook $before, array $items): DesignPlan|WP_Error + { + $book = $before; + $errors = []; + foreach ($items as $index => $item) { + $field = sprintf('items[%d]', $index); + if (! is_array($item)) { + $errors[$field] = 'Objekt erwartet'; + continue; + } + $book = $this->apply($book, $item, $field, $errors); + } + if ($errors !== []) { + return Errors::invalidInput('Ungültige Farb-Angaben; nichts wurde gespeichert.', $errors); + } + + $diff = ColorDiff::between($before, $book); + $touched = []; + foreach ($diff as $row) { + if ($row['action'] !== 'added') { + $touched[] = (string) $row['id']; + } + } + $touched = array_values(array_unique($touched)); + + return new DesignPlan( + $book->data(), + $diff, + ColorDiff::summary($diff), + $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched)) + ); + } + + /** + * $book with the color of $item created or changed; problems go to $errors and leave $book as it was. + * + * @param array $item + * @param array $errors + */ + private function apply(ColorBook $book, array $item, string $field, array &$errors): ColorBook + { + $known = count($errors); + $id = array_key_exists('id', $item) ? self::existing($book, $item['id'], $field . '.id', $errors) : null; + if (count($errors) > $known) { + return $book; + } + + $main = $id !== null && $book->isCustomizer($id); + $entry = $id === null ? self::NEW_ENTRY : (array) $book->entry($id); + foreach (['label', 'folder', 'status', 'color'] as $key) { + if (! array_key_exists($key, $item)) { + if ($id === null && ($key === 'label' || $key === 'color')) { + $errors[$field . '.' . $key] = 'Pflicht für eine neue Farbe'; + } + continue; + } + if ($main && $key !== 'color') { + $errors[$field . '.' . $key] = self::MAIN_COLOR_RULE; + continue; + } + try { + $entry[$key] = match ($key) { + 'label' => self::cleanLabel($book, $item['label'], $id), + 'folder' => self::folder($item['folder']), + 'status' => self::status($item['status']), + default => self::value($item), + }; + } catch (InvalidArgumentException $e) { + $errors[$field . '.' . $key] = $e->getMessage(); + } + } + if (count($errors) > $known || ($id !== null && $entry === $book->entry($id))) { + return $book; + } + + if (! $main) { + // Ruling V3: the main colors' lastUpdated stays "" (ColorBook ignores it). + $entry['lastUpdated'] = ColorBook::now(); + } + + return $book->with($id ?? $this->newId($book), $entry); + } + + /** @param array $errors */ + private static function existing(ColorBook $book, mixed $needle, string $field, array &$errors): ?string + { + if (! is_string($needle) || trim($needle) === '') { + $errors[$field] = 'ID, Name, Alias oder Optionsname der Farbe erwartet'; + + return null; + } + try { + $id = $book->find($needle); + } catch (InvalidArgumentException $e) { + $errors[$field] = $e->getMessage(); + + return null; + } + if ($id === null) { + $errors[$field] = sprintf('unbekannte Farbe „%s“', $needle); + } + + return $id; + } + + /** + * A label no other color has as name, ID or alias (it would make {{color:…}} ambiguous). + * + * @throws InvalidArgumentException + */ + private static function cleanLabel(ColorBook $book, mixed $label, ?string $id): string + { + $clean = is_string($label) && strlen($label) <= self::LABEL_MAX * 4 ? sanitize_text_field($label) : ''; + if ($clean === '' || mb_strlen($clean) > self::LABEL_MAX) { + throw new InvalidArgumentException(self::LABEL_RULE); + } + try { + $taken = $book->find($clean); + } catch (InvalidArgumentException) { + $taken = ''; // several colors share the name already + } + if ($taken !== null && $taken !== $id) { + throw new InvalidArgumentException(sprintf( + 'Name schon vergeben (%s); zum Ändern id angeben oder einen anderen Namen wählen', + $taken === '' ? 'mehrfach' : $taken + )); + } + + return $clean; + } + + /** @throws InvalidArgumentException */ + private static function folder(mixed $folder): string + { + $clean = is_string($folder) && strlen($folder) <= self::LABEL_MAX * 4 ? sanitize_text_field($folder) : null; + if ($clean === null || mb_strlen($clean) > self::LABEL_MAX || $clean === 'customizer') { + throw new InvalidArgumentException('Ordner: bis 100 Zeichen ohne HTML, nicht „customizer“'); + } + + return $clean; + } + + /** @throws InvalidArgumentException */ + private static function status(mixed $status): string + { + if (! is_string($status) || ! in_array($status, self::STATUSES, true)) { + throw new InvalidArgumentException('active oder inactive'); + } + + return $status; + } + + /** + * @param array $item + * @throws InvalidArgumentException + */ + private static function value(array $item): string + { + $color = $item['color']; + if (! is_string($color)) { + throw new InvalidArgumentException('Farbwert als Text erwartet'); + } + $color = trim($color); + $problem = ColorValue::check($color); + if ($problem !== null) { + throw new InvalidArgumentException($problem); + } + + return $color; + } + + private function newId(ColorBook $book): string + { + do { + $id = ColorBook::newId($this->random); + } while ($book->entry($id) !== null); + + return $id; + } +} +``` + +- [ ] **Step 9: Tests laufen lassen – sie bestehen** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviColorsSetAbilityTest|ColorAreaTest'` +Expected: PASS. + +- [ ] **Step 10: Lint und Commit** + +Run: `composer lint` +Expected: keine Meldungen. + +```bash +git add src/Integrations/Divi/Design/DiviColorsSetAbility.php tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php +git commit -m "feat: lit/divi-colors-set legt globale Farben an, ändert und deaktiviert sie (Probelauf, design_rev, Snapshot)" -m "Co-Authored-By: Claude " +``` + +#### 6c – Platzhalter, `relative`, Zyklen und Warnungen + +- [ ] **Step 11: Failing tests ergänzen** + +In `tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php` die Importe ergänzen (alphabetisch einsortieren): + +```php +use LIT\MCP\Integrations\Divi\Compose\VariableRef; +use LIT\MCP\Integrations\Divi\Design\ColorValue; +``` + +und vor der schließenden `}` der Klasse einfügen: + +```php + /** Ruling V5: {{color:…}} with relative is stored as Divi reference on the named color's ID. */ + public function testAPlaceholderBecomesARelativeColorOfTheNamedColor(): void + { + $ability = $this->ability(null, self::snapshots()); + $input = ['items' => [ + ['label' => 'Holz hell', 'color' => '{{color:Holz dunkel}}', 'relative' => ['lightness' => 30]], + ]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + $created = array_values(array_diff_key($this->stored, ColorAreaTest::stored())); + self::assertSame( + VariableRef::serialize('color', 'gcid-holz000001', ColorValue::relative(['lightness' => 30])), + $created[0]['color'] + ); + } + + /** Ruling V5: the placeholder is resolved against the planned inventory, also colors of the same call. */ + public function testAPlaceholderMayNameAColorOfTheSameCall(): void + { + $ability = $this->ability(null, self::snapshots()); + $input = ['items' => [ + ['label' => 'Basis', 'color' => '#112233'], + ['label' => 'Basis hell', 'color' => '{{color:basis}}', 'relative' => ['lightness' => 20]], + ]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + self::assertSame(['added', 'added'], array_column($dry['diff'], 'action')); + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + $byLabel = []; + foreach ($this->stored as $id => $entry) { + $byLabel[$entry['label']] = ['id' => $id] + $entry; + } + self::assertSame( + VariableRef::serialize('color', $byLabel['Basis']['id'], ColorValue::relative(['lightness' => 20])), + $byLabel['Basis hell']['color'] + ); + } + + public function testPlaceholderProblemsAreCollected(): void + { + $error = $this->ability()->execute(['items' => [ + ['label' => 'A', 'color' => '{{color:Fehlt}}', 'relative' => ['lightness' => 10]], + ['label' => 'B', 'color' => '#ffffff', 'relative' => ['lightness' => 10]], + ['id' => 'primary', 'color' => '{{color:Holz dunkel}}', 'relative' => ['lightness' => 10]], + ['label' => 'C', 'color' => '{{color:Holz dunkel}}', 'relative' => ['farbe' => 10]], + ['id' => 'Sand', 'relative' => ['lightness' => 10]], + ['label' => 'D', 'color' => VariableRef::serialize('color', 'gcid-holz000001', [])], + ['label' => 'E', 'color' => '{{color:Holz dunkel}}'], + ]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame( + ['items[0].color', 'items[1].relative', 'items[2].color', 'items[3].relative', 'items[4].relative', + 'items[5].color', 'items[6].relative'], + array_keys(self::errorFields($error)) + ); + } + + /** Ruling V5: Sand is built on Holz dunkel – Holz dunkel built on Sand would be a cycle. */ + public function testRelativeColorsMustNotFormACycle(): void + { + $error = $this->ability()->execute(['items' => [ + ['id' => 'Holz dunkel', 'color' => '{{color:Sand}}', 'relative' => ['lightness' => -40]], + ]]); + + self::assertToolError($error, 'validation_failed'); + $fields = self::errorFields($error); + self::assertSame(['items'], array_keys($fields)); + self::assertStringContainsString('Sand', $fields['items']); + self::assertSame([], $this->saves); + } + + /** Ruling V8: presets and relative colors built on a changed color are warnings, never a block. */ + public function testChangesWarnAboutPresetsAndRelativeColorsBuiltOnTheColor(): void + { + $dry = self::assertToolError( + $this->ability(null, null, PresetBookTest::storedWithReferences()) + ->execute(['items' => [['id' => 'Holz dunkel', 'color' => '#000000']]]), + 'confirm_required' + ); + + self::assertSame([ + 'Die Änderung an Farbe „Holz dunkel“ wirkt auch auf die Presets CTA primär.', + 'Die relativen Farben Sand bauen auf Farbe „Holz dunkel“ auf und ändern sich mit.', + ], $dry['warnings']); + } + + public function testDeactivatingAUsedColorWarnsAndListsTheUsages(): void + { + $dry = self::assertToolError( + $this->ability(self::usages(self::used()), null, PresetBookTest::storedWithReferences()) + ->execute(['items' => [['id' => 'Holz dunkel', 'status' => 'inactive']]]), + 'confirm_required' + ); + + self::assertSame(7, $dry['affected'][0]['post_id']); + self::assertSame([ + 'Farbe „Holz dunkel“ wird deaktiviert, aber noch von Presets verwendet: CTA primär.', + 'Farbe „Holz dunkel“ wird deaktiviert, aber die relativen Farben Sand bauen auf ihr auf.', + 'Die unter affected genannten Inhalte verweisen auf geänderte oder deaktivierte Farben; deaktivierte ' + . 'Farben bleiben dort als Verweis stehen.', + ], $dry['warnings']); + } + + public function testNewColorsHaveNoWarnings(): void + { + $dry = self::assertToolError( + $this->ability(null, null, PresetBookTest::storedWithReferences()) + ->execute(['items' => [['label' => 'Weiß', 'color' => 'white']]]), + 'confirm_required' + ); + + self::assertSame([], $dry['warnings']); + } +``` + +- [ ] **Step 12: Tests laufen lassen – sie schlagen fehl** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviColorsSetAbilityTest` +Expected: FAIL – `testAPlaceholderBecomesARelativeColorOfTheNamedColor` und `testAPlaceholderMayNameAColorOfTheSameCall` mit +`Failed asserting that two strings are identical.` (`'lit_confirm_required'` erwartet, `'lit_validation_failed'` erhalten: der +Platzhalter ist noch kein gültiger Farbwert), `testPlaceholderProblemsAreCollected` mit abweichenden Feldpfaden, +`testRelativeColorsMustNotFormACycle` mit `Failed asserting that two arrays are identical` (`items[0].color` statt `items`), die beiden +Warnungstests mit `Failed asserting that two arrays are identical` (keine Warnungen). Die übrigen Tests bestehen. + +- [ ] **Step 13: `DiviColorsSetAbility` vervollständigen** + +`src/Integrations/Divi/Design/DiviColorsSetAbility.php` vollständig ersetzen durch: + +```php + '', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active', + 'usedInPosts' => []]; + + private const PLACEHOLDER = '/^\{\{\s*color\s*:\s*([^{}]+?)\s*\}\}$/D'; + + /** Exception code of value(): the problem belongs to the item's relative, not to its color. */ + private const RELATIVE = 1; + + /** At most this many names per warning. */ + private const LISTED = 10; + + private const IN_USE = 'Die unter affected genannten Inhalte verweisen auf geänderte oder deaktivierte Farben; ' + . 'deaktivierte Farben bleiben dort als Verweis stehen.'; + + /** @param (Closure(int): string)|null $random Random characters for new IDs (ColorBook::newId(); tests). */ + public function __construct( + private DesignSystemWriter $writer, + private GlobalColorsAdapter $colors, + private DesignChange $change, + private GlobalUsages $usages, + private ?Closure $random = null + ) { + } + + public function name(): string + { + return 'lit/divi-colors-set'; + } + + public function label(): string + { + return 'Globale Divi-Farben anlegen und ändern'; + } + + public function description(): string + { + return 'Legt globale Farben an oder ändert sie. items: id nennt eine vorhandene Farbe (ID, Name, Alias ' + . 'primary, secondary, heading, body, link oder Optionsname wie accent_color); ohne id entsteht eine neue ' + . 'Farbe aus ' + . 'label und color. color: Hex, rgb()/rgba()/hsl()/hsla(), Farbname oder {{color:}} mit relative ' + . '(hue, saturation, lightness, opacity). status inactive deaktiviert (Farben werden nie gelöscht); die ' + . 'fünf Hauptfarben ändern nur color. Wirkt auf alle Seiten: zuerst Probelauf (diff, betroffene Seiten, ' + . 'Warnungen, design_rev), nach Rückfrage beim Nutzer confirm: true mit design_rev. Vorher wird ein ' + . 'Snapshot angelegt (lit/divi-design-restore).'; + } + + public function inputSchema(): array + { + $number = ['type' => 'number']; + + return [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_ITEMS, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Vorhandene Farbe: ID, Name, Alias oder Optionsname.', + ], + 'label' => ['type' => 'string', 'description' => 'Name einer neuen Farbe oder umbenennen.'], + 'color' => ['type' => 'string'], + 'relative' => [ + 'type' => 'object', + 'properties' => [ + 'hue' => $number, + 'saturation' => $number, + 'lightness' => $number, + 'opacity' => $number, + ], + ], + 'folder' => ['type' => 'string'], + 'status' => ['type' => 'string', 'description' => 'active oder inactive.'], + ], + ], + ], + ] + DesignChange::inputProperties(), + 'required' => ['items'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return DesignChange::outputSchema(); + } + + public function annotations(): array + { + return Annotations::destructive(false); + } + + public function isDirect(): bool + { + return false; + } + + /** Reads and writes the global colors; the warnings read the presets (DesignRead). */ + public function diviFeatures(array $input): array + { + return [DiviFeature::DesignRead, DiviFeature::ColorWrite]; + } + + public function permission(array $input): bool|WP_Error + { + return DesignRights::colors($this->writer) ?? true; + } + + public function execute(array $input): mixed + { + $refusal = DesignRights::colors($this->writer); + if ($refusal !== null) { + return $refusal; + } + $items = $input['items'] ?? null; + if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) { + return Errors::invalidInput( + 'items fehlt oder ist zu lang; nichts wurde gespeichert.', + ['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)] + ); + } + + return $this->change->run( + $this->name(), + new ColorArea($this->colors), + $input, + fn (array $raw): DesignPlan|WP_Error => $this->plan(ColorBook::fromDivi($raw), $items) + ); + } + + /** @param list $items */ + private function plan(ColorBook $before, array $items): DesignPlan|WP_Error + { + $book = $before; + $errors = []; + foreach ($items as $index => $item) { + $field = sprintf('items[%d]', $index); + if (! is_array($item)) { + $errors[$field] = 'Objekt erwartet'; + continue; + } + $book = $this->apply($book, $item, $field, $errors); + } + $cycle = $errors === [] ? $book->cycle() : null; + if ($cycle !== null) { + $errors['items'] = sprintf( + 'Relative Farben bauen im Kreis aufeinander auf oder sind tiefer als 10 Stufen verschachtelt: %s', + implode(' → ', array_map($book->label(...), $cycle)) + ); + } + if ($errors !== []) { + return Errors::invalidInput('Ungültige Farb-Angaben; nichts wurde gespeichert.', $errors); + } + + $diff = ColorDiff::between($before, $book); + $touched = []; + foreach ($diff as $row) { + if ($row['action'] !== 'added') { + $touched[] = (string) $row['id']; + } + } + $touched = array_values(array_unique($touched)); + $report = $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched)); + + return new DesignPlan( + $book->data(), + $diff, + ColorDiff::summary($diff), + $report, + $this->warnings($book, $diff, $report) + ); + } + + /** + * $book with the color of $item created or changed; problems go to $errors and leave $book as it was. + * + * @param array $item + * @param array $errors + */ + private function apply(ColorBook $book, array $item, string $field, array &$errors): ColorBook + { + $known = count($errors); + $id = array_key_exists('id', $item) ? self::existing($book, $item['id'], $field . '.id', $errors) : null; + if (count($errors) > $known) { + return $book; + } + + $main = $id !== null && $book->isCustomizer($id); + $entry = $id === null ? self::NEW_ENTRY : (array) $book->entry($id); + foreach (['label', 'folder', 'status', 'color'] as $key) { + if (! array_key_exists($key, $item)) { + if ($id === null && ($key === 'label' || $key === 'color')) { + $errors[$field . '.' . $key] = 'Pflicht für eine neue Farbe'; + } + continue; + } + if ($main && $key !== 'color') { + $errors[$field . '.' . $key] = self::MAIN_COLOR_RULE; + continue; + } + try { + $entry[$key] = match ($key) { + 'label' => self::cleanLabel($book, $item['label'], $id), + 'folder' => self::folder($item['folder']), + 'status' => self::status($item['status']), + default => self::value($book, $item, $main), + }; + } catch (InvalidArgumentException $e) { + $errors[$field . '.' . ($e->getCode() === self::RELATIVE ? 'relative' : $key)] = $e->getMessage(); + } + } + if (array_key_exists('relative', $item) && ! array_key_exists('color', $item)) { + $errors[$field . '.relative'] = 'nur zusammen mit color als Platzhalter {{color:…}}'; + } + if (count($errors) > $known || ($id !== null && $entry === $book->entry($id))) { + return $book; + } + + if (! $main) { + // Ruling V3: the main colors' lastUpdated stays "" (ColorBook ignores it). + $entry['lastUpdated'] = ColorBook::now(); + } + + return $book->with($id ?? $this->newId($book), $entry); + } + + /** @param array $errors */ + private static function existing(ColorBook $book, mixed $needle, string $field, array &$errors): ?string + { + if (! is_string($needle) || trim($needle) === '') { + $errors[$field] = 'ID, Name, Alias oder Optionsname der Farbe erwartet'; + + return null; + } + try { + $id = $book->find($needle); + } catch (InvalidArgumentException $e) { + $errors[$field] = $e->getMessage(); + + return null; + } + if ($id === null) { + $errors[$field] = sprintf('unbekannte Farbe „%s“', $needle); + } + + return $id; + } + + /** + * A label no other color has as name, ID or alias (it would make {{color:…}} ambiguous). + * + * @throws InvalidArgumentException + */ + private static function cleanLabel(ColorBook $book, mixed $label, ?string $id): string + { + $clean = is_string($label) && strlen($label) <= self::LABEL_MAX * 4 ? sanitize_text_field($label) : ''; + if ($clean === '' || mb_strlen($clean) > self::LABEL_MAX) { + throw new InvalidArgumentException(self::LABEL_RULE); + } + try { + $taken = $book->find($clean); + } catch (InvalidArgumentException) { + $taken = ''; // several colors share the name already + } + if ($taken !== null && $taken !== $id) { + throw new InvalidArgumentException(sprintf( + 'Name schon vergeben (%s); zum Ändern id angeben oder einen anderen Namen wählen', + $taken === '' ? 'mehrfach' : $taken + )); + } + + return $clean; + } + + /** @throws InvalidArgumentException */ + private static function folder(mixed $folder): string + { + $clean = is_string($folder) && strlen($folder) <= self::LABEL_MAX * 4 ? sanitize_text_field($folder) : null; + if ($clean === null || mb_strlen($clean) > self::LABEL_MAX || $clean === 'customizer') { + throw new InvalidArgumentException('Ordner: bis 100 Zeichen ohne HTML, nicht „customizer“'); + } + + return $clean; + } + + /** @throws InvalidArgumentException */ + private static function status(mixed $status): string + { + if (! is_string($status) || ! in_array($status, self::STATUSES, true)) { + throw new InvalidArgumentException('active oder inactive'); + } + + return $status; + } + + /** + * The stored value of $item's color (ruling V5): a checked fixed value, or {{color:…}} with relative resolved + * against $book (the planned inventory) into a Divi reference. A main color only takes a fixed value – Divi keeps + * it in et_divi, where a reference would break the Customizer. + * + * @param array $item + * @throws InvalidArgumentException Code RELATIVE when the problem is in relative. + */ + private static function value(ColorBook $book, array $item, bool $main): string + { + $color = $item['color']; + if (! is_string($color)) { + throw new InvalidArgumentException('Farbwert als Text erwartet'); + } + $color = trim($color); + if (preg_match(self::PLACEHOLDER, $color, $match) !== 1) { + if (array_key_exists('relative', $item)) { + throw new InvalidArgumentException('nur zusammen mit einem Platzhalter {{color:…}}', self::RELATIVE); + } + $problem = ColorValue::check($color); + if ($problem !== null) { + throw new InvalidArgumentException($problem); + } + + return $color; + } + + if ($main) { + throw new InvalidArgumentException('Hauptfarben brauchen einen festen Farbwert, keinen Platzhalter'); + } + $base = $book->find($match[1]); + if ($base === null) { + throw new InvalidArgumentException(sprintf('unbekannte Farbe „%s“ im Platzhalter', $match[1])); + } + $relative = $item['relative'] ?? null; + if (! is_array($relative)) { + throw new InvalidArgumentException( + 'Pflicht mit einem Platzhalter: {hue, saturation, lightness, opacity}', + self::RELATIVE + ); + } + $settings = ColorValue::relative($relative); + if (is_string($settings)) { + throw new InvalidArgumentException($settings, self::RELATIVE); + } + + return VariableRef::serialize('color', $base, $settings); + } + + private function newId(ColorBook $book): string + { + do { + $id = ColorBook::newId($this->random); + } while ($book->entry($id) !== null); + + return $id; + } + + /** + * Ruling V8: presets and relative colors built on a changed, deactivated or activated color are warnings, never + * a block. + * + * @param list> $diff + * @return list + */ + private function warnings(ColorBook $book, array $diff, ?UsageReport $report): array + { + $presets = null; + $warnings = []; + $deactivates = false; + foreach ($diff as $row) { + if ($row['action'] === 'added') { + continue; + } + $deactivated = $row['action'] === 'deactivated'; + $deactivates = $deactivates || $deactivated; + $presets ??= PresetBook::fromDivi($this->writer->presets()); + $users = array_column($presets->referencing((string) $row['id']), 'name'); + if ($users !== []) { + $warnings[] = sprintf( + $deactivated + ? 'Farbe „%s“ wird deaktiviert, aber noch von Presets verwendet: %s.' + : 'Die Änderung an Farbe „%s“ wirkt auch auf die Presets %s.', + $row['name'], + self::names($users) + ); + } + $derived = array_map($book->label(...), $book->referencing((string) $row['id'])); + if ($derived !== []) { + $warnings[] = sprintf( + $deactivated + ? 'Farbe „%1$s“ wird deaktiviert, aber die relativen Farben %2$s bauen auf ihr auf.' + : 'Die relativen Farben %2$s bauen auf Farbe „%1$s“ auf und ändern sich mit.', + $row['name'], + self::names($derived) + ); + } + } + if ($deactivates && $report !== null && $report->exists()) { + $warnings[] = self::IN_USE; + } + + return $warnings; + } + + /** @param list $names */ + private static function names(array $names): string + { + return implode(', ', array_slice($names, 0, self::LISTED)) . (count($names) > self::LISTED ? ', …' : ''); + } +} +``` + +- [ ] **Step 14: Tests laufen lassen – sie bestehen** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviColorsSetAbilityTest|ColorAreaTest'` +Expected: PASS. + +- [ ] **Step 15: Lint und Commit** + +Run: `composer lint` +Expected: keine Meldungen. + +```bash +git add src/Integrations/Divi/Design/DiviColorsSetAbility.php tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php +git commit -m "feat: lit/divi-colors-set löst {{color:…}} mit relative gegen den geplanten Bestand auf, lehnt Zyklen ab und warnt vor Verwendungen in Presets und relativen Farben" -m "Co-Authored-By: Claude " +``` + +- [ ] **Step 16: Gesamtlauf** + +Run: `composer test && composer lint` +Expected: alle Tests grün, Lint sauber. (`DiviColorsSetAbility` ist noch nicht registriert; das folgt in Task 10.) + +--- + +### Task 7: `VariableArea` und `lit/divi-variables-set` + +**Files:** +- Create: `src/Integrations/Divi/Design/VariableArea.php` +- Create: `src/Integrations/Divi/Design/DiviVariablesSetAbility.php` +- Test: `tests/Unit/Integrations/Divi/Design/VariableAreaTest.php` (neu, mit den Fixtures `stored()` und `adapter()`) +- Test: `tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php` (neu) + +**Interfaces:** +- Consumes: `GlobalVariablesAdapter::read()/save(array $data, array $expected)/canManage()` (Task 2; in Tests gemockt, darum nicht + `final`), `DesignRights::variables(DesignSystemWriter, GlobalVariablesAdapter)` (Task 2), `DiviFeature::VariableWrite` (Task 1), + `VariableValue::TYPES/check()`, `VariableBook::fromDivi()/data()/rev()/entry()/typeOf()/find()/with()/nextOrder()/restoredFrom()/ + newId(?Closure)/now()`, `VariableDiff::between()/summary()` (Task 4), `ReferenceUsageQuery`, `PresetBook::referencing()` (Task 5), + `DesignChange`, `DesignPlan`, `GlobalUsages::reportFor()`, `UsageReport`, `DesignSystemWriter::presets()/canUseVisualBuilder()`. +- Produces: + - `final class VariableArea implements DesignArea { public function __construct(private GlobalVariablesAdapter $variables) }` – + `name()` `'variables'`, `label()` `'Variablen'`, `feature()` `DiviFeature::VariableWrite`; `write($raw, $mayRemove)` = + `save(VariableBook::fromDivi($raw)->data(), )` (`$mayRemove` ohne Bedeutung: es wird nie entfernt, V1); + `restore($raw)` = `save(->restoredFrom(<$raw>)->data(), )` – auch beim Rollback der Pipeline bleiben so alle IDs erhalten. + - `final class DiviVariablesSetAbility implements Ability, RequiresDiviFeatures` mit + `__construct(DesignSystemWriter $writer, GlobalVariablesAdapter $variables, DesignChange $change, GlobalUsages $usages, ?Closure $random = null)`; + `public const MAX_ITEMS = 50`. + +**Eingabe `items[]` von `lit/divi-variables-set`:** `id` (vorhandene Variable: ID oder Name über `VariableBook::find()`, mit `type` +eingeschränkt), ohne `id` neue Variable mit Pflichtfeldern `type` (`VariableValue::TYPES`), `label` und `value`; `label` (1–100 Zeichen, +frei über alle Typen), `value` (`VariableValue::check()`), `status` (`active|archived`). Archivieren setzt `order` auf `''`, Aktivieren hängt +die Variable mit `nextOrder()` hinten an; eine neue aktive Variable bekommt `nextOrder()`. Aktive Variablen brauchen einen Wert. Der Typ +einer vorhandenen Variable bleibt; Variablen vom Typ `gradients` (und anderen nicht anlegbaren Typen) lehnt das Tool ab. Kein `delete` (V1). + +#### 7a – `VariableArea` + +- [ ] **Step 1: Failing test schreiben** + +`tests/Unit/Integrations/Divi/Design/VariableAreaTest.php`: + +```php +>> + */ + public static function stored(): array + { + $entry = static fn (string $type, string $id, string $label, string $value, string $order, string $status) + : array => ['id' => $id, 'label' => $label, 'value' => $value, 'order' => $order, 'status' => $status, + 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => $type]; + + return [ + 'numbers' => [ + 'gvid-radius0001' => $entry('numbers', 'gvid-radius0001', 'Eckenradius', '8px', '1', 'active'), + ], + 'strings' => [ + 'gvid-slogan0001' => $entry('strings', 'gvid-slogan0001', 'Slogan', 'Holz mit Herz', '1', 'active'), + 'gvid-alt0000001' => $entry('strings', 'gvid-alt0000001', 'Alter Slogan', 'Früher', '', 'archived'), + 'gvid-leer000001' => $entry('strings', 'gvid-leer000001', 'Leer', '', '', 'archived'), + ], + 'gradients' => [ + 'gvid-verlauf001' => $entry( + 'gradients', + 'gvid-verlauf001', + 'Verlauf', + 'linear-gradient(#fff, #000)', + '1', + 'active' + ), + ], + ]; + } + + /** + * A GlobalVariablesAdapter over $stored: save() replaces the inventory like set_global_variables() and notes each + * call (data, expected) in $saves. + * + * @param array>> $stored + * @param list, 1: array}> $saves + */ + public static function adapter(array &$stored, array &$saves, bool $manage = true): GlobalVariablesAdapter + { + $variables = Mockery::mock(GlobalVariablesAdapter::class); + $variables->allows('canManage')->andReturn($manage); + $variables->allows('read')->andReturnUsing(static function () use (&$stored): array { + return $stored; + }); + $variables->allows('save')->andReturnUsing( + static function (array $data, array $expected) use (&$stored, &$saves): void { + $saves[] = [$data, $expected]; + $stored = $data; + } + ); + + return $variables; + } + + public function testAreaReadsThroughTheAdapter(): void + { + $stored = self::stored(); + $saves = []; + $area = new VariableArea(self::adapter($stored, $saves)); + + self::assertSame(['variables', 'Variablen'], [$area->name(), $area->label()]); + self::assertSame(DiviFeature::VariableWrite, $area->feature()); + self::assertSame(VariableBook::fromDivi($stored)->data(), $area->raw()); + self::assertSame(VariableBook::fromDivi($stored)->rev(), $area->rev($stored)); + } + + /** Ruling V7: the whole planned inventory, with the inventory read before for Divi's stale check. */ + public function testWriteSavesThePlannedInventoryAgainstTheLiveOne(): void + { + $stored = self::stored(); + $saves = []; + $area = new VariableArea(self::adapter($stored, $saves)); + $planned = self::stored(); + $planned['numbers']['gvid-radius0001']['value'] = '12px'; + + $saved = $area->write($planned, false); + + self::assertSame([[VariableBook::fromDivi($planned)->data(), self::stored()]], $saves); + self::assertSame('12px', $saved['numbers']['gvid-radius0001']['value']); + } + + /** Ruling V1b: Divi refuses an inventory without a stored ID – later variables stay, archived. */ + public function testRestoreKeepsVariablesCreatedAfterTheSnapshotArchived(): void + { + $stored = self::stored(); + $stored['numbers']['gvid-neu0000001'] = ['id' => 'gvid-neu0000001', 'label' => 'Neu', 'value' => '4px', + 'order' => '2', 'status' => 'active', 'lastUpdated' => '2026-10-19T09:00:00.000Z', + 'variableType' => 'numbers']; + $live = $stored; + $saves = []; + $area = new VariableArea(self::adapter($stored, $saves)); + + $area->restore(VariableBook::fromDivi(self::stored())->data()); + + self::assertCount(1, $saves); + self::assertSame( + VariableBook::fromDivi($live)->restoredFrom(VariableBook::fromDivi(self::stored()))->data(), + $saves[0][0] + ); + self::assertSame($live, $saves[0][1]); + self::assertSame('archived', $saves[0][0]['numbers']['gvid-neu0000001']['status']); + } + + public function testVerifyInsistsOnTheSameVariablesStatusAndValues(): void + { + $stored = self::stored(); + $saves = []; + $area = new VariableArea(self::adapter($stored, $saves)); + $expected = self::stored(); + $sanitized = $expected; + $sanitized['strings']['gvid-slogan0001']['value'] = 'Holz mit Herz '; + $missing = $expected; + unset($missing['numbers']['gvid-radius0001']); + $archived = $expected; + $archived['numbers']['gvid-radius0001']['status'] = 'archived'; + $emptied = $expected; + $emptied['numbers']['gvid-radius0001']['value'] = ''; + + self::assertTrue($area->verify($expected, $sanitized)); + self::assertFalse($area->verify($expected, $missing)); + self::assertFalse($area->verify($expected, $archived)); + self::assertFalse($area->verify($expected, $emptied)); + } + + public function testDifferencesNameTheVariablesDiviChanged(): void + { + $stored = self::stored(); + $saves = []; + $area = new VariableArea(self::adapter($stored, $saves)); + $expected = self::stored(); + $sanitized = $expected; + $sanitized['strings']['gvid-slogan0001']['value'] = 'Holz mit Herz '; + + self::assertSame([], $area->differences($expected, $expected)); + self::assertSame( + ['Divi hat den Wert der Variablen „Slogan“ beim Speichern bereinigt.'], + $area->differences($expected, $sanitized) + ); + } +} +``` + +- [ ] **Step 2: Test laufen lassen – er schlägt fehl** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableAreaTest` +Expected: FAIL mit `Error: Class "LIT\MCP\Integrations\Divi\Design\VariableArea" not found`. + +- [ ] **Step 3: `VariableArea` implementieren** + +`src/Integrations/Divi/Design/VariableArea.php`: + +```php +variables->read())->data(); + } + + public function rev(array $raw): string + { + return VariableBook::fromDivi($raw)->rev(); + } + + /** $mayRemove has no effect: variables are never removed (ruling V1). */ + public function write(array $raw, bool $mayRemove): array + { + $this->variables->save(VariableBook::fromDivi($raw)->data(), $this->variables->read()); + + return $this->raw(); + } + + public function restore(array $raw): array + { + $live = $this->variables->read(); + $this->variables->save( + VariableBook::fromDivi($live)->restoredFrom(VariableBook::fromDivi($raw))->data(), + $live + ); + + return $this->raw(); + } + + public function verify(array $expected, array $actual): bool + { + return self::shape($expected) === self::shape($actual); + } + + public function differences(array $expected, array $actual): array + { + $warnings = []; + foreach (VariableDiff::between(VariableBook::fromDivi($expected), VariableBook::fromDivi($actual)) as $row) { + if ($row['action'] === 'changed') { + $warnings[] = sprintf('Divi hat den Wert der Variablen „%s“ beim Speichern bereinigt.', $row['name']); + } + } + + return $warnings; + } + + /** + * @param array $raw + * @return array "type/id" => [status, has a value], sorted + */ + private static function shape(array $raw): array + { + $shape = []; + foreach (VariableBook::fromDivi($raw)->data() as $type => $entries) { + foreach ($entries as $id => $entry) { + $value = $entry['value'] ?? ''; + $shape[$type . '/' . $id] = [ + (string) ($entry['status'] ?? ''), + is_scalar($value) && (string) $value !== '', + ]; + } + } + ksort($shape); + + return $shape; + } +} +``` + +- [ ] **Step 4: Test laufen lassen – er besteht** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableAreaTest` +Expected: PASS (5 Tests). + +- [ ] **Step 5: Lint und Commit** + +Run: `composer lint` +Expected: keine Meldungen. + +```bash +git add src/Integrations/Divi/Design/VariableArea.php tests/Unit/Integrations/Divi/Design/VariableAreaTest.php +git commit -m "feat: VariableArea schreibt globale Variablen mit Divis Stale-Prüfung und behält beim Wiederherstellen spätere Variablen archiviert" -m "Co-Authored-By: Claude " +``` + +#### 7b – `lit/divi-variables-set`: Hülle, Pipeline, Typen, Werte, Archivieren + +- [ ] **Step 6: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php`: + +```php +>> What VariableAreaTest::adapter() stores. */ + private array $stored = []; + + /** @var list, 1: array}> */ + private array $saves = []; + + protected function setUp(): void + { + parent::setUp(); + // DesignLock (final review I-5): the lock is always free here. + Functions\when('add_option')->justReturn(true); + Functions\when('delete_option')->justReturn(true); + Functions\when('current_user_can')->justReturn(true); + $this->stored = VariableAreaTest::stored(); + $this->saves = []; + } + + /** @param array $presets DesignSystemWriter::presets() ([] = PresetBookTest::stored()). */ + private function ability( + ?GlobalUsages $usages = null, + ?SnapshotStore $snapshots = null, + array $presets = [], + ?GlobalVariablesAdapter $variables = null, + bool $manage = true + ): DiviVariablesSetAbility { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->allows('presets')->andReturn($presets === [] ? PresetBookTest::stored() : $presets); + $counter = 0; + + return new DiviVariablesSetAbility( + $writer, + $variables ?? VariableAreaTest::adapter($this->stored, $this->saves, $manage), + new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)), + $usages ?? self::usages(self::unused()), + static function (int $length) use (&$counter): string { + return str_pad((string) ++$counter, $length, '0', STR_PAD_LEFT); + } + ); + } + + private static function usages(UsageReport $report): GlobalUsages + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn($report); + + return $usages; + } + + private static function unused(): UsageReport + { + return new UsageReport(0, [], 0, 0, [], 0, false); + } + + private static function used(): UsageReport + { + return new UsageReport(1, [['post_id' => 7, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']], + 0, 1, [], 0, false); + } + + /** A SnapshotStore that expects one snapshot of the stored variables and the log ID 3 attached to it. */ + private static function snapshots(): SnapshotStore + { + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save') + ->with('variables', VariableBook::fromDivi(VariableAreaTest::stored())->data(), 'lit/divi-variables-set') + ->andReturn('variables-1'); + $snapshots->expects('attachLog')->with('variables', 'variables-1', 3); + + return $snapshots; + } + + public function testSchemaAndMeta(): void + { + $ability = $this->ability(); + $properties = $ability->inputSchema()['properties']; + + self::assertSame('lit/divi-variables-set', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(Annotations::destructive(false), $ability->annotations()); + self::assertSame([DiviFeature::DesignRead, DiviFeature::VariableWrite], $ability->diviFeatures([])); + self::assertSame(50, DiviVariablesSetAbility::MAX_ITEMS); + self::assertSame(DiviVariablesSetAbility::MAX_ITEMS, $properties['items']['maxItems']); + self::assertArrayNotHasKey('delete', $properties['items']['items']['properties'], 'ruling V1: only archived'); + self::assertArrayHasKey('design_rev', $properties); + self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2)); + self::assertStringContainsString('confirm: true mit design_rev', $ability->description()); + self::assertStringContainsString('lit/divi-design-restore', $ability->description()); + self::assertStringContainsString('lit/divi-design-detach', $ability->description()); + } + + public function testNeedsTheVariablesManagerAndEditThemeOptions(): void + { + $input = ['items' => [['type' => 'numbers', 'label' => 'Abstand', 'value' => '24px']]]; + + self::assertToolError($this->ability(manage: false)->execute($input), 'forbidden'); + self::assertToolError($this->ability(manage: false)->permission($input), 'forbidden'); + + Functions\when('current_user_can')->justReturn(false); + self::assertToolError($this->ability()->execute($input), 'forbidden'); + self::assertSame([], $this->saves); + } + + public function testItemsMustBeAListOfOneToFifty(): void + { + $tooMany = array_fill(0, 51, ['type' => 'numbers', 'label' => 'X', 'value' => '1px']); + foreach ([[], ['items' => []], ['items' => ['a' => []]], ['items' => $tooMany]] as $input) { + self::assertToolError($this->ability()->execute($input), 'validation_failed'); + } + } + + public function testDryRunThenConfirmCreatesAVariable(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor')->never(); // ruling V8: a new variable has no usages + $ability = $this->ability($usages, self::snapshots()); + $input = ['items' => [['type' => 'numbers', 'label' => 'Abstand', 'value' => '24px']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + self::assertSame(['added'], array_column($dry['diff'], 'action')); + self::assertSame('Abstand', $dry['diff'][0]['name']); + self::assertSame(VariableBook::fromDivi(VariableAreaTest::stored())->rev(), $dry['design_rev']); + self::assertSame([], $dry['affected']); + self::assertSame([], $dry['warnings']); + self::assertStringContainsString(VariableDiff::summary($dry['diff']), $dry['message']); + self::assertSame([], $this->saves, 'the dry run saves nothing'); + + $result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + self::assertSame('variables-1', $result['snapshot_id']); + $created = array_diff_key($this->stored['numbers'], VariableAreaTest::stored()['numbers']); + self::assertCount(1, $created); + $id = (string) array_key_first($created); + self::assertMatchesRegularExpression('/^gvid-[0-9a-z]{10}$/D', $id); + self::assertSame( + [$id, 'Abstand', '24px', '2', 'active', 'numbers'], + [$created[$id]['id'], $created[$id]['label'], $created[$id]['value'], $created[$id]['order'], + $created[$id]['status'], $created[$id]['variableType']] + ); + self::assertMatchesRegularExpression( + '/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/D', + $created[$id]['lastUpdated'] + ); + self::assertSame(VariableAreaTest::stored(), $this->saves[0][1], 'Divi checks against the inventory read'); + self::assertSame(VariableBook::fromDivi($this->stored)->rev(), $result['design_rev']); + } + + public function testConfirmNeedsTheDesignRevOfTheDryRun(): void + { + $input = ['items' => [['id' => 'Eckenradius', 'value' => '12px']], 'confirm' => true]; + + self::assertToolError($this->ability()->execute($input), 'validation_failed'); + self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch'); + self::assertSame([], $this->saves); + } + + public function testChangingValueAndLabelListsTheUsages(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor') + ->twice() // dry run and confirm + ->with(Mockery::on(static fn (ReferenceUsageQuery $query): bool + => $query->matches('') + && ! $query->matches(''))) + ->andReturn(self::used()); + $ability = $this->ability($usages, self::snapshots()); + $input = ['items' => [['id' => 'slogan', 'label' => 'Leitsatz', 'value' => 'Holz mit Seele']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + self::assertSame(['changed'], array_column($dry['diff'], 'action')); + self::assertSame(7, $dry['affected'][0]['post_id']); + + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + $slogan = $this->stored['strings']['gvid-slogan0001']; + self::assertSame(['Leitsatz', 'Holz mit Seele', '1', 'active'], [$slogan['label'], $slogan['value'], + $slogan['order'], $slogan['status']]); + } + + public function testTheTypeOfAVariableStays(): void + { + $error = $this->ability()->execute(['items' => [['id' => 'Eckenradius', 'type' => 'strings', 'value' => 'x']]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame(['items[0].type'], array_keys(self::errorFields($error))); + } + + /** Ruling V6: gradients are passed through, never changed by the tool. */ + public function testGradientsAreLeftToTheBuilder(): void + { + $error = $this->ability()->execute(['items' => [['id' => 'Verlauf', 'status' => 'archived']]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame(['items[0].id'], array_keys(self::errorFields($error))); + } + + /** Ruling V1: archiving clears the order; activating appends the variable at the end of its type. */ + public function testArchivingClearsTheOrderAndActivatingAppendsIt(): void + { + $ability = $this->ability(null, self::snapshots()); + $input = ['items' => [ + ['id' => 'Eckenradius', 'status' => 'archived'], + ['id' => 'Alter Slogan', 'status' => 'active'], + ]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + $actions = array_column($dry['diff'], 'action'); + sort($actions); + self::assertSame(['activated', 'archived'], $actions); + + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + $radius = $this->stored['numbers']['gvid-radius0001']; + $old = $this->stored['strings']['gvid-alt0000001']; + self::assertSame(['archived', ''], [$radius['status'], $radius['order']]); + self::assertSame(['active', '2'], [$old['status'], $old['order']]); + self::assertSame('8px', $radius['value'], 'archiving keeps the value'); + } + + public function testActiveVariablesNeedAValue(): void + { + $error = $this->ability()->execute(['items' => [['id' => 'gvid-leer000001', 'status' => 'active']]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame(['items[0].value'], array_keys(self::errorFields($error))); + } + + public function testAllProblemsOfAllItemsAreCollected(): void + { + $error = $this->ability()->execute(['items' => [ + ['id' => 'Fehlt', 'value' => '1px'], + ['type' => 'colors', 'label' => 'Farbe', 'value' => '#fff'], + ['type' => 'numbers', 'label' => 'eckenradius', 'value' => '1px'], + ['type' => 'numbers', 'label' => 'Neu', 'value' => '1px;}'], + ['type' => 'numbers', 'label' => 'Ohne Wert'], + ['id' => 'Slogan', 'status' => 'gelöscht'], + 'kein Objekt', + ]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame( + ['items[0].id', 'items[1].type', 'items[2].label', 'items[3].value', 'items[4].value', 'items[5].status', + 'items[6]'], + array_keys(self::errorFields($error)) + ); + self::assertSame([], $this->saves); + } + + public function testAFailedWriteIsRolledBack(): void + { + $calls = []; + $variables = Mockery::mock(GlobalVariablesAdapter::class); + $variables->allows('canManage')->andReturn(true); + $variables->allows('read')->andReturn(VariableAreaTest::stored()); + $variables->allows('save')->andReturnUsing(static function (array $data, array $expected) use (&$calls): void { + $calls[] = $data; + if (count($calls) === 1) { + throw new RuntimeException('global_variables_stale'); + } + }); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('variables-1'); + $ability = $this->ability(null, $snapshots, [], $variables); + $input = ['items' => [['id' => 'Eckenradius', 'value' => '12px']]]; + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + try { + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + self::fail('expected WriteFailed'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame('variables-1', $e->details['snapshot_id']); + } + self::assertCount(2, $calls); + self::assertSame(VariableBook::fromDivi(VariableAreaTest::stored())->data(), $calls[1], 'the snapshot again'); + } +} +``` + +- [ ] **Step 7: Tests laufen lassen – sie schlagen fehl** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviVariablesSetAbilityTest` +Expected: FAIL mit `Error: Class "LIT\MCP\Integrations\Divi\Design\DiviVariablesSetAbility" not found`. + +- [ ] **Step 8: `DiviVariablesSetAbility` implementieren** + +`src/Integrations/Divi/Design/DiviVariablesSetAbility.php`: + +```php + '', 'label' => '', 'value' => '', 'order' => '', 'status' => 'active', + 'lastUpdated' => '', 'variableType' => '']; + + /** @param (Closure(int): string)|null $random Random characters for new IDs (VariableBook::newId(); tests). */ + public function __construct( + private DesignSystemWriter $writer, + private GlobalVariablesAdapter $variables, + private DesignChange $change, + private GlobalUsages $usages, + private ?Closure $random = null + ) { + } + + public function name(): string + { + return 'lit/divi-variables-set'; + } + + public function label(): string + { + return 'Globale Divi-Variablen anlegen und ändern'; + } + + public function description(): string + { + return 'Legt globale Variablen an oder ändert sie. items: id nennt eine vorhandene Variable (ID oder Name); ' + . 'ohne id entsteht eine neue aus type (numbers, strings, images, links, fonts), label und value. label ' + . 'benennt um, status archived archiviert (Variablen werden nie gelöscht), active holt sie zurück; der Typ ' + . 'bleibt. Verweise vor dem Archivieren mit lit/divi-design-detach durch den Wert ersetzen. Wirkt auf alle ' + . 'Seiten: zuerst Probelauf (diff, betroffene Seiten, Warnungen, design_rev), nach Rückfrage beim Nutzer ' + . 'confirm: true mit design_rev. Vorher wird ein Snapshot angelegt (lit/divi-design-restore).'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_ITEMS, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'description' => 'Vorhandene Variable: ID oder Name.'], + 'type' => [ + 'type' => 'string', + 'description' => 'Neue Variable: numbers, strings, images, links oder fonts.', + ], + 'label' => [ + 'type' => 'string', + 'description' => 'Name einer neuen Variable oder umbenennen.', + ], + 'value' => ['type' => 'string'], + 'status' => ['type' => 'string', 'description' => 'active oder archived.'], + ], + ], + ], + ] + DesignChange::inputProperties(), + 'required' => ['items'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return DesignChange::outputSchema(); + } + + public function annotations(): array + { + return Annotations::destructive(false); + } + + public function isDirect(): bool + { + return false; + } + + /** Reads and writes the global variables; the warnings read the presets (DesignRead). */ + public function diviFeatures(array $input): array + { + return [DiviFeature::DesignRead, DiviFeature::VariableWrite]; + } + + public function permission(array $input): bool|WP_Error + { + return DesignRights::variables($this->writer, $this->variables) ?? true; + } + + public function execute(array $input): mixed + { + $refusal = DesignRights::variables($this->writer, $this->variables); + if ($refusal !== null) { + return $refusal; + } + $items = $input['items'] ?? null; + if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) { + return Errors::invalidInput( + 'items fehlt oder ist zu lang; nichts wurde gespeichert.', + ['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)] + ); + } + + return $this->change->run( + $this->name(), + new VariableArea($this->variables), + $input, + fn (array $raw): DesignPlan|WP_Error => $this->plan(VariableBook::fromDivi($raw), $items) + ); + } + + /** @param list $items */ + private function plan(VariableBook $before, array $items): DesignPlan|WP_Error + { + $book = $before; + $errors = []; + foreach ($items as $index => $item) { + $field = sprintf('items[%d]', $index); + if (! is_array($item)) { + $errors[$field] = 'Objekt erwartet'; + continue; + } + $book = $this->apply($book, $item, $field, $errors); + } + if ($errors !== []) { + return Errors::invalidInput('Ungültige Variablen-Angaben; nichts wurde gespeichert.', $errors); + } + + $diff = VariableDiff::between($before, $book); + $touched = []; + foreach ($diff as $row) { + if ($row['action'] !== 'added') { + $touched[] = (string) $row['id']; + } + } + $touched = array_values(array_unique($touched)); + + return new DesignPlan( + $book->data(), + $diff, + VariableDiff::summary($diff), + $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched)) + ); + } + + /** + * $book with the variable of $item created or changed; problems go to $errors and leave $book as it was. + * + * @param array $item + * @param array $errors + */ + private function apply(VariableBook $book, array $item, string $field, array &$errors): VariableBook + { + $known = count($errors); + $named = array_key_exists('id', $item); + $found = $named ? self::existing($book, $item, $field, $errors) : null; + $type = $found['type'] ?? ($named ? null : self::newType($item, $field, $errors)); + if ($type === null || count($errors) > $known) { + return $book; + } + + $id = $found['id'] ?? null; + $before = $id === null ? null : $book->entry($type, $id); + $entry = $before ?? array_merge(self::NEW_ENTRY, ['variableType' => $type]); + foreach (['label', 'value', 'status'] as $key) { + if (! array_key_exists($key, $item)) { + if ($before === null && $key !== 'status') { + $errors[$field . '.' . $key] = 'Pflicht für eine neue Variable'; + } + continue; + } + try { + $entry[$key] = match ($key) { + 'label' => self::cleanLabel($book, $item['label'], $id), + 'value' => self::value($type, $item['value']), + default => self::status($item['status']), + }; + } catch (InvalidArgumentException $e) { + $errors[$field . '.' . $key] = $e->getMessage(); + } + } + if (count($errors) > $known) { + return $book; + } + if ($entry['status'] === 'active' && ! self::hasValue($entry['value'] ?? null)) { + // Divi refuses it too (global_variable_active_value_empty, ruling V6). + $errors[$field . '.value'] = 'aktive Variablen brauchen einen Wert'; + + return $book; + } + + $entry['order'] = match (true) { + $entry['status'] !== 'active' => '', + ($before['status'] ?? null) !== 'active' => $book->nextOrder($type), + default => $entry['order'], + }; + if ($entry === $before) { + return $book; + } + $id ??= $this->newId($book); + $entry['id'] = $id; + $entry['lastUpdated'] = VariableBook::now(); + + return $book->with($type, $id, $entry); + } + + /** + * The variable $item['id'] names (within $item['type'] first, when given), or null after noting the problem. + * + * @param array $item + * @param array $errors + * @return array{type: string, id: string}|null + */ + private static function existing(VariableBook $book, array $item, string $field, array &$errors): ?array + { + $needle = $item['id']; + if (! is_string($needle) || trim($needle) === '') { + $errors[$field . '.id'] = 'ID oder Name der Variablen erwartet'; + + return null; + } + $type = is_string($item['type'] ?? null) ? $item['type'] : null; + try { + $found = ($type === null ? null : $book->find($needle, $type)) ?? $book->find($needle); + } catch (InvalidArgumentException $e) { + $errors[$field . '.id'] = $e->getMessage(); + + return null; + } + if ($found === null) { + $errors[$field . '.id'] = sprintf('unbekannte Variable „%s“', $needle); + + return null; + } + if (! in_array($found['type'], VariableValue::TYPES, true)) { + $errors[$field . '.id'] = sprintf('Variablen vom Typ %s nur im Divi-Builder ändern', $found['type']); + + return null; + } + if ($type !== null && $type !== $found['type']) { + $errors[$field . '.type'] = sprintf('Der Typ einer Variablen bleibt (ist %s)', $found['type']); + + return null; + } + + return $found; + } + + /** + * @param array $item + * @param array $errors + */ + private static function newType(array $item, string $field, array &$errors): ?string + { + $type = $item['type'] ?? null; + if (is_string($type) && in_array($type, VariableValue::TYPES, true)) { + return $type; + } + $errors[$field . '.type'] = sprintf( + 'Pflicht für eine neue Variable, einer von: %s', + implode(', ', VariableValue::TYPES) + ); + + return null; + } + + /** + * A label no other variable has as name or ID (it would make {{var:…}} ambiguous). + * + * @throws InvalidArgumentException + */ + private static function cleanLabel(VariableBook $book, mixed $label, ?string $id): string + { + $clean = is_string($label) && strlen($label) <= self::LABEL_MAX * 4 ? sanitize_text_field($label) : ''; + if ($clean === '' || mb_strlen($clean) > self::LABEL_MAX) { + throw new InvalidArgumentException(self::LABEL_RULE); + } + try { + $taken = $book->find($clean)['id'] ?? null; + } catch (InvalidArgumentException) { + $taken = ''; // several variables share the name already + } + if ($taken !== null && $taken !== $id) { + throw new InvalidArgumentException(sprintf( + 'Name schon vergeben (%s); zum Ändern id angeben oder einen anderen Namen wählen', + $taken === '' ? 'mehrfach' : $taken + )); + } + + return $clean; + } + + /** @throws InvalidArgumentException */ + private static function value(string $type, mixed $value): string + { + if (is_int($value)) { + $value = (string) $value; // an attachment ID for images + } + if (! is_string($value)) { + throw new InvalidArgumentException('Wert als Text erwartet'); + } + $problem = VariableValue::check($type, $value); + if ($problem !== null) { + throw new InvalidArgumentException($problem); + } + + return $value; + } + + /** @throws InvalidArgumentException */ + private static function status(mixed $status): string + { + if (! is_string($status) || ! in_array($status, self::STATUSES, true)) { + throw new InvalidArgumentException('active oder archived'); + } + + return $status; + } + + private static function hasValue(mixed $value): bool + { + return is_scalar($value) && (string) $value !== ''; + } + + private function newId(VariableBook $book): string + { + do { + $id = VariableBook::newId($this->random); + } while ($book->typeOf($id) !== null); + + return $id; + } +} +``` + +- [ ] **Step 9: Tests laufen lassen – sie bestehen** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviVariablesSetAbilityTest|VariableAreaTest'` +Expected: PASS. + +- [ ] **Step 10: Lint und Commit** + +Run: `composer lint` +Expected: keine Meldungen. + +```bash +git add src/Integrations/Divi/Design/DiviVariablesSetAbility.php tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php +git commit -m "feat: lit/divi-variables-set legt globale Variablen an, ändert und archiviert sie (Probelauf, design_rev, Snapshot)" -m "Co-Authored-By: Claude " +``` + +#### 7c – Warnungen: Verwendung in Presets, archivierte verwendete Variablen + +- [ ] **Step 11: Failing tests ergänzen** + +In `tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php` vor der schließenden `}` der Klasse einfügen: + +```php + /** Ruling V1a/V8: archiving a used variable is allowed; presets and posts are warnings, the dry run lists posts. */ + public function testArchivingAUsedVariableWarnsAndListsTheUsages(): void + { + $dry = self::assertToolError( + $this->ability(self::usages(self::used()), null, PresetBookTest::storedWithReferences()) + ->execute(['items' => [['id' => 'Slogan', 'status' => 'archived']]]), + 'confirm_required' + ); + + self::assertSame(7, $dry['affected'][0]['post_id']); + self::assertSame([ + 'Variable „Slogan“ wird archiviert, aber noch von Presets verwendet: Doppelt.', + 'Die unter affected genannten Inhalte verweisen auf geänderte oder archivierte Variablen; archivierte ' + . 'Variablen bleiben dort als Verweis stehen. lit/divi-design-detach mit variable ersetzt die Verweise ' + . 'vorher durch den Wert.', + ], $dry['warnings']); + } + + public function testChangingAVariableWarnsAboutThePresetsThatUseIt(): void + { + $dry = self::assertToolError( + $this->ability(null, null, PresetBookTest::storedWithReferences()) + ->execute(['items' => [['id' => 'Eckenradius', 'value' => '12px']]]), + 'confirm_required' + ); + + self::assertSame( + ['Die Änderung an Variable „Eckenradius“ wirkt auch auf die Presets Lesetext.'], + $dry['warnings'] + ); + } + + public function testNewVariablesHaveNoWarnings(): void + { + $dry = self::assertToolError( + $this->ability(null, null, PresetBookTest::storedWithReferences()) + ->execute(['items' => [['type' => 'strings', 'label' => 'Gruß', 'value' => 'Hallo']]]), + 'confirm_required' + ); + + self::assertSame([], $dry['warnings']); + } +``` + +- [ ] **Step 12: Tests laufen lassen – sie schlagen fehl** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviVariablesSetAbilityTest` +Expected: FAIL – `testArchivingAUsedVariableWarnsAndListsTheUsages` und `testChangingAVariableWarnsAboutThePresetsThatUseIt` mit +`Failed asserting that two arrays are identical.` (noch keine Warnungen); alle übrigen Tests bestehen. + +- [ ] **Step 13: Warnungen ergänzen** + +In `src/Integrations/Divi/Design/DiviVariablesSetAbility.php`: + +1. Import ergänzen (nach `use LIT\MCP\Integrations\Divi\Library\GlobalUsages;`): + +```php +use LIT\MCP\Integrations\Divi\Library\UsageReport; +``` + +2. Klassen-Docblock, letzter Satz vorher: + +```php + * builder. Values are checked per type (VariableValue); an active variable needs one. The dry run lists the posts that + * reference a changed, archived or activated variable (ReferenceUsageQuery). + */ +``` + +nachher: + +```php + * builder. Values are checked per type (VariableValue); an active variable needs one. The dry run lists the posts that + * reference a changed, archived or activated variable (ReferenceUsageQuery) and warns about presets that use it. + */ +``` + +3. Nach der Konstante `NEW_ENTRY` einfügen: + +```php + + /** At most this many names per warning. */ + private const LISTED = 10; + + private const IN_USE = 'Die unter affected genannten Inhalte verweisen auf geänderte oder archivierte Variablen; ' + . 'archivierte Variablen bleiben dort als Verweis stehen. lit/divi-design-detach mit variable ersetzt die ' + . 'Verweise vorher durch den Wert.'; +``` + +4. Am Ende von `plan()` vorher: + +```php + $touched = array_values(array_unique($touched)); + + return new DesignPlan( + $book->data(), + $diff, + VariableDiff::summary($diff), + $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched)) + ); + } +``` + +nachher: + +```php + $touched = array_values(array_unique($touched)); + $report = $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched)); + + return new DesignPlan( + $book->data(), + $diff, + VariableDiff::summary($diff), + $report, + $this->warnings($diff, $report) + ); + } +``` + +5. Nach der Methode `newId()` (vor der schließenden `}` der Klasse) einfügen: + +```php + + /** + * Ruling V8: presets that use a changed, archived or activated variable are warnings, never a block; archiving a + * variable that posts still use is allowed (V1a) and points to lit/divi-design-detach. + * + * @param list> $diff + * @return list + */ + private function warnings(array $diff, ?UsageReport $report): array + { + $presets = null; + $warnings = []; + $archives = false; + foreach ($diff as $row) { + if ($row['action'] === 'added') { + continue; + } + $archived = $row['action'] === 'archived'; + $archives = $archives || $archived; + $presets ??= PresetBook::fromDivi($this->writer->presets()); + $users = array_column($presets->referencing((string) $row['id']), 'name'); + if ($users !== []) { + $warnings[] = sprintf( + $archived + ? 'Variable „%s“ wird archiviert, aber noch von Presets verwendet: %s.' + : 'Die Änderung an Variable „%s“ wirkt auch auf die Presets %s.', + $row['name'], + self::names($users) + ); + } + } + if ($archives && $report !== null && $report->exists()) { + $warnings[] = self::IN_USE; + } + + return $warnings; + } + + /** @param list $names */ + private static function names(array $names): string + { + return implode(', ', array_slice($names, 0, self::LISTED)) . (count($names) > self::LISTED ? ', …' : ''); + } +``` + +- [ ] **Step 14: Tests laufen lassen – sie bestehen** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviVariablesSetAbilityTest|VariableAreaTest'` +Expected: PASS. + +- [ ] **Step 15: Lint und Commit** + +Run: `composer lint` +Expected: keine Meldungen. + +```bash +git add src/Integrations/Divi/Design/DiviVariablesSetAbility.php tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php +git commit -m "feat: lit/divi-variables-set warnt vor Verwendungen in Presets und beim Archivieren verwendeter Variablen" -m "Co-Authored-By: Claude " +``` + +- [ ] **Step 16: Gesamtlauf** + +Run: `composer test && composer lint` +Expected: alle Tests grün, Lint sauber. (`DiviVariablesSetAbility` ist noch nicht registriert; das folgt in Task 10.) + +--- + + +### Task 8: `DetachTarget`, Variablen-Detach (`VariableDetachRewrite`, `lit/divi-design-detach` mit `variable`) + +**Files:** +- Create: `src/Integrations/Divi/Design/DetachTarget.php`, `src/Integrations/Divi/Design/VariableDetachRewrite.php` +- Test: `tests/Unit/Integrations/Divi/Design/DetachTargetTest.php`, `tests/Unit/Integrations/Divi/Design/VariableDetachRewriteTest.php` +- Modify: `src/Integrations/Divi/Design/DiviDesignDetachAbility.php`, `src/Integrations/Divi/Design/DetachRewrite.php` (nur `mentions()` öffentlich), `src/Integrations/Divi/DiviIntegration.php` (nur der Konstruktoraufruf des Detach-Tools) +- Modify (Tests): `tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php`, `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php` (ein `inputCases()`-Fall), `tests/Integration/divi-presets-assign-smoke.sh` (Erwartung für eine unbekannte Variable) + +**Interfaces:** +- Consumes (Schnittstellenvertrag, Tasks 1–5): `DiviFeature::VariableWrite`; `GlobalVariablesAdapter::read()`, `::canManage()` (über `DesignRights::variables()`); `DesignRights::variables(DesignSystemWriter, GlobalVariablesAdapter)`; `VariableBook::fromDivi()`, `->find(string $needle): ?array{type, id}` (wirft `InvalidArgumentException` bei mehrdeutigem Label), `->entry(string $type, string $id): ?array`; `new ReferenceUsageQuery(list $ids)`; `PresetBook::referencing(string $id): list`, `->withVariableResolved(string $id, string $value): self`. Vorhanden: `VariableRef::nameOf()/settingsOf()/contains()/serialize()` (`Compose/VariableRef.php`; `nameOf()` liefert den Namen nur für einen *ganzen* Verweis vom Typ `content` – Farbverweise `type: color` bleiben damit außen vor), `PageRewriter::preview()/apply()`, `TreeRewrite` (ruft `notes()` nur für geänderte Blöcke), `DesignChange::run()`, `PresetArea`, `PresetDiff`, `PageRewriteReport`, `PostIdsInput`, `DetachRewrite`. +- Produces: + - `final class DetachTarget { public function __construct(public readonly string $subject, public readonly ?array $postIds, public readonly UsageQuery $query, public readonly Closure $rewrite, public readonly ?Closure $presets, public readonly string $nestedIn, public readonly array $notes, public readonly string $doneHint); public function change(): string; public function finished(array $skipped, int $hidden): bool; }` – `$presets` ist `Closure(PresetBook): PresetBook` (der Preset-Schritt) oder `null`. + - `final class VariableDetachRewrite implements NodeRewrite { public function __construct(string $id, string $value, string $variableType); public static function replacement(string $reference, string $value, string $variableType): string; }` + - `DetachRewrite::mentions(Block $block, string $id): bool` wird `public static` (vorher `private`). + - **Neue Konstruktor-Signatur** `DiviDesignDetachAbility::__construct(DesignSystemWriter $writer, GlobalVariablesAdapter $variables, DesignChange $change, GlobalUsages $usages, PageRewriter $pages, ?Closure $unfiltered = null)`; `diviFeatures($input)` = bisherige Liste, mit `variable` zusätzlich `VariableWrite`. + +Regeln (V9, R10, Spec §10): +- `variable` (Name oder ID, ohne Beachtung der Groß-/Kleinschreibung über `VariableBook::find()`) und `preset` schließen sich aus (`validation_failed` an `variable`). Eine `gcid-`-ID ist eine Farbe → `unsupported` mit Hinweis „nur deaktivieren (lit/divi-colors-set)“; eine unbekannte Variable → `not_found` mit demselben Hinweis (deckt auch Farb-Labels ab). Ob die Variable aktiv oder archiviert ist, spielt keine Rolle; die Variable selbst wird nicht geschrieben. Ein Wert, der kein String ist (Typ `gradients`, falls Divi ihn als Objekt speichert), → `unsupported`. +- Ersetzt wird jeder Attributwert, der *genau ein* Verweis `$variable({"type":"content","value":{"name":"",…}})$` ist, durch den aktuellen Wert; bei `strings` mit `before`/`after` aus den Einstellungen des Verweises (`before . Wert . after`). Eingebettete Verweise bleiben; `notes()` meldet sie je geändertem Modul. Farbverweise, andere Variablen, dynamische Inhalte und Nicht-Divi-Blöcke bleiben unberührt. Globale Instanzen, deren Attribute die ID nennen, stehen in `skipped` (`concernsInstance()` wie `DetachRewrite`). +- Ohne `post_ids` und wenn `PresetBook::referencing()` Presets nennt, läuft der Preset-Schritt (`withVariableResolved()`) wie bei Gruppen-Presets zuerst, mit Snapshot `presets`, in **einem** `confirm_required` (pages + diff + design_rev). Rechte: `DesignRights::presets()` (der Preset-Schritt schreibt Presets) und `DesignRights::variables()`; `edit_post` je Live-Fundstelle prüft der `PageRewriter` (P8/P8a) bzw. der Plan des Preset-Schritts. +- Fertig-Hinweis: „Jetzt kann lit/divi-variables-set die Variable archivieren (status: archived).“ (wie `DELETE_HINT` nur ohne `post_ids`, ohne `skipped`, ohne verborgene Fundstellen und ohne `remaining`). + +#### 8a – `DetachTarget` statt des Arrays (Refactor, Verhalten unverändert) + +- [ ] **Step 1: Absicherung: bestehende Detach-Tests grün** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignDetachAbilityTest|DetachRewriteTest/'` +Expected: `OK (31 tests, …)` (26 + 5). Diese Tests bleiben in 8a unverändert. + +- [ ] **Step 2: Failing Test `DetachTargetTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/DetachTargetTest.php`: + +```php +|null $postIds */ + private static function target(?array $postIds): DetachTarget + { + $scope = new PresetScope('module', 'divi/button'); + + return new DetachTarget( + 'Modul-Preset divi/button „CTA primär“', + $postIds, + new PresetUsageQuery([['scope' => $scope, 'id' => 'aaaaaaaaaa', 'default' => false]]), + static fn (): NodeRewrite => new DetachRewrite($scope, 'aaaaaaaaaa'), + null, + 'anderen Presets', + [], + 'Jetzt kann lit/divi-presets-delete das Preset löschen.' + ); + } + + public function testTheChangeNamesTheSubject(): void + { + self::assertSame('Entkoppeln von Modul-Preset divi/button „CTA primär“', self::target(null)->change()); + } + + public function testFinishedOnlyWithoutPostIdsSkippedOrHiddenUsages(): void + { + self::assertTrue(self::target(null)->finished([], 0)); + self::assertFalse(self::target([12])->finished([], 0), 'post_ids leave other usages'); + self::assertFalse(self::target(null)->finished([['post_id' => 12, 'reason' => 'x']], 0)); + self::assertFalse(self::target(null)->finished([], 3)); + } +} +``` + +- [ ] **Step 3: RED** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DetachTargetTest` +Expected: FAIL – `Error: Class "LIT\MCP\Integrations\Divi\Design\DetachTarget" not found`. + +- [ ] **Step 4: `DetachTarget` implementieren** + +Datei `src/Integrations/Divi/Design/DetachTarget.php`: + +```php +|null $postIds Only these posts; null = every usage. + * @param Closure(): NodeRewrite $rewrite A fresh strategy per post. + * @param (Closure(PresetBook): PresetBook)|null $presets The preset step; null = none. + * @param string $nestedIn Where the preset step removes references (messages). + * @param list $notes Warnings of every answer. + * @param string $doneHint The next step once nothing references the target. + */ + public function __construct( + public readonly string $subject, + public readonly ?array $postIds, + public readonly UsageQuery $query, + public readonly Closure $rewrite, + public readonly ?Closure $presets, + public readonly string $nestedIn, + public readonly array $notes, + public readonly string $doneHint + ) { + } + + /** The change in messages and in the change log of every saved post. */ + public function change(): string + { + return 'Entkoppeln von ' . $this->subject; + } + + /** + * Whether nothing is left to detach: all usages, nothing skipped, and no usage this account may not read. + * + * @param list> $skipped + */ + public function finished(array $skipped, int $hidden): bool + { + return $this->postIds === null && $skipped === [] && $hidden === 0; + } +} +``` + +- [ ] **Step 5: GREEN** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DetachTargetTest` +Expected: `OK (2 tests, 5 assertions)`. + +- [ ] **Step 6: `DiviDesignDetachAbility` auf `DetachTarget` umbauen** + +`target()` wird `presetTarget(): DetachTarget|WP_Error`; `dryRun()`, `presetDryRun()`, `nestedPlan()`, `confirmed()` und `afterPresets()` nehmen das Wertobjekt; `finished()` und `notes()` der Ability entfallen (jetzt `DetachTarget::finished()` bzw. `$target->notes`). Der Preset-Schritt ist die Closure `$target->presets` (`withoutNestedRef($id)`), `null` ohne Verschachtelung oder mit `post_ids` – genau die bisherige Bedingung `nested`. Texte bleiben Wort für Wort gleich: `subject` = ` „“`, `nestedIn` = `anderen Presets`. Der Rest der Datei (Kopf bis `execute()`, `firstPageError()`, `invalid()`) bleibt. Ganze Datei nach dem Umbau: + +```php +unfiltered ??= DesignRights::unfilteredHtml(); + } + + public function name(): string + { + return 'lit/divi-design-detach'; + } + + public function label(): string + { + return 'Divi-Preset-Verweise entfernen'; + } + + public function description(): string + { + return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) aus ' + . 'Seiten, Bibliothekseinträgen und Theme-Builder-Layouts und – bei Gruppen-Presets – aus anderen Presets; ' + . 'Module fallen auf das nächste Preset im Stapel bzw. das Standard-Preset zurück. post_ids beschränkt auf ' + . 'diese Seiten (Verweise in Presets bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff ' + . 'und design_rev), nach Rückfrage beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine ' + . 'nennt); je Aufruf höchstens ' . PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit ' + . 'post_ids können nur die genannten Seiten das Schreiben blockieren und die Obergrenze für sehr viele ' + . 'Seiten gilt nicht; eine genannte veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht ' + . 'verweigert den ganzen Aufruf (forbidden, blocked_ids), eine genannte andere Seite ohne ' + . 'Bearbeitungsrecht steht in skipped (bei mehr als 2000 Live-Fundstellen entfällt die Vorabprüfung, ' + . 'dann auch eine genannte Live-Seite). Danach kann lit/divi-presets-delete das Preset löschen. ' + . self::VARIABLES; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'preset' => [ + 'type' => 'object', + 'properties' => [ + 'module' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'preset' => ['type' => 'string', 'description' => 'Name oder ID des Presets.'], + ], + 'required' => ['preset'], + ], + 'variable' => ['type' => 'string', 'description' => self::VARIABLES], + 'post_ids' => PostIdsInput::schema(), + ] + DesignChange::inputProperties(), + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return PageRewriteReport::outputSchema([ + 'diff' => ['type' => 'array', 'items' => ['type' => 'object']], + 'design_rev' => ['type' => ['string', 'null']], + ]); + } + + public function annotations(): array + { + return Annotations::write(true); + } + + public function isDirect(): bool + { + return false; + } + + /** + * Like lit/divi-presets-assign (it rewrites every kind of post), plus the preset step that removes references + * nested in other presets (PresetWrite). + */ + public function diviFeatures(array $input): array + { + return [ + DiviFeature::PageRead, + DiviFeature::PageWrite, + DiviFeature::Catalog, + DiviFeature::DesignRead, + DiviFeature::PresetWrite, + DiviFeature::LibraryRead, + DiviFeature::LibraryWrite, + DiviFeature::ThemeBuilderWrite, + DiviFeature::Roles, + ]; + } + + public function permission(array $input): bool|WP_Error + { + return DesignRights::presets($this->writer) ?? true; + } + + public function execute(array $input): mixed + { + $refusal = DesignRights::presets($this->writer); + if ($refusal !== null) { + return $refusal; + } + if (array_key_exists('variable', $input)) { + return Errors::unsupported( + 'Verweise auf Variablen entfernt LIT MCP noch nicht; nichts wurde geändert.', + self::VARIABLES . ' Bis dahin nur Presets angeben (preset).' + ); + } + $target = $this->presetTarget($input); + if ($target instanceof WP_Error) { + return $target; + } + + return ($input['confirm'] ?? false) === true ? $this->confirmed($input, $target) : $this->dryRun($target); + } + + /** + * The preset to detach, resolved on the live inventory. + * + * @param array $input + */ + private function presetTarget(array $input): DetachTarget|WP_Error + { + $preset = $input['preset'] ?? null; + if (! is_array($preset)) { + return self::invalid(['preset' => 'Objekt mit module oder group und preset (Name oder ID) erwartet']); + } + try { + $postIds = PostIdsInput::parse($input['post_ids'] ?? null); + } catch (InvalidArgumentException $e) { + return self::invalid(['post_ids' => $e->getMessage()]); + } + try { + $scope = PresetScope::fromInput($preset, 'preset'); + } catch (InvalidArgumentException $e) { + [$path, $problem] = explode(': ', $e->getMessage(), 2) + [1 => '']; + + return self::invalid([$path => $problem]); + } + $name = is_string($preset['preset'] ?? null) ? trim($preset['preset']) : ''; + if ($name === '') { + return self::invalid(['preset.preset' => 'Name oder ID des Presets erwartet']); + } + + $book = PresetBook::fromDivi($this->writer->presets()); + try { + $found = $book->find($scope, $name); + } catch (InvalidArgumentException $e) { + return self::invalid(['preset.preset' => $e->getMessage()]); + } + if ($found === null) { + return Errors::notFound( + sprintf('Unbekanntes %s „%s“; nichts wurde geändert.', $scope->label(), $name), + 'Vorhandene Presets mit Namen und IDs liefert lit/divi-design-system (direkt: lit-divi-design-system).' + ); + } + + $id = (string) $found['id']; + $label = is_string($found['name'] ?? null) ? $found['name'] : $id; + $nested = $postIds === null && $book->nestedUsers($id) !== []; + + return new DetachTarget( + sprintf('%s „%s“', $scope->label(), $label), + $postIds, + // Explicit references only (R10), also for a default preset: no withGroups(), implicit uses stay. + new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => false]]), + static fn (): NodeRewrite => new DetachRewrite($scope, $id), + $nested ? static fn (PresetBook $presets): PresetBook => $presets->withoutNestedRef($id) : null, + 'anderen Presets', + $book->defaultId($scope) === $id ? [sprintf(self::DEFAULT_NOTE, $label, $scope->name)] : [], + self::DELETE_HINT + ); + } + + /** + * ONE confirm_required with the pages and, when presets nest the target, the preset step's diff and + * design_rev; a normal result when nothing references it any more. + * + * @return array|WP_Error + */ + private function dryRun(DetachTarget $target): array|WP_Error + { + $preview = $this->pages->preview($target->query, $target->rewrite, $target->postIds); + if ($preview instanceof WP_Error) { + return $preview; + } + $presets = $target->presets !== null ? $this->presetDryRun($target) : []; + if ($presets instanceof WP_Error) { + return $presets; + } + + if ($presets === [] && PageRewriteReport::isEmpty($preview)) { + $summary = sprintf('Keine Verweise auf %s gefunden – nichts zu tun.', $target->subject); + $done = $target->finished($preview['skipped'], $preview['hidden_count']); + + return PageRewriteReport::nothingToDo( + $summary . ($done ? ' ' . $target->doneHint : ''), + $preview, + $target->notes + ); + } + + $message = PageRewriteReport::dryRunMessage($preview, $target->change()); + if ($presets !== []) { + $message .= sprintf( + ' Zuerst entfernt der Aufruf die Verweise in %d %s (diff, mit Snapshot); dafür die design_rev mit ' + . 'confirm: true mitsenden.', + count($presets['diff']), + $target->nestedIn + ); + } + + return Errors::confirmRequired($message, $preview + $presets + ['warnings' => $target->notes]); + } + + /** + * The preset step's dry run through DesignChange::run() without confirm: its confirm_required carries diff and + * design_rev; a result without error means no preset nests the target any more. + * + * @return array{diff: list>, design_rev: string}|array{}|WP_Error + */ + private function presetDryRun(DetachTarget $target): array|WP_Error + { + $result = $this->change->run($this->name(), new PresetArea($this->writer), [], $this->nestedPlan($target)); + if (! $result instanceof WP_Error) { + return []; + } + if (Errors::codeOf($result) !== 'confirm_required') { + return $result; + } + $data = (array) $result->get_error_data(); + + return ['diff' => $data['diff'] ?? [], 'design_rev' => (string) ($data['design_rev'] ?? '')]; + } + + /** @return Closure(array): (DesignPlan|WP_Error) */ + private function nestedPlan(DetachTarget $target): Closure + { + return function (array $raw) use ($target): DesignPlan|WP_Error { + $before = PresetBook::fromDivi($raw); + $after = $target->presets === null ? $before : ($target->presets)($before); + $diff = PresetDiff::between($before, $after); + if ($diff === []) { + return new DesignPlan($after->data(), [], PresetDiff::summary([])); + } + // The whole inventory is saved: HTML/CSS of any preset would be stripped without unfiltered_html (I-2). + $refusal = DesignRights::markup($this->unfiltered, $after->markupOutside([])); + if ($refusal !== null) { + return $refusal; + } + + // The page usages ride along: a blocked live post refuses before the presets are written (P8a). + return new DesignPlan( + $after->data(), + $diff, + PresetDiff::summary($diff), + $this->usages->reportFor($target->query) + ); + }; + } + + /** + * Preset step first (stops on any error – nothing written), then at most PAGE_LIMIT pages. + * + * @param array $input + * @return array|WP_Error + * @throws WriteFailed When the preset step fails, or the first page fails without a preset step. + * @throws DiviLocked Likewise. + */ + private function confirmed(array $input, DetachTarget $target): array|WP_Error + { + $presets = null; + if ($target->presets !== null) { + $presets = $this->change->run( + $this->name(), + new PresetArea($this->writer), + $input, + $this->nestedPlan($target) + ); + if ($presets instanceof WP_Error) { + return $presets; + } + } + $wrotePresets = $presets !== null && $presets['diff'] !== []; + + try { + $pages = $this->pages->apply( + $this->name(), + $target->query, + $target->rewrite, + $target->postIds, + $target->change() + ); + } catch (WriteFailed | DiviLocked $e) { + // Thrown when the first page fails: the registrar would answer "nothing was saved" – untrue after the + // preset step. Without one, its mapping stays as for every Divi write. + if (! $wrotePresets) { + throw $e; + } + Log::failure($this->name(), $e); + + return self::afterPresets(self::firstPageError($e), $presets, $target); + } + if ($pages instanceof WP_Error) { + return $wrotePresets ? self::afterPresets($pages, $presets, $target) : $pages; + } + + $summary = PageRewriteReport::savedSummary($pages, $target->change()); + if ($wrotePresets) { + $summary = $presets['summary'] . ' ' . $summary; + } + if ($pages['remaining'] === 0 && $target->finished($pages['skipped'], $pages['hidden_count'])) { + $summary .= ' ' . $target->doneHint; + } + + return ToolResult::ok($summary) + ->snapshotId($presets['snapshot_id'] ?? null) + ->logId($presets['log_id'] ?? null) + ->warnings(array_merge($presets['warnings'] ?? [], $pages['warnings'], $target->notes)) + ->with('saved', $pages['saved']) + ->with('remaining', $pages['remaining']) + ->with('skipped', $pages['skipped']) + ->with('hidden_count', $pages['hidden_count']) + ->with('diff', $presets['diff'] ?? []) + ->with('design_rev', $presets['design_rev'] ?? null) + ->toArray(); + } + + /** + * The client error for a failure on the first page, mapped like AbilityRegistrar::errorFor(), but a clean rollback + * no longer claims that nothing was saved (afterPresets() adds what the preset step changed). + */ + private static function firstPageError(WriteFailed|DiviLocked $e): WP_Error + { + if ($e instanceof WriteFailed && ! $e->rolledBack) { + return $e->toError(); + } + $locked = DiviLocked::in($e); + if ($locked !== null) { + return $locked->toError(); + } + + return $e->withDetails([], state: 'Keine Seite wurde gespeichert.')->toError(); + } + + /** + * A page error after the preset step wrote: the client learns that the nested references are gone already. + * + * @param array $presets The preset step's ToolResult. + */ + private static function afterPresets(WP_Error $error, array $presets, DetachTarget $target): WP_Error + { + $data = (array) $error->get_error_data(); + + return Errors::make( + Errors::codeOf($error), + Errors::clientMessage($error) . sprintf( + ' Die Verweise in %s sind bereits entfernt (Snapshot %s).', + $target->nestedIn, + (string) $presets['snapshot_id'] + ), + (string) ($data['hint'] ?? ''), + (int) ($data['status'] ?? 500), + array_diff_key($data, ['status' => true, 'hint' => true]) + + ['snapshot_id' => $presets['snapshot_id'], 'diff' => $presets['diff']] + ); + } + + /** @param array $errors */ + private static function invalid(array $errors): WP_Error + { + return Errors::invalidInput('Ungültige Angaben; nichts wurde geändert.', $errors); + } +} +``` + +- [ ] **Step 7: GREEN mit unveränderten Tests, Lint** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignDetachAbilityTest|DetachRewriteTest|DetachTargetTest/' && composer lint` +Expected: `OK (33 tests, …)`, phpcs ohne Meldung. + +- [ ] **Step 8: Commit** + +Commit wie in den Global Constraints: + +```bash +git add src/Integrations/Divi/Design/DetachTarget.php \ + tests/Unit/Integrations/Divi/Design/DetachTargetTest.php \ + src/Integrations/Divi/Design/DiviDesignDetachAbility.php +git commit -m "refactor: DetachTarget ersetzt das Array in DiviDesignDetachAbility::target(); Verhalten unverändert" -m "Co-Authored-By: Claude " +``` + +#### 8b – `VariableDetachRewrite` (V9) + +- [ ] **Step 9: Failing Test `VariableDetachRewriteTest` schreiben** + +`ref()` ist öffentlich, weil `DiviDesignDetachAbilityTest` (8c) dieselben Verweise baut. Datei `tests/Unit/Integrations/Divi/Design/VariableDetachRewriteTest.php`: + +```php + ['value' => $value]]; + } + + /** + * @param list}> $blocks name and attributes, in the order of the column + * @return NodeIndex Blocks below s0.r0.c0 are m0, m1, … (g0, … for instances, x0, … for other blocks). + */ + private static function index(array $blocks): NodeIndex + { + $inner = ''; + foreach ($blocks as [$name, $attrs]) { + $inner .= sprintf('', $name, Json::encode($attrs)); + } + + return NodeIndex::build((new Parser())->parse( + '' . $inner + . '' + )); + } + + private static function node(NodeIndex $index, string $id): Block + { + $block = $index->find('s0.r0.c0.' . $id); + self::assertNotNull($block); + + return $block; + } + + public function testReplacesWholeNumberReferencesAtAnyDepth(): void + { + $index = self::index([['divi/button', [ + 'button' => ['decoration' => ['border' => self::desktop([ + 'radius' => ['topLeft' => self::ref(self::RADIUS), 'topRight' => self::ref(self::RADIUS)], + ])]], + 'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['top' => '10px']])]], + ]]]); + + $attrs = (new VariableDetachRewrite(self::RADIUS, '8px', 'numbers'))->rewrite(self::node($index, 'm0')); + + self::assertSame( + ['topLeft' => '8px', 'topRight' => '8px'], + $attrs['button']['decoration']['border']['desktop']['value']['radius'] ?? null + ); + self::assertSame('10px', $attrs['module']['decoration']['spacing']['desktop']['value']['padding']['top']); + } + + public function testAStringsVariableKeepsBeforeAndAfterOfTheReference(): void + { + $index = self::index([ + ['divi/text', ['content' => ['innerContent' => self::desktop( + self::ref(self::CLAIM, ['before' => 'Jetzt: ', 'after' => '!']) + )]]], + ['divi/text', ['content' => ['innerContent' => self::desktop(self::ref(self::CLAIM))]]], + ]); + $rewrite = new VariableDetachRewrite(self::CLAIM, 'Gut beraten', 'strings'); + + self::assertSame( + 'Jetzt: Gut beraten!', + $rewrite->rewrite(self::node($index, 'm0'))['content']['innerContent']['desktop']['value'] ?? null + ); + self::assertSame( + 'Gut beraten', + $rewrite->rewrite(self::node($index, 'm1'))['content']['innerContent']['desktop']['value'] ?? null + ); + } + + public function testOtherTypesIgnoreBeforeAndAfter(): void + { + self::assertSame( + 'Open Sans', + VariableDetachRewrite::replacement(self::ref('gvid-font000001', ['before' => 'x']), 'Open Sans', 'fonts') + ); + self::assertSame('a1b', VariableDetachRewrite::replacement(self::ref(self::CLAIM, [ + 'before' => 'a', 'after' => 'b', + ]), '1', 'strings')); + } + + public function testFontsAndOtherReferencesStayUntouched(): void + { + $color = VariableRef::serialize('color', 'gcid-primary-color', []); + $dynamic = VariableRef::serialize('content', 'post_title', []); + $index = self::index([ + ['divi/heading', [ + 'title' => ['decoration' => ['font' => ['font' => self::desktop([ + 'family' => self::ref('gvid-font000001'), + 'color' => $color, + ])]]], + 'content' => ['innerContent' => self::desktop($dynamic)], + ]], + ['divi/text', ['content' => ['innerContent' => self::desktop(self::ref(self::RADIUS))]]], + ['core/paragraph', ['content' => self::ref('gvid-font000001')]], + ]); + $rewrite = new VariableDetachRewrite('gvid-font000001', 'Open Sans', 'fonts'); + + $attrs = $rewrite->rewrite(self::node($index, 'm0')); + self::assertSame( + ['family' => 'Open Sans', 'color' => $color], + $attrs['title']['decoration']['font']['font']['desktop']['value'] ?? null + ); + self::assertSame($dynamic, $attrs['content']['innerContent']['desktop']['value']); + self::assertNull($rewrite->rewrite(self::node($index, 'm1')), 'another variable'); + self::assertNull($rewrite->rewrite(self::node($index, 'x0')), 'not a Divi block'); + } + + public function testEmbeddedReferencesStayAndAreNoted(): void + { + $embedded = 'Ab ' . self::ref(self::RADIUS) . ' Rundung'; + $index = self::index([ + ['divi/text', [ + 'content' => ['innerContent' => self::desktop($embedded)], + 'module' => ['decoration' => ['border' => self::desktop([ + 'radius' => ['topLeft' => self::ref(self::RADIUS)], + ])]], + ]], + ['divi/text', ['content' => ['innerContent' => self::desktop($embedded)]]], + ]); + $rewrite = new VariableDetachRewrite(self::RADIUS, '8px', 'numbers'); + $first = self::node($index, 'm0'); + + $attrs = $rewrite->rewrite($first); + self::assertSame($embedded, $attrs['content']['innerContent']['desktop']['value'] ?? null); + $first->setAttrs((array) $attrs); + self::assertSame( + ['s0.r0.c0.m0: Die Variable steht hier eingebettet in einem längeren Text; dieser Verweis bleibt stehen – ' + . 'im Divi-Builder ersetzen.'], + $rewrite->notes($first, 's0.r0.c0.m0') + ); + self::assertNull($rewrite->rewrite(self::node($index, 'm1')), 'only embedded: nothing to replace'); + self::assertSame( + [], + (new VariableDetachRewrite(self::CLAIM, 'x', 'strings'))->notes($first, 's0.r0.c0.m0'), + 'another variable is not noted' + ); + } + + public function testInstancesThatMentionTheIdConcernTheRewrite(): void + { + $index = self::index([ + ['divi/global-layout', ['globalModule' => '1058', 'blockName' => 'divi/text', 'localAttrs' => [ + 'content' => ['innerContent' => self::desktop(self::ref(self::RADIUS))], + ]]], + ['divi/global-layout', ['globalModule' => '1059', 'blockName' => 'divi/text']], + ]); + $rewrite = new VariableDetachRewrite(self::RADIUS, '8px', 'numbers'); + + self::assertTrue($rewrite->concernsInstance(self::node($index, 'g0'))); + self::assertFalse($rewrite->concernsInstance(self::node($index, 'g1'))); + } +} +``` + +- [ ] **Step 10: RED** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableDetachRewriteTest` +Expected: FAIL – `Error: Class "LIT\MCP\Integrations\Divi\Design\VariableDetachRewrite" not found` (6 Fehler). + +- [ ] **Step 11: `DetachRewrite::mentions()` öffentlich machen** + +`src/Integrations/Divi/Design/DetachRewrite.php`, Zeilen 127–128 – vorher: + +```php + /** Whether the attributes of $block or of any block below mention $id (fail closed on unencodable values). */ + private static function mentions(Block $block, string $id): bool +``` + +nachher: + +```php + /** + * Whether the attributes of $block or of any block below mention $id (fail closed on unencodable values); also + * VariableDetachRewrite::concernsInstance(). + */ + public static function mentions(Block $block, string $id): bool +``` + +- [ ] **Step 12: `VariableDetachRewrite` implementieren** + +Datei `src/Integrations/Divi/Design/VariableDetachRewrite.php`: + +```php +",…}})$ becomes the value – for a strings variable with the reference's own before/after + * around it. References embedded in a longer text stay (Divi resolves them inside the text; cutting them out would + * change the text) and are named in notes(). Other references – colors, other variables, dynamic content – stay + * untouched. Pure PHP. + */ +final class VariableDetachRewrite implements NodeRewrite +{ + private const EMBEDDED = '%s: Die Variable steht hier eingebettet in einem längeren Text; dieser Verweis bleibt ' + . 'stehen – im Divi-Builder ersetzen.'; + + public function __construct(private string $id, private string $value, private string $variableType) + { + } + + /** + * What one whole reference to a variable of $variableType becomes: its value, for strings with the reference's + * before/after settings around it. + */ + public static function replacement(string $reference, string $value, string $variableType): string + { + if ($variableType !== 'strings') { + return $value; + } + $settings = VariableRef::settingsOf($reference); + $before = is_string($settings['before'] ?? null) ? $settings['before'] : ''; + $after = is_string($settings['after'] ?? null) ? $settings['after'] : ''; + + return $before . $value . $after; + } + + public function rewrite(Block $block): ?array + { + if (! $block->isDivi()) { + return null; + } + + $changed = false; + $attrs = $this->resolved($block->attrs(), $changed); + + return $changed && is_array($attrs) ? $attrs : null; + } + + public function notes(Block $block, string $nodeId): array + { + return $this->embeds($block->attrs()) ? [sprintf(self::EMBEDDED, $nodeId)] : []; + } + + public function concernsInstance(Block $instance): bool + { + return DetachRewrite::mentions($instance, $this->id); + } + + /** $value with every whole reference to the variable replaced; $changed tells whether one was. */ + private function resolved(mixed $value, bool &$changed): mixed + { + if (is_string($value)) { + if (VariableRef::nameOf($value) !== $this->id) { + return $value; + } + $changed = true; + + return self::replacement($value, $this->value, $this->variableType); + } + if (! is_array($value)) { + return $value; + } + + foreach ($value as $key => $item) { + $value[$key] = $this->resolved($item, $changed); + } + + return $value; + } + + /** Whether a string below $value names the variable inside a longer text. */ + private function embeds(mixed $value): bool + { + if (is_string($value)) { + return VariableRef::contains($value) && str_contains($value, '"' . $this->id . '"'); + } + if (! is_array($value)) { + return false; + } + + foreach ($value as $item) { + if ($this->embeds($item)) { + return true; + } + } + + return false; + } +} +``` + +- [ ] **Step 13: GREEN, Lint** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/VariableDetachRewriteTest|DetachRewriteTest/' && composer lint` +Expected: `OK (11 tests, …)`, phpcs ohne Meldung. + +- [ ] **Step 14: Commit** + +Commit wie in den Global Constraints: + +```bash +git add src/Integrations/Divi/Design/VariableDetachRewrite.php \ + src/Integrations/Divi/Design/DetachRewrite.php \ + tests/Unit/Integrations/Divi/Design/VariableDetachRewriteTest.php +git commit -m "feat: VariableDetachRewrite ersetzt ganze Verweise auf eine Variable durch ihren Wert (V9)" -m "Co-Authored-By: Claude " +``` + +#### 8c – `lit/divi-design-detach` mit `variable` + +- [ ] **Step 15: Tests anpassen und ergänzen** + +`tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php` – die Hilfe `ability()` reicht einen `GlobalVariablesAdapter` durch, `testVariablesFollowWithStage4c()` entfällt, alle übrigen Preset-Tests bleiben unverändert. Die Variablen-Fixture ist Divis Speicherform (`VariableBook::fromDivi()`); der Matcher `referencing()` prüft die Abfrage nur über `UsageQuery::matches()` (Vertrag Task 5: Inhalt, dessen Attribute die ID nennen). + +*Importe (nach `use LIT\MCP\Abilities\WriteFailed;`)* – vorher: + +```php +use LIT\MCP\Abilities\WriteFailed; +``` + +nachher: + +```php +use LIT\MCP\Abilities\WriteFailed; +use LIT\MCP\Integrations\Divi\BlockTree\Block; +``` + +*Importe (nach `DiviDesignDetachAbility`)* – vorher: + +```php +use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility; +``` + +nachher: + +```php +use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility; +use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; +``` + +*Importe (nach `PresetUsageQuery`)* – vorher: + +```php +use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery; +``` + +nachher: + +```php +use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery; +use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery; +``` + +*Importe (nach `SnapshotStore`)* – vorher: + +```php +use LIT\MCP\Integrations\Divi\Design\SnapshotStore; +``` + +nachher: + +```php +use LIT\MCP\Integrations\Divi\Design\SnapshotStore; +use LIT\MCP\Integrations\Divi\Design\VariableDetachRewrite; +``` + +*Konstanten (Zeile 34)* – vorher: + +```php + private const LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext']; +``` + +nachher: + +```php + private const LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext']; + + private const RADIUS = 'gvid-radius0001'; + + private const CLAIM = 'gvid-claim00001'; +``` + +*Hilfe `ability()` (Zeilen 50–58)* – vorher: + +```php + ?UsageReport $report = null, + bool $unfiltered = true + ): DiviDesignDetachAbility { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn($report ?? new UsageReport(0, [], 0, 0, [], 0, false)); + + return new DiviDesignDetachAbility( + $writer ?? self::readOnlyWriter(), + new DesignChange +``` + +nachher: + +```php + ?UsageReport $report = null, + bool $unfiltered = true, + ?GlobalVariablesAdapter $variables = null + ): DiviDesignDetachAbility { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn($report ?? new UsageReport(0, [], 0, 0, [], 0, false)); + + return new DiviDesignDetachAbility( + $writer ?? self::readOnlyWriter(), + $variables ?? self::variableAdapter(), + new DesignChange +``` + +*neue Hilfen vor `untouched()` (Zeile 80)* – vorher: + +```php + private static function untouched(): PageRewriter&MockInterface +``` + +nachher: + +```php + /** Reads variables() (the Variablen-Verwaltung is allowed unless $manage is false), never saves. */ + private static function variableAdapter(bool $manage = true): GlobalVariablesAdapter&MockInterface + { + $variables = Mockery::mock(GlobalVariablesAdapter::class); + $variables->allows('read')->andReturn(self::variables()); + $variables->allows('canManage')->andReturn($manage); + $variables->expects('save')->never(); + + return $variables; + } + + /** @return array>> Global variables in Divi's stored form. */ + private static function variables(): array + { + return [ + 'numbers' => [self::RADIUS => [ + 'id' => self::RADIUS, 'label' => 'Eckenradius', 'value' => '8px', 'order' => '1', + 'status' => 'active', 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'numbers', + ]], + // Archived or not does not matter for a detach (V9). + 'strings' => [self::CLAIM => [ + 'id' => self::CLAIM, 'label' => 'Claim', 'value' => 'Gut beraten', 'order' => '', + 'status' => 'archived', 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'strings', + ]], + ]; + } + + /** @return array PresetBookTest::stored() with the radius variable in preset "CTA primär". */ + private static function storedWithVariable(): array + { + $stored = PresetBookTest::stored(); + $radius = ['topLeft' => VariableDetachRewriteTest::ref(self::RADIUS)]; + $stored['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = ['button' => ['decoration' => [ + 'border' => ['desktop' => ['value' => ['radius' => $radius]]], + ]]]; + + return $stored; + } + + /** Matches the reference query of a preview()/apply() call: it finds $id and nothing else. */ + private static function referencing(string $id): Closure + { + return static fn (ReferenceUsageQuery $query): bool => $query->matches( + '' + ) && ! $query->matches(''); + } + + /** Matches a VariableDetachRewrite strategy that turns a whole reference to $id into $expected. */ + private static function resolving(string $id, string $expected, array $settings = []): Closure + { + return static function (Closure $rewrite) use ($id, $expected, $settings): bool { + $strategy = $rewrite(); + $block = Block::create('divi/text', ['x' => VariableDetachRewriteTest::ref($id, $settings)]); + + return $strategy instanceof VariableDetachRewrite && $strategy->rewrite($block) === ['x' => $expected]; + }; + } + + private static function untouched(): PageRewriter&MockInterface +``` + +*`testVariablesFollowWithStage4c()` (Zeilen 113–121) wird durch die Variablen-Tests ersetzt* – vorher: + +```php + public function testVariablesFollowWithStage4c(): void + { + $error = self::assertToolError( + $this->ability(self::untouched())->execute(['variable' => 'Eckenradius']), + 'unsupported' + ); + + self::assertStringContainsString('Variablen folgen mit Etappe 4c.', $error['hint']); + } +``` + +nachher: + +```php + public function testAVariableNeedsTheVariableRightsToo(): void + { + $ability = $this->ability(self::untouched(), null, null, null, null, true, self::variableAdapter(false)); + + self::assertToolError($ability->permission(['variable' => 'Eckenradius']), 'forbidden'); + self::assertToolError($ability->execute(['variable' => 'Eckenradius']), 'forbidden'); + self::assertTrue($ability->permission(['preset' => self::LESETEXT])); + } + + public function testUnknownAmbiguousAndColorVariables(): void + { + $ability = $this->ability(self::untouched()); + + $unknown = self::assertToolError($ability->execute(['variable' => 'Fehlt']), 'not_found'); + self::assertStringContainsString('lit/divi-colors-set', $unknown['hint']); + $color = self::assertToolError($ability->execute(['variable' => 'gcid-primary-color']), 'unsupported'); + self::assertStringContainsString('nur deaktivieren', $color['hint']); + self::assertArrayHasKey('variable', self::errorFields($ability->execute(['variable' => ' ']))); + $both = $ability->execute(['variable' => 'Eckenradius', 'preset' => self::LESETEXT]); + self::assertSame('entweder preset oder variable angeben, nicht beides', self::errorFields($both)['variable']); + } + + public function testVariableDryRunListsPagesAndThePresetsThatUseIt(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview') + ->with( + Mockery::on(self::referencing(self::RADIUS)), + Mockery::on(self::resolving(self::RADIUS, '8px')), + null + ) + ->andReturn(PageRewriteReportTest::preview(1)); + $pages->expects('apply')->never(); + $ability = $this->ability($pages, self::readOnlyWriter(self::storedWithVariable())); + + $dry = self::assertToolError($ability->execute(['variable' => 'eckenradius']), 'confirm_required'); + + self::assertSame(12, $dry['pages'][0]['post_id']); + self::assertSame([['aaaaaaaaaa', 'changed']], array_map( + static fn (array $row): array => [$row['id'], $row['action']], + $dry['diff'] + )); + self::assertSame(PresetBook::fromDivi(self::storedWithVariable())->rev(), $dry['design_rev']); + self::assertStringStartsWith('Entkoppeln von Variable „Eckenradius“ – betroffen', $dry['message']); + self::assertStringContainsString('die Verweise in 1 Presets (diff, mit Snapshot)', $dry['message']); + } + + public function testAStringsVariableKeepsBeforeAndAfterAndArchivedOnesWork(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview') + ->with( + Mockery::on(self::referencing(self::CLAIM)), + Mockery::on(self::resolving(self::CLAIM, '„Gut beraten“', ['before' => '„', 'after' => '“'])), + null + ) + ->andReturn(PageRewriteReportTest::preview(1)); + + $dry = self::assertToolError($this->ability($pages)->execute(['variable' => self::CLAIM]), 'confirm_required'); + + self::assertArrayNotHasKey('diff', $dry, 'no preset uses the claim'); + } + + public function testVariableConfirmResolvesThePresetsFirstThenThePages(): void + { + $calls = []; + $stored = self::storedWithVariable(); + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('presets')->andReturn($stored); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->expects('savePresets') + ->with( + Mockery::on(static fn (array $rest): bool => ($rest['module'][0]['items'][0]['attrs']['button'] + ['decoration']['border']['desktop']['value']['radius']['topLeft'] ?? null) === '8px'), + false + ) + ->andReturnUsing(static function () use (&$calls, $stored): array { + $calls[] = 'presets'; + + return PresetBook::fromDivi($stored)->withVariableResolved(self::RADIUS, '8px')->data(); + }); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->with('presets', $stored, 'lit/divi-design-detach')->andReturn('presets-5'); + $snapshots->expects('attachLog'); + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('apply') + ->with( + 'lit/divi-design-detach', + Mockery::on(self::referencing(self::RADIUS)), + Mockery::on(self::resolving(self::RADIUS, '8px')), + null, + 'Entkoppeln von Variable „Eckenradius“' + ) + ->andReturnUsing(static function () use (&$calls): array { + $calls[] = 'pages'; + + return PageRewriteReportTest::applied(1, 0); + }); + $ability = $this->ability($pages, $writer, $snapshots, new FakeChangeLog(9)); + + $result = $ability->execute([ + 'variable' => self::RADIUS, + 'confirm' => true, + 'design_rev' => PresetBook::fromDivi($stored)->rev(), + ]); + + self::assertSame(['presets', 'pages'], $calls); + self::assertSame('presets-5', $result['snapshot_id']); + self::assertSame('aaaaaaaaaa', $result['diff'][0]['id']); + self::assertStringStartsWith('Presets: 1 geändert.', $result['summary']); + self::assertStringEndsWith( + 'Jetzt kann lit/divi-variables-set die Variable archivieren (status: archived).', + $result['summary'] + ); + } + + public function testVariablePostIdsSkipThePresetStep(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('apply') + ->with('lit/divi-design-detach', Mockery::any(), Mockery::any(), [12], Mockery::type('string')) + ->andReturn(PageRewriteReportTest::applied(1, 0)); + $ability = $this->ability($pages, self::readOnlyWriter(self::storedWithVariable())); + + $result = $ability->execute(['variable' => 'Eckenradius', 'post_ids' => [12], 'confirm' => true]); + + self::assertSame([], $result['diff']); + self::assertNull($result['snapshot_id']); + self::assertStringNotContainsString('lit/divi-variables-set', $result['summary']); + } + + public function testNoVariableReferenceLeftPointsToArchiving(): void + { + $pages = Mockery::mock(PageRewriter::class); + $pages->expects('preview')->andReturn(PageRewriteReportTest::preview(0)); + + $result = $this->ability($pages)->execute(['variable' => 'Eckenradius']); + + self::assertSame( + 'Keine Verweise auf Variable „Eckenradius“ gefunden – nichts zu tun. Jetzt kann lit/divi-variables-set ' + . 'die Variable archivieren (status: archived).', + $result['summary'] + ); + } + + public function testFeaturesPerBranch(): void + { + $ability = $this->ability(self::untouched()); + + self::assertNotContains(DiviFeature::VariableWrite, $ability->diviFeatures(['preset' => self::LESETEXT])); + self::assertContains(DiviFeature::VariableWrite, $ability->diviFeatures(['variable' => 'Eckenradius'])); + self::assertContains(DiviFeature::PresetWrite, $ability->diviFeatures(['variable' => 'Eckenradius'])); + } +``` + +- [ ] **Step 16: RED** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviDesignDetachAbilityTest` +Expected: FAIL – alle 33 Tests mit `TypeError: LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility::__construct(): Argument #2 ($change) must be of type LIT\MCP\Integrations\Divi\Design\DesignChange, Mockery_…_GlobalVariablesAdapter given`. + +- [ ] **Step 17: Ability um den Zweig `variable` erweitern** + +`src/Integrations/Divi/Design/DiviDesignDetachAbility.php` (Zeilenangaben: Stand nach Step 6): + +*Klassen-Docblock (Zeilen 23–32)* – vorher: + +```php +/** + * lit/divi-design-detach (spec §6.8, ruling R10 – presets only): removes every explicit reference to one preset – + * from the modulePreset/groupPreset stacks of pages, library entries and Theme Builder layouts (DetachRewrite through + * the PageRewriter) and, for a group preset without post_ids, from the groupPresets of other presets (DesignChange, + * snapshot "presets"). Modules fall back to the next preset of their stack or the default. The dry run is ONE + * confirm_required with the pages and, when other presets nest it, the diff and design_rev of the preset step. + * Confirmed, the preset step runs first – its plan carries the page usages, so a blocked live post refuses before + * anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A default preset keeps working implicitly + * (Divi applies it); only explicit references go. Variables follow with Etappe 4c. + */ +``` + +nachher: + +```php +/** + * lit/divi-design-detach (spec §6.8, rulings R10, V9): removes every explicit reference to one preset or one global + * variable from pages, library entries and Theme Builder layouts (DetachRewrite or VariableDetachRewrite through the + * PageRewriter) and, without post_ids, from presets that nest it (DesignChange, snapshot "presets"): a group preset + * leaves the groupPresets of other presets, a variable reference in a preset becomes the variable's value. Modules + * fall back to the next preset of their stack or the default; a variable reference becomes the value (the variable + * itself stays as it is). The dry run is ONE confirm_required with the pages and, when presets nest the target, the + * diff and design_rev of the preset step. Confirmed, the preset step runs first – its plan carries the page usages, + * so a blocked live post refuses before anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A + * default preset keeps working implicitly (Divi applies it); only explicit references go. Global colors have no + * detach (ruling V9: they are only deactivated). + */ +``` + +*Konstante `VARIABLES` entfällt (Zeilen 35–37)* – vorher: + +```php + private const VARIABLES = 'Variablen folgen mit Etappe 4c.'; + + private const DELETE_HINT +``` + +nachher: + +```php + private const DELETE_HINT +``` + +*neue Konstanten nach `DEFAULT_NOTE` (Zeilen 39–40)* – vorher: + +```php + private const DEFAULT_NOTE = '„%s“ ist das Standard-Preset von %s: Divi wendet es weiter auf Module ohne eigenes ' + . 'Preset an; entfernt werden nur die ausdrücklichen Verweise.'; +``` + +nachher: + +```php + private const DEFAULT_NOTE = '„%s“ ist das Standard-Preset von %s: Divi wendet es weiter auf Module ohne eigenes ' + . 'Preset an; entfernt werden nur die ausdrücklichen Verweise.'; + + private const ARCHIVE_HINT = 'Jetzt kann lit/divi-variables-set die Variable archivieren (status: archived).'; + + private const NO_COLORS = 'Globale Farben lassen sich nicht entkoppeln, nur deaktivieren (lit/divi-colors-set).'; +``` + +*Konstruktor (Zeilen 43–45)* – vorher: + +```php + private DesignSystemWriter $writer, + private DesignChange $change, +``` + +nachher: + +```php + private DesignSystemWriter $writer, + private GlobalVariablesAdapter $variables, + private DesignChange $change, +``` + +*`label()` (Zeile 60)* – vorher: + +```php + return 'Divi-Preset-Verweise entfernen'; +``` + +nachher: + +```php + return 'Divi-Preset- und Variablen-Verweise entfernen'; +``` + +*`description()` (Zeilen 65–76)* – vorher: + +```php + return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) aus ' + . 'Seiten, Bibliothekseinträgen und Theme-Builder-Layouts und – bei Gruppen-Presets – aus anderen Presets; ' + . 'Module fallen auf das nächste Preset im Stapel bzw. das Standard-Preset zurück. post_ids beschränkt auf ' + . 'diese Seiten (Verweise in Presets bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff ' + . 'und design_rev), nach Rückfrage beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine ' + . 'nennt); je Aufruf höchstens ' . PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit ' + . 'post_ids können nur die genannten Seiten das Schreiben blockieren und die Obergrenze für sehr viele ' + . 'Seiten gilt nicht; eine genannte veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht ' + . 'verweigert den ganzen Aufruf (forbidden, blocked_ids), eine genannte andere Seite ohne ' + . 'Bearbeitungsrecht steht in skipped (bei mehr als 2000 Live-Fundstellen entfällt die Vorabprüfung, ' + . 'dann auch eine genannte Live-Seite). Danach kann lit/divi-presets-delete das Preset löschen. ' + . self::VARIABLES; +``` + +nachher: + +```php + return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) oder auf ' + . 'eine globale Variable (variable: Name oder ID) aus Seiten, Bibliothekseinträgen und ' + . 'Theme-Builder-Layouts und – bei Gruppen-Presets und Variablen – aus Presets; Module fallen auf das ' + . 'nächste Preset im Stapel bzw. das Standard-Preset zurück, ein Variablenverweis wird durch den aktuellen ' + . 'Wert der Variablen ersetzt (die Variable selbst bleibt; Verweise mitten in einem längeren Text bleiben ' + . 'stehen und stehen als Hinweis in warnings). post_ids beschränkt auf diese Seiten (Verweise in Presets ' + . 'bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff und design_rev), nach Rückfrage ' + . 'beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine nennt); je Aufruf höchstens ' + . PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit post_ids können nur die genannten ' + . 'Seiten das Schreiben blockieren und die Obergrenze für sehr viele Seiten gilt nicht; eine genannte ' + . 'veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht verweigert den ganzen Aufruf ' + . '(forbidden, blocked_ids), eine genannte andere Seite ohne Bearbeitungsrecht steht in skipped (bei mehr ' + . 'als 2000 Live-Fundstellen entfällt die Vorabprüfung, dann auch eine genannte Live-Seite). Danach kann ' + . 'lit/divi-presets-delete das Preset löschen bzw. lit/divi-variables-set die Variable archivieren. ' + . self::NO_COLORS; +``` + +*`inputSchema()`, Eigenschaft `variable` (Zeile 93)* – vorher: + +```php +'variable' => ['type' => 'string', 'description' => self::VARIABLES], +``` + +nachher: + +```php +'variable' => [ + 'type' => 'string', + 'description' => 'Name oder ID einer globalen Variablen (statt preset). ' . self::NO_COLORS, + ], +``` + +*`diviFeatures()` – Docblock und Anfang (Zeilen 118–124)* – vorher: + +```php + /** + * Like lit/divi-presets-assign (it rewrites every kind of post), plus the preset step that removes references + * nested in other presets (PresetWrite). + */ + public function diviFeatures(array $input): array + { + return [ +``` + +nachher: + +```php + /** + * Like lit/divi-presets-assign (it rewrites every kind of post), plus the preset step that removes references + * nested in presets (PresetWrite); a variable is read and its rights checked through GlobalVariablesAdapter + * (VariableWrite). + */ + public function diviFeatures(array $input): array + { + $features = [ +``` + +*Ende von `diviFeatures()`, `permission()` und Anfang von `execute()` (Zeilen 133–154)* – vorher: + +```php + DiviFeature::Roles, + ]; + } + + public function permission(array $input): bool|WP_Error + { + return DesignRights::presets($this->writer) ?? true; + } + + public function execute(array $input): mixed + { + $refusal = DesignRights::presets($this->writer); + if ($refusal !== null) { + return $refusal; + } + if (array_key_exists('variable', $input)) { + return Errors::unsupported( + 'Verweise auf Variablen entfernt LIT MCP noch nicht; nichts wurde geändert.', + self::VARIABLES . ' Bis dahin nur Presets angeben (preset).' + ); + } + $target = $this->presetTarget($input); +``` + +nachher: + +```php + DiviFeature::Roles, + ]; + + return array_key_exists('variable', $input) ? [...$features, DiviFeature::VariableWrite] : $features; + } + + public function permission(array $input): bool|WP_Error + { + return $this->rights($input) ?? true; + } + + public function execute(array $input): mixed + { + $refusal = $this->rights($input); + if ($refusal !== null) { + return $refusal; + } + if (array_key_exists('variable', $input) && array_key_exists('preset', $input)) { + return self::invalid(['variable' => 'entweder preset oder variable angeben, nicht beides']); + } + $target = array_key_exists('variable', $input) ? $this->variableTarget($input) : $this->presetTarget($input); +``` + +*neue Methoden `rights()` und `variableTarget()` vor `presetTarget()` (Docblock-Anfang Zeilen 162–163)* – vorher: + +```php + /** + * The preset to detach, resolved on the live inventory. +``` + +nachher: + +```php + /** + * Spec §10: presets need the preset rights; a variable also the variable rights – the preset step writes presets + * either way (V9). + * + * @param array $input + */ + private function rights(array $input): ?WP_Error + { + $refusal = DesignRights::presets($this->writer); + if ($refusal !== null || ! array_key_exists('variable', $input)) { + return $refusal; + } + + return DesignRights::variables($this->writer, $this->variables); + } + + /** + * The global variable to detach, resolved on the live inventory; its current value replaces the references + * (V9: whether it is active or archived does not matter). + * + * @param array $input + */ + private function variableTarget(array $input): DetachTarget|WP_Error + { + $needle = is_string($input['variable'] ?? null) ? trim($input['variable']) : ''; + if ($needle === '') { + return self::invalid(['variable' => 'Name oder ID der Variablen erwartet']); + } + try { + $postIds = PostIdsInput::parse($input['post_ids'] ?? null); + } catch (InvalidArgumentException $e) { + return self::invalid(['post_ids' => $e->getMessage()]); + } + if (str_starts_with($needle, 'gcid-')) { + return Errors::unsupported( + sprintf('„%s“ ist eine globale Farbe; nichts wurde geändert.', $needle), + self::NO_COLORS + ); + } + + $book = VariableBook::fromDivi($this->variables->read()); + try { + $found = $book->find($needle); + } catch (InvalidArgumentException $e) { + return self::invalid(['variable' => $e->getMessage()]); + } + if ($found === null) { + return Errors::notFound( + sprintf('Unbekannte Variable „%s“; nichts wurde geändert.', $needle), + 'Vorhandene Variablen mit Namen und IDs liefert lit/divi-design-system (direkt: ' + . 'lit-divi-design-system). ' . self::NO_COLORS + ); + } + + $id = $found['id']; + $type = $found['type']; + $entry = $book->entry($type, $id) ?? []; + $label = is_string($entry['label'] ?? null) && $entry['label'] !== '' ? $entry['label'] : $id; + $value = $entry['value'] ?? null; + if (! is_string($value)) { + return Errors::unsupported( + sprintf('Variable „%s“ (Typ %s) hat keinen Textwert; nichts wurde geändert.', $label, $type), + 'Verweise auf diese Variable im Divi-Builder lösen.' + ); + } + $nested = $postIds === null && PresetBook::fromDivi($this->writer->presets())->referencing($id) !== []; + + return new DetachTarget( + sprintf('Variable „%s“', $label), + $postIds, + new ReferenceUsageQuery([$id]), + static fn (): NodeRewrite => new VariableDetachRewrite($id, $value, $type), + $nested ? static fn (PresetBook $presets): PresetBook => $presets->withVariableResolved($id, $value, $type === 'strings') : null, + 'Presets', + [], + self::ARCHIVE_HINT + ); + } + + /** + * The preset to detach, resolved on the live inventory. +``` + +- [ ] **Step 18: Konstruktoraufruf in `DiviIntegration`, Fähigkeiten-Tabelle, Smoke** + +Ohne die Anpassung bricht `DiviIntegration::abilities()` (u. a. `DiviIntegrationTest`, `DiviToolFeaturesTest`). Nur der Konstruktoraufruf; alles Weitere der Verdrahtung bleibt Task 10. + +`src/Integrations/Divi/DiviIntegration.php`: + +*Import (nach `DiviDesignDetachAbility`)* – vorher: + +```php +use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility; +``` + +nachher: + +```php +use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility; +use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; +``` + +*Konstruktoraufruf (Zeilen 275–281)* – vorher: + +```php + new DiviDesignDetachAbility( + $this->designWriter, + $this->designChange(), +``` + +nachher: + +```php + new DiviDesignDetachAbility( + $this->designWriter, + new GlobalVariablesAdapter(), + $this->designChange(), +``` + +`tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php` (die Zeile in `EXPECTED` bleibt: ohne `variable` ändert sich nichts): + +*`inputCases()`, vor `'library-list'` (Zeile 176)* – vorher: + +```php + 'library-list' => ['lit/library-list', [], ['library-read', 'roles']], +``` + +nachher: + +```php + 'design-detach of a variable' => [ + 'lit/divi-design-detach', ['variable' => 'Eckenradius'], + [ + 'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', + 'library-write', 'tb-write', 'roles', 'variable-write', + ], + ], + 'library-list' => ['lit/library-list', [], ['library-read', 'roles']], +``` + +`tests/Integration/divi-presets-assign-smoke.sh` (eine unbekannte Variable ist jetzt `not_found`): + +*Zeilen 219–220* – vorher: + +```bash +UNSUPPORTED=$(ability lit/divi-design-detach '{"variable": "Irgendeine Variable"}') +[ "$(error_code "$UNSUPPORTED")" = "unsupported" ] || fail "detach of a variable: $UNSUPPORTED" +``` + +nachher: + +```bash +UNKNOWN_VARIABLE=$(ability lit/divi-design-detach '{"variable": "Irgendeine Variable"}') +[ "$(error_code "$UNKNOWN_VARIABLE")" = "not_found" ] || fail "detach of an unknown variable: $UNKNOWN_VARIABLE" +``` + +*Zeile 230* – vorher: + +```bash +pass "group preset is in use, variable detach unsupported, stale design_rev refused without writing" +``` + +nachher: + +```bash +pass "group preset is in use, unknown variable not found, stale design_rev refused without writing" +``` + +- [ ] **Step 19: GREEN, Gesamtlauf, Lint** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignDetachAbilityTest|DiviToolFeaturesTest|DiviIntegrationTest/'` +Expected: PASS (`DiviDesignDetachAbilityTest` 33 Tests, `DiviToolFeaturesTest` 35). + +Run: `composer test && composer lint` +Expected: alle Tests grün, phpcs ohne Meldung. + +- [ ] **Step 20: Commit** + +Commit wie in den Global Constraints: + +```bash +git add src/Integrations/Divi/Design/DiviDesignDetachAbility.php \ + tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php \ + src/Integrations/Divi/DiviIntegration.php \ + tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php \ + tests/Integration/divi-presets-assign-smoke.sh +git commit -m "feat: lit/divi-design-detach entkoppelt Variablen – Verweise in Seiten und Presets werden durch den Wert ersetzt (V9)" -m "Co-Authored-By: Claude " +``` + +Integration (optional, Smoke-Anpassung): `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-assign-smoke.sh` → alle `pass`. Der Variablen-Detach gegen echtes Divi folgt im Smoke von Task 11. + +--- + +### Task 9: `RestorePlanner` je Bereich – Restore für Farben und Variablen (V1b, V10) + +**Files:** +- Create: `src/Integrations/Divi/Design/RestorePlanner.php`, `src/Integrations/Divi/Design/PresetRestorePlanner.php`, `src/Integrations/Divi/Design/ColorRestorePlanner.php`, `src/Integrations/Divi/Design/VariableRestorePlanner.php` +- Test: `tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php`, `tests/Unit/Integrations/Divi/Design/ColorRestorePlannerTest.php`, `tests/Unit/Integrations/Divi/Design/VariableRestorePlannerTest.php` +- Modify: `src/Integrations/Divi/Design/DiviDesignRestoreAbility.php`, `src/Integrations/Divi/DiviIntegration.php` (nur der Konstruktoraufruf des Restore-Tools) +- Modify (Tests): `tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php`, `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php` + +**Interfaces:** +- Consumes (Schnittstellenvertrag, Tasks 1–7): `DiviFeature::ColorWrite`, `::VariableWrite`; `DesignRights::colors()`, `::variables()`; `GlobalColorsAdapter`, `GlobalVariablesAdapter` (mockbar, ohne Konstruktorargumente); `ColorBook::fromDivi()/data()/entry()/label()`, `ColorDiff::between()/summary()`; `VariableBook::fromDivi()/data()/restoredFrom()/onlyHere()`, `VariableDiff::between()/summary()`; `new ColorArea(GlobalColorsAdapter)`, `new VariableArea(GlobalVariablesAdapter)`. Vorhanden: `DesignChange::run()`, `SnapshotStore::get()` (Eintrag mit `data`), `PresetArea`, `PresetBook`, `PresetDiff`, `DesignRights::presets()/markup()/unfilteredHtml()`, `FeatureSets::merge()`. +- Produces: + - `interface RestorePlanner { public function area(): string; public function designArea(): DesignArea; public function rights(): ?WP_Error; public function features(): array; public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error; }` – `$snapshot` ist das Feld `data` des Snapshots. + - `PresetRestorePlanner::__construct(DesignSystemWriter $writer, ?Closure $unfiltered = null)` + - `ColorRestorePlanner::__construct(DesignSystemWriter $writer, GlobalColorsAdapter $colors)` + - `VariableRestorePlanner::__construct(DesignSystemWriter $writer, GlobalVariablesAdapter $variables)` + - **Neue Konstruktor-Signatur** `DiviDesignRestoreAbility::__construct(DesignChange $change, SnapshotStore $snapshots, RestorePlanner ...$planners)`; `diviFeatures($input)` = Fähigkeiten des Planers zum Bereich der `snapshot_id`, ohne gültige ID bzw. ohne Planer die Vereinigung aller Planer (fail closed; das liest auch `FeatureOverview` mit `[]`). + +Regeln (V10, V1b, V4, Spec §5/§10): +- Bereich = erste Gruppe der Snapshot-ID. Rechte und Fähigkeiten sind die des Bereichs; ohne gültige ID prüft die Ability die Rechte aller Planer (fail closed). `unsupported` („Theme-Optionen folgen mit Etappe 4d.“) nur noch für `theme_options`. +- Presets: Code und Texte unverändert verschoben (Ersetzen des ganzen Bestands, `mayRemove`, Markup-Sperre). +- Farben: der Snapshot ersetzt alle gespeicherten Farben (`mayRemove: true` → `GlobalColorsAdapter::save(…, true)`). Farben, die es nur jetzt gibt, entfallen: der Planer hängt dafür Zeilen `action: removed` an den Diff (ColorDiff kennt keine Entfernung), nennt sie in `summary` („Entfernt: N.“) und in einer Warnung. +- Variablen: `VariableBook::restoredFrom()` – jede Variable des Snapshots kommt zurück, neuere bleiben **archiviert** erhalten (Divi lehnt fehlende `gvid-`-IDs ab), je eine Warnung aus `onlyHere()`; `mayRemove: false`. + +#### 9a – `RestorePlanner` und `PresetRestorePlanner` (Refactor, Verhalten unverändert) + +- [ ] **Step 1: Absicherung: bestehende Restore-Tests grün** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignRestoreAbilityTest|DiviToolFeaturesTest/'` +Expected: `OK (39 tests, …)` (4 + 35 nach Task 8). + +- [ ] **Step 2: Failing Test `PresetRestorePlannerTest` schreiben** + +Datei `tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php`: + +```php + $unfiltered); + } + + public function testAreaFeaturesAndDesignArea(): void + { + $planner = self::planner(); + + self::assertSame('presets', $planner->area()); + self::assertSame([DiviFeature::DesignRead, DiviFeature::PresetWrite], $planner->features()); + self::assertInstanceOf(PresetArea::class, $planner->designArea()); + } + + public function testPlansTheWholeSnapshotWithRemovals(): void + { + $older = PresetBookTest::stored(); + unset($older['module']['divi/button']['items']['bbbbbbbbbb']); + + $plan = self::planner()->plan(PresetBookTest::stored(), $older, 'presets-20261016T100000Z-aaaaaa'); + + self::assertInstanceOf(DesignPlan::class, $plan); + self::assertTrue($plan->mayRemove); + self::assertSame([['bbbbbbbbbb', 'removed']], array_map( + static fn (array $row): array => [$row['id'], $row['action']], + $plan->diff + )); + self::assertStringStartsWith('Snapshot presets-20261016T100000Z-aaaaaa wiederherstellen – ', $plan->summary); + self::assertStringContainsString('Standard-Preset', $plan->warnings[0]); + } + + public function testMarkupNeedsUnfilteredHtml(): void + { + $plan = self::planner(false)->plan(PresetBookTest::stored(), PresetBookTest::storedWithMarkup(), 'presets-1'); + + self::assertToolError($plan, 'forbidden'); + } +} +``` + +- [ ] **Step 3: RED** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter PresetRestorePlannerTest` +Expected: FAIL – `Error: Class "LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner" not found`. + +- [ ] **Step 4: `RestorePlanner` und `PresetRestorePlanner` anlegen** + +Der Planungscode ist die bisherige Closure aus `DiviDesignRestoreAbility::execute()` (Zeilen 125–147), die Konstante `MARKUP` (Zeilen 26–27) und `DesignRights::presets()` – unverändert verschoben. + +Datei `src/Integrations/Divi/Design/RestorePlanner.php`: + +```php + */ + public function features(): array; + + /** + * @param array $current The area's inventory now (DesignArea::raw(), read by DesignChange). + * @param array $snapshot The data of the snapshot $snapshotId. + */ + public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error; +} +``` + +Datei `src/Integrations/Divi/Design/PresetRestorePlanner.php`: + +```php +unfiltered ??= DesignRights::unfilteredHtml(); + } + + public function area(): string + { + return 'presets'; + } + + public function designArea(): DesignArea + { + return new PresetArea($this->writer); + } + + public function rights(): ?WP_Error + { + return DesignRights::presets($this->writer); + } + + /** Writes the presets (PresetWrite), reading them first (DesignRead). */ + public function features(): array + { + return [DiviFeature::DesignRead, DiviFeature::PresetWrite]; + } + + public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error + { + $before = PresetBook::fromDivi($current); + $after = PresetBook::fromDivi($snapshot); + $refusal = DesignRights::markup( + $this->unfiltered, + array_merge($before->markupOutside([]), $after->markupOutside([])), + self::MARKUP + ); + if ($refusal !== null) { + return $refusal; + } + $diff = PresetDiff::between($before, $after); + + return new DesignPlan( + $after->data(), + $diff, + sprintf('Snapshot %s wiederherstellen – %s', $snapshotId, PresetDiff::summary($diff)), + null, + ['Seiten, die Presets verwenden, die es im Snapshot nicht gibt, behalten den Verweis; das Modul fällt ' + . 'dann auf das Standard-Preset zurück.'], + true + ); + } +} +``` + +- [ ] **Step 5: GREEN** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter PresetRestorePlannerTest` +Expected: `OK (3 tests, 11 assertions)`. + +- [ ] **Step 6: `DiviDesignRestoreAbility` auf Planer umbauen, Aufrufer anpassen** + +Ganze Datei `src/Integrations/Divi/Design/DiviDesignRestoreAbility.php` nach dem Umbau (in 9a nur mit dem Presets-Planer verdrahtet; der Hinweis für andere Bereiche bleibt bis 9c wörtlich): + +```php + area => planner */ + private array $planners = []; + + public function __construct( + private DesignChange $change, + private SnapshotStore $snapshots, + RestorePlanner ...$planners + ) { + foreach ($planners as $planner) { + $this->planners[$planner->area()] = $planner; + } + } + + public function name(): string + { + return 'lit/divi-design-restore'; + } + + public function label(): string + { + return 'Designsystem wiederherstellen'; + } + + public function description(): string + { + return 'Stellt einen Snapshot des Designsystems wieder her (snapshot_id aus lit/divi-design-snapshots oder ' + . 'einer Änderungsantwort). Zuerst Probelauf (Unterschied zum aktuellen Stand, design_rev), nach Rückfrage ' + . 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => ['snapshot_id' => ['type' => 'string']] + DesignChange::inputProperties(), + 'required' => ['snapshot_id'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return DesignChange::outputSchema(); + } + + public function annotations(): array + { + return Annotations::destructive(false); + } + + public function isDirect(): bool + { + return false; + } + + /** The features of the snapshot's area; without a restorable area those of every area (fail closed). */ + public function diviFeatures(array $input): array + { + return FeatureSets::merge(...array_map( + static fn (RestorePlanner $planner): array => $planner->features(), + $this->plannersFor($input) + )); + } + + public function permission(array $input): bool|WP_Error + { + return $this->rights($input) ?? true; + } + + public function execute(array $input): mixed + { + $refusal = $this->rights($input); + if ($refusal !== null) { + return $refusal; + } + $id = is_string($input['snapshot_id'] ?? null) ? $input['snapshot_id'] : ''; + $area = self::areaOf($input); + if ($area === null) { + return Errors::invalidInput( + 'Ungültige Snapshot-ID.', + ['snapshot_id' => 'z. B. presets-20261016T101500Z-a1b2c3'] + ); + } + $planner = $this->planners[$area] ?? null; + if ($planner === null) { + return Errors::unsupported( + 'Snapshots dieses Bereichs lassen sich noch nicht wiederherstellen.', + 'Farben und Variablen folgen mit Etappe 4c, Theme-Optionen mit 4d.' + ); + } + $snapshot = $this->snapshots->get($area, $id); + if ($snapshot === null) { + return Errors::notFound( + sprintf('Snapshot %s gibt es nicht (mehr).', $id), + 'Vorhandene Snapshots mit lit/divi-design-snapshots auflisten; je Bereich bleiben die letzten 20.' + ); + } + + return $this->change->run( + $this->name(), + $planner->designArea(), + $input, + static fn (array $raw): DesignPlan|WP_Error => $planner->plan($raw, $snapshot['data'], $id) + ); + } + + /** + * Spec §10: the rights of the snapshot's area; without a restorable area those of every area (fail closed). + * + * @param array $input + */ + private function rights(array $input): ?WP_Error + { + foreach ($this->plannersFor($input) as $planner) { + $refusal = $planner->rights(); + if ($refusal !== null) { + return $refusal; + } + } + + return null; + } + + /** + * @param array $input + * @return list The planner of the snapshot's area, or every planner. + */ + private function plannersFor(array $input): array + { + $planner = $this->planners[self::areaOf($input) ?? ''] ?? null; + + return $planner !== null ? [$planner] : array_values($this->planners); + } + + /** @param array $input */ + private static function areaOf(array $input): ?string + { + $id = $input['snapshot_id'] ?? null; + + return is_string($id) && preg_match(self::ID, $id, $match) === 1 ? $match[1] : null; + } +} +``` + +`tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php` – nur die Konstruktion ändert sich, Erwartungen bleiben: + +*Import (nach `DiviDesignRestoreAbility`)* – vorher: + +```php +use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility; +``` + +nachher: + +```php +use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility; +use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner; +``` + +*`testRestoresWithDryRunAndASnapshotOfTheCurrentState()` (Zeile 40)* – vorher: + +```php + $ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, new FakeChangeLog(8)), $snapshots); +``` + +nachher: + +```php + $ability = new DiviDesignRestoreAbility( + new DesignChange($snapshots, new FakeChangeLog(8)), + $snapshots, + new PresetRestorePlanner($writer) + ); +``` + +*`restoring()` (Zeilen 65–70)* – vorher: + +```php + return new DiviDesignRestoreAbility( + $writer, + new DesignChange($snapshots, FakeChangeLog::forbidden()), + $snapshots, + static fn (): bool => $unfiltered + ); +``` + +nachher: + +```php + return new DiviDesignRestoreAbility( + new DesignChange($snapshots, FakeChangeLog::forbidden()), + $snapshots, + new PresetRestorePlanner($writer, static fn (): bool => $unfiltered) + ); +``` + +*`testNeedsEditThemeOptions()` (Zeile 99) und `testUnknownSnapshotAndOtherAreas()` (Zeile 113) – beide Zeilen gleich, beide ersetzen* – vorher: + +```php + $ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots); +``` + +nachher: + +```php + $ability = new DiviDesignRestoreAbility( + new DesignChange($snapshots, FakeChangeLog::forbidden()), + $snapshots, + new PresetRestorePlanner($writer) + ); +``` + +`src/Integrations/Divi/DiviIntegration.php` (`DesignRights` bleibt importiert, es wird weiter verwendet): + +*Import (nach `PresetComposer`)* – vorher: + +```php +use LIT\MCP\Integrations\Divi\Design\PresetComposer; +``` + +nachher: + +```php +use LIT\MCP\Integrations\Divi\Design\PresetComposer; +use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner; +``` + +*Konstruktoraufruf (Zeilen 264–269)* – vorher: + +```php + new DiviDesignRestoreAbility( + $this->designWriter, + $this->designChange(), + new SnapshotStore(), + DesignRights::unfilteredHtml() + ), +``` + +nachher: + +```php + new DiviDesignRestoreAbility( + $this->designChange(), + new SnapshotStore(), + new PresetRestorePlanner($this->designWriter, DesignRights::unfilteredHtml()) + ), +``` + +- [ ] **Step 7: GREEN, Lint** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignRestoreAbilityTest|PresetRestorePlannerTest|DiviToolFeaturesTest|DiviIntegrationTest/' && composer lint` +Expected: PASS (Restore-Tests 4, Tabelle unverändert), phpcs ohne Meldung. + +- [ ] **Step 8: Commit** + +Commit wie in den Global Constraints: + +```bash +git add src/Integrations/Divi/Design/RestorePlanner.php \ + src/Integrations/Divi/Design/PresetRestorePlanner.php \ + src/Integrations/Divi/Design/DiviDesignRestoreAbility.php \ + tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php \ + tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php \ + src/Integrations/Divi/DiviIntegration.php +git commit -m "refactor: lit/divi-design-restore plant über RestorePlanner; PresetRestorePlanner übernimmt den bisherigen Code unverändert" -m "Co-Authored-By: Claude " +``` + +#### 9b – `ColorRestorePlanner` und `VariableRestorePlanner` + +- [ ] **Step 9: Failing Tests schreiben** + +Die Fixtures sind öffentlich, weil die Ability-Tests (9c) sie wiederverwenden. Datei `tests/Unit/Integrations/Divi/Design/ColorRestorePlannerTest.php`: + +```php + $color, 'folder' => '', 'label' => $label, 'lastUpdated' => '2026-10-01T10:00:00.000Z', + 'status' => 'active', 'usedInPosts' => []]; + } + + /** @return array> The colors now: "Marke" red, "Akzent" created after the snapshot. */ + public static function current(): array + { + return [ + 'gcid-brand00001' => self::color('Marke', '#ff0000'), + 'gcid-akzent0001' => self::color('Akzent', '#00ff00'), + ]; + } + + /** @return array> The snapshot: "Marke" blue, no "Akzent". */ + public static function snapshot(): array + { + return ['gcid-brand00001' => self::color('Marke', '#0000ff')]; + } + + private static function planner(bool $visualBuilder = true): ColorRestorePlanner + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn($visualBuilder); + + return new ColorRestorePlanner($writer, Mockery::mock(GlobalColorsAdapter::class)); + } + + public function testAreaFeaturesAndDesignArea(): void + { + $planner = self::planner(); + + self::assertSame('colors', $planner->area()); + self::assertSame([DiviFeature::DesignRead, DiviFeature::ColorWrite], $planner->features()); + self::assertInstanceOf(ColorArea::class, $planner->designArea()); + } + + public function testNeedsTheColorRights(): void + { + Functions\when('current_user_can')->justReturn(true); + self::assertNull(self::planner()->rights()); + self::assertToolError(self::planner(false)->rights(), 'forbidden'); + } + + public function testTheSnapshotReplacesEveryColorAndNamesTheRemovedOnes(): void + { + $plan = self::planner()->plan(self::current(), self::snapshot(), self::SNAPSHOT_ID); + + self::assertInstanceOf(DesignPlan::class, $plan); + self::assertTrue($plan->mayRemove); + self::assertSame(ColorBook::fromDivi(self::snapshot())->data(), $plan->raw); + self::assertSame( + [['gcid-brand00001', 'changed'], ['gcid-akzent0001', 'removed']], + array_map(static fn (array $row): array => [$row['id'], $row['action']], $plan->diff) + ); + self::assertStringStartsWith('Snapshot ' . self::SNAPSHOT_ID . ' wiederherstellen – Farben: ', $plan->summary); + self::assertStringEndsWith(' Entfernt: 1.', $plan->summary); + self::assertSame( + ['Farben, die es im Snapshot nicht gibt, entfallen („Akzent“); Seiten und Presets, die sie verwenden, ' + . 'behalten den Verweis ohne Wert.'], + $plan->warnings + ); + } + + public function testTheSameColorsAreNoChange(): void + { + $plan = self::planner()->plan(self::snapshot(), self::snapshot(), self::SNAPSHOT_ID); + + self::assertInstanceOf(DesignPlan::class, $plan); + self::assertTrue($plan->isEmpty()); + self::assertSame([], $plan->warnings); + } +} +``` + +Datei `tests/Unit/Integrations/Divi/Design/VariableRestorePlannerTest.php`: + +```php + $id, 'label' => $label, 'value' => $value, 'order' => '1', 'status' => 'active', + 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'numbers']; + } + + /** @return array>> Now: radius 8px and "Neu", created afterwards. */ + public static function current(): array + { + return ['numbers' => [ + 'gvid-radius0001' => self::number('gvid-radius0001', 'Eckenradius', '8px'), + 'gvid-neu0000001' => self::number('gvid-neu0000001', 'Neu', '2rem'), + ]]; + } + + /** @return array>> The snapshot: radius 4px. */ + public static function snapshot(): array + { + return ['numbers' => ['gvid-radius0001' => self::number('gvid-radius0001', 'Eckenradius', '4px')]]; + } + + private static function planner(bool $manage = true): VariableRestorePlanner + { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $variables = Mockery::mock(GlobalVariablesAdapter::class); + $variables->allows('canManage')->andReturn($manage); + + return new VariableRestorePlanner($writer, $variables); + } + + public function testAreaFeaturesAndDesignArea(): void + { + $planner = self::planner(); + + self::assertSame('variables', $planner->area()); + self::assertSame([DiviFeature::DesignRead, DiviFeature::VariableWrite], $planner->features()); + self::assertInstanceOf(VariableArea::class, $planner->designArea()); + } + + public function testNeedsTheVariableRights(): void + { + Functions\when('current_user_can')->justReturn(true); + self::assertNull(self::planner()->rights()); + self::assertToolError(self::planner(false)->rights(), 'forbidden'); + } + + public function testRestoresTheSnapshotAndKeepsNewerVariablesArchived(): void + { + $current = VariableBook::fromDivi(self::current()); + $snapshot = VariableBook::fromDivi(self::snapshot()); + + $plan = self::planner()->plan(self::current(), self::snapshot(), self::SNAPSHOT_ID); + + self::assertInstanceOf(DesignPlan::class, $plan); + self::assertFalse($plan->mayRemove, 'V1: variables are never removed'); + self::assertSame($current->restoredFrom($snapshot)->data(), $plan->raw); + self::assertSame('4px', $plan->raw['numbers']['gvid-radius0001']['value']); + self::assertSame('archived', $plan->raw['numbers']['gvid-neu0000001']['status']); + self::assertEqualsCanonicalizing( + [['gvid-radius0001', 'changed'], ['gvid-neu0000001', 'archived']], + array_map(static fn (array $row): array => [$row['id'], $row['action']], $plan->diff) + ); + self::assertStringStartsWith('Snapshot ' . self::SNAPSHOT_ID . ' wiederherstellen – Variablen: ', $plan->summary); + self::assertSame( + ['Variable „Neu“ gibt es im Snapshot nicht; sie bleibt archiviert erhalten (Divi löscht keine Variablen).'], + $plan->warnings + ); + } +} +``` + +- [ ] **Step 10: RED** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/ColorRestorePlannerTest|VariableRestorePlannerTest/'` +Expected: FAIL – `Error: Class "LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner" not found` bzw. `… VariableRestorePlanner" not found` (7 Fehler). + +- [ ] **Step 11: `ColorRestorePlanner` implementieren** + +Zeilen von `ColorDiff::between()` zu IDs, die der Snapshot nicht kennt, filtert der Planer heraus und ersetzt sie durch eigene `removed`-Zeilen – so hängt das Ergebnis nicht davon ab, wie `ColorDiff` fehlende Einträge behandelt. Datei `src/Integrations/Divi/Design/ColorRestorePlanner.php`: + +```php +colors); + } + + public function rights(): ?WP_Error + { + return DesignRights::colors($this->writer); + } + + /** Writes the colors (ColorWrite), reading the design system first (DesignRead). */ + public function features(): array + { + return [DiviFeature::DesignRead, DiviFeature::ColorWrite]; + } + + public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error + { + $before = ColorBook::fromDivi($current); + $after = ColorBook::fromDivi($snapshot); + $kept = array_values(array_filter( + ColorDiff::between($before, $after), + static fn (array $row): bool => $after->entry($row['id']) !== null + )); + $removed = []; + foreach (array_keys(array_diff_key($before->data(), $after->data())) as $id) { + $removed[] = ['id' => (string) $id, 'name' => $before->label((string) $id), 'action' => 'removed']; + } + + $summary = sprintf('Snapshot %s wiederherstellen – %s', $snapshotId, ColorDiff::summary($kept)); + $warnings = []; + if ($removed !== []) { + $summary .= sprintf(' Entfernt: %d.', count($removed)); + $warnings[] = sprintf( + self::REMOVED, + implode(', ', array_map(static fn (array $row): string => '„' . $row['name'] . '“', $removed)) + ); + } + + return new DesignPlan($after->data(), array_merge($kept, $removed), $summary, null, $warnings, true); + } +} +``` + +- [ ] **Step 12: `VariableRestorePlanner` implementieren** + +Datei `src/Integrations/Divi/Design/VariableRestorePlanner.php`: + +```php +variables); + } + + public function rights(): ?WP_Error + { + return DesignRights::variables($this->writer, $this->variables); + } + + /** Writes the variables (VariableWrite), reading the design system first (DesignRead). */ + public function features(): array + { + return [DiviFeature::DesignRead, DiviFeature::VariableWrite]; + } + + public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error + { + $before = VariableBook::fromDivi($current); + $older = VariableBook::fromDivi($snapshot); + $after = $before->restoredFrom($older); + $diff = VariableDiff::between($before, $after); + + return new DesignPlan( + $after->data(), + $diff, + sprintf('Snapshot %s wiederherstellen – %s', $snapshotId, VariableDiff::summary($diff)), + null, + array_map(static fn (array $row): string => sprintf(self::KEPT, $row['label']), $before->onlyHere($older)) + ); + } +} +``` + +- [ ] **Step 13: GREEN, Lint** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/ColorRestorePlannerTest|VariableRestorePlannerTest/' && composer lint` +Expected: `OK (7 tests, …)`, phpcs ohne Meldung. + +- [ ] **Step 14: Commit** + +Commit wie in den Global Constraints: + +```bash +git add src/Integrations/Divi/Design/ColorRestorePlanner.php \ + src/Integrations/Divi/Design/VariableRestorePlanner.php \ + tests/Unit/Integrations/Divi/Design/ColorRestorePlannerTest.php \ + tests/Unit/Integrations/Divi/Design/VariableRestorePlannerTest.php +git commit -m "feat: ColorRestorePlanner und VariableRestorePlanner – Restore-Pläne für Farben und Variablen (V1b, V10)" -m "Co-Authored-By: Claude " +``` + +#### 9c – Restore für Farben und Variablen freischalten + +- [ ] **Step 15: Tests ergänzen** + +`tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php`: Probelauf und `confirm` für Farben und Variablen, Rechte und Fähigkeiten je Bereich, `theme_options` als einziger nicht unterstützter Bereich. Die Adapter-Doubles speichern, was `save()` bekommt, und geben es bei `read()` zurück (Zurücklesen der Pipeline). + +*Importe (Zeilen 8–14 nach Task 9 Step 6)* – vorher: + +```php +use LIT\MCP\Integrations\Divi\Design\DesignChange; +use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; +use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility; +use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner; +use LIT\MCP\Integrations\Divi\Design\SnapshotStore; +use LIT\MCP\Tests\Support\FakeChangeLog; +use LIT\MCP\Tests\Unit\TestCase; +use Mockery; +``` + +nachher: + +```php +use LIT\MCP\Integrations\Divi\Design\ColorBook; +use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner; +use LIT\MCP\Integrations\Divi\Design\DesignChange; +use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; +use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility; +use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter; +use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; +use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner; +use LIT\MCP\Integrations\Divi\Design\SnapshotStore; +use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Tests\Support\FakeChangeLog; +use LIT\MCP\Tests\Unit\TestCase; +use Mockery; +use Mockery\MockInterface; +``` + +*`testUnknownSnapshotAndOtherAreas()` (ganze Methode, Stand nach Task 9 Step 6) wird ersetzt* – vorher: + +```php + public function testUnknownSnapshotAndOtherAreas(): void + { + Functions\when('current_user_can')->justReturn(true); + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->allows('get')->andReturn(null); + $ability = new DiviDesignRestoreAbility( + new DesignChange($snapshots, FakeChangeLog::forbidden()), + $snapshots, + new PresetRestorePlanner($writer) + ); + + self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found'); + self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'unsupported'); + self::assertToolError($ability->execute(['snapshot_id' => 'kaputt']), 'validation_failed'); + } +``` + +nachher: + +```php + public function testUnknownSnapshotAndThemeOptions(): void + { + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->allows('get')->andReturn(null); + $ability = self::allAreas($snapshots, self::colorStore([]), self::variableStore([])); + + self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found'); + self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'not_found'); + self::assertToolError($ability->execute(['snapshot_id' => 'variables-20261016T100000Z-abcdef']), 'not_found'); + $themeOptions = self::assertToolError( + $ability->execute(['snapshot_id' => 'theme_options-20261016T100000Z-abcdef']), + 'unsupported' + ); + self::assertSame('Theme-Optionen folgen mit Etappe 4d.', $themeOptions['hint']); + self::assertToolError($ability->execute(['snapshot_id' => 'kaputt']), 'validation_failed'); + } + + /** The ability with the planners of all three areas; the user has every right unless $manage is false. */ + private static function allAreas( + SnapshotStore $snapshots, + GlobalColorsAdapter $colors, + GlobalVariablesAdapter $variables, + ?FakeChangeLog $log = null + ): DiviDesignRestoreAbility { + Functions\when('current_user_can')->justReturn(true); + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->shouldNotReceive('savePresets'); + + return new DiviDesignRestoreAbility( + new DesignChange($snapshots, $log ?? FakeChangeLog::forbidden()), + $snapshots, + new PresetRestorePlanner($writer), + new ColorRestorePlanner($writer, $colors), + new VariableRestorePlanner($writer, $variables) + ); + } + + /** + * A colors adapter that reads what it last saved, starting with $stored. + * + * @param array> $stored + */ + private static function colorStore(array $stored): GlobalColorsAdapter&MockInterface + { + $colors = Mockery::mock(GlobalColorsAdapter::class); + $colors->allows('read')->andReturnUsing(static function () use (&$stored): array { + return $stored; + }); + $colors->allows('save')->andReturnUsing(static function (array $entries, bool $replace) use (&$stored): void { + self::assertTrue($replace, 'a restore replaces every color (V4)'); + $stored = $entries; + }); + + return $colors; + } + + /** + * A variables adapter that reads what it last saved, starting with $stored. + * + * @param array>> $stored + */ + private static function variableStore(array $stored, bool $manage = true): GlobalVariablesAdapter&MockInterface + { + $variables = Mockery::mock(GlobalVariablesAdapter::class); + $variables->allows('canManage')->andReturn($manage); + $variables->allows('read')->andReturnUsing(static function () use (&$stored): array { + return $stored; + }); + $variables->allows('save')->andReturnUsing(static function (array $data) use (&$stored): void { + $stored = $data; + }); + + return $variables; + } + + /** @param array $data */ + private static function snapshotIn(string $area, string $id, array $data): array + { + return ['id' => $id, 'data' => $data, 'created' => 'x', 'user_id' => 1, 'ability' => 'lit/divi-' . $area . '-set', + 'log_id' => 2, 'size' => 1]; + } + + public function testRestoresColorsWithDryRunAndASnapshotOfTheCurrentColors(): void + { + $id = ColorRestorePlannerTest::SNAPSHOT_ID; + $colors = self::colorStore(ColorRestorePlannerTest::current()); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->allows('get')->with('colors', $id) + ->andReturn(self::snapshotIn('colors', $id, ColorRestorePlannerTest::snapshot())); + $snapshots->expects('save') + ->with('colors', ColorBook::fromDivi(ColorRestorePlannerTest::current())->data(), 'lit/divi-design-restore') + ->andReturn('colors-5'); + $snapshots->expects('attachLog'); + $ability = self::allAreas($snapshots, $colors, self::variableStore([]), new FakeChangeLog(8)); + + $dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required'); + self::assertSame(['changed', 'removed'], array_column($dry['diff'], 'action')); + self::assertStringContainsString('„Akzent“', $dry['warnings'][0]); + + $result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]); + self::assertSame('colors-5', $result['snapshot_id']); + self::assertSame(ColorBook::fromDivi(ColorRestorePlannerTest::snapshot())->data(), $colors->read()); + } + + public function testRestoresVariablesAndKeepsNewerOnesArchived(): void + { + $id = VariableRestorePlannerTest::SNAPSHOT_ID; + $variables = self::variableStore(VariableRestorePlannerTest::current()); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->allows('get')->with('variables', $id) + ->andReturn(self::snapshotIn('variables', $id, VariableRestorePlannerTest::snapshot())); + $snapshots->expects('save') + ->with('variables', Mockery::type('array'), 'lit/divi-design-restore') + ->andReturn('variables-5'); + $snapshots->expects('attachLog'); + $ability = self::allAreas($snapshots, self::colorStore([]), $variables, new FakeChangeLog(8)); + + $dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required'); + self::assertStringContainsString('„Neu“', $dry['warnings'][0]); + + $result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]); + self::assertSame('variables-5', $result['snapshot_id']); + $stored = $variables->read(); + self::assertSame('4px', $stored['numbers']['gvid-radius0001']['value']); + self::assertSame('archived', $stored['numbers']['gvid-neu0000001']['status']); + } + + /** Spec §10, V10: the rights are those of the snapshot's area. */ + public function testRightsFollowTheSnapshotArea(): void + { + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->shouldNotReceive('get'); + $ability = self::allAreas($snapshots, self::colorStore([]), self::variableStore([], false)); + + self::assertToolError( + $ability->permission(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]), + 'forbidden' + ); + self::assertToolError( + $ability->execute(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]), + 'forbidden' + ); + self::assertTrue($ability->permission(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID])); + self::assertTrue($ability->permission(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'])); + self::assertToolError($ability->permission(['snapshot_id' => 'kaputt']), 'forbidden', 'fail closed'); + } + + public function testFeaturesFollowTheSnapshotArea(): void + { + $ability = self::allAreas(Mockery::mock(SnapshotStore::class), self::colorStore([]), self::variableStore([])); + + self::assertSame( + [DiviFeature::DesignRead, DiviFeature::ColorWrite], + $ability->diviFeatures(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID]) + ); + self::assertSame( + [DiviFeature::DesignRead, DiviFeature::VariableWrite], + $ability->diviFeatures(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]) + ); + self::assertSame( + [DiviFeature::DesignRead, DiviFeature::PresetWrite], + $ability->diviFeatures(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa']) + ); + self::assertSame( + [DiviFeature::DesignRead, DiviFeature::PresetWrite, DiviFeature::ColorWrite, DiviFeature::VariableWrite], + $ability->diviFeatures([]) + ); + } +``` + +`tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php`: + +*Tabelle `EXPECTED` (Zeile 59)* – vorher: + +```php + 'lit/divi-design-restore' => ['design-read', 'preset-write'], +``` + +nachher: + +```php + 'lit/divi-design-restore' => ['design-read', 'preset-write', 'color-write', 'variable-write'], +``` + +*`inputCases()`, vor `'library-list'`* – vorher: + +```php + 'library-list' => ['lit/library-list', [], ['library-read', 'roles']], +``` + +nachher: + +```php + 'design-restore of presets' => [ + 'lit/divi-design-restore', ['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'], + ['design-read', 'preset-write'], + ], + 'design-restore of colors' => [ + 'lit/divi-design-restore', ['snapshot_id' => 'colors-20261016T100000Z-aaaaaa'], + ['design-read', 'color-write'], + ], + 'design-restore of variables' => [ + 'lit/divi-design-restore', ['snapshot_id' => 'variables-20261016T100000Z-aaaaaa'], + ['design-read', 'variable-write'], + ], + 'library-list' => ['lit/library-list', [], ['library-read', 'roles']], +``` + +- [ ] **Step 16: RED** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignRestoreAbilityTest|DiviToolFeaturesTest/'` +Expected: FAIL – `testUnknownSnapshotAndThemeOptions` (Hinweis noch „Farben und Variablen folgen mit Etappe 4c, Theme-Optionen mit 4d.“), `testEveryToolDeclaresExactlyTheTableForAnInputThatNamesNothing` (`lit/divi-design-restore`: `color-write`, `variable-write` fehlen) und `testInputDependentFeatures` für „design-restore of colors/variables“ (DiviIntegration kennt nur den Presets-Planer). Die übrigen neuen Ability-Tests laufen schon, weil 9a die Ability allgemein gemacht hat. + +- [ ] **Step 17: Ability-Texte und Verdrahtung der Planer** + +`src/Integrations/Divi/Design/DiviDesignRestoreAbility.php`: + +*Klassen-Docblock* – vorher: + +```php + * the plan. Areas without a planner are not restorable yet. +``` + +nachher: + +```php + * the plan. Areas without a planner (theme_options until Etappe 4d) are not restorable yet. +``` + +*`description()`* – vorher: + +```php + . 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert.'; +``` + +nachher: + +```php + . 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert. Bereiche: presets, colors ' + . '(Farben, die es im Snapshot nicht gibt, entfallen) und variables (Variablen, die erst nach dem Snapshot ' + . 'entstanden, bleiben archiviert erhalten).'; +``` + +*Hinweis für Bereiche ohne Planer in `execute()`* – vorher: + +```php + 'Farben und Variablen folgen mit Etappe 4c, Theme-Optionen mit 4d.' +``` + +nachher: + +```php + 'Theme-Optionen folgen mit Etappe 4d.' +``` + +`src/Integrations/Divi/DiviIntegration.php` (die Adapter entstehen hier wie `SnapshotStore` inline; teilt Task 10 eine Instanz je Adapter, ersetzt es diese beiden `new`): + +*Import (vor `DesignSystemReader`)* – vorher: + +```php +use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +``` + +nachher: + +```php +use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner; +use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +``` + +*Import (vor `GlobalVariablesAdapter`, in Task 8 ergänzt)* – vorher: + +```php +use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; +``` + +nachher: + +```php +use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter; +use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; +``` + +*Import (nach `SnapshotStore`)* – vorher: + +```php +use LIT\MCP\Integrations\Divi\Design\SnapshotStore; +``` + +nachher: + +```php +use LIT\MCP\Integrations\Divi\Design\SnapshotStore; +use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner; +``` + +*Konstruktoraufruf (aus Task 9 Step 6)* – vorher: + +```php + new PresetRestorePlanner($this->designWriter, DesignRights::unfilteredHtml()) + ), +``` + +nachher: + +```php + new PresetRestorePlanner($this->designWriter, DesignRights::unfilteredHtml()), + new ColorRestorePlanner($this->designWriter, new GlobalColorsAdapter()), + new VariableRestorePlanner($this->designWriter, new GlobalVariablesAdapter()) + ), +``` + +- [ ] **Step 18: GREEN, Gesamtlauf, Lint** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignRestoreAbilityTest|DiviToolFeaturesTest|DiviIntegrationTest/'` +Expected: PASS (`DiviDesignRestoreAbilityTest` 8 Tests, `DiviToolFeaturesTest` 38). + +Run: `composer test && composer lint` +Expected: alle Tests grün, phpcs ohne Meldung. + +- [ ] **Step 19: Commit** + +Commit wie in den Global Constraints: + +```bash +git add src/Integrations/Divi/Design/DiviDesignRestoreAbility.php \ + tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php \ + src/Integrations/Divi/DiviIntegration.php \ + tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php +git commit -m "feat: lit/divi-design-restore stellt Farben und Variablen wieder her; nur theme_options folgt mit 4d (V10)" -m "Co-Authored-By: Claude " +``` + + +### Task 10: Verdrahtung in `DiviIntegration` und Tabellen der Fähigkeiten + +**Files:** +- Modify: `src/Integrations/Divi/DiviIntegration.php` +- Modify: `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` (Namensliste) +- Modify: `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php` (Tabelle `EXPECTED`, Zeile ca. 331 `inputCases`/Schreib-Fähigkeit je Tool) + +**Interfaces:** +- Consumes: `DiviColorsSetAbility(DesignSystemWriter, GlobalColorsAdapter, DesignChange, GlobalUsages, ?Closure $random = null)` und `DiviVariablesSetAbility(DesignSystemWriter, GlobalVariablesAdapter, DesignChange, GlobalUsages, ?Closure $random = null)` (Tasks 6/7); `GlobalColorsAdapter`/`GlobalVariablesAdapter` ohne Konstruktorargumente (Task 2). +- Produces: beide Tools in `DiviIntegration::abilities()`, **eine** Adapter-Instanz je Integration (Eigenschaften `$colorsAdapter`, `$variablesAdapter`). Die Konstruktoraufrufe von `lit/divi-design-detach` (Task 8, Step 18) und `lit/divi-design-restore` (Task 9) haben dort inline `new GlobalVariablesAdapter()`/`new GlobalColorsAdapter()` eingeführt; dieser Task ersetzt sie durch die geteilten Eigenschaften. Die Fähigkeiten-Zeilen von `lit/divi-design-detach`/`-restore` in `DiviToolFeaturesTest` haben die Tasks 8/9 bereits angepasst und bleiben unberührt. + +- [ ] **Step 1: Tests zuerst – Namensliste und Fähigkeitentabelle** + +`tests/Unit/Integrations/Divi/DiviIntegrationTest.php`, in `EXPECTED` nach `'lit/divi-design-detach',` ergänzen (die Tools stehen am Ende von `abilities()`): + +```php + 'lit/divi-colors-set', + 'lit/divi-variables-set', +``` + +`tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php`, in `EXPECTED` unter dem Kommentar `// Design system (divi)` nach der Zeile `'lit/divi-design-detach' => [ … ],` ergänzen: + +```php + 'lit/divi-colors-set' => ['design-read', 'color-write'], + 'lit/divi-variables-set' => ['design-read', 'variable-write'], +``` + +Und in der zweiten Tabelle der Datei (Zeile mit `'lit/divi-presets-set' => 'preset-write', 'lit/divi-presets-delete' => 'preset-write',`, die jedem schreibenden Tool seine Schreib-Fähigkeit zuordnet) ergänzen: + +```php + 'lit/divi-colors-set' => 'color-write', 'lit/divi-variables-set' => 'variable-write', +``` + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviIntegrationTest|DiviToolFeaturesTest/'` +Expected: FAIL – `DiviIntegrationTest` (die Namensliste enthält die zwei Tools nicht) und `DiviToolFeaturesTest` (die Tabelle nennt Tools, die es nicht gibt). + +- [ ] **Step 2: Imports, Eigenschaften und Konstruktor** + +`src/Integrations/Divi/DiviIntegration.php` – Imports (alphabetisch in die vorhandene Reihe der `Design\`-Imports einsortieren; `GlobalColorsAdapter` und `GlobalVariablesAdapter` stehen nach Task 8/9 schon dort): + +```php +use LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility; +use LIT\MCP\Integrations\Divi\Design\DiviVariablesSetAbility; +``` + +Eigenschaften neben `private DesignSystemWriter $designWriter;`: + +```php + private GlobalColorsAdapter $colorsAdapter; + + private GlobalVariablesAdapter $variablesAdapter; +``` + +Im Konstruktor nach `$this->designWriter = new DesignSystemWriter();`: + +```php + $this->colorsAdapter = new GlobalColorsAdapter(); + $this->variablesAdapter = new GlobalVariablesAdapter(); +``` + +- [ ] **Step 3: Tools registrieren, geteilte Adapter verwenden** + +In `abilities()` die beiden inline-`new` aus Task 8/9 ersetzen und die neuen Tools ans Ende der Liste hängen. Vorher (Restore, nach Task 9): + +```php + new ColorRestorePlanner($this->designWriter, new GlobalColorsAdapter()), + new VariableRestorePlanner($this->designWriter, new GlobalVariablesAdapter()) +``` + +nachher: + +```php + new ColorRestorePlanner($this->designWriter, $this->colorsAdapter), + new VariableRestorePlanner($this->designWriter, $this->variablesAdapter) +``` + +Vorher (Detach, nach Task 8): + +```php + $this->designWriter, + new GlobalVariablesAdapter(), + $this->designChange(), +``` + +nachher: + +```php + $this->designWriter, + $this->variablesAdapter, + $this->designChange(), +``` + +Nach dem Eintrag `new DiviDesignDetachAbility( … ),` (letzter Eintrag der Liste) anfügen: + +```php + new DiviColorsSetAbility( + $this->designWriter, + $this->colorsAdapter, + $this->designChange(), + $this->globalUsages() + ), + new DiviVariablesSetAbility( + $this->designWriter, + $this->variablesAdapter, + $this->designChange(), + $this->globalUsages() + ), +``` + +- [ ] **Step 4: GREEN, Gesamtlauf, Lint** + +Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviIntegrationTest|DiviToolFeaturesTest|IntegrationRegistryTest|ToolNamingTest/'` +Expected: PASS. + +Run: `composer test && composer lint` +Expected: alle Tests grün, phpcs ohne Befund. Schlägt ein Test fehl, der Tool-Namen oder Beschreibungen prüft (`ToolNamingTest`, Exposure-/Guide-Tests), zuerst lesen, was er verlangt (indirekte Tools heißen in Texten `lit/`, nicht `lit-`), und den Text der Ability anpassen, nicht den Test. + +- [ ] **Step 5: Die eine falsche Zeile im Guide berichtigen** + +`docs/guides/divi-design-system.md` Zeile 128 behauptet, `variable` werde nicht unterstützt. Ersetzen: + +Alt: `- Variablen: \`variable\` wird noch nicht unterstützt (\`unsupported\`; Variablen folgen mit Etappe 4c).` +Neu: `- Variablen: \`variable\` (Name oder ID) ersetzt jeden ganzen Verweis auf die Variable durch ihren aktuellen Wert, auch in Presets; danach lässt sie sich mit \`lit/divi-variables-set\` archivieren (löschen kann Divi sie nicht).` + +Run: `grep -rn "folgen mit Etappe 4c\|Variablen folgen" src docs/guides` +Expected: keine Treffer. Die übrigen Guides (Farben/Variablen schreiben, Reihenfolge) folgen gesammelt mit 4e (Spec §14 Schritt 9); Treffer zu 4d (Theme-Optionen) bleiben. + +- [ ] **Step 6: Commit** + +```bash +git add src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php \ + tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php docs/guides/divi-design-system.md +git commit -m "feat: lit/divi-colors-set und lit/divi-variables-set in der Divi-Integration; je ein geteilter Farben- und Variablen-Adapter" -m "Co-Authored-By: Claude " +``` + + +### Task 11: Smoke gegen echtes Divi, Formate-Nachtrag, Spec und Notizen + +**Files:** +- Create: `tests/Integration/divi-colors-variables-smoke.sh` +- Modify: `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` (Nachtrag 5.15.1 für Farben/Variablen, direkt nach dem Nachtrag „Divi 5.15.1 (Etappe 4a …)“) +- Modify: `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` (§7.2) +- Modify: `docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md` (Abschnitt „Offen“) + +**Interfaces:** +- Consumes: alle Tools aus den Tasks 6–9 über den OAuth-Endpunkt (`lit/divi-colors-set`, `lit/divi-variables-set`, `lit/divi-design-detach`, `lit/divi-design-restore`, `lit/divi-design-snapshots`) und die Hilfen aus `tests/Integration/lib.sh` (`connect`, `ability`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`, `cleanup_app_passwords_for`). +- Produces: ein Skript, das 4c gegen das echte Divi 5.15.1 prüft und alles, was es ändert, im EXIT-Trap wiederherstellt. + +- [ ] **Step 1: Das Smoke-Skript anlegen** + +Datei `tests/Integration/divi-colors-variables-smoke.sh`: + +```bash +#!/usr/bin/env bash +# End-to-end checks of Etappe 4c (global colors and variables: create, relative color, cycle, main color, archive, +# detach on a page, snapshots, restore) through the OAuth-protected endpoint against the real Divi. Runs INSIDE the +# DDEV web container: +# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-colors-variables-smoke.sh +# Creates colors/variables named "LIT Smoke …" and one draft page. The EXIT trap puts the options et_divi (colors, +# customizer colors) and et_divi_global_variables back to what they were and deletes the page by exact ID. +set -euo pipefail +cd /var/www/html +# shellcheck source=lib.sh +source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh + +ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1) +RUN="$(date +%s)-$$" +PREFIX="LIT Smoke $RUN" +ORIGINAL_THEME=$(wp option get et_divi --format=json 2>/dev/null || echo '{}') +ORIGINAL_VARIABLES=$(wp option get et_divi_global_variables --format=json 2>/dev/null || echo '{}') +PAGE_ID="" + +cleanup() { + printf '%s' "$LIT_THEME" | wp option update et_divi --format=json >/dev/null 2>&1 \ + || echo "WARN: et_divi not restored" + printf '%s' "$LIT_VARIABLES" | wp option update et_divi_global_variables --format=json >/dev/null 2>&1 \ + || echo "WARN: global variables not restored" + wp eval 'ET_Core_PageResource::remove_static_resources("all", "all", true);' >/dev/null 2>&1 || true + if [ -n "$PAGE_ID" ]; then wp post delete "$PAGE_ID" --force >/dev/null 2>&1 || true; fi + cleanup_app_passwords_for "$ADMIN_LOGIN" +} +export LIT_THEME="$ORIGINAL_THEME" LIT_VARIABLES="$ORIGINAL_VARIABLES" +trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT + +connect "$ADMIN_LOGIN" + +# confirm : dry run must answer confirm_required; then the confirmed call; prints the result. +confirm() { + local dry rev + dry=$(ability "$1" "$2") + [ "$(error_code "$dry")" = "confirm_required" ] || fail "$1 dry run: $dry" + rev=$(echo "$dry" | jq -r '.__error | fromjson | .design_rev') + ability "$1" "$(echo "$2" | jq -c --arg r "$rev" '. + {confirm: true, design_rev: $r}')" +} +stored_color() { # $1 = label -> the stored color value + wp eval "foreach (ET\Builder\Packages\GlobalData\GlobalData::get_global_colors() as \$c) { if ((\$c['label'] ?? '') === '$1') { echo \$c['color']; } }" +} +color_id() { # $1 = label + wp eval "foreach (ET\Builder\Packages\GlobalData\GlobalData::get_global_colors() as \$id => \$c) { if ((\$c['label'] ?? '') === '$1') { echo \$id; } }" +} +variable_field() { # $1 = label, $2 = field + wp eval "foreach (ET\Builder\Packages\GlobalData\GlobalData::get_global_variables() as \$items) { foreach ((array) \$items as \$v) { \$v = (array) \$v; if ((\$v['label'] ?? '') === '$1') { echo \$v['$2'] ?? ''; } } }" +} + +# 1. Create a color: dry run, confirm, stale design_rev, stored in Divi. +NEW=$(jq -cn --arg l "$PREFIX Holz" '{items: [{label: $l, color: "#5b3a29"}]}') +DONE=$(confirm lit/divi-colors-set "$NEW") +[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "colors create: $DONE" +COLORS_SNAPSHOT=$(echo "$DONE" | jq -r '.snapshot_id') +[ "$(stored_color "$PREFIX Holz")" = "#5b3a29" ] || fail "color not stored" +STALE=$(ability lit/divi-colors-set "$(echo "$NEW" | jq -c '.items[0].label += " 2" | . + {confirm: true, design_rev: "veraltet"}')") +[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale design_rev: $STALE" +pass "color created ($COLORS_SNAPSHOT), stale design_rev refused" + +# 2. A relative color from a placeholder; a cycle is refused. +REL=$(jq -cn --arg l "$PREFIX Holz hell" --arg b "{{color:$PREFIX Holz}}" \ + '{items: [{label: $l, color: $b, relative: {lightness: 20}}]}') +DONE=$(confirm lit/divi-colors-set "$REL") +[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "relative color: $DONE" +BASE_ID=$(color_id "$PREFIX Holz") +stored_color "$PREFIX Holz hell" | grep -q "$BASE_ID" || fail "relative color does not reference $BASE_ID" +CYCLE=$(jq -cn --arg l "$PREFIX Holz" --arg b "{{color:$PREFIX Holz hell}}" \ + '{items: [{label: $l, color: $b, relative: {lightness: 5}}]}') +[ "$(error_code "$(ability lit/divi-colors-set "$CYCLE")")" = "validation_failed" ] || fail "cycle not refused" +pass "relative color stored, cycle refused" + +# 3. A main color changes the customizer option and reads back at once. +OLD_PRIMARY=$(wp eval 'echo et_get_option("accent_color", "#2ea3f2");') +NEW_PRIMARY="#123456"; [ "$OLD_PRIMARY" = "$NEW_PRIMARY" ] && NEW_PRIMARY="#654321" +DONE=$(confirm lit/divi-colors-set "$(jq -cn --arg c "$NEW_PRIMARY" '{items: [{name: "primary", color: $c}]}')") +[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "main color: $DONE" +[ "$(wp eval 'echo et_get_option("accent_color");')" = "$NEW_PRIMARY" ] || fail "accent_color not written" +pass "main color written to the customizer option" + +# 4. Deactivating a color is a status change, not a removal. +DONE=$(confirm lit/divi-colors-set "$(jq -cn --arg l "$PREFIX Holz hell" '{items: [{name: $l, status: "inactive"}]}')") +[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "deactivate: $DONE" +wp eval "foreach (ET\Builder\Packages\GlobalData\GlobalData::get_global_colors() as \$c) { if ((\$c['label'] ?? '') === '$PREFIX Holz hell') { exit(\$c['status'] === 'inactive' ? 0 : 1); } } exit(1);" \ + || fail "color not inactive" +pass "color deactivated" + +# 5. Variables: create a number variable, archive it (never deleted, V1). +VAR=$(jq -cn --arg l "$PREFIX Radius" '{items: [{type: "numbers", label: $l, value: "12px"}]}') +DONE=$(confirm lit/divi-variables-set "$VAR") +[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "variable create: $DONE" +VARIABLES_SNAPSHOT=$(echo "$DONE" | jq -r '.snapshot_id') +[ "$(variable_field "$PREFIX Radius" value)" = "12px" ] || fail "variable not stored" +VAR_ID=$(variable_field "$PREFIX Radius" id) +case "$VAR_ID" in gvid-??????????) ;; *) fail "variable id format: $VAR_ID" ;; esac +pass "variable created ($VARIABLES_SNAPSHOT, $VAR_ID)" + +# 6. A page uses the variable; detach replaces the reference by its value. +PAGE_ID=$(wp post create --post_type=page --post_status=draft --post_title="$PREFIX Seite" --post_content="$(wp eval " + \$ref = '\$variable(' . wp_json_encode(['type' => 'content', 'value' => ['name' => '$VAR_ID', 'settings' => new stdClass()]]) . ')\$'; + \$text = ['content' => ['innerContent' => ['desktop' => ['value' => 'Hallo']]], + 'module' => ['decoration' => ['spacing' => ['desktop' => ['value' => ['padding' => ['top' => \$ref]]]]]]]; + echo serialize_block(['blockName' => 'divi/placeholder', 'attrs' => [], 'innerBlocks' => [ + ['blockName' => 'divi/section', 'attrs' => [], 'innerBlocks' => [ + ['blockName' => 'divi/row', 'attrs' => [], 'innerBlocks' => [ + ['blockName' => 'divi/column', 'attrs' => ['module' => ['decoration' => ['sizing' => ['desktop' => ['value' => ['flexType' => '24_24']]]]]], 'innerBlocks' => [ + ['blockName' => 'divi/text', 'attrs' => \$text, 'innerBlocks' => [], 'innerHTML' => '', 'innerContent' => []], + ], 'innerHTML' => '', 'innerContent' => [null]], + ], 'innerHTML' => '', 'innerContent' => [null]], + ], 'innerHTML' => '', 'innerContent' => [null]], + ], 'innerHTML' => '', 'innerContent' => [null]]); +")" --porcelain) +wp post get "$PAGE_ID" --field=post_content | grep -q "$VAR_ID" || fail "page does not reference the variable" +DETACH=$(jq -cn --arg v "$VAR_ID" --argjson p "$PAGE_ID" '{variable: $v, post_ids: [$p]}') +DRY=$(ability lit/divi-design-detach "$DETACH") +[ "$(error_code "$DRY")" = "confirm_required" ] || fail "detach dry run: $DRY" +DONE=$(ability lit/divi-design-detach "$(echo "$DETACH" | jq -c '. + {confirm: true}')") +[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "detach: $DONE" +CONTENT=$(wp post get "$PAGE_ID" --field=post_content) +echo "$CONTENT" | grep -q "$VAR_ID" && fail "reference still in the page" +echo "$CONTENT" | grep -q '12px' || fail "value not written into the page" +pass "variable detached from the page (reference replaced by 12px)" + +# 7. Archive the variable; it stays in the option with status archived. +DONE=$(confirm lit/divi-variables-set "$(jq -cn --arg l "$PREFIX Radius" '{items: [{name: $l, status: "archived"}]}')") +[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "archive: $DONE" +[ "$(variable_field "$PREFIX Radius" status)" = "archived" ] || fail "variable not archived" +pass "variable archived, not removed" + +# 8. Restore the variables snapshot taken before step 5: the variable created later stays, archived (V1). +LIST=$(ability lit/divi-design-snapshots '{"area": "variables"}') +echo "$LIST" | jq -e --arg s "$VARIABLES_SNAPSHOT" '[.snapshots[].id] | index($s) != null' >/dev/null || fail "snapshot not listed: $LIST" +DONE=$(confirm lit/divi-design-restore "$(jq -cn --arg s "$VARIABLES_SNAPSHOT" '{snapshot_id: $s}')") +[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "variables restore: $DONE" +[ "$(variable_field "$PREFIX Radius" status)" = "archived" ] || fail "variable lost by the restore" +pass "variables restored, the later variable stays archived" + +# 9. Restore the colors snapshot taken before step 1: the colors of this run are gone, the main color is back. +DONE=$(confirm lit/divi-design-restore "$(jq -cn --arg s "$COLORS_SNAPSHOT" '{snapshot_id: $s}')") +[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "colors restore: $DONE" +[ -z "$(color_id "$PREFIX Holz")" ] || fail "restored colors still hold the new color" +[ "$(wp eval 'echo et_get_option("accent_color", "#2ea3f2");')" = "$OLD_PRIMARY" ] || fail "main color not restored" +pass "colors restored (new colors gone, main color back)" + +finish +``` + +- [ ] **Step 2: Syntax prüfen** + +Run: `bash -n tests/Integration/divi-colors-variables-smoke.sh && (command -v shellcheck >/dev/null && shellcheck -x tests/Integration/divi-colors-variables-smoke.sh || true)` +Expected: keine Syntaxfehler; shellcheck (falls installiert) ohne Fehler der Stufe error (Warnungen zu `$`-Escapes in `wp eval`-Strings sind erwartet). + +- [ ] **Step 3: Formate-Nachtrag 5.15.1 für Farben und Variablen** + +In `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` direkt nach dem Abschnitt „Nachtrag Divi 5.15.1 (Etappe 4a, 2026-10-16)“ einfügen: + +```markdown +## Nachtrag Divi 5.15.1 – Farben und Variablen (Etappe 4c, 2026-10-19) + +Geprüft gegen den installierten Quelltext `includes/builder-5/server/Packages/GlobalData/GlobalData.php` (5.15.1): + +- **Farben unverändert:** `set_global_colors(array $data, $already_sanitized = false): void` mischt mit `true` (`array_merge`), ersetzt mit `false` und bereinigt dann selbst; die fünf Hauptfarben gehen über `et_update_option()` in `et_divi` und werden aus den Daten entfernt; danach `remove_static_resources('all', 'all', true)`. `sanitize_global_colors_data(array $data): array` ist öffentlich und verlangt, dass `usedInPosts` ein Array ist. +- **`get_customizer_colors()` hat einen `static`-Cache ohne Reset.** Nach einer Änderung der Hauptfarbe liefert `get_global_colors()` im selben Request noch den alten Wert (`$_cached_data` wird zurückgesetzt, der `static` nicht). LIT MCP liest die fünf Hauptfarben deshalb für das Zurücklesen aus `et_divi` (`GlobalColorsAdapter::read()`). +- **Variablen: neue Signatur und Prüfungen.** `set_global_variables(array $data, ?array $expected_variables = null): ?WP_Error` kehrt weiterhin still zurück, wenn `edit_theme_options` oder `variables_manager` fehlt; sonst läuft es unter einer MySQL-Sperre (`GET_LOCK`), prüft optional den erwarteten Bestand (`global_variables_stale`, 409), verlangt, dass **jede gespeicherte `gvid-`-ID im neuen Bestand vorkommt** (`validate_global_variable_ids()` → `global_variable_ids_missing`, 400) und prüft geänderte Einträge (`validate_global_variable_changes()`: `status` `active`|`archived`, aktive Variablen mit nicht leerem `value`). **Folge: Variablen lassen sich über Divis Funktionen nicht löschen, nur archivieren** (Ruling V1 des Plans 4c). +- **Neuer Typ `gradients`** (`GLOBAL_VARIABLE_TYPES = numbers, strings, images, links, fonts, gradients`); `strings` und `gradients` behalten ihren Wert zeichengetreu. `get_global_variables()` liefert zusätzlich die leere Gruppe `colors`, wandelt Objekt-Gruppen in Arrays und fügt `allowedActions` und die Customizer-Schriften hinzu (jede Gruppe als `stdClass`); beim Zurückschreiben bleiben nur Speicherformat-Felder. +- **Archivierte Variablen:** `_sort_global_variable_snapshot()` behandelt `order` archivierter Variablen als `''`; der Builder setzt beim Archivieren `order: null`, das Sanitize macht daraus `''`. +``` + +- [ ] **Step 4: Spec §7.2 nachführen** + +In `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` die Zeilen mit `delete: true` und „Löschen nur unbenutzt …“ in §7.2 ersetzen: + +Alt (Zeile `- \`items\`: { id? | name?, "type": … }`): `…, "status": "active"|"archived" }`, `delete: true` für echtes Löschen.` +Neu: `…, "status": "active"|"archived" }`. Echtes Löschen gibt es nicht: Divi 5.15.1 verweigert jeden Bestand, dem eine gespeicherte `gvid-`-ID fehlt (Plan 4c, Ruling V1).` + +Alt (Zeile `- Löschen nur unbenutzt (G5; …) …`): `- Löschen nur unbenutzt (G5; Fundstellen auch in Presets und relativen Farben), sonst \`in_use\` mit Hinweis auf detach. „Archivieren“ geht immer (Divi-Verhalten).` +Neu: `- Archivieren geht immer (Divi-Verhalten); der Probelauf nennt Fundstellen in Seiten, Presets und Farben als Warnung, \`lit/divi-design-detach\` löst die Verweise vorher (§6.8). Ein Restore behält später angelegte Variablen archiviert.` + +Run: `grep -n "delete: true\|Löschen nur unbenutzt" docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` +Expected: keine Treffer in §7.2 (§6.4 „Nur unbenutzte Presets“ bleibt unberührt). + +- [ ] **Step 5: Offene Punkte der Divi-Fähigkeiten-Notiz nachführen** + +In `docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md` im Abschnitt „Offen“ den Punkt `DesignArea::feature() braucht … (Etappe 4c/4d).` ersetzen durch: + +```markdown +- `DesignArea::feature()`: Farben (`color-write`) und Variablen (`variable-write`) haben seit Etappe 4c eigene Fähigkeiten; Theme-Optionen bekommen ihre mit 4d. +``` + +- [ ] **Step 6: Commit** + +```bash +git add tests/Integration/divi-colors-variables-smoke.sh docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md \ + docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md +git commit -m "test: Smoke für Farben und Variablen gegen Divi 5.15.1; docs: Formate-Nachtrag 4c, Variablen nur archivieren (Spec §7.2)" -m "Co-Authored-By: Claude " +``` + +### Task 12: Gesamtverifikation und Abschluss + +**Files:** +- Modify: Memory `etappe-4-stand` (außerhalb des Repos, `/home/janniclas/.claude/projects/-home-janniclas-Projekte-WordPress-wp-content-plugins-WP-LIT-MCP/memory/etappe-4-stand.md`) + +**Interfaces:** +- Consumes: alles aus den Tasks 1–11. +- Produces: grüne Suite, grüne Smokes, Gesamtreview ohne offene wichtige Befunde, aktualisierter Stand. + +- [ ] **Step 1: Suite und Lint** + +Run: `composer test && composer lint` +Expected: alle Tests grün (Zahl größer als 2877), phpcs ohne Befund. + +- [ ] **Step 2: Divi-Prüfung und Referenz** + +Run (DDEV, Divi 5.15.1): `cd /home/janniclas/Projekte/WordPress && ddev exec wp --user=admin lit-mcp divi-check` +Expected: Exit-Code 0, alle Fähigkeiten nutzbar, `color-write` und `variable-write` in der Matrix als „freigegeben“ bzw. „automatisch“. Meldet die Prüfung `signature`-Probleme für die neuen Symbole, `fingerprints.json` mit `--update-reference` neu erzeugen (Task 2 beschreibt das) und erneut prüfen. + +- [ ] **Step 3: Smokes** + +Run, jeweils einzeln (jeder muss mit `ALL CHECKS PASSED` enden): + +```bash +cd /home/janniclas/Projekte/WordPress +timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-colors-variables-smoke.sh +timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-smoke.sh +timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-assign-smoke.sh +timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh +``` + +Erwartet: viermal `ALL CHECKS PASSED`. Schlägt ein Schritt des neuen Smokes fehl, zuerst die Antwort der Ability lesen (`fail` druckt sie), die Ursache im Code beheben (nicht das Skript lockern) und mit einem eigenen Commit nachziehen. + +- [ ] **Step 4: Gesamtreview (zwei unabhängige Opus-Reviews parallel)** + +Dispatch (zwei Subagents, Modell Opus, je mit dem Diff `git diff 63389f3..HEAD` und den Plan-Rulings V1–V10): +1. Fachreview: Korrektheit gegen Plan und Spec, Sicherheit (Werteprüfung, Rechte, `variables_manager`, Fundstellen-Rechte P8/P8a, Rollback, Snapshot-Größe), Verhalten der Randfälle (Hauptfarben, Zyklen, archivierte Variablen, Restore mit später angelegten Variablen). +2. `anthropic-skills:clean-code-review` über die neuen und geänderten Klassen (Quick-Modus), mit der Bitte, die vorgemerkten Punkte (`PresetStack`, `PageRewriter` aufteilen, gemeinsame Validierungshelfer der Abilities) nur zu erwähnen, nicht zu verlangen. + +Wichtige und größere Befunde in einer Fix-Welle (Sonnet) beheben, danach Re-Review durch Opus; kleinere Befunde in die Ledger-Notiz der offenen Folgearbeit. + +- [ ] **Step 5: Memory nachführen** + +`etappe-4-stand.md` aktualisieren: 4c fertig (Commit-Hash, Testzahl, Datum), nächster Schritt „Plan 4d: Theme-Optionen und Schriften“ (`DesignArea::feature()` für `theme_options` braucht eine eigene Fähigkeit); aus 4c offen gelassene Punkte (aus dem Review) anhängen; Hinweis auf V1 (Variablen nur archivieren). + +- [ ] **Step 6: Abschluss** + +`git status` sauber; nichts pushen oder mergen ohne Auftrag. Dem Nutzer berichten: Testzahl, Smoke-Ergebnis, Befunde und Rulings (insbesondere V1), und fragen, ob der Branch gemergt/gepusht werden soll. + +--- + +## Anhang: Abweichungen der Task-Autoren vom Schnittstellenvertrag (geprüft, im Plan berücksichtigt) + +Die Autoren haben jeden Task in einer Kopie des Repos ausgeführt. Die folgenden Punkte weichen vom Vertrag ab oder verlangen Absprachen zwischen Tasks; wo der Vertrag oben schon angepasst ist, steht es dort. Verbindlich bleibt der Taskinhalt. + +### Tasks 1-2 + +- **A1 Task 1 ohne eigenen Commit.** Global Constraints verlangen `composer test` grün nach jedem Task. Mit den zwei neuen Fällen allein schlägt `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` fehl (jede Fähigkeit braucht eine Manifest-Anforderung; die kommen erst mit den Adaptern) – per Probelauf bestätigt, sonst bricht nichts. Den Test aufzuweichen kommt nicht in Frage; darum committet Task 2 (Step 13) die Dateien beider Tasks zusammen. Alternative: Task 1 und 2 zu einem Task zusammenlegen. +- **A2 Adapter nicht `final`.** Der Schnittstellenvertrag schreibt „final class … implements DiviAdapter“. Das Repo hat kein bypass-finals; Mockery kann finale Klassen nicht ersetzen. `DesignRightsTest` (Task 2) und die Bereiche/Tools der Tasks 6/7/9 müssen die Adapter aber ersetzen (Divi fehlt in Unit-Tests). Die beiden Adapter folgen deshalb `DesignSystemReader`/`DesignSystemWriter` („Not final so … can be tested with a mock“). Namen und Signaturen bleiben wie im Vertrag. +- **A3 `save()` der Farben: „wirft, wenn Divi nichts speichert“ konkretisiert.** `set_global_colors()` ist `void` und meldet nichts. Der Adapter bereinigt deshalb in beiden Wegen vorher selbst mit `sanitize_global_colors_data()` und wirft, wenn dabei ein Eintrag wegfällt (leeres `color`, ID ohne `gcid-`); beim Ersetzen (`$replace = true`) bekommt `set_global_colors($clean, false)` die schon bereinigten Daten (Divi bereinigt erneut, idempotent). Was Divi danach still anders speichert, fängt erst `verify()` beim Zurücklesen (Task 6). +- **A4 Hauptfarben-Zuordnung doppelt.** `GlobalColorsAdapter` braucht die Abbildung ID → Option in `et_divi` schon in Task 2, `ColorBook::CUSTOMIZER_IDS` entsteht erst in Task 3. Der Adapter hält sie als `private const CUSTOMIZER`. Empfehlung für Task 3: `ColorBook::CUSTOMIZER_IDS` mit denselben Werten anlegen und in Task 3 einen Test ergänzen, der beide gleich hält – oder den Adapter in Task 3 auf `ColorBook::CUSTOMIZER_IDS` umstellen (ColorBook ist reines PHP, eine Konstante daraus berührt Divi nicht). +- **A5 Lese-Aufrufe nur mit der Schreib-Fähigkeit getaggt.** `get_global_colors`/`get_global_variables` stehen in den neuen Manifesten nur mit `color-write`/`variable-write` (Vorgabe des Manifests), nicht wie `GlobalPreset::get_data` im Writer zusätzlich mit `design-read`: das Designsystem-Tool liest weiter über `DesignSystemReader`, dessen Einträge `design-read`/`page-read` tragen. Ein Bruch dieser Aufrufe sperrt so Lesen (über den Reader) und Schreiben (über die Adapter) – wie gewünscht. +- **A6 `save()` prüft zusätzlich das Freigabe-Gate** (`ReleaseGate::assertFeature()` der eigenen Fähigkeit, wie `DesignSystemWriter::savePresets()`), obwohl `DesignChange::run()` das für `DesignArea::feature()` schon tut – doppelte Absicherung für Aufrufer außerhalb der Pipeline (Restore-Planer, Detach). Im Vertrag nicht genannt, ändert keine Signatur. + +### Tasks 3-4 + +Keine Signatur des Schnittstellenvertrags ist geändert; die folgenden Punkte sind Auslegungen und Ergänzungen, die der Plan-Gesamtreview kennen muss. Der gesamte Code dieser zwei Tasks wurde in einer Kopie des Repos (Scratchpad `work/`, nur `src/` und `tests/` der neuen Dateien) geschrieben und ausgeführt: alle sechs Testklassen grün (121 Tests), `phpcs` ohne Meldung, die Gesamtsuite grün (2998 Tests, davon 2877 bestehende). + +1. **`rev()` ist nicht sha1.** Der Vertrag nennt „sha1 über kanonisches JSON“; `PresetBook::rev()` (maßgebliches Vorbild laut Auftrag) benutzt `Rev::of()` = erste 12 Hex-Zeichen von sha256. Übernommen wurde das Verfahren von `PresetBook` (mit Präfix `colors`/`variables` + NUL, Schlüssel rekursiv sortiert, `JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES`). Die Signatur `rev(): string` bleibt; kein späterer Task hängt vom Hash-Verfahren ab. +2. **Farbverweis-Ziel ohne Änderung an `VariableRef`.** `VariableRef::nameOf()` liefert den Namen nur für `content`-Verweise; für `color`-Verweise gibt es keine öffentliche Lesefunktion (`settingsOf()` liefert nur die Einstellungen). `ColorBook::targetOf()` (privat, 6 Zeilen) nutzt `VariableRef::typeOf()` + `MARKER`/`END` + `json_decode`. Kleinste saubere Alternative, falls gewünscht: `VariableRef::colorNameOf(string): ?string` als Zwilling von `nameOf()` und `targetOf()` entfällt; für den Plan nicht nötig. +3. **`ColorDiff` kennt zusätzlich die Aktion `removed`** (Vertrag: `added|changed|deactivated|activated`). Ein Restore mit Ersetzen (V4: `set_global_colors($data, false)`) löscht Farben, die nach dem Snapshot entstanden; ohne `removed` fehlte diese Zeile im Probelauf. Task 9 (`ColorRestorePlanner`) kann stattdessen jüngere Farben als `inactive` behalten (wie V1b bei Variablen) – dann kommt `removed` nie vor, ist aber harmlos. Zusammenfassungstext dafür: „n gelöscht“. Außerdem: ein Wechsel nach `temporary` (oder andere Statuswerte) ist `changed` mit `status` in `fields`; `deactivated`/`activated` tragen `fields` nur für weitere Änderungen. +4. **`VariableBook::onlyHere()`** nennt nur aktive Einträge, die dem Snapshot fehlen (Vertrag: „Einträge, die `restoredFrom()` archiviert hätte“ – bereits archivierte archiviert `restoredFrom()` nicht neu, sie bekommen lediglich `order: ''`). +5. **`VariableDiff`** erzeugt für Einträge, die nachher fehlen, keine Zeile (V1: Variablen verschwinden nie); archivierte/aktivierte Zeilen ignorieren `order`. +6. **Beim Einlesen fallen Daten weg, die Divi selbst verwirft oder die das Format nicht kennt:** `VariableBook::fromDivi` ignoriert Einträge ohne `gvid-`-Präfix (Divis `sanitize_global_variables_data` verwirft sie beim Schreiben ebenfalls; das deckt zugleich die Customizer-Schriften ab), unbekannte Typen und fremde Eintragsschlüssel; `ColorBook::fromDivi` behält nur die sechs Schlüssel aus Formate §1 (Divis `sanitize_global_colors_data` würde beliebige Zusatzschlüssel mitschreiben – ein Snapshot gäbe sie nicht zurück). `ColorBook` setzt `lastUpdated` für jede Farbe mit `folder: customizer` auf `''`, nicht nur für die fünf Standard-IDs. +7. **Strenge der Werteprüfung (V5/V6) ist eine Auslegung:** `ColorValue::check()` trimmt nicht (Leerraum am Rand = ungültig; der Aufrufer trimmt vorher) und prüft Schlüsselwörter nicht gegen die CSS-Liste (jedes Wort aus 3–20 Buchstaben, Divi prüft ebenfalls nichts); `VariableValue` erlaubt bei Links auch `//host/pfad` (fällt unter „`/`-URL“). Divis `sanitize_text_field` kann bei `numbers`/`fonts`/`images` Zeichenfolgen wie `%ab` (Prozent plus zwei Hex-Zeichen) entfernen – das fängt das Zurücklesen (`verify`, Task 6/7), nicht die Vorabprüfung. +8. **Tiefengrenze:** „Tiefe > 10“ = mehr als 10 Verweise hintereinander (11 Verweise = 12 IDs in der Kette). Das entspricht `GlobalData::resolve_global_color_variable()` (`$depth >= 10` bricht ab; der 11. Verweis bliebe unaufgelöst). Der Vertrag lässt offen, ob Tiefe Verweise oder Farben zählt; die Tests (`testCycleStopsAtMoreThanTenReferences`) halten die Auslegung fest. +9. **`VariableBook::now()` ruft `ColorBook::now()` auf** (ein Zeitstempel-Format); Task 4 hängt dadurch von Task 3 ab. `newId()` ist in beiden Büchern getrennt (je 6 Zeilen), um keine dritte öffentliche Hilfsklasse außerhalb des Vertrags einzuführen. +10. **Test-Hilfen als öffentliche statische Methoden** (`ColorBookTest::stored()/ref()`, `VariableBookTest::stored()/fromGetter()/variable()`) – dasselbe Muster wie `PresetBookTest::stored()`; Tasks 6, 7 und 9 sollen sie wiederverwenden statt eigene Bestände zu bauen. + +### Tasks 5-7 + +1. **Adapter nicht `final` (Task 2).** Der Schnittstellenvertrag nennt `final class GlobalColorsAdapter … implements DiviAdapter`. Die + Tests der Tasks 6/7 mocken `GlobalColorsAdapter` und `GlobalVariablesAdapter` mit Mockery (wie `DesignSystemWriter`, dessen Docblock + „Not final so abilities can be tested with a mock“ sagt); Mockery kann finale Klassen nicht mocken. Beide Adapter müssen darum + `class … implements DiviAdapter` sein (mit demselben Docblock-Satz). Alternative wäre ein Interface je Adapter – mehr Code ohne Nutzen. +2. **`PresetBook::withVariableResolved()` bekommt einen optionalen dritten Parameter** `bool $affixes = false`. V9 verlangt bei `strings` + `before . Wert . after` **aus den Einstellungen des jeweiligen Verweises** – das kann ein einziger `$value` nicht ausdrücken, und + `PresetBook` kennt den Typ der Variablen nicht. Vertragskompatibel (Default); Task 8 ruft `withVariableResolved($id, $value, + $variableType === 'strings')` auf. +3. **`ReferenceUsageQuery` nutzt nicht `NodeIndex`**, sondern läuft rekursiv über `Container::children()` des geparsten `Document`: + `NodeIndex::walk()` steigt nicht in opake Blöcke (globale Module) und Nicht-Divi-Blöcke ab, die Suche muss aber jeden Block sehen. + Zusätzlich fail closed: ein Treffer im Rohinhalt (`str_contains`, deckt Divi-4-Shortcodes wie `background_color="gcid-…"` ab) und + Blöcke mit unlesbaren Attributen zählen. Neu öffentlich: `ESCAPED_HYPHEN_REGEX` (zweites `REGEXP` für `gcid\u002d…`, das `LIKE` nicht + findet) und `mentions()` (von `PresetBook::referencing()` mitbenutzt); der Konstruktor nimmt optional `Parser $parser = new Parser()`. +4. **Konstruktoren der Abilities (für Task 10):** `DiviColorsSetAbility(DesignSystemWriter $writer, GlobalColorsAdapter $colors, + DesignChange $change, GlobalUsages $usages, ?Closure $random = null)` und `DiviVariablesSetAbility(DesignSystemWriter $writer, + GlobalVariablesAdapter $variables, DesignChange $change, GlobalUsages $usages, ?Closure $random = null)`. Der `DesignSystemWriter` wird + für `DesignRights::colors()/variables()` und für `presets()` (Warnungen) gebraucht. +5. **`VariableArea::write()` liest `$expected` selbst**, unmittelbar vor `save()` (`DesignArea::write()` bekommt den Planungsbestand nicht). + Gleichwertig zu V7, weil `DesignChange` unter der Sperre gerade erst die `design_rev` dieses Bestands verglichen hat; Divis + Stale-Prüfung fängt nur noch Schreibzugriffe am Plugin vorbei. +6. **`VariableArea::restore()` wendet `restoredFrom()` selbst an** (idempotent, wenn der Restore-Planer aus Task 9 es schon getan hat). Ohne + das würde der Rollback der Pipeline nach einem durchgegangenen Schreiben mit anschließend fehlgeschlagenem `verify()` an Divis + `global_variable_ids_missing` scheitern (die neue ID wäre gespeichert, der Snapshot kennt sie nicht) → `partial` statt `rolled_back`. +7. **Zusätzliche Regeln, die V3/V5/Spec nicht ausdrücklich nennen:** Hauptfarben bekommen keinen `{{color:…}}`-Verweis (Divi speichert sie + als festen Wert in `et_divi`, ein `$variable(…)$` bräche den Customizer); `{{color:…}}` verlangt `relative` (V5 „**mit** relative“); + Namen neuer/umbenannter Farben bzw. Variablen müssen frei sein (nicht Name, ID oder Alias eines anderen Eintrags, sonst würden + `{{color:…}}`/`{{var:…}}` mehrdeutig – analog A5 bei Presets); Ordner `customizer` ist für neue Farben gesperrt. +8. **Risiko `VariableArea::verify()`:** Es vergleicht wie gefordert nur IDs, Status und „Wert nicht leer“. Tut Divi bei einer reinen + Wertänderung still nichts (fehlendes `variables_manager`), besteht `verify()`, und `differences()` meldet „bereinigt“. Abgefangen + wird das vorher durch `DesignRights::variables()` (V7); wer es enger will, vergleicht in `verify()` zusätzlich die Werte der + geänderten Einträge. +9. **Annahmen über die parallel geplanten Klassen, auf denen die Tests ruhen:** `ColorBook::fromDivi($book->data())` und + `VariableBook::fromDivi($book->data())` sind idempotent; `ColorDiff`/`VariableDiff` liefern in `name` das Label; `VariableBook::fromDivi()` + behält die Gruppe `gradients`; `$book->restoredFrom($book)` ergibt denselben Bestand; `ColorValue::check('kein Wert!')`, + `ColorValue::check('$variable(…)$')`, `ColorValue::relative(['farbe' => 10])` und `VariableValue::check('numbers', '1px;}')` melden + Fehler, `ColorValue::check('white')` nicht; `nextOrder('strings')` ist bei vorhandenem `order` `'1'` und archivierten `''` gleich `'2'`; + `ColorBook::cycle()` liefert eine Kette, die `gcid-sand000002` enthält. Weicht ein Task-3/4-Plan davon ab, sind nur Testerwartungen + anzupassen, nicht der Ablauf. (Geprüft: Task 5 gegen den echten Code; Tasks 6/7 mit einfachen Ersatzklassen für Tasks 1–4 nach dem + Vertrag in einer Kopie außerhalb des Repos – alle RED-/GREEN-Schritte verhalten sich wie beschrieben, die 312 Design-Tests bleiben grün, + phpcs ist sauber.) +10. **Neue IDs zwischen Probelauf und `confirm`:** Wie bei `lit/divi-presets-set` (`PresetBook::newId()`) wird beim `confirm` neu geplant, + eine neue Farbe/Variable bekommt also eine andere zufällige ID als im Probelauf-`diff`. Die `design_rev` bezieht sich auf den Bestand, + nicht auf den Plan, darum stört das die Bestätigung nicht; die Tests prüfen IDs deshalb nur per Muster. + +### Tasks 8-9 + +- **Verdrahtung vorgezogen (Task 10):** Beide Tools bekommen neue Konstruktor-Signaturen; `DiviIntegration::abilities()` wird in `DiviIntegrationTest`, `DiviToolFeaturesTest`, `IntegrationRegistryTest` u. a. gebaut. Damit `composer test` nach Task 8 und 9 grün ist, ändern 8c/9a/9c die zwei Konstruktoraufrufe in `DiviIntegration` (Adapter inline per `new`) und die Zeilen von `lit/divi-design-detach`/`lit/divi-design-restore` in `DiviToolFeaturesTest`. Task 10 darf das nicht doppelt tun. +- **Adapter müssen mockbar sein:** Der Vertrag nennt `GlobalColorsAdapter` „final class“. Die Ability- und Planer-Tests mocken beide Adapter mit Mockery (wie `DesignSystemWriter`, „Not final so abilities can be tested with a mock“); Mockery kann finale Klassen nicht mocken. Annahme: beide Adapter sind nicht final und haben einen Konstruktor ohne Pflichtargumente. +- **`PresetBook::withVariableResolved(string $id, string $value)` kennt den Variablentyp nicht:** Verweise auf eine `strings`-Variable mit `before`/`after` *in Presets* verlören diese Texte, während Seiten sie behalten (V9). Vorschlag für Task 5: intern `VariableDetachRewrite::replacement($ref, $value, $type)` nutzen bzw. die Signatur um `string $variableType` erweitern. Task 8 ruft die Methode wie im Vertrag auf. +- **`ColorDiff` kennt kein `removed`:** Ein Farben-Restore ersetzt über `set_global_colors($data, false)` alle Farben (V4) und entfernt damit neuere Farben. `ColorRestorePlanner` ergänzt die `removed`-Zeilen selbst und filtert Zeilen von `between()` zu IDs, die der Snapshot nicht kennt. Sauberer wäre `removed` in `ColorDiff`/`summary()` (Task 3). +- **Eingebettete Verweise:** `TreeRewrite` ruft `notes()` nur für geänderte Blöcke. Ein Modul (oder eine Seite), das die Variable *nur* eingebettet nennt, bekommt keinen Hinweis und steht nicht in `pages`; `ReferenceUsageQuery` zählt die Seite aber weiter als Fundstelle (blockiert als Live-Seite ohne `edit_post`, P8a), und der Fertig-Hinweis kann erscheinen, obwohl eingebettete Verweise bleiben. Die Tool-Beschreibung sagt, dass sie stehen bleiben. +- **`VariableWrite` im Detach:** Der Detach schreibt keine Variablen, liest sie aber über `GlobalVariablesAdapter` und prüft `canManage()`. Ob dafür `VariableWrite` nötig ist, hängt am Manifest aus Task 2 (Fähigkeiten von `get_global_variables`/`et_pb_is_allowed`); gewählt ist der Zweig `variable` = bisherige Liste + `VariableWrite`. +- **Rechte bei ungültiger `snapshot_id`:** Ohne gültigen Bereich prüft die Restore-Ability die Rechte aller Planer (fail closed) – ein Konto ohne `variables_manager` bekommt dann `forbidden` statt `validation_failed`. +- **Variablen ohne Textwert:** Ist `value` einer Variablen kein String (z. B. `gradients`, falls Divi den Typ als Objekt speichert), antwortet der Detach mit `unsupported`; V6 sagt nur „unverändert durchgereicht“. +