/* Hamnkapten web admin: SJÖKORT component recipes (design spec §4–§8).
   Tokens come from ui-tokens/generated/theme.css; nothing in here hardcodes a
   colour. Constitution reminders: mono = every numeral (soundings rule);
   signal magenta = non-text, budgeted; one decorative tile per region max;
   motion is event-driven and plays once. */

/* 1. Fonts: the three SJÖKORT voices (spec §3). Plex Sans = UI, Plex Mono =
   soundings (every time/price/date numeral), Besley = display at the six
   enumerated places only (decision 6, Captain 2026-06-10: one-for-one swap
   from Fraunces). All SIL OFL, self-hosted. */
@font-face { font-family: 'IBM Plex Sans'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/ibm-plex-sans-latin-400-normal.woff2') format('woff2'); }
@font-face { font-family: 'IBM Plex Sans'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/ibm-plex-sans-latin-500-normal.woff2') format('woff2'); }
@font-face { font-family: 'IBM Plex Sans'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/ibm-plex-sans-latin-600-normal.woff2') format('woff2'); }
@font-face { font-family: 'IBM Plex Mono'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/ibm-plex-mono-latin-400-normal.woff2') format('woff2'); }
@font-face { font-family: 'IBM Plex Mono'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/ibm-plex-mono-latin-500-normal.woff2') format('woff2'); }
@font-face { font-family: 'Besley'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/besley-latin-500-normal.woff2') format('woff2'); }
@font-face { font-family: 'Besley'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/besley-latin-600-normal.woff2') format('woff2'); }

/* 2. Base */
*, *::before, *::after { box-sizing: border-box; }
body {
  margin: 0;
  background: var(--bg-base);
  color: var(--fg-primary);
  font-family: var(--font-ui);
  font-size: var(--text-base);
  line-height: var(--leading-body);
  -webkit-font-smoothing: antialiased;
}
h1, h2 { font-weight: var(--weight-display); line-height: var(--leading-heading); margin: 0 0 var(--space-2); }
/* Screen titles are one of the display voice's six enumerated places (spec §3). */
h1 { font-size: var(--text-2xl); font-family: var(--font-display); }
h2 { font-size: var(--text-lg); margin-top: var(--space-3); }
p { margin: 0 0 var(--space-2); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
/* The soundings voice: every time, price, date numeral, reference, phone mask. */
.mono, .booking-time, .activity-time, .device-seen, .cal-range, .metric-value,
.customer-phone, .booking-ref { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* Chart-legend labels: small caps, letter-spaced. */
.legend-label, .metric-label, .cal-colhead {
  text-transform: uppercase; letter-spacing: 0.06em; font-size: var(--text-xs);
}
/* Reduced-motion contract (spec §6): everything collapses to a 90 ms fade.
   Reduce when EITHER the OS prefers reduced motion OR the in-app toggle sets
   `[data-reduce-motion]` on the root (theme.rs `apply_reduce_motion`), the
   desktop's "either ⇒ reduce" semantics.

   The two triggers cannot share one selector list: an @media query and a plain
   attribute selector sit on opposite sides of the @media boundary, and CSS has
   no way to project the media state onto the attribute. The declaration block
   is therefore deliberately repeated once. The only DRY-able part, the reduced
   timing, is already factored into the `--motion-reduced` token. */
@media (prefers-reduced-motion: reduce) {
  * {
    transition-property: opacity !important;
    transition-duration: var(--motion-reduced) !important;
    animation: none !important;
  }
}
[data-reduce-motion] * {
  transition-property: opacity !important;
  transition-duration: var(--motion-reduced) !important;
  animation: none !important;
}

/* 3. Frame */
.app-shell { display: flex; align-items: stretch; min-height: 100vh; }
.app-nav {
  /* `--nav-item-h` fixes every item to one height so the travelling marker can
     step by `index * (item-h + gap)` exactly (desktop parity, nav-chrome.slint).
     40px = 24px label line-box (16px × 1.5) + 16px vertical padding. */
  --nav-item-h: 40px;
  position: relative;
  display: flex; flex-direction: column; gap: var(--space-0-5);
  width: 208px; flex-shrink: 0;
  padding: var(--space-3) var(--space-2);
  background: var(--bg-elevated);
  border-right: 1px solid var(--border-default);
}
.app-nav a {
  height: var(--nav-item-h); box-sizing: border-box;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-sm);
  color: var(--fg-secondary); font-weight: var(--weight-ui);
  transition: background var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard);
}
.app-nav a:hover { background: var(--bg-sunken); color: var(--fg-primary); text-decoration: none; }
/* Active rail item: ink + sunken fill only. The leading-edge magenta route-line
   is no longer per-item; a single travelling marker (`.nav-marker`) owns it,
   matching the desktop rail's one sliding indicator (FR-9 fork F-6). */
.app-nav a[aria-current="page"] { color: var(--accent); background: var(--bg-sunken); }
/* Travelling route-line marker: one 3px `signal` bar on the leading edge that
   slides to the active item, mirroring desktop's animated marker (200ms,
   ease-in-out). It rides the items' equal height: starting at the rail's top
   padding and stepping `index * (item-h + gap)`, set inline from the active
   route. Reduced motion drops the transition below. */
.nav-marker {
  position: absolute; left: var(--space-2); top: var(--space-3);
  width: 3px; height: var(--nav-item-h);
  border-radius: 1.5px; background: var(--signal);
  transition: transform var(--motion-base) ease-in-out;
  pointer-events: none;
}
.app-main { flex: 1; min-width: 0; max-width: 1100px; padding: var(--space-4); }
.boot-status, .boot-error { padding: var(--space-4); color: var(--fg-secondary); }

/* 4. Controls: 4 px radius, borders over shadows, fast hover micro. */
button {
  font: inherit; font-weight: var(--weight-ui); cursor: pointer;
  padding: var(--space-1) var(--space-3);
  border: 1px solid transparent; border-radius: var(--radius-sm);
  background: var(--accent); color: var(--fg-on-accent);
  transition: background var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard);
}
button:hover { background: var(--accent-hover); }
button:active { transform: scale(0.99); }
button:disabled { opacity: .6; cursor: not-allowed; }
.btn-secondary { background: transparent; color: var(--accent); border-color: var(--border-strong); }
.btn-secondary:hover { background: var(--bg-sunken); }
.btn-danger { background: var(--danger); color: var(--fg-on-accent); }

label { display: block; font-weight: var(--weight-ui); font-size: var(--text-sm); color: var(--fg-secondary); margin-bottom: var(--space-2); }
input, select {
  font: inherit; width: 100%; margin-top: var(--space-0-5);
  padding: var(--space-1) var(--space-2);
  background: var(--bg-elevated); color: var(--fg-primary);
  border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
}
input:focus-visible, select:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-color: var(--focus-ring); }
.login-form, .search-form { display: flex; flex-direction: column; gap: var(--space-2); max-width: 360px; }
.search-form { flex-direction: row; align-items: flex-end; }
.search-form button { white-space: nowrap; }
.form-error { color: var(--danger); font-size: var(--text-sm); }

/* 4b. Six-box TOTP input (FR-21): the web gains the desktop's SegmentedCode look.
   Values track the desktop Cell (segmented-code.slint): 40×48 px boxes, 8 px radius,
   8 px gap, centred --text-xl digit, a 2 px focus-ring border on focus. `.totp-field`
   stacks its label over the box row, matching the label→input rhythm of the other
   fields; `.totp-label` reuses the plain-label voice. */
.totp-field { display: flex; flex-direction: column; gap: var(--space-0-5); }
.totp-label { display: inline-flex; align-items: center; }
.totp-boxes { display: flex; gap: var(--space-1); margin-top: var(--space-0-5); }
.totp-box {
  width: 40px; height: 48px; flex: 0 0 auto; margin-top: 0;
  padding: 0; text-align: center;
  font-size: var(--text-xl); font-variant-numeric: tabular-nums;
  background: var(--bg-base); color: var(--fg-primary);
  border: 1px solid var(--border-default); border-radius: var(--radius-md);
}
.totp-box:focus-visible {
  outline: none; border-width: 2px; border-color: var(--focus-ring);
  /* Compensate the extra 1 px border so the box does not jump on focus. */
  margin: -1px; padding: 0;
}

/* 5. Neatline register-ticks (spec §4): four 6 px corner strokes, inset 6 px,
   fg-secondary at 35 %. Allowed ONLY on stat cards, modals and the login card
   (ink budget). Eight gradient layers draw the four L-shaped ticks. */
.neatline { position: relative; }
.neatline::before {
  content: ""; position: absolute; inset: 6px; pointer-events: none;
  --tick: color-mix(in srgb, var(--fg-secondary) 35%, transparent);
  background:
    linear-gradient(var(--tick), var(--tick)) 0 0 / 6px 1px,
    linear-gradient(var(--tick), var(--tick)) 0 0 / 1px 6px,
    linear-gradient(var(--tick), var(--tick)) 100% 0 / 6px 1px,
    linear-gradient(var(--tick), var(--tick)) 100% 0 / 1px 6px,
    linear-gradient(var(--tick), var(--tick)) 0 100% / 6px 1px,
    linear-gradient(var(--tick), var(--tick)) 0 100% / 1px 6px,
    linear-gradient(var(--tick), var(--tick)) 100% 100% / 6px 1px,
    linear-gradient(var(--tick), var(--tick)) 100% 100% / 1px 6px;
  background-repeat: no-repeat;
}

/* 6. Cards + sections: hairline + one tight shadow (spec §4). */
.metric-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-2); margin-bottom: var(--space-4); }
.metric-card {
  display: flex; flex-direction: column; gap: var(--space-0-5);
  padding: var(--space-2);
  background: var(--bg-elevated); border: 1px solid var(--border-default);
  border-radius: var(--radius-md); box-shadow: var(--shadow-sm);
}
.metric-label { color: var(--fg-secondary); }
/* Instrumentet: the stat numeral is a sounding in mono, large, primary ink. */
.metric-value { font-size: var(--text-2xl); font-weight: var(--weight-ui); color: var(--fg-primary); }

.settings-section, .customer-detail {
  padding: var(--space-3); margin-bottom: var(--space-3);
  background: var(--bg-elevated); border: 1px solid var(--border-default);
  border-radius: var(--radius-md); box-shadow: var(--shadow-sm);
}
.settings-note { color: var(--fg-muted); font-size: var(--text-sm); }
.settings-values { margin: 0; }
.settings-row, .customer-fields { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); padding: var(--space-1) 0; }
.customer-fields dt, .settings-row dt { color: var(--fg-secondary); font-size: var(--text-sm); min-width: 160px; }
.customer-fields dd, .settings-row dd { margin: 0; }
/* Checkbox rows (e.g. Reducera rörelse): the label sits inline with a natural-
   width box, overriding the full-width `input` default used by text fields. */
.settings-check { display: flex; align-items: center; gap: var(--space-1); margin-bottom: 0; }
.settings-checkbox { width: auto; margin-top: 0; accent-color: var(--accent); }

/* 7. Lists + status inks: status is never colour alone; the glyph half of the
   system (signal-flag SVGs) joins with the P2 markup. */
.booking-list, .activity-list { list-style: none; margin: 0; padding: 0; }
.booking-row, .activity-row {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-1) 0; border-bottom: 1px solid var(--border-default);
}
.booking-time, .activity-time, .device-seen { color: var(--fg-secondary); font-size: var(--text-sm); }
/* Customer + device lists: shared-column grids (desktop). Each was a stack of
   independent content-driven flex rows, so column N began at a different x in
   every row: phone/stats jittered ~12px in the customer list, the "Återkalla"
   revoke buttons ~45px in the device list. Promoting each <ul> to a grid and
   letting every row be a `subgrid` locks the cells into shared columns. The
   leading `1fr` (name) absorbs slack. (Mobile ≤640px reverts these to the dense
   flex/column reflow in §11.) */
.customer-list, .device-list {
  list-style: none; margin: 0; padding: 0;
  display: grid;
}
/* The customer list's three trailing columns are single-sourced in ui-tokens
   (`--customer-phone-w` / `--customer-stats-w` / `--customer-badge-w`, FR-16) so
   the web grid and the desktop Slint list resolve to the SAME edges at every
   width, instead of the web self-balancing to its widest cell while the desktop
   pinned the 1280-reference numbers. Trade-off: the three columns no longer
   auto-fit the data, but they were already ≈58|226|118 at the reference, the
   values elide (`.customer-phone/.customer-stats` clip, the badge hugs its cell),
   and the `1fr` name column still absorbs all width slack. The device list keeps
   its self-balancing `auto` columns (out of FR-16 scope). */
.customer-list { grid-template-columns: 1fr var(--customer-phone-w) var(--customer-stats-w) var(--customer-badge-w); }
.device-list { grid-template-columns: 1fr auto auto auto; }
.device-row {
  display: grid; grid-template-columns: subgrid; grid-column: 1 / -1;
  align-items: center; column-gap: var(--space-2);
  padding: var(--space-1) 0; border-bottom: 1px solid var(--border-default);
}
/* The revoke cell (4th column) hugs the right so the resting "Återkalla"
   buttons line up; the armed inline `.device-confirm` prompt is wider but is a
   transient state, so its overflow is contained to that one cell and does not
   widen the resting column. The seen/created columns start at their shared
   left edge (auto-sized to their widest date), reading as tidy columns. */
.device-row > :last-child { justify-self: end; min-width: 0; }
.device-row .device-confirm { flex-wrap: wrap; }
/* Inline two-step revoke confirm: prompt + Återkalla + Ångra share one row but
   may wrap when the row is narrow, so the prompt sentence stays readable. */
.device-confirm { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); }
.device-confirm-prompt { color: var(--fg-secondary); font-size: var(--text-sm); }
.booking-who, .activity-action { flex: 1; min-width: 0; }
/* The Swish-reconciliation booking code: a compact sounding in the row's trailing
   run, secondary ink and letter-spaced so the six-char reference reads as a code. */
