/* Card */
.card {
  background: var(--c-surface);
  border: 1px solid var(--c-line);
  border-radius: var(--r-3);
  box-shadow: var(--elev-1);
  padding: var(--s-5);
}
.card > header { margin-bottom: var(--s-4); }
.card h2 { font-size: var(--fs-200); }
.card .sub { margin: var(--s-1) 0 0; font-size: var(--fs-75); color: var(--c-ink-2); }

/* Fields */
/* A <fieldset> is the grid container where a group needs a name; the browser's default border
   and padding are removed so it lays out exactly like a plain .fields. */
fieldset.fields { border: 0; padding: 0; margin: 0; min-width: 0; }
/* Distinct from .field label, which is also uppercase micro-type -- side by side they read as
   two stacked labels rather than a group and its first field. The legend is the heavier of the
   two and carries a rule under it. */
.fieldset-legend {
  grid-column: 1 / -1;
  width: 100%;
  padding: 0 0 var(--s-2);
  margin-bottom: var(--s-1);
  border-bottom: 1px solid var(--c-line);
  font-family: var(--font-serif);
  font-size: var(--fs-100);
  color: var(--c-ink);
}
.fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--s-4); }
.fields .wide { grid-column: 1 / -1; }
@media (max-width: 640px) { .fields { grid-template-columns: 1fr; } }
/* THE GRID FOLLOWS THE CONTENT. The even 2-up left the column beside a telephone empty, because
   the address under it is .wide. Two variants, one per real shape on the dossier form, both
   collapsing to one column with .fields at 640px.
   A third, .fields--locality, was a 1fr + 10rem pair for « Ville » and « Code postal ». It went on
   2026-08-26 with the columns behind it: the signer's address is ONE 250-character field now, the
   same as the payer's, so there is no postal code left to keep from taking half the measure. */
.fields--identity { grid-template-columns: 1fr 1fr minmax(0, 12rem); }   /* prénom, nom, date */
.fields--contact  { grid-template-columns: minmax(0, 16rem) 1fr; }        /* a name, then a phone */
@media (max-width: 640px) {
  .fields--identity, .fields--contact { grid-template-columns: 1fr; }
}

/* The required marker, inside the caption. A word rather than an asterisk: an asterisk needs a
   legend elsewhere on the page, and colour is never the only signal here (WCAG 1.4.1). It sits in
   the <label>, so a screen reader meets it with the field's name. text-transform is reset because
   .field label uppercases, and "(OBLIGATOIRE)" shouts. */
.req {
  color: var(--c-caution);
  font-weight: 600;
  text-transform: none;
  letter-spacing: 0;
}

/* A <summary> that reads as a control rather than a disclosure triangle. Three call sites:
   « Expédier à », folded away until it holds something, the dossier list's status legend, and
   since 2026-08-27 the header's account menu -- which is the one of the three that is a popup,
   and puts its caret last for that reason.
   <details> rather than a controller in both, so they work with JavaScript off -- the same
   reason the journal's editor is one.

   It was .ship-toggle until 2026-08-27, when the legend needed the identical control and the
   choice was a second copy or a name that is not one screen's. The rules below are unchanged;
   only the caret's [open] selector was generalised off `details.ship`. */
.disclosure-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-1) var(--s-3);
  min-height: var(--tap-min);
  font-size: var(--fs-75);
  font-weight: 600;
  color: var(--c-ink);
  border: 1px solid var(--c-line-control);
  border-radius: var(--r-2);
  background: transparent;
  cursor: pointer;
  list-style: none;
}
.disclosure-toggle::-webkit-details-marker { display: none; }
.disclosure-toggle:hover { border-color: var(--c-accent); color: var(--c-accent-dk); }
/* The caret points down when closed and up when open, which is the only state the summary shows
   once the disclosure triangle is gone. */
.disclosure-toggle svg { transition: transform var(--dur-1) var(--ease-1); }
details[open] > .disclosure-toggle svg { transform: rotate(180deg); }
/* The gap between summary and content is the OPEN state's, so it stays per-<details>: .ship has
   fields under it and .legend has a list, and they do not want the same air. */
details.ship[open] > .disclosure-toggle { margin-bottom: var(--s-4); }

/* GONE 2026-08-31, and this is a resolution rather than a revert. `.jform .fields` was
   `minmax(0, 16rem) 1fr` because the journal's two forms held exactly a timestamp and a note on one
   row: that is what let the note skip `.wide` and stopped column 2 of row 1 from sitting empty above
   640px. The chips now sit BETWEEN those two fields, so there is no two-field row left to lay out --
   a grid there would strand the timestamp alone in column 1 and the comment above would have become
   false. Both fields are plain full-width `.field`s in the form's own `.stack` now. `.jform` itself
   stays: the textarea rule below reads it. */

.field { display: flex; flex-direction: column; gap: var(--s-1); }
/* dt as well as label: a read-only value is a <dl> term/description pair, not a form control.
   A <label> that wraps nothing and has no `for` labels nothing at all -- it was being used as
   a caption for a <span>, which reads to assistive technology as an orphan. */
/* Exclude a <label> that is itself the CONTROL rather than the caption above one -- the
   journal's file picker hides its native input and drives it from a label, which must read as
   the button it is.
   :not(:where(.btn)) and NOT :not(.btn). :not() takes the specificity of its argument, so
   `.field label:not(.btn)` is (0,2,1) and started outranking `.check-row label` at (0,1,1),
   which had only ever won on source order -- and the exemption toggle's own label, the one
   sentence on the billing screen that names the Indian Act, silently became 12px uppercase
   micro-type. :where() contributes ZERO, so this stays (0,1,1) and the cascade is unchanged.
   That worked example is HISTORICAL as of 2026-08-26: the exemption row dropped `.field`, which is
   what it was fighting. The rule stays -- the next .check-row placed inside a .field would hit it,
   and the specificity trap is real whether or not anything is standing in it today. */
.field label:not(:where(.btn)), .field dt {
  font-size: var(--fs-50);
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--c-ink-3);
}
.field dd { margin: 0; display: flex; flex-direction: column; gap: var(--s-1); }

/* A checkbox with its label BESIDE it, which is where a checkbox label belongs -- .field label
   is the micro-caption above a text input and is wrong here.

   flex-direction: row is EXPLICIT, and it is a fix rather than a default spelled out (2026-08-26).
   `.field` above sets flex-direction: column, and this rule set `display: flex` without resetting
   the direction -- so `class="field check-row"` was a CENTRED COLUMN: the box stacked above its
   label, both centred, which is the one arrangement a checkbox must never have. It had been that
   way on the exemption toggle since the panel was written, invisible because the other call site
   (admin/users/_form) carries .check-row alone and inherits row from nothing.
   A class whose entire purpose is "these two sit side by side" must not depend on what is beside
   it in the class attribute. */
.check-row {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--s-2);
  min-height: var(--tap-min);
}
.check-row label {
  font-size: var(--fs-100);
  text-transform: none;
  letter-spacing: 0;
  color: var(--c-ink);
  cursor: pointer;
}
.check-row input[type="checkbox"] { width: 1.15rem; height: 1.15rem; cursor: pointer; }
/* <div> wrapping a dt/dd pair is valid inside <dl>, and is what keeps the grid working. */
dl.fields { margin: 0; }
/* .control is the SAME recipe reaching a control that has no .field around it, and the billing
   rows are why it exists (2026-08-28). A <td> cannot hold a .field -- the label, the control and
   the amount are separate cells -- so every input on the one screen in the app with 33 rows of
   them was falling through to the browser default: 15px type, 1px 2px padding, a 2px inset border
   and no min-height, measured at 72x29px. Two consequences, both real on the target devices: 29px
   fails the 44pt minimum on the two controls the office taps most, and 15px is under the 16px
   below which iOS ZOOMS THE VIEWPORT on focus -- which is the whole reason --fs-200 exists.
   One selector list, not a second recipe: the shape of a field in this app has to stay one edit. */
.field input, .field select, .field textarea, .control {
  width: 100%;
  min-height: var(--tap-min);
  padding: var(--s-2) var(--s-3);
  /* 17px: below 16px, iOS zooms the viewport on focus. */
  font-size: var(--fs-200);
  background: var(--c-surface);
  border: 1px solid var(--c-line-control);
  border-radius: var(--r-input);
}
.field .hint { font-size: var(--fs-50); color: var(--c-ink-3); }
/* A hint that describes a GROUP rather than one control sits directly in the .fields grid, so
   `.field .hint` never reached it and it rendered at full body size and full ink, louder than
   the fields it annotates -- both of settings/edit's group hints, including the one under the
   two tax RATES. Scoped to the grid child, not made a bare `.hint` rule: `.jentry .hint` below
   records why a blanket rule is wrong, and billing/_exemption and journal/_entry_read both use
   a bare .hint for prose that must NOT shrink to 12px micro-type. */
.fields > .hint { font-size: var(--fs-50); color: var(--c-ink-3); }
/* .field is a flex column, which stretches its children -- right for an input, wrong for a
   label acting as a button (the journal's file picker). Same reason .stack > .btn exists. */
.field > label.btn { align-self: flex-start; }
/* The message beside the field it rejected. Colour is never the only signal -- it carries an
   icon and the text -- and the control it describes also gets aria-invalid and a 2px border. */
.field-error {
  display: flex;
  align-items: flex-start;
  gap: var(--s-1);
  font-size: var(--fs-75);
  color: var(--c-caution);
}
.field-error svg { flex: none; margin-top: .15em; }
.field [aria-invalid="true"] { border-color: var(--c-caution); border-width: 2px; }
.field input[readonly] { background: var(--c-surface-2); color: var(--c-ink-2); }

/* THREE value classes, three different statements, and they are not interchangeable:
     .empty        nothing is there (italic, recessed -- see below)
     .value--muted something is there and is merely secondary
     .value--plain something is there and is not editable, on a screen where that is the
                   NORMAL case: the dossier overview is a list of values, so plain text under
                   its <dt> caption is the honest render.
     .value--ro    something is there and is not editable, where the surrounding elements ARE
                   editable -- among real inputs (admin/users/_form) or in a money column
                   (catalog_items/index, cases/billings/_row). There the recessed box is what
                   separates the value from the controls beside it, and it is the direct
                   descendant of the prototype's .price[readonly] (prototype/index.html:66),
                   which applyRole (:713) flips to mark a NON-ADMIN. The grey means "this one,
                   exceptionally, is not yours to edit".

   The dossier card used .value--ro for all of its values, which inverted that: the grey stopped
   marking an exception and became the page's default, while the one genuinely editable control
   on the screen -- the status select -- read as one more greyed box. The invoice-number slot
   keeps the box on purpose, as the prototype does (:68, :235): it is an empty slot awaiting
   issuance, and the box is what says so. */
