5.6 KiB
Responsive layout
Breakpoints — Bootstrap 5, and only these
| Name | min-width |
Typical device |
|---|---|---|
| (base) | — | phones, 320–575 px |
sm |
576px | large phones |
md |
768px | tablets |
lg |
992px | small laptops |
xl |
1200px | desktops |
xxl |
1400px | large desktops |
Mobile-first, min-width only:
/* ✅ base = mobile, enhance upwards */
.products-grid {
display: grid;
gap: var(--ac-space-md);
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.products-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1200px) {
.products-grid {
grid-template-columns: repeat(4, 1fr);
}
}
/* ❌ desktop-first bands: overlapping ranges, unclear cascade, private breakpoints */
@media (max-width: 1599px) and (min-width: 1400px) { … }
@media (max-width: 720px) { … }
Prefer intrinsic layout — most grids need no media query at all
.products-grid {
display: grid;
gap: var(--ac-space-md);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
}
min(100%, 16rem) is what keeps it from overflowing at 320 px. Reach for a media query only when the
layout must genuinely change (sidebar moves, table becomes cards), not merely resize.
Fluid type and space
:root {
--ac-text-sm: clamp(0.875rem, 0.85rem + 0.1vw, 0.9375rem);
--ac-text-base: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
--ac-text-lg: clamp(1.25rem, 1.15rem + 0.5vw, 1.5rem);
--ac-text-xl: clamp(1.5rem, 1.3rem + 1vw, 2.25rem);
--ac-space-md: clamp(1rem, 0.9rem + 0.5vw, 1.5rem);
}
One continuous scale beats three step-changes at breakpoints. Never redefine a token inside a media query to make it smaller.
Body copy: 1rem minimum, line-height ≥ 1.5, measure 45–75 characters (max-width: 65ch).
Touch targets and pointer
WCAG 2.2 AA (§2.5.8 Target Size, Minimum) asks for 24×24 px. The familiar 44×44 px is §2.5.5, level AAA — treat it as the house rule for anything a thumb hits, applied by pointer type rather than by viewport width: a 1024 px tablet needs the big target, a 1024 px laptop doesn't.
.qty-control .btn,
.tag-filter-badge {
min-width: 2.25rem; /* 36px — comfortable with a mouse */
min-height: 2.25rem;
}
@media (pointer: coarse) {
.qty-control .btn,
.tag-filter-badge {
min-width: 2.75rem; /* 44px */
min-height: 2.75rem;
}
}
@media (hover: hover) and (pointer: fine) {
.product-card:hover {
transform: translateY(-2px);
box-shadow: var(--ac-shadow-md);
}
}
Hover effects belong inside @media (hover: hover) — on touch they stick after a tap and look broken.
Tables → cards, without restructuring
@media (max-width: 767.98px) {
.checkout-summary-table thead {
/* keep it in the a11y tree; hide visually only */
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.checkout-summary-table tr {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--ac-space-xs);
padding: var(--ac-space-sm) 0;
border-bottom: 1px solid var(--ac-border-light);
}
}
This is the one place a max-width query is honest: it's an override of the desktop table. Keep the
markup semantic — never emit different HTML per viewport.
Sticky bars (cart / checkout actions on mobile)
.checkout-actions {
position: sticky;
bottom: 0;
z-index: var(--ac-z-sticky);
padding: var(--ac-space-sm);
padding-bottom: calc(var(--ac-space-sm) + env(safe-area-inset-bottom, 0px));
background: var(--ac-surface, #fff);
box-shadow: 0 -2px 8px rgb(0 0 0 / 8%);
}
env(safe-area-inset-bottom) keeps the button above the iOS home indicator. Sticky elements must not
eat more than ~20 % of a 568 px-tall viewport.
Images
<img class="product-card-img" t-att-src="product['image_url']" t-att-alt="product['name']"
loading="lazy" decoding="async" width="400" height="400"/>
width/heightalways — they reserve the box and prevent layout shift (CLS).loading="lazy"for everything below the fold; never on the LCP image (first product row, hero) — that delays the largest paint.- CSS:
max-width: 100%; height: auto;plusobject-fit: coverwith a fixedaspect-ratiowhen cards must align. - Odoo serves resized variants:
/web/image/product.product/<id>/image_256etc. Ask for the size you render, notimage_1920scaled down in CSS.
Container and page rhythm
.eskaera-page {
width: 100%;
max-width: 75rem;
margin-inline: auto;
padding-inline: clamp(1rem, 4vw, 2rem);
}
Use logical properties (margin-inline, padding-block, inset-inline-start) — Odoo auto-generates
an RTL stylesheet, and logical properties survive the flip without a second rule.
Test matrix
| Width | Why |
|---|---|
| 320 px | smallest supported; the one that breaks first |
| 360 / 390 px | real Android / iPhone |
| 768 px | tablet portrait, first breakpoint |
| 1024 px | tablet landscape / small laptop |
| 1280 / 1440 px | desktop |
| 1280 px @ 200 % zoom | WCAG 1.4.4 reflow — equivalent to 640 px |
Run the stack (docker-compose up -d, http://localhost:8070) and check, per viewport: no horizontal
scroll, no clipped text, touch targets still ≥ 44 px on coarse pointers, sticky bars not covering
content, images not stretched.
DevTools device toolbar is enough for layout; check touch targets by actually tapping on a phone once before calling it done.