/* ============================================================
 * Features — login, projects, chat, estimates, context review
 * Everything here composes the components in 03 and the layout
 * in 04; no new colours, radii or spacing values are introduced.
 * ============================================================ */

/* ── Public login ───────────────────────────────────────── */

/* Centred in the window, not in seven tenths of it. `align-content: center`
 * only ever centred this block inside its own min-height, and at 70vh of a
 * 900px window that box ended 270px above the fold: the wordmark, the tagline,
 * the button and the language switcher sat between y=215 and y=495 with 400px
 * of nothing under them. Four elements and one thing to do, and the screen
 * read as though the content had fallen upwards.
 *
 * `dvh` and not `vh`: on a phone `vh` is the height with the browser chrome
 * retracted, so a 100vh block is taller than what is actually visible and the
 * page scrolls by the height of the address bar with nothing to scroll to.
 * The dynamic unit tracks the chrome instead.
 *
 * Minus the shell's own padding, because this box sits inside main.app-shell
 * and would otherwise be the window's full height plus 88px of it. One value
 * and not a pair: below 1024 the shell's padding is --space-6 + --space-10
 * rather than --space-10 + --space-12, so this over-subtracts by 24px there
 * and the block sits twelve pixels above true centre. That is the safe
 * direction - it can only leave the box shorter than the space, never taller -
 * and twelve pixels is not worth a second breakpoint. Measured at 390x844:
 * centre 402 against the window's 422, and no vertical overflow.
 *
 * And `min-height`, never `height`: on a short window - a laptop with the
 * inspector open, a phone in landscape - the content is taller than the space
 * and the box has to grow and let the page scroll rather than clip the button
 * off the bottom. Measured at 1440x240 the document grows to 368 and scrolls,
 * with the button still whole. */
.public-login {
  display: grid;
  place-items: center;
  align-content: center;
  gap: var(--space-6);
  min-height: calc(100dvh - var(--space-10) - var(--space-12));
  text-align: center;
}

.public-login h1 {
  font-size: var(--text-h1);
}

.public-login__tagline {
  max-width: 34rem;
  color: var(--text-secondary);
}

.public-login .locale-switcher {
  justify-items: center;
  justify-self: center;
}

/* ── Dev login ──────────────────────────────────────────── */

.dev-session-row {
  display: grid;
  gap: var(--space-4);
  padding: var(--space-4) 0;
  border-bottom: 1px solid var(--border-default);
}

.dev-sessions-card .resource-list > li:last-child {
  border-bottom: 0;
}

.dev-sessions-card .resource-list > li:last-child.dev-session-row {
  border-bottom: 0;
}

@media (min-width: 641px) {
  .dev-session-row {
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
  }
}

/* ── Projects ───────────────────────────────────────────── */

.project-list-section {
  display: grid;
  gap: var(--space-4);
}

.project-page {
  gap: var(--space-6);
}

.project-summary-card {
  gap: var(--space-2);
}

/* ── Markdown-rendered content ──────────────────────────── */

/* minmax(0, 1fr) and min-width: 0 because a grid track and a grid item both
 * size to their content by default: one long line in a <pre> made the track
 * as wide as the line, the <pre> never had anything to scroll, and the whole
 * page grew sideways to 2322px - the reader's own messages and the header's
 * actions ended up off screen. overflow-wrap for the same thing without a
 * <pre>: a long URL or token in a paragraph. */
.project-context-markdown,
.message-card__markdown {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-3);
  min-width: 0;
  color: var(--text-primary);
  overflow-wrap: anywhere;
}

.project-context-markdown > *,
.message-card__markdown > * {
  margin: 0;
  min-width: 0;
}

.project-context-markdown h1,
.project-context-markdown h2,
.project-context-markdown h3,
.message-card__markdown h1,
.message-card__markdown h2,
.message-card__markdown h3 {
  margin: 0;
  font-size: var(--text-h4);
  letter-spacing: var(--tracking-normal);
}

/* Folded sections (ChatMessagesHelper#fold_sections!, assistant replies
 * only): each <details> becomes the grid item .message-card__markdown was
 * gapping directly, so its own content needs the same grid+gap treatment
 * inside, or a folded reply's paragraphs and lists would collapse back
 * together with no spacing once collapsed content is a details child
 * instead of a markdown-root child. */
.message-card__markdown details {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-3);
}

.message-card__markdown details > * {
  margin: 0;
  min-width: 0;
}

.message-card__markdown summary {
  cursor: pointer;
  font-weight: var(--weight-medium);
}

/* Only the disclosure triangle should be clickable-looking, not the whole
 * heading line turning into something that reads as a link or button. */
.message-card__markdown summary > * {
  display: inline;
}

/* How much a folded section holds ("3 puntos"), after its title. Secondary
 * text and not a pill: it is a caption on the heading, not a status. */
.message-card__section-count {
  margin-inline-start: var(--space-2);
  font-size: var(--text-caption);
  font-weight: var(--weight-book);
  color: var(--text-secondary);
}

.project-context-markdown ul,
.project-context-markdown ol,
.message-card__markdown ul,
.message-card__markdown ol {
  display: grid;
  gap: var(--space-1);
  padding-left: var(--space-5);
}

.project-context-markdown code,
.message-card__markdown code {
  padding: 1px var(--space-1);
  border-radius: var(--radius-xs);
  background: var(--bg-subtle);
}

.project-context-markdown pre,
.message-card__markdown pre {
  overflow-x: auto;
  padding: var(--space-3);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-xs);
  background: var(--bg-canvas);
}

/* A model reply or a pasted table can be wider than the message column;
   without this it forces the whole page to scroll horizontally instead of
   just the table. */
.project-context-markdown table,
.message-card__markdown table {
  display: block;
  overflow-x: auto;
  max-width: 100%;
}

pre.context-block {
  margin: 0;
  overflow-x: auto;
  white-space: pre-wrap;
  word-break: break-word;
}

/* ── LLM usage ──────────────────────────────────────────────
 * Deliberately a plain definition list rather than a grid of
 * hero-metric tiles: this is invoice data, not a headline. */

.llm-usage-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: var(--space-4) var(--space-8);
}

.llm-usage-item {
  display: grid;
  gap: var(--space-1);
  align-content: start;
}

.llm-usage-value {
  font-size: var(--text-body);
  font-variant-numeric: tabular-nums;
}

.llm-usage-note {
  font-size: var(--text-body-sm);
  color: var(--text-secondary);
}

/* The total, readable with the card closed. Secondary colour so the
 * summary still reads as the control that opens the card. */
.llm-usage-summary {
  margin-left: var(--space-2);
  font-size: var(--text-body-sm);
  color: var(--text-secondary);
}

/* ── Estimates: panel and meta ──────────────────────────── */

.project-estimates-panel,
.estimate-card {
  gap: var(--space-6);
}

/* The Summary tab's pointer to the latest estimate: same meta row as the
 * panel, then the one number it is there to report. */
.latest-estimate-card__total {
  font-size: var(--text-h4);
  font-weight: var(--weight-strong);
}

.estimate-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3) var(--space-6);
}

/* Status carries a badge shape plus a hue, never hue alone. */
.estimate-status {
  display: inline-flex;
  align-items: center;
  min-height: var(--badge-h);
  margin: 0;
  padding: 2px 10px;
  border: 1px solid var(--border-default);
  border-radius: var(--radius-full);
  background: var(--bg-subtle);
  color: var(--text-primary);
  font-size: var(--text-caption);
  line-height: 1;
  white-space: nowrap;
}

.estimate-status--completed {
  border-color: var(--success-border);
  background: var(--success-bg);
}

.estimate-status--failed {
  border-color: var(--error-border);
  background: var(--error-bg);
}

.estimate-status--pending,
.estimate-status--processing {
  border-color: var(--info-border);
  background: var(--info-bg);
}

.estimate-grand-total {
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

.estimate-actions,
.estimate-download,
.panel-actions,
.proposal-action-row,
.proposal-bulk-actions,
.thread-header__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}

/* Alone among those rows, this one holds the model picker: a label over a
 * control, and so taller than the buttons beside it. Centring leaves the
 * select hanging half a line below them. */
.panel-actions {
  align-items: flex-end;
  gap: var(--space-3);
}

/* Replaces the bulk-action footer once nothing is pending, where the review
 * screen used to dead-end. Success tinted because reaching it is the good
 * outcome; the border and the eyebrow carry the state, not the fill alone. */
.proposal-closing {
  display: grid;
  gap: var(--space-4);
  padding: var(--space-5);
  border: 1px solid var(--success-border);
  border-radius: var(--radius-sm);
  background: var(--success-bg);
}

.proposal-closing__copy {
  display: grid;
  gap: var(--space-1);
}

.proposal-closing__copy p {
  margin: 0;
  max-width: 62ch;
}

.proposal-closing__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
}

.estimate-error {
  display: grid;
  gap: var(--space-2);
  padding: var(--space-4);
  border: 1px solid var(--error-border);
  border-radius: var(--radius-sm);
  background: var(--error-bg);
}

.estimate-stale-notice,
.estimate-slow-notice {
  margin: 0;
  padding: var(--space-4);
  border: 1px solid var(--warning-border);
  border-radius: var(--radius-sm);
  background: var(--warning-bg);
  color: var(--text-primary);
}

/* Revealed by project_estimate_status_controller once its polling budget runs
 * out on a run still in flight. [hidden] is restated because .estimate-actions
 * sets display, which beats the user agent's own rule for the attribute. */
/* An old ballpark reached by its URL while the mode is parked. The info tint
 * and not the warning one: nothing is wrong with it, it is an earlier
 * version, and the warning tint is kept for "this is out of date". */
.estimate-paused-note {
  margin: 0;
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--info-border);
  border-radius: var(--radius-sm);
  background: var(--info-bg);
  color: var(--text-primary);
}

.estimate-slow-notice[hidden] {
  display: none;
}

.estimate-history {
  display: grid;
  gap: var(--space-3);
}

.estimate-history__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
}

.estimate-history__list a {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-2);
  border-bottom: 1px solid var(--border-default);
  border-radius: var(--radius-xs);
  color: var(--text-primary);
  font-size: var(--text-body-sm);
  transition: background-color var(--motion-base) var(--ease-standard);
}

.estimate-history__list a:hover {
  background: var(--bg-subtle);
  text-decoration: none;
}

.estimate-history__list li:last-child a {
  border-bottom: 0;
}

