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(); + } +}