/* ==========================================================================
   TABLE COMPONENTS
   ========================================================================== */

.table-container {
  background: var(--surface);
  border-radius: var(--radius);
  overflow-x: auto;
  /* `none`, et pas `contain`, parce que le geste à deux doigts sur un tableau
     large causait DEUX ennuis d'un coup :
       - arrivé au bout du défilement, il enchaînait sur la navigation
         ARRIÈRE/AVANT du navigateur — on quittait la page en croyant faire
         défiler une colonne (`contain` suffit pour ça) ;
       - et il déclenchait le rebond élastique, qui découvre une bande blanche
         aux deux extrémités du tableau (seul `none` le supprime).
     Même choix que `dropdowns.css`. Décision Olivier, 2026-08-04. */
  overscroll-behavior-x: none;
  border: 1px solid var(--border);
  box-shadow: var(--shadow);
  position: relative;
}

.table {
  width: 100%;
  min-width: 600px;
  border-collapse: collapse;
  font-size: 0.875rem;
}

/* ── `table-layout: fixed` — opt-in via `'table_class' => 'table--fixed'`.
   En disposition AUTO (le défaut), la clé `width` d'une colonne n'est qu'une
   suggestion : le navigateur élargit la colonne à la largeur de son contenu.
   Mesuré sur airfact /invoices : « Client » demandé à 210 px en occupait 479,
   « Description » 383 — la table faisait 2 087 px dans un cadre de 1 126, et
   les quatre colonnes de montants restaient hors champ.
   En disposition FIXE les `width` sont respectées à la lettre, et les colonnes
   sans largeur se partagent le reste à parts égales. À n'utiliser QUE sur une
   table dont les cellules de texte portent `.cell-truncate` : sans elle, un
   contenu trop long est coupé sans ellipse. ── */
.table--fixed {
  table-layout: fixed;
}
/* En disposition fixe, la troncature se règle sur la CELLULE, pas sur les
   460 px par défaut de `.cell-truncate` — sinon la colonne est bien à sa
   largeur mais son contenu déborde. */
.table--fixed .cell-truncate {
  max-width: 100%;
}
/* Compagnon OBLIGATOIRE de `table-layout: fixed` : sans lui, tout contenu plus
   large que sa colonne sort de la cellule et se superpose à la voisine — les
   libellés d'affaire chevauchaient les références, les dates les montants.
   La cellule coupe donc, avec ellipse (`.table td` est déjà en `nowrap`). Les
   `<span>` internes (`.cell-main`, `.cell-sub`) sont bornés au passage : sans
   ça la seconde ligne d'une cellule à deux niveaux déborde toute seule. */
.table--fixed td,
.table--fixed th {
  overflow: hidden;
  text-overflow: ellipsis;
}
/* …SAUF les cellules de CONTRÔLE, qui ne portent pas de texte à tronquer.
   Une ellipse y est un caractère parasite, et sur une colonne étroite elle
   est même ROGNÉE : la cellule de 34 px de la case de masse imprimait un
   point noir à droite de chaque case, sur chaque ligne ET dans l'en-tête —
   vu au rendu du 2026-09-03, invisible partout ailleurs parce que build
   /stock, seul autre appelant de `'bulk'`, n'est pas en disposition fixe.
   Ce que ces cellules doivent faire d'un débordement, c'est le couper net. */
.table--fixed td.bulk-cell,
.table--fixed th.bulk-cell,
.table--fixed td.drag-cell,
.table--fixed th.drag-cell,
.table--fixed td.actions-cell,
.table--fixed th.actions-cell {
  text-overflow: clip;
}
/* TOUT enfant direct, quelle que soit la balise choisie par l'appelant. La
   version d'origine ne visait que span/a/.cell-main/.cell-sub : une cellule
   construite en `<div>` (colonne Client de /quotes) n'ellipsait pas du tout.
   Le comportement ne doit pas dépendre de la balise retenue au rendu.
   Exclusions : `.entity-actions` porte un menu positionné (cf. plus haut), et
   `.cell-wrap` demande explicitement le retour à la ligne — lui imposer
   `nowrap` d'ici l'écraserait, cette règle étant plus spécifique.

   Et les cellules de CONTRÔLE sont exclues À LA SOURCE, pas par une règle qui
   viendrait lutter derrière : leur enfant est une case à cocher, une poignée ou
   un bouton, jamais du texte. C'est CETTE règle qui laissait le point noir sur
   les LIGNES après que l'exemption au niveau de la cellule (plus haut) eut
   réparé l'EN-TÊTE — elle ne vise que `td > *`, donc les `<th>` y échappaient
   déjà, et la moitié réparée masquait l'autre. Vu au rendu du 2026-09-03, puis
   sur l'écran d'Olivier.
   ⚠ Ne pas « corriger » ça par une règle ajoutée après : `:not()` porte la
   spécificité de son argument, donc ce sélecteur pèse (0,3,2) et un
   `.table--fixed td.bulk-cell > *` (0,2,2) PERDRAIT en silence.

   ⚠ Et le `max-width: 100 %` ci-dessous n'est PAS décoratif : le seul plafond
   juste est celui de la colonne. Un plafond en PIXELS écrit par-dessus (un
   sélecteur d'id le bat sans effort : (1,1,0) > (0,3,2)) ne supprime pas
   l'ellipse — il la PEINT HORS DE LA CELLULE : l'enfant tronque à SA largeur,
   plus large que sa colonne, et le `<td>` (`overflow: hidden`) recoupe le
   texte NET, sans ellipse. Un « … » plus large que sa colonne est invisible,
   pas absent. Mesuré le 2026-09-04 sur airfact /jobs (span à 180 px dans des
   colonnes de 150/140/120). */