.estimate-history__total {
  margin-left: auto;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* ── Estimates: breakdown table ───────────────────────────── */

/* ── Page tabs ──────────────────────────────────────────────
 * An underline tablist rather than pills or a segmented control. The estimate
 * card already carries a border and a fill, and a second enclosed control
 * inside it reads as a card in a card; a baseline the active tab sits on top
 * of belongs to the content below it, which is what a tab is claiming. */
.page-tabs {
  display: flex;
  gap: var(--space-1);
  /* The rule the tabs sit on is this border, so it has to run the full width
   * of the card and not only under the tabs themselves. */
  border-bottom: 1px solid var(--border-default);
  /* Below 640px there are four labels in a 343px column. Scrolling the strip
   * keeps them one line and full words - wrapping to two rows moves the
   * baseline off the content, and truncating them makes "Supuestos y riesgos"
   * indistinguishable from "Supuestos utilizados" inside it. */
  overflow-x: auto;
  scrollbar-width: none;
}

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

/* Registered so the two widths interpolate: an unregistered custom
 * property animates discretely, and the fade would snap at the halfway
 * point instead of tracking the scroll. */
@property --tabs-fade-start {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@property --tabs-fade-end {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

/* Read against the strip's own scroll position, not time: at the start
 * there is content off the right and nothing off the left, at the end the
 * other way round. */
@keyframes tabs-scroll-fade {
  from {
    --tabs-fade-start: 0;
    --tabs-fade-end: 1.25rem;
  }

  to {
    --tabs-fade-start: 1.25rem;
    --tabs-fade-end: 0;
  }
}

/* The strip scrolls and says so nowhere. Measured at 390px: 358px of
 * clientWidth over 578px of content, so two of the five categories sit
 * off the right-hand end with no fade, no arrow, and no scrollbar - it
 * hides its own, three rules up, on purpose.
 *
 * A fade that knows where the scroll is. A permanent one would dim the
 * last tab once you had reached it, which reads as a disabled tab, so the
 * two widths ride the strip's scroll timeline: the fade is on whichever
 * side still has content, and on neither side when there is nothing to
 * scroll. Both widths rest at 0, which is what an inactive timeline
 * leaves behind - a strip whose tabs all fit is painted exactly as it is
 * today.
 *
 * A mask and not the background-gradient version of this trick, because a
 * background paints behind the labels: any tint dark enough to be seen
 * would also be a ground under 14px --text-secondary, which is the one
 * thing 03_components.css:155 says not to build.
 *
 * mask-clip is in the @supports condition and not only in the rule: the
 * default clips painting to the border box, which would cut the top and
 * bottom off a focused tab's 2px outline at 2px offset. Without no-clip
 * there is no fade at all, which is the right trade - losing the focus
 * ring to gain a gradient is not one.
 *
 * The end padding is what makes the fade safe to leave standing. It is
 * inside the scrollable area, so it only comes into view at the far end,
 * and it is the same 20px the fade is wide: scrolled all the way right,
 * the gradient lies over that reserved strip and not over the last
 * label. Measured in headless Chrome, the timeline gates correctly on
 * whether the strip scrolls at all - no overflow, both widths stay 0 and
 * nothing is painted - but its progress does not advance on a
 * programmatic scroll, so the end state has to be legible on the
 * assumption that it never advances. Where it does advance the fade also
 * crosses over to the left, which is strictly better and not relied on.
 *
 * Scoped to the review page for the same reason the button boundaries
 * are: .page-tabs is shared with the estimate pages and this is a
 * review-page ticket.
 *
 * animation-timeline drives the fade, not transition-duration, so
 * 01_tokens.css's reduced-motion override - which only zeroes the
 * --motion-* durations - never touches it. Gated here instead, and
 * scoped no wider than the rule it guards, since .page-tabs is shared
 * with the estimate page and this fixes the same problem there too. */
@supports (animation-timeline: scroll()) and (mask-clip: no-clip) {
  @media (prefers-reduced-motion: no-preference) {
    .proposal-review-page .page-tabs {
      padding-inline-end: 1.25rem;
      mask-image: linear-gradient(
        to right,
        transparent 0,
        #000 var(--tabs-fade-start),
        #000 calc(100% - var(--tabs-fade-end)),
        transparent 100%
      );
      mask-clip: no-clip;
      /* Arrow keys scroll the tab they move to into view, and without this
       * it arrives under the fade it just uncovered. */
      scroll-padding-inline: 1.25rem;
      animation: tabs-scroll-fade linear both;
      animation-timeline: scroll(self inline);
    }
  }
}

.page-tabs__tab {
  flex: 0 0 auto;
  min-height: var(--control-h-sm);
  padding: 0 var(--space-3);
  border: 0;
  /* Drawn on the element's own bottom edge so it covers the strip's border,
   * and reserved as transparent on every tab so activating one does not move
   * the row by a pixel. */
  border-bottom: 2px solid transparent;
  background: transparent;
  color: var(--text-secondary);
  font-family: inherit;
  font-size: var(--text-body-sm);
  white-space: nowrap;
  cursor: pointer;
  transition: color var(--motion-base) var(--ease-standard),
    border-color var(--motion-base) var(--ease-standard);
}

.page-tabs__tab:hover {
  color: var(--text-primary);
}

/* Weight and full text contrast carry the selection as well as the underline:
 * the rule alone is a single hue, and which tab is open is the one thing on
 * this control that must not depend on colour. */
.page-tabs__tab.is-active,
.page-tabs__tab[aria-selected="true"] {
  border-bottom-color: var(--link-color);
  color: var(--text-primary);
  font-weight: var(--weight-medium);
}

/* The global :focus-visible rule (02_base.css) sits 2px outside the border
 * box; on a tab that is also the open one, its bottom edge lands 2-4px past
 * the underline above, and the same blue on both reads as a broken bracket
 * around the label instead of one clean indicator. Pulled inside, like the
 * panels already do below, so the two never draw two lines. */
.page-tabs__tab:focus-visible {
  outline-offset: -2px;
}

/* The panels are focusable, so a keyboard reader can leave the tablist and
 * scroll the section it opened.
 *
 * --focus-ring and not --link-color. This was the only focus ring in the
 * product drawn in a second colour - rgb(167,201,252) here against
 * rgb(64,105,255) everywhere else - and a focus indicator that changes hue by
 * region is not an indicator, it is two.
 *
 * The offset is negative, so the ring is drawn INSIDE the panel. Outside, it
 * was a 1,136 x 4,027px rectangle around four and a half screens of content,
 * neither of whose long edges a reader ever sees, overlapping whatever sits
 * beside it. Drawn inside, the top edge is on screen at the moment the panel
 * takes focus, which is the only moment the ring is for.
 *
 * The real fix is upstream of this file: `tabindex="0"` on a tabpanel is
 * required by APG only when the panel holds nothing focusable, and all of
 * these hold links and buttons - so the attribute buys a dead tab stop and
 * pays for it with this outline. The panels' markup is in
 * project_estimates/show.html.erb, _estimate_card.html.erb,
 * projects/show.html.erb and proposed_context_updates/show.html.erb, none of
 * which this stream owns. Handed to the coordinator; this is the mitigation
 * that does not need them. */
.page-panel:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -2px;
}

/* minmax(0, 1fr): same guard as .card and .page-section, and for the same
 * reason - a bare auto column sizes to its widest child's max-content and
 * bleeds past this element's own bounds instead of wrapping or scrolling. */
.page-panel {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-6);
}

/* An author `display` beats the UA rule behind the `hidden` attribute, so the
 * grid above would keep every panel on the page and the tabs would only
 * change which one is highlighted. The controller toggles `hidden`, so this
 * is the rule that actually closes a panel. */
.page-panel[hidden] {
  display: none;
}

.estimate-preview {
  display: grid;
  gap: var(--space-6);
}

/* 62rem and not 52: the figure headers grew a help button each
 * (H40), and at 52rem the fixed columns plus Notas' 30% left Tarea
 * about 40px between 1025px and 1300px of viewport. */
.estimate-table {
  width: 100%;
  min-width: 62rem;
  border-collapse: separate;
  border-spacing: 0;
  table-layout: fixed;
}

.estimate-table thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  height: var(--table-header-h);
  padding: 0 var(--space-4);
  border-bottom: 1px solid var(--border-default);
  background: var(--bg-canvas);
  color: var(--text-secondary);
  font-size: var(--text-body-sm);
  font-weight: var(--weight-book);
  text-align: left;
}

.estimate-table tbody td,
.estimate-table tbody th {
  padding: var(--space-4);
  border-bottom: 1px solid var(--border-default);
  font-weight: var(--weight-book);
  text-align: left;
  vertical-align: top;
}

/* Column widths live on the thead and nowhere else. `table-layout: fixed`
 * reads every column's width off the first row, which here is the header row,
 * so a width declared on a tbody cell is read by nothing at all. That is the
 * defect the Ola column exposed rather than caused: the 24% and 52% below had
 * never been doing the work, columns 5 to 7 were never given a width, and the
 * browser fell back to splitting the table into near-equal shares. Notas came
 * out at 15% of the table for 220 characters of prose per row, which grew a
 * 41-row estimate to 13,000px - three quarters of it wrapped text.
 *
 * The five figure columns are sized in rem against their own headers instead
 * of in percent, because each one holds a single number and what it actually
 * has to fit is the word above it. "Puntos ajustados" is the widest of those
 * at 8.5rem, and it has to stay on one line: the header is a fixed 48px with
 * no vertical padding, so a second line has nowhere to go. Notas is the only
 * `auto` column, which is what sends every pixel the figures do not need to
 * the prose that does. */
/* The two text columns swapped roles when the notes went behind a
 * disclosure. Notas is no longer the column that needs every spare
 * pixel - closed it holds one short control, open it holds prose
 * that is capped at a reading measure anyway - so 30% is what it
 * gets, which at 1440px is ~413px, about 58 characters, inside the
 * 45-75 target. Tarea takes the slack instead: task names run to 55
 * characters ("Definición de plantilla y normalización acordada con
 * el cliente") and at the old 20% every one of them wrapped. */
.estimate-table thead th:nth-child(1) { width: auto; }
.estimate-table thead th:nth-child(2) { width: 30%; }
.estimate-table thead th:nth-child(3) { width: 5rem; }
.estimate-table thead th:nth-child(4) { width: 5rem; }
.estimate-table thead th:nth-child(5) { width: 5.5rem; }
/* 10.5rem since the header carries its help button (H40): "Puntos
 * ajustados" plus the 24px button no longer fit 8.5rem on one line. */
.estimate-table thead th:nth-child(6) { width: 10.5rem; }
/* Wider than the other figure columns, and not because of its header. The
 * subtotal and grand-total rows print a band here - "132.9 h (entre 106.3 y
 * 159.5)" - and at the 5rem the bare row figures need, that string stacked
 * into a seven-line tower that cost the table more height than the extra
 * width costs the prose beside it. 8rem is where that curve bottoms out. */
.estimate-table thead th:nth-child(7) { width: 8rem; }

.estimate-table td:first-child,
.estimate-table th[scope="row"] {
  color: var(--text-primary);
}

.estimate-table td:nth-child(2) {
  color: var(--text-secondary);
}

/* Every numeric column, not only the first two. Points and risk were right
 * aligned; adjusted points and hours never were, from before waves existed.
 *
 * The subtotal and chain rows are matched by class because nth-child counts
 * elements and not columns: both open with a colspan'd header, so their cells
 * are numbered two short. Every cell after that header is a number or blank
 * in both, which makes the class the honest selector here.
 *
 * Those two carry the `tbody` compound they do not need for matching, only to
 * outweigh `.estimate-table tbody td`'s text-align: left. A bare
 * `.estimate-table__subtotal td` loses to it and does nothing. */
.estimate-table td:nth-child(n + 3),
.estimate-table thead th:nth-child(n + 3),
.estimate-table tbody .estimate-table__subtotal td {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* The chain row is now a single cell of prose spanning the table rather than a
 * header plus an empty cell, so it takes the body's own left alignment - the
 * right-aligned rule above would have thrown its sentence to the far edge. */
.estimate-table tbody .estimate-table__chain td {
  text-align: left;
}

/* Four adjacent integer columns in which "1" means four different
 * things: a point count, a multiplier, a wave number and another
 * point count, all right-aligned in the same size, weight and
 * colour. Risk is the odd one - it is not a quantity, it is what
 * the hours were multiplied BY - and the operator in front of it
 * says so without the header having to be read. The glyph is
 * aria-hidden: the column header already names it for a screen
 * reader, and "times 1.3" read out per row is noise.
 *
 * Ola is the other one that is not a quantity: it is the name of a
 * bucket. Centred rather than right-aligned, and recessed, so the
 * eye stops grouping it with the two point columns it sits
 * between. */
.estimate-table .figure-operator {
  margin-right: 0.15em;
  color: var(--text-tertiary);
}

.estimate-table tbody td.estimate-table__wave {
  color: var(--text-secondary);
  text-align: center;
}

/* ── The table's key (H40) ────────────────────────────────────
 * Above the table, in the open: it answers "how do I read this",
 * and an answer behind a control is one somebody has to know to
 * look for. One term per line, the term in the primary colour and
 * its sentence in the secondary one. */
.estimate-legend {
  display: grid;
  gap: var(--space-2);
  margin-bottom: var(--space-4);
  font-size: var(--text-body-sm);
}

.estimate-legend__heading {
  margin: 0;
  color: var(--text-secondary);
  font-size: var(--text-caption);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
}

.estimate-legend__list {
  display: grid;
  gap: var(--space-1);
  margin: 0;
}

.estimate-legend__item {
  display: flex;
  flex-wrap: wrap;
  gap: 0 var(--space-1);
}

.estimate-legend__item dt {
  color: var(--text-primary);
  font-weight: var(--weight-medium);
}

.estimate-legend__item dd {
  margin: 0;
  color: var(--text-secondary);
}

/* The half minute between "Completada" and the waves arriving. Discreet on
 * purpose: nothing is wrong and nothing is asked of the reader. The ellipsis
 * in the copy says "in progress" without an animation to switch off. */
.estimate-legend__sequencing {
  margin: 0;
  color: var(--text-secondary);
  font-style: italic;
}

/* The same sentence again, on the header it is about. The button
 * draws at 24px so the 48px header keeps its single line, and a
 * transparent ::before takes the target to 40px. The tip opens on
 * hover AND on focus - a keyboard is how most people walk a
 * table's headers. */
.column-help {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  white-space: nowrap;
}

/* `button.column-help__button` for weight: the base button rule's
 * min-height would otherwise stretch the circle into a 40px pill. */
button.column-help__button {
  position: relative;
  display: inline-grid;
  flex: none;
  place-items: center;
  width: 1.5rem;
  height: 1.5rem;
  min-height: 0;
  padding: 0;
  border: 1px solid var(--border-default);
  border-radius: 50%;
  background: var(--bg-surface);
  color: var(--text-secondary);
  font-size: var(--text-caption);
  line-height: 1;
  cursor: help;
}

button.column-help__button::before {
  position: absolute;
  inset: -0.5rem;
  content: "";
}

button.column-help__button:hover,
button.column-help__button:focus-visible {
  border-color: var(--border-strong);
  color: var(--text-primary);
}

.column-help__tip {
  position: absolute;
  top: calc(100% + var(--space-2));
  right: 0;
  z-index: 3;
  display: none;
  width: max-content;
  max-width: 16rem;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-xs);
  background: var(--bg-surface);
  box-shadow: var(--shadow-popover);
  color: var(--text-primary);
  font-size: var(--text-caption);
  line-height: var(--leading-snug);
  text-align: left;
  white-space: normal;
}

/* WCAG 1.4.13: the tip stays while the pointer is on it (the
 * container is hovered, not only the button), while anything in it
 * has the focus, and while a click holds it open - touch has no
 * hover. Escape and a close from the button add is-dismissed, which
 * out-ranks all three until the pointer and the focus leave. */
.column-help:hover .column-help__tip,
.column-help:focus-within .column-help__tip,
.column-help.is-open .column-help__tip {
  display: block;
}

.column-help.is-dismissed .column-help__tip {
  display: none;
}

/* Bridges the gap between the button and the tip, so moving the
 * pointer down onto the tip never crosses a pixel that is neither. */
.column-help__tip::before {
  position: absolute;
  top: calc(-1 * var(--space-2) - 1px);
  right: 0;
  left: 0;
  height: var(--space-2);
  content: "";
}

/* ── Horas stays on screen at phone width (H55) ───────────────
 * At 390px the table is 832px inside a 314px wrapper, and Horas -
 * the one column the table exists to report - was the last thing
 * you reached by scrolling, with nothing saying it was there. It
 * is pinned to the right edge instead - at every width where the
 * table scrolls - over the surface colour so
 * the rows scrolling under it do not show through.
 *
 * The chain row is left out: its one cell spans all seven columns,
 * so it is also the last child, and pinning it would pin the row. */
