/* Gentoo-specific additions on top of the shared styles.css, which is byte-identical to
   corduroy's and is where the theme lives: the tokens (--bs-*, --surface-*, --text-muted, the
   accent), the light and dark palettes, and the Bootstrap component restyles. Keep this file
   additive, and keep it on the tokens: a raw hex here is right only for a color that is data
   rather than theme -- drills and markers, the mask swatches, the FR4 substrate -- and those
   live in the fab/PCB block below, where they say so. The one token that block defines rather
   than borrows is `--board-ground`, the gray a board is drawn on; it is theme, not data, and
   both faces and both views take it from there. */

/* ─── Brand ──────────────────────────────────────────────────────────────────── */
/* The shared banner, then the mascot (graphics/logo/gentoo); the same rules as secretary's. */
.app-nav .gentoo-brand {
    display: flex;
    align-items: center;
    gap: 0.45rem;
}

.app-nav .brand-mascot {
    display: block;
    height: 2.1rem;
    width: auto;
    margin: -0.2rem 0 -0.35rem;  /* taller than the banner, without growing the navbar */
}

/* Black outlines are the mascot's edge; on the dark navbar a thin light rim keeps it. */
[data-bs-theme="dark"] .app-nav .brand-mascot {
    filter: drop-shadow(0 0 0.6px rgba(255, 255, 255, 0.9)) drop-shadow(0 0 0.6px rgba(255, 255, 255, 0.6));
}

/* ─── Numeric columns ────────────────────────────────────────────────────────── */
/* Quantities and money line up only with tabular figures; without this a column of
   "1,240 / 409 / 91" visibly wobbles. */
.num {
    font-variant-numeric: tabular-nums;
    text-align: right;
    white-space: nowrap;
}

/* ─── Staleness banner ───────────────────────────────────────────────────────── */
/* Same shape as corduroy's .backup-site-banner, amber instead of orange, because it means
   "these numbers may be old" rather than "this whole site is a replica". */
.sync-stale-banner {
    background-color: var(--bs-warning-bg-subtle);
    color: var(--bs-warning-text-emphasis);
    border-bottom: 2px solid var(--bs-warning);
    padding: 0.5rem 1rem;
    font-size: 0.85rem;
    font-weight: 500;
    text-align: center;
}

.sync-stale-banner.sync-stale-critical {
    background-color: var(--bs-danger-bg-subtle);
    color: var(--bs-danger-text-emphasis);
    border-bottom-color: var(--bs-danger);
}

/* ─── Shortfall table ────────────────────────────────────────────────────────── */
.shortfall-blocking {
    /* Left rail rather than a full row tint: the row already carries a Blocking badge, and a
       red row makes the whole table read as an error state. */
    box-shadow: inset 3px 0 0 var(--bs-danger);
}

.shortfall-blocking td:first-child {
    font-weight: 600;
}

/* ─── BOM disclosure tree ────────────────────────────────────────────────────── */
/* Multi-level drill-down uses <details>/<summary> so it works with JS disabled. */
.bom-tree details {
    margin-left: 1.25rem;
    border-left: 1px solid rgba(var(--bs-emphasis-color-rgb), 0.08);
    padding-left: 0.75rem;
}

.bom-tree > details {
    margin-left: 0;
    border-left: none;
    padding-left: 0;
}

.bom-tree summary {
    cursor: pointer;
    padding: 0.25rem 0;
    font-size: 0.875rem;
}

.bom-tree summary:hover {
    background-color: rgba(var(--bs-emphasis-color-rgb), 0.03);
}

/* ─── Step chart (server-rendered inline SVG) ────────────────────────────────── */
.step-chart {
    display: block;
    width: 100%;
    height: auto;
    max-width: 100%;
}

.step-chart .chart-grid {
    stroke: rgba(var(--bs-emphasis-color-rgb), 0.08);
    stroke-width: 1;
}

.step-chart .chart-axis {
    stroke: var(--bs-tertiary-color);
    stroke-width: 1;
}

.step-chart .chart-line {
    fill: none;
    stroke: var(--bs-primary);
    stroke-width: 2;
    stroke-linejoin: round;
}

.step-chart .chart-area {
    fill: rgba(var(--bs-primary-rgb), 0.10);
}

.step-chart .chart-label {
    font-size: 11px;
    fill: var(--text-muted);
}

.step-chart .chart-point {
    fill: var(--bs-primary);
}

/* Hover targets sit above the plot and carry a <title> child, so tooltips are native and the
   chart needs no JavaScript at all. */
.step-chart .chart-hover {
    fill: transparent;
}

.step-chart .chart-hover:hover {
    fill: rgba(var(--bs-primary-rgb), 0.08);
}

/* ─── Sparkline (dashboard rows) ─────────────────────────────────────────────── */
.sparkline {
    vertical-align: middle;
}

.sparkline .spark-line {
    fill: none;
    stroke: var(--bs-primary);
    stroke-width: 1.5;
}

/* ─── Empty states ───────────────────────────────────────────────────────────── */
.empty-state {
    padding: 2.5rem 1.25rem;
    text-align: center;
    color: var(--text-muted);
    font-size: 0.9rem;
}

/* ─── Part thumbnails ────────────────────────────────────────────────────────── */
/* Fixed box so rows keep a constant height whether or not a part has a picture --
   otherwise a BOM table jitters as images load in. */
.part-thumb {
    width: 32px;
    height: 32px;
    object-fit: contain;
    border-radius: 0.25rem;
    /* White in both themes: product photos have white backgrounds, and a photo on a dark well
       is a photo with a visible box around it. */
    background-color: #fff;
    border: 1px solid rgba(var(--bs-emphasis-color-rgb), 0.08);
    display: inline-block;
    vertical-align: middle;
}

.part-thumb-link:hover .part-thumb {
    border-color: var(--bs-primary);
}

/* The hover preview of a part picture (js/magnify.js): fixed, so it moves nothing. White like
   the tiles: product photos have white backgrounds. */
.part-magnify {
    position: fixed;
    z-index: 1090;
    width: 280px;
    height: 280px;
    padding: 6px;
    background: #fff;
    border: 1px solid rgba(var(--bs-emphasis-color-rgb), 0.15);
    border-radius: 0.5rem;
    box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.25);
    pointer-events: none;
}
.part-magnify[hidden] { display: none; }
.part-magnify img { width: 100%; height: 100%; object-fit: contain; display: block; }
.part-magnify.loading img { visibility: hidden; }
.part-magnify.loading { background: #fff linear-gradient(90deg, #fff, #f1f3f5, #fff) 0 0 / 200% 100%; }
@media (hover: none) { .part-magnify { display: none; } }

.part-thumb-blank {
    background-color: var(--surface-2);
    border-style: dashed;
}

/* A part's name as a link to its page (partials/part_link.html): reads as the name, underlines
   on hover; the InvenTree arrow beside it stays small. */
.part-link { text-decoration: none; }
.part-link:hover, .part-link:focus-visible { text-decoration: underline; }
.part-link code { color: inherit; }
/* The jump to another site, after a value and its copy button (partials/value_actions.html). */
.value-external, .part-it-link { font-size: .8em; text-decoration: none; color: var(--bs-secondary-color); }
.value-external:hover, .part-it-link:hover { color: var(--bs-primary); }
.value-actions .copy-btn, .value-actions .value-external { margin-left: .25rem; }
.part-thumb-link span.part-thumb { display: inline-block; }

/* The image column should never win space from the part name. */
.col-thumb {
    width: 44px;
    padding-right: 0 !important;
}

/* ─── Sourcing ───────────────────────────────────────────────────────────────── */
/* The asterisk marking a line-level override. Deliberately part of the badge rather than a
   separate column: it is a qualifier on the value, not a fact of its own. */
.sourcing-override-dot {
    font-weight: 700;
    margin-left: 0.15rem;
}

/* Sticky bulk-assign bar on the BOM page. Without it, selecting rows near the bottom of an
   88-line BOM means scrolling back to the top to act on them. */
.bulk-bar {
    position: sticky;
    bottom: 0;
    z-index: 5;
    background-color: var(--surface-1);
    border-top: 2px solid var(--bs-primary);
    box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
    padding: 0.75rem 1.25rem;
}

.risk-BLOCKED td:first-child,
.risk-SHORT td:first-child {
    box-shadow: inset 3px 0 0 var(--bs-danger);
}

.risk-UNKNOWN td:first-child,
.risk-THIN td:first-child {
    box-shadow: inset 3px 0 0 var(--bs-warning);
}

/* ─── Two-series buildability chart ──────────────────────────────────────────── */
/* The upper line is what the parts PFB buys support; the lower is what can actually be built
   once the external parts count too. The lower can never exceed the upper (strictly more
   constraints), so the band between them is exactly the capacity external parts hold back. */
.chart-line.line-ours   { stroke: var(--bs-primary); }
.chart-line.line-all    { stroke: var(--bs-success); stroke-dasharray: 5 3; }
.chart-point.point-ours { fill: var(--bs-primary); }
.chart-point.point-all  { fill: var(--bs-success); }

.chart-band {
    fill: rgba(var(--bs-danger-rgb), 0.13);
    stroke: none;
}

.chart-legend {
    font-size: 0.8rem;
    color: var(--bs-body-color);
}

.legend-item {
    display: inline-flex;
    align-items: center;
    margin-right: 1.25rem;
}

.legend-swatch {
    width: 1.1rem;
    height: 0.3rem;
    border-radius: 0.15rem;
    margin-right: 0.4rem;
    display: inline-block;
}

.swatch-ours { background-color: var(--bs-primary); }
.swatch-all  { background-color: var(--bs-success); }
.swatch-band { background-color: rgba(var(--bs-danger-rgb), 0.35); height: 0.7rem; }

/* ─── Type-ahead ─────────────────────────────────────────────────────────────── */
/* .typeahead wraps just the input and its menu, so the dropdown aligns with the field
   rather than with whatever grid column happens to contain it. */
.typeahead {
    position: relative;
}

.typeahead-menu {
    position: absolute;
    z-index: 20;
    left: 0;
    right: 0;
    margin: 0;
    padding: 0.25rem;
    list-style: none;
    background-color: var(--surface-1);
    border: 1px solid var(--bs-border-color);
    border-radius: 0.4rem;
    box-shadow: var(--shadow-menu);
    max-height: 18rem;
    overflow-y: auto;
}

.typeahead-menu li {
    padding: 0.35rem 0.6rem;
    border-radius: 0.3rem;
    font-size: 0.875rem;
    cursor: pointer;
}

.typeahead-menu li:hover,
.typeahead-menu li.active {
    background-color: rgba(var(--bs-primary-rgb), 0.1);
}

/* A miss is reported, not silently shown as an empty box -- "nothing happened" and "nothing
   matched" look identical otherwise. */
.typeahead-menu li.typeahead-empty {
    color: var(--text-muted);
    cursor: default;
}

.typeahead-menu li.typeahead-empty:hover {
    background-color: transparent;
}

.typeahead-hint {
    font-size: 0.78rem;
    color: var(--text-muted);
}

/* ─── Expandable rows / part card ────────────────────────────────────────────── */
.row-expandable td {
    cursor: pointer;
}

.row-expanded td {
    border-bottom: none;
}

/* ─── Disclosure: one chevron, on the left, for everything that expands ──────
   ▸ shut, turned to ▾ open. A table row's is a button in its first cell
   (partials/row_chevron.html); a <details class="disclosure"> draws it before its summary.
   disclosure.js keeps aria-expanded in step. AGENTS.md, "Disclosure and Save". */
details.disclosure > summary::before {
    display: inline-block;
    width: 1em;
    flex: none;
    text-align: center;
    font-size: 1.15em;
    vertical-align: -0.05em;
    line-height: 1;
    color: var(--text-muted);
    transition: transform 0.15s ease;
    content: "\25B8";
}
/* A row's chevron (t-0804, owner: "too small, hard to see"): Font Awesome's solid chevron, about
   1.6 rem tall, in the text's full color -- not the thin ▸ of a text font, muted. */
.chev {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    flex: none;
    font-size: 1.75rem;
    line-height: 1;
    color: var(--bs-emphasis-color);
    transition: transform 0.15s ease;
}
.chev::before {
    content: "\f054";
    font-family: "Font Awesome 6 Free";
    font-weight: 900;
    font-size: 0.62em;
}
[aria-expanded="true"] > .chev,
.row-expanded .row-toggle > .chev,
details.disclosure[open] > summary::before { transform: rotate(90deg); }

details.disclosure > summary { cursor: pointer; list-style: none; }
details.disclosure > summary::-webkit-details-marker { display: none; }
details.disclosure > summary::before { margin-right: 0.25rem; }
details.disclosure > summary:hover::before,
.row-toggle:hover > .chev,
.row-expanded .row-toggle > .chev { color: var(--bs-primary); }

.col-chevron {
    width: 2.5rem;
    padding-left: 0.15rem !important;
    padding-right: 0 !important;
}

/* At least 24 px to hit (WCAG 2.5.8), the ▸ centered in it. */
.row-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 2.25rem;
    min-height: 2.25rem;
    /* The bigger target overhangs the cell's padding rather than growing every row. */
    margin: -0.4rem -0.2rem;
    border: 0;
    border-radius: 0.375rem;
    background: none;
    padding: 0;
    line-height: 1;
}
.row-toggle:hover { background: var(--bs-secondary-bg); box-shadow: inset 0 0 0 1px var(--bs-border-color); }
.row-toggle:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 1px; }

