@crisp-ui-kit/crisp 0.55.1 → 0.55.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.
@@ -21,16 +21,32 @@ const config = (0, core_1.defineRecipe)({
21
21
  radius, and datatable-grip and the embedded pickers zero theirs. An 8px
22
22
  radius survived the chip-to-full-cell conversion and shipped rounded
23
23
  until #282, so the spec check now guards it. */
24
- /* The hit target is the WHOLE summary cell -- measured 246x37 with
24
+ /* The hit target is the WHOLE summary cell -- measured 246x36 with
25
25
  padding 0 12px, not a content-sized chip. Clicking anywhere in the cell
26
- opens the aggregation menu. */
27
- & .crisp-columncalc-trigger { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; width: 100%; height: 100%; min-height: ${tokens_1.contract.size.tableRow}; margin: 0; padding: 0 ${tokens_1.contract.space["3"]}; border: none; background: transparent; color: ${tokens_1.contract.fgMuted}; font-family: inherit; font-size: ${tokens_1.contract.text.md}; font-weight: ${tokens_1.contract.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s, color .12s; }
26
+ opens the aggregation menu.
27
+ No \`min-height\` (crisp#834, found reproducing the footer-row-38px-
28
+ not-36px bug in an isolated browser test, not table.recipe.ts's own
29
+ border/box-sizing, the hypothesis the issue itself flagged as
30
+ unconfirmed): the table cell's OWN \`height: tableRow\` (36px,
31
+ border-box) leaves 34px of content box once its 2px of top+bottom
32
+ border are counted, and \`min-height: tableRow\` here demanded 36px
33
+ of CONTENT height regardless -- overflowing the cell by 2px, which
34
+ auto-height table layout resolves by growing the whole row to fit,
35
+ landing at exactly the reported 38. \`height: 100%\` alone already
36
+ fills whatever the cell's real content box is (the doc comment above
37
+ this rule's own stated intent), with nothing left demanding more. */
38
+ & .crisp-columncalc-trigger { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; width: 100%; height: 100%; margin: 0; padding: 0 ${tokens_1.contract.space["3"]}; border: none; background: transparent; color: ${tokens_1.contract.fgMuted}; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s, color .12s; }
28
39
  & .crisp-columncalc-trigger:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); color: ${tokens_1.contract.fgPrimary}; }
29
40
  & .crisp-columncalc-trigger svg { flex-shrink: 0; opacity: 0.7; }
30
41
  & .crisp-columncalc-trigger[data-align="right"] { justify-content: flex-end; }
31
42
  & .crisp-columncalc-placeholder { overflow: hidden; text-overflow: ellipsis; }
32
43
  & .crisp-columncalc-value { color: ${tokens_1.contract.fgPrimary}; font-variant-numeric: tabular-nums; }
33
- & .crisp-columncalc-op { color: ${tokens_1.contract.fgMuted}; }
44
+ /* crisp#837: re-measured live against Attio's real footer calc label --
45
+ 14px (was 13px, \`c.text.md\`, fixed on the shared trigger rule above)
46
+ and rgba(0,0,0,.63), the same color-mix(fgPrimary, 63%) figure this
47
+ file's own -op label already should have used instead of the flatter
48
+ \`fgMuted\` token (a close but not measured match). */
49
+ & .crisp-columncalc-op { color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); }
34
50
  & .crisp-columncalc-section { padding: ${tokens_1.contract.space["1.5"]} ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["1"]}; font-size: ${tokens_1.contract.text.sm}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgMuted}; }`,
35
51
  variants: {},
36
52
  defaultVariants: {},
@@ -210,6 +210,19 @@ function parsePhoneValue(v) {
210
210
  return null;
211
211
  return { iso2: s.slice(0, i), digits: s.slice(i + 1) };
212
212
  }
213
+ // crisp/#816 -- a `multiValue` phone column's raw value is several
214
+ // `parsePhoneValue`-shaped entries joined with `;`. Empty entries (a blank
215
+ // trailing slot that was never filled) are dropped rather than rendered as
216
+ // a stray chip.
217
+ function parsePhoneValues(v) {
218
+ const s = typeof v === "string" ? v : "";
219
+ if (!s)
220
+ return [];
221
+ return s
222
+ .split(";")
223
+ .map((entry) => parsePhoneValue(entry))
224
+ .filter((p) => !!p?.digits);
225
+ }
213
226
  // stable pastel tone per string value — colour-coded tags/statuses
214
227
  const TAG_TONES = [
215
228
  "brand",
@@ -430,6 +443,22 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
430
443
  return (0, jsx_runtime_1.jsx)(RatingStars, { value: n, max: col.maxRating ?? 5 });
431
444
  }
432
445
  case "phone": {
446
+ // crisp/#816, confirmed live -- a `multiValue` phone cell renders
447
+ // each value as its own real `tel:` link (Attio: a measured link
448
+ // blue, underlined, no flag/dial-code decoration, `4px` flex gap
449
+ // between values), unlike the single-value cell above/below (a plain
450
+ // flag+dial+digits span, never a link). The whole cell hard-clips
451
+ // when it overflows the column -- confirmed live, Attio does not
452
+ // ellipsis a multi-value phone cell.
453
+ if (col.multiValue) {
454
+ const values = parsePhoneValues(value);
455
+ if (!values.length)
456
+ return empty;
457
+ return ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-phone-multi", children: values.map((p) => {
458
+ const country = (0, countries_1.countryByIso2)(p.iso2);
459
+ return ((0, jsx_runtime_1.jsxs)("a", { className: "crisp-datatable-phone-link", href: `tel:+${country?.dialCode ?? ""}${p.digits}`, onClick: (ev) => ev.stopPropagation(), children: ["+", country?.dialCode ?? "?", p.digits] }, `${p.iso2}|${p.digits}`));
460
+ }) }));
461
+ }
433
462
  const parsed = parsePhoneValue(value);