.booking-code { color: var(--fg-secondary); font-size: var(--text-sm); letter-spacing: 0.08em; }
/* Name cells in the grid lists sit in the `1fr` first column and just need to
   clip long values rather than flex-grow (the column already grows). */
.customer-name, .device-name { min-width: 0; }
.booking-status { font-size: var(--text-sm); font-weight: var(--weight-ui); }
.booking-status.paid { color: var(--status-paid); }
.booking-status.unpaid { color: var(--status-unpaid); }
.booking-status.held { color: var(--status-held); }
.booking-status.cancelled { color: var(--status-cancelled); text-decoration: line-through; }

/* 7b. Feedback (FR-22) review screen, the twin the desktop feedback screen is
   measured against (Track D parity). The filter bar reuses the calendar chip
   vocabulary; each row is a flex line with the same rhythm as the booking rows.
   The star run is decorative (aria-hidden), the numeral columns are soundings. */
.feedback-filterbar { margin-bottom: var(--space-1); }
.feedback-filter-label { color: var(--fg-secondary); font-size: var(--text-sm); margin-right: var(--space-0-5); }
.feedback-list { list-style: none; margin: var(--space-2) 0 0; padding: 0; }
.feedback-row {
  display: flex; align-items: baseline; gap: var(--space-2);
  padding: var(--space-1) 0; border-bottom: 1px solid var(--border-default);
}
.feedback-date { color: var(--fg-secondary); font-size: var(--text-sm); white-space: nowrap; }
.feedback-role { color: var(--fg-secondary); font-size: var(--text-sm); white-space: nowrap; }
/* Signal-budgeted magenta stars: the rating is the row's one accent, kept off the
   text half of the status system (the role/body carry the words). */
