diff --git a/app/templates/_header.html b/app/templates/_header.html index 2fdd79c..6cf325f 100644 --- a/app/templates/_header.html +++ b/app/templates/_header.html @@ -37,7 +37,7 @@ {% if not hide_hamburger %} diff --git a/app/templates/base.html b/app/templates/base.html index da33e31..30235c7 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1944,6 +1944,17 @@ this.sidebarCollapsed = !this.sidebarCollapsed; }, + // Hamburger: mobile opens the drawer (assignment expressions are illegal + // in the Alpine CSP build, hence this method), desktop toggles collapse. + fdHamburgerClick() { + if (window.matchMedia('(max-width: 768px)').matches) { + this.mobileSidebarOpen = true; + this.sidebarCollapsed = false; + } else { + this.toggleSidebar(); + } + }, + closeSidebar() { if (this.sidebarPeek) { this.sidebarPeek = false; return; } this.sidebarCollapsed = true; diff --git a/app/templates/settings.html b/app/templates/settings.html index c8bf7fc..6703830 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -125,6 +125,21 @@ .llm-detail .prov-field-row{margin-top:12px;} .llm-detail-empty{display:flex;align-items:center;justify-content:center;height:260px;font-size:13px;color:var(--text-dim);text-align:center;padding:0 20px;} @media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}} + +/* ── Mobile: full-screen panel + horizontal nav tabs ── */ +@media (max-width:768px){ + .settings-overlay{background:var(--bg-primary);backdrop-filter:none;} + .settings-panel{flex-direction:column;width:100vw;max-width:100vw;height:100dvh;max-height:100dvh;margin:0;border:none;border-radius:0;box-shadow:none;} + .settings-nav{width:auto;min-width:0;flex-direction:row;flex-shrink:0;display:flex;align-items:center;overflow-x:auto;border-right:none;border-bottom:1px solid var(--border);padding:6px 8px;gap:4px;overscroll-behavior-x:contain;} + .settings-nav-header{display:none;} + .settings-nav-item{white-space:nowrap;padding:8px 12px;border-radius:8px;flex-shrink:0;} + .settings-content{padding:16px;} + .settings-close{top:8px;right:8px;width:44px;height:44px;background:var(--bg-tertiary);} + .setting-row{flex-wrap:wrap;} + .setting-control{margin-left:0;width:100%;} + .prov-grid{grid-template-columns:1fr;} + .avatar-section{gap:10px;} +} diff --git a/e2e/mobile_regression.spec.js b/e2e/mobile_regression.spec.js new file mode 100644 index 0000000..c4dd20d --- /dev/null +++ b/e2e/mobile_regression.spec.js @@ -0,0 +1,74 @@ +// Mobile regression: hamburger opens the sidebar drawer, settings page fits viewport. +const { test, expect } = require('@playwright/test'); +const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080'; +const USER = process.env.FD_USER || 'e2e@flowdeck.test'; +const PASS = process.env.FD_PASS || 'e2e-pass-123'; + +async function login(page) { + await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' }); + await page.fill('#email', USER); + await page.fill('#password', PASS); + await page.click('.btn-primary'); + const ok = await page.waitForURL('**/workspaces', { timeout: 10000 }).then(() => true).catch(() => false); + if (!ok) { + const resp = await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E Mobile' } }); + if (!resp.ok() && resp.status() !== 409) throw new Error(`register ${resp.status()}`); + await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' }); + await page.fill('#email', USER); + await page.fill('#password', PASS); + await page.click('.btn-primary'); + await page.waitForURL('**/workspaces', { timeout: 10000 }); + } +} + +test.describe('mobile', () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test('hamburger opens mobile sidebar drawer', async ({ page }) => { + const errs = []; + page.on('pageerror', (e) => errs.push(e.message)); + await login(page); + await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' }); + + const burger = page.locator('.hamburger-btn'); + await expect(burger).toBeVisible(); + + // Sidebar starts off-canvas + const sidebar = page.locator('#sidebar'); + const boxBefore = await sidebar.boundingBox(); + expect(boxBefore.x).toBeLessThan(0); + + await burger.click(); + await expect(sidebar).toHaveClass(/mobile-open/, { timeout: 5000 }); + const boxAfter = await sidebar.boundingBox(); + expect(boxAfter.x).toBeGreaterThanOrEqual(0); + expect(errs).toEqual([]); + + // Overlay tap closes it + await page.locator('.sidebar-overlay').click({ position: { x: 350, y: 400 } }); + await expect(sidebar).not.toHaveClass(/mobile-open/, { timeout: 5000 }); + }); + + test('settings page fits mobile viewport', async ({ page }) => { + await login(page); + await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' }); + const panel = page.locator('.settings-panel'); + await expect(panel).toBeVisible(); + + const box = await panel.boundingBox(); + expect(box.width).toBeLessThanOrEqual(390); + expect(box.height).toBeLessThanOrEqual(844); + + // Nav becomes horizontal tabs: nav wider than tall + const navBox = await page.locator('.settings-nav').boundingBox(); + expect(navBox.width).toBeGreaterThan(navBox.height); + + // Tapping a nav item still switches sections + await page.locator('.settings-nav-item', { hasText: 'Tags' }).click(); + await expect(page.locator('h2', { hasText: 'Tags' }).first()).toBeVisible(); + + // No horizontal page overflow + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(0); + }); +});