/*
 * FeatureFuel design tokens (CLAUDE.md "Visual design").
 * Editorial, flat, understated. No gradients, no glassmorphism, no pills.
 * Bootstrap's grid/utilities/JS stay; its visual layer is overridden here.
 */

:root {
  --bg:      #FBF9F5;  /* warm off-white — never pure white */
  --surface: #F4F1EA;
  --ink:     #1A1815;  /* warm near-black — never pure black */
  --muted:   #6B655C;
  --rule:    #E2DCD1;  /* 1px hairlines */
  --accent:  #B4432A;  /* burnt ember — reserved for money/pledges */
  --danger:  #A3312A;
  --success: #3F6B4D;
  --warning: #8A6421;

  --font-display: 'Instrument Serif', Georgia, 'Times New Roman', serif;
  --font-body: 'Public Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;

  --radius: 2px;

  /* Bootstrap variable overrides — do not fight components, retune tokens. */
  --bs-body-bg: var(--bg);
  --bs-body-color: var(--ink);
  --bs-body-font-family: var(--font-body);
  --bs-border-color: var(--rule);
  --bs-border-radius: var(--radius);
  --bs-border-radius-sm: var(--radius);
  --bs-border-radius-lg: var(--radius);
  --bs-primary: var(--ink);
  --bs-primary-rgb: 26, 24, 21;
  --bs-link-color: var(--ink);
  --bs-link-hover-color: var(--accent);
  --bs-link-decoration: underline;
}

html, body {
  background: var(--bg);
  color: var(--ink);
}

body {
  font-family: var(--font-body);
  line-height: 1.65;
}

h1, h2, h3, h4, .display-serif {
  font-family: var(--font-display);
  letter-spacing: -0.02em;
  font-weight: 400;
}

.tabular {
  font-variant-numeric: tabular-nums;
}

/* Money — the one place the accent colour is allowed to appear. */
.money {
  font-family: var(--font-display);
  color: var(--accent);
}

hr, .rule {
  border: none;
  border-top: 1px solid var(--rule);
  opacity: 1;
  margin: 0;
}

/* Kill Bootstrap's default decoration set outright. */
.card, .btn, .form-control, .form-select, .alert, .badge {
  box-shadow: none !important;
  border-radius: var(--radius) !important;
}

.card {
  border: 1px solid var(--rule);
  background: var(--surface);
}

.btn {
  border-radius: var(--radius);
  font-weight: 600;
  transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}

.btn-primary {
  background-color: var(--ink);
  border-color: var(--ink);
  color: var(--bg);
}

.btn-primary:hover, .btn-primary:focus {
  background-color: var(--accent);
  border-color: var(--accent);
}

.btn-outline-danger {
  color: var(--ink);
  border-color: var(--rule);
}

.btn-outline-danger:hover {
  background-color: var(--ink);
  border-color: var(--ink);
  color: var(--bg);
}

/* Vote buttons use Bootstrap's .active class (via data-class) to show the
   visitor's own vote. Without this, hovering an already-active button (the
   moment right after clicking it, exactly when it needs to read clearly)
   fell through to the plain :hover rule above and flashed ink/black instead
   of staying the vote colour — active state must win over hover, using our
   own --danger token rather than Bootstrap's stock red. */
.btn-outline-danger.active,
.btn-outline-danger.active:hover,
.btn-outline-danger.active:focus {
  background-color: var(--danger);
  border-color: var(--danger);
  color: var(--bg);
}

.form-control, .form-select {
  background-color: var(--bg);
  border: 1px solid var(--rule);
  color: var(--ink);
}

.form-control:focus, .form-select:focus {
  border-color: var(--ink);
  box-shadow: none !important;
}

.text-muted {
  color: var(--muted) !important;
}

.alert {
  border: 1px solid var(--rule);
  background: var(--surface);
  color: var(--ink);
}

.alert-danger { border-color: var(--danger); color: var(--danger); }
.alert-success { border-color: var(--success); color: var(--success); }
.alert-warning { border-color: var(--warning); color: var(--warning); background: var(--surface); }

