/* Generic pointer affordance for clickable non-button elements (e.g. trust
   insight cards opening their provenance drill-down). */
.cursor-pointer { cursor: pointer; }

/* Prevent page-level scrolling when master-detail layout is active */
html:has(.master-detail-container),
html:has(.master-detail-container) body,
html:has(.master-detail-container) #kvapp {
    overflow: hidden !important;
    max-height: 100vh !important;
}

/* Remove uppercase button and navbar text from Sandstone theme */
.btn, .navbar .nav-link {
    text-transform: none;
}

/* M9 — la valeur sélectionnée d'un TomSelect peignait sous le chevron.
 *
 * Le contrôle réserve pourtant la place : `.ts-control` a `padding-right: 36px`
 * et `overflow: hidden`. Mais l'item est en `white-space: nowrap` et
 * `overflow: visible`, donc il ne se contraint pas à la zone de contenu : il
 * peint par-dessus le chevron, puis se fait couper net au bord du contrôle,
 * sans ellipse ni coupure lisible. Mesuré sur `Porte sur` (chantier
 * « CHA-4TPS — Westfield Les 4 Temps — mail central ») : item de 392 px dans un
 * contrôle de 310 px, dépassant de 130 px la zone de contenu.
 *
 * `min-width: 0` est nécessaire et pas décoratif : `.ts-control` est un
 * conteneur flex, et un enfant flex refuse par défaut de rétrécir sous sa
 * largeur de contenu — `max-width` seul ne suffit donc pas.
 *
 * Ce que l'ellipse retire est rendu au survol par `installTruncatedSelectionTooltip`
 * (App.kt), qui ne pose le `title` que lorsque le texte est réellement coupé. */
.ts-wrapper .ts-control > .item {
    min-width: 0;
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ⚠️ `text-overflow` ne s'applique **pas** au contenu d'un conteneur flex, et
 * `.item` est `display: flex` chez TomSelect : le texte se faisait bien couper
 * à la bonne largeur, mais sans « … », donc coupé net au milieu d'un mot — la
 * borne était juste, la coupure muette. Repasser en `display: block` rétablit
 * l'ellipse (mesuré : « CHA-4TPS — Westfield Les 4 Te… »).
 *
 * Cantonné à `.single` : en sélection multiple, `.item` est une puce qui porte
 * sa croix de suppression, et c'est le flex qui l'aligne. */
.ts-wrapper.single .ts-control > .item {
    display: block;
}

/* `#570` — dans une fenêtre d'édition, la liste ouverte se donne la largeur de
 * ses options au lieu de celle du champ. 📏 À 128 px, « GC-AC-BAR — Garde-corps
 * acier barreaudé » tenait sur quatre lignes et le quatrième article était
 * coupé. Au moins la largeur du champ, au plus 32rem : un libellé plus long
 * revient à la ligne au lieu de sortir de l'écran. Limité à `.modal` : ailleurs
 * (filtres, en-têtes de liste), la liste ne doit pas déborder sur ses voisins. */
.modal .ts-dropdown {
    min-width: 100%;
    width: max-content;
    max-width: min(32rem, 90vw);
}

/* Fix grey dropdown items and uppercase text in Sandstone theme */
.dropdown-menu .dropdown-item {
    color: #325D88;  /* Sandstone primary color */
    text-transform: none;
}

.dropdown-menu .dropdown-item:hover,
.dropdown-menu .dropdown-item:focus {
    color: #325D88;
    background-color: var(--surface-row-hover);
}

/* Sticky navbar - ensure background covers content when scrolling */
.navbar.sticky-top {
    background-color: var(--surface-row-hover);
    z-index: 1020;
}

/* M1a — la colonne d'actions figée est épinglée au bord droit de la zone
 * défilante : dès que la table déborde, elle flotte au-dessus de vraies
 * cellules. Rien ne le disait à l'écran, et une colonne aux deux tiers cachée
 * sous un aplat opaque se lit comme une colonne vide, pas comme une colonne
 * recouverte.
 *
 * L'ombre n'est peinte que sous `.frozen-overlays-content`, classe posée par
 * `ShowEntitiesActionHandler` quand `scrollWidth > clientWidth` — une ombre sur
 * une table qui tient affirmerait qu'il y a du contenu dessous alors qu'il n'y
 * en a pas.
 *
 * ⚠️ Ne pas ajouter ici de `border-left` : la frontière existe déjà. Tabulator
 * pose `.tabulator-row .tabulator-cell.tabulator-frozen.tabulator-frozen-right
 * { border-left: 2px solid #dee2e6 }` dans sa propre feuille, et la règle
 * `[data-theme="dark"] .tabulator .tabulator-row .tabulator-cell` plus bas la
 * reteinte au thème (mesuré : rgb(48,54,64) en sombre). Une règle
 * `.tabulator-frozen-right { border-left: … }` est écrasée par spécificité et
 * ne fait rien — elle a été écrite ici puis retirée après mesure.
 *
 * ⚠️ Ne pas non plus repasser par `box-shadow` **sur les cellules** : c'était
 * la première version, et elle rendait un store vénitien. Chaque cellule
 * projette alors sa propre ombre, que le fond opaque de la cellule voisine
 * rogne aussitôt, et les bordures de ligne découpent le reste — l'objet est
 * droit, l'ombre ondulait. L'ombre d'un bord droit se peint **une fois**, d'où
 * la bande unique ci-dessous.
 *
 * `--frozen-overlay-offset` est mesurée en JS (distance entre le bord droit de
 * la table et le bord gauche de la colonne figée) plutôt que déduite de sa
 * largeur : une barre de défilement verticale décale ce bord, et une valeur
 * déduite placerait la bande à côté du joint qu'elle est censée souligner. */
/* `#393` — **la bande dit COMBIEN est caché, et le texte s'y estompe.**
 *
 * ⛔ Deux défauts de la version précédente, mesurés au navigateur le 2026-09-18
 * sur la liste des devis, aux trois largeurs :
 *
 * 1. **12 px constants** — la même bande pour 39 px cachés (1728 px) et pour
 *    142 px (1280 et 1024). Un signal qui ne varie pas avec ce qu'il signale ne
 *    renseigne pas : il dit « il y a quelque chose », jamais « il y a beaucoup ».
 * 2. ⛔ **Une ombre À CÔTÉ n'empêche pas de lire un chiffre TRANCHÉ.** Les
 *    cellules recouvertes sont des montants alignés à **droite** : ce sont les
 *    derniers chiffres qui passent dessous. `18 666,00 €` se lisait `18 666,` —
 *    un nombre **complet et plus petit**, que rien ne signale comme coupé. C'est
 *    le critère de `perte-de-donnee` : l'écran ne dit rien d'étrange.
 *
 * ⇒ D'où un **voile de la couleur du fond** plutôt qu'une ombre noire : les
 * derniers caractères s'**effacent** au lieu d'être sectionnés, et une valeur
 * estompée ne se lit pas comme une valeur.
 *
 * ⚠️ **`--surface-card` est le bon jeton, et l'élément qui le PEINT change avec
 * le thème** — 📏 mesuré le 2026-09-18 dans les trois états, en remontant la
 * chaîne jusqu'au premier fond non transparent :
 *
 * | état | fond peint | peint par |
 * |---|---|---|
 * | clair tamponné | `rgb(255,255,255)` | `.tabulator-row` |
 * | sombre tamponné | `rgb(29,33,39)` | `.tabulator-cell` |
 * | **système non tamponné** | `rgb(255,255,255)` | `.tabulator-row` |
 *
 * ⇒ Le jeton correspond dans les trois cas, mais **la cellule est transparente
 * en clair** : c'est la ligne qui peint. Une première rédaction disait « le fond
 * réel des cellules », vrai en sombre seulement. ⓘ Ça n'a pas de conséquence
 * ici — on lit le **jeton**, pas l'élément — mais quelqu'un qui vérifierait en
 * lisant `background-color` d'une cellule en clair mesurerait `transparent` et
 * conclurait à un défaut.
 *
 * ⚠️ **Et le troisième état n'est pas décoratif** : ce lot retire la règle
 * `[data-theme="dark"]` qui existait ici. Une couleur qui ne vivrait que sous un
 * `[data-theme]` ne s'appliquerait pas à l'état **système**, celui que voit qui
 * n'a rien choisi — c'est-à-dire le défaut le plus courant.
 *
 * ⛔ **Mais « système » n'est PAS ici un troisième jeu de couleurs**, et il ne
 * faut pas lire la table ci-dessus comme si le sombre-système était couvert.
 * 📏 Recensé le 2026-09-18 en parcourant les `cssRules` de toutes les feuilles
 * chargées : **aucun `@media` ne redéfinit `--surface-card`** — ses **cinq**
 * déclarations sont `:root` et les quatre blocs `[data-theme="…"]`. ⇒ Sans
 * tampon, la palette est celle de `:root`, donc claire, quel que soit le réglage
 * du système d'exploitation.
 *
 * ⚠️ **Et l'affirmation s'arrête à ce jeton.** Une première rédaction disait
 * « zéro `@media (prefers-color-scheme)` dans ce dépôt » : c'est **faux**, il y
 * en a 7 dans 4 fichiers (`ToneColors.kt`, `WebPalette.kt`, `mr-monitor.py`, et
 * ce fichier même, 3 fois). La commande portait sur les déclarations d'**un**
 * jeton dans les feuilles **chargées** ; la phrase portait sur tout le dépôt.
 * ⓘ Le jeton se publie à l'exécution depuis `themes/*.json`, donc `git grep`
 * sur `--surface-card:` ne le trouve nulle part — la mesure devait bien se
 * prendre au navigateur, mais elle ne conclut que sur ce qu'elle a regardé.
 *
 * ⓘ Ce qui reste vrai et suffit : le voile lit le **jeton**, donc il suit le
 * thème sans qu'on y retouche — c'est précisément ce qu'une couleur écrite en
 * dur sous `[data-theme="dark"]` ne faisait pas, et la raison de retirer celle
 * qui était ici.
 *
 * ⭐ **Le voile disparaît quand il n'y a plus rien à cacher.** 📏 Mesuré à
 * 1024 px, défilement poussé au bout : `.frozen-overlays-content` est retirée,
 * `::after` passe à `content: none`, plus aucune bande. Le contrôle inverse est
 * pris dans la foulée — retour à gauche, la classe revient et la bande vaut
 * 70 px. ⇒ C'est un indicateur d'état, pas un ornement permanent, et c'est ce
 * qui lui donne le droit d'être aussi large quand beaucoup est caché.
 *
 * ⭐ `--frozen-overlay-hidden` est `min(remaining, largeur de la colonne figée)`,
 * et le minimum n'est pas une prudence : `remaining` seul vaut **487** à 1280 px
 * quand **142** seulement sont recouverts — le reste sort du cadre à droite,
 * débordement légitime de `#138 §1` que personne ne cache.
 *
 * ⚠️ **Le plancher de 26 px est le cas DANGEREUX**, pas le cas mineur : c'est à
 * 1728 px — là où la table a l'air de tenir et où seuls 39 px manquent — que
 * rien n'alerte. Un voile strictement proportionnel y serait le plus faible,
 * exactement là où il faut avertir le plus.
 *
 * ⓘ Le filet du bord vient en plus du `border-left` que Tabulator pose déjà sur
 * la cellule figée : celui-ci marque la frontière du **joint**, le filet marque
 * celle du **voile**, qui commence plus à gauche. */
.tabulator.frozen-overlays-content::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    right: var(--frozen-overlay-offset, 0px);
    width: clamp(26px, calc(var(--frozen-overlay-hidden, 12px) + 14px), 70px);
    z-index: 11;
    pointer-events: none;
    /* Le voile prend le fond des cellules — donc le thème, jamais un blanc.
     *
     * ⛔ **Et le jeton s'écrit SANS repli**, contrairement à une première
     * rédaction qui portait `var(--surface-card, #fff)`. `--surface-card` est
     * publiée à l'exécution depuis `themes/*.json` pour les cinq thèmes : le
     * repli ne s'applique donc **jamais**. C'est la forme que
     * `StylesheetKeepsNoStrayColourGuardTest` appelle la plus traître du lot —
     * elle *a l'air* d'une variable, et le jour où quelqu'un renomme le jeton
     * elle peint du blanc dans le thème sombre sans rien casser visiblement.
     * ⓘ Le reste de cette feuille l'écrit déjà nu (`background-color:
     * var(--surface-card)`, plus bas) : c'étaient ces trois replis l'exception. */
    background:
        linear-gradient(to right, transparent 0%, rgba(0, 0, 0, 0.10) 100%),
        linear-gradient(to left,
            var(--surface-card) 0%,
            color-mix(in srgb, var(--surface-card) 55%, transparent) 45%,
            transparent 100%);
}
.tabulator.frozen-overlays-content::before {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    right: var(--frozen-overlay-offset, 0px);
    width: 2px;
    z-index: 12;
    pointer-events: none;
    /* Même règle que le voile ci-dessus : le jeton, sans repli. */
    background: var(--surface-border);
}

/* Column header gear icon: overlay in top-right, visible on hover */
.tabulator-col .col-gear-btn {
    position: absolute;
    top: 2px;
    right: 2px;
    opacity: 0;
    cursor: pointer;
    z-index: 1;
    font-size: 0.75rem;
    transition: opacity 0.15s;
}
.tabulator-col:hover .col-gear-btn {
    opacity: 0.5;
}
.tabulator-col .col-gear-btn:hover {
    opacity: 1 !important;
}

/* Tabulator sort icons: hide arrow when unsorted */
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="none"] .tabulator-col-sorter {
    display: none;
}

/* ⛔ **Un champ plus long que sa colonne se coupait NET.** 📏 Mesuré le
   2026-09-14 : 364 px de texte dans 257, `text-overflow: clip` — la valeur
   s'arrêtait au milieu d'un mot, et rien ne disait qu'il en manquait. Trois
   points le disent ; l'infobulle de la cellule (`EntityFormBuilder`) le rend
   lisible, parce qu'un champ désactivé ne reçoit pas le survol. */
.form-grid-cell input[type=text],
.form-grid-cell input[type=number] {
    text-overflow: ellipsis;
}

/* Sticky toolbar for browse/list views */
.browse-toolbar-sticky {
    position: sticky;
    top: 56px;  /* navbar height */
    background-color: var(--surface-row-hover);
    z-index: 1010;
    flex-shrink: 0;
    /* 📏 Le tableau était **collé** à la barre — 0 px — quand l'en-tête, lui,
     * s'en détachait de 8 px (Didier, 2026-09-14, sur capture). Un même geste
     * de séparation, deux valeurs opposées.
     *
     * ⭐ **Et l'espace est un `padding`, pas un `margin` — c'est la barre qui
     * doit le peindre.** Elle est `sticky` : sous une marge, le fond de la
     * barre s'arrête à son bord, et les lignes du tableau qui défilent
     * passeraient dans le filet de 8 px ainsi laissé transparent. En padding,
     * le fond opaque couvre l'écart et rien ne transparaît au défilement.
     *
     * ⚠️ **Et le haut reçoit le même**, sinon la correction crée sa propre
     * asymétrie : 📏 la barre portait déjà 9 px au-dessus de son bouton ; le
     * seul `padding-bottom` en mettait 17 en dessous, et le bouton ne tenait
     * plus au milieu de sa bande. 17 de chaque côté. */
    padding-top: 0.5rem;
    padding-bottom: 0.5rem;
}

/* In master-detail mode, toolbar is in a flex column — no sticky needed */
html:has(.master-detail-container) .browse-toolbar-sticky {
    position: static;
}

/* Master/detail row selection */
.tabulator-row.tabulator-selected {
    background-color: #cfe2ff !important;
}

/* C74 — RowStyle. The state machine decides WHICH rows get these classes
   (`@State(rowStyle = …)`, twenty-two declarations across billing,
   manufacturing, sports, chess and planning); this file only decides what
   they look like.

   Deliberately not colour-only: UC-02 criterion 3 requires a cancelled slot
   to stay distinguishable "in black and white, printed". Opacity and a
   line-through survive a monochrome print; a hue does not. */
.tabulator-row.row-emphasised .tabulator-cell {
    font-weight: 600;
}
/* The dimming is a `color`, **not an `opacity`** — and that is a correctness
   point, not a taste one. `opacity` below 1 creates a stacking group, so no
   descendant can ever be *more* opaque than its ancestor: the `opacity: 1`
   exception written below for `.row-action` was silently ineffective, and the
   icons of a cancelled invoice faded with the rest of the row. Seen in the
   browser on FAC2026-0106 — and *not* seen by reading `getComputedStyle`,
   which reports the declared `opacity: 1` on the button and says nothing
   about the group it is composited into. `color` inherits rather than
   composites, so a descendant can genuinely override it. */
.tabulator-row.row-dimmed .tabulator-cell,
.tabulator-row.row-strikethrough .tabulator-cell {
    color: var(--surface-muted);
}
.tabulator-row.row-strikethrough .tabulator-cell {
    text-decoration: line-through;
}
/* The action buttons stay legible whatever the row style: dimming a row says
   "this record is less important", never "these controls are disabled" — and
   a struck-through Delete button reads as already done. `text-decoration`
   propagates to descendants and cannot be cancelled by them; what stops it
   here is `.row-action` being `display: inline-flex`, i.e. an atomic
   inline-level box, which does not inherit the line. The rule below is
   therefore belt-and-braces: keep it, so that changing the button's `display`
   does not silently put a line through the icons. */
.tabulator-row.row-dimmed .tabulator-cell .row-action,
.tabulator-row.row-strikethrough .tabulator-cell .row-action {
    color: var(--surface-muted);
    text-decoration: none;
}

/* #165 — RowAccent tones. The DESCRIPTION decides which row carries which tone
   (a report script's `_tone`); this file only decides what a tone looks like,
   the same split `row-emphasised` above already follows.

   The colour is `--bs-<role>`, i.e. the very variable the status badges are
   built from (`toneToBadgeClass` → `bg-warning`, `bg-danger`, …). That is
   deliberate and it is the point of #94: a hand-picked hex here would be the
   fifth tone→colour table in the repository, and the fifth one to drift.

   ⚠️ **The left bar is the signal; the tint is the comfort.** Same reason the
   block above dims with `color` rather than `opacity`: UC-02 criterion 3 wants
   the row to stay distinguishable printed in black and white. A 3 px rule
   survives a monochrome print, a pale hue does not — so the bar is drawn for
   every tone, and any tint stays a second, redundant cue.

   Drawn with `inset box-shadow` rather than `border-left`, exactly like
   `.lane-grid-lane.over-capacity`: a border would add to the row's box and
   shift every cell by 3 px when a tone appears. */
.tabulator-row[class*="row-tone-"] .tabulator-cell:first-child {
    box-shadow: inset 3px 0 0 var(--row-tone-color);
}
.tabulator-row[class*="row-tone-"] .tabulator-cell {
    background-color: var(--row-tone-tint);
}
.tabulator-row.row-tone-warning {
    --row-tone-color: var(--tone-warning-bg);
    --row-tone-tint: var(--tone-warning-soft);
}
.tabulator-row.row-tone-danger {
    --row-tone-color: var(--tone-danger-bg);
    --row-tone-tint: var(--tone-danger-soft);
}
.tabulator-row.row-tone-success {
    --row-tone-color: var(--tone-success-bg);
    --row-tone-tint: var(--tone-success-soft);
}
.tabulator-row.row-tone-info {
    --row-tone-color: var(--tone-info-bg);
    --row-tone-tint: var(--tone-info-soft);
}
.tabulator-row.row-tone-muted {
    --row-tone-color: var(--tone-muted-bg);
    --row-tone-tint: transparent;
}
.tabulator-row.row-tone-done {
    --row-tone-color: var(--tone-done-bg);
    --row-tone-tint: var(--tone-done-soft);
}
/* The selected row keeps its own background: selection is about what the user
   is doing now, the tone is about what the row means, and "now" wins. The bar
   is unaffected, so the meaning does not disappear while a row is selected. */
.tabulator-row.tabulator-selected[class*="row-tone-"] .tabulator-cell {
    background-color: transparent;
}

/* Detail panel headers */
.detail-panel-header {
    padding: 0.4rem 0.75rem;
    font-weight: 600;
    font-size: 0.85rem;
    border-bottom: 1px solid #dee2e6;
    background-color: var(--surface-row-hover);
    flex-shrink: 0;
}

/* Branch banner — shown when a branch is active */
.branch-banner {
    background-color: #fff3cd;  /* Bootstrap warning yellow */
    border-bottom: 1px solid #ffc107;
    font-size: 0.9rem;
    position: sticky;
    top: 56px;  /* below navbar */
    z-index: 1015;
}

/* Branch active tint — the frame itself is the inset border further down.
   `#338` lot 5 — ⛔ this rule used to carry `min-height: 100vh` and a literal
   `#fffbf0`. Measured in the served sidebar chrome: the content column is
   already sized by its flex parent, so `100vh` made it one topbar + one banner
   taller than the window — a SECOND scrollbar, 105 px, on every screen in
   copy mode. And the literal stayed pale on the dark theme. Both gone; the
   class stays because the inset border hangs on it (lot 4 rule, below). */

/* Sticky form buttons - always visible at bottom */
.form-buttons-sticky {
    position: sticky;
    bottom: 0;
    background-color: var(--surface-row-hover);
    padding: 1rem 0;
    margin-top: 1rem;
    border-top: 1px solid #dee2e6;
    z-index: 10;
}

