diff --git a/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx b/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx index cb79ef299a..2a401cad10 100644 --- a/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx +++ b/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx @@ -171,9 +171,13 @@ export function NestedMenuPattern() { const [position, setPosition] = useState<{ x: number; y: number } | undefined>(); const [lastAction, setLastAction] = useState(""); const [pinned, setPinned] = useState(false); + const [scrolling, setScrolling] = useState(false); return { event.preventDefault(); setPosition({ x: event.clientX, y: event.clientY }); setOpen(true); }}> + + + setOpen(false)} anchorRef={position ? undefined : anchorRef} position={position} items={[ { id: "open", label: t("patterns.actions.openFiles"), icon: , shortcut: Ctrl O, onSelect: () => setLastAction(t("patterns.actions.openFiles")) }, { id: "tools", label: t("patterns.navigation.tools"), icon: , submenu: [ @@ -185,6 +189,7 @@ export function NestedMenuPattern() { ] }, { id: "separator", label: "", separator: true }, { id: "pin", label: t("patterns.menu.pin"), role: "menuitemcheckbox", checked: pinned, icon: , onSelect: () => { setPinned(!pinned); setLastAction(t("patterns.menu.pin")); } }, + ...(scrolling ? Array.from({ length: 18 }, (_, index) => ({ id: `file-${index}`, label: `${t("patterns.actions.openFiles")} — workspace-${index + 1}`, icon: , onSelect: () => setLastAction(`workspace-${index + 1}`) })) : []), ]} />

{lastAction ? t("patterns.menu.lastAction", { action: lastAction }) : t("patterns.menu.keyboard")}

; diff --git a/design-system/packages/design-tokens/src/system.tokens.json b/design-system/packages/design-tokens/src/system.tokens.json index 20c9233835..2b56f707da 100644 --- a/design-system/packages/design-tokens/src/system.tokens.json +++ b/design-system/packages/design-tokens/src/system.tokens.json @@ -169,6 +169,10 @@ "letterSpacing": { "$value": "{letterSpacing.normal}" } } }, + "actionRow": { + "$description": "Single-line action leading: 16px at the default 13px type size, scaling with user typography.", + "lineHeight": { "$type": "number", "$value": 1.2307692307692308 } + }, "fieldHelper": { "$description": "Field helper leading: 16px at the default 11px type size, scaling with user typography.", "lineHeight": { "$type": "number", "$value": 1.4545454545454546 } @@ -724,7 +728,7 @@ "surfacePadding": { "$value": "{space.2}" }, "contentGap": { "$value": "{space.4}" }, "sectionGap": { "$value": "{space.1}" }, - "headingHeight": { "$value": "22px" }, + "headingHeight": { "$value": "24px" }, "headingPaddingInline": { "$value": "{space.2}" }, "headingGap": { "$value": "{space.5}" }, "headingActionSize": { "$value": "22px" }, diff --git a/design-system/packages/design-tokens/tests/contract.test.mjs b/design-system/packages/design-tokens/tests/contract.test.mjs index 5d811fcb00..75350bce22 100644 --- a/design-system/packages/design-tokens/tests/contract.test.mjs +++ b/design-system/packages/design-tokens/tests/contract.test.mjs @@ -227,7 +227,7 @@ test("NavigationPanel tokens preserve the grouped sidebar composition contract", const systemDocument = await readSource("system.tokens.json"); assert.equal(tokens["layout.navigationPanel.inlineSize"], "216px"); - assert.equal(tokens["layout.navigationPanel.headingHeight"], "22px"); + assert.equal(tokens["layout.navigationPanel.headingHeight"], "24px"); assert.equal(tokens["layout.navigationPanel.itemHeight"], "30px"); assert.equal(tokens["layout.navigationPanel.itemIconSize"], "14px"); assert.equal(tokens["layout.navigationPanel.footerHeight"], "40px"); diff --git a/design-system/packages/theme-openbitfun/README.md b/design-system/packages/theme-openbitfun/README.md index 4afc95dbd8..ad49098403 100644 --- a/design-system/packages/theme-openbitfun/README.md +++ b/design-system/packages/theme-openbitfun/README.md @@ -69,3 +69,11 @@ component token is absent, and keeps explicit component overrides intact. - `color.codeChange.added` (`#1aa73e`) and `color.codeChange.removed` (`#ec221f`) also anchor success and danger emphasis. Warning emphasis uses `#ff8c00`; information uses the existing creative-action blue (`#2e7eff`). These clear hues share light tints instead of separate per-component palettes. - `color.status.*.emphasis` colors icons and short emphasis. `content` derives a readable shade from that anchor for text; `surface` and `border` derive 10% and 30% tints. High-contrast themes may strengthen text contrast without changing the emphasis anchors. - Status source tokens retain their `color-mix()` references. The theme build resolves these mixes to concrete hex/RGBA values so CSS, plugins, and renderer payloads consume the same palette without relying on renderer-specific CSS color support. + +`color.content.caption` distinguishes low-emphasis menu/navigation group headings +from body descriptions and input placeholders. Light mode supplies final black +40%; dark and high-contrast modes reuse readable muted content. An imported +appearance's explicit old muted color is retained when the caption token is absent. +The built-in light Appearance preserves the public neutral action content (80%) +in both root and chrome; generic palette projection previously reduced product +menu labels to secondary text (60%). Explicit imported action colors still win. diff --git a/design-system/packages/theme-openbitfun/src/dark.tokens.json b/design-system/packages/theme-openbitfun/src/dark.tokens.json index ec8d286684..035d9143ce 100644 --- a/design-system/packages/theme-openbitfun/src/dark.tokens.json +++ b/design-system/packages/theme-openbitfun/src/dark.tokens.json @@ -64,6 +64,7 @@ "thumbHover": { "$type": "color", "$value": "{color.action.neutral.surfacePressed}" } }, "content": { + "caption": { "$description": "Menu and navigation group captions; carries final opacity without attenuating primary text again.", "$type": "color", "$value": "{color.content.muted}" }, "primary": { "$type": "color", "$value": "{ref.color.neutral.200}" }, "secondary": { "$type": "color", "$value": "{ref.color.neutral.350}" }, "muted": { "$type": "color", "$value": "{ref.color.neutral.550}" }, diff --git a/design-system/packages/theme-openbitfun/src/high-contrast-dark.tokens.json b/design-system/packages/theme-openbitfun/src/high-contrast-dark.tokens.json index 30aa15e486..68b3d0f84e 100644 --- a/design-system/packages/theme-openbitfun/src/high-contrast-dark.tokens.json +++ b/design-system/packages/theme-openbitfun/src/high-contrast-dark.tokens.json @@ -24,6 +24,7 @@ "panel": { "$type": "color", "$value": "{ref.color.gray.900}" } }, "content": { + "caption": { "$description": "Menu and navigation group captions; carries final opacity without attenuating primary text again.", "$type": "color", "$value": "{color.content.muted}" }, "primary": { "$type": "color", "$value": "{ref.color.gray.0}" }, "secondary": { "$type": "color", "$value": "{ref.color.gray.50}" }, "muted": { "$type": "color", "$value": "{ref.color.gray.150}" } diff --git a/design-system/packages/theme-openbitfun/src/high-contrast-light.tokens.json b/design-system/packages/theme-openbitfun/src/high-contrast-light.tokens.json index 0a66e1552f..2f85b85a3c 100644 --- a/design-system/packages/theme-openbitfun/src/high-contrast-light.tokens.json +++ b/design-system/packages/theme-openbitfun/src/high-contrast-light.tokens.json @@ -24,6 +24,7 @@ "panel": { "$type": "color", "$value": "{ref.color.gray.0}" } }, "content": { + "caption": { "$description": "Menu and navigation group captions; carries final opacity without attenuating primary text again.", "$type": "color", "$value": "{color.content.muted}" }, "primary": { "$type": "color", "$value": "{ref.color.gray.1000}" }, "secondary": { "$type": "color", "$value": "{ref.color.gray.800}" }, "muted": { "$type": "color", "$value": "{ref.color.gray.700}" } diff --git a/design-system/packages/theme-openbitfun/src/light.tokens.json b/design-system/packages/theme-openbitfun/src/light.tokens.json index 1578ff5c16..3ec1cdf62a 100644 --- a/design-system/packages/theme-openbitfun/src/light.tokens.json +++ b/design-system/packages/theme-openbitfun/src/light.tokens.json @@ -64,6 +64,7 @@ "thumbHover": { "$type": "color", "$value": "{color.action.neutral.surfacePressed}" } }, "content": { + "caption": { "$description": "Menu and navigation group captions; carries final opacity without attenuating primary text again.", "$type": "color", "$value": "rgba(0, 0, 0, 0.40)" }, "primary": { "$type": "color", "$value": "rgba(0, 0, 0, 0.80)" }, "secondary": { "$type": "color", "$value": "rgba(0, 0, 0, 0.60)" }, "muted": { "$type": "color", "$value": "{ref.color.neutral.650}" }, @@ -254,7 +255,7 @@ "xl": { "$value": "0 12px 24px rgba(16, 26, 39, 0.11)" }, "raised": { "$value": "{shadow.sm}" }, "composer": { "$value": "0 2px 6px rgba(0, 0, 0, 0.08)" }, - "menu": { "$value": "0 4px 10px rgba(0, 0, 0, 0.12)" }, + "menu": { "$value": "0 4px 20px rgba(0, 0, 0, 0.12)" }, "overlay": { "$value": "0 4px 20px rgba(0, 0, 0, 0.12)" }, "accentGlow": { "$description": "Layered emphasis glow derived from the active accent.", diff --git a/design-system/packages/theme-openbitfun/tests/theme-contract.test.mjs b/design-system/packages/theme-openbitfun/tests/theme-contract.test.mjs index c915eb0721..cbffd84c40 100644 --- a/design-system/packages/theme-openbitfun/tests/theme-contract.test.mjs +++ b/design-system/packages/theme-openbitfun/tests/theme-contract.test.mjs @@ -329,7 +329,7 @@ test("default modes preserve the built-in Appearance anchor values", () => { assert.equal(themes.light["color.status.warning.surface"], "rgba(255, 140, 0, 0.1)"); assert.equal(themes.light["shadow.base"], "0 4px 8px rgba(16, 26, 39, 0.07)"); assert.equal(themes.light["shadow.composer"], "0 2px 6px rgba(0, 0, 0, 0.08)"); - assert.equal(themes.light["shadow.menu"], "0 4px 10px rgba(0, 0, 0, 0.12)"); + assert.equal(themes.light["shadow.menu"], "0 4px 20px rgba(0, 0, 0, 0.12)"); assert.equal(themes.light["shadow.overlay"], "0 4px 20px rgba(0, 0, 0, 0.12)"); assert.equal(themes.light["opacity.disabled"], 0.55); assert.equal(themes.dark["color.surface.canvas"], "#0e0e10"); diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 3ef306285d..5fa5ef09b8 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -405,3 +405,11 @@ FormSection descriptions use primary content with 16px leading at 13px. Both leading roles scale with user typography. FieldGroup uses the form group tint, retaining its existing row padding, dividers, and radius. The Patterns form specimen shows both orientations and long values over a tinted container. + +Menus keep contiguous 30px rows with no additional list or heading-to-item gap; +separators own their 8px vertical margins. Their keyboard focus indicator is +inset so scrolling does not clip it or require extra permanent padding. +ActionItem hover and pressed surfaces use the semantic neutral hover fill; +pressed text remains semibold. Menu and navigation captions consume the final +caption color directly, avoiding a second opacity multiplier. The nested-menu +Pattern includes a scrolling toggle for keyboard and submenu verification. diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts b/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts index c5f960cd92..538038af0a 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts @@ -28,6 +28,8 @@ export const actionItemMeta = { "color.status.danger.surface", "control.height.sm", "type.label.md.fontSize", + "type.actionRow.lineHeight", + "color.action.neutral.surfaceHover", "type.label.selected.fontWeight", "type.meta.fontSize", "radius.base", diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css b/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css index 03ed61e455..4377888c89 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css @@ -16,12 +16,12 @@ .root:not([data-disabled="true"]):hover, .root:not([data-disabled="true"]):has(.trigger[data-openbitfun-preview-state="hover"]) { - background: var(--openbitfun-color-action-neutral-surface); + background: var(--openbitfun-color-action-neutral-surface-hover); } .root:not([data-disabled="true"]):has(.trigger:active), .root:not([data-disabled="true"]):has(.trigger[data-openbitfun-preview-state="active"]) { - background: var(--openbitfun-color-action-neutral-surface-pressed); + background: var(--openbitfun-color-action-neutral-surface-hover); } .root:not([data-disabled="true"]):has(.trigger:active) .label, @@ -98,7 +98,7 @@ font-family: var(--openbitfun-type-label-md-font-family); font-size: var(--openbitfun-type-label-md-font-size); font-weight: var(--openbitfun-type-label-md-font-weight); - line-height: var(--openbitfun-type-body-sm-line-height); + line-height: var(--openbitfun-type-action-row-line-height); letter-spacing: var(--openbitfun-type-label-md-letter-spacing); white-space: nowrap; } diff --git a/design-system/packages/ui/src/components/Menu/Menu.meta.ts b/design-system/packages/ui/src/components/Menu/Menu.meta.ts index 66a7998988..7ab52bd4f4 100644 --- a/design-system/packages/ui/src/components/Menu/Menu.meta.ts +++ b/design-system/packages/ui/src/components/Menu/Menu.meta.ts @@ -20,10 +20,12 @@ export const menuMeta = { "color.surface.panel", "color.border.subtle", "color.content.muted", + "color.content.caption", "color.action.neutral.content", "color.action.neutral.contentDisabled", "color.action.neutral.surface", - "color.action.neutral.surfacePressed", + "color.action.neutral.surfaceHover", + "color.selection.surface", "color.focus.ring", "overlay.menu.inlineSize", "overlay.menu.maxBlockSize", diff --git a/design-system/packages/ui/src/components/Menu/Menu.module.css b/design-system/packages/ui/src/components/Menu/Menu.module.css index 0908cdc458..5fcdb0fcdd 100644 --- a/design-system/packages/ui/src/components/Menu/Menu.module.css +++ b/design-system/packages/ui/src/components/Menu/Menu.module.css @@ -40,13 +40,11 @@ display: flex; min-inline-size: 0; flex-direction: column; - gap: calc(var(--openbitfun-space-1) / 2); - /* Keep item focus rings inside the scroll viewport on every edge. */ - padding: var(--openbitfun-focus-width); + gap: 0; + padding: 0; /* Balance the end inset with the start inset after the viewport scrollbar gap. */ padding-inline-end: calc( var(--openbitfun-overlay-menu-surface-padding) - + var(--openbitfun-focus-width) - var(--openbitfun-overlay-menu-scrollbar-gap) ); } @@ -60,11 +58,15 @@ } .section { - gap: var(--openbitfun-overlay-menu-section-gap); + gap: 0; + } + + .section + .section { + margin-block-start: var(--openbitfun-overlay-menu-section-gap); } .items { - gap: calc(var(--openbitfun-space-1) / 2); + gap: 0; } .heading { @@ -80,11 +82,11 @@ overflow: hidden; flex: 1 1 auto; min-inline-size: 0; - color: var(--openbitfun-color-content-muted); + color: var(--openbitfun-color-content-caption); font-family: var(--openbitfun-type-meta-font-family); font-size: var(--openbitfun-type-meta-font-size); - font-weight: var(--openbitfun-type-meta-font-weight); - line-height: var(--openbitfun-type-body-sm-line-height); + font-weight: var(--openbitfun-type-label-xs-font-weight); + line-height: var(--openbitfun-type-label-xs-line-height); letter-spacing: var(--openbitfun-type-meta-letter-spacing); white-space: nowrap; } @@ -112,9 +114,15 @@ gap: var(--openbitfun-overlay-menu-item-gap); } - .item:has(> [data-openbitfun-part="trigger"][aria-checked="true"]) { + .item:has(> [data-openbitfun-part="trigger"]:focus-visible) { + /* Keep the entire focus indicator within a scrolled or clipped item. */ + box-shadow: inset 0 0 0 var(--openbitfun-focus-width) var(--openbitfun-color-focus-ring); + } + + .item:has(> [data-openbitfun-part="trigger"][aria-checked="true"]), + .item:has(> [data-openbitfun-part="trigger"][aria-expanded="true"]) { color: var(--openbitfun-color-action-neutral-content); - background: var(--openbitfun-color-action-neutral-surface); + background: var(--openbitfun-color-selection-surface); } .item [data-openbitfun-part="leading"] { @@ -126,14 +134,25 @@ flex: 0 0 auto; inline-size: 100%; block-size: var(--openbitfun-border-width-default); - margin-block: calc( - var(--openbitfun-overlay-menu-section-gap) - - calc(var(--openbitfun-space-1) / 2) - ); + margin-block: var(--openbitfun-overlay-menu-section-gap); background: var(--openbitfun-color-border-subtle); } + @media (prefers-contrast: more) { + .headingLabel { + color: var(--openbitfun-color-content-muted); + } + } + @media (forced-colors: active) { + .headingLabel { + color: CanvasText; + } + + .item:has(> [data-openbitfun-part="trigger"]:focus-visible) { + box-shadow: inset 0 0 0 var(--openbitfun-focus-width) Highlight; + } + .root { border-color: CanvasText; box-shadow: none; diff --git a/design-system/packages/ui/src/components/Menu/MenuPopover.module.css b/design-system/packages/ui/src/components/Menu/MenuPopover.module.css index 79b5f3117f..188ca45c85 100644 --- a/design-system/packages/ui/src/components/Menu/MenuPopover.module.css +++ b/design-system/packages/ui/src/components/Menu/MenuPopover.module.css @@ -11,6 +11,5 @@ } .popup[data-state="entered"] { opacity: 1; translate: 0 0; pointer-events: auto; } .popup[data-state="exiting"] { transition-duration: 100ms; } - .popup [aria-expanded="true"] { background: var(--openbitfun-color-action-neutral-surface); } @media (prefers-reduced-motion: reduce) { .popup { translate: none; transition: none; } } } diff --git a/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.meta.ts b/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.meta.ts index fdb2a2377e..7088c70927 100644 --- a/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.meta.ts +++ b/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.meta.ts @@ -14,10 +14,12 @@ export const navigationPanelMeta = { "color.border.subtle", "color.content.primary", "color.content.muted", + "color.content.caption", "color.action.neutral.content", "color.action.neutral.contentDisabled", "color.action.neutral.surface", - "color.action.neutral.surfacePressed", + "color.action.neutral.surfaceHover", + "color.selection.surface", "color.focus.ring", "type.label.selected.fontWeight", "layout.navigationPanel.inlineSize", diff --git a/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.module.css b/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.module.css index c156b1295d..c4f0bb43b1 100644 --- a/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.module.css +++ b/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.module.css @@ -85,8 +85,7 @@ overflow: hidden; flex: 1 1 auto; min-inline-size: 0; - /* Keep group captions at 40% semantic ink without borrowing disabled-state color. */ - color: color-mix(in srgb, var(--openbitfun-color-content-primary) 40%, transparent); + color: var(--openbitfun-color-content-caption); font-family: var(--openbitfun-type-label-xs-font-family); font-size: var(--openbitfun-type-label-xs-font-size); font-weight: var(--openbitfun-type-label-xs-font-weight); diff --git a/design-system/packages/ui/tests/action-item.test.mjs b/design-system/packages/ui/tests/action-item.test.mjs index 6b08402cb1..c2ebd71a93 100644 --- a/design-system/packages/ui/tests/action-item.test.mjs +++ b/design-system/packages/ui/tests/action-item.test.mjs @@ -88,7 +88,7 @@ test("ActionItem styles share action state and focus tokens", async () => { const styles = await readFile(new URL("../dist/styles.css", import.meta.url), "utf8"); assert.match(styles, /--openbitfun-color-action-neutral-surface/); - assert.match(styles, /--openbitfun-color-action-neutral-surface-pressed/); + assert.match(styles, /--openbitfun-color-action-neutral-surface-hover/); assert.match(styles, /--openbitfun-color-action-neutral-content-disabled/); assert.match(styles, /--openbitfun-color-focus-ring/); assert.match(styles, /--openbitfun-control-height-sm/); diff --git a/design-system/packages/ui/tests/menu.test.mjs b/design-system/packages/ui/tests/menu.test.mjs index 951b35027e..050b57b563 100644 --- a/design-system/packages/ui/tests/menu.test.mjs +++ b/design-system/packages/ui/tests/menu.test.mjs @@ -74,22 +74,23 @@ test("Menu styling uses only public surface, geometry, action, and scrollbar tok assert.match(styles, /--openbitfun-overlay-menu-inline-size/); assert.match(styles, /--openbitfun-overlay-menu-item-height/); - assert.match(styles, /\.list\s*\{[^}]*gap: calc\(var\(--openbitfun-space-1\) \/ 2\)/); - assert.match(styles, /\.items\s*\{[^}]*gap: calc\(var\(--openbitfun-space-1\) \/ 2\)/); - assert.match(styles, /\.separator\s*\{[^}]*margin-block: calc\(/); + assert.match(styles, /\.list\s*\{[^}]*gap: 0/); + assert.match(styles, /\.items\s*\{[^}]*gap: 0/); + assert.match(styles, /\.separator\s*\{[^}]*margin-block: var\(--openbitfun-overlay-menu-section-gap\)/); assert.match(styles, /--openbitfun-color-surface-panel/); assert.match(styles, /--openbitfun-shadow-menu/); assert.match(styles, /--openbitfun-overlay-menu-scrollbar-gap/); assert.doesNotMatch(styles, /#[0-9a-f]{3,8}/i); }); -test("Menu reserves space inside its scroll viewport for focus rings on all edges", async () => { +test("Menu keeps focus rings inside items without adding a permanent gutter", async () => { const styles = await readFile(new URL("../src/components/Menu/Menu.module.css", import.meta.url), "utf8"); const scrollStyles = await readFile(new URL("../src/components/ScrollArea/ScrollArea.module.css", import.meta.url), "utf8"); const itemStyles = await readFile(new URL("../src/components/ActionItem/ActionItem.module.css", import.meta.url), "utf8"); - assert.match(styles, /\.list\s*\{[^}]*padding:\s*var\(--openbitfun-focus-width\)/); + assert.match(styles, /\.list\s*\{[^}]*padding:\s*0/); assert.match(itemStyles, /\.root:has\(\.trigger:focus-visible\)\s*\{[^}]*box-shadow:\s*0 0 0 var\(--openbitfun-focus-width\)/); - // Keep clipping and scrolling; the content, not the viewport, owns the gutter. + assert.match(styles, /\.item:has\(> \[data-openbitfun-part="trigger"\]:focus-visible\)\s*\{[^}]*box-shadow:\s*inset 0 0 0 var\(--openbitfun-focus-width\)/); + // Keep clipping and scrolling; the item owns its focus indicator. assert.match(scrollStyles, /data-openbitfun-orientation="vertical"\]\s*\{[^}]*overflow-x:\s*hidden;[^}]*overflow-y:\s*auto/); assert.doesNotMatch(styles, /overflow[^:]*:\s*visible/); }); @@ -101,7 +102,7 @@ test("Menu keeps equal item insets while its scrollbar stays on the surface edge assert.match(styles, /\.viewport\s*\{[^}]*padding-inline-end:\s*var\(--openbitfun-overlay-menu-scrollbar-gap\);[^}]*scrollbar-gutter:\s*auto/); assert.match( styles, - /\.list\s*\{[^}]*padding-inline-end:\s*calc\(\s*var\(--openbitfun-overlay-menu-surface-padding\)\s*\+ var\(--openbitfun-focus-width\)\s*- var\(--openbitfun-overlay-menu-scrollbar-gap\)\s*\)/, + /\.list\s*\{[^}]*padding-inline-end:\s*calc\(\s*var\(--openbitfun-overlay-menu-surface-padding\)\s*- var\(--openbitfun-overlay-menu-scrollbar-gap\)\s*\)/, ); assert.doesNotMatch(styles, /scrollbar-gutter:\s*stable/); }); diff --git a/design-system/packages/ui/tests/navigation-panel.test.mjs b/design-system/packages/ui/tests/navigation-panel.test.mjs index 5a4d737c64..104250c2f3 100644 --- a/design-system/packages/ui/tests/navigation-panel.test.mjs +++ b/design-system/packages/ui/tests/navigation-panel.test.mjs @@ -84,7 +84,7 @@ test("NavigationPanel separates group captions, destinations, and the selected d ); const heading = styles.match(/\.headingLabel\s*\{([^}]+)\}/)?.[1]; assert.ok(heading); - assert.match(heading, /color: color-mix\(in srgb, var\(--openbitfun-color-content-primary\) 40%, transparent\)/); + assert.match(heading, /color: var\(--openbitfun-color-content-caption\)/); assert.match(heading, /font-family: var\(--openbitfun-type-label-xs-font-family\)/); assert.match(heading, /font-size: var\(--openbitfun-type-label-xs-font-size\)/); assert.match(heading, /font-weight: var\(--openbitfun-type-label-xs-font-weight\)/); diff --git a/design-system/packages/ui/tests/text-clipping.test.mjs b/design-system/packages/ui/tests/text-clipping.test.mjs index 6c12ec0712..6a4b976e61 100644 --- a/design-system/packages/ui/tests/text-clipping.test.mjs +++ b/design-system/packages/ui/tests/text-clipping.test.mjs @@ -31,7 +31,9 @@ for (const [component, selector] of [ } assert.match( declarations["line-height"], - /^var\(--openbitfun-(?:line-height-base|type-body-sm-line-height|type-label-(?:xs|md)-line-height)\)$/, + component === "ActionItem" + ? /^var\(--openbitfun-type-action-row-line-height\)$/ + : /^var\(--openbitfun-(?:line-height-base|type-body-sm-line-height|type-label-(?:xs|md)-line-height)\)$/, ); assert.equal(declarations.overflow, "hidden"); assert.equal(declarations["text-overflow"], undefined); diff --git a/src/apps/data-migrator/ui/generated/design-system.css b/src/apps/data-migrator/ui/generated/design-system.css index 2f22ed0dc8..da5dba6489 100644 --- a/src/apps/data-migrator/ui/generated/design-system.css +++ b/src/apps/data-migrator/ui/generated/design-system.css @@ -245,7 +245,7 @@ --openbitfun-layout-navigation-panel-footer-padding: var(--openbitfun-space-2); --openbitfun-layout-navigation-panel-heading-action-size: 22px; --openbitfun-layout-navigation-panel-heading-gap: var(--openbitfun-space-5); - --openbitfun-layout-navigation-panel-heading-height: 22px; + --openbitfun-layout-navigation-panel-heading-height: 24px; --openbitfun-layout-navigation-panel-heading-padding-inline: var(--openbitfun-space-2); --openbitfun-layout-navigation-panel-inline-size: 216px; --openbitfun-layout-navigation-panel-item-gap: var(--openbitfun-space-2); @@ -398,6 +398,7 @@ --openbitfun-space-8: 32px; --openbitfun-space-component-block: 8px; --openbitfun-space-component-inline: 12px; + --openbitfun-type-action-row-line-height: 1.2307692307692308; --openbitfun-type-body-lg-font-family: var(--openbitfun-font-family-sans); --openbitfun-type-body-lg-font-size: var(--openbitfun-font-size-lg); --openbitfun-type-body-lg-font-weight: var(--openbitfun-font-weight-regular); @@ -704,6 +705,7 @@ --openbitfun-color-border-subtle: rgba(16, 26, 39, 0.08); --openbitfun-color-code-change-added: #1aa73e; --openbitfun-color-code-change-removed: #ec221f; + --openbitfun-color-content-caption: rgba(0, 0, 0, 0.40); --openbitfun-color-content-disabled: rgba(0, 0, 0, 0.30); --openbitfun-color-content-inverse: #ffffff; --openbitfun-color-content-muted: #6a6a6a; @@ -805,7 +807,7 @@ --openbitfun-shadow-inner-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.08); --openbitfun-shadow-inner-highlight-hover: inset 0 1px 0 rgba(255, 255, 255, 0.24); --openbitfun-shadow-lg: 0 8px 16px rgba(16, 26, 39, 0.09); - --openbitfun-shadow-menu: 0 4px 10px rgba(0, 0, 0, 0.12); + --openbitfun-shadow-menu: 0 4px 20px rgba(0, 0, 0, 0.12); --openbitfun-shadow-overlay: 0 4px 20px rgba(0, 0, 0, 0.12); --openbitfun-shadow-raised: 0 2px 4px rgba(16, 26, 39, 0.055); --openbitfun-shadow-sm: 0 2px 4px rgba(16, 26, 39, 0.055); @@ -852,6 +854,7 @@ --openbitfun-color-border-subtle: rgba(255, 255, 255, 0.12); --openbitfun-color-code-change-added: #1aa73e; --openbitfun-color-code-change-removed: #ec221f; + --openbitfun-color-content-caption: #858585; --openbitfun-color-content-disabled: #555555; --openbitfun-color-content-inverse: #0e0e10; --openbitfun-color-content-muted: #858585; @@ -978,6 +981,7 @@ --openbitfun-color-border-default: #3b3b3b; --openbitfun-color-border-strong: #000000; --openbitfun-color-border-subtle: #707070; + --openbitfun-color-content-caption: #343434; --openbitfun-color-content-muted: #343434; --openbitfun-color-content-primary: #000000; --openbitfun-color-content-required-indicator: #005fcc; @@ -1040,6 +1044,7 @@ --openbitfun-color-border-default: #b5b5b5; --openbitfun-color-border-strong: #ffffff; --openbitfun-color-border-subtle: #8a8a8a; + --openbitfun-color-content-caption: #d0d0d0; --openbitfun-color-content-muted: #d0d0d0; --openbitfun-color-content-primary: #ffffff; --openbitfun-color-content-required-indicator: #ffcc00; diff --git a/src/apps/desktop/src/generated/bootstrap_theme.css b/src/apps/desktop/src/generated/bootstrap_theme.css index f7431c6a52..0d82aa44e7 100644 --- a/src/apps/desktop/src/generated/bootstrap_theme.css +++ b/src/apps/desktop/src/generated/bootstrap_theme.css @@ -37,6 +37,7 @@ --openbitfun-color-border-subtle: rgba(255, 255, 255, 0.12); --openbitfun-color-code-change-added: #1aa73e; --openbitfun-color-code-change-removed: #ec221f; + --openbitfun-color-content-caption: #858585; --openbitfun-color-content-disabled: #555555; --openbitfun-color-content-inverse: #0e0e10; --openbitfun-color-content-muted: #858585; diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPetStyles.test.ts b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPetStyles.test.ts index 369ed08806..0b120dd043 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPetStyles.test.ts +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPetStyles.test.ts @@ -79,13 +79,17 @@ describe('AgentCompanionDesktopPet styles', () => { const actionItemStyles = readPublicActionItemStyles(); const overlay = extractBlock(stylesheet, '&__overlay'); const menuItem = extractBlock(stylesheet, '&__menu-item'); + const hover = extractBlock(actionItemStyles, + '.root:not([data-disabled="true"]):hover,\n .root:not([data-disabled="true"]):has(.trigger[data-openbitfun-preview-state="hover"])'); + const pressed = extractBlock(actionItemStyles, + '.root:not([data-disabled="true"]):has(.trigger:active),\n .root:not([data-disabled="true"]):has(.trigger[data-openbitfun-preview-state="active"])'); expect(source).toContain('import { OverflowText, Menu, MenuItem, ScrollArea } from \'@openbitfun/ui\''); expect(source).not.toContain('triggerClassName'); expect(overlay).not.toMatch(/\b(?:color|background|border|box-shadow|backdrop-filter)\s*:/); expect(menuItem).toBe(''); expect(actionItemStyles).toContain('color: var(--openbitfun-color-action-neutral-content);'); - expect(actionItemStyles).toContain('background: var(--openbitfun-color-action-neutral-surface);'); - expect(actionItemStyles).toContain('background: var(--openbitfun-color-action-neutral-surface-pressed);'); + expect(hover).toContain('background: var(--openbitfun-color-action-neutral-surface-hover);'); + expect(pressed).toContain('background: var(--openbitfun-color-action-neutral-surface-hover);'); }); }); 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 ac87080a1a..bb30bf8cf4 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/appearancePresetOutput.test.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/appearancePresetOutput.test.ts @@ -81,7 +81,7 @@ describe('builtin appearance preset output', () => { expect(light['--openbitfun-component-button-content']).toBe('rgba(0, 0, 0, 0.80)'); expect(light['--openbitfun-component-button-text-content']).toBe('#059cb0'); expect(light['--openbitfun-color-action-primary-background']).toBe('#101a27'); - expect(light['--openbitfun-color-action-neutral-content']).toBe('rgba(0, 0, 0, 0.60)'); + expect(light['--openbitfun-color-action-neutral-content']).toBe('rgba(0, 0, 0, 0.80)'); }); it('preserves the action colors of branded presets through the Button contract', () => { @@ -94,6 +94,17 @@ describe('builtin appearance preset output', () => { } }); + it('keeps menu label and caption colors consistent between the public light theme and product portals', () => { + const settings = getBuiltinAppearance('openbitfun-light')!.renderers!['theme-tokens']!.settings; + for (const tokens of [settings.tokens, ...(settings.scopes?.chrome ? [settings.scopes.chrome] : [])]) { + expect(tokens['--openbitfun-color-action-neutral-content']).toBe(themes.light['color.action.neutral.content']); + expect(tokens['--openbitfun-color-content-caption']).toBe(themes.light['color.content.caption']); + } + for (const palette of builtinAppearancePalettes.filter(p => p.id !== 'openbitfun-light')) { + expect(getBuiltinAppearanceThemeTokens(palette.id)['--openbitfun-color-action-neutral-content']).toBe(palette.colors.text.secondary); + } + }); + it('formats hex palette references as stable rgb strings', () => { expect(rgbFromHex('#00e6ff')).toBe('rgb(0, 230, 255)'); expect(rgbaFromHex('#00e6ff', 0.12)).toBe('rgba(0, 230, 255, 0.12)'); diff --git a/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts b/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts index ecb1881237..203fe79c85 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/buildBuiltinAppearance.ts @@ -77,6 +77,10 @@ function themeValuesToCssTokens( ) as Record; if (palette.id === DEFAULT_LIGHT_APPEARANCE_ID || palette.id === DEFAULT_DARK_APPEARANCE_ID) { if (palette.id === DEFAULT_LIGHT_APPEARANCE_ID) { + // Neutral action labels are primary text in the public theme. The generic + // palette's secondary text projection used to make product menus too faint. + tokens['--openbitfun-color-action-neutral-content'] = String(themes.light['color.action.neutral.content']); + tokens['--openbitfun-color-content-caption'] = String(themes.light['color.content.caption']); // Default light fields use the published neutral states in both root and // chrome. Branded palettes and imported overrides retain their own colors. for (const name of Object.keys(themes.light) as ThemeTokenName[]) { @@ -128,6 +132,7 @@ function createThemeTokenValues(palette: AppearancePalette): Record> | undefined, +): Partial> { + const result = { ...tokens }; + if (result['--openbitfun-color-content-caption'] === undefined + && tokens?.['--openbitfun-color-content-muted'] !== undefined) { + result['--openbitfun-color-content-caption'] = tokens['--openbitfun-color-content-muted']; + } + return result; +} diff --git a/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts b/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts index a4923568d3..fa6e339dae 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.test.ts @@ -26,6 +26,8 @@ describe('composeAppearancePackage', () => { const payload = JSON.stringify(original); const resolved = composeAppearancePackage(JSON.parse(payload)); const settings = resolved.renderers!['theme-tokens']!.settings; + expect(settings.tokens['--openbitfun-color-content-caption']).toBe('#778899'); + expect(settings.scopes?.chrome?.['--openbitfun-color-content-caption']).toBe('#556677'); expect(settings.tokens).toMatchObject({ '--openbitfun-color-field-border': '#123456', '--openbitfun-color-field-border-focus': '#654321', @@ -45,7 +47,11 @@ describe('composeAppearancePackage', () => { original.renderers!['theme-tokens']!.settings.scopes!.chrome!['--openbitfun-color-field-border-active'] = '#556688'; original.renderers!['theme-tokens']!.settings.tokens['--openbitfun-color-field-group-background'] = '#123abc'; original.renderers!['theme-tokens']!.settings.scopes!.chrome!['--openbitfun-color-field-group-background'] = '#456def'; + original.renderers!['theme-tokens']!.settings.tokens['--openbitfun-color-content-caption'] = '#abcdef'; + original.renderers!['theme-tokens']!.settings.scopes!.chrome!['--openbitfun-color-content-caption'] = '#fedcba'; const explicit = composeAppearancePackage(original).renderers!['theme-tokens']!.settings; + expect(explicit.tokens['--openbitfun-color-content-caption']).toBe('#abcdef'); + expect(explicit.scopes?.chrome?.['--openbitfun-color-content-caption']).toBe('#fedcba'); expect(explicit.tokens['--openbitfun-color-field-group-background']).toBe('#123abc'); expect(explicit.scopes?.chrome?.['--openbitfun-color-field-group-background']).toBe('#456def'); expect(explicit.tokens['--openbitfun-color-field-placeholder']).toBe('#112233'); diff --git a/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.ts b/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.ts index 935c02d9a9..0e41d7d8aa 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/composeAppearancePackage.ts @@ -14,6 +14,7 @@ import { } from './catalog'; import { withLegacyButtonTokens } from './buttonThemeCompatibility'; import { withLegacyFieldTokens } from './fieldThemeCompatibility'; +import { withLegacyCaptionToken } from './captionThemeCompatibility'; const COMPOSITION_LAYERS = Symbol('openbitfun.appearance.composition-layers'); @@ -122,12 +123,12 @@ function mergeRenderers( ...override['theme-tokens'].settings, tokens: { ...(base?.['theme-tokens']?.settings.tokens ?? {}), - ...withLegacyFieldTokens(withLegacyButtonTokens(override['theme-tokens'].settings.tokens)), + ...withLegacyCaptionToken(withLegacyFieldTokens(withLegacyButtonTokens(override['theme-tokens'].settings.tokens))), }, scopes: { chrome: { ...(base?.['theme-tokens']?.settings.scopes?.chrome ?? {}), - ...withLegacyFieldTokens(withLegacyButtonTokens(override['theme-tokens'].settings.scopes?.chrome)), + ...withLegacyCaptionToken(withLegacyFieldTokens(withLegacyButtonTokens(override['theme-tokens'].settings.scopes?.chrome))), }, }, }, diff --git a/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts b/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts index 269183bf32..41b446be9b 100644 --- a/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts +++ b/src/web-ui/src/tools/generative-widget/appearancePayload.test.ts @@ -43,6 +43,9 @@ const FIELD_STATE_THEME_VARIABLE_NAMES = [ // Grouped forms own a translucent fill independently of opaque tertiary surfaces // and transient subtle feedback; keep this addition outside the shared fingerprint. const FIELD_GROUP_THEME_VARIABLE_NAME = '--openbitfun-color-field-group-background'; +// Menu and navigation captions own their final contrast independently of body +// descriptions and field hints; assert this addition without changing the shared baseline. +const CAPTION_THEME_VARIABLE_NAME = '--openbitfun-color-content-caption'; const RETIRED_WIDGET_VARIABLE_NAMES = [ '--background-primary', '--bg-primary', @@ -91,10 +94,12 @@ describe('generated widget appearance payload contract', () => { !name.startsWith('--openbitfun-component-button-') && !FIELD_STATE_THEME_VARIABLE_NAMES.some(fieldName => fieldName === name) && name !== FIELD_GROUP_THEME_VARIABLE_NAME + && name !== CAPTION_THEME_VARIABLE_NAME )); expect(buttonNames).toEqual(BUTTON_THEME_VARIABLE_NAMES); expect(fieldStateNames).toEqual(FIELD_STATE_THEME_VARIABLE_NAMES); expect(WIDGET_APPEARANCE_VAR_NAMES).toContain(FIELD_GROUP_THEME_VARIABLE_NAME); + expect(WIDGET_APPEARANCE_VAR_NAMES).toContain(CAPTION_THEME_VARIABLE_NAME); expect({ count: sharedNames.length, hash: hashNames(sharedNames), @@ -151,6 +156,7 @@ describe('generated widget appearance payload contract', () => { '--openbitfun-color-action-primary-content': '#101010', '--openbitfun-color-action-primary-hover': 'linear-gradient(test-hover)', '--openbitfun-color-action-primary-pressed': '#202020', + '--openbitfun-color-content-caption': 'rgba(0, 0, 0, 0.45)', '--openbitfun-component-button-primary-background': '#303030', '--openbitfun-component-button-fill-background': 'rgba(0, 0, 0, 0.08)', '--openbitfun-color-field-border-active': 'rgba(0, 0, 0, 0.20)',