.value--plain {
  display: block;
  font-size: var(--fs-200);
  color: var(--c-ink);
}

.value--ro {
  display: block;
  min-height: var(--tap-min);
  padding: var(--s-2) var(--s-3);
  background: var(--c-surface-2);
  color: var(--c-ink-2);
  border: 1px solid var(--c-line);
  border-radius: var(--r-2);
}

/* The payer combobox: a text field with our own listbox under it. It replaced a native <datalist>,
   whose popup is browser chrome -- on Chrome a dark callout with a tail, anchored beside the field
   rather than to it, over the hints below, and no CSS of ours reaches any of it. See
   app/views/cases/_payer_combobox.html.erb. */
.combo { position: relative; display: flex; flex-direction: column; }
/* The input keeps `.field input`'s own rule; only room for the caret is added here. */
.combo input { padding-inline-end: var(--s-6); }

.combo-toggle {
  position: absolute;
  inset-inline-end: 1px;
  /* Aligned to the INPUT, not to the .combo box: the listbox is a child too, so `inset-block: 0`
     would stretch the caret down the whole open widget. */
  top: 1px;
  height: var(--tap-min);
  width: var(--s-6);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: 0;
  border-start-end-radius: var(--r-input);
  border-end-end-radius: var(--r-input);
  color: var(--c-ink-2);
  cursor: pointer;
}
.combo-toggle:hover { color: var(--c-accent-dk); }

/* position, z-index and the surface itself are in the shared `.combo-list, .account-panel` rule
   below -- this block is only what makes this popup a scrolling list under its field. */
.combo-list {
  inset-inline: 0;
  /* Below the input, wherever the input ends: the field is the first child, so 100% of the .combo
     box is the wrong offset once the list itself is in the flow. calc off the tap floor instead. */
  top: calc(var(--tap-min) + var(--s-1));
  margin: 0;
  padding: var(--s-1);
  list-style: none;
  max-height: 18rem;
  overflow-y: auto;
}

/* `display` ON AN ELEMENT THAT USES [hidden] HAS TO BE UNDONE FOR IT. An author `display` rule
   beats the UA stylesheet's `[hidden] { display: none }` whatever the specificity, so the flex
   below silently defeated `hidden` on every row: the filter hid nothing, and "aucun payeur ne
   correspond" showed underneath two matching payers. */
.combo-option[hidden] { display: none; }

.combo-option {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-1);
  cursor: pointer;
}
.combo-name { font-size: var(--fs-100); color: var(--c-ink); }
/* 5.07:1 on the surface. */
.combo-detail { font-size: var(--fs-75); color: var(--c-ink-2); }

/* The highlighted row, moved by aria-activedescendant rather than by focus. The detail line comes
   up to full ink here: --c-ink-2 on --c-accent-tint is 4.50:1, which is at the 1.4.3 line rather
   than above it, and this row is the one being read. */
.combo-option:hover,
.combo-option.is-active {
  background: var(--c-accent-tint);
}
.combo-option.is-active .combo-detail,
.combo-option:hover .combo-detail { color: var(--c-ink); }

/* The row that says a typed name will be a NEW payer. Not a choice -- aria-disabled, no pointer,
   no hover -- and not a refusal either, which is what "aucun payeur ne correspond" read as. Italic
   and recessed like .empty, because it is the widget talking rather than data. */
.combo-option--new {
  font-style: italic;
  font-size: var(--fs-75);
  color: var(--c-ink-3);
  cursor: default;
}
.combo-option--new:hover { background: transparent; }

/* The account menu in the app header: the app's SECOND popup, and the reason the surface above
   is now shared. A <details> rather than a controller -- signing out is the one control that has
   to survive JavaScript failing, so the panel opens natively and menu_controller only adds
   Escape, the outside click and the close before Turbo caches the page.

   .app-header sets no position and no z-index, so it creates no stacking context and this paints
   over .app-nav below it. */
.account { position: relative; }

/* Said once for both popups. Everything else about the two is different -- the listbox scrolls
   and stretches to its field, the panel hugs the header's right edge -- but the surface is the
   surface. */
.combo-list,
.account-panel {
  position: absolute;
  z-index: var(--z-popover);
  background: var(--c-surface);
  border: 1px solid var(--c-line-control);
  border-radius: var(--r-2);
  box-shadow: var(--elev-1);
}

/* The chip. It takes .disclosure-toggle for the 44px floor, the marker suppression and the caret
   rotation, and overrides only its spacing: the monogram wants more air than a text summary, and
   less of it on the leading edge because the circle already carries its own. */
.account-toggle {
  gap: var(--s-3);
  padding-block: var(--s-1);
  padding-inline: var(--s-2) var(--s-3);
}
/* users.name is limit: 120. Unbounded, one long name wraps the chip onto a second line or pushes
   the caret out of it. The truncated value is not lost: the panel below carries it in full, and
   the disclosure is what makes that recoverable by keyboard and touch rather than by hover. */
.account-name {
  min-width: 0;
  max-width: 12rem;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.account-toggle svg { flex: none; }

/* The app's first monogram, and under 640px the whole of what says whose session this is. A
   CIRCLE, not a pill: --r-pill marks state and the chip around this stays a 6px rectangle.
   --c-on-accent is 6.0:1 on --c-accent. */
.account-avatar {
  flex: none;
  width: 1.75rem;
  height: 1.75rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--c-accent);
  color: var(--c-on-accent);
  font-size: var(--fs-75);
  font-weight: 600;
  letter-spacing: .02em;
}

.account-panel {
  /* Anchored to the RIGHT edge: at 375px a left-anchored panel runs off the viewport.
     The max-width stays although the panel now holds one short button: an absolutely positioned
     box with only `right` set is shrink-to-fit against a containing block the width of .account,
     so anything long put in here grows the panel to the LEFT, past the viewport edge, rather than
     wrapping. That is the trap the bound exists for, and it is cheaper to keep than to rediscover
     the next time a line is added. */
  inset-inline-end: 0;
  top: calc(100% + var(--s-1));
  /* --s-3 and not --s-2, measured: the focus ring is outline 2px at offset 2px plus a 3px tint,
     so it reaches 7px past the row. At 8px of panel padding it grazed the panel's own border --
     visible and conformant, but read as cramped. 12px leaves it 5px of ground. The row gives
     the same amount back on the inline axis so the label lands where it did. */
  padding: var(--s-3);
  min-width: 14rem;
  max-width: min(20rem, calc(100vw - var(--s-6)));
}
/* The panel's one item reads as a MENU ROW, not as a button sitting in a box. With the name line
   gone the panel frames a single element, and a .btn--ghost's own border inside the panel's own
   border is the double-framing that `.card` around a lone `.notice` is banned for. So the surface
   is the frame and the row loses its outline -- which is exactly what .combo-option does inside
   .combo-list, the first popup. The hover tint and the focus ring are what remain, and the ring
   is untouched: this is a border, not an outline. */
.account-panel .btn {
  justify-content: flex-start;
  padding-inline: var(--s-3);
  border-color: transparent;
  border-radius: var(--r-1);
  /* 500, not .btn's 600. A menu row is a label; 600 is the weight the page's primary wears, and
     with nothing beside it to out-weigh, a bold row in an empty panel only reads as loud. */
  font-weight: 500;
}
/* The glyph is one step back from the label -- 4.7:1, comfortably past the 3:1 a non-text mark
   needs -- so the row reads label-first and the icon confirms rather than announces. */
.account-panel .btn svg { color: var(--c-ink-2); }
.account-panel .btn:hover {
  border-color: transparent;
  background: var(--c-accent-tint);
  color: var(--c-accent-dk);
}
.account-panel .btn:hover svg { color: inherit; }

/* The app's FIRST @keyframes, and it is a keyframe only because [open] is not a transitionable
   state -- there is no from-value for the browser to interpolate when the panel is inserted.
   Everything else here still transitions. It respects prefers-reduced-motion for free: --dur-1
   is 0ms under it (tokens.css), so the rule stays and the motion does not. */
details.account[open] > .account-panel { animation: account-panel-in var(--dur-1) var(--ease-1); }
@keyframes account-panel-in {
  from { opacity: 0; transform: translateY(-2px); }
}

/* The visible name folds; the accessible name does not -- the .u-vh sentence in the summary is
   unconditional, so the control announces the same string at every width. This is layout, not
   withholding. */
@media (max-width: 640px) { .account-name { display: none; } }

/* THE CONFIRM DIALOG -- the app's third popup and its first modal. It answers every
   data-turbo-confirm in the app through confirm_dialog_controller.js, so there is one of these
   and it lives in the layout.

   It does NOT join `.combo-list, .account-panel` above, although it wants the same ground,
   border and shadow: that rule carries `position: absolute` and `z-index: var(--z-popover)`, and
   a dialog in the TOP LAYER wants neither. That is also why no z-index token was added -- the
   app still has exactly two stacking decisions. The radius is --r-card, not the popups' --r-2:
   this is a card-sized box, not a menu hugging a control. */