.table--fixed td:not(.bulk-cell):not(.drag-cell):not(.actions-cell) > *:not(.entity-actions):not(.cell-wrap) {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.table th {
  text-align: left;
  padding: var(--space-3);
  font-weight: 600;
  background: var(--gray-50);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.4px;
  color: var(--text-secondary);
  white-space: nowrap;
  border-bottom: 1px solid var(--border);
}

/* Right/center alignment inside a .table — must beat `.table th { text-align:left }`
   (0,1,1). Without this, `text-right`/`dash-text-right` (0,1,0) lose on headers, so
   right-aligned content ended up under a left-aligned header. Covers both the
   table utility (`text-right`) and the dashboard helper class (`dash-text-right`),
   on th AND td, so header alignment always follows the content. */
.table th.text-right,  .table td.text-right,
.table th.dash-text-right, .table td.dash-text-right { text-align: right; }
.table th.text-center, .table td.text-center,
.table th.dash-text-center, .table td.dash-text-center { text-align: center; }

/* Une colonne alignée à droite est une colonne de nombres — montants, quantités,
   compteurs. Les chiffres à chasse fixe alignent les unités, dizaines, centaines
   sur une même verticale ; sans ça, une colonne de montants « danse » d'une ligne
   à l'autre alors qu'elle est censée se lire en diagonale. Posé ici, sur la
   colonne, plutôt qu'une classe `tabular-nums` à répéter dans chaque cellule. */
.table td.text-right,
.table th.text-right,
.table td.dash-text-right,
.table th.dash-text-right { font-variant-numeric: tabular-nums; }

/* Sortable column headers — click to sort. The caret reflects the active
   column + direction; inactive columns show a dim neutral caret on hover. */
/* `.sortable` porte soit le `<th>` lui-même, soit un élément À L'INTÉRIEUR du
   th — le cas d'un en-tête qui héberge en plus un filtre multiselect : seul le
   libellé y déclenche le tri, cliquer l'entonnoir ouvre le panneau. Les deux
   formes ont le même dessin. */
.table th.sortable,
.table th .sortable { cursor: pointer; user-select: none; }
.table th.sortable:hover,
.table th .sortable:hover { color: var(--text); }
.table th.sortable .sort-caret,
.table th .sortable .sort-caret {
  display: inline-block;
  margin-left: 0.3em;
  font-size: 0.85em;
  opacity: 0;
  transition: opacity 0.12s ease;
}
.table th.sortable:hover .sort-caret,
.table th .sortable:hover .sort-caret { opacity: 0.4; }
.table th.sortable.is-sorted,
.table th .sortable.is-sorted { color: var(--text); }
.table th.sortable.is-sorted .sort-caret,
.table th .sortable.is-sorted .sort-caret { opacity: 1; }
/* Keep the caret next to the label on right-aligned numeric headers */
.table th.sortable.text-right .sort-caret { margin-left: 0.3em; }

table td {
  padding: var(--space-3);
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
  font-size: 0.875rem;
  white-space: nowrap;
}

/* ══ LA CELLULE TRONQUE PAR DÉFAUT (inversion du défaut, 2026-08-05) ═══════
   `.table td` était déjà en `white-space: nowrap` : il ne lui manquait que de
   quoi COUPER proprement. Sans ça, un texte trop long débordait sur la colonne
   voisine, ou était coupé net, sans ellipse — et il fallait y penser cellule
   par cellule (`.cell-truncate` + `title`). Une règle qui demande de penser à
   deux choses par cellule ne tient pas : 42 occurrences, dont 3 sans `title`,
   et 33 autres cellules qui posaient un `max-width` en ligne à la place.

   Désormais : tronquer est le NORMAL, revenir à la ligne se DEMANDE
   (`.cell-wrap`, plus bas). L'infobulle, elle, est posée automatiquement par
   core.js — et seulement si le texte est réellement coupé, donc elle ne ment
   jamais.

   ⚠ L'EXCLUSION EST UN GARDE-FOU, et voici ce qui a été RÉELLEMENT mesuré.
   Un menu `entity_actions` est ancré `position: absolute; top: 100%` : il
   descend sous sa cellule — 86 px sur /catalog/products (50 menus), 169 px sur
   /catalog/overview (423 menus). On s'attendait donc à ce qu'un
   `overflow: hidden` sur le `<td>` le décapite.

   Il ne le décapite PAS. Vérifié en forçant `overflow: hidden !important` sur
   tous les `<td>` des deux pages, puis en sondant la PEINTURE
   (`elementFromPoint` à 4, 20, 50 et 90 px sous la cellule, pas les rectangles
   — `getBoundingClientRect` rend la boîte entière même rognée) : le menu
   répond à toutes les profondeurs. Raison : `.entity-actions` est
   `position: relative`, c'est donc LUI le bloc conteneur du menu, et
   l'`overflow` d'une boîte `display: table-cell` ne rogne pas un descendant
   absolu dont le bloc conteneur est à l'intérieur d'elle. `/quotes` le
   confirme en production : `table--fixed` y pose `overflow: hidden` sur les
   cellules depuis toujours, et ses menus s'affichent en entier.

   L'exclusion est conservée quand même, pour deux sous : le comportement ci-
   dessus est subtil et dépend du moteur, `entity_actions` vit encore dans 80
   fichiers, et une cellule d'actions n'a de toute façon RIEN à tronquer — un
   bouton de 28 px dans une colonne de 40. Coût nul, filet réel.

   La spécificité monte à **(0,6,1)** du fait des `:not()` : chacun compte pour
   son argument le plus spécifique, et `:has(.entity-actions)` en fait partie.
   (Ce commentaire annonçait (0,4,1) — il datait de l'époque où la règle n'avait
   que trois `:not()`, et il a menti pendant tout ce temps. Un chiffre faux dans
   un commentaire de cascade est pire que pas de chiffre : il se cite.)

   C'est voulu, mais PAS sans conflit, et ça s'est payé le 2026-09-03 : une
   exemption écrite plus haut
   pour les cellules de contrôle (`.table--fixed td.bulk-cell`, (0,2,1)) perdait
   ici en silence, et sur la seule moitié `td` — l'en-tête paraissait réparé
   pendant que CHAQUE LIGNE gardait son point. Toute cellule qui ne doit pas
   tronquer doit donc être exclue ICI, à la source, jamais par une règle ajoutée
   derrière.
   `.bulk-cell` (case de masse) et `.drag-cell` (poignée) rejoignent donc les
   cellules d'actions : leur contenu est un contrôle, pas du texte. Dans les
   34 px de la case de masse, l'ellipse était même ROGNÉE — il n'en restait
   qu'un point noir, à droite de chaque case, sur chaque ligne. ── */
/* ⚠ CE QUE CETTE SPÉCIFICITÉ COÛTE AUX APPS (CSS-09, mesuré le 2026-09-06) :
   **116 déclarations** de feuilles d'app visant une cellule de tableau ne sont
   JAMAIS appliquées — elles sont écrites à (0,1,1) ou (0,2,1) contre le (0,6,1)
   ci-dessous (build 80, plan 20, airfact 9, xorizo 3, bal 2, comptoir 2), et
   **14** autres ne l'emportent qu'en posant `!important`. Aucune erreur, aucun
   avertissement : la règle se lit, elle a l'air juste, elle ne peint rien.
   `php app-core/cli/probe-css-cascade-tables.php --verbose` les nomme une par
   une, avec l'adversaire qui gagne — et la sonde refuse que le compte remonte.

   ❓ DÉCISION EN ATTENTE (Olivier). Envelopper chaque exclusion dans `:where()`
   — `td:where(:not(.actions-cell)):where(…)` — ramène la spécificité à (0,1,1)
   SANS changer ce que la règle matche : `:where()` vaut zéro par construction.
   Les exclusions resteraient donc exactement aussi efficaces, et les 116
   déclarations d'app se mettraient à peindre. C'est la correction de FOND ;
   c'est aussi un changement d'apparence sur beaucoup d'écrans d'un coup, qui
   demande d'être regardé. Elle n'est pas appliquée ici. ── */
.table td:not(.actions-cell):not(.v2-actions-cell):not(.bulk-cell):not(.drag-cell):not(:has(.entity-actions)) {
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ── Truncating cell content — wrap a long free-text value (descriptions,
   references, …) in `<span class="cell-truncate">` so it ellipsises instead
   of pushing a `white-space: nowrap` table past its container. Sibling
   content in the same cell (badges, counts) stays visible after it. Pair
   L'infobulle est AUTOMATIQUE depuis le 2026-08-05 (core.js) : ne posez un
   `title` à la main que s'il doit dire autre chose que le texte visible.

   ⚠ Sa largeur n'est pas neutre et c'est ce qui a fait remettre des
   `max-width` en ligne : **460 px en disposition AUTO**, **100 % en
   `.table--fixed`** (règle ci-dessus). En auto, une colonne plus étroite que
   460 px ne tronque donc PAS par `.cell-truncate` — c'est la cellule qui s'en
   charge maintenant. Depuis l'inversion du défaut, `.cell-truncate` ne sert
   plus qu'à borner UN élément dans une cellule qui en contient plusieurs
   (un libellé long suivi d'un badge, par exemple). ── */
.cell-truncate {
  display: inline-block;
  max-width: 460px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  vertical-align: middle;
}

/* ── Ligne de SECTION dans une table (lot du marché, chapitre d'un devis…).
   Une ligne pleine largeur qui titre le groupe de lignes en dessous : sans elle,
   des postes comme « 2.1 - Modification porte » ou « Travaux de préparation »
   flottent sans indiquer de quel lot ils viennent. ── */
.table .table-section-row td {
  background: var(--gray-50);
  padding: var(--space-2) var(--space-3);
  font-weight: 700;
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--primary);
  white-space: normal;
  overflow-wrap: anywhere;
}

/* ── Cellule qui REVIENT À LA LIGNE — l'autre réponse au texte long. À utiliser
   quand le texte doit rester lisible EN ENTIER (libellés de marché, descriptions
   de lignes dans une modale de choix) : tronquer y ferait choisir à l'aveugle, et
   `nowrap` pousserait la table en scroll horizontal. `.cell-truncate` reste le
   défaut pour les listes denses, où l'infobulle suffit. ── */
td .cell-wrap,
th .cell-wrap,
td.cell-wrap {
  white-space: normal;
  overflow-wrap: anywhere;
  word-break: break-word;
}
@media (max-width: 1100px) { .cell-truncate { max-width: 300px; } }
@media (max-width: 768px)  { .cell-truncate { max-width: 160px; } }

.table tr:last-child td {
  border-bottom: none;
}

/* ── LA LIGNE PORTE SA COULEUR ─────────────────────────────────────────────
   UN SEUL endroit décide la couleur d'une ligne, et c'est le `<tr>` : au repos
   comme au survol, depuis `--row-bg`. Une cellule collante ne décide RIEN,
   elle COPIE sa ligne (`background: inherit`, plus bas). C'est la propriété
   qui compte : `inherit` n'est pas une couleur, c'est un LIEN — et un lien ne
   peut pas diverger de sa source. L'ancien modèle faisait CALCULER la couleur
   des deux côtés, à partir de deux jetons lus sur deux éléments différents ;
   deux calculs peuvent différer, et ils différaient.

   TEINTER UNE LIGNE = UNE LIGNE DE CSS :
       .ma-ligne { --row-bg: var(--gray-50); }
   Le survol suit tout seul (il retombe sur `--row-bg`, donc la ligne GARDE sa
   teinte). `--row-bg-hover` ne sert plus qu'à vouloir un survol DIFFÉRENT.

   Ce piège — ligne teintée, colonne d'actions restée blanche — avait été
   découvert, corrigé localement et ré-expliqué CINQ fois dans trois apps avant
   qu'on le supprime (2026-09-13). Gardé par
   `cli/probe-ligne-teintee-colonne-collante.mjs`. Les deux jetons sont des
   EXEMPTIONS déclarées de `cli/probe-css-tokens.php` : fantômes VOLONTAIRES,
   posés par le `<tr>` appelant. */
.table tbody tr {
  position: relative;
  background: var(--row-bg, var(--surface));
}
.table tbody tr:hover {
  background: var(--row-bg-hover, var(--row-bg, var(--gray-50)));
}

.table-totals td {
  border-top: 2px solid var(--border);
  background: var(--gray-50);
  font-weight: 600;
}

.table-preview {
  font-size: 0.75rem;
}

.table-preview th,
.table-preview td {
  padding: var(--space-2);
}

.table td .text-muted {
  font-size: 0.75rem;
  color: var(--text-secondary);
}

/* Line Items Table */
.line-items-table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: var(--space-4);
}

.line-items-table th {
  background: var(--gray-50);
  padding: var(--space-3);
  text-align: left;
  font-weight: 600;
  font-size: 0.875rem;
  color: var(--text-secondary);
  border-bottom: 2px solid var(--border);
  white-space: nowrap;
}

.line-items-table td {
  padding: var(--space-2);
  border-bottom: 1px solid var(--gray-100);
  vertical-align: top;
}

.line-items-table tbody tr:hover {
  background: var(--gray-50);
}

.line-items-table .line-item-input {
  width: 100%;
  padding: var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font-size: 0.875rem;
  background: var(--surface);
}

.line-items-table .line-item-input:focus {
  border-color: var(--primary);
  outline: none;
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 10%, transparent);
}

.line-items-table .line-item-input:invalid {
  border-color: var(--error);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--error) 10%, transparent);
}

.line-items-table .line-item-description {
  min-width: 200px;
}

.line-items-table .line-item-quantity,
.line-items-table .line-item-price,
.line-items-table .line-item-vat,
.line-items-table .line-item-total {
  width: 100px;
}

.line-items-table .line-item-actions {
  width: 60px;
  text-align: center;
}

