:root {
  --ink: #1c2321;
  --ink-soft: #4a5049;
  --paper: #f7f4ec;
  --paper-raised: #fffdf8;
  --line: #d6d0bd;
  --line-strong: #b7ae94;
  --building-all: #5b6570;
  --building-unmatched: #c81e1e;
  /* buildings-all-outline only. Not redefined for dark mode -- like the
     ratio ramp above, this paints onto the OSM raster basemap, which is
     always light, so a literal black reads the same in both UI themes. */
  --building-outline-all: #000000;
  --address-all: #6f5a9e;
  --address-unmatched: #1a56db;
  --accent: #17324d;
  --accent-soft: #23496b;
  --danger: #a8402a;
  --ok: #1f7a45;
  /* Sequential ramp for the z5-10 aggregate grid (agg-grid-fill in its
     "Liczba" colour mode): low-density gold through to high-density wine,
     read by app.js via getComputedStyle since MapLibre paint properties
     don't resolve CSS var() -- see the "MapLibre paint properties" gotcha in
     CLAUDE.md. points-dots (z11-13) shows egib with the same
     --building-unmatched hue as bdot10k -- both are unmatched buildings, so
     they read as one category there; only prg (addresses) gets a distinct
     hue (--address-unmatched).
  */
  --ramp-1: #f2c14e;
  --ramp-2: #e8883b;
  --ramp-3: #d4552f;
  --ramp-4: #b02e3c;
  --ramp-5: #6e1b3d;
  /* Separate ramp for the completeness ratio (unmatched / total), which is a
     different quantity from --ramp-1.._5's density and must not look like it.
     Light->dark: pale = almost everything is already in OSM, deep = much of
     it is missing. Multi-hue (pale blue -> lavender -> violet -> purple ->
     magenta) rather than one hue's tints: these fills are semi-transparent
     over a busy raster basemap, where lightness alone is not salient enough
     to read a regional pattern -- a single-hue version of this ramp was
     tried in-browser and washed out. Hue travel carries the pattern for
     full-colour vision while monotone lightness carries it under CVD.
     This replaced a green->yellow->red ramp, which was doubly broken. Measured
     (OKLab, Machado 2009 at severity 1.0): its worst pair was #9bb43c (~5%
     missing) vs #de7f3c (~20% missing) at deuteranopic dE 3.3, against a
     target of 8 -- red-green deficiency is the common one, and those two bands
     collapsed into each other for it. Its lightness was also non-monotone,
     peaking at the yellow middle (L 0.569 -> 0.828 -> 0.507), so the two ends
     nearly matched in lightness and the ramp carried no signal at all once
     hue was discounted. This ramp is monotone in lightness (L 0.911 -> 0.336
     in even steps), which is what makes a sequential scale readable under any
     colour deficiency, and its worst pair measures dE 13.4.
     The blue-purple axis specifically: a warm yellow->red ramp scored better
     still, but is the density ramp above, which the same layer shows in count
     mode; and a yellow->green->blue one collides with --building-unmatched
     and --address-unmatched, drawn over these same bins by the z11-13 points
     tier. Blue-purple is the axis nothing else on the map spends.
     Deliberately not redefined for dark mode: unlike every other token here,
     these paint onto the OSM raster basemap, which is always the light one
     (see the `osm` source in app.js) -- there is no dark ground to re-anchor
     against, and flipping the ramp would invert the ratio's meaning.
     --ratio-unknown is the sentinel colour for a bin with no denominator,
     which is what a database whose cell_totals has not been built yet returns
     for every bin -- it has to be visibly *not* part of the ramp, since
     reading it as "done" would be exactly backwards. Kept neutral-warm so it
     reads as off-ramp against the cool purples rather than as a step in
     them. */
  --ratio-0: #edf0f8;
  --ratio-1: #b8bce0;
  --ratio-2: #a077c4;
  --ratio-3: #8a3f9e;
  --ratio-4: #5e1258;
  --ratio-unknown: #9b968a;
  /* "Draw an area to download" overlay (draw-fill, draw-outline-drawing,
     draw-outline-committed, draw-vertices, draw-rubberband layers in
     app.js). Same reasoning as --ratio-0.._4 and
     --building-outline-all above: this paints on top of the OSM raster
     basemap, which is always light, so it is not redefined for dark mode --
     a dark-mode teal would vanish against the still-light map underneath it.
     Teal was picked because it's the one hue nothing else on the map already
     spends: red is unmatched buildings, blue is unmatched addresses/EGiB
     dots, orange-red is the count ramp, purple is the ratio ramp. --draw-fill
     reuses the same value as --draw-outline (paint layers apply their own
     opacity, so one colour serves both). --draw-invalid deliberately reuses
     --danger rather than inventing a fourth colour -- "this polygon can't be
     downloaded" is the same semantic as the feedback modal's error state. */
  --draw-outline: #0f8a6b;
  --draw-fill: #0f8a6b;
  --draw-invalid: var(--danger);
  /* Recently-downloaded /package export areas (updates-fill/updates-outline
     in app.js, fed by GET /updates). Orange: the one warm hue not already
     spoken for by a *live* map layer -- --ramp-1.._5 is close but is the
     z5-13 aggregate grid's density ramp, a different section of the legend
     shown at a different scale, and --building-unmatched's red plus
     --address-unmatched's blue are both taken. Redefined for dark mode like
     the building/address colours (unlike --ratio-0.._4/--draw-outline/
     --building-outline-all, which stay fixed because they're anchored to the
     always-light OSM raster specifically) since this also has to read
     clearly as a legend swatch against the panel's own dark background. */
  --updates: #d97706;
  /* Grid cells a government registry published a change in recently
     (changes-outline in app.js, driven by agg_cells' ts_bdot10k/ts_egib/
     ts_prg). Green is what's left: red is unmatched buildings, blue unmatched
     addresses, purple the ratio ramp this outline draws directly on top of,
     yellow-to-magenta the count ramp, orange --updates. --draw-outline's teal
     is the near miss, and it's tolerable because the draw overlay is a
     transient interaction the user is deliberately looking at rather than a
     standing data layer. Redefined for dark mode for the same reason
     --updates is: it also has to read as a legend swatch against the panel's
     dark background, even though the map under it is always the light
     raster. */
  --changes: #1f9d55;
  --font-label: ui-sans-serif, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
  --font-data: ui-monospace, "SFMono-Regular", "IBM Plex Mono", Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ink: #eae6da;
    --ink-soft: #b8b2a0;
    --paper: #1a1d17;
    --paper-raised: #23261f;
    --line: #3a3c32;
    --line-strong: #55583e;
    --building-all: #aab4bf;
    --building-unmatched: #ff6b5e;
    --address-all: #9d89c9;
    --address-unmatched: #6fa8f5;
    --accent: #a9c4dd;
    --accent-soft: #cddaea;
    --ramp-1: #ffd873;
    --ramp-2: #ffab5c;
    --ramp-3: #ff7d54;
    --ramp-4: #ef5b6b;
    --ramp-5: #b8496f;
    --updates: #f0a03c;
    --changes: #4ade80;
    /* --ratio-0.._4 are intentionally absent -- see their definition above:
       they paint onto a basemap that is light in both themes. Only the
       sentinel is re-tuned, since it also has to hold up in the legend. */
    --ratio-unknown: #7d7869;
  }
}

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  height: 100%;
  font-family: var(--font-label);
  color: var(--ink);
}