434
463
  if (!parsed?.digits)
435
464
  return empty;
@@ -446,6 +475,16 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
446
475
  // link's own navigation must not also fire the row's onRowClick.
447
476
  onClick: (ev) => ev.stopPropagation(), children: text }));
448
477
  }
478
+ case "date":
479
+ case "datetime": {
480
+ const text = display || formatCell(col.type, value, col.symbol, locale);
481
+ if (!text)
482
+ return empty;
483
+ // crisp#822 -- a column-level predicate, not `tones` (see its doc
484
+ // comment): the danger state is computed over the row, not read back
485
+ // off this formatted string.
486
+ return col.danger?.(value, row) ? ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-danger", children: text })) : (text);
487
+ }
449
488
  default: {
450
489
  const text = display || formatCell(col.type, value, col.symbol, locale);
451
490
  const isNum = col.type === "number" || col.type === "currency";
@@ -589,6 +628,23 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
589
628
  onCommit(v);
590
629
  } }) }));
591
630
  }
631
+ // crisp/#842, confirmed live -- Attio's own country-picker popover: each
632
+ // option row is plain text, name (opaque) + dial code (muted 63%), NO flag
633
+ // anywhere -- not in the option row, and not in the CLOSED trigger either,
634
+ // which shows only the bare 2-letter ISO code ("MN") + a chevron. That's a
635
+ // different, more compact string than the option row's own "{name}
636
+ // +{dialCode}", so this builds `label` (the option row) and `triggerLabel`
637
+ // (the closed trigger) as two different values on the same `SelectOption`
638
+ // rather than one flag+name+dialcode string doing both jobs -- the shape
639
+ // #765 originally shipped, unmeasured, and #842 corrected.
640
+ function buildCountryOptions() {
641
+ return countries_1.COUNTRIES.map((c) => ({
642
+ value: c.iso2,
643
+ label: ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-country-option", children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-country-name", children: c.name }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-country-dial", children: ["+", c.dialCode] })] })),
644
+ triggerLabel: c.iso2,
645
+ keywords: `${c.name} ${c.iso2} ${c.dialCode}`,
646
+ }));
647
+ }
592
648
  // crisp/#765, confirmed live -- Attio's own Phone Number editor: a
593
649
  // country-code select (ghost trigger, "MN ▾"), then a static, non-editable
594
650
  // "+{dialCode}" prefix, then a borderless digits field -- measured
@@ -596,20 +652,16 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
596
652
  // value already measured elsewhere in this file (14px in both cases).
597
653
  // Reuses crisp's own searchable `Select` for the country list -- the same
598
654
  // popover chrome every other picker in this file already uses -- rather
599
- // than inventing new country-picker chrome; the flag/name/dial-code ROW
600
- // shape wasn't independently re-measured against Attio's own popover this
601
- // pass (time-boxed), so it's built to the same shape crisp's other search
602
- // pickers already carry.
655
+ // than inventing new country-picker chrome; the panel/row chrome (radius,
656
+ // padding, row height) already lines up with `Select`'s own measured
657
+ // tokens (#842 re-confirmed this), so only the option content itself
658
+ // (`buildCountryOptions` above) needed a real fix.
603
659
  function DataPhoneEditor({ col, value, isPending, onCommit, }) {
604
660
  const parsed = parsePhoneValue(value);
605
661
  const [iso2, setIso2] = React.useState(parsed?.iso2 ?? "US");
606
662
  const [digits, setDigits] = React.useState(parsed?.digits ?? "");
607
663
  const country = (0, countries_1.countryByIso2)(iso2);
608
- const countryOptions = countries_1.COUNTRIES.map((c) => ({
609
- value: c.iso2,
610
- label: `${(0, countries_1.flagEmoji)(c.iso2)} ${c.name} +${c.dialCode}`,
611
- keywords: `${c.name} ${c.iso2} ${c.dialCode}`,
612
- }));
664
+ const countryOptions = buildCountryOptions();
613
665
  const commit = (nextIso2, nextDigits) => onCommit(`${nextIso2}|${nextDigits}`);
614
666
  return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-editor", children: [(0, jsx_runtime_1.jsx)(select_1.Select, { variant: "ghost", searchable: true, "aria-label": `Edit ${col.key} country`, value: iso2, disabled: isPending, options: countryOptions, onValueChange: (v) => {
615
667
  setIso2(v);
@@ -619,6 +671,40 @@ function DataPhoneEditor({ col, value, isPending, onCommit, }) {
619
671
  commit(iso2, digits);
620
672
  } })] }));
621
673
  }
