/* Design system for the static report.
 *
 * Lifted verbatim out of the <style> block tracker.ps1 used to interpolate
 * into report.html, so the new page is pixel-identical to the old one and any
 * difference during the 2.3 migration is a rendering bug rather than a
 * restyling. It is a plain stylesheet now: no PowerShell, no escaping, and a
 * browser that can cache it instead of re-downloading it inside a 2,6 MB
 * document every hour.
 *
 * Rules added for the static client (load states, stale marker) are at the
 * bottom, under their own heading, so the lifted part stays a clean diff
 * against the original.
 */

:root {
    --bg-color: #0b0f19;
    --card-bg: rgba(17, 24, 39, 0.7);
    --border-color: rgba(255, 255, 255, 0.08);
    --text-primary: #f3f4f6;
    --text-secondary: #9ca3af;
    --accent-color: #df0000; 
    --success-color: #10b981;
    --warning-color: #f59e0b;
    --hover-bg: rgba(255, 255, 255, 0.03);
    --shadow-glow: 0 0 20px rgba(223, 0, 0, 0.15);
}

* { box-sizing: border-box; margin: 0; padding: 0; }

body {
    background-color: var(--bg-color);
    color: var(--text-primary);
    font-family: 'Inter', sans-serif;
    padding: 2.5rem 1.5rem;
    min-height: 100vh;
    /* The page itself must never scroll sideways: wide content scrolls
       inside its own .table-responsive box, never by dragging the
       whole layout out from under the viewport. */
    overflow-x: hidden;
}

/* 1200px was narrower than the tracked table's nine columns need, so
   the action column (link + delete) was pushed past the right edge on
   an ordinary desktop. The cap now follows the window up to a width
   the table comfortably fits in, and min() keeps it inside narrower
   viewports instead of forcing the body to scroll. */
.container { max-width: min(1560px, 100%); margin: 0 auto; }

header {
    display: flex;
    align-content: center;
    justify-content: space-between;
    align-items: center;
    border-bottom: 1px solid var(--border-color);
    padding-bottom: 1.5rem;
    margin-bottom: 2.5rem;
}

.header-left h1 {
    font-family: 'Outfit', sans-serif;
    font-size: 2.2rem;
    font-weight: 700;
    background: linear-gradient(to right, #ffffff, #9ca3af);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    letter-spacing: -0.5px;
}

.header-left p {
    color: var(--text-secondary);
    font-size: 0.95rem;
    margin-top: 0.3rem;
}

.timestamp {
    background: rgba(255, 255, 255, 0.05);
    border: 1px solid var(--border-color);
    padding: 0.6rem 1.2rem;
    border-radius: 12px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* The header's action row.
   Wrapping rather than the single line it used to be. Five buttons want ~970px
   and the title beside them wants the rest, which is more than .container's
   1560px ceiling has to give at ANY window size - and under nowrap the
   difference came out of the BUTTONS: at 1440 the scan button's label was
   broken over two lines, at 900 over four. Wrapping spends a second row of
   header instead, and every button keeps the shape it was drawn at. */
.header-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: 10px;
}

/* The three list buttons on a desktop, where they fit: the wrapper and the
   menu are `display: contents`, so the buttons are laid out by .header-actions
   as its own children and nothing here changes what the header looked like
   before the menu existed. The "⋯" that would open it is not drawn at all.
   Both of these are overturned below the mobile breakpoint. */
.header-more,
.header-more-menu { display: contents; }
.more-btn { display: none; }

/* Any of the three lists is non-empty. The three buttons carry their own
   badges, but a collapsed menu hides them, and "something is filtering this
   report" must not be a thing you only learn by opening a menu. A dot rather
   than a number: three counts of three different kinds (a count, a ✓, a "?")
   do not add up to one number that means anything. */
.more-dot {
    display: inline-block;
    width: 7px;
    height: 7px;
    margin-left: 0.4rem;
    border-radius: 50%;
    background: var(--accent-color);
    vertical-align: middle;
}

.section-card {
    background: var(--card-bg);
    border: 1px solid var(--border-color);
    border-radius: 20px;
    padding: 2rem;
    margin-bottom: 2.5rem;
    backdrop-filter: blur(16px);
    box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}

.section-card h2 {
    font-family: 'Outfit', sans-serif;
    font-size: 1.5rem;
    font-weight: 600;
    margin-bottom: 1.5rem;
    display: flex;
    align-items: center;
    gap: 0.6rem;
    border-left: 4px solid var(--accent-color);
    padding-left: 0.8rem;
}

.campaign-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 1.2rem;
}

.campaign-banner-card {
    background: rgba(255, 255, 255, 0.02);
    border: 1px solid var(--border-color);
    border-radius: 14px;
    padding: 1.2rem;
    display: flex;
    align-items: center;
    justify-content: space-between;
    transition: all 0.3s ease;
    text-decoration: none;
    color: var(--text-primary);
}

.campaign-banner-card:hover {
    transform: translateY(-3px);
    background: rgba(223, 0, 0, 0.05);
    border-color: rgba(223, 0, 0, 0.3);
    box-shadow: var(--shadow-glow);
}

.campaign-info { display: flex; flex-direction: column; gap: 0.3rem; }
.campaign-title { font-weight: 600; font-size: 1rem; }
.campaign-btn { font-size: 0.8rem; color: var(--accent-color); font-weight: 700; }

.table-responsive {
    width: 100%;
    max-width: 100%;
    -webkit-overflow-scrolling: touch;
}

/* Sideways scrolling is switched ON by table.js, and only for a table that
   really is wider than this box (see watchOverflow there). With the fixed
   layout and the 1100px floor below, that means a window under ~1100px or a
   cell that refuses to wrap - it is the fallback, not the usual case.

   It used to be unconditional, and that is what kept the column headers from
   being able to stick: `position: sticky` sticks to the nearest scrollport,
   and setting overflow on EITHER axis makes this box one. The header then
   stuck to a box that never scrolls vertically - which looks exactly like a
   header that does not stick at all. A table that fits needs no scroller, so
   it does not get one, and its header can stick to the window instead. */
.table-responsive.is-scrollable {
    overflow-x: auto;
    /* Only the horizontal axis scrolls; without this, browsers add a
       vertical scrollbar to the box as soon as overflow-x is set. */
    overflow-y: hidden;
}

.styled-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.9rem;
    text-align: left;
    /* Fixed rather than content-sized, and this is what keeps the sticky
       column headers alive.

       Under `auto` a table is as wide as its widest row needs, and the
       tracked table - 29 rows against the TOP table's three - outgrew the
       window somewhere around 1440px. .table-responsive then turned its
       sideways scroller on, and a header inside a scroller sticks to the
       SCROLLER rather than to the window (see .is-scrollable below), i.e. it
       stopped sticking at all. Which table kept its header therefore came
       down to how long its longest product name happened to be.

       Fixed columns take the widths declared below and wrap their text
       instead of widening, so the table is exactly as wide as the card at
       any window size and every header keeps its pin. */
    table-layout: fixed;
    /* The floor under that. Below this the columns stop being narrow and
       start being squeezed, so the sideways scroller comes back and the pin
       is given up - which is what EVERY table on the page already did at
       this width, the TOP one included. Lifted on mobile, where the rows are
       stacked cards and none of this applies. */
    min-width: 1100px;
}

/* Column widths, read by `table-layout: fixed` off the header row.
   Percentages for the columns holding a price, which is as long as the price
   is - four figures and a separator at the top of the range - and so wants a
   little more room in a wide window. Pixels for the two that hold the same
   thing at every size, a status pill and a pair of buttons: they gain nothing
   from a wider window and would only take room from the product name.
   .col-product declares no width on purpose: fixed layout hands the leftover
   to the columns that ask for none, so the name gets all the slack. */
.styled-table th.col-price, .styled-table td.col-price { width: 12%; }
.styled-table th.col-prev, .styled-table td.col-prev { width: 8%; }
.styled-table th.col-target, .styled-table td.col-target { width: 10.5%; }

