@crisp-ui-kit/crisp 0.54.8 → 0.54.10

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 (59) hide show
  1. package/dist/board.css +32 -8
  2. package/dist/board.layered.css +32 -8
  3. package/dist/cjs/components/contextmenu/contextmenu.js +2 -2
  4. package/dist/cjs/components/contextmenu/contextmenu.recipe.js +24 -0
  5. package/dist/cjs/components/datatable/datatable.js +593 -51
  6. package/dist/cjs/components/datatable/datatable.recipe.js +67 -3
  7. package/dist/cjs/components/dialog/dialog.js +65 -1
  8. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +37 -2
  9. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +218 -39
  10. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +48 -1
  11. package/dist/cjs/components/text/text.recipe.js +17 -7
  12. package/dist/cjs/product/board/board.js +62 -28
  13. package/dist/cjs/product/board/board.recipe.js +42 -2
  14. package/dist/cjs/product/comments/comments.js +11 -2
  15. package/dist/cjs/product/comments/comments.recipe.js +14 -4
  16. package/dist/cjs/product/recordstable/recordstable.js +1 -17
  17. package/dist/cjs/product/savedviews/savedviews.js +55 -24
  18. package/dist/cjs/product/savedviews/savedviews.recipe.js +27 -0
  19. package/dist/comments.css +14 -4
  20. package/dist/comments.layered.css +14 -4
  21. package/dist/contextmenu.css +24 -0
  22. package/dist/contextmenu.layered.css +24 -0
  23. package/dist/datatable.css +106 -77
  24. package/dist/datatable.layered.css +106 -77
  25. package/dist/esm/components/contextmenu/contextmenu.js +2 -2
  26. package/dist/esm/components/contextmenu/contextmenu.recipe.js +24 -0
  27. package/dist/esm/components/datatable/datatable.js +593 -52
  28. package/dist/esm/components/datatable/datatable.recipe.js +68 -4
  29. package/dist/esm/components/dialog/dialog.js +65 -1
  30. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +37 -2
  31. package/dist/esm/components/tabletoolbar/tabletoolbar.js +219 -40
  32. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +50 -3
  33. package/dist/esm/components/text/text.recipe.js +17 -7
  34. package/dist/esm/product/board/board.js +63 -29
  35. package/dist/esm/product/board/board.recipe.js +42 -2
  36. package/dist/esm/product/comments/comments.js +12 -3
  37. package/dist/esm/product/comments/comments.recipe.js +14 -4
  38. package/dist/esm/product/recordstable/recordstable.js +2 -18
  39. package/dist/esm/product/savedviews/savedviews.js +57 -26
  40. package/dist/esm/product/savedviews/savedviews.recipe.js +27 -0
  41. package/dist/recordstable.css +33 -31
  42. package/dist/recordstable.layered.css +33 -31
  43. package/dist/savedviews.css +27 -0
  44. package/dist/savedviews.layered.css +27 -0
  45. package/dist/styles.css +282 -125
  46. package/dist/styles.layered.css +282 -125
  47. package/dist/tabletoolbar.css +45 -4
  48. package/dist/tabletoolbar.layered.css +45 -4
  49. package/dist/text.css +1 -1
  50. package/dist/text.layered.css +1 -1
  51. package/dist/types/components/contextmenu/contextmenu.d.ts +8 -1
  52. package/dist/types/components/datatable/datatable.d.ts +98 -2
  53. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +72 -2
  54. package/dist/types/components/text/text.recipe.d.ts +1 -1
  55. package/dist/types/product/board/board.d.ts +48 -2
  56. package/dist/types/product/comments/comments.d.ts +6 -1
  57. package/dist/types/product/recordstable/recordstable.d.ts +0 -1
  58. package/dist/types/product/savedviews/savedviews.d.ts +45 -8
  59. package/package.json +1 -1
@@ -38,6 +38,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
38
38
  const lucide_react_1 = require("lucide-react");
39
39
  const React = __importStar(require("react"));
40
40
  const menu_1 = require("../../components/menu");
41
+ const radiogroup_1 = require("../../components/radiogroup");
41
42
  const core_1 = require("../../core");
42
43
  const board_recipe_1 = require("./board.recipe");
43
44
  const BOARD_AGG_ORDER = [
@@ -97,11 +98,44 @@ function computeBoardAgg(agg, values) {
97
98
  * it via `onCardsChange` (the parent owns the cards, so the move is a
98
99
  * controlled state update). Measured from a deals pipeline.
99
100
  */
100
- function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel: newCardLabelProp, getCardLabel, announceMove: announceMoveProp, calc, className, ...rest }) {
101
+ function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel: newCardLabelProp, getCardLabel, announceMove: announceMoveProp, calc, density = false, columnDensity: columnDensityProp, onColumnDensityChange, cardDensity: cardDensityProp, onCardDensityChange, collapseColumnsLabel: collapseColumnsLabelProp, expandColumnsLabel: expandColumnsLabelProp, collapseCardsLabel: collapseCardsLabelProp, expandCardsLabel: expandCardsLabelProp, columnDensityGroupLabel: columnDensityGroupLabelProp, cardDensityGroupLabel: cardDensityGroupLabelProp, onAddStage, addStageLabel: addStageLabelProp, className, ...rest }) {
101
102
  // crisp/#471: same resolution order as every other component's own —
102
103
  // explicit prop, then a CrispMessagesProvider's translation of the English
103
104
  // default, then that default itself.
104
105
  const newCardLabel = (0, core_1.useCrispMessage)(newCardLabelProp, "New");
106
+ const collapseColumnsLabel = (0, core_1.useCrispMessage)(collapseColumnsLabelProp, "Collapse columns");
107
+ const expandColumnsLabel = (0, core_1.useCrispMessage)(expandColumnsLabelProp, "Expand columns");
108
+ const collapseCardsLabel = (0, core_1.useCrispMessage)(collapseCardsLabelProp, "Collapse cards");
109
+ const expandCardsLabel = (0, core_1.useCrispMessage)(expandCardsLabelProp, "Expand cards");
110
+ const columnDensityGroupLabel = (0, core_1.useCrispMessage)(columnDensityGroupLabelProp, "Column density");
111
+ const cardDensityGroupLabel = (0, core_1.useCrispMessage)(cardDensityGroupLabelProp, "Card density");
112
+ const addStageLabel = (0, core_1.useCrispMessage)(addStageLabelProp, "Add stage");
113
+ // #718: same controlled/uncontrolled shape as `sort`/`selectedIds`
114
+ // elsewhere in crisp — explicit prop wins; otherwise this manages its own
115
+ // state, seeded "expand" (Attio's own board opens expanded by default).
116
+ const [internalColumnDensity, setInternalColumnDensity] = React.useState("expand");
117
+ const [internalCardDensity, setInternalCardDensity] = React.useState("expand");
118
+ const columnDensity = columnDensityProp ?? internalColumnDensity;
119
+ const cardDensity = cardDensityProp ?? internalCardDensity;
120
+ const setColumnDensity = (next) => {
121
+ onColumnDensityChange?.(next);
122
+ if (columnDensityProp === undefined)
123
+ setInternalColumnDensity(next);
124
+ };
125
+ const setCardDensity = (next) => {
126
+ onCardDensityChange?.(next);
127
+ if (cardDensityProp === undefined)
128
+ setInternalCardDensity(next);
129
+ };
130
+ // crisp's shared radio-group keyboard model (#200) — the same
131
+ // ←/→/↑/↓/Home/End-with-roving-tabindex model `Segmented`/`RadioGroup`
132
+ // already use, not a bespoke per-button handler (see that hook's own
133
+ // comment on the bug a per-button closure caused once already). Two
134
+ // independent hook instances: each density pair is its own radio group.
135
+ const { onKeyDown: onColumnDensityKeyDown, groupRef: columnDensityGroupRef } = (0, radiogroup_1.useRadioGroupKeyboard)({
136
+ select: (v) => setColumnDensity(v),
137
+ });
138
+ const { onKeyDown: onCardDensityKeyDown, groupRef: cardDensityGroupRef } = (0, radiogroup_1.useRadioGroupKeyboard)({ select: (v) => setCardDensity(v) });
105
139
  // #604: which BoardAgg each column's footer shows, keyed by column id.
106
140
  // Unset (undefined) means the column still shows the "+ Add calculation"
107
141
  // trigger, exactly like the table footer's own per-column state.
@@ -245,32 +279,32 @@ function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLa
245
279
  setRefocusId(card.id);
246
280
  return true;
247
281
  };
