/*
 * app.css -- pbrs_manager
 *
 * A dense operational tool, not a dashboard: table rows are 36px so eleven
 * devices fit the screen without scrolling, the toolbar is one row, page
 * headings stay small. No metric tiles, no equal card grids as a lead, no
 * coloured side stripes, no glass, no shadow at rest -- see DESIGN.md and
 * PRODUCT.md for the reasoning.
 *
 * No colour literal in this file. Every colour is var(--color-*) or
 * var(--shadow-float) from tokens.css, which is loaded before this file.
 */

/* ================= fonts, self hosted ================= */
@font-face {
  font-family: 'Familjen Grotesk';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('../fonts/familjen-grotesk-variable.woff2') format('woff2');
}

@font-face {
  font-family: 'Martian Mono';
  font-style: normal;
  font-weight: 100 800;
  font-display: swap;
  src: url('../fonts/martian-mono-variable.woff2') format('woff2');
}

/* ================= reset ================= */
*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  /* The second line is the real one; the first is the fallback for a browser
     that does not know dynamic viewport units. Safari's address bar makes
     100vh taller than the screen, which leaves a strip of page nobody can
     reach -- docs/ui-contract.md, "Touch and the iPad". */
  min-height: 100vh;
  min-height: 100dvh;
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-sans);
  font-size: var(--text-15);
  line-height: 1.5;
  font-kerning: normal;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* The page body never scrolls sideways; a wide table scrolls inside its
     own container (.table-scroll) instead. */
  overflow-x: hidden;
}

img,
svg {
  max-width: 100%;
}

a {
  color: inherit;
}

h1,
h2,
h3,
h4 {
  margin: 0;
  font-weight: 600;
  line-height: 1.25;
}

/* Design principle: an administrative page needs room for rows, not a
   headline. Headings stay small and quiet everywhere. */
h1 {
  font-size: var(--text-17);
}

h2 {
  font-size: var(--text-15);
  color: var(--color-text-2);
}

p {
  margin: 0;
  max-width: 70ch;
}

ul,
ol {
  margin: 0;
  padding: 0;
}

button,
input,
select,
textarea {
  font: inherit;
  color: inherit;
}

/* The `hidden` attribute means hidden. The browser supplies that as a plain
   `display: none` on the element, which any class setting a `display` of its
   own outranks, and this stylesheet has plenty of those. It has cost us
   twice: the plan carried an empty undo pill on screen at all times, and the
   booking dock showed its name field, its read-only group line and its
   delete button before any of the three applied. Settled once, here, rather
   than remembered at every component that hides something.

   `!important` is the point rather than a slip: this rule exists precisely
   to outrank a component's own display, and there is nothing above it left
   to outrank in turn. */
[hidden] {
  display: none !important;
}

/* ================= focus ================= */
/* Visible, separate from hover, and always the brand accent -- DESIGN.md
   gives the accent exactly three jobs: active tab, primary button, focus
   ring. This is the third. */
:focus {
  outline: none;
}

:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/* ================= helpers ================= */
.mono {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

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

/* ================= application shell ================= */
.app-shell {
  display: flex;
  flex-direction: column;
  /* vh first as the fallback, dvh as the value that is actually used -- see
     the note on `body` above. */
  min-height: 100vh;
  min-height: 100dvh;
}

/* The bar is chrome, not content. Every pixel it keeps is a pixel the plan
   grid and the device list do not get, and those are what people came for.
   So: the row is 36px instead of 48, its inner padding is one step tighter,
   and the gap between brand, tabs and controls is 16 instead of 24. Nothing
   was taken away to get there, only the air around it. The touch rules at the
   end of this file grow all of it back under a coarse pointer, where a 36px
   target is not a target. */
.app-header {
  display: flex;
  align-items: center;
  gap: var(--space-16);
  min-height: var(--header-height);
  padding: 0 var(--space-12);
  background: var(--color-surface);
  border-bottom: var(--border-width) solid var(--color-border);
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  text-decoration: none;
  flex-shrink: 0;
  color: var(--color-text);
}

.brand__mark {
  width: 1.25rem;
  height: 1.25rem;
  flex-shrink: 0;
}

.brand__name {
  font-family: var(--font-mono);
  font-size: var(--text-12);
  font-weight: 500;
  letter-spacing: 0.04em;
  color: var(--color-text-2);
}

/* The shell is what the folded row folds into: it holds the one button that
   is left over and, when the row opens, is the thing the panel is positioned
   against. It keeps the flex share the nav used to have, so nothing else in
   the header moves when the row disappears. */
.app-nav-shell {
  position: relative;
  display: flex;
  align-items: center;
  flex: 1;
  min-width: 0;
}

.app-nav {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  flex: 1;
  min-width: 0;
  overflow-x: auto;
}

/* No JavaScript, or the row pinned open: exactly the old header. The toggle
   is not merely hidden, it is absent from the tab order too, because a button
   that folds nothing is a stop on the way to the tabs for no reason. */
.app-nav__toggle {
  display: none;
}

.app-nav-shell.is-folded .app-nav__toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  padding: var(--space-4) var(--space-8);
  border: 0;
  border-radius: var(--radius-md);
  background: transparent;
  font: inherit;
  font-size: var(--text-13);
  font-weight: 500;
  color: var(--color-text);
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--ease-out);
}

.app-nav-shell.is-folded .app-nav__toggle:hover {
  background: var(--color-surface-2);
}

.app-nav__toggle svg {
  width: 1.05rem;
  height: 1.05rem;
  flex-shrink: 0;
  color: var(--color-text-2);
}

