.material-symbols-outlined {
  font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
  vertical-align: middle;
  /* WEB.22 (2026-10-04): an icon is its ligature name in plain text until
     the font arrives -- "favorite_border" is 135px wide in the fallback
     font, 20px as an icon -- and the swap moved the listing page's content
     (PageSpeed CLS 0.095). One em square from the first paint, so the
     font landing changes nothing around it. */
  width: 1em;
  overflow: hidden;
}

/* User-reported (2026-09-15): create-listing.html/create-classified.html
   scrolled sideways into a blank patch roughly one button's width wide, on
   both a fresh form and an edit-mode one -- present regardless of content,
   which points at layout (the off-canvas mobile-drawer sidebar, present
   identically on every admin page, `position:fixed; transform:
   translateX(-100%)`) rather than any specific field/row. The original
   author couldn't pin down the exact element with the Browser pane's
   tooling and added this rule to enforce the invariant this codebase
   already documents as a hard rule -- "the page body must never scroll
   sideways" (groundwork-conventions skill).

   2026-09-21: the culprit WAS found, and it was not the drawer. It was
   create-listing.html's `#landmark-name-input`: a `flex-1` text input in a
   `flex items-center gap-2` row with no `min-w-0`. A flex item's min-width
   defaults to `auto`, which for a text input resolves to its default
   intrinsic width (size=20, 197px), so flex-1 could not shrink it. At a
   375px viewport the row had 301px and `#landmark-add-btn` was pushed to a
   right edge of 381px -- 6px past the viewport, and sliced rather than
   scrolled-to because of this very rule. Measured with the rule lifted:
   documentElement.scrollWidth 381 vs clientWidth 375; setting min-width:0
   on that one input alone took it to 375/375 and the button's right edge
   back to 338. `min-w-0` is now on the input.

   2026-09-22: the rule is GONE, and the last thing it was hiding is fixed.

   Its own note said the cost of keeping it was that a real overflow gets
   silently clipped instead of announcing itself. That is precisely what had
   happened: with the rule lifted at 375px, the public hamburger menu made
   the document 425px wide -- 50px past the viewport, "Want Joint-Venture"
   cut mid-word on every phone. See .nav-dropdown-menu-mobile below for the
   cause and the fix. The net was not protecting the site from an unknown
   overflow; it was concealing a known-able one.

   What replaced it: the containment is per-element and deliberate now --
   `truncate` on every row cell that carries unbounded agent text
   (2026-09-22), `min-w-0` on the flex inputs that caused the original
   report, and each wide container's own `overflow-x: auto`. Verified at
   375px with real production data plus a deliberately hostile entry (a
   240-character unbroken URL as headline AND location, 14 long tags, a
   12-digit price): 0px of document overflow on the public feed, the hub
   pages and /l/:id, in both themes, with every nav dropdown open.

   What could NOT be verified, stated plainly: the admin pages
   (contacts/deals/index/tasks/enquiries) render their rows only for a
   signed-in session, and local D1 is knowingly broken, so their
   data-populated state was measured only as far as their static shells
   (0px) plus the row markup itself, which carries `truncate` on every
   unbounded cell. If a sideways scroll ever reappears on an admin page,
   that is where to look first -- and it will now announce itself instead
   of being clipped.

   Historical note worth keeping: if a backstop is ever re-added, it must be
   `clip`, never `hidden`. Any `overflow-x` other than `visible` or `clip`
   on html/body makes the box a scroll container, and `position: sticky`
   computes against its nearest scroll container -- `hidden` broke every
   sticky header on the site when it was tried. */

/* Native form controls have no bg utility class anywhere in the markup
   (admin create/edit forms, public enquiry form) — they were relying on
   Tailwind CDN's Preflight forms reset (`[type="text"], input:where(...),
   textarea, select { background-color: #fff; ... }`), which hardcodes
   white and doesn't respond to dark mode. That Preflight rule's
   attribute-selector list outranks a plain `input, textarea, select`
   selector on specificity, so !important is needed here to reliably win
   regardless of stylesheet injection order. One rule covers every
   surface instead of touching the duplicated/drift-guarded enquiry-form
   HTML in publicPageRender.js and listing-public.js.

   Checkboxes and radios are EXCLUDED (2026-09-21) and handled by their own
   rule below. They are not text fields: the forms plugin paints their
   :checked state with `background-color: currentColor` and drops the border
   with `:checked { border-color: transparent }`. A blanket !important
   background beat the first half and not the second, so a TICKED box kept
   the unticked fill and lost its border — measurably less visible than an
   unticked one (light: fill rgb(241 237 236) on rgb(253 251 250), 1.13:1,
   border rgba(0,0,0,0); dark: fill rgb(31 27 27) on rgb(5 8 15), 1.17:1).
   Three pages had each patched this for their own checkbox class; the fix
   belongs here, once, for every control on the site. */