248
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, board_recipe_1.boardRecipe)({ className }), ...rest, children: [columns.map((col) => {
249
- const colCards = cards.filter((c) => c.columnId === col.id);
250
- return ((0, jsx_runtime_1.jsxs)("div", { ref: (el) => {
251
- if (el)
252
- colRefs.current.set(col.id, el);
253
- else
254
- colRefs.current.delete(col.id);
255
- }, className: "crisp-board-col", "data-over": overCol === col.id ? "" : undefined, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-board-col-header", children: [col.accent && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-dot", style: { background: col.accent } })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-title", children: col.title }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-count", children: colCards.length }), onNewCard && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-board-col-add", "aria-label": `${crispMessages["Add to"] ?? "Add to"} ${col.title}`, onClick: () => onNewCard(col.id), children: "+" }))] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-board-col-body", role: "list", "aria-label": col.title, children: [onNewCard && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-board-newcard", onClick: () => onNewCard(col.id), children: ["+ ", newCardLabel] })), colCards.map((card) => ((0, jsx_runtime_1.jsx)("div", { className: "crisp-board-card", role: "listitem",
256
- // biome-ignore lint/a11y/noNoninteractiveTabindex: the rule assumes a listitem cannot be operated. This one carries the board's only keyboard path to move a card, so it has to be reachable by Tab
257
- tabIndex: 0, "aria-label": getCardLabel?.(card), "data-card-id": card.id, "data-dragging": dragId === card.id ? "" : undefined, onPointerDown: (e) => onCardPointerDown(e, card), onPointerMove: onCardPointerMove, onPointerUp: (e) => endCardDrag(e, true), onPointerCancel: (e) => endCardDrag(e, false), onKeyDown: (e) => {
258
- if (!(e.ctrlKey || e.metaKey))
259
- return;
260
- // the columns are a flex row, so under RTL the next one
261
- // is to the LEFT and the keys swap (#255)
262
- const rtl = (0, core_1.isRtlElement)(e.currentTarget);
263
- const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
264
- const backKey = rtl ? "ArrowRight" : "ArrowLeft";
265
- const delta = e.key === backKey ? -1 : e.key === forwardKey ? 1 : 0;
266
- // only swallow the key when the move actually happens, so
267
- // Ctrl+Right at the last column still reaches the browser
268
- if (delta && moveByKey(card, delta))
269
- e.preventDefault();
270
- }, children: renderCard(card) }, card.id)))] }), calc && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-board-col-footer", children: (0, jsx_runtime_1.jsx)(menu_1.Menu, { align: "start", trigger: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-board-col-calc", "data-empty": colAgg[col.id] ? undefined : "", children: colAgg[col.id] ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-calc-label", children: crispMessages[AGG_LABEL[colAgg[col.id]]] ??
271
- AGG_LABEL[colAgg[col.id]] }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-calc-value", children: computeBoardAgg(colAgg[col.id], colCards.map((c) => calc.getValue(c))) })] })) : ((crispMessages["+ Add calculation"] ??
272
- "+ Add calculation")) }), children: BOARD_AGG_ORDER.map((a) => ((0, jsx_runtime_1.jsxs)(menu_1.MenuItem, { onSelect: () => setColAgg((prev) => ({ ...prev, [col.id]: a })), children: [crispMessages[AGG_LABEL[a]] ?? AGG_LABEL[a], colAgg[col.id] === a ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { size: 15, strokeWidth: 1.75 })) : null] }, a))) }) }))] }, col.id));
273
- }), dragId &&
282
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, board_recipe_1.boardRecipe)({ className }), ...rest, children: [density && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-board-toolbar", children: [(0, jsx_runtime_1.jsxs)("div", { ref: columnDensityGroupRef, className: "crisp-board-density", role: "radiogroup", "aria-label": columnDensityGroupLabel, onKeyDown: onColumnDensityKeyDown, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", role: "radio", "data-value": "collapse", "aria-checked": columnDensity === "collapse", "aria-label": collapseColumnsLabel, "data-state": columnDensity === "collapse" ? "on" : "off", onClick: () => setColumnDensity("collapse"), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Columns3, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", role: "radio", "data-value": "expand", "aria-checked": columnDensity === "expand", "aria-label": expandColumnsLabel, "data-state": columnDensity === "expand" ? "on" : "off", onClick: () => setColumnDensity("expand"), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Columns2, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), (0, jsx_runtime_1.jsxs)("div", { ref: cardDensityGroupRef, className: "crisp-board-density", role: "radiogroup", "aria-label": cardDensityGroupLabel, onKeyDown: onCardDensityKeyDown, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", role: "radio", "data-value": "collapse", "aria-checked": cardDensity === "collapse", "aria-label": collapseCardsLabel, "data-state": cardDensity === "collapse" ? "on" : "off", onClick: () => setCardDensity("collapse"), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Rows3, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", role: "radio", "data-value": "expand", "aria-checked": cardDensity === "expand", "aria-label": expandCardsLabel, "data-state": cardDensity === "expand" ? "on" : "off", onClick: () => setCardDensity("expand"), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Square, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] })] })), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-board-row", children: [columns.map((col) => {
283
+ const colCards = cards.filter((c) => c.columnId === col.id);
284
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: (el) => {
285
+ if (el)
286
+ colRefs.current.set(col.id, el);
287
+ else
288
+ colRefs.current.delete(col.id);
289
+ }, className: "crisp-board-col", "data-over": overCol === col.id ? "" : undefined, "data-density": columnDensity === "collapse" ? "collapse" : undefined, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-board-col-header", children: [col.accent && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-dot", style: { background: col.accent } })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-title", children: col.title }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-count", children: colCards.length }), onNewCard && columnDensity !== "collapse" && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-board-col-add", "aria-label": `${crispMessages["Add to"] ?? "Add to"} ${col.title}`, onClick: () => onNewCard(col.id), children: "+" }))] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-board-col-body", role: "list", "aria-label": col.title, children: [onNewCard && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-board-newcard", onClick: () => onNewCard(col.id), children: ["+ ", newCardLabel] })), colCards.map((card) => ((0, jsx_runtime_1.jsx)("div", { className: "crisp-board-card", role: "listitem",
290
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: the rule assumes a listitem cannot be operated. This one carries the board's only keyboard path to move a card, so it has to be reachable by Tab
291
+ tabIndex: 0, "aria-label": getCardLabel?.(card), "data-card-id": card.id, "data-dragging": dragId === card.id ? "" : undefined, "data-density": cardDensity === "collapse" ? "collapse" : undefined, onPointerDown: (e) => onCardPointerDown(e, card), onPointerMove: onCardPointerMove, onPointerUp: (e) => endCardDrag(e, true), onPointerCancel: (e) => endCardDrag(e, false), onKeyDown: (e) => {
292
+ if (!(e.ctrlKey || e.metaKey))
293
+ return;
294
+ // the columns are a flex row, so under RTL the next one
295
+ // is to the LEFT and the keys swap (#255)
296
+ const rtl = (0, core_1.isRtlElement)(e.currentTarget);
297
+ const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
298
+ const backKey = rtl ? "ArrowRight" : "ArrowLeft";
299
+ const delta = e.key === backKey ? -1 : e.key === forwardKey ? 1 : 0;
300
+ // only swallow the key when the move actually happens, so
301
+ // Ctrl+Right at the last column still reaches the browser
302
+ if (delta && moveByKey(card, delta))
303
+ e.preventDefault();
304
+ }, children: renderCard(card, cardDensity) }, card.id)))] }), calc && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-board-col-footer", children: (0, jsx_runtime_1.jsx)(menu_1.Menu, { align: "start", trigger: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-board-col-calc", "data-empty": colAgg[col.id] ? undefined : "", children: colAgg[col.id] ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-calc-label", children: crispMessages[AGG_LABEL[colAgg[col.id]]] ??
305
+ AGG_LABEL[colAgg[col.id]] }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-calc-value", children: computeBoardAgg(colAgg[col.id], colCards.map((c) => calc.getValue(c))) })] })) : ((crispMessages["+ Add calculation"] ??
306
+ "+ Add calculation")) }), children: BOARD_AGG_ORDER.map((a) => ((0, jsx_runtime_1.jsxs)(menu_1.MenuItem, { onSelect: () => setColAgg((prev) => ({ ...prev, [col.id]: a })), children: [crispMessages[AGG_LABEL[a]] ?? AGG_LABEL[a], colAgg[col.id] === a ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { size: 15, strokeWidth: 1.75 })) : null] }, a))) }) }))] }, col.id));
307
+ }), onAddStage && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-board-addstage", "aria-label": addStageLabel, onClick: onAddStage, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }))] }), dragId &&
274
308
  ghost &&
