Files
Behavision/desktop/frontend/src/styles.css
Suriyakumarvijayanayagam ecc8bbba6f The app on a laptop reported an engine that was never meant to be there
Signing in on a second Mac showed "engine not reachable at
http://127.0.0.1:8010" and 0 of 0 cameras, on an account whose shops were
running and recognising people the whole time. Nothing was broken: Live() and
Cameras() read only the engine on loopback, so the app answered as though the
person had never signed in - and camera sync goes through the engine, which is
why the count was zero rather than stale.

Having no engine is a normal state. A shop PC watches cameras; an owner's
laptop, a manager's machine and a second till being set up do not, and all
three are signed in to the same estate. Both methods now fall back to head
office when loopback fails and somebody is signed in. Loopback is still tried
first: a real shop PC must never be shown a minute-old summary when the engine
two milliseconds away has the live one.

Decisions worth keeping:

- Viewing is on the snapshot, not inferred per screen. Three surfaces read it,
  and a screen that computed it separately is how the shops screen once came
  out labelled Working, in green, above "2 of 3 cameras not connecting".
- fraction_below_gate takes the WORST shop, never an average. 0.10 against
  0.73 averages to 0.42 and hides the only shop anyone needs to visit.
- A remote camera is flagged, and Edit, Remove and Check placement are
  withheld. They talk to a camera on a LAN this computer cannot reach, and a
  button that cannot work is worse than one that is absent.
- connected is three states. null is "no shop computer has reported yet" and
  reads as waiting; false is "Not connecting". A bare false sends somebody to
  check cabling on a camera nobody has tried to reach.
- Snapshots are fetched in Go as data: URIs and cached by snapshot_at. A
  webview <img> resolves a relative src against wails:// and cannot send the
  bearer - the problem VisitorImage already solved - and this screen polls
  every 8 seconds at ~90 KB a camera.
- With no engine AND no session, the engine error is still the answer. The
  person is most likely setting this PC up.

The picture is the last snapshot and the banner says so: there is no live
video from here, because the engine's MJPEG stream is on the shop PC's
loopback behind a router with no inbound route. The LiveHub relay head office
uses is the answer to that and is a further step for this client.

Verified against production: five arrivals and two cameras parsed from the
real API. viewing_test.go covers the fallback, the worst-shop rule, the
withheld credentials and that an unchanged snapshot is fetched once across
two polls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 16:33:32 +05:30

651 lines
40 KiB
CSS

