[DOC] skills: update odoo-javascript skill documentation
This commit is contained in:
parent
158933e96e
commit
162ba0b570
6 changed files with 875 additions and 1 deletions
245
.claude/skills/odoo-web-design/references/accessibility.md
Normal file
245
.claude/skills/odoo-web-design/references/accessibility.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# Accessibility patterns (QWeb + CSS + JS)
|
||||
|
||||
WCAG 2.2 AA. Snippets use this repo's conventions: 4-space indent, Bootstrap 5.3 utilities,
|
||||
translatable attributes, `window.i18nManager.get(key)` for JS strings.
|
||||
|
||||
## Page skeleton
|
||||
|
||||
```xml
|
||||
<t t-call="website.layout">
|
||||
<a href="#eskaera-main" class="visually-hidden-focusable btn btn-primary m-2">
|
||||
Skip to main content
|
||||
</a>
|
||||
<div id="wrap" class="eskaera-shop-page">
|
||||
<main id="eskaera-main" tabindex="-1">
|
||||
<h1 class="eskaera-page-title" t-esc="page_title"/>
|
||||
...
|
||||
</main>
|
||||
</div>
|
||||
</t>
|
||||
```
|
||||
|
||||
- Exactly one `<h1>` per page; never skip heading levels to get a font size — that's what classes are for.
|
||||
- `<main>`, `<nav aria-label="...">`, `<aside>` instead of anonymous `<div>`s.
|
||||
- `tabindex="-1"` on the skip-link target so focus actually lands there.
|
||||
|
||||
## Buttons vs links
|
||||
|
||||
| Intent | Element |
|
||||
| --- | --- |
|
||||
| Navigates to a URL | `<a href="...">` |
|
||||
| Changes state on this page | `<button type="button">` |
|
||||
| Submits a form | `<button type="submit">` |
|
||||
|
||||
Never `<div t-on-click>` / `<span onclick>` — no keyboard, no role, no focus.
|
||||
|
||||
Icon-only:
|
||||
|
||||
```xml
|
||||
<button type="button" class="btn btn-outline-secondary qty-decrease"
|
||||
aria-label="Decrease quantity">
|
||||
<i class="fa fa-minus" aria-hidden="true"/>
|
||||
</button>
|
||||
```
|
||||
|
||||
`aria-label` is auto-translated; `aria-hidden` on the icon stops the screen reader announcing
|
||||
"minus icon" twice. Alternative when the label is long: visible `<span class="visually-hidden">`.
|
||||
|
||||
## Quantity stepper (live-announced)
|
||||
|
||||
```xml
|
||||
<div class="qty-control" role="group" aria-label="Quantity">
|
||||
<button type="button" class="btn qty-decrease" aria-label="Decrease quantity">
|
||||
<i class="fa fa-minus" aria-hidden="true"/>
|
||||
</button>
|
||||
<input type="number" class="form-control qty-input"
|
||||
t-att-value="line.qty" min="0" step="1"
|
||||
aria-label="Quantity" inputmode="numeric"/>
|
||||
<button type="button" class="btn qty-increase" aria-label="Increase quantity">
|
||||
<i class="fa fa-plus" aria-hidden="true"/>
|
||||
</button>
|
||||
</div>
|
||||
<p class="visually-hidden" aria-live="polite" id="qty_status"/>
|
||||
```
|
||||
|
||||
```js
|
||||
// after the server confirms the new qty
|
||||
const status = document.getElementById("qty_status");
|
||||
status.textContent = window.i18nManager.get("qty_updated_announcement");
|
||||
```
|
||||
|
||||
Buttons must stay ≥ 24×24 px (WCAG 2.2 AA §2.5.8) everywhere, and ≥ 44×44 px under
|
||||
`@media (pointer: coarse)` — including at 320 px width.
|
||||
|
||||
## Forms and errors
|
||||
|
||||
```xml
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="delivery_note">Delivery note</label>
|
||||
<textarea id="delivery_note" name="delivery_note" class="form-control"
|
||||
t-att-aria-invalid="'true' if errors.get('delivery_note') else None"
|
||||
t-att-aria-describedby="'delivery_note_help delivery_note_error'
|
||||
if errors.get('delivery_note')
|
||||
else 'delivery_note_help'"/>
|
||||
<small id="delivery_note_help" class="form-text">Optional, visible to the group manager.</small>
|
||||
<p t-if="errors.get('delivery_note')" id="delivery_note_error"
|
||||
class="invalid-feedback d-block" t-esc="errors['delivery_note']"/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- The `for`/`id` pair is mandatory; wrapping alone is not enough for every AT.
|
||||
- Error text is referenced by `aria-describedby`, and never colour-only — keep the message.
|
||||
- The error summary at the top of a failed form should be focused on submit
|
||||
(`container.focus()` on a `tabindex="-1"` heading).
|
||||
- Required fields: `required` + a visible "(required)" or `*` with a legend explaining it.
|
||||
|
||||
## Product card — one link, no duplicates
|
||||
|
||||
```xml
|
||||
<article class="product-card">
|
||||
<img class="product-card-img" t-att-src="product['image_url']"
|
||||
t-att-alt="product['name']" loading="lazy" width="400" height="400"/>
|
||||
<h3 class="product-card-title">
|
||||
<a t-att-href="product['url']" class="stretched-link" t-esc="product['name']"/>
|
||||
</h3>
|
||||
<p class="product-card-price">
|
||||
<span class="visually-hidden">Price</span>
|
||||
<span t-esc="product['price_formatted']"/>
|
||||
</p>
|
||||
</article>
|
||||
```
|
||||
|
||||
- `stretched-link` makes the whole card clickable with a **single** link in the a11y tree — do not
|
||||
also wrap the image in an `<a>`, that produces two identical stops.
|
||||
- `width`/`height` reserve space (no layout shift); CSS `object-fit: cover` handles the ratio.
|
||||
- Decorative product placeholder → `alt=""`.
|
||||
|
||||
## Filters as toggle buttons
|
||||
|
||||
```xml
|
||||
<div class="tag-filter" role="group" aria-label="Filter by category">
|
||||
<button t-foreach="categories" t-as="cat" t-key="cat['id']"
|
||||
type="button" class="tag-filter-badge"
|
||||
t-att-aria-pressed="'true' if cat['active'] else 'false'"
|
||||
t-att-data-category-id="cat['id']">
|
||||
<span t-esc="cat['name']"/>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
JS flips `aria-pressed` together with the active class — the class alone announces nothing.
|
||||
If the filters are a true multi-select set, `<fieldset><legend>` + checkboxes is better still.
|
||||
|
||||
## Search with results announcement
|
||||
|
||||
```xml
|
||||
<label class="visually-hidden" for="product_search">Search products</label>
|
||||
<input id="product_search" type="search" class="form-control"
|
||||
placeholder="Search products…" autocomplete="off"
|
||||
aria-describedby="search_results_count"/>
|
||||
<p id="search_results_count" class="visually-hidden" aria-live="polite" aria-atomic="true"/>
|
||||
```
|
||||
|
||||
Debounce ≥ 200 ms, then set the text once per settled query — announcing on every keystroke makes
|
||||
the page unusable with a screen reader.
|
||||
|
||||
## Loading, infinite scroll, empty states
|
||||
|
||||
```xml
|
||||
<div id="products_grid" class="products-grid" aria-busy="false">…</div>
|
||||
<p class="visually-hidden" aria-live="polite" id="load_status"/>
|
||||
<button type="button" id="load_more" class="btn btn-outline-primary">Load more products</button>
|
||||
```
|
||||
|
||||
- Set `aria-busy="true"` on the grid while fetching, back to `false` when done.
|
||||
- Announce `"N more products loaded"` via `load_status`; don't move focus mid-scroll.
|
||||
- **Always keep a real "Load more" button** alongside scroll-triggered loading — infinite scroll
|
||||
alone is unreachable by keyboard and traps users away from the footer.
|
||||
- Empty state is content, not a blank div: heading + explanation + the action that fixes it.
|
||||
|
||||
## Modals
|
||||
|
||||
Use Bootstrap's modal (focus trap, Esc, `aria-modal` come free) and give it a label:
|
||||
|
||||
```xml
|
||||
<div class="modal fade" id="confirm_order_modal" tabindex="-1"
|
||||
aria-labelledby="confirm_order_title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="confirm_order_title">Confirm your order</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"/>
|
||||
</div>
|
||||
…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Return focus to the trigger on close (Bootstrap does this if the trigger is a real button).
|
||||
|
||||
## Order tables
|
||||
|
||||
```xml
|
||||
<table class="table checkout-summary-table">
|
||||
<caption class="visually-hidden">Order lines</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Product</th>
|
||||
<th scope="col" class="text-end">Quantity</th>
|
||||
<th scope="col" class="text-end">Subtotal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="lines" t-as="line" t-key="line['id']">
|
||||
<th scope="row" t-esc="line['name']"/>
|
||||
<td class="text-end" t-esc="line['qty']"/>
|
||||
<td class="text-end" t-esc="line['subtotal_formatted']"/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
```
|
||||
|
||||
Never use a table for layout, and never drop `<thead>`/`scope` when stacking to cards on mobile —
|
||||
restyle, don't restructure.
|
||||
|
||||
## Focus and motion in CSS
|
||||
|
||||
```css
|
||||
/* One ring, everywhere, on keyboard focus only. */
|
||||
.eskaera-page :focus-visible {
|
||||
outline: 2px solid var(--ac-color-focus, var(--focus-ring-color, #0d6efd));
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--ac-radius-sm);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is the one legitimate `!important` in the codebase — a user preference must beat every component.
|
||||
|
||||
## Multilingual
|
||||
|
||||
The addon runs es / eu / ca. `website.layout` sets `<html lang>`; when a string stays in another
|
||||
language inside a translated page (a supplier name, a product origin), mark it: `<span lang="eu">`.
|
||||
Never build a sentence by concatenating translated fragments — pass the whole string with `%s`
|
||||
placeholders so translators can reorder.
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Keyboard only**: Tab through the page. Visible focus at every stop, order matches the visual
|
||||
order, no trap, every action reachable, Esc closes overlays.
|
||||
2. **Zoom 200 %** and **320 px width**: no horizontal scroll, nothing clipped.
|
||||
3. **Contrast**: check rendered colours, including disabled states and placeholder text.
|
||||
4. **Screen reader** smoke test on the cart flow (Orca on Linux, NVDA on Windows).
|
||||
5. Lighthouse/axe in DevTools catches the mechanical half — it does not catch focus order,
|
||||
announcement quality, or a label that lies.
|
||||
160
.claude/skills/odoo-web-design/references/css-architecture.md
Normal file
160
.claude/skills/odoo-web-design/references/css-architecture.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# CSS architecture
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
static/src/css/
|
||||
├── website_sale.css # index only: @import list, no rules of its own
|
||||
├── base/
|
||||
│ ├── variables.css # :root tokens — the ONLY file with hex literals
|
||||
│ └── utilities.css # tiny single-purpose helpers, when Bootstrap lacks one
|
||||
├── layout/
|
||||
│ ├── pages.css # page shells, containers, backgrounds
|
||||
│ └── header.css
|
||||
├── components/ # one file per reusable component
|
||||
│ ├── product-card.css
|
||||
│ ├── order-card.css
|
||||
│ ├── cart.css
|
||||
│ ├── buttons.css
|
||||
│ ├── quantity-control.css
|
||||
│ ├── forms.css
|
||||
│ ├── alerts.css
|
||||
│ └── tag-filter.css
|
||||
└── sections/ # page-specific composition only
|
||||
├── products-grid.css
|
||||
├── order-list.css
|
||||
├── checkout.css
|
||||
└── info-cards.css
|
||||
```
|
||||
|
||||
Rules of placement:
|
||||
|
||||
- A rule that could appear on two pages is a **component**, never a section.
|
||||
- A **section** file may only position and space components — if it restyles a component's internals,
|
||||
the component is missing a modifier.
|
||||
- Each component file owns its own media queries, at the bottom. There is no global responsive file
|
||||
in a healthy tree; a legacy `layout/responsive.css` should shrink to zero as components are touched.
|
||||
- `website_sale.css` contains imports and comments only.
|
||||
|
||||
## Naming
|
||||
|
||||
Prefix everything with `eskaera-` (the product name already used for page-level classes). Unprefixed
|
||||
names like `.product-card`, `.cart-header`, `.qty-control` sit in the same namespace as Bootstrap,
|
||||
Odoo core and every other installed addon — that collision is what forces `!important` later.
|
||||
|
||||
```
|
||||
.eskaera-product-card /* block */
|
||||
.eskaera-product-card__title /* element */
|
||||
.eskaera-product-card--out-of-stock /* modifier */
|
||||
.is-loading .is-active .has-error /* state, always paired with a block class */
|
||||
```
|
||||
|
||||
`js-` prefix for hooks JS queries, and never style a `js-` class — that way markup can be restyled
|
||||
without breaking behaviour, and behaviour can move without breaking styles.
|
||||
|
||||
When renaming existing classes, do it component by component: template + CSS + JS selectors + tests
|
||||
in the same commit, then grep for the old name across `views/`, `static/src/`, and `README*`.
|
||||
|
||||
## Specificity — how to stop writing `!important`
|
||||
|
||||
Overriding Bootstrap globally (`.list-group-item { … }`, `.alert-warning { … }`) is a fight you win
|
||||
only with `!important`, and it leaks into every other addon. Scope the override under the page root
|
||||
instead: equal specificity plus one class always wins, no escape hatch needed.
|
||||
|
||||
```css
|
||||
/* ❌ global override, needs !important to beat Bootstrap's own later rules */
|
||||
.list-group-item {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* ✅ scoped: higher specificity, no !important, no leakage */
|
||||
.eskaera-checkout-page .list-group-item {
|
||||
border-radius: 0;
|
||||
}
|
||||
```
|
||||
|
||||
Other rules:
|
||||
|
||||
- Max **3** levels of nesting in a selector. Deeper means the markup is doing the work the class
|
||||
should do.
|
||||
- No ID selectors for styling; IDs are for anchors and `aria-*` references.
|
||||
- No element selectors outside `base/` (`div.product-card` locks the markup).
|
||||
- `!important` is allowed in exactly two places: the `prefers-reduced-motion` reset, and a documented
|
||||
override of a core rule you cannot scope — with a comment saying which rule and why.
|
||||
|
||||
## Ordering inside a file
|
||||
|
||||
```css
|
||||
.eskaera-product-card {
|
||||
/* 1. layout */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 2. box */
|
||||
padding: var(--ac-space-md);
|
||||
border: 1px solid var(--ac-border-light);
|
||||
border-radius: var(--ac-radius-lg);
|
||||
/* 3. typography */
|
||||
font-size: var(--ac-text-base);
|
||||
color: var(--ac-text-primary);
|
||||
/* 4. paint & motion */
|
||||
background: var(--ac-surface);
|
||||
box-shadow: var(--ac-shadow-sm);
|
||||
transition: box-shadow var(--ac-transition-fast);
|
||||
}
|
||||
```
|
||||
|
||||
Then, in order: `:hover` (inside `@media (hover: hover)`), `:focus-visible`, `:active`, `:disabled`,
|
||||
state classes, modifiers, media queries.
|
||||
|
||||
## Tokens
|
||||
|
||||
Full rules in `SKILL.md` §2. In short:
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* alias Odoo/Bootstrap's unprefixed custom properties so theming still works */
|
||||
--ac-color-primary: var(--primary, #007bff);
|
||||
--ac-color-danger: var(--danger, #dc3545);
|
||||
--ac-surface: var(--body-bg, #fff);
|
||||
--ac-text-primary: var(--body-color, #1a202c);
|
||||
--ac-border-light: var(--border-color, #e2e8f0);
|
||||
|
||||
/* own scales */
|
||||
--ac-space-xs: 0.25rem;
|
||||
--ac-space-sm: 0.5rem;
|
||||
--ac-space-md: 1rem;
|
||||
--ac-space-lg: 1.5rem;
|
||||
--ac-space-xl: 2rem;
|
||||
|
||||
--ac-radius-sm: 0.25rem;
|
||||
--ac-radius-md: 0.5rem;
|
||||
--ac-radius-lg: 0.75rem;
|
||||
|
||||
--ac-shadow-sm: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
--ac-shadow-md: 0 4px 12px rgb(0 0 0 / 10%);
|
||||
|
||||
--ac-transition-fast: 200ms ease;
|
||||
}
|
||||
```
|
||||
|
||||
Migrating a legacy token set: add the `--ac-` token aliased to the old name, move consumers file by
|
||||
file, delete the old token when `grep -rn "\-\-old-name" static/src/` is empty. Don't rename all at once.
|
||||
|
||||
## Anti-patterns seen in Odoo addons
|
||||
|
||||
| Smell | Fix |
|
||||
| --- | --- |
|
||||
| `!important` to beat Bootstrap | scope under the page root class |
|
||||
| Same colour as `#28a745` in six files | one token in `variables.css` |
|
||||
| `responsive.css` overriding twelve components | move each query into its component |
|
||||
| `style="margin-top:20px"` in QWeb | utility class or component spacing |
|
||||
| `.card .body .row .col span` | give the span a class |
|
||||
| `px` for font sizes | `rem`, so browser zoom and user font size work |
|
||||
| Colour hardcoded instead of `var(--primary)` | breaks the website theme editor silently |
|
||||
| Both `@import` chain **and** manifest entries | double download; pick one (see SKILL.md §6) |
|
||||
|
||||
## Formatting
|
||||
|
||||
Prettier handles `.css` via pre-commit: `printWidth: 100`, `tabWidth: 4`. Run
|
||||
`pre-commit run --all-files` (or `make format`) before committing; never hand-align properties, it
|
||||
will be reformatted.
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
# The finish pass
|
||||
|
||||
What separates "it works" from "it looks designed". Go through this per screen, not per file.
|
||||
|
||||
## 1. Every interactive element has all its states
|
||||
|
||||
For each button, link, card, input, filter chip:
|
||||
|
||||
| State | Must be | Common miss |
|
||||
| --- | --- | --- |
|
||||
| default | on the token scale | — |
|
||||
| `:hover` | inside `@media (hover: hover)` | sticks after tap on touch |
|
||||
| `:focus-visible` | visible ring, 3:1 contrast | removed via `outline: none` |
|
||||
| `:active` | perceptible press | missing entirely |
|
||||
| `:disabled` | dimmed **and** `cursor: not-allowed`, still 4.5:1 if it carries text | unreadable grey |
|
||||
| loading | spinner + `aria-busy`, size unchanged | layout jumps |
|
||||
|
||||
The element must not resize between states — animate `box-shadow`, `background`, `transform`, never
|
||||
`padding`, `border-width` or `font-size`.
|
||||
|
||||
## 2. Vertical rhythm and spacing
|
||||
|
||||
- All spacing from `--ac-space-*`. A `margin: 13px` anywhere is a defect.
|
||||
- Space belongs to **one** side consistently (block-end margins, or `gap` — pick one per container).
|
||||
Prefer `gap` in flex/grid; it doesn't collapse and doesn't need `:last-child` cleanups.
|
||||
- Related things sit closer than unrelated things. If the label is as far from its input as from the
|
||||
previous field, the form reads as noise.
|
||||
- Cards in a grid must be equal height (`align-items: stretch` + the price pinned with `margin-top: auto`).
|
||||
|
||||
## 3. Typography
|
||||
|
||||
- One scale (`--ac-text-*`), max 3 weights, max 2 sizes per card.
|
||||
- Headings are structural (`h1`→`h2`→`h3`); size comes from a class, never from picking `h4` because
|
||||
it looks right.
|
||||
- `max-width: 65ch` on paragraphs of real prose.
|
||||
- Numbers in tables: `font-variant-numeric: tabular-nums` and right-aligned so columns line up.
|
||||
- Long product names truncate predictably: `text-overflow: ellipsis` with `title` attribute, or
|
||||
`-webkit-line-clamp: 2` with a fixed `min-height` so cards don't stagger.
|
||||
|
||||
## 4. Colour
|
||||
|
||||
- Semantic, not decorative: success/danger/warning always the same hue for the same meaning.
|
||||
- Status never colour-only — icon or text alongside (colour blindness, and grayscale printing).
|
||||
- Max ~2 accent colours per screen. Everything else is surface, border, text.
|
||||
- Check the rendered contrast, including: placeholder text, disabled buttons, badges on coloured
|
||||
backgrounds, white text on `--warning` (this one almost always fails).
|
||||
|
||||
## 5. The states people forget
|
||||
|
||||
Every list, grid and total needs four designs, not one:
|
||||
|
||||
1. **Loading** — skeletons matched to the real layout (not a centred spinner that collapses the page).
|
||||
2. **Empty** — heading, one sentence explaining why it's empty, and the button that fixes it.
|
||||
"No products found" alone is a dead end; offer "Clear filters".
|
||||
3. **Error** — what failed, in the user's language, and what to do now. Never a raw traceback or code.
|
||||
4. **Partial / stale** — an order past its cutoff, an out-of-stock line, a price that changed. Say it
|
||||
inline, next to the affected row, not only in a toast that disappears.
|
||||
|
||||
## 6. Feedback and motion
|
||||
|
||||
- Any action taken by the user gets a response within 100 ms — disable the button, show the spinner,
|
||||
optimistic-update the quantity. Silence reads as "broken", and users double-submit.
|
||||
- Toasts: `aria-live="polite"`, dismissible, never the only place an error is shown.
|
||||
- Motion is 150–250 ms and eases out. Anything above 400 ms feels slow; anything that moves the page
|
||||
under the cursor is a bug.
|
||||
- Everything animated respects `prefers-reduced-motion`.
|
||||
|
||||
## 7. Content and copy
|
||||
|
||||
- Sentence case for buttons and labels; imperative verbs ("Add to cart", not "Adding products").
|
||||
- Prices always formatted server-side with the currency and locale (`i18nManager.formatCurrency`),
|
||||
never string-concatenated in the template.
|
||||
- Dates: locale-formatted, and relative only when the absolute date is also available (`title`).
|
||||
- Nothing user-visible is a hardcoded English literal — templates get translatable text, JS gets
|
||||
`window.i18nManager.get(key)`.
|
||||
|
||||
## 8. Cross-cutting checks before "done"
|
||||
|
||||
```
|
||||
[ ] 320 px: no horizontal scroll on any page
|
||||
[ ] 200 % zoom: nothing clipped or overlapping
|
||||
[ ] Tab through the whole flow: visible focus, logical order, no trap
|
||||
[ ] All actions reachable without a mouse, Esc closes overlays
|
||||
[ ] No hex outside base/variables.css
|
||||
[ ] No inline style="" in views/*.xml
|
||||
[ ] No new !important (except the reduced-motion reset)
|
||||
[ ] Every <img> has alt and width/height
|
||||
[ ] Every icon-only control has aria-label
|
||||
[ ] Empty / loading / error states exist for each list
|
||||
[ ] es, eu and ca render without overflow (German-length strings break tight buttons)
|
||||
[ ] pre-commit run --all-files is clean
|
||||
[ ] Lighthouse a11y ≥ 95 on shop, cart, checkout
|
||||
```
|
||||
|
||||
The last three matter as much as the rest: a layout tuned only against Spanish copy usually breaks in
|
||||
Basque, and a11y regressions land silently.
|
||||
191
.claude/skills/odoo-web-design/references/responsive.md
Normal file
191
.claude/skills/odoo-web-design/references/responsive.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# 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
|
||||
<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`/`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/<id>/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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue