/* ═══════════════════════════════════════════════════════════════════════
   Design tokens (lot 3) — ALIASING CONTRACT with the vendored holaf-tokens
   brick. The brick (only Holaf brick allowed to write on :root) poses the
   runtime palette under its reserved --holaf-* prefix (see public/js/ui/
   theme.js: named palettes 'homy' / 'homy-light' via HolafTokens.setTokens).
   The values below are the CSS FALLBACKS (pre-JS / no-JS paint, login view):
   they MUST mirror the JS palettes in theme.js — keep both in sync.
   ═══════════════════════════════════════════════════════════════════ */
:root {
  --bg: var(--holaf-surface, #0f1115);
  --bg-elev: var(--holaf-surface-elev, #171a21);
  --bg-elev-2: var(--holaf-surface-raised, #1f232c);
  --border: var(--holaf-border, #2a2f3a);
  --text: var(--holaf-text, #e6e8ee);
  --text-muted: var(--holaf-text-muted, #9aa1ad);
  --accent: var(--holaf-accent, #4f8cff);
  --accent-hover: var(--holaf-accent-hover, #3b78e8);
  --danger: var(--holaf-danger, #e5484d);
  --radius: var(--holaf-radius, 10px);
  --shadow: var(--holaf-shadow, 0 8px 24px rgba(0, 0, 0, 0.35));
  --overlay: var(--holaf-overlay, rgba(0, 0, 0, 0.55));
  --btn-hover: var(--holaf-btn-hover, #262b36);
  /* VIEW<->EDIT transition timing. Declared on :root (not on #dashboard-view)
     because the background layers live on <body>, OUTSIDE the dashboard
     subtree: their frame clip must use the SAME duration/easing as the frame
     dezoom (main.js reads --mode-dur from #dashboard-view, which inherits it
     from here — a single source of truth, no duplicated literal). */
  --mode-dur: 480ms;
  --mode-ease: cubic-bezier(0.4, 0, 0.2, 1);
  --success: #2e9e5b;
  --warning: #e0a030;
  /* Edit-mode grid lines (v5 64×36 fine trame). Homy-level tokens — NOT part of the
     holaf palette: the lines are a functional overlay drawn on .grid-stack in
     edit mode only (see .grid-stack.editing-grid below). Minor = every cell,
     major = every 4 columns / 3 rows, center = the two 50% axes.
     Lot 4 (mode transition): the line colors are now split into CHANNELS +
     per-theme ALPHAS so the whole guide overlay can be FADED by multiplying
     those alphas by --guide-op (a registered, transitionable custom property,
     see @property below and .grid-stack.editing-grid). */
  --grid-line-rgb: 255 255 255;
  --grid-minor-a: 0.07;
  --grid-global-a: 0.12;
  --grid-major-a: 0.17;
  /* Widget surface alpha: 100% by default; relaxed when a background is
     active (image or procedural) so widgets become translucent glass. */
  --surface-alpha: 100%;
}

/* Light theme — same aliasing, light fallbacks. Toggled by the inline <head>
   script (localStorage mirror) and by ui/theme.js (server = source of truth). */
[data-theme="light"] {
  --bg: var(--holaf-surface, #eef1f6);
  --bg-elev: var(--holaf-surface-elev, #f7f9fc);
  --bg-elev-2: var(--holaf-surface-raised, #ffffff);
  --border: var(--holaf-border, #d7dce5);
  --text: var(--holaf-text, #1a2233);
  --text-muted: var(--holaf-text-muted, #5b6472);
  --accent: var(--holaf-accent, #2f6fe4);
  --accent-hover: var(--holaf-accent-hover, #4680ea);
  --danger: var(--holaf-danger, #d33b40);
  --shadow: var(--holaf-shadow, 0 8px 24px rgba(15, 23, 42, 0.12));
  --overlay: var(--holaf-overlay, rgba(15, 23, 42, 0.35));
  --btn-hover: var(--holaf-btn-hover, #e3e8f2);
  /* Edit-mode grid lines — light-theme variants (kept readable on #eef1f6). */
  --grid-line-rgb: 15 23 42;
  --grid-minor-a: 0.1;
  --grid-global-a: 0.15;
  --grid-major-a: 0.2;
}

/* A background (image or procedural) is active → translucent glass surfaces. */
body[data-bg-active] {
  --surface-alpha: 80%;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  height: 100%;
  color: var(--text);
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  font-size: 15px;
}

/* Background layers (z-index -3/-2, see #homy-bg-*) sit between the canvas
   background (html) and the content: body itself must stay transparent. */
html { background: var(--bg); }
body { background: transparent; transition: background 0.2s; }

.hidden { display: none !important; }
.muted { color: var(--text-muted); }

.view { min-height: 100dvh; } /* dvh: mobile URL bars don't create dead space (review C9) */

/* ---------- Auth ---------- */
#login-view {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}
.auth-card {
  width: 100%;
  max-width: 380px;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 28px;
  box-shadow: var(--shadow);
}
.brand { margin: 0 0 20px; font-size: 24px; letter-spacing: 0.5px; }
.auth-form h2 { margin: 0 0 16px; font-size: 18px; }
.auth-form label { display: block; margin-bottom: 14px; }
.auth-form label span { display: block; margin-bottom: 6px; font-size: 13px; color: var(--text-muted); }
.auth-form input {
  width: 100%;
  padding: 10px 12px;
  background: var(--bg-elev-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  font-size: 15px;
}
.auth-form input:focus { outline: none; border-color: var(--accent); }
.form-error { color: var(--danger); font-size: 13px; min-height: 18px; margin: 4px 0 8px; }

/* ---------- Buttons ---------- */
.btn {
  padding: 9px 16px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-elev-2);
  color: var(--text);
  font-size: 14px;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}
.btn:hover { background: var(--btn-hover); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-ghost { background: transparent; }
.btn-danger { background: transparent; border-color: var(--danger); color: var(--danger); }
.btn-danger:hover { background: rgba(229, 72, 77, 0.12); }
.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }

/* ---------- Topbar ----------
   Compact 2-row flex column: row 1 = brand + actions, row 2 = reserved tabs
   slot. Height is reduced vs the old bar (padding 14px 20px → 6px 14px, brand
   20px → 16px, buttons 9/16 → 5/10) so the display area below gets more room
   and a future tab row can be added without growing the bar itself. */
.topbar {
  display: flex;
  flex-direction: column;
  padding: 6px 14px;
  border-bottom: 1px solid var(--border);
  background: var(--bg-elev);
  /* position:relative instead of sticky: the dashboard is a fixed 100dvh flex
     column that never scrolls, and sticky would fight the collapsed state's
     negative margin (sticky top:0 would clamp the pulled-up bar back into the
     viewport). z-index kept for the same stacking as before. */
  position: relative;
  z-index: 20;
}
/* position:relative is the anchor for the absolutely-positioned #editor-actions
   (GROUP 3): it must sit to the LEFT of the right cluster in EDIT mode and be
   free to translate/descend without ever affecting the flex layout. */
.topbar-row { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.topbar .brand { margin: 0; font-size: 16px; letter-spacing: 0.4px; }
.topbar-actions { display: flex; align-items: center; gap: 8px; }
.topbar .btn { padding: 5px 10px; font-size: 13px; }
/* Edit-only topbar actions (Background / Theme, lot 2; re-homed by the topbar
   motion chantier): GROUP 3 of the validated 2-beat choreography. ABSOLUTELY
   positioned to the left of the cluster (right = cluster width + gap) so the
   cluster can slide right while the admin descends into the vacated slot.
   Its resting VIEW/EDIT look (translateY + opacity + inert) is defined with the
   other topbar-motion states further down — NOT via .hidden, which would
   display:none the group and make the transition impossible. */
.editor-actions {
  position: absolute;
  top: 0;
  bottom: 0;
  right: calc(var(--cluster-w, 0px) + 10px); /* 10px = gap to the cluster */
  display: flex;
  align-items: center;
  gap: 8px;
}
/* Reserved tabs/pages row (multiple pages, lot C+D) — rendered by
   public/js/ui/tabs.js. CONSTANT height (the topbar must only grow ONCE when
   the row appears): fixed row height + hidden scrollbars + NO wrap ever. On
   narrow screens the row scrolls horizontally (overflow-x:auto) while the
   tabs shrink (max-width + ellipsis on the label) instead of stacking. */
.topbar-tabs {
  display: flex;
  flex-wrap: nowrap; /* NEVER wrap: the topbar must not grow row by row */
  align-items: center;
  gap: 6px;
  height: 32px; /* constant row height (tabs + « + » are 24px) */
  margin-top: 6px;
  padding-bottom: 2px;
  overflow-x: auto; /* narrow screens: scroll instead of wrap */
  overflow-y: hidden;
  /* Hidden scrollbar (no reserved gutter → height stays constant): */
  scrollbar-width: none; /* Firefox */
}
.topbar-tabs::-webkit-scrollbar { display: none; } /* Chromium/Safari */

/* Tab: name (ellipsized, full name in title) + hover-revealed ✕ (edit). */
.topbar-tab {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 0 1 auto;
  min-width: 0; /* allow shrinking below content so the ellipsis can engage */
  max-width: 160px;
  padding: 3px 8px 3px 10px;
  background: var(--bg-elev-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  font-size: 13px;
  line-height: 18px;
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.topbar-tab:hover { border-color: var(--accent); }
.topbar-tab-label {
  /* Floor: the label keeps ≥ 36px of visible text before the row scrolls —
     an ellipsized-to-nothing tab is useless; past that floor the ROW
     (overflow-x:auto) scrolls instead of shrinking further. */
  min-width: 36px;
  overflow: hidden; /* ellipsis carrier (label only, never the ✕) */
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Active tab: accent underline + tinted surface, readable in BOTH themes
   (accent on the elevated surface passes contrast in dark and light). */
.topbar-tab.active {
  border-color: var(--accent);
  color: var(--accent);
  font-weight: 600;
  background: color-mix(in srgb, var(--accent) 12%, var(--bg-elev-2));
  box-shadow: inset 0 -2px 0 var(--accent); /* the underline */
}
.topbar-tab.active:hover { background: color-mix(in srgb, var(--accent) 18%, var(--bg-elev-2)); }

/* Delete ✕ (edit only — the button is not even attached in view mode).
   ALWAYS laid out (visibility, not display): hover must not reflow the row. */
.topbar-tab-del {
  visibility: hidden;
  flex: none;
  width: 18px;
  height: 18px;
  padding: 0;
  border: 0;
  border-radius: 5px;
  background: transparent;
  color: var(--text-muted);
  font-size: 11px;
  line-height: 1;
  cursor: pointer;
}
.topbar-tab:hover .topbar-tab-del,
.topbar-tab-del:focus-visible { visibility: visible; }
.topbar-tab-del:hover { background: var(--btn-hover); color: var(--danger); }
/* 2-step confirmation (no modal): armed state = filled red ✕, ~3 s (tabs.js). */
.topbar-tab-del.confirm {
  visibility: visible;
  background: var(--danger);
  color: #fff;
}
.topbar-tab-del.confirm:hover { background: var(--danger); color: #fff; }

/* « + » (edit only): ghost button pinned at the end of the row. */
.topbar-tab-add {
  flex: none;
  width: 26px;
  height: 24px;
  padding: 0;
  border: 1px dashed var(--border);
  border-radius: 8px;
  background: transparent;
  color: var(--text-muted);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  transition: border-color 0.15s, color 0.15s;
}
.topbar-tab-add:hover { border-color: var(--accent); color: var(--accent); }

/* Inline rename input (replaces the tab content on dblclick, edit only). */
.topbar-tab-input {
  width: 150px;
  min-width: 60px;
  padding: 2px 6px;
  background: var(--bg-elev-2);
  border: 1px solid var(--accent);
  border-radius: 6px;
  color: var(--text);
  font: inherit;
  font-size: 13px;
  line-height: 18px;
}
.topbar-tab-input:focus { outline: none; }

/* VIEW-mode hiding (right-click toggles it via main.js). The bar is pulled
   ABOVE the viewport by its MEASURED height (--topbar-h, set by
   updateTopbarHeight()), so the flex:1 #dashboard-body reclaims exactly that
   space and no content is ever masked. The animated margin-top/opacity give
   the fluid dezoom (200ms, within the 150–250ms budget); visibility flips
   after the slide so the hidden bar is neither clickable nor focusable in the
   meantime. The fixed background layers live on <body> and are NOT affected. */
#dashboard-view.topbar-collapsed .topbar {
  margin-top: calc(-1 * var(--topbar-h, 0px));
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: margin-top 200ms ease, opacity 200ms ease, visibility 0s linear 200ms;
}
/* Base transition (expanding back): visibility must switch on immediately. */
.topbar { transition: margin-top 200ms ease, opacity 200ms ease, visibility 0s linear 0s; }
/* Mode switches (setMode) disable the slide for one frame so gridstack reads
   the final container size — the right-click toggle never sets this class. */
.topbar.topbar-instant { transition: none !important; }

/* ---------- Palette ---------- */
.palette {
  position: fixed;
  left: 16px;
  top: 76px;
  width: 200px;
  max-height: calc(100vh - 96px);
  overflow-y: auto;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 14px;
  box-shadow: var(--shadow);
  z-index: 15;
}
.palette h3 { margin: 0 0 10px; font-size: 14px; color: var(--text-muted); }
/* Canvas-object entry points (element / report). Two compact, clearly labelled
   buttons above the widget list so adding an icon is discoverable at a glance
   in EDIT mode — the widget palette alone only creates widgets. */
.palette-add-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.palette-add-btn { width: 100%; justify-content: center; font-size: 13px; }
.palette-add-btn:first-child { border-color: var(--accent); }
.palette-add-btn:first-child:hover { background: var(--accent); color: #fff; }
.palette-subtitle {
  margin: 0 0 8px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.palette-list { display: flex; flex-direction: column; gap: 8px; }
.palette-item {
  padding: 10px 12px;
  background: var(--bg-elev-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  cursor: grab;
  font-size: 14px;
  text-align: left;
  /* Buttons do NOT inherit the body color: the UA sheet pins `color:
     buttontext` (black — no color-scheme is declared), which left the widget
     name unreadable (black) on the dark --bg-elev-2 surface while the <small>
     description stayed readable via its explicit --text-muted. Theme it. */
  color: var(--text);
}
.palette-item:hover { border-color: var(--accent); }
.palette-item small { display: block; color: var(--text-muted); font-size: 12px; margin-top: 2px; }
.palette-head { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.palette-icon { font-size: 18px; line-height: 1; }
.palette-size { opacity: 0.7; }

/* Docked palette (edit-preview mode): a real flex column to the LEFT of the
   framed preview, so it never covers the canvas. Only active when the preview
   frame is on — on too-small screens the preview bails out (see main.js) and
   the palette keeps its floating `position: fixed` behavior (step 5). */
#dashboard-view.preview-active .palette {
  position: static;
  flex: 0 0 auto;
  width: 200px;
  max-height: none;
  margin: 20px 8px 20px 20px;
  z-index: auto;
}
/* Docked mode reserves its column itself: drop the floating-mode margin that
   used to keep the (fixed) palette clear of the grid. */
#dashboard-view.preview-active .grid-wrap.with-palette { margin-left: 0; }

/* ---------- Grid ---------- */

/* The dashboard is a fixed-height viewport layout: topbar (auto) + grid area
   (everything that remains). The grid never makes the page scroll. */
#dashboard-view {
  display: flex;
  flex-direction: column;
  height: 100dvh;
  /* Main-axis space reserved by the DOCKED palette in edit-preview mode:
     left margin 20 + palette width 200 + right margin 8. Read by main.js to
     compute the adaptive dezoom; keep it in sync with the docked .palette
     rule above (they are a contract, not a coincidence). */
  --palette-dock-w: 228px;
  /* NOTE: there is NO cell-aspect tolerance knob any more. The canvas is fitted
     with ONE uniform scale (main.js updateCanvasGeometry → computeCanvasFit in
     grid/config.js): scale = min(availW / 64, availH / 36). Cells are therefore
     EXACTLY square (ratio 1.000) at every window size and the excess of the
     non-binding axis becomes a CENTERED margin (letterbox on an ultra-wide
     window, pillarbox on a 4/3 one). The old ±20 % « fill » stretch and its
     ±2 % snap dead band are gone by user decision. */
  /* VIEW↔EDIT transition timing lives on :root (see above) so the body-level
     background layers share it. All the animated properties here are VISUAL
     only (transform/opacity and the registered --guide-op): none of them
     feeds gridstack's clientWidth, so the grid geometry stays final from the
     first frame (the topbar-instant lesson).
     480ms = 2 × the original 240ms; the easing is a standard ease-in-out so
     the motion RAMP UP instead of the old exponential ease-out which reached
     its target in the first ~10% of the timeline (that was the « pop »). */
}
#dashboard-view .topbar { flex: none; }

/* Horizontal row hosting the (optionally docked) palette and the grid area.
   In view mode the palette is .hidden, so this row collapses onto the single
   grid column: identical geometry to the pre-preview layout. */
.dashboard-body {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: row;
}

.grid-wrap {
  flex: 1 1 auto;
  min-height: 0; /* allow shrinking below content size inside the flex column */
  /* CENTERING CONTAINER (uniform fit): the area is a 1-item flex box and the
     canvas (or the EDIT frame) uses `margin: auto`. Per the flexbox spec an
     auto margin resolves to 0 when the free space is negative, so this centers
     the canvas on BOTH axes on every window shape (letterbox/pillarbox margins)
     while never making the overflowed start edge unreachable on the small-screen
     pan fallback (`min-width: 640px` on #grid-container). */
  display: flex;
  /* L2: positioned so the .group-layer (absolute, sibling of #grid-container
     through the display:contents #grid-preview) gets a deterministic
     containing block — .group-layer's inline left/top mirror the canvas
     container's offsetLeft/offsetTop (see grid/groups.js syncCanvas). */
  position: relative;
  padding: 20px;
  margin-left: 0;
  /* Review C2: on screens too small for a readable grid (see the 640px cell
     floor on #grid-container) the canvas overflows this box — pan INSIDE it
     (overflow auto) instead of scrolling the whole page. On desktop the grid
     always fits exactly (the fit below is computed from this very box), so no
     scrollbar ever appears there. */
  overflow: auto;
  /* NOTE: no transition on margin-left on purpose. GridStack reads the
     container width at init to derive the square cell height (cellHeight:
     'auto'); an animating with-palette margin meant every edit entry painted
     ~200ms of stale cell heights until the resize observer caught up. Instant
     margin = correct geometry from the first frame. */
  /* Size containment + container: #grid-container sizes itself from this
     box (cqw/cqh) so the 64×36 grid always fits the visible area exactly. */
  container-type: size;
}
.grid-wrap.with-palette { margin-left: 232px; }

/* ---------- Framed dezoomed preview (edit mode, lot 1) ----------
   Base state: the wrapper is transparent to layout, so VIEW mode keeps the
   exact same DOM/CSS sizing as before (the canvas sizes itself from
   .grid-wrap through container queries). Only `preview-active` turns it into
   a real 16:9 frame. */
#grid-preview { display: contents; }

#grid-container {
  /* 64×36 canvas. The box is pinned by main.js to measured integer pixels
     (--canvas-w / --canvas-h) with the UNIFORM fit
     scale = min(availW / 64, availH / 36): the canvas takes the largest 16:9 box
     the area allows (≈100 % of the area on the constraining axis) and is
     CENTERED by `margin: auto` above (the excess becomes a margin, never a
     stretch — cells stay exactly square). gridstack derives item widths from
     clientWidth (percentage column units) and the row pitch from the cellHeight
     main.js passes (h/36), and sets an INLINE container height of 36 rows ×
     cell height: that inline height agrees with the pinned box by construction,
     so there is no sub-pixel overflow and no scrollbar. The cq / 16:9 fallbacks
     below cover the first paint before main.js runs. No min-height here:
     gridstack only reads the computed min-height when no minRow is configured
     (we always set minRow:36). */
  width: var(--canvas-w, min(100cqw, calc(100cqh * 16 / 9)));
  /* Pinned height (always exactly 16:9 for that width). gridstack's inline
     height overrides this once the grid is built; keeping it here makes the CSS
     box correct even before the first grid init. */
  height: var(--canvas-h, auto);
  /* --canvas-w (main.js updateCanvasGeometry): the fit measured in JS and pinned
     as an integer pixel value — a source of truth shared with gridstack, so
     the inline height always agrees with this box even when the grid is
     (re)built while the topbar transition is mid-flight (stale container-query
     units would otherwise freeze an oversized canvas and show a phantom
     scrollbar — lot E). */
  /* Review C2 — cell floor: never let a cell shrink below a readable 20px.
     640px = 20px × 32 cells: on viewports narrower than ~680px (or short
     landscape phones where 100cqh × 16/9 would win) the canvas is clamped to
     its desktop geometry and .grid-wrap pans instead of shrinking cells to
     unreadable ~10px. Desktop is unaffected: the width formula already
     yields ≥ 20px cells there. */
  min-width: 640px;
  aspect-ratio: 16 / 9;
  /* Centered in the flex box on both axes (see .grid-wrap): the letterbox /
     pillarbox margin is produced here, not by any per-window branch. */
  margin: auto;
}
/* Fallback for engines without container queries: full-width grid (may
   overflow vertically on short windows — pre-2023 behavior). */
@supports not (container-type: size) {
  #grid-container { width: 100%; }
}

/* Lot E — hard containment of the canvas in VIEW mode (and the edit
   small-screen fallback). Belt-and-braces over --canvas-w: even if gridstack's
   inline height (36 × clientWidth/64, its ResizeObserver is throttled) is
   stale by a few px after a mid-animation rebuild, the BOX can never exceed
   the exact 16:9 fit of the pinned width and nothing rendered inside it can
   spill a phantom scrollbar into .grid-wrap (the empty-page scrollbar of lot
   E's screenshots). max() keeps the 640px pan floor unsquashed (a clamped
   640-wide canvas keeps its full 360px height); the 100000px fallback is a
   no-clamp before main.js pins --canvas-w. overflow:clip (not hidden) also
   never creates a scroll container. */
#dashboard-view:not(.preview-active) #grid-container {
  max-height: var(--canvas-h, calc(max(var(--canvas-w, 100000px), 640px) * 9 / 16));
  overflow: clip;
}

/* The canvas frame that hosts the whole canvas in edit-preview mode. Its width
   is the largest 16:9 box that fits the grid area (now narrowed by the docked
   palette); it is centered in the area — the SAME uniform fit as the VIEW
   canvas (min(100cqw, 100cqh·16/9)), so frame and canvas share one aspect and
   the dezoom is always a UNIFORM scale (never a distorting non-uniform one).
   DECISION (user): the EDIT frame stays FIXED 16:9 with its uniform dezoom;
   the VIEW canvas now uses a uniform fit too (no stretch anywhere), so the
   VIEW↔EDIT seam is exact at every window shape and the cross-fade branch of
   the transition is a defensive fallback only (see main.js
   setViewOriginTransform + style.css .anim-from-view below).
   `overflow: hidden` + `isolation` clip and isolate the scaled canvas (grid
   lines, and the drag helper, which is the item element itself and therefore
   lives inside this tree). */
#dashboard-view.preview-active #grid-preview {
  display: block;
  position: relative;
  isolation: isolate;
  overflow: hidden;
  width: min(100cqw, calc(100cqh * 16 / 9));
  aspect-ratio: 16 / 9;
  margin: auto;
  border: 2px solid var(--accent);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}
/* Geometry anchor for the themed background clip (lot 2, reworked): in
   edit-preview mode the manager measures this element's inner rect and clips
   the body-level background wrapper to it (the layers are NOT re-parented).
   Sits behind the grid (first child) — it paints nothing itself. */
#dashboard-view.preview-active #grid-preview-bg {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
/* The logical canvas keeps the SAME size it has in view mode (so widgets lay
   out identically — true WYSIWYG), enlarged by 1/scale and then uniformly
   shrunk by the scale: the rendered box fills the frame exactly while the
   internal layout stays at its natural (view-mode) size. min-width/auto and
   aspect-ratio are neutralized so the logical box can exceed the frame. */
#dashboard-view.preview-active #grid-container {
  /* --view-canvas-w / --view-canvas-h: the LOGICAL canvas box, pinned to a
     16:9 size by main.js (computeEditTarget). Keeping them constant px values
     decouples gridstack from the animating frame box (topbar reveal, dezoom
     transform): the container clientWidth never changes during a transition,
     so item geometry stays final from the first frame. Fallbacks keep the old
     proportional behaviour before the vars are set. */
  width: var(--view-canvas-w, calc(100% / var(--preview-scale, 1)));
  height: var(--view-canvas-h, calc(100% / var(--preview-scale, 1)));
  min-width: 0;
  aspect-ratio: auto;
  margin: 0;
}
/* Lot 9: the per-item ⚙/✕ controls are GONE — every object setting now lives
   in the EDIT right-click menu (see `.context-menu` below, ui/contextMenu.js).
   Nothing to counter-scale here any more. */
/* Lot 2 — same compensation for gridstack's resize handles: they live INSIDE
   the scaled canvas, so their hit boxes would shrink to 20·f px. Counter-scale
   them about their own center to keep a ~20px grab area at any dezoom. The
   four corner handles also carry a vendor rotate(45deg) which must be kept. */
#dashboard-view.preview-active .grid-stack-item > .ui-resizable-handle {
  transform: scale(calc(1 / var(--preview-scale, 1)));
}
#dashboard-view.preview-active .grid-stack-item > .ui-resizable-ne,
#dashboard-view.preview-active .grid-stack-item > .ui-resizable-sw {
  transform: rotate(45deg) scale(calc(1 / var(--preview-scale, 1)));
}
#dashboard-view.preview-active .grid-stack-item > .ui-resizable-nw,
#dashboard-view.preview-active .grid-stack-item > .ui-resizable-se {
  transform: rotate(-45deg) scale(calc(1 / var(--preview-scale, 1)));
}

/* ---------- Edit-mode grid lines (v5 fine trame) ----------
   Drawn ONLY in edit mode via the .editing-grid class set by editor.js —
   never in view mode, never on the login view (no .grid-stack there).
   Alignment contract: gridstack positions items at exact multiples of the
   cell width (--gs-column-width) and the margin:8 lives INSIDE each cell, so
   lines at multiples of that width coincide with item snap edges.
   Layering: the 4 repeating layers below (major every 4 columns / 3 rows,
   then the 1px minor cell lines) live on the container's background-image;
   the two accent CENTER axes live on a separate ::before layer. Review C5:
   a single 6-layer background-image was one bad component away from losing
   ALL the guides — the axes depend on --grid-center (a color-mix(), the most
   fragile part), so they are isolated: an engine without color-mix support
   drops only the two center lines and keeps the 4 structural layers.
   .grid-stack has no ::before/::after of its own in the vendored gridstack
   CSS (only .grid-stack-placeholder>.placeholder-content uses pseudos), so
   this layer is free; as the container's FIRST child it paints below every
   grid item, exactly like the old background layers did.

   Lot 4 (mode transition) — FADEABLE GUIDES: the four structural layers stay
   on the SAME background-image and the axes stay on ::before, but their colors
   now derive from --guide-op (a registered <number>, default 1, see @property
   below). Multiplying the per-theme alphas (--grid-minor-a/--grid-major-a)
   and the center color-mix weight by --guide-op fades the WHOLE overlay —
   background layers AND ::before axes — without touching opacity (which would
   also fade the grid items). --guide-op is animated by main.js during the
   VIEW↔EDIT swap so the guides fade IN/OUT in sync with the frame dezoom. */
@property --guide-op {
  syntax: "<number>";
  inherits: false;
  initial-value: 1;
}
.grid-stack.editing-grid {
  --guide-op: 1;
  /* Compose the fadeable line colors here (on the element that uses them) so
     a class toggle on an ancestor that changes --guide-op is picked up — a
     derived token declared at :root would substitute --guide-op too early. */
  --grid-minor: rgb(var(--grid-line-rgb) / calc(var(--grid-minor-a) * var(--guide-op)));
  --grid-global: rgb(var(--grid-line-rgb) / calc(var(--grid-global-a) * var(--guide-op)));
  --grid-major: rgb(var(--grid-line-rgb) / calc(var(--grid-major-a) * var(--guide-op)));
  --grid-center: color-mix(in srgb, var(--accent, #4f8cff) calc(65% * var(--guide-op)), transparent);
  /* Lot 2 — CONSTANT RENDERED THICKNESS: .editing-grid is scaled by
     --preview-scale (f ∈ (0,1]) in edit-preview mode, so a raw `2px` stroke
     would render 2·f px wide (thinner as the canvas dezooms). Dividing each
     stroke width by f makes the rendered width (CSS width × f) constant.
     --preview-scale is unset in view mode and in the small-screen fallback →
     var(..., 1) → f = 1 → identical to before. */
  --guide-major-w: calc(2px / var(--preview-scale, 1));
  --guide-minor-w: calc(1px / var(--preview-scale, 1));
  /* L2 — the trame is now the v5 64×36 fine grid (was 32×18):
       fine    = every 1 unit (every column / row);
       global  = every 2 units (widget-alignment level, NEW);
       major   = every 8 columns / 6 rows (keeps the pre-L2 visual rhythm:
                 before it was every 4/32 = ⅛ col and every 3/18 = ⅙ row).
     Vertical/global/major first (coarser), minor LAST so the finest lines
     paint over the coarser ones, as before. */
  background-image:
    /* major vertical: every 8 units (x = 8, 16, … 56 — includes 32) */
    repeating-linear-gradient(90deg,
      var(--grid-major) 0 var(--guide-major-w), transparent var(--guide-major-w) calc(var(--gs-column-width, calc(100% / 64)) * 8)),
    /* major horizontal: every 6 units (y = 6, 12, … 30 — includes 18) */
    repeating-linear-gradient(0deg,
      var(--grid-major) 0 var(--guide-major-w), transparent var(--guide-major-w) calc(var(--gs-cell-height, calc(100% / 36)) * 6)),
    /* global vertical: every 2 units */
    repeating-linear-gradient(90deg,
      var(--grid-global) 0 var(--guide-minor-w), transparent var(--guide-minor-w) calc(var(--gs-column-width, calc(100% / 64)) * 2)),
    /* global horizontal: every 2 units */
    repeating-linear-gradient(0deg,
      var(--grid-global) 0 var(--guide-minor-w), transparent var(--guide-minor-w) calc(var(--gs-cell-height, calc(100% / 36)) * 2)),
    /* minor vertical: every column boundary */
    repeating-linear-gradient(90deg,
      var(--grid-minor) 0 var(--guide-minor-w), transparent var(--guide-minor-w) var(--gs-column-width, calc(100% / 64))),
    /* minor horizontal: every row boundary */
    repeating-linear-gradient(0deg,
      var(--grid-minor) 0 var(--guide-minor-w), transparent var(--guide-minor-w) var(--gs-cell-height, calc(100% / 36)));
}
/* Center axes (x = 16/32 = 50%, y = 9/18 = 50%) — accent-colored via
   --grid-center (color-mix). Isolated on ::before so a failure of color-mix
   in --grid-center cannot take the structural layers down with it (review
   C5). As the first child of the container it stays UNDER every item.
   Lot 2: the 1px half-offsets are also divided by the dezoom factor so the
   two axis bands keep a constant RENDERED width at any zoom. */
.grid-stack.editing-grid::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  --guide-axis-w: calc(1px / var(--preview-scale, 1));
  background-image:
    /* center vertical axis */
    linear-gradient(90deg,
      transparent calc(50% - var(--guide-axis-w)), var(--grid-center) calc(50% - var(--guide-axis-w)),
      var(--grid-center) calc(50% + var(--guide-axis-w)), transparent calc(50% + var(--guide-axis-w))),
    /* center horizontal axis */
    linear-gradient(0deg,
      transparent calc(50% - var(--guide-axis-w)), var(--grid-center) calc(50% - var(--guide-axis-w)),
      var(--grid-center) calc(50% + var(--guide-axis-w)), transparent calc(50% + var(--guide-axis-w)));
}
/* Adaptive dezoom (edit mode). --preview-scale is computed in main.js so the
   ENTIRE frame fits the available area; it is unset → scale(1) when the frame
   falls back on small screens (zero visual change). The transform also makes
   the container a containing block for the position:fixed drag helper (which
   is the item element itself), keeping it correctly placed at any scale. */
.grid-stack.editing-grid {
  transform: scale(var(--preview-scale, 1));
  transform-origin: top left;
}

/* ---------- Topbar motion — validated 2-beat choreography ----------
   The topbar is ONE bar that grows, in two beats, mirroring the standalone
   reference mockup with the REAL measured geometry (never the mockup's fixed
   52/108 px):

     ENTER (VIEW -> EDIT)               EXIT (EDIT -> VIEW)
       beat 1 (0ms)   bar grows           beat 2 first (tabs 0ms, admin 30ms)
                      cluster slides right beat 1 later  (cluster 150ms, bar 150ms)
       beat 2 (~120ms) admin descends
                       tabs slide from left = exact reverse order

   4 groups, 4 motions:
     • brand              STATIC (no motion)
     • .topbar-actions    translateX  → slides RIGHT on enter
     • .editor-actions    translateY from the top + opacity → descends
     • .topbar-tabs       translateX from the left + opacity → slides in

   GEOMETRY CONTRACT (same lesson as the dezoom): the topbar LAYOUT is frozen
   at the final EDIT height for the whole swap (gridstack already read it), so
   the bar "grows" by animating clip-path (a VISUAL prop) — never `height`,
   which would resize #dashboard-body/.grid-wrap mid-flight and feed gridstack
   a stale size. Every motion here is transform/opacity/clip-path only.
   Delays/durations/easing are the validated mockup's, on --mode-ease
   (cubic-bezier(0.4, 0, 0.2, 1)). */
#dashboard-view {
  /* Durations (≈480 ms total with a deliberate ~120 ms overlap between beats) */
  --d-bar: 320ms;     /* bar growth (clip-path)      */
  --d-cluster: 320ms; /* user / Edit / Logout        */
  --d-admin: 340ms;   /* Background / Theme          */
  --d-tabs: 350ms;    /* pages row                   */
}
/* ENTER delays (NORMAL -> EDIT): beat 1 at 0ms, beat 2 overlaps at ~120ms. */
#dashboard-view.mode-edit,
#dashboard-view.anim-from-view {
  --delay-bar: 0ms;
  --delay-cluster: 0ms;
  --delay-admin: 120ms;
  --delay-tabs: 130ms;
}
/* EXIT delays (EDIT -> NORMAL): exact reverse order. */
#dashboard-view.mode-view,
#dashboard-view.anim-to-view {
  --delay-bar: 150ms;
  --delay-cluster: 150ms;
  --delay-admin: 30ms;
  --delay-tabs: 0ms;
}

/* --- resting states -------------------------------------------------------
   .mode-view / .mode-edit are set by main.js setMode (distinct from the
   generic `.view` class carried by #login-view/#dashboard-view). The
   .anim-from-view / .anim-to-view rules repeat the VIEW geometry exactly, so
   the directional classes are self-contained snap/target states of the swap. */

/* GROUP 2 — cluster: slides RIGHT on enter (from -shift to 0). */
#dashboard-view.mode-edit .topbar-actions { transform: translateX(0); }
#dashboard-view.mode-view .topbar-actions,
#dashboard-view.anim-from-view .topbar-actions,
#dashboard-view.anim-to-view .topbar-actions {
  transform: translateX(calc(-1 * var(--shift, 0px)));
}

/* GROUP 3 — admin: descends from ABOVE the bar + fades in on enter.
   No `visibility` here: it is NOT transitionable and would switch instantly at
   exit t=0, making the admin vanish instead of fading out. `inert` (set by
   main.js) keeps it unfocusable outside edit mode; pointer-events handles the
   clicks during the fade. */
#dashboard-view.mode-edit .editor-actions {
  opacity: 1;
  transform: translateY(0);
}
#dashboard-view.mode-view .editor-actions,
#dashboard-view.anim-from-view .editor-actions,
#dashboard-view.anim-to-view .editor-actions {
  opacity: 0;
  transform: translateY(calc(-1 * var(--topbar-h, 48px)));
  pointer-events: none;
}

/* GROUP 4 — tabs: slide in from the LEFT + fade on enter. When the row is
   already visible in VIEW (>= 2 pages, .tabs-static set by main.js) it must
   NOT move: a slide would snap backwards on the first enter frame (pop). */
#dashboard-view.mode-edit .topbar-tabs { opacity: 1; transform: none; }
#dashboard-view.mode-view .topbar-tabs,
#dashboard-view.anim-from-view .topbar-tabs,
#dashboard-view.anim-to-view .topbar-tabs { opacity: 0; transform: translateX(-100%); }
#dashboard-view.tabs-static .topbar-tabs { opacity: 1; transform: none; }

/* GROUP 1 + bar: brand static; the bar only GROWS (clip-path). The bottom
   inset is the measured VIEW↔EDIT height difference (--bar-grow, main.js) so
   an edit bar with a tabs row reveals exactly that extra row. */
#dashboard-view.mode-edit .topbar { clip-path: inset(0 0 0 0); }
#dashboard-view.mode-view .topbar,
#dashboard-view.anim-from-view .topbar,
#dashboard-view.anim-to-view .topbar { clip-path: inset(0 0 var(--bar-grow, 0px) 0); }

/* --- transitions (enabled only while .mode-anim is on) -------------------- */
#dashboard-view.mode-anim .topbar {
  transition: clip-path var(--d-bar) var(--mode-ease) var(--delay-bar, 0ms);
  will-change: clip-path;
}
#dashboard-view.mode-anim .topbar-actions {
  transition: transform var(--d-cluster) var(--mode-ease) var(--delay-cluster, 0ms);
  will-change: transform;
}
#dashboard-view.mode-anim .editor-actions {
  transition: transform var(--d-admin) var(--mode-ease) var(--delay-admin, 0ms),
              opacity var(--d-admin) var(--mode-ease) var(--delay-admin, 0ms);
  will-change: transform, opacity;
}
#dashboard-view.mode-anim .topbar-tabs {
  transition: transform var(--d-tabs) var(--mode-ease) var(--delay-tabs, 0ms),
              opacity var(--d-tabs) var(--mode-ease) var(--delay-tabs, 0ms);
  will-change: transform, opacity;
}

/* ---------- VIEW ↔ EDIT mode transition (lot 4) ----------
   Fluid swap between the full-area VIEW canvas and the framed EDIT preview.
   DESIGN CONTRACT (mirrors the topbar-instant lesson): the FINAL geometry is
   in place on the first frame — gridstack already measured it — and ONLY
   visual props are animated:
     • #grid-preview  : transform scale()/translateX()  → the framed preview
                        DEZOOMS from (or back to) the view-sized canvas;
     • .palette       : transform translateX() + opacity  → slides in/out of
                        its docked column (translate never moves the layout);
     • .editing-grid  : --guide-op (registered) → the guide overlay FADES,
                        in sync with the frame scale, so the grid of markers
                        follows the workspace zoom instead of popping in.
   main.js adds .mode-anim + a directional state for one transition, forces a
   reflow so the start style is committed, then removes the state class on the
   next frame → the transitions run. All classes are removed at the end and
   on any new toggle, so VIEW is always left with no residual transform or
   opacity. Fallback (no .preview-active) leaves --preview-scale unset →
   var(...,1) → scale(1): zero visual change. */
#dashboard-view.mode-anim.preview-active #grid-preview {
  transition: transform var(--mode-dur) var(--mode-ease),
              opacity var(--mode-dur) var(--mode-ease),
              border-color var(--mode-dur) var(--mode-ease),
              box-shadow var(--mode-dur) var(--mode-ease);
}
#dashboard-view.mode-anim.preview-active .palette {
  transition: transform var(--mode-dur) var(--mode-ease), opacity var(--mode-dur) var(--mode-ease);
}
#dashboard-view.mode-anim .grid-stack.editing-grid {
  transition: --guide-op var(--mode-dur) var(--mode-ease);
}
/* The topbar's own motion (bar growth + the 4 groups) is declared in the
   « Topbar motion » section above; while a mode transition runs its LAYOUT is
   frozen at the final edit height (gridstack already measured it) and only
   clip-path / transform / opacity animate it. */
/* Clip the (temporarily oversized) scaled frame at the DASHBOARD level, NOT at
   .grid-wrap: at the start of the swap the frame is scaled up to the full view
   canvas, which is wider than the palette-narrowed .grid-wrap — clipping there
   sliced ~190 px off the canvas edges. overflow:clip never creates a scroll
   container, so no scrollbar can steal clientWidth from the grid. */
#dashboard-view.mode-anim,
#dashboard-view.anim-from-view,
#dashboard-view.anim-to-view { overflow: clip; }
#dashboard-view.mode-anim .grid-wrap,
#dashboard-view.anim-from-view .grid-wrap,
#dashboard-view.anim-to-view .grid-wrap { overflow: visible; }

/* Start/end state, both directions. TWO cases (chosen by main.js
   setViewOriginTransform from the VIEW/EDIT aspect gap):
     • aspects match (±ε) — now EVERY window shape, since VIEW and EDIT share
       the uniform 16:9 fit: the frame glides onto the REAL view canvas rect —
       --from-scale/--from-tx/--from-ty map it exactly (letterbox margins
       included), --from-opacity stays 1 → seam-free, no fade.
     • aspects differ (defensive only): an exact overlay would
       need a NON-uniform scale, which would visibly deform cells/guides during
       the anim. Instead main.js pins a small UNIFORM self-dezoom about the
       frame's own centre (--from-tx/--from-ty = 0) and --from-opacity = 0, so
       the geometry change is masked by a short cross-fade — no distortion, no
       abrupt geometry jump.
   translate comes FIRST so its offset is not scaled by the following scale();
   the fallbacks keep a sane value if unset. */
#dashboard-view.anim-from-view.preview-active #grid-preview,
#dashboard-view.anim-to-view.preview-active #grid-preview {
  transform: translate(var(--from-tx, calc(var(--palette-dock-w, 0px) / -2)), var(--from-ty, 0px))
             scale(var(--from-scale, calc(1 / var(--preview-scale, 1))));
  opacity: var(--from-opacity, 1);
  border-color: transparent;
  box-shadow: none;
}
#dashboard-view.anim-from-view.preview-active .palette,
#dashboard-view.anim-to-view.preview-active .palette {
  transform: translateX(calc(-1 * var(--palette-dock-w, 228px)));
  opacity: 0;
}
/* The guides follow the zoom: opacity 0 at the beginning/end of the swap. */
#dashboard-view.anim-from-view .grid-stack.editing-grid,
#dashboard-view.anim-to-view .grid-stack.editing-grid {
  --guide-op: 0;
}
/* The « from view » topbar state is the compact (clipped) VIEW bar: it must
   SNAP there so the base .topbar collapse transition (margin-top/opacity) can
   never fire before the real grown transition starts. The bar is NOT hidden
   here — it grows in place (clip-path, see the Topbar motion section) instead
   of dropping in from above, so the VIEW resting geometry is fully preserved. */
#dashboard-view.anim-from-view .topbar { transition: none !important; }

@media (prefers-reduced-motion: reduce) {
  /* Belt-and-braces: main.js already skips the animation entirely under
     reduced-motion (no .mode-anim is ever added), but the transitions must
     never run either — zero the topbar beats too. */
  #dashboard-view {
    --d-bar: 0ms;
    --d-cluster: 0ms;
    --d-admin: 0ms;
    --d-tabs: 0ms;
    --delay-bar: 0ms;
    --delay-cluster: 0ms;
    --delay-admin: 0ms;
    --delay-tabs: 0ms;
  }
  /* :root too: the background frame clip consumes --mode-dur on <body> (and no
     .bg-anim class is ever added under reduced-motion anyway). */
  :root { --mode-dur: 0ms; }
  #dashboard-view.mode-anim.preview-active #grid-preview,
  #dashboard-view.mode-anim.preview-active .palette,
  #dashboard-view.mode-anim .topbar,
  #dashboard-view.mode-anim .topbar-actions,
  #dashboard-view.mode-anim .editor-actions,
  #dashboard-view.mode-anim .topbar-tabs,
  #dashboard-view.mode-anim .grid-stack.editing-grid {
    transition: none;
  }
  #dashboard-view.anim-from-view.preview-active #grid-preview,
  #dashboard-view.anim-to-view.preview-active #grid-preview,
  #dashboard-view.anim-from-view.preview-active .palette,
  #dashboard-view.anim-to-view.preview-active .palette,
  #dashboard-view.anim-from-view .grid-stack.editing-grid,
  #dashboard-view.anim-to-view .grid-stack.editing-grid,
  #dashboard-view.anim-from-view .topbar,
  #dashboard-view.anim-to-view .topbar {
    transform: none;
    opacity: 1;
    clip-path: none;
    --guide-op: 1;
  }
}

/* ---------- Page switch transition (arrows slide / tab-click cross-fade) ----
   A fixed layer clipped to the grid area hosts a two-pane track holding
   snapshots of the outgoing and incoming pages (see main.js runPageAnim). The
   LIVE grid is hidden while .page-anim is on so the wallpaper (not the new
   grid) shows through the panes' transparent gaps; both pages are therefore
   visible simultaneously during the slide without ever overlapping. The layer
   is removed (and .page-anim cleared) at the end: no residual node/class.
   Durations live here (read LIVE by main.js); the easing reuses --mode-ease. */
#dashboard-view {
  --page-slide-dur: 340ms;
  --page-fade-dur: 220ms;
}
/* Hide ONLY the live grid (child of .dashboard-body) — the snapshots in the
   layer are .grid-wrap clones OUTSIDE .dashboard-body and must stay visible. */
