/*
    ObsidianBinder design system.

    Hand-rolled rather than a themed component library: the chrome's job is to recede so that card
    art carries the page. A library's default look would fight that, and every Bootstrap-shaped
    element is a tell that nobody has designed this yet.

    Dark by default and dark only, for now. Collectors photograph and browse cards against dark
    backgrounds because it's what makes holo foil read; a light theme is a real feature, not a
    media query, so it waits until someone asks for it.
*/

/*
    Inter (SIL OFL 1.1 — licence beside the file), self-hosted because the app's typeface shouldn't
    depend on a CDN. One variable file carries every weight; the system stack below it is the
    fallback while it loads and wherever woff2 doesn't reach. Chosen over the system stack because
    "system-ui" means Segoe UI on the machines most collectors run, and Segoe is the single
    strongest "unstyled Windows app" signal a page can send.
*/
@font-face {
    font-family: "Inter";
    font-style: normal;
    font-weight: 100 900;
    font-display: swap;
    src: url("fonts/InterVariable.woff2") format("woff2");
}

:root {
    /*
        Obsidian: near-black with a cold *blue* cast. Sampled from the reference mockups rather than
        picked — every dark neutral in them measures blue > green > red, consistently.

        The ramp between these is deliberately tight: panels sit only a few points above the ground,
        and separation comes from --ob-border rather than from lightness steps. That is what makes a
        dense screen read as calm instead of as a stack of boxes competing for depth.
    */
    --ob-bg: #050a12;

    /*
        App chrome — the top bar. Deliberately *darker* than the page ground rather than lighter,
        which inverts the usual convention: the frame recedes behind the content instead of sitting
        on top of it. Matches the reference mockups, and it means the bar reads as the edge of the
        window rather than as another panel.
    */
    --ob-chrome: #03060d;

    --ob-surface: #0d111a;
    --ob-surface-raised: #131824;
    --ob-surface-hover: #1a2030;
    --ob-border: #1e2534;
    --ob-border-strong: #2c3548;

    --ob-text: #e7e9f0;
    --ob-text-muted: #949bad;
    --ob-text-faint: #666d7e;

    /*
        One accent, used for interaction only — card art supplies all the other colour.

        Split into a fill tone and a text tone, because one value cannot do both jobs on a ground
        this dark. #773fd1 against #050a12 measures 3.23:1, below the 4.5:1 WCAG AA needs for text,
        so link-coloured text in it would be legible-ish rather than legible. As a *fill* it is
        fine: white on it measures 6.15:1.

        So: --ob-accent fills (buttons, active states, the mark), --ob-accent-text writes
        (links, accent-coloured labels) at 5.35:1. Don't swap them.
    */
    --ob-accent: #773fd1;
    --ob-accent-hover: #8f5ce4;
    --ob-accent-dim: #773fd126;

    --ob-accent-text: #9a6bea;
    --ob-accent-text-hover: #b39bf3;

    /* Text placed *on* an accent fill — the inverse of --ob-accent-text, hence the name. */
    --ob-on-accent: #ffffff;

    --ob-positive: #55bb8a;
    --ob-warning: #deb52a;
    --ob-danger: #d43946;

    /*
        Condition as a severity ramp, not six arbitrary colours: green for the conditions a buyer
        wants, amber for the ones they'll accept, red for the ones they need warning about. Lets a
        grid communicate quality without anyone reading a word.

        Each pairs a saturated text colour with a dim fill of the same hue, so the badge reads at
        tile size against a near-black ground.
    */
    --ob-cond-mint: #4ade80;
    --ob-cond-mint-bg: #4ade8018;
    --ob-cond-good: #7cc96a;
    --ob-cond-good-bg: #7cc96a18;
    --ob-cond-fair: #d9a441;
    --ob-cond-fair-bg: #d9a44118;
    --ob-cond-poor: #d97a41;
    --ob-cond-poor-bg: #d97a4118;
    --ob-cond-bad: #d9534f;
    --ob-cond-bad-bg: #d9534f18;

    /* Type scale, 1.25 ratio off a 16px base, rounded to something that renders crisply. */
    --ob-text-xs: 0.75rem;
    --ob-text-sm: 0.8125rem;
    --ob-text-base: 0.9375rem;
    --ob-text-lg: 1.125rem;
    --ob-text-xl: 1.5rem;
    --ob-text-2xl: 2rem;

    --ob-space-1: 0.25rem;
    --ob-space-2: 0.5rem;
    --ob-space-3: 0.75rem;
    --ob-space-4: 1rem;
    --ob-space-5: 1.5rem;
    --ob-space-6: 2rem;
    --ob-space-7: 3rem;

    --ob-radius-sm: 4px;
    --ob-radius: 8px;
    --ob-radius-lg: 12px;

    --ob-shadow: 0 1px 2px rgb(0 0 0 / 40%), 0 4px 12px rgb(0 0 0 / 25%);
    --ob-shadow-lifted: 0 4px 8px rgb(0 0 0 / 45%), 0 16px 32px rgb(0 0 0 / 35%);

    /* Trading cards are 63x88mm. Every card image and placeholder uses this, so a grid of
       half-loaded images doesn't reflow as each one arrives. */
    --ob-card-aspect: 63 / 88;

    color-scheme: dark;

    /* Native controls (checkboxes, radios, ranges) take the accent everywhere, instead of each
       scoped stylesheet remembering to say so — an unthemed browser-blue checkbox is as loud a
       tell as a Bootstrap button. */
    accent-color: var(--ob-accent);
}