.confirm-dialog {
  /* REQUIRED, and it is a measure decision rather than a cosmetic one. The UA gives <dialog>
     `width: fit-content`, capped only by `dialog:modal { max-width: calc(100% - 6px - 2em) }` --
     so without this the 260-character invoice.reissue_confirm sets as ONE LINE the width of the
     office's monitor, and only wraps on the phone. 30rem is --narrower, the measure this system
     already picked for a short single-column card (docs/design-system.md, Structure). */
  max-inline-size: min(30rem, 100% - var(--s-6));
  padding: var(--s-5);
  background: var(--c-surface);
  /* NOT optional. The UA sets `color: CanvasText` ON THE ELEMENT, which beats inheritance from
     <body> -- and CanvasText under the layout's `color-scheme: light` is #000, not --c-ink. */
  color: var(--c-ink);
  /* A full reset: the UA's is `border: solid`, i.e. 3px of currentColor. */
  border: 1px solid var(--c-line-control);
  border-radius: var(--r-card);
  box-shadow: var(--elev-1);
  /* No max-height: `dialog:modal`'s UA cap is already a percentage of the initial containing
     block and is already correct. A calc(100vh - …) here would be LOOSER than the UA rule and
     would put back the exact iOS error layout.css records -- 100vh is the LARGE viewport. If a
     cap is ever needed, copy layout.css's .pane--preview shape for shape: bare declaration plus a
     SIBLING @supports (max-height: 1dvh) with the selector repeated. Never CSS nesting --
     component_layout_spec's brace scanner states it assumes a declaration block holds no nested
     braces, and a nested block desynchronises it silently for everything added after.
     No scroll lock either: overflow:hidden on html/body while this is open would reach the
     billing screen's sticky preview pane, which has already been broken twice. */
}
/* base.css has no `p` rule, so the message keeps the UA's `margin: 1em 0` -- 15px of non-token
   space stacked on .stack's --s-4 gap, which is the two-spacing-mechanisms-on-one-axis fault the
   .jentry .actions note further down records as a shipped bug. */
.confirm-dialog p { margin: 0; }
/* .actions is the required class for a button row (print.css hides it, .row is the invoice's
   totals line). Only the base rule reaches this one: `.card > .actions` is a child combinator and
   `.jentry .actions` a descendant one, and this dialog is a child of <body>. */
.confirm-dialog .actions { justify-content: flex-end; }
/* A LITERAL, and the reason is arithmetic rather than compatibility -- ::backdrop does inherit
   custom properties from its originating element on every engine that ships showModal() today.
   There is no --c-scrim, --c-ink is a hex, and 45% alpha cannot be derived from a hex without
   color-mix(). tokens.css's --elev-1 and base.css's focus-ring tint are the same case answered
   the same way. This is --c-ink at 45%: move one and move the other. */
.confirm-dialog::backdrop { background: rgb(27 42 43 / .45); }
/* EVERY declaration that would set `display` lives here, gated on [open], and that gate is the
   whole reason this rule exists rather than a `.stack` on the element. A closed <dialog> is
   hidden by the UA's `dialog:not([open]) { display: none }` -- and an AUTHOR declaration beats a
   UA one on ORIGIN, not on specificity, so ANY unconditional display here un-hides the dialog on
   every page in the app. `.stack` did exactly that for part of 2026-08-31: an empty box with two
   buttons, in normal flow, on every screen. It is the same gap as .stack's own tokens, said in
   the one place that cannot leak.
   The motion is the account panel's, said again for the same reason: [open] is not a
   transitionable state, so there is no from-value to interpolate. --dur-1 is 0ms under
   prefers-reduced-motion, so the rule stays and the motion does not. */
.confirm-dialog[open] {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  animation: confirm-dialog-in var(--dur-1) var(--ease-1);
}
@keyframes confirm-dialog-in {
  from { opacity: 0; transform: translateY(-2px); }
}

/* Buttons. Rectangles, not pills -- the pill marks STATE (the status badge, and the journal
   chips as tap targets), so a page where every control is a pill cannot use shape to say so.
   Not an aesthetic preference: it is why the badge is legible at a glance. */
.btn {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  min-height: var(--tap-min);
  padding: var(--s-2) var(--s-4);
  font-size: var(--fs-100);
  font-weight: 600;
  border: 1px solid transparent;
  border-radius: var(--r-2);
  cursor: pointer;
  text-decoration: none;
}
.btn--primary { background: var(--c-accent); color: var(--c-on-accent); border-color: var(--c-accent-dk); }
.btn--primary:hover { background: var(--c-accent-dk); color: var(--c-on-accent); }
.btn--ghost { background: transparent; color: var(--c-ink); border-color: var(--c-line-control); }
.btn--ghost:hover { border-color: var(--c-accent); color: var(--c-accent-dk); }
.btn--danger { background: transparent; color: var(--c-caution); border-color: var(--c-caution); }
.btn--danger:hover { background: var(--c-caution-tint); }
.btn[disabled] { opacity: .55; cursor: not-allowed; }
/* Full width, on purpose. Only the sign-in card wants this -- a lone submit stretching to the
   form's width elsewhere is the flex default leaking through, not a decision. */
.btn--block { align-self: stretch; width: 100%; }

/* Status badge. The pill shape carries state, which is why buttons are rectangles. The base
   rule paints a NEUTRAL badge rather than nothing: every call site builds its modifier by
   interpolation -- badge--<%= status %> -- so a status with no rule of its own (invoice
   statuses `issued`, `voided` and `superseded` have none) would otherwise render with no
   background at all and a dot in currentColor, which reads as a rendering fault rather than a
   state. */
.badge {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-1) var(--s-3);
  border-radius: var(--r-pill);
  font-size: var(--fs-75);
  font-weight: 600;
  color: var(--c-ink-2);
  background: var(--c-surface-2);
}
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge--draft { color: var(--c-st-draft); background: var(--c-st-draft-bg); }
.badge--verified { color: var(--c-st-verified); background: var(--c-st-verified-bg); }
.badge--invoiced { color: var(--c-st-invoiced); background: var(--c-st-invoiced-bg); }
.badge--sent { color: var(--c-st-sent); background: var(--c-st-sent-bg); }
.badge--role { color: var(--c-ink-2); background: var(--c-surface-2); }
.badge--kind { color: var(--c-ink-2); background: var(--c-surface-2); }
/* An event mapping is a record, not a control, so it takes the role badge's neutral pill and
   drops the dot. nowrap because a compact label that wraps to a second line stops reading as one
   pill -- the COLLECTION wraps instead (.chiplist), which .cell-events bounds the column to
   allow. The markers are weight-only: --c-ink-3 on this ground is 3.9:1.

   Deliberately NOT .chip--static. The flat-tag/pill split below protects the JOURNAL, where both
   shapes sit side by side and shape is the only channel left; the catalogue has no interactive
   chip, and .badge is already this app's static-record pill. Do not harmonize them. */
.badge--event { color: var(--c-ink-2); background: var(--c-surface-2); white-space: nowrap; }
.badge--event small { font-weight: 400; }
.badge--role::before, .badge--kind::before, .badge--event::before { display: none; }

/* Invoice statuses. `issued` and `sent` share the dossier's hues deliberately -- they are the
   same two states seen from the invoice's side. */
.badge--issued { color: var(--c-st-invoiced); background: var(--c-st-invoiced-bg); }
.badge--voided { color: var(--c-caution); background: var(--c-caution-tint); }
.badge--superseded { color: var(--c-ink-2); background: var(--c-surface-2); }

/* The status legend, under the dossier list. A key to the « Statut » column beside it and
   nothing more: the facture's own statuses and the two states that merely READ like one
   ("PROJET DE FACTURE", "correction en cours") are deliberately absent, because a legend that
   explains screens the reader is not on stops being a key and becomes a glossary.

   No .card. A card frames a heading plus its content, and a <details> is already its own frame
   -- one inside the other renders the group double-framed. It goes straight into the .stack.

   No heading either. There is one group, and the <summary> names it 40px above; an <h2>
   repeating that label is the same fault as a button duplicating a tab. */
details.legend[open] > .disclosure-toggle { margin-bottom: var(--s-4); }
.legend-body {
  display: grid;
  gap: var(--s-3);
  padding-left: var(--s-4);
  /* Decorative, and allowed to be: this rule borders no control, so --c-line's 1.3:1 is not
     the 3:1 of WCAG 1.4.11. It marks the content as subordinate to its own summary. */
  border-left: 2px solid var(--c-line);
}
.legend-note { margin: 0; font-size: var(--fs-75); color: var(--c-ink-2); }
/* max-content: the badge column is as wide as the longest label and no wider. NO margin of its
   own -- .legend-body's gap already spaces it from the note, and carrying both put 36px between
   two lines of text. Spacing between stacked blocks belongs to the container, the same rule
   .page-head states for .case-nav. */
.legend-list {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  gap: var(--s-3) var(--s-4);
  align-items: baseline;
  margin: 0;
}
.legend-list dt { margin: 0; }
.legend-list dd {
  margin: 0;
  max-width: 68ch;
  font-size: var(--fs-75);
  color: var(--c-ink-2);
  line-height: var(--lh-body);
}
/* --bp-one-col. The badge sits ON its sentence rather than beside it: at 375px a max-content
   column takes a third of the width and leaves the sentence four words wide. */
@media (max-width: 640px) {
  .legend-list { grid-template-columns: 1fr; gap: var(--s-1); }
  .legend-list dd { margin-bottom: var(--s-4); }
  .legend-list dd:last-child { margin-bottom: 0; }
}

/* The CTQ section letter beside a section heading. Used in two views and defined in NO
   stylesheet until now, so "A" and "Services" ran together as plain text. Sans inside a serif
   heading deliberately: it is a label, not a word. */
.tag {
  display: inline-grid;
  place-items: center;
  min-width: 1.5rem;
  height: 1.5rem;
  padding: 0 var(--s-1);
  border-radius: var(--r-2);
  background: var(--c-accent);
  color: var(--c-on-accent);
  font-family: var(--font-sans);
  font-size: var(--fs-75);
  font-weight: 700;
  margin-right: var(--s-2);
  vertical-align: .06em;
}

/* Attachments, the invoice chain and the delivery history. All three are TABULAR data rendered
   as a <ul> of unlabelled <span>s, and the class was defined nowhere -- so they rendered as run
   together inline text. Given real columns here rather than rewritten as tables, because the
   markup is shared by three screens and the row shapes differ. */
.filelist { list-style: none; margin: 0; padding: 0; }
.filelist li {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--s-2) var(--s-4);
  padding: var(--s-3) 0;
  border-bottom: 1px solid var(--c-line);
}
.filelist li:last-child { border-bottom: 0; }
/* The first cell is the thing itself and takes the slack; the rest are metadata. */
.filelist li > :first-child { flex: 1 1 14rem; min-width: 0; }
.filelist li > span:not(:first-child) { font-size: var(--fs-75); color: var(--c-ink-2); }
.filelist .num { font-variant-numeric: tabular-nums; }

