/* ------------------------------------------------------------------ *
 *  Systematic Utility — admin UI stylesheet (base rules + components).
 *
 *  Design tokens (colors/typography/radii/spacing) are NOT here: they live
 *  in static/ui/themes/<theme>.css, generated from themes/manifest_*.py by
 *  `codegen.py -f css` and linked directly from templates/base.html. Theme is
 *  driven by the `data-theme` attribute on <html>.
 * ------------------------------------------------------------------ */

/* ----------------------------- base ----------------------------- */
/* Tailwind's preflight (static/ui/tailwind.css) supplies border-box sizing, the
   zero body margin, line-height 1.5 and undecorated links; ui/css/tailwind.src.css
   hands back the browser defaults the screens still lean on. */
html, body { height: 100%; }

body {
  background: var(--background);
  color: var(--on-background);
  font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  font-size: 14px;
  -webkit-font-smoothing: antialiased;
}

code, .id { font-family: "JetBrains Mono", ui-monospace, monospace; }

a { color: var(--primary); }
a:hover { text-decoration: underline; }

h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 .25rem; }
h2 { font-size: 20px; font-weight: 600; letter-spacing: -0.01em; margin: 0 0 .25rem; }
h3 { font-size: 16px; font-weight: 600; margin: 0; }

.label {
  text-transform: uppercase;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.04em;
  color: var(--on-surface-variant);
}
.muted { color: var(--on-surface-variant); }

/* --------------------------- app shell -------------------------- */
.app { display: flex; min-height: 100vh; }

.sidebar {
  width: 240px;
  flex-shrink: 0;
  background: var(--surface-container-low);
  border-right: 1px solid var(--outline-variant);
  display: flex;
  flex-direction: column;
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;            /* brand fixed, nav scrolls, account pinned */
  transition: width .15s ease;
}

/* ---- Collapsed sidebar: an icon-only rail. Toggled by the topbar button
   (toggleSidebar in base.html) and remembered in localStorage. ---- */
body.sidebar-collapsed .sidebar { width: 64px; }
body.sidebar-collapsed .sidebar .brand { justify-content: center; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .sidebar .brand > span,
body.sidebar-collapsed .sidebar .brand .help-icon,
body.sidebar-collapsed .nav-section,
body.sidebar-collapsed .nav-link > span,
body.sidebar-collapsed .nav-group .twirl,
body.sidebar-collapsed .nav-group-row > .twirl,
body.sidebar-collapsed .nav-group-row .help-icon,
body.sidebar-collapsed .nav-children,
body.sidebar-collapsed .account-name { display: none !important; }
body.sidebar-collapsed .nav-link,
body.sidebar-collapsed .nav-group-row { justify-content: center; }
body.sidebar-collapsed .sidebar .menu li > .nav-link { padding-left: .5rem; padding-right: .5rem; justify-content: center; }
body.sidebar-collapsed .sidebar .menu .nav-group-row { justify-content: center; }
body.sidebar-collapsed .sidebar .menu .nav-group-row > .nav-group { padding: .55rem .5rem; justify-content: center; }
body.sidebar-collapsed .account-user { padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .account-user h2 { justify-content: center; }
body.sidebar-collapsed .sidebar-account { align-items: center; }
.sidebar .brand {
  display: flex; align-items: center; gap: .6rem;
  padding: 1rem 1.25rem;
  font-weight: 600;
  border-bottom: 1px solid var(--outline-variant);
  flex-shrink: 0;
}

/* Manifest help link — a muted question-mark icon shown next to an element
   (project brand, and later schema/table/column headers). Brightens on hover;
   pushed to the trailing edge when it sits in the brand row. */
.help-icon {
  display: inline-flex; align-items: center; justify-content: center;
  vertical-align: middle;      /* aligns the icon when it trails an <h1> text run */
  margin-left: .3rem;          /* small gap after the label it annotates */
  color: var(--on-surface-variant); opacity: .6;
  text-decoration: none; flex-shrink: 0;
}
.help-icon:hover { opacity: 1; color: var(--primary); }
.help-icon i { width: 16px; height: 16px; }
/* lucide icon inherited onto a form section header (column-group ui_icon) — sized
   down to sit inline with the small uppercase heading text. */
.section-icon { width: 14px; height: 14px; vertical-align: middle; }
/* table ``ui_icon`` before the grid's <h1> title — sized to the 24px heading. */
.grid-title-icon { width: 1.25rem; height: 1.25rem; vertical-align: middle; margin-right: .4rem; }
.sidebar .brand .help-icon { margin-left: auto; }
/* the site link (manifest home_page_url) right after the help link, not halfway to it */
.sidebar .brand .help-icon + .help-icon { margin-left: .25rem; }
/* the menu is the only scrolling region; brand + account stay put */
.sidebar nav { flex: 1 1 auto; overflow-y: auto; min-height: 0; }

/* the markup wraps avatar + name in an <h2>; reset its heading defaults and make
   it a left-aligned row so the avatar comes first with the username inline beside
   it, both on the same level (vertically centred) */
.account-user h2 {
  margin: 0; font: inherit;
  display: flex; align-items: center; gap: .6rem;
  min-width: 0;
}
.account-avatar-fallback svg { width: 18px; height: 18px; }
/* The sidebar's lists are DaisyUI `menu`s (docs/ui_stack_migration.md): item
   padding, radius, hover, the nested list's indent and guide line, `menu-title`
   section headings and the `menu-active` entry are DaisyUI's. nav-link /
   nav-group-row / nav-children stay as the keyboard layer's hooks. On top of
   DaisyUI: the active entry is primary (DaisyUI's is the neutral, near-black in
   the light theme), a link item never underlines, and an item keeps the theme's
   text colour against the unlayered `a { color }`. */
.menu { --menu-active-bg: var(--color-primary); --menu-active-fg: var(--color-primary-content); }
/* The sidebar keeps the shell's own metrics on DaisyUI's menu: the menu and every
   entry fit the sidebar's width (DaisyUI sizes an item to its max-content and keeps
   it on one line, so a long table name pushed the whole menu past the edge) and a
   long label wraps; entries keep the shell's 41px rows and .55rem/.75rem padding
   (DaisyUI's are tighter); the collapsed rail fits its 64px (below). */
.sidebar .menu { width: 100%; padding: .5rem; margin-block: 0; }
.sidebar .menu ul { margin-block: 0; }   /* the browser's list margin — no reset yet */
.sidebar .menu .nav-link { font-weight: 500; }
.sidebar .menu .nav-children .nav-link { font-weight: 400; }
.sidebar .menu li, .sidebar .menu ul { min-width: 0; }
.sidebar .menu li > .nav-link, .sidebar .menu .nav-group-row {
  display: flex; align-items: center; gap: .6rem; min-width: 0;
  padding: .55rem .75rem; white-space: normal;
}
/* A group header row is the menu item, but what opens the group is the button and
   the arrow inside it — so they, not the row, carry the padding: the button fills
   the row from its left edge (top to bottom, up to the help icon), the arrow (flush
   against the right edge, where it always sat) takes the rest. Padding on the row left dead bands around both that highlighted
   on hover but ignored a click. */
.sidebar .menu .nav-group-row { padding: 0; gap: 0; }
.sidebar .menu .nav-group-row > .nav-group { padding: .55rem 0 .55rem .75rem; align-self: stretch; }
.sidebar .menu .nav-group-row > .twirl { align-self: stretch; padding: 0 0 0 .5rem; }   /* flush right, as before */
.sidebar .menu .nav-link > span { min-width: 0; overflow-wrap: anywhere; }
.menu a.nav-link, .menu a.nav-link:hover { text-decoration: none; }
.menu a.nav-link { color: inherit; }
.menu a.nav-link.menu-active { color: var(--menu-active-fg); }

/* expandable nav groups */
[x-cloak] { display: none !important; }
/* Any nav entry that is a <button> rather than a link — the group headers, and
   the permission tools, which open a modal instead of navigating. A button is
   used so the keyboard can reach them at all (an <a> with no href is neither
   focusable nor Enter-activatable); this strips the button chrome back to a
   nav-link's look. */
button.nav-link {
  width: 100%; border: none; background: transparent; cursor: pointer;
  font: inherit; text-align: left; color: inherit;
}
/* The menu is walked with Alt+K and the arrows, so the focused entry has to be
   visible — the row cursor gets the same treatment on table rows. */
.nav-link:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
.nav-group .twirl { margin-left: auto; display: inline-flex; transition: transform .15s ease; }
.nav-group .twirl.open { transform: rotate(90deg); }
/* Group header row: the toggle button grows to fill, the help ? and the expand
   twirl trail it (twirl lives outside the button so the help link — an <a> —
   isn't nested inside a <button>, which is invalid). */
/* The row is the DaisyUI menu item (padded, hover-filled); the toggle inside it
   carries no box of its own. */
.nav-group-row { display: flex; align-items: center; }
.nav-group-row .nav-group { flex: 1 1 auto; width: auto; padding: 0; display: flex; align-items: center; gap: .6rem; }
.nav-group-row > .twirl {
  display: inline-flex; align-items: center; cursor: pointer;
  transition: transform .15s ease; color: var(--on-surface-variant);
}
.nav-group-row > .twirl.open { transform: rotate(90deg); }


.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1rem;
  padding: .75rem 1.5rem;
  border-bottom: 1px solid var(--outline-variant);
  background: var(--surface-container-lowest);
  position: sticky; top: 0; z-index: 20;
}
.content { padding: 1.5rem; flex: 1; }
/* CMS pages render their own full-bleed sections (backgrounds span the whole
   width; inner content is centered/padded by base.css's .cms-section__inner), so
   drop the admin content padding when a CMS page is shown inside the shell. */
.app:has(.cms-page) .content { padding: 0; }

/* ---------------------------- cards ----------------------------- */
/* A card is DaisyUI's `card card-border bg-base-100 shadow-sm` with its
   `card-body` (docs/ui_stack_migration.md). DaisyUI has no header or footer part,
   so the title bar / tab bar above a body and the button row below it stay ours.
   DaisyUI's card border is --color-base-200, nearly the page in the dark theme; a
   card here is framed in the tokens' outline colour in both. */
.card-border { border-color: var(--outline-variant); }
/* DaisyUI grows every paragraph in a card body, to push a card's actions down to its
   bottom. Ours sit in a .card-footer, and in a fixed-height dialog (a prompt or
   review modal, a maximised one) the grow hands the intro line half the spare
   height, opening a gap above the editor below it. */
.card-body p { flex-grow: 0; }
.card-header {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1rem;
  padding: 1rem 1.25rem;
  border-bottom: 1px solid var(--outline-variant);
}
/* ACE code editor (JSON / code fields). ACE auto-sizes via min/maxLines; the
   container just supplies the frame and rounded corners. */
.ace-editor {
  width: 100%;
  min-height: 9rem;
  border: 1px solid var(--outline-variant);
  border-radius: .5rem;
  font-size: 13px;
  line-height: 1.4;
}
.card-footer {
  display: flex; align-items: center; justify-content: flex-end;
  gap: .5rem;
  padding: 1rem 1.25rem;
  border-top: 1px solid var(--outline-variant);
}

/* --------------------------- buttons ---------------------------- */
/* Text buttons are DaisyUI's (docs/ui_stack_migration.md): `btn` neutral (a
   secondary action, Cancel, an inactive tab), `btn btn-primary` the main action
   (Save, Create, the active tab), `btn btn-error` a destructive one, `btn-sm`
   smaller. On top of DaisyUI only what the missing reset and our element rules
   would otherwise change: the page font (a <button> keeps the browser's own), and
   on a link button the button's colour rather than `a`'s, with no hover underline. */
.btn { font-family: inherit; }
/* …and the size text buttons always had (DaisyUI's are a fixed, taller --size box
   with 14px text): content height, .5rem/.9rem padding, 13px; `btn-sm` .3rem/.6rem,
   12px. Icon buttons have their own box (.btn.btn-icon below). */
.btn { height: auto; min-height: 0; padding: .5rem .9rem; font-size: 13px; }
.btn-sm { padding: .3rem .6rem; font-size: 12px; }
a.btn, a.btn:hover { color: var(--btn-fg); text-decoration: none; }
/* A neutral button (Cancel, Save & new, a toolbar's Actions…) keeps the look it had
   before the migration: transparent with a thin outline, a fill only on hover —
   DaisyUI's neutral is a solid grey that reads as disabled. :where() keeps this at a
   single class's weight, so the state rules (an active filter, a set bookmark) and
   the coloured variants still win. A disabled one fades, as before. */
.btn:where(:not(.btn-primary, .btn-error, .btn-ghost, .btn-link)) {
  background-color: transparent; border-color: var(--outline-variant);
  color: var(--on-surface); box-shadow: none;
}
.btn:where(:not(.btn-primary, .btn-error, .btn-ghost, .btn-link)):hover { background-color: var(--surface-container); }
.btn:where(:not(.btn-primary, .btn-error, .btn-ghost, .btn-link)):disabled { opacity: .5; cursor: not-allowed; }
/* Keyboard focus on any button: a primary-toned ring, as on the form controls,
   not DaisyUI's 2px outline in the text colour (a heavy black / white frame). */
.btn:focus-visible { outline: 2px solid color-mix(in srgb, var(--primary) 60%, transparent); outline-offset: 2px; }
/* Icon buttons are DaisyUI's `btn btn-ghost btn-square` plus our `btn-icon`,
   which the rules below and the Actions column's arithmetic key on. DaisyUI's
   square is a fixed --size box; ours has always been sized by its .4rem padding
   around a 24px icon (40.8 x 43.8px), and the Actions column and every list row's
   height are worked out from that — so the old box is restored by its own mechanics,
   and DaisyUI's ghost supplies the look: an inline box (DaisyUI's is an inline-flex
   square) whose icon sits inline on the text baseline, .4rem padding and the
   browser's 2px button border (transparent). Copying the resulting numbers instead
   broke wherever the icon is wrapped in a span (the dialog's maximise button). */
.btn.btn-icon {
  display: inline-block; width: auto; height: auto; font-size: 14px; line-height: normal;
  color: var(--on-surface-variant); border-width: 2px; padding: .4rem;
}
/* Hover: a tile of its own, a shade darker than a hovered row (DaisyUI's ghost
   fills with --color-base-200, the hovered row's own colour, so it vanished). */
.btn.btn-icon:hover { color: var(--on-surface); background-color: var(--surface-container); }
/* The one action in a row that is the way INTO the record rather than something
   done to it (an action's ``is_primary`` — templates/_action_button.html). A size
   up and a touch darker, so it reads as the door among a dozen tools: enough to
   find at a glance, not so much that the row stops being a row of icons.

   The ICON grows, not the button. Inside .row-actions every .btn-icon is pinned to
   a 2.425rem square and the column's width is arithmetic over six of them — the
   comment there says what happens if that is off by a pixel. 28px still sits
   comfortably inside the 38.8px box the 24px default leaves ~7px of margin in, so
   the grid is untouched and only the glyph changes. */
.btn-icon-lg { color: var(--on-surface); }
.btn-icon-lg i, .btn-icon-lg svg { width: 28px; height: 28px; }
/* an action that doesn't apply to this row (see _action_off.html): still drawn,
   so same-type icons stay aligned across rows and the user can see the action
   exists, but greyed out and inert. */
.btn-icon:disabled { opacity: .35; cursor: not-allowed; color: var(--on-surface-variant); }
.btn-icon:disabled:hover { background: transparent; color: var(--on-surface-variant); }
/* A count carried on the icon itself (see templates/_action_button.html, an
   action's `badge` key): how many records the shortcut leads to. Absolutely
   positioned INSIDE the button rather than around it, because the Actions column
   is a grid whose tracks are sized from the buttons themselves — a wrapper span
   would become the grid child and the arithmetic in .row-actions above would be
   off by a border. It sits just inside the corner rather than overhanging it, so
   the 1px gap between buttons stays a gap even on a badged one. */
.btn-icon.has-badge { position: relative; overflow: visible; }
.action-badge {
  position: absolute; bottom: 1px; right: 1px;
  min-width: 14px; height: 14px; padding: 0 3px;
  box-sizing: border-box;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 7px;
  background: var(--primary); color: var(--on-primary);
  font-size: 9px; font-weight: 700; line-height: 1;
  /* the ring keeps the pill readable where it overlaps the icon's strokes, and
     separates it from a hovered button's filled background */
  box-shadow: 0 0 0 1px var(--surface-container-lowest);
  pointer-events: none;
}
/* Tone, named with the status tone words (err / ok / warn / info, as in
   templates/filters.py:BADGE_TONES) and set per row by the action's
   ``badge_tone`` column — so a badge can say what KIND of count it is carrying and
   not just how many. The default above is --primary, which reads as the neutral
   "here is how many"; err is the one that wants attention.

   Only these two: --error is the sole tone beyond --primary with a matching
   --on-error, and a pill this small has to be a solid fill with a contrasting
   label rather than the status badges' soft wash, so a tone without its on-colour would be
   guesswork about legibility. */
.action-badge.err { background: var(--error); color: var(--on-error); }

/* ---------------------------- forms ----------------------------- */
/* a disabled select reads as disabled (the browser's own dimming, said explicitly) */
select:disabled { opacity: .7; }
.field { display: flex; flex-direction: column; gap: .3rem; margin-bottom: 1rem; }
/* A document field (textarea / markdown / JSON / plain array) spans every column of
   a fixed `columns` grid rather than sitting in one of them. Harmless outside a
   grid, where the field is full width anyway. See registry.is_solo_field. */
.field-solo { grid-column: 1 / -1; }
.field > label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--on-surface-variant); }
/* Form controls are DaisyUI's (docs/ui_stack_migration.md): `input` (also on an
   input-looking <button> or wrapper <div>), `select` on a <select>, `textarea` on a
   <textarea>, `checkbox`, `radio`, `toggle` (+ `toggle-sm`), `input-error` for a
   rejected value — focus ring, disabled look and sizes included. On top of DaisyUI:
   - full width: DaisyUI caps a control at 20rem; a form field here spans its column;
   - the page font: without the reset a control keeps the browser's own;
   - a textarea is monospace — it holds JSON, markdown, expressions more often than prose;
   - option colours paired explicitly, so a dark theme's dropdown is never same-on-same. */