*,
*::before,
*::after {
    box-sizing: border-box;
}

html {
    -webkit-text-size-adjust: 100%;
}

body {
    margin: 0;
    background-color: var(--ob-bg);
    color: var(--ob-text);
    font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    font-size: var(--ob-text-base);
    line-height: 1.55;
    -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4 {
    margin: 0;
    font-weight: 600;
    line-height: 1.2;
    letter-spacing: -0.015em;
}

h1 { font-size: var(--ob-text-2xl); }
h2 { font-size: var(--ob-text-xl); }
h3 { font-size: var(--ob-text-lg); }

p {
    margin: 0 0 var(--ob-space-4);
}

a {
    color: var(--ob-accent-text);
    text-decoration: none;
}

a:hover {
    color: var(--ob-accent-text-hover);
    text-decoration: underline;
}

code {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.9em;
    color: var(--ob-text-muted);
}

:focus-visible {
    outline: 2px solid var(--ob-accent);
    outline-offset: 2px;
    border-radius: var(--ob-radius-sm);
}

/* Routes.razor's FocusOnNavigate moves focus to the page heading so screen readers announce the
   new page; the heading is not a control, so it gets no ring. */
h1:focus { outline: none; }

/* ---------------------------------------------------------------- page furniture */

.ob-page-header {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: var(--ob-space-4);
    margin-bottom: var(--ob-space-5);
}

/* Baseline-aligned rather than centred: the pill reads as part of the title's line, not as a
   badge stuck to it. */
.ob-page-header__title {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--ob-space-3);
}

.ob-page-header__count {
    padding: 2px var(--ob-space-3);
    /* A white wash rather than --ob-border: the pill sits on the raised surface, and the border
       token is tuned to separate panels from the page ground, where it disappears. */
    border: 1px solid #ffffff1a;
    border-radius: var(--ob-radius-sm);
    background-color: var(--ob-surface-raised);
    color: var(--ob-text-muted);
    font-size: var(--ob-text-sm);
    font-weight: 500;
    white-space: nowrap;
}

.ob-page-header__meta {
    margin: var(--ob-space-2) 0 0;
    color: var(--ob-text-muted);
    font-size: var(--ob-text-sm);
}

.ob-backlink {
    display: inline-block;
    margin-bottom: var(--ob-space-3);
    color: var(--ob-text-muted);
    font-size: var(--ob-text-sm);
}

.ob-backlink:hover {
    color: var(--ob-text);
    text-decoration: none;
}

.ob-muted { color: var(--ob-text-muted); }
.ob-faint { color: var(--ob-text-faint); }

/* Dot separator for inline metadata, so markup doesn't carry literal "·" characters. */
.ob-dotlist > * + *::before {
    content: "·";
    margin: 0 var(--ob-space-2);
    color: var(--ob-text-faint);
}

/* ------------------------------------------------------------------- icons */

/*
   Phosphor Icons, MIT — the sprite in wwwroot/icons.svg, rendered by Components/Shared/Icon.razor.

   Sized in em rather than px, so an icon in a small button shrinks with the button's font-size
   instead of needing a modifier for every control that has a compact variant. currentColor means
   the same applies to colour: an icon inside a muted label is muted, and one inside a hovered
   button brightens with the text beside it, without a rule here knowing either of those exist.
 */
.ob-icon {
    width: 1.15em;
    height: 1.15em;
    flex: none;
    fill: currentColor;

    /* Optical centring against a cap-height line of text. Icons are drawn on a full 256 box, so
       baseline-aligning them sits them a touch high next to the label they belong to. */
    vertical-align: -0.18em;
}

/* Where an icon is the whole control (the pill's remove, the search box's glyph) rather than a
   mark beside a word. Big enough to hit, dim enough not to compete with the content. */
.ob-icon--lead {
    color: var(--ob-text-faint);
}

/* ---------------------------------------------------------------- surfaces */

.ob-panel {
    background-color: var(--ob-surface);
    border: 1px solid var(--ob-border);
    border-radius: var(--ob-radius-lg);
    padding: var(--ob-space-5);
}

.ob-empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--ob-space-2);
    padding: var(--ob-space-7) var(--ob-space-5);
    background-color: var(--ob-surface);
    border: 1px dashed var(--ob-border);
    border-radius: var(--ob-radius-lg);
    text-align: center;
}

.ob-empty__title {
    font-size: var(--ob-text-lg);
    font-weight: 600;
}

