feat(d25): bright doc type/status colours + sectioned client details + username UI

- Badge extended with green/blue/violet/gold/red tones (light+dark). Document
  TYPE badges: Quotation=gold, Proforma=violet, Invoice=teal, Credit note=red,
  Receipt=green (Documents, DocumentView, Client 360). STATUS brightened:
  paid/accepted=green, sent=blue, part-paid=amber, draft=grey, lost/cancelled=red.
- Client 360: new sectioned Details card (identity, full address, contacts
  labeled Office/Secretary/WhatsApp with mailto:/tel: links).
- Employees create form gains a required Username (login id) field + table
  column; Login field relabeled 'Username'.
typecheck + web build clean, web tests 4/4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 921fd7e6d1
commit 9b920e50f9

@ -40,7 +40,7 @@ export function Login() {
{/* A real <form> so Enter submits from either field (not just password). */} {/* A real <form> so Enter submits from either field (not just password). */}
<form className="login-card" onSubmit={(e) => { e.preventDefault(); void submit() }}> <form className="login-card" onSubmit={(e) => { e.preventDefault(); void submit() }}>
<h2 style={{ marginTop: 0 }}>Sign in</h2> <h2 style={{ marginTop: 0 }}>Sign in</h2>
<Field label="Email or username"> <Field label="Username">
<input <input
className="wf" type="text" autoComplete="username" value={email} autoFocus className="wf" type="text" autoComplete="username" value={email} autoFocus
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}

