@crisp-ui-kit/crisp 0.57.2 → 0.57.3

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 (53) hide show
  1. package/dist/button.css +3 -0
  2. package/dist/button.d.ts +2 -2
  3. package/dist/button.layered.css +3 -0
  4. package/dist/chip.css +1 -1
  5. package/dist/chip.layered.css +1 -1
  6. package/dist/cjs/components/breadcrumb/breadcrumb.js +1 -1
  7. package/dist/cjs/components/button/button.recipe.js +28 -0
  8. package/dist/cjs/components/chip/chip.recipe.js +3 -1
  9. package/dist/cjs/components/datatable/datatable.recipe.js +10 -4
  10. package/dist/cjs/components/datepickerfield/datepickerfield.js +2 -2
  11. package/dist/cjs/components/datepickerfield/datepickerfield.recipe.js +16 -3
  12. package/dist/cjs/components/menu/menu.js +18 -2
  13. package/dist/cjs/components/table/table.recipe.js +7 -1
  14. package/dist/cjs/core/floating-portal.js +7 -4
  15. package/dist/cjs/product/inbox/inbox.js +11 -3
  16. package/dist/cjs/product/recordpage/index.js +2 -1
  17. package/dist/cjs/product/recordpage/recordpage.js +49 -3
  18. package/dist/cjs/product/recordpage/recordpage.recipe.js +25 -0
  19. package/dist/datatable.css +10 -4
  20. package/dist/datatable.layered.css +10 -4
  21. package/dist/datepickerfield.css +8 -2
  22. package/dist/datepickerfield.d.ts +2 -2
  23. package/dist/datepickerfield.layered.css +8 -2
  24. package/dist/esm/components/breadcrumb/breadcrumb.js +1 -1
  25. package/dist/esm/components/button/button.recipe.js +28 -0
  26. package/dist/esm/components/chip/chip.recipe.js +3 -1
  27. package/dist/esm/components/datatable/datatable.recipe.js +10 -4
  28. package/dist/esm/components/datepickerfield/datepickerfield.js +2 -2
  29. package/dist/esm/components/datepickerfield/datepickerfield.recipe.js +16 -3
  30. package/dist/esm/components/menu/menu.js +18 -2
  31. package/dist/esm/components/table/table.recipe.js +7 -1
  32. package/dist/esm/core/floating-portal.js +7 -4
  33. package/dist/esm/product/inbox/inbox.js +11 -3
  34. package/dist/esm/product/recordpage/index.js +1 -1
  35. package/dist/esm/product/recordpage/recordpage.js +49 -4
  36. package/dist/esm/product/recordpage/recordpage.recipe.js +25 -0
  37. package/dist/recordpage.css +23 -0
  38. package/dist/recordpage.layered.css +23 -0
  39. package/dist/styles.css +52 -9
  40. package/dist/styles.layered.css +52 -9
  41. package/dist/table.css +7 -2
  42. package/dist/table.layered.css +7 -2
  43. package/dist/types/components/breadcrumb/breadcrumb.d.ts +9 -1
  44. package/dist/types/components/button/button.d.ts +2 -0
  45. package/dist/types/components/button/button.recipe.d.ts +13 -0
  46. package/dist/types/components/chip/chip.recipe.d.ts +1 -1
  47. package/dist/types/components/datepickerfield/datepickerfield.d.ts +4 -1
  48. package/dist/types/components/datepickerfield/datepickerfield.recipe.d.ts +18 -3
  49. package/dist/types/core/floating-portal.d.ts +6 -5
  50. package/dist/types/product/inbox/inbox.d.ts +13 -1
  51. package/dist/types/product/recordpage/index.d.ts +1 -1
  52. package/dist/types/product/recordpage/recordpage.d.ts +46 -0
  53. package/package.json +1 -1
@@ -1,9 +1,9 @@
1
1
  .crisp-datepickerfield {
2
2
  display: inline-flex; font-family: var(--crisp-font-sans);
3
- & .crisp-datepickerfield-box { display: inline-flex; align-items: center; box-sizing: border-box; height: 32px; margin: 0; border-radius: var(--crisp-radius-md); background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 7%, transparent); color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); }
3
+ & .crisp-datepickerfield-box { display: inline-flex; align-items: center; width: 100%; box-sizing: border-box; height: 32px; margin: 0; border-radius: var(--crisp-radius-md); background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 7%, transparent); color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); }
4
4
  & .crisp-datepickerfield-box[data-ghost] { background: transparent; box-shadow: none; }
5
5
  & .crisp-datepickerfield-box[data-ghost]:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); }
6
- & .crisp-datepickerfield-trigger { display: inline-flex; align-items: center; gap: var(--crisp-space-2); height: 100%; padding: 0 var(--crisp-space-2-5); margin: 0; border: none; background: none; color: inherit; font-family: inherit; font-size: inherit; white-space: nowrap; cursor: pointer; }
6
+ & .crisp-datepickerfield-trigger { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: var(--crisp-space-2); height: 100%; padding: 0 var(--crisp-space-2-5); margin: 0; border: none; background: none; color: inherit; font-family: inherit; font-size: inherit; white-space: nowrap; cursor: pointer; }
7
7
  & .crisp-datepickerfield-box[data-placeholder] .crisp-datepickerfield-trigger { color: var(--crisp-fg-muted); }
8
8
  & .crisp-datepickerfield-trigger svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