.ob-empty__body {
    max-width: 42ch;
    margin: 0;
    color: var(--ob-text-muted);
    font-size: var(--ob-text-sm);
}

/* ---------------------------------------------------------------- notices */

.ob-notice {
    display: flex;
    gap: var(--ob-space-3);
    padding: var(--ob-space-3) var(--ob-space-4);
    margin-bottom: var(--ob-space-4);
    border: 1px solid var(--ob-border);
    border-left-width: 3px;
    border-radius: var(--ob-radius);
    background-color: var(--ob-surface);
    font-size: var(--ob-text-sm);
}

.ob-notice--success { border-left-color: var(--ob-positive); }
.ob-notice--warning { border-left-color: var(--ob-warning); }
.ob-notice--info    { border-left-color: var(--ob-accent); }

/* ---------------------------------------------------------------- controls */

.ob-button {
    display: inline-flex;
    align-items: center;
    gap: var(--ob-space-2);
    padding: var(--ob-space-2) var(--ob-space-4);
    border: 1px solid transparent;
    border-radius: var(--ob-radius);
    background-color: var(--ob-surface-raised);
    color: var(--ob-text);
    font: inherit;
    font-size: var(--ob-text-sm);
    font-weight: 500;
    line-height: 1.4;
    cursor: pointer;
    transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}

.ob-button:hover {
    background-color: var(--ob-surface-hover);
    text-decoration: none;
    color: var(--ob-text);
}

.ob-button:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

/* The gradient the mockups run on their primary buttons — light catching the top-left of the
   fill. Subtle on purpose: the two stops are the accent and its hover tone, so the button still
   reads as one violet, just lit. Hover lifts both stops rather than swapping the fill. */
.ob-button--primary {
    background-color: var(--ob-accent);
    background-image: linear-gradient(135deg, var(--ob-accent-hover), var(--ob-accent) 60%);
    color: var(--ob-on-accent);
}

.ob-button--primary:hover {
    background-color: var(--ob-accent-hover);
    background-image: linear-gradient(
        135deg,
        color-mix(in srgb, var(--ob-accent-hover) 82%, white),
        var(--ob-accent-hover) 60%);
    color: var(--ob-on-accent);
}

.ob-button--outline {
    background-color: transparent;
    border-color: var(--ob-border);
    color: var(--ob-text-muted);
}

.ob-button--outline:hover {
    background-color: var(--ob-surface-raised);
    border-color: var(--ob-text-faint);
    color: var(--ob-text);
}

.ob-button--quiet {
    background-color: transparent;
    color: var(--ob-text-muted);
    padding-inline: var(--ob-space-2);
}

.ob-button--quiet:hover {
    background-color: transparent;
    color: var(--ob-text);
    text-decoration: underline;
}

.ob-button--danger:hover {
    background-color: transparent;
    border-color: var(--ob-danger);
    color: var(--ob-danger);
}

.ob-button--sm {
    padding: var(--ob-space-1) var(--ob-space-3);
    font-size: var(--ob-text-xs);
}

.ob-field {
    display: flex;
    flex-direction: column;
    gap: var(--ob-space-1);
}

