+
{items.map((plugin) => {
const rowUpdate: PluginRowUpdateState = {
latest: updates.latestById.get(plugin.id),
diff --git a/apps/web/e2e/tests/plugins/plugins.spec.ts b/apps/web/e2e/tests/plugins/plugins.spec.ts
index fde440b087..ca9896dc4b 100644
--- a/apps/web/e2e/tests/plugins/plugins.spec.ts
+++ b/apps/web/e2e/tests/plugins/plugins.spec.ts
@@ -62,6 +62,24 @@ import {
const NAV_ITEM_ID = "e2e-hello";
const PLUGIN_ROUTE = "/plugins/e2e-hello";
+// @covers AC-UI-CONTROL-SIZING-001.1, AC-UI-CONTROL-SIZING-001.3, AC-UI-CONTROL-SIZING-001.8
+test("plugin settings toolbar stays compact on desktop", async ({ testPage }) => {
+ await testPage.goto("/settings/plugins");
+ const install = testPage.getByTestId("install-plugin-trigger");
+ const sync = testPage.getByTestId("plugins-sync-button");
+ const check = testPage.getByTestId("plugins-check-updates-button");
+ await expect(install).toBeVisible();
+ const boxes = await Promise.all([install.boundingBox(), sync.boundingBox(), check.boundingBox()]);
+ for (const box of boxes) {
+ expect(box).not.toBeNull();
+ expect(box!.height).toBeCloseTo(28, 0);
+ expect(box!.y).toBeCloseTo(boxes[0]!.y, 0);
+ }
+ await install.focus();
+ await testPage.keyboard.press("Enter");
+ await expect(testPage.getByTestId("install-plugin-dialog")).toBeVisible();
+});
+
/** Every deliveries.jsonl `event_type` recorded so far, read straight off
* disk from the plugin's real KANDEV_PLUGIN_DATA_DIR (no in-process mock —
* this is the fixture Go binary's own gRPC OnEvent handler writing to its
diff --git a/apps/web/e2e/tests/settings/mobile-plugin-updates.spec.ts b/apps/web/e2e/tests/settings/mobile-plugin-updates.spec.ts
index 0a677fd966..9a123e6987 100644
--- a/apps/web/e2e/tests/settings/mobile-plugin-updates.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-plugin-updates.spec.ts
@@ -7,6 +7,7 @@ import {
uploadPackage,
} from "../plugins/plugin-test-helpers";
+// @covers AC-UI-CONTROL-SIZING-001.3, AC-UI-CONTROL-SIZING-001.4, AC-UI-CONTROL-SIZING-001.5, AC-UI-CONTROL-SIZING-001.8
test.describe("Mobile plugin updates", () => {
test.afterEach(async ({ apiClient }) => {
await uninstallPluginFixture(apiClient);
@@ -14,6 +15,7 @@ test.describe("Mobile plugin updates", () => {
test("checks and updates marketplace state from the mobile settings flow", async ({
testPage,
+ prCapture,
}) => {
await openInstallDialog(testPage);
await uploadPackage(testPage, PACKAGE_PATH);
@@ -81,6 +83,19 @@ test.describe("Mobile plugin updates", () => {
expect(buttonBox).not.toBeNull();
expect(buttonBox!.height).toBeGreaterThanOrEqual(44);
+ await prCapture.screenshot("plugin-settings", { caption: "Plugin settings on a phone" });
+ const syncBox = await testPage.getByTestId("plugins-sync-button").boundingBox();
+ const installBox = await testPage.getByTestId("install-plugin-trigger").boundingBox();
+ const headingBox = await testPage
+ .getByRole("heading", { name: "Installed plugins", exact: true })
+ .boundingBox();
+ expect(syncBox).not.toBeNull();
+ expect(installBox).not.toBeNull();
+ expect(headingBox).not.toBeNull();
+ expect(syncBox!.y).toBeCloseTo(buttonBox!.y, 0);
+ expect(installBox!.y).toBeLessThan(headingBox!.y + headingBox!.height);
+ expect(installBox!.width).toBeLessThan(testPage.viewportSize()!.width / 2);
+
await checkButton.tap();
await expect.poll(() => refreshRequests).toBe(1);
@@ -106,5 +121,27 @@ test.describe("Mobile plugin updates", () => {
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
+
+ for (const width of [320, 767, 768]) {
+ await testPage.setViewportSize({ width, height: 851 });
+ const sync = testPage.getByTestId("plugins-sync-button");
+ const install = testPage.getByTestId("install-plugin-trigger");
+ await expect(sync).toBeVisible();
+ await expect(install).toBeVisible();
+ const [syncBounds, checkBounds, installBounds] = await Promise.all([
+ sync.boundingBox(),
+ checkButton.boundingBox(),
+ install.boundingBox(),
+ ]);
+ expect(syncBounds!.y).toBeCloseTo(checkBounds!.y, 0);
+ for (const box of [syncBounds!, checkBounds!, installBounds!]) {
+ expect(box.height).toBeGreaterThanOrEqual(44);
+ expect(box.x).toBeGreaterThanOrEqual(0);
+ expect(box.x + box.width).toBeLessThanOrEqual(width);
+ }
+ expect(
+ await testPage.evaluate(() => document.documentElement.scrollWidth),
+ ).toBeLessThanOrEqual(width);
+ }
});
});
diff --git a/docs/plans/plugin-settings-layout/plan.md b/docs/plans/plugin-settings-layout/plan.md
new file mode 100644
index 0000000000..da760b066f
--- /dev/null
+++ b/docs/plans/plugin-settings-layout/plan.md
@@ -0,0 +1,84 @@
+---
+created: 2026-09-24
+status: done
+requirements:
+ - REQ-UI-CONTROL-SIZING-001
+system_design:
+ - ../../specs/ui/system-design/control-sizing.md
+legacy_specs: []
+---
+
+# Implementation Plan: Plugin settings layout
+
+## Overview
+
+Flatten the host plugin settings page and compact its actions while preserving
+the existing plugin lifecycle and permission behavior. This is a presentation
+refactor under the existing [control sizing contract](../../specs/ui/requirements/control-sizing.md).
+The user authorized implementation, screenshots, and PR delivery in one session.
+
+## Scope and technical approach
+
+Use the unframed SettingsPageTemplate variant, a compact installed-list toolbar,
+an inline SettingsRow for automatic updates, and divided PluginRow entries.
+Keep the current hooks, translated copy, direct row navigation, errors, and
+confirmation surfaces. No backend, persistence, plugin SDK, or release changes.
+
+## ASCII UI preview
+
+UI-01: Settings > Plugins > Installed, populated state. Current phone layout
+stacks three full-width buttons and nests the list and preference in cards.
+
+```text
+Phone Desktop
+[Installed] [Browse] [Installed] [Browse]
+Installed plugins [Install plugin] Installed plugins [Install plugin] Sync Check for updates
+Sync Check for updates Automatic updates description [switch]
+Automatic updates [switch] Last-check status
+Description ---------------------------------------------------------
+Last-check status Plugin name / status / version / actions
+-------------------------------- Description and categories
+Plugin name / status / version Auto-update [switch]
+Actions ---------------------------------------------------------
+Description and categories Next plugin
+Auto-update [switch]
+--------------------------------
+Next plugin
+```
+
+Structural requirements: retain labels and touch targets while removing nested
+frames. The existing settings page owns scrolling and safe areas. Use shared
+SettingsRow and control-sizing primitives; retain the existing phone uninstall
+drawer. Spacing in the preview is illustrative. Empty/loading/error and pending
+actions retain their current content and behavior. Long labels may wrap the
+toolbar without causing horizontal scrolling.
+
+## Tests
+
+Existing page and PluginRow unit coverage protects actions and permissions.
+Mobile Playwright checks aligned secondary actions, compact Install placement,
+44px controls, responsive boundaries, and reachable row actions. Desktop
+Playwright checks 28px toolbar controls and the installation lifecycle.
+These cover AC-UI-CONTROL-SIZING-001.1, .3, .4, .5, .6, .7, and .8.
+
+## Work orders
+
+- [x] [Task 01: Flatten plugin settings](task-01-flatten-settings.md)
+
+## Verification results
+
+Completed on 2026-09-24. The mobile regression first failed with secondary
+actions separated vertically by 52px. After implementation: 2 mobile and 17
+desktop Playwright scenarios passed, as did 54 unit tests, typecheck, focused
+ESLint, localization, public-doc and spec validators, and diff checks. See the
+[work-order results](task-01-flatten-settings.md#results). Four fresh PR screenshots
+cover Installed and Browse on phone and desktop with synthetic records; the
+disposable capture test passed and was removed afterward.
+The public plugin guide uses the new desktop Installed screenshot and no longer
+repeats an illustration of the previous layout.
+
+## Risks
+
+Row overlay links must not intercept inline actions. Removing padding must not
+clip long plugin IDs or touch targets. All plugin controls retain their existing
+permission checks.
diff --git a/docs/plans/plugin-settings-layout/task-01-flatten-settings.md b/docs/plans/plugin-settings-layout/task-01-flatten-settings.md
new file mode 100644
index 0000000000..13e29015d3
--- /dev/null
+++ b/docs/plans/plugin-settings-layout/task-01-flatten-settings.md
@@ -0,0 +1,118 @@
+---
+id: "01-flatten-settings"
+title: "Flatten plugin settings"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-UI-CONTROL-SIZING-001
+acceptance_criteria:
+ - AC-UI-CONTROL-SIZING-001.1
+ - AC-UI-CONTROL-SIZING-001.3
+ - AC-UI-CONTROL-SIZING-001.4
+ - AC-UI-CONTROL-SIZING-001.5
+ - AC-UI-CONTROL-SIZING-001.6
+ - AC-UI-CONTROL-SIZING-001.7
+ - AC-UI-CONTROL-SIZING-001.8
+system_design:
+ - ../../specs/ui/system-design/control-sizing.md
+---
+
+# Task 01: Flatten plugin settings
+
+## Summary and scope
+
+Remove redundant frames, place Install beside the heading, keep secondary
+actions compact, and render installed plugins as a divided list. Own the host
+settings layout, focused browser regressions, documentation, and PR screenshots.
+Preserve every existing plugin action and permission boundary. API, SDK,
+persistence, and plugin implementation changes are out of scope.
+
+## Acceptance
+
+- Install sits beside the heading; Sync and update checks share a compact row
+ on phones and join the heading on wider screens. Controls stay touch-safe.
+- Automatic updates and plugin rows are no longer enclosed in nested cards;
+ names, versions, descriptions, state, and all actions remain available.
+- Existing install, check, disable, settings-navigation, and uninstall flows
+ pass on mobile and desktop, with no horizontal overflow.
+
+## ASCII UI preview
+
+UI-01, excerpt of the [combined preview](plan.md#ascii-ui-preview):
+
+```text
+Phone Desktop
+Installed plugins [Install plugin] Installed plugins [Install plugin] Sync Check updates
+Sync Check for updates Automatic updates [switch]
+Automatic updates [switch] -----------------------------------------------------
+-------------------------------- Plugin name / state / version / controls
+Plugin name / state / controls -----------------------------------------------------
+--------------------------------
+```
+
+The existing settings surface owns vertical scrolling. Keep 44px phone/coarse
+targets and 28px desktop toolbar controls per the listed acceptance criteria.
+
+## Verification
+
+```bash
+(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/settings/mobile-plugin-updates.spec.ts tests/plugins/mobile-plugin-settings-row.spec.ts --retries=0)
+(cd apps/web && pnpm e2e:run --host --no-build --project chromium tests/plugins/plugins.spec.ts --retries=0)
+(cd apps/web && pnpm exec vitest run app/settings/plugins/page.test.tsx components/settings/plugins/plugin-row.test.tsx)
+(cd apps/web && pnpm run typecheck)
+(cd apps/web && pnpm exec eslint components/settings/plugins/plugins-settings.tsx components/settings/plugins/plugin-row.tsx e2e/tests/settings/mobile-plugin-updates.spec.ts e2e/tests/plugins/plugins.spec.ts --max-warnings 0)
+(cd apps/web && pnpm run i18n:check)
+node scripts/validate-public-docs.mjs
+python3 scripts/list-docs.py validate
+python3 scripts/lint-spec-files.py --all
+git diff --check
+```
+
+## Files likely touched
+
+- `apps/web/components/settings/plugins/plugins-settings.tsx`
+- `apps/web/components/settings/plugins/plugin-row.tsx`
+- `apps/web/e2e/tests/settings/mobile-plugin-updates.spec.ts`
+- `apps/web/e2e/tests/plugins/plugins.spec.ts`
+- `docs/public/plugins.md`
+- `docs/screenshots/plugin-settings.png`
+
+## Dependencies and parallelism
+
+None. Sequential execution in the primary session.
+
+## Inputs and risks
+
+Read the linked control-sizing design, existing settings primitives, and plugin
+E2E fixtures. Keep overlay links below inline controls; check translated label
+wrapping and plugin IDs after removing padding.
+
+## Results
+
+Completed on 2026-09-24. All commands in Verification passed:
+
+- Mobile Playwright: 2 passed, including 320px, 767px, and 768px containment and
+ touch-control checks in addition to the canonical phone viewport.
+- Desktop Playwright: 17 passed, including 28px toolbar geometry, keyboard
+ activation, installation, synchronization, updates, persistence, and uninstall.
+- Focused Vitest: 54 passed. The existing version assertion now checks the row's
+ combined text because ID and version occupy separate wrapping spans.
+- Typecheck, focused ESLint, and all localization checks passed.
+- Public docs: 47 published pages validated. Catalog validation checked 304
+ decisions and 1138 specifications; all specification files passed lint.
+- Documentation coverage validator: covered; no reference errors.
+- `git diff --check` passed.
+
+RED evidence: the original mobile toolbar failed the same-row assertion with
+Sync at y=314.5 and Check for updates at y=366.5. The final page preserves both
+44px touch targets while placing these actions side by side.
+
+A disposable capture spec passed against the production build with isolated,
+synthetic plugin records. Four compressed screenshots show Installed and Browse
+on phone and desktop; PR comparison assets are published on a separate media
+branch. The rendered phone composition matches UI-01. The public plugin guide
+describes the compact toolbar and flat list and includes the new desktop
+Installed screenshot. Its duplicate illustration of the previous layout was
+removed during PR review.
diff --git a/docs/public/plugins.md b/docs/public/plugins.md
index 82b0e84a06..17392c5ad3 100644
--- a/docs/public/plugins.md
+++ b/docs/public/plugins.md
@@ -167,14 +167,15 @@ Once installed, the plugin appears in the list with its category, a status
badge (`active`), a signing badge (`unsigned` today), and **Disable** and
**Uninstall** actions. Selecting the row anywhere opens that plugin's own
settings page; a `Setup required` badge marks a plugin whose manifest declares
-a required setting that has no value yet:
-
-
+a required setting that has no value yet.
The Installed tab also gives you an overview of automatic updates, installed
-versions, available updates, and per-plugin controls:
+versions, available updates, and per-plugin controls. **Install plugin** sits
+beside the list heading. **Sync** and **Check for updates** share a compact
+toolbar, with the automatic-update preference and plugin list below. On phones,
+the secondary actions wrap below the heading and retain touch-sized targets:
-
+
Filesystem sideload and synchronization
diff --git a/docs/screenshots/plugin-settings.png b/docs/screenshots/plugin-settings.png
index a04b70b1ae..8e6f343085 100644
Binary files a/docs/screenshots/plugin-settings.png and b/docs/screenshots/plugin-settings.png differ