From 6e40d1e5915f4b835306afc661317a03ce5d2bd0 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 01:09:13 +0200 Subject: [PATCH 1/2] fix(frontend/mobile): bottom-sheet modal at <=480px + vw cap on .modal-confirm (closes #985) --- frontend/src/__tests__/css.test.ts | 13 +++++++++++++ frontend/src/styles/components.css | 25 +++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/css.test.ts b/frontend/src/__tests__/css.test.ts index fc84802ba..24d86c9ea 100644 --- a/frontend/src/__tests__/css.test.ts +++ b/frontend/src/__tests__/css.test.ts @@ -301,6 +301,19 @@ describe('CSS Styles', () => { test('has modal-wide variant', () => { expect(css).toMatch(/\.modal-wide\s*\{/); }); + + test('has a vw cap on modal-confirm', () => { + expect(css).toMatch(/\.modal-confirm\s*\{[^}]*max-width:\s*min\(480px,\s*calc\(100vw - 2rem\)\)/); + }); + + test('has a 44px minimum tap target on modal-confirm-close', () => { + expect(css).toMatch(/\.modal-confirm-close\s*\{[^}]*min-width:\s*44px[^}]*min-height:\s*44px/); + }); + + test('flips modals to a bottom sheet at 480px', () => { + const bottomSheetQuery = /@media \(max-width: 480px\)\s*\{[^]*?\.modal,\s*\.modal-confirm-backdrop\s*\{[^}]*align-items:\s*flex-end[^}]*\}[^]*?\.modal-content,\s*\.modal-confirm\s*\{[^}]*width:\s*100%[^}]*\}/; + expect(css).toMatch(bottomSheetQuery); + }); }); describe('CLI Command Styles', () => { diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index f053ba47f..8ad31d8cd 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -986,7 +986,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { background: white; border-radius: 8px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); - max-width: 480px; + max-width: min(480px, calc(100vw - 2rem)); min-width: 320px; padding: 1.5rem; } @@ -995,13 +995,18 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { position: absolute; top: 0.5rem; right: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; background: none; border: none; font-size: 1.5rem; line-height: 1; cursor: pointer; color: var(--cudly-text-muted); - padding: 0 0.25rem; + padding: 0; } .modal-confirm-close:hover { @@ -1027,6 +1032,22 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { gap: 0.5rem; } +/* Bottom-sheet layout on narrow viewports (issue #985): modals slide up + * from the bottom edge instead of floating centered, so there's no + * side margin to lose on small screens. */ +@media (max-width: 480px) { + .modal, .modal-confirm-backdrop { + align-items: flex-end; + } + + .modal-content, .modal-confirm { + border-radius: 12px 12px 0 0; + width: 100%; + max-width: 100%; + margin: 0; + } +} + /* Approval-details modal (issue #374): when the confirmDialog body * carries an .approval-details element, widen the dialog so the 12-col * per-rec table can breathe. The selector targets the parent modal via From 1f33433276a89f57bd0ac541fe76d9312a90b8cd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 23 Jul 2026 16:08:26 +0200 Subject: [PATCH 2/2] fix(frontend/mobile): move bottom-sheet media query to win the cascade (#985) The <=480px bottom-sheet block lived in components.css, which index.css imports before modals.css and responsive.css. With equal selector specificity, the later-imported responsive.css 768px rule (.modal-content{width:95%}) always won, so only .modal-confirm actually flipped to a bottom sheet; every real modal (#user-modal, #purchase-modal, #account-modal, #plan-modal, #override-modal, login, profile) stayed centered. Move the block into responsive.css, placed after the existing 768px block so source order lets it win. Strengthen the regression test to check the rule lives in responsive.css (not components.css) and that it appears after the 768px block, instead of only string-matching components.css. --- frontend/src/__tests__/css.test.ts | 26 ++++++++++++++++++++++++-- frontend/src/styles/components.css | 16 ---------------- frontend/src/styles/responsive.css | 21 +++++++++++++++++++++ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/frontend/src/__tests__/css.test.ts b/frontend/src/__tests__/css.test.ts index 24d86c9ea..182080e8f 100644 --- a/frontend/src/__tests__/css.test.ts +++ b/frontend/src/__tests__/css.test.ts @@ -7,6 +7,8 @@ import path from 'path'; describe('CSS Styles', () => { let css: string; + let responsiveCss: string; + let componentsCss: string; beforeAll(() => { // Read all CSS files from the styles directory @@ -15,6 +17,8 @@ describe('CSS Styles', () => { .filter(file => file.endsWith('.css')) .map(file => fs.readFileSync(path.join(stylesDir, file), 'utf8')); css = cssFiles.join('\n'); + responsiveCss = fs.readFileSync(path.join(stylesDir, 'responsive.css'), 'utf8'); + componentsCss = fs.readFileSync(path.join(stylesDir, 'components.css'), 'utf8'); }); describe('Required CSS Rules', () => { @@ -310,9 +314,27 @@ describe('CSS Styles', () => { expect(css).toMatch(/\.modal-confirm-close\s*\{[^}]*min-width:\s*44px[^}]*min-height:\s*44px/); }); - test('flips modals to a bottom sheet at 480px', () => { + test('flips modals to a bottom sheet at 480px, winning the cascade', () => { + // The bottom-sheet rule must live in responsive.css (imported last in + // index.css), not components.css (imported 3rd). components.css is + // imported before modals.css and responsive.css, so an equal-specificity + // .modal-content rule placed there would always lose to the + // .modal-content{width:95%} 768px rule in responsive.css -- only + // .modal-confirm would actually flip, leaving every real modal + // (#user-modal, #purchase-modal, etc.) centered instead of bottom-sheet. const bottomSheetQuery = /@media \(max-width: 480px\)\s*\{[^]*?\.modal,\s*\.modal-confirm-backdrop\s*\{[^}]*align-items:\s*flex-end[^}]*\}[^]*?\.modal-content,\s*\.modal-confirm\s*\{[^}]*width:\s*100%[^}]*\}/; - expect(css).toMatch(bottomSheetQuery); + + expect(responsiveCss).toMatch(bottomSheetQuery); + expect(componentsCss).not.toMatch(bottomSheetQuery); + + // Source-order check: the 480px bottom-sheet block must appear after + // the existing 768px block within responsive.css, so at <=480px it + // wins the cascade over the 768px .modal-content{width:95%} rule + // (equal selector specificity -> later declaration wins). + const idx768 = responsiveCss.search(/@media \(max-width: 768px\)/); + const idx480 = responsiveCss.search(/@media \(max-width: 480px\)/); + expect(idx768).toBeGreaterThan(-1); + expect(idx480).toBeGreaterThan(idx768); }); }); diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 8ad31d8cd..4454caafd 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1032,22 +1032,6 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { gap: 0.5rem; } -/* Bottom-sheet layout on narrow viewports (issue #985): modals slide up - * from the bottom edge instead of floating centered, so there's no - * side margin to lose on small screens. */ -@media (max-width: 480px) { - .modal, .modal-confirm-backdrop { - align-items: flex-end; - } - - .modal-content, .modal-confirm { - border-radius: 12px 12px 0 0; - width: 100%; - max-width: 100%; - margin: 0; - } -} - /* Approval-details modal (issue #374): when the confirmDialog body * carries an .approval-details element, widen the dialog so the 12-col * per-rec table can breathe. The selector targets the parent modal via diff --git a/frontend/src/styles/responsive.css b/frontend/src/styles/responsive.css index 803474119..c6cb33fd6 100644 --- a/frontend/src/styles/responsive.css +++ b/frontend/src/styles/responsive.css @@ -225,6 +225,27 @@ body.sidebar-open { } } +/* Bottom-sheet layout on narrow viewports (issue #985): modals slide up + * from the bottom edge instead of floating centered, so there's no + * side margin to lose on small screens. Deliberately placed here (after + * the 768px block above) rather than in components.css: modals.css and + * this file are imported after components.css in index.css, so with + * equal selector specificity, a .modal-content rule in components.css + * loses the cascade to the .modal-content{width:95%} rule from the + * 768px block above and would never take effect at <=480px. */ +@media (max-width: 480px) { + .modal, .modal-confirm-backdrop { + align-items: flex-end; + } + + .modal-content, .modal-confirm { + border-radius: 12px 12px 0 0; + width: 100%; + max-width: 100%; + margin: 0; + } +} + @media (max-width: 1200px) { .planned-purchases-table { display: block;