﻿/*
 * BrickDb component styles (design-system.md §5)
 * ----------------------------------------------------------------------------
 * Shared component rules whose BEM blocks are all pinned to real renderers by
 * ComponentBlockIsRenderedTests. Retired blocks stay deleted until their component exists.
 *
 * Rules that apply throughout, per the brief:
 *   - Authored at 360px first; every widening step is a min-width media query
 *     (600 / 905 / 1240 / 1440, matching design-system.md §4).
 *   - Every interactive target is >=44x44px.
 *   - Nothing requires :hover to reveal an action — hover-only refinements are
 *     wrapped in @media (hover: hover) and (pointer: fine) so touch/keyboard
 *     users never lose an affordance.
 *   - No page scrolls horizontally at any width; wide content gets its own
 *     .bd-scroll-x (app.css) container.
 *   - Class names are prefixed bd-, BEM-ish (block__element--modifier).
 * ----------------------------------------------------------------------------
 */

/* =========================================================================
   GlassSurface (§2.4.2) — ported from OutaStory's own GlassSurface.razor /
   ui-v2-worlds.css, retinted to BrickDb's indigo/coral. Chrome only: TopBar,
   AppSidebar, sheets, modals, overlay surfaces. Never a card, list
   row, or anything inside a scrolling region — see Components/GlassSurface.razor and
   design-system.md §2.4.3.

   background/border/box-shadow are set UNCONDITIONALLY; only backdrop-filter (the blur
   itself) is wrapped in @supports, with the -webkit- prefix alongside. That means the
   un-blurred state is never a different, separately-verified "fallback" colour — it's
   the same translucent fill, just without blur, which design-system.md §2.4.3's proof
   already covers because that proof never depended on blur being present. This is what
   keeps GlassSurface correct on older Android WebView (pre-Chromium "blink"
   backdrop-filter support, still present on some Play/Amazon/Samsung-shipped devices —
   see the store matrix) with zero extra work: the @supports block just never fires there.
   ========================================================================= */

.bd-glass {
  background: var(--bd-color-glass-bg);
  border: 1px solid var(--bd-color-glass-border);
  box-shadow: var(--bd-shadow-2), var(--bd-color-glass-highlight);
}

.bd-glass--strong {
  background: var(--bd-color-glass-bg-strong);
}

@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .bd-glass,
  .bd-glass--strong {
    backdrop-filter: blur(var(--bd-glass-blur));
    -webkit-backdrop-filter: blur(var(--bd-glass-blur));
  }
}

/* Three independent user preferences collapse every glass surface to the same opaque,
   already-verified fill (--bd-color-surface-raised) — design-system.md §2.4.2/§8:
   prefers-reduced-transparency (a user who finds translucency hard to read), prefers-
   contrast: more (the concrete mechanism OutaStory's own token comment notes high-
   contrast mode opts out of), and reduced motion is handled separately below since
   transparency and motion are different preferences. */
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
  .bd-glass,
  .bd-glass--strong {
    background: var(--bd-color-surface-raised);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

/* prefers-reduced-motion follows the existing rule elsewhere in this file: a sheet's or
   modal's appear/disappear blur/opacity TRANSITION is skipped or capped at
   --bd-duration-instant; the resting glass state itself (this block) is unaffected —
   motion and transparency are separate preferences, gated by separate media queries. */
@media (prefers-reduced-motion: reduce) {
  .bd-glass,
  .bd-glass--strong {
    transition: none;
  }
}

/* =========================================================================
   Shared primitives — buttons
   Used by EmptyState, ConfirmSheet, TopBar and SearchField actions.
   ========================================================================= */

.bd-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--bd-space-2);
  min-height: 44px;
  min-width: 44px;
  padding: 0 var(--bd-space-5);
  border-radius: var(--bd-radius-pill);
  border: none;
  font-family: var(--bd-font-body);
  font-weight: 600;
  font-size: var(--bd-type-body-sm-size);
  line-height: 1;
  white-space: nowrap;
  transition: background-color var(--bd-duration-short) var(--bd-ease-standard),
              color var(--bd-duration-short) var(--bd-ease-standard),
              border-color var(--bd-duration-short) var(--bd-ease-standard);
}

.bd-btn:disabled {
  opacity: 0.5;
  pointer-events: none;
}

.bd-btn--primary {
  background: var(--bd-color-primary);
  color: var(--bd-color-on-primary); /* white since 2026-09-04 — see tokens.css §2.3 */
}

.bd-btn--primary:active {
  background: var(--bd-color-primary-dark);
}

@media (hover: hover) and (pointer: fine) {
  .bd-btn--primary:hover {
    background: var(--bd-color-primary-mid);
  }
}

.bd-btn--secondary {
  background: transparent;
  color: var(--bd-color-on-surface);
  border: 1px solid var(--bd-color-border-strong);
}

.bd-btn--secondary:active {
  background: var(--bd-color-surface);
}

.bd-btn--danger-text {
  background: transparent;
  color: var(--bd-color-danger);
  padding-inline: var(--bd-space-3);
  text-decoration: underline;
  text-decoration-thickness: max(1px, 0.08em);
}

.bd-btn--icon {
  min-width: 44px;
  width: 44px;
  height: 44px;
  padding: 0;
  border-radius: var(--bd-radius-pill);
  background: transparent;
  color: var(--bd-color-on-surface);
}

.bd-btn--icon:active {
  background: var(--bd-color-surface);
}

.bd-btn__icon-svg {
  width: 22px;
  height: 22px;
  flex: none;
}

.bd-quantity-stepper {
  display: grid;
  grid-template-columns: 44px minmax(0, 1fr) 44px;
  gap: var(--bd-space-1);
  width: 100%;
  max-width: 12rem;
}

.bd-quantity-stepper__value {
  min-width: 0;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* =========================================================================
   AppShell — root layout: AppSidebar (mobile burger/rail/expanded) +
   content outlet + toast/scrim layers. Owns safe-area insets
   (tokens.css --bd-safe-area-*); the web head's App.razor owns data-theme on <html>.
   ========================================================================= */

.bd-app-shell {
  display: flex;
  min-height: 100dvh;
  background: var(--bd-color-surface);
  color: var(--bd-color-on-surface);
}

.bd-app-shell__main {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  /* Base/sm: reserve top clearance for the 56px burger strip, safe-area aware. No side
     clearance — the drawer is off-canvas and overlays, it never pushes content
     (sidebar-navigation-and-home-launcher.md §5). */
  padding-top: calc(56px + var(--bd-safe-area-top));
}

.bd-app-shell__content {
  flex: 1 1 auto;
  padding: var(--bd-space-4);
  /* Safe-area-top is carried by .bd-app-shell__main's own padding-top below md (folded
     into the 56px burger-strip clearance above) and restored here at md+, once the
     burger strip is gone and the top edge is this element's own again. */
  padding-top: 0;
}

@media (min-width: 600px) {
  .bd-app-shell__content {
    padding: var(--bd-space-6);
    padding-top: 0;
  }
}

@media (min-width: 905px) {
  .bd-app-shell__main {
    /* md: the icon rail replaces the burger strip — 72px on the left, nothing on top. */
    padding-top: 0;
    padding-left: 72px;
  }

  .bd-app-shell__content {
    padding-top: var(--bd-safe-area-top);
  }
}

@media (min-width: 1240px) {
  .bd-app-shell__main {
    /* lg/xl: the expanded sidebar reserves ~264px. AppSidebar's collapsed/expanded state
       lives on its own root element (.bd-mobile-topbar), .bd-app-shell__main's direct
       flex sibling, so a plain sibling combinator tracks it — see the rail override
       immediately below — with no need for :has(). */
    padding-left: 264px;
  }

  .bd-mobile-topbar--rail ~ .bd-app-shell__main {
    padding-left: 72px;
  }
}

@media (min-width: 1440px) {
  .bd-app-shell__content {
    max-width: 1280px;
    margin-inline: auto;
    width: 100%;
  }
}

/* =========================================================================
   AppSidebar — the one navigation, in three responsive presentations of the same
   ordered list (docs/design/sidebar-navigation-and-home-launcher.md; replaced
   BottomNav/PrimaryNav on 2026-09-09 when Thimo repealed the five-destination ceiling —
   AGENTS.md, design-system.md §4.1/§5).

   ONE <details> ELEMENT DRIVES ALL THREE STATES, never two renderings of the nav list.
   Below 905px it behaves as a native disclosure: <summary> is the fixed 56px top strip
   (burger icon + wordmark), and .bd-sidebar is <details>'s hidden-by-default content,
   revealed as an off-canvas drawer with a scrim when [open]. At 905px and above, an
   author rule overrides the UA default that hides a closed <details>'s non-summary
   children and repositions .bd-sidebar as a persistent fixed column instead — 72px icon
   rail at md, ~264px expanded (or 72px collapsed) at lg/xl.

   Chrome (design-system.md §2.4.3): the burger strip and the sidebar panel are both
   glass, background/border/box-shadow set unconditionally with backdrop-filter itself
   wrapped in @supports, mirroring the GlassSurface pattern above without using the
   component directly — GlassSurface's own root is a plain div, and this markup needs
   <summary>/<nav> for their native semantics.
   ========================================================================= */

/* AppSidebar's whole rendered output roots on this element and the <details> beneath
   it; neither has any box of its own (display: contents), so .bd-app-shell's flex
   layout sees straight through to the real chrome — the fixed burger strip/drawer below
   md, the fixed rail/expanded column at and above it. */
.bd-mobile-topbar,
.bd-sidebar-drawer {
  display: contents;
}

/* ---- the burger strip (<summary>), base/sm only ---- */

/* THE STRIP IS THE CLOSE CONTROL, SO IT MUST NOT BE UNDER THE PANEL (issue #443). It used to
   be: <summary> and .bd-sidebar both carried z-index: var(--bd-z-sidebar), equal, and the panel
   comes later in the DOM - so an open drawer painted straight over the strip, and
   document.elementFromPoint at the summary's own centre returned .bd-sidebar__header. Both
   glyphs were there, the CSS swapped them correctly, every computed style read fine, and the
   one control that closes the drawer could not be tapped. The panel now starts BELOW the strip
   (see .bd-sidebar's own top, further down) rather than the two merely being reordered, because
   disjoint boxes cannot be got wrong again by a later z-index edit.

   IT CARRIES NO background AND NO backdrop-filter. A filtered element is the containing block
   for its own position: fixed descendants, which would pin .bd-sidebar-drawer__scrim to this
   56px box instead of the viewport - and the scrim being a descendant is what makes
   tap-outside-to-dismiss work with no script at all (AppSidebar.razor says why). The glass
   lives on .bd-sidebar-drawer__bar instead. */
.bd-sidebar-drawer__summary {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: var(--bd-z-sidebar);
  height: calc(56px + var(--bd-safe-area-top));
  list-style: none; /* Firefox/Safari draw a disclosure triangle on <summary> by default */
  cursor: pointer;
}

.bd-sidebar-drawer__summary::-webkit-details-marker,
.bd-sidebar-drawer__summary::marker {
  display: none;
  content: "";
}

/* The visible strip. `z-index: 1` inside the summary's own stacking context, so the scrim
   beside it (z-index: 0) dims the page and never this. */
.bd-sidebar-drawer__bar {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: var(--bd-space-3);
  height: 100%;
  padding-top: var(--bd-safe-area-top);
  padding-inline: var(--bd-space-4);
  background: var(--bd-color-glass-bg);
  border-bottom: 1px solid var(--bd-color-glass-border);
}

@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .bd-sidebar-drawer__bar {
    backdrop-filter: blur(var(--bd-glass-blur));
    -webkit-backdrop-filter: blur(var(--bd-glass-blur));
  }
}

/* Keeps the mark and the wordmark as direct flex items of the bar, so the aria-hidden wrapper
   that stops them joining the control's accessible name costs no layout. */
.bd-sidebar-drawer__brand-lockup {
  display: contents;
}

.bd-sidebar-drawer__icon {
  width: 24px;
  height: 24px;
  flex: none;
}

.bd-sidebar-drawer__icon--open {
  display: none;
}

/* The ☰ ↔ ✕ swap: a CSS-only read of <details>'s own [open] state, no script involved. */
details[open] > .bd-sidebar-drawer__summary .bd-sidebar-drawer__icon--closed {
  display: none;
}

details[open] > .bd-sidebar-drawer__summary .bd-sidebar-drawer__icon--open {
  display: block;
}

/* The same swap applied to the control's accessible name - "Open menu" while closed, "Close
   menu" while open. `display: none` is what takes the inactive one out of the accessibility
   tree, so exactly one ever contributes to the name. */
.bd-sidebar-drawer__sr-label--open {
  display: none;
}

details[open] > .bd-sidebar-drawer__summary .bd-sidebar-drawer__sr-label--closed {
  display: none;
}

details[open] > .bd-sidebar-drawer__summary .bd-sidebar-drawer__sr-label--open {
  display: block;
}

.bd-sidebar-drawer__brand-icon {
  width: 26px;
  height: 26px;
  flex: none;
}

.bd-sidebar-drawer__brand {
  font-family: var(--bd-font-display);
  font-weight: 700;
  font-size: var(--bd-type-body-lg-size);
}

.bd-sidebar-drawer__scrim {
  display: none;
}

/* Inside the summary's stacking context, below .bd-sidebar-drawer__bar (z-index: 1) and
   therefore below the panel too - so it dims everything and intercepts nothing a visitor needs
   to reach. `inset: 0` deliberately covers the area behind the panel as well: the panel is
   glass, and a scrim stopping at its edge would show an undimmed page through it. */
details[open] > .bd-sidebar-drawer__summary > .bd-sidebar-drawer__scrim {
  display: block;
  position: fixed;
  inset: 0;
  z-index: 0;
  background: var(--bd-color-scrim);
}

/* ---- the panel itself: <details>'s hidden-by-default content below md, the
   off-canvas drawer when [open], the persistent rail/expanded column at md+ ---- */

