The shop app looks like a product now, not a prototype

The window a shop assistant stares at all day was the weakest surface in
this system, and it looked improvised because it was: navigation drawn
with text characters (◉ ☺ ▢) that sit on the text baseline and cannot
take a stroke weight, margins set inline per screen, and four large stat
boxes dominating the page while the product's entire reason for existing
- WHO JUST WALKED IN - was a list of "person.seen" rows in the corner.

Rebuilt around the person in front of it: a counter, a cheap monitor,
somebody mid-conversation with a customer.

  - ui/icons.jsx: one drawn icon set, 24-unit grid, 1.6 stroke,
    currentColor, so one icon works on every surface and in every state.
  - styles.css: a real system. Four-step ground→raised palette biased
    blue-green (this product lives in the world of lenses), one spacing
    scale, one type scale, tabular figures wherever digits are compared
    or refreshed in place, and the scrollbars restyled - the default
    light scrollbar on a dark panel is the loudest "web page in a frame"
    tell there is.
  - Live: a status strip that answers "is this working" in one line,
    cameras as pictures with the caption over the image, and arrivals as
    cards big enough to match against the person standing there. The
    four stat boxes became a slim strip at the foot, where numbers that
    nobody acts on belong.
  - State is carried by shape AND colour everywhere - a pill, a dot and
    an edge stripe - because this gets read from two metres away and
    some operators do not see red and green apart.
  - Motion only where it means something: a live camera pulses, a fresh
    arrival slides in once. Nothing loops for decoration; this process
    shares a CPU with recognition.

Two things fixed because the screen showed them, not because a test did:

  - The sidebar read "Stopped" beside a live camera feed and a counter
    ticking up, whenever the engine was running but not started BY the
    app. That is the two-surfaces-disagreeing bug the tray exists to
    avoid. It now reads "Running outside the app" in amber, and Start is
    disabled rather than offering to launch a second engine onto one
    SQLite WAL.
  - The arrivals panel shrank to fit its content and left a hole beside
    a tall camera tile - so the layout looked broken exactly when the
    shop was quiet, which is most of the time. Both panels stretch and
    scroll their own content now.

