@agent-native/core 0.124.0 → 0.124.2

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 (55) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +25 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/assets/branding/favicon-base64.ts +5 -0
  5. package/corpus/core/src/cli/templates-meta.ts +1 -0
  6. package/corpus/core/src/server/email.ts +2 -5
  7. package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +6 -3
  8. package/corpus/templates/analytics/server/lib/report-chart-svg.ts +18 -5
  9. package/corpus/templates/crm/actions/_crm-list-utils.ts +178 -0
  10. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +23 -2
  11. package/corpus/templates/crm/actions/create-crm-list.ts +229 -43
  12. package/corpus/templates/crm/actions/create-crm-record.ts +13 -5
  13. package/corpus/templates/crm/actions/update-crm-list-entry.ts +1 -1
  14. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +22 -2
  15. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +143 -32
  16. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +190 -75
  17. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +267 -115
  18. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +265 -131
  19. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +79 -100
  20. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +12 -8
  21. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +27 -31
  22. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +16 -13
  23. package/corpus/templates/crm/app/components/crm/record/attribute-row.tsx +108 -0
  24. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +201 -9
  25. package/corpus/templates/crm/app/components/crm/record/panel-resize.ts +61 -0
  26. package/corpus/templates/crm/app/components/crm/record/record-data.ts +5 -4
  27. package/corpus/templates/crm/app/components/crm/shared/README-tokens.md +103 -0
  28. package/corpus/templates/crm/app/components/crm/shared/RecordReferencePicker.tsx +117 -0
  29. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +84 -0
  30. package/corpus/templates/crm/app/components/crm/shared/ui-tokens.ts +89 -0
  31. package/corpus/templates/crm/app/global.css +170 -0
  32. package/corpus/templates/crm/app/i18n/en-US.ts +1 -0
  33. package/corpus/templates/crm/changelog/2026-07-26-a-half-typed-number-or-date-no-longer-saves-as-empty-the-fie.md +6 -0
  34. package/corpus/templates/crm/changelog/2026-07-26-a-new-pipeline-board-now-opens-with-stage-columns-a-summabl.md +6 -0
  35. package/corpus/templates/crm/changelog/2026-07-26-record-reference-fields-like-account-can-now-be-linked-from-.md +6 -0
  36. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-record-field-now-replaces-the-old-value-instead-o.md +6 -0
  37. package/dist/assets/branding/favicon-base64.d.ts +2 -0
  38. package/dist/assets/branding/favicon-base64.d.ts.map +1 -0
  39. package/dist/assets/branding/favicon-base64.js +5 -0
  40. package/dist/assets/branding/favicon-base64.js.map +1 -0
  41. package/dist/cli/templates-meta.d.ts.map +1 -1
  42. package/dist/cli/templates-meta.js +1 -0
  43. package/dist/cli/templates-meta.js.map +1 -1
  44. package/dist/collab/routes.d.ts +1 -1
  45. package/dist/collab/struct-routes.d.ts +1 -1
  46. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  47. package/dist/notifications/routes.d.ts +3 -3
  48. package/dist/secrets/routes.d.ts +3 -3
  49. package/dist/server/email.d.ts.map +1 -1
  50. package/dist/server/email.js +2 -2
  51. package/dist/server/email.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/assets/branding/favicon-base64.ts +5 -0
  54. package/src/cli/templates-meta.ts +1 -0
  55. package/src/server/email.ts +2 -5
@@ -1,17 +1,22 @@
1
1
  /**
2
- * The left pane: highlights, then the rest of the object's typed attributes,
3
- * each inline-editable when `update-crm-record` would actually accept it.
2
+ * Highlights, then the rest of the object's typed attributes, each
3
+ * inline-editable when `update-crm-record` would actually accept it.
4
+ *
5
+ * This renders the *sections*; the resizable pane that holds them (and the
6
+ * list memberships below them) belongs to `RecordWorkspace`.
4
7
  */
5
8
 
6
- import { ExtensionSlot } from "@agent-native/core/client/extensions";
7
9
  import { useT } from "@agent-native/core/client/i18n";
8
10
  import { IconChevronRight } from "@tabler/icons-react";
9
11
  import { useState } from "react";
10
12
 
13
+ import { overlayProps } from "@/components/crm/shared/ui-tokens";
14
+
11
15
  import type {
12
16
  CrmAttributeDefinition,
13
17
  CrmValue,
14
18
  } from "../../../../shared/crm-contract";