@ -450,7 +450,7 @@ export type EmployeeRole = 'owner' | 'manager' | 'staff'
export const EMPLOYEE_ROLES: EmployeeRole[] = ['owner', 'manager', 'staff'] export const EMPLOYEE_ROLES: EmployeeRole[] = ['owner', 'manager', 'staff']
export interface Employee { export interface Employee {
id: string; email: string; displayName: string; role: EmployeeRole; active: boolean id: string; email: string; username: string; displayName: string; role: EmployeeRole; active: boolean
phone: string | null; title: string | null phone: string | null; title: string | null
} }
@ -458,7 +458,7 @@ export interface Employee {
export const getEmployees = (): Promise<{ employees: Employee[]; total: number }> => export const getEmployees = (): Promise<{ employees: Employee[]; total: number }> =>
apiFetch<{ employees: Employee[]; total: number }>('/employees') apiFetch<{ employees: Employee[]; total: number }>('/employees')
export const createEmployee = ( export const createEmployee = (
body: { email: string; displayName: string; role: EmployeeRole; password: string }, body: { username: string; email: string; displayName: string; role: EmployeeRole; password: string },
): Promise<Employee> => ): Promise<Employee> =>
apiFetch<{ employee: Employee }>('/employees', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.employee) apiFetch<{ employee: Employee }>('/employees', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.employee)
/** Email is the login id — immutable; name, role, phone and title may change. */ /** Email is the login id — immutable; name, role, phone and title may change. */

@ -1,4 +1,4 @@
import { Fragment, useState } from 'react' import { Fragment, useState, type CSSProperties, type ReactNode } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain' import { formatINR } from '@sims/domain'
import { import {
@ -20,7 +20,7 @@ import {
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule, type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
type ClientStatus, type FieldDef, type Interaction, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail, type ClientStatus, type FieldDef, type Interaction, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail,
} from '../api' } from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients' import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
import { import {
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE, NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE,
} from './Tickets' } from './Tickets'
@ -215,6 +215,7 @@ export function ClientDetail() {
<StatCard label="Open documents" value={String(openDocs.length)} hint={`${docs.length} total`} /> <StatCard label="Open documents" value={String(openDocs.length)} hint={`${docs.length} total`} />
<StatCard label="Last interaction" value={lastInteraction ?? '—'} /> <StatCard label="Last interaction" value={lastInteraction ?? '—'} />
</Stats> </Stats>
<ClientDetailsCard client={c} />
<PulseRibbon events={pulseEvents} todayIso={todayIso} onOpen={openPulse} /> <PulseRibbon events={pulseEvents} todayIso={todayIso} onOpen={openPulse} />
<h3 style={{ marginTop: 18 }}>Recent documents</h3> <h3 style={{ marginTop: 18 }}>Recent documents</h3>
{docs.length === 0 ? <EmptyState>No documents yet.</EmptyState> : ( {docs.length === 0 ? <EmptyState>No documents yet.</EmptyState> : (
@ -226,7 +227,9 @@ export function ClientDetail() {
]} ]}
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)} onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
rows={docs.slice(0, 5).map((d) => ({ rows={docs.slice(0, 5).map((d) => ({
no: d.docNo ?? 'draft', type: d.docType, date: d.docDate, no: d.docNo ?? 'draft',
type: <Badge tone={DOC_TYPE_TONE[d.docType]}>{DOC_TYPE_LABEL[d.docType]}</Badge>,
date: d.docDate,
payable: inr(d.payablePaise), payable: inr(d.payablePaise),
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>, status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
}))} }))}
@ -363,7 +366,8 @@ export function ClientDetail() {
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)} onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
rows={docs.map((d) => ({ rows={docs.map((d) => ({
no: d.docNo ?? <Badge tone="warn">draft</Badge>, no: d.docNo ?? <Badge tone="warn">draft</Badge>,
type: d.docType, date: d.docDate, type: <Badge tone={DOC_TYPE_TONE[d.docType]}>{DOC_TYPE_LABEL[d.docType]}</Badge>,
date: d.docDate,
payable: inr(d.payablePaise), payable: inr(d.payablePaise),
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>, status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
}))} }))}
@ -583,6 +587,71 @@ export function ClientDetail() {
) )
} }
/** Contact-role display label (spec §5 typed contacts). Office is the default for
* a blank/'office'/'primary' role; a custom role shows verbatim. */
function contactRoleLabel(role: string | undefined): string {
if (role === 'secretary') return 'Secretary'
if (role === 'whatsapp') return 'WhatsApp'
if (role === undefined || role === '' || role === 'office' || role === 'primary') return 'Office'
return role
}
/**
* Client core-details card (Overview tab): identity, full address and a sectioned,
* role-labelled contact list each with mailto:/tel: links so the whole record
* reads at a glance instead of only contacts[0] inline in the header. Reuses the
* wf-card surface and the file's micro-label styling; no dedicated CSS.
*/
function ClientDetailsCard(props: { client: Client }) {
const c = props.client
const micro: CSSProperties = { fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }
const field = (label: string, value: ReactNode, mono = false) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 130 }}>
<span style={micro}>{label}</span>
<span className={mono ? 'mono' : undefined}>{value}</span>
</div>
)
return (
<div className="wf-card">
<h3 style={{ margin: '0 0 12px' }}>Details</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '14px 28px' }}>
{field('Name', c.name)}
{field('Code', c.code, true)}
{c.gstin !== undefined && c.gstin !== '' && field('GSTIN', c.gstin, true)}
{field('Status', <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>)}
{field('State', c.stateCode)}
</div>
{c.address !== '' && (
<div style={{ marginTop: 14 }}>
<span style={micro}>Address</span>
<div style={{ marginTop: 3, whiteSpace: 'pre-wrap' }}>{c.address}</div>
</div>
)}
{c.contacts.length > 0 && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '14px 0 12px' }} />
<span style={micro}>Contacts</span>
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
{c.contacts.map((ct, i) => (
<div key={i} style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: '4px 14px' }}>
<span style={{ ...micro, minWidth: 84 }}>{contactRoleLabel(ct.role)}</span>
{ct.name !== undefined && ct.name !== '' && <span style={{ fontWeight: 500 }}>{ct.name}</span>}
{ct.email !== undefined && ct.email !== '' && <a href={`mailto:${ct.email}`}>{ct.email}</a>}
{ct.phone !== undefined && ct.phone !== '' && <a href={`tel:${ct.phone}`} className="mono">{ct.phone}</a>}
{(ct.name === undefined || ct.name === '') && (ct.email === undefined || ct.email === '')
&& (ct.phone === undefined || ct.phone === '') && <span style={{ opacity: 0.6 }}></span>}
</div>
))}
</div>
</>
)}
</div>
)
}
/** /**
* Support-access card (D18 WS-F): the fields the old APEX book carried on every * Support-access card (D18 WS-F): the fields the old APEX book carried on every
* client row AnyDesk id (44× referenced there), OS, district/sector plus the * client row AnyDesk id (44× referenced there), OS, district/sector plus the

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, PageHeader, Skeleton, Toolbar } from '@sims/ui' import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, PageHeader, Skeleton, Toolbar, type BadgeTone } from '@sims/ui'
import { getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus } from '../api' import { getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus, type DocType } from '../api'
import { ClientFormDialog } from '../components/ClientFormDialog' import { ClientFormDialog } from '../components/ClientFormDialog'
/** /**
@ -58,9 +58,27 @@ export const CLIENT_TONE: Record<ClientStatus, Tone> = {
lead: 'accent', active: 'ok', dormant: 'warn', lost: 'err', lead: 'accent', active: 'ok', dormant: 'warn', lost: 'err',
} }
export const DOC_TONE: Record<DocStatus, Tone> = { /**
draft: 'warn', sent: 'accent', accepted: 'ok', invoiced: 'accent', * Document STATUS badge tone. Brightened for at-a-glance state: paid/accepted green,
part_paid: 'warn', paid: 'ok', lost: 'err', cancelled: 'err', * part_paid amber (warn), sent blue, invoiced teal (accent), draft muted grey (undefined),
* lost bright red, cancelled muted red (err). `undefined` renders the default muted pill.
*/
export const DOC_TONE: Record<DocStatus, BadgeTone | undefined> = {
draft: undefined, sent: 'blue', accepted: 'green', invoiced: 'accent',
part_paid: 'warn', paid: 'green', lost: 'red', cancelled: 'err',
}
/**
* Document TYPE badge tone. Bright and mutually distinct so the type reads instantly:
* quotation gold, proforma violet, invoice teal (brand accent), credit note red, receipt green.
*/
export const DOC_TYPE_TONE: Record<DocType, BadgeTone> = {
QUOTATION: 'gold', PROFORMA: 'violet', INVOICE: 'accent', CREDIT_NOTE: 'red', RECEIPT: 'green',
}
/** Friendly one-word-ish label for a document type (shared by the register, 360° and doc view). */
export const DOC_TYPE_LABEL: Record<DocType, string> = {
QUOTATION: 'Quotation', PROFORMA: 'Proforma', INVOICE: 'Invoice', CREDIT_NOTE: 'Credit note', RECEIPT: 'Receipt',
} }
/** Client registry — search, list, inline create; row click opens the Client 360°. */ /** Client registry — search, list, inline create; row click opens the Client 360°. */