.feedback-stars { color: var(--accent); letter-spacing: 0.06em; white-space: nowrap; }
.feedback-body { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feedback-contact { color: var(--status-paid); font-size: var(--text-sm); white-space: nowrap; }

/* 7c. Användning, the anonymous usage panel: one horizontal bar per event scaled
   to the busiest, in the same elevated card frame as the settings sections. Bars
   are calm (sunken track, accent fill), the count is a trailing sounding. */
.usage-section {
  padding: var(--space-3); margin-top: var(--space-4);
  background: var(--bg-elevated); border: 1px solid var(--border-default);
  border-radius: var(--radius-md); box-shadow: var(--shadow-sm);
}
.usage-bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1); }
.usage-bar-row { display: grid; grid-template-columns: minmax(120px, 1fr) 2fr auto; align-items: center; gap: var(--space-2); }
.usage-event { color: var(--fg-secondary); font-size: var(--text-sm); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.usage-track { display: block; height: var(--space-2); background: var(--bg-sunken); border-radius: var(--radius-pill); overflow: hidden; }
.usage-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-pill); }
.usage-count { color: var(--fg-primary); font-size: var(--text-sm); }

/* 7d. Anonymisation due-section (FR-15): a due-count chip at the top of Kunder
   that expands an inline list. The chip is a status-tinted pill; each row carries
   the name, a reason badge, the last-booking sounding, and an inline two-step
   confirm reusing the device-revoke idiom. Silent (unrendered) when nothing is due. */
.due-section { margin-bottom: var(--space-3); }
.due-chip {
  display: inline-flex; align-items: center; gap: var(--space-1);
  padding: var(--space-0-5) var(--space-2); border-radius: var(--radius-pill);
  border: 1px solid var(--status-unpaid); background: var(--status-unpaid-tint);
  color: var(--status-unpaid); font: inherit; font-size: var(--text-sm); cursor: pointer;
}
.due-chip-count {
  min-width: 1.4em; padding: 0 var(--space-0-5); border-radius: var(--radius-pill);
  background: var(--status-unpaid); color: var(--bg-elevated); text-align: center;
}
.due-list { list-style: none; margin: var(--space-2) 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1); }
.due-row {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-2);
  padding: var(--space-1) 0; border-bottom: 1px solid var(--border-default);
}
.due-name { font-weight: var(--weight-ui); min-width: 0; }
.due-last { color: var(--fg-secondary); font-size: var(--text-sm); }
.due-anonymise { margin-left: auto; }
.due-confirm { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); margin-left: auto; }
.due-confirm-prompt { color: var(--fg-secondary); font-size: var(--text-sm); }

/* The shared audit glyph: a centred `currentColor` line SVG in the leading
   space-3 column. `color` drives the stroke (the SVG uses `stroke:currentColor`),
   matching the secondary-ink tint the desktop `AuditIcon` applies. */
.activity-icon {
  width: var(--space-3); display: inline-flex;
  align-items: center; justify-content: center;
  color: var(--fg-secondary);
}
.activity-icon svg { width: 17px; height: 17px; display: block; }
.activity-empty { color: var(--fg-muted); font-size: var(--text-sm); }
/* The row and its clickable <a> both pass the four parent columns through as a
   subgrid (`grid-column: 1 / -1` spans the whole template), so the four spans
   inside the <a> place into the list's shared columns and align row-to-row. The
   <a> stays a real grid box (not `display: contents`), so its border-bottom,
   padding and hover-fill still render and the whole link is clickable. */
.customer-row {
  display: grid; grid-template-columns: subgrid; grid-column: 1 / -1;
}
.customer-row a {
  display: grid; grid-template-columns: subgrid; grid-column: 1 / -1;
  align-items: center; column-gap: var(--space-2);
  padding: var(--space-1) 0; border-bottom: 1px solid var(--border-default);
  color: var(--fg-primary);
}
.customer-row a:hover { text-decoration: none; background: var(--bg-sunken); }
/* Phone and stats start at their (shared) column edge, left-aligned so each
   column's left is one x across all rows; the auto columns still size to the
   widest cell, so the three trailing columns line up as a tidy right cluster.
   The status badge hugs the right of the final column. */
.customer-phone, .customer-stats {
  color: var(--fg-secondary); font-size: var(--text-sm);
  /* Fixed tracks (see the token note above) elide long values, twin to the
     desktop MetaText's `overflow: elide`. */
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.customer-row a > .badge { justify-self: end; }

.badge {
  display: inline-flex; align-items: center; gap: var(--space-0-5);
  padding: 2px var(--space-1); border-radius: var(--radius-pill);
  font-size: var(--text-xs); font-weight: var(--weight-ui);
  background: var(--bg-sunken); color: var(--fg-secondary);
}
.badge.active { background: var(--status-paid-tint); color: var(--status-paid); }
.badge.anonymised { color: var(--fg-muted); }
.quick-links { display: flex; gap: var(--space-2); margin-top: var(--space-3); }

/* 8. Screens */
.login-form {
  margin: var(--space-6) auto 0;
  padding: var(--space-4);
  background: var(--bg-elevated); border: 1px solid var(--border-default);
  border-radius: var(--radius-md); box-shadow: var(--shadow-md);
}
.overview .metric-cards { margin-top: var(--space-2); }

/* 8b. ErrorView: the structured screen/section load-failure panel, mirroring the
   desktop's shared component (ui-shared error-view.slint): a flat bordered
   bg-elevated panel (radius-md, 24px padding) with a danger ✕ + bold title head,
   the message in fg-secondary, and a retry button. Consistent with the rest of
   the shell: no shadow, hairline border only. The request-id line and report
   button are absent because the web ApiError carries no request id. */
.error-view {
  display: flex; flex-direction: column; gap: var(--space-2);
  align-items: flex-start;
  padding: var(--space-3);
  background: var(--bg-elevated);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
}
.error-view-head { display: flex; align-items: center; gap: var(--space-1); }
.error-view-mark { color: var(--danger); font-size: var(--text-xl); line-height: 1; }
.error-view-title { color: var(--fg-primary); font-weight: var(--weight-display); font-size: var(--text-lg); }
.error-view-message { color: var(--fg-secondary); font-size: var(--text-sm); margin: 0; }

/* 9. Help-hints */
/* The view! macro emits no whitespace between a label text node and the hint,
   so the gap is the component's own (Captain: "Sessionslängd?" read conjoined). */
.help-hint { position: relative; display: inline-flex; vertical-align: middle; margin-left: var(--space-0-5); }
.help-hint-button {
  width: 16px; height: 16px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  background: transparent; color: var(--fg-muted);
  border: 1px solid var(--fg-muted); border-radius: var(--radius-pill);
  font-size: var(--text-xs); font-weight: var(--weight-ui); line-height: 1;
  transition: color var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard);
}
.help-hint-button:hover { background: transparent; color: var(--accent); border-color: var(--accent); }
.help-hint-text {
  /* Desktop: the popup opens below the trigger, growing rightward. (At the
     narrow ≤640px breakpoint it is re-pinned as a viewport-clamped fixed banner,
     see §11b, because no static left/right anchor keeps a trigger-anchored
     box on-screen for every trigger position: `left:0` overflows the right edge
     near right-side triggers, `right:0` clips left-side triggers off the left.) */
  position: absolute; left: 0; right: auto; top: calc(100% + var(--space-0-5)); z-index: 10;
  width: max-content; max-width: min(240px, calc(100vw - var(--space-4)));
  padding: var(--space-1) var(--space-2);
  background: var(--bg-elevated); color: var(--fg-primary);
  border: 1px solid var(--border-default); border-radius: var(--radius-md);
  box-shadow: var(--shadow-md);
  font-size: var(--text-sm); font-weight: var(--weight-body);
  opacity: 0; visibility: hidden; transition: opacity var(--motion-fast) var(--ease-standard);
}
.help-hint:hover .help-hint-text, .help-hint:focus-within .help-hint-text { opacity: 1; visibility: visible; }

