@tldraw/commenting 5.3.0-next.7654e7ac2a02 → 5.3.0-next.aefb8773a7eb

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 (44) hide show
  1. package/commenting.css +34 -28
  2. package/dist-cjs/canvas/comment-tool.js +12 -6
  3. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  4. package/dist-cjs/canvas/comments-filter-menu.js +1 -1
  5. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  6. package/dist-cjs/canvas/comments-overlay.js +4 -1
  7. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  8. package/dist-cjs/canvas/pending-composer.js +1 -0
  9. package/dist-cjs/canvas/pending-composer.js.map +2 -2
  10. package/dist-cjs/canvas/thread-pin.js +1 -1
  11. package/dist-cjs/canvas/thread-pin.js.map +2 -2
  12. package/dist-cjs/index.d.ts +4 -2
  13. package/dist-cjs/index.js +1 -1
  14. package/dist-cjs/ui/format-time.js +1 -1
  15. package/dist-cjs/ui/format-time.js.map +2 -2
  16. package/dist-cjs/ui/icons.js +52 -29
  17. package/dist-cjs/ui/icons.js.map +2 -2
  18. package/dist-esm/canvas/comment-tool.mjs +12 -6
  19. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  20. package/dist-esm/canvas/comments-filter-menu.mjs +2 -2
  21. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  22. package/dist-esm/canvas/comments-overlay.mjs +4 -1
  23. package/dist-esm/canvas/comments-overlay.mjs.map +2 -2
  24. package/dist-esm/canvas/pending-composer.mjs +1 -0
  25. package/dist-esm/canvas/pending-composer.mjs.map +2 -2
  26. package/dist-esm/canvas/thread-pin.mjs +1 -1
  27. package/dist-esm/canvas/thread-pin.mjs.map +2 -2
  28. package/dist-esm/index.d.mts +4 -2
  29. package/dist-esm/index.mjs +1 -1
  30. package/dist-esm/ui/format-time.mjs +1 -1
  31. package/dist-esm/ui/format-time.mjs.map +2 -2
  32. package/dist-esm/ui/icons.mjs +53 -30
  33. package/dist-esm/ui/icons.mjs.map +2 -2
  34. package/package.json +7 -6
  35. package/src/canvas/comment-tool.test.ts +109 -4
  36. package/src/canvas/comment-tool.tsx +24 -8
  37. package/src/canvas/comments-filter-menu.tsx +2 -2
  38. package/src/canvas/comments-overlay.tsx +7 -2
  39. package/src/canvas/pending-composer.tsx +4 -0
  40. package/src/canvas/thread-pin.tsx +3 -2
  41. package/src/ui/comments.css +34 -28
  42. package/src/ui/format-time.test.ts +3 -3
  43. package/src/ui/format-time.ts +2 -2
  44. package/src/ui/icons.tsx +43 -21
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/commenting",
3
3
  "description": "tldraw commenting UI components.",
4
- "version": "5.3.0-next.7654e7ac2a02",
4
+ "version": "5.3.0-next.aefb8773a7eb",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -38,11 +38,12 @@
38
38
  "lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
39
39
  },
40
40
  "devDependencies": {
41
- "@tldraw/driver": "5.3.0-next.7654e7ac2a02",
41
+ "@tldraw/driver": "5.3.0-next.aefb8773a7eb",
42
42
  "@types/react": "^19.2.7",
43
43
  "@types/react-dom": "^19.2.3",
44
44
  "react": "^19.2.1",
45
- "react-dom": "^19.2.1"
45
+ "react-dom": "^19.2.1",
46
+ "vitest": "^4.1.7"
46
47
  },
47
48
  "peerDependencies": {
48
49
  "react": "^18.2.0 || ^19.2.1",
@@ -55,9 +56,9 @@
55
56
  "@tiptap/core": "^3.12.1",
56
57
  "@tiptap/pm": "^3.12.1",
57
58
  "@tiptap/react": "^3.12.1",
58
- "@tldraw/mentions": "5.3.0-next.7654e7ac2a02",
59
- "@tldraw/utils": "5.3.0-next.7654e7ac2a02",
60
- "tldraw": "5.3.0-next.7654e7ac2a02"
59
+ "@tldraw/mentions": "5.3.0-next.aefb8773a7eb",
60
+ "@tldraw/utils": "5.3.0-next.aefb8773a7eb",
61
+ "tldraw": "5.3.0-next.aefb8773a7eb"
61
62
  },
62
63
  "module": "dist-esm/index.mjs",
63
64
  "source": "src/index.ts",
@@ -13,6 +13,7 @@ import {
13
13
  } from 'tldraw'
14
14
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
15
15
  import { CommentTool } from './comment-tool'
16
+ import { commentsSidebarOpen, pendingComment, regionDraft } from './state'
16
17
 
