DESIGN.md · git:20260922.79f21ec · 2026-09-22 · sha256 8446944cd03dc41e

DESIGN.md git:20260922.79f21ecA

Immutable. This exact content is served forever at /api/v1/blob/8446944cd03dc41e.

---
name: Cambio Uruguay
description: A light-first, evidence-led interface for comparing Uruguay's public market information.
colors:
  midnight-canvas: "#0a0e1a"
  navy-surface: "#121a2e"
  paper-canvas: "#f6f7f9"
  paper-surface: "#ffffff"
  white: "#ffffff"
  ink-black: "#000000"
  action-blue: "#1976d2"
  link-sky: "#64b5f6"
  ink-blue: "#1565c0"
  accent-blue: "#448aff"
  amber-signal: "#ff8f00"
  amber-deep: "#ff6f00"
  teal-context: "#26a69a"
  teal-deep: "#00897b"
  success-green: "#00e676"
  light-success: "#2e7d32"
  warning-amber: "#ffc107"
  error-orange: "#dd2c00"
  light-error: "#bf360c"
  sentiment-positive: "#35d07f"
  sentiment-neutral: "#7d8aa3"
  sentiment-negative: "#ff655d"
  map-shade-1: "#d6e6f5"
  map-shade-2: "#a8cae8"
  map-shade-3: "#75a9d7"
  map-shade-4: "#3d80ba"
  map-shade-5: "#15517e"
  map-no-data: "#d4d9df"
  map-ink: "#102c4c"
typography:
  display:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "clamp(1.55rem, 4.4vw, 2.5rem)"
    fontWeight: 800
    lineHeight: 1.1
    letterSpacing: "-0.02em"
  content-title:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "clamp(1.5rem, 3vw, 2.125rem)"
    fontWeight: 300
    lineHeight: 1.14
    letterSpacing: "-0.02em"
  headline:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "clamp(1.35rem, 3vw, 1.75rem)"
    fontWeight: 700
    lineHeight: 1.2
    letterSpacing: "-0.01em"
  title:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "1.25rem"
    fontWeight: 500
    lineHeight: 1.6
    letterSpacing: "0.0125em"
  lead:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "1.075rem"
    fontWeight: 400
    lineHeight: 1.65
    letterSpacing: "0.03125em"
  body:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "1rem"
    fontWeight: 400
    lineHeight: 1.5
    letterSpacing: "0.03125em"
  secondary-body:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "0.95rem"
    fontWeight: 400
    lineHeight: 1.5
  list-text:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "0.875rem"
    fontWeight: 400
    lineHeight: 1.45
  meta:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "0.8rem"
    fontWeight: 400
    lineHeight: 1.35
  stat:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "1.5rem"
    fontWeight: 700
    lineHeight: 1.2
  label:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "0.75rem"
    fontWeight: 700
    lineHeight: 1.4
    letterSpacing: "0.0333em"
  eyebrow:
    fontFamily: "Open Sans, sans-serif"
    fontSize: "0.75rem"
    fontWeight: 700
    lineHeight: 1.4
    letterSpacing: "0.18em"
rounded:
  xs: "4px"
  sm: "8px"
  md: "12px"
  lg: "16px"
  pill: "999px"
spacing:
  xs: "4px"
  sm: "8px"
  md: "16px"
  lg: "24px"
  xl: "40px"
components:
  button-primary:
    backgroundColor: "{colors.action-blue}"
    textColor: "{colors.white}"
    typography: "{typography.label}"
    rounded: "{rounded.xs}"
    padding: "0 20px"
    height: "40px"
  button-ghost:
    backgroundColor: "transparent"
    textColor: "{colors.link-sky}"
    typography: "{typography.label}"
    rounded: "{rounded.xs}"
    padding: "0 16px"
    height: "40px"
  card:
    backgroundColor: "{colors.navy-surface}"
    textColor: "{colors.white}"
    rounded: "{rounded.lg}"
    padding: "24px"
  card-compact:
    backgroundColor: "{colors.navy-surface}"
    textColor: "{colors.white}"
    rounded: "{rounded.md}"
    padding: "16px"
  input:
    backgroundColor: "{colors.navy-surface}"
    textColor: "{colors.white}"
    rounded: "{rounded.xs}"
    padding: "0 16px"
    height: "48px"
  chip:
    backgroundColor: "{colors.action-blue}"
    textColor: "{colors.white}"
    typography: "{typography.label}"
    rounded: "{rounded.pill}"
    padding: "0 12px"
    height: "32px"
  eyebrow:
    backgroundColor: "transparent"
    textColor: "{colors.link-sky}"
    typography: "{typography.eyebrow}"