/* Chrome, rendered through <GlassSurface> (AppSidebar.razor) — background/border/box-shadow
   come from .bd-glass/.bd-glass--strong unconditionally, this block owns layout only, per the
   rule TopBar's own CSS states. border-right is the one single-edge exception every glass
   chrome block in this file takes (TopBar's border-bottom is the precedent). */
.bd-sidebar {
  position: fixed;
  /* Below the burger strip, never over it - see .bd-sidebar-drawer__summary above for the
     failure that reordering alone would leave available. The safe-area inset is spent by the
     strip's own height here, which is why padding-top is 0 below md and restored at md+, where
     the strip is gone and this column owns the top edge again. */
  top: calc(56px + var(--bd-safe-area-top));
  bottom: 0;
  left: 0;
  z-index: var(--bd-z-sidebar);
  display: none;
  flex-direction: column;
  width: min(85vw, 320px);
  padding-top: 0;
  padding-bottom: var(--bd-safe-area-bottom);
  padding-left: var(--bd-safe-area-left);
  border-right: 1px solid var(--bd-color-border);
  transform: translateX(-100%);
  transition: transform var(--bd-duration-medium) var(--bd-ease-standard);
}

details[open] > .bd-sidebar {
  display: flex;
  transform: translateX(0);
}

@media (prefers-reduced-motion: reduce) {
  .bd-sidebar {
    transition: none;
  }
}

/* ---- the sidebar's own internal layout, all states ----

   THIS BLOCK SITS ABOVE THE BREAKPOINTS ON PURPOSE, AND IT DID NOT USED TO. A media query
   adds no specificity, so between two equally-specific rules the LATER one wins whatever
   width it was written for. This block was originally below the md/lg blocks, which made
   twelve of their `display` declarations dead — among them the three that hide the group
   headings and the four legal links in the 72px rail and show the compact /legal icon
   instead. The rail therefore painted the expanded presentation's chrome inside a 72px
   column: headings clipped mid-word, legal links overflowing, and a horizontal scrollbar
   inside the navigation that no page-level overflow guard could see, because .bd-sidebar is
   position: fixed and scrolls inside itself. Nothing looked wrong in the stylesheet — the
   rules are all there, in the right breakpoints, struck through in a dev-tools list nobody
   scrolls. Guards: StylesheetCascadeOrderTests (source, in CI) and SidebarPresentationTests
   (a real browser, not in CI). Anything added below a breakpoint block here has to be an
   override of it, never a base. */
/* display: none below md, restored at md+ in the rail block. The drawer now opens beneath the
   burger strip, which already carries the mark and the wordmark, so the panel repeating both
   immediately underneath is the same lockup twice - and the collapse toggle this row also holds
   is an lg/xl affordance that was never shown at this width anyway. */
.bd-sidebar__header {
  display: none;
  align-items: center;
  justify-content: space-between;
  gap: var(--bd-space-2);
  min-height: 56px;
  padding-inline: var(--bd-space-3);
  flex: none;
}

.bd-sidebar__brand-link {
  display: flex;
  align-items: center;
  gap: var(--bd-space-2);
  min-width: 0;
  color: var(--bd-color-on-surface);
  text-decoration: none;
  font-family: var(--bd-font-display);
  font-weight: 700;
}

.bd-sidebar__brand-icon {
  width: 26px;
  height: 26px;
  flex: none;
}

.bd-sidebar__brand-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.bd-sidebar__collapse-toggle {
  display: none; /* only ever shown at lg/xl — see the media block above */
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  flex: none;
  border-radius: var(--bd-radius-pill);
  background: transparent;
  color: var(--bd-color-muted);
  border: none;
}

.bd-sidebar__collapse-icon {
  width: 18px;
  height: 18px;
  flex: none;
}

/* `flex: 0 1 auto`, not `1 1 auto`: the nav list takes the height it needs and no more, so
   the utility zone below it follows the navigation it belongs to instead of being pushed to
   the bottom of the column. On a 1919px-tall screen the old `1 1 auto` opened a 620px void
   between the last destination and the legal group, which read as detached debris rather
   than as chrome. It still shrinks (`1` in the middle) with `min-height: 0` and its own
   scrollbar when the viewport is too short for the list, which is the case bottom-pinning
   was never about. Guard: SidebarPresentationTests.OnAVeryTallViewport_... */
.bd-sidebar__scroll {
  flex: 0 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: var(--bd-space-2) var(--bd-space-2) var(--bd-space-3);
}

.bd-sidebar__group {
  margin-top: var(--bd-space-4);
}

.bd-sidebar__group-label {
  display: block;
  padding: var(--bd-space-2) var(--bd-space-3) var(--bd-space-1);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--bd-color-muted);
}

/* A flex row (icon-wrap + label), and the flex item of a flex COLUMN one level up
   (.bd-sidebar__scroll/.bd-sidebar__group) rather than of another flex row — but
   min-width: 0 lives here anyway, on the row that actually contains the localized text,
   rather than only on .bd-sidebar__label below: NoHorizontalScrollSourceGuardTests reads
   whichever occurrence of a selector it finds first, and this one has no @media-scoped
   duplicate to be confused with. */
.bd-sidebar__row {
  display: flex;
  align-items: center;
  gap: var(--bd-space-3);
  min-width: 0;
  min-height: 44px;
  padding-inline: var(--bd-space-3);
  border-radius: var(--bd-radius-md);
  color: var(--bd-color-muted);
  text-decoration: none;
}

.bd-sidebar__icon-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  flex: none;
  border-radius: var(--bd-radius-pill);
  transition: background-color var(--bd-duration-short) var(--bd-ease-standard);
}

.bd-sidebar__icon {
  width: 22px;
  height: 22px;
  flex: none;
}

/* The text-bearing flex item inside .bd-sidebar__row (display: flex, row by default):
   min-width: 0 is what lets it truncate instead of forcing the whole 72-320px-wide
   sidebar wider than its own reserved column (NoHorizontalScrollSourceGuardTests). */
.bd-sidebar__label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Active row: --bd-color-primary fill + --bd-color-on-primary icon; inactive:
   --bd-color-muted — the same rule BottomNav used, ported unchanged. */
.bd-sidebar__row--active {
  color: var(--bd-color-on-surface);
}

.bd-sidebar__row--active .bd-sidebar__icon-wrap {
  background: var(--bd-color-primary);
  color: var(--bd-color-on-primary);
}

.bd-sidebar__utility {
  flex: none;
  padding: var(--bd-space-3);
  border-top: 1px solid var(--bd-color-border);
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-1);
}

/* ---- the settings block: theme + language, at the foot of the utility zone (issue #419) ----

   ONE ROW THAT OPENS A FLYOUT, AT EVERY WIDTH. The summary is laid out exactly like a
   destination row (it is deliberately NOT given .bd-sidebar__row - it is not a destination, and
   AppSidebarTests enumerates the navigation by that class), and it carries a .bd-sidebar__label
   so the rail's existing label-clipping makes it icon-only for free.

   The panel is absolutely positioned, so opening it is out of flow. That is not a style
   preference: rendering both chip groups inline costs 316px of a fixed-height column whose
   utility zone does not shrink, and at 1440x900 it pushed the Discover and Account groups off
   the navigation entirely - with every DOM measurement reading fine and only the screenshot
   showing the navigation gone.

   The UA hides a closed <details>'s content two different ways depending on engine - display:
   none on the child in older ones, content-visibility: hidden on ::details-content in current
   Chrome, the pair that shipped the sidebar itself invisible in v0.0.36. Both are neutralised
   unconditionally here, and visibility is decided by `display` on the panel alone: one mechanism
   to reason about rather than two that disagree per browser. */
.bd-sidebar__settings {
  position: relative;
  flex: none;
}

.bd-sidebar__settings::details-content {
  content-visibility: visible;
  block-size: auto;
}

.bd-sidebar__settings-summary {
  display: flex;
  align-items: center;
  gap: var(--bd-space-3);
  min-width: 0;
  min-height: 44px;
  padding-inline: var(--bd-space-3);
  border-radius: var(--bd-radius-md);
  color: var(--bd-color-muted);
  cursor: pointer;
  list-style: none; /* Firefox/Safari draw a disclosure triangle on <summary> by default */
}

.bd-sidebar__settings-summary::-webkit-details-marker,
.bd-sidebar__settings-summary::marker {
  display: none;
  content: "";
}

/* Base/sm - inside the off-canvas drawer the flyout opens upward and stays within the panel's
   own width: anchoring it to the right (left: 100%, as at md+) would put a 240px popover past
   the viewport edge on a 360px phone, which is the horizontal overflow this repository has a
   whole rule about. */
.bd-sidebar__settings-panel {
  display: none;
  position: absolute;
  left: var(--bd-space-2);
  right: var(--bd-space-2);
  bottom: 100%;
  z-index: var(--bd-z-sidebar);
  flex-direction: column;
  gap: var(--bd-space-3);
  margin-bottom: var(--bd-space-2);
  padding: var(--bd-space-3);
  border: 1px solid var(--bd-color-border);
  border-radius: var(--bd-radius-lg);
  background: var(--bd-color-surface-raised);
  box-shadow: var(--bd-shadow-2);
}

.bd-sidebar__settings[open] > .bd-sidebar__settings-panel {
  display: flex;
}

.bd-sidebar__setting {
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-2);
  min-width: 0;
}

.bd-sidebar__setting-label {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--bd-color-muted);
}

.bd-sidebar__legal-links {
  display: flex;
  flex-direction: column;
  padding-bottom: var(--bd-space-2);
}

.bd-sidebar__legal-link {
  display: flex;
  align-items: center;
  min-height: 36px;
  padding-inline: var(--bd-space-3);
  border-radius: var(--bd-radius-md);
  color: var(--bd-color-muted);
  font-size: var(--bd-type-body-sm-size);
  text-decoration: none;
}

.bd-sidebar__legal-compact {
  display: none; /* only shown in rail mode — see the media blocks above */
  align-items: center;
  gap: var(--bd-space-3);
  min-height: 44px;
  padding-inline: var(--bd-space-3);
  border-radius: var(--bd-radius-md);
  color: var(--bd-color-muted);
  text-decoration: none;
}

/* ---- the icon-rail hover/focus tooltip (issue #456) ----

   display: none UNCONDITIONALLY here, outside any media query, is belt and braces: JS only ever
   toggles the `hidden` attribute (sidebar-tooltip.js), never a display style, so this rule alone
   is what stops the bubble ever painting at any width but the rail even if JS mistakenly clears
   `hidden` somewhere else — the @media (min-width: 905px) block below is the ONE place that turns
   it back on, which is what makes it the single source of truth the JS comment promises.

   It is deliberately NOT nested under .bd-sidebar__utility or .bd-sidebar__scroll — see
   AppSidebar.razor's own remarks on why the markup places it as a direct child of .bd-sidebar
   itself instead. */
.bd-sidebar__tooltip {
  display: none;
}

