@startsimpli/ui 0.4.143 → 0.4.144

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * TrackedField — one text field under track changes: the rendered Read view
5
+ * or the source editor, show/hide changes, the Current / Final / Original
6
+ * views, and the changes rail beside it (bd startsim-whwxd.3; design
7
+ * startsim-xyn25 section d). The page composer, so every foundry that tracks
8
+ * a field mounts the same thing instead of re-assembling the parts.
9
+ *
10
+ * WHAT THE HOST OWNS, and why. Persistence: `onChange` reports edits made in
11
+ * the source view's EDITING mode, and the host saves them with its own
12
+ * conditional save. The accept precondition: `runAccept` hands the host a
13
+ * function that accepts at a version it chooses, so the host can close its
14
+ * write gate, flush real unsaved typing, accept at its LIVE version and fold
15
+ * the answer (the new text and version) before anything else writes. A host
16
+ * that skips the fold would let its next save PATCH the old text back over
17
+ * the accept.
18
+ *
19
+ * WHAT `value` AND `version` MUST BE: the text as stored at `version`, the
20
+ * version the host's next write will assert. Not unsaved keystrokes (every
21
+ * offset would describe other text) and not a version loaded once and never
22
+ * advanced (every read after the first save would be held back as stale).
23
+ *
24
+ * LAYOUT on its own container, never the viewport (startsim-jkkn7.12): the
25
+ * rail sits beside the text when the field is wide, under it when narrow.
26
+ */
27
+ import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState, type ReactNode, type Ref } from 'react';
28
+
29
+ import { ChangesRail } from './ChangesRail';
30
+ import { applyOpenSuggestions } from './final-text';
31
+ import { TrackedReadView } from './TrackedReadView';
32
+ import { TrackedTextEditor, type TrackedTextEditorHandle } from './TrackedTextEditor';
33
+ import { useTrackChanges } from './useTrackChanges';
34
+ import type { AcceptResponseWire, SelectionAnchor, TrackChangesClient } from './types';
35
+
36
+ export type TrackedFieldSurface = 'read' | 'source';
37
+ export type TrackedFieldView = 'current' | 'final' | 'original';
38
+
39
+ export interface TrackedFieldProps {
40
+ client: TrackChangesClient;
41
+ field: string;
42
+ /** "Blog post". */
43
+ label: string;
44
+ language?: 'markdown' | 'plain';
45
+ /** The stored text at `version`. */
46
+ value: string;
47
+ version: number | null;
48
+ currentActorSub: string | null;
49
+ /** False: suggest and comment only (view-only reviewers, Quinn Q5). */
50
+ canEdit?: boolean;
51
+ /** Source view, editing mode: the whole string after each edit. */
52
+ onChange?: (value: string) => void;
53
+ /** See the file header. Default: accept at `version`. */
54
+ runAccept?: (accept: (expectedVersion: number) => Promise<AcceptResponseWire>) => Promise<unknown>;
55
+ defaultSurface?: TrackedFieldSurface;
56
+ /** Extra controls for the header (word count, save state). */
57
+ headerExtra?: ReactNode;
58
+ /** The Read view's element, for a host that paints its own highlights. */
59
+ readViewRef?: Ref<HTMLDivElement>;
60
+ className?: string;
61
+ }
62
+
63
+ export interface TrackedFieldHandle {
64
+ /** Show the source or the rendered text. */
65
+ setSurface: (s: TrackedFieldSurface) => void;
66
+ /** Post a pending suggestion now (before a navigation, say). */
67
+ flush: () => Promise<void>;
68
+ }
69
+
70
+ const seg = (on: boolean) =>
71
+ [
72
+ 'inline-flex items-center px-2 py-1 text-xs transition-colors',
73
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
74
+ on ? 'bg-primary text-primary-foreground' : 'bg-transparent text-muted-foreground hover:bg-muted',
75
+ ].join(' ');
76
+
77
+ export const TrackedField = forwardRef<TrackedFieldHandle, TrackedFieldProps>(function TrackedField(
78
+ {
79
+ client,
80
+ field,
81
+ label,
82
+ language = 'markdown',
83
+ value,
84
+ version,
85
+ currentActorSub,
86
+ canEdit = true,
87
+ onChange,
88
+ runAccept,
89
+ defaultSurface,
90
+ headerExtra,
91
+ readViewRef,
92
+ className,
93
+ },
94
+ ref,
95
+ ) {
96
+ const tc = useTrackChanges({ client, field, value, version });
97
+ const [surface, setSurface] = useState<TrackedFieldSurface>(defaultSurface ?? (language === 'markdown' ? 'read' : 'source'));
98
+ const [view, setView] = useState<TrackedFieldView>('current');
99
+ const [showChanges, setShowChanges] = useState(true);
100
+ const [showAuthors, setShowAuthors] = useState(false);
101
+ const [active, setActive] = useState<string | null>(null);
102
+ const [selection, setSelection] = useState<SelectionAnchor | null>(null);
103
+ const editorRef = useRef<TrackedTextEditorHandle>(null);
104
+
105
+ useImperativeHandle(
106
+ ref,
107
+ () => ({
108
+ setSurface,
109
+ flush: async () => {
110
+ await editorRef.current?.flush();
111
+ },
112
+ }),
113
+ [],
114
+ );
115
+
116
+ const final = useMemo(() => applyOpenSuggestions(value, tc.suggestions), [value, tc.suggestions]);
117
+
118
+ const focusItem = useCallback(
119
+ (id: string) => {
120
+ setActive(id);
121
+ if (surface === 'source') editorRef.current?.focusMark(id);
122
+ },
123
+ [surface],
124
+ );
125
+
126
+ const accept = tc.actions.accept;
127
+ const onAccept = accept
128
+ ? async (ids: string[]) => {
129
+ const doAccept = (v: number) => accept(ids, v);
130
+ if (runAccept) await runAccept(doAccept);
131
+ else if (version != null) await doAccept(version);
132
+ else throw new Error('This record has no version yet, so nothing can be accepted.');
133
+ }
134
+ : undefined;
135
+
136
+ const onComment = tc.actions.comment
137
+ ? async (body: string, mentions: string[], sel: SelectionAnchor | null) => {
138
+ const span = sel && sel.to > sel.from && version != null;
139
+ await tc.actions.comment!({
140
+ body,
141
+ field,
142
+ ...(mentions.length ? { mentions } : {}),
143
+ ...(span ? { from: sel!.from, to: sel!.to, base_version: version! } : {}),
144
+ });
145
+ setSelection(null);
146
+ }
147
+ : undefined;
148
+
149
+ const marked = view === 'current';
150
+ const readSource = view === 'final' ? final.text : value;
151
+ const nSugg = tc.suggestions.filter((s) => s.status === 'open').length;
152
+
153
+ return (
154
+ <section
155
+ data-testid={`tracked-field-${field}`}
156
+ aria-label={label}
157
+ className={['@container rounded-lg border border-border bg-card', className].filter(Boolean).join(' ')}
158
+ >
159
+ <div className="flex flex-col gap-2 border-b border-border px-4 py-2 @lg:flex-row @lg:flex-wrap @lg:items-center @lg:justify-between">
160
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</h3>
161
+ <div className="flex flex-wrap items-center gap-2">
162
+ {headerExtra}
163
+ <div role="group" aria-label={`${label}: which text`} className="inline-flex overflow-hidden rounded-md border border-border">
164
+ <button type="button" className={seg(view === 'current')} aria-pressed={view === 'current'} onClick={() => setView('current')}>
165
+ Current
166
+ </button>
167
+ <button
168
+ type="button"
169
+ className={seg(view === 'final')}
170
+ aria-pressed={view === 'final'}
171
+ onClick={() => setView('final')}
172
+ title="As it would read with every open suggestion accepted"
173
+ >
174
+ Final
175
+ </button>
176
+ <button
177
+ type="button"
178
+ className={seg(view === 'original')}
179
+ aria-pressed={view === 'original'}
180
+ onClick={() => setView('original')}
181
+ title="As stored, without any suggestion"
182
+ >
183
+ Original
184
+ </button>
185
+ </div>
186
+ {marked && (
187
+ <div role="group" aria-label={`${label}: surface`} className="inline-flex overflow-hidden rounded-md border border-border">
188
+ <button type="button" className={seg(surface === 'read')} aria-pressed={surface === 'read'} onClick={() => setSurface('read')}>
189
+ Read
190
+ </button>
191
+ <button type="button" className={seg(surface === 'source')} aria-pressed={surface === 'source'} onClick={() => setSurface('source')}>
192
+ {canEdit ? 'Edit' : 'Suggest'}
193
+ </button>
194
+ </div>
195
+ )}
196
+ {marked && (
197
+ <button type="button" className={`${seg(showChanges)} rounded-md border border-border`} aria-pressed={showChanges} onClick={() => setShowChanges((v) => !v)}>
198
+ Show changes
199
+ </button>
200
+ )}
201
+ {marked && surface === 'read' && (
202
+ <button type="button" className={`${seg(showAuthors)} rounded-md border border-border`} aria-pressed={showAuthors} onClick={() => setShowAuthors((v) => !v)}>
203
+ Show authors
204
+ </button>
205
+ )}
206
+ </div>
207
+ </div>
208
+
209
+ <div className="grid gap-4 p-4 @3xl:grid-cols-[minmax(0,1fr)_18rem]">
210
+ <div className="min-w-0">
211
+ {!marked ? (
212
+ <>
213
+ <p className="mb-2 text-xs text-muted-foreground" role="status">
214
+ {view === 'final'
215
+ ? `Final: as it would read with ${final.applied.length} open ${final.applied.length === 1 ? 'suggestion' : 'suggestions'} accepted.` +
216
+ (final.skipped.length
217
+ ? ` ${final.skipped.length} overlapping ${final.skipped.length === 1 ? 'suggestion is' : 'suggestions are'} not included.`
218
+ : '')
219
+ : `Original: as stored, without ${nSugg === 1 ? 'the open suggestion' : `the ${nSugg} open suggestions`}.`}
220
+ </p>
221
+ <TrackedReadView ref={readViewRef} source={readSource} language={language} showChanges={false} />
222
+ </>
223
+ ) : surface === 'read' ? (
224
+ <TrackedReadView
225
+ ref={readViewRef}
226
+ source={value}
227
+ language={language}
228
+ blame={tc.blame}
229
+ suggestions={tc.suggestions}
230
+ threads={tc.threads}
231
+ showChanges={showChanges}
232
+ showAuthors={showAuthors}
233
+ activeId={active}
234
+ onActivate={(id) => {
235
+ setActive(id);
236
+ const item = document.querySelector<HTMLElement>(`[data-testid="tracked-field-${field}"] [data-item-id="${id}"]`);
237
+ item?.focus();
238
+ }}
239
+ onSelectionChange={setSelection}
240
+ />
241
+ ) : (
242
+ <TrackedTextEditor
243
+ ref={editorRef}
244
+ label={label}
245
+ language={language}
246
+ value={value}
247
+ version={version}
248
+ canEdit={canEdit}
249
+ currentActorSub={currentActorSub}
250
+ blame={tc.blame}
251
+ suggestions={tc.suggestions}
252
+ threads={tc.threads}
253
+ notices={[]}
254
+ show={{ suggestions: showChanges, comments: showChanges }}
255
+ showAuthors={showAuthors}
256
+ onShowAuthorsChange={setShowAuthors}
257
+ onChange={onChange}
258
+ onPropose={tc.propose}
259
+ onActiveChange={(m) => setActive(m && m.kind !== 'blame' && m.kind !== 'local' ? m.id : null)}
260
+ onSelectionChange={setSelection}
261
+ />
262
+ )}
263
+ {tc.notices.map((n) => (
264
+ <p key={n} className="mt-2 text-xs text-muted-foreground">
265
+ {n}
266
+ </p>
267
+ ))}
268
+ </div>
269
+ <ChangesRail
270
+ fieldLabel={label}
271
+ suggestions={tc.suggestions}
272
+ threads={tc.threads}
273
+ currentActorSub={currentActorSub}
274
+ canAccept={canEdit}
275
+ activeId={active}
276
+ onFocusItem={focusItem}
277
+ onNextChange={surface === 'source' && marked ? () => editorRef.current?.nextChange() : undefined}
278
+ onPrevChange={surface === 'source' && marked ? () => editorRef.current?.prevChange() : undefined}
279
+ onAccept={canEdit ? onAccept : undefined}
280
+ onReject={canEdit ? tc.actions.reject : undefined}
281
+ onWithdraw={tc.actions.withdraw}
282
+ onComment={onComment}
283
+ onReply={tc.actions.reply}
284
+ onResolve={tc.actions.resolve}
285
+ onReopen={tc.actions.reopen}
286
+ selection={selection}
287
+ mentionable={tc.mentionable}
288
+ mentionNotice={tc.rosterNotice}
289
+ />
290
+ </div>
291
+ </section>
292
+ );
293
+ });
@@ -0,0 +1,273 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * TrackedReadView — the rendered Read view with track-changes marks drawn over
5
+ * it (bd startsim-whwxd.3; design startsim-xyn25 section d).
6
+ *
7
+ * Markdown goes through the shared MarkdownRenderer with the
8
+ * {@link rehypeTrackMarks} transform; plain text (LinkedIn) is one text node
9
+ * through the SAME transform, so both fields mark identically.
10
+ *
11
+ * WHAT WORKS HERE. Clicking a mark selects it (`onActivate`) so the rail can
12
+ * offer accept/reject/reply for it; selecting text reports a source anchor
13
+ * (`onSelectionChange`) for "comment on selection". Editing is not offered in
14
+ * the Read view; that is the source view's job.
15
+ *
16
+ * WHAT IT WILL NOT DO is draw a mark somewhere it cannot prove. Anything that
17
+ * could not be placed (inside code, or in text whose rendering cannot be
18
+ * aligned to its source) is counted in a line under the text, and it is still
19
+ * in the rail and in the source view.
20
+ */
21
+ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
22
+
23
+ import { MarkdownRenderer } from '../blog/MarkdownRenderer';
24
+ import { describeBlame, describeSuggestion, describeThread } from './describe';
25
+ import { anchorFor } from './normalize';
26
+ import { markTree, plainTree, rehypeTrackMarks, sourceOffsetOf, type HastNode, type ReadMark } from './read-marks';
27
+ import type { BlameSpan, CommentThread, SelectionAnchor, Suggestion } from './types';
28
+
29
+ export interface TrackedReadViewProps {
30
+ /** The text to render: the stored string, or a Final/Original preview. */
31
+ source: string;
32
+ language?: 'markdown' | 'plain';
33
+ blame?: BlameSpan[];
34
+ suggestions?: Suggestion[];
35
+ threads?: CommentThread[];
36
+ /** Draw suggestions and comments. Default true. */
37
+ showChanges?: boolean;
38
+ /** Draw authorship. Default false. */
39
+ showAuthors?: boolean;
40
+ /** The mark the rail has selected; drawn with an outline. */
41
+ activeId?: string | null;
42
+ onActivate?: (id: string) => void;
43
+ onSelectionChange?: (anchor: SelectionAnchor | null) => void;
44
+ className?: string;
45
+ /** Shown when `source` is empty. */
46
+ emptyText?: string;
47
+ }
48
+
49
+ /** Stable tint per author: eight hues, picked by a hash of the key. */
50
+ const TINTS = [
51
+ 'tc-author-0',
52
+ 'tc-author-1',
53
+ 'tc-author-2',
54
+ 'tc-author-3',
55
+ 'tc-author-4',
56
+ 'tc-author-5',
57
+ 'tc-author-6',
58
+ 'tc-author-7',
59
+ ];
60
+
61
+ export function authorTint(key: string): string {
62
+ let h = 0;
63
+ for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
64
+ return TINTS[Math.abs(h) % TINTS.length];
65
+ }
66
+
67
+ /** The marks to draw, in source offsets. */
68
+ export function readMarksOf({
69
+ blame = [],
70
+ suggestions = [],
71
+ threads = [],
72
+ showChanges = true,
73
+ showAuthors = false,
74
+ }: Pick<TrackedReadViewProps, 'blame' | 'suggestions' | 'threads' | 'showChanges' | 'showAuthors'>): ReadMark[] {
75
+ const out: ReadMark[] = [];
76
+ if (showAuthors) {
77
+ for (const b of blame) {
78
+ const key = b.author ? b.author.sub || b.author.label : '';
79
+ out.push({
80
+ id: b.id,
81
+ kind: 'blame',
82
+ from: b.from,
83
+ to: b.to,
84
+ title: describeBlame(b),
85
+ className: b.author ? (b.author.kind === 'machine' ? 'tc-author-machine' : authorTint(key)) : 'tc-author-unknown',
86
+ });
87
+ }
88
+ }
89
+ if (showChanges) {
90
+ for (const s of suggestions) {
91
+ if (s.status !== 'open' || s.anchorState === 'orphaned') continue;
92
+ out.push({ id: s.id, kind: 'suggestion', from: s.from, to: s.to, title: describeSuggestion(s), insert: s.replacement });
93
+ }
94
+ for (const t of threads) {
95
+ if (t.resolved || t.anchorState === 'orphaned' || t.from == null || t.to == null || t.to <= t.from) continue;
96
+ out.push({ id: t.id, kind: 'comment', from: t.from, to: t.to, title: describeThread(t) });
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /* ---------------------------- hast -> React ------------------------------ */
103
+
104
+ const ALLOWED = new Set(['span', 'del', 'ins', 'mark', 'div', 'p', 'br']);
105
+
106
+ function propsOf(p: Record<string, unknown> | undefined): Record<string, unknown> {
107
+ const out: Record<string, unknown> = {};
108
+ if (!p) return out;
109
+ for (const [k, v] of Object.entries(p)) {
110
+ if (k === 'className') out.className = Array.isArray(v) ? v.join(' ') : String(v);
111
+ else if (k.startsWith('data')) out[`data-${k.slice(4).replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`).replace(/^-/, '')}`] = String(v);
112
+ else if (k === 'title') out.title = String(v);
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Renders the transform's own output (plain fields only). */
118
+ export function renderHast(node: HastNode, key = 'n'): ReactNode {
119
+ if (node.type === 'text') return (node as { value: string }).value;
120
+ const kids = ((node as { children?: HastNode[] }).children ?? []).map((c, i) => renderHast(c, `${key}.${i}`));
121
+ if (node.type === 'root') return kids;
122
+ const tag = (node as { tagName?: string }).tagName ?? 'span';
123
+ const Tag = (ALLOWED.has(tag) ? tag : 'span') as 'span';
124
+ return (
125
+ <Tag key={key} {...propsOf((node as { properties?: Record<string, unknown> }).properties)}>
126
+ {kids}
127
+ </Tag>
128
+ );
129
+ }
130
+
131
+ export const TrackedReadView = forwardRef<HTMLDivElement, TrackedReadViewProps>(function TrackedReadView(
132
+ {
133
+ source,
134
+ language = 'markdown',
135
+ blame,
136
+ suggestions,
137
+ threads,
138
+ showChanges = true,
139
+ showAuthors = false,
140
+ activeId,
141
+ onActivate,
142
+ onSelectionChange,
143
+ className,
144
+ emptyText = 'Nothing to show yet.',
145
+ },
146
+ ref,
147
+ ) {
148
+ const marks = useMemo(
149
+ () => readMarksOf({ blame, suggestions, threads, showChanges, showAuthors }),
150
+ [blame, suggestions, threads, showChanges, showAuthors],
151
+ );
152
+ // What the markdown transform drew, as a sorted id list. The transform runs
153
+ // inside the renderer on every render and makes a new Set each time, so it
154
+ // is compared by CONTENT, or the state write would loop.
155
+ const [placedKey, setPlacedKey] = useState<string | null>(null);
156
+ const placedRef = useRef<Set<string> | null>(null);
157
+
158
+ // Plain text is transformed here; markdown inside the renderer's pipeline.
159
+ const plain = useMemo(() => {
160
+ if (language !== 'plain') return null;
161
+ const tree = plainTree(source);
162
+ const r = markTree(tree, source, marks);
163
+ return { tree, placed: r.placed };
164
+ }, [language, source, marks]);
165
+
166
+ const onPlaced = useCallback((p: Set<string>) => {
167
+ placedRef.current = p;
168
+ }, []);
169
+ const plugins = useMemo(
170
+ () => (language === 'markdown' ? [rehypeTrackMarks({ source, marks, onPlaced })] : undefined),
171
+ [language, source, marks, onPlaced],
172
+ );
173
+
174
+ useEffect(() => {
175
+ if (plain || !placedRef.current) return;
176
+ const k = [...placedRef.current].sort().join('\u0000');
177
+ if (k !== placedKey) setPlacedKey(k);
178
+ });
179
+ const placedNow = plain
180
+ ? plain.placed
181
+ : placedKey == null
182
+ ? null
183
+ : new Set(placedKey ? placedKey.split('\u0000') : []);
184
+ const unplaced = marks.filter((m) => m.kind !== 'blame' && placedNow && !placedNow.has(m.id)).length;
185
+
186
+ const hostRef = useRef<HTMLDivElement | null>(null);
187
+ const setRefs = (el: HTMLDivElement | null) => {
188
+ hostRef.current = el;
189
+ if (typeof ref === 'function') ref(el);
190
+ else if (ref) ref.current = el;
191
+ };
192
+
193
+ const reportSelection = () => {
194
+ if (!onSelectionChange) return;
195
+ const sel = typeof window !== 'undefined' ? window.getSelection() : null;
196
+ const host = hostRef.current;
197
+ if (!sel || sel.isCollapsed || !host || !sel.anchorNode || !host.contains(sel.anchorNode)) {
198
+ onSelectionChange(null);
199
+ return;
200
+ }
201
+ const a = sourceOffsetOf(sel.anchorNode, sel.anchorOffset);
202
+ const b = sourceOffsetOf(sel.focusNode, sel.focusOffset);
203
+ if (a == null || b == null || a === b) {
204
+ onSelectionChange(null);
205
+ return;
206
+ }
207
+ onSelectionChange(anchorFor(source, a, b));
208
+ };
209
+
210
+ const onClick = (e: React.MouseEvent) => {
211
+ const hit = (e.target as Element | null)?.closest?.('[data-tc-id]');
212
+ const id = hit?.getAttribute('data-tc-id');
213
+ if (id && onActivate) onActivate(id);
214
+ };
215
+
216
+ const activeStyle = activeId
217
+ ? `[data-tc-read] [data-tc-id="${CSS_ESCAPE(activeId)}"]{outline:2px solid currentColor;outline-offset:1px;border-radius:2px}`
218
+ : '';
219
+
220
+ return (
221
+ <div
222
+ ref={setRefs}
223
+ data-tc-read=""
224
+ data-testid="tracked-read-view"
225
+ className={className}
226
+ onClick={onClick}
227
+ onMouseUp={reportSelection}
228
+ onKeyUp={reportSelection}
229
+ >
230
+ {/* Static, author-written CSS plus an id that is escaped. */}
231
+ <style>{READ_STYLE + activeStyle}</style>
232
+ {!source ? (
233
+ <p className="text-sm italic text-muted-foreground">{emptyText}</p>
234
+ ) : plain ? (
235
+ <div className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">{renderHast(plain.tree)}</div>
236
+ ) : (
237
+ <MarkdownRenderer content={source} rehypePlugins={plugins} />
238
+ )}
239
+ {unplaced > 0 && (
240
+ <p className="mt-2 text-xs text-muted-foreground">
241
+ {unplaced === 1
242
+ ? '1 suggestion or comment cannot be drawn on the rendered text (it sits in code or formatting). It is in the list, and in the source view.'
243
+ : `${unplaced} suggestions or comments cannot be drawn on the rendered text (they sit in code or formatting). They are in the list, and in the source view.`}
244
+ </p>
245
+ )}
246
+ </div>
247
+ );
248
+ });
249
+
250
+ function CSS_ESCAPE(s: string): string {
251
+ return s.replace(/["\\\n\r]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `);
252
+ }
253
+
254
+ /**
255
+ * The marks' paint. Tokens where the theme has them; the author tints are
256
+ * fixed hues at low alpha so they read on light and dark backgrounds.
257
+ */
258
+ export const READ_STYLE = [
259
+ '[data-tc-read] .tc-read-suggestion{text-decoration:line-through;text-decoration-color:rgb(220 38 38);background:rgb(220 38 38 / .10);cursor:pointer}',
260
+ '[data-tc-read] .tc-read-ins{text-decoration:underline;text-decoration-color:rgb(22 163 74);background:rgb(22 163 74 / .12);color:inherit;cursor:pointer}',
261
+ '[data-tc-read] .tc-read-comment{background:rgb(234 179 8 / .30);color:inherit;cursor:pointer;border-bottom:2px solid rgb(234 179 8)}',
262
+ '[data-tc-read] .tc-read-blame{box-shadow:inset 0 -2px 0 var(--tc-tint,transparent)}',
263
+ '[data-tc-read] .tc-author-0{--tc-tint:rgb(59 130 246 / .7)}',
264
+ '[data-tc-read] .tc-author-1{--tc-tint:rgb(168 85 247 / .7)}',
265
+ '[data-tc-read] .tc-author-2{--tc-tint:rgb(20 184 166 / .7)}',
266
+ '[data-tc-read] .tc-author-3{--tc-tint:rgb(249 115 22 / .7)}',
267
+ '[data-tc-read] .tc-author-4{--tc-tint:rgb(236 72 153 / .7)}',
268
+ '[data-tc-read] .tc-author-5{--tc-tint:rgb(132 204 22 / .7)}',
269
+ '[data-tc-read] .tc-author-6{--tc-tint:rgb(14 165 233 / .7)}',
270
+ '[data-tc-read] .tc-author-7{--tc-tint:rgb(234 88 12 / .7)}',
271
+ '[data-tc-read] .tc-author-machine{--tc-tint:rgb(100 116 139 / .9);background:rgb(100 116 139 / .08)}',
272
+ '[data-tc-read] .tc-author-unknown{--tc-tint:rgb(148 163 184 / .5)}',
273
+ ].join('');