674
+ // crisp/#816, confirmed live -- Attio's own multi-value Phone Number
675
+ // editor (`col.multiValue`): a stack of already-committed values (a
676
+ // drag grip, the value as a real `tel:` link, and a hover-revealed
677
+ // copy/delete pair -- measured `32px` row, `5px 8px` padding, `8px` gap,
678
+ // `16×16` icons) plus one ALWAYS-blank trailing "country + digits" row in
679
+ // `DataPhoneEditor`'s own shape. The blank row itself is the only
680
+ // affordance for adding another value -- there is no separate "+ Add"
681
+ // button; committing it (Enter/blur with digits) appends and clears back
682
+ // to blank so the next value can be typed immediately. Drag-to-reorder
683
+ // (the grip icon) was measured present but isn't wired here -- reordering
684
+ // a committed array is a distinct interaction from add/remove/copy and is
685
+ // left as a follow-up rather than a partial, non-functional drag handle.
686
+ function DataPhoneMultiEditor({ col, value, isPending, onCommit, }) {
687
+ const committed = parsePhoneValues(value);
688
+ const [draftIso2, setDraftIso2] = React.useState("US");
689
+ const [draftDigits, setDraftDigits] = React.useState("");
690
+ const countryOptions = buildCountryOptions();
691
+ const join = (list) => list.map((p) => `${p.iso2}|${p.digits}`).join(";");
692
+ const removeAt = (i) => onCommit(join(committed.filter((_, idx) => idx !== i)));
693
+ const commitDraft = () => {
694
+ if (!draftDigits)
695
+ return;
696
+ onCommit(join([...committed, { iso2: draftIso2, digits: draftDigits }]));
697
+ setDraftDigits("");
698
+ };
699
+ return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-multi-editor", children: [committed.map((p, i) => {
700
+ const country = (0, countries_1.countryByIso2)(p.iso2);
701
+ const tel = `+${country?.dialCode ?? "?"}${p.digits}`;
702
+ return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-multi-row", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.GripVertical, { size: 16, strokeWidth: 1.75, className: "crisp-datatable-phone-multi-grip", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("a", { className: "crisp-datatable-phone-link", href: `tel:${tel}`, onClick: (ev) => ev.stopPropagation(), children: tel }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-multi-actions", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-datatable-phone-multi-action", "aria-label": `Copy ${col.key} value`, onClick: () => navigator.clipboard?.writeText(tel), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Copy, { size: 16, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-datatable-phone-multi-action", "aria-label": `Remove ${col.key} value`, disabled: isPending, onClick: () => removeAt(i), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Trash2, { size: 16, strokeWidth: 1.75 }) })] })] }, `${p.iso2}|${p.digits}`));
703
+ }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-editor", children: [(0, jsx_runtime_1.jsx)(select_1.Select, { variant: "ghost", searchable: true, "aria-label": `Edit ${col.key} country`, value: draftIso2, disabled: isPending, options: countryOptions, onValueChange: setDraftIso2 }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-dial", "aria-hidden": "true", children: ["+", (0, countries_1.countryByIso2)(draftIso2)?.dialCode ?? "?"] }), (0, jsx_runtime_1.jsx)("input", { className: "crisp-datatable-edit", "aria-label": `Edit ${col.key} number (add another)`, value: draftDigits, disabled: isPending, onChange: (e) => setDraftDigits(e.currentTarget.value), onBlur: commitDraft, onKeyDown: (e) => {
704
+ if (e.key === "Enter")
705
+ commitDraft();
706
+ } })] })] }));
707
+ }
622
708
  // crisp/#762, confirmed live -- Attio's own Timestamp editor is a calendar +
623
709
  // time + preset picker, not the native `datetime-local` input `type:
624
710
  // "datetime"` gets by default (that stays the DEFAULT, unchanged, for
@@ -675,7 +761,7 @@ function DataDateTimeEditor({ col, value, isPending, onCommit, }) {
675
761
  commit(date, t);
676
762
  } })] }));
677
763
  }
