From 7dcca5f3caa00ec0db349daf4e209d485a136433 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sat, 20 Jun 2026 11:11:33 -0700 Subject: [PATCH 1/9] feat: add presence indicators to fields - for multiplayer mode, show the profile image of all users who have a field focused next to the field itself - also tune the focused/highlighted behavior to remove some of the padding - improve the highlighted styles of drawers - linkify the field title instead of the hidden "#" to make it easier to access --- .../ui/components/DocEditor/DocEditor.css | 86 +++++++-- .../ui/components/DocEditor/DocEditor.tsx | 99 ++++++---- .../ui/components/UserAvatar/UserAvatar.css | 2 +- .../ui/components/UserAvatar/UserAvatar.tsx | 25 ++- .../ui/components/Viewers/Viewers.tsx | 182 ++++++++++++++++-- 5 files changed, 329 insertions(+), 65 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.css b/packages/root-cms/ui/components/DocEditor/DocEditor.css index 03f3484c5..7b7adac57 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.css +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.css @@ -105,12 +105,38 @@ } .DocEditor__field.deeplink-target { - padding: 16px; + padding: 8px; + /* Negative horizontal margins offset the padding so the field contents stay + * aligned with unfocused fields (no horizontal shift). */ + margin-left: -8px; + margin-right: -8px; background: linear-gradient(rgb(255, 248, 197), rgb(255, 248, 197)); border: 1px solid rgba(212, 167, 44, 0.4); border-radius: 4px; } +/* For drawer object fields, highlight the drawer element itself rather than + * shifting the whole field with padding + negative margins. */ +.DocEditor__field.deeplink-target:has( + > .DocEditor__field__input + > .DocEditor__ObjectFieldDrawer + > .DocEditor__ObjectFieldDrawer__drawer + ) { + padding: 0; + margin-left: 0; + margin-right: 0; + background: none; + border: none; + border-radius: 0; +} + +.DocEditor__field.deeplink-target + > .DocEditor__field__input + > .DocEditor__ObjectFieldDrawer + > .DocEditor__ObjectFieldDrawer__drawer { + background: linear-gradient(rgb(255, 248, 197), rgb(255, 248, 197)); +} + .DocEditor__FieldHeader { position: relative; padding-right: 30px; @@ -123,20 +149,12 @@ gap: 8px; } -.DocEditor__FieldHeader__label__deeplink { - color: #ced4da; +.DocEditor__FieldHeader__label__link { + color: inherit; text-decoration: none; - opacity: 0; - transition: opacity 0.3s ease; -} - -.DocEditor__FieldHeader__label:focus-within - .DocEditor__FieldHeader__label__deeplink, -.DocEditor__FieldHeader__label:hover .DocEditor__FieldHeader__label__deeplink { - opacity: 1; } -.DocEditor__FieldHeader__label__deeplink:hover { +.DocEditor__FieldHeader__label__link:hover { text-decoration: underline; text-underline-offset: 2px; } @@ -147,10 +165,52 @@ margin-top: 2px; } -.DocEditor__FieldHeader__translate { +/* + * Right-aligned actions row (presence avatars + translate icon). Absolutely + * positioned so it never causes layout shift; the avatars stay flush with the + * right edge of the field even when the translate icon isn't rendered. + */ +.DocEditor__FieldHeader__actions { position: absolute; top: 0; right: 0; + /* Match the height of the translate ActionIcon (size="xs" = 18px) so the + * avatars' centers line up with it (the avatars are taller due to their + * border). */ + height: 18px; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} + +.DocEditor__FieldHeader__translate { + display: inline-flex; + align-items: center; +} + +/* + * Presence avatars for other users focused on this field. + */ +.DocEditor__FieldHeader__viewers { + display: flex; + flex-direction: row; + align-items: center; + pointer-events: none; +} + +/* Overlap is applied to the direct flex children (which may be a Tooltip + * wrapper element, not the avatar itself) so the layout stays stable on hover. */ +.DocEditor__FieldHeader__viewers > * { + pointer-events: auto; +} + +.DocEditor__FieldHeader__viewers > * + * { + margin-left: -8px; +} + +.DocEditor__FieldHeader__viewers__avatar { + margin-left: 0; } .DocEditor__FieldHeader__translate__iconDisabled { diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 10a6c5a50..9ed8f4d73 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -125,7 +125,8 @@ import {useLocalizationModal} from '../LocalizationModal/LocalizationModal.js'; import {useLockPublishingModal} from '../LockPublishingModal/LockPublishingModal.js'; import {usePublishDocModal} from '../PublishDocModal/PublishDocModal.js'; import {Text} from '../Text/Text.js'; -import {Viewers} from '../Viewers/Viewers.js'; +import {UserAvatar} from '../UserAvatar/UserAvatar.js'; +import {useFieldViewers, Viewers, ViewersProvider} from '../Viewers/Viewers.js'; import {BooleanField} from './fields/BooleanField.js'; import {DateField} from './fields/DateField.js'; import {DateTimeField} from './fields/DateTimeField.js'; @@ -226,34 +227,36 @@ export function DocEditor(props: DocEditorProps) { - -
- - - {!loading && !props.hideStatusBar && ( - + +
+ - )} -
- {fields.map((field) => ( - + {!loading && !props.hideStatusBar && ( + - ))} + )} +
+ {fields.map((field) => ( + + ))} +
-
-
+ + ); } @@ -854,12 +857,10 @@ DocEditor.FieldHeader = (props: FieldProps & {className?: string}) => {
DEPRECATED: {label}
) : ( )} {field.help && (
{field.help}
)} - +
+ + +
+
+ ); +}; + +/** + * Renders miniature avatars of other viewers who currently have this field + * focused. Sized to match the translate icon so it never causes layout shift. + */ +DocEditor.FieldHeaderViewers = (props: {deepKey: string}) => { + const viewers = useFieldViewers(props.deepKey); + if (viewers.length === 0) { + return null; + } + return ( +
+ {viewers.map((viewer) => ( + + ))}
); }; diff --git a/packages/root-cms/ui/components/UserAvatar/UserAvatar.css b/packages/root-cms/ui/components/UserAvatar/UserAvatar.css index 2f2708efb..27772605d 100644 --- a/packages/root-cms/ui/components/UserAvatar/UserAvatar.css +++ b/packages/root-cms/ui/components/UserAvatar/UserAvatar.css @@ -1,5 +1,5 @@ .UserAvatar { - border: 2px solid var(--mantine-color-body, #fff); + border: 1px solid var(--mantine-color-body, #fff); box-sizing: content-box; transition: opacity 0.2s ease; } diff --git a/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx b/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx index d8cd0c053..b4fd52a5b 100644 --- a/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx +++ b/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx @@ -50,6 +50,11 @@ export function UserAvatar(props: UserAvatarProps) { const displayName = profile?.displayName || ''; const photoURL = profile?.photoURL || ''; const [imgError, setImgError] = useState(false); + const [imgLoaded, setImgLoaded] = useState(false); + + const hasPhoto = Boolean(photoURL) && !imgError; + const showInitials = !hasPhoto; + const avatarColor = getAvatarColor(email || displayName || '?'); const avatar = ( setImgLoaded(true), onError: () => setImgError(true), }} > - {!photoURL || imgError ? getUserInitials(email, displayName) : null} + {showInitials ? getUserInitials(email, displayName) : null} ); diff --git a/packages/root-cms/ui/components/Viewers/Viewers.tsx b/packages/root-cms/ui/components/Viewers/Viewers.tsx index 28ae3faa5..0ff490f7b 100644 --- a/packages/root-cms/ui/components/Viewers/Viewers.tsx +++ b/packages/root-cms/ui/components/Viewers/Viewers.tsx @@ -9,8 +9,10 @@ import { setDoc, updateDoc, } from 'firebase/firestore'; -import {useEffect, useState} from 'preact/hooks'; +import {ComponentChildren, createContext} from 'preact'; +import {useContext, useEffect, useMemo, useState} from 'preact/hooks'; import {normalizeSlug} from '../../../shared/slug.js'; +import {debounce} from '../../utils/debounce.js'; import {EventListener} from '../../utils/events.js'; import {throttle} from '../../utils/throttle.js'; import {TIME_UNITS} from '../../utils/time.js'; @@ -29,6 +31,8 @@ interface Viewer { photoURL: string; lastViewedAt: Timestamp; disconnectedAt: Timestamp; + /** The `deepKey` of the field this viewer currently has focused, if any. */ + focusedField?: string; } export interface ViewersProps { @@ -44,6 +48,7 @@ class ViewersController extends EventListener { started = false; timer: Timer = new Timer(UPDATE_INTERVAL); idleTimer: Timer = new Timer(IDLE_TIMEOUT); + private focusedField = ''; constructor(id: string) { super(); @@ -118,6 +123,38 @@ class ViewersController extends EventListener { this.idleTimer.reset(); }, 5000); + /** Broadcasts the field (`deepKey`) the current user has focused. */ + setFocusedField(deepKey: string) { + const value = deepKey || ''; + if (this.focusedField === value) { + return; + } + this.focusedField = value; + this.writeFocusedField(); + } + + private writeFocusedField = debounce(async () => { + if (!this.started) { + return; + } + const user = window.firebase.user; + if (!user.email) { + return; + } + await setDoc( + this.docRef, + { + [user.email]: { + email: user.email, + photoURL: user.photoURL, + focusedField: this.focusedField, + lastViewedAt: serverTimestamp(), + }, + }, + {merge: true} + ); + }, 200); + stop() { if (!this.started) { return; @@ -149,17 +186,45 @@ class ViewersController extends EventListener { } } +function isViewerDisconnected(viewer: Viewer) { + if (!viewer.disconnectedAt) { + return false; + } + return viewer.disconnectedAt > viewer.lastViewedAt; +} + +interface ViewersContextValue { + viewers: Viewer[]; + controller: ViewersController | null; + /** The `deepKey` of the field the current user currently has focused. */ + currentFocusedField: string; +} + +const ViewersContext = createContext({ + viewers: [], + controller: null, + currentFocusedField: '', +}); + /** - * Displays avatars viewing the current page. + * Hook that wires up a {@link ViewersController} for the given page `id`. It + * keeps the list of other active viewers up to date and tracks which field the + * current user has focused so it can be broadcast to other viewers. */ -export function Viewers(props: ViewersProps) { - const id = normalizeSlug(props.id); +function useViewersController(id: string): ViewersContextValue { const [viewers, setViewers] = useState([]); + const [controller, setController] = useState(null); + const [currentFocusedField, setCurrentFocusedField] = useState(''); useEffect(() => { + if (!id) { + return; + } const controller = new ViewersController(id); + setController(controller); controller.on('change', (viewers: Viewer[]) => setViewers(viewers)); controller.start(); + const onVisibilityChange = () => { if (document.hidden || document.visibilityState !== 'visible') { controller.stop(); @@ -170,21 +235,116 @@ export function Viewers(props: ViewersProps) { } }; document.addEventListener('visibilitychange', onVisibilityChange); + + // Broadcast which field the current user has focused so other viewers can + // render a presence indicator next to it. + const getFocusedDeepKey = () => { + const active = document.activeElement; + if (!active) { + return ''; + } + const fieldEl = active.closest('.DocEditor__field'); + return fieldEl?.id || ''; + }; + const updateFocusedField = () => { + const deepKey = getFocusedDeepKey(); + controller.setFocusedField(deepKey); + setCurrentFocusedField(deepKey); + }; + const onFocusIn = () => updateFocusedField(); + const onFocusOut = () => { + // Defer so `document.activeElement` reflects the newly focused element. + window.setTimeout(() => { + updateFocusedField(); + }, 0); + }; + document.addEventListener('focusin', onFocusIn); + document.addEventListener('focusout', onFocusOut); + return () => { controller.dispose(); document.removeEventListener('visibilitychange', onVisibilityChange); + document.removeEventListener('focusin', onFocusIn); + document.removeEventListener('focusout', onFocusOut); + setController(null); + setCurrentFocusedField(''); }; }, [id]); - if (viewers.length === 0) { - return null; - } + return {viewers, controller, currentFocusedField}; +} - function isViewerDisconnected(viewer: Viewer) { - if (!viewer.disconnectedAt) { - return false; +/** + * Provides viewer presence (including per-field focus) to descendants. Wrap the + * doc editor with this so individual fields can render presence indicators. + */ +export function ViewersProvider(props: { + id: string; + children: ComponentChildren; +}) { + const id = normalizeSlug(props.id); + const value = useViewersController(id); + return ( + + {props.children} + + ); +} + +export interface FieldViewer { + email: string; + /** Whether this viewer is the current (signed-in) user. */ + isCurrentUser: boolean; +} + +/** + * Returns the viewers who currently have the field identified by `deepKey` + * focused. The current user is only included when at least one *other* viewer + * is also focused on the same field (so we never show your own avatar when + * you're the only one on the field). + */ +export function useFieldViewers(deepKey: string): FieldViewer[] { + const {viewers, currentFocusedField} = useContext(ViewersContext); + return useMemo(() => { + if (!deepKey) { + return []; } - return viewer.disconnectedAt > viewer.lastViewedAt; + const others = viewers.filter( + (viewer) => + viewer.focusedField === deepKey && !isViewerDisconnected(viewer) + ); + if (others.length === 0) { + return []; + } + const result: FieldViewer[] = others.map((viewer) => ({ + email: viewer.email, + isCurrentUser: false, + })); + // Include the current user's avatar when they share this field with at + // least one other viewer. + if (currentFocusedField === deepKey) { + const currentEmail = window.firebase.user?.email || ''; + if (currentEmail) { + result.unshift({email: currentEmail, isCurrentUser: true}); + } + } + return result; + }, [viewers, deepKey, currentFocusedField]); +} + +/** + * Displays avatars viewing the current page. When rendered inside a + * {@link ViewersProvider} it reuses the shared controller; otherwise it spins + * up its own controller (e.g. for standalone usage). + */ +export function Viewers(props: ViewersProps) { + const id = normalizeSlug(props.id); + const context = useContext(ViewersContext); + const standalone = useViewersController(context.controller ? '' : id); + const viewers = context.controller ? context.viewers : standalone.viewers; + + if (viewers.length === 0) { + return null; } // Use plain CSS instead of `AvatarsGroup` because that From ce3c3e47b4d60cac0d78bd3fe23cf5b62d69e2ed Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Sat, 20 Jun 2026 17:22:24 -0700 Subject: [PATCH 2/9] feat: enhance deeplink functionality in DocEditor and add Esc key support to clear selection --- .../ui/components/DocEditor/DocEditor.tsx | 17 +++++++++++++-- packages/root-cms/ui/hooks/useDeeplink.tsx | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 9ed8f4d73..a1e030134 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -1076,7 +1076,17 @@ DocEditor.ObjectFieldDrawer = (props: FieldProps) => { const modalTheme = useModalTheme(); const deeplink = useDeeplink(); - const initialOpen = !collapsed || deeplink.value.includes(props.deepKey); + const isDeeplinkTarget = deeplink.value.includes(props.deepKey); + const [open, setOpen] = useState(!collapsed || isDeeplinkTarget); + + // Open (but never force-close) the drawer when it becomes the deeplink + // target, so that clearing the deeplink (e.g. pressing Esc) leaves the + // drawer's open state untouched. + useEffect(() => { + if (isDeeplinkTarget) { + setOpen(true); + } + }, [isDeeplinkTarget]); const copyToClipboard = () => { const data = draft.getValue(props.deepKey) || {}; @@ -1160,7 +1170,10 @@ DocEditor.ObjectFieldDrawer = (props: FieldProps) => { >
{ + setOpen((e.currentTarget as HTMLDetailsElement).open); + }} >
diff --git a/packages/root-cms/ui/hooks/useDeeplink.tsx b/packages/root-cms/ui/hooks/useDeeplink.tsx index 5bace3417..dffb8ebfb 100644 --- a/packages/root-cms/ui/hooks/useDeeplink.tsx +++ b/packages/root-cms/ui/hooks/useDeeplink.tsx @@ -32,6 +32,27 @@ export function DeeplinkProvider(props: {children: ComponentChildren}) { setValue(deeplinkFromUrl); }, [deeplinkFromUrl]); + // Allow pressing "Esc" to unselect the currently highlighted field. + useEffect(() => { + if (!value) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) { + return; + } + setValue(''); + // Remove the deeplink from the URL without modifying history. + const url = new URL(window.location.href); + url.searchParams.delete('deeplink'); + window.history.replaceState({}, '', url.toString()); + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [value]); + // Enable posting messages from the preview frame to the DocEditor so that // fields can be focused. useEffect(() => { From ffb49732c47b92efa5d7d4883d7e9bdb2d237064 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Thu, 25 Jun 2026 18:31:44 -0700 Subject: [PATCH 3/9] chore: add colors to profile pictures - polish deeplink behavior - desaturate inactive icons --- .../ui/components/DocEditor/DocEditor.css | 14 +++ .../ui/components/DocEditor/DocEditor.tsx | 55 ++++++++++-- .../ui/components/UserAvatar/UserAvatar.css | 16 +++- .../ui/components/UserAvatar/UserAvatar.tsx | 34 +++++++ .../ui/components/Viewers/Viewers.css | 20 +++++ .../ui/components/Viewers/Viewers.tsx | 61 ++++++++++--- packages/root-cms/ui/hooks/useDeeplink.tsx | 88 +++++++++++++++---- packages/root-cms/ui/utils/user-profile.ts | 25 +++--- 8 files changed, 268 insertions(+), 45 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.css b/packages/root-cms/ui/components/DocEditor/DocEditor.css index 7b7adac57..eda260631 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.css +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.css @@ -27,6 +27,7 @@ } .DocEditor__statusBar__saveState, +.DocEditor__statusBar__viewers, .DocEditor__statusBar__statusBadges { margin-right: 16px; } @@ -137,6 +138,19 @@ background: linear-gradient(rgb(255, 248, 197), rgb(255, 248, 197)); } +/* + * Presence highlight: when another user has this field focused, outline the + * field in that user's color (matching their avatar), à la Google Docs. An + * outline is used (instead of border/padding) so it never shifts layout. + */ +.DocEditor__field--presence { + position: relative; + border-radius: 2px; + outline: 1px solid var(--presence-color, transparent); + outline-offset: 4px; + transition: outline-color 0.2s ease; +} + .DocEditor__FieldHeader { position: relative; padding-right: 30px; diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index a1e030134..a6699a7f0 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -411,10 +411,10 @@ DocEditor.StatusBar = (props: StatusBarProps) => { return (
+
- {data?.sys && (
{ title="Link to field" onClick={(e) => { e.preventDefault(); - window.history.replaceState({}, '', deeplinkUrl); - deeplink.setValue(props.deepKey!); + // Stop the event before it reaches the preact-iso router's + // document-level click handler, which would otherwise treat + // this same-origin `` as a navigation and pushState the + // deeplink URL back after we just cleared it. + e.stopPropagation(); + // Toggle: re-clicking the already-focused field clears the + // deeplink instead of re-applying it. + if (deeplink.value === props.deepKey) { + deeplink.setValue(''); + } else { + deeplink.setValue(props.deepKey!); + } }} > {label} @@ -892,19 +902,54 @@ DocEditor.FieldHeader = (props: FieldProps & {className?: string}) => { /** * Renders miniature avatars of other viewers who currently have this field * focused. Sized to match the translate icon so it never causes layout shift. + * + * Also color-codes the field: the enclosing `.DocEditor__field` gets a colored + * border matching the (first other) viewer focused on it, à la Google Docs, so + * it's easy to tell at a glance who is on which field. */ DocEditor.FieldHeaderViewers = (props: {deepKey: string}) => { const viewers = useFieldViewers(props.deepKey); + const ref = useRef(null); + + // The color to highlight the field with: prefer the first *other* viewer so + // the highlight reflects who else is here (not your own color). + const highlightColor = useMemo(() => { + const other = viewers.find((viewer) => !viewer.isCurrentUser); + return (other || viewers[0])?.color || ''; + }, [viewers]); + + useEffect(() => { + const fieldEl = ref.current?.closest('.DocEditor__field'); + if (!fieldEl) { + return; + } + if (highlightColor) { + fieldEl.style.setProperty('--presence-color', highlightColor); + fieldEl.classList.add('DocEditor__field--presence'); + } else { + fieldEl.style.removeProperty('--presence-color'); + fieldEl.classList.remove('DocEditor__field--presence'); + } + return () => { + fieldEl.style.removeProperty('--presence-color'); + fieldEl.classList.remove('DocEditor__field--presence'); + }; + }, [highlightColor]); + if (viewers.length === 0) { - return null; + // Keep an (empty) anchor element mounted so the cleanup effect above can + // still find the field and clear the highlight when viewers leave. + return
; } return ( -
+
{viewers.map((viewer) => ( ))} diff --git a/packages/root-cms/ui/components/UserAvatar/UserAvatar.css b/packages/root-cms/ui/components/UserAvatar/UserAvatar.css index 27772605d..39d5dfdd3 100644 --- a/packages/root-cms/ui/components/UserAvatar/UserAvatar.css +++ b/packages/root-cms/ui/components/UserAvatar/UserAvatar.css @@ -1,11 +1,23 @@ .UserAvatar { border: 1px solid var(--mantine-color-body, #fff); box-sizing: content-box; - transition: opacity 0.2s ease; + transition: filter 0.2s ease; } +/* Desaturate + dim inactive avatars instead of using opacity, which looks off + * when avatars overlap (you'd see the avatar behind it bleed through). */ .UserAvatar--inactive { - opacity: 0.5; + filter: grayscale(1) brightness(1.1); +} + +.UserAvatar--clickable { + cursor: pointer; +} + +/* When a color ring is drawn, the colored border should sit directly against + * the photo (the white ring is provided by the box-shadow outside it). */ +.UserAvatar--colorRing { + border: none; } .UserAvatar__tooltip { diff --git a/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx b/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx index b4fd52a5b..b9e79d3c9 100644 --- a/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx +++ b/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx @@ -29,6 +29,23 @@ export interface UserAvatarProps { * inactive (e.g. disconnected from a viewing session). */ inactive?: boolean; + /** + * Optional click handler. When provided, the avatar becomes interactive + * (e.g. to deeplink to the field a viewer is focused on). + */ + onClick?: (e: MouseEvent) => void; + /** + * If true, draws a colored ring around the avatar using the user's + * deterministic color (à la Google Docs), so the same user is easy to + * recognize across the UI even when they have a profile photo. When a + * profile photo is shown, this is the only color cue. + */ + colorRing?: boolean; + /** + * Width (in px) of the colored ring drawn when {@link colorRing} is set. A + * white ring of 1px is always drawn just outside it. Defaults to 2. + */ + ringWidth?: number; } /** @@ -56,17 +73,32 @@ export function UserAvatar(props: UserAvatarProps) { const showInitials = !hasPhoto; const avatarColor = getAvatarColor(email || displayName || '?'); + // Color-codes users consistently across the UI (à la Google Docs): a ring in + // the user's color directly around the photo, then a 1px white ring just + // outside that. Drawn with box-shadow so it never shifts layout. + const ringWidth = props.ringWidth ?? 2; + const ringStyle = props.colorRing + ? { + boxShadow: `0 0 0 ${ringWidth}px ${avatarColor}, 0 0 0 ${ + ringWidth + 1 + }px #fff`, + } + : undefined; + const avatar = ( *:first-child { diff --git a/packages/root-cms/ui/components/Viewers/Viewers.tsx b/packages/root-cms/ui/components/Viewers/Viewers.tsx index 0ff490f7b..ea368032f 100644 --- a/packages/root-cms/ui/components/Viewers/Viewers.tsx +++ b/packages/root-cms/ui/components/Viewers/Viewers.tsx @@ -12,11 +12,16 @@ import { import {ComponentChildren, createContext} from 'preact'; import {useContext, useEffect, useMemo, useState} from 'preact/hooks'; import {normalizeSlug} from '../../../shared/slug.js'; +import { + scrollToDeeplink, + useOptionalDeeplink, +} from '../../hooks/useDeeplink.js'; import {debounce} from '../../utils/debounce.js'; import {EventListener} from '../../utils/events.js'; import {throttle} from '../../utils/throttle.js'; import {TIME_UNITS} from '../../utils/time.js'; import {Timer} from '../../utils/timer.js'; +import {getAvatarColor} from '../../utils/user-profile.js'; import {UserAvatar} from '../UserAvatar/UserAvatar.js'; import './Viewers.css'; @@ -295,6 +300,8 @@ export interface FieldViewer { email: string; /** Whether this viewer is the current (signed-in) user. */ isCurrentUser: boolean; + /** Deterministic color for this user (used to color-code presence). */ + color: string; } /** @@ -319,13 +326,18 @@ export function useFieldViewers(deepKey: string): FieldViewer[] { const result: FieldViewer[] = others.map((viewer) => ({ email: viewer.email, isCurrentUser: false, + color: getAvatarColor(viewer.email), })); // Include the current user's avatar when they share this field with at // least one other viewer. if (currentFocusedField === deepKey) { const currentEmail = window.firebase.user?.email || ''; if (currentEmail) { - result.unshift({email: currentEmail, isCurrentUser: true}); + result.unshift({ + email: currentEmail, + isCurrentUser: true, + color: getAvatarColor(currentEmail), + }); } } return result; @@ -342,11 +354,27 @@ export function Viewers(props: ViewersProps) { const context = useContext(ViewersContext); const standalone = useViewersController(context.controller ? '' : id); const viewers = context.controller ? context.viewers : standalone.viewers; + const deeplink = useOptionalDeeplink(); if (viewers.length === 0) { return null; } + // Deeplinks to the field a viewer currently has focused (à la Figma's + // "click an avatar to follow"). Only available inside the doc editor where a + // `` is present. + const followViewer = (deepKey: string) => { + if (!deeplink || !deepKey) { + return; + } + // setValue keeps the `?deeplink=` URL param in sync. + deeplink.setValue(deepKey); + const element = document.getElementById(deepKey); + if (element) { + scrollToDeeplink(element, {behavior: 'smooth', force: true}); + } + }; + // Use plain CSS instead of `AvatarsGroup` because that // component doesn't support using Tooltips as children. const limit = props.max || 3; @@ -355,15 +383,28 @@ export function Viewers(props: ViewersProps) { return (
- {visibleViewers.map((viewer) => ( - - ))} + {visibleViewers.map((viewer) => { + const canFollow = Boolean( + deeplink && viewer.focusedField && !isViewerDisconnected(viewer) + ); + return ( + followViewer(viewer.focusedField!) : undefined + } + /> + ); + })} {overflow > 0 && ( +{overflow} diff --git a/packages/root-cms/ui/hooks/useDeeplink.tsx b/packages/root-cms/ui/hooks/useDeeplink.tsx index dffb8ebfb..c675c483d 100644 --- a/packages/root-cms/ui/hooks/useDeeplink.tsx +++ b/packages/root-cms/ui/hooks/useDeeplink.tsx @@ -1,5 +1,11 @@ import {ComponentChildren, createContext} from 'preact'; -import {useContext, useEffect, useState} from 'preact/hooks'; +import { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'preact/hooks'; import {isScrollToDeeplinkMessage} from '../../shared/embed-protocol.js'; import {isAllowedOrigin} from '../utils/embed-bridge.js'; @@ -20,17 +26,51 @@ function getDeeplinkFromUrl(): string { return params.get('deeplink') || ''; } +/** Writes (or clears) the `deeplink` query param without touching history. */ +function syncDeeplinkUrl(deepKey: string) { + const url = new URL(window.location.href); + if (deepKey) { + url.searchParams.set('deeplink', deepKey); + } else { + url.searchParams.delete('deeplink'); + } + window.history.replaceState({}, '', url.toString()); +} + const DEEPLINK_CONTEXT = createContext(null); export function DeeplinkProvider(props: {children: ComponentChildren}) { - const deeplinkFromUrl = getDeeplinkFromUrl(); - const [value, setValue] = useState(deeplinkFromUrl); - const deeplinkCtx = {value, setValue}; + const [value, setValueState] = useState(getDeeplinkFromUrl); - // When navigating between docs (URL changes), update the deeplink. - useEffect(() => { - setValue(deeplinkFromUrl); - }, [deeplinkFromUrl]); + // Live mirror of `value` so `setValue` can read the current value + // synchronously (without depending on a possibly-stale render closure). + const valueRef = useRef(value); + valueRef.current = value; + + // Tracks a field that was *just* cleared so we can ignore the preview + // iframe's `scrollToDeeplink` echo (clicking/focusing in the preview posts a + // message back, which would otherwise immediately re-add the deeplink and + // cause a visible "flash off then back on" in the URL). + const recentlyCleared = useRef<{deepKey: string; at: number} | null>(null); + + // Single source of truth: update React state AND keep the URL in sync. This + // avoids fragile "read the URL on every render" coupling that made the + // deeplink toggle unreliable. + const setValue = useCallback((next: string) => { + const prev = valueRef.current; + if (!next && prev) { + // Mark the cleared key synchronously so an echo arriving before React + // commits the new state is still suppressed. + recentlyCleared.current = {deepKey: prev, at: Date.now()}; + } else if (next) { + recentlyCleared.current = null; + } + valueRef.current = next; + setValueState(next); + syncDeeplinkUrl(next); + }, []); + + const deeplinkCtx = {value, setValue}; // Allow pressing "Esc" to unselect the currently highlighted field. useEffect(() => { @@ -42,16 +82,12 @@ export function DeeplinkProvider(props: {children: ComponentChildren}) { return; } setValue(''); - // Remove the deeplink from the URL without modifying history. - const url = new URL(window.location.href); - url.searchParams.delete('deeplink'); - window.history.replaceState({}, '', url.toString()); }; window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, [value]); + }, [value, setValue]); // Enable posting messages from the preview frame to the DocEditor so that // fields can be focused. @@ -64,14 +100,21 @@ export function DeeplinkProvider(props: {children: ComponentChildren}) { } if (isScrollToDeeplinkMessage(event.data)) { const deepKey = event.data.scrollToDeeplink.deepKey; + // Ignore the preview's echo of a deeplink the user just toggled off. + // Match the exact key as well as parent/child keys, since the echo can + // reference a nested field of the one that was cleared. + const cleared = recentlyCleared.current; + if (cleared && Date.now() - cleared.at < 1000) { + const a = cleared.deepKey; + const b = deepKey; + if (a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`)) { + return; + } + } const element = document.getElementById(deepKey); if (element) { setValue(deepKey); scrollToDeeplink(element, {behavior: 'smooth', force: true}); - // Update the URL without modifying the history. - const url = new URL(window.location.href); - url.searchParams.set('deeplink', deepKey); - window.history.replaceState({}, '', url.toString()); } } }; @@ -79,7 +122,7 @@ export function DeeplinkProvider(props: {children: ComponentChildren}) { return () => { window.removeEventListener('message', handleMessage); }; - }, []); + }, [setValue]); return ( @@ -98,6 +141,15 @@ export function useDeeplink(): DeeplinkContext { return ctxValue; } +/** + * Like {@link useDeeplink} but returns `null` instead of throwing when there is + * no surrounding ``. Useful for components that can be used + * both inside and outside the doc editor (e.g. {@link Viewers}). + */ +export function useOptionalDeeplink(): DeeplinkContext | null { + return useContext(DEEPLINK_CONTEXT); +} + /** Opens all the ancestor detail elements. */ function setAncestorsOpen(deeplinkEl: HTMLElement) { let current = deeplinkEl; diff --git a/packages/root-cms/ui/utils/user-profile.ts b/packages/root-cms/ui/utils/user-profile.ts index 631f23709..16665d403 100644 --- a/packages/root-cms/ui/utils/user-profile.ts +++ b/packages/root-cms/ui/utils/user-profile.ts @@ -216,19 +216,24 @@ export function isOrgEmail(email: string): boolean { /** * Returns a deterministic background color for an email so missing-photo * avatars are visually distinct but stable across renders. + * + * The palette is a bright, saturated, Figma-style spread of hues so different + * users are easy to tell apart at a glance. */ export function getAvatarColor(email: string): string { const palette = [ - '#1f6feb', - '#0e7490', - '#0f766e', - '#15803d', - '#9333ea', - '#c026d3', - '#db2777', - '#dc2626', - '#ea580c', - '#b45309', + '#1e88ff', // blue + '#00b8d9', // cyan + '#00c781', // green + '#36b37e', // teal-green + '#ffab00', // amber + '#ff8b00', // orange + '#ff5630', // red-orange + '#f5365c', // pink-red + '#e040fb', // magenta + '#9c5cff', // violet + '#6554ff', // indigo + '#ff7eb6', // pink ]; let hash = 0; for (let i = 0; i < email.length; i++) { From 005ae2659acc26bd6be8fe800d86f0cac8c14843 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Mon, 6 Jul 2026 11:08:44 -0700 Subject: [PATCH 4/9] fix: presence color compatibility with deeplink --- .../ui/components/DocEditor/DocEditor.tsx | 56 +++++++------------ 1 file changed, 21 insertions(+), 35 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index a6699a7f0..7b9eba0cd 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -45,6 +45,7 @@ import { IconTriangleFilled, } from '@tabler/icons-preact'; import {createContext, RefObject} from 'preact'; +import {CSSProperties} from 'preact/compat'; import { useContext, useEffect, @@ -768,6 +769,16 @@ DocEditor.FieldBody = (props: FieldProps) => { const targeted = deeplink.value === props.deepKey; const ref = useRef(null); + // Presence highlight: color the field to match the (first other) viewer + // focused on it, à la Google Docs. Applied declaratively (rather than via + // imperative classList/style mutations) so it survives re-renders such as the + // `deeplink-target` class toggling on/off. + const fieldViewers = useFieldViewers(props.deepKey); + const presenceColor = useMemo(() => { + const other = fieldViewers.find((viewer) => !viewer.isCurrentUser); + return (other || fieldViewers[0])?.color || ''; + }, [fieldViewers]); + const showFieldHeader = useMemo(() => { if (field.type === 'object') { // Default to the "drawer" variant. @@ -790,8 +801,14 @@ DocEditor.FieldBody = (props: FieldProps) => { className={joinClassNames( 'DocEditor__field', field.deprecated && 'DocEditor__field--deprecated', - targeted && 'deeplink-target' + targeted && 'deeplink-target', + presenceColor && 'DocEditor__field--presence' )} + style={ + presenceColor + ? ({'--presence-color': presenceColor} as CSSProperties) + : undefined + } data-type={field.type} data-level={level} id={props.deepKey} @@ -902,47 +919,16 @@ DocEditor.FieldHeader = (props: FieldProps & {className?: string}) => { /** * Renders miniature avatars of other viewers who currently have this field * focused. Sized to match the translate icon so it never causes layout shift. - * - * Also color-codes the field: the enclosing `.DocEditor__field` gets a colored - * border matching the (first other) viewer focused on it, à la Google Docs, so - * it's easy to tell at a glance who is on which field. + * (The field's colored presence border is applied in `FieldBody`.) */ DocEditor.FieldHeaderViewers = (props: {deepKey: string}) => { const viewers = useFieldViewers(props.deepKey); - const ref = useRef(null); - - // The color to highlight the field with: prefer the first *other* viewer so - // the highlight reflects who else is here (not your own color). - const highlightColor = useMemo(() => { - const other = viewers.find((viewer) => !viewer.isCurrentUser); - return (other || viewers[0])?.color || ''; - }, [viewers]); - - useEffect(() => { - const fieldEl = ref.current?.closest('.DocEditor__field'); - if (!fieldEl) { - return; - } - if (highlightColor) { - fieldEl.style.setProperty('--presence-color', highlightColor); - fieldEl.classList.add('DocEditor__field--presence'); - } else { - fieldEl.style.removeProperty('--presence-color'); - fieldEl.classList.remove('DocEditor__field--presence'); - } - return () => { - fieldEl.style.removeProperty('--presence-color'); - fieldEl.classList.remove('DocEditor__field--presence'); - }; - }, [highlightColor]); if (viewers.length === 0) { - // Keep an (empty) anchor element mounted so the cleanup effect above can - // still find the field and clear the highlight when viewers leave. - return
; + return null; } return ( -
+
{viewers.map((viewer) => ( Date: Tue, 21 Jul 2026 10:58:00 -0700 Subject: [PATCH 5/9] fix: remove presence ring around fields and fade inactive avatars to white - Per review feedback, drop the colored outline around fields another user has focused; the avatar next to the field is the sole presence indicator. - Inactive (disconnected) avatars now render grayscale with a 50% white overlay instead of a brightness tweak. Co-Authored-By: Claude Fable 5 --- .../ui/components/DocEditor/DocEditor.css | 13 ------------ .../ui/components/DocEditor/DocEditor.tsx | 20 +------------------ .../ui/components/UserAvatar/UserAvatar.css | 19 +++++++++++++++--- 3 files changed, 17 insertions(+), 35 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.css b/packages/root-cms/ui/components/DocEditor/DocEditor.css index eda260631..8c1b0c57b 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.css +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.css @@ -138,19 +138,6 @@ background: linear-gradient(rgb(255, 248, 197), rgb(255, 248, 197)); } -/* - * Presence highlight: when another user has this field focused, outline the - * field in that user's color (matching their avatar), à la Google Docs. An - * outline is used (instead of border/padding) so it never shifts layout. - */ -.DocEditor__field--presence { - position: relative; - border-radius: 2px; - outline: 1px solid var(--presence-color, transparent); - outline-offset: 4px; - transition: outline-color 0.2s ease; -} - .DocEditor__FieldHeader { position: relative; padding-right: 30px; diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 7b9eba0cd..8997c4081 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -45,7 +45,6 @@ import { IconTriangleFilled, } from '@tabler/icons-preact'; import {createContext, RefObject} from 'preact'; -import {CSSProperties} from 'preact/compat'; import { useContext, useEffect, @@ -769,16 +768,6 @@ DocEditor.FieldBody = (props: FieldProps) => { const targeted = deeplink.value === props.deepKey; const ref = useRef(null); - // Presence highlight: color the field to match the (first other) viewer - // focused on it, à la Google Docs. Applied declaratively (rather than via - // imperative classList/style mutations) so it survives re-renders such as the - // `deeplink-target` class toggling on/off. - const fieldViewers = useFieldViewers(props.deepKey); - const presenceColor = useMemo(() => { - const other = fieldViewers.find((viewer) => !viewer.isCurrentUser); - return (other || fieldViewers[0])?.color || ''; - }, [fieldViewers]); - const showFieldHeader = useMemo(() => { if (field.type === 'object') { // Default to the "drawer" variant. @@ -801,14 +790,8 @@ DocEditor.FieldBody = (props: FieldProps) => { className={joinClassNames( 'DocEditor__field', field.deprecated && 'DocEditor__field--deprecated', - targeted && 'deeplink-target', - presenceColor && 'DocEditor__field--presence' + targeted && 'deeplink-target' )} - style={ - presenceColor - ? ({'--presence-color': presenceColor} as CSSProperties) - : undefined - } data-type={field.type} data-level={level} id={props.deepKey} @@ -919,7 +902,6 @@ DocEditor.FieldHeader = (props: FieldProps & {className?: string}) => { /** * Renders miniature avatars of other viewers who currently have this field * focused. Sized to match the translate icon so it never causes layout shift. - * (The field's colored presence border is applied in `FieldBody`.) */ DocEditor.FieldHeaderViewers = (props: {deepKey: string}) => { const viewers = useFieldViewers(props.deepKey); diff --git a/packages/root-cms/ui/components/UserAvatar/UserAvatar.css b/packages/root-cms/ui/components/UserAvatar/UserAvatar.css index 39d5dfdd3..09f0d8efd 100644 --- a/packages/root-cms/ui/components/UserAvatar/UserAvatar.css +++ b/packages/root-cms/ui/components/UserAvatar/UserAvatar.css @@ -4,10 +4,23 @@ transition: filter 0.2s ease; } -/* Desaturate + dim inactive avatars instead of using opacity, which looks off - * when avatars overlap (you'd see the avatar behind it bleed through). */ +/* Grey out inactive avatars: desaturate, then fade toward white via a 50% + * white overlay. An overlay is used instead of opacity on the avatar itself, + * which looks off when avatars overlap (you'd see the avatar behind it bleed + * through). */ .UserAvatar--inactive { - filter: grayscale(1) brightness(1.1); + position: relative; + filter: grayscale(1); +} + +.UserAvatar--inactive::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: #fff; + opacity: 0.5; + pointer-events: none; } .UserAvatar--clickable { From 55a4597ed656ef36550bbbd6f068c315a61294cc Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Tue, 21 Jul 2026 10:59:43 -0700 Subject: [PATCH 6/9] fix: remove white ring between avatar photo and color ring Co-Authored-By: Claude Fable 5 --- .../ui/components/UserAvatar/UserAvatar.css | 2 +- .../ui/components/UserAvatar/UserAvatar.tsx | 14 +++++--------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/root-cms/ui/components/UserAvatar/UserAvatar.css b/packages/root-cms/ui/components/UserAvatar/UserAvatar.css index 09f0d8efd..9c34763d4 100644 --- a/packages/root-cms/ui/components/UserAvatar/UserAvatar.css +++ b/packages/root-cms/ui/components/UserAvatar/UserAvatar.css @@ -28,7 +28,7 @@ } /* When a color ring is drawn, the colored border should sit directly against - * the photo (the white ring is provided by the box-shadow outside it). */ + * the photo (no white border/gap in between). */ .UserAvatar--colorRing { border: none; } diff --git a/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx b/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx index b9e79d3c9..83f3d42e2 100644 --- a/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx +++ b/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx @@ -42,8 +42,8 @@ export interface UserAvatarProps { */ colorRing?: boolean; /** - * Width (in px) of the colored ring drawn when {@link colorRing} is set. A - * white ring of 1px is always drawn just outside it. Defaults to 2. + * Width (in px) of the colored ring drawn when {@link colorRing} is set. + * Defaults to 2. */ ringWidth?: number; } @@ -74,15 +74,11 @@ export function UserAvatar(props: UserAvatarProps) { const avatarColor = getAvatarColor(email || displayName || '?'); // Color-codes users consistently across the UI (à la Google Docs): a ring in - // the user's color directly around the photo, then a 1px white ring just - // outside that. Drawn with box-shadow so it never shifts layout. + // the user's color directly around the photo. Drawn with box-shadow so it + // never shifts layout. const ringWidth = props.ringWidth ?? 2; const ringStyle = props.colorRing - ? { - boxShadow: `0 0 0 ${ringWidth}px ${avatarColor}, 0 0 0 ${ - ringWidth + 1 - }px #fff`, - } + ? {boxShadow: `0 0 0 ${ringWidth}px ${avatarColor}`} : undefined; const avatar = ( From 3f9cd49b52ac7ab3322261a9a4f71d30785d3198 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Tue, 21 Jul 2026 11:06:09 -0700 Subject: [PATCH 7/9] feat: tint the field's input element for presence instead of a field ring Instead of outlining the entire field, decorate the nested input (textarea, input[type=text/datetime/etc.]) with the viewer's color. Co-Authored-By: Claude Fable 5 --- .../ui/components/DocEditor/DocEditor.css | 21 +++++++++++++++++++ .../ui/components/DocEditor/DocEditor.tsx | 20 +++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.css b/packages/root-cms/ui/components/DocEditor/DocEditor.css index 8c1b0c57b..56bbee8ee 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.css +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.css @@ -138,6 +138,27 @@ background: linear-gradient(rgb(255, 248, 197), rgb(255, 248, 197)); } +/* + * Presence highlight: when another user has this field focused, tint the + * field's actual input element (textarea, text input, etc.) in that user's + * color (matching their avatar), à la Google Docs. Note: the focused field is + * always the innermost `.DocEditor__field` (focus tracking resolves the + * closest field element), so these descendant selectors never leak into + * nested child fields (which are themselves `.DocEditor__field` elements with + * their own presence state). + */ +.DocEditor__field--presence + > .DocEditor__field__input + :is( + textarea, + select, + input:not([type='checkbox'], [type='radio'], [type='file']) + ) { + border-color: var(--presence-color); + box-shadow: 0 0 0 1px var(--presence-color); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + .DocEditor__FieldHeader { position: relative; padding-right: 30px; diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 8997c4081..09eec5496 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -45,6 +45,7 @@ import { IconTriangleFilled, } from '@tabler/icons-preact'; import {createContext, RefObject} from 'preact'; +import {CSSProperties} from 'preact/compat'; import { useContext, useEffect, @@ -768,6 +769,17 @@ DocEditor.FieldBody = (props: FieldProps) => { const targeted = deeplink.value === props.deepKey; const ref = useRef(null); + // Presence highlight: tint the field's input element (textarea, text input, + // etc.) to match the (first other) viewer focused on it, à la Google Docs. + // Applied declaratively (rather than via imperative classList/style + // mutations) so it survives re-renders such as the `deeplink-target` class + // toggling on/off. + const fieldViewers = useFieldViewers(props.deepKey); + const presenceColor = useMemo(() => { + const other = fieldViewers.find((viewer) => !viewer.isCurrentUser); + return (other || fieldViewers[0])?.color || ''; + }, [fieldViewers]); + const showFieldHeader = useMemo(() => { if (field.type === 'object') { // Default to the "drawer" variant. @@ -790,8 +802,14 @@ DocEditor.FieldBody = (props: FieldProps) => { className={joinClassNames( 'DocEditor__field', field.deprecated && 'DocEditor__field--deprecated', - targeted && 'deeplink-target' + targeted && 'deeplink-target', + presenceColor && 'DocEditor__field--presence' )} + style={ + presenceColor + ? ({'--presence-color': presenceColor} as CSSProperties) + : undefined + } data-type={field.type} data-level={level} id={props.deepKey} From 6fc7b17cb5305f37a65e1d088d988fea090be724 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Tue, 21 Jul 2026 11:41:17 -0700 Subject: [PATCH 8/9] chore: polish presence indicators - use outline around fields (most fields) - polish the border style --- .../ui/components/DocEditor/DocEditor.css | 36 ++++++++++++------- .../ui/components/DocEditor/DocEditor.tsx | 1 - .../ui/components/UserAvatar/UserAvatar.tsx | 4 +-- .../ui/components/Viewers/Viewers.css | 2 -- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.css b/packages/root-cms/ui/components/DocEditor/DocEditor.css index 56bbee8ee..895969003 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.css +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.css @@ -139,24 +139,36 @@ } /* - * Presence highlight: when another user has this field focused, tint the - * field's actual input element (textarea, text input, etc.) in that user's - * color (matching their avatar), à la Google Docs. Note: the focused field is - * always the innermost `.DocEditor__field` (focus tracking resolves the - * closest field element), so these descendant selectors never leak into - * nested child fields (which are themselves `.DocEditor__field` elements with - * their own presence state). + * Presence highlight: when another user has this field focused, outline the + * field's actual input element (textarea, text input, select, etc.) in that + * user's color (matching their avatar), à la Google Docs. An outline (drawn + * just outside the control's own border) is used rather than tinting the + * border itself, which renders poorly on controls with inner chrome (e.g. + * selects). Note: focusing a container field directly (e.g. an array item + * drawer's toggle) marks the container as the presence field, so the rule is + * limited to leaf fields (no nested `.DocEditor__field`) to avoid outlining + * every input inside the container at once. */ -.DocEditor__field--presence +/* For composite controls (e.g. the multiselect field or the richtext editor), + * outline the outer wrapper rather than the inner input (`[type='search']` is + * excluded below for that reason). */ +.DocEditor__field--presence:not(:has(.DocEditor__field)) > .DocEditor__field__input :is( textarea, select, - input:not([type='checkbox'], [type='radio'], [type='file']) + .LexicalEditor, + .DocEditor__MultiSelectField, + input:not( + [type='checkbox'], + [type='radio'], + [type='file'], + [type='search'] + ) ) { - border-color: var(--presence-color); - box-shadow: 0 0 0 1px var(--presence-color); - transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: 1px solid var(--presence-color); + outline-offset: 1px; + transition: outline-color 0.2s ease; } .DocEditor__FieldHeader { diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 09eec5496..5fd589a24 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -935,7 +935,6 @@ DocEditor.FieldHeaderViewers = (props: {deepKey: string}) => { email={viewer.email} size={18} colorRing - ringWidth={1} className="DocEditor__FieldHeader__viewers__avatar" /> ))} diff --git a/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx b/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx index 83f3d42e2..9aee27485 100644 --- a/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx +++ b/packages/root-cms/ui/components/UserAvatar/UserAvatar.tsx @@ -43,7 +43,7 @@ export interface UserAvatarProps { colorRing?: boolean; /** * Width (in px) of the colored ring drawn when {@link colorRing} is set. - * Defaults to 2. + * Defaults to 1. */ ringWidth?: number; } @@ -76,7 +76,7 @@ export function UserAvatar(props: UserAvatarProps) { // Color-codes users consistently across the UI (à la Google Docs): a ring in // the user's color directly around the photo. Drawn with box-shadow so it // never shifts layout. - const ringWidth = props.ringWidth ?? 2; + const ringWidth = props.ringWidth ?? 1; const ringStyle = props.colorRing ? {boxShadow: `0 0 0 ${ringWidth}px ${avatarColor}`} : undefined; diff --git a/packages/root-cms/ui/components/Viewers/Viewers.css b/packages/root-cms/ui/components/Viewers/Viewers.css index e4e61b3c1..aaa404735 100644 --- a/packages/root-cms/ui/components/Viewers/Viewers.css +++ b/packages/root-cms/ui/components/Viewers/Viewers.css @@ -8,8 +8,6 @@ } .Viewers__avatar { - border: 2px solid var(--mantine-color-body, #fff); - box-sizing: content-box; position: relative; /* Active avatars stack above their overlapping neighbors. */ z-index: 1; From e493c55d7715f1c114d94f225328e2666069ebb4 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Tue, 21 Jul 2026 11:46:37 -0700 Subject: [PATCH 9/9] chore: fix untoggle from collapsing the drawers --- .../ui/components/DocEditor/DocEditor.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 5fd589a24..d5bb85f1a 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -2051,6 +2051,17 @@ interface ArrayFieldItemProps { DocEditor.ArrayFieldItem = (props: ArrayFieldItemProps) => { const itemDeepKey = `${props.parentDeepKey}.${props.itemKey}`; + // The drawer is natively toggled by the user, so `initialOpen` only ever + // forces it open (e.g. newly-added items or deeplink targets) — never + // closed. Without this, clearing the deeplink would re-render with a falsy + // `initialOpen` and collapse a drawer the user had open. + const [open, setOpen] = useState(props.initialOpen); + useEffect(() => { + if (props.initialOpen) { + setOpen(true); + } + }, [props.initialOpen]); + return ( {
{ - if ((e.target as HTMLDetailsElement).open) { + const isOpen = (e.target as HTMLDetailsElement).open; + setOpen(isOpen); + if (isOpen) { requestHighlightNode(itemDeepKey, {scroll: true}); } else { requestHighlightNode(null);