/* Product-detail meta line under the title (product.php) — url/company/
   support/donation, joined with middot separators. Links here (the site
   link, a mailto:/donation link from Product::formatContactLink()) stay
   muted like the rest of the line instead of Bootstrap's own link color —
   this is metadata, not a primary action. */
.ff-product-meta a {
  color: var(--muted);
}
.ff-product-meta a:hover {
  color: var(--ink);
}

/* Verified-owner badge — flat, never a pill (CLAUDE.md "Visual design"). */
.badge-verified {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  border: 1px solid var(--success);
  border-radius: var(--radius);
  color: var(--success);
  padding: 0.15rem 0.55rem;
  font-size: 0.8rem;
  font-weight: 600;
  line-height: 1.4;
}

a { color: var(--ink); }
a:hover { color: var(--accent); }

/* Account menu in the nav — flat hairline panel, never Bootstrap's default
   shadow/rounded card (CLAUDE.md "Visual design"). */
.dropdown-menu {
  box-shadow: none;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: var(--bg);
  padding: 0.25rem 0;
  min-width: 9rem;
}
.dropdown-item {
  color: var(--ink);
  padding: 0.4rem 0.9rem;
}
.dropdown-item:hover, .dropdown-item:focus {
  background: var(--surface);
  color: var(--ink);
}
.dropdown-divider {
  border-top: 1px solid var(--rule);
  margin: 0.25rem 0;
  opacity: 1;
}

/* Status badge — flat, bordered, never a pill (plan.md §9), same family as
   .badge-verified above. 'requested' is the default/baseline and never
   renders one — see request.php / theme/partials/request-status.php. */