.line-item-total-display {
  font-family: inherit;
  font-weight: 500;
  color: var(--text);
  text-align: right;
  padding: var(--space-2) var(--space-3);
}

/* Table Images */
.table-image {
  width: 48px;
  height: 48px;
  object-fit: cover;
  border-radius: 0.375rem;
  border: 1px solid var(--border);
}

.table-image-placeholder {
  width: 48px;
  height: 48px;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: var(--gray-100);
  border-radius: 0.375rem;
  color: var(--gray-400);
}

/* Sticky actions column — only when last th is empty and last td contains entity-actions.
   `:not([colspan])` : une cellule qui fusionne des colonnes N'EST PAS la colonne
   d'actions. Sans cette garde, une ligne de regroupement rendue en un seul
   `<td colspan="N">` portant un menu ⋮ (en-tête de section du marché Airfact,
   lignes de groupe en général) matche la règle et devient collante, large de
   58 px et sur fond BLANC — elle écrase alors le fond de sa propre ligne et,
   z-index 1 à la clé, passe devant tout élément positionné qui la chevauche.
   Les contraintes 58px min/max de cette règle disent assez qu'elle ne visait
   qu'une vraie colonne d'actions. */
/* `:not(.actions-cell)` : depuis que la colonne d'actions CANONIQUE est en
   dernière position (2026-08-02), son `<th class="actions-cell">` est lui aussi
   `:last-child:empty` — il tombait donc dans cette règle héritée. La règle
   `.actions-cell` (40 px) perdait contre celle-ci, plus spécifique.

   GÉOMÉTRIE ALIGNÉE SUR `.actions-cell` (2026-08-02). Le dialecte hérité
   posait 58 px de large et héritait des 12 px de padding de `.table td` — le
   bouton s'y retrouvait avec 15 px d'air de chaque côté contre 6 px sur une
   liste convertie, à une page d'écart (/quotes vs /clients). Le bouton, lui,
   était déjà identique (28 x 28) : c'est la CELLULE qui divergeait. Les 58 px
   dataient d'un bouton de 34 x 34 avec sa bordure permanente ; ils n'ont plus
   d'objet. On règle ça ici plutôt qu'en ajoutant `.actions-cell` sur chaque
   `<td>` hérité : une seule édition, aucune page à toucher. */
.table th:last-child:empty:not(.actions-cell),
.table td:last-child:has(.entity-actions):not([colspan]) {
  position: sticky;
  right: 0;
  z-index: 1;
  width: 40px;
  min-width: 40px;
  max-width: 40px;
  padding: 4px 6px;
  text-align: center;
  /* AUCUNE couleur ici : ce bloc ne dit que la GÉOMÉTRIE. La couleur vient de
     la règle `background: inherit` plus bas pour les `<td>`, et de `.table th`
     (l. 109, `--gray-50`) pour les `<th>`. C'est ce partage qui obligeait
     naguère à re-corriger l'en-tête juste en dessous, et à garder deux
     sélecteurs de spécificité ÉGALE pour que l'ordre d'écriture tranche. */
}
.table th:last-child:empty:not(.actions-cell) {
  z-index: 2;
}
.table td:last-child:has(.entity-dropdown.show):not([colspan]) {
  z-index: 1002; /* ordre local : au-dessus de la colonne collante (1000) et du thead flottant */
}

/* ── Colonne d'actions CANONIQUE en dernière position ──────────────────────
   `'actions' => 'last'` est le défaut depuis le 2026-08-02. La règle collante
   ci-dessus ne connaît que le dialecte hérité (`th:last-child:empty` +
   `td:last-child:has(.entity-actions)`) : `window.rowActions` rend, lui, un
   `<td class="actions-cell">` dont le menu est détaché dans `<body>`, donc
   SANS `.entity-actions` — il n'était pas collant, et son `<th>` l'était.
   Ces règles-ci ferment l'écart, et ne posent PAS de largeur : les 40 px de
   `.actions-cell` s'appliquent (l'ancienne en forçait 58, d'où la
   contradiction que documentait le jumeau PHP).

   `:not([colspan])` : une ligne de regroupement rendue en un seul
   `<td colspan="N">` n'est pas une colonne d'actions — sans cette garde elle
   devient collante et opaque, et écrase le fond de sa propre ligne. */
.table td.actions-cell:last-child:not([colspan]),
.table th.actions-cell:last-child {
  position: sticky;
  right: 0;
  z-index: 1;
}
.table th.actions-cell:last-child {
  z-index: 2;
}

/* ── LA CELLULE COLLANTE COPIE SA LIGNE ────────────────────────────────────
   La règle qui remplace à elle seule les quatre `var(--row-bg…)` d'avant, dans
   les DEUX dialectes et dans TOUS les états — repos, survol, sélection, et
   tout état futur, sans rien y ajouter.

   Elle doit peindre : la cellule est `position: sticky`, le contenu de la
   ligne défile DESSOUS. `inherit` prend le fond du `<tr>`, que `.table tbody
   tr` garantit toujours opaque (`var(--row-bg, var(--surface))`). Une valeur
   en dur ici — blanc ou gris — trahirait toute ligne teintée : c'était
   exactement le défaut. */
.table td.actions-cell:last-child:not([colspan]),
.table td:last-child:has(.entity-actions):not([colspan]) {
  background: inherit;
}

/* Smart table positioning for last columns */
.table td:nth-last-child(1) .entity-dropdown,
.table td:nth-last-child(2) .entity-dropdown {
  right: 0;
  left: auto;
}

/* Mobile Responsive */
@media (max-width: 768px) {
  .table-container { 
    margin: 0 calc(-1 * var(--space-4)); 
  }
  
  /* Line Items Table Mobile */
  .line-items-table {
    display: block;
    overflow-x: auto;
    white-space: nowrap;
  }
  
  .line-items-table thead,
  .line-items-table tbody,
  .line-items-table th,
  .line-items-table td,
  .line-items-table tr {
    display: block;
  }
  
  .line-items-table thead tr {
    position: absolute;
    top: -9999px;
    left: -9999px;
  }
  
  .line-items-table tr {
    border: 1px solid var(--border);
    border-radius: var(--radius);
    margin-bottom: var(--space-4);
    padding: var(--space-4);
    background: var(--surface);
  }
  
  .line-items-table td {
    border: none;
    position: relative;
    padding-left: 50% !important;
    padding-top: var(--space-2);
    padding-bottom: var(--space-2);
  }
  
  .line-items-table td:before {
    content: attr(data-label) ": ";
    position: absolute;
    left: var(--space-3);
    width: 45%;
    padding-right: var(--space-2);
    white-space: nowrap;
    font-weight: 600;
    color: var(--text-secondary);
    font-size: 0.75rem;
  }
  
  /* Line item input mobile sizing */
  .line-items-table .line-item-input {
    font-size: 16px; /* Prevent zoom on iOS */
  }
}

/* ==========================================================================
   BLOC LISTE — barre d'outils + tableau + pagination dans UN seul cadre
   ==========================================================================
   Règle plateforme : **jamais de `.card` autour d'une `.table`**. Le cadre,
   c'est `.table-container` (bordure, rayon, ombre). Ce qui appartient à la
   liste — filtres libres, bascule de vue, pagination — se pose donc DEDANS,
   pas dans une carte qui doublerait le cadre :

     <div class="table-container">
       <div class="table-toolbar">…recherche, bascule table/kanban…</div>
       <table class="table">…</table>
       <div class="table-pagination">…</div>
     </div>

   `.table-container` défile horizontalement (`overflow-x: auto`). Une barre
   d'outils posée dedans partirait donc à gauche dès qu'on fait défiler un
   tableau plus large que son cadre. `position: sticky; left: 0` la retient :
   elle reste lisible pendant que le tableau glisse dessous.
   ========================================================================== */
.table-container > .table-pagination {
  position: sticky;
  left: 0;
}

.table-toolbar {
  display: flex;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  /* Gouttière HORIZONTALE = celle des cellules (`--space-3`, 12 px), pas
     `--space-4` : la barre et le tableau partagent le même cadre, le champ de
     recherche doit donc démarrer sur la même verticale que le premier en-tête
     de colonne. Mesuré au DOM : conteneur à x=282, sa bordure de 1 px porte le
     contenu à 283, où commence la table. Sans gouttière horizontale, le champ
     de recherche démarre donc lui aussi à 283 — à l'aplomb du bord du tableau.
     Avec 16 px il flottait 13 px plus à droite, sans raison lisible.
     Pas de `border-bottom` non plus : le trait collait la barre au tableau.

     Et pas de fond : la barre FLOTTE au-dessus du cadre, sur le fond de page,
     séparée par 16 px — c'est la forme de /quotes, prise comme référence
     (mesurée au DOM : fond transparent, padding 0, marge basse 16 px).
     Le fond gris + le trait faisaient une bande collée au tableau.
     Décision Olivier, 2026-08-04. */
  padding: var(--space-2) 0;
  margin-bottom: var(--space-4);
  min-height: 48px;
  flex-wrap: wrap;
}
/* Il n'y a plus de `.table-container > .table-toolbar` : depuis le 2026-08-06,
   `table_list()` rend la barre AU-DESSUS du cadre dans les DEUX dialectes.
   La règle qui lui donnait un fond gris et une gouttière quand elle était
   dedans est donc morte, et retirée avec.

   Motif — les deux dialectes rendaient deux placements : `blocks` au-dessus
   (/quotes, /invoices), `rows` dedans (/clients, /suppliers), 29 pages contre
   37 sur les huit apps. Retirer le fond gris ne suffisait pas : une barre sans
   fond DANS un cadre bordé fait une zone vide encadrée au-dessus des en-têtes.
   Ce n'est pas la couleur qui gênait, c'est la PLACE. Décision Olivier,
   2026-08-06 ; détail dans UIComponents::tableList(). */