275
309
  (() => {
276
310
  const draggedCard = cards.find((c) => c.id === dragId);
@@ -281,7 +315,7 @@ function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLa
281
315
  top: ghost.y,
282
316
  width: ghost.width,
283
317
  height: ghost.height,
284
- }, children: renderCard(draggedCard) }));
318
+ }, children: renderCard(draggedCard, cardDensity) }));
285
319
  })(), (0, jsx_runtime_1.jsx)("div", { className: "crisp-board-live", role: "status", "aria-live": "assertive", children: announcement })] }));
286
320
  }
287
321
  Board.displayName = "Board";
@@ -18,8 +18,33 @@ const tokens_1 = require("../../tokens");
18
18
  // reference lacks).
19
19
  const config = (0, core_1.defineRecipe)({
20
20
  name: "board",
21
- base: `display: flex; gap: ${tokens_1.contract.space["2"]}; height: 100%; box-sizing: border-box; padding: ${tokens_1.contract.space["3"]}; overflow-x: auto; align-items: flex-start; font-family: ${tokens_1.contract.font.sans};
22
- & .crisp-board-col { display: flex; flex-direction: column; width: 268px; flex-shrink: 0; max-height: 100%; box-sizing: border-box; }
21
+ base: `display: flex; flex-direction: column; gap: ${tokens_1.contract.space["2"]}; height: 100%; box-sizing: border-box; font-family: ${tokens_1.contract.font.sans};
22
+ /* #718: the density toolbar's own row -- everything that used to be on
23
+ the root (padding/overflow/the columns themselves) moves to
24
+ .crisp-board-row below so this can sit above it, unscrolled. */
25
+ & .crisp-board-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: ${tokens_1.contract.space["2"]}; padding: 0 ${tokens_1.contract.space["3"]}; flex-shrink: 0; }
26
+ /* Measured live (Deals pipeline): a 54×28px group, 24×24px buttons,
27
+ 2px padding/gap -- an 8px gap between the two groups comes from the
28
+ toolbar's own \`gap\` above. */
29
+ & .crisp-board-density { display: flex; align-items: center; gap: 2px; padding: 2px; border-radius: 10px; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); }
30
+ & .crisp-board-density button { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: none; border-radius: 8px; background: none; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); font-family: inherit; cursor: pointer; }
31
+ & .crisp-board-density button[data-state="on"] { background: ${tokens_1.contract.bgSurface}; color: ${tokens_1.contract.fgPrimary}; }
32
+ & .crisp-board-row { display: flex; gap: ${tokens_1.contract.space["2"]}; flex: 1; min-height: 0; box-sizing: border-box; padding: ${tokens_1.contract.space["3"]}; overflow-x: auto; align-items: flex-start; }
33
+ & .crisp-board-col { display: flex; flex-direction: column; width: 268px; flex-shrink: 0; max-height: 100%; box-sizing: border-box; transition: width 0.15s; }
34
+ /* #718: a collapsed column is a 52px vertical rail -- header only (dot
35
+ + count stacked above a vertical-rl title), body/footer hidden
36
+ entirely. Measured live: 52px width, 8px 0 padding on the header,
37
+ 14px/600 title text. */
38
+ & .crisp-board-col[data-density="collapse"] { width: 52px; }
39
+ & .crisp-board-col[data-density="collapse"] .crisp-board-col-header { flex-direction: column; align-items: center; height: auto; padding: ${tokens_1.contract.space["2"]} 0; gap: ${tokens_1.contract.space["1"]}; }
40
+ /* Measured stacking order (top to bottom): dot, count, THEN the vertical
41
+ title -- reordered visually via \`order\`, not by changing the JSX/DOM
42
+ order, which stays dot/title/count (reading order, unaffected by
43
+ density). */
44
+ & .crisp-board-col[data-density="collapse"] .crisp-board-col-title { order: 1; writing-mode: vertical-rl; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.semibold}; }
45
+ & .crisp-board-col[data-density="collapse"] .crisp-board-col-count { order: 0; }
46
+ & .crisp-board-col[data-density="collapse"] .crisp-board-col-body,
47
+ & .crisp-board-col[data-density="collapse"] .crisp-board-col-footer { display: none; }
23
48
  & .crisp-board-col-header { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: 36px; padding: 0 ${tokens_1.contract.space["1"]}; flex-shrink: 0; }
24
49
  & .crisp-board-col-dot { width: 12px; height: 12px; border-radius: ${tokens_1.contract.radius.full}; flex-shrink: 0; }
25
50
  & .crisp-board-col-title { font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.semibold}; color: ${tokens_1.contract.fgPrimary}; }
@@ -38,6 +63,14 @@ const config = (0, core_1.defineRecipe)({
38
63
  & .crisp-board-newcard { display: flex; align-items: center; justify-content: center; gap: ${tokens_1.contract.space["1.5"]}; height: 32px; flex-shrink: 0; border: none; border-radius: ${tokens_1.contract.radius.md}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); color: ${tokens_1.contract.fgMuted}; font-family: inherit; font-size: ${tokens_1.contract.text.md}; font-weight: ${tokens_1.contract.weight.medium}; cursor: pointer; }
39
64
  & .crisp-board-newcard:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 7%, transparent); color: ${tokens_1.contract.fgPrimary}; }
40
65
  & .crisp-board-card { display: flex; flex-direction: column; gap: ${tokens_1.contract.space["2"]}; box-sizing: border-box; padding: ${tokens_1.contract.space["2.5"]} ${tokens_1.contract.space["3"]}; border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgSurface}; box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderSubtle}; cursor: grab; touch-action: none; }
