@crisp-ui-kit/crisp 0.54.9 → 0.54.11

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 (52) hide show
  1. package/dist/cjs/components/datatable/datatable.js +175 -12
  2. package/dist/cjs/components/datatable/datatable.recipe.js +30 -0
  3. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +25 -2
  4. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +159 -63
  5. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +41 -9
  6. package/dist/cjs/components/text/text.recipe.js +17 -7
  7. package/dist/cjs/product/recordpage/column.js +19 -0
  8. package/dist/cjs/product/recordpage/recordpage.js +5 -4
  9. package/dist/cjs/product/recordpage/test-fixtures.js +55 -0
  10. package/dist/cjs/product/recordstable/index.js +1 -1
  11. package/dist/cjs/product/recordstable/recordstable.js +5 -0
  12. package/dist/cjs/product/savedviews/savedviews.recipe.js +13 -11
  13. package/dist/cjs/product/taskstable/taskstable.js +5 -0
  14. package/dist/datatable.css +17 -0
  15. package/dist/datatable.layered.css +17 -0
  16. package/dist/esm/components/datatable/datatable.js +175 -12
  17. package/dist/esm/components/datatable/datatable.recipe.js +30 -0
  18. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +25 -2
  19. package/dist/esm/components/tabletoolbar/tabletoolbar.js +159 -63
  20. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +41 -9
  21. package/dist/esm/components/text/text.recipe.js +17 -7
  22. package/dist/esm/product/recordpage/column.js +16 -0
  23. package/dist/esm/product/recordpage/recordpage.js +2 -1
  24. package/dist/esm/product/recordpage/test-fixtures.js +52 -0
  25. package/dist/esm/product/recordstable/index.js +1 -1
  26. package/dist/esm/product/recordstable/recordstable.js +5 -0
  27. package/dist/esm/product/savedviews/savedviews.recipe.js +13 -11
  28. package/dist/esm/product/taskstable/taskstable.js +5 -0
  29. package/dist/recordstable.css +19 -2
  30. package/dist/recordstable.layered.css +19 -2
  31. package/dist/savedviews.css +6 -18
  32. package/dist/savedviews.layered.css +6 -18
  33. package/dist/styles.css +78 -24
  34. package/dist/styles.layered.css +78 -24
  35. package/dist/tabletoolbar.css +35 -3
  36. package/dist/tabletoolbar.layered.css +35 -3
  37. package/dist/text.css +1 -1
  38. package/dist/text.layered.css +1 -1
  39. package/dist/types/components/datatable/datatable.d.ts +38 -3
  40. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +26 -1
  41. package/dist/types/components/text/text.recipe.d.ts +1 -1
  42. package/dist/types/product/recordpage/column.d.ts +112 -0
  43. package/dist/types/product/recordpage/recordpage.d.ts +1 -1
  44. package/dist/types/product/recordpage/test-fixtures.d.ts +3 -0
  45. package/dist/types/product/recordpanel/recordpanel.d.ts +1 -1
  46. package/dist/types/product/recordstable/index.d.ts +1 -1
  47. package/dist/types/product/recordstable/recordstable.d.ts +6 -1
  48. package/dist/types/product/taskstable/taskstable.d.ts +5 -0
  49. package/package.json +1 -1
  50. /package/dist/cjs/product/{recordstable → recordpage}/editable-field.js +0 -0
  51. /package/dist/esm/product/{recordstable → recordpage}/editable-field.js +0 -0
  52. /package/dist/types/product/{recordstable → recordpage}/editable-field.d.ts +0 -0
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.testRows = exports.testColumns = void 0;
4
+ const lucide_react_1 = require("lucide-react");
5
+ // Moved here from product/recordstable/recordstable.tsx's `defaultColumns`/
6
+ // `defaultRows` (crisp#786): RecordsTable is deprecated and its whole
7
+ // directory is being deleted, but RecordPage's own tests (and the shared
8
+ // RTL-keyboard-convention tests that live alongside them) still need a
9
+ // real, 12-column-deep dataset to exercise "a field past the first 5 needs
10
+ // View all values" and similar shape-dependent assertions. Simplified from
11
+ // the original: no baked-in company-logo data URIs (nothing here asserts on
12
+ // avatars), a plain favicon URL instead.
13
+ exports.testColumns = [
14
+ { key: "company", label: "Company", type: "entity", icon: lucide_react_1.Building2 },
15
+ { key: "domain", label: "Domain", type: "url", icon: lucide_react_1.Globe },
16
+ {
17
+ key: "categories",
18
+ label: "Categories",
19
+ type: "tags",
20
+ icon: lucide_react_1.Tag,
21
+ width: 240,
22
+ },
23
+ {
24
+ key: "employees",
25
+ label: "Employees",
26
+ type: "number",
27
+ icon: lucide_react_1.Users,
28
+ width: 120,
29
+ },
30
+ { key: "location", label: "Location", type: "text", icon: lucide_react_1.MapPin },
31
+ { key: "stage", label: "Stage", type: "tags", icon: lucide_react_1.TrendingUp, width: 150 },
32
+ { key: "ceo", label: "CEO", type: "text", icon: lucide_react_1.Briefcase, width: 170 },
33
+ { key: "twitter", label: "Twitter", type: "url", icon: lucide_react_1.AtSign, width: 150 },
34
+ {
35
+ key: "followers",
36
+ label: "Followers",
37
+ type: "number",
38
+ icon: lucide_react_1.Users,
39
+ width: 120,
40
+ },
41
+ { key: "linkedin", label: "LinkedIn", type: "url", icon: lucide_react_1.Globe, width: 180 },
42
+ {
43
+ key: "founded",
44
+ label: "Founded",
45
+ type: "date",
46
+ icon: lucide_react_1.Calendar,
47
+ width: 110,
48
+ },
49
+ { key: "arr", label: "Est. ARR", type: "number", icon: lucide_react_1.Hash, width: 130 },
50
+ ];
51
+ // biome-ignore format: one row per line keeps the wide demo dataset readable
52
+ exports.testRows = [
53
+ { id: "google", company: { name: "Google", avatar: "https://www.google.com/s2/favicons?domain=google.com&sz=64" }, domain: "google.com", categories: ["Search", "Ads", "Cloud", "AI", "Android", "Maps", "Hardware", "Workspace", "Chrome"], employees: 182000, location: "Mountain View, CA", stage: ["Public"], ceo: "Sundar Pichai", twitter: "twitter.com/google", followers: 29800000, linkedin: "linkedin.com/company/google", founded: "1998", arr: 307000000000 },
54
+ { id: "stripe", company: { name: "Stripe", avatar: "https://www.google.com/s2/favicons?domain=stripe.com&sz=64" }, domain: "stripe.com", categories: ["Payments", "Fintech"], employees: 8000, location: "San Francisco, CA", stage: ["Series I"], ceo: "Patrick Collison", twitter: "twitter.com/stripe", followers: 1200000, linkedin: "linkedin.com/company/stripe", founded: "2010", arr: 16000000000 },
55
+ ];
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.recordstableRecipe = exports.tagTone = exports.RecordsTable = exports.defaultRows = exports.defaultColumns = exports.useEditableField = exports.editableInputType = void 0;
4
- var editable_field_1 = require("./editable-field");
4
+ var editable_field_1 = require("../recordpage/editable-field");
5
5
  Object.defineProperty(exports, "editableInputType", { enumerable: true, get: function () { return editable_field_1.editableInputType; } });
