From 6ae1e5fe763d22ff8b7a9a52254a06ac14879f72 Mon Sep 17 00:00:00 2001 From: Doug Brown Date: Mon, 11 May 2026 15:19:28 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=93=9B=20feat(notes):=20show=20agent?= =?UTF-8?q?=20author=20in=20inline=20notes=20and=20popovers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AgentAnnotation schema has carried an optional `author` field end-to-end (sidecar JSON, session daemon, wire protocol) but the TUI never surfaced it. Render it in the note title bar and the matching agent popover so reviewers can tell which agent left which note when multiple agents annotate the same diff. Falls back to "AI note" when author is absent for backward compat. --- CHANGELOG.md | 2 + .../3-agent-review-demo/agent-context.json | 18 ++- src/ui/components/panes/AgentCard.tsx | 3 + src/ui/components/panes/AgentInlineNote.tsx | 8 +- src/ui/components/ui-components.test.tsx | 141 ++++++++++++++++++ src/ui/lib/agentPopover.ts | 11 +- 6 files changed, 170 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 712d8b44..bfc17736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file. ### Added +- Surfaced the agent author name in inline notes and the matching agent popover so multi-agent reviews are readable at a glance, with a fallback title when an annotation has no author. + ### Changed ### Fixed diff --git a/examples/3-agent-review-demo/agent-context.json b/examples/3-agent-review-demo/agent-context.json index 583bb031..725692cc 100644 --- a/examples/3-agent-review-demo/agent-context.json +++ b/examples/3-agent-review-demo/agent-context.json @@ -9,7 +9,8 @@ { "newRange": [1, 3], "summary": "Adds one normalization helper for whitespace, case, and dashed shortcut terms.", - "rationale": "This lets the search layer reason about one normalized token shape instead of repeating slightly different cleanup logic in multiple places." + "rationale": "This lets the search layer reason about one normalized token shape instead of repeating slightly different cleanup logic in multiple places.", + "author": "sonnet" } ] }, @@ -20,7 +21,14 @@ { "newRange": [15, 35], "summary": "Prefix and exact keyword matches now outrank weaker substring hits before the result list is sorted.", - "rationale": "The old behavior made every match look equally good, which was fine for filtering but weak for command-palette ranking where the top result should usually be the most obvious intent." + "rationale": "The old behavior made every match look equally good, which was fine for filtering but weak for command-palette ranking where the top result should usually be the most obvious intent.", + "author": "sonnet" + }, + { + "newRange": [20, 27], + "summary": "Worth checking the score floor — could mask edge cases.", + "rationale": "The scoring thresholds (4, 3, 2, 1) look good but validate that zero-score items are properly filtered out.", + "author": "prism" } ] }, @@ -31,7 +39,8 @@ { "newRange": [1, 8], "summary": "The preview now shows only the top three ranked commands.", - "rationale": "Once ranking is reliable, the preview can stay compact and let the best results carry the review without flooding the UI." + "rationale": "Once ranking is reliable, the preview can stay compact and let the best results carry the review without flooding the UI.", + "author": "prism" } ] }, @@ -42,7 +51,8 @@ { "newRange": [1, 8], "summary": "The test covers a dashed query form so the new normalization helper has a visible behavioral contract.", - "rationale": "Without a test that exercises `short-cuts` specifically, it would be easy to regress the helper and still pass on simpler substring-only cases." + "rationale": "Without a test that exercises `short-cuts` specifically, it would be easy to regress the helper and still pass on simpler substring-only cases.", + "author": "sonnet" } ] } diff --git a/src/ui/components/panes/AgentCard.tsx b/src/ui/components/panes/AgentCard.tsx index b996059d..01804ed7 100644 --- a/src/ui/components/panes/AgentCard.tsx +++ b/src/ui/components/panes/AgentCard.tsx @@ -12,6 +12,7 @@ export function AgentCard({ summary, theme, width, + author, }: { locationLabel: string; noteCount?: number; @@ -21,6 +22,7 @@ export function AgentCard({ summary: string; theme: AppTheme; width: number; + author?: string; }) { const popover = buildAgentPopoverContent({ summary, @@ -29,6 +31,7 @@ export function AgentCard({ noteIndex, noteCount, width, + author, }); const titleWidth = Math.max(1, popover.innerWidth - (onClose ? 4 : 0)); diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index a14be446..bd00281e 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -1,13 +1,9 @@ import type { AgentAnnotation, LayoutMode } from "../../../core/types"; -import { wrapText } from "../../lib/agentPopover"; +import { formatAgentNoteTitle, wrapText } from "../../lib/agentPopover"; import { annotationRangeLabel } from "../../lib/agentAnnotations"; import { fitText, padText } from "../../lib/text"; import type { AppTheme } from "../../themes"; -function inlineNoteTitle(noteIndex: number, noteCount: number) { - return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note"; -} - interface AgentInlineNoteLine { kind: "summary" | "rationale"; text: string; @@ -83,7 +79,7 @@ export function AgentInlineNote({ width: number; }) { const closeText = onClose ? "[x]" : ""; - const titleText = `${inlineNoteTitle(noteIndex, noteCount)} · ${annotationRangeLabel(annotation)}`; + const titleText = `${formatAgentNoteTitle(noteIndex, noteCount, annotation.author)} · ${annotationRangeLabel(annotation)}`; const splitWidths = splitColumnWidths(width); const canDockRight = layout === "split" && anchorSide === "new" && width >= 84; const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84; diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 668a3f15..fa16ed98 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1285,6 +1285,147 @@ describe("UI components", () => { expect(lines[4]?.trimStart().startsWith("└")).toBe(true); }); + test("AgentInlineNote shows author name in title when author is set", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 100, + 5, + ); + + const lines = frame.split("\n"); + expect(lines[1]).toContain("sonnet"); + expect(lines[1]).not.toContain("AI note"); + }); + + test("AgentInlineNote falls back to 'AI note' when author is absent", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 100, + 5, + ); + + const lines = frame.split("\n"); + expect(lines[1]).toContain("AI note"); + }); + + test("AgentInlineNote includes index when multiple notes share a hunk", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 100, + 5, + ); + + const lines = frame.split("\n"); + expect(lines[1]).toContain("sonnet"); + expect(lines[1]).toContain("1/2"); + }); + + test("AgentInlineNote preserves special characters in author", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 100, + 5, + ); + + const lines = frame.split("\n"); + expect(lines[1]).toContain("prism (arbiter)"); + }); + + test("AgentCard shows author in title when set", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 40, + 12, + ); + + const lines = frame + .split("\n") + .slice(0, 8) + .map((line) => line.trimEnd()); + expect(lines[1]).toContain("sonnet"); + expect(lines[1]).not.toContain("AI note"); + }); + + test("AgentCard falls back to 'AI note' when author absent", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 40, + 12, + ); + + const lines = frame + .split("\n") + .slice(0, 8) + .map((line) => line.trimEnd()); + expect(lines[1]).toContain("AI note"); + }); + test("DiffPane renders all visible hunk notes across the review stream", async () => { const bootstrap = createBootstrap(); bootstrap.changeset.files[1]!.agent = { diff --git a/src/ui/lib/agentPopover.ts b/src/ui/lib/agentPopover.ts index 9b7ea8c1..c25f8821 100644 --- a/src/ui/lib/agentPopover.ts +++ b/src/ui/lib/agentPopover.ts @@ -49,8 +49,11 @@ export function wrapText(text: string, width: number) { return lines.length > 0 ? lines : [""]; } -/** Build the framed agent-popover title shown in the card header. */ -function agentPopoverTitle(noteIndex: number, noteCount: number) { +/** Title shown above an agent note — author name if present, otherwise "AI note", with optional "i/n" suffix. */ +export function formatAgentNoteTitle(noteIndex: number, noteCount: number, author?: string) { + if (author) { + return noteCount > 1 ? `${author} ${noteIndex + 1}/${noteCount}` : author; + } return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note"; } @@ -62,6 +65,7 @@ export function buildAgentPopoverContent({ rationale, summary, width, + author, }: { locationLabel: string; noteCount: number; @@ -69,6 +73,7 @@ export function buildAgentPopoverContent({ rationale?: string; summary: string; width: number; + author?: string; }) { const innerWidth = Math.max(1, width - 4); const summaryLines = wrapText(summary, innerWidth); @@ -78,7 +83,7 @@ export function buildAgentPopoverContent({ 1 + summaryLines.length + (rationaleLines.length > 0 ? 1 + rationaleLines.length : 0) + 1 + 1; return { - title: agentPopoverTitle(noteIndex, noteCount), + title: formatAgentNoteTitle(noteIndex, noteCount, author), summaryLines, rationaleLines, footer, From 9a2a46056df451f2cf3785d85e84fbfc4d3a34df Mon Sep 17 00:00:00 2001 From: Doug Brown Date: Mon, 11 May 2026 15:33:33 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8C=88=20feat(notes):=20tint=20inline?= =?UTF-8?q?=20notes=20per=20agent=20author?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an annotation carries `author`, derive a stable accent color from a new per-theme `noteAccentPalette` via FNV-1a hash and tint the entire inline note card from that accent — top/side/bottom borders, the title row's right-edge bar, the trailing `╵` guide cap below a multi-row range, and the body/title backgrounds (derived in HSL with low saturation and theme-appropriate lightness so `theme.text` / `theme.muted` body copy stays legible). The cap accent is threaded through the planned `note-guide-cap` row and resolved in PierreDiffView, with first-author-wins when two notes share a row+side. Background tint falls back to `theme.noteBackground` / `theme.noteTitleBackground` when no author is set, palette resolution returns null, or accent parsing fails; unauthored notes keep `theme.noteBorder`. Contrast-ratio tests guard the dark and light theme palettes against `theme.text`. Also spread the `3-agent-review-demo` example across all five accent slots (llama, grok, phi, gemini, sonnet) so one screenshot captures the full palette. --- CHANGELOG.md | 1 + .../3-agent-review-demo/agent-context.json | 8 +- src/ui/components/panes/AgentCard.tsx | 4 +- src/ui/components/panes/AgentInlineNote.tsx | 38 ++-- src/ui/components/ui-components.test.tsx | 87 +++++++++ src/ui/diff/PierreDiffView.tsx | 10 +- src/ui/diff/reviewRenderPlan.test.ts | 34 ++++ src/ui/diff/reviewRenderPlan.ts | 17 +- src/ui/lib/agentColor.test.ts | 182 ++++++++++++++++++ src/ui/lib/agentColor.ts | 123 ++++++++++++ src/ui/themes.ts | 5 + 11 files changed, 488 insertions(+), 21 deletions(-) create mode 100644 src/ui/lib/agentColor.test.ts create mode 100644 src/ui/lib/agentColor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc17736..7c0cb52b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable user-visible changes to Hunk are documented in this file. ### Added - Surfaced the agent author name in inline notes and the matching agent popover so multi-agent reviews are readable at a glance, with a fallback title when an annotation has no author. +- Tinted inline agent notes per author with a stable per-theme accent — borders, the title-row right-edge bar, the guide cap below multi-row ranges, and a soft body/title background — so notes from different agents are visually distinct on the same diff. ### Changed diff --git a/examples/3-agent-review-demo/agent-context.json b/examples/3-agent-review-demo/agent-context.json index 725692cc..d484be52 100644 --- a/examples/3-agent-review-demo/agent-context.json +++ b/examples/3-agent-review-demo/agent-context.json @@ -10,7 +10,7 @@ "newRange": [1, 3], "summary": "Adds one normalization helper for whitespace, case, and dashed shortcut terms.", "rationale": "This lets the search layer reason about one normalized token shape instead of repeating slightly different cleanup logic in multiple places.", - "author": "sonnet" + "author": "llama" } ] }, @@ -22,13 +22,13 @@ "newRange": [15, 35], "summary": "Prefix and exact keyword matches now outrank weaker substring hits before the result list is sorted.", "rationale": "The old behavior made every match look equally good, which was fine for filtering but weak for command-palette ranking where the top result should usually be the most obvious intent.", - "author": "sonnet" + "author": "grok" }, { "newRange": [20, 27], "summary": "Worth checking the score floor — could mask edge cases.", "rationale": "The scoring thresholds (4, 3, 2, 1) look good but validate that zero-score items are properly filtered out.", - "author": "prism" + "author": "gemini" } ] }, @@ -40,7 +40,7 @@ "newRange": [1, 8], "summary": "The preview now shows only the top three ranked commands.", "rationale": "Once ranking is reliable, the preview can stay compact and let the best results carry the review without flooding the UI.", - "author": "prism" + "author": "phi" } ] }, diff --git a/src/ui/components/panes/AgentCard.tsx b/src/ui/components/panes/AgentCard.tsx index 01804ed7..86cc9e97 100644 --- a/src/ui/components/panes/AgentCard.tsx +++ b/src/ui/components/panes/AgentCard.tsx @@ -1,5 +1,6 @@ import { buildAgentPopoverContent } from "../../lib/agentPopover"; import { fitText, padText } from "../../lib/text"; +import { resolveAuthorAccent } from "../../lib/agentColor"; import type { AppTheme } from "../../themes"; /** Render one framed floating agent note popover. */ @@ -33,6 +34,7 @@ export function AgentCard({ width, author, }); + const accent = resolveAuthorAccent(author, theme.noteAccentPalette) ?? theme.accent; const titleWidth = Math.max(1, popover.innerWidth - (onClose ? 4 : 0)); return ( @@ -41,7 +43,7 @@ export function AgentCard({ width, height: popover.height, border: true, - borderColor: theme.accent, + borderColor: accent, backgroundColor: theme.panel, paddingLeft: 1, paddingRight: 1, diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index bd00281e..f235239f 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -2,6 +2,11 @@ import type { AgentAnnotation, LayoutMode } from "../../../core/types"; import { formatAgentNoteTitle, wrapText } from "../../lib/agentPopover"; import { annotationRangeLabel } from "../../lib/agentAnnotations"; import { fitText, padText } from "../../lib/text"; +import { + deriveAuthorBackground, + deriveAuthorTitleBackground, + resolveAuthorAccent, +} from "../../lib/agentColor"; import type { AppTheme } from "../../themes"; interface AgentInlineNoteLine { @@ -80,6 +85,14 @@ export function AgentInlineNote({ }) { const closeText = onClose ? "[x]" : ""; const titleText = `${formatAgentNoteTitle(noteIndex, noteCount, annotation.author)} · ${annotationRangeLabel(annotation)}`; + const resolvedAccent = resolveAuthorAccent(annotation.author, theme.noteAccentPalette); + const accent = resolvedAccent ?? theme.noteBorder; + const noteBackground = resolvedAccent + ? (deriveAuthorBackground(resolvedAccent, theme.appearance) ?? theme.noteBackground) + : theme.noteBackground; + const noteTitleBackground = resolvedAccent + ? (deriveAuthorTitleBackground(resolvedAccent, theme.appearance) ?? theme.noteTitleBackground) + : theme.noteTitleBackground; const splitWidths = splitColumnWidths(width); const canDockRight = layout === "split" && anchorSide === "new" && width >= 84; const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84; @@ -121,7 +134,7 @@ export function AgentInlineNote({ {" ".repeat(boxLeft)} - + {topBorder} @@ -132,12 +145,12 @@ export function AgentInlineNote({ {" ".repeat(boxLeft)} - + - + {padText(fitText(titleText, titleWidth), titleWidth)} @@ -146,11 +159,11 @@ export function AgentInlineNote({ onMouseUp={onClose} style={{ width: closeText.length + 1, height: 1, backgroundColor: theme.panel }} > - {` ${closeText}`} + {` ${closeText}`} ) : null} - + @@ -165,17 +178,17 @@ export function AgentInlineNote({ {" ".repeat(boxLeft)} - + - + {padText(line.text, bodyWidth)} - + @@ -187,7 +200,7 @@ export function AgentInlineNote({ {" ".repeat(boxLeft)} - + {bottomBorder} @@ -201,17 +214,20 @@ export function AgentInlineNoteGuideCap({ side, theme, width, + accent, }: { side: "old" | "new"; theme: AppTheme; width: number; + accent?: string; }) { + const borderColor = accent ?? theme.noteBorder; return ( {side === "old" ? ( <> - + {" ".repeat(Math.max(0, width - 1))} @@ -223,7 +239,7 @@ export function AgentInlineNoteGuideCap({ {" ".repeat(Math.max(0, width - 1))} - + )} diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index fa16ed98..b2d45cc2 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1426,6 +1426,93 @@ describe("UI components", () => { expect(lines[1]).toContain("AI note"); }); + test("AgentInlineNote uses theme.noteBorder when author is absent", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 100, + 5, + ); + + // Verify it renders without error; the border color should be theme.noteBorder + expect(frame).toContain("AI note · ▶ new 2-4"); + expect(frame).toContain("Summary line"); + }); + + test("AgentInlineNote derives border color from palette when author is set", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {}} + />, + 100, + 5, + ); + + // Verify it renders without error with author set + expect(frame).toContain("sonnet · ▶ new 2-4"); + expect(frame).toContain("Summary line"); + }); + + test("AgentInlineNote renders different border colors for different authors", async () => { + const theme = resolveTheme("midnight", null); + const frame1 = await captureFrame( + , + 100, + 5, + ); + + const frame2 = await captureFrame( + , + 100, + 5, + ); + + // Both frames should render successfully; the internal accent colors differ + expect(frame1).toContain("sonnet · ▶ new 2-4"); + expect(frame2).toContain("prism · ▶ new 2-4"); + }); + test("DiffPane renders all visible hunk notes across the review stream", async () => { const bootstrap = createBootstrap(); bootstrap.changeset.files[1]!.agent = { diff --git a/src/ui/diff/PierreDiffView.tsx b/src/ui/diff/PierreDiffView.tsx index 0060525d..c7b4e212 100644 --- a/src/ui/diff/PierreDiffView.tsx +++ b/src/ui/diff/PierreDiffView.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import type { DiffFile, LayoutMode } from "../../core/types"; import { AgentInlineNote, AgentInlineNoteGuideCap } from "../components/panes/AgentInlineNote"; import type { VisibleAgentNote } from "../lib/agentAnnotations"; +import { resolveAuthorAccent } from "../lib/agentColor"; import type { DiffSectionGeometry } from "../lib/diffSectionGeometry"; import { reviewRowId } from "../lib/ids"; import type { AppTheme } from "../themes"; @@ -170,9 +171,16 @@ export function PierreDiffView({ } if (plannedRow.kind === "note-guide-cap") { + const capAccent = + resolveAuthorAccent(plannedRow.author, theme.noteAccentPalette) ?? undefined; return ( - + ); } diff --git a/src/ui/diff/reviewRenderPlan.test.ts b/src/ui/diff/reviewRenderPlan.test.ts index 39c3bc56..6f0a211b 100644 --- a/src/ui/diff/reviewRenderPlan.test.ts +++ b/src/ui/diff/reviewRenderPlan.test.ts @@ -118,6 +118,40 @@ describe("review render plan", () => { expect(cap?.kind).toBe("note-guide-cap"); if (cap?.kind === "note-guide-cap") { expect(cap.side).toBe("new"); + expect(cap.author).toBeUndefined(); + } + }); + + test("propagates annotation author onto the matching guide cap row", () => { + const theme = resolveTheme("midnight", null); + const file = createDiffFile( + "alpha", + "alpha.ts", + "export const alpha = 1;\n", + "export const alpha = 2;\nexport const beta = 3;\nexport const gamma = 4;\n", + ); + const rows = buildSplitRows(file, null, theme); + const plannedRows = buildReviewRenderPlan({ + fileId: file.id, + rows, + selectedHunkIndex: 0, + showHunkHeaders: true, + visibleAgentNotes: [ + { + id: "annotation:alpha:0:0", + annotation: { + newRange: [2, 3], + summary: "Authored note", + author: "sonnet", + }, + }, + ], + }); + + const cap = plannedRows.find((row) => row.kind === "note-guide-cap"); + expect(cap?.kind).toBe("note-guide-cap"); + if (cap?.kind === "note-guide-cap") { + expect(cap.author).toBe("sonnet"); } }); diff --git a/src/ui/diff/reviewRenderPlan.ts b/src/ui/diff/reviewRenderPlan.ts index ba2e89ed..10ce0c0c 100644 --- a/src/ui/diff/reviewRenderPlan.ts +++ b/src/ui/diff/reviewRenderPlan.ts @@ -50,6 +50,7 @@ export type PlannedReviewRow = fileId: string; hunkIndex: number; side: "old" | "new"; + author?: string; }; function lineRows(rows: DiffRow[]) { @@ -292,7 +293,10 @@ function buildNoteGuideSideByRowKey(placementsByAnchor: Map) { - const guideCapsByRowKey = new Map>(); + // Track the first author seen per (row, side) so the cap glyph can adopt the + // matching per-author accent. Two notes ending on the same row+side is rare; + // if it happens, first-author-wins keeps the colour stable. + const guideCapsByRowKey = new Map>(); for (const placements of placementsByAnchor.values()) { for (const placement of placements) { @@ -300,8 +304,12 @@ function buildGuideCapsByRowKey(placementsByAnchor: Map(); - rowCaps.add(placement.anchorSide); + const rowCaps = + guideCapsByRowKey.get(placement.endGuideAfterKey) ?? + new Map<"old" | "new", string | undefined>(); + if (!rowCaps.has(placement.anchorSide)) { + rowCaps.set(placement.anchorSide, placement.note.annotation.author); + } guideCapsByRowKey.set(placement.endGuideAfterKey, rowCaps); } } @@ -383,7 +391,7 @@ export function buildReviewRenderPlan({ const guideCaps = guideCapsByRowKey.get(row.key); if (guideCaps) { - Array.from(guideCaps).forEach((side) => { + Array.from(guideCaps.entries()).forEach(([side, author]) => { plannedRows.push({ kind: "note-guide-cap", key: `note-guide-cap:${row.key}:${side}`, @@ -391,6 +399,7 @@ export function buildReviewRenderPlan({ fileId, hunkIndex: row.hunkIndex, side, + author, }); }); } diff --git a/src/ui/lib/agentColor.test.ts b/src/ui/lib/agentColor.test.ts new file mode 100644 index 00000000..568f4095 --- /dev/null +++ b/src/ui/lib/agentColor.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import { + deriveAuthorBackground, + deriveAuthorTitleBackground, + resolveAuthorAccent, +} from "./agentColor"; + +describe("agentColor helpers", () => { + test("resolveAuthorAccent returns null for undefined author", () => { + const palette = ["#c6a0ff", "#7fd1ff", "#88d39b"]; + const result = resolveAuthorAccent(undefined, palette); + expect(result).toBe(null); + }); + + test("resolveAuthorAccent returns null for empty string author", () => { + const palette = ["#c6a0ff", "#7fd1ff", "#88d39b"]; + const result = resolveAuthorAccent("", palette); + expect(result).toBe(null); + }); + + test("resolveAuthorAccent returns null for whitespace-only author", () => { + const palette = ["#c6a0ff", "#7fd1ff", "#88d39b"]; + const result = resolveAuthorAccent(" ", palette); + expect(result).toBe(null); + }); + + test("resolveAuthorAccent returns null for empty palette", () => { + const result = resolveAuthorAccent("sonnet", []); + expect(result).toBe(null); + }); + + test("same author and palette yield same color when called twice", () => { + const palette = ["#c6a0ff", "#7fd1ff", "#88d39b", "#e6cf98", "#f0a0a0"]; + const author = "sonnet"; + const color1 = resolveAuthorAccent(author, palette); + const color2 = resolveAuthorAccent(author, palette); + expect(color1).toBe(color2); + }); + + test("different authors yield different colors with 5-color palette", () => { + const palette = ["#c6a0ff", "#7fd1ff", "#88d39b", "#e6cf98", "#f0a0a0"]; + const color1 = resolveAuthorAccent("sonnet", palette); + const color2 = resolveAuthorAccent("prism", palette); + expect(color1).not.toBe(color2); + }); + + test("returned color is always a member of the supplied palette", () => { + const palette = ["#c6a0ff", "#7fd1ff", "#88d39b", "#e6cf98", "#f0a0a0"]; + const authors = ["alice", "bob", "charlie", "david", "eve"]; + for (const author of authors) { + const color = resolveAuthorAccent(author, palette); + expect(color).not.toBe(null); + if (color !== null) { + expect(palette).toContain(color); + } + } + }); + + test("whitespace is trimmed from author before hashing", () => { + const palette = ["#c6a0ff", "#7fd1ff", "#88d39b"]; + const color1 = resolveAuthorAccent("sonnet", palette); + const color2 = resolveAuthorAccent(" sonnet ", palette); + expect(color1).toBe(color2); + }); + + test("deriveAuthorBackground returns null for unparseable hex", () => { + expect(deriveAuthorBackground("not-a-color", "dark")).toBe(null); + expect(deriveAuthorBackground("#abc", "dark")).toBe(null); + expect(deriveAuthorBackground("#zzzzzz", "dark")).toBe(null); + }); + + test("deriveAuthorBackground is deterministic for same input", () => { + const a = deriveAuthorBackground("#c6a0ff", "dark"); + const b = deriveAuthorBackground("#c6a0ff", "dark"); + expect(a).toBe(b); + }); + + test("deriveAuthorBackground produces dark output for dark appearance", () => { + const result = deriveAuthorBackground("#c6a0ff", "dark"); + expect(result).not.toBe(null); + if (result === null) return; + const value = Number.parseInt(result.slice(1), 16); + const r = (value >> 16) & 0xff; + const g = (value >> 8) & 0xff; + const b = value & 0xff; + const lightness = (Math.max(r, g, b) + Math.min(r, g, b)) / 2 / 255; + expect(lightness).toBeLessThan(0.25); + }); + + test("deriveAuthorBackground produces light output for light appearance", () => { + const result = deriveAuthorBackground("#7d5bc4", "light"); + expect(result).not.toBe(null); + if (result === null) return; + const value = Number.parseInt(result.slice(1), 16); + const r = (value >> 16) & 0xff; + const g = (value >> 8) & 0xff; + const b = value & 0xff; + const lightness = (Math.max(r, g, b) + Math.min(r, g, b)) / 2 / 255; + expect(lightness).toBeGreaterThan(0.85); + }); + + test("deriveAuthorBackground differs across hues", () => { + const purple = deriveAuthorBackground("#c6a0ff", "dark"); + const blue = deriveAuthorBackground("#7fd1ff", "dark"); + const green = deriveAuthorBackground("#88d39b", "dark"); + expect(purple).not.toBe(blue); + expect(blue).not.toBe(green); + expect(purple).not.toBe(green); + }); + + test("deriveAuthorTitleBackground is lighter than body for dark appearance", () => { + const body = deriveAuthorBackground("#c6a0ff", "dark"); + const title = deriveAuthorTitleBackground("#c6a0ff", "dark"); + expect(body).not.toBe(null); + expect(title).not.toBe(null); + if (body === null || title === null) return; + const lightnessOf = (hex: string) => { + const v = Number.parseInt(hex.slice(1), 16); + const r = (v >> 16) & 0xff; + const g = (v >> 8) & 0xff; + const b = v & 0xff; + return (Math.max(r, g, b) + Math.min(r, g, b)) / 2 / 255; + }; + expect(lightnessOf(title)).toBeGreaterThan(lightnessOf(body)); + }); + + test("deriveAuthorTitleBackground is darker than body for light appearance", () => { + const body = deriveAuthorBackground("#7d5bc4", "light"); + const title = deriveAuthorTitleBackground("#7d5bc4", "light"); + expect(body).not.toBe(null); + expect(title).not.toBe(null); + if (body === null || title === null) return; + const lightnessOf = (hex: string) => { + const v = Number.parseInt(hex.slice(1), 16); + const r = (v >> 16) & 0xff; + const g = (v >> 8) & 0xff; + const b = v & 0xff; + return (Math.max(r, g, b) + Math.min(r, g, b)) / 2 / 255; + }; + expect(lightnessOf(title)).toBeLessThan(lightnessOf(body)); + }); + + test("derived background keeps WCAG contrast above 4.5 against light text", () => { + // Dark theme body text (#f2f4f6, near white) on derived dark bg. + const palette = ["#c6a0ff", "#7fd1ff", "#88d39b", "#e6cf98", "#f0a0a0"]; + const lightText = "#f2f4f6"; + for (const accent of palette) { + const bg = deriveAuthorBackground(accent, "dark"); + expect(bg).not.toBe(null); + if (bg === null) continue; + expect(contrastRatio(lightText, bg)).toBeGreaterThan(4.5); + } + }); + + test("derived background keeps WCAG contrast above 4.5 against dark text", () => { + // Light theme body text (#2f2417, near black) on derived light bg. + const palette = ["#7d5bc4", "#4a6890", "#3f8d58", "#9f6c1f", "#b4545b"]; + const darkText = "#2f2417"; + for (const accent of palette) { + const bg = deriveAuthorBackground(accent, "light"); + expect(bg).not.toBe(null); + if (bg === null) continue; + expect(contrastRatio(darkText, bg)).toBeGreaterThan(4.5); + } + }); +}); + +function relativeLuminance(hex: string): number { + const v = Number.parseInt(hex.slice(1), 16); + const r = ((v >> 16) & 0xff) / 255; + const g = ((v >> 8) & 0xff) / 255; + const b = (v & 0xff) / 255; + const channel = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +function contrastRatio(a: string, b: string): number { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + const [hi, lo] = la > lb ? [la, lb] : [lb, la]; + return (hi + 0.05) / (lo + 0.05); +} diff --git a/src/ui/lib/agentColor.ts b/src/ui/lib/agentColor.ts new file mode 100644 index 00000000..6cdcc262 --- /dev/null +++ b/src/ui/lib/agentColor.ts @@ -0,0 +1,123 @@ +/** Stable, order-independent hash of an author string → palette index. + * Uses FNV-1a (32-bit) for low collisions on short identifiers. */ +function hashAuthor(author: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < author.length; i++) { + h ^= author.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** Resolve the accent color a given author should use against a palette. + * Returns null when the author is absent or the palette is empty so callers + * can fall back to theme defaults. */ +export function resolveAuthorAccent( + author: string | undefined, + palette: readonly string[], +): string | null { + if (!author || palette.length === 0) return null; + const trimmed = author.trim(); + if (trimmed.length === 0) return null; + const index = hashAuthor(trimmed) % palette.length; + return palette[index] ?? null; +} + +type Hsl = readonly [number, number, number]; +type Rgb = readonly [number, number, number]; + +function hexToRgb(hex: string): Rgb | null { + const cleaned = hex.startsWith("#") ? hex.slice(1) : hex; + if (cleaned.length !== 6) return null; + const num = Number.parseInt(cleaned, 16); + if (Number.isNaN(num)) return null; + return [(num >> 16) & 0xff, (num >> 8) & 0xff, num & 0xff]; +} + +function rgbToHsl(r: number, g: number, b: number): Hsl { + const rN = r / 255; + const gN = g / 255; + const bN = b / 255; + const max = Math.max(rN, gN, bN); + const min = Math.min(rN, gN, bN); + const l = (max + min) / 2; + if (max === min) return [0, 0, l * 100]; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h = 0; + if (max === rN) h = (gN - bN) / d + (gN < bN ? 6 : 0); + else if (max === gN) h = (bN - rN) / d + 2; + else h = (rN - gN) / d + 4; + h *= 60; + return [h, s * 100, l * 100]; +} + +function hslToRgb(h: number, s: number, l: number): Rgb { + const sN = s / 100; + const lN = l / 100; + const c = (1 - Math.abs(2 * lN - 1)) * sN; + const hN = h / 60; + const x = c * (1 - Math.abs((hN % 2) - 1)); + let r = 0; + let g = 0; + let b = 0; + if (hN >= 0 && hN < 1) [r, g, b] = [c, x, 0]; + else if (hN < 2) [r, g, b] = [x, c, 0]; + else if (hN < 3) [r, g, b] = [0, c, x]; + else if (hN < 4) [r, g, b] = [0, x, c]; + else if (hN < 5) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + const m = lN - c / 2; + return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; +} + +function rgbToHex(r: number, g: number, b: number): string { + const toHex = (n: number) => + Math.max(0, Math.min(255, Math.round(n))) + .toString(16) + .padStart(2, "0"); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +function hexToHsl(hex: string): Hsl | null { + const rgb = hexToRgb(hex); + if (!rgb) return null; + return rgbToHsl(rgb[0], rgb[1], rgb[2]); +} + +function hslToHex(h: number, s: number, l: number): string { + const [r, g, b] = hslToRgb(h, s, l); + return rgbToHex(r, g, b); +} + +/** Derive a body background that carries the author's accent hue but stays + * legible against `theme.text` and `theme.muted`. Dark themes get a deep, + * lightly-tinted backdrop; light themes get a near-white tint. Returns null + * for unparseable hex so callers can fall back to `theme.noteBackground`. */ +export function deriveAuthorBackground( + accent: string, + appearance: "light" | "dark", +): string | null { + const hsl = hexToHsl(accent); + if (!hsl) return null; + const [h, s] = hsl; + if (appearance === "dark") { + return hslToHex(h, Math.min(s, 32), 15); + } + return hslToHex(h, Math.min(s, 90), 95); +} + +/** Derive the title-strip background. Slightly more saturated and one shade + * away from the body backdrop so the title row reads as its own band. */ +export function deriveAuthorTitleBackground( + accent: string, + appearance: "light" | "dark", +): string | null { + const hsl = hexToHsl(accent); + if (!hsl) return null; + const [h, s] = hsl; + if (appearance === "dark") { + return hslToHex(h, Math.min(s, 40), 22); + } + return hslToHex(h, Math.min(s, 95), 91); +} diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 38ad3883..cf306a2a 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -35,6 +35,7 @@ export interface AppTheme { noteBackground: string; noteTitleBackground: string; noteTitleText: string; + noteAccentPalette: readonly string[]; syntaxColors: SyntaxColors; syntaxStyle: SyntaxStyle; } @@ -124,6 +125,7 @@ export const THEMES: AppTheme[] = [ noteBackground: "#241c31", noteTitleBackground: "#322446", noteTitleText: "#f5edff", + noteAccentPalette: ["#c6a0ff", "#7fd1ff", "#88d39b", "#e6cf98", "#f0a0a0"], }, { default: "#f2f4f6", @@ -173,6 +175,7 @@ export const THEMES: AppTheme[] = [ noteBackground: "#211a36", noteTitleBackground: "#30234f", noteTitleText: "#f5eeff", + noteAccentPalette: ["#c49bff", "#7fd1ff", "#5ad188", "#ffd883", "#ff8b8b"], }, { default: "#e8f1ff", @@ -222,6 +225,7 @@ export const THEMES: AppTheme[] = [ noteBackground: "#efe6ff", noteTitleBackground: "#e3d7ff", noteTitleText: "#462b74", + noteAccentPalette: ["#7d5bc4", "#4a6890", "#3f8d58", "#9f6c1f", "#b4545b"], }, { default: "#2f2417", @@ -271,6 +275,7 @@ export const THEMES: AppTheme[] = [ noteBackground: "#311d36", noteTitleBackground: "#452650", noteTitleText: "#fff0ff", + noteAccentPalette: ["#e1a3ff", "#ffb07a", "#83d99d", "#ffd08f", "#ff9d8f"], }, { default: "#fff0e6",