/* One button, two faces: the bars open the panel, the cross closes it. Both
   are in the markup and the state picks one, rather than a script rewriting
   a path -- the icon can never disagree with `aria-expanded` that way. */
.app-nav__cross { display: none; }
.app-nav-shell.is-open .app-nav__burger { display: none; }
.app-nav-shell.is-open .app-nav__cross { display: inline-block; }

.app-nav__here {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Only opacity and position move, never a size in the page flow (DESIGN.md),
   and the whole thing is off under prefers-reduced-motion by the rule at the
   end of this file. */
.app-nav__caret {
  transition: transform var(--duration-fast) var(--ease-out);
}

.app-nav-shell.is-open .app-nav__caret {
  transform: rotate(180deg);
}

.app-nav-shell.is-folded .app-nav {
  display: none;
}

/* Open, but still folded: a panel of tiles floating over the page rather than
   a strip pushing it down. Pushing would move every row on the page for as
   long as the menu is open, which is the jump this whole change exists to
   avoid, and a thing that genuinely hovers is the one case DESIGN.md allows a
   shadow.

   Tiles rather than a line of words, after the IONOS menu the owner pointed
   at: ten destinations read as a shape you aim at, not as a sentence you scan
   left to right. The symbol carries the recognition after the first week, the
   word stays for the first. Deliberately no border or shadow on the tile
   itself -- DESIGN.md forbids a grid of equal cards, and what is wanted here
   is a target, not a card. */
.app-nav-shell.is-folded.is-open .app-nav {
  display: grid;
  /* Three columns where there is room, fewer where there is not. The width
     has to be stated: with `max-content` the grid measures its own longest
     tile and `auto-fit` then finds room for exactly one column, which is a
     list, not the tile panel this is meant to be. */
  grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
  gap: var(--space-4);
  position: absolute;
  z-index: 20;
  top: calc(100% + var(--space-4));
  left: 0;
  width: min(92vw, 32rem);
  padding: var(--space-8);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-lg);
  background: var(--color-surface);
  box-shadow: var(--shadow-float);
  overflow: visible;
}

.app-nav-shell.is-folded.is-open .app-nav__link {
  gap: var(--space-12);
  padding: var(--space-8) var(--space-12);
  font-size: var(--text-15);
}

.app-nav-shell.is-folded.is-open .app-nav__icon {
  width: 1.5rem;
  height: 1.5rem;
}

/* The pin belongs to the panel, not to the row of destinations: it sits on
   its own line under them so it cannot be mistaken for an eleventh place to
   go. */
.app-nav-shell.is-folded.is-open .app-nav__pin {
  grid-column: 1 / -1;
  justify-self: end;
}

.app-nav__pin {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  border: 0;
  border-radius: var(--radius-md);
  background: transparent;
  color: var(--color-text-3);
  cursor: pointer;
}

.app-nav__pin svg {
  width: 1rem;
  height: 1rem;
}

.app-nav__pin:hover {
  background: var(--color-surface-2);
  color: var(--color-text);
}

.app-nav__pin[aria-pressed="true"] {
  color: var(--color-accent);
  background: var(--color-surface-2);
}

.app-nav__link {
  display: inline-flex;
  align-items: center;
  padding: var(--space-4) var(--space-8);
  border-radius: var(--radius-md);
  font-size: var(--text-13);
  font-weight: 500;
  color: var(--color-text-2);
  text-decoration: none;
  white-space: nowrap;
  transition: background-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
}

/* In the pinned strip the symbol is the quiet half and the word carries: a
   horizontal bar has no room for a 24 pixel drawing beside every one of ten
   labels. In the panel the sizes swap, above. */
.app-nav__icon {
  width: 1.05rem;
  height: 1.05rem;
  flex-shrink: 0;
  color: var(--color-text-3);
}

.app-nav__link {
  gap: var(--space-4);
}

.app-nav__link[aria-current="page"] .app-nav__icon {
  color: var(--color-accent);
}

.app-nav__link:hover .app-nav__icon {
  color: var(--color-text-2);
}

.app-nav__link:hover {
  background: var(--color-surface-2);
  color: var(--color-text);
}

.app-nav__link[aria-current="page"] {
  background: var(--color-surface-2);
  color: var(--color-accent);
}

.app-header__actions {
  display: flex;
  align-items: center;
  gap: var(--space-12);
  flex-shrink: 0;
}

/* Both views of the device list are in the page and the inactive one carries
   the `hidden` attribute, so switching costs no page load and the two cannot
   show different truths. Deliberately no `display` rule on those panes: any
   display value here would override the browser's own [hidden] rule and show
   both at once. */

/* ================= occupancy ================= */
/* How full a cabinet is, said as a shape before it is said as a number: on a
   fairground the question is "which one is running empty", and that is a
   comparison, not a reading. */
.occupancy {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  white-space: nowrap;
}

.occupancy__bar {
  position: relative;
  display: inline-block;
  width: 3.5rem;
  height: 0.4rem;
  border-radius: var(--radius-sm);
  background: var(--color-surface-2);
  overflow: hidden;
  flex-shrink: 0;
}

.occupancy__fill {
  position: absolute;
  inset: 0 auto 0 0;
  background: var(--color-accent);
}

.occupancy__text {
  font-size: var(--text-12);
  color: var(--color-text-2);
}