17
18
  /**
18
19
  * Pointer-driven tests for the comment tool's placement affordances. These need a real editor
@@ -90,14 +91,80 @@ describe('comment tool placement hints', () => {
90
91
  driver.pointerMove(150, 125)
91
92
  expect(editor.getHintingShapeIds()).toEqual([id])
92
93
 
93
- // Releasing places the comment and hands back to select, which owns its own hover state.
94
- driver.pointerDown(150, 125)
95
- driver.pointerUp(150, 125)
96
- expect(editor.isIn('select')).toBe(true)
94
+ // Select owns its own hover state, so the comment hint leaves with the tool.
95
+ editor.setCurrentTool('select')
97
96
  expect(editor.getHintingShapeIds()).toEqual([])
98
97
  })
99
98
  })
100
99
 
100
+ describe('comment tool placement lifecycle', () => {
101
+ let driver: Driver
102
+
103
+ beforeEach(() => {
104
+ driver = makeEditor()
105
+ })
106
+
107
+ it('stays in the tool with the composer open after placing', () => {
108
+ editor.setCurrentTool('comment')
109
+ driver.pointerDown(400, 300)
110
+ driver.pointerUp(400, 300)
111
+ // Placement opens the composer but the interaction isn't over — the tool holds on until
112
+ // the comment is posted or dismissed, so the surrounding UI stays stable.
113
+ expect(editor.isIn('comment.idle')).toBe(true)
114
+ expect(pendingComment.get(editor)).toEqual({
115
+ anchor: { type: 'point', x: 400, y: 300 },
116
+ point: { x: 400, y: 300 },
117
+ })
118
+ })
119
+
120
+ it('re-places the composer on a second click', () => {
121
+ editor.setCurrentTool('comment')
122
+ driver.pointerDown(400, 300)
123
+ driver.pointerUp(400, 300)
124
+ driver.pointerDown(200, 200)
125
+ driver.pointerUp(200, 200)
126
+ expect(editor.isIn('comment.idle')).toBe(true)
127
+ expect(pendingComment.get(editor)).toEqual({
128
+ anchor: { type: 'point', x: 200, y: 200 },
129
+ point: { x: 200, y: 200 },
130
+ })
131
+ })
132
+
133
+ it('drops the pending composer when the tool exits', () => {
134
+ editor.setCurrentTool('comment')
135
+ driver.pointerDown(400, 300)
136
+ driver.pointerUp(400, 300)
137
+ expect(pendingComment.get(editor)).not.toBeNull()
138
+
139
+ // A direct tool switch (toolbar, shortcut) abandons the draft composer — it belongs to
140
+ // the tool. The text itself survives in the comment draft store.
141
+ editor.setCurrentTool('select')
142
+ expect(pendingComment.get(editor)).toBeNull()
143
+ })
144
+
145
+ it('closes the comments sidebar for the whole interaction', () => {
146
+ commentsSidebarOpen.set(editor, true)
147
+ editor.setCurrentTool('comment')
148
+ expect(commentsSidebarOpen.get(editor)).toBe(false)
149
+
150
+ // ...and placing doesn't reopen it: the tool (and the closed sidebar) hold through the
151
+ // composer. Reopening is the host's button, never a side effect of leaving the tool.
152
+ driver.pointerDown(400, 300)
153
+ driver.pointerUp(400, 300)
154
+ expect(editor.isIn('comment.idle')).toBe(true)
155
+ expect(commentsSidebarOpen.get(editor)).toBe(false)
156
+ })
157
+
158
+ it('escape leaves the tool for select and drops the draft', () => {
159
+ editor.setCurrentTool('comment')
160
+ driver.pointerDown(400, 300)
161
+ driver.pointerUp(400, 300)
162
+ editor.cancel()
163
+ expect(editor.isIn('select')).toBe(true)
164
+ expect(pendingComment.get(editor)).toBeNull()
165
+ })
166
+ })
167
+
101
168
  describe('comment tool placement hints with regions enabled', () => {
102
169
  it('clears the hint when a drag becomes a region', () => {
103
170
  const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
@@ -113,6 +180,44 @@ describe('comment tool placement hints with regions enabled', () => {
113
180
  expect(editor.isIn('comment.dragging')).toBe(true)
114
181
  expect(editor.getHintingShapeIds()).toEqual([])
115
182
  })
183
+
184
+ it('stays in the tool with the composer open after a region drag', () => {
185
+ const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
186
+ editor.setCurrentTool('comment')
187
+ driver.pointerDown(100, 100)
188
+ driver.pointerMove(200, 180)
189
+ driver.pointerUp(200, 180)
190
+ expect(editor.isIn('comment.idle')).toBe(true)
191
+ expect(pendingComment.get(editor)).toEqual({
192
+ anchor: { type: 'region', x: 100, y: 100, w: 100, h: 80, pinX: 1, pinY: 1 },
193
+ point: { x: 200, y: 180 },
194
+ })
195
+ })
196
+
197
+ it('does not hint a shape under the pin corner of a placed region', () => {
198
+ const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
199
+ makeShape(100, 100)
200
+ editor.setCurrentTool('comment')
201
+ // Release on top of the shape: a region anchors to its rectangle, so the outline that
202
+ // normally previews a shape anchor would be a lie here.
203
+ driver.pointerDown(50, 50)
204
+ driver.pointerMove(150, 125)
205
+ driver.pointerUp(150, 125)
206
+ expect(editor.isIn('comment.idle')).toBe(true)
207
+ expect(editor.getHintingShapeIds()).toEqual([])
208
+ })
209
+
210
+ it('drops the region draft when the tool exits mid-drag', () => {
211
+ const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
212
+ editor.setCurrentTool('comment')
213
+ driver.pointerDown(100, 100)
214
+ driver.pointerMove(200, 180)
215
+ expect(regionDraft.get(editor)).not.toBeNull()
216
+
217
+ // A direct tool switch (shortcut, toolbar) mid-drag must not strand the drawn rectangle.
218
+ editor.setCurrentTool('select')
219
+ expect(regionDraft.get(editor)).toBeNull()
220
+ })
116
221
  })
117
222
 
118
223
  describe('comment tool anchoring to frames', () => {
@@ -52,7 +52,9 @@ export function regionBetween(a: VecLike, b: VecLike): BoxModel {
52
52
  * The comment tool. Pressing down opens the comment composer at the pointer and it follows until
53
53
  * release — like placing a sticky note — settling on a point, or on a shape when released over one.
54
54
  * With region comments enabled, dragging past the threshold draws a region rectangle instead.
55
- * Placement only opens a composer; the records are created when the comment is posted.
55
+ * Placement only opens a composer; the records are created when the comment is posted. The tool
56
+ * stays active while the composer is open — posting returns to select, and clicking elsewhere
57
+ * re-places the composer.
56
58
  * @public
57
59
  */
