diff --git a/src/api/agents.js b/src/api/agents.js new file mode 100644 index 0000000..3321296 --- /dev/null +++ b/src/api/agents.js @@ -0,0 +1,52 @@ +/** + * The AI employees, as people the operator can recognise. + * + * Five agents carry this workflow and the console referred to them by their + * role slug or their full title, differently in each place — "ai_engage" in one + * view, "Engage" in another, "Engage AI" in a third. Someone watching a lead + * move could not tell that the thing which called the customer and the thing + * which wrote the quote were the same worker. + * + * So: one roster, one short name, one colour, one initial. Used wherever an + * agent is named. `does` is written in the present tense and in the operator's + * words, not the charter's — it appears on hover and in the roster strip, and + * it is what makes an unfamiliar name mean something the first time. + * + * Keyed by the ROLE, because that is what an audit row carries. + */ +export const AGENTS = { + ai_intake: { + short: 'Intake', full: 'Intake & Attribution', initials: 'IA', tone: 'violet', + does: 'checks the lead is real, scores it, and works out whose it is', + }, + ai_engage: { + short: 'Engage', full: 'Engage', initials: 'EN', tone: 'blue', + does: 'talks to the customer, writes up the call, and asks for what is missing', + }, + ai_advisor: { + short: 'Advisor', full: 'Advisor', initials: 'AD', tone: 'teal', + does: 'reads the risk and recommends the cover', + }, + ai_kyc: { + short: 'KYC', full: 'KYC & Evidence', initials: 'KY', tone: 'amber', + does: 'verifies identity and screens the risk for underwriting', + }, + ai_uw_referral: { + short: 'Referral', full: 'Underwriting Referral', initials: 'UW', tone: 'plum', + does: 'prepares a referred file so an underwriter can decide', + }, +} + +/** The agent behind a set of roles, or null when a person did it. */ +export function agentFor(roles) { + if (!Array.isArray(roles)) return null + const key = roles.find((r) => AGENTS[r]) + return key ? { key, ...AGENTS[key] } : null +} + +/** Initials for a person, so a human entry reads as a name too. */ +export function initialsOf(name) { + const parts = String(name || '').trim().split(/\s+/).filter(Boolean) + if (!parts.length) return '—' + return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase() +} diff --git a/src/api/config.js b/src/api/config.js index 1a1ecd4..0be3b88 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -45,7 +45,14 @@ export const STAGES = [ { uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Customer decision', by: 'the customer', doing: 'Waiting for the customer to reply on WhatsApp' }, // The one queue where the machine stops and a person decides. { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriting referral', by: 'an underwriter' }, - { uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Premium confirmation', by: 'operations' }, + // `approval` marks the one queue that is a person's SIGN-OFF rather than a + // piece of work: confirming money has arrived. It is locked to ops_admin in + // the workflow — no agent and no other role can perform it — because an + // employee that can mark a premium received can put a customer on risk for a + // policy nobody paid for. It gets its own band on the overview so the role + // that owns it does not have to find it in a list of things mostly handled + // by somebody else. + { uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Premium confirmation', by: 'operations', approval: 'ops_admin', approvalLabel: 'Premium awaiting your confirmation', approvalNote: 'Cover cannot start until the premium is received — section 64VB. Only operations can confirm it.' }, { uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file', by: 'Engage AI' }, // Nurture. There is no scheduler yet, so Resume Outreach is a button and it // is the only thing that wakes a parked lead — do not present this as a diff --git a/src/components/AgentChip.css b/src/components/AgentChip.css new file mode 100644 index 0000000..8432410 --- /dev/null +++ b/src/components/AgentChip.css @@ -0,0 +1,35 @@ +.who { display: inline-flex; align-items: center; gap: 6px; min-width: 0; } + +.who__disc { + display: grid; + place-items: center; + flex: none; + width: 20px; + height: 20px; + border-radius: 50%; + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.02em; + color: #fff; + user-select: none; +} + +.who--md .who__disc { width: 26px; height: 26px; font-size: 0.66rem; } + +.who__name { + font-size: 0.76rem; + font-weight: 500; + color: var(--zk-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Five agents, five hues, all readable on white at 20px and all distinguishable + from the Zurich blue the interface already uses for its own chrome. */ +.who__disc--blue { background: #2167ae; } +.who__disc--teal { background: #0f7b78; } +.who__disc--violet { background: #5b4bb7; } +.who__disc--amber { background: #a8651a; } +.who__disc--plum { background: #8a3d6b; } +.who__disc--grey { background: #6b7280; } diff --git a/src/components/AgentChip.jsx b/src/components/AgentChip.jsx new file mode 100644 index 0000000..077b977 --- /dev/null +++ b/src/components/AgentChip.jsx @@ -0,0 +1,27 @@ +import { AGENTS, initialsOf } from '../api/agents.js' +import './AgentChip.css' + +/** + * Who did this — as a face, not a slug. + * + * A disc with initials, coloured per agent and consistent everywhere. The + * point is recognition at a glance down a timeline: the operator should see + * that one worker made four consecutive entries without reading four names. + * + * People get a disc too, in grey. A trail where the agents are decorated and + * the humans are plain text reads as though the machines are the important + * ones, which is the wrong way round on a screen whose job is oversight. + */ +export default function AgentChip({ agentKey, name, size = 'sm', showName = true }) { + const a = agentKey ? AGENTS[agentKey] : null + const label = a ? a.short : (name || 'System') + const initials = a ? a.initials : initialsOf(name) + const tone = a ? a.tone : 'grey' + + return ( + + + {showName ? {label} : null} + + ) +} diff --git a/src/components/Conversation.css b/src/components/Conversation.css new file mode 100644 index 0000000..f5f2cf5 --- /dev/null +++ b/src/components/Conversation.css @@ -0,0 +1,63 @@ +.conv { max-height: min(80vh, 760px); } + +.conv__body { + overflow-y: auto; + padding: 18px 22px 8px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.conv__empty { + margin: 0; + padding: 24px 0; + text-align: center; + font-size: 0.85rem; + color: var(--zk-grey); +} + +/* Theirs left, ours right: the arrangement everybody reads without being told, + which is the whole reason to render a thread rather than a log. */ +.bub { + max-width: 78%; + padding: 9px 13px 7px; + border-radius: 14px; +} + +.bub p { + margin: 0; + font-size: 0.86rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.bub span { + display: block; + margin-top: 4px; + font-size: 0.66rem; + opacity: 0.65; +} + +.bub--them { + align-self: flex-start; + background: var(--zk-line-soft); + color: var(--zk-ink); + border-bottom-left-radius: 4px; +} + +.bub--us { + align-self: flex-end; + background: var(--zk-tint-blue); + color: var(--zk-blue-dark); + border-bottom-right-radius: 4px; +} + +.conv__note { + margin: 0; + padding: 12px 22px 18px; + border-top: 1px solid var(--zk-line-soft); + font-size: 0.72rem; + line-height: 1.5; + color: var(--zk-grey); +} diff --git a/src/components/Conversation.jsx b/src/components/Conversation.jsx new file mode 100644 index 0000000..ccc9583 --- /dev/null +++ b/src/components/Conversation.jsx @@ -0,0 +1,115 @@ +import { useEffect, useRef } from 'react' +import { baseFieldId } from '../api/config.js' +import './Conversation.css' + +/** + * The WhatsApp thread, read out of the audit trail. + * + * The trail shows each turn as its own entry among twenty others, so reading a + * conversation meant scrolling a log and holding the order in your head. Here + * it is what it is: a thread, oldest first, theirs on the left and ours on the + * right. + * + * WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather + * than left for someone to discover. Every message the workflow RECORDS is + * here: the customer's replies (customer_reply) and the replies written back + * (customer_answer). The three automatic sends are not. The quote goes out as + * a registered WhatsApp template, and the acknowledgement and the acceptance + * confirmation are composed inside trigger nodes and never written to a field. + * A thread that quietly omitted them would be worse than one that says which + * parts it holds. + */ +function turnsFrom(rows) { + if (!Array.isArray(rows)) return [] + const out = [] + const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at))) + for (const r of ordered) { + const fields = Array.isArray(r.fields) && r.fields.length + ? r.fields + : Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, value: v })) + for (const f of fields) { + const base = baseFieldId(f.field_id) + const v = typeof f.value === 'string' ? f.value.trim() : '' + if (!v) continue + if (base === 'customer_reply') out.push({ side: 'them', text: v, at: r.created_at, key: r.id + '-in' }) + if (base === 'customer_answer') out.push({ side: 'us', text: v, at: r.created_at, key: r.id + '-out' }) + } + } + // One submission can be recorded more than once (the trigger commit and the + // settle both carry the payload), so the same sentence would print twice. + // De-duped on side + text rather than on the row id for that reason. + const seen = new Set() + return out.filter((t) => { + const k = t.side + ' ' + t.text + if (seen.has(k)) return false + seen.add(k) + return true + }) +} + +function at(ts) { + const d = new Date(ts) + if (isNaN(d)) return '' + return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) +} + +export default function Conversation({ rows, name, onClose }) { + const ref = useRef(null) + const restore = useRef(null) + const turns = turnsFrom(rows) + + useEffect(() => { + restore.current = document.activeElement + ref.current?.focus() + const onKey = (e) => { if (e.key === 'Escape') onClose() } + document.addEventListener('keydown', onKey) + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', onKey) + document.body.style.overflow = prev + if (restore.current instanceof HTMLElement) restore.current.focus() + } + }, [onClose]) + + return ( +
+
e.stopPropagation()} + > +
+

WhatsApp with {name || 'the customer'}

+ +
+ +
+ {turns.length ? turns.map((t) => ( +
+

{t.text}

+ {at(t.at)} +
+ )) : ( +

Nothing has been exchanged on WhatsApp yet.

+ )} +
+ +

+ Shows the customer's messages and the replies written back. The quote + itself, the read receipt and the acceptance confirmation are sent + automatically and are not recorded as text, so they do not appear here. +

+
+
+ ) +} diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 050f1c8..99fb46f 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -1,17 +1,14 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useZino } from '../api/provider.jsx' +import AgentChip from './AgentChip.jsx' import ClampText from './ClampText.jsx' +import { AGENTS } from '../api/agents.js' import { APP_ID, STAGES, baseFieldId } from '../api/config.js' import './Timeline.css' -/** Which employee holds which role — used to badge an entry as machine work. */ -const AI_ROLES = { - ai_intake: 'Intake & Attribution', - ai_engage: 'Engage', - ai_kyc: 'KYC & Evidence', - ai_advisor: 'Advisor', - ai_uw_referral: 'Underwriting Referral', -} +/* The roster lives in api/agents.js now — one short name, one colour and one + set of initials per employee, so the same worker looks the same everywhere + it appears. This file only needs to know which roles are agents. */ /** * The fields worth surfacing per activity — an agent's reasoning, a rule's @@ -98,24 +95,18 @@ function filesIn(value) { return value.filter((f) => f && typeof f === 'object' && f.uuid) } -export default function Timeline({ instanceId }) { +/** + * `rows` is owned by the lead page and refreshed on its poll, so the trail + * keeps up with a lead that five agents are working through in three minutes. + * This component fetched them itself once on mount and never again. + */ +export default function Timeline({ rows }) { 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 - client.audit(instanceId) - .then((r) => { if (!dead) setRows(Array.isArray(r) ? r : (r?.data ?? [])) }) - .catch((e) => { if (!dead) setErr(e) }) - return () => { dead = true } - }, [client, instanceId]) - - if (err) return
Could not load the timeline — {err.status} {err.message}
if (!rows) return