---

# Design System: Cambio Uruguay

## Overview

**Creative North Star: "La Mesa de Mercado"**

Cambio Uruguay feels like a well-run market desk after dark: many live signals, one calm frame, and
no ambiguity about what is actionable. Deep navy surfaces establish continuity while blue, amber,
teal, green, and orange communicate roles rather than decorate.

The system is information-dense but never cramped. Familiar Material controls, generous container
spacing, short transitions, and visible provenance make complex financial content easy to scan on a
phone moments before a decision.

The implementation is Vuetify 4 running a restored Material 2 look: the v3 type ramp, elevation
scale, and uppercase button/overline casing are pinned back in `app/assets/variables.scss` and
`app/assets/css/legacy-vuetify.css`. Theme tokens come from a two-theme Vuetify definition
(`app/plugins/vuetify.ts`); dark is the SSR default and `useThemeMode` swaps to the persisted or
system preference after hydration.

**Key Characteristics:**

- Light on a first visit, including when the operating system is dark. Preserve an explicitly saved dark or system preference; both palettes remain fully usable. This default follows the mobile property-search feedback of September 2026.
- Evidence and freshness stay close to every comparison or recommendation.
- Color carries meaning; hierarchy comes from type, spacing, and tonal layering.
- Responsive layouts recompose instead of merely shrinking.
- Motion acknowledges state changes without delaying access to content.
- Every token has a light-theme counterpart chosen for contrast, not for symmetry.

## Colors

The palette combines a midnight financial canvas with cool blue actions and semantically reserved
signal colors. Each semantic role is defined twice — the light theme deepens the hue rather than
reusing the dark one, because the same swatch cannot clear 4.5:1 on both `#0a0e1a` and `#ffffff`.

### Primary

- **Action Blue** (`#1976d2`): The main action, active navigation, selected state, focus ring, and
  the skip link. Identical in both themes.
- **Link Sky** (`#64b5f6`): Anchors, small blue labels, and kickers on dark surfaces.
- **Ink Blue** (`#1565c0`): The light theme's `link` token. Small bold blue text (12–14px kickers,
  channel labels, "from" prices) must use the link token, not Action Blue — Action Blue measures
  ~4.2:1 at 12px on tinted panels.
- **Accent Blue** (`#448aff`): The light theme's `accent`; reserved for rare emphasis, not actions.

### Secondary

- **Amber Signal** (`#ff8f00`) / **Amber Deep** (`#ff6f00`): Rankings, attention markers, ruler
  ticks, and secondary emphasis. Amber Deep is the light-theme `secondary`.
- **Teal Context** (`#26a69a`) / **Teal Deep** (`#00897b`): Supporting information and contextual
  data that is neither success nor warning. Teal Deep is the light-theme `info`.

### Tertiary

- **Success Green** (`#00e676`) / **Light Success** (`#2e7d32`), **Warning Amber** (`#ffc107`),
  **Error Orange** (`#dd2c00`) / **Light Error** (`#bf360c`): Status colors, always with a label or
  icon. The light variants exist because `deepOrange darken-1` (3.47:1) and `green darken-2`
  (4.11:1) both failed on the `/estado` status chips.
- **Sentiment Positive / Neutral / Negative** (`#35d07f`, `#7d8aa3`, `#ff655d`): the three-way
  opinion split on Reddit-mined pages. These are **fill** colors for distribution bars and dots. As
  text they must be mixed toward the surface ink — `color-mix(in srgb, var(--tone) 45%,
  rgb(var(--v-theme-on-surface)))` — because the raw hues measure 2.0–2.9:1 on white.

