From 8bd9d0593f9765f94409be2dafe593640d3a6846 Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Tue, 18 Aug 2026 13:50:21 -0500 Subject: [PATCH] Add per-author highlight colors --- CHANGELOG.md | 6 + README.md | 10 ++ src/author-colors.ts | 173 +++++++++++++++++++++ src/author-index.ts | 198 ++++++++++++++++++++++++ src/editor/config.ts | 9 +- src/editor/layout.ts | 24 ++- src/editor/margin.ts | 6 +- src/editor/state.ts | 20 ++- src/editor/table-highlights.ts | 78 ++++++++-- src/format/serialize.ts | 3 +- src/main.ts | 268 +++++++++++++++++++++++++++++++-- src/reading/highlight.ts | 27 +++- src/reading/margin.ts | 28 +++- src/settings-storage.ts | 28 ++++ src/settings.ts | 193 ++++++++++++++++++++++-- src/ui/card.ts | 21 ++- src/ui/sidebar.ts | 9 +- src/util/dom.ts | 7 + styles.css | 38 ++--- test/author-colors.test.ts | 125 +++++++++++++++ test/author-index.test.ts | 140 +++++++++++++++++ test/card.test.ts | 33 ++++ test/decorations.test.ts | 77 +++++++++- test/dom.test.ts | 21 +++ test/editor-view.test.ts | 31 ++++ test/obsidian-mock.ts | 187 ++++++++++++++++++++++- test/plugin-settings.test.ts | 87 +++++++++++ test/reading-highlight.test.ts | 36 +++++ test/reading-margin.test.ts | 127 ++++++++++++++++ test/settings-storage.test.ts | 43 ++++++ test/settings.test.ts | 156 +++++++++++++++++++ test/styles.test.ts | 49 ++++++ test/table-highlights.test.ts | 31 +++- 33 files changed, 2204 insertions(+), 85 deletions(-) create mode 100644 src/author-colors.ts create mode 100644 src/author-index.ts create mode 100644 src/settings-storage.ts create mode 100644 src/util/dom.ts create mode 100644 test/author-colors.test.ts create mode 100644 test/author-index.test.ts create mode 100644 test/dom.test.ts create mode 100644 test/plugin-settings.test.ts create mode 100644 test/reading-margin.test.ts create mode 100644 test/settings-storage.test.ts create mode 100644 test/settings.test.ts create mode 100644 test/styles.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4b68c..ced0030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ matching the pushed tag as that GitHub release's notes, so add an entry here bef ## Unreleased +- Added persistent per-author highlight colors. The settings page discovers comment and reply authors across the vault, assigns distinct Radix colors automatically, supports custom colors through Obsidian's built-in picker, colors author names on comment cards, can disable colors without losing assignments, and can delete individual mappings so those authors use the normal theme color. Resolved highlights keep a dashed creator-colored underline ([#67](https://github.com/kylemcd/obsidian-document-comments/issues/67)). +- Removed the per-author Reset icon. Color rows now expose only the picker and a trash-can action; an uncolored author can receive a new automatic assignment from the Uncolored section. +- Turning off **Use author colors** now restores the original yellow document highlights while keeping author names neutral and preserving saved color assignments. +- **Use author colors** now defaults to off. The initial vault scan still generates and persists mappings for existing comment authors, so enabling it later applies colors immediately. +- The **Highlight colors** settings section is hidden while **Use author colors** is off; saved mappings remain intact and reappear when it is enabled. + ## 0.1.12 - Added an **Allow empty comments** setting. An empty comment highlights its selected text and shows an editable **Empty** card. Run **Add comment** on the same text to add text or delete the comment ([#52](https://github.com/kylemcd/obsidian-document-comments/issues/52)). diff --git a/README.md b/README.md index e8f9228..0d41f09 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,16 @@ Open **Settings → Document Comments**. Set **Author** to the name that the plu The plugin uses `me` when the Author setting is empty. +### Set highlight colors + +Enable **Use author colors** in **Settings → Document Comments** to reveal the **Highlight colors** section and see every person whose name appears in a comment thread. The setting is off by default, and the color section stays hidden while it is off. Use Obsidian's color picker to choose a custom color. The same color identifies each person in document highlights and beside their comments and replies. Turn the setting off to restore the original yellow document highlights and render names with the normal theme color without deleting any saved assignments. + +Document Comments assigns colors during its initial vault scan even while author colors are off, so enabling them works immediately for existing comments. It uses a 12-color Radix palette without repeating a color until every palette color is in use. Generated and custom assignments are stored locally in the plugin's `data.json`; they do not change the Markdown comment format and do not need to be shared with collaborators. + +Resolved highlights keep the creator's color as a dashed underline. Creators whose highlights are no longer present remain listed under **Not currently found**, so their color returns if their comments reappear. + +Use the trash-can button beside a person to remove their assignment. Deleted mappings are not automatically recreated; those people use the normal theme color and appear under **Uncolored**, where **Assign color** creates a new automatically generated color. + ## Desktop and mobile behavior Desktop views show cards in a margin beside the note. The cards align with their selected text and avoid overlaps. diff --git a/src/author-colors.ts b/src/author-colors.ts new file mode 100644 index 0000000..a3ca255 --- /dev/null +++ b/src/author-colors.ts @@ -0,0 +1,173 @@ +import { Result } from "better-result"; +import type { ParsedComment } from "./format/types"; + +export type HexColor = `#${string}`; +export type ResolvedAuthorColor = HexColor | null; +export type AuthorColorResolver = (author: string) => ResolvedAuthorColor; +export type AuthorColorMode = "generated" | "custom"; + +export type AuthorColorAssignment = { + color: HexColor; + mode: AuthorColorMode; +}; + +export type AuthorColorAssignments = Record; + +// Matches the yellow highlight used before per-author colors were introduced. +export const DEFAULT_HIGHLIGHT_COLOR = "#f2b90d" as const satisfies HexColor; + +export type AuthorColorResolution = { + author: string; + color: ResolvedAuthorColor; + created: boolean; +}; + +// Radix Colors 3 light scale, step 9. Step 9 is Radix's highest-chroma accent +// step and is intended for overlays and accent borders. +export const AUTHOR_COLOR_PALETTE = [ + "#0090ff", // blue + "#e54d2e", // tomato + "#46a758", // grass + "#6e56cf", // violet + "#ffc53d", // amber + "#00a2c7", // cyan + "#d6409f", // pink + "#12a594", // teal + "#f76b15", // orange + "#3e63dd", // indigo + "#bdee63", // lime + "#8e4ec6", // purple +] as const satisfies readonly HexColor[]; + +type AssignmentEntry = readonly [string, AuthorColorAssignment]; + +export const canonicalAuthorKey = (author: string): string => { + return author.trim().replace(/-->/g, "--​>").replace(/\s+/g, "_"); +}; + +export const creatorForComment = (comment: ParsedComment): string | null => { + const author = canonicalAuthorKey(comment.author ?? comment.thread[0]?.author ?? ""); + return author || null; +}; + +export const creatorsForComments = (comments: readonly ParsedComment[]): string[] => { + return [...new Set(comments.map(creatorForComment).filter((author) => author !== null))].sort((a, b) => + a.localeCompare(b), + ); +}; + +export const commentersForComments = (comments: readonly ParsedComment[]): string[] => { + const authors = comments.flatMap((comment) => [ + creatorForComment(comment), + ...comment.thread.map((entry) => canonicalAuthorKey(entry.author) || null), + ]); + return [...new Set(authors.filter((author) => author !== null))].sort((a, b) => a.localeCompare(b)); +}; + +export const parseHexColor = (value: unknown): Result => { + if (typeof value !== "string" || !/^#[0-9a-f]{6}$/i.test(value)) { + return Result.err("Expected a six-digit hexadecimal color."); + } + return Result.ok(value.toLowerCase() as HexColor); +}; + +const parseAssignment = (key: string, value: unknown): Result => { + if (!value || typeof value !== "object") return Result.err(`Invalid color assignment for ${key}.`); + const candidate = value as Partial; + if (candidate.mode !== "generated" && candidate.mode !== "custom") { + return Result.err(`Invalid color mode for ${key}.`); + } + const mode = candidate.mode; + return parseHexColor(candidate.color).map((color) => [canonicalAuthorKey(key), { color, mode }] as const); +}; + +export const hydrateAuthorColors = (value: unknown): AuthorColorAssignments => { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const results = Object.entries(value).map(([key, assignment]) => parseAssignment(key, assignment)); + const [valid] = Result.partition(results); + return Object.fromEntries(valid.filter(([key]) => key)); +}; + +export const hydrateExcludedAuthors = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + const authors = value + .filter((author): author is string => typeof author === "string") + .map(canonicalAuthorKey) + .filter(Boolean); + return [...new Set(authors)].sort((a, b) => a.localeCompare(b)); +}; + +const stableHash = (value: string): number => { + return [...value].reduce((hash, character) => (Math.imul(hash, 31) + character.charCodeAt(0)) >>> 0, 0); +}; + +export const generatedColorForAuthor = (author: string, assignments: Readonly): HexColor => { + const usage = AUTHOR_COLOR_PALETTE.map((color) => ({ + color, + count: Object.values(assignments).filter((assignment) => assignment.color === color).length, + })); + const unused = usage.find(({ count }) => count === 0); + if (unused) return unused.color; + + const minimum = Math.min(...usage.map(({ count }) => count)); + const leastUsed = usage.filter(({ count }) => count === minimum); + return leastUsed[stableHash(canonicalAuthorKey(author)) % leastUsed.length]?.color ?? AUTHOR_COLOR_PALETTE[0]; +}; + +export const ensureAuthorColor = ( + assignments: AuthorColorAssignments, + author: string, +): { assignment: AuthorColorAssignment; created: boolean; author: string } => { + const key = canonicalAuthorKey(author); + const existing = assignments[key]; + if (existing) return { assignment: existing, created: false, author: key }; + + const assignment: AuthorColorAssignment = { + color: generatedColorForAuthor(key, assignments), + mode: "generated", + }; + assignments[key] = assignment; + return { assignment, created: true, author: key }; +}; + +export const ensureAuthorColors = ( + assignments: AuthorColorAssignments, + authors: readonly string[], + excludedAuthors: ReadonlySet, +): boolean => { + const canonicalAuthors = [...new Set(authors.map(canonicalAuthorKey).filter(Boolean))] + .filter((author) => !excludedAuthors.has(author)) + .sort((a, b) => a.localeCompare(b)); + return canonicalAuthors.map((author) => ensureAuthorColor(assignments, author)).some(({ created }) => created); +}; + +export const resolveAuthorColor = ( + assignments: AuthorColorAssignments, + excludedAuthors: ReadonlySet, + author: string, + enabled: boolean, +): AuthorColorResolution => { + const key = canonicalAuthorKey(author); + if (!key || excludedAuthors.has(key)) return { author: key, color: null, created: false }; + const ensured = ensureAuthorColor(assignments, key); + return { + author: key, + color: enabled ? ensured.assignment.color : null, + created: ensured.created, + }; +}; + +export const authorColorCss = (color: ResolvedAuthorColor): string => color ?? "var(--text-normal)"; + +export const effectiveHighlightColor = ( + color: ResolvedAuthorColor, + authorColorsEnabled: boolean, +): ResolvedAuthorColor => { + return authorColorsEnabled ? color : DEFAULT_HIGHLIGHT_COLOR; +}; + +export const resetAuthorColor = (assignments: AuthorColorAssignments, author: string): AuthorColorAssignment => { + const key = canonicalAuthorKey(author); + delete assignments[key]; + return ensureAuthorColor(assignments, key).assignment; +}; diff --git a/src/author-index.ts b/src/author-index.ts new file mode 100644 index 0000000..04f5683 --- /dev/null +++ b/src/author-index.ts @@ -0,0 +1,198 @@ +import type { TFile, Vault } from "obsidian"; +import { Result } from "better-result"; +import { commentersForComments } from "./author-colors"; +import { parseComments } from "./format/parse"; + +export type AuthorIndexError = { + path: string; + message: string; +}; + +export type AuthorIndexState = + | { status: "idle"; authors: string[] } + | { status: "scanning"; authors: string[] } + | { status: "ready"; authors: string[] } + | { status: "partial"; authors: string[]; errors: AuthorIndexError[] }; + +export type AuthorIndex = { + getState: () => AuthorIndexState; + subscribe: (listener: (state: AuthorIndexState) => void) => () => void; + scan: () => Promise; + scheduleRefresh: (file: TFile) => void; + remove: (path: string) => void; + rename: (file: TFile, oldPath: string) => void; + dispose: () => void; +}; + +type ReadSuccess = { + path: string; + authors: Set; +}; + +type ReadRequest = { + file: TFile; + path: string; + revision: number; +}; + +type VersionedReadResult = { + path: string; + revision: number; + result: Result; +}; + +const SCAN_BATCH_SIZE = 8; +const REFRESH_DELAY_MS = 150; + +const errorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : "Unknown read error"; +}; + +const markdownFile = (file: TFile): boolean => file.extension.toLowerCase() === "md"; + +export const createAuthorIndex = (vault: Pick): AuthorIndex => { + const fileAuthors = new Map>(); + const errors = new Map(); + const listeners = new Set<(state: AuthorIndexState) => void>(); + const refreshTimers = new Map(); + const revisions = new Map(); + let state: AuthorIndexState = { status: "idle", authors: [] }; + + const nextRevision = (path: string): number => { + const revision = (revisions.get(path) ?? 0) + 1; + revisions.set(path, revision); + return revision; + }; + + const aggregateAuthors = (): string[] => { + return [...new Set([...fileAuthors.values()].flatMap((authors) => [...authors]))].sort((a, b) => + a.localeCompare(b), + ); + }; + + const publish = (status?: "idle" | "scanning" | "ready" | "partial"): AuthorIndexState => { + const authors = aggregateAuthors(); + const failures = [...errors.values()].sort((a, b) => a.path.localeCompare(b.path)); + const nextStatus = status ?? (failures.length > 0 ? "partial" : "ready"); + state = + nextStatus === "partial" + ? { status: "partial", authors, errors: failures } + : { status: nextStatus, authors }; + listeners.forEach((listener) => listener(state)); + return state; + }; + + const requestRead = (file: TFile): ReadRequest => { + const path = file.path; + return { file, path, revision: nextRevision(path) }; + }; + + const readFile = async ({ file, path, revision }: ReadRequest): Promise => { + const result = await Result.tryPromise({ + try: async () => { + const content = await vault.cachedRead(file); + const authors = content.includes("`; @@ -12,7 +13,7 @@ export const closeMarker = (id: string): string => { /** Serialize a comment body block: ``. */ export const serializeBody = (id: string, data: CommentData): string => { const head: string[] = [`co:${id}`]; - if (data.author) head.push(`by:${sanitizeToken(data.author)}`); + if (data.author) head.push(`by:${canonicalAuthorKey(data.author)}`); if (data.createdAt) head.push(`at:${sanitizeToken(data.createdAt)}`); head.push(`status:${data.status}`); // A code quote must round-trip exactly (it re-anchors to the code); a prose diff --git a/src/main.ts b/src/main.ts index 92cb697..64f0326 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,13 +6,14 @@ import { Notice, Platform, Plugin, + TAbstractFile, TFile, WorkspaceLeaf, debounce, } from "obsidian"; import { Result } from "better-result"; import { EditorView } from "@codemirror/view"; -import { commentField } from "./editor/state"; +import { commentField, refreshCommentColors } from "./editor/state"; import { marginPlugin } from "./editor/margin"; import { commentConfig } from "./editor/config"; import { editorLayoutField } from "./editor/layout"; @@ -25,19 +26,49 @@ import { COMMENTS_VIEW_TYPE, CommentsSidebarView, SidebarDeps } from "./ui/sideb import { CommentModal } from "./ui/comment-modal"; import { DEFAULT_SETTINGS, DocCommentsSettings, DocCommentsSettingTab } from "./settings"; import { tableHighlightPlugin } from "./editor/table-highlights"; +import { + authorColorCss, + canonicalAuthorKey, + ensureAuthorColor, + ensureAuthorColors, + effectiveHighlightColor, + hydrateAuthorColors, + hydrateExcludedAuthors, + parseHexColor, + resolveAuthorColor, + type AuthorColorAssignment, + type ResolvedAuthorColor, +} from "./author-colors"; +import { createAuthorIndex, type AuthorIndex, type AuthorIndexState } from "./author-index"; +import { loadSettingsData, saveSettingsData } from "./settings-storage"; +import { isHtmlElement } from "./util/dom"; + +type AuthorColorStateSnapshot = { + assignment: AuthorColorAssignment | undefined; + excluded: boolean; +}; export default class DocCommentsPlugin extends Plugin { - settings: DocCommentsSettings = { ...DEFAULT_SETTINGS }; + settings: DocCommentsSettings = { ...DEFAULT_SETTINGS, authorColors: {}, excludedAuthorColors: [] }; private markdown = new Component(); private ribbonIcon: HTMLElement | null = null; private readingManager: ReadingMarginManager | null = null; private scheduleReadingRefresh: () => void = () => {}; + private authorIndex: AuthorIndex | null = null; + private unsubscribeAuthorIndex: (() => void) | null = null; + private settingsTab: DocCommentsSettingTab | null = null; + private settingsPersistenceError: string | null = null; + private authorIndexError: string | null = null; + private excludedAuthorColorSet = new Set(); + private scheduleAuthorColorSave = debounce(() => void this.persistAuthorColors(), 100, true); /** True while the "All discussions" sidebar panel is mounted. */ private sidebarOpen = false; async onload(): Promise { await this.loadSettings(); this.addChild(this.markdown); + this.authorIndex = createAuthorIndex(this.app.vault); + this.unsubscribeAuthorIndex = this.authorIndex.subscribe((state) => this.handleAuthorIndexState(state)); this.registerEditorExtension([ commentField, @@ -54,6 +85,8 @@ export default class DocCommentsPlugin extends Plugin { this.markdown, ), author: () => this.authorName(), + colorForAuthor: (author) => this.colorForAuthor(author), + highlightColorForAuthor: (author) => this.highlightColorForAuthor(author), showComments: () => this.settings.showComments, showResolved: () => this.settings.showResolved, allowEmptyComments: () => this.settings.allowEmptyComments, @@ -75,6 +108,8 @@ export default class DocCommentsPlugin extends Plugin { const readingDeps: ReadingDeps = { app: this.app, getAuthor: () => this.authorName(), + colorForAuthor: (author) => this.colorForAuthor(author), + highlightColorForAuthor: (author) => this.highlightColorForAuthor(author), showComments: () => this.settings.showComments, showResolved: () => this.settings.showResolved, allowEmptyComments: () => this.settings.allowEmptyComments, @@ -90,11 +125,12 @@ export default class DocCommentsPlugin extends Plugin { const sidebarDeps: SidebarDeps = { app: this.app, getAuthor: () => this.authorName(), + colorForAuthor: (author) => this.colorForAuthor(author), }; this.registerView(COMMENTS_VIEW_TYPE, (leaf) => new CommentsSidebarView(leaf, sidebarDeps)); this.registerMarkdownPostProcessor((el, ctx) => { - highlightPostProcessor(el, ctx); + highlightPostProcessor(el, ctx, (author) => this.highlightColorForAuthor(author), this.authorName()); this.scheduleReadingRefresh(); }); // layout-change / active-leaf-change fire for every way the panel shows or @@ -116,7 +152,31 @@ export default class DocCommentsPlugin extends Plugin { // resize fires while a dock collapses/expands — catches that case promptly // even if layout-change doesn't. this.registerEvent(this.app.workspace.on("resize", () => this.syncSidebarOpen())); - this.registerEvent(this.app.vault.on("modify", () => this.scheduleReadingRefresh())); + this.app.workspace.onLayoutReady(() => { + // Register after vault startup so Obsidian's initial create-event burst does + // not duplicate the bounded full scan. + this.registerEvent( + this.app.vault.on("create", (file: TAbstractFile) => { + if (file instanceof TFile) this.authorIndex?.scheduleRefresh(file); + }), + ); + this.registerEvent( + this.app.vault.on("modify", (file: TAbstractFile) => { + this.scheduleReadingRefresh(); + if (file instanceof TFile) this.authorIndex?.scheduleRefresh(file); + }), + ); + this.registerEvent( + this.app.vault.on("delete", (file: TAbstractFile) => this.authorIndex?.remove(file.path)), + ); + this.registerEvent( + this.app.vault.on("rename", (file: TAbstractFile, oldPath: string) => { + if (file instanceof TFile) this.authorIndex?.rename(file, oldPath); + else this.authorIndex?.remove(oldPath); + }), + ); + void this.rescanAuthors(); + }); this.addCommand({ id: "add-comment", @@ -161,7 +221,8 @@ export default class DocCommentsPlugin extends Plugin { this.updateRibbon(); this.addRibbonIcon("messages-square", "Open comments sidebar", () => void this.activateSidebar()); - this.addSettingTab(new DocCommentsSettingTab(this.app, this)); + this.settingsTab = new DocCommentsSettingTab(this.app, this); + this.addSettingTab(this.settingsTab); } private startAddComment(editor: Editor): void { @@ -296,16 +357,28 @@ export default class DocCommentsPlugin extends Plugin { } private async toggleComments(): Promise { - this.settings.showComments = !this.settings.showComments; - await this.saveSettings(); + const previous = this.settings.showComments; + this.settings.showComments = !previous; + const saved = await this.saveSettings(); + if (saved.isErr()) { + this.settings.showComments = previous; + new Notice(`Couldn't save settings: ${saved.error}`); + return; + } this.updateRibbon(); this.refreshEditors(); new Notice(this.settings.showComments ? "Comments shown" : "Comments hidden"); } private async toggleResolved(): Promise { - this.settings.showResolved = !this.settings.showResolved; - await this.saveSettings(); + const previous = this.settings.showResolved; + this.settings.showResolved = !previous; + const saved = await this.saveSettings(); + if (saved.isErr()) { + this.settings.showResolved = previous; + new Notice(`Couldn't save settings: ${saved.error}`); + return; + } this.refreshEditors(); new Notice(this.settings.showResolved ? "Resolved comments shown" : "Resolved comments hidden"); } @@ -322,7 +395,21 @@ export default class DocCommentsPlugin extends Plugin { /** Force open editors + reading views (+ the sidebar) to re-evaluate live config. */ refreshEditors(): void { this.app.workspace.getLeavesOfType("markdown").forEach((leaf: WorkspaceLeaf) => { - editorViewFromLeaf(leaf)?.dispatch({}); + editorViewFromLeaf(leaf)?.dispatch({ effects: refreshCommentColors.of(null) }); + const readingView = leaf.view.containerEl.querySelector(".markdown-reading-view"); + if (!isHtmlElement(readingView)) return; + const draftColor = authorColorCss(this.highlightColorForAuthor(this.authorName())); + readingView.style.setProperty("--dc-highlight-color", draftColor); + readingView.style.setProperty("--dc-draft-highlight-color", draftColor); + readingView.querySelectorAll(".doc-comment-span[data-dc-author]").forEach((span) => { + const author = span.dataset.dcAuthor; + if (author) { + span.style.setProperty( + "--dc-highlight-color", + authorColorCss(this.highlightColorForAuthor(author)), + ); + } + }); }); this.scheduleReadingRefresh(); this.sidebarView()?.requestRefresh(); @@ -390,19 +477,172 @@ export default class DocCommentsPlugin extends Plugin { onunload(): void { this.readingManager?.destroy(); + this.unsubscribeAuthorIndex?.(); + this.authorIndex?.dispose(); } private authorName(): string { return this.settings.author.trim() || "me"; } + colorForAuthor(author: string): ResolvedAuthorColor { + const key = canonicalAuthorKey(author) || canonicalAuthorKey(this.authorName()); + const resolved = resolveAuthorColor( + this.settings.authorColors, + this.excludedAuthorColorSet, + key, + this.settings.authorColorsEnabled, + ); + if (resolved.created) this.scheduleAuthorColorSave(); + return resolved.color; + } + + highlightColorForAuthor(author: string): ResolvedAuthorColor { + return effectiveHighlightColor(this.colorForAuthor(author), this.settings.authorColorsEnabled); + } + + ensureCurrentAuthorColor(): void { + this.colorForAuthor(this.authorName()); + } + + async setAuthorColor(author: string, value: unknown): Promise { + const parsed = parseHexColor(value); + if (parsed.isErr()) { + this.settingsPersistenceError = `Couldn't save ${author}'s color: ${parsed.error}`; + this.settingsTab?.refresh(); + return; + } + const key = canonicalAuthorKey(author); + const previous = this.captureAuthorColorState(key); + this.removeAuthorColorExclusion(key); + this.settings.authorColors[key] = { color: parsed.value, mode: "custom" }; + const saved = await this.persistAuthorColors(); + if (saved.isErr()) this.restoreAuthorColorState(key, previous); + this.refreshEditors(); + this.settingsTab?.refresh(); + } + + async deleteAuthorColor(author: string): Promise { + const key = canonicalAuthorKey(author); + if (!key) return; + const previous = this.captureAuthorColorState(key); + delete this.settings.authorColors[key]; + this.excludedAuthorColorSet.add(key); + this.syncExcludedAuthorColors(); + const saved = await this.persistAuthorColors(); + if (saved.isErr()) this.restoreAuthorColorState(key, previous); + this.refreshEditors(); + this.settingsTab?.refresh(); + } + + async restoreAuthorColor(author: string): Promise { + const key = canonicalAuthorKey(author); + if (!key) return; + const previous = this.captureAuthorColorState(key); + this.removeAuthorColorExclusion(key); + ensureAuthorColor(this.settings.authorColors, key); + const saved = await this.persistAuthorColors(); + if (saved.isErr()) this.restoreAuthorColorState(key, previous); + this.refreshEditors(); + this.settingsTab?.refresh(); + } + + async rescanAuthors(): Promise { + const scanned = await Result.tryPromise({ + try: async () => this.authorIndex?.scan(), + catch: (error) => (error instanceof Error ? error.message : "Unknown vault scan error"), + }); + this.authorIndexError = scanned.isErr() ? `Couldn't scan highlight creators: ${scanned.error}` : null; + this.settingsTab?.refresh(); + } + + authorColorView(): { + state: AuthorIndexState; + active: string[]; + missing: string[]; + uncolored: string[]; + saveError: string | null; + } { + const state = this.authorIndex?.getState() ?? { status: "idle", authors: [] }; + const discovered = [...new Set([...state.authors, canonicalAuthorKey(this.authorName())])].sort((a, b) => + a.localeCompare(b), + ); + const discoveredSet = new Set(discovered); + const active = discovered.filter((author) => this.settings.authorColors[author] !== undefined); + const missing = Object.keys(this.settings.authorColors) + .filter((author) => !discoveredSet.has(author)) + .sort((a, b) => a.localeCompare(b)); + const uncolored = [...this.excludedAuthorColorSet].sort((a, b) => a.localeCompare(b)); + return { state, active, missing, uncolored, saveError: this.settingsPersistenceError ?? this.authorIndexError }; + } + + private handleAuthorIndexState(state: AuthorIndexState): void { + const created = ensureAuthorColors(this.settings.authorColors, state.authors, this.excludedAuthorColorSet); + if (created) this.scheduleAuthorColorSave(); + this.settingsTab?.refresh(); + this.refreshEditors(); + } + + private async writeSettings(errorPrefix: string): Promise> { + const saved = await saveSettingsData((data) => this.saveData(data), this.settings); + this.settingsPersistenceError = saved.isErr() ? `${errorPrefix}: ${saved.error.message}` : null; + this.settingsTab?.refresh(); + return saved.mapError((error) => error.message); + } + + private async persistAuthorColors(): Promise> { + return this.writeSettings("Couldn't persist highlight colors"); + } + async loadSettings(): Promise { - const data = ((await this.loadData()) as Partial | null) ?? {}; - this.settings = Object.assign({}, DEFAULT_SETTINGS, data); + const loaded = await loadSettingsData(() => this.loadData()); + this.settingsPersistenceError = loaded.isErr() ? `Couldn't load settings: ${loaded.error.message}` : null; + const rawData = loaded.isOk() ? loaded.value : null; + const data = rawData && typeof rawData === "object" ? (rawData as Partial) : {}; + this.settings = Object.assign({}, DEFAULT_SETTINGS, data, { + authorColors: hydrateAuthorColors(data.authorColors), + excludedAuthorColors: hydrateExcludedAuthors(data.excludedAuthorColors), + }); + this.excludedAuthorColorSet = new Set(this.settings.excludedAuthorColors); + const resolved = resolveAuthorColor( + this.settings.authorColors, + this.excludedAuthorColorSet, + this.authorName(), + this.settings.authorColorsEnabled, + ); + if (loaded.isOk() && resolved.created) await this.persistAuthorColors(); + } + + private removeAuthorColorExclusion(author: string): void { + if (!this.excludedAuthorColorSet.delete(author)) return; + this.syncExcludedAuthorColors(); + } + + private syncExcludedAuthorColors(): void { + this.settings.excludedAuthorColors = [...this.excludedAuthorColorSet].sort((a, b) => a.localeCompare(b)); + } + + private captureAuthorColorState(author: string): AuthorColorStateSnapshot { + return { + assignment: this.settings.authorColors[author], + excluded: this.excludedAuthorColorSet.has(author), + }; + } + + private restoreAuthorColorState(author: string, snapshot: AuthorColorStateSnapshot): void { + if (snapshot.assignment) this.settings.authorColors[author] = snapshot.assignment; + else delete this.settings.authorColors[author]; + if (snapshot.excluded) this.excludedAuthorColorSet.add(author); + else this.excludedAuthorColorSet.delete(author); + this.syncExcludedAuthorColors(); + } + + settingsError(): string | null { + return this.settingsPersistenceError; } - async saveSettings(): Promise { - await this.saveData(this.settings); + async saveSettings(): Promise> { + return this.writeSettings("Couldn't save settings"); } } diff --git a/src/reading/highlight.ts b/src/reading/highlight.ts index 8c03f2d..81ddfa9 100644 --- a/src/reading/highlight.ts +++ b/src/reading/highlight.ts @@ -3,6 +3,12 @@ import { ParsedComment } from "../format/types"; import { anchorRange, fencedRanges, isHighlight, parseComments } from "../format/parse"; import { isCodeComment, resolveCodeAnchor } from "../format/code-anchor"; import { commentPreview } from "../format/preview"; +import { + authorColorCss, + creatorForComment, + type AuthorColorResolver, + type ResolvedAuthorColor, +} from "../author-colors"; export type SectionRange = { from: number; @@ -74,7 +80,12 @@ const commentsFor = (text: string): ParsedComment[] => { * `.doc-comment-span[data-cid]` so the highlight shows in rendered output. * The `` / `` markers are HTML comments, already invisible. */ -export const highlightPostProcessor = (el: HTMLElement, ctx: MarkdownPostProcessorContext): void => { +export const highlightPostProcessor = ( + el: HTMLElement, + ctx: MarkdownPostProcessorContext, + colorForAuthor?: AuthorColorResolver, + currentAuthor = "me", +): void => { const info = ctx.getSectionInfo(el); if (!info) return; const { text, lineStart, lineEnd } = info; @@ -94,6 +105,8 @@ export const highlightPostProcessor = (el: HTMLElement, ctx: MarkdownPostProcess if (comments.length === 0) return; for (const c of comments) { + const author = creatorForComment(c) ?? currentAuthor; + const color = colorForAuthor?.(author); // A code comment highlights its resolved target lines within this block's //
. Each line is wrapped separately — a whole-line match sits in one
 		// text node for plain code blocks (syntax-highlighted blocks split it across
@@ -103,7 +116,9 @@ export const highlightPostProcessor = (el: HTMLElement, ctx: MarkdownPostProcess
 			const target = resolveCodeAnchor(text, c);
 			if (!target || target.from < sectionFrom || target.from >= sectionTo) continue;
 			for (const lineText of text.slice(target.from, target.to).split("\n")) {
-				if (lineText.trim()) wrapFirstMatch(el, lineText, c.id, c.status === "resolved", commentPreview(c));
+				if (lineText.trim()) {
+					wrapFirstMatch(el, lineText, c.id, c.status === "resolved", commentPreview(c), author, color);
+				}
 			}
 			continue;
 		}
@@ -117,10 +132,10 @@ export const highlightPostProcessor = (el: HTMLElement, ctx: MarkdownPostProcess
 		const codeText = inlineCodeText(quote);
 		if (codeText !== null) {
 			const code = inlineCodeElement(el, sectionSource, range.from - sectionFrom, codeText);
-			if (code) wrapFirstMatch(code, codeText, c.id, c.status === "resolved", preview);
+			if (code) wrapFirstMatch(code, codeText, c.id, c.status === "resolved", preview, author, color);
 			continue;
 		}
-		wrapFirstMatch(el, quote, c.id, c.status === "resolved", preview);
+		wrapFirstMatch(el, quote, c.id, c.status === "resolved", preview, author, color);
 	}
 };
 
@@ -339,6 +354,8 @@ const wrapFirstMatch = (
 	id: string,
 	resolved: boolean,
 	title: string | null,
+	author: string,
+	color: ResolvedAuthorColor | undefined,
 ): boolean => {
 	const doc = root.ownerDocument;
 	const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
@@ -354,6 +371,8 @@ const wrapFirstMatch = (
 			});
 			span.detach();
 			span.setAttribute("data-cid", id);
+			span.setAttribute("data-dc-author", author);
+			if (color !== undefined) span.style.setProperty("--dc-highlight-color", authorColorCss(color));
 			if (title) span.setAttribute("title", title);
 			try {
 				range.surroundContents(span);
diff --git a/src/reading/margin.ts b/src/reading/margin.ts
index 3b487ae..6ddddc9 100644
--- a/src/reading/margin.ts
+++ b/src/reading/margin.ts
@@ -19,10 +19,14 @@ import { stackTops } from "../ui/stack";
 import { CARD_GAP, FLASH_MS } from "../ui/constants";
 import { buildDraftComposer } from "../ui/draft-composer";
 import { EmptySubmitAction } from "../ui/draft-behavior";
+import { authorColorCss, type AuthorColorResolver } from "../author-colors";
+import { isHtmlElement } from "../util/dom";
 
 export type ReadingDeps = {
 	app: App;
 	getAuthor: () => string;
+	colorForAuthor: AuthorColorResolver;
+	highlightColorForAuthor: AuthorColorResolver;
 	showComments: () => boolean;
 	showResolved: () => boolean;
 	allowEmptyComments: () => boolean;
@@ -138,15 +142,21 @@ class ReadingMargin {
 				if (this.activeId === id) this.activeId = null;
 			}
 		}
-		const cardView = { app: this.deps.app, sourcePath: () => this.view.file?.path ?? "", collapsible: true };
+		const cardView = {
+			app: this.deps.app,
+			sourcePath: () => this.view.file?.path ?? "",
+			collapsible: true,
+			colorForAuthor: this.deps.colorForAuthor,
+		};
 		for (const c of this.comments) {
 			const existing = this.cards.get(c.id);
 			if (!existing) {
 				const card = new Card(c, this.cb, cardView);
 				this.cards.set(c.id, card);
 				this.container.appendChild(card.el);
-			} else if (existing.signature !== cardSignature(c)) {
-				existing.update(c);
+			} else {
+				if (existing.signature !== cardSignature(c)) existing.update(c);
+				existing.refreshAuthorColors();
 			}
 		}
 		this.readingView.toggleClass("dc-hide-resolved", !this.deps.showResolved());
@@ -154,6 +164,9 @@ class ReadingMargin {
 
 	private position(): void {
 		if (this.destroyed) return;
+		const draftColor = authorColorCss(this.deps.highlightColorForAuthor(this.deps.getAuthor()));
+		this.readingView.style.setProperty("--dc-highlight-color", draftColor);
+		this.readingView.style.setProperty("--dc-draft-highlight-color", draftColor);
 		// State classes live on the reading-view container (Obsidian-owned, safe to
 		// write directly), so the stylesheet caps the text column with plain
 		// descendant selectors instead of :has().
@@ -343,7 +356,7 @@ class ReadingMargin {
 		if (!card) return;
 		window.requestAnimationFrame(() => {
 			const box = card.el.querySelector(".dc-field--composer");
-			if (!(box instanceof HTMLElement)) return;
+			if (!isHtmlElement(box)) return;
 			const c = box.getBoundingClientRect();
 			const s = this.scroller.getBoundingClientRect();
 			let delta = 0;
@@ -420,7 +433,7 @@ export class ReadingMarginManager {
 			const view = leaf.view;
 			if (!(view instanceof MarkdownView) || view.getMode() !== "preview") continue;
 			const rv = view.containerEl.querySelector(".markdown-reading-view");
-			if (!(rv instanceof HTMLElement)) continue;
+			if (!isHtmlElement(rv)) continue;
 			active.add(rv);
 			if (mobile) {
 				// Mobile: no floating cards or reserved column. Just keep the in-text
@@ -429,6 +442,9 @@ export class ReadingMarginManager {
 				rv.toggleClass("dc-highlights", this.deps.showComments());
 				rv.toggleClass("dc-hide-resolved", !this.deps.showResolved());
 				rv.removeClasses(["dc-has", "dc-margin"]);
+				const draftColor = authorColorCss(this.deps.highlightColorForAuthor(this.deps.getAuthor()));
+				rv.style.setProperty("--dc-highlight-color", draftColor);
+				rv.style.setProperty("--dc-draft-highlight-color", draftColor);
 				continue;
 			}
 			let margin = this.margins.get(rv);
@@ -457,7 +473,7 @@ export class ReadingMarginManager {
 		targetHighlightId?: string,
 	): void {
 		const rv = view.containerEl.querySelector(".markdown-reading-view");
-		if (!(rv instanceof HTMLElement)) return;
+		if (!isHtmlElement(rv)) return;
 		let margin = this.margins.get(rv);
 		if (!margin) {
 			margin = new ReadingMargin(rv, view, this.deps);
diff --git a/src/settings-storage.ts b/src/settings-storage.ts
new file mode 100644
index 0000000..814955b
--- /dev/null
+++ b/src/settings-storage.ts
@@ -0,0 +1,28 @@
+import { Result } from "better-result";
+
+export type SettingsStorageError =
+	| { type: "settings_load_failed"; message: string }
+	| { type: "settings_save_failed"; message: string };
+
+const errorMessage = (error: unknown): string => {
+	return error instanceof Error ? error.message : "Unknown settings storage error";
+};
+
+export const loadSettingsData = async (
+	loadData: () => Promise,
+): Promise> => {
+	return Result.tryPromise({
+		try: loadData,
+		catch: (error) => ({ type: "settings_load_failed", message: errorMessage(error) }),
+	});
+};
+
+export const saveSettingsData = async (
+	saveData: (data: unknown) => Promise,
+	data: unknown,
+): Promise> => {
+	return Result.tryPromise({
+		try: () => saveData(data),
+		catch: (error) => ({ type: "settings_save_failed", message: errorMessage(error) }),
+	});
+};
diff --git a/src/settings.ts b/src/settings.ts
index 326d48d..099ba12 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,5 +1,6 @@
-import { App, PluginSettingTab, Setting, type SettingDefinitionItem } from "obsidian";
+import { App, PluginSettingTab, Setting, type SettingDefinition, type SettingDefinitionItem } from "obsidian";
 import type DocCommentsPlugin from "./main";
+import type { AuthorColorAssignments } from "./author-colors";
 
 export type DocCommentsSettings = {
 	/** Author handle attached to comments you create. Empty falls back to "me". */
@@ -10,6 +11,12 @@ export type DocCommentsSettings = {
 	showResolved: boolean;
 	/** Allow a blank comment to persist with an empty comment card. */
 	allowEmptyComments: boolean;
+	/** Apply stored per-author colors to highlights and author names. */
+	authorColorsEnabled: boolean;
+	/** Local, persistent colors keyed by the canonical original highlight creator. */
+	authorColors: AuthorColorAssignments;
+	/** Authors whose generated mapping was explicitly deleted. */
+	excludedAuthorColors: string[];
 };
 
 export const DEFAULT_SETTINGS: DocCommentsSettings = {
@@ -17,9 +24,12 @@ export const DEFAULT_SETTINGS: DocCommentsSettings = {
 	showComments: true,
 	showResolved: false,
 	allowEmptyComments: false,
+	authorColorsEnabled: false,
+	authorColors: {},
+	excludedAuthorColors: [],
 };
 
-type DocCommentsSettingKey = keyof DocCommentsSettings;
+type DocCommentsSettingKey = Exclude;
 
 type TextControl = { type: "text"; placeholder: string };
 type ToggleControl = { type: "toggle" };
@@ -63,6 +73,13 @@ const SETTING_META: ReadonlyArray<{
 		aliases: ["empty comments", "comment-free highlights"],
 		control: { type: "toggle" },
 	},
+	{
+		key: "authorColorsEnabled",
+		name: "Use author colors",
+		desc: "Color highlights and author names by person. Turning this off restores yellow highlights and keeps every saved assignment.",
+		aliases: ["highlight colors", "people colors", "disable colors"],
+		control: { type: "toggle" },
+	},
 ];
 
 export class DocCommentsSettingTab extends PluginSettingTab {
@@ -73,8 +90,8 @@ export class DocCommentsSettingTab extends PluginSettingTab {
 		super(app, plugin);
 	}
 
-	getSettingDefinitions(): SettingDefinitionItem[] {
-		return SETTING_META.map((meta) => ({
+	getSettingDefinitions(): SettingDefinitionItem[] {
+		const base: SettingDefinitionItem[] = SETTING_META.map((meta) => ({
 			name: meta.name,
 			desc: meta.desc,
 			aliases: meta.aliases,
@@ -88,6 +105,10 @@ export class DocCommentsSettingTab extends PluginSettingTab {
 						}
 					: { type: "toggle", key: meta.key, defaultValue: DEFAULT_SETTINGS[meta.key] as boolean },
 		}));
+		const settingsError = this.plugin.settingsError();
+		if (settingsError) base.push(this.settingsErrorDefinition(settingsError));
+		if (!this.plugin.settings.authorColorsEnabled) return base;
+		return [...base, this.authorColorGroup()];
 	}
 
 	async setControlValue(key: string, value: unknown): Promise {
@@ -95,8 +116,12 @@ export class DocCommentsSettingTab extends PluginSettingTab {
 	}
 
 	display(): void {
+		this.renderLegacy();
+	}
+
+	private renderLegacy(): void {
 		const { containerEl } = this;
-		containerEl.empty();
+		containerEl.replaceChildren();
 		for (const meta of SETTING_META) {
 			const setting = new Setting(containerEl).setName(meta.name).setDesc(meta.desc);
 			if (meta.control.type === "text") {
@@ -115,17 +140,165 @@ export class DocCommentsSettingTab extends PluginSettingTab {
 				);
 			}
 		}
+		const settingsError = this.plugin.settingsError();
+		if (settingsError) new Setting(containerEl).setName("Settings error").setDesc(settingsError);
+		if (!this.plugin.settings.authorColorsEnabled) return;
+
+		new Setting(containerEl).setName("Highlight colors").setHeading();
+		this.configureIndexStatus(new Setting(containerEl));
+		const { active, missing, uncolored } = this.plugin.authorColorView();
+		active.forEach((author) => this.configureAuthorColorRow(new Setting(containerEl), author, false));
+		if (missing.length > 0) {
+			new Setting(containerEl).setName("Not currently found").setHeading();
+			missing.forEach((author) => this.configureAuthorColorRow(new Setting(containerEl), author, true));
+		}
+		if (uncolored.length === 0) return;
+		new Setting(containerEl).setName("Uncolored").setHeading();
+		uncolored.forEach((author) => this.configureUncoloredAuthorRow(new Setting(containerEl), author));
 	}
 
-	/** Persist one setting and run its side effects (editor refresh, ribbon sync).
-	 *  Shared by both the declarative and imperative settings paths. */
-	private async applySetting(key: DocCommentsSettingKey, value: unknown): Promise {
+	refresh(): void {
+		const dynamicTab = this as unknown as { update?: () => void };
+		if (dynamicTab.update) {
+			dynamicTab.update();
+			return;
+		}
+		this.renderLegacy();
+	}
+
+	private authorColorGroup(): SettingDefinitionItem {
+		const { active, missing, uncolored } = this.plugin.authorColorView();
+		const authorRow = (author: string, unavailable: boolean): SettingDefinition => ({
+			name: author,
+			desc: unavailable ? "This creator is not currently found in the vault." : undefined,
+			render: (setting) => this.configureAuthorColorRow(setting, author, unavailable),
+		});
+		const uncoloredRow = (author: string): SettingDefinition => ({
+			name: author,
+			desc: "No color assigned. Uses the normal theme text color.",
+			render: (setting) => this.configureUncoloredAuthorRow(setting, author),
+		});
+		const missingHeading: SettingDefinition[] =
+			missing.length === 0
+				? []
+				: [
+						{
+							name: "Not currently found",
+							searchable: false,
+							render: (setting) => {
+								setting.setName("Not currently found").setHeading();
+							},
+						},
+					];
+		const uncoloredHeading: SettingDefinition[] =
+			uncolored.length === 0
+				? []
+				: [
+						{
+							name: "Uncolored",
+							searchable: false,
+							render: (setting) => {
+								setting.setName("Uncolored").setHeading();
+							},
+						},
+					];
+		return {
+			type: "group",
+			heading: "Highlight colors",
+			items: [
+				{
+					name: "Highlight color index",
+					searchable: false,
+					render: (setting) => this.configureIndexStatus(setting),
+				},
+				...active.map((author) => authorRow(author, false)),
+				...missingHeading,
+				...missing.map((author) => authorRow(author, true)),
+				...uncoloredHeading,
+				...uncolored.map(uncoloredRow),
+			],
+		};
+	}
+
+	private configureIndexStatus(setting: Setting): void {
+		const { state, saveError } = this.plugin.authorColorView();
+		const description =
+			state.status === "idle"
+				? "Waiting to scan Markdown files for highlight creators."
+				: state.status === "scanning"
+					? "Scanning Markdown files for highlight creators…"
+					: state.status === "partial"
+						? `Found creators, but ${state.errors.length} file${state.errors.length === 1 ? "" : "s"} could not be read.`
+						: "Colors are stored locally in this plugin's data.json file.";
+		setting.setName("Creators").setDesc(saveError ? `${description} ${saveError}` : description);
+		setting.addButton((button) =>
+			button
+				.setButtonText("Rescan")
+				.setDisabled(state.status === "scanning")
+				.onClick(() => void this.plugin.rescanAuthors()),
+		);
+	}
+
+	private configureAuthorColorRow(setting: Setting, author: string, unavailable: boolean): void {
+		const assignment = this.plugin.settings.authorColors[author];
+		if (!assignment) return;
+		setting.settingEl.classList.add("dc-author-color-setting");
+		const row = setting.setName(author);
+		if (unavailable) row.setDesc("This creator is not currently found in the vault.");
+		row.addColorPicker((picker) =>
+			picker.setValue(assignment.color).onChange((value) => void this.plugin.setAuthorColor(author, value)),
+		);
+		row.addExtraButton((button) =>
+			button
+				.setIcon("trash-2")
+				.setTooltip("Delete this color assignment")
+				.onClick(() => void this.plugin.deleteAuthorColor(author)),
+		);
+	}
+
+	private configureUncoloredAuthorRow(setting: Setting, author: string): void {
+		setting.settingEl.classList.add("dc-author-color-setting");
+		setting
+			.setName(author)
+			.setDesc("No color assigned. Uses the normal theme text color.")
+			.addButton((button) =>
+				button.setButtonText("Assign color").onClick(() => void this.plugin.restoreAuthorColor(author)),
+			);
+	}
+
+	private settingsErrorDefinition(message: string): SettingDefinition {
+		return {
+			name: "Settings error",
+			desc: message,
+			searchable: false,
+			render: (setting) => {
+				setting.setName("Settings error").setDesc(message);
+			},
+		};
+	}
+
+	private assignSetting(key: DocCommentsSettingKey, value: unknown): void {
 		if (key === "author") this.plugin.settings.author = String(value);
 		else if (key === "showComments") this.plugin.settings.showComments = Boolean(value);
 		else if (key === "showResolved") this.plugin.settings.showResolved = Boolean(value);
 		else if (key === "allowEmptyComments") this.plugin.settings.allowEmptyComments = Boolean(value);
-		await this.plugin.saveSettings();
-		if (key !== "author") this.plugin.refreshEditors();
+		else if (key === "authorColorsEnabled") this.plugin.settings.authorColorsEnabled = Boolean(value);
+	}
+
+	/** Persist one setting and run its side effects (editor refresh, ribbon sync).
+	 *  Shared by both the declarative and imperative settings paths. */
+	private async applySetting(key: DocCommentsSettingKey, value: unknown): Promise {
+		const previous = this.plugin.settings[key];
+		this.assignSetting(key, value);
+		const saved = await this.plugin.saveSettings();
+		if (saved.isErr()) {
+			this.assignSetting(key, previous);
+			this.refresh();
+			return;
+		}
+		if (key === "author") this.plugin.ensureCurrentAuthorColor();
+		this.plugin.refreshEditors();
 		if (key === "showComments") this.plugin.updateRibbon();
+		this.refresh();
 	}
 }
diff --git a/src/ui/card.ts b/src/ui/card.ts
index 4af087f..2f7aa12 100644
--- a/src/ui/card.ts
+++ b/src/ui/card.ts
@@ -1,5 +1,6 @@
 import { App, Component, MarkdownRenderer, Menu, setIcon } from "obsidian";
 import type { Result } from "better-result";
+import type { AuthorColorResolver } from "../author-colors";
 import { ParsedComment } from "../format/types";
 import { CardEntry, cardEntries, cardSignature, formatRelativeTime } from "./card-format";
 
@@ -42,6 +43,7 @@ export type CardView = {
 	/** Collapse a tall card to a "Show more" preview. Margin only — the sidebar
 	 *  scrolls its list, so sidebar cards stay full height. */
 	collapsible?: boolean;
+	colorForAuthor?: AuthorColorResolver;
 };
 
 /** A single margin comment card with the full Notion-style interaction set. */
@@ -128,6 +130,18 @@ export class Card {
 		this.el.toggleClass("is-active", active);
 	}
 
+	refreshAuthorColors(): void {
+		const colorForAuthor = this.view.colorForAuthor;
+		if (!colorForAuthor) return;
+		this.el.querySelectorAll(".dc-entry__author[data-dc-author]").forEach((authorEl) => {
+			const author = authorEl.dataset.dcAuthor;
+			if (!author) return;
+			const color = colorForAuthor(author);
+			if (color) authorEl.style.setProperty("--dc-author-color", color);
+			else authorEl.style.removeProperty("--dc-author-color");
+		});
+	}
+
 	private setOpen(open: boolean): void {
 		if (this.open === open) return;
 		const fromHeight = this.clipEl?.offsetHeight ?? 0;
@@ -181,6 +195,7 @@ export class Card {
 		const thread = clip.createDiv("dc-thread");
 		this.threadEl = thread;
 		cardEntries(c).forEach((entry, i) => this.renderEntry(thread, entry, i));
+		this.refreshAuthorColors();
 		if (this.open && this.editingIndex < 0) this.renderComposer(clip);
 
 		this.footEl = this.el.createDiv("dc-card-foot");
@@ -270,7 +285,11 @@ export class Card {
 		this.iconButton(bar, "more-horizontal", "More", (e) => this.openMoreMenu(e, i));
 
 		const head = row.createDiv("dc-entry__head");
-		head.createSpan({ cls: "dc-entry__author", text: entry.author || "—" });
+		head.createSpan({
+			cls: "dc-entry__author",
+			text: entry.author || "—",
+			attr: entry.author ? { "data-dc-author": entry.author } : undefined,
+		});
 		const time = formatRelativeTime(entry.timestamp ?? (i === 0 ? this.comment.createdAt : undefined));
 		if (time) head.createSpan({ cls: "dc-entry__time", text: time });
 
diff --git a/src/ui/sidebar.ts b/src/ui/sidebar.ts
index 23602c0..68283a0 100644
--- a/src/ui/sidebar.ts
+++ b/src/ui/sidebar.ts
@@ -1,6 +1,7 @@
 import { App, Debouncer, ItemView, MarkdownView, Notice, TFile, WorkspaceLeaf, debounce } from "obsidian";
 import { EditorView } from "@codemirror/view";
 import { Result } from "better-result";
+import type { AuthorColorResolver } from "../author-colors";
 import { ParsedComment } from "../format/types";
 import { anchorRange, hasCommentCard, parseComments } from "../format/parse";
 import { Card, CardCallbacks } from "./card";
@@ -24,6 +25,7 @@ export const COMMENTS_VIEW_TYPE = "document-comments-sidebar";
 export type SidebarDeps = {
 	app: App;
 	getAuthor: () => string;
+	colorForAuthor: AuthorColorResolver;
 };
 
 /** Panel-local status filter — independent of the document's resolved setting. */
@@ -234,7 +236,11 @@ export class CommentsSidebarView extends ItemView {
 				this.cards.delete(id);
 			}
 		}
-		const cardView = { app: this.app, sourcePath: () => this.file?.path ?? "" };
+		const cardView = {
+			app: this.app,
+			sourcePath: () => this.file?.path ?? "",
+			colorForAuthor: this.deps.colorForAuthor,
+		};
 		const desired: HTMLElement[] = [];
 		for (const c of comments) {
 			const existing = this.cards.get(c.id);
@@ -244,6 +250,7 @@ export class CommentsSidebarView extends ItemView {
 				desired.push(card.el);
 			} else {
 				if (existing.signature !== cardSignature(c)) existing.update(c);
+				existing.refreshAuthorColors();
 				desired.push(existing.el);
 			}
 		}
diff --git a/src/util/dom.ts b/src/util/dom.ts
new file mode 100644
index 0000000..75a931f
--- /dev/null
+++ b/src/util/dom.ts
@@ -0,0 +1,7 @@
+export const isHtmlElement = (value: unknown): value is HTMLElement => {
+	if (typeof value !== "object" || value === null) return false;
+	const node = value as Node;
+	const htmlElement = node.ownerDocument?.defaultView?.HTMLElement;
+	if (htmlElement) return value instanceof htmlElement;
+	return node.nodeType === 1;
+};
diff --git a/styles.css b/styles.css
index 1d25238..4b5ef25 100644
--- a/styles.css
+++ b/styles.css
@@ -6,9 +6,15 @@ body {
 	--dc-margin-width: 320px;
 	--dc-card-pad: 16px;
 	--dc-card-gap: 8px;
-	--dc-highlight-bg: hsla(45, 90%, 50%, 0.18);
-	--dc-highlight-bg-active: hsla(45, 90%, 50%, 0.38);
-	--dc-highlight-border: hsla(45, 90%, 45%, 0.7);
+	--dc-highlight-color: var(--text-normal);
+	--dc-highlight-bg: color-mix(in srgb, var(--dc-highlight-color) 18%, transparent);
+	--dc-highlight-bg-active: color-mix(in srgb, var(--dc-highlight-color) 38%, transparent);
+	--dc-highlight-border: color-mix(in srgb, var(--dc-highlight-color) 70%, transparent);
+}
+
+.dc-author-color-setting {
+	align-items: center;
+	padding-block: 10px;
 }
 
 /* Layout is keyed on a `dc-has` class set on the editor / reading-view container.
@@ -52,6 +58,9 @@ body {
 
 /* The highlighted span in the text. */
 .doc-comment-span {
+	--dc-highlight-bg: color-mix(in srgb, var(--dc-highlight-color) 18%, transparent);
+	--dc-highlight-bg-active: color-mix(in srgb, var(--dc-highlight-color) 38%, transparent);
+	--dc-highlight-border: color-mix(in srgb, var(--dc-highlight-color) 70%, transparent);
 	background-color: var(--dc-highlight-bg);
 	border-bottom: 2px solid var(--dc-highlight-border);
 	border-radius: 2px;
@@ -82,19 +91,10 @@ body {
 }
 .doc-comment-span.is-resolved {
 	background-color: transparent;
-	border-bottom: 1px dashed var(--text-faint);
-}
-/* Obsidian renders Live Preview tables as nested editor widgets, outside the
-   parent CodeMirror decoration tree. The table view plugin maps comment anchors
-   into those cells and exposes non-mutating CSS Custom Highlight ranges. */
-::highlight(document-comments-table) {
-	background-color: hsla(45, 90%, 50%, 0.18);
-	text-decoration: underline;
-}
-::highlight(document-comments-table-resolved) {
-	background-color: hsla(0, 0%, 50%, 0.12);
-	text-decoration: underline;
+	border-bottom: 1px dashed var(--dc-highlight-border);
 }
+/* Live Preview table rules are generated per owner document because CSS Custom
+   Highlight ranges cannot carry per-range custom properties. */
 /* A selected no-space marker needs distinct caret endpoints, but the raw HTML
    comment must never become visible or take layout width (especially in tables). */
 .dc-comment-marker {
@@ -250,7 +250,10 @@ body {
 }
 .dc-entry__author {
 	font-weight: var(--font-semibold);
-	color: var(--text-normal);
+	color: color-mix(in srgb, var(--dc-author-color, var(--text-normal)) 40%, var(--text-normal));
+}
+.theme-dark .dc-entry__author {
+	color: color-mix(in srgb, var(--dc-author-color, var(--text-normal)) 70%, var(--text-normal));
 }
 .dc-entry__time {
 	color: var(--text-muted);
@@ -513,8 +516,9 @@ body {
 }
 /* The text being commented on, highlighted while the draft is open. */
 .doc-comment-span.dc-draft {
+	--dc-highlight-color: var(--dc-draft-highlight-color, var(--text-normal));
 	background-color: var(--dc-highlight-bg-active);
-	border-bottom-color: var(--interactive-accent);
+	border-bottom-color: var(--dc-highlight-border);
 }
 
 /* ── "All discussions" sidebar panel ───────────────────────────────────── */
diff --git a/test/author-colors.test.ts b/test/author-colors.test.ts
new file mode 100644
index 0000000..14f3dd9
--- /dev/null
+++ b/test/author-colors.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, test } from "vitest";
+import {
+	AUTHOR_COLOR_PALETTE,
+	canonicalAuthorKey,
+	commentersForComments,
+	creatorsForComments,
+	effectiveHighlightColor,
+	ensureAuthorColor,
+	ensureAuthorColors,
+	hydrateAuthorColors,
+	hydrateExcludedAuthors,
+	resetAuthorColor,
+	resolveAuthorColor,
+	type AuthorColorAssignments,
+} from "../src/author-colors";
+import { parseComments } from "../src/format/parse";
+
+describe("author highlight colors", () => {
+	test("assigns every palette color before reusing one", () => {
+		const assignments: AuthorColorAssignments = {};
+		const firstTwelve = Array.from(
+			{ length: AUTHOR_COLOR_PALETTE.length },
+			(_, index) => ensureAuthorColor(assignments, `author-${index}`).assignment.color,
+		);
+
+		expect(new Set(firstTwelve)).toEqual(new Set(AUTHOR_COLOR_PALETTE));
+		expect(firstTwelve).toHaveLength(new Set(firstTwelve).size);
+		expect(AUTHOR_COLOR_PALETTE).toContain(ensureAuthorColor(assignments, "author-12").assignment.color);
+	});
+
+	test("reserves a palette color used by a manual override", () => {
+		const assignments: AuthorColorAssignments = {
+			custom: { color: AUTHOR_COLOR_PALETTE[0], mode: "custom" },
+		};
+
+		expect(ensureAuthorColor(assignments, "generated").assignment.color).toBe(AUTHOR_COLOR_PALETTE[1]);
+	});
+
+	test("assigns colors to every existing discovered author while the feature is off", () => {
+		const assignments: AuthorColorAssignments = {};
+
+		expect(ensureAuthorColors(assignments, ["Carol", "Alice", "Bob"], new Set())).toBe(true);
+		expect(Object.keys(assignments)).toEqual(["Alice", "Bob", "Carol"]);
+		expect(new Set(Object.values(assignments).map(({ color }) => color)).size).toBe(3);
+	});
+
+	test("persists generated assignments and resets custom colors", () => {
+		const assignments: AuthorColorAssignments = {};
+		const original = ensureAuthorColor(assignments, "Kyle McDonald").assignment;
+		const reloaded = hydrateAuthorColors(JSON.parse(JSON.stringify(assignments)));
+
+		expect(ensureAuthorColor(reloaded, "Kyle McDonald")).toEqual({
+			author: "Kyle_McDonald",
+			assignment: original,
+			created: false,
+		});
+		reloaded.Kyle_McDonald = { color: "#abcdef", mode: "custom" };
+		expect(resetAuthorColor(reloaded, "Kyle McDonald").mode).toBe("generated");
+	});
+
+	test("normalizes valid stored colors and discards malformed assignments", () => {
+		expect(
+			hydrateAuthorColors({
+				Alice: { color: "#AABBCC", mode: "custom" },
+				Bob: { color: "blue", mode: "generated" },
+				Carol: { color: "#123456", mode: "unknown" },
+			}),
+		).toEqual({ Alice: { color: "#aabbcc", mode: "custom" } });
+	});
+
+	test("hydrates a canonical, deduplicated author color opt-out list", () => {
+		expect(hydrateExcludedAuthors([" Alice ", "Bob Smith", "Alice", 42, ""])).toEqual(["Alice", "Bob_Smith"]);
+		expect(hydrateExcludedAuthors({ Alice: true })).toEqual([]);
+	});
+
+	test("creates initial colors but returns no color when globally disabled or individually excluded", () => {
+		const assignments: AuthorColorAssignments = {};
+		const disabled = resolveAuthorColor(assignments, new Set(), "Alice", false);
+
+		expect(disabled.color).toBeNull();
+		expect(disabled.created).toBe(true);
+		expect(assignments.Alice).toBeDefined();
+
+		const excluded = resolveAuthorColor(assignments, new Set(["Bob"]), "Bob", true);
+		expect(excluded).toEqual({ author: "Bob", color: null, created: false });
+		expect(assignments.Bob).toBeUndefined();
+	});
+
+	test("restores the legacy yellow highlight only when author colors are globally disabled", () => {
+		expect(effectiveHighlightColor("#0090ff", false)).toBe("#f2b90d");
+		expect(effectiveHighlightColor(null, false)).toBe("#f2b90d");
+		expect(effectiveHighlightColor(null, true)).toBeNull();
+	});
+
+	test("canonicalizes whitespace exactly like the stored by token", () => {
+		expect(canonicalAuthorKey("  Kyle  McDonald\tJr. ")).toBe("Kyle_McDonald_Jr.");
+	});
+
+	test("indexes original creators, falls back for legacy comments, and excludes reply-only authors", () => {
+		const doc = [
+			"",
+			"",
+		].join("\n");
+
+		expect(creatorsForComments(parseComments(doc))).toEqual(["Alice", "Legacy_Author"]);
+	});
+
+	test("indexes reply authors whose names are rendered on comment cards", () => {
+		const doc = [
+			"",
+		].join("\n");
+
+		expect(commentersForComments(parseComments(doc))).toEqual(["Alice", "Bob_Smith"]);
+	});
+});
diff --git a/test/author-index.test.ts b/test/author-index.test.ts
new file mode 100644
index 0000000..71409db
--- /dev/null
+++ b/test/author-index.test.ts
@@ -0,0 +1,140 @@
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, test, vi } from "vitest";
+import type { TFile, Vault } from "obsidian";
+import { createAuthorIndex } from "../src/author-index";
+
+type FakeFile = TFile & { path: string; extension: string };
+
+const file = (path: string): FakeFile => ({ path, extension: "md" }) as FakeFile;
+const body = (id: string, author: string): string => ``;
+
+afterEach(() => vi.useRealTimers());
+
+describe("author index", () => {
+	test("deduplicates creators and retains usable data after read failures", async () => {
+		const files = [file("a.md"), file("b.md"), file("broken.md")];
+		const vault = {
+			getMarkdownFiles: () => files,
+			cachedRead: async (target: FakeFile) => {
+				if (target.path === "broken.md") throw new Error("permission denied");
+				return target.path === "a.md" ? `${body("a", "Alice")}\n${body("b", "Alice")}` : body("c", "Bob");
+			},
+		} as unknown as Pick;
+		const index = createAuthorIndex(vault);
+
+		await expect(index.scan()).resolves.toEqual({
+			status: "partial",
+			authors: ["Alice", "Bob"],
+			errors: [{ path: "broken.md", message: "permission denied" }],
+		});
+	});
+
+	test("updates per-file creators across modify, rename, and delete events", async () => {
+		vi.useFakeTimers();
+		const note = file("note.md");
+		const files = [note];
+		const contents = new Map([[note.path, body("a", "Alice")]]);
+		const vault = {
+			getMarkdownFiles: () => files,
+			cachedRead: async (target: FakeFile) => contents.get(target.path) ?? "",
+		} as unknown as Pick;
+		const index = createAuthorIndex(vault);
+		await index.scan();
+
+		contents.set(note.path, body("b", "Bob"));
+		index.scheduleRefresh(note);
+		await vi.runAllTimersAsync();
+		expect(index.getState().authors).toEqual(["Bob"]);
+
+		const renamed = file("renamed.md");
+		contents.set(renamed.path, body("b", "Bob"));
+		index.rename(renamed, note.path);
+		expect(index.getState().authors).toEqual(["Bob"]);
+		await vi.runAllTimersAsync();
+
+		index.remove(renamed.path);
+		expect(index.getState()).toEqual({ status: "ready", authors: [] });
+	});
+
+	test("retains the last successful creator set when a refresh fails", async () => {
+		vi.useFakeTimers();
+		const note = file("note.md");
+		let fail = false;
+		const vault = {
+			getMarkdownFiles: () => [note],
+			cachedRead: async () => {
+				if (fail) throw new Error("temporarily locked");
+				return body("a", "Alice");
+			},
+		} as unknown as Pick;
+		const index = createAuthorIndex(vault);
+		await index.scan();
+
+		fail = true;
+		index.scheduleRefresh(note);
+		await vi.runAllTimersAsync();
+
+		expect(index.getState()).toEqual({
+			status: "partial",
+			authors: ["Alice"],
+			errors: [{ path: "note.md", message: "temporarily locked" }],
+		});
+	});
+
+	test("does not let stale full-scan reads undo newer modify and delete events", async () => {
+		vi.useFakeTimers();
+		const modified = file("modified.md");
+		const deleted = file("deleted.md");
+		let resolveModifiedScan: (content: string) => void = () => {};
+		let resolveDeletedScan: (content: string) => void = () => {};
+		let modifiedReads = 0;
+		const vault = {
+			getMarkdownFiles: () => [modified, deleted],
+			cachedRead: async (target: FakeFile) => {
+				if (target.path === modified.path && modifiedReads++ > 0) return body("new", "Bob");
+				return new Promise((resolve) => {
+					if (target.path === modified.path) resolveModifiedScan = resolve;
+					else resolveDeletedScan = resolve;
+				});
+			},
+		} as unknown as Pick;
+		const index = createAuthorIndex(vault);
+		const scan = index.scan();
+
+		index.scheduleRefresh(modified);
+		index.remove(deleted.path);
+		await vi.runAllTimersAsync();
+		expect(index.getState().authors).toEqual(["Bob"]);
+
+		resolveModifiedScan(body("old", "Alice"));
+		resolveDeletedScan(body("gone", "Carol"));
+		await scan;
+
+		expect(index.getState()).toEqual({ status: "ready", authors: ["Bob"] });
+	});
+
+	test("does not scan a deleted file that was still waiting in a later batch", async () => {
+		const files = Array.from({ length: 9 }, (_, index) => file(`note-${index}.md`));
+		const deleted = files[8]!;
+		let releaseFirstBatch: () => void = () => {};
+		const firstBatch = new Promise((resolve) => {
+			releaseFirstBatch = resolve;
+		});
+		const vault = {
+			getMarkdownFiles: () => files,
+			cachedRead: async (target: FakeFile) => {
+				if (target.path === deleted.path) return body("gone", "Carol");
+				await firstBatch;
+				return "";
+			},
+		} as unknown as Pick;
+		const index = createAuthorIndex(vault);
+		const scan = index.scan();
+
+		index.remove(deleted.path);
+		releaseFirstBatch();
+		await scan;
+
+		expect(index.getState()).toEqual({ status: "ready", authors: [] });
+	});
+});
diff --git a/test/card.test.ts b/test/card.test.ts
index aa125bb..e18331a 100644
--- a/test/card.test.ts
+++ b/test/card.test.ts
@@ -85,6 +85,39 @@ const callbacks = (): CardCallbacks => ({
 });
 
 describe("empty comment card", () => {
+	test("colors every displayed author name with that author's assignment", () => {
+		const comment = {
+			...commentWithText(),
+			thread: [
+				{ author: "kyle", text: "Original" },
+				{ author: "Cathy", text: "Reply" },
+			],
+		};
+		let changed: "custom" | "deleted" | "initial" = "initial";
+		const card = new Card(comment, callbacks(), {
+			sourcePath: () => "note.md",
+			colorForAuthor: (author) => {
+				if (author === "kyle") return "#0090ff";
+				if (changed === "deleted") return null;
+				return changed === "custom" ? "#6e56cf" : "#e54d2e";
+			},
+		});
+		const authors = [...card.el.querySelectorAll(".dc-entry__author")];
+
+		expect(authors.map((author) => author.dataset.dcAuthor)).toEqual(["kyle", "Cathy"]);
+		expect(authors.map((author) => author.style.getPropertyValue("--dc-author-color"))).toEqual([
+			"#0090ff",
+			"#e54d2e",
+		]);
+		changed = "custom";
+		card.refreshAuthorColors();
+		expect(authors[1]?.style.getPropertyValue("--dc-author-color")).toBe("#6e56cf");
+		changed = "deleted";
+		card.refreshAuthorColors();
+		expect(authors[1]?.style.getPropertyValue("--dc-author-color")).toBe("");
+		card.destroy();
+	});
+
 	test("shows an Empty placeholder and saves its first text as a reply", () => {
 		const cb = callbacks();
 		const card = new Card(emptyComment(), cb, { sourcePath: () => "note.md" });
diff --git a/test/decorations.test.ts b/test/decorations.test.ts
index d4f1261..737c305 100644
--- a/test/decorations.test.ts
+++ b/test/decorations.test.ts
@@ -1,8 +1,20 @@
 import { describe, expect, test } from "vitest";
 import { EditorState } from "@codemirror/state";
-import { commentField } from "../src/editor/state";
+import { commentField, refreshCommentColors } from "../src/editor/state";
+import { commentConfig } from "../src/editor/config";
 import { parseComments } from "../src/format/parse";
 import { applyChanges, computeAddComment } from "../src/editor/edits";
+import type { ResolvedAuthorColor } from "../src/author-colors";
+
+const decorationAttributes = (state: EditorState): Array> => {
+	const attributes: Array> = [];
+	const cursor = state.field(commentField).decorations.iter();
+	while (cursor.value) {
+		if (cursor.value.spec?.attributes) attributes.push(cursor.value.spec.attributes);
+		cursor.next();
+	}
+	return attributes;
+};
 
 // Two comments anchored on overlapping text — `xoua6` sits nested inside `zz1q`,
 // both covering "resolved". This is the shape that crashed CodeMirror's
@@ -18,6 +30,69 @@ const NESTED = [
 ].join("\n");
 
 describe("commentField decorations", () => {
+	test("carries the original creator and live configured color", () => {
+		let color: ResolvedAuthorColor = "#0090ff";
+		const doc = [
+			"Ship Friday.",
+			'",
+		].join("\n");
+		const extensions = [
+			commentConfig.of({
+				author: () => "me",
+				colorForAuthor: () => color,
+				showComments: () => true,
+				showResolved: () => true,
+				allowEmptyComments: () => false,
+				sidebarOpen: () => false,
+			}),
+			commentField,
+		];
+		const state = EditorState.create({ doc, extensions });
+		const first = [...decorationAttributes(state)].find((attributes) => attributes["data-cid"] === "c1");
+		expect(first).toMatchObject({
+			"data-dc-author": "Alice",
+			style: "--dc-highlight-color: #0090ff",
+		});
+
+		color = "#e54d2e";
+		const refreshed = state.update({ effects: refreshCommentColors.of(null) }).state;
+		const second = [...decorationAttributes(refreshed)].find((attributes) => attributes["data-cid"] === "c1");
+		expect(second?.style).toBe("--dc-highlight-color: #e54d2e");
+
+		color = null;
+		const uncolored = refreshed.update({ effects: refreshCommentColors.of(null) }).state;
+		const third = [...decorationAttributes(uncolored)].find((attributes) => attributes["data-cid"] === "c1");
+		expect(third?.style).toBe("--dc-highlight-color: var(--text-normal)");
+	});
+
+	test("uses a separate legacy-yellow resolver when author colors are disabled", () => {
+		const doc = [
+			"Ship Friday.",
+			'",
+		].join("\n");
+		const state = EditorState.create({
+			doc,
+			extensions: [
+				commentConfig.of({
+					author: () => "me",
+					colorForAuthor: () => null,
+					highlightColorForAuthor: () => "#f2b90d",
+					showComments: () => true,
+					showResolved: () => true,
+					allowEmptyComments: () => false,
+					sidebarOpen: () => false,
+				}),
+				commentField,
+			],
+		});
+
+		const highlight = [...decorationAttributes(state)].find((attributes) => attributes["data-cid"] === "c1");
+		expect(highlight?.style).toBe("--dc-highlight-color: #f2b90d");
+	});
 	test("overlapping/nested comment anchors build and map without crashing", () => {
 		// EditorState.create runs compute(); .map() is what CodeMirror does to the
 		// decoration set on setViewData — both must survive overlapping anchors.
diff --git a/test/dom.test.ts b/test/dom.test.ts
new file mode 100644
index 0000000..85e10ef
--- /dev/null
+++ b/test/dom.test.ts
@@ -0,0 +1,21 @@
+// @vitest-environment happy-dom
+import { describe, expect, test } from "vitest";
+import { isHtmlElement } from "../src/util/dom";
+
+describe("realm-safe DOM guards", () => {
+	test("accepts an HTMLElement created by another window realm", () => {
+		class ForeignHTMLElement {}
+		const element = Object.assign(new ForeignHTMLElement(), {
+			nodeType: 1,
+			ownerDocument: { defaultView: { HTMLElement: ForeignHTMLElement } },
+		});
+
+		expect(element instanceof HTMLElement).toBe(false);
+		expect(isHtmlElement(element)).toBe(true);
+	});
+
+	test("rejects non-elements", () => {
+		expect(isHtmlElement(null)).toBe(false);
+		expect(isHtmlElement({ nodeType: 3 })).toBe(false);
+	});
+});
diff --git a/test/editor-view.test.ts b/test/editor-view.test.ts
index c76a9dd..19fe8cd 100644
--- a/test/editor-view.test.ts
+++ b/test/editor-view.test.ts
@@ -415,4 +415,35 @@ describe("editor extensions open every note without crashing", () => {
 		expect(className).not.toContain("dc-has"); // draft is a floating overlay, no column reserved
 		expect(className).toContain("dc-highlights"); // highlights still follow the master toggle
 	});
+
+	test("publishes a separate current-author color for drafts nested in another author's highlight", () => {
+		const parent = document.createElement("div");
+		document.body.appendChild(parent);
+		const doc = [
+			"Ship on Friday regardless.",
+			'",
+		].join("\n");
+		const colored = commentConfig.of({
+			author: () => "Bob",
+			colorForAuthor: (author) => (author === "Alice" ? "#0090ff" : "#e54d2e"),
+			highlightColorForAuthor: (author) => (author === "Alice" ? "#0090ff" : "#e54d2e"),
+			showComments: () => true,
+			showResolved: () => true,
+			allowEmptyComments: () => false,
+			sidebarOpen: () => false,
+		});
+		const view = new EditorView({
+			state: EditorState.create({ doc, extensions: [commentField, draftField, colored, editorLayoutField] }),
+			parent,
+		});
+		const from = doc.indexOf("Friday");
+
+		view.dispatch({ effects: setDraft.of({ from, to: from + "Friday".length }) });
+
+		expect(view.dom.style.getPropertyValue("--dc-draft-highlight-color")).toBe("#e54d2e");
+		expect(view.contentDOM.querySelector(".dc-draft")).not.toBeNull();
+		view.destroy();
+	});
 });
diff --git a/test/obsidian-mock.ts b/test/obsidian-mock.ts
index a9378fa..3958253 100644
--- a/test/obsidian-mock.ts
+++ b/test/obsidian-mock.ts
@@ -1,5 +1,80 @@
 export class App {}
 
+export class Plugin {
+	readonly app: App;
+
+	constructor(app: App = new App()) {
+		this.app = app;
+	}
+
+	async loadData(): Promise {
+		return null;
+	}
+
+	async saveData(_data: unknown): Promise {}
+}
+
+export class Editor {}
+
+export class TAbstractFile {
+	constructor(readonly path = "") {}
+}
+
+export class TFile extends TAbstractFile {
+	constructor(
+		path = "",
+		readonly extension = "md",
+	) {
+		super(path);
+	}
+}
+
+export class WorkspaceLeaf {
+	constructor(readonly view: unknown = null) {}
+}
+
+export class ItemView {
+	readonly app = new App();
+	readonly containerEl = document.createElement("div");
+	readonly contentEl = document.createElement("div");
+
+	constructor(readonly leaf: WorkspaceLeaf) {
+		this.containerEl.appendChild(this.contentEl);
+	}
+}
+
+export const Platform = { isMobile: false };
+
+export const debounce = (callback: (...args: Args) => void, delay: number) => {
+	let timer: number | null = null;
+	const debounced = (...args: Args): void => {
+		if (timer !== null) window.clearTimeout(timer);
+		timer = window.setTimeout(() => {
+			timer = null;
+			callback(...args);
+		}, delay);
+	};
+	debounced.cancel = (): void => {
+		if (timer !== null) window.clearTimeout(timer);
+		timer = null;
+	};
+	return debounced;
+};
+
+export class MarkdownView {
+	readonly containerEl = document.createElement("div");
+	file: unknown = null;
+	editor: unknown = {};
+
+	getMode(): string {
+		return "preview";
+	}
+}
+
+export class Notice {
+	constructor(readonly message: string) {}
+}
+
 export class Modal {
 	readonly contentEl = document.createElement("div");
 	readonly titleEl = document.createElement("div");
@@ -39,22 +114,130 @@ class ButtonComponent {
 	setCta(): this {
 		return this;
 	}
+
+	setDisabled(disabled: boolean): this {
+		this.buttonEl.disabled = disabled;
+		return this;
+	}
+}
+
+class ValueComponent {
+	constructor(readonly inputEl: HTMLInputElement) {}
+
+	setValue(value: T): this {
+		if (typeof value === "boolean") this.inputEl.checked = value;
+		else this.inputEl.value = String(value);
+		return this;
+	}
+
+	onChange(callback: (value: T) => void): this {
+		this.inputEl.addEventListener("change", () => {
+			const value = (this.inputEl.type === "checkbox" ? this.inputEl.checked : this.inputEl.value) as T;
+			callback(value);
+		});
+		return this;
+	}
+
+	setPlaceholder(value: string): this {
+		this.inputEl.placeholder = value;
+		return this;
+	}
+}
+
+class ExtraButtonComponent extends ButtonComponent {
+	setIcon(icon: string): this {
+		this.buttonEl.dataset.icon = icon;
+		return this;
+	}
+
+	setTooltip(tooltip: string): this {
+		this.buttonEl.title = tooltip;
+		return this;
+	}
 }
 
 export class Setting {
-	private readonly settingEl: HTMLDivElement;
+	readonly settingEl: HTMLDivElement;
+	private readonly nameEl: HTMLDivElement;
+	private readonly descEl: HTMLDivElement;
+	private readonly controlEl: HTMLDivElement;
 
 	constructor(containerEl: HTMLElement) {
 		this.settingEl = document.createElement("div");
+		this.settingEl.className = "setting-item";
+		this.nameEl = document.createElement("div");
+		this.nameEl.className = "setting-item-name";
+		this.descEl = document.createElement("div");
+		this.descEl.className = "setting-item-description";
+		this.controlEl = document.createElement("div");
+		this.controlEl.className = "setting-item-control";
+		this.settingEl.append(this.nameEl, this.descEl, this.controlEl);
 		containerEl.appendChild(this.settingEl);
 	}
 
+	setName(name: string): this {
+		this.nameEl.textContent = name;
+		return this;
+	}
+
+	setDesc(description: string): this {
+		this.descEl.textContent = description;
+		return this;
+	}
+
+	setHeading(): this {
+		this.settingEl.classList.add("setting-item-heading");
+		return this;
+	}
+
 	addButton(configure: (button: ButtonComponent) => void): this {
 		const button = document.createElement("button");
-		this.settingEl.appendChild(button);
+		this.controlEl.appendChild(button);
 		configure(new ButtonComponent(button));
 		return this;
 	}
+
+	addExtraButton(configure: (button: ExtraButtonComponent) => void): this {
+		const button = document.createElement("button");
+		this.controlEl.appendChild(button);
+		configure(new ExtraButtonComponent(button));
+		return this;
+	}
+
+	addText(configure: (component: ValueComponent) => void): this {
+		const input = document.createElement("input");
+		input.type = "text";
+		this.controlEl.appendChild(input);
+		configure(new ValueComponent(input));
+		return this;
+	}
+
+	addToggle(configure: (component: ValueComponent) => void): this {
+		const input = document.createElement("input");
+		input.type = "checkbox";
+		this.controlEl.appendChild(input);
+		configure(new ValueComponent(input));
+		return this;
+	}
+
+	addColorPicker(configure: (component: ValueComponent) => void): this {
+		const input = document.createElement("input");
+		input.type = "color";
+		this.controlEl.appendChild(input);
+		configure(new ValueComponent(input));
+		return this;
+	}
+}
+
+export class PluginSettingTab {
+	readonly containerEl = document.createElement("div");
+
+	constructor(
+		readonly app: App,
+		readonly plugin: unknown,
+	) {}
+
+	update(): void {}
 }
 
 export class Component {
diff --git a/test/plugin-settings.test.ts b/test/plugin-settings.test.ts
new file mode 100644
index 0000000..48d1e45
--- /dev/null
+++ b/test/plugin-settings.test.ts
@@ -0,0 +1,87 @@
+// @vitest-environment happy-dom
+import { describe, expect, test, vi } from "vitest";
+import DocCommentsPlugin from "../src/main";
+import { DEFAULT_SETTINGS } from "../src/settings";
+
+const createPlugin = (): DocCommentsPlugin => {
+	const PluginConstructor = DocCommentsPlugin as unknown as new () => DocCommentsPlugin;
+	return new PluginConstructor();
+};
+
+describe("plugin settings persistence", () => {
+	test("persists an initial generated assignment and restores it on reload", async () => {
+		const first = createPlugin();
+		let saved: unknown = null;
+		vi.spyOn(first, "loadData").mockResolvedValue({
+			author: "Alice",
+			authorColorsEnabled: true,
+			authorColors: {},
+			excludedAuthorColors: [],
+		});
+		vi.spyOn(first, "saveData").mockImplementation(async (data) => {
+			saved = structuredClone(data);
+		});
+
+		await first.loadSettings();
+		const assignment = first.settings.authorColors.Alice;
+
+		expect(assignment).toBeDefined();
+		expect(saved).not.toBeNull();
+
+		const reloaded = createPlugin();
+		vi.spyOn(reloaded, "loadData").mockResolvedValue(saved);
+		const reloadSave = vi.spyOn(reloaded, "saveData").mockResolvedValue();
+		await reloaded.loadSettings();
+
+		expect(reloaded.settings.authorColors.Alice).toEqual(assignment);
+		expect(reloadSave).not.toHaveBeenCalled();
+	});
+
+	test("falls back without overwriting data when plugin settings fail to load", async () => {
+		const plugin = createPlugin();
+		vi.spyOn(plugin, "loadData").mockRejectedValue(new Error("vault unavailable"));
+		const saveData = vi.spyOn(plugin, "saveData").mockResolvedValue();
+
+		await expect(plugin.loadSettings()).resolves.toBeUndefined();
+
+		expect(plugin.settings.authorColorsEnabled).toBe(DEFAULT_SETTINGS.authorColorsEnabled);
+		expect(plugin.settingsError()).toBe("Couldn't load settings: vault unavailable");
+		expect(saveData).not.toHaveBeenCalled();
+	});
+
+	test("rolls back picker, delete, and restore mutations after rejected writes", async () => {
+		const plugin = createPlugin();
+		plugin.settings = {
+			...DEFAULT_SETTINGS,
+			authorColorsEnabled: true,
+			authorColors: { Alice: { color: "#0090ff", mode: "generated" } },
+			excludedAuthorColors: [],
+		};
+		vi.spyOn(plugin, "saveData").mockRejectedValue(new Error("disk full"));
+		vi.spyOn(plugin, "refreshEditors").mockImplementation(() => {});
+
+		await plugin.setAuthorColor("Alice", "#abcdef");
+		expect(plugin.settings.authorColors.Alice).toEqual({ color: "#0090ff", mode: "generated" });
+
+		await plugin.deleteAuthorColor("Alice");
+		expect(plugin.settings.authorColors.Alice).toEqual({ color: "#0090ff", mode: "generated" });
+		expect(plugin.settings.excludedAuthorColors).toEqual([]);
+
+		const restorePlugin = createPlugin();
+		vi.spyOn(restorePlugin, "loadData").mockResolvedValue({
+			...DEFAULT_SETTINGS,
+			author: "Bob",
+			authorColorsEnabled: true,
+			authorColors: { Bob: { color: "#e54d2e", mode: "generated" } },
+			excludedAuthorColors: ["Alice"],
+		});
+		await restorePlugin.loadSettings();
+		vi.spyOn(restorePlugin, "saveData").mockRejectedValue(new Error("disk full"));
+		vi.spyOn(restorePlugin, "refreshEditors").mockImplementation(() => {});
+
+		await restorePlugin.restoreAuthorColor("Alice");
+		expect(restorePlugin.settings.authorColors.Alice).toBeUndefined();
+		expect(restorePlugin.settings.excludedAuthorColors).toEqual(["Alice"]);
+		expect(restorePlugin.settingsError()).toBe("Couldn't persist highlight colors: disk full");
+	});
+});
diff --git a/test/reading-highlight.test.ts b/test/reading-highlight.test.ts
index 8004ee3..d29c629 100644
--- a/test/reading-highlight.test.ts
+++ b/test/reading-highlight.test.ts
@@ -48,6 +48,42 @@ describe("reading-view highlight post-processor", () => {
 		const span = el.querySelector(".doc-comment-span[data-cid='p1']");
 		expect(span?.textContent).toBe("Friday");
 		expect(span?.getAttribute("title")).toBe("me: ok");
+		expect(span?.getAttribute("data-dc-author")).toBe("me");
+	});
+
+	test("applies the original creator's configured color", () => {
+		const doc = [
+			"We ship on Friday regardless.",
+			'",
+		].join("\n");
+		const el = document.createElement("p");
+		el.textContent = "We ship on Friday regardless.";
+
+		highlightPostProcessor(el, ctxFor(doc, 0, 0), () => "#0090ff");
+		const span = el.querySelector(".doc-comment-span[data-cid='p2']");
+
+		expect(span?.dataset.dcAuthor).toBe("Alice");
+		expect(span?.style.getPropertyValue("--dc-highlight-color")).toBe("#0090ff");
+		expect(span?.classList.contains("is-resolved")).toBe(true);
+	});
+
+	test("uses the normal theme text color when an author has no mapping", () => {
+		const doc = [
+			"Ship Friday.",
+			'",
+		].join("\n");
+		const el = document.createElement("p");
+		el.textContent = "Ship Friday.";
+
+		highlightPostProcessor(el, ctxFor(doc, 0, 0), () => null);
+
+		expect(el.querySelector(".doc-comment-span")?.style.getPropertyValue("--dc-highlight-color")).toBe(
+			"var(--text-normal)",
+		);
 	});
 
 	test("wraps an empty comment as a highlight without a preview", () => {
diff --git a/test/reading-margin.test.ts b/test/reading-margin.test.ts
new file mode 100644
index 0000000..9e92d42
--- /dev/null
+++ b/test/reading-margin.test.ts
@@ -0,0 +1,127 @@
+// @vitest-environment happy-dom
+import { beforeAll, describe, expect, test, vi } from "vitest";
+import { MarkdownView } from "obsidian";
+import { ReadingMarginManager, type ReadingDeps } from "../src/reading/margin";
+
+type ElementOptions = string | { cls?: string | string[]; text?: string; attr?: Record };
+
+const applyOptions = (element: HTMLElement, options?: ElementOptions): void => {
+	if (typeof options === "string") element.className = options;
+	else if (options) {
+		if (options.cls) element.className = Array.isArray(options.cls) ? options.cls.join(" ") : options.cls;
+		if (options.text !== undefined) element.textContent = options.text;
+		Object.entries(options.attr ?? {}).forEach(([name, value]) => element.setAttribute(name, value));
+	}
+};
+
+beforeAll(() => {
+	(globalThis as unknown as { createDiv: (options?: ElementOptions) => HTMLDivElement }).createDiv = (options) => {
+		const element = document.createElement("div");
+		applyOptions(element, options);
+		return element;
+	};
+	HTMLElement.prototype.createDiv = function (options?: ElementOptions) {
+		const element = this.ownerDocument.createElement("div");
+		applyOptions(element, options);
+		this.appendChild(element);
+		return element;
+	};
+	HTMLElement.prototype.createEl = function (tag: string, options?: ElementOptions) {
+		const element = this.ownerDocument.createElement(tag);
+		applyOptions(element, options);
+		this.appendChild(element);
+		return element;
+	};
+	HTMLElement.prototype.createSpan = function (options?: ElementOptions) {
+		const element = this.ownerDocument.createElement("span");
+		applyOptions(element, options);
+		this.appendChild(element);
+		return element;
+	};
+	HTMLElement.prototype.detach = function () {
+		this.remove();
+	};
+	HTMLElement.prototype.toggleClass = function (name: string, enabled: boolean) {
+		this.classList.toggle(name, enabled);
+	};
+	HTMLElement.prototype.removeClasses = function (names: string[]) {
+		this.classList.remove(...names);
+	};
+	HTMLElement.prototype.setCssStyles = function (styles: Partial) {
+		Object.assign(this.style, styles);
+	};
+});
+
+describe("reading margin windows", () => {
+	test("refreshes a reading container created in a pop-out window realm", () => {
+		class ForeignHTMLElement {
+			readonly nodeType = 1;
+			readonly ownerDocument = { defaultView: { HTMLElement: ForeignHTMLElement } };
+			readonly style = { setProperty: vi.fn() };
+			readonly toggleClass = vi.fn();
+			readonly removeClasses = vi.fn();
+		}
+		const readingView = new ForeignHTMLElement();
+		const view = new MarkdownView();
+		Object.defineProperty(view, "containerEl", {
+			value: { querySelector: () => readingView },
+		});
+		const deps = {
+			app: { workspace: { getLeavesOfType: () => [{ view }] } },
+			getAuthor: () => "Bob",
+			colorForAuthor: () => "#e54d2e",
+			highlightColorForAuthor: () => "#e54d2e",
+			showComments: () => true,
+			showResolved: () => false,
+			allowEmptyComments: () => false,
+			sidebarOpen: () => false,
+			isMobile: () => true,
+		} as unknown as ReadingDeps;
+
+		new ReadingMarginManager(deps).refresh();
+
+		expect(readingView.toggleClass).toHaveBeenCalledWith("dc-highlights", true);
+		expect(readingView.style.setProperty).toHaveBeenCalledWith("--dc-highlight-color", "#e54d2e");
+		expect(readingView.style.setProperty).toHaveBeenCalledWith("--dc-draft-highlight-color", "#e54d2e");
+	});
+
+	test("keeps an open nested draft live when the current author's color changes", () => {
+		let currentColor: "#e54d2e" | "#6e56cf" = "#e54d2e";
+		let mobile = false;
+		const wrapper = document.createElement("div");
+		const readingView = wrapper.createDiv("markdown-reading-view");
+		const scroller = readingView.createDiv("markdown-preview-view");
+		const existing = scroller.createSpan("doc-comment-span");
+		existing.style.setProperty("--dc-highlight-color", "#0090ff");
+		existing.textContent = "Friday";
+		const view = new MarkdownView();
+		Object.defineProperty(view, "containerEl", { value: wrapper });
+		const deps = {
+			app: { workspace: { getLeavesOfType: () => [{ view }] } },
+			getAuthor: () => "Bob",
+			colorForAuthor: () => currentColor,
+			highlightColorForAuthor: () => currentColor,
+			showComments: () => true,
+			showResolved: () => false,
+			allowEmptyComments: () => false,
+			sidebarOpen: () => false,
+			isMobile: () => mobile,
+		} as unknown as ReadingDeps;
+		const manager = new ReadingMarginManager(deps);
+		const range = document.createRange();
+		range.selectNodeContents(existing);
+
+		manager.startDraft(view, 0, 6, range, "Friday", "none");
+		const draft = existing.querySelector(".dc-draft");
+		expect(draft?.style.getPropertyValue("--dc-highlight-color")).toBe("");
+		expect(readingView.style.getPropertyValue("--dc-draft-highlight-color")).toBe("#e54d2e");
+
+		currentColor = "#6e56cf";
+		mobile = true;
+		manager.refresh();
+
+		expect(readingView.style.getPropertyValue("--dc-draft-highlight-color")).toBe("#6e56cf");
+		expect(draft?.style.getPropertyValue("--dc-highlight-color")).toBe("");
+		manager.destroy();
+	});
+});
diff --git a/test/settings-storage.test.ts b/test/settings-storage.test.ts
new file mode 100644
index 0000000..b47e3eb
--- /dev/null
+++ b/test/settings-storage.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test, vi } from "vitest";
+import { loadSettingsData, saveSettingsData } from "../src/settings-storage";
+
+describe("settings storage", () => {
+	test("turns a rejected plugin load into an explicit Result", async () => {
+		const loadData = vi.fn(async () => {
+			throw new Error("vault unavailable");
+		});
+
+		const loaded = await loadSettingsData(loadData);
+
+		expect(loaded.isErr()).toBe(true);
+		if (loaded.isErr()) {
+			expect(loaded.error).toEqual({ type: "settings_load_failed", message: "vault unavailable" });
+		}
+	});
+
+	test("turns a rejected plugin save into an explicit Result", async () => {
+		const saveData = vi.fn(async () => {
+			throw new Error("disk full");
+		});
+
+		const saved = await saveSettingsData(saveData, { author: "Alice" });
+
+		expect(saved.isErr()).toBe(true);
+		if (saved.isErr()) {
+			expect(saved.error).toEqual({ type: "settings_save_failed", message: "disk full" });
+		}
+	});
+
+	test("passes loaded and saved plugin data through successful Results", async () => {
+		const data = { author: "Alice" };
+		const loadData = vi.fn(async () => data);
+		const saveData = vi.fn(async () => {});
+
+		const loaded = await loadSettingsData(loadData);
+		const saved = await saveSettingsData(saveData, data);
+
+		expect(loaded.isOk() && loaded.value).toBe(data);
+		expect(saved.isOk()).toBe(true);
+		expect(saveData).toHaveBeenCalledWith(data);
+	});
+});
diff --git a/test/settings.test.ts b/test/settings.test.ts
new file mode 100644
index 0000000..7b3edac
--- /dev/null
+++ b/test/settings.test.ts
@@ -0,0 +1,156 @@
+// @vitest-environment happy-dom
+import { describe, expect, test, vi } from "vitest";
+import { App, Setting } from "obsidian";
+import { Result, type Result as ResultType } from "better-result";
+import { DEFAULT_SETTINGS, DocCommentsSettingTab, type DocCommentsSettings } from "../src/settings";
+import type { AuthorIndexState } from "../src/author-index";
+
+const settings = (): DocCommentsSettings => ({
+	author: "Alice",
+	showComments: true,
+	showResolved: false,
+	allowEmptyComments: false,
+	authorColorsEnabled: true,
+	authorColors: {
+		Alice: { color: "#0090ff", mode: "generated" },
+		Former: { color: "#e54d2e", mode: "custom" },
+	},
+	excludedAuthorColors: ["Bob"],
+});
+
+const plugin = (state: AuthorIndexState) => ({
+	settings: settings(),
+	authorColorView: () => ({ state, active: ["Alice"], missing: ["Former"], uncolored: ["Bob"], saveError: null }),
+	setAuthorColor: vi.fn(async () => {}),
+	deleteAuthorColor: vi.fn(async () => {}),
+	restoreAuthorColor: vi.fn(async () => {}),
+	rescanAuthors: vi.fn(async () => {}),
+	saveSettings: vi.fn(async (): Promise> => Result.ok(undefined)),
+	settingsError: vi.fn((): string | null => null),
+	ensureCurrentAuthorColor: vi.fn(),
+	refreshEditors: vi.fn(),
+	updateRibbon: vi.fn(),
+});
+
+describe("highlight color settings", () => {
+	test("defaults author colors to off for vaults without a saved preference", () => {
+		expect(DEFAULT_SETTINGS.authorColorsEnabled).toBe(false);
+	});
+
+	test("hides the highlight color group in declarative and legacy settings while colors are off", () => {
+		const fake = plugin({ status: "ready", authors: ["Alice"] });
+		fake.settings.authorColorsEnabled = false;
+		const tab = new DocCommentsSettingTab(new App(), fake as never);
+
+		expect(
+			tab.getSettingDefinitions().some((definition) => "type" in definition && definition.type === "group"),
+		).toBe(false);
+		tab.display();
+		expect(tab.containerEl.textContent).not.toContain("Highlight colors");
+		expect(tab.containerEl.querySelector('input[type="color"]')).toBeNull();
+
+		fake.settings.authorColorsEnabled = true;
+		expect(
+			tab.getSettingDefinitions().some((definition) => "type" in definition && definition.type === "group"),
+		).toBe(true);
+		tab.display();
+		expect(tab.containerEl.textContent).toContain("Highlight colors");
+		expect(tab.containerEl.querySelector('input[type="color"]')).not.toBeNull();
+	});
+
+	test("declarative definitions render built-in color pickers and retain missing creators", () => {
+		const fake = plugin({ status: "ready", authors: ["Alice"] });
+		const tab = new DocCommentsSettingTab(new App(), fake as never);
+		const group = tab
+			.getSettingDefinitions()
+			.find((definition) => "type" in definition && definition.type === "group");
+		expect(group && "items" in group ? group.items?.map((item) => ("name" in item ? item.name : "")) : []).toEqual([
+			"Highlight color index",
+			"Alice",
+			"Not currently found",
+			"Former",
+			"Uncolored",
+			"Bob",
+		]);
+
+		const alice =
+			group && "items" in group ? group.items?.find((item) => "name" in item && item.name === "Alice") : null;
+		const container = document.createElement("div");
+		const setting = new Setting(container);
+		if (alice && "render" in alice && alice.render) alice.render(setting, {} as never);
+
+		expect(container.querySelector('input[type="color"]')?.value).toBe("#0090ff");
+		expect(setting.settingEl.classList.contains("dc-author-color-setting")).toBe(true);
+		expect(container.querySelector('button[data-icon="rotate-ccw"]')).toBeNull();
+		expect(container.querySelector('button[data-icon="trash-2"]')).not.toBeNull();
+	});
+
+	test("legacy display uses the same picker rows and exposes partial scan state", () => {
+		const fake = plugin({
+			status: "partial",
+			authors: ["Alice"],
+			errors: [{ path: "locked.md", message: "locked" }],
+		});
+		const tab = new DocCommentsSettingTab(new App(), fake as never);
+		tab.display();
+
+		expect(tab.containerEl.querySelectorAll('input[type="color"]')).toHaveLength(2);
+		expect(tab.containerEl.textContent).toContain("1 file could not be read");
+		expect(tab.containerEl.textContent).toContain("Not currently found");
+		expect(tab.containerEl.textContent).toContain("Uncolored");
+		expect(tab.containerEl.textContent).toContain("This creator is not currently found in the vault.");
+		expect(tab.containerEl.textContent).not.toContain("Automatically assigned color");
+		expect(tab.containerEl.textContent).not.toContain("Custom color");
+	});
+
+	test("picker, delete, and restore controls route through plugin persistence", () => {
+		const fake = plugin({ status: "ready", authors: ["Alice"] });
+		fake.settings.authorColors.Alice = { color: "#0090ff", mode: "custom" };
+		const tab = new DocCommentsSettingTab(new App(), fake as never);
+		tab.display();
+		const picker = [...tab.containerEl.querySelectorAll('input[type="color"]')].find(
+			(input) => input.value === "#0090ff",
+		);
+		picker!.value = "#abcdef";
+		picker!.dispatchEvent(new Event("change"));
+		expect(tab.containerEl.querySelector('button[data-icon="rotate-ccw"]')).toBeNull();
+		const remove = tab.containerEl.querySelector('button[data-icon="trash-2"]');
+		remove?.click();
+		const restore = [...tab.containerEl.querySelectorAll("button")].find(
+			(button) => button.textContent === "Assign color",
+		);
+		restore?.click();
+
+		expect(fake.setAuthorColor).toHaveBeenCalledWith("Alice", "#abcdef");
+		expect(fake.deleteAuthorColor).toHaveBeenCalledWith("Alice");
+		expect(fake.restoreAuthorColor).toHaveBeenCalledWith("Bob");
+	});
+
+	test("global author color toggle persists without changing saved assignments", async () => {
+		const fake = plugin({ status: "ready", authors: ["Alice"] });
+		const before = structuredClone(fake.settings.authorColors);
+		const tab = new DocCommentsSettingTab(new App(), fake as never);
+
+		await tab.setControlValue("authorColorsEnabled", false);
+
+		expect(fake.settings.authorColorsEnabled).toBe(false);
+		expect(fake.settings.authorColors).toEqual(before);
+		expect(fake.saveSettings).toHaveBeenCalledOnce();
+		expect(fake.refreshEditors).toHaveBeenCalled();
+	});
+
+	test("rolls back a rejected setting write and surfaces the failure inline", async () => {
+		const fake = plugin({ status: "ready", authors: ["Alice"] });
+		fake.saveSettings.mockResolvedValue(Result.err("disk full"));
+		fake.settingsError.mockReturnValue("Couldn't save settings: disk full");
+		const tab = new DocCommentsSettingTab(new App(), fake as never);
+
+		await tab.setControlValue("authorColorsEnabled", false);
+		tab.display();
+
+		expect(fake.settings.authorColorsEnabled).toBe(true);
+		expect(fake.refreshEditors).not.toHaveBeenCalled();
+		expect(tab.containerEl.textContent).toContain("Settings error");
+		expect(tab.containerEl.textContent).toContain("Couldn't save settings: disk full");
+	});
+});
diff --git a/test/styles.test.ts b/test/styles.test.ts
new file mode 100644
index 0000000..042eff8
--- /dev/null
+++ b/test/styles.test.ts
@@ -0,0 +1,49 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, test } from "vitest";
+
+const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
+
+describe("per-author highlight styles", () => {
+	test("uses the theme text color when no author mapping is active", () => {
+		expect(styles).toMatch(/--dc-highlight-color: var\(--text-normal\)/);
+	});
+
+	test("derives colors on each span so its local author variable wins the cascade", () => {
+		const rule = /\.doc-comment-span\s*\{([\s\S]*?)\}/.exec(styles)?.[1] ?? "";
+
+		expect(rule).toContain("--dc-highlight-bg: color-mix(in srgb, var(--dc-highlight-color) 18%");
+		expect(rule).toContain("--dc-highlight-bg-active: color-mix(in srgb, var(--dc-highlight-color) 38%");
+		expect(rule).toContain("--dc-highlight-border: color-mix(in srgb, var(--dc-highlight-color) 70%");
+	});
+
+	test("keeps resolved and draft treatments tied to the author color", () => {
+		expect(styles).toMatch(
+			/\.doc-comment-span\.is-resolved\s*\{[\s\S]*?border-bottom: 1px dashed var\(--dc-highlight-border\)/,
+		);
+		expect(styles).toMatch(
+			/\.doc-comment-span\.dc-draft\s*\{[\s\S]*?border-bottom-color: var\(--dc-highlight-border\)/,
+		);
+		expect(styles).toMatch(
+			/\.doc-comment-span\.dc-draft\s*\{[\s\S]*?--dc-highlight-color: var\(--dc-draft-highlight-color/,
+		);
+	});
+
+	test("mixes author names with the theme text color for readable contrast", () => {
+		expect(styles).toMatch(
+			/\.dc-entry__author\s*\{[\s\S]*?color: color-mix\(in srgb, var\(--dc-author-color, var\(--text-normal\)\) 40%, var\(--text-normal\)\)/,
+		);
+	});
+
+	test("uses deeper author colors in dark themes", () => {
+		expect(styles).toMatch(
+			/\.theme-dark \.dc-entry__author\s*\{[\s\S]*?color: color-mix\(in srgb, var\(--dc-author-color, var\(--text-normal\)\) 70%, var\(--text-normal\)\)/,
+		);
+	});
+
+	test("centers and tightens only author color setting rows", () => {
+		const rule = /\.dc-author-color-setting\s*\{([\s\S]*?)\}/.exec(styles)?.[1] ?? "";
+
+		expect(rule).toContain("align-items: center");
+		expect(rule).toContain("padding-block: 10px");
+	});
+});
diff --git a/test/table-highlights.test.ts b/test/table-highlights.test.ts
index 98b12c1..dd72661 100644
--- a/test/table-highlights.test.ts
+++ b/test/table-highlights.test.ts
@@ -1,6 +1,11 @@
 import { describe, expect, test } from "vitest";
 import { parseComments } from "../src/format/parse";
-import { mapTableWidgets, tableHighlightTargets } from "../src/editor/table-highlights";
+import {
+	mapTableWidgets,
+	tableHighlightName,
+	tableHighlightRule,
+	tableHighlightTargets,
+} from "../src/editor/table-highlights";
 
 describe("tableHighlightTargets", () => {
 	test("maps header and body comments to rendered table cells", () => {
@@ -17,8 +22,8 @@ describe("tableHighlightTargets", () => {
 		].join("\n");
 
 		expect(tableHighlightTargets(doc, parseComments(doc))).toEqual([
-			{ table: 0, row: 0, column: 0, quote: "Day", resolved: true },
-			{ table: 0, row: 1, column: 1, quote: "ship", resolved: false },
+			{ table: 0, row: 0, column: 0, quote: "Day", resolved: true, author: "me" },
+			{ table: 0, row: 1, column: 1, quote: "ship", resolved: false, author: "me" },
 		]);
 	});
 
@@ -40,11 +45,27 @@ describe("tableHighlightTargets", () => {
 		].join("\n");
 
 		expect(tableHighlightTargets(doc, parseComments(doc))).toEqual([
-			{ table: 0, row: 1, column: 1, quote: "two", resolved: false },
-			{ table: 1, row: 1, column: 0, quote: "three", resolved: false },
+			{ table: 0, row: 1, column: 1, quote: "two", resolved: false, author: "me" },
+			{ table: 1, row: 1, column: 0, quote: "three", resolved: false, author: "me" },
 		]);
 	});
 
+	test("uses separate stable registry names for each color and state", () => {
+		expect(tableHighlightName("#0090ff", false)).toBe("document-comments-table-open-0090ff");
+		expect(tableHighlightName("#0090ff", true)).toBe("document-comments-table-resolved-0090ff");
+		expect(tableHighlightName("#e54d2e", false)).not.toBe(tableHighlightName("#0090ff", false));
+		expect(tableHighlightName(null, false)).toBe("document-comments-table-open-default");
+	});
+
+	test("renders open and resolved table colors with distinct treatments", () => {
+		expect(tableHighlightRule("#0090ff", false)).toContain("background-color: color-mix");
+		expect(tableHighlightRule("#0090ff", false)).toContain("text-decoration-style: solid");
+		expect(tableHighlightRule("#e54d2e", true)).toContain("background-color: transparent");
+		expect(tableHighlightRule("#e54d2e", true)).toContain("text-decoration-style: dashed");
+		expect(tableHighlightRule("#e54d2e", true)).toContain("text-decoration-color: #e54d2e");
+		expect(tableHighlightRule(null, false)).toContain("var(--text-normal)");
+	});
+
 	test("maps mounted table widgets by source position when earlier tables are virtualized", () => {
 		const doc = [
 			"| A |",