19
+ import { AttributeRowShell, PANEL_SECTION_HEADING } from "./attribute-row";
15
20
  import { FieldEditor, FieldLockNote, FieldValueDisplay } from "./field-editors";
16
21
  import { FieldHistoryButton } from "./FieldHistory";
17
22
  import {
@@ -37,32 +42,31 @@ export function AttributePanel({
37
42
  values: page.values,
38
43
  });
39
44
 
45
+ const row = (attribute: CrmAttributeDefinition) => (
46
+ <AttributeRow
47
+ key={attribute.id}
48
+ recordId={page.record.id}
49
+ attribute={attribute}
50
+ value={page.values[attribute.apiSlug]}
51
+ meta={page.valueMeta[attribute.apiSlug]}
52
+ isEditing={editingSlug === attribute.apiSlug}
53
+ onStartEdit={() => setEditingSlug(attribute.apiSlug)}
54
+ onDone={() => setEditingSlug(null)}
55
+ onCommit={(value) => onCommit(attribute, value)}
56
+ />
57
+ );
58
+
40
59
  return (
41
- <aside className="flex min-h-full w-full flex-col gap-6 border-border/70 p-5 lg:w-[22rem] lg:shrink-0 lg:border-r">
60
+ <>
42
61
  <section aria-labelledby="crm-record-highlights">
43
- <h2
44
- id="crm-record-highlights"
45
- className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground"
46
- >
62
+ <h2 id="crm-record-highlights" className={PANEL_SECTION_HEADING}>
47
63
  {t("record.highlights")}
48
64
  </h2>
49
- <div className="mt-3 grid gap-0.5">
65
+ <div className="mt-2">
50
66
  {highlights.length ? (
51
- highlights.map((attribute) => (
52
- <AttributeRow
53
- key={attribute.id}
54
- recordId={page.record.id}
55
- attribute={attribute}
56
- value={page.values[attribute.apiSlug]}
57
- meta={page.valueMeta[attribute.apiSlug]}
58
- isEditing={editingSlug === attribute.apiSlug}
59
- onStartEdit={() => setEditingSlug(attribute.apiSlug)}
60
- onDone={() => setEditingSlug(null)}
61
- onCommit={(value) => onCommit(attribute, value)}
62
- />
63
- ))
67
+ highlights.map(row)
64
68
  ) : (
65
- <p className="py-3 text-sm text-muted-foreground">
69
+ <p className="py-3 text-sm text-content-tertiary">
66
70
  {t("record.noAttributes")}
67
71
  </p>
68
72
  )}
@@ -71,41 +75,16 @@ export function AttributePanel({
71
75
 
72
76
  {rest.length ? (
73
77
  <details open className="group/details">
74
- <summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">
78
+ <summary
79
+ className={`flex cursor-pointer list-none items-center gap-1.5 ${PANEL_SECTION_HEADING}`}
80
+ >
75
81
  <IconChevronRight className="size-3.5 transition-transform group-open/details:rotate-90" />
76
82
  {t("record.allAttributes")}
77
83
  </summary>
78
- <div className="mt-3 grid gap-0.5">
79
- {rest.map((attribute) => (
80
- <AttributeRow
81
- key={attribute.id}
82
- recordId={page.record.id}
83
- attribute={attribute}
84
- value={page.values[attribute.apiSlug]}
85
- meta={page.valueMeta[attribute.apiSlug]}
86
- isEditing={editingSlug === attribute.apiSlug}
87
- onStartEdit={() => setEditingSlug(attribute.apiSlug)}
88
- onDone={() => setEditingSlug(null)}
89
- onCommit={(value) => onCommit(attribute, value)}
90
- />
91
- ))}
92
- </div>
84
+ <div className="mt-2">{rest.map(row)}</div>
93
85
  </details>
94
86
  ) : null}
95
-
96
- {/* Stable extension contract: changing this slot id or context is a migration. */}
97
- <ExtensionSlot
98
- id="crm.record-sidebar.bottom"
99
- context={{
100
- recordId: page.record.id,
101
- objectType: page.record.objectType,
102
- kind: page.record.kind,
103
- displayName: page.record.displayName,
104
- provider: page.record.provider,
105
- values: page.values,
106
- }}
107
- />
108
- </aside>
87
+ </>
109
88
  );
110
89
  }
111
90
 
@@ -139,54 +118,54 @@ function AttributeRow({
139
118
  (attribute.attributeType === "status" ||
140
119
  attribute.attributeType === "select")));
141
120
 
121
+ // Provenance rides on the row's tooltip instead of a hover-revealed caption:
122
+ // a caption either reserves ~15px on every row (killing the 36px rhythm) or
123
+ // grows the row on hover, and animating layout is off the table. The history
124
+ // dialog still carries the full actor-and-when list.
125
+ const title = [
126
+ attribute.description ?? attribute.label,
127
+ meta
128
+ ? t("record.lastSetBy", { actor: meta.actorId ?? meta.actorType })
129
+ : "",
130
+ ]
131
+ .filter(Boolean)
132
+ .join(" · ");
133
+
142
134
  return (
143
- <div className="group grid grid-cols-[minmax(6.5rem,0.8fr)_minmax(0,1.2fr)] items-start gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
144
- <div className="flex min-w-0 items-start gap-1 pt-1">
145
- {/* Wrapped, not truncated: a label column this narrow truncates long
146
- attribute names (e.g. "Desired Cadence Days") past readability, and
147
- a hover-only tooltip would hide the same text at rest. Wrapping
148
- keeps the two-column grid and just grows the row. */}
149
- <span
150
- className="text-sm text-muted-foreground"
151
- title={attribute.description ?? attribute.label}
152
- >
153
- {attribute.label}
154
- </span>
155
- {attribute.historyTracked ? (
135
+ <AttributeRowShell
136
+ type={attribute.attributeType}
137
+ label={attribute.label}
138
+ title={title}
139
+ affordance={
140
+ attribute.historyTracked ? (
156
141
  <FieldHistoryButton recordId={recordId} attribute={attribute} />
157
- ) : null}
158
- </div>
159
- <div className="min-w-0 text-sm">
160
- {alwaysLive || isEditing ? (
161
- <FieldEditor
162
- attribute={attribute}
163
- value={value}
164
- autoFocus={isEditing}
165
- onCommit={onCommit}
166
- onDone={onDone}
167
- />
168
- ) : editability.editable ? (
169
- <button
170
- type="button"
171
- className="w-full cursor-pointer rounded px-1 py-1 text-left hover:bg-background"
172
- onClick={onStartEdit}
173
- >
174
- <FieldValueDisplay attribute={attribute} value={value} />
175
- </button>
176
- ) : (
177
- <div className="grid gap-1 px-1 py-1">
178
- <FieldValueDisplay attribute={attribute} value={value} />
179
- <FieldLockNote reason={editability.reason} />
180
- </div>
181
- )}
182
- {meta && !isEditing ? (
183
- <p className="mt-0.5 px-1 text-[11px] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
184
- {t("record.lastSetBy", {
185
- actor: meta.actorId ?? meta.actorType,
186
- })}
187
- </p>
188
- ) : null}
189
- </div>
190
- </div>
142
+ ) : null
143
+ }
144
+ >
145
+ {alwaysLive || isEditing ? (
146
+ <FieldEditor
147
+ attribute={attribute}
148
+ value={value}
149
+ autoFocus={isEditing}
150
+ onCommit={onCommit}
151
+ onDone={onDone}
152
+ />
153
+ ) : editability.editable ? (
154
+ <button
155
+ type="button"
156
+ {...overlayProps({
157
+ className: "w-full cursor-pointer rounded-lg px-2 py-1 text-left",
158
+ })}
159
+ onClick={onStartEdit}
160
+ >
161
+ <FieldValueDisplay attribute={attribute} value={value} />
162
+ </button>
163
+ ) : (
164
+ <div className="grid gap-1 px-2 py-1">
165
+ <FieldValueDisplay attribute={attribute} value={value} />
166
+ <FieldLockNote reason={editability.reason} />
167
+ </div>
168
+ )}
169
+ </AttributeRowShell>
191
170
  );
192
171
  }
@@ -31,15 +31,17 @@ export function RecordHeader({
31
31
  : undefined;
32
32
 
33
33
  return (
34
- <header className="border-b border-border/70 px-5 py-4 sm:px-6">
35
- <div className="flex flex-wrap items-start justify-between gap-4">
34
+ /* 104px: the record header's fixed band. `min-h` rather than `h` so a
35
+ wrapped title or a second row of badges grows it instead of clipping. */
36
+ <header className="flex min-h-[6.5rem] items-center border-b border-hairline px-5 py-4 sm:px-6">
37
+ <div className="flex w-full flex-wrap items-center justify-between gap-4">
36
38
  <div className="flex min-w-0 items-center gap-3">
37
39
  <RecordAvatar name={record.displayName} kind={record.kind} />
38
40
  <div className="min-w-0">
39
- <h1 className="truncate text-xl font-semibold tracking-tight">
41
+ <h1 className="truncate text-xl font-semibold">
40
42
  {record.displayName}
41
43
  </h1>
42
- <div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
44
+ <div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-content-secondary">
43
45
  <span className="capitalize">{record.kind}</span>
44
46
  {record.stage ? (
45
47
  <Badge variant="secondary" className="font-normal">
@@ -69,7 +71,7 @@ export function RecordHeader({
69
71
  </Button>
70
72
  ) : null}
71
73
  {providerLabel && !recordUrl && recordUrlUnavailableReason ? (
72
- <span className="text-xs text-muted-foreground">
74
+ <span className="text-xs text-content-tertiary">
73
75
  {t("record.upstreamLinkUnavailable", {
74
76
  provider: providerLabel,
75
77
  reason: recordUrlUnavailableReason,
@@ -90,12 +92,14 @@ function RecordAvatar({ name, kind }: { name: string; kind: string }) {
90
92
  .slice(0, 2)
91
93
  .map((part) => part[0]?.toUpperCase() ?? "")
92
94
  .join("");
95
+ // Person avatars are circles, company avatars a 30% squircle — the shape is
96
+ // how the two record kinds stay distinguishable at a glance.
93
97
  return (
94
98
  <div
95
99
  aria-hidden
96
- className={`grid size-10 shrink-0 place-items-center text-sm font-semibold text-muted-foreground ${
97
- kind === "person" ? "rounded-full" : "rounded-lg"
98
- } bg-muted`}
100
+ className={`grid size-10 shrink-0 place-items-center bg-muted text-sm font-medium uppercase text-content-secondary ring-1 ring-inset ring-hairline ${
101
+ kind === "person" ? "rounded-full" : "rounded-avatar-company"
102
+ }`}
99
103
  >
100
104
  {initials || "?"}
101
105
  </div>
@@ -26,6 +26,7 @@ import {
26
26
  } from "@/components/ui/select";
27
27
 
28
28
  import type { CrmValue } from "../../../../shared/crm-contract";
29
+ import { AttributeRowShell, PANEL_SECTION_HEADING } from "./attribute-row";
29
30
  import { FieldEditor } from "./field-editors";
30
31
  import { FieldHistoryButton } from "./FieldHistory";
31
32
  import {
@@ -57,12 +58,9 @@ export function RecordLists({
57
58
  }) {
58
59
  const t = useT();
59
60
  return (
60
- <section className="grid gap-3" aria-labelledby="crm-record-lists">
61
- <div className="flex items-center justify-between gap-2">
62
- <h2
63
- id="crm-record-lists"
64
- className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground"
65
- >
61
+ <section className="grid gap-2" aria-labelledby="crm-record-lists">
62
+ <div className="flex min-h-9 items-center justify-between gap-2">
63
+ <h2 id="crm-record-lists" className={PANEL_SECTION_HEADING}>
66
64
  {t("record.lists")}
67
65
  </h2>
68
66
  <AddToListControl page={page} />
@@ -77,12 +75,12 @@ export function RecordLists({
77
75
  />
78
76
  ))
79
77
  ) : (
80
- <p className="text-sm text-muted-foreground">
78
+ <p className="text-sm text-content-tertiary">
81
79
  {t("record.listsEmpty")}
82
80
  </p>
83
81
  )}
84
82
  {page.listMembershipsTruncated ? (
85
- <p className="text-xs text-muted-foreground">
83
+ <p className="text-xs text-content-tertiary">
86
84
  {t("record.listsTruncated")}
87
85
  </p>
88
86
  ) : null}
@@ -105,49 +103,47 @@ function ListCard({
105
103
  }) {
106
104
  const t = useT();
107
105
  return (
108
- <div className="rounded-lg border border-border/70 bg-card">
109
- <div className="flex items-center justify-between gap-2 border-b border-border/70 px-3 py-2">
106
+ <div className="rounded-card border border-hairline bg-card">
107
+ <div className="flex min-h-9 items-center justify-between gap-2 border-b border-hairline px-3">
110
108
  <Link
111
109
  to={`/lists/${encodeURIComponent(list.id)}`}
112
110
  className="truncate text-sm font-medium hover:underline"
113
111
  >
114
112
  {list.name}
115
113
  </Link>
116
- <span className="shrink-0 text-xs text-muted-foreground">
114
+ <span className="shrink-0 text-xs tabular-nums text-content-tertiary">
117
115
  {t("record.entryCount", { count: list.entries.length })}
118
116
  </span>
119
117
  </div>
120
- <div className="divide-y divide-border/70">
118
+ <div className="divide-y divide-hairline">
121
119
  {list.entries.map((entry, index) => (
122
- <div key={entry.id} className="grid gap-1.5 px-3 py-2.5">
120
+ <div key={entry.id} className="px-1 py-1">
123
121
  {list.entries.length > 1 ? (
124
- <p className="text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
122
+ <p className="px-2 py-1 text-xs text-content-tertiary">
125
123
  {t("record.entryOrdinal", { index: index + 1 })}
126
124
  </p>
127
125
  ) : null}
128
126
  {list.attributes.length ? (
129
127
  list.attributes.map((attribute) => (
130
- <div
128
+ /* An entry attribute is the list's own workflow value, not the
129
+ record's — an opportunity's `Stage` and a pipeline's `Stage`
130
+ are different fields, and moving one does not move the other.
131
+ This label still reads identically to the object attribute
132
+ above; qualifying it needs a new i18n key, which is reported
133
+ rather than added here. */
134
+ <AttributeRowShell
131
135
  key={attribute.id}
132
- className="group grid grid-cols-[minmax(5rem,0.7fr)_minmax(0,1.3fr)] items-center gap-2"
133
- >
134
- <div className="flex min-w-0 items-start gap-1">
135
- {/* An entry attribute is the list's own workflow value, not
136
- the record's — an opportunity's `Stage` and a pipeline's
137
- `Stage` are different fields, and moving one does not
138
- move the other. This label still reads identically to
139
- the object attribute in the left panel; qualifying it
140
- needs a new i18n key, which is reported rather than
141
- added here. */}
142
- <span className="min-w-0 text-xs text-muted-foreground">
143
- {attribute.label}
144
- </span>
136
+ type={attribute.attributeType}
137
+ label={attribute.label}
138
+ title={attribute.description ?? attribute.label}
139
+ affordance={
145
140
  <FieldHistoryButton
146
141
  recordId={recordId}
147
142
  entryId={entry.id}
148
143
  attribute={entryAttributeAsEditable(attribute)}
149
144
  />
150
- </div>
145
+ }
146
+ >
151
147
  <FieldEditor
152
148
  attribute={entryAttributeAsEditable(attribute)}
153
149
  value={entry.values[attribute.apiSlug]}
@@ -155,10 +151,10 @@ function ListCard({
155
151
  onEntryCommit(entry.id, attribute, value)
156
152
  }
157
153
  />
158
- </div>
154
+ </AttributeRowShell>
159
155
  ))
160
156
  ) : (
161
- <p className="text-xs text-muted-foreground">
157
+ <p className="px-2 py-1 text-xs text-content-tertiary">
162
158
  {t("record.listNoAttributes")}
163
159
  </p>
164
160
  )}
@@ -16,6 +16,7 @@ import {
16
16
  } from "@tabler/icons-react";
17
17
  import { Link } from "react-router";
18
18
 
19
+ import { overlayProps } from "@/components/crm/shared/ui-tokens";
19
20
  import { Badge } from "@/components/ui/badge";
20
21
  import { Button } from "@/components/ui/button";
21
22
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -45,7 +46,7 @@ export function RecordTabs({
45
46
  <Tabs
46
47
  value={tab}
47
48
  onValueChange={(next) => onTabChange(next as RecordTab)}
48
- className="flex min-h-full flex-col p-5 sm:p-6"
49
+ className="flex flex-col p-5 sm:p-6"
49
50
  >
50
51
  <TabsList className="h-9 self-start bg-muted/70">
51
52
  <TabsTrigger value="activity" className="gap-1.5">
@@ -73,16 +74,16 @@ export function RecordTabs({
73
74
  description={t("record.activityNotIngestedDescription")}
74
75
  />
75
76
  ) : (
76
- <div className="divide-y divide-border rounded-lg border border-border/70 bg-card">
77
+ <div className="divide-y divide-hairline rounded-card border border-hairline bg-card">
77
78
  {activity.items.map((item) => (
78
79
  <div key={item.id} className="px-4 py-3">
79
80
  <p className="text-sm font-medium">{item.title}</p>
80
81
  {item.summary ? (
81
- <p className="mt-1 text-sm text-muted-foreground">
82
+ <p className="mt-1 text-sm text-content-secondary">
82
83
  {item.summary}
83
84
  </p>
84
85
  ) : null}
85
- <p className="mt-2 text-xs text-muted-foreground">
86
+ <p className="mt-2 text-xs text-content-secondary">
86
87
  {[
87
88
  item.actor,
88
89
  item.occurredAt ? formatDate(item.occurredAt) : undefined,
@@ -104,13 +105,13 @@ export function RecordTabs({
104
105
  </TabsContent>
105
106
 
106
107
  <TabsContent value="tasks" className="mt-4 max-w-2xl">
107
- <div className="divide-y divide-border rounded-lg border border-border/70 bg-card">
108
+ <div className="divide-y divide-hairline rounded-card border border-hairline bg-card">
108
109
  {tasks.length ? (
109
110
  tasks.map((task) => (
110
111
  <div key={task.id} className="flex items-center gap-3 px-4 py-3">
111
112
  <div className="min-w-0 flex-1">
112
113
  <p className="text-sm font-medium">{task.title}</p>
113
- <p className="mt-0.5 text-xs text-muted-foreground">
114
+ <p className="mt-0.5 text-xs text-content-secondary">
114
115
  {task.dueAt
115
116
  ? t("record.taskDue", { when: formatDate(task.dueAt) })
116
117
  : task.status}
@@ -132,7 +133,7 @@ export function RecordTabs({
132
133
  </div>
133
134
  ))
134
135
  ) : (
135
- <p className="px-4 py-8 text-center text-sm text-muted-foreground">
136
+ <p className="px-4 py-8 text-center text-sm text-content-secondary">
136
137
  {t("record.tasksEmpty")}
137
138
  </p>
138
139
  )}
@@ -140,19 +141,21 @@ export function RecordTabs({
140
141
  </TabsContent>
141
142
 
142
143
  <TabsContent value="related" className="mt-4 max-w-2xl">
143
- <div className="divide-y divide-border rounded-lg border border-border/70 bg-card">
144
+ <div className="divide-y divide-hairline rounded-card border border-hairline bg-card">
144
145
  {record.relatedRecords?.length ? (
145
146
  record.relatedRecords.map((related) => (
146
147
  <Link
147
148
  key={`${related.id}:${related.relationshipType}`}
148
149
  to={`/records/${encodeURIComponent(related.id)}`}
149
- className="flex items-center gap-3 px-4 py-3 hover:bg-muted/50"
150
+ {...overlayProps({
151
+ className: "flex items-center gap-3 px-4 py-3",
152
+ })}
150
153
  >
151
154
  <div className="min-w-0 flex-1">
152
155
  <p className="truncate text-sm font-medium">
153
156
  {related.displayName}
154
157
  </p>
155
- <p className="mt-0.5 truncate text-xs text-muted-foreground">
158
+ <p className="mt-0.5 truncate text-xs text-content-secondary">
156
159
  {[
157
160
  related.relationshipLabel ?? related.relationshipType,
158
161
  related.subtitle,
@@ -167,7 +170,7 @@ export function RecordTabs({
167
170
  </Link>
168
171
  ))
169
172
  ) : (
170
- <p className="px-4 py-8 text-center text-sm text-muted-foreground">
173
+ <p className="px-4 py-8 text-center text-sm text-content-secondary">
171
174
  {t("record.relatedEmpty")}
172
175
  </p>
173
176
  )}
@@ -185,9 +188,9 @@ function ComingSoon({
185
188
  description: string;
186
189
  }) {
187
190
  return (
188
- <div className="rounded-lg border border-dashed border-border/70 bg-card px-5 py-8 text-center">
191
+ <div className="rounded-card border border-dashed border-hairline bg-card px-5 py-8 text-center">
189
192
  <p className="text-sm font-medium">{title}</p>
190
- <p className="mx-auto mt-1.5 max-w-md text-sm leading-6 text-muted-foreground">
193
+ <p className="mx-auto mt-1.5 max-w-md text-sm leading-6 text-content-secondary">
191
194
  {description}
192
195
  </p>
193
196
  </div>
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The label/value row shared by the record's object attributes and by a list
3
+ * entry's own attributes. One shell, so a pipeline value and a record value
4
+ * cannot drift into looking like two different products.
5
+ *
6
+ * Geometry: a 30%/1fr split, a 20px icon box with a 14px glyph at a 7px gap,
7
+ * and a hairline under every row but the last.
8
+ */
9
+
10
+ import {
11
+ IconActivity,
12
+ IconAlignLeft,
13
+ IconCalendar,
14
+ IconCircleDot,
15
+ IconClock,
16
+ IconCurrencyDollar,
17
+ IconHash,
18
+ IconId,
19
+ IconLink,
20
+ IconMail,
21
+ IconMapPin,
22
+ IconPhone,
23
+ IconSquareCheck,
24
+ IconStar,
25
+ IconTag,
26
+ IconUser,
27
+ IconWorld,
28
+ } from "@tabler/icons-react";
29
+
30
+ import { overlayProps } from "@/components/crm/shared/ui-tokens";
31
+
32
+ import type { CrmAttributeType } from "../../../../shared/crm-attributes";
33
+
34
+ const TYPE_ICONS: Record<CrmAttributeType, typeof IconAlignLeft> = {
35
+ text: IconAlignLeft,
36
+ number: IconHash,
37
+ checkbox: IconSquareCheck,
38
+ currency: IconCurrencyDollar,
39
+ date: IconCalendar,
40
+ timestamp: IconClock,
41
+ rating: IconStar,
42
+ status: IconCircleDot,
43
+ select: IconTag,
44
+ "record-reference": IconLink,
45
+ "actor-reference": IconUser,
46
+ location: IconMapPin,
47
+ domain: IconWorld,
48
+ "email-address": IconMail,
49
+ "phone-number": IconPhone,
50
+ interaction: IconActivity,
51
+ "personal-name": IconId,
52
+ };
53
+
54
+ /** Section headings sit at body size in the secondary content color — the same
55
+ * relationship the grid's column headers use. Small uppercase micro-labels are
56
+ * the tell we are moving away from. */
57
+ export const PANEL_SECTION_HEADING =
58
+ "text-sm font-medium text-content-secondary";
59
+
60
+ export function AttributeTypeIcon({ type }: { type: CrmAttributeType }) {
61
+ const Icon = TYPE_ICONS[type] ?? IconAlignLeft;
62
+ return (
63
+ <span
64
+ aria-hidden
65
+ className="grid size-5 shrink-0 place-items-center text-content-tertiary"
66
+ >
67
+ <Icon className="size-3.5" />
68
+ </span>
69
+ );
70
+ }
71
+
72
+ /**
73
+ * `min-h-9` rather than a fixed 36px: a long attribute name ("Desired Cadence
74
+ * Days") in a 30% column has to go somewhere, and truncating it hides the one
75
+ * piece of text the row exists to identify. The row grows; it never clips.
76
+ */
77
+ export function AttributeRowShell({
78
+ type,
79
+ label,
80
+ title,
81
+ affordance,
82
+ children,
83
+ }: {
84
+ type: CrmAttributeType;
85
+ label: string;
86
+ title?: string;
87
+ affordance?: React.ReactNode;
88
+ children: React.ReactNode;
89
+ }) {
90
+ return (
91
+ <div
92
+ {...overlayProps({
93
+ className:
94
+ "group grid min-h-9 grid-cols-[30%_1fr] items-center gap-2 rounded-card py-1 pl-[7px] pr-3 [&:not(:last-child)]:border-b [&:not(:last-child)]:border-hairline",
95
+ })}
96
+ title={title}
97
+ >
98
+ <div className="flex min-w-0 items-center gap-[7px]">
99
+ <AttributeTypeIcon type={type} />
100
+ <span className="min-w-0 break-words text-sm text-content-secondary">
101
+ {label}
102
+ </span>
103
+ {affordance}
104
+ </div>
105
+ <div className="min-w-0 text-sm">{children}</div>
106
+ </div>
107
+ );
108
+ }