diff --git a/docs/architecture/theme-token-optimization.md b/docs/architecture/theme-token-optimization.md index fb00602c13..2e41666e4e 100644 --- a/docs/architecture/theme-token-optimization.md +++ b/docs/architecture/theme-token-optimization.md @@ -91,6 +91,11 @@ Primitive/reference 色值只存在于主题 authoring、明确的主题 preset 数值接近不是唯一判断标准;相邻背景/边框、文本层级、状态色、diff、syntax 和数据系列必须结合同时 出现时的区分度审查。反过来,也不能以“可能有视觉差异”为理由给每个组件建立近似私有颜色。 +对话注释由 Web UI Appearance 的 `--openbitfun-component-conversation-excerpt-accent` 承载 UIKit 青色, +引用公开浅色主题的 accent(`#059cb0`),保持各内置主题的注释标识一致。全局 selection 是中性选中语义, +全局 accent 则随主题变化,均不满足这项局部设计约束。正文拖选、编辑与定位高亮使用此色的 30% 底色和 +同色文字;注释编号和工具栏交互复用同一 Token。系统强制色模式下正文选区使用系统 Highlight/HighlightText。 + ## 普通 UI 的硬约束 普通应用组件和页面必须满足: diff --git a/src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx index 24b65d0d3a..de8c46bf42 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputAttachments.test.tsx @@ -67,7 +67,11 @@ describe('numbered composer attachments', () => { }); const dialog = () => document.querySelector('[role="dialog"][data-state="open"]'); const click = (node: HTMLElement) => { act(() => node.click()); flushFrames(); }; - const trigger = (number: number) => container.querySelector(`button[aria-label="Annotation ${number}"]`)!; + const chip = () => container.querySelector('[data-openbitfun-product-part="attachment"]')!; + const trigger = (number: number) => { + if (!document.querySelector('[data-openbitfun-product-part="details"]')) click(chip()); + return document.querySelector(`[data-openbitfun-product-part="details"] button[aria-label="Annotation ${number}"]`)!; + }; const action = (key: string) => [...dialog()!.querySelectorAll('button')].find(button => button.textContent === key)!; const typeComment = (value: string) => act(() => { const textarea = dialog()!.querySelector('textarea')!; @@ -92,18 +96,22 @@ describe('numbered composer attachments', () => { vi.unstubAllGlobals(); }); - it('shows images and individual annotations in one strip and removes only the chosen attachment', () => { + it('groups annotations in a capsule, previews on hover, and removes only the chosen detail', () => { act(() => root.render()); const strip = container.querySelector('[data-openbitfun-part="imageStrip"]')!; expect(strip.querySelector('img')?.alt).toBe('Photo.png'); - expect(strip.contains(trigger(1))).toBe(true); - expect(strip.contains(trigger(2))).toBe(true); - click(container.querySelector('[aria-label="Remove Annotation 1"]')!); + expect(strip.contains(chip())).toBe(true); + expect(document.querySelector('[data-openbitfun-product-part="details"]')).toBeNull(); + act(() => chip().dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))); + flushFrames(); + expect(document.querySelector('[data-openbitfun-product-part="details"]')?.textContent).toContain(excerpt.fragments[0].text); + expect(document.querySelector('[data-openbitfun-product-part="details"]')?.textContent).toContain('First comment'); + click(document.querySelector('[aria-label="Remove Annotation 1"]')!); expect(callbacks.remove).toHaveBeenCalledWith('annotation-1'); expect(trigger(1)).toBeNull(); - expect(trigger(2).textContent).toBe('#2'); + expect(trigger(2)).not.toBeNull(); expect(strip.querySelector('img')).not.toBeNull(); - expect(dialog()).toBeNull(); + expect(document.querySelector('textarea')).toBeNull(); }); it('edits only the selected comment, keeps the number, and contains composer keyboard shortcuts', () => { @@ -131,13 +139,29 @@ describe('numbered composer attachments', () => { act(() => textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }))); expect(callbacks.update).toHaveBeenCalledExactlyOnceWith(excerpt.id, 'Revised'); expect(dialog()).toBeNull(); - expect(trigger(1).textContent).toBe('#1'); + expect(trigger(1)).not.toBeNull(); click(trigger(2)); expect(dialog()!.querySelector('textarea')!.value).toBe('Second comment'); click([...dialog()!.querySelectorAll('button')].find(button => button.textContent === 'selection.cancel')!); expect(callbacks.update).toHaveBeenCalledOnce(); }); + it('opens details from keyboard focus, dismisses with Escape, and clears annotations without removing images', () => { + act(() => root.render()); + act(() => chip().focus()); + flushFrames(); + expect(chip().getAttribute('aria-expanded')).toBe('true'); + act(() => chip().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Annotation 1'); + act(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + expect(document.activeElement).toBe(chip()); + expect(document.querySelector('[data-openbitfun-product-part="details"]')).toBeNull(); + click(container.querySelector('[aria-label="selection.remove"]')!); + expect(callbacks.remove.mock.calls).toEqual([['annotation-1'], ['annotation-2']]); + expect(chip()).toBeNull(); + expect(container.querySelector('img')?.alt).toBe('Photo.png'); + }); + it('views sent source markers and message annotations without editor controls or write shortcuts', () => { act(() => root.render(
@@ -172,13 +196,14 @@ describe('numbered composer attachments', () => { )); - click(trigger(1)); + click(container.querySelector('button[aria-label="Annotation 1"]')!); expect(dialog()!.querySelector('[data-openbitfun-product-part="comment"]')!.textContent).toBe('First comment'); expect(dialog()!.querySelector('textarea')).toBeNull(); click(action('selection.locate')); expect(callbacks.locate).toHaveBeenCalledExactlyOnceWith(excerpt, expect.any(Function)); expect(useContextStore.getState().contexts).toEqual([draft]); click(container.querySelector('[data-openbitfun-product-part="attachment"]')!); + click(trigger(1)); expect(dialog()!.querySelector('textarea')!.value).toBe('Unsent draft revision'); typeComment('Continue editing draft'); click(action('selection.save')); @@ -187,7 +212,7 @@ describe('numbered composer attachments', () => { it('shows only the source quote and locate action for a sent annotation without a comment', () => { act(() => root.render()); - click(trigger(1)); + click(container.querySelector('button[aria-label="Annotation 1"]')!); expect(dialog()!.querySelector('textarea, input, [data-openbitfun-product-part="comment"]')).toBeNull(); expect(dialog()!.querySelector('[data-openbitfun-product-part="quote"]')!.textContent).toBe(excerpt.fragments[0].text); expect(action('selection.locate')).toBeDefined(); @@ -207,6 +232,7 @@ describe('numbered composer attachments', () => { expect(useContextStore.getState().contexts).toEqual([{ ...excerpt, comment: 'From the source' }]); expect(sessionComposerStore.getState().getDraft('main').contexts).toEqual([{ ...excerpt, comment: 'From the source' }]); click(attachment()); + click(trigger(1)); expect(dialog()!.querySelector('textarea')!.value).toBe('From the source'); expect([...dialog()!.querySelectorAll('button')].map(button => button.textContent)).toEqual(actions); typeComment('From the attachment'); 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 073588cda6..dd8ce0ab15 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 @@ -18,7 +18,7 @@ each missing what the other had. |---|---| | `../../hooks/useSessionReadOnOpen.test.tsx` | opening and foreground results mark read; inactive scenes, background windows, unmounts, and device switches cannot acknowledge from stale views | | `../../selection/conversationExcerptInventory.test.ts` | source session/device isolation, draft/queue removal, sent-number retention, stable snapshots during streaming | -| `../../selection/conversationExcerptMarkerPosition.test.ts` | text endpoint coordinates, wrapping and nested clipping; geometry contracts only, not visual acceptance | +| `../../selection/conversationExcerptMarkerPosition.test.ts` | full selection bounds, measured badge groups, persistent upper-right placement over occupied text, and clipping; geometry contracts only, not visual acceptance | | `../ChatInputAttachments.test.tsx` | shared image/annotation strip, pending editing/removal, read-only sent snapshots with no write controls/shortcuts, one-line source quote, locate, keyboard containment and stale-device rejection | | `../../selection/conversationExcerptEditing.test.ts` | draft/visible/queue ownership, preserved prompt context and attachments, legacy payload handling, sending guards and sent snapshots with no edit capability | | `../../services/flow-chat-manager/PendingQueueModule.test.ts` | persisted payload edits preserve queue order/identity, images and device isolation; sending entries reject edits | 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 9cacb6eefe..1c9b4d4de4 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 @@ -271,14 +271,22 @@ numbers. The number is additive presentation metadata, never annotation identity legacy excerpts without it remain readable. New numbers follow the loaded session family's saved, queued and pending annotations, not a cross-controller global counter. -`ConversationExcerptMarkers` paints numbered superscripts at each selected text -fragment's trailing caret in a row-local overlay. Selections in the same block keep -separate anchors, and wrapping follows the caret's current line. It validates the -frozen text anchor, omits hidden/clipped endpoints, observes only +`ConversationExcerptMarkers` paints numbered superscripts above the top-right +of each selected fragment's full text bounds in a row-local overlay. It measures +the badge group and overlaps the selection's upper-right corner by 6px on both axes, +leaving the marker body above the selected text. The marker may cover surrounding +text or another marker so a dense layout never hides the annotation. +Selection bounds use selected text runs, excluding full-width block rectangles. +Selections with different starts keep separate anchors even when +their ends match. It validates the frozen text anchor, clips partially visible anchors +to the row's visible bounds, omits fully hidden anchors, observes only mounted rows and releases observers on unmount. It changes neither transcript text -nor row keys, row height, or scroll position. Images and individual annotation tiles -share the composer attachment strip. Source superscripts and composer attachments -open `ConversationExcerptDialog`; creation shares its compact editor content. +nor row keys, row height, or scroll position. Persistent CSS highlights follow the +draft, queued and sent annotation inventory and release their row-owned ranges on +unmount. Images and an annotation-count capsule share the composer attachment strip. +The capsule opens a hover/focus/click detail list with individual edit/remove actions. +Source superscripts and detail edit actions open `ConversationExcerptDialog`; +creation shares its compact editor content. Both modes show one ellipsized, quoted source line, with both quote marks outside the clipped text so the closing quote remains visible. Pending annotations use an unlabelled textarea with an accessible name. Sent-message entries always view diff --git a/src/web-ui/src/flow_chat/selection/ConversationExcerpt.appearance.ts b/src/web-ui/src/flow_chat/selection/ConversationExcerpt.appearance.ts index d4a027ff4e..f7df8ca952 100644 --- a/src/web-ui/src/flow_chat/selection/ConversationExcerpt.appearance.ts +++ b/src/web-ui/src/flow_chat/selection/ConversationExcerpt.appearance.ts @@ -4,9 +4,10 @@ export const conversationExcerptAppearanceDescriptor: AppearanceSurfaceDescripto id: 'conversation-excerpt', componentAttribute: 'data-openbitfun-product-component', parts: [ - { id: 'root' }, { id: 'toolbar' }, { id: 'editor' }, { id: 'attachments' }, + { id: 'root' }, { id: 'toolbar' }, { id: 'action' }, { id: 'editor' }, { id: 'attachments' }, { id: 'attachment' }, { id: 'dialog' }, { id: 'quote' }, { id: 'quoteText' }, { id: 'comment' }, { id: 'locate' }, { id: 'preview' }, { id: 'markers' }, { id: 'superscript' }, + { id: 'chip' }, { id: 'details' }, { id: 'detail' }, { id: 'detailNumber' }, { id: 'detailText' }, { id: 'detailLabel' }, ], states: [], }; diff --git a/src/web-ui/src/flow_chat/selection/ConversationExcerpt.scss b/src/web-ui/src/flow_chat/selection/ConversationExcerpt.scss index 78dd0d029b..4294993092 100644 --- a/src/web-ui/src/flow_chat/selection/ConversationExcerpt.scss +++ b/src/web-ui/src/flow_chat/selection/ConversationExcerpt.scss @@ -4,10 +4,44 @@ z-index: var(--openbitfun-layer-popover); inline-size: max-content; max-inline-size: calc(100vw - var(--openbitfun-space-4)); - padding: var(--openbitfun-space-1); + padding: 0; + border-radius: var(--openbitfun-radius-pill); + border: 0.5px solid var(--openbitfun-color-border-subtle); + background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 80%, transparent); + box-shadow: var(--openbitfun-shadow-sm); + backdrop-filter: var(--openbitfun-effect-blur-base); } - &__toolbar { flex: 0 1 auto; } + &__toolbar { + flex: 0 1 auto; + gap: 0; + + > [data-openbitfun-part='separator'] { block-size: var(--openbitfun-space-4); } + } + + &__action { + block-size: 30px; + padding: 7px var(--openbitfun-space-3); + border: 0; + border-radius: var(--openbitfun-radius-pill); + color: var(--openbitfun-color-content-primary); + font-size: var(--openbitfun-type-body-sm-font-size); + + [data-openbitfun-part='leading-icon'] { inline-size: 14px; block-size: 14px; } + + &:hover:not(:disabled), &:active:not(:disabled) { + color: var(--openbitfun-component-conversation-excerpt-accent); + text-decoration: none; + } + + &:hover:not(:disabled)::before { + background: color-mix(in srgb, var(--openbitfun-component-conversation-excerpt-accent) 10%, transparent); + } + + &:active:not(:disabled)::before { + background: color-mix(in srgb, var(--openbitfun-component-conversation-excerpt-accent) 20%, transparent); + } + } &__dialog { > [data-openbitfun-part='header'] { @@ -82,12 +116,74 @@ } &__attachment { - inline-size: 100%; - block-size: 100%; + block-size: 28px; min-inline-size: 0; - padding: var(--openbitfun-space-1); + max-inline-size: 200px; + padding: 0 var(--openbitfun-space-2); border-radius: inherit; font-variant-numeric: tabular-nums; + color: var(--openbitfun-color-content-secondary); + + &:hover:not(:disabled), &:active:not(:disabled) { + color: var(--openbitfun-component-conversation-excerpt-accent); + text-decoration: none; + } + } + + &__chip { + display: inline-flex; + align-items: center; + max-inline-size: 100%; + padding: var(--openbitfun-space-1); + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + border-radius: var(--openbitfun-radius-pill); + background: var(--openbitfun-color-surface-raised); + gap: var(--openbitfun-space-1); + } + + &__details { + position: fixed; + z-index: var(--openbitfun-layer-popover); + inline-size: 560px; + max-inline-size: calc(100vw - var(--openbitfun-space-4)); + max-block-size: min(400px, calc(100vh - var(--openbitfun-space-6))); + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--openbitfun-space-4); + border-radius: var(--openbitfun-radius-lg); + box-shadow: var(--openbitfun-shadow-sm); + } + + &__detail { + display: flex; + align-items: flex-start; + gap: var(--openbitfun-space-2); + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-body-sm-font-family); + font-size: var(--openbitfun-type-body-sm-font-size); + font-weight: var(--openbitfun-type-body-sm-font-weight); + line-height: var(--openbitfun-type-body-sm-line-height); + letter-spacing: var(--openbitfun-type-body-sm-letter-spacing); + + & + & { + margin-block-start: var(--openbitfun-space-3); + padding-block-start: var(--openbitfun-space-3); + border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + } + } + + &__detail-number { color: var(--openbitfun-color-content-muted); font-variant-numeric: tabular-nums; } + &__detail-text { flex: 1; min-inline-size: 0; white-space: pre-wrap; overflow-wrap: anywhere; } + &__detail-label { display: block; color: var(--openbitfun-color-content-muted); } + + &__superscript { + color: var(--openbitfun-color-content-inverse); + + &::before, + &:hover:not(:disabled)::before, + &:active:not(:disabled)::before { + background: var(--openbitfun-component-conversation-excerpt-accent); + } } &__preview { display: inline-flex; max-inline-size: 100%; vertical-align: baseline; } @@ -97,7 +193,6 @@ z-index: 1; display: flex; gap: var(--openbitfun-space-1); - transform: translate(-100%, -75%); max-inline-size: 100%; font-size: inherit; line-height: inherit; @@ -126,19 +221,41 @@ inline-size: var(--openbitfun-space-1); block-size: var(--openbitfun-space-1); clip-path: polygon(0 0, 100% 0, 0 100%); - background: var(--openbitfun-component-button-primary-background); + background: var(--openbitfun-component-conversation-excerpt-accent); } - &:hover::after { background: var(--openbitfun-component-button-primary-background-hover); } - &:active::after { background: var(--openbitfun-component-button-primary-background-pressed); } + &:hover::after, + &: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: var(--openbitfun-color-selection-surface); - color: var(--openbitfun-color-content-primary); + 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/ConversationExcerptAttachments.tsx b/src/web-ui/src/flow_chat/selection/ConversationExcerptAttachments.tsx index c0045f96fd..91b8b41b1b 100644 --- a/src/web-ui/src/flow_chat/selection/ConversationExcerptAttachments.tsx +++ b/src/web-ui/src/flow_chat/selection/ConversationExcerptAttachments.tsx @@ -1,10 +1,12 @@ -import { useState } from 'react'; -import { Button } from '@openbitfun/ui'; +import { useEffect, useId, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Button, Card, Icon, IconButton, useDismissibleLayer } from '@openbitfun/ui'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useI18n } from '@/infrastructure/i18n'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import type { ContextItem, ConversationExcerptContext } from '@/shared/types/context'; -import { excerptNumber, isConversationExcerpt } from '@/shared/utils/conversationExcerpt'; -import { ChatInputAttachment } from '../components/ChatInputAttachment'; +import { excerptNumber, excerptText, isConversationExcerpt } from '@/shared/utils/conversationExcerpt'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { ConversationExcerptDialog } from './ConversationExcerptDialog'; import { conversationExcerptDialogTarget, type ExcerptDialogTarget } from './conversationExcerptEditing'; import './ConversationExcerpt.scss'; @@ -19,23 +21,46 @@ function useExcerptLabel(excerpt: ConversationExcerptContext) { const number = excerptNumber(excerpt); return { label: number ? t('selection.numbered', { number: formatNumber(number) }) : t('selection.annotation'), - mark: number ? `#${formatNumber(number)}` : t('selection.annotation'), numberLabel: number ? formatNumber(number) : undefined, }; } -function ExcerptAttachment({ excerpt, onUpdate, onRemove }: { - excerpt: ConversationExcerptContext; +export function ConversationExcerptAttachments({ contexts, onUpdate, onRemove, inline = false }: { + contexts: ContextItem[]; onUpdate: (id: string, comment: string) => void; onRemove: (id: string) => void; + inline?: boolean; }) { - const { t } = useI18n('flow-chat'); - const { label, mark } = useExcerptLabel(excerpt); - const [dialog, setDialog] = useState(null); - const openDialog = () => { + const { t, formatNumber } = useI18n('flow-chat'); + const excerpts = contexts.filter(isConversationExcerpt); + const [open, setOpen] = useState(false); + const [dialog, setDialog] = useState<(ExcerptDialogState & { label: string }) | null>(null); + const anchorRef = useRef(null); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const hideTimer = useRef>(); + const id = useId(); + const cancelHide = () => { clearTimeout(hideTimer.current); }; + const close = () => { cancelHide(); setOpen(false); }; + const show = () => { cancelHide(); if (!dialog?.open) setOpen(true); }; + const contains = (node: Node | null) => !!node && Boolean(anchorRef.current?.contains(node) || popoverRef.current?.contains(node)); + const scheduleHide = () => { + cancelHide(); + hideTimer.current = setTimeout(() => { + if (!contains(document.activeElement)) setOpen(false); + }, 200); + }; + useEffect(() => () => clearTimeout(hideTimer.current), []); + useEffect(() => { if (!excerpts.length) { setOpen(false); setDialog(null); } }, [excerpts.length]); + const layout = useAnchoredPopoverPosition({ open, anchorRef, popoverRef, preferredPlacement: 'top', layoutRevision: contexts }); + useDismissibleLayer({ enabled: open, layerRef: popoverRef, branchRefs: [anchorRef], onDismiss: reason => { + if (reason === 'escape-key') triggerRef.current?.focus(); + close(); + } }); + const edit = (excerpt: ConversationExcerptContext, label: string) => { + close(); const scope = getActiveSurfaceScope(); - // The owning composer supplies the write, including a sent-message edit draft. - setDialog({ open: true, target: { + setDialog({ open: true, label, target: { mode: 'edit', excerpt, isCurrent: () => scope.isCurrent() && scope.surfaceId === excerpt.source.surfaceId, save: comment => { @@ -45,32 +70,59 @@ function ExcerptAttachment({ excerpt, onUpdate, onRemove }: { }, } }); }; - return <> - onRemove(excerpt.id)}> - - - {dialog && setDialog(current => current && { ...current, open })} />} - ; -} - -export function ConversationExcerptAttachments({ contexts, onUpdate, onRemove, inline = false }: { - contexts: ContextItem[]; - onUpdate: (id: string, comment: string) => void; - onRemove: (id: string) => void; - inline?: boolean; -}) { - const excerpts = contexts.filter(isConversationExcerpt); if (!excerpts.length) return null; + const countLabel = t('selection.count', { count: formatNumber(excerpts.length) }); return
- {excerpts.map(excerpt => )} +
{ if (!contains(event.relatedTarget)) close(); }}> + + } + onClick={() => { close(); excerpts.forEach(excerpt => onRemove(excerpt.id)); }} /> +
+ {open && createPortal( { if (!contains(event.relatedTarget)) close(); }}> + {excerpts.map(excerpt => { + const number = excerptNumber(excerpt); + const label = number ? t('selection.numbered', { number: formatNumber(number) }) : t('selection.annotation'); + return
+ {number ? `${formatNumber(number)}.` : ''} +
+ {t('context.selection')} +
{excerptText(excerpt)}
+ {excerpt.comment?.trim() && <>{t('selection.annotation')} +
{excerpt.comment}
} +
+ } + onClick={() => edit(excerpt, label)} /> + } + onClick={() => { triggerRef.current?.focus(); onRemove(excerpt.id); }} /> +
; + })} +
, getAppearanceOverlayHost())} + {dialog && setDialog(current => current && { ...current, open: value })} />}
; } diff --git a/src/web-ui/src/flow_chat/selection/ConversationExcerptMarkers.test.tsx b/src/web-ui/src/flow_chat/selection/ConversationExcerptMarkers.test.tsx new file mode 100644 index 0000000000..5cb643a11a --- /dev/null +++ b/src/web-ui/src/flow_chat/selection/ConversationExcerptMarkers.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ConversationExcerptContext } from '@/shared/types/context'; +import { ConversationExcerptMarkers } from './ConversationExcerptMarkers'; +import { ConversationExcerptSourceContext } from './conversationExcerptSourceContext'; + +vi.mock('./ConversationExcerptAttachments', () => ({ ConversationExcerptPreview: () => null })); +// Highlight the source even if its endpoint is currently clipped out of view. +vi.mock('./conversationExcerptMarkerPosition', () => ({ measureExcerptMarkerPosition: () => null })); + +const excerpt: ConversationExcerptContext = { + id: 'annotation', type: 'conversation-excerpt', timestamp: 1, annotationNumber: 1, + source: { surfaceId: 'local', sessionId: 'main', sessionName: 'Main' }, + fragments: [{ turnId: 'turn', flowItemId: 'text', text: 'selected', start: 0, end: 8, prefix: '', suffix: '' }], +}; +const name = 'openbitfun-flowchat-annotations'; + +describe('persistent annotation highlights', () => { + let container: HTMLDivElement; + let first: HTMLDivElement; + let second: HTMLDivElement; + let root: Root; + let highlights: Map>; + const frames = new Map(); + let frame = 0; + const render = (left: ConversationExcerptContext[], right: ConversationExcerptContext[] = []) => act(() => { + root.render(<> + + + + + + + ); + }); + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + highlights = new Map(); + vi.stubGlobal('CSS', { highlights }); + vi.stubGlobal('Highlight', class extends Set { constructor(...ranges: Range[]) { super(ranges); } }); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frame, callback); return frame; }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + container = document.createElement('div'); + first = document.createElement('div'); second = document.createElement('div'); + for (const wrapper of [first, second]) { + wrapper.innerHTML = '
selected source
'; + document.body.append(wrapper); + } + document.body.append(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); first.remove(); second.remove(); frames.clear(); + vi.unstubAllGlobals(); + }); + + it('keeps both panes highlighted after native selection clears and removes only the deleted annotation', () => { + render([excerpt], [{ ...excerpt, id: 'peer-annotation' }]); + window.getSelection()?.removeAllRanges(); + expect([...highlights.get(name)!].map(range => range.toString())).toEqual(['selected', 'selected']); + render([], [{ ...excerpt, id: 'peer-annotation' }]); + expect(highlights.get(name)?.size).toBe(1); + expect(second.contains([...highlights.get(name)!][0].startContainer)).toBe(true); + render([]); + expect(highlights.has(name)).toBe(false); + }); + + it('restores every fragment of legacy annotations and rebuilds ranges after source DOM replacement', async () => { + const legacy = { ...excerpt, annotationNumber: undefined, fragments: [ + ...excerpt.fragments, + { ...excerpt.fragments[0], text: 'source', start: 9, end: 15, prefix: '', suffix: '' }, + ] }; + render([legacy]); + expect([...highlights.get(name)!].map(range => range.toString())).toEqual(['selected', 'source']); + const previous = first.querySelector('[data-flow-item-id]')!.firstChild; + await act(async () => { + first.querySelector('[data-flow-item-id]')!.textContent = 'selected source'; + await Promise.resolve(); + }); + act(() => { const pending = [...frames.values()]; frames.clear(); pending.forEach(callback => callback(0)); }); + expect([...highlights.get(name)!].map(range => range.toString())).toEqual(['selected', 'source']); + expect([...highlights.get(name)!][0].startContainer).not.toBe(previous); + render([]); + render([legacy]); + expect(highlights.get(name)?.size).toBe(2); + }); + + it('cleans up on unmount without clearing temporary locate highlights', () => { + const temporary = new Set(); + highlights.set('openbitfun-flowchat-excerpt', temporary); + render([excerpt]); + act(() => root.render(null)); + expect(highlights.has(name)).toBe(false); + expect(highlights.get('openbitfun-flowchat-excerpt')).toBe(temporary); + }); +}); diff --git a/src/web-ui/src/flow_chat/selection/ConversationExcerptMarkers.tsx b/src/web-ui/src/flow_chat/selection/ConversationExcerptMarkers.tsx index d8a8ee8dba..86dac48f9e 100644 --- a/src/web-ui/src/flow_chat/selection/ConversationExcerptMarkers.tsx +++ b/src/web-ui/src/flow_chat/selection/ConversationExcerptMarkers.tsx @@ -1,16 +1,16 @@ -import { useLayoutEffect, useState } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; import type { ConversationExcerptContext } from '@/shared/types/context'; import { excerptNumber } from '@/shared/utils/conversationExcerpt'; import { findExcerptSource, resolveExcerptRange } from './flowChatSelection'; import { useConversationExcerptSources } from './conversationExcerptSourceContext'; import { ConversationExcerptPreview } from './ConversationExcerptAttachments'; -import { measureExcerptMarkerPosition } from './conversationExcerptMarkerPosition'; +import { measureExcerptMarkerPosition, type ExcerptMarkerPosition } from './conversationExcerptMarkerPosition'; +import { createExcerptHighlights } from './conversationExcerptHighlights'; interface MarkerGroup { key: string; element: HTMLElement; - left: number; - top: number; + range: Range; excerpts: readonly ConversationExcerptContext[]; } @@ -18,38 +18,38 @@ interface MarkerGroup { export function ConversationExcerptMarkers({ wrapper, turnId }: { wrapper: HTMLElement | null; turnId: string }) { const excerpts = useConversationExcerptSources(turnId); const [groups, setGroups] = useState([]); + const markerRefs = useRef(new Map()); + const [positions, setPositions] = useState(new Map()); useLayoutEffect(() => { if (!wrapper || !excerpts.length) { setGroups(previous => previous.length ? [] : previous); return; } const view = wrapper.ownerDocument.defaultView; if (!view) return; + const highlights = createExcerptHighlights(wrapper.ownerDocument); let frame: number | null = null; const refresh = () => { frame = null; const next = new Map(); + const ranges: Range[] = []; for (const excerpt of excerpts) { - if (!excerptNumber(excerpt)) continue; for (const fragment of excerpt.fragments) { if (fragment.turnId !== turnId) continue; const element = findExcerptSource(wrapper, fragment); const range = element && resolveExcerptRange(element, fragment); - const position = element && range && measureExcerptMarkerPosition(wrapper, element, range); - if (!element || !position) continue; + if (range) ranges.push(range); + if (!excerptNumber(excerpt)) continue; + if (!element || !range) continue; // Separate selections in one text block keep their own superscripts. - const key = JSON.stringify([fragment.flowItemId ?? null, fragment.end]); + const key = JSON.stringify([fragment.flowItemId ?? null, fragment.start, fragment.end]); const previous = next.get(key); if (previous) { if (!previous.excerpts.some(item => item.id === excerpt.id)) previous.excerpts = [...previous.excerpts, excerpt]; } else next.set(key, { - key, element, ...position, excerpts: [excerpt], + key, element, range, excerpts: [excerpt], }); } } - const values = [...next.values()]; - setGroups(previous => previous.length === values.length && previous.every((group, i) => { - const other = values[i]; - return group.key === other.key && group.element === other.element && group.left === other.left && group.top === other.top - && group.excerpts.length === other.excerpts.length && group.excerpts.every((excerpt, j) => excerpt === other.excerpts[j]); - }) ? previous : values); + highlights.update(ranges); + setGroups([...next.values()]); }; const schedule = () => { if (frame === null) frame = view.requestAnimationFrame(refresh); }; const mutation = new MutationObserver(records => { @@ -62,22 +62,46 @@ export function ConversationExcerptMarkers({ wrapper, turnId }: { wrapper: HTMLE attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'open', 'data-expanded'] }); const resize = new ResizeObserver(schedule); resize.observe(wrapper); - wrapper.addEventListener('scroll', schedule, { capture: true, passive: true }); + wrapper.ownerDocument.addEventListener('scroll', schedule, { capture: true, passive: true }); wrapper.ownerDocument.fonts?.addEventListener('loadingdone', schedule); refresh(); return () => { + highlights.dispose(); if (frame !== null) view.cancelAnimationFrame(frame); mutation.disconnect(); resize.disconnect(); - wrapper.removeEventListener('scroll', schedule, true); + wrapper.ownerDocument.removeEventListener('scroll', schedule, true); wrapper.ownerDocument.fonts?.removeEventListener('loadingdone', schedule); }; }, [wrapper, excerpts, turnId]); + useLayoutEffect(() => { + if (!wrapper) return; + const update = () => { + const next = new Map(); + for (const group of groups) { + const marker = markerRefs.current.get(group.key); + if (!marker) continue; + const size = marker.getBoundingClientRect(); + const position = measureExcerptMarkerPosition(wrapper, group.element, group.range, size); + if (!position) continue; + next.set(group.key, position); + } + setPositions(previous => previous.size === next.size && [...next].every(([key, value]) => + previous.get(key)?.left === value.left && previous.get(key)?.top === value.top) ? previous : next); + }; + update(); + const resize = new ResizeObserver(update); + markerRefs.current.forEach(marker => resize.observe(marker)); + return () => resize.disconnect(); + }, [wrapper, groups]); + return <>{groups.map(group => ( { if (element) markerRefs.current.set(group.key, element); else markerRefs.current.delete(group.key); }} data-openbitfun-product-component="conversation-excerpt" data-openbitfun-product-part="markers" className="conversation-excerpt__markers" data-flowchat-selection-ignore="true" - style={{ left: `max(var(--openbitfun-space-5), ${group.left}px)`, top: group.top }}> + style={{ left: positions.get(group.key)?.left ?? 0, top: positions.get(group.key)?.top ?? 0, + visibility: positions.has(group.key) ? 'visible' : 'hidden' }}> {group.excerpts.map(excerpt => )} ))}; diff --git a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx index 764a5b7496..3162e6cbe0 100644 --- a/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx +++ b/src/web-ui/src/flow_chat/selection/FlowChatSelectionBar.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type import { createPortal } from 'react-dom'; import { Button, Card, Dialog, DialogBody, DialogClose, DialogFooter, DialogHeader, DialogHeading, - DialogTitle, Icon, ToolbarGroup, useDismissibleLayer, + DialogTitle, Icon, ToolbarGroup, ToolbarSeparator, useDismissibleLayer, } from '@openbitfun/ui'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useI18n } from '@/infrastructure/i18n'; @@ -215,10 +215,15 @@ export function FlowChatSelectionBar({ rootRef, sessionId, parentSessionId, acti style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? 'visible' : 'hidden' }}> - - diff --git a/src/web-ui/src/flow_chat/selection/conversationExcerptHighlights.ts b/src/web-ui/src/flow_chat/selection/conversationExcerptHighlights.ts new file mode 100644 index 0000000000..1179a1af83 --- /dev/null +++ b/src/web-ui/src/flow_chat/selection/conversationExcerptHighlights.ts @@ -0,0 +1,26 @@ +type ExcerptHighlight = Set; +interface HighlightView { + CSS?: { highlights?: Map }; + Highlight?: new (...ranges: Range[]) => ExcerptHighlight; +} + +const documents = new WeakMap>(); +const name = 'openbitfun-flowchat-annotations'; + +/** 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(); }, + }; +} diff --git a/src/web-ui/src/flow_chat/selection/conversationExcerptMarkerPosition.test.ts b/src/web-ui/src/flow_chat/selection/conversationExcerptMarkerPosition.test.ts index 6a96211281..ff0a73386c 100644 --- a/src/web-ui/src/flow_chat/selection/conversationExcerptMarkerPosition.test.ts +++ b/src/web-ui/src/flow_chat/selection/conversationExcerptMarkerPosition.test.ts @@ -5,22 +5,46 @@ const rect = (left: number, top: number, width: number, height: number) => ({ left, top, width, height, right: left + width, bottom: top + height, }); const wrapper = rect(100, 200, 600, 300); -const source = rect(100, 210, 600, 100); +const badge = { width: 20, height: 20 }; -describe('annotation text endpoint coordinates', () => { - it('positions the mark at the caret instead of the source corner', () => { - expect(computeExcerptMarkerPosition(wrapper, rect(246, 214, 0, 18), source)).toEqual({ left: 146, top: 14 }); - expect(computeExcerptMarkerPosition(wrapper, rect(412, 214, 0, 18), source)).toEqual({ left: 312, top: 14 }); +describe('annotation selection bounds', () => { + it('overlaps the selection upper-right corner', () => { + const selection = rect(120, 260, 230, 18); + expect(computeExcerptMarkerPosition(wrapper, selection, wrapper, badge)) + .toEqual({ left: 244, top: 46 }); }); - it('follows the endpoint onto its new line after wrapping', () => { - expect(computeExcerptMarkerPosition(wrapper, rect(162, 244, 0, 18), source)).toEqual({ left: 62, top: 44 }); + it('anchors to the top right of the whole multiline selection', () => { + const selection = rect(120, 260, 450, 78); + expect(computeExcerptMarkerPosition(wrapper, selection, wrapper, badge)) + .toEqual({ left: 464, top: 46 }); }); - it('omits an endpoint outside a nested clip rather than moving it to other visible text', () => { - const clip = rect(100, 210, 250, 24); - expect(computeExcerptMarkerPosition(wrapper, rect(246, 244, 0, 18), clip)).toBeNull(); - expect(computeExcerptMarkerPosition(wrapper, rect(380, 214, 0, 18), clip)).toBeNull(); - expect(computeExcerptMarkerPosition(wrapper, rect(246, 214, 0, 0), source)).toBeNull(); + it('keeps the corner position when surrounding text is dense', () => { + const selection = rect(120, 280, 230, 18); + expect(computeExcerptMarkerPosition(wrapper, selection, wrapper, badge)) + .toEqual({ left: 244, top: 66 }); + }); + + it('keeps a wide badge inside the visible right edge', () => { + const selection = rect(120, 280, 580, 18); + expect(computeExcerptMarkerPosition(wrapper, selection, wrapper, { width: 68, height: 20 })) + .toEqual({ left: 532, top: 66 }); + }); + + it('uses the right gutter for selections at the first line without covering their text', () => { + const selection = rect(120, 200, 230, 18); + expect(computeExcerptMarkerPosition(wrapper, selection, wrapper, badge)) + .toEqual({ left: 244, top: 0 }); + }); + + it('keeps partially clipped selections visible and rejects fully clipped or unmeasured markers', () => { + const clip = rect(100, 270, 250, 60); + expect(computeExcerptMarkerPosition(wrapper, rect(120, 260, 100, 18), clip, badge)) + .toEqual({ left: 114, top: 70 }); + expect(computeExcerptMarkerPosition(wrapper, rect(120, 300, 280, 18), clip, badge)) + .toEqual({ left: 230, top: 86 }); + expect(computeExcerptMarkerPosition(wrapper, rect(120, 240, 100, 18), clip, badge)).toBeNull(); + expect(computeExcerptMarkerPosition(wrapper, rect(120, 300, 100, 18), clip, { width: 0, height: 0 })).toBeNull(); }); }); diff --git a/src/web-ui/src/flow_chat/selection/conversationExcerptMarkerPosition.ts b/src/web-ui/src/flow_chat/selection/conversationExcerptMarkerPosition.ts index 0c4dc9391c..f3edab49b4 100644 --- a/src/web-ui/src/flow_chat/selection/conversationExcerptMarkerPosition.ts +++ b/src/web-ui/src/flow_chat/selection/conversationExcerptMarkerPosition.ts @@ -1,31 +1,47 @@ -type Rect = Pick; +import { sourceTextNodes } from './flowChatSelection'; +type Rect = Pick; export interface ExcerptMarkerPosition { left: number; top: number } +export interface ExcerptMarkerSize { width: number; height: number } +const CORNER_OVERLAP = 6; -/** A superscript follows the selection's trailing caret, including a wrapped line. */ -export function computeExcerptMarkerPosition(wrapper: Rect, caret: Rect, visible: Rect): ExcerptMarkerPosition | null { - if (caret.height <= 0 || caret.top < visible.top || caret.bottom > visible.bottom - || caret.right < visible.left || caret.left > visible.right) return null; - return { left: Math.max(0, Math.min(wrapper.width, caret.right - wrapper.left)), top: caret.top - wrapper.top }; +/** Keep the badge attached to the selection's upper-right corner. */ +export function computeExcerptMarkerPosition(wrapper: Rect, selection: Rect, visible: Rect, + size: ExcerptMarkerSize): ExcerptMarkerPosition | null { + if (selection.width <= 0 || selection.height <= 0 || size.width <= 0 || size.height <= 0 + || selection.bottom <= visible.top || selection.top >= visible.bottom + || selection.right <= visible.left || selection.left >= visible.right) return null; + const maxLeft = visible.right - size.width; + const maxTop = visible.bottom - size.height; + if (maxLeft < visible.left || maxTop < visible.top) return null; + const left = Math.max(visible.left, Math.min(selection.right - CORNER_OVERLAP, maxLeft)); + const top = Math.max(visible.top, Math.min(selection.top - size.height + CORNER_OVERLAP, maxTop)); + return { left: left - wrapper.left, top: top - wrapper.top }; } -export function measureExcerptMarkerPosition(wrapper: HTMLElement, source: HTMLElement, range: Range): ExcerptMarkerPosition | null { +export function measureExcerptMarkerPosition(wrapper: HTMLElement, source: HTMLElement, range: Range, + size: ExcerptMarkerSize): ExcerptMarkerPosition | null { const view = wrapper.ownerDocument.defaultView; if (!view) return null; - // A collapsed range follows the logical text endpoint through inline markup - // and bidi text; a block rectangle or the first visible line cannot do that. - const endpoint = range.cloneRange(); - endpoint.collapse(false); - const caret = [...endpoint.getClientRects()].find(rect => rect.height > 0); - if (!caret) return null; - const sourceRect = source.getBoundingClientRect(); - const bounds = wrapper.getBoundingClientRect(); - const visible = { - top: Math.max(sourceRect.top, bounds.top), bottom: Math.min(sourceRect.bottom, bounds.bottom), - left: Math.max(sourceRect.left, bounds.left), right: Math.min(sourceRect.right, bounds.right), - width: sourceRect.width, height: sourceRect.height, + // Range rectangles can include full-width list/block boxes. Measure selected + // text runs only so whitespace after a wrapped line cannot push the badge away. + const rects = sourceTextNodes(source).flatMap(node => { + if (!range.intersectsNode(node)) return []; + const text = source.ownerDocument.createRange(); + text.setStart(node, node === range.startContainer ? range.startOffset : 0); + text.setEnd(node, node === range.endContainer ? range.endOffset : node.length); + return [...text.getClientRects()]; + }).filter(rect => rect.width > 0 && rect.height > 0); + if (!rects.length) return null; + const selection = { + left: Math.min(...rects.map(rect => rect.left)), right: Math.max(...rects.map(rect => rect.right)), + top: Math.min(...rects.map(rect => rect.top)), bottom: Math.max(...rects.map(rect => rect.bottom)), }; - for (let element = endpoint.endContainer.parentElement; element && element !== wrapper; element = element.parentElement) { + const bounds = wrapper.getBoundingClientRect(); + const visible = { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom, + width: bounds.width, height: bounds.height }; + // Clipping belongs to ancestors, not the text block's box (the badge sits outside it). + for (let element: HTMLElement | null = source; element; element = element.parentElement) { const style = view.getComputedStyle(element); if (style.visibility === 'hidden' || style.display === 'none') return null; const rect = element.getBoundingClientRect(); @@ -36,5 +52,6 @@ export function measureExcerptMarkerPosition(wrapper: HTMLElement, source: HTMLE visible.left = Math.max(visible.left, rect.left); visible.right = Math.min(visible.right, rect.right); } } - return computeExcerptMarkerPosition(bounds, caret, visible); + return computeExcerptMarkerPosition(bounds, + { ...selection, width: selection.right - selection.left, height: selection.bottom - selection.top }, visible, size); } diff --git a/src/web-ui/src/infrastructure/appearance/appearanceTokenContract.ts b/src/web-ui/src/infrastructure/appearance/appearanceTokenContract.ts index 884b68fe70..bf5cc89cfa 100644 --- a/src/web-ui/src/infrastructure/appearance/appearanceTokenContract.ts +++ b/src/web-ui/src/infrastructure/appearance/appearanceTokenContract.ts @@ -85,6 +85,7 @@ const prismTokenSuffixes = [ ] as const; const componentTokenSuffixes = [ + 'conversation-excerpt-accent', 'config-page-section-background', 'config-page-section-border', 'config-page-section-border-width', diff --git a/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts b/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts index be84afff09..1748eb80d5 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts @@ -307,6 +307,8 @@ function createAppearanceOwnedTokens( '--openbitfun-component-config-page-row-hover-background': configPageRowHover, '--openbitfun-component-scene-viewport-border-width': palette.layout?.sceneViewportBorder === false ? '0' : '1px', '--openbitfun-component-badge-padding-block': '2px', + // Annotation selections retain the UIKit cyan across named product themes. + '--openbitfun-component-conversation-excerpt-accent': String(themes.light['color.accent.default']), '--openbitfun-domain-context-compression': purple[500], '--openbitfun-domain-generative-ui': '#06b6d4', '--openbitfun-domain-mini-app': purple[500], diff --git a/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts b/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts index 36ad70e2cc..334bfb4eda 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts @@ -4,6 +4,24 @@ import { APPEARANCE_THEME_TOKEN_NAMES } from './catalog'; import { composeAppearancePackage } from './composeAppearancePackage'; describe('composeAppearancePackage', () => { + it.each(['light', 'dark'] as const)('supplies annotation cyan to legacy %s packages without replacing their accent', mode => { + const original: AppearancePackage = { + schema: 'openbitfun.appearance', schemaVersion: 2, + id: 'example.legacy-annotations', name: 'Legacy annotations', version: '1.0.0', mode, + renderers: { 'theme-tokens': { version: 1, settings: { + tokens: { '--openbitfun-color-accent-default': '#7755aa' }, + } } }, + }; + const resolved = composeAppearancePackage(JSON.parse(JSON.stringify(original))); + const tokens = resolved.renderers!['theme-tokens']!.settings.tokens; + expect(tokens['--openbitfun-component-conversation-excerpt-accent']).toBe('#059cb0'); + expect(tokens['--openbitfun-color-accent-default']).toBe('#7755aa'); + expect(composeAppearancePackage(JSON.parse(JSON.stringify(resolved))).renderers?.['theme-tokens']) + .toEqual(resolved.renderers?.['theme-tokens']); + expect(original.renderers!['theme-tokens']!.settings.tokens) + .not.toHaveProperty('--openbitfun-component-conversation-excerpt-accent'); + }); + it('preserves legacy action-card backgrounds in root and chrome across old-payload round trips', () => { const original: AppearancePackage = { schema: 'openbitfun.appearance', schemaVersion: 2,