.input, .select, .textarea { width: 100%; font-family: inherit; }
.textarea { font-family: var(--font-mono); }
.select option { background: var(--surface-container-lowest); color: var(--on-surface); }
/* Focus: the field's own border turns primary with a soft glow around it, in place
   of DaisyUI's 2px outline in the text colour (a heavy black/white frame). Still
   unmistakable for a keyboard user — the glow is 3px wide and the border changes
   colour. A field holding a rejected value focuses in the error colour. */
.input:focus, .input:focus-within, .select:focus, .select:focus-within,
.textarea:focus, .textarea:focus-within {
  outline: none;
  border-color: var(--focus-color, var(--primary));
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus-color, var(--primary)) 22%, transparent);
}
.input-error, .field.has-error .input, .field.has-error .select, .field.has-error .textarea {
  --focus-color: var(--error);
}
.checkbox:focus-visible, .radio:focus-visible, .toggle:focus-visible {
  outline: 2px solid color-mix(in srgb, var(--primary) 60%, transparent); outline-offset: 2px;
}

/* Clearable search box (templates/_search_form.html, _manifest_grid.html): an
   in-field "x" button that appears only while there's a query (the .has-text
   class is toggled by the input, or rendered server-side) and wipes the search. */
.search-field { position: relative; display: inline-block; }
.search-field .input { padding-right: 1.85rem; }
/* suppress the browser's native search clear so we don't render two x's */
.search-field .input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
.search-field .search-clear {
  position: absolute; top: 50%; right: .4rem; transform: translateY(-50%);
  display: block; align-items: center; justify-content: center;
  padding: 2px; border: 0; background: transparent; cursor: pointer;
  color: var(--on-surface-variant); line-height: 0;
}
.search-field.has-text .search-clear { display: inline-flex; }
.search-field .search-clear:hover { color: var(--on-surface); }
.search-field .search-clear svg, .search-field .search-clear i { width: 15px; height: 15px; }
/* Show/hide password (initPasswordReveal in templates/base.html): an in-field
   eye button; .shown swaps eye for eye-off while the value is visible. */
.password-field .input { padding-right: 2.1rem; }
/* Edge/IE draw their own reveal eye — suppress it so there are never two */
.password-field .input::-ms-reveal { display: none; }
.password-toggle {
  position: absolute; top: 50%; right: .45rem; transform: translateY(-50%);
  display: inline-flex; align-items: center; justify-content: center;
  padding: 2px; border: 0; background: transparent; cursor: pointer;
  color: var(--on-surface-variant); line-height: 0;
}
.password-toggle:hover { color: var(--on-surface); }
.password-toggle svg { width: 16px; height: 16px; }
.password-toggle .pw-eye-off, .password-field.shown .password-toggle .pw-eye { display: none; }
.password-field.shown .password-toggle .pw-eye-off { display: inline; }
.form-row { display: flex; gap: 1rem; }
.form-row > * { flex: 1; }

/* on/off switch (permission-overview matrix toggles) */
.switch { position: relative; display: inline-block; width: 38px; height: 22px; flex: 0 0 auto;
  border: none; border-radius: 999px; background: var(--outline-variant); cursor: pointer; padding: 0;
  transition: background .15s ease; vertical-align: middle; }
.switch .switch-knob { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px;
  border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: left .15s ease; }
.switch.on { background: var(--primary); }
.switch.on .switch-knob { left: 18px; }
.switch:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* labelled switch row (e.g. the admin Settings modal) */
.switch-row:last-child { border-bottom: none; }
.switch-row svg { width: 16px; height: 16px; color: var(--on-surface-variant); }

/* ------------- form widget variants (templates/_field.html, f.widget) ------------- */
/* switch: a real checkbox drawn as DaisyUI's toggle (`toggle toggle-primary`, the
   list cell's `toggle-sm`), so it posts like any checkbox */
.toggle.htmx-request { opacity: .6; cursor: progress; }
/* radio_group: segmented control; wraps on a narrow screen rather than overflowing */
.segmented { display: inline-flex; flex-wrap: wrap; align-self: flex-start; max-width: 100%;
  border: 1px solid var(--outline-variant); border-radius: .25rem; overflow: hidden;
  background: var(--surface-container-lowest); }
.segmented-opt + .segmented-opt { border-left: 1px solid var(--outline-variant); }
.segmented-opt input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.segmented-opt span { padding: .45rem .8rem; color: var(--on-surface-variant); }
.segmented-opt:hover span { background: var(--surface-container); }
.segmented-opt input:checked + span { background: var(--primary); color: var(--on-primary); }
.segmented-opt input:focus-visible + span { outline: 2px solid var(--primary); outline-offset: -3px; }
/* checkbox_multi / list_box */
.checklist { display: flex; flex-wrap: wrap; gap: .35rem 1.1rem; padding: .2rem 0; }
/* slider: range + live value; an unset (NULL) value shows a dimmed thumb */
.slider { flex: 1 1 auto; min-width: 0; accent-color: var(--primary); }
.slider.unset { opacity: .45; }
.slider-clear svg { width: 14px; height: 14px; }
/* number with a unit / currency adornment inside the box */
.input-affix .affix, .duration-field .affix { display: inline-flex; align-items: center; padding: 0 .55rem;
  font-size: 13px; color: var(--on-surface-variant); background: var(--surface-container);
  border: 1px solid var(--outline-variant); }
.input-affix .affix:first-child { border-right: 0; border-radius: .25rem 0 0 .25rem; }
.input-affix .affix:last-child { border-left: 0; border-radius: 0 .25rem .25rem 0; }
.input-affix .affix:first-child + .input { border-top-left-radius: 0; border-bottom-left-radius: 0; }
.input-affix .input:not(:last-child) { border-top-right-radius: 0; border-bottom-right-radius: 0; }
.input-affix .input { min-width: 0; }
/* duration: [h] h [mm] min */
.duration-field .input { width: 5.5rem; flex: 0 1 auto; border-radius: .25rem 0 0 .25rem; text-align: right; }
.duration-field .affix { border-left: 0; border-radius: 0 .25rem .25rem 0; margin-right: .5rem; }
/* picker_color: the native well beside the text value */
.color-well { flex: 0 0 auto; width: 38px; height: 38px; padding: 2px; cursor: pointer;
  border: 1px solid var(--outline-variant); border-radius: .25rem; background: var(--surface-container-lowest); }

/* key → value editor (json_key_value, templates/_kv_editor.html) */
.kv-row .kv-bool { display: flex; align-items: center; min-height: 38px; }
.kv-row .muted.kv-value { font-family: var(--font-mono); font-size: 13px; }
.kv-foot { display: flex; gap: .4rem; flex-wrap: wrap; margin-top: .2rem; }
@media (max-width: 640px) {
  .kv-row { grid-template-columns: minmax(0,1fr) 6.5rem auto; }
  .kv-row .kv-value { grid-column: 1 / 3; }
}
/* list editor (json_list, templates/_list_editor.html) */
.list-row .input { flex: 1 1 auto; min-width: 0; }
.list-row .row-drag { cursor: grab; color: var(--on-surface-variant); display: inline-flex; }
.list-row .row-drag svg { width: 16px; height: 16px; }
.list-row.drag-over { box-shadow: 0 -2px 0 var(--primary); }
/* rich text (multiline_html, templates/_field.html): Quill's snow theme in the
   app's tokens, light and dark; the editor grows with its text, then scrolls */
.ql-toolbar.ql-snow { border: 1px solid var(--outline-variant); border-radius: .25rem .25rem 0 0;
  background: var(--surface-container-low); font-family: inherit; }
.ql-container.ql-snow { border: 1px solid var(--outline-variant); border-top: 0; border-radius: 0 0 .25rem .25rem;
  background: var(--surface-container-lowest); color: var(--on-surface); font-family: inherit; font-size: 14px; }
.ql-container.ql-snow:focus-within { border-color: var(--primary); }
/* Quill builds .ql-editor itself, so this stays a rule: it grows with its text, then scrolls */
.ql-editor { min-height: 8rem; max-height: 30rem; overflow-y: auto; }
.ql-editor.ql-blank::before { color: var(--on-surface-variant); opacity: .7; font-style: normal; }
.ql-snow .ql-stroke { stroke: var(--on-surface-variant); }
.ql-snow .ql-fill { fill: var(--on-surface-variant); }
.ql-snow .ql-picker { color: var(--on-surface-variant); }
.ql-snow.ql-toolbar button:hover .ql-stroke, .ql-snow.ql-toolbar button.ql-active .ql-stroke { stroke: var(--primary); }
.ql-snow.ql-toolbar button:hover .ql-fill, .ql-snow.ql-toolbar button.ql-active .ql-fill { fill: var(--primary); }
.ql-snow.ql-toolbar button.ql-active, .ql-snow.ql-toolbar button:hover { color: var(--primary); }
.ql-snow .ql-tooltip { background: var(--surface-container-lowest); color: var(--on-surface);
  border: 1px solid var(--outline-variant); box-shadow: var(--shadow); }
.ql-snow .ql-tooltip input[type=text] { background: var(--surface-container-lowest); color: var(--on-surface);
  border: 1px solid var(--outline-variant); }
.ql-snow .ql-editor pre.ql-syntax, .ql-snow .ql-editor .ql-code-block-container { background: var(--surface-container);
  color: var(--on-surface); }
.rich-view { padding: .5rem .65rem; border: 1px solid color-mix(in srgb, var(--outline-variant) 55%, transparent);
  border-radius: .25rem; background: var(--surface-container); font-size: 14px; max-height: 30rem; overflow: auto; }
.rich-view > :first-child { margin-top: 0; }
.rich-view > :last-child { margin-bottom: 0; }

/* phone with country (textbox_phone, templates/_phone_input.html) */
.phone-input > .input[type=tel] { flex: 1 1 auto; min-width: 0; }
.phone-country:not(.ts-wrapper) { flex: 0 0 auto; width: auto; display: inline-flex; align-items: center; gap: .3rem; cursor: pointer;
  font-variant-numeric: tabular-nums; }
.phone-country svg { width: 14px; height: 14px; }
.phone-panel { min-width: min(20rem, 90vw); }

/* barcode / QR (textbox_barcode): codes on a white plate in either theme, so
   they keep the contrast a scanner needs; the camera overlay */
.barcode-input .input { flex: 1 1 auto; min-width: 0; font-family: var(--font-mono); }
.barcode-field .code-plate { margin-top: .4rem; }
.code-plate { display: inline-flex; flex-direction: column; align-items: center; gap: .3rem; background: #fff;
  color: #111827; border: 1px solid var(--outline-variant); border-radius: .25rem; padding: .3rem; line-height: 0;
  vertical-align: middle; }
button.code-plate { cursor: zoom-in; font: inherit; }
.code-plate-large { padding: 1rem; line-height: 1.2; }
.code-plate-large code { color: #111827; font-size: 13px; }
.code-text { font-family: var(--font-mono); font-size: 12px; }
.scan-box { display: flex; flex-direction: column; align-items: center; gap: .6rem; background: var(--surface-container-lowest);
  color: var(--on-surface); border-radius: .5rem; padding: 1rem; width: min(480px, 100%); box-shadow: var(--shadow); }
.scan-box video { width: 100%; max-height: 60vh; border-radius: .25rem; background: #000; object-fit: cover; }
.scan-title { margin: 0; font-size: 14px; text-align: center; }

/* signature pad (picker_signature, templates/_signature_pad.html): ink on white
   paper in either theme; no page scroll while signing */
.signature-paper canvas { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; cursor: crosshair; }
.signature-thumb { display: inline-flex; background: #fff; border: 1px solid var(--outline-variant);
  border-radius: .25rem; padding: .2rem .4rem; }
.signature-thumb img { max-height: 70px; max-width: 260px; }
table.data .signature-thumb img { max-height: 28px; max-width: 110px; }
@media (max-width: 640px) { .signature-paper { height: 140px; } }

/* map picker (picker_map, templates/_field.html) */
.map-coord .input { width: 10rem; }
@media (max-width: 640px) { .map-box { height: 200px; } .map-coord .input { width: 8.5rem; } }
.map-cell svg { width: 13px; height: 13px; vertical-align: -2px; margin-right: .25rem; }
.leaflet-container { font: inherit; }

/* repeater (json_repeater, templates/_repeater_editor.html): scrolls sideways
   inside the field on a narrow screen, never the page */
.repeater-table th { text-align: left; font-size: 11px; font-weight: 600; color: var(--on-surface-variant);
  padding: .2rem .35rem; white-space: nowrap; }
.repeater-table td { padding: .2rem .3rem; vertical-align: middle; }
.repeater-table td .input, .repeater-table td .select { min-width: 7rem; padding-block: .3rem; }
.repeater-table .repeater-handle { width: 1%; }
.repeater-table .repeater-handle .row-drag { cursor: grab; color: var(--on-surface-variant); display: inline-flex; }
.repeater-table .repeater-handle svg { width: 16px; height: 16px; }
.repeater-table .repeater-end { width: 1%; white-space: nowrap; text-align: right; }
.repeater-table tr.drag-over > td { box-shadow: inset 0 2px 0 var(--primary); }

/* rating (form field and cell) */
button.rating-star { cursor: pointer; }
.rating-star svg { width: 20px; height: 20px; }
.rating-cell .rating-star svg { width: 15px; height: 15px; }
.rating-star.on { color: var(--warning); }
.rating-star.on svg { fill: currentColor; }
button.rating-star:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; border-radius: 3px; }
.rating-clear svg { width: 14px; height: 14px; }
/* multi-column lookup panel (autocomplete_grid) */
.lookup-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.lookup-table th { text-align: left; font-size: 11px; font-weight: 600; color: var(--on-surface-variant);
  padding: .3rem .5rem; border-bottom: 1px solid var(--outline-variant); position: sticky; top: 0;
  background: var(--surface-container-lowest); }
.lookup-table td { padding: .35rem .5rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 16rem; }
.lookup-table tr.flag-opt-row { display: table-row; cursor: pointer; }
/* date-range filter (templates/_filter_form.html) */
.date-range-box .input { padding-right: 1.9rem; cursor: pointer; }
.date-range-box .icon-select-clear { position: absolute; right: .5rem; }
.date-range-box .icon-select-clear svg { width: 15px; height: 15px; }
.date-range-presets .badge { cursor: pointer; font: inherit; font-size: 11px; }
.date-range-presets .badge:hover { background: var(--surface-container-highest); color: var(--on-surface); }

/* ------------- list cell variants (templates/_cell.html, col.widget) ------------- */
.switch.switch-static { cursor: default; transform: scale(.8); transform-origin: left center; }
.color-swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; vertical-align: -2px;
  margin-right: .35rem; border: 1px solid color-mix(in srgb, var(--on-surface) 25%, transparent); }
.progress-track { flex: 1 1 auto; height: 6px; border-radius: 999px; background: var(--surface-container-high); overflow: hidden; }
.progress-cell .progress-num { font-size: 12px; font-variant-numeric: tabular-nums; color: var(--on-surface-variant); min-width: 2.8rem; text-align: right; }
.cell-link { color: var(--primary); text-decoration: none; }
.cell-link:hover { text-decoration: underline; }
.cell-link svg { width: 12px; height: 12px; vertical-align: -1px; margin-left: .2rem; }

/* permission-overview matrix: sticky header row + sticky first column */
.perm-matrix th, .perm-matrix td { white-space: nowrap; }
.perm-matrix thead th { position: sticky; top: 0; z-index: 2; background: var(--surface-container-low, var(--surface-container-lowest)); }
.perm-matrix .sticky-col { position: sticky; left: 0; z-index: 1; background: var(--surface-container-lowest); text-align: left; }
.perm-matrix thead th.sticky-col { z-index: 3; }

/* Tabulator (editable grid modal) — blend the light-only base theme with the
   app's surface/on-surface variables so it works in dark mode too. */
.tabulator { background: var(--surface-container-lowest); border-color: var(--outline-variant); color: var(--on-surface); font-family: inherit; font-size: 13px; }
.tabulator .tabulator-header, .tabulator .tabulator-header .tabulator-col { background: var(--surface-container-low, var(--surface-container-lowest)); color: var(--on-surface); border-color: var(--outline-variant); }
.tabulator .tabulator-row { background: var(--surface-container-lowest); color: var(--on-surface); }
.tabulator .tabulator-row.tabulator-row-even { background: var(--surface-container, var(--surface-container-lowest)); }
.tabulator .tabulator-row .tabulator-cell { border-color: var(--outline-variant); }
/* taller rows (~50% more than Tabulator's default) — more vertical padding so
   the row grows with content rather than being clipped to a fixed height. */
