:root {
  color-scheme: light dark;

  /* Page + surfaces */
  --bg-top: #f6f8fb;
  --bg-bottom: #e9eef6;
  --surface: #ffffff;
  --surface-raised: #f9fbff;
  --surface-muted: #edf1f7;
  --panel: #eef3fb;
  --bar: #dde5f2;
  --debug-bg: #e6edf8;

  /* Text */
  --text: #1f2a3a;
  --muted: #5e6a7f;
  --disabled-text: #7d889a;

  /* Borders */
  --border: #c7cfdb;
  --border-soft: #d6dfeb;
  --disabled-border: #c4cbd8;

  /* Accent */
  --accent: #0d5ea8;
  --accent-strong: #20598d;

  /* Controls (buttons / links) */
  --control-top: #f9fcff;
  --control-bottom: #dce6f4;
  --control-border: #9aa8bf;
  --control-disabled-top: #eef2f8;
  --control-disabled-bottom: #dee5f0;
  --control-active-top: #fefefe;
  --control-active-bottom: #cfe3fb;

  /* Table */
  --table-header: #e3ebf8;
  --selected-bg: #dcecff;
  --focus-bg: #f2f8ff;
  /* The channel grid's dropdown indicator, drawn at --muted; see #mem-table td
     select. Inlined rather than loaded, because an artifact of this size costs
     more as a request than as bytes, and the colour has to change with the
     theme, which an external file cannot do. */
  --select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='7' height='5' viewBox='0 0 7 5'%3E%3Cpath d='M0.5 0.5 3.5 4 6.5 0.5' fill='none' stroke='%235e6a7f' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");

  /* Settings / info */
  --info-bg: #eef5fe;
  --info-text: #274466;
  --section-header: #edf3fc;

  /* Warning (amber / orange) */
  --warn-border: #d1a862;
  --warn-bg-top: #fff7e8;
  --warn-bg-bottom: #f6e4bf;
  --warn-text: #5b3810;
  --warn-bg: #fff4e8;
  --warn-border-2: #d97b2f;
  --warn-text-2: #61330f;
  --warn-link: #7b3f14;
  --warn-btn-top: #fff4df;
  --warn-btn-bottom: #f6d7ab;
  --warn-btn-border: #965200;

  /* Error (red) */
  --error-border: #c44a4a;
  --error-bg: #fff1f1;
  --error-bg-strong: #ffdede;
  --error-text: #982b2b;

  /* Pass (green) */
  --ok-text: #1c7c3f;

  /* Shadows / overlays */
  --shadow-color: rgba(20, 37, 61, 0.12);
  --overlay: rgba(12, 23, 40, 0.45);
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Page + surfaces */
    --bg-top: #121821;
    --bg-bottom: #0d1219;
    --surface: #1a212c;
    --surface-raised: #212a37;
    --surface-muted: #161c25;
    --panel: #161d28;
    --bar: #1b2532;
    --debug-bg: #141b25;

    /* Text */
    --text: #e7ecf3;
    --muted: #9aa6b8;
    --disabled-text: #6b7787;

    /* Borders */
    --border: #2d3848;
    --border-soft: #283143;
    --disabled-border: #333d4d;

    /* Accent */
    --accent: #4c9be8;
    --accent-strong: #74b2ef;

    /* Controls (buttons / links) */
    --control-top: #2a3543;
    --control-bottom: #222c39;
    --control-border: #3a4555;
    --control-disabled-top: #1c232e;
    --control-disabled-bottom: #181e27;
    --control-active-top: #284460;
    --control-active-bottom: #1f3650;

    /* Table */
    --table-header: #222e3d;
    --selected-bg: #234061;
    --focus-bg: #243549;
    --select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='7' height='5' viewBox='0 0 7 5'%3E%3Cpath d='M0.5 0.5 3.5 4 6.5 0.5' fill='none' stroke='%239aa6b8' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");

    /* Settings / info */
    --info-bg: #182636;
    --info-text: #a9cbf0;
    --section-header: #1e2937;

    /* Warning (amber / orange) */
    --warn-border: #8a6a2e;
    --warn-bg-top: #2a2113;
    --warn-bg-bottom: #221a0f;
    --warn-text: #e8c98a;
    --warn-bg: #2a2012;
    --warn-border-2: #9a5a28;
    --warn-text-2: #e6b483;
    --warn-link: #e0a26a;
    --warn-btn-top: #3a2b12;
    --warn-btn-bottom: #2e2210;
    --warn-btn-border: #8a5a1e;

    /* Error (red) */
    --error-border: #c0564f;
    --error-bg: #2e1719;
    --error-bg-strong: #3a1c1f;
    --error-text: #f0a0a0;

    /* Pass (green) */
    --ok-text: #7fd6a0;

    /* Shadows / overlays */
    --shadow-color: rgba(0, 0, 0, 0.5);
    --overlay: rgba(0, 0, 0, 0.62);
  }
}

* {
  box-sizing: border-box;
}

/* Form controls do not inherit font-family from an ancestor — without this
   they fall back to the user agent's own default, which is Arial on macOS but
   not everywhere, so the app's buttons, dropdowns and grid cells drifted away
   from the body font by platform. Only the family is inherited: the `font`
   shorthand would also pull in the body font-size and grow every control from
   the UA's ~13px to 16px. #debug-output overrides the family with its
   monospace stack; nothing else should. */
button,
input,
select,
textarea {
  font-family: inherit;
}

body {
  margin: 0;
  background: linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
  color: var(--text);
  font-family: Arial, Helvetica, sans-serif;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
}

.about-page {
  min-height: 100vh;
  height: auto;
  overflow: auto;
  background:
    radial-gradient(circle at top right, rgba(13, 94, 168, 0.16), transparent 45%),
    linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
}

.about-shell {
  max-width: 860px;
  margin: 2.2rem auto;
  padding: 0 1rem 2rem;
}

.about-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 14px 28px var(--shadow-color);
  padding: 1.5rem 1.6rem;
  line-height: 1.6;
}

.about-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.8rem;
  margin-bottom: 0.9rem;
}

.about-header h1 {
  margin: 0;
  font-size: 1.35rem;
}

.about-signoff {
  margin-top: 1.1rem;
  font-weight: 600;
}

/* Static per-model pages (web/radios/, built by scripts/build-model-pages.mjs),
   which reuse the about-page shell above. Only the parts the about page has no
   equivalent for live here: the call to action into the app, the model list and
   the sibling-model footer. */

.radio-page h2 {
  font-size: 1.05rem;
  margin: 1.4rem 0 0.4rem;
}

.radio-page h3 {
  font-size: 0.95rem;
  margin: 1rem 0 0.3rem;
}

.radio-page-cta {
  display: inline-block;
  background: var(--accent);
  color: #fff;
  border-radius: 6px;
  padding: 0.55rem 1rem;
  font-weight: 600;
  text-decoration: none;
}

.radio-page-cta:hover {
  filter: brightness(1.08);
}

/* Two columns of names rather than one long ribbon down the page. Kenwood's
   hub runs to 92 models, so a single column would be several screens of it. */
.radio-page-list {
  columns: 2;
  gap: 1.2rem;
}

/* The model count beside each maker on the vendor directory. Dimmed because it
   qualifies the link rather than being part of it -- the maker's name is what
   a reader scans for. */
.radio-page-count {
  color: var(--muted);
  font-size: 0.85rem;
}

.radio-page-related {
  margin-top: 1.4rem;
  padding-top: 0.8rem;
  border-top: 1px solid var(--border);
  font-size: 0.9rem;
}

/* Adapter loopback diagnostics (serial-test.html), which reuses the about-page
   shell above for its card and layout. */

.loopback-caveat {
  color: var(--muted);
  font-size: 0.87rem;
}

.loopback-unsupported {
  border: 1px solid var(--warn-border);
  background: linear-gradient(180deg, var(--warn-bg-top) 0%, var(--warn-bg-bottom) 100%);
  border-radius: 6px;
  padding: 0.6rem 0.7rem;
  margin-bottom: 0.9rem;
  font-size: 0.87rem;
}

.loopback-unsupported[hidden] {
  display: none;
}

.loopback-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.9rem;
  margin: 1.1rem 0 0.6rem;
  padding: 0.85rem;
  background: var(--surface-raised);
  border: 1px solid var(--border-soft);
  border-radius: 8px;
}