@media (max-width: 1024px) {
  .estimate-table thead th:last-child,
  .estimate-table tbody td:last-child:not([colspan]),
  .estimate-table tfoot td:last-child {
    position: sticky;
    right: 0;
    box-shadow: -1px 0 0 var(--border-default);
  }

  .estimate-table thead th:last-child {
    z-index: 2;
  }

  .estimate-table tbody td:last-child:not([colspan]),
  .estimate-table tfoot td:last-child {
    z-index: 1;
    background: var(--bg-surface);
  }

}

.estimate-note-line {
  display: inline;
}

/* ── Task notes, behind a disclosure ─────────────────────────
 * The single biggest thing on this page. Three to five lines of
 * prose in every row put the breakdown at 9,848px - eleven
 * screens at 1440x900, with exactly one heading in them - and
 * none of it is read on the way to a figure. Closed, a row is
 * one line high; the prose is one click away, still in the
 * column whose header names it.
 *
 * Shared by the generated breakdown and the scenario editor, so
 * it is written once against the class and never against either
 * table. Both tables set their own note typography inside
 * __body: `.estimate-note-line` there, `.scenario-table__note`
 * here.
 *
 * The open/closed label pair is CSS rather than JavaScript: a
 * <details> already carries its own state, and the two strings
 * the contract froze - notes_toggle and notes_toggle_hide - are
 * both in the markup either way, which is also what keeps the
 * summary from resizing as it opens. */

.task-notes-disclosure {
  margin-top: var(--space-1);
}

.task-notes-disclosure__summary {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  width: fit-content;
  padding: var(--space-1) 0;
  color: var(--text-secondary);
  font-size: var(--text-caption);
  cursor: pointer;
  list-style: none;
}

/* Safari still draws its own triangle through ::-webkit-details-marker
 * even with list-style: none, and the chevron below is the marker
 * this design uses. */
.task-notes-disclosure__summary::-webkit-details-marker {
  display: none;
}

.task-notes-disclosure__summary:hover {
  color: var(--text-primary);
}

.task-notes-disclosure__summary:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: var(--radius-xs);
}

.task-notes-disclosure__chevron {
  flex: none;
  transition: transform var(--motion-base) var(--ease-standard);
}

.task-notes-disclosure[open] > .task-notes-disclosure__summary .task-notes-disclosure__chevron {
  transform: rotate(90deg);
}

.task-notes-disclosure__open,
.task-notes-disclosure[open] > .task-notes-disclosure__summary .task-notes-disclosure__closed {
  display: none;
}

.task-notes-disclosure[open] > .task-notes-disclosure__summary .task-notes-disclosure__open {
  display: inline;
}

/* The measure is the point of opening it in a column rather than
 * in the row: 65ch is a reading line, and the longest line on
 * this page before this change was 178 characters. The number
 * itself lives in 01_tokens.css as --measure and is read from
 * there, not restated here: a measure that two files disagree
 * about is not a measure. */
.task-notes-disclosure__body {
  max-width: var(--measure);
  padding-bottom: var(--space-2);
  line-height: var(--leading-body);
}

/* Section rows are structural, so they get the subtle fill and the
 * eyebrow treatment rather than a coloured band.
 *
 * The eyebrow treatment stops short of the caps, though. These are
 * epic titles, not labels: the ones in the seeded estimate run 31,
 * 32, 37, 38 and 41 characters ("7. Adopción, documentación y
 * capacitación"), and all-caps over a run that long removes the
 * word shapes a reader scans by - exactly backwards for the row
 * whose whole job is to be found while skimming a long table.
 *
 * Under `.estimate-table tbody` for the reason spelled out above
 * the right-aligned figures: `.estimate-table tbody th` outweighs
 * a bare `.estimate-table__section th`, so the weight and the
 * padding written here had never applied. Measured in the browser,
 * the band was rendering at 400 in 16px of padding, which means
 * all-caps at 13px was the only thing separating it from the rows.
 * Dropping the caps at that specificity would have left the header
 * SMALLER than the prose it heads and no heavier.
 *
 * So the prominence comes back as size and weight rather than as
 * caps: 16px Medium, the size of the rows and a step above their
 * book weight, over the fill and between the two rules.
 *
 * The padding this rule used to declare is gone rather than
 * promoted with it. It was dead for the same specificity reason,
 * and the cell padding it lost to is the one that tracks the
 * narrow breakpoint - taking it over here would have left the
 * band's title inset 4px further than the rows under it below
 * 1024px. */
.estimate-table tbody .estimate-table__section th {
  border-top: 1px solid var(--border-default);
  border-bottom: 1px solid var(--border-default);
  background: var(--bg-canvas);
  color: var(--text-primary);
  font-size: var(--text-body);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-caption);
  text-align: left;
}

.estimate-table tbody .estimate-table__section:first-child th {
  border-top: 0;
}

.estimate-table__subtotal th,
.estimate-table__subtotal td {
  background: var(--bg-canvas);
  color: var(--text-secondary);
  font-size: var(--text-body-sm);
  font-variant-numeric: tabular-nums;
}

/* The chain annotates the subtotal above it, so it has to read as quieter
 * than the subtotal - with no rule at all it inherited full body size and
 * read as a task row instead. Recessed by size, not by a lighter gray: no
 * gray in the ramp below neutral-500 carries text (see 01_tokens.css), so
 * caption size plus its tracking is what separates it from the subtotal's
 * body-sm while both stay on the same recessed fill.
 *
 * `tbody` in the selector is there for weight, not for matching: the padding
 * below has to outweigh `.estimate-table tbody td`'s own padding. */
.estimate-table tbody .estimate-table__chain th,
.estimate-table tbody .estimate-table__chain td {
  padding: var(--space-2) var(--space-4);
  background: var(--bg-canvas);
  color: var(--text-secondary);
  font-size: var(--text-caption);
  letter-spacing: var(--tracking-caption);
  font-variant-numeric: tabular-nums;
}

.estimate-table tfoot .estimate-table__grand-total td {
  padding: var(--space-4);
  border-top: 1px solid var(--border-strong);
  background: var(--info-bg);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
  text-align: left;
}

/* Same colspan'd-header shift as the subtotal: the label spans two columns,
 * so every cell from the second onward is a figure. */
.estimate-table tfoot .estimate-table__grand-total td:nth-child(n + 2) {
  text-align: right;
}

/* The headline number of the page, and an unstyled <p> until now. Same
 * callout shape as .estimate-stale-notice, in the info tint rather than the
 * warning one: the critical path is the answer, not a caveat. */
.estimate-critical-path {
  margin: 0;
  padding: var(--space-4);
  border: 1px solid var(--info-border);
  border-radius: var(--radius-sm);
  background: var(--info-bg);
  color: var(--text-primary);
  font-size: var(--text-h4);
  line-height: var(--leading-snug);
  font-variant-numeric: tabular-nums;
}

/* The note explaining what a critical path is belongs under the number, not
 * beside it at the same size. */
.estimate-critical-path .muted-text {
  display: block;
  margin-top: var(--space-1);
  font-size: var(--text-body-sm);
}

.estimate-notes {
  display: grid;
  gap: var(--space-3);
}

.estimate-notes ul {
  display: grid;
  gap: var(--space-2);
  color: var(--text-secondary);
}

/* ── Row hover, all four table families ──────────────────────
 * Not one of the four had a `tr:hover` rule. The scenario
 * comparison is the case that settles it: 13 columns, 72rem of
 * minimum width and a horizontal scroller, so reading one
 * scenario's figures means tracking a row across a viewport it
 * does not fit in, with nothing marking which row the eye is on.
 * The scenario editor's table is 9 columns of controls and has
 * the same problem one step down.
 *
 * `.data-table` lives in 03_components.css, which belongs to
 * another stream. This file loads after it, so the rule is
 * written here and wins on cascade order - the same move the
 * contract prescribes for a rule you do not own the home of.
 *
 * On the cells and not on the `tr`: a row is not a painting
 * surface in a `border-collapse: separate` table, so a
 * background declared on it is drawn under cells that have their
 * own and disappears everywhere it matters.
 *
 * The breakdown excludes rows that carry a class - section,
 * subtotal, chain - because those are structure, not data, and
 * they paint their own fill. `:not([class])` is exact there: its
 * data rows are the only ones with no class attribute at all.
 *
 * background-color and not `all`. Transitioning `all` animates
 * every property the row inherits, including ones that force
 * layout, and it is the reason a hover on a 33-row table can
 * cost more than the hover is worth. */

.data-table tbody tr:hover > *,
.scenario-table tbody tr:hover > *,
.scenario-comparison__table tbody tr:hover > *,
.estimate-table tbody tr:not([class]):hover > * {
  background: var(--bg-subtle);
}

.data-table tbody tr > *,
.scenario-table tbody tr > *,
.scenario-comparison__table tbody tr > *,
.estimate-table tbody tr:not([class]) > * {
  transition: background-color var(--motion-fast) var(--ease-standard);
}

@media (max-width: 1024px) {
  /* The 42rem floor this rule used to set is gone, and the base floor (62rem now) applies
   * here too. It was letting the table shrink to 672px, and with the figure
   * columns sized to their headers in rem that left 50px for the prose column
   * - 41 rows of notes wrapping at four words a line, 28,000px of table. The
   * wrapper already scrolls horizontally at these widths, so the wider table
   * costs a sideways drag that was there anyway and buys back 20,000px of
   * vertical scrolling.
   *
   * The figure columns come down with the padding above: 12px a side instead
   * of 16px means each header needs 8px less to stay on one line, and every
   * pixel not spent on a single digit goes to the notes beside it. They are
   * still sized to their own headers - "Puntos ajustados" is still the widest
   * at 8rem - so none of them wraps into a header that has no room for a
   * second line. */
  .estimate-table tbody td,
  .estimate-table tbody th,
  .estimate-table thead th {
    padding: var(--space-3);
  }

  /* Each figure header also carries its 24px help button (H40), so
   * every width here is its label plus the button and the gap, and
   * Notas gives up a third of its share so Tarea keeps a column. */
  .estimate-table thead th:nth-child(2) { width: 20%; }
  .estimate-table thead th:nth-child(3) { width: 6.5rem; }
  .estimate-table thead th:nth-child(4) { width: 6.5rem; }
  .estimate-table thead th:nth-child(5) { width: 5rem; }
  .estimate-table thead th:nth-child(6) { width: 10rem; }
  .estimate-table thead th:nth-child(7) { width: 6.5rem; }
}

/* ── Chat thread ────────────────────────────────────────── */

.thread-canvas {
  position: relative;
  display: grid;
  gap: var(--space-6);
}

/* A zero-footprint marker at the true end of the page's content, watched by
 * chat-scroll's IntersectionObserver to know whether the reader is at the
 * bottom. Absolutely positioned rather than a normal grid child so it never
 * adds its own row and gap after the composer - a 1px block would otherwise
 * still claim a full --space-6 of blank space beneath everything. */
.scroll-sentinel {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 1px;
  height: 1px;
  pointer-events: none;
}

/* Fixed to the viewport, not the page, so it stays reachable regardless of
 * how long the conversation has scrolled - the whole point is that the
 * bottom of the thread is out of view when this is visible at all. */
.scroll-to-bottom-button {
  position: fixed;
  right: var(--space-4);
  bottom: var(--space-4);
  z-index: 40;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-full);
  background: var(--bg-surface);
  color: var(--text-primary);
  font-size: var(--text-body-sm);
  font-weight: var(--weight-medium);
  box-shadow: var(--shadow-popover);
}

.scroll-to-bottom-button:hover {
  border-color: var(--border-strong);
}

/* New content waiting below gets the same treatment as the primary action
 * elsewhere in the thread, so the button reads as "something changed" and
 * not just as a permanent piece of chrome. */
.scroll-to-bottom-button--new {
  border-color: var(--primary-500);
  color: var(--primary-500);
}

/* On a phone the labelled pill sat on top of the in-flight reply's timer,
 * which is right-aligned in the same bottom corner. There it shrinks to its
 * arrow - the label stays in the accessibility tree as its name - and the
 * progress row keeps the corner clear, so the clock is never under it. */
@media (max-width: 640px) {
  .scroll-to-bottom-button {
    width: 2.75rem;
    height: 2.75rem;
    justify-content: center;
    padding: 0;
  }

  .scroll-to-bottom-button [data-chat-scroll-target="label"] {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .message-card__progress {
    padding-inline-end: 3.5rem;
  }
}

/* Pinned while the transcript scrolls past it. A long conversation otherwise
 * reads as a wall of text with nothing saying which thread it belongs to, and
 * the actions - delete, generate updates, generate the estimate - scroll out
 * of reach as soon as the reply is long enough to be worth acting on.
 *
 * Stretched into the shell's gutters and given an opaque background so the
 * messages pass behind it rather than showing along its edges. */
.thread-header {
  position: sticky;
  top: 0;
  z-index: 30;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-4);
  margin-inline: calc(-1 * var(--gutter));
  padding: var(--space-4) var(--gutter);
  border-bottom: 1px solid var(--border-default);
  background: var(--bg-canvas);
}

@media (max-width: 1024px) {
  .thread-header {
    margin-inline: calc(-1 * var(--gutter-mobile));
    padding-inline: var(--gutter-mobile);
  }
}

/* `--minimal` had no rules at all, so the header it modified was the full
 * page-header treatment: a 48px display title that wrapped to two lines, an
 * eyebrow and a subtitle. Sticky, that is a quarter of the viewport following
 * the reader down a conversation. This is what the class was named for.
 *
 * The title comes down to h3. It is not a page title competing for a first
 * impression - it is a label on the thread being read, in a band that stays on
 * screen, next to the controls that act on it. */