input:not([type=checkbox]):not([type=radio]), textarea, select {
  background-color: rgb(var(--color-surface-container)) !important;
  color: rgb(var(--color-on-surface)) !important;
}

/* iOS Safari zoom-on-focus (2026-09-22). Safari on iOS zooms the whole
   viewport whenever a focused text field's font-size is under 16px: the page
   jumps, the field slides toward the keyboard, and the user has to pinch back
   out. Every text control on this site was under that line -- 87 admin
   controls and 7 of the 8 public ones, all on the `text-body-sm` utility
   (13px), none at 16px or above. The two that matter most are the public
   enquiry form, which is the buyer's conversion path, and create-listing,
   where an agent enters a listing from their phone.

   Scoped below 768px so the design is unchanged on the desktop widths it was
   drawn for -- this is a mobile input-handling fix, not a type-scale change.
   Line-height is raised with it because `text-body-sm` pairs 13px with an
   18px line box, which is too tight to hold 16px text in a textarea.

   !important for the same reason the background rule above needs it: these
   sizes come from utility classes (0,1,0), which outrank a bare element
   selector. Checkboxes and radios are excluded -- they render no text, so
   font-size does not trigger the zoom and a 16px box would just be bigger. */
@media (max-width: 767px) {
  input:not([type=checkbox]):not([type=radio]), textarea, select {
    font-size: 16px !important;
    line-height: 1.5 !important;
  }
}

/* Focus indicator (2026-09-22). Every form control carries
   `focus:border-secondary focus:ring-0` -- a deliberate pair, not a bare
   removal: the Tailwind forms plugin's default ring is switched off and the
   border recolours to secondary, which is 9:1 against the surface and plainly
   visible. What it is not is thick. WCAG 2.2's 2.4.11 asks a focus indicator
   to cover at least a 2px perimeter or equivalent area, and a 1px border
   changing colour does not reach that.

   Stated as `outline` rather than by editing 129 utility pairs, and that is
   what makes it safe: the plugin's ring is a box-shadow, so `ring-0` cannot
   touch `outline` and no specificity fight arises -- these two indicators
   stack rather than compete, giving the border recolour plus a 2px ring.

   :focus-visible, not :focus, so a mouse click on a button does not paint a
   ring the pointer user did not ask for, while keyboard and AT navigation
   still get one. Applied to links and buttons too, which previously relied on
   the browser default that Preflight strips. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid rgb(var(--color-secondary));
  outline-offset: 2px;
}

/* Checkbox/radio treatment, tokenised, both themes — the site-wide version
   of the .task-done-checkbox / .listing-task-checkbox / .panel-task-checkbox
   rules that public/tasks.html, public/create-listing.html and
   public/index.html each carried separately until 2026-09-21.

   Unchecked still needs its background stated, for exactly the reason the
   rule above documents: Preflight's forms reset hardcodes #fff and never
   responds to dark mode. No !important is needed here — `input[type=…]`
   (0,1,1) already outranks Preflight's `[type=…]` (0,1,0), and the :checked
   rules below (0,2,1) outrank the plugin's `[type=…]:checked` (0,2,0).
   Border is deliberately left alone when unchecked so the pages that put a
   `border-outline-variant` utility on their boxes keep it.

   The fill has to be the DARK half of the secondary pair in both themes,
   because the plugin's tick/dot glyph is a hardcoded white SVG that no
   token can recolour:
     light — fill --color-secondary (deep brown), white tick, 9.1:1 on the
             page background;
     dark  — --color-secondary is a bright amber a white tick disappears
             into, so the fill flips to --color-on-secondary (the pair's
             dark half) and the amber moves to the border, where it carries
             the box at 11.8:1 against --color-background.
   Same reading in both: a solid box with a light tick. */
input[type=checkbox], input[type=radio] {
  background-color: rgb(var(--color-surface-container));
  color: rgb(var(--color-on-surface));
}
input[type=checkbox]:checked, input[type=radio]:checked {
  background-color: rgb(var(--color-secondary));
  border-color: rgb(var(--color-secondary));
}
[data-theme="dark"] input[type=checkbox]:checked,
[data-theme="dark"] input[type=radio]:checked {
  background-color: rgb(var(--color-on-secondary));
  border-color: rgb(var(--color-secondary));
}