.tabulator .tabulator-row .tabulator-cell { padding-top: 11px; padding-bottom: 11px; }
.tabulator .tabulator-row:hover { background: var(--surface-container-high, var(--surface-container)); }
.tabulator .tabulator-footer { background: var(--surface-container-low, var(--surface-container-lowest)); color: var(--on-surface); border-color: var(--outline-variant); }
.tabulator .tabulator-cell.tabulator-editing input, .tabulator .tabulator-cell.tabulator-editing select { background: var(--surface-container-lowest); color: var(--on-surface); border-color: var(--primary); }
.tabulator-edit-list { background: var(--surface-container-lowest); color: var(--on-surface); border: 1px solid var(--outline-variant); }
.tabulator-edit-list .tabulator-edit-list-item { color: var(--on-surface); }
.tabulator-edit-list .tabulator-edit-list-item.active, .tabulator-edit-list .tabulator-edit-list-item:hover { background: var(--primary); color: var(--on-primary); }

/* --------------------------- tables ----------------------------- */
/* Every grid's header stays put while its rows scroll. The `position: sticky` that
   does it is on `table.data thead th` below and has always been there; what decides
   whether it does anything is the SCROLLPORT, because sticky resolves against the
   nearest scroll-container ancestor and nothing further out.

   `.table-wrap` is that ancestor already — `overflow-x: auto` computes `overflow-y`
   to `auto` too, so the box is a scroll container on both axes. But with an auto
   height it never overflows vertically, so `top: 0` pins the header to a box that
   does not move: the real scrolling happens on the page, on `.modal-flex .card-body`
   or on `.pt-detail`, and the whole wrap — header included — simply rides it out of
   view. Giving the wrap a BOUND is therefore the whole of "make the header sticky".

   A bound comes in two shapes. A wrap inside a pane that already owns a height
   FILLS it (`.grid-fill` below, and the frozen list layout further down); everywhere
   else it CAPS here, which covers a grid on a page that scrolls, and any grid written
   later or built in JS, without either having to know about this. A table shorter
   than the cap grows no scrollbar and is untouched.

   `--table-wrap-max-h: none` is the opt-out, for a wrap that flexes to its container
   instead. The other opt-out is the table's own: a `thead th { position: static }`
   for a table with nothing scrolling under it (see .llm-summary-wrap). */
.table-wrap { overflow: auto; max-height: var(--table-wrap-max-h, 60vh); }

/* A box that already owns a height, handing it to the grid inside: the wrap fills
   it rather than capping, so the header pins to the top of the PANE and the rows use
   whatever room the pane has. Mark the box, not the wrap — the box is the thing that
   knows it has a height. */
.grid-fill { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.grid-fill > .table-wrap { --table-wrap-max-h: none; flex: 1 1 auto; min-height: 0; }
/* A pane whose grid is one htmx fragment inside it nests a second .grid-fill (the
   child-record tab does: its toolbar stays out of the swap so the search box keeps
   focus, and the swapped part carries the table). The inner one has to TAKE the
   height the outer one was handed, or it would size to its rows and the table
   would stop filling the pane. */
.grid-fill > .grid-fill { flex: 1 1 auto; }

/* A modal body is such a box (`.modal-flex .card-body` is already flex-bounded and
   scrolling), so it hands its height down to a grid that is its whole subject —
   either the wrap directly, or one `.grid-fill` wrapper deep. Without the `:has()`
   the body would keep scrolling and the wrap would be back to an auto height. */
.modal-flex > .card-body:has(> .table-wrap),
.modal-flex > .card-body:has(> .grid-fill) { display: flex; flex-direction: column; overflow: hidden; }
.modal-flex > .card-body > .table-wrap { --table-wrap-max-h: none; flex: 1 1 auto; min-height: 0; }
.modal-flex > .card-body > .grid-fill { flex: 1 1 auto; }
/* A list / grid table is DaisyUI's `table table-sm` (cell padding, borders,
   header type — docs/ui_stack_migration.md) plus our `data`, which every
   structural rule below keys on: the sticky header, the frozen control columns,
   banding, group rows, totals. What stays here is what those need — an opaque
   header and opaque rows (a frozen cell inherits its row's fill, and sticky cells
   must hide what scrolls under them), and top-aligned cells for multi-line values. */
table.data { --bulk-col-w: 2rem; }
table.data thead th {
  position: sticky; top: 0; z-index: 2;
  background: var(--color-base-200);
  border-bottom: 1px solid var(--outline-variant);
}
/* A header whose manifest column carries a description shows it as a native
   tooltip on hover (_column_head.html) — the help cursor is the affordance. */
table.data thead th.has-desc { cursor: help; }
/* A header the list can sort by (_column_head.html, listSort): the whole cell is
   the click target, the trailing icon says the state — a faint up-down while the
   column does not order the list, a primary-coloured arrow while it does. */
table.data thead th.sortable { cursor: pointer; user-select: none; }
table.data thead th.sortable:hover { color: var(--on-surface); }
table.data thead th.sorted { color: var(--on-surface); }
table.data thead th .col-sort {
  display: inline-flex; align-items: center; vertical-align: middle;
  margin-left: .25rem; padding: 0; border: 0; background: none;
  color: inherit; cursor: pointer; opacity: .35;
}
table.data thead th.sortable:hover .col-sort { opacity: .7; }
table.data thead th.sorted .col-sort { opacity: 1; color: var(--primary); }
table.data thead th .col-sort svg { width: 13px; height: 13px; }
/* Row lines in the tokens' outline colour, as before the migration — DaisyUI's
   (the text colour at 5%) were too faint to follow a long row across. */
table.data tbody td { vertical-align: top; border-bottom: 1px solid var(--outline-variant); }
/* Every row carries an opaque background of its own, so the frozen columns
   below can inherit it (see "frozen columns"). It is the surface the row was
   already sitting on — a card, a modal — so nothing looks different. */
table.data tbody tr { background: var(--color-base-100); }
table.data tbody tr:hover { background: var(--surface-container-low); }
table.data .id { font-size: 12px; color: var(--on-surface-variant); }
/* bookmark toggle on list rows: outline when off, filled amber when on */
.bookmark-toggle.on { color: var(--warning); }
.bookmark-toggle.on svg { fill: currentColor; }
/* The list toolbar's "bookmarked only" filter wears the same state, so a filled
   bookmark means the same thing wherever it appears — on a row, "this one is
   bookmarked"; on the toolbar, "showing only those". */
.btn.bookmark-filter.on { color: var(--warning); border-color: var(--warning); }
.btn.bookmark-filter.on svg { fill: currentColor; }
/* "My records" toggles (_mine_filter.html): owned / created / updated by me.
   Their icons are outline-only (no fill trick like the bookmark's), so a tint alone
   reads as off — an active toggle is filled solid instead, as a picked icon cell (.icon-opt.selected) is. */
.btn.mine-filter.on { background: var(--primary); color: var(--on-primary); border-color: var(--primary); }
.btn.mine-filter.on:hover { background: var(--primary); filter: brightness(1.1); }
/* A record this user has already written a comment on — same outline-vs-filled
   trick as the bookmark above, in the neutral primary rather than the bookmark's
   warning, because the two sit side by side in an edit modal's header and must
   not read as one control. */
.row-comment-toggle.on { color: var(--primary); }
.row-comment-toggle.on svg { fill: currentColor; }
/* The vote button of a votable table (templates/_row_vote.html): a .btn-icon like
   every action, so it keeps the Actions grid's square — a smaller star with the
   record's average (everyone's votes) in tiny type under it. Filled star: you
   have voted. Gold, as stars are; the outline-vs-filled trick of the bookmark. */
.btn-icon.row-vote { display: inline-flex; flex-direction: column; align-items: center;
                     justify-content: center; gap: 1px; line-height: 1; }
.btn-icon.row-vote svg { width: 16px; height: 16px; }
.btn-icon.row-vote.on { color: var(--warning); }
.btn-icon.row-vote.on svg { fill: currentColor; }
.row-vote-average:empty { display: none; }
/* The rating dialog (_row_vote_dialog.html): five large stars filled up to your
   vote; hovering previews the vote under the pointer. */
.row-vote-star svg { width: 30px; height: 30px; }
.row-vote-star.on { color: var(--warning); }
.row-vote-star.on svg { fill: currentColor; }
.row-vote-stars:hover .row-vote-star svg { fill: none; color: var(--on-surface-variant); }
.row-vote-stars:hover .row-vote-star:has(~ .row-vote-star:hover) svg,
.row-vote-stars .row-vote-star:hover svg { fill: currentColor; color: var(--warning); }
.row-vote-summary { margin: 0; font-size: 13px; }
/* Tags of a taggable table. The Tag button (templates/_row_tag.html) is filled
   while the record carries a tag — the bookmark's outline-vs-filled trick. */
.btn-icon.row-tag.on { color: var(--primary); }
.btn-icon.row-tag.on svg { fill: color-mix(in srgb, currentColor 25%, transparent); }
/* A tag as a pill: under a list row (_row_tags.html, a button that narrows the
   list to it), in the tag dialog and the filter modal's Tags field (with × to
   take it off). */
.tag-pill {
  display: inline-flex; align-items: center; gap: .25rem; max-width: 100%;
  padding: .05rem .5rem; border-radius: 999px; font-size: 12px; line-height: 1.5;
  color: var(--primary);
  background: color-mix(in srgb, var(--primary) 10%, transparent);
  border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tag-pill svg { width: 12px; height: 12px; flex: none; }
button.tag-pill { cursor: pointer; font: inherit; font-size: 12px; }
button.tag-pill:hover, button.tag-pill:focus-visible {
  background: color-mix(in srgb, var(--primary) 20%, transparent);
}
.tag-pill.mine { border-style: solid; font-weight: 600; }
.tag-pill-x:hover { opacity: 1; }
/* The pill footer is a row of its own directly under its data row, read as one
   with it: the data row loses its bottom rule, the footer keeps it, and hovering
   either lights both. No tabindex, so the keyboard's row cursor skips it. */
table.data tbody tr:has(+ tr.row-tags) > td { border-bottom-color: transparent; }
/* sparklines (templates/_sparkline.html) */
td.spark-cell { color: var(--primary); line-height: 0; }
svg.sparkline { overflow: visible; vertical-align: middle; }
svg.sparkline polyline { fill: none; stroke: currentColor; stroke-width: 1.5;
  stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
svg.sparkline circle { fill: currentColor; }

/* number tint: a grid column's threshold bands (tint_rules, templates/_cell.html) */
table.data td.cell-tint { font-variant-numeric: tabular-nums; font-weight: 600; }
table.data td.cell-tint-ok { background: color-mix(in srgb, var(--success) 14%, transparent); color: var(--success); }
table.data td.cell-tint-warn { background: color-mix(in srgb, var(--warning) 16%, transparent); color: var(--warning); }
table.data td.cell-tint-err { background: color-mix(in srgb, var(--error) 14%, transparent); color: var(--error); }
table.data td.cell-tint-info { background: color-mix(in srgb, var(--primary) 12%, transparent); color: var(--primary); }

/* avatars: a user before their name (templates/_avatar.html) — a picture, or
   initials on one of eight tones keyed by the user's id */
.avatar { flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; object-fit: cover;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 9px; font-weight: 700; letter-spacing: .02em; line-height: 1; }
img.avatar { background: var(--surface-container-high); }
.avatar-tone-0 { background: color-mix(in srgb, var(--primary) 22%, transparent); color: var(--primary); }
.avatar-tone-1 { background: color-mix(in srgb, var(--success) 22%, transparent); color: var(--success); }
.avatar-tone-2 { background: color-mix(in srgb, var(--warning) 24%, transparent); color: var(--warning); }
.avatar-tone-3 { background: color-mix(in srgb, var(--error) 20%, transparent); color: var(--error); }
.avatar-tone-4 { background: color-mix(in srgb, var(--violet) 24%, transparent); color: var(--violet); }
.avatar-tone-5 { background: color-mix(in srgb, var(--tertiary) 22%, transparent); color: var(--tertiary); }
.avatar-tone-6 { background: color-mix(in srgb, var(--secondary) 24%, transparent); color: var(--secondary); }
.avatar-tone-7 { background: var(--surface-container-highest); color: var(--on-surface-variant); }

/* group-by rows (templates/_group_row.html) and the aggregate footer */
table.data tbody tr.row-group > td { background: var(--surface-container); font-weight: 600;
  font-variant-numeric: tabular-nums; border-bottom: 1px solid var(--outline-variant); }
table.data tbody tr.row-group > td.group-label { white-space: nowrap; }
table.data tbody tr.row-group .group-toggle svg { transition: transform .15s ease; }
table.data tbody tr.row-group.folded .group-toggle svg { transform: rotate(-90deg); }
table.data tbody tr.row-group .badge { margin-left: .35rem; font-weight: 600; }
table.data tbody tr.row-group .group-agg { font-weight: 600; }
table.data tfoot.agg-totals td { font-weight: 600; font-variant-numeric: tabular-nums;
  border-top: 2px solid var(--outline-variant); }
.group-by-select svg { width: 16px; height: 16px; }
.group-by-select .select { width: auto; padding-block: .3rem; font-size: 13px; }

/* master-detail: a row's open detail (templates/_row_detail.html) — an inset strip
   under it holding the child panes. Nested grids pin nothing: their sticky header
   and frozen columns would stick to the OUTER list's scroll box instead. */
table.data tbody tr.row-detail > td { background: var(--surface-container-low); }
table.data tbody tr.row-detail > td.row-detail-cell { padding: .5rem .75rem .9rem; border-left: 3px solid var(--primary); }
table.data tbody tr.row-detail:hover { background: transparent; }
table.data tbody tr:has(+ tr.row-detail:not([hidden])) > td { border-bottom-color: transparent; }
.row-detail-cell table.data thead th,
.row-detail-cell table.data .bulk-col, .row-detail-cell table.data .row-drag-col,
.row-detail-cell table.data .actions-col { position: static; }
.btn-icon.detail-toggle svg { transition: transform .15s ease; }
.btn-icon.detail-toggle.open svg { transform: rotate(90deg); }
table.data tbody tr.row-tags > td { padding: 0 .75rem .4rem; }
table.data tbody tr.row-tags > td .tag-pill { margin: 0 .3rem .1rem 0; }
table.data tbody tr:hover + tr.row-tags,
table.data tbody tr:has(+ tr.row-tags:hover) { background: var(--surface-container-low); }
/* The tag input (_row_tag_dialog.html, _filter_tags.html): a Tom Select (initTagSelects, base.html). */
/* typed into the field itself, like a text box: no chevron, the caret space given back */
.ts-wrapper.ag-tag-select.single { --ts-pr-caret: 0px; }
.ts-wrapper.ag-tag-select.single::after { content: none; }
.ts-wrapper.ag-tag-select.single .ts-control { cursor: text; }
.ts-wrapper.ag-tag-select.single .ts-control > input { min-width: 0; flex: 1 1 auto; }
.row-tag-dialog .tag-pills { margin-bottom: .2rem; }
/* One record's pills under the tree view panel's title (row_tag_pills); empty
   when untagged, kept in the page so agRowTagMark can fill it. */
.row-tag-pills:empty { display: none; }
/* Lucide ships strokes only, no filled variants — so an on/off toggle reads as
   outline vs filled by painting the stroke icon's interior (same trick as the
   bookmark above). Shape, not just colour, so the state survives a glance, a
   colour-blind reader and a greyscale print. Used by the query builder's
   default-view stars (templates/_search_library_list.html). */
.icon-toggle.on { color: var(--primary); }
.icon-toggle.on svg { fill: currentColor; }
/* boolean cells render as icons: check (true) / minus (false) */
table.data .bool-icon { width: 24px; height: 24px; vertical-align: middle; }
table.data tbody tr.clickable { cursor: pointer; }

/* ------------------------ bulk selection ------------------------- */
/* The checkbox column (templates/_bulk_bar.html) and the bar that acts on it.
   The column is as narrow as its box and never grows; the cell drops the default
   top alignment so the box sits on the text baseline of the row.
   Its width is stated rather than left to the content because the drag handle
   beside it pins at exactly this offset (see "frozen columns"): --bulk-col-w is
   what the column measured anyway — .75rem of padding + a 16px box + .25rem. */
table.data th.bulk-col, table.data td.bulk-col {
  width: var(--bulk-col-w); min-width: var(--bulk-col-w);
  white-space: nowrap; padding-right: .25rem; vertical-align: middle;
}
table.data td.bulk-col { cursor: default; }
/* The Actions cell wraps after six buttons instead of running off to the right.
   A row can carry a lot of them — file analytics, bookmark, preview, editor,
   tree, view, run, every custom action, plus edit/delete — and on one line the
   frozen column ate a third of a wide window and pushed the data columns out.

   Six is the cap because it is about as many icons as read as a group at a glance,
   and two rows of six is still shorter than most rows are tall, so wrapping costs
   no row height in practice.

   The nowrap above is what fixed the column's minimum at "every button on one
   line"; the cell releases it and the flex container below takes over that job,
   with a maximum instead of a minimum. The header keeps nowrap so "Actions" itself
   never wraps.

   The buttons are pinned to their natural square (a 24px lucide icon + .4rem
   padding + a 1px border) rather than left to size themselves, so the width below
   is arithmetic rather than a guess: get it wrong by a pixel and the sixth button
   wraps or a seventh joins the line. */
table.data td.actions-col { white-space: normal; }
/* Grid rather than flex-wrap, because the column is `width: 1%` — it sizes to its
   content's intrinsic width. A wrapping flex container's intrinsic minimum is its
   widest single item, so the cell collapsed to one button per line; six explicit
   tracks give it an intrinsic width of exactly six buttons, which is the number we
   want. Tracks are `auto`, so a row with fewer than six buttons leaves its unused
   tracks at zero width instead of reserving space for buttons that aren't there. */
.row-actions {
  --row-action-size: 2.425rem;  /* 24px + 2×.4rem + 2×1px */
  --row-action-gap: 1px;
  display: grid;
  grid-template-columns: repeat(6, auto);
  justify-content: end;
  gap: var(--row-action-gap);
  margin-left: auto;
}
.row-actions > .btn-icon {
  width: var(--row-action-size);
  height: var(--row-action-size);
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
}
/* A ticked row is tinted, so the selection is readable at a glance while
   scrolling past the checkbox column. Mixed into the surface rather than into
   `transparent`: the frozen columns inherit this colour and would let the
   columns sliding underneath show through a translucent one. */
table.data tbody tr.bulk-selected { background: color-mix(in srgb, var(--primary) 10%, var(--surface-container-lowest)); }
table.data tbody tr.bulk-selected:hover { background: color-mix(in srgb, var(--primary) 16%, var(--surface-container-lowest)); }

/* ------------------------ frozen columns ------------------------- */
/* What a row IS scrolls sideways; what you DO to it does not — the row controls
   pin to the edges: checkbox and drag handle left, actions right, so a wide
   table can be read across without losing the tick box, the grip or the row
   buttons. All three are of fixed width, unlike the data columns between them,
   so pinning them costs no readable width.
   Two things make it work: a sticky cell must be opaque (it inherits the row
   background set above, hover / selection / focus tint included), and the
   sticky header must outrank the sticky body cells — hence the same z-index
   ladder .perm-matrix uses: body 1 < header 2 < the header's own frozen cells 3. */
/* The other two control columns hold buttons, not data, so they are worth exactly
   what they contain — at any window width. `width: 1%` is how a column shrink-wraps
   inside a width:100% auto-layout table: it is under the content's minimum, so the
   browser falls back to that minimum and hands every spare pixel to the data columns
   instead of sharing it out proportionally (which is what made the actions column
   grow a field of empty space on a wide window and snap tight on a narrow one).
   `white-space: nowrap` is what fixes that minimum at "all the buttons on one line" —
   the cells already state it inline; the headers need it too, or a widened window
   would let "Actions" wrap instead. */
table.data th.actions-col, table.data td.actions-col,
table.data th.row-drag-col, table.data td.row-drag-col { width: 1%; white-space: nowrap; }
table.data th.bulk-col, table.data td.bulk-col,
table.data th.row-drag-col, table.data td.row-drag-col,
table.data th.actions-col, table.data td.actions-col { position: sticky; z-index: 1; }
table.data tbody td.bulk-col, table.data tbody td.row-drag-col,
table.data tbody td.actions-col { background: inherit; }
table.data thead th.bulk-col, table.data thead th.row-drag-col,
table.data thead th.actions-col { z-index: 3; }
table.data th.bulk-col, table.data td.bulk-col { left: 0; }
table.data th.actions-col, table.data td.actions-col { right: 0; }
/* The handle sits behind the checkbox column, so it pins one column-width in —
   the one width in this table that is declared rather than measured. A table
   with no bulk actions has no checkbox column, and then the handle IS the
   left edge. */
table.data th.row-drag-col, table.data td.row-drag-col { left: var(--bulk-col-w); }
table.data th.row-drag-col:first-child, table.data td.row-drag-col:first-child { left: 0; }

/* The seam the scrolled-away columns disappear behind. A pseudo-element rather
   than a border (border-collapse leaves a collapsed border behind when the cell
   scrolls away from it) and rather than a box-shadow (the keyboard cursor's bar
   on the row's first cell is one, and a single element has only one). Drawn by
   whichever pinned-left column is the inner one, so the two never show a rule
   between themselves. */
table.data .bulk-col::after, table.data .row-drag-col::after, table.data .actions-col::after {
  content: ''; position: absolute; top: 0; bottom: 0; width: 1px;
  background: var(--outline-variant);
}
table.data .bulk-col::after, table.data .row-drag-col::after { right: 0; }
table.data .actions-col::after { left: 0; }
table.data tr:has(> .row-drag-col) > .bulk-col::after { content: none; }

/* Sits directly above the table and appears only once something is ticked, so an
   untouched list looks exactly as it did before. */
.bulk-bar {
  display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
  padding: .5rem .75rem; margin-bottom: .25rem;
  border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--outline-variant));
  border-radius: .5rem;
  background: color-mix(in srgb, var(--primary) 8%, var(--surface-container-low));
  font-size: 13px;
}
.bulk-bar .bulk-count { font-weight: 600; color: var(--on-surface); }
.bulk-bar.busy { opacity: .6; pointer-events: none; }
/* The bulk bar's action menu is the toolbar Actions dropdown (.actions-menu), so
   it needs no styling of its own beyond the smaller button the bar calls for and
   the destructive item — Delete has to read as Delete inside a menu the way it did
   as a red button. A disabled item (one that cannot run over the whole queryset)
   dims and stops responding rather than leaving the panel. */
.bulk-bar .bulk-menu-btn { padding: .3rem .6rem; font-size: 12px; }
/* what a stopped run left behind — reads as an error without shouting, since the
   rows it names are still ticked right below it */
.bulk-bar .bulk-error { color: var(--error); font-size: 12px; }
.bulk-bar .bulk-error[hidden] { display: none; }
.actions-menu-item.danger { color: var(--error); }
.actions-menu-item.danger:hover { background: color-mix(in srgb, var(--error) 14%, transparent); }
.actions-menu-item[disabled] { opacity: .45; cursor: default; }
.actions-menu-item[disabled]:hover { background: transparent; }
/* keyboard cursor inside the panel: ↑ ↓ move focus, so focus has to be visible */
.actions-menu-item:focus-visible { background: var(--surface-container); outline: 2px solid var(--primary); outline-offset: -2px; }

/* severity-level badges (logs): info=green, warning=amber, error/critical=red */
.sev {
  display: inline-block; padding: .1rem .5rem; border-radius: 999px;
  font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .02em;
  border: 1px solid transparent; white-space: nowrap;
}
.sev-info { color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); border-color: color-mix(in srgb, var(--success) 35%, transparent); }
.sev-warning { color: var(--warning); background: color-mix(in srgb, var(--warning) 16%, transparent); border-color: color-mix(in srgb, var(--warning) 38%, transparent); }
.sev-error, .sev-critical { color: var(--error); background: color-mix(in srgb, var(--error) 14%, transparent); border-color: color-mix(in srgb, var(--error) 35%, transparent); }

