/* Sensify Vision - design tokens from the Sensify Brand Pack 2025.
   Palette extracted from 1_Elementos Basicos/2_Color/1_RGB/Sensify_Color_RGB.ase
   Typefaces: Geist + Roboto Mono (both OFL, self-hosted). */

@font-face {
  font-family: 'Geist';
  src: url('/static/fonts/Geist.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}
@font-face {
  font-family: 'Roboto Mono';
  src: url('/static/fonts/RobotoMono.woff2') format('woff2-variations');
  font-weight: 100 700;
  font-display: swap;
}

:root {
  --ink: #000228;
  --ink-70: rgba(0, 2, 40, 0.70);
  --ink-45: rgba(0, 2, 40, 0.45);
  --ink-12: rgba(0, 2, 40, 0.12);
  --ink-06: rgba(0, 2, 40, 0.06);

  --bone: #f6f6f2;
  --bone-2: #ededE4;
  --bone-3: #e5e4d7;
  --sand: #d3d2bc;
  --surface: #ffffff;

  --amber: #ffc84d;
  --orange: #ff9725;
  --coral: #ff3a38;
  --crimson: #bb1838;
  --grad-brand: linear-gradient(135deg, #ffc84d 0%, #ff3a38 100%);

  /* Annotation only. Deliberately outside the brand palette -- see .det-box. */
  --scan: #2fdff0;

  --radius-sm: 8px;
  --radius: 16px;
  --radius-lg: 24px;
  --shadow: 0 1px 2px rgba(0, 2, 40, 0.04), 0 8px 24px rgba(0, 2, 40, 0.05);

  --font-ui: 'Geist', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-mono: 'Roboto Mono', ui-monospace, 'SF Mono', Menlo, monospace;

  --space: 8px;
  --header-h: 68px;
  --max-w: 1400px;
}

*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }

body {
  margin: 0;
  /* Sticky footer: main takes the leftover height so the footer sits at the bottom of the
     viewport on short pages, and is pushed down normally on long ones. */
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
  background: var(--bone);
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: 15px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

a { color: inherit; }

h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.015em; }
h1 { font-size: 28px; }
h2 { font-size: 20px; }
h3 { font-size: 15px; }

.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.muted { color: var(--ink-45); }
.small { font-size: 13px; }
.stack { display: flex; flex-direction: column; gap: calc(var(--space) * 3); }
.row { display: flex; align-items: center; gap: 12px; }
.spread { display: flex; align-items: center; justify-content: space-between; gap: 12px; }

/* ---------- co-branded header ---------- */

.app-header {
  position: sticky; top: 0; z-index: 20;
  /* body is a flex column (sticky footer), so without this the header shrinks on any page
     tall enough to scroll. */
  flex: 0 0 var(--header-h);
  height: var(--header-h);
  display: flex; align-items: center; gap: calc(var(--space) * 2);
  padding: 0 calc(var(--space) * 3);
  background: rgba(246, 246, 242, 0.85);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--ink-12);
}

.brand-lockup { display: flex; align-items: center; gap: 16px; }
.brand-lockup img.sensify { height: 24px; width: auto; display: block; }
.brand-divider { width: 1px; height: 24px; background: var(--ink-12); }
.brand-lockup .org-logo { height: 26px; width: auto; display: block; }
.brand-lockup .org-name { font-weight: 500; letter-spacing: -0.01em; font-size: 15px; }

.app-nav { display: flex; gap: 4px; margin-left: auto; align-items: center; }
.app-nav a {
  padding: 7px 14px; border-radius: 999px; text-decoration: none;
  color: var(--ink-70); font-size: 14px; font-weight: 500;
}
.app-nav a:hover { background: var(--ink-06); color: var(--ink); }
.app-nav a[aria-current='page'] { background: var(--ink); color: var(--bone); }

.user-chip {
  display: flex; align-items: center; gap: 10px;
  padding-left: 16px; margin-left: 8px;
  border-left: 1px solid var(--ink-12);
  font-size: 13px; color: var(--ink-45);
}

/* ---------- layout ---------- */

main {
  width: 100%;
  max-width: var(--max-w);
  margin: 0 auto;
  padding: calc(var(--space) * 4) calc(var(--space) * 3);
  flex: 1 0 auto;
  display: flex;
  flex-direction: column;
}
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: calc(var(--space) * 3); gap: 16px; flex: 0 0 auto; }
/* Empty-state wrapper on the analyse page. The result partial replaces it with .analyze-grid,
   which is a grid and must not inherit this. */