/* La rangée de boutons d'une barre d'actions : centrée tant qu'elle tient,
   alignée à gauche dès qu'elle déborde.

   ⛔ **Le défaut que ça ferme, mesuré le 2026-09-22 à 1280 × 900.** La fiche
   d'un devis offre 14 actions : 1 132 px de boutons dans un conteneur de 977.
   Avec `justify-content: center`, les 155 px de trop débordent **des deux
   côtés** — et le débord de GAUCHE passe sous la barre latérale
   (`.chrome-sidebar-rail`, `z-index: 100` contre 10 ici).

   ⚠️ **Et ce débord-là est irrattrapable, pas seulement gênant.** Le conteneur
   de page défile horizontalement, mais `scrollLeft` ne descend pas sous 0 en
   LTR : défiler vers la droite éloigne encore les boutons. Mesuré — « Retour »
   passe de x=126 à x=12, toujours sous le rail. ⇒ *Retour* et *Modifier*
   étaient injoignables par n'importe quel geste humain.

   ⭐ `safe center` est fait exactement pour ça : identique à `center` tant que
   le contenu tient, replié sur `start` dès qu'il déborde. Le rendu ne change
   donc **que** dans le cas cassé. Mesuré après : « Retour » à x=264,
   `elementFromPoint` rend le bouton lui-même, et le dernier bouton reste
   atteignable par le défilement horizontal, qui va maintenant dans le bon sens.

   ⓘ Repli sûr si un moteur ignorait le mot-clé `safe` : la déclaration entière
   est jetée, `justify-content` retombe sur `normal`, qui se comporte comme
   `flex-start` — c'est-à-dire le comportement corrigé, pas le défaut.

   ⚠️ **Ceci ne corrige QUE l'atteignabilité.** Que 14 actions de même poids
   soient la bonne façon de présenter une fiche est une autre question, ouverte
   en `#458`, et rien ici ne la tranche. */
.form-buttons-row {
    justify-content: safe center;
}

/* `#458` — **une barre de fiche passe à la ligne plutôt que de déborder.**
   📏 2026-09-29, devis brouillon à 1 280 px : 15 boutons pour 977 px, et les
   quatre derniers (Aperçu, Envoyer par courriel, Demander un acompte, Exporter
   en PDF) hors de l'écran — la barre collante n'a pas de défilement horizontal
   qui les rattrape, contrairement à ce que le commentaire ci-dessus supposait.
   Et un libellé long s'écrivait sur quatre lignes dans un bouton étroit (barre
   de 124 px). ⇒ La rangée passe à la ligne, un libellé tient sur la sienne.
   Le rangement (menu, deux rangées, tout à la suite) est la facette qui le dit
   (`RecordActionLayout`) ; ceci ne garantit que l'atteignabilité. */
.form-buttons-sticky .form-buttons-row {
    flex-wrap: wrap;
    row-gap: 0.5rem;
}
.form-buttons-sticky .form-buttons-row > .btn,
.form-buttons-sticky .form-buttons-row > .action-refused > .btn {
    white-space: nowrap;
}
/* `TWO_ROWS` : une rupture pleine largeur ; ce qui suit commence sa rangée. */
.form-buttons-row > .form-buttons-break {
    flex-basis: 100%;
    height: 0;
}

/* `#356` — a field the description shows without its label (the card around
 * it already names it). Hidden from the eye only, the Bootstrap
 * `visually-hidden` way: the <label> stays, and so does the control's
 * accessible name. `display: none` would have removed both. */
.form-grid-cell--label-hidden > .form-group > .form-label {
    position: absolute !important;
    width: 1px !important;
    height: 1px !important;
    padding: 0 !important;
    margin: -1px !important;
    overflow: hidden !important;
    clip: rect(0, 0, 0, 0) !important;
    white-space: nowrap !important;
    border: 0 !important;
}

/* Form content needs bottom padding so the last field isn't hidden
   behind the sticky button bar when scrolled to the bottom. */
.form-content {
    padding-bottom: 5rem;
}

/* ============================================================
   F.8 + F.9 — theme tokens (M.7 wiring) and demo brand identity.

   ⛔ **Les valeurs ne sont plus ici.** Depuis `#94`, la vérité d'un
   thème vit dans `themes/<id>.json` à la racine du dépôt, et
   `:web:generateThemeCss` en engendre `themes.generated.css` — lié
   AVANT ce fichier par `index.html`. Écrire un `:root { --brand-… }`
   ici recréerait une seconde source, libre de diverger de celle que lit
   Android : c'est exactement le défaut que ce lot a retiré, et il avait
   déjà produit deux valeurs fausses dans `dark`.

   Ce qui reste ici, et pourquoi : `[data-density="comfortable"]`
   surcharge les jetons de densité que porte le `:root` engendré.
   ⚠️ Il DOIT rester APRÈS lui — même spécificité (0,1,0), donc c'est
   l'ordre d'apparition qui décide, pas la sélectivité.
   ============================================================ */
[data-density="comfortable"] {
    --row-height: 48px;
    --cell-padding-y: 12px;
    --cell-padding-x: 14px;
    --form-row-gap: 0.7rem;
    --form-col-gap: 1rem;
    --header-band-h: 56px;
    --toolbar-band-h: 56px;
    --action-icon-size: 1.15rem;
    --action-icon-padding: 0.4rem;
    --form-control-py: 0.45rem;
    --card-padding: 1.1rem;
    --base-font-size: 14px;
}


/* Apply theme variables to body and form chrome. */
body {
    background-color: var(--surface-bg);
    color: var(--surface-fg);
    font-family: var(--font-base-family);
    font-size: var(--base-font-size);
    font-feature-settings: "ss01", "cv11";  /* Inter stylistic alts: rounded a, single-storey g */
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}
[data-theme="dark"] body { color-scheme: dark; }

/* D.1 — density-aware Tabulator: cell padding + row height pulled from
   the density tokens. Compact = MEG-tight; comfortable = the previous
   spacious default. */
.tabulator .tabulator-row { min-height: var(--row-height); }
.tabulator .tabulator-row .tabulator-cell {
    padding: var(--cell-padding-y) var(--cell-padding-x);
}
.tabulator .tabulator-header .tabulator-col {
    padding-top: var(--cell-padding-y);
    padding-bottom: var(--cell-padding-y);
}

/* Density-aware form controls. */
.form-control, .form-select, textarea.form-control {
    padding-top: var(--form-control-py);
    padding-bottom: var(--form-control-py);
}

/* Density-aware form grid (F.1) — keep the column count, tighten the gaps. */
.form-grid { row-gap: var(--form-row-gap); column-gap: var(--form-col-gap); }
.form-grid-cell .form-group,
.form-grid-cell .mb-3 { margin-bottom: var(--form-row-gap) !important; }

/* D.3 — tighten the label-above-input gap in compact mode. KVision
   wraps every form control in a `.form-group` and renders the label
   with a default 8 px bottom margin; that adds ~10 px of dead space
   per field on a dense form. */
.form-grid-cell .form-group > label,
.form-grid-cell .form-group > .form-label {
    margin-bottom: 2px;
    font-size: 0.78rem;
    font-weight: 500;
    color: var(--surface-muted);
    line-height: 1.2;
    text-transform: none;
}
[data-density="comfortable"] .form-grid-cell .form-group > label,
[data-density="comfortable"] .form-grid-cell .form-group > .form-label {
    margin-bottom: 4px;
    font-size: 0.85rem;
}

/* Density-aware card padding. */
.card-body { padding: var(--card-padding); }
.card-header { padding: var(--cell-padding-y) var(--card-padding); }

/* `#349` — headlines are Inter, like the body: one family, told apart by
 * weight. They were Fraunces, a high-contrast display serif whose hairlines
 * vanish at screen-title sizes (24-30 px) — the « A » of « Affaires » read as
 * « f » on the demo. The family is a theme value (`font-headline-family`,
 * themes/light.json); the tracking below is the other half of the change and
 * lives here, on the SEVEN rules that read that variable — -0.02em, because a
 * grotesque at title size looks dated without it. A rule that starts reading
 * `--font-headline-family` should carry it too. */
h1, h2, h3, h4, h5, h6,
.headline, .navbar-brand {
    font-family: var(--font-headline-family);
    font-weight: 600;
    letter-spacing: -0.02em;
}
.navbar-brand { font-size: 1.25rem; }

/* F.9 — tabular figures everywhere a number lives in a column / total / KPI. */
.tabulator-cell,
.line-table-totals,
.form-display-value,
.kpi-value,
.numeric { font-variant-numeric: tabular-nums; }
/* ⛔ **A total is not a line like the others**, and nothing said so.
 * 📏 Measured 2026-09-14 on a quote's fiche: the totals strip came out at
 * 13 px / weight 400 — the body's own weight — so « Marge », « Déboursé
 * total » and « PV calculé » read as one more row of the table they close.
 * The figures already had tabular digits (above); what they lacked is the
 * weight that says « this is the sum ». ⓘ Their PLACE — below the table
 * instead of at its head — is a renderer decision, and it is NOT fixed here. */
.line-table-totals {
    font-family: var(--font-base-family);
    font-weight: 600;
}

/* Cards inherit the brand surface + a softer shadow than Bootstrap default. */
.card {
    background-color: var(--surface-card);
    border-color: var(--surface-border);
    border-radius: var(--radius-md);
    box-shadow: var(--surface-shadow);
}
.card-header {
    background-color: transparent;
    border-bottom-color: var(--surface-divider);
    font-family: var(--font-headline-family);
    font-weight: 600;
    letter-spacing: -0.02em;
}

/* F.9 brand-primary on Bootstrap utility classes that Bootswatch ships. */
.btn-primary {
    background-color: var(--brand-primary);
    border-color: var(--brand-primary);
    color: var(--brand-primary-fg);
}
.btn-primary:hover, .btn-primary:focus, .btn-primary:active {
    background-color: var(--brand-primary-hover) !important;
    border-color: var(--brand-primary-hover) !important;
    color: var(--brand-primary-fg) !important;
}
.btn-outline-primary {
    color: var(--brand-primary);
    border-color: var(--brand-primary);
}
.btn-outline-primary:hover {
    background-color: var(--brand-primary);
    color: var(--brand-primary-fg);
}
/* `#354` — secondary buttons (« Look and feel » spec): an outline, text in the
 * brand colour, so the one filled primary button is the only thing that pulls
 * the eye. Bootstrap's `secondary` was a grey — filled for `.btn-secondary`,
 * outlined for `.btn-outline-secondary` — which read as « disabled » next to the
 * teal primary. Both spellings are mapped: the renderer writes each (17 and 20
 * sites). */
.btn-secondary,
.btn-outline-secondary {
    background-color: transparent;
    color: var(--brand-primary);
    border-color: var(--brand-primary);
}
.btn-secondary:hover, .btn-secondary:focus, .btn-secondary:active,
.btn-outline-secondary:hover, .btn-outline-secondary:focus, .btn-outline-secondary:active {
    background-color: var(--brand-primary-soft) !important;
    color: var(--brand-primary) !important;
    border-color: var(--brand-primary) !important;
}
/* `#354` — the tone buttons (a transition's Envoyer / Accepter / Refuser, a
 * form's Enregistrer) wore Bootswatch's own palette: cyan for info, lime for
 * success, whatever the theme said. 📏 Measured 2026-09-11 on a quote's fiche:
 * « Envoyer » `btn-info`, « Accepter » `btn-success`, « Enregistrer »
 * `btn-success` — none remapped anywhere; only the BADGES were. A colour the
 * theme cannot change is a colour nobody controls. They now read the tone
 * variables, filled and outlined; hover darkens through `filter`, not through a
 * second colour to maintain. */
.btn-success, .btn-danger, .btn-warning, .btn-info { border-width: 1px; }
.btn-success { background-color: var(--tone-success-bg); border-color: var(--tone-success-bg); color: var(--tone-success-fg); }
.btn-danger  { background-color: var(--tone-danger-bg);  border-color: var(--tone-danger-bg);  color: var(--tone-danger-fg); }
.btn-warning { background-color: var(--tone-warning-bg); border-color: var(--tone-warning-bg); color: var(--tone-warning-fg); }
.btn-info    { background-color: var(--tone-info-bg);    border-color: var(--tone-info-bg);    color: var(--tone-info-fg); }
.btn-success:hover, .btn-success:focus, .btn-success:active,
.btn-danger:hover,  .btn-danger:focus,  .btn-danger:active,
.btn-warning:hover, .btn-warning:focus, .btn-warning:active,
.btn-info:hover,    .btn-info:focus,    .btn-info:active { filter: brightness(0.9); }
.btn-success:hover, .btn-success:focus, .btn-success:active { background-color: var(--tone-success-bg) !important; border-color: var(--tone-success-bg) !important; color: var(--tone-success-fg) !important; }
.btn-danger:hover,  .btn-danger:focus,  .btn-danger:active  { background-color: var(--tone-danger-bg) !important;  border-color: var(--tone-danger-bg) !important;  color: var(--tone-danger-fg) !important; }
.btn-warning:hover, .btn-warning:focus, .btn-warning:active { background-color: var(--tone-warning-bg) !important; border-color: var(--tone-warning-bg) !important; color: var(--tone-warning-fg) !important; }
.btn-info:hover,    .btn-info:focus,    .btn-info:active    { background-color: var(--tone-info-bg) !important;    border-color: var(--tone-info-bg) !important;    color: var(--tone-info-fg) !important; }
.btn-outline-success { color: var(--tone-success-ink, var(--tone-success-bg)); border-color: var(--tone-success-bg); background-color: transparent; }
.btn-outline-danger  { color: var(--tone-danger-ink, var(--tone-danger-bg));   border-color: var(--tone-danger-bg);  background-color: transparent; }
.btn-outline-warning { color: var(--tone-warning-ink, var(--tone-warning-bg)); border-color: var(--tone-warning-bg); background-color: transparent; }
.btn-outline-info    { color: var(--tone-info-ink, var(--tone-info-bg));       border-color: var(--tone-info-bg);    background-color: transparent; }
.btn-outline-success:hover, .btn-outline-success:focus, .btn-outline-success:active { background-color: var(--tone-success-soft) !important; color: var(--tone-success-ink, var(--tone-success-bg)) !important; border-color: var(--tone-success-bg) !important; }
.btn-outline-danger:hover,  .btn-outline-danger:focus,  .btn-outline-danger:active  { background-color: var(--tone-danger-soft) !important;  color: var(--tone-danger-ink, var(--tone-danger-bg)) !important;   border-color: var(--tone-danger-bg) !important; }
.btn-outline-warning:hover, .btn-outline-warning:focus, .btn-outline-warning:active { background-color: var(--tone-warning-soft) !important; color: var(--tone-warning-ink, var(--tone-warning-bg)) !important; border-color: var(--tone-warning-bg) !important; }
.btn-outline-info:hover,    .btn-outline-info:focus,    .btn-outline-info:active    { background-color: var(--tone-info-soft) !important;    color: var(--tone-info-ink, var(--tone-info-bg)) !important;       border-color: var(--tone-info-bg) !important; }
/* `#354` — a toned outline (`ButtonLook.outline(tone)`, rendered by
 * `buttonClass()` as `btn-tone tone-<tone>`): a transition keeps its colour
 * without being a second filled button. Derived from the tone's name like the
 * badges, so these rules cover the enum by construction. Higher specificity
 * than `.btn-outline-secondary`, whose brand outline they recolour. */
.btn.btn-tone { background-color: transparent; }
.btn.btn-tone.tone-success { color: var(--tone-success-ink, var(--tone-success-bg)); border-color: var(--tone-success-bg); }
.btn.btn-tone.tone-danger  { color: var(--tone-danger-ink, var(--tone-danger-bg));   border-color: var(--tone-danger-bg); }
.btn.btn-tone.tone-warning { color: var(--tone-warning-ink, var(--tone-warning-bg)); border-color: var(--tone-warning-bg); }
.btn.btn-tone.tone-info    { color: var(--tone-info-ink, var(--tone-info-bg));       border-color: var(--tone-info-bg); }
.btn.btn-tone.tone-muted   { color: var(--tone-muted-ink, var(--tone-muted-bg));     border-color: var(--tone-muted-bg); }
.btn.btn-tone.tone-neutral { color: var(--tone-neutral-ink, var(--tone-neutral-fg)); border-color: var(--tone-neutral-fg); }
.btn.btn-tone.tone-done    { color: var(--tone-done-ink, var(--tone-done-bg));       border-color: var(--tone-done-bg); }
.btn.btn-tone.tone-success:hover, .btn.btn-tone.tone-success:focus { background-color: var(--tone-success-soft) !important; color: var(--tone-success-ink, var(--tone-success-bg)) !important; border-color: var(--tone-success-bg) !important; }
.btn.btn-tone.tone-danger:hover,  .btn.btn-tone.tone-danger:focus  { background-color: var(--tone-danger-soft) !important;  color: var(--tone-danger-ink, var(--tone-danger-bg)) !important;   border-color: var(--tone-danger-bg) !important; }
.btn.btn-tone.tone-warning:hover, .btn.btn-tone.tone-warning:focus { background-color: var(--tone-warning-soft) !important; color: var(--tone-warning-ink, var(--tone-warning-bg)) !important; border-color: var(--tone-warning-bg) !important; }
.btn.btn-tone.tone-info:hover,    .btn.btn-tone.tone-info:focus    { background-color: var(--tone-info-soft) !important;    color: var(--tone-info-ink, var(--tone-info-bg)) !important;       border-color: var(--tone-info-bg) !important; }
.btn.btn-tone.tone-muted:hover,   .btn.btn-tone.tone-muted:focus   { background-color: var(--tone-muted-soft) !important;   color: var(--tone-muted-ink, var(--tone-muted-bg)) !important;     border-color: var(--tone-muted-bg) !important; }
.btn.btn-tone.tone-neutral:hover, .btn.btn-tone.tone-neutral:focus { background-color: var(--tone-neutral-soft) !important; color: var(--tone-neutral-ink, var(--tone-neutral-fg)) !important; border-color: var(--tone-neutral-fg) !important; }
.btn.btn-tone.tone-done:hover,    .btn.btn-tone.tone-done:focus    { background-color: var(--tone-done-soft) !important;    color: var(--tone-done-ink, var(--tone-done-bg)) !important;       border-color: var(--tone-done-bg) !important; }
a, .text-primary { color: var(--brand-primary); }

/* Form controls: slightly softer radius, focus ring uses brand-primary-soft.
 *
 * ⛔ **The inputs ignored the body size, for the very reason `#353` already
 * wrote down about the list**: Bootstrap sets `.form-control { font-size: 1rem }` —
 * 16 px — and nothing here ever said otherwise. 📏 Measured 2026-09-14 on a
 * quote's edit form: 11 inputs and 4 selects at 16 px, their labels at
 * 12.48 px, every displayed value and every list cell at 13 px. The field a
 * user types in was the largest text on the page, and it named nothing.
 *
 * ⇒ Same token as the rest of the page, so the density setting moves the form
 * too (13 px compact, 14 px comfortable). No new token: `#353` decided the
 * scale, this only stops one family of widgets from sitting outside it.
 *
 * ⓘ **`.form-control-sm` is overridden too, and that is the intent** (revue
 * devlead, 2026-09-14). Same specificity, this rule comes after Bootstrap, so
 * the two `-sm` sites — `view/widget/DocumentPanel.kt`, `indice` l. 208 and
 * `fichier` l. 216 — read 13 px instead
 * of 14. The density is a page-wide decision and outranks a per-widget size
 * variant; the reduced padding of `-sm`, which is what those two sites are
 * really after, is untouched. Written down so it reads as a choice rather than
 * as a side effect someone has to rediscover. */
.form-control, .form-select {
    border-radius: var(--radius-sm);
    border-color: var(--surface-border);
    background-color: var(--surface-input-bg);
    font-size: var(--base-font-size);
}
.form-control:focus, .form-select:focus {
    border-color: var(--brand-primary);
    box-shadow: 0 0 0 0.2rem var(--brand-primary-soft);
}
.form-control:disabled, .form-control[readonly] {
    background-color: var(--surface-input-disabled-bg);
}

/* Navbar — paper background, brand wordmark accents. */
.navbar.navbar-light, .navbar.bg-light, .navbar {
    background-color: var(--surface-card) !important;
    border-bottom: 1px solid var(--surface-border);
    box-shadow: var(--surface-shadow);
}

/* Brand mark — every navbar gets a small brand square before the
   wordmark, painted with the brand-primary / brand-accent diagonal.
   No Kotlin change needed; the pseudo-element rides on the existing
   `.navbar-brand` element KVision renders. */
.navbar-brand {
    display: inline-flex;
    align-items: center;
    gap: 10px;
    color: var(--surface-fg);
}
.navbar-brand::before {
    content: "";
    display: inline-block;
    width: 22px;
    height: 22px;
    border-radius: var(--radius-sm);
    background:
        linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary) 60%, var(--brand-accent) 60%, var(--brand-accent) 100%);
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
    flex-shrink: 0;
}

/* Status badges pick up the brand tones. */
.badge {
    font-family: var(--font-base-family);
    font-weight: 500;
    letter-spacing: 0.01em;
    border-radius: var(--radius-sm);
    padding: 0.35em 0.7em;
}
/* Une pastille de ton porte le NOM DU TON, pas un mot de Bootstrap (#94, L5).
   `toneBadgeClass()` rend `badge tone-<nom>` en minuscules, dérivé de
   `SemanticTone`, donc ces règles couvrent l'enum entier par construction.
   ⚠️ Pas de `!important` ici, contrairement aux règles de compatibilité plus
   bas : on ne lutte plus contre une classe Bootstrap qui poserait sa couleur.

   `#354` — fond doux + encre de la même teinte (spec « Look and feel »), comme
   Android (`StatusBadgeField`). L'encre `--tone-<ton>-ink` n'est pas une donnée
   du thème : elle est calculée par `core` (`toneInk`, contraste garanti) et
   publiée sur <html> par `ToneInkVariables` à chaque pose du thème. Le repli
   sur le `-bg` ne sert qu'avant ce premier appel. */