Every existing class name still resolves, so the screens not rewritten
here pick the system up unchanged. Windows and darwin build; tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-15 11:06:57 +05:30
parent a1fe0942e2
commit 3d3775c8be
11 changed files with 751 additions and 382 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Behavision</title>
<script type="module" crossorigin src="./assets/index-B3NH0cQK.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.css">
<script type="module" crossorigin src="./assets/index-DmoSrGcS.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CGxw2EVn.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { api, isDesktop, message } from './bridge.js'
import { usePolled } from './hooks.js'
import * as Icon from './ui/icons.jsx'
import Login from './views/Login.jsx'
import Setup from './views/Setup.jsx'
import Live from './views/Live.jsx'
@@ -22,9 +23,9 @@ import Cameras from './views/Cameras.jsx'
// the customer record lives on the server, the cameras and what this PC is
// seeing do not.
const VIEWS = [
{ id: 'live', label: 'Live', glyph: '◉', View: Live },
{ id: 'customers', label: 'Customers', glyph: '☺', View: Customers, cloud: true },
{ id: 'cameras', label: 'Cameras', glyph: '▢', View: Cameras },
{ id: 'live', label: 'Live', Glyph: Icon.Live, View: Live },
{ id: 'customers', label: 'Customers', Glyph: Icon.People, View: Customers, cloud: true },
{ id: 'cameras', label: 'Cameras', Glyph: Icon.Camera, View: Cameras },
]
export default function App() {
@@ -48,6 +49,7 @@ export default function App() {
// error nobody will read.
return (
<div className="login"><div className="box">
<span className="mark"><Icon.Shield size={20} /></span>
<h1>Behavision</h1>
<p className="lead">
This is the Behavision window running outside the app, so it has no
@@ -78,36 +80,41 @@ export default function App() {
<div className="shell">
<aside className="side">
<div className="brand">
<span className="mark"><Icon.Shield size={17} /></span>
<div className="id">
<h1>Behavision</h1>
<p>{session.site_name || session.user?.client_name || 'Store'}</p>
<p>{session.site_name || session.user?.client_name || 'This shop'}</p>
</div>
</div>
<nav className="nav">
{views.map(v => (
<button key={v.id} onClick={() => setView(v.id)}
aria-current={v.id === view ? 'page' : undefined}>
<span className="glyph">{v.glyph}</span>{v.label}
{views.map(({ id, label, Glyph }) => (
<button key={id} onClick={() => setView(id)}
aria-current={id === view ? 'page' : undefined}>
<Glyph size={17} />{label}
</button>
))}
</nav>
<EngineBox />
<div style={{ padding: '10px 12px 14px', borderTop: '1px solid var(--line-soft)' }}>
<div className="who">
{session.standalone
? <>
<div className="note" style={{ marginBottom: 8 }}>
Running on its own
<div className="id">
<b>On its own</b>
<span>No head office</span>
</div>
<button className="btn sm" style={{ width: '100%' }}
<button className="btn sm icon" title="Link to head office"
onClick={() => setLinking(true)}>
Link to head office
<Icon.Link size={15} />
</button>
</>
: <>
<div className="note" style={{ marginBottom: 8 }}>
{session.user?.email}
<div className="id">
<b>Signed in</b>
<span>{session.user?.email}</span>
</div>
<button className="btn sm" style={{ width: '100%' }}
<button className="btn sm icon" title="Sign out"
onClick={async () => setSession(await api.logout())}>
Sign out
<Icon.Logout size={15} />
</button>
</>}
</div>
@@ -136,7 +143,12 @@ function EngineBox() {
const up = cams.filter(Boolean).length
let tone = 'idle', text = 'Stopped'
if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
// Reachable but not ours: somebody started the engine outside this app, or a
// previous copy is still up. Saying "Stopped" beside live camera feeds is the
// two-surfaces-disagreeing bug the tray exists to avoid - and it is exactly
// what this panel showed while recognition was visibly running.
if (!running && s.reachable) { tone = 'warn'; text = 'Running outside the app' }
else if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }
@@ -145,16 +157,21 @@ function EngineBox() {
return (
<div className="enginebox">
<div className="row"><i className={`dot ${tone}`} /><strong>{text}</strong></div>
{s.recognition_model && (
<span className="label">Model: {s.recognition_model}</span>
)}
<div className="row">
<i className={`dot ${tone === 'ok' ? 'live' : tone}`} />
<span className="state">{text}</span>
</div>
{s.recognition_model && <span className="label">{s.recognition_model}</span>}
{s.error && <span className="label" style={{ color: 'var(--bad)' }}>{s.error}</span>}
<div className="actions">
<button className="btn sm" disabled={busy || running}
onClick={() => act(api.startEngine)}>Start</button>
<button className="btn sm" disabled={busy || running || s.reachable}
onClick={() => act(api.startEngine)}>
<Icon.Play size={13} />Start
</button>
<button className="btn sm" disabled={busy || !running}
onClick={() => act(api.stopEngine)}>Stop</button>
onClick={() => act(api.stopEngine)}>
<Icon.Stop size={13} />Stop
</button>
</div>
</div>
)

View File

@@ -1,252 +1,471 @@
/* Behavision desktop — an instrument panel, not a website.
A shop PC runs this all day on a cheap monitor, so: high contrast, dense
but not cramped, and state readable at a glance from across a counter. */
/* 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: #0E1317;
--surface: #161D23;
--surface-2: #1D262D;
--line: #27333B;
--line-soft: #1F2A31;
--ink: #E7EEF3;
--ink-2: #B4C2CC;
--muted: #7C8B97;
--accent: #45B0C7;
--accent-dim:#123039;
--ok: #4FB37B;
--warn: #E0A33A;
--bad: #E0655A;
--radius: 8px;
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
/* 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(--ground);
background: var(--bg);
color: var(--ink);
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
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; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---------------------------------------------------------------- shell -- */
.shell { display: grid; grid-template-columns: 216px 1fr; height: 100%; }
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; height: 100%; }
.side {
background: var(--surface); border-right: 1px solid var(--line);
background: var(--s1); border-right: 1px solid var(--line);
display: flex; flex-direction: column; min-height: 0;
}
.side .brand {
padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft);
.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; border-radius: 9px; flex: none; display: grid; place-items: center;
background: linear-gradient(160deg, var(--accent-2), var(--s2));
border: 1px solid #1B4C5A; color: var(--accent);
}
.side .brand h1 { font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
.side .brand p { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
.nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
.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 {
display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: 0; border-radius: 6px; padding: 8px 10px;
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px;
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:hover { background: var(--surface-2); color: var(--ink); }
.nav button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
.nav .glyph { width: 16px; text-align: center; opacity: .85; font-size: 13px; }
.enginebox { padding: 12px; border-top: 1px solid var(--line-soft); }
.enginebox .row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.enginebox .label { color: var(--muted); font-size: 11px; margin-top: 2px;
display: block; line-height: 1.4; }
.enginebox .actions { display: flex; gap: 6px; margin-top: 10px; }
.main { min-width: 0; min-height: 0; overflow-y: auto; }
.page { padding: 22px 26px 40px; max-width: 1180px; }
.page > header { margin-bottom: 18px; }
.page h2 { font-size: 19px; font-weight: 620; letter-spacing: -.01em; }
.page header p { color: var(--muted); font-size: 13px; margin-top: 3px; }
/* --------------------------------------------------------------- pieces -- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 16px;
.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);
}
.card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); font-weight: 600; margin-bottom: 12px; }
.grid { display: grid; gap: 14px; }
.cols-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
font-variant-numeric: tabular-nums; line-height: 1.1; }
.stat .unit { font-size: 15px; color: var(--muted); margin-left: 3px; }
.stat .sub { color: var(--muted); font-size: 12px; margin-top: 5px; }
/* 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; }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.bad { background: var(--bad); }
.dot.idle { background: var(--muted); }
.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; }
.pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line);
color: var(--muted); white-space: nowrap; }
.pill.ok { color: var(--ok); border-color: #2b5c42; background: #12251b; }
.pill.warn { color: var(--warn); border-color: #5c4a22; background: #241d0f; }
.pill.bad { color: var(--bad); border-color: #5c2e2a; background: #241312; }
.main { min-width: 0; min-height: 0; overflow: auto; }
/* ================================================================= 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 {
background: var(--surface-2); border: 1px solid var(--line);
border-radius: 6px; padding: 7px 13px; cursor: pointer; font-size: 13px;
color: var(--ink); white-space: nowrap;
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: #26323a; }
.btn:disabled { opacity: .45; cursor: default; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #06222a;
font-weight: 600; }
.btn.primary:hover:not(:disabled) { background: #5ac0d6; }
.btn.danger { color: var(--bad); border-color: #4a2823; }
.btn.sm { padding: 4px 9px; font-size: 12px; }
.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; }
.field { display: block; margin-bottom: 12px; }
.field span { display: block; font-size: 11.5px; color: var(--muted);
margin-bottom: 4px; letter-spacing: .01em; }
.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%; background: var(--ground); border: 1px solid var(--line);
border-radius: 6px; padding: 8px 10px; font-size: 13.5px;
user-select: text;
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 {
border-color: var(--accent); outline: none;
outline: none; border-color: var(--accent); background: var(--s1); box-shadow: 0 0 0 3px var(--accent-3);
}
.field textarea { resize: vertical; min-height: 66px; }
.fieldrow { display: grid; gap: 0 12px; grid-template-columns: 1fr 1fr; }
.field .hint { display: block; font-size: 11.5px; color: var(--ink-3); margin-top: 5px; line-height: 1.45; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; font-size: 10.5px; text-transform: uppercase;
letter-spacing: .08em; color: var(--muted); font-weight: 600;
padding: 8px 10px; border-bottom: 1px solid var(--line); }
td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
tr:last-child td { border-bottom: 0; }
tbody tr.click { cursor: pointer; }
tbody tr.click:hover { background: var(--surface-2); }
td.num { font-variant-numeric: tabular-nums; text-align: right; }
.tablewrap { overflow-x: auto; }
.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)); }
.empty { color: var(--muted); font-size: 13px; padding: 26px 4px; text-align: center; }
.err {
border: 1px solid #5c2e2a; background: #241312; color: #f0b3ad;
border-radius: 6px; padding: 10px 12px; font-size: 13px; margin-bottom: 14px;
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);
}
.note { color: var(--muted); font-size: 12.5px; }
.mono { font-family: var(--mono); font-size: 12px; }
.err svg { flex: none; margin-top: 1px; }
/* --------------------------------------------------------------- login --- */
.login { height: 100%; display: grid; place-items: center; padding: 24px; }
.login .box { width: 100%; max-width: 380px; }
.login h1 { font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
.login .lead { color: var(--muted); font-size: 13px; margin: 6px 0 22px; }
.login form { background: var(--surface); border: 1px solid var(--line);
border-radius: 10px; padding: 20px; }
.login .btn { width: 100%; margin-top: 6px; }
.login .foot { color: var(--muted); font-size: 11.5px; margin-top: 14px;
text-align: center; line-height: 1.5; }
/* The second way out of the setup screen: a shop with no head office. Styled
quieter than the form above it because linking is still the common case,
but present, because for a single-till shop it is the only one that works. */
.login .alt { margin-top: 18px; padding-top: 16px; text-align: center;
border-top: 1px solid var(--line-soft); }
.login .alt .note { line-height: 1.55; margin-bottom: 12px; text-align: left; }
.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: 38px; height: 38px; border-radius: 11px; margin-bottom: var(--sp-4);
display: grid; place-items: center; color: var(--accent);
background: linear-gradient(160deg, var(--accent-2), var(--s2)); border: 1px solid #1B4C5A;
}
.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; }
.linkbtn { background: none; border: 0; padding: 0; cursor: pointer;
font: inherit; font-size: 12.5px; color: var(--accent);
text-decoration: underline; text-underline-offset: 3px; }
.linkbtn:hover { color: var(--ink); }
.login .note { margin: 0; }
/* ---------------------------------------------------------------- live --- */
.feeds { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.feed { background: #000; border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden; }
.feed img { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; background: #000; }
.feed .cap { display: flex; justify-content: space-between; align-items: center;
padding: 8px 11px; background: var(--surface); font-size: 12.5px; }
/* =============================================================== drawer == */
.events { list-style: none; max-height: 420px; overflow-y: auto; }
.events li { display: flex; gap: 9px; align-items: baseline;
padding: 7px 2px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.events li:last-child { border-bottom: 0; }
.events .when { color: var(--muted); font-family: var(--mono); font-size: 11px;
flex: none; }
.tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; flex: none;
background: var(--surface-2); color: var(--muted); }
.tag.new { background: #17364f; color: #86c2ec; }
.tag.seen { background: #14301f; color: #7fcb9c; }
.tag.miss { background: #3a1c1a; color: #eb9a92; }
.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); }
/* -------------------------------------------------------------- charts --- */
.bars { display: flex; align-items: flex-end; gap: 3px; height: 150px; margin-top: 4px; }
.bars .col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end;
gap: 2px; min-width: 0; }
.bars .seg { border-radius: 2px 2px 0 0; }
.bars .seg.ret { background: var(--accent); }
.bars .seg.new { background: #2f6f81; }
.axis { display: flex; justify-content: space-between; color: var(--muted);
font-size: 10.5px; margin-top: 6px; font-family: var(--mono); }
.key { display: flex; gap: 14px; font-size: 11.5px; color: var(--muted); margin-top: 10px; }
.key i { display: inline-block; width: 9px; height: 9px; border-radius: 2px;
margin-right: 5px; vertical-align: -1px; }
.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; }
/* --------------------------------------------------------------- drawer -- */
.drawer { position: fixed; inset: 0; background: rgba(4,8,10,.6);
display: flex; justify-content: flex-end; z-index: 30; }
.drawer .panel { width: min(480px, 100%); height: 100%; background: var(--surface);
border-left: 1px solid var(--line); overflow-y: auto; padding: 20px 22px 40px; }
.drawer h3 { font-size: 16px; font-weight: 620; text-transform: none;
letter-spacing: -.01em; color: var(--ink); margin-bottom: 2px; }
/* Close lives in the sticky header (.who) now. Positioned against the fixed
overlay it stayed put while the sheet scrolled underneath it, printing the
button on top of whatever happened to be at the top of the viewport. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
/* -- customer record ---------------------------------------------------- */
/* Full-bleed sticky header: a customer record is long enough to scroll, and
both the name and the way out have to stay reachable. The negative margins
cancel the panel's padding so the background covers the full width. */
.who { position: sticky; top: -20px; z-index: 1; display: flex; gap: 14px;
align-items: flex-start; background: var(--surface);
margin: -20px -22px 18px; padding: 20px 22px 14px;
border-bottom: 1px solid var(--line-soft); }
.who .grow { flex: 1; min-width: 0; }
.who h3 { margin-bottom: 2px; }
.avatar { width: 64px; height: 64px; border-radius: 10px; flex: none;
object-fit: cover; background: var(--ground);
border: 1px solid var(--line); }
.avatar.none { display: grid; place-items: center; color: var(--muted);
font-size: 20px; font-weight: 600; letter-spacing: .02em; }
.timeline { list-style: none; max-height: 220px; overflow-y: auto; }
.timeline li { display: flex; gap: 10px; align-items: baseline; padding: 6px 0;
border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.timeline li:last-child { border-bottom: 0; }
.timeline .when { font-family: var(--mono); font-size: 11px; color: var(--muted);
flex: none; min-width: 108px; }
.timeline .where { flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
/* Visually separated from Save: this is the one control in the sheet that
cannot be undone, and it must not read as just another button in a row. */
.danger-zone { margin-top: 22px; border-color: #4a2823; }
.danger-zone > h3 { color: var(--bad); }
.danger-zone .note { margin-bottom: 10px; }
.confirm h4 { font-size: 13.5px; font-weight: 620; margin-bottom: 10px; }
.confirm .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
margin-bottom: 12px; }
@media (max-width: 560px) { .confirm .cols { grid-template-columns: 1fr; } }
.confirm .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); margin-bottom: 5px; }
.confirm .lbl.bad { color: var(--bad); }
.confirm ul { list-style: none; font-size: 12.5px; }
.confirm li { padding: 3px 0 3px 12px; position: relative; color: var(--ink); }
.confirm li::before { content: '·'; position: absolute; left: 2px;
color: var(--muted); }
.confirm .row { display: flex; gap: 8px; }
/* 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; }
}

View File

@@ -0,0 +1,71 @@
// One icon set, drawn rather than typed.
//
// The navigation used to be text characters — ◉ ☺ ▢ — which render in whatever
// the system decides, sit on the text baseline instead of optical centre, and
// cannot take a stroke weight. On a shop PC that is the difference between
// software somebody trusts with their customers and something that looks
// improvised.
//
// All of these are 24-unit grid, 1.6 stroke, currentColor, no fill. That means
// one icon works on every surface and in every state without a second copy.
const base = {
width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none',
stroke: 'currentColor', strokeWidth: 1.6,
strokeLinecap: 'round', strokeLinejoin: 'round',
'aria-hidden': 'true', focusable: 'false',
}
function Svg({ size, children, ...rest }) {
return <svg {...base} {...rest} width={size ?? base.width} height={size ?? base.height}>{children}</svg>
}
export const Live = p => (
<Svg {...p}><circle cx="12" cy="12" r="3.2" /><path d="M5.6 5.6a9 9 0 0 0 0 12.8M18.4 18.4a9 9 0 0 0 0-12.8" /></Svg>
)
export const People = p => (
<Svg {...p}><circle cx="9" cy="8.5" r="3.2" /><path d="M2.8 19.5a6.4 6.4 0 0 1 12.4 0" /><path d="M16.5 6.2a3.2 3.2 0 0 1 0 6.1M18 19.5a6 6 0 0 0-1.6-4" /></Svg>
)
export const Camera = p => (
<Svg {...p}><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /></Svg>
)
export const Search = p => (
<Svg {...p}><circle cx="11" cy="11" r="6.4" /><path d="M15.8 15.8 20.5 20.5" /></Svg>
)
export const Plus = p => (<Svg {...p}><path d="M12 5.5v13M5.5 12h13" /></Svg>)
export const Close = p => (<Svg {...p}><path d="M6.5 6.5l11 11M17.5 6.5l-11 11" /></Svg>)
export const Check = p => (<Svg {...p}><path d="M5 12.8l4.4 4.2L19 7" /></Svg>)
export const Play = p => (<Svg {...p}><path d="M8 5.6v12.8L18.5 12z" /></Svg>)
export const Stop = p => (<Svg {...p}><rect x="7" y="7" width="10" height="10" rx="1.6" /></Svg>)
export const Warning = p => (
<Svg {...p}><path d="M12 4.6 21 19.4H3z" /><path d="M12 10v4.1" /><path d="M12 17.1v.01" /></Svg>
)
export const Signal = p => (
<Svg {...p}><path d="M5 19.4v-4.2M10.3 19.4v-7.6M15.7 19.4v-11M21 19.4V4.6" /></Svg>
)
export const Cloud = p => (
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 .3 9.4z" /></Svg>
)
export const CloudOff = p => (
<Svg {...p}><path d="M7.2 18.4a4.2 4.2 0 0 1-.6-8.35 6.2 6.2 0 0 1 2-3.2M10.6 5.2A6.2 6.2 0 0 1 18.4 9a4.2 4.2 0 0 1 1.9 7.6" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
)
export const Shield = p => (
<Svg {...p}><path d="M12 3.8 19.4 6.6v5.2c0 4.2-3 7.4-7.4 8.4-4.4-1-7.4-4.2-7.4-8.4V6.6z" /></Svg>
)
export const Link = p => (
<Svg {...p}><path d="M10.2 13.8a3.6 3.6 0 0 0 5.2 0l2.8-2.8a3.7 3.7 0 0 0-5.2-5.2l-1.3 1.3" /><path d="M13.8 10.2a3.6 3.6 0 0 0-5.2 0l-2.8 2.8a3.7 3.7 0 0 0 5.2 5.2l1.3-1.3" /></Svg>
)
export const Logout = p => (
<Svg {...p}><path d="M14.4 7.6V5.4H4.6v13.2h9.8v-2.2" /><path d="M10 12h9.4M16.4 8.8 19.8 12l-3.4 3.2" /></Svg>
)
export const Back = p => (<Svg {...p}><path d="M14.6 5.6 8 12l6.6 6.4" /></Svg>)
export const Chevron = p => (<Svg {...p}><path d="M9.4 5.6 16 12l-6.6 6.4" /></Svg>)
export const Dot = p => (<Svg {...p}><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none" /></Svg>)
// Drawn for the empty states rather than an apologetic sentence in grey.
export const NoCamera = p => (
<Svg {...p} strokeWidth="1.2"><path d="M3 8.5h3.4L8 6h8l1.6 2.5H21v10.2H3z" /><circle cx="12" cy="13.2" r="3.1" /><path d="M3.6 3.6l16.8 16.8" /></Svg>
)
export const NoFaces = p => (
<Svg {...p} strokeWidth="1.2"><circle cx="12" cy="9" r="3.4" /><path d="M5.4 20a6.8 6.8 0 0 1 13.2 0" /></Svg>
)

View File

@@ -1,9 +1,19 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, fmtTime } from '../hooks.js'
import * as Icon from '../ui/icons.jsx'
// What is happening right now. The first screen a shop manager opens, so it
// answers "is it working" before it answers anything else.
// The shop floor screen.
//
// Rebuilt around what somebody standing at the counter is actually here for:
// WHO JUST WALKED IN. The previous version led with four large stat boxes and
// left arrivals as a thin list of "person.seen" rows in the corner — the least
// actionable content taking the most space, and the product's whole reason for
// existing rendered as a log.
//
// Now: a status strip that answers "is this working" in one line, cameras as
// pictures, and arrivals as cards big enough to recognise a customer from
// while looking up at them.
export default function Live() {
const { data, error } = usePolled(() => api.live(), 3000)
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
@@ -14,157 +24,205 @@ export default function Live() {
const events = data?.events ?? []
// fraction_below_gate is the number that decides a site: what share of the
// faces this camera saw were too poor to enrol. Surfaced here rather than
// buried, because a high value looks exactly like "a quiet day".
// faces this camera saw were too poor to enrol. Surfaced rather than buried,
// because a high value looks exactly like "a quiet day".
const worst = cameras.reduce((acc, c) => {
const f = c?.pipeline?.best_quality?.fraction_below_gate
return typeof f === 'number' && f > acc ? f : acc
}, 0)
const up = cameras.filter(c => c.connected).length
const arrivals = events.filter(e => e.type === 'person.new' || e.type === 'person.seen')
const freshest = useFreshest(arrivals[0])
return (
<div className="page">
<header>
<h2>Live</h2>
<p>Cameras, recent detections, and whether this site is recognising people.</p>
<p>Who is in the shop, what the cameras see, and whether any of it is reaching head office.</p>
</header>
{error && <div className="err">{error}</div>}
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="People known" value={gallery.identities ?? '—'} />
<Stat label="Sightings" value={gallery.sightings ?? '—'} />
<Stat label="Cameras live"
value={`${cameras.filter(c => c.connected).length}/${cameras.length || 0}`} />
<Stat label="Below quality gate"
value={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
sub={worst > 0.5 ? 'Most visitors are being missed — check camera placement'
: 'Share of faces too poor to enrol'} />
</div>
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
<Pipeline pipe={pipe} />
<div className="grid cols-2">
<div>
<div className="card">
<div className="live-split">
<div className="panel">
<div className="panelhead">
<h3>Cameras</h3>
{cameras.length > 0 && (
<span className="pill">{up} of {cameras.length} live</span>
)}
</div>
<div className="panelbody flush">
{cameras.length === 0
? <div className="empty">No cameras yet. Add one in Cameras.</div>
? <div className="empty">
<Icon.NoCamera size={34} />
<b>No cameras yet</b>
<p>Add one on the Cameras screen. You will need its address on the shop network and its password.</p>
</div>
: <div className="feeds">
{cameras.map(c => (
<div className="feed" key={c.camera_id}>
<figure className="feed" key={c.camera_id}>
{cams[c.camera_id]
? <img src={cams[c.camera_id]} alt={c.camera_id} />
: <div style={{ aspectRatio: '16/9' }} />}
<div className="cap">
? <img src={cams[c.camera_id]} alt={`Live view from ${c.camera_id}`} />
: <div className="placeholder"><Icon.Camera size={26} /></div>}
<figcaption className="cap">
<span>{c.camera_id}</span>
<span className={`pill ${c.connected ? 'ok' : 'bad'}`}>
<i className={`dot ${c.connected ? 'ok' : 'bad'}`} />
<i className={`dot ${c.connected ? 'live' : 'bad'}`} />
{c.connected ? 'live' : 'offline'}
</span>
</div>
</div>
</figcaption>
</figure>
))}
</div>}
</div>
</div>
<div className="card">
<h3>Recent detections</h3>
{events.length === 0
? <div className="empty">Nothing detected yet.</div>
: <ul className="events">
{events.map((e, i) => <EventRow key={i} e={e} />)}
</ul>}
<div className="panel">
<div className="panelhead">
<h3>Who just walked in</h3>
{arrivals.length > 0 && <span className="note">{arrivals.length} today</span>}
</div>
<div className="panelbody flush">
{arrivals.length === 0
? <div className="empty">
<Icon.NoFaces size={34} />
<b>Nobody yet</b>
<p>Customers appear here the moment a camera recognises a face.</p>
</div>
: <div className="arrivals">
{arrivals.slice(0, 12).map((e, i) => (
<Arrival key={`${e.ts}-${i}`} e={e} fresh={i === 0 && freshest} />
))}
</div>}
</div>
</div>
</div>
<div className="metrics" style={{ marginTop: 'var(--sp-4)' }}>
<Metric k="People known" v={gallery.identities ?? '—'} />
<Metric k="Sightings" v={gallery.sightings ?? '—'} />
<Metric k="Cameras live" v={cameras.length ? `${up}/${cameras.length}` : '—'}
tone={!cameras.length ? null : up === 0 ? 'bad' : up < cameras.length ? 'warn' : 'ok'} />
<Metric k="Faces too poor to use"
v={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
s={worst > 0.5 ? 'Most visitors are being missed — move the camera'
: 'Share of faces below the enrolment gate'} />
</div>
</div>
)
}
// Whether anything is actually reaching head office. Without this the app can
// look perfectly healthy while every detection piles up on disk unsent — which
// is exactly what it did before the bridge existed.
function Pipeline({ pipe }) {
// One customer, big enough to match against the person in front of you.
function Arrival({ e, fresh }) {
const isNew = e.type === 'person.new'
const name = e.data?.label || 'Unrecognised'
const bits = [e.data?.gender, e.data?.age ?? e.data?.age_range, e.data?.emotion].filter(Boolean)
const sim = typeof e.data?.similarity === 'number' ? e.data.similarity : null
return (
<article className={`arrival ${isNew ? 'is-new' : 'is-seen'} ${fresh ? 'fresh' : ''}`}>
<div className="avatar">{avatarText(name)}</div>
<div className="who">
<div className="name">{name}</div>
<div className="meta">
{e.camera_id}
{bits.length > 0 && <> · {bits.join(', ')}</>}
{sim !== null && !isNew && <> · match {sim.toFixed(2)}</>}
</div>
</div>
<div className="right">
<span className={`tag ${isNew ? 'new' : 'seen'}`}>{isNew ? 'new' : 'returning'}</span>
<span className="when">{fmtTime(e.ts)}</span>
</div>
</article>
)
}
// "Visitor 13" must show 13, not V1 — initials() would give the same two
// characters to Visitor 10, 13 and 15, and read as the reference V-1 for a
// fourth person. Found by looking at the screen, not by a test.
function avatarText(name) {
const auto = /^Visitor (\d+)$/.exec(String(name).trim())
if (auto) return auto[1]
const words = String(name).trim().split(/\s+/).filter(Boolean)
if (!words.length) return '?'
return (words[0][0] + (words[1]?.[0] ?? '')).toUpperCase()
}
// One line, above everything, answering the question every other screen is a
// detail of: is this shop working, and is anything leaving it.
function PipelineStrip({ pipe, cameras, up }) {
if (!pipe) return null
// A PC set up on its own is not "not linked yet" — nothing is coming, and
// saying so with an idle dot beside a count of zero reads as a fault.
// A PC set up on its own is not "not linked yet" — nothing is coming, and an
// idle dot beside a count of zero reads as a fault.
if (pipe.standalone) {
return (
<div className="card" style={{ marginBottom: 16, display: 'flex',
gap: 10, alignItems: 'center' }}>
<i className="dot ok" />
<strong style={{ fontSize: 13 }}>Running on this PC only</strong>
<span className="note">Recognition and customers stay here.</span>
<div className="statusbar">
<span className="item"><i className="dot ok" /><b>Running on this PC only</b></span>
<span className="sep" />
<span className="item note">Recognition and customers stay here.</span>
<span className="grow" />
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
</div>
)
}
const stuck = pipe.claimed && !pipe.broker_up
const tone = !pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'
const text = !pipe.claimed ? 'Not linked to head office'
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'
return (
<div className="card" style={{ marginBottom: 16, display: 'flex',
gap: 22, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<i className={`dot ${!pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'}`} />
<strong style={{ fontSize: 13 }}>
{!pipe.claimed ? 'Not linked to head office'
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'}
</strong>
<div className="statusbar">
<span className="item">
{pipe.broker_up ? <Icon.Cloud size={15} /> : <Icon.CloudOff size={15} />}
<i className={`dot ${tone}`} /><b>{text}</b>
</span>
<span className="note">{pipe.accepted} recorded today</span>
<span className="sep" />
<span className="item note">{pipe.accepted} recorded today</span>
{pipe.queued > 0 && (
<span className="note" style={stuck ? { color: 'var(--warn)' } : undefined}>
{pipe.queued} waiting to send
</span>
<span className={`item note ${stuck ? 'warn' : ''}`}>{pipe.queued} waiting to send</span>
)}
{pipe.dropped > 0 && (
<span className="note" style={{ color: 'var(--bad)' }}>
{pipe.dropped} lost — this PC was offline too long
</span>
<span className="item note bad"><Icon.Warning size={14} />{pipe.dropped} lost — this PC was offline too long</span>
)}
<span className="grow" />
<span className="item note"><Icon.Signal size={14} />{up} of {cameras.length} cameras</span>
</div>
)
}
function EventRow({ e }) {
const cls = e.type === 'person.new' ? 'new'
: e.type === 'person.seen' ? 'seen'
: e.type === 'person.missed' ? 'miss' : ''
const age = e.data?.age ?? e.data?.age_range
const extra = [e.data?.gender, age, e.data?.emotion].filter(Boolean).join(', ')
function Metric({ k, v, s, tone }) {
return (
<li>
<span className="when">{fmtTime(e.ts)}</span>
<span className={`tag ${cls}`}>{label(e.type)}</span>
<span style={{ flex: 1, minWidth: 0 }}>
{e.data?.label || e.camera_id}
{extra && <span className="note"> · {extra}</span>}
</span>
</li>
<div className={`metric ${tone ?? ''}`}>
<div className="metric-k">{k}</div>
<div className="metric-v">{v}</div>
{s && <div className="metric-s">{s}</div>}
</div>
)
}
// The event names are internal; a shop manager should not have to learn them.
function label(type) {
return {
'person.new': 'new',
'person.seen': 'returning',
'person.missed': 'missed',
'camera.up': 'camera up',
'camera.down': 'camera down',
'identity.merged': 'merged',
}[type] ?? type
}
function Stat({ label, value, sub, tone }) {
return (
<div className="card stat">
<h3>{label}</h3>
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>
{value}
</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
// True for a few seconds after a genuinely new arrival, so the top card can
// announce itself once. Keyed on the timestamp rather than the array, which
// changes identity on every poll.
function useFreshest(top) {
const [fresh, setFresh] = useState(false)
const seen = useRef(null)
useEffect(() => {
if (!top || top.ts === seen.current) return
const first = seen.current === null
seen.current = top.ts
if (first) return // do not flash the whole list on mount
setFresh(true)
const id = setTimeout(() => setFresh(false), 1200)
return () => clearTimeout(id)
}, [top?.ts])
return fresh
}
// Stream URLs are fetched once per camera and then left alone: reassigning an

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
import * as Icon from '../ui/icons.jsx'
// The gate. Nothing else in the app is reachable until this succeeds, because
// the broker credentials and the customer database both live behind it.
@@ -24,10 +25,11 @@ export default function Login({ onDone }) {
return (
<div className="login">
<div className="box">
<span className="mark"><Icon.Shield size={20} /></span>
<h1>Behavision</h1>
<p className="lead">Sign in to connect this PC to your store.</p>
<form onSubmit={submit}>
{error && <div className="err">{error}</div>}
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
<label className="field">
<span>Email</span>
<input type="email" value={email} autoComplete="username" required

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
import * as Icon from '../ui/icons.jsx'
// Linking this PC to a shop — the first thing that happens on a new install,
// and until now the one thing the app could not do.
@@ -41,12 +42,13 @@ export default function Setup({ onDone, onCancel }) {
return (
<div className="login">
<div className="box">
<span className="mark"><Icon.Link size={20} /></span>
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
<p className="lead">
Type the installation code for this shop. You only do this once.
</p>
<form onSubmit={submit}>
{error && <div className="err">{error}</div>}
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
<label className="field">
<span>Installation code</span>
{/* Uppercase and letter-spaced because the code arrives read aloud