.loopback-field {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  font-size: 0.85rem;
}

.loopback-bauds {
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem;
  border: 1px solid var(--border-soft);
  border-radius: 6px;
  padding: 0.35rem 0.6rem 0.5rem;
  margin: 0;
}

.loopback-bauds legend {
  padding: 0 0.3rem;
  color: var(--muted);
  font-size: 0.78rem;
}

.loopback-bauds label,
.loopback-check {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font-size: 0.85rem;
}

.loopback-buttons {
  display: flex;
  gap: 0.5rem;
  margin-left: auto;
}

.loopback-adapter {
  margin: 0.4rem 0 0;
  font-size: 0.85rem;
  font-weight: 600;
}

.loopback-status {
  margin: 0.35rem 0 0;
  font-size: 0.85rem;
  color: var(--muted);
}

.loopback-status[hidden] {
  display: none;
}

.loopback-results {
  width: 100%;
  border-collapse: collapse;
  margin-top: 0.9rem;
  font-size: 0.8rem;
}

.loopback-results[hidden] {
  display: none;
}

.loopback-results th {
  background: var(--table-header);
  border: 1px solid var(--border);
  padding: 0.35rem 0.5rem;
  text-align: left;
}

.loopback-results td {
  border: 1px solid var(--border-soft);
  padding: 0.3rem 0.5rem;
  vertical-align: top;
}

.loopback-mark {
  font-weight: 700;
  white-space: nowrap;
}

.loopback-row.is-pass .loopback-mark {
  color: var(--ok-text);
}

.loopback-row.is-fail .loopback-mark {
  color: var(--error-text);
}

.loopback-row.is-skip .loopback-mark {
  color: var(--muted);
}

.loopback-baud {
  white-space: nowrap;
  text-align: right;
}

.loopback-detail {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.74rem;
  word-break: break-word;
}

.loopback-report-wrap[hidden] {
  display: none;
}

.loopback-report-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.8rem;
  margin-top: 1.1rem;
}

.loopback-report-header h2 {
  margin: 0;
  font-size: 1rem;
}

.loopback-report-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.loopback-report {
  margin: 0.5rem 0 0;
  padding: 0.6rem;
  background: var(--debug-bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 0.74rem;
  line-height: 1.45;
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 320px;
  overflow: auto;
}

.app-shell {
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid var(--border);
  background: var(--bar);
  padding: 0.8rem 1rem;
}

.toolbar-title {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}

.toolbar h1 {
  margin: 0;
  font-size: 1.1rem;
}

.version-info {
  margin: 0;
  font-size: 0.7rem;
  color: var(--muted);
}

.version-info a {
  color: inherit;
  text-decoration: underline;
}

.version-info a:hover {
  color: var(--accent);
}

.toolbar-actions {
  display: flex;
  gap: 0.6rem;
}

button {
  border: 1px solid var(--control-border);
  background: linear-gradient(180deg, var(--control-top) 0%, var(--control-bottom) 100%);
  color: var(--text);
  padding: 0.45rem 0.75rem;
  border-radius: 4px;
  cursor: pointer;
}

button:not(:disabled):hover {
  border-color: var(--accent);
}

/* Only scoped disabled styles existed, so a disabled button outside those
   scopes looked identical to a live one — it read as broken rather than as
   not-yet-available. */
button:disabled {
  background: linear-gradient(180deg, var(--control-disabled-top) 0%, var(--control-disabled-bottom) 100%);
  border-color: var(--disabled-border);
  color: var(--disabled-text);
  cursor: default;
}

.toolbar-link {
  display: inline-flex;
  align-items: center;
  border: 1px solid var(--control-border);
  background: linear-gradient(180deg, var(--control-top) 0%, var(--control-bottom) 100%);
  color: var(--text);
  padding: 0.45rem 0.75rem;
  border-radius: 4px;
  text-decoration: none;
  font-size: 0.85rem;
}

.toolbar-link:hover {
  border-color: var(--accent);
}

/* The one toolbar control that is an offer rather than a tool, so it is filled
   rather than outlined -- it competes with Import/Export for the same row and
   loses at equal weight, which is the whole reason the browser's own install
   affordance goes unnoticed. Same accent fill as the per-model page CTA, the
   app's other ask. */
.toolbar-install {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
  font-weight: 600;
}

.toolbar-install:hover {
  filter: brightness(1.08);
}

/* Chromeless variant of .toolbar-link for the off-site community marks: the
   logo alone carries the meaning, so the border and gradient of a toolbar
   button would only compete with the controls that do something in-app. The
   default is dimmed and brightens on hover so the marks recede until sought. */
.toolbar-icon-link {
  display: inline-flex;
  align-items: center;
  padding: 0.45rem 0.25rem;
  color: var(--text);
  text-decoration: none;
  opacity: 0.75;
}

.toolbar-icon-link:hover {
  opacity: 1;
}

.toolbar-logo {
  width: 1.15rem;
  height: 1.15rem;
  flex: 0 0 auto;
}

.main-layout {
  display: grid;
  grid-template-columns: 280px 1fr;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}

.left-panel {
  border-right: 1px solid var(--border);
  background: var(--panel);
  padding: 1rem;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.left-panel-main {
  flex: 0 0 auto;
}

.left-panel h2 {
  margin-top: 0;
  font-size: 1rem;
}

/* Serial Bridge is a <details>: a static heading on desktop, a collapsible
   disclosure on mobile (the mobile breakpoint re-enables the toggle/marker). */
.left-panel-summary {
  margin: 0 0 0.6rem;
  font-size: 1rem;
  font-weight: 700;
  list-style: none;
  pointer-events: none;
}

.left-panel-summary::-webkit-details-marker {
  display: none;
}

.sidebar-warning-panel {
  margin: 0.2rem 0 0.9rem;
  padding: 0.75rem 0.8rem;
  border: 1px solid var(--warn-border);
  border-radius: 8px;
  background: linear-gradient(180deg, var(--warn-bg-top) 0%, var(--warn-bg-bottom) 100%);
  color: var(--warn-text);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
  flex-shrink: 0;
}

.sidebar-warning-panel strong {
  display: block;
  margin-bottom: 0.35rem;
  font-size: 0.78rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.sidebar-warning-panel p {
  margin: 0;
  font-size: 0.82rem;
  line-height: 1.45;
}

.serial-support-warning {
  margin: 0.35rem 0 0.8rem;
  padding: 0.55rem 0.6rem;
  border: 1px solid var(--warn-border-2);
  border-radius: 6px;
  background: var(--warn-bg);
  color: var(--warn-text-2);
  font-size: 0.82rem;
  line-height: 1.35;
}

.serial-support-warning a {
  color: var(--warn-link);
  font-weight: 600;
}

.serial-support-warning[hidden] {
  display: none;
}

#radio-search {
  width: 100%;
  margin: 0.2rem 0 0.55rem;
  padding: 0.4rem 0.45rem;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--surface);
  color: var(--text);
}

.radio-selection {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  margin: 0.2rem 0 0.75rem;
  padding: 0.45rem 0.55rem;
  border: 1px solid var(--border);
  border-left: 3px solid var(--accent);
  border-radius: 4px;
  background: var(--surface);
}

.radio-selection-caption {
  font-size: 0.68rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.radio-selection-name {
  font-size: 0.95rem;
  line-height: 1.25;
  color: var(--text);
  overflow-wrap: anywhere;
}

.radio-selection.is-empty {
  border-left-color: var(--border);
}

.radio-selection.is-empty .radio-selection-name {
  font-weight: 400;
  color: var(--muted);
}

.radio-search-wrap {
  position: relative;
}

.radio-search-results {
  position: absolute;
  top: calc(100% - 0.45rem);
  left: 0;
  right: 0;
  z-index: 40;
  max-height: 16rem;
  overflow-y: auto;
  margin: 0;
  padding: 0.2rem 0;
  list-style: none;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--surface);
  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
}

.radio-search-results[hidden] {
  display: none;
}

.radio-search-results li {
  display: flex;
  flex-direction: column;
  gap: 0.05rem;
  padding: 0.32rem 0.5rem;
  font-size: 0.85rem;
  line-height: 1.3;
  color: var(--text);
  cursor: pointer;
  overflow-wrap: anywhere;
}

.radio-search-alias {
  font-size: 0.72rem;
  color: var(--muted);
}

.radio-search-results li:hover,
.radio-search-results li.is-active {
  background: var(--selected-bg);
}

.radio-search-results li.radio-search-empty,
.radio-search-results li.radio-search-more {
  color: var(--muted);
  cursor: default;
  font-style: italic;
}

.radio-search-results li.radio-search-empty:hover,
.radio-search-results li.radio-search-more:hover {
  background: transparent;
}

.left-panel button:disabled,
.left-panel input:disabled,
.left-panel select:disabled {
  color: var(--disabled-text);
  border-color: var(--disabled-border);
  background: linear-gradient(180deg, var(--control-disabled-top) 0%, var(--control-disabled-bottom) 100%);
  cursor: not-allowed;
}

.serial-actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.5rem;
  margin: 0.3rem 0 0.6rem;
}