.thread-header--minimal {
  align-items: center;
  padding-block: var(--space-3);
}

.thread-header--minimal .thread-header__copy h1 {
  font-size: var(--text-h3);
  /* One line. A title long enough to wrap would put the actions beside it back
   * where they started, and the full text is in the breadcrumb, the tab title
   * and the edit field this heading opens.
   *
   * width: 100%, not just min-width: 0: .thread-title-editor's own
   * `justify-items: start` sizes grid items to their content instead of
   * stretching them, so without an explicit width this h1 (and the
   * overflow/ellipsis below) never has a constrained box to truncate
   * against in the first place - the title just grows to fit itself. */
  width: 100%;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* While the rename field is open the header gives it the whole row and drops
 * the actions below. Sharing the row left the field about 330px wide - twenty
 * characters of a heading-sized input - and the title being renamed is
 * routinely longer than that. Set by thread_title_editor_controller. */
.thread-header.is-editing {
  flex-wrap: wrap;
}

.thread-header.is-editing .thread-header__copy {
  flex-basis: 100%;
}

.thread-header__copy {
  display: grid;
  /* minmax(0, 1fr): without it, the implicit single column's automatic
   * minimum is its widest child's content size, so the flex `min-width: 0`
   * below only lets the flex item shrink - it does not stop this grid's own
   * track from growing back to fit the rename button's full text, which is
   * exactly what let that button render past this column's edge. */
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-2);
  min-width: 0;
  /* Takes the slack the actions beside it do not need. At its natural width
   * this column was 262px of a 1040px header, which ellipsised the title on
   * sight and left the rename field too narrow to read what was being typed
   * into it. */
  flex: 1 1 auto;
}

.thread-header__actions {
  justify-content: flex-end;
  /* Never compressed. The title column beside it now takes the slack, and
   * letting these shrink instead made four buttons wrap onto three rows and
   * the header grow from 61px to 144px. */
  flex: 0 0 auto;
}

/* "Interpretar la conversación" and how its last run ended, as one item of the
 * actions row: the note sits beside the button it is about instead of
 * wrapping onto a line of its own under the whole row. */
.thread-interpretation {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}

.thread-interpretation__running {
  gap: var(--space-2);
}

/* The one number in the row that changes while it is being read. */
.thread-interpretation__elapsed {
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

.thread-interpretation__note {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  font-size: var(--text-caption);
  color: var(--text-secondary);
}

/* Primary text, as .inline-error has it, with the icon in the error hue:
 * the tokens carry an error border and background but no error text colour,
 * and the sentence has to stay readable on either theme. */
/* [hidden] restated: the display above beats the user agent's own rule. */
.thread-interpretation__note[hidden] {
  display: none;
}

.thread-interpretation__note--error {
  color: var(--text-primary);
}

.thread-interpretation__note--error .icon {
  color: var(--error-border);
}

/* The one irreversible action in the row, set apart from the constructive
 * ones behind a divider rather than only a few pixels further along: margin
 * alone left it reading as one more button in the same cluster, 20px from
 * the primary. A border and not colour, so the separation does not depend
 * on telling red from blue. */
.thread-header__danger {
  display: inline-flex;
  align-items: center;
  margin-inline-start: var(--space-2);
  padding-inline-start: var(--space-4);
  border-inline-start: 1px solid var(--border-default);
}

/* Everything but the primary action lives in .thread-header__more, a popover
 * behind "…" on a phone. On a wider screen the menu is not a menu: display:
 * contents lays its buttons into the actions row itself (an author rule, so
 * it beats the user agent's `[popover]:not(:popover-open) { display: none }`),
 * and `order` keeps the row reading interpret, estimate, ballpark, delete
 * whichever of the first two is the primary sitting outside the menu. */
.thread-header__more-toggle {
  display: none;
}

.thread-header__actions .thread-interpretation { order: 1; }
.thread-header__actions .thread-header__estimate { order: 2; }
.thread-header__actions .thread-header__ballpark { order: 3; }
.thread-header__actions .thread-header__danger { order: 4; }
.thread-header__actions .thread-header__more-toggle { order: 5; }

@media (min-width: 641px) {
  .thread-header__more {
    display: contents;
  }
}

/* The confirmation before a detailed estimate with unanswered questions. A
 * native modal <dialog> in the top layer, dressed like .app-modal: the same
 * overlay pair, width, radius and scrim. Not .app-modal itself, whose
 * top/left + transform centring fights the UA's inset: 0 on a <dialog>. */
.thread-estimate-dialog {
  width: min(calc(100vw - 2 * var(--space-4)), var(--alert-dialog-w));
  max-height: min(90vh, 46rem);
  padding: var(--space-6);
  border: 1px solid var(--border-overlay);
  border-radius: var(--radius-sm);
  background: var(--bg-overlay);
  color: var(--text-primary);
  box-shadow: var(--shadow-dialog);
}

.thread-estimate-dialog::backdrop {
  background: var(--scrim);
}

.thread-estimate-dialog h2 {
  margin: 0;
  font-size: var(--text-h4);
}

.thread-estimate-dialog p {
  margin: 0;
}

/* The daylight above was doing the job alone: measured in the header, this
 * button computed background rgba(0,0,0,0) and border rgba(0,0,0,0) - 1.00:1 -
 * beside a filled blue primary and a --border-control secondary. Combining the
 * two modifiers SUBTRACTS the edge rather than adding it, because
 * 03_components.css sets `.button--subtle.button--danger { border-color:
 * transparent }` at 0-2-0, which beats `.button--danger` at 0-1-0. The red
 * only ever appeared on hover, so the one irreversible action in the row was
 * the only control in it with no boundary at rest.
 *
 * --error-border, by the rule 06_curation.css writes for the sweep: this is
 * one destructive action set apart from its neighbours, not one per row, so
 * the red edge is affordable here where it is not on a list. An edge and not
 * a fill - it must stop being invisible, not out-shout the primary. Measured
 * as a boundary: 3.60:1 on the light canvas and 5.26:1 on the dark one, both
 * over the 3:1 WCAG 1.4.11 asks of a component edge, where it was 1.00:1.
 *
 * Doubled classes for the reason stated at .proposal-bulk-actions below:
 * 03_components.css qualifies the ghost with its element. "Revisar
 * actualizaciones de contexto" beside it stays edgeless deliberately - it
 * commits nothing, and being the one ghost without a boundary in a row where
 * everything else has one is the hierarchy reading correctly. */
.thread-header__delete.button.button--subtle {
  border-color: var(--error-border);
}

.thread-header__delete.button.button--subtle:hover {
  border-color: var(--error-border);
  background: var(--error-bg);
}

.thread-title-editor {
  display: grid;
  justify-items: start;
  min-width: 0;
}

/* The trigger is a button for keyboard reach but must not read as a
 * control: it inherits the h1's type entirely.
 *
 * display: block + the overflow trio, because a <button> is `display:
 * inline-flex` by the generic button reset (03_components.css) and sized
 * to its own content like any other flex item - the h1's overflow/ellipsis
 * never reaches through it as a result, and the full title renders past
 * the header into the actions beside it instead of being clipped. */
.thread-title-editor__button {
  display: block;
  width: 100%;
  min-height: 0;
  min-width: 0;
  overflow: hidden;
  padding: 0 var(--space-1);
  margin-left: calc(-1 * var(--space-1));
  border: 1px solid transparent;
  border-radius: var(--radius-xs);
  background: transparent;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  line-height: inherit;
  text-align: left;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.thread-title-editor__button:hover,
.thread-title-editor__button:focus {
  background: var(--bg-subtle);
  border-color: var(--border-default);
  text-decoration: none;
  transform: none;
}

.thread-title-editor__form {
  display: grid;
  gap: var(--space-2);
  width: min(100%, 38rem);
}

/* An author `display` beats the user agent's rule behind the `hidden`
 * attribute, so the grid above kept the closed form in the layout: the header
 * sat at 144px with nothing visible in the bottom 90px of it. The controller
 * toggles `hidden`, so this is the rule that actually closes the form. Same
 * trap as .page-panel[hidden] and the one the button rules already guard. */
.thread-title-editor__form[hidden] {
  display: none;
}

/* Matched to the heading it replaces, which came down to h3 when the header
 * became one compact band - at h1 the field was twice the size of the title it
 * was editing.
 *
 * Qualified with `input`, the way .button--subtle and friends are in
 * 03_components.css and for the same reason: the base control rule is
 * `input[type="text"], ...`, and an attribute selector plus an element name
 * outranks a bare class. Unqualified, this rule matched and lost, and the
 * field silently stayed at 14px. */
input.thread-title-editor__input {
  font-size: var(--text-h3);
  letter-spacing: var(--tracking-tight);
  line-height: var(--leading-heading);
}

.thread-title-editor__actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

/* ── Messages ───────────────────────────────────────────── */

.messages-panel {
  display: grid;
  gap: var(--space-4);
}

.message-stack {
  display: grid;
  gap: var(--space-6);
}

.message-card {
  display: grid;
  gap: var(--space-2);
  max-width: 100%;
}

.message-card--user {
  justify-items: end;
}

.message-card--assistant,
.message-card--pending,
.message-card--processing,
.message-card--failed {
  justify-items: start;
}

.message-card__chrome {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.message-card__role {
  color: var(--text-secondary);
}

.message-card--failed .message-card__role {
  color: var(--text-primary);
}

.message-card__bubble,
.message-card__panel {
  width: min(100%, 46rem);
  padding: var(--space-4);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
}

.message-card__bubble {
  background: var(--info-bg);
  border-color: var(--info-border);
}

.message-card__panel {
  background: var(--bg-surface);
  border-color: var(--border-default);
}

.message-card--assistant .message-card__panel {
  width: min(100%, 52rem);
}

.message-card--pending .message-card__panel,
.message-card--processing .message-card__panel {
  border-color: var(--warning-border);
  background: var(--warning-bg);
}

/* Once the answer starts arriving the card stops looking like a notice and
   starts looking like the answer it is becoming. Otherwise finishing flips the
   panel from tinted to plain under text that has not changed, which reads as
   the same message being sent twice. */
.message-card--streaming .message-card__panel {
  border-color: var(--border-default);
  background: var(--bg-surface);
}

.message-card--streaming .message-card__stream:not([hidden]) + .message-card__progress {
  border-top-color: var(--border-default);
}

.message-card--failed .message-card__panel {
  border-color: var(--error-border);
  background: var(--error-bg);
}

.message-card__content {
  margin: 0;
}

/* Files sent with no text: a note about the message, not words in it, so it
 * reads as secondary and in italics rather than as the reader's own voice. */
.message-card__implicit {
  color: var(--text-secondary);
  font-style: italic;
}

/* The label carries the live region, so the timer sits beside it rather than
   inside it: a value that changes every second would otherwise be announced
   every second. */
.message-card__progress {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-4);
}

.message-card__progress-label {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
}

/* The streamed text sits above the progress row and inherits the panel's
   in-flight tint, so the swap to the finished answer changes the frame rather
   than the type. The rule between them belongs to the progress row and only
   once there is text above it: before the first chunk the stream is hidden,
   and a rule on top of a lone progress row would divide it from nothing. */
.message-card__stream:not([hidden]) + .message-card__progress {
  margin-top: var(--space-3);
  border-top: 1px solid var(--warning-border);
  padding-top: var(--space-3);
}

.message-card__progress-elapsed {
  margin: 0;
  flex-shrink: 0;
  font-size: var(--text-caption);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

.message-card__status-block {
  display: grid;
  justify-items: start;
  gap: var(--space-3);
}

.message-attachments {
  list-style: none;
  margin: var(--space-3) 0 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

.message-attachment-chip {
  display: inline-flex;
  align-items: center;
  min-height: var(--badge-h);
  padding: 2px 10px;
  border: 1px solid var(--border-default);
  border-radius: var(--radius-full);
  background: var(--bg-canvas);
  color: var(--text-secondary);
  font-size: var(--text-caption);
  line-height: 1;
}

/* ── Composer ───────────────────────────────────────────── */

.composer-panel {
  display: grid;
  gap: var(--space-4);
}

/* Grown by chat_composer_controller#autoResize instead of the generic
 * textarea's manual resize handle, which is disabled here so the two
 * resize mechanisms cannot fight each other. Capped rather than unbounded:
 * a discovery message can run to dozens of paragraphs, and the composer
 * still needs to leave the conversation above it reachable. */
.composer-content {
  max-height: 16rem;
  overflow-y: auto;
  resize: none;
}

.composer-form {
  display: grid;
  gap: var(--space-4);
  padding: var(--space-4);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
  background: var(--bg-surface);
}

/* Carries the type the bare `label` rule used to give this line. It stopped
 * being a label because it titles the group rather than the input - see the
 * note in _composer.html.erb - and nothing about how it reads should change. */
.composer-upload-label {
  display: block;
  color: var(--text-primary);
  font-size: var(--text-body-sm);
  font-weight: var(--weight-book);
}

.composer-upload-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
}

/* Bottom-aligned for the same reason as .chat-composer__actions: the
 * model picker is a label over a control, and centring it leaves the
 * hint and the submit button sitting at different heights. */
.composer-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--space-3);
}

/* Keyboard-reachable file picker: the native input is visually
 * hidden but focusable, and the label carries the focus ring. */
.composer-upload-button {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--control-h-sm);
  padding: 0 var(--space-3);
  border: 1px dashed var(--border-control);
  border-radius: var(--radius-xs);
  color: var(--text-primary);
  font-size: var(--text-body-sm);
  cursor: pointer;
  transition: border-color var(--motion-base) var(--ease-standard),
    background-color var(--motion-base) var(--ease-standard);
}

