diff --git a/apps/web/app/settings/plugins/page.test.tsx b/apps/web/app/settings/plugins/page.test.tsx index 67456c924c..63591cb07e 100644 --- a/apps/web/app/settings/plugins/page.test.tsx +++ b/apps/web/app/settings/plugins/page.test.tsx @@ -227,7 +227,9 @@ describe("PluginsSettingsPage", () => { render(); expect(screen.getByText(PLUGIN_DISPLAY_NAME)).toBeTruthy(); - expect(screen.getByText(/acme-tools.*v1\.0\.0/)).toBeTruthy(); + expect(screen.getByTestId(`plugin-row-${PLUGIN_ID}`).textContent).toMatch( + /acme-tools.*v1\.0\.0/, + ); expect(screen.getAllByText(/active/i).length).toBeGreaterThan(0); expect(screen.getAllByTestId("plugin-unsigned-badge")).toHaveLength(1); }); diff --git a/apps/web/components/settings/plugins/plugin-row.tsx b/apps/web/components/settings/plugins/plugin-row.tsx index b0cee9edf7..3ec48009c3 100644 --- a/apps/web/components/settings/plugins/plugin-row.tsx +++ b/apps/web/components/settings/plugins/plugin-row.tsx @@ -120,7 +120,7 @@ export function PluginRow({ return (
-
+
{canManage && ( +
{plugin.display_name} @@ -349,9 +349,10 @@ function PluginRowIdentity({ )}
- - {plugin.id} · v{plugin.version} + + {plugin.id} + v{plugin.version}
@@ -493,7 +494,7 @@ function PluginRowActions({ const { isMobile } = useResponsiveBreakpoint(); const updateEntry = update?.hasUpdate ? update.latest : undefined; return ( -
+
{updateEntry && onUpdate && ( undefined} showSaveButton={false} + contentFrame="none" > - + - + - - - -
- ) : undefined - } - > +
+ {canManage && } - +
+ ); +} + +function InstalledPluginsToolbar({ + actions, + updates, + canManage, +}: Pick) { + const { t } = useTranslation(); + return ( +
+

+ {t("plugins:installedPlugins")} +

+ {canManage && ( + <> + +
+ + +
+ + )} +
); } @@ -227,25 +246,24 @@ function GlobalAutoUpdateToggle({ }) { const { t } = useTranslation(); return ( -
-
- -

{t("plugins:autoUpdateDescription")}

-
- -
+ {t("plugins:autoUpdateTitle")}} + description={t("plugins:autoUpdateDescription")} + controlId="plugins-auto-update-default" + touchTarget="switch" + className="flex-row items-center gap-4 border-y border-border/60" + controlWrapperClassName="max-md:w-auto" + control={ + + } + /> ); } @@ -297,7 +315,7 @@ function PluginList({ } return ( -
+
{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: - -![The Settings > Plugins page listing an installed, active plugin with its category, a Setup required badge, an unsigned badge, Disable/Uninstall actions, and a chevron opening the plugin's settings page.](../screenshots/plugin-settings-list.png) +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: -![Settings > Plugins showing automatic updates and the installed plugin list with sync, update, enable, disable, uninstall, and settings controls.](../screenshots/plugin-settings.png) +![Settings > Plugins showing the compact install, sync, and update toolbar, automatic updates, and divided plugin rows with enable, disable, uninstall, and settings controls.](../screenshots/plugin-settings.png)
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