@media (min-width: 905px) {
  /* No burger, no scrim, no off-canvas drawer any more — the panel is persistent chrome. */
  .bd-sidebar-drawer__summary {
    display: none;
  }

  .bd-sidebar-drawer__scrim {
    display: none !important;
  }

  /* THE LINE BELOW IS NOT ENOUGH ON ITS OWN, AND THAT SHIPPED BROKEN IN v0.0.36.
     Chrome stopped hiding a closed <details>'s content with `display: none` on the
     child. It now wraps that content in a `::details-content` pseudo-element and sets
     `content-visibility: hidden` there. So overriding `display` on .bd-sidebar wins an
     argument nobody is having: the sidebar gets a full layout box — 264px wide,
     `display: flex`, `visibility: visible`, and `content-visibility: visible` on the
     element itself — and is never painted.

     Every probe reports it visible, because every property you would think to check IS
     visible. It was found by looking at a screenshot, after three rounds of measuring
     the DOM said the navigation was fine while the person using the site could see that
     it was not. The suppression is on the pseudo-element, so that is where the override
     has to go. */
  .bd-sidebar-drawer::details-content {
    content-visibility: visible;
    /* The pseudo also clamps block-size while hidden; without this the sidebar lays out
       at full document height (measured: 3,884px against a 900px viewport) instead of
       the fixed viewport column it is meant to be. */
    block-size: auto;
  }

  .bd-sidebar {
    display: flex !important; /* overrides the UA default that hides a closed <details>'s content */
    /* The burger strip is gone at this width, so the column owns the top edge and the safe-area
       inset again - both of which the drawer state hands to the strip instead. */
    top: 0;
    padding-top: var(--bd-safe-area-top);
    width: 72px;
    transform: none;
    transition: none;
  }

  .bd-sidebar__header {
    display: flex;
  }

  /* Rail-mode compaction, unconditional at md (§4.2: "md gets no manual override — at
     this width the rail is the only state"). The labels stay in the DOM — read by every
     screen reader exactly as if visible — they are only clipped, never display:none or
     aria-hidden (the existing BottomNav "never icon-only without a text label" rule,
     carried forward unchanged). The group headings and the wordmark ARE display:none
     here, safely: both are aria-hidden decoration whose accessible name is carried
     elsewhere (the role="group" wrapper's own aria-label; the brand link's own
     aria-label) — see AppSidebar.razor's remarks. */
  .bd-sidebar__label {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

  .bd-sidebar__group-label,
  .bd-sidebar__brand-label,
  .bd-sidebar__legal-links {
    display: none;
  }

  /* THE RAIL HAS ONE AXIS AND EVERYTHING ON IT SITS ON THAT AXIS (issue #476).
     Reported as "the logo in the top left corner still looks wrong", and measured on production
     at 988px before changing anything: the rail is 72px, so its centre is x=36 - and nothing was
     on it. The brand mark's centre was x=26 and every nav glyph's was x=41, fifteen pixels apart
     from each other and both off the column they appear to belong to.

     Two independent causes, which is why it survived a look at either one alone.
     The brand: .bd-sidebar__header is `justify-content: space-between` with `padding-inline`, and
     in the rail its label and its collapse toggle are both display:none - so the lone 26px glyph
     had nothing to be spaced against and simply sat against the left padding.
     The glyphs: .bd-sidebar__row keeps `padding-inline: var(--bd-space-3)`, which leaves a 30px
     content box inside a 54px row - and .bd-sidebar__icon-wrap is 40px. A 40px box in a 30px
     content box overflows to the right by exactly the 5px measured.

     So the rail zeroes the inline padding it cannot afford and centres both, rather than nudging
     either with a magic offset. */
  .bd-sidebar__header {
    justify-content: center;
    padding-inline: 0;
  }

  .bd-sidebar__row {
    justify-content: center;
    padding-inline: 0;
  }


  /* The group headings are gone in the rail, so the grouping is carried by a hairline
     instead — otherwise nine icons read as one undifferentiated list. */
  .bd-sidebar__group {
    margin-top: var(--bd-space-2);
    padding-top: var(--bd-space-2);
    border-top: 1px solid var(--bd-color-border);
  }

  .bd-sidebar__legal-compact {
    display: flex;
  }

  /* md and up - the drawer is gone and the panel is a persistent fixed column, so the flyout
     anchors to its right-hand edge instead of opening upward over the navigation.
     .bd-sidebar is position: fixed with no overflow clipping, so it escapes the column. */
  .bd-sidebar__settings-panel {
    left: calc(100% + var(--bd-space-2));
    right: auto;
    bottom: 0;
    width: 240px;
    margin-bottom: 0;
  }

  .bd-sidebar__collapse-toggle {
    display: none;
  }

  /* The tooltip bubble itself, shown only at rail width (§4.2 — labels are clipped nowhere
     else, so there is nothing for a tooltip to stand in for at md+/expanded or below md, where
     the drawer already shows full labels). `:not([hidden])` rather than a bare selector: the
     element is always in the DOM (sidebar-tooltip.js finds it once on load), and `display` here
     only overrides the unconditional `display: none` above while JS has cleared `hidden` — the
     un-hidden-but-covered-by-`display:none` state the base rule guards against everywhere else
     is exactly what this selector is careful not to reintroduce.

     position: absolute, not fixed: .bd-sidebar is itself position: fixed, which makes it the
     containing block for an absolutely-positioned descendant (the same mechanism
     .bd-sidebar__settings-panel below relies on, and the same "escapes the column" comment
     applies unchanged) — so this paints outside the 72px rail without being clipped by anything,
     regardless of which row (inside .bd-sidebar__scroll or the never-clipped utility zone)
     triggered it. `top` is set by JS to the hovered/focused row's own vertical midpoint;
     transform: translateY(-50%) is what turns that midpoint into true vertical centering rather
     than the bubble's own top edge landing there.

     pointer-events: none: the bubble must never intercept the hover it is a response to, or
     moving the mouse from a row onto the tooltip covering it would fire this row's own pointerout
     and hide the very thing the pointer is now over.

     z-index: same tier as .bd-sidebar__settings-panel (--bd-z-sidebar) — both are floating
     content anchored to the rail rather than a dropdown/modal a click can open beneath them, so
     there is no ordering requirement between the two, and neither shows while the other's
     triggering gesture is in progress (a settings-panel is a click-opened <details>; a tooltip is
     a hover/focus response) so they cannot occupy the same pixel at the same time regardless. */
  .bd-sidebar__tooltip:not([hidden]) {
    display: block;
    position: absolute;
    left: 100%;
    margin-left: var(--bd-space-2);
    z-index: var(--bd-z-sidebar);
    padding: var(--bd-space-1) var(--bd-space-3);
    border-radius: var(--bd-radius-md);
    background: var(--bd-color-surface-overlay);
    color: var(--bd-color-on-surface);
    box-shadow: var(--bd-shadow-3);
    font-size: var(--bd-type-body-sm-size);
    white-space: nowrap;
    pointer-events: none;
    transform: translateY(-50%);
  }
}

@media (min-width: 1240px) {
  .bd-sidebar {
    width: 264px;
  }

  .bd-sidebar__collapse-toggle {
    display: inline-flex;
  }

  /* Expanded is the default at lg/xl: undo every rail-mode compaction from the md block
     above... */
  .bd-sidebar__label {
    position: static;
    width: auto;
    height: auto;
    padding: 0;
    margin: 0;
    overflow: hidden;
    clip: auto;
    white-space: nowrap;
    text-overflow: ellipsis;
    border: 0;
  }

  .bd-sidebar__group-label,
  .bd-sidebar__brand-label,
  .bd-sidebar__legal-links {
    display: block;
  }

  .bd-sidebar__group {
    margin-top: var(--bd-space-4);
    padding-top: 0;
    border-top: none;
  }

  .bd-sidebar__legal-compact {
    display: none;
  }

  /* ...unless the visitor collapsed it (AppSidebar.razor's _collapsed state, reflected
     as this class on its own root element) — then lg/xl reads exactly like the md rail
     above, one selector at a time so the two never drift apart. */
  .bd-mobile-topbar--rail .bd-sidebar {
    width: 72px;
  }

  .bd-mobile-topbar--rail .bd-sidebar__label {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

  .bd-mobile-topbar--rail .bd-sidebar__group-label,
  .bd-mobile-topbar--rail .bd-sidebar__brand-label,
  .bd-mobile-topbar--rail .bd-sidebar__legal-links {
    display: none;
  }

  /* The collapsed lg/xl rail reads exactly like the md rail above - same axis, same reason
     (#476). Stated separately rather than shared, one selector at a time, which is the
     convention this whole block already follows so the two states cannot drift apart. */
  .bd-mobile-topbar--rail .bd-sidebar__header {
    justify-content: center;
    padding-inline: 0;
  }

  .bd-mobile-topbar--rail .bd-sidebar__row {
    justify-content: center;
    padding-inline: 0;
  }


  .bd-mobile-topbar--rail .bd-sidebar__group {
    margin-top: var(--bd-space-2);
    padding-top: var(--bd-space-2);
    border-top: 1px solid var(--bd-color-border);
  }

  .bd-mobile-topbar--rail .bd-sidebar__legal-compact {
    display: flex;
  }

}

/* =========================================================================
   TopBar — sticky header (§5). Chrome, rendered through
   <GlassSurface Class="bd-top-bar"> — see the note on BottomNav above; this
   block owns layout only, never background/box-shadow.
   ========================================================================= */

.bd-top-bar {
  position: sticky;
  top: 0;
  z-index: var(--bd-z-sticky-header);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--bd-space-3);
  min-height: 56px;
  padding: var(--bd-space-2) var(--bd-space-4);
  padding-top: calc(var(--bd-space-2) + var(--bd-safe-area-top));
  border-bottom: 1px solid var(--bd-color-border);
}

.bd-top-bar__title {
  font-family: var(--bd-font-display);
  font-weight: 600;
  font-size: var(--bd-type-headline-size);
  line-height: var(--bd-type-headline-line);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.bd-top-bar__actions {
  display: flex;
  align-items: center;
  gap: var(--bd-space-1);
  flex: none;
  /* Caller supplies a bounded action fragment. */
}

/* =========================================================================
   SearchField (§5).
   ========================================================================= */

.bd-search-form {
  margin: var(--bd-space-4) 0;
}

.bd-search-field {
  display: flex;
  align-items: center;
  gap: var(--bd-space-2);
  min-height: 44px;
  padding-inline: var(--bd-space-3);
  border-radius: var(--bd-radius-md);
  background: var(--bd-color-surface-raised);
  border: 1px solid var(--bd-color-border);
}

.bd-search-field:focus-within {
  border-color: var(--bd-color-focus-ring);
}

.bd-search-field__icon {
  width: 20px;
  height: 20px;
  flex: none;
  color: var(--bd-color-muted);
}

.bd-search-field__input {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 44px;
  border: none;
  background: transparent;
  font-family: var(--bd-font-body);
  font-size: var(--bd-type-body-size);
  line-height: var(--bd-type-body-line);
  color: var(--bd-color-on-surface);
}

.bd-search-field__input::placeholder {
  color: var(--bd-color-muted);
}

.bd-search-field__input:focus-visible {
  outline: 3px solid var(--bd-color-focus-ring);
  outline-offset: 2px;
}

.bd-search-field__filter {
  position: relative;
}

.bd-search-field__filter-count {
  position: absolute;
  inset: 0 0 auto auto;
  min-width: 16px;
  padding: 2px;
  border-radius: var(--bd-radius-pill);
  background: var(--bd-color-primary);
  color: var(--bd-color-on-primary);
  font-size: var(--bd-type-label-size);
  line-height: var(--bd-type-label-line);
}

/* =========================================================================
   Card grid / dense list shells shared by SetCard, PartTile.
   Browse surfaces use the card grid (2 cols at sm, 2-3 at md — §4.1);
   Collection surfaces stay single-column (density beats grid — §1.4).
   ========================================================================= */

.bd-card-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--bd-space-4);
}

@media (min-width: 600px) {
  .bd-card-grid {
    /* minmax(0, 1fr), not a bare 1fr: a bare 1fr track's minimum width is its content's
       intrinsic size, so a set/part/minifig title long enough to be one unbroken run of
       characters would widen the whole grid rather than truncate inside its card -
       BrickDb.App.Shared.Tests.Design.NoHorizontalScrollSourceGuardTests. */
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@media (min-width: 905px) {
  .bd-card-grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--bd-space-5);
  }
}

.bd-list {
  display: flex;
  flex-direction: column;
}

/* =========================================================================
   LauncherCard — the home launcher's grid (docs/design/sidebar-navigation-and-home-
   launcher.md §7.1), laid out through the same .bd-card-grid shell as every other
   browse surface. Cards are solid, never <GlassSurface> (design-system.md §2.4.3).
   ========================================================================= */

.bd-launcher-card {
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-2);
  min-width: 0;
  padding: var(--bd-space-4);
  border-radius: var(--bd-radius-lg);
  background: var(--bd-color-card-surface);
  color: var(--bd-color-on-surface);
  text-decoration: none;
  border: 1px solid var(--bd-color-border);
}

.bd-launcher-card__icon-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: var(--bd-radius-pill);
  /* --bd-color-primary-light is documented as "tints, subtle fills… AA-exempt, never a
     text-bearing fill" (tokens.css §2.3) — exactly this use, a decorative icon backing
     rather than text, so no new WCAG pair is needed in TokenContrastTests' curated table. */
  background: var(--bd-color-primary-light);
  color: var(--bd-color-primary);
}

.bd-launcher-card__icon {
  width: 22px;
  height: 22px;
  flex: none;
}

.bd-launcher-card__title {
  font-weight: 700;
  font-size: var(--bd-type-body-lg-size);
}

.bd-launcher-card__body {
  color: var(--bd-color-muted);
  font-size: var(--bd-type-body-sm-size);
}

.bd-launcher-card__badge {
  align-self: flex-start;
  margin-top: auto;
  padding: var(--bd-space-1) var(--bd-space-3);
  border-radius: var(--bd-radius-pill);
  background: var(--bd-color-surface);
  color: var(--bd-color-muted);
  font-size: 11px;
  font-weight: 700;
}

/* =========================================================================
   SetCard (§5) — large rounded card, hero image top ~60%, title, counts,
   dominant ConditionBadge.
   ========================================================================= */

.bd-set-card {
  display: flex;
  flex-direction: column;
  border-radius: var(--bd-radius-lg);
  /* Cards are solid, decided (design-system.md §2.4.3) — never wrapped in
     <GlassSurface>. --bd-color-card-surface resolves to --bd-color-surface-raised in
     both themes; the indirection is the point (see tokens.css). */
  background: var(--bd-color-card-surface);
  box-shadow: var(--bd-shadow-2);
  overflow: hidden;
  text-decoration: none;
  color: inherit;
}

.bd-set-card__media {
  position: relative;
  width: 100%;
  aspect-ratio: 5 / 3; /* hero image occupies roughly the top 60% of the card */
  background: var(--bd-color-section-surface);
}

.bd-minifig-card .bd-set-card__media {
  aspect-ratio: 1;
}

.bd-set-card__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* CatalogImage (design-system.md §7) — a self-hosted catalogue render layered over its own
   designed empty state. The fallback sits underneath at all times, because the failed-load
   path is the plain HTML `onerror` attribute (an anonymous visitor gets no Blazor circuit,
   so no C# handler can run) and `onerror` can only hide the image, never conjure markup. */
.bd-catalog-image {
  position: absolute;
  inset: 0;
}

.bd-catalog-image__fallback {
  position: absolute;
  inset: 0;
}

/* Two classes deep so it beats `.bd-set-card__media img` (0,1,1) on specificity: a
   catalogue render is a whole product photographed against its own ground, so cropping it
   to fill the card - which is right for a user's own photo - cuts the set in half. */
.bd-catalog-image > .bd-catalog-image__img {
  /* PAINTS ABOVE THE FALLBACK, AND WITHOUT THIS IT DOES NOT.
     The image and the "No image" fallback are both position:absolute in the same box.
     With z-index:auto on both, paint order is DOM order and the fallback is second, so
     the placeholder was drawn on top of a perfectly loaded image - naturalWidth set,
     complete true, display block, opacity 1, and invisible. elementFromPoint at the card
     centre returned the fallback's icon. Shipped that way; found by looking at a
     screenshot after three rounds of DOM probes reported everything visible. */
  z-index: 1;

  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  padding: var(--bd-space-2);
  object-fit: contain;
  background: var(--bd-color-section-surface);
}

/* NOT REDUNDANT. reset.css sets `img { display: block }`, and an author `display` beats the
   user-agent `[hidden] { display: none }` rule - so without this, `onerror` would set the
   attribute, the browser would honour nothing, and the broken-image glyph this whole
   component exists to avoid would stay on screen. */
.bd-catalog-image__img[hidden] {
  display: none;
}

/* The small square-ish frame the same CatalogImage sits in outside a card - the
   add-to-collection header. It supplies what .bd-set-card__media supplies there: a
   positioning context and a fixed aspect ratio, so nothing reflows when the image lands. */
.bd-catalog-thumb {
  position: relative;
  flex: 0 0 auto;
  width: 96px;
  aspect-ratio: 5 / 3;
  overflow: hidden;
  border-radius: var(--bd-radius-md);
  background: var(--bd-color-section-surface);
}

.bd-set-card__badge-slot {
  z-index: 2;
  position: absolute;
  top: var(--bd-space-3);
  right: var(--bd-space-3);
}

.bd-set-card__body {
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-1);
  padding: var(--bd-space-4);
}