/* The attachment drop target. Dashed while idle so it reads as somewhere to drop as well as
   somewhere to click, and filled while a file is over it. */
.dropzone { border-style: dashed; }
.dropzone.is-over { background: var(--c-accent-tint); border-style: solid; border-color: var(--c-accent); }

/* Notices. Colour is never the only signal: each carries an icon and a text label -- and between
   --alert and --caution the icon is the ONLY signal, because those two share a colour (see below).
   That claim was false for both of them until 2026-08-26: they shared the `warning` triangle as
   well as the amber, so the billing screen stacked a billing error and a normal state as two
   identical boxes. --alert now draws an octagon. spec/guards/notice_severity_spec.rb pins the
   disjointness; --caution also carries `lock`, which is fine because --alert never does. */
.notice {
  display: flex;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-radius: var(--r-2);
  font-size: var(--fs-75);
  line-height: 1.45;
}
.notice svg { flex: none; margin-top: 2px; }
.notice--info { background: var(--c-surface-2); color: var(--c-ink-2); border: 1px solid var(--c-line); }
.notice--ok { background: var(--c-ok-tint); color: var(--c-ok); border: 1px solid var(--c-ok); }

/* Journal chips. Big tap targets: the field worker uses these on a phone, standing up, and the
   whole point of the journal is that it needs almost no typing. The checkbox itself is visually
   hidden but focusable, so the keyboard and the screen reader still see a checkbox. */
/* Stripping the browser's fieldset chrome. `margin` is in the reset too, and that is the whole
   story of a bug worth not repeating: `.chips` below carried `margin: var(--s-3) 0` for the gap
   above and below the chip block, and it NEVER APPLIED -- `fieldset.chips` is (0,1,1) against
   `.chips`'s (0,1,0), so the reset won on specificity whatever the source order, and the chips
   sat flush against the field above them on every screen that renders them.
   The margin is gone rather than made to win: block spacing inside these forms belongs to
   `.stack` on the <form>, which is what every other form in the app uses, and two mechanisms on
   one axis is how this happened. Do not put a margin back here. */
fieldset.chips { border: 0; padding: 0; margin: 0; min-width: 0; }
.chips { display: flex; flex-wrap: wrap; gap: var(--s-2); align-items: center; }
/* journal.chips_legend used to render `u-vh`, so fourteen pills appeared under a text input with
   no name on them. Still a <legend> in a <fieldset> -- the accessible grouping is unchanged --
   and `flex: 1 0 100%` is what puts it on its own line, because a legend in a flex fieldset is
   laid out as one of the flex items. */
.chips > legend {
  flex: 1 0 100%;
  width: 100%;
  padding: 0 0 var(--s-1);
  margin: 0;
  font-size: var(--fs-50);
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--c-ink-3);
}
/* The UNSELECTED chip is a control, and it used to be painted `--c-surface-2` + `--c-ink-2`:
   tokens.css reserves that ground for "read-only values, recessed rows", so the primary input of
   this screen wore the read-only recipe and the ticked chip was the bright one -- the affordance
   inverted. It also measured 4.29:1, under the 4.5:1 that WCAG 1.4.3 wants of 13px text. On
   `--c-surface` with ink it is 15:1.
   --tap-min-lg, finally used: docs/design-system.md has said since the design pass that the
   journal chips take 48px and that the token was declared for them and read by nothing. */
.chip {
  display: inline-flex; align-items: center; gap: var(--s-1);
  min-height: var(--tap-min-lg); padding: var(--s-2) var(--s-3);
  border: 1px solid var(--c-line-control); border-radius: var(--r-pill);
  background: var(--c-surface); color: var(--c-ink);
  font-size: var(--fs-75); cursor: pointer;
}
.chip input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.chip:hover { border-color: var(--c-accent); color: var(--c-accent-dk); }
/* Shape as well as colour (WCAG 1.4.1): the tick is a real glyph in a slot that is 0-wide until
   the box is checked, so it animates open on tap and needs no JavaScript and no icon file.
   `width` and `opacity` are the slot -- anything else that wants a ::before on a chip has to
   reset both, or it renders generated and invisible. */
.chip::before {
  content: "\2713";
  font-weight: 700;
  color: var(--c-accent-dk);
  width: 0;
  overflow: hidden;
  opacity: 0;
  transition: width var(--dur-1) var(--ease-1), opacity var(--dur-1) var(--ease-1);
}
.chip:has(input:checked) {
  background: var(--c-accent-tint, var(--c-ok-tint));
  color: var(--c-ink);
  border-color: var(--c-accent);
  font-weight: 600;
}
.chip:has(input:checked)::before { width: 1.1ch; opacity: 1; }
.chip:has(input:focus-visible) { outline: 2px solid var(--c-ink); outline-offset: 2px; }
.chip small { color: var(--c-ink-3); font-weight: 400; }
/* --c-ink-3 on --c-accent-tint is 4.07:1 and fails 1.4.3. The "(note)" marker on a checked chip
   comes up a step; --c-ink-2 on that tint is 4.50:1. */
.chip:has(input:checked) small { color: var(--c-ink-2); }

/* THE GROUP IS THE PILL. The border and the radius live here rather than on .chip, so a chip and
   its count are one object with two zones instead of a pill beside a floating square box -- which
   is what a 4.5rem bordered input between two pills read as. .chip-group is already the semantic
   unit (a count must never wrap away from the chip it belongs to), so this needs no markup
   change: only the paint moves up one level. */
.chip-group {
  display: inline-flex; align-items: center; gap: 0;
  min-height: var(--tap-min-lg);
  background: var(--c-surface);
  border: 1px solid var(--c-line-control); border-radius: var(--r-pill);
}
.chip-group:hover { border-color: var(--c-accent); }
.chip-group:has(input:checked) { background: var(--c-accent-tint); border-color: var(--c-accent); }
.chip-group > .chip { border: 0; background: transparent; }

/* A SAVED entry's chips: what was recorded, not what can be tapped. No cursor, no hover, no tap
   floor -- a static label is not a target, so --tap-min would only make a read timeline taller
   on the phone this change exists to help.
   IT NO LONGER SHARES THE CONTROL'S LOOK, and the reversal is the point (2026-08-26). This rule
   used to keep the pill shape and the ticked chip's fill so that "the same event reads the same
   in both states"; with the editor open, one card then showed the same vocabulary twice in two
   pill styles that differed only by fill, and nothing said which four were the record and which
   fourteen were the buttons. A record is now a FLAT TAG -- squared to --r-2, no teal, hairline
   rather than a control border -- and a control is a pill. Two channels, so neither carries it
   alone. Do not re-unify them.
   --c-ink, not --c-ink-2: on --c-surface-2 that would be 4.29:1. */
.chiplist { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s-2); margin: 0; padding: 0; }
.chip--static {
  min-height: 0;
  padding: var(--s-1) var(--s-3);
  cursor: default;
  background: var(--c-surface-2);
  color: var(--c-ink);
  border-color: var(--c-line);
  border-radius: var(--r-2);
}
.chip--static::before { content: none; }
/* Every <small> in a read tag is INK, not a grey. On --c-surface-2 the greys do not clear
   1.4.3 at 13px: --c-ink-2 is 4.29:1 and --c-ink-3 is 3.88:1. So the count and the markers are
   separated by WEIGHT, not by colour -- which is also why the count carries a class of its own
   rather than being "the first <small>". It is the one number on this screen that moves money:
   Journal::ApplyToCase#accumulate multiplies it. */
.chip--static small { color: var(--c-ink); font-weight: 400; }
.chip--static .chip-qty { font-weight: 600; }

/* One saved entry, read. */
.jentry-read { display: flex; flex-direction: column; gap: var(--s-2); }
.jentry-when { margin: 0; font-size: var(--fs-75); color: var(--c-ink-2); }
/* common.separator carries ASCII spaces because it is also joined into a stored precision note,
   so the no-break has to happen here: neither the author after the timestamp nor a chip's count
   may start a line with a lone middot. */
.jentry-when > span, .chip--static small { white-space: nowrap; }
/* pre-wrap since 2026-08-31: the note is multi-line -- the paper timeline it replaces is a short
   paragraph plus a few lines -- and a <p> collapses those breaks. CSS rather than simple_format,
   because no HTML-producing helper belongs on free text an intervenant typed.
   overflow-wrap IS REQUIRED BESIDE IT, and not because pre-wrap causes overflow -- it does not, it
   only preserves breaks. The note had no wrap rule at all, so a single unbroken 200-character token
   (a pasted URL, a run-on address) already overflowed; above --bp-two-pane that is an internal
   scrollbar in the preview pane, but BELOW it the page scrolls sideways, which is the failure
   docs/build-state.md records on this very screen at 375px. Going 255 -> 1000 characters makes it
   materially likelier, so the fix ships with the widening. */
.jentry-note { margin: 0; font-size: var(--fs-200); font-weight: 500; white-space: pre-wrap; overflow-wrap: anywhere; }
/* The retired-chip line. `.hint` is styled ONLY as `.field .hint`, and this <p> is not in a
   .field, so it rendered at body size in full ink -- louder than the note above it. Scoped to
   the entry rather than unscoped: `billing/_exemption` uses a bare .hint for the sentence that
   says whether TPS and TVQ are being collected, and shrinking that to 12px micro-type would be
   a demotion, not a fix -- the same failure this file already records once, on the exemption
   label. */
.jentry .hint { font-size: var(--fs-50); color: var(--c-ink-3); }

/* The edit form, folded away. <details> rather than a controller so it works with JS off; the
   summary is the button, so the default disclosure triangle goes. */
.jentry-edit { margin-top: var(--s-4); border-top: 1px solid var(--c-line); padding-top: var(--s-4); }
/* The disclosure is secondary to the entry it belongs to, so it is smaller than a real button --
   still --tap-min, because a phone taps it. */
.jentry-edit > summary { display: inline-flex; list-style: none; padding: var(--s-1) var(--s-3); min-height: var(--tap-min); font-size: var(--fs-75); }
.jentry-edit > summary::-webkit-details-marker { display: none; }
.jentry-edit[open] > summary { margin-bottom: var(--s-4); }
/* Editing is a MODE, and the card has to show it: the wrapper becomes a panel recessed onto the
   page's own ground, so read content and editor are two zones instead of one run of 16px gaps. */