### Neutral

- **Midnight Canvas** (`#0a0e1a`): The dark application background.
- **Navy Surface** (`#121a2e`): Cards, menus, fields, and elevated dark surfaces.
- **Paper Canvas** (`#f6f7f9`) and **Paper Surface** (`#ffffff`): The light theme's background and
  content surfaces.
- **White** (`#ffffff`): Primary dark-theme text and text on strong filled actions.
- **Ink Black** (`#000000`): **never solid.** It exists only as the light theme's ink at reduced
  alpha, mirroring Vuetify's emphasis levels: `0.76` for a lead paragraph, `0.6` for hints and
  field labels, `0.12`–`0.1` for hairlines, `0.02` for a card's tonal fill. The dark theme's
  counterpart is White at `0.82` / `0.14` / `0.03`. Both are surface ink, not palette colors: a
  solid black or a solid white fill is drift.
- Muted dark text is drawn from the canvas ramps (`#b3bdcc`, `#b5bdc9`, `#818da0`), not from grey.

### Map Data

Choropleth maps (rental zones, rental analysis) paint values, not surfaces, so their colors are a
sequential ramp rather than theme tokens — the fill has to stay readable against OpenStreetMap
tiles, which are neither of our canvases.

- **Map Shade 1–5** (`#d6e6f5`, `#a8cae8`, `#75a9d7`, `#3d80ba`, `#15517e`): the five quantile bins
  of the active metric, light to dark. Always fills at `0.78` opacity over the tile layer, always
  accompanied by a legend that repeats each bin's numeric range: color alone never carries the
  value.
- **Map No Data** (`#d4d9df`): a zone with no measurement. Deliberately outside the blue ramp — it
  is not "the lowest value", and the legend labels it as its own class.
- **Map Ink** (`#102c4c`): the stroke of the selected polygon and of `:focus-visible`, over the
  white `#ffffff` stroke every other polygon carries. It is the only place this value is used, and
  never as a fill or as text.

### Named Rules

**The Signal Has a Job Rule.** Saturated colors must communicate action, selection, tier, or status;
large decorative color fields are reserved for authored feature surfaces.

**The Paired Contrast Rule.** Every colored surface must be checked in both themes; permanent dark
slabs use the shared `.on-dark` treatment.

**The Link Token Rule.** Blue text below 16px uses `rgb(var(--v-theme-link))`. `--v-theme-primary`
is for fills, strokes, and focus rings — as small text on a tinted panel it fails AA.

**The Map Ramp Is Data Rule.** The map shades, Map No Data and Map Ink are the only literals allowed
outside the theme tokens, and only inside a map layer: they answer to the tile background, not to
the light/dark surfaces. They never become text, chips, borders or panel fills, and a map that uses
them always ships the legend that decodes them. The ramp lives twice today —
`components/rentals/zones/Explorer.vue` and `utils/rentalAnalysisMap.ts` — and the two must stay
identical; a third map imports one of them instead of pasting new hexes.

**The Ramp Not Grey Rule.** Secondary text on a colored surface is tinted from that surface's own
hue ramp. A grey (`#9e9e9e`, `#757575`) on either canvas fails AA and is remapped away globally in
`critical.css`.

## Typography

**Display Font:** Open Sans (sans-serif fallback)
**Body Font:** Open Sans (sans-serif fallback)

**Character:** A single workhorse family keeps rates, labels, tools, and long-form explanations in
the same practical voice. Hierarchy relies on weight, scale, measure, and rhythm — not decorative
font switching.

### Hierarchy

- **Display** (800, `clamp(1.55rem, 4.4vw, 2.5rem)`, 1.1, -0.02em, `text-wrap: balance`): the page
  thesis. This is the `.hero-title` idiom shared by the landing heroes; the tracking tightens as the
  size grows.
- **Content Title** (300, `clamp(1.5rem, 3vw, 2.125rem)`, 1.14, -0.02em, `text-wrap: balance`): a
  heading whose text is *content* rather than a written label — a product name, a full sentence, a
  question. It is capped below Display because its length is not ours to control: the same rule at
  Display size turned a 62-character section heading into four lines and a quarter of the viewport.
  Pair it with a measure of 24–28ch so it breaks at two lines, never four.