.fill { flex: 1; display: flex; flex-direction: column; }

.card { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: calc(var(--space) * 3); }
.card.tight { padding: calc(var(--space) * 2); }
/* Wide tables scroll inside their card instead of overflowing the page. */
.table-wrap { overflow-x: auto; border-radius: var(--radius); }
/* Scoped to the wrapper, not to every .data table. The floor exists so the submissions
   columns stay readable and the wrapper scrolls; applied globally it also hit the Cooler
   card, which is a two-column key/value list in a ~380 px column with no wrapper around it.
   That table was 720 px wide inside a 380 px card, and its row rules ran out past the card's
   edge. */
.table-wrap table.data { min-width: 720px; }
.card-title { font-size: 13px; font-weight: 500; color: var(--ink-45); margin-bottom: 12px; }

.analyze-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(360px, 1fr); gap: calc(var(--space) * 3); align-items: start; }
@media (max-width: 1080px) { .analyze-grid { grid-template-columns: 1fr; } }

/* ---------- dropzone ---------- */

#upload-form { flex: 1; display: flex; flex-direction: column; }

.dropzone {
  /* Grows into the height main leaves over, with a floor for short viewports. */
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 340px;
  border: 1.5px dashed var(--sand); border-radius: var(--radius-lg);
  background: var(--surface); padding: calc(var(--space) * 6) calc(var(--space) * 3);
  text-align: center; transition: border-color .15s, background .15s; cursor: pointer;
}
.dropzone:hover, .dropzone.dragging { border-color: var(--coral); background: #fffdfa; }
.dropzone .dz-mark { width: 44px; height: 44px; margin: 0 auto 16px; opacity: .9; }
.dropzone h2 { font-size: 17px; margin-bottom: 6px; }
.dropzone input[type='file'] { display: none; }

/* Next-image bar. Sits ABOVE the result, spanning both columns: the JSON panel below is tall
   enough that a control after it is reliably off-screen, which is where the previous
   "Analyse another image" button lived -- four actions from a result to the next inference
   (scroll, click, then the empty dropzone appears, then click again). This is one. */
.again-bar {
  grid-column: 1 / -1;
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  padding: 12px 16px;
  border: 1px dashed var(--ink-12); border-radius: var(--radius);
  background: var(--surface);
  transition: border-color .12s ease, background .12s ease;
}
.again-bar.dragging { border-color: var(--coral); background: #fffdfa; }
.again-bar label.btn { cursor: pointer; display: inline-flex; align-items: center; gap: 8px; }
.again-bar input[type='file'] { display: none; }
.again-bar .again-clear { margin-left: auto; }
/* The hint and the spinner occupy the same slot: while a request is in flight the bar says
   what it is doing instead of how to use it. */
.again-bar .again-running { display: none; }
.again-bar.htmx-request .again-hint { display: none; }
.again-bar.htmx-request .again-running { display: inline; }
.again-bar.htmx-request label.btn { opacity: .5; pointer-events: none; }

.btn {
  appearance: none; border: 0; cursor: pointer;
  font-family: inherit; font-size: 14px; font-weight: 500;
  padding: 10px 20px; border-radius: 999px;
  background: var(--ink); color: var(--bone);
}
.btn:hover { opacity: .9; }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn.ghost { background: var(--ink-06); color: var(--ink); }
.btn.brand { background: var(--grad-brand); color: #fff; }
.btn.small { padding: 6px 14px; font-size: 13px; }

/* ---------- detection overlay ----------
   Corner brackets: the same framing Sensify's digital language already uses over product
   photography. A box drawn this way belongs to the brand instead of looking like OpenCV debug
   output. */

.viewer { position: relative; border-radius: var(--radius); overflow: hidden; background: var(--bone-2); }
.viewer img { display: block; width: 100%; height: auto; }
.viewer svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }

/* Detection overlay. Cyan rather than the brand gradient, on purpose: these boxes sit on
   top of Coca-Cola red under cooler lighting, where amber and coral are the two colours
   already in the photograph. A hue nothing in the scene shares is what makes the frame read
   as annotation instead of as part of the shelf. */
/* geometricPrecision, not the default: the SVG is scaled to the rendered image size, so a
   hairline lands on fractional pixels. Left to optimizeSpeed the browser snaps it to the
   pixel grid and the same box gets a 1 px edge on one side and a 2 px edge on the other. */
.det-box { fill: none; stroke: var(--scan); shape-rendering: geometricPrecision; }
.det-halo { fill: none; stroke: rgba(0, 2, 40, .45); shape-rendering: geometricPrecision; }
.det-label { font-family: var(--font-mono); fill: var(--scan); font-weight: 500;
             letter-spacing: .02em; }
.det-label-bg { fill: rgba(4, 12, 24, .78); }
.det[hidden] { display: none; }

/* Hover. The svg is pointer-events: none so it never sits between the cursor and the page;
   the hit rect opts back in, which works because pointer-events inherits and a descendant
   may override an ancestor's `none`. */
.det { pointer-events: none; }
.det-hit { fill: transparent; pointer-events: all; }
.det, .gap { transition: opacity .12s ease; }
/* :has() rather than a JS handler. Fifty boxes would be fifty listeners, and the state is
   already expressed by the cursor -- there is nothing for JavaScript to remember. */
.viewer svg:has(.det:hover) .det:not(:hover),
.viewer svg:has(.det:hover) .gap { opacity: .16; }
.det-tag.on-hover { opacity: 0; }
.det:hover .det-tag.on-hover { opacity: 1; }

/* ---------- metrics ---------- */

.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 1px; background: var(--ink-12); border-radius: var(--radius); overflow: hidden; }
.metric { background: var(--surface); padding: 16px 18px; }
.metric .label { font-size: 12px; color: var(--ink-45); margin-bottom: 4px; }
.metric .value {
  font-family: var(--font-mono); font-size: 26px; font-weight: 500; letter-spacing: -0.02em;
  background: var(--grad-brand); -webkit-background-clip: text; background-clip: text; color: transparent;
}
.metric .value.plain { background: none; -webkit-text-fill-color: var(--ink); color: var(--ink); }
.metric .unit { font-size: 13px; color: var(--ink-45); font-family: var(--font-ui); }

/* ---------- classes / legend ---------- */

.legend { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 5px 12px; border-radius: 999px;
  background: var(--ink-06); font-size: 13px; cursor: pointer; user-select: none;
  border: 1px solid transparent; font-family: inherit; color: inherit;
}
.chip[aria-pressed='false'] { opacity: .4; }
.chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--grad-brand); }
.chip .count { font-family: var(--font-mono); color: var(--ink-45); }