6
6
  Object.defineProperty(exports, "useEditableField", { enumerable: true, get: function () { return editable_field_1.useEditableField; } });
7
7
  var recordstable_1 = require("./recordstable");
@@ -552,6 +552,11 @@ function CreateRecordDialog({ open, onOpenChange, columns, entityLabel, onCreate
552
552
  * editing (double-click a text/number/url/date cell), add-row and add-column,
553
553
  * and a floating bulk-action bar (delete / deselect) when rows are selected.
554
554
  * Selection can be controlled via `selectedIds`/`onSelectionChange`.
555
+ *
556
+ * @deprecated Reach for `DataTable` instead — a single Attio-exact records
557
+ * grid replaces the separate table variants. `RecordsTable` has no
558
+ * consumers left (checked GRC and the fms/Mercury admin) and is slated for
559
+ * removal in a future minor.
555
560
  */
556
561
  function RecordsTable({ labels, columns: columnsProp = exports.defaultColumns, rows: rowsProp = exports.defaultRows, selectedIds, onSelectionChange, showNew = true, allowAddColumn = true, onOpenRecord, onOpenRecordPage, avatarTone = "brand", emptyStateExtra, className, ...rest }) {
557
562
  const [cols, setCols] = React.useState(columnsProp);
@@ -26,24 +26,26 @@ const config = (0, core_1.defineRecipe)({
26
26
  position: relative; display: inline-flex; font-family: ${tokens_1.contract.font.sans};
27
27
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
28
28
 
29
- /* Trigger fill measured rgb(246,247,247); bgRaised's light value is
30
- #f4f4f5 (244,244,245) — a 2-unit-per-channel difference, inside
31
- screenshot/anti-aliasing noise, not a second real colour. Reusing the
32
- token (Lesson 3 — tokens, not raw px/hex) gets a correct,
33
- already-established dark-mode fill for free instead of a light-only
34
- literal; AUTHORING.md Lesson 29 is the case where that shortcut went
35
- wrong (a flat colour that stayed visible where the reference had
36
- none) — this is the opposite risk (a token that already flips per
37
- theme, applied to the closest measured match). */
29
+ /* crisp/#772 follow-up, re-measured live 2026-09-25 in the trigger's
30
+ actual position (Attio's real view bar, next to View settings --
31
+ fixed there in #766/#768/#778): white (bgSurface, rgb(255,255,255)
32
+ exact) with elevation.field's 3-layer shadow, the same raised
33
+ chrome View settings and Import/Export both carry
34
+ (tabletoolbar.recipe.ts's own --raised, not reused here directly
35
+ since this is a separate recipe/component, but the identical measured
36
+ values). Supersedes the original #555 measurement (rgb(246,247,247),
37
+ reused as bgRaised -- a flat grey fill with no shadow at all), which
38
+ was wrong: this trigger was never re-checked once it moved into its
39
+ real view-bar position. */
38
40
  & .crisp-savedviews-trigger {
39
41
  box-sizing: border-box; display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]};
40
42
  height: ${tokens_1.contract.size.row}; margin: 0; padding: ${tokens_1.contract.space["1"]} ${tokens_1.contract.space["1.5"]};
41
- border: none; border-radius: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgRaised};
43
+ border: none; border-radius: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgSurface}; box-shadow: ${tokens_1.elevation.field};
42
44
  font-family: inherit; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgPrimary};
43
45
  cursor: pointer; white-space: nowrap; transition: background-color .12s ease;
44
46
  }
45
47
  & .crisp-savedviews-trigger:hover,
46
- & .crisp-savedviews-trigger[data-open] { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, ${tokens_1.contract.bgRaised}); }
48
+ & .crisp-savedviews-trigger[data-open] { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, ${tokens_1.contract.bgSurface}); }
47
49
  & .crisp-savedviews-trigger-icon { flex-shrink: 0; display: inline-flex; width: ${tokens_1.contract.size.icon}; height: ${tokens_1.contract.size.icon}; }
48
50
  & .crisp-savedviews-trigger-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
49
51
  & .crisp-savedviews-trigger-chevron { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; }
@@ -14,6 +14,11 @@ function Cell({ flex, className, children, }) {
14
14
  * taskstable.recipe.ts) with a typed column/cell layer: a title+checkbox
15
15
  * row grouped into due-date sections, a due-date cell that goes red past
16
16
  * due, and consumer-supplied record/assignee chip slots.
17
+ *
18
+ * @deprecated Reach for `DataTable`'s `groupBy` instead — a single
19
+ * Attio-exact records grid replaces the separate table variants.
20
+ * `TasksTable` has no consumers left (checked GRC and the fms/Mercury
21
+ * admin) and is slated for removal in a future minor.
17
22
  */
18
23
  function TasksTable({ columns, groups, leading, trailing, emptyLabel: emptyLabelProp, completeLabel: completeLabelProp, incompleteLabel: incompleteLabelProp, className, ...rest }) {
19
24
  const crispMessages = (0, core_1.useCrispMessages)();
@@ -90,6 +90,9 @@
90
90
  & tbody tr[data-selected] td { background: color-mix(in srgb, var(--crisp-bg-brand-solid) 10%, var(--crisp-bg-surface)); }
91
91
  & tbody tr[data-selected]:hover td { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, color-mix(in srgb, var(--crisp-bg-brand-solid) 10%, var(--crisp-bg-surface))); }
92
92
  & .crisp-datatable-editable:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, var(--crisp-bg-surface)); cursor: text; }