/* The whole row is the chevron's (disclosure.js, row_expand.js): it says so on hover. */
tr:has(> .col-chevron > .row-toggle) > td,
[data-row-click] { cursor: pointer; }
tr:has(> .col-chevron > .row-toggle):hover > td,
.row-expandable:hover > td { background-color: var(--bs-tertiary-bg); }
tr:has(> .col-chevron > .row-toggle) :is(input, select, textarea),
[data-row-click] :is(input, select, textarea) { cursor: auto; }
tr:has(> .col-chevron > .row-toggle) :is(a, button, label),
[data-row-click] :is(a, button, label) { cursor: pointer; }

/* An icon-only button (✓, ✗, a pencil): the glyph centered in a square box. */
.btn-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.9rem;
    height: 1.9rem;
    padding: 0 !important;
    line-height: 1;
}
.btn-icon.btn-sm { width: 1.75rem; height: 1.75rem; }
.btn-icon.btn-sm.py-0 { height: 1.5rem; }
.btn-icon > .fa-solid, .btn-icon > svg { margin: 0; }

.datasheet-retry .fa-rotate-right {
    font-size: 0.7em;
}

form[data-row-form].is-busy .fa-rotate-right {
    animation: fa-spin 1s linear infinite;
}

/* The detail cell spans every column of a table that is wider than the window (the BOM is 17
   columns inside overflow-x:auto). Laid out across that full width, the card's right-hand
   column lands off-screen. The wrapper is sized to the viewport and sticks to the left of the
   scroll container, so the card stays where the user is looking however far the table is
   scrolled. */
.detail-sticky {
    position: sticky;
    left: 0;
    /* Width is set by row_expand.js from the scroll container's clientWidth; this is only the
       fallback for a card rendered without it. */
    max-width: 100%;
}

.detail-row > td {
    background-color: var(--surface-2);
    box-shadow: inset 3px 0 0 var(--bs-primary);
    padding: 1rem 1.25rem;
}

/* Expanded rows must not inherit the stripe or the hover tint of the table around them. */
.table-striped > tbody > tr.detail-row > td,
.table > tbody > tr.detail-row:hover > td {
    background-color: var(--surface-2);
    --bs-table-bg-type: transparent;
    --bs-table-bg-state: transparent;
}

.part-card {
    display: flex;
    gap: 1.25rem;
    align-items: flex-start;
}

.part-card-media img,
.part-card-noimage {
    width: 140px;
    height: 140px;
    object-fit: contain;
    background-color: var(--surface-2);
    border: 1px solid rgba(var(--bs-emphasis-color-rgb), 0.08);
    border-radius: 0.4rem;
}

.part-card-media img {
    background-color: #fff;
}

.part-card-noimage {
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--bs-tertiary-color);
    font-size: 0.8rem;
    border-style: dashed;
}

.part-card-body {
    /* Capped, not free-growing. With plain `flex: 1` the body box stretched to fill the row and
       shoved the sources table against the far edge -- the box gap was only the 20px flex gap,
       but the *content* ended hundreds of pixels earlier, so it read as one huge hole in the
       middle of the card. Capping the body lets the sources table sit beside the text it
       belongs to, and leaves the slack as ordinary trailing space at the right. */
    /* flex-grow 0 so the base size stays `auto` (max-content) and the box hugs its widest
       line, rather than stretching and leaving the sources table stranded to the right. */
    flex: 0 1 auto;
    min-width: 0;
    max-width: 44rem;
}

.part-card-stats {
    display: flex;
    flex-wrap: wrap;
    gap: 1.5rem;
    margin: 0.5rem 0 0.75rem;
}

.part-card-stats > div {
    text-align: left;
}

.part-card-stats .num {
    display: block;
    font-weight: 600;
    font-size: 1.05rem;
    text-align: left;
}

.part-card-stats label {
    font-size: 0.72rem;
    color: var(--text-muted);
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

.part-card-links {
    display: flex;
    gap: 0.5rem;
}

/* ─── Import line card ───────────────────────────────────────────────────────── */
.line-card-facts {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 0.15rem 0.75rem;
    margin: 0;
    font-size: 0.875rem;
}

.line-card-facts dt {
    color: var(--text-muted);
    font-weight: 500;
}

.line-card-facts dd {
    margin: 0;
}

/* A card mid-request stays readable but visibly inert, so a slow distributor search does not
   look like a dead button. */
.card-busy {
    opacity: 0.55;
    pointer-events: none;
}

.candidate-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
    gap: 0.75rem;
}

.candidate {
    border: 1px solid var(--bs-border-color);
    border-radius: 0.4rem;
    padding: 0.6rem 0.75rem;
    background-color: var(--surface-1);
}

.candidate-head {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 0.5rem;
    margin-bottom: 0.25rem;
}

/* An externally supplied part the market cannot cover. Amber rail, matching the shortfall
   convention above: a left rail rather than a row tint, because the row already carries a
   Blocked/Short badge in the Market column and a tinted table reads as an error state.

   A different color from shortfall-blocking because the two need different actions -- one is
   our purchase order, the other is a conversation with whoever supplies the part. */
.market-blocking {
    box-shadow: inset 3px 0 0 var(--bs-orange);
}

/* Both at once: short for us AND short on the market. An EITHER part is in scope for both, so
   on a large build this is the common case, not a corner one -- two rails rather than one
   winning, because they call for two different actions. */
.shortfall-blocking.market-blocking {
    box-shadow: inset 3px 0 0 var(--bs-danger), inset 6px 0 0 var(--bs-orange);
}

/* ─── Part card: stock by source ─────────────────────────────────────────────── */
/* The third column. Replaced a one-line vendor banner along the bottom of the card, which had
   nowhere to put stock, freshness or a link. */
.part-card-sources {
    min-width: 18rem;
}

.part-card-source-table {
    background-color: var(--surface-1);
    border: 1px solid var(--bs-border-color);
    border-radius: 0.4rem;
    overflow: hidden;
}

.part-card-source-table thead th {
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-muted);
    font-weight: 600;
    border-bottom-width: 1px;
}

.part-card-source-table td,
.part-card-source-table th {
    padding: 0.3rem 0.5rem;
    vertical-align: middle;
}

/* Our own stock is a different kind of thing from a distributor's, and it is the row a build
   actually consumes -- worth reading as the answer rather than as the first of six. */
.part-card-source-table tr.source-ours td {
    background-color: rgba(var(--bs-primary-rgb), 0.06);
    font-weight: 600;
}

.part-card-sourcing {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.4rem;
    margin-bottom: 0.75rem;
}

.part-card-sourcing select {
    width: auto;
    min-width: 9rem;
}

.part-card {
    flex-wrap: wrap;
}

/* Whether the sources table sits beside the details is a question about the CARD's width,
   not the window's. A viewport media query answered it wrongly on purpose-built pages: the
   BOM's 17-column table narrows the card far below the viewport, while the buildability page
   gives it nearly the full window -- the same 1500px viewport meant "cramped" on one page and
   "a meter of dead space" on the other. The container query asks the card itself.

   Default is stacked (also the no-support fallback: worse layout, never an overflow); the
   threshold is the body's cap (44rem) plus the table's working width. */
.detail-sticky {
    container-type: inline-size;
}

.part-card-sources {
    /* No max-width on the flex item: line-breaking uses the hypothetical size *after*
       max-width, so capping the item let it keep fitting beside the body and it never
       wrapped. Cap the table inside it instead (.part-card-source-table). */
    flex: 1 1 100%;
    margin-top: 0.25rem;
}

@container (min-width: 1150px) {
    .part-card-sources {
        flex: 0 1 auto;
        margin-top: 0;
    }
}

/* A four-row stock table stretched across a 1400px window is harder to read than one that
   stays a sensible width. */
.part-card-source-table {
    max-width: 42rem;
}

/* ─── Distributor candidate cards ────────────────────────────────────────────── */
.candidate-media {
    display: flex;
    gap: 0.6rem;
    align-items: flex-start;
    margin: 0.35rem 0;
}

.candidate-media img,
.candidate-noimage {
    width: 64px;
    height: 64px;
    flex: 0 0 64px;
    object-fit: contain;
    background-color: var(--surface-2);
    border: 1px solid rgba(var(--bs-emphasis-color-rgb), 0.08);
    border-radius: 0.3rem;
}

.candidate-media img {
    background-color: #fff;
}

.candidate-noimage {
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--bs-tertiary-color);
    font-size: 0.65rem;
    border-style: dashed;
    text-align: center;
}

.candidate-facts {
    min-width: 0;
}

/* Every distributor stocking one part, each linking to its own product page. */
.offer-list {
    list-style: none;
    margin: 0.4rem 0 0.3rem;
    padding: 0;
    font-size: 0.8rem;
}

.offer-list li {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.4rem;
    padding: 0.2rem 0.35rem;
    border-radius: 0.25rem;
}

.offer-list li:nth-child(odd) {
    background-color: rgba(var(--bs-emphasis-color-rgb), 0.025);
}

.offer-list a {
    text-decoration: none;
}

.offer-list a:hover {
    text-decoration: underline;
}

.offer-supplier {
    font-weight: 600;
    text-transform: capitalize;
}

.offer-stock {
    margin-left: auto;
    color: var(--text-muted);
    white-space: nowrap;
}

/* Live output from a running sync. Monospace and scrolled, because it is a log. */
.sync-log {
    max-height: 12rem;
    overflow-y: auto;
    background-color: var(--surface-2);
    border: 1px solid var(--bs-border-color);
    border-radius: 0.3rem;
    padding: 0.5rem 0.6rem;
    font-size: 0.78rem;
    margin: 0;
    white-space: pre-wrap;
}

/* ─── Click-to-copy ──────────────────────────────────────────────────────────── */
/* The icon rides after the value and only surfaces on hover, so forty part numbers do not
   render forty permanent icons. The copied state swaps it for a check, which is the entire
   feedback -- a toast for a clipboard write would be ceremony. */
.copyable {
    /* pointer, not `copy`: macOS renders the copy cursor as drag-and-drop's arrow-with-a-plus,
       which promises the wrong interaction entirely. The hover icon says what the click does. */
    cursor: pointer;
    border-radius: 0.2rem;
}

.copyable::after,
.copy-btn::after {
    content: '';
    display: inline-block;
    width: 0.85em;
    height: 0.85em;
    margin-left: 0.25em;
    vertical-align: -0.08em;
    background-color: currentColor;
    opacity: 0;
    transition: opacity 0.12s;
    -webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3h-1v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M6 0a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H6zm0 1h8a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/></svg>') no-repeat center / contain;
            mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3h-1v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M6 0a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H6zm0 1h8a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/></svg>') no-repeat center / contain;
}

.copyable:hover::after,
tr:hover .copy-btn::after,
li:hover .copy-btn::after,
.copy-btn:hover::after {
    opacity: 0.55;
}

/* The MCP token, shown once: its copy icon is always visible. */
#mcp-created .copy-btn::after { opacity: 0.55; }

.copyable:hover {
    background-color: rgba(var(--bs-primary-rgb), 0.08);
}