/* 10. Sjökortet: the calendar (spec §8). Hour-sounding gutter + now-line
    arrive with the P2 markup; this is the chart-paper + Ruttblock layer. */
/* `flex-wrap: wrap` lets the Vecka/Dag/Månad toggle group drop to a second row
   whenever it cannot fit, needed at 641–~800 px where the desktop nav rail
   reappears and eats width, but wrap engages only when the row overflows so the
   toolbar still sits on one line at wide widths. `.cal-viewtoggle`'s
   `margin-left: auto` keeps the toggles right-aligned on whatever line they land. */
.cal-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2); }
.cal-range { font-weight: var(--weight-ui); }
.cal-viewtoggle { margin-left: auto; display: flex; gap: var(--space-0-5); }
.cal-viewtoggle button { background: transparent; color: var(--accent); border-color: var(--border-strong); }
.cal-viewtoggle button:hover { background: var(--bg-sunken); }
.cal-viewtoggle button.active { background: var(--accent); color: var(--fg-on-accent); border-color: var(--accent); }
/* 10a. Filter bar: customer search + status-filter chips (desktop calendar.slint
   FilterChip parity). The bar wraps like the toolbar, so it never overflows the
   ≤768 px responsive width. A chip is flat-outline + muted when inactive; when
   active it gains a soft status tint, a tint hairline, and status-ink text, the
   same status palette the Ruttblock and month chips speak. */
.cal-filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); margin-bottom: var(--space-2); }
/* The search box is the shared input, sized for a bar (not the full-width form
   field): it keeps the resting border-strong + focus-ring of every other input. */
.cal-filter-search { width: auto; min-width: 200px; margin: 0; }
.cal-filter-chip {
  height: 30px; padding: 0 12px;
  font-size: var(--text-sm); font-weight: 500; line-height: 1;
  background: var(--bg-base); color: var(--fg-muted);
  border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
}
.cal-filter-chip:hover { background: var(--bg-sunken); }
/* Active = the status bucket is shown. Tint fill + tint border + status-ink label,
   keyed to each bucket's palette (cancelled · paid · unpaid · held). */
.cal-filter-chip.active { font-weight: 600; }
.cal-filter-chip.cancelled.active { background: var(--status-cancelled-tint); color: var(--status-cancelled); border-color: var(--status-cancelled); }
.cal-filter-chip.paid.active { background: var(--status-paid-tint); color: var(--status-paid); border-color: var(--status-paid); }
.cal-filter-chip.unpaid.active { background: var(--status-unpaid-tint); color: var(--status-unpaid); border-color: var(--status-unpaid); }
.cal-filter-chip.held.active { background: var(--status-held-tint); color: var(--status-held); border-color: var(--status-held); }

/* 10b. Logg (Händelselogg), the full audit log: a period quick-filter reusing the
   calendar chip (soft-accent active, mirroring the desktop PeriodChip), and
   tap-to-expand activity rows mirroring the desktop AuditRowCard. */
.log-filterbar { margin-bottom: var(--space-2); }
.log-filterbar .cal-filter-chip.active {
  background: color-mix(in srgb, var(--accent) 22%, var(--bg-base));
  border-color: var(--accent); color: var(--accent);
}
.log-row { border-bottom: 1px solid var(--border-default); }
/* The resting line reuses the shared activity-row layout, but as a full-width
   button so the whole row is the tap target (desktop TouchArea parity). */
.log-line {
  display: flex; align-items: center; gap: var(--space-2); width: 100%;
  padding: var(--space-1) 0; margin: 0;
  background: transparent; border: 0; border-radius: 0;
  font: inherit; color: inherit; text-align: left; cursor: pointer;
}
.log-line:hover { background: var(--bg-elevated); }
/* The inline expansion (Logg-only detail), indented under the icon column so it
   reads as detail of the row above: target summary · acting admin · before/after. */
.log-detail {
  display: flex; flex-direction: column; gap: var(--space-0-5);
  padding: 0 0 var(--space-1) calc(var(--space-3) + var(--space-2));
}
.log-target { margin: 0; color: var(--fg-muted); font-size: var(--text-sm); }
.log-meta { margin: 0; color: var(--fg-secondary); font-size: var(--text-xs); word-break: break-word; }
/* The chart sits in a shallow-water well. */
.cal-grid { display: grid; grid-template-columns: auto 1fr; gap: var(--space-0-5); padding: var(--space-0-5); background: var(--bg-sunken); border: 1px solid var(--border-default); border-radius: var(--radius-md); }
.cal-grid.cal-week { grid-template-columns: auto repeat(7, 1fr); }
/* `clip`, not `hidden`: both clip the rounded corners, but `hidden` makes the
   column a scroll container, which would pin the sticky header to the column
   itself (zero travel, sticky silently dead). `clip` is not a scroll
   container, so the header sticks against the page scroll as intended. */
/* Borderless, elevated card. The grid draws the separators: `.cal-grid`'s gap
   lets the well's sunken fill show through between columns, so the gap IS the
   rule and no column carries an edge of its own. The radius is whole again now
   that no border has to stop anywhere, and `overflow: clip` keeps the sticky
   header band inside the rounded top. Mirrors the Slint week and day grids. */
.cal-col { background: var(--bg-elevated); border-radius: var(--radius-sm); overflow: clip; }
/* High contrast keeps the column edge. Dropping it in the other themes is a
   deliberate look, but here the gap alone measures 1.15:1 against the card
   where the border measured 19.27:1, and WCAG 1.4.11 asks 3:1 of a boundary
   that carries meaning. This theme exists to make that boundary explicit, so
   it opts back in rather than inheriting the quieter chrome. */
:root[data-theme="high-contrast"] .cal-col { border: 1px solid var(--border-default); }
/* One uniform header height across every column (FR-16, Captain-ruled 2026-07-05):
   the strip is a fixed `--cal-header-h` (the desktop week-grid/day-grid reference)
   so a long label ("MÅN 6") can NEVER wrap to a second line and shift its column's
   rows below; it is centred and clipped to one line instead. The head is sticky
   to the page scroll (the grid scrolls beneath it, matching the desktop's fixed
   strip); the now-line sits at z-index:2, so `z-index:3` keeps a stuck head
   painted over the track. */