.ob-label {
    color: var(--ob-text-muted);
    font-size: var(--ob-text-xs);
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

.ob-input,
.ob-select {
    width: 100%;
    padding: var(--ob-space-2) var(--ob-space-3);
    border: 1px solid var(--ob-border);
    border-radius: var(--ob-radius);
    background-color: var(--ob-bg);
    color: var(--ob-text);
    font: inherit;
    font-size: var(--ob-text-sm);
}

.ob-input:focus,
.ob-select:focus {
    outline: none;
    border-color: var(--ob-accent);
    box-shadow: 0 0 0 3px var(--ob-accent-dim);
}

.ob-input::placeholder {
    color: var(--ob-text-faint);
}

.ob-select {
    appearance: none;
    padding-right: var(--ob-space-6);
    /* Inline chevron: an external icon font would be another dependency for one glyph. */
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath fill='%23949bad' d='M2.5 4.5 6 8l3.5-3.5z'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right var(--ob-space-3) center;
    background-size: 12px;
}

.ob-input--compact { width: 4.5rem; }

/*
    Floating-label field (FloatingField): the label sits inside the box and moves to its top edge
    once the field has focus or a value. CSS only, so it works on static SSR pages — the input
    carries placeholder=" " and :placeholder-shown tells empty from filled.
*/
.ob-float { position: relative; }

.ob-float .ob-input {
    height: 3.5rem;
    padding: 1.375rem var(--ob-space-3) var(--ob-space-2);
    font-size: var(--ob-text-base);
}

.ob-float .ob-input::placeholder { color: transparent; }

.ob-float__label {
    position: absolute;
    top: 50%;
    left: calc(var(--ob-space-3) + 1px);
    transform: translateY(-50%);
    color: var(--ob-text-muted);
    font-size: var(--ob-text-base);
    pointer-events: none;
    transition: top 120ms ease, font-size 120ms ease, color 120ms ease;
}

.ob-float:focus-within .ob-float__label,
.ob-float:has(.ob-input:not(:placeholder-shown)) .ob-float__label {
    top: 0.875rem;
    font-size: var(--ob-text-xs);
}

/* Before the visitor touches the page, the browser shows autofilled values without exposing them,
   so :placeholder-shown still reads "empty" — the autofill state has to float the label too.
   Separate rules: an unknown pseudo-class in a selector list drops the whole rule. */
.ob-float:has(.ob-input:autofill) .ob-float__label {
    top: 0.875rem;
    font-size: var(--ob-text-xs);
}

.ob-float:has(.ob-input:-webkit-autofill) .ob-float__label {
    top: 0.875rem;
    font-size: var(--ob-text-xs);
}

.ob-float:focus-within .ob-float__label { color: var(--ob-accent-text); }

/* Chrome paints autofilled fields its own colour and cannot be told otherwise; an inset shadow
   covers it, and ::first-line sets the font of the not-yet-exposed preview text. */
.ob-input:-webkit-autofill {
    -webkit-text-fill-color: var(--ob-text);
    box-shadow: inset 0 0 0 100px var(--ob-bg);
    caret-color: var(--ob-text);
}

.ob-input:-webkit-autofill:focus {
    box-shadow: inset 0 0 0 100px var(--ob-bg), 0 0 0 3px var(--ob-accent-dim);
}

.ob-input:-webkit-autofill::first-line {
    font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    font-size: var(--ob-text-base);
}

.ob-float .ob-input[readonly] {
    background-color: var(--ob-surface-raised);
    color: var(--ob-text-muted);
}

.ob-float .ob-input.invalid { border-color: var(--ob-warning); }

.ob-float:has(.ob-float__reveal:not([hidden])) .ob-input { padding-right: 3rem; }

.ob-float__reveal {
    position: absolute;
    top: 50%;
    right: var(--ob-space-2);
    transform: translateY(-50%);
    display: inline-flex;
    padding: var(--ob-space-2);
    border: 0;
    border-radius: var(--ob-radius-sm);
    background: none;
    color: var(--ob-text-muted);
    font-size: var(--ob-text-lg);
    cursor: pointer;
}

.ob-float__reveal:hover { color: var(--ob-text); }
.ob-float__reveal[hidden] { display: none; }
.ob-float__reveal[aria-pressed="true"] .ob-float__reveal-show,
.ob-float__reveal[aria-pressed="false"] .ob-float__reveal-hide { display: none; }

@media (prefers-reduced-motion: reduce) {
    .ob-float__label { transition: none; }
}

.ob-field-hint,
.ob-field-error {
    display: block;
    margin-top: var(--ob-space-1);
    font-size: var(--ob-text-xs);
}

.ob-field-hint { color: var(--ob-text-faint); }

/* The error takes the hint's place rather than repeating it underneath. */
.ob-float-field:has(.ob-field-error) .ob-field-hint { display: none; }
.ob-field-error { color: var(--ob-warning); }

/*
    Segmented control: one setting's mutually exclusive options, joined in one bordered track rather
    than spaced apart as independent buttons. Options are icon-only squares of a fixed size, so a
    control's width states how many options it has and two controls side by side line up whatever
    they contain. The selected option takes the accent fill — the same "this is on" signal the rest
    of the app uses, rather than a raised-surface tint that reads as hover.

    The accent fill is a single thumb that slides between options, not a background on the selected
    button — a fill that cross-fades in place says "this one lit up", while one that travels says
    "the setting moved", which is what actually happened. It costs the host one custom property:
    set --ob-segmented-index to the selected option's position, and keep the options in DOM order.
*/
.ob-segmented {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: var(--ob-segmented-gap);
    padding: 3px;
    border: 1px solid var(--ob-border);
    border-radius: var(--ob-radius);
    background-color: var(--ob-bg);

    --ob-segmented-size: 1.75rem;
    --ob-segmented-gap: 2px;
    --ob-segmented-index: 0;
}

/*
    Transform rather than `left`, so the slide is composited and never reflows the row it sits in.
    One option's stride is its own width plus the gap between them.
*/
.ob-segmented::before {
    content: "";
    position: absolute;
    top: 3px;
    left: 3px;
    width: var(--ob-segmented-size);
    height: var(--ob-segmented-size);
    border-radius: var(--ob-radius-sm);
    background-color: var(--ob-accent);
    transform: translateX(
        calc(var(--ob-segmented-index) * (var(--ob-segmented-size) + var(--ob-segmented-gap))));
    transition: transform 180ms cubic-bezier(0.32, 0.72, 0, 1);
}

.ob-segmented__option {
    /* Above the thumb, so the icon reads against it rather than under it. */
    position: relative;
    z-index: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--ob-segmented-size);
    height: var(--ob-segmented-size);
    padding: 0;
    border: none;
    border-radius: var(--ob-radius-sm);
    background: none;
    color: var(--ob-text-muted);
    font: inherit;
    cursor: pointer;
    transition: background-color 120ms ease, color 120ms ease;
}

