Files
krow_talent_app/src/components/ai-assistant/ResponseBlocks.jsx
Aravind 6249e00a3a
Some checks failed
CI / check (push) Failing after 4m58s
candidates and board ui agent issue
2026-09-05 10:46:06 +05:30

702 lines
28 KiB
JavaScript

import * as React from 'react';
import { Link } from 'react-router-dom';
import {
ArrowDownRight, ArrowRight, ArrowUpRight, Check, ChevronRight, TriangleAlert,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
import { useSkillDataContext } from '@/components/skills/SkillSurface';
import { resolveSkillData } from '@/lib/skills/dataResolver';
import { usePageAction } from './PageContext';
/**
* Renderers for response blocks.
*
* Every block is drawn with the same tokens as the dashboard — the ink scale,
* the shared radius, the score-band colours — so a generated report looks like
* it belongs to Krow rather than to a chat library.
*
* Blocks are memoized individually: a streaming response re-renders on every
* snapshot, and settled blocks must not re-render with it.
*/
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_|\[[^\]\n]+\]\([^)\s]*\))/g;
const LINK = /^\[([^\]\n]+)\]\(([^)\s]*)\)$/;
/**
* Where a link in an answer is allowed to point.
*
* An allow-list, and it is a security boundary rather than a tidiness rule: the
* text being parsed here was written by a model, and a model reading a document
* that says "link to javascript:…" is exactly the injection §I7 of CLAUDE.md
* calls untrusted input. Anything not on this list renders as the plain text it
* came from — visible, inert, and obvious.
*
* `#` alone is deliberately absent. A bare empty anchor is the shape the
* citation sanitiser removes; one that reaches here is a link to nowhere, and
* showing its label as text is better than an anchor that does nothing.
*/
const isSafeHref = (href) => (
/^\/(?!\/)/.test(href) // in-app route
|| /^#[^\s]+$/.test(href) // an anchor on this page, but not a bare '#'
|| /^https?:\/\//i.test(href) // the open web
|| /^mailto:[^\s]+$/i.test(href)
);
/**
* Inline `**bold**`, `_italic_` and `[label](href)`.
*
* Links were the gap: `markdownToBlocks` never touched them, and this renderer
* had no case for them, so `[staffing policy](#staffing)` reached the reader as
* its own source. Structure is still carried by blocks rather than by markup —
* this stays three constructs, not a markdown library.
*
* A citation-shaped link never arrives here at all. `[337042b3](#)` is removed
* upstream in `provider.js`, by the wrapper's shape, before a block is built —
* so the two concerns stay apart: the sanitiser decides what is an internal
* reference, and this decides how a real link looks.
*/
function Inline({ value }) {
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i} className="font-semibold text-ink-1">{part.slice(2, -2)}</strong>;
}
if (part.startsWith('_') && part.endsWith('_')) {
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
}
const link = LINK.exec(part);
if (link) {
const [, label, href] = link;
if (!isSafeHref(href)) return part;
/* An in-app route goes through the router, like every other internal link
in this file — a full page load would throw away the conversation the
reader is being pointed away from. Everything else is an anchor, and
anything leaving the app opens away from it. */
const external = /^https?:\/\//i.test(href);
const className = 'font-medium text-krow-blue underline decoration-krow-blue/30 underline-offset-2'
+ ' transition-colors hover:decoration-krow-blue focus-visible:outline-none'
+ ' focus-visible:ring-2 focus-visible:ring-krow-blue/50 rounded-sm';
if (href.startsWith('/')) return <Link key={i} to={href} className={className}>{label}</Link>;
return (
<a
key={i}
href={href}
className={className}
{...(external ? { target: '_blank', rel: 'noreferrer noopener' } : null)}
>
{label}
</a>
);
}
return part;
});
}
const TONE_TEXT = {
info: 'text-krow-blue',
success: 'text-success',
warning: 'text-warning',
risk: 'text-destructive',
neutral: 'text-ink-3',
};
const TONE_SURFACE = {
info: 'bg-krow-blue-tint border-krow-blue/15',
success: 'bg-success-muted border-success/15',
warning: 'bg-warning-muted border-warning/20',
risk: 'bg-destructive-muted border-destructive/15',
neutral: 'bg-surface-subtle border-border',
};
const TONE_BADGE = {
info: 'info', success: 'success', warning: 'warning', risk: 'destructive', neutral: 'neutral',
};
/* ── Individual blocks ──────────────────────────────────────────────────── */
const TextBlock = React.memo(/** @param {any} props */ ({ block }) => (
<p className="text-body-sm leading-relaxed text-ink-2">
<Inline value={block.text} />
</p>
));
TextBlock.displayName = 'TextBlock';
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
<div className="space-y-0.5">
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
</div>
));
HeadingBlock.displayName = 'HeadingBlock';
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
{block.items.map((item) => (
<div
key={item.label}
className="rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs"
>
<p className="truncate text-[10px] uppercase tracking-wide text-ink-4">{item.label}</p>
<div className="mt-0.5 flex items-baseline gap-1.5">
<span className={cn(
'font-heading text-title font-bold tabular-nums',
item.tone ? TONE_TEXT[item.tone] : 'text-ink-1'
)}>
{item.value}
</span>
{typeof item.delta === 'number' && item.delta !== 0 && (
<span className={cn(
'inline-flex items-center text-[10px] font-semibold',
item.delta > 0 ? 'text-success' : 'text-destructive'
)}>
{item.delta > 0
? <ArrowUpRight className="h-2.5 w-2.5" aria-hidden="true" />
: <ArrowDownRight className="h-2.5 w-2.5" aria-hidden="true" />}
{Math.abs(item.delta)}%
</span>
)}
</div>
{item.sub && <p className="mt-0.5 truncate text-[10px] text-ink-4">{item.sub}</p>}
</div>
))}
</div>
));
KpisBlock.displayName = 'KpisBlock';
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
<ul className="space-y-1.5">
{block.items.map((item) => (
<li key={item.label} className="flex items-start gap-2">
<span
className={cn(
'mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full',
item.ok ? 'bg-success-muted text-success' : 'bg-warning-muted text-warning'
)}
>
{item.ok
? <Check className="h-2.5 w-2.5" aria-hidden="true" />
: <TriangleAlert className="h-2.5 w-2.5" aria-hidden="true" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-body-sm font-medium text-ink-1">{item.label}</span>
{item.value && (
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-2">
{item.value}
</span>
)}
</div>
{item.note && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.note}</p>}
</div>
<span className="sr-only">{item.ok ? 'Passing' : 'Needs attention'}</span>
</li>
))}
</ul>
));
StatusBlock.displayName = 'StatusBlock';
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
<div className="space-y-2.5">
{block.items.map((item) => (
<div key={item.label} className="space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-caption text-ink-2">{item.label}</span>
<span className="flex shrink-0 items-baseline gap-1.5">
{item.weight != null && <span className="text-[10px] text-ink-4">{item.weight}%</span>}
<span className="text-caption font-semibold tabular-nums text-ink-1">{item.value}</span>
</span>
</div>
<ProgressBar value={item.value} max={item.max ?? 100} tone={item.tone ?? 'score'} size="sm" />
</div>
))}
</div>
));
MetersBlock.displayName = 'MetersBlock';
/**
* A table. Scrolls horizontally inside its own container so a wide comparison
* never widens the panel.
*/
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
const cell = (value) => {
if (value == null || value === '') return <span className="text-ink-4">—</span>;
if (typeof value === 'object') {
if (value.badge) {
return <Badge variant={TONE_BADGE[value.tone] || 'neutral'} size="sm">{value.badge}</Badge>;
}
return (
<span className={cn('tabular-nums', value.tone ? TONE_TEXT[value.tone] : undefined)}>
{value.value}
</span>
);
}
return <span className="tabular-nums">{value}</span>;
};
return (
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
<table className="w-full border-collapse text-caption">
{block.caption && <caption className="sr-only">{block.caption}</caption>}
<thead>
<tr className="border-b border-border bg-surface-subtle">
{block.columns.map((column) => (
<th
key={column.key}
scope="col"
className={cn(
'whitespace-nowrap px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
column.align === 'right' ? 'text-right' : 'text-left'
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr key={i} className="border-b border-border last:border-0">
{block.columns.map((column, ci) => (
<td
key={column.key}
className={cn(
'px-2.5 py-2 align-middle',
column.align === 'right' ? 'text-right' : 'text-left',
ci === 0 ? 'font-medium text-ink-1' : 'text-ink-2'
)}
>
{cell(row[column.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
});
TableBlock.displayName = 'TableBlock';
/** Stage progression, widths relative to the largest stage. */
const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
const max = Math.max(...block.steps.map((s) => s.count), 1);
return (
<div className="space-y-2">
{block.steps.map((step) => (
<div key={step.label}>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-caption text-ink-2">{step.label}</span>
<span className="flex shrink-0 items-baseline gap-1.5">
{step.rate != null && <span className="text-[10px] text-ink-4">{step.rate}%</span>}
<span className="text-caption font-semibold tabular-nums text-ink-1">{step.count}</span>
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-surface-sunken">
<div
className="h-full rounded-full bg-krow-blue transition-all duration-slow ease-out"
style={{ width: `${Math.max((step.count / max) * 100, step.count > 0 ? 4 : 0)}%` }}
/>
</div>
</div>
))}
</div>
);
});
FunnelBlock.displayName = 'FunnelBlock';
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
const Tag = block.ordered ? 'ol' : 'ul';
return (
<Tag className="space-y-1.5">
{block.items.map((item, i) => (
<li key={i} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
{block.ordered ? (
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{i + 1}
</span>
) : (
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-krow-blue" aria-hidden="true" />
)}
<span className="min-w-0"><Inline value={item} /></span>
</li>
))}
</Tag>
);
});
ListBlock.displayName = 'ListBlock';
/**
* Findings, and — where the item carries one — a way to act on it.
*
* Two kinds of action, and the distinction is the point:
*
* `prompt` asks Owliver the next question, in place. The panel stays open,
* the thread keeps its history, and the reader never loses the
* position they were working on. This is the default for a
* recommended person: inspecting a candidate is part of the
* conversation, not a trip to another page.
* `to` leaves for a route the app already has, with the whole card as the
* target. Reserved for items whose only meaning is "open this".
* `action` a named link inside the card — `{ label, to }`. For an item that
* has *both* a conversation and a record: the card keeps asking
* Owliver, and the link is the one explicit way to leave. Reading a
* card can then never navigate by accident, which matters most where
* the body is an explanation worth reading.
*
* `hint` is for an item that can do neither — it says why, instead of looking
* clickable and doing nothing. An item may carry a hint *and* an action: the
* hint explains the record, the action opens it.
*/
const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt }) => (
<div className="space-y-2">
{block.items.map((item, i) => {
const surface = cn(
'block w-full text-left rounded-xl border px-3 py-2.5',
TONE_SURFACE[item.tone] || TONE_SURFACE.neutral
);
const interactive = cn(
surface,
'transition-colors hover:border-krow-blue/50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50'
);
const asksOwliver = Boolean(item.prompt && onPrompt);
const actionable = asksOwliver || Boolean(item.to);
const body = (
<>
<p className="flex items-center gap-1 text-body-sm font-semibold text-ink-1">
{item.title}
{actionable && <ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden="true" />}
</p>
{item.body && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
)}
{item.hint && (
<p className="mt-1 text-[11px] italic leading-relaxed text-ink-4">{item.hint}</p>
)}
</>
);
/* A named link cannot live inside a button, so when an item has both, the
card is a plain container: the readable area is its own button and the
action sits beside it. One click target each, never nested. */
if (item.action?.to) {
return (
<div key={i} className={surface}>
{asksOwliver ? (
<button
type="button"
onClick={() => onPrompt(item.prompt)}
className="block w-full rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{body}
</button>
) : body}
<div className="mt-2 flex justify-end">
<Link
to={item.action.to}
className="inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue transition-colors hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{item.action.label}
<ArrowRight className="h-3 w-3" aria-hidden="true" />
</Link>
</div>
</div>
);
}
if (asksOwliver) {
return (
<button key={i} type="button" onClick={() => onPrompt(item.prompt)} className={interactive}>
{body}
</button>
);
}
if (item.to) {
return <Link key={i} to={item.to} className={interactive}>{body}</Link>;
}
return <div key={i} className={surface}>{body}</div>;
})}
</div>
));
InsightsBlock.displayName = 'InsightsBlock';
/** Recommended steps — numbered, because order is the recommendation. */
const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
<ol className="space-y-2">
{block.items.map((item, i) => (
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-[10px] font-bold text-white">
{i + 1}
</span>
<div className="min-w-0">
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
{item.body && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
)}
</div>
</li>
))}
</ol>
));
ActionsBlock.displayName = 'ActionsBlock';
const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
<div className="flex flex-wrap gap-1.5">
{block.items.map((item) => (
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
{item.label}
</Badge>
))}
</div>
));
BadgesBlock.displayName = 'BadgesBlock';
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
<ol className="relative space-y-3">
{block.items.map((item, i) => (
<li key={i} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className={cn(
'mt-1 h-2 w-2 shrink-0 rounded-full',
item.tone === 'success' ? 'bg-success'
: item.tone === 'warning' ? 'bg-warning'
: item.current ? 'bg-krow-blue' : 'bg-ink-4'
)}
aria-hidden="true"
/>
{i < block.items.length - 1 && <span className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />}
</div>
<div className="min-w-0 flex-1 pb-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-body-sm font-medium text-ink-1">{item.title}</span>
{item.timestamp && (
<span className="shrink-0 text-[10px] text-ink-4">{item.timestamp}</span>
)}
</div>
{item.description && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.description}</p>
)}
</div>
</li>
))}
</ol>
));
TimelineBlock.displayName = 'TimelineBlock';
const NoteBlock = React.memo(/** @param {any} props */ ({ block }) => (
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
<Inline value={block.text} />
</p>
));
NoteBlock.displayName = 'NoteBlock';
/**
* A section a skill declared, drawn by the component the page uses for it.
*
* There is no chat-specific renderer for a skill's shapes, and there must not
* be: `SECTION_COMPONENTS` is the single table from a declared type to a
* component, and this block goes through it exactly as `SkillSurface` does. A
* definition that gains a new shape gains it in both places at once, and a
* shape the table does not carry renders nothing rather than something
* improvised.
*/
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
const section = block.section;
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
/**
* A settable answer is re-read, never replayed.
*
* An ordinary answer is a record of what was true when it was given, and the
* stored blocks are exactly right for that. A section offering *controls* is
* different: it is a live view of a value the reader can still change, from
* here or on the page behind the panel. Replaying the stored copy would show
* a weight of 25% next to a form that now says 30%, and the control would
* write the stale figure back.
*
* So an editable section resolves from the current context on every render,
* through the same resolver the page's own card uses. There is one value and
* one place it lives; this is a second window onto it, not a second copy.
*/
const live = Boolean(section?.editable);
const context = useSkillDataContext(null);
const data = React.useMemo(
() => (live && section ? resolveSkillData(section, context) : block.data),
[live, section, context, block.data]
);
const apply = usePageAction(live ? section?.source : null);
if (!Component) return null;
return (
<div className="rounded-xl border border-border bg-surface-subtle/60 p-3">
<Component data={data} section={section} onApply={apply} compact />
</div>
);
});
SkillSectionBlock.displayName = 'SkillSectionBlock';
/**
* A write the agent has proposed. The only block a person can act on.
*
* Three things it must do, and they are all about not being clicked past:
*
* - **Say what will happen, in the server's words.** The title, summary and
* details are rendered as they arrived. A browser paraphrasing them would
* be describing a different act than the one the token authorises.
* - **Put warnings above the button.** A clash or an over-headcount is
* exactly what somebody is about to approve without noticing, and a warning
* underneath the decision is a warning read afterwards.
* - **Not pretend to be finished.** Once approved, the block stays visible
* and says so. Replacing it with a tick would lose what was agreed to.
*/
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
const [state, setState] = React.useState('pending');
const approve = React.useCallback(() => {
/* Guarded rather than debounced. The token is single-use server-side, so a
double click costs a confusing refusal rather than a duplicate write —
but a button that visibly does nothing the second time is kinder than
one that reports an error somebody caused by being quick. */
if (state !== 'pending') return;
setState('approved');
onConfirm?.(block);
}, [state, block, onConfirm]);
return (
<div className="rounded-xl border border-krow-blue/30 bg-krow-blue/5 p-3.5">
<div className="flex items-start gap-2.5">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-white">
<TriangleAlert className="h-3 w-3" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<p className="text-body-sm font-semibold text-ink-1">{block.title}</p>
{block.summary && (
<p className="mt-1 text-caption leading-relaxed text-ink-2">{block.summary}</p>
)}
{block.details?.length > 0 && (
<dl className="mt-2.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{block.details.map((d, i) => (
<React.Fragment key={i}>
<dt className="text-caption text-ink-3">{d.label}</dt>
<dd className="text-caption font-medium text-ink-1">{d.value}</dd>
</React.Fragment>
))}
</dl>
)}
{/* Above the button, deliberately. */}
{block.warnings?.length > 0 && (
<ul className="mt-2.5 space-y-1">
{block.warnings.map((w, i) => (
<li key={i} className="flex gap-1.5 text-caption text-amber-700 dark:text-amber-400">
<TriangleAlert className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
<span>{w}</span>
</li>
))}
</ul>
)}
<div className="mt-3 flex items-center gap-2">
{state === 'pending' ? (
<>
<button
type="button"
onClick={approve}
className="rounded-lg bg-krow-blue px-3 py-1.5 text-caption font-semibold text-white
transition hover:opacity-90 focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
>
Approve
</button>
<button
type="button"
onClick={() => setState('dismissed')}
className="rounded-lg px-3 py-1.5 text-caption font-medium text-ink-3
transition hover:text-ink-1 focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
>
Not now
</button>
</>
) : (
<p className="flex items-center gap-1.5 text-caption font-medium text-ink-3">
<Check className="h-3.5 w-3.5" aria-hidden="true" />
{state === 'approved' ? 'Approved — carrying on.' : 'Left for now. Nothing was changed.'}
</p>
)}
</div>
</div>
</div>
</div>
);
});
ConfirmationBlock.displayName = 'ConfirmationBlock';
const RENDERERS = {
text: TextBlock,
confirmation: ConfirmationBlock,
skillSection: SkillSectionBlock,
heading: HeadingBlock,
kpis: KpisBlock,
status: StatusBlock,
meters: MetersBlock,
table: TableBlock,
funnel: FunnelBlock,
list: ListBlock,
insights: InsightsBlock,
actions: ActionsBlock,
badges: BadgesBlock,
timeline: TimelineBlock,
note: NoteBlock,
};
/**
* Renders a response document.
*
* Spacing is decided here rather than by the blocks, so any combination of
* blocks has consistent rhythm — a heading hugs what follows it, everything
* else breathes.
*/
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt, onConfirm }) => (
<div className="space-y-3">
{blocks.map((block, i) => {
const Renderer = RENDERERS[block.type];
if (!Renderer) return null;
const hugsNext = block.type === 'heading' && i < blocks.length - 1;
const isLast = i === blocks.length - 1;
return (
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
<Renderer block={block} onPrompt={onPrompt} onConfirm={onConfirm} />
{/* The cursor trails the final block only while text is still arriving. */}
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
<span
className="ml-0.5 inline-block h-3.5 w-1.5 -mb-0.5 animate-caret bg-krow-blue align-middle"
aria-hidden="true"
/>
)}
</div>
);
})}
</div>
));
ResponseDocument.displayName = 'ResponseDocument';
export { toneForScore };