.cal-colhead {
  position: sticky; top: 0; z-index: 3;
  display: flex; align-items: center; justify-content: center;
  height: var(--cal-header-h); padding: 0 var(--space-0-5);
  background: var(--bg-sunken); color: var(--fg-secondary);
  font-weight: var(--weight-display);
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
/* 24 h window (00:00–24:00) × 40 px/h = 960 px; block top/height percentages
   resolve against this. The hour count must match calendar-core's
   DAY_START_HOUR..DAY_END_HOUR. Solid 1 px hour hairlines: dashes in assets only. */
.cal-track {
  position: relative; height: 960px;
  background: repeating-linear-gradient(
    to bottom,
    transparent 0, transparent calc(100% / 24 - 1px),
    var(--border-default) calc(100% / 24 - 1px), var(--border-default) calc(100% / 24)
  );
}
/* The month lattice: Monday-start 6×7. Cells are whole-day buttons that helm
   to the Day view; chips carry the status flag, never colour alone. */
.cal-grid.cal-month { grid-template-columns: repeat(7, 1fr); }
.cal-mhead { padding: var(--space-0-5); text-align: center; background: var(--bg-sunken); color: var(--fg-secondary); }
.cal-mday {
  display: flex; flex-direction: column; align-items: stretch; gap: 2px;
  min-height: 88px; padding: var(--space-0-5);
  background: var(--bg-elevated); color: var(--fg-primary);
  border: 1px solid var(--border-default); border-radius: var(--radius-sm);
  cursor: pointer; text-align: left; font: inherit;
}
.cal-mday:hover { background: var(--bg-sunken); }
.cal-mday.other-month { background: var(--bg-base); }
.cal-mday.other-month .cal-mdate { color: var(--fg-muted); }
.cal-mday.today .cal-mdate { color: var(--accent); font-weight: 700; }
.cal-mdate { align-self: flex-end; }
.cal-mchip {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: 12px; padding: 1px 4px; border-left: 3px solid; border-radius: 1px;
}
.cal-mchip.paid { border-color: var(--status-paid); background: var(--status-paid-tint); color: var(--status-paid); }
.cal-mchip.unpaid { border-color: var(--status-unpaid); background: var(--status-unpaid-tint); color: var(--status-unpaid); }
.cal-mmore { font-size: 12px; color: var(--fg-secondary); }

/* Ruttblock: chart-crisp 2 px tile, status tint + 3 px status-ink bar.
   Status = tint + bar (+ glyph/pattern with P2 markup), never colour alone. */
.cal-block {
  position: absolute; left: 2px; right: 2px;
  border: none; border-radius: var(--radius-block);
  padding: 2px 4px 2px 7px; font-size: var(--text-xs); text-align: left;
  font-family: var(--font-mono); font-variant-numeric: tabular-nums;
  cursor: pointer; overflow: hidden; line-height: 1.2;
  transition: box-shadow var(--motion-fast) var(--ease-standard);
}
.cal-block:hover { box-shadow: var(--shadow-md); }
.cal-block:active { transform: scale(0.99); }
.cal-block.paid { background: var(--status-paid-tint); color: var(--status-paid); box-shadow: inset 3px 0 0 var(--status-paid); }
.cal-block.paid:hover { box-shadow: inset 3px 0 0 var(--status-paid), var(--shadow-md); }
.cal-block.unpaid { background: var(--status-unpaid-tint); color: var(--status-unpaid); box-shadow: inset 3px 0 0 var(--status-unpaid); }
.cal-block.unpaid:hover { box-shadow: inset 3px 0 0 var(--status-unpaid), var(--shadow-md); }
.cal-block.held { background: var(--status-held-tint); color: var(--status-held); box-shadow: inset 3px 0 0 var(--status-held); }
.cal-block.cancelled { background: var(--status-cancelled-tint); color: var(--status-cancelled); box-shadow: inset 3px 0 0 var(--status-cancelled); text-decoration: line-through; }
/* Filtered-out status / search miss: faded, not hidden (desktop booking-block
   parity, `opacity: 0.4`). The block stays clickable so a dimmed slot can
   still be opened; the now-line and today header are siblings, so their colour
   is unaffected. Opacity is not motion, so this needs no reduced-motion guard. */
.cal-block.dimmed { opacity: 0.4; }

/* 10b. Agenda fallback: under 640 px the week grid linearises to a stacked list. */
@media (max-width: 640px) {
  /* The toolbar's period-nav group and the Vecka/Dag/Månad toggle together
     overrun the viewport, clipping "Månad" past the right edge and scrolling
     the whole page. Let the bar wrap so the toggle drops to its own second row
     (resetting its `margin-left: auto` push, which would otherwise shove it off
     the wrapped line), and tighten the gap. The period-nav controls may shrink
     but never clip. The >640 px single-row layout is untouched. */
  .cal-toolbar { flex-wrap: wrap; gap: var(--space-1); }
  .cal-viewtoggle { margin-left: 0; flex-wrap: wrap; }
  .cal-range { min-width: 0; }
  .cal-grid.cal-week { grid-template-columns: 1fr; }
  .cal-track { height: auto; min-height: 48px; background: none; }
  /* The FR-11 lane geometry arrives as inline left/width; in the stacked agenda
     layout blocks are position:static, so lanes are meaningless; force the
     full-width stack back over the inline style. */
  .cal-block { position: static; display: block; width: 100% !important; left: auto !important; margin: 2px 0; }
}

/* 11. Responsive: rail collapses to a pinned BOTTOM bar under 640 px, matching
   the desktop's compact bottom bar (nav-chrome.slint §compact): equal-flex items,
   icon over a small label, active tint, thumb-reachable. The Captain ruled
   (2026-06-17) to converge on the bottom bar; desktop was already correct. */
@media (max-width: 640px) {
  /* `--nav-bar-h` fixes the bar height so the content region can reserve exactly
     that much bottom space and nothing hides behind the fixed bar. */
  :root { --nav-bar-h: 56px; }
  .app-shell { flex-direction: column; }
  .app-nav {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
    flex-direction: row; width: auto; height: var(--nav-bar-h); gap: var(--space-0-5);
    border-right: none; border-top: 1px solid var(--border-default);
    padding: var(--space-0-5);
    background: var(--bg-elevated);
  }
  /* Equal-flex items, icon stacked over a small centred label (desktop bottom-bar
     idiom); drop the fixed rail item height so the column layout sizes naturally. */
  .app-nav a {
    flex: 1; height: auto; min-width: 0;
    flex-direction: column; justify-content: center; gap: 2px;
    padding: var(--space-0-5);
  }
  .app-nav a .nav-label { font-size: var(--text-xs); }
  /* The travelling marker is a wide-rail (vertical) device; the bottom bar hides
     it and marks the active item with a magenta top indicator instead. */
  .nav-marker { display: none; }
  /* Native bottom-bar active cue, consistent with the rest of the shell: drop the
     wide-rail grey `bg-sunken` tint box (which looked like a stray desktop-rail
     treatment) and signal the active item with a 2px magenta (`--signal`) top
     edge, the horizontal echo of the rail's vertical travelling marker, plus
     its accent label and lit icon-detail overlay. No hover box on touch. */
  .app-nav a:hover { background: transparent; }
  .app-nav a[aria-current="page"] {
    background: transparent;
    box-shadow: inset 0 2px 0 var(--signal);
  }
  /* Reserve the bar's height so the last content row clears the fixed bar. */
  .app-main { padding: var(--space-2); padding-bottom: calc(var(--nav-bar-h) + var(--space-2)); }
  .search-form { flex-direction: column; align-items: stretch; }
  /* Unwind the desktop shared-column grids: the lists become plain blocks so
     rows stack, and each row reverts from `subgrid` to the dense flex/column
     reflow below. (`justify-self` is inert in flex, so the desktop right-cluster
     hints simply do nothing here.) */
  .customer-list, .device-list { display: block; }
  .customer-row { display: block; }
  /* The flat name-vs-metadata rows had no room for both sides at phone width, so
     the metadata used to overlap the wrapping name. Booking / activity / device /
     feedback rows restack to a full column (their leading field isn't a plain name,
     and the device and feedback rows carry several fields, so a column reads
     clearest; without this the five-child feedback row squeezes its body column
     to nothing at phone width). */
  .booking-row, .activity-row, .device-row, .feedback-row {
    display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-0-5);
  }
  /* Stacked feedback bodies are no longer a one-line cell: let the excerpt wrap. */
  .feedback-body { white-space: normal; }
  /* Customer rows are denser: the name takes its own full-width line, then the
     compact metadata (…last4 · stats · status) flows on the line below, wrapping
     if needed, collision-proof but tighter than a per-field stack, so more
     customers fit per screen. */
  .customer-row a {
    display: flex; flex-wrap: wrap; align-items: baseline; row-gap: var(--space-0-5);
  }
  .customer-name { flex-basis: 100%; }
}