/* ================= data source indicator ================= */
.data-source {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  font-family: var(--font-mono);
  font-size: var(--text-12);
  /* text-3 measures 4.44:1 on --color-bg and 4.19:1 on --color-surface in
     light mode, short of the 4.5:1 PRODUCT.md sets for text on any ground --
     this sentence is read, not decorative, so it takes text-2 instead
     (design pass 2026-08-29, contrast recomputed from tokens.css, same fix
     already applied to .figure__hint on the revenue tab). */
  color: var(--color-text-2);
  white-space: nowrap;
  /* A named problem is a sentence, not a word, and the actions row does not
     shrink: without a ceiling here the warning printed straight over the
     navigation. It is cut with an ellipsis and carries the full text in its
     title instead. */
  max-width: 24ch;
  min-width: 0;
}

.data-source__icon {
  width: 1rem;
  height: 1rem;
  flex-shrink: 0;
  color: var(--color-good);
}

.data-source__age {
  font-size: var(--text-12);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0;
}

/* Stale vendor data is dampened, never hidden and never dressed up as fresh
   -- PRODUCT.md forbids invented freshness. The shape has already changed by
   this point (clock to warning triangle, see base.html); the colour only
   agrees with it, it is not what carries the message. */
.data-source--stale .data-source__icon,
.data-source--stale .data-source__age {
  color: var(--color-warning);
}

/* The indicator is a link to the settings page, because every problem it can
   report is fixed there. It must not look like body text turned blue. */
a.data-source { text-decoration: none; }
a.data-source:hover .data-source__age { color: var(--color-text); }

/* A named problem is louder than stale data: stale means late, this means the
   poll is not running at all until somebody acts. */
.data-source--alert .data-source__icon,
.data-source--alert .data-source__age {
  color: var(--color-critical);
}

/* ================= theme switch ================= */
.theme-switch {
  display: inline-flex;
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-md);
  overflow: hidden;
}

.theme-switch__option {
  padding: var(--space-4) var(--space-12);
  border: none;
  border-right: var(--border-width) solid var(--color-border);
  background: transparent;
  color: var(--color-text-2);
  font-size: var(--text-12);
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
}

.theme-switch__option:last-child {
  border-right: none;
}

.theme-switch__option:hover {
  background: var(--color-surface-2);
  color: var(--color-text);
}

.theme-switch__option[aria-pressed="true"] {
  background: var(--color-surface-2);
  color: var(--color-accent);
}

/* ================= icon button (settings gear) ================= */
/* Worn by both an <a> (the gear) and a <button> (the density switch, sign
   out, the plan's undo). Those three were visibly the odd ones out until
   this rule started saying so: a bare <button> keeps the browser's own
   raised chrome -- a filled grey box and a border nobody asked for -- while
   the link beside it stayed flat, so the two buttons read as lit up next to
   their neighbours. Every other button in the application inherits its
   surface from `.btn`, which sets both; this one is transparent by design
   and therefore has to say it out loud. `appearance: none` is the same
   sentence again for Safari, which rounds a button's corners on its own
   (docs/ui-contract.md: the iPad is not a second-class target here). */
.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  appearance: none;
  padding: 0;
  border: none;
  background: transparent;
  cursor: pointer;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: var(--radius-md);
  color: var(--color-text-2);
  text-decoration: none;
  transition: background-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
}

.icon-btn svg {
  width: 1.1rem;
  height: 1.1rem;
}

.icon-btn:hover {
  background: var(--color-surface-2);
  color: var(--color-text);
}

.icon-btn[aria-current="page"] {
  color: var(--color-accent);
  background: var(--color-surface-2);
}

.app-signout {
  display: inline-flex;
  margin: 0;
}

/* ================= subnav ================= */
/* Same reasoning as the header: a second bar of chrome above the rows earns
   its height once, not twice. */
.subnav {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-4) var(--space-12);
  background: var(--color-surface);
  border-bottom: var(--border-width) solid var(--color-border);
}

.subnav nav {
  display: flex;
  align-items: center;
  gap: var(--space-4);
}

.subnav a {
  display: inline-flex;
  align-items: center;
  padding: var(--space-4) var(--space-8);
  border-radius: var(--radius-md);
  font-size: var(--text-13);
  color: var(--color-text-2);
  text-decoration: none;
  transition: background-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
}

.subnav a:hover {
  background: var(--color-surface-2);
  color: var(--color-text);
}

.subnav a[aria-current="page"] {
  background: var(--color-surface-2);
  color: var(--color-accent);
}

/* ================= content ================= */
/* 16 above the first row instead of 24: enough to hold the content off the
   bar, not enough to read as an empty band. The horizontal padding stays as
   it was, a table flush against the window edge is worse than a short line. */
.app-main {
  flex: 1;
  padding: var(--space-16);
  max-width: var(--content-max-width);
  width: 100%;
  margin: 0 auto;
}

.app-main > * + * {
  margin-top: var(--space-16);
}

/* ================= toolbar: one row ================= */
.toolbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-8);
}

.toolbar > * {
  flex-shrink: 0;
}

.toolbar input[type="search"],
.toolbar input[type="text"] {
  flex: 0 1 22rem;
  min-width: 10rem;
  /* The search box used to fall back to the browser default, which renders as
     a bright box on a dark page. It carries the same skin as every other
     input now. */
  padding: var(--space-8) var(--space-12);
  color: var(--color-text);
  background: var(--color-surface-2);
  border: var(--border-width) solid var(--color-border-strong);
  border-radius: var(--radius-md);
  font: inherit;
  font-size: var(--text-15);
}
.toolbar input[type="search"]::placeholder,
.toolbar input[type="text"]::placeholder { color: var(--color-text-3); }

/* ================= table ================= */
.table-scroll {
  overflow-x: auto;
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-md);
}