.serial-actions > :only-child {
  grid-column: 1 / -1;
}

.clone-progress {
  margin: 0.1rem 0 0.6rem;
}

.clone-progress-text {
  display: flex;
  justify-content: space-between;
  gap: 0.5rem;
  margin-bottom: 0.25rem;
  font-size: 0.8rem;
  color: var(--muted);
}

.clone-progress progress {
  display: block;
  width: 100%;
  height: 0.6rem;
  accent-color: var(--accent);
}

/* App-wide progress strip, rendered as the first row inside the Debug Output
   panel. It deliberately carries no border or background of its own: the panel
   already supplies both, and a second rule here would read as a divider
   splitting the panel in two. */
.app-progress {
  margin-bottom: 0.6rem;
}

.app-progress[hidden] {
  display: none;
}

.app-progress-text {
  display: flex;
  justify-content: space-between;
  gap: 0.5rem;
  margin-bottom: 0.3rem;
  font-size: 0.8rem;
  color: var(--muted);
}

.app-progress-label {
  color: var(--text);
}

.app-progress progress {
  display: block;
  width: 100%;
  height: 0.6rem;
  accent-color: var(--accent);
}

.serial-log {
  margin: 0.6rem 0 0;
  min-height: 92px;
  max-height: 180px;
  overflow: auto;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  padding: 0.45rem;
  font-size: 0.74rem;
  line-height: 1.4;
}

.editor-panel {
  padding: 1rem;
  min-height: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

/* Folder tabs rather than a pair of raised buttons: Channels and Radio
   Settings are two views of the same radio, and button chrome made them read
   as two separate actions. The strip carries a rule across the editor pane;
   the active tab overlaps that rule and paints its own bottom edge out, so the
   tab opens into the view below it the way a file-folder tab does.

   The tabs deliberately do not enclose the view in a card: both views already
   bring their own surface (#mem-table and .settings-panel are bordered cards),
   so a card here would only nest one inside another. The active tab is filled
   with --surface instead, which sits all but flush with the page in the light
   theme and a step lighter than it in the dark one. */
.editor-view-toggle {
  display: flex;
  align-items: flex-end;
  gap: 0;
  margin-bottom: 0.75rem;
  border-bottom: 1px solid var(--border);
}

/* Square bottom corners, and a shared hairline with the neighbour to the left,
   so the row reads as one strip of tabs rather than adjacent controls. Every
   tab hangs a pixel past the strip's rule; on an inactive tab that only
   double-draws the same colour, on the active one it is what breaks the line.

   Unselected tabs drop the control gradient for a flat, recessed fill. The
   gradient is lighter than --surface in the dark theme, which made the
   selected tab the dimmest thing in the strip; --surface-muted sits below
   --surface in both themes, so the selected tab always reads as the raised one. */
.editor-view-toggle button {
  position: relative;
  min-width: 8.5rem;
  margin: 0 0 -1px;
  padding: 0.4rem 0.9rem;
  border-color: var(--border);
  border-radius: 6px 6px 0 0;
  background: var(--surface-muted);
  color: var(--muted);
}

.editor-view-toggle button + button {
  margin-left: -1px;
}

/* Scoped over the global button:hover, which would otherwise tint all four
   edges -- including the bottom one that has to stay on the strip's rule. */
.editor-view-toggle button:not(:disabled):hover {
  border-color: var(--border);
  background: var(--surface-raised);
  color: var(--text);
}

.editor-view-toggle button:disabled {
  color: var(--disabled-text);
  border-color: var(--disabled-border);
  background: var(--surface-muted);
  box-shadow: none;
  cursor: not-allowed;
}

/* Taller body and no bottom border: the extra padding lifts the active tab
   clear of its neighbour (the strip is flex-end aligned, so it grows upward),
   and the accent bar along the top edge names the selected view without the
   filled background a button would need. */
.editor-view-toggle button.is-active {
  z-index: 1;
  padding-top: 0.6rem;
  background: var(--surface);
  border-color: var(--border);
  border-bottom-color: var(--surface);
  color: var(--text);
  box-shadow: inset 0 2px 0 var(--accent);
}

.editor-view {
  min-height: 0;
  flex: 1 1 auto;
  display: none;
  flex-direction: column;
}

.editor-view.is-active {
  display: flex;
}

/* The toolbar wraps: it carries every channel action now, and on a narrow
   window the row is wider than the editor pane. */
.channel-toolbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.45rem;
  margin-bottom: 0.6rem;
  flex: 0 0 auto;
  position: relative;
}

/* The repeater sources travel together so their group label stays with them
   when the toolbar wraps. */
.channel-toolbar-group {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.45rem;
}

/* Uniform height for every toolbar button, icon-only or labelled. */
.channel-toolbar button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.3rem;
  height: 2.3rem;
  padding: 0.3rem 0.6rem;
  line-height: 1;
  white-space: nowrap;
}

/* The display above is author-level, so it outranks the browser's own
   [hidden] rule. A repeater source that a blank proxy base makes unavailable
   is hidden exactly that way (web/js/ui/repeater-query.js), and without this
   its button would stay on the toolbar doing nothing when clicked. */
.channel-toolbar button[hidden] {
  display: none;
}

/* Icon-only buttons are squares matching the toolbar height. */
.channel-toolbar button.channel-toolbar-icon {
  width: 2.3rem;
  padding: 0;
}

/* One stroke style for the whole icon set. currentColor is the point: the
   icons follow the button's text colour, so they grey out with a disabled
   button and stay legible in the dark theme, neither of which emoji did. */
