/* Geek Corporate Design v2 — the single source of truth for every value in the UI.
   Architect's sketchbook meets terminal: cool paper, ink blue, pen red, sharp edges,
   graph-paper grid. Tokens are final (NF-04, high fidelity): no colour literal belongs
   anywhere else. Reference: GeekDesign repo (design/geekdesign), reference/HANDOFF.md.

   Sections, in file order:
     fonts · tokens · base · utilities · masthead · theme toggle · buttons · layout ·
     maintenance notice (F-11) · panels · tiles (F-16) · teaser · forms · login ·
     admin · reordering (F-17) · admin forms · admin register (F-12) · About (F-18)

   A rule belongs to the section its selector names — .btn-* under buttons, .tile-* under
   tiles — and not to the page that happened to need it first. That is the only filing rule
   here, and it is what keeps a 2000-line stylesheet searchable. */

/* --- fonts ----------------------------------------------------------------- */

/* IBM Plex, self-hosted (NF-04): no Google Fonts, no CDN — the homelab has to work without
   internet, and the CSP stays default-src 'self'. Fetched by tools/fetch-assets.sh.
   font-display: swap keeps text readable while the file loads; the fallbacks below are
   metric-ish neighbours, so the swap does not shove the layout around (NF-05). */
@font-face {
  font-family: "IBM Plex Sans";
  src: url("../fonts/IBMPlexSans-Regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "IBM Plex Sans";
  src: url("../fonts/IBMPlexSans-SemiBold.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "IBM Plex Serif";
  src: url("../fonts/IBMPlexSerif-SemiBold.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

/* Bold is only for the logo G (v2 spec: Serif 700 inside the compass mark). */
@font-face {
  font-family: "IBM Plex Serif";
  src: url("../fonts/IBMPlexSerif-Bold.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "IBM Plex Mono";
  src: url("../fonts/IBMPlexMono-Regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "IBM Plex Mono";
  src: url("../fonts/IBMPlexMono-SemiBold.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}

/* --- tokens ---------------------------------------------------------------- */

/* NF-04 asks that a colour value stand here ONCE. It did not: the light palette, the dark
   palette and the manual-override palette each carried their own copy of every hex, so a
   revision had to be made in two or three places and a missed one was invisible until
   somebody looked at the wrong theme.

   The split below is what makes "once" true. --light-* and --dark-* hold the VALUES, each
   stated a single time. Everything after them holds nothing but the MAPPING — which palette
   the live token points at. A wrong mapping shows up at once (the whole page loses a colour);
   a drifted value never does. So the part that is duplicated is the part that cannot rot. */
:root {
  /* -- palette: the ink-blue ramp ----------------------------------------------
     Three tones, and dark mode invents no fourth: it moves one step up the same ladder —
     Himmel becomes the fill, Tintenblau becomes the deep tone. The dark entries therefore
     reference the light ones instead of restating their hex, which is also what keeps the
     ramp a ramp: shift one tone and the night side shifts with it. */
  --light-accent: #1F3D7A;        /* Tintenblau */
  --light-accent-hover: #3A5FB0;  /* Himmel */
  --light-accent-deep: #132A5C;   /* Tinte */
  --dark-accent: var(--light-accent-hover);
  --dark-accent-hover: #4569BE;
  --dark-accent-deep: var(--light-accent);

  /* accent — fountain-pen ink blue.
     Two roles, not one (WCAG-driven, see the dark values below): "fill" (--accent/-hover/
     -deep, --danger) is a background with light text sitting ON it — buttons, the toggle
     track. "text" (--accent-text/-hover, --success-text, --danger-text, --warning-text) is
     for links/labels/dots/the logo stroke sitting directly on the page background, with
     nothing under it. In light mode text == fill (9.8:1 on paper); in dark mode ink blue
     and pen red on near-black fall under 2:1 — nowhere near WCAG AA (4.5:1 text, 3:1
     graphics/UI) — so dark mode needs a visibly brighter "text" tone. Values below. */
  --light-accent-text: var(--light-accent);
  --light-accent-text-hover: var(--light-accent-hover);

  /* Text: sits directly on the page/panel background, nothing light drawn on top of it —
     needs more headroom than a fill does. Checked against all three dark surfaces
     (bg/surface/surface-raised): 5.0–6.1:1. */
  --dark-accent-text: #6E93D6;
  --dark-accent-text-hover: #93B2E6;

  /* -- palette: neutral, light (cool paper) ---------------------------------- */
  --light-bg: #FBFBF9;
  --light-surface: #F0F0ED;
  --light-surface-raised: #FFFFFF;
  --light-border: #DBDBD6;
  --light-muted: #A7A7A2;
  /* Same fill-vs-text split as the accent above, for the quiet tone. --muted is a surface
     value — borders, a disabled field, the ground of a dot. Read as TEXT on paper it is
     2.1:1, far under WCAG AA, and it was being used for the tile address, the coordinate
     line and the footer (NF-05). --muted-text is the readable sibling: 4.7:1 on --surface,
     5.2:1 on --bg, still visibly quieter than --text-secondary. Dark needs no second value —
     #84848A already clears AA on the night ground. */
  --light-muted-text: #6B6B67;
  --light-text-secondary: #494947;
  --light-text: #191917;
  /* info is ink on paper; on the night ground it flips to the paper tone */
  --light-info: var(--light-text);

  /* -- palette: neutral, dark (night workshop) ------------------------------- */
  --dark-bg: #111112;
  --dark-surface: #1A1A1B;
  --dark-surface-raised: #242426;
  --dark-border: #363638;
  --dark-muted: #84848A;
  --dark-muted-text: var(--dark-muted);
  --dark-text-secondary: #ADADB0;
  --dark-text: #F1F1EF;
  --dark-info: var(--dark-text);

  /* -- palette: the graph-paper grid ----------------------------------------- */
  --light-grid-line: rgba(60, 80, 150, 0.09);
  --light-grid-cell: 28px;
  --dark-grid-line: rgba(120, 150, 220, 0.06);
  --dark-grid-cell: 34px;

  /* -- status: a traffic light, and ONLY for operational state ----------------
     Revision 2026-07-20, replaces "no green, online is ink blue". Green/amber/red is read
     without thinking; blue-for-online has to be learned. The exception stays narrow: brand,
     links and buttons remain ink blue — widen it and the design becomes a traffic light. All
     three are levelled to ~5:1 contrast so no state shouts louder than another. Mirrors
     GeekDesign tokens/geek-tokens.css.

     The three fills carry no theme: as a fill with light text on it, --danger is already
     5.6:1 and its boundary against the page bg 3.2:1 — red only needs the dark-mode bump as
     TEXT. The light tones, read as text, drop under 3:1 on the night ground and get their own
     values; levelled to the red's 5.1:1 rather than pushed as bright as possible, because a
     neon green beside a muted red reads as a rendering fault instead of a state. */
  --success: #17703A;
  --warning: #9A4C07;
  --danger: #C22026;
  --light-success-text: var(--success);
  --light-warning-text: var(--warning);
  --light-danger-text: var(--danger);
  --dark-success-text: #3FA96A;
  --dark-warning-text: #CF8226;
  --dark-danger-text: #F2666D;

  /* -- theme-independent ------------------------------------------------------
     The label ON a filled accent button — the "light text sitting on it" the accent note
     above describes. Deliberately NOT --bg: ink blue stays dark in both themes, so this stays
     paper in both, while --bg flips to near-black at night and would swallow the label. Kept
     out of the mapping blocks for exactly that reason. --on-danger is its twin, stated
     separately so the two button roles can part ways if --danger ever lightens; change one,
     look at both. */
  --on-accent: #FBFBF9;
  --on-danger: #FBFBF9;

  --font-serif: "IBM Plex Serif", Georgia, serif;
  --font-sans: "IBM Plex Sans", system-ui, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, monospace;

  --content-width: 1080px;

  /* The tile box (F-16). Up here rather than on .tiles because the wide-screen margin is stated
     in tile widths and sits on the grid's PARENT — a custom property declared on the child would
     not reach it, custom properties inherit downwards only. */
  --tile-width: 340px;
  --tile-height: 250px;

  --radius: 0; /* sharp edges. Only real switches and status dots are round. */

  /* -- mapping: light is the default ----------------------------------------- */
  --accent: var(--light-accent);
  --accent-hover: var(--light-accent-hover);
  --accent-deep: var(--light-accent-deep);
  --accent-text: var(--light-accent-text);
  --accent-text-hover: var(--light-accent-text-hover);
  --bg: var(--light-bg);
  --surface: var(--light-surface);
  --surface-raised: var(--light-surface-raised);
  --border: var(--light-border);
  --muted: var(--light-muted);
  --muted-text: var(--light-muted-text);
  --text-secondary: var(--light-text-secondary);
  --text: var(--light-text);
  --info: var(--light-info);
  --grid-line: var(--light-grid-line);
  --grid-cell: var(--light-grid-cell);
  --success-text: var(--light-success-text);
  --warning-text: var(--light-warning-text);
  --danger-text: var(--light-danger-text);
}

/* -- mapping: dark ------------------------------------------------------------
   The next two blocks are identical on purpose and must stay identical — they are the same
   switch thrown by two different hands: the system preference, and the viewer's explicit pick
   in the masthead. CSS cannot share a declaration block across a media query, so the MAPPING
   is written twice; no value is, which is the point of the palette above.

   :not([data-theme="light"]) is what makes an explicit "light" pick win over a dark system
   preference. Without it prefers-color-scheme keeps applying underneath the attribute, and the
   old fix — a third full palette in a [data-theme="light"] block — is exactly the duplication
   this section exists to remove. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --accent: var(--dark-accent);
    --accent-hover: var(--dark-accent-hover);
    --accent-deep: var(--dark-accent-deep);
    --accent-text: var(--dark-accent-text);
    --accent-text-hover: var(--dark-accent-text-hover);
    --bg: var(--dark-bg);
    --surface: var(--dark-surface);
    --surface-raised: var(--dark-surface-raised);
    --border: var(--dark-border);
    --muted: var(--dark-muted);
    --muted-text: var(--dark-muted-text);
    --text-secondary: var(--dark-text-secondary);
    --text: var(--dark-text);
    --info: var(--dark-info);
    --grid-line: var(--dark-grid-line);
    --grid-cell: var(--dark-grid-cell);
    --success-text: var(--dark-success-text);
    --warning-text: var(--dark-warning-text);
    --danger-text: var(--dark-danger-text);
  }
}

:root[data-theme="dark"] {
  --accent: var(--dark-accent);
  --accent-hover: var(--dark-accent-hover);
  --accent-deep: var(--dark-accent-deep);
  --accent-text: var(--dark-accent-text);
  --accent-text-hover: var(--dark-accent-text-hover);
  --bg: var(--dark-bg);
  --surface: var(--dark-surface);
  --surface-raised: var(--dark-surface-raised);
  --border: var(--dark-border);
  --muted: var(--dark-muted);
  --muted-text: var(--dark-muted-text);
  --text-secondary: var(--dark-text-secondary);
  --text: var(--dark-text);
  --info: var(--dark-info);
  --grid-line: var(--dark-grid-line);
  --grid-cell: var(--dark-grid-cell);
  --success-text: var(--dark-success-text);
  --warning-text: var(--dark-warning-text);
  --danger-text: var(--dark-danger-text);
}

/* --- base ------------------------------------------------------------------ */

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

/* min-height and the column: pushes the foot to the bottom on a short page, so the band frames
   the content instead of floating halfway up the viewport. */
body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  background-color: var(--bg);
  background-image:
    linear-gradient(var(--grid-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
  background-size: var(--grid-cell) var(--grid-cell);
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 17px;
  line-height: 1.6;
}

h1,
h2,
h3 {
  font-family: var(--font-serif);
  font-weight: 600;
  line-height: 1.15;
}

a {
  color: var(--accent-text);
}

a:hover {
  color: var(--accent-text-hover);
}

:focus-visible {
  outline: 2px solid var(--accent-text);
  outline-offset: 2px;
}

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  margin: 0;
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.22em;
  color: var(--accent-text);
}

/* A short drafting rule leading into the eyebrow label. */
.eyebrow::before {
  content: "";
  width: 24px;
  height: 1px;
  background: var(--accent-text);
}

.container {
  max-width: var(--content-width);
  margin: 0 auto;
  padding: 0 24px;
}

/* The admin area: a wide service table with nine columns does not fit the reading column, and the
   check-now action fell off the right edge (F-07). The wider measure lets it sit on the page;
   the table keeps its own overflow-x for viewports narrower than the table itself. */
.container--wide {
  max-width: 1440px;
}

/* --- utilities ------------------------------------------------------------- */

/* .page-head, .page-title and .page-lead lived here. All three are gone with the hero line:
   the dashboard opens on the tiles, and no other page used them. */

/* Off-screen but still read aloud and still focusable — not display:none, which would remove
   it from the accessibility tree along with the layout. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* A machine reading set in the technical face — a handle, a claim value, a role name. */
.mono {
  font-family: var(--font-mono);
  font-size: 13px;
}

/* A note inside a diagnosis block (F-02): it explains an observation, it does not report a fault
   to the reader. Deliberately NOT .login-error — the mapper hints on /me wore that red box and
   greeted every Keycloak user with an instruction meant for whoever runs the realm. */
.diagnosis-hint {
  margin: 14px 0 0;
  padding-left: 12px;
  border-left: 2px solid var(--border);
  color: var(--text-secondary);
  font-size: 13px;
}

/* --- masthead -------------------------------------------------------------- */

/* A graph-paper band, not a thin bar: the drafting-table instrument-panel feel of the
   reference. The grid shows through a hair stronger here than on the body. */
.masthead {
  border-bottom: 1px solid var(--border);
  background-color: var(--surface);
  background-image:
    linear-gradient(var(--grid-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
  background-size: var(--grid-cell) var(--grid-cell);
}

/* flex-start, not flex-end: the brand block is taller than the nav because the coordinate
   line hangs below the lockup, and aligning to the bottom edge dropped the nav to the level
   of that small print instead of the logo it belongs to. */
/* The masthead carries the SAME grid as the tile board (F-16) — same track width, same gap, same
   centring, same padding — purely so that the row inside it can span all the tracks and therefore
   begin exactly where the first tile begins and end where the last one ends. The tiles are not
   touched: their centred layout is the fixed point, and the masthead is what moves onto it.
   Copying the values would be the obvious way and the wrong one, because the two would drift the
   first time a tile changes width; every number here is the same custom property the grid uses. */
.masthead-inner {
  display: grid;
  grid-template-columns: repeat(auto-fill, var(--tile-width));
  justify-content: center;
  gap: 18px;
  padding-block: 34px 26px;
  padding-inline: 24px;
}

.masthead-row {
  /* Every track, so the row's edges ARE the board's edges. */
  grid-column: 1 / -1;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 24px;
  flex-wrap: wrap;
}

@media (min-width: 2100px) {
  .masthead-inner {
    padding-inline: var(--tile-width);
  }
}

/* One column on a phone, as the board has (NF-05): a 340px track beside a 390px screen would
   push the wordmark off the edge. */
@media (max-width: 600px) {
  .masthead-inner {
    grid-template-columns: 1fr;
  }
}

.brand {
  display: flex;
  align-items: center;
  gap: 18px;
  text-decoration: none;
  color: var(--text);
}

.brand-mark {
  width: 56px;
  height: 56px;
  flex: none;
}

/* The serif G centred in the constructed mark; fill follows the text colour so the mark
   works on both grounds without a second asset (NF-04).
   It is an outline, not a <text>: the letter no longer waits for the webfont, so the mark
   cannot flash a fallback serif while IBM Plex Serif is still loading. The geometry comes
   from the design system — regenerate via GeekDesign/tools/glyph-to-path.py, never by hand. */
.brand-g {
  fill: var(--text);
}

/* The wordmark is the hero: IBM Plex Mono, big, with a blinking ink-blue cursor block —
   the "geek" console signature from the reference. */
.brand-word {
  display: inline-flex;
  align-items: center;
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: 52px;
  line-height: 0.9;
  letter-spacing: -0.01em;
}

.brand-cursor {
  display: inline-block;
  width: 0.42em;
  height: 0.92em;
  margin-left: 0.14em;
  background: var(--accent-text);
  animation: blink 1.1s steps(1) infinite;
}

@keyframes blink {
  50% {
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .brand-cursor {
    animation: none;
  }
}

.masthead-coords {
  margin: 10px 0 0;
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.22em;
  color: var(--muted-text);
}

/* --- theme toggle ---------------------------------------------------------- */

.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1px solid var(--text-secondary);
  border-radius: var(--radius);
  background: transparent;
  color: var(--text);
  cursor: pointer;
}

.theme-toggle:hover {
  border-color: var(--accent-text);
  color: var(--accent-text);
}

.theme-toggle-icon {
  width: 18px;
  height: 18px;
}

.theme-toggle-icon--moon {
  display: none;
}

/* Follows the system preference by default, an explicit data-theme wins either way — the same
   precedence, written the same way, as the colour tokens above: the media query steps aside for
   an explicit "light" instead of being undone by a third block afterwards. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle-icon--sun {
    display: none;
  }

  :root:not([data-theme="light"]) .theme-toggle-icon--moon {
    display: block;
  }
}

:root[data-theme="dark"] .theme-toggle-icon--sun {
  display: none;
}

:root[data-theme="dark"] .theme-toggle-icon--moon {
  display: block;
}

/* --- buttons --------------------------------------------------------------- */

/* "Verwaltung" is an <a> and "Abmelden" is a <button>, and they sat at different heights: a
   button does not inherit the page font and defaults to line-height: normal, while the anchor
   inherited the body's 1.6. Same padding, different box. Pinning font, line-height and
   box-sizing makes the element type stop mattering. */
/* min-height 44px on every interactive control below: NF-05 asks for 44x44 tap targets, and
   padding alone left the buttons at 42px and the register links at 41.6px — close enough to
   look right and still wrong on a phone. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 11px 20px;
  border-radius: var(--radius);
  font-family: inherit;
  font-weight: 600;
  font-size: 15px;
  line-height: 1.2;
  text-align: center;
  text-decoration: none;
  box-sizing: border-box;
  cursor: pointer;
}

.btn-primary {
  background: var(--accent);
  border: 1px solid var(--accent);
  color: var(--on-accent);
}

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

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

.btn-danger {
  background: var(--danger);
  border: 1px solid var(--danger);
  color: var(--on-danger);
}

/* A button that reads like the row's links — it is one action among them. */
.btn-link {
  background: none;
  border: none;
  padding: 0;
  color: var(--accent-text);
  font: inherit;
  cursor: pointer;
  text-decoration: underline;
}

/* Small is the type, not the target: the caption stays 11px mono, the button keeps the 44px
   height every other control has. "Will ich auch" measured 94x27px before (NF-05). */
.btn-small {
  padding: 6px 14px;
  font-size: 11px;
}

/* --- layout ---------------------------------------------------------------- */

main {
  padding-block: 56px 72px;
  flex: 1;
}

/* The foot carries the same band as the masthead — graph paper on --surface, hairline edge.
   The page is then closed by the same rule it opens with, instead of the footer being a strip
   of ordinary page background (GeekDesign .g-band, revision 2026-07-20). */
.site-footer {
  border-top: 1px solid var(--border);
  padding-block: 24px;
  background-color: var(--surface);
  background-image:
    linear-gradient(var(--grid-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
  background-size: var(--grid-cell) var(--grid-cell);
  color: var(--text-secondary);
  font-family: var(--font-mono);
  font-size: 12px;
}

/* The board's grid once more (F-16), so the footer line begins under the first tile and ends
   under the last. Masthead, category label and footer all read off the same tracks; copying the
   centring margin into three places is how they would drift apart. */
.site-footer-inner {
  display: grid;
  grid-template-columns: repeat(auto-fill, var(--tile-width));
  justify-content: center;
  gap: 18px;
  padding-inline: 24px;
}

.site-footer-row {
  grid-column: 1 / -1;
  display: flex;
  justify-content: space-between;
  gap: 16px;
}

@media (min-width: 2100px) {
  .site-footer-inner {
    padding-inline: var(--tile-width);
  }
}

@media (max-width: 600px) {
  .site-footer-inner {
    grid-template-columns: 1fr;
  }
}

.footer-mark {
  color: var(--muted-text);
}

/* The nav box is exactly as tall as the compass mark and starts at the same top edge, so
   centring inside it puts the nav's midline on the logo's midline — level with the G, not
   with the coordinate line under it. */
/* Wraps rather than pushing the page wide: at 360px the admin masthead (Verwaltung, the user
   name, Abmelden) ran 19px past the viewport and the whole page scrolled sideways (NF-05). */
.site-nav {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  min-height: 56px;
}

.site-nav form {
  margin: 0;
}

.user-name {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--text-secondary);
  text-decoration: none;
}

.user-name:hover {
  color: var(--accent-text);
}

/* --- maintenance notice (F-11) --------------------------------------------- */

/* A raised bar, sharp-edged like everything else, with a coloured left rule that carries the
   severity. The text carries the meaning too — colour is never the only signal (NF-05). */
.notice {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  margin-bottom: 32px;
  padding: 16px 20px;
  border: 1px solid var(--notice-color);
  border-left-width: 4px;
  background: var(--notice-tint);
  color: var(--text);
}

.notice-icon {
  width: 22px;
  height: 22px;
  flex: none;
  margin-top: 1px;
  color: var(--notice-color);
}

.notice-body {
  min-width: 0;
}

/* The optional headline (F-11): serif, so it reads as a title rather than a louder sentence. */
.notice-headline {
  margin: 0 0 3px;
  font-family: var(--font-serif);
  font-weight: 600;
  font-size: 17px;
  line-height: 1.25;
}

.notice-text {
  margin: 0;
  font-size: 16px;
}

/* Severity → the design's own tokens. Info is ink (paper tone in dark), warning and error
   share the one pen red — v2 knows no green and no amber. The tint is a faint wash of the
   same hue, kept subtle so the text stays readable on both grounds. */
.notice--info {
  --notice-color: var(--info);
  --notice-tint: color-mix(in srgb, var(--info) 8%, var(--surface));
}

.notice--warning {
  --notice-color: var(--warning-text);
  --notice-tint: color-mix(in srgb, var(--warning-text) 14%, var(--surface));
}

.notice--error {
  --notice-color: var(--danger-text);
  --notice-tint: color-mix(in srgb, var(--danger-text) 12%, var(--surface));
}

/* --- panels (login, admin, empty, error) ----------------------------------- */

/* .panel and .empty are one card in two names: .panel takes a --empty modifier, .empty is the
   standalone version the error and "nothing here yet" pages use. Stated together so the two
   cannot drift into two different cards. */
.panel,
.empty {
  margin-top: 32px;
  padding: 28px;
  border: 1px solid var(--border);
  background: var(--surface);
}

.panel--empty,
.empty {
  color: var(--text-secondary);
}

/* --- tiles (F-16) ---------------------------------------------------------- */

/* Category sections (F-03): a numbered eyebrow, then the grid. Sections are spaced apart so
   the grouping reads at a glance. */
/* Same grid as the masthead, for the same reason: the category label belongs on the edge of the
   tiles it labels, not on the page margin. It sat on the margin unnoticed as long as nothing
   else was aligned either — the moment the masthead moved onto the tile edge, the label was the
   one thing left standing somewhere else, and that reads as a mistake rather than as a margin.
   Label and board both span every track, so the label starts on the first tile while the board
   keeps its own centred layout inside a width that holds exactly the same columns. */
.tile-group {
  margin-top: 44px;
  display: grid;
  grid-template-columns: repeat(auto-fill, var(--tile-width));
  justify-content: center;
  gap: 18px;
}

.tile-group > * {
  grid-column: 1 / -1;
}

@media (max-width: 600px) {
  .tile-group {
    grid-template-columns: 1fr;
  }
}

/* The first group sits directly under the masthead now that the lead paragraph is gone — the
   44px existed to separate it from that text, and without it the page opened on a gap. Later
   groups keep the separation; main's own top padding is the page inset and stays. */
.tile-group:first-of-type {
  margin-top: 0;
}

.tile-group .eyebrow {
  margin-bottom: 16px;
}

/* Tiles have a FIXED size — height and width (F-16). The height so that one monitored part or
   two does not change the tile, the width so that a wide screen gives you MORE tiles rather than
   wider ones: a stretched tile carries no more information, only more empty surface.

   Both numbers are reckoned, not chosen. Height: 22 padding + 48 head + 14 gap + 24 description
   (one line at 15px/1.6) + 14 gap + 64 history (two bar rows and the scale) + 22 padding = 208,
   plus a 39px single-line footer = 247, rounded to 250. Width: 260, which is what the footer needs
   to hold "● ONLINE · 100 %" together with both links on one line.

   Two bar rows stay reserved even when a service has one — that reservation IS the fixed height
   (F-15). The description is where the room was found instead: three lines, then two, now one. */
.tiles {
  display: grid;
  grid-template-columns: repeat(auto-fill, var(--tile-width));
  grid-auto-rows: var(--tile-height);
  /* Centred, so the leftover after the last whole column sits as an even margin on both sides
     rather than as a ragged gutter on the right. */
  justify-content: center;
  gap: 18px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The grid — and only the grid — leaves the 1080px text container (F-16). That container was the
   real reason three tiles were the most that ever stood side by side; the tile width had nothing
   to do with it. Masthead, banner and every text page keep it. The negative margin cancels
   .container's own inset, so the tiles start where the page does. */
.tile-grid-wide {
  width: 100vw;
  max-width: 100vw;
  margin-inline: calc(50% - 50vw);
  padding-inline: 24px;
}

/* On a genuinely wide screen the grid keeps one tile width clear on either side (F-16). Edge to
   edge, the outermost tiles sat against the viewport with nothing to stand on; a margin the width
   of the thing it frames reads as deliberate where 24px reads as an oversight.

   The threshold is chosen, not fluid: the margin costs two tile widths, so it may only apply
   where there are columns to spare. With a 340px tile and an 18px gap that puts the floor at
   2100px — there the grid still holds four columns, one fewer than the same screen edge to edge.
   At 1900 it would drop to three against five, and a laptop cannot afford two columns for a
   margin. The number moves with --tile-width; it is not a round figure picked for looks. */
@media (min-width: 2100px) {
  .tile-grid-wide {
    padding-inline: var(--tile-width);
  }
}

/* One column on a phone, and there the tile is as wide as the column: a 340px card with a margin
   beside it on a 390px screen would be a postage stamp (NF-05). The height stays fixed. */
@media (max-width: 600px) {
  .tiles {
    grid-template-columns: 1fr;
  }
}

/* A column, because the footer is a SIBLING of the link rather than inside it: the footer carries
   links of its own (F-16) and an <a> in an <a> is invalid markup. overflow: hidden enforces the
   grid's fixed height against anything that would otherwise spill out. */
.tile {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--surface);
  /* radius 0 — sharp edges are the design, not a matter of taste (NF-04). */
  transition: border-color 0.12s ease;
}

/* The accent rule that lights up on hover — a drafting-table highlight, top edge. */
.tile::before {
  content: "";
  position: absolute;
  inset: -1px -1px auto -1px;
  height: 2px;
  background: var(--accent-text);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.14s ease;
}

.tile:hover {
  border-color: var(--accent-text);
}

.tile:hover::before {
  transform: scaleX(1);
}

.tile--locked:hover,
.tile--flat:hover {
  border-color: var(--border);
}

.tile--locked:hover::before,
.tile--flat:hover::before {
  transform: scaleX(0);
}

/* min-height: 0 so the link may shrink inside the fixed-height tile instead of pushing the footer
   off the bottom edge — a flex item's default min-height is its content. */
/* Less padding at the bottom than at the sides and top, and that asymmetry is deliberate: the
   footer already carries its own 10px of air above its rule, so a full 22px here stacked two
   paddings into one gap and left the bar rows floating in the middle of the tile. The 14px it
   frees is what pays for the description's second line at an unchanged tile height (F-16). */
.tile-link {
  display: flex;
  flex: 1;
  min-height: 0;
  flex-direction: column;
  gap: 14px;
  padding: 22px 22px 8px;
  text-decoration: none;
  color: var(--text);
}

/* The head is the identifying row: logo left, name and address right. align-items: start keeps
   the logo level with the name and not floating in the middle of a two-line heading. */
.tile-head {
  display: flex;
  align-items: flex-start;
  gap: 14px;
}

.tile-heading {
  display: block;
  /* min-width: 0 lets a long address wrap inside the flex item instead of stretching the
     tile and pushing the grid into a horizontal scroll (NF-05). */
  min-width: 0;
}

/* No frame and no separate ground: the logo stands on the tile itself. The bordered box read
   as a second card inside the card, and every service logo already brings its own shape —
   putting a square around a round mark just fought with it. Slightly larger now that the
   padding is gone, so the logo keeps its optical weight next to the name. */
.tile-image {
  display: block;
  width: 44px;
  height: 44px;
  flex: none;
  color: var(--muted-text);
}

.tile-image img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.tile-glyph {
  width: 100%;
  height: 100%;
}

.tile-name {
  display: block;
  font-family: var(--font-serif);
  font-weight: 600;
  font-size: 22px;
  line-height: 1.2;
}

/* The address, quiet and monospaced under the name — it reads as a machine address, not as a
   second title. overflow-wrap because a service url is one unbroken token. */
.tile-url {
  display: block;
  margin-top: 5px;
  color: var(--muted-text);
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.02em;
  overflow-wrap: anywhere;
}

/* Up to two lines (F-16) — a cap, not a reservation. One line cut most descriptions mid-sentence
   at 260px; at 340px the column stores 80 characters, which is exactly what the database allows,
   in two lines. Nothing is reserved: a short description still occupies one line, and because the
   bar rows are anchored to the bottom rather than stacked from the top, a one-line tile and a
   two-line tile still line their bars up across the grid. The full text stays in the title. */
.tile-description {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  color: var(--text-secondary);
  /* 14px, not 15: the description is the quietest line on the tile — name and address above it,
     status below — and a step down from the body size says so without a second colour. It also
     fits more of the 80 characters into the two lines it is allowed. */
  font-size: 14px;
}

/* The locked tile's status line (F-04). It has no footer: a locked tile has no status, no
   availability, no address and no references to show — the API never sent any of them. What is
   left is the padlock and the name of the missing role, and it stays inside the (link-less) body. */
.tile-status {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: auto;
  padding-top: 14px;
  border-top: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-secondary);
}

/* The footer (F-16): status, availability, address and the two references, on their own ground at
   the bottom edge of the tile. A sibling of the link, not part of it — see the .tile comment. */
.tile-footer {
  display: flex;
  align-items: center;
  /* No wrapping (F-16): a reserved second line that is almost never used is a hole on a tile of
     fixed height. What gives way instead is the link group, which may be clipped at the right
     edge — the status is the tile's statement, the links are the extra. */
  flex-wrap: nowrap;
  overflow: hidden;
  gap: 8px;
  margin-top: auto;
  padding: 10px 22px;
  border-top: 1px solid var(--border);
  /* The page ground, one step back from the card — that is what sets the strip apart in the
     reference. No new colour is invented for it (NF-04). */
  background: var(--bg);
  font-family: var(--font-mono);
  /* 10px, matching the probe labels and the time scale: everything on the tile that is a machine
     reading rather than prose now shares one size. It also buys the link group room before it has
     to give way (F-16) — at 11px "OFFLINE · 93.94 % WIKI CODE" was one word from clipping. */
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-secondary);
}

/* Middots between the readings, drawn by CSS so the markup stays free of punctuation spans. Not
   before the first item, not before the link group (which sits apart on the right), and not
   before the status word: the dot in front of it is the colour of that word, not a second
   reading, and a middot between them read as two (F-16). */
.tile-footer > * + *:not(.tile-links):not(.status-text)::before {
  content: "·";
  margin-right: 8px;
  color: var(--muted-text);
}

.tile-uptime {
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

.tile-uptime::after {
  content: " %";
  color: var(--text-secondary);
}

/* Pushed to the right edge, away from the readings. The first thing to give when the line runs
   out of room — hence the shrink and the hidden overflow (F-16). */
.tile-links {
  display: flex;
  flex: 0 1 auto;
  gap: 10px;
  margin-left: auto;
  overflow: hidden;
}

/* Status and percentage never shrink and never wrap: they are the reason the footer exists. */
.tile-footer .status-text,
.tile-uptime {
  flex: none;
  white-space: nowrap;
}

/* The hover underline is drawn with text-decoration, not with a transparent bottom border. The
   border was the usual trick for "underline on hover without the text jumping", and it did keep
   the text still — while making the footer one pixel taller on every tile that HAS links. That
   pixel came off the body above, so the bar rows of a tile with wiki/code links sat 1px higher
   than those of its neighbour without them. Invisible to the eye, plain in the measurement
   (tools/check-tile-geometry.sh, F-16). text-decoration does not occupy layout at all. */
.tile-links a {
  color: var(--accent-text);
  text-decoration: none;
  text-underline-offset: 3px;
}

.tile-links a:hover,
.tile-links a:focus-visible {
  text-decoration: underline;
}

/* The bar rows (F-14/F-15): one per monitored part, with the time scale printed once below.

   margin-top: auto rather than a fixed gap: whatever slack the fixed height (F-16) leaves belongs
   ABOVE the bars, not below them, because a hole between the bars and the footer separates two
   things the eye reads as one. The auto margin is a remainder, never a reservation — it collapses
   to nothing when the content fills the tile, which at the current height it very nearly does.
   That is the point: the height is set so there is almost no slack to place. Raising the height
   without raising the content turns this margin into the visible gap it is meant to absorb. */
.tile-history {
  display: block;
  margin-top: auto;
}

/* Caption left, bars right. The caption column keeps its width even when the label is empty, so
   the bars of every tile in the grid start on the same edge (F-15). */
.probe-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

.probe-row + .probe-row {
  margin-top: 4px;
}

/* line-height tied to the bar height, so the ROW is exactly as tall as what it shows. Left at the
   default the 10px caption made a ~16px line box, the row took its height from the text rather
   than from the measurement, and the reserved empty slot below a lone probe — sized against the
   bars — came out two pixels short. The caption labels the row; it must not size it. */
.probe-label {
  flex: none;
  width: 52px;
  overflow: hidden;
  font-family: var(--font-mono);
  font-size: 10px;
  line-height: 14px;
  letter-spacing: 0.1em;
  color: var(--muted-text);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.history-bars {
  display: flex;
  flex: 1;
  gap: 2px;
  /* One height for every row, whatever the tile monitors. Stretching a lone probe to fill the
     block was tried and looked wrong: a bar that tall reads as a different kind of reading rather
     than the same one drawn larger. The row keeps its size; the empty slot stays empty. */
  height: 14px;
  /* The bars share the width rather than scrolling: a fixed bar width would push the tile
     sideways on a phone, and NF-05 forbids the page scrolling horizontally. Each bar keeps a
     hairline minimum so a long window degrades into a dense band, not into nothing. */
  align-items: stretch;
}

/* A tile with one probe holds the second row's slot open — 14px row + 4px gap = 18px — instead of
   closing the gap or growing the row into it. Two things follow, and both matter: the row stays in
   the FIRST slot, so the caption of a single-probe tile sits on the same line as the first caption
   of its two-probe neighbour (the block is anchored to the bottom, so without the reservation the
   lone row would drop into the second slot and the labels would stagger across the grid); and the
   time scale below keeps its place, so the bar block measures the same on every tile. That is what
   makes the height independent of how the service happens to be assembled (F-15/F-16).

   PADDING, not margin, and that is the whole trick: .tile-history is a block context, so a bottom
   margin here collapses against the time scale's own 4px top margin to max(18, 4) = 18px, while
   the two-row case adds those 4px on top of its 32px. The lone row then sits four pixels low —
   plain to see, invisible in the arithmetic. Padding does not collapse. */
.tile-history:not(:has(.probe-row + .probe-row)) .probe-row {
  padding-bottom: 18px;
}

.bar {
  flex: 1 1 0;
  min-width: 2px;
  background: var(--muted);
}

.bar--online {
  background: var(--success-text);
}

.bar--offline {
  background: var(--danger-text);
}

/* Indented to where the bars start, so "vor 8 Std." sits under the first bar and not under the
   caption column (F-15). */
.history-scale {
  display: flex;
  justify-content: space-between;
  margin-top: 4px;
  margin-left: 60px;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.12em;
  color: var(--muted-text);
  text-transform: none;
}

/* The dot is the only round thing on a tile — a real indicator, not a rounded corner. */
.dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--muted);
  flex: none;
}

.status--online .dot {
  background: var(--success-text);
}

.status--offline .dot {
  background: var(--danger-text);
}

/* Stale means the last check is older than it should be: the service is not reported broken,
   but the reading can no longer be trusted. That is the amber case, not the red one. */
.status--stale .dot {
  background: var(--warning-text);
}

/* Never checked is NOT offline, and not amber either: a tile created two minutes ago has not
   failed at anything and nothing is wrong yet. Grey says "no reading", which is the truth. */
.status--unknown .dot {
  background: var(--muted);
}

/* Locked: greyed out, no link, no status dot. The greying is presentation — the protection
   already happened in the API, which never sent the url or the status (F-04). */
.tile--locked {
  opacity: 0.55;
}

.tile--locked .tile-link {
  cursor: not-allowed;
}

.status--locked {
  color: var(--muted-text);
}

/* A small padlock drawn from borders — no icon font, no external asset (NF-04). */
.lock-icon {
  width: 9px;
  height: 7px;
  border: 1px solid var(--muted);
  border-radius: 0 0 1px 1px;
  position: relative;
  flex: none;
}

.lock-icon::before {
  content: "";
  position: absolute;
  left: 1px;
  top: -4px;
  width: 5px;
  height: 5px;
  border: 1px solid var(--muted);
  border-bottom: 0;
  border-radius: 3px 3px 0 0;
}

/* The request affordance on a locked tile (F-13). The button itself is .btn .btn-secondary
   .btn-small — see the buttons section; only its place on the tile is stated here. */
.tile-request {
  margin-top: 12px;
}

.tile-requested {
  margin-top: 12px;
  font-size: 11px;
  letter-spacing: 0.12em;
  color: var(--muted-text);
}

/* --- teaser (anonymous) ---------------------------------------------------- */

/* Blurred placeholder tiles under a call to sign in. Nothing below comes from the database:
   these are invented shapes, so an anonymous visitor learns that MORE exists without learning
   WHAT exists — the line CLAUDE.md §5 draws. aria-hidden on the ghosts keeps the decoration
   out of the accessibility tree; the overlay carries the actual message (NF-05). */
.tile-teaser {
  position: relative;
  margin-top: 44px;
}

/* Three placeholders side by side cost nothing on a wide screen; stacked on a phone they are a
   thousand pixels of blur to scroll past before the call to sign in ever appears (F-16). Two of
   them step aside there — a layout decision, which is why it lives here and not in the loop. */
@media (max-width: 600px) {
  .tile--ghost:nth-child(n + 2) {
    display: none;
  }
}

.tiles--ghost {
  /* Two layers of removal: blurred, and faded. Neither is protection — there is nothing here
     to protect — both are there to read as "not yet yours". */
  filter: blur(5px);
  opacity: 0.5;
  pointer-events: none;
  user-select: none;
}

.tile--ghost:hover {
  border-color: var(--border);
}

.tile--ghost:hover::before {
  transform: scaleX(0);
}

/* Grey bars standing in for text. Deliberately uneven widths — three identical tiles would
   read as a rendering fault rather than as content behind frosted glass. */
.ghost-bar {
  display: block;
  height: 10px;
  background: var(--border);
}

.ghost-bar--name {
  width: 62%;
  height: 18px;
}

.ghost-bar--url {
  width: 45%;
  height: 9px;
  margin-top: 7px;
}

.ghost-bar--text {
  width: 100%;
}

.ghost-bar--short {
  width: 70%;
  margin-top: 7px;
}

.tile-teaser-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 24px;
  text-align: center;
}

.tile-teaser-lead {
  margin: 0;
  font-family: var(--font-serif);
  font-weight: 600;
  font-size: 24px;
}

.tile-teaser-text {
  margin: 0;
  max-width: 42ch;
  color: var(--text-secondary);
}

/* The blur is decoration; a reader who cannot see it must not be left with a bare button. */
@media (prefers-reduced-motion: reduce) {
  .tiles--ghost {
    filter: none;
    opacity: 0.25;
  }
}

/* --- forms ----------------------------------------------------------------- */

/* The login form and the admin forms are one field idiom in two places, and they were written
   out twice — the same six declarations on the label, the same seven on the input. Stated once
   here; what genuinely differs stays with the form it belongs to. */
.login-form label,
.admin-form label {
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-secondary);
}

.login-form input,
.admin-form input,
.admin-form textarea {
  /* 44px on every field, the login inputs included (NF-05). They stood at 38px — the very number
     this requirement already rejected for the theme toggle — and after the tidy-up they were the
     last interactive controls in the app under the bar. That the login field was smaller than its
     own submit button was nobody's decision; it was the one place the rule had not reached. */
  min-height: 44px;
  padding: 10px 12px;
  border: 1px solid var(--muted);
  border-radius: var(--radius);
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 14px;
}

/* --- login ----------------------------------------------------------------- */

.login-panel {
  max-width: 420px;
  margin-top: 40px;
  padding: 28px;
  border: 1px solid var(--border);
  background: var(--surface);
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.login-form {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.login-error {
  margin: 0;
  padding: 10px 12px;
  border: 1px solid var(--danger-text);
  color: var(--danger-text);
  font-size: 14px;
}

.login-divider {
  text-align: center;
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: var(--muted-text);
}

/* --- admin ----------------------------------------------------------------- */

/* The table scrolls in its own container on a phone instead of blowing up the page (NF-05). */
.admin-table-wrap {
  overflow-x: auto;
  border: 1px solid var(--border);
  background: var(--surface);
}

.admin-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 15px;
}

.admin-table th {
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-secondary);
  text-align: left;
}

.admin-table th,
.admin-table td {
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
}

.row--disabled {
  opacity: 0.5;
}

.status-online-text {
  color: var(--success-text);
}

.status-offline-text {
  color: var(--danger-text);
}

/* display:flex on a <td> stops it being a table cell, and its children then behave like flex
   items: they may be squeezed below their own content width. The check-now action needs 82px and
   was handed 47.6 below 1024px — the label broke across two lines and took the row's height with
   it, which reads as a cut-off label (NF-05, measured 2026-07-27). Above 1280px there happened to
   be room, so it only showed on the narrow screen.

   flex: none is the fix and wrap is the concession: when the room really is gone the actions go
   under each other, which also lets the table itself get narrower on a phone (806px instead of
   970 at a 500px viewport) — less sideways scrolling, not more. */
.admin-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
}

.admin-actions > * {
  flex: none;
}

/* An action names itself in one piece or not at all. */
.admin-actions .btn-link,
.admin-actions a {
  white-space: nowrap;
}

.admin-actions form {
  margin: 0;
}

/* The action column of the access-request register (F-13). It carried this class with no rule
   behind it at all, and the cell therefore behaved like any other: the table's auto layout is free
   to squeeze a column down to its longest WORD, so "Als erledigt markieren" came apart into three
   lines at 500px and two at 768 while sitting on one at 1280. Same promise as above — the action
   names itself in one piece — and the ~90px the column gains cost nothing: on a screen that narrow
   the table is already scrolling inside its own container (NF-05). */
.row-actions {
  white-space: nowrap;
}

.row-actions form {
  margin: 0;
}

.admin-toolbar {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
}

.check-message {
  padding: 10px 14px;
  border: 1px solid var(--border);
  background: var(--surface);
}

.notice-saved {
  margin: 0 0 16px;
  padding: 10px 14px;
  border: 1px solid var(--success-text);
  color: var(--success-text);
}

/* --- reordering the admin list (F-17) -------------------------------------- */

/* The handle column: grip on the left, the two buttons beside it. The buttons are not a
   keyboard-only afterthought — they are visible to everyone, because an accessibility affordance
   nobody can find is not one (NF-05). */
.reorder-cell {
  white-space: nowrap;
}

.reorder-buttons {
  display: inline-flex;
  gap: 2px;
}

.reorder-buttons .btn-link {
  padding: 0 4px;
  font-size: 11px;
  line-height: 1;
}

/* Disabled at the ends of the list: the first row cannot go up. Dimmed rather than hidden, so the
   column does not change width from row to row. */
.reorder-buttons .btn-link:disabled {
  opacity: 0.3;
  cursor: default;
}

.drag-handle {
  margin-right: 8px;
  color: var(--muted-text);
  cursor: grab;
  user-select: none;
}

.admin-table--sortable tr[draggable="true"]:active .drag-handle {
  cursor: grabbing;
}

/* The row being dragged, and where it currently sits — dragging into the unknown is the failure
   mode of every hand-rolled drag and drop. Accent, from the tokens (NF-04). */
.row--dragging {
  opacity: 0.4;
  outline: 2px solid var(--accent-text);
  outline-offset: -2px;
}

.reorder-status {
  min-height: 1.2em;
  margin: 0 0 8px;
  font-size: 12px;
  color: var(--muted-text);
}

/* A failed save is not a quiet footnote: the rows have just jumped back, and the reason has to be
   as visible as the movement was. */
.reorder-status--failed {
  color: var(--danger-text);
}

/* --- admin forms ----------------------------------------------------------- */

.admin-form {
  max-width: 680px;
  margin-top: 32px;
  padding: 28px;
  border: 1px solid var(--border);
  background: var(--surface);
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.admin-form small {
  text-transform: none;
  letter-spacing: normal;
  font-family: var(--font-sans);
  font-size: 13px;
}

.checkbox-label {
  flex-direction: row !important;
  align-items: center;
}

/* .admin-form .checkbox is gone: it was a second checkbox idiom used only by the settings form,
   and it set display:flex and align-items:center without ever resetting flex-direction — so
   .admin-form label's `column` still won and the box sat stacked above its own text. The form
   uses .checkbox-label now, like every other admin form. */

/* The notice window (F-11): two optional ends, grouped so they read as one setting rather
   than two unrelated date fields. */
.notice-window {
  margin: 0;
  padding: 16px 18px 18px;
  border: 1px solid var(--border);
}

.notice-window legend {
  padding: 0 8px;
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-secondary);
}

.notice-window-hint {
  display: block;
  margin-bottom: 14px;
  color: var(--text-secondary);
  font-size: 13px;
}

/* What the server read back (F-11). Quiet, but not a hint: it states a fact about the values in
   the fields above, and it is the only place on the page that shows them in Swiss notation — the
   fields themselves are drawn in the browser's language, whatever that happens to be. */
.notice-window-read {
  /* No margin: the fieldset is a flex column with its own 16px gap, and adding to it put this
     line 30px below the fields — far enough to read as a separate thing rather than as a remark
     about them. */
  margin: 0;
  color: var(--text-secondary);
  font-size: 13px;
}

/* Wraps to one column on a phone rather than squeezing two date pickers side by side (NF-05). */
.notice-window-fields {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
}

.notice-window-fields label {
  margin: 0;
}

.admin-form-actions {
  display: flex;
  gap: 12px;
  margin-top: 8px;
}

/* The admin preview shows the logo exactly as the tile will: no frame, no separate ground.
   A framed preview of an unframed tile would be a lie about what you are about to publish. */
.admin-image-preview {
  width: 96px;
  height: 96px;
  object-fit: contain;
}

.validation-summary:empty,
.validation-summary:has(ul:empty) {
  display: none;
}

.role-list {
  list-style: none;
  padding: 0;
  margin: 8px 0 0;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.role-list li {
  padding: 4px 10px;
  border: 1px solid var(--border);
  background: var(--bg);
}

.field-hint {
  margin: -6px 0 18px;
  font-size: 13px;
  color: var(--text-secondary);
}

/* A fieldset is a flex container in its own right. Without this the form's 16px gap stopped at
   the fieldset border and the rows inside it sat flush against each other. */
.admin-form fieldset {
  display: flex;
  flex-direction: column;
  gap: 16px;
  border: 1px solid var(--border);
  padding: 24px 28px 8px;
  margin: 0 0 28px;
}

.admin-form legend {
  padding: 0 10px;
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-secondary);
}

/* --- admin register navigation (F-12) -------------------------------------- */

/* Wraps rather than scrolls: four registers on a phone belong under each other, not behind an
   edge the user has to discover (NF-05). */
.register {
  display: flex;
  flex-wrap: wrap;
  gap: 2px 24px;
  margin: 0 0 28px;
  padding-bottom: 0;
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.16em;
}

.register a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 10px 0 12px;
  color: var(--text-secondary);
  text-decoration: none;
  /* Reserves the underline's space so the active register does not shift the row. */
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
}

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

/* Colour is the cue, aria-current is the meaning — and the selector uses the attribute so the
   two can never drift apart (NF-05). */
.register a[aria-current="page"] {
  color: var(--accent-text);
  border-bottom-color: var(--accent-text);
}

/* --- About (F-18) ---------------------------------------------------------- */

/* Two columns: the gallery holds the left, the text the right. 5:7 rather than 1:1 because a
   column of prose needs the room and a portrait does not — an even split gave the photo more
   surface than the words it is there to illustrate. Both collapse to one column below 900px
   (NF-05), gallery first, which is the order the markup already has. */
/* The head runs full width across the top — name, tagline and the intro get the whole measure,
   so the tagline no longer wraps in a narrow column and the description has room to breathe.
   Below it the two columns begin: photo on the left, the role and contact blocks on the right. */
.about {
  display: grid;
  grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
  grid-template-rows: auto auto;
  grid-template-areas:
    "head    head"
    "gallery body";
  column-gap: 48px;
  row-gap: 32px;
  align-items: start;
  margin: 40px 0 72px;
}

.about-head {
  grid-area: head;
}

/* A fixed aspect box, so the page does not jump when the next photo has other proportions —
   the whole point of a cross-fade is that nothing else moves. 4:5 is the portrait ratio a
   phone camera produces without cropping. */
.about-gallery {
  grid-area: gallery;
  position: relative;
  margin: 0;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--surface);
}

.about-body {
  grid-area: body;
  /* Top-aligned, level with the top of the photo: "was ich mache" begins right under the head
     instead of floating down the page. The blocks and the photo are close enough in height that
     they also finish near each other — pushing the blocks to the foot only opened a gap up top,
     now that the head no longer shares this column. */
  align-self: start;
}

/* No photos yet is a normal state (F-18). Then the page is a text page and takes a text page's
   measure rather than stretching one column across a layout built for two — head over blocks,
   no empty gallery row. */
.about--textonly {
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: auto auto;
  grid-template-areas:
    "head"
    "body";
  max-width: 640px;
}

@media (max-width: 900px) {
  .about {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto auto auto;
    grid-template-areas:
      "head"
      "gallery"
      "body";
    row-gap: 32px;
  }

  /* One column with no photo: the empty gallery row would only add a second row-gap. */
  .about--textonly {
    grid-template-rows: auto auto;
    grid-template-areas:
      "head"
      "body";
  }

  /* One column means the gallery is as wide as the screen, and 4:5 of a phone's width is most of
     a screenful — the name would start below the fold. The cap keeps the picture an illustration
     rather than the page (NF-05). */
  .about-gallery {
    max-height: 52vh;
  }
}

.about-photos {
  position: absolute;
  inset: 0;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* All photos are stacked and only opacity moves — no transform, no reflow, and the box keeps
   its size whatever is on top of it. */
.about-photo {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 900ms ease-in-out;
}

.about-photo.is-active {
  opacity: 1;
}

.about-photo img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Reduced motion cuts the fade. The module separately declines to start the rotation at all —
   this rule only makes sure a deliberate press of the play button switches rather than glides. */
@media (prefers-reduced-motion: reduce) {
  .about-photo {
    transition: none;
  }
}

/* The pause control (WCAG 2.2.2). It sits ON the photo, so it carries its own ground rather than
   relying on whatever pixels happen to be underneath. */
.about-gallery-toggle {
  position: absolute;
  right: 12px;
  bottom: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-raised);
  color: var(--text-secondary);
  cursor: pointer;
}

.about-gallery-toggle:hover {
  border-color: var(--accent-text);
  color: var(--accent-text);
}

/* Icons as masks rather than <img>: a masked SVG takes currentColor, so one file serves both
   themes and every state. Self-hosted, same-origin — the CSP needs no exception (NF-03/NF-04). */
.about-gallery-icon {
  width: 18px;
  height: 18px;
  background-color: currentColor;
  mask: url("../icons/pause.svg") center / contain no-repeat;
}

.about-gallery-toggle.is-paused .about-gallery-icon {
  mask-image: url("../icons/play.svg");
}

.about-name {
  margin: 4px 0 0;
}

.about-tagline {
  margin: 8px 0 0;
  font-size: 17px;
  color: var(--text-secondary);
}

.about-intro {
  margin: 20px 0 0;
  max-width: 62ch;
  line-height: 1.7;
}

/* Space BETWEEN blocks only. The first block carries no top margin, so "was ich mache" starts
   level with the top of the photo beside it rather than 36px below it. */
.about-block + .about-block {
  margin-top: 36px;
}

/* Same mono eyebrow as every other section label in the design — the block headings are
   signposts, not headlines (NF-04). */
.about-block-title {
  margin: 0 0 14px;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--text-secondary);
}

.about-links {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
}

.about-link {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 10px 0;
  border-top: 1px solid var(--border);
}

.about-link:last-child {
  border-bottom: 1px solid var(--border);
}

/* The neutral glyph is the DEFAULT, not a special case: a kind with no rule of its own below
   lands here rather than on an empty square (F-18). */
.about-icon {
  flex: none;
  width: 20px;
  height: 20px;
  background-color: var(--muted-text);
  mask: url("../icons/link.svg") center / contain no-repeat;
}

.about-icon--website { mask-image: url("../icons/globe.svg"); }
.about-icon--code { mask-image: url("../icons/code.svg"); }
.about-icon--politics { mask-image: url("../icons/landmark.svg"); }
.about-icon--music { mask-image: url("../icons/mic-vocal.svg"); }
.about-icon--mail { mask-image: url("../icons/mail.svg"); }

/* The platform marks are Simple Icons (CC0) and solid where the rest of the set is line art.
   They are kept in their own block as a reminder that they are quotations of somebody else's
   mark, not part of this design system. */
.about-icon--facebook { mask-image: url("../icons/brand/facebook.svg"); }
.about-icon--instagram { mask-image: url("../icons/brand/instagram.svg"); }
.about-icon--threads { mask-image: url("../icons/brand/threads.svg"); }
.about-icon--bluesky { mask-image: url("../icons/brand/bluesky.svg"); }
.about-icon--linkedin { mask-image: url("../icons/brand/linkedin.svg"); }

.about-link:hover .about-icon {
  background-color: var(--accent-text);
}

/* The whole row is the target, so the label and the handle sit inside one <a> — and the row
   grows the 44px touch height out of its padding rather than out of a fixed size (NF-05). */
.about-link-target {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  flex: 1;
  min-height: 24px;
  text-decoration: none;
  color: var(--text);
}

.about-link-target:hover .about-link-label {
  color: var(--accent-text);
}

.about-link-label {
  font-weight: 600;
}

.about-link-handle {
  color: var(--muted-text);
  word-break: break-all;
}

/* The no-JavaScript reveal (F-18). It has to sit in the row like the link it replaces, so the
   form is a flex line rather than a block that pushes the icon off its baseline. */
.about-mail-form {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  flex: 1;
}

/* A button that reads as a link, because that is what it does — the POST is the mechanism, not
   the meaning. It keeps a real focus ring: it is the only way to the address without JS. */
.about-mail-reveal {
  padding: 0;
  border: 0;
  background: none;
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--accent-text);
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

.about-mail-reveal:hover {
  color: var(--accent-text-hover);
}

/* --- About, admin side (F-18) ---------------------------------------------- */

/* The link table needs more than the 560px a single-column form does: five fields per row, and
   squeezing them into the standard width turned every row into a stack of five (NF-05). */
.admin-form--wide {
  max-width: 900px;
}

/* auto-fit rather than five fixed columns: on a phone the row becomes a stack by itself, and no
   breakpoint has to know how many fields a row happens to have. */
.about-link-row,
.about-photo-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 12px 16px;
  align-items: end;
  padding-bottom: 14px;
  border-bottom: 1px dashed var(--border);
}

.about-link-row label,
.about-photo-row label {
  margin: 0;
}

.about-photo-row .admin-image-preview {
  width: 120px;
  height: 120px;
  object-fit: cover;
}