/* read-only detail view (log rows) */
.detail-value { font-size: 13px; color: var(--on-surface); word-break: break-word; }
.detail-value.id { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; color: var(--on-surface-variant); }
/* what an edit changed (templates/_diff.html), on top of a log row's detail */
.diff-title svg { width: 15px; height: 15px; }
.diff-group + .diff-group { margin-top: .75rem; }
.diff-arrow { width: 14px; height: 14px; color: var(--on-surface-variant); align-self: center; flex: none; }
.diff-view del, .diff-view ins { text-decoration: none; border-radius: .2rem; padding: 0 .15rem; }
.diff-view del { background: color-mix(in srgb, var(--error) 16%, transparent); color: var(--on-surface); text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--error) 60%, transparent); }
.diff-view ins { background: color-mix(in srgb, var(--success) 18%, transparent); color: var(--on-surface); }
.diff-masked svg { width: 14px; height: 14px; }
.diff-line .diff-sign { flex: none; width: 1.6rem; text-align: center; color: var(--on-surface-variant); user-select: none; }
.diff-line .diff-text { flex: 1; min-width: 0; }
.diff-line.delete { background: color-mix(in srgb, var(--error) 8%, transparent); }
.diff-line.insert { background: color-mix(in srgb, var(--success) 9%, transparent); }
.diff-line.delete del, .diff-line.insert ins { padding: 0; }
.diff-line.delete del { text-decoration: none; }
.diff-line.skip { color: var(--on-surface-variant); font-style: italic; padding-left: 1.6rem; }
.diff-view.bare { margin: .5rem 0 0; padding: 0; border: 0; }

/* a record's history (templates/_row_history.html) — newest first */
.history-entry::before { content: ""; position: absolute; left: -6px; top: .3rem; width: 10px; height: 10px;
  border-radius: 50%; background: var(--surface); border: 2px solid var(--outline); }
.history-entry:last-of-type { border-left-color: transparent; }
table.data .bool-icon.on { color: var(--success); }
table.data .bool-icon.off { color: var(--on-surface-variant); }

/* ------------------- list totals (grid footer) ------------------- */
/* The totals are the grid's own <tfoot> (templates/_list_totals.html), so they
   inherit the row columns and widths and need no layout of their own — only a
   shade and a rule to separate them from the rows they add up.

   The footer sits at the bottom of the panel whatever the row count, which takes
   two rules working together, because sticky alone cannot do it: sticky is bounded
   by its containing block — the table — so on a short list the footer has nothing
   to be pushed down to and stops under the last row.

     1. the table is stretched to fill the scrollport, and an empty filler row in
        the tbody (templates/table.html) absorbs the slack, so the real rows keep
        their natural height and the footer lands on the bottom edge;
     2. sticky then holds it there once the rows DO overflow and start scrolling
        under it, the way thead is held at the top.

   z-index lifts the whole section over the (unpositioned) rows passing beneath. */
#admin-table > .table-wrap > table.data.has-totals,
#log-table > .table-wrap > table.data.has-totals { height: 100%; }
/* the filler claims all the leftover height, which is what stops the browser
   distributing it across the real rows and stretching them */
table.data tr.list-totals-filler { height: 100%; }
table.data tr.list-totals-filler td { padding: 0; border-bottom: none; }
table.data tbody tr.list-totals-filler:hover { background: none; }
table.data tfoot.list-totals { position: sticky; bottom: 0; z-index: 2; }
/* Opaque at BOTH row and cell level. The rows scroll underneath, so anything
   see-through here shows them; painting the row as well as the cell means a cell
   whose background the table's painting order skips still lands on solid ground. */
table.data tfoot.list-totals tr,
table.data tfoot.list-totals td {
  background: var(--surface-container-low, var(--surface-container-lowest));
}
table.data tfoot.list-totals td {
  padding: .45rem .75rem;
  border-bottom: none;
  white-space: nowrap;
}
/* Rules as inset shadows, not borders: this table collapses its borders, and a
   collapsed border belongs to the table rather than to the cell — which is the
   one part of a sticky row that does not reliably travel with it. */
table.data tfoot.list-totals tr:first-child td,
table.data tfoot.list-totals .list-totals-all td {
  box-shadow: inset 0 1px 0 var(--outline-variant);
}
.list-totals-label .muted { font-size: 11px; }
table.data tfoot.list-totals .list-totals-all td { font-weight: 600; }
table.data tfoot.list-totals .list-totals-note td {
  padding-top: .1rem; font-size: 11px; color: var(--on-surface-variant); white-space: normal;
}

/* ---------------------------- tree ------------------------------ */
.tree { width: 100%; border-collapse: collapse; font-size: 13px; }
.tree td { padding: .4rem .6rem; border-bottom: 1px solid var(--outline-variant); }
.tree tr:hover { background: var(--surface-container-low); }
.tree .node-cell { display: flex; align-items: center; gap: .4rem; }
.tree .twisty {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; border-radius: .25rem;
  cursor: pointer; color: var(--on-surface);
  border: none; background: transparent;
  flex-shrink: 0;
}
.tree .twisty svg { width: 22px; height: 22px; stroke-width: 2.5; flex-shrink: 0; }
.tree .twisty:hover { background: var(--surface-container-high); }
.tree .twisty.leaf { visibility: hidden; }
.tree .node-label { font-weight: 500; }
.tree .htmx-request .twisty svg { animation: spin .8s linear infinite; }
.kind-chip {
  font-size: 10px; text-transform: uppercase; letter-spacing: .04em;
  padding: .1rem .4rem; border-radius: 9999px;
  background: var(--surface-container-high); color: var(--on-surface-variant);
  font-weight: 600;
}

/* --------------------------- badges ----------------------------- */
/* Status chips are DaisyUI badges (`badge badge-soft badge-sm` + a tone from
   templates/filters.py:badge_tone, docs/ui_stack_migration.md). On top of
   DaisyUI: the pill shape the app has always used (DaisyUI rounds a badge by
   --radius-selector, which also shapes checkboxes) and room for an icon. */
.badge { border-radius: 9999px; gap: .3rem; }
/* A link badge (the topbar's user chip): the unlayered `a { color }` above would
   beat DaisyUI's layered text colour, so the soft badge's colour is restated. */
a.badge { color: var(--badge-color, var(--color-base-content)); }
/* The clipboard indicator in the topbar (_shell.html): what one copy/cut is
   holding, and the way to let go of it. A chip like the user chip beside it, tinted
   like the primary actions because it is a pending one — something is waiting to be
   pasted. It is only in the DOM's flow while the buffer is full (x-show). */
.clip-chip {
  display: inline-flex; align-items: center; gap: .3rem; max-width: 15rem;
  font-size: 11px; padding: .1rem .2rem .1rem .5rem; border-radius: 9999px;
  background: color-mix(in srgb, var(--primary) 14%, transparent); color: var(--primary);
}
.clip-chip i, .clip-chip svg { width: 13px; height: 13px; flex: 0 0 auto; }
.clip-chip .btn-icon { width: 18px; height: 18px; padding: 0; color: inherit; }
.clip-chip .btn-icon:hover { background: color-mix(in srgb, var(--primary) 22%, transparent); }
/* a lucide icon in a chip renders at its default 24px otherwise, dwarfing the
   11px label (affects the pagination's query/filter chips too) */
.badge svg, .badge i { width: 13px; height: 13px; flex-shrink: 0; }

/* Celery worker online/offline pill (operations queue list header). Mirrors the
   dataflow editor's .df-worker; empty until the first poll resolves. */
.worker-pill:empty { display: none; }
.worker-pill { display: inline-flex; align-items: center; gap: .35rem; font-size: 11px;
  padding: .15rem .55rem; border-radius: 9999px; white-space: nowrap;
  background: var(--surface-container-high); color: var(--on-surface-variant); }
.worker-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.worker-pill.online { color: var(--success); background: color-mix(in srgb, var(--success) 16%, transparent); }
.worker-pill.offline { color: var(--warning); background: color-mix(in srgb, var(--warning) 18%, transparent); }

/* Colour-graded inline-edit select (e.g. the queue priority dropdown): tints the
   control + its options by the column's value_color_json class, reusing the chip
   palette. The tone class is set on render and refreshed on change (see
   _inline_edit.html). */
.inline-select.ok   { background: color-mix(in srgb, var(--success) 18%, transparent); color: var(--success); }
.inline-select.warn { background: color-mix(in srgb, var(--warning) 18%, transparent); color: var(--warning); }
.inline-select.err  { background: color-mix(in srgb, var(--error)   18%, transparent); color: var(--error); }
.inline-select.info { background: color-mix(in srgb, var(--primary) 18%, transparent); color: var(--primary); }
.inline-select option        { background: var(--surface-container-high); color: var(--on-surface); font-weight: 500; }
.inline-select option.ok     { color: var(--success); }
.inline-select option.warn   { color: var(--warning); }
.inline-select option.err    { color: var(--error); }
.inline-select option.info   { color: var(--primary); }

/* ------------------------- pagination --------------------------- */
.pagination { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: .75rem 1.25rem; border-top: 1px solid var(--outline-variant); }
.pagination .pages { display: flex; gap: 2px; }
.pagination .page-btn { min-width: 32px; text-align: center; padding: .3rem .5rem; border-radius: .25rem; border: 1px solid var(--outline-variant); background: var(--surface-container-lowest); color: var(--on-surface); cursor: pointer; font-size: 12px; }
.pagination .page-btn.active { background: var(--primary); color: var(--on-primary); border-color: var(--primary); }
.pagination .page-btn:hover:not(.active) { background: var(--surface-container); }

/* ---------------------------- modal ----------------------------- */
/* Dialogs are DaisyUI's: the overlay `modal modal-open` (always open — a dialog
   is swapped into #modal / #modal-2 to show it and emptied to close it), the panel
   `modal-box p-0` holding our card-header / card-body / card-footer, which bring
   their own padding (docs/ui_stack_migration.md). On top of DaisyUI:
   - anchored near the top, not centred: a dialog changes height as a tab switches
     or a fragment loads, and a centred one would jump;
   - our stacking (dialog 50, nested 70, text editor 85, lightbox 90, popovers
     1000) instead of DaisyUI's 999 for every modal;
   - the app's default width (DaisyUI's 32rem is narrower than a two-column form)
     and a frame in the tokens' outline colour. */