.slider-row { display: flex; align-items: center; gap: 14px; }
input[type='range'] { flex: 1; accent-color: var(--coral); }

/* ---------- JSON ---------- */

.json-panel { position: relative; }
.json-panel pre {
  margin: 0; max-height: 460px; overflow: auto;
  background: var(--ink); color: #e8e8f0;
  border-radius: var(--radius); padding: 18px 20px;
  font-family: var(--font-mono); font-size: 12.5px; line-height: 1.65;
}
.json-actions { position: absolute; top: 12px; right: 12px; display: flex; gap: 8px; }
/* The panel's own buttons sit over the dark <pre>, so an anchor styled as a button has to be
   told the things a <button> already knew. */
.json-actions .btn { text-decoration: none; line-height: normal; }

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

table.data { width: 100%; border-collapse: collapse; font-size: 14px; }
table.data th { text-align: left; font-weight: 500; font-size: 12px; color: var(--ink-45); padding: 0 14px 10px; border-bottom: 1px solid var(--ink-12); }
table.data td { padding: 12px 14px; border-bottom: 1px solid var(--ink-06); }
table.data tr:hover td { background: var(--bone); }
table.data a { text-decoration: none; font-weight: 500; }
.thumb { width: 56px; height: 40px; object-fit: cover; border-radius: 6px; background: var(--bone-2); display: block; }