.bd-set-card__number,
.bd-scan-result__number {
  color: var(--bd-color-muted);
  font-size: var(--bd-type-caption-size);
}

.bd-set-card__number {
  font-family: var(--bd-font-body);
  font-weight: 500;
  line-height: var(--bd-type-caption-line);
  font-variant-numeric: tabular-nums;
}

.bd-set-card__title,
.bd-scan-result__name {
  font-size: var(--bd-type-title-size);
  font-weight: 600;
}

.bd-set-card__title {
  font-family: var(--bd-font-display);
  line-height: var(--bd-type-title-line);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.bd-set-card__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--bd-space-3);
  margin-top: var(--bd-space-1);
  color: var(--bd-color-muted);
  font-family: var(--bd-font-body);
  font-weight: 500;
  font-size: var(--bd-type-body-sm-size);
  line-height: var(--bd-type-body-sm-line);
}

.bd-set-card__meta-item {
  display: inline-flex;
  align-items: center;
  gap: var(--bd-space-1);
  font-variant-numeric: tabular-nums;
}

.bd-set-card__meta-item svg {
  width: 16px;
  height: 16px;
  flex: none;
}

/* SetCard's "view details" link (issue: /sets/{SetNumber} shipped in #411 reachable from
   nowhere - neither /search's nor /themes/{id}'s card linked to it). Two separate <a> elements
   share this class - one wraps .bd-set-card__media, the other wraps the number/title/meta
   group inside .bd-set-card__body - because the card also carries the "Add to collection" CTA
   as its own <a>, and nesting an <a> inside another <a> is invalid HTML that breaks
   click-through. Siblings, never nested.

   The two usages need different box behaviour, so the base rule below is `block` (right for
   wrapping the image, which has no internal layout of its own to preserve) and the
   `.bd-set-card__body >` rule re-declares the flex column + gap that the wrapped
   number/title/meta spans had as direct children of .bd-set-card__body before this link
   existed - so wrapping them changes nothing about how they stack or space themselves. */
.bd-set-card__link {
  display: block;
  color: inherit;
  text-decoration: none;
}

.bd-set-card__body > .bd-set-card__link {
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-1);
}

.bd-set-card__body > .bd-btn {
  margin-top: var(--bd-space-3);
}

/* =========================================================================
   PartTile (§5) — compact list/grid density row for OwnedPartView.
   ========================================================================= */

.bd-part-tile {
  display: flex;
  align-items: center;
  gap: var(--bd-space-3);
  min-height: 56px;
  padding: var(--bd-space-2) var(--bd-space-3);
  border-bottom: 1px solid var(--bd-color-border);
  /* Cards/rows are solid, never <GlassSurface> — design-system.md §2.4.3. A
     virtualised list of these is exactly the unbounded-scroll case backdrop-filter
     cannot afford. */
  background: var(--bd-color-card-surface);
}

.bd-list .bd-part-tile:first-child {
  border-top-left-radius: var(--bd-radius-md);
  border-top-right-radius: var(--bd-radius-md);
}

.bd-list .bd-part-tile:last-child {
  border-bottom-left-radius: var(--bd-radius-md);
  border-bottom-right-radius: var(--bd-radius-md);
  border-bottom: none;
}

.bd-part-tile__media {
  position: relative;
  flex: none;
  width: 48px;
  height: 48px;
  border-radius: var(--bd-radius-sm);
  background: var(--bd-color-section-surface);
  overflow: hidden;
}

.bd-part-tile__media img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* Colour swatch overlay, corner-anchored, indicating the part's LEGO colour. */
.bd-part-tile__swatch {
  position: absolute;
  bottom: 2px;
  right: 2px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 2px solid var(--bd-color-card-surface);
}

.bd-part-tile__body {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.bd-part-tile__name {
  font-family: var(--bd-font-body);
  font-weight: 400;
  font-size: var(--bd-type-body-size);
  line-height: var(--bd-type-body-line);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.bd-part-tile__meta {
  display: flex;
  align-items: center;
  gap: var(--bd-space-2);
}

/* Source chip — "Loose" or "From: <set nickname>". */
.bd-part-tile__source {
  display: inline-flex;
  align-items: center;
  padding: 2px var(--bd-space-2);
  border-radius: var(--bd-radius-sm);
  background: var(--bd-color-surface);
  border: 1px solid var(--bd-color-border);
  color: var(--bd-color-muted);
  font-family: var(--bd-font-body);
  font-weight: 500;
  font-size: var(--bd-type-caption-size);
  line-height: var(--bd-type-caption-line);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}

.bd-part-tile__qty {
  flex: none;
  font-family: var(--bd-font-body);
  font-weight: 500;
  font-size: var(--bd-type-body-sm-size);
  font-variant-numeric: tabular-nums;
  color: var(--bd-color-on-surface);
  min-width: 2.5ch;
  text-align: right;
}

.bd-part-tile__badge {
  flex: none;
}

/* =========================================================================
   ConditionBadge (§6) — the 9-step ConditionGrade encoding.
   Colour is never the sole channel: shape (rounded-square / circle /
   pennant) + fill texture (solid / diagonal-hatch / cross-hatch) + colour
   (hue ramp, band-stepped) + an always-visible caps text label. The badge
   chip's own FILL is the grade colour; the shape glyph and label are
   rendered in the on-grade contrast colour on top of it, so:
     - shape gets a colourblind/low-vision user to the right third (band),
     - texture narrows it further within the band,
     - the caps label resolves the exact grade regardless of any visual
       channel,
     - a full-word aria-label (set in markup, see gallery.html) backs every
       instance for assistive tech; the visible abbreviation is decorative
       (aria-hidden) so it is never the only text a screen reader gets.
   The shape/texture SVG (with its own <pattern> defs) lives in markup per
   instance — components.css only sizes and colours it via currentColor,
   which is why the shape glyph's fill/stroke must always be currentColor.
   ========================================================================= */

.bd-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--bd-space-1);
  height: 20px;
  padding: 0 var(--bd-space-2) 0 6px;
  border-radius: var(--bd-radius-sm);
  color: var(--bd-color-on-grade); /* on-grade also drives glyph currentColor */
  white-space: nowrap;
}

.bd-badge__shape {
  width: 14px;
  height: 14px;
  flex: none;
  display: block;
}

.bd-badge__label {
  font-family: var(--bd-font-body);
  font-weight: 600;
  font-size: var(--bd-type-label-size);
  line-height: var(--bd-type-label-line);
  letter-spacing: var(--bd-type-label-tracking);
  text-transform: uppercase;
}

/* Band 1 — Like-new: rounded square, green family */
.bd-badge--grade-1 { background: var(--bd-grade-1); } /* Sealed */
.bd-badge--grade-2 { background: var(--bd-grade-2); } /* Mint */
.bd-badge--grade-3 { background: var(--bd-grade-3); } /* NearMint */

/* Band 2 — Displayed: circle, blue-indigo family */
.bd-badge--grade-4 { background: var(--bd-grade-4); } /* Excellent */
.bd-badge--grade-5 { background: var(--bd-grade-5); } /* VeryGood */
.bd-badge--grade-6 { background: var(--bd-grade-6); } /* Good */

/* Band 3 — Well-used: pennant, amber-red family */
.bd-badge--grade-7 { background: var(--bd-grade-7); } /* Fair */
.bd-badge--grade-8 { background: var(--bd-grade-8); } /* Poor */
.bd-badge--grade-9 { background: var(--bd-grade-9); } /* ForParts */

/* =========================================================================
   ThemeChip (§2.6) — a set's LEGO theme in one of the six accent hues.

   Colour is decoration here, never information: ThemeChip.razor always renders
   the theme's name as text inside the chip, so a user who cannot tell the hues
   apart loses nothing. What the hue buys is scannability in a grid of forty
   cards.

   The 1px border is load-bearing, not trim. It is drawn in the hue's OWN ink,
   which is what gives the chip a visible edge in both directions of §2.6's
   boundary rule: a deep hue's fill already contrasts with the card and its
   white ink border disappears into the fill's edge harmlessly, while a bright
   yellow chip's fill reaches only 1.51:1 against a near-white card and it is
   the dark-indigo border that carries the boundary at 14.49:1. Removing the
   border makes the bright half of the family vanish into the card. Guard:
   BrickDb.App.Shared.Tests.Design.TokenContrastTests
   .EveryAccentChipHasAVisibleEdgeAgainstTheCardItSitsOn.
   ========================================================================= */

.bd-theme-chip {
  display: inline-flex;
  align-items: center;
  padding: 2px var(--bd-space-2);
  border-radius: var(--bd-radius-sm);
  border: 1px solid currentColor;
  font-family: var(--bd-font-body);
  font-weight: 600;
  font-size: var(--bd-type-caption-size);
  line-height: var(--bd-type-caption-line);
  white-space: nowrap;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
}

.bd-theme-chip--red {
  background: var(--bd-accent-red);
  color: var(--bd-accent-red-ink);
}

.bd-theme-chip--orange {
  background: var(--bd-accent-orange);
  color: var(--bd-accent-orange-ink);
}

.bd-theme-chip--yellow {
  background: var(--bd-accent-yellow);
  color: var(--bd-accent-yellow-ink);
}

.bd-theme-chip--green {
  background: var(--bd-accent-green);
  color: var(--bd-accent-green-ink);
}

.bd-theme-chip--blue {
  background: var(--bd-accent-blue);
  color: var(--bd-accent-blue-ink);
}

.bd-theme-chip--purple {
  background: var(--bd-accent-purple);
  color: var(--bd-accent-purple-ink);
}

/* =========================================================================
   BrickRow (§2.7) — a decorative row of six studded bricks, one per accent
   hue. Pure decoration (the component renders aria-hidden="true"): nothing
   here is information a screen reader needs, and every colour is one of the
   existing --bd-accent-* tokens — no new hue is introduced for this.
   ========================================================================= */

.bd-brick-row {
  display: flex;
  gap: var(--bd-space-2);
  padding-top: var(--bd-space-2);
}

.bd-brick-row__brick {
  position: relative;
  flex: none;
  width: 40px;
  height: 24px;
  border-radius: var(--bd-radius-sm);
  box-shadow: var(--bd-shadow-1);
}

/* The stud: a small circle in the same fill, floated above the brick's own
   top edge — the same "physical brick" silhouette as the mark's own two
   chamfered nubs (brand/README.md §1), rendered in accent colour rather than
   the brand indigo/coral so it stays clearly a design-system element, never
   a redraw of the logo. */
.bd-brick-row__brick::before {
  content: "";
  position: absolute;
  top: -5px;
  left: 50%;
  transform: translateX(-50%);
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: inherit;
}

.bd-brick-row__brick--red { background: var(--bd-accent-red); }
.bd-brick-row__brick--orange { background: var(--bd-accent-orange); }
.bd-brick-row__brick--yellow { background: var(--bd-accent-yellow); }
.bd-brick-row__brick--green { background: var(--bd-accent-green); }
.bd-brick-row__brick--blue { background: var(--bd-accent-blue); }
.bd-brick-row__brick--purple { background: var(--bd-accent-purple); }

/* A gentle, staggered pop-in on first paint. Decorative motion only, never
   load-bearing — the row is complete and correct with this rule simply not
   matching, which is exactly what happens under prefers-reduced-motion. */
@media (prefers-reduced-motion: no-preference) {
  .bd-brick-row__brick {
    animation: bd-brick-pop var(--bd-duration-long) var(--bd-ease-emphasized) backwards;
  }

  .bd-brick-row__brick:nth-child(1) { animation-delay: 0ms; }
  .bd-brick-row__brick:nth-child(2) { animation-delay: 60ms; }
  .bd-brick-row__brick:nth-child(3) { animation-delay: 120ms; }
  .bd-brick-row__brick:nth-child(4) { animation-delay: 180ms; }
  .bd-brick-row__brick:nth-child(5) { animation-delay: 240ms; }
  .bd-brick-row__brick:nth-child(6) { animation-delay: 300ms; }
}

@keyframes bd-brick-pop {
  from {
    opacity: 0;
    transform: translateY(6px) scale(0.85);
  }

  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

/* =========================================================================
   EmptyState (§5) — zero-result search, empty collection, missing-image
   slots (lighter inline variant).
   ========================================================================= */

.bd-empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--bd-space-4);
  padding: var(--bd-space-8) var(--bd-space-5);
}

/* The illustration is the one large graphic on an otherwise empty screen, and a grey
   one makes an empty collection read as a broken collection. It takes the brand coral,
   resolved per theme exactly the way app.css resolves link colour (base primary in
   light, primary-light in dark — the base ramp is calibrated for fills, not for text
   or line-art on a dark ground, tokens.css §2.3), on a tinted disc so it reads as a
   deliberate illustration rather than a stray icon. Non-text, ≥3:1 against the section
   surface in both themes. */
.bd-empty-state__icon {
  width: 88px;
  height: 88px;
  padding: var(--bd-space-3);
  border-radius: var(--bd-radius-pill);
  background: var(--bd-color-section-surface);
  color: var(--bd-color-primary);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .bd-empty-state__icon {
    color: var(--bd-color-primary-light);
  }
}

:root[data-theme="dark"] .bd-empty-state__icon {
  color: var(--bd-color-primary-light);
}

.bd-empty-state__title,
.bd-confirm-sheet__title,
.bd-filter-sheet__title,
.bd-theme-tile__name,
.bd-alert__title {
  font-family: var(--bd-font-display);
  font-weight: 600;
  font-size: var(--bd-type-title-size);
  line-height: var(--bd-type-title-line);
}