93
+ /* crisp/#758: AI-enriched column marker, ported from RecordsTable's own already-measured value -- a full-bleed #9b69ff (bgPurple) wash at 4%, composited OVER whatever row-state background the cell already has (hover, selected), which is why it's an ::after overlay and not a
94
+ plain background. */
95
+ & td[data-enriched]::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: color-mix(in srgb, var(--crisp-bg-purple) 4%, transparent); }
93
96
  & .crisp-datatable-edit { width: 100%; height: 28px; margin: 0; padding: 0 var(--crisp-space-1-5); border: none; border-radius: var(--crisp-radius-sm); background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-bg-brand-solid); font-family: inherit; font-size: var(--crisp-text-lg); color: var(--crisp-fg-primary); outline: none; vertical-align: middle; }
94
97
  & .crisp-datatable-edit:focus { box-shadow: inset 0 0 0 1px var(--crisp-bg-brand-solid); }
95
98
  /* An async onEdit's in-flight state (#385) -- the field/picker dims and stops accepting input rather than disappearing, so the just-typed or just-chosen value stays visible while its commit is pending. Same treatment Input already gives its own disabled state. */
@@ -149,6 +152,10 @@
149
152
  & th[data-drop-after]::after { inset-inline-end: -1.5px; }
150
153
  /* typed cells — entity (avatar + name), colour-coded tags, numbers, dates */
151
154
  & .crisp-datatable-entity { display: flex; align-items: center; gap: var(--crisp-space-1-5); min-width: 0; color: var(--crisp-fg-primary); font-weight: var(--crisp-weight-medium); }
155
+ /* crisp/#601 supersession follow-up -- the same flex-row shape .crisp-datatable-entity gives its own avatar+name+actions, but structural only (no entity-specific colour/weight): lets onOpenComments/onOpenPreview compose alongside ANY column's own
156
+ content (a custom render, or a non-entity built-in type), not just
157
+ type: "entity"'s own inline rendering. */
158
+ & .crisp-datatable-cell-actions-row { display: flex; align-items: center; gap: var(--crisp-space-1-5); min-width: 0; width: 100%; }
152
159
  /* crisp/#601, G-050: measured LIVE (getComputedStyle on the name text, plain hover vs rest) that Attio's underline is unconditional, not a
153
160
  hover reveal -- identical at rest and on hover: text-decoration-color
154
161
  is rgba(0,0,0,0.09), a 9% tint, not the name's own full-contrast text colour. The previous :hover-gated, full-contrast rule here was itself unmeasured and wrong in two ways at once. */
@@ -180,6 +187,16 @@
180
187
  /* url/email cell types (#654) -- same measured values as RecordsTable's own .crisp-recordstable-link, which this mirrors: no underline at rest, underline on hover, own-colour text rather than a browser-blue link. */