.status { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
.status::before { content: ''; width: 7px; height: 7px; border-radius: 50%; }
.status.ok::before { background: #16a34a; }
.status.error::before { background: var(--crimson); }

/* ---------- logs ---------- */

.logs { font-family: var(--font-mono); font-size: 12.5px; }
.log-row { display: grid; grid-template-columns: 96px 66px 1fr; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--ink-06); }
.log-level { text-transform: uppercase; font-size: 11px; letter-spacing: .04em; }
.log-level.error, .log-level.critical { color: var(--crimson); }
.log-level.warning { color: var(--orange); }
.log-level.info, .log-level.debug { color: var(--ink-45); }
.log-ctx { color: var(--ink-45); word-break: break-word; }

/* ---------- login ---------- */

.login-shell { min-height: 100dvh; display: grid; place-items: center; padding: 24px; }

/* With an organisation background the page becomes a split: the photograph keeps the left,
   the form sits over the dark right-hand side.
 *
 * The scrim stops well short of black at that end, which looks like a contrast mistake and
 * is the opposite. Glass is only convincing when there is something behind it to refract:
 * over a flat dark field the blur has nothing to work on and the panel reads as a tinted
 * rectangle. Leaving the shelf and the cans faintly legible under the pane is what makes it
 * a pane. The panel carries its own contrast now -- it did not when it was opaque white,
 * which is why this gradient used to end at .92. */
.login-shell.has-bg {
  place-items: center end;
  padding: clamp(24px, 5vw, 64px);
  background-image:
    linear-gradient(100deg,
      rgba(0, 2, 40, .04) 0%,
      rgba(0, 2, 40, .20) 42%,
      rgba(0, 2, 40, .44) 68%,
      rgba(0, 2, 40, .52) 100%),
    var(--login-bg);
  background-size: cover, cover;
  background-position: center, center left;
  background-repeat: no-repeat, no-repeat;
}
/* Narrow viewports lose the split: the photo becomes a backdrop and the card centres. */
@media (max-width: 820px) {
  .login-shell.has-bg {
    place-items: center;
    background-image:
      linear-gradient(rgba(0, 2, 40, .72), rgba(0, 2, 40, .88)),
      var(--login-bg);
  }
}

.login-panel { width: 100%; max-width: 400px; }
.login-card { width: 100%; }
.login-card .mark { width: 40px; margin-bottom: 24px; display: block; }

/* Over a photograph the card stops being a white card. An opaque light panel on a dark
   cinematic shot reads as a widget pasted on top of an unrelated picture -- two things in
   one frame rather than one. Glass instead: the photograph carries on THROUGH the panel,
   blurred, so the two belong to the same image.
 *
 * The hairline border is not decoration. A translucent panel with no edge has no surface,
 * and the eye reads it as a smudge; the light edge is what says "there is a pane here".
 * The scrim above is lighter than it was for the same reason -- it existed to guarantee
 * contrast for opaque white, and the panel now provides its own. */
.has-bg .login-card {
  border-radius: 28px;
  background: rgba(8, 12, 28, .58);
  border: 1px solid rgba(255, 255, 255, .18);
  box-shadow: 0 32px 80px rgba(0, 2, 40, .5), inset 0 1px 0 rgba(255, 255, 255, .10);
  color: #fff;
}
/* Where the backdrop cannot be blurred the panel has to carry contrast on its own, so it
   goes nearly opaque. Checked rather than assumed: unsupported and translucent is
   unreadable text over cans. */
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .has-bg .login-card {
    /* Three things stacked, and each one is doing a different job.
     *
     * saturate(180%) is what separates glass from a grey filter. A plain blur washes the
     * colour out of what is behind it; pushing saturation back up is why the reds under the
     * panel stay red, and it is most of the reason the material reads as a lens rather than
     * as fog.
     *
     * The gradient on top is the sheen -- light falling across the pane from one corner,
     * which is what stops a flat translucent rectangle from looking like a hole.
     *
     * The bright inset on the first row of pixels is the specular edge. It is the single
     * detail that carries this material: a lit rim says the pane has thickness and is
     * catching light, and without it the same blur and tint just look like a dimmed area of
     * the photograph. */
    background:
      linear-gradient(158deg,
        rgba(255, 255, 255, .17) 0%,
        rgba(255, 255, 255, .05) 32%,
        rgba(255, 255, 255, 0) 58%),
      rgba(10, 14, 30, .30);
    box-shadow:
      0 32px 80px rgba(0, 2, 40, .5),
      inset 0 1px 0 rgba(255, 255, 255, .40),
      inset 0 -1px 0 rgba(255, 255, 255, .07);
    -webkit-backdrop-filter: blur(40px) saturate(180%) brightness(1.06);
    backdrop-filter: blur(40px) saturate(180%) brightness(1.06);
  }
  /* The fields are the same material, thinner. Their own blur is what keeps them reading as
     recessed panes in the glass rather than as flat holes cut through it. */
  .has-bg .field input {
    -webkit-backdrop-filter: blur(10px) saturate(140%);
    backdrop-filter: blur(10px) saturate(140%);
  }
}
.has-bg .login-card h1 { color: #fff; }
.has-bg .login-card .muted { color: rgba(255, 255, 255, .62); }
.has-bg .login-lockup .lockup-divider { background: rgba(255, 255, 255, .22); }

/* .72, not the .62 the light card uses. Measured off the rendered page: at .62 the labels
   came out at 4.52:1 against the panel's lightest corner, where the sheen is strongest --
   AA by 0.02, and only for this photograph. The background is per-organisation, so the
   next one uploaded could be brighter behind the panel and there is no margin to spend. */
.has-bg .field label { color: rgba(255, 255, 255, .72); }
.has-bg .field input {
  background: rgba(255, 255, 255, .10);
  border-color: rgba(255, 255, 255, .20);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15);
  color: #fff;
}
.has-bg .field input::placeholder { color: rgba(255, 255, 255, .38); }
.has-bg .field input:focus { outline-color: var(--amber); border-color: transparent; }
.has-bg .login-lockup .lockup-divider { background: rgba(255, 255, 255, .28); }
/* Chrome paints its own near-white background on an autofilled field and ignores
   `background`. The inset shadow is the only thing it honours, and without it a saved login
   turns both fields pale on a dark panel -- which is exactly the state anyone with a
   password manager sees first. */