#dashboard-view.page-anim .dashboard-body .grid-wrap { visibility: hidden; }
.page-anim-layer {
  position: fixed;
  overflow: hidden;
  z-index: 60;
  pointer-events: none;
}
.page-anim-track {
  position: absolute;
  inset: 0;
  display: flex;
  will-change: transform;
}
.page-anim-pane {
  position: relative;
  flex: 0 0 100%;
  width: 100%;
  height: 100%;
  overflow: hidden;
}
/* The cloned .grid-wrap fills its pane exactly (the pane box == the measured
   grid rect), so both snapshots render at the SAME geometry as the live grid. */
.page-anim-pane > .grid-wrap {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
}
/* Cross-fade: the two panes stack in the same box and only opacity animates. */
.page-anim-layer.fade .page-anim-track { display: block; }
.page-anim-layer.fade .page-anim-pane { position: absolute; inset: 0; }

/* ---------- Background layers (lot 3) ---------- */
/* ONE fixed full-viewport wrapper hosts every layer: keeping the layers at
   VIEWPORT geometry in BOTH modes is what makes the VIEW↔EDIT transition
   pop-free. Previously the layers were re-parented into #grid-preview-bg in
   edit mode, which (a) re-resolved background-size:cover for the 16:9 frame
   (different aspect than the viewport → a visible crop/scale jump), (b) made
   the brick's ResizeObserver re-back the procedural canvas at the frame size,
   and (c) jumped the layer rect from the viewport to the view-canvas rect on
   the first transition frame. Now the wrapper is merely CLIPPED to the frame
   (clip-path, animated with the same --mode-dur/--mode-ease as the frame
   dezoom): the image crop and the canvas backing store never change, so there
   is nothing left to pop. A wrapper (rather than clipping each layer) keeps
   the clip immune to the image's blur overscale transform(1.08). */