.copyable.copied::after,
.copy-btn.copied::after {
    opacity: 1;
    background-color: var(--bs-success);
    -webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>') no-repeat center / contain;
            mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>') no-repeat center / contain;
}

/* styles.css is shared with corduroy, whose copied state is a "Copied!" label at 0.7em. Its
   two-class selector outranks the `content: ''` above, so the label landed inside the 0.85em
   icon box, wrapped a letter per line (`code` breaks anywhere), and pushed everything under
   the value down for as long as the check showed. Undo both here; styles.css stays shared. */
.copyable.copied::after {
    content: '';
    font-size: 0.8em;   /* styles.css's resting .copyable::after size: the check matches the icon */
}

.copy-btn {
    border: 0;
    padding: 0;
    background: transparent;
    color: inherit;
    line-height: 1;
    cursor: pointer;
}

/* The creation progress bar inside a card form. */
.card-progress {
    margin-top: 0.6rem;
    max-width: 26rem;
}

/* ─── Tables grow taller, not wider ──────────────────────────────────────────── */
/* A cell cannot be narrower than its longest unbreakable run of text, so one MPN, one IPN, or
   one KiCad designator list with no space in it (`C6,C16,C17,...`) used to set the width of its
   whole column -- and every column after it went off behind a horizontal scrollbar. A fatter
   row is the better trade. `anywhere` rather than `break-word` because only `anywhere` lets the
   browser count such a token as breakable when it sizes the column; `break-word` wraps it only
   after the column is already too wide.

   Numbers (.num), badges and the button cells keep their own nowrap, so a quantity or an action
   never splits. The expanded card is left alone: the property inherits, and the card lays out
   its own contents. */
.table > tbody > tr:not(.detail-row) > td {
    overflow-wrap: anywhere;
}

/* Headers wrap between words. `.num` is nowrap for the figures under it, not for a two-word
   label like "In stock". */
.table > thead > tr > th.num {
    white-space: normal;
}

/* ─── Sortable tables ────────────────────────────────────────────────────────── */
/* No nowrap here any more (see above): the sort arrow is generated content with no space before
   it, so it still cannot wrap away from the last word of its label. */
table[data-sortable] thead th {
    cursor: pointer;
    user-select: none;
}

table[data-sortable] thead th[aria-sort]::after {
    font-size: 0.65em;
    margin-left: 0.3em;
    vertical-align: 0.15em;
}

table[data-sortable] thead th[aria-sort="ascending"]::after { content: '▲'; }
table[data-sortable] thead th[aria-sort="descending"]::after { content: '▼'; }

/* The import mapping preview: selectors sit directly above the columns they describe. */
.preview-table th select {
    min-width: 8.5rem;
}

.preview-table td {
    white-space: nowrap;
    max-width: 16rem;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ─── Part page header ───────────────────────────────────────────────────────── */
/* Picture + name + copyable identifiers, on every page that is about one part. */
.part-page-header {
    display: flex;
    gap: 0.9rem;
    align-items: center;
}

.part-page-header img,
.part-page-header-noimage {
    width: 64px;
    height: 64px;
    object-fit: contain;
    background-color: var(--surface-2);
    border: 1px solid rgba(var(--bs-emphasis-color-rgb), 0.08);
    border-radius: 0.4rem;
    flex: 0 0 64px;
}

.part-page-header img {
    background-color: #fff;
}

.part-page-header-noimage {
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--bs-tertiary-color);
    font-size: 0.7rem;
    border-style: dashed;
    text-align: center;
}

/* ─── Progress overlay ───────────────────────────────────────────────────────── */
/* Shown while a write to InvenTree is in flight. It covers the page on purpose: the write is
   already running and a second click would start a second one. */
.progress-overlay {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 1080;
    background: rgba(33, 37, 41, 0.45);
    align-items: center;
    justify-content: center;
    padding: 1rem;
}

.progress-overlay.is-open {
    display: flex;
}

[data-bs-theme="dark"] .progress-overlay {
    background: rgba(0, 0, 0, 0.6);
}

.progress-overlay-card {
    background: var(--surface-1);
    border-radius: 0.5rem;
    padding: 1.25rem 1.5rem;
    width: min(30rem, 100%);
    box-shadow: var(--shadow-modal);
}

.progress-overlay-title {
    font-weight: 600;
    margin-bottom: 0.6rem;
}

/* The step counter sits under the label and must not shove it about as the digits grow. */
.progress-overlay-count {
    font-variant-numeric: tabular-nums;
    min-height: 1.2em;
}

.progress-overlay-tally {
    font-variant-numeric: tabular-nums;
}

/* ─── The board viewer ───────────────────────────────────────────────────────── */

/* The ground a board is drawn on — every board, on both faces and in both views.

   A neutral gray, and deliberately not the page's own background: the laminate showing
   through a mask pullback is tan (FR4_HEX in fab/static/fab/palette.js), and a tan margin
   around a board reads as more board. Gray outside the silhouette, laminate inside it, and
   the two cannot be confused.

   This started life in portal.css as the ground under the customer's board thumbnails, where
   it was right; the stage here stayed at #0d1117, near black, which is hard to read a dark
   board against and meant the portal's own viewer page did not match its own thumbnails.
   One token now, so a board looks the same wherever it is drawn. `viewer3d.js` reads it for
   the 3D scene's background, which is why it must resolve on the root element. */
