improvement: fdi tooth chart selection modes polished. bugs related to teeth selecyion fixed.

This commit is contained in:
2026-07-18 00:02:40 +03:30
parent 4b2349228a
commit 0740493384
23 changed files with 384 additions and 161 deletions

View File

@@ -76,6 +76,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
);
}
// Do not mount clinic-only pages for lab orgs (avoids PERMISSION_CLINIC_ONLY toasts during redirect).
if (!canAccessDashboardRoute(currentOrganization, pathname)) {
return (
<div className="h-screen flex items-center justify-center app-web-bg">
{t('loadingWorkspace')}
</div>
);
}
return (
<ToastProvider>
<div className="app-dashboard-shell flex h-[100dvh] app-web-bg text-text-primary">

View File

@@ -11,10 +11,10 @@ export default function HomePage() {
const t = useTranslations('landing');
const tAuth = useTranslations('auth');
const tCommon = useTranslations('common');
const { user } = useAuth();
const { user, isAuthReady } = useAuth();
return (
<div className="min-h-[100dvh] app-web-bg text-text-primary">
<div className="min-h-[100dvh] flex flex-col app-web-bg text-text-primary">
<header className="border-b border-border/70 bg-background-secondary/65 backdrop-blur-sm fixed top-0 w-full z-10">
<div className="container mx-auto px-4 py-3 sm:py-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
<Link href="/" className="text-xl sm:text-2xl font-semibold text-text-primary shrink-0">
@@ -23,7 +23,9 @@ export default function HomePage() {
<div className="flex items-center gap-1.5 sm:gap-3 ml-auto shrink-0">
<TopBarControls />
{user ? (
{!isAuthReady ? (
<span className="inline-block h-9 w-24 sm:w-28 rounded-[var(--radius-md)] bg-background-card/60 animate-pulse" aria-hidden />
) : user ? (
<Link href="/today">
<Button variant="primary" size="sm" className="sm:px-4 sm:py-2 sm:text-sm">
{tAuth('dashboard')}
@@ -48,7 +50,7 @@ export default function HomePage() {
</div>
</header>
<main className="container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20">
<main className="flex-1 container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20">
<div className="max-w-4xl mx-auto text-center">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold mb-4 sm:mb-6 leading-tight">
{t('heroTitle')}
@@ -59,7 +61,7 @@ export default function HomePage() {
{t('heroSubtitle')}
</p>
{!user && (
{isAuthReady && !user && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-center gap-3">
<Link href="/register" className="w-full sm:w-auto">
<Button size="lg" variant="primary" fullWidth className="sm:w-auto sm:px-8">
@@ -109,7 +111,7 @@ export default function HomePage() {
</div>
</main>
<footer className="border-t border-border/70 bg-background-secondary/80">
<footer className="mt-auto border-t border-border/70 bg-background-secondary/80">
<div className="container mx-auto px-4 py-8 flex flex-col md:flex-row justify-between items-center text-sm text-text-secondary">
<div>{t('footerCopyright')}</div>

View File

@@ -23,6 +23,28 @@ export function deriveTeethFromGroups(groups: ToothSelectionGroup[]): FdiToothId
return [...set].sort() as FdiToothId[];
}
/** Never allow a 1-tooth connected group (no lone connected dots). */
function normalizeGroupKind(kind: ToothSelectionKind, teeth: FdiToothId[]): ToothSelectionKind {
if (kind === 'connected' && teeth.length < 2) return 'single';
return kind;
}
function makeGroup(kind: ToothSelectionKind, teeth: FdiToothId[]): ToothSelectionGroup | null {
if (teeth.length === 0) return null;
return {
groupId: newGroupId(),
kind: normalizeGroupKind(kind, teeth),
teeth,
};
}
function sortInArchOrder(teeth: FdiToothId[]): FdiToothId[] {
if (teeth.length === 0) return [];
const arch = archOrder(teeth[0]);
if (!arch) return [...teeth];
return [...teeth].sort((a, b) => arch.indexOf(a) - arch.indexOf(b));
}
/** Legacy / empty groups: each selected tooth becomes its own single group. */
export function groupsFromFlatTeeth(
teeth: readonly FdiToothId[],
@@ -36,11 +58,10 @@ export function groupsFromFlatTeeth(
teeth: g.teeth.filter((t) => flat.has(t)) as FdiToothId[],
}))
.filter((g) => g.teeth.length > 0)
.map((g) =>
g.kind === 'connected' && g.teeth.length < 2
? { ...g, kind: 'single' as const }
: g,
);
.map((g) => ({
...g,
kind: normalizeGroupKind(g.kind, g.teeth),
}));
const covered = new Set(next.flatMap((g) => g.teeth));
for (const tooth of teeth) {
if (!covered.has(tooth)) {
@@ -94,8 +115,11 @@ export function teethBetweenInclusive(a: FdiToothId, b: FdiToothId): FdiToothId[
/**
* Plain click:
* - unselected → add as single
* - selected single → remove
* - selected in connected → drop the whole connected span and keep only this tooth as a single
* - selected single → remove (deselect)
* - in connected bridge → cut from bridge, keep selected as single:
* - end tooth → remainder stays one bridge (or demotes if 1 left)
* - middle tooth → left and right become separate bridges/singles
* Never leaves a 1-tooth connected group (no lone dots).
*/
export function toggleToothInGroups(
groups: ToothSelectionGroup[],
@@ -110,15 +134,26 @@ export function toggleToothInGroups(
return groups.filter((g) => g.groupId !== owning.groupId);
}
// Collapse connected span: only the clicked tooth remains, as a separate single.
const ordered = sortInArchOrder(owning.teeth);
const idx = ordered.indexOf(tooth);
if (idx < 0) return groups;
const left = ordered.slice(0, idx);
const right = ordered.slice(idx + 1);
const next = groups.filter((g) => g.groupId !== owning.groupId);
const leftGroup = makeGroup(left.length >= 2 ? 'connected' : 'single', left);
const rightGroup = makeGroup(right.length >= 2 ? 'connected' : 'single', right);
if (leftGroup) next.push(leftGroup);
if (rightGroup) next.push(rightGroup);
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });
return next;
}
/**
* Shift-click creates a connected span between anchor and target on the same arch.
* Any existing groups that intersect the span are replaced by the new connected group.
* Overlapping existing bridges are merged (union) into one connected bridge.
* Non-overlapping bridges are left alone. Singles inside the union are absorbed.
*/
export function applyShiftRange(
groups: ToothSelectionGroup[],
@@ -129,58 +164,47 @@ export function applyShiftRange(
const span = teethBetweenInclusive(anchor, target);
if (!span || span.length < 2) return null;
const spanSet = new Set(span);
const next = groups
.map((g) => ({
...g,
teeth: g.teeth.filter((t) => !spanSet.has(t)) as FdiToothId[],
}))
.filter((g) => g.teeth.length > 0)
.map((g) =>
g.kind === 'connected' && g.teeth.length < 2
? { ...g, kind: 'single' as const }
: g,
);
next.push({ groupId: newGroupId(), kind: 'connected', teeth: span });
return next;
}
/**
* Ctrl-click selects every tooth in the same-arch range as its own single group
* (not connected). Lab dispatch gets one prosthesis row per tooth.
*/
export function applyCtrlRange(
groups: ToothSelectionGroup[],
anchor: FdiToothId,
target: FdiToothId,
): ToothSelectionGroup[] | null {
if (anchor === target) return null;
const span = teethBetweenInclusive(anchor, target);
if (!span || span.length < 2) return null;
const spanSet = new Set(span);
const next = groups
.map((g) => ({
...g,
teeth: g.teeth.filter((t) => !spanSet.has(t)) as FdiToothId[],
}))
.filter((g) => g.teeth.length > 0)
.map((g) =>
g.kind === 'connected' && g.teeth.length < 2
? { ...g, kind: 'single' as const }
: g,
);
for (const tooth of span) {
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });
const union = new Set<FdiToothId>(span);
let changed = true;
while (changed) {
changed = false;
for (const group of groups) {
if (group.kind !== 'connected') continue;
if (!group.teeth.some((t) => union.has(t))) continue;
for (const t of group.teeth) {
if (!union.has(t)) {
union.add(t);
changed = true;
}
}
}
}
const unionTeeth = sortInArchOrder([...union] as FdiToothId[]);
if (unionTeeth.length < 2) return null;
const next = groups
.map((g) => ({
...g,
teeth: g.teeth.filter((t) => !union.has(t)) as FdiToothId[],
}))
.filter((g) => g.teeth.length > 0)
.map((g) => ({
...g,
kind: normalizeGroupKind(g.kind, g.teeth),
}));
next.push({
groupId: newGroupId(),
kind: 'connected',
teeth: unionTeeth,
});
return next;
}
/**
* Drop prosthesis rows for teeth no longer selected on a detail, and refresh
* selectionGroupId so connected→single collapses do not leave stale group ids.
* selectionGroupId so group changes do not leave stale group ids.
*/
export function pruneToothProsthesisForGroups<
T extends { detailClientId: string; tooth: string; selectionGroupId?: string },
@@ -213,7 +237,7 @@ export function normalizeToothSelectionGroups(value: unknown): ToothSelectionGro
if (teeth.length === 0) continue;
out.push({
groupId,
kind: kind === 'connected' && teeth.length < 2 ? 'single' : kind,
kind: normalizeGroupKind(kind, teeth),
teeth,
});
}

View File

@@ -38,7 +38,7 @@ import type {
PaginatedLabCases,
} from '@/types/cases';
const PAGE_SIZE = 20;
const PAGE_SIZE = 10;
export function CasesPage() {
const t = useTranslations('cases');
@@ -308,9 +308,9 @@ export function CasesPage() {
<p className="text-sm text-text-muted mt-1">{t('subtitle')}</p>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr]">
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr] lg:items-stretch">
<section
className={`rounded-lg border border-border bg-surface p-3 sm:p-4 space-y-3 flex flex-col min-h-0 ${
className={`rounded-lg border border-border bg-surface p-3 sm:p-4 space-y-3 flex flex-col min-h-0 h-full lg:min-h-[420px] ${
mobileDetailOpen && selectedCaseId ? 'hidden lg:flex' : 'flex'
}`}
>
@@ -394,13 +394,13 @@ export function CasesPage() {
</Button>
) : null}
<div className="flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto">
{loadingList ? (
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
) : cases.length === 0 ? (
<p className="text-sm text-text-muted">{t('emptyList')}</p>
) : (
<ul className="space-y-2 max-h-[55vh] overflow-y-auto pr-1">
<ul className="space-y-2 pr-1">
{cases.map((item) => {
const isActive = item.id === selectedCaseId;
@@ -466,7 +466,7 @@ export function CasesPage() {
</div>
{pagination.totalPages > 1 ? (
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border">
<div className="flex shrink-0 items-center justify-between gap-2 pt-2 border-t border-border">
<Button
variant="outline"
size="sm"

View File

@@ -208,9 +208,9 @@ export function LabCaseCommentsPanel({
aria-pressed={visibleToClinic}
>
{visibleToClinic ? (
<Eye className="h-4 w-4 text-white stroke-current" />
<Eye className="lucide-on-primary h-4 w-4" color="#fff" />
) : (
<EyeOff className="h-4 w-4 text-white stroke-current" />
<EyeOff className="lucide-on-primary h-4 w-4" color="#fff" />
)}
</button>
) : null}
@@ -222,7 +222,7 @@ export function LabCaseCommentsPanel({
title={t('send')}
aria-label={t('send')}
>
<Send className="h-4 w-4 text-white stroke-current fill-none rtl:-scale-x-100" />
<Send className="lucide-on-primary h-4 w-4 rtl:-scale-x-100" color="#fff" />
</button>
</div>
</div>

View File

@@ -23,7 +23,7 @@ interface FdiToothChartProps {
selected: ReadonlySet<FdiToothId>;
/** Teeth that belong to a connected selection span (dots above/below). */
connectedTeeth?: ReadonlySet<FdiToothId>;
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean; ctrlKey: boolean }) => void;
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void;
disabled?: boolean;
/** Non-interactive display (Cases / connection history). */
readOnly?: boolean;
@@ -144,7 +144,7 @@ export function FdiToothChart({
return accent ? { color: accent } : undefined;
};
/** Dots + thin links — only for Shift-connected spans (not plain / Ctrl singles). */
/** Dots + thin links — only for Shift-connected spans (not plain singles). */
const ConnectedRail = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => {
const hasConnected = teeth.some((fdi) => connectedTeeth?.has(fdi));
if (!hasConnected) return null;
@@ -221,15 +221,12 @@ export function FdiToothChart({
type="button"
disabled={isDisabled}
onMouseDown={(e) => {
// Modifier+click otherwise triggers browser text-selection / sticky focus boxes.
if (e.shiftKey || e.ctrlKey || e.metaKey) e.preventDefault();
// Shift+click otherwise triggers browser text-selection / sticky focus boxes.
if (e.shiftKey) e.preventDefault();
}}
onClick={(e) => {
e.preventDefault();
onToggle?.(fdi, {
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey || e.metaKey,
});
onToggle?.(fdi, { shiftKey: e.shiftKey });
}}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`

View File

@@ -41,7 +41,6 @@ import {
isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules';
import {
applyCtrlRange,
applyShiftRange,
connectedTeethSet,
deriveTeethFromGroups,
@@ -267,6 +266,26 @@ function isDetailsDirty(
return serializeDetails(details) !== savedSnapshot;
}
/** Apply server ids/attachments onto local rows without replacing newer local edits. */
function mergeServerIdsIntoDetails(
local: TreatmentDetailDraft[],
fromServer: TreatmentDetailDraft[],
): TreatmentDetailDraft[] {
const byClientId = new Map(fromServer.map((d) => [d.clientId, d]));
return local.map((d) => {
const s = byClientId.get(d.clientId);
if (!s) return d;
return {
...d,
id: s.id ?? d.id,
labCaseId: s.labCaseId ?? d.labCaseId,
taskProgress: s.taskProgress ?? d.taskProgress,
attachmentMetas:
d.attachmentMetas.length > 0 ? d.attachmentMetas : s.attachmentMetas,
};
});
}
function detailsToPreviewTreatment(
details: TreatmentDetailDraft[],
meta: { title: string; patientId: string; treatmentAt: string; id?: string },
@@ -387,6 +406,8 @@ export function TreatmentWorkspace({
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
const pendingEntryStepRef = useRef<EntryStep | null>(null);
useEffect(() => {
pendingAppointmentIdRef.current = initialAppointmentId;
@@ -613,10 +634,13 @@ export function TreatmentWorkspace({
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
// Reset whole-plan overview when switching details.
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to teeth.
useEffect(() => {
setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null;
setEntryStep('teeth');
const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'teeth');
}, [activeDetailId]);
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
@@ -927,24 +951,40 @@ export function TreatmentWorkspace({
),
});
const mapped = response.data.details.map(mapDetailFromApi);
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
});
setSavedSnapshot(serializeDetails(mapped));
const sentSnapshot = serializeDetails(currentDetails);
const localNow = detailsRef.current;
// If the user changed details while this save was in flight (e.g. removed a
// detail), do not clobber local state with the stale response.
if (serializeDetails(localNow) === sentSnapshot) {
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
});
setSavedSnapshot(serializeDetails(mapped));
} else {
const merged = mergeServerIdsIntoDetails(localNow, mapped);
detailsRef.current = merged;
setDetails(merged);
setActiveDetailId((prev) => {
const stillExists = merged.some((d) => d.clientId === prev);
return stillExists ? prev : merged[0]?.clientId ?? prev;
});
// Keep dirty so the queued autosave persists the newer local state.
}
return response.data;
},
[selectedAppointment, t],
);
const refreshHistory = useCallback(async (patientId: string) => {
const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => {
const requestId = ++historyRequestRef.current;
try {
const response = await treatmentsApi.listPatientHistory(patientId, 50);
if (requestId !== historyRequestRef.current) return;
setHistory(response.data);
void refreshPatientLabCases(patientId);
void refreshPatientLabCases(patientId, { silent: options?.silentLabCases ?? false });
} catch (error: unknown) {
if (requestId !== historyRequestRef.current) return;
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
@@ -1119,10 +1159,16 @@ export function TreatmentWorkspace({
skipNextGetDraftRef.current = true;
draftHydratingRef.current = true;
if (focusDetailClientId && options?.scrollToLabPanel !== false) {
pendingEntryStepRef.current = 'lab';
}
hydrateFromTreatment(treatmentToLoad);
draftHydratingRef.current = false;
if (focusDetailClientId) {
if (options?.scrollToLabPanel !== false) {
pendingEntryStepRef.current = 'lab';
}
setActiveDetailId(focusDetailClientId);
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatmentToLoad.labCases ?? []).map(mapLabCaseDraftFromApi),
@@ -1187,6 +1233,7 @@ export function TreatmentWorkspace({
(item: LabDispatchAttentionItem) => {
if (item.isCurrentDraft) {
exitBrowse();
pendingEntryStepRef.current = 'lab';
setActiveDetailId(item.detailClientId);
const linked = labCaseDrafts.find(
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
@@ -1268,12 +1315,21 @@ export function TreatmentWorkspace({
workspaceMode === 'live' &&
!isBrowsing
) {
pendingEntryStepRef.current = 'lab';
setActiveDetailId(item.detailClientId);
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
if (matchingDraft) {
setActiveLabCaseId(matchingDraft.clientId);
}
setEntryStep('lab');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
return;
}
await loadTreatmentIntoWorkspace(treatment, item.detailClientId, {
scrollToLabPanel: false,
scrollToLabPanel: true,
});
})();
},
@@ -1284,6 +1340,7 @@ export function TreatmentWorkspace({
history,
historyPanelItems,
isBrowsing,
labCaseDrafts,
loadTreatmentIntoWorkspace,
selectedAppointment?.id,
selectedAppointmentId,
@@ -1456,6 +1513,9 @@ export function TreatmentWorkspace({
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
nextDetails[0]?.clientId)
: activeDetailId;
// Sync ref before any persist triggered by lab-case cleanup (same tick).
detailsRef.current = nextDetails;
setDetails(nextDetails);
if (nextActive) setActiveDetailId(nextActive);
@@ -1554,6 +1614,9 @@ export function TreatmentWorkspace({
const activeDetail = details.find((d) => d.clientId === activeDetailId);
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
// Already shipped (or locked) — do not create another draft (avoids post-send flicker).
if (isDetailLocked(activeDetail)) return;
if (labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId && lc.sentAt)) return;
const hasDraft = labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId);
if (hasDraft) return;
void handleAddLabCase();
@@ -1563,6 +1626,7 @@ export function TreatmentWorkspace({
details,
entryStep,
handleAddLabCase,
isDetailLocked,
labCaseDrafts,
labDependentCodes,
selectedAppointment,
@@ -1608,12 +1672,17 @@ export function TreatmentWorkspace({
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
if (draftResponse.data?.details?.length) {
const mapped = draftResponse.data.details.map(mapDetailFromApi);
detailsRef.current = mapped;
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
});
setSavedSnapshot(serializeDetails(mapped));
} else {
const sentDetailClientId = labCase.detailClientId;
setDetails((prev) =>
prev.map((detail) => {
setDetails((prev) => {
const next = prev.map((detail) => {
if (detail.clientId !== sentDetailClientId) return detail;
return {
...detail,
@@ -1624,8 +1693,10 @@ export function TreatmentWorkspace({
? [response.data.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
}),
);
});
detailsRef.current = next;
return next;
});
}
setLabCaseDrafts((prev) =>
@@ -1637,6 +1708,7 @@ export function TreatmentWorkspace({
sentAt: response.data.sentAt,
destinationOrganizationId: response.data.destinationOrganizationId,
sends: response.data.sends,
taskProgress: response.data.taskProgress ?? lc.taskProgress,
}
: lc,
),
@@ -1649,8 +1721,8 @@ export function TreatmentWorkspace({
showSuccess(t('successCaseSent'));
notifyTabBadgesChanged();
if (selectedAppointment.patientId) {
void refreshPatientLabCases(selectedAppointment.patientId);
void refreshHistory(selectedAppointment.patientId);
// Silent: avoid rail/history loading flicker while staying on Lab step.
void refreshHistory(selectedAppointment.patientId, { silentLabCases: true });
}
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
@@ -1663,7 +1735,6 @@ export function TreatmentWorkspace({
selectedAppointment,
persistDraft,
persistLabCases,
refreshPatientLabCases,
refreshHistory,
showSuccess,
showError,
@@ -1959,17 +2030,14 @@ export function TreatmentWorkspace({
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
// Shift wins if both modifiers are held (connected span).
if (event.shiftKey || event.ctrlKey) {
if (event.shiftKey) {
const anchor = rangeAnchorRef.current;
// Need a prior click as range start; modifier alone on one tooth does nothing.
// Need a prior click as range start; shift alone on one tooth does nothing.
if (!anchor || anchor === fdi) {
rangeAnchorRef.current = fdi;
return;
}
nextGroups = event.shiftKey
? applyShiftRange(currentGroups, anchor, fdi)
: applyCtrlRange(currentGroups, anchor, fdi);
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
rangeAnchorRef.current = fdi;
if (!nextGroups) return;
} else {

View File

@@ -354,6 +354,12 @@ select option {
stroke: currentColor;
}
/* Opt out when an icon sits on a primary/filled control (Send, etc.) */
.lucide.lucide-on-primary {
color: #fff;
stroke: currentColor;
}
@keyframes task-row-complete-exit {
0% {
opacity: 1;