Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/pr-assets/authorship-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-assets/authorship-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions src/editor/plugins/authorship-color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getMarkColor, isAI, isHuman, isSystem } from '../../formats/marks.js';

export interface AuthoredBlockColorCounts {
human: number;
ai: number;
system: number;
explicitHuman: number;
explicitAI: number;
}

const AUTHORED_DECORATION_STYLES = {
human: 'box-shadow: inset 0 -0.38em rgba(110, 231, 183, 0.28); border-radius: 2px;',
ai: 'box-shadow: inset 0 -0.38em rgba(165, 180, 252, 0.24); border-radius: 2px;',
system: 'box-shadow: inset 0 -0.38em rgba(147, 197, 253, 0.26); border-radius: 2px;',
} as const;

export function resolveAuthoredBlockColor(counts: AuthoredBlockColorCounts): string | null {
const { human, ai, system, explicitHuman, explicitAI } = counts;

if (system > 0) return getMarkColor('system');
if (human === 0 && ai === 0) return null;

// Keep same-block inline human edits visible even when AI text still dominates.
if (explicitHuman > 0 && explicitAI > 0) {
return getMarkColor('mixed');
}

return ai >= human ? getMarkColor('ai') : getMarkColor('human');
}

export function getAuthoredDecorationStyle(actor: string): string {
if (isHuman(actor)) return AUTHORED_DECORATION_STYLES.human;
if (isAI(actor)) return AUTHORED_DECORATION_STYLES.ai;
if (isSystem(actor)) return AUTHORED_DECORATION_STYLES.system;
return '';
}
11 changes: 7 additions & 4 deletions src/editor/plugins/heatmap-decorations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type MarkKind,
type ReplaceData,
} from '../../formats/marks.js';
import { resolveAuthoredBlockColor } from './authorship-color';
import { getMarks, marksPluginKey, resolveMarks } from './marks';
import { isMobileTouch } from './mobile-detect';

Expand Down Expand Up @@ -129,26 +130,27 @@ function getAuthoredBlockColor(
let human = 0;
let ai = 0;
let system = 0;
let explicitHuman = 0;
let explicitAI = 0;

for (const mark of authored) {
if (!blockIntersectsMark(blockFrom, blockTo, mark)) continue;
const overlap = Math.max(0, Math.min(blockTo, mark.to) - Math.max(blockFrom, mark.from));
if (overlap <= 0) continue;
if (isHuman(mark.mark.by)) {
human += overlap;
explicitHuman += overlap;
} else if (isAI(mark.mark.by)) {
ai += overlap;
explicitAI += overlap;
} else if (isSystem(mark.mark.by)) {
system += overlap;
}
}

const unmarked = Math.max(0, blockTextLength - (human + ai + system));
ai += unmarked;

if (system > 0) return getMarkColor('system');
if (human === 0 && ai === 0) return null;
return ai >= human ? getMarkColor('ai') : getMarkColor('human');
return resolveAuthoredBlockColor({ human, ai, system, explicitHuman, explicitAI });
}

