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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 14:07:54 +02:00
co-authored by Claude Sonnet 5.5
parent 337b87b572
commit 2fd03f3900
2 changed files with 259 additions and 11 deletions
@@ -9,7 +9,9 @@ use InvalidArgumentException;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors; 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\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures; use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error; use WP_Error;
@@ -20,8 +22,10 @@ defined('ABSPATH') || exit;
* lit/divi-colors-set (spec §7.1, rulings V3–V5, V8): creates and changes global colors through the change pipeline * 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 * (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 * 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 * their value, never to a reference. {{color:…}} with relative is resolved against the planned inventory – also
* (ReferenceUsageQuery). * 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 final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
{ {
@@ -39,6 +43,17 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
private const NEW_ENTRY = ['color' => '', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active', private const NEW_ENTRY = ['color' => '', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active',
'usedInPosts' => []]; '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). */ /** @param (Closure(int): string)|null $random Random characters for new IDs (ColorBook::newId(); tests). */
public function __construct( public function __construct(
private DesignSystemWriter $writer, private DesignSystemWriter $writer,
@@ -172,6 +187,13 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
} }
$book = $this->apply($book, $item, $field, $errors); $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 !== []) { if ($errors !== []) {
return Errors::invalidInput('Ungültige Farb-Angaben; nichts wurde gespeichert.', $errors); return Errors::invalidInput('Ungültige Farb-Angaben; nichts wurde gespeichert.', $errors);
} }
@@ -184,12 +206,14 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
} }
} }
$touched = array_values(array_unique($touched)); $touched = array_values(array_unique($touched));
$report = $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched));
return new DesignPlan( return new DesignPlan(
$book->data(), $book->data(),
$diff, $diff,
ColorDiff::summary($diff), ColorDiff::summary($diff),
$touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched)) $report,
$this->warnings($book, $diff, $report)
); );
} }
@@ -225,12 +249,15 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
'label' => self::cleanLabel($book, $item['label'], $id), 'label' => self::cleanLabel($book, $item['label'], $id),
'folder' => self::folder($item['folder']), 'folder' => self::folder($item['folder']),
'status' => self::status($item['status']), 'status' => self::status($item['status']),
default => self::value($item), default => self::value($book, $item, $main),
}; };
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$errors[$field . '.' . $key] = $e->getMessage(); $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))) { if (count($errors) > $known || ($id !== null && $entry === $book->entry($id))) {
return $book; return $book;
} }
@@ -313,16 +340,24 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
} }
/** /**
* 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 * @param array<array-key, mixed> $item
* @throws InvalidArgumentException * @throws InvalidArgumentException Code RELATIVE when the problem is in relative.
*/ */
private static function value(array $item): string private static function value(ColorBook $book, array $item, bool $main): string
{ {
$color = $item['color']; $color = $item['color'];
if (! is_string($color)) { if (! is_string($color)) {
throw new InvalidArgumentException('Farbwert als Text erwartet'); throw new InvalidArgumentException('Farbwert als Text erwartet');
} }
$color = trim($color); $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); $problem = ColorValue::check($color);
if ($problem !== null) { if ($problem !== null) {
throw new InvalidArgumentException($problem); throw new InvalidArgumentException($problem);
@@ -331,6 +366,28 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
return $color; 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 private function newId(ColorBook $book): string
{ {
do { do {
@@ -339,4 +396,57 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
return $id; 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 ? ', …' : '');
}
} }
@@ -7,8 +7,10 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Annotations; use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Design\ColorBook; use LIT\MCP\Integrations\Divi\Design\ColorBook;
use LIT\MCP\Integrations\Divi\Design\ColorDiff; use LIT\MCP\Integrations\Divi\Design\ColorDiff;
use LIT\MCP\Integrations\Divi\Design\ColorValue;
use LIT\MCP\Integrations\Divi\Design\DesignChange; use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility; use LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility;
@@ -300,4 +302,140 @@ final class DiviColorsSetAbilityTest extends TestCase
} }
self::assertSame([false, true], $calls, 'the merge failed, the snapshot replaced all colors'); self::assertSame([false, true], $calls, 'the merge failed, the snapshot replaced all colors');
} }
/** 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']);
}
/** Review note: ColorBook::with() replaces, so a changed color keeps usedInPosts and its other fields. */
public function testChangingAColorKeepsItsUsedInPostsAndOtherFields(): void
{
$this->stored['gcid-holz000001']['usedInPosts'] = [7, 9];
$ability = $this->ability();
$input = ['items' => [['id' => 'Holz dunkel', 'color' => '#000000']]];
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->allows('save')->andReturn('colors-1');
$snapshots->allows('attachLog');
$this->ability(null, $snapshots)->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
$saved = $this->saves[0][0]['gcid-holz000001'];
self::assertSame([7, 9], $saved['usedInPosts']);
self::assertSame(['Holz dunkel', '#000000'], [$saved['label'], $saved['color']]);
}
} }