66
+ /* #718: a collapsed card is a single 40px row -- measured live and
67
+ independent of \`c.size.tableRow\`/\`tableHeader\` (36/40px) even
68
+ though it happens to match the latter; a plain literal, not a
69
+ cross-reference to a token measured for a different surface.
70
+ \`renderCard\`'s own second argument is what actually decides what a
71
+ collapsed card SHOWS (Board never sees inside its opaque output);
72
+ this only supplies the row-shaped box that content sits in. */
73
+ & .crisp-board-card[data-density="collapse"] { flex-direction: row; align-items: center; height: 40px; padding: 0 ${tokens_1.contract.space["3"]}; }
41
74
  & .crisp-board-card:active { cursor: grabbing; }
42
75
  & .crisp-board-card[data-dragging] { opacity: 0.4; }
43
76
  & .crisp-board-card:focus { outline: none; }
@@ -57,6 +90,13 @@ const config = (0, core_1.defineRecipe)({
57
90
  & .crisp-board-col-calc[data-empty] { opacity: 0.7; }
58
91
  & .crisp-board-col-calc-label { text-transform: capitalize; }
59
92
  & .crisp-board-col-calc-value { color: ${tokens_1.contract.fgPrimary}; font-variant-numeric: tabular-nums; }
93
+ /* #718: "Add stage" -- measured live: 52×52px, transparent, a 1px
94
+ dashed border (rgb(230,231,234), exactly \`borderDropzone\`/\`line150\`
95
+ -- the same dashed-border colour ImportDialog's own dropzone already
96
+ uses, not borderSubtle/line100, a close but different value), radius.xl
97
+ (16px), a centered 16px "+". */
98
+ & .crisp-board-addstage { display: inline-flex; align-items: center; justify-content: center; width: 52px; height: 52px; flex-shrink: 0; border: 1px dashed ${tokens_1.contract.borderDropzone}; border-radius: ${tokens_1.contract.radius.xl}; background: none; color: ${tokens_1.contract.fgPrimary}; font-family: inherit; cursor: pointer; }
99
+ & .crisp-board-addstage:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); }
60
100
  /* announced, never drawn */
61
101
  & .crisp-board-live {
62
102
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
@@ -369,7 +369,7 @@ function CommentsPanel({ threads, currentUserId, subject, subjectIcon, showResol
369
369
  const activeThread = activeThreadId
370
370
  ? threads.find((t) => t.id === activeThreadId)
371
371
  : null;
372
- return ((0, jsx_runtime_1.jsxs)("div", { className: (0, comments_recipe_1.commentsRecipe)({ className }), role: "dialog", "aria-label": title, ...rest, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-comments-head", children: [activeThread ? ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": backLabel, onClick: () => setActiveThreadId(null), children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronLeft, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "crisp-comments-title-wrap", children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-comments-title", children: activeThread ? threadTitle : title }), activeThread && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-comments-breadcrumb", children: [breadcrumbPrefix, " ", subjectIcon, " ", subject] }))] }), !activeThread && ((0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "end", trigger: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": filterLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ListFilter, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: [(0, jsx_runtime_1.jsx)(menu_1.MenuCheckboxItem, { checked: showResolved, onCheckedChange: onShowResolvedChange, children: showResolvedLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuCheckboxItem, { checked: onlyMine, onCheckedChange: onOnlyMineChange, children: onlyMineLabel })] })), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": closeLabel, onClick: onClose, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-comments-body crisp-comments-body--pad-top", children: activeThread ? ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(CommentRow, { comment: activeThread.root, currentUserId: currentUserId, replyLabel: replyActionLabel, reactLabel: reactActionLabel, resolveLabel: resolveActionLabel, moreLabel: moreActionsLabel, copyLinkLabel: copyLinkLabel, deleteLabel: deleteLabel, addReactionLabel: addReactionLabel, reactionSearchLabel: reactionSearchLabel, emojiOptions: emojiOptions, resolvable: !!onResolve, resolved: activeThread.resolved, resolvedLabel: activeThread.resolved
372
+ return ((0, jsx_runtime_1.jsxs)("div", { className: (0, comments_recipe_1.commentsRecipe)({ className }), role: "dialog", "aria-label": title, ...rest, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-comments-head", children: [activeThread ? ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": backLabel, onClick: () => setActiveThreadId(null), children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronLeft, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "crisp-comments-title-wrap", children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-comments-title", children: activeThread ? threadTitle : title }), activeThread && ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-comments-breadcrumb", children: [breadcrumbPrefix, " ", subjectIcon, " ", subject] }))] }), !activeThread && ((0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "end", trigger: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": filterLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ListFilter, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: [(0, jsx_runtime_1.jsx)(menu_1.MenuCheckboxItem, { checked: showResolved, onCheckedChange: onShowResolvedChange, children: showResolvedLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuCheckboxItem, { checked: onlyMine, onCheckedChange: onOnlyMineChange, children: onlyMineLabel })] })), onClose && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": closeLabel, onClick: onClose, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }))] }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-comments-body crisp-comments-body--pad-top", children: activeThread ? ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(CommentRow, { comment: activeThread.root, currentUserId: currentUserId, replyLabel: replyActionLabel, reactLabel: reactActionLabel, resolveLabel: resolveActionLabel, moreLabel: moreActionsLabel, copyLinkLabel: copyLinkLabel, deleteLabel: deleteLabel, addReactionLabel: addReactionLabel, reactionSearchLabel: reactionSearchLabel, emojiOptions: emojiOptions, resolvable: !!onResolve, resolved: activeThread.resolved, resolvedLabel: activeThread.resolved
373
373
  ? activeThread.resolvedBy?.id === currentUserId ||
374
374
  !activeThread.resolvedBy
375
375
  ? resolvedByYouLabel
@@ -426,6 +426,15 @@ function CommentCountBadge({ count, hovered, onOpen, onOpenPreview, label: label
426
426
  ? `crisp-comments-badge ${className}`
427
427
  : "crisp-comments-badge", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-comments-badge-dot" }) }));
428
428
  }
429
- return ((0, jsx_runtime_1.jsxs)("span", { className: className ? `crisp-comments-badge ${className}` : "crisp-comments-badge", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-comments-badge", "aria-label": label, onClick: onOpen, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.MessageSquare, { size: 12, strokeWidth: 2, "aria-hidden": "true" }), count > 0 && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-comments-badge-count", children: count }))] }), onOpenPreview && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-comments-badge", "aria-label": previewLabel, onClick: onOpenPreview, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowUpRight, { "data-rtl-flip": "", size: 12, strokeWidth: 2, "aria-hidden": "true" }) }))] }));
429
+ return ((0, jsx_runtime_1.jsxs)("span", { className: className ? `crisp-comments-badge ${className}` : "crisp-comments-badge", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-comments-badge-trigger", "aria-label": label, onClick: (ev) => {
430
+ // this badge lives in a grid row (crisp/#601 supersession) whose
431
+ // own click opens the record -- the same stopPropagation every
432
+ // other per-row affordance in that cell already uses.
433
+ ev.stopPropagation();
434
+ onOpen();
435
+ }, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.MessageSquare, { size: 12, strokeWidth: 2, "aria-hidden": "true" }), count > 0 && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-comments-badge-count", children: count }))] }), onOpenPreview && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-comments-badge-preview", "aria-label": previewLabel, onClick: (ev) => {
436
+ ev.stopPropagation();
437
+ onOpenPreview();
438
+ }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.PanelRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }))] }));
430
439
  }
431
440
  CommentCountBadge.displayName = "CommentCountBadge";