.jentry-edit[open] > .stack {
  background: var(--c-paper);
  border: 1px solid var(--c-line);
  border-radius: var(--r-2);
  padding: var(--s-4);
}
/* Recessing a surface is not free. --c-ink-3 is 4.6:1 on --c-surface and 4.24:1 on --c-paper, so
   the micro-type inside the panel comes up one step to --c-ink-2 (4.69:1). The add form on the
   index is still on --c-surface and keeps ink-3. */
.jentry-edit[open] .field label:not(:where(.btn)),
.jentry-edit[open] .chips > legend { color: var(--c-ink-2); }
/* The edit form and the delete form are wrapped in a .stack rather than the <details> being made
   one. `display: flex` on <details> DOES NOT stack its content: the content sits in a single
   anonymous box (::details-content), so the flex container gets two items -- the <summary> and
   that box -- and the forms inside it stay ordinary blocks. It measures convincingly, too: the
   summary/content gap appears, so it looks like it worked, while the delete button stays flush
   against save. Wrap; do not flex a <details>. */

/* The count, inside the pill: borderless, with a hairline of its own drawn as a ::before so the
   divider follows the group's state. Tabular figures because this is a quantity, not a label. */
.chip-count { display: flex; align-self: stretch; align-items: center; }
.chip-count::before { content: ""; align-self: stretch; width: 1px; background: var(--c-line-control); }
.chip-group:has(input:checked) .chip-count::before { background: var(--c-accent); }
.chip-count input {
  width: 3.25rem;
  min-height: var(--tap-min-lg);
  border: 0;
  background: transparent;
  text-align: center;
  font-size: var(--fs-200);
  font-variant-numeric: tabular-nums;
  border-start-end-radius: var(--r-pill);
  border-end-end-radius: var(--r-pill);
}

/* The count appears only on a SELECTED chip, as the prototype does. A number box beside an
   unticked chip reads on a phone as a quantity that is already counted. Pure CSS on the native
   checkbox state, so it follows the tap with no JavaScript at all -- and the input stays in the
   DOM, so ticking and setting a count is still one submit rather than two. */
.chip-group:not(:has(.chip input:checked)) .chip-count { display: none; }

/* THE TEXTAREA. The app had exactly one (admin/settings) until 2026-08-31 and now has three: the
   journal's note and the billing row's Note joined it that day. The shape is already right for all
   three, and by two different arms of the SAME selector list -- `.field textarea` for the two in a
   .field, and the bare `.control` for the billing one, which is in a <td> where a .field cannot go.
   Either way they inherit the 44px floor, the --c-line-control border, --r-input and --fs-200 --
   that last one being what keeps iOS from zooming the viewport on focus, which matters most on the
   two screens these are used from.

   resize: vertical, because the browser default is `both` and a user-widened textarea becomes its
   grid column's min-content contribution -- the same mechanism that pushed this screen into
   horizontal scroll at 375px once before (docs/build-state.md, .u-vh).

   field-sizing is autogrow with no JavaScript, and it is a genuine PROGRESSIVE enhancement rather
   than a fallback story: allow_browser :modern floors at Safari 17.2 / Chrome 120 and field-sizing
   shipped in Chrome 123 and later still in Safari, so it is inert on the intervenant's phone today.
   `rows` in the markup is the real behaviour there. min-height from the recipe does not fight it --
   it clamps the content size at the tap floor, which is wanted. @supports in the shape layout.css
   already uses for dvh. */
/* EVERY textarea, not just the ones in a .field -- the billing row's Note is the first one that
   is not (2026-08-31), and it reaches the same control recipe through the bare `.control` arm.
   textarea.control rather than .precision so the next such field is covered by this same edit,
   which is the rule the recipe above already states. */
.field textarea, textarea.control { resize: vertical; }
@supports (field-sizing: content) {
  /* field-sizing: content sizes to the CONTENT and ignores `rows` entirely, so an empty note
     opened at ONE line -- clamped up to the recipe's 44px tap floor and no further. The markup
     said rows="6" and the office's Chrome showed one row, while the intervenant's Safari, where
     field-sizing is inert, showed six. Same screen, two heights, and the taller one was on the
     device that types least.
     min-height is what field-sizing respects, so this is where the floor is said: three lines of
     text, plus the recipe's --s-2 padding on both edges and its 1px border on both, under
     box-sizing: border-box. It must stay in step with `rows` in the THREE templates that render
     one -- the journal's add and edit forms and the billing row -- which
     spec/guards/component_layout_spec.rb reads out of the markup and compares against this number.
     LITERALS rather than var(--s-2) * 2 and 2px, deliberately: a calc() holding a var() is a
     pending-substitution value that is not validated until computed-value time, so on an engine
     with field-sizing but without `lh` it would compute to min-height: auto -- below the tap floor
     -- instead of failing at parse time and leaving the recipe's 44px standing. The same trap
     layout.css records for dvh, and the reason 1rem is written where --s-2 * 2 is meant. */
  .jform textarea, .jentry-edit textarea, .lines .precision {
    field-sizing: content;
    min-height: calc(3lh + 1rem + 2px);
  }
}

/* One day of the timeline, the shape of the page this screen replaces: a date, then its times.
   The heading is the app's ordinary <h2>; only the gap between days is this rule's business. */
.jday + .jday { margin-top: var(--s-5); }
.jday-head { font-size: var(--fs-200); margin: 0 0 var(--s-3); }

/* A row of buttons. Used unscoped in seven places and, until now, styled in NONE of them --
   only `.jentry .actions` existed, so every other action row laid its buttons out as inline
   text and wrapped raggedly. print.css hides this class, which is a confidentiality rule
   (the invoice sheet carries no controls), so button rows must keep using it rather than
   .row. */
.actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3);
}
/* A card lays its children out in normal flow with no gap, so a row of buttons sitting after a
   <dl> or a table butts straight up against it.
   The CHILD combinator is deliberate and is also a trap: a row inside a <form> is
   `.card > form > .actions` and this does not reach it. That is correct -- a form gets its block
   spacing from `.stack` on the form itself -- but it means adding this class to a form-nested row
   buys no spacing, which is exactly how the journal's three forms ended up with 0px gaps. Do not
   broaden it to a descendant selector: `.jentry .actions` sets its own 12px and the billing rows
   would gain 24px they do not want. */
.card > .actions { margin-top: var(--s-5); }
/* The journal entry's row carries BOTH the save and the delete, so it closes the panel with a
   rule and pushes the destructive action to the far end. Colour was the only thing separating
   "Retirer l'entrée" from "Enregistrer" directly above it, and placement is the cheaper signal.
   :not(:only-child) so a row that is delete-only does not float it right against nothing. */
.jentry .actions { border-top: 1px solid var(--c-line); padding-top: var(--s-3); }
.jentry .actions > form:last-child:not(:only-child) { margin-inline-start: auto; }
/* .jentry .actions had margin-top: var(--s-3). Gone: its one match is the edit form's button
   row, and that form is now a .stack, so the 12px stacked on top of the stack's 16px and made
   that one gap 28px where every other gap in the card is 16px. Two spacing mechanisms on one
   axis -- the thing the .chips note above records as the cause of the original bug. */
/* A row mixing a labelled field with its submit. align-items: center would centre the button
   against the label AND the control together, floating it above the control it saves. */
.actions--field { align-items: flex-end; }

/* ONE COLOUR FOR TWO LEVELS, deliberately: there is no --c-alert and the palette was chosen
   without one (docs/design-system.md, Palette). Adding a red would reach btn--danger, the print
   sheet and every contrast check, so severity is carried by the glyph and the wording instead --
   which is a real constraint on both, not a shortcut. */
.notice--caution, .notice--alert {
  background: var(--c-caution-tint);
  color: var(--c-caution);
  border: 1px solid var(--c-caution);
}

/* The journal's report, moved onto the row it is about. "Appliquer à la facture" used to hand it
   back in a flash; the recompute now runs on every journal write, so there is no press left to
   attach a flash to -- and both states are properties of the line rather than events, so they are
   read here, where the office can act, and they survive a reload.
   --caution is the one that matters: the family is still being charged for something the journal
   no longer stands behind. The plain form is deliberately quiet, because once a dossier has been
   reviewed most lines are office-owned, and painting all of them as warnings buries the one that
   is not. */
.line-note {
  display: flex;
  align-items: flex-start;
  gap: var(--s-2);
  font-size: var(--fs-50);
  line-height: 1.35;
  color: var(--c-ink-3);
}
.line-note--caution { color: var(--c-caution); font-weight: 600; }
/* flex:none, or the 16px icon is squeezed to nothing by a long label in a narrow table cell. */
.line-note svg { flex: none; margin-top: .1em; }

/* THE PREVIEW PANE'S TWO TABS -- « Aperçu » and « Journal » -- and the timeline behind the second.
   Added 2026-08-28. See app/views/cases/billings/show.html.erb for why they are radios.

   display: none is the BASE state and the switch rules live only in the min-width query at the
   foot of this file. Below --bp-two-pane the pane is not a scrollport at all (layout.css keeps
   sticky, max-height and overflow inside that same query), so both documents simply stack and
   there is nothing for a tab to buy. It is also what keeps .totalbar's « Voir la facture » anchor
   working: it renders only below the breakpoint, where #billing_totals is never hidden.

   The strip carries its own background because it becomes sticky INSIDE the scrollport above the
   breakpoint, and content has to pass behind it, not through it. */
.pane-tabs {
  display: none;
  gap: var(--s-1);
  margin: 0;
  padding: 0;
  border: 0;
  background: var(--c-surface);
  border-bottom: 1px solid var(--c-line);
}
/* Shaped on .case-nav, which is the other row of tabs on this screen. --tap-min and not
   --tap-min-lg: the 48px floor is docs/design-system.md's rule for the JOURNAL chips, which are
   tapped on a phone in the field, and this pane is office-only. */
