diff --git a/docs/architecture/theme-token-optimization.md b/docs/architecture/theme-token-optimization.md index 4aa23c13d4..230eccad30 100644 --- a/docs/architecture/theme-token-optimization.md +++ b/docs/architecture/theme-token-optimization.md @@ -65,6 +65,16 @@ Token 只按职责分四层: 兼容别名不是第五层。它只服务已确认的迁移调用方,必须声明 canonical 目标、owner 和移除条件;新代码不得继续 读取历史别名。 +应用结构层可以使用 `--bf-appearance-token-chrome-*` 这一窄化的 component token 家族,表达导航、标签栏和 +工作台外壳相对于内容面的稳定反相关系。它由 Web UI builtin Appearance 投影拥有,只能在结构层 scope 内重绑定 +普通 semantic token;未声明独立 chrome palette 的内置或导入外观沿用所属 light/dark 基础外观的内容色,不能让 +组件按 Appearance ID 硬编码颜色特例。 + +共享设置页可以使用 `--bf-appearance-token-config-page-*` 这一窄化的 component token 家族,分别表达 section +块面、section 边界、行分隔线与 hover 填充。它只由 `ConfigPageLayout` 消费,默认映射回普通 element/border +semantic token;仅当某个 Appearance 需要让“信息块面”和“表单控件边界”采用不同层级时才覆写,不能扩散成 +通用卡片色板或按 Appearance ID 编写 CSS 特例。 + 新增颜色按以下顺序判断: 1. 语义相同:复用现有 Token。 diff --git a/scripts/theme-color-governance-baseline.json b/scripts/theme-color-governance-baseline.json index b972c7b41e..22c43d546f 100644 --- a/scripts/theme-color-governance-baseline.json +++ b/scripts/theme-color-governance-baseline.json @@ -252,10 +252,10 @@ "max": 0 }, "colorDomainScopes.themePreset.occurrences": { - "max": 158 + "max": 153 }, "colorDomainScopes.themePreset.uniqueColors": { - "max": 112 + "max": 111 }, "colorDomainScopes.themeRuntime.occurrences": { "max": 0 diff --git a/scripts/theme-color-near-pair-decisions.json b/scripts/theme-color-near-pair-decisions.json index 1bc27dee5e..6847755af5 100644 --- a/scripts/theme-color-near-pair-decisions.json +++ b/scripts/theme-color-near-pair-decisions.json @@ -23,10 +23,10 @@ { "root": "src/web-ui/src", "domain": "themePreset", - "key": "#fafafa <-> #ffffff", + "key": "#fdfdfd <-> #ffffff", "decision": "keep", "owner": "src/web-ui/src/infrastructure/appearance/builtins", - "reason": "The default Light primary background intentionally uses the requested off-white surface while secondary, elevated, and scene surfaces remain pure white. These adjacent roles need a restrained but visible hierarchy.", + "reason": "The default Light navigation and app background intentionally uses the sampled near-white surface while secondary, elevated, editor, and scene surfaces remain pure white. These adjacent roles preserve the reference hierarchy without introducing a tinted canvas.", "reevaluateWhen": "Only after a focused Light-theme surface review proves primary and elevated surfaces no longer need separate values." }, { @@ -44,7 +44,7 @@ "key": "#f3f3f5 <-> #faf8f0", "decision": "keep", "owner": "src/web-ui/src/infrastructure/appearance/builtins", - "reason": "China Style paper uses a warm first-paint surface while #f3f3f5 is the default Light editor line highlight and a button state stop. This is cross-theme and renderer identity, not a duplicate role.", + "reason": "China Style paper uses a warm first-paint surface while #f3f3f5 is the default Light soft surface and a dark-theme primary button text stop. This is cross-theme and component identity, not a duplicate role.", "reevaluateWhen": "Only after China Style paper identity is intentionally neutralized." }, { diff --git a/scripts/theme-visual-governance-contract.json b/scripts/theme-visual-governance-contract.json index e27f47d837..436bdec56d 100644 --- a/scripts/theme-visual-governance-contract.json +++ b/scripts/theme-visual-governance-contract.json @@ -7,9 +7,9 @@ "owner": "src/web-ui/src/app/layout; src/web-ui/src/app/components/NavPanel", "platforms": ["desktop-webview", "web"], "formFactors": ["desktop", "narrow"], - "themes": ["dark", "light", "system"], + "themes": ["dark", "light", "system", "bitfun-monochrome"], "states": ["default", "hover", "focus", "selected", "disabled"], - "tokenFamilies": ["--bf-appearance-token-color-bg-*", "--bf-appearance-token-color-text-*", "--bf-appearance-token-element-bg-*", "--bf-appearance-token-border-*", "--bitfun-nav-*"], + "tokenFamilies": ["--bf-appearance-token-color-bg-*", "--bf-appearance-token-color-text-*", "--bf-appearance-token-chrome-*", "--bf-appearance-token-element-bg-*", "--bf-appearance-token-border-*", "--bitfun-nav-*"], "evidence": [ { "type": "theme-color-audit", @@ -31,7 +31,7 @@ "owner": "src/web-ui/src/flow_chat; src/web-ui/src/app/scenes/session", "platforms": ["desktop-webview", "web"], "formFactors": ["desktop", "narrow"], - "themes": ["dark", "light", "system"], + "themes": ["dark", "light", "system", "bitfun-monochrome"], "states": ["default", "streaming", "hover", "focus", "selected", "error", "empty"], "tokenFamilies": ["--bf-appearance-token-flowchat-*", "--bf-appearance-token-tool-card-*", "--bf-appearance-token-color-bg-scene", "--bf-appearance-token-color-text-*", "--bf-appearance-token-color-accent-*"], "evidence": [ @@ -175,9 +175,9 @@ "owner": "src/web-ui/src/app/scenes/settings; src/web-ui/src/infrastructure/appearance", "platforms": ["desktop-webview", "web"], "formFactors": ["desktop", "narrow"], - "themes": ["dark", "light", "system"], + "themes": ["dark", "light", "system", "bitfun-monochrome"], "states": ["default", "selected", "hover", "focus", "imported-appearance", "system-appearance"], - "tokenFamilies": ["--bf-appearance-token-color-accent-*", "--bf-appearance-token-color-bg-*", "--bf-appearance-token-color-text-*"], + "tokenFamilies": ["--bf-appearance-token-color-accent-*", "--bf-appearance-token-color-bg-*", "--bf-appearance-token-color-text-*", "--bf-appearance-token-border-*", "--bf-appearance-token-element-bg-*", "--bf-appearance-token-config-page-*"], "evidence": [ { "type": "theme-color-audit", diff --git a/scripts/validate-theme-visual-contract.mjs b/scripts/validate-theme-visual-contract.mjs index d790165d5e..5352a2b001 100644 --- a/scripts/validate-theme-visual-contract.mjs +++ b/scripts/validate-theme-visual-contract.mjs @@ -21,6 +21,7 @@ const ALLOWED_THEMES = new Set([ 'system', 'bitfun-dark', 'bitfun-light', + 'bitfun-monochrome', 'bitfun-midnight', 'bitfun-china-style', 'bitfun-china-night', diff --git a/src/apps/desktop/src/generated/startup_appearance_bootstrap.json b/src/apps/desktop/src/generated/startup_appearance_bootstrap.json index 578f6a2c6f..b83647e0ca 100644 --- a/src/apps/desktop/src/generated/startup_appearance_bootstrap.json +++ b/src/apps/desktop/src/generated/startup_appearance_bootstrap.json @@ -5,13 +5,23 @@ "appearances": [ { "id": "bitfun-light", - "bgPrimary": "#fafafa", + "bgPrimary": "#fdfdfd", "bgSecondary": "#ffffff", "bgScene": "#ffffff", "isLight": true, - "textPrimary": "#1e293b", - "textMuted": "#64748b", - "accentColor": "#64748b" + "textPrimary": "#1c1c1f", + "textMuted": "#6a6a6a", + "accentColor": "#101a27" + }, + { + "id": "bitfun-monochrome", + "bgPrimary": "#1c1c1f", + "bgSecondary": "#262626", + "bgScene": "#ffffff", + "isLight": true, + "textPrimary": "#f3f3f5", + "textMuted": "#9a9a9a", + "accentColor": "#f3f3f5" }, { "id": "bitfun-slate", diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/generated/appearance_prompt_snapshots.json b/src/crates/assembly/core/src/agentic/tools/implementations/generated/appearance_prompt_snapshots.json index f2cbfa7f6a..dedd7825fb 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/generated/appearance_prompt_snapshots.json +++ b/src/crates/assembly/core/src/agentic/tools/implementations/generated/appearance_prompt_snapshots.json @@ -6,19 +6,36 @@ { "id": "bitfun-light", "mode": "light", - "bgPrimary": "#fafafa", + "bgPrimary": "#fdfdfd", "bgSecondary": "#ffffff", "bgScene": "#ffffff", - "textPrimary": "#1e293b", - "textMuted": "#64748b", - "accent500": "#64748b", - "accent600": "#475569", - "borderBase": "rgba(100, 116, 139, 0.22)", - "elementBase": "rgba(15, 23, 42, 0.09)", + "textPrimary": "#1c1c1f", + "textMuted": "#6a6a6a", + "accent500": "#101a27", + "accent600": "#1c1c1f", + "borderBase": "rgba(16, 26, 39, 0.15)", + "elementBase": "rgba(16, 26, 39, 0.09)", + "radiusBase": "8px", + "spacing4": "16px", + "shadowBase": "0 4px 8px rgba(16, 26, 39, 0.07)", + "styleNotes": "Light appearance - Crisp white surfaces, soft neutral grays, deep navy actions" + }, + { + "id": "bitfun-monochrome", + "mode": "light", + "bgPrimary": "#ffffff", + "bgSecondary": "#ffffff", + "bgScene": "#ffffff", + "textPrimary": "#1c1c1f", + "textMuted": "#6a6a6a", + "accent500": "#1c1c1f", + "accent600": "#000000", + "borderBase": "rgba(16, 26, 39, 0.15)", + "elementBase": "rgba(16, 26, 39, 0.09)", "radiusBase": "8px", "spacing4": "16px", - "shadowBase": "0 4px 8px rgba(71, 85, 105, 0.1)", - "styleNotes": "Light appearance - Neutral gray surfaces, black primary actions" + "shadowBase": "0 4px 8px rgba(16, 26, 39, 0.07)", + "styleNotes": "Black-and-white contrast appearance - Deep black chrome, bright white workspace, soft neutral blocks" }, { "id": "bitfun-slate", diff --git a/src/web-ui/index.html b/src/web-ui/index.html index 5232a9baa1..3dabe11a1f 100644 --- a/src/web-ui/index.html +++ b/src/web-ui/index.html @@ -10,7 +10,7 @@ (function () { var dark = '#121214'; /* Keep in sync with infrastructure/appearance/builtins/light.ts colors.background.primary */ - var light = '#fafafa'; + var light = '#fdfdfd'; var m = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)'); var root = document.documentElement; var fallbackMessages = { @@ -85,7 +85,7 @@ } @media (prefers-color-scheme: light) { html { - background-color: #fafafa; + background-color: #fdfdfd; } } diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss index d916f727b4..4796eb154a 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss @@ -12,7 +12,6 @@ padding: 12px 14px; border: 1px dashed var(--bf-appearance-token-border-subtle); border-radius: 8px; - background: transparent; display: flex; flex-direction: column; gap: 12px; @@ -314,7 +313,6 @@ .bitfun-about-dialog__info-card { padding: 10px 14px; - background: transparent; border: 1px dashed var(--bf-appearance-token-border-subtle); border-radius: 8px; } @@ -378,8 +376,7 @@ transform 150ms cubic-bezier(0.23, 1, 0.32, 1), background-color 150ms ease, border-color 150ms ease, - color 150ms ease, - box-shadow 150ms ease; + color 150ms ease; display: flex; align-items: center; @@ -394,205 +391,6 @@ } } -// ==================== Dependencies section ==================== - -.bitfun-about-dialog__dependencies-section { - margin-bottom: 16px; - animation: about-section-enter 0.4s ease-out 0.25s both; -} - -.bitfun-about-dialog__dependencies-toggle { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - background: transparent; - border: 1px dashed var(--bf-appearance-token-border-subtle); - border-radius: 8px; - color: var(--bf-appearance-token-color-text-secondary); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: - transform 180ms cubic-bezier(0.23, 1, 0.32, 1), - background-color 180ms ease, - border-color 180ms ease, - color 180ms ease, - box-shadow 180ms ease; - - &:hover { - background: var(--bf-appearance-token-element-bg-subtle); - border-color: var(--bf-appearance-token-border-base); - border-style: solid; - color: var(--bf-appearance-token-color-text-primary); - - svg { - color: var(--bf-appearance-token-color-accent-500); - } - } - - span { - display: flex; - align-items: center; - gap: 8px; - } - - svg { - color: var(--bf-appearance-token-color-text-muted); - transition: color 0.2s ease; - } -} - -.bitfun-about-dialog__dependencies-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 20px; - height: 18px; - padding: 0 6px; - background: color-mix(in srgb, var(--bf-appearance-token-color-accent-500) 10%, transparent); - color: var(--bf-appearance-token-color-accent-400); - font-size: 10px; - font-weight: 600; - border-radius: 9px; -} - -.bitfun-about-dialog__dependencies-list { - margin-top: 8px; - padding: 4px; - background: transparent; - border: 1px solid var(--bf-appearance-token-border-subtle); - border-radius: 8px; - animation: about-list-expand 0.25s ease-out; -} - -@keyframes about-list-expand { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.bitfun-about-dialog__dependencies-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 4px; -} - -.bitfun-about-dialog__dependency-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - background: transparent; - border: 1px solid transparent; - border-radius: 6px; - transition: - transform 150ms cubic-bezier(0.23, 1, 0.32, 1), - background-color 150ms ease, - border-color 150ms ease, - color 150ms ease; - min-width: 0; - - &:hover { - background: var(--bf-appearance-token-element-bg-subtle); - border-color: var(--bf-appearance-token-border-subtle); - - .bitfun-about-dialog__dependency-icon { - background: color-mix(in srgb, var(--bf-appearance-token-color-accent-500) 10%, transparent); - - svg { - color: var(--bf-appearance-token-color-accent-500); - } - } - } -} - -.bitfun-about-dialog__dependency-icon { - flex-shrink: 0; - width: 26px; - height: 26px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bf-appearance-token-element-bg-subtle); - border-radius: 6px; - transition: - transform 150ms cubic-bezier(0.23, 1, 0.32, 1), - background-color 150ms ease, - border-color 150ms ease, - color 150ms ease; - - svg { - width: 12px; - height: 12px; - color: var(--bf-appearance-token-color-text-secondary); - transition: color 0.2s ease; - } -} - -.bitfun-about-dialog__dependency-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 1px; -} - -.bitfun-about-dialog__dependency-name { - font-size: 11px; - font-weight: 500; - color: var(--bf-appearance-token-color-text-primary); - font-family: 'Consolas', 'Monaco', monospace; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.bitfun-about-dialog__dependency-license { - font-size: 9px; - color: var(--bf-appearance-token-color-text-muted); -} - -.bitfun-about-dialog__dependency-tag { - display: inline-flex; - align-items: center; - padding: 1px 5px; - font-size: 8px; - font-weight: 600; - border-radius: 3px; - text-transform: uppercase; - letter-spacing: 0.3px; - margin-left: auto; - flex-shrink: 0; - - // Frontend tag - blue - &--frontend { - background: var(--bf-appearance-token-color-accent-100); - color: var(--bf-appearance-token-color-accent-500); - border: 1px solid var(--bf-appearance-token-color-accent-200); - } - - // Backend tag - green - &--backend { - background: var(--bf-appearance-token-color-success-bg); - color: var(--bf-appearance-token-color-success); - border: 1px solid var(--bf-appearance-token-color-success-border); - } - - // General tag - purple - &--general { - background: var(--bf-appearance-token-color-purple-100); - color: var(--bf-appearance-token-color-purple-500); - border: 1px solid var(--bf-appearance-token-color-purple-200); - } -} - // ==================== Footer ==================== .bitfun-about-dialog__footer { @@ -648,10 +446,6 @@ padding: 0 16px; } - .bitfun-about-dialog__dependencies-grid { - grid-template-columns: 1fr; - } - .bitfun-about-dialog__footer { padding: 14px 16px 16px; diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx index 1da64775c2..3d61323222 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx @@ -148,6 +148,7 @@ export const AboutDialog: React.FC = ({ title={t('header.about')} showCloseButton={true} size="medium" + testId="about-dialog-modal" >
svg { + color: var(--bf-appearance-token-color-bg-scene); + stroke: currentColor; + } } &:focus-visible { @@ -2985,15 +2975,12 @@ $_section-header-height: 24px; &__session-filter-menu, &__session-filter-submenu { @include nav-font.nav-panel-font-token-scope; + @include surfaces.floating-surface; position: fixed; z-index: $z-popover; box-sizing: border-box; padding: $size-gap-1; - border: 1px solid var(--bf-appearance-token-border-medium); - border-radius: $size-radius-lg; - background: var(--bf-appearance-token-color-bg-elevated); - box-shadow: 0 14px 42px -12px var(--bf-appearance-token-color-overlay-black-30); color: var(--bf-appearance-token-color-text-primary); animation: bitfun-nav-popover-in 150ms cubic-bezier(0.23, 1, 0.32, 1) both; @@ -3128,7 +3115,7 @@ $_section-header-height: 24px; height: 32px; padding-inline: $size-gap-2; border-radius: $size-radius-sm; - font-weight: 500; + font-weight: 400; transition: transform 120ms cubic-bezier(0.23, 1, 0.32, 1), background 120ms ease, @@ -3140,7 +3127,9 @@ $_section-header-height: 24px; } &.is-active { - background: var(--bf-appearance-token-element-bg-medium); + color: var(--bf-appearance-token-color-text-primary); + background: var(--bf-appearance-token-element-bg-soft); + font-weight: 600; } &:not(.bitfun-nav-panel__top-action-btn--sub):hover .bitfun-nav-panel__top-action-icon-slot { @@ -3258,8 +3247,8 @@ $_section-header-height: 24px; } &.is-active { - background: var(--bf-appearance-token-element-bg-medium); - font-weight: 500; + background: var(--bf-appearance-token-element-bg-soft); + font-weight: 600; &::before { left: 3px; @@ -3306,9 +3295,13 @@ $_section-header-height: 24px; } } + &__pages-entry.is-active, + &__assistant-entry.is-active, &__miniapp-entry.is-active { - border-color: var(--bf-appearance-token-border-subtle); - background: var(--bf-appearance-token-element-bg-medium); + border-color: transparent; + color: var(--bf-appearance-token-color-text-primary); + background: var(--bf-appearance-token-element-bg-soft); + font-weight: 600; } &__miniapp-bubble { @@ -3395,9 +3388,6 @@ $_section-header-height: 24px; .bitfun-nav-panel__mode-dropdown, .bitfun-nav-panel__footer-menu, .bitfun-nav-panel__footer-multimodal-menu { - border-color: var(--bf-appearance-token-border-medium); - border-radius: $size-radius-lg; - box-shadow: 0 14px 42px -12px var(--bf-appearance-token-color-overlay-black-30); animation: bitfun-nav-popover-in 150ms cubic-bezier(0.23, 1, 0.32, 1) both; &.is-closing { @@ -3522,6 +3512,7 @@ $_section-header-height: 24px; height: 34px; padding: 0 $size-gap-2; border: 0; + font-family: inherit; font-size: var(--bf-appearance-token-font-size-sm); font-weight: 400; line-height: 1.25; @@ -3591,7 +3582,9 @@ $_section-header-height: 24px; &__miniapp-entry { @include nav-font.nav-panel-text-body; + font-family: inherit; font-size: var(--bf-appearance-token-font-size-sm); + font-weight: 400; line-height: 1.25; } @@ -3613,7 +3606,7 @@ $_section-header-height: 24px; &__assistant-entry-label, &__miniapp-entry-title { font-size: var(--bf-appearance-token-font-size-sm); - font-weight: 500; + font-weight: inherit; line-height: 1.25; } diff --git a/src/web-ui/src/app/components/NavPanel/components/AssistantSessionCreateMenu.test.tsx b/src/web-ui/src/app/components/NavPanel/components/AssistantSessionCreateMenu.test.tsx index 5287ce7364..a6a85d4e30 100644 --- a/src/web-ui/src/app/components/NavPanel/components/AssistantSessionCreateMenu.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/AssistantSessionCreateMenu.test.tsx @@ -88,12 +88,12 @@ describe('AssistantSessionCreateMenu', () => { expect(onCreateAssistant).not.toHaveBeenCalled(); }); - it('lists the primary assistant first and creates a session for the selected assistant', () => { + it('lists the primary assistant first without a badge and creates a session for the selected assistant', () => { const { onCreateAssistant } = renderMenu(); click('nav-assistant-session-menu-toggle'); const items = [...document.querySelectorAll('[role="menuitem"]')]; - expect(items.map(item => item.textContent)).toEqual(['MiraPrimary', 'Sage']); + expect(items.map(item => item.textContent)).toEqual(['Mira', 'Sage']); click('nav-assistant-session-menu-item-secondary'); expect(onCreateAssistant).toHaveBeenCalledWith(secondaryAssistant); diff --git a/src/web-ui/src/app/components/NavPanel/components/AssistantSessionCreateMenu.tsx b/src/web-ui/src/app/components/NavPanel/components/AssistantSessionCreateMenu.tsx index c75a5a4bb7..dc268d5419 100644 --- a/src/web-ui/src/app/components/NavPanel/components/AssistantSessionCreateMenu.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/AssistantSessionCreateMenu.tsx @@ -137,7 +137,6 @@ const AssistantSessionCreateMenu: React.FC = ({ > {orderedAssistants.map(workspace => { const assistantName = getAssistantDisplayName(workspace); - const isPrimary = workspace.id === primaryAssistant?.id; return ( ); })} diff --git a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss index b7cc189278..3294ebdc9d 100644 --- a/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss +++ b/src/web-ui/src/app/components/NavPanel/components/BranchQuickSwitch.scss @@ -4,14 +4,13 @@ */ @use '../../../../component-library/styles/tokens' as *; +@use '../../../../component-library/styles/overlay-surfaces' as surfaces; .branch-quick-switch { + @include surfaces.floating-surface; + position: fixed; z-index: $z-popover; - background: var(--bf-appearance-token-color-bg-primary); - border: 1px solid var(--bf-appearance-token-border-subtle); - border-radius: 8px; - box-shadow: 0 8px 32px var(--bf-appearance-token-color-overlay-black-30); overflow: hidden; } diff --git a/src/web-ui/src/app/components/NavPanel/components/MiniAppEntry.tsx b/src/web-ui/src/app/components/NavPanel/components/MiniAppEntry.tsx index 5d61479466..00df8f86ef 100644 --- a/src/web-ui/src/app/components/NavPanel/components/MiniAppEntry.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/MiniAppEntry.tsx @@ -70,6 +70,7 @@ const MiniAppEntry: React.FC = ({ role="button" tabIndex={0} aria-label={t('scenes.miniApps')} + data-testid="nav-miniapps-entry" >
, getAppearanceOverlayHost(), )} diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss index 44d349fc7a..d6e5d082a0 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss @@ -3,6 +3,7 @@ */ @use '../../../component-library/styles/tokens.scss' as tokens; +@use '../../typography' as flow-type; .user-message-item { box-sizing: border-box; @@ -22,7 +23,7 @@ */ width: auto; padding: 0.46rem 0.68rem; - background: color-mix(in srgb, var(--bf-appearance-token-element-bg-medium) 90%, var(--bf-appearance-token-color-bg-scene)); + background: var(--bf-appearance-token-element-bg-soft); border: 1px solid color-mix(in srgb, var(--bf-appearance-token-border-base) 72%, transparent); border-radius: calc(var(--bf-appearance-token-flowchat-card-radius) + 2px); margin: @@ -42,7 +43,7 @@ position: relative; &:hover { - background: color-mix(in srgb, var(--bf-appearance-token-element-bg-medium) 92%, var(--bf-appearance-token-color-bg-scene)); + background: var(--bf-appearance-token-element-bg-soft); border-color: color-mix(in srgb, var(--bf-appearance-token-border-subtle) 56%, transparent); .user-message-item__copy-btn { @@ -76,7 +77,7 @@ } .user-message-item__timestamp { - font-size: var(--bf-appearance-token-flowchat-font-size-xxs); + font-size: flow-type.$meta-size; color: var(--bf-appearance-token-color-text-muted); font-weight: 400; margin-bottom: 0.12rem; @@ -101,7 +102,7 @@ width: fit-content; padding: 0.12rem 0.44rem; border-radius: 999px; - font-size: var(--bf-appearance-token-flowchat-font-size-xxs); + font-size: flow-type.$micro-size; font-weight: 600; line-height: var(--bf-appearance-token-flowchat-compact-line-height); letter-spacing: 0; @@ -139,9 +140,9 @@ .user-message-item__content { flex: 1; - font-size: var(--bf-appearance-token-flowchat-font-size-base); + font-size: flow-type.$body-size; font-weight: 500; - line-height: var(--bf-appearance-token-flowchat-text-line-height); + line-height: flow-type.$body-line-height; color: var(--bf-appearance-token-color-text-primary); white-space: pre-wrap; display: -webkit-box; @@ -169,7 +170,7 @@ border-radius: var(--bf-appearance-token-size-radius-sm); background: var(--bf-appearance-token-element-bg-hover); color: var(--bf-appearance-token-color-text-secondary); - font-size: calc(var(--bf-appearance-token-flowchat-font-size-base) * 0.9); + font-size: flow-type.$control-size; font-weight: 500; line-height: 1.35; white-space: nowrap; @@ -198,7 +199,7 @@ // Failed state: no bubble. Icons/actions align to the first text line (not flex baseline: // `-webkit-box` line-clamp does not expose a reliable baseline for `align-items: baseline`). .user-message-item--failed { - --user-message-failed-font-size: calc(var(--bf-appearance-token-flowchat-font-size-base) * 0.86); + --user-message-failed-font-size: #{flow-type.$control-size}; --user-message-failed-line-height: 1.55; --user-message-failed-line-box: calc( var(--user-message-failed-font-size) * var(--user-message-failed-line-height) @@ -369,7 +370,7 @@ .bitfun-textarea__field { min-height: 5.5rem; max-height: 45vh; - font-size: var(--bf-appearance-token-flowchat-font-size-base); + font-size: flow-type.$body-size; line-height: var(--bf-appearance-token-flowchat-text-line-height); resize: vertical; } @@ -391,7 +392,7 @@ border: none; background: transparent; box-shadow: none; - font-size: var(--bf-appearance-token-flowchat-font-size-base); + font-size: flow-type.$body-size; line-height: var(--bf-appearance-token-flowchat-text-line-height); } diff --git a/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.scss b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.scss index d7dd3ac550..aa9f34793a 100644 --- a/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.scss +++ b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.scss @@ -4,6 +4,7 @@ */ @use '../../../component-library/styles/tokens' as tokens; +@use '../../../component-library/styles/overlay-surfaces' as surfaces; $session-menu-timing: cubic-bezier(0.4, 0, 0.2, 1); @@ -53,6 +54,8 @@ $session-menu-timing: cubic-bezier(0.4, 0, 0.2, 1); } .bitfun-session-menu__dropdown { + @include surfaces.floating-surface; + --session-menu-entry-y: -4px; position: fixed; box-sizing: border-box; @@ -61,10 +64,6 @@ $session-menu-timing: cubic-bezier(0.4, 0, 0.2, 1); display: flex; flex-direction: column; overflow: hidden; - background: var(--bf-appearance-token-color-bg-primary); - border: 1px solid var(--bf-appearance-token-border-medium); - border-radius: var(--bf-appearance-token-size-radius-base); - box-shadow: var(--bf-appearance-token-shadow-xl); z-index: tokens.$z-popover; animation: session-menu-appear 0.2s $session-menu-timing; transform-origin: top left; diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss index f519e0bc40..fc808e2a1e 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss @@ -4,6 +4,7 @@ */ @use '../../../component-library/styles/tokens.scss' as *; +@use '../../../component-library/styles/overlay-surfaces' as surfaces; $transition-duration: 0.25s; $transition-timing: cubic-bezier(0.4, 0, 0.2, 1); @@ -246,6 +247,8 @@ $transition-timing: cubic-bezier(0.4, 0, 0.2, 1); } .bitfun-toolbar-mode__session-dropdown { + @include surfaces.floating-surface; + position: absolute; top: 100%; left: 0; @@ -256,10 +259,6 @@ $transition-timing: cubic-bezier(0.4, 0, 0.2, 1); display: flex; flex-direction: column; overflow: hidden; - background: var(--bf-appearance-token-color-bg-primary); - border: 1px solid var(--bf-appearance-token-border-medium); - border-radius: var(--bf-appearance-token-size-radius-base); - box-shadow: var(--bf-appearance-token-shadow-xl); z-index: 100; animation: dropdown-appear 0.2s $transition-timing; transform-origin: top left; @@ -372,16 +371,14 @@ $transition-timing: cubic-bezier(0.4, 0, 0.2, 1); } .bitfun-toolbar-mode__overflow-menu { + @include surfaces.floating-surface; + position: fixed; min-width: 180px; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow-y: auto; padding: 4px; - background: var(--bf-appearance-token-color-bg-primary); - border: 1px solid var(--bf-appearance-token-border-medium); - border-radius: var(--bf-appearance-token-size-radius-base); - box-shadow: var(--bf-appearance-token-shadow-xl); z-index: $z-popover; color: var(--bf-appearance-token-color-text-primary); font-family: var(--bf-appearance-token-font-family-sans); diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageModal.scss b/src/web-ui/src/flow_chat/components/usage/SessionUsageModal.scss index 78405a6f3b..4975b88789 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageModal.scss +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageModal.scss @@ -20,12 +20,6 @@ max-width: 720px; height: min(520px, calc(100vh - 24px)); max-height: calc(100vh - 24px); - border-color: color-mix(in srgb, var(--bf-appearance-token-border-base) 70%, transparent); - border-radius: 16px; - background: var(--bf-appearance-token-color-bg-secondary); - box-shadow: - 0 18px 48px var(--bf-appearance-token-color-overlay-black-20), - 0 2px 10px var(--bf-appearance-token-color-overlay-black-08); } > .modal.modal--xlarge > .modal__header-shell > .modal__header { @@ -77,7 +71,6 @@ > .modal.modal--xlarge { height: calc(100vh - 20px); max-height: calc(100vh - 20px); - border-radius: 14px; } > .modal.modal--xlarge > .modal__header-shell > .modal__header { @@ -102,7 +95,6 @@ > .modal.modal--xlarge { height: calc(100vh - 16px); max-height: calc(100vh - 16px); - border-radius: 12px; } > .modal.modal--xlarge > .modal__header-shell > .modal__header { diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss index 90a17f296f..669a2a595c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ToolTimeoutIndicator.scss @@ -1,4 +1,5 @@ @use '../../component-library/styles/tokens' as tokens; +@use '../../component-library/styles/overlay-surfaces' as surfaces; .tool-timeout-indicator { display: inline-flex; @@ -133,6 +134,8 @@ } .timeout-extend-popover { + @include surfaces.floating-surface; + position: fixed; z-index: tokens.$z-popover; box-sizing: border-box; @@ -141,10 +144,6 @@ max-height: calc(100vh - 16px); overflow-y: auto; padding: var(--bf-appearance-token-flowchat-inline-gap) 0; - border-radius: 6px; - background: var(--bf-appearance-token-color-bg-elevated); - border: 1px solid var(--bf-appearance-token-border-base); - box-shadow: 0 4px 12px var(--bf-appearance-token-color-overlay-black-15); font-family: var(--bf-appearance-token-font-family-sans); } diff --git a/src/web-ui/src/flow_chat/utils/chatInputHarnessPolicy.test.ts b/src/web-ui/src/flow_chat/utils/chatInputHarnessPolicy.test.ts index 13ef3a9f07..f764fc5878 100644 --- a/src/web-ui/src/flow_chat/utils/chatInputHarnessPolicy.test.ts +++ b/src/web-ui/src/flow_chat/utils/chatInputHarnessPolicy.test.ts @@ -30,6 +30,21 @@ describe('chatInputHarnessPolicy', () => { expect(resolvePendingHarnessProfileForCreation(policy, 'minimal')).toBeNull(); }); + it('hides Harness configuration for a Claw Assistant session when workspace state is stale', () => { + const policy = resolveChatInputHarnessProfilePolicy({ + isAssistantWorkspace: false, + sessionMode: ' claw ', + isAcpTargetSession: false, + isSubagentInputTarget: false, + }); + + expect(policy).toEqual({ + owner: 'assistant-runtime-default', + userConfigurable: false, + }); + expect(resolvePendingHarnessProfileForCreation(policy, 'balanced')).toBeNull(); + }); + it.each([ { label: 'ACP target', diff --git a/src/web-ui/src/flow_chat/utils/chatInputHarnessPolicy.ts b/src/web-ui/src/flow_chat/utils/chatInputHarnessPolicy.ts index b3ed40fafd..744e37b702 100644 --- a/src/web-ui/src/flow_chat/utils/chatInputHarnessPolicy.ts +++ b/src/web-ui/src/flow_chat/utils/chatInputHarnessPolicy.ts @@ -22,6 +22,7 @@ export type ChatInputHarnessProfilePolicy = */ export function resolveChatInputHarnessProfilePolicy(params: { isAssistantWorkspace: boolean; + sessionMode?: string | null; isAcpTargetSession: boolean; isSubagentInputTarget: boolean; }): ChatInputHarnessProfilePolicy { @@ -33,7 +34,8 @@ export function resolveChatInputHarnessProfilePolicy(params: { return { owner: 'parent-session', userConfigurable: false }; } - if (params.isAssistantWorkspace) { + const isAssistantSession = params.sessionMode?.trim().toLowerCase() === 'claw'; + if (params.isAssistantWorkspace || isAssistantSession) { return { owner: 'assistant-runtime-default', userConfigurable: false }; } diff --git a/src/web-ui/src/infrastructure/appearance/builtins/AppearancePalette.ts b/src/web-ui/src/infrastructure/appearance/builtins/AppearancePalette.ts index 9455b324e3..afe568ae43 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/AppearancePalette.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/AppearancePalette.ts @@ -77,6 +77,19 @@ export interface ScrollbarColors { thumbHover: ColorValue; } +/** + * Structural application chrome can deliberately contrast with the content + * palette. Builtins that omit this slice keep today's single-surface behavior. + */ +export interface ChromeColors { + background: BackgroundColors; + text: TextColors; + accent: AccentColors; + border: BorderColors; + element: ElementBackgrounds; + scrollbar?: ScrollbarColors; +} + export interface ShadowConfig { xs: string; sm: string; @@ -130,6 +143,22 @@ export interface ButtonConfig { }; } +/** + * Shared settings pages need a stable surface role that can vary independently + * from form controls. In particular, a filled section card must not force every + * input, divider, and focus ring to inherit the same treatment. + */ +export interface ConfigPageConfig { + section: { + background: ColorValue; + border: ColorValue; + borderWidth: string; + shadow: string; + }; + divider: ColorValue; + rowHover: ColorValue; +} + export interface MotionConfig { instant: string; fast: string; @@ -211,6 +240,7 @@ export interface AppearancePalette { element: ElementBackgrounds; git: GitColors; scrollbar?: ScrollbarColors; + chrome?: ChromeColors; }; effects: { shadow: ShadowConfig; @@ -229,7 +259,10 @@ export interface AppearancePalette { size: FontSizeConfig; lineHeight: LineHeightConfig; }; - components?: { button?: ButtonConfig }; + components?: { + button?: ButtonConfig; + configPage?: ConfigPageConfig; + }; monaco?: MonacoAppearancePaletteConfig; layout?: { sceneViewportBorder?: boolean }; } diff --git a/src/web-ui/src/infrastructure/appearance/builtins/appearancePresetOutput.test.ts b/src/web-ui/src/infrastructure/appearance/builtins/appearancePresetOutput.test.ts index 8f6ceb82dc..78fbd1eded 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/appearancePresetOutput.test.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/appearancePresetOutput.test.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { builtinAppearancePalettes } from './palettes'; +import { getBuiltinAppearanceCssTokens } from './catalog'; import { PLUGIN_APPEARANCE_COLOR_KEYS, createPluginAppearanceColorProjection, @@ -112,14 +113,172 @@ describe('builtin appearance preset output', () => { const serializedAppearances = JSON.stringify(builtinAppearancePalettes).toLowerCase(); const lightAppearance = builtinAppearancePalettes.find(appearance => appearance.id === 'bitfun-light'); - expect(lightAppearance?.colors.background.primary).toBe('#fafafa'); - expect(lightAppearance?.monaco?.colors.background).toBe('#fafafa'); - expect(lightAppearance?.monaco?.colors.lineHighlight).toBe('#f3f3f5'); - expect(serializedAppearances.match(/#fafafa/g)).toHaveLength(2); + expect(lightAppearance?.colors.background.primary).toBe('#fdfdfd'); + expect(lightAppearance?.monaco?.colors.background).toBe('#ffffff'); + expect(lightAppearance?.monaco?.colors.lineHighlight).toBe('rgba(16, 26, 39, 0.03)'); + expect(serializedAppearances.match(/#fdfdfd/g)).toHaveLength(1); expect(serializedAppearances).not.toContain('#e2e6eb'); expect(serializedAppearances).not.toContain('#f0f2f5'); }); + it('keeps the default light appearance on the neutral, navy, and restrained semantic palette', () => { + const lightAppearance = builtinAppearancePalettes.find(appearance => appearance.id === 'bitfun-light'); + const tokens = getBuiltinAppearanceCssTokens('bitfun-light'); + + expect(lightAppearance).toMatchObject({ + description: 'Light appearance - Crisp white surfaces, soft neutral grays, deep navy actions', + version: '2.4.0', + colors: { + background: { + primary: '#fdfdfd', + secondary: '#ffffff', + tertiary: '#f3f3f5', + elevated: '#ffffff', + workbench: '#f3f3f5', + scene: '#ffffff', + }, + text: { + primary: '#1c1c1f', + secondary: '#555555', + muted: '#6a6a6a', + disabled: '#9a9a9a', + }, + accent: { + 50: 'rgba(16, 26, 39, 0.03)', + 100: '#f3f3f5', + 500: '#101a27', + 600: '#1c1c1f', + 700: '#000000', + }, + semantic: { + success: '#247344', + successBg: '#e1fbe9', + successBorder: '#247344', + error: '#a74352', + errorBg: 'rgba(167, 67, 82, 0.12)', + info: '#555555', + infoBg: '#f3f3f5', + infoBorder: 'rgba(16, 26, 39, 0.15)', + }, + border: { + base: 'rgba(16, 26, 39, 0.15)', + }, + element: { + subtle: 'rgba(16, 26, 39, 0.03)', + soft: '#f3f3f5', + }, + }, + components: { + button: { + primary: { + default: { background: '#101a27', color: '#ffffff' }, + hover: { background: '#1c1c1f', color: '#ffffff' }, + active: { background: '#000000', color: '#ffffff' }, + }, + }, + }, + monaco: { + colors: { + background: '#ffffff', + lineHighlight: 'rgba(16, 26, 39, 0.03)', + }, + }, + }); + expect(tokens).toMatchObject({ + '--bf-appearance-token-config-page-section-bg': 'rgba(16, 26, 39, 0.03)', + '--bf-appearance-token-config-page-section-border': 'rgba(16, 26, 39, 0.08)', + '--bf-appearance-token-config-page-section-border-width': '1px', + '--bf-appearance-token-config-page-divider': 'rgba(16, 26, 39, 0.08)', + }); + }); + + it('keeps monochrome content readable while projecting inverse structural chrome', () => { + const monochrome = builtinAppearancePalettes.find( + appearance => appearance.id === 'bitfun-monochrome', + ); + const tokens = getBuiltinAppearanceCssTokens('bitfun-monochrome'); + + expect(monochrome).toMatchObject({ + type: 'light', + description: 'Black-and-white contrast appearance - Deep black chrome, bright white workspace, soft neutral blocks', + colors: { + background: { + primary: '#ffffff', + scene: '#ffffff', + }, + text: { + primary: '#1c1c1f', + secondary: '#555555', + muted: '#6a6a6a', + }, + border: { + subtle: 'rgba(16, 26, 39, 0.08)', + base: 'rgba(16, 26, 39, 0.15)', + prominent: 'rgba(16, 26, 39, 0.48)', + }, + element: { + subtle: 'rgba(16, 26, 39, 0.03)', + soft: '#f3f3f5', + strong: 'rgba(16, 26, 39, 0.18)', + }, + accent: { + 500: '#1c1c1f', + 600: '#000000', + }, + chrome: { + background: { + primary: '#1c1c1f', + secondary: '#262626', + }, + text: { + primary: '#f3f3f5', + secondary: '#b0b0b0', + }, + accent: { + 500: '#f3f3f5', + 600: '#ffffff', + }, + }, + }, + components: { + button: { + primary: { + default: { background: '#1c1c1f', color: '#ffffff' }, + hover: { background: '#000000', color: '#ffffff' }, + }, + }, + configPage: { + section: { + background: '#f3f3f5', + border: 'transparent', + borderWidth: '0', + shadow: 'none', + }, + divider: 'rgba(16, 26, 39, 0.08)', + rowHover: 'rgba(16, 26, 39, 0.03)', + }, + }, + }); + expect(tokens).toMatchObject({ + '--bf-appearance-token-color-bg-primary': '#ffffff', + '--bf-appearance-token-color-text-primary': '#1c1c1f', + '--bf-appearance-token-color-text-secondary': '#555555', + '--bf-appearance-token-border-subtle': 'rgba(16, 26, 39, 0.08)', + '--bf-appearance-token-border-base': 'rgba(16, 26, 39, 0.15)', + '--bf-appearance-token-element-bg-subtle': 'rgba(16, 26, 39, 0.03)', + '--bf-appearance-token-element-bg-soft': '#f3f3f5', + '--bf-appearance-token-scrollbar-thumb': 'rgba(16, 26, 39, 0.15)', + '--bf-appearance-token-config-page-section-bg': '#f3f3f5', + '--bf-appearance-token-config-page-section-border': 'transparent', + '--bf-appearance-token-config-page-section-border-width': '0', + '--bf-appearance-token-config-page-section-shadow': 'none', + '--bf-appearance-token-config-page-divider': 'rgba(16, 26, 39, 0.08)', + '--bf-appearance-token-chrome-bg-primary': '#1c1c1f', + '--bf-appearance-token-chrome-text-primary': '#f3f3f5', + '--bf-appearance-token-chrome-element-bg-soft': 'rgba(255, 255, 255, 0.06)', + }); + }); + it('projects builtin appearances to a compact OpenCode-compatible plugin color key set', () => { expect(PLUGIN_APPEARANCE_COLOR_KEYS).toEqual([ 'primary', @@ -153,10 +312,15 @@ describe('builtin appearance preset output', () => { }))).toMatchInlineSnapshot(` [ { - "hash": "1d420a9266664694a5668328d2df91309bc263098ad5e7d5a07c106cad760cc3", + "hash": "e9bcb4147d483930dae32099a5af094e494e7dbd5d970db3bb1e7e13cf43551f", "id": "bitfun-light", "type": "light", }, + { + "hash": "f14b9ef8e30626fc271d6ee54d9b8a47f4afbb2a8fa83f245eca92823b1c4aa2", + "id": "bitfun-monochrome", + "type": "light", + }, { "hash": "89abef6e2224b6fb86ac8bb34578c0d7ef725172ec1f29b29818038288b5dae0", "id": "bitfun-slate", diff --git a/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts b/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts index 595af79dcc..8ad95ea145 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts @@ -14,6 +14,8 @@ import { type WidgetAppearanceVariableName, } from '../adapters/widgetAppearanceVariables'; +const OVERLAY_WHITE_04 = 'rgba(255, 255, 255, 0.04)'; + const ref = (path: string): AppearanceReference => ({ kind: 'ref', path }); const colorRef = (id: string): AppearanceReference => ref(`globals.colors.${id}`); const lengthRef = (id: string): AppearanceReference => ref(`globals.lengths.${id}`); @@ -97,6 +99,20 @@ function createCssTokens(palette: AppearancePalette): Record { 500: colors.accent[500], 600: colors.accent[600], }; + const scrollbar = colors.scrollbar ?? { + thumb: palette.type === 'dark' ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.15)', + thumbHover: palette.type === 'dark' ? 'rgba(255, 255, 255, 0.24)' : 'rgba(0, 0, 0, 0.3)', + }; + const chrome = colors.chrome ?? { + background: colors.background, + text: colors.text, + accent: colors.accent, + border: colors.border, + element: colors.element, + scrollbar, + }; + const chromeScrollbar = chrome.scrollbar ?? scrollbar; + const configPage = palette.components?.configPage; const tokens: Record = { '--bf-appearance-token-color-bg-primary': colors.background.primary, '--bf-appearance-token-color-bg-secondary': colors.background.secondary, @@ -104,6 +120,40 @@ function createCssTokens(palette: AppearancePalette): Record { '--bf-appearance-token-color-bg-elevated': colors.background.elevated, '--bf-appearance-token-color-bg-workbench': colors.background.workbench, '--bf-appearance-token-color-bg-scene': colors.background.scene, + '--bf-appearance-token-chrome-bg-primary': chrome.background.primary, + '--bf-appearance-token-chrome-bg-secondary': chrome.background.secondary, + '--bf-appearance-token-chrome-bg-tertiary': chrome.background.tertiary, + '--bf-appearance-token-chrome-bg-elevated': chrome.background.elevated, + '--bf-appearance-token-chrome-bg-workbench': chrome.background.workbench, + '--bf-appearance-token-chrome-bg-scene': chrome.background.scene, + '--bf-appearance-token-chrome-text-primary': chrome.text.primary, + '--bf-appearance-token-chrome-text-secondary': chrome.text.secondary, + '--bf-appearance-token-chrome-text-muted': chrome.text.muted, + '--bf-appearance-token-chrome-text-disabled': chrome.text.disabled, + '--bf-appearance-token-chrome-accent-50': chrome.accent[50], + '--bf-appearance-token-chrome-accent-100': chrome.accent[100], + '--bf-appearance-token-chrome-accent-200': chrome.accent[200], + '--bf-appearance-token-chrome-accent-300': chrome.accent[300], + '--bf-appearance-token-chrome-accent-400': chrome.accent[400], + '--bf-appearance-token-chrome-accent-500': chrome.accent[500], + '--bf-appearance-token-chrome-accent-600': chrome.accent[600], + '--bf-appearance-token-chrome-accent-700': chrome.accent[700], + '--bf-appearance-token-chrome-border-subtle': chrome.border.subtle, + '--bf-appearance-token-chrome-border-base': chrome.border.base, + '--bf-appearance-token-chrome-border-medium': chrome.border.medium, + '--bf-appearance-token-chrome-border-strong': chrome.border.strong, + '--bf-appearance-token-chrome-border-prominent': chrome.border.prominent, + '--bf-appearance-token-chrome-border-accent-soft': `color-mix(in srgb, ${chrome.accent[600]} 30%, transparent)`, + '--bf-appearance-token-chrome-border-accent-subtle': `color-mix(in srgb, ${chrome.accent[600]} 15%, transparent)`, + '--bf-appearance-token-chrome-border-accent': `color-mix(in srgb, ${chrome.accent[600]} 40%, transparent)`, + '--bf-appearance-token-chrome-border-accent-strong': `color-mix(in srgb, ${chrome.accent[600]} 60%, transparent)`, + '--bf-appearance-token-chrome-element-bg-subtle': chrome.element.subtle, + '--bf-appearance-token-chrome-element-bg-soft': chrome.element.soft, + '--bf-appearance-token-chrome-element-bg-base': chrome.element.base, + '--bf-appearance-token-chrome-element-bg-medium': chrome.element.medium, + '--bf-appearance-token-chrome-element-bg-strong': chrome.element.strong, + '--bf-appearance-token-chrome-scrollbar-thumb': chromeScrollbar.thumb, + '--bf-appearance-token-chrome-scrollbar-thumb-hover': chromeScrollbar.thumbHover, '--bf-appearance-token-color-static-white': '#ffffff', '--bf-appearance-token-color-static-black': '#000000', '--bf-appearance-token-color-static-white-rgb': '255, 255, 255', @@ -156,6 +206,13 @@ function createCssTokens(palette: AppearancePalette): Record { '--bf-appearance-token-element-bg-medium': colors.element.medium, '--bf-appearance-token-element-bg-strong': colors.element.strong, '--bf-appearance-token-element-bg-hover': colors.element.medium, + '--bf-appearance-token-config-page-section-bg': configPage?.section.background ?? colors.element.subtle, + '--bf-appearance-token-config-page-section-border': configPage?.section.border ?? colors.border.subtle, + '--bf-appearance-token-config-page-section-border-width': configPage?.section.borderWidth ?? '1px', + '--bf-appearance-token-config-page-section-shadow': configPage?.section.shadow + ?? `inset 0 1px 0 ${OVERLAY_WHITE_04}`, + '--bf-appearance-token-config-page-divider': configPage?.divider ?? colors.border.subtle, + '--bf-appearance-token-config-page-row-hover-bg': configPage?.rowHover ?? OVERLAY_WHITE_04, '--bf-appearance-token-git-color-branch': colors.git.branch, '--bf-appearance-token-git-color-branch-bg': colors.git.branchBg, '--bf-appearance-token-git-color-branch-bg-hover': colors.element.medium, @@ -163,8 +220,8 @@ function createCssTokens(palette: AppearancePalette): Record { '--bf-appearance-token-git-color-added': colors.git.added, '--bf-appearance-token-git-color-deleted': colors.git.deleted, '--bf-appearance-token-git-color-staged': colors.git.staged, - '--bf-appearance-token-scrollbar-thumb': colors.scrollbar?.thumb ?? (palette.type === 'dark' ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.15)'), - '--bf-appearance-token-scrollbar-thumb-hover': colors.scrollbar?.thumbHover ?? (palette.type === 'dark' ? 'rgba(255, 255, 255, 0.24)' : 'rgba(0, 0, 0, 0.3)'), + '--bf-appearance-token-scrollbar-thumb': scrollbar.thumb, + '--bf-appearance-token-scrollbar-thumb-hover': scrollbar.thumbHover, '--bf-appearance-token-opacity-disabled': String(effects.opacity.disabled), '--bf-appearance-token-opacity-hover': String(effects.opacity.hover), '--bf-appearance-token-badge-padding-y': '2px', @@ -313,7 +370,7 @@ function createCssTokens(palette: AppearancePalette): Record { }; const overlays = { - '--bf-appearance-token-color-overlay-white-04': 'rgba(255, 255, 255, 0.04)', + '--bf-appearance-token-color-overlay-white-04': OVERLAY_WHITE_04, '--bf-appearance-token-color-overlay-white-08': 'rgba(255, 255, 255, 0.08)', '--bf-appearance-token-color-overlay-white-12': 'rgba(255, 255, 255, 0.12)', '--bf-appearance-token-color-overlay-white-15': 'rgba(255, 255, 255, 0.15)', @@ -672,7 +729,9 @@ export function buildBuiltinAppearance(palette: AppearancePalette): AppearancePa parts: { overlay: { base: { backgroundColor: { kind: 'rgb', r: 0, g: 0, b: 0, a: 0.55 }, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: { kind: 'px', value: 24 } } }, dialog: { - base: { backgroundColor: colorRef('bg-primary'), borderColor: colorRef('border-subtle'), borderStyle: 'solid', borderWidth: lengthRef('border-one'), borderRadius: { kind: 'px', value: 8 }, maxHeight: { kind: 'percent', value: 100 } }, + // Outer popup-card chrome is owned exclusively by dialog-surface. + // Appearance keeps only layout so it cannot fork border/radius/shadow. + base: { maxHeight: { kind: 'percent', value: 100 } }, facets: { size: { small: { width: { kind: 'percent', value: 100 }, maxWidth: { kind: 'px', value: 420 } }, @@ -695,7 +754,7 @@ export function buildBuiltinAppearance(palette: AppearancePalette): AppearancePa version: 1, settings: { tokens: cssTokens, - background: palette.colors.background.primary, + background: palette.colors.chrome?.background.primary ?? palette.colors.background.primary, }, }, monaco: { diff --git a/src/web-ui/src/infrastructure/appearance/builtins/light.ts b/src/web-ui/src/infrastructure/appearance/builtins/light.ts index 54de2ecf20..8b293bf5cd 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/light.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/light.ts @@ -16,31 +16,35 @@ import { STATIC_WHITE, } from './paletteHelpers'; -const LIGHT_INK = '#0f172a'; -const LIGHT_TEXT_PRIMARY = '#1e293b'; -const LIGHT_TEXT_STRONG = '#334155'; -const LIGHT_ACCENT = '#64748b'; -const LIGHT_ACCENT_HOVER = '#475569'; +const LIGHT_NAVY = '#101a27'; +const LIGHT_TEXT_PRIMARY = '#1c1c1f'; +const LIGHT_TEXT_SECONDARY = '#555555'; +const LIGHT_TEXT_MUTED = '#6a6a6a'; +const LIGHT_TEXT_DISABLED = '#9a9a9a'; +const LIGHT_NAVY_HOVER = LIGHT_TEXT_PRIMARY; const LIGHT_PURPLE = '#7c6b99'; const LIGHT_PURPLE_HOVER = '#655680'; -const LIGHT_SUCCESS = '#5b9a6f'; -const LIGHT_WARNING = '#c08c42'; -const LIGHT_ERROR = '#c26565'; -const LIGHT_BACKGROUND_PRIMARY = '#fafafa'; -const LIGHT_EDITOR_LINE_HIGHLIGHT = '#f3f3f5'; - -const lightInk = (alpha: number | string) => rgbaFromHex(LIGHT_INK, alpha); -const lightAccent = (alpha: number | string) => rgbaFromHex(LIGHT_ACCENT, alpha); -const lightAccentHover = (alpha: number | string) => rgbaFromHex(LIGHT_ACCENT_HOVER, alpha); +const LIGHT_SUCCESS = '#247344'; +const LIGHT_SUCCESS_BG = '#e1fbe9'; +const LIGHT_WARNING = '#9a651f'; +const LIGHT_ERROR = '#a74352'; +const LIGHT_ERROR_BG = rgbaFromHex(LIGHT_ERROR, 0.12); +const LIGHT_BACKGROUND_PRIMARY = '#fdfdfd'; +const LIGHT_SURFACE_SUBTLE = rgbaFromHex(LIGHT_NAVY, 0.03); +const LIGHT_SURFACE_SOFT = '#f3f3f5'; +const LIGHT_BORDER_BASE = rgbaFromHex(LIGHT_NAVY, 0.15); + +const lightNavy = (alpha: number | string) => rgbaFromHex(LIGHT_NAVY, alpha); +const lightNavyHover = (alpha: number | string) => rgbaFromHex(LIGHT_NAVY_HOVER, alpha); export const bitfunLightPalette: AppearancePalette = { id: 'bitfun-light', name: 'Light', type: 'light', - description: 'Light appearance - Neutral gray surfaces, black primary actions', + description: 'Light appearance - Crisp white surfaces, soft neutral grays, deep navy actions', author: 'BitFun Team', - version: '2.3.1', + version: '2.4.0', layout: { sceneViewportBorder: false, @@ -51,30 +55,30 @@ export const bitfunLightPalette: AppearancePalette = { background: { primary: LIGHT_BACKGROUND_PRIMARY, secondary: STATIC_WHITE, - tertiary: '#e8e8e8', + tertiary: LIGHT_SURFACE_SOFT, elevated: STATIC_WHITE, - workbench: '#e8e8e8', + workbench: LIGHT_SURFACE_SOFT, scene: STATIC_WHITE, }, text: { primary: LIGHT_TEXT_PRIMARY, - secondary: '#3d4f66', - muted: LIGHT_ACCENT, - disabled: '#94a3b8', + secondary: LIGHT_TEXT_SECONDARY, + muted: LIGHT_TEXT_MUTED, + disabled: LIGHT_TEXT_DISABLED, }, accent: createAccentScale({ - base: LIGHT_ACCENT, - hover: LIGHT_ACCENT_HOVER, - alpha: { 700: 0.88 }, + base: LIGHT_NAVY, + hover: LIGHT_NAVY_HOVER, stops: { - 50: lightInk(0.04), - 100: lightInk(0.07), - 200: lightInk(0.1), - 300: lightInk(0.16), - 400: lightInk(0.26), + 50: LIGHT_SURFACE_SUBTLE, + 100: LIGHT_SURFACE_SOFT, + 200: lightNavy(0.12), + 300: lightNavy(0.18), + 400: lightNavy(0.3), + 700: STATIC_BLACK, }, }), @@ -93,37 +97,41 @@ export const bitfunLightPalette: AppearancePalette = { success: LIGHT_SUCCESS, warning: LIGHT_WARNING, error: LIGHT_ERROR, - info: LIGHT_ACCENT, + info: LIGHT_TEXT_SECONDARY, bgAlpha: 0.08, borderAlpha: 0.25, overrides: { - infoBg: lightAccent(0.1), - infoBorder: lightAccent(0.28), + successBg: LIGHT_SUCCESS_BG, + successBorder: LIGHT_SUCCESS, + errorBg: LIGHT_ERROR_BG, + errorBorder: rgbaFromHex(LIGHT_ERROR, 0.36), + infoBg: LIGHT_SURFACE_SOFT, + infoBorder: LIGHT_BORDER_BASE, }, }), border: { - subtle: lightAccent(0.15), - base: lightAccent(0.22), - medium: lightAccent(0.32), - strong: lightAccent(0.42), - prominent: lightAccent(0.52), + subtle: lightNavy(0.08), + base: LIGHT_BORDER_BASE, + medium: lightNavy(0.24), + strong: lightNavy(0.34), + prominent: lightNavy(0.48), }, element: { - subtle: lightInk(0.045), - soft: lightInk(0.065), - base: lightInk(0.09), - medium: lightInk(0.12), - strong: lightInk(0.16), + subtle: LIGHT_SURFACE_SUBTLE, + soft: LIGHT_SURFACE_SOFT, + base: lightNavy(0.09), + medium: lightNavy(0.13), + strong: lightNavy(0.18), }, git: createGitColors({ - branch: rgbFromHex(LIGHT_ACCENT_HOVER), - branchBg: lightAccentHover(0.1), + branch: rgbFromHex(LIGHT_NAVY_HOVER), + branchBg: lightNavyHover(0.1), changes: rgbFromHex(LIGHT_WARNING), added: rgbFromHex(LIGHT_SUCCESS), deleted: rgbFromHex(LIGHT_ERROR), @@ -134,11 +142,11 @@ export const bitfunLightPalette: AppearancePalette = { effects: { shadow: { - xs: `0 1px 2px ${lightAccentHover(0.06)}`, - sm: `0 2px 4px ${lightAccentHover(0.08)}`, - base: `0 4px 8px ${lightAccentHover(0.1)}`, - lg: `0 8px 16px ${lightAccentHover(0.12)}`, - xl: `0 12px 24px ${lightAccentHover(0.14)}`, + xs: `0 1px 2px ${lightNavy(0.04)}`, + sm: `0 2px 4px ${lightNavy(0.055)}`, + base: `0 4px 8px ${lightNavy(0.07)}`, + lg: `0 8px 16px ${lightNavy(0.09)}`, + xl: `0 12px 24px ${lightNavy(0.11)}`, }, @@ -181,20 +189,20 @@ export const bitfunLightPalette: AppearancePalette = { primary: { default: { - background: STATIC_BLACK, + background: LIGHT_NAVY, color: STATIC_WHITE, border: 'transparent', shadow: 'none', }, hover: { - background: '#262626', + background: LIGHT_NAVY_HOVER, color: STATIC_WHITE, border: 'transparent', shadow: 'none', transform: 'none', }, active: { - background: '#1c1c1f', + background: STATIC_BLACK, color: STATIC_WHITE, border: 'transparent', shadow: 'none', @@ -205,11 +213,11 @@ export const bitfunLightPalette: AppearancePalette = { ghost: { default: { - color: LIGHT_ACCENT_HOVER, + color: LIGHT_TEXT_SECONDARY, }, hover: { - background: lightInk(0.08), - color: LIGHT_TEXT_STRONG, + background: LIGHT_SURFACE_SOFT, + color: LIGHT_TEXT_PRIMARY, border: 'transparent', }, }, @@ -221,36 +229,36 @@ export const bitfunLightPalette: AppearancePalette = { base: 'vs', inherit: true, rules: [ - { token: 'comment', foreground: '94a3b8', fontStyle: 'italic' }, + { token: 'comment', foreground: '9a9a9a', fontStyle: 'italic' }, { token: 'keyword', foreground: '6b5a89' }, - { token: 'string', foreground: '5b9a6f' }, - { token: 'number', foreground: 'b8863a' }, - { token: 'type', foreground: '475569' }, - { token: 'class', foreground: '475569' }, + { token: 'string', foreground: '247344' }, + { token: 'number', foreground: '9a651f' }, + { token: 'type', foreground: '555555' }, + { token: 'class', foreground: '555555' }, { token: 'function', foreground: '7c6b99' }, - { token: 'variable', foreground: '475569' }, - { token: 'constant', foreground: 'c08c42' }, + { token: 'variable', foreground: '555555' }, + { token: 'constant', foreground: '9a651f' }, { token: 'operator', foreground: '6b5a89' }, - { token: 'tag', foreground: '475569' }, + { token: 'tag', foreground: '555555' }, { token: 'attribute.name', foreground: '7c6b99' }, - { token: 'attribute.value', foreground: '5b9a6f' }, + { token: 'attribute.value', foreground: '247344' }, ], colors: { - background: LIGHT_BACKGROUND_PRIMARY, + background: STATIC_WHITE, foreground: LIGHT_TEXT_PRIMARY, - lineHighlight: LIGHT_EDITOR_LINE_HIGHLIGHT, - selection: lightInk(0.14), + lineHighlight: LIGHT_SURFACE_SUBTLE, + selection: lightNavy(0.14), cursor: LIGHT_TEXT_PRIMARY, - 'editor.selectionBackground': lightInk(0.14), + 'editor.selectionBackground': lightNavy(0.14), 'editor.selectionForeground': LIGHT_TEXT_PRIMARY, - 'editor.inactiveSelectionBackground': lightInk(0.09), - 'editor.selectionHighlightBackground': lightInk(0.1), - 'editor.selectionHighlightBorder': lightInk(0.22), + 'editor.inactiveSelectionBackground': lightNavy(0.09), + 'editor.selectionHighlightBackground': lightNavy(0.1), + 'editor.selectionHighlightBorder': lightNavy(0.22), 'editorCursor.foreground': LIGHT_TEXT_PRIMARY, - 'editor.wordHighlightBackground': lightInk(0.07), - 'editor.wordHighlightStrongBackground': lightInk(0.11), + 'editor.wordHighlightBackground': lightNavy(0.07), + 'editor.wordHighlightStrongBackground': lightNavy(0.11), }, }, }; diff --git a/src/web-ui/src/infrastructure/appearance/builtins/monochrome.ts b/src/web-ui/src/infrastructure/appearance/builtins/monochrome.ts new file mode 100644 index 0000000000..258d6819e9 --- /dev/null +++ b/src/web-ui/src/infrastructure/appearance/builtins/monochrome.ts @@ -0,0 +1,145 @@ +import type { AppearancePalette } from './AppearancePalette'; +import { bitfunDarkPalette } from './dark'; +import { bitfunLightPalette } from './light'; +import { + createAccentScale, + createSecondaryAccentScale, + STATIC_BLACK, + STATIC_WHITE, +} from './paletteHelpers'; +import { bitfunSlatePalette } from './slate'; + +const content = bitfunLightPalette; +const chrome = bitfunSlatePalette; +const paper = STATIC_WHITE; +const ink = chrome.colors.background.primary; + +export const bitfunMonochromePalette: AppearancePalette = { + id: 'bitfun-monochrome', + name: 'Black & White', + type: 'light', + description: 'Black-and-white contrast appearance - Deep black chrome, bright white workspace, soft neutral blocks', + author: 'BitFun Team', + version: '1.0.0', + + layout: content.layout, + + colors: { + background: { + primary: paper, + secondary: paper, + tertiary: content.colors.background.tertiary, + elevated: paper, + workbench: paper, + scene: paper, + }, + text: content.colors.text, + accent: createAccentScale({ + base: ink, + hover: STATIC_BLACK, + stops: { + 50: content.colors.accent[50], + 100: content.colors.accent[100], + 200: content.colors.accent[200], + 300: content.colors.accent[300], + 400: content.colors.accent[400], + 700: STATIC_BLACK, + }, + }), + purple: createSecondaryAccentScale({ + base: ink, + hover: STATIC_BLACK, + }), + semantic: content.colors.semantic, + border: content.colors.border, + element: content.colors.element, + git: content.colors.git, + scrollbar: { + thumb: content.colors.border.base, + thumbHover: content.colors.border.strong, + }, + chrome: { + background: { + primary: chrome.colors.background.primary, + secondary: chrome.colors.background.secondary, + tertiary: chrome.colors.background.secondary, + elevated: chrome.colors.background.elevated, + workbench: chrome.colors.background.primary, + scene: chrome.colors.background.primary, + }, + text: { + primary: content.colors.element.soft, + secondary: bitfunDarkPalette.colors.text.secondary, + muted: content.colors.text.disabled, + disabled: content.colors.text.muted, + }, + accent: createAccentScale({ + base: content.colors.element.soft, + hover: STATIC_WHITE, + stops: { + 50: bitfunDarkPalette.colors.element.subtle, + 100: bitfunDarkPalette.colors.element.soft, + 200: bitfunDarkPalette.colors.element.base, + 300: bitfunDarkPalette.colors.element.medium, + 400: bitfunDarkPalette.colors.element.strong, + }, + }), + border: chrome.colors.border, + element: chrome.colors.element, + scrollbar: chrome.colors.scrollbar, + }, + }, + + effects: content.effects, + motion: content.motion, + typography: content.typography, + + components: { + button: { + primary: { + default: { + background: ink, + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + }, + hover: { + background: STATIC_BLACK, + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + active: { + background: ink, + color: STATIC_WHITE, + border: 'transparent', + shadow: 'none', + transform: 'none', + }, + }, + ghost: { + default: { + color: content.colors.text.secondary, + }, + hover: { + background: content.colors.element.soft, + color: ink, + border: 'transparent', + }, + }, + }, + configPage: { + section: { + background: content.colors.element.soft, + border: 'transparent', + borderWidth: '0', + shadow: 'none', + }, + divider: content.colors.border.subtle, + rowHover: content.colors.element.subtle, + }, + }, + + monaco: content.monaco, +}; diff --git a/src/web-ui/src/infrastructure/appearance/builtins/palettes.ts b/src/web-ui/src/infrastructure/appearance/builtins/palettes.ts index 33e2ef8862..8ef84812b9 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/palettes.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/palettes.ts @@ -1,5 +1,6 @@ export { bitfunDarkPalette } from './dark'; export { bitfunLightPalette } from './light'; +export { bitfunMonochromePalette } from './monochrome'; export { bitfunMidnightPalette } from './midnight'; export { bitfunChinaStylePalette } from './chinaStyle'; export { bitfunChinaNightPalette } from './chinaNight'; @@ -9,6 +10,7 @@ export { bitfunTokyoNightPalette } from './tokyoNight'; import { bitfunDarkPalette } from './dark'; import { bitfunLightPalette } from './light'; +import { bitfunMonochromePalette } from './monochrome'; import { bitfunMidnightPalette } from './midnight'; import { bitfunChinaStylePalette } from './chinaStyle'; import { bitfunChinaNightPalette } from './chinaNight'; @@ -22,6 +24,7 @@ export const DEFAULT_DARK_APPEARANCE_ID: AppearancePaletteId = 'bitfun-dark'; export const builtinAppearancePalettes: readonly AppearancePalette[] = Object.freeze([ bitfunLightPalette, + bitfunMonochromePalette, bitfunSlatePalette, bitfunDarkPalette, bitfunMidnightPalette, diff --git a/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts b/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts index 37e534a9e6..2c6430d110 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts @@ -37,15 +37,26 @@ describe('startup appearance bootstrap manifest', () => { const light = manifest.appearances.find(entry => entry.id === DEFAULT_LIGHT_APPEARANCE_ID); const palette = builtinAppearancePalettes.find(entry => entry.id === DEFAULT_LIGHT_APPEARANCE_ID); + const chrome = palette?.colors.chrome; expect(light).toEqual({ id: palette?.id, - bgPrimary: palette?.colors.background.primary, - bgSecondary: palette?.colors.background.secondary, + bgPrimary: chrome?.background.primary ?? palette?.colors.background.primary, + bgSecondary: chrome?.background.secondary ?? palette?.colors.background.secondary, bgScene: palette?.colors.background.scene, isLight: true, - textPrimary: palette?.colors.text.primary, - textMuted: palette?.colors.text.muted, - accentColor: palette?.colors.accent[500], + textPrimary: chrome?.text.primary ?? palette?.colors.text.primary, + textMuted: chrome?.text.muted ?? palette?.colors.text.muted, + accentColor: chrome?.accent[500] ?? palette?.colors.accent[500], + }); + + const monochrome = manifest.appearances.find(entry => entry.id === 'bitfun-monochrome'); + expect(monochrome).toMatchObject({ + bgPrimary: '#1c1c1f', + bgSecondary: '#262626', + bgScene: '#ffffff', + isLight: true, + textPrimary: '#f3f3f5', + accentColor: '#f3f3f5', }); }); diff --git a/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.ts b/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.ts index a2926b2c83..ff8e2f020b 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.ts @@ -24,15 +24,16 @@ export interface StartupAppearanceBootstrapManifest { export function createStartupAppearanceBootstrapEntry( palette: AppearancePalette, ): StartupAppearanceBootstrapEntry { + const chrome = palette.colors.chrome; return { id: palette.id, - bgPrimary: palette.colors.background.primary, - bgSecondary: palette.colors.background.secondary, + bgPrimary: chrome?.background.primary ?? palette.colors.background.primary, + bgSecondary: chrome?.background.secondary ?? palette.colors.background.secondary, bgScene: palette.colors.background.scene, isLight: palette.type === 'light', - textPrimary: palette.colors.text.primary, - textMuted: palette.colors.text.muted, - accentColor: palette.colors.accent[500], + textPrimary: chrome?.text.primary ?? palette.colors.text.primary, + textMuted: chrome?.text.muted ?? palette.colors.text.muted, + accentColor: chrome?.accent[500] ?? palette.colors.accent[500], }; } diff --git a/src/web-ui/src/infrastructure/config/components/ReasoningPresetEditor.scss b/src/web-ui/src/infrastructure/config/components/ReasoningPresetEditor.scss index 393c312c4a..a943da269a 100644 --- a/src/web-ui/src/infrastructure/config/components/ReasoningPresetEditor.scss +++ b/src/web-ui/src/infrastructure/config/components/ReasoningPresetEditor.scss @@ -1,4 +1,5 @@ @use '../../../component-library/styles/tokens' as *; +@use '../../../component-library/styles/overlay-surfaces' as surfaces; .bitfun-reasoning-preset-editor { display: flex; @@ -92,6 +93,8 @@ } &__models-dev-search-results { + @include surfaces.floating-surface; + position: fixed; z-index: $z-popover; box-sizing: border-box; @@ -99,10 +102,6 @@ max-height: min(280px, calc(100vh - 16px)); overflow-y: auto; padding: $size-gap-1 0; - border: 1px solid var(--bf-appearance-token-border-base); - border-radius: $size-radius-sm; - background: var(--bf-appearance-token-color-bg-secondary); - box-shadow: var(--bf-appearance-token-shadow-sm); } &__models-dev-search-result { diff --git a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss index 373cd0fed1..c51a9b1345 100644 --- a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss +++ b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.scss @@ -114,11 +114,11 @@ .bitfun-config-page-section__body { min-width: 0; - background: var(--bf-appearance-token-element-bg-subtle); - border: 1px solid var(--bf-appearance-token-border-subtle); + background: var(--bf-appearance-token-config-page-section-bg); + border: var(--bf-appearance-token-config-page-section-border-width) solid var(--bf-appearance-token-config-page-section-border); border-radius: var(--bf-appearance-token-size-radius-md); overflow: visible; - box-shadow: inset 0 1px 0 var(--bf-appearance-token-color-overlay-white-04); + box-shadow: var(--bf-appearance-token-config-page-section-shadow); } // Sections whose children own their own cards. Every visual boundary has to go, @@ -139,12 +139,12 @@ } .bitfun-config-page-row + .bitfun-config-page-row { - border-top: 1px solid var(--bf-appearance-token-border-subtle); + border-top: 1px solid var(--bf-appearance-token-config-page-divider); } @media (hover: hover) and (pointer: fine) { .bitfun-config-page-row:hover { - background: var(--bf-appearance-token-color-overlay-white-04); + background: var(--bf-appearance-token-config-page-row-hover-bg); } } diff --git a/src/web-ui/src/infrastructure/i18n/components/LanguageSelector.scss b/src/web-ui/src/infrastructure/i18n/components/LanguageSelector.scss index bc842b988e..1fc5231dc5 100644 --- a/src/web-ui/src/infrastructure/i18n/components/LanguageSelector.scss +++ b/src/web-ui/src/infrastructure/i18n/components/LanguageSelector.scss @@ -1,4 +1,4 @@ - +@use '../../../component-library/styles/overlay-surfaces' as surfaces; .language-selector { position: relative; @@ -133,16 +133,14 @@ } .language-selector__dropdown { + @include surfaces.floating-surface; + position: absolute; top: 100%; right: 0; margin-top: 4px; min-width: 150px; padding: 4px; - background: var(--bf-appearance-token-color-bg-elevated); - border: 1px solid var(--bf-appearance-token-border-base); - border-radius: var(--bf-appearance-token-size-radius-md); - box-shadow: var(--bf-appearance-token-shadow-lg); opacity: 0; visibility: hidden; transform: translateY(-4px); diff --git a/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.scss b/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.scss index 9580eb285f..9a3585faf2 100644 --- a/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.scss +++ b/src/web-ui/src/infrastructure/peer-device/DeviceSurfaceSwitcher.scss @@ -4,6 +4,7 @@ */ @use '../../component-library/styles/tokens.scss' as *; +@use '../../component-library/styles/overlay-surfaces' as surfaces; .bitfun-device-switcher { display: flex; @@ -86,16 +87,14 @@ } &__menu { + @include surfaces.floating-surface; + position: fixed; min-width: 216px; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow-y: auto; padding: $size-gap-1; - background: var(--bf-appearance-token-color-bg-elevated); - border: 1px solid var(--bf-appearance-token-border-subtle); - border-radius: $size-radius-base; - box-shadow: 0 4px 12px var(--bf-appearance-token-color-overlay-black-30); z-index: 9999; color: var(--bf-appearance-token-color-text-primary); font-family: var(--bf-appearance-token-font-family-sans); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.scss b/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.scss index 9b6656ce3c..ed0f4bd656 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.scss +++ b/src/web-ui/src/infrastructure/peer-device/PeerDirectoryBrowser.scss @@ -1,6 +1,7 @@ /** * Peer directory browser styles (controller-side in-app picker). */ +@use '../../component-library/styles/overlay-surfaces' as surfaces; .peer-directory-browser-overlay { position: fixed; @@ -22,15 +23,13 @@ } .peer-directory-browser { + @include surfaces.dialog-surface; + display: flex; flex-direction: column; height: 72vh; width: 90vw; max-width: 840px; - background: var(--bf-appearance-token-color-bg-secondary); - border: 1px solid var(--bf-appearance-token-border-subtle); - border-radius: 12px; - box-shadow: 0 24px 48px var(--bf-appearance-token-color-overlay-black-50); overflow: hidden; opacity: 1; transform: translateY(0) scale(1); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 9d2e4dff91..ecc0c6a0b9 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -518,7 +518,6 @@ "emptyProjects": "No projects yet", "emptyAssistants": "No personal assistants", "emptySessionGroups": "No projects or assistants yet", - "primaryAssistant": "Primary", "closeFailed": "Failed to close workspace", "createAssistantFailed": "Failed to create personal assistant", "deleteAssistantFailed": "Failed to delete personal assistant", diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 16b1e00d1a..203a63e925 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1310,6 +1310,14 @@ "searchClose": "Close search", "searchOpen": "Search messages", "jumpToCurrentTurn": "Jump to Turn {{turn}}", + "sessionOverview": "Session overview", + "sessionOverviewActive": "Session overview, activity in progress", + "sessionOverviewAgentsUnavailable": "No active session", + "sessionOverviewAgentsActive": "Agent work in progress", + "sessionOverviewAgentsIdle": "View the Agent session tree", + "sessionOverviewBackgroundRunning": "{{count}} running", + "sessionOverviewBackgroundFinished": "{{count}} recently finished", + "sessionOverviewPullRequests": "Open workspace pull requests", "agentTree": "Agents", "agentTreeLoading": "Loading Agent tree...", "agentTreeEmpty": "No subagents in this session", @@ -1330,8 +1338,10 @@ "error": "$t(shared:statuses.failed)", "idle": "Idle" }, + "backgroundCommandOverview": "Background terminals", "backgroundCommands": "Background commands ({{count}})", "backgroundCommandEmpty": "No background commands", + "backgroundTerminalEmpty": "No background terminals", "backgroundCommandUntitled": "Background command", "backgroundCommandSession": "Session #{{id}}", "backgroundCommandStatusRunning": "Active", @@ -1342,7 +1352,13 @@ "backgroundCommandStopping": "Stopping command", "backgroundCommandStopAll": "Stop all", "backgroundCommandStopFailed": "Failed to stop background command.", - "pullRequests": "Pull requests" + "pullRequests": "Pull requests", + "pullRequestCount": "{{count}} pull requests", + "pullRequestEmpty": "No pull requests", + "pullRequestLoading": "Loading pull requests...", + "pullRequestNotGitRepository": "This workspace is not a Git repository", + "pullRequestWorkspaceUnavailable": "No active workspace", + "pullRequestLoadFailed": "Unable to load pull requests. Select to retry." }, "subagentIdentity": { "names": { diff --git a/src/web-ui/src/locales/en-US/settings/basics.json b/src/web-ui/src/locales/en-US/settings/basics.json index ddf7d13fd4..ce93ddf38f 100644 --- a/src/web-ui/src/locales/en-US/settings/basics.json +++ b/src/web-ui/src/locales/en-US/settings/basics.json @@ -22,7 +22,11 @@ }, "bitfun-light": { "name": "Light", - "description": "Light appearance - Elegant and refined, soft blue-gray tones" + "description": "Light appearance - Crisp white surfaces, soft neutral grays, and deep navy actions" + }, + "bitfun-monochrome": { + "name": "Black & White", + "description": "Black-and-white contrast appearance - Deep black chrome, bright white workspace, and soft neutral blocks" }, "bitfun-midnight": { "name": "Midnight", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 74d262d786..f459be35a8 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -518,7 +518,6 @@ "emptyProjects": "暂无项目", "emptyAssistants": "暂无个人助理", "emptySessionGroups": "暂无项目或助理", - "primaryAssistant": "主助理", "closeFailed": "关闭工作区失败", "createAssistantFailed": "新建个人助理失败", "deleteAssistantFailed": "删除个人助理失败", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index be916f7050..4c3d41686b 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1310,6 +1310,14 @@ "searchClose": "关闭搜索", "searchOpen": "搜索消息", "jumpToCurrentTurn": "跳转到第 {{turn}} 轮", + "sessionOverview": "会话概览", + "sessionOverviewActive": "会话概览,有活动正在进行", + "sessionOverviewAgentsUnavailable": "当前没有可用会话", + "sessionOverviewAgentsActive": "有 Agent 正在运行", + "sessionOverviewAgentsIdle": "查看 Agent 会话树", + "sessionOverviewBackgroundRunning": "{{count}} 个正在运行", + "sessionOverviewBackgroundFinished": "{{count}} 个最近已结束", + "sessionOverviewPullRequests": "打开工作区拉取请求", "agentTree": "Agents", "agentTreeLoading": "正在加载 Agent 会话树...", "agentTreeEmpty": "当前会话没有子 Agent", @@ -1330,8 +1338,10 @@ "error": "$t(shared:statuses.failed)", "idle": "空闲" }, + "backgroundCommandOverview": "后台终端", "backgroundCommands": "后台命令({{count}})", "backgroundCommandEmpty": "暂无后台命令", + "backgroundTerminalEmpty": "暂无后台终端", "backgroundCommandUntitled": "后台命令", "backgroundCommandSession": "会话 #{{id}}", "backgroundCommandStatusRunning": "进行中", @@ -1342,7 +1352,13 @@ "backgroundCommandStopping": "正在终止命令", "backgroundCommandStopAll": "全部终止", "backgroundCommandStopFailed": "终止后台命令失败。", - "pullRequests": "拉取请求" + "pullRequests": "拉取请求", + "pullRequestCount": "{{count}} 个拉取请求", + "pullRequestEmpty": "暂无拉取请求", + "pullRequestLoading": "正在加载拉取请求...", + "pullRequestNotGitRepository": "当前工作区不是 Git 仓库", + "pullRequestWorkspaceUnavailable": "当前没有可用工作区", + "pullRequestLoadFailed": "无法加载拉取请求,点击重试。" }, "subagentIdentity": { "names": { diff --git a/src/web-ui/src/locales/zh-CN/settings/basics.json b/src/web-ui/src/locales/zh-CN/settings/basics.json index 2df39b45d0..e2abc735fe 100644 --- a/src/web-ui/src/locales/zh-CN/settings/basics.json +++ b/src/web-ui/src/locales/zh-CN/settings/basics.json @@ -22,7 +22,11 @@ }, "bitfun-light": { "name": "亮色", - "description": "亮色外观 - 素雅高级,淡蓝灰调" + "description": "亮色外观 - 纯白界面、柔和中性灰与深海军蓝操作色" + }, + "bitfun-monochrome": { + "name": "黑白撞色", + "description": "黑白撞色外观 - 深黑结构层、明亮白色工作区与柔和中性灰块面" }, "bitfun-midnight": { "name": "午夜", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index a6677576a3..9bea2a50a0 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -518,7 +518,6 @@ "emptyProjects": "暫無項目", "emptyAssistants": "暫無個人助理", "emptySessionGroups": "暫無項目或助理", - "primaryAssistant": "主助理", "closeFailed": "關閉工作區失敗", "createAssistantFailed": "新增個人助理失敗", "deleteAssistantFailed": "刪除個人助理失敗", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index c5d870752b..e9f011e767 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1310,6 +1310,14 @@ "searchClose": "關閉搜尋", "searchOpen": "搜尋消息", "jumpToCurrentTurn": "跳轉到第 {{turn}} 輪", + "sessionOverview": "會話概覽", + "sessionOverviewActive": "會話概覽,有活動正在進行", + "sessionOverviewAgentsUnavailable": "目前沒有可用會話", + "sessionOverviewAgentsActive": "有 Agent 正在執行", + "sessionOverviewAgentsIdle": "查看 Agent 會話樹", + "sessionOverviewBackgroundRunning": "{{count}} 個正在執行", + "sessionOverviewBackgroundFinished": "{{count}} 個最近已結束", + "sessionOverviewPullRequests": "開啟工作區拉取請求", "agentTree": "Agents", "agentTreeLoading": "正在載入 Agent 會話樹...", "agentTreeEmpty": "目前會話沒有子 Agent", @@ -1330,8 +1338,10 @@ "error": "$t(shared:statuses.failed)", "idle": "閒置" }, + "backgroundCommandOverview": "背景終端", "backgroundCommands": "背景命令({{count}})", "backgroundCommandEmpty": "目前沒有背景命令", + "backgroundTerminalEmpty": "目前沒有背景終端", "backgroundCommandUntitled": "背景命令", "backgroundCommandSession": "會話 #{{id}}", "backgroundCommandStatusRunning": "進行中", @@ -1342,7 +1352,13 @@ "backgroundCommandStopping": "正在終止命令", "backgroundCommandStopAll": "全部終止", "backgroundCommandStopFailed": "終止背景命令失敗。", - "pullRequests": "拉取請求" + "pullRequests": "拉取請求", + "pullRequestCount": "{{count}} 個拉取請求", + "pullRequestEmpty": "目前沒有拉取請求", + "pullRequestLoading": "正在載入拉取請求...", + "pullRequestNotGitRepository": "目前工作區不是 Git 倉庫", + "pullRequestWorkspaceUnavailable": "目前沒有可用工作區", + "pullRequestLoadFailed": "無法載入拉取請求,點擊重試。" }, "subagentIdentity": { "names": { diff --git a/src/web-ui/src/locales/zh-TW/settings/basics.json b/src/web-ui/src/locales/zh-TW/settings/basics.json index 74cfb41d20..cfbb3f85c7 100644 --- a/src/web-ui/src/locales/zh-TW/settings/basics.json +++ b/src/web-ui/src/locales/zh-TW/settings/basics.json @@ -22,7 +22,11 @@ }, "bitfun-light": { "name": "亮色", - "description": "亮色外觀 - 素雅高級,淡藍灰調" + "description": "亮色外觀 - 純白界面、柔和中性灰與深海軍藍操作色" + }, + "bitfun-monochrome": { + "name": "黑白撞色", + "description": "黑白撞色外觀 - 深黑結構層、明亮白色工作區與柔和中性灰塊面" }, "bitfun-midnight": { "name": "午夜", diff --git a/src/web-ui/src/shared/announcement-system/styles/AnnouncementToast.scss b/src/web-ui/src/shared/announcement-system/styles/AnnouncementToast.scss index ebbaf29bb7..1d69b7da95 100644 --- a/src/web-ui/src/shared/announcement-system/styles/AnnouncementToast.scss +++ b/src/web-ui/src/shared/announcement-system/styles/AnnouncementToast.scss @@ -1,4 +1,5 @@ @use '../../../component-library/styles/tokens' as *; +@use '../../../component-library/styles/overlay-surfaces' as surfaces; $toast-width: 320px; @@ -29,19 +30,18 @@ $toast-width: 320px; > .announcement-toast { position: relative; z-index: 3; - transition: - box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1), - transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1); + transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1); } // Ghost layers ───────────────────────────────────────────────────────────── .announcement-toast-ghost { + @include surfaces.floating-surface; + position: absolute; top: 0; left: 0; right: 0; bottom: 0; - border-radius: $size-radius-lg; pointer-events: none; transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1); @@ -49,18 +49,12 @@ $toast-width: 320px; &--1 { z-index: 2; transform: rotate(-8deg); - background: var(--bf-appearance-token-color-bg-secondary); - border: 1px solid var(--bf-appearance-token-border-base); - box-shadow: 0 8px 20px var(--bf-appearance-token-color-overlay-black-12); } // Ghost 2: tilted right → fans right on hover &--2 { z-index: 1; transform: rotate(8deg); - background: var(--bf-appearance-token-color-bg-tertiary); - border: 1px solid var(--bf-appearance-token-border-subtle); - box-shadow: 0 6px 16px rgba(var(--bf-appearance-token-color-static-black-rgb), 0.09); } } @@ -69,7 +63,6 @@ $toast-width: 320px; &:hover { > .announcement-toast { transform: translateY(-2px) scale(1.005); - box-shadow: 0 10px 28px var(--bf-appearance-token-color-overlay-black-20); } .announcement-toast-ghost--1 { @@ -85,15 +78,13 @@ $toast-width: 320px; // ─── Single toast card ──────────────────────────────────────────────────────── .announcement-toast { + @include surfaces.floating-surface; + width: $toast-width; min-width: $toast-width; max-width: $toast-width; display: flex; flex-direction: column; - background: var(--bf-appearance-token-color-bg-primary); - border: 1px solid var(--bf-appearance-token-border-base); - border-radius: $size-radius-lg; - box-shadow: 0 6px 24px rgba(var(--bf-appearance-token-color-static-black-rgb), 0.14); overflow: hidden; &--entering { diff --git a/src/web-ui/src/shared/announcement-system/styles/FeatureModal.scss b/src/web-ui/src/shared/announcement-system/styles/FeatureModal.scss index 1bd2cb3a18..bdbb474239 100644 --- a/src/web-ui/src/shared/announcement-system/styles/FeatureModal.scss +++ b/src/web-ui/src/shared/announcement-system/styles/FeatureModal.scss @@ -1,4 +1,5 @@ @use '../../../component-library/styles/tokens' as *; +@use '../../../component-library/styles/overlay-surfaces' as surfaces; // ─── Overlay backdrop ───────────────────────────────────────────────────────── .feature-modal-backdrop { @@ -19,11 +20,9 @@ // ─── Modal shell ────────────────────────────────────────────────────────────── .feature-modal { + @include surfaces.dialog-surface; + position: relative; - background: var(--bf-appearance-token-color-bg-primary); - border-radius: $size-radius-lg; - border: 1px solid var(--bf-appearance-token-border-base); - box-shadow: 0 24px 64px rgba(var(--bf-appearance-token-color-static-black-rgb), 0.28); display: flex; flex-direction: column; overflow: hidden; diff --git a/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.scss b/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.scss index d4db2146a7..6d85666aad 100644 --- a/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.scss +++ b/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.scss @@ -1,11 +1,14 @@ @use '@/component-library/styles/tokens.scss' as *; +@use '@/component-library/styles/overlay-surfaces' as surfaces; // Surface + motion aligned with .bitfun-nav-panel__workspace-menu / __workspace-item-menu-popover .context-menu { + @include surfaces.floating-surface; + --context-menu-transform-origin: top left; position: fixed; @@ -13,11 +16,6 @@ min-width: 180px; max-width: min(320px, calc(100vw - 24px)); - background: var(--bf-appearance-token-color-bg-elevated); - border: 1px solid var(--bf-appearance-token-border-subtle); - border-radius: $size-radius-base; - box-shadow: 0 4px 12px var(--bf-appearance-token-color-overlay-black-30); - padding: $size-gap-1 0; box-sizing: border-box; @@ -33,18 +31,6 @@ font-size: var(--bf-appearance-token-font-size-sm); line-height: 1.4; - :root[data-bf-appearance-mode='light'] & { - box-shadow: - 0 1px 2px var(--bf-appearance-token-color-overlay-slate-08), - 0 10px 28px -8px var(--bf-appearance-token-color-overlay-slate-12); - } - - :root[data-bf-appearance-mode='dark'] & { - box-shadow: - 0 1px 0 color-mix(in srgb, var(--bf-appearance-token-color-static-white) 4%, transparent), - 0 10px 28px -8px rgba(var(--bf-appearance-token-color-static-black-rgb), 0.55); - } - &[data-state='entered'] { opacity: 1; transform: translateY(0) scale(1); @@ -263,7 +249,6 @@ .context-menu { border-width: 2px; border-color: var(--bf-appearance-token-border-strong); - box-shadow: var(--bf-appearance-token-shadow-xl); } .context-menu-item:hover:not(.disabled) { diff --git a/src/web-ui/src/shared/notification-system/components/LoadingNotification.scss b/src/web-ui/src/shared/notification-system/components/LoadingNotification.scss index c8350e9714..68cf03431f 100644 --- a/src/web-ui/src/shared/notification-system/components/LoadingNotification.scss +++ b/src/web-ui/src/shared/notification-system/components/LoadingNotification.scss @@ -1,14 +1,13 @@ @use '../../../component-library/styles/tokens' as *; +@use '../../../component-library/styles/overlay-surfaces' as surfaces; .loading-notification { + @include surfaces.floating-surface; + min-width: 260px; max-width: 320px; - background: var(--bf-appearance-token-color-bg-primary); - border-radius: $size-radius-base; - box-shadow: 0 2px 6px var(--bf-appearance-token-color-overlay-black-08); - border: 1px solid var(--bf-appearance-token-border-base); padding: $size-gap-2 $size-gap-3; display: flex; align-items: flex-start; @@ -23,8 +22,6 @@ } &--failed { - border-color: var(--bf-appearance-token-color-error-border); - .loading-notification__spinner { animation: none; } diff --git a/src/web-ui/src/shared/notification-system/components/NotificationItem.scss b/src/web-ui/src/shared/notification-system/components/NotificationItem.scss index 7dd2db4d59..18648e89fb 100644 --- a/src/web-ui/src/shared/notification-system/components/NotificationItem.scss +++ b/src/web-ui/src/shared/notification-system/components/NotificationItem.scss @@ -1,16 +1,15 @@ @use '../../../component-library/styles/tokens' as *; +@use '../../../component-library/styles/overlay-surfaces' as surfaces; .notification-item { + @include surfaces.floating-surface; + display: flex; align-items: flex-start; gap: $size-gap-2; padding: $size-gap-3; - background: var(--bf-appearance-token-color-bg-primary); - border-radius: $size-radius-base; - box-shadow: 0 2px 6px var(--bf-appearance-token-color-overlay-black-08); - border: 1px solid var(--bf-appearance-token-border-base); min-width: 280px; max-width: 320px; animation: bitfun-notification-enter 180ms cubic-bezier(0.23, 1, 0.32, 1) both; diff --git a/src/web-ui/src/shared/notification-system/components/ProgressNotification.scss b/src/web-ui/src/shared/notification-system/components/ProgressNotification.scss index f5442442dc..4de01697b3 100644 --- a/src/web-ui/src/shared/notification-system/components/ProgressNotification.scss +++ b/src/web-ui/src/shared/notification-system/components/ProgressNotification.scss @@ -1,16 +1,15 @@ @use '../../../component-library/styles/tokens' as *; +@use '../../../component-library/styles/overlay-surfaces' as surfaces; .progress-notification { + @include surfaces.floating-surface; + display: flex; align-items: flex-start; gap: $size-gap-2; padding: $size-gap-3; - background: var(--bf-appearance-token-color-bg-primary); - border-radius: $size-radius-base; - box-shadow: 0 2px 6px var(--bf-appearance-token-color-overlay-black-08); - border: 1px solid var(--bf-appearance-token-border-base); min-width: 280px; max-width: 320px; animation: bitfun-progress-notification-slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1); diff --git a/src/web-ui/src/tools/editor/components/EditorBreadcrumb.scss b/src/web-ui/src/tools/editor/components/EditorBreadcrumb.scss index 96dea7667e..eb2ff4d587 100644 --- a/src/web-ui/src/tools/editor/components/EditorBreadcrumb.scss +++ b/src/web-ui/src/tools/editor/components/EditorBreadcrumb.scss @@ -4,6 +4,7 @@ */ @use '@/component-library/styles/tokens' as *; +@use '@/component-library/styles/overlay-surfaces' as surfaces; // Variables $_breadcrumb-height: 24px; @@ -85,12 +86,10 @@ $_dropdown-width: 220px; // Dropdown Menu (Portal rendered) .editor-breadcrumb-dropdown { + @include surfaces.floating-surface; + width: $_dropdown-width; max-height: $_dropdown-max-height; - background: var(--bf-appearance-token-color-bg-elevated); - border: 1px solid var(--bf-appearance-token-border-subtle); - border-radius: 6px; - box-shadow: var(--bf-appearance-token-shadow-base); z-index: 10000; overflow: hidden; animation: breadcrumb-dropdown-in 0.12s ease-out; diff --git a/src/web-ui/src/tools/editor/components/StatusBarPopovers/StatusBarPopovers.scss b/src/web-ui/src/tools/editor/components/StatusBarPopovers/StatusBarPopovers.scss index 568fc9e5d4..1bed48971c 100644 --- a/src/web-ui/src/tools/editor/components/StatusBarPopovers/StatusBarPopovers.scss +++ b/src/web-ui/src/tools/editor/components/StatusBarPopovers/StatusBarPopovers.scss @@ -4,21 +4,17 @@ */ @use '@/component-library/styles/tokens' as *; + @use '@/component-library/styles/overlay-surfaces' as surfaces; $popover-z: 360; $popover-gap: 4px; - $popover-radius: 6px; - $popover-shadow: 0 4px 16px rgba(var(--bf-appearance-token-color-static-black-rgb), 0.35); - .status-bar-popover { + @include surfaces.floating-surface; + position: fixed; z-index: $popover-z; min-width: 200px; max-width: 320px; - background: var(--bf-appearance-token-color-bg-elevated); - border: 1px solid var(--bf-appearance-token-border-subtle); - border-radius: $popover-radius; - box-shadow: $popover-shadow; font-size: 12px; font-family: $font-family-sans; color: var(--bf-appearance-token-color-text-primary); diff --git a/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.scss b/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.scss index 31873d380e..125adf44a6 100644 --- a/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.scss +++ b/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.scss @@ -1,4 +1,5 @@ @use '../../../../component-library/styles/tokens.scss' as *; +@use '../../../../component-library/styles/overlay-surfaces' as surfaces; @use '../../../../component-library/styles/flowchat-markdown-code-vars' as markdownCode; @use '../../../../component-library/styles/flowchat-markdown-table-vars' as markdownTable; @use '../../styles/markdown-editor-layout' as markdownLayout; @@ -473,47 +474,10 @@ } .m-editor-inline-ai__panel { + @include surfaces.floating-surface; + width: min(26rem, calc(100vw - #{$size-gap-8})); padding: 10px 10px 8px; - border: 1px solid var(--bf-appearance-token-color-overlay-white-12); - border-radius: 12px; - background: var(--bf-appearance-token-color-bg-tertiary); - backdrop-filter: blur(16px) saturate(1.2); - -webkit-backdrop-filter: blur(16px) saturate(1.2); - box-shadow: - 0 8px 24px color-mix(in srgb, var(--bf-appearance-token-color-static-black) 35%, transparent), - inset 0 1px 0 var(--bf-appearance-token-color-overlay-white-08), - inset 0 -1px 0 var(--bf-appearance-token-color-overlay-white-04); - transition: - transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.28s cubic-bezier(0.4, 0, 0.2, 1); - } - - .m-editor-inline-ai:hover .m-editor-inline-ai__panel { - transform: translateY(-4px); - box-shadow: - 0 16px 40px color-mix(in srgb, var(--bf-appearance-token-color-static-black) 45%, transparent), - 0 0 28px var(--bf-appearance-token-color-accent-100), - inset 0 1px 0 var(--bf-appearance-token-color-overlay-white-12), - inset 0 -1px 0 var(--bf-appearance-token-color-overlay-white-04); - } - - :root[data-bf-appearance-mode='light'] &, - :root[data-bf-appearance-mode='light'] &, - .light & { - .m-editor-inline-ai__panel { - border-color: var(--bf-appearance-token-color-overlay-slate-12); - box-shadow: - 0 8px 24px var(--bf-appearance-token-color-overlay-slate-08), - inset 0 1px 0 color-mix(in srgb, var(--bf-appearance-token-color-static-white) 85%, transparent); - } - - .m-editor-inline-ai:hover .m-editor-inline-ai__panel { - box-shadow: - 0 14px 36px var(--bf-appearance-token-color-overlay-slate-12), - 0 0 24px var(--bf-appearance-token-glass-blue-subtle), - inset 0 1px 0 var(--bf-appearance-token-color-overlay-white-95); - } } .m-editor-inline-ai__composer { diff --git a/src/web-ui/src/tools/git/components/CreateBranchDialog/CreateBranchDialog.scss b/src/web-ui/src/tools/git/components/CreateBranchDialog/CreateBranchDialog.scss index 1bad05dceb..9585c2d713 100644 --- a/src/web-ui/src/tools/git/components/CreateBranchDialog/CreateBranchDialog.scss +++ b/src/web-ui/src/tools/git/components/CreateBranchDialog/CreateBranchDialog.scss @@ -1,15 +1,11 @@ @use '@/component-library/styles/tokens.scss' as *; .bitfun-create-branch-dialog { - background: var(--bf-appearance-token-color-bg-primary); - border: 1px solid var(--bf-appearance-token-border-base); - border-radius: $size-radius-base; padding: $size-gap-5; min-width: 520px; max-width: 600px; max-height: 90vh; overflow-y: auto; - box-shadow: 0 8px 16px var(--bf-appearance-token-color-overlay-black-30); &__header { display: flex; diff --git a/src/web-ui/src/tools/git/components/GitDiffView/GitDiffView.scss b/src/web-ui/src/tools/git/components/GitDiffView/GitDiffView.scss index 73b4fbe8ef..e38325e2c0 100644 --- a/src/web-ui/src/tools/git/components/GitDiffView/GitDiffView.scss +++ b/src/web-ui/src/tools/git/components/GitDiffView/GitDiffView.scss @@ -1,4 +1,5 @@ @use '../../../../component-library/styles/tokens' as *; +@use '../../../../component-library/styles/overlay-surfaces' as surfaces; $size-gap-1-5: 6px !default; $size-radius-xs: 4px !default; @@ -390,13 +391,11 @@ $opacity-high: 0.8 !default; } &__commit-dialog { - background: var(--bf-appearance-token-color-bg-secondary); - border: 1px solid var(--bf-appearance-token-border-base); - border-radius: $size-radius-lg; + @include surfaces.dialog-surface; + padding: $size-gap-6; min-width: 500px; max-width: 600px; - box-shadow: 0 20px 25px -5px var(--bf-appearance-token-color-overlay-black-50), 0 10px 10px -5px var(--bf-appearance-token-color-overlay-black-40); animation: bitfun-git-diff-view-scale-in 0.2s ease-out; } diff --git a/src/web-ui/src/tools/git/components/PushButton/PushButton.scss b/src/web-ui/src/tools/git/components/PushButton/PushButton.scss index 09aa067c02..cbdaff9370 100644 --- a/src/web-ui/src/tools/git/components/PushButton/PushButton.scss +++ b/src/web-ui/src/tools/git/components/PushButton/PushButton.scss @@ -1,4 +1,5 @@ @use '../../../../component-library/styles/tokens' as *; +@use '../../../../component-library/styles/overlay-surfaces' as surfaces; .bitfun-push-button { --private-git-push-menu-text-rgb: 229, 229, 229; @@ -62,11 +63,9 @@ } &__menu { + @include surfaces.floating-surface; + min-width: 120px; - background: var(--bf-appearance-token-element-bg-subtle); - border: 1px solid var(--bf-appearance-token-border-base); - border-radius: $size-radius-base; - box-shadow: 0 8px 20px var(--bf-appearance-token-color-overlay-black-80); z-index: $z-dropdown; padding: 6px; animation: bitfun-push-button-menu-slide-in 0.15s ease-out; diff --git a/src/web-ui/src/tools/lsp/components/ReferencesPanel/ReferencesPanel.scss b/src/web-ui/src/tools/lsp/components/ReferencesPanel/ReferencesPanel.scss index 213cfb5da3..bf38005833 100644 --- a/src/web-ui/src/tools/lsp/components/ReferencesPanel/ReferencesPanel.scss +++ b/src/web-ui/src/tools/lsp/components/ReferencesPanel/ReferencesPanel.scss @@ -3,6 +3,7 @@ */ @use '@/component-library/styles/tokens.scss' as *; +@use '@/component-library/styles/overlay-surfaces' as surfaces; .references-panel-container { position: fixed; @@ -15,6 +16,8 @@ } .references-panel { + @include surfaces.floating-surface; + --references-panel-file-icon-rgb: 127, 219, 202; --references-panel-accent-shadow-rgb: 59, 130, 246; --references-panel-mark-rgb: 255, 215, 0; @@ -24,18 +27,6 @@ max-width: 90vw; min-width: 300px; - background: var(--bf-appearance-token-color-bg-primary); - border: 1px solid var(--bf-appearance-token-border-prominent); - border-radius: $size-radius-base; - - box-shadow: - var(--bf-appearance-token-shadow-xl), - 0 0 0 1px var(--bf-appearance-token-color-overlay-black-80), - $inner-glow-top; - - backdrop-filter: $blur-medium; - -webkit-backdrop-filter: $blur-medium; - overflow: hidden; pointer-events: all; diff --git a/tests/e2e/page-objects/components/SessionTree.ts b/tests/e2e/page-objects/components/SessionTree.ts index 5d629a7ee1..a6d2018a85 100644 --- a/tests/e2e/page-objects/components/SessionTree.ts +++ b/tests/e2e/page-objects/components/SessionTree.ts @@ -1,4 +1,4 @@ -import { $, $$ } from '@wdio/globals'; +import { $, $$, browser } from '@wdio/globals'; export interface RenderedSubagentIdentity { avatarId: string; @@ -8,17 +8,122 @@ export interface RenderedSubagentIdentity { } export class SessionTree { - private readonly triggerSelector = '[data-testid="flowchat-header-session-tree"]'; - private readonly panelSelector = '[data-bf-part="sessionTreePanel"]'; + private readonly overviewTriggerSelector = '[data-testid="flowchat-header-session-overview"]'; + private readonly overviewPanelSelector = '[data-testid="flowchat-header-session-overview-panel"]'; + private readonly panelSelector = '[data-testid="flowchat-header-session-tree-content"]'; + private readonly rightPanelTriggerSelector = '[data-testid="flowchat-header-right-panel"]'; + private readonly rightPanelSelector = '[data-testid="session-aux-pane"]'; + + async openOverview() { + await browser.waitUntil(async () => { + try { + const trigger = await $(this.overviewTriggerSelector); + if (!(await trigger.isClickable())) return false; + await trigger.click(); + return true; + } catch { + // Workspace/session hydration can replace the header node between lookup + // and click. Re-query the selector instead of retaining a stale handle. + return false; + } + }, { + timeout: 15000, + interval: 200, + timeoutMsg: 'The session overview trigger did not become clickable', + }); + await browser.waitUntil(async () => { + try { + return await $(this.overviewPanelSelector).isDisplayed(); + } catch { + return false; + } + }, { + timeout: 15000, + interval: 200, + timeoutMsg: 'The session overview panel did not become visible', + }); + return $(this.overviewPanelSelector); + } async open(): Promise { - const trigger = await $(this.triggerSelector); - await trigger.waitForClickable({ timeout: 15000 }); - await trigger.click(); + await this.openOverview(); const panel = await $(this.panelSelector); await panel.waitForDisplayed({ timeout: 15000 }); } + async closeOverview(): Promise { + const panel = await $(this.overviewPanelSelector); + if (!(await panel.isExisting())) return; + + const trigger = await $(this.overviewTriggerSelector); + await trigger.waitForClickable({ timeout: 5000 }); + await trigger.click(); + await browser.waitUntil(async () => { + try { + return !(await $(this.overviewPanelSelector).isExisting()); + } catch { + return true; + } + }, { + timeout: 5000, + interval: 100, + timeoutMsg: 'The session overview panel did not close', + }); + } + + async setRightPanelOpen(open: boolean): Promise { + await browser.waitUntil(async () => { + try { + return await $(this.rightPanelTriggerSelector).isClickable(); + } catch { + return false; + } + }, { + timeout: 15000, + interval: 200, + timeoutMsg: 'The session right-panel trigger did not become clickable', + }); + + const trigger = await $(this.rightPanelTriggerSelector); + const isOpen = (await trigger.getAttribute('aria-pressed')) === 'true'; + if (isOpen !== open) { + await trigger.click(); + } + + await browser.waitUntil(async () => { + try { + const currentTrigger = await $(this.rightPanelTriggerSelector); + const panel = await $(this.rightPanelSelector); + const isPressed = (await currentTrigger.getAttribute('aria-pressed')) === 'true'; + const isCollapsed = (await panel.getAttribute('class')) + ?.split(/\s+/) + .includes('bitfun-session-scene__aux-pane--collapsed') ?? false; + return isPressed === open && isCollapsed === !open; + } catch { + return false; + } + }, { + timeout: 10000, + interval: 100, + timeoutMsg: `The session right panel did not become ${open ? 'open' : 'collapsed'}`, + }); + } + + async getRightPanelControlState(): Promise<{ + open: boolean; + state: string | null; + hasOpenIcon: boolean; + hasCloseIcon: boolean; + }> { + const trigger = await $(this.rightPanelTriggerSelector); + return { + open: (await trigger.getAttribute('aria-pressed')) === 'true', + state: await trigger.getAttribute('data-bf-state'), + hasOpenIcon: await trigger.$('.lucide-panel-right-open').isExisting(), + hasCloseIcon: await trigger.$('.lucide-panel-right-close').isExisting(), + }; + } + async getSubagentIdentities(): Promise { const avatars = await $$(`${this.panelSelector} [data-bf-component="subagent-avatar"]`); const identities: RenderedSubagentIdentity[] = []; @@ -37,4 +142,53 @@ export class SessionTree { return identities; } + + async getBackgroundEmptyStateText(): Promise { + const emptyState = await $(`${this.overviewPanelSelector} [data-testid="flowchat-header-background-empty"]`); + await emptyState.waitForDisplayed({ timeout: 15000 }); + return (await emptyState.getText()).trim(); + } + + async waitForPullRequestOverviewState(): Promise<'loaded' | 'unavailable' | 'error'> { + const section = await $(`${this.overviewPanelSelector} [data-testid="flowchat-header-pull-requests"]`); + await browser.waitUntil(async () => { + const state = await section.getAttribute('data-bf-state'); + return state === 'loaded' || state === 'unavailable' || state === 'error'; + }, { + timeout: 30000, + interval: 250, + timeoutMsg: 'The pull request overview did not leave its loading state', + }); + return await section.getAttribute('data-bf-state') as 'loaded' | 'unavailable' | 'error'; + } + + async hasPullRequestEmptyState(): Promise { + return $( + `${this.overviewPanelSelector} [data-testid="flowchat-header-pull-requests-empty"]`, + ).isExisting(); + } + + async getPullRequestUnavailableStateText(): Promise { + const unavailableState = await $( + `${this.overviewPanelSelector} [data-testid="flowchat-header-pull-requests-unavailable"]`, + ); + await unavailableState.waitForDisplayed({ timeout: 15000 }); + return (await unavailableState.getText()).trim(); + } + + async getPullRequestItemCount(): Promise { + return (await $$( + `${this.overviewPanelSelector} [data-testid="flowchat-header-pull-request-item"]`, + )).length; + } + + async pullRequestItemsHaveDetailIndicators(): Promise { + const items = await $$( + `${this.overviewPanelSelector} [data-testid="flowchat-header-pull-request-item"]`, + ); + for (const item of items) { + if (!(await item.$('.lucide-chevron-right').isExisting())) return false; + } + return (await items.length) > 0; + } } diff --git a/tests/e2e/specs/l0-appearance.spec.ts b/tests/e2e/specs/l0-appearance.spec.ts index ce238df5a3..dd2324d130 100644 --- a/tests/e2e/specs/l0-appearance.spec.ts +++ b/tests/e2e/specs/l0-appearance.spec.ts @@ -3,6 +3,7 @@ */ import { browser, expect, $ } from '@wdio/globals'; +import { saveStepScreenshot } from '../helpers/screenshot-utils'; async function waitForDisplayed(selector: string, timeout = 15000) { const element = await $(selector); @@ -109,20 +110,328 @@ describe('L0 Appearance', () => { expect(appearanceStyles.accent).not.toBe(''); }); - it('should project the light primary background token onto the navigation', async () => { + it('should project the neutral and navy light palette into the native app', async () => { await selectAppearance('bitfun-light'); const lightNavigation = await browser.execute(() => { const styles = window.getComputedStyle(document.documentElement); const navPanel = document.querySelector('[data-testid="nav-panel"]'); return { - token: styles.getPropertyValue('--bf-appearance-token-color-bg-primary').trim(), + primary: styles.getPropertyValue('--bf-appearance-token-color-bg-primary').trim(), + scene: styles.getPropertyValue('--bf-appearance-token-color-bg-scene').trim(), + softSurface: styles.getPropertyValue('--bf-appearance-token-color-accent-100').trim(), + text: styles.getPropertyValue('--bf-appearance-token-color-text-primary').trim(), + mutedText: styles.getPropertyValue('--bf-appearance-token-color-text-muted').trim(), + accent: styles.getPropertyValue('--bf-appearance-token-color-accent-500').trim(), + primaryButton: styles.getPropertyValue('--bf-appearance-token-btn-primary-bg').trim(), + successBackground: styles.getPropertyValue('--bf-appearance-token-color-success-bg').trim(), + errorBackground: styles.getPropertyValue('--bf-appearance-token-color-error-bg').trim(), + border: styles.getPropertyValue('--bf-appearance-token-border-base').trim(), navBackground: navPanel ? window.getComputedStyle(navPanel).backgroundColor : null, }; }); - expect(lightNavigation.token.toLowerCase()).toBe('#fafafa'); - expect(lightNavigation.navBackground).toBe('rgb(250, 250, 250)'); + expect(lightNavigation).toMatchObject({ + primary: '#fdfdfd', + scene: '#ffffff', + softSurface: '#f3f3f5', + text: '#1c1c1f', + mutedText: '#6a6a6a', + accent: '#101a27', + primaryButton: '#101a27', + successBackground: '#e1fbe9', + errorBackground: 'rgba(167, 67, 82, 0.12)', + border: 'rgba(16, 26, 39, 0.15)', + navBackground: 'rgb(253, 253, 253)', + }); + }); + + it('should compile inverse new-session hover colors for the light appearance', async () => { + await selectAppearance('bitfun-light'); + + const hoverContract = await browser.execute(() => { + const styleRules: CSSStyleRule[] = []; + const collectStyleRules = (rules: CSSRuleList): void => { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSStyleRule) { + styleRules.push(rule); + } + if ('cssRules' in rule) { + collectStyleRules((rule as CSSGroupingRule).cssRules); + } + } + }; + + for (const styleSheet of Array.from(document.styleSheets)) { + try { + collectStyleRules(styleSheet.cssRules); + } catch { + // Ignore stylesheets that the WebView does not expose through CSSOM. + } + } + + const hasSelector = (rule: CSSStyleRule, selector: string): boolean => { + return rule.selectorText.split(',').some(candidate => candidate.trim() === selector); + }; + const buttonRule = styleRules.find(rule => hasSelector( + rule, + '.bitfun-nav-panel__utility-action:hover', + )); + const iconRule = styleRules.find(rule => hasSelector( + rule, + '.bitfun-nav-panel__utility-action:hover > svg', + )); + const rootStyle = window.getComputedStyle(document.documentElement); + + return { + appearanceMode: document.documentElement.getAttribute('data-bf-appearance-mode'), + textPrimary: rootStyle.getPropertyValue('--bf-appearance-token-color-text-primary').trim(), + scene: rootStyle.getPropertyValue('--bf-appearance-token-color-bg-scene').trim(), + buttonBackground: buttonRule?.style.background ?? null, + buttonBorder: buttonRule?.style.borderColor ?? null, + iconColor: iconRule?.style.color ?? null, + iconStroke: iconRule?.style.stroke ?? null, + }; + }); + + expect(hoverContract).toMatchObject({ + appearanceMode: 'light', + textPrimary: '#1c1c1f', + scene: '#ffffff', + buttonBackground: 'var(--bf-appearance-token-color-text-primary)', + buttonBorder: 'var(--bf-appearance-token-color-text-primary)', + iconColor: 'var(--bf-appearance-token-color-bg-scene)', + iconStroke: 'currentcolor', + }); + }); + + it('should lift only the scene viewport above the light navigation shell', async () => { + await selectAppearance('bitfun-light'); + await waitForDisplayed('.bitfun-workspace-body__scene-area'); + await waitForDisplayed('.bitfun-scene-viewport'); + + const sceneSurface = await browser.execute(() => { + const workbench = document.querySelector('.bitfun-workspace-body'); + const sceneArea = document.querySelector('.bitfun-workspace-body__scene-area'); + const viewport = document.querySelector('.bitfun-scene-viewport'); + + if (!workbench || !sceneArea || !viewport) { + return null; + } + + const workbenchStyle = window.getComputedStyle(workbench); + const sceneStyle = window.getComputedStyle(sceneArea); + const viewportStyle = window.getComputedStyle(viewport); + + return { + workbenchBackground: workbenchStyle.backgroundColor, + sceneBackground: sceneStyle.backgroundColor, + sceneBorderWidth: sceneStyle.borderTopWidth, + sceneRadius: sceneStyle.borderTopLeftRadius, + sceneShadow: sceneStyle.boxShadow, + viewportBackground: viewportStyle.backgroundColor, + viewportBorderWidth: viewportStyle.borderTopWidth, + viewportBorderColor: viewportStyle.borderTopColor, + viewportRadius: viewportStyle.borderTopLeftRadius, + viewportShadow: viewportStyle.boxShadow, + }; + }); + + expect(sceneSurface).not.toBeNull(); + expect(sceneSurface).toMatchObject({ + workbenchBackground: 'rgb(253, 253, 253)', + sceneBackground: 'rgba(0, 0, 0, 0)', + sceneBorderWidth: '0px', + sceneRadius: '0px', + sceneShadow: 'none', + viewportBackground: 'rgb(255, 255, 255)', + viewportBorderWidth: '1px', + viewportRadius: '12px', + }); + expect(sceneSurface?.viewportBorderColor).not.toBe('rgba(0, 0, 0, 0)'); + expect(sceneSurface?.viewportShadow).not.toBe('none'); + await saveStepScreenshot('l0-appearance-light-floating-scene'); + }); + + it('should keep Mini Apps and the selected navigation surface visually light', async () => { + await selectAppearance('bitfun-light'); + + const navBack = await waitForDisplayed( + '[data-bf-component="nav-bar"][data-bf-part="back"]:not(.is-inactive)', + ); + await navBack.click(); + + const miniAppsEntry = await waitForDisplayed('[data-testid="nav-miniapps-entry"]'); + await miniAppsEntry.click(); + await waitForDisplayed('[data-bf-scene="miniapp-gallery"]'); + await waitForDisplayed('.miniapp-gallery-scene__tabs .bitfun-tabs__tab--active'); + await waitForDisplayed('[data-bf-component="mini-app-card"]', 20000); + + const miniAppPresentation = await browser.execute(() => { + const selectedNavigation = document.querySelector('[data-testid="nav-miniapps-entry"]'); + const selectedTab = document.querySelector( + '.miniapp-gallery-scene__tabs .bitfun-tabs__tab--active', + ); + const scene = document.querySelector('[data-bf-scene="miniapp-gallery"]'); + const card = document.querySelector('[data-bf-component="mini-app-card"]'); + const cardFooter = card?.querySelector('.miniapp-card__footer') ?? null; + const cardPrimaryAction = card?.querySelector('.miniapp-card__action-btn--primary') ?? null; + const emptyRunningZone = document.querySelector( + '.miniapp-gallery__running-zone.is-empty', + ); + + return { + selectedNavigation: selectedNavigation + ? { + background: window.getComputedStyle(selectedNavigation).backgroundColor, + border: window.getComputedStyle(selectedNavigation).borderColor, + } + : null, + selectedTabBackground: selectedTab + ? window.getComputedStyle(selectedTab).backgroundColor + : null, + sceneBackground: scene ? window.getComputedStyle(scene).backgroundColor : null, + emptyRunningZoneHeight: emptyRunningZone?.getBoundingClientRect().height ?? null, + card: card + ? { + background: window.getComputedStyle(card).backgroundColor, + borderStyle: window.getComputedStyle(card).borderStyle, + footerBackground: cardFooter ? window.getComputedStyle(cardFooter).backgroundColor : null, + primaryActionBackground: cardPrimaryAction + ? window.getComputedStyle(cardPrimaryAction).backgroundColor + : null, + } + : null, + }; + }); + + expect(miniAppPresentation.selectedNavigation).toEqual({ + background: 'rgb(243, 243, 245)', + border: 'rgba(0, 0, 0, 0)', + }); + expect(miniAppPresentation.selectedTabBackground).toBe('rgb(243, 243, 245)'); + expect(miniAppPresentation.sceneBackground).toBe('rgb(255, 255, 255)'); + expect(miniAppPresentation.emptyRunningZoneHeight).not.toBeNull(); + expect(miniAppPresentation.emptyRunningZoneHeight!).toBeLessThanOrEqual(40); + expect(miniAppPresentation.card).toEqual({ + background: 'rgb(243, 243, 245)', + borderStyle: 'none', + footerBackground: 'rgba(0, 0, 0, 0)', + primaryActionBackground: 'rgb(16, 26, 39)', + }); + }); + + it('should render monochrome structural chrome against a white workspace', async () => { + await selectAppearance('bitfun-monochrome'); + await waitForDisplayed('[data-testid="nav-panel"]'); + await waitForDisplayed('[data-testid="settings-nav"]'); + await waitForDisplayed('.bitfun-scene-bar'); + await waitForDisplayed('.bitfun-scene-viewport'); + + const contrastPresentation = await browser.execute(() => { + const rootStyles = window.getComputedStyle(document.documentElement); + const bodyStyles = window.getComputedStyle(document.body); + const workbench = document.querySelector('.bitfun-workspace-body'); + const navPanel = document.querySelector('[data-testid="nav-panel"]'); + const settingsTitle = document.querySelector('.bitfun-settings-nav__title'); + const settingsActiveItem = document.querySelector('.bitfun-settings-nav__item.is-active'); + const settingsSectionBody = document.querySelector( + '[data-testid="appearance-settings-section"] .bitfun-config-page-section__body', + ); + const settingsRows = document.querySelectorAll( + '[data-testid="appearance-settings-section"] .bitfun-config-page-row', + ); + const settingsRowDivider = settingsRows.item(1); + const appearanceSelect = document.querySelector( + '[data-testid="appearance-palette-select"]', + ); + const sceneBar = document.querySelector('.bitfun-scene-bar'); + const viewport = document.querySelector('.bitfun-scene-viewport'); + + if ( + !workbench || + !navPanel || + !settingsTitle || + !settingsActiveItem || + !settingsSectionBody || + !settingsRowDivider || + !appearanceSelect || + !sceneBar || + !viewport + ) { + return null; + } + + const workbenchStyles = window.getComputedStyle(workbench); + const navStyles = window.getComputedStyle(navPanel); + const sceneBarStyles = window.getComputedStyle(sceneBar); + const viewportStyles = window.getComputedStyle(viewport); + + return { + appearanceId: document.documentElement.getAttribute('data-bf-appearance'), + appearanceMode: document.documentElement.getAttribute('data-bf-appearance-mode'), + contentBackgroundToken: rootStyles.getPropertyValue('--bf-appearance-token-color-bg-primary').trim(), + contentTextToken: rootStyles.getPropertyValue('--bf-appearance-token-color-text-primary').trim(), + contentSecondaryTextToken: rootStyles.getPropertyValue('--bf-appearance-token-color-text-secondary').trim(), + contentBorderToken: rootStyles.getPropertyValue('--bf-appearance-token-border-subtle').trim(), + contentSurfaceToken: rootStyles.getPropertyValue('--bf-appearance-token-element-bg-subtle').trim(), + configSectionBackgroundToken: rootStyles.getPropertyValue('--bf-appearance-token-config-page-section-bg').trim(), + configSectionBorderToken: rootStyles.getPropertyValue('--bf-appearance-token-config-page-section-border').trim(), + configSectionBorderWidthToken: rootStyles.getPropertyValue('--bf-appearance-token-config-page-section-border-width').trim(), + configDividerToken: rootStyles.getPropertyValue('--bf-appearance-token-config-page-divider').trim(), + chromeBackgroundToken: rootStyles.getPropertyValue('--bf-appearance-token-chrome-bg-primary').trim(), + chromeTextToken: rootStyles.getPropertyValue('--bf-appearance-token-chrome-text-primary').trim(), + bodyBackground: bodyStyles.backgroundColor, + workbenchBackground: workbenchStyles.backgroundColor, + navBackground: navStyles.backgroundColor, + navTextToken: navStyles.getPropertyValue('--bf-appearance-token-color-text-primary').trim(), + settingsTitleColor: window.getComputedStyle(settingsTitle).color, + settingsActiveItemColor: window.getComputedStyle(settingsActiveItem).color, + settingsSectionBackground: window.getComputedStyle(settingsSectionBody).backgroundColor, + settingsSectionBorder: window.getComputedStyle(settingsSectionBody).borderTopColor, + settingsSectionBorderWidth: window.getComputedStyle(settingsSectionBody).borderTopWidth, + settingsSectionShadow: window.getComputedStyle(settingsSectionBody).boxShadow, + settingsRowDivider: window.getComputedStyle(settingsRowDivider).borderTopColor, + appearanceSelectBorder: window.getComputedStyle(appearanceSelect).borderTopColor, + sceneBarTextToken: sceneBarStyles.getPropertyValue('--bf-appearance-token-color-text-primary').trim(), + viewportBackground: viewportStyles.backgroundColor, + viewportTextToken: viewportStyles.getPropertyValue('--bf-appearance-token-color-text-primary').trim(), + viewportRadius: viewportStyles.borderTopLeftRadius, + }; + }); + + expect(contrastPresentation).toEqual({ + appearanceId: 'bitfun-monochrome', + appearanceMode: 'light', + contentBackgroundToken: '#ffffff', + contentTextToken: '#1c1c1f', + contentSecondaryTextToken: '#555555', + contentBorderToken: 'rgba(16, 26, 39, 0.08)', + contentSurfaceToken: 'rgba(16, 26, 39, 0.03)', + configSectionBackgroundToken: '#f3f3f5', + configSectionBorderToken: 'transparent', + configSectionBorderWidthToken: '0', + configDividerToken: 'rgba(16, 26, 39, 0.08)', + chromeBackgroundToken: '#1c1c1f', + chromeTextToken: '#f3f3f5', + bodyBackground: 'rgb(28, 28, 31)', + workbenchBackground: 'rgb(28, 28, 31)', + navBackground: 'rgb(28, 28, 31)', + navTextToken: '#f3f3f5', + settingsTitleColor: 'rgb(243, 243, 245)', + settingsActiveItemColor: 'rgb(243, 243, 245)', + settingsSectionBackground: 'rgb(243, 243, 245)', + settingsSectionBorder: 'rgba(0, 0, 0, 0)', + settingsSectionBorderWidth: '0px', + settingsSectionShadow: 'none', + settingsRowDivider: 'rgba(16, 26, 39, 0.08)', + appearanceSelectBorder: 'rgba(16, 26, 39, 0.15)', + sceneBarTextToken: '#f3f3f5', + viewportBackground: 'rgb(255, 255, 255)', + viewportTextToken: '#1c1c1f', + viewportRadius: '12px', + }); + await saveStepScreenshot('l0-appearance-monochrome-contrast'); }); it('should expose the Appearance selector in settings', async () => { diff --git a/tests/e2e/specs/l0-navigation.spec.ts b/tests/e2e/specs/l0-navigation.spec.ts index 16996682e3..70be6cfc0f 100644 --- a/tests/e2e/specs/l0-navigation.spec.ts +++ b/tests/e2e/specs/l0-navigation.spec.ts @@ -84,6 +84,22 @@ describe('L0 Navigation Panel', () => { console.log('[L0] Navigation sections found:', sectionsExist); expect(sectionsExist).toBe(true); }); + + it('should not mark the primary assistant in the navigation', async function () { + if (!hasWorkspace) { + hasWorkspace = await openWorkspace(undefined, { requireWorkspaceLabel: false }); + } + expect(hasWorkspace).toBe(true); + + const assistantGroups = await browser.$$('.bitfun-nav-panel__assistant-item'); + expect(assistantGroups.length).toBeGreaterThan(0); + + for (const assistantGroup of assistantGroups) { + expect(await assistantGroup.$('.bitfun-nav-panel__assistant-item-badge').isExisting()).toBe(false); + } + + await saveStepScreenshot('l0-navigation-assistants-without-primary-badge'); + }); }); describe('Navigation interactivity', () => { @@ -178,6 +194,65 @@ describe('L0 Navigation Panel', () => { ); }); + it('should give peer session groups more breathing room than their nested rows', async function () { + if (!hasWorkspace) { + hasWorkspace = await openWorkspace(undefined, { requireWorkspaceLabel: false }); + } + expect(hasWorkspace).toBe(true); + + const viewToggle = await $('[data-testid="nav-workspace-session-view-toggle"]'); + await viewToggle.waitForDisplayed({ timeout: 10000 }); + if (await viewToggle.getAttribute('data-view-mode') === 'all') { + await viewToggle.click(); + } + await browser.waitUntil( + async () => await viewToggle.getAttribute('data-view-mode') === 'grouped', + { timeout: 5000, timeoutMsg: 'Session view did not enter grouped mode' }, + ); + + const spacing = await browser.execute(() => { + const list = document.querySelector( + '[data-testid="nav-workspace-list"][data-workspace-list="all"]', + ); + if (!list) return null; + + const groups = Array.from(list.querySelectorAll( + ':scope > [data-testid="nav-workspace-drop-target"]', + )).filter(group => group.getBoundingClientRect().height > 0); + const groupWithSession = groups.find(group => group.querySelector( + '[data-testid="nav-session-item"][data-session-level="0"]', + )); + const parentCard = groupWithSession?.querySelector( + ':scope > [data-testid="nav-workspace-item"] > [data-testid="nav-workspace-card"]', + ); + const firstSession = groupWithSession?.querySelector( + '[data-testid="nav-session-item"][data-session-level="0"]', + ); + + if (groups.length < 2 || !parentCard || !firstSession) return null; + + const firstGroupRect = groups[0].getBoundingClientRect(); + const secondGroupRect = groups[1].getBoundingClientRect(); + const parentRect = parentCard.getBoundingClientRect(); + const sessionRect = firstSession.getBoundingClientRect(); + + return { + configuredGroupGap: Number.parseFloat(window.getComputedStyle(list).rowGap), + measuredGroupGap: secondGroupRect.top - firstGroupRect.bottom, + parentChildGap: sessionRect.top - parentRect.bottom, + }; + }); + + expect(spacing).not.toBeNull(); + if (!spacing) return; + + console.log('[L0] Grouped session spacing:', spacing); + expect(spacing.configuredGroupGap).toBe(8); + expect(spacing.measuredGroupGap).toBeGreaterThanOrEqual(7.5); + expect(spacing.measuredGroupGap).toBeGreaterThan(spacing.parentChildGap); + await saveStepScreenshot('l0-navigation-group-spacing'); + }); + it('should align nested session titles with the active workspace or assistant label', async function () { expect(hasWorkspace).toBe(true); @@ -196,6 +271,81 @@ describe('L0 Navigation Panel', () => { await saveStepScreenshot('l0-navigation-grouped-session-alignment'); }); + it('should give the selected child session the full row without filling its parent', async function () { + expect(hasWorkspace).toBe(true); + + const activeGroup = await $('[data-testid="nav-workspace-item"][data-workspace-active="true"]'); + const activeSession = await activeGroup.$( + '[data-testid="nav-session-item"][data-session-level="0"]', + ); + await activeGroup.waitForDisplayed({ timeout: 10000 }); + await activeSession.waitForDisplayed({ timeout: 10000 }); + if (await activeSession.getAttribute('data-session-active') !== 'true') { + await activeSession.click(); + } + await browser.waitUntil( + async () => await activeSession.getAttribute('data-session-active') === 'true', + { timeout: 10000, timeoutMsg: 'Nested session did not become active' }, + ); + await browser.pause(200); + + const hierarchy = await browser.execute(() => { + const group = document.querySelector( + '[data-testid="nav-workspace-item"][data-workspace-active="true"]', + ); + const card = group?.querySelector( + ':scope > [data-testid="nav-workspace-card"]', + ); + const session = group?.querySelector( + '[data-testid="nav-session-item"][data-session-active="true"]', + ); + const groupLabel = card?.querySelector('[data-bf-part="label"]'); + const sessionLabel = session?.querySelector( + '.bitfun-nav-panel__inline-item-label', + ); + + if (!card || !session || !groupLabel || !sessionLabel) { + return null; + } + + const cardRect = card.getBoundingClientRect(); + const sessionRect = session.getBoundingClientRect(); + const selectedBackgroundProbe = document.createElement('span'); + selectedBackgroundProbe.style.backgroundColor = 'var(--bf-appearance-token-element-bg-soft)'; + session.append(selectedBackgroundProbe); + const selectedBackground = window.getComputedStyle(selectedBackgroundProbe).backgroundColor; + selectedBackgroundProbe.remove(); + + return { + cardBackground: window.getComputedStyle(card).backgroundColor, + cardLabelWeight: window.getComputedStyle(groupLabel).fontWeight, + sessionBackground: window.getComputedStyle(session).backgroundColor, + sessionLabelWeight: window.getComputedStyle(sessionLabel).fontWeight, + selectedBackground, + cardLeft: cardRect.left, + cardRight: cardRect.right, + cardHeight: cardRect.height, + sessionLeft: sessionRect.left, + sessionRight: sessionRect.right, + sessionHeight: sessionRect.height, + }; + }); + + expect(hierarchy).not.toBeNull(); + if (!hierarchy) { + return; + } + + expect(Math.abs(hierarchy.sessionLeft - hierarchy.cardLeft)).toBeLessThanOrEqual(1); + expect(Math.abs(hierarchy.sessionRight - hierarchy.cardRight)).toBeLessThanOrEqual(1); + expect(hierarchy.sessionHeight).toBeLessThanOrEqual(hierarchy.cardHeight); + expect(hierarchy.sessionBackground).toBe(hierarchy.selectedBackground); + expect(hierarchy.sessionLabelWeight).toBe('600'); + expect(hierarchy.cardBackground).toBe('rgba(0, 0, 0, 0)'); + expect(hierarchy.cardLabelWeight).toBe('500'); + await saveStepScreenshot('l0-navigation-selected-child-full-width'); + }); + it('should align extension child icons with the parent label', async function () { expect(hasWorkspace).toBe(true); @@ -321,7 +471,10 @@ describe('L0 Navigation Panel', () => { await backdrop.waitForExist({ reverse: true, timeout: 2000 }); }); - it('should keep the session divider full-width and remove the footer divider', async function () { + it('should keep the session and footer dividers full-width', async function () { + if (!hasWorkspace) { + hasWorkspace = await openWorkspace(undefined, { requireWorkspaceLabel: false }); + } expect(hasWorkspace).toBe(true); const dividerLayout = await browser.execute(() => { @@ -337,6 +490,7 @@ describe('L0 Navigation Panel', () => { const panelRect = panel.getBoundingClientRect(); const sectionsRect = sections.getBoundingClientRect(); const topActionsRect = topActions.getBoundingClientRect(); + const footerRect = footer.getBoundingClientRect(); const dividerStyle = window.getComputedStyle(topActions, '::after'); const stickyDividerStyle = window.getComputedStyle(stickyHeader, '::after'); const footerStyle = window.getComputedStyle(footer); @@ -356,6 +510,8 @@ describe('L0 Navigation Panel', () => { stickyDividerBorderWidth: stickyDividerStyle.borderBottomWidth, footerBorderStyle: footerStyle.borderTopStyle, footerBorderWidth: footerStyle.borderTopWidth, + footerLeft: footerRect.left, + footerRight: footerRect.right, }; }); @@ -371,9 +527,12 @@ describe('L0 Navigation Panel', () => { expect(dividerLayout.dividerRight).toBeGreaterThanOrEqual(dividerLayout.sectionsRight - 1); expect(dividerLayout.stickyDividerBorderStyle).toBe('dashed'); expect(dividerLayout.stickyDividerBorderWidth).toBe('1px'); - expect(dividerLayout.footerBorderStyle).toBe('none'); - expect(dividerLayout.footerBorderWidth).toBe('0px'); + expect(dividerLayout.footerBorderStyle).toBe('dashed'); + expect(dividerLayout.footerBorderWidth).toBe('1px'); + expect(Math.abs(dividerLayout.footerLeft - dividerLayout.panelLeft)).toBeLessThanOrEqual(1); + expect(dividerLayout.footerRight).toBeGreaterThanOrEqual(dividerLayout.panelRight - 1); console.log('[L0] Navigation divider layout:', dividerLayout); + await saveStepScreenshot('l0-navigation-footer-divider'); }); it('should align navigation scroll and resize controls with the scene border', async function () { diff --git a/tests/e2e/specs/l0-open-settings.spec.ts b/tests/e2e/specs/l0-open-settings.spec.ts index 31261fb8a3..52ab454e2b 100644 --- a/tests/e2e/specs/l0-open-settings.spec.ts +++ b/tests/e2e/specs/l0-open-settings.spec.ts @@ -45,6 +45,49 @@ describe('L0 Settings Panel', () => { expect(settingsButtonVisible).toBe(true); await saveStepScreenshot('l0-settings-footer-entry'); }); + + it('should align upper and lower main navigation item typography', async function () { + expect(hasWorkspace).toBe(true); + + const comparison = await browser.execute(() => { + const upperItem = document.querySelector( + '[data-testid="nav-assistant-manager"]', + ); + const lowerItem = document.querySelector( + '[data-testid="nav-session-item"]:not([data-session-active="true"]) .bitfun-nav-panel__inline-item-label', + ); + + if (!upperItem || !lowerItem) { + return null; + } + + const typeStyle = (element: HTMLElement) => { + const style = window.getComputedStyle(element); + return { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontStyle: style.fontStyle, + fontWeight: style.fontWeight, + letterSpacing: style.letterSpacing, + lineHeight: style.lineHeight, + textTransform: style.textTransform, + }; + }; + + return { + upper: typeStyle(upperItem), + lower: typeStyle(lowerItem), + }; + }); + + expect(comparison).not.toBeNull(); + if (!comparison) { + return; + } + + expect(comparison.upper).toEqual(comparison.lower); + await saveStepScreenshot('l0-main-navigation-typography-aligned'); + }); }); describe('Settings panel interaction', () => { @@ -72,6 +115,80 @@ describe('L0 Settings Panel', () => { await saveStepScreenshot('l0-settings-panel-opened'); } }); + + it('should match the main navigation typography and row rhythm', async function () { + expect(hasWorkspace).toBe(true); + + const comparison = await browser.execute(() => { + const mainItem = document.querySelector( + '[data-testid="nav-assistant-manager"]', + ); + const settingsItem = document.querySelector( + '.bitfun-settings-nav__item:not(.is-active)', + ); + const lowerItem = document.querySelector( + '[data-testid="nav-session-item"]:not([data-session-active="true"]) .bitfun-nav-panel__inline-item-label', + ); + const activeSettingsItem = document.querySelector( + '.bitfun-settings-nav__item.is-active', + ); + const mainCategory = document.querySelector( + '.bitfun-nav-panel__section-label', + ); + const settingsCategory = document.querySelector( + '.bitfun-settings-nav__category-label', + ); + + if (!mainItem || !settingsItem || !lowerItem || !activeSettingsItem || !mainCategory || !settingsCategory) { + return null; + } + + const typeStyle = (element: HTMLElement) => { + const style = window.getComputedStyle(element); + return { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontStyle: style.fontStyle, + fontWeight: style.fontWeight, + letterSpacing: style.letterSpacing, + lineHeight: style.lineHeight, + textTransform: style.textTransform, + }; + }; + const rowStyle = (element: HTMLElement) => { + const style = window.getComputedStyle(element); + return { + borderRadius: style.borderRadius, + height: element.getBoundingClientRect().height, + paddingLeft: style.paddingLeft, + paddingRight: style.paddingRight, + }; + }; + + return { + mainItemType: typeStyle(mainItem), + settingsItemType: typeStyle(settingsItem), + lowerItemType: typeStyle(lowerItem), + activeSettingsWeight: window.getComputedStyle(activeSettingsItem).fontWeight, + mainCategoryType: typeStyle(mainCategory), + settingsCategoryType: typeStyle(settingsCategory), + mainRow: rowStyle(mainItem), + settingsRow: rowStyle(settingsItem), + }; + }); + + expect(comparison).not.toBeNull(); + if (!comparison) { + return; + } + + expect(comparison.mainItemType).toEqual(comparison.lowerItemType); + expect(comparison.settingsItemType).toEqual(comparison.lowerItemType); + expect(comparison.settingsCategoryType).toEqual(comparison.mainCategoryType); + expect(comparison.activeSettingsWeight).toBe('600'); + expect(comparison.settingsRow).toEqual(comparison.mainRow); + await saveStepScreenshot('l0-settings-navigation-aligned'); + }); }); describe('UI stability after settings interaction', () => { diff --git a/tests/e2e/specs/l0-popup-surface-consistency.spec.ts b/tests/e2e/specs/l0-popup-surface-consistency.spec.ts new file mode 100644 index 0000000000..5493ea48d0 --- /dev/null +++ b/tests/e2e/specs/l0-popup-surface-consistency.spec.ts @@ -0,0 +1,171 @@ +/** + * Native Desktop coverage for the two popup surface contracts. + * + * The device overview is the floating reference surface. The About dialog is + * the centered dialog reference surface. Border, radius, and shadow must stay + * identical, while the centered dialog background must remain fully opaque. + */ + +import { $, browser, expect } from '@wdio/globals'; +import { openWorkspace } from '../helpers/workspace-helper'; +import { saveElementScreenshot, saveStepScreenshot } from '../helpers/screenshot-utils'; + +interface PopupChrome { + backgroundColor: string; + borderBottomColor: string; + borderBottomLeftRadius: string; + borderBottomRightRadius: string; + borderBottomStyle: string; + borderBottomWidth: string; + borderLeftColor: string; + borderLeftStyle: string; + borderLeftWidth: string; + borderRightColor: string; + borderRightStyle: string; + borderRightWidth: string; + borderTopColor: string; + borderTopLeftRadius: string; + borderTopRightRadius: string; + borderTopStyle: string; + borderTopWidth: string; + boxShadow: string; +} + +async function ensureLightAppearance(): Promise { + const isLight = await browser.execute(() => ( + document.documentElement.getAttribute('data-bf-appearance') === 'bitfun-light' + )); + if (isLight) return; + + const settings = await $('[data-testid="nav-footer-settings-item"]'); + await settings.click(); + const themeConfiguration = await $('[data-testid="nav-settings-theme-item"]'); + await themeConfiguration.waitForDisplayed({ timeout: 10_000 }); + await themeConfiguration.click(); + + const picker = await $('[data-testid="appearance-palette-select"]'); + await picker.waitForDisplayed({ timeout: 10_000 }); + await picker.click(); + const lightOption = await $( + '[data-testid="appearance-palette-option"][data-appearance-id="bitfun-light"]', + ); + await lightOption.waitForDisplayed({ timeout: 10_000 }); + await lightOption.click(); + + await browser.waitUntil(async () => browser.execute(() => ( + document.documentElement.getAttribute('data-bf-appearance') === 'bitfun-light' + )), { + timeout: 10_000, + timeoutMsg: 'The native app did not switch to the light Appearance', + }); +} + +async function readPopupChrome(selector: string): Promise { + const chrome = await browser.execute((surfaceSelector: string) => { + const element = document.querySelector(surfaceSelector); + if (!element) return null; + const style = window.getComputedStyle(element); + return { + backgroundColor: style.backgroundColor, + borderBottomColor: style.borderBottomColor, + borderBottomLeftRadius: style.borderBottomLeftRadius, + borderBottomRightRadius: style.borderBottomRightRadius, + borderBottomStyle: style.borderBottomStyle, + borderBottomWidth: style.borderBottomWidth, + borderLeftColor: style.borderLeftColor, + borderLeftStyle: style.borderLeftStyle, + borderLeftWidth: style.borderLeftWidth, + borderRightColor: style.borderRightColor, + borderRightStyle: style.borderRightStyle, + borderRightWidth: style.borderRightWidth, + borderTopColor: style.borderTopColor, + borderTopLeftRadius: style.borderTopLeftRadius, + borderTopRightRadius: style.borderTopRightRadius, + borderTopStyle: style.borderTopStyle, + borderTopWidth: style.borderTopWidth, + boxShadow: style.boxShadow, + }; + }, selector); + + expect(chrome).not.toBeNull(); + return chrome as PopupChrome; +} + +async function readBackgroundAlpha(selector: string): Promise { + const alpha = await browser.execute((surfaceSelector: string) => { + const element = document.querySelector(surfaceSelector); + if (!element) return null; + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext('2d'); + if (!context) return null; + context.clearRect(0, 0, 1, 1); + context.fillStyle = 'rgba(0, 0, 0, 0)'; + context.fillStyle = window.getComputedStyle(element).backgroundColor; + context.fillRect(0, 0, 1, 1); + return context.getImageData(0, 0, 1, 1).data[3]; + }, selector); + + expect(alpha).not.toBeNull(); + return alpha as number; +} + +describe('L0 Popup Surface Consistency', () => { + it('keeps the shared frame and makes the About dialog opaque', async () => { + expect(await openWorkspace(undefined, { requireWorkspaceLabel: false })).toBe(true); + await ensureLightAppearance(); + + const deviceTrigger = await $('[data-testid="nav-footer-device-status"]'); + await deviceTrigger.waitForDisplayed({ timeout: 15_000 }); + if ((await deviceTrigger.getAttribute('aria-expanded')) !== 'true') { + await deviceTrigger.click(); + } + + const deviceOverview = await $('[data-testid="nav-device-status-popover"]'); + await deviceOverview.waitForDisplayed({ timeout: 10_000 }); + const deviceChrome = await readPopupChrome('[data-testid="nav-device-status-popover"]'); + + expect(deviceChrome.borderTopWidth).toBe('1px'); + expect(deviceChrome.borderTopStyle).toBe('solid'); + expect(deviceChrome.borderTopLeftRadius).not.toBe('0px'); + expect(deviceChrome.boxShadow).not.toBe('none'); + await saveElementScreenshot( + '[data-testid="nav-device-status-popover"]', + 'l0-popup-surface-device-reference', + ); + + const deviceBackdrop = await $('[data-testid="nav-device-status-backdrop"]'); + await deviceBackdrop.click(); + await deviceOverview.waitForExist({ reverse: true, timeout: 5_000 }); + + const settings = await $('[data-testid="nav-footer-settings-item"]'); + await settings.click(); + const aboutEntry = await $('[data-testid="nav-settings-about-item"]'); + await aboutEntry.waitForDisplayed({ timeout: 10_000 }); + await aboutEntry.click(); + + const aboutDialog = await $('[data-testid="about-dialog-modal"]'); + await aboutDialog.waitForDisplayed({ timeout: 10_000 }); + const dialogChrome = await readPopupChrome('[data-testid="about-dialog-modal"]'); + const { backgroundColor: deviceBackground, ...deviceFrame } = deviceChrome; + const { backgroundColor: dialogBackground, ...dialogFrame } = dialogChrome; + expect(dialogFrame).toEqual(deviceFrame); + expect(dialogBackground).not.toBe(deviceBackground); + expect(await readBackgroundAlpha('[data-testid="about-dialog-modal"]')).toBe(255); + + const contentChrome = await readPopupChrome( + '[data-bf-component="about-dialog"][data-bf-part="root"]', + ); + expect(contentChrome.backgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(contentChrome.borderTopWidth).toBe('0px'); + expect(contentChrome.borderTopLeftRadius).toBe('0px'); + expect(contentChrome.boxShadow).toBe('none'); + + await saveElementScreenshot( + '[data-testid="about-dialog-modal"]', + 'l0-popup-surface-about-dialog', + ); + await saveStepScreenshot('l0-popup-surface-consistency'); + }); +}); diff --git a/tests/e2e/specs/l1-subagent-identity.spec.ts b/tests/e2e/specs/l1-subagent-identity.spec.ts index 940db7bebe..6549f31633 100644 --- a/tests/e2e/specs/l1-subagent-identity.spec.ts +++ b/tests/e2e/specs/l1-subagent-identity.spec.ts @@ -7,9 +7,13 @@ import { browser, expect, $ } from '@wdio/globals'; import { randomUUID } from 'crypto'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { Header } from '../page-objects/components/Header'; import { SessionTree } from '../page-objects/components/SessionTree'; -import { getWorkspaceState } from '../helpers/workspace-helper'; +import { getWorkspaceState, openWorkspaceThroughFrontend } from '../helpers/workspace-helper'; +import { saveElementScreenshot } from '../helpers/screenshot-utils'; type InvokeOutcome = | { ok: true; value: T } @@ -108,16 +112,32 @@ describe('L1 Subagent identity', () => { createdSessionIds.push(child.sessionId); } - await invoke('start_dialog_turn', { - sessionId: rootSessionId, - userInput: 'Verify the subagent identity presentation.', - turnId: randomUUID(), - execution: { kind: 'standard' }, - agentType: 'agentic', - workspacePath, + const fixtureTimestamp = Date.now(); + await invoke('save_session_turn', { + turn_data: { + turnId: randomUUID(), + turnIndex: 0, + sessionId: rootSessionId, + timestamp: fixtureTimestamp, + kind: 'local_command', + agentType: 'agentic', + userMessage: { + id: randomUUID(), + content: '# Session overview E2E fixture', + timestamp: fixtureTimestamp, + metadata: { + localCommandKind: 'usage_report', + }, + }, + modelRounds: [], + startTime: fixtureTimestamp, + endTime: fixtureTimestamp, + durationMs: 0, + status: 'completed', + hasFinalResponse: false, + }, + workspace_path: workspacePath, }); - await browser.pause(250); - await invokeOutcome('cancel_session', { sessionId: rootSessionId }); await browser.refresh(); await header.waitForLoad(); @@ -126,13 +146,42 @@ describe('L1 Subagent identity', () => { await rootSession.click(); }); - it('shows stable, non-repeating avatars and localized names in the Agent tree', async () => { - await sessionTree.open(); + it('shows one default status list with the real Agent tree', async () => { + const overviewPanel = await sessionTree.openOverview(); + const overviewItems = await overviewPanel.$$('[data-bf-part="sessionOverviewItem"]'); + expect(overviewItems).toHaveLength(3); + expect(await overviewItems[0].getText()).toMatch(/Agents/); + expect(await overviewItems[1].getText()).toMatch(/后台终端|背景終端|Background terminals/); + expect(await overviewItems[2].getText()).toMatch(/拉取请求|拉取請求|Pull requests/); + expect((await overviewItems[2].getCSSProperty('border-bottom-width')).value).toBe('0px'); + expect(await overviewPanel.$('[data-testid="flowchat-header-session-overview-back"]').isExisting()).toBe(false); + expect(await sessionTree.getBackgroundEmptyStateText()).toMatch( + /暂无后台终端|目前沒有背景終端|No background terminals/, + ); await browser.waitUntil(async () => (await sessionTree.getSubagentIdentities()).length === 3, { timeout: 15000, interval: 250, - timeoutMsg: 'The Agent tree did not render all persisted subagent identities', + timeoutMsg: 'The default status list did not render all persisted subagent identities', }); + const agentRows = await overviewPanel.$$('.session-tree-popover__node--subagent'); + expect(agentRows).toHaveLength(3); + for (const agentRow of agentRows) { + expect(await agentRow.getSize('height')).toBeLessThanOrEqual(34); + expect((await agentRow.$('.session-tree-popover__node-copy') + .getCSSProperty('flex-direction')).value).toBe('row'); + } + const pullRequestState = await sessionTree.waitForPullRequestOverviewState(); + if (pullRequestState === 'loaded') { + const pullRequestItemCount = await sessionTree.getPullRequestItemCount(); + expect((await sessionTree.hasPullRequestEmptyState()) || pullRequestItemCount > 0).toBe(true); + if (pullRequestItemCount > 0) { + expect(await sessionTree.pullRequestItemsHaveDetailIndicators()).toBe(true); + } + } + await saveElementScreenshot( + '[data-testid="flowchat-header-session-overview-panel"]', + 'l1-session-overview-list', + ); const identities = await sessionTree.getSubagentIdentities(); expect(identities).toHaveLength(3); @@ -146,6 +195,115 @@ describe('L1 Subagent identity', () => { } }); + it('opens the session right panel from the header control', async () => { + await sessionTree.closeOverview(); + await sessionTree.setRightPanelOpen(false); + expect(await sessionTree.getRightPanelControlState()).toEqual({ + open: false, + state: 'collapsed', + hasOpenIcon: true, + hasCloseIcon: false, + }); + + try { + await sessionTree.setRightPanelOpen(true); + expect(await sessionTree.getRightPanelControlState()).toEqual({ + open: true, + state: 'open', + hasOpenIcon: false, + hasCloseIcon: true, + }); + await saveElementScreenshot( + '[data-testid="session-scene"]', + 'l1-session-right-panel-control', + ); + } finally { + await sessionTree.setRightPanelOpen(false); + } + }); + + it('shows pull requests as unavailable in a real non-Git workspace', async () => { + const nonGitWorkspacePath = await mkdtemp(join(tmpdir(), 'bitfun-e2e-non-git-')); + const nonGitSessionId = randomUUID(); + + try { + await openWorkspaceThroughFrontend(nonGitWorkspacePath); + expect(await invoke('git_is_repository', { + repositoryPath: nonGitWorkspacePath, + })).toBe(false); + + await invoke('create_session', { + sessionId: nonGitSessionId, + sessionName: 'Non-Git session overview E2E', + agentType: 'agentic', + workspacePath: nonGitWorkspacePath, + sessionKind: 'standard', + }); + + const fixtureTimestamp = Date.now(); + await invoke('save_session_turn', { + turn_data: { + turnId: randomUUID(), + turnIndex: 0, + sessionId: nonGitSessionId, + timestamp: fixtureTimestamp, + kind: 'local_command', + agentType: 'agentic', + userMessage: { + id: randomUUID(), + content: '# Non-Git session overview E2E fixture', + timestamp: fixtureTimestamp, + metadata: { + localCommandKind: 'usage_report', + }, + }, + modelRounds: [], + startTime: fixtureTimestamp, + endTime: fixtureTimestamp, + durationMs: 0, + status: 'completed', + hasFinalResponse: false, + }, + workspace_path: nonGitWorkspacePath, + }); + + await browser.refresh(); + await header.waitForLoad(); + const nonGitSession = await $(`[data-testid="nav-session-item"][data-session-id="${nonGitSessionId}"]`); + await nonGitSession.waitForClickable({ timeout: 20000 }); + await nonGitSession.click(); + + await sessionTree.openOverview(); + expect(await sessionTree.waitForPullRequestOverviewState()).toBe('unavailable'); + expect(await sessionTree.getPullRequestUnavailableStateText()).toMatch( + /当前工作区不是 Git 仓库|目前工作區不是 Git 倉庫|This workspace is not a Git repository/, + ); + await saveElementScreenshot( + '[data-testid="flowchat-header-session-overview-panel"]', + 'l1-session-overview-non-git', + ); + } finally { + await invokeOutcome('archive_session', { + session_id: nonGitSessionId, + workspace_path: nonGitWorkspacePath, + }); + await invokeOutcome('delete_session', { + sessionId: nonGitSessionId, + workspacePath: nonGitWorkspacePath, + }); + try { + await openWorkspaceThroughFrontend(workspacePath); + } finally { + await rm(nonGitWorkspacePath, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100, + }); + } + } + }); + after(async () => { for (const sessionId of [...createdSessionIds].reverse()) { await invokeOutcome('archive_session', {