@crisp-ui-kit/crisp 0.54.2 → 0.54.4

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 (116) hide show
  1. package/dist/board.css +13 -1
  2. package/dist/board.layered.css +13 -1
  3. package/dist/chip.css +7 -1
  4. package/dist/chip.d.ts +2 -1
  5. package/dist/chip.layered.css +7 -1
  6. package/dist/cjs/components/chip/chip.js +3 -2
  7. package/dist/cjs/components/chip/chip.recipe.js +37 -1
  8. package/dist/cjs/components/datatable/datatable.js +489 -111
  9. package/dist/cjs/components/emptystate/emptystate.js +3 -2
  10. package/dist/cjs/components/emptystate/emptystate.recipe.js +26 -1
  11. package/dist/cjs/components/personchip/index.js +17 -0
  12. package/dist/cjs/components/personchip/personchip.js +50 -0
  13. package/dist/cjs/components/table/table.js +2 -2
  14. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +41 -4
  15. package/dist/cjs/components/tabs/tabs.js +2 -2
  16. package/dist/cjs/components/tabs/tabs.recipe.js +30 -3
  17. package/dist/cjs/components/text/index.js +7 -0
  18. package/dist/cjs/components/text/text.js +55 -0
  19. package/dist/cjs/components/text/text.recipe.js +34 -0
  20. package/dist/cjs/index.js +4 -0
  21. package/dist/cjs/product/board/board.js +60 -2
  22. package/dist/cjs/product/board/board.recipe.js +10 -0
  23. package/dist/cjs/product/comments/comments.js +426 -0
  24. package/dist/cjs/product/comments/comments.recipe.js +126 -0
  25. package/dist/cjs/product/comments/index.js +8 -0
  26. package/dist/cjs/product/headerbar/headerbar.recipe.js +9 -1
  27. package/dist/cjs/product/listpage/listpage.recipe.js +10 -1
  28. package/dist/cjs/product/objectsettings/index.js +16 -0
  29. package/dist/cjs/product/objectsettings/objectsettings.js +365 -0
  30. package/dist/cjs/product/objectsettings/objectsettings.recipe.js +65 -0
  31. package/dist/cjs/product/recordpage/recordpage.recipe.js +10 -1
  32. package/dist/cjs/product/recordstable/recordstable.js +89 -6
  33. package/dist/cjs/product/richtext/richtext.recipe.js +26 -1
  34. package/dist/cjs/product/taskstable/taskstable.recipe.js +10 -1
  35. package/dist/comments.css +80 -0
  36. package/dist/comments.d.ts +7 -0
  37. package/dist/comments.layered.css +82 -0
  38. package/dist/emptystate.css +15 -1
  39. package/dist/emptystate.d.ts +2 -2
  40. package/dist/emptystate.layered.css +15 -1
  41. package/dist/esm/components/chip/chip.js +3 -2
  42. package/dist/esm/components/chip/chip.recipe.js +37 -1
  43. package/dist/esm/components/datatable/datatable.js +488 -111
  44. package/dist/esm/components/emptystate/emptystate.js +4 -3
  45. package/dist/esm/components/emptystate/emptystate.recipe.js +26 -1
  46. package/dist/esm/components/emptystate/index.js +1 -1
  47. package/dist/esm/components/personchip/index.js +1 -0
  48. package/dist/esm/components/personchip/personchip.js +47 -0
  49. package/dist/esm/components/table/table.js +2 -2
  50. package/dist/esm/components/tabletoolbar/tabletoolbar.js +43 -6
  51. package/dist/esm/components/tabs/tabs.js +2 -2
  52. package/dist/esm/components/tabs/tabs.recipe.js +30 -3
  53. package/dist/esm/components/text/index.js +2 -0
  54. package/dist/esm/components/text/text.js +19 -0
  55. package/dist/esm/components/text/text.recipe.js +31 -0
  56. package/dist/esm/index.js +4 -0
  57. package/dist/esm/product/board/board.js +61 -3
  58. package/dist/esm/product/board/board.recipe.js +10 -0
  59. package/dist/esm/product/comments/comments.js +388 -0
  60. package/dist/esm/product/comments/comments.recipe.js +123 -0
  61. package/dist/esm/product/comments/index.js +2 -0
  62. package/dist/esm/product/headerbar/headerbar.recipe.js +9 -1
  63. package/dist/esm/product/listpage/listpage.recipe.js +10 -1
  64. package/dist/esm/product/objectsettings/index.js +2 -0
  65. package/dist/esm/product/objectsettings/objectsettings.js +322 -0
  66. package/dist/esm/product/objectsettings/objectsettings.recipe.js +62 -0
  67. package/dist/esm/product/recordpage/recordpage.recipe.js +10 -1
  68. package/dist/esm/product/recordstable/recordstable.js +90 -8
  69. package/dist/esm/product/richtext/richtext.recipe.js +26 -1
  70. package/dist/esm/product/taskstable/taskstable.recipe.js +10 -1
  71. package/dist/headerbar.css +1 -1
  72. package/dist/headerbar.layered.css +1 -1
  73. package/dist/listpage.css +8 -1
  74. package/dist/listpage.layered.css +8 -1
  75. package/dist/objectsettings.css +49 -0
  76. package/dist/objectsettings.d.ts +7 -0
  77. package/dist/objectsettings.layered.css +51 -0
  78. package/dist/recordpage.css +13 -2
  79. package/dist/recordpage.layered.css +13 -2
  80. package/dist/richtext.css +11 -1
  81. package/dist/richtext.layered.css +11 -1
  82. package/dist/styles.css +224 -10
  83. package/dist/styles.layered.css +224 -10
  84. package/dist/tabs.css +7 -1
  85. package/dist/tabs.d.ts +2 -2
  86. package/dist/tabs.layered.css +7 -1
  87. package/dist/taskstable.css +8 -1
  88. package/dist/taskstable.layered.css +8 -1
  89. package/dist/text.css +9 -0
  90. package/dist/text.d.ts +7 -0
  91. package/dist/text.layered.css +11 -0
  92. package/dist/types/components/chip/chip.d.ts +32 -0
  93. package/dist/types/components/chip/chip.recipe.d.ts +31 -0
  94. package/dist/types/components/datatable/datatable.d.ts +28 -4
  95. package/dist/types/components/emptystate/emptystate.d.ts +29 -4
  96. package/dist/types/components/emptystate/emptystate.recipe.d.ts +2 -0
  97. package/dist/types/components/emptystate/index.d.ts +1 -1
  98. package/dist/types/components/personchip/index.d.ts +1 -0
  99. package/dist/types/components/personchip/personchip.d.ts +37 -0
  100. package/dist/types/components/table/table.d.ts +36 -0
  101. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +8 -2
  102. package/dist/types/components/tabs/tabs.d.ts +4 -2
  103. package/dist/types/components/tabs/tabs.recipe.d.ts +18 -3
  104. package/dist/types/components/text/index.d.ts +2 -0
  105. package/dist/types/components/text/text.d.ts +32 -0
  106. package/dist/types/components/text/text.recipe.d.ts +40 -0
  107. package/dist/types/index.d.ts +4 -0
  108. package/dist/types/product/board/board.d.ts +20 -1
  109. package/dist/types/product/comments/comments.d.ts +192 -0
  110. package/dist/types/product/comments/comments.recipe.d.ts +3 -0
  111. package/dist/types/product/comments/index.d.ts +2 -0
  112. package/dist/types/product/objectsettings/index.d.ts +2 -0
  113. package/dist/types/product/objectsettings/objectsettings.d.ts +261 -0
  114. package/dist/types/product/objectsettings/objectsettings.recipe.d.ts +3 -0
  115. package/dist/types/product/recordstable/recordstable.d.ts +24 -2
  116. package/package.json +21 -1