181
188
  & .crisp-datatable-link { color: var(--crisp-fg-primary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
182
189
  & .crisp-datatable-link:hover { text-decoration: underline; }
190
+ /* rating cell type/editor (#760) -- measured live: 16px stars on an 18px pitch (a 2px gap), an empty star at rgba(0,0,0,0.1), a committed/filled star at brand600 (--crisp-bg-brand-solid, an exact match to the measured #266df0), and a distinct hover-preview colour -- rgb(33,91,196) -- that's close to but NOT brand700/bgBrandSolidPressed
191
+ (#1f5fd6); kept as its own literal rather than forced onto that token,
192
+ since the two were measured stable and different. The read-only svg
193
+ and the editable button's own inner svg both carry the crisp-datatable-rating-star class on the svg itself (not the button), so one set of fill rules covers both -- the button only adds its own reset + cursor via a separate -btn class. */
194
+ & .crisp-datatable-rating { display: inline-flex; align-items: center; gap: 2px; }
195
+ & .crisp-datatable-rating-star { display: block; }
196
+ & .crisp-datatable-rating-star path { fill: rgba(0, 0, 0, 0.1); }
197
+ & .crisp-datatable-rating-star[data-filled] path { fill: var(--crisp-bg-brand-solid); }
198
+ & .crisp-datatable-rating-star[data-hovering] path { fill: #215bc4; }
199
+ & .crisp-datatable-rating-star-btn { display: inline-flex; padding: 0; margin: 0; border: none; background: none; font-family: inherit; cursor: pointer; }
183
200
  /* row drag-to-reorder -- a Settings > Objects > Attributes screen measures a leading 20px grip column, a 16px grip icon, and the row's own
184
201
  hairline doubling as the drop indicator (it tints toward the brand
185
202
  colour as the active drop target). The affordance + geometry are
@@ -91,6 +91,9 @@
91
91
  & tbody tr[data-selected] td { background: color-mix(in srgb, var(--crisp-bg-brand-solid) 10%, var(--crisp-bg-surface)); }
92
92
  & tbody tr[data-selected]:hover td { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, color-mix(in srgb, var(--crisp-bg-brand-solid) 10%, var(--crisp-bg-surface))); }
93
93
  & .crisp-datatable-editable:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, var(--crisp-bg-surface)); cursor: text; }
94
+ /* crisp/#758: AI-enriched column marker, ported from RecordsTable's own already-measured value -- a full-bleed #9b69ff (bgPurple) wash at 4%, composited OVER whatever row-state background the cell already has (hover, selected), which is why it's an ::after overlay and not a
95
+ plain background. */
96
+ & td[data-enriched]::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: color-mix(in srgb, var(--crisp-bg-purple) 4%, transparent); }
94
97
  & .crisp-datatable-edit { width: 100%; height: 28px; margin: 0; padding: 0 var(--crisp-space-1-5); border: none; border-radius: var(--crisp-radius-sm); background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-bg-brand-solid); font-family: inherit; font-size: var(--crisp-text-lg); color: var(--crisp-fg-primary); outline: none; vertical-align: middle; }
95
98
  & .crisp-datatable-edit:focus { box-shadow: inset 0 0 0 1px var(--crisp-bg-brand-solid); }
96
99
  /* An async onEdit's in-flight state (#385) -- the field/picker dims and stops accepting input rather than disappearing, so the just-typed or just-chosen value stays visible while its commit is pending. Same treatment Input already gives its own disabled state. */
@@ -150,6 +153,10 @@
150
153
  & th[data-drop-after]::after { inset-inline-end: -1.5px; }
151
154
  /* typed cells — entity (avatar + name), colour-coded tags, numbers, dates */
152
155
  & .crisp-datatable-entity { display: flex; align-items: center; gap: var(--crisp-space-1-5); min-width: 0; color: var(--crisp-fg-primary); font-weight: var(--crisp-weight-medium); }
156
+ /* crisp/#601 supersession follow-up -- the same flex-row shape .crisp-datatable-entity gives its own avatar+name+actions, but structural only (no entity-specific colour/weight): lets onOpenComments/onOpenPreview compose alongside ANY column's own
157
+ content (a custom render, or a non-entity built-in type), not just
158
+ type: "entity"'s own inline rendering. */
159
+ & .crisp-datatable-cell-actions-row { display: flex; align-items: center; gap: var(--crisp-space-1-5); min-width: 0; width: 100%; }
153
160
  /* crisp/#601, G-050: measured LIVE (getComputedStyle on the name text, plain hover vs rest) that Attio's underline is unconditional, not a
154
161
  hover reveal -- identical at rest and on hover: text-decoration-color
155
162
  is rgba(0,0,0,0.09), a 9% tint, not the name's own full-contrast text colour. The previous :hover-gated, full-contrast rule here was itself unmeasured and wrong in two ways at once. */
@@ -181,6 +188,16 @@
181
188
  /* url/email cell types (#654) -- same measured values as RecordsTable's own .crisp-recordstable-link, which this mirrors: no underline at rest, underline on hover, own-colour text rather than a browser-blue link. */
182
189
  & .crisp-datatable-link { color: var(--crisp-fg-primary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
183
190
  & .crisp-datatable-link:hover { text-decoration: underline; }
191
+ /* rating cell type/editor (#760) -- measured live: 16px stars on an 18px pitch (a 2px gap), an empty star at rgba(0,0,0,0.1), a committed/filled star at brand600 (--crisp-bg-brand-solid, an exact match to the measured #266df0), and a distinct hover-preview colour -- rgb(33,91,196) -- that's close to but NOT brand700/bgBrandSolidPressed
192
+ (#1f5fd6); kept as its own literal rather than forced onto that token,
193
+ since the two were measured stable and different. The read-only svg
194
+ and the editable button's own inner svg both carry the crisp-datatable-rating-star class on the svg itself (not the button), so one set of fill rules covers both -- the button only adds its own reset + cursor via a separate -btn class. */
195
+ & .crisp-datatable-rating { display: inline-flex; align-items: center; gap: 2px; }
196
+ & .crisp-datatable-rating-star { display: block; }
197
+ & .crisp-datatable-rating-star path { fill: rgba(0, 0, 0, 0.1); }
198
+ & .crisp-datatable-rating-star[data-filled] path { fill: var(--crisp-bg-brand-solid); }
199
+ & .crisp-datatable-rating-star[data-hovering] path { fill: #215bc4; }
200
+ & .crisp-datatable-rating-star-btn { display: inline-flex; padding: 0; margin: 0; border: none; background: none; font-family: inherit; cursor: pointer; }
184
201
  /* row drag-to-reorder -- a Settings > Objects > Attributes screen measures a leading 20px grip column, a 16px grip icon, and the row's own
185
202
  hairline doubling as the drop indicator (it tints toward the brand
186
203
  colour as the active drop target). The affordance + geometry are
@@ -15,6 +15,7 @@ import { Checkbox } from "../checkbox/index.js";
15
15
  import { ColumnCalc } from "../columncalc/index.js";
16
16
  import { ContextMenu } from "../contextmenu/index.js";
17
17
  import { Menu, MenuItem } from "../menu/index.js";
18
+ import { useRadioGroupKeyboard } from "../radiogroup/index.js";
18
19
  import { Select } from "../select/index.js";
19
20
  import { Spinner } from "../spinner/index.js";
20
21
  import { Switch } from "../switch/index.js";
@@ -254,6 +255,73 @@ function formatCell(type, v, symbol, locale = "en-US") {
254
255
  }
255
256
  return asText(v);
256
257
  }
258
+ // crisp/#760 -- the exact 5-point star Attio's own Rating attribute draws
259
+ // (path traced from the live widget's own SVG, `app.attio.com`, both the
260
+ // attribute-creation default-value picker and the grid cell render the
261
+ // identical path). Kept as one shared `d` string rather than a lucide icon
262
+ // so the star's silhouette matches Attio verbatim rather than an
263
+ // approximation, and so `RatingStars` below can flip its own `fill`
264
+ // per-star instead of drawing a whole new icon per state.
265
+ const RATING_STAR_D = "M7.32203 2.81085C7.65793 1.97438 8.84207 1.97438 9.17797 2.81085L10.0851 5.06967C10.2281 5.42587 10.5624 5.66876 10.9454 5.69473L13.3739 5.8594C14.2733 5.92038 14.6392 7.04656 13.9475 7.62451L12.0795 9.18521C11.7849 9.43132 11.6572 9.82433 11.7509 10.1966L12.3447 12.5572C12.5647 13.4313 11.6067 14.1273 10.8433 13.6481L8.78171 12.3538C8.45661 12.1497 8.04339 12.1497 7.71829 12.3538L5.65675 13.6481C4.89333 14.1273 3.93534 13.4313 4.15526 12.5572L4.74911 10.1966C4.84276 9.82433 4.71507 9.43132 4.4205 9.18521L2.55254 7.62451C1.86081 7.04656 2.22673 5.92038 3.12606 5.8594L5.55463 5.69473C5.9376 5.66876 6.27191 5.42587 6.41495 5.06967L7.32203 2.81085Z";
266
+ /** A star row for `type: "rating"` / `editor: "rating"` (crisp/#760).
267
+ * Read-only (a plain `<span>` row, no interaction) unless `onChange` is
268
+ * given, which renders Attio's own measured accessible shape: a
269
+ * `role="radiogroup"` of `role="radio"` buttons, one per star. Clicking a
270
+ * star sets the rating to its 1-based index; clicking the star that
271
+ * already equals the current value clears it to 0 -- confirmed live
272
+ * (rating a record 5, then clicking the 5th star again cleared it
273
+ * entirely rather than re-committing 5). */
274
+ function RatingStars({ value, max = 5, onChange, "aria-label": ariaLabel, }) {
275
+ const [hoverIdx, setHoverIdx] = React.useState(null);
276
+ // The house radiogroup keyboard model (crisp's own spec check requires it
277
+ // on every role="radiogroup", regardless of Attio parity): arrows move
278
+ // focus AND select, Home/End jump to the ends. Landing on a star always
279
+ // SETS the rating to that star's position -- arrow nav has no "clear on
280
+ // re-land" toggle, that's a click-only gesture (see onClick below).
281
+ const { onKeyDown, groupRef } = useRadioGroupKeyboard({
282
+ select: (v) => onChange?.(Number(v)),
283
+ });
284
+ const stars = Array.from({ length: max }, (_, i) => i);
285
+ const content = stars.map((i) => {
286
+ // Hovering star i previews filling every star up to and including it,
287
+ // whether or not it's already part of the committed value -- measured:
288
+ // hovering the 5th star while 4 were already filled turned all 5 the
289
+ // hover colour, not just the would-be-new one.
290
+ const hovering = onChange != null && hoverIdx != null && i <= hoverIdx;
291
+ const filled = hovering ? true : i < value;
292
+ // A native <input type="radio"> can't be unchecked by re-clicking the
293
+ // already-selected one, which is exactly the measured behaviour
294
+ // (clicking the star that already equals the rating clears it to 0) --
295
+ // Attio's own live widget is a <button role="radio"> for the same
296
+ // reason.
297
+ return onChange ? (_jsx("button", { type: "button", role: "radio", "aria-checked": i < value, "aria-label": `${i + 1} star${i === 0 ? "" : "s"}`, "data-value": String(i + 1), className: "crisp-datatable-rating-star-btn", onMouseEnter: () => setHoverIdx(i), onMouseLeave: () => setHoverIdx(null), onClick: (e) => {
298
+ e.stopPropagation();
299
+ onChange(i + 1 === value ? 0 : i + 1);
300
+ }, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", className: "crisp-datatable-rating-star", "data-filled": filled ? "" : undefined, "data-hovering": hovering ? "" : undefined, children: _jsx("path", { d: RATING_STAR_D }) }) }, i)) : (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", className: "crisp-datatable-rating-star", "data-filled": filled ? "" : undefined, children: _jsx("path", { d: RATING_STAR_D }) }, i));
301
+ });
302
+ return onChange ? (_jsx("span", { role: "radiogroup", "aria-label": ariaLabel, className: "crisp-datatable-rating", ref: groupRef, onKeyDown: onKeyDown, children: content })) : (_jsx("span", { className: "crisp-datatable-rating", children: content }));
303
+ }
304
+ /** The preview/comments action icons a column's `onOpenPreview`/
305
+ * `onOpenComments`/`commentCount` produce — the exact markup `type: "entity"`
306
+ * already renders inline, extracted so a column that ISN'T `"entity"` (a
307
+ * custom `render`, or any other built-in type) can compose the same actions
308
+ * alongside its own content instead of the props silently doing nothing
309
+ * (crisp/#601 supersession follow-up: these three props used to only have
310
+ * an effect inside `renderTyped`'s `"entity"` case). `null` when neither
311
+ * prop is set, so a caller can render nothing rather than an empty
312
+ * flex wrapper. */
313
+ function renderCellActions(col, row, rowHovered, openPreviewLabel) {
314
+ if (col.onOpenComments) {
315
+ return (_jsx("span", { className: "crisp-datatable-entity-actions", children: _jsx(CommentCountBadge, { count: col.commentCount?.(row) ?? 0, hovered: rowHovered, onOpen: () => col.onOpenComments?.(row), onOpenPreview: col.onOpenPreview ? () => col.onOpenPreview?.(row) : undefined, previewLabel: openPreviewLabel }) }));
316
+ }
317
+ if (col.onOpenPreview) {
318
+ return (_jsx("span", { className: "crisp-datatable-entity-actions", children: _jsx("button", { type: "button", className: "crisp-datatable-entity-action", "aria-label": openPreviewLabel, onClick: (ev) => {
319
+ ev.stopPropagation();
320
+ col.onOpenPreview?.(row);
321
+ }, children: _jsx(PanelRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) }));
322
+ }
323
+ return null;
324
+ }
257
325
  /** Built-in typed-cell renderer (used when a column has no custom `render`). */
258
326
  // `value` is always the RAW cell value and `display` the caller's `format`
259
327
  // output, if any. They are separate arguments on purpose: the entity and tags
@@ -281,10 +349,7 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
281
349
  // at a DIFFERENT record than the row itself (#501).
282
350
  ev.stopPropagation();
283
351
  col.onEntityClick?.(row);
284
- }, children: e.name })) : (_jsx("span", { className: "crisp-datatable-entity-name", children: e.name })), col.onOpenComments ? (_jsx("span", { className: "crisp-datatable-entity-actions", children: _jsx(CommentCountBadge, { count: col.commentCount?.(row) ?? 0, hovered: rowHovered, onOpen: () => col.onOpenComments?.(row), onOpenPreview: col.onOpenPreview ? () => col.onOpenPreview?.(row) : undefined, previewLabel: openPreviewLabel }) })) : (col.onOpenPreview && (_jsx("span", { className: "crisp-datatable-entity-actions", children: _jsx("button", { type: "button", className: "crisp-datatable-entity-action", "aria-label": openPreviewLabel, onClick: (ev) => {
285
- ev.stopPropagation();
286
- col.onOpenPreview?.(row);
287
- }, children: _jsx(PanelRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) })))] }));
352
+ }, children: e.name })) : (_jsx("span", { className: "crisp-datatable-entity-name", children: e.name })), renderCellActions(col, row, rowHovered, openPreviewLabel)] }));
288
353
  }