/* Token-ised 2026-09-21. These were three hardcoded zinc greys left over from
   the pre-2026-08-18 "Forest & Cream" palette, and because they were literals
   they never heard about dark mode: on a near-black page (#05080F) the
   scrollbars stayed light grey, a bright rail down the side of every dark-mode
   screen. The light-mode values shift by a few RGB points at most (#F4F4F5 ->
   #F1EDEC, #D4D4D8 -> #DDD9D9, #A1A1AA -> #8F909A) -- off zinc's cool neutral
   and onto the palette's warm one, which is the same move the rest of the site
   made in that redesign.

   Still webkit-only, deliberately. Setting the standard `scrollbar-color` /
   `scrollbar-width` would give Firefox these colours too, but Chrome ignores
   ::-webkit-scrollbar entirely once either standard property is set, so the
   8px width here would silently become whatever `thin` resolves to. That is a
   visual change to every Chrome user for a Firefox benefit -- its own
   decision, not a side effect of fixing the colours. */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: rgb(var(--color-surface-container)); }
::-webkit-scrollbar-thumb { background: rgb(var(--color-outline-variant)); border-radius: 0px; }
::-webkit-scrollbar-thumb:hover { background: rgb(var(--color-outline)); }

.listing-row:nth-child(even),
.contact-row:nth-child(even),
.deal-row:nth-child(even),
.enquiry-row:nth-child(even) { background-color: rgb(var(--color-surface-container-low)); }

/* Active filter pill — admin and public share one palette as of the
   2026-08-18 institutional redesign (public/theme-tokens.css), so the
   admin-vs-.public-theme split this rule used to need no longer applies.
   Uses the secondary token directly. .type-filter-btn (2026-08-25, admin's
   Sale/Rent pills), .tier-filter-btn (admin's All/Full/Classified pills),
   and .subcategory-filter-btn (2026-09-14, admin's subcategory fine-tune
   row) are each a separate class from .filter-btn on purpose — their click
   wiring must not be swept up by .filter-btn's own querySelectorAll
   (category) loop — but they share this same active-pill look. */
.filter-btn.active,
.type-filter-btn.active,
.tier-filter-btn.active,
.subcategory-filter-btn.active {
  background-color: rgb(var(--color-secondary));
  color: rgb(var(--color-on-secondary));
  border-color: rgb(var(--color-secondary));
}

/* Public top nav (2026-09-13 nav restructure) — Buy/Rent, the Login/
   Profile auth slot, and the mobile hamburger are all plain
   <details>/<summary> disclosures; nothing here needs a click handler to
   open/close (public-nav.js only ever swaps the auth slot's *content*, see
   that file). .nav-pill is a distinct class from .filter-btn on purpose —
   nav links have no JS click-wiring to keep isolated from (unlike
   .filter-btn/.type-filter-btn/.tier-filter-btn's querySelectorAll
   concern above), this is just a separate visual role: top nav vs.
   in-page filter pill. */
.nav-pill { color: rgb(var(--color-on-surface-variant)); }
.nav-pill:hover { background-color: rgb(var(--color-surface-container)); }
.nav-pill.active {
  background-color: rgb(var(--color-secondary));
  color: rgb(var(--color-on-secondary));
}
.nav-dropdown > summary { list-style: none; cursor: pointer; }
.nav-dropdown > summary::-webkit-details-marker { display: none; }
.nav-dropdown-menu {
  position: absolute;
  top: 100%;
  left: 0;
  margin-top: 0.375rem;
  min-width: 12rem;
  background-color: rgb(var(--color-surface));
  border: 1px solid rgb(var(--color-outline-variant));
  border-radius: 0.5rem;
  box-shadow: 0 4px 16px rgb(0 0 0 / 0.16);
  z-index: 40;
  padding: 0.375rem;
  display: flex;
  flex-direction: column;
}
/* The auth slot and the mobile hamburger sit at the right edge of the
   header — a left-aligned dropdown would overflow off-screen there, so
   both open right-aligned instead. max-width clamp (2026-09-15,
   user-reported): on some narrow phones the header's own horizontal
   padding (px-4 on the shell wrapper) plus this box's min-width (12rem)
   pushed its right edge past the true viewport edge, clipping ~10% of it
   off-screen -- right:0 alone only guarantees alignment to the anchor,
   not that the box fits the viewport. Clamping to the viewport width minus
   a safety margin guarantees it never exceeds the screen regardless of
   anchor padding math. */
#public-nav-slot .nav-dropdown-menu,
.nav-dropdown-hamburger .nav-dropdown-menu {
  left: auto;
  right: 0;
  max-width: calc(100vw - 1.5rem);
}
.nav-dropdown-menu a {
  padding: 0.5rem 0.75rem;
  border-radius: 0.25rem;
  font-size: 0.8125rem;
  color: rgb(var(--color-on-surface));
}
.nav-dropdown-menu a:hover,
.nav-dropdown-menu a.active { background-color: rgb(var(--color-surface-container)); }

/* Touch targets (2026-09-22). These links are 36px tall from their own
   0.5rem padding, and the theme toggle is 32px wide from p-1.5 around an 18px
   icon -- both under the 44px a thumb needs, and on the public nav, which is
   where a buyer arrives on a phone.
   Scoped to coarse pointers and phone widths so a mouse-driven dropdown keeps
   its compact 36px rhythm: this is a thumb problem, not a layout one. The
   menu is already flex-column, so min-height on the item is enough -- no
   display change, nothing reflows on desktop. */