#homy-bg-layer {
  position: fixed;
  inset: 0;
  z-index: -3;
  pointer-events: none;
}
#homy-bg-image,
#homy-bg-dim,
#homy-bg-canvas {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* Frame clip (edit mode). --bg-clip-* are measured by manager.js from the
   #grid-preview-bg inner rect (the frame padding box): insets in viewport px
   + the inner corner radius. Expressed as a single inset() shape round() so
   clip-path interpolates smoothly between the frame rect and the full
   viewport (no shape-structure mismatch → no snap). */
body.bg-frame #homy-bg-layer {
  clip-path: inset(var(--bg-clip-t, 0px) var(--bg-clip-r, 0px) var(--bg-clip-b, 0px) var(--bg-clip-l, 0px)
    round var(--bg-clip-radius, 0px));
}
body.bg-anim #homy-bg-layer {
  transition: clip-path var(--mode-dur, 480ms) var(--mode-ease, cubic-bezier(0.4, 0, 0.2, 1));
  will-change: clip-path;
}
/* Start/end of a mode swap: the wrapper covers the WHOLE viewport (the VIEW
   look) so the clip glides between full-bleed and the frame rect. Declared
   AFTER the body.bg-frame rule on purpose (equal specificity → wins). */
body.bg-from-view #homy-bg-layer,
body.bg-to-view #homy-bg-layer {
  clip-path: inset(0 0 0 0 round 0px);
}