.has-bg .field input:-webkit-autofill,
.has-bg .field input:-webkit-autofill:hover,
.has-bg .field input:-webkit-autofill:focus {
  -webkit-box-shadow: inset 0 0 0 1000px rgba(20, 24, 44, .96);
  -webkit-text-fill-color: #fff;
  caret-color: #fff;
}

.login-lockup { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; }
.login-lockup .mark { margin-bottom: 0; }
.login-lockup .lockup-divider { width: 1px; height: 26px; background: var(--ink-12); }
.login-lockup .org { height: 28px; width: auto; display: block; }

.login-footer { margin: 20px 0 0; text-align: center; }
.has-bg .login-footer { color: rgba(255, 255, 255, .62); }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; color: var(--ink-45); margin-bottom: 6px; }
.field input {
  width: 100%; font-family: inherit; font-size: 15px;
  padding: 11px 14px; border-radius: var(--radius-sm);
  border: 1px solid var(--ink-12); background: var(--bone); color: var(--ink);
}
.field .select, select.select {
  width: 100%; font-family: inherit; font-size: 15px;
  padding: 11px 14px; border-radius: var(--radius-sm);
  border: 1px solid var(--ink-12); background: var(--bone); color: var(--ink);
}
.field input:focus { outline: 2px solid var(--coral); outline-offset: -1px; border-color: transparent; }

/* ---------- modal ----------
   Native <dialog>: the browser handles the top layer, focus trapping and Escape, so there is
   no bespoke focus management to get wrong. */

dialog.modal {
  margin: auto;
  border: 0;
  border-radius: var(--radius-lg);
  padding: calc(var(--space) * 3);
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 24px 64px rgba(0, 2, 40, .22);
  max-width: 420px;
  width: calc(100vw - 32px);
  text-align: left;
}
dialog.modal::backdrop { background: rgba(0, 2, 40, .45); }
dialog.modal[open] { animation: modal-in .12s ease-out; }
@keyframes modal-in { from { opacity: 0; transform: translateY(-6px); } }

.modal-title { font-size: 18px; margin-bottom: 6px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }

.alert { padding: 12px 16px; border-radius: var(--radius-sm); font-size: 14px; margin-bottom: 16px; }
.alert.error { background: rgba(187, 24, 56, .08); color: var(--crimson); }
.alert.info { background: var(--ink-06); color: var(--ink-70); }

.app-footer { flex: 0 0 auto; width: 100%; max-width: var(--max-w); margin: 0 auto; padding: 32px 24px 40px; color: var(--ink-45); font-size: 12.5px; display: flex; justify-content: space-between; gap: 16px; }

.htmx-indicator { opacity: 0; transition: opacity .15s; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
.spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--ink-12); border-top-color: var(--coral); animation: spin .7s linear infinite; display: inline-block; vertical-align: -3px; }
@keyframes spin { to { transform: rotate(360deg); } }

/* Reported gaps. Read as an absence, not as a detection: no gradient, no bracket, no
   label. The dash also says "estimated position" without needing a caption on the image. */
.gap-box {
  fill: rgb(0 2 40 / 0.28);
  stroke: var(--bone, #f6f6f2);
  stroke-opacity: 0.85;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