:root { --board-ground: #e4e7eb; }
[data-bs-theme="dark"] { --board-ground: #17242e; }

.fab-stage {
    position: relative;
    flex: 1 1 32rem;
    min-width: 22rem;
    height: min(78vh, 46rem);
    overflow: hidden;
    border-radius: 0.5rem;
    background: var(--board-ground);
    cursor: grab;
    touch-action: none;
}

.fab-stage.is-dragging { cursor: grabbing; }

/* The flat stage (fab/viewer.js, portal/board_viewer.js): a boarddd/view2d stage. The
   board is drawn on its laminate, clipped to its outline, by fab/flat.js; nothing behind it
   is painted here. view2d's panes carry their own grab cursor; the stage's states override it. */
.fab-stage-view { position: absolute; inset: 0; }
.fab-stage.is-picking .bd2-pane { cursor: crosshair; }
/* What is drawn on the board takes the pointer for its hover and its tooltip; the press still
   reaches the stage under it, which pans, or picks (portal/board_viewer.js, by a hit test). */
.fab-stage-view .fab-marker, .fab-stage-view .fab-envelope { pointer-events: visiblePainted; }

.fab-status {
    position: absolute;
    left: 0.6rem;
    bottom: 0.5rem;
    color: rgba(255, 255, 255, 0.72);
    background: rgba(0, 0, 0, 0.45);
    padding: 0.1rem 0.45rem;
    border-radius: 0.25rem;
    pointer-events: none;
}

/* Holes read as holes: open, so the stage shows through them as it would through the board,
   with the ring saying whether they are plated. The opening is not this rule's to make -- the
   board canvas is cut with a mask (holeMask in fab/static/fab/drills.js), since paint over
   the canvas could only show the laminate behind it. So no fill: this draws the rim alone.
   It used to be a near-black fill, which read as a hole on the old near-black stage and as a
   black dot on any other.

   One rule for both shapes: a round hole is a `circle` and a routed slot is a `path` around
   the stadium the cutter leaves (fab/drills.js), so the fill is the opening and the stroke is
   the rim in either case. A slot used to be a `line` at stroke-width = the drill diameter,
   which made the stroke the body -- a solid gold bar among dark holes, and the dash below,
   meant for an unplated rim, ran down the length of it. */
.fab-drill { fill: none; stroke-width: 0.5; pointer-events: none; }
.fab-drill.is-plated { stroke: #d8a13a; }
.fab-drill.is-unplated { stroke: #e9edf2; stroke-dasharray: 2 1.5; }

.fab-marker circle { fill: none; stroke: #ff2d95; }
.fab-marker line { stroke: #ff2d95; }
.fab-marker { cursor: pointer; }
.fab-stage.is-picking { cursor: crosshair; }
.fab-marker:hover circle { stroke: #fff; }
.fab-marker.is-selected circle { stroke: #21d4fd; fill: rgba(33, 212, 253, 0.25); }
.fab-marker.is-selected line { stroke: #21d4fd; }

/* A layer on the face you are not looking at: still listed, because the package did not
   change when you turned the board over, but plainly not in play. */
.fab-layer.is-other-side { opacity: 0.45; }
.fab-layer.is-other-side .form-check-label::after {
    content: " (other side)";
    font-style: italic;
}

.fab-swatch {
    display: inline-block;
    width: 0.7rem;
    height: 0.7rem;
    border-radius: 0.15rem;
    margin-right: 0.35rem;
    border: 1px solid rgba(var(--bs-emphasis-color-rgb), 0.25);
    vertical-align: -1px;
}

/* The "how these were matched" disclosure on the package's file list. A <details> rather than
   a modal: it is reference material somebody reads while looking at the table it explains, and
   a dialog would cover the thing being explained. */
.fab-help { border-bottom: 1px solid var(--bs-border-color, #dee2e6); }

.fab-help > summary {
    cursor: pointer;
    padding: 0.4rem 0.75rem;
    color: var(--bs-secondary-color, var(--text-muted));
    user-select: none;
}

.fab-help > summary:hover { background: rgba(var(--bs-emphasis-color-rgb), 0.03); }
.fab-help[open] > summary { font-weight: 600; }
.fab-help-body { padding: 0.25rem 0.75rem 0.75rem; }
.fab-help-body code { font-size: 0.78rem; }

/* Candidate board shapes on the outline picker. Each drawn to its own bounds so a 400 mm
   sheet border and a 14 mm board are both legible; the caption is what tells them apart. */
.fab-candidate {
    width: 100%;
    height: 7rem;
    background: #0d1117;
    border-radius: 0.3rem;
}

.fab-candidate polygon {
    fill: rgba(60, 160, 90, 0.35);
    stroke: #d8d84f;
    stroke-width: 0.5%;
    vector-effect: non-scaling-stroke;
}

/* ─── Picking the board's outline ────────────────────────────────────────────── */
/* Every candidate shape drawn once in one shared space, beside a preview of the board each
   would make. Scaled together on purpose: relative size is the whole question. */
.fab-picker { display: flex; gap: 1rem; align-items: stretch; flex-wrap: wrap; }

.fab-picker-map {
    flex: 1 1 30rem;
    min-width: 20rem;
    height: min(70vh, 40rem);
    background: #0d1117;
    border-radius: 0.5rem;
    overflow: hidden;
}

.fab-picker-map .fab-picker-stage { width: 100%; height: 100%; }
.fab-picker-side { flex: 0 1 24rem; min-width: 18rem; }

.fab-preview { background: #0d1117; }
.fab-preview .fab-preview-stage { width: 100%; height: 16rem; }

/* The map is a boarddd/view2d stage (grab to pan); over a line, a click chooses it. */
.fab-picker-map .is-over-line .bd2-pane { cursor: pointer; }

/* The fill is there to be looked at, not clicked: the outermost shape's fill covers every
   shape nested inside it, which on a page border is all of them. What is picked is the line
   nearest the pointer, within a few pixels (outline_picker.js, a hit index of the edges). */
.outline-fill {
    fill: rgba(90, 170, 120, 0.10);
    stroke: #7fd6a2;
    stroke-width: 1.4;
    pointer-events: none;
    vector-effect: non-scaling-stroke;
    transition: fill 90ms ease, stroke 90ms ease;
}

.outline-shape.is-hover .outline-fill {
    fill: rgba(120, 210, 255, 0.28);
    stroke: #7fdcff;
    stroke-width: 2.6;
}

.outline-shape.is-selected .outline-fill {
    fill: rgba(255, 45, 149, 0.22);
    stroke: #ff2d95;
    stroke-width: 2.8;
}

/* ─── Do not populate ────────────────────────────────────────────────────────── */

/* And on the board itself: drawn, because it is still a position, but hollow and faint so a
   marker that means "do not place this" cannot be mistaken for one that means "place this". */
.fab-marker.is-dnp circle { fill: none; opacity: 0.55; }
.fab-marker.is-dnp line { opacity: 0.55; stroke-dasharray: 3 2; }

/* ── The assembly's four views, and what belongs to each ───────────────────── */

/*
 * The strip separates the assembly from the view of it; the action row underneath is
 * inside that separation, so a button there reads as acting on what is on screen. Hence
 * the shared left edge and the row sitting tight under the tabs with no gap between. The
 * tabs are underlines (styles.css), so the row is a quiet tray hanging from the strip's
 * rule rather than a box completing a frame.
 */
.assembly-tabs {
    margin-bottom: 0;
}

.assembly-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    align-items: center;
    padding: 0.5rem 0.75rem;
    margin-bottom: 1rem;
    border-radius: 0 0 0.5rem 0.5rem;
    background: var(--bs-tertiary-bg);
}

.assembly-actions form {
    margin: 0;
}

/* ─── Import line actions ────────────────────────────────────────────────────── */
/* Three symbols, one of which is a state rather than an action: do-not-populate is either
   on or off for the line, so it is outlined until it is on and filled after. Same shape as
   the placement table's DNP button, and the same meaning, deliberately. */
.import-dnp-toggle {
    color: var(--bs-secondary-color);
    border: 1px solid var(--bs-secondary-color);
}

.import-dnp-toggle.is-on {
    background: var(--bs-secondary-color);
    color: var(--surface-1);
}

/* ─── Files needing a decision ───────────────────────────────────────────────── */
/* On the file's own line, because the answer to "what is wrong" is always a particular file
   and a page-level banner makes you go and find which one. Two levels: something is missing
   or contradicts the board (error), or something is waiting on a person (warn). */
.fab-files tr.fab-file-error td:first-child { box-shadow: inset 3px 0 0 var(--bs-danger); }
.fab-files tr.fab-file-warn  td:first-child { box-shadow: inset 3px 0 0 var(--bs-warning); }

.fab-files tr.fab-file-error .fab-attention { color: var(--bs-danger-text-emphasis); }
.fab-files tr.fab-file-warn  .fab-attention { color: var(--bs-warning-text-emphasis); }

.fab-attention { margin-top: 0.15rem; }

/* The tick column for removing several files at once: as narrow as the box, so the filename
   column keeps the room. */
.fab-files .fab-pick { width: 1.75rem; }

/* ─── The 3D board ───────────────────────────────────────────────────────────── */
/* A fixed height rather than a share of the page: the canvas needs a size before the camera
   can be framed against it, and a stage sized by its own contents has none to give. */
.view3d-stage {
    position: relative;
    height: min(78vh, 46rem);
    /* Only seen before the WebGL canvas paints; the scene's own background is set from this
       same token in viewer3d.js, so the two do not flash different colors at each other. */
    background: var(--board-ground);
    border-radius: 0.5rem;
    overflow: hidden;
}

.view3d-stage canvas {
    display: block;
    width: 100%;
    height: 100%;
}

/*
 * `hidden` means hidden, whatever else the element is.
 *
 * The attribute's rule lives in the browser's own stylesheet, where any class beats it -- and
 * Bootstrap has a lot of classes that set `display`. `.card` is flex, `.d-flex` is flex and
 * `!important` besides, so `<div class="card" hidden>` is a card that shows. Every pane on
 * this page is switched by that attribute, so without this the 2D controls stay on screen in
 * the 3D view and the color palette never closes.
 */
[hidden] {
    display: none !important;
}

.fab-solid-name {
    font-family: var(--bs-font-monospace, monospace);
    font-size: 0.8rem;
}

/* A solid the model calls something else. Marked, because it is a fact somebody asserted
   rather than one the files agree on. */
.fab-solid-name.is-override {
    border-bottom: 1px dotted currentColor;
}

/* Over the view cube, which is a control rather than part of the scene. Beaten by the
   crosshair, because what the click will do matters more than what it is over. */
.view3d-stage.is-over-cube canvas {
    cursor: pointer;
}

/* Lining the model up by hand (fab/static/fab/align3d.js): the model can be taken hold of,
   so the pointer says so; the panel is marked live so it reads as the thing in charge. */
.view3d-stage.is-aligning canvas { cursor: grab; }
.view3d-stage.is-align-dragging canvas { cursor: grabbing; }
.align-panel.is-live { border-color: var(--bs-primary); }

/*
 * The selected component's name, over the component.
 *
 * Positioned in the stage's own pixels each frame by the viewer; everything here is about
 * making it readable over a dark board and keeping it out of the way of the pointer.
 */
/*
 * How far along the loading is, across the bottom of the viewer.
 *
 * Along the bottom rather than over the middle: it says the pane is working without covering
 * the board it is working on, and the board draws first, so there is something to look at
 * behind it while the model arrives.
 */
.view3d-progress {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    padding: 0.4rem 0.6rem 0.5rem;
    background: linear-gradient(to top, rgba(13, 17, 23, 0.9), rgba(13, 17, 23, 0));
    pointer-events: none;
    z-index: 4;
}

.view3d-progress-label {
    color: rgba(255, 255, 255, 0.85);
    font-size: 0.75rem;
    margin-bottom: 0.25rem;
}

.view3d-progress-track {
    height: 0.25rem;
    border-radius: 0.125rem;
    background: rgba(255, 255, 255, 0.18);
    overflow: hidden;
}

.view3d-progress-fill {
    height: 100%;
    width: 0;
    border-radius: inherit;
    background: var(--bs-primary, #0d6efd);
    transition: width 0.25s ease-out;
}

/* Why the model is not on the stage, and what to do about it. Inside the stage, top left,
   where the eye goes when the board is there and the parts are not; unlike the status pill
   it takes clicks, because it carries the way out. */
.view3d-notice {
    position: absolute;
    top: 0.6rem;
    left: 0.6rem;
    max-width: min(34rem, calc(100% - 1.2rem));
    padding: 0.55rem 0.7rem;
    border-radius: 0.375rem;
    background: rgba(13, 17, 23, 0.88);
    color: #e6edf3;
    border: 1px solid rgba(230, 237, 243, 0.25);
    font-size: 0.8rem;
    line-height: 1.35;
    z-index: 5;
}

.view3d-notice-actions {
    display: flex;
    gap: 0.4rem;
    margin-top: 0.5rem;
}

.view3d-label {
    position: absolute;
    transform: translate(-50%, -115%);
    padding: 0.05rem 0.35rem;
    border-radius: 0.25rem;
    background: rgba(13, 17, 23, 0.85);
    color: #e6edf3;
    border: 1px solid rgba(230, 237, 243, 0.35);
    font-size: 0.72rem;
    font-family: var(--bs-font-monospace, monospace);
    white-space: nowrap;
    pointer-events: none;
    z-index: 3;
}

/*
 * Soldermask colors, shown as the colors they are.
 *
 * A swatch is the whole control: naming a color in a dropdown asks somebody to imagine what
 * "matte green" looks like next to "green", which is exactly the comparison the eye is for.
 */
.mask-picker {
    position: relative;
}

/*
 * A question mark that opens a sentence or two of explanation.
 *
 * For prose that answers a question nobody asks twice: it earns its place the first time and
 * costs layout every time after. The marker goes, because a disclosure triangle beside a
 * question mark is two symbols for one idea.
 *
 * Opening it must not move anything, which is the whole reason the prose was folded away in
 * the first place -- so what it opens is positioned over the card rather than inside its flow.
 */
.board-help > summary,
.help-toggle > summary {
    list-style: none;
    cursor: pointer;
}

.board-help > summary::-webkit-details-marker,
.help-toggle > summary::-webkit-details-marker {
    display: none;
}

.help-toggle {
    position: relative;
}

.help-toggle[open] > p {
    position: absolute;
    z-index: 20;
    top: 1.6rem;
    left: 0;
    width: max-content;
    max-width: 26rem;
    padding: 0.5rem 0.65rem;
    border: 1px solid var(--bs-border-color, #dee2e6);
    border-radius: 0.35rem;
    background: var(--surface-1);
    box-shadow: var(--shadow-menu);
}

.mask-menu {
    position: absolute;
    z-index: 20;
    top: calc(100% + 0.35rem);
    left: 0;
    min-width: 14rem;
    box-shadow: var(--shadow-menu);
}

.mask-swatches {
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    gap: 0.35rem;
    align-items: center;
}

/* The closed control: the color itself, which is the only description that needs no words. */
.mask-current {
    width: 1.9rem;
    height: 1.9rem;
}

.mask-swatch {
    width: 1.6rem;
    height: 1.6rem;
    padding: 0;
    border-radius: 0.25rem;
    border: 1px solid rgba(var(--bs-emphasis-color-rgb), 0.35);
    background: var(--swatch, #888);
    cursor: pointer;
    position: relative;
}

.mask-swatch:disabled {
    cursor: default;
    opacity: 0.6;
}

/* The one in use, marked with a ring rather than a tick: a tick would have to be a color, and
   any color it could be is one of the swatches it might land on. */
.mask-swatch.is-current {
    outline: 2px solid var(--bs-primary, #0d6efd);
    outline-offset: 2px;
}

/* The free choice sits in the row as one more swatch, showing whatever it is currently set to. */
.mask-swatch-custom {
    overflow: hidden;
    display: inline-block;
}

.mask-swatch-custom input[type="color"] {
    width: 200%;
    height: 200%;
    margin: -25%;
    padding: 0;
    border: 0;
    background: none;
    cursor: pointer;
}

.mask-hex {
    width: 6.5rem;
    font-family: var(--bs-font-monospace, monospace);
    text-transform: uppercase;
}

/* A panel that folds: its card header is the summary, and .disclosure gives it the chevron. */
details.card > summary.card-header { cursor: pointer; }

/* ─── Machine setup ──────────────────────────────────────────────────────────── */
/* The three things Ptool needs. Pickers rather than number boxes: these are indices into the
   machine's own tables, and "26" does not tell anybody it means a fine-pitch chip. */
.machine-pick { width: 15rem; }
.machine-pick-narrow { width: 9rem; }

/* ─── Yamaha's nozzle classes ────────────────────────────────────────────────── */
/* The code column stays narrow so "what it is for" gets the room -- it is the thing being
   read. Fixed columns so the table does not re-measure itself as names are typed into it. */
.nozzle-table { table-layout: fixed; min-width: 44rem; }
.nozzle-table td, .nozzle-table th { vertical-align: middle; }
.nozzle-table .nozzle-code { width: 4.5rem; }
.nozzle-table .nozzle-for { width: 32%; }
.nozzle-table .nozzle-name { width: 17rem; }
.nozzle-table td { overflow-wrap: anywhere; }

/* One nozzle at a time: its tip face to scale on the left, P-Tool's drawing and what it prints
   on the right. Two columns that stack on a phone. The drawing keeps its white ground because
   P-Tool's line art is black on white and dark mode would eat it. */
.nozzle-viewer-panes { display: grid; grid-template-columns: minmax(0, 3fr) minmax(16rem, 2fr); }
@media (max-width: 720px) { .nozzle-viewer-panes { grid-template-columns: minmax(0, 1fr); } }
.nozzle-face-pane { display: grid; place-items: center; min-height: 16rem; padding: 0.75rem;
                    background: var(--surface-2, rgba(128, 128, 128, 0.06)); }
.nozzle-face-pane svg { width: 100%; max-width: 18rem; height: auto; }
.nozzle-face-pane, .stock-face { --nz-face: var(--bs-primary); --nz-hole: var(--bs-body-bg);
                                 --nz-text: var(--bs-secondary-color); }
.stock-face { flex: 0 0 auto; }
.stock-face svg { width: 3.5rem; height: auto; }
.nozzle-viewer-drawing { padding: 0.75rem; border-left: 1px solid var(--bs-border-color); }
@media (max-width: 720px) { .nozzle-viewer-drawing { border-left: 0; border-top: 1px solid var(--bs-border-color); } }
.nozzle-viewer-drawing img { display: block; width: 100%; max-width: 16rem; margin: 0 auto 0.5rem;
                             background: #fff; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; }
.nozzle-viewer-dims { display: grid; grid-template-columns: max-content 1fr; column-gap: 0.75rem; row-gap: 0.1rem; }
.nozzle-viewer-dims dt { font-weight: normal; color: var(--bs-secondary-color); }
.nozzle-viewer-dims dd { margin: 0; }

/* What this shop HAS -- the nozzle list and the feeder list, one row per type with P-Tool's
   drawing at its left. Rows and not a tile grid: the facts beside the picture are what is
   read, and a grid of pictures would make them the small print. The picture keeps P-Tool's
   white ground: the line art is black on white. */
.stock-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.75rem;
              border-bottom: 1px solid var(--bs-border-color); }
.stock-item:last-child { border-bottom: 0; }
/* A button on the nozzles page, because it drives the face pane above; styled as the picture it
   is. `.stock-picture-plain` is the same tile where there is nothing to drive. */
.stock-picture { flex: 0 0 auto; width: 5.5rem; padding: 0.15rem; background: #fff;
                 border: 1px solid var(--bs-border-color); border-radius: 0.25rem;
                 cursor: pointer; }
.stock-picture-plain { cursor: default; }
.stock-picture:not(.stock-picture-plain):hover { border-color: var(--bs-primary); }
.stock-picture img { display: block; width: 100%; }
.stock-nopicture { display: grid; place-items: center; min-height: 3.5rem; text-align: center; }
.stock-facts { flex: 1 1 auto; min-width: 0; }
.stock-count, .stock-actions { flex: 0 0 auto; text-align: right; }
/* The roll-up by tape width, in the header: the form the "can we run this BOM" question
   actually takes. Wraps rather than widening the card. */
.stock-summary { display: flex; flex-wrap: wrap; gap: 0.15rem 1rem; color: var(--bs-secondary-color); }
/* P-Tool's nozzle and feeder drawings are wide, so the square 32px thumbnail every other
   picker uses makes them unreadable. Wider, in these two menus only. */
#nozzleStockMatches .part-thumb, #feederStockMatches .part-thumb { width: 3.5rem; height: 2.4rem; }
@media (max-width: 560px) {
    .stock-item { flex-wrap: wrap; }
    .stock-count, .stock-actions { width: 100%; text-align: left; }
}

/* A part number is one unbreakable token, so across forty rows the longest one sets the
   column's minimum width and the table grows a scrollbar with nothing to scroll. */
.machine-part-cell { overflow-wrap: anywhere; }

/* Not set up yet: the optimizer will refuse, so the row says so before Ptool does. */
.machine-setup tr.machine-unset td:first-child { box-shadow: inset 3px 0 0 var(--bs-warning); }

/* What the numbers mean, beside the boxes that hold them. Muted: the numbers are the thing
   being edited, and the reading is there to check them against. */
.machine-reads { flex-basis: 100%; }

/* The machine-setup state beside a placement. Read-only here on purpose: a feeder belongs to
   the part, and an input on a placement row would say it belonged to the placement. */
.fab-feed-cell { overflow-wrap: anywhere; }

/* ─── Catalog candidates ─────────────────────────────────────────────────────── */
/* A candidate is a picture and a name, and the picture is the point: at a shared scale a
   row of them shows relative size, which is the question "is this the right entry" really
   asks. Stacked rather than side by side so the names stay readable at a small width. */
.candidate {
    display: inline-flex;
    flex-direction: column;
    align-items: center;
    gap: 0.15rem;
    line-height: 1.1;
}

.candidate .part-box {
    display: block;
    /* The SVG carries its own size; this only stops a long name stretching it. */
    flex: none;
}

.candidate span { white-space: nowrap; }

/* The alternatives sit in a row that should wrap rather than push the column wide. */
.candidate-others {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
    margin-top: 0.25rem;
}

/* ─── The catalog picker ─────────────────────────────────────────────────────── */
/* A gallery of envelopes, all drawn at one scale, so the grid reads as relative size. Fixed
   columns rather than auto-fit: cards of a constant width keep the pictures aligned across
   rows, which is what makes them comparable down a column as well as across one. */
.catalog-gallery {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
    gap: 0.5rem;
}

.catalog-card { margin: 0; }

.catalog-card-button {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.1rem;
    width: 100%;
    padding: 0.5rem 0.35rem;
    border: 1px solid var(--bs-border-color, #dee2e6);
    border-radius: 0.4rem;
    background: var(--surface-1);
    text-align: center;
    line-height: 1.15;
}

.catalog-card-button:hover:not(:disabled) {
    border-color: var(--bs-primary);
    background: rgba(var(--bs-primary-rgb), 0.04);
}

.catalog-card-button:disabled { opacity: 0.6; }

.catalog-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.3rem;
    margin-top: 0.35rem;
}

.catalog-crumbs a { text-decoration: none; }
.catalog-crumbs span { margin: 0 0.25rem; color: var(--text-muted); }

/* The picker's top strip: the three tabs, a name search, and the way out to the editor, on
   one line that wraps rather than pushing the gallery down on a narrow modal. */
.catalog-picker-bar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
}
.catalog-picker-bar .catalog-search { margin-left: auto; }
.catalog-picker-bar .catalog-search input { width: 12rem; max-width: 40vw; }
.catalog-define { white-space: nowrap; }

/* ─── The new-part editor ────────────────────────────────────────────────────── */
/* Grouped fieldsets so a form with fifteen fields reads as five decisions. The legend is a
   small caps-y label rather than a heading, because these sit inside the modal body under the
   compare pane and a run of <h_> would fight the modal's own heading. */
.part-editor .editor-group {
    border: 1px solid var(--bs-border-color, #dee2e6);
    border-radius: 0.4rem;
    padding: 0.5rem 0.6rem 0.6rem;
    margin-bottom: 0.6rem;
}
.part-editor .editor-group > legend {
    float: none;
    width: auto;
    font-size: 0.8rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.02em;
    color: var(--text-muted);
    padding: 0 0.35rem;
    margin: 0;
}

/* The guessed alignment type, under the picker that carries it. Muted, because it is filled
   in rather than decided -- somebody still has to agree with it. */
.machine-guess { margin-top: 0.15rem; }

/* ─── Alignment types, drawn ─────────────────────────────────────────────────── */
/* A glyph is the pattern a type describes -- a body and where its terminals are -- in
   currentColor so it reads in both themes. The body is faint, the terminals solid: the
   terminals are what the record asks about. */
.align-glyph { display: inline-block; vertical-align: middle; flex: none; }
.align-glyph .ag-body { fill: currentColor; fill-opacity: 0.16; stroke: currentColor; stroke-opacity: 0.35; stroke-width: 0.8; }
.align-glyph .ag-pad { fill: currentColor; fill-opacity: 0.9; }
.align-glyph .ag-mark { fill: currentColor; fill-opacity: 0.45; }
.align-glyph .ag-gap { fill: none; stroke: currentColor; stroke-opacity: 0.5; stroke-width: 0.6; stroke-dasharray: 1 1; }
.align-glyph .ag-outline { fill: none; stroke: currentColor; stroke-width: 1; stroke-dasharray: 2 1.5; }

.machine-shape-glyph { display: inline-flex; width: 28px; height: 28px; color: var(--text-muted); }

/* The chooser: what the board showed, what is chosen, the bins, then the cards of the open
   bin. It lives inside the editor's fieldset and inherits its type. */
.align-chooser { display: flex; flex-direction: column; gap: 0.5rem; }

.align-detected {
    display: flex;
    gap: 0.75rem;
    align-items: center;
    padding: 0.5rem 0.6rem;
    border: 1px solid var(--bs-border-color, #dee2e6);
    border-left: 3px solid var(--bs-primary);
    border-radius: 0.4rem;
    background: rgba(var(--bs-primary-rgb), 0.04);
}
.align-detected-text { font-size: 0.85rem; line-height: 1.3; }

/* The openings read off the board, at true relative scale. Solid where a type's record
   would describe them, faint where it would not -- a thermal pad, a neighbor's stray. */
.align-overlay { flex: none; background: var(--surface-1); border-radius: 0.3rem; }
.align-overlay .ov-used { fill: var(--bs-primary); fill-opacity: 0.85; }
.align-overlay .ov-spare { fill: currentColor; fill-opacity: 0.18; stroke: currentColor; stroke-opacity: 0.3; stroke-width: 0.5; }

.align-current { display: flex; align-items: center; gap: 0.4rem; min-height: 1.5rem; }
.align-current .align-glyph { width: 24px; height: 24px; }
/* The chosen type's own picture beside its name: the card's photograph, small. */
.align-current img.align-current-art { height: 2rem; width: auto; border-radius: 3px; }

.align-bins { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.align-bin {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.2rem 0.55rem 0.2rem 0.3rem;
    font-size: 0.8rem;
    border: 1px solid var(--bs-border-color, #dee2e6);
    border-radius: 999px;
    background: var(--surface-1);
    color: inherit;
    line-height: 1.1;
}
.align-bin .align-glyph { width: 22px; height: 22px; }
.align-bin:hover { border-color: var(--bs-primary); }
.align-bin.active { border-color: var(--bs-primary); background: rgba(var(--bs-primary-rgb), 0.1); }

.align-cards {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
    gap: 0.4rem;
}
.align-card {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.15rem;
    padding: 0.45rem 0.5rem;
    text-align: left;
    font-size: 0.78rem;
    line-height: 1.2;
    border: 1px solid var(--bs-border-color, #dee2e6);
    border-radius: 0.4rem;
    background: var(--surface-1);
    color: inherit;
}
.align-card .align-glyph { width: 40px; height: 40px; }
.align-card:hover { border-color: var(--bs-primary); }
.align-card.active { border-color: var(--bs-primary); box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), 0.25); }
.align-card-name { font-size: 0.82rem; }
.align-card-asks, .align-card-families { font-size: 0.72rem; }
.align-card-desc { font-size: 0.72rem; }

/* ─── P-Tool's own artwork ───────────────────────────────────────────────────── */
/* Yamaha ships a drawing for every Alignment Type, every Algorithm, and most feeders, and the
   editor shows those rather than a glyph of our own wherever one exists. They are photographs
   of line art on white, so they get a light plate behind them in both themes -- inverting them
   for dark mode turns a lead diagram into a negative, which is harder to read, not easier.
   The hand-drawn SVG glyph remains the fallback: type 22 Ignore ships no picture, because it
   does no image processing. */
.align-card-picture,
.align-algo img,
.editor-picture img,
.editor-diagram,
.align-algo-params-figures img {
    background: #fff;
    border-radius: 0.25rem;
    object-fit: contain;
}
.align-card-picture { width: 100%; max-height: 4.5rem; }

/* The Algorithm strip, under a chosen type. Wider cards than the type cards: an algorithm's
   picture is a before/after of what the camera does and is unreadable at thumbnail size. */
.align-algorithms {
    border-top: 1px dashed var(--bs-border-color, #dee2e6);
    padding-top: 0.5rem;
}
.align-algo-cards {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
    gap: 0.4rem;
}
.align-algo {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.15rem;
    padding: 0.4rem 0.45rem;
    text-align: left;
    font-size: 0.75rem;
    line-height: 1.2;
    border: 1px solid var(--bs-border-color, #dee2e6);
    border-radius: 0.4rem;
    background: var(--surface-1);
    color: inherit;
}
.align-algo img { width: 100%; max-height: 3.5rem; }
.align-algo:hover { border-color: var(--bs-primary); }
.align-algo.active { border-color: var(--bs-primary); box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), 0.25); }
.align-algo-name { font-size: 0.78rem; }
.align-algo-name.is-unnamed { font-style: italic; color: var(--text-muted); }
.align-algo-note, .align-algo-params { font-size: 0.68rem; }

/* The diagrams for the chosen algorithm's own parameters: each says what one measurement
   means. Bigger than the cards, because that is what they are for. */
.align-algo-params-figures {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-top: 0.5rem;
}
.align-algo-params-figures figure { margin: 0; max-width: 11rem; }
.align-algo-params-figures img { width: 100%; }
.align-algo-params-figures figcaption {
    font-size: 0.7rem;
    color: var(--text-muted);
    text-align: center;
}

.align-base { max-width: 22rem; }

/* ─── The Part Editor's tabs ─────────────────────────────────────────────────── */
/* P-Tool's own four. Tighter than Bootstrap's default because they sit inside a modal under
   the compare pane and a full-height tab row would push the first field off the fold. */
.part-editor .editor-tabs { margin-bottom: 0.6rem; }
.part-editor .editor-tabs .nav-link {
    padding: 0.25rem 0.7rem;
    font-size: 0.85rem;
}
/* The dot on Vision, while no Alignment Type has been chosen. It is the one unanswered
   question that can fail a save, so it is marked on the tab rather than found on arrival. */
.part-editor .editor-tab-dot {
    color: var(--bs-warning, #ffc107);
    font-size: 0.7rem;
    vertical-align: super;
}

/* The feed form as four drawings rather than a dropdown: a reel, a stick, a hopper, a tray.
   Four choices with pictures is something to recognize; a <select> of four words is something
   to read. */
.editor-packages { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.editor-package {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.1rem;
    padding: 0.25rem 0.35rem;
    font-size: 0.72rem;
    line-height: 1.1;
    border: 1px solid var(--bs-border-color, #dee2e6);
    border-radius: 0.4rem;
    background: var(--surface-1);
    color: inherit;
}
.editor-package img { width: 3rem; height: 2.2rem; object-fit: contain; background: #fff; border-radius: 0.2rem; }
.editor-package:hover { border-color: var(--bs-primary); }
.editor-package.active { border-color: var(--bs-primary); box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), 0.25); }

.editor-picture img { width: 100%; max-height: 3.2rem; margin-top: 0.2rem; }
.editor-diagram { max-width: 14rem; max-height: 8rem; margin-bottom: 0.4rem; }

/* A sentence under a control, where a value is genuinely easy to get wrong. Distinct from
   .editor-note above, which says where a filled-in value came from. */
.part-editor .editor-hint { font-size: 0.7rem; line-height: 1.25; }

/* On every tab: a blank field is not an unanswered question, it is one taken from a part of
   the same alignment type that already runs. Worth saying where somebody is looking at eight
   empty dropdowns and deciding whether they have to fill them. */
.part-editor .editor-carried { font-style: italic; }

/* Where a filled-in editor value came from, under its field. The same muted register as the
   guess above, for the same reason: it is evidence, not a decision. Wraps rather than
   truncates, because "what 41 catalog parts this size use" is the whole point of the line. */
.part-editor .editor-note {
    font-size: 0.72rem;
    line-height: 1.2;
    color: var(--text-muted);
    margin-top: 0.15rem;
}

/* The nearest catalog parts, offered as starting points above the form. A row of small
   buttons that wraps; each carries its size so a person can tell "that one, but taller"
   from "that one". */
.part-editor .editor-starts {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
    align-items: center;
}
.part-editor .editor-starts .btn { font-family: var(--bs-font-monospace); font-size: 0.75rem; }

/* The long tail of body sizes, folded. `display: block` because a summary inside a flex row
   of chips would otherwise stretch to the row's height. */
.catalog-more-sizes { display: block; width: 100%; }
.catalog-more-sizes > summary { cursor: pointer; list-style: none; display: inline-block; }
.catalog-more-sizes > summary::-webkit-details-marker { display: none; }

/* ─── Catalog part against the real one ──────────────────────────────────────── */
/* Above the gallery, because it is about the part being set up rather than about any one
   candidate -- it changes as candidates are looked at, and the gallery scrolls under it. */
.compare-pane {
    display: flex;
    gap: 0.75rem;
    align-items: center;
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--bs-border-color, #dee2e6);
    background: var(--bs-tertiary-bg);
}

.compare-stage {
    width: 15rem;
    height: 11rem;
    flex: none;
    border-radius: 0.4rem;
    background: #10151a;
}

.compare-legend { display: flex; flex-direction: column; gap: 0.3rem; }
.compare-key { display: flex; align-items: center; gap: 0.35rem; }
/* The two colors of the shapes themselves, so the legend needs no swatch of its own. */
.compare-key-solid { color: #0b7f99; }
.compare-key-catalog { color: #b3206a; }

/* The rows skipped between the top of a BOM and the end of what was read. Quiet, and still a
   row: the point is that something is missing here, not what. */
.bom-gap td {
    background: var(--bs-tertiary-bg, #f8f9fa);
    font-style: italic;
}

/* The top of a BOM, shown so the header row can be picked by eye rather than by number. Cells
   stay on one line and the table scrolls: what matters is which row, not reading every cell. */
.bom-head td {
    white-space: nowrap;
    max-width: 12rem;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ─── A component's own outline, on the flat view ────────────────────────────── */
/* Drawn from the 3D model's silhouette, so the flat view can answer "does this hang over the
   edge" and "do these two clash". Deliberately faint and unfilled: it is a second opinion laid
   over the board, not a layer of it, and seventy filled shapes would be a sheet. */
.fab-projection {
    fill: rgba(122, 220, 255, 0.10);
    stroke: rgba(122, 220, 255, 0.75);
    stroke-width: 0.6;
    pointer-events: none;
    vector-effect: non-scaling-stroke;
}

.fab-projection.is-selected {
    fill: rgba(122, 220, 255, 0.22);
    stroke: #7adcff;
    stroke-width: 1.2;
}

/* The catalog entry's envelope, laid flat at the placement: the same box the 3D view stands
   on the board. Magenta is a settled choice and amber a suggestion, the 3D view's colors, so
   a boardful of guesses does not look like a boardful of decisions. Clickable: it is a far
   bigger target than the marker's dot. */
.fab-envelope {
    fill: rgba(255, 45, 149, 0.12);
    stroke: rgba(255, 45, 149, 0.8);
    stroke-width: 0.8;
    cursor: pointer;
    vector-effect: non-scaling-stroke;
}
.fab-envelope.is-guess {
    fill: rgba(255, 167, 36, 0.10);
    stroke: rgba(255, 167, 36, 0.8);
}
.fab-envelope:hover { stroke: #fff; }
.fab-envelope.is-selected { fill: rgba(33, 212, 253, 0.25); stroke: #21d4fd; stroke-width: 1.4; }
.fab-envelope.is-dnp { opacity: 0.45; stroke-dasharray: 3 2; }

/* A nozzle's printed tip face and vacuum hole at a part's pick point (fab/nozzle2d.js), to
   scale. Cyan rather than the envelopes' magenta and amber; the hole is the dark of the stage
   so it reads as a hole. Hover gives the type and sizes. */
.nozzle-mark { pointer-events: visiblePainted; }
.nozzle-mark .nozzle-face { fill: rgba(62, 155, 255, 0.28); stroke: #3e9bff; stroke-width: 1;
                            vector-effect: non-scaling-stroke; }
.nozzle-mark .nozzle-hole { fill: rgba(10, 14, 20, 0.45); stroke: #3e9bff; stroke-width: 0.6;
                            vector-effect: non-scaling-stroke; }
.nozzle-mark:hover .nozzle-face, .nozzle-mark.is-selected .nozzle-face { fill: rgba(62, 155, 255, 0.5); stroke: #fff; }
/* The part editor's body with the fitting nozzle over it (catalog_picker.js startNozzleFit). */
.editor-nozzle-drawing svg { display: block; width: 12rem; height: 12rem; margin: 0.25rem 0;
                             background: var(--surface-2, rgba(128, 128, 128, 0.06)); border-radius: 0.25rem; }
.editor-nozzle-body { fill: rgba(128, 128, 128, 0.35); stroke: var(--bs-secondary-color); stroke-width: 1;
                      vector-effect: non-scaling-stroke; }
.editor-nozzle-edge { fill: none; stroke: var(--bs-body-color); stroke-width: 1.2; stroke-dasharray: 3 2;
                      vector-effect: non-scaling-stroke; pointer-events: none; }
.editor-nozzle .nozzle-mark .nozzle-hole { fill: rgba(128, 128, 128, 0.15); }

/* ─── The catalog preview on a placement row ─────────────────────────────────── */
/* Sized to the row, not to the drawing. The table is a hundred and thirty rows long, and a
   picture that made each one taller would cost more than it gives. */
.fab-catalog-preview {
    display: inline-block;
    width: 22px;
    height: 22px;
    vertical-align: middle;
    margin-right: 0.2rem;
}

.fab-catalog-preview svg { display: block; width: 100%; height: 100%; }

/* ─── Adding files, and saying what the board is ─────────────────────────────── */
/* Shared by both faces: fab/partials/upload_files.html, spec_fields.html, spec_card.html and
   fab/static/fab/upload.js. The portal loads this stylesheet too. */
.upload-drop {
    border: 1px dashed var(--bs-border-color, #dee2e6);
    border-radius: 0.375rem;
    padding: 0.5rem 0.65rem;
    transition: border-color 0.15s, background-color 0.15s;
}
.upload-drop.is-over {
    border-color: var(--bs-primary);
    background: var(--bs-primary-bg-subtle, rgba(13, 110, 253, 0.06));
}
.upload-list { list-style: none; padding-left: 0; }
.upload-list li { font-family: var(--bs-font-monospace, monospace); font-size: 0.78rem; }

.spec-presets .btn.active { background: var(--bs-primary); color: var(--accent-contrast, #fff); border-color: var(--bs-primary); }
.spec-fields .mask-swatch { width: 1.6rem; height: 1.6rem; }

.spec-summary {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
    gap: 0.35rem 1rem;
}
.spec-line dt { font-weight: 500; }
.spec-line.is-unstated dd { opacity: 0.8; }
.spec-swatch {
    display: inline-block;
    width: 0.9rem; height: 0.9rem;
    border-radius: 0.2rem;
    background: var(--swatch);
    border: 1px solid rgba(0, 0, 0, 0.25);
    vertical-align: -0.1rem;
}
.upload-list td, .upload-list th { padding: 0.2rem 0.4rem; }
.upload-list [data-tag] .btn.active { background: var(--bs-primary); color: var(--accent-contrast, #fff); border-color: var(--bs-primary); }

/* ─── The designs list ───────────────────────────────────────────────────────── */
/* Search takes the room; the owner select and buttons keep their size and wrap under it. */
.design-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.design-filters-search { flex: 1 1 18rem; max-width: 34rem; }
.design-filters-owner { flex: 0 1 16rem; width: auto; min-width: 0; }

.design-status-chips { --bs-nav-link-padding-y: 0.25rem; --bs-nav-link-padding-x: 0.6rem; font-size: 0.85rem; }
.design-status-chips .nav-link { display: inline-flex; align-items: center; gap: 0.4rem; }
.design-status-chips .chip-count {
    font-variant-numeric: tabular-nums;
    font-size: 0.75rem;
    padding: 0 0.4rem;
    border-radius: 999px;
    background: var(--surface-2);
    color: var(--bs-secondary-color);
}
.design-status-chips .nav-link.active .chip-count { background: var(--bs-primary-border-subtle); color: inherit; }
.status-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex: none; }

/* Server-sorted headings are links, with the same arrow the client-sorted tables draw. */
table.sort-links thead th a { color: inherit; text-decoration: none; }
table.sort-links thead th a:hover { color: var(--bs-emphasis-color); text-decoration: underline; }
table.sort-links thead th[aria-sort]::after { font-size: 0.65em; margin-left: 0.3em; vertical-align: 0.15em; }
table.sort-links thead th[aria-sort="ascending"]::after { content: '▲'; }
table.sort-links thead th[aria-sort="descending"]::after { content: '▼'; }
/* On a phone the table scrolls sideways rather than breaking names mid-word; at desktop
   widths these minimums never bind. */
.design-list table td:nth-child(1) { min-width: 12rem; }
.design-list table td:nth-child(2) { min-width: 8rem; }

/* ── The order desk (jobs) ─────────────────────────────────────────────────── */

/* Where an order is: every stage in one line, done ones quiet, the current one the accent.
   Every rule is scoped to `.order-stepper`, the <ol> these pills live in: the portal has an
   `.order-step` of its own (a pill with a link and a numbered disc inside it, styled by
   portal.css), gentoo.css is loaded on both faces, and unscoped these drew a second oval
   and a stray tick around each of the portal's steps. */
.order-stepper { gap: 0.25rem; }
.order-stepper .order-step {
    font-size: 0.8rem; padding: 0.2rem 0.6rem; border-radius: 999px;
    border: 1px solid var(--bs-border-color); color: var(--text-muted);
}
.order-stepper .order-step.is-done { background: var(--surface-2); }
.order-stepper .order-step.is-done::before { content: "\2713\00a0"; }
.order-stepper .order-step.is-current {
    border-color: var(--accent); color: var(--accent); font-weight: 600;
    background: color-mix(in srgb, var(--accent) 10%, transparent);
}

/* The way back to the order from a design tab. */
.order-bar {
    padding: 0.35rem 0.75rem; border-radius: var(--bs-border-radius);
    background: var(--surface-2); border: 1px solid var(--bs-border-color);
}

.order-next { border-color: color-mix(in srgb, var(--accent) 45%, var(--bs-border-color)); }
.quote-lines td { vertical-align: middle; }

/* ─── New part: lookup box and result choice ─────────────────────────────────── */
/* A scan is a part number or a label's text: monospace makes a mistyped O/0 visible. */
.part-lookup-input {
    font-family: var(--bs-font-monospace);
}

.part-lookup-label {
    border: 1px solid var(--bs-border-color);
    border-radius: 0.4rem;
    padding: 0.5rem 0.75rem;
    background-color: var(--surface-2);
}

/* The result a part will be created from. `:has` does it without the script; the class is
   the script's, for the card clicked outside its radio. */
.candidate-choice { cursor: pointer; }
.candidate-choice:has(input[type="radio"]:checked),
.candidate-choice.is-chosen {
    border-color: var(--accent);
    box-shadow: 0 0 0 1px var(--accent);
}

/* ─── BOM health strip ───────────────────────────────────────────────────────── */
/* Replaces the one red alert that used to sit above nearly every BOM. A left rail rather than a
   tinted block, matching the table rails below it: the panel has to be able to be green without
   shouting, and a full-bleed success banner reads as "something happened", not "nothing wrong". */
.bom-health {
    border: 1px solid var(--bs-border-color);
    border-left-width: 4px;
    border-radius: 0.4rem;
    padding: 0.6rem 0.9rem;
    background-color: var(--surface-1);
}

.bom-health-blocking { border-left-color: var(--bs-danger); }
.bom-health-warning  { border-left-color: var(--bs-warning); }
.bom-health-note     { border-left-color: var(--bs-info); }
.bom-health-ok       { border-left-color: var(--bs-success); }

.bom-health-head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.5rem;
}

.bom-health-verdict { font-weight: 500; }

.bom-health-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
    list-style: none;
    margin: 0.5rem 0 0;
    padding: 0;
}

/* Folded away on purpose: the chips are the glance, this is the reading. */
.bom-health-detail { margin-top: 0.5rem; }
.bom-health-detail > summary { cursor: pointer; font-size: 0.875rem; }
.bom-health-detail dl {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 0.35rem 0.75rem;
    margin-top: 0.5rem;
    font-size: 0.875rem;
}
.bom-health-detail dt { font-weight: 400; }
.bom-health-detail dd { margin: 0; }

@media (max-width: 575.98px) {
    /* Two columns of wildly different widths collapse badly on a phone; stack them. */
    .bom-health-detail dl { grid-template-columns: 1fr; }
    .bom-health-detail dd { margin-bottom: 0.4rem; }
}

.bom-health-foot { margin-top: 0.5rem; }

/* A line whose gap is only closed by spending an approved alternate. Amber text rather than a
   row rail: the two rails already mean "we are short" and "the market is short", and this is a
   qualifier on a number in one cell, not a third kind of problem with the row. */
.substitute-note { color: var(--bs-warning-text-emphasis); }

/* The distributor's own setup pages, on the configure screen. A wrapping row of small
   buttons rather than a list: they are three destinations, not three paragraphs, and at
   phone width a list of full-width buttons pushes the credential form off the screen. */
.vendor-setup-links { display: flex; flex-wrap: wrap; gap: 0.35rem; }

/* ─── The carrier tape, drawn from the part being defined ────────────────────
   core/static/js/tape.js emits the geometry in millimeters and this gives it color, so the
   drawing is one coordinate system and one palette rather than inline fills. Tokens, not
   hex, because the editor opens in whichever theme the reader is in and a tape drawn on
   white inside a dark modal reads as an image that failed to load.

   Stroke widths are in millimeters, like everything else in the viewBox: `vector-effect` is
   deliberately NOT used, so a 56 mm tape's outline is the same fraction of its width as an
   8 mm one's and the drawings of two feeders can be compared. */
/* Explicit, both sides: a bare <figure> carries the browser's own 40 px side margins,
   and only Bootstrap's reboot was taking them off. */
.editor-tape { margin: 0.6rem 0 0; }
.editor-tape-drawing { line-height: 0; }
/* Sized by HEIGHT, with the width following the tape's own proportions: a 2 mm-pitch 8 mm
   tape is five times as long as it is wide and a 24 mm one is twice, so fitting either into a
   full-width box leaves one of them floating in a band of empty page. Capped at the
   container, which letterboxes rather than overflows on a narrow window. */
.tape-figure {
    height: 7.5rem;
    width: auto;
    max-width: 100%;
    border-radius: 0.25rem;
}
.tape-web { fill: var(--surface-3); stroke: var(--bs-border-color, #dee2e6); stroke-width: 0.06; }
.tape-hole { fill: var(--bs-body-bg); stroke: var(--bs-secondary-color); stroke-width: 0.06; }
.tape-pocket { fill: var(--surface-1); stroke: var(--bs-secondary-color); stroke-width: 0.06; }
/* An embossed pocket is formed into the tape rather than punched through it, so it keeps
   something of the carrier's own color instead of reading as a hole. */
.tape-pocket-embossed { fill: var(--surface-2); }
.tape-part { fill: rgba(var(--bs-primary-rgb), 0.75); stroke: var(--bs-primary); stroke-width: 0.06; }
/* The pitch, marked between two pocket centers. Heavier than the tape's own lines
   because it is the one thing on the drawing that is not tape: at 8 px/mm a
   0.08 mm rule is under a pixel and disappears. */
.tape-dim { stroke: var(--bs-danger, #dc3545); stroke-width: 0.16; fill: none;
            stroke-linecap: round; }

/* ── P-Tool's figures in the Part Editor ─────────────────────────────────────────────────
   The drawing for the field in focus, floated beside the fields the way P-Tool's own editor
   has it; the letter a field wears in that drawing; the nozzle drawings under Required
   Nozzle; and the tab's whole set in a gallery. The pictures are black line art on white, so
   they keep a white ground in dark mode -- inverted, the red NG / green OK labels on the
   threshold figures swap meaning. See fab/ptoolfigures.py. */
.editor-figure { float: right; width: 17rem; max-width: 45%; margin: 0 0 0.75rem 1rem; }
.editor-figure > img { width: 100%; background: #fff; border: 1px solid var(--bs-border-color);
                       border-radius: 0.375rem; }
.editor-panel::after { content: ''; display: block; clear: both; }
.editor-letter { display: inline-block; min-width: 1.15rem; padding: 0 0.25rem; margin-left: 0.2rem;
                 border-radius: 0.6rem; font-size: 0.7rem; font-weight: 600; text-align: center;
                 color: var(--bs-primary-text-emphasis); background: var(--bs-primary-bg-subtle); }
[data-editor-field].is-figured .editor-letter { color: #fff; background: var(--bs-primary); }
.editor-gallery summary { cursor: pointer; color: var(--bs-secondary-color); }
.editor-gallery-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.4rem;
                       margin-top: 0.4rem; max-height: 22rem; overflow-y: auto; }
.editor-gallery-grid figure { margin: 0; }
.editor-gallery-grid img { width: 100%; background: #fff; border: 1px solid var(--bs-border-color);
                           border-radius: 0.25rem; }
.editor-gallery-grid figcaption { line-height: 1.2; color: var(--bs-secondary-color); }
.editor-gallery-heading { grid-column: 1 / -1; margin-top: 0.3rem; font-weight: 600; }
@media (max-width: 576px) { .editor-figure { float: none; width: 100%; max-width: 100%; margin-left: 0; } }
.editor-gallery-wide { grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); }

/* ── The board at table size ─────────────────────────────────────────────────────────────
   A row's picture of the board it is about: the screenshot the setup's own viewers took of
   it, or the seagull when nobody has looked at it yet (fab/views.design_preview,
   partials/board_thumb.html).

   A fixed box with `object-fit: contain` inside it, because the pictures do not share a
   shape: a 3D view of the assembly comes out of a wide stage, the flat front of a bare board
   comes out the shape of the board, and the seagull is a seagull. `contain` letterboxes each
   of them in the same box, so a column of them lines up and a long thin board still reads as
   long and thin -- stretched to fill the box it would be a different board, which is the one
   thing a picture meant to tell boards apart must not do. `flex-shrink: 0` because these sit
   in a flex cell beside a name that may wrap to three lines, and a squeezed thumbnail is a
   squeezed board. No border and no background: every picture is taken on a transparent
   ground, so the table's own striping shows through and the same shot suits both themes. */
.board-thumb { width: 3.5rem; height: 2.5rem; object-fit: contain; flex-shrink: 0; }
/* Beside a name that wraps, the picture stays at the top with the first line rather than
   floating to the middle of the block. */
.board-thumb-row { display: flex; gap: 0.5rem; align-items: flex-start; }
.board-thumb-row > .board-thumb { margin-top: 0.05rem; }

/* ── The home page (home/templates/home/home.html) ──────────────────────────────────────
   A row of figures, each a link to the page it counts, then a card per part of the shop.
   A card's lists are one line per row -- the thing, who, and a number or an age pushed to the
   right -- so five rows read as a column rather than as five sentences. */
.home-stat { display: block; height: 100%; color: inherit; text-decoration: none; }
.home-stat .stat-card { transition: border-color 0.12s ease; }
.home-stat:hover .stat-card, .home-stat:focus-visible .stat-card { border-color: var(--accent); }
.home-subhead { font-size: 0.75rem; font-weight: 600; color: var(--bs-secondary-color);
                margin: 0.35rem 0 0.25rem; display: flex; align-items: center; gap: 0.35rem; }
.home-list li { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 0.5rem; padding: 0.2rem 0;
                border-bottom: 1px solid rgba(var(--bs-emphasis-color-rgb), 0.06); min-width: 0; }
.home-list li:last-child { border-bottom: 0; }
.home-list .home-ago { min-width: 3.5rem; text-align: right; }
.home-list .home-why { max-width: 55%; }
/* The jobs by stage: a chip per stage, the empty ones faded rather than dropped, so the
   pipeline reads the same every day and a stage with work in it stands out. */
.home-stage { display: inline-flex; align-items: baseline; gap: 0.3rem; padding: 0.15rem 0.55rem;
              border: 1px solid var(--bs-border-color); border-radius: 999px; font-size: 0.8rem;
              color: inherit; text-decoration: none; background: var(--surface-1); }
.home-stage:hover { border-color: var(--accent); }
.home-stage-count { font-weight: 600; font-variant-numeric: tabular-nums; }
.home-stage.is-empty { color: var(--bs-secondary-color); opacity: 0.7; }
.home-card .empty-state { padding: 1.5rem 0.75rem; }
.home-systems th { width: 34%; white-space: nowrap; }

/* Order thread (quoting.OrderMessage): shop left, customer right. */
.order-thread { display: flex; flex-direction: column; gap: 0.6rem; }
.order-thread-item { max-width: min(42rem, 88%); }
.order-thread-item.is-customer { align-self: flex-end; text-align: right; }
.order-thread-body {
    display: inline-block; text-align: left; padding: 0.5rem 0.75rem; border-radius: 0.75rem;
    background: var(--bs-tertiary-bg); border: 1px solid var(--bs-border-color); overflow-wrap: anywhere;
}
.order-thread-item.is-customer .order-thread-body {
    background: color-mix(in srgb, var(--bs-primary) 12%, var(--bs-body-bg));
    border-color: color-mix(in srgb, var(--bs-primary) 35%, var(--bs-border-color));
}

/* Manage > Portal lead-time boxes, and build photo thumbnails on both order pages. */
.lead-times { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem; }
.lead-time { width: 8.5rem; }
.lead-time-arrow { padding-bottom: 0.3rem; color: var(--bs-secondary-color); }
@media (max-width: 575.98px) {
    .lead-time { width: calc(50% - 0.25rem); }
    .lead-time-arrow { display: none; }
}
.build-photos { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.build-photo { position: relative; margin: 0; width: 9.5rem; }
.build-photo img {
    display: block; width: 100%; height: 7rem; object-fit: cover;
    border-radius: var(--bs-border-radius); border: 1px solid var(--bs-border-color);
}
.build-photo figcaption { line-height: 1.2; margin-top: 0.2rem; overflow-wrap: anywhere; }
.build-photo-remove { position: absolute; top: 0.25rem; right: 0.25rem; padding: 0.1rem 0.4rem; opacity: 0.9; }
@media (max-width: 575.98px) { .build-photo { width: calc(50% - 0.25rem); } }

/* The design check's one-line summary and the viewers' finding ring. Report cards: fab/static/fab/dfm.css. */
.dfm-line { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.dfm-line-link { text-decoration: none; }
.dfm-line-success, .dfm-line-success a { color: var(--bs-success-text-emphasis); }
.dfm-line-warning, .dfm-line-warning a { color: var(--bs-warning-text-emphasis); }
.dfm-line-danger, .dfm-line-danger a { color: var(--bs-danger-text-emphasis); }
.dfm-line-detail { font-weight: 400; }
.dfm-line-auto { font-size: 0.72rem; border: 1px solid var(--bs-border-color); border-radius: 1rem; padding: 0 0.4rem; cursor: help; }

.dfm-ring { fill: none; stroke: #ffd21f; stroke-width: 3px; vector-effect: non-scaling-stroke; filter: drop-shadow(0 0 1.5px #000); }
.dfm-ring-other { stroke-width: 1.5px; stroke-dasharray: 4 3; }

/* "Your default" chip beside an answer taken from the organization's saved defaults. Both faces. */
.from-defaults {
    display: inline-flex; align-items: center; gap: 0.25rem; margin-left: 0.4rem; white-space: nowrap;
    font-size: 0.75rem; font-weight: 400; padding: 0 0.45rem; border-radius: 999px; text-decoration: none;
    background: var(--bs-primary-bg-subtle); color: var(--bs-primary-text-emphasis);
    border: 1px solid var(--bs-primary-border-subtle);
}

/* ─── Adding a part's picture (js/part_picture.js) ──────────────────────────── */
/* The blank tile, for a writer: a button, with a + on hover and focus. */
.part-picture-add {
    padding: 0;
    cursor: pointer;
    position: relative;
    font-size: 0;
}
.part-picture-add::after {
    content: '+';
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--bs-primary);
    font-size: 1.1rem;
    font-weight: 600;
    opacity: 0;
}
.part-card-noimage.part-picture-add::after { font-size: 2rem; }
.part-page-header-noimage.part-picture-add::after { font-size: 1.6rem; }
.part-picture-add:hover, .part-picture-add:focus-visible { border-color: var(--bs-primary); }
.part-picture-add:hover::after, .part-picture-add:focus-visible::after { opacity: 1; }

/* The one drop zone (partials/drop_zone.html, js/drop_zone.js): every upload on the shop face. */
.dz {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: 0.25rem 0.6rem;
    border: 2px dashed rgba(var(--bs-emphasis-color-rgb), 0.2);
    border-radius: 0.5rem;
    padding: 0.75rem 0.9rem;
    color: var(--bs-secondary-color);
    text-align: center;
    transition: background-color 0.1s, border-color 0.1s;
}
.dz.is-slim { padding: 0.4rem 0.6rem; border-width: 1px; }
.dz.is-over { border-color: var(--bs-primary); background: rgba(var(--bs-primary-rgb), 0.06); }
.dz.is-busy .dz-pick { pointer-events: none; opacity: 0.6; }
.dz-icon { font-size: 1.4rem; }
.dz.is-slim .dz-icon { font-size: 1rem; }
.dz-what { min-width: 0; }
.dz-pick { cursor: pointer; text-decoration: underline; color: var(--bs-link-color); }
.dz-aside { font-size: 0.875em; }
.dz-queue { flex: 1 1 100%; text-align: left; }
.dz-queue:empty { display: none; }
.dz-job { display: flex; align-items: center; gap: 0.5rem; border: 1px solid var(--bs-border-color); border-radius: 0.375rem; padding: 0.4rem 0.6rem; margin-top: 0.5rem; color: var(--bs-body-color); }
.dz-job.is-failed { border-color: var(--bs-danger); }
.dz-job-body { flex: 1 1 auto; min-width: 0; }
.dz-job .progress { height: 0.35rem; margin-top: 0.25rem; }
.dz-job img { width: 40px; height: 40px; object-fit: cover; border-radius: 0.25rem; }
.dz-name { flex-wrap: wrap; }
.dz-name .input-group { max-width: 32rem; }
/* An order's Files card (partials/attachments_card.html, js/attachments.js). */
.attach-list .attach-row { padding: 0.4rem 0; border-top: 1px solid var(--bs-border-color-translucent); }
.attach-list .attach-row:first-child { border-top: 0; }
.attach-thumb { flex: 0 0 40px; width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; }
.attach-thumb img { width: 40px; height: 40px; object-fit: cover; border-radius: 0.25rem; background: #fff; border: 1px solid var(--bs-border-color-translucent); }
.attach-icon { font-size: 1.4rem; color: var(--bs-secondary-color); }
.min-w-0 { min-width: 0; }
.picture-crop {
    width: 280px;
    height: 280px;
    max-width: 100%;
    aspect-ratio: 1;
    border-radius: 0.5rem;
    background: #fff;
    border: 1px solid rgba(var(--bs-emphasis-color-rgb), 0.15);
    cursor: grab;
    touch-action: none;
}
.picture-crop:active { cursor: grabbing; }
.picture-renders { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.picture-render {
    width: 84px;
    height: 84px;
    padding: 0;
    border-radius: 0.4rem;
    border: 1px solid rgba(var(--bs-emphasis-color-rgb), 0.15);
    background: var(--board-ground, var(--surface-2));
    position: relative;
    overflow: hidden;
}
.picture-render img { width: 100%; height: 100%; object-fit: contain; }
.picture-render .picture-render-label {
    position: absolute; left: 0; right: 0; bottom: 0;
    font-size: 0.7rem; background: rgba(0, 0, 0, 0.45); color: #fff;
}
.picture-render.is-picked { outline: 2px solid var(--bs-primary); outline-offset: 1px; }
.picture-render-frame { position: absolute; left: -10000px; top: 0; width: 800px; height: 800px; border: 0; }

/* ─── Price ladders (partials/price_ladder.html, js/price_breaks.js) ──────────── */
/* A unit price whose break table opens on hover or focus, beside it, over the page. */
.price-ladder { position: relative; border-bottom: 1px dotted currentColor; cursor: help; white-space: nowrap; }
.price-ladder:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 2px; border-radius: 2px; }
.price-ladder-pop {
    display: none; position: absolute; left: 0; top: calc(100% + 4px); z-index: 1080; min-width: 9rem;
    background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); border-radius: .375rem;
    box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15); font-size: .8rem; font-variant-numeric: tabular-nums;
}
.price-ladder:hover .price-ladder-pop, .price-ladder:focus .price-ladder-pop { display: block; }
.price-ladder-pop th, .price-ladder-pop td { padding: .15rem .5rem; }
/* Inline variant: the PO line editor, whose table scrolls and would clip a popover. */
.price-ladder-inline { display: flex; flex-wrap: wrap; gap: .25rem; font-size: .8rem; font-variant-numeric: tabular-nums; }
.price-ladder-inline span { border: 1px solid var(--bs-border-color); border-radius: .25rem; padding: 0 .35rem; }
.price-ladder-inline span.table-active { background: var(--bs-primary-bg-subtle); border-color: var(--bs-primary); }

/* ─── Process badges (partials/msl_badge.html, wash_badge.html) ───────────────── */
/* A badge whose explanation opens on hover or focus, over the page, like the price ladder. */
.process-badge { position: relative; display: inline-block; cursor: help; }
.process-badge:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 2px; border-radius: 2px; }
.process-pop {
    display: none; position: absolute; left: 0; top: calc(100% + 4px); z-index: 1080; min-width: 13rem; max-width: 18rem;
    background: var(--bs-body-bg); color: var(--bs-body-color); border: 1px solid var(--bs-border-color);
    border-radius: .375rem; box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15); font-size: .8rem; font-weight: normal;
    text-align: left; white-space: normal;
}
.process-badge:hover .process-pop, .process-badge:focus .process-pop { display: block; }
.process-pop th, .process-pop td { padding: .1rem .5rem; }

/* ─── Info tips (partials/info_tip.html, js/info_tip.js) ─────────────────────────
   A small ⓘ after a label; its note opens over the page on hover, focus or a tap (.is-open). */
.info-tip { position: relative; display: inline-block; vertical-align: baseline; text-transform: none;
            letter-spacing: 0; font-weight: normal; }
.info-tip-btn { display: inline-flex; align-items: center; justify-content: center; min-width: 1.25rem; min-height: 1.25rem;
                padding: 0; border: 0; border-radius: 50%; background: none; color: var(--bs-secondary-color);
                font-size: .8rem; line-height: 1; cursor: help; }
.info-tip-btn:hover, .info-tip.is-open .info-tip-btn { color: var(--bs-primary); }
.info-tip-btn:focus-visible { outline: 2px solid var(--bs-primary); outline-offset: 1px; }
.info-tip:hover .info-tip-pop, .info-tip:focus-within .info-tip-pop, .info-tip.is-open .info-tip-pop { display: block; }
.info-tip-pop { min-width: 15rem; max-width: 20rem; text-transform: none; letter-spacing: 0; line-height: 1.35; }
.info-tip-end, .info-tip-flip { left: auto; right: 0; }
.info-tip-rows { display: grid; grid-template-columns: max-content 1fr; gap: .1rem .5rem; }
.info-tip-rows dt { font-weight: 600; }
.info-tip-rows dd { margin: 0; }

/* ─── Save only when something changed (js/dirty.js, AGENTS.md "Disclosure and Save") ───
   A changed field has the amber edge of a changed auto-fill (parts.css); Save is muted with nothing
   to save and fills in with the accent once something is, with a count of the changes. */
.is-changed:is(input, select, textarea) { border-color: var(--bs-warning); box-shadow: inset 3px 0 0 var(--bs-warning); }
.is-changed:is(input, select, textarea):focus { box-shadow: inset 3px 0 0 var(--bs-warning), 0 0 0 .25rem rgba(var(--bs-warning-rgb), .25); }
:is(.form-check, label).is-changed { box-shadow: inset 3px 0 0 var(--bs-warning); border-radius: .25rem; padding-right: .35rem; }
.btn[data-dirty-state="clean"] {
    --bs-btn-color: var(--bs-secondary-color); --bs-btn-bg: transparent; --bs-btn-border-color: var(--bs-border-color);
    --bs-btn-hover-color: var(--bs-secondary-color); --bs-btn-hover-bg: transparent; --bs-btn-hover-border-color: var(--bs-border-color);
    --bs-btn-active-color: var(--bs-secondary-color); --bs-btn-active-bg: transparent; --bs-btn-active-border-color: var(--bs-border-color);
    cursor: default; opacity: .65; box-shadow: none;
}
.btn[data-dirty-state="dirty"] {
    --bs-btn-color: var(--accent-contrast); --bs-btn-bg: var(--accent); --bs-btn-border-color: var(--accent);
    --bs-btn-hover-color: var(--accent-contrast); --bs-btn-hover-bg: var(--accent-hover); --bs-btn-hover-border-color: var(--accent-hover);
    --bs-btn-active-color: var(--accent-contrast); --bs-btn-active-bg: var(--accent-active); --bs-btn-active-border-color: var(--accent-active);
}
.dirty-count { display: inline-block; min-width: 1.2em; margin-left: .35em; padding: 0 .3em; border-radius: 1em; font-size: .75em;
               line-height: 1.4; font-weight: 600; text-align: center; vertical-align: .1em;
               color: var(--accent); background: var(--accent-contrast); }
/* Hand load (intake/hand.py): the hand on a Parts row, the Overview tile, the Machine files list. */
.info-tip-hand .info-tip-btn { min-width: 1.5rem; padding: 0 .3rem; border-radius: 1rem; font-size: .8rem;
                               color: var(--bs-warning-text-emphasis); background: var(--bs-warning-bg-subtle);
                               border: 1px solid var(--bs-warning-border-subtle); }
.ov-hand .ov-tile-n { color: var(--bs-warning-text-emphasis); }
.hand-place-table .hand-refs { max-width: 28rem; word-break: break-word; }
