feat: admin settings page UI/UX pass with WordPress-native patterns

- Header: h1 + logo in a wrapping flex row, hr.wp-header-end before the tabs,
  aria-current on the active tab; own CSS/JS enqueued only on this screen.
- Verbindung: ordered "So verbindest du Claude" steps, copy button (core
  clipboard.js + wp.a11y.speak), Site-Health-style status list, German details.
- Zugriff: roles as form-table fieldset, connections with site date format,
  empty-state row, confirm on revoke; ConnectionUsage records last use via
  WP_Application_Passwords::record_application_password_usage (once a day).
- Tools: plain-language legend, counter as inline notice, groups as core
  postboxes (postbox.js toggles, per-user closed state, summary when closed),
  fixed column widths with new "Art" column, submit above and below.
- Protokoll: site-timezone times with UTC tooltip, result icons, filters with
  labels in the list table nav, retention link to Limits.
- Limits: short labels with units and descriptions; snapshot count hidden but
  preserved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 16:50:58 +02:00
co-authored by Claude Opus 5.5
parent 5965e32acb
commit 843f45aeec
22 changed files with 1535 additions and 242 deletions
+133 -21
View File
@@ -15,16 +15,17 @@ use Mockery;
final class ToolsTabTest extends TestCase
{
/** @param list<array{string, bool, bool}> $abilities name, isDirect, readonly */
/** @param list<array{0: string, 1: bool, 2: bool, 3?: bool}> $abilities name, isDirect, readonly, destructive */
private function integration(string $id, array $abilities, bool $available = true): Integration
{
$mocks = [];
foreach ($abilities as [$name, $direct, $readonly]) {
foreach ($abilities as $spec) {
[$name, $direct, $readonly] = $spec;
$ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn($name);
$ability->allows('label')->andReturn('Label ' . $name);
$ability->allows('isDirect')->andReturn($direct);
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
$ability->allows('annotations')->andReturn(['readonly' => $readonly, 'destructive' => $spec[3] ?? false]);
$mocks[] = $ability;
}
$integration = Mockery::mock(Integration::class);
@@ -45,13 +46,24 @@ final class ToolsTabTest extends TestCase
);
}
/** @param array<string, string|bool> $option */
private function render(array $option, ?IntegrationRegistry $registry = null): string
/**
* @param array<string, string|bool> $option
* @param mixed $closedBoxes the user's saved closed postboxes (false = never saved)
*/
private function render(array $option, ?IntegrationRegistry $registry = null, mixed $closedBoxes = false): string
{
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
Functions\when('settings_fields')->justReturn(null);
Functions\when('submit_button')->justReturn(null);
Functions\when('submit_button')->alias(static function ($text = null, $type = 'primary', $name = 'submit'): void {
echo '<button name="' . $name . '">Speichern</button>';
});
Functions\when('wp_nonce_field')->alias(static function (string $action, string $name): string {
return '<input type="hidden" id="' . $name . '" name="' . $name . '" value="nonce-' . $action . '">';
});
Functions\when('get_user_option')->alias(
static fn (string $key) => $key === 'closedpostboxes_settings_page_lit-mcp' ? $closedBoxes : false
);
Functions\when('selected')->alias(
static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' selected="selected"' : ''
);
@@ -76,21 +88,22 @@ final class ToolsTabTest extends TestCase
/** The table row of one tool (from its <tr> to </tr>). */
private function row(string $html, string $name): string
{
$at = strpos($html, '<code>' . $name . '</code>');
$at = strpos($html, '<span class="lit-mcp-tool-name">' . $name . '</span>');
self::assertNotFalse($at, 'No row for ' . $name);
$start = (int) strrpos(substr($html, 0, $at), '<tr');
return substr($html, $start, (int) strpos($html, '</tr>', $at) - $start);
}
/** The <details> block of one group. */
/** The postbox of one group (from its opening div to the next group or the end). */
private function group(string $html, string $label): string
{
$at = strpos($html, '<summary>' . $label);
$at = strpos($html, '<h2 class="hndle">' . $label . ' ');
self::assertNotFalse($at, 'No group ' . $label);
$start = (int) strrpos(substr($html, 0, $at), '<details');
$start = (int) strrpos(substr($html, 0, $at), '<div id="lit-mcp-group-');
$end = strpos($html, '<div id="lit-mcp-group-', $at);
return substr($html, $start, (int) strpos($html, '</details>', $at) - $start);
return substr($html, $start, ($end === false ? strlen($html) : $end) - $start);
}
public function testRegistersTheExposureOptionWithAGroupAwareSanitizer(): void
@@ -125,10 +138,10 @@ final class ToolsTabTest extends TestCase
{
$html = $this->render(['lit/divi-update' => 'disabled']);
self::assertSame(3, substr_count($html, '<details open'));
self::assertStringContainsString('<summary>Grundlagen (2 Tools)', $html);
self::assertStringContainsString('<summary>Medien (2 Tools)', $html);
self::assertStringContainsString('<summary>Divi (2 Tools)', $html);
self::assertSame(3, substr_count($html, 'class="postbox lit-mcp-group'));
self::assertStringContainsString('<h2 class="hndle">Grundlagen <span class="count">(2 Tools)</span>', $html);
self::assertStringContainsString('<h2 class="hndle">Medien <span class="count">(2 Tools)</span>', $html);
self::assertStringContainsString('<h2 class="hndle">Divi <span class="count">(2 Tools)</span>', $html);
self::assertStringNotContainsString('pods', $html);
self::assertStringContainsString('lit/divi-outline', $this->group($html, 'Divi'));
self::assertStringNotContainsString('lit/guide', $this->group($html, 'Divi'));
@@ -139,7 +152,10 @@ final class ToolsTabTest extends TestCase
self::assertStringContainsString('value="' . $mode . '"', $groupSelect);
}
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1"', $html);
self::assertStringContainsString('nur lesen (schreibende Tools dieser Gruppe aus)', $html);
self::assertStringContainsString('<label for="lit-mcp-group-mode-divi">Ganze Gruppe</label>', $html);
self::assertStringContainsString('id="lit-mcp-group-mode-divi"', $groupSelect);
self::assertStringContainsString('Nur lesen', $this->group($html, 'Divi'));
self::assertStringContainsString('<option value="disabled">aus</option>', $groupSelect);
$guide = $this->select($html, 'lit/guide');
self::assertStringNotContainsString('value="disabled"', $guide);
@@ -182,14 +198,14 @@ final class ToolsTabTest extends TestCase
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1" checked="checked"', $html);
$update = $this->row($html, 'lit/divi-update');
self::assertStringContainsString('<select name="lit_mcp_exposure[lit/divi-update]" disabled', $update);
self::assertStringContainsString('durch ‚nur lesen‘ aus', $update);
self::assertStringContainsString('Durch „Nur lesen“ aus', $update);
self::assertStringContainsString(
'<input type="hidden" name="lit_mcp_exposure[lit/divi-update]" value="indirect">',
$update
);
$outline = $this->row($html, 'lit/divi-outline');
self::assertStringNotContainsString('disabled>', $outline);
self::assertStringNotContainsString('nur lesen', $outline);
self::assertStringNotContainsString('Nur lesen', $outline);
}
public function testCountsDirectToolsUnderTheSavedSettings(): void
@@ -199,7 +215,8 @@ final class ToolsTabTest extends TestCase
// guide, site-status, media-preview (divi disabled, media-upload indirect by default).
self::assertStringContainsString('Direkt sichtbar: 3 Tools', $html);
self::assertStringNotContainsString('Kontext', $html);
self::assertLessThan(strpos($html, '<details'), strpos($html, 'Direkt sichtbar'));
self::assertLessThan(strpos($html, 'class="postbox'), strpos($html, 'Direkt sichtbar'));
self::assertStringContainsString('<div class="notice notice-info inline"><p><strong>Direkt sichtbar: 3 Tools', $html);
}
public function testWarnsWhenMoreThan25ToolsAreDirect(): void
@@ -215,7 +232,9 @@ final class ToolsTabTest extends TestCase
self::assertStringContainsString('Direkt sichtbar: 26 Tools', $warned);
self::assertStringContainsString('Kontext', $warned);
self::assertStringContainsString('<summary>WordPress (26 Tools)', $warned);
self::assertStringContainsString('<div class="notice notice-warning inline">', $warned);
self::assertStringNotContainsString('notice-warning', $notWarned);
self::assertStringContainsString('<h2 class="hndle">WordPress <span class="count">(26 Tools)</span>', $warned);
self::assertStringContainsString('Direkt sichtbar: 25 Tools', $notWarned);
self::assertStringNotContainsString('Kontext', $notWarned);
}
@@ -226,8 +245,101 @@ final class ToolsTabTest extends TestCase
$html = $this->render([], $registry);
self::assertStringContainsString('<summary>shop (1 Tool)', $html);
self::assertStringContainsString('<h2 class="hndle">shop <span class="count">(1 Tool)</span>', $html);
self::assertStringNotContainsString('<b>', $html);
self::assertStringContainsString('lit/shop-&lt;b&gt;', $html);
}
public function testArtColumnSaysWhetherAToolReadsWritesOrDeletes(): void
{
$registry = new IntegrationRegistry($this->integration('media', [
['lit/media-preview', true, true],
['lit/media-upload', false, false],
['lit/media-delete', false, false, true],
]));
$html = $this->render([], $registry);
self::assertStringContainsString('<th scope="col" class="column-kind">Art</th>', $html);
self::assertStringContainsString('liest', $this->row($html, 'lit/media-preview'));
self::assertStringContainsString('schreibt', $this->row($html, 'lit/media-upload'));
self::assertStringContainsString('löscht', $this->row($html, 'lit/media-delete'));
self::assertStringNotContainsString('schreibt', $this->row($html, 'lit/media-delete'));
}
public function testToolCellShowsLabelAndTheNameAsMutedTextWithALabelledSelect(): void
{
$html = $this->render([]);
$row = $this->row($html, 'lit/guide');
self::assertStringContainsString('<strong>Label lit/guide</strong>', $row);
self::assertStringNotContainsString('<code>', $row);
self::assertMatchesRegularExpression('#<label class="screen-reader-text" for="([a-z0-9-]+)">[^<]*Label lit/guide</label>#', $row);
preg_match('#for="([a-z0-9-]+)"#', $row, $m);
self::assertStringContainsString('id="' . $m[1] . '"', $this->select($html, 'lit/guide'));
}
public function testIntroUsesPlainLanguageAndMentionsDiviOnlyWhenDiviIsAvailable(): void
{
$withDivi = $this->render([]);
$withoutDivi = $this->render([], new IntegrationRegistry($this->integration('core', [['lit/guide', true, true]])));
self::assertStringContainsString('Claude sieht das Tool sofort.', $withDivi);
self::assertStringContainsString('Claude findet es bei Bedarf über die Tool-Suche.', $withDivi);
self::assertStringNotContainsString('Meta-Tools', $withDivi);
self::assertStringNotContainsString('discover', $withDivi);
self::assertStringContainsString('Divi-Code-Modul', $withDivi);
self::assertStringNotContainsString('Divi-Code-Modul', $withoutDivi);
}
public function testHasASubmitButtonAboveAndBelowTheGroups(): void
{
$html = $this->render([]);
self::assertSame(2, substr_count($html, 'Speichern</button>'));
self::assertLessThan(strpos($html, 'class="postbox'), strpos($html, 'name="submit-top"'));
self::assertGreaterThan(strrpos($html, 'class="postbox'), strrpos($html, 'name="submit"'));
}
public function testGroupsAreWordPressPostboxesWithAnAccessibleToggle(): void
{
$html = $this->render([]);
$divi = $this->group($html, 'Divi');
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group">', $divi);
self::assertStringContainsString('<div class="postbox-header">', $divi);
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="true">', $divi);
self::assertStringContainsString('<span class="screen-reader-text">Gruppe ein-/ausblenden: Divi</span>', $divi);
self::assertStringContainsString('<span class="toggle-indicator" aria-hidden="true"></span>', $divi);
self::assertStringContainsString('<div class="inside">', $divi);
// The group controls live in the header but outside the clickable heading.
$heading = substr($divi, (int) strpos($divi, '<h2'), (int) strpos($divi, '</h2>') - (int) strpos($divi, '<h2'));
self::assertStringNotContainsString('<select', $heading);
self::assertStringNotContainsString('<input', $heading);
self::assertLessThan(strpos($divi, 'class="inside"'), strpos($divi, 'lit-mcp-group-mode-divi'));
// Nonce for WordPress' own "closed-postboxes" state saving.
self::assertStringContainsString('id="closedpostboxesnonce" name="closedpostboxesnonce" value="nonce-closedpostboxes"', $html);
}
public function testHeaderSummaryCountsEffectiveModes(): void
{
$html = $this->render(['@readonly:divi' => true]);
self::assertStringContainsString('<span class="lit-mcp-group-summary">2 direkt · 0 indirekt · 0 aus</span>', $this->group($html, 'Grundlagen'));
self::assertStringContainsString('<span class="lit-mcp-group-summary">1 direkt · 1 indirekt · 0 aus</span>', $this->group($html, 'Medien'));
self::assertStringContainsString('<span class="lit-mcp-group-summary">nur lesen · 1 direkt · 0 indirekt · 1 aus</span>', $this->group($html, 'Divi'));
}
public function testAGroupWithAllToolsOffStartsClosedUnlessTheUserSavedOtherwise(): void
{
$default = $this->render(['@group:divi' => 'disabled']);
$saved = $this->render(['@group:divi' => 'disabled'], null, ['lit-mcp-group-media']);
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group closed">', $default);
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="false">', $this->group($default, 'Divi'));
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group">', $default);
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group">', $saved);
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group closed">', $saved);
}
}