#map {
  position: absolute;
  inset: 0;
}

.panel,
.masthead {
  position: absolute;
  background: var(--paper);
  border: 1px solid var(--line);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
}

.masthead {
  top: 16px;
  left: 16px;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 16px 10px 12px;
  max-width: min(72vw, 420px);
}

.crosshair {
  flex: none;
  width: 22px;
  height: 22px;
  position: relative;
}
.crosshair::before,
.crosshair::after {
  content: "";
  position: absolute;
  background: var(--accent);
}
.crosshair::before {
  left: 50%;
  top: 2px;
  bottom: 2px;
  width: 1px;
  transform: translateX(-50%);
}
.crosshair::after {
  top: 50%;
  left: 2px;
  right: 2px;
  height: 1px;
  transform: translateY(-50%);
}

.masthead h1 {
  margin: 0;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: 0.02em;
  font-family: var(--font-data);
}

.masthead p {
  margin: 2px 0 0;
  font-size: 12px;
  color: var(--ink-soft);
  line-height: 1.3;
}

/* Matches the crosshair icon's hue instead of the browser-default link blue,
   which clashed against --paper. */
.masthead a {
  color: var(--accent-soft);
}
.masthead a:hover,
.masthead a:focus-visible {
  color: var(--accent);
}

.masthead-warning {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  margin: 6px 0 0;
  padding: 6px 8px;
  background: color-mix(in srgb, var(--danger) 12%, transparent);
  border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
  border-radius: 4px;
  color: var(--danger);
  line-height: 1.3;
}

/* Triangle + "!" drawn from borders/pseudo-elements, matching the
   crosshair/panel-toggle-icon pattern rather than pulling in an emoji or an
   SVG asset. */