- **Headline** (700, `clamp(1.35rem, 3vw, 1.75rem)`, 1.2): major section openings inside a page.
- **Title** (500, 1.25rem, 1.6): card, tool, and dialog titles. Matches Vuetify `text-h6`.
- **Lead** (400, 1.075rem, 1.65, measure 72ch): the paragraph that answers the page's question
  before any section does — the `.lead` under an `h1`. It is a *documented role, not drift*: the
  same declaration appears verbatim on 18 pages, and it is deliberately the smallest step that
  still separates the opening paragraph from body copy. Flattening it to Body would erase that
  separation on all 18 at once, which is why the ramp records it instead. One per page.
- **Body** (400, 1rem, 1.5): reading text; prose stays within 65–75ch.
- **Label** (700, 0.75rem, 0.0333em): metadata, compact controls, status labels, table meta.
- **Eyebrow** (700, 0.75rem, 0.18em, uppercase): the small colored kicker above a display heading or
  a section title. One per section at most.

Vuetify's Material 2 ramp (`text-h1` 6rem … `text-overline` 0.75rem) remains available for stock
components; authored page surfaces use the roles above.

### Named Rules

**The Number Breathes Rule.** Important rates and scores get surrounding quiet and tabular
alignment (`font-variant-numeric: tabular-nums`); they are not crowded with competing labels.

**The Four Steps Rule.** Dense data surfaces get four micro sizes and no more: 0.75rem (label),
0.8rem (meta), 0.875rem (list text), 0.95rem (secondary body). Nothing renders below 0.75rem. A
page that grows a fifth step is drifting, not designing. The four are now in the front-matter ramp
as `label` / `meta` / `list-text` / `secondary-body`, so the detector can tell a documented step
from a page-local invention — before, the rule lived in this prose only, and every one of the four
read as drift.

**The Stat Step.** A headline figure in a stat or metric card is `stat` (700, 1.5rem, tabular
nums) — the one size above Title that is not a heading. It exists because The Number Breathes Rule
needs a size to breathe at, and because 14 files had already converged on it.

**A Glyph Is Not Type.** A single decorative glyph — an emoji, a medal, a tier letter — is sized
like an icon, not off the ramp; `2rem` on `.cv-emoji`, `.podium-medal` and `.tier-letter` is
deliberate and stays. The distinction is whether the character is *read as text*: a number in a
card is, a 🥇 is not. Anything that is read as text uses a documented step.

**The One Eyebrow Rule.** A kicker marks a section that earns one. An eyebrow above every block is
grammar nobody chose.

## Layout