@@ -0,0 +1,388 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ArrowUpRight, Check, ChevronLeft, CornerUpLeft, ListFilter, MessageSquare, MoreHorizontal, SmilePlus, X, } from "lucide-react";
3
+ import * as React from "react";
4
+ import { Avatar } from "../../components/avatar/index.js";
5
+ import { Button } from "../../components/button/index.js";
6
+ import { Dialog, DialogBody, DialogFooter } from "../../components/dialog/index.js";
7
+ import { Menu, MenuCheckboxItem, MenuItem } from "../../components/menu/index.js";
8
+ import { Popover } from "../../components/popover/index.js";
9
+ import { matchMentionTrigger, useCrispMessage, } from "../../core/index.js";
10
+ import { commentsRecipe } from "./comments.recipe.js";
11
+ /** A single literal JSX occurrence for the reply glyph, used at two sizes --
12
+ * so the RTL-mirroring sweep (which counts the flip-marker attribute against
13
+ * source-text occurrences, not runtime renders) sees one mark for one
14
+ * direction-bearing icon, not two. */
15
+ function ReplyGlyph({ size }) {
16
+ return (_jsx(CornerUpLeft, { "data-rtl-flip": "", size: size, strokeWidth: 2, "aria-hidden": "true" }));
17
+ }
18
+ const DEFAULT_EMOJI = [
19
+ "👍",
20
+ "👎",
21
+ "😀",
22
+ "😂",
23
+ "😍",
24
+ "🎉",
25
+ "🙏",
26
+ "👏",
27
+ "🔥",
28
+ "❤️",
29
+ "✅",
30
+ "👀",
31
+ "🚀",
32
+ "💯",
33
+ "😢",
34
+ "🤔",
35
+ ];
36
+ function initial(name) {
37
+ return (name.trim().charAt(0) || "?").toUpperCase();
38
+ }
39
+ function AuthorAvatar({ author }) {
40
+ if (author.avatar != null)
41
+ return _jsx(_Fragment, { children: author.avatar });
42
+ return (_jsx(Avatar, { size: "sm", tone: "cyan", children: initial(author.name) }));
43
+ }
44
+ /**
45
+ * A minimal reaction picker — search box + a curated emoji grid. Attio's own
46
+ * picker (`Search Emojis`, categorised sections) wasn't independently
47
+ * pixel-measured this pass: the live CDP session's `Page.captureScreenshot`
48
+ * started timing out after the panel/composer/row measurements were already
49
+ * captured, and the popover itself closed on every retry before a shadow/
50
+ * radius read landed. The chrome below reuses crisp's own already-measured
51
+ * floating-popover shell (`radius.xl`, `elevation.floating`-family shadow —
52
+ * see `Menu`) rather than inventing new values, and the search placeholder
53
+ * ("Search Emojis") IS the live-read copy. The emoji SET is crisp's own
54
+ * curated 16, not Attio's full category grid — a full emoji library is out
55
+ * of scope for a dependency-free kit; swap `emojiOptions` for a real one.
56
+ */
57
+ function ReactionPicker({ options, searchLabel, onPick, }) {
58
+ const [query, setQuery] = React.useState("");
59
+ const filtered = query.trim()
60
+ ? options.filter((e) => e.includes(query.trim()))
61
+ : options;
62
+ return (_jsxs("div", { className: "crisp-comments-reactionpicker", children: [_jsx("input", { className: "crisp-comments-reactionpicker-search", placeholder: searchLabel, "aria-label": searchLabel, value: query, onChange: (e) => setQuery(e.target.value) }), _jsx("div", { className: "crisp-comments-reactionpicker-grid", children: filtered.map((emoji) => (_jsx("button", { type: "button", className: "crisp-comments-reactionpicker-item", onClick: () => onPick(emoji), children: emoji }, emoji))) })] }));
63
+ }
64
+ /**
65
+ * Reads the composer field's DOM into plain text, serializing each mention
66
+ * chip as `@[label](id)` — a fixed, parseable token a consumer (GRC) can
67
+ * pull the mentioned record's id back out of.
68
+ *
69
+ * This is the actual fix for a real bug (GRC's own composer, a plain
70
+ * `<textarea>` holding `@[name](actor:id)` as its literal editable value:
71
+ * a caret placed inside that markup can edit it like any other character,
72
+ * so a stray keystroke corrupts the token and the broken markup posts
73
+ * verbatim — seen live, `/private/tmp/grc-land138/mentions-6-posted.png`).
74
+ * A mention here is NEVER the editable representation: it's a real DOM node
75
+ * (`contentEditable=false`, so a browser won't place a caret inside it to
76
+ * begin with) carrying its id/label in two `data-` attributes set once, at
77
+ * insertion (`insertMention`), and never touched again. This function reads
78
+ * those attributes — not the chip's own `.textContent` — specifically so
79
+ * that even if something DID manage to mutate the chip's interior (a
80
+ * hostile paste, an IME composition, a direct DOM call bypassing the
81
+ * `contentEditable=false` guard entirely — exactly the class of thing a
82
+ * real browser mostly prevents but a test, or a buggy extension, would
83
+ * not), the token this function emits still can't be corrupted: it was
84
+ * never read from the part that changed.
85
+ */
86
+ export function serializeComposerField(field) {
87
+ let out = "";
88
+ for (const node of Array.from(field.childNodes)) {
89
+ if (node.nodeType === Node.TEXT_NODE) {
90
+ out += node.textContent ?? "";
91
+ continue;
92
+ }
93
+ if (node.nodeType !== Node.ELEMENT_NODE)
94
+ continue;
95
+ const el = node;
96
+ if (el.classList.contains("crisp-comments-mention")) {
97
+ const id = el.dataset.mentionId ?? "";
98
+ const label = el.dataset.mentionLabel ?? "";
99
+ out += `@[${label}](${id})`;
100
+ continue;
101
+ }
102
+ if (el.tagName === "BR") {
103
+ out += "\n";
104
+ continue;
105
+ }
106
+ // A wrapper the browser itself inserted (Enter's own new-line block,
107
+ // Shift+Enter's, a pasted `<span>`) -- recurse so a mention nested
108
+ // inside one still serializes atomically instead of falling through to
109
+ // a flattened `.textContent` read that would re-expose its raw text.
110
+ out += serializeComposerField(el);
111
+ if (el.tagName === "DIV" || el.tagName === "P")
112
+ out += "\n";
113
+ }
114
+ return out;
115
+ }
116
+ /**
117
+ * The bottom composer — a plain `contentEditable` field (no rich-text
118
+ * toolbar; Attio's own comment composer has none, unlike its note editor),
119
+ * with an "@" mention trigger mirroring `RichTextEditor`'s own detection
120
+ * (`matchMentionTrigger` + a live `Range`), and a `Comment`/`Reply` button
121
+ * disabled until the field holds text. Uncontrolled — reads its own DOM on
122
+ * submit, the same shape `RichTextEditor` uses for its body.
123
+ */
124
+ const Composer = React.forwardRef(function Composer({ placeholder, postLabel, mentionTrigger, renderMentionPicker, replyToolLabel, mentionToolLabel, onSubmit, }, ref) {
125
+ const fieldRef = React.useRef(null);
126
+ const [empty, setEmpty] = React.useState(true);
127
+ const [mention, setMention] = React.useState(null);
128
+ const mentionRangeRef = React.useRef(null);
129
+ const syncEmpty = () => setEmpty((fieldRef.current?.textContent ?? "") === "");
130
+ const closeMention = React.useCallback(() => {
131
+ mentionRangeRef.current = null;
132
+ setMention(null);
133
+ }, []);
134
+ const updateMention = React.useCallback(() => {
135
+ const field = fieldRef.current;
136
+ const sel = window.getSelection();
137
+ if (!field ||
138
+ !sel?.isCollapsed ||
139
+ sel.rangeCount === 0 ||
140
+ !field.contains(sel.anchorNode)) {
141
+ closeMention();
142
+ return;
143
+ }
144
+ const range = sel.getRangeAt(0);
145
+ const node = range.startContainer;
146
+ if (node.nodeType !== Node.TEXT_NODE) {
147
+ closeMention();
148
+ return;
149
+ }
150
+ const textBefore = (node.textContent ?? "").slice(0, range.startOffset);
151
+ const match = matchMentionTrigger(textBefore, mentionTrigger);
152
+ if (!match) {
153
+ closeMention();
154
+ return;
155
+ }
156
+ const triggerRange = document.createRange();
157
+ triggerRange.setStart(node, match.start);
158
+ triggerRange.setEnd(node, range.startOffset);
159
+ mentionRangeRef.current = triggerRange;
160
+ const rect = triggerRange.getBoundingClientRect();
161
+ setMention({
162
+ query: match.query,
163
+ anchor: { x: rect.left, y: rect.bottom },
164
+ });
165
+ }, [mentionTrigger, closeMention]);
166
+ const insertMention = (item) => {
167
+ const field = fieldRef.current;
168
+ const sel = window.getSelection();
169
+ if (!field || !sel)
170
+ return;
171
+ field.focus();
172
+ const range = mentionRangeRef.current ??
173
+ (sel.rangeCount > 0 ? sel.getRangeAt(0) : null);
174
+ if (!range)
175
+ return;
176
+ const chip = document.createElement("span");
177
+ chip.className = "crisp-comments-mention";
178
+ chip.contentEditable = "false";
179
+ // An immutable snapshot, read back only from these two attributes at
180
+ // submit time -- never from the chip's own live text (see `serialize`'s
181
+ // own doc comment for why).
182
+ chip.dataset.mentionId = item.id;
183
+ chip.dataset.mentionLabel = item.label;
184
+ chip.textContent = `${mentionTrigger}${item.label}`;
185
+ range.deleteContents();
186
+ range.insertNode(chip);
187
+ const space = document.createTextNode(" ");
188
+ chip.after(space);
189
+ const after = document.createRange();
190
+ after.setStartAfter(space);
191
+ after.collapse(true);
192
+ sel.removeAllRanges();
193
+ sel.addRange(after);
194
+ closeMention();
195
+ syncEmpty();
196
+ };
197
+ /**
198
+ * Delete a mention chip as one unit, the same "Backspace removes it whole"
199
+ * behaviour a real browser already gives a `contentEditable=false` node --
200
+ * made explicit rather than relied on, since that native behaviour isn't
201
+ * consistent enough across engines to trust alone. Only fires when the
202
+ * caret sits immediately after a chip with nothing selected; a caret
203
+ * anywhere else (including genuinely inside a chip, which a real
204
+ * `contentEditable=false` node refuses to place one in) is untouched.
205
+ */
206
+ const backspaceMention = (e) => {
207
+ const field = fieldRef.current;
208
+ const sel = window.getSelection();
209
+ if (!field || !sel?.isCollapsed || sel.rangeCount === 0)
210
+ return false;
211
+ const range = sel.getRangeAt(0);
212
+ let mention = null;
213
+ if (range.startContainer.nodeType === Node.TEXT_NODE) {
214
+ if (range.startOffset === 0) {
215
+ const prev = range.startContainer.previousSibling;
216
+ if (prev instanceof HTMLElement)
217
+ mention = prev;
218
+ }
219
+ }
220
+ else if (range.startContainer === field) {
221
+ const prev = field.childNodes[range.startOffset - 1];
222
+ if (prev instanceof HTMLElement)
223
+ mention = prev;
224
+ }
225
+ if (!mention?.classList.contains("crisp-comments-mention"))
226
+ return false;
227
+ e.preventDefault();
228
+ mention.remove();
229
+ syncEmpty();
230
+ return true;
231
+ };
232
+ const clear = () => {
233
+ if (fieldRef.current)
234
+ fieldRef.current.innerHTML = "";
235
+ setEmpty(true);
236
+ closeMention();
237
+ };
238
+ React.useImperativeHandle(ref, () => ({
239
+ clear,
240
+ focus: () => fieldRef.current?.focus(),
241
+ }));
242
+ const submit = () => {
243
+ const field = fieldRef.current;
244
+ const text = field ? serializeComposerField(field).trim() : "";
245
+ if (!text)
246
+ return;
247
+ onSubmit(text);
248
+ clear();
249
+ };
250
+ return (_jsxs("div", { className: "crisp-comments-composer", children: [_jsx("div", { ref: fieldRef, className: "crisp-comments-field", contentEditable: true, suppressContentEditableWarning: true, role: "textbox", tabIndex: 0, "aria-multiline": "true", "aria-label": placeholder, "data-placeholder": placeholder, onInput: () => {
251
+ syncEmpty();
252
+ updateMention();
253
+ }, onKeyUp: updateMention, onMouseUp: updateMention, onKeyDown: (e) => {
254
+ if (e.key === "Backspace" && backspaceMention(e))
255
+ return;
256
+ if (e.key === "Enter" && !e.shiftKey && !mention) {
257
+ e.preventDefault();
258
+ submit();
259
+ }
260
+ } }), _jsxs("div", { className: "crisp-comments-composer-foot", children: [_jsxs("div", { className: "crisp-comments-composer-tools", children: [_jsx("button", { type: "button", className: "crisp-comments-tool", "aria-label": replyToolLabel, children: _jsx(ReplyGlyph, { size: 14 }) }), _jsx("button", { type: "button", className: "crisp-comments-tool", "aria-label": mentionToolLabel, onClick: () => {
261
+ const field = fieldRef.current;
262
+ if (!field)
263
+ return;
264
+ field.focus();
265
+ document.execCommand("insertText", false, mentionTrigger);
266
+ syncEmpty();
267
+ updateMention();
268
+ }, children: "@" })] }), _jsx("button", { type: "button", className: "crisp-comments-post", disabled: empty, onClick: submit, children: postLabel })] }), mention &&
269
+ renderMentionPicker?.({
270
+ query: mention.query,
271
+ anchor: mention.anchor,
272
+ select: insertMention,
273
+ close: closeMention,
274
+ })] }));
275
+ });
276
+ function CommentRow({ comment, replyLabel, reactLabel, resolveLabel, moreLabel, copyLinkLabel, deleteLabel, addReactionLabel, reactionSearchLabel, emojiOptions, resolvable, resolved, resolvedLabel, onReply, onReact, onResolve, onCopyLink, onDeleteRequest, }) {
277
+ return (_jsxs("div", { className: "crisp-comments-row", children: [_jsx(AuthorAvatar, { author: comment.author }), _jsxs("div", { className: "crisp-comments-row-main", children: [resolvedLabel != null && (_jsxs("div", { className: "crisp-comments-resolved-label", children: [_jsx(Check, { size: 12, strokeWidth: 2.5, "aria-hidden": "true" }), resolvedLabel] })), _jsxs("div", { className: "crisp-comments-row-head", children: [_jsx("span", { className: "crisp-comments-author", children: comment.author.name }), _jsx("span", { className: "crisp-comments-time", children: comment.time })] }), _jsx("div", { className: "crisp-comments-body-text", children: comment.body }), comment.reactions && comment.reactions.length > 0 && (_jsxs("div", { className: "crisp-comments-reactions", children: [comment.reactions.map((r) => (_jsxs("button", { type: "button", className: "crisp-comments-reaction", "data-reacted": r.reactedByMe ? "" : undefined, onClick: () => onReact(r.emoji), children: [_jsx("span", { "aria-hidden": "true", children: r.emoji }), r.count] }, r.emoji))), _jsx(Popover, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-comments-reaction-add", "aria-label": addReactionLabel, children: _jsx(SmilePlus, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: _jsx(ReactionPicker, { options: emojiOptions, searchLabel: reactionSearchLabel, onPick: onReact }) })] }))] }), _jsxs("div", { className: "crisp-comments-row-actions", children: [onReply && (_jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": replyLabel, onClick: onReply, children: _jsx(ReplyGlyph, { size: 16 }) })), _jsx(Popover, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": reactLabel, children: _jsx(SmilePlus, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: _jsx(ReactionPicker, { options: emojiOptions, searchLabel: reactionSearchLabel, onPick: onReact }) }), resolvable && (_jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": resolveLabel, "aria-pressed": !!resolved, "data-active": resolved ? "" : undefined, onClick: onResolve, children: _jsx(Check, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })), _jsxs(Menu, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": moreLabel, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: [_jsx(MenuItem, { onSelect: onCopyLink, children: copyLinkLabel }), _jsx(MenuItem, { danger: true, onSelect: onDeleteRequest, children: deleteLabel })] })] })] }));
278
+ }
279
+ /**
280
+ * Per-record comment threads (crisp#625) — measured live on app.attio.com's
281
+ * own right-hand comments panel: a header (title, filter menu), a scrolling
282
+ * thread list or a single narrowed thread, and a persistent bottom composer.
283
+ * Controlled: pass `threads` and wire the callbacks; the panel renders
284
+ * nothing on its own beyond that data. See `CommentCountBadge` for the
285
+ * separate row affordance (P-051) — deliberately NOT part of this
286
+ * component, since it lives in a table cell this component doesn't own.
287
+ */
288
+ export function CommentsPanel({ threads, currentUserId, subject, subjectIcon, showResolved = false, onShowResolvedChange, onlyMine = false, onOnlyMineChange, activeThreadId: activeThreadIdProp, onActiveThreadChange, onPost, onReact, onResolve, onDelete, onCopyLink, emojiOptions = DEFAULT_EMOJI, mentionTrigger = "@", renderMentionPicker, onClose, closeLabel: closeLabelProp, backLabel: backLabelProp, title: titleProp, threadTitle: threadTitleProp, breadcrumbPrefix: breadcrumbPrefixProp, composerPlaceholder: composerPlaceholderProp, replyPlaceholder: replyPlaceholderProp, postLabel: postLabelProp, replyPostLabel: replyPostLabelProp, emptyLabel, filterLabel: filterLabelProp, showResolvedLabel: showResolvedLabelProp, onlyMineLabel: onlyMineLabelProp, replyActionLabel: replyActionLabelProp, reactActionLabel: reactActionLabelProp, resolveActionLabel: resolveActionLabelProp, resolvedByYouLabel: resolvedByYouLabelProp, resolvedByLabel, moreActionsLabel: moreActionsLabelProp, copyLinkLabel: copyLinkLabelProp, deleteLabel: deleteLabelProp, addReactionLabel: addReactionLabelProp, reactionSearchLabel: reactionSearchLabelProp, deleteConfirmTitle: deleteConfirmTitleProp, deleteConfirmBody: deleteConfirmBodyProp, deleteConfirmActionLabel: deleteConfirmActionLabelProp, cancelLabel: cancelLabelProp, composerReplyToolLabel: composerReplyToolLabelProp, composerMentionToolLabel: composerMentionToolLabelProp, className, ...rest }) {
289
+ const closeLabel = useCrispMessage(closeLabelProp, "Close");
290
+ const backLabel = useCrispMessage(backLabelProp, "Back");
291
+ const title = useCrispMessage(titleProp, "All comments");
292
+ const threadTitle = useCrispMessage(threadTitleProp, "Comments");
293
+ const breadcrumbPrefix = useCrispMessage(breadcrumbPrefixProp, "Comment on");
294
+ const composerPlaceholder = useCrispMessage(composerPlaceholderProp, "Add comment...");
295
+ const replyPlaceholder = useCrispMessage(replyPlaceholderProp, "Reply...");
296
+ const postLabel = useCrispMessage(postLabelProp, "Comment");
297
+ const replyPostLabel = useCrispMessage(replyPostLabelProp, "Reply");
298
+ const filterLabel = useCrispMessage(filterLabelProp, "Filter comments");
299
+ const showResolvedLabel = useCrispMessage(showResolvedLabelProp, "Show resolved comments");
300
+ const onlyMineLabel = useCrispMessage(onlyMineLabelProp, "Only my threads");
301
+ const replyActionLabel = useCrispMessage(replyActionLabelProp, "Reply to comment");
302
+ const reactActionLabel = useCrispMessage(reactActionLabelProp, "Pick emoji");
303
+ const resolveActionLabel = useCrispMessage(resolveActionLabelProp, "Resolve comment");
304
+ const resolvedByYouLabel = useCrispMessage(resolvedByYouLabelProp, "Resolved by you");
305
+ const moreActionsLabel = useCrispMessage(moreActionsLabelProp, "More actions");
306
+ const copyLinkLabel = useCrispMessage(copyLinkLabelProp, "Copy link to comment");
307
+ const deleteLabel = useCrispMessage(deleteLabelProp, "Delete comment");
308
+ const addReactionLabel = useCrispMessage(addReactionLabelProp, "Add reaction");
309
+ const reactionSearchLabel = useCrispMessage(reactionSearchLabelProp, "Search Emojis");
310
+ const deleteConfirmTitle = useCrispMessage(deleteConfirmTitleProp, "Delete comment");
311
+ const deleteConfirmBody = useCrispMessage(deleteConfirmBodyProp, "You're about to delete this comment. Once deleted it cannot be restored.");
312
+ const deleteConfirmActionLabel = useCrispMessage(deleteConfirmActionLabelProp, "Delete");
313
+ const cancelLabel = useCrispMessage(cancelLabelProp, "Cancel");
314
+ const composerReplyToolLabel = useCrispMessage(composerReplyToolLabelProp, "Reply");
315
+ const composerMentionToolLabel = useCrispMessage(composerMentionToolLabelProp, "Mention someone");
316
+ const [internalActiveId, setInternalActiveId] = React.useState(null);
317
+ const activeThreadId = activeThreadIdProp ?? internalActiveId;
318
+ const setActiveThreadId = (id) => {
319
+ if (activeThreadIdProp === undefined)
320
+ setInternalActiveId(id);
321
+ onActiveThreadChange?.(id);
322
+ };
323
+ const [deleteTarget, setDeleteTarget] = React.useState(null);
324
+ const copyLink = (threadId, commentId) => {
325
+ if (onCopyLink) {
326
+ onCopyLink(threadId, commentId);
327
+ return;
328
+ }
329
+ const url = `${typeof window !== "undefined" ? window.location.href.split("#")[0] : ""}#comment-${commentId}`;
330
+ navigator.clipboard?.writeText(url).catch(() => { });
331
+ };
332
+ const visibleThreads = threads.filter((t) => {
333
+ if (!showResolved && t.resolved)
334
+ return false;
335
+ if (onlyMine && !t.mine)
336
+ return false;
337
+ return true;
338
+ });
339
+ const activeThread = activeThreadId
340
+ ? threads.find((t) => t.id === activeThreadId)
341
+ : null;
342
+ return (_jsxs("div", { className: commentsRecipe({ className }), role: "dialog", "aria-label": title, ...rest, children: [_jsxs("div", { className: "crisp-comments-head", children: [activeThread ? (_jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": backLabel, onClick: () => setActiveThreadId(null), children: _jsx(ChevronLeft, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })) : null, _jsxs("div", { className: "crisp-comments-title-wrap", children: [_jsx("span", { className: "crisp-comments-title", children: activeThread ? threadTitle : title }), activeThread && (_jsxs("span", { className: "crisp-comments-breadcrumb", children: [breadcrumbPrefix, " ", subjectIcon, " ", subject] }))] }), !activeThread && (_jsxs(Menu, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": filterLabel, children: _jsx(ListFilter, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: [_jsx(MenuCheckboxItem, { checked: showResolved, onCheckedChange: onShowResolvedChange, children: showResolvedLabel }), _jsx(MenuCheckboxItem, { checked: onlyMine, onCheckedChange: onOnlyMineChange, children: onlyMineLabel })] })), _jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": closeLabel, onClick: onClose, children: _jsx(X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), _jsx("div", { className: "crisp-comments-body crisp-comments-body--pad-top", children: activeThread ? (_jsxs("div", { children: [_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
343
+ ? activeThread.resolvedBy?.id === currentUserId ||
344
+ !activeThread.resolvedBy
345
+ ? resolvedByYouLabel
346
+ : (resolvedByLabel?.(activeThread.resolvedBy.name) ??
347
+ `Resolved by ${activeThread.resolvedBy.name}`)
348
+ : undefined, onReact: (emoji) => onReact(activeThread.id, activeThread.root.id, emoji), onResolve: onResolve ? () => onResolve(activeThread.id) : undefined, onCopyLink: () => copyLink(activeThread.id, activeThread.root.id), onDeleteRequest: () => setDeleteTarget({
349
+ threadId: activeThread.id,
350
+ commentId: activeThread.root.id,
351
+ }) }), activeThread.replies && activeThread.replies.length > 0 && (_jsx("div", { className: "crisp-comments-replies", children: activeThread.replies.map((reply) => (_jsx(CommentRow, { comment: reply, currentUserId: currentUserId, replyLabel: replyActionLabel, reactLabel: reactActionLabel, resolveLabel: resolveActionLabel, moreLabel: moreActionsLabel, copyLinkLabel: copyLinkLabel, deleteLabel: deleteLabel, addReactionLabel: addReactionLabel, reactionSearchLabel: reactionSearchLabel, emojiOptions: emojiOptions, onReact: (emoji) => onReact(activeThread.id, reply.id, emoji), onCopyLink: () => copyLink(activeThread.id, reply.id), onDeleteRequest: () => setDeleteTarget({
352
+ threadId: activeThread.id,
353
+ commentId: reply.id,
354
+ }) }, reply.id))) }))] })) : visibleThreads.length === 0 && emptyLabel != null ? (_jsx("div", { className: "crisp-comments-empty", children: emptyLabel })) : (visibleThreads.map((t) => (_jsx(CommentRow, { comment: t.root, currentUserId: currentUserId, replyLabel: replyActionLabel, reactLabel: reactActionLabel, resolveLabel: resolveActionLabel, moreLabel: moreActionsLabel, copyLinkLabel: copyLinkLabel, deleteLabel: deleteLabel, addReactionLabel: addReactionLabel, reactionSearchLabel: reactionSearchLabel, emojiOptions: emojiOptions, resolvable: !!onResolve, resolved: t.resolved, resolvedLabel: t.resolved
355
+ ? t.resolvedBy?.id === currentUserId || !t.resolvedBy
356
+ ? resolvedByYouLabel
357
+ : (resolvedByLabel?.(t.resolvedBy.name) ??
358
+ `Resolved by ${t.resolvedBy.name}`)
359
+ : undefined, onReply: () => setActiveThreadId(t.id), onReact: (emoji) => onReact(t.id, t.root.id, emoji), onResolve: onResolve ? () => onResolve(t.id) : undefined, onCopyLink: () => copyLink(t.id, t.root.id), onDeleteRequest: () => setDeleteTarget({ threadId: t.id, commentId: t.root.id }) }, t.id)))) }), _jsx(Composer, { placeholder: activeThread ? replyPlaceholder : composerPlaceholder, postLabel: activeThread ? replyPostLabel : postLabel, mentionTrigger: mentionTrigger, renderMentionPicker: renderMentionPicker, replyToolLabel: composerReplyToolLabel, mentionToolLabel: composerMentionToolLabel, onSubmit: (text) => onPost(text, activeThread ? activeThread.id : null) }, activeThread ? activeThread.id : "root"), _jsxs(Dialog, { open: deleteTarget != null, onOpenChange: (open) => {
360
+ if (!open)
361
+ setDeleteTarget(null);
362
+ }, title: deleteConfirmTitle, children: [_jsx(DialogBody, { children: deleteConfirmBody }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", intent: "neutral", size: "md", onClick: () => setDeleteTarget(null), children: cancelLabel }), _jsx(Button, { type: "button", intent: "danger", size: "md", onClick: () => {
363
+ if (deleteTarget)
364
+ onDelete(deleteTarget.threadId, deleteTarget.commentId);
365
+ setDeleteTarget(null);
366
+ }, children: deleteConfirmActionLabel })] })] })] }));
367
+ }
368
+ CommentsPanel.displayName = "CommentsPanel";
369
+ /**
370
+ * The row's own comment affordance (P-051) — a persistent 6px dot once a
371
+ * record has ≥1 comment, replaced on hover by a comment-count icon (P-035's
372
+ * "icon 1") plus, when `onOpenPreview` is given, the row's second hover icon
373
+ * ("Open preview"). A standalone piece by design (see the module doc
374
+ * comment) — the grid lane wires it into a cell.
375
+ */
376
+ export function CommentCountBadge({ count, hovered, onOpen, onOpenPreview, label: labelProp, previewLabel: previewLabelProp, className, }) {
377
+ const label = useCrispMessage(labelProp, count === 1 ? "1 comment" : `${count} comments`);
378
+ const previewLabel = useCrispMessage(previewLabelProp, "Open preview");
379
+ if (count === 0 && !hovered)
380
+ return null;
381
+ if (!hovered) {
382
+ return (_jsx("span", { className: className
383
+ ? `crisp-comments-badge ${className}`
384
+ : "crisp-comments-badge", "aria-hidden": "true", children: _jsx("span", { className: "crisp-comments-badge-dot" }) }));
385
+ }
386
+ return (_jsxs("span", { className: className ? `crisp-comments-badge ${className}` : "crisp-comments-badge", children: [_jsxs("button", { type: "button", className: "crisp-comments-badge", "aria-label": label, onClick: onOpen, children: [_jsx(MessageSquare, { size: 12, strokeWidth: 2, "aria-hidden": "true" }), count > 0 && (_jsx("span", { className: "crisp-comments-badge-count", children: count }))] }), onOpenPreview && (_jsx("button", { type: "button", className: "crisp-comments-badge", "aria-label": previewLabel, onClick: onOpenPreview, children: _jsx(ArrowUpRight, { "data-rtl-flip": "", size: 12, strokeWidth: 2, "aria-hidden": "true" }) }))] }));
387
+ }
388
+ CommentCountBadge.displayName = "CommentCountBadge";
@@ -0,0 +1,123 @@
1
+ import { defineRecipe, resolveRecipe, rtlFlipCss } from "../../core/index.js";
2
+ import { contract as c, elevation as e } from "../../tokens/index.js";
3
+ // Per-record comment threads (crisp#625) — measured live on app.attio.com's
4
+ // own per-record comments panel (open the "zz-parity-crisp-comments" test
5
+ // company's Overview tab, click the speech-bubble icon top-right of the
6
+ // record header). Unlike `RecordPanel`'s peek, this panel is laid out
7
+ // IN-FLOW beside the record view, not floated over it: `getComputedStyle`
8
+ // on the panel wrapper read a shadow of `none` and no left-edge border at all
9
+ // (elevation.floating was tried first and contradicted the live read), so
10
+ // this recipe carries no shadow of its own — only a hairline
11
+ // `border-inline-start` for the seam visible in every screenshot. Width read
12
+ // 319px in situ; used here as 320px (one hairline off, almost certainly a
13
+ // scrollbar/subpixel artifact of the sampled window, not a real 319px
14
+ // design value — flag this if a future pass measures a *different* window
15
+ // width and still gets an odd number).
16
+ const config = defineRecipe({
17
+ name: "comments",
18
+ base: `display: flex; flex-direction: column; box-sizing: border-box; width: 320px; height: 100%;
19
+ background: ${c.bgSurface}; border-inline-start: 1px solid ${c.borderSubtle};
20
+ font-family: ${c.font.sans};
21
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
22
+ & * { box-sizing: border-box; }
23
+
24
+ & .crisp-comments-head { display: flex; align-items: center; gap: ${c.space["1"]}; flex-shrink: 0; height: 49px; padding: 0 ${c.space["2"]} 0 ${c.space["4"]}; }
25
+ & .crisp-comments-headbtn { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: ${c.radius.md}; background: none; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); font-family: inherit; cursor: pointer; transition: background-color .12s, color .12s; }
26
+ & .crisp-comments-headbtn:hover { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
27
+ & .crisp-comments-title-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; }
28
+ & .crisp-comments-title { font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
29
+ & .crisp-comments-breadcrumb { display: flex; align-items: center; gap: ${c.space["1"]}; font-size: 12px; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
30
+
31
+ & .crisp-comments-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 ${c.space["4"]}; display: flex; flex-direction: column; gap: ${c.space["4"]}; }
32
+ & .crisp-comments-body--pad-top { padding-top: ${c.space["3"]}; }
33
+
34
+ /* One comment row -- avatar 16px (Avatar size="sm"), 6px gap to the
35
+ name/time line, 14px/600 name + 12px/500 63%-muted time, 14px/400 body
36
+ two lines below. Hover actions fade in flush with the name line. */
37
+ & .crisp-comments-row { position: relative; display: flex; gap: ${c.space["1.5"]}; }
38
+ & .crisp-comments-row-main { flex: 1; min-width: 0; }
39
+ & .crisp-comments-row-head { display: flex; align-items: baseline; gap: ${c.space["1.5"]}; height: 20px; }
40
+ & .crisp-comments-author { font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; color: ${c.fgPrimary}; }
41
+ & .crisp-comments-time { font-size: 12px; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); white-space: nowrap; }
42
+ & .crisp-comments-resolved-label { display: flex; align-items: center; gap: ${c.space["1"]}; margin-bottom: ${c.space["1"]}; font-size: 12px; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
43
+ & .crisp-comments-body-text { font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.regular}; color: ${c.fgPrimary}; white-space: pre-wrap; word-break: break-word; }
44
+
45
+ /* Hover actions -- measured: 4 buttons, 28x28, radius 8, flush
46
+ side-by-side (0 gap), transparent by default, 16px icon at 55% ink. */
47
+ & .crisp-comments-row-actions { position: absolute; top: 0; inset-inline-end: 0; display: flex; opacity: 0; pointer-events: none; background: ${c.bgSurface}; }
48
+ & .crisp-comments-row:hover .crisp-comments-row-actions,
49
+ & .crisp-comments-row-actions:focus-within { opacity: 1; pointer-events: auto; }
50
+ & .crisp-comments-action { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: ${c.radius.md}; background: transparent; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); font-family: inherit; cursor: pointer; transition: background-color .12s, color .12s; }
51
+ & .crisp-comments-action:hover { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
52
+ & .crisp-comments-action[data-active] { color: ${c.bgBrandSolid}; }
53
+
54
+ /* Reactions -- measured: 28px pill, radius 8, subtle 4% ink fill,
55
+ padding 4px 8px 4px 6px, gap 4px between the emoji glyph and count. */
56
+ & .crisp-comments-reactions { display: flex; flex-wrap: wrap; gap: ${c.space["1"]}; margin-top: ${c.space["1.5"]}; }
57
+ & .crisp-comments-reaction { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: 28px; padding: 4px 8px 4px 6px; border: none; border-radius: ${c.radius.md}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); font-family: inherit; font-size: 12px; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; cursor: pointer; }
58
+ & .crisp-comments-reaction[data-reacted] { background: color-mix(in srgb, ${c.bgBrandSolid} 12%, transparent); color: ${c.bgBrandSolid}; }
59
+ & .crisp-comments-reaction-add { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; border-radius: ${c.radius.md}; background: transparent; font-family: inherit; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); cursor: pointer; opacity: 0; transition: opacity .12s, background-color .12s; }
60
+ & .crisp-comments-row:hover .crisp-comments-reaction-add { opacity: 1; }
61
+ & .crisp-comments-reaction-add:hover { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
62
+
63
+ /* Reply-in-thread view -- an indented block of replies below the root. */
64
+ & .crisp-comments-replies { display: flex; flex-direction: column; gap: ${c.space["4"]}; margin-top: ${c.space["3"]}; padding-inline-start: ${c.space["4"]}; border-inline-start: 1px solid ${c.borderSubtle}; }
65
+
66
+ & .crisp-comments-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: ${c.space["1"]}; padding: ${c.space["6"]} ${c.space["4"]}; text-align: center; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); font-size: ${c.text.lg}; }
67
+
68
+ /* Composer -- measured: 14px/20px body text, 16px gap above the
69
+ reply/mention icon row, Comment button 84x32 padding 6px 10px radius
70
+ 9 (= radius.lg) brand-solid fill, 14px/500 white label, opacity .4
71
+ disabled / 1 enabled (no color/background change between the two). */
72
+ & .crisp-comments-composer { flex-shrink: 0; padding: ${c.space["3"]} ${c.space["4"]} ${c.space["4"]}; border-top: 1px solid ${c.borderSubtle}; }
73
+ & .crisp-comments-field { min-height: 20px; max-height: 120px; overflow-y: auto; font-family: inherit; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.regular}; color: ${c.fgPrimary}; outline: none; white-space: pre-wrap; word-break: break-word; }
74
+ & .crisp-comments-field:focus-visible { outline: 2px solid ${c.strokeFocusRing}; outline-offset: 2px; }
75
+ & .crisp-comments-field:empty::before { content: attr(data-placeholder); color: color-mix(in srgb, ${c.fgPrimary} 40%, transparent); }
76
+ & .crisp-comments-composer-foot { display: flex; align-items: center; justify-content: space-between; margin-top: ${c.space["4"]}; }
77
+ & .crisp-comments-composer-tools { display: flex; align-items: center; gap: ${c.space["1"]}; }
78
+ & .crisp-comments-tool { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin: 0; padding: 0; border: none; border-radius: ${c.radius.sm}; background: none; color: color-mix(in srgb, ${c.fgPrimary} 45%, transparent); font-family: inherit; cursor: pointer; }
79
+ & .crisp-comments-tool:hover { color: ${c.fgPrimary}; }
80
+ /* Same fill/shadow as Button's own intent="primary" (crisp/button.recipe.ts) --
81
+ confirmed byte-identical against the live "Comment" button's computed
82
+ box-shadow, so this reuses that literal rather than re-deriving it.
83
+ 32px/radius.lg/6px-10px padding has no matching Button \`size\` yet
84
+ (24/28/40 are the three that exist), so this stays hand-rolled instead
85
+ of stretching one of those. */
86
+ & .crisp-comments-post { display: inline-flex; align-items: center; justify-content: center; height: 32px; padding: 6px ${c.space["2.5"]}; border: none; border-radius: ${c.radius.lg}; background: ${c.bgBrandSolid}; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(38,109,240,0.12), 0 3px 6px -2px rgba(38,109,240,0.08); font-family: inherit; font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; color: ${c.fgOnBrand}; cursor: pointer; opacity: 1; transition: opacity .12s, background-color .12s; }
87
+ & .crisp-comments-post:hover:not(:disabled) { background: ${c.bgBrandSolidPressed}; }
88
+ & .crisp-comments-post:disabled { opacity: .4; cursor: default; }
89
+
90
+ /* Filter menu -- measured: 220px, radius 12 (radius.xl), 4px padding,
91
+ elevation.floating -- a plain reuse of crisp's own Menu, so no rules
92
+ live here; kept as a comment for the record. */
93
+
94
+ /* An inserted "@mention" chip in the composer -- same shape as
95
+ RichTextEditor's own \`.crisp-rte-mention\` (crisp#428), reused here
96
+ rather than re-derived since it's the same atomic-chip affordance. */
97
+ & .crisp-comments-mention { display: inline; padding: 0 2px; border-radius: 4px; background: color-mix(in srgb, ${c.bgBrandSolid} 12%, transparent); color: ${c.bgBrandSolid}; font-weight: ${c.weight.medium}; user-select: none; }
98
+
99
+ /* Reaction picker -- see \`ReactionPicker\`'s own doc comment: the exact
100
+ chrome wasn't independently pixel-measured this pass (screenshot
101
+ capture broke mid-session), so this reuses Menu's already-measured
102
+ floating-popover shell rather than a new value. */
103
+ & .crisp-comments-reactionpicker { width: 240px; padding: ${c.space["2"]}; border-radius: ${c.radius.xl}; background: ${c.bgSurface}; box-shadow: ${e.floating}; }
104
+ & .crisp-comments-reactionpicker-search { box-sizing: border-box; width: 100%; height: 28px; margin-bottom: ${c.space["2"]}; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: ${c.bgRaised}; font-family: inherit; font-size: ${c.text.md}; color: ${c.fgPrimary}; }
105
+ & .crisp-comments-reactionpicker-search::placeholder { color: color-mix(in srgb, ${c.fgPrimary} 40%, transparent); }
106
+ & .crisp-comments-reactionpicker-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: ${c.space["0.5"]}; max-height: 160px; overflow-y: auto; }
107
+ & .crisp-comments-reactionpicker-item { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: ${c.radius.sm}; background: none; font-family: inherit; font-size: 16px; line-height: 1; cursor: pointer; }
108
+ & .crisp-comments-reactionpicker-item:hover { background: ${c.bgRaised}; }
109
+
110
+ /* Row badge (CommentCountBadge) -- lives in its own class family so the
111
+ grid lanes can drop it into a cell without pulling in the panel's
112
+ CSS. Persistent state: a 6px grey dot. Hover state: a 12px comment
113
+ icon + 12px count text, both at 55% ink, replacing the dot. */
114
+ & .crisp-comments-badge { display: inline-flex; align-items: center; gap: ${c.space["1"]}; border: none; background: none; padding: 0; margin: 0; font-family: inherit; cursor: pointer; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); }
115
+ & .crisp-comments-badge-dot { width: 6px; height: 6px; border-radius: ${c.radius.full}; background: color-mix(in srgb, ${c.fgPrimary} 38%, transparent); }
116
+ & .crisp-comments-badge-count { font-size: 12px; line-height: 16px; }
117
+ ${rtlFlipCss}`,
118
+ variants: {},
119
+ defaultVariants: {},
120
+ compoundVariants: [],
121
+ });
122
+ export const commentsRecipe = resolveRecipe(config);
123
+ export default config;
@@ -0,0 +1,2 @@
1
+ export { CommentCountBadge, CommentsPanel, } from "./comments.js";
2
+ export { commentsRecipe } from "./comments.recipe.js";
@@ -17,10 +17,18 @@ import { contract as c } from "../../tokens/index.js";
17
17
  // `title`/`trailing` slot content a consumer supplies. `size.header` (48px)