.table {
  width: 100%;
  min-width: 640px;
  border-collapse: collapse;
  font-size: var(--text-13);
}

.table thead th {
  position: sticky;
  top: 0;
  background: var(--color-surface-2);
  text-align: left;
  padding: 0;
  border-bottom: var(--border-width-strong) solid var(--color-border-strong);
  white-space: nowrap;
}

/* Rows are exactly 36px, so eleven devices fit one screen without
   scrolling -- PRODUCT.md, "density is a function, not a stinginess". */
.table td,
.table tbody th {
  height: var(--row-height);
  padding: 0 var(--space-12);
  border-bottom: var(--border-width) solid var(--color-border);
  color: var(--color-text-2);
  vertical-align: middle;
}

.table tbody th {
  text-align: left;
  color: var(--color-text);
  font-weight: 500;
}

.table tbody tr:last-child td,
.table tbody tr:last-child th {
  border-bottom: none;
}

/* Rows tell apart by alternating surface brightness, not by a rule between
   every line (DESIGN.md). */
.table tbody tr:nth-child(even) {
  background: var(--color-surface);
}

.table tbody tr:hover {
  background: var(--color-surface-2);
}

.table .col-mono {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  color: var(--color-text);
}

.table__sort {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  width: 100%;
  height: var(--row-height);
  padding: 0 var(--space-12);
  border: none;
  background: transparent;
  cursor: pointer;
  font: inherit;
  font-family: var(--font-mono);
  font-size: var(--text-12);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  /* Column names, not decoration -- text-3 fails the 4.5:1 floor at this
     size (see the note on .data-source above). */
  color: var(--color-text-2);
  text-align: left;
}

.table__sort:hover {
  color: var(--color-text);
}

.table__sort-icon {
  display: inline-block;
  width: 0.7em;
  text-align: center;
  opacity: 0.35;
}

th[aria-sort] .table__sort {
  color: var(--color-accent);
}

th[aria-sort] .table__sort-icon {
  opacity: 1;
}

th[aria-sort="ascending"] .table__sort-icon::before {
  content: '\2191';
}

th[aria-sort="descending"] .table__sort-icon::before {
  content: '\2193';
}

th:not([aria-sort]) .table__sort-icon::before {
  content: '\2195';
}

/* ================= status pill ================= */
/* Word plus dot, always both -- colour alone never carries the state
   (PRODUCT.md accessibility rule; red and green side by side are not
   distinguishable for one in twelve men). */
.pill {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  padding: var(--space-4) var(--space-12);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-pill);
  font-family: var(--font-mono);
  font-size: var(--text-12);
  font-weight: 500;
  letter-spacing: 0.02em;
  line-height: 1.5;
  white-space: nowrap;
  color: var(--color-text-2);
}

.pill__dot {
  width: 0.55em;
  height: 0.55em;
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;
}

.pill--good {
  color: var(--color-good);
  border-color: color-mix(in oklch, var(--color-good) 45%, var(--color-border));
}

.pill--warning {
  color: var(--color-warning);
  border-color: color-mix(in oklch, var(--color-warning) 45%, var(--color-border));
}

.pill--critical {
  color: var(--color-critical);
  border-color: color-mix(in oklch, var(--color-critical) 45%, var(--color-border));
}

.pill--neutral {
  color: var(--color-unknown);
}

/* ================= view switch (table / tiles) ================= */
.view-switch {
  display: inline-flex;
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-md);
  overflow: hidden;
}

.view-switch__option {
  padding: var(--space-8) var(--space-16);
  border: none;
  border-right: var(--border-width) solid var(--color-border);
  background: transparent;
  color: var(--color-text-2);
  font-size: var(--text-13);
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
}

.view-switch__option:last-child {
  border-right: none;
}

.view-switch__option:hover {
  background: var(--color-surface-2);
}

.view-switch__option[aria-pressed="true"] {
  background: var(--color-surface-2);
  color: var(--color-accent);
}

.view[hidden] {
  display: none;
}

/* ================= tile view ================= */
/* The same rows as the table, reshaped for a glance -- not a marketing
   card grid: no shadow, no gradient, no icon-plus-heading filler. */
.tile-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: var(--space-12);
}

.tile {
  display: flex;
  flex-direction: column;
  gap: var(--space-8);
  padding: var(--space-12);
  background: var(--color-surface);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-md);
}

.tile__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-8);
  /* A sixteen character serial in mono plus a status pill is wider than a
     tile in the narrowest grid column. Without this the pill hangs out of the
     card and the page body gets a horizontal scrollbar -- measured at 1280,
     eleven pixels over. The pill drops to its own line instead. */
  flex-wrap: wrap;
}

.tile__heading {
  /* Lets the heading shrink inside the flex row; without it a long serial
     refuses to give way and pushes the pill out of the card. */
  min-width: 0;
}

.tile__id {
  margin: 0;
  overflow-wrap: anywhere;
  font-family: var(--font-mono);
  font-size: var(--text-15);
  font-weight: 500;
  color: var(--color-text);
  font-variant-numeric: tabular-nums;
}

.tile__subtitle {
  margin: 0;
  /* text-3 fails 4.5:1 at this size, see the note on .data-source above. */
  color: var(--color-text-2);
  font-size: var(--text-13);
}

.tile__body {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--space-4) var(--space-12);
  font-size: var(--text-13);
}

.tile__body dt {
  color: var(--color-text-2);
}

