/* =============================================================================
   app.css — app shell of the KNK dashboard.

   Structure: tokens → basics → layout → sidebar → topbar → dropdowns →
   tables → pills/page building blocks → mobile → error page.

   Complements Basecoat (CDN), does not replace it: buttons, inputs, selects, cards and
   toasts come from there. Only what Basecoat does not know lives here — the shell.

   NAMING RULE (please stick to it, it has already cost us two layout bugs):
   An own shell class must NEVER have the same name as a Basecoat component.
   "app.css loads later, so we win" is only true per SINGLE property
   — and only because Basecoat lives entirely in @layer. Everything Basecoat sets and
   we do not still comes through. Exactly this is what killed:

     * `.sidebar` — Basecoat attaches `.sidebar + *  { margin-left: 16rem }` to it.
       Our `.app-main` is exactly that sibling element and was therefore pushed right by
       a second sidebar width (we set no margin-left, so there was nothing to
       override). On top of that, on mobile `inset: 0` and
       `display: none` until Basecoat's JS has initialised the sidebar.
     * `.empty` — in Basecoat the empty-state block: `flex: 1; padding: 3rem`.
       Our 2rem tile in the org switcher became a 145x98 box because of it.

   That is why the shell classes here are called `.sb`, `.sb-item`, `.sb-avatar`,
   `.tile.blank` and `.pill` instead of `.sidebar`, `.item`, `.avatar`, `.tile.empty`
   and `.badge`. Taken in Basecoat 1.0.2 are, among others: accordion, alert,
   alert-dialog, avatar, badge, breadcrumb, btn, card, chart, combobox, command,
   dialog, drawer, dropdown-menu, empty, field, fieldset, input, item, kbd, label,
   popover, progress, scrollbar, select, sidebar, skeleton, table, tabs, textarea,
   toast, toaster. Of these, only the real components are used on purpose
   (btn, card, input, label, select, field, alert, dialog, toast*, dark).

   SECOND CLASS OF BUGS, same cause: Basecoat brings Tailwind's **Preflight**
   along, and that sets rules on ELEMENTS, not on classes. The most consequential one:

       img, svg, video, canvas, audio, iframe, embed, object { display: block }

   So an `<svg>` is a block. `text-align: center` NO longer centres it —
   that takes `margin: 0 auto` (see `.tbl-empty svg`). Whoever centres something
   here that contains an icon has to keep this in mind.
   ========================================================================== */

/* --- Tokens ---------------------------------------------------------------
   shadcn neutral in oklch. The same names Basecoat uses, so that its
   components automatically run with our theme. `.dark` hangs on the
   <html> element and is the default (base.html sets it server-side). */
:root {
  --top-h: 3.5rem;   /* height of the topbar — one source for `.top` and `.dp-side` */
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --sidebar: oklch(0.985 0 0);
  --sidebar-foreground: oklch(0.145 0 0);
  --sidebar-accent: oklch(0.97 0 0);
  --sidebar-border: oklch(0.922 0 0);
  /* Status colours. They are NOT the same in the light and the dark theme and
     must not be: here they are a TEXT COLOUR on a light background
     (`.pill` colours the text and only tints the background at 12 %), below in
     `.dark` on a dark one instead. Previously the slightly darkened
     dark values were here, and that was not enough — measured in the browser, text on the
     pill background:

         --warn  L 0.78 -> contrast 1.87    „gesperrt" was practically invisible
         --ok    L 0.68 -> contrast 2.41
         --info  L 0.62 -> contrast 3.20
         --err   L 0.62 -> contrast 3.40

     The lightness values here are the highest that stay above 4.5:1 on the pill AND on
     plain white (WCAG AA for text). Whoever raises them makes the
     pills unreadable in the light theme again. */
  --ok: oklch(0.50 0.16 150);
  --warn: oklch(0.52 0.15 80);
  --err: oklch(0.53 0.21 25);
  --info: oklch(0.52 0.18 255);
  --radius: 0.5rem;
}
.dark {
  --background: oklch(0.10 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.145 0 0);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.165 0 0);
  --popover-foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --secondary: oklch(0.205 0 0);
  --muted: oklch(0.205 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: oklch(0.22 0 0);
  --accent-foreground: oklch(0.985 0 0);
  --border: oklch(1 0 0 / 9%);
  --input: oklch(1 0 0 / 12%);
  --ring: oklch(0.556 0 0);
  --sidebar: oklch(0.145 0 0);
  --sidebar-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.22 0 0);
  --sidebar-border: oklch(1 0 0 / 9%);
  --ok: oklch(0.72 0.16 150);
  --warn: oklch(0.82 0.15 80);
  --err: oklch(0.68 0.20 25);
  --info: oklch(0.68 0.16 255);
}

/* --- Basics -------------------------------------------------------------- */
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--background); color: var(--foreground); }
body {
  font-family: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-size: 14px; -webkit-font-smoothing: antialiased;
}
/* A link in the dashboard is never "browser blue" — but a BUTTON is not a link,
   it is a Basecoat component, and there the text colour belongs to the
   variant (`primary` colours to `--primary-foreground`, `destructive` red).

   The `:not(.btn)` is mandatory, not polish. Third edition of the same
   trap as in the header of this file, this time via an ELEMENT instead of a
   class name: `a { color: inherit }` stood here without exception, app.css lives
   OUTSIDE any @layer, and unlayered beats EVERY rule from a layer —
   specificity plays no role there. For an `<a class="btn">` Chrome lists
   three rules in this order:

       @layer base        a                          color: inherit   (Preflight)
       @layer components  .btn:not([data-variant])   color: var(--color-primary-foreground)
       (no layer)         a                          color: inherit   ← won

   Result: every button that is an `<a>` and carries no variant inherited the
   text colour of the page — in the dark theme WHITE on the white primary button, in the
   light one BLACK on the black one. Invisible, both. A `<button class="btn">`
   next to it was correct, because the `a` rule does not hit it. Affected were
   „Neu" on every descriptor list, „Bearbeiten" on the customer page, the
   „Zur Liste" buttons of the no_org pages and the error page.

   `outline` and `ghost` deliberately do not colour in Basecoat — they keep
   inheriting, because Preflight sets the same `inherit` for `a` in `@layer base`,
   and that is only beaten by what is in NO layer. */
a:not(.btn) { color: inherit; }
svg.ic, .ic svg { width: 1rem; height: 1rem; stroke-width: 2; }
svg.ic-sm, .ic-sm svg { width: .875rem; height: .875rem; stroke-width: 2; }
/* The <i data-lucide> placeholder takes the box of the SVG that replaces it (deferred lucide): no layout shift when
   the icons render — e.g. the sidebar's restored scroll position stays exact (Luca 01.11.). */
i.ic[data-lucide] { display: inline-block; flex-shrink: 0; width: 1rem; height: 1rem; }
i.ic-sm[data-lucide] { display: inline-block; flex-shrink: 0; width: .875rem; height: .875rem; }
.hide-sm { }
@media (max-width: 767px) { .hide-sm { display: none !important; } }

/* --- Layout ---------------------------------------------------------------
   The one layout decision everything else follows from: `.wrap` is exactly
   as tall as the viewport and NEVER scrolls. That keeps sidebar and topbar
   in place without `position: fixed` — only `.content` and `.sb-content` scroll.
   Fixed would be the alternative, but costs reflow chaos with dropdowns and
   causes the well-known jumps on iOS when the address bar slides in and
   out. 100dvh instead of 100vh for exactly the same reason. */
