/* Shared chrome CSS for tdesk-app — every color/spacing/radius value below reads from a theme slot
   (Tad\App\Theme\ThemeSlots) via var(--slot, #fallback); the fallback is the literal value tdesk-app
   used before the theme system existed (Tad\App\Theme\ThemeSlots::defaults()'s own 'light' entries),
   so a main module with no theme assigned renders exactly as it always did. Never hardcode a color/
   spacing/radius directly in a template — add or reuse a slot instead.

   Margin AND padding are per ZONE (btn/card/field/chip/sidebar/dropdown/header/page — the same 6
   component families radius already uses, plus header/page, which radius has no use for), matching
   Tad\App\Theme\ThemeSlots::LENGTH_SLOTS exactly — a button's own breathing room is never forced to
   match a card's. Within one zone, a spot that needs a smaller/larger amount than that zone's own
   base still scales it via calc(var(--padding-{zone}, ...) * N) — same 0.25/0.5/1/1.5/2-ish ratios
   as before, just relative to the zone's own number instead of one shared global number. Radius
   already worked this way (radius_btn/card/field/chip/sidebar/dropdown) — margin/padding now mirror
   it exactly, replacing the single generic --margin/--padding this file used to have. */

* {
    box-sizing: border-box;
}

html {
    font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}

