From 3612fdc6c0d8a7d4e2299e89f739c8b9d07b31a6 Mon Sep 17 00:00:00 2001 From: Brian Genisio Date: Wed, 14 Oct 2026 07:37:22 -0400 Subject: [PATCH] Make the compose panel resizable. On short viewports the reply composer covers the thread. A top-edge handle lets you shrink it, and the chosen height is remembered for the session. Co-authored-by: Cursor --- i18n/en.json | 1 + i18n/es.json | 1 + public/app.css | 116 ++++++++++++++++++++++++- public/app.js | 181 +++++++++++++++++++++++++++++++++++++++- public/index.html | 13 +-- tests/a11y-mail.test.js | 101 ++++++++++++++++++++++ 6 files changed, 405 insertions(+), 8 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index a1ffe0a..2117e49 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -61,6 +61,7 @@ "Thinking…": "Thinking…", "Working…": "Working…", "Resize AI Assistant panel": "Resize AI Assistant panel", + "Resize compose panel": "Resize compose panel", "Preview attachment": "Preview attachment", "Attachment": "Attachment", "Message the AI Assistant": "Message the AI Assistant", diff --git a/i18n/es.json b/i18n/es.json index a88a650..333f658 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -62,6 +62,7 @@ "Thinking…": "Pensando…", "Working…": "Trabajando…", "Resize AI Assistant panel": "Cambiar el tamaño del panel del Asistente de IA", + "Resize compose panel": "Cambiar el tamaño del panel de redacción", "Preview attachment": "Vista previa del archivo adjunto", "Attachment": "Archivo adjunto", "Message the AI Assistant": "Enviar un mensaje al Asistente de IA", diff --git a/public/app.css b/public/app.css index 60c8ecd..9274e91 100644 --- a/public/app.css +++ b/public/app.css @@ -1091,6 +1091,57 @@ body { flex: 1 1 auto; } +/* Top-edge grabber: 24px hit target (WCAG 2.5.8), compact in layout. */ +.composer__resize { + box-sizing: content-box; + position: relative; + flex: 0 0 4px; + height: 4px; + padding: 10px 0; + margin: -12px 0 -4px; + cursor: ns-resize; + touch-action: none; + user-select: none; + -webkit-user-select: none; + z-index: 1; +} + +.composer__resize-handle { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: 4px; + border-radius: 2px; + background: var(--Colors-Border-Medium); + pointer-events: none; +} + +.composer__resize:hover .composer__resize-handle, +.composer__resize:focus-visible .composer__resize-handle, +.composer__resize.is-dragging .composer__resize-handle { + height: 6px; + background: var(--Colors-Base-Primary-700); +} + +.composer__resize:focus-visible { + outline: none; +} + +.composer__resize:focus-visible::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 48px; + height: 16px; + transform: translate(-50%, -50%); + border-radius: var(--UI-Radius-radius-xxs); + outline: 2px solid var(--Colors-Stroke-Primary); + outline-offset: 2px; +} + @media (prefers-color-scheme: dark) { .composer { background: var(--Colors-Surface-Container-Subtle-Alt); @@ -1117,6 +1168,28 @@ body { overflow-y: auto; } +/* User-chosen height: the body flexes and the fields scroll so Send stays put. */ +.composer.is-inline.is-resized, +.composer.is-new.is-resized { + height: var(--composer-height); + max-height: min(var(--composer-height), calc(100% - 64px - var(--UI-Spacing-spacing-mxs))); + overflow: hidden; +} + +.composer.is-resized .composer__panel { + overflow-y: auto; +} + +.composer.is-resized .composer__body { + flex: 1 1 auto; + min-height: 80px; + max-height: none; +} + +.composer.is-resizing .composer__panel { + pointer-events: none; +} + /* Head: icon · title · close */ .composer__head { display: flex; @@ -1448,12 +1521,12 @@ body { color: var(--Colors-Text-Body-Light); } -/* Buttons row (Figma): Send then Cancel, 16px apart, 15px under the box */ +/* Buttons row (Figma): Send then Cancel, 16px apart, pinned under the panel */ .composer__toolbar { display: flex; align-items: center; gap: var(--UI-Spacing-spacing-ms); - margin-top: 7px; + flex: 0 0 auto; } .composer .button.composer__send { @@ -2462,4 +2535,43 @@ body { .email__date { text-align: left; } + + /* Leave room to read the thread; the handle can grow the composer again. */ + .composer.is-inline:not(.is-resized), + .composer.is-new:not(.is-resized) { + max-height: min(20rem, 48vh, calc(100% - 88px)); + overflow: hidden; + } + + .composer.is-inline .composer__panel, + .composer.is-new .composer__panel { + overflow-y: auto; + } + + .composer.is-inline .composer__body, + .composer.is-new .composer__body { + flex: 1 1 auto; + min-height: 80px; + max-height: none; + } +} + +@media (max-height: 720px) { + .composer.is-inline:not(.is-resized), + .composer.is-new:not(.is-resized) { + max-height: min(20rem, 48vh, calc(100% - 88px)); + overflow: hidden; + } + + .composer.is-inline .composer__panel, + .composer.is-new .composer__panel { + overflow-y: auto; + } + + .composer.is-inline .composer__body, + .composer.is-new .composer__body { + flex: 1 1 auto; + min-height: 80px; + max-height: none; + } } diff --git a/public/app.js b/public/app.js index 07a11f7..a375f23 100644 --- a/public/app.js +++ b/public/app.js @@ -157,6 +157,7 @@ const state = { }, characterSessions: {}, attachments: [], + composerHeight: null, // px; null keeps the CSS default mailSplit: null, railDrawerOpen: false, assistantDrawerOpen: false, @@ -192,6 +193,7 @@ const els = { mailViewStatus: document.getElementById('mailViewStatus'), composerStatus: document.getElementById('composerStatus'), composer: document.getElementById('composer'), + composerResize: document.getElementById('composerResize'), composerChrome: document.getElementById('composerChrome'), composerTitle: document.getElementById('composerTitle'), composerIcon: document.getElementById('composerIcon'), @@ -1209,6 +1211,7 @@ function applyView() { els.composerCloseBtn.setAttribute('aria-label', t('Close')); els.composerCloseBtn.title = t('Close'); } + syncComposerResize(); if (els.discardBtn) { els.discardBtn.hidden = !showComposer; els.discardBtn.textContent = t('Cancel'); @@ -2176,6 +2179,7 @@ function applyScenarioChrome() { if (els.mailboxList) els.mailboxList.setAttribute('aria-label', t('Folders')); if (els.mailList) els.mailList.setAttribute('aria-label', t('Mail')); if (els.readingPane) els.readingPane.setAttribute('aria-label', t('Conversation')); + labelComposerResize(); if (els.assistantHint && state.config?.assistant?.initialMessage) { els.assistantHint.textContent = state.config.assistant.initialMessage; } else if (els.assistantHint) { @@ -2300,6 +2304,174 @@ function scheduleDraftSave() { state.draftSaveTimer = setTimeout(saveDraftNow, 600); } +const COMPOSER_HEIGHT_KEY = 'cosmoMail.composerHeight'; +const COMPOSER_MIN_HEIGHT = 160; + +function composerHostHeight() { + const host = els.composer?.parentElement; + const fromHost = host?.clientHeight || 0; + if (fromHost >= COMPOSER_MIN_HEIGHT + 40) return fromHost; + return window.innerHeight || 800; +} + +function composerMaxHeight() { + const reserve = els.composer?.parentElement === els.readingPane ? 96 : 80; + return Math.max(COMPOSER_MIN_HEIGHT, Math.round(composerHostHeight() - reserve)); +} + +function clampComposerHeight(height) { + return Math.round(Math.min(composerMaxHeight(), Math.max(COMPOSER_MIN_HEIGHT, Number(height) || COMPOSER_MIN_HEIGHT))); +} + +function readStoredComposerHeight() { + try { + const value = Number(sessionStorage.getItem(COMPOSER_HEIGHT_KEY)); + if (Number.isFinite(value) && value >= COMPOSER_MIN_HEIGHT) return value; + } catch { + /* ignore */ + } + return null; +} + +function labelComposerResize() { + const handle = els.composerResize; + if (!handle) return; + const label = t('Resize compose panel'); + handle.setAttribute('aria-label', label); + handle.title = label; +} + +function updateComposerResizeAria() { + const handle = els.composerResize; + if (!handle) return; + labelComposerResize(); + const min = COMPOSER_MIN_HEIGHT; + const max = composerMaxHeight(); + const now = clampComposerHeight(state.composerHeight ?? els.composer?.offsetHeight ?? min); + const span = Math.max(1, max - min); + const pct = Math.round(((now - min) / span) * 100); + handle.setAttribute('aria-orientation', 'horizontal'); + handle.setAttribute('aria-valuemin', String(min)); + handle.setAttribute('aria-valuemax', String(max)); + handle.setAttribute('aria-valuenow', String(now)); + handle.setAttribute('aria-valuetext', `${pct}%`); +} + +function applyComposerHeight(height, { persist = true } = {}) { + if (!els.composer) return null; + if (height == null) { + state.composerHeight = null; + els.composer.style.removeProperty('--composer-height'); + els.composer.classList.remove('is-resized'); + updateComposerResizeAria(); + return null; + } + const next = clampComposerHeight(height); + state.composerHeight = next; + els.composer.style.setProperty('--composer-height', `${next}px`); + els.composer.classList.add('is-resized'); + if (persist) { + try { + sessionStorage.setItem(COMPOSER_HEIGHT_KEY, String(next)); + } catch { + /* ignore */ + } + } + updateComposerResizeAria(); + syncCoveredFocus(); + return next; +} + +function currentComposerHeight() { + return state.composerHeight ?? els.composer?.offsetHeight ?? COMPOSER_MIN_HEIGHT; +} + +function syncComposerResize() { + if (state.composerHeight != null) applyComposerHeight(state.composerHeight, { persist: false }); + else updateComposerResizeAria(); +} + +function clearComposerHeight() { + try { + sessionStorage.removeItem(COMPOSER_HEIGHT_KEY); + } catch { + /* ignore */ + } + applyComposerHeight(null, { persist: false }); +} + +function initComposerResize() { + const handle = els.composerResize; + if (!handle || initComposerResize.done) { + syncComposerResize(); + return; + } + initComposerResize.done = true; + + const stored = readStoredComposerHeight(); + if (stored != null) applyComposerHeight(stored, { persist: false }); + else updateComposerResizeAria(); + + let dragging = false; + let startY = 0; + let startHeight = 0; + + function endDrag() { + if (!dragging) return; + dragging = false; + handle.classList.remove('is-dragging'); + els.composer?.classList.remove('is-resizing'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.body.style.webkitUserSelect = ''; + applyComposerHeight(currentComposerHeight(), { persist: true }); + } + + handle.addEventListener('pointerdown', (event) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + event.preventDefault(); + dragging = true; + startY = event.clientY; + startHeight = currentComposerHeight(); + handle.classList.add('is-dragging'); + els.composer?.classList.add('is-resizing'); + document.body.style.cursor = 'ns-resize'; + document.body.style.userSelect = 'none'; + document.body.style.webkitUserSelect = 'none'; + handle.setPointerCapture?.(event.pointerId); + }); + + document.addEventListener('pointermove', (event) => { + if (!dragging) return; + event.preventDefault(); + applyComposerHeight(startHeight + (startY - event.clientY), { persist: false }); + }); + + document.addEventListener('pointerup', endDrag); + document.addEventListener('pointercancel', endDrag); + + handle.addEventListener('keydown', (event) => { + const step = event.shiftKey ? 48 : 16; + if (event.key === 'ArrowUp') { + event.preventDefault(); + applyComposerHeight(currentComposerHeight() + step); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + applyComposerHeight(currentComposerHeight() - step); + } else if (event.key === 'Home') { + event.preventDefault(); + applyComposerHeight(composerMaxHeight()); + } else if (event.key === 'End') { + event.preventDefault(); + applyComposerHeight(COMPOSER_MIN_HEIGHT); + } + }); + + window.addEventListener('resize', () => { + syncComposerResize(); + }); +} + function initComposer() { const draft = computeInitialDraft(); applyRecipientDraft(draft); @@ -2344,6 +2516,7 @@ function initComposer() { els.toolbarComposeBtn?.addEventListener('click', () => startCompose({ blank: true })); els.discardBtn?.addEventListener('click', cancelComposer); els.composerCloseBtn?.addEventListener('click', closeComposer); + initComposerResize(); updateSendEnabled(); } @@ -4332,7 +4505,10 @@ async function boot() { // The browser bundle boots on load. Vitest imports this module after mounting // public/index.html, and drives the real UI from there. if (globalThis.process?.env?.VITEST !== 'true') boot(); -else initShellDrawers(); +else { + initShellDrawers(); + initComposerResize(); +} initSkipToMail(); /** Live mailbox behaviors for the accessibility regression tests. */ @@ -4363,6 +4539,9 @@ export function mailAppTestHooks() { startCompose, closeComposer, cancelComposer, + applyComposerHeight, + clearComposerHeight, + initComposerResize, deleteDraft, saveDraftNow, resetDraftWriteQueue, diff --git a/public/index.html b/public/index.html index 5633110..e5b88c5 100644 --- a/public/index.html +++ b/public/index.html @@ -109,6 +109,9 @@

the same accessible semantics a
would have had. --> +
+ + +
diff --git a/tests/a11y-mail.test.js b/tests/a11y-mail.test.js index f1b34be..5e440c3 100644 --- a/tests/a11y-mail.test.js +++ b/tests/a11y-mail.test.js @@ -165,6 +165,7 @@ async function resetMailbox() { state.recipientQuery = { to: '', cc: '' }; state.recipientActiveIndex = 0; state.attachments = []; + mail.clearComposerHeight(); statusEl().textContent = ''; const composerStatus = document.getElementById('composerStatus'); if (composerStatus) composerStatus.textContent = ''; @@ -222,6 +223,7 @@ describe('English catalog', () => { 'Upload failed': 'Upload failed', 'File type not allowed': 'File type not allowed', 'Message the AI Assistant': 'Message the AI Assistant', + 'Resize compose panel': 'Resize compose panel', }); }); }); @@ -1939,6 +1941,105 @@ describe('date stamps', () => { }); }); +describe('composer resize', () => { + function handle() { + return document.getElementById('composerResize'); + } + + function key(el, keyName, { shiftKey = false } = {}) { + const event = new KeyboardEvent('keydown', { key: keyName, shiftKey, bubbles: true, cancelable: true }); + el.dispatchEvent(event); + return event; + } + + it('exposes a labeled horizontal separator on the compose panel', () => { + showComposer(); + mail.applyScenarioChrome(); + const resize = handle(); + expect(resize).toBeTruthy(); + expect(resize.getAttribute('role')).toBe('separator'); + expect(resize.getAttribute('aria-orientation')).toBe('horizontal'); + expect(resize.tabIndex).toBe(0); + expect(computeAccessibleName(resize)).toBe(strings['Resize compose panel']); + expect(document.getElementById('composer').contains(resize)).toBe(true); + }); + + it('localizes the resize control', () => { + mail.state.config.strings = { + ...strings, + 'Resize compose panel': 'Cambiar el tamaño del panel de redacción', + }; + mail.applyScenarioChrome(); + expect(computeAccessibleName(handle())).toBe('Cambiar el tamaño del panel de redacción'); + }); + + it('shrinks and grows the panel from the keyboard and remembers the height', () => { + showComposer(); + const composer = document.getElementById('composer'); + Object.defineProperty(composer, 'offsetHeight', { configurable: true, value: 360 }); + + mail.applyComposerHeight(360); + expect(composer.classList.contains('is-resized')).toBe(true); + expect(composer.style.getPropertyValue('--composer-height')).toBe('360px'); + + const down = key(handle(), 'ArrowDown'); + expect(down.defaultPrevented).toBe(true); + expect(mail.state.composerHeight).toBe(344); + expect(composer.style.getPropertyValue('--composer-height')).toBe('344px'); + expect(sessionStorage.getItem('cosmoMail.composerHeight')).toBe('344'); + + key(handle(), 'ArrowUp', { shiftKey: true }); + expect(mail.state.composerHeight).toBe(392); + expect(handle().getAttribute('aria-valuenow')).toBe('392'); + }); + + it('clamps Home and End to the available range', () => { + showComposer(); + setViewport(1280, 800); + mail.applyComposerHeight(300); + key(handle(), 'End'); + expect(mail.state.composerHeight).toBe(160); + expect(handle().getAttribute('aria-valuemin')).toBe('160'); + + key(handle(), 'Home'); + const max = Number(handle().getAttribute('aria-valuemax')); + expect(mail.state.composerHeight).toBe(max); + expect(max).toBeGreaterThan(160); + }); + + it('follows a pointer drag on the handle', () => { + showComposer(); + const composer = document.getElementById('composer'); + Object.defineProperty(composer, 'offsetHeight', { configurable: true, value: 400 }); + mail.applyComposerHeight(400); + + const resize = handle(); + resize.dispatchEvent(new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + pointerId: 1, + pointerType: 'mouse', + button: 0, + clientY: 420, + })); + document.dispatchEvent(new PointerEvent('pointermove', { + bubbles: true, + cancelable: true, + pointerId: 1, + clientY: 500, + })); + document.dispatchEvent(new PointerEvent('pointerup', { + bubbles: true, + cancelable: true, + pointerId: 1, + clientY: 500, + })); + + expect(mail.state.composerHeight).toBe(320); + expect(composer.style.getPropertyValue('--composer-height')).toBe('320px'); + }); +}); + describe('composer send chrome', () => { it('localizes Send on the composer button and the assistant send control', () => { mail.state.config.strings = {