From 3e9a392bca0e15073ef168c1eff763d8e8b178aa Mon Sep 17 00:00:00 2001 From: wsp Date: Wed, 23 Sep 2026 01:26:09 +0800 Subject: [PATCH] perf(flowchat): Scope selection highlight paint Remove chat-specific descendant selection styles implicated in WebKitGTK pseudo-style work and use the application selection colors. Scope excerpt, annotation, and search highlights to intersecting text parents while preserving shared ownership across rows and panes. Resolve annotation tints and indirect search/selection color mixes when applying themes, including chrome scopes. Preserve legacy theme settings, alpha, and forced-color behavior without new stored fields. Validation: 42 focused tests and check:web passed, including full color governance. Audit baselines are unchanged. Linux/WebKitGTK performance, UI interaction, and remote scenarios remain unverified. Refs: #3178 --- .../modern/FLOWCHAT_VERIFICATION.md | 2 + .../modern/FLOWCHAT_VIRTUALIZATION.md | 26 ++++ .../modern/VirtualItemRenderer.scss | 8 +- .../modern/flowChatSearchDom.test.ts | 1 + .../components/modern/flowChatSearchDom.ts | 73 ++--------- .../selection/ConversationExcerpt.scss | 31 ----- .../selection/FlowChatSelectionBar.test.tsx | 18 +++ .../conversationExcerptHighlights.ts | 24 +--- .../selection/flowChatHighlights.test.ts | 117 ++++++++++++++++++ .../flow_chat/selection/flowChatHighlights.ts | 108 ++++++++++++++++ .../selection/locateConversationExcerpt.ts | 20 +-- .../ThemeTokenAppearanceAdapter.test.ts | 75 ++++++++++- .../adapters/ThemeTokenAppearanceAdapter.ts | 37 +++++- .../adapters/highlightPaintColors.scss | 15 +++ .../adapters/highlightPaintColors.ts | 51 ++++++++ 15 files changed, 470 insertions(+), 136 deletions(-) create mode 100644 src/web-ui/src/flow_chat/selection/flowChatHighlights.test.ts create mode 100644 src/web-ui/src/flow_chat/selection/flowChatHighlights.ts create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/highlightPaintColors.scss create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/highlightPaintColors.ts diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 550b6c2d4a..fbe0ff271f 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -52,6 +52,8 @@ each missing what the other had. | `FlowChatTurnRail.test.tsx` | single-marker emphasis, neighboring hover fan, independent keyboard focus, reduced motion, and rail navigation | | `useFlowChatSearch.test.ts` | exact matching-block decoration, occurrence counting, and search navigation state | | `flowChatSearchDom.test.ts` | concrete text ranges and independent highlight ownership across rows and panes | +| `../../selection/flowChatHighlights.test.ts` | exact text-parent scoping across Markdown nodes, shared markers, cleanup and document isolation | +| `../../../infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts` | legacy accent projection, alpha preservation, theme changes and paint cleanup | | `../../selection/flowChatSelection.test.ts` | Markdown selection boundaries, source isolation, repeated text anchors, and changed sources | | `../../selection/FlowChatSelectionBar.test.tsx` | annotation Dialog focus containment and return, frozen excerpts during scroll/resize, and comment submission | | `../../selection/useExcerptComposerActions.test.tsx` | main/side draft routing, focus after activation, ordinary child ownership, and stale surface rejection | diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 678428d518..a390881d2d 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -418,6 +418,32 @@ bounded Explore retains 8px bottom padding for its scroll fade. There is no negative adjacent-region margin. The resident runtime slot stays 24px high and continues to participate in the existing footer/reservation contract. +## Selection and custom highlight paint + +Native transcript selections use the application selection style. Do not add +descendant `::selection` overrides to the chat root: WebKitGTK reports show +uncached highlight pseudo-style resolution during long-transcript repaint. + +Search, temporary excerpts and persistent annotations use `flowChatHighlights` +to own both their CSS Highlight ranges and attributes on every intersecting text +parent. A range can span Markdown links/emphasis; marking only its first parent +loses paint. Each owner updates only its own parent set, shared parents are +reference-counted per document and highlight kind, and disposal cannot clear +another mounted row or pane. Attributes stay stable across unchanged updates +and are outside the annotation geometry observer's attribute filter. + +The Appearance theme-token adapter projects the annotation accent's 30% tint +and indirect mixes in search/native-selection color tokens to concrete colors +when a theme is applied, including the chrome theme scope. Its renderer stylesheet supplies +existing semantic colors for sparse/legacy themes and system colors in forced +color mode; derived paint is not a persisted setting or a new theme token. +Scoped highlight rules remain in place without an active range, as with streaming +reveal. Moving color projection to its own stylesheet is an ownership decision, +not evidence of WebKit stylesheet-matching isolation or a measured speedup. + +Linux/WebKitGTK long-session CPU and pseudo-style stacks still require runtime +verification. DOM tests establish range/marker lifecycle, not renderer performance. + ## Streaming glyph presentation The shared Markdown renderer paints newly appended text with diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss index 3f7829863a..5c65b2e235 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss @@ -126,12 +126,12 @@ } } -::highlight(openbitfun-flowchat-search-current) { +[data-flowchat-highlight-search-current]::highlight(openbitfun-flowchat-search-current) { color: inherit; background: var(--openbitfun-color-accent-border-subtle); } -::highlight(openbitfun-flowchat-search-match) { +[data-flowchat-highlight-search-match]::highlight(openbitfun-flowchat-search-match) { color: inherit; background: var(--openbitfun-color-selection-surface); } @@ -145,12 +145,12 @@ } } - ::highlight(openbitfun-flowchat-search-current) { + [data-flowchat-highlight-search-current]::highlight(openbitfun-flowchat-search-current) { color: HighlightText; background: Highlight; } - ::highlight(openbitfun-flowchat-search-match) { + [data-flowchat-highlight-search-match]::highlight(openbitfun-flowchat-search-match) { color: MarkText; background: Mark; } diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts index a3de532ee7..92b43c2cd2 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts @@ -97,6 +97,7 @@ describe('FlowChat search highlight ownership', () => { secondOwner.update(null, [second]); expect([...registry.get('openbitfun-flowchat-search-current')!]).toEqual([first]); expect([...registry.get('openbitfun-flowchat-search-match')!]).toEqual([second]); + expect([...registry.keys()].at(-1)).toBe('openbitfun-flowchat-search-current'); firstOwner.dispose(); expect(registry.has('openbitfun-flowchat-search-current')).toBe(false); diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts index 4027a5e71f..4baa6adca9 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts @@ -1,19 +1,4 @@ -const SEARCH_HIGHLIGHT_CURRENT_NAME = 'openbitfun-flowchat-search-current'; -const SEARCH_HIGHLIGHT_MATCH_NAME = 'openbitfun-flowchat-search-match'; - -type HighlightRegistryLike = { - set: (name: string, highlight: unknown) => void; - delete: (name: string) => void; -}; - -type HighlightConstructorLike = new (...ranges: Range[]) => unknown; - -interface SearchHighlightRanges { - current: Range | null; - matches: readonly Range[]; -} - -const documentHighlights = new WeakMap>(); +import { createFlowChatHighlightOwner } from '../../selection/flowChatHighlights'; interface FoldedTextOffset { start: number; @@ -134,62 +119,18 @@ export function findFlowChatSearchTextRange(root: HTMLElement, query: string): R return findFlowChatSearchTextRanges(root, query)[0] ?? null; } -function publishSearchHighlights(ownerDocument: Document): void { - const view = ownerDocument.defaultView; - const cssWithHighlights = view?.CSS as (typeof CSS & { - highlights?: HighlightRegistryLike; - }) | undefined; - const HighlightConstructor = (view as (Window & { - Highlight?: HighlightConstructorLike; - }) | null)?.Highlight; - - if (!cssWithHighlights?.highlights) { - return; - } - - cssWithHighlights.highlights.delete(SEARCH_HIGHLIGHT_CURRENT_NAME); - cssWithHighlights.highlights.delete(SEARCH_HIGHLIGHT_MATCH_NAME); - if (!HighlightConstructor) { - return; - } - const owners = documentHighlights.get(ownerDocument)?.values() ?? []; - const current: Range[] = []; - const matches: Range[] = []; - for (const ranges of owners) { - if (ranges.current?.startContainer.isConnected) current.push(ranges.current); - matches.push(...ranges.matches.filter(range => range.startContainer.isConnected)); - } - if (matches.length > 0) { - cssWithHighlights.highlights.set( - SEARCH_HIGHLIGHT_MATCH_NAME, - new HighlightConstructor(...matches), - ); - } - // Register current last so it wins if two presentations share a text range. - if (current.length > 0) { - cssWithHighlights.highlights.set(SEARCH_HIGHLIGHT_CURRENT_NAME, new HighlightConstructor(...current)); - } -} - /** Each mounted row releases only its own ranges, including across chat panes. */ export function createFlowChatSearchHighlightOwner(ownerDocument: Document) { - const owner = {}; - let disposed = false; + const currentOwner = createFlowChatHighlightOwner(ownerDocument, 'search-current'); + const matchOwner = createFlowChatHighlightOwner(ownerDocument, 'search-match'); return { update(current: Range | null, matches: readonly Range[]) { - if (disposed) return; - let owners = documentHighlights.get(ownerDocument); - if (!owners) { - owners = new Map(); - documentHighlights.set(ownerDocument, owners); - } - owners.set(owner, { current, matches }); - publishSearchHighlights(ownerDocument); + matchOwner.update(matches); + currentOwner.update(current ? [current] : []); }, dispose() { - disposed = true; - documentHighlights.get(ownerDocument)?.delete(owner); - publishSearchHighlights(ownerDocument); + currentOwner.dispose(); + matchOwner.dispose(); }, }; } diff --git a/src/web-ui/src/flow_chat/selection/ConversationExcerpt.scss b/src/web-ui/src/flow_chat/selection/ConversationExcerpt.scss index fe793d3010..a658477261 100644 --- a/src/web-ui/src/flow_chat/selection/ConversationExcerpt.scss +++ b/src/web-ui/src/flow_chat/selection/ConversationExcerpt.scss @@ -229,34 +229,3 @@ &:active::after { background: var(--openbitfun-component-conversation-excerpt-accent); } } } - -[data-flowchat-selection-root]::selection, -[data-flowchat-selection-root] ::selection, -[data-openbitfun-product-component='conversation-excerpt']::selection, -[data-openbitfun-product-component='conversation-excerpt'] ::selection { - background-color: color-mix(in srgb, var(--openbitfun-component-conversation-excerpt-accent) 30%, transparent); - color: var(--openbitfun-component-conversation-excerpt-accent); -} - -::highlight(openbitfun-flowchat-excerpt) { - background-color: color-mix(in srgb, var(--openbitfun-component-conversation-excerpt-accent) 30%, transparent); - color: var(--openbitfun-component-conversation-excerpt-accent); -} - -::highlight(openbitfun-flowchat-annotations) { - background-color: color-mix(in srgb, var(--openbitfun-component-conversation-excerpt-accent) 30%, transparent); - color: var(--openbitfun-component-conversation-excerpt-accent); -} - -@media (forced-colors: active) { - [data-flowchat-selection-root]::selection, - [data-flowchat-selection-root] ::selection, - [data-openbitfun-product-component='conversation-excerpt']::selection, - [data-openbitfun-product-component='conversation-excerpt'] ::selection { - background-color: Highlight; - color: HighlightText; - } - - ::highlight(openbitfun-flowchat-excerpt) { background-color: Highlight; color: HighlightText; } - ::highlight(openbitfun-flowchat-annotations) { background-color: Highlight; color: HighlightText; } -} diff --git a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx index 10727e9948..0c5843c466 100644 --- a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx +++ b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.test.tsx @@ -7,6 +7,7 @@ import { contextMenuRegistry } from '@/shared/context-menu-system/core/ContextMe import { ContextType, type SelectionContext } from '@/shared/context-menu-system/types/context.types'; import { FlowChatSelectionBar } from './FlowChatSelectionBar'; import { FLOWCHAT_EXCERPT_ACTION, type ExcerptActionRequest } from './excerptActions'; +import { highlightExcerptRange } from './locateConversationExcerpt'; const state = vi.hoisted(() => ({ sessions: new Map([['main', { sessionId: 'main', title: 'Source session', workspacePath: '/workspace' }]]), @@ -97,6 +98,23 @@ describe('selection annotation dialog lifecycle', () => { return textarea; } + it('releases temporary paint without allowing an old locate timer to clear the next excerpt', () => { + const highlights = new Map>(); + vi.stubGlobal('CSS', { highlights }); + vi.stubGlobal('Highlight', class extends Set { constructor(...ranges: Range[]) { super(ranges); } }); + const text = container.querySelector('[data-flow-item-id]')!; + const first = document.createRange(); first.selectNodeContents(text); + const releaseFirst = highlightExcerptRange(first); + const second = first.cloneRange(); second.setStart(text.firstChild!, 1); + const releaseSecond = highlightExcerptRange(second); + releaseFirst(); + expect([...highlights.get('openbitfun-flowchat-excerpt')!]).toEqual([second]); + expect(text.hasAttribute('data-flowchat-highlight-excerpt')).toBe(true); + releaseSecond(); + expect(highlights.size).toBe(0); + expect(text.hasAttribute('data-flowchat-highlight-excerpt')).toBe(false); + }); + it('keeps the comment while the editor scrolls, resizes, or the source unmounts', async () => { const surface = await openAnnotation(); const textarea = enterComment('Keep this note'); diff --git a/src/web-ui/src/flow_chat/selection/conversationExcerptHighlights.ts b/src/web-ui/src/flow_chat/selection/conversationExcerptHighlights.ts index 1179a1af83..51686ab74e 100644 --- a/src/web-ui/src/flow_chat/selection/conversationExcerptHighlights.ts +++ b/src/web-ui/src/flow_chat/selection/conversationExcerptHighlights.ts @@ -1,26 +1,6 @@ -type ExcerptHighlight = Set; -interface HighlightView { - CSS?: { highlights?: Map }; - Highlight?: new (...ranges: Range[]) => ExcerptHighlight; -} - -const documents = new WeakMap>(); -const name = 'openbitfun-flowchat-annotations'; +import { createFlowChatHighlightOwner } from './flowChatHighlights'; /** Each mounted transcript row owns only its ranges, including in sibling panes. */ export function createExcerptHighlights(document: Document) { - const owner = Symbol(); - let owners = documents.get(document); - if (!owners) { owners = new Map(); documents.set(document, owners); } - const publish = () => { - const view = document.defaultView as unknown as HighlightView | null; - if (!view?.CSS?.highlights || !view.Highlight) return; - const ranges = [...owners.values()].flat(); - if (ranges.length) view.CSS.highlights.set(name, new view.Highlight(...ranges)); - else view.CSS.highlights.delete(name); - }; - return { - update(ranges: readonly Range[]) { owners.set(owner, ranges); publish(); }, - dispose() { owners.delete(owner); publish(); }, - }; + return createFlowChatHighlightOwner(document, 'annotations'); } diff --git a/src/web-ui/src/flow_chat/selection/flowChatHighlights.test.ts b/src/web-ui/src/flow_chat/selection/flowChatHighlights.test.ts new file mode 100644 index 0000000000..e16281896d --- /dev/null +++ b/src/web-ui/src/flow_chat/selection/flowChatHighlights.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createFlowChatHighlightOwner } from './flowChatHighlights'; + +const attribute = 'data-flowchat-highlight-annotations'; +const name = 'openbitfun-flowchat-annotations'; +class Highlight extends Set { constructor(...ranges: Range[]) { super(ranges); } } + +describe('FlowChat highlight text scopes', () => { + let registry: Map; + const owners: ReturnType[] = []; + const owner = (kind: Parameters[1] = 'annotations', doc = document) => { + const result = createFlowChatHighlightOwner(doc, kind); + owners.push(result); + return result; + }; + const content = () => { + const root = document.createElement('div'); + root.innerHTML = '

