Co-Study4Grid / frontend /src /components /ActionCard.tsx
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
32.7 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.
import React from 'react';
import type { ActionDetail, NodeMeta, EdgeMeta } from '../types';
import type { ModelDescriptor } from '../api';
import { getActionTargetVoltageLevels, getActionTargetLines, isCouplingAction } from '../utils/svgUtils';
import { colors } from '../styles/tokens';
import { SeverityIcon, type SeverityKind } from './SeverityIcon';
interface ActionCardProps {
id: string;
details: ActionDetail;
index: number;
isViewing: boolean;
isSelected: boolean;
isRejected: boolean;
linesOverloaded: string[];
monitoringFactor: number;
nodesByEquipmentId: Map<string, NodeMeta> | null;
edgesByEquipmentId: Map<string, EdgeMeta> | null;
cardEditMw: Record<string, string>;
cardEditTap: Record<string, string>;
resimulating: string | null;
onActionSelect: (actionId: string | null) => void;
onActionFavorite: (actionId: string) => void;
onActionReject: (actionId: string) => void;
onAssetClick: (actionId: string, assetName: string, tab?: 'action' | 'contingency') => void;
onVlDoubleClick?: (actionId: string, vlName: string) => void;
onCardEditMwChange: (actionId: string, value: string) => void;
onCardEditTapChange: (actionId: string, value: string) => void;
onResimulate: (actionId: string, newMw: number) => void;
onResimulateTap: (actionId: string, newTap: number) => void;
/** Resolve an element ID to its human-readable display name. Falls back to the ID. */
displayName?: (id: string) => string;
/**
* Registered recommender models — used to resolve `details.origin`
* (a model id) to a human-readable label in the unfolded card's
* "Source" row. Absent / no-match falls back to the raw id.
*/
availableModels?: ModelDescriptor[];
}
const clickableLinkStyle: React.CSSProperties = {
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
fontSize: 'inherit',
color: colors.brand,
fontWeight: 600,
textDecoration: 'underline dotted',
};
const ActionCard: React.FC<ActionCardProps> = ({
id,
details,
index,
isViewing,
isSelected,
isRejected,
linesOverloaded,
monitoringFactor,
nodesByEquipmentId,
edgesByEquipmentId,
cardEditMw,
cardEditTap,
resimulating,
onActionSelect,
onActionFavorite,
onActionReject,
onAssetClick,
onVlDoubleClick,
onCardEditMwChange,
onCardEditTapChange,
onResimulate,
onResimulateTap,
displayName = (id: string) => id,
availableModels,
}) => {
const maxRhoPct = details.max_rho != null ? (details.max_rho * 100).toFixed(1) : null;
// Provenance label for the unfolded card's "Source" row. `origin`
// is either the literal `"user"` (manual simulation) or a
// recommender model id — resolved here to the model's label.
const originLabel = details.origin == null
? null
: details.origin === 'user'
? 'Manual simulation (user)'
: (availableModels?.find(m => m.name === details.origin)?.label ?? details.origin);
const severity = details.max_rho != null
? (details.max_rho > monitoringFactor ? 'red' as const : details.max_rho > (monitoringFactor - 0.05) ? 'orange' as const : 'green' as const)
: (details.is_rho_reduction ? 'green' as const : 'red' as const);
const severityColors = {
green: { border: colors.success, badgeBg: colors.successSoft, badgeText: colors.successText, label: 'Solves overload', kind: 'solves' as SeverityKind },
orange: { border: colors.warningStrong, badgeBg: colors.warningSoft, badgeText: colors.warningText, label: 'Solved — low margin', kind: 'lowMargin' as SeverityKind },
red: { border: colors.danger, badgeBg: colors.dangerSoft, badgeText: colors.dangerText, label: details.is_rho_reduction ? 'Still overloaded' : 'No reduction', kind: 'unsolved' as SeverityKind },
};
const sc = details.non_convergence
? { border: colors.danger, badgeBg: colors.danger, badgeText: colors.textOnBrand, label: 'divergent', kind: 'divergent' as SeverityKind }
: details.is_islanded
? { border: colors.danger, badgeBg: colors.danger, badgeText: colors.textOnBrand, label: 'islanded', kind: 'islanded' as SeverityKind }
: severityColors[severity];
const renderRho = (arr: number[] | null, actionId: string, tab: 'action' | 'contingency' = 'action'): React.ReactNode => {
if (!arr || arr.length === 0) return '—';
const halfOpen = details.half_open_overloads;
return arr.map((v, i) => {
const lineName = linesOverloaded[i] || `line ${i}`;
// A line the action left open at one end shows a non-zero loading
// that is pure capacitive charging current (the diagrams show p = 0).
// Annotate it with the live-end reactive power so the operator reads
// it as charging current, not a residual overload.
const reactiveMvar = halfOpen ? halfOpen[lineName] : undefined;
return (
<React.Fragment key={i}>
{i > 0 && ', '}
<button
style={clickableLinkStyle}
title={`Zoom to ${lineName}`}
onClick={(e) => { e.stopPropagation(); onAssetClick(actionId, lineName, tab); }}
>{displayName(lineName)}</button>
{`: ${(v * 100).toFixed(1)}%`}
{reactiveMvar != null && (
<span
style={{ color: colors.textTertiary, fontStyle: 'italic' }}
title="Line open at one end — this loading is the line's capacitive charging current (reactive power), not real power flow. The line carries no active power (the diagrams show 0)."
>
{` — open one end · ${reactiveMvar.toFixed(1)} MVAr capacitive`}
</span>
)}
</React.Fragment>
);
});
};
const renderBadges = () => {
const badges: React.ReactNode[] = [];
const badgeBtn = (name: string, bg: string, color: string, title: string, onDoubleClick?: (e: React.MouseEvent) => void) => (
<button key={name}
style={{ padding: '2px 7px', borderRadius: '4px', border: 'none', cursor: 'pointer', fontSize: '11px', fontWeight: 600, textDecoration: 'underline dotted', flexShrink: 0, backgroundColor: bg, color }}
title={title}
onClick={(e) => { e.stopPropagation(); onAssetClick(id, name, 'action'); }}
onDoubleClick={onDoubleClick}>
{displayName(name)}
</button>
);
// Collect badges from every source that applies. A combined
// action like ``load_shedding_X+reco_Y`` owes a badge to BOTH
// sub-actions — using an if/else-if/else here used to drop the
// topology-based sub-action (reco / disco / coupling) whenever
// the pair also contained a load-shedding or curtailment leg.
const vlSet = new Set<string>();
// Highest-priority signal: backend-supplied VL hint on the
// topology blob. The recommender pipeline writes this for
// pypowsybl switch-based / coupling actions (the dict_action
// entry's ``VoltageLevelId``) so the operator gets a
// clickable, double-clickable VL chip even when the action ID
// is opaque (e.g. UUID-prefixed ``..._VLNAME_..._coupling``).
const explicitVlHint = (details.action_topology as { voltage_level_id?: string } | undefined)?.voltage_level_id;
if (explicitVlHint && !vlSet.has(explicitVlHint)) {
vlSet.add(explicitVlHint);
badges.push(badgeBtn(explicitVlHint, colors.successSoft, colors.successText, `Click: zoom to ${explicitVlHint} | Double-click: open SLD`, (e) => {
e.stopPropagation();
onVlDoubleClick?.(id, explicitVlHint);
}));
}
details.load_shedding_details?.forEach(ls => {
if (ls.voltage_level_id && !vlSet.has(ls.voltage_level_id)) {
vlSet.add(ls.voltage_level_id);
badges.push(badgeBtn(ls.voltage_level_id, colors.successSoft, colors.successText, `Click: zoom to ${ls.voltage_level_id} | Double-click: open SLD`, (e) => {
e.stopPropagation();
onVlDoubleClick?.(id, ls.voltage_level_id!);
}));
}
});
details.curtailment_details?.forEach(rc => {
if (rc.voltage_level_id && !vlSet.has(rc.voltage_level_id)) {
vlSet.add(rc.voltage_level_id);
badges.push(badgeBtn(rc.voltage_level_id, colors.successSoft, colors.successText, `Click: zoom to ${rc.voltage_level_id} | Double-click: open SLD`, (e) => {
e.stopPropagation();
onVlDoubleClick?.(id, rc.voltage_level_id!);
}));
}
});
details.redispatch_details?.forEach(rd => {
if (rd.voltage_level_id && !vlSet.has(rd.voltage_level_id)) {
vlSet.add(rd.voltage_level_id);
badges.push(badgeBtn(rd.voltage_level_id, colors.successSoft, colors.successText, `Click: zoom to ${rd.voltage_level_id} | Double-click: open SLD`, (e) => {
e.stopPropagation();
onVlDoubleClick?.(id, rd.voltage_level_id!);
}));
}
});
if (nodesByEquipmentId) {
const vlNames = getActionTargetVoltageLevels(details, id, nodesByEquipmentId);
vlNames.forEach(vlName => {
if (vlSet.has(vlName)) return;
vlSet.add(vlName);
badges.push(badgeBtn(vlName, colors.successSoft, colors.successText, `Click: zoom to ${vlName} | Double-click: open SLD`, (e) => {
e.stopPropagation();
onVlDoubleClick?.(id, vlName);
}));
});
}
const isCoupling = isCouplingAction(id, details.description_unitaire);
const lineNames = edgesByEquipmentId
? getActionTargetLines(details, id, edgesByEquipmentId)
: Array.from(new Set([
...(isCoupling ? [] : Object.keys(details.action_topology?.lines_ex_bus || {})),
...(isCoupling ? [] : Object.keys(details.action_topology?.lines_or_bus || {})),
...Object.keys(details.action_topology?.pst_tap || {}),
]));
lineNames.forEach(name => {
if (badges.some(b => React.isValidElement(b) && b.key === name)) return;
badges.push(badgeBtn(name, colors.brandSoft, colors.brand, `Zoom to ${name}`));
});
if (badges.length === 0) {
const topo = details.action_topology;
const equipNames = Array.from(new Set([
...Object.keys(topo?.gens_bus || {}),
...Object.keys(topo?.loads_bus || {}),
...Object.keys(topo?.loads_p || {}),
...Object.keys(topo?.gens_p || {}),
]));
equipNames.forEach(name => {
badges.push(badgeBtn(name, colors.brandSoft, colors.brand, `Zoom to ${name}`));
});
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', flexShrink: 0, justifyContent: 'flex-end' }}>
{badges}
</div>
);
};
const isFault = !!(details.non_convergence || details.is_islanded);
// Higher-saturation accent stripe for the viewing card — replaces
// the old vertical "VIEWING" ribbon with a quieter signal that
// doesn't steal a column of horizontal space inside the card.
const accentColor = isViewing ? colors.brandStrong : sc.border;
const editorRowStyle: React.CSSProperties = {
fontSize: '12px',
padding: '6px 10px',
marginTop: '5px',
borderRadius: '4px',
fontWeight: 500,
};
return (
<div
data-testid={`action-card-${id}`}
data-viewing={isViewing ? 'true' : 'false'}
className={`action-card${isViewing ? ' is-viewing' : ''}`}
style={{
background: isFault ? colors.dangerSoft : (isViewing ? colors.brandSoft : colors.surface),
border: isFault ? `1px solid ${colors.danger}` : `1px solid ${colors.border}`,
borderRadius: '8px',
marginBottom: '10px',
boxShadow: isViewing ? '0 2px 8px rgba(0,0,0,0.15)' : '0 2px 4px rgba(0,0,0,0.1)',
borderLeft: `5px solid ${accentColor}`,
cursor: 'pointer',
transition: 'all 0.15s ease',
overflow: 'hidden',
padding: '10px',
position: 'relative',
}} onClick={() => onActionSelect(id)}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '6px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', flex: 1, minWidth: 0 }}>
{/* Severity pictogram — placed before the title so the
operator immediately reads the outcome colour. */}
<span
data-testid={`action-card-${id}-severity`}
title={sc.label}
aria-label={sc.label}
role="img"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '3px',
borderRadius: '50%',
background: sc.badgeBg,
color: sc.badgeText,
flexShrink: 0,
lineHeight: 0,
}}
>
<SeverityIcon kind={sc.kind} />
</span>
<h4 style={{
margin: 0,
fontSize: '12px',
color: isViewing ? colors.brandStrong : undefined,
flex: 1,
minWidth: 0,
overflowWrap: 'anywhere',
fontWeight: 700,
lineHeight: 1.35,
}}>
#{index + 1} {'—'} {id}
</h4>
</div>
{/* Star / reject rail — top-right, revealed on hover via
CSS (.action-card-rail opacity transition). */}
<div className="action-card-rail" style={{ display: 'flex', gap: '3px', flexShrink: 0 }}>
{!isSelected && (
<button
data-testid={`favorite-${id}`}
onClick={(e) => { e.stopPropagation(); onActionFavorite(id); }}
style={{ background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: '4px', cursor: 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Select this action"
><span style={{ fontSize: '15px', lineHeight: 1 }}></span></button>
)}
{!isRejected && (
<button
data-testid={`reject-${id}`}
onClick={(e) => { e.stopPropagation(); onActionReject(id); }}
style={{ background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: '4px', cursor: 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title={isSelected ? "Remove from selected" : "Reject this action"}
><span style={{ fontSize: '15px', lineHeight: 1 }}></span></button>
)}
</div>
</div>
{/* Compact at-rest body: max loading + target badges. The
row wraps so multi-VL badge stacks flow to a second
line when the title is long or the badges don't fit
alongside the loading metric. */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', rowGap: '6px', gap: '8px', marginTop: '6px' }}>
<div style={{ flex: '1 1 160px', fontSize: '12px', minWidth: 'min-content' }}>
{maxRhoPct != null ? (
<div>
Max loading: <strong style={{ color: sc.border }}>{maxRhoPct}%</strong>
{details.max_rho_line && (
<span style={{ color: colors.textTertiary }}> on <button
style={{ ...clickableLinkStyle, color: colors.textTertiary }}
title={`Zoom to ${details.max_rho_line}`}
onClick={(e) => { e.stopPropagation(); onAssetClick(id, details.max_rho_line, 'action'); }}
>{displayName(details.max_rho_line)}</button></span>
)}
</div>
) : (
<div style={{ color: colors.textTertiary }}>No loading metric</div>
)}
</div>
{renderBadges()}
</div>
{/* Fault states (divergent / islanded) are primary signals
and stay visible regardless of the viewing-state — they
replace the missing max-loading indicator. */}
{details.non_convergence && (
<div style={{ fontSize: '11px', color: colors.warningText, backgroundColor: colors.warningSoft, padding: '2px 6px', borderRadius: '4px', marginTop: '6px', border: `1px solid ${colors.warningBorder}`, display: 'inline-block' }}>
⚠️ LoadFlow failure: {details.non_convergence}
</div>
)}
{details.is_islanded && (
<div style={{ fontSize: '12px', background: colors.dangerSoft, color: colors.danger, padding: '6px 10px', marginTop: '6px', borderRadius: '4px', border: `1px solid ${colors.danger}`, fontWeight: 500 }}>
🏝️ Islanding detected ({details.disconnected_mw?.toFixed(1)} MW disconnected)
</div>
)}
{/* Progressive disclosure: description, parameter editors,
and per-line "Loading after" only render on the viewing
card. Keeps non-viewing cards to five fields each. */}
{isViewing && (
<div
data-testid={`action-card-${id}-disclosure`}
style={{ marginTop: '8px', borderTop: `1px solid ${colors.borderSubtle}`, paddingTop: '8px' }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<p style={{ fontSize: '12px', margin: 0, color: colors.textPrimary }}>{details.description_unitaire}</p>
{details.load_shedding_details && details.load_shedding_details.length > 0 && (
<div style={{ ...editorRowStyle, background: colors.warningSoft, color: colors.warningText, border: `1px solid ${colors.warningBorder}` }}>
{details.load_shedding_details.map((ls, i) => (
<div key={ls.load_name} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', marginTop: i > 0 ? '4px' : 0 }}>
<span>Shedding on <strong>{ls.load_name}</strong> in MW:</span>
<input
data-testid={`edit-mw-${id}`}
type="number"
min={0}
step={0.1}
value={cardEditMw[id] ?? ls.shedded_mw.toFixed(1)}
onChange={(e) => onCardEditMwChange(id, e.target.value)}
style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: `1px solid ${colors.warningStrong}`, borderRadius: '3px', textAlign: 'right' }}
/>
<button
data-testid={`resimulate-${id}`}
onClick={() => {
const mwVal = parseFloat(cardEditMw[id] ?? String(ls.shedded_mw));
if (!isNaN(mwVal) && mwVal >= 0) onResimulate(id, mwVal);
}}
disabled={resimulating === id}
style={{ fontSize: '10px', padding: '2px 8px', borderRadius: '3px', border: `1px solid ${colors.warningStrong}`, background: colors.warning, color: colors.textOnBright, cursor: resimulating === id ? 'wait' : 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}
>
{resimulating === id ? 'Simulating...' : 'Re-simulate'}
</button>
</div>
))}
</div>
)}
{details.curtailment_details && details.curtailment_details.length > 0 && (
<div style={{ ...editorRowStyle, background: colors.infoSoft, color: colors.infoText, border: `1px solid ${colors.infoBorder}` }}>
{details.curtailment_details.map((rc, i) => (
<div key={rc.gen_name} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', marginTop: i > 0 ? '4px' : 0 }}>
<span>Curtailment on <strong>{rc.gen_name}</strong> in MW:</span>
<input
data-testid={`edit-mw-${id}`}
type="number"
min={0}
step={0.1}
value={cardEditMw[id] ?? rc.curtailed_mw.toFixed(1)}
onChange={(e) => onCardEditMwChange(id, e.target.value)}
style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: `1px solid ${colors.info}`, borderRadius: '3px', textAlign: 'right' }}
/>
<button
data-testid={`resimulate-${id}`}
onClick={() => {
const mwVal = parseFloat(cardEditMw[id] ?? String(rc.curtailed_mw));
if (!isNaN(mwVal) && mwVal >= 0) onResimulate(id, mwVal);
}}
disabled={resimulating === id}
style={{ fontSize: '10px', padding: '2px 8px', borderRadius: '3px', border: `1px solid ${colors.info}`, background: colors.infoBorder, color: colors.infoText, cursor: resimulating === id ? 'wait' : 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}
>
{resimulating === id ? 'Simulating...' : 'Re-simulate'}
</button>
</div>
))}
</div>
)}
{details.redispatch_details && details.redispatch_details.length > 0 && (
<div style={{ ...editorRowStyle, background: colors.infoSoft, color: colors.infoText, border: `1px solid ${colors.infoBorder}` }}>
{details.redispatch_details.map((rd, i) => {
// Signed-delta bounds from the generator's [min_p, max_p]:
// a raise can go up to +max_raise_mw, a lower down to -max_lower_mw.
const headroom = rd.direction === 'up' ? rd.max_raise_mw : rd.max_lower_mw;
const minDelta = rd.direction === 'up' ? 0 : (rd.max_lower_mw != null ? -rd.max_lower_mw : undefined);
const maxDelta = rd.direction === 'up' ? (rd.max_raise_mw ?? undefined) : 0;
const clamp = (v: number) => {
if (minDelta != null) v = Math.max(minDelta, v);
if (maxDelta != null) v = Math.min(maxDelta, v);
return v;
};
return (
<div key={rd.gen_name} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', marginTop: i > 0 ? '4px' : 0 }}>
<span>Redispatch on <strong>{rd.gen_name}</strong> ({rd.direction === 'up' ? 'raise' : 'lower'}) in MW:</span>
<input
data-testid={`edit-mw-${id}`}
type="number"
step={0.1}
min={minDelta}
max={maxDelta}
value={cardEditMw[id] ?? rd.delta_mw.toFixed(1)}
onChange={(e) => onCardEditMwChange(id, e.target.value)}
style={{ width: '65px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: `1px solid ${colors.info}`, borderRadius: '3px', textAlign: 'right' }}
/>
{headroom != null && (
<span style={{ fontSize: '10px', color: colors.info }}>
max {rd.direction === 'up' ? 'raise' : 'lower'}: {headroom.toFixed(0)} MW
</span>
)}
<button
data-testid={`resimulate-${id}`}
onClick={() => {
const raw = parseFloat(cardEditMw[id] ?? String(rd.delta_mw));
if (!isNaN(raw)) onResimulate(id, clamp(raw));
}}
disabled={resimulating === id}
style={{ fontSize: '10px', padding: '2px 8px', borderRadius: '3px', border: `1px solid ${colors.info}`, background: colors.infoBorder, color: colors.infoText, cursor: resimulating === id ? 'wait' : 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}
>
{resimulating === id ? 'Simulating...' : 'Re-simulate'}
</button>
</div>
);
})}
</div>
)}
{details.pst_details && details.pst_details.length > 0 && (
<div style={{ ...editorRowStyle, background: colors.accentSoft, color: colors.accentText, border: `1px solid ${colors.accentBorder}` }}>
{details.pst_details.map((pst, i) => (
<div key={pst.pst_name} style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', marginTop: i > 0 ? '4px' : 0 }}>
<span>PST <strong>{pst.pst_name}</strong> tap:</span>
<input
data-testid={`edit-tap-${id}`}
type="number"
min={pst.low_tap ?? undefined}
max={pst.high_tap ?? undefined}
step={1}
value={cardEditTap[id] ?? pst.tap_position}
onChange={(e) => onCardEditTapChange(id, e.target.value)}
style={{ width: '55px', fontSize: '11px', fontFamily: 'monospace', padding: '2px 4px', border: `1px solid ${colors.accent}`, borderRadius: '3px', textAlign: 'right' }}
/>
{pst.low_tap != null && pst.high_tap != null && (
<span style={{ fontSize: '10px', color: colors.accent }}>
[{pst.low_tap}..{pst.high_tap}]
</span>
)}
<button
data-testid={`resimulate-tap-${id}`}
onClick={() => {
const tapVal = parseInt(cardEditTap[id] ?? String(pst.tap_position), 10);
if (!isNaN(tapVal)) onResimulateTap(id, tapVal);
}}
disabled={resimulating === id}
style={{ fontSize: '10px', padding: '2px 8px', borderRadius: '3px', border: `1px solid ${colors.accent}`, background: colors.accentBorder, color: colors.accentText, cursor: resimulating === id ? 'wait' : 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}
>
{resimulating === id ? 'Simulating...' : 'Re-simulate'}
</button>
</div>
))}
</div>
)}
{/* "Loading before" stays in the sticky Overloads N-1
section of the left feed — no need to duplicate
it per card (see git blame for the original
rationale). */}
<div style={{ fontSize: '12px', background: colors.brandSoft, padding: '5px', marginTop: '8px', borderRadius: '4px' }}>
Overload loading after: {renderRho(details.rho_after, id, 'action')}
</div>
{/* Source / provenance sits at the very bottom of
the unfolded card — it's a low-information
attribution row that shouldn't compete with the
operational fields above. */}
{originLabel && (
<div
data-testid={`action-card-${id}-origin`}
style={{ fontSize: '11px', marginTop: '8px', color: colors.textTertiary }}
>
Source: <strong style={{ color: colors.textSecondary }}>{originLabel}</strong>
</div>
)}
</div>
)}
</div>
);
};
export default React.memo(ActionCard);