9
9
  /* Reuses TableToolbar's measured filter-chip remove button
@@ -18,3 +18,9 @@
18
18
  & .crisp-datepickerfield-preset { height: var(--crisp-size-chip); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-chip); background: var(--crisp-bg-raised); color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-sm); cursor: pointer; }
19
19
  & .crisp-datepickerfield-preset:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); }
20
20
  }
21
+ .crisp-datepickerfield--width_full {
22
+ width: 100%;
23
+ }
24
+ .crisp-datepickerfield--width_auto {
25
+ width: auto;
26
+ }
@@ -1,7 +1,7 @@
1
1
  import type { RecipeFn } from "../core/types";
2
2
 
3
3
  export interface DatepickerfieldVariants {
4
-
4
+ width?: "full" | "auto";
5
5
  }
6
6
 
7
- export declare const datepickerfieldRecipe: RecipeFn<{ }>;
7
+ export declare const datepickerfieldRecipe: RecipeFn<{ width: { full: string; auto: string } }>;
@@ -1,10 +1,10 @@
1
1
  @layer crisp-components {
2
2
  .crisp-datepickerfield {
3
3
  display: inline-flex; font-family: var(--crisp-font-sans);
4
- & .crisp-datepickerfield-box { display: inline-flex; align-items: center; box-sizing: border-box; height: 32px; margin: 0; border-radius: var(--crisp-radius-md); background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 7%, transparent); color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); }
4
+ & .crisp-datepickerfield-box { display: inline-flex; align-items: center; width: 100%; box-sizing: border-box; height: 32px; margin: 0; border-radius: var(--crisp-radius-md); background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 7%, transparent); color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); }
5
5
  & .crisp-datepickerfield-box[data-ghost] { background: transparent; box-shadow: none; }
6
6
  & .crisp-datepickerfield-box[data-ghost]:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); }
7
- & .crisp-datepickerfield-trigger { display: inline-flex; align-items: center; gap: var(--crisp-space-2); height: 100%; padding: 0 var(--crisp-space-2-5); margin: 0; border: none; background: none; color: inherit; font-family: inherit; font-size: inherit; white-space: nowrap; cursor: pointer; }
7
+ & .crisp-datepickerfield-trigger { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: var(--crisp-space-2); height: 100%; padding: 0 var(--crisp-space-2-5); margin: 0; border: none; background: none; color: inherit; font-family: inherit; font-size: inherit; white-space: nowrap; cursor: pointer; }
8
8
  & .crisp-datepickerfield-box[data-placeholder] .crisp-datepickerfield-trigger { color: var(--crisp-fg-muted); }
9
9
  & .crisp-datepickerfield-trigger svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
10
10
  /* Reuses TableToolbar's measured filter-chip remove button
@@ -19,4 +19,10 @@
19
19
  & .crisp-datepickerfield-preset { height: var(--crisp-size-chip); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-chip); background: var(--crisp-bg-raised); color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-sm); cursor: pointer; }
20
20
  & .crisp-datepickerfield-preset:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); }
21
21
  }
22
+ .crisp-datepickerfield--width_full {
23
+ width: 100%;
24
+ }
25
+ .crisp-datepickerfield--width_auto {
26
+ width: auto;
27
+ }
22
28
  }
@@ -19,7 +19,7 @@ export function Breadcrumb({ items, separator, "aria-label": ariaLabelProp, clas
19
19
  return (_jsx("nav", { "aria-label": ariaLabel, className: breadcrumbRecipe({ className }), ...rest, children: items.map((item, i) => {
20
20
  const last = i === items.length - 1;
21
21
  const content = (_jsxs(_Fragment, { children: [item.icon, item.label] }));
22
- return (_jsxs(React.Fragment, { children: [last ? (_jsx("span", { className: "crisp-breadcrumb-item", "aria-current": "page", children: content })) : item.href ? (_jsx("a", { className: "crisp-breadcrumb-item", href: item.href, children: content })) : (_jsx("button", { type: "button", className: "crisp-breadcrumb-item", onClick: item.onClick, children: content })), !last && _jsx("span", { className: "crisp-breadcrumb-sep", children: sep })] }, i));
22
+ return (_jsxs(React.Fragment, { children: [last ? (_jsx("span", { className: "crisp-breadcrumb-item", "aria-current": "page", children: content })) : item.href ? (_jsx("a", { className: "crisp-breadcrumb-item", href: item.href, onClick: item.onClick, children: content })) : (_jsx("button", { type: "button", className: "crisp-breadcrumb-item", onClick: item.onClick, children: content })), !last && _jsx("span", { className: "crisp-breadcrumb-sep", children: sep })] }, i));
23
23
  }) }));
24
24
  }
25
25
  Breadcrumb.displayName = "Breadcrumb";
@@ -121,6 +121,34 @@ const spec = defineSpec({
121
121
  fontSize: c.text.md,
122
122
  borderRadius: c.radius.md,
123
123
  },
124
+ // crisp#688: Attio's own 20px small button, split out of #682 rather
125
+ // than redefining `sm` -- 72 existing `size="sm"` call sites (sidebar,
126
+ // comments, recordpage, workspacegeneralsettings, personchip,
127
+ // datatable) already rely on sm's 24px, unverified against `xs`'s own
128
+ // context. Box measured live on app.attio.com Home/Tasks ("View all",
129
+ // "Today, <date>", the date-stepper's Previous/Next-day icon
130
+ // buttons): 20px height, 6px radius, 6px inline padding on text
131
+ // buttons (a 20x20 icon-only button centers a 16px icon the same way
132
+ // shape="icon" already does for every other size, so it needs no
133
+ // extra rule), 4px gap between multiple children.
134
+ //
135
+ // The label itself is NOT 14px/#682's tracking -- getComputedStyle on
136
+ // the live button (fonts loaded, DPR 2, zero zoom/transform anywhere
137
+ // in the ancestor chain) reads 10px/regular/15px line-height/normal
138
+ // tracking, independently matching `recordpanel.recipe.ts`'s own
139
+ // "View all" measurement (10px/400) elsewhere in this codebase. 10px
140
+ // sits below the text scale's 11px floor, so it's a literal here, the
141
+ // same as recordpanel's.
142
+ xs: {
143
+ height: "20px",
144
+ paddingInline: c.space["1.5"],
145
+ fontSize: "10px",
146
+ fontWeight: c.weight.regular,
147
+ lineHeight: "15px",
148
+ letterSpacing: "normal",
149
+ borderRadius: c.radius.sm,
150
+ gap: c.space["1"],
151
+ },
124
152
  md: {
125
153
  decls: {
126
154
  height: "28px",
@@ -73,7 +73,9 @@ const spec = defineSpec({
73
73
  selector: "& .crisp-chip-label",
74
74
  decls: {
75
75
  minWidth: 0,
76
- overflow: "hidden",
76
+ // #719: `clip`, not `hidden` -- immune to a programmatic
77
+ // `scrollLeft` assignment; renders the ellipsis identically.
78
+ overflow: "clip",
77
79
  textOverflow: "ellipsis",
78
80
  whiteSpace: "nowrap",
79
81
  display: "block",
@@ -113,7 +113,13 @@ const config = defineRecipe({
113
113
  & .crisp-datatable-group-count { margin-inline-start: ${c.space["1.5"]}; color: ${c.fgMuted}; font-variant-numeric: tabular-nums; }
114
114
  & .crisp-datatable-th-rename { width: 100%; min-width: 0; height: ${c.size.row}; margin: 0; padding: 0 ${c.space["1"]}; border: none; border-radius: ${c.radius.md}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; font-family: inherit; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; outline: none; }
115
115
  & .crisp-datatable-th-rename:focus { box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; }
116
- & .crisp-datatable-th-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
116
+ /* #719: overflow: clip, not hidden, on every truncation rule below --
117
+ hidden still honours a programmatic scrollLeft assignment (found via
118
+ a screenshot tool that pans anything with scrollWidth > clientWidth),
119
+ blanking or fragmenting the visible text even though real user
120
+ scrolling was already blocked. clip renders the ellipsis identically
121
+ and leaves no scroll position to move. */
122
+ & .crisp-datatable-th-label { overflow: clip; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
117
123
  /* #465: 12px per the comment above (and Attio's measured header cell
118
124
  padding: 0 12px) -- was still c.space["2"] (8px), drifted from its
119
125
  own documented value. */
@@ -200,7 +206,7 @@ const config = defineRecipe({
200
206
  /* reference picker: a leading 16px avatar per option, plus a trailing
201
207
  "Create X" row when the consumer supplies one (#449) -- same measured
202
208
  values as RecordsTable's own .crisp-recordstable-refoption*. */
203
- & .crisp-datatable-refoption { display: flex; align-items: center; gap: ${c.space["2"]}; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
209
+ & .crisp-datatable-refoption { display: flex; align-items: center; gap: ${c.space["2"]}; min-width: 0; overflow: clip; text-overflow: ellipsis; }
204
210
  & .crisp-datatable-refoption-avatar { flex-shrink: 0; }
205
211
  & .crisp-datatable-refoption--create { color: ${c.fgMuted}; }
206
212
  & .crisp-datatable-empty { padding: ${c.space["8"]} ${c.space["3"]}; text-align: center; color: ${c.fgMuted}; font-size: ${c.text.lg}; }
@@ -286,7 +292,7 @@ const config = defineRecipe({
286
292
  (#833/#835/#848) that happened to apply the same 500 to every
287
293
  primary-column body cell regardless of type. Made explicit here so
288
294
  fixing that leak doesn't also (wrongly) drop entity names to 400. */
289
- & .crisp-datatable-entity-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: ${c.weight.medium}; text-decoration: underline; text-decoration-color: color-mix(in srgb, ${c.fgPrimary} 9%, transparent); }
295
+ & .crisp-datatable-entity-name { overflow: clip; text-overflow: ellipsis; white-space: nowrap; font-weight: ${c.weight.medium}; text-decoration: underline; text-decoration-color: color-mix(in srgb, ${c.fgPrimary} 9%, transparent); }
290
296
  /* #893: shared by the button (onEntityClick, a different record) and
291
297
  the anchor (href, the row's own record) -- was button-only, which
292
298
  silently left a real <a> unstyled the day #893 added one. */
@@ -332,7 +338,7 @@ const config = defineRecipe({
332
338
  own .crisp-recordstable-link, which this mirrors: no underline at
333
339
  rest, underline on hover, own-colour text rather than a browser-blue
334
340
  link. */
335
- & .crisp-datatable-link { color: ${c.fgPrimary}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
341
+ & .crisp-datatable-link { color: ${c.fgPrimary}; text-decoration: none; overflow: clip; text-overflow: ellipsis; }
336
342
  & .crisp-datatable-link:hover { text-decoration: underline; }
337
343
  /* phone cell type/editor (#765) -- measured live: the dial-code prefix
338
344
  is a muted rgba(0,0,0,0.63) at 14px, matching every other muted-text
@@ -22,7 +22,7 @@ const fromDate = (d) => ({
22
22
  * popover. This is what `DatePicker` used to be; `DatePicker` is now
23
23
  * seed-design's bare calendar, and this composes it.
24
24
  */
25
- export function DatePickerField({ value, defaultValue, onValueChange, onChange, placeholder: placeholderProp, variant = "default", format = defaultFormat, clearable = true, clearLabel: clearLabelProp, presets, calendarProps, "aria-label": ariaLabel, className, ...rest }) {
25
+ export function DatePickerField({ value, defaultValue, onValueChange, onChange, placeholder: placeholderProp, variant = "default", width = "full", format = defaultFormat, clearable = true, clearLabel: clearLabelProp, presets, calendarProps, "aria-label": ariaLabel, className, ...rest }) {
26
26
  // crisp/#471: same resolution order as Select's own.
27
27
  const placeholder = useCrispMessage(placeholderProp, "Set date…");
28
28
  const clearLabel = useCrispMessage(clearLabelProp, "Clear date");
@@ -43,7 +43,7 @@ export function DatePickerField({ value, defaultValue, onValueChange, onChange,
43
43
  onValueChange?.(null);
44
44
  onChange?.(null);
45
45
  };
46
- return (_jsx("div", { className: datepickerfieldRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-datepickerfield-box", "data-ghost": variant === "ghost" ? "" : undefined, "data-placeholder": selected ? undefined : "", children: [_jsx(Popover, { open: open, onOpenChange: setOpen, "aria-label": ariaLabel, trigger: _jsxs("button", { type: "button", className: "crisp-datepickerfield-trigger", "aria-label": ariaLabel, children: [_jsx(CalendarDays, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), selected ? format(selected) : placeholder] }), children: _jsxs("div", { className: "crisp-datepickerfield-panel", children: [_jsx(DatePicker
46
+ return (_jsx("div", { className: datepickerfieldRecipe({ width, className }), ...rest, children: _jsxs("div", { className: "crisp-datepickerfield-box", "data-ghost": variant === "ghost" ? "" : undefined, "data-placeholder": selected ? undefined : "", children: [_jsx(Popover, { open: open, onOpenChange: setOpen, "aria-label": ariaLabel, trigger: _jsxs("button", { type: "button", className: "crisp-datepickerfield-trigger", "aria-label": ariaLabel, children: [_jsx(CalendarDays, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), selected ? format(selected) : placeholder] }), children: _jsxs("div", { className: "crisp-datepickerfield-panel", children: [_jsx(DatePicker
47
47
  // DatePickerProps is a union over selectionMode, and TypeScript
48
48
  // cannot narrow it through a spread: it tries every branch and
49
49
  // reports the range one as missing props. The field is always
@@ -10,7 +10,7 @@ const config = defineRecipe({
10
10
  display: inline-flex; font-family: ${c.font.sans};
11
11
 
12
12
  & .crisp-datepickerfield-box {
13
- display: inline-flex; align-items: center;
13
+ display: inline-flex; align-items: center; width: 100%;
14
14
  box-sizing: border-box; height: 32px; margin: 0;
15
15
  border-radius: ${c.radius.md}; background: transparent;
16
16
  box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 7%, transparent);
@@ -19,6 +19,7 @@ const config = defineRecipe({
19
19
  & .crisp-datepickerfield-box[data-ghost] { background: transparent; box-shadow: none; }
20
20
  & .crisp-datepickerfield-box[data-ghost]:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); }
21
21
  & .crisp-datepickerfield-trigger {
22
+ flex: 1; min-width: 0;
22
23
  display: inline-flex; align-items: center; gap: ${c.space["2"]};
23
24
  height: 100%; padding: 0 ${c.space["2.5"]}; margin: 0;
24
25
  border: none; background: none; color: inherit; font-family: inherit;
@@ -48,8 +49,20 @@ const config = defineRecipe({
48
49
  }
49
50
  & .crisp-datepickerfield-preset:hover { background: color-mix(in srgb, ${c.fgPrimary} 8%, transparent); }
50
51
  `,
51
- variants: {},
52
- defaultVariants: {},
52
+ variants: {
53
+ // crisp#980: full (default) fills its container, matching Select's own
54
+ // width variant/default -- so a form composing DatePickerField alongside
55
+ // Input/Select via FormField gets one consistent column width across
56
+ // every field type, not just some of them. auto sizes to content, the
57
+ // behaviour every consumer got unconditionally before this.
58
+ width: {
59
+ full: `width: 100%;`,
60
+ auto: `width: auto;`,
61
+ },
62
+ },
63
+ defaultVariants: {
64
+ width: "full",
65
+ },
53
66
  compoundVariants: [],
54
67
  });
55
68
  export const datepickerfieldRecipe = Object.assign(resolveRecipe(config), {
@@ -104,12 +104,28 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
104
104
  document.addEventListener("keydown", onKey);
105
105
  return () => document.removeEventListener("keydown", onKey);
106
106
  }, [open, close]);
107
- const { onKeyDown: onContentKeyDown } = useMenuKeyboard({
107
+ const { onKeyDown: onContentKeyDown, items } = useMenuKeyboard({
108
108
  open,
109
109
  close,
110
110
  dismiss: () => setOpen(false),
111
111
  contentRef,
112
112
  });
113
+ // Roving tabindex (crisp#964): every item hardcodes tabIndex={-1} for the
114
+ // arrow-key model above, which means none of them is ever a real Tab stop
115
+ // on its own -- kept in sync with wherever focus actually lands, same as
116
+ // MenuSheet's identical fix (#966).
117
+ const onContentFocus = React.useCallback((e) => {
118
+ for (const el of items())
119
+ el.tabIndex = -1;
120
+ e.target.tabIndex = 0;
121
+ }, [items]);
122
+ React.useEffect(() => {
123
+ if (!open)
124
+ return;
125
+ const [first] = items();
126
+ if (first)
127
+ first.tabIndex = 0;
128
+ }, [open, items]);
113
129
  const triggerEl = React.cloneElement(trigger, {
114
130
  ref: triggerRef,
115
131
  id: triggerId,
@@ -125,7 +141,7 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
125
141
  });
126
142
  return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsx("div", { ref: contentRef, className: contentClassName
127
143
  ? `crisp-menu-content ${contentClassName}`
128
- : "crisp-menu-content", role: "menu", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: _jsx(MenuContext.Provider, { value: { close }, children: children }) }) }))] }));
144
+ : "crisp-menu-content", role: "menu", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, onFocus: onContentFocus, children: _jsx(MenuContext.Provider, { value: { close }, children: children }) }) }))] }));
129
145
  }
130
146
  Menu.displayName = "Menu";
131
147
  export function MenuItem({ children, icon, shortcut, count, danger, disabled, onSelect, closeOnSelect = true, }) {
@@ -21,7 +21,13 @@ const config = defineRecipe({
21
21
  (padding: 0 12px) and independently on a body cell's text inset
22
22
  (getBoundingClientRect delta from the cell edge). Matches
23
23
  ColumnCalc's own already-correct 12px trigger inset. */
24
- & th, & td { position: relative; height: ${c.size.tableRow}; padding: 0 ${c.space["3"]}; border-bottom: 1px solid ${c.borderSubtle}; border-inline-end: 1px solid ${c.borderSubtle}; text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
24
+ /* #719: overflow: clip, not hidden -- both render the ellipsis
25
+ identically, but hidden still honours a programmatic scrollLeft
26
+ assignment (a screenshot tool, a browser extension) even though it
27
+ already blocks user-driven wheel/scrollbar input; clip leaves no
28
+ scroll position for anything to move, so the truncated text can
29
+ never be panned out of view. */
30
+ & th, & td { position: relative; height: ${c.size.tableRow}; padding: 0 ${c.space["3"]}; border-bottom: 1px solid ${c.borderSubtle}; border-inline-end: 1px solid ${c.borderSubtle}; text-align: start; vertical-align: middle; white-space: nowrap; overflow: clip; text-overflow: ellipsis; }
25
31
  /* #554: sticky on the vertical axis, mirroring RecordsTable's own
26
32
  \`.crisp-recordstable-th\`. z-index 2 sits above a plain body cell (auto)
27
33
  and below a sticky-column corner cell (DataTable's \`th[data-sticky]\`,
@@ -16,10 +16,11 @@ import { createPortal } from "react-dom";
16
16
  * `Dialog` was rendering offset by the dialog's own box instead of anchored
17
17
  * to its trigger.
18
18
  *
19
- * Every caller renders this conditionally on its own `open` state, which
20
- * starts `false` and is driven only by client interaction — never a prop a
21
- * fixture sets true at mount (unlike Dialog/Drawer/Command) — so this never
22
- * touches `document` during SSR, the same safety Tooltip already had.
19
+ * Every caller renders this conditionally on its own `open` state. A fixture
20
+ * CAN set that true at mount (crisp#964 needs exactly that, to give Menu's
21
+ * roving-tabindex guard something to inspect), so this checks for `window`
22
+ * itself rather than assuming the caller never does — the same guard
23
+ * `@zag-js/react`'s own `Portal` uses for the identical reason.
23
24
  *
24
25
  * `sourceRef` is the caller's own root — still in its original DOM position,
25
26
  * never portaled — used to carry a `data-theme` set on some ancestor of it
@@ -35,6 +36,8 @@ import { createPortal } from "react-dom";
35
36
  * element carrying that attribute.
36
37
  */
37
38
  export function FloatingPortal({ children, sourceRef, }) {
39
+ if (typeof window === "undefined")
40
+ return children;
38
41
  const theme = sourceRef.current
39
42
  ?.closest("[data-theme]")
40
43
  ?.getAttribute("data-theme");
@@ -18,10 +18,18 @@ export function Inbox({ notifications = [], title: titleProp, tabs, activeTab, o
18
18
  // own `empty`.
19
19
  const crispMessages = useCrispMessages();
20
20
  const title = titleProp ?? crispMessages.Notifications ?? "Notifications";
21
- const emptyTitle = emptyTitleProp ?? crispMessages["No notifications"] ?? "No notifications";
22
- const emptySubtitle = emptySubtitleProp ??
21
+ // A tab's own empty copy wins over the instance-level prop, which wins
22
+ // over the built-in default — same precedence as `title`/`emptyAction`.
23
+ const activeTabDef = tabs?.find((t) => t.id === activeTab);
24
+ const emptyTitle = activeTabDef?.emptyTitle ??
25
+ emptyTitleProp ??
26
+ crispMessages["No notifications"] ??
27
+ "No notifications";
28
+ const emptySubtitle = activeTabDef?.emptySubtitle ??
29
+ emptySubtitleProp ??
23
30
  crispMessages["Notifications such as mentions and important updates will be displayed here."] ??
24
31
  "Notifications such as mentions and important updates will be displayed here.";
32
+ const resolvedEmptyAction = activeTabDef?.emptyAction ?? emptyAction;
25
33
  const tabsLabel = useCrispMessage(tabsLabelProp, "Notification views");
26
34
  // crisp's shared tablist model, the one `Tabs` runs (#195). Hand-rolling a
27
35
  // second one is how Inbox ended up with no arrow keys, no roving tabindex
@@ -32,6 +40,6 @@ export function Inbox({ notifications = [], title: titleProp, tabs, activeTab, o
32
40
  select: (id) => onTabChange?.(id),
33
41
  listRef,
34
42
  });
35
- return (_jsxs("div", { className: inboxRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-inbox-head", children: [_jsx("div", { className: "crisp-inbox-title", children: title }), tabs && tabs.length > 0 && (_jsx("div", { ref: listRef, className: "crisp-inbox-tabs", role: "tablist", "aria-label": tabsLabel, onKeyDown: onKeyDown, children: tabs.map((t) => (_jsxs("button", { type: "button", role: "tab", "aria-selected": t.id === activeTab, tabIndex: tabIndexFor(t.id), "data-value": t.id, disabled: t.disabled, className: "crisp-inbox-tab", "data-active": t.id === activeTab ? "" : undefined, onClick: () => onTabChange?.(t.id), children: [t.label, t.count != null && (_jsxs("span", { className: "crisp-inbox-tab-count", children: ["(", t.count, ")"] }))] }, t.id))) }))] }), notifications.length === 0 ? (_jsxs("div", { className: "crisp-inbox-empty", children: [_jsx("span", { className: "crisp-inbox-empty-icon", children: _jsx(Bell, { size: 40, strokeWidth: 1.25 }) }), _jsx("div", { className: "crisp-inbox-empty-title", children: emptyTitle }), emptySubtitle != null && (_jsx("div", { className: "crisp-inbox-empty-sub", children: emptySubtitle })), emptyAction != null && (_jsx("div", { className: "crisp-inbox-empty-action", children: emptyAction }))] })) : (_jsx("div", { className: "crisp-inbox-list", children: notifications.map((n) => (_jsxs("button", { type: "button", className: "crisp-inbox-row", onClick: n.onClick, children: [n.avatar != null && (_jsx("span", { className: "crisp-inbox-row-avatar", children: n.avatar })), _jsxs("span", { className: "crisp-inbox-row-body", children: [_jsx("span", { className: "crisp-inbox-row-msg", children: n.message }), n.time != null && (_jsx("span", { className: "crisp-inbox-row-time", children: n.time }))] }), n.unread && _jsx("span", { className: "crisp-inbox-row-unread" })] }, n.id))) }))] }));
43
+ return (_jsxs("div", { className: inboxRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-inbox-head", children: [_jsx("div", { className: "crisp-inbox-title", children: title }), tabs && tabs.length > 0 && (_jsx("div", { ref: listRef, className: "crisp-inbox-tabs", role: "tablist", "aria-label": tabsLabel, onKeyDown: onKeyDown, children: tabs.map((t) => (_jsxs("button", { type: "button", role: "tab", "aria-selected": t.id === activeTab, tabIndex: tabIndexFor(t.id), "data-value": t.id, disabled: t.disabled, className: "crisp-inbox-tab", "data-active": t.id === activeTab ? "" : undefined, onClick: () => onTabChange?.(t.id), children: [t.label, t.count != null && (_jsxs("span", { className: "crisp-inbox-tab-count", children: ["(", t.count, ")"] }))] }, t.id))) }))] }), notifications.length === 0 ? (_jsxs("div", { className: "crisp-inbox-empty", children: [_jsx("span", { className: "crisp-inbox-empty-icon", children: _jsx(Bell, { size: 40, strokeWidth: 1.25 }) }), _jsx("div", { className: "crisp-inbox-empty-title", children: emptyTitle }), emptySubtitle != null && (_jsx("div", { className: "crisp-inbox-empty-sub", children: emptySubtitle })), resolvedEmptyAction != null && (_jsx("div", { className: "crisp-inbox-empty-action", children: resolvedEmptyAction }))] })) : (_jsx("div", { className: "crisp-inbox-list", children: notifications.map((n) => (_jsxs("button", { type: "button", className: "crisp-inbox-row", onClick: n.onClick, children: [n.avatar != null && (_jsx("span", { className: "crisp-inbox-row-avatar", children: n.avatar })), _jsxs("span", { className: "crisp-inbox-row-body", children: [_jsx("span", { className: "crisp-inbox-row-msg", children: n.message }), n.time != null && (_jsx("span", { className: "crisp-inbox-row-time", children: n.time }))] }), n.unread && _jsx("span", { className: "crisp-inbox-row-unread" })] }, n.id))) }))] }));
36
44
  }
37
45
  Inbox.displayName = "Inbox";
@@ -1,4 +1,4 @@
1
1
  export { tagTone, } from "./column.js";
2
2
  export { editableInputType, useEditableField, } from "./editable-field.js";
3
- export { RecordDetails, RecordPage, recordPageLabels, } from "./recordpage.js";
3
+ export { RecordDetails, RecordPage, RecordPageMain, recordPageLabels, } from "./recordpage.js";
4
4
  export { recordpageRecipe } from "./recordpage.recipe.js";
@@ -1,7 +1,8 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Activity as ActivityIcon, CalendarClock, ChevronDown, ChevronUp, CircleDollarSign, Copy, DollarSign, FileText, Folder, Inbox, Landmark, LayoutGrid, Mail, Maximize2, Merge, MessageSquare, Minimize2, MoreHorizontal, Pencil, Phone, Plus, Search, Settings, Sparkles, SquareCheck, Star, Trash2, Users, UsersRound, Workflow, X, Zap, } from "lucide-react";
2
+ import { Activity as ActivityIcon, CalendarClock, ChevronDown, ChevronUp, CircleDollarSign, Copy, DollarSign, FileText, Folder, Inbox, Landmark, LayoutGrid, ListFilter, Mail, Maximize2, Merge, MessageSquare, Minimize2, MoreHorizontal, Pencil, Phone, Plus, Search, Settings, Sparkles, SquareCheck, Star, Trash2, Users, UsersRound, Workflow, X, Zap, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { Avatar } from "../../components/avatar/index.js";
5
+ import { Button } from "../../components/button/index.js";
5
6
  import { Checkbox } from "../../components/checkbox/index.js";
6
7
  import { Drawer } from "../../components/drawer/index.js";
7
8
  import { Menu, MenuItem, MenuSeparator } from "../../components/menu/index.js";
@@ -62,6 +63,22 @@ export const recordPageLabels = {
62
63
  share: "Share",
63
64
  comments: "Comments",
64
65
  noLists: "This record has not been added to any lists.",
66
+ filters: "Filters",
67
+ addMeeting: "Add meeting",
68
+ noFiles: "No files",
69
+ filesHint: "Drag a file here or choose one from your computer.",
70
+ newFolder: "New folder",
71
+ uploadFile: "Upload file",
72
+ fileMoreActions: "More file actions",
73
+ noDealsFound: "No Deals found",
74
+ noDealsHint: "There are no related records for this relationship.",
75
+ newDeal: "New Deal",
76
+ noEmails: "No emails",
77
+ noEmailsHint: "This record doesn't have any emails, or they may be hidden due to permissions.",
78
+ configureMailbox: "Configure mailbox",
79
+ upgradeToViewCalls: "Upgrade to view calls",
80
+ callsUpgradeHint: "No call recordings yet! Upgrade to Pro to start recording calls.",
81
+ upgrade: "Upgrade",
65
82
  highlights: "Highlights",
66
83
  recordDetails: "Record Details",
67
84
  searchFields: "Search fields",
@@ -477,6 +494,28 @@ export function RecordDetails({ record, columns, sections, onEdit, labels, class
477
494
  } }))] }));
478
495
  }
479
496
  RecordDetails.displayName = "RecordDetails";
497
+ /**
498
+ * The main/tab-content column's own box, on its own (#750) — the same
499
+ * "standalone piece" `RecordDetails` already is for the Details column,
500
+ * mirrored to the other side of the layout. `RecordPage` composes this
501
+ * internally for its own tabbed body (below) — this exact box, not a second
502
+ * copy — so a consumer building a custom record page (its own tab set, its
503
+ * own menu, `RecordDetails` for the left column, but not the full
504
+ * `RecordPage`, which hardcodes the CRM tab set) gets the same
505
+ * Attio-measured inset: `flex: 1; min-width: 0; display: flex;
506
+ * flex-direction: column; min-height: 0; overflow-y: auto; padding: 12px
507
+ * 20px` (`recordpage.recipe.ts`'s own `.crisp-recordpage-main`) — without
508
+ * reaching for that internal class name directly, the only option before
509
+ * this existed.
510
+ *
511
+ * Needs the same single `.crisp-recordpage`-scoped ancestor `RecordDetails`
512
+ * already documents — wrap both in `recordpageRecipe()`, the way
513
+ * `RecordPage`'s own root does.
514
+ */
515
+ export function RecordPageMain({ className, children, ...rest }) {
516
+ return (_jsx("main", { className: cx("crisp-recordpage-main", className), ...rest, children: children }));
517
+ }
518
+ RecordPageMain.displayName = "RecordPageMain";
480
519
  /**
481
520
  * The full-page company record view — the page that opens when you click into a
482
521
  * record (as opposed to the peek [RecordPanel]). A top breadcrumb bar with
@@ -717,13 +756,19 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
717
756
  case "overview":
718
757
  return (_jsxs("div", { className: "crisp-recordpage-panel", children: [highlights !== false && (_jsxs("section", { children: [_jsx("div", { className: "crisp-recordpage-section-title", children: t.highlights }), _jsx("div", { className: "crisp-recordpage-hl-card", children: _jsx("div", { className: "crisp-recordpage-hl-grid", children: highlights.map((h) => (_jsxs("div", { className: "crisp-recordpage-hl-cell", children: [h.icon && (_jsx(h.icon, { className: "crisp-recordpage-hl-icon", size: 15, strokeWidth: 1.75 })), _jsx("div", { className: "crisp-recordpage-hl-label", children: h.label }), _jsx("div", { className: "crisp-recordpage-hl-value", children: h.value ?? (_jsx("span", { className: "crisp-recordpage-hl-value--empty", children: h.empty })) })] }, h.label))) }) })] })), activityFeed, _jsxs("section", { children: [_jsx(SectionHead, { title: t.tabs.emails, count: 0 }), emailsEmpty] }), _jsx(NotesSection, { notes: notes, onAdd: addNote, labels: t }), _jsx(TasksSection, { tasks: tasks, onAdd: addTask, onToggle: toggleTask, labels: t })] }));
719
758
  case "activity":
720
- return _jsx("div", { className: "crisp-recordpage-panel", children: activityFeed });
759
+ return (_jsx("div", { className: "crisp-recordpage-panel", children: _jsxs("section", { children: [_jsx(SectionHead, { title: t.tabs.activity, action: _jsxs("span", { className: "crisp-recordpage-tabtoolbar", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-action", children: [_jsx(ListFilter, { size: 14, strokeWidth: 1.75 }), t.filters] }), _jsxs("button", { type: "button", className: "crisp-recordpage-action", children: [_jsx(Plus, { size: 14, strokeWidth: 1.75 }), t.addMeeting] })] }) }), _jsx("div", { className: "crisp-recordpage-feed", children: activity.map((a) => (_jsxs("div", { className: "crisp-recordpage-feed-item", children: [_jsx("span", { className: "crisp-recordpage-feed-dot", children: _jsx(ActivityIcon, { size: 13, strokeWidth: 2 }) }), _jsxs("div", { className: "crisp-recordpage-feed-body", children: [a.text, _jsx("div", { className: "crisp-recordpage-feed-time", children: a.time })] })] }, a.id))) })] }) }));
760
+ case "files":
761
+ return (_jsx("div", { className: "crisp-recordpage-panel crisp-recordpage-panel--center", children: _jsxs("div", { className: "crisp-recordpage-illustrated", children: [_jsx("span", { className: "crisp-recordpage-illustrated-icon", "aria-hidden": "true", children: _jsx(Folder, { size: 40, strokeWidth: 1.25 }) }), _jsx("div", { className: "crisp-recordpage-illustrated-title", children: t.noFiles }), _jsx("div", { className: "crisp-recordpage-illustrated-desc", children: t.filesHint }), _jsxs("div", { className: "crisp-recordpage-illustrated-actions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-action", children: [_jsx(Plus, { size: 14, strokeWidth: 1.75 }), t.newFolder] }), _jsxs("button", { type: "button", className: "crisp-recordpage-action", children: [_jsx(Plus, { size: 14, strokeWidth: 1.75 }), t.uploadFile] }), _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.fileMoreActions, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) })] })] }) }));
762
+ case "deals":
763
+ return (_jsx("div", { className: "crisp-recordpage-panel crisp-recordpage-panel--center", children: _jsxs("div", { className: "crisp-recordpage-illustrated", children: [_jsx("span", { className: "crisp-recordpage-illustrated-icon", "aria-hidden": "true", children: _jsx(Merge, { size: 40, strokeWidth: 1.25 }) }), _jsx("div", { className: "crisp-recordpage-illustrated-title", children: t.noDealsFound }), _jsx("div", { className: "crisp-recordpage-illustrated-desc", children: t.noDealsHint }), _jsx("div", { className: "crisp-recordpage-illustrated-actions", children: _jsxs(Button, { intent: "primary", size: "md", children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), t.newDeal] }) })] }) }));
721
764
  case "notes":
722
765
  return (_jsx("div", { className: "crisp-recordpage-panel", children: _jsx(NotesSection, { notes: notes, onAdd: addNote, labels: t }) }));
723
766
  case "tasks":
724
767
  return (_jsx("div", { className: "crisp-recordpage-panel", children: _jsx(TasksSection, { tasks: tasks, onAdd: addTask, onToggle: toggleTask, labels: t }) }));
725
768
  case "emails":
726
- return _jsx("div", { className: "crisp-recordpage-panel", children: emailsEmpty });
769
+ return (_jsxs("div", { className: "crisp-recordpage-panel", children: [_jsx(SectionHead, { title: t.tabs.emails }), _jsx("div", { className: "crisp-recordpage-panel crisp-recordpage-panel--center", children: _jsxs("div", { className: "crisp-recordpage-illustrated", children: [_jsx("span", { className: "crisp-recordpage-illustrated-icon", "aria-hidden": "true", children: _jsx(Mail, { size: 40, strokeWidth: 1.25 }) }), _jsx("div", { className: "crisp-recordpage-illustrated-title", children: t.noEmails }), _jsx("div", { className: "crisp-recordpage-illustrated-desc", children: t.noEmailsHint }), _jsx("div", { className: "crisp-recordpage-illustrated-actions", children: _jsx(Button, { intent: "primary", size: "md", children: t.configureMailbox }) })] }) })] }));
770
+ case "calls":
771
+ return (_jsx("div", { className: "crisp-recordpage-panel crisp-recordpage-panel--center", children: _jsxs("div", { className: "crisp-recordpage-illustrated", children: [_jsx("span", { className: "crisp-recordpage-illustrated-icon", "aria-hidden": "true", children: _jsx(Phone, { size: 40, strokeWidth: 1.25 }) }), _jsx("div", { className: "crisp-recordpage-illustrated-title crisp-recordpage-illustrated-title--lg", children: t.upgradeToViewCalls }), _jsx("div", { className: "crisp-recordpage-illustrated-desc", children: t.callsUpgradeHint }), _jsx("div", { className: "crisp-recordpage-illustrated-actions", children: _jsxs(Button, { intent: "primary", size: "md", children: [_jsx(Zap, { size: 15, strokeWidth: 1.75 }), t.upgrade] }) })] }) }));
727
772
  default:
728
773
  return (_jsx("div", { className: "crisp-recordpage-panel", children: _jsx(EmptyCard, { icon: _jsx(Folder, { size: 22, strokeWidth: 1.5 }), children: t.nothingHereYet }) }));
729
774
  }
@@ -753,6 +798,6 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
753
798
  setDetailsW((w) => grow
754
799
  ? Math.min(DETAILS_MAX_W, w + 16)
755
800
  : Math.max(DETAILS_MIN_W, w - 16));
756
- } }), _jsx("main", { className: "crisp-recordpage-main", children: _jsx(Tabs, { tabs: effectiveTabs, defaultValue: tabsProp ? defaultTab : "overview", "aria-label": `${name} record`, children: effectivePanel }) })] }), _jsx(Drawer, { open: commentsOpen, onOpenChange: setCommentsOpen, "aria-label": t.comments, style: { width: 320 }, children: _jsx(CommentsPanel, { threads: comments, currentUserId: me.id, onPost: postComment, onClose: () => setCommentsOpen(false), role: undefined }) })] }));
801
+ } }), _jsx(RecordPageMain, { children: _jsx(Tabs, { tabs: effectiveTabs, defaultValue: tabsProp ? defaultTab : "overview", "aria-label": `${name} record`, children: effectivePanel }) })] }), _jsx(Drawer, { open: commentsOpen, onOpenChange: setCommentsOpen, "aria-label": t.comments, style: { width: 320 }, children: _jsx(CommentsPanel, { threads: comments, currentUserId: me.id, onPost: postComment, onClose: () => setCommentsOpen(false), role: undefined }) })] }));
757
802
  }
758
803
  RecordPage.displayName = "RecordPage";
@@ -226,6 +226,31 @@ const config = defineRecipe({
226
226
  & .crisp-recordpage-emptycard { display: flex; flex-direction: column; align-items: center; gap: ${c.space["1.5"]}; padding: ${c.space["6"]} ${c.space["3"]}; border: 1px dashed ${c.borderDefault}; border-radius: ${c.radius.xl}; color: ${c.fgMuted}; font-size: ${c.text.md}; text-align: center; }
227
227
  & .crisp-recordpage-emptycard svg { color: ${c.fgMuted}; opacity: 0.7; }
228
228
 
229
+ /* #622: the Activity tab's own toolbar -- "Filters" + "+ Add meeting",
230
+ both the same measured button chrome as the topbar's own action
231
+ buttons (28px, e.field shadow, 14/500 label). */
232
+ & .crisp-recordpage-tabtoolbar { display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; }
233
+
234
+ /* #622: Files tab's empty state -- measured live on Attio's own record
235
+ page (a company with no files uploaded). The 138x138 dotted-grid
236
+ backdrop is the same measured tile EmptyState's records variant uses
237
+ (crisp/#603), duplicated here rather than composed through that
238
+ component because this surface's title (16/600) and body (14/500,
239
+ 63%-mixed fgPrimary, not the flat fgMuted EmptyState uses) measure to
240
+ their own values, matching neither of that component's existing
241
+ variants. */
242
+ & .crisp-recordpage-panel--center { flex: 1; align-items: center; justify-content: center; }
243
+ & .crisp-recordpage-illustrated { display: flex; flex-direction: column; align-items: center; gap: ${c.space["1.5"]}; text-align: center; }
244
+ & .crisp-recordpage-illustrated-icon { display: flex; align-items: center; justify-content: center; width: 138px; height: 138px; margin-bottom: ${c.space["2"]}; color: color-mix(in srgb, ${c.fgPrimary} 22%, transparent); background: radial-gradient(circle, color-mix(in srgb, ${c.fgPrimary} 12%, transparent) 1.2px, transparent 1.2px) 0 0 / 16px 16px; }
245
+ & .crisp-recordpage-illustrated-title { font-size: ${c.text.xl}; font-weight: ${c.weight.semibold}; color: ${c.fgPrimary}; }
246
+ /* #988: the Calls tab's own gated state measures a larger 20/24 title
247
+ than Files/Deals' 16/600 -- Attio's own "empty view" pattern (the
248
+ same 20/600 EmptyState's page/records variants already carry, not a
249
+ one-off). */
250
+ & .crisp-recordpage-illustrated-title--lg { font-size: 20px; line-height: 24px; }
251
+ & .crisp-recordpage-illustrated-desc { max-width: 320px; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
252
+ & .crisp-recordpage-illustrated-actions { display: inline-flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: ${c.space["1.5"]}; margin-top: ${c.space["2"]}; }
253
+
229
254
  & .crisp-recordpage-action[data-active] svg { color: #f5b83d; fill: #f5b83d; }
230
255
 
231
256
  & .crisp-recordstable-tag { display: inline-flex; align-items: center; height: ${c.size.chip}; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.chip}; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; white-space: nowrap; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: color-mix(in srgb, ${c.fgPrimary} 82%, transparent); }
@@ -193,6 +193,29 @@
193
193
  & .crisp-recordpage-task[data-done] .crisp-recordpage-task-text { text-decoration: line-through; color: var(--crisp-fg-muted); }
194
194
  & .crisp-recordpage-emptycard { display: flex; flex-direction: column; align-items: center; gap: var(--crisp-space-1-5); padding: var(--crisp-space-6) var(--crisp-space-3); border: 1px dashed var(--crisp-border-default); border-radius: var(--crisp-radius-xl); color: var(--crisp-fg-muted); font-size: var(--crisp-text-md); text-align: center; }
195
195
  & .crisp-recordpage-emptycard svg { color: var(--crisp-fg-muted); opacity: 0.7; }
196
+ /* #622: the Activity tab's own toolbar -- "Filters" + "+ Add meeting",
197
+ both the same measured button chrome as the topbar's own action
198
+ buttons (28px, e.field shadow, 14/500 label). */
199
+ & .crisp-recordpage-tabtoolbar { display: inline-flex; align-items: center; gap: var(--crisp-space-1-5); }
200
+ /* #622: Files tab's empty state -- measured live on Attio's own record
201
+ page (a company with no files uploaded). The 138x138 dotted-grid
202
+ backdrop is the same measured tile EmptyState's records variant uses
203
+ (crisp/#603), duplicated here rather than composed through that
204
+ component because this surface's title (16/600) and body (14/500,
205
+ 63%-mixed fgPrimary, not the flat fgMuted EmptyState uses) measure to
206
+ their own values, matching neither of that component's existing
207
+ variants. */
208
+ & .crisp-recordpage-panel--center { flex: 1; align-items: center; justify-content: center; }
209
+ & .crisp-recordpage-illustrated { display: flex; flex-direction: column; align-items: center; gap: var(--crisp-space-1-5); text-align: center; }
210
+ & .crisp-recordpage-illustrated-icon { display: flex; align-items: center; justify-content: center; width: 138px; height: 138px; margin-bottom: var(--crisp-space-2); color: color-mix(in srgb, var(--crisp-fg-primary) 22%, transparent); background: radial-gradient(circle, color-mix(in srgb, var(--crisp-fg-primary) 12%, transparent) 1.2px, transparent 1.2px) 0 0 / 16px 16px; }
211
+ & .crisp-recordpage-illustrated-title { font-size: var(--crisp-text-xl); font-weight: var(--crisp-weight-semibold); color: var(--crisp-fg-primary); }
212
+ /* #988: the Calls tab's own gated state measures a larger 20/24 title
213
+ than Files/Deals' 16/600 -- Attio's own "empty view" pattern (the
214
+ same 20/600 EmptyState's page/records variants already carry, not a
215
+ one-off). */
216
+ & .crisp-recordpage-illustrated-title--lg { font-size: 20px; line-height: 24px; }
217
+ & .crisp-recordpage-illustrated-desc { max-width: 320px; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); }
218
+ & .crisp-recordpage-illustrated-actions { display: inline-flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: var(--crisp-space-1-5); margin-top: var(--crisp-space-2); }
196
219
  & .crisp-recordpage-action[data-active] svg { color: #f5b83d; fill: #f5b83d; }
197
220
  & .crisp-recordstable-tag { display: inline-flex; align-items: center; height: var(--crisp-size-chip); padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-chip); font-size: var(--crisp-text-lg); line-height: 20px; font-weight: var(--crisp-weight-medium); white-space: nowrap; background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); color: color-mix(in srgb, var(--crisp-fg-primary) 82%, transparent); }
198
221
  & .crisp-recordstable-tag[data-tone="brand"] { background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: color-mix(in srgb, var(--crisp-bg-brand-solid) 82%, var(--crisp-fg-primary)); }
@@ -194,6 +194,29 @@
194
194
  & .crisp-recordpage-task[data-done] .crisp-recordpage-task-text { text-decoration: line-through; color: var(--crisp-fg-muted); }
195
195
  & .crisp-recordpage-emptycard { display: flex; flex-direction: column; align-items: center; gap: var(--crisp-space-1-5); padding: var(--crisp-space-6) var(--crisp-space-3); border: 1px dashed var(--crisp-border-default); border-radius: var(--crisp-radius-xl); color: var(--crisp-fg-muted); font-size: var(--crisp-text-md); text-align: center; }
196
196
  & .crisp-recordpage-emptycard svg { color: var(--crisp-fg-muted); opacity: 0.7; }
197
+ /* #622: the Activity tab's own toolbar -- "Filters" + "+ Add meeting",
198
+ both the same measured button chrome as the topbar's own action
199
+ buttons (28px, e.field shadow, 14/500 label). */
200
+ & .crisp-recordpage-tabtoolbar { display: inline-flex; align-items: center; gap: var(--crisp-space-1-5); }
201
+ /* #622: Files tab's empty state -- measured live on Attio's own record
202
+ page (a company with no files uploaded). The 138x138 dotted-grid
203
+ backdrop is the same measured tile EmptyState's records variant uses
204
+ (crisp/#603), duplicated here rather than composed through that
205
+ component because this surface's title (16/600) and body (14/500,
206
+ 63%-mixed fgPrimary, not the flat fgMuted EmptyState uses) measure to
207
+ their own values, matching neither of that component's existing
208
+ variants. */
209
+ & .crisp-recordpage-panel--center { flex: 1; align-items: center; justify-content: center; }
210
+ & .crisp-recordpage-illustrated { display: flex; flex-direction: column; align-items: center; gap: var(--crisp-space-1-5); text-align: center; }
211
+ & .crisp-recordpage-illustrated-icon { display: flex; align-items: center; justify-content: center; width: 138px; height: 138px; margin-bottom: var(--crisp-space-2); color: color-mix(in srgb, var(--crisp-fg-primary) 22%, transparent); background: radial-gradient(circle, color-mix(in srgb, var(--crisp-fg-primary) 12%, transparent) 1.2px, transparent 1.2px) 0 0 / 16px 16px; }
212
+ & .crisp-recordpage-illustrated-title { font-size: var(--crisp-text-xl); font-weight: var(--crisp-weight-semibold); color: var(--crisp-fg-primary); }
213
+ /* #988: the Calls tab's own gated state measures a larger 20/24 title
214
+ than Files/Deals' 16/600 -- Attio's own "empty view" pattern (the
215
+ same 20/600 EmptyState's page/records variants already carry, not a
216
+ one-off). */
217
+ & .crisp-recordpage-illustrated-title--lg { font-size: 20px; line-height: 24px; }
218
+ & .crisp-recordpage-illustrated-desc { max-width: 320px; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); }
219
+ & .crisp-recordpage-illustrated-actions { display: inline-flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: var(--crisp-space-1-5); margin-top: var(--crisp-space-2); }
197
220
  & .crisp-recordpage-action[data-active] svg { color: #f5b83d; fill: #f5b83d; }
198
221
  & .crisp-recordstable-tag { display: inline-flex; align-items: center; height: var(--crisp-size-chip); padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-chip); font-size: var(--crisp-text-lg); line-height: 20px; font-weight: var(--crisp-weight-medium); white-space: nowrap; background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); color: color-mix(in srgb, var(--crisp-fg-primary) 82%, transparent); }
199
222
  & .crisp-recordstable-tag[data-tone="brand"] { background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: color-mix(in srgb, var(--crisp-bg-brand-solid) 82%, var(--crisp-fg-primary)); }