58
60
  export class CommentTool extends StateNode {
@@ -98,8 +100,11 @@ export class CommentTool extends StateNode {
98
100
 
99
101
  override onExit() {
100
102
  // Drop the hover hint painted while pointing at shapes (see CommentIdle). The cursor resets
101
- // when the next tool takes over.
103
+ // when the next tool takes over. The draft composer and region draft belong to the tool, so
104
+ // they leave with it; the draft's text survives in the comment draft store.
102
105
  this.editor.setHintingShapes([])
106
+ pendingComment.set(this.editor, null)
107
+ regionDraft.set(this.editor, null)
103
108
  }
104
109
 
105
110
  // Escape leaves the tool, like the built-in tools (the editor dispatches `cancel` on Escape).
@@ -122,7 +127,14 @@ class CommentIdle extends StateNode {
122
127
  override onEnter() {
123
128
  // Back to hovering: restore the pin cursor (a prior placing state may have hidden it).
124
129
  this.editor.setCursor({ type: 'comment', rotation: 0 })
125
- updateAnchorHint(this.editor)
130
+ // With a region composer open, a shape outline under its pin corner would imply a shape
131
+ // anchor the region will never use — and it would stick, since moves over the composer
132
+ // never reach the canvas. Point placements keep the hint: it previews the next click.
133
+ if (pendingComment.get(this.editor)?.anchor.type === 'region') {
134
+ this.editor.setHintingShapes([])
135
+ } else {
136
+ updateAnchorHint(this.editor)
137
+ }
126
138
  }
127
139
 
128
140
  override onPointerMove() {
@@ -185,7 +197,10 @@ class CommentPointing extends StateNode {
185
197
  )
186
198
  : { type: 'point', x: point.x, y: point.y }
187
199
  pendingComment.set(editor, { anchor, point: { x: point.x, y: point.y } })
188
- editor.setCurrentTool('select')
200
+ // Stay in the tool while the composer is open — the interaction isn't over until the
201
+ // comment is posted or dismissed, and staying keeps the surrounding UI (style panel,
202
+ // sidebar) from churning mid-placement.
203
+ this.parent.transition('idle')
189
204
  }
190
205
 
191
206
  override onCancel() {
@@ -196,9 +211,9 @@ class CommentPointing extends StateNode {
196
211
  this.cancel()
197
212
  }
198
213
 
199
- // Abandon the follow composer if placement is interrupted (Escape, focus loss, etc.).
214
+ // Abandon the follow composer if placement is interrupted (Escape, focus loss, etc.). The
215
+ // tool's onExit drops the draft.
200
216
  private cancel() {
201
- pendingComment.set(this.editor, null)
202
217
  this.editor.setCurrentTool('select')
203
218
  }
204
219
  }
@@ -233,7 +248,8 @@ class CommentDragging extends StateNode {
233
248
  anchor: { type: 'region', ...region, pinX: pin.x, pinY: pin.y },
234
249
  point: regionPinPoint(region, pin),
235
250
  })
236
- editor.setCurrentTool('select')
251
+ // Same as the point placement: the tool stays active while the composer is open.
252
+ this.parent.transition('idle')
237
253
  }
238
254
 
239
255
  override onCancel() {
@@ -252,8 +268,8 @@ class CommentDragging extends StateNode {
252
268
  )
253
269
  }
254
270
 
271
+ // The tool's onExit drops the region draft.
255
272
  private cancel() {
256
- regionDraft.set(this.editor, null)
257
273
  this.editor.setCurrentTool('select')
258
274
  }
259
275
  }
