From 3d5c46bbcf445061ca874f9aa155eba596e4c11d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:49:56 +0200 Subject: [PATCH] fix: make plugin sidebar rows as tall as folder rows on phones --- app/globals.css | 11 ++++ .../plugin-frame-touch-height.test.ts | 57 +++++++++++++++++++ lib/__tests__/plugin-ui-kit.test.tsx | 9 +++ lib/plugin-sandbox/host-bridge.ts | 7 +++ lib/plugin-sandbox/host-theme.ts | 1 + lib/plugin-sandbox/ui-kit/components.tsx | 4 +- 6 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 lib/__tests__/plugin-frame-touch-height.test.ts diff --git a/app/globals.css b/app/globals.css index 2c1008564..3ed4e0f32 100644 --- a/app/globals.css +++ b/app/globals.css @@ -68,6 +68,17 @@ --density-header-py: 12px; --density-card-p: 16px; --density-sidebar-py: 4px; + + /* Minimum height of a tappable list row below the lg breakpoint, where + * folder rows use max-lg:min-h-[44px]. Plugin frames get it as a variable + * because their own viewport is the frame, not the page. */ + --touch-row-min-h: 0px; +} + +@media (width < 64rem) { + :root { + --touch-row-min-h: 44px; + } } .dark { diff --git a/lib/__tests__/plugin-frame-touch-height.test.ts b/lib/__tests__/plugin-frame-touch-height.test.ts new file mode 100644 index 000000000..d0b8c130d --- /dev/null +++ b/lib/__tests__/plugin-frame-touch-height.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, it, expect, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import type { ThemeSnapshot } from '../plugin-sandbox/host-theme'; + +// Folder rows are 44px tall below the lg breakpoint. A plugin frame's own +// viewport is the frame, so the page passes that height in as a variable, +// and has to pass it again when the page crosses the breakpoint, which no +// DOM mutation reports. + +const page = vi.hoisted(() => ({ theme: { dark: false, fontFamily: 'sans-serif', vars: { '--touch-row-min-h': '0px' } } as ThemeSnapshot })); + +vi.mock('../plugin-sandbox/host-api', () => ({ dispatchApiCall: vi.fn(async () => 'ok') })); +vi.mock('../plugin-sandbox/host-theme', async (importOriginal) => ({ + ...(await importOriginal()), + snapshotHostTheme: () => page.theme, +})); + +import { createSlotInstance } from '../plugin-sandbox/host-bridge'; + +function plugin(): InstalledPlugin { + return { + id: 'p1', name: 'P', version: '1.0.0', author: '', description: '', type: 'hook', + permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, + }; +} + +const instances: Array<{ destroy(): void }> = []; +afterEach(() => { + for (const i of instances.splice(0)) i.destroy(); +}); + +describe('touch row height in plugin frames', () => { + it('is among the tokens the page mirrors into frames', async () => { + const { snapshotHostTheme } = await vi.importActual('../plugin-sandbox/host-theme'); + document.documentElement.style.setProperty('--touch-row-min-h', '44px'); + try { + expect(snapshotHostTheme().vars['--touch-row-min-h']).toBe('44px'); + } finally { + document.documentElement.style.removeProperty('--touch-row-min-h'); + } + }); + + it('reaches open frames when the page is resized across the breakpoint', async () => { + const inst = createSlotInstance({ + plugin: plugin(), slot: 'sidebar-widget', code: '', locale: 'en', tier: 'untrusted', extraProps: {}, + hostContainer: document.body, onResize: () => {}, + }); + instances.push(inst); + const setTheme = vi.spyOn(inst, 'setTheme'); + + page.theme = { ...page.theme, vars: { '--touch-row-min-h': '44px' } }; + window.dispatchEvent(new Event('resize')); + await new Promise((r) => setTimeout(r, 50)); + + expect(setTheme).toHaveBeenCalledWith(expect.objectContaining({ vars: { '--touch-row-min-h': '44px' } })); + }); +}); diff --git a/lib/__tests__/plugin-ui-kit.test.tsx b/lib/__tests__/plugin-ui-kit.test.tsx index 7b9d44a49..0bd576e43 100644 --- a/lib/__tests__/plugin-ui-kit.test.tsx +++ b/lib/__tests__/plugin-ui-kit.test.tsx @@ -125,6 +125,15 @@ describe('plugin UI kit components', () => { expect(container.querySelector('dd')).toHaveTextContent('2 MB'); }); + it('makes sidebar rows as tall as folder rows on touch screens', () => { + // The page sets --touch-row-min-h to 44px below lg, where folder rows + // are max-lg:min-h-[44px]; the frame can't see the page's width itself. + expect(source('components/layout/sidebar.tsx')).toContain('max-lg:min-h-[44px]'); + expect(source('app/globals.css')).toMatch(/@media \(width < 64rem\) \{\s*:root \{\s*--touch-row-min-h: 44px;/); + render( {}} />); + expect(screen.getByRole('button', { name: 'Anna' }).style.minHeight).toBe('var(--touch-row-min-h, 0px)'); + }); + it('draws a checkbox the user can toggle by its label', () => { const onChange = vi.fn(); render(); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 84c0da1ef..af22c0832 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -129,6 +129,13 @@ function ensureThemeWatcher(): void { }); themeWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] }); themeWatcher.observe(document.head, { childList: true, subtree: true, characterData: true }); + // Breakpoint-dependent tokens (the touch row height) change with the + // page width, which no mutation reports. + window.addEventListener('resize', () => { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { scheduled = false; pushThemeToFrames(); }); + }); } // ─── Sandbox instance ──────────────────────────────────────── diff --git a/lib/plugin-sandbox/host-theme.ts b/lib/plugin-sandbox/host-theme.ts index a97a8d491..eea7411b2 100644 --- a/lib/plugin-sandbox/host-theme.ts +++ b/lib/plugin-sandbox/host-theme.ts @@ -74,6 +74,7 @@ const THEME_TOKENS: readonly string[] = [ '--density-header-py', '--density-card-p', '--density-sidebar-py', + '--touch-row-min-h', ]; // Mirrors the body font stack in app/globals.css. Used when no document is diff --git a/lib/plugin-sandbox/ui-kit/components.tsx b/lib/plugin-sandbox/ui-kit/components.tsx index 61e18d663..7fa9f5ce4 100644 --- a/lib/plugin-sandbox/ui-kit/components.tsx +++ b/lib/plugin-sandbox/ui-kit/components.tsx @@ -787,7 +787,9 @@ export function SidebarRow({ icon, label, detail, onClick, title, ariaLabel }: { title={title} aria-label={ariaLabel} className={cn('bk w-full flex items-center gap-2 ps-[28px] pe-3 text-sm text-foreground text-start border-s-2 border-transparent transition-colors duration-150', onClick && 'hover:bg-muted/50 focus-visible:outline-none focus-visible:bg-muted/50')} - style={{ paddingBlock: 'var(--density-sidebar-py)' }} + // Folder rows are 44px tall on phones (max-lg); the frame's own + // viewport can't tell, so the page passes the height in. + style={{ paddingBlock: 'var(--density-sidebar-py)', minHeight: 'var(--touch-row-min-h, 0px)' }} > {icon && } {label}