.badge.tone-success { background-color: var(--tone-success-soft); color: var(--tone-success-ink, var(--tone-success-bg)); }
.badge.tone-danger  { background-color: var(--tone-danger-soft);  color: var(--tone-danger-ink, var(--tone-danger-bg)); }
.badge.tone-warning { background-color: var(--tone-warning-soft); color: var(--tone-warning-ink, var(--tone-warning-bg)); }
.badge.tone-info    { background-color: var(--tone-info-soft);    color: var(--tone-info-ink, var(--tone-info-bg)); }
.badge.tone-muted   { background-color: var(--tone-muted-soft);   color: var(--tone-muted-ink, var(--tone-muted-bg)); }
.badge.tone-neutral { background-color: var(--tone-neutral-soft); color: var(--tone-neutral-ink, var(--tone-neutral-fg)); }
.badge.tone-done    { background-color: var(--tone-done-soft);    color: var(--tone-done-ink, var(--tone-done-bg)); }

/* Bordure d'accent d'une carte, dans notre vocabulaire (#94, L5). Six regles
   derivees des six tons, la ou `border-success` & co de Bootstrap n'etaient
   remappees nulle part. */
.border-tone-success { border-color: var(--tone-success-bg) !important; }
.border-tone-danger  { border-color: var(--tone-danger-bg) !important; }
.border-tone-warning { border-color: var(--tone-warning-bg) !important; }
.border-tone-info    { border-color: var(--tone-info-bg) !important; }
.border-tone-muted   { border-color: var(--tone-muted-bg) !important; }
.border-tone-neutral { border-color: var(--tone-neutral-bg) !important; }
.border-tone-done    { border-color: var(--tone-done-bg) !important; }

/* Compatibilite : les pastilles qui ne portent pas de ton (un role, un type de
   champ) posent encore une classe Bootstrap. Elles restent remappees vers nos
   variables pour ne pas rouvrir une palette etrangere.
   ⛔ `text-bg-*` est une orthographe DIFFERENTE de `bg-*`, et elle n'etait
   remappee nulle part : cinq pastilles du produit peignaient du Bootstrap brut,
   identique dans les cinq themes, sans que la lecture de ce bloc puisse le
   montrer. C'est une zone aveugle habitee — l'usage legitime la masquait. */
/* `#354` — la marque, plus le ton info : depuis que l'info est un vrai bleu,
   une pastille « primaire » (un rôle, un type de champ) aurait viré au bleu. */
.badge.bg-primary,   .badge.text-bg-primary   { background-color: var(--brand-primary) !important;   color: var(--brand-primary-fg) !important; }
.badge.bg-success,   .badge.text-bg-success   { background-color: var(--tone-success-bg) !important; color: var(--tone-success-fg) !important; }
.badge.bg-danger,    .badge.text-bg-danger    { background-color: var(--tone-danger-bg) !important;  color: var(--tone-danger-fg) !important; }
.badge.bg-warning,   .badge.text-bg-warning   { background-color: var(--tone-warning-bg) !important; color: var(--tone-warning-fg) !important; }
.badge.bg-secondary, .badge.text-bg-secondary { background-color: var(--tone-muted-bg) !important;   color: var(--tone-muted-fg) !important; }
.badge.bg-light,     .badge.text-bg-light     { background-color: var(--tone-neutral-bg) !important; color: var(--tone-neutral-fg) !important; }
.badge.bg-info,      .badge.text-bg-info      { background-color: var(--tone-info-bg) !important;    color: var(--tone-info-fg) !important; }

/* Tabulator: tighter headers, no Bootstrap-blue selected state, brand row hover. */
.tabulator { border-radius: var(--radius-md); }
.tabulator .tabulator-header,
.tabulator .tabulator-header .tabulator-col {
    background-color: var(--surface-card-side);
    color: var(--surface-fg);
    border-color: var(--surface-border);
    font-family: var(--font-base-family);
    font-weight: 600;
    letter-spacing: 0.01em;
}
.tabulator .tabulator-row { border-color: var(--surface-divider); }

/* `#353` — the type scale of the « Look and feel » spec, on lists and cards.
 *
 * ⛔ The list ignored the body size. Tabulator's own stylesheet is injected by
 * the bundle AFTER this file and sets `.tabulator { font-size:
 * var(--bs-body-font-size) }` — 1rem, 16 px — at the same specificity as a bare
 * `.tabulator` rule here, so order decides and it won. 📏 Measured 2026-09-11:
 * header and cells both at 16 px while `body` was at 13 px. Hence `body
 * .tabulator` (0,1,1): it wins whatever the order, and the cells follow the
 * density's `--base-font-size` like the rest of the page.
 *
 * Captions — a column header, a KPI label — are 11 px capitals in the muted
 * colour: they name a value, they are not the value. */
body .tabulator { font-size: var(--base-font-size); }

/* `#356` — **a list is a card**, like the dashboard's widgets already are.
 *
 * 📏 Measured 2026-09-14 before the change: the table was already white on the
 * page's beige (`--surface-bg`) and already had `--radius-md`; what it lacked
 * was the **edge** — no border, no shadow — so the white bled into the page and
 * the screen read as one flat sheet. That is the spec's « le contenu n'est posé
 * dans aucune carte », and it costs one selector, not a renderer.
 *
 * ⭐ The child combinator is the whole point: `.chrome-page-host > .tabulator`
 * is the **browse list**, the one that IS the page. A quote's line table is a
 * Tabulator too, but it lives nested inside `.form-content` — it must stay a
 * table inside its screen, not become a second card floating in the first.
 *
 * ⛔ **A fiche's « header card + lines card » is NOT here.** It needs the
 * renderer to wrap two groups of fields, and `.form-content` already contains a
 * `.card`: styling it here would nest a card in a card. See `#356`.
 *
 * ⛔ **Cette règle n'écrit PAS d'`overflow`, et c'est délibéré — mais l'élément
 * est rogné quand même.** La première rédaction en portait un, par réflexe de
 * « carte ». Retiré en revue (devlead, 2026-09-14) faute de mesure derrière :
 * le rayon venait déjà du CSS existant et rien ne débordait des coins.
 *
 * 📏 Et le retrait ne change **rien au rendu** : la feuille de Tabulator, qui
 * est injectée après celle-ci, pose elle-même `.tabulator { overflow: hidden }`
 * — mesuré au navigateur, `overflowX/Y` vaut toujours `hidden` sans notre
 * déclaration. ⇒ Le contexte de rognage **préexiste à ce lot** ; il n'est ni
 * créé ni supprimé ici. Ce qui disparaît, c'est une ligne qui se serait
 * attribué la responsabilité d'un comportement qu'elle ne gouverne pas.
 *
 * 📏 Ce que le rognage fait aux menus, vérifié le même jour sur la DERNIÈRE
 * ligne (`…` « Plus d'actions », puis « Colonnes ») : le menu de ligne est un
 * enfant direct de `<body>`, zéro ancêtre rogneur ; le menu des colonnes vit
 * dans la barre d'actions. Aucun des deux n'est dans ce conteneur — donc rien
 * n'est coupé aujourd'hui, avec ou sans notre ligne. ⚠️ Un menu qui se
 * rendrait un jour DANS le DOM du tableau, lui, serait coupé par Tabulator, et
 * ce sera un défaut à part — pas une régression de ce lot. */
.chrome-page-host > .tabulator {
    border: 1px solid var(--surface-border);
    box-shadow: var(--surface-shadow);
}
.tabulator .tabulator-header .tabulator-col .tabulator-col-title,
.dashboard-table thead th,
.kpi-label {
    font-size: var(--font-caption);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: var(--font-caption-tracking);
    color: var(--surface-muted);
}
.kpi-value {
    font-size: var(--font-kpi);
    font-weight: 600;
    line-height: 1.2;
}
.dashboard-table td.text-end { font-variant-numeric: tabular-nums; }
/* ⛔ **Bootstrap peint chaque cellule, pas le tableau.** `.table` pose
   `--bs-table-bg: #fff` et chaque `td`/`th` s'en sert : la carte qui les
   contient a beau être sombre, les cellules restent blanches. Mesuré le
   2026-09-18 en thème sombre : `td` à `rgb(255,255,255)` dans une `.card` à
   `rgb(29,33,39)` — la seconde grande zone blanche de l'écran.

   ⚠️ **Le blanc est peint par des DIZAINES de petites cellules**, jamais par
   une grande boîte : une sonde qui cherche les grandes zones claires ne le voit
   pas. C'est comme ça qu'il a survécu à ma première recherche.

   ⇒ `transparent` plutôt qu'une couleur : la cellule laisse voir la surface qui
   la porte, donc elle suit n'importe quel thème sans que ce fichier ait à
   connaître lequel. */
.table { --bs-table-bg: transparent; --bs-table-color: inherit; }
.tabulator .tabulator-row.tabulator-selected,
.tabulator .tabulator-row:hover { background-color: var(--surface-row-hover) !important; }

/* Line-table totals row uses the warm paper tint. */
.line-table-totals { color: var(--surface-muted); }

/* Sticky button bar inherits the page tone. */
.form-buttons-sticky {
    background-color: var(--surface-card-side);
    border-top: 1px solid var(--surface-border);
}

/* Empty state: paint the icon with brand-primary at low opacity. */
.empty-state-icon { color: var(--brand-primary); opacity: 0.55; }
.empty-state-title { font-family: var(--font-headline-family); letter-spacing: -0.02em; }

/* F.10 — page header at the top of every entity screen. Title in the
   headline family (Inter since `#349`); subtitle in the body family for the
   entity identifier or count. */
.page-header {
    display: flex;
    align-items: baseline;
    gap: 0.75rem;
    padding: 1rem 0.5rem 0.75rem;
    border-bottom: 1px solid var(--surface-divider);
    margin-bottom: 0.5rem;
}
.page-title {
    font-family: var(--font-headline-family);
    font-weight: 600;
    font-size: var(--font-title);
    line-height: 1.2;
    margin: 0;
    /* Phase F.14.4 — title uses the theme's brand colour for instant
     * recognisability across themes (was just surface-fg = same on all). */
    color: var(--brand-primary);
    letter-spacing: -0.02em;
}
.page-subtitle {
    color: var(--surface-muted);
    font-size: var(--font-secondary);
    font-feature-settings: "tnum";
}
/* Browse pages render the subtitle as a bare count next to the title;
 * Phase F.14.4 — count chip uses brand-soft so each theme tints it.
 *
 * `#349` — and the chip is CENTRED on the title, not set on its baseline. The
 * header aligns on the baseline, which is right for a text subtitle (view and
 * edit screens) but wrong for a pill: its text sat on the title's baseline and
 * its padding hung below it. 📏 Measured on « Affaires », Inter 24 px, against
 * the centre of the title's CAPITALS (the eye's reference, blind to the « g »
 * of « Ouvrages »): 4.0 px low before, 0.1 px after, on both titles. */
.page-header[data-screen-kind="browse"] {
    align-items: center;
    /* ⛔ **Deux séparateurs pour une séparation** (Didier, 2026-09-14, sur
     * capture). Une liste a déjà une barre d'actions dessous, peinte d'un beige
     * distinct de la page (`--surface-row-hover` contre `--surface-bg`) : le
     * changement de fond sépare le titre des actions à lui seul, et le filet
     * par-dessus ne fait que charger.
     *
     * ⚠️ **Retiré sur `browse` UNIQUEMENT, et c'est le tout du raisonnement** :
     * une fiche, un formulaire et une création n'ont aucune barre sous leur
     * en-tête — le filet y est la seule séparation, et le retirer partout
     * laisserait leur titre flotter. `data-screen-kind` existe pour cette
     * distinction ; c'est ici qu'elle se prend. */
    border-bottom: none;
}
.page-header[data-screen-kind="browse"] .page-subtitle {
    display: inline-flex;
    align-items: center;
    background-color: var(--brand-primary-soft);
    color: var(--brand-primary);
    padding: 0.15rem 0.55rem;
    border-radius: 999px;
    font-size: 0.8rem;
    font-weight: 500;
    line-height: 1.3;
}
/* `#355` 3d — ce que la liste totalise, à droite de son titre.
 *
 * ⭐ `margin-left: auto` plutôt qu'un conteneur en plus : l'en-tête est déjà
 * un flex qui aligne titre et pastille de compte à gauche ; le total prend le
 * reste. Pas de couleur littérale — le jeton de la surface sourde, comme le
 * sous-titre, parce que ce n'est pas une alerte : c'est une information de
 * lecture. `tnum` aligne les chiffres entre deux écrans. */
.page-header .page-summary {
    margin-left: auto;
    color: var(--surface-fg);
    font-size: var(--font-secondary);
    font-weight: 500;
    font-feature-settings: "tnum";
    white-space: nowrap;
}
/* `#355` 3d (A11–A15) — les badges d'état, entre le compte et le total.
 *
 * ⭐ La couleur n'est PAS écrite ici : chaque badge porte déjà `badge
 * tone-<ton>` (`toneBadgeClass`), la même classe que la cellule d'état du
 * tableau. Ce bloc ne règle que ce qui fait d'un badge un **bouton** : le
 * curseur, le compte, et la marque du badge choisi.
 *
 * Choisi : un anneau à la couleur de son propre texte (`currentColor`), donc
 * juste dans tous les thèmes sans jeton de plus. Les autres pâlissent, pour
 * que « la liste est filtrée » se voie d'un coup d'œil sans lire les chiffres. */
.page-header .page-state-badges {
    display: flex;
    flex-wrap: wrap;
    gap: 0.375rem;
    align-items: center;
}
.state-badge-filter {
    border: 0;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.8rem;
    font-weight: 500;
    line-height: 1.3;
    padding: 0.2rem 0.6rem;
    border-radius: 999px;
    transition: opacity 120ms ease;
}
.state-badge-filter .state-badge-count {
    font-weight: 700;
    font-feature-settings: "tnum";
}
.state-badge-filter.is-selected {
    box-shadow: 0 0 0 2px currentColor;
}
.state-badge-filter.is-dimmed {
    opacity: 0.55;
}
.state-badge-filter:hover,
.state-badge-filter:focus-visible {
    opacity: 1;
    box-shadow: 0 0 0 1px currentColor;
}
.state-badge-filter.is-selected:hover,
.state-badge-filter.is-selected:focus-visible {
    box-shadow: 0 0 0 2px currentColor;
}

/* `#229` / mémo « Page Cycle de vie » du 12/09 — la porte vers le cycle est
 * poussée **au bout** de la ligne de titre.
 *
 * `margin-left: auto` sur un enfant de flex absorbe tout l'espace libre à sa
 * gauche : le titre et sa pastille de compte gardent leur place exacte, le
 * bouton part à l'opposé. Rien d'autre à régler — `.page-header` est en
 * `display: flex` depuis F.10, et c'est déjà lui qui pose « Devis » et son
 * compte côte à côte.
 *
 * ⚠️ **Ce bouton n'est pas propre aux Devis** : il est peint sur toute liste
 * d'un type qui porte un cycle — Affaires, Ouvrages, Factures, Contrats. La
 * règle vaut donc partout, ce qui est voulu (une position stable vaut mieux
 * qu'une position par écran) mais suppose que le coin haut-droit reste libre
 * sur ces écrans-là. Aucun autre appelant de `renderPageHeader` ne pose de
 * contenu de fin aujourd'hui.
 *
 * ⓘ Le `flex-shrink: 0` n'est pas décoratif : sans lui, un titre long
 * (« Ouvrages de la bibliothèque ») comprimerait le bouton avant de céder
 * lui-même, et son libellé se couperait au lieu de faire ce que fait déjà le
 * titre — passer à la ligne. */
.page-header .lifecycle-entry {
    margin-left: auto;
    flex-shrink: 0;
}

/* ⛔ **Deux `margin-left: auto` dans le même flex ne font PAS ce qu'on croit.**
 * L'espace libre se **partage à parts égales** entre toutes les marges auto de
 * l'axe : avec le total ET le bouton, le total finirait flottant au milieu de
 * la ligne de titre, et le bouton à mi-chemin — chacun ayant l'air d'avoir été
 * placé là exprès.
 *
 * ⭐ La règle dit donc lequel prend l'espace, et elle le dit **par la présence
 * de l'autre** : le combinateur `~` regarde en avant, et le résumé est peint
 * avant le contenu de fin (`renderPageHeader` : titre, sous-titre, résumé, puis
 * `trailing`). ⇒ Sans résumé, le bouton garde son `auto` et part au bord ; avec
 * résumé, c'est le résumé qui pousse et le bouton le suit à distance fixe.
 *
 * ⚠️ Née de la fusion du 2026-09-14 entre 3c+3d (`.page-summary`) et le lot
 * look'n'feel (`.lifecycle-entry`). Aucun des deux n'était faux seul ; c'est
 * leur coexistence qui l'était, et rien dans les deux diffs ne la montrait. */
.page-header .page-summary ~ .lifecycle-entry {
    margin-left: 0.75rem;
}
[data-theme="dark"] .page-header { border-bottom-color: var(--surface-divider); }