/* §11b: help-hint popup on narrow/tablet viewports (≤768px). A trigger-anchored
   box can't stay on screen for every trigger position (left:0 overflows the right
   edge near right-side triggers; right:0 clips left-side ones off the left), and
   even a hidden one bumps the document scroll width. So re-pin it as a fixed,
   viewport-clamped banner. `position: fixed` removes it from the document scroll
   width entirely (no sideways scroll, hidden or shown). `--nav-bar-h` resolves to
   56px under the ≤640 bottom bar and falls back to 0 in the 641–768 band (no bar),
   so the banner clears the bar when present and sits at the viewport bottom otherwise. */
@media (max-width: 768px) {
  .help-hint-text {
    position: fixed; left: var(--space-2); right: var(--space-2);
    top: auto; bottom: calc(var(--nav-bar-h, 0px) + var(--space-2));
    width: auto; max-width: none;
  }
}

/* 12. Relingen iconography: currentColor base + magenta detail overlay that
    lights on the active item (signal's second structural use, spec §2). */
.app-nav a { display: flex; align-items: center; gap: var(--space-1); }
.nav-icon { position: relative; width: 20px; height: 20px; flex-shrink: 0; }
.nav-icon svg { width: 20px; height: 20px; display: block; }
.nav-icon-base, .nav-icon-detail { position: absolute; inset: 0; }
.nav-icon-detail { color: var(--signal); opacity: 0; transition: opacity var(--motion-fast) var(--ease-standard); }
.app-nav a[aria-current="page"] .nav-icon-detail { opacity: 1; }

/* 13. Kasta loss (lite): the workspace rises once per mount; rail items
    stagger 30 ms (spec §6.1; the route-draw hero joins with onboarding P4). */
@keyframes rise-in { from { opacity: 0; transform: translateY(12px); } }
.app-main { animation: rise-in var(--motion-base) var(--ease-standard) backwards; }
/* `:nth-of-type` (not `:nth-child`): the `.nav-marker` span is the rail's first
   child, so per-item delays must count only the `<a>` items. */
.app-nav a { animation: rise-in var(--motion-base) var(--ease-standard) backwards; }
.app-nav a:nth-of-type(1) { animation-delay: 30ms; }
.app-nav a:nth-of-type(2) { animation-delay: 60ms; }
.app-nav a:nth-of-type(3) { animation-delay: 90ms; }
.app-nav a:nth-of-type(4) { animation-delay: 120ms; }
.app-nav a:nth-of-type(5) { animation-delay: 150ms; }

/* Login lockup + card entry. */
.login-brand {
  display: flex; align-items: center; justify-content: center; gap: var(--space-1);
  margin-top: var(--space-8); color: var(--accent);
}
.login-mark svg { width: 28px; height: 28px; display: block; }
.login-wordmark { font-family: var(--font-display); font-weight: var(--weight-display); font-size: var(--text-xl); color: var(--fg-primary); }
.login-title { margin-bottom: 0; }
.login-form { margin-top: var(--space-3); animation: rise-in var(--motion-slow) var(--ease-standard) backwards; }

/* 14. Signalen, the toast (spec §8): a logbook entry surfacing on the inverse
    card, severity flag, magenta lifetime hairline. The hairline's animation IS
    the dismissal clock; hover pauses both. */
.signalen-stack {
  position: fixed; top: var(--space-2); left: 50%; transform: translateX(-50%);
  display: flex; flex-direction: column; gap: var(--space-1); z-index: 100;
}
.signalen {
  position: relative; overflow: hidden;
  display: flex; align-items: center; gap: var(--space-1);
  min-width: 260px; max-width: 420px;
  padding: var(--space-1) var(--space-2) calc(var(--space-1) + 2px);
  background: var(--bg-inverse); color: var(--fg-inverse);
  border: 1px solid var(--border-strong); border-radius: var(--radius-md);
  box-shadow: var(--shadow-md);
  animation: signal-in var(--motion-base) var(--ease-standard) backwards;
}
@keyframes signal-in { from { opacity: 0; transform: translateY(12px); } }
.signalen-flag { flex-shrink: 0; }
.signalen-flag svg { width: 14px; height: 14px; display: block; }
/* Severity lifted towards fg-inverse: raw light-theme severity inks sit
   near-invisible on the ink-dark card. */
.signalen.danger .signalen-flag { color: color-mix(in srgb, var(--danger) 50%, var(--fg-inverse)); }
.signalen.info .signalen-flag { color: color-mix(in srgb, var(--info) 50%, var(--fg-inverse)); }
.signalen.success .signalen-flag { color: color-mix(in srgb, var(--success) 50%, var(--fg-inverse)); }
.signalen.warning .signalen-flag { color: color-mix(in srgb, var(--warning) 50%, var(--fg-inverse)); }
/* FR-28 §7: primary feedback surface at base size, medium weight. */
.signalen-text { flex: 1; font-size: var(--text-base); font-weight: 500; }
.signalen-close {
  background: transparent; border: none; padding: 0 var(--space-0-5);
  color: var(--fg-inverse); opacity: .7; font-size: var(--text-sm);
}
.signalen-close:hover { background: transparent; opacity: 1; }
.signalen-life {
  position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
  background: var(--signal); transform-origin: left;
  animation: signal-life 5s linear forwards;
}
.signalen:hover .signalen-life { animation-play-state: paused; }
@keyframes signal-life { to { transform: scaleX(0); } }
/* High contrast: white card + 2 px severity border, no magenta (spec §2). */
:root[data-theme="high-contrast"] .signalen { border-width: 2px; }
:root[data-theme="high-contrast"] .signalen.danger { border-color: var(--danger); }
:root[data-theme="high-contrast"] .signalen.info { border-color: var(--info); }
:root[data-theme="high-contrast"] .signalen.success { border-color: var(--success); }
:root[data-theme="high-contrast"] .signalen.warning { border-color: var(--warning); }
:root[data-theme="high-contrast"] .signalen-life { background: var(--fg-primary); }

/* 15. Sjökortet markup layer: gutter soundings, now-line, today-kite,
    Ruttblock rows, Öppet vatten, view-switch motion (spec §6/§8). */
.cal-gutter { display: flex; flex-direction: column; width: 28px; }
/* The gutter spacer shares `.cal-colhead`, so it now inherits the fixed
   `--cal-header-h` height (FR-16). The "00" sounding starts exactly where the
   tracks start with no content. It stays invisible; the old `::before` nbsp
   height hack is gone now that the height is fixed rather than text-derived. */
