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

458 KiB
Raw Permalink Blame History

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.

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

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

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

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

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

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

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

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

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:

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:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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.

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):

use LIT\MCP\Integrations\Divi\Compose\VariableRef;

und direkt nach storedWithMarkup() einfügen:

    /**
     * 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;):
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
  1. Nach private const ALPHABET = …; einfügen:

    /** The keys of a preset that hold its settings (formats §3). */
    private const SETTINGS = ['attrs', 'styleAttrs', 'renderAttrs'];
  1. Direkt nach der Methode withoutNestedRef() einfügen:
    /**
     * 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);
    }
  1. Direkt vor der Methode hasMarkup() einfügen:
    /**
     * @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.

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

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

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.

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

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

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.

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):

use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Design\ColorValue;

und vor der schließenden } der Klasse einfügen:

    /** 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

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.

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

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

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.

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

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

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.

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:

    /** 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;):
use LIT\MCP\Integrations\Divi\Library\UsageReport;
  1. Klassen-Docblock, letzter Satz vorher:
 * 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:

 * 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.
 */
  1. Nach der Konstante NEW_ENTRY einfügen:

    /** 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.';
  1. Am Ende von plan() vorher:
        $touched = array_values(array_unique($touched));

        return new DesignPlan(
            $book->data(),
            $diff,
            VariableDiff::summary($diff),
            $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched))
        );
    }

nachher:

        $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)
        );
    }
  1. Nach der Methode newId() (vor der schließenden } der Klasse) einfügen:

    /**
     * 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.

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

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

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

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:

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

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:

    /** 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:

    /**
     * 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

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:

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:

use LIT\MCP\Abilities\WriteFailed;

nachher:

use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\Block;

Importe (nach DiviDesignDetachAbility) – vorher:

use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility;

nachher:

use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;

Importe (nach PresetUsageQuery) – vorher:

use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;

nachher:

use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery;

Importe (nach SnapshotStore) – vorher:

use LIT\MCP\Integrations\Divi\Design\SnapshotStore;

nachher:

use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\VariableDetachRewrite;

Konstanten (Zeile 34) – vorher:

    private const LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext'];

nachher:

    private const LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext'];

    private const RADIUS = 'gvid-radius0001';

    private const CLAIM = 'gvid-claim00001';

Hilfe ability() (Zeilen 50–58) – vorher:

        ?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:

        ?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:

    private static function untouched(): PageRewriter&MockInterface

nachher:

    /** 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:

    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:

    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:

/**
 * 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:

/**
 * 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:

    private const VARIABLES = 'Variablen folgen mit Etappe 4c.';

    private const DELETE_HINT

nachher:

    private const DELETE_HINT

neue Konstanten nach DEFAULT_NOTE (Zeilen 39–40) – vorher:

    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:

    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:

        private DesignSystemWriter $writer,
        private DesignChange $change,

nachher:

        private DesignSystemWriter $writer,
        private GlobalVariablesAdapter $variables,
        private DesignChange $change,

label() (Zeile 60) – vorher:

        return 'Divi-Preset-Verweise entfernen';

nachher:

        return 'Divi-Preset- und Variablen-Verweise entfernen';

description() (Zeilen 65–76) – vorher:

        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:

        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:

'variable' => ['type' => 'string', 'description' => self::VARIABLES],

nachher:

'variable' => [
                    'type' => 'string',
                    'description' => 'Name oder ID einer globalen Variablen (statt preset). ' . self::NO_COLORS,
                ],

diviFeatures() – Docblock und Anfang (Zeilen 118–124) – vorher:

    /**
     * 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:

    /**
     * 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:

            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:

            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:

    /**
     * The preset to detach, resolved on the live inventory.

nachher:

    /**
     * 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:

use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility;

nachher:

use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;

Konstruktoraufruf (Zeilen 275–281) – vorher:

            new DiviDesignDetachAbility(
                $this->designWriter,
                $this->designChange(),

nachher:

            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:

            'library-list' => ['lit/library-list', [], ['library-read', 'roles']],

nachher:

            '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:

UNSUPPORTED=$(ability lit/divi-design-detach '{"variable": "Irgendeine Variable"}')
[ "$(error_code "$UNSUPPORTED")" = "unsupported" ] || fail "detach of a variable: $UNSUPPORTED"

nachher:

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:

pass "group preset is in use, variable detach unsupported, stale design_rev refused without writing"

nachher:

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:

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

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

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

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

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:

use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;

nachher:

use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner;

testRestoresWithDryRunAndASnapshotOfTheCurrentState() (Zeile 40) – vorher:

        $ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, new FakeChangeLog(8)), $snapshots);

nachher:

        $ability = new DiviDesignRestoreAbility(
            new DesignChange($snapshots, new FakeChangeLog(8)),
            $snapshots,
            new PresetRestorePlanner($writer)
        );

restoring() (Zeilen 65–70) – vorher:

        return new DiviDesignRestoreAbility(
            $writer,
            new DesignChange($snapshots, FakeChangeLog::forbidden()),
            $snapshots,
            static fn (): bool => $unfiltered
        );

nachher:

        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:

        $ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots);

nachher:

        $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:

use LIT\MCP\Integrations\Divi\Design\PresetComposer;

nachher:

use LIT\MCP\Integrations\Divi\Design\PresetComposer;
use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner;

Konstruktoraufruf (Zeilen 264–269) – vorher:

            new DiviDesignRestoreAbility(
                $this->designWriter,
                $this->designChange(),
                new SnapshotStore(),
                DesignRights::unfilteredHtml()
            ),

nachher:

            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:

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

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

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

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

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:

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:

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:

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:

    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:

    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:

        'lit/divi-design-restore' => ['design-read', 'preset-write'],

nachher:

        'lit/divi-design-restore' => ['design-read', 'preset-write', 'color-write', 'variable-write'],

inputCases(), vor 'library-list' – vorher:

            'library-list' => ['lit/library-list', [], ['library-read', 'roles']],

nachher:

            '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:

 * the plan. Areas without a planner are not restorable yet.

nachher:

 * the plan. Areas without a planner (theme_options until Etappe 4d) are not restorable yet.

description() – vorher:

            . 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert.';

nachher:

            . '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:

                'Farben und Variablen folgen mit Etappe 4c, Theme-Optionen mit 4d.'

nachher:

                '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:

use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;

nachher:

use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;

Import (vor GlobalVariablesAdapter, in Task 8 ergänzt) – vorher:

use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;

nachher:

use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;

Import (nach SnapshotStore) – vorher:

use LIT\MCP\Integrations\Divi\Design\SnapshotStore;

nachher:

use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner;

Konstruktoraufruf (aus Task 9 Step 6) – vorher:

                new PresetRestorePlanner($this->designWriter, DesignRights::unfilteredHtml())
            ),

nachher:

                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:

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()):

        '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:

        '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:

            '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):

use LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility;
use LIT\MCP\Integrations\Divi\Design\DiviVariablesSetAbility;

Eigenschaften neben private DesignSystemWriter $designWriter;:

    private GlobalColorsAdapter $colorsAdapter;

    private GlobalVariablesAdapter $variablesAdapter;

Im Konstruktor nach $this->designWriter = new DesignSystemWriter();:

        $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):

                new ColorRestorePlanner($this->designWriter, new GlobalColorsAdapter()),
                new VariableRestorePlanner($this->designWriter, new GlobalVariablesAdapter())

nachher:

                new ColorRestorePlanner($this->designWriter, $this->colorsAdapter),
                new VariableRestorePlanner($this->designWriter, $this->variablesAdapter)

Vorher (Detach, nach Task 8):

                $this->designWriter,
                new GlobalVariablesAdapter(),
                $this->designChange(),

nachher:

                $this->designWriter,
                $this->variablesAdapter,
                $this->designChange(),

Nach dem Eintrag new DiviDesignDetachAbility( … ), (letzter Eintrag der Liste) anfügen:

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

#!/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:

## 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:

- `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
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):

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“.