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);
+});