Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
116 changes: 114 additions & 2 deletions public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
181 changes: 180 additions & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ const state = {
},
characterSessions: {},
attachments: [],
composerHeight: null, // px; null keeps the CSS default
mailSplit: null,
railDrawerOpen: false,
assistantDrawerOpen: false,
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -4363,6 +4539,9 @@ export function mailAppTestHooks() {
startCompose,
closeComposer,
cancelComposer,
applyComposerHeight,
clearComposerHeight,
initComposerResize,
deleteDraft,
saveDraftNow,
resetDraftWriteQueue,
Expand Down
Loading
Loading