.masthead-warning-icon {
  flex: none;
  width: 14px;
  height: 12px;
  position: relative;
  margin-top: 2px;
}
.masthead-warning-icon::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 0;
  transform: translateX(-50%);
  width: 0;
  height: 0;
  border-left: 7px solid transparent;
  border-right: 7px solid transparent;
  border-bottom: 12px solid var(--danger);
}
.masthead-warning-icon::after {
  content: "!";
  position: absolute;
  left: 50%;
  top: 3px;
  transform: translateX(-50%);
  font-size: 9px;
  font-weight: 700;
  line-height: 1;
  color: var(--paper);
}

/* Sponsor credit, fixed bottom-center -- see the placement comment on its
   markup in index.html for why it isn't part of .masthead. Same visual
   language as .panel (paper background, hairline border, soft shadow) so it
   reads as part of this app's UI rather than a stray overlay, but kept out
   of .panel itself since it needs centering transform/positioning none of
   the corner panels use. */
.sponsor-credit {
  position: fixed;
  left: 50%;
  bottom: 12px;
  transform: translateX(-50%);
  margin: 0;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  background: var(--paper);
  border: 1px solid var(--line);
  border-radius: 4px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
  font-size: 13px;
  color: var(--ink-soft);
  white-space: nowrap;
}
/* Mobile twin of .sponsor-credit -- see the markup comment in index.html.
   Hidden here (desktop default); shown by the `max-width: 640px` query
   below, which also gives it its actual layout since it isn't a fixed
   overlay like .sponsor-credit -- it flows as an ordinary masthead row. */
.masthead-sponsor {
  display: none;
}
/* The <a> wraps only the image -- `line-height: 0` kills the few px of
   inline-baseline gap a bare `<img>` inside an inline `<a>` otherwise leaves
   underneath itself, which would throw off the flex row's vertical
   centering. Shared with .masthead-sponsor, which wraps its logo the same
   way. */
.sponsor-credit a,
.masthead-sponsor a {
  display: flex;
  line-height: 0;
}
/* The logo file is a white "Cloud" + orange "Ferro" wordmark on a
   transparent background -- the white half is invisible on --paper, which
   is near-white in light mode -- so it sits on a fixed dark plate rather
   than the theme-flipping panel background behind it. Not redefined for
   dark mode, same reasoning as --draw-outline/--ratio-0.._4 above: the plate
   only exists to give the logo's own fixed colours something to sit on, so
   it has to stay put regardless of theme. */
.sponsor-credit-logo {
  height: 30px;
  width: auto;
  padding: 5px 9px;
  background: #17324d;
  border-radius: 4px;
}

@media (max-width: 640px) {
  .sponsor-credit {
    display: none;
  }
}

.panel {
  padding: 12px 16px 14px;
  font-size: 12px;
  line-height: 1.4;
}

.panel-legend {
  left: 16px;
  bottom: 16px;
  width: 236px;
  /* Header lives in .panel-toggle now (see .panel-status, the pattern this
     was copied from); the section itself carries no padding of its own so
     the toggle button can be a flush, full-width click target. */
  padding: 0;
}

.legend-group + .legend-group {
  margin-top: 10px;
  padding-top: 8px;
  border-top: 1px solid var(--line);
}

.legend-group-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 4px;
}

.legend-title {
  font-weight: 600;
  font-size: 11.5px;
}

.source-toggle {
  display: flex;
  border: 1px solid var(--line-strong);
  border-radius: 3px;
  overflow: hidden;
}

/* #draw-mode-picker is a .source-toggle that also toggles the [hidden]
   attribute (app.js's applyDownloadPanelState). Same fix as .ratio-legend
   [hidden] below: the UA stylesheet's `[hidden] { display: none }` and this
   class's `display: flex` are equal specificity (one attribute selector,
   one class selector), and author CSS beats the UA sheet on a tie -- so
   without this override [hidden] silently loses and the picker stays a
   visible empty-ish flex row. The legend's own .source-toggle instances
   never hit this because they're never [hidden]. */
#draw-mode-picker[hidden] {
  display: none;
}

.source-btn {
  flex: none;
  padding: 2px 7px;
  font-size: 10px;
  font-weight: 600;
  background: var(--paper);
  color: var(--ink-soft);
  border: none;
  border-left: 1px solid var(--line-strong);
}
.source-btn:first-child {
  border-left: none;
}
.source-btn[aria-pressed="true"] {
  background: var(--accent);
  color: var(--paper-raised);
}

/* "Niskie zoomy" group (z5-13 metric pickers -- just "Kolor" now; "Dane" was
   dropped in favor of the Budynki/Adresy toggles above). Distinct from
   .source-toggle/.source-btn above (a connected, content-sized segmented
   control) because individually bordered buttons that wrap via flexbox cope
   better with uneven label lengths ("% braków" vs "Liczba"). */