.pane-tab {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  min-height: var(--tap-min);
  padding: var(--s-2) var(--s-4);
  margin-bottom: -1px;
  border-bottom: 2px solid transparent;
  color: var(--c-ink-2);
  font-size: var(--fs-100);
  cursor: pointer;
  transition: color var(--dur-1) var(--ease-1);
}
/* Hidden the way .chip's input is, and for the same reason: the control keeps its state, its
   keyboard behaviour and its focus, and only its paint moves to the label. Auto offsets, so it
   stays at its static position and cannot extend the scrollport. */
.pane-tab input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.pane-tab:hover { color: var(--c-accent-dk); }
/* THREE channels, not colour alone (WCAG 1.4.1): ink, weight and the rule underneath. */
.pane-tab:has(input:checked) {
  color: var(--c-ink);
  font-weight: 600;
  border-bottom-color: var(--c-accent);
}
.pane-tab:has(input:focus-visible) {
  outline: 2px solid var(--c-ink);
  outline-offset: 2px;
  border-radius: var(--r-1);
}
/* The entry count. --c-ink-3 is the floor at 4.6:1 on --c-surface, which is where this sits. */
.pane-tab small { color: var(--c-ink-3); font-weight: 400; font-size: var(--fs-50); }

/* The journal, read. A list of entries, not a stack of cards -- .card frames a group and the
   group IS the timeline (docs/design-system.md). The rule between entries is what a card border
   would otherwise be doing, at a twelfth of the weight. */
.jtimeline { list-style: none; margin: 0; padding: 0; }
.jtimeline > li + li {
  margin-top: var(--s-4);
  padding-top: var(--s-4);
  border-top: 1px solid var(--c-line);
}

/* The flash's X, and ONLY the flash's: the state notices sharing .notice are facts that stay
   true after a click, so they render no button and a request spec pins that.
   margin-inline-start: auto to the far end -- the same device as `.jentry .actions`' last form,
   and unrelated to the `.card > .actions` note above, which is about a selector's reach.
   Negative block margin so a 24px target does not make the notice taller than its one line of
   text, and margin-top: 0 on the glyph because `.notice svg` nudges icons down 2px for the
   text baseline -- correct for the leading icon, wrong for one centred in a button. */
.notice-dismiss {
  margin-inline-start: auto;
  margin-block: -4px;
  margin-inline-end: calc(var(--s-2) * -1);
  align-self: flex-start;
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  border: 0;
  border-radius: var(--r-1);
  background: none;
  color: inherit;
  cursor: pointer;
}
.notice-dismiss svg { margin-top: 0; }
.notice-dismiss:hover { background: rgb(0 0 0 / .07); }

/* The gap is CONDITIONAL, with the padding, and that is not tidiness: both flash-regions are
   `display: contents` when empty, so the grid still had two zero-height rows -- and a 12px gap
   between them. The empty flash was therefore 12px tall on every authenticated screen in the app,
   measured 2026-08-27, and it pushed the billing preview pane 12px below what --app-chrome-h
   accounts for. Nothing else was affected visibly, which is why it lasted. */
.flash { display: grid; }
.flash:has(.notice) { gap: var(--s-3); padding: var(--s-3) var(--s-6) 0; }
/* Zero-height when empty: a live region has to exist BEFORE its content to be announced, so it
   cannot be display:none -- that removes it from the accessibility tree, which is the whole
   thing being preserved. */
.flash-region:empty { display: contents; }

/* Data table */
.table th {
  text-align: left;
  padding: var(--s-2);
  font-size: var(--fs-50);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--c-ink-3);
  border-bottom: 1px solid var(--c-line);
}
.table td { padding: var(--s-3) var(--s-2); border-bottom: 1px solid var(--c-line-soft); vertical-align: middle; }
.table tbody tr:last-child td { border-bottom: 0; }
/* .r right-aligns a cell. .num sets tabular figures (base.css) and does NOT align -- the two
   were used interchangeably, so a contract number looked like a numeric column and sat left.
   Money takes both. A cell holding a button takes .cell-action, not .r. */
.table .r { text-align: right; }
.table .cell-action { text-align: right; width: 1%; white-space: nowrap; }
/* position: relative is load-bearing, not decoration. overflow-x alone clips ordinary flow
   content but NOT an absolutely positioned descendant: a static container is not its containing
   block, so the descendant escapes the clip and lays out against the initial containing block.
   The action columns carry <th><span class="u-vh">, and those spans sat at their static
   position inside a table wider than the viewport -- extending the PAGE's scroll area by up to
   272px at 375px wide, invisibly, on every screen with a table.

   Since 2026-08-27 the invoice preview uses this class too, and it is the same hazard rather than a
   coincidence: the invoice's <caption class="u-vh"> is absolutely positioned (invoice.css), and the
   document overflows horizontally at narrow widths because it is sized in pt for paper. So the name
   is a little narrow now -- the class means "a horizontal scroller that contains its own
   visually-hidden descendants", and a second copy of two declarations under a second name would be
   worse. docs/design-system.md states the rule generally, not per-table. */
/* overscroll-behavior-X only, and only here. Panning a wide table or the invoice to its left end
   chains to the document's horizontal overscroll, which on iOS is the back/forward navigation
   gesture -- the standard remedy, and the reason every iOS carousel carries it. The Y axis is
   deliberately left to chain: that is what the preview pane's own removal of `contain` was for. */
.table-scroll { overflow-x: auto; overscroll-behavior-x: contain; position: relative; }

/* An identifier beside the thing it identifies -- "Facture 2026-001" in the audit trail's Objet
   column. Never broken across lines: "Facture 2026-" / "001" reads as two facts. */
.value--muted.num, td .value--muted { white-space: nowrap; }

/* ABSENCE. Italic and recessed because there is nothing there. Never put live data in this
   class -- use .value--muted, which is the same recession without the italic claim that the
   cell is empty. */
.empty { font-size: var(--fs-75); color: var(--c-ink-3); font-style: italic; }

/* invoices/_addressee is shared with the invoice document, where its <p> margins are wanted. In
   a <dd> they are not: the dossier's payer rows gained a blank line above every address. */
.value--plain > p { margin: 0; }

/* Real data that is simply secondary. */
.value--muted { font-size: var(--fs-75); color: var(--c-ink-2); }

.errors { list-style: none; margin: 0; padding: 0; }
.errors li { color: var(--c-caution); font-size: var(--fs-75); }

/* A plain list of past values. Shares the shape of .errors and none of its alarm: the catalog
   price history was rendering in the error colour, so a routine record of Serge changing a
   price read as a fault. */
.plainlist { list-style: none; margin: 0; padding: 0; }
.plainlist li { color: var(--c-ink-2); font-size: var(--fs-75); padding: var(--s-1) 0; }

/* The catalog's price cell: input + save button on one line, right-aligned as a unit. The <th>
   above it is .r, and with the cell laying its two children out in normal flow the header sat to
   the RIGHT of the input it labels rather than over it. */
/* flex-wrap is for the refusal message alone -- the input and the button must stay on one line.
   min-width is what holds them there: the price cell has no width of its own, the label columns
   are greedy, and .control's padding widened the field enough that the button wrapped under it
   on every one of the 33 rows. An auto-layout table honours a min-width on the cell's content,
   so this claims the space rather than reacting to losing it; .table-scroll absorbs the cost on
   a narrow viewport, which is the same trade .cell-events makes. */
.cell-price-edit { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: var(--s-2); min-width: 16rem; }
/* The refusal message takes the whole row under the input it rejected. Without flex-basis it
   is a third item on the same line and squeezes the field it is about. */
.cell-price-edit .field-error { flex-basis: 100%; justify-content: flex-end; }

/* The events column. A max-width so two long pills stack instead of widening the table into
   horizontal scroll -- .chiplist can only wrap against a bound. */
.cell-events { max-width: 22rem; }

/* A price field. Narrow and right-aligned, with tabular figures so a column of prices lines
   up. A class rather than an inline style: the CSP blocks style attributes (a nonce cannot
   apply to them), and an inline width is a design-token bypass. */
.input--price {
  /* 7.5rem since .control arrived: its var(--s-3) padding each side took 24px off the old
     6.5rem, and "1 250,00" at --fs-200 tabular no longer cleared what was left. */
  width: 7.5rem;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* Billing line rows */
/* TOP, always, and never the `middle` .table td gives them. A checked row grows DOWNWARDS --
   Précision and the description toggle appear under the label -- so a centred cell drifts down the
   row: the checkbox ends up beside the Précision field rather than beside the label it governs,
   and the quantity, price and amount sit a line below the item they belong to.
   All four together, not the checkbox alone: aligning one and leaving its neighbours centred is
   what makes the row read as broken rather than merely low. The narrow layout already says this
   with `align-self: start` in the container query below, and the two must not disagree.
   Unchecked rows are one line tall, so this changes nothing about them. */
.lines .cell-check,
.lines .cell-qty,
.lines .cell-price,
.lines .cell-amount { vertical-align: top; }
.lines .cell-check { width: var(--tap-min); }
/* Every one of these is sized to its widest REAL value plus padding, and nothing more -- the
   width they give up goes to the item label, which is the only column with no natural width and
   the only one that wraps. Measured at --fs-200 with tabular figures:
     qty     three digits                       -> 68px of content box
     price   « 2 500,00 »                       -> 104px
     amount  « 10 000,00 $ »                    -> 88px
   The three money columns also drop to --s-1 of inline padding: they are right-aligned numerals,
   so the figures separate themselves and 8px each side was buying nothing. */
.lines .cell-qty { width: 4.75rem; }
.lines .cell-price { width: 7.25rem; }
.lines .cell-amount { width: 6.5rem; }
.lines .cell-check,
.lines .cell-qty,
.lines .cell-price,
.lines .cell-amount { padding-inline: var(--s-1); }

.lines tbody tr.is-on { background: var(--c-accent-tint); }

/* An unselected row recedes by COLOUR and by CONTENT, not by opacity: a blanket opacity: .4 drops
   contrast below AA while producing the same visual read.
   The second half of that pair is new on 2026-08-28. An unchecked row used to render a quantity
   input, a price box and « 0,00 $ » -- 23 of 33 rows carrying three controls each that mean
   nothing until the row is checked, and reading as interactive while they did. Now it carries the
   checkbox, the label and the catalog price as text, so the recession is the absence itself.
   The rule that greyed those inputs went with them: an unchecked row has none, and .value--ro left
   this screen when the price became editable for the office. */
.lines tbody tr[data-state="off"] .item-label { color: var(--c-ink-2); }

/* --c-ink-2 on --c-accent-tint is 4.50:1 -- AT the AA line, not above it (see the .combo note
   above) -- and --c-ink-3 is below it. A SELECTED row paints that tint, so the 12px uppercase
   captions inside one step up to full ink rather than inheriting the .field label colour. */
.lines tbody tr.is-on .cell-caption { color: var(--c-ink); }

.item-label { display: flex; flex-direction: column; gap: 1px; }
.item-label small { color: var(--c-ink-3); font-size: var(--fs-50); }

.check-label {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--tap-min);
  min-height: var(--tap-min);
  cursor: pointer;
}
.lines input[type="checkbox"] { width: 24px; height: 24px; accent-color: var(--c-accent); cursor: pointer; }