body {
    margin: 0;
    background: var(--body-bg, #ffffff);
    color: var(--body-text, #333333);
}

h1, h2, h3, h4, h5, h6 {
    color: var(--heading-text, #333333);
}

a {
    color: var(--accent, #3b82f6);
}

.app-page {
    max-width: 480px;
    margin: 5vh auto;
    padding: 0 var(--padding-page, 1rem);
}

.admin-page {
    max-width: 800px;
    margin: 2rem auto;
    padding: 0 var(--padding-page, 1rem);
}

.theme-page {
    max-width: 1100px;
    margin: 2rem auto;
    padding: 0 var(--padding-page, 1rem);
}

.login-page {
    max-width: 360px;
    margin: 10vh auto;
    text-align: center;
    padding: 0 var(--padding-page, 1rem);
}

/* src/templates/password/form.php — since Tad\App\Auth\Routes::$header_template now renders this
   page inside the real app chrome (Tad\App\Module\Chrome::open()/close(), #content is full-width),
   the form itself stays narrow/single-column rather than stretching to that full width — same
   narrow-column spirit as .login-page above, just left-aligned under a real <h1> instead of centered
   on a bare page. */
.password-form {
    max-width: 360px;
}

.login-field {
    text-align: left;
    margin-bottom: var(--margin-field, 1rem);
}

.login-field label {
    display: block;
    font-size: 0.85rem;
    color: var(--body-text, #333333);
    margin-bottom: 0.25rem;
}

.login-submit {
    width: 100%;
    padding: var(--padding-btn, 1rem);
    border: none;
    border-radius: var(--radius-btn, 4px);
    background: var(--btn-bg, #3b82f6);
    color: var(--btn-text, #ffffff);
    font-size: 1rem;
    cursor: pointer;
}

.login-alert {
    background: var(--status-ko-bg, #fdecea);
    color: var(--status-ko-text, #b3261e);
    border: 1px solid var(--status-ko-bg, #fdecea);
    border-radius: var(--radius-card, 6px);
    padding: calc(var(--padding-card, 1rem) * 0.5) var(--padding-card, 1rem);
    font-size: 0.9rem;
    margin-bottom: var(--margin-card, 1rem);
    text-align: left;
}

.login-hint {
    font-size: 0.85rem;
    color: var(--body-text, #333333);
}

.login-notice {
    background: var(--status-ok-bg, #eaf7ea);
    color: var(--status-ok-text, #1e6b1e);
    border: 1px solid var(--status-ok-bg, #eaf7ea);
    border-radius: var(--radius-card, 6px);
    padding: calc(var(--padding-card, 1rem) * 0.5) var(--padding-card, 1rem);
    font-size: 0.9rem;
    margin-bottom: var(--margin-card, 1rem);
    text-align: left;
}

/* --- App shell (Tad\App\Module\Chrome) — top header bar + left sidebar + breadcrumb + module
   switcher, ported/adapted from tdesk-app-op's own app.css/app.js. A single CSS Grid (not op's
   fixed-position + margin-left offset dance — same visual result, simpler to reason about and to
   make responsive) driven by one custom property, --sidebar-width, so collapsing the sidebar to an
   icon rail only ever needs to change ONE value. RTL and the mobile bottom-sheet dropdown treatment
   (op has both) are deliberately NOT ported — not needed here; dropdowns just stay anchored. */

body.app-shell {
    --sidebar-width: 260px;
    display: grid;
    grid-template-columns: var(--sidebar-width) 1fr;
    /* "subheader" (#sub-header, the breadcrumb) is its OWN row, distinct from "main"
       (#main-wrapper) — both used to share a single "main" cell (via #sub-header's own
       grid-area:main + align-self:start), which only "worked" by accident: two grid items placed
       in the same area both start at that area's own top edge, so #main-wrapper's content
       genuinely overlapped #sub-header underneath it the whole time. Invisible on a page whose
       first real element is a tall <h1> (its own line-height/margin happened to clear the overlap
       zone) — glaringly visible the moment a page starts with something short and boxy right at
       the top instead (Tdesk\Si's own list pages: a bare .filter-bar form, no heading above it —
       reported live, screenshotted: the filter's input/button visibly sat ON TOP of the
       breadcrumb text). "sidebar" spans both new rows automatically (it's named in both row 2 and
       row 3, a valid multi-row area, matching #sidebar's own unchanged grid-area:sidebar) — no
       other rule needed updating for that. */
    grid-template-areas: "header header" "sidebar subheader" "sidebar main";
    grid-template-rows: auto auto 1fr;
    /* height (not min-height): the shell must never itself grow past the viewport — #content below
       is the one and only scroll container (both axes), everything else (header/sidebar/breadcrumb)
       stays fixed onscreen. overflow:hidden backs that up at the body level: without it, a stray
       child that briefly renders wider/taller than its own box (a reflow mid-transition, a
       min-width:0 chain that isn't airtight somewhere) would still fall back to scrolling the WHOLE
       window instead of just failing loudly inside #content, which is exactly the failure mode this
       is meant to rule out entirely, not just discourage. */
    height: 100vh;
    overflow: hidden;
}

/* Collapsed width is DERIVED, not a flat guess — chosen so a menu icon ends up perfectly centered
   in the icon-only rail purely as a side effect of its LEFT padding never changing between states
   (see .menu-link/.menu-parent's own padding, untouched here): row content width = padding-sidebar
   (left+right, each half) + the icon's own 1.35rem (.menu-icon's own width — MUST stay in sync with
   it, see that rule's own comment), plus the pill's own left/right margin. Because nothing about the
   icon's own padding/position is overridden for the collapsed state, its distance from the sidebar's
   left edge is IDENTICAL whether collapsed or not — expanding/collapsing never moves it, it just
   reveals/hides the label sitting after it. Stays centered and stays still even if an admin changes
   padding_sidebar/margin_sidebar_x in the theme — nothing here is a fixed guess. */
body.app-shell.sidebar-collapsed {
    --sidebar-width: calc(var(--padding-sidebar, 1rem) + 1.35rem + (var(--margin-sidebar-x, 0.4rem) * 2));
}

#overlay {
    display: none;
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, .45);
    z-index: 30;
}

#overlay.active {
    display: block;
}

/* --- Top header bar --- */

#top-header {
    grid-area: header;
    display: flex;
    align-items: center;
    gap: calc(var(--padding-header, 1rem) * 0.5);
    height: 2.875rem;
    padding: 0 var(--padding-header, 1rem);
    background: var(--header-bg, #ffffff);
    border-bottom: 1px solid var(--header-border, #eeeeee);
    position: sticky;
    top: 0;
    z-index: 20;
}

#top-header .header-spacer {
    flex: 1;
}

#top-header button {
    background: none;
    color: var(--body-text, #333333);
    padding: calc(var(--padding-header, 1rem) * 0.25);
    /* Resets ONLY the generic `button, .app-button` rule's own margin-bottom (app.css, meant for
       spacing rows of form/admin buttons apart) — that bottom-only margin (paired with a real 0 on
       top) otherwise shifts every header button's own margin box 2px above true center relative to
       its non-<button> siblings (#header-logo, the dropdown-wrapper divs, .btn-logout's <a>), which
       #top-header's own align-items:center then centers along with everything else instead of
       correcting for. Found live: #btn-sidebar-toggle/#btn-module-switcher sat visibly higher than
       the logo/settings-icon/logout-icon next to them. Deliberately margin-top/margin-bottom only,
       NOT the margin shorthand — a shorthand here would also stomp #btn-sidebar-toggle's own,
       separately-calculated margin-inline-start (below), breaking ITS horizontal alignment with the
       sidebar's own .menu-icon column instead (found live the same way, right after this fix). */
    margin-top: 0;
    margin-bottom: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

/* Tad\App\Module\Chrome::renderLogoutIcon() — rightmost in #top-header (last element in the markup,
   after .header-spacer pushes everything following it to the far edge), always visible in both
   mobile and desktop, no dropdown — a direct one-click link to Auth::getLogoutUrl(), unlike
   #top-header button's own generic rule above this isn't a <button> (no JS toggle involved at all)
   so it needs its own explicit color/padding/layout rather than inheriting that rule. */
/* Same neutral icon color as #top-header button (#btn-settings, right next to it) — no fixed red
   here any more: a lone icon in a header full of neutral icons read as an error/alert rather than a
   normal action. The dedicated "danger" treatment stays on .dd-item-danger below (the "Se
   déconnecter" row INSIDE the user menu dropdown), where it sits among clearly labeled text actions
   and reads as an intentional warning instead. */
.btn-logout {
    background: none;
    color: var(--body-text, #333333);
    padding: calc(var(--padding-header, 1rem) * 0.25);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    text-decoration: none;
    cursor: pointer;
}

/* #top-header #btn-burger (not a bare #btn-burger id selector) — needed to OUTWEIGH #top-header
   button's own specificity (1 id + 1 type > 1 id alone), which otherwise always wins and forces
   this back to visible on desktop regardless of this rule (found live: burger showed on desktop). */
#top-header #btn-burger {
    display: none;
}

/* Tad\App\Module\Chrome::renderUserMenuMobile() — desktop hides this header trigger entirely
   (renderUserMenuDesktop()'s own sidebar-bottom trigger is the desktop one instead); see the
   <768px media query at the bottom of this file for the swap. */
#top-header #user-menu-wrapper-mobile {
    display: none;
}

/* Chevron points left (collapse) by default, flips to point right (expand) once collapsed —
   same button/icon slot handles both directions, no separate "expand" icon needed. */
#btn-sidebar-toggle svg {
    transition: transform .15s ease;
}

/* Aligns this chevron's own icon with the sidebar's own .menu-icon column right below it (found
   live: 9px+ apart by default) — #top-header button's generic rule (padding: padding-header*0.25,
   centered) puts this icon's center at padding-header*1.25 + 8px (half its own 16px svg) from the
   header's left edge; a .menu-icon-column icon's own center sits at padding-sidebar*0.5 + 0.675rem
   (half of .menu-icon's own 1.35rem width) from the sidebar's left edge, regardless of the real
   icon's own size inside it (centered there via justify-content). The two only coincide by luck when
   padding-header equals padding-sidebar — this shifts by the DIFFERENCE between them instead of a
   fixed guess, so it stays correct if either is customized independently later. */
/* #btn-burger shares this exact value (not its own separately-tuned one) on purpose — the two are
   never visible at the same time (desktop vs. <768px, see the media query below), so giving them
   identical margins is what keeps the menu-toggle icon sitting at the SAME spot across the
   breakpoint switch, rather than visibly jumping sideways the moment the layout swaps one button
   for the other. */
#btn-sidebar-toggle,
#btn-burger {
    margin-inline-start: calc((var(--padding-sidebar, 1rem) * 0.5 + 0.675rem) - (var(--padding-header, 1rem) * 1.25 + 8px));
}

body.sidebar-collapsed #btn-sidebar-toggle svg {
    transform: rotate(180deg);
}

#header-logo {
    display: flex;
    align-items: center;
    gap: calc(var(--padding-header, 1rem) * 0.25);
    font-weight: 600;
    text-decoration: none;
    color: var(--body-text, #333333);
}

#header-logo svg {
    height: 1.5rem;
}

/* Tad\App\Module\Logo::renderInline()'s own 2 retagged fills (public/assets/img/logo_favicon.svg's
   hardcoded #152839/#1B58AF) — deliberately NOT their own dedicated slots (that was tried first;
   reverted) — instead read the SAME two colors Tad\App\Module\Chrome::renderAppNameHtml() already
   uses for the app name next to this same logo (.logo-color-1 = the ink, matching the name's own
   inherited body-text; .logo-color-2 = the accent-colored portion of the name), so the logo and the
   app name always visually agree instead of being customized independently. */
.logo-color-1 {
    fill: var(--body-text, #152839);
    transition: fill 0.15s ease;
}

.logo-color-2 {
    fill: var(--accent, #1B58AF);
    transition: fill 0.15s ease;
}

/* Tad\App\Module\Chrome::renderAppNameHtml()'s own accent_start/accent_end character range — reads
   the theme's existing 'accent' slot, not a dedicated color of its own (see that method's comment). */
.app-name-accent {
    color: var(--accent, #3b82f6);
}

/* Tad\App\Settings\SettingsAdmin's own 3 inputs + live preview, side by side on one row instead of
   each stacking full-width the way a bare <fieldset> normally would. */
.settings-row {
    display: flex;
    flex-wrap: wrap;
    gap: 1.5rem;
    align-items: flex-start;
}

.settings-row-inputs {
    display: flex;
    flex: 2;
    flex-wrap: wrap;
    gap: 1rem;
    min-width: 0;
}

.settings-row-inputs .field {
    flex: 1;
    min-width: 140px;
    margin-bottom: 0;
}

.settings-row-preview {
    flex: 1;
    min-width: 220px;
}

/* Module/mode pickers for the live preview (public/assets/js/settings-preview.js) — inline row
   instead of the usual stacked .field, since both selects are one single "which color am I even
   previewing" choice. */
.settings-preview-controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
}

.settings-preview-controls label {
    margin: 0;
    white-space: nowrap;
}

.settings-preview-controls select {
    width: auto;
}

/* Background is set inline per module/mode (settings-preview.js, reading the theme's own header_bg
   slot) — an app name is only ever actually seen sitting on #top-header, never on a bare page
   background, so the preview reproduces that instead of assuming a fixed light page behind it. The
   border stays constant regardless of the previewed header_bg so the box reads clearly even when
   that background is close to the page's own. */
.settings-preview {
    margin: 0;
    padding: 0.75rem 1rem;
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-card, 6px);
    font-size: 1.1rem;
    font-weight: 600;
}

#btn-module-switcher {
    display: inline-flex;
    align-items: center;
    gap: calc(var(--padding-chip, 1rem) * 0.25);
    background: var(--card-bg, #ffffff);
    border: 1px solid var(--card-border, #eeeeee);
    /* Own dedicated slot (Tad\App\Theme\ThemeSlots) — used to share --radius-chip with unrelated
       tabs/chips elsewhere. */
    border-radius: var(--radius-module-switcher, 10px);
    padding: 0.2rem calc(var(--padding-chip, 1rem) * 0.5);
    cursor: pointer;
    font-family: inherit;
    color: var(--body-text, #333333);
}

.module-tile-badge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 18px;
    height: 18px;
    border-radius: var(--radius-btn, 4px);
    background: var(--accent, #3b82f6);
    color: #fff;
    font-size: 0.7rem;
    flex: none;
}

/* Tad\App\Module\Chrome::renderUserMenuDropdown()'s own trigger — same sizing/shape convention as
   .module-tile-badge (Chrome::badge()), a solid-color circle with the user's own initial, but reading
   the theme's 'accent' slot directly rather than a per-module accent (there's no "module" concept for
   who's logged in). */
.user-menu-badge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.7rem;
    height: 1.7rem;
    border-radius: 50%;
    background: var(--accent, #3b82f6);
    /* A viewer's own badge photo/color (Tad\App\Module\Chrome::renderBadgeSpan()) is set inline as
       background/background-image on this same element — background-size/position here is what
       actually makes an uploaded photo fill the circle instead of tiling/stretching oddly; harmless
       no-op for the plain-color/initial-only cases, which never set background-image at all. */
    background-size: cover;
    background-position: center;
    color: #ffffff;
    font-size: 0.75rem;
    font-weight: 600;
    flex: none;
}

/* Tad\App\Module\Chrome::userMenuDropdownContent()'s own top row — name/ident/role on the left,
   the larger photo badge (below) on the right, ONLY rendered when the viewer has an APPROVED
   badge photo (2026-09-14): the small always-visible trigger badge stays color/initial-only on
   purpose (see Chrome::renderBadgeSpan()'s own comment), so this larger one is the one place a
   real photo actually shows in the chrome. justify-content:space-between still centers/pushes the
   photo to the far right even without it — .dd-user-header keeps its own natural width either way. */
.dd-user-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
}

.user-menu-badge-large {
    width: 3.5rem;
    height: 3.5rem;
    font-size: 1.5rem;
}

/* The dropdown's own name/ident/role block — deliberately its own rule, NOT a reuse of .dd-header
   (that class is a small uppercase muted SECTION LABEL, e.g. "Paramètres" — wrong look entirely for
   a 3-line "who am I" block). */
.dd-user-header {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    padding: 0.3rem calc(var(--padding-dropdown, 1rem) * 0.25) 0.5rem;
}

.dd-user-name {
    font-size: 0.9rem;
    font-weight: 600;
    color: var(--body-text, #333333);
}

.dd-user-ident {
    font-size: 0.75rem;
    color: var(--status-neutral-text, #555555);
}

.dd-user-role {
    font-size: 0.75rem;
    color: var(--accent, #3b82f6);
}

/* "Se déconnecter" — a FIXED red, not the theme's own status-ko slot, same reasoning as .btn-logout
   above: this should always read as the same recognizable "danger" action, not vary per theme. */
.dd-item-danger {
    color: #b3261e;
}

.dd-item-danger:hover {
    background: #fdecea;
}

/* Tad\App\Module\Chrome::userMenuDropdownContent()'s own "Se déconnecter" icon — .dd-item stays
   display:block (shared with the icon-less "Changer de mot de passe" item above it), so the icon is
   just inline content next to the text rather than needing a flex layout change. */
.dd-item-icon {
    vertical-align: middle;
    margin-inline-end: 0.4rem;
}

/* --- Left sidebar — a real tree (2-3 levels), every node possibly carrying its own icon at any
   depth (see Tad\App\Module\Chrome::renderSidebar()'s own comment) --- */

#sidebar {
    grid-area: sidebar;
    background: var(--sidebar-bg, #ffffff);
    border-inline-end: 1px solid var(--sidebar-border, #eeeeee);
    display: flex;
    flex-direction: column;
    overflow-y: auto;
    position: sticky;
    /* Sits BELOW the full-width #top-header (see body.app-shell's own grid-template-areas), so its
       own sticky offset/height must account for that 2.875rem instead of assuming it starts at the
       very top of the viewport. */
    top: 2.875rem;
    height: calc(100vh - 2.875rem);
}

#sidebar-nav {
    flex: 1;
}

/* --- The "pill": .menu-row (link + its optional sibling .chevron-toggle, for any node with a url —
   see Tad\App\Module\Chrome::renderNavNode()'s own comment on why those are separate siblings, not a
   nested <button>, and why this ALSO covers submenu/depth>0 links, which use the exact same wrapper)
   and .menu-parent (a childless-of-its-own-page group heading, already one single element). Both are
   the actual hover/active-highlighted surface — background/color/radius/margin live HERE, not on the
   inner .menu-link/.submenu-link/.chevron-toggle, so a link+chevron pair highlights as ONE seamless
   pill instead of the chevron staying plain when its sibling link is hovered.

   margin_sidebar_x/margin_sidebar_y (real, independent theme slots) ARE, directly and without
   dilution, the pill's own left/right and top/bottom margin — the element always takes 100% of the
   available width MINUS margin_sidebar_x, computed EXPLICITLY via width: calc(100% - x*2) rather
   than left to the browser's own "auto width" block-sizing default. That default technically also
   resolves to "100% minus margins" per spec, but .menu-parent is a real <button> — form controls are
   notorious for NOT reliably switching to full block sizing just because 'display' says so (found
   live: with plain width:auto, the button shrank to fit its own content instead, leaving no free
   space for .chevron's own margin-inline-start:auto to push into — the chevron stayed glued next to
   the label instead of reaching the pill's own right edge). The explicit calc() sidesteps that quirk
   entirely — and also sidesteps the OTHER failure mode (width:100% blindly combined with a non-auto
   margin is over-constrained, and the spec then silently discards one side's margin to make the sums
   balance) since the margin is already subtracted from the declared width itself, not left for the
   browser to reconcile. */
.menu-row,
.menu-parent {
    display: flex;
    align-items: center;
    border-radius: var(--radius-sidebar, 4px);
    width: calc(100% - (var(--margin-sidebar-x, 0.4rem) * 2));
    margin: var(--margin-sidebar-y, 0.15rem) var(--margin-sidebar-x, 0.4rem);
    color: var(--sidebar-text, #333333);
}

.menu-parent {
    gap: calc(var(--padding-sidebar, 1rem) * 0.5);
    padding: 0.45rem calc(var(--padding-sidebar, 1rem) * 0.5);
    font-size: 0.95rem;
    white-space: nowrap;
    overflow: hidden;
    background: none;
    border: none;
    cursor: pointer;
    font-family: inherit;
    text-align: left;
}

/* .menu-link/.submenu-link/.chevron-toggle never carry their OWN background/color — they always
   defer to whichever ancestor pill (.menu-row or .menu-parent) they sit in, idle or hovered/active,
   via plain CSS inheritance (color: inherit; the menu-icon's SVG itself uses stroke="currentColor" —
   see Chrome::svg() — so it follows along for free, no separate icon-color rule needed). This is
   also what makes children (.submenu-link) match parents (.menu-link): same shared rule, same
   inherited colors, only font-size/padding differ by depth (see the .submenu-link override below). */
.menu-link,
.submenu-link,
.chevron-toggle {
    color: inherit;
    background: none;
}

.menu-link,
.submenu-link {
    display: flex;
    align-items: center;
    gap: calc(var(--padding-sidebar, 1rem) * 0.5);
    width: 100%;
    padding: 0.45rem calc(var(--padding-sidebar, 1rem) * 0.5);
    text-decoration: none;
    font-size: 0.95rem;
    white-space: nowrap;
    overflow: hidden;
}

/* Mouse-over preview — deliberately separate from .active (the current page) below, both in slot
   (sidebar_bg_hover/sidebar_text_hover vs sidebar_bg_active/sidebar_text_active) and in source order
   (this rule first, so hovering an already-active row keeps showing the active look — same
   specificity, .active wins by simply appearing later). */
.menu-row:hover,
.menu-parent:hover {
    background: var(--sidebar-bg-hover, #f1f5f9);
    color: var(--sidebar-text-hover, #3b82f6);
}

.menu-row.active {
    background: var(--sidebar-bg-active, #93c5fd);
    color: var(--sidebar-text-active, #3b82f6);
}

.menu-row .menu-link,
.menu-row .submenu-link {
    flex: 1;
    min-width: 0;
}

.chevron-toggle {
    flex: none;
    border: none;
    cursor: pointer;
    padding: 0.45rem calc(var(--padding-sidebar, 1rem) * 0.4);
    display: flex;
    align-items: center;
}

.menu-parent .chevron,
.chevron-toggle .chevron {
    margin-inline-start: auto;
    flex: none;
    transition: transform .15s ease;
}

.chevron-toggle .chevron {
    margin-inline-start: 0;
}

.menu-parent[aria-expanded="true"] .chevron,
.chevron-toggle[aria-expanded="true"] .chevron {
    transform: rotate(90deg);
}

.submenu {
    display: none;
    margin-inline-start: 1.1rem;
    padding-inline-start: 0.6rem;
}

.submenu.open {
    display: block;
}

.submenu-link {
    font-size: 0.9rem;
    padding-top: 0.35rem;
    padding-bottom: 0.35rem;
}

/* width MUST stay in sync with the icon's own rendered size (Tad\App\Module\Chrome::renderNavNode()'s
   own self::svg($iconName, 18)) — and with body.app-shell.sidebar-collapsed's own --sidebar-width
   formula just above, which hardcodes this same number to derive the collapsed rail's width. Bumped
   from 15px/1.1rem to 18px/1.35rem (+20%) together so every row's icon stays centered in that rail
   exactly as before, just bigger — see that formula's own comment for why the two can never drift
   independently. */
/* min-height (not just width) matters here: the icon's own <svg> is a FIXED 18px (Chrome::svg()'s
   own literal width/height attribute, never scaled by font-size), while .menu-label's line-height
   DOES scale with the root font-size (a11y mode's own +10% text scale, see app.css's own :root[data-
   theme="a11y"] rules) — at that scale the label (20px) is taller than the icon (still 18px), so a
   row's own natural content height (flex row height = tallest child) is 2px shorter the instant the
   label is hidden on collapse (body.sidebar-collapsed's own display:none rule below) than it was
   expanded, pulling every icon below it up by that amount — found live: "les icones bougent quand on
   réduit ou agrandit le menu", worse the more rows exist (each row's own 2px shortfall stacks with
   the ones above it). min-height reserves enough room for the SCALED label at any text-size
   preference/color-scheme combination, so the icon (already narrower than that reserve) is never
   what determines the row's height, expanded or collapsed alike. */
.menu-icon {
    flex: none;
    width: 1.35rem;
    min-height: 1.3rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

/* .chevron-toggle itself, not just its own inner .chevron icon — a node with children (e.g.
   "Tableau de bord") sits in a .menu-row alongside a SEPARATE .chevron-toggle sibling button (see
   Tad\App\Module\Chrome::renderNavNode()'s own comment on why they're siblings, not nested). Hiding
   only the inner .chevron left the now-icon-less button ITSELF still taking up flex space in the row
   — found live: on a barely-33px-wide collapsed row, that stole enough width to force .menu-link's
   own flex:1 sizing to shrink below its icon's natural size, clipping and visibly shifting it
   leftward compared to every OTHER row (a plain link with no children, no sibling button to compete
   with). There's no submenu left to reveal via this toggle while collapsed anyway — see .submenu's
   own rule just below for how it's hidden without the same display:none treatment.

   .menu-link/.menu-parent's OWN .menu-label — deliberately NOT a bare ".menu-label" (which would
   also match a .submenu-link's own label, nested one level deeper) — found live: a .submenu-link
   only ever appears inside an already visibility:hidden, space-RESERVED .submenu (see that rule's own
   comment on why its box is kept, not removed), and display:none-ing its label on top of that shrank
   the label's line box away, silently shortening the reserved row by exactly its own text's line-
   height — the reserved space then fell short of the real expanded height by one row per hidden
   label, pulling icons below the submenu back up again despite the whole point of reserving it. */
body.sidebar-collapsed .menu-link > .menu-label,
body.sidebar-collapsed .menu-parent > .menu-label,
body.sidebar-collapsed .chevron,
body.sidebar-collapsed .chevron-toggle {
    display: none;
}

/* An OPEN submenu (Tad\App\Module\Chrome::renderNavNode()'s own "current page's section starts
   already expanded" behavior) used to get display:none here — removing its rendered height entirely,
   which silently pulled every LATER icon up to fill the gap the instant the sidebar collapsed (and
   back down the instant it expanded again) — found live: an icon jumping every time you toggled
   collapse while on any nested admin page, since that section's own submenu is open by default
   whenever its own page (or a descendant's) is the current one. visibility:hidden hides it (and, same
   as display:none, takes it out of the tab order and out of hit-testing) WITHOUT collapsing its
   layout box — the row still occupies the same height, so nothing after it ever has to move. Trades a
   strip of dead space in the icon rail for icons that never move, which is the point: an icon-only
   rail has no affordance to explore a submenu anyway (Tad\App\Module\Chrome doesn't add a flyout), so
   the reserved space isn't hiding a real interaction, just preserving layout stability. */
body.sidebar-collapsed .submenu {
    visibility: hidden;
}

/* Tad\App\Module\Chrome::renderUserMenuDesktop() — a real LAST child of #sidebar (see that method's
   own comment), so #sidebar-nav's own flex:1 is what pins it to the bottom, not fixed/absolute
   positioning. Desktop only — #user-menu-wrapper-mobile (header trigger) is its mobile counterpart,
   see the <768px media query at the bottom of this file for the actual show/hide split. The logout
   icon itself lives in the HEADER instead (.btn-logout below), not here — see
   Chrome::renderUserMenuDesktop()'s own comment on why. */
#sidebar-bottom {
    display: flex;
    align-items: center;
    border-top: 1px solid var(--sidebar-border, #eeeeee);
    padding: calc(var(--padding-sidebar, 1rem) * 0.5);
}

/* #user-menu-wrapper-desktop (its own .dropdown-wrapper) has no display:flex of its own — flex:1
   here would otherwise be a no-op (only meaningful as a child of a flex container) — so the button's
   own width is set explicitly instead. Same "a <button> won't reliably auto-stretch to 100% just
   because a block-level 'display' says so" caveat .menu-parent's own width:calc(...) rule already
   documents — found live here too: without it, the button shrank to fit its own content instead of
   filling #sidebar-bottom's row. */
#user-menu-wrapper-desktop {
    width: 100%;
    min-width: 0;
}

#btn-user-menu-desktop {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    min-width: 0;
    background: none;
    border: none;
    padding: 0;
    cursor: pointer;
    font-family: inherit;
    color: inherit;
    text-align: left;
}

/* Collapsed: .user-menu-badge (1.7rem) is wider than the icon-only rail's own content column
   (1.35rem, matching .menu-icon — see body.app-shell.sidebar-collapsed's own --sidebar-width
   formula) — left-aligned (this button's normal default, matching the label that follows in
   expanded mode), it overflowed past the sidebar's own right edge and got visibly clipped there.
   Centering it here instead keeps the overflow symmetric on both sides, which also happens to land
   its center on the exact same x as .menu-icon's own (both centered within the identical
   symmetric row padding). */
body.sidebar-collapsed #btn-user-menu-desktop {
    justify-content: center;
}

/* The name/ident text (+ the trigger's own up-chevron) — hidden when the sidebar collapses to its
   icon-only rail, same treatment .menu-label already gets (see that rule's own comment), leaving just
   the avatar badge + the always-visible logout icon in the rail. */
.user-meta {
    display: flex;
    flex-direction: column;
    min-width: 0;
    flex: 1;
    overflow: hidden;
}

svg.user-meta {
    flex: none;
    width: 12px;
    height: 12px;
    color: var(--sidebar-text, #333333);
}

.dd-user-name-inline,
.dd-user-ident-inline {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.dd-user-name-inline {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--sidebar-text, #333333);
}

.dd-user-ident-inline {
    font-size: 0.7rem;
    opacity: .65;
    color: var(--sidebar-text, #333333);
}

/* Opens UPWARD (anchored at the bottom of the sidebar) — the mirror image of every other
   .dropdown-menu here, which opens downward from a header trigger. */
#user-menu-dropdown-desktop {
    top: auto;
    bottom: 100%;
    inset-inline: 0;
    margin-top: 0;
    margin-bottom: 0.3rem;
}

#app-info {
    font-size: 0.7rem;
    color: var(--sidebar-text, #333333);
    opacity: .65;
    padding: calc(var(--padding-sidebar, 1rem) * 0.25) calc(var(--padding-sidebar, 1rem) * 0.5);
}

body.sidebar-collapsed #app-info,
body.sidebar-collapsed .user-meta {
    display: none;
}

/* --- Sub-header / breadcrumb ("fil d'Ariane") --- */

#sub-header {
    grid-area: subheader;
    display: flex;
    align-items: center;
    gap: calc(var(--padding-header, 1rem) * 0.25);
    background: var(--subheader-bg, #f9fafb);
    border-bottom: 1px solid var(--subheader-border, #eeeeee);
    padding: calc(var(--padding-header, 1rem) * 0.5) var(--padding-header, 1rem);
    font-size: 0.85rem;
}

.subheader-crumb {
    color: var(--subheader-text, #555555);
}

.subheader-chevron {
    color: var(--subheader-chevron-text, #aaaaaa);
}

.subheader-current {
    color: var(--subheader-current-text, #3b82f6);
    font-weight: 500;
}

#main-wrapper {
    grid-area: main;
    display: flex;
    flex-direction: column;
    /* Both axes: this grid item (body.app-shell's own "main" track, now a fixed height — see that
       rule's own comment) must never be pushed past its track's box by its content's intrinsic
       size, on EITHER axis — a plain grid/flex item's default minimum size is "auto" (its content's
       min-content size), not 0, on both width AND height. */
    min-width: 0;
    min-height: 0;
}

#content {
    padding: var(--padding-page, 1rem);
    /* #content is the ONE designated scroll container for a page's own body — #top-header/#sidebar/
       #sub-header all stay fixed onscreen (body.app-shell now has a hard height:100vh + overflow:
       hidden, see that rule's own comment), so a too-tall page needs to actually become scrollable
       here rather than just clipped/invisible past the viewport edge. flex:1 makes this fill 100%
       of #main-wrapper's (fixed) height — its DEFAULT sizing (flex-grow:0, sized to its own content)
       would otherwise leave it however tall its content happens to be, not "the rest of the
       screen". min-width/min-height:0 is the same "override the default auto-minimum so this can
       actually shrink below its content's intrinsic size" fix #main-wrapper's own rule already
       needed, just one level deeper — without it here too, overflow-y below would have nothing to
       ever actually clip/scroll: the box would just keep growing to fit its content instead.

       overflow-x is deliberately `hidden`, NOT `auto` — a too-WIDE element (Tdesk\Si's own
       si.admin.servers list table, the case that first surfaced this whole chain of fixes) gets its
       own local `overflow-x:auto` wrapper right around itself instead (see that template's own
       comment), so its horizontal scrollbar sits directly under the table, immediately visible,
       rather than at the bottom of #content's own (possibly much taller) scrolled box — reported
       live: with overflow-x:auto here, the horizontal scrollbar for the table existed but only
       appeared after scrolling all the way down #content's vertical scrollbar first. */
    flex: 1;
    min-width: 0;
    min-height: 0;
    overflow-y: auto;
    overflow-x: hidden;
}

/* --- Module switcher modal --- */

#module-grid {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 50;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, .45);
}

#module-grid.show {
    display: flex;
}

/* .module-grid-card/.module-btn (+ its badge)/.module-grid-tiles all used to read the shared
   --radius-card/--padding-card zone (same one ordinary cards use) — now their own dedicated
   'module_switcher' zone (Tad\App\Theme\ThemeSlots), so tweaking cards elsewhere no longer moves the
   module switcher too. */
.module-grid-card {
    width: 320px;
    max-width: 90vw;
    background: var(--card-bg, #ffffff);
    border-radius: var(--radius-module-popup, 6px);
    padding: var(--padding-card, 1rem);
}

.module-grid-title {
    margin: 0 0 var(--margin-card, 1rem);
    text-align: center;
}

.module-grid-tiles {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    /* margin_module_btn's own real spacing mechanism — there's no literal per-tile margin in a CSS
       grid, `gap` IS "space between tiles" here (same "margin slot drives whatever the real spacing
       mechanism is" rule margin_sidebar_x/y already establish). */
    gap: var(--margin-module-btn, 0.5rem);
}

.module-btn {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: calc(var(--padding-module-btn, 1rem) * 0.25);
    padding: var(--padding-module-btn, 1rem);
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-module-btn, 6px);
    background: var(--module-btn-bg, #f9fafb);
    cursor: pointer;
    font-family: inherit;
    color: var(--body-text, #333333);
    text-decoration: none;
}

.module-btn .module-tile-badge {
    width: 32px;
    height: 32px;
    border-radius: var(--radius-module-btn, 6px);
    font-size: 1rem;
}

.module-btn-current {
    border-color: var(--accent, #3b82f6);
    background: var(--module-btn-current-bg, #93c5fd);
}

.module-grid-cancel {
    display: block;
    margin: var(--margin-card, 1rem) auto 0;
    background: none;
    color: var(--body-text, #333333);
    border: 1px solid var(--card-border, #eeeeee);
}

/* --- Generic dropdown system (settings dropdown, etc.) --- */

.dropdown-wrapper {
    position: relative;
}

.dropdown-menu {
    display: none;
    position: absolute;
    inset-inline-end: 0;
    top: 100%;
    margin-top: 0.3rem;
    min-width: 220px;
    background: var(--card-bg, #ffffff);
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-dropdown, 6px);
    box-shadow: 0 4px 16px rgba(0, 0, 0, .15);
    padding: calc(var(--padding-dropdown, 1rem) * 0.5);
    z-index: 25;
}

.dropdown-menu.show {
    display: block;
}

.dd-header {
    font-size: 0.75rem;
    text-transform: uppercase;
    color: var(--status-neutral-text, #555555);
    padding: 0.2rem calc(var(--padding-dropdown, 1rem) * 0.25);
}

.dd-divider {
    border: none;
    border-top: 1px solid var(--card-border, #eeeeee);
    margin: calc(var(--margin-dropdown, 1rem) * 0.25) 0;
}

.dd-item {
    display: block;
    padding: 0.3rem calc(var(--padding-dropdown, 1rem) * 0.25);
}

/* The settings dropdown's own .dd-item rows are plain <div>s (dd-item-static), so they already
   inherit body_text with no browser default to fight. The user menu's own "Personnaliser mon
   badge"/"Changer de mot de passe" are real <a class="dd-item"> links — with no color/
   text-decoration of their own, they fell back to the browser's default blue underlined link style
   instead of matching. :not(.dd-item-danger) so "Se déconnecter" keeps its own deliberate red
   (.dd-item-danger below) regardless of which of the two rules the cascade would otherwise prefer. */
a.dd-item:not(.dd-item-danger) {
    color: var(--body-text, #333333);
    text-decoration: none;
}

/* --- Preference sliders (settings dropdown) — structure/labels ported verbatim from
   tdesk-app-op's own .scheme-slider/.size-slider/.weight-slider (see Tad\App\Module\Chrome::
   renderSettingsDropdown()'s own comment). Unlike op, every color here is var(--slot, #fallback) —
   op hardcoded #fff/#dbe3ed/#4a5b70 for the "unpressed" state and needed separate
   :root[data-theme="dark"]/[data-theme="a11y"] override blocks to compensate; here the SAME rule
   already renders correctly in every mode because the slots it reads (card_bg/card_border/
   body_text/accent) already vary per mode on their own. A sliding ".slider-thumb" (not part of op's
   own version) shows which of the 3 stops is active as one continuous moving highlight instead of
   each button just flipping its own background independently — positioned by public/assets/js/
   chrome.js. --- */

.dd-item-static {
    cursor: default;
}

.dd-item-stacked {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0.3rem;
}

.dd-item-row {
    display: flex;
    align-items: center;
    gap: calc(var(--padding-dropdown, 1rem) * 0.5);
    font-size: 0.85rem;
}

.dd-item-row .flex-1 {
    flex: 1;
}

.scheme-slider,
.size-slider,
.weight-slider {
    position: relative;
    display: flex;
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-field, 4px);
    overflow: hidden;
    width: 100%;
}

.slider-thumb {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: calc(100% / 3);
    background: var(--accent, #3b82f6);
    transition: left .2s ease;
    z-index: 0;
}

.scheme-slider-stop,
.size-slider-stop,
.weight-slider-stop {
    position: relative;
    z-index: 1;
    flex: 1;
    border: none;
    border-radius: 0;
    background: none;
    color: var(--body-text, #333333);
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 600;
    padding: 0.4rem 0.3rem;
}

.size-slider-stop:nth-child(2) { font-size: 11px; }
.size-slider-stop:nth-child(3) { font-size: 13.5px; }
.size-slider-stop:nth-child(4) { font-size: 16px; }

.weight-slider-stop:nth-child(2) { font-weight: 400; }
.weight-slider-stop:nth-child(3) { font-weight: 600; }
.weight-slider-stop:nth-child(4) { font-weight: 800; }

/* #settings-dropdown prefix (not a bare class selector) — needed to OUTWEIGH #top-header button's own
   specificity (1 id + 1 type beats 1 class + 1 attribute), which otherwise always won and silently
   kept every pressed stop on var(--body-text, ...) instead of white — found live: in a11y mode
   specifically, that meant near-black text on the accent's own dark navy background, almost
   unreadable, which is exactly backwards for the one mode whose entire point is guaranteed contrast. */
#settings-dropdown .scheme-slider-stop[aria-pressed="true"],
#settings-dropdown .size-slider-stop[aria-pressed="true"],
#settings-dropdown .weight-slider-stop[aria-pressed="true"] {
    color: #fff;
}

.weight-slider-stop:disabled {
    cursor: not-allowed;
    opacity: .35;
}

/* --- Responsive: sidebar becomes an off-canvas drawer below 767px --- */

@media (max-width: 767px) {
    body.app-shell {
        grid-template-columns: 1fr;
        grid-template-rows: auto 1fr;
        /* Same "subheader" (#sub-header)/"main" (#main-wrapper) split as the desktop grid above —
           without it, both shared this single "main" area here too (same overlap). */
        grid-template-areas: "subheader" "main";
    }

    /* Header switches from sticky (in-grid) to fixed (out of flow) here — some WebKit versions mis-
       stack a position:sticky grid item against a position:fixed sibling regardless of z-index (a
       real, observed bug: the mobile drawer rendered UNDER the header despite its higher z-index).
       Fixed positioning for both sidesteps that ambiguity entirely; #sub-header's own margin-top
       below compensates for the header no longer reserving its own grid row — it, not
       #main-wrapper, is the element that now actually sits FIRST in the "subheader"/"main" grid
       split above, so it's the one that needs to clear the fixed header. Found live: moving the
       split without moving this compensation along with it left the breadcrumb rendered UNDER the
       fixed header, entirely invisible, on mobile specifically. */
    #top-header {
        position: fixed;
        top: 0;
        inset-inline-start: 0;
        inset-inline-end: 0;
        width: 100%;
    }

    #sub-header {
        margin-top: 2.875rem;
    }

    #top-header #btn-sidebar-toggle {
        display: none;
    }

    #top-header #btn-burger {
        display: inline-flex;
    }

    /* Mirror swap of the desktop rules above: mobile shows the header-based avatar-only trigger
       instead of the sidebar-bottom one (which lives inside the off-canvas drawer anyway, but is
       hidden outright here too for clarity — see Tad\App\Module\Chrome::renderUserMenuDesktop()'s
       own comment). */
    #top-header #user-menu-wrapper-mobile {
        display: flex;
    }

    #sidebar-bottom {
        display: none;
    }

    #header-logo span {
        display: none;
    }

    /* top/height both account for #top-header's own fixed height (2.875rem, same value
       #main-wrapper's own padding-top above compensates for) — sidebar used to start at top:0/
       height:100vh, sitting UNDER the header only by z-index (40 vs 20), so opening it visually
       covered the header instead of sliding in below it. */
    #sidebar {
        position: fixed;
        inset-inline-start: -280px;
        top: 2.875rem;
        width: 280px !important;
        height: calc(100vh - 2.875rem);
        z-index: 40;
        transition: inset-inline-start .2s ease;
    }

    #sidebar.open {
        inset-inline-start: 0;
    }

    body.sidebar-collapsed .menu-label,
    body.sidebar-collapsed .chevron,
    body.sidebar-collapsed .chevron-toggle,
    body.sidebar-collapsed #app-info,
    body.sidebar-collapsed .user-meta {
        display: initial;
    }

    /* Counters the desktop collapsed rule's visibility:hidden (display was never touched there any
       more — see that rule's own comment) now that mobile always shows full rows regardless of a
       possibly-stale desktop collapse preference restored from localStorage. */
    body.sidebar-collapsed .submenu.open {
        visibility: visible;
    }
}

/* --- Filter bars / forms --- */

.filter-bar {
    display: flex;
    gap: calc(var(--padding-field, 1rem) * 0.5);
    align-items: center;
    margin-bottom: var(--margin-field, 1rem);
    flex-wrap: wrap;
}

.filter-bar input,
.filter-bar select {
    width: auto;
}

.field {
    margin-bottom: calc(var(--margin-field, 1rem) * 0.5);
}

.field label {
    display: block;
    font-size: 0.85rem;
    margin-bottom: 0.2rem;
}

input, select, textarea {
    width: 100%;
    box-sizing: border-box;
    padding: calc(var(--padding-field, 1rem) * 0.25) calc(var(--padding-field, 1rem) * 0.5);
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-field, 4px);
    font-family: inherit;
    background: var(--card-bg, #ffffff);
    color: var(--body-text, #333333);
}

.checkbox-field {
    display: flex;
    align-items: center;
    gap: calc(var(--padding-field, 1rem) * 0.25);
}

.checkbox-field input {
    width: auto;
}

.checkbox-field label {
    display: inline;
    margin-bottom: 0;
}

/* font-size: 0.875rem (=14px at the default 16px root) — a real <button> has no font-size rule
   ANYWHERE else in this file, so it was falling back to whatever the browser's own UA stylesheet
   picks for form controls (varies by browser/platform, and doesn't reliably match the rest of the
   page's own 0.85-0.95rem text), while an .app-button <a> was instead inheriting whatever its
   surrounding context happened to set — the two could render at visibly different sizes right next
   to each other. rem (not a literal 14px) so this still scales with the a11y +10% text preference
   like every other themed piece of text (Tad\App\Theme\ThemeSlots' own a11y font-scale rules). */
/* margin-right/margin-bottom, not all 4 sides: buttons are inline-block and normally sit in a row
   separated by nothing but a literal space character in the markup, which gives a horizontal gap
   but NO vertical one — on a narrow/mobile viewport, once a row of buttons no longer fits and
   wraps, two buttons land flush against each other with zero space between (reported live: a
   mobile-width button row rendered as a stack with no visible gap at all). Same 0.25 scale-down
   already used for this zone's own padding just above (buttons are small; the raw margin_btn
   value, same as margin_card/margin_field's un-scaled 1rem default, would look oversized next to
   one). margin-bottom also means the same rule already covers a button that's alone on its own
   line right above further content, not just two adjacent buttons. */
button, .app-button {
    padding: calc(var(--padding-btn, 1rem) * 0.25) var(--padding-btn, 1rem);
    margin: 0 calc(var(--margin-btn, 1rem) * 0.25) calc(var(--margin-btn, 1rem) * 0.25) 0;
    border: none;
    border-radius: var(--radius-btn, 4px);
    background: var(--btn-bg, #3b82f6);
    color: var(--btn-text, #ffffff);
    cursor: pointer;
    font-family: inherit;
    font-size: 0.875rem;
}

/* .app-button is also applied to plain <a> links (e.g. "+ Nouveau"/"Modifier" admin actions) so
   they look identical to a real <button> — an <a> has none of a <button>'s built-in inline-block
   sizing or non-underlined text, so both need setting explicitly here (a real <button> already
   behaves this way natively, this is a no-op for it). */
.app-button {
    display: inline-block;
    text-decoration: none;
}

button.btn-delete, a.btn-delete {
    background: var(--btn-delete-bg, #dc2626);
    color: var(--btn-delete-text, #ffffff);
}

/* Marks a page's own CREATE action (a "+ Nouveau"/"+ Nouvel" link) as distinct from an ordinary
   btn_bg action, same idea as .btn-delete for a destructive one — see Tad\App\Theme\ThemeSlots'
   own comment on btn_add_bg/btn_add_text. */
button.btn-add, a.btn-add {
    background: var(--btn-add-bg, #16a34a);
    color: var(--btn-add-text, #ffffff);
}

/* --- Cards --- */

.card {
    background: var(--card-bg, #ffffff);
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-card, 6px);
    padding: var(--padding-card, 1rem);
    margin-bottom: var(--margin-card, 1rem);
}

/* --- Tables --- */

table {
    width: 100%;
    border-collapse: collapse;
    margin-bottom: calc(var(--margin-card, 1rem) * 2);
    background: var(--card-bg, #ffffff);
}

th, td {
    text-align: left;
    padding: calc(var(--padding-card, 1rem) * 0.5);
    border-bottom: 1px solid var(--card-border, #eeeeee);
    vertical-align: top;
}

thead th {
    background: var(--table-header-bg, #f9fafb);
    color: var(--table-header-text, #333333);
}

tbody tr:nth-child(even) {
    background: var(--table-row-even-bg, #ffffff);
}

tbody tr:nth-child(odd) {
    background: var(--table-row-odd-bg, #f9fafb);
}

/* --- Tabs (Tdesk\News\AdminSupport) --- */

.tabs {
    display: flex;
    /* No gap here any more — each .tab now carries its own left/right margin (margin-tab) below, so
       the space between two tabs is themeable per-tab rather than one shared container-level value.
       margin-bottom is its own dedicated slot too (margin-tab-bottom), not margin_chip (an unrelated
       badge's own margin elsewhere) — see Tad\App\Theme\ThemeSlots' own comment on margin_tab. */
    margin-bottom: var(--margin-tab-bottom, 1rem);
    overflow-x: auto;
    scrollbar-width: none;
}

.tabs::-webkit-scrollbar {
    display: none;
}

.tab {
    display: inline-block;
    margin-inline: var(--margin-tab, 0.125rem);
    padding: calc(var(--padding-chip, 1rem) * 0.25) var(--padding-chip, 1rem);
    border-radius: var(--radius-chip, 4px) var(--radius-chip, 4px) 0 0;
    background: var(--tab-bg, #f3f4f6);
    color: var(--tab-text, #555555);
    white-space: nowrap;
    text-decoration: none;
    /* .tab is also applied to real <button> elements (e.g. ThemeAdmin's module-assignment mode
       tabs) — a <button> has no built-in text-decoration:none the way an <a> needs it reset, but it
       DOES have a UA border/font that must be reset explicitly for it to look identical to a .tab
       <a>. No-op for an <a>. */
    border: none;
    font-family: inherit;
    cursor: pointer;
}

/* Hover comes BEFORE .tab-active below so hovering an already-active tab keeps showing the active
   look — same specificity (one class + one pseudo-class vs. two classes both count as 0,2,0), so
   whichever rule is declared LAST in the cascade wins; same technique as .menu-row:hover/.menu-row.
   active in the sidebar. */
.tab:hover {
    background: var(--tab-hover-bg, #e5e7eb);
    color: var(--tab-hover-text, #4b5563);
}

/* Ported from tdesk-app-op's IDE open-file tabs (Tad\Http\...\tabs.js's own .tad-tab/.tad-tab.active
   — see Tad\App\Theme\ThemeSlots' own comment on tab_hover_bg for the port). The selected tab's
   bottom indicator is an INSET box-shadow, not a border-bottom or a pseudo-element — a real
   border-bottom would add to the tab's own box height (shifting its neighbors), and a pseudo-element
   is unnecessary extra markup for a plain solid line with nothing else to position inside it (op's
   OWN codebase reaches for a ::after only where a sibling border on the same row visibly thins a
   box-shadow — not the case here, this .tab has no border of its own). It reuses tab_active_text as
   its color (matching op's own var(--accent) — this app's tab_active_text already follows a theme's
   accent by the same convention other "active" slots do) rather than adding a 4th color slot just
   for the line; only its THICKNESS is separately themeable (tab_active_border_width). */
.tab.tab-active, a.tab.tab-active {
    background: var(--tab-active-bg, #ffffff);
    color: var(--tab-active-text, #3b82f6);
    box-shadow: inset 0 calc(var(--tab-active-border-width, 2px) * -1) 0 0 var(--tab-active-text, #3b82f6);
}

/* --- Alerts / notices / chips --- */

.alert {
    background: var(--status-ko-bg, #fdecea);
    color: var(--status-ko-text, #b3261e);
    border: 1px solid var(--status-ko-bg, #fdecea);
    border-radius: var(--radius-card, 6px);
    padding: calc(var(--padding-card, 1rem) * 0.5) var(--padding-card, 1rem);
    margin-bottom: var(--margin-card, 1rem);
}

.notice {
    background: var(--status-ok-bg, #eaf7ea);
    color: var(--status-ok-text, #1e6b1e);
    border: 1px solid var(--status-ok-bg, #eaf7ea);
    border-radius: var(--radius-card, 6px);
    padding: calc(var(--padding-card, 1rem) * 0.5) var(--padding-card, 1rem);
    margin-bottom: var(--margin-card, 1rem);
}

.chip {
    display: inline-block;
    padding: 0.1rem calc(var(--padding-chip, 1rem) * 0.5);
    border-radius: var(--radius-chip, 10px);
    font-size: 0.75rem;
    margin-left: calc(var(--margin-chip, 1rem) * 0.25);
}

.chip-active {
    background: var(--status-ok-bg, #eaf7ea);
    color: var(--status-ok-text, #1e6b1e);
}

.chip-inactive {
    background: var(--status-neutral-bg, #eeeeee);
    color: var(--status-neutral-text, #555555);
}

.chip-admin {
    background: var(--status-warn-bg, #fff4e5);
    color: var(--status-warn-text, #a15c00);
}

/* Tdesk\News admin — status chip variants (articles: live/draft/scheduled/expired, pinned flag) */
.chip-live { background: var(--status-ok-bg, #eaf7ea); color: var(--status-ok-text, #1e6b1e); }
.chip-draft { background: var(--status-neutral-bg, #eeeeee); color: var(--status-neutral-text, #555555); }
.chip-scheduled { background: var(--status-warn-bg, #fff4e5); color: var(--status-warn-text, #a15c00); }
.chip-expired { background: var(--status-ko-bg, #fdecea); color: var(--status-ko-text, #b3261e); }
.chip-pinned { background: var(--status-warn-bg, #fff4e5); color: var(--status-warn-text, #a15c00); }

/* Generic tone-based chip variants (App\Portal\PortalModule 'ui-demo' page below is the first
   caller) — 'chip-info' has no matching status_info_* slot (Tad\App\Theme\ThemeSlots only defines
   ok/warn/ko/neutral), so it reuses the generic accent slots instead of inventing an unthemeable
   new slot name. */
.chip-ok { background: var(--status-ok-bg, #eaf7ea); color: var(--status-ok-text, #1e6b1e); }
.chip-warn { background: var(--status-warn-bg, #fff4e5); color: var(--status-warn-text, #a15c00); }
.chip-bad { background: var(--status-ko-bg, #fdecea); color: var(--status-ko-text, #b3261e); }
.chip-neutral { background: var(--status-neutral-bg, #eeeeee); color: var(--status-neutral-text, #555555); }
.chip-info { background: var(--accent-light, #e8f1fc); color: var(--accent, #2a5f96); }

/* ---- KPI tiles / search+filter bar / mini bar-chart (App\Portal\PortalModule 'ui-demo' page,
   src/templates/portal/ui_demo.php — ported from tdesk-app-op's own portal/ui-demo.php, itself off
   the "Produits et services" design mockup). Layout-only test page, not a general design system —
   classes are prefixed ui-demo- below where they're specific to that one page's own structure (the
   panel wrapper); kpi-grid/searchbar-/segmented-/bar- stay unprefixed since they're plain reusable
   primitives with no ui-demo-specific behavior baked in. --tone drives the KPI accent bar/delta,
   the table's bar-fill, and the .chip-* tone above from one shared custom property per row/tile,
   same status_*_text slots as everywhere else in this file — no separate palette to keep in sync,
   and no :root[data-theme="dark"] overrides needed here (see this file's own top note: a theme's
   dark values come from its generated per-theme CSS file, not a hand-written override block). ---- */
.tone-ok { --tone: var(--status-ok-text, #1c8560); }
.tone-warn { --tone: var(--status-warn-text, #8a6320); }
.tone-bad { --tone: var(--status-ko-text, #b4462f); }
.tone-neutral { --tone: var(--status-neutral-text, #8496ab); }
.tone-info { --tone: var(--accent, #2a5f96); }

.kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: var(--margin-card, 1rem); }

.kpi-tile { position: relative; background: var(--card-bg, #ffffff); border: 1px solid var(--card-border, #eeeeee); padding: 15px 16px 15px 19px; }
.kpi-tile::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--tone, #8496ab); }
.kpi-label { margin: 0 0 6px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--status-neutral-text, #8496ab); }
.kpi-value { margin: 0 0 4px; font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--heading-text, #333333); }
.kpi-delta { margin: 0; font-size: 0.85rem; font-weight: 600; color: var(--tone, #8496ab); }

@media (max-width: 980px) {
    .kpi-grid { grid-template-columns: 1fr; }
}

.searchbar-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; background: var(--card-bg, #ffffff); border: 1px solid var(--card-border, #eeeeee); padding: 10px 14px; margin-bottom: var(--margin-card, 1rem); }
.searchbar-input-wrap { display: flex; align-items: center; gap: 6px; border: 1px solid var(--card-border, #eeeeee); padding: 0 10px; height: 2.5rem; flex: 1 1 220px; min-width: 180px; }
.searchbar-input-wrap:focus-within { border-color: var(--accent, #3b82f6); }
.searchbar-input-wrap svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--status-neutral-text, #8496ab); }
.searchbar-input-wrap input[type="search"] { flex: 1 1 auto; height: 100%; border: none; background: transparent; box-shadow: none; padding: 0; }
.searchbar-input-wrap input[type="search"]:focus-visible { outline: none; box-shadow: none; }

.segmented-group { display: flex; border: 1px solid var(--card-border, #eeeeee); }
.segmented-btn { border: none; border-right: 1px solid var(--card-border, #eeeeee); background: var(--card-bg, #ffffff); color: var(--body-text, #333333); font-size: 0.8rem; padding: 0.4rem 0.75rem; cursor: pointer; font-family: inherit; }
.segmented-btn:last-child { border-right: none; }
.segmented-btn:hover:not(.active) { background: var(--table-header-bg, #f9fafb); }
.segmented-btn.active { background: var(--sidebar-bg-active, #93c5fd); color: var(--sidebar-text-active, #333333); }

.ui-demo-panel { background: var(--card-bg, #ffffff); border: 1px solid var(--card-border, #eeeeee); margin-bottom: var(--margin-card, 1rem); }
.ui-demo-panel-head {
    padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--card-border, #eeeeee);
    display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; flex-wrap: wrap;
}
.ui-demo-panel-title { font-weight: 600; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--status-neutral-text, #8496ab); }
.ui-demo-panel-count { font-size: 1rem; color: var(--status-neutral-text, #8496ab); }
.ui-demo-panel-body { padding: 1.1rem; }
.ui-demo-panel table { margin-bottom: 0; }
.ui-demo-empty-row { padding: 1.6rem 1.1rem; text-align: center; font-size: 1rem; color: var(--status-neutral-text, #8496ab); }

.bar-track { position: relative; height: 8px; background: var(--table-header-bg, #f9fafb); width: 100%; }
.bar-track-sm { height: 6px; }
.bar-fill { display: block; height: 100%; background: var(--tone, #2a5f96); }

.bar-row { margin-bottom: 12px; }
.bar-row:last-child { margin-bottom: 0; }
.bar-row-head { display: flex; justify-content: space-between; margin-bottom: 4px; font-size: 0.85rem; color: var(--body-text, #333333); }
.bar-row-value { font-weight: 600; color: var(--heading-text, #333333); }

.node {
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-card, 6px);
    padding: calc(var(--padding-card, 1rem) * 0.5) var(--padding-card, 1rem);
    margin: calc(var(--margin-card, 1rem) * 0.5) 0;
}

.tag-list label {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    margin: 0 var(--margin-field, 1rem) calc(var(--margin-field, 1rem) * 0.25) 0;
    font-weight: normal;
}

.tag-list input {
    width: auto;
}

summary {
    cursor: pointer;
    color: var(--accent, #3b82f6);
}

.news-site-switcher {
    margin-bottom: var(--margin-field, 1rem);
}

/* --- Theme editor (Tad\App\Theme\ThemeAdmin) --- */

/* One <fieldset> per MODE (light/dark/a11y) — layout ported from tdesk-app-op's own admin.php
   (.theme-editor-row/.theme-editor-colors/.theme-editor-preview): a scrollable colors column
   alongside that SAME mode's own live-preview iframe, rather than one shared row of 3 iframes up
   top with every mode's inputs side by side further down. Colors column is capped to the preview's
   own height and scrolls internally (min-height:0 is the actual fix — a flex/grid item's default
   min-height:auto would otherwise refuse to shrink below its <details> content and never scroll). */
.theme-mode-block {
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-card, 6px);
    padding: var(--padding-card, 1rem);
    margin-bottom: var(--margin-card, 1rem);
}

.theme-mode-row {
    display: grid;
    grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
    gap: var(--padding-card, 1rem);
    align-items: start;
}

.theme-mode-colors {
    height: 480px;
    min-height: 0;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: calc(var(--padding-field, 1rem) * 0.5);
}

.theme-preview-frame {
    width: 100%;
    height: 480px;
    border: 1px solid var(--card-border, #eeeeee);
}

@media (max-width: 900px) {
    .theme-mode-row {
        grid-template-columns: 1fr;
    }

    /* Preview first, slot fields below — on a narrow screen an admin wants to SEE the mode before
       scrolling through ~20 controls to reach it, unlike the wide two-column layout where both are
       visible side by side and DOM order (fields, then preview) doesn't matter. Grid items respect
       'order' the same way flex items do. */
    .theme-preview-frame {
        order: -1;
    }
}

/* Tad\App\Theme\ThemeAdmin::renderModulePicker() — one row per main module: its own theme <select>
   next to a navigable preview of that module's OWN real page (not always '/', unlike the theme
   editor's own iframes — see that method's own comment), reusing .theme-preview-frame's visual
   treatment via .module-preview-frame. */
.module-assign-grid {
    display: flex;
    flex-direction: column;
    gap: var(--margin-card, 1rem);
    margin-bottom: var(--margin-card, 1rem);
}

.module-assign-row {
    display: grid;
    grid-template-columns: minmax(200px, 280px) minmax(0, 1fr);
    gap: var(--padding-card, 1rem);
    align-items: start;
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-card, 6px);
    padding: var(--padding-card, 1rem);
}

.module-assign-field label {
    display: block;
    margin-bottom: calc(var(--padding-field, 1rem) * 0.5);
}

.module-preview-frame {
    width: 100%;
    height: 320px;
    border: 1px solid var(--card-border, #eeeeee);
}

@media (max-width: 900px) {
    .module-assign-row {
        grid-template-columns: 1fr;
    }

    .module-preview-frame {
        order: -1;
    }
}

/* One collapsible block per Tad\App\Theme\ThemeAdmin::SLOT_CATEGORIES entry — layout idea ported
   from tdesk-app-op's own .color-category/.color-category-title, adapted to a bordered <details>
   block rather than a plain nested list, so ~20 slots per mode read as distinct sections instead of
   one undifferentiated column. */
.theme-slot-category {
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-card, 6px);
    padding: calc(var(--padding-card, 1rem) * 0.5) var(--padding-card, 1rem);
}

.theme-slot-category-title {
    cursor: pointer;
    font-weight: 600;
    padding: calc(var(--padding-card, 1rem) * 0.25) 0;
}

.theme-slot-category[open] .theme-slot-category-title {
    margin-bottom: calc(var(--margin-card, 1rem) * 0.25);
}

.theme-slot-category-body {
    display: flex;
    flex-direction: column;
    gap: calc(var(--padding-field, 1rem) * 0.25);
}

.theme-slot-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: calc(var(--padding-field, 1rem) * 0.5);
    border-radius: var(--radius-field, 4px);
}

/* Only a color row carries data-highlight (public/assets/js/theme-preview.js's own hover-to-inspect
   listener — see Tad\App\Theme\ThemeAdmin::SLOT_HIGHLIGHT_SELECTORS) — this subtle background is
   the only visual cue that hovering it does something, since the actual effect happens inside the
   preview iframe next to it, not on the row itself. */
.theme-slot-row[data-highlight]:hover {
    background: rgba(127, 127, 127, .12);
}

.theme-slot-name {
    font-family: ui-monospace, monospace;
    font-size: 0.8rem;
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* A length slot's plain input is a DIRECT flex child of .theme-slot-row (unlike a color slot's,
   nested one level deeper inside .color-field) — sharing that row with .theme-slot-name (flex: 1),
   so it gets a fixed compact width instead of width:100%, which would fight .theme-slot-name for
   space instead of sharing it (rem/px/% values are always short anyway). .theme-slot-hex (the
   color slot's own text field, inside .color-field) is unaffected — its width comes from its own
   flex:1 rule below instead. */
.theme-slot-row > .theme-slot-input {
    width: 110px;
    flex: none;
}

/* Color slots pair a native swatch (visual picking) with a plain text field (paste/copy a #hex code
   directly — native <input type="color"> alone has no visible/editable text in most browsers) plus
   a copy-to-clipboard button — see Tad\App\Theme\ThemeAdmin::renderForm()'s own comment.

   .color-field itself never grows (flex: none, no min-width:0 shrink-fighting with .theme-slot-name)
   — its 3 children (swatch/hex/copy) are each sized down to just fit their own content, so their
   COMBINED width stays small and predictable regardless of the row's own width. Found live: with
   the old, roomier sizing (2rem swatch, flex:1 hex, 1.8rem copy), .theme-mode-colors' own narrow
   column (Tad\App\Theme\ThemeAdmin's per-mode <fieldset> layout, ~240-320px) left barely any space
   for .theme-slot-name, forcing slot names like "sidebar_bg_hover" into illegible ellipsis. */
.color-field {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    flex: none;
}

/* A plain <button> (opens the shared draggable color-picker modal — see
   public/assets/js/color-picker.js's openColorPicker()), not a native <input type="color"> any
   more — background:none/cursor:pointer override the browser's own default button chrome, which
   would otherwise show through around the inline background color set per-row. Reused as-is by
   src/templates/badge/form.php's own color field, not just ThemeAdmin's theme-slot rows. */
.theme-slot-swatch {
    flex: none;
    width: 1.3rem;
    height: 1.3rem;
    padding: 0;
    background: none;
    cursor: pointer;
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-field, 4px);
}

.theme-slot-hex {
    flex: none;
    width: 5.5rem;
    font-size: 0.75rem;
    font-family: ui-monospace, monospace;
    text-transform: lowercase;
}

.copy-btn {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.3rem;
    height: 1.3rem;
    padding: 0;
    background: none;
    color: var(--body-text, #333333);
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-btn, 4px);
}

.copy-btn svg {
    width: 11px;
    height: 11px;
}

.copy-btn.copied {
    color: var(--status-ok-text, #1e6b1e);
    border-color: var(--status-ok-text, #1e6b1e);
}

/* Tad\App\Theme\ThemeAdmin::ROUNDED_TOGGLE_SLOTS' own "Arrondi" checkbox, next to radius_btn/
   radius_card's plain length field — same flex row as .color-field above. */
.radius-field {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.radius-toggle-label {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    font-size: 0.75rem;
    white-space: nowrap;
    cursor: pointer;
}

.theme-slot-radius {
    width: 4.5rem;
}

.theme-slot-radius[readonly] {
    opacity: 0.55;
}

/* --- Draggable color-picker modal (public/assets/js/theme-preview.js's own openColorPicker()) —
   opened by clicking a .theme-slot-swatch button, one shared instance reused for every color slot
   (see that file's own comment). Fixed positioning + inline left/top set by JS while dragging, so
   this stylesheet only supplies the initial/idle look, never a position. --- */
.color-picker-modal {
    position: fixed;
    z-index: 3000;
    width: 240px;
    background: var(--card-bg, #ffffff);
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-card, 6px);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
    overflow: hidden;
}

.cp-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    padding: 0.4rem 0.6rem;
    background: var(--header-bg, #ffffff);
    border-bottom: 1px solid var(--header-border, #eeeeee);
    cursor: move;
    user-select: none;
}

.cp-title {
    font-size: 0.75rem;
    font-family: ui-monospace, monospace;
    color: var(--body-text, #333333);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.cp-close {
    flex: none;
    width: 1.4rem;
    height: 1.4rem;
    line-height: 1;
    background: none;
    border: none;
    cursor: pointer;
    color: var(--body-text, #333333);
    font-size: 1rem;
}

.cp-body {
    padding: 0.6rem;
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
}

.cp-sv-wrap {
    position: relative;
    width: 100%;
    height: 130px;
    cursor: crosshair;
    border-radius: var(--radius-field, 4px);
    overflow: hidden;
}

.cp-sv {
    display: block;
    width: 100%;
    height: 100%;
}

.cp-sv-cursor {
    position: absolute;
    width: 10px;
    height: 10px;
    margin: -5px 0 0 -5px;
    border: 2px solid #ffffff;
    border-radius: 50%;
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
    pointer-events: none;
}

.cp-hue {
    width: 100%;
    height: 0.9rem;
    -webkit-appearance: none;
    appearance: none;
    border-radius: var(--radius-field, 4px);
    background: linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);
}

.cp-hue::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 0.9rem;
    height: 0.9rem;
    border-radius: 50%;
    background: #ffffff;
    border: 1px solid rgba(0, 0, 0, 0.4);
}

.cp-hue::-moz-range-thumb {
    width: 0.9rem;
    height: 0.9rem;
    border-radius: 50%;
    background: #ffffff;
    border: 1px solid rgba(0, 0, 0, 0.4);
}

.cp-preview-row {
    display: flex;
    align-items: center;
    gap: 0.35rem;
}

.cp-preview-swatch {
    flex: none;
    width: 1.6rem;
    height: 1.6rem;
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-field, 4px);
}

.cp-hex-input {
    flex: 1;
    min-width: 0;
    font-size: 0.8rem;
    font-family: ui-monospace, monospace;
    text-transform: lowercase;
}

.cp-copy-btn {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.6rem;
    height: 1.6rem;
    padding: 0;
    background: none;
    color: var(--body-text, #333333);
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-btn, 4px);
    cursor: pointer;
}

.cp-copy-btn.copied {
    color: var(--status-ok-text, #1e6b1e);
    border-color: var(--status-ok-text, #1e6b1e);
}

.cp-palette {
    display: grid;
    grid-template-columns: repeat(8, 1fr);
    gap: 0.3rem;
}

.cp-palette-swatch {
    width: 100%;
    aspect-ratio: 1;
    padding: 0;
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-field, 4px);
    cursor: pointer;
}

.cp-actions {
    display: flex;
    justify-content: flex-end;
    gap: 0.4rem;
}

/* --- Font size / weight (viewer preference, NOT theme-slot-driven — see
   Tad\App\Theme\DisplayPreferences' own header comment for why this stays a separate axis). ---

   --font-pref-scale carries the SIZE half of that preference as a plain multiplier (instead of each
   data-font-size value setting the root font-size directly) so a11y mode's own +10% below can
   COMPOUND with whatever the viewer separately picked, rather than the two flatly overriding one
   another the way two direct font-size declarations on the same :root would (equal specificity,
   last one in the cascade wins outright — never both applied together). */
:root {
    --font-pref-scale: 1;
}

:root[data-font-size="large"] {
    --font-pref-scale: 1.08;
}

:root[data-font-size="xlarge"] {
    --font-pref-scale: 1.18;
}

:root {
    font-size: calc(100% * var(--font-pref-scale) * var(--a11y-font-scale, 1));
}

:root[data-font-weight="medium"] body,
:root[data-font-weight="medium"] body * {
    font-weight: 600 !important;
}

:root[data-font-weight="bold"] body,
:root[data-font-weight="bold"] body * {
    font-weight: 700 !important;
}

/* --- a11y ("malvoyant") text scale: text should read 10% BIGGER and a visibly heavier weight than
   the same page in light/dark mode — on top of, not instead of, the viewer's own separate font-
   size/weight preference above (a visitor who already picked "Grand" shouldn't have that undone by
   switching color scheme). Weight is bumped one real step (400→500, 600→700, 700→800) rather than a
   literal +10% numeric value (440/660/770) — most system font stacks (see html's own font-family)
   only ship a handful of discrete weight faces, so an in-between numeric weight typically just
   rounds back down to the nearest one the OS provides and renders IDENTICAL to the un-bumped value;
   a real step is what's actually visible. --- */
:root[data-theme="a11y"] {
    --a11y-font-scale: 1.1;
}

:root[data-theme="a11y"] body,
:root[data-theme="a11y"] body * {
    font-weight: 500 !important;
}

:root[data-theme="a11y"][data-font-weight="medium"] body,
:root[data-theme="a11y"][data-font-weight="medium"] body * {
    font-weight: 700 !important;
}

:root[data-theme="a11y"][data-font-weight="bold"] body,
:root[data-theme="a11y"][data-font-weight="bold"] body * {
    font-weight: 800 !important;
}

/* The sidebar expand/collapse chevron (Tad\App\Module\Chrome::renderNavNode()'s own <svg>, fixed at
   12px/stroke-width:2 in the server-rendered markup) — CSS width/height/stroke-width on an <svg>
   element override its own presentation attributes, so this doesn't need any PHP-side change. */
:root[data-theme="a11y"] .chevron svg {
    width: 16px;
    height: 16px;
    stroke-width: 3;
}

/* Wraps .user-menu-badge in src/templates/badge/form.php's own preview — a plain button reset so
   the round badge underneath still looks like a static swatch, not a form control, while staying a
   real, keyboard-reachable <button> (not a <span> with a click listener bolted on). */
.badge-preview-trigger {
    display: inline-block;
    background: none;
    border: none;
    padding: 0;
    margin: 0;
    border-radius: 50%;
    cursor: pointer;
}

.badge-preview-trigger:hover .user-menu-badge,
.badge-preview-trigger:focus-visible .user-menu-badge {
    box-shadow: 0 0 0 2px var(--btn-bg, #3b82f6);
}

/* Holds "Supprimer la photo actuelle" + "Enregistrer" on one row — .login-submit is normally
   full-width (a single primary action elsewhere, e.g. login/password forms), overridden below to
   size to its own content and share the row instead of stacking full-width under the other
   button. */
.badge-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    align-items: center;
}

/* Matches the plain, unmodified `button, .app-button` size exactly — the SAME size the admin
   pages' own action buttons (Approuver/Refuser, etc.) already use — rather than .login-submit's
   own larger default (var(--padding-btn) on ALL sides + 1rem font). #badge-remove-btn needs no
   override of its own: .btn-delete already inherits that same plain button size untouched, and the
   icon below is capped at 16px specifically so it fits inside that height without growing it. */
.badge-actions .login-submit {
    width: auto;
    padding: calc(var(--padding-btn, 1rem) * 0.25) var(--padding-btn, 1rem);
    font-size: 0.875rem;
}

#badge-remove-btn {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
}

/* --- src/templates/badge/form.php crop widget (public/assets/js/badge-crop.js) — a small draggable
   popin, same "fixed position + JS sets left/top while dragging, stylesheet only supplies the idle
   look" idiom as ThemeAdmin's own .color-picker-modal (see that class's own comment); [hidden] used
   for show/hide (native attribute, not a toggled class that could silently not exist — see this
   app's own history with that mistake elsewhere) so no [hidden] rule is needed here beyond the UA
   default. .badge-crop-viewport is a fixed-size "cover" window: the picked photo is drawn
   oversized/panned inside it and clipped by overflow:hidden, while .badge-crop-mask sits on top
   using the classic "circular hole" box-shadow trick (a circle whose own box-shadow spreads
   9999px, clipped by the viewport's overflow:hidden) to dim everything that will NOT survive into
   the round badge and leave only the actual future badge circle at full brightness — this is the
   part that was explicitly requested (previewing the round-badge crop before saving), not a
   decorative frame. */
.badge-crop-overlay {
    position: fixed;
    z-index: 3000;
    width: 260px;
    background: var(--card-bg, #ffffff);
    border: 1px solid var(--card-border, #eeeeee);
    border-radius: var(--radius-card, 6px);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
    overflow: hidden;
}

.badge-crop-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    padding: 0.4rem 0.6rem;
    background: var(--header-bg, #ffffff);
    border-bottom: 1px solid var(--header-border, #eeeeee);
    cursor: move;
    user-select: none;
}

.badge-crop-title {
    font-size: 0.85rem;
    color: var(--body-text, #333333);
}

.badge-crop-close {
    flex: none;
    width: 1.4rem;
    height: 1.4rem;
    line-height: 1;
    background: none;
    border: none;
    cursor: pointer;
    color: var(--body-text, #333333);
    font-size: 1.1rem;
}

.badge-crop-body {
    padding: var(--padding-card, 1rem);
    text-align: center;
}

.badge-crop-viewport {
    position: relative;
    width: 220px;
    height: 220px;
    margin: 0 auto;
    overflow: hidden;
    border-radius: var(--radius-card, 6px);
    background: #111111;
    touch-action: none;
    cursor: grab;
}

.badge-crop-viewport:active {
    cursor: grabbing;
}

.badge-crop-viewport img {
    position: absolute;
    left: 0;
    top: 0;
    max-width: none;
    max-height: none;
    transform-origin: top left;
    pointer-events: none;
    user-select: none;
}

.badge-crop-mask {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 190px;
    height: 190px;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    box-shadow: 0 0 0 9999px rgba(0, 0, 0, .55);
    pointer-events: none;
}

.badge-crop-zoom {
    display: block;
    width: 220px;
    margin: 0.75rem auto 0;
}

.badge-crop-actions {
    margin-top: 0.75rem;
    display: flex;
    gap: 0.5rem;
    justify-content: center;
}

.badge-crop-actions button {
    padding: 0.4rem 0.9rem;
    border: none;
    border-radius: var(--radius-btn, 4px);
    cursor: pointer;
    font-size: 0.85rem;
}

.badge-crop-confirm {
    background: var(--btn-bg, #3b82f6);
    color: var(--btn-text, #ffffff);
}

.badge-crop-cancel {
    background: transparent;
    color: var(--body-text, #333333);
    border: 1px solid var(--card-border, #cccccc) !important;
}

.badge-crop-redo {
    background: none;
    border: none;
    color: var(--btn-bg, #3b82f6);
    font-size: 0.85rem;
    cursor: pointer;
    padding: 0;
    margin-top: 0.35rem;
}

/* --- Tad\App\Auth\AuthAdmin::renderBadgeModeration() ("Modération photo" admin page) +
   public/assets/js/badge-moderation-admin.js — a plain centered backdrop modal (same
   fixed+inset:0+flex-center shape as #module-grid) for the large round photo preview. The reject
   action's OWN confirmation now goes through the shared .admin-confirm-* popin below (chrome.js's
   own window.tdeskConfirm()/data-confirm), not a page-specific instance of this one — .badge-mod-
   btn-secondary stays in use here only for this modal's own "Fermer" button. --- */
.badge-mod-photo-trigger {
    display: inline-block;
    background: none;
    border: none;
    padding: 0;
    cursor: pointer;
}

.badge-mod-backdrop {
    position: fixed;
    inset: 0;
    z-index: 3000;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, .45);
}

/* This class's own `display: flex` above (needed to center .badge-mod-modal) otherwise beats the
   UA stylesheet's plain `[hidden] { display: none }` — any author rule wins over a UA one
   regardless of specificity, so [hidden] alone silently stopped hiding this element, leaving an
   invisible-but-still-clickable backdrop over the whole page. Explicit here, not left to the UA. */
.badge-mod-backdrop[hidden] {
    display: none;
}

.badge-mod-modal {
    width: 280px;
    max-width: 90vw;
    background: var(--card-bg, #ffffff);
    border-radius: var(--radius-card, 6px);
    padding: var(--padding-card, 1rem);
    text-align: center;
}

/* 220px to match src/templates/badge/form.php's own crop viewport — this previews the SAME
   round-badge framing the crop widget already showed the uploader, not an arbitrary size. */
.badge-mod-photo-large {
    display: block;
    width: 220px;
    height: 220px;
    margin: 0 auto var(--margin-field, 1rem);
    border-radius: 50%;
    object-fit: cover;
    border: 1px solid var(--card-border, #eeeeee);
}

.badge-mod-btn-secondary {
    padding: 0.4rem 0.9rem;
    border-radius: var(--radius-btn, 4px);
    cursor: pointer;
    font-size: 0.85rem;
    background: transparent;
    color: var(--body-text, #333333);
    border: 1px solid var(--card-border, #cccccc);
}

/* --- Shared JS-confirmation popin (window.tdeskConfirm() / <form data-confirm="...">,
   public/assets/js/chrome.js's own trailing IIFE) — the ONE popin every confirmable action in this
   app goes through, replacing every native confirm() (see this project's own CLAUDE.md "JS
   confirmations" section). Same fixed+inset:0+flex-center shape as .badge-mod-backdrop/#module-grid,
   but toggled via .open (added/removed by chrome.js) rather than [hidden] — matches the
   .admin-modal-backdrop/.open convention below, since this is built once, lazily, in JS (not
   present in any page's own markup) rather than rendered per-page by PHP. --- */
.admin-confirm-backdrop {
    position: fixed;
    inset: 0;
    z-index: 3000;
    display: none;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, .45);
}

.admin-confirm-backdrop.open {
    display: flex;
}

.admin-confirm-modal {
    width: 320px;
    max-width: 90vw;
    background: var(--card-bg, #ffffff);
    border-radius: var(--radius-card, 6px);
    padding: var(--padding-card, 1rem);
    text-align: center;
}

.admin-confirm-message {
    margin: 0 0 var(--margin-field, 1rem);
    color: var(--body-text, #333333);
    white-space: pre-line;
}

.admin-confirm-actions {
    display: flex;
    gap: 0.5rem;
    justify-content: center;
}

.admin-confirm-btn {
    padding: 0.4rem 0.9rem;
    border-radius: var(--radius-btn, 4px);
    cursor: pointer;
    font-size: 0.85rem;
    font-weight: 600;
    border: none;
}

.admin-confirm-btn-secondary {
    background: transparent;
    color: var(--body-text, #333333);
    border: 1px solid var(--card-border, #cccccc);
}

.admin-confirm-btn-primary {
    background: var(--btn-bg, #3b82f6);
    color: var(--btn-text, #ffffff);
}

.admin-confirm-btn-danger {
    background: #dc2626;
    color: #ffffff;
}

/* ============================================================================================
   --- Admin list pattern (Tad\App\Http\AdminList\{ColumnCatalog,Pager}, tad.admin_list.* templates)
   Shared by EVERY admin list in the app (see this project's own CLAUDE.md "Admin list pattern"
   section) — sticky header, configurable/frozen columns, pagination, icon-only compact action
   buttons under horizontal scroll pressure. First built for Tdesk\Si\Admin\Routing's own servers
   list (2026-09-14), generalized here once every other admin list adopted the same shape. A list
   page only ever needs these class names on its own markup (see si.admin.servers for the
   reference implementation) — none of this is SI-specific despite the "admin-*" prefix reading
   generically on purpose (was "si-*" originally, renamed on generalizing). ============================================================================================ */

.admin-list-flex { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.admin-list-toolbar { flex: none; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; }
.admin-list-toolbar p { margin: 0; }
.admin-perpage-form { margin: 0; }
.admin-perpage-form label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--body-text, #333333); }
.admin-perpage-form select { width: auto; }

/* The ONE scroll container for a list's own table — flex:1 inside .admin-list-flex (itself
   height:100% of #content, see that rule's own comment) gives this a definite, viewport-bounded
   height, so both its scrollbars (vertical AND horizontal) sit at ITS OWN edges, immediately
   visible, rather than at the bottom of a table that can run to 80-100+ rows — reported live
   against the very first list this pattern shipped on (Tdesk\Si's own servers list): without a
   bounded height here, the table's horizontal scrollbar existed but only became reachable after
   scrolling the whole (very tall) thing vertically first. */
.admin-table-scroll { flex: 1; min-height: 0; overflow: auto; position: relative; }

.admin-pagination { flex: none; display: flex; align-items: center; justify-content: center; gap: 0.75rem; padding-top: 0.75rem; }
.admin-pagination-info { font-size: 0.85rem; color: var(--body-text, #333333); }
.admin-pagination-disabled { opacity: 0.5; pointer-events: none; }

/* Sticky header + frozen identity/actions columns (+ any optional column marked "Figée" in the
   Colonnes popin) — three independent sticky layers that can overlap at the header row's own
   frozen cells, hence the z-index ladder: plain sticky header (2) < plain sticky column (1) < a
   cell that's BOTH (corner, 3). Actual `left`/`right` pixel offsets for anything beyond the very
   first frozen-left/-right column are computed in JS (chrome.js's own applyAdminTableFrozen(),
   run against every `table.admin-table` on the page) — CSS alone can't express "this column's
   offset is the sum of every frozen column before it" for a column set whose widths depend on
   real content and can change per viewer/popin choice. */
.admin-table-scroll thead th {
    position: sticky;
    top: 0;
    z-index: 2;
}
.admin-col-frozen-left,
.admin-col-frozen-right {
    position: sticky;
}
.admin-col-frozen-left { left: 0; z-index: 1; }
.admin-col-frozen-right { right: 0; z-index: 1; }
/* Body cells only: background: inherit pulls in the ROW's own computed background (tbody
   tr:nth-child(even/odd)'s zebra striping, above) so a frozen cell fully occludes the scrolled
   columns passing underneath it — a <td> has no background of its own by default, so without this
   its parent <tr>'s background would show through as transparent instead. Deliberately scoped to
   tbody, NOT also thead th: a <tr> itself never carries a background (only the global `thead th`
   rule above sets one, directly on the cell), so "inherit" on a HEADER cell would resolve to its
   <tr>'s own background-color — transparent — undoing that rule instead of reinforcing it.
   Reported live: the frozen columns' background didn't match the configured theme color at all,
   specifically in the header row — this is why. */
.admin-table-scroll tbody .admin-col-frozen-left,
.admin-table-scroll tbody .admin-col-frozen-right {
    background: inherit;
}
.admin-table-scroll thead th.admin-col-frozen-left,
.admin-table-scroll thead th.admin-col-frozen-right {
    z-index: 3;
}

/* "Modifier"/"Supprimer"-style row actions — icon + label normally; .admin-actions-compact
   (toggled by chrome.js whenever the table actually needs its horizontal scrollbar) hides the
   label so the frozen actions column shrinks to icon-only exactly when column width is tightest. */
.admin-action-btn {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
}
.admin-table-scroll.admin-actions-compact .admin-action-label {
    display: none;
}
/* `button, .app-button` above gives every button a margin-bottom specifically so a row of them
   CAN wrap onto a new line on a narrow viewport without landing flush against each other (see
   that rule's own comment) — exactly what must NOT happen in a frozen actions column: the buttons
   always stay side by side on one line, letting the (sticky, so always-reachable) column grow
   wider instead of taller. white-space:nowrap on the cell is what suppresses that wrap point.
   text-align:right matches .admin-columns-th below — actions hug the column's right/outer edge,
   not its left/inner edge (which, being sticky, sits wherever scrolled content happens to end). */
td.admin-col-frozen-right {
    white-space: nowrap;
    text-align: right;
}

/* The row's own primary identity/link column ("Nom"/"Titre"/"Identifiant" — see this project's
   own CLAUDE.md "Admin list pattern" section: always the FIRST admin-col-frozen-left cell,
   structural, never part of the columns popin's own catalog) — 2026-09-15, "les données de la
   première colonne ne font pas de retour à la ligne, sauf si la largeur dépasse les 40% de la
   largeur du tableau". No white-space rule needed: the default (normal) already keeps a cell's
   content on one line whenever there's room in an auto-layout table (the browser only wraps when
   it actually has to fit a width constraint) — max-width is the ONLY thing that has to be added
   here to cap that "room" at 40%. A percentage max-width on a table cell resolves against the
   TABLE's own width, matching "de la largeur du tableau" literally (not the viewport). Applied
   per-list to just the ONE primary cell (never every .admin-col-frozen-left — a list can have
   several additional frozen catalog columns too, see ColumnCatalog's own $defaultFrozen), so a
   plain class on that cell only, not a structural :first-child selector (a couple of lists, e.g.
   the badge-moderation queue, put a non-text photo cell before it). */
.admin-col-primary {
    max-width: 40%;
}

/* A plain numeric column ("Ports"/"Serveurs" counts, "Port SSH", "Nœud"...) — 2026-09-15,
   "aligner les nombres à droite dans les colonnes". Right-aligned like a spreadsheet: numbers
   compare at a glance when their least-significant digit lines up on the same edge, which
   left-alignment (this table's own default, th/td { text-align: left }) never gives them. Applied
   per-cell by each list's own template (it's the only place that already knows which of its
   columns are actually numeric — see e.g. si.admin.servers' own $numericColumns). */
.admin-col-numeric {
    text-align: right;
}

/* --- "Colonnes" popin (tad.admin_list.columns_popin) --- */
.admin-columns-th { text-align: right; }
.admin-columns-gear { background: none; border: none; padding: 0; margin: 0; cursor: pointer; color: var(--body-text, #333333); display: inline-flex; align-items: center; }
.admin-columns-select-all { display: flex; align-items: center; gap: 0.4rem; margin: 0.75rem 0 0.25rem; font-size: 0.85rem; color: var(--body-text, #333333); }
.admin-columns-list { list-style: none; margin: 0.25rem 0 0.75rem; padding: 0; }
.admin-columns-list li { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--card-border, #eeeeee); background: var(--card-bg, #ffffff); }
.admin-columns-list li:last-child { border-bottom: none; }
.admin-columns-list li.dragging { opacity: 0.4; }
.admin-columns-list li label { display: flex; align-items: center; gap: 0.4rem; flex: 1; }
/* `input, select, textarea { width: 100% }` also reaches type="checkbox" — without this override
   each checkbox claims the label's full flex width, shoving its own text all the way to the far
   right instead of sitting right next to it. */
.admin-columns-select-all input[type="checkbox"],
.admin-columns-list input[type="checkbox"] {
    width: auto;
    flex: none;
}
.admin-col-handle { cursor: grab; color: var(--status-neutral-text, #555555); user-select: none; touch-action: none; font-size: 1rem; line-height: 1; }
.admin-col-handle:active { cursor: grabbing; }
/* .admin-columns-visible-label keeps the default li label rule's flex:1 (handle + column name
   share the row's main space) — .admin-columns-frozen-label is a short, fixed-width companion at
   the row's trailing edge, not a second flexible column. */
.admin-columns-frozen-label {
    flex: none !important;
    font-size: 0.8rem;
    color: var(--status-neutral-text, #555555);
    white-space: nowrap;
}
.admin-columns-frozen-label:has(input:disabled) {
    opacity: 0.4;
}

/* --- Generic modal chrome — the Colonnes popin above, and any admin list's own extra popins
   (an "Import Text" bulk-add, say) built the same way. --- */
.admin-modal-backdrop { position: fixed; inset: 0; background: rgba(5,6,9,0.55); display: none; align-items: center; justify-content: center; z-index: 100; }
.admin-modal-backdrop.open { display: flex; }
.admin-modal { width: 520px; max-width: 92vw; background: var(--card-bg, #fff); border: 1px solid var(--card-border, #e2e9f2); border-radius: var(--radius-card, 8px); overflow: hidden; }
.admin-modal-wide { width: 720px; }
.admin-modal-titlebar { padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--card-border, #e2e9f2); }
.admin-modal-titlebar h3 { margin: 0; font-size: 0.9rem; font-weight: 600; color: var(--heading-text, inherit); }
.admin-modal-body { padding: 1.1rem; max-height: 80vh; overflow-y: auto; }
.admin-modal-body input[type="text"], .admin-modal-body textarea { width: 100%; box-sizing: border-box; font-family: inherit; }
.admin-modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.9rem; }

/* --- Column header sort (Tad\App\Http\AdminList\{Sort,HeaderCell}) + filter
   (Tad\App\Http\AdminList\{Filters,ColumnFilter}, tad.admin_list.th_filter) controls --- */
.admin-th-sort-link {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    color: inherit;
    text-decoration: none;
}
.admin-th-sort-link:hover { text-decoration: underline; }
.admin-sort-icon { flex: none; opacity: 0.4; }
.admin-sort-icon-active { opacity: 1; }
.admin-th-filter {
    margin-top: 0.35rem;
    display: flex;
    gap: 0.25rem;
    font-weight: normal;
}
.admin-th-filter-select {
    flex: none;
    width: auto;
    font-size: 0.75rem;
    padding: 0.15rem 0.3rem;
}
.admin-th-filter-input {
    width: 100%;
    min-width: 4rem;
    font-size: 0.75rem;
    padding: 0.15rem 0.3rem;
}
.admin-filter-toolbar { display: flex; align-items: center; gap: 0.5rem; }