.tile__body dd {
  margin: 0;
  color: var(--color-text-2);
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

/* ================= form field ================= */
.field {
  display: grid;
  gap: var(--space-8);
  max-width: 28rem;
}

/* A group of choices is one field and has to look like one. On a `fieldset`
   the browser draws its own frame and its own indent, which is the one border
   on the page nobody chose, so it goes -- the legend keeps the label styling
   and the group keeps the grid. */
fieldset.field {
  margin: 0;
  padding: 0;
  border: 0;
  min-width: 0;
}

fieldset.field > legend.field__label {
  padding: 0;
}

.field__label {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-8);
  font-size: var(--text-13);
  font-weight: 500;
  color: var(--color-text-2);
}

.field__optional {
  font-family: var(--font-mono);
  font-size: var(--text-12);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  /* Says whether the field is required, per PRODUCT.md's accessibility
     rule -- it has to be legible, so text-2, not text-3 (see the note on
     .data-source above). */
  color: var(--color-text-2);
}

.field__control {
  padding: var(--space-8) var(--space-12);
  background: var(--color-surface);
  border: var(--border-width) solid var(--color-border-strong);
  border-radius: var(--radius-md);
  color: var(--color-text);
  font-family: var(--font-sans);
  transition: border-color var(--duration-fast) var(--ease-out);
}

.field__control:hover {
  border-color: var(--color-text-3);
}

.field__control:focus-visible {
  border-color: var(--color-accent);
}

.field--invalid .field__control {
  border-color: var(--color-critical);
}

.field__error {
  display: flex;
  align-items: baseline;
  gap: var(--space-4);
  font-size: var(--text-12);
  color: var(--color-critical);
}

/* A checkbox with its label on one line. The only switch in the interface so
   far, and it sits in a form of ordinary fields, so it borrows their spacing
   rather than inventing a control of its own. */
.field__check {
  display: flex;
  align-items: center;
  gap: var(--space-8);
  margin-bottom: var(--space-4);
  cursor: pointer;
}

.field__check input {
  width: 1rem;
  height: 1rem;
  accent-color: var(--color-accent);
}

.field__hint {
  font-size: var(--text-12);
  color: var(--color-text-2);
}

/* ================= banner ================= */
/* One page-level problem, always the same shape -- design pass 2026-08-29
   found `{% if error %}` rendered four different ways across the
   application (plain text, styled text, styled text with role="alert", and
   nowhere at all on five list pages that can fail too). `error_banner` in
   _macros.html is now the only source of this markup. A full border and a
   faint tint carry the shape so the message does not lean on colour alone
   to say "something is wrong" -- the words underneath say what. */
/* `display: flex` below beats the browser's own `[hidden]` rule, which is
   how the booking panel came to show an empty red strip under "State" at all
   times: the element is the error message, it is hidden until there is one,
   and the layout rule quietly unhid it. Same trap the view switch panes carry
   a note about further up. */
.banner[hidden] {
  display: none;
}

.banner {
  display: flex;
  align-items: flex-start;
  gap: var(--space-8);
  padding: var(--space-12) var(--space-16);
  border: var(--border-width) solid color-mix(in oklch, var(--color-critical) 45%, var(--color-border));
  border-radius: var(--radius-md);
  background: color-mix(in oklch, var(--color-critical) 10%, var(--color-surface));
  color: var(--color-critical);
  font-size: var(--text-13);
  line-height: 1.4;
}

/* ================= buttons ================= */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-8);
  padding: var(--space-8) var(--space-16);
  border: var(--border-width) solid transparent;
  border-radius: var(--radius-md);
  font-family: var(--font-sans);
  font-weight: 600;
  font-size: var(--text-13);
  line-height: 1.4;
  text-decoration: none;
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out), opacity var(--duration-fast) var(--ease-out);
}

.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: 0.55;
  pointer-events: none;
}

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

.btn--primary:hover {
  opacity: 0.88;
}

.btn--secondary {
  background: transparent;
  border-color: var(--color-border-strong);
  color: var(--color-text);
}

.btn--secondary:hover {
  background: var(--color-surface-2);
}

.btn--danger {
  background: transparent;
  border-color: var(--color-critical);
  color: var(--color-critical);
}

.btn--danger:hover {
  background: color-mix(in oklch, var(--color-critical) 12%, transparent);
}

.btn--ghost {
  background: transparent;
  border-color: transparent;
  color: var(--color-text-2);
}

.btn--ghost:hover {
  background: var(--color-surface-2);
  color: var(--color-text);
}

/* ================= empty state ================= */
.empty-state {
  padding: var(--space-32) var(--space-16);
  text-align: center;
  /* The only content on a list with nothing in it -- text-3 fails 4.5:1 at
     this size (see the note on .data-source above). */
  color: var(--color-text-2);
  font-size: var(--text-13);
}

/* ================= age ================= */
.age {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  color: var(--color-text-2);
  transition: opacity var(--duration-base) var(--ease-out);
}

/* Past ten minutes, vendor data is dampened rather than hidden --
   PRODUCT.md: "age of the data is part of the data". Dampened has to stop
   short of illegible: text-3 at 0.6 opacity measured 2.23:1 in light mode
   and 2.79:1 in dark, both well under the 3:1 floor for a UI component,
   let alone the 4.5:1 this is read text and not one. 0.85 opacity on the
   text-2 .age already sets keeps the dampened read (contrast recomputed
   from tokens.css: 5.05:1 light, 7.39:1 dark) without losing legibility. */
.age--old {
  opacity: 0.85;
}

/* ================= floating elements ================= */
/* The one shadow in the application. Nothing at rest uses it; reserved
   for things that actually float above the page, e.g. an open menu. */