/* AI chat panel: header uses brand. */
.chat-panel { border-radius: 0; box-shadow: var(--surface-shadow-lg); }
.chat-header {
    background-color: var(--brand-primary);
    color: var(--brand-primary-fg);
    border-bottom-color: var(--brand-primary-hover);
    font-family: var(--font-headline-family);
    letter-spacing: -0.02em;
}
.chat-close { color: rgba(255,255,255,0.85) !important; }
.chat-close:hover { color: #ffffff !important; }
.chat-message-user { background-color: var(--brand-primary-soft); color: var(--surface-fg); }
.chat-message-assistant { background-color: var(--surface-card-side); }

/* Catch-all: under dark mode, every Bootstrap card and the inner body /
   header / footer surfaces switch to the dark palette so individual
   class overrides don't have to repeat the same rule. */
[data-theme="dark"] .card,
[data-theme="dark"] .card-body,
[data-theme="dark"] .card-header,
[data-theme="dark"] .card-footer {
    background-color: var(--surface-card) !important;
    color: var(--surface-fg) !important;
    border-color: var(--surface-border) !important;
}
[data-theme="dark"] .form-layout-side .card,
[data-theme="dark"] .form-layout-side .card-body,
[data-theme="dark"] .form-layout-side .card-header {
    background-color: var(--surface-card-side) !important;
}

/* Cards inherit the theme's surface colors. */
[data-theme="dark"] .card,
[data-theme="dark"] .card-body,
[data-theme="dark"] .card-header {
    background-color: var(--surface-card);
    color: var(--surface-fg);
    border-color: var(--surface-border);
}
[data-theme="dark"] .form-content,
[data-theme="dark"] .form-buttons-sticky { background-color: var(--surface-bg); border-color: var(--surface-border); }
[data-theme="dark"] .form-buttons-sticky { background-color: var(--surface-card-side); }
[data-theme="dark"] .form-control,
[data-theme="dark"] .form-select,
[data-theme="dark"] textarea {
    background-color: var(--surface-input-bg);
    color: var(--surface-fg);
    border-color: var(--surface-border);
}
[data-theme="dark"] .form-control:disabled,
[data-theme="dark"] .form-control[readonly] {
    background-color: var(--surface-input-disabled-bg);
    color: var(--surface-muted);
}
[data-theme="dark"] .navbar { background-color: var(--surface-card) !important; border-bottom-color: var(--surface-border); }
[data-theme="dark"] .navbar .nav-link { color: var(--surface-fg) !important; }
[data-theme="dark"] .navbar .nav-link:hover { background-color: rgba(255,255,255,0.06) !important; }
[data-theme="dark"] .navbar .navbar-brand { color: var(--surface-fg); }
[data-theme="dark"] .tabulator,
[data-theme="dark"] .tabulator-row,
[data-theme="dark"] .tabulator-header { background-color: var(--surface-card) !important; color: var(--surface-fg); border-color: var(--surface-border); }
[data-theme="dark"] .tabulator-row:hover { background-color: var(--surface-row-hover) !important; }
[data-theme="dark"] .tabulator .tabulator-col,
[data-theme="dark"] .tabulator .tabulator-row .tabulator-cell { background-color: var(--surface-card); color: var(--surface-fg); border-color: var(--surface-border); }
[data-theme="dark"] .form-display-field { border-bottom-color: var(--surface-divider); }
[data-theme="dark"] .form-display-label { color: var(--surface-muted); }
[data-theme="dark"] .form-display-value { color: var(--surface-fg); }
/* `#414` — ⛔ **cette ligne est obligatoire, et le premier jet l'avait retirée
   en affirmant le contraire.**
 *
 * ~~La règle de base lit déjà `var(--surface-muted)`, donc une surcharge sombre
 * ne ferait que répéter le même jeton.~~
 *
 * 📏 Mesuré au navigateur le 2026-09-20, sur le formulaire de création d'un
 * collaborateur, en basculant `data-theme` :
 *
 * | thème | `--surface-muted` | couleur calculée de la phrase |
 * |---|---|---|
 * | clair | `#6f7a87` | `rgb(111,122,135)` ✅ le jeton |
 * | sombre | `#9ba2ad` | `rgb(230,232,235)` ⛔ `--surface-fg` |
 *
 * ⇒ La ligne **juste au-dessus** est `[data-theme="dark"] .form-display-value`,
 * de spécificité (0,2,0), contre (0,1,0) pour `.form-display-value-empty`. Elle
 * gagne quel que soit l'ordre dans le fichier — l'ordre ne départage que des
 * spécificités **égales**, et c'est précisément ce que le premier commentaire
 * avait supposé sans le vérifier. En sombre, la phrase d'attente se peignait
 * donc de la couleur d'une vraie valeur. */
[data-theme="dark"] .form-display-value-empty { color: var(--surface-muted); }
[data-theme="dark"] .empty-state-panel { color: var(--surface-muted); }
[data-theme="dark"] .empty-state-icon { color: var(--surface-muted); }
[data-theme="dark"] .form-layout-side .card { background-color: var(--surface-card-side) !important; color: var(--surface-fg); border-color: var(--surface-border) !important; }
[data-theme="dark"] .bg-light { background-color: var(--surface-card-side) !important; color: var(--surface-fg) !important; }
[data-theme="dark"] .navbar.navbar-light,
[data-theme="dark"] .navbar.bg-light {
    background-color: var(--surface-card) !important;
    border-bottom: 1px solid var(--surface-border);
}
[data-theme="dark"] .navbar.navbar-light .navbar-brand,
[data-theme="dark"] .navbar.navbar-light .nav-link,
[data-theme="dark"] .navbar.bg-light .navbar-brand,
[data-theme="dark"] .navbar.bg-light .nav-link { color: var(--surface-fg) !important; }
[data-theme="dark"] .tabulator,
[data-theme="dark"] .tabulator .tabulator-tableholder,
[data-theme="dark"] .tabulator-row,
[data-theme="dark"] .tabulator .tabulator-row .tabulator-cell {
    background-color: var(--surface-card) !important;
    color: var(--surface-fg) !important;
    border-color: var(--surface-border) !important;
}
[data-theme="dark"] .tabulator .tabulator-row.tabulator-row-even {
    background-color: var(--surface-card-side) !important;
}
[data-theme="dark"] .tabulator .tabulator-header,
[data-theme="dark"] .tabulator .tabulator-header .tabulator-col,
[data-theme="dark"] .tabulator .tabulator-header .tabulator-col-content {
    background-color: var(--surface-card-side) !important;
    color: var(--surface-fg) !important;
    border-color: var(--surface-border) !important;
}
[data-theme="dark"] .tabulator .tabulator-row:hover { background-color: var(--surface-row-hover) !important; }
[data-theme="dark"] .browse-toolbar-sticky,
[data-theme="dark"] .form-buttons-sticky {
    background-color: var(--surface-card-side) !important;
    border-color: var(--surface-border) !important;
}
[data-theme="dark"] .text-muted { color: var(--surface-muted) !important; }
[data-theme="dark"] hr,
[data-theme="dark"] .border,
[data-theme="dark"] .border-top,
[data-theme="dark"] .border-bottom,
[data-theme="dark"] .border-start,
[data-theme="dark"] .border-end { border-color: var(--surface-border) !important; }
[data-theme="dark"] .chat-panel { background-color: var(--surface-card); border-left-color: var(--surface-border); color: var(--surface-fg); }
[data-theme="dark"] .chat-header { background-color: var(--surface-card-side); border-bottom-color: var(--surface-border); }

/* Demo-brand primary button accent in light + dark. */
.btn-primary {
    background-color: var(--brand-primary);
    border-color: var(--brand-primary);
    color: var(--brand-primary-fg);
}
.btn-primary:hover, .btn-primary:focus {
    background-color: var(--brand-primary-hover);
    border-color: var(--brand-primary-hover);
}

/* Calendar — match base font. F.8 fixes item 4. */
.rbc-calendar { font-size: var(--font-base); font-family: inherit; }
.rbc-toolbar { font-size: var(--font-base); }
.rbc-toolbar button { font-size: var(--font-small); }

/* Phase F.3 — make table rows obviously clickable. Cursor and hover
   tint signal that clicking the row opens the entity's $view. The
   action column stays interactive on its own. */
.tabulator .tabulator-row { cursor: pointer; }
.tabulator .tabulator-row:hover { background-color: #f1f3f5; }

/* F.7 — keep navbar hover legible. Bootstrap's default :hover bumps
   font-weight which shifts the layout slightly and reads "bold-ugly"
   against the light navbar. Use a background tint instead. */
.navbar .nav-link:hover,
.navbar .nav-link:focus,
.navbar .navbar-nav .nav-link:hover,
.navbar .navbar-nav .nav-link:focus {
    font-weight: inherit;
    background-color: rgba(0, 0, 0, 0.04);
    border-radius: 0.25rem;
}

/* F.7 — the Ask-AI element is a <button> styled with `nav-link`. On
   mobile the navbar collapses and Bootstrap's button defaults centre
   the text and constrain width, breaking alignment with the sibling
   navLink anchors. Override so it reads as a regular nav row. */
.navbar button.nav-link {
    text-align: left !important;
    width: 100% !important;
    border: 0 !important;
    background-color: transparent !important;
    padding-left: 0.5rem !important;
    padding-right: 0.5rem !important;
    display: block !important;
    color: var(--surface-fg) !important;
    box-shadow: none !important;
}
.navbar button.nav-link:hover,
.navbar button.nav-link:focus {
    background-color: rgba(0, 0, 0, 0.04) !important;
}
@media (min-width: 992px) {
    /* On wide viewports keep the natural button width — only on the
       collapsed (hamburger) layout do we want the full-row pattern. */
    .navbar button.nav-link {
        width: auto !important;
        display: inline-flex !important;
        align-items: center;
        text-align: center !important;
    }
}

/* Phase M.2h / F.5 — main column + side rail layout. The MAIN column
   takes the remaining space; the SIDE rail is fixed-width on desktop
   and collapses below the main column on narrow viewports. */
/* The form root is a container-query context so the SIDE rail collapses
   based on the *available* width (its own container), not the viewport.
   This is what makes a form rendered inside a narrow modal (nested-line
   editor) stack the Summary block below the fields instead of squeezing a
   320px rail alongside them. A full-page form keeps the side rail because
   its container is wide. */
.entity-form { container-type: inline-size; container-name: entityform; }
.form-layout {
    display: grid;
    grid-template-columns: 1fr minmax(280px, 320px);
    column-gap: 1.5rem;
    align-items: start;
}
.form-layout-main { min-width: 0; }
.form-layout-side { min-width: 0; }
.form-layout-side .card { background-color: #f8f9fa; }
/* Collapse when the form's own container is too narrow for a side rail. */
@container entityform (max-width: 760px) {
    .form-layout { grid-template-columns: 1fr; }
}
/* Fallback for very narrow viewports (and browsers without container queries). */
@media (max-width: 900px) {
    .form-layout { grid-template-columns: 1fr; }
}

/* D.2 — ghost-icon action buttons inside list cells. Borderless,
   transparent, hover background. Density-aware sizing. Replaces the
   chunky solid-coloured buttons that used to dominate every row. */
.row-action {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 0;
    background: transparent;
    color: var(--surface-muted);
    padding: var(--action-icon-padding);
    border-radius: var(--radius-sm);
    cursor: pointer;
    line-height: 1;
    margin: 0 1px;
}
.row-action i {
    font-size: var(--action-icon-size);
    display: inline-block;
}
.row-action:hover, .row-action:focus {
    color: var(--brand-primary);
    background-color: var(--brand-primary-soft);
    outline: none;
}
.row-action.row-action-text {
    font-size: 0.8rem;
    padding: 0.2rem 0.5rem;
    color: var(--brand-primary);
}
/* `#460` — l'action qui fait avancer la ligne, écrite et cernée : c'est la
   seule de la ligne qu'on doit pouvoir lire sans survol. Un contour, pas un
   aplat — l'aplat reste la principale de la FICHE (`#354`). */
.row-action.row-action-principal {
    border: 1px solid var(--brand-primary);
    white-space: nowrap;
    font-weight: 500;
}
/* `#633` — une action réglée « texte + icône » sur la ligne : l'icône, puis le
   libellé, sur une seule ligne. */
.row-action.row-action-text-icon {
    gap: 0.3rem;
    white-space: nowrap;
}
.row-action.row-action-text-icon i {
    font-size: 0.85rem;
}
/* `#460` — le trait qui met `Supprimer` à part, en fin de menu. */
.row-action-menu-separator {
    margin: 0.25rem 0;
    border: 0;
    border-top: 1px solid var(--surface-border);
    opacity: 1;
}
[data-theme="dark"] .row-action {
    color: var(--surface-muted);
}
[data-theme="dark"] .row-action:hover {
    color: var(--brand-primary);
    background-color: var(--brand-primary-soft);
}

/* M1b — le menu `…` d'une ligne. Ancré au <body>, pas à la cellule : une
   cellule Tabulator est en `overflow: hidden` et la colonne d'actions est
   `sticky`, donc un menu rendu dedans y serait rogné à la hauteur d'une ligne.
   Le positionnement est mesuré en JS (RowActionMenu.place) ; ici on ne pose que
   l'apparence et le point de départ. `visibility: hidden` parce qu'il faut
   qu'il soit dans le document pour être mesurable, et qu'il ne doit pas se voir
   avant d'être placé. */
.row-action-menu {
    position: fixed;
    top: 0;
    left: 0;
    visibility: hidden;
    z-index: 1080;
    min-width: 12rem;
    padding: 0.25rem 0;
    background: var(--surface-bg, #fff);
    border: 1px solid var(--surface-border, #c8c8c8);
    border-radius: var(--radius-sm);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
}
.row-action-menu-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    padding: 0.375rem 0.75rem;
    border: 0;
    background: transparent;
    color: var(--surface-fg);
    font-size: 0.875rem;
    text-align: left;
    white-space: nowrap;
    cursor: pointer;
}
.row-action-menu-item:hover, .row-action-menu-item:focus {
    color: var(--brand-primary);
    background-color: var(--brand-primary-soft);
    outline: none;
}
.row-action-menu-item i {
    font-size: var(--action-icon-size);
    /* Une largeur fixe : sans elle les libellés s'alignent sur la largeur de
       leur propre icône, et la colonne de texte ondule d'une ligne à l'autre. */
    width: 1.1rem;
    text-align: center;
    flex: 0 0 auto;
}

/* F.4 — display-only widget for computed money / percentage. Reads
   like a label + bold value pair, lives nicely in the side rail. */
.form-display-field {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    padding: 0.35rem 0;
    gap: 0.75rem;
    border-bottom: 1px dashed #dee2e6;
}
.form-display-field:last-child { border-bottom: none; }
.form-display-label {
    color: #495057;
    font-size: 0.9rem;
}
.form-display-value {
    font-weight: 600;
    color: #212529;
    text-align: right;
}

/* `#414` — **la même cellule quand il n'y a encore rien à montrer.**
 *
 * ⛔ Le défaut n'était pas une couleur : le nœud de valeur contenait une espace
 * et mesurait 0 px de haut, donc le libellé « Nom complet » flottait au-dessus
 * du vide. Sur un formulaire de CRÉATION c'est l'état nominal de toute
 * propriété calculée — `fullName` se calcule depuis deux champs encore vides.
 *
 * ⭐ Ces trois lignes ne portent que l'idiome web : le TEXTE vient d'une clé
 * i18n et la décision « c'est vide » de `DisplayOnlySupport.estVide`. L'italique
 * et le poids normal sont ce qui empêche la phrase de se lire comme une donnée —
 * sans eux, « En attente de calcul » aurait l'air d'être la valeur du champ.
 *
 * ⛔ **`var(--surface-muted)`, et surtout pas un gris littéral.** Premier jet
 * écrit `#6c757d` : `StylesheetKeepsNoStrayColourGuardTest` l'a refusé, et il a
 * eu raison deux fois — le cliquet montait d'un cran, ET la couleur aurait été
 * figée sur un seul thème alors que ce jeton est publié par chacun d'eux
 * (`themes/<id>.json`). ⚠️ Pas de valeur de repli non plus : sur un jeton
 * publié au runtime elle est du code mort, et elle a déjà été mesurée fausse. */
.form-display-value-empty {
    font-weight: 400;
    font-style: italic;
    color: var(--surface-muted);
}

/* `#370` — de quel bord se lit un champ numérique **en saisie**.
 *
 * ⚠️ La règle est décidée en `core` (`NumberFieldShape.alignment`) et les deux
 * renderers la lisent ; ces trois classes ne sont que le vocabulaire du web.
 * Ne pas y recoder la décision : un champ ne choisit pas son bord, il l'applique.
 *
 * 📏 Le défaut mesuré le 2026-09-14, fenêtre « Nouvelle Ligne de devis » : tous
 * les champs numériques étaient collés à gauche, comme du texte, pendant que le
 * Récapitulatif — quelques centimètres plus bas, dans la MÊME fenêtre —
 * alignait ses montants à droite via `.form-display-value` ci-dessus. L'écran se
 * contredisait lui-même. `tabular-nums` accompagne l'alignement : ranger des
 * chiffres par la droite ne sert à rien s'ils n'ont pas tous la même chasse. */
/* ⚠️ **On vise le champ, pas son enveloppe — mesuré, pas supposé.**
 * 📏 Le 2026-09-15 au navigateur : `addCssClass` d'un widget KVision pose la
 * classe sur le `.form-group` qui entoure le champ, jamais sur l'`<input>`.
 * Or `text-align` ne descend pas dans un `<input>` : il a le sien, hérité du
 * user-agent. La règle posée sur l'enveloppe seule se lisait donc `align:start`
 * à l'écran — CSS valide, classe bien présente, et aucun effet.
 * ⛔ Ne pas « simplifier » en retirant le descendant. */
/* ⛔ **Un libellé vide ne prend pas de place.**
 * 📏 Mesuré le 2026-09-15 : un `Numeric` KVision rend **toujours** son
 * `<label class="form-label">`, même construit sans libellé — contrairement à
 * un `Text`, qui n'en crée aucun. Quand une enveloppe à suffixe reprend le
 * libellé (`SuffixedNumericFormField`), le champ en garde donc un vide, dont
 * Bootstrap conserve la marge : « Remise (%) » descendait de 19 px et quittait
 * l'alignement de sa rangée, seul de la fenêtre.
 * ⚠️ `:empty` discrimine tout seul — un champ qui porte vraiment son libellé
 * (`Qté`) n'est pas concerné. */
.form-number-end   > label:empty,
.form-number-start > label:empty,
.form-number-center > label:empty { display: none; }

.form-number-end   input, .form-number-end   .form-control { text-align: right; font-variant-numeric: tabular-nums; }
.form-number-start input, .form-number-start .form-control { text-align: left;  font-variant-numeric: tabular-nums; }
.form-number-center input, .form-number-center .form-control { text-align: center; font-variant-numeric: tabular-nums; }

/* Le signe qui se lit à côté d'un champ — `%`, `€`, une unité.
 *
 * ⭐ **Hors de la valeur, jamais dedans** : c'est ce qui lui permet d'exister
 * sans gêner la frappe (`#43` avait posé la limite, ce suffixe passe à côté).
 * Il est en retrait parce qu'il accompagne la valeur au lieu d'en faire partie —
 * la même hiérarchie que le libellé d'un champ. */
.form-field-suffix {
    margin-left: 0.5rem;
    color: var(--surface-muted);
    font-size: 0.9rem;
    white-space: nowrap;
}

/* Status badge (the @StateMachine driver, rendered read-only by
   EntityFormFieldBuilder). Bootstrap's default .badge is sized for an
   inline counter, which made the entity's single most important piece of
   state read like a footnote. This is the renderer's realization of that
   field's weight — the tone itself still comes from @State(tone = …). */
.form-display-value .badge {
    font-size: 0.9rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    padding: 0.4rem 0.85rem;
    border-radius: 999px;
}

/* Phase F.1 — 12-column grid inside every form block.
   Field cells span 3 / 4 / 6 / 8 / 12 columns based on widthHint
   (resolved by EntityFormBuilder.widthClassFor). On viewports below
   720 px every cell collapses to full width. */
.form-grid {
    display: grid;
    grid-template-columns: repeat(12, 1fr);
    column-gap: 1rem;
    row-gap: 0.5rem;
    align-items: start;
}
.form-grid-cell { min-width: 0; }
.form-grid-w-3  { grid-column: span 3; }
.form-grid-w-4  { grid-column: span 4; }
.form-grid-w-6  { grid-column: span 6; }
.form-grid-w-8  { grid-column: span 8; }
.form-grid-w-12 { grid-column: span 12; }
@media (max-width: 720px) {
    .form-grid-w-3,
    .form-grid-w-4,
    .form-grid-w-6,
    .form-grid-w-8,
    .form-grid-w-12 { grid-column: span 12; }
}
/* Tighter vertical rhythm inside grid cells. */
.form-grid-cell .form-group,
.form-grid-cell .mb-3 { margin-bottom: 0.5rem !important; }

/* Empty-state panel — shown inline below the list table when no rows. */
.empty-state-panel {
    text-align: center;
    padding: 3rem 2rem;
    color: #495057;
}
.empty-state-icon {
    font-size: 3rem;
    color: #adb5bd;
    margin-bottom: 0.5rem;
}
.empty-state-title {
    font-weight: 600;
    margin-bottom: 0.25rem;
}
.empty-state-subtitle {
    font-size: 0.95rem;
}

/* AI Chat Panel — overlay on right edge, does not push main content.
   z-index 1015: above sticky browse toolbar (1010) but BELOW the topbar (1020)
   and its dropdowns (2000) so chrome menus remain reachable while the panel is open. */
/* `top` colle à la barre du haut, qui fait 56 px dans les quatre chromes.
 * C'était 60 : quatre pixels de contenu affleuraient au-dessus du panneau,
 * invisibles tant qu'il flottait par-dessus, voyants dès qu'il devient une
 * colonne accolée. */
.chat-panel { position: fixed; right: 0; top: 56px; bottom: 0; width: 420px; background: white; border-left: 1px solid #c8c8c8; box-shadow: -4px 0 12px rgba(0,0,0,0.12); display: flex; flex-direction: column; z-index: 1015; }
/* ⚠️ Ce bloc ne pose plus de couleur, seulement de la géométrie.
 *
 * Il en posait, et elles gagnaient sur la couche thématisée plus haut par
 * simple ordre de déclaration : l'en-tête était repeint en gris clair pendant
 * que sa couleur de texte restait `--brand-primary-fg`, c'est-à-dire blanche.
 * Résultat visible sur toutes les captures depuis des mois : le titre
 * « Assistant IA » écrit en blanc sur gris clair, illisible. Le ✕ ne s'en
 * sortait que parce qu'il forçait à son tour une couleur sombre — deux erreurs
 * qui s'annulaient et donnaient un panneau presque correct.
 *
 * La couleur appartient à la couche thème (`.chat-header` plus haut), la
 * géométrie reste ici. */
.chat-header { padding: 12px 16px; border-bottom: 1px solid; display: flex; justify-content: space-between; align-items: center; font-weight: 600; }
.chat-close { background: transparent !important; border: none !important; padding: 0 8px !important; line-height: 1 !important; font-size: 20px; cursor: pointer; }
.chat-messages { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.chat-message { padding: 8px 12px; border-radius: 8px; max-width: 90%; }

/* ⚠️ Rien de ce que l'IA renvoie n'a de largeur connue à l'avance : un tableau
 * markdown, une trace d'outil avec du JSON sur une ligne, une URL sans espace.
 * Mesuré à 432 px de panneau sur une vraie réponse : **85 px** de défilement
 * horizontal dans la colonne des messages — c'est-à-dire une scrollbar de plus
 * dans un produit qui vient d'en supprimer trois.
 *
 * Chaque coupable est traité chez lui plutôt que masqué chez le parent : un
 * `overflow: hidden` sur la colonne aurait rendu le tableau *illisible* au lieu
 * de le rendre *défilable*, et on ne l'aurait pas vu passer. */
/* `break-word` et non `anywhere` : `anywhere` autorise la coupure **et**
 * réduit la largeur minimale intrinsèque, ce qu'une cellule de tableau
 * prend au mot — mesuré, l'en-tête « Statut » se cassait en « Stat / ut »
 * dans une colonne que rien n'obligeait à être si étroite. La trace d'outil,
 * elle, contient du JSON d'un seul tenant et a vraiment besoin d'`anywhere`. */
.chat-message { min-width: 0; overflow-wrap: break-word; }
.chat-message-assistant table {
    display: block;
    width: max-content;
    max-width: 100%;
    overflow-x: auto;
}
.chat-tool-activity { overflow-wrap: anywhere; white-space: pre-wrap; }
/* ⛔ **Aucun fond en dur ici.** Les l. 902-903 thèment déjà ces deux bulles
   (`--brand-primary-soft`, `--surface-card-side`) ; ce bloc, plus bas dans le
   fichier, les écrasait avec `#d0e7ff` et `#f0f0f0`. En thème sombre le texte
   suivait le thème et le fond non : **1.08:1**, mesuré le 2026-09-18 — la
   conversation entière illisible, la plus grande zone de l'écran.

   ⚠️ `pre` et `code` partent avec elles, et ils ne pouvaient PAS rester : une
   bulle rendue sombre avec un `#e0e0e0` dedans aurait remplacé un défaut par
   un autre, sur un bloc de code au lieu d'un paragraphe. */
.chat-message-user { margin-left: auto; white-space: pre-wrap; }
.chat-message-assistant p { margin: 0 0 0.4em 0; }
.chat-message-assistant p:last-child { margin-bottom: 0; }
/* ⚠️ `--surface-divider`, PAS `--surface-muted`. Le second est une couleur de
   TEXTE (`#9ba2ad`, cf. `.dashboard-table thead th` qui l'emploie en `color`) :
   posé en fond, il donnait du rose sur gris à **1.48:1**. Le nom commence par
   `surface-` sans être une surface. */
.chat-message-assistant pre { background: var(--surface-divider); padding: 6px 8px; border-radius: 4px; overflow-x: auto; font-size: 0.85em; }
/* ⚠️ `color: inherit` : Bootstrap donne à `code` un rose (#d63384) calibré pour
   un fond clair — sur la pastille sombre il tombe à 3.73, sous AA. La pastille
   reste reconnaissable par sa monospace et son fond ; c'est la couleur qui
   n'avait pas à décider, puisqu'elle ne peut pas être juste dans les deux. */
.chat-message-assistant code { background: var(--surface-divider); color: inherit; padding: 1px 4px; border-radius: 3px; font-size: 0.85em; }
.chat-message-assistant pre code { background: none; padding: 0; }
.chat-message-assistant ul, .chat-message-assistant ol { margin: 0.3em 0; padding-left: 1.5em; }
.chat-message-assistant table { border-collapse: collapse; margin: 0.3em 0; font-size: 0.9em; }
.chat-message-assistant th, .chat-message-assistant td { border: 1px solid var(--surface-border); padding: 3px 6px; }
/* `--surface-muted` est ICI à sa place : c'est bien une couleur de texte. */
.chat-tool-activity { font-size: 0.75em; color: var(--surface-muted); font-family: monospace; padding: 2px 8px; border-left: 2px solid var(--surface-border); margin-left: 4px; }
.chat-thinking { font-style: italic; color: var(--surface-muted); padding: 8px 12px; }
/* `#500` — un bloc refusé se dit, discrètement : même voix que « l'assistant réfléchit ». */
.chat-block-refused { font-style: italic; color: var(--surface-muted); font-size: 0.85em; padding: 2px 12px; }
/* `#502` — le bilan du tour quand la trace est masquée : une phrase pour le lecteur, pas de police machine. */
/* `#508` — l'offre de reprise : une ligne et un bouton, aux couleurs du thème. */
.chat-resume-offer { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 12px; margin: 4px 0 8px; border: 1px solid var(--surface-border); border-radius: 6px; color: var(--surface-muted); font-size: 0.9em; }
.chat-turn-summary { font-style: italic; color: var(--surface-muted); font-size: 0.85em; padding: 2px 12px; }
.chat-input-row { border-top: 1px solid var(--surface-divider); padding: 12px; display: flex; gap: 8px; align-items: flex-end; }
.chat-input-row .form-group { flex: 1; margin-bottom: 0; }
.chat-input-row textarea { flex: 1; }
/* Voice dictation mic — pulses red while recording (Web Speech API). */
.chat-mic-recording { color: #dc3545; border-color: #dc3545; animation: chat-mic-pulse 1.2s ease-in-out infinite; }
@keyframes chat-mic-pulse {
    0%, 100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.4); }
    50% { box-shadow: 0 0 0 6px rgba(220, 53, 69, 0); }
}
.chat-clear-row { padding: 4px 12px; border-top: 1px solid #f5f5f5; text-align: center; }
/* Fix 2a — "Effacer la conversation" legibility: override text-muted on the dark panel surface. */
.chat-clear-row .btn-link { color: var(--surface-fg, #495057); }
.chat-clear-row .btn-link:hover { color: var(--brand-primary, #0d6efd); }

/* Review-changes card (rendered inside the chat panel when the AI proposes modifications) */
.chat-review-card { padding: 8px 12px; background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px; margin: 4px 0; }

/* Risk-warning card — rendered in the chat when the agent emits a risk-warning block. */
.chat-risk-card { padding: 10px 12px; border-radius: 6px; margin: 4px 0; border-width: 1px; border-style: solid; }
.chat-risk-card-low { background: var(--tone-muted-soft); border-color: var(--tone-muted-bg); color: var(--tone-muted-fg); }
.chat-risk-card-medium { background: var(--tone-info-soft); border-color: var(--tone-info-bg); color: var(--tone-info-fg); }
.chat-risk-card-high { background: var(--tone-warning-soft); border-color: var(--tone-warning-bg); color: var(--tone-warning-fg); }
.chat-risk-card-critical { background: var(--tone-danger-soft); border-color: var(--tone-danger-bg); color: var(--tone-danger-fg); }
.chat-risk-summary { font-weight: 600; display: flex; align-items: center; }
.chat-risk-icon { margin-right: 6px; }
.chat-risk-bullets { font-size: 0.9em; }
.chat-risk-bullets ul { margin: 6px 0 0 0; padding-left: 20px; }
.chat-risk-bullets p { margin: 6px 0; }
.chat-risk-bullets code { background: rgba(0, 0, 0, 0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.95em; }
.chat-critical-gate { display: flex; flex-direction: column; gap: 6px; }
.chat-critical-gate input { font-family: monospace; }

/* `#416` lot 5 — la permission demandée par le harnais, à CÔTÉ de la conversation.
   ⚠️ Volontairement distincte de `.chat-risk-card` : celle-ci vit DANS le fil et
   se lit comme un message, ce panneau vit à côté et se lit comme un état. Les
   confondre visuellement inviterait à les confondre tout court.

   ⛔ **Ces règles vivaient dans un `css/style.css` que rien ne charge.** La page
   ne sert que `custom.css` ; le panneau est donc resté SANS aucun style, et mes
   sondes ne l'ont pas vu — elles mesuraient la géométrie et le texte, tous deux
   justes sans une ligne de CSS. Vu à l'œil le 2026-09-18 : boutons collés, aucun
   cadre, quatre pleins en enfilade.

   ⭐ Android rendait déjà bien AVEC les mêmes boutons pleins : ce qui les tenait
   était sa `Card` teintée. C'est ce conteneur qui manquait, pas le style des
   boutons — le sens (quatre portées pairs, aucune recommandée) est donc
   inchangé sur les deux renderers. */
.permission-panel {
  flex: 0 0 auto;
  padding: 10px 12px;
  border-top: 1px solid var(--tone-neutral-bg);
  background: var(--tone-neutral-soft);
}
.permission-heading {
  font-weight: 600;
  font-size: 0.85rem;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  opacity: 0.7;
  margin-bottom: 6px;
}
/* La contrepartie de la `Card` Compose : un fond teinté qui SÉPARE la demande
   du fil, et sans lequel les quatre boutons flottent dans la conversation. */
.permission-card {
  padding: 10px 12px;
  border-radius: 6px;
  border: 1px solid var(--tone-info-bg);
  background: var(--tone-info-soft);
  /* ⛔ **Aucune `color` ici, et c'est délibéré.** `--tone-*-fg` est la couleur
     du fond PLEIN (`#ffffff` en thème clair) : posée sur un `-soft`, elle rend
     du blanc sur bleu pâle. Vu à l'écran le 2026-09-18, la carte était
     illisible. Le texte hérite donc de la page, qui se lit sur les deux fonds
     et suit le thème sans qu'on ait à le savoir. */
}
.permission-card + .permission-card { margin-top: 8px; }
.permission-actor { font-size: 0.85rem; opacity: 0.85; }
.permission-action { font-weight: 600; margin: 2px 0 10px; }
.permission-scopes { display: flex; flex-wrap: wrap; gap: 8px; }
/* ⚠️ Les quatre se ressemblent PARCE QU'ils sont pairs. La largeur minimale les
   fait lire comme un choix segmenté plutôt que comme quatre appels à l'action
   concurrents — c'est l'espacement et le cadre qui portent ça, pas une
   hiérarchie qu'on n'a pas. */
.permission-scopes .btn { min-width: 6.5rem; }
/* ⚠️ 0.8 et non 0.7 : l'`opacity` recompose le texte VERS le fond, donc le
   contraste réel n'est pas celui de la couleur. Mesuré sur la page : 5.10 à 0.7
   contre 12.28 pour la même couleur à plein. C'est la ligne qui dit que
   l'assistant attend — 6.5 la met hors de portée du doute. */
.permission-hint { font-size: 0.78rem; opacity: 0.8; margin-top: 10px; }

/* Ask-user question card. */
.chat-ask-user-card { padding: 10px 12px; background: #eef4ff; border: 1px solid #c7d7f3; border-radius: 6px; margin: 4px 0; }
.chat-ask-user-question { font-weight: 600; margin-bottom: 6px; }
.chat-ask-user-choices { display: flex; flex-wrap: wrap; gap: 6px; }
.chat-ask-user-choice { white-space: normal; text-align: left; }

/* Risk criticality badges */
.risk-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 0.75em; font-weight: 600; line-height: 1.4; }
.risk-info { background: var(--tone-muted-soft); color: var(--tone-muted-fg); }
.risk-low { background: var(--tone-success-soft); color: var(--tone-success-fg); }
.risk-medium { background: var(--tone-warning-soft); color: var(--tone-warning-fg); }
.risk-high { background: var(--tone-danger-soft); color: var(--tone-danger-fg); }
.risk-critical { background: #dc3545; color: white; }

/* Review-changes modal content */
.review-summary { display: flex; align-items: center; border-bottom: 1px solid #eee; }
.review-warnings { border-bottom: 1px solid #eee; }
.review-group { border-bottom: 1px solid #eee; }
.review-group-header { cursor: pointer; display: flex; align-items: center; flex-wrap: wrap; }
.review-group-header:hover { background: var(--surface-row-hover); }
.review-group-details { background: #fafafa; }
.review-entity { border-top: 1px dashed #ddd; }
.review-entity:first-child { border-top: none; }
.review-entity-header { display: flex; align-items: center; }
.review-field-diff { padding: 2px 0; font-size: 0.9em; }
.review-old-value { color: #999; text-decoration: line-through; }
.review-new-value { color: #155724; font-weight: 500; }
.review-ai-instruction { font-style: italic; }

/* Branch banner (hidden when not active) */
.branch-banner { background: var(--tone-warning-soft); border-bottom: 1px solid var(--tone-warning-bg); }
/* `#338` lot 4 — principe 1 : le cadre entier change, pas seulement une barre.
   Une bordure du ton d'avertissement entoure la zone de travail tant que le mode
   est actif ; elle suit le thème parce qu'elle vient des jetons. */
/* ⚠️ **Le ton d'avertissement change de rôle d'un thème à l'autre**, et un trait
   de 3 px est une ENCRE, pas un fond. 📏 Mesuré au navigateur le 2026-09-14,
   contre le fond réellement peint (et non le fond transparent de l'hôte, qui se
   lit noir et fabrique un faux chiffre) :

     thème   trait avec `-bg`   trait avec `-fg`
     clair   2,51               15,29
     sombre  1,35  ⇐ invisible  9,67

   ⭐ **La réponse était déjà dans le thème** : `--tone-warning-ink` est une encre
   **calculée** par le cœur avec son contraste garanti, publiée sur `<html>` à
   chaque changement de thème. 📏 Elle vaut 4,96 en clair et 9,67 en sombre contre
   le fond de page — donc un seul jeton, aucune bascule `[data-theme]` dans une
   règle de composant, et le jumeau exact de `semanticToneInk(WARNING)` côté
   Android : une seule idée, deux renderers.

   ⛔ **Et pas d'alias dans `:root`** : `ThemeBlocksAgreeGuardTest` n'y tolère que
   `--chrome-topbar-h`, parce qu'une valeur écrite là recrée la seconde source de
   palette que `#94` a retirée. L'encre s'écrit donc à ses deux sites d'usage,
   avec son repli — l'idiome déjà en place sur les boutons et les badges. */
.branch-active-tint {
    --branch-frame-w: 3px;
    box-shadow: inset 0 0 0 var(--branch-frame-w) var(--tone-warning-ink, var(--tone-warning-bg));
}
/* ⛔ **Une ombre INTÉRIEURE se peint sous les enfants de l'élément.** Tout enfant
   opaque qui touche le bord recouvre donc le cadre, sur toute sa hauteur.
   📏 Mesuré au navigateur le 2026-09-14, en copie, sur la liste des affaires : la
   barre collante va de 240 à 1547 — exactement les bords de l'hôte, **débord 0**.
   Elle ne dépassait pas : elle repeignait la bande. D'où un cadre interrompu à
   gauche ET à droite, au même niveau, sur le film du jour.
   ⭐ On rend les 3 px au cadre sans retirer la raison d'être du débord — masquer
   le contenu qui défile dans la gouttière : la barre déborde juste 3 px de moins,
   et c'est le cadre, opaque, qui occupe ces 3 px.
   ⚠️ Sélecteur à trois classes, et pas deux : la règle de gouttière plus bas
   (`.chrome-page-host .browse-toolbar-sticky`) a la même spécificité et vient
   APRÈS dans le fichier — à égalité, elle gagnerait. */
.chrome-page-host.branch-active-tint .browse-toolbar-sticky,
.chrome-page-host.branch-active-tint .form-buttons-sticky {
    margin-left: calc(-1 * var(--chrome-content-gutter) + var(--branch-frame-w));
    margin-right: calc(-1 * var(--chrome-content-gutter) + var(--branch-frame-w));
    padding-left: calc(var(--chrome-content-gutter) - var(--branch-frame-w));
    padding-right: calc(var(--chrome-content-gutter) - var(--branch-frame-w));
}
/* ⚠️ **Le cadre a révélé un défaut qui n'est pas le sien** : `.chrome-page-host`
   porte `padding: 0 24px` — aucun padding vertical, donc le premier bloc de la
   page est à **0 px** du bord haut (mesuré). Sans cadre ça ne se voit pas ; avec,
   le trait doré touche le texte. On donne l'air ici, là où le trait existe ;
   en donner à TOUS les écrans est un autre sujet, plus large que ce défaut. */
.chrome-page-host.branch-active-tint {
    padding-top: var(--space-md);
    padding-bottom: var(--space-md);
}
/* Un bouton désactivé ne reçoit pas le survol : l'enveloppe porte la raison. */
.branch-apply-refused { display: inline-block; cursor: not-allowed; }
.branch-apply-refused > button { pointer-events: none; }

/* ============================================================
   Phase F.14 — Chrome dispatch CSS
   ============================================================ */

/* ── SIDEBAR chrome ─────────────────────────────────────────── */
[data-chrome="sidebar"] #kvapp > .kv-panel:first-child {
    /* Let the layout div drive the structure */
    display: block;
}

.chrome-sidebar-layout {
    display: flex;
    height: var(--app-h, 100vh);
    /* Phase F.14.4 — visible so chrome dropdowns can escape; the
     * inner content area carries its own scroll. */
    overflow: visible;
}

/* Fixed-width left rail.
 * Phase F.14.4 — the rail is painted with the theme's `--chrome-rail-*`
 * tokens. By default these resolve to the surface palette (back-compat
 * for any renderer that hasn't opted in), but every shipping theme
 * overrides them so each gets a distinctively-coloured sidebar — the
 * single biggest visual identifier between themes. */
.chrome-sidebar-rail {
    width: 240px;
    min-width: 240px;
    height: var(--app-h, 100vh);
    position: sticky;
    top: 0;
    background-color: var(--chrome-rail-bg, var(--surface-card-side));
    color: var(--chrome-rail-fg, var(--surface-fg));
    border-right: 1px solid var(--chrome-rail-border, var(--surface-border));
    display: flex;
    flex-direction: column;
    overflow-y: auto;
    overflow-x: hidden;
    z-index: 100;
    flex-shrink: 0;
}

/* Brand area at top of rail — sits at the very top, separated by a
 * subtle inner-shadow border from the rest of the rail. */
.chrome-sidebar-brand {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: var(--space-md) var(--space-lg);
    border-bottom: 1px solid var(--chrome-rail-border-soft, rgba(255, 255, 255, 0.08));
    min-height: 56px;
}
/* ⛔ **`--brand-mark` et non `--brand-primary`** : celle-là peint aussi les
   boutons, les liens et les survols du rail, donc la couleur d'un client y
   repeindrait le produit entier. Cette variable-ci n'a que ce lecteur.

   Le repli est le dégradé d'origine, et il ne sert que si la publication n'a pas
   eu lieu (`BrandMarkVariable`, posée à chaque pose de `data-theme`) — comme le
   repli des encres de ton, il est là pour qu'une absence se voie, pas pour
   servir. */
.chrome-sidebar-brand::before {
    content: "";
    display: inline-block;
    width: 22px;
    height: 22px;
    min-width: 22px;
    border-radius: var(--radius-sm);
    background: var(--brand-mark, linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary) 60%, var(--brand-accent) 60%, var(--brand-accent) 100%));
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
/* Deux lignes quand l'espace a un nom : lui devant, l'application dessous.
   `min-width: 0` sur le conteneur, sinon l'ellipse du nom ne se déclenche pas —
   un enfant de flex refuse de rétrécir sous son contenu par défaut. */
.chrome-sidebar-brand-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
.chrome-sidebar-brand-app {
    font-size: 0.75rem;
    color: var(--surface-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.chrome-sidebar-brand-name {
    font-family: var(--font-headline-family);
    font-weight: 600;
    font-size: 1.1rem;
    color: var(--chrome-rail-fg, var(--surface-fg));
    letter-spacing: -0.02em;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Nav section */
.chrome-sidebar-nav {
    padding: var(--space-sm) 0;
    flex: 1;
}

/* Individual nav item */
.chrome-sidebar-item {
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    padding: var(--space-sm) var(--space-lg);
    color: var(--chrome-rail-fg, var(--surface-fg)) !important;
    text-decoration: none !important;
    font-size: 0.875rem;
    border-radius: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    transition: background-color var(--motion-fast);
}
.chrome-sidebar-item:hover,
.chrome-sidebar-item:focus {
    background-color: var(--chrome-rail-hover-bg, var(--surface-row-hover));
    color: var(--chrome-rail-active-fg, var(--brand-primary)) !important;
}
.chrome-sidebar-item .bi {
    font-size: 1rem;
    min-width: 18px;
    text-align: center;
    color: var(--chrome-rail-fg-muted, var(--surface-muted));
}
.chrome-sidebar-item:hover .bi { color: var(--chrome-rail-active-fg, var(--brand-primary)); }

/* Divider between primary and secondary nav sections */
.chrome-sidebar-divider {
    height: 1px;
    background-color: var(--chrome-rail-border-soft, var(--surface-divider));
    margin: var(--space-xs) var(--space-lg);
}

/* Group header (accordion toggle) */
.chrome-sidebar-group {
    overflow: hidden;
}
/* ⛔ **La hiérarchie était inversée, et sur les deux axes à la fois.**
 *
 * 📏 Mesuré le 2026-09-13 sur les cinq thèmes : l'en-tête de section était
 * peint en `--chrome-rail-fg-muted`, qui n'atteint 4,5:1 sur le fond du rail
 * dans **aucun** thème clair — light 3,65:1, reworkd 3,72:1, augen 4,02:1,
 * apple 4,27:1 (dark passait, à 4,77:1). L'entrée qu'il chapeaute, elle, est
 * en `--chrome-rail-fg` et tient 12,3 à 15,3:1 partout. Le contenant
 * s'effaçait donc derrière son contenu, chez tout le monde.
 *
 * ⭐ Le correctif est ici et **pas dans les palettes** : c'est le rôle qui
 * était mal choisi, pas la couleur qui était mal réglée. Passer l'en-tête sur
 * l'encre pleine remet les cinq thèmes au-dessus de AA d'un coup, sans
 * toucher un jeton ni re-valider une palette — et le jour où un sixième thème
 * arrive, il hérite de la propriété au lieu d'avoir à la redécouvrir.
 *
 * ⚠️ Les icônes restent sur le jeton clair **délibérément** : ce n'est pas du
 * texte, le seuil opposable est 3:1 (WCAG 1.4.11), et 3,65:1 le passe. Le mémo
 * du 12/09 dit « tout *texte* de navigation » ; on ne s'élargit pas tout seul.
 * ⓘ **Le chevron, lui, en est sorti** (C21, `#575`) — voir sa règle.
 *
 * Ce qui porte le « plus marqué » du mémo : capitales + graisse 700 + encre
 * pleine + interlettrage. La taille reste sous celle de l'entrée — un
 * en-tête de section se reconnaît à sa forme, pas à son encombrement. */
.chrome-sidebar-group-header {
    display: flex;
    align-items: center;
    gap: var(--space-sm);
    padding: var(--space-sm) var(--space-lg);
    font-weight: 700;
    color: var(--chrome-rail-fg, var(--surface-fg));
    cursor: pointer;
    user-select: none;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    /* ⚠️ Une seule déclaration, et c'est un correctif : il y en avait **deux**
     * dans cette règle (`0.875rem` puis `0.75rem`). La seconde gagnait en
     * silence, donc la première ne décrivait rien — et quiconque lisait le
     * fichier par le haut lisait la valeur qui ne s'applique pas. */
    font-size: 0.75rem;
    transition: background-color var(--motion-fast);
}
.chrome-sidebar-group-header:hover { background-color: var(--chrome-rail-hover-bg, var(--surface-row-hover)); }
.chrome-sidebar-group-header .bi:not(.chrome-sidebar-chevron) {
    min-width: 18px;
    text-align: center;
}

/* Chevron rotates when group is open.
 *
 * C21 (`#575`) — **le chevron est ce qui dit qu'une section se déplie**, et il
 * ne se voyait pas : 0,65 rem sur le jeton clair (3,65:1). Il passait le seuil
 * WCAG d'une icône, et le client ne distinguait pourtant pas une section
 * repliée d'une section vide — le seuil borne la perception, pas le sens. Il
 * prend l'encre de l'en-tête qu'il accompagne, et un cran de taille. */
.chrome-sidebar-chevron {
    font-size: 0.75rem;
    transition: transform var(--motion-standard);
    color: var(--chrome-rail-fg, var(--surface-fg));
}

/* Collapsible children container */
.chrome-sidebar-group-items {
    max-height: 0;
    overflow: hidden;
    transition: max-height var(--motion-standard) ease;
}
/* Entrée enfant — le **plus léger** des trois niveaux.
 *
 * ⛔ Avant, elle ne se distinguait que par son indentation : même taille,
 * même graisse, même encre que l'entrée de premier niveau. Un décalage
 * horizontal est un repère qui **disparaît dès qu'on ne voit pas les deux
 * niveaux ensemble** — c'est-à-dire dès qu'un groupe est replié, ou qu'on
 * arrive sur la colonne par le bas.
 *
 * Deux repères, cumulés parce qu'ils échouent différemment : la taille
 * réduite tient même isolée ; le filet, lui, dit à quoi l'entrée se
 * rattache. Le filet est descendu **dans la colonne d'icônes du parent**
 * (`--space-lg` + 8 px, soit le centre d'une icône de 18 px), donc il part
 * visuellement de la section et non d'un bord arbitraire.
 *
 * ⚠️ Peint en `background-image` et pas en `border-left` : la bordure
 * s'ancre à x=0, hors de l'indentation. Et pas sur le conteneur
 * `.chrome-sidebar-group-items` non plus — il est à `max-height: 0` quand
 * le groupe est replié, et un filet d'une hauteur nulle ne se voit pas
 * pendant l'animation d'ouverture. Le `background-color` du survol reste
 * libre : les deux propriétés ne se recouvrent pas. */
.chrome-sidebar-group-items .chrome-sidebar-item {
    padding-left: calc(var(--space-lg) + 26px);
    font-size: 0.8125rem;
    background-image: linear-gradient(
        var(--chrome-rail-border, var(--surface-border)),
        var(--chrome-rail-border, var(--surface-border))
    );
    background-size: 1px 100%;
    background-repeat: no-repeat;
    background-position: calc(var(--space-lg) + 8px) 0;
}

/* Right-side body: topbar + scrollable content.
 * Phase F.14.4 — kept overflow visible so chrome-topbar dropdowns
 * (theme picker, user picker) can escape vertically. The inner
 * content child below carries the actual scroll. */
.chrome-sidebar-body {
    flex: 1;
    display: flex;
    flex-direction: column;
    overflow: visible;
    min-width: 0;
    min-height: 0;
}

/* Slim top bar above content */
.chrome-sidebar-topbar {
    display: flex;
    align-items: center;
    gap: var(--space-md);
    padding: 0 var(--space-lg);
    height: 56px;
    min-height: 56px;
    background-color: var(--surface-card);
    border-bottom: 1px solid var(--surface-border);
    box-shadow: var(--surface-shadow);
    position: sticky;
    top: 0;
    /* Phase F.14.4 — sit above .browse-toolbar-sticky (z=1010) so chrome
     * dropdowns aren't covered by the page-header search bar. The
     * topbar IS the navigation chrome and must win the stacking
     * contest with sticky page elements. */
    z-index: 1020;
    flex-shrink: 0;
    /* Phase F.14.4 — let dropdowns inside escape the chrome body's
     * overflow: hidden clipping. */
    overflow: visible;
}

/* Phase F.14.4 — chrome dropdowns sit above the page-header (z=80)
 * and any sticky table headers. Bootstrap's default is 1000 already,
 * but make this explicit so we don't fight Bootstrap-version drift. */
.chrome-account-menu,
.chrome-sidebar-topbar .dropdown-menu,
.chrome-topbar-right .dropdown-menu {
    z-index: 2000;
}

/* Phase F.14.6 — chrome dropdowns must scroll when their content is
 * taller than the viewport. The Developer dropdown carries 12 items
 * (3 leaves + 4 Layout entries + 4 AI entries + Reset); on a smaller
 * browser window without this rule items below the viewport were
 * clipped with no scroll affordance. */
.navbar .dropdown-menu,
.chrome-account-menu,
.chrome-sidebar-topbar .dropdown-menu,
.chrome-topbar-right .dropdown-menu {
    max-height: calc(100vh - 80px);
    overflow-y: auto;
}
.chrome-topbar-breadcrumb {
    display: flex;
    align-items: center;
    min-width: 0;
    flex-shrink: 1;
    color: var(--surface-muted);
    font-size: 0.875rem;
}
.chrome-topbar-center {
    flex: 1;
    display: flex;
    justify-content: center;
    align-items: center;
}
.chrome-topbar-right {
    display: flex;
    align-items: center;
    gap: var(--space-xs);
    flex-shrink: 0;
}

/* ── Le cluster de droite ne peut plus pousser le document ───────────
 * Il était `flex-shrink: 0` avec deux libellés de longueur **non bornée** —
 * le nom de l'utilisateur connecté et celui de l'espace de travail. À 390 px
 * de large, la barre débordait de 91 px : toute la page gagnait une scrollbar
 * horizontale, sur *chaque* écran, pour un défaut qui vit dans le chrome.
 *
 * Deux moitiés, et il faut les deux :
 *
 * 1. **Le plafond** (ci-dessous) borne ce que le contenu peut réclamer. Sans
 *    lui, la requête média n'est qu'une déclaration : elle dit « à 640 px ça
 *    tient » pendant que le contenu, lui, décide autre chose — une adresse
 *    e-mail de 40 caractères comme nom affiché redéborde à 900 px et la
 *    requête ne le voit jamais.
 * 2. **La bascule icône-seule** (plus bas) évite que ce plafond ne produise
 *    des moignons (« Décon… ») aux petites largeurs.
 *
 * Le plafond est en `rem` et non en `ch` : sous la requête média la taille de
 * police tombe à zéro, donc `ch` y vaudrait 0 px et emporterait l'icône avec
 * le texte.
 *
 * ⚠️ Pas d'`overflow: hidden` sur `.chrome-topbar-right` ni sur `.dropdown` :
 * les menus déroulants du chrome s'échappent volontairement de la barre
 * (F.14.4). Le rognage est posé sur les seuls éléments qui ne contiennent
 * aucun menu — le libellé d'identité, et le *bouton* d'espace de travail dont
 * le menu est un frère, pas un enfant. */
.chrome-account-name {
    display: inline-block;
    max-width: 10rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    vertical-align: bottom;
}

/* Le témoin de connexion est muet quand tout va bien : une pastille verte
 * permanente n'apprend rien et se cesse de regarder au bout d'un jour. Quand
 * ça va mal il reprend sa place ET gagne un libellé — un état anormal doit
 * être plus fort que l'état normal ne l'était, pas plus discret. */
.chrome-connection.is-online { display: none; }
.chrome-connection {
    display: inline-flex;
    align-items: center;
    color: var(--tone-danger-bg);
    font-size: 0.8125rem;
    white-space: nowrap;
}

/* Le menu de compte porte des sections : deux lignes d'identité, puis des
 * en-têtes de section. Sans cette largeur minimale il se replie sur le plus
 * long libellé et les en-têtes se cassent en deux lignes. */
.chrome-account-menu { min-width: 15rem; }
.chrome-account-identity { line-height: 1.3; }
.chrome-account-identity-name { display: block; font-weight: 600; color: var(--surface-fg); }
.chrome-account-identity-mail { display: block; font-size: 0.75rem; }

/* Sous 640 px, les commandes du chrome passent en icône seule.
 *
 * `font-size: 0` plutôt que `display: none`, et ce n'est pas un détail de
 * style : un contenu en `display: none` est **exclu du calcul du nom
 * accessible**, donc le lecteur d'écran perdrait « Déconnexion » en même temps
 * que l'œil. À taille nulle le nœud texte reste dans l'arbre, le nom
 * accessible survit, et l'icône reprend sa taille juste en dessous.
 *
 * C'est aussi ce qui rend la règle indifférente à la forme du libellé : span
 * ou nœud texte nu (ce que rend `button(text = …)` de KVision), les deux
 * disparaissent. Le rail de gauche avait payé exactement cette différence.
 *
 * 640 px et pas 1024 : au-dessus, les libellés tiennent et ils informent — le
 * seuil mesuré du débordement est ~456 px, le plafond ci-dessus garantit que
 * le pire cas reste sous 640. */
@media (max-width: 640px) {
    .chrome-topbar-right .nav-link,
    .chrome-topbar-right .btn,
    .chrome-account-toggle,
    .chrome-connection {
        font-size: 0;
    }
    .chrome-topbar-right .bi,
    .chrome-topbar-right i[class*="bi-"],
    .chrome-account-toggle .bi,
    .chrome-account-toggle i[class*="bi-"],
    .chrome-connection .bi {
        font-size: 1rem;
    }
    /* ⚠️ Le menu déroulant, lui, garde sa taille : la bascule ne touche que la
     * barre. Sans cette remise à zéro, `font-size: 0` posé sur le conteneur
     * `.chrome-topbar-right` descendrait aussi dans le menu ouvert. */
    .chrome-account-menu,
    .chrome-topbar-right .dropdown-menu {
        font-size: 0.875rem;
    }
}

/* Scrollable content area */
.chrome-sidebar-body > .kv-panel:last-child,
.chrome-sidebar-body > div:not(.chrome-sidebar-topbar):not(.branch-banner) {
    flex: 1;
    overflow-y: auto;
}

/* ── Gouttière de contenu, propriété du chrome ───────────────
 * `.chrome-page-host` est le div `mainBodyRef` d'`App.kt` : le MÊME objet est
 * ajouté par les quatre chromes, donc cette règle atteint liste, formulaire,
 * tableau de bord et planning d'un coup. C'est le point de l'audit M7 : le
 * zéro-marge était le défaut involontaire, pas un choix de pleine largeur.
 *
 * ⚠️ Le padding va ici et pas sur les écrans : un écran qui se souvient de sa
 * marge est un écran qui peut l'oublier — c'est déjà ce qui s'était passé, le
 * titre avait 8 px et les champs zéro, sur la même page. */
.chrome-page-host {
    padding-left: var(--chrome-content-gutter);
    padding-right: var(--chrome-content-gutter);
    /* ⛔ **`position: relative` n'est pas décoratif : c'est ce qui empêche une
     * SECONDE barre de défilement** (`#275`).
     *
     * Ce div est le conteneur de défilement (`overflow-y: auto`, posé par
     * `.chrome-sidebar-body > div:not(.chrome-sidebar-topbar)`). Sans contexte de
     * positionnement, tout descendant en `position: absolute` **sans `top`** se
     * pose à sa position statique dans le **bloc conteneur initial** : ses
     * coordonnées sont celles du document, il échappe au découpage du scroller,
     * et il fait grandir `html` — d'où deux ascenseurs pour un écran.
     *
     * 📏 L'habitant mesuré est le `<select>` natif que TomSelect masque
     * (`.ts-hidden-accessible`, frère de `.ts-wrapper`, chaîne d'ancêtres tout
     * en `static`). Sur une fiche de facture à 1024x900 : `html.scrollHeight`
     * 1176 pour 900 de viewport, dont le dernier select à `bottom: 1176` — le
     * document faisait exactement la hauteur de cet élément caché.
     *
     * ⭐ **Le correctif ne déplace pas le débordement, il le supprime** :
     * `html.scrollHeight` passe de 1176 à 900 **et** le scroller reste à 1505.
     * Et le select ne bouge pas d'un pixel : son `top` est `auto`, donc sa
     * position statique est inchangée — seul son bloc conteneur change.
     *
     * ⚠️ **Ne pas « corriger » cela sur `.ts-hidden-accessible`.** Un
     * `height: 1px` + `clip-path` rendrait le débordement plus petit, pas nul :
     * un élément posé à 1152 px déborde même haut d'un pixel. Et le défaut n'est
     * pas propre à TomSelect — c'est la classe entière des absolus sans `top`
     * dans ce scroller. C'est le contexte de positionnement qui répond, une fois,
     * pour les quatre chromes et tous les écrans.
     *
     * ⚠️ Sans effet sur ce qui doit s'échapper : `position: fixed` ignore un
     * ancêtre `relative` (le bouton IA), `sticky` se cale sur le scroller (les
     * deux barres collantes ci-dessous), et la liste déroulante de TomSelect se
     * pose déjà contre `.ts-wrapper`, qui est `relative`. */
    position: relative;
}

/* Le corps de page est aussi le conteneur de défilement vertical sous le
 * chrome sidebar/hybrid. Les deux barres collantes qui s'y trouvent portent un
 * fond opaque : sans ce débord, on verrait le contenu défiler dans la gouttière
 * juste à côté d'elles. Elles reprennent la gouttière en padding, donc leur
 * contenu reste aligné sur le reste de la page. */
.chrome-page-host .browse-toolbar-sticky,
.chrome-page-host .form-buttons-sticky {
    margin-left: calc(-1 * var(--chrome-content-gutter));
    margin-right: calc(-1 * var(--chrome-content-gutter));
    padding-left: var(--chrome-content-gutter);
    padding-right: var(--chrome-content-gutter);
}

/* Dark mode for sidebar */
/* ⛔ **La surcharge du RAIL a été retirée, elle annulait les jetons du thème.**
   Elle reposait `--surface-card-side` par-dessus `.chrome-sidebar-rail`, donc
   par-dessus `var(--chrome-rail-bg, …)` de la règle générale (l. ~1469) — dont
   le commentaire promet pourtant *« each gets a distinctively-coloured sidebar
   — the single biggest visual identifier between themes »*.

   📏 Mesuré le 2026-09-14 : les cinq thèmes déclarent leur `chrome-rail-bg`, et
   le sombre était **le seul** à ne pas le recevoir — `#14282d` déclaré, `#1a1e23`
   peint. La surcharge est antérieure aux jetons `--chrome-rail-*` et avait
   survécu à leur introduction en continuant de faire leur travail.

   ⚠️ Elle ne se voyait d'aucun côté : le gris qu'elle imposait est une couleur
   plausible pour un rail sombre, et le thème n'a aucun moyen de dire que sa
   valeur a été ignorée. C'est ce qui l'a gardée en place. */
[data-theme="dark"] .chrome-sidebar-topbar {
    background-color: var(--surface-card);
    border-bottom-color: var(--surface-border);
}

/* Rail compact — piloté par `AiLayout`, plus par une requête média.
 *
 * Deux raisons de compacter, et une seule d'entre elles est une largeur de
 * viewport : sous 1024 px on est hors de la cible « poste de travail »
 * (`CLAUDE.md`) ; et quand le tiroir IA prend sa colonne dans la bande moyenne,
 * les 184 px rendus par le rail sont ce qui permet au contenu de garder son
 * plancher. Une requête média ne pouvait exprimer que la première : elle ne
 * sait rien de l'état du tiroir. Les deux conditions vivent donc dans
 * `AiLayout.apply`, qui pose cette classe. */
body.ai-rail-compact .chrome-sidebar-rail {
    width: 56px;
    min-width: 56px;
}
body.ai-rail-compact .chrome-sidebar-brand-name,
body.ai-rail-compact .chrome-sidebar-item span:not(.bi),
body.ai-rail-compact .chrome-sidebar-group-header span:not(.bi):not(.chrome-sidebar-chevron),
body.ai-rail-compact .chrome-sidebar-chevron {
    display: none;
}
body.ai-rail-compact .chrome-sidebar-brand { justify-content: center; padding: var(--space-md); }
body.ai-rail-compact .chrome-sidebar-item { justify-content: center; padding: var(--space-sm); }
body.ai-rail-compact .chrome-sidebar-group-header { justify-content: center; padding: var(--space-sm); }
/* ⚠️ `background-image: none` n'est pas décoratif : le filet de rattachement
 * est posé à `--space-lg + 8px`, une abscisse calculée pour l'indentation du
 * rail **déplié**. En rail compact l'entrée retombe à `--space-sm`, et le
 * filet se retrouverait à droite du texte, au milieu de rien. */
body.ai-rail-compact .chrome-sidebar-group-items .chrome-sidebar-item { padding-left: var(--space-sm); background-image: none; }

/* Net de sécurité — la vraie correction est structurelle, elle est en
 * Kotlin : `SidebarChrome.renderSidebarItem` et `homeLink` emballent le
 * libellé dans un `span`, ce que le sélecteur ci-dessus sait atteindre.
 *
 * Ce bloc-ci rattrape le cas que le sélecteur ne PEUT pas voir : un
 * libellé rendu en **nœud texte nu** (ce que produit `link(label = …)` de
 * KVision). C'est précisément ce qui a cassé ce rail — le contenu restait
 * plus large que la boîte de 56 px, et centrer une ligne flex en
 * débordement pousse son *début* dehors : icônes mesurées à x=-50, hors du
 * bord gauche du rail.
 *
 * `font-size: 0` fait disparaître n'importe quel texte, span ou pas ;
 * l'icône reprend sa taille juste en dessous. À garder tant que le rail se
 * construit avec un helper qui peut émettre du texte sans balise — un
 * garde mécanique, parce qu'une règle connue ne protège que le geste en
 * cours. */
body.ai-rail-compact .chrome-sidebar-item,
body.ai-rail-compact .chrome-sidebar-group-header {
    font-size: 0;
}
body.ai-rail-compact .chrome-sidebar-item .bi,
body.ai-rail-compact .chrome-sidebar-item > i[class*="bi-"],
body.ai-rail-compact .chrome-sidebar-group-header .bi {
    font-size: 1rem;
}

/* ── HYBRID chrome ──────────────────────────────────────────── */
.chrome-hybrid-layout {
    display: flex;
    height: var(--app-h, 100vh);
    /* Phase F.14.6 — visible so chrome-topbar dropdowns escape (same
     * fix as the sidebar layout). The inner content carries scroll. */
    overflow: visible;
}
.chrome-hybrid-rail {
    width: 64px;
    min-width: 64px;
    height: var(--app-h, 100vh);
    position: sticky;
    top: 0;
    background-color: var(--surface-card-side);
    border-right: 1px solid var(--surface-border);
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: var(--space-sm) 0;
    z-index: 100;
    flex-shrink: 0;
}
.chrome-hybrid-brand-icon {
    width: 32px;
    height: 32px;
    border-radius: var(--radius-sm);
    background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary) 60%, var(--brand-accent) 60%, var(--brand-accent) 100%);
    margin-bottom: var(--space-sm);
    cursor: pointer;
}
.chrome-hybrid-nav {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 2px;
    width: 100%;
}
.chrome-hybrid-rail-item {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    border-radius: var(--radius-md);
    color: var(--surface-muted) !important;
    text-decoration: none !important;
    transition: background-color var(--motion-fast), color var(--motion-fast);
}
.chrome-hybrid-rail-item:hover { background-color: var(--brand-primary-soft); color: var(--brand-primary) !important; }
.chrome-hybrid-rail-item .bi { font-size: 1.2rem; }
.chrome-hybrid-body {
    flex: 1;
    display: flex;
    flex-direction: column;
    /* Phase F.14.6 — visible so dropdowns escape; inner content area
     * carries its own scroll (same pattern as sidebar chrome). */
    overflow: visible;
    min-width: 0;
    min-height: 0;
}
.chrome-hybrid-body > div:not(.chrome-hybrid-topbar):not(.branch-banner) {
    flex: 1;
    overflow-y: auto;
}
.chrome-hybrid-topbar {
    display: flex;
    align-items: center;
    gap: var(--space-md);
    padding: 0 var(--space-lg);
    height: 56px;
    min-height: 56px;
    background-color: var(--surface-card);
    border-bottom: 1px solid var(--surface-border);
    box-shadow: var(--surface-shadow);
    position: sticky;
    top: 0;
    /* z-index above .browse-toolbar-sticky (z=1010) so chrome dropdowns
     * are not covered by the page-header search bar. */
    z-index: 1020;
    flex-shrink: 0;
    overflow: visible;
}

/* ── SPOTLIGHT chrome ───────────────────────────────────────── */
.chrome-spotlight-layout {
    display: flex;
    flex-direction: column;
    height: var(--app-h, 100vh);
    overflow: hidden;
}
.chrome-spotlight-topbar {
    display: flex;
    align-items: center;
    gap: var(--space-lg);
    padding: 0 var(--space-lg);
    height: 56px;
    min-height: 56px;
    background-color: var(--surface-card);
    border-bottom: 1px solid var(--surface-border);
    box-shadow: var(--surface-shadow);
    z-index: 1020;
}
.chrome-spotlight-brand {
    flex-shrink: 0;
}
.chrome-spotlight-brand-name {
    font-family: var(--font-headline-family);
    font-weight: 600;
    font-size: 1rem;
    color: var(--surface-fg);
    letter-spacing: -0.02em;
}
.chrome-spotlight-search-wrap {
    flex: 1;
    display: flex;
    justify-content: center;
    position: relative;
}
.chrome-spotlight-input {
    width: 100%;
    max-width: 560px;
    padding: 0.4rem 1rem !important;
    border-radius: 999px !important;
    background-color: var(--surface-input-bg) !important;
    border: 1.5px solid var(--surface-border) !important;
    font-size: 0.9rem;
    transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.chrome-spotlight-input:focus {
    border-color: var(--brand-primary) !important;
    box-shadow: 0 0 0 0.2rem var(--brand-primary-soft) !important;
}
.chrome-spotlight-suggestions {
    position: absolute;
    top: calc(100% + 4px);
    left: 50%;
    transform: translateX(-50%);
    width: 100%;
    max-width: 560px;
    background-color: var(--surface-card);
    border: 1px solid var(--surface-border);
    border-radius: var(--radius-md);
    box-shadow: var(--surface-shadow-lg);
    z-index: 1030;
    overflow: hidden;
}
.chrome-spotlight-suggestion-item {
    display: block;
    padding: var(--space-sm) var(--space-lg);
    color: var(--surface-fg) !important;
    text-decoration: none !important;
    font-size: 0.875rem;
    transition: background-color var(--motion-fast);
}
.chrome-spotlight-suggestion-item:hover { background-color: var(--surface-row-hover); color: var(--brand-primary) !important; }
.chrome-spotlight-right {
    display: flex;
    align-items: center;
    gap: var(--space-xs);
    flex-shrink: 0;
}

/* ── AI Launchers ───────────────────────────────────────────── */

/* Top-bar search field */
.ai-topbar-search {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
}
/* `#509` — l'icône collée au champ : la porte visible du panneau de l'assistant. */
.ai-topbar-open {
    flex-shrink: 0;
    margin-left: 0.4rem;
    width: 34px;
    height: 34px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0 !important;
    border-radius: 50% !important;
    border: 1.5px solid var(--surface-border) !important;
    background-color: var(--surface-input-bg) !important;
    color: var(--brand-primary) !important;
    transition: border-color var(--motion-fast), background-color var(--motion-fast);
}
.ai-topbar-open:hover,
.ai-topbar-open:focus {
    border-color: var(--brand-primary) !important;
    background-color: var(--brand-primary-soft) !important;
}
.ai-topbar-input {
    width: 100%;
    max-width: 480px;
    padding: 0.35rem 0.9rem !important;
    border-radius: 999px !important;
    background-color: var(--surface-input-bg) !important;
    border: 1.5px solid var(--surface-border) !important;
    font-size: 0.875rem;
    transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.ai-topbar-input:focus {
    border-color: var(--brand-primary) !important;
    box-shadow: 0 0 0 0.2rem var(--brand-primary-soft) !important;
}

/* FAB — fixed circle, bottom-right */
.ai-fab {
    position: fixed;
    bottom: 24px;
    right: 24px;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    background-color: var(--brand-primary);
    color: var(--brand-primary-fg);
    border: none;
    box-shadow: var(--surface-shadow-lg);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    z-index: 1050;
    transition: background-color var(--motion-fast), transform var(--motion-fast);
}
.ai-fab:hover {
    background-color: var(--brand-primary-hover);
    transform: scale(1.08);
}
.ai-fab .bi {
    font-size: 1.5rem;
    line-height: 1;
}
/* ══ M5 — les trois dispositions du tiroir IA ═══════════════════════════
 *
 * `AiLayout` décide, ce bloc obéit. `data-ai-layout` vaut :
 *
 *   closed  rien à l'écran que l'appel (champ ou bouton flottant)
 *   side    colonne latérale, le contenu **rétrécit** au lieu d'être recouvert
 *   sheet   feuille basse, même chose sur l'axe vertical
 *   front   premier plan, à un appui du retour
 *
 * ⚠️ La largeur du panneau est déclarée **une fois** et sert **deux fois** :
 * le panneau, et la gouttière qui repousse le contenu. Deux constantes
 * séparées auraient dérivé au premier ajustement, et la dérive se serait vue
 * comme un contenu qui passe sous le panneau — c'est-à-dire comme le défaut
 * qu'on est en train de corriger. */
body[data-ai-layout="side"] { --ai-panel-w: clamp(320px, 30vw, 480px); }
body[data-ai-layout="sheet"] { --ai-sheet-h: clamp(320px, 45vh, 560px); }

body[data-ai-layout="side"] .chat-panel {
    left: auto;
    width: var(--ai-panel-w);
}
/* `.chrome-page-host` est le div que **les quatre chromes** ajoutent (voir la
 * gouttière de M7) : une seule règle atteint liste, formulaire, tableau de bord
 * et planning, dans les quatre dispositions de chrome. La barre du haut, elle,
 * n'est pas repoussée — le panneau commence sous elle, donc le menu de compte
 * reste atteignable. */
body[data-ai-layout="side"] .chrome-page-host { margin-right: var(--ai-panel-w); }

/* En feuille basse, on ne pousse pas : on **raccourcit l'application**. Une
 * marge basse n'aurait ajouté que du vide en fin de défilement, la feuille
 * continuant de recouvrir le contenu dès qu'on remonte. `--app-h` est lue par
 * les quatre `height` de layout. */
body[data-ai-layout="sheet"] { --app-h: calc(100vh - var(--ai-sheet-h)); }
body[data-ai-layout="sheet"] .chat-panel {
    top: auto;
    left: 0;
    right: 0;
    bottom: 0;
    width: auto;
    height: var(--ai-sheet-h);
    border-left: none;
    border-top: 1px solid var(--surface-border, #c8c8c8);
    box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.12);
}

body[data-ai-layout="front"] .chat-panel {
    left: 0;
    right: 0;
    width: auto;
    box-shadow: none;
    border-left: none;
}

/* ── L'appel : une porte par taille, jamais deux ────────────────────────
 * Le champ invite à taper et mérite sa place en paysage large ; le bouton
 * flottant est le seul possible quand il n'y a plus de place. Les deux
 * coexistaient sur chaque écran. */
body[data-ai-cta="fab"] .ai-topbar-search { display: none; }
body[data-ai-cta="field"] .ai-fab { display: none; }

/* ⚠️ En premier plan, le bouton flottant **reste** et devient le retour.
 * L'ancienne règle le masquait dès l'ouverture (`display: none !important`) :
 * ouvrir l'IA supprimait le contrôle qui aurait servi à la refermer, et il ne
 * restait qu'un ✕ dans un coin. Un contrôle unique et immobile — on appuie pour
 * montrer, on appuie pour masquer — c'est exactement ce que demande un petit
 * écran, et c'est là qu'il est en premier plan. */
body[data-ai-layout="front"] .ai-fab {
    display: inline-flex;
    z-index: 1030;
}
/* En colonne ou en feuille, le ✕ de l'en-tête suffit et le bouton flottant
 * viendrait se poser sur la zone de saisie. */
body[data-ai-layout="side"] .ai-fab,
body[data-ai-layout="sheet"] .ai-fab { display: none; }

/* `#148` — ces quatre blocs énuméraient trois chromes sur quatre.
 *
 * L'oubli de `top_navbar` n'était pas un choix : c'est le chrome d'origine,
 * et chaque règle écrite pour les trois suivants a été écrite en les nommant.
 * Il en restait donc avec les contraintes de conteneur Bootstrap sur `#kvapp`
 * — d'où le débordement de document mesuré en `#138 §3` — et avec trois
 * décalages verticaux jamais corrigés.
 *
 * ⚠️ **Le but ici est « pas laid », pas « optimisé ».** `top_navbar` n'est plus
 * servi (voir `ChromeLayout`) : ces règles servent le développeur qui bascule
 * dessus par `/dev/ui/chrome/top_navbar`. Son habillage propre — les 46
 * sélecteurs `.chrome-sidebar*` n'ont aucun équivalent — reste à écrire.
 *
 * `--chrome-topbar-h` existe pour que le décalage se dérive au lieu d'être
 * recopié à trois endroits — c'est la même hauteur trois fois, et les trois
 * copies pouvaient diverger sans que rien ne le dise.
 *
 * ⚠️ **56 px est la barre des trois chromes qui en ont une fixe, pas celle de
 * `top_navbar`.** Celle-là est une navbar Bootstrap : elle passe sur deux
 * lignes dès que le menu ne tient pas — mesurée à **121 px** à 1200 px de large
 * sur le shell `all`, le 2026-08-18. Écrire un nombre ici pour ce chrome serait
 * juste sur un shell et une largeur, faux partout ailleurs, et Bootstrap ne
 * publie aucune variable de hauteur de navbar. C'est donc
 * `TopNavbarChrome.publishTopbarHeight` qui écrit la valeur mesurée en ligne
 * sur `:root`, et `App.refreshChrome` qui la retire en démontant le chrome.
 */
:root { --chrome-topbar-h: 56px; }

/* Adjust branch-banner top offset when a chrome topbar is present */
[data-chrome="sidebar"] .branch-banner,
[data-chrome="hybrid"] .branch-banner,
[data-chrome="top_navbar"] .branch-banner {
    top: var(--chrome-topbar-h);
}

/* Prevent the kvapp container from adding its own Bootstrap container constraints.
   All four chromes host `.chrome-page-host`, which owns the content gutter — the
   Bootstrap container underneath it only ever double-constrained the same box. */
[data-chrome="sidebar"] #kvapp,
[data-chrome="hybrid"] #kvapp,
[data-chrome="spotlight"] #kvapp,
[data-chrome="top_navbar"] #kvapp {
    max-width: none !important;
    padding: 0 !important;
    margin: 0 !important;
}

/* Ensure sticky browse toolbar offset is correct when a chrome topbar is present */
[data-chrome="sidebar"] .browse-toolbar-sticky,
[data-chrome="hybrid"] .browse-toolbar-sticky,
[data-chrome="spotlight"] .browse-toolbar-sticky,
[data-chrome="top_navbar"] .browse-toolbar-sticky {
    top: var(--chrome-topbar-h);
}

/* Adjust chat-panel offset when a chrome topbar is present */
[data-chrome="sidebar"] .chat-panel,
[data-chrome="hybrid"] .chat-panel,
[data-chrome="spotlight"] .chat-panel,
[data-chrome="top_navbar"] .chat-panel {
    top: var(--chrome-topbar-h);
}

/* Phase Chess.C — Leaflet map widget target inside the read-only
   entity view. Fixed height so Leaflet measures correctly on first
   paint, rounded corners to match the card body. */
.map-field-canvas {
    height: 280px;
    border-radius: 0 0 var(--bs-card-border-radius, 0.375rem) var(--bs-card-border-radius, 0.375rem);
    z-index: 0;
}

/* ---- Global drag & drop overlay (document → AI analysis) ---- */
.global-drop-overlay {
    position: fixed;
    inset: 0;
    z-index: 2000;
    background: rgba(13, 110, 253, 0.12);
    border: 3px dashed var(--brand-primary, #0d6efd);
    display: flex;
    align-items: center;
    justify-content: center;
    pointer-events: none;
}
.global-drop-overlay-inner {
    background: white;
    border-radius: 12px;
    box-shadow: 0 8px 32px rgba(0,0,0,0.18);
    padding: 32px 48px;
    text-align: center;
    font-size: 1.25rem;
    color: var(--brand-primary, #0d6efd);
}
.global-drop-overlay-inner i {
    font-size: 3rem;
    display: block;
    margin-bottom: 8px;
}

/* ---- M.6 state views (empty / loading / error / forbidden / locked) ----
   Reuses the empty-state typography; adds a tone accent and a CTA row so the
   four non-empty variants read as deliberate states, not as a failed list. */
.state-view-panel {
    text-align: center;
    padding: 3rem 2rem;
    color: #495057;
    border-radius: 0.5rem;
}
.state-view-panel .empty-state-subtitle {
    color: #6c757d;
    margin-bottom: 0;
}
.state-view-detail {
    margin-top: 0.75rem;
    font-family: var(--bs-font-monospace, monospace);
    font-size: 0.85rem;
    color: #6c757d;
    word-break: break-word;
}
.state-view-ctas {
    margin-top: 1.25rem;
    display: flex;
    gap: 0.5rem;
    justify-content: center;
    flex-wrap: wrap;
}
.state-view-danger  .empty-state-icon { color: #dc3545; }
.state-view-warning .empty-state-icon { color: #fd7e14; }
.state-view-info    .empty-state-icon { color: #0d6efd; }
.state-view-success .empty-state-icon { color: #198754; }
.state-view-muted   .empty-state-icon,
.state-view-neutral .empty-state-icon { color: #adb5bd; }

@media (prefers-color-scheme: dark) {
    .state-view-panel { color: #dee2e6; }
    .state-view-panel .empty-state-subtitle,
    .state-view-detail { color: #adb5bd; }
}

/* ── Forage de tableau de bord (DrillDown) ───────────────────────────────────
 * Un widget qui mène quelque part doit *se voir* comme tel : sans ça, la seule
 * façon de découvrir qu'une carte est cliquable est de cliquer dessus au hasard.
 * Appliqué par le renderer uniquement quand la description porte un `drillDown`
 * — une carte sans forage garde exactement son apparence d'avant.
 */
.erp-drillable { cursor: pointer; }
.card.erp-drillable { transition: box-shadow .15s ease, transform .15s ease; }
.card.erp-drillable:hover,
.card.erp-drillable:focus-visible {
    box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12);
    transform: translateY(-1px);
}
/* La zone de clic d'un godet est transparente : le survol la révèle, ce qui
 * indique la granularité du forage (un mois, une catégorie) avant le clic. */
.erp-drillable .erp-hit:hover { fill: rgba(13, 110, 253, .08); }
.erp-drillable tbody tr:hover { background-color: rgba(13, 110, 253, .06); }

/* ---------------------------------------------------------------------------
 * L7 — grille couloirs × axe (docs/plans/lanes-component-design.md)
 *
 * ⚠️ Aucune couleur de **tonalité** ici. Les blocs reçoivent leur fond, leur
 * texte et leur liseré en style en ligne, depuis `ColorPalette.colorsFor` — le
 * point unique que la règle 4 de docs/MultiRenderer.md exige et que `#94`
 * documente. Ce qui est écrit ci-dessous est le châssis : traits, colonne des
 * noms, hachures. Il utilise les variables `--surface-*`, qui suivent déjà le
 * thème, donc la grille suit le thème sans une cinquième table de couleurs.
 * ------------------------------------------------------------------------- */

.lane-grid-host { padding: 0 .5rem .75rem; }

/* Le défilement est **dans** la grille, jamais sur la page : une piste ne peut
 * pas se comprimer sous ~640 px sans devenir illisible, et un poste de travail
 * dont le corps défile horizontalement est cassé (docs/MultiRenderer.md §2). */
.lane-grid-scroll { overflow-x: auto; overflow-y: visible; }

.lane-grid {
    min-width: 640px;
    border: 1px solid var(--surface-border);
    border-radius: var(--corner-md, 10px);
    background: var(--surface-card);
    overflow: hidden;
}

.lane-grid-head,
.lane-grid-lane { display: flex; align-items: stretch; }

.lane-grid-head {
    height: 30px;
    border-bottom: 1px solid var(--surface-border);
    background: var(--surface-card-side, var(--surface-card));
}

.lane-grid-name {
    flex: 0 0 150px;
    width: 150px;
    padding: 4px 8px;
    border-right: 1px solid var(--surface-border);
    font-size: 12px;
    color: var(--surface-fg);
    display: flex;
    flex-direction: column;
    justify-content: center;
    overflow: hidden;
}

.lane-grid-lane-label { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lane-grid-lane-load { font-size: 11px; color: var(--surface-muted); }

.lane-grid-track { position: relative; flex: 1 1 auto; min-width: 0; }

.lane-grid-lane { border-top: 1px solid var(--surface-border); }
.lane-grid-lane:nth-child(even) .lane-grid-track { background: var(--surface-row-hover); }

/* Un couloir au-dessus de sa capacité se signale par un liseré, pas par une
 * couleur de fond : le fond porte déjà l'alternance des lignes, et empiler les
 * deux rend les deux illisibles. */
.lane-grid-lane.over-capacity .lane-grid-name { box-shadow: inset 3px 0 0 var(--tone-warning-bg); }

.lane-grid-tick {
    position: absolute;
    top: 6px;
    transform: translateX(-50%);
    font-size: 11px;
    color: var(--surface-muted);
    white-space: nowrap;
    pointer-events: none;
}
.lane-grid-tick.major { color: var(--surface-fg); font-weight: 600; }

.lane-grid-rule {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 0;
    border-left: 1px solid var(--surface-border);
    opacity: .45;
}
.lane-grid-rule.major { opacity: .9; }

.lane-grid-span {
    position: absolute;
    box-sizing: border-box;
    border-radius: 4px;
    border-left: 3px solid transparent;
    padding: 2px 6px;
    font-size: 11.5px;
    line-height: 1.15;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    cursor: pointer;
    z-index: 2;
}
.lane-grid-span .t { font-weight: 600; display: block; overflow: hidden; text-overflow: ellipsis; }
.lane-grid-span .s { opacity: .75; font-size: 10.5px; }

/* ── M6 — le libellé d'un créneau court sort du bloc ──────────────────
 * Un bloc en `border-box` ne peut pas descendre sous `padding` + `border`,
 * soit 15 px, dont **zéro** pour le texte : à l'échelle Mois, 10 blocs sur 23
 * n'affichaient aucun caractère. La classe est posée par `LaneGridLabels`, qui
 * mesure — la place libre n'est connue qu'en pixels rendus.
 *
 * ⚠️ Le bloc, lui, ne bouge pas d'un pixel. L'élargir pour faire tenir son
 * texte aurait menti sur la durée, ce que cette grille refuse partout ailleurs
 * (voir l'entaille de `clipped-end` juste en dessous).
 *
 * La couleur vient de `--surface-fg` et non du bloc : dehors, le texte est sur
 * le fond du couloir, et l'encre choisie pour contraster avec un fond coloré y
 * serait illisible.
 *
 * `pointer-events: none` : le libellé se pose sur la piste, qui est la **zone
 * de dépôt** du glisser-déposer. Un texte qui capte le pointeur y ouvrirait un
 * trou dans lequel un lâcher se perdrait sans rien dire. */
.lane-grid-span.label-outside { overflow: visible; }
.lane-grid-span.label-outside > .t {
    position: absolute;
    left: calc(100% + 4px);
    top: 50%;
    transform: translateY(-50%);
    max-width: var(--lane-label-space, 0px);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--surface-fg);
    pointer-events: none;
}
/* Le secondaire ne sort pas : dehors, deux lignes empiéteraient sur la
 * sous-ligne voisine. Il reste dans l'infobulle, avec le libellé complet. */
.lane-grid-span.label-outside > .s { display: none; }
.lane-grid-span.movable { cursor: grab; }
.lane-grid-span.dragging { cursor: grabbing; opacity: .6; box-shadow: 0 4px 12px rgba(0,0,0,.25); z-index: 5; }

/* Un chevauchement est une **anomalie** — deux créneaux sur la même ressource
 * au même moment. L'empilement seul le range dans le décor ; le liseré le dit. */
.lane-grid-span.overlapping { box-shadow: 0 0 0 1.5px var(--tone-danger-bg); }

/* ⚠️ Une troncature n'est pas décorative : un bloc coupé au bord de la fenêtre
 * et dessiné droit se lit comme un bloc qui commence là — un chantier de trois
 * jours passerait pour un chantier d'un jour. */
.lane-grid-span.clipped-start { border-top-left-radius: 0; border-bottom-left-radius: 0; border-left-style: dashed; }
.lane-grid-span.clipped-end {
    border-top-right-radius: 0;
    border-bottom-right-radius: 0;
    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 10px), transparent 100%);
    mask-image: linear-gradient(to right, #000 calc(100% - 10px), transparent 100%);
}

/* Un jalon : un losange, et **rien à saisir** — c'est `#64` réglé par la forme
 * plutôt que par un refus serveur. */
.lane-grid-marker {
    position: absolute;
    top: 50%;
    width: 9px;
    height: 9px;
    margin: -4.5px 0 0 -4.5px;
    transform: rotate(45deg);
    z-index: 3;
    cursor: default;
}

/* Un fond : hachuré, sous tout le reste, et sans geste. Un congé qui ressemble
 * à une pose est la lecture qui fait double-réserver. */
.lane-grid-band {
    position: absolute;
    top: 0;
    bottom: 0;
    z-index: 1;
    opacity: .35;
    background-image: repeating-linear-gradient(
        45deg,
        var(--band-ink) 0, var(--band-ink) 3px,
        transparent 3px, transparent 7px
    );
}

.lane-grid-foot {
    padding: 6px 10px;
    font-size: 12px;
    color: var(--surface-muted);
    border-top: 1px solid var(--surface-border);
}
.lane-grid-foot.empty { text-align: center; padding: 24px 10px; }

.lane-grid-notice { margin: 0 0 .5rem; font-size: 13px; }

/* Écran étroit : la colonne des noms rétrécit, la piste garde sa largeur
 * minimale, et c'est la grille qui défile — pas la page. */
@media (max-width: 820px) {
    .lane-grid-name { flex-basis: 96px; width: 96px; }
    .lane-grid-lane-load { display: none; }
    .lane-grid-span { font-size: 11px; padding: 2px 4px; }
}

/* La colonne des noms porte deux lignes (le nom, la charge) dans la hauteur
 * d'un couloir : sans interligne serré, le jambage du « p » de « Équipe » se
 * fait couper — vu à l'écran, pas en relisant. */
.lane-grid-name { line-height: 1.2; }
.lane-grid-lane-label { line-height: 1.25; }

/* Une échéance traverse toute la grille : elle ne concerne pas une ressource,
 * elle concerne la date. Dessinée dans un couloir, elle dirait le contraire. */
.lane-grid-body { position: relative; }
/* ⚠️ Le conteneur commence **après** la colonne des noms, sinon un `left: 40%`
 * serait 40 % du corps entier et pas 40 % de la piste : le trait tomberait
 * systématiquement trop à gauche, d'autant plus que la fenêtre est étroite. */
.lane-grid-deadlines {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 150px;
    right: 0;
    pointer-events: none;
}
.lane-grid-deadline {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 0;
    border-left: 2px dashed var(--deadline-ink);
    z-index: 4;
}
.lane-grid-deadline span {
    position: absolute;
    top: 2px;
    left: 4px;
    font-size: 10px;
    line-height: 1;
    padding: 1px 4px;
    border-radius: 3px;
    white-space: nowrap;
    color: var(--surface-card);
    background: var(--deadline-ink);
}
@media (max-width: 820px) {
    .lane-grid-deadlines { left: 96px; }
}

/* ── `#225` — la marque « sans authentification » ────────────────────────────
 *
 * Bande fixe en haut, et l'application est **raccourcie** plutôt que
 * recouverte : `--app-h` est lue par les quatre `height` de disposition, comme
 * pour la feuille basse de l'IA. Recouvrir aurait rendu le haut de l'écran —
 * marque, barre de chrome, menu de compte — inatteignable.
 *
 * ⛔ **La hauteur est déclarée sur `body[data-auth="off"]`, jamais sur `:root`.**
 * Écrite dans `:root` elle a fait rougir `ThemeBlocksAgreeGuardTest` sur-le-champ,
 * et le garde avait raison deux fois : les jetons de thème vivent dans
 * `themes/<id>.json` depuis `#94`, et le nombre de déclarations résolues sur
 * `:root` (85) est celui qu'attend la sonde navigateur de `docs/Debug.md`. La
 * porter ici la rend **inexistante hors du mode**, ce qui est de toute façon ce
 * qu'elle décrit — l'exception `CSS_ROOT_SURVIVORS` n'avait pas à grandir.
 *
 * ⚠️ Le rail de la barre latérale est `position: sticky; top: 0` : sans la
 * dernière règle il glisse SOUS la bande dès qu'on défile, et la marque se fait
 * manger par le chrome qu'elle est censée qualifier. */
body[data-auth="off"] {
    --auth-off-h: 34px;
    --app-h: calc(100vh - var(--auth-off-h));
    padding-top: var(--auth-off-h);
}

.auth-disabled-marker {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 1900;
    height: var(--auth-off-h, 34px);
    margin: 0;
    border-radius: 0;
    padding: 0 12px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.85rem;
    font-weight: 600;
}

body[data-auth="off"] .chrome-sidebar-rail { top: var(--auth-off-h); }

/* ─────────────────────────────────────────────────────────────────────────────
 * `#229` L3 — le graphe d'un cycle de vie : des boîtes et des liens.
 *
 * ⚠️ Aucune couleur littérale : le ton vient de la description, et la couleur
 * de la variable de thème. Un ton écrit en dur ici serait une décision de
 * description prise dans une feuille de style, invisible aux autres renderers.
 * ─────────────────────────────────────────────────────────────────────────── */
.wf-graph-host { overflow-x: auto; }

.wf-state { fill: var(--bs-body-bg); stroke: var(--bs-border-color); stroke-width: 1.5; }
.wf-state.wf-entry { stroke-width: 3; }
.wf-state.wf-unreachable { stroke-dasharray: 4 3; }

.wf-tone-SUCCESS { stroke: var(--bs-success); }
.wf-tone-WARNING { stroke: var(--bs-warning); }
.wf-tone-DANGER  { stroke: var(--bs-danger); }
.wf-tone-INFO    { stroke: var(--bs-info); }
.wf-tone-MUTED   { stroke: var(--bs-secondary); }
/* NEUTRAL garde la bordure par défaut — pas de règle, donc pas de couleur à tenir. */

.wf-state-label { fill: var(--bs-body-color); font-size: 13px; font-weight: 600; }

.wf-edge { fill: none; stroke: var(--bs-border-color); stroke-width: 1.5; }
.wf-edge-back { stroke-dasharray: 5 3; }
.wf-arrowhead { fill: var(--bs-border-color); }

/* `#580` — **une transition se suit à l'œil.** Sur un cycle de 40 arêtes, tous
 * les traits avaient la même couleur et la même épaisseur : rien n'aidait à
 * suivre un retour de sa source à sa cible.
 *
 * Chaque arête est un `<g class="wf-tr">` qui regroupe son trait, sa pointe et
 * son libellé. Le survol de l'un ou de l'autre les met en avant ensemble et
 * estompe les autres. Un clic ou le clavier (`tabindex`) donne le focus, ce qui
 * **épingle** la transition pendant qu'on fait défiler le dessin.
 *
 * ⭐ Le trait visible fait 1,5 px, trop fin pour une souris. `.wf-edge-hit`
 * repasse sur le même tracé, invisible et épais, et c'est lui qui reçoit le
 * survol.
 *
 * ⚠️ La pointe est un `<marker>`, qui ne lit pas l'état de son trait : il faut
 * un second marqueur (`#wf-arrow-hot`), que la règle substitue au premier. */
.wf-edge-hit { fill: none; stroke: transparent; stroke-width: 12; pointer-events: stroke; }
.wf-arrowhead-hot { fill: var(--brand-primary); }
.wf-tr { cursor: pointer; transition: opacity .12s; }
.wf-tr:focus { outline: none; }
.wf-tr:hover .wf-edge, .wf-tr:focus .wf-edge {
    stroke: var(--brand-primary);
    stroke-width: 3;
    marker-end: url(#wf-arrow-hot);
}
.wf-tr:hover .wf-edge-label, .wf-tr:focus .wf-edge-label { fill: var(--brand-primary); font-weight: 700; }
.wf-graph:has(.wf-tr:hover, .wf-tr:focus) .wf-tr:not(:hover, :focus) { opacity: .2; }
/* `#402` — **le libellé d'une arête ne recouvre plus le trait qu'il étiquette.**
 *
 * ⛔ Il était posé au milieu de la courbe, 5 px au-dessus, sans fond : sur le
 * cycle du devis les mots croisaient la flèche et les deux devenaient illisibles
 * ensemble.
 *
 * ⭐ **Un halo, pas un rectangle de fond.** Poser un `<rect>` derrière le texte
 * demanderait de **mesurer sa largeur**, ce qui exige un canevas hors document —
 * le dépôt en a un (`svgTextWidth`, privé dans `LayoutDashboardView.kt`) et le
 * mutualiser est un lot en soi, comme le dit déjà la KDoc d'`edgeLabel`.
 * `paint-order: stroke` peint un contour épais de la couleur du fond AVANT les
 * glyphes : le trait est interrompu **exactement** autour des lettres, sans
 * qu'on ait à savoir où elles s'arrêtent.
 *
 * ⚠️ `stroke` doit lire un JETON, pas une couleur en dur : ce halo doit valoir
 * le fond du cadre dans les deux thèmes, sinon il dessine une ombre claire sur
 * fond sombre. Et pas de valeur de repli — sur un jeton publié au runtime elle
 * est morte et souvent fausse.
 *
 * ⛔ **`--surface-bg` et NON `--surface-card`.** Premier jet écrit avec `card` ;
 * 📏 mesuré au navigateur en remontant les ancêtres du `<svg>` jusqu'au premier
 * fond opaque : le diagramme est posé sur le **fond de page**, pas sur une
 * carte. Le halo peignait donc `#ffffff` sur `#fbf7ef` — une tache blanche
 * autour de chaque mot, c'est-à-dire le défaut qu'il vient corriger, en plus
 * clair. Un halo se règle sur ce qu'il y a **derrière**, jamais sur le jeton qui
 * porte le mot « fond » dans son nom.
 *
 * ⛔ **Et la couleur du texte ne suivait pas le thème.** Elle lisait
 * `--bs-secondary-color`, une variable Bootstrap que ce dépôt ne re-thème pas :
 * 📏 `rgba(62,63,58,.75)` dans les DEUX thèmes, donc du quasi-noir sur
 * `#161a1f`. Le libellé était illisible en sombre — défaut antérieur à ce lot,
 * corrigé ici parce qu'il est à un jeton près et que `#402` demande qu'on
 * **lise** le diagramme. */
.wf-edge-label {
    fill: var(--surface-muted);
    font-size: 10px;
    paint-order: stroke;
    stroke: var(--surface-bg);
    stroke-width: 3px;
    stroke-linejoin: round;
}

/* `#229` L3 — l'éditeur de cycle : une grille, pas une file.
 *
 * ⚠️ Les rangées étaient des `d-flex` : chaque ligne calculait sa propre largeur
 * de libellé, donc rien ne s'alignait d'une rangée à l'autre et la case à cocher
 * flottait entre le sélecteur et le bouton. Une grille fixe les colonnes une
 * fois, et l'œil suit une verticale au lieu de chercher. */
.wf-edit-row {
    display: grid;
    grid-template-columns: 7rem minmax(8rem, 26rem) 9rem 7rem max-content;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
}

/* ⚠️ **Cette rangée-ci s'aligne par le BAS**, et c'est le seul endroit du lot où
 * `center` est faux. Ses deux champs portent un libellé au-dessus, le bouton
 * non : la grille centre les **cellules**, donc le bouton se centrait sur
 * l'ensemble *libellé + champ* et atterrissait 13 px trop haut, entre les deux.
 * Ce qui doit s'aligner ici est la ligne des **contrôles**, pas celle des blocs.
 * (Relecture de Didier, 2026-09-02 ; bas du bouton 602 contre 615.) */
.wf-edit-add {
    grid-template-columns: 7rem minmax(8rem, 26rem) 9rem 7rem max-content;
    align-items: end;
    margin-bottom: 1rem;
}

.wf-edit-edge {
    display: grid;
    grid-template-columns: 17rem 12rem max-content;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
}

.wf-edit-newedge {
    display: grid;
    grid-template-columns: max-content 10rem max-content 10rem max-content 12rem max-content;
    align-items: center;
    gap: 0.5rem;
    margin-top: 0.75rem;
}

/* Les contrôles KVision arrivent avec la marge basse d'un formulaire ; dans une
 * rangée elle décale la ligne de base d'un contrôle sur deux. */
.wf-edit-row .form-group,
.wf-edit-add .form-group,
.wf-edit-edge .form-group,
.wf-edit-newedge .form-group { margin-bottom: 0; }

/* ⛔ **Une grille étire ses cellules par défaut, et c'est faux pour un bouton.**
 * Mesuré à la première passe : le « Retirer » d'une transition faisait **936 px**
 * de large et sortait du cadre. Les champs, eux, DOIVENT s'étirer — la colonne
 * est leur largeur. On ne désactive donc pas l'étirement de la grille, on le
 * retire aux seuls éléments dont la largeur est leur contenu. */
.wf-edit-row > .btn,
.wf-edit-add > .btn,
.wf-edit-edge > .btn,
.wf-edit-newedge > .btn,
.wf-edit-row > .form-check,
.wf-edit-row > .badge,
.wf-edit-edge > .badge { justify-self: start; }

/* ⛔ **Un `<label>` vide n'est pas invisible : il pousse.** KVision émet un
 * `.form-label` même quand on ne lui donne pas de libellé. Il mesure 0 de haut
 * et porte **8 px de marge basse**, donc le sélecteur descend de 9 px sous
 * l'input voisin et sa cellule fait 58 px au lieu de 38 — le décalage que la
 * relecture de Didier a tracé au feutre, ligne par ligne.
 *
 * ⚠️ Mesuré, pas deviné : `align-items: center` centrait bien les **cellules**
 * les unes par rapport aux autres (toutes à mi-hauteur 397) ; c'est le contrôle
 * qui n'était pas centré **dans** sa cellule. Un garde d'alignement sur les
 * cellules serait resté vert. */
/* La case à cocher restait 3 px trop haut : Bootstrap lui donne un
 * `margin-top` pour l'aligner sur la ligne de base d'un libellé, ce qui n'a pas
 * de sens dans une cellule centrée. */
.wf-edit-row > .form-check {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    margin-bottom: 0;
    padding-left: 0;
}

.wf-edit-row > .form-check .form-check-input { margin-top: 0; margin-left: 0; float: none; }

.wf-edit-row .form-label:empty,
.wf-edit-add .form-label:empty,
.wf-edit-edge .form-label:empty,
.wf-edit-newedge .form-label:empty { display: none; }

/* `#262` — la raison d'un refus connu d'avance, sous le libellé de l'entrée.
   ⚠️ `display: block` : le bouton du menu est en ligne, et sans ça la phrase
   se colle au libellé et se lit comme sa suite. */
.row-action-menu-item[disabled] { opacity: .55; cursor: not-allowed; }
.row-action-menu-item .row-action-menu-why {
    display: block;
    font-size: .8em;
    opacity: .8;
    margin-top: .15rem;
}

/* `#338` lot 5 — le bandeau est monté au haut de la colonne de contenu de chaque
   chrome, entre la barre du haut et le contenu défilant. ⛔ Il ne doit ni
   grandir ni défiler : les règles « tout div du corps défile et prend la place »
   ci-dessus l'excluent, et il garde sa hauteur naturelle. Il n'est plus collant :
   il ne défile pas, c'est le contenu en dessous qui défile. */
.chrome-sidebar-body > .branch-banner,
.chrome-hybrid-body > .branch-banner { flex: 0 0 auto; position: static; }

/* `#338` lot 6 — la copie vue sur la fiche. La mention se lit au-dessus du
   formulaire ; le repère d'un champ est une ombre intérieure à gauche : elle ne
   prend aucune place, donc la grille ne bouge pas (pas de bordure, pas de marge). */
.working-copy-record-mark { color: var(--tone-warning-fg); }
/* ⛔ **Pas une ombre intérieure** : elle se peindrait sous le champ, qui commence
   au bord gauche de la cellule et porte son propre fond. 📏 Mesuré le 2026-09-14
   sur une affaire modifiée : cellule 64 px de haut, champ 36 px à gauche = 264 =
   le bord exact de la cellule ⇒ le repère n'apparaissait qu'au-dessus (20 px) et
   en dessous (8 px), en **deux traits** au lieu d'une barre.
   ⭐ Un pseudo-élément absolu tient la promesse d'origine — ne prendre aucune
   place, donc ne pas décaler la grille — ET se peint au-dessus du champ. */
.working-copy-changed { position: relative; }
.working-copy-changed::before {
    content: '';
    position: absolute;
    /* ⛔ **Dans la gouttière, pas sur le contenu.** 📏 Mesuré le 2026-09-14 : la
       cellule, son libellé et son champ commencent tous à la même abscisse (264),
       donc une barre à `left: 0` se pose SUR le libellé et sur la bordure du
       champ — c'est ce que la relecture du film a montré. La grille laisse 16 px
       entre deux colonnes : la barre se place au milieu de cette gouttière, où
       elle ne recouvre rien et ne pousse rien.
       ⓘ Android fait déjà exactement ça (`drawBehind`, décalé de -10 dp hors des
       bornes) — le commentaire y raconte la même leçon. */
    left: -8px;
    top: 0;
    bottom: 0;
    width: 3px;
    background: var(--tone-warning-ink, var(--tone-warning-bg));
    pointer-events: none;
}

/* `#338` lot 7 — une action refusée d'avance en copie de travail. Un bouton
   `disabled` ne reçoit pas le survol : l'enveloppe porte la raison en `title`, et
   le bouton laisse passer le pointeur jusqu'à elle. */
.action-refused { display: inline-block; cursor: not-allowed; }
.action-refused > button { pointer-events: none; }

/* ── Une fenêtre ne pousse jamais son pied hors de l'écran ──────────────── */
/*
 * ⛔ **Le bouton Enregistrer était sous le bord de l'écran**, et ce n'était pas
 * une fenêtre trop petite : 📏 mesuré le 2026-09-14 sur la fenêtre « Nouvelle
 * Ligne de devis » (37 champs), fenêtre d'affichage à 800 px de haut ⇒
 * *Enregistrer* à **+303 px sous le pli**, `.modal-body` en
 * `overflow-y: visible` et `max-height: none`. La fenêtre grandissait avec son
 * contenu, sans limite et sans défilement propre — donc elle débordait dès que
 * l'écran était plus court que le formulaire.
 *
 * ⭐ **Le défaut est invisible sur un grand écran**, et c'est ce qui le rend
 * cher : à 1134 px de haut le pied tenait (bas à 1119). Une vérification faite
 * sur le poste du développeur ne voit rien. Le contenu (mémo du 12/09 : 19
 * champs, sept `(Aucun)`, un récapitulatif vide) reste un vrai sujet — mais il
 * décide *quand* ça déborde, pas *si*.
 *
 * ⚠️ Posé sur `.modal-content` et pas sur `.modal-dialog` : c'est le contenu
 * qui porte le fond et les bords, donc c'est lui qui doit être borné, sinon la
 * boîte visible dépasse le cadre qu'on vient de limiter.
 *
 * ⚠️ Et les trois règles vont **ensemble** : borner la hauteur sans rendre le
 * corps défilant couperait le formulaire au lieu de le faire défiler —
 * l'utilisateur perdrait les champs du bas au lieu du bouton. `min-height: 0`
 * est ce qui autorise un enfant de flex à rétrécir sous sa hauteur de contenu ;
 * sans lui les deux autres ne font rien.
 *
 * Aucun effet sur une fenêtre courte : `max-height` ne mord que si le contenu
 * dépasse, et `auto` ne peint une barre que s'il y a de quoi défiler.
 */
.modal-content {
    max-height: calc(100vh - 3.5rem);
}
.modal-content > .modal-body {
    overflow-y: auto;
    min-height: 0;
}

/* Palette de la pastille d'espace (`/tenant/users`). Les COULEURS ne sont pas
   ici : elles viennent de `BrandTint` via `brandMarkColour`, posées en ligne par
   la vue. Ce bloc ne porte que la forme et l'état choisi — une seconde palette
   écrite en CSS aurait dérivé de la première en silence. */
.tenant-tint {
    display: inline-block;
    width: 28px;
    height: 28px;
    border-radius: var(--radius-sm);
    cursor: pointer;
    border: 2px solid transparent;
    /* Le jeton du thème, pas une ombre écrite ici : une littérale serait la même
       dans les cinq thèmes, et c'est en sombre que ça se verrait. */
    box-shadow: var(--surface-shadow);
}
.tenant-tint-picked {
    border-color: var(--surface-fg);
    outline: 2px solid var(--surface-card);
    outline-offset: -4px;
}

/* `#371` — « Ce qui tourne » : les versions du front et du backend.
 * ⚠️ Le verdict est en haut et porte la couleur ; les SHA sont des détails.
 * Un commit de douze caractères ne se compare pas à l'œil — c'est la phrase
 * qui répond, pas la grille. */
.build-identity-grid { display: flex; flex-direction: column; gap: 0.75rem; max-width: 42rem; }
.build-identity-row {
    display: grid;
    grid-template-columns: 10rem 1fr;
    gap: 0.25rem 1rem;
    align-items: baseline;
    padding-bottom: 0.75rem;
    border-bottom: 1px solid var(--surface-divider);
}
.build-identity-row:last-child { border-bottom: none; }
.build-identity-label { font-weight: 600; }
.build-identity-commit { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.build-identity-meta { grid-column: 2; font-size: 0.85rem; }
@media (max-width: 640px) {
    .build-identity-row { grid-template-columns: 1fr; }
    .build-identity-meta { grid-column: 1; }
}

/* `#542` — le retour d'une action réussie (`SuccessNotice.kt`), posé sur `body`
   pour survivre au repeint de l'écran qui suit. `#550` : le même emplacement
   porte un refus, qui laisse la liste en place. */
.action-notice {
  max-width: min(32rem, calc(100vw - 2rem));
  position: fixed;
  /* `#616` — en haut au centre, juste sous la barre de navigation. */
  top: 4rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1080;
  margin: 0;
  box-shadow: var(--bs-box-shadow);
  /* #619 — un compte rendu d'action tient sur plusieurs lignes. */
  white-space: pre-line;
}

/* `#592` — the boot indicator of index.html: shown until the app draws its first
   text. Plain CSS on purpose: it must paint before any script has run. */
#erp-boot {
    position: fixed;
    inset: 0;
    display: flex;
    flex-direction: column;
    gap: 1rem;
    align-items: center;
    justify-content: center;
    background: var(--bs-body-bg);
    color: var(--bs-secondary-color);
    z-index: 2000;
    /* Never in the way: an app drawn without text underneath stays clickable. */
    pointer-events: none;
}
.erp-boot-spinner {
    width: 3rem;
    height: 3rem;
    border: 0.3rem solid var(--bs-border-color);
    border-top-color: var(--bs-primary);
    border-radius: 50%;
    animation: erp-boot-spin 0.8s linear infinite;
}
.erp-boot-name { font-size: 1.25rem; font-weight: 600; letter-spacing: 0.02em; }
@keyframes erp-boot-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .erp-boot-spinner { animation-duration: 2.4s; } }

/* `#690` — un champ date grisé ne propose pas son calendrier : le bouton
   invitait à modifier ce qui ne se modifie pas (« déclaré le » d'un ordre de
   virement). Une règle pour les trois constructions de `DateTime` ; le champ
   retrouve ses coins arrondis sans le bouton accolé. */
.input-group.date:has(> input:disabled) > [data-td-toggle] { display: none; }
.input-group.date:has(> input:disabled) > input { border-radius: var(--bs-border-radius); }

/* `#690` — `ButtonLook.DISMISS` : renoncer, sans contour, seul en tête du pied
   de la fenêtre. Le mot reste « Annuler » ; le poids et la place viennent du
   cœur, ceci n'est que l'idiome du web. */
.look-dismiss { color: var(--bs-secondary-color); text-decoration: none; }
.look-dismiss:hover { color: var(--bs-body-color); text-decoration: underline; }
.modal-footer .look-dismiss { margin-right: auto; }