#homy-bg-image {
  z-index: -3; /* image layer, below the procedural canvas */
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
#homy-bg-image.homy-bg-scroll {
  position: absolute;
  inset: 0 auto auto 0;
  width: 100%;
  height: 100%;
}
#homy-bg-dim { z-index: -2; } /* dark overlay above the image */
#homy-bg-canvas { z-index: -2; display: block; width: 100%; height: 100%; }

/* ---------- Widgets ---------- */
/* Review C11: the vendored gridstack CSS positions .grid-stack-item-content
   ABSOLUTELY with the four --gs-item-margin-* insets (selector specificity
   0,3,0 beats this block's 0,1,0), so this element IS the positioned box the
   gridstack resize handles anchor to — an app-level `position: relative`
   here was dead code and is gone. The rule below only carries the surface
   look. Overflow is set in the 0,3,0 rule further down (overflow: hidden —
   review A2: the vendored default is overflow-y:auto which made every widget
   scroll internally instead of clipping). */
.grid-stack-item-content {
  background: var(--bg-elev);
  /* Per-widget appearance wins: --widget-bg-color/--widget-bg-op are set
     inline by applyAppearance() when the user configured a custom look.
     Without them, the surface falls back to the theme elevation color at
     var(--surface-alpha) — 100% (opaque) with no background, 80% (glass)
     when a background is active. */
  background: color-mix(in srgb, var(--widget-bg-color, var(--bg-elev)) var(--widget-bg-op, var(--surface-alpha)), transparent);
  /* Border width is per-widget: applyAppearance() sets
     --widget-border-width: 0 when the user turned « Show border » off. The
     fallback keeps the theme's 1px frame for every widget without the
     setting (legacy configs). */
  border-style: solid;
  border-width: var(--widget-border-width, 1px);
  /* Border alpha is a per-widget setting (borderOpacity, 0..100, default
     100 %). color-mix() lets the SAME rule honour --widget-border-color (or
     the theme --border) while scaling its alpha, so a widget with
     `bgOpacity: 0` + `borderOpacity: 100` keeps a crisp frame (legitimate
     « frame only, invisible fill ») and `borderOpacity: 0` hides it. */
  border-color: color-mix(in srgb, var(--widget-border-color, var(--border)) var(--widget-border-op, 100%), transparent);
  color: var(--text);
  color: var(--widget-text-color, var(--text));
  border-radius: var(--radius);
  display: flex;
  flex-direction: column;
}
/* Review A2 (specificity): same selector as the vendored gridstack rule that
   sets overflow-x:hidden;overflow-y:auto — style.css loads after the vendor
   stylesheet, so this later declaration wins the cascade tie and widget
   content CLIPS instead of scrolling inside its cell. Widgets that need to
   scroll do it on their own children (.notes-textarea…), which are unaffected. */
.grid-stack > .grid-stack-item > .grid-stack-item-content {
  overflow: hidden;
}
/* bgOpacity:0 → the widget BACKGROUND must be PERFECTLY invisible: no
   background, no backdrop blur, no shadow (without this, the backdrop-filter
   left a ghost rounded rectangle over the dashboard even though the background
   colour was already transparent). The BORDER is deliberately NOT neutralized
   here any more: its own alpha is governed by --widget-border-op
   (borderOpacity), so a « frame only, invisible fill » widget stays legible and
   `borderOpacity: 0` is the way to remove the frame.

   SPECIFICITY (measured bug fix): the glass rule below is
   `body[data-bg-active] .grid-stack-item-content` → (0,2,1). The plain
   `.grid-stack-item-content[data-surface-off]` selector is only (0,2,0), so
   the blur used to WIN the cascade and a blurred rectangle stayed visible over
   the wallpaper at opacity 0. The second selector below mirrors the glass rule
   plus the attribute → (0,3,1) so the neutralization actually wins. Keeping the
   plain selector too covers the no-background case (base rule is only
   (0,1,0)). */
.grid-stack-item-content[data-surface-off],
body[data-bg-active] .grid-stack-item-content[data-surface-off] {
  background: transparent;
  box-shadow: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}
/* Review C1 (compact mode): the item is the size container for the widget
   @container query below. Explicit width/height (gridstack CSS vars) keep
   the size containment harmless. */
.grid-stack-item {
  container-type: size;
}

/* backdrop-blur only when a background is active (avoids a costly filter
   over a plain theme color for nothing). */
body[data-bg-active] .grid-stack-item-content {
  -webkit-backdrop-filter: blur(14px) saturate(1.2);
  backdrop-filter: blur(14px) saturate(1.2);
}
/* ── layout v5: the WIDGET surface belongs to WIDGETS only ───────────────────
   Every object (widget, element placement, report placement) is a
   `.grid-stack-item` with a `.grid-stack-item-content` cell, but ONLY a widget
   is painted by that cell. An element/report tile paints its OWN surface one
   level deeper (`.group-tile` / `.report-tile`, driven by the per-tile
   `--tile-surface-*` vars set by applyTileSurface()).

   MEASURED BUG (surfaceOpacity had no visible effect): without this rule the
   cell painted the theme elevation `--bg-elev` at `--surface-alpha` (100 %
   opaque, 80 % + blur when a wallpaper is active) BEHIND every element tile, so
   `--tile-surface-op: 0%` revealed an opaque theme card instead of the
   wallpaper — the tile surface was already transparent, the layer underneath
   was not. `renderWidget()` marks these cells with `data-object-kind`
   (`element` | `report`), so the neutralization can never hit a widget.

   The `body[data-bg-active]` twin is required: the glass rule above is
   (0,2,1) and would otherwise keep the blurred rectangle at opacity 0 —
   this one is (0,3,1) and wins the cascade. Border is removed too: the cell is
   no longer a surface at all (its frame would ring the tile with a second
   rectangle). `--surface-off` semantics of GROUPS/WIDGETS are untouched. */
.grid-stack-item-content[data-object-kind],
body[data-bg-active] .grid-stack-item-content[data-object-kind] {
  background: none;
  border-width: 0;
  box-shadow: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}
.grid-stack-item-content .widget-body {
  flex: 1;
  overflow: auto;
  padding: 12px;
  display: flex;
  flex-direction: column;
}
.widget-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  border-bottom: 1px solid var(--border);
  font-size: 13px;
  color: var(--text-muted);
  font-weight: 600;
}
.widget-header .widget-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Lot 9 — the edit-mode per-tile controls (✕ delete / ⚙ options) were REMOVED:
   they stole canvas space and duplicated what the right-click context menu now
   offers. `.grid-stack-item.editing` is therefore no longer set by editor.js and
   no `.widget-controls` node exists in the DOM. */

/* LOT 6: the shortcut widget was removed with its .shortcut-* styles. */