@@ -143,11 +143,21 @@ const config = (0, core_1.defineRecipe)({
143
143
 
144
144
  /* Row badge (CommentCountBadge) -- lives in its own class family so the
145
145
  grid lanes can drop it into a cell without pulling in the panel's
146
- CSS. Persistent state: a 6px grey dot. Hover state: a 12px comment
147
- icon + 12px count text, both at 55% ink, replacing the dot. */
148
- .crisp-comments-badge { box-sizing: border-box; font-family: ${tokens_1.contract.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; border: none; background: none; padding: 0; margin: 0; cursor: pointer; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 55%, transparent); }
146
+ CSS. Re-measured live on a real commented row (crisp/#601
147
+ supersession, right-aligned in a DataTable primary column): a 6px
148
+ grey dot at rest (unchanged, already matched); on hover, the trigger
149
+ button is auto-width with a 12px icon + 12px/500 count text,
150
+ "padding: 2px 6px 2px 4px", radius 6px -- distinct from the preview
151
+ button beside it, a fixed 20px square with a 16px icon, same radius.
152
+ Both share the 55%-ink idle colour and darken on their OWN hover
153
+ (color-mix 4%, the same formula DataTable's own #601 icons use). */
154
+ .crisp-comments-badge { box-sizing: border-box; font-family: ${tokens_1.contract.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; }
149
155
  .crisp-comments-badge-dot { box-sizing: border-box; width: 6px; height: 6px; border-radius: ${tokens_1.contract.radius.full}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 38%, transparent); }
150
- .crisp-comments-badge-count { font-size: 12px; line-height: 16px; }
156
+ .crisp-comments-badge-trigger { box-sizing: border-box; display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: 20px; margin: 0; padding: 2px ${tokens_1.contract.space["1.5"]} 2px ${tokens_1.contract.space["1"]}; 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; }
157
+ .crisp-comments-badge-trigger:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); color: ${tokens_1.contract.fgPrimary}; }
158
+ .crisp-comments-badge-preview { box-sizing: border-box; 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; }
159
+ .crisp-comments-badge-preview:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); color: ${tokens_1.contract.fgPrimary}; }
160
+ .crisp-comments-badge-count { font-size: 12px; line-height: 16px; font-weight: ${tokens_1.contract.weight.medium}; }
151
161
  [dir="rtl"] .crisp-comments-badge [data-rtl-flip] { transform: scaleX(-1); }
152
162
  `,
153
163
  });
@@ -193,7 +193,6 @@ exports.recordsTableLabels = {
193
193
  selectionAnnouncement: (count) => count === 0 ? "Selection cleared" : `${count} selected`,
194
194
  delete: "Delete",
195
195
  sendEmail: "Send email",
196
- exportCsv: "Export CSV",
197
196
  runWorkflow: "Run workflow",
198
197
  calculate: "Calculate",
199
198
  emptyPlaceholder: "Empty",
@@ -1043,21 +1042,6 @@ function RecordsTable({ labels, columns: columnsProp = exports.defaultColumns, r
1043
1042
  setData((prev) => prev.filter((r) => !selected.has(r.id)));
1044
1043
  setSelected(new Set());
1045
1044
  };
1046
- const exportSelected = () => {
1047
- const picked = data.filter((r) => selected.has(r.id));
1048
- const esc = (s) => `"${s.replace(/"/g, '""')}"`;
1049
- const csv = [
1050
- cols.map((c) => esc(c.label)).join(","),
1051
- ...picked.map((r) => cols.map((c) => esc(cellText(c, r))).join(",")),
1052
- ].join("\n");
1053
- const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
1054
- const a = document.createElement("a");
1055
- a.href = url;
1056
- a.download = "records.csv";
1057
- a.click();
1058
- URL.revokeObjectURL(url);
1059
- setOpenMenu(null);
1060
- };
1061
1045
  const entityCol = cols.find((c) => c.type === "entity");
1062
1046
  // "Add to list" → pin the selected rows (shows a ★ on the entity)
1063
1047
  const addToList = () => {
@@ -1303,7 +1287,7 @@ function RecordsTable({ labels, columns: columnsProp = exports.defaultColumns, r
1303
1287
  // filtered-to-zero ring -- two different Attio surfaces.
1304
1288
  variant: !activeFilters && emptyStateExtra ? "records" : "page", icon: activeFilters ? ((0, jsx_runtime_1.jsx)(lucide_react_1.ListFilter, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.Inbox, { size: !activeFilters && emptyStateExtra ? 40 : 18, strokeWidth: 1.25, "aria-hidden": "true" })), title: activeFilters ? "No records match that filter" : t.noRecords, description: activeFilters
1305
1289
  ? "None of your records match the filters that are set."
1306
- : `Create your first ${cols.find((c) => c.type === "entity")?.label?.toLowerCase() ?? "record"} to get started.`, action: activeFilters ? ((0, jsx_runtime_1.jsxs)(button_1.Button, { intent: "primary", size: "sm", onClick: () => setFilterConds([]), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ListFilter, { size: 15, strokeWidth: 1.75 }), t.clearFilter] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(button_1.Button, { intent: "primary", size: "sm", onClick: addRow, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75 }), t.newRecord] }), emptyStateExtra?.onImportCsv && ((0, jsx_runtime_1.jsxs)(button_1.Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onImportCsv, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Upload, { size: 15, strokeWidth: 1.75 }), t.importCsv] })), emptyStateExtra?.onConnectDataSource && ((0, jsx_runtime_1.jsxs)(button_1.Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onConnectDataSource, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plug, { size: 15, strokeWidth: 1.75 }), t.connectDataSource] }))] })), learnMore: !activeFilters ? emptyStateExtra?.learnMore : undefined }) })) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordstable-addrow", onClick: addRow, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), t.newRecord] }))] }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordstable-live", role: "status", "aria-live": "polite", children: selectionAnnouncement }), selected.size > 0 && ((0, jsx_runtime_1.jsxs)(bulkbar_1.BulkBar, { count: selected.size, countLabel: t.selectedCount, onClear: () => setSelected(new Set()), children: [(0, jsx_runtime_1.jsx)(bulkbar_1.BulkBarButton, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.ListPlus, { size: 15, strokeWidth: 1.75 }), onClick: addToList, children: t.addToList }), (0, jsx_runtime_1.jsx)(bulkbar_1.BulkBarButton, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Mail, { size: 15, strokeWidth: 1.75 }), onClick: sendEmail, children: t.sendEmail }), (0, jsx_runtime_1.jsx)(bulkbar_1.BulkBarButton, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Workflow, { size: 15, strokeWidth: 1.75 }), onClick: runWorkflow, children: t.runWorkflow }), (0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "end", "aria-label": t.moreActions, trigger: (0, jsx_runtime_1.jsx)(bulkbar_1.BulkBarButton, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.MoreHorizontal, { size: 15, strokeWidth: 1.75 }), children: t.more }), children: [(0, jsx_runtime_1.jsx)(menu_1.MenuItem, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Download, { size: 15, strokeWidth: 1.75 }), onSelect: exportSelected, children: t.exportCsv }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, onSelect: deleteSelected, children: t.delete })] })] })), (0, jsx_runtime_1.jsx)(CreateRecordDialog, { labels: t, open: creating, onOpenChange: setCreating, columns: cols, entityLabel: cols.find((c) => c.type === "entity")?.label ?? "record", onCreate: (row) => setData((prev) => [...prev, row]) }), ctxMenu &&
1290
+ : `Create your first ${cols.find((c) => c.type === "entity")?.label?.toLowerCase() ?? "record"} to get started.`, action: activeFilters ? ((0, jsx_runtime_1.jsxs)(button_1.Button, { intent: "primary", size: "sm", onClick: () => setFilterConds([]), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ListFilter, { size: 15, strokeWidth: 1.75 }), t.clearFilter] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(button_1.Button, { intent: "primary", size: "sm", onClick: addRow, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75 }), t.newRecord] }), emptyStateExtra?.onImportCsv && ((0, jsx_runtime_1.jsxs)(button_1.Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onImportCsv, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Upload, { size: 15, strokeWidth: 1.75 }), t.importCsv] })), emptyStateExtra?.onConnectDataSource && ((0, jsx_runtime_1.jsxs)(button_1.Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onConnectDataSource, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plug, { size: 15, strokeWidth: 1.75 }), t.connectDataSource] }))] })), learnMore: !activeFilters ? emptyStateExtra?.learnMore : undefined }) })) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordstable-addrow", onClick: addRow, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), t.newRecord] }))] }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordstable-live", role: "status", "aria-live": "polite", children: selectionAnnouncement }), selected.size > 0 && ((0, jsx_runtime_1.jsxs)(bulkbar_1.BulkBar, { count: selected.size, countLabel: t.selectedCount, onClear: () => setSelected(new Set()), children: [(0, jsx_runtime_1.jsx)(bulkbar_1.BulkBarButton, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.ListPlus, { size: 15, strokeWidth: 1.75 }), onClick: addToList, children: t.addToList }), (0, jsx_runtime_1.jsx)(bulkbar_1.BulkBarButton, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Mail, { size: 15, strokeWidth: 1.75 }), onClick: sendEmail, children: t.sendEmail }), (0, jsx_runtime_1.jsx)(bulkbar_1.BulkBarButton, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Workflow, { size: 15, strokeWidth: 1.75 }), onClick: runWorkflow, children: t.runWorkflow }), (0, jsx_runtime_1.jsx)(menu_1.Menu, { align: "end", "aria-label": t.moreActions, trigger: (0, jsx_runtime_1.jsx)(bulkbar_1.BulkBarButton, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.MoreHorizontal, { size: 15, strokeWidth: 1.75 }), children: t.more }), children: (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, onSelect: deleteSelected, children: t.delete }) })] })), (0, jsx_runtime_1.jsx)(CreateRecordDialog, { labels: t, open: creating, onOpenChange: setCreating, columns: cols, entityLabel: cols.find((c) => c.type === "entity")?.label ?? "record", onCreate: (row) => setData((prev) => [...prev, row]) }), ctxMenu &&
1307
1291
  (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsxs)("div", { ref: ctxRef, className: "crisp-recordstable-menu crisp-recordstable-ctx", role: "menu", "aria-label": t.recordActions, style: { position: "fixed", top: ctxMenu.y, left: ctxMenu.x }, onKeyDown: onCtxKeyDown, children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => copyRow(ctxMenu.id), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Copy, { size: 15, strokeWidth: 1.75 }), t.copy] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => duplicateRow(ctxMenu.id), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CopyPlus, { size: 15, strokeWidth: 1.75 }), t.duplicate] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => pinRow(ctxMenu.id), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Star, { size: 15, strokeWidth: 1.75 }), t.addToList] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordstable-menu-item crisp-recordstable-menu-item--danger", role: "menuitem", tabIndex: -1, onClick: () => deleteRow(ctxMenu.id), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Trash2, { size: 15, strokeWidth: 1.75 }), t.delete] })] }), rootRef.current ?? document.body)] }));