@@ -10,7 +10,7 @@ import {
10
10
  useTranslation,
11
11
  useValue,
12
12
  } from 'tldraw'
13
- import { FilterIcon } from '../ui/icons'
13
+ import { MoreMenuIcon } from '../ui/icons'
14
14
  import { SidebarFilters } from './sidebar-filters'
15
15
  import { sidebarFilters } from './state'
16
16
 
@@ -44,7 +44,7 @@ export function CommentsFilterMenu({
44
44
  title={msg('comments.filter')}
45
45
  className="tlui-cmt-header-btn"
46
46
  >
47
- <FilterIcon />
47
+ <MoreMenuIcon />
48
48
  </TldrawUiButton>
49
49
  </TldrawUiDropdownMenuTrigger>
50
50
  {/* No tlui-cmt-menu here: the rows are tldraw menu items, which carry their own insets —
@@ -68,11 +68,16 @@ function CanvasCommentsLayer(props: CommentingContext) {
68
68
  const pending = usePendingComment()
69
69
  const canComment = useCanComment(props.currentUserId)
70
70
  // Nothing renders a pending comment when composing is blocked and there's no fallback slot, and
71
- // the dismiss handlers live inside PendingComposer — so clear the atom rather than strand it.
71
+ // the dismiss handlers live inside PendingComposer — so clear the atom rather than strand it,
72
+ // and leave the tool too: it holds while a composer is open, so staying would turn every click
73
+ // into a silently swallowed placement.
72
74
  const canRenderComposer = canComment || options.components.ComposerFallback != null
73
75
  const showPendingComposer = pending != null && canRenderComposer
74
76
  useEffect(() => {
75
- if (pending && !showPendingComposer) pendingComment.set(editor, null)
77
+ if (pending && !showPendingComposer) {
78
+ pendingComment.set(editor, null)
79
+ if (editor.isIn('comment')) editor.setCurrentTool('select')
80
+ }
76
81
  }, [editor, pending, showPendingComposer])
77
82
  const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
78
83
  // Matches the sidebar's `showResolved` filter. The open thread stays in — resolving from its own
@@ -107,6 +107,10 @@ export function PendingComposer({
107
107
  setText(EMPTY_COMMENT)
108
108
  clearCommentDraft(NEW_COMMENT_DRAFT)
109
109
  pendingComment.set(editor, null)
110
+ // Posting ends the placement interaction — hand the comment tool back to select. Only from
111
+ // the settled idle state: Enter can land while a fresh press is mid-gesture (the composer
112
+ // trails the pointer), and switching tools under a held pointer would strand the gesture.
113
+ if (editor.isIn('comment.idle')) editor.setCurrentTool('select')
110
114
  // The host's callback is its own operation, not part of the post's history scope. It runs
111
115
  // last so a throwing host can't strand the composer holding a draft of a posted comment.
112
116
  onPostComment?.(comment)
@@ -305,9 +305,10 @@ export const ThreadPin = memo(function ThreadPin({
305
305
  const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
306
306
  const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
307
307
  // A region's pin centres on its corner — overlapping the box — rather than hanging off it.
308
- // The marker anchors bottom-left, so step half its 34px size left and down (screen px).
308
+ // The marker anchors bottom-left, so step half its 28px size (--tlui-cmt-pin-size) left and
309
+ // down (screen px), matching the draft composer's --region centring on the same corner.
309
310
  const renderPoint = isRegion
310
- ? { x: renderPointBase.x - 17, y: renderPointBase.y + 17 }
311
+ ? { x: renderPointBase.x - 14, y: renderPointBase.y + 14 }
311
312
  : renderPointBase
312
313
 
313
314
  // A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
@@ -84,6 +84,15 @@
84
84
  overflow-wrap: anywhere;
85
85
  }
86
86
 
87
+ /* A comment body is read-only but its text must stay selectable so it can be copied. tldraw's
88
+ container sets `user-select: none` on every descendant (`.tl-container *`) to keep canvas gestures
89
+ from selecting UI text, so the body and its rendered children have to opt back in. */
90
+ .tlui-cmt-text,
91
+ .tlui-cmt-text * {
92
+ -webkit-user-select: text;
93
+ user-select: text;
94
+ }
95
+
87
96
  .tlui-cmt-edited {
88
97
  font-style: italic;
89
98
  }
@@ -777,7 +786,7 @@ button.tlui-cmt-reaction--active:hover {
777
786
  align-items: center;
778
787
  justify-content: space-between;
779
788
  gap: 8px;
780
- padding: 6px 6px 6px 14px;
789
+ padding: 2px 2px 2px 14px;
781
790
  border-bottom: 1px solid var(--tl-color-divider);
782
791
  }
783
792
  .tlui-cmt-list__header-title {
@@ -786,41 +795,28 @@ button.tlui-cmt-reaction--active:hover {
786
795
  color: var(--tl-color-text-1);
787
796
  }
788
797
 
789
- /* A small square icon button in the sidebar header (filter funnel, overflow …). Also a
790
- TldrawUiButton, but a bespoke 26px size so it has to override .tlui-button's 40px box, and
791
- `min-width` in particular would otherwise beat `width`. Selectors carry .tlui-button so
792
- stylesheet order can't decide the winner. */
798
+ /* An icon button in the sidebar header (filter funnel, overflow …). Sized like the thread actions:
799
+ a TldrawUiButton (type="icon") left at its native 40px box, with .tlui-button's 32px ::after
800
+ square carrying the hover wash. Only the bits that differ are set here, at .tlui-button's
801
+ specificity + 1 so stylesheet order can't decide the winner. */
793
802
  .tlui-button.tlui-cmt-header-btn {
794
- width: 26px;
795
- min-width: 26px;
796
- height: 26px;
803
+ width: 40px;
797
804
  padding: 0;
798
805
  border-radius: 6px;
799
806
  color: var(--tl-color-text-2);
800
807
  }
801
- /* Same hit-target expansion as the thread actions, tiled the same way this row's gap is 2px too
802
- (see `.tlui-cmt-list__header-actions`). Unlike the thread actions, the hover fill sits on the
803
- button itself, so .tlui-button's ::after wash is cleared and the pseudo-element is left doing
804
- nothing but widening the hit area. */
805
- .tlui-button.tlui-cmt-header-btn::after {
806
- inset: -6px -1px;
807
- background: none;
808
- }
809
- .tlui-button.tlui-cmt-header-btn:first-child::after {
810
- left: -6px;
811
- }
812
- .tlui-button.tlui-cmt-header-btn:last-child::after {
813
- right: -6px;
808
+ /* Overlap each button 4px into the previous so the 32px visual squares sit 4px apart, not 8px
809
+ the same tiling the thread actions use. */
810
+ .tlui-cmt-header-btn + .tlui-cmt-header-btn {
811
+ margin-left: -4px;
814
812
  }
815
813
  .tlui-button.tlui-cmt-header-btn:hover,
816
814
  .tlui-button.tlui-cmt-header-btn[data-state='open'] {
817
- background: var(--tl-color-muted-2);
818
815
  color: var(--tl-color-text-1);
819
816
  }
820
817
  .tlui-cmt-list__header-actions {
821
818
  display: flex;
822
819
  align-items: center;
823
- gap: 2px;
824
820
  }
825
821
 
826
822
  /* The commenting dropdowns' surface — a little air between the items and the menu edge. */
@@ -876,6 +872,7 @@ button.tlui-cmt-reaction--active:hover {
876
872
  font-size: 12px;
877
873
  }
878
874
  .tlui-cmt-list__item {
875
+ position: relative;
879
876
  display: flex;
880
877
  gap: 10px;
881
878
  width: 100%;
@@ -890,18 +887,27 @@ button.tlui-cmt-reaction--active:hover {
890
887
  color: inherit;
891
888
  text-decoration: none;
892
889
  }
893
- .tlui-cmt-list__item:hover {
890
+ /* The highlight is an inset, rounded wash like tldraw's buttons and menu items — not an
891
+ edge-to-edge band — so a comment row lights up the same way every other interactive row does. */
892
+ .tlui-cmt-list__item::after {
893
+ content: '';
894
+ position: absolute;
895
+ inset: 4px;
896
+ border-radius: var(--tl-radius-2);
897
+ pointer-events: none;
898
+ }
899
+ .tlui-cmt-list__item:hover::after {
894
900
  background: var(--tl-color-muted-2);
895
901
  }
896
902
  /* Selected sits a step above hover so the open thread's row stays distinguishable while
897
903
  pointing at other rows — but only a step: the muted-1 token (10%) reads too heavy here.
898
904
  These rows sit on the opaque sidebar panel, so a translucent tint composes safely. */
899
- .tlui-cmt-list__item--selected,
900
- .tlui-cmt-list__item--selected:hover {
905
+ .tlui-cmt-list__item--selected::after,
906
+ .tlui-cmt-list__item--selected:hover::after {
901
907
  background: hsl(0, 0%, 0%, 5%);
902
908
  }
903
- .tl-theme__dark .tlui-cmt-list__item--selected,
904
- .tl-theme__dark .tlui-cmt-list__item--selected:hover {
909
+ .tl-theme__dark .tlui-cmt-list__item--selected::after,
910
+ .tl-theme__dark .tlui-cmt-list__item--selected:hover::after {
905
911
  background: hsl(0, 0%, 100%, 5%);
906
912
  }
907
913
  /* resolved rows mute their content but keep the "Resolved" marker legible */
@@ -11,7 +11,7 @@ const TWO_HOURS = 2 * 60 * 60
11
11
  describe('formatRelativeTime', () => {
12
12
  it('formats in English by default', () => {
13
13
  expect(formatRelativeTime(agoIso(TWO_HOURS))).toBe(
14
- new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' }).format(-2, 'hour')
14
+ new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'narrow' }).format(-2, 'hour')
15
15
  )
16
16
  })
17
17
 
@@ -20,7 +20,7 @@ describe('formatRelativeTime', () => {
20
20
  it('formats in the given locale', () => {
21
21
  const french = formatRelativeTime(agoIso(TWO_HOURS), 'fr')
22
22
  expect(french).toBe(
23
- new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'short' }).format(-2, 'hour')
23
+ new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'narrow' }).format(-2, 'hour')
24
24
  )
25
25
  expect(french).not.toBe(formatRelativeTime(agoIso(TWO_HOURS), 'en'))
26
26
  })
@@ -34,7 +34,7 @@ describe('formatRelativeTime', () => {
34
34
 
35
35
  it('reads under a minute as "now" in the given locale', () => {
36
36
  expect(formatRelativeTime(agoIso(5), 'fr')).toBe(
37
- new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'short' }).format(0, 'second')
37
+ new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'narrow' }).format(0, 'second')
38
38
  )
39
39
  })
40
40
 
@@ -9,7 +9,7 @@ const DIVISIONS = [
9
9
  ] as const
10
10
 
11
11
  /**
12
- * Format an ISO datetime as short relative time ("2 hr. ago", "yesterday", "last wk.").
12
+ * Format an ISO datetime as compact relative time ("2h ago", "yesterday", "last wk.").
13
13
  * Locale-aware via Intl.RelativeTimeFormat.
14
14
  * @public
15
15
  */
@@ -17,7 +17,7 @@ export function formatRelativeTime(iso: string, locale = 'en'): string {
17
17
  const then = new Date(iso).getTime()
18
18
  if (Number.isNaN(then)) return ''
19
19
 
20
- const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'short' })
20
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' })
21
21
  let duration = (then - Date.now()) / 1000
22
22
  // Under a minute is just "now" — with second granularity the label visibly ticks while
23
23
  // typing a reply (every keystroke re-renders the card).
package/src/ui/icons.tsx CHANGED
@@ -2,9 +2,13 @@
2
2
  *
3
3
  * Anything with an equivalent in tldraw's icon assets goes through `TldrawUiIcon` instead (the ⋯
4
4
  * menu, the dismiss cross, the resolve button's check). What's left lives here because the asset
5
- * set has no counterpart — a smiley, a send arrow, the eye pair, a filter — or because the asset
6
- * reads wrong at the size it's needed, which is the case for both resolved checks below. Inline
7
- * also means they render without an `AssetUrlsProvider` and without the consumer serving files.
5
+ * set has no counterpart — a smiley, a send arrow, a filter — or because the asset reads wrong at
6
+ * the size it's needed, which is the case for both resolved checks below. The eye pair mirrors
7
+ * tldraw's presence glyphs: the open state is the `follow` icon the almond eye whose pupil
8
+ * tracks presence — and the closed state the `closed` icon, its shut-lid companion. Both are in
9
+ * the asset set, but they're inlined because the package renders without an `AssetUrlsProvider`,
10
+ * so `TldrawUiIcon` can't reach them. Inline also means they render without the consumer serving
11
+ * files.
8
12
  *
9
13
  * All internal — none are exported from the package. */
10
14
 
@@ -73,43 +77,61 @@ export function SendIcon() {
73
77
  )
74
78
  }
75
79
 
76
- /** Comment pins are showing. Pairs with `EyeClosedIcon` the two must stay the same family. */
80
+ /** Comment pins are showing. tldraw's `follow` glyph (icons/icon/follow.svg) inlined at its native
81
+ * weight — the almond eye with a centred pupil. Pairs with `EyeClosedIcon`; the two must stay the
82
+ * same family. */
77
83
  export function EyeOpenIcon() {
78
84
  return (
79
- <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
85
+ <svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
86
+ <path fill="currentColor" d="M19 15a4 4 0 1 1-8 0 4 4 0 0 1 8 0" />
80
87
  <path
81
- d="M7.5 11C4.80285 11 2.52952 9.62184 1.09622 7.50001C2.52952 5.37816 4.80285 4 7.5 4C10.1971 4 12.4705 5.37816 13.9038 7.5C12.4705 9.62183 10.1971 11 7.5 11ZM7.5 3C4.30786 3 1.65639 4.70638 0.0760002 7.23501C-0.0253338 7.39715 -0.0253334 7.60288 0.0760014 7.76501C1.65639 10.2936 4.30786 12 7.5 12C10.6921 12 13.3436 10.2936 14.924 7.76501C15.0253 7.60288 15.0253 7.39715 14.924 7.23501C13.3436 4.70638 10.6921 3 7.5 3ZM7.5 9.5C8.60457 9.5 9.5 8.60457 9.5 7.5C9.5 6.39543 8.60457 5.5 7.5 5.5C6.39543 5.5 5.5 6.39543 5.5 7.5C5.5 8.60457 6.39543 9.5 7.5 9.5Z"
82
- fill="currentColor"
83
- fillRule="evenodd"
84
- clipRule="evenodd"
88
+ stroke="currentColor"
89
+ strokeWidth="2"
90
+ d="M26 15c0 1.77-1.077 3.496-3.07 4.825C20.946 21.149 18.145 22 15 22s-5.945-.851-7.93-2.175C5.076 18.496 4 16.77 4 15c0-1.77 1.077-3.496 3.07-4.825C9.054 8.851 11.855 8 15 8s5.945.851 7.93 2.175C24.924 11.504 26 13.23 26 15Z"
85
91
  />
86
92
  </svg>
87
93
  )
88
94
  }
89
95
 
90
- /** Comment pins are hidden. */
96
+ /** Comment pins are hidden. tldraw's `closed` eye glyph (icons/icon/closed.svg) — a shut lower lid
97
+ * with lashes, the design pair to `follow`. Inlined like the open eye: the package renders without
98
+ * an `AssetUrlsProvider`. Lash dots are `r=1.5` (vs the asset's `r=1`) because at the 15px header
99
+ * size the asset's finer lashes fall below a pixel; the lid keeps `follow`'s native stroke. */
91
100
  export function EyeClosedIcon() {
92
101
  return (
93
- <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
102
+ <svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
94
103
  <path
95
- d="M14.7649 6.07596C14.9991 6.22231 15.0703 6.53079 14.9239 6.76495C14.4849 7.46743 13.9632 8.10645 13.3702 8.66305L14.5712 9.86406C14.7664 10.0593 14.7664 10.3759 14.5712 10.5712C14.3759 10.7664 14.0593 10.7664 13.8641 10.5712L12.6011 9.30817C11.805 9.90283 10.9089 10.3621 9.93375 10.651L10.383 12.3277C10.4544 12.5944 10.2961 12.8685 10.0294 12.94C9.76267 13.0115 9.4885 12.8532 9.41704 12.5865L8.95917 10.8775C8.48743 10.958 8.00036 11 7.50001 11C6.99965 11 6.51257 10.958 6.04082 10.8775L5.58299 12.5864C5.51153 12.8532 5.23737 13.0115 4.97064 12.94C4.7039 12.8686 4.5456 12.5944 4.61706 12.3277L5.06625 10.651C4.09111 10.3621 3.19503 9.90282 2.39889 9.30815L1.1359 10.5712C0.940638 10.7664 0.624058 10.7664 0.428798 10.5712C0.233537 10.3759 0.233537 10.0593 0.428798 9.86405L1.62982 8.66303C1.03682 8.10643 0.515113 7.46742 0.0760677 6.76495C-0.0702867 6.53079 0.000898544 6.22231 0.235065 6.07596C0.469231 5.9296 0.777703 6.00079 0.924058 6.23496C1.40354 7.00213 1.989 7.68057 2.66233 8.2427C2.67315 8.25096 2.6837 8.25972 2.69397 8.26898C4.00897 9.35527 5.65537 10 7.50001 10C10.3078 10 12.6564 8.5063 14.076 6.23495C14.2223 6.00079 14.5308 5.9296 14.7649 6.07596Z"
96
- fill="currentColor"
97
- fillRule="evenodd"
98
- clipRule="evenodd"
104
+ stroke="currentColor"
105
+ strokeWidth="2"
106
+ strokeLinecap="round"
107
+ d="M27 15C27 19.4183 21.6274 23 15 23C8.37258 23 3 19.4183 3 15"
99
108
  />
109
+ <circle cx="4" cy="20" r="1.5" fill="currentColor" />
110
+ <circle cx="9" cy="23" r="1.5" fill="currentColor" />
111
+ <circle cx="15" cy="24" r="1.5" fill="currentColor" />
112
+ <circle cx="21" cy="23" r="1.5" fill="currentColor" />
113
+ <circle cx="26" cy="20" r="1.5" fill="currentColor" />
100
114
  </svg>
101
115
  )
102
116
  }
103
117
 
104
- /** The sidebar's filter menu trigger. */
105
- export function FilterIcon() {
118
+ /** The sidebar's comment-menu trigger — tldraw's `dots-vertical` glyph (icons/icon/dots-vertical.svg)
119
+ * inlined, a vertical kebab. Inlined like the eye pair because the package renders without an
120
+ * `AssetUrlsProvider`. */
121
+ export function MoreMenuIcon() {
106
122
  return (
107
- <svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
123
+ <svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
108
124
  <path
109
- d="M5.5 3C4.67157 3 4 3.67157 4 4.5C4 5.32843 4.67157 6 5.5 6C6.32843 6 7 5.32843 7 4.5C7 3.67157 6.32843 3 5.5 3ZM3 5C3.01671 5 3.03323 4.99918 3.04952 4.99758C3.28022 6.1399 4.28967 7 5.5 7C6.71033 7 7.71978 6.1399 7.95048 4.99758C7.96677 4.99918 7.98329 5 8 5H13.5C13.7761 5 14 4.77614 14 4.5C14 4.22386 13.7761 4 13.5 4H8C7.98329 4 7.96677 4.00082 7.95048 4.00242C7.71978 2.86009 6.71033 2 5.5 2C4.28967 2 3.28022 2.86009 3.04952 4.00242C3.03323 4.00082 3.01671 4 3 4H1.5C1.22386 4 1 4.22386 1 4.5C1 4.77614 1.22386 5 1.5 5H3ZM11.9505 10.9976C11.7198 12.1399 10.7103 13 9.5 13C8.28967 13 7.28022 12.1399 7.04952 10.9976C7.03323 10.9992 7.01671 11 7 11H1.5C1.22386 11 1 10.7761 1 10.5C1 10.2239 1.22386 10 1.5 10H7C7.01671 10 7.03323 10.0008 7.04952 10.0024C7.28022 8.8601 8.28967 8 9.5 8C10.7103 8 11.7198 8.8601 11.9505 10.0024C11.9668 10.0008 11.9833 10 12 10H13.5C13.7761 10 14 10.2239 14 10.5C14 10.7761 13.7761 11 13.5 11H12C11.9833 11 11.9668 10.9992 11.9505 10.9976ZM8 10.5C8 9.67157 8.67157 9 9.5 9C10.3284 9 11 9.67157 11 10.5C11 11.3284 10.3284 12 9.5 12C8.67157 12 8 11.3284 8 10.5Z"
110
125
  fill="currentColor"
111
- fillRule="evenodd"
112
- clipRule="evenodd"
126
+ d="M15 9.48975C14.3167 9.48975 13.7292 9.24747 13.2375 8.76293C12.7458 8.27003 12.5 7.67688 12.5 6.98348C12.5 6.29843 12.7458 5.71364 13.2375 5.22909C13.7292 4.73619 14.3167 4.48975 15 4.48975C15.6833 4.48975 16.2708 4.73619 16.7625 5.22909C17.2542 5.71364 17.5 6.29843 17.5 6.98348C17.5 7.44296 17.3833 7.86485 17.15 8.24914C16.925 8.62508 16.625 8.92583 16.25 9.1514C15.875 9.37696 15.4583 9.48975 15 9.48975Z"
127
+ />
128
+ <path
129
+ fill="currentColor"
130
+ d="M15 17.5C14.3167 17.5 13.7292 17.2577 13.2375 16.7732C12.7458 16.2803 12.5 15.6871 12.5 14.9937C12.5 14.3087 12.7458 13.7239 13.2375 13.2393C13.7292 12.7464 14.3167 12.5 15 12.5C15.6833 12.5 16.2708 12.7464 16.7625 13.2393C17.2542 13.7239 17.5 14.3087 17.5 14.9937C17.5 15.4532 17.3833 15.8751 17.15 16.2594C16.925 16.6353 16.625 16.9361 16.25 17.1617C15.875 17.3872 15.4583 17.5 15 17.5Z"
131
+ />
132
+ <path
133
+ fill="currentColor"
134
+ d="M15 25.5103C14.3167 25.5103 13.7292 25.268 13.2375 24.7834C12.7458 24.2905 12.5 23.6974 12.5 23.004C12.5 22.319 12.7458 21.7342 13.2375 21.2496C13.7292 20.7567 14.3167 20.5103 15 20.5103C15.6833 20.5103 16.2708 20.7567 16.7625 21.2496C17.2542 21.7342 17.5 22.319 17.5 23.004C17.5 23.4635 17.3833 23.8854 17.15 24.2697C16.925 24.6456 16.625 24.9463 16.25 25.1719C15.875 25.3975 15.4583 25.5103 15 25.5103Z"
113
135
  />
114
136
  </svg>
115
137
  )