@agent-native/core 0.124.0 → 0.124.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +6 -3
  5. package/corpus/templates/analytics/server/lib/report-chart-svg.ts +18 -5
  6. package/corpus/templates/crm/actions/_crm-list-utils.ts +178 -0
  7. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +23 -2
  8. package/corpus/templates/crm/actions/create-crm-list.ts +229 -43
  9. package/corpus/templates/crm/actions/create-crm-record.ts +13 -5
  10. package/corpus/templates/crm/actions/update-crm-list-entry.ts +1 -1
  11. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +22 -2
  12. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +143 -32
  13. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +190 -75
  14. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +267 -115
  15. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +265 -131
  16. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +79 -100
  17. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +12 -8
  18. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +27 -31
  19. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +16 -13
  20. package/corpus/templates/crm/app/components/crm/record/attribute-row.tsx +108 -0
  21. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +201 -9
  22. package/corpus/templates/crm/app/components/crm/record/panel-resize.ts +61 -0
  23. package/corpus/templates/crm/app/components/crm/record/record-data.ts +5 -4
  24. package/corpus/templates/crm/app/components/crm/shared/README-tokens.md +103 -0
  25. package/corpus/templates/crm/app/components/crm/shared/RecordReferencePicker.tsx +117 -0
  26. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +84 -0
  27. package/corpus/templates/crm/app/components/crm/shared/ui-tokens.ts +89 -0
  28. package/corpus/templates/crm/app/global.css +170 -0
  29. package/corpus/templates/crm/app/i18n/en-US.ts +1 -0
  30. package/corpus/templates/crm/changelog/2026-07-26-a-half-typed-number-or-date-no-longer-saves-as-empty-the-fie.md +6 -0
  31. package/corpus/templates/crm/changelog/2026-07-26-a-new-pipeline-board-now-opens-with-stage-columns-a-summabl.md +6 -0
  32. package/corpus/templates/crm/changelog/2026-07-26-record-reference-fields-like-account-can-now-be-linked-from-.md +6 -0
  33. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-record-field-now-replaces-the-old-value-instead-o.md +6 -0
  34. package/dist/collab/routes.d.ts +1 -1
  35. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  36. package/dist/observability/routes.d.ts +1 -1
  37. package/package.json +1 -1
@@ -9,12 +9,11 @@
9
9
  */
10
10
 
11
11
  import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
12
- import { useT } from "@agent-native/core/client/i18n";
12
+ import { useFormatters, useT } from "@agent-native/core/client/i18n";
13
13
  import {
14
14
  IconAlertTriangle,
15
15
  IconClock,
16
16
  IconClockExclamation,
17
- IconGripVertical,
18
17
  } from "@tabler/icons-react";
19
18
  import { useQueries, useQuery } from "@tanstack/react-query";
20
19
  import { useEffect, useMemo, useState, type ReactNode } from "react";
@@ -32,9 +31,12 @@ import {
32
31
  TableHeader,
33
32
  TableRow,
34
33
  } from "@/components/ui/table";
34
+ import { cn } from "@/lib/utils";
35
35
 
36
+ import type { CrmAttributeType } from "../../../../shared/crm-attributes";
36
37
  import { ProvenanceMarker } from "../grid/GridCell";
37
38
  import type { CrmCellProvenance } from "../grid/model";
