feat: the claims tile counts the lakehouse rows the link opens; live trace fills the screen
Claims denied showed Ava's affected_claims (225) while "See the claims" opened 237 rows — her quantify query and the link's filters are two different queries. The tile now asks the record view for the link's own filter set (limit 1, pagination.total_count) and shows that; her figure appears underneath only when it disagrees. The URL→filter mapping moves to lib/claimsLink so the table and the tile share one definition. While a review runs the trace is the page: the layout widens to the screen, the list takes the viewport height, follows the newest step unless the viewer scrolled up, and shows a working row with a spinner and an indeterminate strip under the header. Reduced motion honoured. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
fc1e9dc27c
commit
e775fb0e88
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Brain,
|
Brain,
|
||||||
@ -70,6 +70,7 @@ export function AvaLive({
|
|||||||
steps,
|
steps,
|
||||||
decisions,
|
decisions,
|
||||||
working,
|
working,
|
||||||
|
fill = false,
|
||||||
}: {
|
}: {
|
||||||
steps: AvaTraceStep[];
|
steps: AvaTraceStep[];
|
||||||
decisions: AvaDecision[];
|
decisions: AvaDecision[];
|
||||||
@ -77,8 +78,27 @@ export function AvaLive({
|
|||||||
* the "still working" footer, so an empty trace reads as "starting" rather
|
* the "still working" footer, so an empty trace reads as "starting" rather
|
||||||
* than "nothing happened". */
|
* than "nothing happened". */
|
||||||
working: boolean;
|
working: boolean;
|
||||||
|
/** While the review runs the trace is the page: let the list take the
|
||||||
|
* viewport instead of a fixed 32rem box. */
|
||||||
|
fill?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState<number | null>(null);
|
const [open, setOpen] = useState<number | null>(null);
|
||||||
|
// Follow the run: when a new step lands while she is working, keep the
|
||||||
|
// newest step in view — unless the viewer has scrolled up to read.
|
||||||
|
const listRef = useRef<HTMLOListElement | null>(null);
|
||||||
|
const stuck = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el || !working || !stuck.current) return;
|
||||||
|
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||||
|
}, [steps.length, working]);
|
||||||
|
function onScroll() {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
stuck.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
||||||
|
}
|
||||||
|
const last = steps[steps.length - 1];
|
||||||
|
const sinceLast = last ? Math.max(0, (Date.now() - new Date(last.ended_at ?? last.started_at).getTime()) / 1000) : 0;
|
||||||
|
|
||||||
const queries = steps.filter((s) => s.type === 'api');
|
const queries = steps.filter((s) => s.type === 'api');
|
||||||
const failed = queries.filter((s) => s.query_state === 'FAILED').length;
|
const failed = queries.filter((s) => s.query_state === 'FAILED').length;
|
||||||
@ -112,6 +132,7 @@ export function AvaLive({
|
|||||||
{spend > 0 && <Badge tone="slate">{money(spend.toFixed(2))}</Badge>}
|
{spend > 0 && <Badge tone="slate">{money(spend.toFixed(2))}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{working && <div className="progress-strip" aria-hidden="true" />}
|
||||||
|
|
||||||
{steps.length === 0 ? (
|
{steps.length === 0 ? (
|
||||||
<p className="flex items-center gap-2 px-4 py-8 text-sm text-faint">
|
<p className="flex items-center gap-2 px-4 py-8 text-sm text-faint">
|
||||||
@ -121,7 +142,11 @@ export function AvaLive({
|
|||||||
: 'No steps recorded for this case.'}
|
: 'No steps recorded for this case.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="max-h-[32rem] overflow-y-auto px-4">
|
<ol
|
||||||
|
ref={listRef}
|
||||||
|
onScroll={onScroll}
|
||||||
|
className={`overflow-y-auto px-4 ${fill ? 'max-h-[calc(100vh-21rem)] min-h-[24rem]' : 'max-h-[32rem]'}`}
|
||||||
|
>
|
||||||
{steps.map((s, i) => {
|
{steps.map((s, i) => {
|
||||||
const m = meta(s.type);
|
const m = meta(s.type);
|
||||||
const Icon = m.icon;
|
const Icon = m.icon;
|
||||||
@ -196,6 +221,24 @@ export function AvaLive({
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{working && (
|
||||||
|
<li className="flex items-center gap-3 py-3">
|
||||||
|
<span className="flex shrink-0 flex-col items-center gap-1 pt-0.5">
|
||||||
|
<span className="rounded-full bg-ai-soft p-1.5 text-ai ring-1 ring-ai-edge ring-inset">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
</span>
|
||||||
|
<span className="tnum text-[10px] text-faint">{steps.length + 1}</span>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="text-[13px] font-semibold text-ai">Working on the next step</span>
|
||||||
|
<span className="mt-1 block text-xs text-muted">
|
||||||
|
{last?.type === 'api' ? 'Reading the result and deciding what to query next' : 'Running'}
|
||||||
|
{sinceLast >= 5 ? ` · ${Math.round(sinceLast)}s so far` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="working-dots" aria-hidden="true"><i /><i /><i /></span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,8 @@ import { Link } from 'react-router-dom';
|
|||||||
import { AlertTriangle, CheckCircle2, ChevronDown, ExternalLink, FileSearch } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, ChevronDown, ExternalLink, FileSearch } from 'lucide-react';
|
||||||
import type { Slice } from '../api/charts';
|
import type { Slice } from '../api/charts';
|
||||||
import type { ReviewedFacts } from '../format';
|
import type { ReviewedFacts } from '../format';
|
||||||
import { affectedClaimsHref, recoverableClaimsHref } from '../lib/claimsLink';
|
import { affectedClaimsHref, filtersFromHref, recoverableClaimsHref } from '../lib/claimsLink';
|
||||||
|
import { useQuery, useZino } from '../api/provider';
|
||||||
import type { LinkDates } from '../lib/claimsLink';
|
import type { LinkDates } from '../lib/claimsLink';
|
||||||
import { Badge, Card, Facts } from './core';
|
import { Badge, Card, Facts } from './core';
|
||||||
import type { AvaDecision, AvaTraceStep, Row } from '../api/types';
|
import type { AvaDecision, AvaTraceStep, Row } from '../api/types';
|
||||||
@ -39,14 +40,27 @@ export function Verdict({
|
|||||||
dataSpan?: { from: string; to: string } | null;
|
dataSpan?: { from: string; to: string } | null;
|
||||||
}) {
|
}) {
|
||||||
const summary = str(row.summary);
|
const summary = str(row.summary);
|
||||||
|
const affectedHref = affectedClaimsHref(kind, slice, dates);
|
||||||
|
const recoverableHref = recoverableClaimsHref(kind, slice, dates);
|
||||||
|
// THE COUNT COMES FROM THE DATA, NOT FROM AVA. She writes affected_claims
|
||||||
|
// into her decision; the lakehouse is asked for the exact rows the "See the
|
||||||
|
// claims" link opens, and that total is the number on the tile. Her figure
|
||||||
|
// is shown underneath only when it disagrees, so a discrepancy is visible
|
||||||
|
// rather than silently replaced.
|
||||||
|
const live = useLiveCount(affectedHref);
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
const line = prose(headline(summary));
|
const line = prose(headline(summary));
|
||||||
const scanned = str(row.scanned_summary);
|
const scanned = str(row.scanned_summary);
|
||||||
const affectedHref = affectedClaimsHref(kind, slice, dates);
|
|
||||||
const recoverableHref = recoverableClaimsHref(kind, slice, dates);
|
|
||||||
const atRisk = num(row.amount_at_risk);
|
const atRisk = num(row.amount_at_risk);
|
||||||
const recoverable = num(row.recoverable_amount);
|
const recoverable = num(row.recoverable_amount);
|
||||||
const affected = num(row.affected_claims);
|
const reported = num(row.affected_claims);
|
||||||
|
const affected = live.count ?? reported;
|
||||||
|
const countNote =
|
||||||
|
live.count != null && reported != null && live.count !== reported
|
||||||
|
? `AI analysis reported ${reported.toLocaleString('en-US')}; the lakehouse count is shown`
|
||||||
|
: live.count != null
|
||||||
|
? 'counted from the lakehouse'
|
||||||
|
: undefined;
|
||||||
const deadline = firstDeadline(row.recommendations, dates.anchor);
|
const deadline = firstDeadline(row.recommendations, dates.anchor);
|
||||||
const confidence = num(row.confidence);
|
const confidence = num(row.confidence);
|
||||||
const reconciled = row.data_reconciled === true || str(row.data_reconciled) === 'true';
|
const reconciled = row.data_reconciled === true || str(row.data_reconciled) === 'true';
|
||||||
@ -74,8 +88,9 @@ export function Verdict({
|
|||||||
<div className="grid gap-px bg-line sm:grid-cols-3">
|
<div className="grid gap-px bg-line sm:grid-cols-3">
|
||||||
<Big
|
<Big
|
||||||
label={kind === 'slow_payer' ? 'Claims unanswered' : kind === 'underpayment' ? 'Claims short-paid' : 'Claims denied'}
|
label={kind === 'slow_payer' ? 'Claims unanswered' : kind === 'underpayment' ? 'Claims short-paid' : 'Claims denied'}
|
||||||
value={affected == null ? '—' : affected.toLocaleString('en-US')}
|
value={affected == null ? '—' : `${affected.toLocaleString('en-US')}${live.capped ? '+' : ''}`}
|
||||||
href={affectedHref}
|
href={affectedHref}
|
||||||
|
note={countNote}
|
||||||
/>
|
/>
|
||||||
<Big label="Money at stake" value={money(atRisk)} tone="bad" />
|
<Big label="Money at stake" value={money(atRisk)} tone="bad" />
|
||||||
<Big
|
<Big
|
||||||
@ -399,6 +414,21 @@ export function Recheck({ row }: { row: Row }) {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The lakehouse's own count of the rows behind a claims link: one record-view
|
||||||
|
* call with the link's filters and limit 1, reading pagination.total_count.
|
||||||
|
* No link → nothing asked. */
|
||||||
|
function useLiveCount(href: string | null): { count: number | null; capped: boolean } {
|
||||||
|
const { client } = useZino();
|
||||||
|
const q = useQuery(
|
||||||
|
() => client.recordView('dni-rv-claims', { page: 1, limit: 1, filters: filtersFromHref(href ?? '') }),
|
||||||
|
[href],
|
||||||
|
!!href,
|
||||||
|
);
|
||||||
|
const pg = q.data?.pagination;
|
||||||
|
const count = pg?.total_count ?? (q.data?.total as number | undefined) ?? null;
|
||||||
|
return { count: typeof count === 'number' ? count : null, capped: !!pg?.total_is_capped };
|
||||||
|
}
|
||||||
|
|
||||||
function Big({ label, value, tone, note, href }: { label: string; value: string; tone?: 'good' | 'bad'; note?: string; href?: string | null }) {
|
function Big({ label, value, tone, note, href }: { label: string; value: string; tone?: 'good' | 'bad'; note?: string; href?: string | null }) {
|
||||||
const text = tone === 'good' ? 'text-good' : tone === 'bad' ? 'text-bad' : 'text-ink';
|
const text = tone === 'good' ? 'text-good' : tone === 'bad' ? 'text-bad' : 'text-ink';
|
||||||
const body = (
|
const body = (
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Slice } from '../api/charts';
|
import type { Slice } from '../api/charts';
|
||||||
|
import type { RecordFilter } from '../api/types';
|
||||||
import { addDays, isDenialKind } from '../api/charts';
|
import { addDays, isDenialKind } from '../api/charts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,6 +31,47 @@ export type ClaimsQuery = Partial<Record<
|
|||||||
string
|
string
|
||||||
>>;
|
>>;
|
||||||
|
|
||||||
|
/** URL param → record-view column. The claims table and the finding tiles
|
||||||
|
* both read this, so "the rows the link opens" and "the number on the tile"
|
||||||
|
* are the same query by construction. */
|
||||||
|
export const CLAIM_DIMS: Array<{ key: string; col: string; label: string }> = [
|
||||||
|
{ key: 'payer', col: 'payer', label: 'Insurer' },
|
||||||
|
{ key: 'department', col: 'department', label: 'Department' },
|
||||||
|
{ key: 'status', col: 'status', label: 'Status' },
|
||||||
|
{ key: 'code', col: 'denial_reason_code', label: 'Reason code' },
|
||||||
|
{ key: 'appeal', col: 'appeal_status', label: 'Appeal' },
|
||||||
|
{ key: 'site', col: 'site', label: 'Site' },
|
||||||
|
{ key: 'desk', col: 'registration_desk', label: 'Desk' },
|
||||||
|
{ key: 'provider', col: 'provider', label: 'Provider' },
|
||||||
|
{ key: 'cpt', col: 'procedure_code', label: 'CPT' },
|
||||||
|
{ key: 'category', col: 'denial_category', label: 'Denial category' },
|
||||||
|
];
|
||||||
|
export const CLAIM_RANGES: Array<{ from: string; to: string; col: string; label: string }> = [
|
||||||
|
{ from: 'from', to: 'to', col: 'service_date', label: 'Service date' },
|
||||||
|
{ from: 'sub_from', to: 'sub_to', col: 'submitted_date', label: 'Submitted' },
|
||||||
|
{ from: 'dl_from', to: 'dl_to', col: 'appeal_deadline', label: 'Appeal deadline' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The record-view filters a /claims URL (or its query string) stands for. */
|
||||||
|
export function filtersFromParams(params: URLSearchParams): RecordFilter[] {
|
||||||
|
const f: RecordFilter[] = [];
|
||||||
|
for (const d of CLAIM_DIMS) {
|
||||||
|
const v = params.get(d.key);
|
||||||
|
if (v) f.push({ field_key: d.col, value: v, data_type: 'string' });
|
||||||
|
}
|
||||||
|
for (const r of CLAIM_RANGES) {
|
||||||
|
const a = params.get(r.from) ?? undefined;
|
||||||
|
const b = params.get(r.to) ?? undefined;
|
||||||
|
if (a || b) f.push({ field_key: r.col, data_type: 'date', range: { from: a, to: b } });
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filtersFromHref(href: string): RecordFilter[] {
|
||||||
|
const qs = href.includes('?') ? href.slice(href.indexOf('?') + 1) : '';
|
||||||
|
return filtersFromParams(new URLSearchParams(qs));
|
||||||
|
}
|
||||||
|
|
||||||
export function claimsHref(q: ClaimsQuery): string {
|
export function claimsHref(q: ClaimsQuery): string {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
for (const [k, v] of Object.entries(q)) if (v) p.set(k, v);
|
for (const [k, v] of Object.entries(q)) if (v) p.set(k, v);
|
||||||
|
|||||||
@ -116,10 +116,14 @@ export function CaseScreen() {
|
|||||||
if (cases.error) return <ErrorNote>{cases.error}</ErrorNote>;
|
if (cases.error) return <ErrorNote>{cases.error}</ErrorNote>;
|
||||||
if (!row) return <ErrorNote>Case {id} not found.</ErrorNote>;
|
if (!row) return <ErrorNote>Case {id} not found.</ErrorNote>;
|
||||||
|
|
||||||
const work = <AvaLive steps={trace.data ?? []} decisions={decisions.data ?? []} working={aiOwned} />;
|
const running = aiOwned && !hasFindings;
|
||||||
|
const work = <AvaLive steps={trace.data ?? []} decisions={decisions.data ?? []} working={aiOwned} fill={running} />;
|
||||||
|
|
||||||
|
// While the review runs there is nothing else on the page, so the trace is
|
||||||
|
// given the width and height of the screen; once findings exist the page
|
||||||
|
// narrows back to reading width.
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-4">
|
<div className={`mx-auto space-y-4 ${running ? 'max-w-7xl' : 'max-w-5xl'}`}>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Link to="/q/review" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
|
<Link to="/q/review" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
|
||||||
<ArrowLeft className="h-4 w-4" /> Back
|
<ArrowLeft className="h-4 w-4" /> Back
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import type { Tone } from '../api/config';
|
|||||||
import type { RecordFilter, Row } from '../api/types';
|
import type { RecordFilter, Row } from '../api/types';
|
||||||
import { Badge, ErrorNote, Input, Select, Spinner } from '../components/core';
|
import { Badge, ErrorNote, Input, Select, Spinner } from '../components/core';
|
||||||
import { CARC, money, num, shortDate, str } from '../format';
|
import { CARC, money, num, shortDate, str } from '../format';
|
||||||
|
import { CLAIM_DIMS, CLAIM_RANGES, filtersFromParams } from '../lib/claimsLink';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The claims table — the lakehouse rows themselves, read-only.
|
* The claims table — the lakehouse rows themselves, read-only.
|
||||||
@ -22,24 +23,8 @@ import { CARC, money, num, shortDate, str } from '../format';
|
|||||||
|
|
||||||
const PAGE = 50;
|
const PAGE = 50;
|
||||||
|
|
||||||
/** URL param → record-view column. */
|
const DIMS = CLAIM_DIMS;
|
||||||
const DIMS: Array<{ key: string; col: string; label: string }> = [
|
const RANGES = CLAIM_RANGES;
|
||||||
{ key: 'payer', col: 'payer', label: 'Insurer' },
|
|
||||||
{ key: 'department', col: 'department', label: 'Department' },
|
|
||||||
{ key: 'status', col: 'status', label: 'Status' },
|
|
||||||
{ key: 'code', col: 'denial_reason_code', label: 'Reason code' },
|
|
||||||
{ key: 'appeal', col: 'appeal_status', label: 'Appeal' },
|
|
||||||
{ key: 'site', col: 'site', label: 'Site' },
|
|
||||||
{ key: 'desk', col: 'registration_desk', label: 'Desk' },
|
|
||||||
{ key: 'provider', col: 'provider', label: 'Provider' },
|
|
||||||
{ key: 'cpt', col: 'procedure_code', label: 'CPT' },
|
|
||||||
{ key: 'category', col: 'denial_category', label: 'Denial category' },
|
|
||||||
];
|
|
||||||
const RANGES: Array<{ from: string; to: string; col: string; label: string }> = [
|
|
||||||
{ from: 'from', to: 'to', col: 'service_date', label: 'Service date' },
|
|
||||||
{ from: 'sub_from', to: 'sub_to', col: 'submitted_date', label: 'Submitted' },
|
|
||||||
{ from: 'dl_from', to: 'dl_to', col: 'appeal_deadline', label: 'Appeal deadline' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_TONE: Record<string, Tone> = { paid: 'emerald', partial: 'amber', denied: 'rose', pending: 'slate' };
|
const STATUS_TONE: Record<string, Tone> = { paid: 'emerald', partial: 'amber', denied: 'rose', pending: 'slate' };
|
||||||
const APPEAL_LABEL: Record<string, string> = { not_filed: 'not filed', filed: 'filed', overturned: 'overturned', upheld: 'upheld' };
|
const APPEAL_LABEL: Record<string, string> = { not_filed: 'not filed', filed: 'filed', overturned: 'overturned', upheld: 'upheld' };
|
||||||
@ -53,19 +38,7 @@ export function ClaimsScreen() {
|
|||||||
const [draft, setDraft] = useState(search);
|
const [draft, setDraft] = useState(search);
|
||||||
useEffect(() => setDraft(search), [search]);
|
useEffect(() => setDraft(search), [search]);
|
||||||
|
|
||||||
const filters = useMemo<RecordFilter[]>(() => {
|
const filters = useMemo<RecordFilter[]>(() => filtersFromParams(params), [params]);
|
||||||
const f: RecordFilter[] = [];
|
|
||||||
for (const d of DIMS) {
|
|
||||||
const v = params.get(d.key);
|
|
||||||
if (v) f.push({ field_key: d.col, value: v, data_type: 'string' });
|
|
||||||
}
|
|
||||||
for (const r of RANGES) {
|
|
||||||
const a = params.get(r.from) ?? undefined;
|
|
||||||
const b = params.get(r.to) ?? undefined;
|
|
||||||
if (a || b) f.push({ field_key: r.col, data_type: 'date', range: { from: a, to: b } });
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
}, [params]);
|
|
||||||
const filtered = filters.length > 0 || !!search;
|
const filtered = filters.length > 0 || !!search;
|
||||||
|
|
||||||
const key = params.toString();
|
const key = params.toString();
|
||||||
|
|||||||
@ -241,3 +241,35 @@ body {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Review in progress: an indeterminate strip under the trace header, and
|
||||||
|
three breathing dots on the "working" row. Both honour reduced motion. */
|
||||||
|
@keyframes nl-slide {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(300%); }
|
||||||
|
}
|
||||||
|
.progress-strip {
|
||||||
|
position: relative;
|
||||||
|
height: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: color-mix(in srgb, var(--color-ai) 18%, transparent);
|
||||||
|
}
|
||||||
|
.progress-strip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 33%;
|
||||||
|
background: var(--color-ai);
|
||||||
|
animation: nl-slide 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.working-dots { display: inline-flex; gap: 4px; align-items: center; }
|
||||||
|
.working-dots i {
|
||||||
|
width: 6px; height: 6px; border-radius: 9999px;
|
||||||
|
background: var(--color-ai);
|
||||||
|
animation: nl-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.working-dots i:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.working-dots i:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.progress-strip::after, .working-dots i { animation: none; }
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user