@media (max-width: 767px), (pointer: coarse) {
  .nav-dropdown-menu a {
    min-height: 44px;
    display: flex;
    align-items: center;
  }
  .theme-toggle-btn {
    min-width: 44px;
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  /* The public feed's category/type/tier pills measured 28px -- 12px of
     label-sm plus 6px of padding either side. They sit in a horizontally
     scrolling row, so they are the first thing a thumb reaches for on the
     page a buyer lands on. All three classes are listed because they are
     deliberately separate (see the note by their .active rule): each facet's
     querySelectorAll must only ever match its own buttons. */
  .filter-btn,
  .type-filter-btn,
  .tier-filter-btn {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }
  /* Text fields land at 38px once they are 16px -- still short of a thumb.
     Checkbox and radio keep their own size: they are sized deliberately and
     already sit inside a 44px label (see tasks.js's w-11 h-11 wrapper). */
  input:not([type=checkbox]):not([type=radio]):not([type=range]),
  select {
    min-height: 44px;
  }
  /* The feed card's Enquire control is a bare text link, 14px tall -- the
     smallest target on the page, and the one the whole public feed exists to
     get tapped. Padding rather than min-height alone so the tappable area
     grows around the text instead of leaving it floating in a tall box; the
     negative margin keeps the card's own spacing unchanged. */
  .enquire-btn {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    padding-inline: 0.5rem;
    margin-inline: -0.5rem;
  }
}
.nav-dropdown-group-label {
  padding: 0.5rem 0.75rem 0.125rem;
  font-size: 0.6875rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: rgb(var(--color-on-surface-variant));
}
/* Mobile hamburger menu — the same .nav-dropdown-menu look, without the
   floating panel's shadow.

   It used to be `position: static; width: 100%`, intended as a full-width
   drawer. It never was one: the <details> it lives in sits inside a
   `shrink-0` flex item at the right edge of the header, so `width: 100%`
   resolved against a ~44px button and .nav-dropdown-menu's own
   `min-width: 12rem` won — a 192px box whose LEFT edge started near the
   right edge of a 375px screen. Measured: left 233, right 425, 50px past
   the viewport, with "Want Joint-Venture" cut mid-word.

   Nobody saw it because `html, body { overflow-x: clip }` (removed in the
   same change, see the top of this file) clipped the overflow instead of
   letting the page scroll to it. Static positioning also defeated the
   `.nav-dropdown-hamburger .nav-dropdown-menu { left: auto; right: 0 }`
   rule above, which was written for exactly this menu — dropping `position`
   here lets that rule do its job. Verified at 375px with the backstop
   lifted: left 167, right 359, 0px of document overflow. */
.nav-dropdown-menu-mobile { width: auto; margin-top: 0.5rem; box-shadow: none; }

/* Tactile press feedback (DESIGN.md §6) — a real button gives under your
   thumb. Applied to primary CTAs, not every clickable element. */
.tactile {
  transition: transform 100ms ease-out;
}
.tactile:active {
  transform: translateY(1px);
}

/* AI-filled form fields (create-listing.js) — flags "please verify",
   clears the moment the user touches the field by hand. Uses the
   dedicated ai-flag token (public/theme-tokens.css) — this used to
   piggyback on the old `tertiary` amber; tertiary is now the brand cyan
   accent, so this flag needed its own token to stay visually distinct in
   both themes. */
.ai-filled {
  background-color: rgb(var(--color-ai-flag-container));
  border-color: rgb(var(--color-ai-flag)) !important;
}

/* AI-processing spinner (app.js aiBusyHtml) — the AI parse routes can take
   ~20s on a hard document (the Workers AI call retries up to 3 times, see
   src/ai/parseListing.js), long enough that a static "Parsing…" reads as a
   hung page. Same ai-flag token as .ai-filled above, so "AI is touching
   this" is one consistent color across the form. */
.ai-spinner {
  display: inline-block;
  width: 0.875rem;
  height: 0.875rem;
  border: 2px solid rgb(var(--color-ai-flag) / 0.25);
  border-top-color: rgb(var(--color-ai-flag));
  border-radius: 9999px;
  animation: ai-spin 700ms linear infinite;
}

@keyframes ai-spin {
  to { transform: rotate(360deg); }
}

/* Rotation is vestibular-triggering for motion-sensitive users; fade the
   ring in and out instead so the "still working" signal survives. */
@media (prefers-reduced-motion: reduce) {
  .ai-spinner {
    animation: ai-spinner-fade 1400ms ease-in-out infinite;
    border-top-color: rgb(var(--color-ai-flag));
  }
  @keyframes ai-spinner-fade {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.3; }
  }
}