/* Behavision desktop — a shop-floor instrument, not a website.
===========================================================================
Designed for one situation: a PC behind a counter, on a cheap monitor, in a
room with daylight, glanced at by somebody who is mid-conversation with a
customer. Everything below follows from that.
- Dark, because the screen sits in peripheral vision all day and a white
field at 1000 lux is a lamp pointed at the operator.
- State is carried by shape AND colour: a pill, a dot and an edge stripe,
never colour alone. This gets read from two metres away, and some
operators do not see red and green apart.
- One spacing scale and one type scale. The previous version set margins
inline, per screen, which is how a UI ends up looking assembled rather
than designed.
- Motion only where it carries meaning: a live camera, a fresh arrival.
Nothing loops for decoration — this process shares a CPU with recognition.
=========================================================================== */
:root {
/* ground → raised, four steps, blue-green biased: the product lives in the
world of lenses and CCTV, and a neutral grey reads as unfinished. */
--bg: #0A0F13;
--s1: #111A20;
--s2: #17232B;
--s3: #1E2D37;
--line: #223038;
--line-2: #1A252C;
--ink: #ECF3F7;
--ink-2: #A3B6C2;
--ink-3: #6C808D;
/* Accent is for state and focus only, never decoration, so that when it does
appear the eye goes to it. */
--accent: #40C4DC;
--accent-2: #0F3B47;
--accent-3: #0B2A33;
--ok: #48C78E; --ok-2: #102E22;
--warn: #EAAA3D; --warn-2: #31260F;
--bad: #EC6A5C; --bad-2: #331815;
--r-sm: 6px; --r: 10px; --r-lg: 14px;
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
--sp-5: 20px; --sp-6: 24px; --sp-7: 32px; --sp-8: 40px;
--shadow: 0 1px 2px rgb(0 0 0 / .4), 0 8px 24px -12px rgb(0 0 0 / .6);
--shadow-lg: 0 2px 4px rgb(0 0 0 / .4), 0 24px 48px -16px rgb(0 0 0 / .7);
/* Segoe UI Variable first: it is on every Windows 11 shop PC, it has real
optical sizes, and it is what makes this look like an application rather
than a web page in a frame. No webfont — a shop PC has no internet at
install time, and a font that fails to arrive is a layout that shifts
under the operator. */
--font: "Segoe UI Variable Text", "Segoe UI", Inter, -apple-system,
BlinkMacSystemFont, system-ui, "Helvetica Neue", Arial, sans-serif;
--font-display: "Segoe UI Variable Display", var(--font);
--mono: "Cascadia Mono", "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
/* Kept as aliases so any screen not yet rewritten keeps its colours. */
--ground: var(--bg); --surface: var(--s1); --surface-2: var(--s2);
--line-soft: var(--line-2); --muted: var(--ink-3); --radius: var(--r);
--accent-dim: var(--accent-3);
}
* { box-sizing: border-box; margin: 0; }
html, body, #root { height: 100%; }
body {
background: var(--bg);
color: var(--ink);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
user-select: none;
}
button, input, select, textarea { font: inherit; color: inherit; }
input, textarea { user-select: text; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
::selection { background: var(--accent-2); color: var(--ink); }
/* Digits that line up wherever they are compared or refreshed in place. */
.num, .value, .metric-v, .when, .mono, .code, td { font-variant-numeric: tabular-nums; }
.mono, .code { font-family: var(--mono); }
/* The default light scrollbar on a dark panel is the most obvious "this is a
web page" tell there is. */
* { scrollbar-width: thin; scrollbar-color: var(--s3) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: var(--s3); border-radius: 99px; border: 3px solid var(--bg); }
*::-webkit-scrollbar-thumb:hover { background: #2A3D49; }
/* ================================================================ shell == */
.shell { display: grid; grid-template-columns: 232px 1fr auto; height: 100%; }
.side {
background: var(--s1); border-right: 1px solid var(--line);
display: flex; flex-direction: column; min-height: 0;
}
.side .brand { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-5) var(--sp-5) var(--sp-4); }
.side .brand .mark { width: 30px; height: 30px; flex: none; display: grid; place-items: center; }
.side .brand .mark img, .login .mark img { width: 100%; height: 100%; object-fit: contain; display: block; }
.side .brand .id { min-width: 0; }
.side .brand h1 { font-family: var(--font-display); font-size: 15px; font-weight: 600; letter-spacing: -.012em; line-height: 1.2; }
.side .brand p { font-size: 11.5px; color: var(--ink-3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: 2px; flex: 1; }
.nav button {
position: relative; display: flex; align-items: center; gap: var(--sp-3); width: 100%;
background: none; border: 0; border-radius: var(--r-sm); padding: 9px var(--sp-3);
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px; font-weight: 450;
transition: background .12s ease, color .12s ease;
}
.nav button svg { flex: none; opacity: .9; }
.nav button:hover { background: var(--s2); color: var(--ink); }
.nav button[aria-current="page"] { background: var(--accent-3); color: var(--accent); font-weight: 550; }
/* A rail, not a background wash: it survives being looked at sideways. */
.nav button[aria-current="page"]::before {
content: ""; position: absolute; left: -12px; top: 7px; bottom: 7px;
width: 2.5px; border-radius: 0 2px 2px 0; background: var(--accent);
}
/* The one control that starts and stops the product, so it gets its own block
at the foot rather than a row in a list. */
.enginebox {
margin: var(--sp-3); padding: var(--sp-3) var(--sp-4) var(--sp-4);
border: 1px solid var(--line); border-radius: var(--r); background: var(--s2);
}
.enginebox .row { display: flex; align-items: center; gap: var(--sp-2); }
.enginebox .state { font-size: 12.5px; font-weight: 600; letter-spacing: -.005em; }
.enginebox .label { display: block; color: var(--ink-3); font-size: 11px; line-height: 1.45; margin-top: 3px; font-variant-numeric: tabular-nums; }
.enginebox .actions, .enginebox .controls { display: flex; gap: var(--sp-2); margin-top: var(--sp-3); }
.enginebox .actions .btn, .enginebox .controls .btn { flex: 1; justify-content: center; padding: 6px 8px; font-size: 12px; }
.side .who {
padding: var(--sp-3) var(--sp-5) var(--sp-5); border-top: 1px solid var(--line-2);
display: flex; align-items: center; gap: var(--sp-3);
}
.side .who .id { min-width: 0; flex: 1; }
.side .who .id b { display: block; font-size: 12.5px; font-weight: 550; }
.side .who .id span { display: block; font-size: 11px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.main { min-width: 0; min-height: 0; overflow: auto; position: relative; }
/* ================================================================= page == */
.page { padding: var(--sp-6) var(--sp-7) var(--sp-8); max-width: 1500px; }
.page > header { margin-bottom: var(--sp-5); }
.page > header h2, .page h2 { font-family: var(--font-display); font-size: 22px; font-weight: 600; letter-spacing: -.02em; line-height: 1.2; }
.page > header p, .page header p { color: var(--ink-3); font-size: 13px; margin-top: 3px; }
.pagehead { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-5); flex-wrap: wrap; }
h3 { font-size: 11px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
/* ================================================================ cards == */
.card { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r); padding: var(--sp-4); }
.card > h3 { margin-bottom: var(--sp-3); }
.card.flush { padding: 0; overflow: hidden; }
.panel { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.panel > .panelhead {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--line-2);
background: linear-gradient(var(--s2), var(--s1)); flex: none;
}
.panel > .panelhead h3 { margin: 0; }
.panel > .panelbody { padding: var(--sp-4); min-height: 0; overflow: auto; }
.panel > .panelbody.flush { padding: 0; }
.grid { display: grid; gap: var(--sp-4); }
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 1180px) { .cols-4 { grid-template-columns: repeat(2, minmax(0,1fr)); } }
@media (max-width: 980px) { .cols-2, .cols-3 { grid-template-columns: minmax(0,1fr); } }
/* Four equal boxes used to dominate this screen. The numbers matter, but they
are not what anybody opens the app to see. */
.metrics {
display: grid; grid-template-columns: repeat(auto-fit, minmax(152px, 1fr));
gap: 1px; background: var(--line); border: 1px solid var(--line);
border-radius: var(--r); overflow: hidden;
}
.metric { background: var(--s1); padding: var(--sp-3) var(--sp-4) var(--sp-4); }
.metric .metric-k { font-size: 10.5px; font-weight: 600; letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3); }
.metric .metric-v { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; margin-top: 5px; }
.metric .metric-s { font-size: 11.5px; color: var(--ink-3); margin-top: 3px; line-height: 1.4; }
.metric.ok .metric-v { color: var(--ok); }
.metric.warn .metric-v { color: var(--warn); }
.metric.bad .metric-v { color: var(--bad); }
/* legacy .stat, for screens not yet rewritten */
.stat h3 { margin-bottom: var(--sp-2); }
.stat .value { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -.025em; line-height: 1.1; }
.stat .unit { font-size: 15px; color: var(--ink-3); margin-left: 3px; }
.stat .sub { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; line-height: 1.4; }
/* =============================================================== status == */
.dot { width: 7px; height: 7px; border-radius: 99px; flex: none; background: var(--ink-3); }
.dot.ok { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 18%, transparent); }
.dot.warn { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 18%, transparent); }
.dot.bad { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad) 18%, transparent); }
.dot.idle { background: var(--ink-3); }
/* A live camera is the one thing that should breathe: it is how an operator
knows the picture is not frozen. Everything else holds still. */
.dot.live { background: var(--ok); animation: pulse 2.4s ease-in-out infinite; }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--ok) 0%, transparent); }
}
.pill {
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px 3px 7px;
border-radius: 99px; font-size: 11px; font-weight: 600; letter-spacing: .02em;
background: var(--s3); color: var(--ink-2); border: 1px solid var(--line); white-space: nowrap;
}
.pill.ok { background: var(--ok-2); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 28%, transparent); }
.pill.warn { background: var(--warn-2); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 28%, transparent); }
.pill.bad { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 28%, transparent); }
.pill.accent { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
.tag {
display: inline-flex; align-items: center; padding: 2px 7px; border-radius: var(--r-sm);
font-size: 10.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
background: var(--s3); color: var(--ink-2);
}
.tag.new { background: var(--accent-3); color: var(--accent); }
.tag.seen { background: var(--ok-2); color: var(--ok); }
.tag.miss { background: var(--warn-2); color: var(--warn); }
/* One line that answers "is this shop working" above everything else. */
.statusbar {
display: flex; align-items: center; gap: var(--sp-5); flex-wrap: wrap;
padding: var(--sp-3) var(--sp-4); background: var(--s1);
border: 1px solid var(--line); border-radius: var(--r); margin-bottom: var(--sp-4);
}
.statusbar .item { display: flex; align-items: center; gap: var(--sp-2); font-size: 12.5px; }
.statusbar .item b { font-weight: 600; letter-spacing: -.005em; }
.statusbar .item svg { color: var(--ink-3); }
.statusbar .sep { width: 1px; align-self: stretch; background: var(--line); }
.statusbar .grow { flex: 1; }
/* ============================================================= arrivals == */
/* The reason the product exists, so it gets the width and the weight. */
.arrivals { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); }
.arrival {
display: grid; grid-template-columns: 46px 1fr auto; gap: var(--sp-3); align-items: center;
padding: var(--sp-3); border-radius: var(--r);
background: var(--s2); border: 1px solid var(--line-2);
position: relative; overflow: hidden;
}
.arrival::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2.5px; background: var(--ink-3); }
.arrival.is-new::before { background: var(--accent); }
.arrival.is-seen::before { background: var(--ok); }
.arrival.is-miss::before { background: var(--warn); }
/* Only the newest row animates, and only once. */
.arrival.fresh { animation: slidein .28s cubic-bezier(.2,.8,.3,1); }
@keyframes slidein { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
.arrival .avatar {
width: 46px; height: 46px; border-radius: var(--r-sm); display: grid; place-items: center;
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
font-family: var(--font-display); font-size: 15px; font-weight: 600;
color: var(--ink-2); letter-spacing: -.01em; font-variant-numeric: tabular-nums;
}
.arrival .avatar img { width: 100%; height: 100%; object-fit: cover; }
.arrival.is-new .avatar { background: var(--accent-3); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 25%, transparent); }
.arrival .who { min-width: 0; }
.arrival .who .name { font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.arrival .who .meta { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.arrival .right { text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 5px; }
.arrival .right .when { font-size: 11.5px; color: var(--ink-3); }
/* ================================================================ feeds == */
.feeds { display: grid; gap: var(--sp-3); grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); padding: var(--sp-3); }
.feed { position: relative; border-radius: var(--r); overflow: hidden; background: #05090C; border: 1px solid var(--line); aspect-ratio: 16 / 9; }
.feed img { width: 100%; height: 100%; object-fit: cover; display: block; }
.feed .placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--ink-3); }
/* Caption over the picture, not beneath it: the tile stays a picture. */
.feed .cap {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
padding: var(--sp-5) var(--sp-3) var(--sp-3);
background: linear-gradient(transparent, rgb(0 0 0 / .8));
font-size: 12.5px; font-weight: 600; letter-spacing: -.005em;
}
/* ================================================================ lists == */
.events, .timeline { list-style: none; padding: 0; display: flex; flex-direction: column; }
.events li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-4); border-bottom: 1px solid var(--line-2); font-size: 13px; }
.timeline li { display: flex; align-items: center; gap: var(--sp-3); padding: 9px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
.events li:last-child, .timeline li:last-child { border-bottom: 0; }
.events .when, .timeline .when { font-size: 11.5px; color: var(--ink-3); width: 46px; flex: none; }
.empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: var(--sp-3); padding: var(--sp-8) var(--sp-5); color: var(--ink-3); text-align: center; font-size: 12.5px;
}
.empty svg { opacity: .35; }
.empty b { display: block; color: var(--ink-2); font-size: 13.5px; font-weight: 550; }
.empty p { max-width: 34ch; line-height: 1.5; }
.tablewrap { overflow: auto; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th {
text-align: left; padding: 9px var(--sp-4); font-size: 10.5px; font-weight: 600;
letter-spacing: .085em; text-transform: uppercase; color: var(--ink-3);
background: var(--s2); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 1;
}
td { padding: 10px var(--sp-4); border-bottom: 1px solid var(--line-2); }
tbody tr:last-child td { border-bottom: 0; }
tbody tr[role="button"], tbody tr.clickable { cursor: pointer; }
tbody tr[role="button"]:hover, tbody tr.clickable:hover { background: var(--s2); }
/* ============================================================= controls == */
.btn {
display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px;
border-radius: var(--r-sm); background: var(--s3); color: var(--ink);
border: 1px solid var(--line); cursor: pointer;
font-size: 13px; font-weight: 550; letter-spacing: -.005em; white-space: nowrap;
transition: background .12s ease, border-color .12s ease, transform .06s ease;
}
.btn:hover:not(:disabled) { background: #253643; border-color: #2E414E; }
.btn:active:not(:disabled) { transform: translateY(.5px); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn svg { flex: none; }
.btn.primary { background: var(--accent); color: #04171C; border-color: transparent; font-weight: 600; }
.btn.primary:hover:not(:disabled) { background: #55D0E6; }
.btn.danger { background: var(--bad-2); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 32%, transparent); }
.btn.danger:hover:not(:disabled) { background: #43201C; }
.btn.ghost { background: transparent; }
.btn.ghost:hover:not(:disabled) { background: var(--s2); }
.btn.sm { padding: 5px 10px; font-size: 12px; }
.btn.icon { padding: 7px; }
.linkbtn { background: none; border: 0; color: var(--accent); cursor: pointer; font-size: 12.5px; padding: 2px 0; text-align: left; }
.linkbtn:hover { text-decoration: underline; }
.seg { display: inline-flex; background: var(--s2); border: 1px solid var(--line); border-radius: var(--r-sm); padding: 2px; gap: 2px; }
.seg button { background: none; border: 0; border-radius: 4px; padding: 5px 11px; color: var(--ink-3); cursor: pointer; font-size: 12.5px; font-weight: 500; }
.seg button[aria-pressed="true"] { background: var(--s3); color: var(--ink); }
/* ================================================================ forms == */
.field { display: block; margin-bottom: var(--sp-4); }
.field > span { display: block; font-size: 11.5px; font-weight: 550; color: var(--ink-2); margin-bottom: 6px; }
.field input, .field select, .field textarea {
width: 100%; padding: 9px 11px; background: var(--s2); color: var(--ink);
border: 1px solid var(--line); border-radius: var(--r-sm);
transition: border-color .12s ease, background .12s ease, box-shadow .12s ease;
}
.field input::placeholder { color: var(--ink-3); }
.field input:hover, .field select:hover, .field textarea:hover { border-color: #2C3D49; }
.field input:focus, .field select:focus, .field textarea:focus {
outline: none; border-color: var(--accent); background: var(--s1); box-shadow: 0 0 0 3px var(--accent-3);
}
.field .hint { display: block; font-size: 11.5px; color: var(--ink-3); margin-top: 5px; line-height: 1.45; }
.fieldrow { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--sp-3); }
.fieldrow.three { grid-template-columns: repeat(3, minmax(0,1fr)); }
.err {
display: flex; align-items: flex-start; gap: var(--sp-2);
background: var(--bad-2); color: var(--bad);
border: 1px solid color-mix(in srgb, var(--bad) 30%, transparent);
border-radius: var(--r-sm); padding: 9px 11px; font-size: 12.5px; line-height: 1.45; margin-bottom: var(--sp-4);
}
.err svg { flex: none; margin-top: 1px; }
.note { color: var(--ink-3); font-size: 12px; line-height: 1.5; }
.note.warn { color: var(--warn); }
.note.bad { color: var(--bad); }
.lead { color: var(--ink-2); font-size: 13.5px; line-height: 1.55; }
.sm { font-size: 12px; }
.lbl, .key { color: var(--ink-3); font-size: 11.5px; }
.grow { flex: 1; }
.row { display: flex; align-items: center; gap: var(--sp-3); }
/* ================================================================= gate == */
/* Login and Setup: the first thing anybody sees, and previously a grey box on
a grey field. One soft light behind the card gives the window a centre and
costs nothing — it is a static gradient, not an animation. */
.login {
height: 100%; display: grid; place-items: center; padding: var(--sp-6); overflow: auto;
background: radial-gradient(900px 480px at 50% -10%, #10303A 0%, transparent 62%), var(--bg);
}
.login .box {
width: 100%; max-width: 396px; background: var(--s1); border: 1px solid var(--line);
border-radius: var(--r-lg); padding: var(--sp-7); box-shadow: var(--shadow-lg);
}
.login .mark { width: 44px; height: 44px; margin-bottom: var(--sp-4); display: grid; place-items: center; }
.login h1 { font-family: var(--font-display); font-size: 21px; font-weight: 600; letter-spacing: -.022em; }
.login .lead { margin: 6px 0 var(--sp-5); }
.login .btn { width: 100%; justify-content: center; margin-top: var(--sp-1); }
.login .foot { font-size: 11.5px; color: var(--ink-3); line-height: 1.55; margin-top: var(--sp-5); padding-top: var(--sp-4); border-top: 1px solid var(--line-2); }
.login .alt { margin-top: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
.login .alt .btn { margin-top: 0; }
.login .note { margin: 0; }
/* =============================================================== drawer == */
.drawer { position: fixed; inset: 0; z-index: 40; background: rgb(4 8 11 / .6); display: flex; justify-content: flex-end; animation: fade .16s ease; }
@keyframes fade { from { opacity: 0 } to { opacity: 1 } }
.drawer .sheet {
width: min(540px, 100%); height: 100%; overflow: auto;
background: var(--s1); border-left: 1px solid var(--line); box-shadow: var(--shadow-lg);
animation: slidein-r .2s cubic-bezier(.2,.8,.3,1);
}
@keyframes slidein-r { from { transform: translateX(16px); opacity: .6 } to { transform: none; opacity: 1 } }
.drawer .sheethead {
position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); background: var(--s1); border-bottom: 1px solid var(--line);
}
.drawer .sheethead h2 { font-family: var(--font-display); font-size: 17px; font-weight: 600; letter-spacing: -.015em; }
.drawer .sheetbody { padding: var(--sp-5); }
.drawer .close { background: none; border: 0; color: var(--ink-3); cursor: pointer; padding: 6px; border-radius: var(--r-sm); display: grid; place-items: center; }
.drawer .close:hover { background: var(--s2); color: var(--ink); }
.avatar {
width: 44px; height: 44px; border-radius: var(--r-sm); flex: none; display: grid; place-items: center;
overflow: hidden; background: var(--s3); border: 1px solid var(--line);
font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
/* Cameras and arrivals side by side, the same height, each scrolling its own
content. Left to itself the arrivals panel shrank to fit two cards and left
a hole beside a tall camera tile - the layout looked broken precisely when
the shop was quiet, which is most of the time. */
.live-split {
display: grid; gap: var(--sp-4);
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
align-items: stretch;
min-height: 420px;
}
.live-split > .panel { max-height: 62vh; }
@media (max-width: 1100px) {
.live-split { grid-template-columns: minmax(0, 1fr); }
.live-split > .panel { max-height: none; }
}
/* Arrivals alone on the Live screen: one column, capped so a long day scrolls
inside the panel rather than pushing the metrics off the bottom. */
.arrivals-panel { max-height: 64vh; margin-bottom: var(--sp-4); }
.arrivals-panel .arrivals { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--sp-2); }
/* =============================================================== cameras == */
.pagehead { display: flex; justify-content: space-between; align-items: flex-end; gap: var(--sp-4); }
.page > header.pagehead { margin-bottom: var(--sp-5); }
.camgrid { display: grid; gap: var(--sp-4); grid-template-columns: repeat(auto-fill, minmax(440px, 1fr)); }
.camcard { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; display: flex; flex-direction: column; }
.camview { position: relative; aspect-ratio: 16 / 9; background: #05090C; }
.camview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.camview .placeholder { width: 100%; height: 100%; display: grid; place-items: center; color: var(--ink-3); }
.camview .pill.over { position: absolute; top: 10px; right: 10px; backdrop-filter: blur(6px); }
.cambody { padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
.camtitle { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--sp-3); }
.camtitle h3 { font-family: var(--font-display); font-size: 16px; font-weight: 600; letter-spacing: -.012em; text-transform: none; color: var(--ink); margin: 0 0 2px; }
.camtitle .note { font-size: 12px; }
.camactions { display: flex; gap: 6px; flex: none; }
.camproof { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--sp-3);
padding: var(--sp-3); border-radius: var(--r); background: var(--s2); border: 1px solid var(--line-2); }
.camproof .note { font-size: 12px; line-height: 1.45; }
@media (max-width: 640px) { .camproof { grid-template-columns: 1fr; } }
.empty.tall { padding: var(--sp-8) var(--sp-6); }
.empty.tall .btn { margin-top: var(--sp-3); }
/* The sheet is a form that reads top to bottom: a sentence saying what is
needed, three short sections, the result of the test, the actions. */
.drawer .sheet { width: min(600px, 100%); }
.sheetbody .lead { color: var(--ink-2); font-size: 13.5px; line-height: 1.55; margin-bottom: var(--sp-5); }
.formsection { margin-bottom: var(--sp-5); }
.formsection h4 { font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 var(--sp-3); padding-bottom: 6px; border-bottom: 1px solid var(--line-2); }
.field .hint { font-style: normal; }
.fieldrow { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.fieldrow .field.narrow { max-width: 140px; }
.fieldrow:has(.field.narrow) { grid-template-columns: minmax(0, 1fr) 140px; }
.field input.mono { font-family: var(--font-mono); font-size: 12.5px; }
.sheetactions { display: flex; gap: var(--sp-2); justify-content: flex-end; padding-top: var(--sp-4); border-top: 1px solid var(--line-2); margin-top: var(--sp-2); }
.testresult { display: flex; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-3) var(--sp-4); border-radius: var(--r); margin-bottom: var(--sp-4); border: 1px solid; font-size: 13px; }
.testresult.ok { background: var(--ok-2); border-color: color-mix(in srgb, var(--ok) 30%, transparent); color: var(--ok); }
.testresult.bad { background: var(--bad-2); border-color: color-mix(in srgb, var(--bad) 30%, transparent); color: var(--bad); }
.testresult b { display: block; }
.testresult span { display: block; color: var(--ink-2); margin-top: 2px; }
.testresult img { width: 100%; margin-top: var(--sp-3); border-radius: var(--r-sm); border: 1px solid var(--line); display: block; }
/* Placement check: two numbered steps, then a verdict box in the tone of the answer. */
.steps { list-style: none; counter-reset: step; margin: 0 0 var(--sp-5); padding: 0; display: grid; gap: var(--sp-3); }
.steps li { counter-increment: step; position: relative; padding: var(--sp-3) var(--sp-4) var(--sp-3) 52px; border-radius: var(--r); border: 1px solid var(--line-2); background: var(--s2); opacity: .55; }
.steps li.now, .steps li.done { opacity: 1; }
.steps li::before { content: counter(step); position: absolute; left: 16px; top: 14px; width: 24px; height: 24px; border-radius: 50%;
display: grid; place-items: center; font-size: 12px; font-weight: 600; background: var(--s3); color: var(--ink-2); border: 1px solid var(--line); }
.steps li.now::before { background: var(--accent); color: #041014; border-color: transparent; }
.steps li.done::before { content: '✓'; background: var(--ok-2); color: var(--ok); }
.steps b { display: block; font-size: 14px; }
.steps span { display: block; font-size: 12.5px; color: var(--ink-2); line-height: 1.5; margin-top: 2px; }
.progress { height: 4px; background: var(--s3); border-radius: 2px; overflow: hidden; margin-top: var(--sp-3); }
.progress > div { height: 100%; background: var(--accent); transition: width .4s linear; }
.verdict { padding: var(--sp-4); border-radius: var(--r); border: 1px solid var(--line); background: var(--s2); }
.verdict.ok { border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: var(--ok-2); }
.verdict.warn { border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: var(--warn-2); }
.verdict.bad { border-color: color-mix(in srgb, var(--bad) 35%, transparent); background: var(--bad-2); }
.verdict-head { display: flex; align-items: center; gap: var(--sp-2); font-size: 15px; }
.verdict.ok .verdict-head { color: var(--ok); } .verdict.warn .verdict-head { color: var(--warn); } .verdict.bad .verdict-head { color: var(--bad); }
.verdict ul { margin: var(--sp-3) 0 0 18px; font-size: 13px; color: var(--ink); line-height: 1.5; }
.verdict ul li { margin-bottom: 5px; }
.verdict .note { margin-top: var(--sp-3); }
.spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--accent); animation: spin .8s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg) } }
/* ============================================================== assistant == */
.helpbtn { display: flex; align-items: center; gap: 9px; margin: 0 var(--sp-3) var(--sp-3); padding: 9px 10px; border-radius: var(--r);
border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; font-size: 12.5px; font-weight: 550; text-align: left; }
.helpbtn img { width: 18px; height: 18px; object-fit: contain; }
.helpbtn span { flex: 1; }
.helpbtn:hover, .helpbtn[aria-pressed="true"] { border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: var(--accent-3); }
.helper { width: 380px; height: 100%; display: flex; flex-direction: column; min-height: 0;
background: var(--s1); border-left: 1px solid var(--line); animation: slidein-r .2s cubic-bezier(.2,.8,.3,1); }
.helperhead { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4); border-bottom: 1px solid var(--line); }
.helperhead img { width: 26px; height: 26px; object-fit: contain; }
.helperhead div { flex: 1; min-width: 0; }
.helperhead b { display: block; font-family: var(--font-display); font-size: 14.5px; }
.helperhead span { display: block; font-size: 11.5px; color: var(--ink-3); margin-top: 1px; }
.helperhead .close { background: none; border: 0; color: var(--ink-3); cursor: pointer; padding: 6px; border-radius: var(--r-sm); display: grid; place-items: center; }
.helperhead .close:hover { background: var(--s2); color: var(--ink); }
.helperbody { flex: 1; overflow: auto; padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-3); }
.helperintro p { color: var(--ink-2); font-size: 13px; line-height: 1.55; margin-bottom: var(--sp-3); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { border: 1px solid var(--line); background: var(--s2); color: var(--ink); border-radius: 99px; padding: 6px 11px; font-size: 12px; cursor: pointer; text-align: left; }
.chip:hover { border-color: var(--accent); color: var(--accent); }
.chip:disabled { opacity: .5; cursor: default; }
.helpernote { display: flex; gap: var(--sp-3); padding: var(--sp-3); border-radius: var(--r); background: var(--warn-2); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); color: var(--warn); font-size: 12.5px; }
.helpernote b { display: block; } .helpernote span { display: block; color: var(--ink-2); margin-top: 2px; line-height: 1.45; }
.turn { display: flex; flex-direction: column; gap: 4px; }
.turn.user { align-items: flex-end; }
.bubble { max-width: 92%; padding: 10px 13px; border-radius: 14px; font-size: 13.5px; line-height: 1.5; white-space: pre-wrap; }
.turn.user .bubble { background: var(--accent); color: #041014; border-bottom-right-radius: 4px; }
.turn.assistant .bubble { background: var(--s2); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
.turn .used { font-size: 11px; color: var(--ink-3); padding: 0 4px; }
.bubble.thinking { display: flex; gap: 4px; padding: 12px 14px; }
.bubble.thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-3); animation: blink 1.2s infinite ease-in-out; }
.bubble.thinking span:nth-child(2) { animation-delay: .2s } .bubble.thinking span:nth-child(3) { animation-delay: .4s }
@keyframes blink { 0%, 80%, 100% { opacity: .25 } 40% { opacity: 1 } }
.helperask { display: flex; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--line); }
.helperask input { flex: 1; min-width: 0; background: var(--s2); border: 1px solid var(--line); border-radius: var(--r); padding: 10px 12px; font-size: 13.5px; color: var(--ink); }
.helperask input:focus { outline: none; border-color: var(--accent); }
.helperask .btn { padding: 0 12px; }
.quickhelp { margin-top: var(--sp-5); border-top: 1px solid var(--line-2); padding-top: var(--sp-4); }
.quickhelp dt { font-size: 12.5px; font-weight: 600; margin-top: var(--sp-3); }
.quickhelp dd { font-size: 12.5px; color: var(--ink-2); line-height: 1.5; margin: 3px 0 0; }
@media (max-width: 1100px) { .helper { position: fixed; right: 0; top: 0; bottom: 0; z-index: 30; box-shadow: var(--shadow-lg); } }
.search { display: flex; align-items: center; gap: 8px; background: var(--s1); border: 1px solid var(--line); border-radius: var(--r); padding: 0 12px; height: 36px; width: min(340px, 100%); color: var(--ink-3); }
.search input { flex: 1; min-width: 0; background: none; border: 0; outline: none; color: var(--ink); font-size: 13.5px; }
.search:focus-within { border-color: var(--accent); }
.sheethead.who { align-items: center; gap: var(--sp-3); }
.sheethead.who .grow { flex: 1; min-width: 0; }
.sheethead.who .note { margin-top: 2px; font-size: 12px; }
.sheethead .close { font-size: 14px; line-height: 1; }
.formsection .field:last-child { margin-bottom: 0; }
.formsection.dangerzone { margin-top: var(--sp-6); padding: var(--sp-4); border: 1px solid color-mix(in srgb, var(--bad) 30%, transparent); border-radius: var(--r); background: var(--bad-2); }
.formsection.dangerzone h4 { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 30%, transparent); }
tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
/* Loya's door: fixed to the top-right of the content area, on every screen. */
.loya-fab { position: fixed; top: var(--sp-4); right: var(--sp-5); z-index: 20;
display: flex; align-items: center; gap: 8px; padding: 8px 14px 8px 10px; border-radius: 99px;
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); background: var(--s1); color: var(--ink);
box-shadow: var(--shadow-lg); cursor: pointer; font-size: 13px; font-weight: 600; }
.loya-fab img { width: 20px; height: 20px; object-fit: contain; }
.loya-fab:hover { background: var(--accent-3); border-color: var(--accent); }
/* Camera finder: the pick-list that replaces "type an IP address". */
.finder { display: flex; flex-direction: column; gap: var(--sp-3); align-items: flex-start; padding: var(--sp-3) var(--sp-4); border: 1px dashed var(--line); border-radius: var(--r); background: var(--s2); }
.finder p { font-size: 13px; color: var(--ink-2); line-height: 1.5; margin: 0; }
.finder.busy { flex-direction: row; align-items: center; color: var(--ink-2); font-size: 13px; border-style: solid; }
.foundlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.foundlist li button { width: 100%; display: flex; align-items: center; gap: var(--sp-3); padding: 10px 12px; border-radius: var(--r); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; text-align: left; font-size: 13px; }
.foundlist li button:hover { border-color: var(--accent); }
.foundlist li.picked button { border-color: var(--accent); background: var(--accent-3); }
.foundlist .mono { font-family: var(--font-mono); font-size: 12.5px; min-width: 120px; }
.foundlist .what { flex: 1; color: var(--ink-2); }
.foundlist li.rescan button { width: auto; border: 0; background: none; padding: 4px 0; color: var(--ink-3); }
/* Welcome: two paths, each a card. */
.login .box.wide { max-width: 640px; }
.choices { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); margin-top: var(--sp-2); }
@media (max-width: 720px) { .choices { grid-template-columns: 1fr; } }
.choice { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; text-align: left; padding: var(--sp-4);
border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--s2); color: var(--ink); cursor: pointer; }
.choice:hover { border-color: var(--accent); background: var(--accent-3); }
.choice b { font-size: 14px; }
.choice span { font-size: 12.5px; color: var(--ink-2); line-height: 1.5; }
.choice em { font-family: var(--font-mono); font-style: normal; font-size: 11.5px; }
.choice svg { color: var(--accent); }
.login .foot em { font-style: normal; color: var(--ink-2); }
.linkbtn { display: inline-flex; align-items: center; gap: 6px; }
/* Getting started */
.starter { margin-bottom: var(--sp-4); }
.starter .panelhead { padding: var(--sp-3) var(--sp-4); }
.starter .steps { margin: 0; padding: var(--sp-3) var(--sp-4) 0; }
.starter .steps.compact li { padding: var(--sp-3) var(--sp-4) var(--sp-3) 52px; }
.starter .steps li .btn { margin-top: var(--sp-2); }
.starter .note { padding: var(--sp-3) var(--sp-4); font-size: 12px; }
.btn.ghost { background: none; border-color: transparent; color: var(--ink-3); }
.btn.ghost:hover { color: var(--ink); }
/* Viewer mode: this PC has no engine, so the screens show the company's own
data from head office. Informational, not an error - it is the ordinary
state of a laptop away from a shop, and styling it red would train people
to ignore the red that means something. */
.viewing {
display: flex; gap: 10px; align-items: flex-start;
padding: 12px 14px; margin-bottom: 14px;
border: 1px solid var(--line); border-radius: 10px;
background: color-mix(in srgb, var(--accent) 7%, transparent);
color: var(--ink-2); font-size: 13px; line-height: 1.5;
}
.viewing b { color: var(--ink); font-weight: 600; }
.viewing svg { flex: none; margin-top: 2px; color: var(--accent); }