.modal { z-index: 50; place-items: start center; padding: 4rem 1rem; }
.modal-box { max-width: 640px; border: 1px solid var(--outline-variant); }
/* Add/Edit forms: freeze the header (title + close) and footer (save) while the
   field area scrolls. The modal becomes a flex column with a non-scrolling
   shell and only the body overflowing. */
.modal-flex { display: flex; flex-direction: column; overflow: hidden; }
.modal-flex > form { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
.modal-flex .card-header { flex: 0 0 auto; }
.modal-flex .card-footer { flex: 0 0 auto; }
.modal-flex .card-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }

/* ------------------- maximizable modals ------------------------- */
/* Bookmarks, query builder + entity forms: 60% wide / 80% tall by default;
   .maximized on the backdrop (persisted in localStorage) grows the modal to
   nearly full screen — the backdrop padding shrinks with it. */
.modal-60 { width: 60vw; height: 80vh; max-width: none; max-height: none; }
.modal-pivot { width: 88vw; height: 84vh; max-width: 1500px; max-height: none; }
/* A modal opened *on top of* another one (the query builder's column picker).
   Sits above the backdrop below it, which stays visible and inert. */
.modal.modal-nested { z-index: 70; background-color: rgba(0,0,0,.35); }
.modal.maximized { padding: 1rem; }
.modal.maximized .modal-60 { width: 100%; height: calc(100vh - 2rem); }
.modal.maximized .modal-pivot { width: 100%; max-width: none; height: calc(100vh - 2rem); }

/* -------------------- full-window text editor ------------------- */
/* Every long-text field carries a small expand button (injected by
   initTextExpand in base.html) that opens its value in a window-filling
   editor — an 80px-high box inside a 60vw dialog is no way to write a
   document. It stays faint until the field is hovered or the button focused. */
.text-expand-wrap { position: relative; display: block; flex: 1 1 auto; min-width: 0; }
/* A rich-text editor maximised in place (agToggleRichMax, base.html): the wrapper —
   Quill's toolbar and editor — fills the window over everything, the page dimmed
   behind it; the editor takes the height the toolbar leaves. */
.text-expand-wrap.rich-max {
  position: fixed; inset: 1.5rem; z-index: 60; display: flex; flex-direction: column;
  padding: .75rem; border-radius: .5rem; background: var(--surface-container-lowest);
  box-shadow: 0 0 0 100vmax color-mix(in srgb, #000 45%, transparent);
}
.text-expand-wrap.rich-max > .ql-container { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.text-expand-wrap.rich-max .ql-editor { flex: 1 1 auto; max-height: none; }
.text-expand-wrap.rich-max > .text-expand-btn { top: .75rem; right: .75rem; }
.text-expand-wrap > .textarea { padding-right: 1.7rem; }
/* …but a box that also offers the AI button needs room for two. */
.text-expand-wrap > .textarea[data-ai-enhance] { padding-right: 3.2rem; }
.text-expand-btn {
  position: absolute; top: .3rem; right: .3rem;
  display: inline-flex; align-items: center; justify-content: center;
  padding: 2px; border: 0; border-radius: .25rem; background: transparent;
  color: var(--on-surface-variant); opacity: .55; cursor: pointer; line-height: 0;
}
/* In a standard field the label line above the box is free, so the button sits
   up there, next to the label it belongs to and clear of the scrollbar a text
   long enough to want this always has. Only there: a textarea laid out some
   other way (the CMS section forms) keeps it inside its own top-right corner. */
.field > .text-expand-wrap > .text-expand-btn { top: -1.4rem; right: 0; }
.field > .text-expand-wrap > .textarea { padding-right: .65rem; }
.text-expand-wrap:hover .text-expand-btn,
.text-expand-btn:focus, .text-expand-btn:hover { opacity: 1; background: var(--surface-container); color: var(--on-surface); }
.text-expand-btn svg, .text-expand-btn i { width: 15px; height: 15px; }
/* "Improve this text with AI" — the same chrome button, one slot to the left of
   the expand one, in both of the positions above. It carries the accent colour
   and sits at a higher resting opacity than its neighbour on purpose: expanding
   is a thing you go looking for, this is an offer, and an invisible offer is not
   one. */
.text-ai-btn { right: 1.65rem; opacity: .75; color: var(--primary, var(--on-surface-variant)); }
.field > .text-expand-wrap > .text-ai-btn { top: -1.4rem; right: 1.35rem; }

/* "Translations" — the third button in that same corner, revealing the
   per-language inputs rendered under the field (templates/_field_translations.html).
   It sits one slot further left than the AI button, so a long-text field that
   offers all three reads globe / sparkles / expand, left to right. Unlike its
   neighbours it also has a state: .on means this field already HAS translations,
   which is what makes a translated field recognisable without opening it. */
.text-translate-btn { opacity: .75; }
.field > .text-expand-wrap > .text-translate-btn { top: -1.4rem; }
/* Which slot it takes depends on what else is in the corner, and that is counted
   in JS (agMarkTranslatable) rather than inferred from DOM order — the button is
   inserted first, so a sibling selector would read the order backwards. Slot 0 is
   a one-line input, which has neither of the other two buttons. */
.text-translate-btn.ag-btn-slot-0 { right: .3rem; }
.text-translate-btn.ag-btn-slot-1 { right: 1.65rem; }
.text-translate-btn.ag-btn-slot-2 { right: 3rem; }
.field > .text-expand-wrap > .text-translate-btn.ag-btn-slot-0 { right: 0; }
.field > .text-expand-wrap > .text-translate-btn.ag-btn-slot-1 { right: 1.35rem; }
.field > .text-expand-wrap > .text-translate-btn.ag-btn-slot-2 { right: 2.7rem; }
.text-translate-btn.on { opacity: 1; color: var(--primary, var(--on-surface-variant)); }
.text-translate-btn.open { background: var(--surface-container); color: var(--on-surface); opacity: 1; }

/* Setting display on the class would beat the UA's [hidden] rule and leave the
   block permanently open — the class is more specific than the bare attribute
   selector. So the collapsed state is stated here, on the same class. */
.translation-rows[hidden] { display: none; }
.translation-row > label { min-width: 7.5rem; font-size: 11px; text-transform: none; margin: 0; }
.translation-row > .input { flex: 1 1 auto; min-width: 0; }
.translation-row > .flag-img { width: 18px; height: auto; flex: 0 0 auto; }
@media (max-width: 640px) {
  .translation-row { flex-wrap: wrap; }
  .translation-row > label { min-width: 0; }
}

/* The editor itself: mounted on <body> above every dialog (a field can be
   expanded from a nested modal too), filling the window bar a small margin. */
.modal.ag-text-editor { z-index: 85; padding: 1rem; place-items: stretch; }
.ag-text-editor > .modal-box { width: 100%; max-width: none; max-height: none; }
/* the body is the editor's frame — it does not scroll, the textarea inside does */
.ag-text-editor .card-body { display: flex; overflow: hidden; padding: 1rem 1.25rem; }
/* Markdown formatting row, between the header and the writing surface. Wraps
   rather than scrolls, so a narrow window loses no buttons. `hidden` has to be
   said explicitly: display:flex would otherwise beat the attribute, and preview
   mode hides this the same way it hides the editor. */
.ag-text-editor .text-editor-toolbar {
  display: flex; flex-wrap: wrap; align-items: center; gap: .1rem;
  padding: .35rem 1.25rem;
  border-bottom: 1px solid var(--outline-variant);
}
.ag-text-editor .text-editor-toolbar[hidden] { display: none; }
.ag-text-editor .text-editor-toolbar .btn-icon { width: 30px; height: 30px; }
.ag-text-editor .text-editor-toolbar .btn-icon svg { width: 16px; height: 16px; }
/* The writing surface takes the whole window — that is what opening it asked
   for. Borderless on purpose: at this size the field IS the page, and a focus
   ring around a window-filling box reads as a form control, not a document. */
.text-editor-area {
  flex: 1 1 auto; width: 100%;
  min-height: 0; resize: none; font-size: 14px; line-height: 1.65; padding: .5rem .25rem;
  border: 0; background: transparent;
}
.text-editor-area:focus { border: 0; box-shadow: none; }
/* A JSON column edits in ACE instead, filling the same frame. Absolute inside a
   relative host is what gives ACE the definite height it needs — the same
   problem initAceEditors solves with a ResizeObserver for its data-fill
   editors, which here the layout can answer on its own. */
.ag-text-editor .text-editor-ace-host { position: relative; flex: 1 1 auto; width: 100%; min-height: 0; }
.ag-text-editor .text-editor-ace { position: absolute; inset: 0; border: 0; border-radius: 0; font-size: 14px; }
/* Preview mode for a markdown field: the rendered document takes the editor's
   place in the same frame (`hidden` swaps them), and reads as a document —
   a column, not the full width the writing surface uses. The HTML arrives
   sanitized from the server, so these rules only have to style what is allowed
   through the allowlist in lib/markdown_render.py. */
.ag-text-editor .text-editor-preview {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  width: 100%; max-width: 80ch; margin: 0 auto; padding: .5rem .25rem 2rem;
}
.md-body { line-height: 1.65; }
.md-body > :first-child { margin-top: 0; }
.md-body h1, .md-body h2, .md-body h3, .md-body h4, .md-body h5, .md-body h6 {
  margin: 1.6em 0 .5em; line-height: 1.25; font-weight: 600;
}
.md-body h1 { font-size: 1.7rem; }
.md-body h2 { font-size: 1.35rem; border-bottom: 1px solid var(--outline-variant); padding-bottom: .25rem; }
.md-body h3 { font-size: 1.15rem; }
.md-body h4, .md-body h5, .md-body h6 { font-size: 1rem; }
.md-body p, .md-body ul, .md-body ol, .md-body blockquote, .md-body pre, .md-body table { margin: 0 0 1em; }
.md-body ul, .md-body ol { padding-left: 1.4em; }
.md-body li { margin: .2em 0; }
.md-body a { color: var(--primary); }
.md-body code {
  font-family: "JetBrains Mono", monospace; font-size: .88em;
  background: var(--surface-container); border-radius: .2rem; padding: .1em .3em;
}
.md-body pre {
  background: var(--surface-container); border: 1px solid var(--outline-variant);
  border-radius: .4rem; padding: .75rem 1rem; overflow-x: auto;
}
.md-body pre code { background: none; padding: 0; font-size: .85rem; }
.md-body blockquote {
  border-left: 3px solid var(--outline-variant); padding: .1rem 0 .1rem 1rem;
  color: var(--on-surface-variant);
}
.md-body hr { border: 0; border-top: 1px solid var(--outline-variant); margin: 2em 0; }
.md-body table { border-collapse: collapse; width: 100%; font-size: 13px; }
.md-body th, .md-body td { border: 1px solid var(--outline-variant); padding: .35rem .6rem; text-align: left; }
.md-body th { background: var(--surface-container); font-weight: 600; }
.md-body img { max-width: 100%; height: auto; border-radius: .3rem; }

.ag-text-editor .card-footer { justify-content: flex-start; gap: 1rem; }
.ag-text-editor .text-editor-stats { font-size: 12px; white-space: nowrap; }
/* the JSON in the editor does not parse — said where the counts are, since that
   is the line you are already glancing at */
.ag-text-editor .text-editor-stats.bad { color: var(--error); white-space: normal; }
.ag-text-editor .text-editor-hint { font-size: 11px; }
@media (max-width: 900px) { .ag-text-editor .text-editor-hint { display: none; } }

/* ------------------------- chart tooltips ----------------------- */
/* ApexCharts' light/dark tooltip can leave dark text on a dark slice/bg.
   Force the app's surface/on-surface pair (they flip with the theme), so the
   text is always readable — dark-on-light in light mode, light-on-dark in dark. */
.apexcharts-tooltip {
  background: var(--surface-container-lowest) !important;
  color: var(--on-surface) !important;
  border: 1px solid var(--outline-variant) !important;
  box-shadow: 0 4px 16px rgba(0,0,0,.25) !important;
}
.apexcharts-tooltip-title {
  background: var(--surface-container-lowest) !important;
  color: var(--on-surface) !important;
  border-bottom: 1px solid var(--outline-variant) !important;
  font-weight: 600;
}
/* every text node inside the tooltip (labels + values), overriding inline colors */
.apexcharts-tooltip * { color: var(--on-surface) !important; }

/* ---------------------------- misc ------------------------------ */
.flash { padding: .6rem .9rem; border-radius: .375rem; margin-bottom: 1rem; font-size: 13px; }
.flash.error { background: var(--error-container); color: var(--on-error-container); }
.flash.success { background: color-mix(in srgb, var(--success) 18%, transparent); color: var(--success); }

/* ---------------- a save that failed --------------------------------------
   Three things say it, and they say different halves of it: the banner counts the
   fields and lists them (each entry a way TO the field), the field wears a red
   edge so the form itself is readable as a checklist, and the tab button is marked
   so a page with nothing wrong on it does not look like the end of the work. */
.form-errors-head i, .form-errors-head svg { width: 15px; height: 15px; flex: 0 0 auto; }
/* a rule, not utilities: font-size must follow the font shorthand */
.form-error-item {
  display: flex; align-items: baseline; gap: .4rem; width: 100%; text-align: left;
  border: none; background: transparent; color: inherit; cursor: pointer;
  padding: .15rem .3rem; border-radius: .25rem; font: inherit; font-size: 12.5px;
}
.form-error-item:hover { background: color-mix(in srgb, var(--on-error-container) 10%, transparent); }
.form-error-field { font-weight: 600; flex: 0 0 auto; }
.form-error-field::after { content: ':'; }
/* the tab bar's own marker: a dot rather than a colour swap, so the active/ghost
   state a tab already carries keeps meaning what it meant */
/* Tab bars are DaisyUI's lifted tabs — `tabs tabs-lift` of `tab` buttons, the
   current one `tab-active` (keyboard.js steps by it): the active tab is a raised
   folder tab opening into the content below, the others sit behind it. On top of
   DaisyUI: an inactive <button> tab drops the browser's grey button fill (no reset
   yet), tabs keep the page font, an icon sits apart from the label, the tab
   outline is the tokens' outline colour (DaisyUI's --color-base-300 all but
   vanishes in the dark theme) and the active tab's label is primary. */
button.tab { font-family: inherit; }
button.tab:not(.tab-active) { background-color: transparent; }
.tab { gap: .4rem; }
.tabs-lift > .tab { --tab-border-color: var(--outline-variant); }
.tabs-lift > .tab:not(.tab-active) { --tab-border-colors: #0000; }
/* The bar's baseline runs its full width, drawn inside the bar so the active tab
   — same fill as the content — paints over it and opens into the content below. A
   dialog's tab bar is also its card-header: that border gives way to the baseline. */
.tabs-lift { box-shadow: inset 0 -1px var(--outline-variant); }
.card-header.tabs-lift { border-bottom: 0; padding-bottom: 0; align-items: flex-end; }
.tabs > .tab.tab-active { color: var(--primary); }
.tab.tab-has-error { position: relative; }
.tab.tab-has-error::after {
  content: ''; position: absolute; top: .18rem; right: .18rem;
  width: .4rem; height: .4rem; border-radius: 50%; background: var(--error);
}

/* the field itself: a red edge on whatever widget it holds, and the reason under it */
.field.has-error > .input, .field.has-error > .textarea, .field.has-error > .select,
.field.has-error input.input, .field.has-error textarea.textarea, .field.has-error select.select {
  border-color: var(--error);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--error) 45%, transparent);
}
.field.has-error > label { color: var(--error); }
.field-error {
  display: flex; align-items: center; gap: .3rem; margin: .2rem 0 0;
  font-size: 11.5px; color: var(--error);
}
.field-error i, .field-error svg { width: 12px; height: 12px; flex: 0 0 auto; }
/* one blink when the banner sends the cursor here — a field already in view would
   otherwise answer the click with nothing */
@keyframes ag-field-flash {
  0%, 100% { background: transparent; }
  30% { background: color-mix(in srgb, var(--error) 16%, transparent); }
}
.field.field-flash { animation: ag-field-flash .9s ease-out 1; border-radius: .3rem; }
.center-screen { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1.5rem; background: var(--background); }
.auth-card { width: 100%; max-width: 400px; }
/* will-change promotes the spinner to its own compositor layer so the rotate
   animation keeps running on the GPU/compositor thread even while the main thread
   is blocked by heavy synchronous work (big JSON.parse, chart/pivot render) —
   otherwise the spinner visibly freezes exactly when it's needed most. */
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--outline-variant); border-top-color: var(--primary); border-radius: 50%; animation: spin .7s linear infinite; will-change: transform; }
@keyframes spin { to { transform: rotate(360deg); } }
.toolbar { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; margin-bottom: 1rem; }

/* ---- Frozen list layout: the heading, toolbar and column titles stay put,
   only the table body scrolls. Scoped to pages that contain a list table. ---- */