1308
1292
  }
1309
1293
  exports.defaultColumns = [
@@ -41,28 +41,43 @@ const menu_1 = require("../../components/menu");
41
41
  const select_1 = require("../../components/select");
42
42
  const core_1 = require("../../core");
43
43
  const savedviews_recipe_1 = require("./savedviews.recipe");
44
+ // #618: the same fallback Select's own `optionText` gives a non-string
45
+ // `label` — `keywords` first, then the plain string itself, then (rather
46
+ // than becoming permanently unmatchable) the view's own `id`.
47
+ function viewText(v) {
48
+ if (v.keywords)
49
+ return v.keywords;
50
+ return typeof v.name === "string" ? v.name : v.id;
51
+ }
44
52
  /**
45
53
  * A saved-view switcher — measured from Attio's "All Companies ▾" menu. A
46
54
  * chip trigger opens a popover listing every saved view as a `role="option"`
47
55
  * row (leading coloured icon, name, a hover-revealed kebab of
48
56
  * rename/duplicate/delete), plus a trailing "Create new view" row. The
49
57
  * active view is marked `aria-selected`/`aria-current="page"` and mirrored
50
- * onto the trigger's own icon/label.
58
+ * onto the trigger's own icon/label. The panel opens on a "Search views..."
59
+ * box (#618, P-007) — real DOM focus moves there (`requestAnimationFrame`,
60
+ * same race `Select`'s own `searchable` variant already guards against),
61
+ * not virtual focus on the trigger.
51
62
  *
52
- * Keyboard: while the trigger has focus and the panel is open, ↑/↓ move a
53
- * virtual highlight (`aria-activedescendant`, the same "focus stays on the
54
- * trigger" model `Select`/`MultiSelect` use without a search field), Enter
55
- * chooses the highlighted row, Escape closes — deferring to a still-open
56
- * kebab submenu first (`hasOpenFloatingLayer`), so Escape closes one layer
57
- * at a time.
63
+ * Keyboard: while the panel is open, focus lives on the search input — ↑/↓
64
+ * move a virtual highlight (`aria-activedescendant`) over the (possibly
65
+ * filtered) view list, Enter chooses the highlighted row, Escape closes,
66
+ * deferring to a still-open kebab submenu first (`hasOpenFloatingLayer`),
67
+ * so Escape closes one layer at a time — the same split `Select`'s own
68
+ * `searchable` variant already uses between its trigger and its own search
69
+ * input.
58
70
  */
59
- function SavedViews({ label, icon, views, activeId, onSelect, onCreate, onRename, onDuplicate, onDelete, onToggleFavorite, createLabel: createLabelProp, renameLabel: renameLabelProp, duplicateLabel: duplicateLabelProp, deleteLabel: deleteLabelProp, favoriteLabel: favoriteLabelProp, viewOptionsLabel: viewOptionsLabelProp, className, "aria-label": ariaLabel, ...rest }) {
71
+ function SavedViews({ label, icon, views, activeId, onSelect, onCreate, onRename, onDuplicate, onDelete, onToggleFavorite, createLabel: createLabelProp, renameLabel: renameLabelProp, duplicateLabel: duplicateLabelProp, deleteLabel: deleteLabelProp, favoriteLabel: favoriteLabelProp, viewOptionsLabel: viewOptionsLabelProp, searchPlaceholder: searchPlaceholderProp, searchLabel: searchLabelProp, emptyLabel: emptyLabelProp, className, "aria-label": ariaLabel, ...rest }) {
60
72
  const createLabel = (0, core_1.useCrispMessage)(createLabelProp, "Create new view");
61
73
  const renameLabel = (0, core_1.useCrispMessage)(renameLabelProp, "Rename");
62
74
  const duplicateLabel = (0, core_1.useCrispMessage)(duplicateLabelProp, "Duplicate");
63
75
  const deleteLabel = (0, core_1.useCrispMessage)(deleteLabelProp, "Delete");
64
76
  const favoriteLabel = (0, core_1.useCrispMessage)(favoriteLabelProp, "Add to favorites");
65
77
  const viewOptionsLabel = (0, core_1.useCrispMessage)(viewOptionsLabelProp, "View options");
78
+ const searchPlaceholder = (0, core_1.useCrispMessage)(searchPlaceholderProp, "Search views...");
79
+ const searchLabel = (0, core_1.useCrispMessage)(searchLabelProp, "Filter views");
80
+ const emptyLabel = (0, core_1.useCrispMessage)(emptyLabelProp, "No results");
66
81
  const autoId = React.useId();
67
82
  const triggerId = `${autoId}-trigger`;
68
83
  // role="combobox" (below) does not take its name from content — unlike a
@@ -81,13 +96,22 @@ function SavedViews({ label, icon, views, activeId, onSelect, onCreate, onRename
81
96
  const [up, setUp] = React.useState(false);
82
97
  const [pos, setPos] = React.useState({});
83
98
  const [highlight, setHighlight] = React.useState(-1);
99
+ // #618: the panel's own "Search views..." box — real DOM focus lives here
100
+ // while open, unlike the virtual-focus-on-trigger model this component
101
+ // used before the search box existed.
102
+ const [query, setQuery] = React.useState("");
84
103
  const rootRef = React.useRef(null);
85
104
  const triggerRef = React.useRef(null);
86
105
  const panelRef = React.useRef(null);
106
+ const searchRef = React.useRef(null);
87
107
  const activeView = views.find((v) => v.id === activeId) ?? null;
108
+ const q = query.trim().toLowerCase();
109
+ const filteredViews = q
110
+ ? views.filter((v) => viewText(v).toLowerCase().includes(q))
111
+ : views;
88
112
  // the trailing "Create new view" row is one more enabled index, past every
89
- // real view
90
- const createIndex = views.length;
113
+ // (possibly filtered) view -- it never itself filters out
114
+ const createIndex = filteredViews.length;
91
115
  const openPanel = () => {
92
116
  const rect = triggerRef.current?.getBoundingClientRect();
93
117
  if (rect) {
@@ -100,8 +124,14 @@ function SavedViews({ label, icon, views, activeId, onSelect, onCreate, onRename
100
124
  setUp(flipUp);
101
125
  setPos(style);
102
126
  }
127
+ setQuery("");
103
128
  setHighlight(-1);
104
129
  setOpen(true);
130
+ // Same race Select's own `searchable` variant already guards against
131
+ // (its own comment: calling focus synchronously here loses to the
132
+ // portal's own mount timing) -- a `requestAnimationFrame` after the
133
+ // state update that renders the input wins it.
134
+ requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
105
135
  };
106
136
  const close = React.useCallback(() => {
107
137
  setOpen(false);
@@ -113,7 +143,7 @@ function SavedViews({ label, icon, views, activeId, onSelect, onCreate, onRename
113
143
  close();
114
144
  }
115
145
  else {
116
- const v = views[highlight];
146
+ const v = filteredViews[highlight];
117
147
  if (v) {
118
148
  onSelect(v.id);
119
149
  close();
@@ -121,7 +151,7 @@ function SavedViews({ label, icon, views, activeId, onSelect, onCreate, onRename
121
151
  }
122
152
  };
123
153
  const { onKeyDown: onNavKeys } = (0, select_1.useListboxKeyboard)({
124
- enabledIndexes: Array.from({ length: views.length + 1 }, (_, i) => i),
154
+ enabledIndexes: Array.from({ length: filteredViews.length + 1 }, (_, i) => i),
125
155
  highlight,
126
156
  setHighlight,
127
157
  commit,
@@ -180,25 +210,26 @@ function SavedViews({ label, icon, views, activeId, onSelect, onCreate, onRename
180
210
  const triggerIcon = activeView ? activeView.icon : icon;
181
211
  const triggerLabel = activeView ? activeView.name : label;
182
212
  return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, savedviews_recipe_1.savedviewsRecipe)({ className }), ...rest, children: [(0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, id: triggerId, type: "button",
183
- // combobox, not the bare default "button" role: aria-activedescendant
184
- // (the virtual-focus highlight, same model Select/MultiSelect use
185
- // without a search field) is only valid on a widget role that
186
- // supports it, and combobox's aria-haspopup explicitly allows
187
- // "dialog" — the value Attio's own trigger measures — alongside the
188
- // more common "listbox".
189
- role: "combobox", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? panelId : undefined, "aria-activedescendant": open && highlight >= 0 ? optionId(highlight) : undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerLabelId, "data-open": open ? "" : undefined, className: "crisp-savedviews-trigger", onClick: () => (open ? setOpen(false) : openPanel()), onKeyDown: (e) => {
190
- if (open)
191
- onNavKeys(e);
192
- else if (e.key === "ArrowDown" || e.key === "Enter") {
213
+ // combobox: aria-haspopup explicitly allows "dialog" — the value
214
+ // Attio's own trigger measures — alongside the more common
215
+ // "listbox". aria-activedescendant lives on the search input (#618)
216
+ // now that real focus moves there while open, not here — the same
217
+ // split Select's own `searchable` variant already uses.
218
+ role: "combobox", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? panelId : undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerLabelId, "data-open": open ? "" : undefined, className: "crisp-savedviews-trigger", onClick: () => (open ? setOpen(false) : openPanel()), onKeyDown: (e) => {
219
+ // navigation is handled on the search input while open (#618)
220
+ if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
193
221
  e.preventDefault();
194
222
  openPanel();
195
223
  }
196
- }, children: [triggerIcon != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-savedviews-trigger-icon", children: triggerIcon })), (0, jsx_runtime_1.jsx)("span", { id: triggerLabelId, className: "crisp-savedviews-trigger-label", children: triggerLabel }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-savedviews-trigger-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: panelRef, id: panelId, role: "listbox", "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerLabelId, className: "crisp-savedviews-panel", "data-up": up ? "" : undefined, style: pos, children: [views.map((v, i) => {
224
+ }, children: [triggerIcon != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-savedviews-trigger-icon", children: triggerIcon })), (0, jsx_runtime_1.jsx)("span", { id: triggerLabelId, className: "crisp-savedviews-trigger-label", children: triggerLabel }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-savedviews-trigger-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: panelRef, id: panelId, role: "listbox", "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerLabelId, className: "crisp-savedviews-panel", "data-up": up ? "" : undefined, style: pos, children: [(0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchLabel, "aria-controls": panelId, "aria-activedescendant": highlight >= 0 ? optionId(highlight) : undefined, className: "crisp-savedviews-search", value: query, onChange: (e) => {
225
+ setQuery(e.target.value);
226
+ setHighlight(-1);
227
+ }, onKeyDown: onNavKeys }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-savedviews-search-sep", "aria-hidden": "true" }), filteredViews.length === 0 && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-savedviews-empty", children: emptyLabel })), filteredViews.map((v, i) => {
197
228
  const selected = v.id === activeId;
198
229
  return ((0, jsx_runtime_1.jsxs)("div", { id: optionId(i), role: "option", "aria-selected": selected, "aria-current": selected ? "page" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-savedviews-row", onMouseEnter: () => setHighlight(i), onClick: () => {
199
230
  onSelect(v.id);
200
231
  close();
201
- }, children: [v.icon != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-savedviews-row-icon", children: v.icon })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-savedviews-row-label", children: v.name }), v.shared && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-savedviews-row-shared", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Users2, { size: 12, strokeWidth: 1.75 }) })), (0, jsx_runtime_1.jsx)("div", { className: "crisp-savedviews-kebab-slot", onClick: (e) => e.stopPropagation(), children: (0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "end", trigger: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-savedviews-kebab", "aria-label": viewOptionsLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.MoreVertical, { size: 14, strokeWidth: 1.75 }) }), children: [onToggleFavorite && ((0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => onToggleFavorite(v.id), children: favoriteLabel })), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => onRename(v.id), children: renameLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => onDuplicate(v.id), children: duplicateLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { danger: true, onSelect: () => onDelete(v.id), children: deleteLabel })] }) })] }, v.id));
232
+ }, children: [v.icon != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-savedviews-row-icon", children: v.icon })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-savedviews-row-label", children: v.name }), v.shared && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-savedviews-row-shared", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Users2, { size: 12, strokeWidth: 1.75 }) })), (0, jsx_runtime_1.jsx)("div", { className: "crisp-savedviews-kebab-slot", onClick: (e) => e.stopPropagation(), children: (0, jsx_runtime_1.jsxs)(menu_1.Menu, { align: "end", trigger: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-savedviews-kebab", "aria-label": viewOptionsLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.MoreVertical, { size: 14, strokeWidth: 1.75 }) }), children: [onToggleFavorite && ((0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => onToggleFavorite(v.id), children: favoriteLabel })), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => onRename(v.id), children: renameLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { onSelect: () => onDuplicate(v.id), children: duplicateLabel }), (0, jsx_runtime_1.jsx)(menu_1.MenuSeparator, {}), (0, jsx_runtime_1.jsx)(menu_1.MenuItem, { danger: true, onSelect: () => onDelete(v.id), children: deleteLabel }), (v.createdBy != null || v.createdAt != null) && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(menu_1.MenuSeparator, {}), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-savedviews-kebab-footer", children: [v.createdBy != null && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-savedviews-kebab-footer-line", children: v.createdBy })), v.createdAt != null && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-savedviews-kebab-footer-line", children: v.createdAt }))] })] }))] }) })] }, v.id));
202
233
  }), (0, jsx_runtime_1.jsxs)("div", { id: optionId(createIndex), role: "option", "aria-selected": false, "data-highlighted": highlight === createIndex ? "" : undefined, className: "crisp-savedviews-row", onMouseEnter: () => setHighlight(createIndex), onClick: () => {
203
234
  onCreate();
204
235
  close();
@@ -63,6 +63,21 @@ const config = (0, core_1.defineRecipe)({
63
63
  .crisp-savedviews-panel[data-up] { transform-origin: bottom left; }
64
64
  .crisp-savedviews-panel { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
65
65
 
66
+ /* #618 (P-007): the panel's own "Search views..." box — measured live:
67
+ 32px tall, 6px/8px padding, NO border/background/leading icon of its
68
+ own (unlike Select's own bordered searchable-input pill), 14px text.
69
+ A hairline divider (borderSubtle, 1px, 3px top margin) separates it
70
+ from the view list, matching the divider Attio's own kebab menu uses
71
+ between its action items and the "Created by" footer below. */
72
+ .crisp-savedviews-search {
73
+ box-sizing: border-box; display: block; width: 100%; height: 32px; flex-shrink: 0;
74
+ margin: 0; padding: ${tokens_1.contract.space["1.5"]} ${tokens_1.contract.space["2"]}; border: none; background: none;
75
+ font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; outline: none;
76
+ }
77
+ .crisp-savedviews-search::placeholder { color: ${tokens_1.contract.fgMuted}; }
78
+ .crisp-savedviews-search-sep { height: 1px; flex-shrink: 0; margin: 3px 0 0; background: ${tokens_1.contract.borderSubtle}; }
79
+ .crisp-savedviews-empty { padding: ${tokens_1.contract.space["1.5"]} ${tokens_1.contract.space["2"]}; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgMuted}; }
80
+
66
81
  .crisp-savedviews-row {
67
82
  box-sizing: border-box; position: relative; display: flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]};
68
83
  height: ${tokens_1.contract.size.menuRow}; flex-shrink: 0; padding: ${tokens_1.contract.space["1.5"]} ${tokens_1.contract.space["2"]};
@@ -94,6 +109,18 @@ const config = (0, core_1.defineRecipe)({
94
109
  .crisp-savedviews-row:focus-within .crisp-savedviews-kebab,
95
110
  .crisp-savedviews-kebab[data-open] { opacity: 1; }
96
111
  .crisp-savedviews-kebab:hover, .crisp-savedviews-kebab[data-open] { background: ${tokens_1.contract.bgSurface}; color: ${tokens_1.contract.fgPrimary}; }
112
+
113
+ /* #618 (P-008): the kebab menu's own "Created by X" / date footer —
114
+ measured live: 2 stacked lines, 6px/8px padding, 2px gap between
115
+ them, 12px/500 text, BOTH lines the same color-mix 50% muted (a
116
+ distinct value from the 63% mix #592's chip breadcrumb uses —
117
+ independently measured, not assumed to match). */
118
+ .crisp-savedviews-kebab-footer {
119
+ display: flex; flex-direction: column; gap: 2px;
120
+ padding: ${tokens_1.contract.space["1.5"]} ${tokens_1.contract.space["2"]};
121
+ font-size: ${tokens_1.contract.text.sm}; font-weight: ${tokens_1.contract.weight.medium};
122
+ color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 50%, transparent);
123
+ }
97
124
  `,
98
125
  variants: {},
99
126
  defaultVariants: {},
package/dist/comments.css CHANGED
@@ -12,11 +12,21 @@
12
12
 
13
13
  /* Row badge (CommentCountBadge) -- lives in its own class family so the
14
14
  grid lanes can drop it into a cell without pulling in the panel's
15
- CSS. Persistent state: a 6px grey dot. Hover state: a 12px comment
16
- icon + 12px count text, both at 55% ink, replacing the dot. */
17
- .crisp-comments-badge { box-sizing: border-box; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: inline-flex; align-items: center; gap: var(--crisp-space-1); border: none; background: none; padding: 0; margin: 0; cursor: pointer; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); }
15
+ CSS. Re-measured live on a real commented row (crisp/#601
16
+ supersession, right-aligned in a DataTable primary column): a 6px
17
+ grey dot at rest (unchanged, already matched); on hover, the trigger
18
+ button is auto-width with a 12px icon + 12px/500 count text,
19
+ "padding: 2px 6px 2px 4px", radius 6px -- distinct from the preview
20
+ button beside it, a fixed 20px square with a 16px icon, same radius.
21
+ Both share the 55%-ink idle colour and darken on their OWN hover
22
+ (color-mix 4%, the same formula DataTable's own #601 icons use). */
23
+ .crisp-comments-badge { box-sizing: border-box; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: inline-flex; align-items: center; gap: var(--crisp-space-1-5); }
18
24
  .crisp-comments-badge-dot { box-sizing: border-box; width: 6px; height: 6px; border-radius: var(--crisp-radius-full); background: color-mix(in srgb, var(--crisp-fg-primary) 38%, transparent); }
19
- .crisp-comments-badge-count { font-size: 12px; line-height: 16px; }
25
+ .crisp-comments-badge-trigger { box-sizing: border-box; display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: 20px; margin: 0; padding: 2px var(--crisp-space-1-5) 2px var(--crisp-space-1); border: none; border-radius: var(--crisp-radius-sm); background: none; font-family: inherit; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); cursor: pointer; }
26
+ .crisp-comments-badge-trigger:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); color: var(--crisp-fg-primary); }
27
+ .crisp-comments-badge-preview { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin: 0; padding: 0; border: none; border-radius: var(--crisp-radius-sm); background: none; font-family: inherit; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); cursor: pointer; flex-shrink: 0; }
28
+ .crisp-comments-badge-preview:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); color: var(--crisp-fg-primary); }
29
+ .crisp-comments-badge-count { font-size: 12px; line-height: 16px; font-weight: var(--crisp-weight-medium); }
20
30
  [dir="rtl"] .crisp-comments-badge [data-rtl-flip] { transform: scaleX(-1); }
21
31
 
22
32
  .crisp-comments {