/**
Expand Down Expand Up @@ -188,6 +190,7 @@ function getBlockStatus(
* 4 colors total:
* - Human (soft mint) - human-authored content
* - AI (soft lavender) - AI-authored content
* - Mixed (purple) - block contains both human and AI authored text
* - System (blue) - system-authored content
* - Flagged (dusty rose) - needs attention, overrides authorship
* - Comment (soft gold) - has discussion, overrides authorship
Expand Down
11 changes: 7 additions & 4 deletions src/editor/plugins/marks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { Node as ProseMirrorNode, MarkType } from '@milkdown/kit/prose/mode
import { ySyncPluginKey } from 'y-prosemirror';
import { buildTextIndex, getTextForRange, mapTextOffsetsToRange, resolveQuoteRange } from '../utils/text-range';
import { SHARE_CONTENT_FILTER_ALLOW_META } from './share-content-filter';
import { getAuthoredDecorationStyle } from './authorship-color';

import {
type Mark,
Expand Down Expand Up @@ -3030,9 +3031,6 @@ export function resolveMarks(doc: ProseMirrorNode, marks: Mark[]): ResolvedMark[
// ============================================================================

const STYLES = {
authored_human: 'background-color: rgba(110, 231, 183, 0.08);',
authored_ai: 'background-color: rgba(165, 180, 252, 0.12);',

flagged: 'border-left: 3px solid #FCA5A5; padding-left: 4px; background-color: rgba(252, 165, 165, 0.1);',

comment: 'background-color: rgba(252, 211, 77, 0.3); border-bottom: 2px solid #FCD34D;',
Expand Down Expand Up @@ -3108,7 +3106,12 @@ function createDecorations(
let replacementContent: string | null = null;

switch (mark.kind) {
case 'authored':
case 'authored': {
style = getAuthoredDecorationStyle(mark.by);
cssClass = 'mark-authored';
break;
Comment on lines +3109 to +3112

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip interactive mark metadata on authored highlights

Adding authored entries to createDecorations makes those spans carry the same data-mark-id metadata as suggestion/comment marks, so clicking normal authored text now routes through the popover handlers (mark-popover.ts checks closest('[data-mark-id]') and treats every non-comment mark as a suggestion). In documents where most text is authored, this causes routine clicks/taps to open an irrelevant suggestion popover (and on touch it also calls preventDefault), which interferes with normal editing/navigation.

Useful? React with 👍 / 👎.

}

case 'approved':
case 'flagged':
continue;
Expand Down
1 change: 1 addition & 0 deletions src/formats/marks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const KNOWN_COLORS: Record<string, string> = {
// Origin/authorship
human: '#6EE7B7', // Soft mint
ai: '#A5B4FC', // Soft lavender
mixed: '#9C27B0', // Mixed human + AI authorship
system: '#93C5FD', // Soft sky blue

// Mark kinds (for future use in sidebar counts)
Expand Down
83 changes: 83 additions & 0 deletions src/tests/heatmap-authorship.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { getMarkColor } from '../formats/marks.js';
import {
getAuthoredDecorationStyle,
resolveAuthoredBlockColor,
} from '../editor/plugins/authorship-color.js';

function assertEqual<T>(actual: T, expected: T, message?: string): void {
if (actual !== expected) {
throw new Error(message ?? `Expected ${String(expected)}, got ${String(actual)}`);
}
}

function test(name: string, fn: () => void): void {
try {
fn();
console.log(' ✓', name);
} catch (error) {
console.error(' ✗', name);
console.error(error instanceof Error ? error.stack : String(error));
process.exitCode = 1;
}
}

test('returns mixed when a block contains both explicit human and AI authorship', () => {
const color = resolveAuthoredBlockColor({
human: 7,
ai: 134,
system: 0,
explicitHuman: 7,
explicitAI: 127,
});

assertEqual(color, getMarkColor('mixed'));
});

test('does not mark a block as mixed when AI coverage only comes from unmarked fallback text', () => {
const color = resolveAuthoredBlockColor({
human: 7,
ai: 40,
system: 0,
explicitHuman: 7,
explicitAI: 0,
});

assertEqual(color, getMarkColor('ai'));
});

test('preserves system override over mixed authorship', () => {
const color = resolveAuthoredBlockColor({
human: 7,
ai: 134,
system: 12,
explicitHuman: 7,
explicitAI: 127,
});

assertEqual(color, getMarkColor('system'));
});

test('returns mint inline tint for human-authored spans', () => {
const style = getAuthoredDecorationStyle('human:user');
assertEqual(
style,
'box-shadow: inset 0 -0.38em rgba(110, 231, 183, 0.28); border-radius: 2px;'
);
});

test('returns lavender inline tint for AI-authored spans', () => {
const style = getAuthoredDecorationStyle('ai:test');
assertEqual(
style,
'box-shadow: inset 0 -0.38em rgba(165, 180, 252, 0.24); border-radius: 2px;'
);
});

test('returns no inline tint for unknown actors', () => {
const style = getAuthoredDecorationStyle('someone:else');
assertEqual(style, '');
});

if (process.exitCode) {
process.exit(process.exitCode);
}