.app:has(#admin-table), .app:has(#log-table) { height: 100vh; overflow: hidden; }
.app:has(#admin-table) .content,
.app:has(#log-table) .content { min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.app:has(#admin-table) .toolbar,
.app:has(#log-table) .toolbar { flex: 0 0 auto; }
.app:has(#admin-table) .card,
.app:has(#log-table) .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
#admin-table, #log-table { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
/* --table-wrap-max-h: none — this wrap takes the window's leftover height, so the
   default cap up in "tables" must not shrink it back to 60vh on a tall screen. */
#admin-table > .table-wrap, #log-table > .table-wrap {
  --table-wrap-max-h: none; flex: 1 1 auto; min-height: 0; overflow: auto;
}
#admin-table > .pagination, #log-table > .pagination { flex: 0 0 auto; }
.grow { flex: 1; }
.htmx-indicator { opacity: 0; transition: opacity .2s; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }

/* An indicator that costs NO LAYOUT at rest. The bare .htmx-indicator above only
   fades to transparent, so a spinner carrying a message ("this can take a minute…")
   keeps reserving its full width between the buttons around it — an invisible hole
   that pushes an action row onto two ragged lines. Add this alongside
   .htmx-indicator when the indicator sits IN a button row rather than under it.
   Note the element must not also carry an inline `display:`, which would win over
   these rules and leave the hole in place.
   Two selectors because htmx marks two different elements: hx-indicator="#id"
   marks the named element ITSELF, while an indicator inside the requesting element
   is marked by an ancestor. .btn-swap-box further down is the other answer to the
   same problem — use that one when the spinner can replace a button's own icon,
   and this one when it is a standalone message. */
.htmx-indicator.indicator-collapse { display: none; }
.htmx-indicator.indicator-collapse.htmx-request,
.htmx-request .htmx-indicator.indicator-collapse { display: inline-flex; opacity: 1; }

/* ---- Drag-to-reorder rows (initRowSort in base.html) ----
   The single reordering affordance in the UI: a grip in the row's first cell on
   any list whose table carries the standard sort_order column. There are no
   up/down arrows anywhere. */
/* The column is frozen (see "frozen columns"), so the .25rem on the right is the
   gap between the grip and the seam, not decoration. */
.row-drag-col { width: 1%; padding-left: .5rem !important; padding-right: .25rem !important; color: var(--on-surface-variant); }
.row-drag {
  display: inline-flex; align-items: center; justify-content: center;
  cursor: grab; color: var(--on-surface-variant); opacity: .55; line-height: 0;
  padding: .15rem; border-radius: .25rem;
}
.row-drag:hover { opacity: 1; background: var(--surface-container); }
.row-drag:active { cursor: grabbing; }
.row-drag svg, .row-drag i { width: 18px; height: 18px; }
/* the row being dragged — dimmed so the insertion preview reads clearly */
.row-dragging { opacity: .45; background: var(--surface-container); }

/* flag images */
.flag-img { width: 22px; height: 16px; object-fit: cover; border-radius: 2px; border: 1px solid var(--outline-variant); vertical-align: middle; flex-shrink: 0; }
/* in data-table listings, match the 24px lucide action icons; width follows
   the image's natural aspect */
table.data .flag-img { height: 24px; width: auto; object-fit: contain; }
/* align-self keeps the natural aspect inside .field (a column flexbox would
   otherwise stretch the img to full width while the height stays fixed) */
.flag-img-lg { height: 64px; width: auto; max-width: 100%; object-fit: contain; align-self: flex-start; border-radius: 3px; border: 1px solid var(--outline-variant); display: block; margin-bottom: .5rem; }

/* drag-and-drop image/file upload (see templates/_image_drop.html) */
.image-drop.dragover { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 8%, transparent); }
.image-drop-prompt svg { width: 30px; height: 30px; stroke-width: 1.5; opacity: .85; }
.image-drop-file svg { width: 16px; height: 16px; }
.image-drop-actions svg { width: 15px; height: 15px; }


/* flag-aware select widget */
.flag-select { position: relative; }
.flag-select-btn { display: flex; align-items: center; gap: .5rem; width: 100%; text-align: left; cursor: pointer; }
.flag-select-panel {
  position: absolute; z-index: 40; top: calc(100% + 4px); left: 0; right: 0;
  background: var(--surface-container-lowest); border: 1px solid var(--outline-variant);
  border-radius: .375rem; box-shadow: 0 8px 24px rgba(0,0,0,.25); padding: .4rem; max-height: 280px; display: flex; flex-direction: column; gap: .35rem;
}
/* HTMX autocomplete result panels carry this class but hold no markup until a
   search swaps results in (and are cleared again on blur/outside-click) — keep
   the empty container from rendering as a stray bordered box. */
.flag-select-panel:empty { display: none; }
.flag-select-list { overflow-y: auto; }
.flag-opt-row { display: flex; align-items: center; gap: .5rem; padding: .4rem .5rem; border-radius: .25rem; cursor: pointer; }
.flag-opt-row:hover { background: var(--surface-container); }

/* Server-searched FK lookup — Tom Select over _ref_select.html's <select>
   (docs/ui_stack_migration.md, phase 2). The wrapper inherits the select's `input`
   class, so the field itself is the DaisyUI input every other field is (size,
   border, the soft focus ring via :focus-within); Tom Select's own control inside
   gives up its box. The dropdown is appended to <body> (not clipped by a list's
   scroll box or a dialog) and dressed like the old lookup panel. Tom Select's base
   stylesheet hard-codes white and grey, so every surface is restated in tokens. */
.ts-wrapper.input { padding: 0; position: relative; }
.ts-wrapper .ts-control {
  border: 0; border-radius: inherit; background: transparent; box-shadow: none; color: inherit;
  font: inherit; height: 100%; min-height: 0; padding: 0 2rem 0 .75rem; align-items: center; flex-wrap: nowrap;
}
.ts-wrapper.single .ts-control, .ts-wrapper.single.input-active .ts-control { background: transparent; cursor: pointer; }
.ts-wrapper .ts-control > .item { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ts-wrapper .ts-control > input::placeholder { color: var(--on-surface-variant); opacity: 1; }
/* the chevron the old pickers drew (Lucide chevron-down, 24px, 13px in from the
   right edge, in the text colour); Tom Select's base sheet draws none. Its own
   right padding is max(--ts-pr-min, clear button + --ts-pr-caret), so the caret
   variable keeps the text clear of the chevron (gap + icon + edge) */
.ts-wrapper.single { --ts-pr-caret: 44px; --ts-chevron: 24px; }
/* Tom Select gives its input a 7rem floor; a narrow grid column has to win */
.ts-wrapper.single .ts-control > input { min-width: 0; }
.ts-wrapper.single::after {
  content: ""; position: absolute; top: 50%; right: 12px; width: var(--ts-chevron); height: var(--ts-chevron);
  transform: translateY(-50%); background: currentColor; pointer-events: none;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
          mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
}
.ts-wrapper .clear-button { color: var(--on-surface-variant); right: 40px; }
.ts-wrapper .clear-button:hover { color: var(--on-surface); }
.ts-dropdown {
  z-index: 1000; margin-top: 4px; padding: .4rem;
  background: var(--surface-container-lowest); color: var(--on-surface);
  border: 1px solid var(--outline-variant); border-radius: .375rem; box-shadow: 0 8px 24px rgba(0,0,0,.25);
}
.ts-dropdown .ts-dropdown-content { max-height: 240px; }
.ts-dropdown .option, .ts-dropdown .ref-note { padding: .4rem .5rem; border-radius: .25rem; }
.ts-dropdown .option { cursor: pointer; }
.ts-dropdown .active { background: var(--surface-container); color: var(--on-surface); }
.ts-dropdown .ref-note { font-size: 12px; }
.ts-dropdown .dropdown-input-wrap { margin-bottom: .35rem; }
.ts-dropdown .dropdown-input {
  border: 1px solid var(--outline-variant); border-radius: var(--radius-field, .25rem);
  background: var(--surface-container-lowest); color: var(--on-surface); padding: .45rem .65rem; font: inherit;
}
.ts-dropdown .dropdown-input:focus { outline: none; border-color: var(--primary);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 22%, transparent); }
/* the multi-column lookup (autocomplete_grid): a header row, then one grid row per match */
.ts-dropdown.ref-grid { min-width: min(36rem, 92vw); }
.ts-dropdown .ref-row { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: .5rem; font-size: 13px; }
.ts-dropdown .ref-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ts-dropdown .ref-head { font-size: 11px; font-weight: 600; color: var(--on-surface-variant);
  padding: .3rem .5rem; border-bottom: 1px solid var(--outline-variant); margin-bottom: .25rem; }
/* preloaded pickers (_pick_select.html): a flag before the label, in the field and the list */
.pick-opt { display: flex; align-items: center; gap: .5rem; min-width: 0; }
.pick-opt > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* disabled: DaisyUI's disabled input (a base-200 field, faded text), which the
   wrapper — no form control itself — does not get from :disabled */
.ts-wrapper.input.disabled { cursor: not-allowed; border-color: var(--color-base-200); background-color: var(--color-base-200);
  box-shadow: none; color: color-mix(in oklab, var(--color-base-content) 40%, transparent); }
.ts-wrapper.disabled .ts-control { background: transparent; opacity: 1; cursor: not-allowed; }
.ts-wrapper.disabled .ts-control > input::placeholder { color: inherit; }
/* chips (multiple): the pills wrap and the box grows, as the old chip picker did;
   each pill is DaisyUI's badge — revert-layer hands it back from Tom Select's grey */
.ts-wrapper.input.multi { height: auto; min-height: 38px; }
.ts-wrapper.multi .ts-control { flex-wrap: wrap; gap: .3rem; padding: .3rem .75rem; min-height: 36px; cursor: text; }
.ts-wrapper.multi .ts-control > .item {
  background: revert-layer; color: revert-layer; border: revert-layer; padding: revert-layer;
  margin: 0; cursor: default; display: inline-flex; align-items: center; gap: .25rem;
}
.ts-wrapper.multi .ts-control > .item.active { background: color-mix(in srgb, var(--primary) 18%, transparent); color: var(--on-surface); }
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {   /* :not(.rtl) to outrank Tom Select's divider */
  border: 0; margin: 0; padding: 0 0 0 .1rem; color: inherit; opacity: .6; font-size: 14px; line-height: 1;
}
.ts-wrapper.plugin-remove_button .item .remove:hover { background: transparent; opacity: 1; }
.ts-wrapper.multi .ts-control > input { flex: 1 1 6ch; min-width: 6ch; }
.ts-dropdown .create { padding: .4rem .5rem; border-radius: .25rem; cursor: pointer; }
/* the phone field's country: flag and +code, as narrow as its content */
.ts-wrapper.phone-country { flex: 0 0 auto; display: block; width: auto; }
.ts-wrapper.phone-country { --ts-pr-caret: 30.8px; --ts-chevron: 14px; }   /* .3rem gap + 14px chevron + 12px */
.ts-wrapper.phone-country .ts-control { font-variant-numeric: tabular-nums; }
.ts-wrapper.phone-country .pick-opt { gap: .3rem; }
.ts-wrapper.phone-country .ts-control > input { width: 1ch; min-width: 1ch; }   /* the "+" while empty */
.ts-wrapper.phone-country .ts-control > input::placeholder { color: var(--on-surface); }

/* an inline-edit cell's lookup: compact, as the old one was */
.ts-wrapper.ref-inline { min-width: 9rem; height: auto; min-height: 0; }
.ts-wrapper.ref-inline .ts-control { padding-block: .15rem; min-height: 2rem; }   /* a line even while empty */

/* Icon picker — Tom Select over _icon_select.html's <select> (initIconSelects in
   base.html). The list is a grid as wide as the field: 64px cells, five rows tall,
   then it scrolls; the number of matches sits under it. */
.ts-dropdown.icon-grid .ts-dropdown-content {
  display: grid; gap: .25rem; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); grid-auto-rows: 64px;
  max-height: calc(64px * 5 + .25rem * 4); overflow-y: auto;
}
.ts-dropdown.icon-grid .ts-dropdown-content > .ref-note { grid-column: 1 / -1; }
.ts-dropdown .option.icon-opt {
  display: flex; align-items: center; justify-content: center; padding: 0;
  border: 1px solid transparent; border-radius: .3rem; color: var(--on-surface); font-size: 32px;
}
.ts-dropdown .option.icon-opt.active { background: var(--surface-container); border-color: var(--outline-variant); }
.ts-dropdown .option.icon-opt.selected { background: var(--primary); color: var(--on-primary); }
/* Lucide cells hold an injected <svg> (not a font glyph), so size the svg */
.icon-opt-lucide svg { width: 28px; height: 28px; }
.icon-select-foot { font-size: 11px; padding: .35rem .25rem 0; }
/* the field: the icon (18px), then the stored value */
.ts-wrapper .icon-item { display: inline-flex; justify-content: center; width: 18px; flex: none; }
.ts-wrapper .icon-item svg { width: 18px; height: 18px; }
/* a filter's date range keeps its own "×" (scClearFields clicks it) */
.icon-select-clear { display: inline-flex; cursor: pointer; color: var(--on-surface-variant); }
.icon-select-clear:hover { color: var(--on-surface); }
/* FontAwesome icon shown in a list cell — twice the surrounding text size */
table.data .list-icon { font-size: 2em; vertical-align: middle; }
/* Lucide icon shown in a list cell — an injected <svg>, sized explicitly */
table.data .list-icon-lucide { width: 1.6em; height: 1.6em; vertical-align: middle; }
/* JSON cell: a "braces" icon in the grid whose formatted value pops up
   underneath on hover. The popover (#json-hover-popover) is a single element
   appended to <body> by the handler in base.html so it escapes the table's
   overflow clipping; styles below cover both the trigger and that element. */
.json-cell:hover, .json-cell:focus-visible { color: var(--primary); outline: none; }
.json-cell .json-icon { width: 18px; height: 18px; }
#json-hover-popover {
  position: fixed; z-index: 1000; display: none; pointer-events: none;
  margin: 0; max-width: 480px; max-height: 360px; overflow: hidden;
  padding: .6rem .75rem; border-radius: .5rem;
  background: var(--surface-container-high); color: var(--on-surface);
  border: 1px solid var(--outline-variant); box-shadow: 0 8px 24px rgba(0,0,0,.35);
  font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; line-height: 1.45;
  white-space: pre; tab-size: 2;
}
/* Failed-status chip: the chip becomes a hover trigger revealing the row's
   error_text in the body-level #status-hover-popover (wired in base.html). */
.status-pop { cursor: help; }
#status-hover-popover {
  position: fixed; z-index: 1000; display: none; pointer-events: none;
  margin: 0; max-width: 420px; max-height: 320px; overflow: hidden;
  padding: .55rem .7rem; border-radius: .5rem;
  background: var(--surface-container-high); color: var(--on-surface);
  border: 1px solid var(--outline-variant); box-shadow: 0 8px 24px rgba(0,0,0,.35);
  font-size: 12px; line-height: 1.45; white-space: pre-wrap; word-break: break-word;
}
/* thumbnail image shown in a list cell (e.g. pickup-order photo) */
table.data .list-img { height: 44px; width: auto; max-width: 80px; object-fit: cover;
  border-radius: 4px; border: 1px solid var(--outline-variant); vertical-align: middle; }

/* --------------------------- image lightbox ----------------------------- */
/* A list thumbnail says it opens: the cursor is the whole affordance, so a cell
   that went through templates/_image_cell.html looks clickable and one that did
   not is left alone. */
img.ag-zoomable { cursor: zoom-in; }
/* tabindex="-1" makes the thumbnail programmatically focusable, so closing the
   viewer can hand the cursor back to it — but it is not a tab stop, and a mouse
   click must not leave a ring behind. :focus-visible keeps the outline for the
   keyboard case only. */
img.ag-zoomable:focus { outline: none; }
img.ag-zoomable:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }

/* Above every other layer: a grid can sit in a modal (a child-record tab) or in
   a nested one, so the viewer has to outrank .modal (50), .modal-nested (70) and
   the full-window text editor (85). */
.modal.ag-lightbox {
  z-index: 90; padding: 2rem; place-items: center; background-color: rgba(0, 0, 0, .8);
}
.modal.ag-lightbox:focus { outline: none; }
/* The frame is only as big as the image; its empty space is backdrop and closes
   too (see the click handler), which is what makes the viewer feel like an
   overlay rather than a dialog. */
.ag-lightbox-frame {
  position: relative; margin: 0; max-width: 100%; max-height: 100%;
  display: flex; flex-direction: column; align-items: center; gap: .5rem;
}
/* The image is bounded by the VIEWPORT, not by its natural size: a small image
   is shown at its own size (no upscaling into a blurry mess) and a large one is
   scaled down to fit whole, which is the one thing the cell could not do. */