/* ═══════════════════════════════════════════════════════════════════════
   Group boxes (layout v5, lot L2) — the .group-layer
   ═══════════════════════════════════════════════════════════════════════
   A v5 group is a CONTENT-LESS box `{ id, x, y, w, h, config }`: fill + border +
   title, no trame, no members of its own. The boxes live in a dedicated
   `.group-layer` — the FIRST child of #grid-preview — so they paint UNDER the
   gridstack items AND under the edit guides (the vendored gridstack CSS puts no
   z-index on .grid-stack-item, so DOM order rules the stacking; see
   grid/groups.js).

   ONE conversion unit: main.js publishes `--unit-w = canvasW / 64` and
   `--unit-h = canvasH / 36` on #grid-preview; every box is a pure multiple of
   those (`calc(x * var(--unit-w))`). A box NEVER measures anything.

   The layer mirrors #grid-container's layout box (left/top/width/height set by
   groups.js syncCanvas) so the two siblings share one coordinate space,
   including the EDIT dezoom (the scale is applied to BOTH in preview-active).
   The whole layer is `pointer-events: none`: in VIEW a click on a group goes
   straight THROUGH to the canvas / the object on top (boxes are purely
   decorative). */
.group-layer {
  position: absolute;
  pointer-events: none;
}
/* EDIT dezoom: the canvas (#grid-container.grid-stack.editing-grid) is scaled
   by --preview-scale; the box layer must scale IDENTICALLY to stay aligned. The
   transform is only applied in preview-active, so VIEW keeps a clean stacking /
   backdrop-filter context. */
#dashboard-view.preview-active .group-layer {
  transform: scale(var(--preview-scale, 1));
  transform-origin: top left;
}

/* The BOX. SAME appearance contract as .grid-stack-item-content: the per-widget
   vars (--widget-bg-color/-op, --widget-border-color/-op, --widget-border-width,
   --widget-text-color) are set by applyAppearance() (reused from
   widgets/registry.js). The `.group-layer` prefix raises specificity above the
   plain .grid-stack-item-content rules it mirrors. */
.group-layer .group-box {
  position: absolute;
  display: flex;
  flex-direction: column;
  background: color-mix(in srgb, var(--widget-bg-color, var(--bg-elev)) var(--widget-bg-op, var(--surface-alpha)), transparent);
  border-style: solid;
  border-width: var(--widget-border-width, 1px);
  border-color: color-mix(in srgb, var(--widget-border-color, var(--border)) var(--widget-border-op, 100%), transparent);
  border-radius: var(--radius);
  color: var(--widget-text-color, var(--text));
}
/* bgOpacity:0 → fill + backdrop-filter neutralized (no blurred ghost rectangle
   — the regression already fixed for widgets, not reintroduced here), but the
   BORDER survives: its own alpha is --widget-border-op (borderOpacity).
   Specificity (0,3,0) > the (0,2,0) box rule; the second selector mirrors the
   `body[data-bg-active]` glass rule so the blur can never win the cascade. */
.group-layer .group-box[data-surface-off],
body[data-bg-active] .group-layer .group-box[data-surface-off] {
  background: transparent;
  box-shadow: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}
/* backdrop-filter ONLY when a background is active AND the box keeps an
   explicit fill (groups.js sets [data-group-blur] for bgColor && bgOpacity>0) —
   no useless GPU cost otherwise. */
body[data-bg-active] .group-layer .group-box[data-group-blur] {
  -webkit-backdrop-filter: blur(14px) saturate(1.2);
  backdrop-filter: blur(14px) saturate(1.2);
}

/* Hover / selection affordances. The cursor is driven by the mousemove
   hit-test (groups.js toggles #grid-preview.group-hovering) because the boxes
   themselves are pointer-events:none — a :hover cursor could never fire. */
.group-layer .group-box.group-hover {
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent);
}
.group-layer .group-box.group-selected {
  box-shadow: 0 0 0 2px var(--accent), 0 0 0 5px color-mix(in srgb, var(--accent) 28%, transparent);
}

/* Title chip — FLOATING, reserves ZERO space (absolute; offset 0), never
   interactive. `always` = shown; `hover` = opacity 0 until .group-hover (set by
   the mousemove hit-test — NOT :hover, impossible on a pointer-events:none
   element) OR in EDIT (always revealed); `never` = not rendered at all
   (groups.js never creates the .group-header). */
.group-layer .group-header {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 6px;
  max-width: 50%;
  width: max-content;
  box-sizing: border-box;
  padding: 6px 10px;
  border-radius: var(--radius);
  border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
  background: color-mix(in srgb, var(--bg-elev-2) 72%, transparent);
  -webkit-backdrop-filter: blur(6px) saturate(1.15);
  backdrop-filter: blur(6px) saturate(1.15);
  color: var(--widget-text-color, var(--text));
  font-size: 13px;
  font-weight: 600;
  white-space: nowrap;
  pointer-events: none;
  transition: opacity var(--mode-dur, 160ms) ease;
}
.group-layer .group-header .group-title {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.group-layer .group-box[data-title-visibility='hover'] > .group-header { opacity: 0; }
.group-layer .group-box[data-title-visibility='hover'].group-hover > .group-header,
.group-layer[data-mode='edit'] .group-box[data-title-visibility='hover'] > .group-header { opacity: 1; }

/* prefers-reduced-motion: no chip fade. */
@media (prefers-reduced-motion: reduce) {
  .group-layer .group-header { transition: none; }
}

/* ═══════════════════════════════════════════════════════════════════════
   Overlay layer (lot L2; lasso / selection / group-resize handle — engine L4)
   ═══════════════════════════════════════════════════════════════════════
   LAST child of #grid-preview, EDIT only (groups.js removes it in VIEW): above
   the grid items and the guides. The parent is pointer-events:none; ONLY the
   interactive handles opt back in with pointer-events:auto. The layer mirrors
   the canvas box exactly like .group-layer (same syncCanvas), so a handle
   positioned in fine units lands on the matching box. */
.overlay-layer {
  position: absolute;
  pointer-events: none;
  z-index: 5;
}
#dashboard-view.preview-active .overlay-layer {
  transform: scale(var(--preview-scale, 1));
  transform-origin: top left;
}
.overlay-layer .group-handle {
  position: absolute;
  pointer-events: auto;
  width: 14px;
  height: 14px;
  border: 1px solid var(--accent);
  border-radius: 4px;
  background: color-mix(in srgb, var(--bg-elev-2) 85%, transparent);
}
.overlay-layer .group-handle[data-handle='se'] { right: -7px; bottom: -7px; cursor: nwse-resize; }
.overlay-layer .group-marquee {
  position: absolute;
  pointer-events: none;
  border: 1px solid var(--accent);
  background: color-mix(in srgb, var(--accent) 8%, transparent);
  border-radius: 2px;
}
/* Cursor feedback for the group hit-test (boxes are pointer-events:none, so the
   cursor must live on the grid underneath). */
#grid-preview.group-hovering #grid-container { cursor: move; }

/* ═══════════════════════════════════════════════════════════════════════
   Group editing (layout v5, lot L4) — handles, alert, object ring, bar
   ═══════════════════════════════════════════════════════════════════════ */
/* Contact alert ring (~120 ms, toggled by grid/groups.js flashAlert): a refused
   drag position / a resize stopped at the first group contact. Sits after the
   `.group-selected` rule so it wins the cascade while it is on. */