.ob-segmented__option:hover {
    background-color: var(--ob-surface-raised);
    color: var(--ob-text);
}

/*
    The selected option is transparent — the thumb behind it supplies the fill. Its colour changes
    faster than the thumb travels, so the icon has taken its new colour by the time the thumb
    arrives rather than trailing it.
*/
.ob-segmented__option--on,
.ob-segmented__option--on:hover {
    background-color: transparent;
    color: var(--ob-on-accent);
}

@media (prefers-reduced-motion: reduce) {
    .ob-segmented::before { transition: none; }
}

/* ---------------------------------------------------------------- tables */

/*
    Kept for genuinely tabular data (storage locations). Card lists use the grid below instead —
    a table of card names is the thing that makes an inventory tool feel like a spreadsheet.
*/
/*
    Tables scroll inside this rather than widening the page. Seven columns cannot fit a phone at any
    font size, and the failure mode without it is the whole layout scrolling sideways — which breaks
    the nav and the header too, not just the table.
*/
.ob-table-scroll {
    overflow-x: auto;
    margin-bottom: var(--ob-space-4);
}

/* Just wide enough for the box. A selection column that takes a card-name's width pushes the
   columns that carry meaning off the right-hand edge. */
.ob-table__select {
    width: 1%;
    padding-right: 0;
    white-space: nowrap;
}

.ob-table__row--selected > td {
    background-color: color-mix(in srgb, var(--ob-accent) 12%, transparent);
}

.ob-table {
    width: 100%;
    min-width: 40rem;
    border-collapse: collapse;
    font-size: var(--ob-text-sm);
}

.ob-table th {
    padding: var(--ob-space-2) var(--ob-space-3);
    border-bottom: 1px solid var(--ob-border);
    color: var(--ob-text-faint);
    font-size: var(--ob-text-xs);
    font-weight: 600;
    text-align: left;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    white-space: nowrap;
}

.ob-table td {
    padding: var(--ob-space-3);
    border-bottom: 1px solid var(--ob-border);
    vertical-align: middle;
}

.ob-table tbody tr:hover {
    background-color: var(--ob-surface);
}

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

.ob-table__numeric {
    text-align: right;
    font-variant-numeric: tabular-nums;
}

.ob-table__actions {
    text-align: right;
    white-space: nowrap;
}

/* The line under a list of copies. Stated at all because it is the query shape the model forces:
   entries never merge, so a total is always a sum across them rather than a number in a row. */
.ob-copies__total {
    font-size: var(--ob-text-sm);
}

/* ---------------------------------------------------------------- card grid */

/*
    Fixed columns rather than minmax(150px, 1fr), and this is the difference between a card being a
    fixed thing and a stretchy one. With 1fr the tiles absorbed whatever width was going, so opening
    the filter rail took a column away and resized every card on screen as a side effect — a strange
    thing for a filter to do, and it made comparing two cards depend on what else was open.

    The cost of fixing the width is a ragged right edge where the columns do not divide evenly, which
    is what justify-content solves: the row is centred, so the leftover is split between both sides
    instead of pooling on the right.

    --ob-card-w is set per grid (see CardSize); the fallback keeps every other card grid on the site
    working without one.
*/
.ob-card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, var(--ob-card-w, 10rem));
    justify-content: center;
    gap: var(--ob-space-4);
    margin: 0;
    padding: 0;
    list-style: none;
}

/*
    Each tile is a panel rather than art floating on the page: the text under a card belongs to that
    card, and with nothing enclosing them a grid reads as rows of captions that happen to sit near
    rows of pictures. The panel is also what gives a tile with no artwork yet something to be.

    The lift on hover moved here from the artwork. Inside a panel, art that rises on its own looks
    like it is coming loose from the tile it belongs to.
*/
.ob-card {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: var(--ob-space-2);
    padding: var(--ob-space-2);
    border: 1px solid var(--ob-border);
    border-radius: var(--ob-radius-lg);
    background-color: var(--ob-surface);
    transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease;
}

.ob-card:hover {
    transform: translateY(-3px);
    border-color: var(--ob-border-strong);
    box-shadow: var(--ob-shadow-lifted);
}

/*
    The check-circle over the artwork's top-left corner — the selection channel's shape, and the
    load-bearing half of the encoding: the tint underneath is mostly hidden by artwork, so the
    badge is what makes a selection countable across a grid of forty. Still a real checkbox
    (native click target, Space, checked state announced), drawn as a circle via appearance: none.
    Its plate is opaque dark so the mark survives whatever artwork sits beneath it.
*/
/* Above the body button's overlay (1), so the circle takes its own clicks — the one hole
   deliberately punched in the tile-wide open target. */
.ob-card__select {
    position: absolute;
    z-index: 2;
    top: var(--ob-space-3);
    left: var(--ob-space-3);
}