.elevated {
  box-shadow: var(--shadow-float);
}

/* ================= motion reduction ================= */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}


/* --- header fits on one line ------------------------------------------
   Eight tabs plus the controls did not fit at 1491 pixels and the tab bar
   grew its own scrollbar. The controls now carry icons instead of words,
   and the tab labels give up a little room before the bar does. */

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

.app-nav { overflow: visible; min-width: 0; }

.app-nav__link {
  padding-inline: var(--space-8);
  font-size: var(--text-13);
  white-space: nowrap;
}

.theme-switch__option {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  padding: 0;
}
.theme-switch__option svg { width: 15px; height: 15px; }

.app-header__actions { gap: var(--space-8); }

/* Nothing to hide on a narrow screen any more: the indicator is an icon and
   at most three characters, which fits every width the header has. */


/* The mark is a picture now, changeable from the settings page, so it needs
   a fixed box instead of inheriting a drawing's own size. */
img.brand__mark {
  width: 26px;
  height: 26px;
  object-fit: contain;
  display: block;
}

/* ================= editable list rows ================= */
/* A list in edit mode still has to read as a list: the inputs sit in the
   cells at the row height the table already uses, without borders shouting
   over the rows. Eleven stations are edited in one pass, and the eye has to
   be able to run down a column. */
.cell-input {
  width: 100%;
  min-width: 4rem;
  padding: var(--space-4) var(--space-8);
  background: var(--color-bg);
  color: var(--color-text);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-sm);
  font-size: var(--text-13);
}

.cell-input:focus-visible {
  border-color: var(--color-accent);
}

/* The bar above an editable list: what it does, and the way out. */
.bulk-bar {
  display: flex;
  align-items: center;
  gap: var(--space-12);
  flex-wrap: wrap;
  margin-bottom: var(--space-8);
}

.bulk-bar__hint {
  font-size: var(--text-12);
  color: var(--color-text-2);
}

/* =====================================================================
 * The rest of the application under a finger
 * =====================================================================
 * docs/ui-contract.md, "Touch and the iPad". The plan has its own file,
 * board.css, loaded only where the plan is rendered; this is everything
 * else -- lists, list editing, forms, the header, the settings page.
 *
 * Nearly all of it sits behind `any-pointer: coarse`. On the laptop at the
 * desk a 44 pixel pad around a 28 pixel gear is an invisible wall that
 * swallows the click meant for the button beside it, and a mouse hits 28
 * pixels without help. The pads exist where there is a fingertip and
 * nowhere else -- but an iPad with a keyboard and trackpad attached reports
 * a *fine* primary pointer even though the screen still takes a finger
 * directly, which is exactly the audit finding on the theme switch (design
 * pass, 2026-08-29): `pointer: coarse` alone never fired there, so it
 * stayed 30px on the one device this application is bound to. `any-pointer`
 * asks "is a coarse pointer available at all", which is true for that iPad
 * and stays false for a mouse-only laptop, so the desk still gets the
 * mouse-sized controls this section was written for.
 *
 * 2.75rem is 44px at the root size, written that way so the whole
 * interface scales together if the root size is ever changed.
 * ===================================================================== */

/* A wide table scrolls inside its own frame; the page body must not travel
   with it. `overscroll-behavior-x: contain` is what stops the swipe that
   runs off the end of the table from turning into a back gesture or a pull
   on the page. Not gated on the pointer: a narrow laptop window has the
   same problem. */
.table-scroll {
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
}

/* ---------- list editing on a narrow screen -------------------------
 * Seven columns of input fields do not fit an iPad held upright, and
 * there were two ways out: stack the columns into a block per row below
 * 1024px, or scroll sideways with the first column pinned.
 *
 * Pinned scrolling won, for the job this screen exists for: eleven
 * station IDs typed in one sitting, at the fairground, from a list.
 * Stacking turns eleven rows into eleven forms of seven fields -- 77
 * fields down one page -- and takes away the one thing that makes the
 * pass quick, running the eye down a single column and filling it in. It
 * also loses the row heading as soon as a block is taller than the
 * screen, so the field being filled in belongs to a station whose name is
 * no longer anywhere on it. Scrolling keeps the list a list, keeps the
 * column-wise pass, and the pinned first column keeps the ICCID or the
 * asset tag beside every field that belongs to it. The cost is a sideways
 * swipe, which is the gesture a touch screen is best at.
 *
 * Pinning is not gated on the pointer either: the same problem appears in
 * a half width laptop window, and the column only sticks while the frame
 * is actually scrolled. */
.bulk-form .table th:first-child,
.bulk-form .table td:first-child {
  position: sticky;
  left: 0;
  z-index: 1;
  /* An opaque ground, or the columns travelling underneath show through.
     It has to repeat the row's own colour, hence three rules and not
     one. */
  background: var(--color-bg);
  border-right: var(--border-width) solid var(--color-border);
}

.bulk-form .table tbody tr:nth-child(even) td:first-child {
  background: var(--color-surface);
}

.bulk-form .table tbody tr:hover td:first-child {
  background: var(--color-surface-2);
}

/* The head is already sticky to the top; its first cell is sticky in both
   directions at once and has to stay above both. */
.bulk-form .table thead th { z-index: 2; }

.bulk-form .table thead th:first-child {
  z-index: 3;
  background: var(--color-surface-2);
}