Loading the timeline…

if (!rows.length) return

Nothing has happened yet.

@@ -177,13 +168,13 @@ export default function Timeline({ instanceId }) { const items = merged.map((r, i) => { const roles = r.user_roles || [] // SYSTEM FIRST. A DATA_UPDATE row inherits the roles of whoever caused it, - // so an AI's own field write matched AI_ROLES, was classed as agent work, + // so an AI's own field write matched the agent roster, was classed as its work, // and appeared in the trail as an entry titled "Data updated" — twice, // while the toggle below still offered to reveal two others. The row's // activity id is what says it is bookkeeping; the roles say who triggered // the bookkeeping, which is a different question. const isSystem = r.activity_id === 'DATA_UPDATE' - const aiRole = roles.find((x) => AI_ROLES[x]) + const aiRole = roles.find((x) => AGENTS[x]) const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human' /** @@ -225,7 +216,8 @@ export default function Timeline({ instanceId }) { return { key: r.id ?? i, kind, - actor: aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System'), + agentKey: aiRole || null, + actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'), what: isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)), stage: moved ? stage : null, when: when(r.created_at), @@ -259,10 +251,12 @@ export default function Timeline({ instanceId }) { {it.what} {it.stage ? → {it.stage.name} : null} + {/* The worker, as a face. Recognition down a column beats + reading five names to notice it was one agent throughout. */}
- - {it.kind === 'ai' ? 'AI' : it.kind === 'sys' ? 'system' : 'person'} · {it.actor} - + {it.kind === 'sys' + ? system + : } {it.when}
diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index 47f496d..aee8d24 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -2,9 +2,12 @@ import { useCallback, useEffect, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useZino } from '../api/provider.jsx' import ActivityForm from '../components/ActivityForm.jsx' +import AgentChip from '../components/AgentChip.jsx' import ClampText from '../components/ClampText.jsx' +import Conversation from '../components/Conversation.jsx' import Timeline from '../components/Timeline.jsx' import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js' +import { AGENTS } from '../api/agents.js' import { actionsFor, rolesOf } from '../api/permissions.js' import { describeError } from '../api/errors.js' import './screens.css' @@ -140,9 +143,21 @@ export default function Lead() { const [open, setOpen] = useState(null) const [tab, setTab] = useState(0) const [labels, setLabels] = useState({}) + // The audit rows live HERE, not inside Timeline, for two reasons: Timeline + // fetched them once on mount and never again — so a lead worked by five + // agents in three minutes showed the trail as it was when you opened the + // page — and the conversation view needs the same rows. One fetch, one + // poll, two readers that cannot disagree. + const [audit, setAudit] = useState(null) + const [showChat, setShowChat] = useState(false) const load = useCallback((quiet = false) => { if (!quiet) setErr(null) + // Best-effort and never awaited with the record: a failed audit call must + // not blank the lead, and a slow one must not hold up the fields. + client.audit(instanceId) + .then((r) => setAudit(Array.isArray(r) ? r : (r?.data ?? []))) + .catch(() => { /* keep whatever is on screen */ }) return client.detailView(DV_LEAD, instanceId) .then((r) => { setRow(r?.data ?? r?.record ?? r) @@ -233,6 +248,18 @@ export default function Lead() { // Two different reasons for an empty action list, and they must not read the // same. A terminal lead is finished; a live one you cannot act on belongs to // somebody else, and saying "closed" about it would be a lie. + // Derived from the same audit rows the trail renders, so they cannot drift. + const worked = [...new Set((audit || []) + .flatMap((r) => (r.user_roles || []).filter((x) => AGENTS[x])))] + const chatTurns = (audit || []).reduce((n, r) => { + const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : [] + return n + fs.filter((f) => { + const b = String(f.field_id || '').replace(/_\d+$/, '') + return (b === 'customer_reply' || b === 'customer_answer') + && typeof f.value === 'string' && f.value.trim() + }).length + }, 0) + const isClosed = stage?.kind === 'end' const heldByOthers = !isClosed && actions.length === 0 // An automated stage that has stopped for a stated reason. Checked before @@ -413,10 +440,18 @@ export default function Lead() { // into the fold with the other recovery levers, relabelled for what // it is actually for — a wrong file, or one more. const docsDone = stage?.after === 'documents received' + // Recording an acceptance a second time is not a loop, a recovery + // or an alternative — it is meaningless. The customer accepted on + // WhatsApp at a stated time and the lead carries the reference; + // offering the button again invites somebody to overwrite that + // with a worse record of the same event. + const accepted = Boolean(row.acceptance_ref) const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs' ? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' } : a) - const shown = actions.map(recast) + const shown = actions + .filter((a) => !(accepted && a.uid === 'zk-act-accept')) + .map(recast) const step = shown.filter((a) => a.role === 'do') const again = shown.filter((a) => a.role === 'again') const force = shown.filter((a) => a.role === 'force') @@ -454,7 +489,9 @@ export default function Lead() { ? 'This lead is closed.' : blocked ? 'The chain has stopped. The fix is above.' - : `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`} + : accepted && stage?.uid === 'zk-state-quoted' + ? `Accepted by the customer${row.accepted_at ? ' on ' + row.accepted_at : ''}${row.acceptance_ref === 'whatsapp' ? ', over WhatsApp' : ''}. KYC and underwriting are running.` + : `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}

@@ -589,14 +626,40 @@ export default function Lead() {

Audit trail

Every step, who took it, and what they wrote.

+ {/* The WhatsApp exchange is spread across the trail one turn at a + time, so reading it meant scrolling a log and holding the order + in your head. This shows it as the thread it is. */} + {chatTurns ? ( + + ) : null} + + {/* Who worked this lead. Five agents carry it and their names appear + only inside individual entries, so the team was never visible at + a glance. */} + {worked.length ? ( +
+ Worked by + {worked.map((k) => )} +
+ ) : null}
- +
+ {showChat ? ( + setShowChat(false)} /> + ) : null} ) } diff --git a/src/screens/Overview.jsx b/src/screens/Overview.jsx index e80f344..a5f87e8 100644 --- a/src/screens/Overview.jsx +++ b/src/screens/Overview.jsx @@ -124,12 +124,16 @@ export default function Overview() { } }) + // The sign-off queues this ROLE owns. Empty for everyone else, so the band + // never appears to a partner agent who could not act on it anyway. + const approvals = actionable.filter((s) => s.approval && roles.includes(s.approval)) + return { - byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, + byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, approvals, conversion: closed ? Math.round((won.length / closed) * 100) : null, maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)), } - }, [state.rows]) + }, [state.rows, roles]) const mine = new Set(visibleStages(roles).map((s) => s.uid)) @@ -198,6 +202,23 @@ export default function Overview() { + {/* SIGN-OFF, not work. The only queue that is a person's approval rather + than a task, shown to the role that owns it and to nobody else. It sat + fourth in a list of six and read like any other item, which is the + wrong weight for the step that decides whether cover begins. */} + {m.approvals.map((s) => ( + + {s.n} + + {s.approvalLabel} + {s.approvalNote} + + + {s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'} + + + ))} +