.bd-empty-state__message,
.bd-confirm-sheet__message,
.bd-alert__message {
  font-size: var(--bd-type-body-size);
  line-height: var(--bd-type-body-line);
}

.bd-empty-state__message,
.bd-confirm-sheet__message {
  color: var(--bd-color-muted);
}

.bd-empty-state__message {
  max-width: 40ch;
}

/* Lighter inline variant — used for missing-image slots inside a tile/card
   rather than a full empty screen (design-system.md §7). */
.bd-empty-state--inline {
  padding: var(--bd-space-3);
  gap: var(--bd-space-2);
  background: var(--bd-color-section-surface);
  border-radius: inherit;
  height: 100%;
  justify-content: center;
}

.bd-empty-state--inline .bd-empty-state__icon {
  width: 32px;
  height: 32px;
  padding: 0;
  background: none;
}

.bd-empty-state--inline .bd-empty-state__message {
  font-size: var(--bd-type-caption-size);
  line-height: var(--bd-type-caption-line);
}

/* =========================================================================
   SkeletonList (§5) — shimmer placeholder rows matching the target row
   height exactly, both for named first-page loading and decorative Virtualize placeholders.
   ========================================================================= */

.bd-skeleton-row {
  display: flex;
  align-items: center;
  gap: var(--bd-space-3);
  min-height: 56px; /* matches .bd-part-tile row height exactly */
  padding: var(--bd-space-2) var(--bd-space-3);
}

/* The shimmer's mid-stop is --bd-color-primary-light rather than a second neutral grey
   (was --bd-color-border-strong until 2026-09-06, design-system.md §2.7) — a small,
   deliberately subtle way of making a loading row feel like BrickDb's own rather than a
   generic placeholder, without needing new tokens or reopening any contrast question: the
   gradient carries no text and no information, and --bd-color-primary-light already exists
   and already resolves correctly in all four palettes (tokens.css). */
.bd-skeleton-block {
  border-radius: var(--bd-radius-sm);
  background: linear-gradient(
    100deg,
    var(--bd-color-border) 30%,
    var(--bd-color-primary-light) 50%,
    var(--bd-color-border) 70%
  );
  background-size: 200% 100%;
  animation: bd-shimmer 1.5s ease-in-out infinite;
}

.bd-skeleton-block--media {
  flex: none;
  width: 48px;
  height: 48px;
}

.bd-skeleton-block--line {
  flex: 1 1 auto;
  height: 14px;
}

.bd-skeleton-block--line-short {
  flex: 0 0 40%;
  height: 12px;
}

.bd-skeleton-row__lines {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-2);
}

.bd-skeleton-row--vertical {
  flex-direction: column;
  align-items: stretch;
}

.bd-skeleton-row--card {
  height: 220px;
}

.bd-skeleton-row--tile {
  height: 96px;
}

.bd-skeleton-block--card-media {
  height: 60%;
}

.bd-skeleton-block--tile-media {
  height: 40%;
}

.bd-skeleton-block--detail-media {
  height: 220px;
}

.bd-skeleton-row--vertical .bd-skeleton-row__lines {
  margin-top: var(--bd-space-2);
}

@keyframes bd-shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

@media (prefers-reduced-motion: reduce) {
  .bd-skeleton-block {
    animation: none;
    background: var(--bd-color-border);
  }
}

/* =========================================================================
   ConfirmSheet (§5) — bottom sheet for destructive/irreversible actions.
   Primary button is the SAFE action; the destructive action is secondary,
   text-styled, in --bd-color-danger — never the other way round.

   The spec doesn't call out a >=sm layout for ConfirmSheet the way it does
   for FilterSheet; a full-width bottom sheet stretched across a 1440px
   viewport reads broken, so this extends the same "sheet becomes a centred
   surface once there's room" idea FilterSheet already uses, capping the
   width and rounding all four corners from `sm` up. See the report.
   ========================================================================= */

.bd-scrim {
  position: fixed;
  inset: 0;
  z-index: var(--bd-z-scrim);
  background: var(--bd-color-scrim);
}

.bd-confirm-sheet {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: var(--bd-z-modal);
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-4);
  width: 100%;
  padding: var(--bd-space-3) var(--bd-space-5)
           calc(var(--bd-space-5) + var(--bd-safe-area-bottom));
  border-radius: var(--bd-radius-xl) var(--bd-radius-xl) 0 0;
  background: var(--bd-color-surface-overlay);
  box-shadow: var(--bd-shadow-4);
  animation: bd-sheet-in var(--bd-duration-medium) var(--bd-ease-emphasized);
}

.bd-confirm-sheet__handle {
  align-self: center;
  width: 36px;
  height: 4px;
  border-radius: var(--bd-radius-pill);
  background: var(--bd-color-border-strong);
}

.bd-confirm-sheet__actions {
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-2);
}

.bd-confirm-sheet__actions .bd-btn {
  width: 100%;
}

@media (min-width: 600px) {
  .bd-confirm-sheet {
    left: 50%;
    right: auto;
    bottom: var(--bd-space-6);
    transform: translateX(-50%);
    width: min(420px, calc(100% - var(--bd-space-6) * 2));
    border-radius: var(--bd-radius-xl);
    padding-bottom: var(--bd-space-5);
  }
}

@keyframes bd-sheet-in {
  from { opacity: 0; transform: translateY(24px); }
  to { opacity: 1; transform: translateY(0); }
}

@media (min-width: 600px) {
  @keyframes bd-sheet-in {
    from { opacity: 0; transform: translate(-50%, 24px); }
    to { opacity: 1; transform: translate(-50%, 0); }
  }
}

@media (prefers-reduced-motion: reduce) {
  .bd-confirm-sheet {
    animation: bd-toast-in-reduced var(--bd-duration-instant) linear;
  }
}

/* =========================================================================
   FilterSheet (§5) — issue #88's browse-and-filter chrome. Bottom sheet
   (--bd-radius-xl top corners) on compact/sm, side sheet on md+; grouped
   filter chips (the caller's ChildContent, built from the same
   .bd-toggle-chip-group pattern as ConditionEditor's flags) plus a sticky
   "Apply (N results)" primary button. Never applies filters live - only
   the Apply tap fires OnApply; the scrim or the close button fires
   OnDismiss instead, discarding whatever was toggled inside.

   Solid --bd-color-surface-overlay fill, matching ConfirmSheet's own
   choice immediately above rather than <GlassSurface> - one sheet
   pattern, one fill, and only one component to re-verify against
   TokenContrastTests if that ever changes.
   ========================================================================= */

.bd-filter-sheet {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: var(--bd-z-modal);
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-3);
  max-height: min(80vh, 640px);
  padding: var(--bd-space-3) var(--bd-space-5)
           calc(var(--bd-space-5) + var(--bd-safe-area-bottom));
  border-radius: var(--bd-radius-xl) var(--bd-radius-xl) 0 0;
  background: var(--bd-color-surface-overlay);
  box-shadow: var(--bd-shadow-4);
  animation: bd-filter-sheet-in-bottom var(--bd-duration-medium) var(--bd-ease-emphasized);
}

.bd-filter-sheet__handle {
  align-self: center;
  width: 36px;
  height: 4px;
  flex: none;
  border-radius: var(--bd-radius-pill);
  background: var(--bd-color-border-strong);
}

.bd-filter-sheet__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--bd-space-2);
}

.bd-filter-sheet__body {
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-4);
  overflow-y: auto;
}

.bd-filter-sheet__footer {
  flex: none;
}

.bd-filter-sheet__apply {
  width: 100%;
}

@media (min-width: 600px) {
  .bd-filter-sheet {
    left: auto;
    border-radius: var(--bd-radius-xl) var(--bd-radius-xl) 0 0;
  }
}

/* md+ (§4): a side sheet rather than a bottom sheet - full height, anchored
   to the trailing edge, rounded only on the leading corners so it reads as
   a panel sliding in rather than a bottom sheet stretched tall. */
@media (min-width: 905px) {
  .bd-filter-sheet {
    top: 0;
    left: auto;
    right: 0;
    bottom: 0;
    max-height: none;
    width: min(400px, 100%);
    padding: var(--bd-space-5);
    border-radius: var(--bd-radius-xl) 0 0 var(--bd-radius-xl);
    animation: bd-filter-sheet-in-side var(--bd-duration-medium) var(--bd-ease-emphasized);
  }

  .bd-filter-sheet__handle {
    display: none;
  }
}

@keyframes bd-filter-sheet-in-bottom {
  from { opacity: 0; transform: translateY(24px); }
  to { opacity: 1; transform: translateY(0); }
}

@keyframes bd-filter-sheet-in-side {
  from { opacity: 0; transform: translateX(24px); }
  to { opacity: 1; transform: translateX(0); }
}

@media (prefers-reduced-motion: reduce) {
  .bd-filter-sheet {
    animation: bd-toast-in-reduced var(--bd-duration-instant) linear;
  }
}

/* =========================================================================
   Form controls — text input, select, textarea, field label/hint, toggle
   chip, disclosure, alert banner. The v1 component set (§5) has no entry
   for any of these: everything that existed before this pass was
   read-only display (SetCard, PartTile, ConditionBadge...). The condition
   editor and the add-to-collection form are the first real forms in the
   app, so this is new rather than reused — flagged per the brief. Built
   from the same tokens/rules as everything above (360px-first, >=44px
   targets, no hover-only affordance, visible focus via app.css).
   ========================================================================= */

.bd-field {
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-1);
}

.bd-field__label {
  font-family: var(--bd-font-body);
  font-weight: 600;
  font-size: var(--bd-type-body-sm-size);
  line-height: var(--bd-type-body-sm-line);
  color: var(--bd-color-on-surface);
}

.bd-field__hint,
.bd-theme-tile__meta {
  font-family: var(--bd-font-body);
  font-size: var(--bd-type-caption-size);
  line-height: var(--bd-type-caption-line);
  color: var(--bd-color-muted);
}

.bd-input,
.bd-select,
.bd-textarea {
  width: 100%;
  min-height: 44px;
  padding: var(--bd-space-2) var(--bd-space-3);
  border-radius: var(--bd-radius-md);
  border: 1px solid var(--bd-color-border);
  background: var(--bd-color-surface-raised);
  color: var(--bd-color-on-surface);
  font-family: var(--bd-font-body);
  font-size: var(--bd-type-body-size);
  line-height: var(--bd-type-body-line);
}

.bd-textarea {
  min-height: 88px;
  resize: vertical;
}

.bd-input:focus,
.bd-select:focus,
.bd-textarea:focus {
  outline: none; /* the visible focus ring below replaces it, per app.css */
}

.bd-input:focus-visible,
.bd-select:focus-visible,
.bd-textarea:focus-visible {
  border-color: var(--bd-color-focus-ring);
}

.bd-form-grid {
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-4);
}

@media (min-width: 600px) {
  .bd-form-grid--cols-2 {
    display: grid;
    grid-template-columns: 1fr 1fr;
  }
}

.bd-checkbox-row {
  display: flex;
  align-items: center;
  gap: var(--bd-space-2);
  min-height: 44px;
}

.bd-checkbox-row input[type="checkbox"] {
  width: 22px;
  height: 22px;
  flex: none;
  accent-color: var(--bd-color-primary);
}

/* Multi-select toggle chips — used for ConditionFlag, which is an open,
   additive set (domain-model.md §4.7): a wrapped row of tappable pills
   beats a scrolling checkbox list at this cardinality. */
.bd-toggle-chip-group {
  display: flex;
  flex-wrap: wrap;
  gap: var(--bd-space-2);
}

.bd-toggle-chip {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 var(--bd-space-4);
  border-radius: var(--bd-radius-pill);
  border: 1px solid var(--bd-color-border-strong);
  background: var(--bd-color-surface-raised);
  color: var(--bd-color-on-surface);
  font-family: var(--bd-font-body);
  font-weight: 500;
  font-size: var(--bd-type-body-sm-size);
}

.bd-toggle-chip[aria-pressed="true"] {
  background: var(--bd-color-primary);
  border-color: var(--bd-color-primary);
  color: var(--bd-color-on-primary);
  font-weight: 600;
}

/* Progressive disclosure — a native <details>/<summary> pair so the
   expand/collapse affordance is keyboard- and screen-reader-operable with
   no script. Used to tuck the seven-axis condition editor's less-common
   axes below the fast path (design-system.md brief: "must not feel like a
   tax form"). */
.bd-disclosure {
  border: 1px solid var(--bd-color-border);
  border-radius: var(--bd-radius-md);
  background: var(--bd-color-section-surface);
  overflow: hidden;
}

.bd-disclosure__summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--bd-space-2);
  min-height: 44px;
  /* The 4px inset is the accent rule below, not decoration for its own sake: it is what
     marks the row as a section header rather than another form field, at a glance and
     without reading it. */
  padding: var(--bd-space-3) var(--bd-space-4) var(--bd-space-3) var(--bd-space-3);
  border-left: 4px solid var(--bd-color-primary);
  cursor: pointer;
  list-style: none;
  font-family: var(--bd-font-body);
  font-weight: 600;
  font-size: var(--bd-type-body-size);
}

.bd-disclosure__summary::-webkit-details-marker {
  display: none;
}

.bd-disclosure__summary-icon {
  width: 20px;
  height: 20px;
  flex: none;
  color: var(--bd-color-muted);
  transition: transform var(--bd-duration-short) var(--bd-ease-standard);
}

.bd-disclosure[open] .bd-disclosure__summary-icon {
  transform: rotate(180deg);
}

.bd-disclosure__body {
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-4);
  padding: 0 var(--bd-space-4) var(--bd-space-4);
  border-top: 1px solid var(--bd-color-border);
  padding-top: var(--bd-space-4);
}

/* Alert banner — inline error states ("what went wrong", never "something
   went wrong"), with an optional retry action. */
.bd-alert {
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-3);
  padding: var(--bd-space-4);
  border-radius: var(--bd-radius-md);
  background: var(--bd-color-danger-surface);
}

.bd-alert__title {
  color: var(--bd-color-on-surface);
}