.legend-subrow {
  margin-top: 8px;
}

.legend-subrow-label {
  display: block;
  font-size: 10px;
  font-weight: 600;
  color: var(--ink-soft);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-bottom: 4px;
}

/* Scale key for the aggregate grid's colour mode. #ratio-legend shows
   RATIO_STOPS for "% braków"; #count-legend shows the count quantiles for
   "Liczba" (its own bar/caption below) -- only one of the two is visible at
   a time, toggled in app.js's applyAggMetric alongside the aggColor state. */
.ratio-legend {
  display: flex;
  flex-direction: column;
  gap: 3px;
  margin-top: 8px;
  font-size: 10px;
  color: var(--ink-soft);
}

.ratio-legend[hidden] {
  display: none;
}

.ratio-legend-scale {
  display: flex;
  align-items: center;
  gap: 6px;
}

.ratio-legend-end {
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

.ratio-legend-caption {
  opacity: 0.8;
}

/* Stops are the app's RATIO_STOPS rescaled onto the bar's 0..20% span
   (0.03/0.2 = 15%, 0.06/0.2 = 30%, and so on), so the key is linear in
   ratio the same way the map is -- change one and the other has to follow.
   The flat run to 15% is real, not padding: RATIO_STOPS starts at 0.03, so
   everything below that genuinely paints --ratio-0. */
.ratio-legend-bar {
  flex: 1;
  height: 8px;
  border-radius: 2px;
  border: 1px solid var(--line);
  background: linear-gradient(
    to right,
    var(--ratio-0),
    var(--ratio-0) 15%,
    var(--ratio-1) 30%,
    var(--ratio-2) 45%,
    var(--ratio-3) 65%,
    var(--ratio-4)
  );
}

/* Stops are COUNT_DOMAIN_Z6/Z9's own anchors -- the p10/p25/p50/p75/p90
   percentiles of the real distribution (see app.js) -- placed at their own
   percentile rank along the bar, so the key reads directly as "10% of cells
   are lighter than this, 90% are darker than this point", not a made-up
   scale. */
.count-legend-bar {
  background: linear-gradient(
    to right,
    var(--ramp-1),
    var(--ramp-1) 10%,
    var(--ramp-2) 25%,
    var(--ramp-3) 50%,
    var(--ramp-4) 75%,
    var(--ramp-5) 90%,
    var(--ramp-5)
  );
}

.metric-toggle {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

.metric-btn {
  flex: 1 1 auto;
  min-width: 44px;
  padding: 4px 6px;
  font-family: var(--font-label);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.02em;
  background: var(--paper);
  color: var(--ink-soft);
  border: 1px solid var(--line-strong);
  border-radius: 3px;
  text-align: center;
  white-space: nowrap;
  cursor: pointer;
}
.metric-btn[aria-pressed="true"] {
  background: var(--accent);
  color: var(--paper-raised);
  border-color: var(--accent);
}

/* Static keys now -- visibility is driven by the single source-toggle above
   each group (Wyłącz/BDOT10k/EGiB, Wyłącz/PRG), not per-row checkboxes. */
.legend-row {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 3px 0;
}

.swatch {
  flex: none;
  width: 14px;
  height: 14px;
  border-left: 3px solid var(--line-strong);
}

.swatch[data-swatch="buildings"].swatch-unmatched {
  background: color-mix(in srgb, var(--building-unmatched) 85%, transparent);
  border-left-color: var(--building-unmatched);
}
.swatch[data-swatch="buildings"].swatch-all {
  background: color-mix(in srgb, var(--building-all) 30%, transparent);
  border-left-color: var(--building-outline-all);
}

.swatch[data-swatch="addresses"] {
  border-radius: 50%;
}
.swatch[data-swatch="addresses"].swatch-unmatched {
  background: var(--address-unmatched);
  border-left-color: var(--address-unmatched);
}
/* Full opacity, border-left the same color as the fill (so it reads as a
   plain disc, not a ringed one) -- matches addresses-all-circle on the map,
   which paints a solid var(--address-all) fill with no stroke. This used to
   be a 55%-alpha fill with a solid border, which didn't match what the layer
   actually renders. */
.swatch[data-swatch="addresses"].swatch-all {
  background: var(--address-all);
  border-left-color: var(--address-all);
}

/* updates-fill/updates-outline both paint at var(--updates) -- see the
   token's own comment in :root above -- so one swatch (fill tint + solid
   left border, same recipe as .swatch-all above) represents both. */
.swatch[data-swatch="updates"] {
  background: color-mix(in srgb, var(--updates) 20%, transparent);
  border-left-color: var(--updates);
}

/* changes-outline paints a border and no fill, so its swatch is an outlined
   square rather than the tinted-fill recipe above -- a filled swatch would
   claim the layer covers an area it never covers. `border` (not just
   border-left) overrides .swatch's 3px left edge on all four sides. */
.swatch[data-swatch="changes"] {
  background: transparent;
  border: 2px solid var(--changes);
}

.hint {
  margin: 8px 0 0;
  color: var(--ink-soft);
  font-size: 11px;
}

.panel-download {
  right: 16px;
  /* Clears the AttributionControl app.js adds in the same corner (~20px tall
     text bar + 10px margin from the map edge). Explicitly non-compact (see
     the `new maplibregl.AttributionControl({ compact: false })` call in
     app.js) so the attribution text is always visible instead of collapsing
     behind a click-to-reveal "i" button -- same footprint, so this clearance
     didn't need to grow. */
  bottom: 44px;
  width: 240px;
  /* Header lives in .panel-toggle now, same reasoning as .panel-legend
     above. */
  padding: 0;
}

button {
  font-family: var(--font-label);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.02em;
  cursor: pointer;
}

.download-btn {
  width: 100%;
  margin-top: 4px;
  padding: 8px 10px;
  background: var(--accent);
  color: var(--paper-raised);
  border: 1px solid var(--accent);
  transition: background-color 0.15s ease;
}
.download-btn:hover {
  background: var(--accent-soft);
}
.download-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.feedback-modal {
  min-width: 220px;
  max-width: min(90vw, 360px);
  padding: 18px 20px;
  background: var(--paper-raised);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: 6px;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
}
.feedback-modal::backdrop {
  background: rgba(0, 0, 0, 0.35);
}
.feedback-modal[data-state="error"] {
  border-color: var(--danger);
}
.feedback-modal[data-state="ok"] {
  border-color: var(--ok);
}
#download-feedback-title,
#report-modal-title {
  margin: 0 0 10px;
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--ink-soft);
}
.feedback-modal-details {
  margin: 0 0 12px;
  padding: 0 0 12px;
  border-bottom: 1px solid var(--line);
  font-size: 11px;
}
.feedback-modal-details div + div {
  margin-top: 6px;
}
.feedback-modal-details dt {
  font-weight: 600;
  color: var(--ink-soft);
}
.feedback-modal-details dd {
  margin: 1px 0 0;
  font-family: var(--font-data);
}
#download-feedback-text,
#report-modal-text {
  margin: 0 0 14px;
  font-family: var(--font-data);
  font-size: 12px;
  line-height: 1.5;
}
.feedback-modal[data-state="error"] #download-feedback-text,
.feedback-modal[data-state="error"] #report-modal-text {
  color: var(--danger);
}
.feedback-modal[data-state="ok"] #download-feedback-text,
.feedback-modal[data-state="ok"] #report-modal-text {
  color: var(--ok);
}
.feedback-modal-close {
  padding: 6px 14px;
  background: var(--accent);
  color: var(--paper-raised);
  border: 1px solid var(--accent);
}
.feedback-modal-close:hover {
  background: var(--accent-soft);
}