/* NO SPINNER. `input[type=number]` draws its own − / + at the right edge, which is the affordance
   this field was asked not to have -- and the browser's is worse than the one that was removed:
   roughly 13px of target, well under the 44px everything else on this screen now meets, and it
   reserves the space that was pushing the value off the right edge despite `text-align: right`.
   The type stays `number`: it is what carries min/step and the numeric keyboard, and the server
   validates `only_integer, >= 0` regardless. */
/* An explicit width, not 100%: a quantity is one or two digits on nearly every line, and a field
   sized to its cell read as an invitation to type something long. 4rem leaves ~38px of content
   box at --fs-200, which is three digits comfortably and four at a squeeze -- past that the value
   scrolls inside the field rather than clipping. Set on the INPUT rather than the cell so the
   narrow card layout gets the same size; there .cell-qty is width: auto and would otherwise let
   the field stretch across the row. */
.lines .qty { width: 4rem; text-align: right; appearance: textfield; }
.lines .qty::-webkit-outer-spin-button,
.lines .qty::-webkit-inner-spin-button { appearance: none; margin: 0; }
.lines .precision { width: 100%; }

/* Précision and the description toggle. Its own class rather than .stack: the container query
   below has to place this block after quantity and price, and .stack is on half the app. */
.row-detail { display: flex; flex-direction: column;  margin-top: var(--s-2); }
.showdesc {
  display: inline-flex;
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  min-height: var(--tap-min);
  font-size: var(--fs-75);
  color: var(--c-ink-2);
  cursor: pointer;
}
.showdesc input { width: 18px; height: 18px; }

.amount { font-weight: 600; }
.cell-caption { display: none; }

/* The billing screen's save status. Visible as well as announced: the office is typing prices
   into a form that saves itself, and "did that take?" is a fair question to be able to answer
   by looking.

   IT NOW ACTUALLY TAKES NO SPACE WHEN EMPTY, which this comment claimed while the rule below
   reserved 1.25rem unconditionally. On the billing screen the region is a child of .pane--form,
   a .stack -- so the reserved 20px plus one 16px flex gap put a 36px blank band above the <h1>.

   `position: absolute` and NOT `display: none`. This is a role="status" live region: one that is
   absent from the accessibility tree when its text arrives appears WITH its message rather than
   mutating, and that is the case screen readers do not announce -- the exact failure
   shared/_save_status.html.erb exists to prevent, and the reason the server `update`s this element
   instead of replacing it. Out of flow keeps `display: block` and the tree presence unchanged; only
   the visibility changes. It also drops the element out of the flex line entirely, which
   min-height: 0 would not: a zero-height flex item still consumes its gap.

   No clip-path or !important, unlike .u-vh (base.css): those exist for a hidden CONTROL that still
   has intrinsic size. With no content and width: auto this box shrink-to-fits to 0x0, so there is
   nothing to clip -- and nothing to intercept a click, which is why min-height moved off the base
   rule. Left there it would hold an invisible 20px box open over the heading. */
.save-status {
  font-size: var(--fs-75);
  color: var(--c-ink-2);
}
.save-status:not(:empty) { min-height: 1.25rem; color: var(--c-ok); }
.save-status:empty { position: absolute; }

/* ---- The billing rows in a NARROW PANE ---------------------------------------------------
   @container, not @media, since 2026-08-28, and the difference is not cosmetic. Above
   --bp-two-pane the form pane is roughly half the window: at 1080 this table was being asked to
   fit five columns into ~500px and every label wrapped to two lines, while a viewport query saw
   a wide screen and left it alone. The question was always "how wide is the PANE", and a media
   query cannot ask it. The container is declared on .pane--form in layout.css.
   The literal is --bp-row-card, recorded in tokens.css -- a container query cannot read a custom
   property either, so keep the two in step by hand. It crosses at roughly 684/685 of viewport in
   one column and 1313/1315 in two; those are the widths to verify at.
   Where @container is unsupported the whole block is dropped and the row stays a table inside
   .table-scroll, which scrolls. Degraded, not broken.

   ONE markup tree. The <tr> and its <td>s are restyled, never duplicated: the row has exactly
   one Turbo target (row_<code>), the inputs are bound to a shared form by the `form` attribute,
   and a second copy would both go stale and submit every field twice -- Rack keeps the last, so
   the off-screen copy would win.

   No ::before { content: "Prix unitaire" }. French in a stylesheet reaches the client, is
   invisible to translation review, and is scanned by no guard here. The per-input labels
   already exist as real translated <label>s -- they are .u-vh for sighted users on a wide
   screen -- so the narrow layout simply stops hiding them. The checkbox's own <span class=u-vh>
   stays hidden: it repeats the item label, which is visible right beside it.

   .is-on, [data-state="off"] and [aria-busy] all key off `.lines tbody tr` and keep working,
   because the element is still that row. */
@container form (max-width: 620px) {
  .lines thead { display: none; }

  .lines tbody tr {
    display: grid;
    /* minmax(0, 1fr) on the middle track and not a bare 1fr: the auto floor is min-content, and
       a long item label would push the amount column past the card's right padding and clip it. */
    grid-template-columns: var(--tap-min) minmax(0, 1fr) auto;
    column-gap: var(--s-3);
    row-gap: var(--s-2);
    padding: var(--s-3) 0;
    border-bottom: 1px solid var(--c-line);
    /* .row-submit is absolutely positioned until focused; without a positioned ancestor the
       revealed button lands elsewhere on the page. It is the only save path with JS off. */
    position: relative;
  }
  .lines tbody tr > td { display: block; border: 0; padding: 0; grid-column: 2; }
  /* The money columns' own inline padding, undone. It is (0,2,1) against this rule's (0,1,3), so
     `padding: 0` above does NOT beat it -- and 4px on three cells and not on the label is a 4px
     misalignment between the label and every caption under it. Same specificity, later. */
  .lines .cell-check,
  .lines .cell-qty,
  .lines .cell-price,
  .lines .cell-amount { padding-inline: 0; }

  /* align-self: start, or the checkbox centres itself against a tall expanded row and drifts
     away from the label it governs. It used to span the whole card (grid-row: 1 / span 5), which
     is the same detachment written the other way round. */
  .lines tbody tr > td:first-child { grid-column: 1; grid-row: 1; align-self: start; }

  /* display: contents lets the label cell's THREE children be placed independently, which is what
     puts Précision BELOW quantity and price instead of above them -- it lives inside cell 2, and
     cell order alone cannot express that. `order` moves it last in auto-placement without touching
     the DOM, so there is still one markup tree and one copy of every input.
     .row-submit is unaffected: it is in the AMOUNT cell, so its containing block is still the
     <tr> above. */
  .lines .cell-label { display: contents; }
  .lines .item-label { grid-column: 2; grid-row: 1; }
  .lines .line-note { grid-column: 2 / -1; }
  /* COLUMN 2, not 1 (2026-08-31). Every other child of the row body starts there -- the base
     `td { grid-column: 2 }` puts the quantity and the price on it, .item-label names it, and
     .line-note directly above says `2 / -1`. .row-detail was the one exception, so « Afficher dans
     la facture » and the Note started under the row's CHECKBOX while the label, the provenance
     note and both money fields were indented past it. That is the detachment the
     `align-self: start` note four rules up already records in the other direction: a control
     drifting away from the label it governs.
     `/ -1` is kept, so the block still runs to the amount column's edge and the Note keeps every
     pixel it can have on this side of the indent. */
  .lines .row-detail { order: 1; grid-column: 2 / -1; margin-top: 0; }

  /* The amount stays beside the label it belongs to, checked or not. */
  .lines .cell-amount { grid-column: 3; grid-row: 1; align-self: start; white-space: nowrap; }

  /* An unchecked row has no quantity and no price control at all, so the two cells are empty
     boxes taking a grid row each. */
  .lines tbody tr[data-state="off"] .cell-qty,
  .lines tbody tr[data-state="off"] .cell-price { display: none; }
  /* Label beside the control rather than above it: stacked, the two of them cost four lines per
     billed row, and 44px controls are already 15px taller than the defaults they replaced.
     WRAPPING, though, and that is not decoration. On a 390px phone the card's content box is
     326px, of which the checkbox and the amount take 130 -- so beside a 5.5rem caption the price
     field and its « $ » do not fit, and .table-scroll absorbs the overflow rather than the page:
     no horizontal page scroll, and a control unreachable without panning the table. Wrapping puts
     the caption back above its control at exactly the width where it stops fitting, and nowhere
     wider. (The quantity's stepper was the first thing to overflow here and is gone; the price
     field on its own still crosses the same line.) */
  .lines tbody tr.is-on .cell-qty,
  .lines tbody tr.is-on .cell-price {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s-1) var(--s-3);
  }
  .lines tbody tr.is-on .cell-qty .cell-caption,
  .lines tbody tr.is-on .cell-price .cell-caption { flex: 0 0 5.5rem; }

  .lines .cell-qty, .lines .cell-price, .lines .cell-amount { width: auto; }
  .lines td.r { text-align: left; }
  .lines .cell-amount { text-align: right; }

  /* Short captions, shown only here. The full <label> stays visually hidden and keeps naming
     the item, so the accessible name is unchanged at every width. */
  .cell-caption {
    display: block;
    font-size: var(--fs-50);
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--c-ink-3);
  }
  /* Except the amount's: it needs no name sitting directly beside its own figure. */
  .lines .cell-amount .cell-caption { display: none; }

  .lines .amount { font-size: var(--fs-200); }
}