.bd-alert__message {
  color: var(--bd-color-on-surface);
}

.bd-alert__actions {
  display: flex;
  gap: var(--bd-space-2);
}

/* ============================== Legal pages ==============================
   The four legal documents (Impressum / Datenschutzerklärung / Nutzungs-
   bedingungen / Attributions) and the footer that links them.

   These are long-form prose, which is the one content shape the rest of the
   design system does not cover: everything else here is a list row, a card or
   a control. Two things follow. Body copy gets a reading measure (~68ch) so a
   line does not run the full width of a desktop viewport, and headings get
   generous space above so the document is scannable by section.

   AUTHORED AT 360px, like everything else (design-system.md §1.1 / §4). The
   one real overflow hazard on these pages is the tables — the cookie table in
   the privacy policy and the licence table in the attributions — which is why
   every table is wrapped in .bd-table-scroll rather than left to reflow. A
   table is the one element that legitimately cannot wrap below a certain
   width, so it scrolls inside its own container and the page does not. */

.bd-legal {
  padding: var(--bd-space-4) 0 var(--bd-space-10);
  /* min-width: 0 is what actually stops a wide descendant (the tables below)
     from forcing the whole column wider than the viewport — see
     docs/reuse-outastory-app-layer.md §5. */
  min-width: 0;
}

.bd-legal p,
.bd-legal li {
  max-width: 68ch;
  /* Legal prose carries tokens that have no break opportunity — an e-mail
     address, a cookie name like .AspNetCore.Culture, a statute reference. At
     360px one of those can be wider than the column and push the page into a
     horizontal scroll, which is the one thing no page here may do. */
  overflow-wrap: break-word;
}

.bd-legal section {
  margin-top: var(--bd-space-6);
}

.bd-legal h2 {
  margin-bottom: var(--bd-space-3);
}

.bd-legal h3 {
  margin-top: var(--bd-space-5);
  margin-bottom: var(--bd-space-2);
}

.bd-legal ul {
  padding-left: var(--bd-space-5);
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-2);
}

.bd-legal li {
  list-style: disc;
}

/* The "Stand: … · English version" line under the title. */
.bd-legal__meta {
  color: var(--bd-color-muted);
}

.bd-legal__meta-sep {
  margin: 0 var(--bd-space-2);
}

/* The "the German version is authoritative" notice on each English
   translation. Deliberately given a visible edge rather than left as an
   ordinary paragraph — a reader who skims must not miss it. */
.bd-legal__authoritative {
  border-left: 3px solid var(--bd-color-primary);
  padding: var(--bd-space-3) var(--bd-space-4);
  background: var(--bd-color-surface-raised);
  border-radius: var(--bd-radius-sm);
}

/* ---------------------------- Tables ---------------------------------- */

.bd-table-scroll {
  overflow-x: auto;
  /* Without this the flex/grid parent sizes to the table's intrinsic width
     and the overflow lands on the page instead of in here. */
  min-width: 0;
  max-width: 100%;
  margin: var(--bd-space-3) 0;
}

.bd-table {
  border-collapse: collapse;
  /* Not width:100% — a narrow viewport must be allowed to scroll the table
     rather than crush its columns into unreadable slivers. */
  min-width: 100%;
  font-family: var(--bd-font-body);
  font-size: var(--bd-type-body-sm-size);
  line-height: var(--bd-type-body-sm-line);
  color: var(--bd-color-on-surface);
}

.bd-table th,
.bd-table td {
  text-align: left;
  padding: var(--bd-space-2) var(--bd-space-3);
  border-bottom: 1px solid var(--bd-color-border);
  vertical-align: top;
}

.bd-table th {
  font-weight: 600;
  white-space: nowrap;
}

/* ------------------------------ Footer -------------------------------- */

.bd-footer {
  margin-top: var(--bd-space-8);
  padding: var(--bd-space-5) 0 var(--bd-space-8);
  border-top: 1px solid var(--bd-color-border);
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-3);
  min-width: 0;
}

/* One row for both groups: the About link and the legal <nav> beside it, with the same
   gap between them as between the legal links themselves, so the row reads as one list
   even though it is two elements (see AppFooter.razor on why About is not inside the
   nav). Wraps to as many lines as a narrow viewport needs. */
.bd-footer__links {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--bd-space-2) var(--bd-space-4);
  min-width: 0;
}

.bd-footer__nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--bd-space-2) var(--bd-space-4);
}

.bd-footer__link {
  font-family: var(--bd-font-body);
  font-size: var(--bd-type-body-sm-size);
  line-height: var(--bd-type-body-sm-line);
  /* The Impressum link stays an obvious, ordinary link — see AppFooter.razor.
     No hover-to-reveal, no icon-only treatment. */
  color: var(--bd-color-on-surface);
}

/* SupportLink, footer treatment (issue #485, corrected #506). It is deliberately the SAME link
   styling as its neighbours — .bd-footer__link carries the colour and the type — because § 18
   Abs. 1 MStV wants the Impressum "leicht erkennbar", and a button in that row would compete with
   it for the eye.

   THIS USED TO SAY "it needs inline-flex to sit the icon on the text baseline; the row above
   aligns on `baseline`, which an inline-flex box resolves from its last line box, so the label
   still lines up with the plain links beside it." That is backwards, and issue #506 is the
   measured proof: on production the coffee link's own text sat 3px above its neighbours'. A flex
   item is BLOCKIFIED (CSS Display §2.7) — `display: inline-flex` on a child of `.bd-footer__links`
   (itself `display: flex`) never reaches the browser as inline-flex at all; the computed value is
   `flex`. And a flex container's own first/last baseline is SYNTHESISED from its first in-flow
   flex item — here the icon `<svg>`, which has no text baseline of its own — never read off its
   text, so `align-items: baseline` on the row above was aligning a synthesised icon-derived
   baseline against its neighbours' genuine text baselines. Two different definitions of the word.

   THE FIX TAKES THE ICON OUT OF THE LINE BOX ENTIRELY, rather than fighting that with a flex or
   vertical-align trick — deliberately, because any such trick still depends on the loaded font's
   own ascent/descent metrics to land the same everywhere. This anchor stays a plain, unstyled
   inline element (no `display` at all): an inline anchor's baseline IS the text baseline of its
   own content, identical to the plain links beside it by construction, so there is no baseline
   arithmetic left to get wrong. The icon is `position: absolute` against the anchor's own
   `position: relative`, vertically centred on the anchor's line-box height (`top: 50%` +
   `translateY(-50%)`, both resolved from the box model, not from font metrics), with `padding-left`
   on the anchor reserving exactly its width plus the gap that used to come from `gap`. Nothing
   here reads a font's ascent, descent or line-gap, so no amount of font-metric difference between
   two renderers can move it — see the button treatment's own note below for why it is untouched. */
.bd-support-link {
  position: relative;
}

.bd-support-link:not(.bd-support-link--button) {
  /* Reserves the icon's own width (see .bd-support-link__icon below) plus the gap that used to
     come from `gap` on the flex box this no longer is. */
  padding-left: calc(1.15em + var(--bd-space-2));
}

/* currentColor throughout, so the glyph follows whatever colour the link or button already
   resolved in this palette — that is what lets it work in all four without a token of its own,
   and it is why nothing here names a hex. */
.bd-support-link__icon {
  width: 1.15em;
  height: 1.15em;
  flex: none;
}

/* Taken out of flow and centred on the anchor's own line-box height — see this file's remarks on
   .bd-support-link above for why nothing here may depend on font metrics. Scoped away from the
   button treatment, which keeps its icon as an ordinary in-flow flex child of .bd-btn (untouched:
   .bd-btn already declares its own `display: inline-flex; align-items: center; gap: …`, and
   .bd-btn is never itself a flex item of a `baseline`-aligned row, so it was never exposed to the
   bug this file is otherwise correcting). */
.bd-support-link:not(.bd-support-link--button) .bd-support-link__icon {
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
}

/* The button treatment (/about) sizes its glyph off .bd-btn__icon-svg like every other button
   icon; the em-relative rule above would otherwise shrink it against the button's smaller type. */
.bd-support-link--button .bd-support-link__icon {
  width: 20px;
  height: 20px;
}

/* /about's support section: the prose makes the case, the button follows it. align-items:start
   keeps the pill its own width rather than stretching it across the column. */
.bd-support-section {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--bd-space-3);
  min-width: 0;
}

.bd-footer__disclaimer {
  font-family: var(--bd-font-body);
  font-size: var(--bd-type-caption-size);
  line-height: var(--bd-type-caption-line);
  color: var(--bd-color-muted);
  max-width: 68ch;
}

/* The running build's own version (AppFooter.razor). Tabular figures so the digits do not
   visibly shift width between builds; muted, like the disclaimer beside it, because this is
   metadata about the deploy rather than something a visitor is meant to read on purpose. */
.bd-footer__version {
  font-family: var(--bd-font-body);
  font-size: var(--bd-type-caption-size);
  line-height: var(--bd-type-caption-line);
  color: var(--bd-color-muted);
  font-variant-numeric: tabular-nums;
}

/* =========================================================================
   ThemeTile + ThemeGrid (design-system.md §2.8) — the browse-by-theme
   surface. A theme is a card whose top is a studded brick in that theme's
   accent hue (§2.6, the same hue ThemeChip gives it), with the name and the
   counts on the ordinary card surface below.

   THE BRICK IS DECORATION AND THE TEXT IS THE INFORMATION, which is what
   lets the tile be as bright as it is. Nothing is written on the accent
   fill, so the pair never has to clear 4.5:1 and no new row is needed in
   §2.1's table — the same reasoning BrickRow's own block sets out, and the
   reason the accent family could not be used this way for a label (§2.6's
   closing paragraph: on a light ground a fill cannot be both bright and
   carry small text). The tile is a link, so its focus ring and its label
   come from the anchor, not from the colour.

   A denser grid than .bd-card-grid: there are 150 root themes, and a theme
   tile has no image to give room to.
   ========================================================================= */

.bd-tile-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: var(--bd-space-4);
}

.bd-theme-tile {
  display: flex;
  flex-direction: column;
  border-radius: var(--bd-radius-lg);
  background: var(--bd-color-card-surface);
  box-shadow: var(--bd-shadow-1);
  overflow: hidden;
  text-decoration: none;
  color: inherit;
}

/* The studded brick. Two studs rather than BrickRow's one, because this one
   is wide: the silhouette has to read as a brick at a glance rather than as
   a coloured bar with a bump. They are drawn with box-shadow on a single
   pseudo-element so the tile needs no extra markup — a screen reader meets
   the name and the counts and nothing else. */
.bd-theme-tile__brick {
  position: relative;
  display: block;
  height: 28px;
  margin: var(--bd-space-3) var(--bd-space-3) var(--bd-space-2);
  border-radius: var(--bd-radius-sm);
  background: var(--bd-color-border-strong);
}

.bd-theme-tile__brick::before {
  content: "";
  position: absolute;
  top: -5px;
  left: 22%;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: inherit;
  box-shadow: 34px 0 0 0 currentcolor;
}

.bd-theme-tile__body {
  display: flex;
  flex-direction: column;
  gap: var(--bd-space-1);
  padding: 0 var(--bd-space-4) var(--bd-space-4);
}

/* `color` as well as `background`, so the second stud's box-shadow above
   (which reads currentcolor) tracks the hue without six more rules. The
   inherited text colour is restored on the body, which is the only part
   that carries any. */
.bd-theme-tile--red .bd-theme-tile__brick { background: var(--bd-accent-red); color: var(--bd-accent-red); }
.bd-theme-tile--orange .bd-theme-tile__brick { background: var(--bd-accent-orange); color: var(--bd-accent-orange); }
.bd-theme-tile--yellow .bd-theme-tile__brick { background: var(--bd-accent-yellow); color: var(--bd-accent-yellow); }
.bd-theme-tile--green .bd-theme-tile__brick { background: var(--bd-accent-green); color: var(--bd-accent-green); }
.bd-theme-tile--blue .bd-theme-tile__brick { background: var(--bd-accent-blue); color: var(--bd-accent-blue); }
.bd-theme-tile--purple .bd-theme-tile__brick { background: var(--bd-accent-purple); color: var(--bd-accent-purple); }

/* =========================================================================
   Breadcrumb — where a sub-theme sits in the tree.

   An ordered list inside a <nav aria-label>, because the order is the
   information: "Star Wars › Ultimate Collector Series" read out of order is
   a different claim about the catalogue. The separator is a CSS ::before on
   every item but the first, so it is never in the accessible name.
   ========================================================================= */

.bd-breadcrumb {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--bd-space-1) var(--bd-space-2);
  margin: 0;
  padding: 0;
  list-style: none;
  font-family: var(--bd-font-body);
  font-size: var(--bd-type-body-sm-size);
  line-height: var(--bd-type-body-sm-line);
}

.bd-breadcrumb__item {
  display: flex;
  align-items: center;
  gap: var(--bd-space-2);
  color: var(--bd-color-muted);
}

.bd-breadcrumb__item + .bd-breadcrumb__item::before {
  content: "\203A";
  color: var(--bd-color-muted);
}

/* ---------------------------------------------------------------------------
   ScannerOverlay + /scan (design-system.md §5, issue #111)

   THE RETICLE'S SIZE IS NOT DECORATION. .bd-scanner__reticle is sized as a
   percentage of a SQUARE stage, because the crop handed to the decoder is a
   square of the same fraction of the camera frame's shorter side
   (BrickDb.Scanning.Reticle). Sizing it off a non-square box, or off the width
   alone, would draw a box that is not the box being read - and outside the
   decoder's centre band a plainly visible code is found roughly one time in
   ten, so the symptom is "the camera is broken" rather than "the box is wrong".
   Change these two rules only alongside Reticle.DefaultSideFraction.
   --------------------------------------------------------------------------- */

.bd-scanner {
	display: flex;
	flex-direction: column;
	gap: var(--bd-space-3);
	margin-block-end: var(--bd-space-4);
}

