# 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:
```css
/* ✅ 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);
}
}
```
```css
/* ❌ 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
```css
.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
```css
: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.
```css
.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
```css
@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)
```css
.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
```xml
```
- `width`/`height` always — 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;` plus `object-fit: cover` with a fixed `aspect-ratio` when
cards must align.
- Odoo serves resized variants: `/web/image/product.product//image_256` etc. Ask for the size you
render, not `image_1920` scaled down in CSS.
## Container and page rhythm
```css
.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.