.section-total {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin-top: var(--s-4);
  padding-top: var(--s-3);
  border-top: 2px solid var(--c-line-strong);
}
.section-total .k {
  font-size: var(--fs-75);
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--c-ink-2);
}
.section-total .v { font-family: var(--font-serif); font-size: var(--fs-300); }

/* Visible only on focus. With JS the row autosaves; this keeps the row operable by keyboard and
   with JS off, without putting 33 buttons on the screen. */
.row-submit {
  position: absolute;
  left: -9999px;
  min-height: auto;
  padding: var(--s-1) var(--s-2);
  font-size: var(--fs-50);
}
.row-submit:focus,
.row-submit:focus-visible {
  position: static;
  left: auto;
}

.lines tbody tr[aria-busy="true"] .amount { color: var(--c-ink-3); }

/* ---- The running total, at the foot of the form pane -------------------------------------
   Sticky because the office edits 33 rows and the figure they are editing towards was three and
   a half screens below the fold on an iPad and fourteen on a phone. So was #billing_status, the
   only thing that says a save landed -- it lived under the <h1> and was off-screen for the whole
   of the work it was reporting on.

   Inside .pane--form, which is what keeps it off paper: print.css hides that pane entirely, so
   the bar needs no selector of its own there and print_confidentiality_spec is untouched.

   The bar itself is STATIC markup and only #billing_bar inside it is replaced. That split is not
   tidiness: #billing_status is a role="status" live region, and a region recreated with its
   message already in it is not announced -- which is the whole reason shared/_save_status.html.erb
   renders the CONTENT and the server `update`s rather than replaces. Putting it inside the node
   Turbo swaps on every keystroke would undo that silently.

   Not on .pane--preview, and nothing here is: spec/guards/component_layout_spec.rb refuses
   position: sticky on that pane outside the min-width query, and this is a different element. */
.totalbar {
  position: sticky;
  bottom: 0;
  z-index: var(--z-popover);
  margin: var(--s-4) calc(var(--s-6) * -1) calc(var(--s-5) * -1);
  padding: var(--s-3) var(--s-6);
  /* The home indicator on an iPhone sits over the last ~34px of the viewport. */
  padding-bottom: max(var(--s-3), env(safe-area-inset-bottom));
  background: var(--c-surface);
  border-top: 2px solid var(--c-line-strong);
  box-shadow: 0 -1px 2px rgb(20 40 40 / .05), 0 -8px 24px rgb(20 40 40 / .07);

  /* ONE GRID over children of TWO wrappers, and the wrappers are dissolved into it. They cannot
     be merged in the markup: #billing_bar is replaced on every keystroke and #billing_status is a
     live region that must survive, so they have to be separate elements -- but the layout wants
     the save state tucked under the section chips, not on a line of its own. `display: contents`
     on both makes their five children direct grid items here, which is the same technique the
     billing row uses on its label cell.
     Two rows and four columns: chips over status on the left, and the total then the buttons on
     the right, each spanning both rows. It was three stacked lines before -- 120px of a phone's
     viewport spent on a bar. */
  display: grid;
  grid-template-columns: 1fr auto auto auto;
  align-items: center;
  column-gap: var(--s-4);
  row-gap: var(--s-1);
}
#billing_bar, .totalbar-main { display: contents; }

/* Row 1, the full width, and nothing else on it. The chips need ~420px against the bar's ~650 of
   content, so anything sharing their row squeezes them into wrapping -- which costs a line rather
   than saving one. Spanning every column is what keeps them to one. */
.totalbar .sections {
  grid-column: 1 / -1;
  grid-row: 1;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--s-2) var(--s-4);
}
.totalbar .sub-k {
  display: inline-flex;
  align-items: baseline;
  gap: var(--s-2);
  white-space: nowrap;
  font-size: var(--fs-50);
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--c-ink-3);
}
.totalbar .sub-k b { font-size: var(--fs-75); letter-spacing: 0; color: var(--c-ink-2); font-weight: 600; }

/* Directly under the « A » chip, and deliberately the lightest thing in the bar: it reports on
   the save rather than on the money, so it must not read as another figure. --fs-50 and a normal
   weight against the chips' 600 is what separates them. */
.totalbar .save-status {
  grid-column: 1;
  grid-row: 2;
  font-size: var(--fs-50);
  font-weight: 400;
}

/* The caption and the figure on ONE BASELINE, in one column. Split across two rows the caption
   right-aligned to the bar's edge while the amount right-aligned to its own column, so the label
   sat above the « Émettre » button rather than above the number it names. */
.totalbar .grand {
  grid-column: 2;
  grid-row: 2;
  justify-self: end;
  display: flex;
  align-items: baseline;
  gap: var(--s-3);
}
.totalbar .grand-k {
  font-size: var(--fs-75);
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--c-ink-3);
  white-space: nowrap;
}
/* Sans, not --font-serif. Georgia carries only oldstyle figures and ignores
   font-variant-numeric: lining-nums (tokens.css), so a serif total sits at three different
   heights across « 6 553,58 ». .section-total .v has the same problem and is not in scope here. */
.totalbar .grand-v { font-size: var(--fs-300); font-weight: 700; }

.totalbar .preview-link { grid-column: 3; grid-row: 2; }
/* The bar's own .actions must not inherit the card rule's 24px top margin -- `.card > .actions` is
   a child combinator and this is not a card, but the intent is worth stating where someone would
   otherwise add one. */
.totalbar .actions { grid-column: 4; grid-row: 2; margin-top: 0; }

/* Only below --bp-two-pane. Above it the preview is already on screen beside the form, and a
   link to something visible is noise. An anchor rather than a dialog: a <dialog> means rendering
   the preview twice, so two #billing_totals ids, and Turbo replaces the first only. */
@media (min-width: 1024px) {
  .totalbar .preview-link { display: none; }

  /* THE TABS, and only here. Below this width the strip is display: none and both panels stack --
     see .pane-tabs above.

     position: sticky is REQUIRED, not polish. .pane--preview is itself sticky AND overflow: auto
     (layout.css), so a strip in normal flow scrolls out of its own scrollport: read to the foot of
     the journal and the way back to the invoice has gone with it. This is sticky INSIDE the pane.
     Nothing in the pane is focusable below the strip, so it cannot obscure a focus ring
     (WCAG 2.2 2.4.11); if that ever changes, the answer is scroll-padding-top on the pane.

     spec/guards/component_layout_spec.rb does NOT see these: it scans layout.css only, and only
     for selectors naming .pane--preview. The corollary is worth knowing before anyone adds an
     overflow or a max-height to #billing_journal -- the guard would not see that either.

     The sibling combinator is what makes the switch survive Turbo: #billing_totals is replaced on
     every autosave, and a stylesheet rule re-applies to whatever replaced it. Specificity here is
     (2,2,0) -- :has() takes its most specific argument -- which print.css's reset has to beat with
     !important, not with an id. That dependency is written down at both ends. */
  .pane-tabs {
    display: flex;
    position: sticky;
    z-index: 1;
    /* FULL BLEED to the scrollport's edges, and it is not cosmetic. .pane's own padding is
       var(--s-5) var(--s-6), so a strip that starts inside it leaves a 24px band above and a 32px
       band each side with no background of their own -- and entries scroll THROUGH those bands in
       plain sight, a chip surfacing above the tabs it is supposed to pass behind. Seen in a
       1440x560 viewport, which is what makes this pane short enough to scroll at all.
       The negative margins pull the strip onto the pane's padding box; padding-inline puts the
       labels back where the pane's own inset had them. */
    margin: calc(var(--s-5) * -1) calc(var(--s-6) * -1) 0;
    padding-inline: var(--s-6);
    /* NEGATIVE, and it is NOT a nudge -- it is the margin above, restated. Sticky clamps the
       MARGIN box to the sticky rectangle, so with `top: 0` a -24px margin-top puts the border box
       24px BELOW the scrollport edge: the strip pins one padding's width down and the band above it
       is the very gap the bleed exists to close. The two values are one decision and must move
       together; spec/guards/component_layout_spec.rb pins them to each other. */
    top: calc(var(--s-5) * -1);
  }
  .pane-tabs:has(#pane_tab_journal:checked) ~ #billing_totals { display: none; }
  .pane-tabs:has(#pane_tab_invoice:checked) ~ #billing_journal { display: none; }
}

@media (max-width: 640px) {
  /* 32px of a 375px screen is 17% of it spent on gutters. The app's ONE phone rule -- there is no
     breakpoint below 640px -- and it reaches every pane, the journal being the reason.
     .pane's own declaration stays in layout.css untouched, which is what keeps
     spec/guards/component_layout_spec.rb (it scans layout.css only) and .pane-tabs' negative-margin
     bleed (inside a min-width query that cannot co-apply with this one) correct.
     KNOWN AND INTENDED: .totalbar restates .pane's inline padding as a negative margin just below,
     so on a phone it stops being inset 16px from the pane edge and goes full-bleed to the viewport,
     border-top and shadow at the screen edge. That reads better on the billing screen, and it is a
     decision rather than a side effect. */
  .pane { padding-inline: var(--s-4); }

  .totalbar { margin-inline: calc(var(--s-4) * -1); padding-inline: var(--s-4); }

  /* Two columns rather than four: the per-section subtotals are already at the foot of each
     section card and inside the invoice, so they are what gives way on a phone, and the two
     buttons take a full-width row of their own rather than being squeezed beside the total. */
  .totalbar { grid-template-columns: 1fr auto; column-gap: var(--s-3); }
  .totalbar .sections { display: none; }

  .totalbar .grand { grid-column: 1; grid-row: 1; justify-self: start; }
  .totalbar .grand-k { display: none; }
  .totalbar .grand-v { font-size: var(--fs-400); line-height: 1.1; }
  .totalbar .save-status { grid-column: 2; grid-row: 1; justify-self: end; }
  .totalbar .preview-link { grid-column: 1; grid-row: 2; }
  .totalbar .actions { grid-column: 2; grid-row: 2; }
  .totalbar .actions form, .totalbar .actions .btn, .totalbar .preview-link { width: 100%; }
}
