diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..88ad3ee
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,129 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Commands
+
+```bash
+npm install
+npm run dev # http://localhost:5175
+npm run build # → dist/
+npm run lint # eslint (flat config, ignores dist/)
+npm run preview
+```
+
+There are no tests and no test runner in this project.
+
+`npm run lint` currently reports 12 pre-existing errors — 8 `react-hooks/refs`, 3
+`react-hooks/set-state-in-effect`, 1 `react-refresh/only-export-components` (the provider
+file exports both a component and a hook). A clean run is not the baseline; compare against
+this count rather than against zero.
+
+Local dev reads `VITE_ZINO_API_URL` from `.env` (`https://dev.getzino.in`). A deployed
+build does **not** — see "Runtime config" below.
+
+## What this is
+
+A React + Vite operator console (no state library, no UI framework — plain CSS files
+next to their components) for the Zurich Kotak "Lead to Policy" demo on the Zino
+platform. Leads arrive through three channels — direct, bancassurance, agency — and run
+one workflow state machine to policy issuance. AI employees perform almost every step;
+a human appears at exactly one queue, `Referred to Underwriting`.
+
+The frontend owns **no business logic**. The workflow (org `84`, app `536`, workflow
+`zk_wf_lead`) is the source of truth; this repo is a thin renderer over its API.
+
+Workflow config is seeded outside this repo, from `sm2/custom-apps/zurich-kotak/`
+(SQL migrations `02_workflow.sql`, `04_views.sql`, …). Design doc:
+`sm2/app-designs/zurich-kotak/design.html`.
+
+## Architecture
+
+- `src/api/client.js` — every gateway call lives here (`ZinoClient`). JWT + user in
+ `localStorage`, 401 clears the session via an auth-error handler.
+- `src/api/config.js` — the environment identifiers and the hand-maintained mirrors of
+ workflow tables (`STAGES`, `ACTIONS`, `ENTRY`).
+- `src/api/provider.jsx` — `ZinoProvider` / `useZino()`: one client instance plus session
+ state. Restores an **identity**, never a permission set.
+- `src/layout/Shell.jsx` — header + the pipeline sidebar (the sidebar *is* `STAGES`).
+- `src/screens/Pipeline.jsx` — a queue: one record view filtered server-side on
+ `current_state_name`.
+- `src/screens/Lead.jsx` — one lead: detail view, allowed activities, timeline, then
+ field groups (`GROUPS`, in the order the lead is worked).
+- `src/screens/AddLead.jsx` — the INIT doors from `ENTRY`; skips the chooser when only
+ one is surfaced.
+- `src/components/ActivityForm.jsx` — renders the **live** activity schema and submits it
+ back. `src/components/FileField.jsx` handles `file`/`ocr` fields;
+ `src/components/Timeline.jsx` renders the audit trail as a story.
+
+Routing: `/stage/:stageUid`, `/lead/:instanceId`, `/add`; unauthenticated renders
+`Login` for every path.
+
+## The invariants — break these and it fails in ways tests would not catch
+
+**Forms are never defined in this repo.** `POST /view/form-screens` returns labels,
+types, select options and which fields are mandatory; `ActivityForm` renders whatever
+comes back. Adding a field in Studio and redeploying surfaces it here with no frontend
+change. Do not hardcode a form — it would silently drift from the workflow.
+
+**Permissions are never enforced here.** Nothing hides a button to stop someone. Every
+decision is the workflow's, server-side, on each submission; the console reports the
+refusal. `/activity` refuses with **403**, `/start` with **400**, both carrying
+`permission denied`. Showing an action the signed-in role cannot perform is deliberate —
+the refusal is the demo.
+
+**Runtime config, not build-time.** `VITE_ZINO_API_URL` is read at runtime off
+`window.__RUNTIME_CONFIG__`, from a `config.js` the server writes next to the artifact at
+placement. One artifact is promoted between environments unchanged, so a compiled-in URL
+would point every environment at whichever backend built it. `requireConfigValue` throws
+rather than falling back — a production build with no `config.js` must fail loudly. The
+dev-only `.env` fallback is gated on `import.meta.env.DEV`.
+
+**One build serves any mount path.** `vite.config.js` sets `base: './'`; the router
+basename comes from `basePath()`, which reads the ` ` the server writes into the
+`` placeholder in `index.html`. Do not reintroduce a build-time base, and
+do not remove that placeholder or the `config.js` script tag. `import.meta.env.BASE_URL`
+is unusable here (it resolves to `./`).
+
+**Bundled assets must live in `src/assets/`, not `public/`.** Vite rewrites bundled asset
+URLs to be relative; a `public/` file referenced as `/fonts/…` stays absolute and 404s
+under the `/zurich-kotak/` mount. The favicon is the one exception — it stays in
+`public/brand/` and is referenced relatively.
+
+**`STAGES` and `ACTIONS` are hand-mirrored** from `workflow.tbl_wf_states` and
+`workflow.tbl_wf_state_allowed_activities`, because the API returns neither the order nor
+the labels the sidebar needs. An activity added to the workflow but not added to `ACTIONS`
+is simply invisible — the platform allows it, the console never offers it, nothing errors.
+Keep both in step with the seed SQL.
+
+## API shapes that have already bitten
+
+- `org_id` on `POST /usr/login` must be a **string**; a number returns
+ `cannot unmarshal number into Go struct field LoginRequest.org_id`.
+- `POST /view/recordview` names the view `rv_template_uid` (the GET spelling is `rv_id`),
+ and paging/sort/filters live **inside** `search_query`. Top-level gives
+ `400 Missing param: rv_template_uid (body)`.
+- Row key varies by source type: workflow views return `data`, rdbms views have returned
+ `records` — hence the `data ?? records ?? rows` fallbacks.
+- Audit must use the **app-scoped** path `/app/536/view/audit`. Bare `/view/audit` is not
+ an API route; it falls through to the SPA and returns HTML with a 200.
+- `POST /upload` **requires** `workflow_uuid` (taken from config, not from the caller's
+ ctx). The field context (`activity_id`, `field_id`, `instance_id`) is how the backend
+ resolves allowed types, size limit and `ocr_config` — it ignores anything the client
+ claims about them.
+- `/ocr-extract` takes a **reference** to an already-uploaded file, not bytes, and answers
+ `{ extracted, raw }`. Files therefore upload on pick, not on submit.
+- On submit, `ActivityForm` sends only fields the activity defines, drops empties (an
+ unknown field is fatal to schema validation) and skips `id_gen` — that reference is
+ issued server-side.
+- OCR results fill only fields still blank; never overwrite what a person typed with what
+ a model read.
+
+## Deployment
+
+Registered with frontgen by `sm2/custom-apps/zurich-kotak/05_frontgen_project.sql` (slug
+`zurich-kotak`, repo_name `zurich_kotak`, org 84). A push to `main` builds and serves at
+https://preview-dev.getzino.in/zurich-kotak/. The webhook fires only on a **new** push.
+
+Demo logins (all password `ZurichKotak@2026`, org `84`) are listed in `README.md`; sign in
+as `sanjay.uw@zurichkotak.example` to land in the one human queue.
diff --git a/src/components/ActivityForm.css b/src/components/ActivityForm.css
index 0c395ef..5ff988c 100644
--- a/src/components/ActivityForm.css
+++ b/src/components/ActivityForm.css
@@ -1,82 +1,324 @@
-.af { display: flex; flex-direction: column; gap: 18px; }
-.af__loading { color: var(--zk-muted); font-size: .86rem; }
-.af__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px 18px; }
-.af__field { display: flex; flex-direction: column; gap: 5px; }
-.af__field--wide { grid-column: 1 / -1; }
+.af {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.af__loading {
+ color: var(--zk-muted);
+ font-size: 0.86rem;
+}
+
+.af__grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(248px, 1fr));
+ gap: 16px 20px;
+}
+
+.af__field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.af__field--wide {
+ grid-column: 1 / -1;
+}
+
.af__field > span {
- font-size: .74rem; font-weight: 500; color: var(--zk-muted);
- letter-spacing: .02em; display: flex; align-items: center; gap: 8px;
+ font-size: 0.74rem;
+ font-weight: 500;
+ letter-spacing: 0.03em;
+ color: var(--zk-muted);
+ display: flex;
+ align-items: center;
+ gap: 8px;
}
+
.af__req {
- font-style: normal; font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
- color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 5px; border-radius: 3px;
+ font-style: normal;
+ font-size: 0.58rem;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--zk-blue);
+ background: var(--zk-tint-blue);
+ padding: 2px 7px;
+ border-radius: var(--r-pill);
}
-.af input, .af select, .af textarea {
- font: inherit; font-size: .88rem; padding: 8px 10px; border-radius: 4px;
- border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-ink); width: 100%;
+
+.af input,
+.af select,
+.af textarea {
+ font: inherit;
+ font-size: 0.88rem;
+ padding: 10px 12px;
+ border-radius: var(--r-md);
+ border: 1px solid var(--zk-line);
+ background: var(--zk-white);
+ color: var(--zk-ink);
+ width: 100%;
+ transition: border-color var(--t-fast), box-shadow var(--t-fast), background var(--t-fast);
}
-.af input:focus, .af select:focus, .af textarea:focus {
- outline: none; border-color: var(--zk-blue); box-shadow: 0 0 0 3px var(--zk-tint-blue);
+
+.af input:hover,
+.af select:hover,
+.af textarea:hover {
+ border-color: var(--zk-blue-light);
}
-.af textarea { resize: vertical; }
-.af__multi { display: flex; flex-wrap: wrap; gap: 6px; }
+
+.af input:focus,
+.af select:focus,
+.af textarea:focus {
+ outline: none;
+ border-color: var(--zk-blue);
+ box-shadow: var(--ring);
+}
+
+.af select {
+ appearance: none;
+ padding-right: 34px;
+ /* The caret is drawn rather than loaded: an inline data URI keeps the select
+ consistent across platforms without another asset to serve. */
+ 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 12px center;
+ background-size: 12px;
+}
+
+.af textarea {
+ resize: vertical;
+ min-height: 84px;
+ line-height: 1.55;
+}
+
+.af__multi {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+}
+
.af__chip {
- font: inherit; font-size: .76rem; padding: 5px 10px; border-radius: 4px; cursor: pointer;
- border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
+ font: inherit;
+ font-size: 0.78rem;
+ padding: 6px 13px;
+ border-radius: var(--r-pill);
+ cursor: pointer;
+ border: 1px solid var(--zk-line);
+ background: var(--zk-white);
+ color: var(--zk-muted);
+ transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast);
}
-.af__chip.is-on { background: var(--zk-blue); border-color: var(--zk-blue); color: var(--zk-white); }
-.af__actions { display: flex; justify-content: flex-end; gap: 10px; }
+
+.af__chip:hover {
+ border-color: var(--zk-blue-light);
+ background: var(--zk-tint);
+ color: var(--zk-ink);
+}
+
+.af__chip.is-on {
+ background: var(--grad-blue);
+ border-color: var(--zk-blue);
+ color: var(--zk-white);
+ box-shadow: var(--sh-xs);
+}
+
+.af__actions {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 10px;
+ padding-top: 4px;
+}
+
.af__submit {
- font: inherit; font-size: .88rem; font-weight: 500; padding: 9px 22px; border-radius: 4px;
- cursor: pointer; border: 1px solid var(--zk-blue); background: var(--zk-blue); color: var(--zk-white);
+ font: inherit;
+ font-size: 0.88rem;
+ font-weight: 500;
+ padding: 10px 26px;
+ border-radius: var(--r-md);
+ cursor: pointer;
+ border: 0;
+ background: var(--grad-blue);
+ color: var(--zk-white);
+ box-shadow: var(--sh-sm);
+ transition: background var(--t), box-shadow var(--t), transform var(--t-fast), opacity var(--t-fast);
}
-.af__submit:disabled { opacity: .55; cursor: default; }
+
+.af__submit:hover:not(:disabled) {
+ background: var(--grad-blue-hover);
+ box-shadow: var(--sh-md);
+ transform: translateY(-1px);
+}
+
+.af__submit:active:not(:disabled) {
+ transform: none;
+ box-shadow: var(--sh-xs);
+}
+
+.af__submit:disabled {
+ opacity: 0.5;
+ cursor: default;
+ box-shadow: none;
+}
+
.af__ghost {
- font: inherit; font-size: .88rem; padding: 9px 18px; border-radius: 4px; cursor: pointer;
- border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
+ font: inherit;
+ font-size: 0.88rem;
+ padding: 10px 20px;
+ border-radius: var(--r-md);
+ cursor: pointer;
+ border: 1px solid var(--zk-line);
+ background: var(--zk-white);
+ color: var(--zk-muted);
+ transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
+
+.af__ghost:hover {
+ border-color: var(--zk-grey);
+ background: var(--zk-tint);
+ color: var(--zk-ink);
+}
+
.af__err {
- background: #fdf0ef; border: 1px solid #f0c9c6; border-left: 3px solid var(--zk-danger);
- border-radius: 0 4px 4px 0; padding: 11px 14px; font-size: .84rem; color: var(--zk-ink);
+ background: var(--zk-danger-tint);
+ border: 1px solid var(--zk-danger-line);
+ border-left: 3px solid var(--zk-danger);
+ border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs);
+ padding: 12px 16px;
+ font-size: 0.84rem;
+ color: var(--zk-ink);
+ animation: zk-rise 0.2s var(--ease) both;
+}
+
+.af__err strong {
+ font-family: var(--font-mono);
+ font-size: 0.85em;
+ margin-right: 8px;
+ padding: 1px 7px;
+ border-radius: var(--r-xs);
+ background: rgba(211, 47, 47, 0.1);
+ color: var(--zk-danger-ink);
+}
+
+.af__err p {
+ margin: 7px 0 0;
+ font-size: 0.8rem;
+ color: var(--zk-muted);
+ line-height: 1.55;
}
-.af__err strong { font-family: ui-monospace, monospace; margin-right: 6px; }
-.af__err p { margin: 6px 0 0; font-size: .8rem; color: var(--zk-muted); }
.af__auto {
- font-size: .88rem; padding: 8px 10px; border-radius: 4px; min-height: 37px;
- border: 1px dashed var(--zk-line); background: var(--zk-tint); color: var(--zk-muted);
- display: flex; align-items: center; justify-content: space-between; gap: 8px;
+ font-size: 0.88rem;
+ padding: 10px 12px;
+ border-radius: var(--r-md);
+ min-height: 41px;
+ border: 1px dashed var(--zk-line);
+ background: var(--zk-tint);
+ color: var(--zk-muted);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
}
+
.af__auto span {
- font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
- color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px;
+ font-size: 0.58rem;
+ font-weight: 500;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var(--zk-grey);
+ border: 1px solid var(--zk-line);
+ border-radius: var(--r-pill);
+ padding: 2px 8px;
+ background: var(--zk-white);
}
/* ---- file / ocr ---- */
-.ff { display: flex; flex-direction: column; gap: 6px; }
-.ff__input { display: none; }
+.ff {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.ff__input {
+ display: none;
+}
+
.ff__btn {
- font: inherit; font-size: .84rem; padding: 8px 12px; border-radius: 4px; cursor: pointer;
- border: 1px dashed var(--zk-blue-light); background: var(--zk-tint-blue);
- color: var(--zk-blue-dark); text-align: center;
+ font: inherit;
+ font-size: 0.84rem;
+ font-weight: 500;
+ padding: 14px 14px;
+ border-radius: var(--r-md);
+ cursor: pointer;
+ border: 1px dashed var(--zk-blue-light);
+ background: var(--zk-tint-blue);
+ color: var(--zk-blue-dark);
+ text-align: center;
+ transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
}
-.ff__btn:hover:not(:disabled) { background: var(--zk-white); border-style: solid; }
-.ff__btn:disabled { opacity: .6; cursor: default; border-style: solid; }
+
+.ff__btn:hover:not(:disabled) {
+ background: var(--zk-white);
+ border-style: solid;
+ border-color: var(--zk-blue);
+ box-shadow: var(--ring);
+}
+
+.ff__btn:disabled {
+ opacity: 0.6;
+ cursor: default;
+ border-style: solid;
+}
+
.ff__got {
- font-size: .78rem; color: var(--zk-muted);
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ font-size: 0.78rem;
+ color: var(--zk-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
+
.ff__read {
- border-left: 2px solid var(--zk-blue); background: var(--zk-tint);
- border-radius: 0 4px 4px 0; padding: 8px 10px; display: flex; flex-direction: column; gap: 2px;
+ border: 1px solid var(--zk-blue-light);
+ border-left-width: 3px;
+ background: var(--zk-tint);
+ border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs);
+ padding: 10px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ animation: zk-rise 0.22s var(--ease) both;
}
+
.ff__readlabel {
- font-size: .62rem; letter-spacing: .08em; text-transform: uppercase;
- color: var(--zk-blue-dark); margin-bottom: 2px;
+ font-size: 0.6rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--zk-blue-dark);
+ margin-bottom: 3px;
}
-.ff__read div { font-size: .8rem; color: var(--zk-ink); }
-.ff__read em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; margin-right: 5px; font-size: .72rem; }
+
+.ff__read div {
+ font-size: 0.8rem;
+ color: var(--zk-ink);
+}
+
+.ff__read em {
+ font-style: normal;
+ color: var(--zk-grey);
+ text-transform: capitalize;
+ margin-right: 6px;
+ font-size: 0.72rem;
+}
+
.ff__err {
- font-size: .78rem; color: var(--zk-danger);
- background: #fdf0ef; border: 1px solid #f0c9c6; border-radius: 4px; padding: 6px 9px;
+ font-size: 0.78rem;
+ color: var(--zk-danger-ink);
+ background: var(--zk-danger-tint);
+ border: 1px solid var(--zk-danger-line);
+ border-radius: var(--r-md);
+ padding: 8px 11px;
}
diff --git a/src/components/ClampText.css b/src/components/ClampText.css
new file mode 100644
index 0000000..1c320be
--- /dev/null
+++ b/src/components/ClampText.css
@@ -0,0 +1,59 @@
+.clamp {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+}
+
+.clamp__text {
+ margin: 0;
+ font-size: 0.86rem;
+ line-height: 1.6;
+ color: var(--zk-ink);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: var(--clamp-lines, 3);
+ line-clamp: var(--clamp-lines, 3);
+ overflow: hidden;
+}
+
+.clamp__text--short {
+ display: block;
+ overflow: visible;
+}
+
+.clamp.is-open .clamp__text {
+ display: block;
+ overflow: visible;
+}
+
+.clamp__more {
+ font: inherit;
+ font-size: 0.76rem;
+ font-weight: 500;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 2px 0;
+ border: 0;
+ background: none;
+ color: var(--zk-blue);
+ cursor: pointer;
+ transition: color var(--t-fast);
+}
+
+.clamp__more:hover {
+ color: var(--zk-blue-dark);
+}
+
+.clamp__more svg {
+ width: 11px;
+ height: 11px;
+ transition: transform var(--t);
+}
+
+.clamp.is-open .clamp__more svg {
+ transform: rotate(180deg);
+}
diff --git a/src/components/ClampText.jsx b/src/components/ClampText.jsx
new file mode 100644
index 0000000..d0be5c1
--- /dev/null
+++ b/src/components/ClampText.jsx
@@ -0,0 +1,34 @@
+import { useState } from 'react'
+import './ClampText.css'
+
+/**
+ * Prose, folded.
+ *
+ * The AI employees write at length — a recommendation rationale runs past 1,500
+ * characters — and a lead file that prints all of it is a document, not a
+ * screen. Anything long opens as a few lines with the rest one click away, so
+ * the page can be scanned and still holds everything for whoever wants it.
+ *
+ * Whether to fold is decided on length, not on measuring the rendered box: a
+ * ref-and-measure pass would reflow on every resize to answer a question the
+ * string itself already answers.
+ */
+export default function ClampText({ text, lines = 3, threshold = 150 }) {
+ const [open, setOpen] = useState(false)
+ const value = String(text)
+
+ if (value.length <= threshold) return
{value}
+
+ return (
+
+
{value}
+
setOpen((o) => !o)}>
+ {open ? 'Show less' : 'Read more'}
+
+
+
+
+
+ )
+}
diff --git a/src/components/Timeline.css b/src/components/Timeline.css
index 7e81f25..b3213e6 100644
--- a/src/components/Timeline.css
+++ b/src/components/Timeline.css
@@ -1,39 +1,205 @@
-.tl { list-style: none; margin: 0; padding: 0 0 0 4px; }
-.tl__loading, .tl__err { color: var(--zk-muted); font-size: .86rem; padding: 14px 2px; }
-.tl__err { color: var(--zk-danger); }
+.tl {
+ list-style: none;
+ margin: 0;
+ padding: 0 0 0 4px;
+}
-.tl__item { position: relative; padding: 0 0 20px 26px; border-left: 2px solid var(--zk-line); }
-.tl__item:last-child { border-left-color: transparent; padding-bottom: 4px; }
+.tl__loading,
+.tl__err {
+ color: var(--zk-muted);
+ font-size: 0.86rem;
+ padding: 14px 2px;
+}
+
+.tl__err {
+ color: var(--zk-danger-ink);
+}
+
+.tl__item {
+ position: relative;
+ padding: 0 0 18px 26px;
+ border-left: 2px solid var(--zk-line-soft);
+}
+
+.tl__item:last-child {
+ border-left-color: transparent;
+ padding-bottom: 4px;
+}
.tl__dot {
- position: absolute; left: -7px; top: 3px; width: 12px; height: 12px;
- border-radius: 50%; background: var(--zk-white); border: 2px solid var(--zk-grey);
+ position: absolute;
+ left: -8px;
+ top: 3px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--zk-white);
+ border: 2px solid var(--zk-grey);
+ box-shadow: 0 0 0 4px var(--zk-white);
}
+
/* Machine work is blue, people are amber, the platform itself is grey — so a
file can be read at a glance for how much of it was done by hand. */
-.tl__item--ai .tl__dot { border-color: var(--zk-blue); background: var(--zk-blue); }
-.tl__item--human .tl__dot { border-color: #b5761f; background: var(--zk-white); }
-.tl__item--sys .tl__dot { border-color: var(--zk-line); background: var(--zk-line); }
+.tl__item--ai .tl__dot {
+ border-color: var(--zk-blue);
+ background: var(--zk-blue);
+ box-shadow: 0 0 0 4px var(--zk-white), 0 0 0 7px rgba(33, 103, 174, 0.12);
+}
+
+.tl__item--human .tl__dot {
+ border-color: var(--zk-amber);
+ background: var(--zk-white);
+ box-shadow: 0 0 0 4px var(--zk-white), 0 0 0 7px rgba(181, 118, 31, 0.14);
+}
+
+.tl__item--sys .tl__dot {
+ border-color: var(--zk-line);
+ background: var(--zk-line);
+}
+
+.tl__body {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 2px 12px 8px;
+ margin-left: -12px;
+ border-radius: var(--r-md);
+ transition: background var(--t-fast);
+}
+
+.tl__item:hover .tl__body {
+ background: var(--zk-tint);
+}
+
+.tl__head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 8px;
+}
+
+/* Who and when sit on their own line: in a 384px rail, badge-beside-title wraps
+ into a ragged block that is harder to scan than two clean rows. */
+.tl__meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 8px;
+}
+
+.tl__what {
+ font-size: 0.89rem;
+ font-weight: 500;
+ color: var(--zk-ink);
+}
-.tl__body { display: flex; flex-direction: column; gap: 3px; }
-.tl__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 10px; }
-.tl__what { font-size: .93rem; font-weight: 500; color: var(--zk-ink); }
.tl__who {
- font-size: .68rem; letter-spacing: .04em; padding: 2px 7px; border-radius: 3px; white-space: nowrap;
+ font-size: 0.66rem;
+ font-weight: 500;
+ letter-spacing: 0.03em;
+ padding: 3px 9px;
+ border-radius: var(--r-pill);
+ white-space: nowrap;
+}
+
+.tl__who--ai {
+ background: var(--zk-tint-blue);
+ color: var(--zk-blue-dark);
+}
+
+.tl__who--human {
+ background: var(--zk-amber-tint);
+ color: var(--zk-amber-ink);
+}
+
+.tl__who--sys {
+ background: var(--zk-tint);
+ color: var(--zk-muted);
+ border: 1px solid var(--zk-line);
+}
+
+.tl__stage {
+ font-size: 0.78rem;
+ color: var(--zk-blue);
+}
+
+.tl__when {
+ font-size: 0.72rem;
+ color: var(--zk-grey);
+}
+
+.tl__say {
+ margin-top: 8px;
+ padding: 8px 12px;
+ border-left: 2px solid var(--zk-blue-light);
+ border-radius: 0 var(--r-md) var(--r-md) 0;
+ background: var(--zk-tint);
+}
+
+.tl__item:hover .tl__say {
+ background: var(--zk-white);
}
-.tl__who--ai { background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
-.tl__who--human { background: #f7efe2; color: #7a5a12; }
-.tl__who--sys { background: var(--zk-tint); color: var(--zk-muted); }
-.tl__stage { font-size: .78rem; color: var(--zk-blue); }
-.tl__when { font-size: .72rem; color: var(--zk-grey); }
-.tl__say { margin-top: 7px; padding-left: 10px; border-left: 2px solid var(--zk-line-soft); }
.tl__saylabel {
- display: block; font-size: .64rem; letter-spacing: .08em; text-transform: uppercase;
- color: var(--zk-grey); margin-bottom: 2px;
+ display: block;
+ font-size: 0.62rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--zk-grey);
+ margin-bottom: 3px;
}
-.tl__say p { margin: 0; font-size: .84rem; line-height: 1.55; color: var(--zk-muted); max-width: 78ch; }
-.tl__figs { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 7px; }
-.tl__figs span { font-size: .8rem; color: var(--zk-ink); font-variant-numeric: tabular-nums; }
-.tl__figs em { font-style: normal; font-size: .68rem; color: var(--zk-grey); text-transform: capitalize; margin-right: 4px; }
+.tl__say p {
+ margin: 0;
+ font-size: 0.84rem;
+ line-height: 1.55;
+ color: var(--zk-muted);
+ max-width: 78ch;
+}
+
+.tl__figs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 9px;
+}
+
+.tl__figs span {
+ font-size: 0.8rem;
+ color: var(--zk-ink);
+ font-variant-numeric: tabular-nums;
+ padding: 4px 11px;
+ border-radius: var(--r-pill);
+ background: var(--zk-white);
+ border: 1px solid var(--zk-line);
+}
+
+.tl__figs em {
+ font-style: normal;
+ font-size: 0.68rem;
+ color: var(--zk-grey);
+ text-transform: capitalize;
+ margin-right: 5px;
+}
+
+/* ---- system noise ---- */
+.tl__toggle {
+ font: inherit;
+ font-size: 0.72rem;
+ font-weight: 500;
+ cursor: pointer;
+ margin-bottom: 14px;
+ padding: 5px 12px;
+ border-radius: var(--r-pill);
+ border: 1px solid var(--zk-line);
+ background: var(--zk-white);
+ color: var(--zk-muted);
+ transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
+}
+
+.tl__toggle:hover {
+ border-color: var(--zk-blue-light);
+ background: var(--zk-tint);
+ color: var(--zk-blue-dark);
+}
diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx
index 965dc6c..24d6168 100644
--- a/src/components/Timeline.jsx
+++ b/src/components/Timeline.jsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useZino } from '../api/provider.jsx'
+import ClampText from './ClampText.jsx'
import { STAGES } from '../api/config.js'
import './Timeline.css'
@@ -47,6 +48,10 @@ export default function Timeline({ instanceId }) {
const { client } = useZino()
const [rows, setRows] = useState(null)
const [err, setErr] = useState(null)
+ // DATA_UPDATE entries are the platform writing fields, not anyone deciding
+ // anything. They are the bulk of a busy trail and they are hidden until asked
+ // for — the story is what people and agents did.
+ const [showSys, setShowSys] = useState(false)
useEffect(() => {
let dead = false
@@ -63,58 +68,73 @@ export default function Timeline({ instanceId }) {
// Oldest first: a timeline reads forwards.
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
+ const items = ordered.map((r, i) => {
+ const roles = r.user_roles || []
+ const aiRole = roles.find((x) => AI_ROLES[x])
+ const isSystem = r.activity_id === 'DATA_UPDATE'
+ const kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human'
+ const d = r.data || {}
+ return {
+ key: r.id ?? i,
+ kind,
+ actor: aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System'),
+ what: isSystem ? 'Data updated' : (r.activity_name || r.activity_id),
+ stage: STAGES.find((s) => s.uid === r.execution_state),
+ when: when(r.created_at),
+ narrative: NARRATIVE
+ .map(([k, label]) => [label, d[k]])
+ .filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== ''),
+ figures: Object.entries(d)
+ .filter(([k, v]) => MONEY.has(k) && v)
+ .map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')]),
+ }
+ })
+
+ const sysCount = items.filter((it) => it.kind === 'sys').length
+ const visible = showSys ? items : items.filter((it) => it.kind !== 'sys')
+
return (
-
- {ordered.map((r, i) => {
- const roles = r.user_roles || []
- const aiRole = roles.find((x) => AI_ROLES[x])
- const isSystem = r.activity_id === 'DATA_UPDATE'
- const kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human'
- const actor = aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System')
- const stage = STAGES.find((s) => s.uid === r.execution_state)
- const d = r.data || {}
+ <>
+ {sysCount ? (
+ setShowSys((v) => !v)}>
+ {showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'}
+
+ ) : null}
- const narrative = NARRATIVE
- .map(([k, label]) => [label, d[k]])
- .filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
-
- const figures = Object.entries(d)
- .filter(([k, v]) => MONEY.has(k) && v)
- .map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')])
-
- return (
-
+
+ {visible.map((it) => (
+
-
- {isSystem ? 'Data updated' : (r.activity_name || r.activity_id)}
-
-
- {kind === 'ai' ? 'AI employee' : kind === 'sys' ? 'system' : 'person'} · {actor}
-
- {stage ? → {stage.name} : null}
+ {it.what}
+ {it.stage ? → {it.stage.name} : null}
+
+
+
+ {it.kind === 'ai' ? 'AI' : it.kind === 'sys' ? 'system' : 'person'} · {it.actor}
+
+ {it.when}
-
{when(r.created_at)}
- {narrative.map(([label, v]) => (
+ {it.narrative.map(([label, v]) => (
))}
- {figures.length ? (
+ {it.figures.length ? (
- {figures.map(([k, v]) => (
+ {it.figures.map(([k, v]) => (
{k} {v}
))}
) : null}
- )
- })}
-
+ ))}
+
+ >
)
}
diff --git a/src/index.css b/src/index.css
index 5b27a45..7101e74 100644
--- a/src/index.css
+++ b/src/index.css
@@ -41,13 +41,48 @@
--zk-white: #fff;
--zk-danger: #d32f2f;
+ /* Amber and red accents already used for people / lapsed renewals, named so
+ they stop being magic hex literals scattered across four stylesheets. */
+ --zk-amber: #b5761f;
+ --zk-amber-ink: #93500f;
+ --zk-amber-tint: #fdf1e7;
+ --zk-amber-line: #f0c69a;
+ --zk-danger-ink: #a3261f;
+ --zk-danger-tint: #fdeceb;
+ --zk-danger-line: #f0c2bd;
+
+ /* ── 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
+ rather than as generic grey haze. */
+ --r-xs: 4px;
+ --r-sm: 6px;
+ --r-md: 10px;
+ --r-lg: 14px;
+ --r-pill: 999px;
+
+ --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);
+ --sh-md: 0 1px 2px rgba(35, 54, 111, 0.05), 0 10px 24px -8px rgba(35, 54, 111, 0.14);
+ --sh-lg: 0 2px 6px rgba(35, 54, 111, 0.06), 0 20px 42px -14px rgba(35, 54, 111, 0.22);
+
+ --ring: 0 0 0 3px rgba(33, 103, 174, 0.16);
+
+ --ease: cubic-bezier(0.4, 0, 0.2, 1);
+ --t-fast: 0.14s var(--ease);
+ --t: 0.2s var(--ease);
+
+ --grad-blue: linear-gradient(135deg, var(--zk-blue) 0%, var(--zk-blue-deep) 100%);
+ --grad-blue-hover: linear-gradient(135deg, var(--zk-blue-dark) 0%, var(--zk-navy) 100%);
+
--font-zurich: 'Zurich Sans', Arial, Helvetica, sans-serif;
+ --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
font-family: var(--font-zurich);
font-size: 16px;
line-height: 1.5;
color: var(--zk-ink);
- background: var(--zk-white);
+ background: var(--zk-tint);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
@@ -65,10 +100,75 @@ body,
body {
margin: 0;
+ background: var(--zk-tint);
}
button,
-input {
+input,
+select,
+textarea {
font: inherit;
color: inherit;
}
+
+/* One focus treatment for the whole console: a soft brand ring, never the
+ browser's default outline halfway through a card's rounded corner. */
+:focus-visible {
+ outline: 2px solid var(--zk-blue);
+ outline-offset: 2px;
+}
+
+::selection {
+ background: var(--zk-tint-blue);
+ color: var(--zk-blue-dark);
+}
+
+/* Scrollbars, toned to the palette. Chrome/Safari take the ::-webkit rules,
+ Firefox the standard properties. */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: var(--zk-line) transparent;
+}
+
+::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ border: 3px solid transparent;
+ border-radius: var(--r-pill);
+ background-clip: content-box;
+ background-color: var(--zk-line);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: var(--zk-grey);
+}
+
+@keyframes zk-rise {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: none; }
+}
+
+@keyframes zk-shimmer {
+ from { background-position: -420px 0; }
+ to { background-position: 420px 0; }
+}
+
+/* Motion is decoration here — every animation is an entrance or a hover, so
+ dropping them costs nothing. */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.001ms !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/src/layout/Shell.css b/src/layout/Shell.css
index 680a253..cee139e 100644
--- a/src/layout/Shell.css
+++ b/src/layout/Shell.css
@@ -1,58 +1,288 @@
-.shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--zk-tint); }
+.shell {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--zk-tint);
+}
+/* ── Top bar ───────────────────────────────────────────────── */
.shell__top {
- display: flex; align-items: center; justify-content: space-between;
- gap: 24px; padding: 0 24px; height: 64px; background: var(--zk-white);
- border-bottom: 1px solid var(--zk-line); position: sticky; top: 0; z-index: 10;
+ position: sticky;
+ top: 0;
+ z-index: 30;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ height: 66px;
+ padding: 0 28px;
+ background: rgba(255, 255, 255, 0.86);
+ backdrop-filter: saturate(180%) blur(14px);
+ -webkit-backdrop-filter: saturate(180%) blur(14px);
+ border-bottom: 1px solid var(--zk-line);
+ box-shadow: var(--sh-xs);
}
-.shell__brand { display: flex; align-items: center; gap: 14px; }
-.shell__brand img { height: 30px; width: auto; display: block; }
-.shell__brand div { display: flex; flex-direction: column; line-height: 1.25; padding-left: 14px; border-left: 1px solid var(--zk-line); }
-.shell__brand strong { font-size: .95rem; font-weight: 500; color: var(--zk-ink); }
-.shell__brand span { font-size: .74rem; color: var(--zk-muted); }
-.shell__who { display: flex; align-items: center; gap: 16px; }
-.shell__user { display: flex; flex-direction: column; text-align: right; line-height: 1.3; }
-.shell__user strong { font-size: .85rem; font-weight: 500; }
-.shell__user span { font-size: .72rem; color: var(--zk-muted); }
-.shell__signout {
- font: inherit; font-size: .8rem; padding: 7px 14px; border-radius: 4px; cursor: pointer;
- background: var(--zk-white); color: var(--zk-blue); border: 1px solid var(--zk-line);
+.shell__brand {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ min-width: 0;
}
-.shell__signout:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
-.shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; }
+.shell__brand img {
+ height: 30px;
+ width: auto;
+ display: block;
+}
+
+.shell__brandtext {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ line-height: 1.2;
+ padding-left: 16px;
+ border-left: 1px solid var(--zk-line);
+}
+
+.shell__brandtext strong {
+ font-size: 0.95rem;
+ font-weight: 500;
+ letter-spacing: -0.005em;
+ color: var(--zk-ink);
+}
+
+.shell__brandtext span {
+ font-size: 0.7rem;
+ font-weight: 400;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--zk-grey);
+}
+
+/* ── Body ──────────────────────────────────────────────────── */
+.shell__body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ align-items: stretch;
+}
.shell__nav {
- width: 244px; flex: none; background: var(--zk-white);
- border-right: 1px solid var(--zk-line); padding: 18px 12px 28px;
- display: flex; flex-direction: column; gap: 1px;
+ position: sticky;
+ top: 66px;
+ align-self: flex-start;
+ width: 264px;
+ flex: none;
+ min-height: calc(100vh - 66px);
+ max-height: calc(100vh - 66px);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ background: var(--zk-white);
+ border-right: 1px solid var(--zk-line);
+ padding: 20px 14px 32px;
+ display: flex;
+ flex-direction: column;
}
+
.shell__navlabel {
- margin: 0 0 8px; padding: 0 10px; font-size: .66rem; font-weight: 500;
- letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey);
+ margin: 0 0 10px;
+ padding: 0 10px;
+ font-size: 0.64rem;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--zk-grey);
+}
+
+.shell__navlabel--closed {
+ margin-top: 26px;
+}
+
+/* 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: 2px;
+}
+
+.shell__stages::before {
+ content: '';
+ position: absolute;
+ left: 17px;
+ top: 18px;
+ bottom: 18px;
+ width: 2px;
+ border-radius: var(--r-pill);
+ background: linear-gradient(180deg, var(--zk-line) 0%, var(--zk-line-soft) 100%);
}
-.shell__navlabel--closed { margin-top: 22px; }
.shell__stage {
- display: flex; align-items: center; justify-content: space-between; gap: 8px;
- padding: 8px 10px; border-radius: 4px; text-decoration: none;
- color: var(--zk-ink); font-size: .84rem; border-left: 2px solid transparent;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 9px 12px 9px 38px;
+ border-radius: var(--r-md);
+ text-decoration: none;
+ color: var(--zk-muted);
+ font-size: 0.845rem;
+ transition: background var(--t-fast), color var(--t-fast), transform var(--t-fast);
}
-.shell__stage:hover { background: var(--zk-tint); }
-.shell__stage.is-active { background: var(--zk-tint-blue); border-left-color: var(--zk-blue); color: var(--zk-blue-dark); font-weight: 500; }
+
+.shell__stage::before {
+ content: '';
+ position: absolute;
+ left: 12px;
+ 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 {
+ background: var(--zk-tint-blue);
+ color: var(--zk-blue-dark);
+ font-weight: 500;
+}
+
+.shell__stage.is-active::before {
+ background: var(--zk-blue);
+ border-color: var(--zk-blue);
+ box-shadow: 0 0 0 4px rgba(33, 103, 174, 0.14);
+}
+
/* The one queue where the machine stops and a person decides. */
-.shell__stage.is-human .shell__stagename { font-weight: 500; }
+.shell__stage.is-human .shell__stagename {
+ font-weight: 500;
+ color: var(--zk-ink);
+}
+
+.shell__stage.is-human.is-active .shell__stagename {
+ color: var(--zk-blue-dark);
+}
+
+/* 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;
+}
+
.shell__badge {
- font-size: .6rem; letter-spacing: .08em; text-transform: uppercase;
- padding: 2px 6px; border-radius: 3px; background: var(--zk-blue); color: var(--zk-white);
+ flex: none;
+ font-size: 0.58rem;
+ font-weight: 500;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ padding: 2px 8px;
+ border-radius: var(--r-pill);
+ background: var(--grad-blue);
+ color: var(--zk-white);
+ box-shadow: var(--sh-xs);
}
-.shell__main { flex: 1; min-width: 0; padding: 26px 28px 60px; }
+.shell__main {
+ flex: 1;
+ min-width: 0;
+ width: 100%;
+ max-width: 1460px;
+ margin: 0 auto;
+ padding: 30px 34px 76px;
+ animation: zk-rise 0.28s var(--ease) both;
+}
+/* ── Primary action ────────────────────────────────────────── */
.shell__add {
- display: block; text-align: center; text-decoration: none; margin: 0 2px 18px;
- padding: 9px 12px; border-radius: 4px; font-size: .85rem; font-weight: 500;
- background: var(--zk-blue); color: var(--zk-white); border: 1px solid var(--zk-blue);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ margin: 0 2px 22px;
+ padding: 11px 14px;
+ border-radius: var(--r-md);
+ text-decoration: none;
+ font-size: 0.86rem;
+ 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);
+}
+
+.shell__add:hover {
+ background: var(--grad-blue-hover);
+ box-shadow: var(--sh-md);
+ transform: translateY(-1px);
+}
+
+.shell__add:active {
+ transform: none;
+ box-shadow: var(--sh-xs);
+}
+
+.shell__add svg {
+ width: 15px;
+ height: 15px;
+ flex: none;
+}
+
+/* ── Narrow viewports: the rail lies down and scrolls ──────── */
+@media (max-width: 900px) {
+ .shell__body {
+ flex-direction: column;
+ }
+
+ .shell__nav {
+ position: static;
+ width: 100%;
+ min-height: 0;
+ max-height: none;
+ border-right: 0;
+ border-bottom: 1px solid var(--zk-line);
+ padding: 14px 16px 16px;
+ }
+
+ .shell__stages {
+ flex-direction: row;
+ overflow-x: auto;
+ padding-bottom: 4px;
+ }
+
+ /* 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;
+ white-space: nowrap;
+ background: var(--zk-white);
+ border: 1px solid var(--zk-line);
+ }
+
+ .shell__stage::before {
+ display: none;
+ }
+
+ .shell__main {
+ padding: 22px 18px 56px;
+ }
}
-.shell__add:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); }
diff --git a/src/layout/Shell.jsx b/src/layout/Shell.jsx
index d8eea25..a30d2c0 100644
--- a/src/layout/Shell.jsx
+++ b/src/layout/Shell.jsx
@@ -1,6 +1,6 @@
import { NavLink, Outlet } from 'react-router-dom'
-import { useZino } from '../api/provider.jsx'
import { STAGES } from '../api/config.js'
+import UserMenu from './UserMenu.jsx'
import logo from '../assets/brand/zurich_logo.webp'
import './Shell.css'
@@ -10,7 +10,6 @@ import './Shell.css'
* as such, because it is the only place the machine stops.
*/
export default function Shell() {
- const { user, signOut } = useZino()
const work = STAGES.filter((s) => s.kind !== 'end')
const closed = STAGES.filter((s) => s.kind === 'end')
@@ -19,51 +18,50 @@ export default function Shell() {
-
+
Lead Desk
Lead to Policy
-
-
- {user?.name || user?.email || 'Signed in'}
- {/* The role is shown because RBAC is the demo: what this person can
- and cannot do is the point, so who they are signed in as should
- never be a guess. */}
- {(user?.roles || []).join(', ') || user?.email}
-
-
- Sign out
-
-
+
- + Add a lead
+
+
+
+
+ Add a lead
+
Pipeline
- {work.map((s) => (
-
- 'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'human' ? ' is-human' : '')
- }
- >
- {s.name}
- {s.kind === 'human' ? you : null}
-
- ))}
+
+ {work.map((s) => (
+
+ 'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'human' ? ' is-human' : '')
+ }
+ >
+ {s.name}
+ {s.kind === 'human' ? you : null}
+
+ ))}
+
Closed
- {closed.map((s) => (
- 'shell__stage' + (isActive ? ' is-active' : '')}
- >
- {s.name}
-
- ))}
+
+ {closed.map((s) => (
+ 'shell__stage' + (isActive ? ' is-active' : '')}
+ >
+ {s.name}
+
+ ))}
+
diff --git a/src/layout/UserMenu.css b/src/layout/UserMenu.css
new file mode 100644
index 0000000..02b7d88
--- /dev/null
+++ b/src/layout/UserMenu.css
@@ -0,0 +1,161 @@
+.um {
+ position: relative;
+}
+
+.um__trigger {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ padding: 5px 12px 5px 5px;
+ 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: 0.72rem;
+ font-weight: 500;
+ letter-spacing: 0.03em;
+ box-shadow: var(--sh-xs);
+}
+
+.um__avatar--lg {
+ width: 40px;
+ height: 40px;
+ font-size: 0.86rem;
+}
+
+.um__id {
+ display: flex;
+ flex-direction: column;
+ text-align: left;
+ line-height: 1.3;
+}
+
+.um__id strong {
+ font-size: 0.85rem;
+ font-weight: 500;
+}
+
+.um__id > span {
+ font-size: 0.7rem;
+ 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__menu {
+ position: absolute;
+ top: calc(100% + 10px);
+ 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);
+ box-shadow: var(--sh-lg);
+ transform-origin: top right;
+ animation: zk-rise 0.16s var(--ease) both;
+}
+
+.um__head {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 12px 11px 14px;
+}
+
+.um__headid {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ line-height: 1.35;
+ min-width: 0;
+}
+
+.um__headid strong {
+ font-size: 0.87rem;
+ font-weight: 500;
+}
+
+.um__headid span {
+ font-size: 0.72rem;
+ color: var(--zk-muted);
+ overflow-wrap: anywhere;
+}
+
+.um__headid .um__roles {
+ margin-top: 4px;
+ align-self: flex-start;
+ font-size: 0.68rem;
+ font-weight: 500;
+ color: var(--zk-blue-dark);
+ background: var(--zk-tint-blue);
+ border-radius: var(--r-pill);
+ padding: 2px 9px;
+}
+
+.um__item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.82rem;
+ padding: 10px 11px;
+ border-radius: var(--r-sm);
+ background: none;
+ border: 0;
+ border-top: 1px solid var(--zk-line-soft);
+ 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);
+}
diff --git a/src/layout/UserMenu.jsx b/src/layout/UserMenu.jsx
new file mode 100644
index 0000000..95f17a8
--- /dev/null
+++ b/src/layout/UserMenu.jsx
@@ -0,0 +1,86 @@
+import { useEffect, useRef, useState } from 'react'
+import { useZino } from '../api/provider.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.
+ */
+export default function UserMenu() {
+ const { user, signOut } = useZino()
+ const [open, setOpen] = useState(false)
+ const wrapRef = useRef(null)
+ const triggerRef = useRef(null)
+
+ // Close on an outside click or Escape. Bound only while open, so the shell
+ // costs nothing at rest.
+ useEffect(() => {
+ if (!open) return
+ function onDown(e) {
+ if (!wrapRef.current?.contains(e.target)) setOpen(false)
+ }
+ function onKey(e) {
+ if (e.key !== 'Escape') return
+ setOpen(false)
+ triggerRef.current?.focus()
+ }
+ document.addEventListener('mousedown', onDown)
+ document.addEventListener('keydown', onKey)
+ return () => {
+ document.removeEventListener('mousedown', onDown)
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [open])
+
+ const name = user?.name || user?.email || 'Signed in'
+ const roles = (user?.roles || []).join(', ')
+
+ return (
+
+
setOpen((o) => !o)}
+ aria-haspopup="menu"
+ aria-expanded={open}
+ >
+ {initials(user)}
+
+ {name}
+ {roles || user?.email}
+
+
+
+
+
+
+ {open ? (
+
+
+
{initials(user)}
+
+ {name}
+ {user?.email ? {user.email} : null}
+ {roles ? {roles} : null}
+
+
+
+ Sign out
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/runtimeConfig.js b/src/runtimeConfig.js
index c1b57bd..7f68c25 100644
--- a/src/runtimeConfig.js
+++ b/src/runtimeConfig.js
@@ -35,7 +35,19 @@ export function requireConfigValue(key) {
*
* `import.meta.env.BASE_URL` is NOT usable here — with Vite's relative base it
* resolves to "./".
+ *
+ * It reads the TAG, not `document.baseURI`. With no in the document —
+ * which is every `npm run dev` session, where the placeholder is still an HTML
+ * comment — baseURI falls back to the current page URL, so the basename became
+ * whatever deep path happened to be loaded. Open /lead/16911 directly and every
+ * link then rendered under it: /lead/16911/lead/11411, and again on the next
+ * click. Unmounted means mounted at the root.
*/
export function basePath() {
- return new URL(document.baseURI).pathname
+ const tag = document.querySelector('base[href]')
+ if (!tag) return '/'
+ const path = new URL(tag.href, window.location.origin).pathname
+ // A relative href ("./") resolves against the current URL and would reintroduce
+ // exactly the same drift. Only an absolute mount path is a mount path.
+ return tag.getAttribute('href').startsWith('/') ? path : '/'
}
diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx
index bc7c342..9eb216e 100644
--- a/src/screens/Lead.jsx
+++ b/src/screens/Lead.jsx
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
-import { Link, useParams } from 'react-router-dom'
+import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx'
+import ClampText from '../components/ClampText.jsx'
import Timeline from '../components/Timeline.jsx'
import { ACTIONS, DV_LEAD, STAGES } from '../api/config.js'
import './screens.css'
@@ -49,6 +50,26 @@ const GROUPS = [
["The agent's payout", ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']],
]
+/** Past this, a value is prose and gets folded rather than printed in full. */
+const LONG = 150
+
+/**
+ * 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.
+ */
+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',
+ cc: 'CC', id: 'ID', no: 'no.', pct: '%', wip: 'WIP', bi: 'BI', ee: 'EE',
+ amc: 'AMC', km: 'km', posp: 'POSP',
+}
+
+function label(k) {
+ const words = k.split('_').map((w) => WORDS[w] ?? w).join(' ')
+ return words.charAt(0).toUpperCase() + words.slice(1)
+}
+
function fmt(k, v) {
if (v === null || v === undefined || v === '') return null
if (Array.isArray(v)) return v.join(', ')
@@ -59,9 +80,12 @@ function fmt(k, v) {
export default function Lead() {
const { instanceId } = useParams()
const { client } = useZino()
+ const navigate = useNavigate()
+ const location = useLocation()
const [row, setRow] = useState(null)
const [err, setErr] = useState(null)
const [open, setOpen] = useState(null)
+ const [tab, setTab] = useState(0)
const load = useCallback(() => {
setErr(null)
@@ -75,6 +99,12 @@ export default function Lead() {
if (err) return Could not load lead {instanceId}. {err.status} {err.message}
if (!row) return Loading…
+ // Only groups that actually hold something; an empty tab is a dead end.
+ const groups = GROUPS
+ .map(([title, keys]) => [title, keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null)])
+ .filter(([, shown]) => shown.length)
+ const active = Math.min(tab, Math.max(groups.length - 1, 0))
+
const stateName = row.current_state_name || ''
const stage = STAGES.find((s) => s.name === stateName)
const actions = ACTIONS[stage?.uid] || []
@@ -82,16 +112,34 @@ export default function Lead() {
return (
-
-
{row.lead_ref || `Lead ${instanceId}`}
+
+ {/* 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.customer_name}{row.entity_name ? ` · ${row.entity_name}` : ''} ·{' '}
+ {row.lead_ref || `Lead ${instanceId}`}
+ {row.entity_name ? ` · ${row.entity_name}` : ''} ·{' '}
{row.product_line === 'motor' ? 'Motor' : 'SME Package'}
+
Stage
- {stateName || '—'}
+ {stateName || '—'}
+ {/* An end state runs no activity. That is worth one line next to the
+ stage, not a panel of its own where the actions would be. */}
+ {!actions.length ? Closed — nothing runs from here : null}
@@ -102,80 +150,112 @@ export default function Lead() {
{e.label} {e.on}
) : null })()}
+ {row.quoted_premium ? (
+
+
Quoted premium
+ ₹{Number(row.quoted_premium).toLocaleString('en-IN')}
+
+ ) : null}
{row.current_insurer ?
Current insurer {row.current_insurer} : null}
{row.motor_reg_no ?
Registration {row.motor_reg_no} : null}
{row.created_at ?
Lead added {fmtWhen(row.created_at)} : null}
{row.updated_at ?
Last activity {fmtWhen(row.updated_at)} : null}
- {actions.length ? (
-
-
-
-
What happens next
-
- These are the only activities this state allows. Who normally performs
- each is shown — but the workflow decides, server-side, whether you may.
-
+
+
+ {actions.length ? (
+
+
+
+
What happens next
+
+ These are the only activities this state allows. Who normally performs
+ each is shown — but the workflow decides, server-side, whether you may.
+
+
+
+
+ {actions.map((a) => (
+ setOpen(open === a.uid ? null : a.uid)}>
+ {a.label}
+ {a.by}
+
+ ))}
+
+ {open ? (
+
+
setOpen(null)}
+ onDone={() => { setOpen(null); load() }}
+ />
+
+ ) : null}
-
-
- {actions.map((a) => (
- setOpen(open === a.uid ? null : a.uid)}>
- {a.label}
- {a.by}
-
- ))}
-
- {open ? (
-
-
setOpen(null)}
- onDone={() => { setOpen(null); load() }}
- />
+ ) : null}
+
+ {/* The flow's own detail. Each group is one click away rather than one
+ long scroll, and prose is folded to a few lines — the page can be
+ scanned, and still holds everything for whoever wants it. */}
+ {groups.length ? (
+
+
+
+
Flow details
+
Everything captured on this lead, grouped in the order it was worked.
+
+
+
+ {groups.map(([title, shown], i) => (
+ setTab(i)}
+ >
+ {title}{shown.length}
+
+ ))}
+
+
+ {groups[active][1].map(([k, v]) => (
+ v.length > LONG ? (
+
+
{label(k)}
+
+
+ ) : (
+
+
{label(k)}
+ {v}
+
+ )
+ ))}
+
) : null}
- ) : (
-
Terminal. No activity runs from {stateName}.
- )}
-
-
-
-
What has happened
-
- Every step on this lead, in order, and who took it — an AI employee,
- a person, or the platform itself.
-
+ {/* The story runs alongside the file rather than under it, so what was
+ done and what was captured can be read against each other. */}
+
+
+
+
+
What has happened
+
Every step, in order, and who took it.
+
+
+
+
+
-
-
+
- {GROUPS.map(([title, keys]) => {
- const shown = keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null)
- if (!shown.length) return null
- return (
-
-
{title}
-
- {shown.map(([k, v]) => (
-
-
{k.replace(/_/g, ' ')}
- {v}
-
- ))}
-
-
- )
- })}
-
-
Back to the queue
)
}
diff --git a/src/screens/Pipeline.jsx b/src/screens/Pipeline.jsx
index c05e260..b391606 100644
--- a/src/screens/Pipeline.jsx
+++ b/src/screens/Pipeline.jsx
@@ -80,11 +80,18 @@ export default function Pipeline() {
{state.status === 'ready' ? (
-
{state.rows.length}
+
+ {state.rows.length}
+ {state.rows.length === 1 ? 'lead' : 'leads'}
+
) : null}
- {state.status === 'loading' ?
Loading…
: null}
+ {state.status === 'loading' ? (
+
+ {Array.from({ length: 6 }, (_, i) =>
)}
+
+ ) : null}
{state.status === 'error' ? (
@@ -103,44 +110,48 @@ export default function Pipeline() {
) : null}
{state.status === 'ready' && state.rows.length > 0 ? (
-
-
-
- Lead Customer Channel
- Product Renewal due Premium
- Attribution Added
-
-
-
- {state.rows.map((r) => {
- 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)
- return (
-
- {r.lead_ref || id}
- {r.customer_name || '—'}{r.entity_name || ''}
- {ch.label}
- {r.product_line === 'motor' ? 'Motor' : 'SME Package'}
-
- {exp
- ? <>{exp.label}
- {exp.on}
>
- : — }
-
- {r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'}
-
- {r.attribution_status === 'contested'
- ? Contested
- : {r.attribution_status || '—'} }
-
- {add.abs}{add.rel}
-
- )
- })}
-
-
+
+
+
+
+ Customer Channel
+ Product Renewal due Premium
+ Attribution Added
+
+
+
+ {state.rows.map((r) => {
+ 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)
+ return (
+
+ {r.customer_name || '—'}{r.entity_name || ''}
+ {ch.label}
+ {r.product_line === 'motor' ? 'Motor' : 'SME Package'}
+
+ {exp
+ ? <>{exp.label}
+ {exp.on}
>
+ : — }
+
+ {r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'}
+
+ {r.attribution_status === 'contested'
+ ? Contested
+ : {r.attribution_status || '—'} }
+
+ {add.abs}{add.rel}
+
+ Open
+
+
+ )
+ })}
+
+
+
) : null}
)
diff --git a/src/screens/screens.css b/src/screens/screens.css
index 8a514ff..acd483d 100644
--- a/src/screens/screens.css
+++ b/src/screens/screens.css
@@ -1,109 +1,792 @@
+/* ---- page header ---- */
.page__head {
- display: flex; align-items: flex-start; justify-content: space-between;
- gap: 24px; margin-bottom: 20px;
-}
-.page__title { margin: 0; font-size: 1.5rem; font-weight: 500; letter-spacing: -.01em; color: var(--zk-ink); }
-.page__sub { margin: 3px 0 0; font-size: .84rem; color: var(--zk-muted); }
-.page__count {
- font-size: 1.35rem; font-weight: 500; color: var(--zk-blue);
- background: var(--zk-white); border: 1px solid var(--zk-line);
- border-radius: 6px; padding: 6px 16px; font-variant-numeric: tabular-nums;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 24px;
}
-.empty { color: var(--zk-muted); font-size: .88rem; padding: 28px 2px; }
+.page__title {
+ margin: 0;
+ font-size: 1.7rem;
+ font-weight: 500;
+ letter-spacing: -0.022em;
+ line-height: 1.2;
+ color: var(--zk-ink);
+}
+
+.page__sub {
+ margin: 6px 0 0;
+ max-width: 72ch;
+ font-size: 0.855rem;
+ line-height: 1.5;
+ color: var(--zk-muted);
+}
+
+/* The queue depth, as a stat tile rather than a loose number. */
+.page__count {
+ flex: none;
+ 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);
+}
+
+.page__count strong {
+ font-size: 1.6rem;
+ font-weight: 500;
+ line-height: 1.1;
+ color: var(--zk-blue);
+ font-variant-numeric: tabular-nums;
+}
+
+.page__count span {
+ font-size: 0.6rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--zk-grey);
+}
+
+.empty {
+ margin: 0;
+ color: var(--zk-muted);
+ font-size: 0.88rem;
+ text-align: center;
+ padding: 44px 24px;
+ border-radius: var(--r-lg);
+ border: 1px dashed var(--zk-line);
+ background: linear-gradient(180deg, var(--zk-white) 0%, var(--zk-tint) 100%);
+}
.notice {
- background: var(--zk-white); border: 1px solid var(--zk-line);
- border-left: 3px solid var(--zk-blue); border-radius: 0 6px 6px 0; padding: 16px 20px;
- max-width: 60ch;
+ background: var(--zk-white);
+ border: 1px solid var(--zk-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);
+ padding: 18px 22px;
+ max-width: 64ch;
}
-.notice strong { display: block; font-weight: 500; margin-bottom: 6px; }
-.notice p { margin: 0 0 6px; font-size: .86rem; color: var(--zk-muted); line-height: 1.55; }
-.notice__detail { font-size: .76rem !important; color: var(--zk-grey) !important; }
+
+.notice strong {
+ display: block;
+ font-weight: 500;
+ margin-bottom: 7px;
+}
+
+.notice p {
+ margin: 0 0 6px;
+ font-size: 0.86rem;
+ color: var(--zk-muted);
+ line-height: 1.55;
+}
+
+.notice__detail {
+ font-size: 0.76rem !important;
+ color: var(--zk-grey) !important;
+}
+
.notice code {
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8em;
- background: var(--zk-tint); border: 1px solid var(--zk-line-soft);
- border-radius: 3px; padding: 1px 4px;
+ font-family: var(--font-mono);
+ font-size: 0.8em;
+ background: var(--zk-tint);
+ border: 1px solid var(--zk-line-soft);
+ border-radius: var(--r-xs);
+ padding: 1px 5px;
+}
+
+/* ---- queue table ---- */
+.gridwrap {
+ background: var(--zk-white);
+ border: 1px solid var(--zk-line);
+ border-radius: var(--r-lg);
+ box-shadow: var(--sh-sm);
+ overflow: hidden;
+ overflow-x: auto;
}
.grid {
- width: 100%; border-collapse: collapse; background: var(--zk-white);
- border: 1px solid var(--zk-line); border-radius: 6px; overflow: hidden; font-size: .86rem;
+ 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: 900px;
+ border-collapse: separate;
+ border-spacing: 0;
+ font-size: 0.86rem;
}
-.grid th {
- text-align: left; font-size: .68rem; font-weight: 500; letter-spacing: .08em;
- text-transform: uppercase; color: var(--zk-muted); padding: 10px 14px;
- background: var(--zk-tint); border-bottom: 1px solid var(--zk-line); white-space: nowrap;
-}
-.grid td { padding: 11px 14px; border-bottom: 1px solid var(--zk-line-soft); vertical-align: top; }
-.grid tbody tr:last-child td { border-bottom: none; }
-.grid tbody tr:hover { background: var(--zk-tint); }
-.grid .num { text-align: right; font-variant-numeric: tabular-nums; }
-.grid__sub { font-size: .76rem; color: var(--zk-muted); margin-top: 2px; }
-.grid__link { color: var(--zk-blue); text-decoration: none; font-weight: 500; }
-.grid__link:hover { text-decoration: underline; }
-.chip {
- display: inline-block; font-size: .72rem; padding: 2px 8px; border-radius: 3px;
- background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light);
+.grid th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ text-align: left;
+ font-size: 0.65rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--zk-muted);
+ padding: 12px 16px;
+ background: var(--zk-tint);
+ border-bottom: 1px solid var(--zk-line);
+ white-space: nowrap;
+}
+
+.grid td {
+ padding: 13px 16px;
+ border-bottom: 1px solid var(--zk-line-soft);
+ vertical-align: top;
+}
+
+.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;
+}
+
+.grid__sub {
+ font-size: 0.76rem;
+ color: var(--zk-muted);
+ margin-top: 3px;
+}
+
+.grid__link {
+ color: var(--zk-blue);
+ text-decoration: none;
+ font-weight: 500;
+ border-bottom: 1px solid transparent;
+ transition: border-color var(--t-fast);
+}
+
+.grid__link:hover {
+ border-bottom-color: currentColor;
+}
+
+/* Row action. Renders as a button; stays an anchor so an operator can open a
+ lead in a new tab from a queue.
+ NOT `.act` — that class belongs to the Lead page's activity buttons and is
+ display:flex, which stretched this anchor to fill the cell. */
+.grid__act {
+ text-align: right;
+ white-space: nowrap;
+ width: 1%;
+}
+
+.grid__btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ text-decoration: none;
+ white-space: nowrap;
+ font-size: 0.78rem;
+ font-weight: 500;
+ padding: 6px 14px;
+ border-radius: var(--r-pill);
+ background: var(--zk-white);
+ color: var(--zk-blue);
+ border: 1px solid var(--zk-line);
+ transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
+}
+
+.grid__btn::after {
+ content: '→';
+ font-size: 0.85em;
+ transition: transform var(--t-fast);
+}
+
+.grid__btn:hover {
+ background: var(--zk-tint-blue);
+ border-color: var(--zk-blue-light);
+}
+
+.grid__btn:hover::after {
+ transform: translateX(2px);
+}
+
+.grid__btn:focus-visible {
+ outline: none;
+ border-color: var(--zk-blue);
+ box-shadow: var(--ring);
+}
+
+/* ---- loading skeleton ---- */
+.skel {
+ 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;
+}
+
+.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 {
+ display: inline-flex;
+ align-items: center;
+ font-size: 0.72rem;
+ font-weight: 500;
+ padding: 3px 10px;
+ 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);
}
-.chip--warn { background: #fdf1e7; color: #93500f; border-color: #f0c69a; }
/* ---- doors ---- */
-.doors { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
-.door {
- font: inherit; text-align: left; cursor: pointer; background: var(--zk-white);
- border: 1px solid var(--zk-line); border-top: 3px solid var(--zk-blue);
- border-radius: 0 0 6px 6px; padding: 18px 20px; display: flex; flex-direction: column; gap: 8px;
+.doors {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(268px, 1fr));
+ gap: 18px;
+}
+
+.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;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow: hidden;
+ transition: box-shadow var(--t), transform var(--t-fast), 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: 1.02rem;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ color: var(--zk-ink);
+}
+
+.door p {
+ margin: 0;
+ font-size: 0.82rem;
+ color: var(--zk-muted);
+ line-height: 1.55;
+}
+
+.door .chip {
+ align-self: flex-start;
}
-.door:hover { border-color: var(--zk-blue-light); border-top-color: var(--zk-blue); background: var(--zk-tint); }
-.door strong { font-size: 1rem; font-weight: 500; color: var(--zk-ink); }
-.door p { margin: 0; font-size: .82rem; color: var(--zk-muted); line-height: 1.5; }
-.door .chip { align-self: flex-start; }
/* ---- panel ---- */
-.panel { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 20px 22px; margin-bottom: 22px; }
-.panel__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
-.panel__title { margin: 0; font-size: 1.05rem; font-weight: 500; }
-.panel__sub { margin: 3px 0 0; font-size: .82rem; color: var(--zk-muted); max-width: 62ch; line-height: 1.5; }
+.panel {
+ background: var(--zk-white);
+ border: 1px solid var(--zk-line);
+ border-radius: var(--r-lg);
+ box-shadow: var(--sh-sm);
+ padding: 24px 26px;
+ margin-bottom: 22px;
+}
+
+.panel__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 18px;
+}
+
+.panel__title {
+ margin: 0;
+ font-size: 1.06rem;
+ font-weight: 500;
+ letter-spacing: -0.012em;
+}
+
+.panel__sub {
+ margin: 5px 0 0;
+ font-size: 0.82rem;
+ 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: 5px; padding: 10px 14px;
- display: flex; flex-direction: column; gap: 2px; min-width: 190px;
+.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: 0.885rem;
+ font-weight: 500;
+}
+
+.act__by {
+ font-size: 0.72rem;
+ 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;
+}
+
+/* ---- 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: 4px;
+ width: 34px;
+ height: 34px;
+ 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: 14px;
+ min-width: 0;
+}
+
+/* ---- lead: the file beside the story ---- */
+.lead {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 384px;
+ gap: 20px;
+ 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: 90px;
+ min-width: 0;
+}
+
+.panel--rail {
+ padding: 0;
+ margin-bottom: 0;
+ overflow: hidden;
+}
+
+.panel--rail .panel__head {
+ padding: 20px 22px 0;
+ margin-bottom: 14px;
+}
+
+.lead__feed {
+ max-height: calc(100vh - 210px);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: 0 22px 20px;
+}
+
+/* One column below the width where a 384px rail stops paying for itself. */
+@media (max-width: 1180px) {
+ .lead {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .lead__side {
+ position: static;
+ }
+
+ .lead__feed {
+ max-height: 560px;
+ }
+}
+
+/* ---- 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. */
+.tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px;
+ margin-bottom: 20px;
+ border-bottom: 1px solid var(--zk-line);
+}
+
+.tab {
+ font: inherit;
+ font-size: 0.82rem;
+ white-space: nowrap;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 9px 13px;
+ background: none;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ border-radius: var(--r-sm) var(--r-sm) 0 0;
+ color: var(--zk-muted);
+ transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
+}
+
+.tab:hover {
+ background: var(--zk-tint);
+ color: var(--zk-ink);
+}
+
+.tab.is-on {
+ color: var(--zk-blue-dark);
+ font-weight: 500;
+ border-bottom-color: var(--zk-blue);
+}
+
+.tab__n {
+ font-size: 0.66rem;
+ font-variant-numeric: tabular-nums;
+ min-width: 18px;
+ padding: 1px 6px;
+ border-radius: var(--r-pill);
+ background: var(--zk-line-soft);
+ color: var(--zk-muted);
+ text-align: center;
+}
+
+.tab.is-on .tab__n {
+ background: var(--zk-tint-blue);
+ color: var(--zk-blue);
+}
+
+/* ---- property rows ----
+ Facts read as label/value pairs on hairlines rather than as a wall of filled
+ boxes: the tint was carrying no information and made every field shout as
+ loudly as every other. */
+.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: 0.78rem;
+ color: var(--zk-muted);
+ line-height: 1.45;
+}
+
+.prop dd {
+ margin: 0;
+ font-size: 0.875rem;
+ 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: 0.66rem;
+ font-weight: 500;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var(--zk-blue-dark);
+}
+
+.prop--note dd {
+ margin: 0;
}
-.act:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); }
-.act.is-open { border-color: var(--zk-blue); background: var(--zk-tint-blue); }
-.act strong { font-size: .88rem; font-weight: 500; }
-.act__by { font-size: .72rem; color: var(--zk-muted); }
-.acts__form { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--zk-line-soft); }
/* ---- lead file ---- */
-.lead__stage { text-align: right; }
-.lead__stagelabel { display: block; font-size: .66rem; letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey); }
-.lead__stage strong { font-size: 1rem; font-weight: 500; color: var(--zk-blue-dark); }
-.grp { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 16px 20px; margin-bottom: 14px; }
-.grp__title { margin: 0 0 10px; font-size: .72rem; font-weight: 500; letter-spacing: .09em; text-transform: uppercase; color: var(--zk-muted); }
-.grp__list { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 8px 20px; }
-.grp__row { display: flex; flex-direction: column; gap: 1px; padding: 4px 0; border-bottom: 1px solid var(--zk-line-soft); }
-.grp__row dt { font-size: .7rem; color: var(--zk-grey); text-transform: capitalize; }
-.grp__row dd { margin: 0; font-size: .87rem; color: var(--zk-ink); }
-.lead__back { margin-top: 18px; }
+.lead__stage {
+ flex: none;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 5px;
+ text-align: right;
+}
+
+.lead__stagelabel {
+ display: block;
+ font-size: 0.6rem;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--zk-grey);
+}
+
+.lead__closed {
+ font-size: 0.7rem;
+ color: var(--zk-grey);
+}
+
+.lead__stage strong {
+ font-size: 0.9rem;
+ font-weight: 500;
+ color: var(--zk-blue-dark);
+ padding: 6px 14px;
+ 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-block; font-size: .76rem; font-weight: 500;
- padding: 2px 8px; border-radius: 3px; white-space: nowrap;
+ display: inline-flex;
+ align-items: center;
+ font-size: 0.75rem;
+ 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);
}
-.due--lapsed { background: #fdeceb; color: #a3261f; border: 1px solid #f0c2bd; }
-.due--urgent { background: #fdf1e7; color: #93500f; border: 1px solid #f0c69a; }
-.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: 6px 22px; margin-top: 8px; }
-.lead__meta div { display: flex; flex-direction: column; gap: 1px; }
-.lead__meta dt { font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; color: var(--zk-grey); }
-.lead__meta dd { margin: 0; font-size: .84rem; color: var(--zk-ink); }
+.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: 0.6rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--zk-grey);
+}
+
+.lead__meta dd {
+ margin: 0;
+ font-size: 0.85rem;
+ 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: 1rem;
+ font-weight: 500;
+ color: var(--zk-blue-dark);
+ font-variant-numeric: tabular-nums;
+}