.ob-card__box {
    appearance: none;
    display: block;
    /* 24px, the WCAG 2.5.8 minimum target — the glyph could be smaller but the target may not. */
    width: 1.5rem;
    height: 1.5rem;
    margin: 0;
    border: 2px solid color-mix(in srgb, var(--ob-text) 85%, transparent);
    border-radius: 999px;
    background-color: color-mix(in srgb, var(--ob-bg) 80%, transparent);
    cursor: pointer;
}

.ob-card__box:checked {
    border-color: var(--ob-accent);
    background-color: var(--ob-accent);
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5 6.5 11.5 12.5 4.5' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: center;
    background-size: 0.75rem;
}

/*
    Hidden at rest on pointer-precise devices, revealed on hover or focus — and pinned visible on
    every tile the moment any selection exists, which is what makes the quiet resting grid safe:
    once you are picking, every tile says it can be picked. Touch never hides it; there is no
    hover to reveal with.

    clip-path rather than opacity or display — Chrome drops those from the accessibility tree,
    which would take the announced checkbox with them.
*/
/*
    Always present, never hidden: a visibility gradient rather than a mode. It is faint at rest so
    forty of them do not shout over the artwork, and full strength the moment it is hovered,
    focused, checked, or any selection exists — so it is always in the same place, always hittable,
    and never changes what a click means. Hover-revealing it would strand touch and keyboard users,
    which is the objection that retired the hover-only Details bar too.
*/
@media (hover: hover) and (pointer: fine) {
    .ob-card__box { opacity: 0.35; transition: opacity 120ms ease; }

    .ob-card:hover .ob-card__box,
    .ob-card:focus-within .ob-card__box,
    .ob-card__box:checked,
    .ob-card-grid--picking .ob-card__box {
        opacity: 1;
    }
}

/* Both controls put their ring on the tile: the button's own box is just the name, and the
   circle is small, so a ring on either would mark far less than the thing being operated. */
.ob-card:has(.ob-card__box:focus-visible),
.ob-card:has(.ob-card__open:focus-visible) {
    outline: 2px solid var(--ob-accent);
    outline-offset: 2px;
}
/*
    The slot the peek control slides out of. It reproduces the artwork's box exactly — same width,
    same aspect ratio — so the button lands on the bottom edge of the art and its slide is clipped
    by the same rectangle, without the button being a descendant of the frame or of the label.

    pointer-events: none is what keeps the rest of the tile selectable: the slot covers the whole
    artwork, so without it the card would be unclickable. The button takes its own clicks back.
*/
/*
    The whole tile opens the rail. The button wraps only the name — a short accessible name, and
    the heading-ish text stays text — and this stretches its hit area over the tile. Nothing is
    nested inside anything else, so the click has no ancestor handler to fight.

    It covers the meta lines too, which costs text selection on the tile. Deliberate: the point is
    that clicking anywhere opens the card, and two-word meta lines are not worth reading around.
*/
/* Carries .ob-card__name too, so the typography and truncation are the link's — this only
   un-buttons the button and stretches it. */
.ob-card__open {
    display: block;
    width: 100%;
    padding: 0;
    border: none;
    background: none;
    font: inherit;
    text-align: left;
    cursor: pointer;
}

.ob-card__open::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 1;
    border-radius: var(--ob-radius-lg);
}

/*
    Two states, two channels, and neither may borrow the other's — the earlier design gave both
    the same accent ring and they read as one state (collection-and-inventory.md §Decisions 8's
    follow-up). Selected is a set, so it gets the countable, per-tile marks: the accent tint and
    the check-circle. Open-in-the-rail is a singleton and a relationship, so it gets what a badge
    cannot say: an achromatic ring outside the tile and a lifted surface, the same near-white the
    rail's grid-facing edge wears. The accent hue belongs to selection alone; white to the rail
    alone. A tile that is both simply wears all of it — no third design.
*/
.ob-card--expanded {
    outline: 2px solid color-mix(in srgb, var(--ob-text) 92%, transparent);
    outline-offset: 3px;
    border-color: var(--ob-border-strong);
    background-color: var(--ob-surface-hover);
}

.ob-card--selected {
    background-color: color-mix(in srgb, var(--ob-accent) 14%, var(--ob-surface));
}

.ob-card--selected .ob-card__name { color: var(--ob-text); }

/* Unselected tiles step back once anything is selected, so the picked ones are what the eye lands
   on. Applied from the grid, since a tile cannot know whether its neighbours are selected. The
   opened tile is exempt: the rail is showing it, so it must never read as faded out. */
.ob-card-grid--picking .ob-card:not(.ob-card--selected):not(.ob-card--expanded) .ob-card__frame {
    opacity: 0.55;
    transition: opacity 120ms ease;
}

.ob-card-grid--picking .ob-card:not(.ob-card--selected):hover .ob-card__frame { opacity: 1; }

/* Inside the panel now, so it carries no shadow of its own — two stacked shadows an inch apart
   read as a rendering fault rather than as depth. The panel does the lifting. */
