- {it.isChat ? (
-
-
-
-
-
- WhatsApp
-
- ) : (
+
{it.what}
- )}
- {it.count > 1 ? {it.count} messages : null}
- {it.stage && !it.isChat ? → {it.stage.name} : null}
-
- {/* The worker, as a face. Recognition down a column beats
- reading five names to notice it was one agent throughout. */}
-
- {it.kind === 'sys'
- ?
system
- : it.isChat && !it.agentKey
- ?
the customer
- :
}
-
{it.when}
- {it.isChat && onOpenChat ? (
-
- Open thread
-
- ) : null}
- {it.hasCall && onOpenCall ? (
-
- Hear the call
-
- ) : null}
+ {it.count > 1 ?
{it.count} messages : null}
+ {it.stage && !it.isChat ? (
+
{it.stage.name}
+ ) : null}
+
+
{it.when.rel} · {it.when.abs}
- {/* What was received, named and openable. The count is stated
- rather than left to be inferred from a list — "3 documents
- received" is the sentence the operator is looking for. */}
+
+ {it.kind === 'sys'
+ ? platform
+ : it.isChat && !it.agentKey
+ ? the customer
+ : {it.actor}{it.agentKey ? AI : null} }
+
+
+ {/* The finding — the one sentence that answers "what did it
+ conclude" — as the entry's body. */}
+ {it.finding ? (
+
+ {mdRuns(it.finding).map((r, j) => (r.b ? {r.t} : {r.t} ))}
+
+ ) : null}
+
+ {/* A human step's reason, and an AI step whose reasoning has
+ no clean first sentence, stay as a quote. */}
+ {it.kind !== 'ai'
+ ? it.reasoning.map(([label, v]) => (
+
+ {label}
+
+
+ ))
+ : null}
+
+ {it.conversation.map(([label, v]) => (
+
+ {label}
+
+
+ ))}
+
{it.docs.length ? (
+
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
@@ -419,12 +331,6 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
rel="noreferrer"
title={d.original_name}
>
-
-
-
-
{d.slot}
))}
@@ -432,39 +338,6 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
) : null}
- {/* THE CHAIN OF THOUGHT. For an AI step, the reasoning is not a
- loose quote — it is how the employee reached the decision, so
- it renders as the chain: what it checked, what it reasoned,
- what it decided. This is the answer to "why did it do that",
- which is the first thing a person needs before trusting or
- overriding a machine. */}
- {it.kind === 'ai' && it.reasoning.length ? (
-
- ) : null}
-
- {/* A human step's reason, and the AI step's reasoning when it is
- not the story's actor (rare), stay as a plain quote. */}
- {it.kind !== 'ai'
- ? it.reasoning.map(([label, v]) => (
-
- {label}
-
-
- ))
- : null}
-
- {/* What was said to and by the customer — always a plain quote. */}
- {it.conversation.map(([label, v]) => (
-
- {label}
-
-
- ))}
-
{it.figures.length ? (
{it.figures.map(([k, v]) => (
@@ -473,23 +346,55 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
) : null}
- {/* Folded, because most steps write a lot and the trail has to
- stay readable as a story. Open it and the step becomes an
- itemised account of what it decided. */}
- {it.wrote.length ? (
-
- {it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written
-
- {it.wrote.map(([k, v]) => (
-
{k} {v}
- ))}
-
-
+ {/* ONE FOOTER ROW: the chain of thought, what was written, the
+ call, the thread. These used to stack as three rows of blue
+ links under every entry. */}
+ {(it.kind === 'ai' && it.reasoning.length) || it.wrote.length || (it.isChat && onOpenChat) || (it.hasCall && onOpenCall) ? (
+
+ {it.kind === 'ai' && it.reasoning.length ? (
+
+ ) : null}
+ {it.wrote.length ? (
+
+
+
+ {it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written
+
+
+
+ {it.wrote.map(([k, v]) => (
+
{k} {v}
+ ))}
+
+
+ ) : null}
+ {it.isChat && onOpenChat ? (
+
+ Open thread
+
+ ) : null}
+ {it.hasCall && onOpenCall ? (
+
+ Read the call
+
+ ) : null}
+
) : null}
))}
+
+ {sysCount ? (
+
setShowSys((v) => !v)}>
+ {showSys ? 'Hide' : 'Show'} {sysCount} platform update{sysCount === 1 ? '' : 's'}
+
+ ) : null}
>
)
}
diff --git a/src/index.css b/src/index.css
index 782fac4..6c0680f 100644
--- a/src/index.css
+++ b/src/index.css
@@ -56,6 +56,23 @@
--zk-danger-tint: #fdeceb;
--zk-danger-line: #f0c2bd;
+ /* ── Surfaces ───────────────────────────────────────────────
+ One ground, one card. The page sits on a cool off-white and every
+ bounded region is a white card with a hairline and a whisper of shadow,
+ so the eye reads groups before it reads text. */
+ --zk-ground: #f4f6f9;
+ --zk-card: #ffffff;
+ --zk-card-line: #e4e9ef;
+ --zk-hover: #f7f9fc;
+ --zk-green: #1f9d63;
+ --zk-green-ink: #157347;
+ --zk-green-tint: #e6f6ee;
+ --zk-green-line: #b4e3c9;
+ --zk-violet: #5b4bb7;
+ --zk-violet-tint: #eeebfa;
+ --zk-slate: #5b6b7f;
+ --zk-wa: #0f7b45;
+
/* ── Shape, depth, motion ────────────────────────────────────
One scale, used everywhere. The palette above is untouched; every shadow
below is the brand navy at low alpha, so depth reads as part of the brand
@@ -66,6 +83,9 @@
--r-lg: 16px;
--r-xl: 20px;
--r-pill: 999px;
+ --r-card: 14px;
+ --sh-card: 0 1px 2px rgba(35, 54, 111, 0.04), 0 1px 3px rgba(35, 54, 111, 0.06);
+ --sh-card-hover: 0 1px 2px rgba(35, 54, 111, 0.05), 0 8px 20px -10px rgba(35, 54, 111, 0.18);
--sh-xs: 0 1px 2px rgba(35, 54, 111, 0.05);
--sh-sm: 0 1px 2px rgba(35, 54, 111, 0.05), 0 2px 6px -2px rgba(35, 54, 111, 0.07);
@@ -141,7 +161,7 @@
ground — the generic admin-template look. The content ground is white
now; the sidebar is the only tinted surface, and sections are separated
by hairlines and space rather than boxed in shadowed cards. */
- background: var(--zk-white);
+ background: var(--zk-ground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
@@ -159,7 +179,144 @@ body,
body {
margin: 0;
- background: var(--zk-white);
+ background: var(--zk-ground);
+}
+
+/* ── Shared primitives ───────────────────────────────────────
+ Used by every screen so a card, an eyebrow or an avatar looks the same
+ wherever it appears. Screen stylesheets style CONTENT; these style the
+ containers. */
+.card {
+ background: var(--zk-card);
+ border: 1px solid var(--zk-card-line);
+ border-radius: var(--r-card);
+ box-shadow: var(--sh-card);
+}
+
+/* A small label above a value. Sentence case, quiet, never letter-spaced
+ into a shout. */
+.eyebrow {
+ display: block;
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
+ color: var(--zk-muted);
+ letter-spacing: 0.01em;
+}
+
+/* Section heading: a real heading at 16-18px, with an optional one-line
+ subtitle. */
+.h-sec {
+ margin: 0;
+ font-size: var(--fs-lg);
+ font-weight: var(--fw-semi);
+ letter-spacing: -0.015em;
+ line-height: var(--lh-tight);
+ color: var(--zk-ink);
+}
+.h-sub {
+ margin: 4px 0 0;
+ font-size: var(--fs-2xs);
+ color: var(--zk-muted);
+ line-height: 1.5;
+}
+
+/* A round face for a person or a customer. */
+.avatar {
+ display: inline-grid;
+ place-items: center;
+ flex: none;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-semi);
+ letter-spacing: 0.02em;
+ color: #fff;
+ background: var(--zk-slate);
+ user-select: none;
+}
+.avatar--sm { width: 26px; height: 26px; font-size: 11px; }
+.avatar--lg { width: 44px; height: 44px; font-size: var(--fs-sm); }
+.avatar--blue { background: var(--zk-blue); }
+.avatar--teal { background: var(--zk-teal); }
+.avatar--violet { background: var(--zk-violet); }
+.avatar--amber { background: var(--zk-amber); }
+.avatar--plum { background: #8a3d6b; }
+.avatar--slate { background: var(--zk-slate); }
+
+/* Small status pills: product line, channel, stage, tone. */
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 2px 9px;
+ border-radius: var(--r-pill);
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
+ line-height: 1.5;
+ white-space: nowrap;
+ color: var(--zk-muted);
+ background: var(--zk-line-soft);
+}
+.pill--blue { color: var(--zk-blue-dark); background: var(--zk-tint-blue); }
+.pill--teal { color: var(--zk-teal-ink); background: var(--zk-teal-tint); }
+.pill--amber { color: var(--zk-amber-ink); background: var(--zk-amber-tint); }
+.pill--red { color: var(--zk-danger-ink); background: var(--zk-danger-tint); }
+.pill--green { color: var(--zk-green-ink); background: var(--zk-green-tint); }
+.pill--violet { color: var(--zk-violet); background: var(--zk-violet-tint); }
+.pill--line { background: transparent; border: 1px solid var(--zk-line); }
+
+/* Buttons. Three weights, one shape. */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ padding: 8px 14px;
+ border-radius: var(--r-sm);
+ border: 1px solid var(--zk-line);
+ background: var(--zk-card);
+ color: var(--zk-ink);
+ font: inherit;
+ font-size: var(--fs-xs);
+ font-weight: var(--fw-medium);
+ line-height: 1.3;
+ cursor: pointer;
+ text-decoration: none;
+ white-space: nowrap;
+ transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast);
+}
+.btn:hover { background: var(--zk-hover); border-color: var(--zk-grey); }
+.btn:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
+.btn:disabled { opacity: .55; cursor: default; }
+.btn--primary { background: var(--zk-blue); border-color: var(--zk-blue); color: #fff; }
+.btn--primary:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); }
+.btn--ghost { border-color: transparent; background: transparent; color: var(--zk-blue-dark); }
+.btn--ghost:hover { background: var(--zk-tint-blue); border-color: transparent; }
+.btn--sm { padding: 5px 10px; font-size: var(--fs-2xs); border-radius: var(--r-xs); }
+.btn--danger { color: var(--zk-danger-ink); border-color: var(--zk-danger-line); }
+.btn--danger:hover { background: var(--zk-danger-tint); border-color: var(--zk-danger); }
+
+/* A live dot: something is refreshing or in motion. */
+.live {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-size: var(--fs-2xs);
+ color: var(--zk-muted);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.live__dot {
+ width: 7px; height: 7px;
+ border-radius: 50%;
+ background: var(--zk-green);
+ box-shadow: 0 0 0 0 rgba(31, 157, 99, .5);
+ animation: zk-live 2.2s var(--ease) infinite;
+}
+@keyframes zk-live {
+ 70% { box-shadow: 0 0 0 7px rgba(31, 157, 99, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(31, 157, 99, 0); }
}
button,
diff --git a/src/layout/GlobalSearch.jsx b/src/layout/GlobalSearch.jsx
new file mode 100644
index 0000000..4fc16fb
--- /dev/null
+++ b/src/layout/GlobalSearch.jsx
@@ -0,0 +1,121 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { usePortfolio } from '../api/portfolio.jsx'
+import { STAGES } from '../api/config.js'
+import { initials, toneFor } from '../api/display.js'
+import Icon from '../components/Icon.jsx'
+
+/**
+ * Find a lead from anywhere.
+ *
+ * Reads the book the PortfolioProvider already holds — the same rows the
+ * sidebar tallies and the overview count — so this costs no request and can
+ * never disagree with them. Matches on the customer, the business, the
+ * reference and the mobile number, because those are the four things a caller
+ * gives you.
+ */
+export default function GlobalSearch() {
+ const { rows } = usePortfolio()
+ const navigate = useNavigate()
+ const [q, setQ] = useState('')
+ const [open, setOpen] = useState(false)
+ const [cursor, setCursor] = useState(0)
+ const wrap = useRef(null)
+ const input = useRef(null)
+
+ const hits = useMemo(() => {
+ const term = q.trim().toLowerCase()
+ if (term.length < 2) return []
+ const out = []
+ for (const r of rows) {
+ const hay = [r.customer_name, r.entity_name, r.lead_ref, r.mobile, r.motor_reg_no]
+ .filter(Boolean).map((x) => String(x).toLowerCase())
+ if (hay.some((h) => h.includes(term))) out.push(r)
+ if (out.length >= 8) break
+ }
+ return out
+ }, [rows, q])
+
+ // Close on an outside click, and jump to the box on "/".
+ useEffect(() => {
+ function onDown(e) { if (!wrap.current?.contains(e.target)) setOpen(false) }
+ function onKey(e) {
+ if (e.key === '/' && !/input|textarea|select/i.test(document.activeElement?.tagName || '')) {
+ e.preventDefault()
+ input.current?.focus()
+ }
+ }
+ document.addEventListener('mousedown', onDown)
+ document.addEventListener('keydown', onKey)
+ return () => {
+ document.removeEventListener('mousedown', onDown)
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [])
+
+ function go(r) {
+ const id = r.instance_id ?? r.id
+ setQ(''); setOpen(false)
+ navigate(`/lead/${id}`)
+ }
+
+ function onKeyDown(e) {
+ if (e.key === 'Escape') { setOpen(false); input.current?.blur(); return }
+ if (!hits.length) return
+ if (e.key === 'ArrowDown') { e.preventDefault(); setCursor((c) => (c + 1) % hits.length) }
+ if (e.key === 'ArrowUp') { e.preventDefault(); setCursor((c) => (c - 1 + hits.length) % hits.length) }
+ if (e.key === 'Enter') { e.preventDefault(); go(hits[cursor]) }
+ }
+
+ const showList = open && q.trim().length >= 2
+
+ return (
+
+
+
{ setQ(e.target.value); setCursor(0); setOpen(true) }}
+ onFocus={() => setOpen(true)}
+ onKeyDown={onKeyDown}
+ />
+
/
+
+ {showList ? (
+
+ {hits.length ? hits.map((r, i) => {
+ const id = r.instance_id ?? r.id
+ const stage = STAGES.find((s) => s.name === r.current_state_name)
+ return (
+
setCursor(i)}
+ onClick={() => go(r)}
+ >
+
+ {initials(r.customer_name)}
+
+
+ {r.customer_name || r.lead_ref || `#${id}`}
+ {r.lead_ref}{r.mobile ? ` · ${r.mobile}` : ''}
+
+
+ {r.current_state_name}
+
+
+ )
+ }) : (
+
No lead matches “{q.trim()}”.
+ )}
+
+ ) : null}
+
+ )
+}
diff --git a/src/layout/Shell.css b/src/layout/Shell.css
index 5917e7b..765df21 100644
--- a/src/layout/Shell.css
+++ b/src/layout/Shell.css
@@ -2,62 +2,128 @@
min-height: 100vh;
display: flex;
flex-direction: column;
- background: var(--zk-tint);
+ background: var(--zk-ground);
}
-/* ── Top bar ───────────────────────────────────────────────── */
+/* ── Top bar ───────────────────────────────────────────────────
+ 56px, white, three zones: brand, search, identity. */
.shell__top {
position: sticky;
top: 0;
z-index: 30;
- display: flex;
+ display: grid;
+ grid-template-columns: 232px minmax(0, 1fr) auto;
align-items: center;
- justify-content: space-between;
gap: var(--sp-6);
- height: 72px;
- padding: 0 var(--sp-7);
- background: var(--zk-white);
- border-bottom: 1px solid var(--zk-line);
+ height: 56px;
+ padding: 0 var(--sp-5) 0 var(--sp-5);
+ background: var(--zk-card);
+ border-bottom: 1px solid var(--zk-card-line);
}
.shell__brand {
display: flex;
align-items: center;
- gap: 16px;
+ gap: 12px;
min-width: 0;
}
.shell__brand img {
- height: 30px;
+ height: 26px;
width: auto;
display: block;
+ border-radius: 4px;
}
.shell__brandtext {
- display: flex;
- flex-direction: column;
- gap: 1px;
- line-height: 1.2;
- padding-left: 16px;
+ padding-left: 12px;
border-left: 1px solid var(--zk-line);
+ font-size: var(--fs-sm);
+ font-weight: var(--fw-semi);
+ letter-spacing: -0.01em;
+ color: var(--zk-ink);
+ white-space: nowrap;
}
-.shell__brandtext strong {
- font-size: var(--fs-md);
- font-weight: var(--fw-semi);
- letter-spacing: -0.012em;
+/* ── Global search ───────────────────────────────────────────── */
+.gs {
+ position: relative;
+ justify-self: center;
+ width: min(100%, 520px);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 36px;
+ padding: 0 10px 0 12px;
+ border-radius: var(--r-sm);
+ border: 1px solid var(--zk-card-line);
+ background: var(--zk-ground);
+ transition: border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast);
+}
+.gs:hover { border-color: var(--zk-line); }
+.gs:focus-within { background: var(--zk-card); border-color: var(--zk-blue); box-shadow: var(--ring); }
+.gs__icon { flex: none; color: var(--zk-grey); }
+.gs:focus-within .gs__icon { color: var(--zk-blue); }
+.gs input {
+ flex: 1;
+ min-width: 0;
+ border: 0;
+ outline: none;
+ background: none;
+ font: inherit;
+ font-size: var(--fs-xs);
color: var(--zk-ink);
}
-
-.shell__brandtext span {
- font-size: var(--fs-3xs);
- font-weight: 400;
- letter-spacing: 0.08em;
- text-transform: uppercase;
+.gs input::placeholder { color: var(--zk-grey); }
+.gs input::-webkit-search-cancel-button { cursor: pointer; }
+.gs__kbd {
+ flex: none;
+ font: inherit;
+ font-size: 11px;
+ line-height: 1;
+ padding: 3px 6px;
+ border-radius: 4px;
+ border: 1px solid var(--zk-line);
+ background: var(--zk-card);
color: var(--zk-grey);
}
+.gs:focus-within .gs__kbd { display: none; }
-/* ── Body ──────────────────────────────────────────────────── */
+.gs__menu {
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 0;
+ right: 0;
+ z-index: 40;
+ padding: 6px;
+ border-radius: var(--r-md);
+ background: var(--zk-card);
+ border: 1px solid var(--zk-card-line);
+ box-shadow: var(--sh-lg);
+ animation: zk-rise 0.14s var(--ease) both;
+}
+.gs__hit {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ padding: 8px 10px;
+ border: 0;
+ border-radius: var(--r-sm);
+ background: none;
+ text-align: left;
+ font: inherit;
+ color: var(--zk-ink);
+ cursor: pointer;
+}
+.gs__hit.is-cursor { background: var(--zk-tint-blue); }
+.gs__t { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
+.gs__t strong { font-size: var(--fs-xs); font-weight: var(--fw-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.gs__t span { font-size: var(--fs-2xs); color: var(--zk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.gs__none { margin: 0; padding: 14px 12px; font-size: var(--fs-2xs); color: var(--zk-muted); }
+
+/* ── Body ────────────────────────────────────────────────────── */
.shell__body {
display: flex;
flex: 1;
@@ -67,136 +133,196 @@
.shell__nav {
position: sticky;
- top: 72px;
+ top: 56px;
align-self: flex-start;
- width: 264px;
+ width: 248px;
flex: none;
- min-height: calc(100vh - 72px);
- max-height: calc(100vh - 72px);
+ min-height: calc(100vh - 56px);
+ max-height: calc(100vh - 56px);
overflow-y: auto;
overscroll-behavior: contain;
- background: var(--zk-tint);
- border-right: 1px solid var(--zk-line);
- padding: var(--sp-6) var(--sp-4) var(--sp-8);
+ background: var(--zk-card);
+ border-right: 1px solid var(--zk-card-line);
+ padding: var(--sp-4) var(--sp-3) var(--sp-5);
display: flex;
flex-direction: column;
+ gap: var(--sp-5);
}
+.shell__navtop { display: flex; flex-direction: column; gap: 2px; }
+
.shell__navlabel {
- margin: 0 0 var(--sp-3);
- padding: 0 var(--sp-3);
- font-size: var(--fs-3xs);
- font-weight: var(--fw-semi);
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--zk-grey);
-}
-
-.shell__group + .shell__group {
- margin-top: var(--sp-7);
-}
-
-/* The stage list is drawn as a rail with a node per state: the sidebar should
- look like the pipeline it represents, not like a list of links. */
-.shell__stages {
- position: relative;
- display: flex;
- flex-direction: column;
- gap: var(--sp-1);
-}
-
-.shell__stages::before {
- content: '';
- position: absolute;
- left: 18px;
- top: 20px;
- bottom: 20px;
- width: 2px;
- border-radius: var(--r-pill);
- background: linear-gradient(180deg, var(--zk-line) 0%, var(--zk-line-soft) 100%);
-}
-
-.shell__stage {
- position: relative;
display: flex;
align-items: center;
justify-content: space-between;
- gap: var(--sp-2);
- padding: var(--sp-3) var(--sp-3) var(--sp-3) 40px;
- border-radius: var(--r-md);
+ gap: 8px;
+ margin: 0 0 6px;
+ padding: 0 var(--sp-3);
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
+ color: var(--zk-grey);
+}
+
+.shell__group { display: flex; flex-direction: column; }
+
+.shell__stages {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+/* One shape for every nav row: icon, label, count. */
+.shell__stage,
+.shell__today {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px var(--sp-3);
+ border-radius: var(--r-sm);
text-decoration: none;
color: var(--zk-muted);
font-size: var(--fs-xs);
- transition: background var(--t-fast), color var(--t-fast), transform var(--t-fast);
+ font-weight: var(--fw-medium);
+ line-height: 1.35;
+ transition: background var(--t-fast), color var(--t-fast);
}
+.shell__ico { flex: none; color: var(--zk-grey); transition: color var(--t-fast); }
+.shell__stage:hover,
+.shell__today:hover { background: var(--zk-hover); color: var(--zk-ink); }
+.shell__stage:hover .shell__ico,
+.shell__today:hover .shell__ico { color: var(--zk-muted); }
-.shell__stage::before {
- content: '';
- position: absolute;
- left: 13px;
- top: 50%;
- width: 12px;
- height: 12px;
- margin-top: -6px;
- border-radius: 50%;
- background: var(--zk-white);
- border: 2px solid var(--zk-line);
- transition: border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast);
-}
-
-.shell__stage:hover {
- background: var(--zk-tint);
- color: var(--zk-ink);
-}
-
-.shell__stage:hover::before {
- border-color: var(--zk-blue-light);
-}
-
-.shell__stage.is-active {
+.shell__stage.is-active,
+.shell__today.is-active {
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
- font-weight: 500;
}
-
-.shell__stage.is-active::before {
+.shell__stage.is-active .shell__ico,
+.shell__today.is-active .shell__ico { color: var(--zk-blue); }
+.shell__stage.is-active::before,
+.shell__today.is-active::before {
+ content: '';
+ position: absolute;
+ left: -12px;
+ top: 7px;
+ bottom: 7px;
+ width: 3px;
+ border-radius: 0 3px 3px 0;
background: var(--zk-blue);
- border-color: var(--zk-blue);
- box-shadow: 0 0 0 4px rgba(33, 103, 174, 0.14);
}
/* Queues where the machine stops and a person decides. */
-.shell__stage.is-needed .shell__stagename {
- font-weight: 500;
- color: var(--zk-ink);
-}
+.shell__stage.is-needed { color: var(--zk-ink); }
+.shell__stage.is-needed .shell__ico { color: var(--zk-amber); }
+.shell__stage.is-needed.is-active .shell__ico { color: var(--zk-blue); }
-.shell__stage.is-needed.is-active .shell__stagename {
+.shell__stagename { flex: 1; min-width: 0; }
+
+.shell__stage.is-empty { color: var(--zk-grey); font-weight: var(--fw-normal); }
+.shell__stage.is-empty .shell__ico { color: var(--zk-line); }
+
+/* ── Queue tallies ───────────────────────────────────────────── */
+.shell__tally { display: flex; align-items: center; gap: 6px; flex: none; }
+
+.shell__n {
+ min-width: 22px;
+ padding: 1px 7px;
+ border-radius: var(--r-pill);
+ text-align: center;
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-semi);
+ font-variant-numeric: tabular-nums;
color: var(--zk-blue-dark);
+ background: var(--zk-tint-blue);
+}
+.shell__stage.is-needed .shell__n { color: var(--zk-amber-ink); background: var(--zk-amber-tint); }
+.shell__stage.is-active .shell__n { color: #fff; background: var(--zk-blue); }
+.shell__n.is-zero { color: var(--zk-grey); background: transparent; font-weight: var(--fw-normal); }
+
+.shell__auto {
+ font-style: normal;
+ font-size: var(--fs-2xs);
+ font-variant-numeric: tabular-nums;
+ color: var(--zk-blue-mid);
+}
+.shell__auto::after { content: '·'; margin-left: 5px; color: var(--zk-line); }
+
+.shell__urgent { width: 6px; height: 6px; border-radius: 50%; background: var(--zk-danger); flex: none; }
+
+.shell__grouptally {
+ font-size: 11px;
+ font-weight: var(--fw-semi);
+ font-variant-numeric: tabular-nums;
+ color: #fff;
+ background: var(--zk-amber);
+ border-radius: var(--r-pill);
+ padding: 1px 7px;
}
-/* Stage names wrap rather than truncate: "Referred to Underwriting" is the one
- queue a person has to find, and it is also the longest label. */
-.shell__stagename {
- min-width: 0;
- line-height: 1.35;
+/* ── Closed, folded ──────────────────────────────────────────── */
+.shell__closed > summary {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ list-style: none;
+ padding: 6px var(--sp-3);
+ border-radius: var(--r-sm);
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
+ color: var(--zk-grey);
+}
+.shell__closed > summary::-webkit-details-marker { display: none; }
+.shell__closed > summary:hover { color: var(--zk-muted); background: var(--zk-hover); }
+.shell__closed > summary b {
+ margin-left: auto;
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
+ font-variant-numeric: tabular-nums;
+ color: var(--zk-grey);
+}
+.shell__caret { transition: transform var(--t-fast); color: var(--zk-grey); }
+.shell__closed[open] .shell__caret { transform: rotate(90deg); }
+.shell__closed .shell__stages { margin-top: 4px; }
+
+/* ── AI team, at the foot ─────────────────────────────────────── */
+.shell__team {
+ margin-top: auto;
+ padding: var(--sp-4) var(--sp-3) 0;
+ border-top: 1px solid var(--zk-line-soft);
+}
+.shell__team .shell__navlabel { padding: 0; }
+.shell__teamrow { display: flex; flex-wrap: wrap; gap: 4px; }
+.shell__teamnote {
+ display: block;
+ margin-top: 8px;
+ font-size: 11.5px;
+ line-height: 1.45;
+ color: var(--zk-grey);
}
+/* ── Main ────────────────────────────────────────────────────── */
.shell__main {
flex: 1;
min-width: 0;
- background: var(--zk-white);
width: 100%;
- max-width: 1460px;
+ max-width: 1440px;
margin: 0 auto;
- padding: 30px 34px 76px;
+ padding: var(--sp-6) var(--sp-7) var(--sp-9);
+}
+
+/* ── Narrow viewports: the rail lies down and scrolls ────────── */
+@media (max-width: 1100px) {
+ .shell__top { grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--sp-4); }
+ .shell__brandtext { display: none; }
}
-/* ── Narrow viewports: the rail lies down and scrolls ──────── */
@media (max-width: 900px) {
- .shell__body {
- flex-direction: column;
- }
+ .shell__top { grid-template-columns: auto auto; }
+ .gs { display: none; }
+
+ .shell__body { flex-direction: column; }
.shell__nav {
position: static;
@@ -204,167 +330,21 @@
min-height: 0;
max-height: none;
border-right: 0;
- border-bottom: 1px solid var(--zk-line);
- padding: 14px 16px 16px;
- }
-
- .shell__stages {
+ border-bottom: 1px solid var(--zk-card-line);
+ padding: 10px 14px;
flex-direction: row;
- overflow-x: auto;
- padding-bottom: 4px;
+ flex-wrap: wrap;
+ gap: 8px;
}
-
- /* Laid out horizontally the stages are a scrolling strip of pills, and a rail
- behind them would just be a line struck through the row. */
- .shell__stages::before {
- display: none;
- }
-
- .shell__stage {
- padding: 8px 14px;
+ .shell__navtop, .shell__group, .shell__closed { display: contents; }
+ .shell__navlabel, .shell__team, .shell__closed > summary { display: none; }
+ .shell__stages { flex-direction: row; flex-wrap: wrap; gap: 6px; }
+ .shell__stage, .shell__today {
+ padding: 6px 12px;
white-space: nowrap;
- background: var(--zk-white);
border: 1px solid var(--zk-line);
+ background: var(--zk-card);
}
-
- .shell__stage::before {
- display: none;
- }
-
- .shell__main {
- padding: 22px 18px 56px;
- }
+ .shell__stage.is-active::before, .shell__today.is-active::before { display: none; }
+ .shell__main { padding: 18px 16px 48px; }
}
-
-/* The way back to the overview. It sits above "Add a lead" because reading
- the book is the commoner act — most sessions start by looking, not filing. */
-.shell__today {
- display: block;
- margin: 0 0 8px;
- padding: 9px 12px;
- border-radius: var(--r-sm);
- font-size: var(--fs-xs);
- font-weight: 500;
- color: var(--zk-ink);
- text-decoration: none;
- transition: background .12s, color .12s;
-}
-.shell__today:hover { background: var(--zk-tint); }
-.shell__today.is-active { background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
-
-/* ── Queue tallies ─────────────────────────────────────────────────────────
- Three numbers, deliberately unequal in weight, because they are unequal in
- importance: what needs YOU, what an agent holds, and whether anything in
- there is about to lapse. */
-.shell__tally {
- display: flex;
- align-items: center;
- gap: 6px;
- flex: none;
-}
-
-/* What is waiting on a person. The only figure with full contrast. */
-.shell__n {
- min-width: 20px;
- padding: 1px 6px;
- border-radius: var(--r-pill);
- text-align: center;
- font-size: var(--fs-2xs);
- font-weight: 600;
- font-variant-numeric: tabular-nums;
- color: var(--zk-blue-dark);
- background: var(--zk-tint-blue);
-}
-
-/* Zero is shown, not hidden. "Nothing here" is an answer, and a queue that
- disappears when it empties makes the sidebar move under the cursor. */
-.shell__n.is-zero {
- color: var(--zk-grey);
- background: transparent;
- font-weight: 400;
-}
-
-/* What an agent is carrying. Present, quiet, and never added to the badge —
- it is in the queue, it is not your work. */
-.shell__auto {
- font-style: normal;
- font-size: var(--fs-2xs);
- font-variant-numeric: tabular-nums;
- color: var(--zk-blue-mid);
-}
-.shell__auto::after {
- content: '·';
- margin-left: 5px;
- color: var(--zk-line);
-}
-
-/* A renewal inside a week sitting in this queue. */
-.shell__urgent {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--zk-amber);
- flex: none;
-}
-
-.shell__stage.is-empty { color: var(--zk-grey); }
-.shell__stage.is-empty::before { border-color: var(--zk-line-soft); }
-.shell__stage.is-empty.is-needed .shell__stagename { color: var(--zk-muted); font-weight: 400; }
-
-/* The one number worth carrying on a group heading: everything this role has
- to clear, across its queues. */
-.shell__navlabel {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
-}
-
-.shell__grouptally {
- font-size: var(--fs-2xs);
- font-weight: 600;
- letter-spacing: 0;
- font-variant-numeric: tabular-nums;
- color: var(--zk-white);
- background: var(--zk-blue);
- border-radius: var(--r-pill);
- padding: 1px 7px;
-}
-
-/* ── Closed, folded ───────────────────────────────────────────────────────
- Three of the twelve queues, opened about once a week. */
-.shell__closed { margin-top: 22px; }
-
-.shell__closed > summary {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- cursor: pointer;
- list-style: none;
- padding: 0 10px 10px;
- font-size: var(--fs-3xs);
- font-weight: 500;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--zk-grey);
-}
-.shell__closed > summary::-webkit-details-marker { display: none; }
-.shell__closed > summary:hover { color: var(--zk-muted); }
-.shell__closed > summary b {
- font-size: var(--fs-2xs);
- font-weight: 500;
- letter-spacing: 0;
- font-variant-numeric: tabular-nums;
- color: var(--zk-grey);
-}
-.shell__closed > summary::after {
- content: '';
- order: -1;
- width: 5px; height: 5px;
- border-right: 1.5px solid currentColor;
- border-bottom: 1.5px solid currentColor;
- transform: rotate(-45deg);
- transition: transform var(--t-fast);
-}
-.shell__closed[open] > summary::after { transform: rotate(45deg); }
diff --git a/src/layout/Shell.jsx b/src/layout/Shell.jsx
index 64ef4f7..d2ba376 100644
--- a/src/layout/Shell.jsx
+++ b/src/layout/Shell.jsx
@@ -1,8 +1,15 @@
+import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { NAV_GROUPS } from '../api/config.js'
+import { AGENTS } from '../api/agents.js'
import { rolesOf, visibleStages } from '../api/permissions.js'
import { useStageCounts } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx'
+import AgentCard from '../components/AgentCard.jsx'
+import AgentChip from '../components/AgentChip.jsx'
+import Icon from '../components/Icon.jsx'
+import { KIND_ICON } from '../api/display.js'
+import GlobalSearch from './GlobalSearch.jsx'
import UserMenu from './UserMenu.jsx'
import logo from '../assets/brand/zurich_logo.webp'
import './Shell.css'
@@ -13,22 +20,13 @@ import './Shell.css'
* is not the question anyone signing in has; these answer "is anything waiting
* on me?". Queues that need a person are named by what they want done.
*
- * IT CARRIES COUNTS NOW. It used not to, on the reasoning that a tally could
- * only come from a second full list call that would then disagree with the
- * queue's own total. That was right about the cost and wrong about the
- * conclusion — with no numbers, the only way to learn whether anything is
- * waiting on you is to open all nine queues in turn, which is precisely the
- * question navigation exists to answer. The overview's existing fetch moved
- * into PortfolioProvider and both surfaces read it, so this is one call fewer
- * than before rather than one more.
+ * The counts come from PortfolioProvider — one fetch shared with the overview,
+ * so the two cannot disagree. The badge counts leads whose PHASE needs a
+ * person, not leads in the state (see phaseOf); a dot marks a queue holding a
+ * renewal inside a week.
*
- * The badge counts leads whose PHASE needs a person, not leads in the state —
- * see phaseOf. A dot marks a queue holding a renewal inside a week, which is
- * the only reason to open one queue before another.
- *
- * The closed states are folded away. They are three of the twelve, they are
- * reporting rather than work, and at equal weight they made the live queues
- * harder to find.
+ * The closed states are folded away: three of the twelve, reporting rather
+ * than work, and at equal weight they made the live queues harder to find.
*/
export default function Shell() {
const { user } = useZino()
@@ -37,6 +35,7 @@ export default function Shell() {
// lives on the overview, which counts the whole book for everyone.
const stages = visibleStages(roles)
const { counts, ready } = useStageCounts()
+ const [openAgent, setOpenAgent] = useState(null)
// One queue row. Extracted because the live groups and the folded Closed
// group render the same thing and drifted apart when they did not.
@@ -54,12 +53,13 @@ export default function Shell() {
+ (ready && !n && !c.working ? ' is-empty' : '')
}
>
+
{s.need ?? s.name}
{ready ? (
{c.urgent ? : null}
- {/* The agents' share is shown in the middle dot notation rather
- than added in: it is in the queue, it is not your work. */}
+ {/* The agents' share is shown apart rather than added in: it is in
+ the queue, it is not your work. */}
{c.working ? {c.working} : null}
{n}
@@ -73,35 +73,24 @@ export default function Shell() {
- 'shell__today' + (isActive ? ' is-active' : '')}
- >
- Overview
-
- 'shell__today' + (isActive ? ' is-active' : '')}
- >
- All leads
-
-
- {/* "New lead" used to sit here. A sidebar is for moving between
- places; filing a lead is an ACTION, and an action in a list of
- destinations is the one item that does not behave like its
- neighbours. It lives on the screens where leads are looked at —
- the overview and the queues — beside the thing it adds to. */}
+
+ 'shell__today' + (isActive ? ' is-active' : '')}>
+
+ Overview
+
+ 'shell__today' + (isActive ? ' is-active' : '')}>
+
+ All leads
+
+
{NAV_GROUPS.filter((g) => g.kind !== 'end').map((group) => {
const inGroup = stages.filter((s) => s.kind === group.kind)
@@ -120,11 +109,11 @@ export default function Shell() {
)
})}
- {/* Closed. Folded, because it is reporting rather than work — and at
- equal weight with the live queues it made them harder to find. */}
+ {/* Closed. Folded, because it is reporting rather than work. */}
{stages.some((s) => s.kind === 'end') ? (
+
Closed
{ready ? (
{stages.filter((s) => s.kind === 'end')
@@ -134,12 +123,26 @@ export default function Shell() {
{stages.filter((s) => s.kind === 'end').map(queue)}
) : null}
+
+ {/* The team that carries the work. Five employees, each one click
+ from what it does and what it may touch. */}
+
+
AI employees
+
+ {Object.keys(AGENTS).map((k) => (
+
+ ))}
+
+
Working every open lead. Click one to see its remit.
+
+
+ {openAgent ?
setOpenAgent(null)} /> : null}
)
}
diff --git a/src/layout/UserMenu.css b/src/layout/UserMenu.css
index b3d5912..b126718 100644
--- a/src/layout/UserMenu.css
+++ b/src/layout/UserMenu.css
@@ -1,96 +1,43 @@
-.um {
- position: relative;
-}
+.um { position: relative; justify-self: end; }
.um__trigger {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
- padding: 5px 12px 5px 5px;
+ padding: 4px 10px 4px 4px;
border-radius: var(--r-pill);
background: transparent;
border: 1px solid transparent;
color: inherit;
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
}
-
-.um__trigger:hover {
- background: var(--zk-tint);
- border-color: var(--zk-line);
-}
-
-.um__trigger.is-open {
- background: var(--zk-tint-blue);
- border-color: var(--zk-blue-light);
-}
-
-.um__trigger:focus-visible {
- outline: none;
- border-color: var(--zk-blue);
- box-shadow: var(--ring);
-}
-
-.um__avatar {
- flex: none;
- width: 32px;
- height: 32px;
- border-radius: 50%;
- display: grid;
- place-items: center;
- background: var(--grad-blue);
- color: var(--zk-white);
- font-size: var(--fs-2xs);
- font-weight: 500;
- letter-spacing: 0.03em;
- box-shadow: var(--sh-xs);
-}
-
-.um__avatar--lg {
- width: 40px;
- height: 40px;
- font-size: var(--fs-xs);
-}
+.um__trigger:hover { background: var(--zk-hover); border-color: var(--zk-card-line); }
+.um__trigger.is-open { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
+.um__trigger:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
.um__id {
display: flex;
flex-direction: column;
text-align: left;
- line-height: 1.3;
+ line-height: 1.25;
}
+.um__id strong { font-size: var(--fs-xs); font-weight: var(--fw-medium); }
+.um__id > span { font-size: 11.5px; color: var(--zk-muted); }
-.um__id strong {
- font-size: var(--fs-xs);
- font-weight: 500;
-}
-
-.um__id > span {
- font-size: var(--fs-2xs);
- color: var(--zk-muted);
-}
-
-.um__caret {
- width: 12px;
- height: 12px;
- flex: none;
- color: var(--zk-grey);
- transition: transform var(--t);
-}
-
-.um__trigger.is-open .um__caret {
- transform: rotate(180deg);
-}
+.um__caret { flex: none; color: var(--zk-grey); transition: transform var(--t); }
+.um__trigger.is-open .um__caret { transform: rotate(180deg); }
.um__menu {
position: absolute;
- top: calc(100% + 10px);
+ top: calc(100% + 8px);
right: 0;
z-index: 40;
min-width: 300px;
- padding: 7px;
- border-radius: var(--r-lg);
- background: var(--zk-white);
- border: 1px solid var(--zk-line);
+ padding: 6px;
+ border-radius: var(--r-md);
+ background: var(--zk-card);
+ border: 1px solid var(--zk-card-line);
box-shadow: var(--sh-lg);
transform-origin: top right;
animation: zk-rise 0.16s var(--ease) both;
@@ -106,56 +53,31 @@
.um__headid {
display: flex;
flex-direction: column;
- gap: 1px;
+ gap: 2px;
line-height: 1.35;
min-width: 0;
}
-
-.um__headid strong {
- font-size: var(--fs-xs);
- font-weight: 500;
-}
-
-.um__headid span {
- font-size: var(--fs-2xs);
- color: var(--zk-muted);
- overflow-wrap: anywhere;
-}
-
-.um__headid .um__roles {
- margin-top: 4px;
- align-self: flex-start;
- font-size: var(--fs-2xs);
- font-weight: 500;
- color: var(--zk-blue-dark);
- background: var(--zk-tint-blue);
- border-radius: var(--r-pill);
- padding: 2px 9px;
-}
+.um__headid strong { font-size: var(--fs-xs); font-weight: var(--fw-medium); }
+.um__headid > span { font-size: var(--fs-2xs); color: var(--zk-muted); overflow-wrap: anywhere; }
+.um__roles { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.um__item {
- display: block;
+ display: flex;
+ align-items: center;
+ gap: 9px;
width: 100%;
text-align: left;
cursor: pointer;
font: inherit;
- font-size: var(--fs-2xs);
- padding: 10px 11px;
+ font-size: var(--fs-xs);
+ padding: 9px 11px;
border-radius: var(--r-sm);
background: none;
border: 0;
border-top: 1px solid var(--zk-line-soft);
+ border-radius: 0 0 var(--r-sm) var(--r-sm);
color: var(--zk-ink);
transition: background var(--t-fast), color var(--t-fast);
}
-
-.um__item:hover {
- background: var(--zk-tint);
- color: var(--zk-blue-dark);
-}
-
-.um__item:focus-visible {
- outline: none;
- background: var(--zk-tint-blue);
- color: var(--zk-blue-dark);
-}
+.um__item:hover { background: var(--zk-hover); color: var(--zk-blue-dark); }
+.um__item:focus-visible { outline: none; background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
diff --git a/src/layout/UserMenu.jsx b/src/layout/UserMenu.jsx
index 95f17a8..860c8b5 100644
--- a/src/layout/UserMenu.jsx
+++ b/src/layout/UserMenu.jsx
@@ -1,20 +1,15 @@
import { useEffect, useRef, useState } from 'react'
import { useZino } from '../api/provider.jsx'
+import { initials, roleLabel } from '../api/display.js'
+import Icon from '../components/Icon.jsx'
import './UserMenu.css'
-/** First letters of the first two words — a name is all we have for an avatar. */
-function initials(user) {
- const from = user?.name || user?.email || ''
- const parts = from.replace(/@.*$/, '').split(/[\s._-]+/).filter(Boolean)
- if (!parts.length) return '?'
- return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
-}
-
/**
* The signed-in identity, with sign-out folded inside it.
*
* The role stays visible on the trigger rather than only inside the menu: RBAC is
* the demo, so who you are signed in as should never take a click to find out.
+ * It reads as a label — "SME underwriter" — never as the stored slug.
*/
export default function UserMenu() {
const { user, signOut } = useZino()
@@ -43,7 +38,8 @@ export default function UserMenu() {
}, [open])
const name = user?.name || user?.email || 'Signed in'
- const roles = (user?.roles || []).join(', ')
+ const roles = (user?.roles || []).map(roleLabel).filter(Boolean)
+ const who = initials(user?.name || (user?.email || '').replace(/@.*$/, '').replace(/[._-]+/g, ' '))
return (
@@ -55,28 +51,30 @@ export default function UserMenu() {
aria-haspopup="menu"
aria-expanded={open}
>
-
{initials(user)}
+
{who}
{name}
- {roles || user?.email}
+ {roles.join(' · ') || user?.email}
-
-
-
+
{open ? (
-
{initials(user)}
+
{who}
{name}
{user?.email ? {user.email} : null}
- {roles ? {roles} : null}
+ {roles.length ? (
+
+ {roles.map((r) => {r} )}
+
+ ) : null}
+
Sign out
diff --git a/src/pages/Login.css b/src/pages/Login.css
index cabccf7..a83bc25 100644
--- a/src/pages/Login.css
+++ b/src/pages/Login.css
@@ -1,6 +1,6 @@
.login {
display: grid;
- grid-template-columns: 45% 55%;
+ grid-template-columns: minmax(380px, 44%) minmax(0, 1fr);
min-height: 100%;
}
@@ -9,12 +9,10 @@
position: relative;
display: flex;
flex-direction: column;
- justify-content: center;
padding: 40px 48px;
overflow: hidden;
color: var(--zk-white);
- background:
- linear-gradient(160deg, var(--zk-navy) 0%, #1d4c86 55%, var(--zk-blue) 100%);
+ background: linear-gradient(160deg, var(--zk-navy) 0%, #1d4c86 55%, var(--zk-blue) 100%);
}
.login__grid {
@@ -31,22 +29,14 @@
position: absolute;
inset: 0;
background:
- radial-gradient(
- 520px circle at 12% 12%,
- rgba(84, 149, 207, 0.38),
- transparent 62%
- ),
- radial-gradient(
- 620px circle at 78% 96%,
- rgba(199, 235, 249, 0.16),
- transparent 60%
- );
+ radial-gradient(520px circle at 12% 12%, rgba(84, 149, 207, 0.38), transparent 62%),
+ radial-gradient(620px circle at 78% 96%, rgba(199, 235, 249, 0.16), transparent 60%);
}
.login__logo {
position: relative;
z-index: 1;
- width: 186px;
+ width: 170px;
height: auto;
border-radius: 8px;
}
@@ -54,153 +44,146 @@
.login__brand-body {
position: relative;
z-index: 1;
- margin-top: auto;
- margin-bottom: auto;
+ margin: auto 0;
+ padding: 48px 0 24px;
max-width: 480px;
}
.login__badge {
display: inline-flex;
align-items: center;
- gap: 8px;
- padding: 8px 16px;
- border: 1px solid rgba(199, 235, 249, 0.34);
+ gap: 9px;
+ padding: 6px 14px 6px 10px;
+ border: 1px solid rgba(199, 235, 249, 0.3);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--zk-cyan);
font-size: var(--fs-2xs);
- font-weight: 400;
- letter-spacing: 0.01em;
+ font-weight: var(--fw-medium);
}
-
-.login__badge svg {
- width: 15px;
- height: 15px;
+.login__badgedot {
+ width: 7px; height: 7px; border-radius: 50%;
+ background: #7ee2b8;
+ box-shadow: 0 0 0 0 rgba(126, 226, 184, .6);
+ animation: zk-live 2.2s var(--ease) infinite;
}
.login__headline {
- /* Zurich Kotak set their hero in Zurich Sans Light */
- margin: 26px 0 0;
- font-size: var(--fs-3xl);
- font-weight: 300;
- line-height: 1.16;
- letter-spacing: -0.01em;
-}
-
-.login__headline-accent {
- display: block;
- background: linear-gradient(
- 92deg,
- #a9dcf6 0%,
- var(--zk-cyan) 55%,
- #e9f8ff 100%
- );
- -webkit-background-clip: text;
- background-clip: text;
- color: transparent;
+ margin: 22px 0 0;
+ font-size: 44px;
+ font-weight: 500;
+ line-height: 1.05;
+ letter-spacing: -0.03em;
}
.login__lede {
- margin: 16px 0 0;
- max-width: 430px;
+ margin: 14px 0 0;
+ max-width: 440px;
text-wrap: balance;
font-size: var(--fs-md);
font-weight: 300;
- line-height: 1.5;
- color: rgba(255, 255, 255, 0.8);
+ line-height: 1.55;
+ color: rgba(255, 255, 255, 0.82);
}
.login__features {
- margin: 36px 0 0;
+ margin: 34px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
- gap: 22px;
-}
-
-.login__features li {
- display: flex;
- align-items: flex-start;
- gap: 14px;
+ gap: 18px;
}
+.login__features li { display: flex; align-items: flex-start; gap: 14px; }
.login__feature-icon {
display: grid;
place-items: center;
flex: none;
- width: 34px;
- height: 34px;
+ width: 36px;
+ height: 36px;
border: 1px solid rgba(199, 235, 249, 0.26);
- border-radius: 9px;
+ border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
color: var(--zk-cyan);
}
-.login__feature-icon svg {
- width: 17px;
- height: 17px;
-}
-
.login__features strong {
display: block;
font-size: var(--fs-sm);
font-weight: 500;
- letter-spacing: 0.005em;
}
-
.login__features em {
display: block;
margin-top: 3px;
font-size: var(--fs-xs);
font-style: normal;
font-weight: 300;
+ line-height: 1.5;
color: rgba(255, 255, 255, 0.74);
}
+.login__team {
+ margin-top: 36px;
+ padding-top: 22px;
+ border-top: 1px solid rgba(255, 255, 255, 0.14);
+}
+.login__team > span {
+ display: block;
+ margin-bottom: 10px;
+ font-size: var(--fs-2xs);
+ color: rgba(255, 255, 255, 0.6);
+}
+.login__teamrow { display: flex; flex-wrap: wrap; gap: 6px 14px; }
+.login__teamrow .who__name { color: rgba(255, 255, 255, 0.86); }
+
/* ── Right form panel ──────────────────────────────────────── */
.login__panel {
- display: grid;
- place-items: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 22px;
padding: 40px 24px;
- background: var(--zk-tint);
+ background: var(--zk-ground);
}
-.login__form-wrap {
+.login__card {
width: 100%;
- max-width: 360px;
+ max-width: 440px;
+ padding: 34px 36px 30px;
+ background: var(--zk-card);
+ border: 1px solid var(--zk-card-line);
+ border-radius: var(--r-xl);
+ box-shadow: var(--sh-md);
}
-.login__logo--mobile {
- display: none;
- margin-bottom: 28px;
-}
+.login__logo--mobile { display: none; margin-bottom: 24px; }
.login__title {
margin: 0;
font-size: var(--fs-2xl);
font-weight: 500;
- letter-spacing: -0.015em;
+ letter-spacing: -0.02em;
color: var(--zk-navy);
}
.login__subtitle {
- margin: 8px 0 0;
+ margin: 6px 0 0;
font-size: var(--fs-xs);
- font-weight: 300;
color: var(--zk-muted);
}
.login__form {
- margin-top: 30px;
+ margin-top: 26px;
display: flex;
flex-direction: column;
- gap: 18px;
+ gap: 16px;
}
.login__field span {
display: block;
- margin-bottom: 7px;
+ margin-bottom: 6px;
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-ink);
@@ -210,31 +193,45 @@
width: 100%;
padding: 11px 14px;
border: 1px solid var(--zk-line);
- border-radius: 8px;
+ border-radius: var(--r-sm);
background: var(--zk-white);
font-size: var(--fs-xs);
- font-weight: 400;
color: var(--zk-ink);
- transition:
- border-color 0.16s ease,
- box-shadow 0.16s ease;
+ transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
+.login__field input::placeholder { color: var(--zk-grey); }
+.login__field input:focus { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
-.login__field input::placeholder {
+.login__pw { position: relative; }
+.login__pw input { padding-right: 44px; }
+.login__eye {
+ position: absolute;
+ right: 6px; top: 50%;
+ transform: translateY(-50%);
+ display: grid; place-items: center;
+ width: 32px; height: 32px;
+ border: 0; border-radius: 6px;
+ background: none;
color: var(--zk-grey);
+ cursor: pointer;
}
-
-.login__field input:focus {
- outline: none;
- border-color: var(--zk-blue);
- box-shadow: 0 0 0 3px rgba(33, 103, 174, 0.14);
-}
+.login__eye:hover { background: var(--zk-hover); color: var(--zk-ink); }
+.login__eye[aria-pressed="true"] { color: var(--zk-blue); }
.login__error {
- margin: -4px 0 0;
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ margin: -2px 0 0;
+ padding: 10px 12px;
+ border-radius: var(--r-sm);
+ background: var(--zk-danger-tint);
+ border: 1px solid var(--zk-danger-line);
font-size: var(--fs-2xs);
- color: var(--zk-danger);
+ line-height: 1.45;
+ color: var(--zk-danger-ink);
}
+.login__error svg { flex: none; margin-top: 2px; }
.login__submit {
display: inline-flex;
@@ -244,44 +241,67 @@
margin-top: 4px;
padding: 12px 18px;
border: 0;
- border-radius: 8px;
+ border-radius: var(--r-sm);
background: var(--zk-blue);
color: var(--zk-white);
font-size: var(--fs-xs);
font-weight: 500;
cursor: pointer;
- transition:
- background 0.16s ease,
- box-shadow 0.16s ease;
+ transition: background 0.16s ease, box-shadow 0.16s ease;
}
+.login__submit:hover:not(:disabled) { background: var(--zk-blue-dark); }
+.login__submit:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(33, 103, 174, 0.28); }
+.login__submit:disabled { opacity: 0.72; cursor: default; }
-.login__submit svg {
- width: 16px;
- height: 16px;
+/* ── Personas ─────────────────────────────────────────────── */
+.login__personas {
+ margin-top: 26px;
+ padding-top: 20px;
+ border-top: 1px solid var(--zk-line-soft);
}
+.login__personas-l {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+ margin: 0 0 10px;
+}
+.login__personas-l span { font-size: var(--fs-2xs); font-weight: 500; color: var(--zk-ink); }
+.login__personas-l em { font-size: 11.5px; font-style: normal; color: var(--zk-grey); }
-.login__submit:hover:not(:disabled) {
- background: var(--zk-blue-dark);
-}
+.login__personagrid { display: flex; flex-direction: column; gap: 4px; }
-.login__submit:focus-visible {
- outline: none;
- box-shadow: 0 0 0 3px rgba(33, 103, 174, 0.28);
-}
-
-.login__submit:disabled {
- opacity: 0.72;
- cursor: default;
+.persona {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 11px;
+ width: 100%;
+ padding: 7px 10px 7px 7px;
+ border: 1px solid transparent;
+ border-radius: var(--r-sm);
+ background: transparent;
+ text-align: left;
+ font: inherit;
+ color: var(--zk-ink);
+ cursor: pointer;
+ transition: background var(--t-fast), border-color var(--t-fast);
}
+.persona:hover { background: var(--zk-hover); border-color: var(--zk-card-line); }
+.persona.is-on { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
+.persona:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
+.persona__t { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
+.persona__t strong { font-size: var(--fs-2xs); font-weight: 500; }
+.persona__t span { font-size: 11.5px; color: var(--zk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.persona__ok { color: var(--zk-blue); }
.login__powered {
- margin: 26px 0 0;
+ margin: 0;
text-align: center;
font-size: var(--fs-2xs);
font-weight: 300;
color: var(--zk-grey);
}
-
.login__powered span {
font-weight: 500;
letter-spacing: 0.06em;
@@ -291,34 +311,13 @@
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 1024px) {
- .login__brand {
- padding: 32px;
- }
-
- .login__headline {
- font-size: var(--fs-3xl);
- }
-
- .login__lede {
- font-size: var(--fs-sm);
- }
+ .login__brand { padding: 32px; }
+ .login__headline { font-size: 38px; }
}
@media (max-width: 860px) {
- .login {
- grid-template-columns: 1fr;
- }
-
- .login__brand {
- display: none;
- }
-
- .login__panel {
- align-content: center;
- background: var(--zk-white);
- }
-
- .login__logo--mobile {
- display: block;
- }
+ .login { grid-template-columns: 1fr; }
+ .login__brand { display: none; }
+ .login__logo--mobile { display: block; }
+ .login__card { padding: 26px 22px; }
}
diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx
index c4148c7..82af900 100644
--- a/src/pages/Login.jsx
+++ b/src/pages/Login.jsx
@@ -1,51 +1,64 @@
-import { useState } from 'react'
+import { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import { ORG_ID } from '../api/config.js'
+import { AGENTS } from '../api/agents.js'
+import AgentChip from '../components/AgentChip.jsx'
+import Icon from '../components/Icon.jsx'
import logo from '../assets/brand/zurich_logo.webp'
import './Login.css'
-const FEATURES = [
+/**
+ * What the console is, said to the person about to use it — not what the
+ * insurer sells. The old panel carried retail copy ("we cover it all"); this is
+ * an operator's tool, and the three lines are the three claims the demo makes.
+ */
+const CLAIMS = [
{
- title: 'Instant policy issuance',
- body: 'No inspection; hassle free policy',
- icon: (
- <>
-
-
-
- >
- ),
+ icon: 'bot',
+ title: 'Autonomous by default',
+ body: 'Five AI employees qualify, call, chase, price and check every renewal lead.',
},
{
- title: '24/7 claim assistance',
- body: 'Making claims simple, transparent, and customer-first',
- icon: (
- <>
-
-
- >
- ),
+ icon: 'user',
+ title: 'A person at the gates',
+ body: 'Documents, the underwriting call and the premium sign-off stay with people.',
},
{
- title: 'A trusted brand',
- body: 'You can count on Zurich Kotak General Insurance at all times',
- icon: (
- <>
-
-
- >
- ),
+ icon: 'brain',
+ title: 'Every decision on record',
+ body: 'Each step shows who took it, what it wrote, and the reasoning behind it.',
},
]
+/**
+ * Demo sign-ins. These are the accounts listed in README.md for org 84; the
+ * picker only saves typing the address during a walkthrough. No password is
+ * held here — the gateway authenticates every sign-in as it always did, and
+ * anything a persona can do is still decided by the workflow, server-side.
+ */
+const PERSONAS = [
+ { email: 'sanjay.uw@zurichkotak.example', name: 'Sanjay Bhat', role: 'SME underwriter', does: 'Clears or declines referrals', tone: 'blue' },
+ { email: 'arjun.posp@zurichkotak.example', name: 'Arjun Shetty', role: 'Partner agent', does: 'Files leads, uploads documents', tone: 'teal' },
+ { email: 'meera.rm@zurichkotak.example', name: 'Meera Rao', role: 'Bancassurance RM', does: 'Files partner-bank leads', tone: 'violet' },
+ { email: 'kavya.csr@zurichkotak.example', name: 'Kavya Nair', role: 'Direct sales', does: 'Files self-serve leads', tone: 'amber' },
+ { email: 'deepa.ops@zurichkotak.example', name: 'Deepa Iyer', role: 'Operations', does: 'Sees every queue, confirms premiums', tone: 'plum' },
+]
+
+function initialsOf(name) {
+ const p = name.split(' ')
+ return (p[0][0] + (p[1]?.[0] || '')).toUpperCase()
+}
+
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
+ const [showPw, setShowPw] = useState(false)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const { signIn } = useZino()
const navigate = useNavigate()
+ const pwRef = useRef(null)
async function handleSubmit(event) {
event.preventDefault()
@@ -72,83 +85,69 @@ export default function Login() {
}
}
+ function pick(p) {
+ setEmail(p.email)
+ setError('')
+ // The address is filled; the password is still theirs to type.
+ pwRef.current?.focus()
+ }
+
+ const picked = PERSONAS.find((p) => p.email === email)
+
return (
-
+
-
-
-
-
- General insurance that has it all
+
+ Lead to Policy · operator console
- Protecting what
-
- matters the most to you
-
+ Lead Desk
- Get tailored insurance for all your needs, cause we cover it all
+ Every motor and SME renewal lead, from the first call to the issued policy —
+ carried by AI employees, with people at the decisions that need one.
- {FEATURES.map((feature) => (
-
+ {CLAIMS.map((c) => (
+
-
- {feature.icon}
-
+
- {feature.title}
- {feature.body}
+ {c.title}
+ {c.body}
))}
+
+
+
The team
+
+ {Object.keys(AGENTS).map((k) => (
+
+ ))}
+
+
-
-
+
+
Sign in
-
- Enter your credentials to access your dashboard.
-
+
Use your Lead Desk account for organisation {ORG_ID}.
-
- Powered by zino
-
+
+
+ Demo personas
+ pick one to fill the email
+
+
+ {PERSONAS.map((p) => (
+ pick(p)}
+ >
+ {initialsOf(p.name)}
+
+ {p.name}
+ {p.role} · {p.does}
+
+ {email === p.email ? : null}
+
+ ))}
+
+
+
+
+ Powered by zino
+
)
diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx
index c36bc72..e3bfd59 100644
--- a/src/screens/Lead.jsx
+++ b/src/screens/Lead.jsx
@@ -7,13 +7,15 @@ import AgentChip from '../components/AgentChip.jsx'
import CallTranscript from '../components/CallTranscript.jsx'
import ClampText from '../components/ClampText.jsx'
import Conversation from '../components/Conversation.jsx'
+import Icon from '../components/Icon.jsx'
import LeadFileDialog from '../components/LeadFileDialog.jsx'
import StageRail from '../components/StageRail.jsx'
import Timeline from '../components/Timeline.jsx'
-import { APP_ID, DOC_SLOTS, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
+import { APP_ID, CHANNELS, DOC_SLOTS, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
import { AGENTS } from '../api/agents.js'
import { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
+import { humanize, initials, toneFor } from '../api/display.js'
import './screens.css'
/** The countdown, not just the date. On a renewal book this is the number
@@ -50,10 +52,6 @@ const GROUPS = [
['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']],
['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
['Contact', ['contact_outcome','outreach_window','contact_notes','call_transcript']],
- // The five OCR slots led this list and were absent from it, which is how a
- // lead with an RC, an expiring policy and a PAN attached showed "Documents 2"
- // — the status and the notes. They were also absent from the view itself
- // until 76; adding them here without that would have changed nothing.
['Documents', ['doc_rc','doc_prev_policy','doc_pan','doc_gst_cert','doc_udyam_cert',
'doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos',
'doc_vehicle_photos','doc_financials','documents_status','documents_notes']],
@@ -74,22 +72,10 @@ const GROUPS = [
const LONG = 150
/**
- * WHAT THE PAGE SHOWS WITHOUT BEING ASKED.
- *
- * Three things are always true of a lead — who the customer is, what is being
- * insured, and what has been attached — so those are always here. The fourth
- * section depends on where the lead has got to: a lead in Quoted needs the
- * cover and the quote's expiry; the same lead in Onboarded needs the policy
- * number and when it renews. Everything else is in the full file.
- *
- * THIS IS A MIRROR, in the same sense as STAGES, ACTIONS and DOC_SLOTS, and it
- * carries the same hazard: a stage not listed here simply gets no fourth
- * section, and a field renamed in the workflow quietly stops appearing. That is
- * the safe direction to fail — the full file is generated from the data and
- * still holds everything — but it is a mirror, not a source.
- *
- * The metrics strip at the top of the page already carries premium, commission,
- * product, renewal date, lead age and AI confidence. Nothing is repeated here.
+ * WHAT THE PAGE SHOWS WITHOUT BEING ASKED. Three things are always true of a
+ * lead — who the customer is, what is being insured, and what has been
+ * attached — so those are always here. The fourth section depends on where
+ * the lead has got to. Everything else is in the full file.
*/
const GLANCE_STAGE = {
'zk-state-new': ['lead_score', 'eligibility_outcome'],
@@ -111,14 +97,7 @@ const GLANCE_RISK = {
sme: ['sme_product_variant', 'sme_occupancy', 'sme_value_at_risk'],
}
-/**
- * Field ids are snake_case and several carry acronyms, which sentence-casing
- * turns into "Pan" and "Gstin". Only the words that need it are listed; every
- * other word passes through.
- *
- * This is the FALLBACK. The detail view ships an output_label for every field
- * and that is preferred — this covers a field the config does not describe.
- */
+/** Fallback labels for a field the config does not describe. */
const WORDS = {
pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW',
od: 'OD', tp: 'TP', gst: 'GST', idv: 'IDV', ncb: 'NCB', rm: 'RM', si: 'SI',
@@ -136,73 +115,70 @@ function filesOf(v) {
return Array.isArray(v) ? v.filter((f) => f && typeof f === 'object' && f.uuid) : []
}
+/** Fields whose value is a code that must NOT be spelt out. */
+const VERBATIM = new Set(['lead_ref', 'pan', 'gstin', 'udyam_no', 'motor_reg_no', 'partner_code', 'policy_no',
+ 'payment_ref', 'kyc_ref', 'acceptance_ref', 'dedupe_match_ref', 'payout_ref', 'product_code', 'motor_prev_policy_no', 'email', 'mobile'])
+
function fmt(k, v) {
if (v === null || v === undefined || v === '') return null
if (k === 'product_line') return PRODUCTS[v] ?? String(v)
if (Array.isArray(v)) {
- // A file field holds an array of upload references — {uuid, original_name,
- // blob_path, …} — so joining it raw prints [object Object].
return v
- .map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : x))
+ .map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : humanize(x)))
.filter((x) => x !== '' && x !== null && x !== undefined)
.join(', ') || null
}
- // No field carries a bare object today, but one arriving as JSON should not
- // print as [object Object].
if (typeof v === 'object') return JSON.stringify(v)
if (MONEY.has(k)) { const n = Number(v); return Number.isFinite(n) ? '₹' + n.toLocaleString('en-IN') : String(v) }
- return String(v)
+ // Stored tokens read as English; references, names and prose pass through.
+ return VERBATIM.has(k) ? String(v) : humanize(String(v))
}
/**
- * Whose move it is, right now — the question an operator opens a lead to
- * answer, said as a coloured pill in the header. Derived from the stage's own
- * kind, so it stays in step with the workflow: an AI-run stage is with the AI,
- * a customer-gated one is with the customer, a person-gated one is waiting on
- * someone, and a closed lead holds nobody.
+ * Whose move it is, right now — said as a coloured pill in the header.
*/
function holdsOf(stage) {
if (!stage) return null
switch (stage.kind) {
- case 'auto': return { tone: 'ai', label: 'With the AI' }
- case 'customer': return { tone: 'grey', pillTone: 'cust', label: 'With the customer' }
- case 'needs': return { tone: 'person', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' }
- case 'waiting': return { tone: 'grey', label: 'In nurture' }
- case 'end': return { tone: 'grey', label: 'Closed' }
+ case 'auto': return { tone: 'ai', icon: 'bot', label: 'With the AI' }
+ case 'customer': return { tone: 'cust', icon: 'chat', label: 'With the customer' }
+ case 'needs': return { tone: 'person', icon: 'user', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' }
+ case 'waiting': return { tone: 'grey', icon: 'clock', label: 'In nurture' }
+ case 'end': return { tone: 'grey', icon: 'archive', label: 'Closed' }
default: return null
}
}
+/** A file's extension, for the little type tag on a document chip. */
+function extOf(name) {
+ const m = String(name || '').match(/\.([a-z0-9]{2,5})$/i)
+ return m ? m[1].toUpperCase() : 'FILE'
+}
+
export default function Lead() {
const { instanceId } = useParams()
const { client, user } = useZino()
const roles = rolesOf(user)
const navigate = useNavigate()
const location = useLocation()
- // What the workflow said when the last activity was submitted. It is written
- // per activity — "Documents received — capturing the risk" — and is the
- // workflow telling the operator what it just set in motion, so it is shown
+ // What the workflow said when the last activity was submitted — shown
// verbatim rather than replaced with a generic "Saved".
const [note, setNote] = useState(location.state?.message ?? null)
const [row, setRow] = useState(null)
const [err, setErr] = useState(null)
const [open, setOpen] = useState(null)
const [labels, setLabels] = useState({})
- // The audit rows live HERE, not inside Timeline, for two reasons: Timeline
- // fetched them once on mount and never again — so a lead worked by five
- // agents in three minutes showed the trail as it was when you opened the
- // page — and the conversation view needs the same rows. One fetch, one
- // poll, two readers that cannot disagree.
+ // The audit rows live HERE, not inside Timeline: one fetch, one poll, and
+ // the conversation view reads the same rows.
const [audit, setAudit] = useState(null)
const [showChat, setShowChat] = useState(false)
const [openAgent, setOpenAgent] = useState(null)
const [showCall, setShowCall] = useState(false)
- // The full record, on demand — see the panel comment below.
const [showFile, setShowFile] = useState(false)
- // A clock in state rather than Date.now() in the render body. Reading the
- // wall clock while rendering is impure — React may render twice and get two
- // answers — and it also means the "nothing for 6 minutes" counter would only
- // move when something else happened to re-render the page. This ticks it.
+ // Newest first by default: on a live demo the step that just happened is
+ // the one everybody is looking for. One click reads it as a story instead.
+ const [newestFirst, setNewestFirst] = useState(true)
+ // A clock in state rather than Date.now() in the render body.
const [now, setNow] = useState(() => Date.now())
const load = useCallback((quiet = false) => {
@@ -215,8 +191,6 @@ export default function Lead() {
return client.detailView(DV_LEAD, instanceId)
.then((r) => {
setRow(r?.data ?? r?.record ?? r)
- // The detail view ships an output_label for every field. Using the
- // server's names means a field renamed in Studio is renamed here.
const fields = r?.config?.fields
if (Array.isArray(fields)) {
setLabels(Object.fromEntries(
@@ -225,26 +199,19 @@ export default function Lead() {
}
})
// A poll that fails leaves the screen exactly as it is. Only a first load
- // becomes an error page: a gateway blip must not throw away a lead the
- // operator is reading, or unmount a half-filled form under them.
+ // becomes an error page.
.catch((e) => { if (!quiet) setErr(e) })
}, [client, instanceId])
- // `load` is CALLED here, not handed to useEffect: it returns a promise so the
- // poll below and onDone can chain on it, and an effect that returns anything
- // but a function has that value called as cleanup — React invokes the promise
- // as destroy(), throws, and unmounts the whole tree to a blank screen.
+ // `load` is CALLED here, not handed to useEffect: it returns a promise.
useEffect(() => { load() }, [load])
- // The message is handed over on the history entry, so it would show again
- // every time this lead is reached by Back — on a lead that has since moved on.
- // Consume it once, then strike it from the entry.
+ // The message is handed over on the history entry; consume it once.
useEffect(() => {
if (location.state?.message) navigate(location.pathname, { replace: true, state: null })
}, [location.state, location.pathname, navigate])
- // A lead that no longer exists is not a page to look at. Send the operator
- // back to the queue rather than leaving them on an empty shell.
+ // A lead that no longer exists is not a page to look at.
useEffect(() => {
if (err && describeError(err).kind === 'gone') {
const id = setTimeout(() => navigate('/', { replace: true }), 1600)
@@ -252,22 +219,14 @@ export default function Lead() {
}
}, [err, navigate])
- /**
- * Nothing is pushed — no socket, no SSE — and most of this workflow is carried
- * by AI employees that take one to three minutes a step, so a lead genuinely
- * moves while it is on screen. Poll quietly: no spinner, no skeleton, and not
- * at all while the tab is in the background.
- */
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 20000)
return () => clearInterval(id)
}, [])
useEffect(() => {
- // Not while a form is open. A successful poll rewrites `actions`, and if the
- // lead has moved on the open form unmounts — taking whatever was typed into
- // it with no warning. A submission against a stale state is refused by the
- // workflow anyway, and that refusal is handled properly below.
+ // Not while a form is open: a successful poll rewrites `actions`, and if
+ // the lead has moved on the open form unmounts with whatever was typed.
if (open) return undefined
const id = setInterval(() => {
if (document.visibilityState === 'visible') load(true)
@@ -285,7 +244,15 @@ export default function Lead() {
)
}
- if (!row) return Loading…
+ if (!row) {
+ return (
+
+ )
+ }
// Only groups that actually hold something; an empty tab is a dead end.
const groups = GROUPS
@@ -296,22 +263,12 @@ export default function Lead() {
.filter(([, shown]) => shown.length)
const stateName = row.current_state_name || ''
- // The state, then what is really happening inside it — see phaseOf. Actions
- // are still looked up on the state's own uid; only the presentation moves.
const stage = phaseOf(STAGES.find((s) => s.name === stateName), row)
const holds = holdsOf(stage)
- // Only what THIS user may run. The workflow refuses the rest server-side
- // anyway; showing them a row of buttons that all 403 reads as a broken app
- // rather than as a control.
+ // Only what THIS user may run. The workflow refuses the rest server-side.
const actions = actionsFor(roles, stage?.uid)
- // THE ONE THING THIS USER MUST DO, promoted to the header. "What do I do
- // now" is the first question an operator asks of a lead, and it was answered
- // only by scrolling past the metrics and the stage rail to the Next step
- // panel. When this user has an action, it belongs top-right, where the eye
- // goes for the next move; when they do not, the passive status stays there
- // instead. `role: 'do'` is the committing step — never a re-run or a
- // recovery lever, which must not be dressed as the thing to do.
+ // THE ONE THING THIS USER MUST DO, promoted to the header.
const acceptedAlready = Boolean(row.acceptance_ref)
const primaryAction = actions.find(
(a) => a.role === 'do' && !(acceptedAlready && a.uid === 'zk-act-accept'),
@@ -319,16 +276,11 @@ export default function Lead() {
const goToAction = () => {
if (!primaryAction) return
setOpen(primaryAction.uid)
- // Let the form mount, then bring the working surface into view.
requestAnimationFrame(() =>
document.getElementById('lead-next')?.scrollIntoView({ behavior: 'smooth', block: 'center' }))
}
const fieldCount = groups.reduce((n, [, shown]) => n + shown.length, 0)
- // Built from the SAME rows the full file renders, so the two can never
- // disagree about a value. A section with nothing in it is dropped rather
- // than rendered empty — an SME lead has no registration number, and a
- // heading over a blank box reads as a bug.
const pick = (keys) => keys
.map((k) => [k, fmt(k, row[k]), filesOf(row[k])])
.filter(([, v]) => v !== null)
@@ -337,25 +289,22 @@ export default function Lead() {
const line = row.product_line === 'sme_package' ? 'sme' : 'motor'
const glance = [
- ['Customer', pick(['customer_name', 'entity_name', 'mobile', 'email'])],
- [line === 'sme' ? 'The risk' : 'The vehicle', pick(GLANCE_RISK[line])],
- ['Documents', pick(docKeys)],
- // THE CALL IS THE ONE STEP NOBODY CAN REPLAY FROM THE FIELDS. Everything
- // else on this lead was typed or derived; this happened out loud, and
- // until now its outcome sat in the full file with the source channel while
- // the words themselves were reachable only from a button halfway down the
- // trail. It gets its own card whenever a call has actually happened.
- ['The call', pick(['contact_outcome', 'outreach_window', 'contact_notes'])],
- ['This stage', pick(GLANCE_STAGE[stage?.uid] || [])],
- ].filter(([, rows]) => rows.length)
+ ['Customer', 'user', pick(['customer_name', 'entity_name', 'mobile', 'email'])],
+ [line === 'sme' ? 'The risk' : 'The vehicle', line === 'sme' ? 'building' : 'car', pick(GLANCE_RISK[line])],
+ ['Documents', 'file', pick(docKeys)],
+ ['The call', 'phone', pick(['contact_outcome', 'outreach_window', 'contact_notes'])],
+ ['This stage', 'sparkle', pick(GLANCE_STAGE[stage?.uid] || [])],
+ ].filter(([, , rows]) => rows.length)
-
- // Two different reasons for an empty action list, and they must not read the
- // same. A terminal lead is finished; a live one you cannot act on belongs to
- // somebody else, and saying "closed" about it would be a lie.
- // Derived from the same audit rows the trail renders, so they cannot drift.
- const worked = [...new Set((audit || [])
- .flatMap((r) => (r.user_roles || []).filter((x) => AGENTS[x])))]
+ // Who worked this lead, and how many steps each took. Derived from the same
+ // audit rows the trail renders, so they cannot drift.
+ const stepsBy = {}
+ for (const r of audit || []) {
+ if (r.activity_id === 'DATA_UPDATE') continue
+ const k = (r.user_roles || []).find((x) => AGENTS[x])
+ if (k) stepsBy[k] = (stepsBy[k] || 0) + 1
+ }
+ const worked = Object.keys(AGENTS).filter((k) => stepsBy[k])
const chatTurns = (audit || []).reduce((n, r) => {
const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : []
return n + fs.filter((f) => {
@@ -365,74 +314,53 @@ export default function Lead() {
}).length
}, 0)
- // An automated stage that has stopped for a stated reason. Checked before
- // the "in progress" strip below, which would otherwise keep promising that
- // something is happening for as long as the lead is left alone.
const blocked = stage?.kind === 'auto' ? blockedOn(row) : null
- // An automated step that has sat too long. Distinct from `blocked`, which is
- // the workflow refusing for a stated reason — this is the agent not having
- // come back, and the cause is usually outside this app entirely: a slow or
- // failing model provider, a thinking budget that ran out mid-sentence, a
- // wake that never arrived. The console cannot see any of that, so it reports
- // the only thing it can know — nothing has happened for a while — and offers
- // the way out rather than going on promising two minutes.
const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0
const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS
? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) }
: null
+ const e = expiryOf(row.renewal_due_date)
+ const premium = inrShort(row.quoted_premium)
+ const commission = inrShort(row.commission_amount)
+ const conf = Number(row.ai_recommendation_confidence)
+ const ch = CHANNELS[row.source_channel]
+ const openAction = open ? [...actions, { uid: 'zk-act-collect-docs', label: 'Replace or add a document', by: 'you' }].find((a) => a.uid === open) : null
return (
-
+
- {/* React Router stamps the first entry in a session with key 'default',
- so a lead opened from its own URL has nothing to go back TO and
- lands on the queue instead of leaving the app. */}
(location.key === 'default' ? navigate('/') : navigate(-1))}
>
-
-
-
+
-
-
{row.customer_name || row.lead_ref || `Lead ${instanceId}`}
-
- {row.lead_ref || `Lead ${instanceId}`}
- {row.entity_name ? ` · ${row.entity_name}` : ''}
-
+
{initials(row.customer_name)}
+
+
{row.customer_name || row.lead_ref || `Lead ${instanceId}`}
+
+ {row.lead_ref || `Lead ${instanceId}`}
+ {row.product_line ? {PRODUCTS[row.product_line] ?? row.product_line} : null}
+ {ch ? {ch.label} : null}
+ {row.entity_name ? {row.entity_name} : null}
+
- {/* WHOSE MOVE IT IS. The stage name is in the rail below; this says
- who holds the lead right now, which is the question the header is
- really being asked. The customer tone reuses the teal cust palette. */}
{primaryAction ? (
- /* YOUR MOVE. When this user owns the next step, the header carries it
- as the prominent thing — amber, because it is waiting on a person —
- and pressing it opens the form below and scrolls to it. */
+ /* YOUR MOVE. When this user owns the next step, the header carries
+ it as the prominent thing. */
Your move
{primaryAction.label}
-
+
) : holds ? (
-
-
- {holds.tone === 'ai' ? (
-
- ) : holds.pillTone === 'cust' ? (
-
- ) : holds.tone === 'person' ? (
-
- ) : (
-
- )}
-
+
+
Right now
{holds.label}
@@ -441,86 +369,67 @@ export default function Lead() {
) : null}
- {/* Measures, not fields. Age and dwell time are computed — neither is on
- the record — and they are the two that answer "is this lead moving?",
- which no label/value pair on the page could. */}
- {(() => {
- const e = expiryOf(row.renewal_due_date)
- const premium = inrShort(row.quoted_premium)
- const commission = inrShort(row.commission_amount)
- const conf = Number(row.ai_recommendation_confidence)
- // FOUR NUMBERS, NOT SEVEN. Money leads, because that is what a renewal
- // is about; the AI's confidence in the cover sits beside it; the
- // renewal countdown is the one deadline that matters. Dropped: lead age
- // and time-in-stage (they overlapped each other and the feed carries
- // timing), and product/vehicle (the rail's "The vehicle" holds them).
- // Nothing is lost — every removed measure is still on the page once,
- // where it belongs.
- return (
-
-
- Premium
- {premium ?? '—'}
- {premium ? 'quoted, incl. GST' : 'not yet rated'}
-
-
- Commission
- {commission ?? '—'}
-
- {row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'}
-
-
- {Number.isFinite(conf) && conf > 0 ? (
-
- AI confidence
- {conf}%
- on the cover advice
-
- ) : null}
-
- Renewal
- {e ? e.label : '—'}
- {e ? e.on : 'no date on file'}
-
+ {/* Measures, not fields. FOUR NUMBERS: money leads, the AI's confidence
+ sits beside it, the renewal countdown is the one deadline that matters. */}
+
+
+ Premium
+ {premium ?? '—'}
+ {premium ? 'quoted, incl. GST' : 'not yet rated'}
+
+
+ Commission
+ {commission ?? '—'}
+
+ {row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'}
+
+
+ {Number.isFinite(conf) && conf > 0 ? (
+
+ AI confidence
+ {conf}%
+ on the cover advice
- )
- })()}
-
-
+ ) : null}
+
+ Renewal
+ {e ? e.label : '—'}
+ {e ? e.on : 'no date on file'}
+
+
+
+
+
{note ? (
+
{note}
-
setNote(null)} aria-label="Dismiss">×
+
setNote(null)} aria-label="Dismiss">
) : null}
{/* An AI-carried stage has no empty action bar and no idle screen: it
- says what is happening and who is doing it. The activities below it
- stay available — ops can run them by hand — but they are not the
- answer to "why is nothing moving?". */}
+ says what is happening and who is doing it. */}
{blocked ? (
-
+
Stopped — {blocked.what}
{blocked.fix} The agents will pick the lead up again on their own once it
is there; nothing else is running in the meantime.
- setOpen(blocked.via)}
- >
+ setOpen(blocked.via)}>
Open Collect Documents
) : stalled ? (
-
+
{stage.doing} — nothing for {stalled.mins} minutes
@@ -532,22 +441,15 @@ export default function Lead() {
: ' There is no step to re-run from here; the actions below are the way on.'}
{stalled.nudge ? (
- setOpen(stalled.nudge.uid)}
- >
+ setOpen(stalled.nudge.uid)}>
{stalled.nudge.label}
) : null}
) : stage?.kind === 'customer' && stage.doing ? (
- /* Not an agent working, and not a task of yours either — the lead
- is with someone outside the business. Without this the screen
- went blank at exactly the stage where an operator is most likely
- to wonder whether something has broken. */
-
+
{stage.doing}
@@ -558,7 +460,7 @@ export default function Lead() {
) : stage?.kind === 'auto' ? (
-
+
{stage.doing}
@@ -570,26 +472,12 @@ export default function Lead() {
) : null}
- {/* WHAT TO DO NEXT, not what is permitted.
- Every action carries a role: `do` is the step this stage is
- waiting on, `again` is a bounded loop, `force` is an AI's own job
- offered only so a stalled lead can be pushed by hand, `exit` is
- Mark Lost. Rendering them as one flat row of equals is what made
- six buttons appear where one was the answer. */}
+ {/* WHAT TO DO NEXT, not what is permitted. `do` is the step this
+ stage is waiting on, `again` a bounded loop, `force` an AI's own
+ job offered only so a stalled lead can be pushed by hand, `exit`
+ Mark Lost. */}
{(() => {
- // Once the documents are in, the upload is not the step, not an
- // alternative to the step, and not a loop worth offering: it is
- // recovery. Demoting it only to 'again' still printed an Upload
- // documents button under a panel saying the documents had been
- // received, which is the contradiction that was reported. It goes
- // into the fold with the other recovery levers, relabelled for what
- // it is actually for — a wrong file, or one more.
const docsDone = stage?.after === 'documents received'
- // Recording an acceptance a second time is not a loop, a recovery
- // or an alternative — it is meaningless. The customer accepted on
- // WhatsApp at a stated time and the lead carries the reference;
- // offering the button again invites somebody to overwrite that
- // with a worse record of the same event.
const accepted = Boolean(row.acceptance_ref)
const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs'
? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' }
@@ -605,6 +493,15 @@ export default function Lead() {
const form = open ? (
+
+
+
{openAction?.label ?? 'Complete the step'}
+ {openAction?.by ?
Performed by {openAction.by}
: null}
+
+
setOpen(null)}>
+ Close
+
+
- {/* THE HEADING IS FOR A REAL STEP ONLY. When there is no `do`
- action, the "what is happening / nothing owed" story is
- already told once — by the status strip above and the
- header — so repeating it here as "Next step: nothing is
- waiting on you" was the same sentence a third time. The
- secondary actions (add a document, mark lost) still render
- below, without the framing. */}
+
{step.length ? (
-
+
-
Next step
-
This is what this lead is waiting on.
+
Next step
+
This is what this lead is waiting on.
) : null}
- {/* The step. One button, sized like a decision. Referred has
- two because clear and decline are a pair, not a choice
- between doing something and doing nothing. */}
{step.length ? (
{step.map((a) => (
@@ -652,8 +539,11 @@ export default function Lead() {
+ (a.uid === 'zk-act-uw-decline' ? ' step__btn--no' : '')}
onClick={() => setOpen(open === a.uid ? null : a.uid)}
>
- {a.label}
- {a.by}
+
+
+ {a.label}
+ {a.by}
+
))}
@@ -673,12 +563,9 @@ export default function Lead() {
{form}
- {/* Recovery, folded. An operator needs these on the day an
- agent stalls and never otherwise, and putting them in the
- open makes an AI's own work look like an outstanding task. */}
{extras.length ? (
- Lead not moving?
+ Lead not moving?
{force.length
? 'These normally run by themselves. Use one only if this lead has been sitting longer than it should.'
@@ -701,38 +588,31 @@ export default function Lead() {
)
})()}
- {/* THE STORY IS THE MAIN COLUMN NOW.
- This sat in a 384px gutter with its own inner scrollbar, beside a
- panel of reference fields that had the whole width. That was
- exactly backwards. The trail is what this product is FOR — it is
- the record of work nobody watched happen — and putting it in a
- sidebar said it was a footnote to the form fields.
-
- It gets the wide column and the page's own scroll. The reference
- data moved to the rail, where short key/value pairs belong and
- where a narrow measure costs nothing. */}
-
-
+ {/* THE STORY IS THE MAIN COLUMN. */}
+
+
-
What happened
-
- Every step on this lead, who took it, and what they wrote.
-
+
What happened
+
Every step on this lead, who took it, and what they wrote.
+
+
+ {chatTurns ? (
+
setShowChat(true)}>
+
+ Conversation
+ {chatTurns}
+
+ ) : null}
+
+ setNewestFirst(true)}>Newest first
+ setNewestFirst(false)}>As it happened
+
- {chatTurns ? (
-
setShowChat(true)}>
-
-
-
- Conversation
- {chatTurns}
-
- ) : null}
setShowChat(true)}
onOpenCall={() => setShowCall(true)}
@@ -743,44 +623,43 @@ export default function Lead() {
{/* Reference, not narrative. Sticky, so the facts stay put while the
story is read against them. */}
- {/* Who worked this lead. Five agents carry it and their names appear
- only inside individual entries, so the team was never visible at
- a glance. */}
+ {/* The team on this lead: who, what it does, how many steps. */}
{worked.length ? (
-
-
Worked by
-
- {worked.map((k) =>
)}
+
+
+
+
AI team on this lead
+
{Object.values(stepsBy).reduce((a, b) => a + b, 0)} steps taken without a person.
+
+
) : null}
- {/* WHAT MATTERS HERE, NOT EVERYTHING THERE IS.
- The whole record used to sit open on the page: fifty-seven
- fields over thirteen groups, which pushed the audit trail — the
- part people actually read — a screen and a half down. Most of it
- is reference material. Nobody opens a lead to find its source
- channel; they open it to see who the customer is, what is being
- insured, what has been attached, and what this stage turns on.
- So the page carries that, and the complete file is one click
- away with a filter on it. Hiding is only safe when it stays
- findable, which is why the trigger below names the number it is
- holding back rather than saying "more". */}
{glance.length ? (
-
-
+
+
-
At a glance
-
- The customer, the risk, and what this stage turns on.
-
+
At a glance
+
The customer, the risk, and what this stage turns on.
- {glance.map(([title, rows]) => (
+ {glance.map(([title, icon, rows]) => (
- {title}{rows.length}
+ {title}{rows.length}
{rows.map(([k, v, files]) => (
@@ -790,19 +669,17 @@ export default function Lead() {
{files.map((f) => (
- {f.original_name || 'Document'}
+ {extOf(f.original_name)}
+ {f.original_name || 'Document'}
))}
) : v.length > LONG ? (
- /* A stage reason can run to a paragraph —
- uw_referral_reason and lost_reason both do — and
- a paragraph printed raw here would rebuild the
- wall this panel exists to remove. */
) : (
{v}
@@ -811,11 +688,8 @@ export default function Lead() {
))}
{title === 'The call' && row.call_transcript ? (
-
setShowCall(true)}>
-
-
-
+ setShowCall(true)}>
+
Read what was actually said
) : null}
@@ -825,12 +699,10 @@ export default function Lead() {
{fieldCount ? (
setShowFile(true)}>
-
-
-
+
Open the full lead file
{fieldCount} fields
+
) : null}
diff --git a/src/screens/Overview.jsx b/src/screens/Overview.jsx
index b0db725..515b9cf 100644
--- a/src/screens/Overview.jsx
+++ b/src/screens/Overview.jsx
@@ -5,7 +5,9 @@ import { useZino } from '../api/provider.jsx'
import { ENTRY, STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
+import { initials, toneFor, inrShort, KIND_ICON } from '../api/display.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx'
+import Icon from '../components/Icon.jsx'
import './screens.css'
/**
@@ -41,10 +43,7 @@ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
/** Indian money, short. A dashboard tile has no room for eight digits. */
function inr(n) {
- if (!n) return '₹0'
- if (n >= 1e7) return '₹' + (n / 1e7).toFixed(n >= 1e8 ? 0 : 2) + ' Cr'
- if (n >= 1e5) return '₹' + (n / 1e5).toFixed(n >= 1e6 ? 0 : 2) + ' L'
- return '₹' + Math.round(n).toLocaleString('en-IN')
+ return inrShort(n) ?? '₹0'
}
function expiryTone(d) {
@@ -55,6 +54,9 @@ function expiryTone(d) {
return 'later'
}
+/** The colour a stage's share of the book wears in the flow bar. */
+const FLOW_TONE = { needs: 'amber', customer: 'teal', auto: 'blue', waiting: 'grey', end: 'line' }
+
export default function Overview() {
const { user } = useZino()
const navigate = useNavigate()
@@ -63,6 +65,9 @@ export default function Overview() {
// Filing a lead happens over this page, not instead of it — see
// NewLeadDialog. The overview behind it keeps its counts and its poll.
const [adding, setAdding] = useState(false)
+ // Which renewal bucket the table below is narrowed to. Null is "all due
+ // within 30 days", as before.
+ const [bucket, setBucket] = useState(null)
const state = usePortfolio()
const m = useMemo(() => {
@@ -135,8 +140,7 @@ export default function Overview() {
// THE HEADLINE OF AN AGENTIC CONSOLE IS WHO HOLDS THE WORK RIGHT NOW.
// Every open lead is in exactly one of three hands: a person's, the AI's,
- // or the customer's. The page led with money, which is the outcome; this
- // is the state, and it is the sentence the whole product exists to say.
+ // or the customer's.
const byStageDef = new Map(STAGES.map((s) => [s.name, s]))
let withAI = 0, withCustomer = 0, withPerson = 0
for (const r of open) {
@@ -148,9 +152,14 @@ export default function Overview() {
else withPerson += 1
}
+ // The flow: every stage in pipeline order with its share of the book.
+ const flow = STAGES.map((s) => ({ ...s, n: byStage[s.name] || 0 }))
+ const flowOpen = flow.filter((s) => s.kind !== 'end')
+ const flowTotal = flowOpen.reduce((t, s) => t + s.n, 0)
+
return {
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, approvals,
- withAI, withCustomer, withPerson,
+ withAI, withCustomer, withPerson, flow, flowOpen, flowTotal,
conversion: closed ? Math.round((won.length / closed) * 100) : null,
maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)),
}
@@ -176,11 +185,16 @@ export default function Overview() {
return (
)
}
+ const shownAttention = bucket
+ ? m.exposure[bucket]
+ : m.attention
+
return (
- {/* The action sits with the thing it acts on. It was in the sidebar,
- which is for moving between places — an action among destinations
- is the one item that does not behave like its neighbours. */}
{state.at ? (
-
- Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
+
+
+ Live · {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
) : null}
{canFile ? (
- setAdding(true)}>
-
-
-
+ setAdding(true)}>
+
New lead
) : null}
- {/* Who holds the work, right now. Three numbers, one sentence: this many
- are being worked by the AI, this many are waiting on a person, this
- many on a customer. It sits above the money because it is the claim
- the money is evidence for. */}
+ {/* Who holds the work, right now. Three numbers and one risk, as four
+ quiet cards — the one that is actually owed warms up. */}
-
+
+
With the AI
{m.withAI}
- being qualified, rated, checked or chased — no one waiting
+ being qualified, rated, checked or chased
-
+
+
Waiting on a person
{m.withPerson}
{m.withPerson ? 'a decision or an upload is owed' : 'nothing owed by anyone'}
-
+
+
With the customer
{m.withCustomer}
a quote or a payment is theirs to answer
-
+
+
Renewals at risk
{m.exposure.lapsed.length + m.exposure.week.length}
{m.exposure.lapsed.length} lapsed · {m.exposure.week.length} within 7 days
-
+ {/* The pipeline as one bar. Every open stage in order, sized by its
+ share, coloured by who holds it — and every stage, zeroes included,
+ named beneath so a stage that has quietly stopped receiving leads is
+ visible as a zero. */}
+
+
+
+
Pipeline
+
{m.open.length} open leads across {m.flowOpen.filter((s) => s.n).length} stages · {m.closed} closed
+
+
+ AI
+ Person
+ Customer
+ Scheduled
+
+
+
+ {m.flowOpen.filter((s) => s.n).map((s) => (
+
+ {s.n}
+
+ ))}
+ {!m.flowTotal ? : null}
+
+
+ {m.flow.map((s) => (
+
+
+
+ {s.name}
+ {s.n}
+
+
+ ))}
+
+
+
+ {/* Money and outcome, one row. */}
+
Open leads
{m.open.length}
@@ -265,11 +321,10 @@ export default function Overview() {
{/* SIGN-OFF, not work. The only queue that is a person's approval rather
- than a task, shown to the role that owns it and to nobody else. It sat
- fourth in a list of six and read like any other item, which is the
- wrong weight for the step that decides whether cover begins. */}
+ than a task, shown to the role that owns it and to nobody else. */}
{m.approvals.map((s) => (
-
+
+
{s.n}
{s.approvalLabel}
@@ -277,52 +332,74 @@ export default function Overview() {
{s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'}
+
))}
-
-
Action required
+
+
+
+
Action required
+
Queues where a person has to move a lead on.
+
+
{m.actionable.map((s) => (
- {s.n}
+
{s.need ?? s.name}
- {s.by}
+ {s.by}{s.working ? ` · +${s.working} with the AI` : ''}
{s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest}d` : ''}
- {s.working ? +{s.working} with the AI : null}
- {!mine.has(s.uid) ? view : null}
+ {s.n}
+ {!mine.has(s.uid)
+ ?
+ : }
))}
+
-
Renewal exposureopen leads by time to expiry
-
-
- {m.exposure.lapsed.length} Lapsed
-
-
- {m.exposure.week.length} Within 7 days
-
-
-
{m.exposure.month.length} 8 to 30 days
+
+
+
+
Renewal exposure
+
Open leads by time to expiry. Pick a band to narrow the list.
+
+ {[
+ ['lapsed', 'Lapsed', m.exposure.lapsed.length],
+ ['week', 'Within 7 days', m.exposure.week.length],
+ ['month', '8 to 30 days', m.exposure.month.length],
+ ].map(([k, label, n]) => (
+ setBucket(bucket === k ? null : k)}
+ >
+ {n} {label}
+
+ ))}
+
- {m.attention.length ? (
-
+ {shownAttention.length ? (
+
Lead Expiry Stage Premium
- {m.attention.slice(0, 8).map((r) => {
+ {shownAttention.slice(0, 8).map((r) => {
const id = r.instance_id ?? r.id
+ const st = STAGES.find((s) => s.name === r.current_state_name)
return (
- {r.customer_name || r.lead_ref || `#${id}`}
- {r.lead_ref || ''}
+
+
{initials(r.customer_name)}
+
+
{r.customer_name || r.lead_ref || `#${id}`}
+
{r.lead_ref || ''}
+
+
{r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'}
- {r.current_state_name}
+ {r.current_state_name}
{r.quoted_premium ? inr(num(r.quoted_premium)) : '—'}
)
})}
+ {shownAttention.length > 8 ? (
+
See all {shownAttention.length} in the lead list
+ ) : null}
) : (
-
No renewals due within 30 days.
+
+
+ {bucket ? 'No renewals in this band.' : 'No renewals due within 30 days.'}
+
)}
-
-
-
Pipeline distribution
- {/* Every stage, zeroes included. A stage that has quietly stopped
- receiving leads is only visible if its zero is on the page. */}
-
- {STAGES.map((s) => {
- const n = m.byStage[s.name] || 0
- return (
-
- {s.name}
-
-
-
- {n}
-
- )
- })}
-
-
{adding ?
setAdding(false)} /> : null}
diff --git a/src/screens/Pipeline.jsx b/src/screens/Pipeline.jsx
index b5bf40f..ce63d3c 100644
--- a/src/screens/Pipeline.jsx
+++ b/src/screens/Pipeline.jsx
@@ -1,10 +1,13 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import { CHANNELS, ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
+import { initials, toneFor, inrFull, KIND_ICON } from '../api/display.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx'
+import Icon from '../components/Icon.jsx'
+import './screens.css'
/** Short absolute date plus how long ago — a queue needs both: the absolute
* for "when exactly", the relative for "is this going stale". */
@@ -27,21 +30,15 @@ function added(ts) {
* Is an automated stage actually moving?
*
* The agents carry six of the nine stages, and a lead sitting in one is in
- * exactly one of two states: being worked right now, or stuck. The row showed
- * neither, so a lead three hours into "Calling the customer" looked identical
- * to one thirty seconds in — which is the failure this console exists to catch.
- *
- * The threshold is generous on purpose. An employee wake takes a minute or two
- * and a scheduled retry can be hours out, so `stalled` means "longer than any
+ * exactly one of two states: being worked right now, or stuck. The threshold
+ * is generous on purpose. An employee wake takes a minute or two and a
+ * scheduled retry can be hours out, so `stalled` means "longer than any
* normal step", not "longer than average".
*/
function progress(row, stage) {
if (!stage || stage.kind !== 'auto') return null
// `updated_at` ONLY. Falling back to created_at dates the measure from when
- // the lead was filed rather than from its last step, so every lead older than
- // half an hour reports "stalled" — an alarm on every row is an alarm on none.
- // The view returns the column since 76; before that it was silently absent,
- // which is exactly how that fallback got there.
+ // the lead was filed rather than from its last step.
const t = Date.parse(row.updated_at)
if (isNaN(t)) return null
const mins = (Date.now() - t) / 60000
@@ -50,8 +47,7 @@ function progress(row, stage) {
return { state: 'stalled', label: 'No movement for ' + (mins < 120 ? Math.round(mins) + ' minutes' : Math.round(mins / 60) + ' hours') }
}
-/** Days to expiry, and how alarmed to be about it. This is a renewal book —
- * the countdown is the most operationally useful number in the row. */
+/** Days to expiry, and how alarmed to be about it. */
function expiry(dateStr) {
if (!dateStr) return null
const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z')
@@ -61,30 +57,36 @@ function expiry(dateStr) {
const label = days < 0 ? Math.abs(days) + 'd overdue' : days === 0 ? 'today' : 'in ' + days + 'd'
return { days, tone, label, on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) }
}
-import './screens.css'
+
+/** The tone a closed state's chip wears. */
+const END_TONE = { 'Onboarded': 'green', 'Policy Issued': 'green', 'Lost / Dropped': '', 'Declined': 'red' }
+
+const SORTS = {
+ customer: (r) => String(r.customer_name || '').toLowerCase(),
+ stage: (r) => STAGES.findIndex((s) => s.name === r.current_state_name),
+ renewal: (r) => { const t = Date.parse(String(r.renewal_due_date || '').substring(0, 10)); return isNaN(t) ? Infinity : t },
+ premium: (r) => Number(r.quoted_premium) || 0,
+ waiting: (r) => { const t = Date.parse(r.created_at); return isNaN(t) ? 0 : t },
+}
/**
* One record view drives every queue; the stage is a server-side filter on
* current_state_name. Filtering server-side rather than fetching everything and
* narrowing in the browser is what keeps a queue honest once there are more
* leads than one page.
+ *
+ * The filter bar below narrows the PAGE that came back — product, channel,
+ * expiry band, stalled — client-side, on data already fetched. It never
+ * changes the request.
*/
export default function Pipeline() {
const { stageUid } = useParams()
const navigate = useNavigate()
const { client, user } = useZino()
const canFile = entryDoorsFor(rolesOf(user), ENTRY).length > 0
- // Over the queue rather than away from it: filing a lead used to cost you
- // your place in the list you were working through.
const [adding, setAdding] = useState(false)
- // "all" is not a stage — it is every lead in one list, so an operator
- // wanting to find one does not have to guess which queue it is in.
- //
- // IT MEANS ALL. This view used to drop closed leads, so a lead that was lost
- // or onboarded disappeared from the page called "All leads" — the one place
- // somebody goes when they cannot find something. A lead that ended is still
- // a lead, and "where did it go?" is exactly the question this page exists to
- // answer. Closed rows are shown, and dimmed, and can be hidden by choice.
+ // "all" is not a stage — it is every lead in one list. IT MEANS ALL: closed
+ // rows are shown, dimmed, and can be hidden by choice.
const isAll = stageUid === 'all'
const [hideClosed, setHideClosed] = useState(false)
const stage = isAll
@@ -92,33 +94,38 @@ export default function Pipeline() {
: STAGES.find((s) => s.uid === stageUid)
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
+ const [at, setAt] = useState(null)
+
+ // Filters. Reset when the queue changes so a product filter does not follow
+ // the operator into a queue where it hides everything.
+ const [q, setQ] = useState('')
+ const [product, setProduct] = useState('')
+ const [channel, setChannel] = useState('')
+ const [band, setBand] = useState('')
+ const [stalledOnly, setStalledOnly] = useState(false)
+ const [sort, setSort] = useState({ key: 'waiting', dir: 'desc' })
+ useEffect(() => { setQ(''); setProduct(''); setChannel(''); setBand(''); setStalledOnly(false) }, [stageUid])
useEffect(() => {
let cancelled = false
// `quiet` is what makes polling bearable: the first load may show a skeleton,
- // a refresh may not — dropping back to the loading state every 30 seconds
- // would flash the whole queue away under whoever is reading it.
+ // a refresh may not.
function fetchRows(quiet) {
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
return client
.recordView(RV_LEADS, {
limit: isAll ? 200 : 100,
- // No stage filter on the all view. Closed leads are dropped below
- // rather than in the query: one request beats three negations, and
- // 200 covers a demo book comfortably.
...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }),
})
.then((res) => {
if (cancelled) return
let rows = res?.data ?? res?.rows ?? res?.records ?? []
- // Nothing is dropped here any more. Whether closed leads are shown
- // is a choice made below, on data already fetched — a filter that
- // throws rows away at the query cannot be undone by a toggle.
// total_count is the size of the QUEUE; rows is one page of at most
// 100 of it. Counting the page would quietly under-report a busy stage.
const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
setState({ status: 'ready', rows, total, error: null })
+ setAt(new Date())
})
.catch((err) => {
if (cancelled) return
@@ -136,62 +143,175 @@ export default function Pipeline() {
return () => { cancelled = true; clearInterval(id) }
}, [client, stageUid, stage?.name, isAll])
- // Split rather than filtered at the query, so the header can say how many
- // are closed instead of silently showing fewer than the page claims.
- const CLOSED = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name))
+ const CLOSED = useMemo(() => new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name)), [])
const closedCount = isAll ? state.rows.filter((r) => CLOSED.has(r.current_state_name)).length : 0
- const shownRows = isAll && hideClosed
- ? state.rows.filter((r) => !CLOSED.has(r.current_state_name))
- : state.rows
+
+ // Decorate once, then filter and sort on the decorated row.
+ const decorated = useMemo(() => state.rows.map((r) => {
+ const rowStage = phaseOf(STAGES.find((x) => x.name === r.current_state_name), r)
+ return { r, rowStage, prog: progress(r, rowStage), exp: expiry(r.renewal_due_date), add: added(r.created_at) }
+ }), [state.rows])
+
+ const canStall = decorated.some((d) => d.prog)
+ const channelsPresent = useMemo(() => [...new Set(state.rows.map((r) => r.source_channel).filter(Boolean))], [state.rows])
+ const productsPresent = useMemo(() => [...new Set(state.rows.map((r) => r.product_line).filter(Boolean))], [state.rows])
+
+ const shown = useMemo(() => {
+ const term = q.trim().toLowerCase()
+ let out = decorated.filter(({ r, prog, exp }) => {
+ if (isAll && hideClosed && CLOSED.has(r.current_state_name)) return false
+ if (product && r.product_line !== product) return false
+ if (channel && r.source_channel !== channel) return false
+ if (stalledOnly && prog?.state !== 'stalled') return false
+ if (band) {
+ if (!exp) return false
+ if (band === 'lapsed' && exp.days >= 0) return false
+ if (band === 'week' && (exp.days < 0 || exp.days > 7)) return false
+ if (band === 'month' && (exp.days <= 7 || exp.days > 30)) return false
+ }
+ if (term) {
+ const hay = [r.customer_name, r.entity_name, r.lead_ref, r.mobile, r.motor_reg_no, r.current_state_name]
+ .filter(Boolean).map((x) => String(x).toLowerCase())
+ if (!hay.some((h) => h.includes(term))) return false
+ }
+ return true
+ })
+ const f = SORTS[sort.key]
+ if (f) {
+ out = [...out].sort((a, b) => {
+ const x = f(a.r), y = f(b.r)
+ const c = x < y ? -1 : x > y ? 1 : 0
+ return sort.dir === 'asc' ? c : -c
+ })
+ }
+ return out
+ }, [decorated, q, product, channel, band, stalledOnly, sort, isAll, hideClosed, CLOSED])
+
+ const filtering = Boolean(q.trim() || product || channel || band || stalledOnly || (isAll && hideClosed))
+ const stalledCount = decorated.filter((d) => d.prog?.state === 'stalled').length
+
+ function toggleSort(key) {
+ setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'customer' || key === 'stage' ? 'asc' : 'desc' }))
+ }
+ // A sortable header cell. A plain function, not a component: it is called
+ // in place, so React never sees a new component type per render.
+ const sortHead = (k, text, className) => (
+
+ toggleSort(k)}>
+ {text}
+
+
+
+ )
if (!stage) return Unknown stage.
+ const headCount = state.status === 'ready' ? (isAll ? state.rows.length - (hideClosed ? closedCount : 0) : state.total) : null
+
return (
-
-
{stage.need ?? stage.name}
-
- {isAll
- ? 'Every lead not yet closed, across all stages.'
- : stage.kind === 'auto'
- ? `Automated · ${stage.doing.toLowerCase()}`
- : stage.kind === 'end'
- ? 'Closed — retained for reporting.'
- : stage.kind === 'waiting'
- ? 'Held until the renewal window opens. Re-enters outreach automatically.'
- : `Pending action by ${stage.by}.`}
- {stage.need ? Stage · {stage.name} : null}
-
+
+
+
+
+ {stage.need ?? stage.name}
+ {headCount !== null ? {headCount} : null}
+
+
+ {isAll
+ ? 'Every lead, open and closed, across all stages.'
+ : stage.kind === 'auto'
+ ? `Automated · ${stage.doing.toLowerCase()}`
+ : stage.kind === 'end'
+ ? 'Closed — retained for reporting.'
+ : stage.kind === 'waiting'
+ ? 'Held until the renewal window opens. Re-enters outreach automatically.'
+ : `Pending action by ${stage.by}.`}
+ {stage.need ? · Stage: {stage.name} : null}
+
+
- {state.status === 'ready' ? (
-
- {/* Offered only where there is something to hide, and OFF by
- default: this page's job is that nothing disappears from it. */}
- {isAll && closedCount ? (
-
- setHideClosed(ev.target.checked)} />
- Hide {closedCount} closed
-
- ) : null}
-
- {isAll ? shownRows.length : state.total}
- {(isAll ? shownRows.length : state.total) === 1 ? 'lead' : 'leads'}
-
- {canFile ? (
-
setAdding(true)}>
-
-
-
+
+ {at ? (
+
+
+ Live · {at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
+
+ ) : null}
+ {canFile ? (
+ setAdding(true)}>
+
New lead
- ) : null}
-
- ) : null}
+ ) : null}
+
+ {state.status === 'ready' && state.rows.length ? (
+
+
+
+ setQ(e.target.value)}
+ placeholder="Filter by name, reference, mobile, registration…"
+ aria-label="Filter this list"
+ />
+
+ {productsPresent.length > 1 ? (
+ setProduct(e.target.value)} aria-label="Product line">
+ All products
+ Motor
+ SME Package
+
+ ) : null}
+ {channelsPresent.length > 1 ? (
+ setChannel(e.target.value)} aria-label="Channel">
+ All channels
+ {channelsPresent.map((c) => {CHANNELS[c]?.label || c} )}
+
+ ) : null}
+ setBand(e.target.value)} aria-label="Renewal band">
+ Any expiry
+ Lapsed
+ Within 7 days
+ 8 to 30 days
+
+ {canStall ? (
+ setStalledOnly((v) => !v)}
+ >
+
+ Stalled{stalledCount ? ` · ${stalledCount}` : ''}
+
+ ) : null}
+ {isAll && closedCount ? (
+ setHideClosed((v) => !v)}
+ >
+
+ Hide {closedCount} closed
+
+ ) : null}
+ {filtering ? (
+
+ {shown.length} of {state.rows.length}
+ { setQ(''); setProduct(''); setChannel(''); setBand(''); setStalledOnly(false); setHideClosed(false) }}>
+ Clear
+
+
+ ) : null}
+
+ ) : null}
+
{state.status === 'loading' ? (
{Array.from({ length: 6 }, (_, i) =>
)}
@@ -206,39 +326,36 @@ export default function Pipeline() {
) : null}
- {state.status === 'ready' && shownRows.length === 0 ? (
-
{isAll ? 'No leads yet.' : 'No records in this stage.'}
+ {state.status === 'ready' && shown.length === 0 ? (
+
+
+ {filtering ? 'Nothing matches these filters.' : isAll ? 'No leads yet.' : 'Nothing in this queue.'}
+
) : null}
- {state.status === 'ready' && shownRows.length > 0 ? (
-
+ {state.status === 'ready' && shown.length > 0 ? (
+
- Customer
- {isAll ? 'Stage' : 'Status'}
- Renewal due
- Premium
- Waiting
+ {sortHead('customer', 'Customer')}
+ {sortHead('stage', isAll ? 'Stage' : 'Status')}
+ {sortHead('renewal', 'Renewal due')}
+ {sortHead('premium', 'Premium', 'num')}
+ {sortHead('waiting', 'Added')}
- {shownRows.map((r) => {
+ {shown.map(({ r, rowStage, prog, exp, add }) => {
const id = r.instance_id ?? r.id
- const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
- const exp = expiry(r.renewal_due_date)
- const add = added(r.created_at)
- // What is really happening in the state, not just its name —
- // a lead whose documents are in is being worked by the AI, not
- // waiting on the agent whose queue it is sitting in.
- const rowStage = phaseOf(STAGES.find((x) => x.name === r.current_state_name), r)
- const prog = progress(r, rowStage)
+ const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '' }
+ const closed = CLOSED.has(r.current_state_name)
return (
navigate(`/lead/${id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) }
@@ -248,11 +365,16 @@ export default function Pipeline() {
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
>
- {r.customer_name || '—'}
-
- {r.lead_ref || `#${id}`}
- {r.product_line ? ` · ${r.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
- {ch.label ? ` · ${ch.label}` : ''}
+
+
{initials(r.customer_name)}
+
+
{r.customer_name || '—'}
+
+ {r.lead_ref || `#${id}`}
+ {r.product_line ? {r.product_line === 'motor' ? 'Motor' : 'SME'} : null}
+ {ch.label ? {ch.label} : null}
+
+
{/* Who holds it, and — where an agent does — whether it is
@@ -265,8 +387,10 @@ export default function Pipeline() {
{prog.label}
+ ) : closed ? (
+ {r.current_state_name}
) : isAll ? (
- {r.current_state_name || '—'}
+ {r.current_state_name || '—'}
) : (
{rowStage?.by ? `with ${rowStage.by}` : '—'}
@@ -279,8 +403,8 @@ export default function Pipeline() {
{exp.on}
>
: — }
- {r.quoted_premium ? '₹' + Number(r.quoted_premium).toLocaleString('en-IN') : '—'}
- {add.rel || add.abs}
+ {r.quoted_premium ? inrFull(r.quoted_premium) : — }
+ {add.rel || add.abs}
)
})}
diff --git a/src/screens/screens.css b/src/screens/screens.css
index ea66307..6e4e4bf 100644
--- a/src/screens/screens.css
+++ b/src/screens/screens.css
@@ -1,123 +1,236 @@
-/* ---- page header ---- */
+/* ═══════════════════════════════════════════════════════════════════════════
+ Screens — overview, queues, lead.
+
+ One language: the page sits on the ground colour, every bounded region is
+ a .card (index.css), headings are sentence-case at real sizes, labels are
+ quiet, and colour is spent on state — who holds a lead, how urgent a
+ renewal is — never on decoration.
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+/* ── Page header ─────────────────────────────────────────────────────────── */
.page__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
- gap: 24px;
- margin-bottom: 24px;
+ gap: var(--sp-6);
+ margin-bottom: var(--sp-5);
}
.page__title {
margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 10px;
font-size: var(--fs-2xl);
font-weight: var(--fw-semi);
- letter-spacing: -0.022em;
- line-height: 1.2;
+ letter-spacing: -0.025em;
+ line-height: 1.15;
color: var(--zk-ink);
}
+/* The count, inline with the title: "Document collection 9". */
+.page__n {
+ font-size: var(--fs-md);
+ font-weight: var(--fw-semi);
+ letter-spacing: 0;
+ line-height: 1;
+ padding: 4px 10px;
+ border-radius: var(--r-pill);
+ color: var(--zk-blue-dark);
+ background: var(--zk-tint-blue);
+ font-variant-numeric: tabular-nums;
+}
+
.page__sub {
- margin: 6px 0 0;
+ margin: 5px 0 0;
max-width: 72ch;
font-size: var(--fs-xs);
line-height: 1.5;
color: var(--zk-muted);
}
+.page__stage { color: var(--zk-grey); }
-/* The queue depth, as a stat tile rather than a loose number. */
-.page__count {
- flex: none;
+.page__lead {
display: flex;
- flex-direction: column;
align-items: center;
- gap: 1px;
- min-width: 84px;
- padding: 10px 18px;
- border-radius: var(--r-lg);
- background: var(--zk-white);
- border: 1px solid var(--zk-line);
- box-shadow: var(--sh-sm);
+ gap: var(--sp-4);
+ min-width: 0;
}
+.page__idblock { min-width: 0; }
+.page__sub--chips { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 6px; }
+.page__ref { font-variant-numeric: tabular-nums; color: var(--zk-muted); }
+.page__entity { color: var(--zk-muted); }
+.page__entity::before { content: '·'; margin-right: 8px; color: var(--zk-grey); }
-.page__count strong {
- font-size: var(--fs-2xl);
- font-weight: 500;
- line-height: 1.1;
- color: var(--zk-blue);
- font-variant-numeric: tabular-nums;
+/* The queue's kind, as a tinted disc beside its title. */
+.page__kind {
+ display: grid;
+ place-items: center;
+ flex: none;
+ width: 44px;
+ height: 44px;
+ border-radius: 12px;
+ color: var(--zk-blue-dark);
+ background: var(--zk-tint-blue);
}
+.page__kind--needs { color: var(--zk-amber-ink); background: var(--zk-amber-tint); }
+.page__kind--customer { color: var(--zk-teal-ink); background: var(--zk-teal-tint); }
+.page__kind--waiting,
+.page__kind--end,
+.page__kind--all { color: var(--zk-muted); background: var(--zk-line-soft); }
-.page__count span {
- font-size: var(--fs-3xs);
- font-weight: 500;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--zk-grey);
+.page__right { display: flex; align-items: center; gap: var(--sp-4); flex: none; margin-top: 4px; }
+
+/* ── Back ────────────────────────────────────────────────────────────────── */
+.backbtn {
+ flex: none;
+ width: 36px;
+ height: 36px;
+ display: grid;
+ place-items: center;
+ cursor: pointer;
+ border-radius: var(--r-sm);
+ border: 1px solid var(--zk-card-line);
+ background: var(--zk-card);
+ color: var(--zk-muted);
+ transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
+.backbtn:hover { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
+.backbtn:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
+/* ── Generic states ──────────────────────────────────────────────────────── */
.empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
margin: 0;
color: var(--zk-muted);
font-size: var(--fs-xs);
text-align: center;
padding: 44px 24px;
- border-radius: var(--r-lg);
+ border-radius: var(--r-card);
border: 1px dashed var(--zk-line);
- background: linear-gradient(180deg, var(--zk-white) 0%, var(--zk-tint) 100%);
+ background: var(--zk-card);
}
+.empty svg { color: var(--zk-grey); }
+.empty--flush { border: 0; border-top: 1px dashed var(--zk-line); border-radius: 0; padding: 28px 0 8px; }
.notice {
- background: var(--zk-white);
- border: 1px solid var(--zk-line);
+ background: var(--zk-card);
+ border: 1px solid var(--zk-card-line);
border-left: 3px solid var(--zk-blue);
- border-radius: var(--r-xs) var(--r-lg) var(--r-lg) var(--r-xs);
- box-shadow: var(--sh-sm);
+ border-radius: var(--r-xs) var(--r-card) var(--r-card) var(--r-xs);
+ box-shadow: var(--sh-card);
padding: 18px 22px;
max-width: 64ch;
}
-
-.notice strong {
- display: block;
- font-weight: 500;
- margin-bottom: 7px;
-}
-
-.notice p {
- margin: 0 0 6px;
- font-size: var(--fs-xs);
- color: var(--zk-muted);
- line-height: 1.55;
-}
-
-.notice__detail {
- font-size: var(--fs-2xs) !important;
- color: var(--zk-grey) !important;
-}
-
+.notice strong { display: block; font-weight: var(--fw-medium); margin-bottom: 7px; }
+.notice p { margin: 0 0 6px; font-size: var(--fs-xs); color: var(--zk-muted); line-height: 1.55; }
+.notice__detail { font-size: var(--fs-2xs) !important; color: var(--zk-grey) !important; }
.notice code {
font-family: var(--font-mono);
font-size: 0.8em;
- background: var(--zk-tint);
+ background: var(--zk-hover);
border: 1px solid var(--zk-line-soft);
border-radius: var(--r-xs);
padding: 1px 5px;
}
-/* ---- queue table ---- */
+/* Skeletons */
+.skel {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ padding: 14px 0;
+ background: var(--zk-card);
+ border: 1px solid var(--zk-card-line);
+ border-radius: var(--r-card);
+ overflow: hidden;
+}
+.skel__row,
+.skel__line,
+.skel__disc,
+.skel--tiles > div {
+ border-radius: var(--r-sm);
+ background: linear-gradient(90deg, var(--zk-line-soft) 0%, var(--zk-ground) 40%, var(--zk-line-soft) 80%);
+ background-size: 420px 100%;
+ animation: zk-shimmer 1.25s linear infinite;
+}
+.skel__row { height: 18px; margin: 9px 16px; }
+.skel__row:nth-child(odd) { width: calc(100% - 32px); }
+.skel__row:nth-child(3n) { width: 72%; }
+.skel__row:nth-child(3n + 2) { width: 88%; }
+.skel--tiles {
+ flex-direction: row;
+ gap: 12px;
+ padding: 0;
+ margin-bottom: 14px;
+ background: transparent;
+ border: 0;
+}
+.skel--tiles > div { flex: 1; height: 118px; border-radius: var(--r-card); }
+.skel__disc { width: 44px; height: 44px; border-radius: 50%; }
+.skel__line { height: 12px; width: 140px; margin-top: 6px; }
+.skel__line--title { height: 22px; width: 220px; margin-top: 0; }
+
+/* ── Sections (cards with a heading) ─────────────────────────────────────── */
+.sect { padding: var(--sp-5) var(--sp-6) var(--sp-6); margin-bottom: var(--sp-5); }
+.sect--tight { padding: var(--sp-4) var(--sp-5) var(--sp-5); }
+.sect__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--sp-4);
+ margin-bottom: var(--sp-4);
+}
+.sect--tight .sect__head { margin-bottom: var(--sp-3); }
+.sect--tight .h-sec { font-size: var(--fs-md); }
+.sect__tools { display: flex; align-items: center; gap: 8px; flex: none; flex-wrap: wrap; justify-content: flex-end; }
+
+.btn__n {
+ font-size: 11px;
+ font-weight: var(--fw-semi);
+ font-variant-numeric: tabular-nums;
+ padding: 1px 6px;
+ border-radius: var(--r-pill);
+ background: var(--zk-green-tint);
+ color: var(--zk-green-ink);
+}
+
+/* Segmented control */
+.seg {
+ display: inline-flex;
+ padding: 2px;
+ border-radius: var(--r-sm);
+ background: var(--zk-line-soft);
+}
+.seg__b {
+ font: inherit;
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
+ padding: 4px 10px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--zk-muted);
+ cursor: pointer;
+ transition: background var(--t-fast), color var(--t-fast);
+}
+.seg__b:hover { color: var(--zk-ink); }
+.seg__b.is-on { background: var(--zk-card); color: var(--zk-ink); box-shadow: var(--sh-xs); }
+
+/* ── Tables ──────────────────────────────────────────────────────────────── */
.gridwrap {
- background: var(--zk-white);
- border: 1px solid var(--zk-line);
- border-radius: var(--r-lg);
- box-shadow: var(--sh-sm);
+ min-width: 0;
overflow: hidden;
overflow-x: auto;
}
+.gridwrap--flush { margin: 0 calc(var(--sp-6) * -1) calc(var(--sp-6) * -1); border-top: 1px solid var(--zk-line-soft); }
.grid {
width: 100%;
- /* Eight columns of operational data do not compress: below this the card
- scrolls sideways rather than squeezing every cell into two words a line. */
- min-width: 1000px;
+ min-width: 720px;
border-collapse: separate;
border-spacing: 0;
font-size: var(--fs-sm);
@@ -128,997 +241,504 @@
top: 0;
z-index: 1;
text-align: left;
- font-size: var(--fs-3xs);
- font-weight: var(--fw-semi);
- letter-spacing: 0.09em;
- text-transform: uppercase;
- color: var(--zk-grey);
- padding: 12px 16px;
- background: var(--zk-tint);
- border-bottom: 1px solid var(--zk-line);
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
+ color: var(--zk-muted);
+ padding: 10px 16px;
+ background: var(--zk-card);
+ border-bottom: 1px solid var(--zk-card-line);
white-space: nowrap;
}
+.grid th.num { text-align: right; }
+
+.grid__sort {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font: inherit;
+ font-size: inherit;
+ font-weight: inherit;
+ color: inherit;
+ padding: 0;
+ border: 0;
+ background: none;
+ cursor: pointer;
+}
+.grid__sort svg { opacity: 0; transition: opacity var(--t-fast); color: var(--zk-grey); }
+.grid__sort:hover svg, .grid__sort.is-on svg { opacity: 1; }
+.grid__sort.is-on { color: var(--zk-ink); }
+.grid__sort.is-on svg { color: var(--zk-blue); }
.grid td {
- padding: var(--sp-4) var(--sp-5);
+ padding: 11px 16px;
border-bottom: 1px solid var(--zk-line-soft);
vertical-align: middle;
}
+.grid tbody tr:last-child td { border-bottom: none; }
+.grid .num { text-align: right; font-variant-numeric: tabular-nums; }
-.grid tbody tr {
- transition: background var(--t-fast);
-}
-
-.grid tbody tr:last-child td {
- border-bottom: none;
-}
-
-.grid tbody tr:hover {
- background: var(--zk-tint);
-}
-
-/* An accent that arrives on hover, drawn inside the cell so it never disturbs
- the table's own borders. */
-.grid tbody tr:hover td:first-child {
- box-shadow: inset 3px 0 0 var(--zk-blue);
-}
-
-.grid .num {
- text-align: right;
- font-variant-numeric: tabular-nums;
-}
-
-/* The lead reference is the thing an operator quotes on a call. */
-.grid__ref {
- font-variant-numeric: tabular-nums;
- white-space: nowrap;
- color: var(--zk-muted);
-}
-
+.grid__who { display: flex; align-items: center; gap: 12px; min-width: 0; }
+.grid__id { min-width: 0; }
+.grid__name { font-weight: var(--fw-medium); color: var(--zk-ink); }
.grid__sub {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 8px;
font-size: var(--fs-2xs);
color: var(--zk-muted);
margin-top: 3px;
}
+td > .grid__sub { display: block; }
+.grid__ref { font-variant-numeric: tabular-nums; white-space: nowrap; }
+.grid--tight th, .grid--tight td { padding-top: 8px; padding-bottom: 8px; }
+.grid--tight { min-width: 0; }
-.grid__link {
- color: var(--zk-blue);
- text-decoration: none;
- font-weight: 500;
- border-bottom: 1px solid transparent;
- transition: border-color var(--t-fast);
-}
+/* The row IS the control. */
+.grid__row { cursor: pointer; transition: background .11s; }
+.grid__row:hover { background: var(--zk-hover); }
+.grid__row:hover .grid__name { color: var(--zk-blue-dark); }
+.grid__row:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: -2px; background: var(--zk-hover); }
-.grid__link:hover {
- border-bottom-color: currentColor;
-}
+/* A left edge that says who holds the lead. Stalled rows are tinted too —
+ a lead three hours into a two-minute step is what this console is for. */
+.grid__row.is-stalled td { background: rgba(211, 47, 47, 0.035); }
+.grid__row.is-stalled td:first-child { box-shadow: inset 3px 0 0 var(--zk-danger); }
+.grid__row.is-working td:first-child { box-shadow: inset 3px 0 0 var(--zk-blue-mid); }
+.grid__row.is-stalled:hover td { background: rgba(211, 47, 47, 0.06); }
-/* ---- loading skeleton ---- */
-.skel {
+/* A finished lead, dimmed rather than removed. */
+.grid__row.is-done { color: var(--zk-grey); }
+.grid__row.is-done .grid__name { color: var(--zk-muted); font-weight: var(--fw-normal); }
+.grid__row.is-done .avatar { opacity: .55; }
+.grid__row.is-done:hover { color: var(--zk-ink); }
+.grid__row.is-done:hover .grid__name { color: var(--zk-blue-dark); }
+
+.grid__more {
display: flex;
- flex-direction: column;
- gap: 1px;
- padding: 14px 0;
- background: var(--zk-white);
- border: 1px solid var(--zk-line);
- border-radius: var(--r-lg);
- box-shadow: var(--sh-sm);
- overflow: hidden;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 12px;
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
+ color: var(--zk-blue-dark);
+ text-decoration: none;
+ border-top: 1px solid var(--zk-line-soft);
+}
+.grid__more:hover { background: var(--zk-hover); }
+
+/* Agent status in a row */
+.run { display: inline-flex; align-items: center; gap: 7px; font-size: var(--fs-2xs); line-height: 1.3; }
+.run__dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
+.run--working { color: var(--zk-blue-dark); }
+.run--working .run__dot { background: var(--zk-blue); animation: runPulse 1.6s ease-in-out infinite; }
+.run--waiting { color: var(--zk-muted); }
+.run--waiting .run__dot { background: var(--zk-blue-light); }
+.run--stalled { color: var(--zk-danger-ink); font-weight: var(--fw-medium); }
+.run--stalled .run__dot, .run__dot--stalled { background: var(--zk-danger); }
+@keyframes runPulse {
+ 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(33,103,174,.45); }
+ 50% { opacity: .55; box-shadow: 0 0 0 5px rgba(33,103,174,0); }
}
-.skel__row {
- height: 18px;
- margin: 9px 16px;
- border-radius: var(--r-sm);
- background: linear-gradient(90deg, var(--zk-line-soft) 0%, var(--zk-tint) 40%, var(--zk-line-soft) 80%);
- background-size: 420px 100%;
- animation: zk-shimmer 1.25s linear infinite;
-}
-
-.skel__row:nth-child(odd) { width: calc(100% - 32px); }
-.skel__row:nth-child(3n) { width: 72%; }
-.skel__row:nth-child(3n + 2) { width: 88%; }
-
-/* ---- chips ---- */
-.chip {
+/* Renewal countdown */
+.due {
display: inline-flex;
align-items: center;
font-size: var(--fs-2xs);
- font-weight: 500;
- padding: 3px 10px;
+ font-weight: var(--fw-medium);
+ padding: 2px 9px;
border-radius: var(--r-pill);
- background: var(--zk-tint-blue);
- color: var(--zk-blue-dark);
- border: 1px solid var(--zk-blue-light);
-}
-
-.chip--warn {
- background: var(--zk-amber-tint);
- color: var(--zk-amber-ink);
- border-color: var(--zk-amber-line);
-}
-
-/* ---- doors ---- */
-.doors {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(268px, 1fr));
- gap: 18px;
+ white-space: nowrap;
+}
+.due--lapsed { background: var(--zk-danger-tint); color: var(--zk-danger-ink); }
+.due--urgent { background: var(--zk-amber-tint); color: var(--zk-amber-ink); }
+.due--soon { background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
+.due--later { background: var(--zk-line-soft); color: var(--zk-muted); }
+
+/* ── Filter bar ──────────────────────────────────────────────────────────── */
+.filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ margin-bottom: var(--sp-4);
+}
+.filters__q {
+ flex: 1 1 260px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 34px;
+ padding: 0 12px;
+ border-radius: var(--r-sm);
+ border: 1px solid var(--zk-card-line);
+ background: var(--zk-ground);
+ color: var(--zk-grey);
+}
+.filters__q:focus-within { border-color: var(--zk-blue); background: var(--zk-card); box-shadow: var(--ring); color: var(--zk-blue); }
+.filters__q input { flex: 1; min-width: 0; border: 0; outline: none; background: none; font: inherit; font-size: var(--fs-xs); color: var(--zk-ink); }
+.filters__q input::placeholder { color: var(--zk-grey); }
+
+.filters__sel {
+ height: 34px;
+ padding: 0 30px 0 12px;
+ border-radius: var(--r-sm);
+ border: 1px solid var(--zk-card-line);
+ background: var(--zk-card);
+ font: inherit;
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
+ color: var(--zk-ink);
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%235d6162' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ background-size: 11px;
+ cursor: pointer;
+}
+.filters__sel:hover { border-color: var(--zk-grey); }
+.filters__sel:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
+
+.filters__tog {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ height: 34px;
+ padding: 0 12px;
+ border-radius: var(--r-sm);
+ border: 1px solid var(--zk-card-line);
+ background: var(--zk-card);
+ font: inherit;
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
+ color: var(--zk-muted);
+ cursor: pointer;
+ transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
+}
+.filters__tog:hover { border-color: var(--zk-grey); color: var(--zk-ink); }
+.filters__tog.is-on { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); color: var(--zk-blue-dark); }
+.filters__count {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: auto;
+ font-size: var(--fs-2xs);
+ color: var(--zk-muted);
+ font-variant-numeric: tabular-nums;
}
+/* ── Doors (new-lead chooser) ────────────────────────────────────────────── */
+.doors { display: grid; grid-template-columns: repeat(auto-fit, minmax(268px, 1fr)); gap: 16px; }
.door {
position: relative;
font: inherit;
text-align: left;
cursor: pointer;
- background: var(--zk-white);
- border: 1px solid var(--zk-line);
- border-radius: var(--r-lg);
- box-shadow: var(--sh-sm);
- padding: 22px 22px 20px;
+ background: var(--zk-card);
+ border: 1px solid var(--zk-card-line);
+ border-radius: var(--r-card);
+ padding: 20px 20px 18px;
display: flex;
flex-direction: column;
gap: 10px;
- overflow: hidden;
- transition: box-shadow var(--t), transform var(--t-fast), border-color var(--t-fast);
+ transition: box-shadow var(--t), border-color var(--t-fast);
}
-
-/* The accent bar is the door's handle: 3px at rest, the full brand gradient
- once the pointer is on it. */
-.door::before {
- content: '';
- position: absolute;
- inset: 0 0 auto;
- height: 3px;
- background: var(--grad-blue);
- transform-origin: left;
- transition: height var(--t);
-}
-
-.door:hover {
- border-color: var(--zk-blue-light);
- box-shadow: var(--sh-md);
- transform: translateY(-2px);
-}
-
-.door:hover::before {
- height: 5px;
-}
-
-.door:focus-visible {
- border-color: var(--zk-blue);
- box-shadow: var(--ring);
-}
-
-.door strong {
- font-size: var(--fs-md);
- font-weight: 500;
- letter-spacing: -0.01em;
- color: var(--zk-ink);
-}
-
-.door p {
- margin: 0;
- font-size: var(--fs-2xs);
- color: var(--zk-muted);
- line-height: 1.55;
-}
-
-.door .chip {
- align-self: flex-start;
-}
-
-/* ---- panel ---- */
-.panel {
- /* FLAT. Was a shadowed white card; now a plain section on the white
- canvas, set off by its heading and by space, not by a box. */
- background: transparent;
- border: 0;
- border-radius: 0;
- box-shadow: none;
- padding: 0;
- margin-bottom: var(--sp-8);
-}
-
-.panel__head {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: var(--sp-5);
- margin-bottom: var(--sp-6);
-}
-
-.panel__title {
- margin: 0;
- font-size: var(--fs-lg);
- font-weight: var(--fw-semi);
- letter-spacing: -0.018em;
- line-height: var(--lh-tight);
-}
-
-.panel__sub {
- margin: 5px 0 0;
- font-size: var(--fs-2xs);
- color: var(--zk-muted);
- max-width: 62ch;
- line-height: 1.55;
-}
-
-/* ---- actions ---- */
-.acts {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
-}
-
-.act {
- font: inherit;
- cursor: pointer;
- text-align: left;
- background: var(--zk-white);
- border: 1px solid var(--zk-line);
- border-radius: var(--r-md);
- padding: 11px 16px;
- display: flex;
- flex-direction: column;
- gap: 3px;
- min-width: 196px;
- transition: border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast), transform var(--t-fast);
-}
-
-.act:hover {
- border-color: var(--zk-blue-light);
- background: var(--zk-tint);
- box-shadow: var(--sh-sm);
- transform: translateY(-1px);
-}
-
-.act.is-open {
- border-color: var(--zk-blue);
- background: var(--zk-tint-blue);
- box-shadow: var(--ring);
- transform: none;
-}
-
-.act strong {
- font-size: var(--fs-xs);
- font-weight: 500;
-}
-
-.act__by {
- font-size: var(--fs-2xs);
- color: var(--zk-muted);
-}
-
-.acts__form {
- margin-top: 22px;
- padding-top: 22px;
- border-top: 1px solid var(--zk-line-soft);
- animation: zk-rise 0.24s var(--ease) both;
-}
-
-/* ---- what the machine is doing ----
- Shown where an empty action bar used to be. Most stages are carried by an AI
- employee that takes a minute or two, and a screen that says nothing for two
- minutes reads as broken. */
-.doing {
- display: flex;
- align-items: flex-start;
- gap: 14px;
- margin-bottom: 22px;
- padding: 16px 20px;
- border-radius: var(--r-lg);
- border: 1px solid var(--zk-blue-light);
- background: var(--zk-tint-blue);
-}
-
-.doing div {
- display: flex;
- flex-direction: column;
- gap: 3px;
- min-width: 0;
-}
-
-.doing strong {
- font-size: var(--fs-sm);
- font-weight: 500;
- color: var(--zk-blue-dark);
-}
-
-.doing span {
- font-size: var(--fs-2xs);
- line-height: 1.5;
- color: var(--zk-muted);
-}
-
-.doing__pulse {
- flex: none;
- margin-top: 5px;
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: var(--zk-blue);
- box-shadow: 0 0 0 0 rgba(33, 103, 174, 0.5);
- animation: zk-pulse 1.9s var(--ease) infinite;
-}
-
-@keyframes zk-pulse {
- 70% { box-shadow: 0 0 0 9px rgba(33, 103, 174, 0); }
- 100% { box-shadow: 0 0 0 0 rgba(33, 103, 174, 0); }
-}
-
-/* ---- and when it has stopped ----
- Same strip, amber, and NOT pulsing: the animation is what says "something is
- happening", so it is the first thing that has to go when nothing is. The
- agents refuse rather than invent — the rating engine will not price without
- an IDV — and that refusal used to be visible only as a lead that never moved
- again. */
-.doing--blocked {
- border-color: var(--zk-amber-line);
- background: var(--zk-amber-tint);
- align-items: flex-start;
-}
-
-.doing--blocked strong { color: var(--zk-amber-ink); }
-
-.doing__stop {
- flex: none;
- margin-top: 5px;
- width: 10px;
- height: 10px;
- border-radius: 2px;
- background: var(--zk-amber);
-}
-
-.doing__fix {
- align-self: flex-start;
- margin-top: 9px;
- font: inherit;
- font-size: var(--fs-2xs);
- font-weight: 500;
- cursor: pointer;
- padding: 7px 15px;
- border-radius: var(--r-pill);
- color: var(--zk-amber-ink);
- background: var(--zk-white);
- border: 1px solid var(--zk-amber-line);
- transition: background var(--t-fast), border-color var(--t-fast);
-}
-.doing__fix:hover { background: #fff8f1; border-color: var(--zk-amber); }
-.doing__fix:focus-visible { outline: 2px solid var(--zk-amber); outline-offset: 1px; }
-
-/* ---- what the workflow said ----
- The message the API returns per activity, shown verbatim: it is the workflow
- telling the operator what it just set in motion. */
-.said {
- display: flex;
- align-items: flex-start;
- gap: 14px;
- margin-bottom: 22px;
- padding: 14px 16px 14px 20px;
- border-radius: var(--r-lg);
- border: 1px solid var(--zk-line);
- border-left: 3px solid var(--zk-blue);
- background: var(--zk-white);
- box-shadow: var(--sh-sm);
- animation: zk-rise 0.24s var(--ease) both;
-}
-
-.said p {
- flex: 1;
- margin: 0;
- font-size: var(--fs-xs);
- line-height: 1.5;
- color: var(--zk-ink);
-}
-
-.said button {
- flex: none;
- font: inherit;
- font-size: var(--fs-lg);
- line-height: 1;
- cursor: pointer;
- padding: 2px 6px;
- border: 0;
- border-radius: var(--r-sm);
- background: none;
- color: var(--zk-grey);
- transition: background var(--t-fast), color var(--t-fast);
-}
-
-.said button:hover {
- background: var(--zk-tint);
- color: var(--zk-ink);
-}
-
-/* The stage a queue named by need actually maps to. */
-.page__stage {
- display: block;
- margin-top: 4px;
- font-size: var(--fs-2xs);
- color: var(--zk-grey);
-}
-
-/* ---- back ----
- An icon, not a sentence: the way out of a record is a known shape and does not
- need naming twice on the page. It carries a title and an aria-label, so the
- name is still there for a pointer that hovers and for a screen reader. */
-.backbtn {
- flex: none;
- margin-top: var(--sp-1);
- width: 38px;
- height: 38px;
- display: grid;
- place-items: center;
- cursor: pointer;
- border-radius: var(--r-md);
- border: 1px solid var(--zk-line);
- background: var(--zk-white);
- color: var(--zk-muted);
- box-shadow: var(--sh-xs);
- transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast);
-}
-
-.backbtn svg {
- width: 16px;
- height: 16px;
- transition: transform var(--t-fast);
-}
-
-.backbtn:hover {
- border-color: var(--zk-blue-light);
- background: var(--zk-tint-blue);
- color: var(--zk-blue-dark);
- box-shadow: var(--sh-sm);
-}
-
-.backbtn:hover svg {
- transform: translateX(-2px);
-}
-
-.backbtn:focus-visible {
- outline: none;
- border-color: var(--zk-blue);
- box-shadow: var(--ring);
-}
-
-.page__lead {
- display: flex;
- align-items: flex-start;
- gap: var(--sp-4);
- min-width: 0;
-}
-
-/* ---- lead: the file beside the story ---- */
-.lead {
- display: grid;
- /* THE STORY GETS THE WIDE COLUMN. The trail used to sit in a 384px gutter
- with its own scrollbar while a panel of reference fields had the whole
- width — exactly backwards for a product whose point is the record of
- work nobody watched. Now the narrative has the measure prose needs and
- the reference rail is narrow, because short key/value pairs are the one
- thing a narrow column is good for. */
- grid-template-columns: minmax(0, 1fr) 340px;
- gap: var(--sp-6);
- align-items: start;
-}
-
-.lead__main {
- min-width: 0;
-}
-
-/* The trail stays in view while the file is read against it. */
-.lead__side {
- position: sticky;
- top: 92px;
- min-width: 0;
- max-height: calc(100vh - 108px);
- overflow-y: auto;
- overscroll-behavior: contain;
-}
-
-/* Rail panels are tighter than main panels — a 340px measure cannot afford
- 32px of padding on each side. */
-.lead__side .panel { padding: 0; margin-bottom: var(--sp-6); }
-.lead__side .panel > .panel__head { padding: 0; margin-bottom: var(--sp-4); }
-.lead__side .panel--crew { padding: 0 0 var(--sp-6); border-bottom: 1px solid var(--zk-line); }
-/* rail headings sit at label size, not the big panel-title size — the rail is
- reference, and a 18px "Customer" heading would shout over the facts. */
-.lead__side .panel__title {
- font-size: var(--fs-3xs);
- font-weight: var(--fw-semi);
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--zk-blue-dark);
-}
-.lead__side .panel__sub { display: none; }
-
-.panel--rail {
- padding: 0;
- margin-bottom: 0;
- overflow: hidden;
-}
-
-.panel--rail .panel__head {
- padding: 20px 22px 0;
- margin-bottom: 14px;
-}
-
-/* .lead__feed is gone: the trail is page content and scrolls with the page.
- An inner scroll region is a widget; the story is not a widget. */
-
-/* One column below the width where a rail stops paying for itself. */
-@media (max-width: 1100px) {
- .lead {
- grid-template-columns: minmax(0, 1fr);
- }
-
- .lead__side {
- position: static;
- max-height: none;
- overflow: visible;
- }
-}
-
-/* ---- tabs ----
- Underline rather than pills: these swap the whole panel below them, which is
- what a tab is for; a pill segment reads as a filter on shared content. The
- 2px rule lives on the button, so a strip that wraps to two rows still marks
- the active one correctly. */
-.props {
- margin: 0;
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
- gap: 0 36px;
-}
-
-.prop {
- display: grid;
- grid-template-columns: minmax(96px, 33%) minmax(0, 1fr);
- gap: 16px;
- align-items: baseline;
- padding: 11px 10px;
- margin: 0 -10px;
- border-radius: var(--r-md);
- border-bottom: 1px solid var(--zk-line-soft);
- transition: background var(--t-fast);
-}
-
-.prop:hover {
- background: var(--zk-tint);
-}
-
-.prop dt {
- font-size: var(--fs-2xs);
- color: var(--zk-muted);
- line-height: 1.45;
-}
-
-.prop dd {
- margin: 0;
- font-size: var(--fs-xs);
- color: var(--zk-ink);
- line-height: 1.45;
- overflow-wrap: anywhere;
-}
-
-.prop__num {
- font-variant-numeric: tabular-nums;
- font-weight: 500;
-}
-
-/* Prose is not a property. It takes the full width, is labelled above rather
- than beside, and is folded by ClampText. */
-.prop--note {
- grid-column: 1 / -1;
- display: block;
- margin: 10px -10px;
- padding: 14px 16px;
- border-bottom: 0;
- border: 1px solid var(--zk-line-soft);
- border-left: 3px solid var(--zk-blue-light);
- border-radius: 0 var(--r-md) var(--r-md) 0;
- background: var(--zk-tint);
-}
-
-.prop--note:hover {
- background: var(--zk-tint);
- border-color: var(--zk-blue-light);
-}
-
-.prop--note dt {
- margin-bottom: 7px;
- font-size: var(--fs-3xs);
- font-weight: 500;
- letter-spacing: 0.09em;
- text-transform: uppercase;
- color: var(--zk-blue-dark);
+.door:hover { border-color: var(--zk-blue-light); box-shadow: var(--sh-card-hover); }
+.door:focus-visible { border-color: var(--zk-blue); box-shadow: var(--ring); outline: none; }
+.door strong { font-size: var(--fs-md); font-weight: var(--fw-medium); color: var(--zk-ink); }
+.door p { margin: 0; font-size: var(--fs-2xs); color: var(--zk-muted); line-height: 1.55; }
+.chip {
+ display: inline-flex; align-items: center; align-self: flex-start;
+ font-size: var(--fs-2xs); font-weight: var(--fw-medium);
+ padding: 2px 9px; border-radius: var(--r-pill);
+ background: var(--zk-tint-blue); color: var(--zk-blue-dark);
}
-.prop--note dd {
- margin: 0;
-}
-
-/* ---- lead file ---- */
-.lead__stage {
- flex: none;
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: var(--sp-2);
- text-align: right;
-}
-
-.lead__stagelabel {
- display: block;
- font-size: var(--fs-3xs);
- font-weight: 500;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--zk-grey);
-}
-
-.lead__closed {
- font-size: var(--fs-2xs);
- color: var(--zk-grey);
-}
-
-.lead__stage strong {
- font-size: var(--fs-md);
- font-weight: var(--fw-semi);
- letter-spacing: -0.01em;
- color: var(--zk-blue-dark);
- padding: var(--sp-2) var(--sp-5);
- border-radius: var(--r-pill);
- background: var(--zk-tint-blue);
- border: 1px solid var(--zk-blue-light);
-}
-
-/* A closed lead is not a live stage; it should not wear the live stage's blue. */
-.lead__stage strong.is-closed {
- color: var(--zk-muted);
- background: var(--zk-tint);
- border-color: var(--zk-line);
-}
-
-/* ---- renewal countdown ---- */
-.due {
- display: inline-flex;
- align-items: center;
- font-size: var(--fs-2xs);
- font-weight: 500;
- padding: 3px 10px;
- border-radius: var(--r-pill);
- white-space: nowrap;
-}
-
-.due--lapsed {
- background: var(--zk-danger-tint);
- color: var(--zk-danger-ink);
- border: 1px solid var(--zk-danger-line);
-}
-
-.due--urgent {
- background: var(--zk-amber-tint);
- color: var(--zk-amber-ink);
- border: 1px solid var(--zk-amber-line);
-}
-
-.due--soon {
- background: var(--zk-tint-blue);
- color: var(--zk-blue-dark);
- border: 1px solid var(--zk-blue-light);
-}
-
-.due--later {
- background: var(--zk-tint);
- color: var(--zk-muted);
- border: 1px solid var(--zk-line);
-}
-
-/* ---- lead header meta ---- */
-.lead__meta {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- margin: 0 0 22px;
-}
-
-.lead__meta div {
- display: flex;
- flex-direction: column;
- gap: 3px;
- padding: 9px 16px;
- border-radius: var(--r-md);
- background: var(--zk-white);
- border: 1px solid var(--zk-line);
- box-shadow: var(--sh-xs);
-}
-
-.lead__meta dt {
- font-size: var(--fs-3xs);
- font-weight: 500;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--zk-grey);
-}
-
-.lead__meta dd {
- margin: 0;
- font-size: var(--fs-xs);
- color: var(--zk-ink);
-}
-
-.lead__meta--money {
- border-color: var(--zk-blue-light) !important;
- background: var(--zk-tint-blue) !important;
-}
-
-.lead__meta--money dd {
- font-size: var(--fs-md);
- font-weight: 500;
- color: var(--zk-blue-dark);
- font-variant-numeric: tabular-nums;
-}
-
-/* ── Portfolio overview ──────────────────────────────────────────────────
- An operations dashboard: numbers first, prose nowhere. Every measure on
- this page is derived from the lead list in the browser, so nothing here is
- a figure the reader cannot get back to by opening a queue. */
-
-.stamp {
- flex: none;
- align-self: center;
- font-size: var(--fs-3xs);
- letter-spacing: .04em;
- text-transform: uppercase;
- color: var(--zk-grey);
- font-variant-numeric: tabular-nums;
-}
+/* ═══════════════════════════════════════════════════════════════════════════
+ OVERVIEW
+ ═══════════════════════════════════════════════════════════════════════════ */
-/* ── Right now ─────────────────────────────────────────────────────────────
- The overview's first row. Four large numbers on a navy ground so the page
- opens on a statement rather than a grid: this is who holds the work. Each
- cell carries its own hue at the top edge — blue for the machine, amber for
- a person, teal for the customer, red for risk — so the row can be read as
- colour before it is read as text. */
+/* Who holds the work — four quiet cards; the ones actually owed warm up. */
.now {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-3);
- margin-bottom: var(--sp-5);
+ margin-bottom: var(--sp-3);
}
-
.now__cell {
position: relative;
- padding: var(--sp-6) var(--sp-6) var(--sp-5);
- border-radius: var(--r-lg);
- background: var(--zk-navy);
- color: var(--zk-white);
- overflow: hidden;
- box-shadow: var(--sh-md);
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ grid-template-areas: 'ico label' 'ico value' 'ico sub';
+ column-gap: 14px;
+ row-gap: 2px;
+ padding: var(--sp-4) var(--sp-5);
+ transition: box-shadow var(--t);
}
-
-.now__cell::before {
- content: '';
- position: absolute;
- inset: 0 0 auto 0;
- height: 4px;
- background: var(--zk-blue-light);
+.now__ico {
+ grid-area: ico;
+ display: grid;
+ place-items: center;
+ width: 40px;
+ height: 40px;
+ border-radius: 11px;
+ color: var(--zk-blue-dark);
+ background: var(--zk-tint-blue);
}
-.now__cell--person::before { background: var(--zk-amber-line); }
-.now__cell--cust::before { background: var(--zk-cyan); }
-.now__cell--risk::before { background: var(--zk-danger-line); }
-
-/* A live person-queue or a lapsed renewal is the one thing on this page that
- is actually owed. It warms up; the rest stay navy. */
-.now__cell--person.is-live { background: var(--zk-amber-ink); }
-.now__cell--risk.is-live { background: var(--zk-danger-ink); }
-
-.now__l {
- display: block;
- margin-bottom: var(--sp-3);
- font-size: var(--fs-3xs);
- font-weight: var(--fw-semi);
- letter-spacing: .09em;
- text-transform: uppercase;
- opacity: .78;
-}
-
+.now__l { grid-area: label; font-size: var(--fs-2xs); font-weight: var(--fw-medium); color: var(--zk-muted); }
.now__v {
- display: block;
- margin-bottom: var(--sp-2);
- font-size: var(--fs-3xl);
+ grid-area: value;
+ font-size: var(--fs-2xl);
font-weight: var(--fw-semi);
- line-height: 1;
- letter-spacing: -.03em;
+ line-height: 1.1;
+ letter-spacing: -0.03em;
+ color: var(--zk-ink);
font-variant-numeric: tabular-nums;
}
+.now__s { grid-area: sub; font-size: var(--fs-2xs); line-height: 1.4; color: var(--zk-grey); }
-.now__s {
- display: block;
- font-size: var(--fs-2xs);
- line-height: var(--lh-snug);
- opacity: .82;
-}
+.now__cell--person .now__ico { color: var(--zk-amber-ink); background: var(--zk-amber-tint); }
+.now__cell--cust .now__ico { color: var(--zk-teal-ink); background: var(--zk-teal-tint); }
+.now__cell--risk .now__ico { color: var(--zk-danger-ink); background: var(--zk-danger-tint); }
-/* KPI strip. Whitespace rather than rules — see .lmetrics for why the cage
- went. Tabular figures so the row does not jitter as it refreshes. */
-.kpis {
+.now__cell--person.is-live { border-color: var(--zk-amber-line); background: linear-gradient(180deg, #fff 0%, var(--zk-amber-tint) 100%); }
+.now__cell--person.is-live .now__v { color: var(--zk-amber-ink); }
+.now__cell--risk.is-live { border-color: var(--zk-danger-line); background: linear-gradient(180deg, #fff 0%, var(--zk-danger-tint) 100%); }
+.now__cell--risk.is-live .now__v { color: var(--zk-danger-ink); }
+
+/* The pipeline, as one bar */
+.flow { padding: var(--sp-5) var(--sp-6) var(--sp-5); margin-bottom: var(--sp-3); }
+.flow__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-4); }
+.flow__legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: var(--fs-2xs); color: var(--zk-muted); }
+.flow__legend span { display: inline-flex; align-items: center; gap: 6px; }
+.flow__sw { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
+.flow__sw--blue { background: var(--zk-blue); }
+.flow__sw--amber { background: var(--zk-amber); }
+.flow__sw--teal { background: var(--zk-teal); }
+.flow__sw--grey { background: var(--zk-grey); }
+
+.flow__bar {
display: flex;
- flex-wrap: wrap;
- gap: var(--sp-6) var(--sp-8);
- padding: var(--sp-6) 0;
- border-top: 1px solid var(--zk-line);
- border-bottom: 1px solid var(--zk-line);
- margin-bottom: var(--sp-6);
+ gap: 3px;
+ height: 34px;
+ border-radius: 9px;
+ overflow: hidden;
}
-
-.kpi { min-width: 0; }
-
-.kpi__l {
- display: block;
- margin-bottom: var(--sp-2);
- font-size: var(--fs-3xs);
+.flow__seg {
+ flex: 1 1 0;
+ min-width: 34px;
+ display: grid;
+ place-items: center;
+ border-radius: 5px;
+ color: #fff;
+ font-size: var(--fs-2xs);
font-weight: var(--fw-semi);
- letter-spacing: .08em;
- text-transform: uppercase;
- color: var(--zk-grey);
+ font-variant-numeric: tabular-nums;
+ text-decoration: none;
+ transition: filter var(--t-fast), transform var(--t-fast);
}
+.flow__seg:hover { filter: brightness(1.08); }
+.flow__seg--blue { background: var(--zk-blue); }
+.flow__seg--amber { background: var(--zk-amber); }
+.flow__seg--teal { background: var(--zk-teal); }
+.flow__seg--grey { background: var(--zk-grey); }
+.flow__seg--line { background: var(--zk-line-soft); }
+.flow__stages {
+ list-style: none;
+ margin: var(--sp-4) 0 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 4px 8px;
+}
+.flow__stage {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ border-radius: var(--r-xs);
+ text-decoration: none;
+ color: var(--zk-ink);
+ font-size: var(--fs-2xs);
+ transition: background var(--t-fast);
+}
+.flow__stage:hover { background: var(--zk-hover); }
+.flow__stage b { margin-left: auto; font-weight: var(--fw-semi); font-variant-numeric: tabular-nums; }
+.flow__stage.is-zero { color: var(--zk-grey); }
+.flow__stage.is-zero b { font-weight: var(--fw-normal); }
+.flow__stage.is-end .flow__sname { color: var(--zk-muted); }
+.flow__sname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.flow__ki { flex: none; }
+.flow__ki--blue { color: var(--zk-blue); }
+.flow__ki--amber { color: var(--zk-amber); }
+.flow__ki--teal { color: var(--zk-teal); }
+.flow__ki--grey { color: var(--zk-grey); }
+.flow__ki--line { color: var(--zk-grey); }
+
+/* KPI strip */
+.kpis {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: var(--sp-4) var(--sp-6);
+ padding: var(--sp-4) var(--sp-6);
+ margin-bottom: var(--sp-5);
+}
+.kpi { min-width: 0; padding-left: var(--sp-4); border-left: 1px solid var(--zk-line-soft); }
+.kpi:first-child { padding-left: 0; border-left: 0; }
+.kpi__l { display: block; margin-bottom: 4px; font-size: var(--fs-2xs); font-weight: var(--fw-medium); color: var(--zk-muted); }
.kpi__v {
display: block;
- margin-bottom: var(--sp-1);
- font-size: var(--fs-3xl);
+ margin-bottom: 2px;
+ font-size: var(--fs-xl);
font-weight: var(--fw-semi);
line-height: var(--lh-tight);
color: var(--zk-navy);
font-variant-numeric: tabular-nums;
- letter-spacing: -.028em;
+ letter-spacing: -.025em;
}
+.kpi__s { display: block; font-size: var(--fs-2xs); line-height: 1.4; color: var(--zk-grey); }
-.kpi__s {
- display: block;
- font-size: var(--fs-2xs);
- line-height: var(--lh-snug);
- color: var(--zk-muted);
-}
-
-/* Two columns on a desk, one on anything narrower. The distribution is the
- half that survives being pushed below the fold. */
-.split { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); gap: 30px; align-items: start; }
-
-.sec {
+/* A sign-off, not a task */
+.appr {
display: flex;
- align-items: baseline;
- gap: 10px;
- margin: 0 0 12px;
- font-size: var(--fs-3xs);
- font-weight: 500;
- letter-spacing: .07em;
- text-transform: uppercase;
- color: var(--zk-muted);
+ align-items: center;
+ gap: 16px;
+ margin-bottom: var(--sp-5);
+ padding: 14px 20px;
+ text-decoration: none;
+ color: inherit;
+ transition: border-color var(--t-fast), box-shadow var(--t-fast);
}
-.split > div > .sec:not(:first-child) { margin-top: 30px; }
-.sec__hint { font-size: var(--fs-2xs); font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--zk-grey); }
+.appr:hover { box-shadow: var(--sh-card-hover); }
+.appr:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
+.appr__ico { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 11px; color: var(--zk-muted); background: var(--zk-line-soft); flex: none; }
+.appr.is-live { border-color: var(--zk-amber-line); background: linear-gradient(90deg, var(--zk-amber-tint) 0%, #fff 60%); }
+.appr.is-live .appr__ico { color: var(--zk-amber-ink); background: #fff; }
+.appr__n { flex: none; min-width: 34px; font-size: var(--fs-2xl); font-weight: var(--fw-semi); line-height: 1; letter-spacing: -0.02em; color: var(--zk-grey); font-variant-numeric: tabular-nums; }
+.appr.is-live .appr__n { color: var(--zk-amber-ink); }
+.appr__t { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; font-size: var(--fs-sm); font-weight: var(--fw-medium); color: var(--zk-ink); }
+.appr__t em { font-style: normal; font-size: var(--fs-2xs); font-weight: var(--fw-normal); line-height: 1.45; color: var(--zk-muted); }
+.appr__go { flex: none; display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-2xs); color: var(--zk-muted); white-space: nowrap; }
-/* Work queues. A row rather than a card: five of them as cards is a wall. */
+/* Two columns: what needs a person / what is about to lapse. */
+.split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: var(--sp-5); align-items: start; }
+
+/* Work queues */
.qlist { display: flex; flex-direction: column; gap: 6px; }
.q {
display: grid;
- grid-template-columns: 44px 1fr auto auto;
+ grid-template-columns: auto minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 12px;
- padding: 11px 14px;
+ padding: 10px 12px 10px 10px;
border: 1px solid var(--zk-line-soft);
- border-radius: var(--r-sm);
- background: var(--zk-white);
+ border-radius: var(--r-md);
+ background: var(--zk-card);
text-decoration: none;
color: inherit;
transition: border-color .12s, background .12s;
}
-.q:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); }
-.q__n { font-size: var(--fs-xl); font-weight: 500; color: var(--zk-grey); font-variant-numeric: tabular-nums; text-align: right; }
-.q__t { font-size: var(--fs-xs); color: var(--zk-ink); }
-.q__t em { display: block; font-style: normal; font-size: var(--fs-2xs); color: var(--zk-muted); margin-top: 1px; }
-.q__age { font-size: var(--fs-2xs); color: var(--zk-grey); font-variant-numeric: tabular-nums; }
-.q__ro {
- font-size: var(--fs-3xs); letter-spacing: .06em; text-transform: uppercase;
- color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px;
-}
-/* Only a queue with work in it earns weight. The eye should land on the
- number that needs clearing, not read five identical rows. */
-.q.is-live { border-color: var(--zk-line); }
-.q.is-live .q__n { color: var(--zk-navy); }
+.q:hover { border-color: var(--zk-blue-light); background: var(--zk-hover); }
+.q__ico { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; color: var(--zk-muted); background: var(--zk-line-soft); }
+.q__ico--needs { color: var(--zk-amber-ink); background: var(--zk-amber-tint); }
+.q__ico--customer { color: var(--zk-teal-ink); background: var(--zk-teal-tint); }
+.q__t { font-size: var(--fs-xs); font-weight: var(--fw-medium); color: var(--zk-ink); min-width: 0; }
+.q__t em { display: block; font-style: normal; font-weight: var(--fw-normal); font-size: var(--fs-2xs); color: var(--zk-muted); margin-top: 1px; }
+.q__age { font-size: var(--fs-2xs); color: var(--zk-grey); font-variant-numeric: tabular-nums; white-space: nowrap; }
+.q__n { min-width: 28px; text-align: right; font-size: var(--fs-lg); font-weight: var(--fw-semi); color: var(--zk-grey); font-variant-numeric: tabular-nums; }
+.q.is-live .q__n { color: var(--zk-ink); }
+.q__go { display: grid; place-items: center; width: 22px; height: 22px; color: var(--zk-grey); }
+.q:hover .q__go { color: var(--zk-blue); }
+.q__go--ro { color: var(--zk-grey); }
-.buckets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
+/* Expiry bands as filters */
+.buckets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: var(--sp-4); }
.bucket {
- padding: 13px 15px;
- border: 1px solid var(--zk-line);
- border-left-width: 3px;
- border-radius: var(--r-sm);
- background: var(--zk-white);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1px;
+ padding: 10px 12px;
+ border: 1px solid var(--zk-card-line);
+ border-radius: var(--r-md);
+ background: var(--zk-card);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition: border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast);
}
-.bucket strong { display: block; font-size: var(--fs-xl); font-weight: 500; line-height: 1.1; font-variant-numeric: tabular-nums; }
-.bucket span { display: block; margin-top: 2px; font-size: var(--fs-2xs); color: var(--zk-muted); }
-.bucket--lapsed { border-left-color: var(--zk-danger); }
+.bucket:hover { border-color: var(--zk-grey); }
+.bucket strong { font-size: var(--fs-lg); font-weight: var(--fw-semi); line-height: 1.1; font-variant-numeric: tabular-nums; }
+.bucket span { font-size: var(--fs-2xs); color: var(--zk-muted); }
.bucket--lapsed strong { color: var(--zk-danger-ink); }
-.bucket--urgent { border-left-color: var(--zk-amber); }
-.bucket--urgent strong { color: var(--zk-amber-ink); }
-.bucket--soon { border-left-color: var(--zk-blue-mid); }
-.bucket--soon strong { color: var(--zk-blue-dark); }
+.bucket--week strong { color: var(--zk-amber-ink); }
+.bucket--month strong { color: var(--zk-blue-dark); }
+.bucket--lapsed.is-on { background: var(--zk-danger-tint); border-color: var(--zk-danger-line); }
+.bucket--week.is-on { background: var(--zk-amber-tint); border-color: var(--zk-amber-line); }
+.bucket--month.is-on { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
+.bucket:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
-/* Stage distribution. A bar per stage, scaled to the largest — enough to see
- where the book is banked without pulling in a charting library. */
-.dist { display: flex; flex-direction: column; gap: 3px; }
-.dist__r {
- display: grid;
- grid-template-columns: 1fr 74px 26px;
+/* ═══════════════════════════════════════════════════════════════════════════
+ LEAD
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+/* Whose move it is — a pill in the header */
+.holds {
+ flex: none;
+ display: inline-flex;
align-items: center;
gap: 10px;
- padding: 5px 8px;
- border-radius: var(--r-xs);
- text-decoration: none;
- color: inherit;
+ padding: 6px 14px 6px 6px;
+ border-radius: var(--r-pill);
+ border: 1px solid var(--zk-card-line);
+ background: var(--zk-card);
}
-.dist__r:hover { background: var(--zk-tint); }
-.dist__l { font-size: var(--fs-2xs); color: var(--zk-ink); }
-.dist__bar { height: 6px; border-radius: 3px; background: var(--zk-line-soft); overflow: hidden; }
-.dist__f { display: block; height: 100%; border-radius: 3px; background: var(--zk-blue-mid); min-width: 0; }
-.dist__f--needs { background: var(--zk-amber); }
-.dist__f--customer { background: var(--zk-blue); }
-.dist__f--auto { background: var(--zk-blue-light); }
-.dist__f--waiting { background: var(--zk-grey); }
-.dist__f--end { background: var(--zk-line); }
-.dist__n { font-size: var(--fs-2xs); text-align: right; color: var(--zk-muted); font-variant-numeric: tabular-nums; }
-.dist__r.is-zero .dist__l, .dist__r.is-zero .dist__n { color: var(--zk-grey); }
+.holds__puck { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; color: var(--zk-muted); background: var(--zk-line-soft); }
+.holds__txt { display: flex; flex-direction: column; line-height: 1.2; }
+.holds__txt small { font-size: 11px; font-weight: var(--fw-medium); color: var(--zk-grey); }
+.holds__txt b { font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--zk-ink); }
+.holds--ai .holds__puck { color: var(--zk-blue-dark); background: var(--zk-tint-blue); }
+.holds--person .holds__puck { color: var(--zk-amber-ink); background: var(--zk-amber-tint); }
+.holds--cust .holds__puck { color: var(--zk-teal-ink); background: var(--zk-teal-tint); }
-.grid--tight th, .grid--tight td { padding-top: 8px; padding-bottom: 8px; }
-
-@media (max-width: 1080px) {
- .split { grid-template-columns: 1fr; gap: 26px; }
-}
-@media (max-width: 640px) {
- .buckets { grid-template-columns: 1fr; }
+/* Your move */
+.cta {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 14px;
+ padding: 8px 14px 8px 16px;
+ border: 0;
+ border-radius: var(--r-md);
+ background: var(--zk-amber);
+ color: #fff;
+ cursor: pointer;
+ box-shadow: 0 6px 16px -8px rgba(181, 118, 31, .6);
+ transition: background var(--t-fast), box-shadow var(--t-fast), transform var(--t-fast);
}
+.cta:hover { background: var(--zk-amber-ink); transform: translateY(-1px); }
+.cta:focus-visible { outline: 2px solid var(--zk-amber-ink); outline-offset: 2px; }
+.cta__txt { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.2; }
+.cta__txt small { font-size: 11px; font-weight: var(--fw-medium); opacity: .85; }
+.cta__txt b { font-size: var(--fs-sm); font-weight: var(--fw-semi); }
-/* ── Lead measures ───────────────────────────────────────────────────────
- The same strip as the portfolio KPIs, at lead scale. Age and dwell time are
- computed rather than stored, and they are the two figures that answer "is
- this moving?" — a question the record itself cannot answer. */
-/* ── The numbers that matter ──────────────────────────────────────────────
- This was a 1px-gap hairline grid — seven equal cells in a cage, every
- value the same size as every label. The cage is a 2015 device and the
- equality was the real fault: if nothing is bigger, the reader has to do
- the prioritising themselves, seven times, on every lead.
-
- Whitespace separates the cells now instead of rules, and the values are
- 22px against 12px labels. Premium and commission — the two numbers
- anyone actually came for — are bigger again. */
+/* The numbers that matter, plus the journey */
.lmetrics {
- display: flex;
- flex-wrap: wrap;
- gap: var(--sp-6) var(--sp-8);
- padding: var(--sp-6) 0;
- border-top: 1px solid var(--zk-line);
- border-bottom: 1px solid var(--zk-line);
- margin-bottom: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: var(--sp-4) var(--sp-6);
+ padding: var(--sp-4) var(--sp-6) 0;
+ margin-bottom: var(--sp-5);
}
-
-.lm { min-width: 0; }
-
-.lm__l {
- display: block;
- margin-bottom: var(--sp-2);
- font-size: var(--fs-3xs);
- font-weight: var(--fw-semi);
- letter-spacing: .08em;
- text-transform: uppercase;
- color: var(--zk-grey);
-}
-
+.lm { min-width: 0; padding-bottom: var(--sp-4); }
+.lm__l { display: block; margin-bottom: 4px; font-size: var(--fs-2xs); font-weight: var(--fw-medium); color: var(--zk-muted); }
.lm__v {
display: block;
- margin-bottom: var(--sp-1);
+ margin-bottom: 2px;
font-size: var(--fs-xl);
font-weight: var(--fw-semi);
line-height: var(--lh-tight);
@@ -1126,84 +746,165 @@
font-variant-numeric: tabular-nums;
letter-spacing: -.02em;
}
-
-/* Money leads. Size does the prioritising so the reader does not have to. */
.lm--hero .lm__v { font-size: var(--fs-2xl); }
+.lm__s { display: block; font-size: var(--fs-2xs); line-height: 1.4; color: var(--zk-grey); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+/* The countdown carries its tone as COLOUR only — no pill box on a figure. */
+.lm__v--lapsed { color: var(--zk-danger-ink); }
+.lm__v--urgent { color: var(--zk-amber-ink); }
+.lm__v--soon { color: var(--zk-blue-dark); }
+.lm--rail { grid-column: 1 / -1; padding: var(--sp-3) 0 var(--sp-2); border-top: 1px solid var(--zk-line-soft); }
-.lm__v--sm { font-size: var(--fs-md); font-weight: var(--fw-medium); }
-
-.lm__s {
- display: block;
- font-size: var(--fs-2xs);
- line-height: var(--lh-snug);
- color: var(--zk-muted);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+/* Two columns: the story, and the reference rail */
+.lead {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 340px;
+ gap: var(--sp-5);
+ align-items: start;
}
+.lead__main { min-width: 0; }
+.lead__side {
+ position: sticky;
+ top: 72px;
+ min-width: 0;
+ max-height: calc(100vh - 88px);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+.lead__side .sect { margin-bottom: var(--sp-4); }
-/* The countdown carries its own tone here, the same one the queues use, so a
- lapsed renewal reads the same wherever it appears. */
-.lm__v.due--lapsed { color: var(--zk-danger-ink); }
-.lm__v.due--urgent { color: var(--zk-amber-ink); }
+/* What the workflow said */
+.said {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: var(--sp-4);
+ padding: 12px 12px 12px 16px;
+ border-radius: var(--r-md);
+ border: 1px solid var(--zk-green-line);
+ background: var(--zk-green-tint);
+ animation: zk-rise 0.24s var(--ease) both;
+}
+.said__ico { flex: none; color: var(--zk-green-ink); }
+.said p { flex: 1; margin: 0; font-size: var(--fs-xs); line-height: 1.5; color: var(--zk-green-ink); }
+.said button { flex: none; display: grid; place-items: center; width: 26px; height: 26px; cursor: pointer; border: 0; border-radius: 6px; background: none; color: var(--zk-green-ink); }
+.said button:hover { background: rgba(255,255,255,.6); }
-/* ── Next step ───────────────────────────────────────────────────────────
- One decision, sized like one. The stage's own step is the only thing at
- full weight; loops sit under it, and an AI's own work is folded away
- entirely — it is recovery, not an outstanding task. */
+/* What the machine is doing */
+.doing {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ margin-bottom: var(--sp-4);
+ padding: 14px 18px;
+ border-radius: var(--r-card);
+ border: 1px solid var(--zk-blue-light);
+ background: var(--zk-tint-blue);
+}
+.doing > div { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
+.doing strong { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--zk-blue-dark); }
+.doing span { font-size: var(--fs-2xs); line-height: 1.5; color: var(--zk-muted); }
+.doing__ico { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; color: var(--zk-blue-dark); background: #fff; }
+.doing__ico--pulse { box-shadow: 0 0 0 0 rgba(33, 103, 174, 0.4); animation: zk-pulse 1.9s var(--ease) infinite; }
+@keyframes zk-pulse {
+ 70% { box-shadow: 0 0 0 9px rgba(33, 103, 174, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(33, 103, 174, 0); }
+}
+.doing--blocked, .doing--stalled { border-color: var(--zk-amber-line); background: var(--zk-amber-tint); }
+.doing--blocked strong, .doing--stalled strong { color: var(--zk-amber-ink); }
+.doing--blocked .doing__ico, .doing--stalled .doing__ico { color: var(--zk-amber-ink); }
+.doing--waiting { border-color: var(--zk-teal-line); background: var(--zk-teal-tint); }
+.doing--waiting strong { color: var(--zk-teal-ink); }
+.doing--waiting .doing__ico { color: var(--zk-teal-ink); }
+.doing__fix {
+ align-self: flex-start;
+ margin-top: 8px;
+ font: inherit;
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
+ cursor: pointer;
+ padding: 6px 13px;
+ border-radius: var(--r-sm);
+ color: var(--zk-amber-ink);
+ background: #fff;
+ border: 1px solid var(--zk-amber-line);
+ transition: background var(--t-fast), border-color var(--t-fast);
+}
+.doing__fix:hover { border-color: var(--zk-amber); }
+/* Next step */
.step { display: flex; flex-wrap: wrap; gap: 10px; }
.step__btn {
flex: 1 1 220px;
- display: block;
- padding: 14px 18px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
border: 1px solid var(--zk-blue);
border-radius: var(--r-md);
background: var(--zk-blue);
- color: var(--zk-white);
+ color: #fff;
text-align: left;
cursor: pointer;
+ font: inherit;
transition: background .12s, border-color .12s, box-shadow .12s;
}
-.step__btn:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); box-shadow: 0 2px 10px rgba(33,103,174,.25); }
-.step__btn strong { display: block; font-size: var(--fs-sm); font-weight: 500; }
-.step__btn span { display: block; margin-top: 2px; font-size: var(--fs-2xs); opacity: .85; }
-.step__btn.is-open { background: var(--zk-blue-deep); border-color: var(--zk-blue-deep); }
-
-/* Decline sits beside Clear as an equal — an underwriter is choosing between
- two decisions, not deciding whether to act. It is outlined rather than
- filled so the pair does not read as two identical recommendations. */
-.step__btn--no {
- background: var(--zk-white);
- color: var(--zk-danger-ink);
- border-color: var(--zk-danger-line);
-}
+.step__btn:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); box-shadow: 0 6px 16px -8px rgba(33,103,174,.6); }
+.step__btn.is-open { background: var(--zk-blue-deep); border-color: var(--zk-blue-deep); box-shadow: inset 0 0 0 2px rgba(255,255,255,.35); }
+.step__ico { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: rgba(255,255,255,.18); flex: none; }
+.step__t { display: flex; flex-direction: column; min-width: 0; }
+.step__t strong { font-size: var(--fs-sm); font-weight: var(--fw-semi); }
+.step__t span { font-size: var(--fs-2xs); opacity: .85; margin-top: 1px; }
+.step__btn--no { background: #fff; color: var(--zk-danger-ink); border-color: var(--zk-danger-line); }
+.step__btn--no .step__ico { background: var(--zk-danger-tint); }
.step__btn--no:hover { background: var(--zk-danger-tint); border-color: var(--zk-danger); box-shadow: none; }
-.step__btn--no.is-open { background: var(--zk-danger-tint); border-color: var(--zk-danger); }
+.step__btn--no.is-open { background: var(--zk-danger-tint); border-color: var(--zk-danger); box-shadow: none; }
-/* Loops. Real, bounded, and never the answer to "what now". */
.alt { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.alt__btn {
- padding: 8px 13px;
- border: 1px solid var(--zk-line);
+ padding: 7px 12px;
+ border: 1px solid var(--zk-card-line);
border-radius: var(--r-sm);
- background: var(--zk-white);
+ background: var(--zk-card);
color: var(--zk-ink);
+ font: inherit;
font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
cursor: pointer;
transition: border-color .12s, background .12s;
}
-.alt__btn:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); }
-.alt__btn.is-open { border-color: var(--zk-blue); background: var(--zk-tint-blue); }
-.alt__btn em { font-style: normal; margin-left: 7px; font-size: var(--fs-2xs); color: var(--zk-grey); }
+.alt__btn:hover { border-color: var(--zk-blue-light); background: var(--zk-hover); }
+.alt__btn.is-open { border-color: var(--zk-blue); background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
+.alt__btn em { font-style: normal; margin-left: 7px; font-weight: var(--fw-normal); color: var(--zk-grey); }
.alt__btn--exit { color: var(--zk-danger-ink); }
.alt__btn--exit:hover { border-color: var(--zk-danger-line); background: var(--zk-danger-tint); }
-/* Recovery, folded. Needed on the day an agent stalls and never otherwise. */
-.stuck { margin-top: 18px; border-top: 1px solid var(--zk-line-soft); padding-top: 14px; }
+/* The form, in a bounded panel with the action's own name on it */
+.acts__form {
+ margin-top: var(--sp-4);
+ padding: var(--sp-4) var(--sp-5) var(--sp-5);
+ border: 1px solid var(--zk-card-line);
+ border-radius: var(--r-md);
+ background: var(--zk-ground);
+ animation: zk-rise 0.24s var(--ease) both;
+}
+.acts__formhead {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: var(--sp-4);
+ padding-bottom: var(--sp-3);
+ border-bottom: 1px solid var(--zk-line-soft);
+}
+.acts__formhead h3 { margin: 0; font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--zk-ink); }
+.acts__formhead p { margin: 2px 0 0; font-size: var(--fs-2xs); color: var(--zk-muted); }
+
+/* Recovery, folded */
+.stuck { margin-top: 16px; border-top: 1px solid var(--zk-line-soft); padding-top: 12px; }
.stuck > summary {
cursor: pointer;
font-size: var(--fs-2xs);
+ font-weight: var(--fw-medium);
color: var(--zk-muted);
list-style: none;
display: inline-flex;
@@ -1211,622 +912,171 @@
gap: 6px;
}
.stuck > summary::-webkit-details-marker { display: none; }
-.stuck > summary::before { content: '▸'; font-size: var(--fs-2xs); color: var(--zk-grey); }
-.stuck[open] > summary::before { content: '▾'; }
+.stuck__caret { transition: transform var(--t-fast); }
+.stuck[open] .stuck__caret { transform: rotate(90deg); }
.stuck > summary:hover { color: var(--zk-blue-dark); }
.stuck__why { margin: 10px 0 0; font-size: var(--fs-2xs); color: var(--zk-muted); max-width: 64ch; }
-/* ── The row IS the control ───────────────────────────────────────────────
- An "Open" button was a ~90px target inside a 1000px row that already reads
- as one object, and it put a second tab stop on every line. The row carries
- the click, the keyboard and the affordance; the button is gone.
-
- role="link" + tabIndex on a rather than wrapping cells in : an
- anchor cannot span table cells without breaking the table layout, and
- display:block on a collapses the column alignment the queue depends on. */
-.grid__row { cursor: pointer; transition: background .11s, box-shadow .11s; }
-.grid__row:hover { background: var(--zk-tint); }
-.grid__row:hover .grid__name { color: var(--zk-blue-dark); }
-.grid__row:focus-visible {
- outline: 2px solid var(--zk-blue);
- outline-offset: -2px;
- background: var(--zk-tint);
-}
-.grid__name { font-weight: 500; color: var(--zk-ink); }
-
-/* A left edge that says who holds the lead, readable before any text is.
- Only the states worth acting on are marked — colouring every row would
- make the marking mean nothing. */
-.grid__row.is-stalled td:first-child { box-shadow: inset 3px 0 0 var(--zk-danger); }
-.grid__row.is-working td:first-child { box-shadow: inset 3px 0 0 var(--zk-blue-mid); }
-
-/* ── What the agent is doing ──────────────────────────────────────────────
- Agent-status disclosure: an automated stage is either moving or stuck, and
- after an hour those look identical without this. `working` pulses because
- something is happening right now; `waiting` is steady because it is normal;
- `stalled` is red because a lead three hours into a two-minute step is the
- thing this console exists to catch. */
-.run { display: inline-flex; align-items: center; gap: 7px; font-size: var(--fs-2xs); line-height: 1.3; }
-.run__dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
-
-.run--working { color: var(--zk-blue-dark); }
-.run--working .run__dot { background: var(--zk-blue); animation: runPulse 1.6s ease-in-out infinite; }
-
-.run--waiting { color: var(--zk-muted); }
-.run--waiting .run__dot { background: var(--zk-blue-light); }
-
-.run--stalled { color: var(--zk-danger-ink); font-weight: 500; }
-.run--stalled .run__dot { background: var(--zk-danger); }
-
-@keyframes runPulse {
- 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(33,103,174,.45); }
- 50% { opacity: .55; box-shadow: 0 0 0 5px rgba(33,103,174,0); }
-}
-/* Respect a reader who has asked the interface to stop moving. */
-@media (prefers-reduced-motion: reduce) {
- .run--working .run__dot { animation: none; }
-}
-
-/* ── An attached document is a thing to open ──────────────────────────────
- The record printed a filename as text, so the only way to see what an
- agent had actually uploaded was to perform the activity again. */
-.prop__files { display: flex; flex-wrap: wrap; gap: 6px; }
-
-.prop__file {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- max-width: 100%;
- font-size: var(--fs-2xs);
- text-decoration: none;
- padding: 3px 10px;
- border-radius: var(--r-pill, 999px);
- background: var(--zk-tint-blue);
- border: 1px solid transparent;
- color: var(--zk-blue-dark);
- overflow-wrap: anywhere;
-}
-.prop__file:hover { border-color: var(--zk-blue-light); }
-.prop__file:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
-
-/* The part of a queue that is not anyone's task. Reported rather than hidden:
- the leads are in that state, they are just being carried by an agent, and an
- operator who counts the queue by hand should find the same total. */
-.q__auto {
- display: block;
- font-weight: 400;
- font-size: var(--fs-2xs);
- color: var(--zk-muted);
- margin-top: 2px;
-}
-
-/* ---- waiting on someone outside the business ----
- Neither an agent working nor a task of yours. Grey rather than blue, and
- no pulse: nothing is happening, and that is the correct state. */
-.doing--waiting {
- border-color: var(--zk-line);
- background: var(--zk-tint);
-}
-.doing--waiting strong { color: var(--zk-ink); }
-
-.doing__wait {
- flex: none;
- margin-top: 5px;
- width: 10px;
- height: 10px;
- border-radius: 50%;
- border: 2px solid var(--zk-grey);
- background: transparent;
-}
-
-/* ---- panel action ----
- A secondary control on a panel heading. Quiet: it opens a view, it does not
- change anything. */
-.panel__act {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- flex: none;
- font: inherit;
- font-size: var(--fs-2xs);
- font-weight: 500;
- cursor: pointer;
- padding: 6px 12px;
- border-radius: var(--r-pill);
- color: var(--zk-blue-dark);
- background: var(--zk-white);
- border: 1px solid var(--zk-line);
- transition: background var(--t-fast), border-color var(--t-fast);
-}
-.panel__act svg { width: 13px; height: 13px; opacity: .75; }
-.panel__act b {
- font-size: var(--fs-2xs);
- font-weight: 600;
- font-variant-numeric: tabular-nums;
- padding: 1px 6px;
- border-radius: var(--r-pill);
- background: var(--zk-tint-blue);
-}
-.panel__act:hover { background: var(--zk-tint); border-color: var(--zk-blue-light); }
-.panel__act:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
-
-/* ---- who worked this lead ----
- Five agents carry a lead and their names appeared only inside individual
- entries. The team is a fact about the lead, so it sits above the trail. */
-.panel--crew { padding: var(--sp-5) var(--sp-6); }
-
-.crew {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: var(--sp-2) var(--sp-4);
- margin-top: var(--sp-3);
-}
-
-.crew__l {
- display: block;
- font-size: var(--fs-3xs);
- font-weight: var(--fw-semi);
- letter-spacing: 0.09em;
- text-transform: uppercase;
- color: var(--zk-grey);
-}
-
-/* ---- a sign-off, not a task ----
- Confirming a premium is the only step in this workflow that is a person's
- approval rather than a piece of work, and the only one locked to one role.
- It sat fourth in a list of six queues, which is the wrong weight for the
- decision that starts somebody's cover. Shown only to the role that owns it. */
-.appr {
- display: flex;
- align-items: center;
- gap: 18px;
- margin-bottom: 22px;
- padding: 16px 22px;
- border-radius: var(--r-lg);
- text-decoration: none;
- border: 1px solid var(--zk-line);
- background: var(--zk-white);
- transition: border-color var(--t-fast), box-shadow var(--t-fast);
-}
-
-/* Amber only when something is actually waiting. A permanent alert colour on
- an empty queue teaches people to stop seeing it. */
-.appr.is-live {
- border-color: var(--zk-amber-line);
- background: var(--zk-amber-tint);
-}
-.appr:hover { box-shadow: var(--sh-sm); border-color: var(--zk-blue-light); }
-.appr:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
-
-.appr__n {
- flex: none;
- min-width: 46px;
- font-size: var(--fs-2xl);
- font-weight: 300;
- line-height: 1;
- letter-spacing: -0.02em;
- color: var(--zk-grey);
- font-variant-numeric: tabular-nums;
-}
-.appr.is-live .appr__n { color: var(--zk-amber-ink); }
-
-.appr__t {
- display: flex;
- flex-direction: column;
- gap: 3px;
- min-width: 0;
- flex: 1;
- font-size: var(--fs-sm);
- font-weight: 500;
- color: var(--zk-ink);
-}
-.appr__t em {
- font-style: normal;
- font-size: var(--fs-2xs);
- font-weight: 400;
- line-height: 1.45;
- color: var(--zk-muted);
-}
-
-.appr__go {
- flex: none;
- font-size: var(--fs-2xs);
- color: var(--zk-muted);
- white-space: nowrap;
-}
-
-/* ---- an agent that has not come back ----
- Amber like `blocked`, because both need a person — but worded and coloured
- apart from it: blocked means the workflow refused for a stated reason and
- the fix is known; stalled means nobody knows, and the honest offer is to try
- again. No pulse either way. */
-.doing--stalled {
- border-color: var(--zk-amber-line);
- background: var(--zk-amber-tint);
-}
-.doing--stalled strong { color: var(--zk-amber-ink); }
-
-/* ---- header right-hand cluster ---- */
-.page__right { display: flex; align-items: center; gap: 18px; flex: none; }
-
-.toggle {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- font-size: var(--fs-2xs);
- color: var(--zk-muted);
- cursor: pointer;
- user-select: none;
- white-space: nowrap;
-}
-.toggle input { accent-color: var(--zk-blue); cursor: pointer; }
-.toggle:hover { color: var(--zk-ink); }
-
-/* A finished lead, still listed. Dimmed rather than removed: "where did it
- go?" is the question this page exists to answer, and a lead that ended is
- still a lead. Hover restores it — it is muted, not disabled. */
-.grid__row.is-done { color: var(--zk-grey); }
-.grid__row.is-done .grid__name { color: var(--zk-muted); font-weight: 400; }
-.grid__row.is-done:hover { color: var(--zk-ink); }
-.grid__row.is-done:hover .grid__name { color: var(--zk-blue-dark); }
-
-/* ---- file a lead ----
- Moved off the sidebar. A nav is for moving between places; this adds
- something, and it now sits beside the lists it adds to. */
-.newlead {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- flex: none;
- padding: 9px 16px;
- border-radius: var(--r-pill);
- text-decoration: none;
- font-size: var(--fs-xs);
- font-weight: 500;
- color: var(--zk-white);
- background: var(--grad-blue);
- box-shadow: var(--sh-sm);
- transition: box-shadow var(--t), transform var(--t-fast), background var(--t);
-}
-.newlead svg { width: 14px; height: 14px; }
-.newlead:hover { background: var(--grad-blue-hover); box-shadow: var(--sh-md); transform: translateY(-1px); }
-.newlead:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
-
-/* ── the lead file, all of it ─────────────────────────────────────────────
- This was thirteen tabs with one group visible at a time, so "what do we
- know about this lead?" took thirteen clicks and a good memory. Then it was
- thirteen groups in CSS columns, which was worse in a different way: column
- flow reads top-to-bottom-then-across, so a file whose groups run Source,
- Customer, Intake landed on the page in an order nobody could predict, and
- the groups are wildly uneven — Motor risk has twelve fields, Proposal has
- two — so the columns tore raggedly.
-
- A GRID OF CARDS instead. Reading order is left-to-right like every other
- list on this screen, each group is bounded so its fields cannot be mistaken
- for the next group's, and an uneven card leaves a clean gap under itself
- rather than dragging the next heading up into the previous group's fields. */
-.file360 {
- padding: 4px 22px 22px;
+/* The AI team on this lead */
+.crew { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
+.crew__row {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
- gap: 14px;
- align-items: start;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 0;
+ border-top: 1px solid var(--zk-line-soft);
}
-
-.fgroup {
- padding: var(--sp-5) 0;
+.crew__row:first-child { border-top: 0; padding-top: 0; }
+.crew__t { display: flex; flex-direction: column; min-width: 0; line-height: 1.35; }
+.crew__name {
+ font: inherit;
+ font-size: var(--fs-xs);
+ font-weight: var(--fw-medium);
+ color: var(--zk-ink);
+ padding: 0;
border: 0;
- border-bottom: 1px solid var(--zk-line);
- border-radius: 0;
- background: transparent;
+ background: none;
+ text-align: left;
+ cursor: pointer;
+}
+.crew__name:hover { color: var(--zk-blue-dark); text-decoration: underline; }
+.crew__t span { font-size: 11.5px; color: var(--zk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.crew__n {
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-semi);
+ font-variant-numeric: tabular-nums;
+ padding: 2px 8px;
+ border-radius: var(--r-pill);
+ color: var(--zk-blue-dark);
+ background: var(--zk-tint-blue);
}
+/* At a glance / lead file groups */
+.glance { display: grid; grid-template-columns: 1fr; }
+.fgroup { padding: var(--sp-3) 0; border-top: 1px solid var(--zk-line-soft); }
+.glance .fgroup:first-child { border-top: 0; padding-top: 0; }
.fgroup h3 {
display: flex;
align-items: center;
- gap: var(--sp-2);
- margin: 0 0 var(--sp-4);
- padding-bottom: var(--sp-3);
- border-bottom: 1px solid var(--zk-line-soft);
- font-size: var(--fs-3xs);
+ gap: 7px;
+ margin: 0 0 6px;
+ font-size: var(--fs-2xs);
font-weight: var(--fw-semi);
- letter-spacing: 0.09em;
- text-transform: uppercase;
- color: var(--zk-blue-dark);
-}
-
-.fgroup h3 span {
- margin-left: auto;
- font-size: var(--fs-2xs);
- font-weight: 500;
- letter-spacing: 0;
- color: var(--zk-grey);
- font-variant-numeric: tabular-nums;
-}
-
-/* Label and value on ONE line. Stacked pairs doubled the height of every fact
- in the file for no gain — these are two-word labels against short values,
- and a label gutter is what makes a column of them scannable. */
-.file360 .props { display: block; padding: 0; }
-
-.file360 .prop {
- display: grid;
- grid-template-columns: minmax(0, 40%) minmax(0, 1fr);
- gap: var(--sp-4);
- align-items: baseline;
- margin: 0;
- padding: var(--sp-2) 0;
- border: 0;
- border-radius: 0;
-}
-
-.file360 .prop:hover { background: transparent; }
-
-.file360 .prop dt {
- font-size: var(--fs-2xs);
- line-height: 1.5;
- color: var(--zk-grey);
- margin: 0;
-}
-
-.file360 .prop dd {
- font-size: var(--fs-2xs);
- line-height: 1.5;
color: var(--zk-ink);
- overflow-wrap: anywhere;
}
+.fgroup h3 svg { color: var(--zk-blue); }
+.fgroup h3 span { margin-left: auto; font-size: 11px; font-weight: var(--fw-medium); color: var(--zk-grey); font-variant-numeric: tabular-nums; }
-/* ── the prose, folded ────────────────────────────────────────────────────
- The AI paragraphs used to render inline with the same weight the audit
- trail gives them — a tinted block, a bolded finding, a "Read the reasoning"
- button — and with seven of them the file read as a wall of quotations with
- the facts hidden between. Here they are a caption and two lines of grey.
- Nothing is lost: the full text is one click away in the same dialog, and
- the trail still shows each paragraph against the step that wrote it. */
-.fnotes {
- margin-top: 11px;
- padding-top: 10px;
- border-top: 1px dashed var(--zk-line);
+.props { margin: 0; display: block; padding: 0; }
+.prop {
display: grid;
- gap: 10px;
-}
-
-.fnote__l {
- display: block;
- margin-bottom: 2px;
- font-size: var(--fs-3xs);
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: var(--zk-grey);
-}
-
-/* The finding is emphasised in the trail, where it is the entry. Here it is
- one fact among fifty, so it drops to body weight and two lines. */
-.file360 .clamp__lead,
-.file360 .clamp__text {
- font-size: var(--fs-2xs);
- font-weight: 400;
- line-height: 1.5;
- color: var(--zk-muted);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.file360 .clamp__more { margin-top: 3px; font-size: var(--fs-2xs); }
-
-/* ── at a glance ──────────────────────────────────────────────────────────
- The same card as the full file uses, so opening the drawer feels like more
- of the thing you were already reading rather than a different screen. Four
- sections at most, so a fixed auto-fit grid never leaves a lone card on a
- row of its own the way the thirteen-group version did. */
-.glance {
- display: grid;
- grid-template-columns: 1fr;
- gap: 0;
- padding: 0;
-}
-
-.glance .props { display: block; padding: 0; }
-
-.glance .prop {
- display: grid;
- grid-template-columns: minmax(0, 40%) minmax(0, 1fr);
- gap: var(--sp-4);
+ grid-template-columns: minmax(0, 42%) minmax(0, 1fr);
+ gap: var(--sp-3);
align-items: baseline;
margin: 0;
- padding: var(--sp-2) 0;
- border: 0;
- border-radius: 0;
+ padding: 5px 0;
}
+.prop dt { font-size: var(--fs-2xs); line-height: 1.5; color: var(--zk-grey); margin: 0; }
+.prop dd { margin: 0; font-size: var(--fs-2xs); line-height: 1.5; color: var(--zk-ink); overflow-wrap: anywhere; }
+.prop__num { font-variant-numeric: tabular-nums; font-weight: var(--fw-medium); }
+.prop__files { display: flex; flex-wrap: wrap; gap: 6px; }
-.glance .prop:hover { background: transparent; }
-.glance .prop dt { font-size: var(--fs-2xs); line-height: 1.5; color: var(--zk-grey); margin: 0; }
-.glance .prop dd { font-size: var(--fs-2xs); line-height: 1.5; color: var(--zk-ink); overflow-wrap: anywhere; }
-
-/* Same de-weighting as the full file: at a glance a reason is one fact among
- a dozen, not the entry it is in the trail. */
-.glance .clamp__lead,
-.glance .clamp__text {
- font-size: var(--fs-2xs);
- font-weight: 400;
- line-height: 1.5;
- color: var(--zk-muted);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.glance .clamp__more { margin-top: 3px; font-size: var(--fs-2xs); }
-
-/* An action that belongs to one card, not to the panel. Quiet until hovered:
- the notes above it are the answer most of the time, and the transcript is
- the follow-up question. */
-.glance__act {
+/* A document chip: type tag + name */
+.doc,
+.prop__file {
display: inline-flex;
align-items: center;
- gap: 6px;
- margin-top: 10px;
- padding: 5px 10px 5px 8px;
- border: 1px solid var(--zk-line);
- border-radius: var(--r-pill);
- background: var(--zk-white);
- cursor: pointer;
- font: inherit;
- font-size: var(--fs-2xs);
- font-weight: 500;
+ gap: 7px;
+ max-width: 100%;
+ padding: 3px 9px 3px 4px;
+ border-radius: var(--r-sm);
+ border: 1px solid var(--zk-card-line);
+ background: var(--zk-card);
color: var(--zk-blue-dark);
- transition: background var(--t-fast), border-color var(--t-fast);
+ font-size: var(--fs-2xs);
+ text-decoration: none;
+ transition: border-color var(--t-fast), background var(--t-fast);
}
+.doc:hover, .prop__file:hover { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); }
+.doc__ext {
+ flex: none;
+ font-size: 9.5px;
+ font-weight: var(--fw-semi);
+ letter-spacing: .04em;
+ padding: 2px 5px;
+ border-radius: 4px;
+ color: #fff;
+ background: var(--zk-danger);
+}
+.doc__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.glance__act:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
-.glance__act:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
-.glance__act svg { width: 13px; height: 13px; flex: none; }
+.glance .clamp__lead, .glance .clamp__text,
+.file360 .clamp__lead, .file360 .clamp__text {
+ font-size: var(--fs-2xs);
+ font-weight: var(--fw-normal);
+ line-height: 1.5;
+ color: var(--zk-muted);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.glance .clamp__more, .file360 .clamp__more { margin-top: 2px; font-size: var(--fs-2xs); }
+
+.glance__act { margin-top: 8px; }
-/* HIDING IS ONLY SAFE WHILE IT STAYS FINDABLE, so this names the number it is
- holding rather than saying "more". Full width and on the panel's own floor:
- a link tucked into the header would be the discoverability problem that
- sinks progressive disclosure. */
.glance__all {
display: flex;
align-items: center;
gap: 9px;
- width: 100%;
- padding: 13px 22px;
+ width: calc(100% + var(--sp-5) * 2);
+ margin: var(--sp-3) calc(var(--sp-5) * -1) calc(var(--sp-5) * -1);
+ padding: 12px var(--sp-5);
border: 0;
border-top: 1px solid var(--zk-line-soft);
- border-radius: 0 0 var(--r-lg) var(--r-lg);
- background: none;
+ border-radius: 0 0 var(--r-card) var(--r-card);
+ background: var(--zk-ground);
cursor: pointer;
font: inherit;
font-size: var(--fs-2xs);
- font-weight: 500;
+ font-weight: var(--fw-medium);
color: var(--zk-blue-dark);
transition: background var(--t-fast);
}
-
-.glance__all:hover { background: var(--zk-tint); }
+.glance__all:hover { background: var(--zk-tint-blue); }
.glance__all:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: -2px; }
-.glance__all svg { width: 15px; height: 15px; flex: none; }
+.glance__all b { margin-left: auto; font-weight: var(--fw-normal); color: var(--zk-grey); font-variant-numeric: tabular-nums; }
-.glance__all b {
- margin-left: auto;
- font-weight: 400;
- font-size: var(--fs-2xs);
- color: var(--zk-grey);
- font-variant-numeric: tabular-nums;
+/* The full file, in its dialog */
+.file360 {
+ padding: 4px 22px 22px;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 6px 28px;
+ align-items: start;
}
+.file360 .fgroup { border-top: 0; padding: var(--sp-4) 0; }
+.file360 .fgroup h3 { padding-bottom: 8px; border-bottom: 1px solid var(--zk-line-soft); margin-bottom: 8px; }
+.fnotes { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--zk-line); display: grid; gap: 10px; }
+.fnote__l { display: block; margin-bottom: 2px; font-size: 11px; font-weight: var(--fw-medium); color: var(--zk-grey); }
-.panel__meta {
- flex: none;
- font-size: var(--fs-2xs);
- color: var(--zk-grey);
- font-variant-numeric: tabular-nums;
+/* ── Responsive ──────────────────────────────────────────────────────────── */
+@media (max-width: 1180px) {
+ .now { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .kpi { border-left: 0; padding-left: 0; }
+ .split { grid-template-columns: minmax(0, 1fr); }
+ .lead { grid-template-columns: minmax(0, 1fr); }
+ .lead__side { position: static; max-height: none; overflow: visible; }
}
-
-
-/* ── modern surface pass ──────────────────────────────────────────────────
- The layout was right but the surface still read as a flat corporate form.
- Two levers close most of the gap: a display face on the things the eye
- lands on — the page title, panel headings and every figure — and one warm,
- coloured signal for who holds the lead right now. */
-.page__title,
-.panel__title,
-.lead__stage strong,
-.lm__v,
-.kpi__v,
-.now__v,
-.fig .v {
- font-family: var(--font-display);
- letter-spacing: -0.02em;
+@media (max-width: 720px) {
+ .now, .kpis { grid-template-columns: minmax(0, 1fr); }
+ .buckets { grid-template-columns: 1fr; }
+ .page__head { flex-direction: column; }
+ .page__right { margin-top: 0; }
}
-
-.page__title { letter-spacing: -0.03em; }
-
-/* WHO HOLDS THE LEAD, as a coloured pill — the first thing the eye should
- catch. The stage name lives in the rail below; this says whose move it is,
- which is the question an operator actually opens a lead to answer. */
-.holds {
- flex: none;
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- padding: 0;
- border: 0;
- background: transparent;
+@media (prefers-reduced-motion: reduce) {
+ .run--working .run__dot, .doing__ico--pulse { animation: none; }
}
-.holds__puck {
- flex: none;
- width: 10px; height: 10px;
- border-radius: 50%;
- background: var(--zk-teal);
- box-shadow: 0 0 0 4px var(--zk-teal-tint);
-}
-/* the icon inside the disc is dropped in the flat treatment — the dot alone
- carries the tone, and the label says the rest. */
-.holds__puck svg { display: none; }
-.holds__txt { display: inline; line-height: 1.3; }
-.holds__txt small { display: inline; }
-.holds__txt small {
- font-size: var(--fs-3xs);
- font-weight: var(--fw-semi);
- letter-spacing: .09em;
- text-transform: uppercase;
- color: var(--zk-teal-ink);
- opacity: .9;
-}
-.holds__txt b {
- font-weight: var(--fw-semi);
- font-size: var(--fs-sm);
- color: var(--zk-ink);
- margin-left: var(--sp-1);
-}
-
-.holds--ai .holds__puck { background: var(--zk-blue); box-shadow: 0 0 0 4px var(--zk-tint-blue); }
-.holds--ai .holds__txt small { color: var(--zk-blue-dark); }
-
-.holds--person .holds__puck { background: var(--zk-amber); box-shadow: 0 0 0 4px var(--zk-amber-tint); }
-.holds--person .holds__txt small { color: var(--zk-amber-ink); }
-
-.holds--grey .holds__puck { background: var(--zk-grey); box-shadow: 0 0 0 4px var(--zk-tint); }
-.holds--grey .holds__txt small { color: var(--zk-muted); }
-
-.holds--cust .holds__puck { background: var(--zk-teal); box-shadow: 0 0 0 4px var(--zk-teal-tint); }
-.holds--cust .holds__txt small { color: var(--zk-teal-ink); }
-
-/* ── Your move: the immediate action, in the header ───────────────────────
- The one thing this user must do, promoted top-right where the eye looks for
- the next move. Amber because it is waiting on a person; pressing it opens
- the form and scrolls to it. When nothing is owed, the passive holds status
- sits here instead. */
-.cta {
- flex: none;
- display: inline-flex;
- align-items: center;
- gap: var(--sp-3);
- padding: var(--sp-3) var(--sp-4) var(--sp-3) var(--sp-5);
- border: 0;
- border-radius: var(--r-md);
- background: var(--zk-amber);
- color: #fff;
- cursor: pointer;
- box-shadow: var(--sh-sm);
- transition: background var(--t-fast), box-shadow var(--t-fast), transform var(--t-fast);
-}
-.cta:hover { background: var(--zk-amber-ink); box-shadow: var(--sh-md); }
-.cta:focus-visible { outline: 2px solid var(--zk-amber-ink); outline-offset: 2px; }
-.cta__txt { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.2; }
-.cta__txt small {
- font-size: var(--fs-3xs);
- font-weight: var(--fw-semi);
- letter-spacing: .08em;
- text-transform: uppercase;
- opacity: .82;
-}
-.cta__txt b { font-size: var(--fs-sm); font-weight: var(--fw-semi); }
-.cta svg { width: 16px; height: 16px; flex: none; }