.channel-toolbar-icon svg,
.channel-bulk-edit svg {
  width: 1.15rem;
  height: 1.15rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Names the group of buttons that follows it; not a control itself. */
.channel-toolbar-label {
  color: var(--muted);
  font-size: 0.85rem;
  white-space: nowrap;
}

/* Separates the toolbar's action groups. */
.channel-toolbar-divider {
  width: 1px;
  height: 1.5rem;
  background: var(--border);
}

/* Holds the grid and the empty-state notice, only one of which is ever shown. */
.channel-grid {
  min-height: 0;
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
}

.mem-table-scroll {
  min-height: 0;
  overflow: auto;
  flex: 1 1 auto;
}

.channel-empty-state {
  min-height: 0;
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
}

.channel-empty-state[hidden] {
  display: none;
}

.channel-empty-card {
  max-width: 34rem;
  padding: 1.6rem 1.9rem;
  color: var(--muted);
  background: var(--surface);
  border: 1px dashed var(--border);
  border-radius: 10px;
  box-shadow: 0 1px 2px var(--shadow-color);
}

.channel-empty-icon {
  display: block;
  font-size: 2rem;
  line-height: 1;
  margin-bottom: 0.7rem;
}

.channel-empty-card h2 {
  margin: 0 0 0.45rem;
  font-size: 1.05rem;
  color: var(--text);
}

.channel-empty-card p {
  margin: 0;
  font-size: 0.92rem;
  line-height: 1.5;
}

.channel-empty-card .channel-empty-hint {
  margin-top: 0.55rem;
  font-size: 0.83rem;
  opacity: 0.8;
}

/* The settings groups keep the place the buttons had -- a column down the left
   of the settings panel -- but read as vertical folder tabs, the same language
   as the Channels / Radio Settings strip above them. The column and the panel
   are flush, so the selected tab joins the panel it fills. */
.settings-layout {
  min-height: 0;
  flex: 1 1 auto;
  display: grid;
  grid-template-columns: 220px minmax(0, 1fr);
  /* No gap in either direction: the tabs have to touch the panel they open
     into, beside it here and above it once the layout collapses to one column. */
  gap: 0;
}

.settings-tabs {
  min-height: 0;
  overflow: auto;
  display: flex;
  flex-direction: column;
  gap: 0;
  padding-right: 0;
}

/* The spine down the right of the column is drawn by the tabs themselves: each
   one carries its own right border and the selected one paints that edge out in
   the panel's --surface. This filler continues the line past the last tab to
   the foot of the column. A border on .settings-tabs could not do the job --
   the column is a scroll container, so its border box sits outside the clip and
   no tab could ever paint over it. */
.settings-tabs::after {
  content: "";
  flex: 1 1 auto;
  border-right: 1px solid var(--border);
}

.settings-tab {
  width: 100%;
  margin: 0 0 -1px;
  padding: 0.45rem 0.7rem;
  border-color: var(--border);
  border-radius: 6px 0 0 6px;
  background: var(--surface-muted);
  color: var(--muted);
  text-align: left;
  font-size: 0.85rem;
}

/* Scoped over the global button:hover, which tints all four edges -- including
   the right one that has to stay part of the spine. */
.settings-tab:not(:disabled):hover {
  border-color: var(--border);
  background: var(--surface-raised);
  color: var(--text);
}

.settings-tab.has-invalid {
  border-color: var(--error-border);
  background: var(--error-bg);
  color: var(--error-text);
}

/* Filled with the panel's own surface and missing its right edge, so the tab
   opens into the fields it controls. Positioned so it paints above the panel's
   drop shadow, which would otherwise darken the seam it is meant to erase. */
.settings-tab.is-active {
  position: relative;
  z-index: 1;
  border-color: var(--border);
  border-right-color: var(--surface);
  background: var(--surface);
  color: var(--text);
  box-shadow: inset 2px 0 0 var(--accent);
}

/* A group with an invalid value keeps its red frame while selected; only the
   edge that joins the panel and the marker colour change. */
.settings-tab.is-active.has-invalid {
  border-color: var(--error-border);
  border-right-color: var(--surface);
  background: var(--surface);
  color: var(--error-text);
  box-shadow: inset 2px 0 0 var(--error-border);
}

.settings-panel {
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border);
  /* Open on the left: the tab column supplies that edge. */
  border-left: 0;
  border-radius: 0 8px 8px 0;
  background: var(--surface);
  box-shadow: 0 10px 22px var(--shadow-color);
}

/* An inset notice rather than a bar across the panel's top edge. Full bleed, it
   was the first thing the selected tab met, so a tab filled with --surface
   opened into the notice's tint instead of into the fields -- most visible in
   the stacked layout, where the tabs always sit directly above it. Indented by
   the same 0.9rem as .settings-content, it lines up with the fields it warns
   about and leaves --surface at every edge a tab can join. */
.settings-summary {
  margin: 0.9rem 0.9rem 0;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--info-bg);
  color: var(--info-text);
  padding: 0.55rem 0.7rem;
  font-size: 0.84rem;
}

.settings-summary.has-invalid {
  border-color: var(--error-border);
  background: var(--error-bg);
  color: var(--error-text);
}

.settings-empty {
  margin: auto;
  max-width: 36rem;
  padding: 1rem;
  color: var(--muted);
  text-align: center;
  line-height: 1.45;
}

.settings-content {
  min-height: 0;
  overflow: auto;
  padding: 0.9rem;
}

.settings-group {
  margin-bottom: 1rem;
  border: 1px solid var(--border-soft);
  border-radius: 8px;
  background: var(--surface-raised);
  overflow: hidden;
}

.settings-group > h3,
.settings-subgroup > h4 {
  margin: 0;
  padding: 0.65rem 0.8rem;
  background: var(--section-header);
  font-size: 0.88rem;
}

.settings-subgroup {
  border-top: 1px solid var(--border-soft);
}

.settings-fields {
  display: grid;
  grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
  gap: 0.45rem 0.8rem;
  align-items: start;
  padding: 0.8rem;
}

.settings-field-label {
  font-size: 0.84rem;
  line-height: 1.35;
  color: var(--text);
}

.settings-field-label strong {
  display: block;
  margin-bottom: 0.1rem;
  font-size: 0.86rem;
}

.settings-field-doc {
  color: var(--muted);
}

.settings-field-control {
  min-width: 0;
}

.settings-field-control input[type="text"],
.settings-field-control input[type="number"],
.settings-field-control select {
  width: 100%;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--surface);
  color: var(--text);
  padding: 0.45rem 0.55rem;
}

.settings-field-control input[type="checkbox"] {
  width: 1rem;
  height: 1rem;
  margin-top: 0.4rem;
}

.settings-field-control.is-invalid input,
.settings-field-control.is-invalid select {
  border-color: var(--error-border);
  background: var(--error-bg);
}

.settings-field-control.is-immutable input,
.settings-field-control.is-immutable select,
.settings-field-control.is-immutable label {
  color: var(--disabled-text);
}

.settings-field-control.is-immutable input,
.settings-field-control.is-immutable select {
  background: var(--surface-muted);
}

.settings-field-error {
  margin-top: 0.25rem;
  color: var(--error-text);
  font-size: 0.78rem;
  line-height: 1.3;
}

.settings-field-warning {
  margin-top: 0.25rem;
  color: var(--warn-text-2);
  font-size: 0.78rem;
  line-height: 1.3;
}

/* Drag-and-drop file loading (web/js/ui/codeplug-io.js). The whole page is the
   drop target, so this only signals that a dragged file will be accepted.
   pointer-events stay off so the overlay appearing under the cursor cannot
   become the drag target and cancel the drag it is announcing. */
.drop-overlay {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--overlay);
  padding: 1rem;
  pointer-events: none;
}

.drop-overlay.hidden {
  display: none;
}

.drop-overlay-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3rem;
  border: 2px dashed var(--accent);
  border-radius: 10px;
  background: var(--surface);
  box-shadow: 0 16px 36px var(--shadow-color);
  padding: 1.4rem 2rem;
  text-align: center;
}

.drop-overlay-title {
  font-size: 1.05rem;
  font-weight: 600;
}

.drop-overlay-hint {
  font-size: 0.85rem;
  color: var(--muted);
}

.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  /* flex-start, not center: a flex item taller than its container is centred
     by overflowing equally at both ends, and overflow past the *start* edge is
     outside the scroll range — so the top of a tall dialog cannot be reached
     at all. flex-start plus the card's auto margins centres it while there is
     room and keeps its top edge reachable once there is not. The repeater
     query form clears 1100px on a phone once every label and control stacks,
     which is what makes this the difference between a usable dialog and one
     whose Cancel button is off-screen. */
  align-items: flex-start;
  justify-content: center;
  overflow: auto;
  background: var(--overlay);
  padding: 1rem;
}

.modal-overlay.hidden {
  display: none;
}

.modal-card {
  width: min(560px, 100%);
  /* Centres the card in the overlay while it fits, and collapses to nothing
     once it does not — see .modal-overlay. */
  margin: auto;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  box-shadow: 0 16px 36px var(--shadow-color);
  padding: 1rem;
}

.modal-card h2 {
  margin: 0 0 0.8rem;
  font-size: 1rem;
}

/*
  Browser-capability overlay. The grayscale lives on #app-shell (toggled
  together with the overlay) rather than on a backdrop-filter so it works in
  exactly the older browsers the overlay exists for. The filter creates a
  containing block, which is safe only because every fixed-position overlay is
  a sibling of #app-shell, not a descendant — keep it that way.
*/
.app-shell.browser-unsupported {
  filter: grayscale(1);
}

.unsupported-browser-overlay {
  /* Above the regular modals (60); below the drag-feedback overlay (80). */
  z-index: 70;
}

.unsupported-browser-card {
  /* A third of the screen, with floors so it stays readable on phones. */
  width: max(33vw, 340px);
  max-width: 92vw;
  min-height: 33vh;
  display: flex;
  flex-direction: column;
}

.unsupported-browser-card p {
  margin: 0 0 0.9rem;
  font-size: 0.9rem;
  line-height: 1.45;
}