.ag-lightbox-img {
  max-width: 100%; min-height: 0; object-fit: contain;
  border-radius: .5rem; box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
  background: var(--surface-container-lowest);
}
.ag-lightbox-bar {
  display: flex; align-items: center; gap: .35rem; width: 100%; flex: 0 0 auto;
  color: #fff;
}
.ag-lightbox-bar .btn-icon { color: #fff; }
.ag-lightbox-bar .btn-icon:hover { background: rgba(255, 255, 255, .15); color: #fff; }
.ag-lightbox-counter { font-size: 12px; font-variant-numeric: tabular-nums; opacity: .75; }
/* The caption is the stored path — the same string the cell shows on hover. It
   wraps rather than stretching the frame, which would resize the image with it. */
.ag-lightbox-caption {
  flex: 0 0 auto; max-width: 100%; color: #fff; opacity: .8;
  font-size: 12px; text-align: center; word-break: break-all;
}
/* The arrows sit ON the image, vertically centred, and are hidden outright when
   the grid holds a single image (the `hidden` attribute the opener sets). */
.ag-lightbox-nav {
  position: absolute; top: 50%; transform: translateY(-50%); z-index: 1;
  color: #fff; background: rgba(0, 0, 0, .45);
}
.ag-lightbox-nav:hover { background: rgba(0, 0, 0, .7); color: #fff; }
.ag-lightbox-nav.prev { left: .5rem; }
.ag-lightbox-nav.next { right: .5rem; }
.ag-lightbox-nav[hidden] { display: none; }

/* --------------------------------------------------------------------------
   PivotTable.js — themed to match the app (scoped to #pivot-output, so the
   ID selectors win over the library's default class rules in either theme).
   -------------------------------------------------------------------------- */
#pivot-output { font-size: 13px; color: var(--on-surface); }
#pivot-output table.pvtUi { color: var(--on-surface); border-collapse: separate; border-spacing: 8px; }
#pivot-output table.pvtUi > tbody > tr > td { padding: 0; vertical-align: top; }
/* PivotTable.js builds its layout rows straight under the table (no tbody) and spaces
   them with cellpadding="5" — said here, now that the base layer no longer hands
   table cells their HTML-attribute padding back (:where, so the drop zones below keep theirs) */
#pivot-output :where(table.pvtUi > tr > td) { padding: 5px; }

/* drop zones (unused / rows / cols / vals) */
#pivot-output .pvtAxisContainer,
#pivot-output td.pvtVals {
  background: var(--surface-container-low);
  border: 1px solid var(--outline-variant);
  border-radius: .5rem;
  padding: .4rem;
}
#pivot-output .pvtAxisContainer.pvtHorizList li,
#pivot-output .pvtAxisContainer.pvtVertList li { margin: .15rem; }

/* draggable attribute pills (look like the app's chips) */
#pivot-output .pvtAxisContainer li span.pvtAttr {
  background: var(--surface-container-highest);
  border: 1px solid var(--outline-variant);
  color: var(--on-surface);
  border-radius: 999px;
  padding: .2rem .55rem;
  font-size: 12px;
  white-space: nowrap;
  box-shadow: var(--shadow);
}
#pivot-output .pvtAxisContainer li.pvtPlaceholder {
  border: 1px dashed var(--primary);
  border-radius: 999px;
}
#pivot-output .pvtTriangle { color: var(--on-surface-variant); cursor: pointer; }

/* renderer / aggregator selectors + native selects */
#pivot-output .pvtRenderers,
#pivot-output .pvtVals { line-height: 1.8; }
#pivot-output select.pvtRenderer,
#pivot-output select.pvtAggregator,
#pivot-output select.pvtAttrDropdown,
#pivot-output .pvtVals select {
  border: 1px solid var(--outline-variant);
  border-radius: .375rem;
  background: var(--surface-container-lowest);
  color: var(--on-surface);
  padding: .25rem .45rem;
  font-size: 12px;
}
#pivot-output a.pvtRowOrder,
#pivot-output a.pvtColOrder { color: var(--on-surface-variant); text-decoration: none; }

/* the result table */
/* No `overflow: hidden` here, though the border-radius below would like one: it
   would make the TABLE its own scrollport, and the sticky header further down
   would then pin to a box that never scrolls — the same trap .table-wrap falls
   into. Square corners on a collapsed-border table is the cheaper loss. */
#pivot-output table.pvtTable { border-collapse: collapse; font-size: 13px; box-shadow: var(--shadow); border-radius: .5rem; }
#pivot-output table.pvtTable thead tr th,
#pivot-output table.pvtTable tbody tr th {
  background: var(--surface-container);
  color: var(--on-surface-variant);
  border: 1px solid var(--outline-variant);
  font-weight: 600;
  padding: .45rem .65rem;
  text-align: left;
}
/* The column titles stay put while the pivot scrolls — #pivot-output is a bounded
   scroll container already, so this is all it takes. `top` is NOT set here: a pivot
   has one header ROW per column attribute, and a single `top: 0` would stack them all
   at the same line. templates/_pivot.html measures the rows after each render and
   stamps each one's offset (stickPivotHead). Until it does, an unset `top` leaves the
   header in normal flow rather than pinned over its own second row. */
#pivot-output table.pvtTable thead tr th { position: sticky; z-index: 2; }
#pivot-output table.pvtTable tbody tr td {
  background: var(--surface-container-lowest);
  color: var(--on-surface);
  border: 1px solid var(--outline-variant);
  padding: .4rem .65rem;
  text-align: right;
}
#pivot-output table.pvtTable .pvtTotalLabel,
#pivot-output table.pvtTable .pvtTotal,
#pivot-output table.pvtTable .pvtGrandTotal {
  background: var(--surface-container-high);
  color: var(--on-surface);
  font-weight: 600;
}
#pivot-output table.pvtTable tbody tr td:hover { background: var(--surface-container-low); }

/* attribute filter popup */
#pivot-output .pvtFilterBox {
  background: var(--surface-container-lowest);
  border: 1px solid var(--outline-variant);
  border-radius: .5rem;
  box-shadow: 0 12px 32px rgba(0,0,0,.25);
  color: var(--on-surface);
}
#pivot-output .pvtFilterBox h4 { color: var(--on-surface); }
#pivot-output .pvtFilterBox p { color: var(--on-surface-variant); }
#pivot-output .pvtFilterBox input[type="text"],
#pivot-output .pvtFilterBox .pvtSearch {
  border: 1px solid var(--outline-variant);
  border-radius: .375rem;
  background: var(--surface-container-lowest);
  color: var(--on-surface);
  padding: .35rem .5rem;
}
#pivot-output .pvtFilterBox .pvtCheckContainer p { border-radius: .25rem; }
#pivot-output .pvtFilterBox .pvtCheckContainer p:hover { background: var(--surface-container-low); }
#pivot-output .pvtFilterBox .pvtCheckContainer p.selected { background: var(--secondary-container); color: var(--on-secondary-container); }
#pivot-output .pvtButton {
  border: 1px solid var(--outline-variant);
  border-radius: .25rem;
  background: var(--surface-container);
  color: var(--on-surface);
  padding: .25rem .6rem;
  font-size: 12px;
  cursor: pointer;
}
#pivot-output .pvtButton:hover { background: var(--surface-container-high); }

/* Toolbar dropdowns — the export format menu (templates/_export_menu.html, used
   by the Grid popup and the manifest grid) and the list toolbar's "Actions" menu
   (templates/_actions_menu.html), which folds the analytics tools, the
   table-specific actions and those same export formats into one button. Both
   reuse the icon-picker panel look; the button anchors the panel to its right
   edge. */
.export-menu, .actions-menu { position: relative; display: inline-block; }
.export-menu-panel, .actions-menu-panel {
  position: absolute; z-index: 60; top: calc(100% + 4px); right: 0; min-width: 170px;
  background: var(--surface-container-lowest); border: 1px solid var(--outline-variant);
  border-radius: .375rem; box-shadow: 0 8px 24px rgba(0,0,0,.25); padding: .25rem;
  display: flex; flex-direction: column; gap: .1rem;
}
.export-menu-item, .actions-menu-item {
  display: flex; align-items: center; gap: .5rem; width: 100%; text-align: left;
  padding: .4rem .6rem; border: none; background: transparent; color: var(--on-surface);
  border-radius: .3rem; cursor: pointer; font-size: 13px; white-space: nowrap;
}
.export-menu-item:hover, .actions-menu-item:hover { background: var(--surface-container); text-decoration: none; }
/* menu items are <button>s, except the wizard launchers, which are links */
a.actions-menu-item { text-decoration: none; }
/* divider before the export formats (last section of the Actions menu) */
.actions-menu-sep { height: 1px; margin: .25rem .3rem; background: var(--outline-variant); }

/* ------------------------- keyboard shortcuts -------------------------- */
/* Standard chords are declared once in templates/base.html and attached to
   markup with data-shortcut="<action>"; these styles are what the user sees of
   them — the key chip on menu items and form buttons, the focused-row cursor,
   and the Alt+/ cheat-sheet. */
.sc-kbd {
  font-family: var(--font-mono, monospace); font-size: 10px; line-height: 1;
  padding: .2rem .32rem; margin-left: auto;
  border: 1px solid var(--outline-variant); border-bottom-width: 2px; border-radius: .25rem;
  background: var(--surface-container); color: var(--on-surface-variant); white-space: nowrap;
}
/* inside a footer button the chip sits next to the label, not pushed to the far
   right (that is the menu-item layout) */
.btn > .sc-kbd, .btn-sm > .sc-kbd, .tab > .sc-kbd { margin-left: .45rem; }
/* on a filled button (primary, error) the chip borrows the button's own text
   colour instead of the page's surface tokens, which would fight the fill */
.btn-primary > .sc-kbd, .btn-error > .sc-kbd {
  color: inherit;
  background: color-mix(in srgb, currentColor 18%, transparent);
  border-color: color-mix(in srgb, currentColor 35%, transparent);
}

/* Row cursor — ↑/↓ move it, Enter opens the row, Del deletes it. Focusable rows
   carry tabindex (templates/table.html); the marker is a left bar plus a tint so
   it reads without relying on colour alone. */
table.data tbody tr[tabindex]:focus { outline: none; background: var(--surface-container); }
table.data tbody tr[tabindex]:focus > td:first-child { box-shadow: inset 3px 0 0 var(--primary); }
table.data tbody tr[tabindex]:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }

/* Cheat-sheet (Alt+/) — two columns of action → chord, grouped by scope. */
.sc-help-group h4 {
  margin: 0 0 .35rem; font-size: 11px; font-weight: 600; letter-spacing: .04em;
  text-transform: uppercase; color: var(--on-surface-variant);
}
.sc-help-row:last-child { border-bottom: none; }
.sc-help-row .sc-kbd { margin-left: 0; }
/* dimmed: the action has no target on this page */
.sc-help-row.sc-off { opacity: .45; }

/* Export chooser (templates/_export_modal.html) — the one "Export" entry in the
   Actions menu opens this instead of spilling a menu row per format. One row per
   format: icon, label + what the file is, and a download affordance on the right. */
.export-choice:hover, .export-choice:focus-visible {
  background: var(--surface-container); border-color: var(--primary); outline: none;
}
.export-choice > span { display: flex; flex-direction: column; gap: .1rem; min-width: 0; }
.export-choice strong { font-size: 13px; font-weight: 600; }
.export-choice .muted { font-size: 11px; }
.export-choice > i:first-child { width: 20px; height: 20px; flex: 0 0 auto; color: var(--primary); }
.export-choice-go { width: 16px; height: 16px; margin-left: auto; flex: 0 0 auto; color: var(--on-surface-variant); }

/* --------------------------------------------------------------------------- */
/*  Hierarchical tree + detail view (ui/tree_routes.py, templates/tree/)       */
/* --------------------------------------------------------------------------- */
/* Full-bleed two-pane layout: the content padding is dropped so the tree and
   detail panes own the whole viewport below the topbar and scroll on their own. */
.app:has(.pt-page) .content { padding: 0; }
.pt-page {
  display: grid;
  /* tree | drag handle | detail. Tree width is drag-adjustable via --pt-tree-w
     (set by the resizer JS in tree/page.html); clamped to a sane default. */
  grid-template-columns: var(--pt-tree-w, 300px) 6px 1fr;
  height: calc(100dvh - 3.25rem);   /* viewport minus the sticky topbar */
  min-height: 0;
}
.pt-resizer {
  cursor: col-resize;
  background: transparent;
  margin-left: -1px;                /* sit over the tree's right border */
  transition: background .12s ease;
}
.pt-resizer:hover, .pt-resizer.dragging { background: var(--primary); }
.pt-tree {
  border-right: 1px solid var(--outline-variant);
  background: var(--surface-container-lowest);
  display: flex; flex-direction: column; min-height: 0;
}
.pt-tree-head {
  display: flex; align-items: center; justify-content: space-between; gap: .5rem;
  padding: .65rem .75rem; border-bottom: 1px solid var(--outline-variant);
}
.pt-tree-title { margin: 0; font-size: 14px; display: flex; align-items: center; gap: .4rem; }
.pt-tree-title i { width: 18px; height: 18px; }
.pt-roots { flex: 1; overflow-y: auto; padding: .4rem; min-height: 0; }

/* tree node rows */
.pt-node { display: flex; flex-direction: column; }
.pt-row { display: flex; align-items: center; gap: .1rem; border-radius: .35rem; position: relative; }
.pt-row:hover { background: var(--surface-container); }
.pt-twirl {
  flex: 0 0 auto; width: 22px; height: 26px; display: inline-flex;
  align-items: center; justify-content: center; border: none; background: transparent;
  color: var(--on-surface-variant); cursor: pointer; border-radius: .3rem;
}
.pt-twirl i { width: 15px; height: 15px; transition: transform .15s ease; }
.pt-twirl.open i { transform: rotate(90deg); }
.pt-labelbtn {
  flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: .45rem;
  border: none; background: transparent; color: var(--on-surface); cursor: pointer;
  text-align: left; padding: .32rem .4rem; border-radius: .35rem; font-size: 13px;
}
.pt-labelbtn.active { background: var(--secondary-container); color: var(--on-secondary-container); }
.pt-rootbtn { font-weight: 500; }
.pt-ico { display: inline-flex; flex: 0 0 auto; color: var(--on-surface-variant); }
.pt-ico i { width: 16px; height: 16px; }
.pt-labelbtn.active .pt-ico { color: inherit; }
.pt-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pt-kids { display: flex; flex-direction: column; padding-left: 1.15rem; gap: 1px; }

/* branch (child-tab) rows */
.pt-branch > .pt-row { padding-right: .35rem; }
.pt-branch-label { flex: 1 1 auto; font-size: 12.5px; color: var(--on-surface-variant);
  text-transform: uppercase; letter-spacing: .03em; padding: .3rem 0; }
.pt-branch-ico i { width: 15px; height: 15px; }
.pt-count {
  flex: 0 0 auto; font-size: 11px; min-width: 1.2rem; text-align: center;
  padding: .05rem .35rem; border-radius: 1rem; background: var(--surface-container-high);
  color: var(--on-surface-variant);
}

/* leaf rows */
.pt-dot { flex: 0 0 auto; width: 5px; height: 5px; border-radius: 50%;
  background: var(--on-surface-variant); opacity: .55; }
.pt-labelbtn.active .pt-dot { background: currentColor; opacity: 1; }
.pt-leaf-empty { font-size: 12px; padding: .3rem .5rem .3rem 1rem; }
/* drag handle on an orderable leaf: the same grip the lists use, sized for the
   tree row and kept out of sight until the row is under the pointer — its space
   is always reserved, so nothing shifts when it appears */
.pt-drag { flex: 0 0 auto; opacity: 0; padding: .1rem; }
.pt-drag i, .pt-drag svg { width: 13px; height: 13px; }
.pt-row:hover .pt-drag, .pt-node[draggable="true"] .pt-drag { opacity: .55; }
.pt-drag:hover { opacity: 1; }
/* clipboard affordances on a node row (copy / cut / paste). The cluster floats
   over the right end of the row rather than sitting in its flow, because the tree
   pane is narrow and the label is worth more than the buttons: it appears on hover
   (or whenever a paste is possible, which is the cue that the clipboard has
   somewhere to go) and covers the tail of the label while it is there. */
.pt-acts {
  position: absolute; right: .15rem; top: 50%; transform: translateY(-50%);
  display: none; align-items: center; gap: .05rem; padding: .05rem .1rem;
  border-radius: .35rem; background: var(--surface-container-high);
  box-shadow: 0 0 0 1px var(--outline-variant);
}
/* hover only reveals a cluster that HAS something to reveal (pt-acts-hover — a
   record the user may copy or cut); a paste-only cluster, on a branch or on a root,
   shows exactly when the clipboard fits it and is otherwise not there at all */
.pt-row:hover .pt-acts-hover, .pt-acts-hover:focus-within, .pt-acts.pt-acts-on { display: flex; }
.pt-act {
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
  width: 20px; height: 20px; padding: 0; border: none; background: transparent;
  color: var(--on-surface-variant); cursor: pointer; border-radius: .25rem;
}
.pt-act:hover { color: var(--primary); background: var(--surface-container); }
.pt-act i, .pt-act svg { width: 13px; height: 13px; }
.pt-act-paste { color: var(--primary); }
/* the record currently held as a CUT — still where it was, but on its way out */
.pt-cut > .pt-row > .pt-labelbtn { opacity: .5; font-style: italic; }

/* Drag and drop across the tree (page.html's drag layer). A drag fades the record
   being moved and outlines every list that takes it — the same places its Paste
   button would have appeared — so the tree answers "where can this go?" before the
   pointer gets there. The row under the pointer fills in as the one that would take
   the drop. Rows that take nothing are left alone rather than dimmed: the marked
   ones are few, and dimming a whole tree to point at three rows reads as breakage. */