Action required

diff --git a/src/screens/screens.css b/src/screens/screens.css index 478c03d..f1abc67 100644 --- a/src/screens/screens.css +++ b/src/screens/screens.css @@ -1244,3 +1244,117 @@ border: 2px solid var(--zk-grey); background: transparent; } + +/* ---- panel action ---- + A secondary control on a panel heading. Quiet: it opens a view, it does not + change anything. */ +.panel__act { + display: inline-flex; + align-items: center; + gap: 7px; + flex: none; + font: inherit; + font-size: 0.78rem; + font-weight: 500; + cursor: pointer; + padding: 6px 12px; + border-radius: var(--r-pill); + color: var(--zk-blue-dark); + background: var(--zk-white); + border: 1px solid var(--zk-line); + transition: background var(--t-fast), border-color var(--t-fast); +} +.panel__act svg { width: 13px; height: 13px; opacity: .75; } +.panel__act b { + font-size: 0.68rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + padding: 1px 6px; + border-radius: var(--r-pill); + background: var(--zk-tint-blue); +} +.panel__act:hover { background: var(--zk-tint); border-color: var(--zk-blue-light); } +.panel__act:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; } + +/* ---- who worked this lead ---- + Five agents carry a lead and their names appeared only inside individual + entries. The team is a fact about the lead, so it sits above the trail. */ +.crew { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 10px 18px 12px; + border-bottom: 1px solid var(--zk-line-soft); +} +.crew__l { + font-size: 0.64rem; + font-weight: 500; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--zk-grey); +} + +/* ---- a sign-off, not a task ---- + Confirming a premium is the only step in this workflow that is a person's + approval rather than a piece of work, and the only one locked to one role. + It sat fourth in a list of six queues, which is the wrong weight for the + decision that starts somebody's cover. Shown only to the role that owns it. */ +.appr { + display: flex; + align-items: center; + gap: 18px; + margin-bottom: 22px; + padding: 16px 22px; + border-radius: var(--r-lg); + text-decoration: none; + border: 1px solid var(--zk-line); + background: var(--zk-white); + transition: border-color var(--t-fast), box-shadow var(--t-fast); +} + +/* Amber only when something is actually waiting. A permanent alert colour on + an empty queue teaches people to stop seeing it. */ +.appr.is-live { + border-color: var(--zk-amber-line); + background: var(--zk-amber-tint); +} +.appr:hover { box-shadow: var(--sh-sm); border-color: var(--zk-blue-light); } +.appr:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; } + +.appr__n { + flex: none; + min-width: 46px; + font-size: 1.9rem; + font-weight: 300; + line-height: 1; + letter-spacing: -0.02em; + color: var(--zk-grey); + font-variant-numeric: tabular-nums; +} +.appr.is-live .appr__n { color: var(--zk-amber-ink); } + +.appr__t { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + flex: 1; + font-size: 0.95rem; + font-weight: 500; + color: var(--zk-ink); +} +.appr__t em { + font-style: normal; + font-size: 0.78rem; + font-weight: 400; + line-height: 1.45; + color: var(--zk-muted); +} + +.appr__go { + flex: none; + font-size: 0.76rem; + color: var(--zk-muted); + white-space: nowrap; +}