Content sits in a single centred column: `.container_custom` caps the page at **1280px** with 12px of
its own gutter (each page's `<VContainer>` adds the other 16px), and full-bleed routes (home, `/mapa`, `/sucursales`, `/avanzado`) opt out by route class
rather than by page-local CSS. The app bar is fixed; `VMain` reserves 64px (56px under 768px).

Breakpoints are pinned to the Vuetify 3 thresholds in **both** `useDisplay()` and
`$grid-breakpoints` — xs 0, sm 600px, md 960px, lg 1280px, xl 1920px, xxl 2560px — so JS reflow and
CSS reflow happen at the same width. Vuetify 4's narrower defaults (md 840, lg 1145) are explicitly
overridden; keep the two lists in sync.

Spacing runs on a 4/8/16/24/40px rhythm, and a control group reads by the **contrast** between its
intervals, not by their size: **8px inside a group, 24px between groups**, roughly three to one. A
panel that uses one value for both — the filter rail shipped 20px everywhere — flattens into a
single evenly-spaced list no matter how correct each label is.

Desktop can support side rails, sticky markers, wide
comparison regions, and dense controls; mobile reorders the same evidence into one readable column
with 44px-or-larger interactive targets. Wide tables either use Vuetify's native `:mobile` mode or
the `cu-mobile-cards` contract: put the class on the table, give every `<td>` a `data-label`, and
below 600px each row stacks into a labelled card. A table whose cells run past one line adds
`cu-roomy` alongside it, which owns the ≥600px rhythm the card mode never covered.

### Named Rules

**The Page Brings Its Container Rule.** The layout's 12px is not the gutter; it is the half the
layout can promise. Every page root is a `<VContainer>` (or a wrapper whose first child is one),
which adds the other 16px and the shared width cap, so content starts 28px from the edge on a phone
and sits in the same column on every route. A bare `<div>` or a `<v-row>` at the root gets only
the 12px — and Vuetify 4's grid no longer hides that behind the v3 negative row margins — which is
how 57 of 214 templates measured 12px on 2026-09-12 while the rest measured 28. Use `fluid` where
width is information (charts, wide tables, the branches directory) so the 1200px cap does not crop
them; the full-bleed exceptions stay on the route class, never on the page.
`tests/unit/pageContainer.test.ts` reads the source; `npm run audit:gutters` reads the rendered
result.

**The Layout Owns The Top Rule.** The same 28px holds above the page: the layout's 12px plus a
16px first container, so content starts the same distance from the app bar as from the edge. The
page's own `py-*` no longer reaches the top — each page had chosen 16, 24, 32, 40 or 48 (56 pages
shipped `py-6 py-md-10`, 43 shipped `py-8 py-md-12`) and a breadcrumb added Vuetify's 16 on top,
so on 2026-09-22 the crumbs of `/celulares-uruguay` sat 67px under the bar and the H1 at 115: the
breadcrumb was farther from the bar than from the title it belongs to. A breadcrumb that opens the
page is the layout's on both sides: no top padding, 8px below it, the `<ul>`'s browser margin
zeroed and the next block's `margin-top` zeroed, with or without the family bar. Measured the same
day across the 47 pages that open with one, the crumb-to-title gap was 20, 24, 31, 32 or 40px:
`VBreadcrumbs` renders a `<ul>` that keeps its 16px `margin-bottom` (The Text Block Owns Its Top
Margin Rule), a direct `<h1>` adds 22.8 that collapse with it, and two headers brought 16–20 of
their own. The one thing the rule cannot reach is a margin that collapses *through* the next
block — equipar's eyebrow carried 12px from `.equipar p` and surfaced above its `<header>` — and
that is the page's to zero.
The rule lives in `layouts/default.vue` next to `.container_custom` and is the sibling of the one
`FamiliaNav.vue` applies after the family bar: when the bar renders it is the first child and owns
the gap; when it does not, the layout does. `tests/unit/layoutTop.test.ts` reads the source.

**The Rail Is Not The Column Rule.** A desktop ad rail comes out of the 1280px cap; it never
widens it. At lg the reading column already fills the cap edge to edge — there is no free gutter
until ~1928px — so the only honest place for a 300px rail from 1280 is inside the cap:
`.container_custom--rail` turns the container into a two-column grid (reading column, 24px gap,
300px), and `max-width`, `padding` and `.layout-tail` do not change, so the top and side
arithmetic (12 + 16 = 28) holds and the tail keeps its own column inside column 1. Everything
that is not the rail is pinned to column 1 — the page root, the family bar, the tail, the
content-end unit and whatever Auto Ads inserts (`.google-auto-placed`) — because a grid child
with no explicit column auto-places into the first free cell, and that cell can be the rail's
(the cars directory had exactly this bug with a `VRow`). The rule is unscoped on purpose: half of
the container's children carry no layout scope attribute (the page root is rendered by
`NuxtPage`, Google's div by Google), so a scoped rule compiles dead without an error. The grid
exists only while the rail renders — `useAds().canRender('sidebar')`: publisher id, slot id,
`normal` density — and only from 1280px; without it the route is byte-for-byte what it was,
including the margin collapsing between the container's children, which a grid stops. The rail
sticks at 80px (the 64px bar plus the 16px the page keeps under it), and that is acceptable only
because it covers nothing: it lives in its own column. A rail whose unit goes unfilled leaves its
column empty rather than reflowing the text to full width — a wider column mid-read is the
layout shift the reserved box exists to prevent. `tests/unit/adRail.test.ts` reads the source.

