From 6e922fd1cbb48e221a12c3acd5caa99da5751a8b Mon Sep 17 00:00:00 2001 From: juanatsap Date: Thu, 9 Apr 2026 12:21:28 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20mobile-first=20chat=20layout=20?= =?UTF-8?q?=E2=80=94=20split=20mode,=20hidden=20desktop=20modes,=2079=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile (≤480px): - Hide Side Panel, Floating, Full Screen buttons - Show Split button (50vh vertical split, CV visible above) - Compact mode: 55vh max bottom sheet - Force desktop modes to compact if somehow activated - Disable tooltips on mobile (overflow prevention) - Tighter header padding Desktop (>480px): - Split button hidden, all modes available as before Tests: 85-chat-mobile.test.mjs — 79 assertions across iPhone SE (320x568), iPhone 14 (393x852), iPhone 14 Pro Max (430x932), plus desktop sanity check --- static/css/04-interactive/_chat.css | 74 ++++++- templates/partials/widgets/chat-widget.html | 5 +- tests/mjs/85-chat-mobile.test.mjs | 227 ++++++++++++++++++++ 3 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 tests/mjs/85-chat-mobile.test.mjs diff --git a/static/css/04-interactive/_chat.css b/static/css/04-interactive/_chat.css index 1189206..644fb32 100644 --- a/static/css/04-interactive/_chat.css +++ b/static/css/04-interactive/_chat.css @@ -783,12 +783,13 @@ ========================================================================== */ @media (max-width: 480px) { + /* Default compact: bottom sheet */ .chat-panel { bottom: 0; left: 0; right: 0; width: 100%; - max-height: 70vh; + max-height: 55vh; border-radius: 8px 8px 0 0; } @@ -798,6 +799,75 @@ } .chat-messages { - max-height: 200px; + max-height: 180px; + } + + /* Hide desktop-only layout modes on mobile */ + .chat-mode-btn[data-mode="chat-half"], + .chat-mode-btn[data-mode="chat-float"], + .chat-mode-btn[data-mode="chat-full"] { + display: none; + } + + /* Show mobile split button */ + .chat-mode-btn[data-mode="chat-split"] { + display: flex; + } + + /* Mobile split: CV on top, chat on bottom half */ + .chat-panel.chat-split { + bottom: 0; + left: 0; + right: 0; + top: auto; + width: 100%; + height: 50vh; + max-height: 50vh; + border-radius: 8px 8px 0 0; + border-top: 2px solid var(--accent-green, #27ae60); + } + + .chat-panel.chat-split .chat-messages { + max-height: none; + flex: 1; + } + + /* Force compact on mobile for desktop-only modes (safety) */ + .chat-panel.chat-half, + .chat-panel.chat-float, + .chat-panel.chat-full { + bottom: 0; + left: 0; + right: 0; + top: auto; + width: 100%; + height: auto; + max-height: 55vh; + border-radius: 8px 8px 0 0; + border: 1px solid var(--border-light, #e0e0e0); + resize: none; + } + + /* Tooltips: prevent overflow on mobile */ + .chat-mode-btn[title]:hover::after { + display: none; + } + + /* Tighter header on mobile */ + .chat-header { + padding: 8px 10px; + font-size: 0.8rem; + } + + .chat-mode-btn { + font-size: 0.85rem; + padding: 3px; + } +} + +/* Hide mobile split button on desktop */ +@media (min-width: 481px) { + .chat-mode-btn[data-mode="chat-split"] { + display: none; } } diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index 56eb311..9ae288f 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -20,6 +20,9 @@ + @@ -216,7 +219,7 @@ document.addEventListener('htmx:afterRequest', function(event) { // Set chat panel size function setChatSize(size) { var panel = document.getElementById('chat-panel'); - panel.classList.remove('chat-half', 'chat-full', 'chat-float'); + panel.classList.remove('chat-half', 'chat-full', 'chat-float', 'chat-split'); // Reset all inline styles from drag/resize panel.style.top = ''; panel.style.left = ''; diff --git a/tests/mjs/85-chat-mobile.test.mjs b/tests/mjs/85-chat-mobile.test.mjs new file mode 100644 index 0000000..f8bb0bf --- /dev/null +++ b/tests/mjs/85-chat-mobile.test.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env bun +/** + * CHAT MOBILE LAYOUT TEST + * ======================== + * Tests that the chat widget behaves correctly on mobile viewports: + * - Only Compact and Split modes available + * - Desktop-only modes (side panel, floating, full) hidden + * - CV always visible (no full takeover) + * - Bottom sheet positioning + * - Split mode: 50vh with CV visible above + * + * Tested viewports: iPhone SE (320x568), iPhone 14 (393x852), iPhone 14 Pro Max (430x932) + */ + +import { chromium } from 'playwright'; + +const URL = "http://localhost:1999"; + +const VIEWPORTS = [ + { name: 'iPhone SE', width: 320, height: 568 }, + { name: 'iPhone 14', width: 393, height: 852 }, + { name: 'iPhone 14 Pro Max', width: 430, height: 932 }, +]; + +async function testChatMobile() { + console.log('šŸ“± CHAT MOBILE LAYOUT TEST\n'); + console.log('='.repeat(70)); + + const browser = await chromium.launch({ headless: true }); + + let passed = 0; + let failed = 0; + + function record(name, ok, detail = '') { + ok ? passed++ : failed++; + console.log(` ${ok ? 'āœ…' : 'āŒ'} ${name}${detail ? ' — ' + detail : ''}`); + } + + for (const vp of VIEWPORTS) { + console.log(`\nšŸ“± ${vp.name} (${vp.width}x${vp.height})`); + console.log('-'.repeat(50)); + + const page = await browser.newPage({ viewport: { width: vp.width, height: vp.height } }); + await page.goto(`${URL}/?lang=en`); + await page.waitForTimeout(1500); + + // ================================================================ + // 1. TOGGLE BUTTON + // ================================================================ + console.log(`\n 1ļøāƒ£ Toggle Button`); + + const btnVisible = await page.locator('#chat-toggle-btn').isVisible(); + record(`${vp.name}: toggle button visible`, btnVisible); + + const btnBox = await page.locator('#chat-toggle-btn').boundingBox(); + record(`${vp.name}: button within viewport`, btnBox && btnBox.x + btnBox.width <= vp.width, + `right=${Math.round(btnBox?.x + btnBox?.width)}`); + + // ================================================================ + // 2. OPEN CHAT — COMPACT MODE + // ================================================================ + console.log(`\n 2ļøāƒ£ Compact Mode (default)`); + + await page.evaluate(() => document.getElementById('chat-toggle-btn').click()); + await page.waitForTimeout(400); + + const panelVisible = await page.locator('#chat-panel.chat-open').isVisible(); + record(`${vp.name}: panel opens`, panelVisible); + + const panelBox = await page.locator('#chat-panel').boundingBox(); + record(`${vp.name}: full width`, panelBox && panelBox.width >= vp.width - 2, + `width=${panelBox?.width}`); + record(`${vp.name}: anchored to bottom`, panelBox && (panelBox.y + panelBox.height) >= vp.height - 2, + `bottom=${Math.round(panelBox?.y + panelBox?.height)}`); + record(`${vp.name}: max 55% viewport height`, panelBox && panelBox.height <= vp.height * 0.56, + `height=${Math.round(panelBox?.height)} (max=${Math.round(vp.height * 0.55)})`); + + // CV is visible above chat + const cvVisible = panelBox && panelBox.y > 50; + record(`${vp.name}: CV visible above (y > 50px)`, cvVisible, `y=${Math.round(panelBox?.y)}`); + + // ================================================================ + // 3. HEADER BUTTONS — DESKTOP MODES HIDDEN + // ================================================================ + console.log(`\n 3ļøāƒ£ Header Buttons`); + + const compactBtn = await page.locator('.chat-mode-btn[data-mode=""]').isVisible(); + record(`${vp.name}: compact button visible`, compactBtn); + + const splitBtn = await page.locator('.chat-mode-btn[data-mode="chat-split"]').isVisible(); + record(`${vp.name}: split button visible`, splitBtn); + + const halfHidden = await page.locator('.chat-mode-btn[data-mode="chat-half"]').isHidden(); + record(`${vp.name}: side panel button hidden`, halfHidden); + + const floatHidden = await page.locator('.chat-mode-btn[data-mode="chat-float"]').isHidden(); + record(`${vp.name}: floating button hidden`, floatHidden); + + const fullHidden = await page.locator('.chat-mode-btn[data-mode="chat-full"]').isHidden(); + record(`${vp.name}: full screen button hidden`, fullHidden); + + const helpVisible = await page.locator('.chat-mode-btn[command="show-modal"]').isVisible(); + record(`${vp.name}: help button visible`, helpVisible); + + // ================================================================ + // 4. SPLIT MODE — 50vh + // ================================================================ + console.log(`\n 4ļøāƒ£ Split Mode`); + + await page.click('.chat-mode-btn[data-mode="chat-split"]'); + await page.waitForTimeout(400); + + const splitClass = await page.locator('#chat-panel.chat-split').count(); + record(`${vp.name}: has chat-split class`, splitClass === 1); + + const splitBox = await page.locator('#chat-panel').boundingBox(); + const expectedSplitH = vp.height * 0.5; + record(`${vp.name}: split ~50vh height`, + splitBox && Math.abs(splitBox.height - expectedSplitH) < 20, + `height=${Math.round(splitBox?.height)} (expected ~${Math.round(expectedSplitH)})`); + record(`${vp.name}: split anchored to bottom`, + splitBox && (splitBox.y + splitBox.height) >= vp.height - 2, + `bottom=${Math.round(splitBox?.y + splitBox?.height)}`); + record(`${vp.name}: CV visible above split (top ~50%)`, + splitBox && splitBox.y >= expectedSplitH - 20, + `y=${Math.round(splitBox?.y)}`); + record(`${vp.name}: split full width`, splitBox && splitBox.width >= vp.width - 2, + `width=${splitBox?.width}`); + + // Messages area should expand + const splitMsgBox = await page.locator('#chat-panel .chat-messages').boundingBox(); + record(`${vp.name}: split messages expanded`, splitMsgBox && splitMsgBox.height > 100, + `height=${Math.round(splitMsgBox?.height)}`); + + // Split active button + const splitActive = await page.locator('.chat-mode-btn[data-mode="chat-split"].active').count(); + record(`${vp.name}: split button is active`, splitActive === 1); + + // ================================================================ + // 5. BACK TO COMPACT + // ================================================================ + console.log(`\n 5ļøāƒ£ Back to Compact`); + + await page.click('.chat-mode-btn[data-mode=""]'); + await page.waitForTimeout(400); + + const backBox = await page.locator('#chat-panel').boundingBox(); + record(`${vp.name}: compact restored`, backBox && backBox.height <= vp.height * 0.56, + `height=${Math.round(backBox?.height)}`); + + const noSplit = await page.locator('#chat-panel.chat-split').count(); + record(`${vp.name}: no split class`, noSplit === 0); + + // ================================================================ + // 6. INPUT & SEND BUTTON FIT + // ================================================================ + console.log(`\n 6ļøāƒ£ Input Area`); + + const inputBox = await page.locator('#chat-input').boundingBox(); + record(`${vp.name}: input within viewport`, + inputBox && inputBox.x >= 0 && (inputBox.x + inputBox.width) <= vp.width, + `x=${Math.round(inputBox?.x)} w=${Math.round(inputBox?.width)}`); + + const sendBox = await page.locator('.chat-send-btn').boundingBox(); + record(`${vp.name}: send button within viewport`, + sendBox && (sendBox.x + sendBox.width) <= vp.width, + `right=${Math.round(sendBox?.x + sendBox?.width)}`); + + // ================================================================ + // 7. CHIPS NOT OVERFLOWING + // ================================================================ + console.log(`\n 7ļøāƒ£ Chips`); + + const chipsBox = await page.locator('.chat-suggestions').boundingBox(); + record(`${vp.name}: chips within viewport`, + chipsBox && chipsBox.width <= vp.width, + `width=${Math.round(chipsBox?.width)}`); + + await page.close(); + } + + // ====================================================================== + // DESKTOP SANITY CHECK — split button hidden, others visible + // ====================================================================== + console.log(`\nšŸ–„ļø Desktop Sanity Check (1920x1080)`); + console.log('-'.repeat(50)); + + const desktopPage = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); + await desktopPage.goto(`${URL}/?lang=en`); + await desktopPage.waitForTimeout(1500); + await desktopPage.evaluate(() => document.getElementById('chat-toggle-btn').click()); + await desktopPage.waitForTimeout(400); + + const dSplitHidden = await desktopPage.locator('.chat-mode-btn[data-mode="chat-split"]').isHidden(); + record('Desktop: split button hidden', dSplitHidden); + + const dHalfVisible = await desktopPage.locator('.chat-mode-btn[data-mode="chat-half"]').isVisible(); + record('Desktop: side panel button visible', dHalfVisible); + + const dFloatVisible = await desktopPage.locator('.chat-mode-btn[data-mode="chat-float"]').isVisible(); + record('Desktop: floating button visible', dFloatVisible); + + const dFullVisible = await desktopPage.locator('.chat-mode-btn[data-mode="chat-full"]').isVisible(); + record('Desktop: full screen button visible', dFullVisible); + + await desktopPage.close(); + + // ====================================================================== + // SUMMARY + // ====================================================================== + console.log('\n' + '='.repeat(70)); + console.log(`\nšŸ“Š RESULTS: ${passed} passed, ${failed} failed, ${passed + failed} total`); + + if (failed > 0) { + console.log('\nāŒ SOME TESTS FAILED'); + } else { + console.log('\nāœ… ALL TESTS PASSED'); + } + + await browser.close(); + process.exit(failed > 0 ? 1 : 0); +} + +testChatMobile().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +});