Files
WP-LIT-MCP/docs/superpowers/plans/2026-10-19-etappe-4c-farben-variablen.md
T

10158 lines
458 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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', <id>, <settings>)`, 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/<name>` (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/<skript>.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 "<Nachricht>" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"` 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:<Name|ID|Alias>}}` **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', <gcid>, <settings>)` 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":"<gvid>",…}})$` 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<string, array<string, mixed>> get_global_colors() mit den Hauptfarben aus et_divi (V3) */
public function read(): array;
/** @param array<string, array<string, mixed>> $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<string, array<string, array<string, mixed>>> Speicherform, Typ => ID => Eintrag (V7) */
public function read(): array;
/** @param array<string, array<string, array<string, mixed>>> $data @param array<string, array<string, array<string, mixed>>> $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<string, mixed> $relative @return array<string, int|float>|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<string, array{color: string, folder: string, label: string, lastUpdated: string, status: string, usedInPosts: list<mixed>}> */
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<string> IDs von Farben, deren color ein Verweis auf $id ist
public function cycle(): ?array; // list<string> Kette eines Zyklus oder einer Tiefe > 10, sonst null
public function changedSince(self $before): array; // array<string, entry> 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<array{id: string, name: string, action: string, fields?: list<string>}> 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<string, array<string, array<string, mixed>>> 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<array{type: string, id: string, label: string}> 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<array{type: string, id: string, name: string, action: string, fields?: list<string>}> 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<string> 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<array{id: string, name: string, scope: string}> 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<DiviFeature>
/** @param array<array-key, mixed> $current @param array<array-key, mixed> $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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use PHPUnit\Framework\TestCase;
final class DiviFeatureTest extends TestCase
{
public function testTheThirteenFeaturesAndTheirValues(): void
{
self::assertSame(
[
'PageRead' => '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-<name>, indirect lit/<name>): $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 <IDs>')`, 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: <code> <message>')`; `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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
/**
* Divi's GlobalData is replaced by a Mockery alias; the tests that need it run in their own process (an alias
* defines the class for the rest of the process).
*/
final class GlobalColorsAdapterTest extends TestCase
{
private const GLOBAL_DATA = 'ET\\Builder\\Packages\\GlobalData\\GlobalData';
public function testIsARegisteredDesignAdapterForColorWrite(): void
{
$manifest = GlobalColorsAdapter::manifest();
self::assertContains(GlobalColorsAdapter::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::DESIGN, $manifest->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Error;
/**
* Divi's GlobalData is replaced by a Mockery alias and et_pb_is_allowed() by Brain Monkey; those tests run in their
* own process (both define a symbol for the rest of the process).
*/
final class GlobalVariablesAdapterTest extends TestCase
{
private const GLOBAL_DATA = 'ET\\Builder\\Packages\\GlobalData\\GlobalData';
public function testIsARegisteredDesignAdapterForVariableWrite(): void
{
$manifest = GlobalVariablesAdapter::manifest();
self::assertContains(GlobalVariablesAdapter::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::DESIGN, $manifest->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Packages\GlobalData\GlobalData;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* Adapter "Designsystem" (spec §3) for the global colors (formats §1, plan 4c V3/V4): reads them through
* GlobalData::get_global_colors() and writes them through set_global_colors(). The five customizer colors live in
* Divi's theme options; get_customizer_colors() caches them in a static variable for the whole request, so read()
* lays the stored option values over Divi's answer – a read-back after writing sees the new value. Not final so the
* color area can be tested with a mock.
*/
class GlobalColorsAdapter implements DiviAdapter
{
/** Divi's theme options (one array option) with the customizer colors. */
private const OPTIONS = 'et_divi';
/** Customizer color ID => 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<string, array<string, mixed>> 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<string, array<string, mixed>> $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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use ET\Builder\Packages\GlobalData\GlobalData;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Adapter "Designsystem" (spec §3) for the global variables (formats §2, plan 4c V7): reads them through
* GlobalData::get_global_variables() back into the stored form and writes them through set_global_variables() with
* the inventory the change was planned on (Divi's own stale check). Without edit_theme_options or Divi's role right
* "Variables Manager" Divi silently saves nothing – DesignRights::variables() asks canManage() first. Not final so
* the variable area can be tested with a mock.
*/
class GlobalVariablesAdapter implements DiviAdapter
{
public static function manifest(): Manifest
{
return new Manifest(
Manifest::DESIGN,
calls: [
new Call(GlobalData::class . '::get_global_variables', 0),
new Call(GlobalData::class . '::set_global_variables', 2),
new Call('et_pb_is_allowed', 1),
],
features: [DiviFeature::VariableWrite]
);
}
/**
* The stored form: type => 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<string, array<string, array<string, mixed>>>
*/
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<string, array<string, array<string, mixed>>> $data The whole new inventory (stored form).
* @param array<string, array<string, array<string, mixed>>> $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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignRights;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DesignRightsTest extends TestCase
{
public function testPresetsNeedEditThemeOptionsAndTheVisualBuilder(): 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::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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use LIT\MCP\Abilities\Errors;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Who may change the design system (spec §10): presets and colors need edit_theme_options and Divi's role right
* "Visual Builder verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1"); variables
* also the role right "Variables Manager". Without unfiltered_html a write is refused while presets it saves along
* hold HTML/CSS that Divi would strip (final review I-2).
*/
final class DesignRights
{
public const OTHER_MARKUP = 'Andere Presets enthalten HTML/CSS, das beim Speichern ohne das Recht unfiltered_html '
. 'verloren ginge; nichts wurde gespeichert.';
/** @return Closure(): bool Whether the current user may save unfiltered HTML. */
public static function unfilteredHtml(): Closure
{
return static fn (): bool => 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<string> $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": "<sha256>"`, `"…GlobalData::set_global_colors": "<sha256>"`, `"…GlobalData::set_global_variables": "<sha256>"`; 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 <Modellname> <noreply@anthropic.com>"
```
---
### 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<array{id, name, action, fields?}>`, `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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\ColorValue;
use PHPUnit\Framework\TestCase;
final class ColorValueTest extends TestCase
{
/** @return array<string, array{string}> */
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<string, array{string}> */
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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
defined('ABSPATH') || exit;
/**
* Checks the value of a global color before it goes to Divi (ruling V5, D7 principle): hex, rgb()/rgba()/hsl()/hsla(),
* or a color keyword. Placeholders ({{color:…}}) and $variable(…)$ references are no plain values – they are resolved
* against the planned inventory by the caller; relative() checks the "relative" settings that belong to them.
* Pure PHP.
*/
final class ColorValue
{
public const TYPES_NOTE = 'Erlaubt sind Hex-Werte (#rgb, #rgba, #rrggbb, #rrggbbaa), rgb()/rgba()/hsl()/hsla(), '
. 'ein Farbschlüsselwort (z. B. red) oder ein Platzhalter {{color:<Name>}} 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:<Name>}} '
. '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<string, mixed> $relative
* @return array<string, int|float>|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 <noreply@anthropic.com>"
```
#### 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Design\ColorBook;
use PHPUnit\Framework\TestCase;
use stdClass;
final class ColorBookTest extends TestCase
{
/** @return array<string, array<string, mixed>> 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<string, int|float> $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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
defined('ABSPATH') || exit;
/**
* The global colors in Divi's format (GlobalData::get_global_colors(), formats §1): id => {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<string, array{color: string, folder: string, label: string, lastUpdated: string,
* status: string, usedInPosts: list<mixed>}> $data */
private function __construct(private array $data)
{
}
/** @param array<array-key, mixed>|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<string, array{color: string, folder: string, label: string, lastUpdated: string,
* status: string, usedInPosts: list<mixed>}> */
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<mixed>}|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<string, mixed> $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<string> 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<string>|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<string, array{color: string, folder: string, label: string, lastUpdated: string,
* status: string, usedInPosts: list<mixed>}>
*/
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<array-key, mixed> $entry
* @return array{color: string, folder: string, label: string, lastUpdated: string, status: string,
* usedInPosts: list<mixed>}
*/
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 <noreply@anthropic.com>"
```
#### 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\ColorBook;
use LIT\MCP\Integrations\Divi\Design\ColorDiff;
use PHPUnit\Framework\TestCase;
final class ColorDiffTest extends TestCase
{
public function testNoChangeIsAnEmptyDiffEvenWhenOnlyDerivedFieldsDiffer(): void
{
$book = ColorBook::fromDivi(ColorBookTest::stored());
$touched = $book->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
defined('ABSPATH') || exit;
/**
* What a color change does (spec §4 step 4, "diff"): per color added, changed (with the changed fields), deactivated
* or activated (status change; "fields" lists further changed fields), removed (only a restore can drop a color).
* lastUpdated and usedInPosts are no change of their own. Sorted by ID. Pure PHP.
*/
final class ColorDiff
{
private const COMPARED = ['color', 'folder', 'label', 'status'];
/**
* @return list<array{id: string, name: string, action: string, fields?: list<string>}>
* 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<array<string, mixed>> $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 <noreply@anthropic.com>"
```
#### 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\VariableValue;
use PHPUnit\Framework\TestCase;
final class VariableValueTest extends TestCase
{
/** @return array<string, array{string, string}> */
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" & <willkommen>'],
'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<string, array{string, string}> */
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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
defined('ABSPATH') || exit;
/**
* Checks the value of a global variable before it goes to Divi (ruling V6, D7 principle). The type "gradients"
* (new in Divi 5.15) is passed through by VariableBook, but the tools never create or change it. The check of fonts
* against Divi's font list follows with 4d. Pure PHP.
*/
final class VariableValue
{
/** Types the tools may create (V6). */
public const TYPES = ['numbers', 'strings', 'images', 'links', 'fonts'];
private const NUMBERS_MAX = 200;
private const STRINGS_MAX = 2000;
private const URL_MAX = 2000;
private const FONT_MAX = 100;
/** Control characters except the line feed. */
private const CONTROL = '/[\x00-\x09\x0B-\x1F\x7F]/';
private const URL = '/^(?:https?:\/\/[^\s<>"]+|\/[^\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 <noreply@anthropic.com>"
```
#### 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\VariableBook;
use PHPUnit\Framework\TestCase;
use stdClass;
final class VariableBookTest extends TestCase
{
/** @return array<string, mixed> 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<string, array<string, array<string, mixed>>> 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<string, mixed> 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
use LIT\MCP\Abilities\Rev;
defined('ABSPATH') || exit;
/**
* The global variables in Divi's stored format (formats §2): type => 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<string, array<string, array<string, mixed>>> $data */
private function __construct(private array $data)
{
}
/** @param array<array-key, mixed>|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<string, array<string, array<string, mixed>>> */
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<string, mixed>|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<string, mixed> $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<array{type: string, id: string, label: string}>
*/
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<array-key, mixed> $entry
* @return array<string, mixed>
*/
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<string, array<string, array<string, mixed>>> $data
* @return array<string, array<string, array<string, mixed>>>
*/
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 <noreply@anthropic.com>"
```
#### 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\VariableBook;
use LIT\MCP\Integrations\Divi\Design\VariableDiff;
use PHPUnit\Framework\TestCase;
final class VariableDiffTest extends TestCase
{
public function testNoChangeIsAnEmptyDiffEvenWhenOnlyLastUpdatedDiffers(): void
{
$book = VariableBook::fromDivi(VariableBookTest::stored());
$touched = $book->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
defined('ABSPATH') || exit;
/**
* What a variable change does (spec §4 step 4, "diff"): per variable added, changed (with the changed fields label,
* value, order, status), archived or activated (status switch; "fields" then lists label/value changes only – archiving
* clears the order on its own). lastUpdated is no change of its own. Variables are never removed (ruling V1), so a
* variable missing in $after produces no row. Sorted by type, then ID. Pure PHP.
*/
final class VariableDiff
{
private const COMPARED = ['label', 'value', 'order', 'status'];
/**
* @return list<array{type: string, id: string, name: string, action: string, fields?: list<string>}>
* 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<array<string, mixed>> $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 <noreply@anthropic.com>"
```
#### 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<string>` 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<array{id: string, name: string, scope: string}>` (`scope` = Modul- bzw.
Gruppenname wie in `PresetDiff`).
- `PresetBook::withVariableResolved(string $id, string $value, bool $affixes = false): self` – ganze Verweise
`$variable({"type":"content","value":{"name":"<id>",…}})$` 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ReferenceUsageQueryTest extends TestCase
{
/** A color reference as post_content holds it: quotes JSON-escaped (formats §2). */
private const COLOR_REF = '$variable({\u0022type\u0022:\u0022color\u0022,\u0022value\u0022:{\u0022name\u0022:'
. '\u0022gcid-holz000001\u0022,\u0022settings\u0022:{}}})$';
public function testCandidatesContainAnIdAnEscapedHyphenOrAnEscapedLetter(): void
{
$db = Mockery::mock(\wpdb::class);
$db->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('<!-- wp:divi/text {"module":{"decoration":{"background":{"desktop":'
. '{"value":{"color":"' . self::COLOR_REF . '"}}}}}} /-->'));
self::assertTrue($query->matches('<!-- wp:divi/text {"x":"gvid-radius0001"} /-->'));
self::assertFalse($query->matches('<!-- wp:divi/text {"module":{"decoration":{"background":{"desktop":'
. '{"value":{"color":"#ffffff"}}}}}} /-->'));
self::assertFalse($query->matches('<!-- wp:divi/text {"x":"gcid-anders0001"} /-->'));
self::assertFalse($query->matches('<p>Keine Verweise</p>'));
}
public function testFindsAnIdWrittenWithJsonEscapesAlsoInsideGlobalModules(): void
{
$query = new ReferenceUsageQuery(['gcid-holz000001']);
self::assertTrue($query->matches('<!-- wp:divi/text {"x":"gcid\u002dholz000001"} /-->'));
self::assertTrue($query->matches('<!-- wp:divi/section {"globalModule":"12"} -->'
. '<!-- wp:divi/text {"x":"gcid\u002dholz000001"} /--><!-- /wp:divi/section -->'));
self::assertFalse($query->matches('<!-- wp:divi/text {"x":"gcid\u002danders0001"} /-->'));
}
public function testUnreadableMarkupAndDivi4ContentCount(): void
{
$query = new ReferenceUsageQuery(['gcid-holz000001']);
self::assertTrue($query->matches('<!-- wp:divi/section --><!-- wp:divi/text {"x":1} /-->'), 'never closed');
self::assertTrue($query->matches('<!-- wp:divi/text {"x":"gcid\u002dholz000001",} /-->'), '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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\Container;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
defined('ABSPATH') || exit;
/**
* The posts that reference any of the global colors or variables $ids (GlobalUsages::reportFor(), ruling V8). Divi
* writes a reference as $variable({"type":"color"|"content","value":{"name":"<id>",…}})$ 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<string> */
private array $ids;
/**
* @param list<string> $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<string> $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 <Modellname> <noreply@anthropic.com>"
```
#### 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<string, mixed>
*/
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<array{id: string, name: string, scope: string}> 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<array-key, mixed> $settings
* @return array<array-key, mixed>
*/
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 <Modellname> <noreply@anthropic.com>"
```
- [ ] **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(<live gelesen>)` ü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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Design\ColorArea;
use LIT\MCP\Integrations\Divi\Design\ColorBook;
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ColorAreaTest extends TestCase
{
/**
* GlobalColorsAdapter::read(): the main color primary, the color Holz dunkel and the relative color Sand built
* on it.
*
* @return array<string, array<string, mixed>>
*/
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<string, array<string, mixed>> $stored
* @param list<array{0: array<string, mixed>, 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<string, array<string, mixed>> $stored
* @param array<string, mixed> $fields
* @return array<string, array<string, mixed>>
*/
private static function changed(array $stored, string $id, array $fields): array
{
$stored[$id] = $fields + $stored[$id];
return $stored;
}
/** @return array<string, mixed> */
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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
defined('ABSPATH') || exit;
/**
* The area "colors" (spec §7.1, rulings V3, V4): the global colors as ColorBook reads them through
* GlobalColorsAdapter (the five main colors from et_divi, their lastUpdated empty). A change writes only the new and
* changed colors – merged, the way Divi's builder saves –, compared with the inventory read right before; a restore
* (and a rollback) replaces all stored colors with the snapshot. Divi's sanitizing may change values: verify() only
* insists on the same colors with the same status, differences() names the colors whose values changed.
*/
final class ColorArea implements DesignArea
{
public function __construct(private GlobalColorsAdapter $colors)
{
}
public function name(): string
{
return 'colors';
}
public function label(): string
{
return 'Farben';
}
public function feature(): DiviFeature
{
return DiviFeature::ColorWrite;
}
public function raw(): array
{
return ColorBook::fromDivi($this->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<array-key, mixed> $raw
* @return array<string, string> 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 <Modellname> <noreply@anthropic.com>"
```
#### 6b – `lit/divi-colors-set`: Hülle, Pipeline, feste Farbwerte, Hauptfarben
- [ ] **Step 6: Failing tests schreiben**
`tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Design\ColorBook;
use LIT\MCP\Integrations\Divi\Design\ColorDiff;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility;
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class DiviColorsSetAbilityTest extends TestCase
{
/** @var array<string, array<string, mixed>> What the adapter stores (ColorAreaTest::adapter()). */
private array $stored = [];
/** @var list<array{0: array<string, mixed>, 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<string, mixed> $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('<!-- wp:divi/text {"x":"gcid-holz000001"} /-->')
&& ! $query->matches('<!-- wp:divi/text {"x":"gcid-sand000002"} /-->')))
->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-colors-set (spec §7.1, rulings V3–V5, V8): creates and changes global colors through the change pipeline
* (ColorArea). An item names an existing color in id (ID, label, alias primary … or option name accent_color …) or
* creates one from label and color. Colors are never deleted, only deactivated; the five main colors only change
* their value. The dry run lists the posts that reference a changed, deactivated or activated color
* (ReferenceUsageQuery).
*/
final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
{
public const MAX_ITEMS = 50;
private const LABEL_MAX = 100;
private const LABEL_RULE = 'Name: 1–100 Zeichen, ohne HTML';
private const STATUSES = ['active', 'inactive'];
private const MAIN_COLOR_RULE = 'Hauptfarben ändern nur color, nicht label, folder oder status';
/** A color as Divi stores it (formats §1); new colors start from it. */
private const NEW_ENTRY = ['color' => '', '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:<Name>}} 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<mixed> $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<array-key, mixed> $item
* @param array<string, string> $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<string, string> $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<array-key, mixed> $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 <Modellname> <noreply@anthropic.com>"
```
#### 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-colors-set (spec §7.1, rulings V3–V5, V8): creates and changes global colors through the change pipeline
* (ColorArea). An item names an existing color in id (ID, label, alias primary … or option name accent_color …) or
* creates one from label and color. Colors are never deleted, only deactivated; the five main colors only change
* their value, never to a reference. {{color:…}} with relative is resolved against the planned inventory – also
* colors of the same call, in item order – and stored as Divi reference (VariableRef); cycles and chains deeper than
* 10 are refused. The dry run lists the posts that reference a changed, deactivated or activated color
* (ReferenceUsageQuery) and warns about presets and relative colors built on it.
*/
final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
{
public const MAX_ITEMS = 50;
private const LABEL_MAX = 100;
private const LABEL_RULE = 'Name: 1–100 Zeichen, ohne HTML';
private const STATUSES = ['active', 'inactive'];
private const MAIN_COLOR_RULE = 'Hauptfarben ändern nur color, nicht label, folder oder status';
/** A color as Divi stores it (formats §1); new colors start from it. */
private const NEW_ENTRY = ['color' => '', '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:<Name>}} 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<mixed> $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<array-key, mixed> $item
* @param array<string, string> $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<string, string> $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<array-key, mixed> $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<array<string, mixed>> $diff
* @return list<string>
*/
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<string> $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 <Modellname> <noreply@anthropic.com>"
```
- [ ] **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(), <live gelesen>)` (`$mayRemove` ohne Bedeutung: es wird nie entfernt, V1);
`restore($raw)` = `save(<live>->restoredFrom(<$raw>)->data(), <live>)` – 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Design\VariableArea;
use LIT\MCP\Integrations\Divi\Design\VariableBook;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class VariableAreaTest extends TestCase
{
/**
* GlobalVariablesAdapter::read() (stored form, V7): Eckenradius (numbers), Slogan, the archived Alter Slogan
* and the archived empty Leer (strings), and Verlauf (gradients, only passed through).
*
* @return array<string, array<string, array<string, mixed>>>
*/
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<string, array<string, array<string, mixed>>> $stored
* @param list<array{0: array<string, mixed>, 1: array<string, mixed>}> $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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
defined('ABSPATH') || exit;
/**
* The area "variables" (spec §7.2, rulings V1, V7): the global variables in their stored form (VariableBook), saved
* as a whole through GlobalVariablesAdapter::save() together with the inventory read right before, which Divi checks
* for changes in between (global_variables_stale). Divi refuses an inventory that lacks a stored ID, so a restore keeps
* the variables created after the snapshot, archived (VariableBook::restoredFrom()) – also when the pipeline rolls back
* a write that went through. Without the variables_manager right Divi silently saves nothing: verify() compares the
* read-back (same variables, status and non-empty values), differences() names the values Divi sanitized.
*/
final class VariableArea implements DesignArea
{
public function __construct(private GlobalVariablesAdapter $variables)
{
}
public function name(): string
{
return 'variables';
}
public function label(): string
{
return 'Variablen';
}
public function feature(): DiviFeature
{
return DiviFeature::VariableWrite;
}
public function raw(): array
{
return VariableBook::fromDivi($this->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<array-key, mixed> $raw
* @return array<string, array{0: string, 1: bool}> "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 <Modellname> <noreply@anthropic.com>"
```
#### 7b – `lit/divi-variables-set`: Hülle, Pipeline, Typen, Werte, Archivieren
- [ ] **Step 6: Failing tests schreiben**
`tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php`:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviVariablesSetAbility;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\VariableBook;
use LIT\MCP\Integrations\Divi\Design\VariableDiff;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class DiviVariablesSetAbilityTest extends TestCase
{
/** @var array<string, array<string, array<string, mixed>>> What VariableAreaTest::adapter() stores. */
private array $stored = [];
/** @var list<array{0: array<string, mixed>, 1: array<string, mixed>}> */
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<string, mixed> $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('<!-- wp:divi/text {"x":"gvid-slogan0001"} /-->')
&& ! $query->matches('<!-- wp:divi/text {"x":"gvid-radius0001"} /-->')))
->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-variables-set (spec §7.2 with rulings V1, V6–V8): creates and changes global variables through the change
* pipeline (VariableArea). An item names an existing variable in id (ID or name) or creates one from type, label and
* value. Variables are never deleted – Divi refuses an inventory that lacks a stored ID (V1) –, only archived (order
* "") and activated again (appended at the end of their type); the type of a variable stays, gradients are left to the
* 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).
*/
final class DiviVariablesSetAbility implements Ability, RequiresDiviFeatures
{
public const MAX_ITEMS = 50;
private const LABEL_MAX = 100;
private const LABEL_RULE = 'Name: 1–100 Zeichen, ohne HTML';
private const STATUSES = ['active', 'archived'];
/** A variable as Divi stores it (formats §2); new variables start from it. */
private const NEW_ENTRY = ['id' => '', '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<mixed> $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<array-key, mixed> $item
* @param array<string, string> $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<array-key, mixed> $item
* @param array<string, string> $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<array-key, mixed> $item
* @param array<string, string> $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 <Modellname> <noreply@anthropic.com>"
```
#### 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<array<string, mixed>> $diff
* @return list<string>
*/
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<string> $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 <Modellname> <noreply@anthropic.com>"
```
- [ ] **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<string> $ids)`; `PresetBook::referencing(string $id): list<array{id, name, scope}>`, `->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":"<gvid>",…}})$` 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\DetachRewrite;
use LIT\MCP\Integrations\Divi\Design\DetachTarget;
use LIT\MCP\Integrations\Divi\Design\NodeRewrite;
use LIT\MCP\Integrations\Divi\Design\PresetScope;
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
use PHPUnit\Framework\TestCase;
final class DetachTargetTest extends TestCase
{
/** @param list<int>|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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
defined('ABSPATH') || exit;
/**
* What lit/divi-design-detach removes (spec §6.8, rulings R10, V9), resolved on the live inventory: the usage query
* and a fresh NodeRewrite per post for the PageRewriter and – when presets nest the target and no post_ids narrow the
* call – the preset step, i.e. how the preset inventory changes (DesignChange, snapshot "presets"). Pure PHP.
*/
final class DetachTarget
{
/**
* @param string $subject The target in messages: 'Variable „Eckenradius“'.
* @param list<int>|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<string> $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<array<string, mixed>> $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` = `<Scope-Label> „<Name>“`, `nestedIn` = `anderen Presets`. Der Rest der Datei (Kopf bis `execute()`, `firstPageError()`, `invalid()`) bleibt. Ganze Datei nach dem Umbau:
```php
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Log;
use WP_Error;
defined('ABSPATH') || exit;
/**
* 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.
*/
final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
{
private const VARIABLES = 'Variablen folgen mit Etappe 4c.';
private const DELETE_HINT = 'Jetzt kann lit/divi-presets-delete das Preset löschen.';
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.';
/** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */
public function __construct(
private DesignSystemWriter $writer,
private DesignChange $change,
private GlobalUsages $usages,
private PageRewriter $pages,
private ?Closure $unfiltered = null
) {
$this->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<string, mixed> $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<string, mixed>|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<array<string, mixed>>, 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<array-key, mixed>): (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<string, mixed> $input
* @return array<string, mixed>|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<string, mixed> $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<string, string> $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 <Modellname> <noreply@anthropic.com>"
```
#### 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Json;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Design\VariableDetachRewrite;
use PHPUnit\Framework\TestCase;
final class VariableDetachRewriteTest extends TestCase
{
private const RADIUS = 'gvid-radius0001';
private const CLAIM = 'gvid-claim00001';
/** A whole reference to global variable $id, as the Divi Builder stores it. */
public static function ref(string $id, array $settings = []): string
{
return VariableRef::serialize('content', $id, $settings);
}
/** A breakpoint map with one desktop value. */
private static function desktop(mixed $value): array
{
return ['desktop' => ['value' => $value]];
}
/**
* @param list<array{0: string, 1: array<string, mixed>}> $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('<!-- wp:%s %s /-->', $name, Json::encode($attrs));
}
return NodeIndex::build((new Parser())->parse(
'<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->' . $inner
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->'
));
}
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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
defined('ABSPATH') || exit;
/**
* Replaces the references to global variable $id in the attributes of Divi blocks by its value (lit/divi-design-detach
* with variable, ruling V9): every attribute value that is exactly one reference $variable({"type":"content",
* "value":{"name":"<id>",…}})$ 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 <Modellname> <noreply@anthropic.com>"
```
#### 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<string, array<string, array<string, mixed>>> 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<string, mixed> 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(
'<!-- wp:divi/text {"x":"' . $id . '"} /-->'
) && ! $query->matches('<!-- wp:divi/text {"x":"gvid-andere0001"} /-->');
}
/** 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<string, mixed> $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<string, mixed> $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 <Modellname> <noreply@anthropic.com>"
```
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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\PresetArea;
use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class PresetRestorePlannerTest extends TestCase
{
private static function planner(bool $unfiltered = true): PresetRestorePlanner
{
return new PresetRestorePlanner(Mockery::mock(DesignSystemWriter::class), static fn (): bool => $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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
defined('ABSPATH') || exit;
/**
* How lit/divi-design-restore writes a snapshot of one area back (spec §5, ruling V10): the area for DesignChange, the
* rights and Divi features of that area, and the plan from the current inventory to the restored one.
*/
interface RestorePlanner
{
/** The snapshot area this planner restores – one of SnapshotStore::AREAS. */
public function area(): string;
public function designArea(): DesignArea;
/** Why the current user may not restore this area (spec §10); null when allowed. */
public function rights(): ?WP_Error;
/** @return list<DiviFeature> */
public function features(): array;
/**
* @param array<array-key, mixed> $current The area's inventory now (DesignArea::raw(), read by DesignChange).
* @param array<array-key, mixed> $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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Restores a snapshot of the presets (Etappe 4a): the snapshot replaces the whole inventory (presets missing in it are
* removed); without unfiltered_html it is refused while the snapshot or the current inventory holds HTML/CSS that
* Divi would strip (final review I-2).
*/
final class PresetRestorePlanner implements RestorePlanner
{
private const MARKUP = 'Der Snapshot oder der aktuelle Bestand enthält Presets mit HTML/CSS, das beim Speichern '
. 'ohne das Recht unfiltered_html verloren ginge; nichts wurde gespeichert.';
/** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */
public function __construct(private DesignSystemWriter $writer, private ?Closure $unfiltered = null)
{
$this->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-design-restore (spec §5, G6, ruling V10): writes a snapshot back through the change pipeline – dry run with
* the diff to the current state, confirm with design_rev, and first a snapshot of the current state, so a restore can
* be undone as well. The area comes from the snapshot ID; its RestorePlanner brings the rights, the Divi features and
* the plan. Areas without a planner are not restorable yet.
*/
final class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures
{
private const ID = '/^(colors|variables|presets|theme_options)-\d{8}T\d{6}Z-[0-9a-f]{6}$/D';
/** @var array<string, RestorePlanner> 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<string, mixed> $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<string, mixed> $input
* @return list<RestorePlanner> 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<string, mixed> $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 <Modellname> <noreply@anthropic.com>"
```
#### 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\ColorArea;
use LIT\MCP\Integrations\Divi\Design\ColorBook;
use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner;
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ColorRestorePlannerTest extends TestCase
{
public const SNAPSHOT_ID = 'colors-20261016T100000Z-aaaaaa';
/** One stored color in ColorBook::data() form. */
public static function color(string $label, string $color): array
{
return ['color' => $color, 'folder' => '', 'label' => $label, 'lastUpdated' => '2026-10-01T10:00:00.000Z',
'status' => 'active', 'usedInPosts' => []];
}
/** @return array<string, array<string, mixed>> 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<string, array<string, mixed>> 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Design\VariableArea;
use LIT\MCP\Integrations\Divi\Design\VariableBook;
use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class VariableRestorePlannerTest extends TestCase
{
public const SNAPSHOT_ID = 'variables-20261016T100000Z-aaaaaa';
/** One stored variable of type numbers. */
public static function number(string $id, string $label, string $value): array
{
return ['id' => $id, 'label' => $label, 'value' => $value, 'order' => '1', 'status' => 'active',
'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'numbers'];
}
/** @return array<string, array<string, array<string, mixed>>> 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<string, array<string, array<string, mixed>>> 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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Restores a snapshot of the global colors (ruling V10): the snapshot replaces every stored color
* (set_global_colors($data, false), ruling V4) – colors created after the snapshot are removed. ColorDiff knows no
* removal, so the planner adds those rows ("removed") itself and warns that pages keep their references.
*/
final class ColorRestorePlanner implements RestorePlanner
{
private const REMOVED = 'Farben, die es im Snapshot nicht gibt, entfallen (%s); Seiten und Presets, die sie '
. 'verwenden, behalten den Verweis ohne Wert.';
public function __construct(private DesignSystemWriter $writer, private GlobalColorsAdapter $colors)
{
}
public function area(): string
{
return 'colors';
}
public function designArea(): DesignArea
{
return new ColorArea($this->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
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Restores a snapshot of the global variables (rulings V1b, V10): every variable of the snapshot comes back as it was;
* Divi refuses to drop a stored variable, so variables created after the snapshot stay – archived
* (VariableBook::restoredFrom()) – and the dry run names them as a warning. Nothing is removed.
*/
final class VariableRestorePlanner implements RestorePlanner
{
private const KEPT = 'Variable „%s“ gibt es im Snapshot nicht; sie bleibt archiviert erhalten (Divi löscht keine '
. 'Variablen).';
public function __construct(private DesignSystemWriter $writer, private GlobalVariablesAdapter $variables)
{
}
public function area(): string
{
return 'variables';
}
public function designArea(): DesignArea
{
return new VariableArea($this->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 <Modellname> <noreply@anthropic.com>"
```
#### 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<string, array<string, mixed>> $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<string, array<string, array<string, mixed>>> $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<array-key, mixed> $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 <Modellname> <noreply@anthropic.com>"
```
### 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/<name>`, nicht `lit-<name>`), 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 <Modellname> <noreply@anthropic.com>"
```
### 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 <run> …" 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 <tool> <input JSON>: 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 <Modellname> <noreply@anthropic.com>"
```
### 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“.