@media (any-pointer: coarse) {
  /* ---------- the header ----------
     Three grown controls plus eight tabs plus the freshness line do not
     fit one row at 820px. The header wraps instead: brand and controls on
     the first row, the tabs on a row of their own underneath, swipeable
     if a ninth tab ever makes them too wide. Nothing in base.html moves;
     this is flex order, not markup. */
  @media (max-width: 1100px) {
    .app-header {
      flex-wrap: wrap;
      gap: var(--space-8) var(--space-16);
      padding-block: var(--space-4);
    }

    .app-header__actions { margin-left: auto; }

    /* The wrap moved from the row to its shell: the tabs are one level deeper
       now, and a rule aimed at the old place would have wrapped nothing.
       Folded there is nothing to wrap -- one button belongs on the first row
       with everything else, which is the whole point of folding it. */
    .app-nav-shell {
      order: 3;
      flex-basis: 100%;
    }

    .app-nav-shell.is-folded {
      order: 0;
      flex-basis: auto;
    }
  }

  /* A tab that does not fit must still be reachable. The vertical padding
     keeps the focus ring from being cut off by the scroll frame. */
  .app-nav {
    overflow-x: auto;
    overscroll-behavior-x: contain;
    padding-block: 2px;
  }

  /* The desktop bar was tightened to 36px; under a finger it goes back to
     the full 44, padding included. The touch contract outranks the pixel
     count every time the two disagree. */
  .app-nav__link,
  .subnav a {
    min-height: var(--tap);
    min-width: var(--tap);
    padding-inline: var(--space-12);
    justify-content: center;
  }

  .brand { min-height: var(--tap); }

  /* A minimum, not a size: at compact density `--tap` is zero and the base
     rule's own 1.75rem stands, which is the whole point of expressing the
     pad as a floor. A fixed width here would collapse the button instead. */
  .icon-btn {
    min-width: var(--tap);
    min-height: var(--tap);
  }

  .theme-switch__option {
    min-width: var(--tap);
    min-height: var(--tap);
  }

  .app-nav__toggle,
  .app-nav__pin {
    min-height: var(--tap);
    min-width: var(--tap);
  }

  /* Folded open on a tablet the row is a list, not a strip: ten tabs at 44
     pixels each do not fit one line of an iPad in portrait, and a strip that
     scrolls sideways hides half its own contents. */
  .app-nav-shell.is-folded.is-open .app-nav {
    flex-direction: column;
    align-items: stretch;
    min-width: 12rem;
    /* The scroll frame the rule above puts on the strip would clip the panel's
       own shadow; a panel that is already a column has nothing to scroll. */
    overflow: visible;
  }

  .app-nav-shell.is-folded.is-open .app-nav__link {
    justify-content: flex-start;
  }

  .view-switch__option { min-height: var(--tap); }

  /* The freshness of the vendor data used to vanish below 980px and live on
     in the `title`, which never appears on a touch screen -- on the iPad the
     whole state was a five pixel dot with nothing readable behind it. It is
     now an icon plus a figure at every width, so there is nothing left to
     bring back here; only the target grows. */
  .data-source {
    min-height: var(--tap);
    padding-inline: var(--space-4);
  }

  /* ---------- buttons ---------- */
  .btn { min-height: var(--tap); }

  /* ---------- tables ----------
     A row grows from 36 to 44 pixels: nine devices to a screen instead of
     eleven, which is the price of the row being hittable at all. The link
     inside the cell takes the full height, so the target is the row and
     not the sixteen pixels of the word. */
  /* A cell has a height, not a minimum, so the floor has to be written as
     one: the table row keeps its 36px at compact density and grows to 44
     under a finger. */
  .table td,
  .table tbody th {
    height: max(var(--row-height), var(--tap));
  }

  .table td a:not(.btn),
  .table tbody th a:not(.btn) {
    display: inline-flex;
    align-items: center;
    min-height: var(--tap);
    min-width: var(--tap);
  }

  .table__sort {
    height: auto;
    min-height: var(--tap);
  }

  /* ---------- forms ----------
     16px is not a taste, it is the size below which Safari zooms the page
     in on focus and never zooms back out. Every field where a keyboard
     opens carries it. */
  .field__control,
  .toolbar input[type="search"],
  .toolbar input[type="text"],
  .toolbar select,
  .columns-panel input[type="number"],
  .period-form__date,
  input[type="file"] {
    min-height: var(--tap);
    font-size: 1rem;
  }

  /* 9rem is sized for the 13px desk font; at the 16px floor a touch screen
     needs above, the same ten characters want a little more room. */
  .period-form__date {
    width: 10rem;
  }

  textarea.field__control {
    /* A textarea takes its height from `rows`; a floor of 44 pixels says
       nothing there. The font size is the whole point. */
    min-height: 0;
  }

  .toolbar label {
    display: inline-flex;
    align-items: center;
    gap: var(--space-8);
    min-height: var(--tap);
  }

  /* A checkbox at its default size is a 13 pixel target in a 22 pixel row.
     The row is what a finger aims at, the box is what it has to land on --
     the same pair the plan's panel form already uses. */
  .field__check,
  .columns-panel label {
    min-height: var(--tap);
  }

  .field__check input[type="checkbox"],
  .columns-panel input[type="checkbox"] {
    width: 1.5rem;
    height: 1.5rem;
  }

  .columns-panel label {
    display: inline-flex;
    align-items: center;
    gap: var(--space-8);
  }

  .columns-panel > summary {
    display: flex;
    align-items: center;
    min-height: var(--tap);
  }

  /* A stack of plain links -- the CSV exports on the settings page. Each
     line is its own target, so each line gets its own row height. */
  .link-list a {
    display: inline-flex;
    align-items: center;
    min-height: var(--tap);
  }

  /* ---------- the editable list ----------
     Eleven rows typed one after another: this is the field that decides
     whether the page zooms itself into uselessness. The floor of 8rem a
     column is what makes the seven column table wider than the screen on
     purpose, so it scrolls with its first column pinned instead of
     squeezing every field down to four characters. */
  .bulk-form .cell-input {
    min-height: var(--tap);
    min-width: 8rem;
    font-size: 1rem;
  }
}
/* ---------------------------------------------------------------------------
   Revenue (S-14). The closing brace above was missing before this pass, which
   left everything below silently scoped inside `@media (any-pointer: coarse)`
   -- present at all only where a touchscreen exists, absent for the laptop
   this page is mostly read on. Fixed as part of weaving the touch rule below
   into the block it belongs to, rather than appending a second one.
   No colour literal below, per docs/ui-contract.md -- every value comes from
   tokens.css.
   --------------------------------------------------------------------------- */

