diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js index bb73f432e7..909782cd05 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js +++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js @@ -244,7 +244,28 @@ function MainTopBarSlot(props) { var slotProps = props.slotProps || {}; - return jsx("span", { id: "hello-main-top-bar" }, "Hello " + slotProps.currentPage); + var label = "Hello " + slotProps.currentPage; + return jsx( + ui.Button, + { + id: "hello-main-top-bar", + variant: "outline", + size: "icon-sm", + "aria-label": label, + }, + jsx( + "svg", + { + className: "h-4 w-4", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "aria-hidden": "true", + }, + jsx("path", { d: "M5 12h14M12 5l7 7l-7 7" }), + ), + jsx("span", { className: "sr-only" }, label), + ); } // Debounce delay for the Notes panel's autosave — short, so e2e specs diff --git a/apps/packages/plugin-sdk/src/index.ts b/apps/packages/plugin-sdk/src/index.ts index 0a9dee218e..dcbfe548da 100644 --- a/apps/packages/plugin-sdk/src/index.ts +++ b/apps/packages/plugin-sdk/src/index.ts @@ -27,6 +27,14 @@ export type PluginIcon = string | Component; /** Placement for a registered nav item; see `PluginRegistry.registerNavItem`. */ export type PluginNavSection = "main" | "settings" | "integrations" | "sidebar-footer"; +/** Context passed to components registered for the `main-top-bar` slot. */ +export interface MainTopBarSlotProps { + workspaceId: string | null; + workspaceLabel?: string; + currentPage: "kanban" | "tasks"; + presentation: "desktop" | "mobile"; +} + export type StateUpdater = Value | ((previous: Value) => Value); export type StateSetter = (value: StateUpdater) => void; diff --git a/apps/web/components/kanban/kanban-header-mobile.test.tsx b/apps/web/components/kanban/kanban-header-mobile.test.tsx index 1c7fc46f53..55572af8e6 100644 --- a/apps/web/components/kanban/kanban-header-mobile.test.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.test.tsx @@ -98,10 +98,12 @@ describe("KanbanHeaderMobile", () => { it("renders page title and workspace label for non-Home pages", () => { renderHeader("Tasks", undefined, undefined, "tasks"); - const leftActions = screen.getByTestId(LEFT_ACTIONS_TEST_ID); - expect(leftActions.textContent).toContain("Tasks"); - expect(leftActions.textContent).toContain("/root/kandev"); - expect(leftActions.firstElementChild?.className).toContain("max-w-[38vw]"); + const actionStrip = screen.getByTestId("mobile-topbar-action-strip"); + expect(actionStrip.textContent).toContain("Tasks"); + expect(actionStrip.textContent).toContain("/root/kandev"); + expect( + actionStrip.querySelector("[data-testid='mobile-topbar-page-context']")?.className, + ).toContain("max-w-[38vw]"); }); it("opens quick chat from the header action when a workspace is active", () => { @@ -114,11 +116,11 @@ describe("KanbanHeaderMobile", () => { it("opens quick terminal immediately before quick chat", () => { renderHeader("Home", ACTIVE_WORKSPACE_ID); - const terminal = screen.getByTestId(QUICK_TERMINAL_TEST_ID); - const quickChat = screen.getByTestId(QUICK_CHAT_TEST_ID); - expect(terminal.nextElementSibling).toBe(quickChat); + const terminalTarget = screen.getByTestId("mobile-quick-terminal-hit-target"); + const quickChatTarget = screen.getByTestId("mobile-quick-chat-hit-target"); + expect(terminalTarget.nextElementSibling).toBe(quickChatTarget); - fireEvent.click(terminal); + fireEvent.click(screen.getByTestId(QUICK_TERMINAL_TEST_ID)); expect(quickChatMocks.openQuickTerminal).toHaveBeenCalledTimes(1); }); @@ -132,11 +134,36 @@ describe("KanbanHeaderMobile", () => { it("places quick chat immediately before search", () => { renderHeader("Home", "workspace-1", vi.fn()); - const quickChat = screen.getByTestId(QUICK_CHAT_TEST_ID); + const quickChat = screen.getByTestId("mobile-quick-chat-hit-target"); const search = screen.getByTestId("mobile-search-toggle"); expect(quickChat.nextElementSibling).toBe(search); }); + it("keeps the brand and menu outside the middle action strip", () => { + renderHeader("Home", ACTIVE_WORKSPACE_ID, vi.fn()); + + const strip = screen.getByTestId("mobile-topbar-action-strip"); + const menu = screen.getByTestId("mobile-topbar-menu"); + expect(strip.parentElement).toBe(menu.parentElement); + expect(strip.previousElementSibling).not.toBe(menu); + expect(menu.previousElementSibling).toBe(strip); + }); + + it("uses the shared compact icon geometry for native mobile actions", () => { + renderHeader("Home", ACTIVE_WORKSPACE_ID, vi.fn()); + + for (const id of [ + QUICK_TERMINAL_TEST_ID, + QUICK_CHAT_TEST_ID, + "mobile-search-toggle", + "mobile-topbar-menu", + ]) { + expect(screen.getByTestId(id).className).not.toContain("!size-11"); + } + expect(screen.getByTestId("mobile-quick-terminal-hit-target").className).toContain("h-11"); + expect(screen.getByTestId("mobile-quick-chat-hit-target").className).toContain("h-11"); + }); + it("describes a connectivity warning on the persistent Home menu trigger", () => { statusDrawerState.issueSeverity = "lost"; renderHeader("Home"); diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx index 082b1541e0..0970d3e843 100644 --- a/apps/web/components/kanban/kanban-header-mobile.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.tsx @@ -2,11 +2,13 @@ import { Button } from "@kandev/ui/button"; import { IconMenu2, IconMessageCircle, IconSearch, IconTerminal2 } from "@tabler/icons-react"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import Link from "@/components/routing/app-link"; import { PageTopbar } from "@/components/page-topbar"; import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics"; import { MainTopBarPluginActions } from "./main-top-bar-plugin-actions"; +import { MobileTopbarActionStrip } from "./mobile-topbar-action-strip"; import { MobileMenuSheet } from "./mobile-menu-sheet"; import type { TasksListDisplayOptions } from "./mobile-menu-task-list-options"; import { useAppStore } from "@/components/state-provider"; @@ -37,12 +39,33 @@ function MobileBrandLink({ workspaceId }: Pick Kandev ); } +function MobileLauncherTarget({ + children, + onClick, + testId, +}: { + children: ReactNode; + onClick: () => void; + testId: string; +}) { + return ( + + {children} + + ); +} + function MobileQuickChatButton({ workspaceId, onClick, @@ -54,72 +77,86 @@ function MobileQuickChatButton({ const dot = useAppStore((state) => selectQuickChatHasUnseenIdle(state, workspaceId)); const quickChatLabel = t(dot ? "sidebar:quickChatUnseen" : "sidebar:quickChat"); return ( - + + + ); } -function MobileHeaderActions({ +function MobileHeaderActionItems({ workspaceId, workspaceLabel, + title, + hideTitle, currentPage, onSearchChange, isSearchOpen, handleOpenQuickChat, handleOpenQuickTerminal, toggleSearch, - setMenuOpen, }: { workspaceId?: string; workspaceLabel: string; + title: string; + hideTitle: boolean; currentPage: "kanban" | "tasks"; onSearchChange?: (query: string) => void; isSearchOpen: boolean; handleOpenQuickChat: () => void; handleOpenQuickTerminal: () => void; toggleSearch: () => void; - setMenuOpen: (open: boolean) => void; }) { const { t } = useTranslation(); - const { issueSeverity } = useAppStatusDrawer(); - const issueDetails = useConnectionIssueCopy(issueSeverity); + const isHome = currentPage !== "tasks"; return ( <> + {!hideTitle && !isHome && ( + + {title} + {workspaceLabel} + + )} - + {workspaceId && ( - + + )} {workspaceId && ( @@ -137,6 +174,25 @@ function MobileHeaderActions({ )} + + ); +} + +function MobileHeaderActions( + props: Parameters[0] & { + setMenuOpen: (open: boolean) => void; + }, +) { + const { setMenuOpen, ...actionProps } = props; + const { t } = useTranslation(); + const { issueSeverity } = useAppStatusDrawer(); + const issueDetails = useConnectionIssueCopy(issueSeverity); + + return ( + <> + + + + ); + }); + + render(); + + expect(screen.getByTestId("plugin-button").textContent).toBe("mobile"); + expect(screen.getByTestId("mobile-main-top-bar-plugin-actions").className).toContain( + "[&_[data-slot=button]]:!size-8", + ); + }); }); diff --git a/apps/web/components/kanban/main-top-bar-plugin-actions.tsx b/apps/web/components/kanban/main-top-bar-plugin-actions.tsx index 64f630bec1..648c2da754 100644 --- a/apps/web/components/kanban/main-top-bar-plugin-actions.tsx +++ b/apps/web/components/kanban/main-top-bar-plugin-actions.tsx @@ -2,6 +2,9 @@ import { useMemo } from "react"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import type { MainTopBarSlotProps } from "@/lib/plugins/types"; + +export type { MainTopBarSlotProps } from "@/lib/plugins/types"; /** * Props forwarded to every plugin component registered for the `main-top-bar` @@ -15,15 +18,6 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; * context a plugin gets is the active workspace and which listing view is * showing. */ -export type MainTopBarSlotProps = { - /** Workspace the top bar is currently showing, or null on the global home. */ - workspaceId: string | null; - /** Human-readable label of that workspace, when known. */ - workspaceLabel?: string; - /** Which listing the top bar belongs to. */ - currentPage: "kanban" | "tasks"; -}; - /** * Plugin extension point in the default app top bar (Home / Kanban / Tasks), * rendered alongside the first-party controls (metrics, view toggle, display @@ -36,13 +30,24 @@ export function MainTopBarPluginActions(props: { workspaceId?: string; workspaceLabel?: string; currentPage: "kanban" | "tasks"; + presentation?: MainTopBarSlotProps["presentation"]; }) { - const { workspaceId, workspaceLabel, currentPage } = props; + const { workspaceId, workspaceLabel, currentPage, presentation = "desktop" } = props; const slotProps = useMemo( - () => ({ workspaceId: workspaceId ?? null, workspaceLabel, currentPage }), - [workspaceId, workspaceLabel, currentPage], + () => ({ workspaceId: workspaceId ?? null, workspaceLabel, currentPage, presentation }), + [workspaceId, workspaceLabel, currentPage, presentation], ); - return ; + const content = ; + if (presentation === "desktop") return content; + + return ( +
+ {content} +
+ ); } diff --git a/apps/web/components/kanban/mobile-topbar-action-strip.test.tsx b/apps/web/components/kanban/mobile-topbar-action-strip.test.tsx new file mode 100644 index 0000000000..5a3c591647 --- /dev/null +++ b/apps/web/components/kanban/mobile-topbar-action-strip.test.tsx @@ -0,0 +1,101 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MobileTopbarActionStrip } from "./mobile-topbar-action-strip"; + +const observers: Array<{ trigger: () => void }> = []; +const VIEWPORT_TEST_ID = "mobile-topbar-action-strip-viewport"; +const LEFT_FADE_TEST_ID = "mobile-topbar-action-strip-left-fade"; +const RIGHT_FADE_TEST_ID = "mobile-topbar-action-strip-right-fade"; + +class MockResizeObserver { + private readonly callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + observers.push({ trigger: () => this.callback([], this as unknown as ResizeObserver) }); + } + + observe() {} + + disconnect() {} +} + +function setScrollMetrics( + element: HTMLElement, + metrics: { clientWidth: number; scrollWidth: number; scrollLeft: number }, +) { + Object.defineProperties(element, { + clientWidth: { configurable: true, value: metrics.clientWidth }, + scrollWidth: { configurable: true, value: metrics.scrollWidth }, + scrollLeft: { configurable: true, value: metrics.scrollLeft, writable: true }, + }); +} + +describe("MobileTopbarActionStrip", () => { + beforeEach(() => { + observers.length = 0; + vi.stubGlobal("ResizeObserver", MockResizeObserver); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("does not render directional fades when all actions fit", () => { + render( + + Actions + , + ); + + const viewport = screen.getByTestId(VIEWPORT_TEST_ID); + expect((viewport.firstElementChild as HTMLElement).className).toContain("justify-end"); + setScrollMetrics(viewport, { clientWidth: 200, scrollWidth: 200, scrollLeft: 0 }); + act(() => observers[0]?.trigger()); + + expect(screen.queryByTestId(LEFT_FADE_TEST_ID)).toBeNull(); + expect(screen.queryByTestId(RIGHT_FADE_TEST_ID)).toBeNull(); + }); + + it("shows only the right fade when content overflows at the initial position", () => { + render( + + Actions + , + ); + + const viewport = screen.getByTestId(VIEWPORT_TEST_ID); + setScrollMetrics(viewport, { clientWidth: 100, scrollWidth: 240, scrollLeft: 0 }); + act(() => observers[0]?.trigger()); + + expect(screen.queryByTestId(LEFT_FADE_TEST_ID)).toBeNull(); + expect(screen.getByTestId(RIGHT_FADE_TEST_ID)).toBeTruthy(); + }); + + it("updates both fades while scrolling through overflowing actions", () => { + render( + + Actions + , + ); + + const viewport = screen.getByTestId(VIEWPORT_TEST_ID); + setScrollMetrics(viewport, { clientWidth: 100, scrollWidth: 240, scrollLeft: 0 }); + act(() => observers[0]?.trigger()); + + act(() => { + viewport.scrollLeft = 60; + fireEvent.scroll(viewport); + }); + expect(screen.getByTestId(LEFT_FADE_TEST_ID)).toBeTruthy(); + expect(screen.getByTestId(RIGHT_FADE_TEST_ID)).toBeTruthy(); + + act(() => { + viewport.scrollLeft = 140; + fireEvent.scroll(viewport); + }); + expect(screen.getByTestId(LEFT_FADE_TEST_ID)).toBeTruthy(); + expect(screen.queryByTestId(RIGHT_FADE_TEST_ID)).toBeNull(); + }); +}); diff --git a/apps/web/components/kanban/mobile-topbar-action-strip.tsx b/apps/web/components/kanban/mobile-topbar-action-strip.tsx new file mode 100644 index 0000000000..e4759c70ca --- /dev/null +++ b/apps/web/components/kanban/mobile-topbar-action-strip.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react"; + +const SCROLL_END_TOLERANCE_PX = 1; + +type ScrollCues = { + left: boolean; + right: boolean; +}; + +function scrollCuesFor(element: HTMLDivElement): ScrollCues { + const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); + return { + left: element.scrollLeft > SCROLL_END_TOLERANCE_PX, + right: maxScrollLeft - element.scrollLeft > SCROLL_END_TOLERANCE_PX, + }; +} + +export function MobileTopbarActionStrip({ children }: { children: ReactNode }) { + const viewportRef = useRef(null); + const contentRef = useRef(null); + const [scrollCues, setScrollCues] = useState({ left: false, right: false }); + + const updateScrollCues = useCallback(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const next = scrollCuesFor(viewport); + setScrollCues((current) => + current.left === next.left && current.right === next.right ? current : next, + ); + }, []); + + useLayoutEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + updateScrollCues(); + viewport.addEventListener("scroll", updateScrollCues, { passive: true }); + window.addEventListener("resize", updateScrollCues); + + const observer = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateScrollCues); + observer?.observe(viewport); + if (contentRef.current) observer?.observe(contentRef.current); + + return () => { + viewport.removeEventListener("scroll", updateScrollCues); + window.removeEventListener("resize", updateScrollCues); + observer?.disconnect(); + }; + }, [updateScrollCues]); + + return ( +
+
+
+ {children} +
+
+ {scrollCues.left && ( + ); } -function EmptyMetrics({ drawer = false }: { drawer?: boolean }) { +function EmptyMetrics({ drawer = false, iconSize }: { drawer?: boolean; iconSize: string }) { const { t } = useTranslation(); return (
- + {t("system:metricsUnavailable")}
); @@ -109,23 +113,31 @@ function BarSourceMetrics({ showSourceLabel, metricLimit, simplified, + iconSize, }: { source: SystemMetricsSource; updatedAt?: string; showSourceLabel: boolean; metricLimit: number; simplified: boolean; + iconSize: string; }) { return (
{!simplified ? ( - + ) : null}
); @@ -135,16 +147,26 @@ function DrawerSourceMetrics({ source, updatedAt, simplified, + iconSize, }: { source: SystemMetricsSource; updatedAt?: string; simplified: boolean; + iconSize: string; }) { return (
- {!simplified ? : null} + {!simplified ? ( + + ) : null}
- +
); @@ -154,10 +176,12 @@ function SourceBadge({ source, updatedAt, showLabel, + iconSize, }: { source: SystemMetricsSource; updatedAt?: string; showLabel: boolean; + iconSize: string; }) { const { t } = useTranslation(); const locale = useDateLocale(); @@ -168,7 +192,7 @@ function SourceBadge({ className="flex shrink-0 items-center gap-1.5 text-current" aria-label={t("system:hostMetrics")} > - + {showLabel ? {t("system:host")} : null} @@ -190,11 +214,13 @@ function MetricValues({ updatedAt, limit, simplified, + iconSize, }: { source: SystemMetricsSource; updatedAt?: string; limit: number; simplified: boolean; + iconSize: string; }) { const metrics = source.metrics.slice(0, limit); if (metrics.length === 0) return -; @@ -207,6 +233,7 @@ function MetricValues({ source={source} updatedAt={updatedAt} simplified={simplified} + iconSize={iconSize} /> ))} @@ -218,11 +245,13 @@ function MetricValue({ source, updatedAt, simplified, + iconSize, }: { metric: SystemMetricSample; source: SystemMetricsSource; updatedAt?: string; simplified: boolean; + iconSize: string; }) { const { t } = useTranslation(); const locale = useDateLocale(); @@ -234,7 +263,7 @@ function MetricValue({ className={`inline-flex shrink-0 items-center gap-1.5 tabular-nums ${metricColor(metric)}`} aria-label={`${metricLabel(t, metric.id)} ${formatMetric(metric)}`} > - {metricIcon(metric.id)} + {metricIcon(metric.id, iconSize)} {!simplified ? : null} {formatMetric(metric)} @@ -275,7 +304,7 @@ function metricLabel(t: TFunction, id: string) { ); } -function metricIcon(id: string) { +function metricIcon(id: string, iconSize: string) { const Icon = { cpu_percent: IconCpu, @@ -284,7 +313,7 @@ function metricIcon(id: string) { cpu_temp: IconFlame, io_load: IconGauge, }[id] ?? IconActivity; - return ; + return ; } function MetricMeter({ metric }: { metric: SystemMetricSample }) { diff --git a/apps/web/components/system-metrics/topbar-metrics.test.tsx b/apps/web/components/system-metrics/topbar-metrics.test.tsx index 2f6c3e85b5..7f9fb6ac0c 100644 --- a/apps/web/components/system-metrics/topbar-metrics.test.tsx +++ b/apps/web/components/system-metrics/topbar-metrics.test.tsx @@ -13,7 +13,7 @@ vi.mock("@/hooks/use-system-metrics-subscription", () => ({ useSystemMetricsSubscription: subscribeMock, })); -function renderMetrics(appStatusBarEnabled: boolean) { +function renderMetrics(appStatusBarEnabled: boolean, mobile = false) { const initialState = { userSettings: { ...defaultSettingsState.userSettings, @@ -40,7 +40,7 @@ function renderMetrics(appStatusBarEnabled: boolean) { return render( - + , ); @@ -57,6 +57,7 @@ describe("TopbarMetrics preference fallback", () => { renderMetrics(false); expect(screen.getByTestId("topbar-metrics")).toBeTruthy(); + expect(screen.getByTestId("topbar-metrics").className).toContain("h-8"); expect(screen.getByLabelText("CPU 42%")).toBeTruthy(); expect(subscribeMock).toHaveBeenCalledWith(true); }); @@ -67,4 +68,12 @@ describe("TopbarMetrics preference fallback", () => { expect(screen.queryByTestId("topbar-metrics")).toBeNull(); expect(subscribeMock).not.toHaveBeenCalled(); }); + + it("uses 16px metric icons in the mobile topbar", () => { + renderMetrics(false, true); + + expect(screen.getByLabelText("CPU 42%").querySelector("svg")?.getAttribute("class")).toContain( + "size-4", + ); + }); }); diff --git a/apps/web/components/system-metrics/topbar-metrics.tsx b/apps/web/components/system-metrics/topbar-metrics.tsx index 9f06e84757..6e0c901bc5 100644 --- a/apps/web/components/system-metrics/topbar-metrics.tsx +++ b/apps/web/components/system-metrics/topbar-metrics.tsx @@ -6,10 +6,11 @@ import { StatusSurfaceMetrics } from "./status-surface-metrics"; type TopbarMetricsProps = { activeSessionId?: string | null; size?: "sm" | "lg"; + mobile?: boolean; }; /** Preserves topbar metrics while the user-owned status surface is hidden. */ -export function TopbarMetrics({ size = "lg" }: TopbarMetricsProps) { +export function TopbarMetrics({ size = "lg", mobile = false }: TopbarMetricsProps) { const statusBarEnabled = useAppStore((state) => state.userSettings.appStatusBarEnabled); const metricsEnabled = useAppStore( (state) => state.userSettings.systemMetricsDisplay.showInTopbar, @@ -19,10 +20,15 @@ export function TopbarMetrics({ size = "lg" }: TopbarMetricsProps) { return (
- +
); } diff --git a/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts b/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts new file mode 100644 index 0000000000..00f0439b13 --- /dev/null +++ b/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts @@ -0,0 +1,170 @@ +import path from "node:path"; +import type { Locator, Page } from "@playwright/test"; +import { expect, test } from "../../fixtures/test-base"; +import { + captureAppStatusBarSettings, + restoreAppStatusBarSettings, + type AppStatusBarSettingsBaseline, +} from "../../helpers/app-status-bar-settings"; +import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions"; + +const PLUGIN_ID = "kandev-plugin-e2e"; +const PACKAGE_PATH = path.resolve( + __dirname, + "../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz", +); + +type SystemMetricsDisplayBaseline = { + show_in_topbar: boolean; + simplified?: boolean; +}; + +async function installFixture(page: Page) { + await page.goto("/settings/plugins"); + await page.getByTestId("install-plugin-trigger").tap(); + await page.getByTestId("install-plugin-tab-upload").tap(); + await page.getByTestId("install-plugin-file-input").setInputFiles(PACKAGE_PATH); + await page.getByTestId("install-plugin-upload-submit").tap(); + await expect(page.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 30_000 }); +} + +test.describe("Mobile topbar action strip", () => { + let metricsBaseline: SystemMetricsDisplayBaseline; + let statusBarBaseline: AppStatusBarSettingsBaseline; + + test.beforeEach(async ({ apiClient, testPage }) => { + void testPage; + const settings = await apiClient.getUserSettings(); + metricsBaseline = (settings.settings.system_metrics_display as + | SystemMetricsDisplayBaseline + | undefined) ?? { show_in_topbar: false }; + statusBarBaseline = await captureAppStatusBarSettings(apiClient); + + const response = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: false, + system_metrics_display: { show_in_topbar: true, simplified: false }, + }); + expect(response.ok).toBe(true); + }); + + test.afterEach(async ({ apiClient }) => { + await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); + await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + system_metrics_display: metricsBaseline, + }); + await restoreAppStatusBarSettings(apiClient, statusBarBaseline); + }); + + test("scrolls plugin, metric, and native actions without moving fixed chrome", async ({ + testPage, + }) => { + test.setTimeout(120_000); + + await installFixture(testPage); + await testPage.goto("/"); + await testPage.reload(); + + const strip = testPage.getByTestId("mobile-topbar-action-strip"); + const viewport = testPage.getByTestId("mobile-topbar-action-strip-viewport"); + const leftFade = testPage.getByTestId("mobile-topbar-action-strip-left-fade"); + const rightFade = testPage.getByTestId("mobile-topbar-action-strip-right-fade"); + const brand = testPage.getByTestId("mobile-topbar-brand"); + const menu = testPage.getByTestId("mobile-topbar-menu"); + const pluginButton = testPage.locator("#hello-main-top-bar"); + const metrics = testPage.getByTestId("topbar-metrics"); + const cpuMetric = metrics.getByLabel(/^CPU /); + const terminal = testPage.getByTestId("mobile-quick-terminal-button"); + const quickChat = testPage.getByTestId("mobile-quick-chat-button"); + const terminalHitTarget = testPage.getByTestId("mobile-quick-terminal-hit-target"); + const quickChatHitTarget = testPage.getByTestId("mobile-quick-chat-hit-target"); + const search = testPage.getByTestId("mobile-search-toggle"); + + await expect(strip).toBeVisible(); + await expect(pluginButton).toBeVisible(); + await expect(metrics).toBeVisible(); + await expect(cpuMetric).toBeVisible(); + await expect(terminal).toBeVisible(); + await expect(quickChat).toBeVisible(); + await expect(search).toBeVisible(); + await expect(menu).toBeVisible(); + + const nativeButtons = [terminal, quickChat, search, menu]; + const nativeBoxes = await Promise.all( + nativeButtons.map((button, index) => requireBox(button, `native button ${index}`)), + ); + for (const box of nativeBoxes) { + expect(box.width).toBeCloseTo(32, 0); + expect(box.height).toBeCloseTo(32, 0); + } + + const pluginBox = await requireBox(pluginButton, "plugin action"); + expect(pluginBox.width).toBeCloseTo(32, 0); + expect(pluginBox.height).toBeCloseTo(32, 0); + + const metricBox = await requireBox(metrics, "topbar metrics"); + expect(metricBox.height).toBeCloseTo(32, 0); + const metricIconBox = await requireBox(cpuMetric.locator("svg").first(), "CPU metric icon"); + expect(metricIconBox.width).toBeCloseTo(16, 0); + expect(metricIconBox.height).toBeCloseTo(16, 0); + + const pluginIconBox = await requireBox(pluginButton.locator("svg").first(), "plugin icon"); + expect(pluginIconBox.width).toBeCloseTo(16, 0); + expect(pluginIconBox.height).toBeCloseTo(16, 0); + + const expectTouchTarget = async (target: Locator, label: string) => { + const targetBox = await requireBox(target, `${label} hit target`); + expect(targetBox.height).toBeCloseTo(44, 0); + expect(targetBox.width).toBeCloseTo(32, 0); + const hitTarget = await target.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const points = [rect.top + 1, rect.bottom - 1]; + return points.every((y) => document.elementFromPoint(x, y) === element); + }); + expect(hitTarget, `${label} keeps a 44px hit area`).toBe(true); + }; + + await expectTouchTarget(terminalHitTarget, "Quick Terminal"); + + const brandBefore = await requireBox(brand, "brand before scroll"); + const menuBefore = await requireBox(menu, "menu before scroll"); + await expect(strip).toHaveAttribute("data-can-scroll-left", "false"); + await expect(strip).toHaveAttribute("data-can-scroll-right", "true"); + await expect(leftFade).toHaveCount(0); + await expect(rightFade).toBeVisible(); + await assertNoDocumentHorizontalOverflow(testPage, "mobile topbar initial layout"); + + await viewport.evaluate((element) => { + const node = element as HTMLElement; + node.scrollLeft = Math.floor((node.scrollWidth - node.clientWidth) / 2); + node.dispatchEvent(new Event("scroll")); + }); + await expect(strip).toHaveAttribute("data-can-scroll-left", "true"); + await expect(strip).toHaveAttribute("data-can-scroll-right", "true"); + await expect(leftFade).toBeVisible(); + await expect(rightFade).toBeVisible(); + + await viewport.evaluate((element) => { + const node = element as HTMLElement; + node.scrollLeft = node.scrollWidth; + node.dispatchEvent(new Event("scroll")); + }); + await expect(strip).toHaveAttribute("data-can-scroll-left", "true"); + await expect(strip).toHaveAttribute("data-can-scroll-right", "false"); + await expect(leftFade).toBeVisible(); + await expect(rightFade).toHaveCount(0); + await expect(search).toBeInViewport(); + await expectTouchTarget(quickChatHitTarget, "Quick Chat"); + await assertNoDocumentHorizontalOverflow(testPage, "mobile topbar scrolled layout"); + + const brandAfter = await requireBox(brand, "brand after scroll"); + const menuAfter = await requireBox(menu, "menu after scroll"); + expect(Math.abs(brandAfter.x - brandBefore.x)).toBeLessThanOrEqual(1); + expect(Math.abs(menuAfter.x - menuBefore.x)).toBeLessThanOrEqual(1); + + await search.tap(); + await expect(testPage.getByTestId("mobile-search-bar")).toBeVisible(); + await expect(testPage.getByTestId("mobile-search-bar").getByRole("textbox")).toBeVisible(); + await assertNoDocumentHorizontalOverflow(testPage, "mobile search layout"); + }); +}); diff --git a/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts b/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts index c08f974ea4..81cd4cedfb 100644 --- a/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts +++ b/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts @@ -60,13 +60,19 @@ test.describe("mobile quick terminal tabs", () => { try { const terminalButton = testPage.getByTestId("mobile-quick-terminal-button"); const quickChatButton = testPage.getByTestId("mobile-quick-chat-button"); + const menuButton = testPage.getByTestId("mobile-topbar-menu"); await expect(terminalButton).toBeVisible(); await expect(quickChatButton).toBeVisible(); + await expect(menuButton).toBeVisible(); + const headerMenuBox = await menuButton.boundingBox(); + expect(headerMenuBox).not.toBeNull(); + if (!headerMenuBox) throw new Error("mobile menu geometry unavailable"); for (const button of [terminalButton, quickChatButton]) { const buttonBox = await button.boundingBox(); expect(buttonBox).not.toBeNull(); - expect(buttonBox!.width).toBeGreaterThanOrEqual(44); - expect(buttonBox!.height).toBeGreaterThanOrEqual(44); + if (!buttonBox) throw new Error("mobile launcher geometry unavailable"); + expect(buttonBox.width).toBeCloseTo(headerMenuBox.width, 1); + expect(buttonBox.height).toBeCloseTo(headerMenuBox.height, 1); } await terminalButton.tap(); diff --git a/apps/web/lib/plugins/sdk-contract.test.ts b/apps/web/lib/plugins/sdk-contract.test.ts index 7ced0d18c6..a693d9382d 100644 --- a/apps/web/lib/plugins/sdk-contract.test.ts +++ b/apps/web/lib/plugins/sdk-contract.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { PluginHostApi as PublicPluginHostApi, + MainTopBarSlotProps as PublicMainTopBarSlotProps, PluginNavSection as PublicPluginNavSection, PluginRegistry as PublicPluginRegistry, RepositoryProviderRegistration as PublicRepositoryProviderRegistration, @@ -9,6 +10,7 @@ import type { } from "@kandev/plugin-sdk"; import type { PluginHostApi, + MainTopBarSlotProps as HostMainTopBarSlotProps, PluginNavSection, PluginRegistry, RepositoryProviderRegistration, @@ -48,6 +50,10 @@ describe("public plugin SDK", () => { // `import type { PluginNavSection } from "@kandev/plugin-sdk"` verbatim, and // TS's structural typing can't otherwise tell an inline union from a named export. const navSectionIsCanonical: SameType = true; + const mainTopBarSlotPropsAreCanonical: SameType< + HostMainTopBarSlotProps, + PublicMainTopBarSlotProps + > = true; expect(hostHasEveryPublicKey).toBe(true); expect(registryHasEveryPublicKey).toBe(true); expect(publicHasEveryRegistryKey).toBe(true); @@ -57,6 +63,7 @@ describe("public plugin SDK", () => { expect(reviewSummaryIsCanonical).toBe(true); expect(associationIsCanonical).toBe(true); expect(navSectionIsCanonical).toBe(true); + expect(mainTopBarSlotPropsAreCanonical).toBe(true); expect(publicHostContract).toBeTypeOf("function"); expect(publicRegistryContract).toBeTypeOf("function"); }); diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts index 443e32dd94..1974be5306 100644 --- a/apps/web/lib/plugins/types.ts +++ b/apps/web/lib/plugins/types.ts @@ -12,6 +12,7 @@ import type { PluginUIApi } from "@kandev/plugin-sdk"; export type { PluginContextApi, PluginHostRepository, + MainTopBarSlotProps, PluginNavSection, PluginUIApi, } from "@kandev/plugin-sdk"; @@ -117,7 +118,12 @@ export interface IntegrationSettingsRegistration { * sessionIds }`), "main-top-bar" (status/actions in the default app top bar on * the Home / Kanban / Tasks views, beside the CPU/DB metrics and the * view/display controls — the app-wide, task-agnostic counterpart to - * "chat-top-bar"; receives `{ workspaceId, workspaceLabel, currentPage }`), + * "chat-top-bar"; receives `{ workspaceId, workspaceLabel, currentPage, + * presentation }`). On phones, `presentation` is "mobile": contributions + * join the horizontally scrollable middle action strip between the fixed + * Kandev link and menu button. Use the host `ui.Button` icon-button contract + * there: a 32px box with a 16px SVG icon. Desktop contributions retain their + * existing sizing. * "app-status-bar-left" / "app-status-bar-right" (receives * `AppStatusBarSlotProps` as `slotProps`), and * "plugin-settings" (inline UI on a plugin's own settings diff --git a/docs/plans/mobile-topbar-action-strip/plan.md b/docs/plans/mobile-topbar-action-strip/plan.md new file mode 100644 index 0000000000..6fa17e9582 --- /dev/null +++ b/docs/plans/mobile-topbar-action-strip/plan.md @@ -0,0 +1,147 @@ +--- +spec: docs/specs/mobile-quick-chat-topbar/spec.md +created: 2026-08-18 +status: complete +--- + +# Implementation Plan: Mobile Topbar Action Strip + +## Overview + +The phone header gives Quick Terminal and Quick Chat a 44px box. Search and menu use the shared +32px icon size. The action container also refuses to shrink, so metrics and plugin actions can push +the header past the viewport. This repair normalizes the phone controls and gives the middle actions +one contained horizontal scroll owner with directional fades. + +## Confirmed root cause + +- `apps/web/components/kanban/kanban-header-mobile.tsx` adds `!size-11` to the terminal and chat + buttons. The shared `icon-lg` variant used by search and menu is 32px. +- `apps/web/components/page-topbar.tsx` renders the full action cluster with `shrink-0`. The mobile + header provides no nested overflow owner for optional metrics or plugin contributions. +- `apps/web/components/task/chat/chat-input-toolbar-mobile.tsx` provides the nearest shipped fade + treatment. `apps/web/components/task/task-sidebar-scroll-area.tsx` provides the resize and content + observation pattern for conditional scroll cues. +- `apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts` records the regression as expected + behavior by requiring the two oversized header launchers to be at least 44px. + +## Frontend + +### Scrollable phone action strip + +- Add `apps/web/components/kanban/mobile-topbar-action-strip.tsx`. It owns the horizontal viewport, + content row, hidden scrollbar, and left and right fades. When the content fits, the row aligns + its actions against the fixed menu; overflow retains the same horizontal scroll order. +- Observe both the viewport and content row with `ResizeObserver`. Recalculate the fades after + content changes, viewport changes, and horizontal scrolling. +- Show each fade only while content remains hidden in that direction. Use the gradient treatment + from the mobile chat-input controls. +- Update `apps/web/components/kanban/kanban-header-mobile.tsx` so `PageTopbar` lets its actions use + the remaining width. Keep the Kandev link outside the strip and keep the menu button outside it. +- Put the optional non-Home context, plugin actions, metrics, terminal, chat, and search inside the + strip. Preserve their current order. Remove the 44px overrides from terminal and chat. +- Keep the current labels, launch handlers, search state, status indicator, and menu behavior. + +### Native, metric, and plugin icon sizing + +- Update `apps/web/components/system-metrics/topbar-metrics.tsx` so topbar metric icons inherit the + same 16px size as native header icons. Keep the metrics container at 32px high. +- Add a mobile presentation option to + `apps/web/components/kanban/main-top-bar-plugin-actions.tsx`. The mobile wrapper normalizes + `host.ui.Button` contributions to a 32px box and their SVG icons to 16px. +- Keep Quick Terminal and Quick Chat as 32px visual buttons inside 44px interaction wrappers. The + wrappers keep the strip item's width at 32px while preserving a real touch target. +- Keep desktop plugin contribution sizing unchanged. Keep existing plugin slot fields and lifecycle + compatible; the mobile presentation adds the `presentation` value described by the public SDK. +- Export `MainTopBarSlotProps` from `@kandev/plugin-sdk` and assert the host binding remains an exact + consumer of that public type. + +### Plugin contract documentation + +- Update `apps/web/lib/plugins/types.ts`, `docs/plans/plugins/PLUGIN-API.md`, and + `docs/public/plugins-authoring.md` with the mobile `main-top-bar` sizing and overflow contract. +- State that phone contributions join the middle scroll strip. Host UI icon buttons use the native + 32px box and 16px icon. +- This public page is a reference document. No new page or navigation entry is required. + +## Mobile design contract + +- **Desktop outcome:** desktop Home, Kanban, and Tasks headers keep their current layout and sizes. +- **Mobile entry point:** the existing Home and Tasks topbar remains the direct entry point. +- **Nearest exemplars:** the mobile chat-input toolbar supplies the fade. The task sidebar supplies + conditional cue measurement after content and viewport changes. +- **Hierarchy:** Kandev is fixed on the left. Menu is fixed on the right. Page context and workspace + actions scroll between them, align against the menu when they fit, and retain their current order. +- **Presentation:** the actions stay inline because they are frequent, shallow controls. A drawer + hides direct actions and adds an extra interaction. +- **Scroll owner:** only the middle strip scrolls horizontally. The document and board do not gain + horizontal overflow. +- **Touch and state:** the visible boxes match the existing compact search and menu chrome. Launch, + search, metrics, plugin, and menu state remain shared with wider layouts. Quick Terminal and Quick + Chat keep a 44px coarse-pointer hit area around their 32px visible boxes. +- **Mobile proof:** Pixel 5 coverage enables metrics, installs the real plugin fixture, and proves + control equality, fixed edges, directional fades, contained scrolling, and action reachability. + +## Tests + +- **What:** native mobile controls use one visual size and retain their order and handlers. + **File:** `apps/web/components/kanban/kanban-header-mobile.test.tsx`. + **How:** render the real header actions through the existing mocks. Assert shared size classes, + the fixed-menu boundary, the title's scroll ownership, action order, and launcher callbacks. +- **What:** fades reflect the available scroll direction and react to size changes. + **File:** `apps/web/components/kanban/mobile-topbar-action-strip.test.tsx`. + **How:** use controlled `clientWidth`, `scrollWidth`, and `scrollLeft` values with a mock + `ResizeObserver`. Cover fitting content, initial overflow, middle scroll, and end scroll. +- **What:** mobile plugin and metric wrappers normalize icon geometry without changing desktop. + **Files:** `apps/web/components/kanban/main-top-bar-plugin-actions.test.tsx` and + `apps/web/components/system-metrics/topbar-metrics.test.tsx`. + **How:** render host UI plugin buttons and metric icons. Assert the mobile wrapper contract and + unchanged desktop classes. + +## E2E Tests + +- **Scenario:** GIVEN metrics and a real plugin contribution that overflow the phone header, WHEN + Home renders, THEN Kandev and menu stay fixed while the middle strip scrolls with correct fades. + **File:** `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts`. + **What to verify:** equal native button boxes, 32px metrics height, 16px metric icons, actual + 44px terminal/chat hit areas, initial and final fade states, successful search or launcher + activation after scrolling, and no document horizontal overflow. Restore user settings and + uninstall the fixture in `afterEach`. +- **Scenario:** GIVEN the existing mobile terminal flow, WHEN the launcher renders, THEN its visible + box matches search, chat, and menu instead of the old 44px expectation. + **File:** `apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts`. + **What to verify:** relational header geometry while retaining the existing dialog, menu-row, + terminal containment, scrolling, dismissal, and focus-return assertions. + +## Verification Results + +- Focused Vitest: 5 files and 20 tests passed after the hit-target review fix. +- Plugin SDK typecheck: passed. +- Focused ESLint: passed with no errors or warnings on the changed frontend files. +- Web typecheck: passed. +- Public documentation tests: 61 passed, 0 failed; validator covered 41 published pages. +- Managed Pixel 5 E2E: 1 test passed for the real plugin and metrics overflow scenario, including + actual 44px terminal/chat hit-target checks when each action is in view. +- Managed Pixel 5 quick-terminal regression: 1 test passed with relational header geometry. +- Follow-up alignment fix: fitting action rows use right justification against the fixed menu; the + focused action-strip suite passed 3 tests and focused ESLint passed. + +## Implementation Waves And Parallel Candidates + +Wave 1: + +- [x] [Task 01: Build the mobile action strip](task-01-mobile-action-strip.md) + +Wave 2: + +- [x] [Task 02: Prove mobile overflow behavior](task-02-mobile-overflow-e2e.md) + +The tasks run sequentially. The E2E task depends on the component and contract changes. + +## Risks + +- Plugin components can render arbitrary markup. Host-enforced normalization applies to + `host.ui.Button` and SVG contributions, which are the documented plugin path. +- Metrics and plugin contributions can appear after first paint. The observer must measure both the + viewport and content row so the fades do not become stale. +- The fixed menu status dot must remain visible and clickable outside the fading scroll viewport. diff --git a/docs/plans/mobile-topbar-action-strip/task-01-mobile-action-strip.md b/docs/plans/mobile-topbar-action-strip/task-01-mobile-action-strip.md new file mode 100644 index 0000000000..97ad753c27 --- /dev/null +++ b/docs/plans/mobile-topbar-action-strip/task-01-mobile-action-strip.md @@ -0,0 +1,103 @@ +--- +id: "01-mobile-action-strip" +title: "Build the mobile action strip" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/mobile-quick-chat-topbar/spec.md" +--- + +# Task 01: Build the mobile action strip + +## Intent + +Make the phone Home and Tasks action region compact and horizontally scrollable. Keep Kandev and +the menu fixed. Normalize native, metric, and documented plugin icon geometry. + +## Acceptance + +- Terminal, chat, search, and menu use 32px visible boxes with 16px icons. Topbar metric and + documented plugin icons also use 16px icons. Terminal and chat retain a 44px coarse-pointer hit + area without widening their visible boxes. +- Page context, plugin actions, metrics, terminal, chat, and search use one middle strip. Kandev and + menu stay outside that scroll owner. +- Each directional fade appears only while content remains hidden beyond its edge. +- Desktop and tablet header geometry, state, handlers, and existing public plugin slot fields remain + unchanged. The mobile presentation is exported from the public plugin SDK. + +## Files likely touched + +- `apps/web/components/kanban/mobile-topbar-action-strip.tsx` +- `apps/web/components/kanban/mobile-topbar-action-strip.test.tsx` +- `apps/web/components/kanban/kanban-header-mobile.tsx` +- `apps/web/components/kanban/kanban-header-mobile.test.tsx` +- `apps/web/components/kanban/main-top-bar-plugin-actions.tsx` +- `apps/web/components/kanban/main-top-bar-plugin-actions.test.tsx` +- `apps/web/components/system-metrics/topbar-metrics.tsx` +- `apps/web/components/system-metrics/topbar-metrics.test.tsx` +- `apps/web/lib/plugins/types.ts` +- `apps/packages/plugin-sdk/src/index.ts` +- `apps/web/lib/plugins/sdk-contract.test.ts` +- `docs/plans/plugins/PLUGIN-API.md` +- `docs/public/plugins-authoring.md` + +## Dependencies + +None. + +## Parallelism + +`sequential`. The component, tests, and public contract describe one shared layout behavior. + +## Inputs + +- Spec: `What`, `Scenarios`, and `Out of scope` in + `docs/specs/mobile-quick-chat-topbar/spec.md`. +- Plan: `Confirmed root cause`, `Frontend`, `Mobile design contract`, and `Tests` in `plan.md`. +- Fade exemplar: `apps/web/components/task/chat/chat-input-toolbar-mobile.tsx`. +- Resize exemplar: `apps/web/components/task/task-sidebar-scroll-area.tsx`. +- Shared button sizes: `apps/packages/ui/src/button.tsx`. + +## TDD sequence + +1. Add failing tests for shared sizes, fixed boundaries, title ownership, and directional fades. +2. Run the focused tests and record the expected failures. +3. Add the smallest strip, sizing, and wrapper changes that make the tests pass. +4. Update the plugin reference contract and run the public-doc checks. +5. Run the focused tests, lint, and type check. + +## Verification + +```bash +(cd apps && rtk pnpm install --frozen-lockfile) +(cd apps && rtk pnpm --filter @kandev/web test -- --run components/kanban/mobile-topbar-action-strip.test.tsx components/kanban/kanban-header-mobile.test.tsx components/kanban/main-top-bar-plugin-actions.test.tsx components/system-metrics/topbar-metrics.test.tsx) +(cd apps && rtk pnpm --filter @kandev/web exec eslint components/kanban/mobile-topbar-action-strip.tsx components/kanban/mobile-topbar-action-strip.test.tsx components/kanban/kanban-header-mobile.tsx components/kanban/kanban-header-mobile.test.tsx components/kanban/main-top-bar-plugin-actions.tsx components/kanban/main-top-bar-plugin-actions.test.tsx components/system-metrics/topbar-metrics.tsx components/system-metrics/topbar-metrics.test.tsx lib/plugins/types.ts) +(cd apps/web && rtk pnpm run typecheck) +rtk node --test scripts/validate-public-docs.test.mjs +rtk node scripts/validate-public-docs.mjs +``` + +## Output contract + +Report the changed behavior, files, RED and GREEN results, public-doc result, blockers, and risks. +Update this task and `plan.md` in the same conversation. + +## Results + +- Added the resize-aware mobile action strip with conditional directional fades. +- Kept Kandev and the menu outside the scroll owner, and placed page context, plugin actions, + metrics, terminal, chat, and search inside it. +- Normalized native, metric, and documented host plugin icon geometry without changing desktop + plugin presentation or existing slot data. +- Updated the plugin API reference and public authoring guide with the mobile contract. +- TDD RED: the new focused suite initially failed 9 assertions against the old layout. +- TDD GREEN: focused Vitest passed 4 files and 19 tests. +- Review-fix GREEN: focused Vitest passed 5 files and 20 tests after replacing clipped pseudo-element + hit areas with real 44px interaction wrappers around the 32px launcher buttons. +- Focused ESLint, web typecheck, and public documentation validation passed. +- No blockers. The E2E task provides the remaining production-build coverage. +- Review fixup: exported `MainTopBarSlotProps` from the public SDK, kept the host type canonical, and + added a Pixel 5 `elementFromPoint` regression for the 44px terminal/chat hit areas. +- Follow-up alignment fix: fitting action rows right-align against the fixed menu while overflowing + rows retain their contained horizontal scroll behavior. diff --git a/docs/plans/mobile-topbar-action-strip/task-02-mobile-overflow-e2e.md b/docs/plans/mobile-topbar-action-strip/task-02-mobile-overflow-e2e.md new file mode 100644 index 0000000000..1cd05f92e0 --- /dev/null +++ b/docs/plans/mobile-topbar-action-strip/task-02-mobile-overflow-e2e.md @@ -0,0 +1,82 @@ +--- +id: "02-mobile-overflow-e2e" +title: "Prove mobile overflow behavior" +status: done +wave: 2 +depends_on: ["01-mobile-action-strip"] +plan: "plan.md" +spec: "../../specs/mobile-quick-chat-topbar/spec.md" +--- + +# Task 02: Prove mobile overflow behavior + +## Intent + +Prove the phone header with real metrics and plugin content. Replace the obsolete 44px launcher +expectation with relational geometry while preserving the complete terminal flow. + +## Acceptance + +- Pixel 5 coverage proves the Kandev link and menu stay fixed while middle actions scroll. +- The test proves native control equality, metric and plugin icon sizing, directional fades, and no + document horizontal overflow. +- A scrolled action remains operable. Existing Quick Terminal dialog and focus behavior still pass. +- The test restores user settings, uninstalls the plugin fixture, and leaves no test-owned state. + +## Files likely touched + +- `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts` +- `apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts` + +## Dependencies + +- Task 01 must be complete. + +## Parallelism + +`sequential`. This task verifies Task 01 against the production build. + +## Inputs + +- Spec scenarios for equal sizing, fixed edges, directional fades, and contained overflow. +- Plan section `E2E Tests`. +- Plugin install and cleanup pattern in `apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts`. +- User-settings cleanup rules in `.agents/skills/e2e/references/ui-state-and-cleanup.md` and + `apps/web/AGENTS.md`. + +## TDD sequence + +1. Add the mobile plugin topbar scenario and update the old terminal geometry assertion. +2. Run the focused specs against the current implementation and record the expected failures. +3. Complete any minimal selector or geometry correction required by the specification. +4. Run both focused specs against a fresh production build with retries disabled. + +## Verification + +```bash +(cd apps && rtk pnpm install --frozen-lockfile) +(cd apps/web && rtk pnpm e2e:run --project mobile-chrome tests/plugins/mobile-plugin-topbar.spec.ts -- --retries=0) +(cd apps/web && rtk pnpm e2e:run --no-build --project mobile-chrome tests/terminal/mobile-quick-terminal.spec.ts -- --retries=0) +``` + +## Output contract + +Report test discovery counts, RED and GREEN results, artifact paths, cleanup evidence, blockers, and +risks. Update this task and `plan.md` in the same conversation. + +## Results + +- Added `mobile-plugin-topbar.spec.ts` to the existing `mobile-chrome` Pixel 5 project. +- The test installs the real fixture package, enables real metrics, checks native and plugin + geometry, verifies the actual 44px terminal/chat hit areas, all directional fade states, scroll + reachability, fixed edge positions, and document overflow, then restores settings and removes the + fixture. +- Updated the existing quick-terminal test to compare launcher geometry with the fixed menu instead + of requiring the obsolete 44px launcher size. +- Initial runner attempt caught a duplicate local variable in the edited test and was corrected + before the production run. +- Managed production E2E: 1 plugin topbar test passed with retries disabled. +- Managed no-build regression E2E: 1 quick-terminal test passed with retries disabled. +- Follow-up production-build E2E: 1 plugin topbar test passed with actual 44px hit-target checks; + the quick-terminal regression also passed against that build with no rebuild. +- No PR screenshot artifacts were created by these test runs. diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index 621302bddf..5ac003493f 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -719,9 +719,14 @@ interface PluginRegistry { // carry the active session plus every kandev session id on the task. // "main-top-bar" renders status/actions in the default app top bar on the // Home / Kanban / Tasks views (beside the CPU/DB metrics and the view/display - // controls) and forwards `{ workspaceId, workspaceLabel, currentPage }`. It is - // the app-wide, task-agnostic counterpart to "chat-top-bar", so it carries no - // task/session ids. + // controls) and forwards `{ workspaceId, workspaceLabel, currentPage, + // presentation }`, where presentation is "desktop" or "mobile". On a phone, + // contributions join the horizontally scrollable middle action strip between + // the fixed Kandev link and menu button. Documented host ui.Button icon + // contributions are normalized to a 32px box with a 16px SVG icon on phones; + // desktop contribution sizing is unchanged. It is the app-wide, + // task-agnostic counterpart to "chat-top-bar", so it carries no task/session + // ids. // "sidebar-workspace-actions" renders icon buttons after the built-in Quick // Terminal and Quick Chat actions in the desktop sidebar's New Task row and // in the shared phone navigation sheet. It forwards diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index c2fdf924e1..6caa23af74 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -1524,12 +1524,18 @@ type MainTopBarSlotProps = { workspaceId: string | null; // workspace the top bar is showing, null on global home workspaceLabel?: string; // human-readable workspace name, when known currentPage: "kanban" | "tasks"; + presentation: "desktop" | "mobile"; }; ``` Because the bar is not scoped to a task, no task/session ids are provided. Like `chat-top-bar` it is a compact horizontal strip, so keep contributions to small -badges or `h-7` buttons that match the native metric chips. +badges or icon buttons. On a phone, `presentation` is `"mobile"`; the +contribution joins the horizontally scrollable middle strip between the fixed +Kandev link and menu button. Use `host.ui.Button` for documented icon actions. +The host normalizes those buttons to a 32px box and their SVG icons to 16px. +Do not add a second horizontal scroll container. Desktop contributions keep +their existing sizing. ```js // inside initialize(registry, host): diff --git a/docs/specs/mobile-quick-chat-topbar/spec.md b/docs/specs/mobile-quick-chat-topbar/spec.md index 1436f616dc..83e05adaf2 100644 --- a/docs/specs/mobile-quick-chat-topbar/spec.md +++ b/docs/specs/mobile-quick-chat-topbar/spec.md @@ -1,48 +1,75 @@ --- status: building created: 2026-07-17 +updated: 2026-08-18 owner: kandev --- -# Mobile Quick Chat Topbar +# Mobile Workspace Topbar Actions ## Why -Mobile users cannot discover Quick Chat from the board header and must rely on a keyboard -shortcut or menu path. The same header also repeats `Home` beside the Kandev wordmark even -though the wordmark can provide the home navigation affordance. +Mobile users need direct access to workspace actions from the Home and Tasks headers. The header +must remain usable when resource metrics and plugin actions add more controls than a phone can fit. ## What -- On mobile Home and Tasks headers with an active workspace, an icon button labeled - `Quick Chat` appears immediately before the task-search button. -- Activating the button opens Quick Chat for the active workspace. Existing workspace chats +- On mobile Home and Tasks headers with an active workspace, `Quick Terminal` appears before + `Quick Chat`. `Quick Chat` appears immediately before the task-search button. +- Activating `Quick Chat` opens Quick Chat for the active workspace. Existing workspace chats are available in the dialog, and its existing new-chat action remains available. +- Activating `Quick Terminal` opens the active workspace's terminal in the shared Quick Chat + surface. - The mobile Kandev wordmark is a link to the active workspace's Home board. - The Home header does not render a separate `Home` label. Non-Home headers retain their existing page-title visibility while keeping the Kandev home link available. -- The tablet header and mobile task switcher retain the Quick Chat entry points supplied by - the underlying mobile-access change. +- The Kandev link is the only fixed content at the left edge. The menu button is the only fixed + content at the right edge. +- A non-Home title, its workspace label, plugin actions, resource metrics, `Quick Terminal`, + `Quick Chat`, and search use one horizontal strip between the fixed controls. +- When the strip content fits, its actions are aligned against the fixed menu on the right. The + middle strip scrolls horizontally only when its content does not fit. It does not add horizontal + scrolling to the page. +- A fade appears at each strip edge only when more actions exist beyond that edge. The fade + disappears when the strip reaches that edge. +- Native icon buttons in this header use the same 32 by 32 CSS-pixel visible box and 16 CSS-pixel + icon. Resource metric and host-rendered plugin icons use the same 16 CSS-pixel icon size. +- The resource metrics region uses the same 32 CSS-pixel height as the icon buttons. +- Quick Terminal and Quick Chat retain a 44 CSS-pixel coarse-pointer hit area around their 32 + CSS-pixel visible boxes without widening the action-strip items. +- The tablet header and mobile task switcher retain their existing Quick Chat entry points. - Desktop headers do not change. ## Scenarios - **GIVEN** a mobile Home header with an active workspace, **WHEN** the header renders, **THEN** - the `Quick Chat` button appears immediately to the left of `Search tasks` and no separate - `Home` label is shown. + the terminal, chat, search, and menu controls use the same visible size and no separate `Home` + label is shown. - **GIVEN** a mobile Home header, **WHEN** the user activates `Quick Chat`, **THEN** the active workspace's Quick Chat dialog opens and the user can access existing chats or start a new one. +- **GIVEN** a mobile Home header, **WHEN** the user activates `Quick Terminal`, **THEN** the active + workspace's terminal opens in the shared Quick Chat surface. +- **GIVEN** resource metrics and plugin actions that exceed the middle strip width, **WHEN** the + mobile header renders, **THEN** the Kandev link and menu remain fixed while the strip scrolls. +- **GIVEN** a scrollable middle strip, **WHEN** hidden actions exist to the left or right, **THEN** + only the corresponding directional fade is visible. +- **GIVEN** a middle strip whose actions fit, **WHEN** the mobile header renders, **THEN** its + actions align against the menu, no overflow fade is visible, and the document has no horizontal + overflow. - **GIVEN** a mobile non-Home workbench page, **WHEN** the user activates the Kandev wordmark, **THEN** the app navigates to that workspace's Home board. - **GIVEN** no active workspace, **WHEN** the mobile header renders, **THEN** it does not show an - unusable Quick Chat button. + unusable Quick Chat or Quick Terminal button. ## Out of scope - Changes to Quick Chat creation, persistence, session selection, or modal layout. +- Changes to Quick Terminal lifecycle or terminal layout. - Changes to desktop topbars. -- Changes to search, menu, metrics, or floating task-creation behavior. +- Changes to tablet header sizing. +- Changes to task-session topbars, mobile bottom navigation, or floating task creation. ## Implementation plan -[Mobile Quick Chat Topbar implementation](../../plans/mobile-quick-chat-topbar/plan.md) +- [Original Quick Chat topbar implementation](../../plans/mobile-quick-chat-topbar/plan.md) +- [Scrollable mobile topbar repair](../../plans/mobile-topbar-action-strip/plan.md)