.status-badge {
  display: inline-flex;
  align-items: center;
  border: 1px solid;
  border-radius: var(--radius);
  padding: 0.15rem 0.5rem;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  line-height: 1.4;
}
.status-badge-sm {
  padding: 0.05rem 0.35rem;
  font-size: 0.65rem;
  vertical-align: middle;
}
.status-badge-planned      { color: #8C7A5C; border-color: #8C7A5C; }
.status-badge-in_progress  { color: #B4432A; border-color: #B4432A; }
.status-badge-completed    { color: #3F6B4D; border-color: #3F6B4D; }
.status-badge-declined     { color: #B7ADA0; border-color: #B7ADA0; }

/* 'requested' has no color of its own elsewhere (it's the undecorated
   baseline — see the .status-badge comment above), but the owner's status
   dropdown trigger (theme/partials/request-status.php) shows it too, since
   there's no other case where it'd render nothing. Muted/neutral rather
   than a real status color, on purpose. */
.status-badge-requested    { color: var(--muted); border-color: var(--rule); }

/* The owner's status dropdown trigger (see .status-badge-requested comment
   above) is a plain .btn with no Bootstrap variant, so it has no hover
   background of its own — the outline-secondary siblings beside it
   (Create GitHub issue / Link / Merge) fill with --bs-btn-active-bg on
   hover. That variable is only ever set by .btn-outline-secondary itself
   (scoped to that class, not inherited across sibling buttons), so it's
   unset here without the fallback — hardcode the same grey Bootstrap
   uses as the fallback value. Flip text to white too, same as the
   outline-secondary hover, so status text stays legible against it. */
.status-toggle:hover, .status-toggle:focus {
  background-color: var(--bs-btn-active-bg, #6c757d);
  border-color: var(--bs-btn-active-bg, #6c757d);
  color: #fff;
}

/* Request ledger — hairline rows, not cards (plan.md §9). */
.ledger-row {
  display: flex;
  gap: 1.25rem;
  align-items: baseline;
  padding: 1.1rem 0;
  border-bottom: 1px solid var(--rule);
}
.ledger-row:first-child { border-top: 1px solid var(--rule); }
.ledger-rank {
  font-family: var(--font-display);
  color: var(--muted);
  min-width: 2.5rem;
  text-align: right;
}
/* Section headings inside the ledger — product.php's "Requests by Status"
   tab only. A rule and a label, not a boxed header: the sections are a
   grouping of the same list, so they read as divisions of it rather than as
   separate components. .ledger-row's own :first-child top border only fires
   when no heading precedes it, which is exactly right — the heading carries
   the rule in that case instead. */
.request-section-heading {
  font-family: var(--font-display);
  font-size: 1.35rem;
  letter-spacing: -0.02em;
  color: var(--ink);
  margin: 2.5rem 0 0;
  padding-bottom: 0.4rem;
  border-bottom: 1px solid var(--rule);
}
.request-section-heading-first { margin-top: 0; }

/*
 * Homepage discovery sections (index.php / api/homepage.php) — up to 3
 * columns (For you / Last viewed / Trending). Deliberately just ONE rule
 * per section (a short underline beneath its heading) — no column
 * dividers, no per-item borders; space alone separates items, per request.
 *
 * flex, not grid: grid's `repeat(N, minmax(0, max-content))` sizes each
 * COLUMN TRACK once for the whole grid — every row shares the same column
 * widths, so a track gets pulled wide by whichever row's content in it is
 * widest, even if that's not the row you're looking at. flex items each
 * size to their own content independently (still capped by
 * `.homepage-section`'s own max-width so one long subtitle line can't blow
 * a column out), and `justify-content: space-evenly` distributes however
 * many sections actually render (2 or 3, since "Last viewed" hides when
 * empty) with equal space between and around them — no fixed column count
 * to get out of sync with the real number of sections shown.
 */
.homepage-sections {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-evenly;
  /* row-gap only — column spacing is left entirely to space-evenly above;
     a column-gap here would add fixed spacing BETWEEN items on top of
     space-evenly's own distributed space without adding anything to the
     outer edges, breaking the "equal space everywhere" space-evenly
     implies (confirmed live: inner gaps came out ~40px wider than the
     outer ones with a column-gap set). row-gap only matters on the rare
     row that wraps before the true mobile stack breakpoint kicks in. */
  row-gap: 2.5rem;
}
.homepage-section {
  max-width: 22rem;
}
.homepage-section-heading {
  font-family: var(--font-display);
  font-size: 1.25rem;
  position: relative;
  margin-bottom: 1rem;
  padding-bottom: 0.6rem;
}
.homepage-section-heading::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: 0;
  width: 2.5rem;
  height: 1px;
  background: var(--rule);
}
.homepage-item {
  display: block;
  padding: 0.4rem 0;
  color: var(--ink);
  text-decoration: none;
}
.homepage-item-name { font-weight: 600; }
.homepage-item-sub {
  font-size: 0.85rem;
  margin-top: 0.1rem;
}
/* Below md, content-width columns side by side get cramped — stack to one
   full-width column instead, same collapse idiom as .kanban-board below. */
@media (max-width: 767.98px) {
  .homepage-sections { flex-direction: column; }
  .homepage-section { max-width: none; }
}

/*
 * Kanban board (product-manage.php). Columns as plain hairline-divided
 * rails, not shadowed panels — same "rules, not cards" instinct as
 * .ledger-row above. Cards are thin-bordered blocks, no shadow, no radius
 * beyond the shared 2px token.
 */
.kanban-board {
  display: grid;
  grid-template-columns: repeat(5, minmax(220px, 1fr));
  gap: 0;
  border-top: 1px solid var(--rule);
}
.kanban-column {
  border-right: 1px solid var(--rule);
  padding: 0.75rem;
  min-height: 8rem;
}
.kanban-column:last-child { border-right: none; }
/* Below md, five side-by-side columns don't fit — stack each as its own
   full-width row instead. Drag-and-drop stays on (js/kanban.js), tuned for
   touch there so a card-drag doesn't fight the page's own scroll. */
@media (max-width: 767.98px) {
  .kanban-board {
    display: block;
    border-top: 1px solid var(--rule);
  }
  .kanban-column {
    border-right: none;
    border-bottom: 1px solid var(--rule);
    min-height: 0;
  }
  .kanban-column:last-child { border-bottom: none; }
}
.kanban-column-header {
  font-family: var(--font-display);
  font-size: 1.05rem;
  margin-bottom: 0.75rem;
  padding-bottom: 0.5rem;
  border-bottom: 1px solid var(--rule);
  display: flex;
  justify-content: space-between;
  align-items: baseline;
}
.kanban-card {
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  padding: 0.65rem 0.75rem;
  margin-bottom: 0.6rem;
  cursor: grab;
}
.kanban-card:active { cursor: grabbing; }
.kanban-card-title {
  color: var(--ink);
  text-decoration: none;
  font-weight: 600;
  display: block;
  margin-bottom: 0.35rem;
}
.kanban-card-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 0.75rem;
  align-items: center;
  color: var(--muted);
  font-size: 0.8rem;
}
/* The control cluster (arrows, Merge, Status) is wide enough that a narrow
   column would otherwise squeeze "25 pts" onto two lines. Let the row wrap
   instead — the .ms-auto on the cluster keeps it right-aligned on whichever
   line it lands. */
.kanban-card-meta > span { white-space: nowrap; }
/* Up/down reorder arrows sit immediately left of Merge/Status in the same
   control cluster (theme/partials/kanban-board.php). Bootstrap's .btn-group
   rounds its end caps; the shared 2px token already flattens that, so this
   only has to stop the chevron glyphs from setting the cluster's line-height. */
.kanban-card-nudge .btn { line-height: 1; }
.kanban-card-nudge .btn i { vertical-align: -0.05em; }
/* A disabled arrow (top/bottom of a column) stays in place rather than
   disappearing, so the cluster doesn't change width row to row — it just
   stops reading as something to click. */
.kanban-card-nudge .btn:disabled { opacity: 0.3; }

/* SortableJS's own drag-state classes — flat highlight, no shadow/glow. */
.kanban-card.sortable-ghost {
  opacity: 0.4;
}
.kanban-card.sortable-chosen {
  border-color: var(--ink);
}

/*
 * Public roadmap (roadmap.php). Deliberately not the kanban board's columns
 * — a visitor reads top to bottom, and the owner's manual order only means
 * something as a sequence, so this is three numbered lists stacked, the same
 * .ledger-row instinct as everywhere else: hairlines, no cards, no boxes.
 */
.roadmap-section { margin-bottom: 3rem; }
.roadmap-heading {
  font-family: var(--font-display);
  font-size: 1.6rem;
  letter-spacing: -0.02em;
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
}
.roadmap-count {
  font-family: var(--font-body);
  font-size: 0.85rem;
  font-weight: 400;
  color: var(--muted);
}
.roadmap-blurb {
  font-size: 0.9rem;
  margin: 0.15rem 0 0.75rem;
  padding-bottom: 0.75rem;
  border-bottom: 1px solid var(--rule);
}
.roadmap-row {
  display: flex;
  gap: 1.25rem;
  align-items: baseline;
  padding: 0.85rem 0;
  border-bottom: 1px solid var(--rule);
}
/* The section already ends at the next heading's own rule (or, for the last
   section, the closing note's border-top) — a rule under the final row would
   double it. */
.roadmap-row:last-child { border-bottom: none; }
/* The position number is the roadmap — it's what the owner set by dragging.
   Muted and small so it reads as an index, not as a score. */
.roadmap-rank {
  color: var(--muted);
  font-size: 0.85rem;
  min-width: 1.75rem;
}
.roadmap-body { flex: 1 1 auto; min-width: 0; }
.roadmap-title {
  color: var(--ink);
  text-decoration: none;
  font-weight: 600;
  display: block;
}
.roadmap-title:hover { text-decoration: underline; }
.roadmap-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.9rem;
  font-size: 0.8rem;
  margin-top: 0.2rem;
}

/*
 * Search-as-you-type combobox (js/combobox.js handles the keyboard side;
 * this is the visual side — a bordered panel that reads as "attached" to
 * the input, in normal document flow so it pushes the "Can't find it?"
 * section below it down, rather than floating over it).
 */
.ff-search-panel {
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  /* Vertical only — horizontal spacing lives on each row (below), not
     here, so a highlighted row's background reaches the panel's own
     edges instead of leaving an unstyled margin on both sides. */
  padding: 0.25rem 0;
}
.ff-search-panel a,
.ff-search-panel p {
  padding-left: 0.75rem;
  padding-right: 0.75rem;
}
/* The last result row's own border-bottom would otherwise double up with
   the panel's own bottom border right below it. */
.ff-search-panel a:last-child {
  border-bottom: none !important;
}
/* Same doubling, opposite edge: api/dedup-search.php and
   theme/partials/similar-requests.php use a border-top per row (rather than
   product-search.php's border-bottom), so the first row's own border-top
   would otherwise double up with the panel's own top border right above it. */
.ff-search-panel a:first-child {
  border-top: none !important;
}
/* Keyboard (js/combobox.js) and mouse hover share this same highlight —
   a plain surface tint, not a colored ring; --accent stays reserved for
   money, never spent on a UI highlight. */
.ff-search-panel a:hover,
.ff-search-panel a.ff-option-active {
  background: var(--surface);
}
/* Matched-term highlight in the dedup panel (api/dedup-search.php,
   classes/Search.php::highlight) — a muted gold tint, not a saturated
   yellow, to stay inside the warm palette while still reading instantly as
   "this is why it matched." Defined here, not left to the browser's own
   <mark> default, since this file is the one CSS source never dropped by
   the PurgeCSS build step (CLAUDE.md "Asset build"). */
.ff-search-hit {
  background: #F0D875;
  color: var(--ink);
  padding: 0 0.15em;
  border-radius: var(--radius);
}
/* Toggled on #product-search-results (theme/partials/product-search.php)
   exactly when the "Add it" panel below it is showing, so the two read as
   one continuous bordered unit — square off the shared edge and drop the
   now-redundant bottom border, so only the background-color change (--bg to
   --surface, .ff-add-product-panel below) marks the seam, not a doubled
   line. Scoped to this one class, not .ff-search-panel itself, so a future
   standalone use of the search panel (with no "Add it" panel following it)
   keeps its normal fully-rounded, fully-bordered box. */
.ff-attached-below .ff-search-panel {
  border-bottom: none;
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}

/* Whole-form busy state, while a pledge is being processed (request.php,
   toggled by data-class off $pledging/$cardBusy/$confirming). A card pledge
   spends several seconds inside Stripe.js confirming, and Stripe gives you
   no loading state for your own form — without this the page looks idle and
   people click Pledge again.

   pointer-events is the load-bearing half, not the opacity: it locks the
   Stripe Element's iframe too, which a disabled attribute on our own button
   cannot reach. The opacity is only what makes the lock visible.

   The one animated "please wait" on the site — the search skeleton stays
   deliberately still (CLAUDE.md design rules). Multiple seconds of silence
   on a payment is the case that earns motion; a fade at the fast end of this
   project's 150-200ms range, and the button's own spinner. */
.ff-busy {
  opacity: 0.5;
  pointer-events: none;
  transition: opacity 150ms ease;
}

/* Shared flat surface-tint box for any interactive form (pledge, comment,
   "Can't find it? Add it") — sets it apart as its own distinct input area
   rather than a continuation of the surrounding page. Same border+radius
   language as .ff-search-panel and .card, never a shadow (CLAUDE.md design
   rules). .ff-add-product-panel is an alias kept for its existing markup. */
.ff-panel,
.ff-add-product-panel {
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  padding: 1.5rem;
}
/* Stuck directly to the bottom of the search results box, always — see the
   .ff-attached-below comment above for the matching top edge. Safe to leave
   unconditional (rather than also gating on a class) because this panel is
   always display:none until #product-search-results is showing results to
   attach to in the first place (theme/partials/product-search.php). */
.ff-add-product-panel {
  margin-top: 0;
  border-top: none;
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}

/* Sized to land on the SAME height as the shortest thing that can replace
   it — the 33px "No matches. Add it below." panel — and below a real result
   row (~54px). A loading state taller than its own result is a bounce: the
   results box grows to fit the skeleton and shrinks again a moment later
   when the real content arrives, shoving everything below it (the "Add it"
   panel) down and back up. One row at 0.5rem padding, not two at 0.75rem,
   is what keeps that swap flat. Measured, not eyeballed — if this padding or
   the row height changes, re-check it against the no-matches panel. */
.search-skeleton {
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  padding: 0.5rem;
}
.search-skeleton-row {
  height: 0.9rem;
  background: var(--rule);
  border-radius: var(--radius);
  margin-bottom: 0.5rem;
}
.search-skeleton-row:last-child {
  width: 60%;
  margin-bottom: 0;
}

/* --- Unconfirmed content -------------------------------------------------
   A provisional account (migration 008) is signed in but has published
   nothing: everything it creates is visible only to itself until the
   confirmation link is clicked.

   Deliberately not Bootstrap's .alert-warning. Its #fff3cd is the bright,
   saturated yellow that reads as a generic SaaS template, which the design
   rules ban outright. This is the same warm neutral family as the rest of
   the palette, one step warmer and darker than --surface so it registers as
   a state without shouting. --accent stays reserved for money. */
:root {
  --pending-bg:   #F5EDDC;  /* warm parchment, a shade off --surface */
  --pending-ink:  #6B5730;  /* muted ochre-brown, readable on the above */
  --pending-rule: #E3D5B8;
}

.ff-confirm-bar {
  background: var(--pending-bg);
  border-bottom: 1px solid var(--pending-rule);
  color: var(--pending-ink);
  position: sticky;
  top: 0;
  z-index: 999;
}
.ff-confirm-bar a {
  color: var(--pending-ink);
  text-underline-offset: 2px;
}
.ff-confirm-sep {
  opacity: 0.5;
  margin: 0 0.35rem;
}

/* Panel form of the same state, used on a pending request/product page. */
.ff-pending-panel {
  background: var(--pending-bg);
  border: 1px solid var(--pending-rule);
  border-radius: var(--radius);
  color: var(--pending-ink);
  padding: 1rem 1.25rem;
}

/* Inline marker on a single pending row in a list. Not a Bootstrap .badge —
   pill-shaped UI is banned; this is a plain uppercase label on a hairline. */
.ff-pending-tag {
  display: inline-block;
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--pending-ink);
  border: 1px solid var(--pending-rule);
  background: var(--pending-bg);
  border-radius: var(--radius);
  padding: 0.05rem 0.4rem;
}

/* The global "prove you're human" modal (theme/header.php, CLAUDE.md
   "Turnstile"). Deliberately a plain class, NOT Bootstrap's .d-flex —
   Bootstrap's display utilities carry !important, which permanently beats
   the element's own inline style="display: none;" and makes data-show's
   toggle a no-op forever (the exact trap this file's own header comment
   documents for the opposite direction — a class fighting an inline style).
   An unqualified class has lower precedence than any inline style, so it
   sits correctly underneath: hidden while style="display: none" holds,
   flex once data-show clears it. */
.ff-captcha-modal {
  display: flex;
  align-items: center;
  justify-content: center;
}

/* The row itself recedes slightly: it is real, but it isn't live yet. */
.ff-pending {
  opacity: 0.75;
}

/* "Log in as" banner (theme/header.php, CLAUDE.md admin rebuild). Own
   variables, not --pending-* — this is a distinct state (an admin acting as
   someone else) from the unconfirmed-account bar above, and should not read
   as the same thing. --accent stays reserved for money. Deliberately NOT
   Bootstrap's .d-flex — same trap as .ff-captcha-modal above: its
   !important would permanently beat data-show's inline style. */
:root {
  --impersonate-bg:   #EDE3D9;
  --impersonate-ink:  #5C4A32;
  --impersonate-rule: #D9C7AE;
}
.ff-impersonation-bar {
  background: var(--impersonate-bg);
  border-bottom: 1px solid var(--impersonate-rule);
  color: var(--impersonate-ink);
}
.ff-impersonation-bar a {
  color: var(--impersonate-ink);
  text-underline-offset: 2px;
}

/* Hand-rolled rich-text editor (js/richtext.js) — bold/italic/lists/indent
   only (no underline — reads as a link on a site with real links). Toolbar
   is hidden until the editor (or a toolbar
   button) has focus, per CLAUDE.md "the rich text controls should only be
   visible when you're editing the text"; .ff-richtext-open is toggled by
   the script, not a Datastar data-show, so a plain class (not the inline-
   style pattern CLAUDE.md documents for data-show) is the right tool here. */
.ff-richtext-toolbar {
  display: none;
  gap: 0.15rem;
  border: 1px solid var(--rule);
  border-bottom: none;
  border-top-left-radius: var(--radius);
  border-top-right-radius: var(--radius);
  background: var(--surface);
  padding: 0.25rem;
}
.ff-richtext.ff-richtext-open .ff-richtext-toolbar {
  display: flex;
}
.ff-richtext-btn {
  border: 1px solid transparent;
  background: transparent;
  color: var(--muted);
  font-family: var(--font-body);
  font-size: 0.8rem;
  line-height: 1;
  padding: 0.35rem 0.6rem;
  border-radius: var(--radius);
  cursor: pointer;
}
.ff-richtext-btn:hover {
  border-color: var(--rule);
  color: var(--ink);
}
.ff-richtext-editor {
  background-color: var(--bg);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  color: var(--ink);
  padding: 0.5rem 0.75rem;
  min-height: 4.5rem;
}
.ff-richtext-editor:focus {
  outline: none;
  border-color: var(--ink);
}
.ff-richtext.ff-richtext-open .ff-richtext-editor {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}
.ff-richtext-editor:empty::before {
  content: attr(data-placeholder);
  color: var(--muted);
}
.ff-richtext-editor ul, .ff-richtext-editor ol {
  padding-left: 1.5rem;
  margin-bottom: 0;
}
/* Live character count (js/richtext.js) — a courtesy readout, not the
   enforcement; classes/HtmlSanitizer.php's server-side check is. */
.ff-richtext-counter {
  font-size: 0.75rem;
  color: var(--muted);
  text-align: right;
  padding-top: 0.2rem;
  font-variant-numeric: tabular-nums;
}
.ff-richtext-counter.ff-richtext-counter-over {
  color: var(--danger);
}

/*
 * /products/ (products.php) — flat A-Z directory, three columns wide.
 * CSS multi-column, not a Bootstrap row/col grid: letter groups flow into
 * whichever column has room next (a phone-book/Wikipedia-index layout),
 * rather than a grid where every row shares one fixed set of column widths.
 * break-inside: avoid keeps a short letter group from splitting across a
 * column break; a genuinely long group (many products under one letter)
 * can still split across columns — accepted, same as any CSS multi-column
 * layout, and not worth fighting for a plain index page.
 */
.products-index {
  column-count: 3;
  column-gap: 3rem;
}
@media (max-width: 767.98px) {
  .products-index { column-count: 1; }
}
@media (min-width: 768px) and (max-width: 1199.98px) {
  .products-index { column-count: 2; }
}
.products-letter-group {
  break-inside: avoid;
  margin-bottom: 1.75rem;
}
.products-letter-heading {
  font-family: var(--font-display);
  font-size: 1.15rem;
  color: var(--muted);
  border-bottom: 1px solid var(--rule);
  padding-bottom: 0.3rem;
  margin-bottom: 0.5rem;
}
.products-letter-group ul {
  list-style: none;
  padding: 0;
  margin: 0;
}
.products-letter-group li {
  padding: 0.2rem 0;
}
.products-count {
  color: var(--muted);
  font-size: 0.8rem;
}
/* Sticky-note-style A-Z jump row at the top of the page. Unlinked letters
   (no products yet) render as plain, undecorated text — not a disabled
   link — so a sparse early-stage catalogue doesn't imply two dozen dead
   links a visitor could click. */
.products-jump {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 0.35rem;
  margin-bottom: 2rem;
  font-variant-numeric: tabular-nums;
}
.products-jump a,
.products-jump span {
  display: inline-block;
  min-width: 1.3rem;
  text-align: center;
  font-size: 0.85rem;
}
.products-jump a {
  color: var(--ink);
  text-decoration: none;
}
.products-jump a:hover {
  color: var(--accent);
}
.products-jump span {
  color: var(--rule);
}