/* Une barre vide est une bande grise sans raison d'être. */
.table-toolbar:empty { display: none; }

/* ── LA RANGÉE DE FILTRES (mode `bar`, 2026-09-14) ─────────────────────────
   `table_list()` la rend entre la barre d'outils et le cadre ; elle est vide
   et `hidden` tant que le mode vaut `header`. Ce qu'elle contient, ce sont les
   MÊMES `.ms-wrap` que les en-têtes de colonne — variante `.ms-trigger--chip`,
   qui garde son libellé quand une valeur est cochée (le déclencheur par défaut
   l'échange contre « N sélectionné », et une puce sans nom dans une rangée de
   puces ne dit plus rien).

   Elle suit le rythme de la barre d'outils : même gouttière horizontale que
   les cellules (`--space-3`), pour démarrer à l'aplomb du premier en-tête, et
   `--space-4` sous elle comme la barre au-dessus.

   Pas de fond ni de trait : comme la barre d'outils, elle FLOTTE au-dessus du
   cadre. Un aplat gris ici ferait une seconde bande collée au tableau — le
   défaut réglé le 2026-08-04 sur `.table-toolbar`. */
/* Le LOGEMENT des puces — ce que `window.tableFilters` réécrit, et lui seul.
   Ce qui vit à côté (`filters_bar_extra`, la puce de tri de /jobs) survit à
   chaque rendu des filtres. Vide, il ne laisse pas de gouttière derrière lui. */
.table-filters-bar-items {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
}
.table-filters-bar-items:empty { display: none; }

.table-filters-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  /* ── AUCUNE GOUTTIÈRE HORIZONTALE, ET C'EST MESURÉ ─────────────────────────
     La barre d'OUTILS en porte une de 12 px pour que son champ de recherche
     démarre à l'aplomb du TEXTE du premier en-tête de colonne (un champ n'a pas
     de bord visible : c'est son texte qui fait la ligne).

     Une rangée de PUCES ne se lit pas comme ça : chaque puce a un bord, et
     l'œil aligne ce bord sur celui du cadre en dessous. Avec 12 px, mesuré au
     navigateur le 2026-09-14 : cadre à 282, puces à 294, texte des en-têtes à
     295 — les puces n'étaient alignées NI sur le cadre NI sur les titres, et
     l'écart de 12 px se voyait. À 0, le bord de la première puce tombe sur le
     bord du cadre.

     ⚠ Ne pas « corriger » en 12 px pour aligner le TEXTE des puces : il
     démarrerait alors à 303 (bord + bordure + gouttière interne du bouton),
     soit 8 px après le titre de colonne. Deux lignes fausses au lieu d'une. */
  padding: 0;
  /* AUCUNE marge haute : la barre d'outils porte déjà la sienne en bas, et
     c'est elle qui précède. La rangée ne pose que ce qui la sépare du cadre. */
  margin: 0 0 var(--space-4);
}
/* …et une barre dont AUCUN enfant n'est visible est tout aussi vide à l'œil,
   sans être `:empty` au sens du sélecteur (2026-09-13). Le cas est arrivé en
   sortant la recherche des listes : sur /invoices il ne restait que deux
   boutons d'action de masse, `hidden` tant que rien n'est coché — 48 px de
   barre + 16 px de marge qui ne montraient RIEN.

   On n'inspecte que les enfants DIRECTS de la barre et ceux du groupe de
   droite : viser tous les descendants ferait qu'un `<svg>` à l'intérieur d'un
   bouton caché compte comme « visible » (il n'a pas `.hidden`, lui).

   ⚠ CONSÉQUENCE ASSUMÉE : quand le premier enfant apparaît, la barre revient
   et le tableau descend de 64 px. C'est acceptable ICI parce que le seul
   déclencheur restant est la SÉLECTION DE LIGNES — l'utilisateur vient de
   cocher, une barre d'actions qui apparaît est le motif attendu (Gmail,
   GitHub, Linear). Ce ne le serait PAS pour « Supprimer les filtres », dont
   l'apparition est incidente : c'est pourquoi ce bouton part avec la
   recherche dans le champ de la barre du haut (`clear_id`) au lieu de rester
   ici. Si un jour un élément non lié à une sélection revient dans une barre
   par ailleurs vide, ce sont ses 64 px de saut qu'il faudra justifier. */
.table-toolbar:not(:has(> :not(.hidden):not(.table-toolbar-right))):not(:has(> .table-toolbar-right > :not(.hidden))) {
  display: none;
}
/* Pousse le groupe suivant à droite (bouton « Supprimer les filtres »…). */
.table-toolbar > .table-toolbar-right { margin-left: auto; }
/* Le groupe de droite est TOUJOURS un conteneur — `table_list()` /
   `table_toolbar()` l'enveloppent (PHP comme JS), et les vues qui l'écrivent à
   la main posent un `<div>`. Il doit donc se remettre en ligne, sinon ses
   enfants s'empilent. Le sélecteur reste borné à `div` : la variante « classe
   posée directement sur un bouton » a existé (`/projects`,
   `/configurator/dossiers`) et a disparu le 2026-08-09 quand ces deux pages
   sont passées au helper — plus aucune occurrence sur les huit apps. */
.table-toolbar > div.table-toolbar-right {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
/* Case à cocher d'option dans la barre (« Afficher les archivées »…) —
   remplace le `<label class="form-label" style="display:flex;…">` que chaque
   page réinventait.
   Sélecteur DÉ-SCOPÉ le 2026-09-07 : la même case se pose désormais dans le
   slot de droite d'une bande d'onglets (`.nav-tabs-right`, navigation.css).
   Scopée à `.table-toolbar`, elle y retombait sur le `<label>` nu — texte de
   formulaire, empilé sous sa case. Une seule définition, partout. */
.toolbar-check {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin: 0;
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--text-secondary);
  cursor: pointer;
  white-space: nowrap;
}

.table-pagination {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  padding: var(--space-3) var(--space-4);
  background: var(--gray-50);
  border-top: 1px solid var(--border);
  font-size: 0.75rem;
  color: var(--text-secondary);
}
/* rustine `[hidden]` RETIRÉE le 2026-09-06 : `base.css` porte la règle globale (CSS-06). */
.table-pagination-summary,
.table-pagination-page { font-variant-numeric: tabular-nums; }
.table-pagination-controls {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-left: auto;
}
/* Le sélecteur « N / page » se dimensionne sur son contenu — sinon un
   `.form-input` occupe 100 % et pousse la pagination hors du cadre. Réglé ici
   plutôt qu'en `style="width:auto"` sur chaque rendu. */
.table-pagination .form-input,
.table-pagination select { width: auto; }

/* Boutons de navigation (première/précédente/suivante/dernière) : carrés,
   28 px — le chevron SVG est centré par flex, pas par le padding du .btn-sm
   (qui donnerait des rectangles inégaux entre chevron simple et double). */
.table-pagination-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
}
.table-pagination-btn[disabled] { opacity: 0.4; }

/* « 3 / 12 » : respiration autour de l'indicateur, chiffres tabulaires
   (hérités), pas de retour à la ligne au milieu de l'indication. */
.table-pagination-page {
  padding: 0 var(--space-2);
  white-space: nowrap;
}

/* Sélecteur « N/page » compact : même hauteur que les boutons de navigation,
   texte 12 px — un .form-input plein format (40 px) écrasait la barre. */
.table-pagination-size {
  height: 28px;
  padding: 0 var(--space-2);
  font-size: 0.75rem;
}

/* ── Champ de recherche d'une liste ────────────────────────────────────────
   À ne pas confondre avec `.search-wrapper` / `.search-input` (top-nav.css),
   qui sont dimensionnés pour la barre de navigation (padding 1.5rem) : les
   emprunter dans une liste donnait un champ deux fois trop haut. Celui-ci fait
   32 px, rayon 6 — la mesure du reste des contrôles de liste. */
.filter-search {
  display: flex;
  align-items: center;
  /* Gabarit repris a l'identique du champ de recherche de /quotes, mesure au
     DOM le 2026-08-04 : 400 x 50, padding 14 px, rayon 8 px, texte 14 px.
     Decision Olivier — c'est CE champ qui fait reference, pas les 32 px
     retenus le 2026-08-01. Les pages deja migrees s'y alignent, et les 7
     pages qui restent le gardent tel quel en migrant. */
  width: 400px;
  max-width: 100%;
  height: 50px;
  padding: 0 14px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  transition: border-color 0.15s, box-shadow 0.15s;
}
.filter-search:focus-within {
  border-color: var(--primary);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 8%, transparent);
}
.filter-search-icon {
  display: flex;
  align-items: center;
  flex-shrink: 0;
  margin-right: 6px;
  color: var(--gray-400);
}
.filter-search-input {
  flex: 1;
  min-width: 0;
  padding: 0;
  border: none;
  outline: none;
  background: transparent;
  font-size: 0.875rem;
  font-family: inherit;
  color: var(--text-primary);
}
/* Variante « champ date » — même boîte, à la largeur d'une date.
   `.filter-search` fait 260 px : c'est la mesure d'un champ de RECHERCHE, pas
   d'un `jj/mm/aaaa` suivi de son icône de calendrier. Sans cette variante,
   chaque page qui pose une date dans sa barre d'outils réinvente sa largeur —
   ou pire, colle `.filter-search-input` sur un input nu, qui s'étire alors sur
   toute la barre en perdant son cadre (`flex:1; border:none`). */
.filter-search--date {
  width: auto;
  min-width: 0;
}
.filter-search--date .filter-search-input {
  flex: 0 0 auto;
  width: auto;
}
.filter-search-input::placeholder { color: var(--gray-400); }