.group-layer .group-box.group-alert {
  box-shadow: 0 0 0 2px var(--danger, #e5484d), 0 0 0 5px color-mix(in srgb, var(--danger, #e5484d) 30%, transparent);
}

/* A single handle set per visible group (selected and/or hovered). The wrapper
   is a pure fine-unit box inside `.overlay-layer` (same coordinate space as the
   `.group-box`es); its children opt back into pointer events. */
.overlay-layer .group-handle-set {
  position: absolute;
  pointer-events: none;
  z-index: 6;
}
/* 4px grab frame glued INSIDE the box border. Four strips (not one ring) so the
   group stays grabbable along its edges while the interior remains click-through
   for the objects on top of it. */
.overlay-layer .group-handle-set .group-edge {
  position: absolute;
  pointer-events: auto;
  cursor: move;
  box-sizing: border-box;
  background: color-mix(in srgb, var(--accent) 22%, transparent);
  border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
}
.overlay-layer .group-handle-set .group-edge[data-edge='n'] { left: 0; right: 0; top: 0; height: 4px; border-width: 0 0 1px 0; }
.overlay-layer .group-handle-set .group-edge[data-edge='s'] { left: 0; right: 0; bottom: 0; height: 4px; border-width: 1px 0 0 0; }
.overlay-layer .group-handle-set .group-edge[data-edge='w'] { top: 0; bottom: 0; left: 0; width: 4px; border-width: 0 1px 0 0; }
.overlay-layer .group-handle-set .group-edge[data-edge='e'] { top: 0; bottom: 0; right: 0; width: 4px; border-width: 0 0 0 1px; }
/* SE resize handle, 12×12, glued INSIDE the bottom-right corner (above the two
   strips so the nwse cursor wins there). Overrides the default .group-handle
   geometry from the L2 block. */
.overlay-layer .group-handle-set .group-handle[data-handle='se'] {
  right: 0;
  bottom: 0;
  width: 12px;
  height: 12px;
  z-index: 2;
  cursor: nwse-resize;
  border-radius: 2px;
  background: var(--accent);
  border-color: var(--accent);
}

/* Selected OBJECT ring (L4 minimal multi-selection). The item content is inset
   8px by gridstack's margin, so an outline on the cell box reads as a clean
   halo around the object. */
.grid-stack-item.grid-object-selected {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: calc(var(--radius) + 2px);
}

/* Floating multi-selection bar (L6 — replaces the minimal L4 context bar).
   position:fixed, centred at the bottom, appended to <body> so it is never
   clipped by the canvas and never scrolls with the grid. Revealed in EDIT only,
   when ≥ 1 object OR group is selected. Sections lay out in a wrapping row:
   counter → Objects → Groups → actions. */
.selection-bar {
  position: fixed;
  left: 50%;
  bottom: 16px;
  transform: translateX(-50%);
  z-index: 60;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 8px;
  max-width: min(94vw, 1180px);
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: color-mix(in srgb, var(--bg-elev) 94%, transparent);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.32);
}
.selection-bar.hidden { display: none; }
.selection-bar .btn { font-size: 13px; }
.selection-bar .btn.confirm {
  background: var(--danger, #e5484d);
  border-color: var(--danger, #e5484d);
  color: #fff;
}
.selection-bar .sel-count {
  display: flex;
  align-items: center;
  gap: 8px;
  padding-right: 4px;
  border-right: 1px solid var(--border);
  font-size: 13px;
  font-weight: 600;
  white-space: nowrap;
}
.selection-bar .sel-count-detail {
  font-weight: 400;
  color: var(--text-muted, #8a93a6);
}
.selection-bar .sel-section {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 4px;
  border-right: 1px solid var(--border);
}
.selection-bar .sel-section-title {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted, #8a93a6);
}
.selection-bar .sel-actions {
  display: flex;
  align-items: center;
  gap: 6px;
}
/* One field = label + control, laid out inline. `is-mixed` marks a key whose
   carriers disagree; moving the control overwrites the key on every carrier. */
.selection-bar .sel-field {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  white-space: nowrap;
}
.selection-bar .sel-field.is-mixed {
  outline: 1px dashed color-mix(in srgb, var(--accent) 60%, transparent);
  outline-offset: 2px;
  border-radius: 4px;
}
.selection-bar .sel-field.is-mixed .sel-field-label::after {
  content: ' · mixed';
  color: var(--accent);
  font-weight: 600;
}
.selection-bar .sel-field-label { color: var(--text-muted, #8a93a6); }
.selection-bar .sel-range { width: 90px; }
.selection-bar .sel-field-val { min-width: 34px; text-align: right; font-variant-numeric: tabular-nums; }
.selection-bar .sel-color-row { display: flex; align-items: center; gap: 4px; }
.selection-bar .sel-color { width: 28px; height: 24px; padding: 0; border: 1px solid var(--border); border-radius: 4px; background: none; }
.selection-bar .sel-text {
  width: 120px;
  padding: 4px 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg, transparent);
  color: var(--text);
  font-size: 12px;
}
.selection-bar .segmented { display: inline-flex; }
.selection-bar .segmented-item { font-size: 12px; padding: 3px 8px; }

/* While a group gesture or a rigid multi-translation runs, keep the grabbing
   cursor and kill text selection. While a lasso runs, crosshair + no select. */
body.group-dragging,
body.group-dragging * { cursor: grabbing !important; user-select: none; }
body.lasso-active,
body.lasso-active * { cursor: crosshair !important; user-select: none; }

/* ═══════════════════════════════════════════════════════════════════════
   EDIT context menu (layout v5, lot L9) — ui/contextMenu.js
   ═══════════════════════════════════════════════════════════════════════
   The right-click menu that REPLACED the per-tile ✕/⚙ controls. It is appended
   to <body> and positioned `fixed` at the pointer, so it is never clipped by
   the canvas, never scrolls with the grid and never inherits the EDIT dezoom.
   z-index 400 sits above every canvas layer (.overlay-layer = 5, gridstack
   resize handles = 101, the selection bar = 60) while staying under the holaf
   modals opened from its action rows. Colours come ONLY from the theme tokens,
   so dark and light are both covered. */
.context-menu {
  position: fixed;
  z-index: 400;
  min-width: 212px;
  max-width: 300px;
  max-height: calc(100vh - 12px);
  overflow-y: auto;
  padding: 5px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: color-mix(in srgb, var(--bg-elev-2) 97%, transparent);
  box-shadow: 0 14px 36px rgba(0, 0, 0, 0.32);
  color: var(--text);
  font-size: 13px;
  user-select: none;
  outline: none;
}
.context-menu .context-header {
  padding: 5px 8px 6px;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.context-menu .context-sep {
  height: 1px;
  margin: 4px 6px;
  background: var(--border);
}
.context-menu .context-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 8px;
  border-radius: 6px;
  cursor: pointer;
  outline: none;
}
.context-menu .context-item:hover,
.context-menu .context-item:focus { background: var(--btn-hover); }
.context-menu .context-item[aria-checked='true'] .context-mark { opacity: 1; }
.context-menu .context-item.open { background: var(--btn-hover); }
.context-menu .context-mark {
  width: 12px;
  flex: 0 0 12px;
  text-align: center;
  color: var(--accent);
  opacity: 0;
  font-size: 11px;
  line-height: 1;
}
.context-menu .context-main { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
.context-menu .context-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.context-menu .context-hint {
  color: var(--text-muted);
  font-size: 11px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.context-menu .context-arrow { margin-left: auto; color: var(--text-muted); }
.context-menu .context-item.danger { color: var(--danger); }
.context-menu .context-item.danger .context-mark { color: var(--danger); }
.context-menu .context-item.disabled,
.context-menu .context-field.disabled { opacity: 0.45; cursor: default; }
.context-menu .context-item.disabled:hover { background: none; }
/* Field rows (range / select / color / text) stay OUT of the ↑/↓ roving focus
   (they are reached with Tab and keep their own key semantics). */
.context-menu .context-field { padding: 5px 8px 7px; }
.context-menu .context-field-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 5px;
  color: var(--text-muted);
  font-size: 12px;
}
.context-menu .context-field-value {
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.context-menu .context-range { width: 100%; }
.context-menu .context-color-row { display: flex; align-items: center; gap: 6px; }
.context-menu .context-color {
  width: 34px;
  height: 24px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: none;
}
.context-menu .context-theme { font-size: 12px; padding: 3px 8px; }
.context-menu .context-text {
  width: 100%;
  padding: 4px 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg, transparent);
  color: var(--text);
  font-size: 12px;
}
.context-menu .segmented { display: inline-flex; }
.context-menu .segmented-item { font-size: 12px; padding: 3px 8px; }

/* ---------- Button tiles (matrix of variants) ----------
   The TILE IS THE SURFACE. Its background/border/radius are driven by per-tile
   CSS custom properties set inline by applyTileSurface() (elements/button.js):
     --tile-surface-color  explicit background (fallback: theme elevation)
     --tile-surface-op     background opacity 0..100 % (fallback 100 %)
     --tile-surface-radius 8px (rounded) | 0px (square)
   The tile MARGIN (the regular gap around every tile) is driven by the trame,
   not by a per-tile var — a residual `--tile-surface-inset` is deliberately
   ignored.
   Defaults reproduce the historical look EXACTLY (opaque theme surface, 8px
   radius, no margin), so a button without these options is unchanged. */
.group-tile {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  padding: 3px;
  margin: var(--group-tile-inset, 0px);
  border-radius: var(--tile-surface-radius, 8px);
  border: 1px solid var(--border);
  background: color-mix(in srgb,
    var(--tile-surface-color, var(--bg-elev-2)) var(--tile-surface-op, 100%),
    transparent);
  color: var(--text);
  overflow: hidden;
  font-size: 12px;
  /* The tile is its OWN size container so the compact-typography @container
     queries below measure the TILE (not the enclosing widget/group). */
  container-type: size;
}
/* surfaceOpacity:0 → the tile surface must be PERFECTLY invisible: no
   background, no border, no shadow, no backdrop blur. Specificity (0,2,0) beats
   the plain .group-tile rule (0,1,0). */
.group-tile[data-surface-off] {
  background: transparent;
  border-color: transparent;
  box-shadow: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}
/* Advanced option « allow icon overflow »: let the icon spill instead of clip. */
.group-tile.allow-overflow { overflow: visible; }
.tile-main {
  position: relative;
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  min-width: 0;
  min-height: 0;
  padding: 2px;
  color: inherit;
  text-decoration: none;
  /* Follow the tile surface shape (2px smaller than the tile radius, never
     negative so `square` really is square). */
  border-radius: max(0px, calc(var(--tile-surface-radius, 8px) - 2px));
  overflow: hidden; /* content can never escape its own main zone */
}
.tile-main:not(.tile-main-static):hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
/* Icon + label core: the label POSITION (bottom/top/left/right, per-button
   option) flips this box's axis. Monitoring / health stay stacked OUTSIDE it
   (just below) so they never move with the label. */
.tile-core {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  min-width: 0;
  min-height: 0;
  max-width: 100%;
  max-height: 100%;
}
.group-tile[data-label-pos='top'] .tile-core { flex-direction: column-reverse; }
.group-tile[data-label-pos='left'] .tile-core { flex-direction: row-reverse; }
.group-tile[data-label-pos='right'] .tile-core { flex-direction: row; }
/* ---------- Icon box: ALWAYS square (1:1) ----------
   WHY: the trame is square (uniform VIEW fit, ratio 1.000 at every window
   size), but the tile box can still be a RECTANGLE when the placement is not
   (w: 2, h: 1) — a tile several fine cells wide and few tall, or the reverse,
   is a rectangle whatever the trame. The icon must NEVER
   follow that shape — an icon squeezed into a non-square viewport is
   either stretched (raster <img>) or letterboxed (SVG), which is exactly the
   « icons are deformed » defect. The icon therefore gets its own SQUARE box,
   sized from the SMALLER side of the tile's real box:

     --tile-icon-pct        iconSize / 100          (0.08 … 1.2)
     --tile-icon-chrome-w/h px eaten by the tile's inner padding + the sibling
                            blocks (label / monitoring / controls), PER AXIS

   The length is computed TWICE from ONE expression, so the box is square by
   construction whatever the tile aspect, and it re-resolves by itself when the
   window (and therefore the cell) resizes — no JS re-render needed.
   `100cqw`/`100cqh` measure the TILE (`.group-tile` is a size container), i.e.
   its CONTENT box — the tile's own border/padding are already excluded.
   Browsers without container-query units keep the px fallback computed by
   applyTileMetrics (--tile-icon-px). */
.tile-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--tile-icon-px, 24px);
  height: var(--tile-icon-px, 24px);
  /* Never shrunk on ONE axis only: a flex column would happily shrink the
     height of its main axis and leave the width untouched (= distortion). */
  flex: 0 0 auto;
  font-size: var(--tile-icon-px, 24px);
  line-height: 1;
  /* ELEMENT-level icon colour: `element.iconColor` is applied inline by
     renderButtonTile as --tile-icon-color on the tile. `var(--text)` when unset
     keeps the historical look. Only monochrome / currentColor glyphs follow it;
     multicolour / raster icons keep their own colours. */
  color: var(--tile-icon-color, var(--text));
  overflow: hidden;
}
@supports (width: 1cqw) {
  .tile-icon {
    --tile-icon-size: calc(
      var(--tile-icon-pct, 0.55) *
        min(100cqw - var(--tile-icon-chrome-w, 4px), 100cqh - var(--tile-icon-chrome-h, 4px))
    );
    width: var(--tile-icon-size);
    height: var(--tile-icon-size);
    font-size: var(--tile-icon-size);
  }
}
/* Icon glyphs (raster <img> included) fill their SQUARE box: `object-fit:
   contain` + the SVG's `preserveAspectRatio="xMidYMid meet"` both preserve the
   artwork's own aspect even if the box were ever to be clamped. */
.tile-icon-img { object-fit: contain; }
/* Advanced option « allow icon overflow »: let the icon spill instead of clip.
   BOTH the icon box AND the main zone must stop clipping — the main zone used
   to keep `overflow: hidden`, which cropped the spilling glyph and made the
   option a no-op for anything wider than the tile. */
.group-tile.allow-overflow,
.group-tile.allow-overflow .tile-main { overflow: visible; }
.group-tile.allow-overflow .tile-icon { overflow: visible; max-width: none; max-height: none; }
.tile-icon-img, .tile-icon svg { width: 100%; height: 100%; max-width: 100%; max-height: 100%; display: block; }
/* The inline SVG must never be shrunk by a parent flex line: it is sized by
   100% of its host box only (see also .tile-icon-local svg below). */
.tile-icon svg { stroke: currentColor; flex: 0 0 auto; }
.tile-icon-text {
  display: block;
  max-width: 100%;
  max-height: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tile-label {
  max-width: 100%;
  min-width: 0;
  flex: 0 1 auto;
  font-size: 11px;
  line-height: 1.15;
  text-align: center;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* ---------- labelPosition: overlay (label ON TOP of the icon) ----------
   The label is CENTRED over the icon instead of taking its own slot: no axis is
   consumed, so the icon keeps the whole tile (see iconChrome() in
   elements/button.js). It must stay readable on ANY background — theme surface,
   explicit tile colour, dashboard background image, dark AND light theme — so it
   sits on a translucent theme-tinted pill (same colour family as the tile
   surface, alpha-blended) plus a soft halo drawn in the OPPOSITE tonal direction
   (--bg): light text gets a dark halo in dark mode, dark text a light one in
   light mode, with no per-theme rule.
   Long names are clamped to 2 lines with an ellipsis and can never escape the
   tile: the box is inset 0/0 inside the (centred, max-100 %) core, so it is
   bounded by the tile's content box.
   `data-icon='off'` (no icon painted) falls back to the normal flow, so an
   icon-less tile still shows its label. */
.group-tile[data-label-pos='overlay'][data-icon='on'] .tile-core { position: relative; width: 100%; }
.group-tile[data-label-pos='overlay'][data-icon='on'] .tile-label {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  max-height: calc(2.3em + 2px); /* 2 whole lines at line-height 1.15 + the 1 px vertical padding */
  padding: 1px 4px;
  border-radius: 5px;
  background: color-mix(in srgb, var(--tile-surface-color, var(--bg-elev-2)) 68%, transparent);
  color: var(--text);
  text-shadow: 0 1px 2px color-mix(in srgb, var(--bg) 55%, transparent);
  white-space: normal;
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  /* Pure decoration: never steal a click / a drag / a link hover from the tile. */
  pointer-events: none;
}
/* In the overlay mode the label overlays the glyph, so the tile does NOT need
   to reserve room for it in the compact-typography queries either: keep 10 px
   so two short lines fit a 2×2 tile. */
@container (max-width: 64px) {
  .group-tile[data-label-pos='overlay'][data-icon='on'] .tile-label { font-size: 10px; }
}
/* Health dot — DEGRADED state: grey (unknown), never green/red until Docky. */
.tile-health {
  position: absolute;
  top: 3px;
  right: 3px;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: #9aa1ad;
  box-shadow: 0 0 0 2px var(--bg-elev-2);
  flex: 0 0 auto;
}
/* status-only variant: a big centred dot */
.group-tile[data-variant='health'] .tile-health {
  position: static;
  width: 22px;
  height: 22px;
  box-shadow: none;
}
/* monitoring — DEGRADED state: values are « — » */
.tile-monitoring { display: flex; flex-direction: column; gap: 1px; width: 100%; font-size: 10px; color: var(--text-muted); }
.tile-metric { display: flex; justify-content: space-between; gap: 6px; padding: 0 2px; }
.tile-metric-value { font-variant-numeric: tabular-nums; }
/* monitoring info alone: larger, stacked */
.group-tile[data-variant='monitoring'] .tile-monitoring { font-size: 13px; gap: 4px; }
.group-tile[data-variant='monitoring'] .tile-metric { flex-direction: column; align-items: center; gap: 0; }
/* controls — DEGRADED state: inert/disabled buttons (no listener attached) */
.tile-controls {
  display: flex;
  gap: 3px;
  align-items: center;
  justify-content: center;
  padding: 3px 2px 1px;
  border-top: 1px solid var(--border);
  flex: 0 0 auto;
}
.group-tile[data-variant='controls'] .tile-controls { flex: 1; border-top: 0; }
.tile-control {
  min-width: 20px;
  height: 20px;
  padding: 0 4px;
  font-size: 11px;
  line-height: 1;
  border-radius: 5px;
  border: 1px solid var(--border);
  background: var(--bg-elev);
  color: var(--text-muted);
  cursor: not-allowed;
  opacity: 0.55;
}
.tile-control:disabled { cursor: not-allowed; }
.tile-control:not(:disabled) { cursor: pointer; opacity: 1; color: var(--text); }
.tile-control:not(:disabled):hover { border-color: var(--accent); color: var(--accent); }
.tile-control.armed,
.tile-control.armed:hover {
  cursor: pointer;
  opacity: 1;
  border-color: var(--warning, #e0a800);
  color: var(--warning, #e0a800);
}
/* live health colours (data-state set by the Docky refresh or the URL probe) */
.tile-health[data-state='healthy'] { background: var(--success, #3fb950); }
.tile-health[data-state='unhealthy'] { background: var(--danger, #f85149); }
.tile-health[data-state='starting'] { background: var(--warning, #e0a800); animation: tile-health-pulse 1s ease-in-out infinite; }
/* custom-URL probe: host answered error → red; transport/offline → amber. */
.tile-health[data-state='degraded'] { background: var(--warning, #e0a800); }
@keyframes tile-health-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
/* « Docky offline » marker shown over a degraded tile */
.tile-docky-note {
  position: absolute;
  left: 3px;
  bottom: 2px;
  z-index: 2;
  font-size: 8px;
  line-height: 1;
  letter-spacing: 0.02em;
  color: var(--warning, #e0a800);
  background: color-mix(in srgb, var(--bg-elev-2) 84%, transparent);
  padding: 1px 3px;
  border-radius: 4px;
  pointer-events: none;
  white-space: nowrap;
}
/* explicit « nothing enabled » fallback — never an empty/broken tile */
.tile-empty {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-muted);
  font-size: 16px;
}

/* Compact typography (roadmap §A.7): in a small tile the label / monitoring /
   controls shrink so nothing ever spills over a neighbouring tile. These
   @container queries measure the TILE (see container-type on .group-tile). */
@container (max-width: 64px) {
  .tile-label { font-size: 10px; }
  .tile-monitoring { font-size: 9px; gap: 0; }
  .tile-controls { gap: 2px; }
  .tile-control { min-width: 16px; height: 16px; padding: 0 3px; font-size: 10px; }
}
@container (max-height: 44px) {
  .tile-label { font-size: 10px; }
}

/* ---------- Report tiles (lot 8) ----------
   A dedicated tile kind inside a group, referencing an element that carries a
   Special-reporting config. FREE size on the internal trame (no button-variant
   minima). Renders the normalized provider payload (sessions + progress) or a
   readable degraded state. */
.report-tile {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  padding: 6px 8px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--bg-elev-2);
  color: var(--text);
  overflow: hidden;
  font-size: 12px;
  container-type: size;
}
.report-tile.tile-editable { cursor: move; }
.report-tile.tile-editable:hover { outline: 1px dashed var(--accent); outline-offset: 1px; }
body.tile-dragging .report-tile.tile-editable { cursor: grabbing; }
.report-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; flex: 0 0 auto; }
.report-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.report-count {
  flex: 0 0 auto;
  font-size: 11px;
  padding: 0 6px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 20%, transparent);
  color: var(--accent);
}
.report-body { flex: 1; min-height: 0; overflow: auto; margin-top: 4px; }
.report-state { color: var(--text-muted); font-size: 12px; }
.report-sessions { display: flex; flex-direction: column; gap: 6px; }
.report-session { border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); padding-top: 5px; }
.report-session:first-child { border-top: 0; padding-top: 0; }
.report-session-top { display: flex; align-items: center; gap: 5px; min-width: 0; }
.report-media { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.report-badge {
  flex: 0 0 auto;
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 1px 5px;
  border-radius: 999px;
  border: 1px solid var(--border);
  color: var(--text-muted);
}
.report-badge-tc { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.report-badge-paused { color: var(--warning, #e0a800); }
.report-sub { font-size: 11px; margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.report-bar { height: 4px; margin-top: 4px; border-radius: 999px; background: color-mix(in srgb, var(--text) 12%, transparent); overflow: hidden; }
.report-bar i { display: block; height: 100%; background: var(--accent); }
.report-meta { display: flex; justify-content: space-between; font-size: 10px; color: var(--text-muted); margin-top: 2px; }

/* ---------- Group tile editing (lot 4) ----------
   Edit-mode only affordances: header « + Add element », per-tile ⚙/✕ and a
   bottom-right resize handle. All are position:absolute so they never change
   the tile's measured size (the compact-typography @container queries above
   still see the same box). */
.group-tile.tile-editable { cursor: move; }
.group-tile.tile-editable:hover { outline: 1px dashed var(--accent); outline-offset: 1px; }
body.tile-dragging { user-select: none; -webkit-user-select: none; }
body.tile-dragging .group-tile.tile-editable { cursor: grabbing; }

/* Multi-selection (lot 4): a clear 2px accent ring + a soft halo, independent
   from the hover outline so a selected tile stays obvious while hovered.
   Placed AFTER the [data-surface-off] rule so a selected invisible-surface tile
   still shows its ring (same specificity, later wins). */
.group-tile.tile-selected {
  box-shadow: 0 0 0 2px var(--accent), 0 0 0 4px color-mix(in srgb, var(--accent) 32%, transparent);
  z-index: 5;
}

.tile-edit-controls {
  position: absolute;
  top: 2px;
  right: 2px;
  z-index: 3;
  display: flex;
  gap: 2px;
}
.tile-edit-controls button {
  width: 18px;
  height: 18px;
  padding: 0;
  font-size: 11px;
  line-height: 1;
  border-radius: 5px;
  border: 1px solid var(--border);
  background: color-mix(in srgb, var(--bg-elev-2) 92%, transparent);
  color: var(--text-muted);
  cursor: pointer;
}
.tile-edit-controls button:hover { border-color: var(--accent); color: var(--accent); }
.tile-edit-controls .tile-edit-del.confirm,
.tile-edit-controls .tile-edit-del.confirm:hover { border-color: var(--danger); color: var(--danger); }

.tile-resize {
  position: absolute;
  right: 0;
  bottom: 0;
  z-index: 3;
  width: 10px;
  height: 10px;
  cursor: nwse-resize;
  border-right: 2px solid var(--accent);
  border-bottom: 2px solid var(--accent);
  border-bottom-right-radius: 7px;
  opacity: 0.7;
}
.tile-resize:hover { opacity: 1; }

/* clock */
.clock-widget { align-items: center; justify-content: center; text-align: center; gap: 4px; }
.clock-time { font-size: 2.2em; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
.clock-date { color: var(--text-muted); font-size: 0.9em; }

/* iframe */
.iframe-widget { padding: 0; }
.iframe-widget iframe { width: 100%; height: 100%; border: 0; flex: 1; }

/* LOT 6: the links widget was removed with its .links-* styles. */

/* search — review C1: a default search (11×2 since the C1 change) must never
   clip its bar. Budget (border-box): widget padding 2×6 + input (2×7 padding
   + 2×1 border + 16px line) + widget borders 2×1 ≈ 46px. A 2-row cell offers
   ≥ 57px from 1366×768 up, and even a 1-row cell at 2560×1440 offers ~58px →
   no clip, no scroll anywhere ≥ 1366×768. Vertical centering keeps the bar
   centered in taller cells (`safe center` falls back to start-alignment when
   the content actually overflows, so nothing is unreachable); engines that
   don't support `safe` keep plain `center` from the first declaration. */
.search-widget {
  padding: 6px 10px;
  justify-content: center;
  justify-content: safe center;
}
.search-form { display: flex; gap: 8px; }
.search-input {
  flex: 1;
  padding: 7px 10px;
  background: var(--bg-elev-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  font-size: 14px;
}
.search-input:focus { outline: none; border-color: var(--accent); }
.search-widget .btn { padding: 7px 12px; }
/* Compact mode (review C1/C2): when the ITEM is under ~62px tall (1-row cells
   on small desktops, 2-row cells on portrait tablets) the normal paddings
   cannot fit (an abs-positioned box can never be smaller than its own
   padding+border), so shave paddings AND font so the bar stays usable:
   budget drops to ~25px, fitting a 2-row cell at 768×1024 (~29px available). */
@container (max-height: 62px) {
  .search-widget { padding: 2px 6px; }
  .search-widget .search-input { padding: 2px 8px; font-size: 13px; }
  .search-widget .btn { padding: 2px 8px; font-size: 13px; }
}

/* notes */
.notes-widget { padding: 0; }
.notes-textarea {
  flex: 1;
  width: 100%;
  border: 0;
  background: transparent;
  color: var(--text);
  font-family: inherit;
  font-size: 14px;
  line-height: 1.5;
  padding: 12px;
  resize: none;
}
.notes-textarea:focus { outline: none; }

/* weather */
.weather-widget { padding: 12px; }
.weather-body { display: flex; flex-direction: column; gap: 4px; }
.weather-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.weather-temp { font-size: 2em; font-weight: 700; }
.weather-city { color: var(--text-muted); font-size: 0.9em; }
.weather-cond { color: var(--text-muted); }
.weather-wind { color: var(--text-muted); font-size: 0.85em; }
.weather-loading { color: var(--text-muted); font-size: 0.9em; }

/* ---------- Modal (dead) removed ---------- */
.config-form label { display: block; margin-bottom: 12px; }
.config-form label span { display: block; margin-bottom: 6px; font-size: 13px; color: var(--text-muted); }
.config-form input, .config-form textarea {
  width: 100%;
  padding: 9px 11px;
  background: var(--bg-elev-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  font-size: 14px;
  font-family: inherit;
}
.config-form input:focus, .config-form textarea:focus { outline: none; border-color: var(--accent); }
/* Checkbox fields (toggle type): the generic full-width input box would make a
   lone checkbox span the row; keep it inline and themed. */
.config-form input[type='checkbox'] {
  width: auto;
  padding: 0;
  accent-color: var(--accent);
}
.config-form select {
  width: 100%;
  padding: 9px 11px;
  background: var(--bg-elev-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  font-size: 14px;
}
.config-form .field { margin-bottom: 14px; }
/* « Special reporting » section of the element form (lot 8). */
.report-section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.report-section-title { margin: 0 0 8px; font-size: 13px; font-weight: 600; }
.report-toggle { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; }
.report-config { margin-top: 10px; }
.report-test-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.report-test-result { font-size: 12px; color: var(--text-muted); }
.report-test-result.success { color: var(--success); }
.report-test-result.error { color: var(--danger); }

/* ---------- Element health source (Off | Docky target | Custom URL) ----------
   A 3-way source choice in the element form's health section, INDEPENDENT from
   the always-visible Docky target picker (`docky-target-section`). `Custom URL`
   reveals the healthUrl input; the inline `.health-warning` appears when
   « Docky target » is chosen without a target. */
.health-section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.health-section-title { margin: 0 0 8px; font-size: 13px; font-weight: 600; }
.health-source-group { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.health-source-group label {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0;
  padding: 6px 10px;
  font-size: 13px;
  cursor: pointer;
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--text-muted);
}
.health-source-group label:has(input:checked) {
  border-color: var(--accent);
  color: var(--text);
  background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.health-source-group input[type='radio'] { margin: 0; }
.health-panel.hidden { display: none; }
.health-url-field { margin-top: 4px; }
.health-url-field input { width: 100%; }
.health-url-help { display: block; color: var(--text-muted); font-size: 12px; margin-top: 4px; }
.health-warning {
  margin-top: 8px;
  padding: 8px 10px;
  font-size: 12px;
  line-height: 1.4;
  color: var(--warning, #e0a800);
  border: 1px solid color-mix(in srgb, var(--warning, #e0a800) 45%, var(--border));
  border-radius: 6px;
  background: color-mix(in srgb, var(--warning, #e0a800) 10%, transparent);
}
.health-warning.hidden { display: none; }
.config-form .field-label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--text-muted); }
.config-form .field-help { display: block; color: var(--text-muted); font-size: 12px; margin-top: 2px; }
.config-form .color-field { display: flex; gap: 8px; align-items: center; }
.config-form .color-field input[type='color'] {
  width: 48px;
  height: 36px;
  padding: 2px;
  flex: 0 0 auto;
}
.config-form .list-editor { margin-bottom: 8px; }
.config-form .list-row {
  display: flex;
  gap: 8px;
  margin-bottom: 8px;
  align-items: flex-end;
  flex-wrap: wrap;
}
.config-form .list-cell { flex: 1; min-width: 120px; }
.config-form .list-cell-label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.config-form .list-row .remove-shortcut { flex: 0 0 auto; }
.config-form .shortcut-row { display: flex; gap: 6px; margin-bottom: 8px; align-items: center; }
.config-form .shortcut-row input { flex: 1; }
.config-form .shortcut-row .remove-shortcut { flex: 0 0 auto; }

/* ---------- Sliders (mock homy-bg-modal-mock.html) ----------
   Piste fine (4px), poignée accent, label à gauche + valeur live alignée à
   droite (tabular-nums), aide sous le champ. Thème via tokens (--accent,
   --border) : suit le switch sombre/clair. Les inputs existants gardent leur
   style boîte : seul input[type=range] est détouré ci-dessous. */
.config-form input[type='range'] {
  width: 100%;
  height: 20px;
  padding: 0;
  margin: 2px 0;
  border: 0;
  background: none;
  -webkit-appearance: none;
  appearance: none;
  cursor: pointer;
}
.config-form input[type='range']:focus { outline: none; }
.config-form input[type='range']::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 999px;
  background: var(--border);
}
.config-form input[type='range']::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 16px;
  height: 16px;
  margin-top: -6px; /* centre la poignée sur la piste de 4px */
  border: 0;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.config-form input[type='range']:focus-visible::-webkit-slider-thumb {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.config-form input[type='range']::-moz-range-track {
  height: 4px;
  border-radius: 999px;
  background: var(--border);
}
.config-form input[type='range']::-moz-range-thumb {
  width: 14px;
  height: 14px;
  border: 0;
  border-radius: 50%;
  background: var(--accent);
}
/* Valeur live, alignée à droite dans la ligne de label (« 1.0 », « 0 px »…).
   Imbriquée dans le span du label : neutralise la marge basse héritée de
   `.config-form label span` et force la couleur pleine (vs texte muted). */
.config-form .field-val {
  float: right;
  margin: 0;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}

/* ---------- Background modal (lot 3) ---------- */
.bg-section-title { margin: 4px 0 10px; font-size: 14px; color: var(--text-muted); }
.bg-thumbs {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  margin-bottom: 12px;
}
.bg-thumb {
  position: relative;
  aspect-ratio: 16 / 10;
  border: 2px solid var(--border);
  border-radius: 8px;
  padding: 0;
  cursor: pointer;
  overflow: hidden;
  background: var(--bg-elev-2);
}
.bg-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bg-thumb.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.bg-thumb-del {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 22px;
  height: 22px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: rgba(0, 0, 0, 0.55);
  color: #fff;
  font-size: 11px;
  line-height: 1;
  cursor: pointer;
}
.bg-thumb-del:hover { border-color: var(--danger); color: var(--danger); }
.bg-empty { grid-column: 1 / -1; margin: 0; font-size: 13px; }
.bg-file-input { font-size: 13px; }
.bg-toggle { display: flex !important; align-items: center; gap: 8px; margin-bottom: 12px; }
.bg-toggle input { width: auto; }
.bg-toggle span { font-size: 13px; color: var(--text-muted); }
/* Live preview = the REAL full-screen background behind the modal (no in-modal
   canvas any more). This banner tells the user, and warns that Cancel reverts. */
.bg-live-hint {
  margin: 0 0 14px;
  padding: 8px 10px;
  font-size: 12.5px;
  line-height: 1.4;
  color: var(--text-muted);
  background: var(--bg-elev-2);
  border: 1px solid var(--border);
  border-radius: 8px;
}
.bg-swatches { display: flex; gap: 6px; margin-top: 8px; }
.bg-swatches i {
  width: 22px;
  height: 22px;
  border: 1px solid var(--border);
  border-radius: 6px;
}
.bg-error { color: var(--danger); font-size: 13px; min-height: 18px; margin: 8px 0 0; }

/* ---------- Elements catalogue modal (lot 3) ----------
   CRUD screen for the global element catalogue. All colors via theme tokens so
   it follows the dashboard dark/light switch. */
.elements-body { display: flex; flex-direction: column; gap: 12px; }
.elements-count { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; padding-right: 6px; }
.elements-list { display: flex; flex-direction: column; gap: 8px; }
.elements-empty { margin: 8px 0; font-size: 14px; }
.elements-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-elev-2);
}
/* Shared icon preview box (list row + form preview) — hosts the exact node
   elements/button.js renders inside a tile. */
.elements-icon-box {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-elev);
  color: var(--text);
  line-height: 1;
}
.elements-icon-box svg,
.elements-icon-box img { width: 100%; height: 100%; display: block; }
.elements-icon-box svg { stroke: currentColor; }
.elements-icon-box img { object-fit: contain; }
.elements-row-icon { width: 40px; height: 40px; font-size: 22px; }
.elements-icon-preview { width: 56px; height: 56px; font-size: 30px; margin-top: 6px; }
.elements-row-main { flex: 1; min-width: 0; }
.elements-row-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.elements-row-url {
  display: block;
  font-size: 12px;
  color: var(--accent);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  text-decoration: none;
}
.elements-row-url:hover { text-decoration: underline; }
.elements-row-desc { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.elements-row-badges { display: flex; flex-direction: column; gap: 4px; align-items: flex-start; flex: 0 0 auto; }
.badge {
  font-size: 11px;
  padding: 2px 8px;
  border-radius: 999px;
  border: 1px solid var(--border);
  color: var(--text-muted);
  white-space: nowrap;
}
.badge-docky { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.badge-health { color: var(--success); border-color: color-mix(in srgb, var(--success) 45%, var(--border)); }
.badge-report { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.elements-row-actions { display: flex; gap: 6px; flex: 0 0 auto; }
.elements-row-actions .btn { padding: 5px 10px; font-size: 13px; }
.elements-row-actions .btn.confirm { border-color: var(--danger); color: var(--danger); }
/* 409 conflict panel: element still referenced by group buttons. */
.elements-notice {
  border: 1px solid var(--danger);
  border-radius: 8px;
  background: color-mix(in srgb, var(--danger) 12%, transparent);
  padding: 10px 12px;
}
.elements-notice-title { margin: 0 0 4px; font-weight: 600; color: var(--danger); }
.elements-notice p { margin: 4px 0; font-size: 13px; }
.elements-usages { margin: 6px 0; padding-left: 18px; font-size: 13px; }
.elements-usages li { margin: 2px 0; }
.elements-notice-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
.elements-form-body { display: flex; flex-direction: column; }

/* ---------- Element picker (lot 4) ---------- */
.picker-body { display: flex; flex-direction: column; gap: 10px; }
.picker-search {
  width: 100%;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--bg-elev);
  color: var(--text);
  font-size: 14px;
}
.picker-search:focus { outline: none; border-color: var(--accent); }
.picker-list { display: flex; flex-direction: column; gap: 6px; max-height: 52vh; overflow-y: auto; }
.picker-empty { margin: 8px 0; font-size: 13px; }
.picker-count { font-size: 12px; }
.picker-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 8px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-elev);
  cursor: pointer;
}
.picker-row:hover, .picker-row:focus-visible { border-color: var(--accent); outline: none; }
.picker-row-icon { width: 34px; height: 34px; font-size: 20px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; overflow: hidden; color: var(--text); }
.picker-row-icon svg, .picker-row-icon img { width: 100%; height: 100%; display: block; }
.picker-row-icon svg { stroke: currentColor; }
.picker-row-icon img { object-fit: contain; }
.picker-row-main { flex: 1; min-width: 0; }
.picker-row-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.picker-row-url { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.picker-row-badges { display: flex; gap: 4px; flex: 0 0 auto; }
.picker-row-del {
  flex: 0 0 auto;
  width: 24px;
  height: 24px;
  padding: 0;
  font-size: 12px;
  line-height: 1;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
}
.picker-row-del:hover { border-color: var(--danger); color: var(--danger); }
.picker-row-del.confirm { border-color: var(--danger); color: var(--danger); font-size: 10px; }

/* ---------- Button options panel (lot 4) ---------- */
.opt-panel-body { padding: 0; }
.opt-panel { display: flex; flex-direction: column; gap: 14px; }
.opt-element {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-elev);
}
.opt-element-icon { width: 36px; height: 36px; font-size: 20px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; overflow: hidden; color: var(--text); }
.opt-element-icon svg, .opt-element-icon img { width: 100%; height: 100%; display: block; }
.opt-element-icon svg { stroke: currentColor; }
.opt-element-main { flex: 1; min-width: 0; }
.opt-element-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.opt-element-url { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.opt-group { display: flex; flex-direction: column; gap: 6px; }
.opt-group-title {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
}
.opt-min-hint { font-weight: 500; text-transform: none; letter-spacing: 0; }
.opt-switch { display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; cursor: pointer; }
.opt-switch.disabled { opacity: 0.5; cursor: not-allowed; }
.opt-switch input { margin-top: 2px; flex: 0 0 auto; }
.opt-switch-main { display: flex; flex-direction: column; min-width: 0; }
.opt-switch-label { font-size: 13px; }
.opt-switch-help { color: var(--text-muted); font-size: 11px; }
.segmented { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; width: fit-content; }
.segmented-item {
  padding: 5px 12px;
  font-size: 12px;
  border: 0;
  border-right: 1px solid var(--border);
  background: var(--bg-elev);
  color: var(--text-muted);
  cursor: pointer;
}
.segmented-item:last-child { border-right: 0; }
.segmented-item:hover:not(:disabled) { color: var(--text); }
.segmented-item.active { background: var(--accent); color: #fff; }
.segmented-item:disabled { opacity: 0.4; cursor: not-allowed; }
/* Fine icon-size control: a 0.5-step percentage slider + a live value. */
.icon-size-row { display: flex; align-items: center; gap: 10px; }
.icon-size-row input[type='range'] {
  flex: 1;
  height: 20px;
  padding: 0;
  margin: 0;
  border: 0;
  background: none;
  -webkit-appearance: none;
  appearance: none;
  cursor: pointer;
}
.icon-size-row input[type='range']:focus { outline: none; }
.icon-size-row input[type='range']:disabled { opacity: 0.4; cursor: not-allowed; }
.icon-size-row input[type='range']::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 999px;
  background: var(--border);
}
.icon-size-row input[type='range']::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 16px;
  height: 16px;
  margin-top: -6px;
  border: 0;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.icon-size-row input[type='range']:focus-visible::-webkit-slider-thumb {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.icon-size-row input[type='range']::-moz-range-track {
  height: 4px;
  border-radius: 999px;
  background: var(--border);
}
.icon-size-row input[type='range']::-moz-range-thumb {
  width: 14px;
  height: 14px;
  border: 0;
  border-radius: 50%;
  background: var(--accent);
}
.icon-size-value {
  flex: 0 0 auto;
  min-width: 42px;
  text-align: right;
  font-size: 12px;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.opt-danger { display: flex; justify-content: flex-end; border-top: 1px solid var(--border); padding-top: 12px; }
.opt-danger .btn.confirm { border-color: var(--danger); color: var(--danger); }

/* ---------- Icon library picker (lot 7) ----------
   Search + local install screen for the icon library. All colors via theme
   tokens so the dark/light switch is honoured; SVGs are inlined and sized by
   these rules (currentColor follows the theme). */
.icons-body { display: flex; flex-direction: column; gap: 12px; }
.icons-count { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; padding-right: 6px; }
.icons-tabs { align-self: flex-start; }
.icons-pane { display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.icons-search-row { display: flex; align-items: center; gap: 12px; }
.icons-query {
  flex: 1;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--bg-elev);
  color: var(--text);
  font-size: 14px;
}
.icons-query:focus { outline: none; border-color: var(--accent); }
.icons-permissive {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 13px;
  color: var(--text-muted);
  cursor: pointer;
  white-space: nowrap;
}
.icons-permissive input { margin: 0; }
.icons-samples { display: flex; flex-wrap: wrap; gap: 6px; }
.icons-chip {
  padding: 3px 10px;
  font-size: 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-elev);
  color: var(--text-muted);
  cursor: pointer;
}
.icons-chip:hover { color: var(--text); border-color: var(--accent); }
.icons-status { margin: 0; font-size: 12px; }

.icons-split { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 12px; min-height: 0; }
@media (max-width: 640px) { .icons-split { grid-template-columns: 1fr; } }
.icons-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
  gap: 8px;
  align-content: start;
  max-height: 50vh;
  overflow-y: auto;
  padding: 2px;
}
.icons-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 8px 6px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-elev);
  color: var(--text);
  cursor: pointer;
  text-align: center;
  min-width: 0;
}
.icons-card:hover { border-color: var(--accent); }
.icons-card.selected { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.icons-card.non-permissive { opacity: 0.55; }
.icons-card-icon { width: 32px; height: 32px; color: var(--text); display: flex; align-items: center; justify-content: center; }
.icons-card-icon svg { width: 100%; height: 100%; display: block; }
.icons-fallback { font-size: 13px; font-weight: 600; color: var(--text-muted); }
.icons-card-meta { display: flex; flex-direction: column; gap: 3px; min-width: 0; width: 100%; }
.icons-card-name { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.icons-card-badges { display: flex; flex-wrap: wrap; gap: 3px; justify-content: center; }
.icons-card-badges .badge { font-size: 9px; padding: 1px 6px; }
.badge-installed { color: var(--success); border-color: color-mix(in srgb, var(--success) 45%, var(--border)); }
.badge-author { color: var(--text-muted); }

.icons-preview {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-elev-2);
  align-self: start;
  min-width: 0;
}
.icons-preview-empty { margin: 0; font-size: 12px; }
.icons-preview-icon {
  width: 72px;
  height: 72px;
  align-self: center;
  color: var(--accent);
  display: flex;
  align-items: center;
  justify-content: center;
}
.icons-preview-icon svg { width: 100%; height: 100%; display: block; }
.icons-preview-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.icons-preview-name { font-weight: 600; overflow-wrap: anywhere; }
.icons-preview-id { font-size: 11px; overflow-wrap: anywhere; }
.icons-preview-meta { margin: 4px 0 0; display: flex; flex-direction: column; gap: 3px; }
.icons-meta-row { display: flex; gap: 8px; font-size: 12px; }
.icons-meta-row dt { color: var(--text-muted); flex: 0 0 68px; margin: 0; }
.icons-meta-row dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.icons-preview-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.icons-preview-actions .btn { padding: 6px 12px; font-size: 13px; }
.icons-note { font-size: 11px; color: var(--danger); }

.icons-installed-list { display: flex; flex-direction: column; gap: 8px; max-height: 58vh; overflow-y: auto; }
.icons-empty { margin: 8px 0; font-size: 13px; }
.icons-installed-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-elev);
}
.icons-installed-icon {
  width: 36px;
  height: 36px;
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-elev-2);
}
.icons-installed-icon svg { width: 100%; height: 100%; display: block; }
.icons-installed-main { flex: 1; min-width: 0; }
.icons-installed-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.icons-installed-badges { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
.icons-installed-actions { display: flex; gap: 6px; flex: 0 0 auto; }
.icons-installed-actions .btn { padding: 5px 10px; font-size: 13px; }

/* Local-icon holder inside a tile / preview box: fills the host box and clips
   (exactly like the other icon nodes) so a large `local:` icon never spills.
   The SVG is INLINE (no URL) and its `width`/`height` attributes are stripped
   on import (see normalizeSvgNode in elements/button.js), so this holder + the
   `width/height:100%` below are the ONLY sizing rules — the glyph can never be
   cropped by a font-dependent `1em` viewport. */
.tile-icon-local {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  max-width: 100%;
  max-height: 100%;
}
.tile-icon-local svg { width: 100%; height: 100%; max-width: 100%; max-height: 100%; display: block; flex: 0 0 auto; }
.icon-browse-btn { margin-top: 8px; padding: 6px 12px; font-size: 13px; }

/* ---------- Docky target picker + config (lot 5) ----------
   Used by the element form (filterable agent/container combos with state/health
   badges) and the Docky config modal (URL + write-only key). Kept in the same
   visual language as the rest of the forms. */
.docky-target-section { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
.docky-target-title { font-weight: 600; margin-bottom: 4px; }
.docky-target-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.docky-target-status { font-size: 12px; margin-top: 6px; min-height: 16px; }
.docky-target-status.success { color: var(--success, #3fb950); }
.docky-target-status.error { color: var(--danger); }
.docky-target-status.warn { color: var(--warning, #e0a800); }

.docky-combo { position: relative; }
.docky-combo input { width: 100%; }
.docky-combo-list {
  position: absolute;
  z-index: 20;
  top: calc(100% + 2px);
  left: 0;
  right: 0;
  max-height: 220px;
  overflow: auto;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-elev-2);
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28);
}
.docky-combo-row {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 8px;
  cursor: pointer;
  font-size: 13px;
}
.docky-combo-row:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.docky-combo-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.docky-combo-sub { font-size: 11px; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.docky-combo-empty { padding: 8px; font-size: 12px; }
.badge-docky-status.ok { color: var(--success, #3fb950); border-color: color-mix(in srgb, var(--success, #3fb950) 45%, var(--border)); }
.badge-docky-status.bad { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
.badge-docky-state.ok { color: var(--success, #3fb950); border-color: color-mix(in srgb, var(--success, #3fb950) 45%, var(--border)); }
.badge-docky-state.bad { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
.badge-docky-health.ok { color: var(--success, #3fb950); }
.badge-docky-health.bad { color: var(--danger); }
.badge-docky-health.warn { color: var(--warning, #e0a800); }

.docky-config-form .field { margin-bottom: 10px; }
.docky-test-row { display: flex; align-items: center; gap: 10px; margin-top: 6px; }
.docky-config-status { font-size: 12px; }
.docky-config-status.success { color: var(--success, #3fb950); }
.docky-config-status.error { color: var(--danger); }
.docky-config-status.warn { color: var(--warning, #e0a800); }
