From c9b42494811a0fa79d681d9045f3c05ba340e40f Mon Sep 17 00:00:00 2001 From: Lycoon Date: Sun, 10 May 2026 03:18:43 +0200 Subject: [PATCH] fixed is-empty css on cloud sync, collab caret, added durable object observability --- .../project/CollaboratorsSettings.tsx | 28 +------ src/context/ProjectContext.tsx | 19 ++++- src/lib/cloud/protocol.ts | 22 +++-- src/lib/cloud/room.ts | 84 +++++++++---------- src/lib/cloud/utils.ts | 41 ++++----- src/lib/cloud/wrangler.toml | 4 + src/lib/editor/use-document-editor.ts | 15 ++-- .../extensions/placeholder-extension.ts | 24 +----- styles/scriptio.css | 43 +++++++++- 9 files changed, 154 insertions(+), 126 deletions(-) diff --git a/components/dashboard/project/CollaboratorsSettings.tsx b/components/dashboard/project/CollaboratorsSettings.tsx index 05f4b053..1270f6b7 100644 --- a/components/dashboard/project/CollaboratorsSettings.tsx +++ b/components/dashboard/project/CollaboratorsSettings.tsx @@ -3,11 +3,11 @@ import { useContext, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; -import { useCookieUser, useIsPro, useProjectCollaborators, useProjectInvites, useProjectMembership } from "@src/lib/utils/hooks"; +import { useCookieUser, useProjectCollaborators, useProjectInvites, useProjectMembership } from "@src/lib/utils/hooks"; import { CookieUser } from "@src/lib/utils/types"; import { ProjectRole } from "../../../src/generated/client/browser"; import { Collaborator, ProjectInvite, ProjectMembershipPayload } from "@src/server/repository/project-repository"; -import { Info, Lock } from "lucide-react"; +import { Info } from "lucide-react"; import form from "./../../utils/Form.module.css"; import shared from "./ProjectSettings.module.css"; @@ -16,7 +16,6 @@ import { deleteInvite, inviteCollaborator, kickCollaborator, updateMemberRole } import * as Roles from "@src/lib/utils/roles"; import { DashboardContext } from "@src/context/DashboardContext"; -import Link from "next/link"; const MAX_COLLABORATORS = 5; @@ -26,7 +25,6 @@ const CollaboratorsSettings = () => { const { invites, mutate: mutateInvites } = useProjectInvites(membership?.project.id); const { collaborators, mutate: mutateCollaborators } = useProjectCollaborators(membership?.project.id); const { user } = useCookieUser(); - const { isPro } = useIsPro(); const slots = useMemo(() => { if (!membership) return []; @@ -103,15 +101,6 @@ const CollaboratorsSettings = () => {

{t("teamHelp")}

- {/* Pro gate banner */} - {!isPro && ( -
- - {t("proRequired")} - {t("upgrade")} -
- )} - {/* Project Collaborators */}
{slots.map((slot) => { @@ -137,7 +126,7 @@ const CollaboratorsSettings = () => { ); case "EMPTY": return ( - + ); default: return null; @@ -257,10 +246,9 @@ const InviteSlot = ({ data, membership, mutateInvites }: InviteSlotProps) => { interface EmptySlotProps { membership: ProjectMembershipPayload; mutateInvites: () => void; - isPro: boolean; } -const EmptySlot = ({ membership, mutateInvites, isPro }: EmptySlotProps) => { +const EmptySlot = ({ membership, mutateInvites }: EmptySlotProps) => { const t = useTranslations("collaborators"); const [email, setEmail] = useState(""); @@ -273,14 +261,6 @@ const EmptySlot = ({ membership, mutateInvites, isPro }: EmptySlotProps) => { } }; - if (!isPro) { - return ( -
- {t("proRequiredInvite")} -
- ); - } - return (
diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 74e4c638..f13d1f2b 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -370,14 +370,29 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = // revalidation of the membership endpoint. useEffect(() => { if (!provider) return; - const handler = (newRole: string) => { + const handler = async (newRole: string) => { setProject((prev) => (prev ? { ...prev, role: newRole as ProjectRole } : prev)); + if (project?.project.id) { + try { + const res = await fetch(`/api/projects/${project.project.id}/cloud-token`); + if (res.ok) { + const { token } = (await res.json()) as { token: string }; + if (token) { + // Update token silently so future reconnects use the new role + // We don't force reconnect because the DO already updated our active session + await provider.updateToken(token, false); + } + } + } catch (e) { + console.warn("Failed to fetch new token on role change", e); + } + } }; provider.on("role-changed", handler); return () => { provider.off("role-changed", handler); }; - }, [provider]); + }, [provider, project?.project.id]); const updateScreenplay = useCallback((newScreenplay: Screenplay) => { setScreenplay(newScreenplay); diff --git a/src/lib/cloud/protocol.ts b/src/lib/cloud/protocol.ts index 261ba4b2..07ae86d9 100644 --- a/src/lib/cloud/protocol.ts +++ b/src/lib/cloud/protocol.ts @@ -42,9 +42,10 @@ export function handleProtocolMessage(room: ProjectRoom, fullMessage: Uint8Array // apply them, so the rest of the packet stays parseable. if (isReadOnly) { decoding.readVarUint8Array(decoder); - console.warn( - `[Room] Dropped doc write from viewer ${session?.userId ?? "?"}`, - ); + console.warn(JSON.stringify({ + event: "dropped_write_from_viewer", + userId: session?.userId ?? "?" + })); } else if (subType === SYNC_STEP_2) { syncProtocol.readSyncStep2(decoder, room.doc, sender); } else { @@ -52,12 +53,15 @@ export function handleProtocolMessage(room: ProjectRoom, fullMessage: Uint8Array } } else { // Unknown sub-type — bail to avoid mis-aligning the decoder. - console.warn(`[Room] Unknown sync sub-message type: ${subType}`); + console.warn(JSON.stringify({ + event: "unknown_sync_sub_message_type", + subType + })); break; } } } catch (e) { - console.error("[Room] Error reading sync message:", e); + console.error(JSON.stringify({ event: "error_reading_sync_message", error: String(e) })); } // If there's a response to send (e.g., SyncStep2 in response to SyncStep1), @@ -96,11 +100,15 @@ export function handleProtocolMessage(room: ProjectRoom, fullMessage: Uint8Array break; default: - console.warn(`[Room] Unknown message type: ${messageType}`); + console.warn(JSON.stringify({ event: "unknown_message_type", messageType })); break; } } catch (e) { - console.error(`[Room] Protocol error for message type ${messageType}:`, e); + console.error(JSON.stringify({ + event: "protocol_error", + messageType, + error: String(e) + })); // For non-awareness messages, still try to broadcast (might be important) if (messageType !== 1) { room.broadcast(fullMessage, sender); diff --git a/src/lib/cloud/room.ts b/src/lib/cloud/room.ts index 8d359e1b..ac9156e7 100644 --- a/src/lib/cloud/room.ts +++ b/src/lib/cloud/room.ts @@ -179,16 +179,14 @@ export class ProjectRoom extends DurableObject { this.userConnections.set(attachment.userId, ws); } if (hibernatedSockets.length > 0) { - console.log( - `[Room] Restored ${this.sessions.size} session(s) from ${hibernatedSockets.length} hibernated WebSocket(s)`, - ); + console.log(JSON.stringify({ event: "room_restore", hibernatedSockets: hibernatedSockets.length, restoredSessions: this.sessions.size })); // Awareness state was lost when the DO hibernated. Ask all // restored clients to re-broadcast their awareness so we can // rebuild room.awareness from scratch. this.broadcastAwarenessRequest(); } - console.log("[Room] Initialized"); + console.log(JSON.stringify({ event: "room_initialized" })); } /** @@ -219,10 +217,7 @@ export class ProjectRoom extends DurableObject { break; case "migrated": this.docVersion = outcome.to; - console.log( - `[Room] Migrated doc from v${outcome.from} to v${outcome.to} ` + - `(${outcome.appliedSteps.length} step${outcome.appliedSteps.length === 1 ? "" : "s"})`, - ); + console.log(JSON.stringify({ event: "document_migrated", fromVersion: outcome.from, toVersion: outcome.to, steps: outcome.appliedSteps.length })); // Persist the migrated state immediately so a restart doesn't replay. await this.saveToDisk(); break; @@ -231,18 +226,12 @@ export class ProjectRoom extends DurableObject { // Refuse new connections until the worker is upgraded. this.docMigrationFailed = true; this.docVersion = outcome.storedVersion; - console.error( - `[Room] Doc at v${outcome.storedVersion} but worker only supports v${outcome.expected}. ` + - `Worker is out of date — refusing connections.`, - ); + console.error(JSON.stringify({ event: "document_migration_future_version", storedVersion: outcome.storedVersion, expectedVersion: outcome.expected, message: "Worker is out of date — refusing connections." })); break; case "failed": this.docMigrationFailed = true; this.docVersion = outcome.from; - console.error( - `[Room] Doc migration failed at step v${outcome.failedAt} ` + `(stored v${outcome.from}):`, - outcome.error, - ); + console.error(JSON.stringify({ event: "document_migration_failed", failedAtStep: outcome.failedAt, storedVersion: outcome.from, error: String(outcome.error) })); break; } } @@ -305,12 +294,12 @@ export class ProjectRoom extends DurableObject { await (this.env as Env).SNAPSHOTS.put(key, state, { customMetadata: { type: "auto" }, }); - console.log(`[Room] Snapshot saved to R2: ${key}`); + console.log(JSON.stringify({ event: "snapshot_saved", key })); // Run retention cleanup await this.cleanupAutoSaves(); } catch (e) { - console.error("[Room] Failed to snapshot to R2:", e); + console.error(JSON.stringify({ event: "snapshot_failed", error: String(e) })); // Re-mark dirty so next alarm retries this.isDirty = true; this.scheduleSnapshotAlarm(); @@ -381,7 +370,7 @@ export class ProjectRoom extends DurableObject { // Batch delete (R2 supports up to 1000 keys per delete) if (toDelete.length > 0) { await (this.env as Env).SNAPSHOTS.delete(toDelete); - console.log(`[Room] Retention cleanup: deleted ${toDelete.length} auto-saves`); + console.log(JSON.stringify({ event: "retention_cleanup", deletedCount: toDelete.length })); } } @@ -419,9 +408,7 @@ export class ProjectRoom extends DurableObject { const timeSinceActivity = now - session.lastActivity; if (timeSinceActivity > STALE_AWARENESS_TIMEOUT_MS) { - console.log( - `[Room] Session for user ${session.userId} is stale (${timeSinceActivity}ms since activity)`, - ); + console.log(JSON.stringify({ event: "stale_session", userId: session.userId, timeSinceActivity })); staleClientIds.push(...session.clientIds); staleSockets.push(socket); } @@ -470,7 +457,7 @@ export class ProjectRoom extends DurableObject { } } - console.log(`[Room] Cleaned up ${staleClientIds.length} stale awareness states`); + console.log(JSON.stringify({ event: "cleaned_stale_awareness", count: staleClientIds.length })); } } @@ -567,7 +554,7 @@ export class ProjectRoom extends DurableObject { this.ctx.storage.sql.exec("INSERT OR IGNORE INTO blacklist (user_id) VALUES (?);", userId); - console.log(`[Room] Blacklisted user ${userId}`); + console.log(JSON.stringify({ event: "user_blacklisted", userId })); return new Response(`User ${userId} blacklisted.`, { status: 200 }); } @@ -583,7 +570,7 @@ export class ProjectRoom extends DurableObject { this.ctx.storage.sql.exec("DELETE FROM blacklist WHERE user_id = ?;", userId); } - console.log(`[Room] Allowed user ${userId}`); + console.log(JSON.stringify({ event: "user_allowed", userId })); return new Response(`User ${userId} allowed.`, { status: 200 }); } @@ -612,11 +599,11 @@ export class ProjectRoom extends DurableObject { encoding.writeVarString(encoder, role); socket.send(encoding.toUint8Array(encoder)); } catch (e) { - console.warn(`[Room] Failed to push role-update to ${userId}:`, e); + console.error(JSON.stringify({ event: "role_update_push_failed", userId, error: String(e) })); } } - console.log(`[Room] Role updated for user ${userId} -> ${role}`); + console.log(JSON.stringify({ event: "role_updated", userId, role })); return new Response(`User ${userId} role updated.`, { status: 200 }); } @@ -670,7 +657,7 @@ export class ProjectRoom extends DurableObject { const clientVersionParam = url.searchParams.get("clientVersion"); const clientVersion = clientVersionParam !== null ? Number(clientVersionParam) : NaN; if (Number.isFinite(clientVersion) && clientVersion < this.docVersion) { - console.log(`[Room] Rejecting stale client v${clientVersion} (doc at v${this.docVersion})`); + console.warn(JSON.stringify({ event: "client_rejected_stale", clientVersion, docVersion: this.docVersion })); try { server.close(4006, `Stale client: update to access v${this.docVersion}`); } catch {} @@ -709,7 +696,7 @@ export class ProjectRoom extends DurableObject { server.send(encoding.toUint8Array(awarenessEncoder)); } - console.log(`[Room] User ${userId} connected. Total sessions: ${this.sessions.size}`); + console.log(JSON.stringify({ event: "user_connected", userId, totalSessions: this.sessions.size })); // Opportunistic cleanup on connect — a new client arriving is the // best moment to drop awareness for clients that quietly went away. @@ -793,7 +780,7 @@ export class ProjectRoom extends DurableObject { customMetadata: { type: "manual", name }, }); - console.log(`[Room] Manual save created: ${name}`); + console.log(JSON.stringify({ event: "manual_save_created", name })); const entry: SaveEntry = { key, type: "manual", name, date: timestamp, size: state.byteLength }; return Response.json(entry, { status: 201 }); @@ -856,7 +843,7 @@ export class ProjectRoom extends DurableObject { this.sessions.clear(); this.userConnections.clear(); - console.log(`[Room] Restored from: ${key}`); + console.log(JSON.stringify({ event: "restored_from_save", key })); return new Response("Restored", { status: 200 }); } @@ -882,7 +869,7 @@ export class ProjectRoom extends DurableObject { }); await (this.env as Env).SNAPSHOTS.delete(key); - console.log(`[Room] Renamed save: ${key} -> ${name}`); + console.log(JSON.stringify({ event: "save_renamed", key, name })); return new Response("Renamed", { status: 200 }); } @@ -893,7 +880,7 @@ export class ProjectRoom extends DurableObject { } await (this.env as Env).SNAPSHOTS.delete(key); - console.log(`[Room] Deleted save: ${key}`); + console.log(JSON.stringify({ event: "save_deleted", key })); return new Response("Deleted", { status: 200 }); } @@ -921,17 +908,17 @@ export class ProjectRoom extends DurableObject { const fullDocState = Y.encodeStateAsUpdate(this.doc); this.ctx.storage.sql.exec("INSERT OR REPLACE INTO project (id, data) VALUES (1, ?);", fullDocState); this.saveTimeout = null; - console.log("[Room] Document saved to disk"); + console.log(JSON.stringify({ event: "document_saved" })); } catch (e) { - console.error("[Room] Failed to save document:", e); + console.error(JSON.stringify({ event: "document_save_failed", error: String(e) })); } } - async webSocketClose(ws: WebSocket): Promise { + async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { const session = this.sessions.get(ws); if (session) { - console.log(`[Room] User ${session.userId} disconnected`); + console.log(JSON.stringify({ event: "websocket_close", userId: session.userId })); if (session.clientIds.size > 0) { const clientIds = Array.from(session.clientIds); @@ -942,7 +929,7 @@ export class ProjectRoom extends DurableObject { // Broadcast removal to remaining clients this.broadcastAwarenessRemoval(clientIds, ws); - console.log(`[Room] Removed awareness for clients: ${clientIds.join(", ")}`); + console.log(JSON.stringify({ event: "awareness_removed", clientIds })); } // Only delete from userConnections if this is the active entry @@ -952,11 +939,24 @@ export class ProjectRoom extends DurableObject { } this.sessions.delete(ws); - console.log(`[Room] Remaining sessions: ${this.sessions.size}`); + console.log(JSON.stringify({ event: "session_count_update", count: this.sessions.size })); } async webSocketError(ws: WebSocket, error: unknown): Promise { - console.error("[Room] WebSocket error:", error); + const errStr = String(error); + if (errStr.includes("Network connection lost") || errStr.includes("WebSocket disconnected") || errStr.includes("1006") || errStr.includes("1005")) { + console.log(JSON.stringify({ + event: "websocket_disconnect", + level: "info", + reason: "idle or network connection lost", + error: errStr + })); + } else { + console.error(JSON.stringify({ + event: "websocket_error", + error: errStr + })); + } // The close handler will clean up } @@ -979,7 +979,7 @@ export class ProjectRoom extends DurableObject { const encoder = encoding.createEncoder(); encoding.writeVarUint(encoder, 3); // Message type 3: messageQueryAwareness this.broadcast(encoding.toUint8Array(encoder), excludeSocket); - console.log("[Room] Sent awareness request to existing clients"); + console.log(JSON.stringify({ event: "awareness_request_sent" })); } /** @@ -991,7 +991,7 @@ export class ProjectRoom extends DurableObject { try { client.send(message); } catch (e) { - console.error(`[Room] Failed to send to client ${session.userId}:`, e); + console.error(JSON.stringify({ event: "send_to_client_failed", userId: session.userId, error: String(e) })); } } } diff --git a/src/lib/cloud/utils.ts b/src/lib/cloud/utils.ts index 2edded27..ea742242 100644 --- a/src/lib/cloud/utils.ts +++ b/src/lib/cloud/utils.ts @@ -421,10 +421,11 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { } /** - * Update the authentication token and reconnect. - * This allows refreshing expired tokens without destroying the provider. + * Update the authentication token. + * @param newToken The new JWT token + * @param forceReconnect Whether to immediately disconnect and reconnect using the new token (default: true) */ - public async updateToken(newToken: string): Promise { + public async updateToken(newToken: string, forceReconnect: boolean = true): Promise { if (this.isDestroyed) { console.warn("[WS] Cannot update token on destroyed provider"); return; @@ -435,26 +436,26 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { return; } - console.log("[WS] Updating token and reconnecting..."); - // Snapshot current params so we can roll back if reconnect fails. - // Without this, a failed reconnect would leave the provider holding - // a new (possibly invalid) token with no active connection. const previousParams = { ...this.params }; - try { - // Update params with new token - this.params = { - ...this.params, - token: newToken, - }; - await this.reconnect(); - } catch (e) { - // Restore original params — the old token stays in effect so that - // a future updateToken() call or reconnect attempt can succeed. - this.params = previousParams; - console.warn("[WS] Failed to update token, params restored to previous state", e); - throw e; + // Update params with new token + this.params = { + ...this.params, + token: newToken, + }; + + if (forceReconnect) { + console.log("[WS] Updating token and reconnecting..."); + try { + await this.reconnect(); + } catch (e) { + // Restore original params — the old token stays in effect so that + // a future updateToken() call or reconnect attempt can succeed. + this.params = previousParams; + console.warn("[WS] Failed to update token, params restored to previous state", e); + throw e; + } } } diff --git a/src/lib/cloud/wrangler.toml b/src/lib/cloud/wrangler.toml index 37e7f2db..400c38d3 100644 --- a/src/lib/cloud/wrangler.toml +++ b/src/lib/cloud/wrangler.toml @@ -4,6 +4,10 @@ main = "index.ts" compatibility_date = "2025-12-08" compatibility_flags = ["nodejs_compat"] +[observability.logs] +enabled = true +invocation_logs = false + # Migrations are global to the script class [[migrations]] tag = "v1" diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index ae244132..81a9c31b 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -305,15 +305,18 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum provider, user: userInfo, render: (user: { color: string; name: string }) => { + // Render with no DOM children. The username label is rendered + // via a ::before pseudo-element (see styles/scriptio.css), + // so there is no text node Firefox can place the local HTML + // caret into when the user clicks an empty node containing + // this remote caret. const caret = document.createElement("span"); caret.classList.add("collab-caret"); caret.style.borderLeft = `2px solid ${user.color}`; - const label = document.createElement("div"); - label.classList.add("collab-caret-label"); - label.style.backgroundColor = user.color; - label.innerText = user.name; - label.contentEditable = "false"; - caret.appendChild(label); + caret.style.setProperty("--collab-caret-color", user.color); + // JSON.stringify yields a CSS-safe quoted string (escapes \ and "). + caret.style.setProperty("--collab-caret-name", JSON.stringify(user.name)); + caret.contentEditable = "false"; return caret; }, }), diff --git a/src/lib/screenplay/extensions/placeholder-extension.ts b/src/lib/screenplay/extensions/placeholder-extension.ts index 08fc9b6d..bac529a5 100644 --- a/src/lib/screenplay/extensions/placeholder-extension.ts +++ b/src/lib/screenplay/extensions/placeholder-extension.ts @@ -135,33 +135,13 @@ export const Placeholder = Extension.create({ const oldAnchor = oldState.selection.anchor const newAnchor = newState.selection.anchor - // Check if we need to recompute: - // 1. Anchor moved to a different node - // 2. A node became empty or non-empty - const anchorNodeChanged = oldState.doc.resolve(oldAnchor).parent !== newState.doc.resolve(newAnchor).parent - if (tr.docChanged) { - // Check if the node at the cursor changed emptiness - try { - const newNode = newState.doc.resolve(newAnchor).parent - const wasEmpty = !oldState.doc.resolve(oldAnchor).parent.content.size - const isEmpty = !newNode.content.size - - // If emptiness changed or anchor moved to different node, recompute - if (wasEmpty !== isEmpty || anchorNodeChanged) { - return computePlaceholderDecorations(newState.doc, newAnchor) - } - } catch { - // Position resolution failed, recompute to be safe - return computePlaceholderDecorations(newState.doc, newAnchor) - } - - // Simple text edit in non-empty node — just remap positions (O(log n)) - return oldDecorations.map(tr.mapping, newState.doc) + return computePlaceholderDecorations(newState.doc, newAnchor) } // Selection-only change: recompute if anchor moved to different node // (showOnlyCurrent mode needs this, but also hasAnchor attribute changes) + const anchorNodeChanged = oldState.doc.resolve(oldAnchor).parent !== newState.doc.resolve(newAnchor).parent if (anchorNodeChanged) { return computePlaceholderDecorations(newState.doc, newAnchor) } diff --git a/styles/scriptio.css b/styles/scriptio.css index ab047c03..a6f359ab 100644 --- a/styles/scriptio.css +++ b/styles/scriptio.css @@ -103,6 +103,34 @@ height: var(--line-height); } + /* Stabilize the absolute caret's containing block. Without this, the caret + resolves up to .ProseMirror — and during a backspace that destroys the + paragraph it lives in, y-prosemirror briefly remaps the caret's head + position to 0 before awareness syncs, painting it for one frame at the + editor's leftmost edge (the very start of the document). Pinning the + containing block to whichever paragraph the caret currently sits in + keeps the absolute position bounded to that paragraph's box. */ + p:has(.collab-caret), + .dual_dialogue_column p:has(.collab-caret) { + position: relative; + } + + /* Empty non-parenthetical paragraphs additionally need overflow:visible so + the caret's pseudo-element username label (anchored above the line via + top: -1.8em) isn't clipped. Parenthetical empties keep overflow:hidden + to clip the trailing line box that would push their `)` to a second + line; the label is sacrificed there as an acceptable trade. */ + .is-empty:has(.collab-caret):not(.parenthetical) { + overflow: visible; + } + + /* Defensive: the widget should always live inside a paragraph. If a + transaction ever briefly attaches it directly to .ProseMirror (e.g. + during a structural restructure), don't paint it. */ + > .collab-caret { + display: none; + } + .action { text-align: var(--action-align, left) !important; font-weight: var(--action-weight, normal); @@ -308,10 +336,19 @@ pointer-events: none; user-select: none; -webkit-user-select: none !important; - transition: transform 0.1s ease-out; + -moz-user-select: none !important; } - .collab-caret-label { + /* Username label rendered as a pseudo-element rather than a child
. + Pseudo-elements aren't in the DOM, so they cannot host a text cursor; + this prevents Firefox from placing the local HTML caret into the + label's text node when the local user clicks an empty node hosting a + remote caret. The color and name come from custom properties set + inline by the cursor render function. */ + .collab-caret::before { + content: var(--collab-caret-name, ""); + background-color: var(--collab-caret-color, currentColor); + position: absolute; top: -1.8em; left: -0.5em; @@ -322,7 +359,7 @@ border-radius: 1em; line-height: normal; - /* Prevent the collaboration caret from inheriting parent styles, such as with scene headings (uppercase & bold) */ + /* Prevent inheritance of parent styles (scene headings are uppercase & bold). */ text-transform: none !important; font-weight: 400 !important; font-style: normal !important;