.composer-upload-button:hover {
  border-color: var(--border-strong);
  background: var(--bg-subtle);
}

.composer-upload-button input[type="file"] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.composer-upload-button:focus-within {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

.composer-upload-queue:empty {
  display: none;
}

.composer-upload-queue {
  display: grid;
  gap: var(--space-2);
}

.composer-upload-item {
  display: grid;
  gap: var(--space-2);
  justify-items: start;
  padding: var(--space-3);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-xs);
  background: var(--bg-canvas);
  font-size: var(--text-body-sm);
}

.composer-upload-item--uploading {
  border-color: var(--info-border);
  background: var(--info-bg);
}

.composer-upload-item--ready {
  border-color: var(--success-border);
  background: var(--success-bg);
}

.composer-upload-item--failed {
  border-color: var(--error-border);
  background: var(--error-bg);
}

.composer-upload-item__name {
  margin: 0;
}

/* Caption, not eyebrow: design.md reserves ALL-CAPS for eyebrows, and
 * this text is a machine state string the specs read verbatim. */
.composer-upload-item__state {
  margin: 0;
  font-size: var(--text-caption);
  letter-spacing: var(--tracking-caption);
}

/* Secondary text lands between 3.9:1 and 4.4:1 on the light-mode
 * tinted fills, so anything sitting on a tint steps up to primary and
 * lets size alone carry the hierarchy. Same rule as the status copy in
 * 01_tokens.css: on a tinted surface the hue is the signal, not the
 * text colour. */
.composer-upload-item__state,
.estimate-error .muted-text {
  color: var(--text-primary);
}

.composer-hint {
  margin: 0;
  color: var(--text-secondary);
  font-size: var(--text-body-sm);
}

@media (max-width: 640px) {
  .composer-upload-header,
  .composer-actions {
    flex-direction: column;
    align-items: stretch;
  }

  /* One row and not sticky. Sticky, the header was 151px of a 844px screen
   * following the reader down the conversation, over three rows. Here it is
   * the title, the primary action and "…", and it scrolls away with the page;
   * the breadcrumb above and the tab title still say where the reader is. */
  .thread-header {
    position: static;
    align-items: center;
    gap: var(--space-2);
    padding-block: var(--space-2);
  }

  .thread-header--minimal .thread-header__copy h1 {
    font-size: var(--text-h4);
  }

  .thread-header__actions {
    flex-wrap: nowrap;
    justify-content: flex-end;
  }

  .thread-header__more-toggle {
    display: inline-flex;
    justify-content: center;
    min-width: 40px;
    min-height: 40px;
    padding: 0;
    anchor-name: --thread-more;
  }

  .thread-header__more {
    display: none;
  }

  /* Anchored under "…" where anchor positioning exists; fixed under the top
   * edge where it does not, which is the same place while the header, no
   * longer sticky, is on screen - and the toggle is only reachable then. */
  .thread-header__more:popover-open {
    display: grid;
    gap: var(--space-2);
    position: fixed;
    inset: auto;
    top: var(--space-12);
    right: var(--gutter-mobile);
    width: min(18rem, calc(100vw - 2 * var(--gutter-mobile)));
    margin: 0;
    padding: var(--space-3);
    border: 1px solid var(--border-overlay);
    border-radius: var(--radius-sm);
    background: var(--bg-overlay);
    color: var(--text-primary);
    box-shadow: var(--shadow-dialog);
  }

  @supports (position-anchor: --thread-more) {
    .thread-header__more:popover-open {
      position-anchor: --thread-more;
      top: calc(anchor(bottom) + var(--space-1));
      right: anchor(right);
    }
  }

  .thread-header__more .thread-interpretation {
    display: grid;
  }

  /* "Espera a que termine la respuesta." beside the button squeezed the title
   * down to two letters: on a phone it goes under the button instead. */
  .thread-interpretation--waiting {
    flex-direction: column;
    align-items: flex-end;
    gap: var(--space-1);
  }

  .thread-header__more form,
  .thread-header__more .button {
    width: 100%;
  }

  .thread-header__more .button {
    justify-content: flex-start;
    min-height: 40px;
  }

  /* The divider turns from a vertical rule in a row into a horizontal one in
   * a list, still between Eliminar and everything that builds something. */
  .thread-header__more .thread-header__danger {
    display: block;
    margin: 0;
    padding: var(--space-2) 0 0;
    border-inline-start: 0;
    border-top: 1px solid var(--border-default);
  }

  /* With nothing above it - the interpretation is the row's primary and there
   * is no estimate or ballpark to offer - the divider separated Eliminar from
   * an empty strip at the top of the menu. */
  .thread-header__more .thread-header__danger:first-child {
    padding-top: 0;
    border-top: 0;
  }

}

/* ── Context review ─────────────────────────────────────── */

/* Slabs already carry 24px of their own padding on both edges, so the
 * page-section gap would stack on top of that. Dropping it leaves
 * 24+24 = space-12 between categories, the doc's "separación de
 * bloques mayores". */
.proposal-review-page {
  gap: var(--space-2);
}

.proposal-intro-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
  gap: var(--space-4);
}

.proposal-intro-value {
  margin: 0;
}

/* The split under "quedan por revisar". It is second to the pending count
 * on purpose: what is left is the reason you are still on the page, and
 * the approved/rejected breakdown is the record of how you got here.
 *
 * Tabular figures because #proposal-progress is a Turbo Stream target and
 * these digits are replaced on every single decision - proportional ones
 * shift the whole line each time a 9 becomes a 10. */
.proposal-progress__detail {
  font-size: var(--text-body-sm);
  font-variant-numeric: tabular-nums;
}

.proposal-category-header {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
}

/* An id selector, which nothing else in this file uses, because the element
 * is not a design decision: #proposal-review-actions exists only so a Turbo
 * Stream can aim at whichever of the two footers is rendered, and it has to
 * stay a structural no-op. Any box at all - the div's own default block box
 * included - becomes the containing block of the sticky footer inside it,
 * and a sticky element pinned to a box its own height has nowhere to
 * travel: the footer stops sticking and nobody's spec notices, because the
 * spec asserts which footer renders and not where it sits.
 *
 * It lived as an inline style on the div, which put a layout rule the
 * stylesheet is responsible for in the markup. Same declaration, in the
 * file that owns it. */
#proposal-review-actions {
  display: contents;
}

/* Sticky, so the review's actions and its exit are both reachable from
 * anywhere in a page that runs several screens deep. Full-bleed to the
 * gutters and filled, or the buttons float over the item underneath them.
 *
 * border-top alone is ~1.18:1 - 01_tokens.css:132 already documents that as
 * decorative, not a boundary - so this sticky bar had no elevation cue at
 * all for the content scrolling underneath it. 01_tokens.css's own comment
 * on --shadow-* is that shadows exist to detach an overlay from the page it
 * floats above, and a bottom-pinned footer is exactly that, just floating
 * over content that sits ABOVE it instead of below - so the cast has to go
 * up, not down. --shadow-popover is the right weight for this (a footer bar,
 * not a modal) but its offsets are downward and var() cannot invert part of
 * a compound value, so the numbers are restated here with the sign flipped;
 * the color/blur pairs are copied verbatim from --shadow-popover, light and
 * dark, so this is not a new elevation value, just its mirror. */
.proposal-bulk-actions--footer {
  position: sticky;
  bottom: 0;
  z-index: 20;
  justify-content: flex-end;
  margin-inline: calc(-1 * var(--gutter));
  padding: var(--space-3) var(--gutter);
  border-top: 1px solid var(--border-default);
  background: var(--bg-canvas);
  box-shadow: 0 -4px 12px rgba(17, 24, 39, 0.08), 0 -1px 2px rgba(17, 24, 39, 0.06);
}

/* Same two blocks as 01_tokens.css's dark theme, kept identical on purpose:
 * the media query serves an OS-level dark preference, the attribute
 * selector lets the in-app toggle override it either way. --shadow-popover's
 * dark values, sign flipped the same way as the light shadow above. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .proposal-bulk-actions--footer {
    box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.6), 0 -1px 2px rgba(0, 0, 0, 0.5);
  }
}

:root[data-theme="dark"] .proposal-bulk-actions--footer {
  box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.6), 0 -1px 2px rgba(0, 0, 0, 0.5);
}

/* The exit sits apart from the two actions that commit something, so leaving
 * is never the button next to the one that approves. */
.proposal-bulk-actions__leave {
  margin-inline-start: auto;
}

/* Says what "seleccionados" refers to, in the row that offers it. Tabular
 * because it is the number that moves while you are looking at it. */
.proposal-bulk-actions__count {
  color: var(--text-secondary);
  font-size: var(--text-body-sm);
  font-variant-numeric: tabular-nums;
}

/* Four of the six irreversible actions on this page had no perceivable
 * boundary at all: Rechazar, Rechazar seleccionados, Aprobar todos and
 * Rechazar todos each computed background rgba(0,0,0,0) and border
 * rgba(0,0,0,0) - 1.00:1 - at the same 14px/400 as the primary beside
 * them. Four buttons that read as text links, three of which cannot be
 * undone. Ghost is the right WEIGHT for them and the wrong AFFORDANCE:
 * they keep the transparent fill and get an edge.
 *
 * --border-strong (neutral-500), not --border-control. Control-at-rest is
 * 1.41:1 on the light canvas and 01_tokens.css:118 already names
 * neutral-500 as the fix for precisely this; it is also the only step in
 * design.md's light ramp that gets there, neutral-400 being 2.43:1.
 * Measured: 4.63:1 light, 5.45:1 dark.
 *
 * Scoped to this page and not to .button--subtle, which is used in places
 * where a ghost is not sitting next to something irreversible. The exit
 * comes along: left at --border-control it would carry the faintest edge
 * in the row while the four ghosts sat at 4.63:1, which is the hierarchy
 * upside down - the one action that commits nothing on top.
 *
 * The class lists are doubled because 03_components.css qualifies the
 * ghost with its element (button.button--subtle, 0-2-1) and a single page
 * class does not reach it. The :hover pair is stated rather than
 * inherited for the same reason: .button--subtle:hover sets border-color
 * transparent, and this should not depend on which file loads last.
 *
 * .proposal-discard-question is the fifth of the six and was missed: it
 * carries the same three classes as the others but lives inside the item
 * row rather than in the footer, so none of the selectors above reached
 * it and it rendered as plain text in the middle of a paragraph. Neutral
 * and not --error-border, on the rule 06_curation.css states for the
 * per-row "Rechazar": the red edge is the SWEEP's, and one discard button
 * per pending question down the page is the same "sixty-two red buttons"
 * that comment refuses. */
.proposal-bulk-actions .button.button--subtle,
.proposal-bulk-actions .button.button--secondary,
.proposal-reject-button.button.button--subtle,
.proposal-wording-save.button.button--subtle,
.proposal-discard-question.button.button--subtle {
  border-color: var(--border-strong);
}

.proposal-bulk-actions .button.button--subtle:hover,
.proposal-bulk-actions .button.button--secondary:hover,
.proposal-reject-button.button.button--subtle:hover,
.proposal-wording-save.button.button--subtle:hover,
.proposal-discard-question.button.button--subtle:hover {
  border-color: var(--border-strong);
  background: var(--bg-subtle);
}

@media (max-width: 1024px) {
  /* Wrapped flex centred every row it made: at 390px the six controls came
   * out as three rows of two with six different left edges, none of them
   * the gutter's - the count started at x=89, "Rechazar seleccionados" at
   * x=84.8, "Rechazar todos" at x=79.9. A two-column grid gives all six
   * two edges to share and makes the pairs the same width, which is also
   * what stops "Aprobar todos" and "Rechazar todos" from reading as two
   * differently important buttons.
   *
   * Three rows is the floor and it is set by the words, not by this rule:
   * the widest label needs 171px of the 358px available, so no row holds
   * three of them, and the six items cannot land in two rows. The height
   * comes out of the gaps and the bar's own padding instead - 149px to
   * 133px, 17.7% of an 844px viewport down to 15.8%. Cutting the 36px
   * controls to 32 would have bought another 12px, on a touch device,
   * which is the wrong direction for a target. */
  .proposal-bulk-actions--footer {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-1);
    margin-inline: calc(-1 * var(--gutter-mobile));
    padding-block: var(--space-2);
    padding-inline: var(--gutter-mobile);
  }

  /* The auto margin was flex's way of setting the exit apart and it does
   * nothing in a grid; the separation is the row it ends up on. */
  .proposal-bulk-actions__leave {
    margin-inline-start: 0;
  }

  /* It labels the two actions beside it rather than the bar, so it sits on
   * the baseline of the row it shares instead of stretching to 36px. */
  .proposal-bulk-actions__count {
    align-self: center;
  }
}

/* Reference beside the task, not part of it: quiet until opened.
 *
 * No --bg-subtle fill, on purpose: that fill puts this box's own
 * --text-secondary at 4.39:1, under AA and exactly what
 * 03_components.css:155 says not to do for anything that is not
 * disabled. Same shape as .proposal-wording-editor below for the same
 * reason - the border alone still separates it from canvas/surface,
 * so removing the fill costs nothing but the contrast problem. */
.proposal-existing-context {
  border: 1px solid var(--border-default);
  border-radius: var(--radius-xs);
}

.proposal-existing-context > summary {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--control-h-sm);
  padding: 0 var(--space-3);
  color: var(--text-secondary);
  font-size: var(--text-body-sm);
  cursor: pointer;
}