.cal-gutter-head { visibility: hidden; }
.cal-gutter-track { position: relative; flex: 1; }
.cal-hour {
  position: absolute; right: 2px; transform: translateY(-50%);
  font-family: var(--font-mono); font-size: var(--text-xs);
  font-weight: var(--weight-display);
  color: var(--fg-secondary); line-height: var(--leading-mono);
}
.cal-hour:first-child { transform: none; }
/* The now-line: the chart's only warm line (signal budget, spec §2). The
   gutter arrowhead marks it; high contrast swaps to a 3 px ink rule. */
.cal-now {
  position: absolute; left: 0; right: 0; height: 0;
  border-top: 1.5px solid var(--signal); z-index: 2; pointer-events: none;
}
.cal-now::before {
  content: ""; position: absolute; left: -2px; top: -4.5px;
  border-left: 5px solid var(--signal);
  border-top: 4px solid transparent; border-bottom: 4px solid transparent;
}
:root[data-theme="high-contrast"] .cal-now { border-top-width: 3px; border-top-color: var(--fg-primary); }
:root[data-theme="high-contrast"] .cal-now::before { border-left-color: var(--fg-primary); }
/* Today's column header carries the north-kite. */
.cal-colhead.today { position: relative; color: var(--fg-primary); font-weight: var(--weight-display); }
.cal-colhead.today::before {
  content: ""; display: inline-block; margin-right: 4px;
  border-left: 4px solid transparent; border-right: 4px solid transparent;
  border-bottom: 8px solid var(--signal); vertical-align: 1px;
}
:root[data-theme="high-contrast"] .cal-colhead.today::before { border-bottom-color: var(--fg-primary); }
/* Ruttblock rows. */
.cal-block { display: flex; flex-direction: column; gap: 1px; }
.rb-time { display: flex; align-items: center; gap: 4px; font-weight: var(--weight-ui); }
.rb-flag { margin-left: auto; flex-shrink: 0; }
.rb-flag svg { width: 12px; height: 12px; display: block; }
.rb-name { font-family: var(--font-ui); font-weight: var(--weight-ui); color: var(--fg-primary); }
.rb-phone { color: var(--fg-secondary); }
/* Sea-state: the density-tier caption above a decorative water strip.

   The strip is a canvas, and nothing here draws on it. `seaband` builds the
   geometry, ui-tokens' `sea_canvas` finds the canvas by its `data-sea`
   attribute and paints it frame by frame, and the desktop paints the same scene
   through its own rasteriser. What this file settles is the two things the
   painter reads back off the element.

   Nothing here decides the water's colours, and that is deliberate. The banner
   carries one of sixteen palettes, four hours by four seasons, chosen by the
   clock and held in ui-tokens; the theme the viewer picked decides the caption
   above the strip and nothing inside it. So a December dusk warms the water
   toward amber on a page that is still the user's light or dark, and no text
   anywhere shifts by a shade. It is safe only because the strip is decorative,
   `aria-hidden`, carries no text, and is dropped outright under high contrast.

   Reduced motion: the §2 `* { animation: none }` contract cannot reach inside a
   canvas, so the painter honours the preference itself, from the media query and
   the `[data-reduce-motion]` toggle alike. It paints `seaband`'s chosen still
   frame, the moment in the loop whose fleet is best spread across the band,
   rather than pausing the water wherever it happened to be.

   High contrast: the strip is dropped (water texture off, per spec); the
   caption above it still shows. The painter reads a zero-width canvas and skips
   it, so a hidden strip costs nothing per frame. */
.sea-caption { text-align: center; margin-bottom: var(--space-1); }
.sea-title {
  font-family: var(--font-display); font-weight: var(--weight-display);
  font-size: var(--text-lg); margin: 0; padding-top: var(--space-2);
  animation: rise-in var(--motion-slow) var(--ease-standard) backwards;
}
.sea-text {
  color: var(--fg-secondary); margin: var(--space-0-5) 0 0;
  animation: rise-in var(--motion-slow) var(--ease-standard) backwards;
}

/* The caption's wave glyph, standing where a dash used to separate the sea-state
   phrase from the booking count. It is generated from the same surface as the
   water below it (`hamnkapten-sea-wave`), carries its own px size in the SVG
   attributes, matching the fixed `--text-*` scale the rest of the type uses, and
   strokes in `currentColor` so it takes the caption's ink in every theme.
   `middle` sits it on the text's centre; the baseline would hang it low. */
.sea-wave-glyph { vertical-align: middle; }
.sea-banner {
  position: relative; height: var(--sea-strip-h); overflow: hidden;
  margin-bottom: var(--space-2);
}
:root[data-theme="high-contrast"] .sea-banner { display: none; }
/* The canvas fills the strip. This is its CSS size only: the painter sizes the
   backing store in device pixels, so the band stays crisp on a 2x display
   instead of being an upscaled CSS-sized buffer. */
.sea-canvas { display: block; width: 100%; height: 100%; }
/* View-switch / period motion: the chart slides in once per render. */
@keyframes cal-slide-in { from { opacity: 0; transform: translateX(12px); } }
.cal-anim { animation: cal-slide-in 180ms var(--ease-standard) backwards; }
/* Agenda: no gutter, no now-line. */
@media (max-width: 640px) {
  .cal-grid, .cal-grid.cal-week { grid-template-columns: 1fr; }
  .cal-gutter, .cal-now { display: none; }
  /* The Månad lattice is the lone calendar view that cannot linearise: a
     7-column Mån–Sön week is its whole point. Squashing it to 1fr (the rule
     above) or letting its ~580 px natural width spill past 390 px both fail.
     The Captain's ruling: contain the overflow to the GRID box. Restore the
     seven columns (the `minmax(80px, 1fr)` floor keeps each cell legible) and
     let them overflow the grid's parent-stretched width, so `overflow-x: auto`
     scrolls the grid horizontally inside itself and the page never h-scrolls.
     NB: do NOT add `min-width: min-content` here; it sizes the grid box to its
     content (~592px) and pushes the overflow up to the document. The week/day
     grids (1fr above) are untouched and gain no scroll. */
  .cal-grid.cal-month {
    grid-template-columns: repeat(7, minmax(80px, 1fr));
    overflow-x: auto;
  }
}

/* 16. Loggkortet: the booking side panel (spec §6.8/§7). Slides in over the
    chart; the chart never moves. Scrim click closes. */
.loggkort-scrim {
  position: fixed; inset: 0; z-index: 40;
  background: rgba(22, 48, 62, 0.32);
  animation: scrim-in var(--motion-base) var(--ease-standard) backwards;
}
@keyframes scrim-in { from { opacity: 0; } }
.loggkort {
  position: fixed; top: 0; right: 0; bottom: 0; z-index: 41;
  width: min(360px, 92vw);
  background: var(--bg-elevated);
  border-left: 1px solid var(--border-strong);
  box-shadow: var(--shadow-md);
  animation: loggkort-in 220ms var(--ease-standard) backwards;
  overflow-y: auto;
}
@keyframes loggkort-in { from { transform: translateX(320px); opacity: 0.4; } }
.loggkort-body { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-3); }
.loggkort-title { font-family: var(--font-display); margin: 0; }
.loggkort-when { margin: 0; font-weight: var(--weight-ui); }
/* Label/value rows, matched to the desktop DetailRow in booking-detail-panel.slint
   on every axis but one: space-1 between label and value, space-2 between rows,
   body text-base, fg-muted on the label. A grid rather than the wrapping flex used
   by .customer-fields, because the panel is only 360px wide and a wrapped value
   would stack again (FR-30). The colon is set here so the shared i18n strings stay
   bare for their other uses.

   The label column is the one deliberate difference: 104px here against the
   desktop's 144px. The two panels do not render the same fields. This one has four
   rows and folds the settled date and reference into the payment line (see
   `paid_meta` in booking_panel.rs), where the desktop breaks Referens and
   Swish-meddelande out into rows of their own. So the widest label differs: 101.9px
   for "Booking code:" here, 143.7px for "Swish-meddelande:" there, both measured in
   Plex Sans 400 at 16px. Matching the desktop's 144px would leave ~42px of dead
   column: the panel gives 312px of content, less ~16px once its own scrollbar shows
   (measured 104 + 8 + 184 = 296). If this panel ever grows those two rows, this goes
   back to 144px. FR-33 added the Swish message as a full-width `.stacked` pair rather
   than a fifth grid row, which is what keeps that from happening for this one field. */