39
+ import { AttributeTypeIcon } from "../record/attribute-row";
38
40
  import {
39
41
  currencyCodeOf,
40
42
  formatAttributeValue,
@@ -46,6 +48,7 @@ import {
46
48
  AttributeOptionChip,
47
49
  AttributeRating,
48
50
  } from "../shared/AttributeValueParts";
51
+ import { MOTION, overlayProps } from "../shared/ui-tokens";
49
52
  import {
50
53
  BOARD_UNGROUPED,
51
54
  boardCardSla,
@@ -127,6 +130,32 @@ interface RecordsResponse {
127
130
 
128
131
  const PAGE_LIMIT = 100;
129
132
 
133
+ /**
134
+ * Time in stage, compactly: "3d", "14d", "+5d". `Intl` owns the unit, so a
135
+ * locale that does not abbreviate days still reads correctly (ar-SA renders
136
+ * "١٤ ي") without a per-locale string of our own. The long sentence stays as
137
+ * the row's `title`.
138
+ */
139
+ const DAY_UNIT: Intl.NumberFormatOptions = {
140
+ style: "unit",
141
+ unit: "day",
142
+ unitDisplay: "narrow",
143
+ };
144
+
145
+ /**
146
+ * Drop confirmation: the accent tint lands instantly on the card that just
147
+ * committed, then eases back to rest. Board-local rather than a shared motion
148
+ * token — nothing else in the app confirms a drop, and 900ms is far outside
149
+ * the interaction scale the other surfaces share.
150
+ *
151
+ * It REPLACES the resting `bg-card` rather than being layered on top of it.
152
+ * Two `bg-*` utilities on one element are decided by their order in the
153
+ * generated stylesheet, not by the order they are written, and the theme
154
+ * colour wins — so a flash appended to the class list silently paints nothing.
155
+ */
156
+ const CARD_REST_CLASS = "bg-card";
157
+ const DROP_FLASH_CLASS = "bg-[hsl(var(--crm-accent)/0.12)] transition-none";
158
+
130
159
  export interface CrmBoardTarget {
131
160
  kind: "list" | "object";
132
161
  /** The list id for a list target, the object type for an object target. */
@@ -502,6 +531,16 @@ export function CrmBoard(props: CrmBoardProps) {
502
531
  const now = useMemo(() => new Date(), [cards]);
503
532
  const overruns = useMemo(() => boardOverruns(columns, now), [columns, now]);
504
533
 
534
+ // The confirmed card's id, held only long enough for the tint to land. It is
535
+ // set after the commit resolves, never on the optimistic paint: the flash
536
+ // means "this move is saved", so a move that later rolls back never flashes.
537
+ const [flashCardId, setFlashCardId] = useState<string | null>(null);
538
+ useEffect(() => {
539
+ if (!flashCardId) return;
540
+ const timer = setTimeout(() => setFlashCardId(null), MOTION.fast);
541
+ return () => clearTimeout(timer);
542
+ }, [flashCardId]);
543
+
505
544
  async function move(cardId: string, toValue: string) {
506
545
  const result = await moveBoardCard({
507
546
  cards,
@@ -519,7 +558,10 @@ export function CrmBoard(props: CrmBoardProps) {
519
558
  );
520
559
  return;
521
560
  }
522
- if (result.moved) data.refetch();
561
+ if (result.moved) {
562
+ setFlashCardId(cardId);
563
+ data.refetch();
564
+ }
523
565
  }
524
566
 
525
567
  if (data.error) {
@@ -542,7 +584,7 @@ export function CrmBoard(props: CrmBoardProps) {
542
584
  return (
543
585
  <div className="flex min-h-0 flex-col">
544
586
  <div className="flex flex-wrap items-center gap-2 px-5 pt-4 sm:px-7">
545
- <span className="text-xs text-muted-foreground">
587
+ <span className="text-xs text-content-tertiary">
546
588
  {t("board.groupedBy", { attribute: data.groupAttribute.label })}
547
589
  </span>
548
590
  {overruns.length ? (
@@ -555,7 +597,7 @@ export function CrmBoard(props: CrmBoardProps) {
555
597
  </Badge>
556
598
  ) : null}
557
599
  {data.complete ? null : (
558
- <span className="text-xs text-muted-foreground">
600
+ <span className="text-xs text-content-tertiary">
559
601
  {t("board.partialPage", { limit: PAGE_LIMIT })}
560
602
  </span>
561
603
  )}
@@ -563,12 +605,13 @@ export function CrmBoard(props: CrmBoardProps) {
563
605
  {props.mode === "table" ? (
564
606
  <BoardTable columns={columns} now={now} />
565
607
  ) : (
566
- <div className="flex gap-3 overflow-x-auto p-5 sm:p-7">
608
+ <div className="flex gap-4 overflow-x-auto p-5 sm:p-7">
567
609
  {columns.map((column, index) => (
568
610
  <BoardColumnView
569
611
  key={column.key}
570
612
  column={column}
571
613
  now={now}
614
+ flashCardId={flashCardId}
572
615
  onDropCard={(cardId) => void move(cardId, column.key)}
573
616
  onMoveCard={(cardId, offset) => {
574
617
  const next = columns[index + offset];
@@ -591,9 +634,9 @@ function BoardNotice({
591
634
  }) {
592
635
  const t = useT();
593
636
  return (
594
- <div className="m-5 flex flex-wrap items-center gap-3 rounded-lg border border-border/70 bg-card p-4 sm:m-7">
595
- <IconAlertTriangle className="size-4 text-muted-foreground" />
596
- <p className="text-sm text-muted-foreground">{message}</p>
637
+ <div className="m-5 flex flex-wrap items-center gap-3 rounded-card border border-hairline bg-card p-4 sm:m-7">
638
+ <IconAlertTriangle className="size-4 text-content-tertiary" />
639
+ <p className="text-sm text-content-secondary">{message}</p>
597
640
  {onRetry ? (
598
641
  <Button size="sm" variant="outline" onClick={onRetry}>
599
642
  {t("board.retry")}
@@ -603,14 +646,23 @@ function BoardNotice({
603
646
  );
604
647
  }
605
648
 
649
+ /**
650
+ * A column is 300px of nothing: no trough, no border, the page background. The
651
+ * cards carry the only boundary on the board, which is what keeps a wide
652
+ * pipeline from reading as a row of grey boxes. Its one visual state is
653
+ * drag-over, and that rides the shared overlay so it cross-fades rather than
654
+ * swapping a background.
655
+ */
606
656
  function BoardColumnView({
607
657
  column,
608
658
  now,
659
+ flashCardId,
609
660
  onDropCard,
610
661
  onMoveCard,
611
662
  }: {
612
663
  column: BoardColumn;
613
664
  now: Date;
665
+ flashCardId: string | null;
614
666
  onDropCard: (cardId: string) => void;
615
667
  onMoveCard: (cardId: string, offset: -1 | 1) => void;
616
668
  }) {
@@ -620,14 +672,22 @@ function BoardColumnView({
620
672
 
621
673
  return (
622
674
  <section
623
- className={`flex w-72 shrink-0 flex-col rounded-lg border bg-muted/30 ${
624
- isOver ? "border-primary/60 bg-primary/5" : "border-border/70"
625
- }`}
675
+ {...overlayProps({
676
+ selected: isOver,
677
+ className: "flex w-75 shrink-0 flex-col rounded-column",
678
+ })}
626
679
  onDragOver={(event) => {
627
680
  event.preventDefault();
628
681
  setIsOver(true);
629
682
  }}
630
- onDragLeave={() => setIsOver(false)}
683
+ onDragLeave={(event) => {
684
+ // dragleave also fires crossing into a child, which would strobe the
685
+ // drop tint over every card the cursor passes.
686
+ if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
687
+ return;
688
+ }
689
+ setIsOver(false);
690
+ }}
631
691
  onDrop={(event) => {
632
692
  event.preventDefault();
633
693
  setIsOver(false);
@@ -635,27 +695,22 @@ function BoardColumnView({
635
695
  if (cardId) onDropCard(cardId);
636
696
  }}
637
697
  >
638
- <header className="flex flex-col gap-1 border-b border-border/60 px-3 py-2.5">
639
- <div className="flex items-center justify-between gap-2">
640
- <div className="flex min-w-0 items-center gap-2">
641
- {column.option?.color ? (
642
- <span
643
- aria-hidden
644
- className="size-2 shrink-0 rounded-full"
645
- style={{ backgroundColor: column.option.color }}
646
- />
647
- ) : null}
648
- <p className="truncate text-sm font-medium">
649
- {columnTitle(column, t)}
650
- </p>
651
- </div>
652
- <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
653
- {totals.count}
654
- </span>
655
- </div>
656
- <ColumnTotals totals={totals} />
698
+ {/* Dot, name, count read as one cluster. Pushing the count to the column's
699
+ far edge turns an invisible column into an implied box the whole point
700
+ of dropping the trough was to remove. */}
701
+ <header className="flex h-9 shrink-0 items-center gap-2">
702
+ <StageDot color={column.option?.color} />
703
+ <p className="min-w-0 truncate text-sm font-semibold">
704
+ {columnTitle(column, t)}
705
+ </p>
706
+ <span className="shrink-0 rounded-badge bg-muted px-1.5 py-0.5 text-xs tabular-nums text-content-secondary">
707
+ {totals.count}
708
+ </span>
657
709
  </header>
658
- <div className="flex min-h-24 flex-col gap-2 p-2">
710
+ <ColumnTotals totals={totals} />
711
+ {/* The 8px fade is exactly the list's own top padding, so it is invisible
712
+ at rest and only bites once a card scrolls under the header. */}
713
+ <div className="flex max-h-[70svh] min-h-24 flex-col gap-2 overflow-y-auto pt-2 [mask-image:linear-gradient(to_bottom,transparent,black_8px)]">
659
714
  {column.cards.map((card) => (
660
715
  <BoardCardView
661
716
  key={card.id}
@@ -663,11 +718,12 @@ function BoardColumnView({
663
718
  option={column.option}
664
719
  columnLabel={columnTitle(column, t)}
665
720
  now={now}
721
+ flash={card.id === flashCardId}
666
722
  onMove={(offset) => onMoveCard(card.id, offset)}
667
723
  />
668
724
  ))}
669
725
  {column.cards.length ? null : (
670
- <p className="px-1 py-3 text-xs text-muted-foreground">
726
+ <p className="py-3 text-xs text-content-tertiary">
671
727
  {t("board.columnEmpty")}
672
728
  </p>
673
729
  )}
@@ -676,6 +732,20 @@ function BoardColumnView({
676
732
  );
677
733
  }
678
734
 
735
+ /** The stage's own colour, and the board's only decorative motion. */
736
+ function StageDot({ color }: { color?: string | undefined }) {
737
+ return (
738
+ <span
739
+ aria-hidden
740
+ className={cn(
741
+ "size-2.5 shrink-0 rounded-full transition-transform hover:scale-120",
742
+ color ? null : "ring-1 ring-inset ring-hairline",
743
+ )}
744
+ {...(color ? { style: { backgroundColor: color } } : {})}
745
+ />
746
+ );
747
+ }
748
+
679
749
  function ColumnTotals({
680
750
  totals,
681
751
  }: {
@@ -684,14 +754,16 @@ function ColumnTotals({
684
754
  const t = useT();
685
755
  if (totals.mixedCurrency) {
686
756
  return (
687
- <p className="text-xs text-muted-foreground">
757
+ <p className="text-xs text-content-tertiary">
688
758
  {t("board.mixedCurrency")}
689
759
  </p>
690
760
  );
691
761
  }
762
+ // No summable amount means no total line at all. A "0" here would read as a
763
+ // real, empty pipeline stage.
692
764
  if (totals.sum === null) return null;
693
765
  return (
694
- <p className="flex flex-wrap items-baseline gap-x-1.5 text-xs text-muted-foreground">
766
+ <p className="flex flex-wrap items-baseline gap-x-1.5 text-xs text-content-tertiary">
695
767
  <span className="font-medium tabular-nums text-foreground">
696
768
  {formatMoney(totals.sum, totals.currencyCode)}
697
769
  </span>
@@ -707,21 +779,31 @@ function BoardCardView({
707
779
  option,
708
780
  columnLabel,
709
781
  now,
782
+ flash,
710
783
  onMove,
711
784
  }: {
712
785
  card: BoardCard;
713
786
  option: BoardOption | null;
714
787
  columnLabel: string;
715
788
  now: Date;
789
+ flash: boolean;
716
790
  onMove: (offset: -1 | 1) => void;
717
791
  }) {
718
792
  const t = useT();
793
+ const [dragging, setDragging] = useState(false);
719
794
  const sla = boardCardSla(card, option, now);
720
795
  const shown = card.attributes.flatMap((attribute) => {
721
796
  const node = cardAttributeNode(attribute, t);
722
797
  return node === null
723
798
  ? []
724
- : [{ slug: attribute.slug, label: attribute.label, node }];
799
+ : [
800
+ {
801
+ slug: attribute.slug,
802
+ label: attribute.label,
803
+ attributeType: attribute.attributeType,
804
+ node,
805
+ },
806
+ ];
725
807
  });
726
808
  // The record/entry has no per-field provenance at this data layer, only who
727
809
  // created it — so this is coarser than the grid's per-cell marker, but the
@@ -741,7 +823,9 @@ function BoardCardView({
741
823
  onDragStart={(event) => {
742
824
  event.dataTransfer.setData("text/plain", card.id);
743
825
  event.dataTransfer.effectAllowed = "move";
826
+ setDragging(true);
744
827
  }}
828
+ onDragEnd={() => setDragging(false)}
745
829
  onKeyDown={(event) => {
746
830
  // Keyboard parity with the drag: a card can be moved without a mouse.
747
831
  if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
@@ -749,7 +833,13 @@ function BoardCardView({
749
833
  onMove(event.key === "ArrowLeft" ? -1 : 1);
750
834
  }
751
835
  }}
752
- className="group relative cursor-grab rounded-md border border-border/70 bg-card p-2.5 shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
836
+ {...overlayProps({
837
+ className: cn(
838
+ "group flex cursor-grab flex-col gap-1.5 rounded-lg border border-hairline p-3 outline-none transition-[background-color] duration-[900ms] ease-drop focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing",
839
+ dragging && "shadow-e2",
840
+ flash ? DROP_FLASH_CLASS : CARD_REST_CLASS,
841
+ ),
842
+ })}
753
843
  >
754
844
  {/* A zero-size anchor: `ProvenanceMarker`'s own wedge is absolutely
755
845
  positioned at its containing block's bottom-right corner, so this is
@@ -758,52 +848,71 @@ function BoardCardView({
758
848
  <span className="absolute right-1.5 top-1.5 size-0">
759
849
  <ProvenanceMarker provenance={provenance} attributeLabel={card.title} />
760
850
  </span>
761
- <div className="flex items-start gap-1.5">
762
- <IconGripVertical className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/50" />
763
- <div className="min-w-0 flex-1">
764
- <Link
765
- to={`/records/${encodeURIComponent(card.recordId)}`}
766
- className="block truncate text-sm font-medium hover:underline"
767
- >
768
- {card.title}
769
- </Link>
770
- {card.subtitle ? (
771
- <p className="truncate text-xs text-muted-foreground">
772
- {card.subtitle}
773
- </p>
774
- ) : null}
851
+ {/* 16px avatar + 8px gap and the 20px type-icon box + 4px gap below both
852
+ come to a 24px gutter, so the title, the subtitle and every value
853
+ share one left edge. */}
854
+ <div className="flex items-center gap-2">
855
+ <span
856
+ aria-hidden
857
+ className="grid size-4 shrink-0 place-items-center rounded-full bg-muted text-[10px] font-medium text-content-secondary ring-1 ring-inset ring-hairline"
858
+ >
859
+ {initials(card.title).slice(0, 1)}
860
+ </span>
861
+ <Link
862
+ to={`/records/${encodeURIComponent(card.recordId)}`}
863
+ className="min-w-0 flex-1 truncate text-sm font-medium underline decoration-content-ghost underline-offset-2 hover:decoration-current"
864
+ >
865
+ {card.title}
866
+ </Link>
867
+ </div>
868
+ {card.subtitle ? (
869
+ <p className="flex items-center gap-1 text-xs text-content-tertiary">
870
+ <span aria-hidden className="size-5 shrink-0" />
871
+ <span className="min-w-0 flex-1 truncate">{card.subtitle}</span>
872
+ </p>
873
+ ) : null}
874
+ {/* One row per value, each led by its attribute type's glyph in a fixed
875
+ gutter. The glyph is what carries the label, so the name itself is
876
+ only announced, not printed — a 300px card has no room for both. */}
877
+ {card.amount !== null || shown.length ? (
878
+ <dl className="flex flex-col gap-1.5">
775
879
  {card.amount !== null ? (
776
- <p className="mt-0.5 text-xs font-medium tabular-nums text-foreground">
777
- {formatMoney(card.amount, card.currencyCode)}
778
- </p>
880
+ <div className="flex items-center gap-1">
881
+ <AttributeTypeIcon type="currency" />
882
+ <dt className="sr-only">{t("board.table.amount")}</dt>
883
+ <dd className="min-w-0 flex-1 truncate text-sm font-medium tabular-nums">
884
+ {formatMoney(card.amount, card.currencyCode)}
885
+ </dd>
886
+ </div>
779
887
  ) : null}
780
- </div>
781
- </div>
782
- {shown.length ? (
783
- <dl className="mt-2 grid gap-1">
784
888
  {shown.map((attribute) => (
785
- <div key={attribute.slug} className="flex items-baseline gap-2">
786
- <dt className="w-20 shrink-0 truncate text-[11px] uppercase tracking-wide text-muted-foreground">
787
- {attribute.label}
788
- </dt>
789
- <dd className="min-w-0 flex-1 truncate text-xs">
889
+ <div
890
+ key={attribute.slug}
891
+ className="flex items-center gap-1"
892
+ title={attribute.label}
893
+ >
894
+ <AttributeTypeIcon
895
+ type={attribute.attributeType as CrmAttributeType}
896
+ />
897
+ <dt className="sr-only">{attribute.label}</dt>
898
+ <dd className="min-w-0 flex-1 truncate text-sm">
790
899
  {attribute.node}
791
900
  </dd>
792
901
  </div>
793
902
  ))}
794
903
  </dl>
795
904
  ) : null}
796
- <footer className="mt-2 flex items-center justify-between gap-2">
905
+ <footer className="flex items-center justify-between gap-2">
797
906
  <div className="flex min-w-0 items-center gap-1.5">
798
907
  {card.owner ? (
799
908
  <>
800
909
  <span
801
910
  aria-hidden
802
- className="grid size-5 shrink-0 place-items-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground"
911
+ className="grid size-5 shrink-0 place-items-center rounded-full bg-muted text-[10px] font-medium text-content-secondary ring-1 ring-inset ring-hairline"
803
912
  >
804
913
  {initials(card.owner)}
805
914
  </span>
806
- <span className="truncate text-xs text-muted-foreground">
915
+ <span className="truncate text-xs text-content-secondary">
807
916
  {card.owner}
808
917
  </span>
809
918
  </>
@@ -854,30 +963,36 @@ function cardAttributeNode(
854
963
  return text || null;
855
964
  }
856
965
 
966
+ /**
967
+ * Time in stage, in the card footer's right slot. The compact form is what
968
+ * fits; the full sentence stays reachable as the title, so "14d" never has to
969
+ * carry the whole meaning on its own.
970
+ */
857
971
  function SlaBadge({ sla }: { sla: ReturnType<typeof boardCardSla> }) {
858
972
  const t = useT();
973
+ const { formatNumber } = useFormatters();
859
974
  if (sla.status === "not-tracked") return null;
860
975
  if (sla.status === "unknown") {
861
976
  return (
862
- <span
863
- className="text-muted-foreground/60"
864
- title={t(`board.sla.${sla.reason}`)}
865
- >
977
+ <span className="text-content-ghost" title={t(`board.sla.${sla.reason}`)}>
866
978
  <IconClock className="size-3.5" />
867
979
  </span>
868
980
  );
869
981
  }
870
982
  if (sla.status === "within") {
871
983
  return (
872
- <span className="flex items-center gap-1 text-xs tabular-nums text-muted-foreground">
984
+ <span
985
+ className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-content-tertiary"
986
+ title={t("board.daysInStage", { days: sla.days })}
987
+ >
873
988
  <IconClock className="size-3.5" />
874
- {t("board.daysInStage", { days: sla.days })}
989
+ {formatNumber(sla.days, DAY_UNIT)}
875
990
  </span>
876
991
  );
877
992
  }
878
993
  return (
879
994
  <span
880
- className="flex items-center gap-1 text-xs font-medium tabular-nums text-amber-700 dark:text-amber-400"
995
+ className="flex shrink-0 items-center gap-1 text-xs font-medium tabular-nums text-amber-700 dark:text-amber-400"
881
996
  title={t("board.overrunDetail", {
882
997
  days: sla.days,
883
998
  overBy: sla.overBy,
@@ -885,7 +1000,7 @@ function SlaBadge({ sla }: { sla: ReturnType<typeof boardCardSla> }) {
885
1000
  })}
886
1001
  >
887
1002
  <IconClockExclamation className="size-3.5" />
888
- {t("board.overBy", { overBy: sla.overBy })}
1003
+ {formatNumber(sla.overBy, { ...DAY_UNIT, signDisplay: "always" })}
889
1004
  </span>
890
1005
  );
891
1006
  }