**The Min-Width Zero Rule.** Any grid or flex item that can contain an image, a long thread title,
or a URL carries `min-width: 0`. Without it the item's min-content floor silently widens the whole
row past the viewport — the single most common responsive defect in this codebase.

**The Table Row Is Not One Line Rule.** Vuetify's table densities are calibrated for a cell holding
one line, and every comparison surface here holds two or three: a value plus its caption. At
`comfortable` the 8px of vertical padding that separates two one-line rows has to separate two
three-line rows, and the table renders as a grey slab — the arrangements comparativa shipped that
way and read as compressed even though every individual value was right. `cu-roomy` (in
`assets/css/responsive-tables.css`) is the shared fix: 14px above and below each cell, a 4px lead-in
between a value and its caption, and `vertical-align: top` so rows of unequal height still start on
one baseline. Two things it deliberately does not do — flip a short caption's alignment away from
the number it belongs to (that stays inherited; `cu-cell-prose` is the opt-in for text that actually
wraps), and touch anything below 600px, where the card mode already owns the width.

**The Repeated Constant Is Not A Column Rule.** Before styling a dense table, check what actually
varies down each column. The same comparativa carried `salud $ 900` and `limpieza $ 2.100` identical
in every row and the boleto in all but one, inside the widest caption on the page: roughly half the
ink was spent restating constants, which no amount of padding fixes because the problem is the
content, not the rhythm. A value that does not change from row to row leaves the table and is stated
once beneath it. Decompression is usually a data edit before it is a CSS edit.

**The Legend Is Not A Flex Item Rule.** A rendered `<legend>` is the fieldset's legend box, so a
`display: flex` or `display: grid` fieldset never applies its `gap` to it. The label sits flush on
its own controls at 0px and the CSS looks correct while the spacing is absent. Give the legend an
explicit `margin-bottom`; never let a fieldset's `gap` be the only thing separating a label from
what it labels. The same holds for any element excluded from its parent's layout box — check the
computed gap, not the declared one.

**The Two Intervals Rule.** Anything that stacks labelled groups — a facet rail, a settings list, a
form — declares exactly two vertical intervals and keeps them at least three times apart: the tight
one inside a group, the generous one between groups. If a spacing value has to serve both roles,
the grouping is being carried by labels alone, and the reader has to do the work the layout was
supposed to.

**The Text Block Owns Its Top Margin Rule.** Vuetify 4's reset does not zero the margins of `<p>`,
`<h1>`–`<h6>`, lists, `<blockquote>` or `<pre>`, so every text block still carries the browser's
`margin-block: 1em`. Between stacked siblings it collapses and looks deliberate; inside a padded box
or a flex/grid item it cannot collapse, and against a smaller declared gap it simply wins. Both
failures read as *our* spacing, which is why they survived on 20 pages: a callout title rendered
16px below the icon it belongs beside, and an intro asking for 2px rendered at 14px.
`critical.css` neutralises the `:first-child` case with a zero-specificity `:where()` rule, so any
authored margin — including an `mt-*` utility — still wins. Everything else is on the author: a text
block whose vertical position matters declares its own `margin-top`, and a page that wants the whole
question closed resets `:is(p, h1, h2, h3, ul, ol, pre) { margin-top: 0 }` in its scoped block and
declares each gap (see `pages/recibir-regalos-del-exterior-uruguay.vue`). Never read a gap off the
screen and assume the CSS asked for it — `npm run audit:margins` reports every element still spaced
by the browser rather than by us.

**The Four-Pixel Grid Rule.** Vuetify's own spacing scale is multiples of four — `pa-1` is 4px,
`pa-2` is 8px, `ga-3` is 12px — so every gap, padding and margin we author by hand must land on that
same grid, and preferably on the named steps (4/8/16/24/40). Anything in between reads as a mistake
next to a Vuetify control that is exactly on it. The values that slip in are always the ones typed to
make one thing look right in isolation: a card body shipped `12px 14px 14px`, a chip row `gap: 6px`
with `margin: 10px 0 4px`, a toolbar `padding: 6px 0` and a count badge `margin-left: 5px`. None is
visible on its own; together they are why a dense grid looks hand-placed. The exceptions are values
that are not spacing at all — a 44px touch target, a 1px border, an optical nudge on an icon — and
those state their arithmetic in a comment instead of hiding a 13 in an `inset`.