.loggkort-fields { display: grid; grid-template-columns: 104px 1fr; gap: var(--space-2) var(--space-1); align-items: center; margin: 0; }
.loggkort-fields dt { color: var(--fg-muted); }
.loggkort-fields dt::after { content: ":"; }
.loggkort-fields dd { margin: 0; }
/* FR-33: a full-width label-over-value pair, for free text whose label does not
   fit the 104px column. The 16px row gap would otherwise leave the label sitting
   equidistant between the row above and its own value (measured), so the value is
   pulled up to halve the gap under its label. */
.loggkort-fields .stacked { grid-column: 1 / -1; }
.loggkort-fields dd.stacked { margin-top: calc(-1 * var(--space-1)); }
.loggkort-note { color: var(--fg-secondary); font-size: var(--text-sm); margin: 0; }
.loggkort-actions { display: flex; justify-content: flex-end; gap: var(--space-1); margin-top: var(--space-1); }
/* The track invites creation. */
.cal-track { cursor: cell; }

/* 17. Rorgängaren: the top bar. */
.app-frame { display: flex; flex-direction: column; min-height: 100vh; }
.app-frame .app-shell { flex: 1; min-height: 0; }
.topbar {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-1) var(--space-2);
  background: var(--bg-elevated);
  border-bottom: 1px solid var(--border-default);
}
.topbar-brand { display: flex; align-items: center; gap: var(--space-1); color: var(--accent); }
.topbar-brand:hover { text-decoration: none; }
.topbar-mark svg { width: 22px; height: 22px; display: block; }
/* The wordmark: one of the display voice's six places (login lockup shares it). */
.topbar-wordmark { font-family: var(--font-display); font-weight: var(--weight-display); font-size: var(--text-lg); color: var(--fg-primary); }
.topbar-week {
  padding: 2px var(--space-1); border: 1px solid var(--border-default);
  border-radius: var(--radius-sm); color: var(--fg-secondary); font-size: var(--text-sm);
}
.topbar-spacer { flex: 1; }
.topbar select { width: auto; margin-top: 0; }
@media (max-width: 640px) {
  .topbar-wordmark { display: none; }
  /* At phone width the mark + week chip + theme select + "Logga ut" exceed the
     viewport, so the button used to wrap and clip past the right edge. Tighten
     the bar (smaller gap, trimmed padding, narrower select) and forbid the
     actionable controls from shrinking or wrapping their own labels; if it still
     cannot fit on one row, the bar wraps gracefully to a second row rather than
     overflowing. Nothing is cut off. */
  .topbar { gap: var(--space-1); padding: var(--space-1); flex-wrap: wrap; }
  .topbar select { max-width: 8rem; }
  .topbar select, .topbar .btn-secondary { flex-shrink: 0; white-space: nowrap; }
  .topbar .btn-secondary { padding: var(--space-1) var(--space-2); }
}

/* 18. Ta rodret: the first-run walkthrough (spec §9). */
.onb {
  position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
  z-index: 60; width: min(440px, 92vw);
  background: var(--bg-elevated); border: 1px solid var(--border-strong);
  border-radius: var(--radius-md); box-shadow: var(--shadow-md);
  padding: var(--space-3);
  animation: rise-in var(--motion-base) var(--ease-standard) backwards;
}
.onb-scrim { z-index: 59; }
.onb-stamp { position: absolute; top: var(--space-2); right: var(--space-2); color: var(--signal); opacity: .8; }
.onb-stamp svg { width: 28px; height: 28px; display: block; }
/* Onboarding headlines: one of the display voice's six places. */
.onb-title { font-family: var(--font-display); margin: 0 0 var(--space-1); }
.onb-themes { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1); margin-top: var(--space-1); }
.onb-theme {
  display: flex; align-items: center; gap: var(--space-1);
  background: var(--bg-base); color: var(--fg-primary);
  border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
  padding: var(--space-1);
}
.onb-theme:hover { background: var(--bg-sunken); }
.onb-theme.picked { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
/* Theme tiles: a miniature chart in the CANDIDATE theme's own tokens, scoped
   via the nested data-theme selector the token generator emits; no hex ever
   leaves the token source. The tile IS the theme's bg (visible against any
   panel thanks to the border-strong edge); fg/accent bars and the signal
   now-line are the marks. System = diagonal light/dark split. */
.onb-swatch {
  position: relative; display: block; flex: none;
  width: 56px; height: 36px; overflow: hidden;
  background: var(--bg-base);
  border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
}
.onb-swatch span { position: absolute; border-radius: 1px; }
.onb-swatch .sw-fg { left: 6px; top: 7px; width: 28px; height: 4px; background: var(--fg-primary); }
.onb-swatch .sw-accent { left: 6px; top: 17px; width: 18px; height: 8px; background: var(--accent); }
.onb-swatch .sw-signal { left: 0; right: 0; bottom: 5px; height: 2px; background: var(--signal); }
.onb-swatch-stack { position: relative; display: block; flex: none; width: 56px; height: 36px; }
.onb-swatch-stack .onb-swatch { position: absolute; inset: 0; }
.onb-swatch-stack .onb-swatch:last-child { clip-path: polygon(100% 0, 100% 100%, 0 100%); }
.onb-legend { display: grid; grid-template-columns: auto 1fr; gap: var(--space-0-5) var(--space-1); margin: var(--space-1) 0 0; }
.onb-legend dt { display: flex; align-items: center; }
.onb-legend dd { margin: 0; }
.legend-flag svg { width: 14px; height: 14px; display: block; }
.legend-flag.paid { color: var(--status-paid); }
.legend-flag.unpaid { color: var(--status-unpaid); }
.legend-flag.held { color: var(--status-held); }
.legend-flag.cancelled { color: var(--status-cancelled); }
.onb-actions { display: flex; align-items: center; gap: var(--space-1); margin-top: var(--space-3); }
.onb-dots { color: var(--fg-secondary); margin-left: auto; }

/* 19. Elevation-as-light (decision 8, Captain 2026-06-10). The dark theme
   draws elevation as a 1px lighter top edge plus a faint top-down glow,
   painted as a background gradient INSIDE the surface (never via the border,
   so each surface's own edge treatment is untouched). The two tokens are
   fully transparent in light and high-contrast, so this rule is inert there.
   Shadows stay declared everywhere; on the near-black dark backgrounds they
   were always invisible, which is what this treatment replaces. */
.metric-card, .settings-section, .customer-detail, .login-form,
.help-hint-text, .signalen, .loggkort, .onb {
  background-image: linear-gradient(
    180deg,
    var(--elevation-edge) 0,
    var(--elevation-edge) 1px,
    var(--elevation-glow) 1px,
    transparent 48px
  );
}
