Skip to content

Commit d8e7e92

Browse files
improvement(ui): source collaborator colours from CSS palette (#8210)
* improvement(ui): source collaborator colours from CSS palette * fix(ui): map resolved collaborator colours to palette slots * docs(ui): document collaborator palette lookup
1 parent ab16777 commit d8e7e92

4 files changed

Lines changed: 144 additions & 18 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence.test.ts‎

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
/**
22
* @vitest-environment jsdom
33
*/
4+
import { readFileSync } from 'node:fs'
5+
import path from 'node:path'
46
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5-
import { activateCaretLabel, CARET_LABEL_HOLD_MS, renderCaret } from './caret-presence'
7+
import { USER_COLORS } from '@/lib/workspaces/colors'
8+
import {
9+
activateCaretLabel,
10+
CARET_LABEL_HOLD_MS,
11+
caretColorSlot,
12+
renderCaret,
13+
} from './caret-presence'
614

715
const ACTIVE = 'collaboration-carets__caret--active'
816
const FLIP = 'collaboration-carets__caret--flip'
@@ -12,10 +20,11 @@ describe('caret-presence', () => {
1220
afterEach(() => vi.useRealTimers())
1321

1422
it('builds a tagged caret with a name label, shown on appearance', () => {
15-
const caret = renderCaret({ name: 'Ada', color: '#f783ac', clientId: 4242 })
23+
const caret = renderCaret({ name: 'Ada', color: USER_COLORS[1], clientId: 4242 })
1624
expect(caret.classList.contains('collaboration-carets__caret')).toBe(true)
1725
expect(caret.dataset.caretClientId).toBe('4242')
18-
expect(caret.style.getPropertyValue('--caret-color')).toBeTruthy()
26+
expect(caret.dataset.colorSlot).toBe('1')
27+
expect(caret.style.getPropertyValue('--caret-color')).toBe('')
1928
const label = caret.querySelector('.collaboration-carets__label')
2029
expect(label?.textContent).toBe('Ada')
2130
expect(caret.classList.contains(ACTIVE)).toBe(true)
@@ -24,10 +33,64 @@ describe('caret-presence', () => {
2433
it('falls back to a default name for a bare user state', () => {
2534
const caret = renderCaret({ clientId: 1 })
2635
expect(caret.querySelector('.collaboration-carets__label')?.textContent).toBe('Collaborator')
36+
expect(caret.dataset.colorSlot).toBeUndefined()
37+
})
38+
39+
it('maps the current and historical identity palettes to the same CSS slots', () => {
40+
const legacy = ['#4ADE80', '#F472B6', '#60C5FF', '#FF8533', '#C084FC', '#FCD34D']
41+
for (const [slot, color] of USER_COLORS.entries()) {
42+
expect(caretColorSlot(color)).toBe(slot)
43+
expect(renderCaret({ color }).dataset.colorSlot).toBe(String(slot))
44+
expect(caretColorSlot(legacy[slot].toLowerCase())).toBe(slot)
45+
expect(renderCaret({ color: legacy[slot].toLowerCase() }).dataset.colorSlot).toBe(
46+
String(slot)
47+
)
48+
}
49+
expect(caretColorSlot('#f783ac')).toBe(-1)
50+
expect(renderCaret({ color: '#f783ac' }).dataset.colorSlot).toBeUndefined()
51+
})
52+
53+
it('maps resolved awareness colours back to their CSS palette slots', () => {
54+
const resolved = ['#4ade80', '#f472b6', '#33b4ff', '#fb923c', '#c084fc', '#fcd34d']
55+
try {
56+
for (const [slot, reference] of USER_COLORS.entries()) {
57+
const token = /^var\((--[\w-]+)\)$/.exec(reference)?.[1]
58+
if (!token) throw new Error(`Missing CSS token for slot ${slot}`)
59+
document.documentElement.style.setProperty(token, resolved[slot])
60+
}
61+
for (const [slot, color] of resolved.entries()) {
62+
expect(caretColorSlot(color)).toBe(slot)
63+
expect(renderCaret({ color }).dataset.colorSlot).toBe(String(slot))
64+
}
65+
} finally {
66+
for (const reference of USER_COLORS) {
67+
const token = /^var\((--[\w-]+)\)$/.exec(reference)?.[1]
68+
if (token) document.documentElement.style.removeProperty(token)
69+
}
70+
}
71+
})
72+
73+
it('keeps stylesheet slot colours aligned with the shared identity palette', () => {
74+
const css = readFileSync(
75+
path.join(
76+
process.cwd(),
77+
'app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css'
78+
),
79+
'utf8'
80+
)
81+
for (const [slot, color] of USER_COLORS.entries()) {
82+
const rule = new RegExp(
83+
`\\.collaboration-carets__caret\\[data-color-slot="${slot}"\\],[^{}]*\\.collaboration-carets__selection\\[data-color-slot="${slot}"\\]\\s*\\{([^}]*)\\}`
84+
)
85+
expect(rule.exec(css)?.[1].match(/--caret-color:\s*([^;]+);/)?.[1]).toBe(color)
86+
}
87+
expect(css).toContain(
88+
'background-color: color-mix(in srgb, var(--caret-color) 20%, transparent)'
89+
)
2790
})
2891

2992
it('hides the label after the inactivity hold, and re-activation restarts it', () => {
30-
const caret = renderCaret({ name: 'Ada', color: '#f783ac', clientId: 4242 })
93+
const caret = renderCaret({ name: 'Ada', color: '#F472B6', clientId: 4242 })
3194
vi.advanceTimersByTime(CARET_LABEL_HOLD_MS - 1)
3295
expect(caret.classList.contains(ACTIVE)).toBe(true)
3396
vi.advanceTimersByTime(1)
@@ -40,7 +103,7 @@ describe('caret-presence', () => {
40103
})
41104

42105
it('flips the label left only when it would overflow the editor right edge', () => {
43-
const caret = renderCaret({ name: 'Ada', color: '#f783ac', clientId: 4242 })
106+
const caret = renderCaret({ name: 'Ada', color: '#F472B6', clientId: 4242 })
44107
const label = caret.querySelector<HTMLElement>('.collaboration-carets__label')
45108
if (!label) throw new Error('label missing')
46109
// double-cast-allowed: jsdom has no layout; stub the label's right edge for the measure

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence.ts‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Extension } from '@tiptap/core'
22
import { Plugin, PluginKey } from '@tiptap/pm/state'
33
import type { Awareness } from 'y-protocols/awareness'
4+
import { USER_COLORS } from '@/lib/workspaces/colors'
45

56
/**
67
* Remote-collaborator caret presence for the file editor: the name label's
@@ -17,8 +18,38 @@ import type { Awareness } from 'y-protocols/awareness'
1718
*/
1819
export const CARET_LABEL_HOLD_MS = 2000
1920

20-
/** Fallback caret color when a peer's awareness carries no `color`. */
21-
export const DEFAULT_CARET_COLOR = 'var(--color-black)'
21+
/** Earlier clients published these fixed palette values in awareness. Match them by slot only. */
22+
const LEGACY_USER_COLORS = [
23+
'#4ADE80',
24+
'#F472B6',
25+
'#60C5FF',
26+
'#FF8533',
27+
'#C084FC',
28+
'#FCD34D',
29+
] as const
30+
31+
/**
32+
* Maps a current token, resolved CSS value, or historical collaborator colour
33+
* to its stable palette slot. Returns `-1` for missing or unrecognized colours.
34+
*/
35+
export function caretColorSlot(color: unknown): number {
36+
if (typeof color !== 'string') return -1
37+
const current = USER_COLORS.findIndex((value) => value === color)
38+
if (current >= 0) return current
39+
40+
// CollaborationCaret publishes the resolved CSS value in awareness for older peers.
41+
// Resolve the same palette here so incoming hex values keep their original slots.
42+
if (color && typeof document !== 'undefined') {
43+
const styles = getComputedStyle(document.documentElement)
44+
const resolved = USER_COLORS.findIndex((value) => {
45+
const token = /^var\((--[\w-]+)\)$/.exec(value)?.[1]
46+
return token && styles.getPropertyValue(token).trim().toUpperCase() === color.toUpperCase()
47+
})
48+
if (resolved >= 0) return resolved
49+
}
50+
51+
return LEGACY_USER_COLORS.findIndex((value) => value === color.toUpperCase())
52+
}
2253

2354
/**
2455
* The active-state class {@link activateCaretLabel} toggles on the caret node to reveal the
@@ -76,13 +107,12 @@ export function activateCaretLabel(caret: HTMLElement, editorRight?: number) {
76107
* `doc.clientID`; see `use-file-doc-collaboration.ts`).
77108
*/
78109
export function renderCaret(user: Record<string, unknown>): HTMLElement {
79-
const color = typeof user.color === 'string' ? user.color : DEFAULT_CARET_COLOR
110+
const slot = caretColorSlot(user.color)
80111
const name = typeof user.name === 'string' && user.name ? user.name : 'Collaborator'
81112
const clientId = typeof user.clientId === 'number' ? user.clientId : undefined
82113
const caret = document.createElement('span')
83114
caret.className = 'collaboration-carets__caret'
84-
// One inline var drives the caret bar, the dormant cap, and the name tag (all in CSS).
85-
caret.style.setProperty('--caret-color', color)
115+
if (slot >= 0) caret.dataset.colorSlot = String(slot)
86116
if (clientId !== undefined) caret.dataset.caretClientId = String(clientId)
87117
// The visible caret bar is a SEPARATE, absolutely-positioned child — never an inline border on the
88118
// caret span. The caret is a ProseMirror inline widget inserted between characters; an in-flow bar

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@ import CollaborationCaret from '@tiptap/extension-collaboration-caret'
33
import Placeholder from '@tiptap/extension-placeholder'
44
import type { Awareness } from 'y-protocols/awareness'
55
import type * as Y from 'yjs'
6-
import { withAlpha } from '@/lib/workspaces/colors'
76
import { BlockMover } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover'
87
import { CodeBlockWithLanguage } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block'
98
import { CodeBlockHighlight } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-highlight'
109
import {
10+
caretColorSlot,
1111
createCaretActivityExtension,
12-
DEFAULT_CARET_COLOR,
1312
renderCaret,
1413
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence'
1514
import { FileCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-collaboration'
@@ -106,10 +105,10 @@ export function createMarkdownEditorExtensions({
106105
user: collaboration.user,
107106
render: renderCaret,
108107
selectionRender: (user) => {
109-
const color = typeof user.color === 'string' ? user.color : DEFAULT_CARET_COLOR
108+
const slot = caretColorSlot(user.color)
110109
return {
111110
class: 'collaboration-carets__selection',
112-
style: `background-color: ${withAlpha(color, 0.2)};`,
111+
...(slot >= 0 ? { 'data-color-slot': String(slot) } : {}),
113112
}
114113
},
115114
}),

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -469,14 +469,19 @@
469469

470470
/*
471471
* Collaborative carets (TipTap CollaborationCaret). The caret bar and the name
472-
* label's background are colored inline from each collaborator's identity color
472+
* label's background use the collaborator's identity palette slot from CSS
473473
* (the same `getUserColor` mechanism the canvas cursors use); the selection is a
474-
* translucent tint of that color (set via selectionRender). The name label shows
474+
* translucent tint of that color. The name label shows
475475
* while the peer is active (JS toggles `--active` on each awareness change) or on
476476
* hover, then fades after inactivity — matching Google Docs. `z-index` lifts the
477477
* caret (and its label) above table cell backgrounds so a caret inside a table
478478
* cell is not hidden behind adjacent cells.
479479
*/
480+
.rich-markdown-nodes .collaboration-carets__caret,
481+
.rich-markdown-nodes .collaboration-carets__selection {
482+
--caret-color: var(--color-black);
483+
}
484+
480485
.rich-markdown-nodes .collaboration-carets__caret {
481486
/* Zero inline footprint: a positioned anchor with NO width/border/margin, so inserting or moving this
482487
* inline widget never reflows the surrounding text. The visible bar, dormant cap (::before), name label,
@@ -486,6 +491,36 @@
486491
z-index: 20;
487492
}
488493

494+
.rich-markdown-nodes .collaboration-carets__caret[data-color-slot="0"],
495+
.rich-markdown-nodes .collaboration-carets__selection[data-color-slot="0"] {
496+
--caret-color: var(--indicator-active);
497+
}
498+
499+
.rich-markdown-nodes .collaboration-carets__caret[data-color-slot="1"],
500+
.rich-markdown-nodes .collaboration-carets__selection[data-color-slot="1"] {
501+
--caret-color: var(--color-pink-400);
502+
}
503+
504+
.rich-markdown-nodes .collaboration-carets__caret[data-color-slot="2"],
505+
.rich-markdown-nodes .collaboration-carets__selection[data-color-slot="2"] {
506+
--caret-color: var(--brand-secondary);
507+
}
508+
509+
.rich-markdown-nodes .collaboration-carets__caret[data-color-slot="3"],
510+
.rich-markdown-nodes .collaboration-carets__selection[data-color-slot="3"] {
511+
--caret-color: var(--color-orange-400);
512+
}
513+
514+
.rich-markdown-nodes .collaboration-carets__caret[data-color-slot="4"],
515+
.rich-markdown-nodes .collaboration-carets__selection[data-color-slot="4"] {
516+
--caret-color: var(--color-purple-400);
517+
}
518+
519+
.rich-markdown-nodes .collaboration-carets__caret[data-color-slot="5"],
520+
.rich-markdown-nodes .collaboration-carets__selection[data-color-slot="5"] {
521+
--caret-color: var(--color-amber-300);
522+
}
523+
489524
/* The visible caret bar — absolutely positioned so it draws over the text without occupying inline
490525
* width. `left: -1px` centers the 2px bar on the cursor position; top/bottom span the line box height. */
491526
.rich-markdown-nodes .collaboration-carets__bar {
@@ -579,9 +614,8 @@
579614
border-radius: 2px 2px 0 2px;
580615
}
581616

582-
/* Remote text selection: a rounded translucent tint of the collaborator's identity
583-
* color (the alpha fill is set inline by selectionRender). */
584617
.rich-markdown-nodes .collaboration-carets__selection {
618+
background-color: color-mix(in srgb, var(--caret-color) 20%, transparent);
585619
border-radius: 2px;
586620
pointer-events: none;
587621
}

0 commit comments

Comments
 (0)