.proposal-existing-context > summary:hover {
  color: var(--text-primary);
}

.proposal-existing-context[open] > summary {
  border-bottom: 1px solid var(--border-default);
}

.proposal-existing-context__list {
  display: grid;
  gap: var(--space-2);
  margin: 0;
  padding: var(--space-3) var(--space-3) var(--space-3) var(--space-6);
  color: var(--text-secondary);
  font-size: var(--text-body-sm);
}

.proposal-existing-context > p {
  margin: 0;
  padding: var(--space-3);
  font-size: var(--text-body-sm);
}

.proposal-item-stack {
  display: grid;
}

.proposal-item-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(20rem, 0.9fr);
  gap: var(--space-6);
  /* Inline padding on the start edge only, so the marker below is a
   * marker and not a second table rule butted against the copy. */
  padding: var(--space-6) 0 var(--space-6) var(--space-4);
  border-top: 1px solid var(--border-default);
  /* Reserved transparent on every row and coloured per state, so a
   * decision cannot move the copy by 3px when the frame swaps. */
  border-inline-start: 3px solid transparent;
}

/* The scannable cue. Fifty-two left edges are read in one pass; fifty-two
 * badges have to be read one at a time, and the badge is what you check
 * once the edge has told you where to look.
 *
 * Pending gets the divider token rather than nothing: an undecided row
 * still has an edge, it just does not claim a state. In light that edge is
 * 1.18:1 and effectively invisible, which is the point - approved and
 * rejected are the two that have to show. */
.proposal-item-row--pending {
  border-inline-start-color: var(--border-default);
}

.proposal-item-row--approved {
  border-inline-start-color: var(--success-border);
}

.proposal-item-row--rejected {
  border-inline-start-color: var(--error-border);
}

.proposal-item-stack > *:first-child .proposal-item-row {
  border-top: 0;
  padding-top: 0;
}

.proposal-item-column {
  min-width: 0;
}

.proposal-copy-block,
.proposal-item-review {
  display: grid;
  align-content: start;
  gap: var(--space-2);
}

/* A resolved row steps back so the pending ones read first.
 *
 * The dimming survives here, unlike in the scenario table, because this
 * copy sits on the canvas and never on a tinted fill: --text-secondary is
 * 4.63:1 on the light canvas and 9.44:1 on the dark one. The row's hue
 * lives in its start border and in the badge, and the badge's own label
 * stays at --text-primary - the tinted grounds are exactly where the
 * second text colour runs out (see the note at 1364 below). */
.proposal-item-row--approved .proposal-item-content,
.proposal-item-row--rejected .proposal-item-content {
  color: var(--text-secondary);
}

/* The third cue, and the one that survives monochrome and a colour-blind
 * reader both. Precedent and reasoning are .scenario-table__row--out's:
 * dimming a whole row is the usual move and it takes the copy under
 * 4.5:1, so the line does the work the opacity would have. Rejected only:
 * an approved wording is what the project is about to hold. */
.proposal-item-row--rejected .proposal-item-content {
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}

/* The kind of thing and the decision about it, on one line at the head of
 * the row. Flex and not the paragraph's own inline flow, because the
 * eyebrow's 13px/1 and the badge's 12px/20px would sit on a shared
 * baseline and put the pill 2px low; wrapping is allowed so a long
 * category label does not squeeze the badge. */
.proposal-item-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
}

/* A sibling variant set, not an extraction. Folding this into a shared
 * .status-badge with .estimate-status would save ten lines and put
 * scenario_editor_spec and scenario_comparison_spec inside the blast
 * radius of a ticket about the review screen. Same shape, same rule the
 * comment at 162 states: a badge shape plus a hue, never hue alone. */
.proposal-item-status {
  display: inline-flex;
  align-items: center;
  min-height: var(--badge-h);
  margin: 0;
  padding: 2px 10px;
  border: 1px solid var(--border-default);
  border-radius: var(--radius-full);
  background: var(--bg-subtle);
  color: var(--text-primary);
  font-size: var(--text-caption);
  line-height: 1;
  white-space: nowrap;
}

.proposal-item-status--approved {
  border-color: var(--success-border);
  background: var(--success-bg);
}

.proposal-item-status--rejected {
  border-color: var(--error-border);
  background: var(--error-bg);
}

/* ✓ U+2713 and ✕ U+2715 are different widths and sit at different
 * heights, so the glyph gets a box of its own and is centred in it: both
 * labels then start at the same x instead of shifting by a glyph when a
 * row changes state. The gap is the glyph's own margin rather than the
 * badge's `gap`, which would also open between the label's own words if
 * the label ever wrapped. */
.proposal-item-status__glyph {
  display: inline-flex;
  justify-content: center;
  width: 0.9em;
  margin-inline-end: var(--space-1);
}

.proposal-item-saved {
  margin: 0;
}

.proposal-approve-form {
  align-content: start;
  gap: var(--space-3);
}

.proposal-select-row {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-body-sm);
  cursor: pointer;
}

/* It measured 34.3 x 21 and it is a block link of its own, so WCAG
 * 2.5.8's exception for a link inside a sentence does not cover it. The
 * height comes up to the 24px minimum without the link becoming a
 * button: this is the one action on a resolved row that commits nothing,
 * and giving it a border would put it on a level with Aprobar. */
.proposal-item-edit {
  display: inline-flex;
  align-items: center;
  width: fit-content;
  min-height: var(--target-min);
  color: var(--link-color);
  font-size: var(--text-body-sm);
}

/* The wording behind a disclosure, once a row is resolved: the text is a
 * record at that point and not the task, and fifty-two open textareas
 * were the review's height problem before the tabs.
 *
 * Same shape as .proposal-existing-context - a quiet summary over a
 * bordered box - deliberately without its --bg-subtle fill. That fill
 * puts the summary's --text-secondary at 4.39:1, which is the one thing
 * 03_components.css:155 says not to do; here the box sits on the canvas
 * and the summary label is --text-primary, so it holds in both themes
 * without a second colour. */
.proposal-wording-editor {
  border: 1px solid var(--border-default);
  border-radius: var(--radius-xs);
}

.proposal-wording-editor > summary {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--control-h-sm);
  padding: 0 var(--space-3);
  color: var(--text-primary);
  font-size: var(--text-body-sm);
  cursor: pointer;
}

.proposal-wording-editor[open] > summary {
  border-bottom: 1px solid var(--border-default);
}

/* Padding on the contents and not on the box, so the summary keeps its
 * own 12px inline rhythm and the divider above still runs edge to edge. */
.proposal-wording-editor > :not(summary) {
  padding: var(--space-3);
}

/* The failure belongs to the row that caused it, not to a page-wide flash. */
.proposal-item-error {
  margin: 0;
  padding: var(--space-3);
  border: 1px solid var(--error-border);
  border-radius: var(--radius-xs);
  background: var(--error-bg);
  color: var(--text-primary);
  font-size: var(--text-body-sm);
}

@media (max-width: 1024px) {
  .proposal-item-row {
    grid-template-columns: 1fr;
    gap: var(--space-4);
  }
}

/* ── Scenario editor ─────────────────────────────────────────
 * The adjustable layer over a generated estimate: a summary
 * strip, a rail of cards, the rows by epic, and the roadmap.
 * No new colours, radii, spacing or motion values - everything
 * below composes 01_tokens.css, which is also what makes the
 * whole thing work in both themes without a second palette.
 *
 * Where a figure sits on a tinted ground, its label stays at
 * --text-primary rather than dropping to --text-secondary:
 * secondary is 4.6:1 on the canvas and 4.35:1 on --bg-subtle,
 * so the darker fills are exactly where the second text colour
 * runs out. Tiering there is done with size and case, which is
 * design.md's own rule for the same reason. */

.scenario-editor__head {
  align-items: center;
  gap: var(--space-3);
}

/* Summary before detail: somebody opening the editor wants the
 * number, not the table. */

.scenario-kpis {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: var(--space-4);
  margin: 0;
}

.scenario-kpi {
  display: grid;
  gap: var(--space-1);
  padding: var(--space-4);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
  background: var(--bg-inset);
}

/* Caps stay here: the longest of these is "Horas facturables"
 * at 17 characters, two words, which is the label case caps are
 * actually for. The threshold is around 20. */
.scenario-kpi__label {
  color: var(--text-secondary);
  font-size: var(--text-caption);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
}

.scenario-kpi__body {
  display: grid;
  gap: var(--space-1);
  margin: 0;
}

/* Two lines are reserved whether or not this value needs them. The
 * price band - "US$ 5.386 - US$ 8.080" - is the one figure in the
 * row that does not fit on one line at any tile width, and with
 * the row's height coming off its contents it pushed its own notes
 * a line below every other tile's. Reserving the second line
 * everywhere puts all five notes back on one baseline; balance is
 * what keeps the wrap from leaving one word alone on it. */
.scenario-kpi__value {
  min-height: calc(2 * var(--leading-snug) * 1em);
  font-size: var(--text-h4);
  font-weight: var(--weight-medium);
  line-height: var(--leading-snug);
  font-variant-numeric: tabular-nums;
  text-wrap: balance;
}

.scenario-kpi__note {
  color: var(--text-secondary);
  font-size: var(--text-caption);
}

/* The fifth tile's body, which was never a figure - see the note
 * in the partial. A sentence of advice about the span, under the
 * row rather than inside it. */
.scenario-kpis__note {
  max-width: var(--measure);
  margin: 0;
  font-size: var(--text-body-sm);
}

/* How the reference hours became this roster's billable ones (H41).
 * Primary colour rather than the note's secondary grey: it is the
 * sentence that makes the tiles above it readable, not an aside. */
.scenario-kpis__conversion {
  max-width: var(--measure);
  margin: 0;
  color: var(--text-primary);
  font-size: var(--text-body-sm);
}

/* ── Cards rail ─────────────────────────────────────────── */

.scenario-form {
  display: grid;
  gap: var(--space-6);
}

.scenario-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
  align-items: start;
  gap: var(--space-4);
}

.scenario-card {
  display: grid;
  align-content: start;
  gap: var(--space-3);
  padding: var(--space-4);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
  background: var(--bg-inset);
}

/* The roster is a table of people, not a field: it needs the
 * full width the other three cards share between them - which
 * is also why it is rendered last, so those three fill their
 * row instead of leaving a hole beside them. */
.scenario-card--team {
  grid-column: 1 / -1;
}

/* The card is a grid, so a button in it would stretch to the
 * whole column and read as a banner rather than an action. */
.scenario-card__action {
  justify-self: start;
}

.scenario-card__title {
  margin: 0;
  font-size: var(--text-body);
}

/* Caps go here: these are phrases, not labels - "Distancia entre
 * los escenarios" is 30 characters and "Riesgo máximo por
 * seniority" 27, both past the ~20 where all-caps stops helping.
 * The subtitle still reads as subordinate to the 16px title above
 * it, at 13px Medium against the title's book weight. */
.scenario-card__subtitle {
  margin: 0;
  font-size: var(--text-eyebrow);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-caption);
}

.scenario-card__hint {
  font-size: var(--text-caption);
}

.scenario-card__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
  gap: var(--space-3);
}

.scenario-field {
  display: grid;
  gap: var(--space-1);
  margin: 0;
}

.scenario-field--wide {
  grid-column: span 2;
}

/* Caps stay: one word each, "Velocity" being the longest at 8. */
.scenario-field__label {
  color: var(--text-secondary);
  font-size: var(--text-caption);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
}

.scenario-readouts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
  gap: var(--space-3);
  margin: 0;
}

.scenario-readouts dt {
  color: var(--text-secondary);
  font-size: var(--text-caption);
}

.scenario-readouts dd {
  margin: 0;
  font-size: var(--text-body);
}

/* `.scenario-waves` / `.scenario-wave` went with the wave list the
 * delivery card used to carry - see the note there. */
.scenario-per-dev {
  display: grid;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.scenario-per-dev li {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  font-size: var(--text-body-sm);
}

/* ── Roster ─────────────────────────────────────────────── */

.scenario-roster {
  display: grid;
  gap: var(--space-3);
}

.scenario-dev {
  display: grid;
  gap: var(--space-3);
  padding: var(--space-3);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-xs);
  background: var(--bg-surface);
}

.scenario-dev__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
  gap: var(--space-3);
}

/* auto-fit wraps a field to a new row the moment the row above is full,
 * regardless of how many fields are left - with six fields (name counted
 * twice for its --wide span) that is almost always Streams alone, stranded
 * next to five empty tracks it isn't sized to fill. Giving it the row
 * outright reads as one field with room to breathe, not a layout accident. */
.scenario-dev__grid > .scenario-field:last-child {
  grid-column: 1 / -1;
}

.scenario-dev__foot,
.scenario-dev__removal,
.scenario-dev__confirm,
.scenario-dev__velocity {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
}

.scenario-dev__foot {
  justify-content: space-between;
  gap: var(--space-3);
}

.scenario-dev__velocity,
.scenario-dev__confirm-question {
  font-size: var(--text-body-sm);
}

/* The confirm step is laid out with flex, which would override
 * the [hidden] attribute's own display:none. */
.scenario-dev__confirm[hidden],
.scenario-dev__removal [hidden] {
  display: none;
}

/* ── Rows by epic ───────────────────────────────────────── */

.scenario-rows {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-3);
}

.scenario-rows__title {
  margin: 0;
  font-size: var(--text-body);
}

.scenario-epic {
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
  background: var(--bg-inset);
}

.scenario-epic__summary {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-4);
  font-size: var(--text-body-sm);
  list-style: none;
  cursor: pointer;
}