/* ---- report modal (#report-modal) ----
   Shares .feedback-modal's box, backdrop and data-state colouring with
   #download-feedback; only the form inside it is new. */
/* #report-submit reuses .download-btn, whose width:100%/margin-top are meant
   for a stacked panel button -- neutralised here so the two actions share one
   row evenly instead of the submit button claiming the whole width. */
.report-modal-actions {
  display: flex;
  gap: 8px;
}
.report-modal-actions > * {
  flex: 1;
  margin-top: 0;
}
/* Demoted from .feedback-modal-close's filled accent: that class is the only
   button in #download-feedback, whereas here it sits beside the submit and two
   equally-weighted filled buttons read as two equally-likely actions. */
#report-cancel {
  background: var(--paper);
  color: var(--ink-soft);
  border-color: var(--line);
}
#report-cancel:hover {
  background: var(--paper);
  border-color: var(--ink-soft);
  color: var(--ink);
}

.panel-status {
  top: 16px;
  /* Clears the NavigationControl MapLibre adds at "top-right" (29px wide,
     10px margin from the map edge, so its left edge sits at right:39px). */
  right: 50px;
  padding: 0;
  width: 500px;
  overflow: hidden;
}

.panel-toggle {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  padding: 10px 16px;
  background: var(--paper);
  color: var(--ink);
  border: none;
  text-transform: uppercase;
  font-size: 10px;
  letter-spacing: 0.12em;
}