/* ── Ligne cliquable ───────────────────────────────────────────────────────
   Une ligne de liste mène à sa fiche. Deux chemins, volontairement :
   - un vrai `<a class="cell-link">` sur la cellule d'identité (code, référence)
     — ctrl-clic, molette, « ouvrir dans un nouvel onglet », lecteur d'écran ;
   - un clic délégué sur `tr[data-row-href]` pour la surface confortable.
   Le comportement du second est écrit UNE fois, dans core.js (`RowLink`) : il
   ignore tout élément interactif et toute sélection de texte en cours. */
.table tbody tr[data-row-href] { cursor: pointer; }
.cell-link {
  color: inherit;
  text-decoration: none;
}
.cell-link:hover {
  color: var(--primary);
  text-decoration: underline;
}
.cell-link code { color: var(--primary); font-weight: 600; }
/* Ligne secondaire d'une cellule (sous-libellé client, code interne, heure).
   `block` : c'est une LIGNE sous la principale. Sans display explicite elle
   dépendait du contexte — en disposition fixe, où les spans sont bornés, les
   deux lignes se retrouvaient côte à côte. La variante `--inline` existe
   justement pour le cas où on la veut sur la même ligne. */
.cell-sub {
  display: block;
  margin-top: 2px;
  font-size: 0.75rem;
  color: var(--text-secondary);
  white-space: nowrap;
}
/* Sous-ligne qui reste une information de premier plan (le titre d'une entité
   parente sous son code) — même échelle, encre normale. */
.cell-sub--strong { color: var(--text-primary); }
/* Sous-ligne posée SUR la ligne principale (un horodatage après un libellé)
   plutôt qu'en dessous. */
.cell-sub--inline { display: inline; margin-top: 0; font-weight: 400; }
.cell-main { display: block; font-weight: 600; }

/* ==========================================================================
   COLONNE D'ACTIONS — kebab en PREMIÈRE colonne, menu détaché
   ==========================================================================
   Canon depuis 2026-08-01. Remplace `.entity-actions`, dont le menu est
   `position: absolute` DANS un `.table-container{overflow-x:auto}` : dès que le
   tableau défile, le menu est rogné par le cadre. Ici le bouton reste dans la
   cellule et le menu est monté dans `<body>` en `position: fixed` (voir
   `.kebab-menu`, dropdowns.css) — plus rien ne le rogne.

   Ces règles étaient injectées en JS par le configurateur
   (`v2-core.js`, `<style id="v2-core-styles">`) : invisibles à tout audit CSS,
   impossibles à surcharger proprement. Elles vivent ici, en CSS.
   Les noms `.v2-*` sont conservés comme alias : 35 fichiers du configurateur
   les émettent, les renommer serait une refonte de module, pas une passe de
   style. Le nom canonique pour tout nouveau code est `.actions-cell` /
   `.kebab-btn`. */
/* ── Cellule ÉDITABLE (`.cell-edit`) — 2026-09-10 ────────────────────────────
   Une colonne qui porte une valeur prise dans un catalogue (responsable,
   activité, famille) et qu'on change SANS ouvrir la fiche. Le déclencheur est
   un `<button>` : le garde-fou `row-open` des listes laisse déjà passer
   `button`, donc rien à écrire côté module.

   Il ne se signale qu'au SURVOL DE LA LIGNE — une liste où huit cellules par
   ligne crient « clique-moi » ne se lit plus. Le chevron, lui, reste discret et
   permanent : c'est la seule chose qui dit que la valeur est modifiable.

   Voir /styleguide §Tableaux → « contextMenu({search}) — la cellule ÉDITABLE ». */