before

plain linkbold tail

after

'; + document.body.append(root); + return root; + }; + beforeEach(() => { + registry = new Map(); + vi.stubGlobal('CSS', { highlights: registry }); + vi.stubGlobal('Highlight', Highlight); + }); + afterEach(() => { + owners.splice(0).forEach(item => item.dispose()); + document.body.replaceChildren(); + vi.unstubAllGlobals(); + }); + + it('scopes every intersecting text parent across Markdown nodes without touching surrounding text', () => { + const root = content(); + const paragraph = root.children[1]; + const range = document.createRange(); + range.setStart(paragraph.firstChild!, 2); + range.setEnd(paragraph.querySelector('strong')!.firstChild!, 2); + owner().update([range]); + expect([...root.querySelectorAll(`[${attribute}]`)]).toEqual([ + paragraph, paragraph.querySelector('a'), paragraph.querySelector('strong'), + ]); + expect([...registry.get(name)!]).toEqual([range]); + expect(root.textContent).toBe('beforeplain linkbold tailafter'); + }); + + it('handles element boundaries and excludes text touched only at an empty endpoint', () => { + const root = content(); + const paragraph = root.children[1]; + const range = document.createRange(); + range.setStart(paragraph, 1); range.setEnd(paragraph, 2); + const paint = owner(); paint.update([range]); + expect([...root.querySelectorAll(`[${attribute}]`)]).toEqual([paragraph.querySelector('a')]); + range.setStart(paragraph.firstChild!, paragraph.firstChild!.textContent!.length); + range.setEnd(paragraph.querySelector('strong')!.firstChild!, 0); + paint.update([range]); + expect([...root.querySelectorAll(`[${attribute}]`)]).toEqual([paragraph.querySelector('a')]); + range.collapse(true); paint.update([range]); + expect(root.querySelector(`[${attribute}]`)).toBeNull(); + expect(registry.has(name)).toBe(false); + }); + + it('retains shared parent markers across updates until the last owner releases them', () => { + const root = content(); const link = root.querySelector('a')!; + const range = document.createRange(); range.selectNodeContents(link); + const set = vi.spyOn(link, 'setAttribute'); const remove = vi.spyOn(link, 'removeAttribute'); + const first = owner(); const second = owner(); + first.update([range]); second.update([range]); first.update([range.cloneRange()]); + first.dispose(); first.dispose(); first.update([range]); + expect(set).toHaveBeenCalledTimes(1); expect(remove).not.toHaveBeenCalled(); + expect(registry.get(name)?.size).toBe(1); + second.dispose(); + expect(remove).toHaveBeenCalledTimes(1); expect(registry.has(name)).toBe(false); + }); + + it('cleans replaced parents and preserves independent highlight types', () => { + const root = content(); const link = root.querySelector('a')!; + const range = document.createRange(); range.selectNodeContents(link); + const annotations = owner(); const excerpt = owner('excerpt'); + annotations.update([range]); excerpt.update([range]); annotations.update([]); + expect(link.hasAttribute(attribute)).toBe(false); + expect(link.hasAttribute('data-flowchat-highlight-excerpt')).toBe(true); + expect(registry.has('openbitfun-flowchat-excerpt')).toBe(true); + annotations.update([range]); root.replaceChildren(); + annotations.update([range]); excerpt.update([range]); + expect(link.hasAttribute(attribute)).toBe(false); + expect(link.hasAttribute('data-flowchat-highlight-excerpt')).toBe(false); + expect(registry.size).toBe(0); + }); + + it('keeps documents independent and uses their own Highlight API', () => { + const frame = document.createElement('iframe'); document.body.append(frame); + const doc = frame.contentDocument!; + const remoteRegistry = new Map(); + Object.defineProperty(doc.defaultView, 'CSS', { value: { highlights: remoteRegistry }, configurable: true }); + Object.defineProperty(doc.defaultView, 'Highlight', { value: Highlight, configurable: true }); + doc.body.innerHTML = '