.bd-scanner__stage {
	position: relative;
	width: 100%;
	/* Square, so a percentage side on the reticle is the same fraction in both
	   axes - which is what makes the drawn box match the cropped square. */
	aspect-ratio: 1 / 1;
	overflow: hidden;
	border-radius: var(--bd-radius-lg);
	background: var(--bd-color-section-surface);
}

.bd-scanner__stage--placeholder {
	display: grid;
	place-items: center;
}

.bd-scanner__placeholder-text {
	/* Above the preview, which is position:absolute inside the same stage. Without the
	   stacking context the text sits behind the video element and the user sees a black
	   square while the permission prompt is open. */
	position: relative;
	z-index: 1;
	color: var(--bd-color-muted);
	font-size: var(--bd-type-body-sm-size);
}

.bd-scanner__video {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	/* Cover, matching the square crop: the preview is cropped to the stage the
	   same way the frame is cropped to the reticle, so what is visible and what
	   is readable stay the same picture. */
	object-fit: cover;
}

.bd-scanner__reticle {
	position: absolute;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	border-radius: var(--bd-radius-md);
	/* A hole punched in a dim surround, so the reticle reads as "this part is
	   being looked at" rather than as a sticker on top of the picture. */
	box-shadow: 0 0 0 100vmax rgb(0 0 0 / 45%);
	pointer-events: none;
}

/* The one mode switch (#471). The flex/wrap/gap comes from .bd-toggle-chip-group,
   which the element also carries - this only adds the spacing that binds the switch
   to the instruction sentence beneath it rather than to the intro above it.

   The pills are <a> elements, not buttons: /scan is [AllowAnonymous] and its first
   response has no circuit, so a click handler here would be dead chrome for exactly
   the visitor the public surface exists for (Scan.razor, ScanModes, PagerNav). That
   is also why the selected state reads aria-current="page" rather than the
   aria-pressed .bd-toggle-chip's own rule keys off - a link goes somewhere, it does
   not toggle - and the fill/text pair is AppSidebar's active-row pair rather than a
   fourth "selected" language invented here, so TokenContrastTests already covers it. */
.bd-scan__modes {
	margin-block-start: var(--bd-space-4);
	margin-block-end: var(--bd-space-2);
}

.bd-scan__mode {
	text-decoration: none;
}

.bd-scan__mode[aria-current="page"] {
	background: var(--bd-color-primary);
	border-color: var(--bd-color-primary);
	color: var(--bd-color-on-primary);
	font-weight: 600;
}

.bd-scan__mode-instruction {
	margin-block-start: 0;
	margin-block-end: var(--bd-space-4);
	color: var(--bd-color-muted);
}

/* LiveScanStageSizingTests */
.bd-scanner__stage--live {
	aspect-ratio: 16 / 9;
	overflow: clip;
}

.bd-scanner__stage--live .bd-scanner__video {
	inset: 0 auto auto;
	height: auto;
}

.bd-live-scan__markers {
	position: relative;
	pointer-events: none;
}

.bd-live-scan__canvas {
	position: relative;
	aspect-ratio: inherit;
}

.bd-live-scan__marker {
	position: absolute;
	margin: -1%;
	padding: 1%;
	border: 2px solid var(--bd-color-primary);
	border-radius: var(--bd-radius-sm);
	box-sizing: content-box;
}

.bd-live-scan__marker--unknown {
	border-color: var(--bd-color-muted);
	border-style: dashed;
}

.bd-live-scan__label-anchor {
	display: contents;
}

.bd-live-scan__label {
	position: relative;
	width: 100%;
	padding: var(--bd-space-1) var(--bd-space-2);
	border-radius: var(--bd-radius-sm);
	background: var(--bd-color-surface-overlay);
	overflow-wrap: anywhere;
	text-indent: calc(2rem + var(--bd-space-2));
}

.bd-live-scan__marker-index,
.bd-live-scan-results__index {
	display: grid;
	place-items: center;
	border-radius: 50%;
	background: var(--bd-color-primary);
	color: var(--bd-color-on-primary);
	font-weight: 600;
}

.bd-live-scan__marker-index {
	position: absolute;
	inset-block-start: var(--bd-space-2);
	inset-inline-start: var(--bd-space-2);
	width: 2rem;
	height: 2rem;
}

.bd-live-scan__label-text--unknown {
	color: var(--bd-color-muted);
}

.bd-live-scan__thumb {
	display: none;
}

.bd-live-scan-results__list {
	padding: 0;
}

.bd-live-scan-results__box {
	padding: var(--bd-space-6);
	border: 1px solid var(--bd-color-border);
	border-radius: var(--bd-radius-lg);
	background: var(--bd-color-card-surface);
	box-shadow: var(--bd-shadow-1);
}

.bd-live-scan-results__index {
	flex: none;
	width: 4rem;
	height: 4rem;
}

.bd-live-scan-results__body {
	min-width: 0;
	padding-block: var(--bd-space-7);
}

.bd-live-scan-results__body > .bd-scan-result {
	display: contents;
}

.bd-live-scan-results__body .bd-scan-result__message {
	font-size: var(--bd-type-display-size);
	line-height: var(--bd-type-display-line);
	overflow-wrap: anywhere;
}

.bd-live-scan-results__body .bd-btn {
	min-height: var(--bd-space-10);
	padding-block: var(--bd-space-1);
	white-space: normal;
}

@media (min-width: 600px) {
	.bd-live-scan__labels {
		display: contents;
	}

	.bd-live-scan__label-anchor {
		position: absolute;
		display: block;
	}

	.bd-live-scan__label {
		position: absolute;
		inset: auto;
		display: flex;
		align-items: center;
		gap: var(--bd-space-1);
		width: auto;
		max-width: 40vw;
		font-size: var(--bd-type-body-sm-size);
		line-height: 1.2;
		text-indent: 0;
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
	}

	.bd-live-scan__label.bd-live-scan__marker--inline-start {
		inset-inline-start: 0;
	}

	.bd-live-scan__label.bd-live-scan__marker--inline-end {
		inset-inline-end: 0;
	}

	.bd-live-scan__label.bd-live-scan__marker--block-start {
		inset-block-start: var(--bd-space-1);
	}

	.bd-live-scan__label.bd-live-scan__marker--block-end {
		inset-block-end: var(--bd-space-1);
	}

	.bd-live-scan__marker-index {
		position: static;
		width: 1.25rem;
		height: 1.25rem;
		font-size: var(--bd-type-caption-size);
	}

	.bd-live-scan__thumb {
		display: block;
		flex: none;
		width: 28px;
		height: 28px;
		overflow: hidden;
		border-radius: var(--bd-radius-sm);
	}

	.bd-live-scan-results__box {
		flex-direction: row;
		gap: var(--bd-space-2);
		padding: 0;
		border: 0;
		border-radius: 0;
		background: transparent;
		box-shadow: none;
	}

	.bd-live-scan-results__index {
		width: 1.75rem;
		height: 1.75rem;
		font-size: var(--bd-type-body-sm-size);
	}

	.bd-live-scan-results__body {
		display: block;
		padding-block: 0;
	}

	.bd-live-scan-results__body > .bd-scan-result {
		display: flex;
	}

	.bd-live-scan-results__body .bd-scan-result__message {
		font-size: var(--bd-type-body-size);
		line-height: normal;
	}

	.bd-live-scan-results__body .bd-btn {
		min-height: 44px;
		padding-block: 0;
		white-space: nowrap;
	}
}

.bd-scanner__corner {
	position: absolute;
	width: 18px;
	height: 18px;
	border: 3px solid var(--bd-color-primary);
}

.bd-scanner__corner--tl {
	top: -2px;
	left: -2px;
	border-right: none;
	border-bottom: none;
	border-top-left-radius: var(--bd-radius-md);
}

.bd-scanner__corner--tr {
	top: -2px;
	right: -2px;
	border-bottom: none;
	border-left: none;
	border-top-right-radius: var(--bd-radius-md);
}

.bd-scanner__corner--bl {
	bottom: -2px;
	left: -2px;
	border-top: none;
	border-right: none;
	border-bottom-left-radius: var(--bd-radius-md);
}

.bd-scanner__corner--br {
	bottom: -2px;
	right: -2px;
	border-top: none;
	border-left: none;
	border-bottom-right-radius: var(--bd-radius-md);
}

.bd-scanner__hint {
	margin: 0;
	color: var(--bd-color-on-surface);
	font-size: var(--bd-type-body-sm-size);
}

.bd-scanner__hint--muted {
	color: var(--bd-color-muted);
}

.bd-scanner__controls,
.bd-scan__actions {
	display: flex;
	flex-wrap: wrap;
	gap: var(--bd-space-2);
}

.bd-scan__result,
.bd-scan__manual {
	display: flex;
	flex-direction: column;
	gap: var(--bd-space-3);
	margin-block-end: var(--bd-space-5);
}

/* The mass-scan stage. It has never had a rule of its own - the section was a bare
   <section> whose only child, before #471, was a toggle button - and now that it is one
   of the three stages the mode switch chooses between, it needs the same column rhythm
   the camera stage has, and the same distance from the manual-entry card below it. */
.bd-scan__mass {
	display: flex;
	flex-direction: column;
	gap: var(--bd-space-3);
	margin-block-end: var(--bd-space-5);
}

/* Manual entry is its own block, and the boundary is structural rather than cosmetic
   (#471): whatever mode's stage sits above it, the card is what stops that stage's
   last control reading as the first control of this form - which is exactly how
   "Scan several boxes at once" came to look like part of manual entry. Spacing alone
   is undone by the next thing anybody puts above it. */
.bd-scan__manual--card {
	padding: var(--bd-space-4);
	border: 1px solid var(--bd-color-border);
	border-radius: var(--bd-radius-lg);
	background: var(--bd-color-section-surface);
}

.bd-scan__searching {
	margin: 0;
	color: var(--bd-color-muted);
	font-size: var(--bd-type-body-sm-size);
}

.bd-scan__thanks {
	margin: 0;
	color: var(--bd-color-success);
	font-size: var(--bd-type-body-sm-size);
}

.bd-scan__set-actions {
	display: flex;
	flex-direction: column;
	gap: var(--bd-space-2);
	align-items: flex-start;
}

.bd-scan__owned-count {
	margin: 0;
	color: var(--bd-color-muted);
	font-size: var(--bd-type-body-sm-size);
}

.bd-scan__contribute,
.bd-scan__manual-form {
	display: flex;
	flex-direction: column;
	gap: var(--bd-space-3);
}

.bd-scan__contribute-heading {
	margin: 0;
	font-size: var(--bd-type-title-size);
	font-weight: 600;
}

.bd-scan-result {
	display: flex;
	flex-direction: column;
	gap: var(--bd-space-2);
	padding: var(--bd-space-4);
	border-radius: var(--bd-radius-lg);
	background: var(--bd-color-card-surface);
	box-shadow: var(--bd-shadow-1);
}

.bd-scan-result__lede {
	margin: 0;
	color: var(--bd-color-muted);
	font-size: var(--bd-type-body-sm-size);
}

.bd-scan-result__message {
	margin: 0;
	font-size: var(--bd-type-body-size);
}

.bd-scan-result__figure {
	display: flex;
	gap: var(--bd-space-3);
	align-items: flex-start;
}

/* The frame CatalogImage sits in on a scan result (#264). A positioning context and a
   fixed size, the same job .bd-catalog-thumb does on the add-to-collection header - the
   component itself is absolutely positioned over its own empty state, so it needs a box
   to be absolute within, and a box that is sized before the bytes arrive is what stops
   the name and the confidence jumping when they do. Square rather than 5:3: a minifigure
   render is taller than it is wide. */
.bd-scan-result__media {
	position: relative;
	flex: 0 0 auto;
	width: 96px;
	height: 96px;
	overflow: hidden;
	border-radius: var(--bd-radius-md);
	background: var(--bd-color-section-surface);
}

.bd-scan-result__figure-body {
	display: flex;
	flex-direction: column;
	gap: var(--bd-space-1);
	min-width: 0;
}

/* The confidence and the headcount are one block on purpose - see
   ScanResultView's header. A percentage without its sample size is a claim the
   Wilson score was chosen specifically to avoid making. */
.bd-scan-result__belief {
	display: flex;
	flex-wrap: wrap;
	gap: var(--bd-space-2);
	align-items: baseline;
}

.bd-scan-result__confidence {
	font-variant-numeric: tabular-nums;
	font-weight: 600;
}

.bd-scan-result__confirmations,
.bd-scan-result__source,
.bd-scan-result__status {
	margin: 0;
	color: var(--bd-color-muted);
	font-size: var(--bd-type-caption-size);
}

.bd-scan-result__other-list {
	display: flex;
	flex-direction: column;
	gap: var(--bd-space-2);
	margin: 0;
	padding: 0;
	list-style: none;
}

.bd-scan-result__other {
	display: flex;
	flex-direction: column;
	gap: var(--bd-space-1);
}

.bd-scan-result__code {
	display: flex;
	flex-wrap: wrap;
	gap: var(--bd-space-2);
	margin: 0;
	font-size: var(--bd-type-caption-size);
}

.bd-scan-result__code-label {
	color: var(--bd-color-muted);
}

.bd-scan-result__code-value {
	overflow-wrap: anywhere;
	/* A literal stack rather than a token: this is the only monospace in the
	   design system, and adding a token for one use would put a colour-adjacent
	   decision in tokens.css that nothing else reads. A scanned payload is a
	   string of digits somebody may read back aloud, so the fixed pitch earns
	   its place. */
	font-family: ui-monospace, "SFMono-Regular", menlo, consolas, monospace;
}

/* =========================================================================
   Photo reorder controls (issue #71) — up/down move buttons on each strip
   tile.
   ========================================================================= */

.bd-part-tile__actions {
	display: flex;
	flex: none;
	gap: var(--bd-space-1);
}