678
- function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel: selectAllLabelProp, selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement, columnMenu = false, onColumnsChange, columnOrder, onColumnOrderChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder: searchPlaceholderProp, searchLabel: searchLabelProp, onEdit, onViewEditHistory, stickyFirst = true, stickyLast = false, scrollProps, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel: addRowLabelProp, empty: emptyProp, showCount = true, countLabel, toolbar, height, className, reorderRowLabel: reorderRowLabelProp, openPreviewLabel: openPreviewLabelProp, sortAscendingLabel: sortAscendingLabelProp, sortDescendingLabel: sortDescendingLabelProp, moveColumnLeftLabel: moveColumnLeftLabelProp, moveColumnRightLabel: moveColumnRightLabelProp, editColumnLabel: editColumnLabelProp, hideColumnLabel: hideColumnLabelProp, renameColumnLabel: renameColumnLabelProp, viewSettingsLabel: viewSettingsLabelProp, columnsGroupLabel: columnsGroupLabelProp, cellMenuLabel: cellMenuLabelProp, copyLabel: copyLabelProp, pasteLabel: pasteLabelProp, viewEditHistoryLabel: viewEditHistoryLabelProp, clearValueLabel: clearValueLabelProp, ...rest }) {
764
+ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel: selectAllLabelProp, selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement, columnMenu = false, onColumnsChange, columnOrder, onColumnOrderChange, groupBy, onRowsReorder, error, loading = false, loadingLabel = "Loading...", sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder: searchPlaceholderProp, searchLabel: searchLabelProp, onEdit, onViewEditHistory, stickyFirst = true, stickyLast = false, scrollProps, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel: addRowLabelProp, empty: emptyProp, showCount = true, countLabel, toolbar, height, className, reorderRowLabel: reorderRowLabelProp, openPreviewLabel: openPreviewLabelProp, sortAscendingLabel: sortAscendingLabelProp, sortDescendingLabel: sortDescendingLabelProp, moveColumnLeftLabel: moveColumnLeftLabelProp, moveColumnRightLabel: moveColumnRightLabelProp, editColumnLabel: editColumnLabelProp, hideColumnLabel: hideColumnLabelProp, renameColumnLabel: renameColumnLabelProp, viewSettingsLabel: viewSettingsLabelProp, columnsGroupLabel: columnsGroupLabelProp, cellMenuLabel: cellMenuLabelProp, copyLabel: copyLabelProp, pasteLabel: pasteLabelProp, viewEditHistoryLabel: viewEditHistoryLabelProp, clearValueLabel: clearValueLabelProp, ...rest }) {
679
765
  // crisp/#471: same resolution order as TableToolbar's own — an explicit
680
766
  // prop wins, then the provider's translation of the built-in English
681
767
  // default, then that default itself with no provider in the tree.
@@ -1651,17 +1737,23 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1651
1737
  // value while showing the rejection's message beside the cell (reject).
1652
1738
  // The revert needs no extra code: this component never mutates `data`
1653
1739
  // itself, so once `editing` clears, the cell simply shows `data` again.
1654
- function commitEdit(id, key, value) {
1740
+ // crisp/#816 -- `keepEditing` lets a multi-value editor stay open across
1741
+ // a commit (adding/removing one value must not close the whole cell, the
1742
+ // way it does for every other, single-value editor). Default false
1743
+ // preserves every existing editor's own close-on-commit behaviour.
1744
+ function commitEdit(id, key, value, keepEditing = false) {
1655
1745
  setEditError(null);
1656
1746
  const result = onEdit?.(id, key, value);
1657
1747
  if (result && typeof result.then === "function") {
1658
1748
  setPending({ id, key });
1659
1749
  result.then(() => {
1660
1750
  setPending(null);
1661
- setEditing(null);
1751
+ if (!keepEditing)
1752
+ setEditing(null);
1662
1753
  }, (err) => {
1663
1754
  setPending(null);
1664
- setEditing(null);
1755
+ if (!keepEditing)
1756
+ setEditing(null);
1665
1757
  setEditError({
1666
1758
  id,
1667
1759
  key,
@@ -1674,7 +1766,8 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1674
1766
  });
1675
1767
  return;
1676
1768
  }
1677
- setEditing(null);
1769
+ if (!keepEditing)
1770
+ setEditing(null);
1678
1771
  }
1679
1772
  const reduceMotion = (0, core_1.usePrefersReducedMotion)();
1680
1773
  // crisp/#577 -- keyboard nav to an off-screen (virtualized-out) cell
@@ -2093,7 +2186,7 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
2093
2186
  } })) : columnMenu && !isPrimary ? ((0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "start", trigger: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), "aria-label": `${typeof col.header === "string"
2094
2187
  ? col.header
2095
2188
  : col.key} ${crispMessages["column options"] ?? "column options"}`, children: inner }), children: [(0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => setSort({ key: col.key, dir: "asc" }), children: sortAscendingLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => setSort({ key: col.key, dir: "desc" }), children: sortDescendingLabel }), col.managed === false ? null : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => moveColumn(col.key, -1), children: moveColumnLeftLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => moveColumn(col.key, 1), children: moveColumnRightLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => setRenaming(col.key), children: editColumnLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => hideColumn(col.key), children: hideColumnLabel })] }))] })) : col.sortable && !isPrimary ? ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), onClick: () => cycleSort(col.key), children: inner })) : (inner), col.readOnly && ((0, jsx_runtime_1.jsx)(lucide_react_1.Lock, { className: "crisp-datatable-readonly", size: 12, strokeWidth: 1.75, "aria-hidden": "true" })), col.resizable !== false && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-resize", "data-resizing": resizingKey === col.key ? "" : undefined, onPointerDown: (e) => startResize(col, e), onPointerMove: moveResize, onPointerUp: endResize }))] }, col.key));