.panel-toggle-label {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

/* CSS-only chevron (border triangle, no icon asset) pointing down when the
   panel body is visible, rotated to point right (collapsed = "expand me")
   when [aria-expanded="false"] -- toggled purely by that attribute so it
   never drifts out of sync with the actual panel-body [hidden] state, which
   app.js sets in the same click handler. */
.panel-toggle-icon {
  flex: none;
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid var(--ink-soft);
  transition: transform 0.15s ease;
}
.panel-toggle[aria-expanded="false"] .panel-toggle-icon {
  transform: rotate(-90deg);
}

.status-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--line-strong);
}
.status-dot[data-state="idle"] {
  background: var(--ok);
}
.status-dot[data-state="running"] {
  background: var(--accent);
  animation: pulse 1.4s ease-in-out infinite;
}
.status-dot[data-state="error"] {
  background: var(--danger);
}

@keyframes pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.35;
  }
}

.panel-body {
  padding: 0 16px 14px;
  border-top: 1px solid var(--line);
}

/* Unlike #status-body's own first child (#status-table, which already
   carries margin-top: 10px), the legend's and download panel's first
   children have no top spacing of their own, so they'd otherwise butt
   straight up against the border-top above. */
.panel-legend .panel-body,
.panel-download .panel-body {
  padding-top: 10px;
}

.status-table-scroll {
  overflow-x: auto;
}

#status-table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 10px;
  font-family: var(--font-data);
  font-size: 10.5px;
}

#status-table th {
  text-align: left;
  font-weight: 600;
  color: var(--ink-soft);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 9px;
  padding-bottom: 4px;
  border-bottom: 1px solid var(--line);
}

#status-table td {
  padding: 4px 6px 4px 0;
  border-bottom: 1px solid var(--line);
  vertical-align: top;
}

#status-table tbody tr:last-child td {
  border-bottom: none;
}

.job-state {
  display: inline-flex;
  align-items: center;
  gap: 5px;
}

.job-details-btn {
  display: block;
  width: 16px;
  height: 16px;
  padding: 0;
  background: transparent;
  color: var(--accent);
  border: 1px solid var(--line-strong);
  border-radius: 50%;
  font-family: var(--font-data);
  font-size: 10px;
  font-style: italic;
  font-weight: 700;
  line-height: 14px;
  text-align: center;
}
.job-details-btn:hover {
  background: var(--paper);
  border-color: var(--accent);
}

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

.job-details-modal {
  max-width: min(90vw, 460px);
}

.job-details-modal #job-details-title {
  margin: 0 0 10px;
  font-size: 13px;
  font-weight: 700;
  font-family: var(--font-data);
  color: var(--ink);
}

.job-log-entry {
  padding: 10px 0 0;
}
.job-log-entry + .job-log-entry {
  margin-top: 4px;
  border-top: 1px solid var(--line);
}
.job-log-entry h3 {
  margin: 6px 0 6px;
  font-size: 9px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--ink-soft);
}
.job-log-entry dd {
  overflow-wrap: break-word;
}

@media (prefers-reduced-motion: reduce) {
  .status-dot[data-state="running"] {
    animation: none;
  }
}