.cell-edit {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  max-width: 100%;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: none;
  padding: 0.125rem 0.25rem;
  margin: -0.125rem -0.25rem;   /* le cadre ne pousse pas la cellule */
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

.cell-edit > .cell-edit-val {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.cell-edit > svg { flex-shrink: 0; opacity: 0.45; }

.table tbody tr:hover .cell-edit { border-color: var(--border); }

.cell-edit:hover,
.cell-edit:focus-visible {
  border-color: var(--primary);
  background: var(--surface);
}

.cell-edit.is-empty > .cell-edit-val { color: var(--text-secondary); }

/* Écriture en vol : la valeur est déjà peinte (optimiste), on signale seulement
   que le serveur n'a pas encore confirmé. */
.cell-edit.is-saving { opacity: 0.55; pointer-events: none; }

/* Une valeur POSÉE se rend en `badge()`. `.badge` est `inline-flex` : l'ellipse
   n'y opère pas sur un simple nœud de texte, et un nom long se ferait couper
   NET au bord de la colonne. En `inline-block`, il s'ellipse pour de bon.
   Portée : le badge D'UNE CELLULE ÉDITABLE, jamais `.badge` en général. */
.cell-edit .badge {
  display: inline-block;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  vertical-align: middle;
}

/* Le badge porte déjà son fond : le déclencheur ne doit pas en rajouter. */
.cell-edit:not(.is-empty) { padding: 0 0.25rem 0 0.125rem; }

td.actions-cell, th.actions-cell,
td.v2-actions-cell, th.v2-actions-cell {
  /* ⚠ `min-width`/`max-width` sont INDÉFINIS sur une cellule de tableau
     (CSS 2.1 §17.5.2) : ils ne pinglent rien ici, ils sont posés par symétrie
     avec le sélecteur hérité et par défense en disposition AUTO. Ce n'est PAS
     eux qui tiennent la colonne.

     Ce qui la tient, c'est que la table déclare des largeurs COMPLÈTES. En
     `table-layout: fixed`, le reliquat non attribué va tout entier aux colonnes
     sans pourcentage — donc à celle-ci. Mesuré le 2026-08-30 sur
     /configurator/dossiers : 9 colonnes à 93 %, les 7 % restants (135 px) sont
     tombés en bloc dans la colonne d'actions, qui a `text-align: center` — le
     kebab flottait au milieu au lieu d'être collé à droite.

     RÈGLE : sur une table `table--fixed`, les pourcentages des colonnes de
     contenu doivent totaliser 100 %, et la colonne d'actions déclare ses 40 px.
     Il ne reste alors rien à absorber. */
  width: 40px;
  min-width: 40px;
  max-width: 40px;
  text-align: center;
  padding: 4px 6px;
}

.kebab-btn,
.v2-kebab-btn {
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  /* Gabarit CARRE 28 x 28 (tranche 2026-08-02). Un bouton d'actions est une
     cible unique : il se dessine carre, pas en pastille allongee. 28 et pas
     34 : `.actions-cell` fait 40 px de large avec 6 px de padding lateral, donc
     28 + 12 = 40 — le carre tient EXACTEMENT dans la colonne existante, sans
     l'elargir ni grandir les lignes. Il reste 4 px de marge autour de l'icone
     de 20.
     La boite est posee en DUR (min-width/min-height + centrage) au lieu d'etre
     le resultat d'un padding autour du contenu : c'est ce qui faisait diverger
     les implementations (le configurateur rendait un glyphe texte de 18px de
     hauteur de ligne, les autres un SVG de 16 — deux contenus, deux boites).
     Desormais changer la taille de l'icone ne deforme plus le bouton. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 28px;
  min-height: 28px;
  padding: 0;
  /* Ne sert plus qu'au repli `&#x22EE;` quand le helper d'icones est absent. */
  font-size: 18px;
  line-height: 1;
  color: var(--text-muted);
  cursor: pointer;
  transition: background var(--transition), border-color var(--transition), color var(--transition);
}
.kebab-btn:hover,
.v2-kebab-btn:hover {
  background: var(--gray-100);
  border-color: var(--gray-300);
  color: var(--text);
}
.kebab-btn[aria-expanded="true"],
.v2-kebab-btn[aria-expanded="true"] {
  background: var(--gray-200);
  border-color: var(--gray-300);
  color: var(--text);
}
.kebab-btn:focus-visible,
.v2-kebab-btn:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 1px;
}

/* ── Colonne de POIGNÉE dédiée (canon depuis le 2026-08-08) ────────────────
   La poignée et le menu sont DÉ-FUSIONNÉS. `table_list(['reorderable'=>true])`
   rend cette colonne en tête ; `window.tableReorder` y injecte le `<td>` et son
   icône. Le menu d'actions, lui, reste en DERNIÈRE colonne comme sur toute
   liste de la plateforme.

   Ce qui a sa place à gauche, c'est la POIGNÉE, pas le MENU. La fusion des deux
   (héritée de `A3 §1.0bis`) mettait le kebab à gauche des seules listes
   réordonnables : à l'écran, ces pages se lisaient comme non traitées. */
.table th.drag-cell,
.table td.drag-cell {
  width: 28px;
  min-width: 28px;
  max-width: 28px;
  padding: 4px 2px;
  text-align: center;
  /* La poignée n'est pas du contenu : elle ne participe pas à la lecture de la
     ligne et ne doit jamais élargir la colonne. */
  white-space: nowrap;
}
.table td.drag-cell {
  cursor: grab;
  user-select: none;
  color: var(--gray-300);
}
.table td.drag-cell:active { cursor: grabbing; }
.table tbody tr:hover td.drag-cell { color: var(--gray-500); }
.table td.drag-cell .drag-grip { display: block; margin: 0 auto; fill: currentColor; }

/* ── Poignée HÉRITÉE, fusionnée avec le kebab ──────────────────────────────
   Forme antérieure au 2026-08-08, encore vivante sur les tableaux
   réordonnables écrits à la main (les grilles de saisie du configurateur) :
   faute de colonne `.drag-cell` déclarée dans leur `<thead>`, `tableReorder`
   ne leur en injecte pas et la cellule d'actions reste la poignée.

   ⚠ CE CURSEUR N'APPARTIENT QU'À CETTE FORME (corrigé le 2026-08-30). Il était
   posé sur `.table.is-reorderable … td.actions-cell` et sur toute ligne
   `.is-draggable` — donc AUSSI sur les 41 listes de page qui ont depuis une
   colonne de poignée dédiée. Résultat à l'écran : leur bouton kebab affichait
   une main de préhension alors que la poignée est ailleurs, c'est-à-dire qu'un
   BOUTON mentait sur son affordance (constat Olivier 2026-08-30).

   La feuille ne pouvait pas trancher seule : `.is-reorderable` est posée par
   `table_list()` (qui REND la colonne) comme par `table_start()` (qui ne la
   rend pas) — elle ne distingue donc pas les deux formes. C'est le moteur qui
   sait, puisqu'il vient de lire le `<thead>` : il pose `.reorder-by-actions`
   sur le `<tbody>` quand, et seulement quand, la cellule d'actions sert de
   poignée. Une classe posée par qui SAIT vaut mieux qu'un sélecteur qui devine.

   `.v2-actions-cell` reste couvert : les grilles JS du modeleur et de products
   la rendent encore (mesuré le 2026-08-30). `.v2-kebab-btn`, en revanche, n'est
   RENDU NULLE PART — la seule mention vivante est le sélecteur défensif de
   core.js — donc ses quatre combinaisons sortent d'ici. */
.table tbody.reorder-by-actions tr.is-draggable td.actions-cell,
.table tbody.reorder-by-actions tr.is-draggable td.v2-actions-cell,
.table tbody.reorder-by-actions tr.v2-draggable td.actions-cell,
.table tbody.reorder-by-actions tr.v2-draggable td.v2-actions-cell,
.table tbody.reorder-by-actions tr.is-draggable td.actions-cell .kebab-btn,
.table tbody.reorder-by-actions tr.is-draggable td.v2-actions-cell .kebab-btn,
.table tbody.reorder-by-actions tr.v2-draggable td.actions-cell .kebab-btn,
.table tbody.reorder-by-actions tr.v2-draggable td.v2-actions-cell .kebab-btn {
  cursor: grab;
  user-select: none;
}
.table tbody.reorder-by-actions tr.is-draggable td.actions-cell:active,
.table tbody.reorder-by-actions tr.is-draggable td.v2-actions-cell:active,
.table tbody.reorder-by-actions tr.v2-draggable td.actions-cell:active,
.table tbody.reorder-by-actions tr.v2-draggable td.v2-actions-cell:active,
.table tbody.reorder-by-actions tr.is-draggable td.actions-cell .kebab-btn:active,
.table tbody.reorder-by-actions tr.is-draggable td.v2-actions-cell .kebab-btn:active,
.table tbody.reorder-by-actions tr.v2-draggable td.actions-cell .kebab-btn:active,
.table tbody.reorder-by-actions tr.v2-draggable td.v2-actions-cell .kebab-btn:active { cursor: grabbing; }

.table tbody tr.is-dragging,
.table tbody tr.v2-dragging { opacity: 0.4; }

/* ── Case à cocher de masse, en tête de ligne ──────────────────────────────
   Avant la colonne d'actions : c'est une sélection de LIGNE, elle précède ce
   qu'on décide d'en faire. Remplace le `<th style="width:2rem">` que chaque
   liste réécrivait. */
th.bulk-cell, td.bulk-cell {
  width: 34px;
  text-align: center;
  padding-left: var(--space-2);
  padding-right: var(--space-2);
}

/* ── LA CASE D'EN-TÊTE NE DOIT PAS SURÉLEVER LA LIGNE (2026-09-14) ──────────
   Une `<input>` est un élément EN LIGNE : elle s'assied sur la ligne de base et
   réserve dessous la place des jambages. Dans un `<th>` qui ne contient qu'elle,
   ces quelques pixels deviennent la hauteur de la rangée entière.

   Mesuré sur `/clients` : l'en-tête de l'onglet **Factures** — le seul des
   quatre à porter une sélection de masse — faisait **46 px** contre **42** sur
   Devis, Annuaire et Articles. Quatre pixels, et un tableau « un peu plus bas »
   que ses voisins sans qu'aucune règle ne le dise (constat Olivier).

   `display: block` sort la case du flux en ligne et supprime la réserve ;
   `margin: 0 auto` la recentre, `text-align` n'agissant plus sur un bloc. */
th.bulk-cell input[type="checkbox"] {
  display: block;
  margin: 0 auto;
}

/* La case de LIGNE se révèle, elle ne s'impose pas (2026-09-03, retour Olivier
   sur /invoices). Une colonne de cases affichée en permanence alourdit une
   liste dense, alors qu'on ne sélectionne qu'occasionnellement.

   Ce qui la révèle : le survol de sa ligne, le focus clavier, son propre
   cochage, et — via `tbody.has-selection`, posé par `window.tableSelection` —
   l'existence d'une sélection en cours. Ce dernier point est le plus
   important : dès qu'une ligne est cochée, TOUTES les cases apparaissent, sinon
   on ne verrait plus l'étendue de sa propre sélection en déplaçant la souris.

   `opacity` et non `visibility`/`display` : la colonne garde sa largeur (aucun
   décalage au survol), la case reste cliquable là où l'œil l'attend, et elle
   demeure atteignable au clavier comme aux lecteurs d'écran. La case
   d'EN-TÊTE, elle, reste toujours visible : c'est elle qui annonce que la
   liste est sélectionnable. */
td.bulk-cell input[type="checkbox"] {
  opacity: 0;
  transition: opacity 120ms ease;
}
.table tbody tr:hover td.bulk-cell input[type="checkbox"],
td.bulk-cell input[type="checkbox"]:checked,
td.bulk-cell input[type="checkbox"]:focus-visible,
tbody.has-selection td.bulk-cell input[type="checkbox"] {
  opacity: 1;
}

/* Pas d'animation pour qui n'en veut pas. */
@media (prefers-reduced-motion: reduce) {
  td.bulk-cell input[type="checkbox"] { transition: none; }
}

/* ── Colonne au plus juste ─────────────────────────────────────────────────
   `width: 1px` sur une cellule de table = « aussi étroite que ton contenu »
   (l'algorithme de table élargit ensuite jusqu'au contenu). C'était écrit
   `style="width:1px"` dans chaque vue qui hébergeait un filtre date dans son
   `<th>` ; c'est une classe. */
.table th.col-shrink, .table td.col-shrink { width: 1px; }

/* ── En-tête « titre + filtre » ────────────────────────────────────────────
   Le `<th>` qui fait cohabiter un titre (triable ou non) et le déclencheur
   d'un filtre multiselect. Sans classe partagée, la gouttière et le `nowrap`
   étaient réécrits sous un nom de module différent à chaque fois
   (`.dossiers-th-state`, `.sg-th-filter`, …) — c'était le manque n°6 du
   styleguide. */
.table th.th-filter {
  white-space: nowrap;
}
/* L'ENTONNOIR ne doit jamais être rogné : en disposition fixe la cellule coupe
   son trop-plein, et un libellé long (« Émission / Échéance ») l'emportait avec
   lui — la colonne n'était plus filtrable. On borne donc le LIBELLÉ, qui
   s'ellipse, et le déclencheur garde sa place.
   Surtout pas `display: flex` sur le `<th>` : ça le sort de l'algorithme de
   tableau et disloque la ligne d'en-tête (essayé, constaté). */
/* L'enveloppe porte le flex, JAMAIS le `<th>` — cf. l'avertissement ci-dessus.
   Le libellé prend ce qui reste (`min-width: 0` sans quoi un flex-item refuse
   de passer sous sa largeur de contenu et l'ellipse ne se déclenche jamais),
   le déclencheur garde sa taille réelle.

   Remplace `max-width: calc(100% - 30px)` sur le libellé (2026-08-08). Ce
   budget en pixels ne pouvait pas être juste : le déclencheur mesure 23 px au
   repos et 42 px dès qu'un filtre est actif — il affiche alors son compteur.
   30 px rognaient « ÉTAT » de 3 px sur une colonne de 81 px, et auraient laissé
   déborder de 12 px cette même colonne une fois filtrée. Un budget fixe ne peut
   pas suivre un contenu variable ; c'est la mise en page qui doit s'en charger. */
.table th.th-filter > .th-filter-inner {
  display: flex;
  align-items: center;
  gap: 4px;
  min-width: 0;
  /* Réserve la hauteur du déclencheur AVANT que le JS ne l'injecte : le
     logement vide est masqué, la ligne d'en-tête se calait sur le texte puis
     GRANDISSAIT à l'arrivée des filtres (~+10 px, visible au chargement).
     26px = hauteur de `.ms-trigger` (multiselect.css) ; les variantes --chip
     et --icon (22px) se centrent dans la même réserve. min-height est posé
     ICI parce qu'il serait indéfini sur le `<th>` (cellule de tableau). */
  min-height: 26px;
}
.table th.th-filter > .th-filter-inner > :first-child {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.table th.th-filter > .th-filter-inner > .th-filter-slot {
  flex: none;
  display: inline-flex;
}
/* Une colonne alignée à droite ou centrée pousse son groupe du même côté. */
.table th.th-filter.text-right  > .th-filter-inner { justify-content: flex-end; }
.table th.th-filter.text-center > .th-filter-inner { justify-content: center; }
/* Un logement vide ne doit pas creuser une gouttière : tant que le JS n'a pas
   injecté son entonnoir, le titre reste collé au bord comme les autres. */
.table th.th-filter > .th-filter-inner > .th-filter-slot:empty { display: none; }

/* Le PANNEAU du filtre est un enfant du `<th>` : il hérite donc de
   `.table th { text-transform: uppercase; letter-spacing: .4px; font-size:
   .72rem; font-weight: 600 }`. Sans cette remise à zéro, une liste de valeurs
   à cocher s'affiche EN CAPITALES et espacée — « Cortizo » devient
   « C O R T I Z O ». Défaut trouvé à la conversion de `/stock` (2026-08-01) ;
   il touchait déjà tout `<th>` hébergeant un `renderMsDropdown`, il était
   simplement passé inaperçu sur des libellés de statut d'un seul mot déjà
   capitalisés. Le déclencheur (`.ms-trigger`), lui, est en casse NORMALE,
   posée explicitement dans multiselect.css — c'était auparavant un accident
   de navigateur (un <button> n'hérite pas de text-transform sous Chrome,
   si sous Firefox). */
.table th .ms-panel {
  text-transform: none;
  letter-spacing: normal;
  font-size: 0.8125rem;
  font-weight: 400;
  color: var(--text);
}

/* ── Ligne de regroupement : libellé + valeur à droite ─────────────────────
   La forme (fond, casse, couleur) vit plus haut dans `.table-section-row`.
   Ces deux-là ne servent qu'à opposer le titre du groupe et son total. */
.table .table-section-row td { display: table-cell; }
.table .table-section-label { font-weight: inherit; }
.table .table-section-right { float: right; font-variant-numeric: tabular-nums; }

/* Une cellule ALIGNÉE d'une ligne de regroupement porte un nombre, pas un
   titre : elle échappe à la casse et à l'interlettrage du titre de groupe.
   Sans ça, le total d'un groupe s'écrit « 51.0 H » — l'unité passe en
   capitales — et un libellé de montant se retrouve espacé. Même raisonnement
   que `text-right ⇒ tabular-nums` plus haut : une colonne alignée est une
   colonne de nombres. (Défaut trouvé à la conversion de l'onglet Heures d'une
   affaire, 2026-08-01.) */
.table .table-section-row td.text-right,
.table .table-section-row td.text-center {
  text-transform: none;
  letter-spacing: normal;
}

/* Cross-entity navigation links in table cells (e.g. "Affaire" on
   quotes/invoices pointing to a job, "Devis" on build projects pointing
   to airfact). One source of truth for all cross-app / cross-module jump
   links — simple primary-coloured link, underline on hover. */
.job-link,
.dash-airfact-link {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  color: var(--primary);
  text-decoration: none;
  white-space: nowrap;
}
.job-link:hover,
.dash-airfact-link:hover { text-decoration: underline; }

/* Cross-app jump pill — small uppercase chip with the destination app name
   and an external-link glyph. Used wherever a row in one app has a
   companion row in a sibling app and the jump should be discoverable
   without dominating the column.
   Used on both sides (Airfact rows pointing to Build, Build rows pointing
   to Airfact) so the pattern is recognisable across the platform. */
.cross-app-pill {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 1px 7px;
  font-size: 0.7rem;
  font-weight: 600;
  line-height: 1.4;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--primary);
  background: rgba(30, 64, 175, 0.08);
  border: 1px solid rgba(30, 64, 175, 0.2);
  border-radius: 999px;
  text-decoration: none;
  white-space: nowrap;
  flex-shrink: 0;
  vertical-align: middle;
}
.cross-app-pill:hover {
  background: rgba(30, 64, 175, 0.14);
  text-decoration: none;
}

/* ═══════════════════════════════════════════════════════════════════════════
   LES BADGES DE FILTRE — ce qu'une rangée de filtres RETIENT, valeur par valeur
   ═══════════════════════════════════════════════════════════════════════════
   Venus d'airfact `/dashboard` (2026-09-13), montés dans le socle le 2026-09-14
   quand `table_list()` a gagné sa rangée : `window.tableFilters` les rend pour
   toute liste qui déclare `'filters_badges' => true`, et le tableau de bord
   continue de les rendre lui-même — MÊME dessin, une seule écriture.

   ⚠ Le préfixe `jv-` est conservé tel quel : le dashboard et ses sondes le
   nomment déjà, et renommer une classe pour la faire « sonner socle » aurait
   coûté une migration sans rien réparer.

   ⚠ AUCUN `justify-content` ici : le dashboard aligne ses puces à DROITE, une
   liste les aligne à GAUCHE. L'alignement appartient à l'hôte (`#synthBadges`
   côté airfact, `.table-filters-bar` ici).
   ═══════════════════════════════════════════════════════════════════════════ */
/* ── LES BADGES DE FILTRE (14-09-2026) ───────────────────────────────────────
   Client, activité, affaire et responsable se choisissent à PLUSIEURS : une
   puce ne peut plus nommer ce qu'elle retient dès le deuxième choix, elle
   compte. Les badges réécrivent le détail, un par valeur, chacun avec sa croix.

   Ils prennent une RANGÉE À EUX dans `.jv-bande-tete` (`flex-basis: 100%`), sous
   les puces et alignés sur le même bord qu'elles : c'est la même zone, celle du
   périmètre. Rien à afficher ⇒ `hidden`, et la rangée disparaît avec son
   `row-gap` — un bloc qui n'a rien à dire ne paie pas de place.

   La géométrie est CELLE DE `.ms-trigger--chip` (22 px, rayon plein, 0,72 rem) :
   la puce et le badge qu'elle produit appartiennent à la même famille, ils ne
   peuvent pas se dessiner autrement. Ce qui les distingue est la teinte : la
   puce est un CONTRÔLE (bordure `--primary` quand elle est engagée), le badge
   est un ÉTAT — encre neutre sur fond de surface, la croix seule s'allume au
   survol. La couleur appartient à ce qui décide, pas à ce qui constate. */
.jv-filtres { flex: 0 0 100%; display: flex; flex-wrap: wrap; align-items: center;
              gap: 6px; }
.jv-filtres[hidden] { display: none; }
.jv-filtre { display: inline-flex; align-items: center; gap: 5px; height: 22px;
             max-width: 16rem; padding: 0 3px 0 9px; border: 1px solid var(--border);
             border-radius: 999px; background: var(--surface);
             font-size: 0.72rem; line-height: 1; }
/* L'intitulé dit DE QUEL filtre vient la valeur — « Feralu » seul, à côté de
   « Menuiserie », ne dit pas lequel des cinq on retire. */
.jv-filtre-quoi { color: var(--text-muted); flex: none; }
.jv-filtre-quoi::after { content: " :"; }
/* ⚠ C'est la VALEUR qui tronque, jamais l'intitulé : « CHANTIER RÉSIDENCE LES
   TILLEULS — LOT 4 » déborderait la carte, et un badge est un rappel, pas une
   ligne de tableau. `min-width: 0` parce qu'un enfant de flex refuse de
   rétrécir sous son contenu sans lui. */
.jv-filtre-val { color: var(--text-primary); font-weight: 600; min-width: 0;
                 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.jv-filtre-x { display: inline-flex; align-items: center; justify-content: center;
               flex: none; width: 16px; height: 16px; padding: 0; border: 0;
               border-radius: 50%; background: transparent; color: var(--text-muted);
               cursor: pointer; transition: background-color .12s, color .12s; }
.jv-filtre-x:hover { background: var(--color-surface-2); color: var(--error); }
.jv-filtre-x svg { display: block; }
/* « Tout effacer » n'apparaît qu'à partir de DEUX badges : retirer l'unique
   filtre posé, c'est déjà ce que fait sa croix. */
.jv-filtre-raz { border: 0; background: transparent; padding: 0 4px; cursor: pointer;
                 font-size: 0.72rem; color: var(--text-muted); text-decoration: underline;
                 text-underline-offset: 2px; }
.jv-filtre-raz:hover { color: var(--text); }

/* ==========================================================================
   LA LISTE TIENT DANS LA FENÊTRE — filtres et en-têtes FIGÉS   (2026-09-15)
   ==========================================================================
   Armé par `table_list(…, ['pane' => true])`, qui pose `.table-container--pane`
   sur le cadre. Rien ici ne s'applique sans cette classe : aucune des ~66
   listes du parc ne change de comportement.

   ── POURQUOI CE N'EST PAS `position: sticky` SUR LE `<thead>` ──────────────
   Parce que ça ne peut pas marcher ici, et ça échoue SANS RIEN DIRE : aucune
   erreur, aucun style ignoré — l'en-tête se colle simplement à un bord qui ne
   bouge jamais.

   `.table-container` porte `overflow-x: auto` (une table de 1 560 px dans un
   cadre plus étroit). La spec coerce l'autre axe : `overflow-y` calculé vaut
   donc `auto` lui aussi, et le cadre est un CONTENEUR DÉFILANT. Un
   `position: sticky` se résout contre le plus proche d'entre eux — pas contre
   la fenêtre. Or ce cadre-là ne défile pas verticalement : il grandit avec ses
   lignes, c'est le DOCUMENT qui défile. L'en-tête reste donc collé en haut
   d'une boîte qui sort de l'écran avec lui.

   Deux sorties, une seule juste :
     · retirer `overflow-x` du cadre — la table déborde alors la page ;
     · DESCENDRE le défilement d'un cran : le cadre prend la hauteur qui reste
       dans la fenêtre, et c'est `.table-scroller` (dedans) qui défile, sur les
       DEUX axes. L'en-tête colle à SON bord, qui est enfin immobile.

   La deuxième, donc — et elle donne les trois autres immobilités du même
   coup : la rangée de filtres et la pagination sont HORS du défileur, elles ne
   peuvent plus partir en haut.

   ── LA HAUTEUR SE DÉDUIT, ELLE NE S'ÉCRIT PAS ──────────────────────────────
   Pas de `max-height: calc(100vh - N)`. Ce qui sépare le haut de la fenêtre du
   haut du cadre n'est pas une constante : la barre du haut est un jeton qui
   change (`topbar-empty`, `topbar-has-tabs`, mobile), et la rangée de filtres
   GRANDIT d'une ligne dès qu'un badge de filtre apparaît. Un nombre écrit ici
   serait faux à la première coche, sans que rien ne rougisse.

   `<main>` prend donc la hauteur de la fenêtre et devient une colonne flex :
   la rangée de filtres paie sa hauteur réelle, le cadre prend TOUT le reste,
   quel qu'il soit. C'est le navigateur qui fait la soustraction.

   `:has()` plutôt qu'une classe sur `<body>` : la page n'a rien à déclarer
   deux fois, et la règle dit exactement sa condition — « un `<main>` dont un
   enfant direct est un cadre en mode pane ». `tables.css` en dépend déjà
   (`td:last-child:has(.entity-actions)`).

   ── DEUX HÔTES : LE `<main>`, OU LE PANNEAU ACTIF ──────────────────────────
   Les pages à onglets (`/clients`, `/fournisseurs`) ne posent pas leur liste
   dans le `<main>` : chacune vit dans un `<section class="tab-content">`, et
   un seul de ces panneaux est affiché. La chaîne de hauteur descend donc d'un
   cran de plus — `<main>` → panneau ACTIF → cadre → défileur. Un panneau
   fermé ne compte pas : `display: none` le neutralise, mais `:has()` le VOIT,
   d'où le `.active` dans la condition (sans lui, une page dont l'onglet ouvert
   est une synthèse perdrait son défilement pour une liste invisible).

   ── PAS SOUS 769 px, ET C'EST UN CHOIX ────────────────────────────────────
   Sur un téléphone, `100vh` n'est pas la hauteur visible tant que la barre
   d'adresse est déployée, et une liste de 1 560 px de large s'y consulte en
   défilant des deux mains. La page y garde son défilement de document.
   ========================================================================== */
@media (min-width: 769px) {
  /* ── L'HÔTE : le `<main>`… ou LE PANNEAU ACTIF (2026-09-15, 2e passe) ──
     `/jobs` pose sa liste directement dans le `<main>`. `/clients` et
     `/fournisseurs` ne le peuvent pas : leurs listes vivent chacune dans un
     `<section class="tab-content">`, et un seul de ces panneaux est affiché à
     la fois. La hauteur doit donc descendre d'un cran de plus — le `<main>`
     prend la fenêtre, LE PANNEAU ACTIF prend ce que le `<main>` laisse, et le
     cadre prend ce que le panneau laisse.

     Les deux hôtes s'écrivent en entier, jamais `.main-content *` : la
     condition est « un enfant DIRECT », sans quoi une liste `pane` enfouie
     dans une carte ferait passer toute la page en hauteur fixe.

     ⚠ `.active` FAIT PARTIE DE LA CONDITION. Les panneaux fermés portent le
     même cadre `pane` (`display: none` les neutralise, mais `:has()` les voit
     quand même) : sans `.active`, une page à onglets dont le panneau OUVERT
     est une synthèse passerait en hauteur de fenêtre pour une liste que
     personne ne regarde, et cette synthèse perdrait son défilement. */
  .main-content:has(> .table-container--pane),
  .main-content:has(> .tab-content.active > .table-container--pane) {
    height: 100vh;
    /* dvh quand le navigateur le connaît : la fenêtre VISIBLE, barres d'outils
       déduites. Déclaré après, il gagne ; ignoré, `100vh` tient. */
    height: 100dvh;
    min-height: 0;
    display: flex;
    flex-direction: column;
    /* Le `<main>` ne défile plus : c'est `.table-scroller` qui le fait. Sans
       ça, la page garderait une seconde barre de défilement, inerte. */
    overflow: hidden;
  }

  /* Ce qui n'est ni la roue ni le cadre paie sa hauteur RÉELLE et ne se laisse
     pas comprimer : `min-height: auto` ne protège que l'axe principal d'un
     flex, et la rangée de filtres perdrait sinon sa deuxième ligne de badges.
     C'est dit de TOUS les enfants de l'hôte, et non des deux barres nommées :
     un panneau d'onglet en porte d'autres — bandeau « informations de
     facturation manquantes » de `/clients#factures`, bloc de relecture d'import
     et notices de doublon de `/fournisseurs#factures` — et chacun aurait été
     une régression silencieuse à ajouter à cette liste le jour où il apparaît.
     Les modales n'en sont pas : elles sont `position: fixed`, donc hors flux. */
  .main-content:has(> .table-container--pane) > *,
  .main-content > .tab-content.active:has(> .table-container--pane) > * { flex: 0 0 auto; }

  /* LE PANNEAU ACTIF RELAIE la hauteur. Il est `display: block` par défaut
     (`navigation.css`) : sans cette bascule en colonne flex, le cadre qu'il
     contient n'aurait aucune hauteur à prendre. */
  .main-content > .tab-content.active:has(> .table-container--pane) {
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  /* La roue d'attente occupe la place du cadre qu'elle remplace — sinon elle
     est écrasée à sa hauteur minimale au milieu d'une colonne flex. */
  .main-content:has(> .table-container--pane) > .page-wait,
  .main-content > .tab-content.active:has(> .table-container--pane) > .page-wait { flex: 1 1 auto; }

  /* ── « TENIR DANS LA FENÊTRE » EST UN PLAFOND, PAS UNE TAILLE ───────────
     (retour Olivier, 15-09 : « ici on ne devrait pas avoir un cadre pleine
     page » — `/clients#articles`, UN article, et un cadre blanc qui
     descendait jusqu'en bas de l'écran.)

     `flex: 0 1 auto`, et chaque terme porte sa moitié de la phrase :
       · `0` — ne GRANDIT pas au-delà de son contenu. Une liste d'une ligne
         fait un cadre d'une ligne, comme partout ailleurs dans le parc ;
       · `1` — se laisse RÉTRÉCIR jusqu'à la place disponible. C'est ce qui
         plafonne une liste de 200 lignes à la fenêtre, et ce qui fait
         défiler `.table-scroller` (seul élément à `flex-shrink: 1` dans le
         cadre : la pagination, à `0 0 auto`, ne cède rien).

     ⚠ `flex: 1 1 auto` (ce qui était livré le matin) donnait le plafond SANS
     le premier terme : le cadre prenait la fenêtre entière même vide, et
     `min-height: 0` l'y autorisait sans rien signaler.

     Écrit sous les deux hôtes parce que `> *` ci-dessus est plus spécifique
     que la classe seule et gagnerait sinon. */
  .main-content:has(> .table-container--pane) > .table-container--pane,
  .main-content > .tab-content.active:has(> .table-container--pane) > .table-container--pane {
    flex: 0 1 auto;
  }

  .table-container--pane {
    /* ⚠ SANS CE `min-height: 0`, RIEN NE DÉFILE. Le minimum automatique d'un
       élément flex vaut la taille de son CONTENU : le cadre refuserait de
       descendre sous la hauteur de ses 200 lignes, dépasserait la fenêtre, et
       l'en-tête collerait à un bord de nouveau hors écran. */
    min-height: 0;
    display: flex;
    flex-direction: column;
    /* Le défilement descend dans `.table-scroller` : le cadre garde son bord,
       son rayon et son ombre, et ne défile plus lui-même. */
    overflow: hidden;
  }

  .table-container--pane > .table-scroller {
    flex: 1 1 auto;
    min-height: 0;
    overflow: auto;
    /* ── LES DEUX AXES, ET `none` : PAS DE REBOND ÉLASTIQUE ────────────────
       Le cadre ne portait la règle que sur X, parce qu'il ne défilait que sur
       X. Ce défileur-ci porte les deux, et le rebond vertical se VOIT :
       arrivé en haut des lignes avec un peu d'élan, le geste à deux doigts
       décolle le tableau de son en-tête et découvre une bande claire au-dessus
       de lui, le temps que le rebond se referme (retour Olivier, 15-09 :
       « un espace blanc se crée au-dessus du tableau pendant une demi
       seconde »). `contain` ne suffirait pas — il arrête l'ENCHAÎNEMENT vers
       l'ancêtre, pas le rebond ; seul `none` supprime les deux. Même arbitrage
       que `.table-container` et `dropdowns.css`.
       ⚠ Conséquence assumée : sur un pavé tactile, arrivé en bout de liste,
       le geste ne passe plus la main à la page — mais la page ne défile plus
       en mode pane, il n'y a rien à qui la passer. */
    overscroll-behavior: none;
  }

  .table-container--pane > .table-scroller > .table thead th {
    position: sticky;
    top: 0;
    /* Au-dessus des lignes (rien), et au-dessus de la colonne d'actions
       collante, qui monte à 1 (cellule) / 2 (en-tête) plus haut dans ce
       fichier. L'en-tête de CETTE colonne doit rester au-dessus des deux :
       il est le croisement des deux immobilités. */
    z-index: 3;
    /* ⚠ LE FILET DU BAS EST REDESSINÉ EN OMBRE INTERNE, et ce n'est pas un
       embellissement : `.table` est en `border-collapse: collapse`, où les
       bords appartiennent à la TABLE et non aux cellules. Un `<th>` sorti du
       flux par `position: sticky` les emporte donc… sans eux — l'en-tête
       flotte alors sur les lignes sans rien qui l'en sépare. Le `border-bottom`
       de `.table th` reste en place pour le mode ordinaire ; ici l'ombre le
       double, à la même épaisseur et à la même couleur. */
    box-shadow: inset 0 -1px 0 var(--border);
  }
  .table-container--pane > .table-scroller > .table thead th.actions-cell:last-child,
  .table-container--pane > .table-scroller > .table thead th:last-child:empty {
    z-index: 4;
  }

  /* La pagination est hors du défileur : elle ferme le cadre, toujours visible,
     sans avoir à rester `sticky` (la règle générique plus haut ne gêne pas). */
  .table-container--pane > .table-pagination { flex: 0 0 auto; }
}
