@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +25 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/assets/branding/favicon-base64.ts +5 -0
- package/corpus/core/src/cli/templates-meta.ts +1 -0
- package/corpus/core/src/server/email.ts +2 -5
- package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +6 -3
- package/corpus/templates/analytics/server/lib/report-chart-svg.ts +18 -5
- package/corpus/templates/crm/actions/_crm-list-utils.ts +178 -0
- package/corpus/templates/crm/actions/add-crm-record-to-list.ts +23 -2
- package/corpus/templates/crm/actions/create-crm-list.ts +229 -43
- package/corpus/templates/crm/actions/create-crm-record.ts +13 -5
- package/corpus/templates/crm/actions/update-crm-list-entry.ts +1 -1
- package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +22 -2
- package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +143 -32
- package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +190 -75
- package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +267 -115
- package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +265 -131
- package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +79 -100
- package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +12 -8
- package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +27 -31
- package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +16 -13
- package/corpus/templates/crm/app/components/crm/record/attribute-row.tsx +108 -0
- package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +201 -9
- package/corpus/templates/crm/app/components/crm/record/panel-resize.ts +61 -0
- package/corpus/templates/crm/app/components/crm/record/record-data.ts +5 -4
- package/corpus/templates/crm/app/components/crm/shared/README-tokens.md +103 -0
- package/corpus/templates/crm/app/components/crm/shared/RecordReferencePicker.tsx +117 -0
- package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +84 -0
- package/corpus/templates/crm/app/components/crm/shared/ui-tokens.ts +89 -0
- package/corpus/templates/crm/app/global.css +170 -0
- package/corpus/templates/crm/app/i18n/en-US.ts +1 -0
- package/corpus/templates/crm/changelog/2026-07-26-a-half-typed-number-or-date-no-longer-saves-as-empty-the-fie.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-a-new-pipeline-board-now-opens-with-stage-columns-a-summabl.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-record-reference-fields-like-account-can-now-be-linked-from-.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-retyping-a-record-field-now-replaces-the-old-value-instead-o.md +6 -0
- package/dist/assets/branding/favicon-base64.d.ts +2 -0
- package/dist/assets/branding/favicon-base64.d.ts.map +1 -0
- package/dist/assets/branding/favicon-base64.js +5 -0
- package/dist/assets/branding/favicon-base64.js.map +1 -0
- package/dist/cli/templates-meta.d.ts.map +1 -1
- package/dist/cli/templates-meta.js +1 -0
- package/dist/cli/templates-meta.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/email.d.ts.map +1 -1
- package/dist/server/email.js +2 -2
- package/dist/server/email.js.map +1 -1
- package/package.json +1 -1
- package/src/assets/branding/favicon-base64.ts +5 -0
- package/src/cli/templates-meta.ts +1 -0
- package/src/server/email.ts +2 -5
|
@@ -9,10 +9,15 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { useT } from "@agent-native/core/client/i18n";
|
|
12
|
-
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
12
|
+
import { IconAlertTriangle, IconPlus, IconX } from "@tabler/icons-react";
|
|
13
13
|
import { useEffect, useRef, useState } from "react";
|
|
14
14
|
|
|
15
15
|
import { Input } from "@/components/ui/input";
|
|
16
|
+
import {
|
|
17
|
+
Popover,
|
|
18
|
+
PopoverContent,
|
|
19
|
+
PopoverTrigger,
|
|
20
|
+
} from "@/components/ui/popover";
|
|
16
21
|
import {
|
|
17
22
|
Select,
|
|
18
23
|
SelectContent,
|
|
@@ -29,13 +34,20 @@ import type {
|
|
|
29
34
|
import {
|
|
30
35
|
activeOptions,
|
|
31
36
|
editorDraftFor,
|
|
37
|
+
editorInputType,
|
|
38
|
+
referenceMembers,
|
|
39
|
+
referenceSearchKind,
|
|
40
|
+
toggleReferenceValue,
|
|
32
41
|
valueSpecFor,
|
|
33
42
|
} from "../shared/attribute-value";
|
|
34
43
|
import {
|
|
35
44
|
AttributeOptionChip,
|
|
36
45
|
AttributeRating,
|
|
37
46
|
} from "../shared/AttributeValueParts";
|
|
47
|
+
import { RecordReferencePicker } from "../shared/RecordReferencePicker";
|
|
48
|
+
import { overlayProps } from "../shared/ui-tokens";
|
|
38
49
|
import {
|
|
50
|
+
fieldEditability,
|
|
39
51
|
fieldInputValue,
|
|
40
52
|
formatFieldValue,
|
|
41
53
|
parseFieldInput,
|
|
@@ -48,6 +60,7 @@ const CLEAR_OPTION = "__crm_clear__";
|
|
|
48
60
|
type EditableAttribute = Pick<
|
|
49
61
|
CrmAttributeDefinition,
|
|
50
62
|
| "apiSlug"
|
|
63
|
+
| "label"
|
|
51
64
|
| "attributeType"
|
|
52
65
|
| "multi"
|
|
53
66
|
| "options"
|
|
@@ -134,6 +147,9 @@ export function FieldEditor({
|
|
|
134
147
|
const [state, setState] = useState(() => editorDraftFor(undefined, seed));
|
|
135
148
|
const [error, setError] = useState<string | null>(null);
|
|
136
149
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
150
|
+
// Set the moment the input takes focus, cleared by the click that follows.
|
|
151
|
+
// See `selectOnFocus` below.
|
|
152
|
+
const selectedOnFocus = useRef(false);
|
|
137
153
|
|
|
138
154
|
// Re-seeded during render, not from an effect: an effect re-seed lands after
|
|
139
155
|
// the browser has already applied the keystroke it is about to overwrite.
|
|
@@ -146,12 +162,64 @@ export function FieldEditor({
|
|
|
146
162
|
const input = inputRef.current;
|
|
147
163
|
if (!input) return;
|
|
148
164
|
input.focus();
|
|
149
|
-
// Select
|
|
150
|
-
//
|
|
151
|
-
//
|
|
165
|
+
// Select here as well as in `onFocus`, not instead of it. A programmatic
|
|
166
|
+
// `focus()` fires no focus event while the document itself is unfocused
|
|
167
|
+
// (a background tab, a devtools-driven run), so an `onFocus`-only select
|
|
168
|
+
// silently leaves the caret at the end of the old value and the first
|
|
169
|
+
// thing typed is appended to it.
|
|
152
170
|
input.select();
|
|
153
171
|
}, [autoFocus]);
|
|
154
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Select the whole value whenever the field takes focus, so the next thing
|
|
175
|
+
* typed *replaces* it.
|
|
176
|
+
*
|
|
177
|
+
* This hangs off focus rather than off `autoFocus` alone because
|
|
178
|
+
* `FieldEditor` is also rendered as an always-live input (list entry rows),
|
|
179
|
+
* where nothing ever mounts it focused: a caller that does not pass
|
|
180
|
+
* `autoFocus` would otherwise get an editor that appends to the old value.
|
|
181
|
+
* Selecting is the editor's own property, not something each caller has to
|
|
182
|
+
* remember — that is how the same append bug came back twice.
|
|
183
|
+
*
|
|
184
|
+
* The `mouseUp` half matters: when focus came from a click, the browser
|
|
185
|
+
* places the caret on mouse-up and would drop the selection made here.
|
|
186
|
+
*/
|
|
187
|
+
const selectOnFocus = {
|
|
188
|
+
onFocus: (event: React.FocusEvent<HTMLInputElement>) => {
|
|
189
|
+
selectedOnFocus.current = true;
|
|
190
|
+
event.currentTarget.select();
|
|
191
|
+
},
|
|
192
|
+
onMouseUp: (event: React.MouseEvent<HTMLInputElement>) => {
|
|
193
|
+
if (!selectedOnFocus.current) return;
|
|
194
|
+
selectedOnFocus.current = false;
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const input = editorInputType(attribute);
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Commit what the control actually holds, and refuse when it cannot say.
|
|
203
|
+
*
|
|
204
|
+
* `validity.badInput` is the browser reporting "the user typed something I
|
|
205
|
+
* cannot express" — a half-written date, a stray `e` in a number. In that
|
|
206
|
+
* state `value` is the empty string, which `parse` reads as *cleared* and
|
|
207
|
+
* would store as null. Committing it turns a typo into silent data loss, so
|
|
208
|
+
* the editor says so and keeps the value the record already has.
|
|
209
|
+
*/
|
|
210
|
+
function commitFromInput(node: HTMLInputElement) {
|
|
211
|
+
selectedOnFocus.current = false;
|
|
212
|
+
if (node.validity.badInput) {
|
|
213
|
+
setError(
|
|
214
|
+
input.type === "date" || input.type === "datetime-local"
|
|
215
|
+
? t("record.invalidDate")
|
|
216
|
+
: t("record.invalidNumber"),
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
commit(node.value);
|
|
221
|
+
}
|
|
222
|
+
|
|
155
223
|
function commit(raw: string | boolean) {
|
|
156
224
|
const parsed = parseFieldInput(attribute, raw);
|
|
157
225
|
if (!parsed.ok) {
|
|
@@ -171,6 +239,21 @@ export function FieldEditor({
|
|
|
171
239
|
onDone?.();
|
|
172
240
|
}
|
|
173
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Commit a typed value the picker produced directly. It skips text parsing —
|
|
244
|
+
* a reference display name may contain the comma a multi value splits on —
|
|
245
|
+
* but not the editability gate, which is what keeps a doomed write off the
|
|
246
|
+
* wire.
|
|
247
|
+
*/
|
|
248
|
+
function commitValue(next: CrmValue) {
|
|
249
|
+
if (!fieldEditability(attribute).editable) {
|
|
250
|
+
setError(t("record.notEditable"));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
setError(null);
|
|
254
|
+
onCommit(next);
|
|
255
|
+
}
|
|
256
|
+
|
|
174
257
|
const spec = valueSpecFor(attribute);
|
|
175
258
|
|
|
176
259
|
if (spec.control === "checkbox") {
|
|
@@ -209,27 +292,41 @@ export function FieldEditor({
|
|
|
209
292
|
);
|
|
210
293
|
}
|
|
211
294
|
|
|
212
|
-
|
|
213
|
-
|
|
295
|
+
if (spec.control === "reference") {
|
|
296
|
+
return (
|
|
297
|
+
<div className="grid gap-1">
|
|
298
|
+
<ReferenceField
|
|
299
|
+
attribute={attribute}
|
|
300
|
+
value={value}
|
|
301
|
+
autoFocus={autoFocus}
|
|
302
|
+
onCommit={commitValue}
|
|
303
|
+
onDone={onDone}
|
|
304
|
+
/>
|
|
305
|
+
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
214
309
|
|
|
215
310
|
return (
|
|
216
311
|
<div className="grid gap-1">
|
|
217
312
|
<Input
|
|
218
313
|
ref={inputRef}
|
|
219
314
|
className="h-8"
|
|
220
|
-
type={
|
|
315
|
+
type={input.type}
|
|
316
|
+
{...(input.inputMode ? { inputMode: input.inputMode } : {})}
|
|
221
317
|
value={draft}
|
|
222
318
|
aria-label={attribute.apiSlug}
|
|
223
319
|
placeholder={attribute.multi ? t("record.multiValueHint") : undefined}
|
|
224
320
|
maxLength={4_000}
|
|
321
|
+
{...selectOnFocus}
|
|
225
322
|
onChange={(event) =>
|
|
226
323
|
setState({ ...current, draft: event.target.value })
|
|
227
324
|
}
|
|
228
|
-
onBlur={() =>
|
|
325
|
+
onBlur={(event) => commitFromInput(event.currentTarget)}
|
|
229
326
|
onKeyDown={(event) => {
|
|
230
327
|
if (event.key === "Enter") {
|
|
231
328
|
event.preventDefault();
|
|
232
|
-
|
|
329
|
+
commitFromInput(event.currentTarget);
|
|
233
330
|
}
|
|
234
331
|
if (event.key === "Escape") {
|
|
235
332
|
event.preventDefault();
|
|
@@ -243,3 +340,98 @@ export function FieldEditor({
|
|
|
243
340
|
</div>
|
|
244
341
|
);
|
|
245
342
|
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* A link to another record: a searchable popover, and the committed value as
|
|
346
|
+
* chips. `multi` toggles membership instead of replacing, and a chip carries
|
|
347
|
+
* its own unlink control — the popover would otherwise be the only way to
|
|
348
|
+
* remove one, which needs the user to search for a record they can already see.
|
|
349
|
+
*/
|
|
350
|
+
function ReferenceField({
|
|
351
|
+
attribute,
|
|
352
|
+
value,
|
|
353
|
+
autoFocus,
|
|
354
|
+
onCommit,
|
|
355
|
+
onDone,
|
|
356
|
+
}: {
|
|
357
|
+
attribute: EditableAttribute;
|
|
358
|
+
value: CrmValue | undefined;
|
|
359
|
+
/** The row was just activated, so open the search straight away rather than
|
|
360
|
+
* making the user click the value twice to get to a picker. */
|
|
361
|
+
autoFocus: boolean;
|
|
362
|
+
onCommit: (next: CrmValue) => void;
|
|
363
|
+
onDone?: () => void;
|
|
364
|
+
}) {
|
|
365
|
+
const t = useT();
|
|
366
|
+
const [open, setOpen] = useState(autoFocus);
|
|
367
|
+
const members = referenceMembers(value);
|
|
368
|
+
|
|
369
|
+
function pick(displayName: string) {
|
|
370
|
+
onCommit(toggleReferenceValue(value, displayName, attribute.multi));
|
|
371
|
+
if (!attribute.multi) {
|
|
372
|
+
setOpen(false);
|
|
373
|
+
onDone?.();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// `toggleReferenceValue` is a *pick*: on a single reference it replaces, so
|
|
378
|
+
// reusing it here would re-write the value the user is trying to remove.
|
|
379
|
+
function unlink(member: string) {
|
|
380
|
+
onCommit(
|
|
381
|
+
attribute.multi ? toggleReferenceValue(value, member, true) : null,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<Popover
|
|
387
|
+
open={open}
|
|
388
|
+
onOpenChange={(next) => {
|
|
389
|
+
setOpen(next);
|
|
390
|
+
if (!next) onDone?.();
|
|
391
|
+
}}
|
|
392
|
+
>
|
|
393
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
394
|
+
{members.map((member) => (
|
|
395
|
+
<span key={member} className="inline-flex items-center gap-0.5">
|
|
396
|
+
<AttributeOptionChip token={{ label: member }} />
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
className="cursor-pointer text-content-ghost hover:text-foreground"
|
|
400
|
+
aria-label={t("record.clearValue")}
|
|
401
|
+
onClick={() => unlink(member)}
|
|
402
|
+
>
|
|
403
|
+
<IconX className="size-3.5" />
|
|
404
|
+
</button>
|
|
405
|
+
</span>
|
|
406
|
+
))}
|
|
407
|
+
<PopoverTrigger asChild>
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
{...overlayProps({
|
|
411
|
+
className:
|
|
412
|
+
"cursor-pointer rounded-lg px-2 py-1 text-left text-sm",
|
|
413
|
+
})}
|
|
414
|
+
aria-label={attribute.apiSlug}
|
|
415
|
+
>
|
|
416
|
+
{members.length ? (
|
|
417
|
+
<IconPlus className="size-3.5" />
|
|
418
|
+
) : (
|
|
419
|
+
<span className="text-muted-foreground">
|
|
420
|
+
{t("record.fieldEmpty")}
|
|
421
|
+
</span>
|
|
422
|
+
)}
|
|
423
|
+
</button>
|
|
424
|
+
</PopoverTrigger>
|
|
425
|
+
</div>
|
|
426
|
+
<PopoverContent align="start" className="p-0">
|
|
427
|
+
<RecordReferencePicker
|
|
428
|
+
label={attribute.label}
|
|
429
|
+
kind={referenceSearchKind(attribute)}
|
|
430
|
+
selected={members}
|
|
431
|
+
onPick={pick}
|
|
432
|
+
onCancel={() => setOpen(false)}
|
|
433
|
+
/>
|
|
434
|
+
</PopoverContent>
|
|
435
|
+
</Popover>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Width of the record page's left pane.
|
|
3
|
+
*
|
|
4
|
+
* Stored as a percentage rather than pixels: a pixel width saved on a wide
|
|
5
|
+
* monitor reopens as a completely different layout on a laptop. The pane's
|
|
6
|
+
* floors are enforced in CSS (`min-width` / `max-width`) so a window resize
|
|
7
|
+
* cannot strand the main pane below its minimum; this clamp exists so the
|
|
8
|
+
* *stored* value stays sane too.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
RECORD_MAIN_MIN_WIDTH,
|
|
13
|
+
RECORD_PANEL_MIN_WIDTH,
|
|
14
|
+
} from "../shared/ui-tokens";
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_RECORD_PANEL_PERCENT = 32;
|
|
17
|
+
|
|
18
|
+
/** Attio caps the pane at 60% of the record region however wide it gets. */
|
|
19
|
+
const MAX_PANEL_PERCENT = 60;
|
|
20
|
+
|
|
21
|
+
const STORAGE_KEY = "crm.record.panel-percent";
|
|
22
|
+
|
|
23
|
+
export function clampPanelPercent(
|
|
24
|
+
percent: number,
|
|
25
|
+
containerWidth: number,
|
|
26
|
+
): number {
|
|
27
|
+
if (!Number.isFinite(percent)) return DEFAULT_RECORD_PANEL_PERCENT;
|
|
28
|
+
if (!Number.isFinite(containerWidth) || containerWidth <= 0)
|
|
29
|
+
return DEFAULT_RECORD_PANEL_PERCENT;
|
|
30
|
+
const min = (RECORD_PANEL_MIN_WIDTH / containerWidth) * 100;
|
|
31
|
+
const max = Math.min(
|
|
32
|
+
MAX_PANEL_PERCENT,
|
|
33
|
+
((containerWidth - RECORD_MAIN_MIN_WIDTH) / containerWidth) * 100,
|
|
34
|
+
);
|
|
35
|
+
// Too narrow to satisfy both floors: the main pane wins, because a record
|
|
36
|
+
// page whose content is 200px wide is useless in a way a thin panel is not.
|
|
37
|
+
if (max <= min) return Math.max(0, Math.min(100, max));
|
|
38
|
+
return Math.min(max, Math.max(min, percent));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readPanelPercent(): number {
|
|
42
|
+
if (typeof localStorage === "undefined") return DEFAULT_RECORD_PANEL_PERCENT;
|
|
43
|
+
try {
|
|
44
|
+
const stored = Number(localStorage.getItem(STORAGE_KEY));
|
|
45
|
+
return Number.isFinite(stored) && stored > 0
|
|
46
|
+
? stored
|
|
47
|
+
: DEFAULT_RECORD_PANEL_PERCENT;
|
|
48
|
+
} catch {
|
|
49
|
+
return DEFAULT_RECORD_PANEL_PERCENT;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function writePanelPercent(percent: number): void {
|
|
54
|
+
if (typeof localStorage === "undefined") return;
|
|
55
|
+
try {
|
|
56
|
+
localStorage.setItem(STORAGE_KEY, String(percent));
|
|
57
|
+
} catch {
|
|
58
|
+
// A full or blocked localStorage costs the user a remembered width, which
|
|
59
|
+
// is not worth interrupting a drag over.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -96,10 +96,10 @@ export interface CrmRecordPage {
|
|
|
96
96
|
// ---------------------------------------------------------------------------
|
|
97
97
|
|
|
98
98
|
/**
|
|
99
|
-
* Controls this panel implements.
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
99
|
+
* Controls this panel implements. A type whose control is missing here is
|
|
100
|
+
* locked with `unsupported-type` rather than offered a text input that would
|
|
101
|
+
* store whatever the user typed — so this set and the branches in
|
|
102
|
+
* `field-editors.tsx` have to move together.
|
|
103
103
|
*/
|
|
104
104
|
const PANEL_CONTROLS: ReadonlySet<CrmAttributeControl> = new Set([
|
|
105
105
|
"text",
|
|
@@ -109,6 +109,7 @@ const PANEL_CONTROLS: ReadonlySet<CrmAttributeControl> = new Set([
|
|
|
109
109
|
"datetime",
|
|
110
110
|
"options",
|
|
111
111
|
"rating",
|
|
112
|
+
"reference",
|
|
112
113
|
]);
|
|
113
114
|
|
|
114
115
|
export type FieldLockReason =
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# CRM surface tokens
|
|
2
|
+
|
|
3
|
+
The grid, record page, and board all style from this layer. Declared in
|
|
4
|
+
`app/global.css`; the JS-only half is `ui-tokens.ts` in this directory.
|
|
5
|
+
|
|
6
|
+
**Never hardcode a hex, a shadow, or a duration in a component.** If something
|
|
7
|
+
here has no token, add the token rather than the literal.
|
|
8
|
+
|
|
9
|
+
## Cheat sheet
|
|
10
|
+
|
|
11
|
+
| Need | Use |
|
|
12
|
+
| ------------------------ | ----------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| Row height (36px, fixed) | `h-9`, or `var(--crm-row-h)` / `ROW_HEIGHT` for virtualizer math |
|
|
14
|
+
| Header height (40px) | `h-10`, or `var(--crm-header-h)` / `HEADER_HEIGHT` |
|
|
15
|
+
| Header cell padding | `px-3` (12px), border-top **and** border-bottom |
|
|
16
|
+
| Body cell padding | `px-3 pt-2` (`8px 12px 0`) |
|
|
17
|
+
| Divider (both axes) | `border-hairline` — near-invisible by design, do not reach for `border-border` |
|
|
18
|
+
| Hover overlay | `overlayProps()` → `crm-overlay` |
|
|
19
|
+
| Selection overlay | `overlayProps({ selected })`; cells pass `soft: true` |
|
|
20
|
+
| Chip / record-ref | `rounded-chip py-0.5 pr-1.5 pl-1 gap-1.5 self-start` |
|
|
21
|
+
| Status / select pill | `rounded-badge` (4px), tinted fill, **no border** |
|
|
22
|
+
| Avatar | person `rounded-full`, company `rounded-avatar-company` (30%), both `ring-1 ring-inset ring-hairline` |
|
|
23
|
+
| Elevation | `shadow-e1` resting · `shadow-e2` menu/popover/dragging card · `shadow-e3` dialog |
|
|
24
|
+
| Sticky-column shadow | `var(--crm-shadow-sticky)` |
|
|
25
|
+
| Motion | see below |
|
|
26
|
+
|
|
27
|
+
## Type
|
|
28
|
+
|
|
29
|
+
`letter-spacing: -0.02em` is the app default, already applied at `html`. Body
|
|
30
|
+
and caption sizes (`p`, `small`, `.text-sm`, `.text-xs`) relax to `-0.01em`
|
|
31
|
+
automatically. Both rules use `:where()`, so `tracking-ui` / `tracking-body`
|
|
32
|
+
override them wherever a surface needs to be explicit. **Do not add
|
|
33
|
+
`tracking-*` to routine text** — the default is already correct.
|
|
34
|
+
|
|
35
|
+
Weights are 400/500/600. `font-bold` is capped at 600 in the theme, so a stray
|
|
36
|
+
one is harmless, but write `font-semibold`.
|
|
37
|
+
|
|
38
|
+
Grid header labels are `text-sm font-medium text-content-secondary` — same size
|
|
39
|
+
as body, not uppercase, not smaller.
|
|
40
|
+
|
|
41
|
+
Numeric cells: `tabular-nums`.
|
|
42
|
+
|
|
43
|
+
## Content colors
|
|
44
|
+
|
|
45
|
+
Alpha over the surface, not solid greys, so they composite correctly on tinted
|
|
46
|
+
cells and under hover overlays.
|
|
47
|
+
|
|
48
|
+
- `text-foreground` — primary
|
|
49
|
+
- `text-content-secondary` (63%) — header labels, metadata
|
|
50
|
+
- `text-content-tertiary` (50%) — read-only cell values, placeholders
|
|
51
|
+
- `text-content-ghost` (38%) — disabled, empty-value hints
|
|
52
|
+
|
|
53
|
+
Prefer these over `text-muted-foreground` on grid/record/board surfaces.
|
|
54
|
+
|
|
55
|
+
## Radii
|
|
56
|
+
|
|
57
|
+
`rounded-badge` 4 · `rounded-lg` 8 (buttons, menu items) · `rounded-chip` 10 ·
|
|
58
|
+
`rounded-row` 11 · `rounded-card` 12 · `rounded-column` 16 · `rounded-panel` 20.
|
|
59
|
+
|
|
60
|
+
## Motion
|
|
61
|
+
|
|
62
|
+
| Token | Class | ms |
|
|
63
|
+
| ---------------------- | -------------- | ----------------------------------------------------------- |
|
|
64
|
+
| `--motion-fast` | `duration-80` | 80 — popovers/dialogs, with `scale .97→1` |
|
|
65
|
+
| `--motion-comfortable` | `duration-140` | 140 — **the default; bare `transition-*` is already 140ms** |
|
|
66
|
+
| `--motion-breezy` | `duration-200` | 200 — chip hover, sticky-shadow fade |
|
|
67
|
+
| `--motion-sluggish` | `duration-300` | 300 |
|
|
68
|
+
| `--motion-sloth` | `duration-400` | 400 |
|
|
69
|
+
|
|
70
|
+
Use `duration-[var(--motion-breezy)]` when the value must follow the token
|
|
71
|
+
(e.g. inline styles or JS-driven timing); `MOTION.breezy` for `setTimeout`.
|
|
72
|
+
|
|
73
|
+
Rules: animate only color, opacity, box-shadow and transform — **never
|
|
74
|
+
layout**. Never `transition-all`; list the properties. Nothing on an
|
|
75
|
+
interaction exceeds 200ms. `ease-drop` is the drop-confirm curve.
|
|
76
|
+
`prefers-reduced-motion` zeroes all of it globally — do not re-handle it.
|
|
77
|
+
|
|
78
|
+
## The overlay primitive
|
|
79
|
+
|
|
80
|
+
Hover and selection animate the **opacity of an overlay layer**. They are not
|
|
81
|
+
background-color swaps: a swap cannot composite over a tinted status cell and
|
|
82
|
+
cannot cross-fade.
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
import { overlayProps } from "@/components/crm/shared/ui-tokens";
|
|
86
|
+
|
|
87
|
+
<div
|
|
88
|
+
{...overlayProps({
|
|
89
|
+
selected: isSelected,
|
|
90
|
+
className: "flex h-9 items-stretch",
|
|
91
|
+
})}
|
|
92
|
+
/>;
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Content at 4% for hover, accent at 10% for selection; cells pass `soft: true`
|
|
96
|
+
for the 8% content variant. Nesting is intended — a hovered cell inside a
|
|
97
|
+
hovered row stacks to ~8%, which is the behavior we want.
|
|
98
|
+
|
|
99
|
+
`data-hovered="true"` forces the hover state for keyboard focus or drag-over.
|
|
100
|
+
|
|
101
|
+
Selection tint derives from `--crm-accent`, which currently points at
|
|
102
|
+
`--primary` because our skin has no brand hue. Repoint that one var if it gains
|
|
103
|
+
one; nothing else should reference an accent directly.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record search behind every `record-reference` / `actor-reference` editor.
|
|
3
|
+
*
|
|
4
|
+
* It lives here rather than in either surface because a grid cell and a panel
|
|
5
|
+
* row must resolve a link the same way: same action, same scope, same "no
|
|
6
|
+
* matches" wording. Only the wrapper differs — the grid opens it from a cell
|
|
7
|
+
* popover, the panel from a labelled row.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useActionQuery } from "@agent-native/core/client/hooks";
|
|
11
|
+
import { useT } from "@agent-native/core/client/i18n";
|
|
12
|
+
import { IconCheck } from "@tabler/icons-react";
|
|
13
|
+
import { useEffect, useRef, useState } from "react";
|
|
14
|
+
|
|
15
|
+
interface CrmReferenceMatch {
|
|
16
|
+
id: string;
|
|
17
|
+
displayName: string;
|
|
18
|
+
subtitle?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Two characters, so a picker never asks the server for "every record". */
|
|
22
|
+
const MIN_QUERY_LENGTH = 2;
|
|
23
|
+
|
|
24
|
+
export function RecordReferencePicker({
|
|
25
|
+
label,
|
|
26
|
+
kind,
|
|
27
|
+
selected = [],
|
|
28
|
+
onPick,
|
|
29
|
+
onCancel,
|
|
30
|
+
}: {
|
|
31
|
+
/** The attribute's label; shown in the footer hint. */
|
|
32
|
+
label: string;
|
|
33
|
+
/** Narrow the search to one record kind, or `null` to search all kinds. */
|
|
34
|
+
kind: string | null;
|
|
35
|
+
/** Values already linked, so a multi reference can show and toggle them. */
|
|
36
|
+
selected?: string[];
|
|
37
|
+
onPick: (displayName: string) => void;
|
|
38
|
+
onCancel: () => void;
|
|
39
|
+
}) {
|
|
40
|
+
const t = useT();
|
|
41
|
+
const [search, setSearch] = useState("");
|
|
42
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
inputRef.current?.focus();
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const trimmed = search.trim();
|
|
48
|
+
const results = useActionQuery<{ records?: CrmReferenceMatch[] }>(
|
|
49
|
+
"list-crm-records" as never,
|
|
50
|
+
{
|
|
51
|
+
...(trimmed ? { query: trimmed } : {}),
|
|
52
|
+
...(kind ? { kind } : {}),
|
|
53
|
+
limit: 8,
|
|
54
|
+
} as never,
|
|
55
|
+
{ enabled: trimmed.length >= MIN_QUERY_LENGTH } as never,
|
|
56
|
+
);
|
|
57
|
+
const records = results.data?.records ?? [];
|
|
58
|
+
const chosen = new Set(selected);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="w-64">
|
|
62
|
+
<input
|
|
63
|
+
ref={inputRef}
|
|
64
|
+
value={search}
|
|
65
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
66
|
+
onKeyDown={(event) => {
|
|
67
|
+
if (event.key === "Escape") onCancel();
|
|
68
|
+
}}
|
|
69
|
+
placeholder={t("grid.searchRecords")}
|
|
70
|
+
className="w-full border-b border-border/70 bg-transparent px-3 py-2 text-sm outline-none"
|
|
71
|
+
/>
|
|
72
|
+
<div className="max-h-56 overflow-y-auto py-1">
|
|
73
|
+
{trimmed.length < MIN_QUERY_LENGTH ? (
|
|
74
|
+
<p className="px-3 py-2 text-xs text-muted-foreground">
|
|
75
|
+
{t("grid.searchToLink")}
|
|
76
|
+
</p>
|
|
77
|
+
) : results.isError ? (
|
|
78
|
+
// A failed search is said out loud: "no matches" would claim the
|
|
79
|
+
// record does not exist.
|
|
80
|
+
<p className="px-3 py-2 text-xs text-destructive">
|
|
81
|
+
{t("grid.searchFailed")}
|
|
82
|
+
</p>
|
|
83
|
+
) : records.length === 0 ? (
|
|
84
|
+
<p className="px-3 py-2 text-xs text-muted-foreground">
|
|
85
|
+
{results.isLoading ? t("grid.searching") : t("grid.noMatches")}
|
|
86
|
+
</p>
|
|
87
|
+
) : (
|
|
88
|
+
records.map((record) => (
|
|
89
|
+
<button
|
|
90
|
+
key={record.id}
|
|
91
|
+
type="button"
|
|
92
|
+
className="flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-muted"
|
|
93
|
+
onClick={() => onPick(record.displayName)}
|
|
94
|
+
>
|
|
95
|
+
<span className="min-w-0 flex-1">
|
|
96
|
+
<span className="block truncate font-medium">
|
|
97
|
+
{record.displayName}
|
|
98
|
+
</span>
|
|
99
|
+
{record.subtitle ? (
|
|
100
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
101
|
+
{record.subtitle}
|
|
102
|
+
</span>
|
|
103
|
+
) : null}
|
|
104
|
+
</span>
|
|
105
|
+
{chosen.has(record.displayName) ? (
|
|
106
|
+
<IconCheck className="size-3.5 shrink-0" />
|
|
107
|
+
) : null}
|
|
108
|
+
</button>
|
|
109
|
+
))
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
<p className="border-t border-border/60 px-3 py-1.5 text-[11px] text-muted-foreground">
|
|
113
|
+
{t("grid.referenceHint", { attribute: label })}
|
|
114
|
+
</p>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|