.scenario-epic__summary::-webkit-details-marker {
  display: none;
}

.scenario-epic__title {
  font-weight: var(--weight-medium);
}

.scenario-epic__chevron {
  transition: transform var(--motion-base) var(--ease-standard);
}

.scenario-epic[open] > .scenario-epic__summary .scenario-epic__chevron {
  transform: rotate(90deg);
}

/* 72rem rather than 64. The two select columns were being handed
 * whatever was left over and were clipping their own current
 * values - "Sin dependenc..." and "Lo elige el sch..." in all 33
 * rows, 66 unreadable values - which is a worse trade than the
 * extra horizontal scroll inside a wrapper that already scrolls.
 * A control whose value cannot be read is not a control. */
.scenario-table {
  width: 100%;
  min-width: 72rem;
  border-collapse: separate;
  border-spacing: 0;
}

/* Caps stay: column headers, one or two words, "Responsable"
 * being the longest at 11, and they are nowrap so none of them
 * can be pushed out of its cell. */
.scenario-table thead th {
  padding: var(--space-2) var(--space-3);
  border-top: 1px solid var(--border-default);
  border-bottom: 1px solid var(--border-default);
  background: var(--bg-subtle);
  color: var(--text-primary);
  font-size: var(--text-caption);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-caption);
  text-align: left;
  text-transform: uppercase;
  white-space: nowrap;
}

.scenario-table tbody th,
.scenario-table tbody td {
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--border-default);
  font-size: var(--text-body-sm);
  font-weight: var(--weight-book);
  text-align: left;
  vertical-align: middle;
}

.scenario-table tbody tr:last-child th,
.scenario-table tbody tr:last-child td {
  border-bottom: 0;
}

.scenario-table__scope {
  width: 1%;
}

.scenario-table__task {
  min-width: 14rem;
}

.scenario-table__task-name {
  display: block;
  font-weight: var(--weight-medium);
}

.scenario-table__note,
.scenario-table__lane {
  display: block;
  color: var(--text-secondary);
  font-size: var(--text-caption);
}

/* The width belongs to the cell. `max-width` on the input alone
 * let the column collapse to 39px - narrower than the input's own
 * horizontal padding - so the digit inside was clipped out of
 * sight and every one of the 33 rows showed a blank box. A blank
 * box in a column called OLA reads as a missing value, not as an
 * optional override. */
.scenario-table__wave-cell {
  min-width: 5.5rem;
}

.scenario-table__wave {
  width: 100%;
  max-width: 5rem;
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* Wide enough for the longest value each one can hold, because
 * the value of a closed select is the only thing about it anybody
 * reads: "Sin dependencia" and "Lo elige el scheduler" are the
 * defaults, so they are what the column has to fit. */
/* The operator costs a character, so the cell has to carry it:
 * without this the select clipped "x1,0" to "x1,(". */
.scenario-table__risk-cell {
  min-width: 6rem;
}

.scenario-table__dep-cell {
  min-width: 13rem;
}

.scenario-table__owner-cell {
  min-width: 12rem;
}

.scenario-table__risk-cell > select,
.scenario-table__dep-cell > select,
.scenario-table__owner-cell > select {
  width: 100%;
}

/* The resolved lane reads as a consequence of the control above
 * it rather than as a second value competing with it. */
.scenario-table__lane-arrow {
  margin-right: 0.15em;
  color: var(--text-tertiary);
}

/* The loudest thing in the table was also the least informative:
 * 33 filled squares in saturated brand blue, all checked, in the
 * first column - which is where the eye lands first. Brand blue
 * is the page's emphasis colour and it was spent on the one
 * column where every row says the same thing. Neutral keeps the
 * control legible and checked-ness obvious while giving the
 * emphasis back to the figures. Unchecking one is the event worth
 * seeing, and the row's own strike-through and badge already mark
 * it. */
.scenario-table__scope input[type="checkbox"] {
  accent-color: var(--text-secondary);
}

.scenario-table__figure {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Struck through and badged rather than dimmed. Lowering the
 * opacity of a whole row is the usual move and it takes the
 * notes under 4.5:1, so the cue is the line and the badge. */
.scenario-table__row--out .scenario-table__task-name {
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}

/* ── Roadmap ────────────────────────────────────────────────
 * The axis is calendar days, so a week is always 7 columns and
 * the weekend is always the 6th and 7th of them - see the
 * partial for why working days would not do.
 *
 * Everything on the axis is placed as a PERCENTAGE of the
 * track and not in day-sized pixels, while the track's own
 * width is days x --roadmap-day with a 100% floor. That way a
 * long plan scrolls at a readable day width and a two-day one
 * still spans the panel, instead of huddling in the first
 * 52px of it with the months and the weekend bands beside it
 * measuring a calendar nothing is drawn against. */

/* H45: at 26px a day, a one-day task was a 36px bar and its name
 * came out as "In..." or "C...". 48px a day and a taller lane that
 * lets the name take two lines is what makes most names readable
 * without opening a tooltip; the plan scrolls sideways either way.
 *
 * Three header rows now - months, weeks and waves - so the header
 * is three of the old half-rows tall.
 *
 * --roadmap-tip-h no longer reserves room for the wave labels, which
 * moved to the header. It keeps a strip for the tooltip of a bar in
 * the last lane when JavaScript is off; with it on the tooltip is
 * position: fixed - see roadmap_tooltip_controller.js. */
.roadmap {
  --roadmap-day: 48px;
  --roadmap-label-line: 1rem;
  /* Bar, then two label rows of two lines each - see .roadmap-bar-label. */
  --roadmap-lane-h: calc(var(--roadmap-bar-h) + 4 * var(--roadmap-label-line) + 1.25rem);
  --roadmap-bar-h: 40px;
  --roadmap-head-h: 4.5rem;
  --roadmap-tip-h: 2rem;

  display: grid;
  gap: var(--space-3);
}

.roadmap__title {
  margin: 0;
  font-size: var(--text-body);
}

.roadmap__hint {
  font-size: var(--text-caption);
}

.roadmap__body {
  display: grid;
  grid-template-columns: minmax(8rem, 12rem) minmax(0, 1fr);
  overflow: hidden;
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
}

.roadmap__legend {
  border-right: 1px solid var(--border-default);
  background: var(--bg-inset);
}

/* Caps stay, but this one was close enough to measure rather
 * than argue: "Carriles del roadmap" is 20 characters, which is
 * the threshold itself, and it wraps to 91px of a 127px column
 * at 390px - so it is not being pushed out of its box either. A
 * fourth word here would be the point to drop them. */
.roadmap__legend-head {
  display: flex;
  align-items: center;
  height: var(--roadmap-head-h);
  margin: 0;
  padding: 0 var(--space-3);
  border-bottom: 1px solid var(--border-default);
  color: var(--text-secondary);
  font-size: var(--text-caption);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
}

.roadmap__legend-lane {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  height: var(--roadmap-lane-h);
  margin: 0;
  padding: 0 var(--space-3);
  border-bottom: 1px solid var(--border-default);
  font-size: var(--text-body-sm);
}

.roadmap__legend-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.roadmap__scroll {
  overflow-x: auto;
}

/* --roadmap-day-fit comes from the partial: the day width at which
 * every short bar's name fits under it. The larger of the two wins. */
.roadmap__track {
  position: relative;
  width: calc(var(--roadmap-days) * max(var(--roadmap-day), var(--roadmap-day-fit, 0px)));
  min-width: 100%;
}

.roadmap__months,
.roadmap__weeks,
.roadmap__waves {
  position: relative;
  height: calc(var(--roadmap-head-h) / 3);
  border-bottom: 1px solid var(--border-default);
}

.roadmap__month,
.roadmap__week {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--from) / var(--roadmap-days) * 100%);
  display: flex;
  align-items: center;
  padding-left: var(--space-1);
  border-left: 1px solid var(--border-default);
  color: var(--text-secondary);
  font-size: var(--text-caption);
  white-space: nowrap;
}

.roadmap__month,
.roadmap__week {
  width: calc(var(--span) / var(--roadmap-days) * 100%);
}

.roadmap__field {
  position: relative;
  padding-bottom: var(--roadmap-tip-h);
}

.roadmap__bands,
.roadmap__cuts {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.roadmap__cuts {
  z-index: 2;
}

.roadmap__weekend,
.roadmap__tick,
.roadmap__cut {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--from) / var(--roadmap-days) * 100%);
}

.roadmap__weekend {
  width: calc(var(--span) / var(--roadmap-days) * 100%);
  background: var(--bg-subtle);
}

.roadmap__tick {
  width: 1px;
  background: var(--border-default);
}

.roadmap__cut {
  width: 0;
  border-left: 2px dashed var(--border-strong);
}

/* One wave per stretch of axis, from its start to the next one's,
 * so no two labels can overlap however many waves there are. A
 * label longer than its stretch is cut with an ellipsis inside it;
 * the title and the text itself carry it whole. */
.roadmap__wave {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--from) / var(--roadmap-days) * 100%);
  display: flex;
  align-items: center;
  gap: var(--space-1);
  width: calc(var(--span) / var(--roadmap-days) * 100%);
  min-width: 0;
  padding: 0 var(--space-1);
  overflow: hidden;
  border-left: 2px dashed var(--border-strong);
  font-size: var(--text-caption);
  white-space: nowrap;
}

.roadmap__cut-name {
  flex: none;
  color: var(--text-primary);
  font-weight: var(--weight-medium);
}

.roadmap__cut-lead {
  min-width: 0;
  overflow: hidden;
  color: var(--text-secondary);
  text-overflow: ellipsis;
}

.roadmap__lanes {
  position: relative;
  margin: 0;
  padding: 0;
  list-style: none;
}

.roadmap__lane {
  position: relative;
  height: var(--roadmap-lane-h);
  border-bottom: 1px solid var(--border-default);
}

.roadmap-bar {
  position: absolute;
  top: var(--space-2);
  left: calc(var(--from) / var(--roadmap-days) * 100%);
  z-index: 1;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: calc(var(--span) / var(--roadmap-days) * 100%);
  /* A row worth an hour is a sliver of a ten-week track. The
   * floor keeps it clickable and keeps its name readable; the
   * exact dates are in the tooltip either way. */
  min-width: 2.25rem;
  height: var(--roadmap-bar-h);
  padding: 0 var(--space-2);
  overflow: hidden;
  border: 1px solid var(--primary-edge);
  border-radius: var(--radius-xs);
  background: var(--primary-700);
  color: var(--text-on-primary);
  font-size: var(--text-caption);
}

/* The bar clips its own label, so it has to stop clipping for
 * the tooltip to be able to leave it. */
.roadmap-bar:hover,
.roadmap-bar:focus,
.roadmap-bar:focus-visible {
  z-index: 5;
  overflow: visible;
}

/* Two lines before the ellipsis (H45): most task names are three
 * to six words, and one line of a two-day bar held one of them. */
.roadmap-bar__name {
  display: -webkit-box;
  min-width: 0;
  overflow: hidden;
  line-height: var(--leading-snug);
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
}

.roadmap-bar__hours {
  flex: none;
  font-variant-numeric: tabular-nums;
}

/* A bar narrower than its own contents printed "7.8" with the unit
 * clipped off by the bar's own overflow - a number whose unit has
 * been cut away is worse than no number, because it still reads as
 * a fact. Under four calendar days the hours come off the bar
 * entirely; the tooltip and the aria-label both still carry them,
 * and the name is what the remaining pixels are worth.
 *
 * The threshold is decided in the partial and not by a container
 * query, and that is not a preference: `container-type` implies
 * layout containment, which would make every bar a containing
 * block for `position: fixed` - and the open tooltip is switched
 * to `position: fixed` precisely so that no ancestor of the bar
 * can clip or anchor it. */
.roadmap-bar--narrow .roadmap-bar__hours {
  display: none;
}

/* H45: a bar too short for its own name gives it to the label row
 * beneath it (see the partial), so the bar keeps its colour and its
 * flag and the name is read whole, starting under the bar's left edge.
 * The label is aria-hidden: the bar's aria-label already names it. */
.roadmap-bar--named-outside .roadmap-bar__name {
  display: none;
}

/* Two lines each, in two alternating rows: a label wraps inside its
 * stretch instead of being cut at the first word, and the ellipsis is
 * only for a name longer than two lines of that stretch. */
.roadmap-bar-label {
  position: absolute;
  top: calc(var(--space-2) + var(--roadmap-bar-h) + 2px);
  left: calc(var(--from) / var(--roadmap-days) * 100%);
  display: -webkit-box;
  width: calc(var(--span) / var(--roadmap-days) * 100%);
  max-height: calc(2 * var(--roadmap-label-line));
  padding-right: var(--space-1);
  overflow: hidden;
  color: var(--text-secondary);
  font-size: var(--text-caption);
  line-height: var(--roadmap-label-line);
  overflow-wrap: anywhere;
  pointer-events: none;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
}

/* The second of the two alternating label rows. */
.roadmap-bar-label--second {
  top: calc(var(--space-2) + var(--roadmap-bar-h) + 4px + 2 * var(--roadmap-label-line));
}

/* ── Colour by epic ──────────────────────────────────────────
 * The twelve fill/edge pairs and the label colour are tokens in
 * 01_tokens.css, under "Epic colour ramp" - they were the last
 * colour literals left outside that file. The derivation (OKLCH at
 * a fixed L=0.43 / C=0.10, thirty degrees apart, from the product
 * blue's hue), the reason the palette is the same in both themes,
 * and the measured contrast of every step are written down there,
 * beside the values they describe.
 *
 * What belongs here is only the mapping from a bar's modifier to
 * its pair. */

