From 651fa9dbb499a33fb2ffaef6a73e2251ef312636 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sun, 13 Sep 2026 20:31:48 +0800 Subject: [PATCH 1/4] feat(ui): add ellipsis overflow treatment for skill picker labels --- design-system/packages/ui/README.md | 9 ++++ .../OverflowText/OverflowText.module.css | 42 +++++++++++++------ .../primitives/OverflowText/OverflowText.tsx | 18 +++++--- .../packages/ui/tests/overflow-text.test.mjs | 20 ++++++++- .../components/ChatContextPicker.scss | 29 ------------- .../components/ChatContextPicker.tsx | 4 +- .../ChatContextPickerOverlay.test.tsx | 6 ++- .../src/shared/ui/OverflowText.test.tsx | 22 ++++++++++ 8 files changed, 99 insertions(+), 51 deletions(-) diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index d269968099..d06ae3e766 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -159,6 +159,15 @@ its virtual active state through this contract. Selected tabs do not animate automatically. Motion respects `prefers-reduced-motion`; reduced-motion users keep the static fade. Text and movement follow RTL direction. +Use `overflowStyle="ellipsis"` when a single-line slot requires a visible ellipsis +instead of a resting fade. It works with static `behavior="fade"` labels and with +`behavior="marquee"`, which reveals the full text on interaction and returns to +the ellipsis afterward. Reduced motion keeps the ellipsis. Multiline clamps retain +their existing behavior. Set `marqueeTrigger="interaction"` to ignore virtual +active state on both the label and its owner; the default +`"interaction-or-active"` preserves existing listbox behavior. This also prevents +virtual activation from opening the label's tooltip; actual hover/focus still works. + Rich children default to fade to preserve the label's existing inline composition. Composite containers keep their icons/actions fixed and give each text slot its own `OverflowText`. Marquee measures and diff --git a/design-system/packages/ui/src/primitives/OverflowText/OverflowText.module.css b/design-system/packages/ui/src/primitives/OverflowText/OverflowText.module.css index 8f5e2ff4dd..487302767f 100644 --- a/design-system/packages/ui/src/primitives/OverflowText/OverflowText.module.css +++ b/design-system/packages/ui/src/primitives/OverflowText/OverflowText.module.css @@ -33,6 +33,12 @@ padding-block: calc(var(--openbitfun-space-1) / 2); } + .root[data-overflow-style="ellipsis"] > .content { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + } + .root[data-overflow-lines] { display: -webkit-box; -webkit-box-orient: vertical; @@ -51,8 +57,8 @@ } /* Keep resting specificity low so the owning control can reveal the full end. */ - .root[data-overflow-behavior="fade"][data-overflow="true"]:not([data-overflow-lines]), - .root[data-overflow-behavior="marquee"][data-overflow="true"]:where(:not(:hover):not(:focus-visible):not([data-marquee-active="true"])) { + .root:not([data-overflow-style="ellipsis"])[data-overflow-behavior="fade"][data-overflow="true"]:not([data-overflow-lines]), + .root:not([data-overflow-style="ellipsis"])[data-overflow-behavior="marquee"][data-overflow="true"]:where(:not(:hover):not(:focus-visible):not([data-marquee-active="true"])) { -webkit-mask-image: linear-gradient( to right, currentColor 0, @@ -69,8 +75,8 @@ mask-repeat: no-repeat; } - .root:where(:dir(rtl))[data-overflow-behavior="fade"][data-overflow="true"]:not([data-overflow-lines]), - .root:where(:dir(rtl))[data-overflow-behavior="marquee"][data-overflow="true"]:where(:not(:hover):not(:focus-visible):not([data-marquee-active="true"])) { + .root:not([data-overflow-style="ellipsis"]):where(:dir(rtl))[data-overflow-behavior="fade"][data-overflow="true"]:not([data-overflow-lines]), + .root:not([data-overflow-style="ellipsis"]):where(:dir(rtl))[data-overflow-behavior="marquee"][data-overflow="true"]:where(:not(:hover):not(:focus-visible):not([data-marquee-active="true"])) { -webkit-mask-image: linear-gradient( to left, currentColor 0, @@ -85,15 +91,20 @@ ); } - :where([data-overflow-trigger]:is(:hover, :focus-within, [data-overflow-active="true"])) .root[data-overflow-behavior="marquee"][data-overflow="true"] { + :where([data-overflow-trigger][data-overflow-active="true"]) .root:where(:not([data-marquee-trigger="interaction"])):not([data-overflow-style="ellipsis"])[data-overflow-behavior="marquee"][data-overflow="true"], + :where([data-overflow-trigger]:is(:hover, :focus-within)) .root:not([data-overflow-style="ellipsis"])[data-overflow-behavior="marquee"][data-overflow="true"] { -webkit-mask-image: none; mask-image: none; } - :where([data-overflow-trigger]:is(:hover, :focus-within, [data-overflow-active="true"])) .root[data-overflow-behavior="marquee"][data-overflow="true"] .content, + :where([data-overflow-trigger][data-overflow-active="true"]) .root:where(:not([data-marquee-trigger="interaction"]))[data-overflow-behavior="marquee"][data-overflow="true"] .content, + :where([data-overflow-trigger]:is(:hover, :focus-within)) .root[data-overflow-behavior="marquee"][data-overflow="true"] .content, .root[data-overflow-behavior="marquee"][data-overflow="true"]:hover .content, .root[data-overflow-behavior="marquee"][data-overflow="true"]:focus-visible .content, .root[data-overflow-behavior="marquee"][data-overflow="true"][data-marquee-active="true"] .content { + min-inline-size: max-content; + overflow: visible; + text-overflow: clip; animation: openbitfun-overflow-text-marquee var(--_overflow-text-marquee-duration) linear infinite alternate; will-change: transform; } @@ -110,7 +121,7 @@ } @media (prefers-reduced-motion: reduce) { - .root[data-overflow-behavior="marquee"][data-overflow="true"] { + .root:not([data-overflow-style="ellipsis"])[data-overflow-behavior="marquee"][data-overflow="true"] { -webkit-mask-image: linear-gradient( to right, currentColor 0, @@ -127,7 +138,7 @@ mask-repeat: no-repeat; } - .root:dir(rtl)[data-overflow-behavior="marquee"][data-overflow="true"] { + .root:not([data-overflow-style="ellipsis"]):dir(rtl)[data-overflow-behavior="marquee"][data-overflow="true"] { -webkit-mask-image: linear-gradient( to left, currentColor 0, @@ -142,7 +153,8 @@ ); } - :where([data-overflow-trigger]:is(:hover, :focus-within, [data-overflow-active="true"])) .root[data-overflow-behavior="marquee"][data-overflow="true"] .content, + :where([data-overflow-trigger][data-overflow-active="true"]) .root:where(:not([data-marquee-trigger="interaction"]))[data-overflow-behavior="marquee"][data-overflow="true"] .content, + :where([data-overflow-trigger]:is(:hover, :focus-within)) .root[data-overflow-behavior="marquee"][data-overflow="true"] .content, .root[data-overflow-behavior="marquee"][data-overflow="true"]:hover .content, .root[data-overflow-behavior="marquee"][data-overflow="true"]:focus-visible .content, .root[data-overflow-behavior="marquee"][data-overflow="true"][data-marquee-active="true"] .content { @@ -150,12 +162,18 @@ transform: none; will-change: auto; } + + .root[data-overflow-style="ellipsis"][data-overflow-behavior="marquee"][data-overflow="true"] > .content { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + } } @media (forced-colors: active) { - .root:dir(rtl)[data-overflow-behavior][data-overflow="true"], - .root[data-overflow-behavior][data-overflow="true"], - .root[data-overflow-behavior][data-overflow="true"]:not(:hover):not(:focus-visible):not([data-marquee-active="true"]) { + .root:not([data-overflow-style="ellipsis"]):dir(rtl)[data-overflow-behavior][data-overflow="true"], + .root:not([data-overflow-style="ellipsis"])[data-overflow-behavior][data-overflow="true"], + .root:not([data-overflow-style="ellipsis"])[data-overflow-behavior][data-overflow="true"]:not(:hover):not(:focus-visible):not([data-marquee-active="true"]) { -webkit-mask-image: none; mask-image: none; } diff --git a/design-system/packages/ui/src/primitives/OverflowText/OverflowText.tsx b/design-system/packages/ui/src/primitives/OverflowText/OverflowText.tsx index 5500021608..090940a449 100644 --- a/design-system/packages/ui/src/primitives/OverflowText/OverflowText.tsx +++ b/design-system/packages/ui/src/primitives/OverflowText/OverflowText.tsx @@ -37,6 +37,10 @@ export interface OverflowTextProps extends HTMLAttributes { lines?: number; /** Plain text defaults to marquee; rich composition defaults to a static fade. */ behavior?: OverflowTextBehavior; + /** Single-line resting treatment; multiline clamps keep their existing layout. */ + overflowStyle?: "fade" | "ellipsis"; + /** Interaction-only text ignores virtual active state on itself and its owner. */ + marqueeTrigger?: "interaction-or-active" | "interaction"; /** Runs an overflowing marquee while its owning control is virtually active. */ marqueeActive?: boolean; } @@ -54,6 +58,8 @@ export const OverflowText = forwardRef( className, lines, marqueeActive = false, + marqueeTrigger = "interaction-or-active", + overflowStyle = "fade", style, title, ...props @@ -124,7 +130,7 @@ export const OverflowText = forwardRef( useIsomorphicLayoutEffect(() => { updateOverflow(); - }, [behavior, children, lines, updateOverflow]); + }, [behavior, children, lines, overflowStyle, updateOverflow]); useEffect(() => { if (measurementRef.current.isOverflowing) prepareTooltip(); @@ -160,7 +166,7 @@ export const OverflowText = forwardRef( element.ownerDocument.defaultView?.removeEventListener("resize", updateOverflow); } }; - }, [behavior, prepareTooltip, textOnly, updateOverflow]); + }, [behavior, overflowStyle, prepareTooltip, textOnly, updateOverflow]); const marqueeDuration = Math.max( MARQUEE_MIN_DURATION_MS, @@ -182,17 +188,19 @@ export const OverflowText = forwardRef( - {behavior === "marquee" || (textOnly && lines === undefined) ? ( + {behavior === "marquee" || ((textOnly || overflowStyle === "ellipsis") && lines === undefined) ? ( {children} @@ -200,7 +208,7 @@ export const OverflowText = forwardRef( {hasOverflowTooltip && ( \{\s*updateOverflow\(\);\s*\}, \[behavior, children, lines, updateOverflow\]\);/s, + /useIsomorphicLayoutEffect\(\(\) => \{\s*updateOverflow\(\);\s*\}, \[behavior, children, lines, overflowStyle, updateOverflow\]\);/s, ); assert.match(source, /new ResizeObserver\(updateOverflow\)/); assert.match(source, /resizeObserver\?\.observe\(contentRef\.current\)/); @@ -77,5 +77,21 @@ test("OverflowText measures real clipping for fade and marquee treatments", asyn assert.match(styles, /openbitfun-overflow-text-marquee/); assert.match(styles, /prefers-reduced-motion:\s*reduce/); assert.match(styles, /\.root:dir\(rtl\)/); - assert.doesNotMatch(styles, /text-overflow:\s*ellipsis/); + assert.match(styles, /\.root\[data-overflow-style="ellipsis"\] > \.content\s*\{[^}]*text-overflow:\s*ellipsis/s); + assert.match(styles, /data-overflow-active="true"[^\n]+:not\(\[data-marquee-trigger="interaction"\]\)/); +}); + +test("OverflowText makes resting ellipsis and interaction-only activation explicit", () => { + const markup = renderToStaticMarkup(createElement(OverflowText, { + overflowStyle: "ellipsis", marqueeTrigger: "interaction", marqueeActive: true, + }, "The complete description")); + assert.match(markup, /data-overflow-style="ellipsis"/); + assert.match(markup, /data-marquee-trigger="interaction"/); + assert.doesNotMatch(markup, /data-marquee-active/); + assert.match(markup, /The complete description/); + const multiline = renderToStaticMarkup(createElement(OverflowText, { + overflowStyle: "ellipsis", lines: 2, + }, "The complete description")); + assert.match(multiline, /data-overflow-lines="2"/); + assert.doesNotMatch(multiline, /data-overflow-style/); }); diff --git a/src/web-ui/src/flow_chat/components/ChatContextPicker.scss b/src/web-ui/src/flow_chat/components/ChatContextPicker.scss index 9b8c0a99d6..82a266ef7e 100644 --- a/src/web-ui/src/flow_chat/components/ChatContextPicker.scss +++ b/src/web-ui/src/flow_chat/components/ChatContextPicker.scss @@ -130,13 +130,6 @@ max-inline-size: 60%; } - &__skill-name { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - &__skill-option > [data-openbitfun-part='metadata'] { flex: 1 1 auto; inline-size: 8rem; @@ -146,30 +139,8 @@ &__skill-description { inline-size: 100%; - // This picker uses a visible ellipsis instead of the shared resting fade. - -webkit-mask-image: none; - mask-image: none; - } - - &__skill-option:not(:hover):not(:focus-visible) &__skill-description > [data-overflow-content] { - display: block; - min-inline-size: 0; - overflow: hidden; - text-overflow: ellipsis; - // Virtual selection alone must not start the marquee on initial opening. - animation: none; - transform: none; } - @media (prefers-reduced-motion: reduce) { - &__skill-description > [data-overflow-content] { - display: block; - min-inline-size: 0; - overflow: hidden; - text-overflow: ellipsis; - } - } - &__footer { display: flex; align-items: center; diff --git a/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx b/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx index a44b4879da..015d7d8ee8 100644 --- a/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx +++ b/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx @@ -928,6 +928,8 @@ export const ChatContextPicker: React.FC = ({ = ({ onMouseEnter={() => setSelectedIndex(index)} value={key} > - {skill ? {label} : label} + {skill ? {label} : label} ); })} diff --git a/src/web-ui/src/flow_chat/components/ChatContextPickerOverlay.test.tsx b/src/web-ui/src/flow_chat/components/ChatContextPickerOverlay.test.tsx index 6c71cb6b51..c2c4cd90b6 100644 --- a/src/web-ui/src/flow_chat/components/ChatContextPickerOverlay.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatContextPickerOverlay.test.tsx @@ -374,12 +374,14 @@ describe('ChatContextPicker overlay', () => { .toBe(skill.name); expect(skillOptions[0]?.querySelector('[data-openbitfun-part="label"]') ?.getAttribute('data-overflow-behavior')).toBe('fade'); - expect(skillOptions[0]?.querySelector('[data-openbitfun-part="label"] [data-overflow-content]')) - .toBeNull(); + expect(skillOptions[0]?.querySelector('[data-openbitfun-part="label"] [data-overflow-style="ellipsis"]') + ?.getAttribute('data-overflow-behavior')).toBe('fade'); expect(skillOptions[0]?.querySelector('[data-openbitfun-part="metadata"]')?.textContent) .toBe('Work with PDFs'); const description = skillOptions[0]?.querySelector('[data-openbitfun-part="skillDescription"]'); expect(description?.getAttribute('data-marquee-active')).toBeNull(); + expect(description?.getAttribute('data-marquee-trigger')).toBe('interaction'); + expect(description?.getAttribute('data-overflow-style')).toBe('ellipsis'); expect(description?.getAttribute('title')).toBe(''); expect(skillOptions[0]?.getAttribute('title')).toBe(''); expect(skillOptions[0]?.querySelector('[data-openbitfun-part="label"]')?.getAttribute('title')).toBe(''); diff --git a/src/web-ui/src/shared/ui/OverflowText.test.tsx b/src/web-ui/src/shared/ui/OverflowText.test.tsx index 6388037812..1e6d634492 100644 --- a/src/web-ui/src/shared/ui/OverflowText.test.tsx +++ b/src/web-ui/src/shared/ui/OverflowText.test.tsx @@ -75,6 +75,28 @@ describe('overflow text full-content access', () => { expect(button.getAttribute('aria-describedby')).toBe('help'); }); + it('keeps interaction-only ellipsis idle on virtual selection and reveals full text on focus', () => { + render(); + const label = host.querySelector('[data-overflow]')!; + expect(label.getAttribute('data-overflow')).toBe('true'); + expect(label.getAttribute('data-marquee-active')).toBeNull(); + reveal(); + expect(tooltip()).toBeNull(); + act(() => host.querySelector('button')!.focus()); + reveal(); + expect(tooltip()?.textContent).toBe(longLabel); + act(() => { + availableWidth = 1000; + resizeCallbacks.forEach(callback => callback()); + }); + expect(label.getAttribute('data-overflow')).toBe('false'); + expect(tooltip()).toBeNull(); + }); + it('keeps the command tooltip open when portal enter precedes native trigger leave', () => { render(); expect(host.querySelector('[title]')).toBeNull(); From fdea6f6684e8886725aab6c61afde602d338dd57 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sun, 13 Sep 2026 20:42:51 +0800 Subject: [PATCH 2/4] refactor(ui): adopt TabGroup for session usage sections --- design-system/packages/ui/README.md | 6 + .../src/components/TabGroup/TabGroup.meta.ts | 1 + .../ui/src/components/TabGroup/TabGroup.tsx | 6 +- .../packages/ui/tests/tab-group.test.mjs | 27 +++++ package.json | 2 +- scripts/appearance-dom-contracts.mjs | 61 ++++++++++ scripts/appearance-dom-contracts.test.mjs | 43 +++++++ scripts/audit-appearance-contracts.mjs | 22 ++-- .../usage/SessionUsageComponents.test.tsx | 67 +++++++++++ .../components/usage/SessionUsagePanel.scss | 17 +++ .../components/usage/SessionUsagePanel.tsx | 109 ++++++------------ .../flow_chat/components/usage/appearance.ts | 1 + .../compiler/AppearanceCompiler.test.ts | 2 +- 13 files changed, 276 insertions(+), 88 deletions(-) create mode 100644 scripts/appearance-dom-contracts.mjs create mode 100644 scripts/appearance-dom-contracts.test.mjs diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index d06ae3e766..c1f03c8be5 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -237,6 +237,12 @@ Hosts without Web Animations render the current text immediately. The **RollingText** Design Lab entry includes manual standalone and TabGroup examples for repeated replacement and long labels. +`TabGroupItem.tabProps` applies `className`, `style`, `title`, `aria-label`, +`aria-describedby`, and `data-*` attributes directly to the native tab button. +Use product-namespaced data attributes for product appearance identity. The +component retains its own part marker, IDs, panel association, disabled state, +selection, focus, and event handling. Keep IDs and panel IDs in the item fields. + `TabGroup.renderItem(item, node, index)` can wrap the supplied standard item in a tooltip, context-menu owner, or drag target. Keep `node` intact so TabGroup continues to own selection, keyboard navigation, label overflow, and end-action diff --git a/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts b/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts index 1448d8d41e..f55e8e50e4 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts @@ -9,6 +9,7 @@ export const tabGroupMeta = { { name: "items", type: "readonly TabGroupItem[]" }, { name: "items[].labelTransitionKey", type: "string | number" }, { name: "items[].labelSuffix", type: "ReactNode" }, + { name: "items[].tabProps", type: "TabGroupItem['tabProps']" }, { name: "value", type: "string" }, { name: "defaultValue", type: "string" }, { name: "onValueChange", type: "(value: string) => void" }, diff --git a/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx b/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx index 67b18ae710..840eaebb90 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx @@ -24,6 +24,9 @@ export interface TabGroupItem { /** Opt plain text into vertical replacement without remounting the tab. */ labelTransitionKey?: string | number; panelId?: string; + /** Presentation and product data attributes on the native tab; selection stays owned by TabGroup. */ + tabProps?: Pick, "className" | "style" | "title" | "aria-label" | "aria-describedby"> + & Record<`data-${string}`, string | number | boolean | undefined>; value: string; } @@ -144,10 +147,11 @@ export const TabGroup = forwardRef(function TabGr key={item.value} > - ))} - + value={activeTab} + onValueChange={value => setActiveTab(value as SessionUsagePanelTab)} + items={TABS.map(tab => ({ + value: tab, + id: tabId(tab), + panelId: tabPanelId(tab), + label: t(`usage.tabs.${tab}`), + tabProps: { + className: `session-usage-panel__tab${activeTab === tab ? ' session-usage-panel__tab--active' : ''}`, + 'data-openbitfun-product-component': 'session-usage-panel', + 'data-openbitfun-product-part': 'tab', + 'data-openbitfun-tab': tab, + 'data-openbitfun-state': activeTab === tab ? 'active' : undefined, + }, + }))} + />
void; }) { return ( -
+
{label} {value} {onCopy && copyLabel && ( @@ -621,7 +582,7 @@ function UsageOverview({ report }: { report: SessionUsageReport }) { ]; return ( -
+
{report.coverage.level !== 'complete' && (
@@ -633,7 +594,7 @@ function UsageOverview({ report }: { report: SessionUsageReport }) { {metrics.map(metric => { const Icon = metric.icon; return ( -
+
{metric.label} @@ -952,7 +913,7 @@ function UsageFiles({ }), [handleJumpToFileTurn, handleOpenFileDiff, openingDiffKey, redactPaths, report.files.files, report.files.scope, sessionId, t, workspacePath]); return ( -
+
{t('usage.panel.fileScope')} +
{t('usage.panel.errorScope')} +
{t('usage.sections.slowest')} +
{emptyDescription && {emptyDescription}}
@@ -1273,7 +1234,7 @@ function UsageTable({ empty, emptyLabel, emptyDescription, emptyHelp, headers, r return ( <> -
+
diff --git a/src/web-ui/src/flow_chat/components/usage/appearance.ts b/src/web-ui/src/flow_chat/components/usage/appearance.ts index afb177faa0..e6af481546 100644 --- a/src/web-ui/src/flow_chat/components/usage/appearance.ts +++ b/src/web-ui/src/flow_chat/components/usage/appearance.ts @@ -2,6 +2,7 @@ import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; export const sessionUsagePanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'session-usage-panel', + componentAttribute: 'data-openbitfun-product-component', parts: [ { id: 'root' }, { id: 'header' }, diff --git a/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts b/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts index 398a14fa13..c5a2685ddd 100644 --- a/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts +++ b/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts @@ -1011,7 +1011,7 @@ describe('AppearanceCompiler', () => { expect(snapshot.cssText).toContain('[data-openbitfun-component="remote-connect-dialog"][data-openbitfun-part="sidebar"]'); expect(snapshot.cssText).toContain('[data-openbitfun-component="remote-connect-dialog"][data-openbitfun-part="overviewAction"][data-openbitfun-group="account"][data-openbitfun-state~="authenticated"]'); expect(snapshot.cssText).toContain('[data-openbitfun-scene="agents"][data-openbitfun-part="catalogGrid"]'); - expect(snapshot.cssText).toContain('[data-openbitfun-component="session-usage-panel"][data-openbitfun-part="tab"][data-openbitfun-tab="models"][data-openbitfun-state~="active"]'); + expect(snapshot.cssText).toContain('[data-openbitfun-product-component="session-usage-panel"][data-openbitfun-product-part="tab"][data-openbitfun-tab="models"][data-openbitfun-state~="active"]'); expect(snapshot.cssText).toContain('[data-openbitfun-component="acp-agents-config"][data-openbitfun-part="root"][data-openbitfun-view="json"]'); expect(snapshot.cssText).toContain('[data-openbitfun-component="acp-agents-config"][data-openbitfun-part="remoteServer"]'); expect(snapshot.cssText).toContain('[data-openbitfun-component="tiptap-editor"][data-openbitfun-part="inlineAiPanel"]'); From 435b77a1547286b04d45a1fea923f8e37bd6d6a5 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sun, 13 Sep 2026 20:52:56 +0800 Subject: [PATCH 3/4] refactor(ui): adopt NavigationPanelItem for appearance market views --- .../src/pages/ComponentDetailPage.tsx | 3 + design-system/packages/ui/README.md | 7 +++ .../components/ActionItem/ActionItem.meta.ts | 1 + .../ActionItem/ActionItem.module.css | 5 ++ .../src/components/ActionItem/ActionItem.tsx | 9 ++- .../ui/tests/navigation-panel.test.mjs | 15 +++++ .../AppearanceMarketDialog.test.tsx | 59 ++++++++++++++++++- .../components/AppearanceMarketDialog.tsx | 22 +++++-- .../components/AppearanceSettingsPage.scss | 26 +++++++- 9 files changed, 136 insertions(+), 11 deletions(-) diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index c344f5a7a6..523edf05b5 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -1850,6 +1850,9 @@ export function ComponentDetailPage({ {t("components.preview.navigationPanelMoreItem")} + + {t("components.preview.cardDescription")} + diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index c1f03c8be5..9771701307 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -237,6 +237,13 @@ Hosts without Web Animations render the current text immediately. The **RollingText** Design Lab entry includes manual standalone and TabGroup examples for repeated replacement and long labels. +`ActionItem` and its `NavigationPanelItem` composition also accept +`labelBehavior="static"` to preserve wrapping labels without an implicit overflow +tooltip or marquee. Their default remains `"overflow"`. The public `label` part +retains its typography, and the static label inherits whitespace from its owner. +`NavigationPanelItem.className` styles the outer row; native button attributes and +the ref reach its `trigger` part. Keep row layout separate from trigger styling. + `TabGroupItem.tabProps` applies `className`, `style`, `title`, `aria-label`, `aria-describedby`, and `data-*` attributes directly to the native tab button. Use product-namespaced data attributes for product appearance identity. The diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts b/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts index 538038af0a..2a11e65046 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts @@ -6,6 +6,7 @@ export const actionItemMeta = { maturity: "stable", name: "ActionItem", props: [ + { defaultValue: "overflow", name: "labelBehavior", type: "overflow | static" }, { name: "children", type: "ReactNode" }, { name: "leading", type: "ReactNode" }, { defaultValue: "false", name: "reserveLeadingSpace", type: "boolean" }, diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css b/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css index 4377888c89..c8a0ae4aeb 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css @@ -103,6 +103,11 @@ white-space: nowrap; } + .staticLabel { + overflow: visible; + white-space: inherit; + } + .metadata, .shortcut, .actions { diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx b/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx index c7593438b2..15d1b4ae75 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx @@ -30,6 +30,8 @@ export interface ActionItemProps children: ReactNode; className?: string; leading?: ReactNode; + /** Static labels preserve wrapping without implicit overflow motion or tooltips. */ + labelBehavior?: "overflow" | "static"; metadata?: ReactNode; reserveLeadingSpace?: boolean; shortcut?: ReactNode; @@ -42,6 +44,7 @@ export const ActionItem = forwardRef(functio className, disabled, leading, + labelBehavior = "overflow", metadata, reserveLeadingSpace = false, shortcut, @@ -71,7 +74,11 @@ export const ActionItem = forwardRef(functio {leading} )} - {children} + {labelBehavior === "static" ? ( + {children} + ) : ( + {children} + )} {metadata !== undefined && metadata !== null && ( {metadata} diff --git a/design-system/packages/ui/tests/navigation-panel.test.mjs b/design-system/packages/ui/tests/navigation-panel.test.mjs index 104250c2f3..0345185d7b 100644 --- a/design-system/packages/ui/tests/navigation-panel.test.mjs +++ b/design-system/packages/ui/tests/navigation-panel.test.mjs @@ -14,6 +14,21 @@ import { NavigationPanelSeparator, } from "../dist/index.js"; +test("NavigationPanelItem forwards static labels and keeps native navigation semantics", () => { + const markup = renderToStaticMarkup(createElement(NavigationPanelItem, { + selected: true, labelBehavior: "static", className: "horizontal-navigation", + title: "My submissions", "data-active": true, + }, "My submitted appearance packages")); + assert.match(markup, /]+class="[^"]+ horizontal-navigation"/); + assert.match(markup, /]+aria-current="page"/); + assert.match(markup, /data-active="true"/); + assert.match(markup, /type="button"/); + assert.match(markup, /data-openbitfun-part="label">My submitted appearance packages<\/span>/); + assert.doesNotMatch(markup, /data-overflow-behavior|data-overflow-content|labelBehavior/); + const defaultMarkup = renderToStaticMarkup(createElement(NavigationPanelItem, null, "Navigation")); + assert.match(defaultMarkup, /data-overflow-behavior="marquee"/); +}); + test("NavigationPanel composes independent header, grouped body, and footer regions", () => { const markup = renderToStaticMarkup( createElement( diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx index 716cb0e8c0..96cc9b817e 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx @@ -31,7 +31,7 @@ const mocks = vi.hoisted(() => ({ me: { user: { githubId: 1, login: 'reviewer', avatarUrl: '' }, isAdmin: true, - }, + } as { user: { githubId: number; login: string; avatarUrl: string }; isAdmin: boolean } | null, }, })); @@ -39,7 +39,8 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -vi.mock('@openbitfun/ui', () => ({ +vi.mock('@openbitfun/ui', async (importOriginal) => ({ + NavigationPanelItem: (await importOriginal()).NavigationPanelItem, ScrollArea: ({ children, ...props }: React.HTMLAttributes) =>
{children}
, Icon: ({ name, ...props }: { name: string } & React.HTMLAttributes) => , OverflowText: ({ children, behavior: _behavior, marqueeActive: _marqueeActive, ...props }: any) => {children}, @@ -189,6 +190,10 @@ describe('AppearanceMarketDialog', () => { let root: ReturnType; beforeEach(() => { + mocks.accountState.me = { + user: { githubId: 1, login: 'reviewer', avatarUrl: '' }, isAdmin: true, + }; + mocks.accountState.status = 'signed-in'; mocks.getCachedPage.mockReset(); mocks.browse.mockReset().mockResolvedValue({ items: [summary] }); mocks.getListing.mockReset().mockResolvedValue({ @@ -288,6 +293,11 @@ describe('AppearanceMarketDialog', () => { }); expect(mocks.activate).not.toHaveBeenCalled(); expect(container.textContent).toContain('package.market.noAutoApply'); + const browse = container.querySelector('.appearance-market__nav [aria-current="page"]')!; + expect(browse.textContent).toBe('package.market.views.browse'); + await act(async () => browse.querySelector('[data-openbitfun-part="label"]')!.click()); + expect(container.querySelector('.appearance-market__detail')).toBeNull(); + expect(container.querySelector('.appearance-market__browse')).not.toBeNull(); }); it('holds the grid with placeholder cards while the first page loads', async () => { @@ -400,19 +410,62 @@ describe('AppearanceMarketDialog', () => { const submissionsTab = [...container.querySelectorAll('button')] .find(button => button.textContent === 'package.market.views.submissions'); - await act(async () => submissionsTab?.click()); + await act(async () => submissionsTab?.querySelector('[data-openbitfun-part="label"]')?.click()); await vi.waitFor(() => expect(container.textContent).toContain('Tokyo Night candidate')); expect(mocks.listSubmissions).toHaveBeenCalledOnce(); + expect(submissionsTab?.getAttribute('aria-current')).toBe('page'); + await act(async () => submissionsTab?.click()); + expect(mocks.listSubmissions).toHaveBeenCalledOnce(); const reviewTab = [...container.querySelectorAll('button')] .find(button => button.textContent === 'package.market.views.review'); await act(async () => reviewTab?.click()); await vi.waitFor(() => expect(mocks.getReviewSubmission).toHaveBeenCalledWith('submission-1')); expect(mocks.listReviewSubmissions).toHaveBeenCalledOnce(); + expect(reviewTab?.getAttribute('aria-current')).toBe('page'); expect(container.textContent).toContain('package.market.review.approve'); expect(container.textContent).toContain('package.market.review.reject'); }); + it.each([ + { signedIn: false, admin: false, count: 1 }, + { signedIn: true, admin: false, count: 2 }, + { signedIn: true, admin: true, count: 3 }, + ])('keeps navigation visibility for account state $signedIn / admin $admin', async ({ signedIn, admin, count }) => { + mocks.accountState.me = signedIn ? { + user: { githubId: 1, login: 'member', avatarUrl: '' }, isAdmin: admin, + } : null; + mocks.accountState.status = signedIn ? 'signed-in' : 'signed-out'; + const onClose = vi.fn(); + await act(async () => root.render()); + const nav = container.querySelector('nav')!; + const buttons = nav.querySelectorAll('button'); + expect(buttons).toHaveLength(count); + expect(nav.querySelectorAll('[data-openbitfun-component="action-item"]')).toHaveLength(count); + expect(nav.querySelectorAll('[aria-current="page"]')).toHaveLength(1); + expect(nav.querySelector('[role="tab"]')).toBeNull(); + expect(nav.querySelector('[data-overflow-behavior]')).toBeNull(); + await act(async () => buttons[0].click()); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('returns to browse when account permissions remove the active navigation entry', async () => { + const onClose = vi.fn(); + await act(async () => root.render()); + const review = [...container.querySelectorAll('nav button')] + .find(button => button.textContent === 'package.market.views.review')!; + await act(async () => review.click()); + expect(review.getAttribute('aria-current')).toBe('page'); + mocks.accountState.me = { ...mocks.accountState.me!, isAdmin: false }; + await act(async () => root.render()); + expect(container.querySelectorAll('nav button')).toHaveLength(2); + expect(container.querySelector('nav [aria-current="page"]')?.textContent).toBe('package.market.views.browse'); + mocks.accountState.me = null; + await act(async () => root.render()); + expect(container.querySelectorAll('nav button')).toHaveLength(1); + expect(onClose).not.toHaveBeenCalled(); + }); + it('shows a moderated Skin as unpublished instead of approved', async () => { mocks.listSubmissions.mockResolvedValue([{ submissionId: 'submission-unpublished', diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx index 3f03fbcb64..de67d80744 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx @@ -1,5 +1,6 @@ import { OverflowText, Button, + NavigationPanelItem, Icon, ScrollArea, SearchField, @@ -475,33 +476,42 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial data-openbitfun-part="marketNav" aria-label={t('package.market.views.label')} > - + {account.me && ( - + )} {account.me?.isAdmin && ( - + )} {view !== 'browse' ? : detail ? renderDetail() : ( diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss b/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss index 9ba5fcf1b1..ed9c6e706d 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss +++ b/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss @@ -359,7 +359,20 @@ padding-bottom: var(--openbitfun-space-2); border-bottom: 1px solid var(--openbitfun-color-border-default); - button { + .appearance-market__nav-item { + inline-size: auto; + min-inline-size: min-content; + min-block-size: 0; + align-items: stretch; + padding: 0; + gap: 0; + border-radius: var(--openbitfun-radius-sm); + background: transparent; + box-shadow: none; + transition: none; + } + + .appearance-market__nav-item > [data-openbitfun-part='trigger'] { padding: var(--openbitfun-space-2) var(--openbitfun-space-3); border: 0; border-radius: var(--openbitfun-radius-sm); @@ -367,8 +380,19 @@ color: var(--openbitfun-color-content-secondary); font: inherit; font-size: var(--openbitfun-type-label-md-font-size); + text-align: center; + white-space: normal; cursor: pointer; + @layer openbitfun.components { + outline: revert-layer; + } + + > [data-openbitfun-part='label'] { + font: inherit; + letter-spacing: inherit; + } + &:hover { background: var(--openbitfun-color-surface-subtle); color: var(--openbitfun-color-content-primary); From dec11e47319e3a8e1a684a3e1d85d35416c5ee81 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sun, 13 Sep 2026 22:00:30 +0800 Subject: [PATCH 4/4] fix(ui): separate market account controls from dialog title --- .../config/components/AppearanceMarketDialog.test.tsx | 8 +++++++- .../config/components/AppearanceMarketDialog.tsx | 10 +++++++--- .../config/components/AppearanceSettingsPage.scss | 4 ++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx index 96cc9b817e..023b8d4d1f 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx @@ -41,6 +41,7 @@ vi.mock('react-i18next', () => ({ vi.mock('@openbitfun/ui', async (importOriginal) => ({ NavigationPanelItem: (await importOriginal()).NavigationPanelItem, + DialogHeaderActions: (await importOriginal()).DialogHeaderActions, ScrollArea: ({ children, ...props }: React.HTMLAttributes) =>
{children}
, Icon: ({ name, ...props }: { name: string } & React.HTMLAttributes) => , OverflowText: ({ children, behavior: _behavior, marqueeActive: _marqueeActive, ...props }: any) => {children}, @@ -103,7 +104,7 @@ vi.mock('@/infrastructure/confirm-dialog', () => ({ })); vi.mock('@/features/market-account', () => ({ - AccountIdentityControls: () =>
, + AccountIdentityControls: () => , })); vi.mock('@/infrastructure/account-identity', () => ({ @@ -260,6 +261,11 @@ describe('AppearanceMarketDialog', () => { }); await vi.waitFor(() => expect(container.textContent).toContain('Tokyo Night')); expect(container.querySelector('[data-testid="shared-market-account-controls"]')).not.toBeNull(); + expect(container.querySelector('h2')?.textContent).toBe('package.market.title'); + const accountControls = container.querySelector('[data-testid="shared-market-account-controls"]')!; + const headerActions = accountControls.closest('[data-openbitfun-part="header-actions"]'); + expect(headerActions).not.toBeNull(); + expect(headerActions?.querySelector('button[aria-label="Close"]')).not.toBeNull(); expect(container.textContent).toContain('package.market.updateAvailable'); const listingButton = [...container.querySelectorAll('button')] diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx index de67d80744..204f078789 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx @@ -9,6 +9,7 @@ import { OverflowText, DialogBody, DialogClose, DialogHeader, + DialogHeaderActions, DialogHeading, DialogTitle, } from '@openbitfun/ui'; @@ -457,11 +458,14 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial size="xl" data-testid="appearance-market-dialog" > - + - {t('package.market.title')}{} + {t('package.market.title')} - + + + +
diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss b/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss index ed9c6e706d..c35d4b4593 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss +++ b/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss @@ -338,6 +338,10 @@ } +.appearance-market__dialog-header { + align-items: center; +} + /* Fixed height: every view scrolls internally so the dialog never resizes while loading. */ .appearance-market__modal { min-height: min(720px, 78vh);