.wrap { display: flex; height: 100vh; height: 100dvh; overflow: hidden; }
.app-main { flex: 1; min-width: 0; height: 100%; display: flex; flex-direction: column; overflow: hidden; }
.content { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.backdrop { display: none; }

/* --- Sidebar --------------------------------------------------------------
   Three zones: header (org), scrolling navigation, footer (user). Only the middle
   scrolls — the org switcher at the top and the account at the bottom always stay reachable.
   Collapsing animates via `width`; the state hangs as data-state on the
   <html> element, so that the inline script in the <head> can set it without flicker. */
.sb {
  width: 16rem; flex-shrink: 0; height: 100%;
  display: flex; flex-direction: column;
  background: var(--sidebar); color: var(--sidebar-foreground);
  border-right: 1px solid var(--sidebar-border);
  transition: width .22s cubic-bezier(.4, 0, .2, 1); will-change: width;
}
[data-state="collapsed"] .sb { width: 3.25rem; }

.sb-header, .sb-footer { padding: .5rem; flex-shrink: 0; }
.sb-footer { border-top: 1px solid var(--sidebar-border); }
.sb-content {
  flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden;
  overflow-anchor: none;  /* restored scroll position stays exact while icons render (Luca 01.11.) */
  overscroll-behavior: contain;
  scrollbar-width: thin; scrollbar-color: transparent transparent;
}
/* Scrollbar only on hover — otherwise it visually cuts through the sidebar. */
.sb-content:hover, .sb-content:focus-within {
  scrollbar-color: color-mix(in oklch, var(--sidebar-foreground) 22%, transparent) transparent;
}
.sb-content::-webkit-scrollbar { width: 6px; }
.sb-content::-webkit-scrollbar-track { background: transparent; }
.sb-content::-webkit-scrollbar-thumb { background: transparent; border-radius: 999px; }
.sb-content:hover::-webkit-scrollbar-thumb { background: color-mix(in oklch, var(--sidebar-foreground) 20%, transparent); }
[data-state="collapsed"] .sb-content { scrollbar-width: none; }
[data-state="collapsed"] .sb-content::-webkit-scrollbar { display: none; }

.grp { padding: .5rem; display: flex; flex-direction: column; gap: .125rem; }
.grp.secondary { border-top: 1px solid var(--sidebar-border); }
.grp-label {
  display: flex; align-items: center; height: 2rem; padding: 0 .5rem; overflow: hidden;
  font-size: .75rem; font-weight: 500;
  color: color-mix(in oklch, var(--sidebar-foreground) 65%, transparent);
  transition: height .22s cubic-bezier(.4, 0, .2, 1), opacity .16s ease;
}

.sb-item {
  position: relative; display: flex; align-items: center; gap: .5rem; width: 100%;
  height: 2rem; padding: .5rem; border: 0; border-radius: .375rem;
  background: transparent; color: var(--sidebar-foreground);
  font: inherit; font-size: .875rem; text-align: left; text-decoration: none; cursor: pointer;
  transition: background .12s ease, color .12s ease;
}
.sb-item:hover { background: var(--sidebar-accent); }
.sb-item.active { background: var(--sidebar-accent); font-weight: 500; }
.sb-item.static { cursor: default; }
.sb-item .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sb-item .chev { margin-left: auto; color: currentColor; transition: transform .2s ease; }
details[open] > summary .chev { transform: rotate(90deg); }
summary { list-style: none; }
summary::-webkit-details-marker { display: none; }

/* Locked (`needs_org` without a selected org): visible, but neither clickable nor
   focusable — a link that is guaranteed to lead to an error page is no link. */
.sb-item.disabled, .sub .disabled {
  opacity: .45; cursor: not-allowed; pointer-events: auto; background: transparent !important;
}
.sb-item.disabled .chev { opacity: .7; }
/* A locked child of an open submenu carries the lock too (ADR-090 addendum 30.10., Tebex → Käufer). */
.sub .disabled .chev { margin-left: auto; opacity: .7; }

/* Submenu animation: <details> does not animate out of the box. */
@supports (interpolate-size: allow-keywords) { :root { interpolate-size: allow-keywords; } }
details::details-content {
  height: 0; overflow: hidden;
  transition: height .22s cubic-bezier(.4, 0, .2, 1), content-visibility .22s allow-discrete;
}
details[open]::details-content { height: auto; }

.sub {
  margin-left: .875rem; padding: .125rem 0 .125rem .625rem; translate: 1px 0;
  border-left: 1px solid var(--sidebar-border);
  display: flex; flex-direction: column; gap: .125rem;
}
.sub a, .sub .disabled {
  display: flex; align-items: center; height: 1.75rem; padding: 0 .5rem; border-radius: .375rem;
  font-size: .8125rem; text-decoration: none;
  color: color-mix(in oklch, var(--sidebar-foreground) 88%, transparent);
  transition: background .12s ease;
}
.sub a:hover { background: var(--sidebar-accent); }
.sub a.active { background: var(--sidebar-accent); font-weight: 500; color: var(--sidebar-foreground); }

/* Header/footer row: taller tile with avatar + two lines of text. */
.sb-item.lg { height: 3rem; }
/* `color` is set here explicitly, although inheriting would also be correct in the sidebar:
   the same avatar hangs in Basecoat's `[role="heading"]` in the account menu,
   and that colours its content to `--muted-foreground`. The initials stood
   there in muted grey on the 12 % grey circle — 3.6:1 in the light theme. The
   tile brings its own background, so the text colour belongs with it. */
.tile, .sb-avatar {
  display: flex; height: 2rem; width: 2rem; flex-shrink: 0; align-items: center; justify-content: center;
  border-radius: .5rem; overflow: hidden; font-size: .75rem; font-weight: 600;
  background: color-mix(in oklch, var(--sidebar-foreground) 12%, transparent);
  color: var(--sidebar-foreground);
}
.tile.blank { background: transparent; border: 1px dashed var(--sidebar-border); }
.tile img, .sb-avatar img { width: 100%; height: 100%; object-fit: cover; }
/* An icon in the tile (state "keine Organisation gewaehlt") instead of initials:
   pin the size and do not let it shrink. Lucide sets width/height=24 as an
   ATTRIBUTE on the generated <svg>; without an own rule the tile size depends
   on whether the icon script ran at all. */
.tile > svg, .tile > i, .sb-avatar > svg, .sb-avatar > i {
  width: 1rem; height: 1rem; flex-shrink: 0;
}
/* `min-width: 0` is mandatory, not cosmetic: a flex item has `min-width: auto`
   by default and therefore can NOT shrink below its content width —
   a long org name would blow up the row instead of being ellipsised. */
.meta { display: grid; flex: 1; min-width: 0; text-align: left; line-height: 1.2; overflow: hidden; }
.meta .n { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meta .s {
  font-size: .75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: color-mix(in oklch, var(--sidebar-foreground) 60%, transparent);
}

/* Collapsed: everything except the icon disappears, the row becomes square. */
[data-state="collapsed"] .lbl,
[data-state="collapsed"] .chev,
[data-state="collapsed"] .meta { display: none !important; }
[data-state="collapsed"] .grp-label { height: 0; opacity: 0; }
[data-state="collapsed"] details[open]::details-content { height: 0; }
[data-state="collapsed"] .sb-item { justify-content: center; gap: 0; padding: .5rem; }
[data-state="collapsed"] .sb-item.lg { height: 2.25rem; padding: 0; }

/* Tooltip: replaces the label in the collapsed state. Deliberately no `title`,
   because its delay makes the navigation unusable. */
.tip {
  display: none; position: absolute; left: calc(100% + .5rem); top: 50%; transform: translateY(-50%);
  z-index: 60; padding: .25rem .5rem; border-radius: .375rem; white-space: nowrap; pointer-events: none;
  background: var(--primary); color: var(--primary-foreground); font-size: .75rem; font-weight: 500;
}
[data-state="collapsed"] .sb-item:hover .tip { display: block; }

/* --- Topbar --------------------------------------------------------------- */
/* The line under the topbar is an inset shadow and NOT `border-bottom` (27.10.): a border took 1px
   off the 56px, the centred items thus sat on half pixels (11.5px) — and the bell dot,
   rasterised on a half pixel, came out squashed (see `.hbtn .dot`). Same look, whole pixels. */
.top { flex-shrink: 0; height: var(--top-h); display: flex; align-items: center;
       box-shadow: inset 0 -1px 0 var(--border); }
.top-inner { display: flex; align-items: center; gap: .5rem; width: 100%; padding: 0 1rem; }
.top-right { margin-left: auto; display: flex; align-items: center; gap: .25rem; }
.trigger {
  display: inline-flex; height: 1.75rem; width: 1.75rem; margin-left: -.25rem;
  align-items: center; justify-content: center; border: 0; border-radius: .375rem;
  background: transparent; color: var(--foreground); cursor: pointer;
}
.trigger:hover { background: var(--accent); }
.sep-v { width: 1px; height: 1rem; background: var(--border); margin-right: .25rem; }
.crumb { display: flex; align-items: center; gap: .375rem; font-size: .875rem; min-width: 0; }
.crumb a { color: var(--muted-foreground); text-decoration: none; transition: color .15s ease; }
.crumb a:hover { color: var(--foreground); }
.crumb .cur { color: var(--foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.crumb .csep { color: var(--muted-foreground); display: flex; }
/* The org of ONE page in the platform view (`?org=`, org context 10.10., ADR-047):
   a quiet hint in the topbar, not a button — „Alle
   Organisationen" stays selected, the page only says whom it belongs to. On a list
   with a twin, the cross leads back to all. `org-chip` is not a Basecoat name (ADR-002). */
.org-chip {
  display: inline-flex; align-items: center; gap: .375rem; max-width: 16rem;
  height: 1.75rem; padding: 0 .5rem; margin-right: .25rem;
  border: 1px solid var(--border); border-radius: 999px;
  font-size: .8125rem; color: var(--muted-foreground);
}
.org-chip svg { flex-shrink: 0; }
.org-chip-n { color: var(--foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.org-chip a:not(.btn) { display: inline-flex; color: var(--muted-foreground); border-radius: 999px; }
.org-chip a:not(.btn):hover { color: var(--foreground); background: var(--accent); }

/* The buttons on the right of the topbar are Basecoat's `btn` with
   `data-size="icon-sm"` and `data-variant="ghost"` — 2rem square,
   transparent, `bg-muted` on hover. A third hand-written
   replica stood here for that (`.hbtn`, 2rem, transparent, hover accent);
   all that remains is the dot, which a component library does not know.

   The dot was oval on screens with a fractional pixel ratio (Windows at 125/150 %, browser
   zoom, many phones; reported 27.10.). Two causes, measured and not guessed: it sat on a
   half pixel (y 17.5, the topbar border, see `.top`), and 6px is not a whole number of device
   pixels at 1.25/1.75/2.625 (7.5/10.5/15.75) — at 125 % it rendered 8x7. Therefore 8px: a whole
   number of device pixels at every common ratio (1.25, 1.5, 1.75, 2.25, 2.625, 2.75, 3.5),
   fixed in both directions so nothing can stretch it, on whole pixels. */
.hbtn { position: relative; }
.hbtn .dot {
  position: absolute; top: 3px; right: 5px;
  width: 8px; height: 8px; min-width: 8px; min-height: 8px; aspect-ratio: 1; flex: none;
  padding: 0; border-radius: 50%;
  background: var(--err); box-shadow: 0 0 0 2px var(--background);
}

/* --- Dropdowns ------------------------------------------------------------
   These are Basecoat's `dropdown-menu` now. NOTHING that has to do with
   looks comes from us any more — background, border, shadow, radius, the spacing of the
   entries, hover, focus, separator, heading and the check mark on the active
   choice are all in the bundle. Only what this app needs and what
   a component library cannot know stays here.

   Previously ~45 lines of `.dd-menu`, `.dd-item`, `.dd-sep`, `.dd-label`
   and `.dd-mark` stood here, plus 50 lines of `positionMenu` in app.js — a replica of
   what one line of `data-side` does. */

/* Basecoat's `.dropdown-menu` is `inline-flex`; in the sidebar the
   trigger should take the full width. */
.sb .dropdown-menu { display: flex; width: 100%; }
.sb .dropdown-menu > .sb-item { width: 100%; }

/* The trigger stays highlighted while the menu is open. Basecoat
   sets `aria-expanded` for that — no own state needed. */
.sb-item[aria-expanded="true"], .hbtn[aria-expanded="true"] { background: var(--sidebar-accent); }

/* The org list can get long. Basecoat's popover already scrolls
   (`overflow-y: auto`), but has no height limit. */
.dd-scroll { max-height: 20rem; }

/* The header of the account menu is not a normal `role="heading"`: there
   avatar, name and e-mail sit side by side. */
.dd-head { gap: .5rem; align-items: center; }
.dd-head.between { justify-content: space-between; }

/* Sign-out is the only action that ends something. `data-danger` instead of a
   class — the same notation as Basecoat's own `data-` attributes. */
.dropdown-menu [role="menuitem"][data-danger] { color: var(--err); }
.dropdown-menu [role="menuitem"][data-danger] svg { color: currentColor; }

/* An entry that submits a form is a <button> — it brings
   browser defaults along that Basecoat does not have to clear away for a <div>. */
.dropdown-menu :is([role="menuitem"], [role="menuitemradio"]) {
  border: 0; background: transparent; color: inherit; font: inherit;
  text-align: left; text-decoration: none; cursor: pointer; width: 100%;
}
.dropdown-menu [role="menu"] form { margin: 0; display: contents; }
.dropdown-menu :is([role="menuitem"], [role="menuitemradio"]) > span:first-of-type {
  flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dropdown-menu :is([role="menuitem"], [role="menuitemradio"]) svg { color: var(--muted-foreground); }

/* Notifications (topbar) */
/* The floor of its width — `popoverPlace` sets `min-width` inline (at least the
   trigger's width) and takes this as the minimum; a plain `min-width` here would lose
   against the inline value. */
.notif-menu { --popover-min: 20rem; }
.notif { align-items: flex-start; gap: .6rem; }
.notif .ndot { width: 7px; height: 7px; border-radius: 50%; background: var(--info); margin-top: .35rem; flex-shrink: 0; }
.notif.read .ndot { background: var(--muted-foreground); opacity: .5; }
.ntitle { display: block; font-size: .8125rem; font-weight: 500; line-height: 1.3; }
.ntime { display: block; font-size: .72rem; color: var(--muted-foreground); margin-top: 2px; }
.dd-empty { justify-content: center; color: var(--muted-foreground); }

/* --- Page building blocks ------------------------------------------------- */
/* Dashboard, not a brochure: the content starts right next to the sidebar.
   `margin: 0 auto` centred it — on a wide monitor that left a gap
   several hundred pixels wide between sidebar and content, and a
   narrow content (the "keine Organisation gewaehlt" hint) looked as if it were
   pushed to the right and squeezed. Hence left-aligned; the
   max-width only remains as a lid against endlessly wide tables on 4K. */
.page { padding: 1.25rem 1.5rem 3rem; max-width: 1600px; }
.page.narrow { max-width: 880px; }

/* Wide screens (WQHD and up): lift the lid step by step. At 1600px a 2560px
   monitor leaves the right third empty — a table ends mid-screen, the dashboard
   tiles stop short, a detail page has a column of air next to it. The lid is
   raised only for content that gains from the width (lists, tables, the detail
   grids, the service frame, the tile grids — they all flow into it by
   themselves), NOT for `.page.narrow`, which keeps its reading width.

   Staggered and not one jump, because the content is left-aligned: `max-width`
   only ever binds once the available width (viewport minus sidebar) is larger
   than the lid. A 1366 or 1440 screen is below every step here and therefore
   does not change at all; only from ~1680px upward does the extra room appear. */
@media (min-width: 1680px) { .page:not(.narrow) { max-width: 1880px; } }
@media (min-width: 2080px) { .page:not(.narrow) { max-width: 2080px; } }
@media (min-width: 2480px) { .page:not(.narrow) { max-width: 2240px; } }

/* An entry form is read and filled top to bottom; a field dragged to 1000px is
   worse than one at reading width, because the label above it drifts out of
   sight. The two-column grid therefore keeps a comfortable maximum even when it
   sits in a lid that was lifted for the table on the next page. In a dialog or a
   detail column — narrower than this anyway — the cap does nothing. */
.form-grid { max-width: 64rem; }

/* A standalone entry form is `<form class="card">` carrying that grid; cap the
   whole card, so the surface does not stretch wide around fields that stop at
   64rem. A form whose body is a wide matrix or a line-item table has no
   `.form-grid` and keeps the full width. Left-aligned like every narrow page —
   the panel seats its forms against the sidebar, it does not centre them.
   A form that carries the customer-rights matrix (`.kr-block`, the customer
   group) below its fields is NOT capped: the fields keep 64rem through
   `.form-grid`, the matrix (one column per service category) takes the width. */
.page > form.card:has(.form-grid):not(:has(.kr-block)) { max-width: 64rem; }

.page-head { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.25rem; }
.page-head h1 { margin: 0; font-size: 1.375rem; font-weight: 600; letter-spacing: -.01em; }
.page-head p { margin: .25rem 0 0; font-size: .875rem; color: var(--muted-foreground); }
.page-head .right { margin-left: auto; display: flex; gap: .5rem; align-items: center; }
.sep { height: 1px; background: var(--border); margin: 1rem 0; }

/* --- Tables ---------------------------------------------------------------
   Only the table scrolls horizontally (`.tbl-scroll`), never the page. Otherwise
   the whole shell wanders to the left with one wide column. */
.tbl-wrap { border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); overflow: hidden; }
.tbl-bar {
  display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
  padding: .625rem .75rem; border-bottom: 1px solid var(--border);
}
.tbl-search {
  display: flex; align-items: center; gap: .5rem; height: 2rem; padding: 0 .5rem;
  border: 1px solid var(--border); border-radius: .5rem;
  background: color-mix(in oklch, var(--muted) 55%, transparent);
}
.tbl-search:focus-within { border-color: color-mix(in oklch, var(--foreground) 25%, transparent); }
.tbl-search svg { color: var(--muted-foreground); flex-shrink: 0; }
.tbl-search-input {
  width: 14rem; max-width: 40vw; border: 0; outline: none; background: transparent;
  color: var(--foreground); font: inherit; font-size: .8125rem;
}
.tbl-search-input::placeholder { color: var(--muted-foreground); }
.tbl-filters { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.tbl-actions { margin-left: auto; display: flex; align-items: center; gap: .5rem; }

/* A self-made `.btn-sm` (1.75rem tall) stood here with the comment
   „Basecoats .btn ist fuer die Toolbar zu hoch". Both were wrong:

   * Basecoat DOES have small buttons — `class="btn" data-size="sm"` (2rem) and
     `data-size="xs"` (1.5rem), plus `icon-sm`/`icon-xs` for icon-only buttons.
     The size is a `data` attribute, not a class. `.btn-sm` only exists in
     1.0.2 in an optional legacy layer that we do not load.
   * `ghost` as a stand-alone class never existed — the variant is called
     `data-variant="ghost"`.

   The result was visible: every dialog footer had a 28px button
   next to a 36px button. The rule now:

     form footer          `btn` (default) + `btn data-variant="outline"`
     page header, toolbar `btn data-size="sm"`
     row action           `btn data-size="sm" data-variant="ghost"`
     icon only            `btn data-size="icon-sm" data-variant="ghost"` */

/* While `app.js` fetches the table again: dimmed and not clickable, so that
   a second click does not go nowhere. app.js only sets the attribute after
   150ms — locally the response is usually there before that. */
.tbl-wrap { transition: opacity .12s ease; }
.content[aria-busy="true"] .tbl-wrap { opacity: .55; pointer-events: none; }
/* The same for the registered write paths (`data-tausch`, see app.js).
   Here it is MORE than feedback: a second click on „Antwort senden"
   would be a second message, and messages are append-only — taking them
   back is not possible at all over at the API (405). The element selector carries a
   restriction (ADR-002 addendum): it does not target `form`, but
   `form[data-tausch]`, and therefore cannot hit a foreign form. */
form[data-tausch] { transition: opacity .12s ease; }
.content[aria-busy="true"] form[data-tausch] { opacity: .55; pointer-events: none; }

.tbl-scroll { overflow-x: auto; }
.tbl { width: 100%; border-collapse: collapse; font-size: .8125rem; }
.tbl th {
  padding: .5rem .75rem; text-align: left; white-space: nowrap;
  font-size: .75rem; font-weight: 500; color: var(--muted-foreground);
  border-bottom: 1px solid var(--border); background: color-mix(in oklch, var(--muted) 40%, transparent);
}
.tbl td { padding: .625rem .75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
.tbl tbody tr:last-child td { border-bottom: 0; }
.tbl tbody tr:hover { background: color-mix(in oklch, var(--accent) 60%, transparent); }
.tbl .a-right { text-align: right; }
.tbl .a-center { text-align: center; }
.tbl a { text-decoration: none; }
.tbl a:hover { text-decoration: underline; }
.tbl .mono { font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace; font-size: .75rem; color: var(--muted-foreground); }

.th-sort { display: inline-flex; align-items: center; gap: .3rem; color: inherit; text-decoration: none; }
.th-sort:hover { color: var(--foreground); }
.th-sort svg { opacity: .45; }
.th-sort.on { color: var(--foreground); }
.th-sort.on svg { opacity: 1; }

.tbl-foot {
  display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
  padding: .625rem .75rem; border-top: 1px solid var(--border);
}
.tbl-count { font-size: .8125rem; color: var(--muted-foreground); }
/* A `.tbl-count` WITH an icon is a row, not a paragraph.
   Third edition of the Preflight trap from the header of this file: Basecoat sets
   `svg { display: block }`, and a bare block container thereby pushes the icon
   onto its own line above the text. This was visible in the hint
   „Zum Anlegen erst eine Organisation wählen." in the header of EVERY descriptor list
   without a selected org (and on the tax rates): the ⓘ stood alone above it.
   `:has(svg)` instead of a second class, so that the next hint with an
   icon also sits right by itself — and so that the `.tbl-count` WITHOUT an icon (the
   counter „1–8 von 137" in the footer) stay untouched.

   `align-items: flex-start` and not `center`: the hint wraps onto two lines on narrow
   headers (tax rates), and then the icon belongs on the
   FIRST line and not in the middle of the paragraph. The same alignment and
   the same offset as for `.dp-note`. */
.tbl-count:has(svg) { display: inline-flex; align-items: flex-start; gap: .35rem; }
.tbl-count > svg { flex-shrink: 0; margin-top: .1rem; }
.pager { margin-left: auto; display: flex; align-items: center; gap: .25rem; }
.pg {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 1.75rem; height: 1.75rem; padding: 0 .4rem;
  border: 1px solid var(--border); border-radius: .375rem;
  font-size: .8125rem; text-decoration: none; color: var(--foreground);
  transition: background .12s ease;
}
.pg:hover { background: var(--accent); }
.pg.on { background: var(--primary); color: var(--primary-foreground); border-color: var(--primary); font-weight: 500; }
.pg.off { opacity: .35; pointer-events: none; }

.tbl-empty { padding: 3rem 1rem; text-align: center; color: var(--muted-foreground); }
/* `margin: 0 auto` is mandatory here, not taste: Basecoat's Preflight sets
   `img, svg, video, … { display: block }`. A block cannot be centred via
   `text-align: center` — without the auto margins the icon sticks to the
   left edge while the text below it is centred. */
.tbl-empty svg { width: 2rem; height: 2rem; opacity: .5; margin: 0 auto .5rem; }
.tbl-empty .t { margin: 0; font-size: .9375rem; color: var(--foreground); }
.tbl-empty .d { margin: .35rem 0 1rem; font-size: .8125rem; }

/* --- Pills ----------------------------------------------------------------
   Status values of the API (active/pending/failed/…) in a colour that can be read
   even without the text. The border instead of a full fill keeps tables calm.
   Called `.pill` and not `.badge`, because Basecoat brings a `.badge`: its
   fixed `height: 1.25rem` and `transition: all` came through for us, because we
   set neither ourselves (see naming rule in the header). */
.pill {
  display: inline-flex; align-items: center; gap: .3rem; padding: .1rem .45rem;
  border: 1px solid var(--border); border-radius: 999px;
  font-size: .72rem; font-weight: 500; white-space: nowrap;
  color: var(--muted-foreground); background: color-mix(in oklch, var(--muted) 60%, transparent);
}
.pill.ok    { color: var(--ok);   border-color: color-mix(in oklch, var(--ok) 40%, transparent);   background: color-mix(in oklch, var(--ok) 12%, transparent); }
.pill.warn  { color: var(--warn); border-color: color-mix(in oklch, var(--warn) 40%, transparent); background: color-mix(in oklch, var(--warn) 12%, transparent); }
.pill.err   { color: var(--err);  border-color: color-mix(in oklch, var(--err) 40%, transparent);  background: color-mix(in oklch, var(--err) 12%, transparent); }
.pill.info  { color: var(--info); border-color: color-mix(in oklch, var(--info) 40%, transparent); background: color-mix(in oklch, var(--info) 12%, transparent); }
.pill.muted { }

/* Row actions (ADR-062): a yes/no pill that is the button itself, and the small
   POST forms in a table cell. The pill stays a pill (the same colour as the
   read-only cell) — just with a pointer, focus ring and the small toggle sign, so that one
   sees it can be clicked. `font: inherit` and `cursor`, because a `<button>` otherwise
   brings the system font and the arrow. */
.zeile-form { display: inline; margin: 0; }
.zeile-form + .zeile-form { margin-left: .25rem; }
button.pill-knopf { font: inherit; font-size: .72rem; font-weight: 500; cursor: pointer; }
button.pill-knopf:hover { border-color: var(--ring); }
button.pill-knopf:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
button.pill-knopf svg { width: .7rem; height: .7rem; opacity: .55; }

/* --- Cells with structure: JSON and pill lists -----------------------------
   Two `kind` values from `macros/cells.html` that are more than a formatted
   scalar — and therefore the only ones that need their own CSS.

   `.cell-json` is capped, not complete: a `hardware` object of a
   node easily has thirty lines. Uncapped, ONE row makes the table so
   tall that the next one is off screen — and the list is there
   for an overview, not for reading the whole object (that is what the
   single view is for). `pre-wrap` instead of `pre`, so that a long line wraps
   instead of scrolling horizontally; `.tbl-scroll` already scrolls horizontally, and
   nobody hits two nested scroll areas.

   `white-space` and `font-family` are set here explicitly, although a `<pre>`
   has both out of the box: Basecoat's Preflight sets `pre { font-family:
   var(--font-mono, …) }` and we want the same font as `.tbl .mono`. */
.cell-json {
  margin: 0; max-height: 11rem; overflow-y: auto;
  padding: .3rem .5rem; border-radius: calc(var(--radius) - 2px);
  background: color-mix(in oklch, var(--muted) 45%, transparent);
  font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace;
  font-size: .7rem; line-height: 1.5;
  white-space: pre-wrap; overflow-wrap: anywhere;
  color: var(--muted-foreground);
}
/* Without `flex-wrap` twelve permissions stand in one row and stretch the
   column until everything else is squeezed. `.pill` carries
   `white-space: nowrap` — so the container has to wrap. */
.cell-tags { display: inline-flex; flex-wrap: wrap; gap: .25rem; }

/* --- Detail pages ---------------------------------------------------------
   The key/value table stood almost identically three times in a local
   <style> (services, status page, orgs) — the same values, three names, three
   chances to drift apart. Here it is once, for `detailpage.html`.

   `dp-` and not `kv`: `.kv` is already taken locally in `orgs/detail.html`,
   and a second `.kv` with different values would be exactly the mistake ADR-002
   describes — only this time against ourselves instead of against Basecoat.

   The three existing pages stay with their `.svc-`/`.sp-`/`.kv`-
   names for now. Switching them too would be a rework of three pages in one step
   that only touches looks; that is worth its own clean-up step. */
.dp-grid {
  display: grid; gap: 1rem; align-items: start;
  grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
}
.dp-kv { width: 100%; border-collapse: collapse; font-size: .8125rem; }
/* Fixed column width for the labels: otherwise the value column jumps to a different
   place per box, and two boxes side by side look crooked. */
.dp-kv th {
  width: 11rem; padding: .4rem .75rem .4rem 0; text-align: left;
  font-weight: 400; color: var(--muted-foreground); vertical-align: top;
}
.dp-kv td { padding: .4rem 0; vertical-align: top; overflow-wrap: anywhere; }
.dp-kv tr + tr > * { border-top: 1px solid color-mix(in oklch, var(--border) 60%, transparent); }
/* `.mono` is NOT set globally — the rule for it hangs on `.tbl` (tables
   are the more frequent place). Without the line here a uuid in the box would be in
   the body text font, while it is monospace in every table. */
.dp-kv .mono { font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace; font-size: .75rem; color: var(--muted-foreground); }
/* The hint sentence in a box: quieter than the content, but not hidden.
   Flex and not `display: block`, because an icon sits on the left — and an `<svg>`
   is a block thanks to Basecoat's Preflight (see header of this file). */
.dp-note {
  display: flex; align-items: flex-start; gap: .5rem; margin: 0;
  font-size: .8125rem; color: var(--muted-foreground);
}
.dp-note svg { flex-shrink: 0; margin-top: .1rem; }
/* The org choice in the box „Keine Organisation gewählt" (`_org_waehlen.html`):
   one button per org, wrapping instead of an endlessly wide row. */
.org-wahl { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .75rem; }

/* --- Tool on the left, content on the right (`.dp-split`) -----------------
   The ONE building block the panel did not have so far. All seven existing
   page grids (`.dp-grid`, `.svc-grid`, `.sp-grid`, `.dash-grid`, `.dash-cols`,
   `.pa-cards` and the inline grid of the invoice page) are the same cut
   `repeat(auto-fit, minmax(Xrem, 1fr))` — they can only do EQUALLY wide columns
   and decide the number of columns from the space, not from an intention. A
   ratio of 4/8 cannot be expressed with them at all: `.dp-grid` would have made the
   tool column of the ticket just as wide as the conversation.

   `.dp-split` and not `.split`/`.row`/`.col`: those three are Basecoat-like
   names (ADR-002) — and even if Basecoat does not take them, `col-4` would be
   a dead class here, because the CDN bundle is built with `source(none)` and
   contains not a single utility. The `dp-` prefix, because this is the same
   family as `.dp-grid`/`.dp-kv`/`.dp-note`: building blocks of the single view.

   `minmax(0, 2fr)` on the wide track is mandatory, not polish: a
   `2fr` track is `minmax(auto, 2fr)`, and `auto` grows with its longest
   unbreakable content. A long URL in the conversation would otherwise have made the column
   wider than its share and silently tipped the ratio.

   The narrow track, on the other hand, has a **minimum** instead of a zero, and that is
   measured too: the media query below only knows the viewport, not the
   sidebar. At an 800px window with the sidebar expanded (16rem) the
   content gets less than 550px — a third of that would be 180px, and neither
   a select field plus button nor a `dp-kv` row fits in there. With the minimum
   the tool column gets its 17rem and the timeline gives up the rest; the
   ratio only tips where it has to tip. */
.dp-split {
  display: grid; gap: 1rem; align-items: start;
  grid-template-columns: minmax(17rem, 1fr) minmax(0, 2fr);
}
/* Each column stacks its boxes itself — otherwise every box in it would need
   its own `margin-top`, and the last one would have one too many. */
.dp-side, .dp-main { display: grid; gap: 1rem; align-content: start; min-width: 0; }
/* `min-width: 0` must ALSO go on the boxes, not only on the column — and that is
   measured, not precautionary. A grid item has `min-width: auto`, so it
   cannot go below its min-content width. The column itself already had the zero;
   its children did not, and so the actions box stayed at 307px in
   a 272px track (at a 1024px window with the sidebar expanded) — it
   stuck out 35px, across the gap into the timeline. Affected was every
   width below about 1200px, i.e. every 1024 tablet, until the grid tips
   to one column at 48rem anyway.

   Widening the track would again not have held the next long
   name. A box that may shrink holds every one — the rest is done by
   `overflow-wrap` in `.dp-kv` and the truncation on the select button. */
.dp-side > *, .dp-main > * { min-width: 0; }

/* The narrow column scrolls WITH the page (Luca 2026-10-22). From 2026-08-09 it had its own
   scroll area (a sticky, self-scrolling box) so the long tool column of the
   ticket page would not stretch the content; Luca wants the info column to flow normally. */

/* Links with an icon in the side column: icon and text on ONE line. The cards lay their
   content out as a grid, and without this the icon ends up above the text. */
.dp-side a:not(.btn):has(> svg, > i[data-lucide]) {
  display: inline-flex; align-items: center; gap: .375rem;
}

/* The same breakpoint as `.form-grid` (48rem) and not a third one: the panel has
   exactly two today (40rem and 48rem), and a fourth number would be a width
   at which something different happens on a single page than everywhere else.

   **Left falls BELOW right.** The timeline is the reason this
   page exists; the tool column carries sixteen rows of master data. If it were
   at the top, on a phone one would have to scroll past all sixteen before
   the first message even becomes visible. At the bottom it is where one wants to go
   after reading: set status, assign, close. */
@media (max-width: 48rem) {
  .dp-split { grid-template-columns: minmax(0, 1fr); }
  .dp-split > .dp-side { order: 2; }
}

/* --- The same grid, mirrored (`[data-schmal="rechts"]`) ---------------------
   The customer page needs the split the other way round from the ticket: the
   record is the main thing and sits on the left, the overview tiles
   (balance, PIN, open items) sit next to it on the right.

   **An attribute and not a second class**, because this is a variant
   of the same construction and not its own — the same reasoning as for
   `data-tone` and `data-span` (addendum to ADR-002). A `.dp-split-rechts`
   would be a second name for the same grid, and on the next change
   someone maintains only one of them.

   The order in the document stays `.dp-main`, then `.dp-side` — that way
   the tracks fall into place by themselves (wide first) and no
   `grid-column` is needed. The other way round two rules would be necessary, and every line of
   placement is a line that would have to be taken back again when
   wrapping. */
.dp-split[data-schmal="rechts"] {
  grid-template-columns: minmax(0, 2fr) minmax(17rem, 1fr);
}

/* The repetition is mandatory, not redundancy: `.dp-split[data-schmal]` is
   more specific than `.dp-split`, and specificity beats order — even
   within the same media query. Without this line the page would stay two-column on the
   phone, while the rule above looks as if it
   had taken care of that.

   **And here the narrow column is ON TOP**, unlike the ticket. The reason
   is the same, just read the other way round: one stacks the short before the long.
   On the ticket the tool column is the longer one (sixteen rows of master data),
   here it is five tiles — while on the left the master data table with
   addresses, companies, services, invoices and tickets stands. Balance and
   support PIN are, moreover, exactly what one opens the page on the phone
   for in the first place. */
@media (max-width: 48rem) {
  .dp-split[data-schmal="rechts"] { grid-template-columns: minmax(0, 1fr); }
  .dp-split[data-schmal="rechts"] > .dp-side { order: -1; }
}

/* --- Mobile ---------------------------------------------------------------
   Below 768px the sidebar becomes a drawer above the content: with 3rem left
   the collapsed variant would be neither readable nor hittable. */
@media (max-width: 767px) {
  .sb {
    position: fixed; top: 0; left: 0; z-index: 50; height: 100dvh;
    width: 17rem !important; transform: translateX(-100%);
    transition: transform .3s ease; box-shadow: 0 0 40px rgb(0 0 0 / .55);
  }
  [data-mobile="open"] .sb { transform: translateX(0); }
  /* There is no icon mode on mobile; app.js resets data-state on load
     and on resize back to "expanded". */
  .backdrop { display: none; position: fixed; inset: 0; z-index: 40; background: rgb(0 0 0 / .5); }
  [data-mobile="open"] .backdrop { display: block; }
  .page { padding: 1rem 1rem 3rem; }
  .tbl-actions { margin-left: 0; }
}

/* --- Form grid ------------------------------------------------------------
   The only thing Basecoat does NOT deliver for forms — and for a
   verifiable reason: the CDN bundle is built with `@import "tailwindcss"
   source(none)` and therefore contains **not a single utility**. No
   `grid`, no `grid-cols-2`, no `gap-4`, no `w-full`. Basecoat's docs
   use them anyway, because the docs site builds its own Tailwind. Whoever
   copies them from there writes down classes that do nothing.

   Everything ELSE about the form comes from Basecoat: `.field` brings `gap: .75rem`
   between label, control and help text and styles its direct
   children (`> label`, `> input`, `> select`, `> textarea`, `> p`). Here only
   how the fields sit relative to each other.

   The spacing is 1.5rem — the same that Basecoat's `.fieldset` uses, so that
   one- and two-column forms breathe the same. Previously nothing at all stood here:
   the classes `form-grid`, `field-wide` and `field-help` were wired into the templates,
   but defined nowhere. That is exactly where „der Text ueber dem
   Input steht zu weit oben, der Abstand nach unten ist zu gering" came from — WITHIN
   a field Basecoat's spacing applied, BETWEEN the fields none at all. */
.form-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1.5rem 1rem;
  align-items: start;
}
/* A field across the full width. `data-span` and not a class: a
   `data` attribute cannot clash with any Basecoat name (ADR-002). */
.form-grid > [data-span="2"] { grid-column: 1 / -1; }
/* The native date picker (`Field.picker`): the browser draws its calendar
   icon and popup itself — in dark mode they must be told so. */
.dark input[type="date"] { color-scheme: dark; }

/* Basecoat gives a `<select>` `width: fit-content` — right in a toolbar,
   wrong in a form: it would stand narrow next to 100% wide inputs. The
   docs solve that with the utility `w-full`, which does not exist here.

   In the descriptor form the rule no longer applies anywhere today: simple
   select fields have been Basecoat's `select` COMPONENT (`div.select`) since ADR-010,
   and the multi-select is no `<select>` any more since the checkboxes.
   It stays anyway — it costs nothing and catches the next
   hand-written `<select>` in a `.form-grid` before it stands narrow next to it. */
.form-grid select { width: 100%; }

/* --- Select as a component (ADR-010) -------------------------------------
   `div.select` is Basecoat's select kit made of button + popover instead of a
   native `<select>` whose list the operating system draws. Looks,
   row height, hover and the check mark on the selected row are entirely in the
   CDN bundle. Here are the three things it does not deliver for THIS
   construction — checked in the bundle, not assumed.

   1. **Width.** `.select:not(select)` is `inline-flex` and its button
      `width: fit-content` — exactly right in the toolbar, wrong in a
      grid cell. The same coaching as for `<select>` one rule
      above, just for the other construction. */
.field > .select { display: flex; }
.field > .select > button { width: 100%; }

/* 2. **Size.** For the native branch Basecoat knows `data-size="sm"`
      (`select.select[data-size=sm] { height: 2rem }`), for `div.select` NOT —
      there the button is always 2.25rem tall. In the table toolbar it stands
      next to the search field and buttons, which are all 2rem. The same attribute and
      not an own class, so that the call site does not have to know two notations for
      the same thing. */
.select[data-size="sm"] > button { height: 2rem; font-size: .8125rem; }

/* 3. **Two class names that Basecoat's JS has hard-wired** and that still do not
      exist in the CDN bundle: the bundle is built with
      `@import "tailwindcss" source(none)` and contains not a single
      utility (see "Form grid"). `select.js` sets
      `text-muted-foreground` on the placeholder — without this rule „— bitte wählen —" stands in
      full text colour and looks like a selected value. In the docs the chevron carries
      `text-muted-foreground opacity-50 shrink-0`; since ours comes from
      Lucide, it is handled here as well. */
.select > button > span.text-muted-foreground { color: var(--muted-foreground); }
.select > button > svg { color: var(--muted-foreground); opacity: .5; }

/* 4. **An opened list lives in the top layer** (Luca, 23.10.: „Dropdowns
      transparent … bei Modals verrutscht die Seite … man kann auch nicht
      runterscrollen").

   Basecoat places a `[data-popover]` `position: absolute` at its trigger — so
   every box above it with an `overflow` cuts it off: the field area of a
   dialog, `.tbl-wrap`, a `.card`. Until 23.10. this file answered that by
   switching the clipping OFF while a list was open (`overflow: visible` via
   `:has()`), and exactly that was the cause of all three symptoms. Measured in
   the pool dialog at 1440×900, last field „NetBox-Anbieter":

     * a scroll container that loses its `overflow` loses its scroll position —
       `scrollTop` 407 -> 0: the form jumped, the clicked field landed under the
       footer (field at y=863, footer at 824–860);
     * without `overflow` the fields spilled out of the dialog and over the
       footer; the outline button „Abbrechen" is transparent, so the text behind
       it shone through — the "transparent dropdown";
     * nothing could be scrolled any more, and the list got the 8px that were
       left below the field.

   Now the list leaves its box instead of the box giving up its clipping:
   `app.js` (`popoverLift`) shows it as a popover in the browser's TOP LAYER —
   above the modal dialog, outside every `overflow` — and places it at its
   trigger (`popoverPlace`: Basecoat's side; inside the dialog's field area if it
   fits there — at the bottom it opens upwards —, otherwise wherever the window has
   room; height capped). The dialog keeps scrolling the whole time.

   This is the one rule for it. `position: fixed` works here because the top
   layer's containing block is always the window — inside the dialog without the
   top layer it was the `<article>` (Basecoat's `translate`/`scale` make it the
   containing block of fixed children; measured 2026-08-09: 496px off). `inset`,
   `margin` and `translate` take back what the bundle and the UA sheet for
   `[popover]` bring along; `left`/`top`/`max-height`/`min-width` come from
   `popoverPlace` as inline values. Transitions only for fading in: an animated
   `top`/`left` would slide the list in from its old place.

   (Nine `!important` rules used to stand here that forced every open popover
   back to `absolute`. They were written against a Basecoat JS that set inline
   `position: fixed` — the pinned 1.0.2 does not do that any more (checked in
   `dist/js/all.min.js`: no `position`, `.style.` only for the slider). They had
   become restatements of the bundle's own rules, and with the top layer they
   would have pinned the list into its box again.) */
.select > [data-popover] { --popover-min: 9rem; }
[data-popover]:popover-open {
  position: fixed;
  inset: auto;
  margin: 0;
  translate: none;
  max-width: calc(100vw - 1rem);
  transition-property: opacity, scale;
}

/* A **closed** list must neither count nor show — so it is simply not there.

   Count: Basecoat only makes it invisible (`visibility`, `opacity`); it stays
   `absolute` below its field and enlarges the scrollable height of its
   container — the ticket dialog had a scrollbar with three fields in it (308px of
   content in a 223px viewport). Until 23.10. `max-height: 0` stood here, but the
   padding still counted: opening a list at the bottom of the address dialog
   (1280×620) took those 4px away and the dialog jumped by them (`scrollTop`
   42 -> 38).

   Show: Basecoat fades it out over 0.1s, and `visibility` stays `visible` for that
   time. Since the open list sits in the top layer and the closed one back in its
   box, the fade-out would play at the OLD place, half clipped.

   The fade-IN stays: `@starting-style` gives the first frame of an opened list
   Basecoat's closed look, and the transition of the rule above takes it from
   there (`[aria-hidden="false"]` and not `:popover-open`: `select.js` measures
   the list once before `app.js` lifts it). */
[data-popover][aria-hidden="true"] { display: none; }
@starting-style {
  [data-popover][aria-hidden="false"] { opacity: 0; scale: .95; }
}

/* A field with a checkbox lies horizontally (`data-orientation="horizontal"`) and
   gets a border around the label from Basecoat. In a grid cell
   it should be top-aligned with its neighbours, not centred. */
.form-grid > .field[data-orientation="horizontal"] { align-self: start; }

@media (max-width: 48rem) {
  .form-grid { grid-template-columns: 1fr; }
}

/* --- Dialog: the <form> is one level too deep ------------------------------
   Basecoat builds the dialog so that `.dialog > *` (for us the `<article>`) is a
   flex COLUMN: `gap: 1.5rem`, `padding: 1.5rem`, `max-height: calc(100% - 2rem)`.
   Header, content and footer are DIRECT children — and that is exactly where our
   markup misses, because the `<form>` wraps fields and footer together
   (`dialog > article > form > {.form-grid, footer}`). A great-grandchild gets
   neither the `gap` nor the rules for `> footer` and `> section`.

   That cost two bugs, both measured (54 dialogs, `showModal()`):

   1. **The spacing before the footer was 0px — in EVERY dialog.** „Abbrechen"
      and „Speichern" stuck directly to the last input field.
   2. **A long dialog could not be operated.** The `<article>` is capped at
      window height, but nowhere in it was there `overflow` — at a 620px
      window height the footer lay OUTSIDE the screen in 36 of 54 dialogs,
      and one could not scroll. „Speichern" was unreachable.

   The fix carries the construction one level down: the `<form>` itself becomes a
   flex column with the same `gap`, and the fields become the scroll area.
   Header and footer thus stay in place — for a dialog that is the point: the
   button one is looking for should not scroll away.

   `flex: 1 1 auto` and NOT `flex: 1`: the latter sets `flex-basis: 0`, and in
   a container with `height: auto` (the `<article>` is as tall as its content, only
   capped) that is an unnecessary bet on the browser's arithmetic.
   `min-height: 0` is mandatory, not caution — a flex item otherwise cannot
   shrink below its content height, and without the shrinking `overflow` is
   never triggered (the same trap as with `min-width: 0` in the org switcher). */
.dialog > * > form {
  display: flex; flex-direction: column; gap: 1.5rem;
  flex: 1 1 auto; min-height: 0;
}
.dialog > * > form > footer { flex-shrink: 0; }
/* The scroll area. `padding-inline`/`margin-inline` cancel each other out in width
   and create the 3px that the focus ring of a field at the edge needs —
   a scroll container clips it otherwise (it is a `box-shadow`, not an
   `outline`). The dialog frame has 1.5rem of padding, so the overhang
   does not show. `section` is included, because the delete dialog has its text
   there and the same bug would have the same cause. */
/* It ALWAYS scrolls — an opened select list no longer needs an exception here:
   it is lifted into the top layer (see "Select as a component", point 4).
   Until 23.10. the clipping was switched off while a list was open, and that
   reset the scroll position and let the fields spill over the footer. */
.dialog > * > form > .form-grid,
.dialog > * > section {
  overflow-y: auto; min-height: 0;
  /* The space of the bar is ALWAYS kept free, even if there is none right now —
     otherwise every field gets 10px narrower the moment content grows past the
     height (an error box, a longer help text) and the form twitches. */
  scrollbar-gutter: stable;
  padding-inline: 3px; margin-inline: -3px;
  scrollbar-width: thin;
  scrollbar-color: color-mix(in oklch, var(--foreground) 25%, transparent) transparent;
}

/* Basecoat's rule for the dialog footer is `.dialog > * > footer` — for the
   same reason as above two levels too high. Carried over here one level deeper,
   including the wrapping on narrow screens that Basecoat
   has as well (stacked below 40rem, main button on top). */
.dialog form > footer { display: flex; flex-direction: column-reverse; gap: .5rem; }
@media (min-width: 40rem) {
  .dialog form > footer { flex-direction: row; justify-content: flex-end; }
}

/* --- Multi-select: checkboxes instead of <select multiple> -----------------
   A fix-up for `select[multiple]` stood here (Basecoat gives EVERY
   `<select>` a fixed height and the chevron as a background image, which squashes a
   multi-select onto one line). It is gone without replacement, because the
   element is gone: its opened list is drawn by the OPERATING SYSTEM anyway
   — no CSS reaches it, just like with the simple `<select>` (ADR-010) —, and
   selecting only worked via Ctrl-click, where a click without Ctrl discarded the whole
   previous selection.

   Now: `macros/form.html` renders `kind="multi"` as a checkbox list. The
   names carry the prefix `chk-` (ADR-002 — `.check`, `.list`, `.group` would be
   Basecoat candidates). `.chk` itself is deliberately short and listed as taken
   in the header of the file. */
.chk-set {
  border: 1px solid var(--input); border-radius: var(--radius);
  background: var(--background); overflow: hidden;
  /* A cap instead of unlimited height: the product list of a large org otherwise makes
     the form several screens long, and the buttons below it can
     no longer be found. */
  max-height: 18rem; overflow-y: auto;
}
.chk-head {
  display: flex; align-items: center; justify-content: space-between; gap: .5rem;
  padding: .375rem .625rem;
  background: var(--muted); border-block: 1px solid var(--border);
  font-size: .75rem; font-weight: 600; text-transform: uppercase;
  letter-spacing: .03em; color: var(--muted-foreground);
  /* With long lists the category name stays in place — otherwise one scrolls through
     products without knowing which group they belong to. */
  position: sticky; top: 0; z-index: 1;
}
.chk-head:first-child { border-top: 0; }
/* The „alle" switch. Its own rule and not `.chk-head .btn`: the name is
   at the same time the handle by which `app.js` finds it — a class that is only in the JS
   and in no stylesheet is exactly the kind that `smoke_ui.py`
   complains about (and rightly so: on the next rename only half of it gets noticed).
   The capitals of the header apply to the heading, not to the button. */
.chk-all { text-transform: none; letter-spacing: 0; }
.chk-list { display: grid; gap: .125rem; padding: .375rem; }
@media (min-width: 48rem) {
  .chk-list { grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); }
}
/* The whole label is the click area, not just the 16px checkbox. */
.chk {
  display: flex; align-items: center; gap: .5rem;
  padding: .3125rem .5rem; border-radius: calc(var(--radius) - 2px);
  font-size: .8125rem; cursor: pointer; min-width: 0;
}
.chk:hover { background: var(--muted); }
.chk > input { flex: none; margin: 0; }
/* `min-width: 0` plus `overflow` — without it a long product name blows up the
   grid column instead of being truncated (the same trap as in the org switcher). */
.chk > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chk-leer { margin: 0; padding: .3125rem .5rem; font-size: .8125rem; color: var(--muted-foreground); }
/* The rights editor (`macros/form.html`, field `permissions`, fine permissions): one
   group per category, its „Alles" right (`x:write`) first and bold, the single actions
   below with their description — wrapped instead of truncated, the description IS the
   information. „enthalten" marks what the „Alles" right already covers, „nur einzeln" the
   break-glass rights `x:write` never covers. */
.chk-set[data-rights] { max-height: 32rem; }
.chk-set[data-rights] .chk > span { white-space: normal; }
.chk-top { font-weight: 600; grid-column: 1 / -1; }
.chk-incl, .chk-solo {
  flex: none; margin-left: auto; padding: 0 .375rem; border-radius: 999px;
  font-size: .6875rem; color: var(--muted-foreground); border: 1px solid var(--border);
}
.chk-incl[hidden] { display: none; }
/* „nur diese Nodes/Pools" under a datacenter/ipam action (API-ADR-105): a native <details>,
   so it opens without JS; the full row of the grid, its node/pool boxes indented. */
.chk-scope { grid-column: 1 / -1; margin: -.125rem 0 .25rem 1.75rem; font-size: .75rem; }
.chk-scope > summary { cursor: pointer; color: var(--muted-foreground); padding: .125rem 0; }
.chk-scope > .chk { padding-block: .1875rem; }
/* The nodes of an IP pool with Bridge and VLAN per node (`Field.node_net`, API-ADR-107):
   one row per node — box and name, then the two inputs — instead of the tile grid.
   The inputs of an UNticked node are hidden (`visibility`, so the columns stay put);
   that needs no JavaScript, and the server ignores them anyway. */
.chk-list[data-net] { grid-template-columns: 1fr; }
.chk-net {
  display: grid; grid-template-columns: minmax(0, 1fr) 7.5rem 5.5rem;
  gap: .5rem; align-items: center;
}
.chk-net > .input { height: 2rem; font-size: .8125rem; }
.chk-net-head { font-size: .75rem; color: var(--muted-foreground); }
/* The column titles over the label text (`.chk` padding) and the input text (Basecoat's
   `px-2.5`). */
.chk-net-head > span { padding-inline: .5rem; }
.chk-net-head > span + span { padding-inline: .625rem; }
.chk-net:not(.chk-net-head):not(:has(> .chk input:checked)) > .input { visibility: hidden; }
/* API-ADR-111: the fourth column, the IPv4 assignment per node (`Field.node_modes`) — Basecoat's
   select stretched into its cell like the inputs, hidden on an unticked node like them. While IPv6
   is chosen in the same dialog app.js sets `data-v6` (and disables the selects). */
.chk-list[data-modes] > .chk-net { grid-template-columns: minmax(0, 1fr) 7.5rem 5.5rem 11rem; }
.chk-net > .select { display: flex; min-width: 0; }
.chk-net > .select > button { width: 100%; }
.chk-net:not(.chk-net-head):not(:has(> .chk input:checked)) > .select,
.chk-list[data-v6] > .chk-net > .select,
.chk-list[data-v6] > .chk-net-head > span:nth-child(4) { visibility: hidden; }
/* Four columns do not fit Basecoat's dialog width (`sm:max-w-lg`): the node names collapsed to
   nothing. The dialog that carries such a list gets more room; on a narrow screen the select
   moves to a second line under Bridge/VLAN (its `aria-label` names it, the header drops it). */
.dialog:has(.chk-list[data-modes]) > * { max-width: min(44rem, calc(100% - 2rem)); }
@media (max-width: 48rem) {
  .chk-list[data-modes] > .chk-net { grid-template-columns: minmax(0, 1fr) 7.5rem 5.5rem; }
  .chk-list[data-modes] > .chk-net > .select { grid-column: 2 / -1; }
  .chk-list[data-modes] > .chk-net-head > span:nth-child(4) { display: none; }
}

/* The customer-rights matrix (`macros/customer_rights.html`, API-ADR-109, ADR-068): per group a
   table category × action (the first column sticks while the categories scroll sideways) and a
   box list for the rights without category. „Gruppe" marks what the customer's group already
   grants, „fehlt im Katalog" a right the org cannot store yet. */
.kr { display: grid; gap: 1rem; }
.kr-block { display: grid; gap: .5rem; margin-top: .5rem; }
.kr-block > .kr-note { margin: 0; }
.kr-title { margin: 0; font-size: .9375rem; font-weight: 600; }
.kr-sec { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.kr-head {
  margin: 0; padding: .375rem .625rem; background: var(--muted);
  border-bottom: 1px solid var(--border); font-size: .75rem; font-weight: 600;
  text-transform: uppercase; letter-spacing: .03em; color: var(--muted-foreground);
}
.kr-scroll { overflow-x: auto; }
.kr-table { border-collapse: collapse; width: 100%; font-size: .8125rem; }
.kr-table th, .kr-table td { padding: .3125rem .5rem; border-bottom: 1px solid var(--border); }
.kr-table tbody tr:last-child > * { border-bottom: 0; }
.kr-table td { text-align: center; white-space: nowrap; }
.kr-table thead th { font-size: .75rem; font-weight: 600; color: var(--muted-foreground); }
.kr-col { text-align: center; min-width: 5.5rem; }
.kr-all { background: color-mix(in oklab, var(--muted) 45%, transparent); }
.kr-rowhead {
  text-align: left; font-weight: 400; min-width: 16rem; position: sticky; left: 0;
  background: var(--card, var(--background)); z-index: 1;
}
.kr-top > .kr-rowhead, .kr-top > td { font-weight: 600; }
.kr-text { display: block; }
.kr-perm { font-size: .6875rem; color: var(--muted-foreground); }
.kr-plain { padding: .375rem; }
/* Wrapped, not truncated: the description and the right's name ARE the information. */
.kr-plain .chk > span { white-space: normal; }
.kr-grp, .kr-seed {
  display: inline-block; margin-left: .25rem; padding: 0 .3125rem; border-radius: 999px;
  font-size: .625rem; border: 1px solid var(--border); color: var(--muted-foreground);
  vertical-align: middle; white-space: nowrap; flex: none;
}
.kr-seed { border-color: var(--warn); color: var(--warn); }
.kr-note { margin: .375rem .625rem; font-size: .8125rem; color: var(--muted-foreground); }
.kr-templates {
  display: flex; flex-wrap: wrap; align-items: center; gap: .375rem; margin-bottom: .75rem;
}
.kr-templates-head { font-size: .8125rem; font-weight: 600; margin-right: .25rem; }
.kr-templates > .kr-note { margin: 0 0 0 .25rem; flex-basis: 100%; }
/* The form's first submit button (`default_submit`): rendered, so implicit submission (Enter)
   picks it, but out of sight and out of the tab order. */
.kr-default { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
.kr-eff { display: grid; gap: .5rem; }
.kr-eff > .kr-note { margin: 0; }
.kr-eff > .kr-head { border: 1px solid var(--border); border-radius: var(--radius); }
.kr-yes { color: var(--success, currentColor); font-weight: 600; }
.kr-no { color: var(--muted-foreground); }
.kr-allowed { margin: 0; padding-left: 1rem; font-size: .8125rem; display: grid; gap: .25rem; }

/* The footer of a card. Only THAT — `.dialog > * > footer` already brings
   `justify-content: flex-end` and `gap: .5rem` in Basecoat, `.card >
   footer` on the other hand only `display: flex; align-items: center`. Checked, not
   guessed: otherwise a rule that does nothing would have stood here. */
.card > footer { justify-content: flex-end; gap: .5rem; }

/* --- Type-dependent field blocks ------------------------------------------
   A section with one block per product type, of which only one is ever visible.
   Two forms use the same construction: the product form (`specs`,
   `app/base/productspecs.py`) and „Service anlegen" together with the type data page
   (`app/base/servicedetails.py`).

   **Until 2026-08-09 these rules stood in a `<style>` in the
   product template** and were called `.pr-*`. That was right exactly as long as there
   was one form — the second one took over the class names and then stood there
   without a border, without spacing and with a `<legend>` in the middle of the text. A
   style that two pages need belongs in the one file both load.

   `typ-` and not `pr-`, because it is no longer about products; checked against Basecoat's
   name list (ADR-002), none of the three is taken there. */
.typ-sec { margin-top: 1.25rem; }
.typ-sec > h3 { margin: 0 0 .25rem; font-size: .9375rem; font-weight: 600; }
.typ-sec > p { margin: 0 0 .75rem; font-size: .8125rem; color: var(--muted-foreground); }

/* `<fieldset>`, because that IS the group of fields — and because `legend`
   expresses the belonging without extra aria tinkering.
   `min-inline-size: 0` is not polish: a fieldset has
   `min-width: min-content` by itself, and in it the form grid otherwise blows up the
   card width. */
.typ-block {
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: .875rem 1rem 1rem; margin: 0 0 .75rem; min-inline-size: 0;
}
.typ-block > legend { padding: 0 .375rem; font-size: .8125rem; font-weight: 600; }
.typ-block > .typ-lead {
  margin: 0 0 .875rem; font-size: .8125rem; color: var(--muted-foreground);
}
/* `[hidden]` against Basecoat's Preflight, which sets `display` on elements — without
   the rule hidden blocks would stay visible. */
.typ-block[hidden] { display: none; }
/* Panel-ADR-096: the same for what the product filter and „Server aus dem Bestand" toggle on
   /services/new and /services/{uuid}/edit — a field box, an option of a selection, a hint
   (`.field` and `[role=option]` get `display` from Basecoat, `.hinweis` from below). */
.field[hidden], .select [role="option"][hidden], .hinweis[hidden] { display: none; }

/* A muted row with an icon in front — the little brother of `.alert`, for
   hints that do not deserve a box. Stood as `.svc-note` in the `<style>` of the
   service page; the type data form next to it needs the same shape, and so
   it belongs here (see `.typ-sec` above, same case).
   `svg { flex-shrink: 0 }`, because Basecoat's Preflight makes `svg` a block and
   a flex item otherwise shrinks at the text. */
.hinweis {
  display: flex; align-items: flex-start; gap: .5rem; margin: 0;
  font-size: .8125rem; color: var(--muted-foreground);
}
.hinweis svg { flex-shrink: 0; margin-top: .1rem; }
.hinweis .mono {
  display: block; margin-top: .2rem; opacity: .75;
  font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace; font-size: .72rem;
}
/* „VMID prüfen" next to its hint (API-ADR-144 Nachtrag): button and sentence on one line. */
.hinweis[data-placement-check] { align-items: center; margin-top: .75rem; }
/* The default button of a form that carries a second submit button („VMID prüfen"): Enter presses the
   FIRST submit button in tree order, and that would be the check instead of saving. Rendered (not
   `display: none`, which not every browser treats as the default button), but invisible, silent for
   screen readers and out of the tab order. */
.default-submit {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* --- Error page (without shell) -------------------------------------------
   The box was called `.err` — and that was a **live** naming conflict, not a
   theoretical one. `.err` is at the same time one of the four tones from `status.py`, and
   that travels across the whole panel into the markup as a bare class name
   (`<span class="pill {{ ton }}">`). Both rules have specificity (0,1,0),
   `.err` stood further down in this file — so it won: every pill with
   the tone `err` got `padding: 2rem`, `max-width: 32rem` and `text-align:
   center` and stood in the page as a palm-sized red block. Affected
   were „Dringend" (ticket priority), „Überfällig", „Gesperrt",
   „Fehlgeschlagen", „Betrugsverdacht" and „Abgelehnt" — it became visible
   on the ticket page, where the pill appears twice.

   The same trap as in the header of this file, only against ourselves instead of against
   Basecoat. The ADR-002 addendum describes it for the dashboard and solves it there
   via `data-tone`; switching the pill markup to `data-tone` would be a
   rework of about forty templates. But the conflict already disappears
   when the one side gives up its generic name: `.err-card` now carries
   the same prefix as its six siblings (`.err-body`, `.err-code`,
   `.err-detail`, `.err-actions`, `.err-debug`, `.err-hint`) and can no longer collide
   with any tone. Exactly ONE template uses it (`errors/error.html`). */
.err-body { display: flex; align-items: center; justify-content: center; min-height: 100vh; min-height: 100dvh; }
.err-card { text-align: center; padding: 2rem; max-width: 32rem; }
.err-code { font-size: 3.5rem; font-weight: 700; line-height: 1; color: color-mix(in oklch, var(--foreground) 25%, transparent); }
.err-card h1 { margin: .75rem 0 0; font-size: 1.25rem; font-weight: 600; }
.err-detail { margin: .75rem 0 0; font-size: .875rem; color: var(--muted-foreground); word-break: break-word; }
.err-actions { margin-top: 1.5rem; display: flex; gap: .5rem; justify-content: center; }
/* `.err-actions .btn` stood here — a third replica of the button (own
   height, own colours, own border). Because app.css lies outside any @layer,
   it beat Basecoat's `.btn[data-variant="outline"]` COMPLETELY:
   since the templates were switched to `data-variant`, all three
   buttons of the error page were identical primary buttons without ranking — and without
   the focus ring they have everywhere else. */

/* --- Debug box on the error page ---------------------------------------- */
/* Only in the DOM at all with DEBUG=1 (app/__init__.py). Monospace + wrapping,
   because raw API responses stand there, not prose. */
.err-debug {
  margin: 1.25rem 0 .5rem; padding: .875rem 1rem; text-align: left;
  background: color-mix(in oklch, var(--foreground) 6%, var(--background));
  border: 1px solid var(--border); border-radius: .5rem;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: .8125rem; line-height: 1.55;
  white-space: pre-wrap; word-break: break-word; overflow-x: auto;
}
.err-hint { font-size: .75rem; color: var(--muted-foreground); margin: 0 0 .75rem; }
.err-hint code {
  background: var(--muted); padding: .05rem .3rem; border-radius: .25rem;
}

/* --- Game server pages (ADR-038) -------------------------------------------
   The page family under `/services/{uuid}/gameserver/…` (twelve templates in
   `services/templates/services/gameserver/`). Prefix `gs-`, so that nothing gets hit by
   Basecoat (ADR-002). Everything else there is Basecoat (`btn`,
   `card`, `dialog`, `field`, `select_box()`) or is already here (`.tbl`,
   `.dp-kv`, `.dp-grid`, `.hinweis`, `.pill`).
   - `.gs-konsole`: the log tail and the live output — fixed height, own
     scroll area, dark background from the tokens, wrapping instead of horizontal scrolling.
   - `.gs-editor`: the text field of the file editor — monospace, without wrapping (a
     configuration line should stay one line), tall enough for real files.
   - `.gs-pfad`: the folder path as breadcrumbs above the file list.
   - `.gs-auswahl`: the bar of bulk actions above the file list.
   - `.gs-rahmen`: the invisible target frame of the upload form (the response
     from Wings is of foreign origin and is not shown). */
.gs-abstand { margin-top: .75rem; }
.gs-abstand-unten { margin-bottom: .75rem; }
.gs-innen { padding: .75rem 1rem; }
.gs-knoepfe { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; }
.gs-knoepfe form { margin: 0; }
.gs-rechts { justify-content: flex-end; }
.gs-leise { font-size: .75rem; color: var(--muted-foreground); }
.gs-mono, .gs-code, .gs-konsole, .gs-editor {
  font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace;
}
.gs-mono { font-size: .75rem; }
.gs-code { margin: 0; font-size: .75rem; white-space: pre-wrap; overflow-wrap: anywhere; }
.gs-konsole {
  margin: 0; height: 28rem; overflow: auto; padding: .75rem 1rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: color-mix(in oklch, var(--foreground) 6%, var(--background));
  font-size: .75rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere;
}
.gs-live { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
.gs-live > .hinweis { flex-basis: 100%; }
.gs-editor { min-height: 28rem; font-size: .75rem; line-height: 1.5; white-space: pre; tab-size: 4; }
.gs-pfad { display: flex; flex-wrap: wrap; gap: .3rem; align-items: center; margin-bottom: .75rem;
           font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace; font-size: .8125rem; }
.gs-auswahl { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center;
              padding: .5rem 1rem; border-bottom: 1px solid var(--border); }
.gs-feld-klein { min-width: 7rem; }
.gs-modus { width: 5rem; height: 2rem; }
.gs-kopie { display: flex; gap: .5rem; align-items: center; }
.gs-kopie > input { flex: 1; min-width: 0; }
.gs-rahmen { width: 100%; height: 0; border: 0; }

/* --- Service frame (services/_frame.html, 21.10.) ------------------------------
   The detail page and every tool page below it share one header and one
   navigation (`_frame.py`). The navigation is the same list at every width:
   from 48rem on a sticky column on the left, below that a tab row that scrolls
   sideways. Colours and radii only from the shell's tokens. */
.svc-head { display: flex; align-items: flex-start; gap: 1rem; padding-bottom: 1rem;
            margin-bottom: 1.25rem; border-bottom: 1px solid var(--border); }
.svc-head-main { display: grid; gap: .4rem; min-width: 0; }
.svc-head-title { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .75rem; }
.svc-head-name { margin: 0; font-size: 1.375rem; font-weight: 600; letter-spacing: -.01em;
                 line-height: 1.25; overflow-wrap: anywhere; text-decoration: none; }
a.svc-head-name:hover { text-decoration: underline; text-underline-offset: 3px; }
.svc-head-facts { display: flex; flex-wrap: wrap; gap: .25rem 1.25rem; margin: 0;
                  font-size: .8125rem; }
.svc-head-facts a:hover { text-decoration: underline; text-underline-offset: 2px; }
.svc-head-facts .mono { font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace;
                        font-size: .75rem; }
.svc-head-key { margin-right: .35rem; color: var(--muted-foreground); }
.svc-head > .right { margin-left: auto; display: flex; flex-wrap: wrap; gap: .5rem;
                     align-items: center; }
.svc-head > .right:empty { display: none; }

.svc-frame { display: grid; grid-template-columns: 13.5rem minmax(0, 1fr); gap: 1.75rem;
             align-items: start; }
.svc-main, .svc-tool { min-width: 0; }
.svc-tool.narrow { max-width: 880px; }
/* The tool pages keep their own heading — one size smaller than the name above. */
.svc-main .page-head { margin-bottom: 1rem; }
.svc-main .page-head h1 { font-size: 1.125rem; }

/* `#content` is the scroll container (see `.dp-side`) — `top` does not
   subtract the topbar again. */
.svc-nav { position: sticky; top: 1rem; display: grid; gap: 1rem; align-content: start;
           max-height: calc(100dvh - var(--top-h) - 2rem); overflow-y: auto; }
.svc-nav h3 { margin: 0 0 .25rem; padding: 0 .5rem; font-size: .6875rem; font-weight: 600;
              letter-spacing: .05em; text-transform: uppercase; color: var(--muted-foreground); }
.svc-nav ul { display: grid; gap: 1px; margin: 0; padding: 0; list-style: none; }
.svc-nav a { display: flex; align-items: center; gap: .5rem; padding: .375rem .5rem;
             border-radius: calc(var(--radius) - 2px); font-size: .8125rem; white-space: nowrap;
             color: var(--muted-foreground); text-decoration: none; }
.svc-nav a > span { overflow: hidden; text-overflow: ellipsis; }
.svc-nav a:hover { background: var(--accent); color: var(--accent-foreground); }
.svc-nav a[aria-current="page"] { background: var(--accent); color: var(--foreground);
                                  font-weight: 500;
                                  box-shadow: inset 2px 0 0 var(--foreground); }

@media (max-width: 48rem) {
  .svc-head { flex-direction: column; }
  .svc-head > .right { margin-left: 0; }
  .svc-frame { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
  .svc-nav { position: static; max-height: none; display: flex; gap: .25rem;
             overflow-x: auto; overflow-y: hidden; padding-bottom: .5rem;
             border-bottom: 1px solid var(--border); scrollbar-width: thin; }
  .svc-nav-group { display: flex; flex: none; align-items: center; }
  .svc-nav-group + .svc-nav-group { margin-left: .25rem; padding-left: .5rem;
                                    border-left: 1px solid var(--border); }
  .svc-nav h3 { display: none; }
  .svc-nav ul { display: flex; gap: .25rem; }
  .svc-nav a[aria-current="page"] { box-shadow: inset 0 -2px 0 var(--foreground); }
}

/* Panel-ADR-099: a list row that may not be edited (`ListPage.row_editable`) — the lock with its
   reason in the edit column instead of a button, quiet like a muted cell. */
.row-locked { display: inline-flex; gap: .35rem; align-items: center; white-space: nowrap;
              font-size: .75rem; color: var(--muted-foreground); }
.row-locked svg { width: .875rem; height: .875rem; }

/* The name cell of the file list without JavaScript (lane filemanager, 01.11.2026): the icon LEFT
   of the name — `svg { display: block }` (above) put it on a line of its own. */
.gs-name { display: inline-flex; align-items: center; gap: .45rem; }
.gs-name > svg, .gs-name > i { flex-shrink: 0; }
/* The host of the file manager (`files.html`): it holds the page without JavaScript
   (`[data-fm-fallback]`) and, with it, the app (`css/file-manager.css` hides the former). */
.fm-host { display: block; min-width: 0; }
/* The file manager's frame on the files page (`file-manager.js`, `root_class`). */
.fm-card { border: 1px solid var(--border); border-radius: var(--radius-lg, .75rem); background: var(--card); }