@ -10,7 +10,7 @@ import {
revokeShare, type Client, type Doc, type Share, revokeShare, type Client, type Doc, type Share,
} from '../api' } from '../api'
import { SendDialog } from '../components/SendDialog' import { SendDialog } from '../components/SendDialog'
import { DOC_TONE, useData } from './Clients' import { DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
import { PaymentForm } from './client-forms' import { PaymentForm } from './client-forms'
/** One pending confirmation — replaces the browser confirm() popup with the styled dialog. */ /** One pending confirmation — replaces the browser confirm() popup with the styled dialog. */
@ -99,7 +99,12 @@ export function DocumentView() {
<RecordHeader <RecordHeader
name={TITLE[doc.docType]} name={TITLE[doc.docType]}
avatarSeed={client.data?.name ?? doc.clientId} avatarSeed={client.data?.name ?? doc.clientId}
status={<Badge tone={DOC_TONE[doc.status]}>{doc.status}</Badge>} status={
<>
<Badge tone={DOC_TYPE_TONE[doc.docType]}>{DOC_TYPE_LABEL[doc.docType]}</Badge>
<Badge tone={DOC_TONE[doc.status]}>{doc.status}</Badge>
</>
}
meta={ meta={
<> <>
<span className="mono">{doc.docNo ?? '(draft)'}</span> <span className="mono">{doc.docNo ?? '(draft)'}</span>

@ -7,7 +7,7 @@ import {
} from '@sims/ui' } from '@sims/ui'
import { documentAction, getDocuments, DOC_STATUSES, type DocStatus, type DocType } from '../api' import { documentAction, getDocuments, DOC_STATUSES, type DocStatus, type DocType } from '../api'
import { Pager } from '../components/Pager' import { Pager } from '../components/Pager'
import { useData, DOC_TONE } from './Clients' import { useData, DOC_TONE, DOC_TYPE_TONE } from './Clients'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
@ -94,7 +94,7 @@ export function Documents() {
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)} onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
rows={docs.map((d) => ({ rows={docs.map((d) => ({
no: d.docNo ?? '— draft —', no: d.docNo ?? '— draft —',
type: TYPE_LABEL[d.docType], type: <Badge tone={DOC_TYPE_TONE[d.docType]}>{TYPE_LABEL[d.docType]}</Badge>,
client: d.clientName, client: d.clientName,
date: d.docDate, date: d.docDate,
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>, status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,

@ -17,9 +17,9 @@ type DialogState =
/** /**
* Employee management (owner-only page; the nav item is hidden for others and the * Employee management (owner-only page; the nav item is hidden for others and the
* server 403s every mutation anyway). Standard table per spec §6a: name, email, * server 403s every mutation anyway). Standard table per spec §6a: name, username,
* role, active, actions plus add / edit / reset-password dialogs. Email is the * email, role, active, actions plus add / edit / reset-password dialogs. Username
* login id and is immutable once created. * is the login id; email is a separate contact field. Both are set at create time.
*/ */
export function Employees() { export function Employees() {
const list = useData(getEmployees, []) const list = useData(getEmployees, [])
@ -54,13 +54,14 @@ export function Employees() {
<> <>
<DataTable <DataTable
columns={[ columns={[
{ key: 'name', label: 'Name' }, { key: 'email', label: 'Email', mono: true }, { key: 'name', label: 'Name' }, { key: 'username', label: 'Username', mono: true },
{ key: 'email', label: 'Email', mono: true },
{ key: 'phone', label: 'Phone', mono: true }, { key: 'title', label: 'Title' }, { key: 'phone', label: 'Phone', mono: true }, { key: 'title', label: 'Title' },
{ key: 'role', label: 'Role' }, { key: 'active', label: 'Active' }, { key: 'role', label: 'Role' }, { key: 'active', label: 'Active' },
{ key: 'actions', label: 'Actions' }, { key: 'actions', label: 'Actions' },
]} ]}
rows={employees.map((e) => ({ rows={employees.map((e) => ({
name: e.displayName, email: e.email, name: e.displayName, username: e.username, email: e.email,
phone: e.phone ?? '—', title: e.title ?? '—', phone: e.phone ?? '—', title: e.title ?? '—',
role: <Badge tone={e.role === 'owner' ? 'accent' : undefined}>{e.role}</Badge>, role: <Badge tone={e.role === 'owner' ? 'accent' : undefined}>{e.role}</Badge>,
active: e.active ? <Badge tone="ok">active</Badge> : <Badge tone="err">inactive</Badge>, active: e.active ? <Badge tone="ok">active</Badge> : <Badge tone="err">inactive</Badge>,
@ -112,7 +113,7 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
const kind = props.state?.kind const kind = props.state?.kind
const employee = props.state?.kind === 'edit' || props.state?.kind === 'reset' ? props.state.employee : undefined const employee = props.state?.kind === 'edit' || props.state?.kind === 'reset' ? props.state.employee : undefined
const [addF, setAddF] = useState({ email: '', displayName: '', role: 'staff' as EmployeeRole, password: '' }) const [addF, setAddF] = useState({ username: '', email: '', displayName: '', role: 'staff' as EmployeeRole, password: '' })
const [editF, setEditF] = useState({ displayName: '', role: 'staff' as EmployeeRole, phone: '', title: '' }) const [editF, setEditF] = useState({ displayName: '', role: 'staff' as EmployeeRole, phone: '', title: '' })
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
@ -121,7 +122,7 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setError(undefined) setError(undefined)
setAddF({ email: '', displayName: '', role: 'staff', password: '' }) setAddF({ username: '', email: '', displayName: '', role: 'staff', password: '' })
setEditF({ setEditF({
displayName: employee?.displayName ?? '', role: employee?.role ?? 'staff', displayName: employee?.displayName ?? '', role: employee?.role ?? 'staff',
phone: employee?.phone ?? '', title: employee?.title ?? '', phone: employee?.phone ?? '', title: employee?.title ?? '',
@ -134,11 +135,11 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
const save = () => { const save = () => {
if (saving) return if (saving) return
if (kind === 'add') { if (kind === 'add') {
if (addF.email.trim() === '' || addF.displayName.trim() === '') { setError('Email and name are required'); return } if (addF.username.trim() === '' || addF.email.trim() === '' || addF.displayName.trim() === '') { setError('Username, email and name are required'); return }
if (addF.password.length < 8) { setError('Initial password must be at least 8 characters'); return } if (addF.password.length < 8) { setError('Initial password must be at least 8 characters'); return }
setError(undefined) setError(undefined)
setSaving(true) setSaving(true)
createEmployee({ email: addF.email.trim(), displayName: addF.displayName.trim(), role: addF.role, password: addF.password }) createEmployee({ username: addF.username.trim(), email: addF.email.trim(), displayName: addF.displayName.trim(), role: addF.role, password: addF.password })
.then(() => { toast.ok('Employee created'); props.onDone(); props.onClose() }) .then(() => { toast.ok('Employee created'); props.onDone(); props.onClose() })
.catch((e: Error) => setError(e.message)) .catch((e: Error) => setError(e.message))
.finally(() => setSaving(false)) .finally(() => setSaving(false))
@ -184,8 +185,11 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
> >
{kind === 'add' && ( {kind === 'add' && (
<FormGrid> <FormGrid>
<FormField label="Email (login id)"> <FormField label="Username (login id)">
<input className="wf" type="email" autoFocus value={addF.email} onChange={(e) => setAddF((p) => ({ ...p, email: e.target.value }))} /> <input className="wf" autoFocus autoComplete="off" value={addF.username} onChange={(e) => setAddF((p) => ({ ...p, username: e.target.value }))} />
</FormField>
<FormField label="Email">
<input className="wf" type="email" value={addF.email} onChange={(e) => setAddF((p) => ({ ...p, email: e.target.value }))} />
</FormField> </FormField>
<FormField label="Name"> <FormField label="Name">
<input className="wf" value={addF.displayName} onChange={(e) => setAddF((p) => ({ ...p, displayName: e.target.value }))} /> <input className="wf" value={addF.displayName} onChange={(e) => setAddF((p) => ({ ...p, displayName: e.target.value }))} />

@ -58,7 +58,17 @@ export function Button(props: {
) )
} }
export function Badge(props: { children: ReactNode; tone?: 'ok' | 'warn' | 'err' | 'accent' }) { /**
* Badge tones. `ok`/`warn`/`err`/`accent` are the state tones (accent follows the
* chosen accent colour); `green`/`blue`/`violet`/`gold`/`red` are fixed, bright,
* self-standing hues so a document TYPE or STATUS keeps its identity regardless of
* the configured accent. Each maps to a `.wf-badge-*` `.wf-badge.<tone>` class.
*/
export type BadgeTone =
| 'ok' | 'warn' | 'err' | 'accent'
| 'green' | 'blue' | 'violet' | 'gold' | 'red'
export function Badge(props: { children: ReactNode; tone?: BadgeTone }) {
return <span className={`wf-badge${props.tone !== undefined ? ` ${props.tone}` : ''}`}>{props.children}</span> return <span className={`wf-badge${props.tone !== undefined ? ` ${props.tone}` : ''}`}>{props.children}</span>
} }

@ -201,6 +201,23 @@ table.wf tbody tr.tone-ok:hover td { filter: brightness(0.97); }
.wf-badge.accent { color: var(--accent-strong); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, transparent); } .wf-badge.accent { color: var(--accent-strong); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
[data-theme='dark'] .wf-badge.accent { color: var(--accent); } [data-theme='dark'] .wf-badge.accent { color: var(--accent); }
/* Bright, self-standing badge hues used to colour document TYPE and STATUS so each
reads at a glance (quotation gold, proforma violet, invoice teal/accent, credit note
red, receipt green; paid/accepted green, sent blue, lost red). Solid-tint pills:
coloured ink on a soft wash of the same hue, hue-matched border. Fixed values (not
the configurable --accent) so a type/status keeps its identity in any accent, and
tuned for contrast in both light and dark themes. */
.wf-badge.green { color: #047857; background: #e2f4eb; border-color: color-mix(in srgb, #047857 28%, transparent); }
.wf-badge.blue { color: #1d63d0; background: #e4eefc; border-color: color-mix(in srgb, #1d63d0 28%, transparent); }
.wf-badge.violet { color: #6f42c1; background: #efe8fb; border-color: color-mix(in srgb, #6f42c1 28%, transparent); }
.wf-badge.gold { color: #8a5a00; background: #fbeecb; border-color: color-mix(in srgb, #8a5a00 28%, transparent); }
.wf-badge.red { color: #c62828; background: #fce4e4; border-color: color-mix(in srgb, #c62828 28%, transparent); }
[data-theme='dark'] .wf-badge.green { color: #43c288; background: color-mix(in srgb, #43c288 16%, var(--bg-raised)); border-color: color-mix(in srgb, #43c288 34%, transparent); }
[data-theme='dark'] .wf-badge.blue { color: #66a3f2; background: color-mix(in srgb, #66a3f2 16%, var(--bg-raised)); border-color: color-mix(in srgb, #66a3f2 34%, transparent); }
[data-theme='dark'] .wf-badge.violet { color: #a97ff0; background: color-mix(in srgb, #a97ff0 16%, var(--bg-raised)); border-color: color-mix(in srgb, #a97ff0 34%, transparent); }
[data-theme='dark'] .wf-badge.gold { color: #d9a247; background: color-mix(in srgb, #d9a247 16%, var(--bg-raised)); border-color: color-mix(in srgb, #d9a247 34%, transparent); }
[data-theme='dark'] .wf-badge.red { color: #ef6a63; background: color-mix(in srgb, #ef6a63 16%, var(--bg-raised)); border-color: color-mix(in srgb, #ef6a63 34%, transparent); }
/* ================= stat cards ================= */ /* ================= stat cards ================= */
.wf-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 14px; margin: 16px 0; } .wf-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 14px; margin: 16px 0; }
.wf-stat { .wf-stat {

Loading…
Cancel
Save