.unsupported-browser-card a {
  color: var(--warn-link);
  font-weight: 600;
}

.unsupported-browser-card .modal-actions {
  margin-top: auto;
}

/*
  A notice is shown over whatever raised it, another modal included, so it sits
  above the regular modals (60) and below the browser-capability overlay (70),
  which describes something more fundamental than any notice can.
*/
.notice-overlay {
  z-index: 65;
}

.import-choice-message,
.notice-message {
  margin: 0 0 0.9rem;
  font-size: 0.9rem;
  line-height: 1.45;
}

.modal-grid {
  display: grid;
  /* minmax(0, ...) rather than a bare 1fr: a bare fr track is floored at its
     content's min-content width, which lets a wide field (the map preview)
     push the column past the card and leaves percentage widths inside it
     unresolvable. Flooring at 0 keeps the column at the card's width. */
  grid-template-columns: 160px minmax(0, 1fr);
  gap: 0.45rem 0.7rem;
  align-items: center;
}

.modal-grid input[type="text"],
.modal-grid input[type="number"],
.modal-grid select {
  width: 100%;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--surface);
  color: var(--text);
  padding: 0.35rem 0.45rem;
}

.modal-grid input[type="checkbox"] {
  width: 1rem;
  height: 1rem;
  margin: 0;
}

/* The query modal stacks two multi-row checkbox lists, which the shared
   0.45rem row gap runs together into one block — Band and Mode need to read as
   separate groups. They are also why the rows align to the top rather than the
   shared centre: a centred "Band" floats halfway down three rows of
   checkboxes, next to nothing in particular, when what it names is the row it
   starts on. */
#repeater-query-modal .modal-grid {
  row-gap: 1.1rem;
  align-items: start;
}

/* Top-aligned, a label's box starts where the control's does, which puts its
   text a border and a padding above the text inside the control. This is the
   control's own 1px + 0.35rem, so the two read as sitting on one line. */
#repeater-query-modal .modal-field-label {
  padding-top: calc(0.35rem + 1px);
}

/* The controls that are not a bordered box: a checkbox row, a lone checkbox,
   and the fixed country readout all start their content at the top of the
   grid row, so a label beside one needs no offset to meet it. Named as
   exceptions rather than listing the boxed controls, because a field added
   later is far more likely to be another boxed one. */
#repeater-query-modal .modal-field-label:has(+ .modal-modes),
#repeater-query-modal .modal-field-label:has(+ input[type="checkbox"]),
#repeater-query-modal .modal-field-label:has(+ .modal-fixed-value) {
  padding-top: 0;
}

/* A driver decides how many extras a channel has -- anytone_iii publishes
   thirteen -- so this is the one modal whose height is not something the markup
   knows, and the only one that scrolls inside its card rather than inside the
   overlay. The overlay's own scrolling (see .modal-overlay) already keeps a
   tall dialog reachable, but it moves the whole card: on a phone the title and
   the Save button would both leave the screen, and a form of unknown length is
   exactly where they should not. Capping the card at the viewport and scrolling
   the field grid instead keeps the title, the hint and the actions in place, and
   the overlay never has to scroll this dialog at all. */
#channel-extra-modal .modal-card {
  display: flex;
  flex-direction: column;
  max-height: calc(100dvh - 2rem);
}

/* The per-channel extras editor. Its fields are label/control pairs in the
   shared .modal-grid, but each label carries the driver's own explanation of
   the setting underneath it, so the rows align to the top rather than centre,
   and the label column is wider than the shared 160px -- at 160px a two-line
   explanation becomes an eight-line ribbon. The phone-width override lives with
   the other responsive rules at the foot of this file: this selector outranks
   the shared one there, so it has to be answered at its own specificity. */
#channel-extra-modal .modal-grid {
  grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
  align-items: start;
  row-gap: 0.7rem;
  overflow-y: auto;
  /* A flex child's default minimum is its content height, which would defeat
     the cap above by refusing to shrink. */
  min-height: 0;
}

.channel-extra-hint,
.channel-extra-message {
  margin: 0 0 0.8rem;
  font-size: 0.85rem;
  color: var(--muted);
}

.channel-extra-label label {
  font-size: 0.88rem;
}

.channel-extra-doc {
  margin-top: 0.15rem;
  font-size: 0.78rem;
  line-height: 1.3;
  color: var(--muted);
}

.channel-extra-control {
  min-width: 0;
}

.channel-extra-control.is-immutable input,
.channel-extra-control.is-immutable select {
  background: var(--surface-muted);
  color: var(--disabled-text);
}

.channel-extra-control.is-invalid input,
.channel-extra-control.is-invalid select {
  border-color: var(--error-border);
  background: var(--error-bg);
}

.channel-extra-error {
  margin-top: 0.25rem;
  font-size: 0.78rem;
  line-height: 1.3;
  color: var(--error-text);
}

/* The bulk channel editor (web/js/ui/channel-bulk-edit.js). It shares the
   extras editor's shape -- an unknown number of fields, so the card is capped
   at the viewport and the fields scroll inside it rather than the whole dialog
   scrolling in the overlay -- but scrolls a wrapper rather than one grid,
   because it stacks two grids with a heading between them. */
#channel-bulk-edit-modal .modal-card {
  display: flex;
  flex-direction: column;
  max-height: calc(100dvh - 2rem);
}

/* The two grids and the heading between them scroll as one block, so the title
   and the actions stay put. */
.bulk-edit-body {
  overflow-y: auto;
  /* A flex child's default minimum is its content height, which would defeat
     the cap above by refusing to shrink. */
  min-height: 0;
}

#channel-bulk-edit-modal .bulk-edit-grid {
  /* Wider label column than the shared 160px: a checkbox, the attribute name
     and the "multiple values" note share it. */
  grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
  align-items: start;
  row-gap: 0.7rem;
}

.bulk-edit-hint {
  margin: 0 0 0.8rem;
  font-size: 0.85rem;
  color: var(--muted);
}

/* Separates the driver's per-channel extras from the grid's own columns. */
.bulk-edit-subhead {
  margin: 1rem 0 0.5rem;
  font-size: 0.9rem;
}

/* The checkbox and the attribute's name are one click target. */
.bulk-edit-toggle {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.88rem;
}

.bulk-edit-toggle input[type="checkbox"] {
  width: 1rem;
  height: 1rem;
  margin: 0;
  flex: 0 0 auto;
}

/* Says the selected channels disagree about this attribute, so whatever the
   control shows is one channel's value rather than the selection's. */
.bulk-edit-mixed {
  margin-top: 0.15rem;
  margin-left: 1.4rem;
  font-size: 0.75rem;
  font-style: italic;
  color: var(--muted);
}

.bulk-edit-doc {
  margin-top: 0.15rem;
  margin-left: 1.4rem;
  font-size: 0.78rem;
  line-height: 1.3;
  color: var(--muted);
}

.bulk-edit-control {
  min-width: 0;
}

.bulk-edit-control.is-invalid input,
.bulk-edit-control.is-invalid select {
  border-color: var(--error-border);
  background: var(--error-bg);
}

.bulk-edit-error {
  margin-top: 0.25rem;
  font-size: 0.78rem;
  line-height: 1.3;
  color: var(--error-text);
}

/* A value the modal reports rather than asks for: the fixed RSGB country.
   Styled to read as text, not as a disabled input the user might try to
   edit. */
.modal-fixed-value {
  font-size: 0.9rem;
  color: var(--muted);
}

.modal-geo-row {
  display: grid;
  grid-template-columns: 1fr auto auto;
  gap: 0.4rem;
  align-items: center;
}

/* Latitude and longitude on one row under a single "Coordinates" label
   (web/js/ui/query-fields.js). Two equal tracks rather than auto: the boxes
   hold the same kind of value and their placeholders are near enough the same
   length, so an uneven split would only read as one of them mattering more.
   minmax(0, 1fr) for the reason .modal-grid gives -- a bare fr track floors at
   the content's min-content width, and a number input's is wide enough to push
   the pair past the column. */
.modal-coord-row {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.4rem;
  align-items: center;
}

.modal-geo-button {
  min-width: 2rem;
  padding: 0.35rem 0.45rem;
  line-height: 1;
}