.pt-node.pt-dragging > .pt-row, tr.pt-grid-row.pt-dragging { opacity: .45; }
.pt-drop-ok {
  outline: 1px dashed color-mix(in srgb, var(--primary) 55%, transparent);
  outline-offset: -1px; border-radius: .35rem;
}
.pt-drop-active {
  outline-style: solid; outline-color: var(--primary);
  background: color-mix(in srgb, var(--primary) 15%, transparent);
}
/* the detail pane's grid is a destination too — a whole panel rather than a row, so
   it takes the marker at arm's length and tints instead of filling */
.pt-grid.pt-drop-ok { outline-offset: .5rem; border-radius: .5rem; }
.pt-grid.pt-drop-active { background: color-mix(in srgb, var(--primary) 7%, transparent); }
/* while a drag is in flight a row's own controls step out of the way, so every drag
   event lands on the element carrying the marker instead of on whichever button
   happens to be under the pointer */
.pt-dnd .pt-labelbtn, .pt-dnd .pt-twirl, .pt-dnd .pt-acts, .pt-dnd .pt-drag,
.pt-dnd .pt-grid .btn-icon { pointer-events: none; }

/* the panel head's own actions (copy / cut / delete), kept on one line */
.pt-panel-actions { display: flex; align-items: center; gap: .15rem; flex: 0 0 auto; }

.pt-add {
  display: inline-flex; align-items: center; gap: .35rem; align-self: flex-start;
  margin: .15rem 0 .3rem .5rem; padding: .25rem .5rem; border: 1px dashed var(--outline-variant);
  background: transparent; color: var(--on-surface-variant); border-radius: .35rem;
  cursor: pointer; font-size: 12px;
}
.pt-add:hover { color: var(--primary); border-color: var(--primary); }
.pt-add i { width: 14px; height: 14px; }

/* detail pane */
/* min-width:0 so a wide list scrolls INSIDE .table-wrap instead of stretching the
   grid column (a grid item's auto minimum is its content) and squeezing the tree */
.pt-detail { overflow-y: auto; min-height: 0; min-width: 0; padding: 1.25rem 1.5rem; }
/* When the pane's whole subject is a grid (a branch opened from the tree), the pane
   stops being the scroller and hands its height to the grid instead, so the column
   titles pin to the top of the pane rather than scrolling away with the rows. The
   pane is bounded already (.pt-page is a viewport-height grid), so there is a height
   to hand down. A form tab keeps the pane's normal flow — only .grid-fill opts in.
   The bottom padding goes because the wrap now reaches the pane's edge; the rows
   scroll under it, and a gap there would just be dead space below the scrollbar. */
.pt-detail:has(> .grid-fill) { display: flex; flex-direction: column; overflow: hidden; padding-bottom: 0; }
.pt-detail > .grid-fill { flex: 1 1 auto; }
.pt-panel { max-width: 760px; }
.pt-panel-head {
  display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem;
  padding-bottom: .85rem; margin-bottom: 1rem; border-bottom: 1px solid var(--outline-variant);
}
.pt-panel-title { display: flex; align-items: center; gap: .6rem; min-width: 0; }
.pt-panel-title h2 { margin: 0; font-size: 18px; }
.pt-panel-title .pt-ico i { width: 22px; height: 22px; }
.pt-crumb { font-size: 12px; margin-top: .1rem; }
.pt-section-h { margin: .25rem 0 .5rem; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.pt-form-actions { display: flex; justify-content: flex-end; gap: .5rem; padding-top: .5rem; }

/* empty placeholder */
.pt-empty {
  height: 100%; display: flex; flex-direction: column; align-items: center;
  justify-content: center; gap: .6rem; text-align: center; padding: 2rem;
}
.pt-empty-ico { width: 34px; height: 34px; color: var(--on-surface-variant); opacity: .6; }

/* stack the panes on narrow screens */
@media (max-width: 760px) {
  .pt-page { grid-template-columns: 1fr; grid-template-rows: 45% 55%; height: calc(100dvh - 3.25rem); }
  .pt-tree { border-right: none; border-bottom: 1px solid var(--outline-variant); }
  .pt-resizer { display: none; }   /* panes stack vertically — no horizontal drag */
}

/* branch label is now a button (opens the list grid); keep the count right-aligned */
.pt-branch-btn { gap: .45rem; }
.pt-branch-btn .pt-branch-label { flex: 1 1 auto; }

/* branch list grid in the detail pane */
/* A list fills the pane. Only the FORM is measured (.pt-panel, 760px): a form is
   read line by line and long lines hurt it, while a grid is scanned across and
   every column capped away is one the user has to scroll to find. */
.pt-grid { max-width: none; }
.pt-grid-table { width: 100%; }
.pt-grid-row:hover { background: var(--surface-container); }
.pt-grid-empty {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: .6rem; text-align: center; padding: 2.5rem 1rem; color: var(--on-surface-variant);
  border: 1px dashed var(--outline-variant); border-radius: .5rem;
}
/* the head's New buttons — one per kind on a merged branch, so they wrap rather
   than push the title */
.pt-grid-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .35rem; }
/* Panel overview tabs (_tree_panel.html + _tree_panel_grid.html). The width follows
   the active tab: the form keeps .pt-panel's 760px line length, and only a grid tab
   widens to the whole pane — the same split .pt-grid already makes between a form
   that is read line by line and a list that is scanned across. */
.pt-panel-tabbed.pt-panel-wide { max-width: none; }
.pt-ptabs { padding: .1rem 0 0; margin-bottom: .8rem; }
.pt-ptab-head { padding: 0 0 .6rem; font-size: 12px; }
/* the column-group band inside the overview: a header row, not a data row, so it
   is not hoverable and its cell spans the table */
.pt-group-head td {
  background: var(--surface-container); border-top: 1px solid var(--outline-variant);
  padding: .35rem .5rem;
}
.pt-group-btn {
  display: inline-flex; align-items: center; gap: .45rem; cursor: pointer;
  background: none; border: 0; padding: .1rem 0; font: inherit; font-weight: 500;
  color: var(--on-surface);
}
.pt-group-btn:hover { color: var(--primary); }
/* the band is a row of its own: the group on the left, its "New …" buttons pushed
   to the right, wrapping rather than squeezing the label on a narrow pane */
.pt-group-band { display: flex; align-items: center; justify-content: space-between; gap: .75rem; flex-wrap: wrap; }
.pt-group-adders { display: flex; flex-wrap: wrap; gap: .3rem; }
.pt-group-btn i { width: 15px; height: 15px; }
.pt-group-n { font-weight: 400; font-size: 12px; }
.pt-group-empty { padding: .5rem .75rem; font-size: 12px; }

/* which table a row of a merged branch's unified grid came from */
.pt-kind { white-space: nowrap; color: var(--on-surface-variant); }
.pt-kind i { width: 14px; height: 14px; vertical-align: -2px; margin-right: .3rem; }

/* alignment spacer where a record node has no twirl (terminal table) */
.pt-twirl-spacer { flex: 0 0 auto; width: 22px; height: 26px; display: inline-block; }

/* Match counts painted onto the tree after a search (ptApplyTally). Deliberately
   the accent colour: a node carries two numbers then — how many rows it holds
   (.pt-count) and how many of them the search hit — and only the second one is
   about what the user is doing right now. Pushed to the end of the row so the
   badges line up down the tree instead of drifting with the label width. */
.pt-hits {
  background: var(--primary); color: var(--on-primary); font-weight: 500;
  margin-left: auto;
}
/* on a branch the row count is already at the end: sit next to it, not past it */
.pt-branch-btn .pt-hits { margin-left: .25rem; }

/* The make-up of a collapsed node's total, by table ("Table manifests 9 · Column
   manifests 4 · +2 more") — on records and on branch rows alike, so the user can
   tell what is down there before opening it. It exists only for shut nodes: an
   open one shows its children, each with its own count, and repeating that would
   be noise. Driven off the twirl's own `open` class rather than any state in JS —
   the same class Alpine already toggles to rotate the chevron. */
.pt-split {
  font-size: 10.5px; line-height: 1.3; padding: 0 .4rem .25rem 2.35rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pt-node:has(> .pt-row > .pt-twirl.open) > .pt-split { display: none; }
.pt-labelbtn.active .pt-hits { background: var(--on-secondary-container); color: var(--secondary-container); }

/* Find & replace across the hierarchy (templates/tree/_tree_search*.html) ----- */
.pt-search { max-width: 900px; }
/* find and replace-with sit side by side — they are read as a pair */
.pt-search-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 .75rem; }
.pt-search-opts { display: flex; align-items: center; flex-wrap: wrap; gap: .9rem; padding: .1rem 0 .4rem; }
.pt-search-opt { display: flex; align-items: center; gap: .4rem; font-size: 13px; cursor: pointer; }
.pt-search-scope { flex: 1 1 220px; margin-left: auto; margin-bottom: 0; }
.pt-search-note { font-size: 11.5px; margin: 0 0 .4rem; }
.pt-search-summary {
  display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem;
  flex-wrap: wrap; padding: .7rem 0 .5rem; margin-top: .6rem;
  border-top: 1px solid var(--outline-variant);
}
.pt-search-actions { display: flex; gap: .4rem; }
.pt-search-none { display: flex; align-items: center; gap: .5rem; padding: 1.2rem 0; }
.pt-search-none i { width: 18px; height: 18px; }
.pt-search-group { margin-bottom: .9rem; }
/* one match = one tick box + the record it sits on, its column and the excerpt */
.pt-match {
  display: flex; align-items: flex-start; gap: .55rem; padding: .4rem .5rem;
  border-radius: .35rem;
}
.pt-match:hover { background: var(--surface-container); }
.pt-match-tick { padding-top: .15rem; }
.pt-match-body { min-width: 0; flex: 1 1 auto; }
.pt-match-head { display: flex; align-items: center; gap: .45rem; flex-wrap: wrap; }
.pt-match-name { font-weight: 500; }
.pt-match-col { font-size: 11.5px; }
.pt-match-path { font-size: 11.5px; margin-top: .05rem; }
.pt-match-snip {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px;
  margin-top: .2rem; overflow-wrap: anywhere; color: var(--on-surface-variant);
}
.pt-match-snip mark {
  background: color-mix(in srgb, var(--primary) 30%, transparent);
  color: inherit; border-radius: .15rem; padding: 0 .1rem;
}

/* Column-group tints (AGColumnGroupManifest.ui_tint) ------------------------- */
/* A table with many column groups renders as one long run of fields, and the
   group headings alone do not carry the eye — so a group may declare which of two
   alternating backgrounds it is drawn with, and the blocks become legible at a
   glance. A table with only one or two groups leaves it unset and gets no tint.

   Deliberately barely-there: the tint is mixed from the page's own surface token
   rather than a fixed colour, so it follows every theme (light, dark, and a
   generated project's palette) and never competes with a status chip or a
   validation error. "b" is the untinted slot — the alternation reads as
   tint/no-tint, which is quieter than two competing colours and degrades to
   nothing if a theme's surfaces are already close together. */
.form-section.ag-tint-a {
  background: color-mix(in srgb, var(--surface-container) 55%, transparent);
  border-radius: .4rem;
  padding: .5rem .6rem;
  /* pull the padding back out so a tinted section still lines up with an
     untinted one — only the background inset moves, not the fields */
  margin-left: -.6rem;
  margin-right: -.6rem;
}
.form-section.ag-tint-b { background: transparent; }

/* The same idea in a list: a grid that declares a band_column draws the rows
   sharing a referenced row on one background, so where one group ends and the next
   begins is visible while scrolling (templates/table.html sets the class). */
table.data > tbody > tr.ag-tint-a > td {
  background: color-mix(in srgb, var(--surface-container) 45%, transparent);
}
/* the row cursor and hover still have to win over the band */
table.data > tbody > tr.ag-tint-a:hover > td { background: var(--surface-container); }

/* The llm_log burn summary is a second .table-wrap in #log-table, and the rule
   above would treat it as a second scrolling pane: sharing the leftover height
   with the rows, then growing a vertical scrollbar over three lines of totals.

   It is a footer, so it sizes to its contents. `flex: 0 0 auto` is what removes
   the scrollbar, and the `0 0` matters in both directions: it never GROWS to fill
   space it does not need, and it never SHRINKS below its content — a shrunk box
   would overflow and the scrollbar would come straight back. The data table above
   it is the flexible one and absorbs the whole window instead.

   overflow stays `auto` on both axes rather than becoming `hidden`: it can no
   longer overflow vertically, so the y scrollbar never appears, and `hidden`
   would only mean that a summary listing an implausible number of models is
   silently cut off instead of scrolled. Horizontal scrolling is load-bearing —
   eight numeric columns do not fit a narrow window. */
#log-table > .llm-summary-wrap {
  --table-wrap-max-h: none; flex: 0 0 auto; min-height: 0; overflow: auto; margin-top: .6rem;
}
/* Nothing scrolls under it, so the sticky header has nothing to stick to and
   only costs a stacking context. */
#log-table > .llm-summary-wrap table.data thead th { position: static; }

/* A form's Save/Create button shows its icon at rest and a spinner while the save
   is in flight. The two are stacked in ONE grid cell and swapped by visibility, so
   the button keeps its exact size through the swap and its label never shifts —
   and at rest the spinner costs nothing. A bare .htmx-indicator (above) only fades
   to transparent, which still reserves its 16px: that is what left Save looking
   like it was holding an icon slot it never drew. htmx marks whatever made the
   request — the button when the hx-* is on it, the form around it when it is on
   the form — and either is an ancestor of the box.
   Only those two are asked for, never a bare `.htmx-request` ancestor: a
   source-less `htmx.ajax(...)` marks document.body instead, which is an ancestor
   of every box on the page. That is how expanding a tree node — or any other
   navigation that loads through htmx.ajax — used to put a spinner on the open
   record's Save button while nothing was being saved. */
.btn-swap-box { display: inline-grid; place-items: center; }
.btn-swap-box > * { grid-area: 1 / 1; }
.btn-swap-box > .htmx-indicator { visibility: hidden; opacity: 1; }
button.htmx-request .btn-swap-box > .htmx-indicator,
form.htmx-request .btn-swap-box > .htmx-indicator { visibility: visible; }
button.htmx-request .btn-swap-box > svg,
button.htmx-request .btn-swap-box > i,
form.htmx-request .btn-swap-box > svg,
form.htmx-request .btn-swap-box > i { visibility: hidden; }

/* ------------------- bookmarks modal rows (_bookmarks.html) -------------------
   One grid for the whole list rather than a flex row per bookmark, so the chip
   and name columns size to the widest entry and stay aligned down the list while
   the description input takes whatever is left. Each row is display:contents, so
   its cells are the grid's own items. A name longer than the space available
   ellipsises (minmax(0, max-content)) instead of sliding under the input. */
.bm-rows.no-select { grid-template-columns: minmax(0, max-content) minmax(0, max-content) minmax(6rem, 1fr) auto; }
.bm-rows > .bm-span { grid-column: 1 / -1; }
.bm-row > .kind-chip { justify-self: start; }   /* a pill, not a column-wide bar */
/* The table chip links to that table's list, narrowed to the bookmarks. It stays
   a pill rather than becoming underlined text — the lift on hover is the
   affordance, the way the row buttons beside it work. A chip with no href (a
   table the UI cannot show) is not a link and gets none of this. */
a.kind-chip[href]:hover { text-decoration: none; background: var(--surface-container-highest); color: var(--on-surface); }

/* three levels, indented so "all → schema → table" reads as a hierarchy */
.bm-tables-menu label { display: flex; align-items: center; gap: .5rem; cursor: pointer;
  padding: .3rem .4rem; border-radius: .3rem; font-size: 13px; white-space: nowrap; }
.bm-tables-menu label:hover { background: var(--surface-container); }
.bm-tables-menu label > span:first-of-type { flex: 1; }
.bm-tables-table { padding-left: 1.4rem !important; }
/* the count beside each row — present, not loud */
.bm-tables-menu .muted { font-size: 11px; font-variant-numeric: tabular-nums; }
/* the button says a filter is on, the chip says how much of the list survives it */
.bm-tables .btn.active { color: var(--warning); border-color: var(--warning); }
.bm-tables .btn .badge { font-size: 10px; padding: .05rem .35rem; }

/* ------------------------- console log (captured output) -------------------------
   Captured script output, rendered by templates/filters.py:ansi. A <pre> and not
   a <textarea> because scripts colourise their own output and a textarea can only
   show those escape sequences as text. The .ansi-* colours
   map to theme tokens rather than the literal terminal palette, so the log stays
   readable on a light and a dark surface alike. */
.console-log {
  margin: 0; padding: .6rem; width: 100%; height: 50vh;
  background: var(--surface-container); border: 1px solid var(--outline-variant);
  border-radius: var(--radius-md, .375rem); color: var(--on-surface);
  font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; line-height: 1.5;
  white-space: pre; overflow: auto; resize: vertical; tab-size: 4;
}
.ansi-bold { font-weight: 700; }
.ansi-dim { opacity: .7; }
.ansi-italic { font-style: italic; }
.ansi-underline { text-decoration: underline; }
.ansi-black { color: var(--on-surface-variant); }
.ansi-red { color: var(--error, #ba1a1a); }
.ansi-green { color: var(--success, #00733e); }
.ansi-yellow { color: var(--warning, #8a5a00); }
.ansi-blue { color: var(--primary, #003d9b); }
.ansi-magenta { color: var(--violet, #6750a4); }
.ansi-cyan { color: var(--tertiary, #00418a); }
.ansi-white { color: var(--on-surface); }