/* The period picker: three shortcuts and a custom range, one decision with
   two routes. A shared border says so, instead of the plain-text line that
   used to repeat the same two dates underneath (see overview.html). The
   group wraps as a whole first -- the custom form only drops to a line of
   its own once the shortcuts no longer fit beside it -- and every label
   stays paired with its own field inside that (.period-form__field), so a
   narrower screen never splits the two apart. */
.period-picker {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-8) var(--space-16);
  padding: var(--space-8) var(--space-12);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-md);
}

.period-picker__presets {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
}

.period-form {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-8);
  /* The second route to the same decision, set off from the shortcut
     buttons by a rule rather than by gap alone, so the two halves of the
     one group still read as belonging to each other and not as two
     controls that merely share a row. */
  padding-inline-start: var(--space-12);
  border-inline-start: var(--border-width) solid var(--color-border);
}

.period-form__field {
  display: flex;
  align-items: center;
  gap: var(--space-8);
}

.period-form__label {
  font-size: var(--text-13);
  color: var(--color-text-2);
}

/* Sized to what it holds: ten characters and a calendar glyph, not the
   ~590px a bare .cell-input resolved to here -- inside a flex row, its
   `width: 100%` became the flex-basis, so each date field demanded the
   whole row's width for itself (found on the /revenue screenshot, owner
   redesign 2026-08-29). */
.period-form__date {
  width: 9rem;
  padding: var(--space-4) var(--space-8);
  background: var(--color-bg);
  color: var(--color-text);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-sm);
  font-size: var(--text-13);
}

.period-form__date:focus-visible {
  border-color: var(--color-accent);
}

/* The export sits beside the period decision, not inside it -- pushed
   clear of the picker rather than sharing its row on equal terms. */
.toolbar__aside {
  margin-inline-start: auto;
}

/* The one figure this page calls income, alone: nothing shares its row, and
   nothing else on the page is printed this large. Owner decision on the
   revenue tab redesign -- a reservation used to sit beside it at equal
   weight and read as a second income figure, which it is not. */
.revenue-headline {
  margin-bottom: var(--space-16);
}

.revenue-headline__label {
  margin: 0;
  font-size: var(--text-13);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-text-2);
}

.revenue-headline__value {
  margin: var(--space-4) 0 0;
  font-family: var(--font-mono);
  font-size: var(--text-27);
  font-weight: 600;
  color: var(--color-text);
}

/* Set apart from the value by a wider gap than the value keeps from its own
   label above -- the two margins used to match, which let a three line hint
   visually outweigh the one line number it was only meant to explain. */
.revenue-headline__hint {
  margin: var(--space-8) 0 0;
  max-width: 60ch;
  font-size: var(--text-13);
  line-height: 1.4;
  color: var(--color-text-2);
}

/* Powerbanks still out: a ceiling on what could still be collected, never
   the same weight as the headline above it. Small on purpose, same anatomy
   as the headline at a lower step of the type scale, so "this is the same
   kind of fact at less certainty" reads without a word of explanation. */
.revenue-outstanding {
  display: inline-flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-4);
  padding: var(--space-8) var(--space-12);
  margin-bottom: var(--space-16);
  background: var(--color-surface);
  border: var(--border-width) solid var(--color-border);
  border-radius: var(--radius-md);
}

.revenue-outstanding__label {
  margin: 0;
  font-size: var(--text-12);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-text-2);
}

.revenue-outstanding__value {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--text-17);
  color: var(--color-text);
}

.revenue-outstanding__hint {
  margin: 0;
  max-width: 40ch;
  font-size: var(--text-12);
  line-height: 1.4;
  color: var(--color-text-2);
}

/* Visible without shouting: full text colour, not the warning tone. The
   traffic light stays reserved for device and connection state (DESIGN.md);
   a soon-expiring deposit is a fact about money, not an operating state, so
   it is read at ordinary reading contrast instead of borrowed alarm colour. */
.revenue-outstanding__expiring {
  margin: 0;
  max-width: 40ch;
  font-size: var(--text-12);
  line-height: 1.4;
  color: var(--color-text);
}

/* The rest: per station hour, station hours, payment count, fee and net.
   Context for the headline above, not a result of its own, so it runs as
   one quiet line rather than a second row of tiles. */
.figure__hint {
  margin: var(--space-4) 0 0;
  font-size: var(--text-12);
  line-height: 1.4;
  /* text-3 on the surface colour falls to 4.19:1 in light mode, short of the
     4.5:1 WCAG floor for 12px text (text-2 clears 6.99:1 light / 9.07:1
     dark) -- solved on the type scale, not with a one-off colour. */
  color: var(--color-text-2);
}