**The Neighbour Owns The Gap Rule.** Exactly one element declares the space between two siblings,
and it is the one that is always present. A conditional element cannot own it: the filter chips
render only when a filter is set, so when they owned their top margin the results heading sat 12px
below the sticky bar with chips and flush against its border without them — and each of the three
autos pages had patched around it with a different utility class (`mt-2` on one, `mt-3` on the other
two). Give the permanent element — here the sticky toolbar — a `margin-bottom`, and everything that
can follow it inherits the same rhythm for free. Two neighbours that both declare the gap are worse
than neither: the larger silently wins and the smaller becomes a lie in the stylesheet.

**The Columns Share A First Line Rule.** In a two-column layout the reader's eye sets a horizontal
baseline from whatever ink appears first on each side, so the filter rail's first field and the
results column's first element must share a `top`. They drift apart for reasons that look unrelated
to alignment: a chip row's own `margin-top` pushed the right column 10px down while the left column
started at the container edge. Measure both columns' first child, not the grid cells — the cells
were already aligned in the case that shipped.

## Elevation & Depth

Depth is a hybrid of tonal layering and restrained Material 2 elevation. Most surfaces separate
through navy-on-midnight or white-on-paper contrast; shadows identify menus, dialogs, interactive
hover states, and transient layers rather than decorating every card. The full `.elevation-0`…`-24`
scale is restored in `legacy-vuetify.css`, so stock Vuetify components keep their MD2 shadows.

### Shadow Vocabulary

- **Quiet Lift** (`0 6px 16px rgba(0, 0, 0, 0.35)` dark / `0 6px 16px rgba(0, 0, 0, 0.15)` light):
  interactive cards and compact menus, paired with a −3px `translateY` on hover.
- **Overlay Depth** (`0 16px 40px rgba(0, 0, 0, 0.32)`): dark menus and dialogs; light mode uses a
  softer `rgba(20, 25, 40, 0.14)`.
- **Feature Cast** (`16px 24px 48px rgba(0, 0, 0, 0.28)`): the offset cast under an authored feature
  panel that sits on a dark slab.

### Named Rules

**The Flat Until Needed Rule.** Content cards are tonal at rest. Depth arrives when a surface can be
opened, moved, selected, or hovered.

**The Offset And Blur Rule.** Every shadow carries both an offset and a blur. A zero-offset colored
halo is decoration, not depth.

## Shapes

Gently curved rectangular surfaces on a five-step scale: **4px** for stock Vuetify controls, **8px**
for compact records and list rows, **12px** for cards and inner panels (the most-used radius in the
codebase), **16px** for feature surfaces, hero slabs, and dialogs. Pills (`999px`) are reserved for
chips, filters, tier badges, and status dots; circles (`50%`) for icon-only actions and dots.

The scale is now literal: on 2026-08-11 the 111 declarations that still sat on the old midpoints
were snapped to it — 10px and 14px (both card and panel surfaces) to **12px**, 18px and 20px to
**16px**. Before that sweep 14px alone accounted for 80 declarations across 49 files, which made
"legacy midpoint" a description of the documentation rather than of the code. A new radius outside
these four steps is drift; round to the nearest one instead of adding a fifth. Borders are
one-pixel, quiet, and theme-aware (`rgba(var(--v-border-color), var(--v-border-opacity))`), never a
thick colored edge.

### Named Rules

**The No Side Tab Rule.** A card, list item, or callout never carries a colored border above 1px on
one side. State goes in a labelled chip, not in a stripe.

## Components

### Buttons

- **Shape:** Compact Vuetify controls (4px radius) with at least a 40px visual height.
- **Primary:** Action Blue, white text, a clear verb, uppercase (`.v-btn` casing is restored) at
  weight 600.
- **Hover / Focus:** 150–200ms tonal or lift response; `:focus-visible` always shows the 2px Action
  Blue outline with a 2px offset from `critical.css`.