2096
- })] }) }), (0, jsx_runtime_1.jsx)(table_1.TableBody, { children: error ? ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { colSpan: totalCols, children: (0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-empty", children: error }) }) })) : rows.length === 0 ? ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { colSpan: totalCols, children: (0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-empty", children: empty }) }) })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [topSpacerHeight > 0 && ((0, jsx_runtime_1.jsx)("tr", { "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("td", { colSpan: totalCols, style: {
2189
+ })] }) }), (0, jsx_runtime_1.jsx)(table_1.TableBody, { children: loading ? ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { colSpan: totalCols, children: (0, jsx_runtime_1.jsxs)("div", { className: "crisp-datatable-loading", children: [(0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 16 }), loadingLabel] }) }) })) : error ? ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { colSpan: totalCols, children: (0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-empty", children: error }) }) })) : rows.length === 0 ? ((0, jsx_runtime_1.jsx)(table_1.TableRow, { children: (0, jsx_runtime_1.jsx)(table_1.TableCell, { colSpan: totalCols, children: (0, jsx_runtime_1.jsx)("div", { className: "crisp-datatable-empty", children: empty }) }) })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [topSpacerHeight > 0 && ((0, jsx_runtime_1.jsx)("tr", { "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("td", { colSpan: totalCols, style: {
2097
2190
  height: topSpacerHeight,
2098
2191
  padding: 0,
2099
2192
  border: "none",
@@ -2213,7 +2306,7 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
2213
2306
  // portaled popover, so a handler here can't
2214
2307
  // rely on the key event ever bubbling through
2215
2308
  // this element (#453).
2216
- onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? ((0, jsx_runtime_1.jsx)(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : col.editor === "phone" ? ((0, jsx_runtime_1.jsx)(DataPhoneEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : col.editor === "datetime" ? ((0, jsx_runtime_1.jsx)(DataDateTimeEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : ((0, jsx_runtime_1.jsx)(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
2309
+ onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? ((0, jsx_runtime_1.jsx)(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : col.editor === "phone" && col.multiValue ? ((0, jsx_runtime_1.jsx)(DataPhoneMultiEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v, true) })) : col.editor === "phone" ? ((0, jsx_runtime_1.jsx)(DataPhoneEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : col.editor === "datetime" ? ((0, jsx_runtime_1.jsx)(DataDateTimeEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : ((0, jsx_runtime_1.jsx)(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
2217
2310
  }
2218
2311
  if (isEditing) {
2219
2312
  return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, ...rowHeaderProps, onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-datatable-edit",
@@ -68,8 +68,17 @@ const config = (0, core_1.defineRecipe)({
68
68
  in the whole product and making every interactive column's button
69
69
  double it to 24px. Scoping through \`.crisp-table\` adds a second
70
70
  class to the selector (0,2,1 beats 0,1,1) so this can never lose a
71
- specificity tie to source order again. */
72
- & .crisp-table th { padding: 0; }
71
+ specificity tie to source order again.
72
+ \`thead th\`, not a bare \`th\` (crisp#829): #779 made the primary
73
+ column's BODY cell a real \`<th scope="row">\` for a11y, and a bare
74
+ \`th\` here matches that one too, zeroing an inset nothing else
75
+ supplies -- the primary column's text sat flush left, one column's
76
+ cell no longer matching its own header or any sibling \`<td>\`. Only
77
+ the header row's own \`<th>\` needs zero padding (its sort BUTTON
78
+ owns the inset); the body row's \`<th scope="row">\` has no button,
79
+ and needs the SAME base 12px (table.recipe.ts, #465) every other
80
+ cell already gets. */
81
+ & .crisp-table thead th { padding: 0; }
73
82
  /* Same wrapper trap as AUTHORING.md Lesson 40 (ColumnCalc's summary trigger):
74
83
  Menu wraps its trigger in a flex div that sizes to its content, so a
75
84
  long header grew past the column instead of filling it -- with nothing
@@ -183,6 +192,16 @@ const config = (0, core_1.defineRecipe)({
183
192
  & .crisp-datatable-refoption-avatar { flex-shrink: 0; }
184
193
  & .crisp-datatable-refoption--create { color: ${tokens_1.contract.fgMuted}; }
185
194
  & .crisp-datatable-empty { padding: ${tokens_1.contract.space["8"]} ${tokens_1.contract.space["3"]}; text-align: center; color: ${tokens_1.contract.fgMuted}; font-size: ${tokens_1.contract.text.lg}; }
195
+ /* #826 -- the full-body loading state (a filter/sort re-query, or a
196
+ fresh page boot), measured live on Attio's Companies grid on both
197
+ triggers: icon+text centred in the row-body area, 10px gap, 14px/500
198
+ weight/20px line-height, both icon and text in the SAME muted
199
+ rgba(0,0,0,0.63) -- the same color-mix figure this file already uses
200
+ for other muted text (e.g. -phone-dial). Distinct from the cold
201
+ in-place row load (blank, no indicator) and the warm-navigation
202
+ per-cell shimmer (#599) -- neither of those swaps the whole body. */
203
+ & .crisp-datatable-loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: ${tokens_1.contract.space["8"]} ${tokens_1.contract.space["3"]}; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; line-height: 20px; }
204
+ & .crisp-datatable-loading .crisp-spinner { border-top-color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); }
186
205
  /* #579: click-to-expand overlay for a truncated cell -- a read-only,
187
206
  full-value card, settled live as click (not Attio's usual
188
207
  hover-expand) and measured position: absolute, unlike this file's
@@ -277,6 +296,10 @@ const config = (0, core_1.defineRecipe)({
277
296
  & .crisp-datatable-tag[data-tone="orange"] { background: color-mix(in srgb, ${tokens_1.contract.bgOrange} 16%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgOrange} 82%, ${tokens_1.contract.fgPrimary}); }
278
297
  & .crisp-datatable-num { font-variant-numeric: tabular-nums; }
279
298
  & .crisp-datatable-muted { color: ${tokens_1.contract.fgMuted}; }
299
+ /* crisp#822 -- a date/datetime cell's danger/overdue tone: same token as
300
+ TasksTable's own overdue due-date (crisp#462), plain text colour, no
301
+ fill/weight change. */
302
+ & .crisp-datatable-danger { color: ${tokens_1.contract.fgDanger}; }
280
303
  /* url/email cell types (#654) -- same measured values as RecordsTable's
281
304
  own .crisp-recordstable-link, which this mirrors: no underline at
282
305
  rest, underline on hover, own-colour text rather than a browser-blue
@@ -290,6 +313,44 @@ const config = (0, core_1.defineRecipe)({
290
313
  & .crisp-datatable-phone-dial { color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); }
291
314
  & .crisp-datatable-phone-editor { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.tableRow}; padding: 0 ${tokens_1.contract.space["2"]}; }
292
315
  & .crisp-datatable-phone-editor input { flex: 1; min-width: 0; }
316
+ /* country-picker option row (#842) -- confirmed live: Attio's own popover
317
+ is plain text, no flag anywhere (not the option row, not the closed
318
+ trigger), name opaque + dial code muted rgba(0,0,0,0.63) -- the SAME
319
+ color-mix figure as -phone-dial above. The internal name/dial gap
320
+ wasn't independently measured this pass (Attio's own two <div>s gave
321
+ no exact number); reused space["1"], the same gap -phone already uses
322
+ for its own flag/dial/digits row. */
323
+ & .crisp-datatable-country-option { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; }
324
+ & .crisp-datatable-country-dial { color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); }
325
+ /* multi-value phone (#816) -- confirmed live (a real custom "Multiple
326
+ values" Phone Number attribute, and the built-in People "Phone
327
+ numbers" field): the collapsed cell is a 4px-gap flex row of real
328
+ tel: links in a measured link blue (~rgb(59,108,232) pixel-sampled;
329
+ reused as the closest already-tokened blue, brand600, rather than
330
+ adding a third blue token for a delta this small), underlined,
331
+ UNLIKE the single-value cell above (plain text, no link, no
332
+ underline) -- multi-value is the one shape Attio makes into a real
333
+ link. The cell hard-clips on overflow (no ellipsis -- confirmed
334
+ live, a second value can end mid-digit with no "..."). */
335
+ & .crisp-datatable-phone-multi { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; overflow: hidden; }
336
+ & .crisp-datatable-phone-link { color: ${tokens_1.contract.bgBrandSolid}; text-decoration: underline; white-space: nowrap; }
337
+ /* multi-value editor (#816) -- confirmed live: a stack of committed-
338
+ value rows (32px = size.menuRow, 5px 8px padding, 8px gap between
339
+ the grip/value/actions, 16px icons) above one ALWAYS-blank trailing
340
+ DataPhoneEditor-shaped row -- that blank row is the only "add
341
+ another" affordance, there's no separate button for it. The
342
+ copy/delete pair reuses .crisp-datatable-entity-action's own
343
+ hover-reveal chrome (not independently re-measured as its own hit
344
+ target this pass, only the 16px icon glyphs were). */
345
+ & .crisp-datatable-phone-multi-editor { display: flex; flex-direction: column; }
346
+ & .crisp-datatable-phone-multi-row { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: ${tokens_1.contract.size.menuRow}; padding: 5px ${tokens_1.contract.space["2"]}; }
347
+ & .crisp-datatable-phone-multi-row .crisp-datatable-phone-link { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
348
+ & .crisp-datatable-phone-multi-grip { color: ${tokens_1.contract.fgMuted}; flex-shrink: 0; cursor: grab; }
349
+ & .crisp-datatable-phone-multi-actions { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; margin-inline-start: auto; flex-shrink: 0; opacity: 0; }
350
+ & .crisp-datatable-phone-multi-row:hover .crisp-datatable-phone-multi-actions,
351
+ & .crisp-datatable-phone-multi-actions:focus-within { opacity: 1; }
352
+ & .crisp-datatable-phone-multi-action { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin: 0; padding: 0; border: none; border-radius: ${tokens_1.contract.radius.sm}; background: none; font-family: inherit; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 55%, transparent); cursor: pointer; flex-shrink: 0; }
353
+ & .crisp-datatable-phone-multi-action:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); color: ${tokens_1.contract.fgPrimary}; }
293
354
  /* datetime cell editor (#762) -- DatePickerField + TimePicker side by side. */
294
355
  & .crisp-datatable-datetime-editor { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.tableRow}; padding: 0 ${tokens_1.contract.space["2"]}; }
295
356
  /* rating cell type/editor (#760) -- measured live: 16px stars on an
@@ -204,7 +204,7 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
204
204
  return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
205
205
  // controlled by the listbox above; `readOnly` would bar it from
206
206
  // validation, which is the only reason this input is not hidden
207
- onChange: () => { } })), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-select-value", children: selectedOption ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [selectedOption.color && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-placeholder", children: placeholder })) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { sourceRef: rootRef, children: (0, jsx_runtime_1.jsxs)("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-select-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
207
+ onChange: () => { } })), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-select-value", children: selectedOption ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [selectedOption.color && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.triggerLabel ?? selectedOption.label] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-placeholder", children: placeholder })) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { sourceRef: rootRef, children: (0, jsx_runtime_1.jsxs)("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-select-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
208
208
  setQuery(e.target.value);
209
209
  setHighlight(0);
210
210
  onSearchChange?.(e.target.value);
@@ -28,8 +28,25 @@ const config = (0, core_1.defineRecipe)({
28
28
  /* #554: sticky on the vertical axis, mirroring RecordsTable's own
29
29
  \`.crisp-recordstable-th\`. z-index 2 sits above a plain body cell (auto)
30
30
  and below a sticky-column corner cell (DataTable's \`th[data-sticky]\`,
31
- z-index 3), so the corner always wins. */
32
- & th { position: sticky; top: 0; z-index: 2; height: ${tokens_1.contract.size.tableHeader}; background: ${tokens_1.contract.bgSurface}; border-top: 1px solid ${tokens_1.contract.borderSubtle}; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; letter-spacing: -0.14px; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); user-select: none; }
31
+ z-index 3), so the corner always wins.
32
+ \`thead th\`, not a bare \`th\` (crisp#833/#835): DataTable's primary
33
+ column renders its BODY cell as a real \`<th scope="row">\` (#779, for
34
+ a11y), and a bare \`th\` here matched that one too -- every property in
35
+ this rule leaked onto it: \`height: tableHeader\` (40px) stretched the
36
+ whole body row to 40px instead of 36 (#833, confirmed live on the
37
+ showcase, every td in the row equalized to the one th); \`color:
38
+ color-mix(...63%...)\` muted the primary column's own text below every
39
+ sibling \`<td>\`'s full-opacity color (#835, re-measured live against
40
+ Attio's own header labels -- including a genuinely computed one,
41
+ "Twitter follower count" -- all fully opaque \`rgb(16,17,18)\`, no
42
+ muting at all, so this drops the color-mix entirely rather than
43
+ narrowing it); \`position:sticky; top:0\` tried to stick a BODY row
44
+ to the scroll container's top; the extra \`border-top\` doubled that
45
+ row's own grid line. \`thead th\` simply never matches
46
+ \`tbody th[scope=row]\` at all, so none of this reaches it; the base
47
+ \`& th, & td\` rule above (36px, one border-bottom, full opacity via
48
+ \`& table\`'s own color) applies to it like every other cell. */
49
+ & thead th { position: sticky; top: 0; z-index: 2; height: ${tokens_1.contract.size.tableHeader}; background: ${tokens_1.contract.bgSurface}; border-top: 1px solid ${tokens_1.contract.borderSubtle}; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; letter-spacing: -0.14px; color: ${tokens_1.contract.fgPrimary}; user-select: none; }
33
50
  & td { color: ${tokens_1.contract.fgPrimary}; background: ${tokens_1.contract.bgSurface}; }
34
51
  & th:last-child, & td:last-child { border-inline-end: none; }
35
52
  & tbody tr:last-child td { border-bottom: none; }
@@ -56,7 +73,7 @@ const config = (0, core_1.defineRecipe)({
56
73
  // bottom one: the container's edge wins, cells never double it.
57
74
  surface: {
58
75
  card: `border: 1px solid ${tokens_1.contract.borderSubtle}; border-radius: ${tokens_1.contract.radius.xl}; overflow: hidden;
59
- & th { border-top: none; }
76
+ & thead th { border-top: none; }
60
77
  & tfoot td { border-bottom: none; }`,
61
78
  bleed: `border: none; border-radius: 0;`,
62
79
  },
@@ -53,7 +53,14 @@ const toolbarChromeHead = (p) => ` & .${namespace_1.CLASS_PREFIX}${p}-toolbar
53
53
  & .${namespace_1.CLASS_PREFIX}${p}-search svg { flex-shrink: 0; }
54
54
  & .${namespace_1.CLASS_PREFIX}${p}-search input { border: none; outline: none; background: transparent; font-family: inherit; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgPrimary}; width: 180px; }
55
55
  & .${namespace_1.CLASS_PREFIX}${p}-search input::placeholder { color: ${tokens_1.contract.fgMuted}; }
56
- & .${namespace_1.CLASS_PREFIX}${p}-tool { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.row}; margin: 0; padding: 0 ${tokens_1.contract.space["2"]}; border: none; border-radius: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgSurface}; box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderDivider}; color: ${tokens_1.contract.fgPrimary}; font-family: inherit; font-size: ${tokens_1.contract.text.md}; font-weight: ${tokens_1.contract.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
56
+ /* crisp/#830: every trigger label here (Sort, Filter, View settings,
57
+ Import/Export -- all compose this one shared .tool rule) read the
58
+ "md" text size, 13px. Re-measured on Attio's own trigger LEAF node
59
+ (the button root itself inherits Attio's own 10px rem base and is a
60
+ red herring, the same trap the original "10px font" layout-audit
61
+ finding fell into): every label leaf is 14px/500/lh20, the "lg" text
62
+ size. */
63
+ & .${namespace_1.CLASS_PREFIX}${p}-tool { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.row}; margin: 0; padding: 0 ${tokens_1.contract.space["2"]}; border: none; border-radius: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgSurface}; box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderDivider}; color: ${tokens_1.contract.fgPrimary}; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
57
64
  & .${namespace_1.CLASS_PREFIX}${p}-tool:hover { background: ${tokens_1.contract.bgRaised}; }
58
65
  & .${namespace_1.CLASS_PREFIX}${p}-tool svg { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; }
59
66
  /* crisp/#772: re-measured live on Sort with an actual sort applied --
@@ -78,8 +85,22 @@ const toolbarChromeHead = (p) => ` & .${namespace_1.CLASS_PREFIX}${p}-toolbar
78
85
  same reasoning --ghost above already uses. Only wired into
79
86
  TableToolbar itself (tabletoolbar.tsx) this pass -- RecordsTable's
80
87
  own Sort/Filter triggers share this same chrome function but are
81
- deprecated, not touched here. */
82
- & .${namespace_1.CLASS_PREFIX}${p}-tool--dashed { position: relative; box-shadow: none; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 2%, transparent); }
88
+ deprecated, not touched here.
89
+ crisp/#844, correcting #831: the label colour was added here later,
90
+ muting only the FILTER trigger (via a "--filter" modifier), on the
91
+ assumption that was a Filter-specific tone distinct from Sort's.
92
+ Re-measured live and that premise was wrong -- Sort's own resting
93
+ label ("Sort", no condition set) is the exact SAME muted rgba(0,0,0,.5)
94
+ as Filter's, not full-strength. (The rgba(0,0,0,.63) that looked like
95
+ "Sort's colour" in an earlier pass is a different thing entirely: the
96
+ "Sorted by" PREFIX word inside an ACTIVE sort chip, #788 -- a
97
+ compound-label treatment that only exists once a sort is applied, not
98
+ a resting-state colour.) --dashed is currently only ever applied to
99
+ Sort's and Filter's own empty triggers (never View settings/
100
+ Import-Export, which stay full-strength always, confirmed live), so
101
+ the mute belongs on --dashed itself, not gated behind a Filter-only
102
+ modifier. */
103
+ & .${namespace_1.CLASS_PREFIX}${p}-tool--dashed { position: relative; box-shadow: none; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 2%, transparent); color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 50%, transparent); }
83
104
  & .${namespace_1.CLASS_PREFIX}${p}-tool--dashed::before { content: ""; position: absolute; inset: 0; border-radius: inherit; border: 1px dashed color-mix(in srgb, ${tokens_1.contract.fgPrimary} 6%, transparent); pointer-events: none; }
84
105
  & .${namespace_1.CLASS_PREFIX}${p}-tool--dashed:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); }
85
106
  & .${namespace_1.CLASS_PREFIX}${p}-toolgroup { position: relative; display: inline-flex; }
@@ -7,16 +7,26 @@
7
7
  radius, and datatable-grip and the embedded pickers zero theirs. An 8px
8
8
  radius survived the chip-to-full-cell conversion and shipped rounded
9
9
  until #282, so the spec check now guards it. */
10
- /* The hit target is the WHOLE summary cell -- measured 246x37 with
10
+ /* The hit target is the WHOLE summary cell -- measured 246x36 with
11
11
  padding 0 12px, not a content-sized chip. Clicking anywhere in the cell
12
- opens the aggregation menu. */
13
- & .crisp-columncalc-trigger { display: flex; align-items: center; gap: var(--crisp-space-1); width: 100%; height: 100%; min-height: var(--crisp-size-tableRow); margin: 0; padding: 0 var(--crisp-space-3); border: none; background: transparent; color: var(--crisp-fg-muted); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); white-space: nowrap; cursor: pointer; transition: background-color .12s, color .12s; }
12
+ opens the aggregation menu.
13
+ No `min-height` (crisp#834, found reproducing the footer-row-38px-
14
+ not-36px bug in an isolated browser test, not table.recipe.ts's own border/box-sizing, the hypothesis the issue itself flagged as unconfirmed): the table cell's OWN `height: tableRow` (36px,
15
+ border-box) leaves 34px of content box once its 2px of top+bottom
16
+ border are counted, and `min-height: tableRow` here demanded 36px
17
+ of CONTENT height regardless -- overflowing the cell by 2px, which
18
+ auto-height table layout resolves by growing the whole row to fit,
19
+ landing at exactly the reported 38. `height: 100%` alone already
20
+ fills whatever the cell's real content box is (the doc comment above this rule's own stated intent), with nothing left demanding more. */
21
+ & .crisp-columncalc-trigger { display: flex; align-items: center; gap: var(--crisp-space-1); width: 100%; height: 100%; margin: 0; padding: 0 var(--crisp-space-3); border: none; background: transparent; color: var(--crisp-fg-muted); font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); white-space: nowrap; cursor: pointer; transition: background-color .12s, color .12s; }
14
22
  & .crisp-columncalc-trigger:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); color: var(--crisp-fg-primary); }
15
23
  & .crisp-columncalc-trigger svg { flex-shrink: 0; opacity: 0.7; }
16
24
  & .crisp-columncalc-trigger[data-align="right"] { justify-content: flex-end; }
17
25
  & .crisp-columncalc-placeholder { overflow: hidden; text-overflow: ellipsis; }
18
26
  & .crisp-columncalc-value { color: var(--crisp-fg-primary); font-variant-numeric: tabular-nums; }
19
- & .crisp-columncalc-op { color: var(--crisp-fg-muted); }
27
+ /* crisp#837: re-measured live against Attio's real footer calc label -- 14px (was 13px, `c.text.md`, fixed on the shared trigger rule above) and rgba(0,0,0,.63), the same color-mix(fgPrimary, 63%) figure this file's own -op label already should have used instead of the flatter
28
+ `fgMuted` token (a close but not measured match). */
29
+ & .crisp-columncalc-op { color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); }
20
30
  & .crisp-columncalc-section { padding: var(--crisp-space-1-5) var(--crisp-space-2) var(--crisp-space-1); font-size: var(--crisp-text-sm); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); }
21
31
  }
22
32
 
@@ -8,16 +8,26 @@
8
8
  radius, and datatable-grip and the embedded pickers zero theirs. An 8px
9
9
  radius survived the chip-to-full-cell conversion and shipped rounded
10
10
  until #282, so the spec check now guards it. */
11
- /* The hit target is the WHOLE summary cell -- measured 246x37 with
11
+ /* The hit target is the WHOLE summary cell -- measured 246x36 with
12
12
  padding 0 12px, not a content-sized chip. Clicking anywhere in the cell
13
- opens the aggregation menu. */
14
- & .crisp-columncalc-trigger { display: flex; align-items: center; gap: var(--crisp-space-1); width: 100%; height: 100%; min-height: var(--crisp-size-tableRow); margin: 0; padding: 0 var(--crisp-space-3); border: none; background: transparent; color: var(--crisp-fg-muted); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); white-space: nowrap; cursor: pointer; transition: background-color .12s, color .12s; }
13
+ opens the aggregation menu.
14
+ No `min-height` (crisp#834, found reproducing the footer-row-38px-
15
+ not-36px bug in an isolated browser test, not table.recipe.ts's own border/box-sizing, the hypothesis the issue itself flagged as unconfirmed): the table cell's OWN `height: tableRow` (36px,
16
+ border-box) leaves 34px of content box once its 2px of top+bottom
17
+ border are counted, and `min-height: tableRow` here demanded 36px
18
+ of CONTENT height regardless -- overflowing the cell by 2px, which
19
+ auto-height table layout resolves by growing the whole row to fit,
20
+ landing at exactly the reported 38. `height: 100%` alone already
21
+ fills whatever the cell's real content box is (the doc comment above this rule's own stated intent), with nothing left demanding more. */
22
+ & .crisp-columncalc-trigger { display: flex; align-items: center; gap: var(--crisp-space-1); width: 100%; height: 100%; margin: 0; padding: 0 var(--crisp-space-3); border: none; background: transparent; color: var(--crisp-fg-muted); font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); white-space: nowrap; cursor: pointer; transition: background-color .12s, color .12s; }
15
23
  & .crisp-columncalc-trigger:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); color: var(--crisp-fg-primary); }
16
24
  & .crisp-columncalc-trigger svg { flex-shrink: 0; opacity: 0.7; }
17
25
  & .crisp-columncalc-trigger[data-align="right"] { justify-content: flex-end; }
18
26
  & .crisp-columncalc-placeholder { overflow: hidden; text-overflow: ellipsis; }
19
27
  & .crisp-columncalc-value { color: var(--crisp-fg-primary); font-variant-numeric: tabular-nums; }
20
- & .crisp-columncalc-op { color: var(--crisp-fg-muted); }
28
+ /* crisp#837: re-measured live against Attio's real footer calc label -- 14px (was 13px, `c.text.md`, fixed on the shared trigger rule above) and rgba(0,0,0,.63), the same color-mix(fgPrimary, 63%) figure this file's own -op label already should have used instead of the flatter
29
+ `fgMuted` token (a close but not measured match). */
30
+ & .crisp-columncalc-op { color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); }
21
31
  & .crisp-columncalc-section { padding: var(--crisp-space-1-5) var(--crisp-space-2) var(--crisp-space-1); font-size: var(--crisp-text-sm); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); }
22
32
  }
23
33