@media (max-width: 640px) {
  /* Same right:50px the desktop .panel-status already uses to clear the
     NavigationControl zoom buttons (see that rule's own comment) -- at
     phone width the masthead's text wraps tall enough to reach that high,
     where the old calc(100vw - 32px) (sized for sitting *above* the zoom
     control, not beside it) let it run underneath the buttons. width is
     intentionally unset: with left and right both given, an absolutely
     positioned box with no explicit width stretches to fill the gap between
     them. */
  .masthead {
    left: 16px;
    right: 50px;
    max-width: none;
  }
  /* Shown here -- see the base `display: none` above and the markup comment
     in index.html for why this is a separate element from .sponsor-credit
     rather than that one reflowed into place. font-size/gap echo
     .masthead p's own 12px/1.3 line-height rather than .sponsor-credit's
     13px, since this one is a masthead row among masthead rows, not a
     standalone pill.
     Selector is `.masthead .masthead-sponsor`, not the bare class: this is
     itself a `<p>`, so the plain class (0,1,0) loses its `margin` to
     `.masthead p`'s (0,1,1) above -- the exact trap the attribution-control
     rule elsewhere in this file already warns about. Two classes (0,2,0)
     beats it outright instead of relying on source order. */
  .masthead .masthead-sponsor {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 6px 0 0;
    font-size: 12px;
    color: var(--ink-soft);
  }
  /* max-height + the flex/overflow pair below cap the panel instead of
     letting it grow upward off-content like the desktop version does: at
     phone height, an uncapped expanded legend grows tall enough to run
     under the masthead, since both are independently-positioned overlays
     with no shared layout that would otherwise keep them apart. The 60vh
     fallback is a rough guess for the instant before app.js's resize
     listener computes the real figure (--legend-max-height, set from the
     masthead's actual measured height -- a fixed vh guess undershot on a
     narrow phone, where wrapped masthead text runs tall enough to eat past
     any fraction that still leaves useful legend height on a *short*
     phone) -- see updateLegendMaxHeight in app.js. The toggle button stays
     outside the scroll region (only #legend-body scrolls) so it remains
     reachable to re-collapse the panel however far the content is
     scrolled. */
  .panel-legend {
    width: calc(100vw - 32px);
    /* 16px (the desktop/other-panel inset) plus 8px: at phone width the
       AttributionControl's compact:false text bar (always-on, see app.js)
       runs the full width of the map and can sit tall enough to overlap a
       plain 16px-inset panel bottom-left. Must move in lockstep with
       updateLegendMaxHeight's own bottomOffset in app.js, which this same
       number feeds into the max-height budget. */
    bottom: 24px;
    max-height: var(--legend-max-height, 60vh);
    display: flex;
    flex-direction: column;
  }
  /* flex: none pins the toggle to its natural size -- without it, a flex
     item's default flex-shrink:1 lets the column squash it once content
     forces the container against its max-height, same failure mode as
     #legend-body's min-height:0 below guards against. */
  .panel-legend #legend-toggle {
    flex: none;
  }
  /* min-height: 0 overrides a flex item's default min-height:auto, which
     would otherwise size #legend-body to its content and defeat the parent's
     max-height instead of scrolling within it. flex: 1 1 auto lets it claim
     whatever space #legend-toggle doesn't. */
  .panel-legend #legend-body {
    flex: 1 1 auto;
    overflow-y: auto;
    min-height: 0;
  }
  /* Neither is useful on a phone: /package assumes a JOSM desktop workflow,
     and the status table's six columns don't fit a phone-width screen.
     Hidden outright, unlike the legend -- which stays reachable, just
     folded by default at load (see the matchMedia check in app.js) -- since
     there's no useful folded state for either of these to fall back to. */
  .panel-status,
  .panel-download {
    display: none;
  }
}

/* `color` needs pinning: the credit text ("&copy; OpenStreetMap
   contributors", see the `osm` source's `attribution` in app.js) is plain
   text, not a link, so MapLibre's own `.maplibregl-ctrl-attrib a` rule never
   touches it; without an explicit color here it inherits `color: var(--ink)`
   from body, which in dark mode is a light tone -- rendered over a light
   background, that's light-on-white. Pinned to the same rgba(0,0,0,.75)
   MapLibre already uses for the linked variant, so both are legible in both
   color schemes without depending on this app's theme tokens. background-
   color is bumped from MapLibre's own default (a 50%-alpha white, which
   reads as a soft gradient over a busy basemap rather than a solid plate)
   to a near-opaque white -- deliberately not this app's `--paper`/
   `--paper-raised` tokens, which flip dark in dark mode and would go
   dark-text-on-dark-background against the `color` pinned above.
   font-size/line-height are bumped from what the control would otherwise
   just inherit (`.maplibregl-map`'s base 12px/20px in maplibre-gl.css,
   MapLibre sets no font-size of its own on the attribution control), since
   compact:false keeps this permanently on-screen rather than the small
   click-to-reveal text it was sized for.
   Selector must repeat `.maplibregl-ctrl-attrib` (matching maplibre-gl.css's
   own `.maplibregl-ctrl.maplibregl-ctrl-attrib`, specificity 0,2,0) -- a
   plain single-class override loses background-color to that rule despite
   loading after it in the stylesheet, since specificity is checked before
   source order (see the CLAUDE.md-worthy trap this already caused once). */
.maplibregl-ctrl-attrib.maplibregl-ctrl-attrib {
  color: rgba(0, 0, 0, 0.75);
  background-color: rgba(255, 255, 255, 0.92);
  font-size: 14px;
  line-height: 22px;
}

.maplibregl-popup-content {
  background: var(--paper-raised);
  color: var(--ink);
  font-family: var(--font-label);
  padding: 10px 12px;
  border: 1px solid var(--line);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
}
.maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
  border-top-color: var(--paper-raised);
}
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
  border-bottom-color: var(--paper-raised);
}
.maplibregl-popup-anchor-left .maplibregl-popup-tip {
  border-right-color: var(--paper-raised);
}
.maplibregl-popup-anchor-right .maplibregl-popup-tip {
  border-left-color: var(--paper-raised);
}
.maplibregl-popup-close-button {
  color: var(--ink-soft);
  font-size: 16px;
  padding: 2px 6px;
}