- **Ghost:** Transparent, using the theme's accessible link blue.
- **Padding:** a button keeps Vuetify's own inline padding for its size (8/12/16/20/24px). The
  hover and focus box is the button's border box, so `px-0` or `padding-inline: 0` leaves the
  label — and a prepend icon, which sits in a negative margin — on the edge, and the control reads
  as cut off. A label that has to align with the paragraph above it uses `cu-btn-flush`
  (`legacy-vuetify.css`): the padding stays and a negative start margin moves the box.
  `scripts/btn-overflow-audit.mjs` reports the breach as `FLUSH`.

### Chips

- **Style:** Pill-shaped, concise, and semantic; filled for selected states, quiet tonal treatments
  for metadata. Weight 600.
- **State:** Never use hue alone — retain text, icon, or tier letter.

### Cards / Containers

- **Corner Style:** 16px for feature surfaces, 12px for cards and inner panels, 8px for compact
  records.
- **Background:** Navy Surface in dark mode, Paper Surface in light mode.
- **Shadow Strategy:** Tonal by default, Quiet Lift for interactive cards.
- **Border:** One-pixel low-contrast border when adjacent surfaces would otherwise merge.
- **Internal Padding:** 16px compact, 24px standard, 32–40px for feature surfaces.

### Inputs / Fields

- **Style:** Filled or outlined Vuetify fields with explicit labels; 48px tall.
- **Focus:** Primary-color stroke plus the global focus-visible outline.
- **Error / Disabled:** Semantic color plus readable copy; light-theme labels, hints, and messages
  are forced to `rgba(0, 0, 0, 0.6)` at full opacity so Vuetify's emphasis multiplier cannot wash
  them below AA.

### Navigation

Medium-weight Open Sans, rounded active regions, short tonal transitions. Desktop exposes broad
wayfinding in the app bar and mega menu; mobile consolidates the same source of truth into a drawer
built from `utils/siteNav.ts`, so a route can never exist in one and be missing from the other. A
skip link is the first focusable element on every page.

### Permanently Dark Slab (`.on-dark`)

Used on 27 pages. A hero or feature slab whose dark/colored background does **not** flip with the
theme is tagged `.on-dark` on its root; a global block in `critical.css` then keeps its descendant
text light in the light theme, overriding the grey/`text-white` remaps that would otherwise darken
text onto a dark surface. Marking the slab is the contract — nothing else is needed.

### Evidence Card

Evidence cards bind a result to sample size, date, provenance, and a direct source link. The score
may lead visually, but its method and uncertainty remain one gesture away. Sentiment is carried by a
labelled tone tag (dot + word), never by hue alone.

### Share Row

`ShareButtons` closes 41 pages: a single row of share targets in the page's own voice, placed with
the closing call to action rather than floating over the content.

## Do's and Don'ts

### Do:

- **Do** keep source, freshness, and uncertainty adjacent to consequential rankings.
- **Do** compose a distinct mobile reading order rather than scale down a desktop grid.
- **Do** use shared Vuetify semantics and the existing theme tokens for controls and states.
- **Do** preserve readable content and meaning when reduced motion is requested — the global
  `prefers-reduced-motion` block already flattens durations; do not fight it.
- **Do** put `min-width: 0` on grid and flex items that can hold media or long unbroken strings.
- **Do** register every new route in `utils/siteNav.ts` so nav, sitemap, search, and the command
  palette stay in agreement.

### Don't:

- **Don't** add a second generic stylesheet or import `vuetify/styles`.
- **Don't** use glass, glow, gradients, or icon tiles as substitutes for authored information.
- **Don't** make permanently dark sections depend on dark-theme inheritance.
- **Don't** hide a ranking's sample size or present AI prose as the underlying measurement.
- **Don't** use `--v-theme-primary` for small blue text, or a raw sentiment hue for any text.
- **Don't** invent a sixth radius step or a fifth micro type size for one surface.
- **Don't** author a spacing value off the 4px grid (no 6px, 10px, 14px), and don't let two
  neighbouring elements each declare the gap between them.