.roadmap-bar--epic-0  { background: var(--epic-0-fill);  border-color: var(--epic-0-edge); }
.roadmap-bar--epic-1  { background: var(--epic-1-fill);  border-color: var(--epic-1-edge); }
.roadmap-bar--epic-2  { background: var(--epic-2-fill);  border-color: var(--epic-2-edge); }
.roadmap-bar--epic-3  { background: var(--epic-3-fill);  border-color: var(--epic-3-edge); }
.roadmap-bar--epic-4  { background: var(--epic-4-fill);  border-color: var(--epic-4-edge); }
.roadmap-bar--epic-5  { background: var(--epic-5-fill);  border-color: var(--epic-5-edge); }
.roadmap-bar--epic-6  { background: var(--epic-6-fill);  border-color: var(--epic-6-edge); }
.roadmap-bar--epic-7  { background: var(--epic-7-fill);  border-color: var(--epic-7-edge); }
.roadmap-bar--epic-8  { background: var(--epic-8-fill);  border-color: var(--epic-8-edge); }
.roadmap-bar--epic-9  { background: var(--epic-9-fill);  border-color: var(--epic-9-edge); }
.roadmap-bar--epic-10 { background: var(--epic-10-fill); border-color: var(--epic-10-edge); }
.roadmap-bar--epic-11 { background: var(--epic-11-fill); border-color: var(--epic-11-edge); }

/* --epic-label and not --text-on-primary: the twelve fills are
 * fixed across both themes, so the label has to be fixed with
 * them - it is the only colour they have been measured against. */
.roadmap-bar[class*="roadmap-bar--epic-"] {
  color: var(--epic-label);
}

/* ── Epic legend ─────────────────────────────────────────────
 * Under the chart rather than over it: the chart is what somebody
 * came for, and a key is only read once a colour has been seen.
 * It wraps instead of scrolling, because twelve epic titles do not
 * fit on one line at any width this app is used at. */

.roadmap__epics {
  display: grid;
  gap: var(--space-2);
}

.roadmap__epics-head {
  margin: 0;
  color: var(--text-secondary);
  font-size: var(--text-caption);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
}

.roadmap__epic-list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-4);
  margin: 0;
  padding: 0;
  list-style: none;
}

.roadmap__epic {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
  font-size: var(--text-body-sm);
}

.roadmap__epic-swatch {
  flex: none;
  width: 0.85rem;
  height: 0.85rem;
  border: 1px solid;
  border-radius: var(--radius-xs);
}

/* The app's own tooltip rather than the browser's title, and it
 * opens on focus as well as on hover: a title attribute exists
 * for a mouse only, which would put half of what the roadmap
 * knows out of reach of anybody using a keyboard.
 *
 * Position: the bar stops clipping above, but .roadmap__scroll is
 * a scroller and .roadmap__body clips so its corners hold, so a
 * tooltip anchored to its bar loses whatever runs past the right
 * edge - 183px of 301px for the last bar of a ten-week plan at
 * 1440px. So roadmap_tooltip_controller.js switches the open one
 * to position: fixed and clamps it to the viewport, where no
 * ancestor's overflow reaches it. These declarations are what it
 * degrades to with no JavaScript, which is where the roadmap was
 * before, and they are also what places the tooltip of a bar that
 * is nowhere near an edge.
 *
 * The cap is the reason the clamp always fits: 20rem is the
 * readable measure for six labelled lines, and at 390px the
 * viewport minus the page's own two gutters is less than that. A
 * tooltip wider than the window cannot be clamped into it. */

.roadmap-tooltip {
  position: absolute;
  top: calc(100% + var(--space-2));
  left: 0;
  z-index: 6;
  display: none;
  width: max-content;
  max-width: min(20rem, calc(100vw - 2 * var(--space-4)));
  padding: var(--space-3);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-xs);
  background: var(--bg-surface);
  box-shadow: var(--shadow-popover);
  color: var(--text-primary);
  font-size: var(--text-caption);
  text-align: left;
  white-space: normal;
}

.roadmap-bar:hover .roadmap-tooltip,
.roadmap-bar:focus .roadmap-tooltip,
.roadmap-bar:focus-visible .roadmap-tooltip,
.roadmap-bar:focus-within .roadmap-tooltip {
  display: grid;
  gap: var(--space-1);
}

.roadmap-tooltip__task {
  display: block;
  margin-bottom: var(--space-1);
  font-weight: var(--weight-medium);
}

.roadmap-tooltip__line {
  display: grid;
  grid-template-columns: 5rem minmax(0, 1fr);
  gap: var(--space-2);
}

.roadmap-tooltip__key {
  color: var(--text-secondary);
}

/* Narrower than on a desktop, but not the 18px it was: that is
 * the width that cut every name to a letter (H45). */
@media (max-width: 1024px) {
  .roadmap {
    --roadmap-day: 36px;
  }

  .scenario-field--wide {
    grid-column: span 1;
  }
}

@media (max-width: 640px) {
  .roadmap__body {
    grid-template-columns: minmax(6rem, 8rem) minmax(0, 1fr);
  }
}

/* ── Scenario selector ──────────────────────────────────────
 * The list of an estimate's scenarios plus everything done to
 * the collection. Composes 01_tokens.css like the rest of the
 * editor above: no colour, radius, spacing or motion value is
 * introduced here.
 *
 * The current tab is marked three ways and only one of them is
 * colour: the weight goes to medium, a 2px rule sits under it,
 * and the row it names in the comparison table carries a text
 * badge. Colour alone would leave the state invisible to
 * anybody who cannot see the hue and to a printout. */

.scenario-selector {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-3);
  padding-bottom: var(--space-4);
  border-bottom: 1px solid var(--border-default);
}

.scenario-tabs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.scenario-tab {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--control-h-sm);
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--border-control);
  border-bottom: 2px solid var(--border-control);
  border-radius: var(--radius-xs);
  background: var(--bg-surface);
  color: var(--text-primary);
  font-size: var(--text-body-sm);
}

.scenario-tab:hover {
  border-color: var(--border-strong);
  background: var(--bg-subtle);
  text-decoration: none;
}

/* A scenario's name is user text with nowhere near the review a piece of
 * copy gets, and nothing upstream caps it to words a layout can break on -
 * NAME_MAX_LENGTH (60) allows a single unbroken run of characters. Without
 * this, one such name would push the tab wider than the row instead of
 * wrapping inside it. */
.scenario-tab__name {
  min-width: 0;
  overflow-wrap: anywhere;
}

.scenario-tab--current {
  border-color: var(--primary-edge);
  border-bottom-color: var(--primary-edge);
  background: var(--info-bg);
}

.scenario-tab--current .scenario-tab__name {
  font-weight: var(--weight-medium);
}

/* ── Actions on the active scenario ─────────────────────── */

.scenario-selector__bar,
.scenario-selector__actions,
.scenario-rename,
.scenario-selector__removal,
.scenario-selector__confirm {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: var(--space-2);
  margin: 0;
}

.scenario-selector__bar {
  justify-content: space-between;
  gap: var(--space-4);
}

.scenario-selector__actions,
.scenario-selector__removal,
.scenario-selector__confirm {
  align-items: center;
}

/* Wide enough that a name is readable while it is being typed,
 * and not so wide that it reads as the page's main field. */
.scenario-rename__field {
  min-width: 14rem;
}

.scenario-selector__note,
.scenario-selector__confirm-question {
  max-width: 34rem;
  font-size: var(--text-body-sm);
}

/* Same unbroken-name reasoning as .scenario-tab__name: this span carries
 * the name again, inside a flex row that would rather stretch than wrap
 * a flex item's content by default. */
.scenario-selector__confirm-question {
  min-width: 0;
  overflow-wrap: anywhere;
}

/* Both are laid out with flex, which would otherwise win over
 * the [hidden] attribute's own display:none. */
.scenario-selector__confirm[hidden],
.scenario-selector__removal [hidden] {
  display: none;
}

/* ── Scenario comparison ────────────────────────────────────
 * A row per scenario, a column per figure. Weeks lead every
 * calendar cell and working days follow as the precision, so
 * the detail line is the only place --text-secondary is used -
 * and it sits on --bg-surface, where it measures 4.83:1. */

.scenario-comparison {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-3);
}

.scenario-comparison__table {
  width: 100%;
  min-width: 72rem;
  border-collapse: separate;
  border-spacing: 0;
}

/* Caps stay, same reasoning as the scenario table's headers:
 * "Delta en días hábiles" is the longest at 21, three words, and
 * nowrap keeps every one of them inside its cell. */
.scenario-comparison__table thead th {
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--border-default);
  background: var(--bg-subtle);
  color: var(--text-primary);
  font-size: var(--text-caption);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-caption);
  text-align: left;
  text-transform: uppercase;
  white-space: nowrap;
}

/* Every column but the name is a figure, and the partial's own note is the
 * reason this matters: a comparison is read down a column, not across a row,
 * so the header has to line up over digits that are themselves right-aligned
 * - a left-aligned heading over a right-aligned column reads as belonging to
 * the column beside it. */
.scenario-comparison__table thead th:not(:first-child) {
  text-align: right;
}

.scenario-comparison__table tbody th,
.scenario-comparison__table tbody td {
  padding: var(--space-3);
  border-bottom: 1px solid var(--border-default);
  font-size: var(--text-body-sm);
  font-weight: var(--weight-book);
  text-align: left;
  vertical-align: top;
}

.scenario-comparison__table tbody tr:last-child th,
.scenario-comparison__table tbody tr:last-child td {
  border-bottom: 0;
}

/* Marked by fill AND by the text badge the partial puts in the
 * name cell, for the reason the current tab is marked twice. An opaque
 * fill here would sit above .table-wrapper's own scroll-fade layers in
 * paint order and block them outright for this row - a reader scanning
 * the base scenario, almost always this row, would get no hint that
 * PRECIO and the columns after it are one scroll away. color-mix keeps
 * the fill translucent enough for the fade to still read through it. */
.scenario-comparison__row--current > th,
.scenario-comparison__row--current > td {
  background: color-mix(in srgb, var(--bg-inset) 55%, transparent);
}

.scenario-comparison__name {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  min-width: 12rem;
}

.scenario-comparison__scenario {
  font-weight: var(--weight-medium);
}

/* Right-aligned to match the header above it: tabular-nums only lines up
 * digits between rows when the box they sit in is anchored on the same
 * edge every time.
 *
 * `tbody td` is in the selector for weight rather than matching - the same
 * trick this file already uses elsewhere - because `.scenario-comparison__table
 * tbody td` above this rule carries two elements against this class's one and
 * would otherwise win the cascade regardless of source order, leaving the
 * figures left-aligned under the right-aligned headers this rule is supposed
 * to match. */
.scenario-comparison__table tbody td.scenario-comparison__figure {
  font-variant-numeric: tabular-nums;
  text-align: right;
  white-space: nowrap;
}

.scenario-comparison__primary {
  display: block;
}

.scenario-comparison__detail {
  display: block;
  color: var(--text-secondary);
  font-size: var(--text-caption);
}

/* The billable cell carries its conversion underneath (H41), three
 * terms long. It wraps inside a fixed measure instead of widening a
 * twelve-column table by another 300px. */
.scenario-comparison__table tbody td[data-scenario-comparison="billable-hours"] {
  min-width: 10rem;
  white-space: normal;
}

.scenario-spread {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: var(--space-3);
  margin: 0;
}

.scenario-spread dt {
  color: var(--text-secondary);
  font-size: var(--text-caption);
}

.scenario-spread dd {
  margin: 0;
  font-size: var(--text-body-sm);
  font-variant-numeric: tabular-nums;
}

@media (max-width: 640px) {
  .scenario-selector__bar {
    display: grid;
    justify-content: stretch;
  }
}

/* ── Dependencies the schedule could not honour ─────────────
 * ScenarioPlan reports these per row and never blocks over
 * them, so the only thing that keeps the page honest is that
 * they are visible: a bar, a Dep cell and a comparison row
 * whose dates came out of ignoring an edge cannot look like
 * ones whose dates honoured it.
 *
 * Every one of the three is marked twice, and only one of the
 * two marks is colour - an icon here, a dashed edge on the bar,
 * a counted badge in the comparison. That is 01_tokens.css's
 * own rule for the semantic hues: the 500s fail 4.5:1 as text
 * on a light surface, so the copy stays at --text-primary and
 * the hue lives in the border, the icon and the fill. */

.scenario-table__dep-warning {
  display: flex;
  align-items: flex-start;
  gap: var(--space-1);
  margin-top: var(--space-1);
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--warning-border);
  border-radius: var(--radius-xs);
  background: var(--warning-bg);
  /* Overrides scenario-table__note, which it also carries: a
   * warning at --text-secondary is the wrong weight for what it
   * says, and secondary on --warning-bg is under 4.5:1 anyway. */
  color: var(--text-primary);
  font-size: var(--text-caption);
}

/* Dashed and thicker, not merely recoloured: a solid bar in a
 * different hue is one cue, and the edge style survives both
 * themes and a greyscale print. */
.roadmap-bar--dep-warning {
  border-width: 2px;
  border-style: dashed;
  border-color: var(--warning-500);
}

.roadmap-bar__flag {
  flex: none;
  color: var(--text-on-primary);
}

.roadmap-tooltip__warning {
  display: block;
  margin-top: var(--space-1);
  color: var(--text-primary);
}

.scenario-comparison__flag {
  gap: var(--space-1);
  border-color: var(--warning-border);
  background: var(--warning-bg);
  color: var(--text-primary);
}

.scenario-comparison__note {
  margin: 0;
}