.feature-popup h3 {
  margin: 0 0 6px;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--ink-soft);
}

/* Match status, which is not one of the feature's attributes -- it comes from
   which layer was clicked. Kept in the header so the <dl> below is purely
   what the tile carries. */
.feature-status {
  flex: none;
  font-weight: 600;
  letter-spacing: 0.06em;
  color: var(--ink-soft);
  border: 1px solid var(--line);
  border-radius: 2px;
  padding: 1px 4px;
}

/* The popup now lists every attribute on the feature (13-14 for a building)
   plus its OSM-tags preview, split into two <dl>s (see popupSection in
   app.js) -- so the scroll bound lives on the wrapping body, not on each
   individual <dl>, to cap the combined height rather than letting each
   section separately grow up to 45vh. */
.feature-popup-body {
  max-height: 45vh;
  overflow-y: auto;
}
/* minmax(0, ...) on both columns lets a long label ("Zrodlo danych
   geometrycznych") wrap instead of forcing the grid wider than the popup's
   max-width. */
.feature-popup dl {
  margin: 0;
  display: grid;
  grid-template-columns: minmax(0, auto) minmax(0, 1fr);
  gap: 3px 12px;
  font-size: 12px;
}
.feature-popup dt {
  color: var(--ink-soft);
  overflow-wrap: anywhere;
}
.feature-popup dd {
  margin: 0;
  font-family: var(--font-data);
  text-align: right;
  overflow-wrap: anywhere;
}
/* Section heading above each of the two <dl>s (attributes / OSM tags). Every
   section but the first gets a divider line, pulled up to the top of its
   margin so the line -- not the heading -- reads as the section boundary. */
.feature-popup-section {
  margin: 14px 0 4px;
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--ink-soft);
}
.feature-popup-section:first-child {
  margin-top: 0;
}
.feature-popup-section:not(:first-child) {
  padding-top: 10px;
  border-top: 1px solid var(--line);
}
/* "Zgłoś problem", shown only on the two unmatched layers (see reportKeyFor
   in app.js). Sits outside .feature-popup-body so it stays put while the
   attribute list scrolls, and reads as a secondary action -- outlined rather
   than filled like .download-btn, since the primary thing a popup does is
   show information. */
.feature-popup-actions {
  margin-top: 12px;
  padding-top: 10px;
  border-top: 1px solid var(--line);
}
.report-btn {
  width: 100%;
  padding: 6px 10px;
  background: var(--paper);
  color: var(--ink-soft);
  border: 1px solid var(--line);
  border-radius: 3px;
  font-size: 11px;
}
.report-btn:hover {
  color: var(--danger);
  border-color: var(--danger);
}

/* Shared box for the inline SVG hint icons (kstHint/exclusionHint/the status
   chip's hint in app.js) -- all render at the same viewBox/size, but this
   fixes their layout box explicitly so hover/click hit-testing lines up with
   what renders regardless of the icon's own geometry, unlike the bare Unicode
   glyphs (ⓘ/⚠) this replaced, whose rendered size varied by font/platform.
   Native `title` tooltip, so this is purely a visual affordance marking the
   value as hoverable. */
.kst-hint,
.exclusion-hint,
.status-hint {
  display: inline-flex;
  align-items: center;
  vertical-align: -2px;
  cursor: help;
}
/* KODKST's hover hint -- see kstHint in app.js. */
.kst-hint {
  color: var(--accent-soft);
}
/* KATEGORIAISTNIENIA's exclusion warning -- see exclusionHint in app.js.
   --danger (not --accent-soft) so it reads as a warning, not just a decode
   hint: the building is excluded from the OSM comparison outright. */
.exclusion-hint {
  color: var(--danger);
}
/* "Zgłoszony" -- the status chip for an object an active user report has
   vetoed out of the import proposals (see featureStatus in app.js). Coloured
   like .exclusion-hint, and for the same reason: both mark an object that
   exists in the registry but will never be offered for import, so they should
   read alike rather than each inventing their own signal. The chip itself
   takes the colour too, since here the *word* is the warning and the icon only
   carries the explanation. Colour and border only -- the chip keeps
   .feature-status's own box so it stays baseline-aligned with the title next
   to it, and the icon's inline-flex box handles its own alignment. */
.feature-status-flagged,
.status-hint {
  color: var(--danger);
}
.feature-status-flagged {
  border-color: var(--danger);
}