other document

'; + const range = doc.createRange(); range.selectNodeContents(doc.body.firstChild!); + const remote = owner('annotations', doc); remote.update([range]); owner().update([range]); + expect(registry.size).toBe(0); expect(remoteRegistry.get(name)?.size).toBe(1); + expect(doc.querySelector(`[${attribute}]`)).toBe(doc.body.firstChild); + remote.dispose(); + expect(remoteRegistry.size).toBe(0); expect(doc.querySelector(`[${attribute}]`)).toBeNull(); + }); + + it('does not mark unsupported highlights or remove a foreign registry entry', () => { + const root = content(); + const range = document.createRange(); range.selectNodeContents(root.querySelector('a')!); + vi.stubGlobal('Highlight', undefined); + const paint = owner(); paint.update([range]); + expect(root.querySelector(`[${attribute}]`)).toBeNull(); + vi.stubGlobal('Highlight', Highlight); paint.update([range]); + const foreign = new Highlight(range); registry.set(name, foreign); paint.dispose(); + expect(registry.get(name)).toBe(foreign); + }); +}); diff --git a/src/web-ui/src/flow_chat/selection/flowChatHighlights.ts b/src/web-ui/src/flow_chat/selection/flowChatHighlights.ts new file mode 100644 index 0000000000..5c92183ffc --- /dev/null +++ b/src/web-ui/src/flow_chat/selection/flowChatHighlights.ts @@ -0,0 +1,108 @@ +const definitions = { + excerpt: { name: 'openbitfun-flowchat-excerpt', attribute: 'data-flowchat-highlight-excerpt' }, + annotations: { name: 'openbitfun-flowchat-annotations', attribute: 'data-flowchat-highlight-annotations' }, + 'search-match': { name: 'openbitfun-flowchat-search-match', attribute: 'data-flowchat-highlight-search-match' }, + 'search-current': { name: 'openbitfun-flowchat-search-current', attribute: 'data-flowchat-highlight-search-current' }, +} as const; + +type Kind = keyof typeof definitions; +type TextHighlight = Set; +interface HighlightView { + CSS?: { highlights?: Map }; + Highlight?: new (...ranges: Range[]) => TextHighlight; +} +interface State { + owners: Map; + elements: Map; + highlight?: TextHighlight; +} +const documents = new WeakMap>(); + +function textParents(document: Document, range: Range, parents: Set): void { + const add = (node: Node) => { + const text = node as Text; + if (!text.length || !range.intersectsNode(text)) return; + // A boundary at the very edge of a Text node paints no glyphs in that node. + if (range.startContainer === text && range.startOffset === text.length) return; + if (range.endContainer === text && range.endOffset === 0) return; + if (text.parentElement) parents.add(text.parentElement); + }; + const root = range.commonAncestorContainer; + if (root.nodeType === Node.TEXT_NODE) add(root); + else { + // Walk only the range's common subtree, never the full transcript per frame. + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node: Node | null; + while ((node = walker.nextNode())) add(node); + } +} + +/** Own both paint ranges and their exact text-parent scope, separately per document. */ +export function createFlowChatHighlightOwner(document: Document, kind: Kind) { + const owner = {}; + let ownedElements = new Set(); + let disposed = false; + let states = documents.get(document); + if (!states) { states = new Map(); documents.set(document, states); } + let state = states.get(kind); + if (!state) { state = { owners: new Map(), elements: new Map() }; states.set(kind, state); } + const shared = state; + const { name, attribute } = definitions[kind]; + const publish = () => { + const view = document.defaultView as (Window & HighlightView) | null; + const registry = view?.CSS?.highlights; + const Highlight = view?.Highlight; + const ranges = registry && Highlight ? [...shared.owners.values()].flat().filter(range => ( + !range.collapsed && range.startContainer.isConnected && range.endContainer.isConnected + && range.startContainer.ownerDocument === document && range.endContainer.ownerDocument === document + )) : []; + if (registry && Highlight && ranges.length) { + const highlight = new Highlight(...ranges); + // Preserve the existing search paint order, including engines without priority. + if (kind === 'search-current') registry.delete(name); + registry.set(name, highlight); + shared.highlight = highlight; + } else { + if (registry?.get(name) === shared.highlight) registry?.delete(name); + shared.highlight = undefined; + } + }; + const updateElements = (next: Set) => { + for (const element of ownedElements) { + if (next.has(element)) continue; + const count = (shared.elements.get(element) ?? 1) - 1; + if (count) shared.elements.set(element, count); + else { shared.elements.delete(element); element.removeAttribute(attribute); } + } + for (const element of next) { + if (ownedElements.has(element)) continue; + const count = shared.elements.get(element) ?? 0; + shared.elements.set(element, count + 1); + if (!count) element.setAttribute(attribute, ''); + } + ownedElements = next; + }; + return { + update(ranges: readonly Range[]) { + if (disposed) return; + const view = document.defaultView as (Window & HighlightView) | null; + const active = view?.CSS?.highlights && view.Highlight ? ranges.filter(range => ( + !range.collapsed && range.startContainer.isConnected && range.endContainer.isConnected + && range.startContainer.ownerDocument === document && range.endContainer.ownerDocument === document + )) : []; + const elements = new Set(); + // Only this owner's changed content is walked; other rows keep their scope. + for (const range of active) textParents(document, range, elements); + updateElements(elements); + shared.owners.set(owner, active); + publish(); + }, + dispose() { + if (disposed) return; + disposed = true; + updateElements(new Set()); + shared.owners.delete(owner); + publish(); + }, + }; +} diff --git a/src/web-ui/src/flow_chat/selection/locateConversationExcerpt.ts b/src/web-ui/src/flow_chat/selection/locateConversationExcerpt.ts index daf2585af0..dc4c810777 100644 --- a/src/web-ui/src/flow_chat/selection/locateConversationExcerpt.ts +++ b/src/web-ui/src/flow_chat/selection/locateConversationExcerpt.ts @@ -4,6 +4,7 @@ import type { ConversationExcerptContext } from '@/shared/types/context'; import { FLOWCHAT_FOCUS_ITEM_EVENT, type FlowChatFocusItemRequest } from '../events/flowchatNavigation'; import { flowChatStore } from '../store/FlowChatStore'; import { SELECTION_ROOT, findExcerptSource, resolveExcerptRange } from './flowChatSelection'; +import { createFlowChatHighlightOwner } from './flowChatHighlights'; export function findExcerptTextRoot(excerpt: ConversationExcerptContext): HTMLElement | null { const root = Array.from(document.querySelectorAll(SELECTION_ROOT)) @@ -12,19 +13,18 @@ export function findExcerptTextRoot(excerpt: ConversationExcerptContext): HTMLEl return findExcerptSource(root, excerpt.fragments[0]); } -let clearLastHighlight: (() => void) | undefined; +const lastHighlights = new WeakMap void>(); export function highlightExcerptRange(range: Range): () => void { - clearLastHighlight?.(); - const css = globalThis.CSS as (typeof CSS & { highlights?: Map }) | undefined; - const HighlightType = (window as unknown as { Highlight?: new (...ranges: Range[]) => unknown }).Highlight; - if (!css?.highlights || !HighlightType) return () => undefined; - const highlight = new HighlightType(range); - css.highlights.set('openbitfun-flowchat-excerpt', highlight); + const document = range.startContainer.ownerDocument; + if (!document) return () => undefined; + lastHighlights.get(document)?.(); + const owner = createFlowChatHighlightOwner(document, 'excerpt'); + owner.update([range]); const clear = () => { - if (css.highlights?.get('openbitfun-flowchat-excerpt') === highlight) css.highlights.delete('openbitfun-flowchat-excerpt'); - if (clearLastHighlight === clear) clearLastHighlight = undefined; + owner.dispose(); + if (lastHighlights.get(document) === clear) lastHighlights.delete(document); }; - clearLastHighlight = clear; + lastHighlights.set(document, clear); return clear; } diff --git a/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts b/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts index 17cc3cea03..f182ae0b2a 100644 --- a/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts +++ b/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts @@ -1,16 +1,89 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { builtinAppearancePackages } from '../builtins/catalog'; import { themeTokenAppearanceAdapter } from './ThemeTokenAppearanceAdapter'; +import { resolveHighlightPaintColors } from './highlightPaintColors'; const context = { revision: 1, appearanceId: 'test', mode: 'light' as const, globals: {}, assets: {} }; afterEach(async () => { + vi.restoreAllMocks(); document.documentElement.removeAttribute('data-openbitfun-native-material'); await themeTokenAppearanceAdapter.apply(undefined, undefined, context); }); +describe('annotation highlight paint colors', () => { + const accent = '--openbitfun-component-conversation-excerpt-accent'; + const paintStyle = () => document.querySelector('style[data-openbitfun-appearance-highlight-paint]'); + const background = () => /background-color: ([^;]+);/.exec(paintStyle()?.textContent ?? '')?.[1] ?? ''; + it.each([ + ['#059cb0', 'rgba(5, 156, 176, 0.3)'], + ['rgba(10, 20, 30, 0.5)', 'rgba(10, 20, 30, 0.15)'], + ['#1234', 'rgba(17, 34, 51, 0.08)'], + ['hsl(120, 100%, 50%)', 'rgba(0, 255, 0, 0.3)'], + ['transparent', 'rgba(0, 0, 0, 0)'], + ])('derives concrete paint from legacy accent %s without adding stored fields', async (value, expected) => { + const settings = { tokens: { [accent]: value } }; + const original = JSON.stringify(settings); + expect(themeTokenAppearanceAdapter.validate(settings)).toEqual([]); + await themeTokenAppearanceAdapter.apply(settings, undefined, context); + const actual = background(); + const channels = (color: string) => color.match(/[\d.]+/g)!.map(Number); + expect(channels(actual).slice(0, 3)).toEqual(channels(expected).slice(0, 3)); + // CSSOM may round 8-bit alpha when serializing a legacy hex color. + expect(channels(actual)[3]).toBeCloseTo(channels(expected)[3], 3); + expect(JSON.stringify(settings)).toBe(original); + expect(document.querySelector('span')).toBeNull(); + }); + it('refreshes derived colors on theme changes and clears them for old sparse settings', async () => { + await themeTokenAppearanceAdapter.apply({ tokens: { [accent]: '#ff0000' } }, undefined, context); + expect(background()).toBe('rgba(255, 0, 0, 0.3)'); + await themeTokenAppearanceAdapter.apply({ tokens: { [accent]: '#0000ff' } }, undefined, context); + expect(background()).toBe('rgba(0, 0, 255, 0.3)'); + await themeTokenAppearanceAdapter.apply({ tokens: {} }, undefined, context); + expect(background()).toBe(''); + }); + it('keeps concrete wide-gamut colors and does not emit unresolved expressions', () => { + const computed = vi.spyOn(window, 'getComputedStyle'); + computed.mockReturnValueOnce({ color: 'color(display-p3 0.2 0.4 0.6 / 0.5)' } as CSSStyleDeclaration); + computed.mockReturnValueOnce({ color: 'color(srgb 0.1 0.4 0.6 / 0.15)' } as CSSStyleDeclaration); + expect(resolveHighlightPaintColors(document, 'color(display-p3 0.2 0.4 0.6 / 0.5)')).toEqual({ + foreground: 'color(display-p3 0.2 0.4 0.6 / 0.5)', background: 'color(srgb 0.1 0.4 0.6 / 0.15)', + }); + computed.mockReturnValue({ color: 'color-mix(in srgb, red 30%, transparent)' } as CSSStyleDeclaration); + expect(resolveHighlightPaintColors(document, '#ff0000')).toEqual({}); + expect(document.querySelector('span')).toBeNull(); + }); + it('leaves forced colors to the static system-color rules and removes old paint on reset', async () => { + await themeTokenAppearanceAdapter.apply({ tokens: { [accent]: '#059cb0' } }, undefined, context); + expect(paintStyle()?.textContent).toContain('@media (forced-colors: none)'); + expect(paintStyle()?.textContent).not.toContain('color-mix('); + await themeTokenAppearanceAdapter.apply(undefined, undefined, context); + expect(paintStyle()).toBeNull(); + }); + it('materializes indirect search/selection mixes in both root and chrome without mutating packages', async () => { + // jsdom does not compute color-mix; model the browser's computed-color boundary. + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ color: 'color(srgb 0 0.5 1 / 0.25)' } as CSSStyleDeclaration); + const value = 'color-mix(in srgb, #0080ff 25%, transparent)'; + const tokens = { + '--openbitfun-color-accent-border-subtle': value, + '--openbitfun-color-selection-surface': value, + '--openbitfun-color-action-secondary-pressed': value, + }; + const settings = { tokens, scopes: { chrome: tokens } }; + const before = JSON.stringify(settings); + await themeTokenAppearanceAdapter.apply(settings, undefined, context); + for (const name of Object.keys(tokens)) { + expect(document.documentElement.style.getPropertyValue(name)).toBe('color(srgb 0 0.5 1 / 0.25)'); + } + const scope = document.querySelector('style[data-openbitfun-appearance-theme-scopes]')!; + expect(scope.textContent).not.toContain('color-mix('); + expect(scope.textContent).toContain('[data-openbitfun-theme-scope="chrome"]'); + expect(JSON.stringify(settings)).toBe(before); + }); +}); + describe('theme root background', () => { it('keeps the native backdrop exposed across theme switches and resets', async () => { document.documentElement.setAttribute('data-openbitfun-native-material', 'sidebar'); diff --git a/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.ts b/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.ts index 3276111769..736cc8b24e 100644 --- a/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.ts +++ b/src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.ts @@ -1,4 +1,6 @@ import { themeCssVariables } from '@openbitfun/theme-openbitfun'; +import { resolveHighlightColor, resolveHighlightPaintColors } from './highlightPaintColors'; +import './highlightPaintColors.scss'; import { APPEARANCE_ROOT_TOKEN_NAMES, @@ -15,8 +17,21 @@ import type { const ROOT_ALLOWED_TOKEN_NAMES = new Set(APPEARANCE_ROOT_TOKEN_NAMES); const SCOPED_ALLOWED_TOKEN_NAMES = new Set(APPEARANCE_SCOPED_TOKEN_NAMES); const SCOPE_STYLE_ATTRIBUTE = 'data-openbitfun-appearance-theme-scopes'; +const HIGHLIGHT_STYLE_ATTRIBUTE = 'data-openbitfun-appearance-highlight-paint'; const ROOT_BACKGROUND_VARIABLE = themeCssVariables['color.surface.chrome']; const FORBIDDEN_VALUE = /(?:url\s*\(|var\s*\(|expression\s*\(|[;{}<>])/i; +const HIGHLIGHT_COLOR_TOKENS = new Set([ + '--openbitfun-color-accent-border-subtle', + '--openbitfun-color-selection-surface', + '--openbitfun-color-action-secondary-pressed', + '--openbitfun-color-content-primary', +]); + +function projectHighlightToken(name: string, value: string): string { + return HIGHLIGHT_COLOR_TOKENS.has(name) && /color-mix\(/i.test(value) + ? resolveHighlightColor(document, value) ?? value + : value; +} function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); @@ -48,6 +63,7 @@ function validateToken( function removeAppliedTokens(): void { const rootStyle = document.documentElement.style; APPEARANCE_ROOT_TOKEN_NAMES.forEach(name => rootStyle.removeProperty(name)); + document.querySelectorAll(`style[${HIGHLIGHT_STYLE_ATTRIBUTE}]`).forEach(node => node.remove()); document.querySelectorAll(`style[${SCOPE_STYLE_ATTRIBUTE}]`) .forEach(node => node.remove()); } @@ -57,7 +73,7 @@ function renderScopeStyles(settings: Readonly): st if (!tokens) return []; const selector = APPEARANCE_THEME_SCOPE_SELECTORS[scopeId as AppearanceThemeScopeId]; const declarations = Object.entries(tokens) - .map(([name, value]) => `${name}:${value};`) + .map(([name, value]) => `${name}:${value === undefined ? value : projectHighlightToken(name, value)};`) .join(''); return declarations ? [`${selector}{${declarations}}`] : []; }).join('\n'); @@ -95,8 +111,25 @@ export const themeTokenAppearanceAdapter: AppearanceRendererAdapter<'theme-token return; } Object.entries(next.tokens).forEach(([name, value]) => { - if (value !== undefined) rootStyle.setProperty(name, value); + if (value !== undefined) rootStyle.setProperty(name, projectHighlightToken(name, value)); }); + const accent = next.tokens['--openbitfun-component-conversation-excerpt-accent']; + if (accent) { + const paint = resolveHighlightPaintColors(document, accent); + // Renderer output only: old theme packages need no new setting or token. + // Keep concrete colors out of pseudo-style color-mix evaluation on repaint. + if (paint.background && paint.foreground) { + const style = document.createElement('style'); + style.setAttribute(HIGHLIGHT_STYLE_ATTRIBUTE, 'true'); + style.textContent = `@media (forced-colors: none) { + [data-flowchat-highlight-excerpt]::highlight(openbitfun-flowchat-excerpt), + [data-flowchat-highlight-annotations]::highlight(openbitfun-flowchat-annotations) { + background-color: ${paint.background}; color: ${paint.foreground}; + } + }`; + document.head.append(style); + } + } const scopeCss = renderScopeStyles(next); if (scopeCss) { const style = document.createElement('style'); diff --git a/src/web-ui/src/infrastructure/appearance/adapters/highlightPaintColors.scss b/src/web-ui/src/infrastructure/appearance/adapters/highlightPaintColors.scss new file mode 100644 index 0000000000..a790d480f1 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/adapters/highlightPaintColors.scss @@ -0,0 +1,15 @@ +// Renderer defaults for sparse/legacy Appearance packages. The adapter adds +// concrete paint colors after this stylesheet when the accent is available. +[data-flowchat-highlight-excerpt]::highlight(openbitfun-flowchat-excerpt), +[data-flowchat-highlight-annotations]::highlight(openbitfun-flowchat-annotations) { + background-color: var(--openbitfun-color-selection-surface); + color: var(--openbitfun-component-conversation-excerpt-accent); +} + +@media (forced-colors: active) { + [data-flowchat-highlight-excerpt]::highlight(openbitfun-flowchat-excerpt), + [data-flowchat-highlight-annotations]::highlight(openbitfun-flowchat-annotations) { + background-color: Highlight; + color: HighlightText; + } +} diff --git a/src/web-ui/src/infrastructure/appearance/adapters/highlightPaintColors.ts b/src/web-ui/src/infrastructure/appearance/adapters/highlightPaintColors.ts new file mode 100644 index 0000000000..bd1cb035c9 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/adapters/highlightPaintColors.ts @@ -0,0 +1,51 @@ +/** Materialize indirect color-mix tokens once, including scoped search colors. */ +export function resolveHighlightColor(document: Document, color: string): string | undefined { + const probe = document.createElement('span'); + probe.hidden = true; + probe.style.setProperty('forced-color-adjust', 'none'); + probe.style.setProperty('color', color, 'important'); + if (!probe.style.color) return undefined; + document.documentElement.append(probe); + try { + const resolved = document.defaultView?.getComputedStyle(probe).color; + return resolved && !/color-mix\(|var\(|currentcolor/i.test(resolved) ? resolved : undefined; + } finally { + probe.remove(); + } +} + +/** Resolve only at theme application, never while selecting or painting text. */ +export function resolveHighlightPaintColors(document: Document, accent: string): { + foreground?: string; + background?: string; +} { + const probe = document.createElement('span'); + probe.hidden = true; + probe.style.setProperty('forced-color-adjust', 'none'); + probe.style.setProperty('color', accent, 'important'); + if (!probe.style.color) return {}; + document.documentElement.append(probe); + try { + const view = document.defaultView; + const foreground = view?.getComputedStyle(probe).color; + if (!foreground || /color-mix\(|var\(|currentcolor/i.test(foreground)) return {}; + const rgb = /^rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)$/.exec(foreground); + if (rgb) { + // Mixing with transparent in sRGB preserves RGB and multiplies source alpha. + const alpha = Number((Number(rgb[4] ?? 1) * 0.3).toFixed(6)); + return { foreground, background: `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})` }; + } + // Let the browser handle supported wide-gamut/nested CSS color expressions. + // Its computed color is concrete; do not carry an unresolved mix into paint. + probe.style.removeProperty('color'); + probe.style.setProperty('color', `color-mix(in srgb, ${accent} 30%, transparent)`, 'important'); + if (!probe.style.color) return { foreground }; + const background = view?.getComputedStyle(probe).color; + return { + foreground, + background: background && !/color-mix\(|var\(|currentcolor/i.test(background) ? background : undefined, + }; + } finally { + probe.remove(); + } +}