diff --git a/frontend/src/__tests__/css.test.ts b/frontend/src/__tests__/css.test.ts index fc84802ba..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', () => { @@ -301,6 +305,37 @@ 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, 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(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); + }); }); describe('CLI Command Styles', () => { diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index f053ba47f..4454caafd 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 { 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;