/* The Place name autocomplete (web/js/ui/query-fields.js): a text box with
   a suggestion list that overlays the fields under it. Positioned rather than
   in flow, so a list that appears on a keystroke does not shove Latitude,
   Longitude and the map down the card mid-type. */
.modal-city-field {
  position: relative;
}

.modal-city-suggestions {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  z-index: 2;
  margin: 0.15rem 0 0;
  padding: 0;
  list-style: none;
  /* Roughly seven rows before it scrolls. The endpoint returns up to twenty and
     all of them are reachable, but a list that covers the map it is about to
     move makes the result of choosing invisible. */
  max-height: 15rem;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--surface);
  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
}

/* Any author `display` beats the user agent's [hidden] rule, for the same
   reason .modal-map-preview-empty[hidden] needs saying twice. */
.modal-city-suggestions[hidden] {
  display: none;
}

.modal-city-suggestion {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.6rem;
  padding: 0.3rem 0.5rem;
  font-size: 0.85rem;
  cursor: pointer;
}

/* One highlight class for the pointer and the arrow keys alike, so hovering and
   arrowing cannot disagree about which entry Enter or a blur will commit. */
.modal-city-suggestion.is-active {
  background: var(--accent);
  color: #fff;
}

.modal-city-name {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.modal-city-context {
  flex: 0 1 auto;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: 0.78rem;
  color: var(--muted);
}

.modal-city-suggestion.is-active .modal-city-context {
  color: inherit;
  opacity: 0.85;
}

/* "No matching places", or why the lookup could not run. A failed suggestion
   request is not a failed query -- coordinates can still be typed -- so it is
   reported here rather than in the status line. */
.modal-city-note {
  margin: 0.2rem 0 0;
  font-size: 0.78rem;
  color: var(--muted);
}

.modal-city-note[hidden] {
  display: none;
}

/* Static OSM preview of the coordinates the repeater-query modal currently
   holds, with the search radius drawn on it (web/js/ui/query-fields.js). It
   spans both grid columns and is pushed right, so it lines up under the values
   it previews rather than under their labels, and it carries no label of its
   own — the map is the label. */
.modal-map-preview {
  grid-column: 1 / -1;
  /* A definite width, not min(100%, 320px): the second track is 1fr, so while
     it is still being sized from its content a percentage here has nothing to
     resolve against and the item collapses to its min-content width (the
     attribution line). justify-self does the right-alignment that margin-left:
     auto would, without needing a width the track cannot supply yet. */
  justify-self: end;
  width: 320px;
  max-width: 100%;
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow: hidden;
}

/* Square, so the range circle has the same room in both directions.
   web/js/ui/query-fields.js measures the container and renders the tile grid
   at that pixel size; the aspect ratio is what it measures against before the
   first render. The stand-in shown before a position is entered is the same
   square, so the block never changes size and the first render already
   measures the width the map will keep. */
.modal-map-preview .repeater-map-canvas,
.modal-map-preview-empty {
  width: 100%;
  aspect-ratio: 1 / 1;
  background: var(--border);
}

.modal-map-preview .repeater-map-canvas {
  /* Draggable: the map sets the position as well as showing it. touch-action
     hands the gesture to the page's own handler instead of letting the browser
     scroll the modal with it, which is what makes the drag work on a phone. */
  cursor: grab;
  touch-action: none;
  user-select: none;
}

.modal-map-preview .repeater-map-canvas.is-panning {
  cursor: grabbing;
}

.modal-map-preview-empty {
  margin: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 1.2rem;
  text-align: center;
  font-size: 0.85rem;
  color: var(--muted);
}

/* How many repeaters the squares on the map add up to. Sits between the map and
   the attribution, because it is a caption for the map rather than part of the
   OSM credit. */
.modal-map-preview-count {
  margin: 0;
  padding: 0.25rem 0.45rem 0;
  font-size: 0.78rem;
  color: var(--muted);
}

/* While a preview is in flight the squares already drawn stay put -- blanking
   the map on every edit would flicker through each keystroke of a radius -- so
   the count is the only thing that says the number is about to change. */
.modal-map-preview-count.is-loading {
  opacity: 0.55;
}

.modal-map-preview-count[hidden] {
  display: none;
}

/* Any author `display` beats the user agent's own [hidden] rule, so a flex
   stand-in stays on screen next to the map it was meant to stand in for
   unless the attribute is honoured explicitly here. */
.modal-map-preview-empty[hidden] {
  display: none;
}

/* Three fixed columns rather than as many 130px ones as fit: the control
   column of a 560px modal only ever fitted two, which turned the seven RSGB
   bands into four rows and the six modes into three, and those seven rows are
   height the map preview below them needs. Every option here is a short token
   ("70cm", "dstar"), so a third of the column is room enough, and fixing the
   count keeps the two groups in the same grid whatever a directory's
   dictionary happens to list. */
.modal-modes {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0.35rem 0.55rem;
}

.modal-mode-option {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font-size: 0.85rem;
}

.modal-actions {
  margin-top: 0.9rem;
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
}

.debug-output-panel {
  border-top: 1px solid var(--border);
  background: var(--debug-bg);
  padding: 0.7rem 0.9rem 0.9rem;
}

.debug-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}

.debug-header-actions,
.debug-actions {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
}

.debug-actions[hidden],
.debug-output-content[hidden] {
  display: none;
}

.debug-header h2 {
  flex: 1;
  margin: 0;
  font-size: 0.9rem;
}

.debug-toggle {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  width: 100%;
  border: 0;
  background: none;
  color: inherit;
  padding: 0.15rem 0;
  font: inherit;
  text-align: left;
}

.debug-toggle:not(:disabled):hover {
  color: var(--accent);
}

.debug-toggle-icon {
  display: inline-block;
  font-size: 1rem;
  line-height: 1;
  transition: transform 120ms ease;
}

.debug-toggle[aria-expanded="true"] .debug-toggle-icon {
  transform: rotate(90deg);
}

.debug-output-content {
  margin-top: 0.4rem;
}

#report-issue {
  border-color: var(--warn-btn-border);
  background: linear-gradient(180deg, var(--warn-btn-top) 0%, var(--warn-btn-bottom) 100%);
}

#debug-output {
  width: 100%;
  min-height: 180px;
  resize: vertical;
  border: 1px solid var(--border);
  background: var(--surface-raised);
  color: var(--text);
  padding: 0.5rem;
  font-size: 0.78rem;
  line-height: 1.35;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

/* Let the table take its natural width so all 17 column headers stay
   readable; it scrolls horizontally inside .mem-table-scroll when the
   window is narrower (previously table-layout: fixed compressed every
   column to fit, overlapping the header text).

   Natural width and nothing more: a min-width of 100% here used to stretch the
   grid across the pane, and auto table layout hands that slack back to every
   column in proportion, which put it straight back between each value and the
   divider to its right -- the columns fitInputColumnWidths()
   (web/js/ui/channel-table.js) had just measured. So the grid now ends where
   its contents end, and a window wider than the channel list simply shows
   page behind it. */
#mem-table {
  width: max-content;
  border-collapse: collapse;
  background: var(--surface);
  border: 1px solid var(--border);
}

/* Less horizontal than vertical padding: the cell borders already separate the
   columns, and across seventeen of them the symmetric padding cost more width
   than the values it framed. */
#mem-table th,
#mem-table td {
  border: 1px solid var(--border);
  padding: 0.35rem 0.25rem;
  font-size: 0.85rem;
  white-space: nowrap;
}

/* Keep the column headers in view while the channel list scrolls. The nearest
   scrolling ancestor is .mem-table-scroll, so top:0 pins them to the top of the
   grid's own scroll area rather than to the page; the background is what stops
   the rows showing through, and the stacking order what keeps them over the
   editors rather than under.

   Sticky on the cells rather than on thead because border-collapse gives the
   borders to the table rather than to the cell, so a sticky header leaves its
   own behind as it detaches from the rows. The shadow redraws the bottom edge
   at the same width and colour; it has to be a shadow rather than a border,
   which would change the header's height and so every row's offset. */
#mem-table th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--table-header);
  box-shadow: inset 0 -1px 0 var(--border);
  text-align: left;
}

#mem-table td input {
  width: 100%;
  border: none;
  background: transparent;
  color: var(--text);
  font-size: 0.85rem;
}