/* =========================================================================
   PhotoLightbox (§5, "PhotoGallery / Lightbox") — issue #71. Full-screen,
   --bd-z-modal, always the original photo. No separate .bd-scrim backdrop
   the way FilterSheet/ConfirmSheet use one: this component covers the whole
   viewport itself, so a same-sized scrim underneath it would never receive
   a pointer event to dismiss on — see PhotoLightbox.razor's own remarks.

   The backdrop opposite a user's own photo is a raw rgb() rather than a
   --bd-color-* token, the same choice .bd-scanner__reticle makes for the
   same reason: this is chrome floating over content whose luminance is
   unknown ahead of time (§2.4.3's "Over unknown content" row names this
   component by name), not a themed app surface, so it does not participate
   in the light/dark palette at all — both themes get the same near-black
   stage. Every icon control instead sits on its own --bd-scrim-photo-icon
   chip and the caption on the --bd-scrim-photo-text gradient, exactly as
   §2.4.4 specifies, and both of those floors are already theme-independent
   by design.
   ========================================================================= */

.bd-lightbox {
	position: fixed;
	inset: 0;
	z-index: var(--bd-z-modal);
	display: flex;
	align-items: center;
	justify-content: center;
	background: rgb(0 0 0 / 92%);
	outline: none;
}

.bd-lightbox__image,
.bd-photo-capture__preview {
	max-width: 100%;
	max-height: 100%;
	/* Contain, never cover - the whole point is the uncropped original. */
	object-fit: contain;
}

.bd-lightbox__icon-btn {
	position: absolute;
	width: 44px;
	height: 44px;
	min-width: 44px;
	display: flex;
	align-items: center;
	justify-content: center;
	border: none;
	border-radius: var(--bd-radius-pill);
	/* §2.4.4's icon-only floor for chrome over unknown content - 75% black,
	   solved for a 3:1 contrast against the worst case (pure white). */
	background: var(--bd-scrim-photo-icon);
	color: var(--bd-color-on-primary);
	cursor: pointer;
}

.bd-lightbox__icon-btn:disabled {
	opacity: 0.5;
	pointer-events: none;
}

.bd-lightbox__top-bar {
	position: absolute;
	top: calc(var(--bd-space-3) + var(--bd-safe-area-top));
	left: var(--bd-space-3);
	right: var(--bd-space-3);
	display: flex;
	align-items: center;
	justify-content: flex-end;
	gap: var(--bd-space-3);
}

.bd-lightbox__counter {
	margin-right: auto;
	padding: 2px var(--bd-space-2);
	border-radius: var(--bd-radius-pill);
	background: var(--bd-scrim-photo-icon);
	color: var(--bd-color-on-primary);
	font-size: var(--bd-type-caption-size);
	font-variant-numeric: tabular-nums;
}

.bd-lightbox__nav--prev {
	left: var(--bd-space-3);
}

.bd-lightbox__nav--next {
	right: var(--bd-space-3);
}

.bd-lightbox__nav {
	top: 50%;
	transform: translateY(-50%);
}

.bd-lightbox__caption-bar {
	position: absolute;
	left: 0;
	right: 0;
	bottom: 0;
	display: flex;
	padding: var(--bd-scrim-photo-fade) var(--bd-space-5)
	         calc(var(--bd-space-4) + var(--bd-safe-area-bottom));
	/* The fade distance is the gradient's own extent (§2.4.4): the scrim is
	   fully opaque at the caption's baseline and fades to transparent
	   --bd-scrim-photo-fade above it, so most of the frame still reads as
	   the photo. */
	background: linear-gradient(to top, var(--bd-scrim-photo-text), transparent);
}

.bd-lightbox__caption {
	color: var(--bd-color-on-primary);
	font-size: var(--bd-type-body-sm-size);
}

/* =========================================================================
   Danger zone (#286) — the panel around an irreversible, self-service
   destructive action. One instance today: account erasure on /account.

   A BORDER AND NOTHING ELSE, deliberately. The obvious version fills the
   panel with --bd-color-danger-surface, which would put body text on a
   colour whose pairing is not in design-system.md §2.1's table — and that
   table is what TokenContrastTests computes AA against, in all four
   resolvable palettes. Colouring only the edge marks the region without
   inventing a text/background pair nobody has verified.

   The button inside stays .bd-btn--danger-text, per §5: a destructive
   action never wears the primary affordance. Here the button only opens
   ConfirmSheet, and it is the sheet that holds the same rule for the
   action itself.
   ========================================================================= */

.bd-danger-zone {
	padding: var(--bd-space-4);
	border: 1px solid var(--bd-color-danger);
	border-radius: var(--bd-radius-lg);
}

/* =========================================================================
   Avatars (#435) — the provided set (BrickAvatar) and an uploaded
   photograph (AccountAvatar), drawn at the same three sizes so the two
   are interchangeable wherever a person is shown.

   NO --bd-* TOKEN OF ITS OWN. The sizes are literals here rather than
   custom properties because TokenSweepTests derives the legal token set
   from tokens.css and app.css alone, and an avatar size is not a palette
   decision — inventing a token for it would put a value nobody theme-
   checks into the same namespace as the ones everybody does.

   THE UPLOADED IMAGE IS CLIPPED TO THE SAME DISC as the provided set is
   drawn on, and object-fit: cover is what stops a portrait photograph
   being squashed into a square rather than cropped to one. Without it a
   9:16 phone photograph renders as a face stretched sideways, which no
   test can see and every person can.
   ========================================================================= */

.bd-avatar {
	display: inline-block;
	flex: none;
	line-height: 0;
}

.bd-avatar--sm {
	width: 32px;
	height: 32px;
}

.bd-avatar--md {
	width: 48px;
	height: 48px;
}

.bd-avatar--lg {
	width: 96px;
	height: 96px;
}

.bd-avatar > svg,
.bd-avatar > img {
	display: block;
	width: 100%;
	height: 100%;
}

.bd-avatar > img {
	border-radius: 50%;
	object-fit: cover;
	background: var(--bd-color-section-surface);
}

/* The controls beside the avatar on /account. A column that does NOT
   stretch its children: the upload control is a <label> wrapping a
   hidden <input type="file">, and a label is block-level, so inside a
   plain flex column it grows to the full width of the section and the
   page reads as one enormous button. Found by looking at the rendered
   page rather than by a measurement - a DOM assertion about the button
   existing passes either way.

   cursor: pointer because the label IS the control here; without it the
   one affordance that says "this is clickable" is missing on the only
   button on the page that is not a <button>. */
.bd-avatar-controls {
	display: flex;
	flex-direction: column;
	align-items: flex-start;
	gap: var(--bd-space-2);
}

.bd-avatar-controls label {
	cursor: pointer;
}

/* ================= Sparkline =================
   A small inline trend line, not a chart library (design-system.md names
   none, and a sparkline does not need one). Sized to read at a glance
   rather than to be inspected.

   GLOBAL, NOT SCOPED, and that is the whole point of the rule sitting
   here. It lived in MarketPricesSection.razor.css until #475 - a scoped
   sheet, so every declaration carried that one component's own
   [b-xxxxxxxxxx] attribute. SetPricingSection renders the identical
   <svg class="bd-sparkline"><polyline> markup on the public
   /sets/{SetNumber} page and matched none of it: no stroke, no width, no
   height. The symptom was not a missing chart, which somebody would have
   reported - it was a tall blank void in the middle of the pricing
   stack, which reads as spacing. Guard:
   PricePanelLayoutTests. */
.bd-sparkline {
	display: block;
	width: 100%;
	height: 40px;
}

.bd-sparkline polyline {
  stroke: var(--bd-color-primary);
	stroke-width: 2;
	stroke-linejoin: round;
	stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}

.bd-toast-region {
  position: fixed;
  right: var(--bd-space-4);
  bottom: calc(var(--bd-space-4) + var(--bd-safe-area-bottom));
  left: var(--bd-space-4);
  z-index: var(--bd-z-toast);
  pointer-events: none;
}

.bd-toast {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  align-items: center;
  gap: var(--bd-space-2);
  max-width: 36rem;
  margin-inline: auto;
  padding: var(--bd-space-3);
  border: 1px solid var(--bd-color-border-strong);
  border-radius: var(--bd-radius-lg);
  background: var(--bd-color-surface-overlay);
  box-shadow: var(--bd-shadow-3);
  pointer-events: auto;
}

.bd-toast__severity {
  font-size: var(--bd-type-caption-size);
  font-weight: 700;
}

.bd-toast--success .bd-toast__severity { color: var(--bd-color-success); }
.bd-toast--warning .bd-toast__severity { color: var(--bd-color-warning); }
.bd-toast--error .bd-toast__severity { color: var(--bd-color-danger); }

/* ================= Set-detail pricing panel =================
   Issue #475. One card around everything the public /sets/{SetNumber}
   page says about money, so the page has a boundary between "facts about
   this set" and "what it has sold for". Before this the pricing content
   was eight sibling blocks in the page's own stack at one visual weight
   - two headline facts, a trend, computed statistics and raw evidence,
   all reading as one undifferentiated list.

   Only pricing gets a card. Boxing the identity block above it too would
   fight the page's deliberate simplicity: a page where everything is in
   a box is a page where nothing is grouped. */
.bd-price-panel {
	display: flex;
	flex-direction: column;
	gap: var(--bd-space-4);
	padding: var(--bd-space-4);
	border: 1px solid var(--bd-color-border);
	border-radius: var(--bd-radius-lg);
	background: var(--bd-color-section-surface);
}

/* Tier 1 - the two numbers a visitor scans for first, side by side once
   there is room and stacked when there is not.

   auto-fit + minmax, never a fixed count of plain 1fr columns, for two
   independent reasons. A bare 1fr track floors at its content's
   intrinsic width, so one long unbroken money string would widen the
   grid past the viewport - the exact shape
   NoHorizontalScrollSourceGuardTests sweeps these stylesheets for, and
   which it will not let this comment even spell out. And with auto-fit
   the column count stops being written down at
   all, which is what reserves the slot for RRP (#472, not built): the
   day a third figure arrives it is one more child element, not a
   re-layout of a shipped page.

   min(100%, 15rem) rather than a bare 15rem so the track's own minimum
   can never exceed the container it sits in - at 390px the panel's
   content box is narrower than 15rem once the shell and the panel's own
   padding are taken off, and a floor wider than the box is an overflow
   whatever the column count says. */
.bd-price-panel__headline {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
	gap: var(--bd-space-4);
}

/* One figure: a quiet label over a promoted value. min-width: 0 because
   each figure is a grid item whose value is a long unbroken-ish string;
   without it the item cannot shrink below that string and the auto-fit
   arithmetic above is undone by its own contents. */
.bd-price-figure {
	display: flex;
	flex-direction: column;
	gap: 2px;
	min-width: 0;
}

/* The value line wraps rather than overflowing - it is one localised
   string ("480.00 EUR - Near mint - sold 15/08/2026") that must not be
   split into parts, so the only place it can give is at its own spaces. */
.bd-price-figure__value {
	overflow-wrap: anywhere;
}

/* ============== The account screen (#436) ==============
   Sections wear --bd-color-section-surface to group them; .bd-danger-zone
   deliberately keeps the page background, because grouped-with-the-rest-
   but-in-red reads as one more setting. No new colour pair below: every
   combination already exists in this file, so design-system.md §2.1's
   table needs no new row, and a chosen avatar is marked on its EDGE — the
   technique .bd-danger-zone uses.

   The identity strip reuses .bd-cluster and the avatar picker reuses
   .bd-toggle-chip-group rather than declaring their own flex rules. */

.bd-account-section {
    padding: var(--bd-space-4);
    border-radius: var(--bd-radius-lg);
    background: var(--bd-color-section-surface);
}

/* min-width: 0 is the shell's own rule applied here — without it a long
   e-mail address refuses to shrink in a flex row and pushes the page
   sideways at 360px. */
[data-account-identity] > .bd-stack {
    min-width: 0;
    overflow-wrap: anywhere;
}

/* A labelled row with an optional trailing control. Column first, row when
   there is width: the base is the narrow one and the media query below
   widens it, because a media query adds no specificity and a base rule
   written after one would win at every width
   (StylesheetCascadeOrderTests). */
.bd-account-row {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--bd-space-3);
}

.bd-account-row > .bd-stack {
    min-width: 0;
}

/* "Coming soon". Deliberately not a disabled button: something that looks
   operable and is not invites the tap that proves it. */
.bd-account-row__note {
    flex: none;
    align-self: flex-start;
    padding: var(--bd-space-1) var(--bd-space-3);
    border-radius: var(--bd-radius-pill);
    border: 1px solid var(--bd-color-border);
    font-family: var(--bd-font-body);
    font-size: var(--bd-type-caption-size);
    line-height: var(--bd-type-caption-line);
    color: var(--bd-color-muted);
}

/* One button wrapping one BrickAvatar, transparent so the drawing is the
   whole of what is seen. */
.bd-avatar-tile {
    display: inline-flex;
    padding: var(--bd-space-1);
    border-radius: var(--bd-radius-lg);
    border: 2px solid transparent;
    background: transparent;
    cursor: pointer;
}

.bd-avatar-tile[aria-pressed="true"] {
    border-color: var(--bd-color-primary);
    background: var(--bd-color-surface-raised);
}

.bd-avatar-tile:disabled {
    cursor: default;
}

@media (min-width: 600px) {
    .bd-account-row {
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
        gap: var(--bd-space-4);
    }

    .bd-account-row__note {
        align-self: center;
    }
}


/* Advertising label on an affiliate price link (SourceLink.razor, which carries
   the reasoning). Built only from tokens that already have a documented
   contrast pair, so it adds no row to design-system.md's table. */

.bd-ad-label {
  display: inline-flex;
  align-items: center;
  flex: none;
  padding: 0 var(--bd-space-2);
  border: 1px solid var(--bd-color-border);
  border-radius: var(--bd-radius-sm);
  color: var(--bd-color-on-surface);
  font-family: var(--bd-font-body);
  font-weight: 600;
  font-size: var(--bd-type-label-size);
  line-height: var(--bd-type-label-line);
  letter-spacing: var(--bd-type-label-tracking);
  text-transform: uppercase;
  white-space: nowrap;
}

.bd-source-link {
  flex: none;
}