289
354
  case "tags": {
290
355
  // asking for a format on a tag column means "render it your way" -- show
@@ -304,6 +369,14 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
304
369
  // translation) can never silently repaint the chip
305
370
  return (_jsx("span", { className: "crisp-datatable-tag", "data-tone": col.tones?.[s] ?? toneFor(s), children: display || s }));
306
371
  }
372
+ case "rating": {
373
+ // Read-only display -- `editor: "rating"` (the always-live picker) is
374
+ // handled separately, the same split `switch` gets (#453).
375
+ const n = typeof value === "number" ? value : Number(value);
376
+ if (!n || Number.isNaN(n))
377
+ return empty;
378
+ return _jsx(RatingStars, { value: n, max: col.maxRating ?? 5 });
379
+ }
307
380
  case "url":
308
381
  case "email": {
309
382
  const text = display || asText(value);
@@ -573,6 +646,19 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
573
646
  // arrow-key nav, Enter) is unaffected -- a plain click resets `selAnchor`
574
647
  // to null, collapsing back to single-cell mode.
575
648
  const [selAnchor, setSelAnchor] = React.useState(null);
649
+ // crisp/#749: Shift+ArrowUp/Down extends the ROW (checkbox) selection --
650
+ // confirmed live on Attio's own Companies grid, a DIFFERENT gesture from
651
+ // `selAnchor` above (that's Shift+CLICK extending a cell-value range;
652
+ // this issue's own text is explicit the two are independent, and arrow
653
+ // keys were never measured as a cell-range trigger, G-101). Same
654
+ // fixed-anchor/moving-cell shape as `selAnchor` -- a spreadsheet's own
655
+ // Shift+Arrow both moves the active cell AND grows/shrinks the selection
656
+ // relative to wherever the drag started, which recomputing the anchor→
657
+ // new-row span from scratch on every press gives for free (arrowing back
658
+ // toward the anchor shrinks the same way arrowing away grows it). Reset
659
+ // to null on any plain (non-shift) navigation or click, same as
660
+ // `selAnchor`'s own reset points.
661
+ const [rowSelectAnchor, setRowSelectAnchor] = React.useState(null);
576
662
  const gridRef = React.useRef(null);
577
663
  // The native-copy-event proxy (crisp/#645): Attio "has no Mousetrap/
578
664
  // JS-keydown binding for c/mod+c ... it copies purely via the native
@@ -982,8 +1068,9 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
982
1068
  * `editor: "select"`/`"reference"` open their own picker instead of this
983
1069
  * native-input family, but through the SAME double-click gate (#453) --
984
1070
  * see the row-render loop's `isEditing` branch for those, this function
985
- * only covers the native-input-backed types. `editor: "switch"` is the
986
- * one editor with no double-click gate at all (always live).
1071
+ * only covers the native-input-backed types.
1072
+ * `editor: "switch"`/`"rating"`/`"checkbox"` are the editors with no
1073
+ * double-click gate at all (always live).
987
1074
  */
988
1075
  function editorFor(col) {
989
1076
  switch (col.type) {
@@ -1160,6 +1247,36 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1160
1247
  setSelected(new Set(rows.map((rw) => rw.id)));
1161
1248
  return;
1162
1249
  }
1250
+ // crisp/#749: Shift+ArrowUp/Down extends the row-checkbox selection --
1251
+ // confirmed live, distinct from Shift+Click's cell-range gesture above
1252
+ // (that one is `selAnchor`; this is `rowSelectAnchor`, and the two
1253
+ // don't interact). Recomputes the whole anchor→destination span on
1254
+ // every press rather than accumulating, so arrowing back toward the
1255
+ // anchor shrinks the selection the same way arrowing away grows it --
1256
+ // the "shrink-from-the-anchor" behavior a spreadsheet's own Shift+Arrow
1257
+ // gives, which a plain add-on-each-press approach can't express.
1258
+ if (selectable &&
1259
+ e.shiftKey &&
1260
+ (e.key === "ArrowDown" || e.key === "ArrowUp")) {
1261
+ const r0 = rows.findIndex((row) => row.id === selCell.id);
1262
+ if (r0 < 0)
1263
+ return;
1264
+ const nr0 = e.key === "ArrowDown"
1265
+ ? Math.min(rows.length - 1, r0 + 1)
1266
+ : Math.max(0, r0 - 1);
1267
+ e.preventDefault();
1268
+ const anchorId = rowSelectAnchor ?? selCell.id;
1269
+ const anchorIdx = rows.findIndex((row) => row.id === anchorId);
1270
+ if (anchorIdx < 0)
1271
+ return;
1272
+ setRowSelectAnchor(anchorId);
1273
+ const lo = Math.min(anchorIdx, nr0);
1274
+ const hi = Math.max(anchorIdx, nr0);
1275
+ setSelected(new Set(rows.slice(lo, hi + 1).map((row) => row.id)));
1276
+ setSelCell({ id: rows[nr0].id, key: selCell.key });
1277
+ clipboardProxyRef.current?.select();
1278
+ return;
1279
+ }
1163
1280
  const r = rows.findIndex((row) => row.id === selCell.id);
1164
1281
  const c = columns.findIndex((col) => col.key === selCell.key);
1165
1282
  if (r < 0 || c < 0)
@@ -1198,6 +1315,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1198
1315
  else if (e.key === "Escape") {
1199
1316
  setSelCell(null);
1200
1317
  setSelAnchor(null);
1318
+ setRowSelectAnchor(null);
1201
1319
  return;
1202
1320
  }
1203
1321
  else if (e.key === "Enter") {
@@ -1205,8 +1323,13 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1205
1323
  // already its own live widget (#385); Enter there is a no-op, same
1206
1324
  // as any other non-editable cell that isn't wired to onRowClick.
1207
1325
  // `select`/`reference` DO have one now (like every other editor),
1208
- // so Enter opens them too.
1209
- if (columns[c]?.editable && columns[c]?.editor !== "switch") {
1326
+ // so Enter opens them too. `rating` (#760) and `checkbox` (#761)
1327
+ // join `switch` in the no-separate-editing-mode exception -- same
1328
+ // always-live shape.
1329
+ if (columns[c]?.editable &&
1330
+ columns[c]?.editor !== "switch" &&
1331
+ columns[c]?.editor !== "rating" &&
1332
+ columns[c]?.editor !== "checkbox") {
1210
1333
  e.preventDefault();
1211
1334
  openEditing(selCell.id, selCell.key);
1212
1335
  }
@@ -1222,8 +1345,10 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1222
1345
  // A plain arrow key moves the single-cell selection and drops any active
1223
1346
  // range -- Attio's own range trigger is Shift+Click only (G-101), arrow
1224
1347
  // keys were never measured as a range-extend gesture, so this doesn't
1225
- // invent one.
1348
+ // invent one. Also drops an in-progress Shift+Arrow row-selection drag
1349
+ // (crisp/#749) for the same reason -- a plain arrow ends that gesture.
1226
1350
  setSelAnchor(null);
1351
+ setRowSelectAnchor(null);
1227
1352
  setSelCell({ id: rows[nr].id, key: columns[nc].key });
1228
1353
  // keeps the clipboard proxy's selection non-collapsed after a move, so
1229
1354
  // a Cmd+C right after an arrow key still has something native `copy`
@@ -1741,6 +1866,22 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1741
1866
  if (col.editable && col.editor === "switch") {
1742
1867
  return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1743
1868
  }
1869
+ // crisp/#760: `editor: "rating"` is the same always-
1870
+ // live shape as `switch` above -- clicking a star IS
1871
+ // the commit gesture, confirmed live.
1872
+ if (col.editable && col.editor === "rating") {
1873
+ const n = typeof value === "number" ? value : Number(value);
1874
+ return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(RatingStars, { "aria-label": `Edit ${col.key}`, value: Number.isNaN(n) ? 0 : n, max: col.maxRating ?? 5, onChange: (next) => commitEdit(row.id, col.key, String(next)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1875
+ }
1876
+ // crisp/#761: `editor: "checkbox"` is a THIRD always-
1877
+ // live boolean shape, distinct from `switch` --
1878
+ // confirmed live against Attio's own "Checkbox"
1879
+ // attribute type, which renders a plain square, not a
1880
+ // toggle. Reuses crisp's own `Checkbox` (already the
1881
+ // measured 16px/r6/brand-fill grid checkbox).
1882
+ if (col.editable && col.editor === "checkbox") {
1883
+ return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(Checkbox, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onChange: (e) => commitEdit(row.id, col.key, String(e.currentTarget.checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1884
+ }
1744
1885
  if (isEditing &&
1745
1886
  (col.editor === "select" ||
1746
1887
  col.editor === "reference" ||
@@ -1796,9 +1937,25 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1796
1937
  i >= range.c0 &&
1797
1938
  i <= range.c1);
1798
1939
  })();
1799
- const rendered = col.render
1940
+ const rowHoveredForCell = hoveredRowId === row.id;
1941
+ const baseRendered = col.render
1800
1942
  ? col.render(value, row)
1801
- : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale, openPreviewLabel, hoveredRowId === row.id);
1943
+ : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale, openPreviewLabel, rowHoveredForCell);
1944
+ // crisp/#601 supersession follow-up: `onOpenComments`/
1945
+ // `onOpenPreview`/`commentCount` used to only do
1946
+ // anything inside `renderTyped`'s `"entity"` case,
1947
+ // which already renders its own actions inline (see
1948
+ // `renderCellActions` there) -- silently dropping them
1949
+ // on any OTHER type, and on a custom `render` of any
1950
+ // type, since neither path went through that switch.
1951
+ // This composes the same actions alongside whatever
1952
+ // `rendered` is instead, decoupling the props from
1953
+ // requiring `type: "entity"`.
1954
+ const cellActions = col.type !== "entity" &&
1955
+ (col.onOpenComments || col.onOpenPreview)
1956
+ ? renderCellActions(col, row, rowHoveredForCell, openPreviewLabel)
1957
+ : null;
1958
+ const rendered = cellActions ? (_jsxs("span", { className: "crisp-datatable-cell-actions-row", children: [baseRendered, cellActions] })) : (baseRendered);
1802
1959
  const isExpanded = expandedCell?.id === row.id &&
1803
1960
  expandedCell?.key === col.key;
1804
1961
  const cellContent = (_jsxs(_Fragment, { children: [!isExpanded && rendered, errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere })), isExpanded && (_jsx("div", { className: "crisp-datatable-cell-overlay", onClick: (e) => e.stopPropagation(), children: rendered }))] }));
@@ -1806,7 +1963,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1806
1963
  ? "crisp-datatable-editable"
1807
1964
  : undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
1808
1965
  ? ""
1809
- : undefined, "data-in-range": inRange ? "" : undefined, "data-cell-expanded": isExpanded ? "" : undefined, onClick: (e) => {
1966
+ : undefined, "data-in-range": inRange ? "" : undefined, "data-cell-expanded": isExpanded ? "" : undefined, "data-enriched": col.enriched ? "" : undefined, onClick: (e) => {
1810
1967
  // the keyboard model's anchor -- a single click
1811
1968
  // only selects. A genuine double-click (or Enter
1812
1969
  // on the selected cell) opens the editor (#411),
@@ -1821,6 +1978,11 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1821
1978
  }
1822
1979
  else {
1823
1980
  setSelAnchor(null);
1981
+ // crisp/#749: a plain click also ends an
1982
+ // in-progress Shift+Arrow row-selection drag
1983
+ // -- Shift+Click itself doesn't touch it,
1984
+ // the two gestures are independent.
1985
+ setRowSelectAnchor(null);
1824
1986
  }
1825
1987
  setSelCell({ id: row.id, key: col.key });
1826
1988
  // Focuses the clipboard proxy, not `gridRef`
@@ -1861,6 +2023,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1861
2023
  // selected (crisp/#598) -- collapses any
1862
2024
  // active range, same as a plain left-click.
1863
2025
  setSelAnchor(null);
2026
+ setRowSelectAnchor(null);
1864
2027
  setSelCell({ id: row.id, key: col.key });
1865
2028
  clipboardProxyRef.current?.focus();
1866
2029
  clipboardProxyRef.current?.select();
@@ -135,6 +135,12 @@ const config = defineRecipe({
135
135
  & tbody tr[data-selected] td { background: color-mix(in srgb, ${c.bgBrandSolid} 10%, ${c.bgSurface}); }
136
136
  & tbody tr[data-selected]:hover td { background: color-mix(in srgb, ${c.fgPrimary} 4%, color-mix(in srgb, ${c.bgBrandSolid} 10%, ${c.bgSurface})); }
137
137
  & .crisp-datatable-editable:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, ${c.bgSurface}); cursor: text; }
138
+ /* crisp/#758: AI-enriched column marker, ported from RecordsTable's own
139
+ already-measured value -- a full-bleed #9b69ff (bgPurple) wash at 4%,
140
+ composited OVER whatever row-state background the cell already has
141
+ (hover, selected), which is why it's an ::after overlay and not a
142
+ plain background. */
143
+ & td[data-enriched]::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: color-mix(in srgb, ${c.bgPurple} 4%, transparent); }
138
144
  & .crisp-datatable-edit { width: 100%; height: 28px; margin: 0; padding: 0 ${c.space["1.5"]}; border: none; border-radius: ${c.radius.sm}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; outline: none; vertical-align: middle; }
139
145
  & .crisp-datatable-edit:focus { box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; }
140
146
  /* An async onEdit's in-flight state (#385) -- the field/picker dims and
@@ -218,6 +224,13 @@ const config = defineRecipe({
218
224
  & th[data-drop-after]::after { inset-inline-end: -1.5px; }
219
225
  /* typed cells — entity (avatar + name), colour-coded tags, numbers, dates */
220
226
  & .crisp-datatable-entity { display: flex; align-items: center; gap: ${c.space["1.5"]}; min-width: 0; color: ${c.fgPrimary}; font-weight: ${c.weight.medium}; }
227
+ /* crisp/#601 supersession follow-up -- the same flex-row shape
228
+ .crisp-datatable-entity gives its own avatar+name+actions, but
229
+ structural only (no entity-specific colour/weight): lets
230
+ onOpenComments/onOpenPreview compose alongside ANY column's own
231
+ content (a custom render, or a non-entity built-in type), not just
232
+ type: "entity"'s own inline rendering. */
233
+ & .crisp-datatable-cell-actions-row { display: flex; align-items: center; gap: ${c.space["1.5"]}; min-width: 0; width: 100%; }
221
234
  /* crisp/#601, G-050: measured LIVE (getComputedStyle on the name text,
222
235
  plain hover vs rest) that Attio's underline is unconditional, not a
223
236
  hover reveal -- identical at rest and on hover: text-decoration-color
@@ -259,6 +272,23 @@ const config = defineRecipe({
259
272
  link. */
260
273
  & .crisp-datatable-link { color: ${c.fgPrimary}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
261
274
  & .crisp-datatable-link:hover { text-decoration: underline; }
275
+ /* rating cell type/editor (#760) -- measured live: 16px stars on an
276
+ 18px pitch (a 2px gap), an empty star at rgba(0,0,0,0.1), a
277
+ committed/filled star at brand600 (--crisp-bg-brand-solid, an exact
278
+ match to the measured #266df0), and a distinct hover-preview colour
279
+ -- rgb(33,91,196) -- that's close to but NOT brand700/bgBrandSolidPressed
280
+ (#1f5fd6); kept as its own literal rather than forced onto that token,
281
+ since the two were measured stable and different. The read-only svg
282
+ and the editable button's own inner svg both carry the
283
+ crisp-datatable-rating-star class on the svg itself (not the
284
+ button), so one set of fill rules covers both -- the button only
285
+ adds its own reset + cursor via a separate -btn class. */
286
+ & .crisp-datatable-rating { display: inline-flex; align-items: center; gap: 2px; }
287
+ & .crisp-datatable-rating-star { display: block; }
288
+ & .crisp-datatable-rating-star path { fill: rgba(0, 0, 0, 0.1); }
289
+ & .crisp-datatable-rating-star[data-filled] path { fill: ${c.bgBrandSolid}; }
290
+ & .crisp-datatable-rating-star[data-hovering] path { fill: #215bc4; }
291
+ & .crisp-datatable-rating-star-btn { display: inline-flex; padding: 0; margin: 0; border: none; background: none; font-family: inherit; cursor: pointer; }
262
292
  /* row drag-to-reorder -- a Settings > Objects > Attributes screen measures
263
293
  a leading 20px grip column, a 16px grip icon, and the row's own
264
294
  hairline doubling as the drop indicator (it tints toward the brand
@@ -50,12 +50,35 @@ export const toolbarChromeHead = (p) => ` & .${CLASS_PREFIX}${p}-toolbar { di
50
50
  & .${CLASS_PREFIX}${p}-search svg { flex-shrink: 0; }
51
51
  & .${CLASS_PREFIX}${p}-search input { border: none; outline: none; background: transparent; font-family: inherit; font-size: ${c.text.md}; color: ${c.fgPrimary}; width: 180px; }
52
52
  & .${CLASS_PREFIX}${p}-search input::placeholder { color: ${c.fgMuted}; }
53
- & .${CLASS_PREFIX}${p}-tool { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.row}; margin: 0; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: transparent; box-shadow: inset 0 0 0 1px ${c.borderDefault}; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
53
+ & .${CLASS_PREFIX}${p}-tool { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.row}; margin: 0; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.borderDivider}; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
54
54
  & .${CLASS_PREFIX}${p}-tool:hover { background: ${c.bgRaised}; }
55
55
  & .${CLASS_PREFIX}${p}-tool svg { flex-shrink: 0; color: ${c.fgMuted}; }
56
- & .${CLASS_PREFIX}${p}-tool--active { background: ${c.bgRaised}; }
56
+ /* crisp/#772: re-measured live on Sort with an actual sort applied --
57
+ an active trigger's own fill is white (bgSurface), the same as the
58
+ base .tool rule above now already sets; this override existed to
59
+ distinguish active from a base state that used to be transparent.
60
+ Kept explicit (not just deleted) so a future change to the base
61
+ rule's background can't silently change this one too. */
62
+ & .${CLASS_PREFIX}${p}-tool--active { background: ${c.bgSurface}; }
57
63
  & .${CLASS_PREFIX}${p}-tool--ghost { box-shadow: none; background: color-mix(in srgb, ${c.fgPrimary} 3%, transparent); color: ${c.fgMuted}; }
58
64
  & .${CLASS_PREFIX}${p}-tool--ghost:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
65
+ /* crisp/#772 follow-up, re-measured live 2026-09-25: an EMPTY Sort/Filter
66
+ trigger (no sort/condition set) isn't a solid-ring chip at all -- it's
67
+ a dashed ::before (1px dashed, border-radius: inherit, absolutely
68
+ positioned inset: 0) over a barely-there fill, confirmed on Attio's
69
+ own empty Filter chip (its ::before border computes to exactly
70
+ "1px dashed rgba(0, 0, 0, 0.06)"). Once a value IS set, the trigger
71
+ drops the dashed pseudo and picks up the base tool's own solid ring
72
+ instead (the --active modifier, unchanged) -- confirmed on Sort with
73
+ an actual sort applied. color-mix (not the raw rgba Attio computes
74
+ to) so both the border and the fill flip correctly in dark mode, the
75
+ same reasoning --ghost above already uses. Only wired into
76
+ TableToolbar itself (tabletoolbar.tsx) this pass -- RecordsTable's
77
+ own Sort/Filter triggers share this same chrome function but are
78
+ deprecated, not touched here. */
79
+ & .${CLASS_PREFIX}${p}-tool--dashed { position: relative; box-shadow: none; background: color-mix(in srgb, ${c.fgPrimary} 2%, transparent); }
80
+ & .${CLASS_PREFIX}${p}-tool--dashed::before { content: ""; position: absolute; inset: 0; border-radius: inherit; border: 1px dashed color-mix(in srgb, ${c.fgPrimary} 6%, transparent); pointer-events: none; }
81
+ & .${CLASS_PREFIX}${p}-tool--dashed:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); }
59
82
  & .${CLASS_PREFIX}${p}-toolgroup { position: relative; display: inline-flex; }
60
83
  & .${CLASS_PREFIX}${p}-divider { flex-shrink: 0; width: 1px; height: 20px; background: ${c.borderSubtle}; }
61
84
  & .${CLASS_PREFIX}${p}-menu { position: absolute; top: calc(100% + ${c.space["1"]}); left: 0; z-index: 30; min-width: 232px; padding: ${c.space["1"]}; border-radius: ${c.radius.xl}; background: ${c.bgPopover}; box-shadow: ${e.floating}; display: flex; flex-direction: column; gap: ${c.space.px}; transform-origin: top; opacity: 1; transform: none; transition: opacity .13s ease, transform .13s ease; }