/* An <input> is the one editor that cannot size itself: its width comes from
   the size attribute, which buys N *average* characters plus a one-character
   surcharge, and the browser never looks at the value inside. That is what made
   every text column the same ~150px whether it held a callsign or a frequency.
   field-sizing hands the browser the job instead, so a column is exactly as
   wide as the widest value the grid is showing.

   All three declarations are load-bearing. width:auto because a percentage
   width suppresses field-sizing outright -- the columns silently fall back to
   their header widths. min-width:100% so the input still fills its cell: a
   percentage minimum does not feed back into the intrinsic width, so it stretches
   the control without widening the column, and a click anywhere in the cell
   still lands in the editor. max-width is a backstop against a pasted
   paragraph, not a working limit: it only ever binds on a free-text column,
   because a name is bounded by the driver's own valid_name_length (16
   characters at the most, across every real driver) and a frequency by the ten
   digits normalizeValue() writes. So it is set well clear of what the repeater
   directories actually produce -- an RSGB query returns comments like
   "KIDDERMINSTER | IO82 | ~3.6 km", 23 to 32 characters -- and a value past it
   still scrolls inside the input.

   Note what this does *not* see: the grid is virtualized (see
   web/js/ui/channel-table.js), so the browser is measuring the rows currently in
   the DOM, not the whole channel list. Columns therefore track what is on
   screen. */
@supports (field-sizing: content) {
  #mem-table td input {
    field-sizing: content;
    width: auto;
    min-width: 100%;
    max-width: 48ch;
  }
}

/* A native dropdown arrow is a fixture about 21px wide -- a few pixels of
   indicator plus the room the platform reserves around it -- and this grid has
   a dozen enum columns, several holding values narrower than that (Skip is one
   character, Duplex two). So the arrow itself was setting their width, and the
   gap it left between a value and the edge of its cell was the visible half of
   the same problem. Draw a chevron scaled to the grid instead, from the
   theme-aware --select-arrow, with the left padding matched to the inputs so
   values line up down a row. */
#mem-table td select {
  width: 100%;
  appearance: none;
  -webkit-appearance: none;
  border: none;
  padding: 0 10px 0 2px;
  background: transparent var(--select-arrow) no-repeat right 2px center;
  color: var(--text);
  font-size: 0.85rem;
}

/* Read-only columns for the selected radio: grey text (cells and header)
   instead of the browser's default disabled rendering, with a title tooltip
   set in createCellEditor/renderTable. */
#mem-table td .readonly-cell,
#mem-table th.readonly-cell {
  color: var(--muted);
  -webkit-text-fill-color: var(--muted);
  opacity: 1;
}

/* With natural table sizing the editors are what give each column its width,
   so they must ask for no more than their own contents: a select is already as
   wide as the widest option its driver publishes, and an input is sized by the
   size attribute columnCharBudget() derives from the column metadata in
   web/js/ui/channel-table.js. A blanket min-width here flattened all of that to
   one width, so a two-character Duplex or Skip column was as wide as a
   frequency and the desktop grid scrolled sideways for space it was not using.
   Keep the auto-indexed Location column compact. */
#mem-table td:first-child,
#mem-table th:first-child {
  min-width: 2rem;
}

/* A floor, not a width: the button holds a memory slot number, so a radio
   numbering past three digits widens the column from its own content. */
#mem-table td:first-child input,
#mem-table td:first-child select,
#mem-table .channel-location-button {
  min-width: 2rem;
}

#mem-table .channel-location-button {
  width: 100%;
  border: none;
  border-radius: 0;
  padding: 0;
  background: transparent;
  color: var(--text);
  font-size: 0.85rem;
  text-align: left;
  cursor: pointer;
}

#mem-table .channel-location-button:hover {
  color: var(--accent-strong);
  text-decoration: underline;
}

#mem-table .channel-location-button:focus-visible {
  outline: 1px solid var(--accent);
  background: var(--focus-bg);
}

/* The Extra column's cell: a button rather than an editor, because the fields
   behind it are per-driver and per-channel (web/js/ui/channel-extra.js). Sized to its
   label so the column stays the narrowest one in the grid. */
#mem-table .channel-extra-button {
  padding: 0.05rem 0.4rem;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--surface);
  color: var(--text);
  font-size: 0.78rem;
  line-height: 1.5;
  cursor: pointer;
}

#mem-table .channel-extra-button:hover {
  border-color: var(--accent);
  color: var(--accent-strong);
}

#mem-table .channel-extra-button:focus-visible {
  outline: 1px solid var(--accent);
}

/* The Extra column is pinned to the right edge of the grid's scroll area. It is
   the eighteenth of eighteen columns, so at any ordinary window width it sits
   past the right edge and the feature is invisible until someone thinks to
   scroll for it -- which is how it was first reported. right:0 keeps the button
   in view at every scroll position while leaving the column where it belongs,
   last, after Comment.

   Same two constraints as the sticky header above, for the same reasons: an
   opaque background so the cells it travels over do not show through, and a
   box-shadow standing in for the left border that border-collapse hands to the
   table and that therefore scrolls away with it. The outer shadow is what says
   the column is floating over the grid rather than sitting in it. The header
   cell is sticky on both axes at once, so it has to outrank the row cells as
   well as the ordinary headers. A selected row still paints through, because
   its own rule is the more specific one. */
#mem-table td[data-column="Extra"] {
  position: sticky;
  right: 0;
  z-index: 2;
  background: var(--surface);
  box-shadow: inset 1px 0 0 var(--border), -3px 0 5px -3px var(--shadow-color);
}

#mem-table th[data-column="Extra"] {
  right: 0;
  z-index: 3;
  box-shadow:
    inset 0 -1px 0 var(--border),
    inset 1px 0 0 var(--border),
    -3px 0 5px -3px var(--shadow-color);
}

/* Static OSM context map for repeater channels (issue #57): a channel named
   after a callsign marks its Location cell, hover looks the callsign up and
   shows the tooltip map on desktop, tap opens the modal map on touch devices
   (web/js/ui/repeater-map.js). The mark says the cell is worth hovering, not
   that a map exists -- only the lookup can answer that. */
#mem-table .channel-location-button.has-callsign {
  text-decoration: underline dotted;
  text-underline-offset: 2px;
}

.repeater-map-tooltip {
  position: fixed;
  z-index: 70;
  /* Matches the sidebar's 280px column (issue #57: "no bigger than"). */
  width: 280px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  box-shadow: 0 16px 36px var(--shadow-color);
  overflow: hidden;
  /* Hoverable on purpose: the attribution link at its foot has to be
     clickable, so repeater-map.js keeps the tooltip up while the pointer is
     inside it rather than hiding on the cell's mouseout. */
}

.repeater-map-tooltip.hidden {
  display: none;
}