18
18
  // is reused rather than a new token, since it's the same "workspace-switcher
19
19
  // header height" measured elsewhere in the sidebar.
20
+ //
21
+ // crisp#576: `border-box` plus a plain `height: ${size.header}` meant the
22
+ // bordered variant's own 1px border was eaten OUT OF the 48px box, leaving
23
+ // only 47px of content -- caught in review of internal/grc's own shell
24
+ // shifting up 1px end-to-end after adopting this component. Re-measured
25
+ // live: Attio's own `<header>` is 49px total (border-box) with the 1px
26
+ // border OUTSIDE the 48px content row, not inside it. `[data-bordered]` now
27
+ // grows the box by the border's own width so content stays 48px either way.
20
28
  const config = defineRecipe({
21
29
  name: "headerbar",
22
30
  base: `box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: ${c.space["2"]}; height: ${c.size.header}; flex-shrink: 0; padding: 0 ${c.space["3"]}; font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
23
- &[data-bordered] { border-bottom: 1px solid ${c.borderSubtle}; }
31
+ &[data-bordered] { height: calc(${c.size.header} + 1px); border-bottom: 1px solid ${c.borderSubtle}; }
24
32
  & .crisp-headerbar-leading { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; min-width: 0; }
25
33
  & .crisp-headerbar-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: ${c.space["2"]}; overflow: hidden; }
26
34
  & .crisp-headerbar-trailing { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; }`,
@@ -12,7 +12,16 @@ const config = defineRecipe({
12
12
  & .crisp-listpage-toolbar { display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.header}; flex-shrink: 0; padding: 0 ${c.space["3"]}; }
13
13
  & .crisp-listpage-toolbar-lead { display: flex; align-items: center; gap: ${c.space["2"]}; }
14
14
  & .crisp-listpage-count { margin-inline-start: auto; font-size: ${c.text.md}; color: ${c.fgMuted}; white-space: nowrap; }
15
- & .crisp-listpage-toolbar-trail { display: flex; align-items: center; gap: ${c.space["2"]}; }
15
+ /* #641: trailing (View settings / New) has to reach the toolbar's own
16
+ inline-end edge on its own -- the only thing pushing it there used
17
+ to be .crisp-listpage-count's margin-inline-start: auto, which a
18
+ consumer with no count badge (TasksTable, measured live: trailing
19
+ sat right after leading, nowhere near the row's own right edge)
20
+ never renders. A bare margin-inline-start: auto here is safely
21
+ overridden by the more specific rule right below whenever count IS
22
+ present, so the two cases don't fight -- count's own auto margin
23
+ still does the pushing there, this one only fires when it's absent. */
24
+ & .crisp-listpage-toolbar-trail { display: flex; align-items: center; gap: ${c.space["2"]}; margin-inline-start: auto; }
16
25
  & .crisp-listpage-count + .crisp-listpage-toolbar-trail { margin-inline-start: ${c.space["3"]}; }
17
26
 
18
27
  & .crisp-listpage-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 ${c.space["3"]} ${c.space["3"]}; }
@@ -0,0 +1,2 @@
1
+ export { ATTRIBUTE_AI_AUTOFILL_OPTIONS, ATTRIBUTE_TYPE_OPTIONS, AttributesTable, AttributeTypeSelect, CreateAttributeDialog, ObjectAppearanceSection, ObjectDetailPage, ObjectGeneralSection, ObjectSettings, ObjectsList, } from "./objectsettings.js";
2
+ export { objectsettingsRecipe } from "./objectsettings.recipe.js";