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:
@@ -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,159 +24,207 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 className={`metric ${tone ?? ''}`}>
|
||||
<div className="metric-k">{k}</div>
|
||||
<div className="metric-v">{v}</div>
|
||||
{s && <div className="metric-s">{s}</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
|
||||
// MJPEG <img> src restarts the stream, so rebuilding them on every poll would
|
||||
// make every feed flicker permanently.
|
||||
|
||||
Reference in New Issue
Block a user