.repeater-map-coords {
  display: block;
  padding: 0.4rem 0.5rem;
  text-align: center;
  font-size: 0.85rem;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

.repeater-map-canvas {
  position: relative;
  overflow: hidden;
  background: var(--border);
}

.repeater-map-tile {
  position: absolute;
  width: 256px;
  height: 256px;
  max-width: none;
  user-select: none;
}

.repeater-map-marker {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 12px;
  height: 12px;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: #e01b24;
  border: 2px solid #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}

/* The search radius a repeater query will use, drawn around the marker.
   web/js/ui/static-map-view.js sizes it in pixels from the ground radius; the
   fill is faint enough to read the map through. */
/* One repeater the current filters would return, on the query modal's preview
   map (web/js/ui/static-map-view.js). A square, not a dot, because that is what
   many of these positions honestly are: a directory that publishes only a
   4-character locator places a station within a box, and a dot would present
   that guess as a survey. Small enough that a dense city stays readable as a
   cluster rather than a blob. */
.repeater-map-pin {
  position: absolute;
  width: 7px;
  height: 7px;
  transform: translate(-50%, -50%);
  background: var(--accent);
  border: 1px solid rgba(255, 255, 255, 0.9);
  pointer-events: none;
}

/* Inside the map but outside the range ring: what a wider search would add.
   Drawn faintly, so it reads as context for the radius rather than as a result
   -- the count under the map is where these are actually tallied. */
.repeater-map-pin.is-out-of-range {
  opacity: 0.4;
}

/* A position derived from a 4-character locator, which names a box some 111 km
   across rather than a point. Hollow, so the difference between "here" and
   "somewhere around here" is visible on the map and not only in the row the
   import later writes. */
.repeater-map-pin.is-approximate {
  background: transparent;
  border-color: var(--accent);
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.75);
}

.repeater-map-range {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  border: 2px solid rgba(224, 27, 36, 0.75);
  background: rgba(224, 27, 36, 0.12);
  pointer-events: none;
}

/* Attribution strip along the bottom of the widget, below the map: the OSM
   tile policy wants the credit visible on every map and reachable through to
   the licence, which the link inside it provides. */
.repeater-map-attribution {
  padding: 3px 6px;
  border-top: 1px solid var(--border);
  text-align: right;
  font-size: 0.65rem;
  line-height: 1.3;
  color: var(--muted);
}

.repeater-map-attribution a {
  color: inherit;
}

.repeater-map-card {
  width: min(90vw, 340px);
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
  box-shadow: 0 16px 36px var(--shadow-color);
  overflow: hidden;
}

.repeater-map-header {
  position: relative;
}

.repeater-map-header .repeater-map-coords {
  /* Keep the coordinates centered on the card, clear of the close button. */
  padding-left: 2rem;
  padding-right: 2rem;
}

.repeater-map-close {
  position: absolute;
  top: 50%;
  right: 0.35rem;
  transform: translateY(-50%);
  border: none;
  background: transparent;
  color: var(--text);
  font-size: 1rem;
  line-height: 1;
  padding: 0.3rem 0.45rem;
  cursor: pointer;
}

/* Square map before the script sizes it in pixels, so the first open can
   measure a laid-out width. */
.repeater-map-card .repeater-map-canvas {
  width: 100%;
  aspect-ratio: 1 / 1;
}

/* Stand-ins for the rows the grid keeps out of the DOM (see channel-table.js):
   pure height, no box of their own. */
#mem-table tbody tr.mem-row-spacer td {
  padding: 0;
  border: 0;
  font-size: 0;
  line-height: 0;
}

#mem-table tbody tr.is-selected td {
  background: var(--selected-bg);
}

#mem-table td.is-invalid {
  background: var(--error-bg);
}

#mem-table td input:focus {
  outline: 1px solid var(--accent);
  background: var(--focus-bg);
}

/* background-color, not the background shorthand: the shorthand resets
   background-image, and since these selects draw their own dropdown chevron
   there (native appearance being off) a focused cell would lose the only sign
   that it is a dropdown at all -- exactly while the user is interacting with
   it. The input rule above can use the shorthand safely because an input has
   no background layer to lose. */
#mem-table td select:focus {
  outline: 1px solid var(--accent);
  background-color: var(--focus-bg);
}

@media (max-width: 900px) {
  /* Let the page flow and scroll vertically instead of locking to the viewport,
     so the stacked panels are all reachable on a phone. */
  body {
    height: auto;
    min-height: 100dvh;
    overflow: auto;
  }

  .app-shell {
    height: auto;
    min-height: 100dvh;
    overflow: visible;
  }

  .toolbar {
    flex-wrap: wrap;
    gap: 0.5rem;
  }

  .toolbar-actions {
    flex-wrap: wrap;
  }

  /* Stack sidebar above the editor; let both size to content and the page scroll. */
  .main-layout {
    display: block;
    overflow: visible;
  }

  .left-panel {
    display: block;
    border-right: none;
    border-bottom: 1px solid var(--border);
  }

  .editor-panel {
    display: block;
    overflow: visible;
  }

  .editor-view.is-active {
    display: block;
  }

  /* Bound the wide channel table so it scrolls internally while the page can
     still scroll past it to the debug panel. */
  .mem-table-scroll {
    max-height: 60dvh;
    -webkit-overflow-scrolling: touch;
  }

  /* Serial Bridge disclosure becomes collapsible on mobile so the channel list
     isn't pushed down the page. */
  .left-panel-summary {
    pointer-events: auto;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin-bottom: 0.4rem;
    min-height: 2.75rem;
  }

  .left-panel-summary::after {
    content: "\25BE";
    color: var(--muted);
  }

  .serial-bridge:not([open]) .left-panel-summary::after {
    content: "\25B8";
  }

  /* The natural-width, horizontally-scrolling table now applies at all
     viewports (see the base #mem-table rules); mobile only adds taller,
     tappable cell editors. */
  #mem-table td input,
  #mem-table td select {
    min-height: 2.25rem;
  }

  /* Comfortable touch targets for primary controls (table cells excluded). */
  .toolbar-actions button,
  .toolbar-link,
  .editor-view-toggle button,
  .left-panel button,
  .left-panel select,
  .serial-actions button,
  .channel-toolbar button,
  .modal-actions button,
  .debug-header-actions button {
    min-height: 2.75rem;
  }

  /* Keep the emoji-only buttons square at the larger touch-target height. */
  .channel-toolbar button.channel-toolbar-icon {
    min-width: 2.75rem;
    width: 2.75rem;
  }

  #debug-output {
    min-height: 120px;
  }

  .modal-grid {
    grid-template-columns: minmax(0, 1fr);
  }

  /* The one modal that keeps its label column on a phone. It is also the
     longest form in the app, and stacking cost it two things per field -- the
     label's own line, and a row gap between the label and the control it names
     -- which ran the dialog past a phone screen and a half. Every label here
     is short enough to sit beside its control in 110px; below that "Only
     operational" wraps, above it the two coordinate boxes stop fitting their
     placeholders. The map preview spans both columns either way, so none of
     this touches it. */
  #repeater-query-modal .modal-grid {
    grid-template-columns: minmax(0, 110px) minmax(0, 1fr);
  }

  /* The extras editor sets its own label column at a specificity the rule above
     cannot reach, so it needs its own override or it keeps two columns -- a
     220px label beside a squeezed control -- on a phone. */
  #channel-extra-modal .modal-grid {
    grid-template-columns: 1fr;
  }

  /* Same reason as the rule above: the bulk editor sets its own label column at
     a specificity the shared .modal-grid override cannot reach. */
  #channel-bulk-edit-modal .bulk-edit-grid {
    grid-template-columns: 1fr;
  }

  .settings-layout {
    grid-template-columns: 1fr;
  }

  /* Stacked layout: the same folder tabs, turned through ninety degrees. The
     spine and the tab edges move from right to bottom, and the panel takes its
     left border back and gives up its top one. */
  .settings-tabs {
    flex-direction: row;
    overflow: auto;
    padding-right: 0;
    padding-bottom: 0;
  }

  .settings-tabs::after {
    border-right: 0;
    border-bottom: 1px solid var(--border);
  }

  .settings-tab {
    width: auto;
    margin: 0 -1px 0 0;
    border-radius: 6px 6px 0 0;
    white-space: nowrap;
  }

  .settings-tab.is-active,
  .settings-tab.is-active.has-invalid {
    border-right-color: var(--border);
    border-bottom-color: var(--surface);
  }

  .settings-tab.is-active {
    box-shadow: inset 0 2px 0 var(--accent);
  }

  .settings-tab.is-active.has-invalid {
    border-right-color: var(--error-border);
    box-shadow: inset 0 2px 0 var(--error-border);
  }

  .settings-panel {
    border-left: 1px solid var(--border);
    border-top: 0;
    border-radius: 0 0 8px 8px;
  }

  .settings-fields {
    grid-template-columns: 1fr;
  }
}

@media (max-width: 560px) {
  /* Single-column serial actions and full-width view toggle on narrow phones. */
  .serial-actions {
    grid-template-columns: 1fr;
  }

  /* The tab strip is flex at every width now; narrow phones only need the two
     tabs to share the row instead of holding their 8.5rem label width. */
  .editor-view-toggle button {
    flex: 1 1 0;
    min-width: 0;
  }

  .toolbar h1 {
    flex: 1 1 100%;
  }
}

/* Custom tooltip bubble (web/js/tooltip.js): replaces native title tooltips
   so every tooltip appears after the same 400ms delay. Sits above modals. */
.app-tooltip {
  position: fixed;
  z-index: 100;
  max-width: 320px;
  padding: 4px 8px;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--surface);
  color: var(--text);
  font-size: 0.78rem;
  line-height: 1.35;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
  pointer-events: none;
  white-space: pre-line;
}