.ob-card__frame {
    position: relative;
    display: block;
    aspect-ratio: var(--ob-card-aspect);
    border-radius: var(--ob-radius-sm);
    overflow: hidden;
    background-color: var(--ob-surface-raised);
}

.ob-card__image {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/*
    Shown when a printing has no cached image yet — which is every printing until the image
    pipeline exists, so this has to look deliberate rather than broken.
*/
.ob-card__placeholder {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    padding: var(--ob-space-3);
    background:
        repeating-linear-gradient(
            135deg,
            var(--ob-surface-raised) 0 8px,
            var(--ob-surface) 8px 16px
        );
    color: var(--ob-text-faint);
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: var(--ob-text-sm);
    text-align: center;
}

/*
    Corner badge for quantity — reads as an owned count without needing a label.

    Deliberately quiet. At full contrast it was the brightest thing on the tile and outranked the
    card's own name, which is the wrong order: how many you own is a detail you check, not the
    thing you are scanning a grid for. It brightens on hover, where you are already looking.
*/
.ob-card__count {
    position: absolute;
    top: var(--ob-space-2);
    right: var(--ob-space-2);
    min-width: 1.25rem;
    padding: 0 var(--ob-space-1);
    border-radius: 999px;
    background-color: rgb(0 0 0 / 55%);
    backdrop-filter: blur(4px);
    color: var(--ob-text-muted);
    font-size: var(--ob-text-xs);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    line-height: 1.25rem;
    text-align: center;
    transition: color 140ms ease, background-color 140ms ease;
}

.ob-card:hover .ob-card__count {
    background-color: rgb(0 0 0 / 75%);
    color: var(--ob-text);
}

.ob-card__body {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
}

/*
    Four weights down the block — name, set, identifiers, location — so the eye lands on the name
    and works down. Everything below used to run at one size in one colour, which made a tile a
    paragraph to be read rather than a card to be recognised.
*/
.ob-card__name {
    color: var(--ob-text);
    font-size: var(--ob-text-base);
    font-weight: 600;
    line-height: 1.3;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.ob-card__name:hover {
    color: var(--ob-accent-text-hover);
    text-decoration: none;
}

.ob-card__meta,
.ob-card__where {
    color: var(--ob-text-faint);
    font-size: var(--ob-text-xs);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The set is what places a card, so it sits directly under the name and a step brighter than the
   identifiers below it. Its own line rather than trailing the number: a long set name on a shared
   line was the first thing an ellipsis ate. */
.ob-card__set-name {
    color: var(--ob-text-muted);
    font-size: var(--ob-text-xs);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.ob-card__number {
    font-variant-numeric: tabular-nums;
}

/* At the smallest step the set name goes — the code on the line below already stands for it, so
   this loses nothing the tile was saying. */
[data-size="small"] .ob-card__set-name {
    display: none;
}

/* A shade back from the printing above it: which box a card is in matters less than what the card
   is, and the two lines running at equal weight makes the tile read as a list rather than a card. */
.ob-card__where {
    color: color-mix(in srgb, var(--ob-text-faint) 78%, transparent);
}

/* Condition badge with the price opposite it — space-between rather than a plain inline row, so
   the badge does not move on the day prices arrive. */
.ob-card__footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--ob-space-2);
    margin-top: var(--ob-space-1);
    padding-top: var(--ob-space-2);
    border-top: 1px solid color-mix(in srgb, var(--ob-border) 70%, transparent);
}

/* The one number on the tile worth reading at distance, so it carries the most weight of anything
   below the artwork. Tabular, because a column of prices that jitters is unreadable as a column. */
.ob-card__price {
    color: var(--ob-text);
    font-size: var(--ob-text-sm);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
}

/* The em dash until a price is shown (DR A048, G006). Faint rather than absent: the slot has to
   look reserved rather than broken, the same argument the artwork placeholder makes. */
.ob-card__price--none {
    color: var(--ob-text-faint);
    font-weight: 500;
}

/* A card the catalogue lists but the collection doesn't hold: dimmed, not hidden, so a set
   page doubles as a completion view. The panel dims with it — a full-strength panel around dimmed
   art would give an unheld card the same presence as a held one. */
.ob-card--absent {
    background-color: transparent;
    border-color: color-mix(in srgb, var(--ob-border) 60%, transparent);
}

.ob-card--absent .ob-card__frame {
    opacity: 0.28;
}

/* Still hoverable, so a set page can be read card by card — it just does not lift. */
.ob-card--absent:hover {
    transform: none;
    box-shadow: none;
    background-color: var(--ob-surface);
}

.ob-card--absent:hover .ob-card__frame {
    opacity: 0.5;
}

.ob-card--absent .ob-card__name {
    color: var(--ob-text-faint);
}

/* Said to screen readers only — disambiguates a control whose visible text repeats across the
   grid, without putting the set name twice on the tile. */
.ob-visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    border: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

/* ---------------------------------------------------------------- tags */

.ob-tag {
    display: inline-flex;
    align-items: center;
    padding: 1px var(--ob-space-2);
    border: 1px solid var(--ob-border);
    border-radius: var(--ob-radius-sm);
    color: var(--ob-text-muted);
    font-size: var(--ob-text-xs);
    font-weight: 500;
    white-space: nowrap;
}

.ob-tag--sale {
    border-color: transparent;
    background-color: var(--ob-accent-dim);
    color: var(--ob-accent-text);
}

/* ---------------------------------------------------------------- condition badges */

/*
    Compact, monospaced, and colour-coded by severity. The abbreviation rather than the full name
    because these sit on card tiles at ~150px wide, where "Near Mint" would wrap or truncate — and
    NM/EX/LP is the vocabulary collectors already read on every marketplace listing.

    The full name goes in the title attribute, so the abbreviation never has to be guessed.
*/
.ob-cond {
    display: inline-flex;
    align-items: center;
    padding: 0 var(--ob-space-2);
    border: 1px solid currentColor;
    border-radius: var(--ob-radius-sm);
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: var(--ob-text-xs);
    font-weight: 600;
    line-height: 1.45;
    letter-spacing: 0.02em;
    white-space: nowrap;
}

.ob-cond--mint { color: var(--ob-cond-mint); background-color: var(--ob-cond-mint-bg); }
.ob-cond--good { color: var(--ob-cond-good); background-color: var(--ob-cond-good-bg); }
.ob-cond--fair { color: var(--ob-cond-fair); background-color: var(--ob-cond-fair-bg); }
.ob-cond--poor { color: var(--ob-cond-poor); background-color: var(--ob-cond-poor-bg); }
.ob-cond--bad  { color: var(--ob-cond-bad);  background-color: var(--ob-cond-bad-bg); }

/* ---------------------------------------------------------------- stats */

.ob-stats {
    display: flex;
    flex-wrap: wrap;
    gap: var(--ob-space-6);
    margin-bottom: var(--ob-space-5);
    padding: var(--ob-space-4) var(--ob-space-5);
    background-color: var(--ob-surface);
    border: 1px solid var(--ob-border);
    border-radius: var(--ob-radius-lg);
}

.ob-stat__value {
    font-size: var(--ob-text-xl);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    line-height: 1.2;
}

.ob-stat__label {
    color: var(--ob-text-faint);
    font-size: var(--ob-text-xs);
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

/* ---------------------------------------------------------------- signed-out pages */

/* Shared by the four AuthLayout pages; global because AuthLayout's scoped rules cannot reach
   markup a page renders into it. */
/* The empty-field notice stands only while a field marked data-required is still empty, so it goes
   as the visitor types rather than at the next submit. */
.ob-auth:not(:has([data-required]:placeholder-shown)) .ob-auth-missing { display: none; }

.ob-auth-title {
    margin: 0 0 var(--ob-space-5);
    font-size: var(--ob-text-xl);
    text-align: center;
}

.ob-auth-body {
    margin: 0 0 var(--ob-space-5);
    text-align: center;
}

.ob-auth-form {
    display: flex;
    flex-direction: column;
    gap: var(--ob-space-4);
}

.ob-auth-check {
    display: flex;
    gap: var(--ob-space-2);
    align-items: flex-start;
    font-size: var(--ob-text-sm);
    color: var(--ob-text-muted);
}

.ob-auth-check input { margin-top: 0.2em; }

.ob-auth-submit {
    justify-content: center;
    width: 100%;
    padding-block: var(--ob-space-3);
    font-size: var(--ob-text-base);
}

.ob-auth-aside {
    margin: var(--ob-space-5) 0 0;
    text-align: center;
    font-size: var(--ob-text-sm);
    color: var(--ob-text-muted);
}

/* ---------------------------------------------------------------- utilities */

.ob-stack {
    display: flex;
    flex-direction: column;
    gap: var(--ob-space-4);
}

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

.ob-row--wrap { flex-wrap: wrap; }

/* ---------------------------------------------------------------- framework */

/* Blazor's disconnect/error bar. Restyled because the stock yellow-on-red is unmissable in the
   worst way, and it is rendered by the framework rather than by any page. */
#blazor-error-ui {
    display: none;
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    z-index: 1000;
    padding: var(--ob-space-3) var(--ob-space-5);
    background-color: var(--ob-surface-raised);
    border-top: 2px solid var(--ob-danger);
    box-shadow: var(--ob-shadow-lifted);
    font-size: var(--ob-text-sm);
}

#blazor-error-ui .dismiss {
    float: right;
    cursor: pointer;
    color: var(--ob-text-muted);
}

.blazor-error-boundary {
    padding: var(--ob-space-4);
    border: 1px solid var(--ob-danger);
    border-radius: var(--ob-radius);
    background-color: var(--ob-surface);
    color: var(--ob-text);
}

.blazor-error-boundary::after {
    content: "Something went wrong rendering this section.";
}

.validation-message {
    color: var(--ob-danger);
    font-size: var(--ob-text-sm);
}
