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}

+ +
+ ) +} 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 ? ( + + ) : 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 ( -
  1. +
      + {visible.map((it) => ( +
    1. - ) - })} -
    + ))} +
+ ) } 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() {
Zurich Kotak General Insurance -
+
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} -
- -
+
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 ( +
+ + + {open ? ( +
+
+ +
+ {name} + {user?.email ? {user.email} : null} + {roles ? {roles} : null} +
+
+ +
+ ) : 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. */} + +
+

{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) => ( + + ))} +
+ {open ? ( +
+ setOpen(null)} + onDone={() => { setOpen(null); load() }} + /> +
+ ) : null}
-
-
- {actions.map((a) => ( - - ))} -
- {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) => ( + + ))} +
+
+ {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. */} +
- +
- {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 ? ( - - - - - - - - - - {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 ( - - - - - - - - - - - ) - })} - -
LeadCustomerChannelProductRenewal duePremiumAttributionAdded
{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}
+
+ + + + + + + + + {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 ( + + + + + + + + + + + ) + })} + +
CustomerChannelProductRenewal duePremiumAttributionAdded +
{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; +}