/* Padding was 1.2rem on every side of nine columns - ~21rem of pure
   whitespace, which is most of why the row overflowed. Vertical
   breathing room is kept; the horizontal share is what is cut. */
.styled-table th {
    color: var(--text-secondary);
    font-weight: 500;
    padding: 0.9rem 0.7rem;
    border-bottom: 1px solid var(--border-color);
    font-size: 0.8rem;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.styled-table td {
    padding: 1rem 0.7rem;
    border-bottom: 1px solid rgba(255, 255, 255, 0.03);
    vertical-align: middle;
}

/* The comparison columns hold a price, a badge and sometimes a source
   note; left to themselves they stretch on the badge text. Wrapping
   long tokens keeps the total inside the container.
   break-word, NOT anywhere: `anywhere` also lowers a cell's min-content
   width to a single character, which let the auto layout squeeze the
   product column into a one-letter-per-line ribbon at 1024px. */
.styled-table td[data-label] { overflow-wrap: break-word; }

/* Action column: as narrow as its two buttons, and pinned to the right
   edge so the link and the delete button stay reachable even if a very
   narrow window does force the table to scroll. */
.styled-table th.col-actions, .styled-table td.col-actions {
    width: 124px;
    white-space: nowrap;
    position: sticky;
    right: 0;
    /* Opaque: a sticky cell sits over the scrolled content, and the
       card's translucent background would let the row underneath show
       through it. This is that background resolved against the page. */
    background-color: #0f1521;
    box-shadow: -8px 0 12px -8px rgba(0, 0, 0, 0.6);
}
.styled-table td.col-actions .buy-btn { padding: 0.45rem 0.7rem; }
/* Wide enough for the longest pill ("⚠️ Ошибка"), because the pill itself
   refuses to wrap - see .status-indicator. */
.styled-table th.col-status, .styled-table td.col-status { width: 116px; white-space: nowrap; }

/* Pinned column headers, so a price 40 rows down still has a column name.
 *
 * Scoped to a box that is NOT scrolling sideways for the reason spelled out
 * on .is-scrollable above: inside a scroller these would stick to the box
 * rather than to the window. When the two cannot both be had, reaching the
 * last column wins over keeping the header in view.
 *
 * Every table on the page gets this, not just the tracked one - each header
 * sticks only while its own table is on screen and lets go at its last row,
 * which is exactly "the header of the table I am looking at".
 *
 * Off below 769px: the mobile block turns rows into stacked cards and hides
 * thead outright, so there is no header to pin. */
@media (min-width: 769px) {
    .table-responsive:not(.is-scrollable) .styled-table thead th {
        position: sticky;
        top: 0;
        z-index: 2;
        /* Opaque, for the same reason the action column is: rows scroll
           underneath this. Same colour - the card resolved against the page. */
        background-color: #0f1521;
        /* An inset shadow rather than the border-bottom above: with
           border-collapse the cell borders belong to the TABLE, so a sticky th
           leaves its own behind and floats over the rows with no line under it. */
        box-shadow: inset 0 -1px 0 var(--border-color);
    }

    /* Pinned in both directions at once, so it has to outrank the headers to
       its left as well as the body cells sliding under it. */
    .table-responsive:not(.is-scrollable) .styled-table th.col-actions {
        z-index: 3;
        box-shadow: inset 0 -1px 0 var(--border-color),
                    -8px 0 12px -8px rgba(0, 0, 0, 0.6);
    }

    /* Below the header row it passes under, above the ordinary cells. */
    .styled-table td.col-actions { z-index: 1; }
}

/* The four comparison columns (Amazon / PCC / CARR / CM) share one width, so
   the table reads as four shops side by side instead of four different sizes.
   Left to the auto layout they came out 89 / 201 / 173 / 127 px at 1600px:
   `table-layout: auto` hands out slack in proportion to each column's
   MAX-content width, and the shops differ wildly there - Carrefour can carry
   "Marketplace: 1.234,56 EUR" on one line and PCC a Tenerife-converted price
   with a source note, while Amazon is usually a bare figure. The widest bought
   its extra room from the narrowest, which is how CM ended up the one column
   that could not show its content.

   A percentage rather than a px width: it is a share of whatever the table
   got, so the four stay equal to each other at any window size and grow
   together as the window does. Under `table-layout: fixed` this is the
   column's width outright rather than a hint the layout may overrule
   upwards, which is why a cell that does not fit now wraps instead. */
.styled-table th.col-shop, .styled-table td.col-shop { width: 8.25%; }

.styled-table tbody tr { transition: background-color 0.2s ease; }
.styled-table tbody tr:hover { background-color: var(--hover-bg); }
.styled-table tbody tr:hover td.col-actions { background-color: #141b2a; }

.product-cell { display: flex; align-items: center; gap: 1rem; }

.product-img-wrapper {
    width: 48px;
    height: 48px;
    border-radius: 8px;
    background: white;
    padding: 2px;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    flex-shrink: 0;
    border: 1px solid var(--border-color);
}

.product-thumbnail { max-width: 100%; max-height: 100%; object-fit: contain; }
/* 400px of title was the single widest thing in the row. MediaMarkt
   names are spec strings that wrap happily, so the column gives most
   of that width back to the columns that were being pushed off the
   right edge. */
/* No max-width: the column is sized by the rules above now, not by this
   text, so the title should use whatever the column gives it. Under the old
   auto layout that cap was the only thing stopping a 121-character name from
   claiming half the table. */
.product-title { font-weight: 500; line-height: 1.4; overflow-wrap: break-word; }
.current-price { font-size: 1.05rem; color: #ffffff; }

/* Prices converted to Tenerife delivered money (mainland shops - see
   Convert-ToTenerifePrice in tracker.ps1). Marked rather than silently
   substituted: the figure deliberately does not match the shop's page,
   and the tooltip explains why. */
.tenerife-price { border-bottom: 1px dotted rgba(34, 211, 238, 0.5); cursor: help; }
.tenerife-mark { font-size: 0.75rem; opacity: 0.75; }

.price-badge {
    display: inline-block;
    font-size: 0.75rem;
    padding: 2px 6px;
    border-radius: 6px;
    font-weight: 600;
    margin-left: 0.5rem;
    vertical-align: middle;
}

/* In a comparison column a badge never shares a line with the price - the
   column is too narrow for that - so the margin meant to separate them
   inline only pushes it past the cell's right edge. A margin sits outside
   max-width, so this is not covered by the rule below. */
.styled-table td.col-shop .price-badge,
.styled-table td.col-shop .same-price-badge { margin-left: 0; margin-top: 4px; }

/* And nothing in a comparison cell may be wider than the cell. These are the
   narrowest columns carrying real text, and their contents are inline-blocks
   - a shop note, a badge - which are as wide as their longest unbreakable run
   unless told otherwise. Under `table-layout: fixed` a child sticking out of
   its cell is the one way a row can still push the table wider than the card,
   which is what costs every header on the page its pin. */
.styled-table td.col-shop * { max-width: 100%; overflow-wrap: break-word; }

.badge-drop { background-color: rgba(16, 185, 129, 0.15); color: var(--success-color); }
.badge-up { background-color: rgba(239, 68, 68, 0.15); color: #f87171; }
.badge-met {
    background-color: rgba(245, 158, 11, 0.15);
    color: var(--warning-color);
    border: 1px solid rgba(245, 158, 11, 0.3);
}

.cheaper-badge {
    display: inline-block;
    background: rgba(16, 185, 129, 0.15);
    color: var(--success-color);
    font-size: 0.7rem;
    font-weight: 700;
    padding: 1px 4.5px;
    border-radius: 4px;
    margin-left: 0.3rem;
    border: 1px solid rgba(16, 185, 129, 0.2);
}

/* Competitor comparison badges. The colour states the competitor's
   price relative to MediaMarkt's, since the badge sits inside the
   competitor column: red = competitor costs more, green = competitor
   is the cheaper place to buy, grey = identical price. Previously
   only the green "Vygoda" variant existed and it was rendered in the
   competitor-is-MORE-expensive case, which read backwards. */
.dearer-badge {
    display: inline-block;
    background: rgba(239, 68, 68, 0.15);
    color: #f87171;
    font-size: 0.7rem;
    font-weight: 700;
    padding: 1px 4.5px;
    border-radius: 4px;
    margin-left: 0.3rem;
    border: 1px solid rgba(239, 68, 68, 0.25);
}
.same-price-badge {
    display: inline-block;
    background: rgba(107, 114, 128, 0.15);
    color: #9ca3af;
    font-size: 0.7rem;
    font-weight: 600;
    padding: 1px 4.5px;
    border-radius: 4px;
    margin-left: 0.3rem;
    border: 1px solid rgba(107, 114, 128, 0.25);
}
/* Labelled marketplace fallback (carrefour_scraper.py): shown when
   Carrefour itself does not sell the item, so it is deliberately
   styled as a muted aside, not as a comparable price. */
.marketplace-note {
    display: block;
    font-size: 0.68rem;
    color: #9ca3af;
    font-style: italic;
    margin-top: 3px;
    /* Deliberately NOT nowrap. As one unbreakable run, "Marketplace: 408,22
       EUR" measured 143px of min-content, which became the floor under the
       whole Carrefour column - the shop columns are meant to share a width
       (col-shop above), and this one line was the reason Carrefour would not
       shrink to it. The figure itself still cannot split: the space before
       the euro sign is non-breaking (see carrefourCell in table.js), so the
       line breaks after the label and nowhere else. */
}

/* Shop search links in the competitor column of list tables, where no
   per-item competitor URL exists to price against. */
.shop-search-link {
    display: inline-block;
    font-size: 0.72rem;
    color: var(--text-secondary);
    text-decoration: none;
    border: 1px solid var(--border-color);
    border-radius: 4px;
    padding: 1px 6px;
    margin-top: 3px;
    white-space: nowrap;
}
.shop-search-link:hover {
    color: var(--warning-color);
    border-color: var(--warning-color);
}

/* "+" that opens the Coolmod- / PCComponentes- / Amazon-URL dialog,
   shown in CM, PCC, Carrefour and Amazon cells that have no price yet.
   Deliberately quiet until hovered - it appears on most rows, and a
   column of bright buttons would read as an error state rather than an
   offer. Each hover colour matches its own column's price colour. */
.cm-add-btn, .pcc-add-btn, .amz-add-btn, .carr-add-btn {
    background: transparent;
    border: 1px dashed var(--border-color);
    border-radius: 4px;
    color: var(--text-secondary);
    cursor: pointer;
    font-size: 0.8rem;
    line-height: 1;
    padding: 3px 6px;
    opacity: 0.55;
    transition: 0.2s;
}
.cm-add-btn:hover {
    opacity: 1;
    color: #22d3ee;
    border-color: #22d3ee;
}
.pcc-add-btn:hover {
    opacity: 1;
    color: #f59e0b;
    border-color: #f59e0b;
}
.amz-add-btn:hover {
    opacity: 1;
    color: #ff9900;
    border-color: #ff9900;
}
.carr-add-btn:hover {
    opacity: 1;
    color: #0051a2;
    border-color: #0051a2;
}
.cm-add-btn:disabled { cursor: progress; opacity: 0.4; }

.status-indicator {
    display: inline-flex;
    align-items: center;
    font-size: 0.8rem;
    font-weight: 600;
    padding: 4px 10px;
    border-radius: 20px;
    /* The status column is the narrowest in the table. A wrapped or clipped
       pill ("Ошибка ска") reads as a rendering fault, so the pill keeps its
       label on one line and the column gives it the width. */
    white-space: nowrap;
}

.status-in-stock { background-color: rgba(16, 185, 129, 0.12); color: var(--success-color); }
.status-out-of-stock {
    background-color: rgba(239, 68, 68, 0.12);
    color: #f87171;
    text-decoration: line-through;
}
/* Neither green nor red: the scan could not tell, which is a third thing.
   Amber is the warning colour the rest of the report already uses, and there
   is deliberately no line-through - nothing here says the product is gone. */
.status-scan-failed {
    background-color: rgba(245, 158, 11, 0.12);
    color: var(--warning-color);
}

/* Price history sparkline.
   Muted on purpose: it is context for the price above it, not a second
   headline, and 200+ of them on one page at full contrast would read as the
   subject of the table. */
.sparkline {
    display: block;
    margin-top: 5px;
    overflow: visible;
}
.sparkline-path {
    fill: none;
    stroke: var(--text-secondary);
    stroke-width: 1.2;
    stroke-linejoin: round;
    stroke-linecap: round;
    opacity: 0.75;
}
.sparkline-now { fill: var(--text-primary); }

/* Today's price, straight across the sparkline. Dashed and faint: it is a
   ruler laid over the drawing, not another series, and it must not be read
   as a price that was ever charged on those days. */
.sparkline-now-line {
    stroke: var(--text-secondary);
    stroke-width: 0.8;
    stroke-dasharray: 2 2;
    opacity: 0.55;
}
.styled-table tr:hover .sparkline-now-line { opacity: 0.85; }

/* The sparkline is a button - it opens the full chart. Styled to look like
   exactly what it looked like before, plus a pointer: 225 outlined boxes
   down a column would be the loudest thing on the page. */
.spark-btn {
    display: block;
    padding: 0;
    border: 0;
    background: none;
    cursor: pointer;
    border-radius: 4px;
}
.spark-btn:hover .sparkline-path { opacity: 1; stroke: var(--accent-color); }
/* Focus is the one state that must be visible: without it a keyboard user
   tabbing down the column cannot see where they are. */
.spark-btn:focus-visible {
    outline: 2px solid var(--accent-color);
    outline-offset: 2px;
}

/* "Lowest in the window", under the chart. Deliberately quiet: it qualifies
   the price above it and appears on 87 of 225 rows, so at headline weight it
   would compete with the number it is describing. */
.price-low-note {
    margin-top: 3px;
    font-size: 0.7rem;
    font-weight: 500;
    color: var(--text-secondary);
    white-space: nowrap;
    cursor: help;
}
.price-low-note.is-low { color: var(--success-color); }

/* --- Scraping health -------------------------------------------------- */

.health-window {
    font-size: 0.75rem;
    color: var(--text-secondary);
    margin-bottom: 0.9rem;
}
.health-subhead {
    font-size: 0.85rem;
    font-weight: 600;
    margin: 0 0 0.5rem;
    color: var(--text-primary);
}
.health-list { list-style: none; margin: 0 0 1.2rem; padding: 0; }
.health-list li {
    padding: 0.6rem 0.8rem;
    margin-bottom: 0.5rem;
    border-radius: 8px;
    border-left: 3px solid var(--warning-color);
    background: rgba(245, 158, 11, 0.07);
    /* The product name and the shop badge after it are two adjacent spans
       with no whitespace between them, so a line breaker sees one word:
       "...Ddr4 3600Mhz Cl18 Intel Xmp Rgb Negro" + "carrefour" was 369px of
       unbreakable run in a 303px box, and on a phone that made the whole PAGE
       scroll sideways (441px against a 390px screen). Breaking anywhere is
       right for this list specifically - these are scraped model numbers, and
       a name that cannot wrap is worth less than a report that fits. */
    overflow-wrap: anywhere;
}
/* A disagreement between two working feeds, not an outage. Deliberately not
   the amber of the list above: amber means "this is not returning prices",
   and everything in this block returned a price - just not the same one. */
.health-list li.is-divergence {
    border-left-color: var(--text-secondary);
    background: rgba(255, 255, 255, 0.04);
}
.health-divergence { margin-top: 1.4rem; }
.health-divergence .health-window { margin: 0 0 0.7rem; }

.health-item-name { font-weight: 600; }
.health-source {
    margin-left: 0.5rem;
    padding: 1px 7px;
    border-radius: 5px;
    font-size: 0.7rem;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    color: var(--text-secondary);
    background: rgba(255, 255, 255, 0.06);
}
.health-detail { font-size: 0.78rem; color: var(--text-secondary); margin-top: 3px; }
.health-reason {
    font-size: 0.72rem;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    color: var(--warning-color);
    margin-top: 2px;
}
.health-ok { font-size: 0.85rem; color: var(--success-color); margin-bottom: 1rem; }
.health-ok-note { font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.6rem; }
.health-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
.health-table td {
    padding: 0.4rem 0.6rem 0.4rem 0;
    border-bottom: 1px solid var(--border-color);
    vertical-align: top;
}
.health-source-name {
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-weight: 600;
}
.health-rate { color: var(--warning-color); font-weight: 600; }

/* One step quieter again than the "lowest" line: this is background for a
   decision, not a claim about today. */
.price-context-note {
    margin-top: 2px;
    font-size: 0.68rem;
    color: #6b7280;
    white-space: nowrap;
    cursor: help;
}

/* Omnibus: the card's "was" price contradicts what we recorded before the
   reduction. Amber, like the scan-failure pill - both mean "this needs your
   judgement", as opposed to red, which in this report means a price went the
   wrong way. */
.omnibus-note {
    margin-top: 4px;
    padding: 3px 7px;
    border-radius: 6px;
    font-size: 0.7rem;
    font-weight: 600;
    line-height: 1.3;
    color: var(--warning-color);
    background: rgba(245, 158, 11, 0.12);
    cursor: help;
}
/* Hovering the row is when someone is actually reading it. */
.styled-table tr:hover .sparkline-path { opacity: 1; stroke: var(--text-primary); }

.no-info { color: #6b7280; font-style: italic; }

.buy-btn {
    display: inline-block;
    background-color: rgba(255,255,255,0.05);
    border: 1px solid var(--border-color);
    color: var(--text-primary);
    text-decoration: none;
    padding: 0.5rem 1rem;
    border-radius: 8px;
    font-weight: 600;
    font-size: 0.8rem;
    text-align: center;
    transition: all 0.2s ease;
}

.buy-btn:hover {
    background-color: var(--accent-color);
    border-color: var(--accent-color);
    box-shadow: 0 0 10px rgba(223, 0, 0, 0.4);
    transform: scale(1.03);
}

.info-block {
    background: rgba(245, 158, 11, 0.05);
    border-left: 4px solid var(--warning-color);
    border-radius: 12px;
    padding: 1.2rem;
    margin-bottom: 2.5rem;
    font-size: 0.9rem;
    line-height: 1.5;
}

.info-block h4 {
    font-family: 'Outfit', sans-serif;
    font-weight: 600;
    color: var(--warning-color);
    margin-bottom: 0.4rem;
}

/* Collapsed by default, so the tab opens on the price table instead of
   on five paragraphs of reference text. The whole summary row is the
   hit target, not just the marker. */
.info-block > summary {
    font-family: 'Outfit', sans-serif;
    font-weight: 600;
    color: var(--warning-color);
    cursor: pointer;
    list-style: none;
    display: flex;
    align-items: center;
    gap: 0.6rem;
    user-select: none;
}
/* Safari/older WebKit draw their own triangle through this pseudo. */
.info-block > summary::-webkit-details-marker { display: none; }

/* The +/- affordance. Sized in a monospace box so the glyph swap
   cannot shift the heading text sideways on open/close. */
.info-block > summary::before {
    content: '+';
    font-family: 'JetBrains Mono', monospace;
    font-size: 1rem;
    line-height: 1;
    width: 1.25rem;
    height: 1.25rem;
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 1px solid var(--warning-color);
    border-radius: 6px;
    opacity: 0.75;
    transition: 0.2s;
}
.info-block[open] > summary::before { content: '\2212'; }
.info-block > summary:hover::before { opacity: 1; }

/* Watch lists reuse .info-block wholesale - same amber accent, same +/-
   affordance - so "collapsed reference material" reads as one thing on the
   tab whether it is the notes or a category table. Only the parts a table
   needs are added here. */
.list-block > summary {
    font-size: 1.05rem;
}
.list-block-count {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.8rem;
    font-weight: 500;
    color: var(--text-secondary);
    background: rgba(255, 255, 255, 0.05);
    border-radius: 999px;
    padding: 0.1rem 0.55rem;
}
/* The table carries its own horizontal scroll; the accordion only has to
   stop it from pushing the padded block sideways. */
.list-block > .table-responsive,
.list-block > details {
    margin-top: 1rem;
}
.list-block[open] { padding-bottom: 1.2rem; }

.info-block ul { padding-left: 1.5rem; margin-top: 0.5rem; }
/* Only when expanded: a collapsed block should read as a single
   compact row, not a padded banner with empty space under it. */
.info-block:not([open]) { padding-bottom: 1.2rem; }

.promo-list {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: 1.5rem;
}

.promo-card {
    background: rgba(255,255,255,0.01);
    border: 1px solid var(--border-color);
    border-radius: 16px;
    padding: 1.2rem;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    gap: 1rem;
    transition: all 0.3s ease;
}

.promo-card:hover {
    transform: translateY(-5px);
    border-color: rgba(16, 185, 129, 0.3);
    box-shadow: 0 5px 15px rgba(16, 185, 129, 0.1);
}

.promo-img-wrapper {
    height: 120px;
    background: white;
    border-radius: 10px;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    margin-bottom: 0.5rem;
    padding: 5px;
}

.promo-img { max-height: 100%; max-width: 100%; object-fit: contain; }

/* 3 lines, not 2: MediaMarkt/Coolmod titles carry the full spec string
   ("TV LED 32\" - Xiaomi A 2025 32, HD-ready, Smart TV...") and were
   being cut mid-model-name, which is exactly the part that identifies
   the product.
   The height unit matters: line-height 1.4 is a multiple of this
   element's own 0.85rem font-size, so one line is 1.4em, NOT 1.4rem.
   Three lines is therefore 4.2em (= 3.57rem). Setting it in rem is the
   bug the old 2-line rule had - "height: 2.8rem" was ~2.35 lines, so
   the clamp and the box disagreed. */
.promo-title {
    font-weight: 500;
    font-size: 0.85rem;
    line-height: 1.4;
    height: 4.2em;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
}

.promo-prices { display: flex; align-items: baseline; gap: 0.5rem; }

.promo-price-current {
    font-size: 1.2rem;
    font-weight: 700;
    color: var(--success-color);
}

.promo-price-old {
    font-size: 0.9rem;
    color: var(--text-secondary);
    text-decoration: line-through;
}

.promo-discount {
    background: var(--success-color);
    color: #ffffff;
    font-weight: 700;
    font-size: 0.75rem;
    padding: 2px 6px;
    border-radius: 6px;
}

/* Tabs Styling */
.tab {
    overflow: hidden;
    border-bottom: 1px solid var(--border-color);
    margin-bottom: 20px;
    display: flex;
    gap: 10px;
}

.tab button {
    background-color: transparent;
    float: left;
    border: none;
    outline: none;
    cursor: pointer;
    padding: 14px 24px;
    transition: 0.3s;
    font-size: 1.1rem;
    font-family: 'Outfit', sans-serif;
    font-weight: 600;
    color: var(--text-secondary);
    border-bottom: 3px solid transparent;
}

.tab button:hover {
    color: var(--text-primary);
    background-color: var(--hover-bg);
}

.tab button.active {
    color: var(--accent-color);
    border-bottom: 3px solid var(--accent-color);
}

.tabcontent {
    display: none;
    animation: fadeEffect 0.5s;
}

@keyframes fadeEffect { from {opacity: 0;} to {opacity: 1;} }

.sort-controls {
    display: flex;
    gap: 10px;
    margin-bottom: 20px;
    flex-wrap: wrap;
}

.sort-btn {
    background-color: rgba(255,255,255,0.05);
    border: 1px solid var(--border-color);
    color: var(--text-primary);
    padding: 8px 16px;
    border-radius: 8px;
    cursor: pointer;
    transition: 0.2s;
    font-size: 0.9rem;
}

.sort-btn:hover { background-color: rgba(255,255,255,0.1); }
.sort-btn.active {
    background-color: rgba(223, 0, 0, 0.2);
    border-color: var(--accent-color);
    color: white;
}
/* Secondary (tie-breaker) sort key: same shape as the primary but
   deliberately quieter, so the control bar reads as "sorted by X,
   then by Y" rather than as two rival primaries. */
.sort-btn.active-secondary {
    background-color: rgba(255,255,255,0.08);
    border-color: var(--text-secondary);
    color: var(--text-primary);
    font-style: italic;
}

/* Discount-tab category filter chips. Smaller/rounder than the sort
   buttons so the two control rows stay visually distinct. All four
   tabs share the look; the per-tab class only scopes the JS. */
.carrefour-cat-btn, .promo-cat-btn, .coolmod-cat-btn, .pcc-cat-btn {
    background-color: rgba(255,255,255,0.05);
    border: 1px solid var(--border-color);
    color: var(--text-secondary);
    padding: 6px 14px;
    border-radius: 999px;
    cursor: pointer;
    transition: 0.2s;
    font-size: 0.85rem;
}
.carrefour-cat-btn:hover, .promo-cat-btn:hover, .coolmod-cat-btn:hover,
.pcc-cat-btn:hover { background-color: rgba(255,255,255,0.1); }
/* Live name search, one per discount tab. Sits in the same control
   header as the category chips and the sort buttons because it is a
   third filter over the same card set, not a separate mode: the three
   compose (category AND text, then sorted). The clear "x" is inside
   the field rather than beside it so the control row keeps its width
   whether or not a query is active. */
.discount-search {
    position: relative;
    display: inline-flex;
    align-items: center;
    flex: 1 1 240px;
    max-width: 420px;
}
.discount-search-input {
    width: 100%;
    background-color: rgba(255,255,255,0.05);
    border: 1px solid var(--border-color);
    color: var(--text-primary);
    padding: 8px 34px 8px 14px;
    border-radius: 999px;
    font-size: 0.9rem;
    font-family: inherit;
    transition: 0.2s;
}
.discount-search-input::placeholder { color: var(--text-secondary); }
.discount-search-input:focus {
    outline: none;
    border-color: #4d94ff;
    background-color: rgba(255,255,255,0.08);
}
.discount-search-clear {
    position: absolute;
    right: 8px;
    background: none;
    border: none;
    color: var(--text-secondary);
    font-size: 0.9rem;
    line-height: 1;
    padding: 4px 6px;
    cursor: pointer;
    border-radius: 50%;
}
.discount-search-clear:hover { color: var(--text-primary); background: rgba(255,255,255,0.1); }
.discount-search-count {
    font-size: 0.8rem;
    color: var(--text-secondary);
    line-height: 34px;
    white-space: nowrap;
}
/* Only the number moves when a query narrows the list, so it is the
   only part that lights up. */
.discount-search-count b { color: var(--text-primary); }
.discount-search-count.filtering b { color: #4d94ff; }
.carrefour-cat-btn.active, .promo-cat-btn.active, .coolmod-cat-btn.active,
.pcc-cat-btn.active {
    background-color: rgba(0, 81, 162, 0.35);
    border-color: #4d94ff;
    color: white;
}
/* Cart-discount note: the headline price already has it applied, so
   this explains where the number came from rather than advertising it. */
.cart-discount-note {
    font-size: 0.7rem;
    color: #4d94ff;
    margin-top: 2px;
    line-height: 1.3;
}

/* Quick-add "+" on promo cards, sitting beside the "view" link. */
.promo-card-actions { display: flex; gap: 6px; margin-top: 0.5rem; }
.promo-card-actions .buy-btn { flex-grow: 1; text-align: center; margin-top: 0; }
.quick-add-btn {
    flex: 0 0 auto;
    width: 40px;
    background: rgba(16, 185, 129, 0.12);
    border: 1px solid rgba(16, 185, 129, 0.35);
    color: var(--success-color);
    border-radius: 8px;
    cursor: pointer;
    font-size: 1.15rem;
    font-weight: 700;
    line-height: 1;
    transition: 0.2s;
}
.quick-add-btn:hover:not(:disabled) {
    background: rgba(16, 185, 129, 0.28);
    transform: translateY(-1px);
}
.quick-add-btn:disabled { cursor: default; opacity: 0.85; }
.quick-add-btn.added {
    background: rgba(16, 185, 129, 0.4);
    color: #fff;
}

.carrefour-brand {
    font-size: 0.7rem;
    color: var(--text-secondary);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    margin-bottom: 2px;
}

/* Modal */
.modal {
    display: none;
    position: fixed;
    z-index: 999;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    overflow: auto;
    background-color: rgba(0,0,0,0.7);
    backdrop-filter: blur(5px);
}
.modal-content {
    background-color: var(--card-bg);
    margin: 10% auto;
    padding: 30px;
    border: 1px solid var(--border-color);
    border-radius: 12px;
    width: 90%;
    max-width: 500px;
    box-shadow: 0 10px 30px rgba(0,0,0,0.5);
    animation: modalFadeIn 0.3s;
}
/* --- Price chart dialog ------------------------------------------------
   Opened from the sparkline under a price; drawn by chart.js. */

/* Wider than a form dialog, because the chart was laid out for 660px of plot
   and axis labels - at the 500px a text field needs, the dates under it
   overlap. Scrolls inside itself so a long list of changes cannot push the
   close button past the bottom of the screen. */
.modal-content.is-chart {
    max-width: 760px;
    max-height: 86vh;
    overflow-y: auto;
}

.price-chart {
    display: block;
    width: 100%;
    height: auto;
    margin: 0.4rem 0 1.3rem;
}

.chart-grid { stroke: var(--border-color); stroke-width: 1; opacity: 0.6; }
.chart-axis-label { fill: var(--text-secondary); font-size: 11px; }
.chart-line {
    fill: none;
    stroke: var(--text-primary);
    stroke-width: 2;
    stroke-linejoin: round;
    stroke-linecap: round;
}
.chart-area { fill: var(--accent-color); opacity: 0.08; stroke: none; }
/* The same dashes the sparkline's ruler uses, for the same reason: it marks
   today's price across days that were never charged it. */
.chart-now-line {
    stroke: var(--text-secondary);
    stroke-width: 1.2;
    stroke-dasharray: 4 3;
    opacity: 0.8;
}
.chart-now-dot { fill: var(--text-primary); }
/* Green, the colour "🔻 минимум" already wears in the table: both say
   "cheaper than what you are being asked now". */
.chart-below-dot { fill: var(--success-color); }
/* Invisible, and the reason the whole chart has a hover readout. */
.chart-hit { fill: transparent; }

.chart-facts { display: grid; gap: 0.35rem; }
.chart-fact {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
    font-size: 0.85rem;
    padding-bottom: 0.35rem;
    border-bottom: 1px solid var(--border-color);
}
.chart-fact-label { color: var(--text-secondary); }
.chart-fact-value { font-weight: 600; text-align: right; }
/* A zero still gets printed - see chart.js - but it is not news. */
.chart-fact.is-muted .chart-fact-value { color: var(--text-secondary); font-weight: 500; }

.chart-note {
    margin-top: 0.9rem;
    font-size: 0.78rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

.chart-section-title {
    font-family: 'Outfit', sans-serif;
    font-size: 1rem;
    font-weight: 600;
    margin: 1.4rem 0 0.6rem;
}
.chart-changes { display: grid; gap: 0.32rem; }
.chart-change { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.82rem; }
.chart-change-day { color: var(--text-secondary); }
.chart-change-value.is-down { color: var(--success-color); }
.chart-change-value.is-up { color: var(--accent-color); }

@media (max-width: 768px) {
    /* The dialog is as wide as the phone here. The chart keeps its shape
       through the viewBox rather than being redrawn, so the padding is what
       gives it back the width its axis labels need. */
    .modal-content.is-chart { max-height: 90vh; padding: 22px 14px; }
}

@keyframes modalFadeIn {
    from {transform: translateY(-20px); opacity: 0;}
    to {transform: translateY(0); opacity: 1;}
}
.close-modal {
    color: var(--text-secondary);
    float: right;
    font-size: 28px;
    font-weight: bold;
    cursor: pointer;
}
.close-modal:hover { color: var(--text-primary); }
.form-group { margin-bottom: 15px; }
.form-group label {
    display: block;
    margin-bottom: 5px;
    color: var(--text-secondary);
}
.form-group input {
    width: 100%;
    padding: 10px;
    background: rgba(0,0,0,0.2);
    border: 1px solid var(--border-color);
    color: var(--text-primary);
    border-radius: 6px;
    font-family: inherit;
}
.modal-btn {
    background: var(--accent-color);
    color: white;
    border: none;
    padding: 10px 20px;
    border-radius: 6px;
    cursor: pointer;
    width: 100%;
    font-size: 1.1rem;
    margin-top: 10px;
    font-weight: 600;
}
.modal-btn:hover { background: #ff5252; }
.delete-btn {
    background: none;
    border: none;
    color: var(--text-secondary);
    cursor: pointer;
    font-size: 1.1rem;
    margin-left: 10px;
    transition: 0.2s;
}
.delete-btn:hover { color: #ff3333; }
    
/* Between the mobile breakpoint and the full desktop width the card's
   2rem of padding is width the table needs more than the card does.
   Declared BEFORE the mobile block: both match on a phone, and the
   stacked layout's overrides have to be the ones that win. */
@media (max-width: 1280px) {
    .section-card { padding: 1.25rem; }
    .styled-table th, .styled-table td { padding-left: 0.5rem; padding-right: 0.5rem; }
}

/* Mobile Table Responsiveness */
@media (max-width: 768px) {
    table { border: 0; }
    /* Stacked cards, so neither the desktop floor nor the fixed columns mean
       anything here - and the floor would force a sideways scroller onto a
       layout that has no table left to scroll. */
    .styled-table { min-width: 0; table-layout: auto; }
    table caption { font-size: 1.3em; }
    table thead { border: none; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px; }
    table tr { border-bottom: 2px solid var(--border-color); display: block; margin-bottom: 1.5rem; background: rgba(0,0,0,0.2); border-radius: 12px; padding: 10px; }
    table td { border-bottom: 1px solid rgba(255,255,255,0.05); display: block; font-size: .95em; text-align: right; min-height: 40px; padding: 12px 10px; }
    table td::before {
        content: attr(data-label);
        float: left;
        font-weight: 600;
        text-transform: uppercase;
        color: var(--text-secondary);
        font-size: 0.85em;
        margin-top: 2px;
    }
    table td:last-child { border-bottom: 0; }

    /* Rows are stacked cards here, so nothing scrolls sideways and the
       pinned action column of the desktop layout has to be undone -
       left sticky it would detach from its card. */
    .styled-table td.col-actions, .styled-table td.col-status {
        position: static;
        width: auto;
        white-space: normal;
        background-color: transparent;
        box-shadow: none;
    }
    .styled-table td.col-actions > div { justify-content: flex-end; }

    /* Both of these are single-line flex rows sized by their content,
       so on a phone they reached ~470px and ~575px against a 390px
       viewport - the title bar's "add" button and the last two tabs
       sat outside the screen entirely, and .tab's own overflow:hidden
       meant they could not even be scrolled to. */
    header { flex-wrap: wrap; gap: 0.75rem; }
    .tab { flex-wrap: wrap; overflow: visible; }
    .tab button { flex: 1 1 auto; }

    /* The other half of that: wrapping alone still left the row 537px wide
       against a 390px screen, because "🚫 Минус-слова", "🎯 Интересы" and
       "🙈 Снятые" are three wide buttons that all wanted a line of their own.
       Here they go behind one "⋯" instead - the wrapper becomes a real box so
       the menu has something to be positioned against, and the menu leaves
       the flow so opening it moves nothing below it. */
    .header-more { display: block; position: relative; }
    .more-btn {
        display: inline-flex;
        align-items: center;
        padding: 0.6rem 0.9rem;
        line-height: 1;
        font-size: 1.05rem;
    }
    .header-more-menu {
        display: none;
        position: absolute;
        right: 0;
        top: calc(100% + 0.5rem);
        z-index: 30;
        flex-direction: column;
        gap: 0.5rem;
        padding: 0.6rem;
        border: 1px solid var(--border-color);
        border-radius: 14px;
        /* Opaque, unlike .section-card: this one is drawn over the report
           rather than on the page background, and translucent panels over
           text are unreadable. */
        background: rgba(17, 24, 39, 0.98);
        backdrop-filter: blur(12px);
        box-shadow: 0 14px 34px rgba(0, 0, 0, 0.5);
    }
    .header-more.is-open .header-more-menu { display: flex; }
    /* One column of equal buttons, so the menu reads as a list rather than
       three differently sized pills. `display: none` above also keeps the
       closed menu out of the tab order, which [hidden] on the wrapper would
       not have done for the desktop `display: contents` case. */
    .header-more-menu .timestamp {
        width: 100%;
        text-align: left;
        white-space: nowrap;
    }

    /* Align elements */
    .product-cell { flex-direction: column; align-items: flex-end; text-align: right; }
    .product-cell .product-img-wrapper { margin-right: 0; margin-bottom: 10px; }
    .target-price-container { justify-content: flex-end; }
    .section-card { padding: 1.1rem; }
    body { padding: 1.25rem 0.75rem; }
}

#toast-container {
    position: fixed;
    bottom: 24px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 1000;
    display: flex;
    flex-direction: column;
    gap: 8px;
    align-items: center;
}
.toast {
    background: var(--card-bg);
    border: 1px solid var(--border-color);
    color: var(--text-primary);
    padding: 10px 20px;
    border-radius: 8px;
    box-shadow: 0 4px 16px rgba(0,0,0,0.3);
    font-size: 0.9rem;
    opacity: 0;
    transform: translateY(10px);
    transition: opacity 0.25s ease, transform 0.25s ease;
}
.toast.toast-show { opacity: 1; transform: translateY(0); }
.toast.toast-success { border-color: var(--success-color); }
.toast.toast-error { border-color: #ff3333; }

/* ==========================================================================
   Static client only - no equivalent in the interpolated report
   ========================================================================== */

/* The single status line shown while data.json is in flight, and in its place
   if the fetch fails. A failed load has to say so: an empty report is
   indistinguishable from a scan that found nothing. */
.load-state {
    background: var(--card-bg);
    border: 1px solid var(--border-color);
    border-radius: 16px;
    padding: 1.5rem;
    margin-top: 1.5rem;
    color: var(--text-secondary);
    font-size: 0.95rem;
    line-height: 1.6;
}

.load-state-error {
    border-color: rgba(223, 0, 0, 0.45);
    color: var(--text-primary);
}

/* The scheduled scan is hourly, so a report older than two hours means a run
   was missed. Marked on the date button itself, because that is the element a
   reader looks at to decide whether the prices below are current. */
.timestamp.is-stale {
    border-color: var(--warning-color);
    color: var(--warning-color);
}

/* A scan is running - this page's own, or the schedule's, which the page only
   learns about by polling /api/scan_status. Red rather than amber: amber above
   means "these prices may be old", this means "the button is not available",
   and two different messages must not wear the same colour. */
.timestamp.is-locked {
    border-color: var(--accent-color);
    color: var(--accent-color);
    cursor: not-allowed;
}

.scan-lock {
    display: inline-flex;
    align-items: center;
    margin-right: 0.4rem;
    color: var(--accent-color);
}

/* Without this the display above would win over [hidden]'s display:none and
   the lock would sit on the button permanently. */
.scan-lock[hidden] { display: none; }

/* --------------------------------------------------------------------------
   Table row internals

   The interpolated report wrote all of this as inline style="" on every cell
   of every row - the same declarations repeated ~225 times per report. The
   declarations below are copied from it verbatim, so rendering is unchanged;
   what changes is that they are now stated once.
   -------------------------------------------------------------------------- */

/* Wraps a price so the whole cell is a link to the shop. */
.shop-link {
    text-decoration: none;
    border-bottom: 1px dashed var(--warning-color);
    padding-bottom: 2px;
    color: inherit;
    display: inline-block;
    margin-top: 4px;
}

.text-xs  { font-size: 0.75rem; }
.text-2xs { font-size: 0.7rem; }
.text-block { display: block; }

/* "Agotado" inside a comparison cell: the status pill at cell scale. */
.stock-chip {
    font-size: 0.8rem;
    padding: 2px 6px;
}

/* Why a row is in TOP. Icons in the badge, words in its tooltip. */
.top-change-badge {
    font-size: 0.75rem;
    color: #10b981;
    font-weight: 600;
    margin-top: 4px;
}

/* min-width: 0 so this may shrink below its own content: a flex item refuses
   to by default, which would push the product cell wider than its column. */
.product-cell-text { display: flex; flex-direction: column; min-width: 0; }

/* Wraps, because the cell holds three things and the column is one of the
   fixed-width ones: the number, the pencil that edits it, and - only when the
   target is actually met - a badge saying so. `order` puts that badge last so
   it is the badge that drops to a second line, keeping the number and its
   pencil together on the first. */
.target-price-container { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.target-price-container .price-badge { order: 1; }

.row-actions { display: flex; gap: 8px; align-items: center; }

.action-btn.edit-btn {
    background: transparent;
    border: none;
    cursor: pointer;
    font-size: 0.9rem;
    opacity: 0.6;
    transition: 0.2s;
}
.action-btn.edit-btn:hover { opacity: 1; }

.action-btn.delete-btn {
    background: transparent;
    border: 1px solid rgba(255, 255, 255, 0.1);
    border-radius: 4px;
    cursor: pointer;
    font-size: 1rem;
    padding: 0 8px;
    transition: 0.2s;
}

/* Out-of-stock rows are collapsed under their own summary rather than mixed
   into the table: they are still tracked, but they are not what the reader
   came for. */
.out-of-stock-block {
    margin-top: 1.5rem;
    background: rgba(255, 255, 255, 0.02);
    border-radius: 8px;
    border: 1px solid var(--border-color);
}
.out-of-stock-block > summary {
    padding: 1rem;
    cursor: pointer;
    color: var(--text-secondary);
    font-weight: 500;
}
.out-of-stock-block .table-responsive { border-top: 1px solid var(--border-color); }

/* One colour per comparison column, so a price is attributable to its shop at
   a glance. Same values the interpolated report hard-coded per cell. */
.shop-price          { font-weight: 700; }
.shop-price-amazon    { color: #ff9900; }
.shop-price-pcc       { color: #f59e0b; }
.shop-price-carrefour { color: #0051a2; }
.shop-price-coolmod   { color: #22d3ee; }

/* --------------------------------------------------------------------------
   Discount card internals

   Same treatment as the table rows above: declarations copied verbatim from
   the inline style="" the old renderer repeated on every one of ~950 cards.
   -------------------------------------------------------------------------- */

.promo-card-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-top: auto;
}

.promo-savings {
    font-size: 0.75rem;
    color: var(--success-color);
    margin-top: -10px;
}

.section-title-tight { margin-bottom: 0.4rem; }

.section-subtitle { margin-top: 0; }

.text-sm { font-size: 0.8rem; }

.sort-label { color: var(--text-secondary); line-height: 34px; }

/* The control rows stack: search, then categories, then sort order. */
.sort-controls + .sort-controls { margin-top: 0.75rem; }

/* --------------------------------------------------------------------------
   Modal internals

   The old markup carried these as inline style="" on hand-written dialogs in
   the report template; the dialogs are now built per item in actions.js.
   -------------------------------------------------------------------------- */

.modal-title { margin-top: 0; }

/* The product a per-item dialog is about, under its heading. */
.modal-product {
    margin-top: 0;
    font-size: 0.85rem;
}

/* The explanatory line under a field - what the price will be converted to,
   why a shop is not scraped, and so on. */
.form-hint {
    font-size: 0.78rem;
    margin: 4px 0 0 0;
}

.form-section {
    margin: 15px 0 5px 0;
    color: var(--text-secondary);
}

/* --------------------------------------------------------------------------
   Discount tab freshness row
   --------------------------------------------------------------------------
   The three external discount tabs are the one part of the report nothing
   scans: the 2 h schedule only reshapes whatever their collectors last left
   on disk. So each tab states the real age of its prices and, where a
   collector exists, offers to run it.

   Sits directly under the section title, above the subtitle and the filters,
   because "are these prices current" is the question that decides whether the
   rest of the tab is worth reading. */
.collect-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.7rem;
    margin-bottom: 1rem;
}

.collect-btn {
    background: rgba(255, 255, 255, 0.05);
    border: 1px solid var(--border-color);
    color: var(--text-primary);
    padding: 0.5rem 1rem;
    border-radius: 12px;
    cursor: pointer;
    font-size: 0.85rem;
    font-family: inherit;
    transition: 0.2s;
}

.collect-btn:hover { background: rgba(255, 255, 255, 0.1); }

/* aria-disabled rather than :disabled, matching the header's scan button: a
   disabled button stops firing mouse events in most browsers and takes the
   tooltip explaining why it is locked with them. startCollect refuses on the
   same state instead. */
.collect-btn.is-running {
    border-color: var(--accent-color);
    color: var(--accent-color);
    cursor: not-allowed;
}

.collect-date {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.collect-date b { color: var(--text-primary); font-weight: 600; }

/* Amber, the same "these prices may be old" signal .timestamp.is-stale uses
   in the header - one meaning, one colour. */
.collect-date.is-stale, .collect-date.is-stale b { color: var(--warning-color); }

.collect-note { color: var(--text-secondary); opacity: 0.75; }

.collect-error {
    font-size: 0.85rem;
    color: var(--accent-color);
}

/* --------------------------------------------------------------------------
   Collection in progress
   --------------------------------------------------------------------------
   A collector run is minutes long, so "it started" has to be visible without
   reading anything. The spinner says something is moving; the elapsed clock
   and the collector's own newest line say WHAT is moving, which is the part a
   bare animation cannot tell apart from a wedged process. */
.collect-spinner {
    display: inline-block;
    width: 0.85em;
    height: 0.85em;
    margin-right: 0.5rem;
    vertical-align: -0.1em;
    border: 2px solid rgba(255, 255, 255, 0.25);
    border-top-color: var(--accent-color);
    border-radius: 50%;
    animation: collect-spin 0.8s linear infinite;
}

@keyframes collect-spin { to { transform: rotate(360deg); } }

/* Respect the OS setting: an infinite spin is exactly what this asks about.
   The ring stays, it just stops turning - the elapsed clock next to it still
   ticks, so progress remains visible without motion. */
@media (prefers-reduced-motion: reduce) {
    .collect-spinner { animation: none; }
}

.collect-live { font-size: 0.85rem; color: var(--text-secondary); }
.collect-elapsed { font-variant-numeric: tabular-nums; color: var(--text-primary); }
.collect-progress { opacity: 0.85; }

/* --------------------------------------------------------------------------
   Interests
   --------------------------------------------------------------------------
   Shares the header-badge shape with minus words and exclusions, but says a
   different kind of thing: those two count what is HIDDEN, and a number there
   is the whole point. Here there is nothing to count - a profile is one
   sentence - so the badge is a tick that answers "did I ever write this",
   and stays away entirely while the answer is no. An empty profile is a
   normal starting state, not a warning. */
.interests-count {
    display: inline-block;
    margin-left: 0.45rem;
    padding: 0.05rem 0.4rem;
    border-radius: 999px;
    background: var(--accent-color);
    color: white;
    font-size: 0.75rem;
    font-weight: 700;
}

.interests-count[hidden] { display: none; }

/* The read-only half of the dialog: what the catalogue already says. Muted
   and capped in height, because it is context for the box below, not the
   thing being edited - a reader who cannot tell them apart writes the tracked
   list out again by hand. */
.interests-tracked {
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
    margin: 0.5rem 0 0.2rem;
    max-height: 140px;
    overflow-y: auto;
}

.interests-tracked-item {
    padding: 0.2rem 0.55rem;
    border: 1px solid var(--border-color);
    border-radius: 999px;
    background: rgba(255, 255, 255, 0.03);
    color: var(--text-secondary);
    font-size: 0.8rem;
}

.interests-text {
    width: 100%;
    min-height: 110px;
    padding: 0.6rem;
    border: 1px solid var(--border-color);
    border-radius: 6px;
    background: rgba(255, 255, 255, 0.05);
    color: inherit;
    font: inherit;
    font-size: 0.9rem;
    line-height: 1.45;
    resize: vertical;
}

.interests-meta {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
    margin-top: 0.35rem;
    font-size: 0.78rem;
    color: var(--text-secondary);
}

/* --------------------------------------------------------------------------
   Minus words
   -------------------------------------------------------------------------- */

/* Count badge on the header button, so a filter that is hiding things is
   visible from the page the filtering happens on. A silent filter is the one
   failure this feature must not have. */
.stopwords-count, .exclusions-count {
    display: inline-block;
    margin-left: 0.45rem;
    padding: 0.05rem 0.4rem;
    border-radius: 999px;
    background: var(--accent-color);
    color: white;
    font-size: 0.75rem;
    font-weight: 700;
}

.stopwords-count[hidden], .exclusions-count[hidden] { display: none; }

/* "скрыто: N" next to a section heading, in the warning colour rather than
   the error one: nothing is broken, the reader asked for this. */
.stopword-hidden {
    margin-left: 0.6rem;
    font-size: 0.8rem;
    font-weight: 400;
    color: var(--warning-color);
    cursor: help;
}

/* Field + a square "+", not two comparable buttons: .modal-btn is width:100%
   by design (it is a dialog's submit), so reusing it here left the input a
   sliver next to a full-width "Добавить". align-items:stretch keeps the button
   exactly as tall as the field whatever the field's padding is. */
.stopword-add {
    display: flex;
    align-items: stretch;
    gap: 0.5rem;
}

.stopword-add input {
    flex: 1;
    min-width: 0;
}

.stopword-add-btn {
    flex: 0 0 auto;
    width: 2.75rem;
    background: var(--accent-color);
    color: white;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    font-size: 1.5rem;
    font-weight: 700;
    line-height: 1;
    padding: 0;
}

.stopword-add-btn:hover { background: #ff5252; }

.stopword-list {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin: 0.8rem 0 0.4rem;
    max-height: 220px;
    overflow-y: auto;
}

.stopword-chip {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.3rem 0.5rem 0.3rem 0.75rem;
    border: 1px solid var(--border-color);
    border-radius: 999px;
    background: rgba(255, 255, 255, 0.05);
    font-size: 0.85rem;
}

.stopword-remove {
    background: none;
    border: none;
    color: var(--text-secondary);
    cursor: pointer;
    font-size: 1.1rem;
    line-height: 1;
    padding: 0 0.15rem;
}

.stopword-remove:hover { color: var(--accent-color); }

.modal-btn-secondary {
    background: rgba(255, 255, 255, 0.08);
    color: var(--text-primary);
}

/* --------------------------------------------------------------------------
   Exclusions
   -------------------------------------------------------------------------- */

/* Rows, not chips. A minus word is one short token and packs into a chip
   cloud; an exclusion carries a product name, a date and where it came from,
   and three of those side by side stop being readable at the second one. */
.exclusion-list {
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    margin: 0.8rem 0 0.4rem;
    max-height: 320px;
    overflow-y: auto;
}

.exclusion-row {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.5rem 0.65rem;
    border: 1px solid var(--border-color);
    border-radius: 8px;
    background: rgba(255, 255, 255, 0.04);
}

.exclusion-info {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    /* min-width:0 so a long product name wraps inside the row instead of
       pushing the button off the dialog's right edge. */
    flex: 1;
    min-width: 0;
}

.exclusion-name {
    font-size: 0.9rem;
    font-weight: 500;
    overflow-wrap: anywhere;
}

.exclusion-meta {
    font-size: 0.75rem;
    color: var(--text-secondary);
}

.exclusion-restore {
    flex: 0 0 auto;
    padding: 0.35rem 0.7rem;
    border: 1px solid var(--border-color);
    border-radius: 6px;
    background: rgba(255, 255, 255, 0.08);
    color: var(--text-primary);
    font-size: 0.8rem;
    cursor: pointer;
}

.exclusion-restore:hover { background: rgba(255, 255, 255, 0.16); }
.exclusion-restore:disabled { opacity: 0.5; cursor: default; }

/* The accent colour, not the muted .no-info grey: "the list could not be
   read" is a failure, and the scan is still hiding rows this dialog cannot
   name. */
.exclusion-error {
    font-size: 0.85rem;
    color: var(--accent-color);
}

/* --------------------------------------------------------------------------
 * Back to top
 *
 * The tracked table is ~200 rows and the discount tabs are grids of a few
 * hundred cards, so "get back to the tab bar" was a long drag every time.
 *
 * Fixed, outside .container, and hidden until the page has actually been
 * scrolled: a button that is always there is furniture on a report that fits
 * on one screen. Under the modal (999) and the toasts (1000) on purpose - it
 * must never sit on top of something asking a question.
 * ------------------------------------------------------------------------ */

.to-top {
    position: fixed;
    right: 1.75rem;
    bottom: 1.75rem;
    z-index: 800;
    width: 44px;
    height: 44px;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 1px solid var(--border-color);
    border-radius: 50%;
    /* Opaque, not the translucent card background: this floats over rows. */
    background-color: #131a29;
    color: var(--text-secondary);
    font-size: 1rem;
    line-height: 1;
    cursor: pointer;
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
    /* Fades rather than appearing, so it does not read as something the page
       just did. [hidden] keeps it out of the tab order while invisible. */
    opacity: 0;
    transform: translateY(6px);
    transition: opacity 0.18s ease, transform 0.18s ease,
                background-color 0.18s ease, color 0.18s ease;
}

.to-top.is-visible { opacity: 1; transform: translateY(0); }

.to-top:hover {
    background-color: #1b2436;
    color: var(--text-primary);
    border-color: rgba(255, 255, 255, 0.18);
}

.to-top:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
    .to-top { transition: none; transform: none; }
    .to-top.is-visible { transform: none; }
}

/* Phone: smaller, and clear of the right edge the stacked cards run to. */
@media (max-width: 768px) {
    .to-top { right: 1rem; bottom: 1rem; width: 40px; height: 40px; }
}

/* The comparison column of the shop the item's own price comes from. Muted
   like .no-info, because it is the absence of a second number rather than a
   value - see isOwnShopPage in table.js for when that happens. */
.shop-is-source {
    color: var(--text-secondary);
    font-size: 0.78rem;
    font-style: italic;
    border-bottom: 1px dotted var(--border-color);
    cursor: help;
}
