diff --git a/design-system/apps/design-lab/src/i18n/messages.ts b/design-system/apps/design-lab/src/i18n/messages.ts index d2a322d87b..8234115c20 100644 --- a/design-system/apps/design-lab/src/i18n/messages.ts +++ b/design-system/apps/design-lab/src/i18n/messages.ts @@ -410,6 +410,8 @@ export const enUSMessages = { "detail.livePlayground": "Live playground", "detail.allStates": "Preview states", "detail.preview": "Preview", + "detail.iconComposition": "Icons in components", + "detail.iconCompositionHint": "Catalog icons (left) and SVG icons (right) share slot sizes. Standalone icons below retain their own size.", "detail.previewUnavailable": "Preview not implemented", "components.preview.inputError": "Please check this value.", "detail.code": "Code", @@ -1105,6 +1107,8 @@ export const zhCNMessages = { "detail.livePlayground": "实时工作台", "detail.allStates": "状态预览", "detail.preview": "预览", + "detail.iconComposition": "组件中的图标尺寸", + "detail.iconCompositionHint": "左侧为图标库图标,右侧为 SVG 图标,均使用组件插槽尺寸;下方独立图标保留自身尺寸。", "detail.previewUnavailable": "此组件尚未实现预览", "components.preview.inputError": "请检查输入内容。", "detail.code": "代码", @@ -1745,6 +1749,8 @@ export const zhTWMessages = { "detail.livePlayground": "即時工作台", "detail.allStates": "狀態預覽", "detail.preview": "預覽", + "detail.iconComposition": "元件中的圖示尺寸", + "detail.iconCompositionHint": "左側為圖示庫圖示,右側為 SVG 圖示,皆使用元件插槽尺寸;下方獨立圖示保留自身尺寸。", "detail.previewUnavailable": "此元件尚未實作預覽", "components.preview.inputError": "請檢查輸入內容。", "detail.code": "程式碼", diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index 584d01a3c4..e5f8935ac5 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -98,6 +98,7 @@ import { } from "@bitfun/ui"; import type { ComponentMeta } from "@bitfun/ui/registry"; import previewImage from "../assets/design-system-hero.webp"; +import { IconCompositionPreview } from "../preview/IconCompositionPreview"; import { NestedMenuPattern } from "./ReferencePatterns"; import { useI18n, type MessageKey } from "../i18n"; import { @@ -1749,14 +1750,17 @@ export function ComponentDetailPage({ ))} ) : component.name === "Icon" ? ( -
- {iconNames.map((name) => ( -
- - {name} -
- ))} -
+ <> + +
+ {iconNames.map((name) => ( +
+ + {name} +
+ ))} +
+ ) : component.name === "IconButton" ? (
+

{t("detail.iconComposition")}

+

{t("detail.iconCompositionHint")}

+ {(["xs", "sm", "md", "lg"] as const).map(size => ( +
+ Button / {size} + + + } /> + } /> +
+ ))} +
+ TabGroup + }, + { value: "settings", label, icon: }, + { value: "assistant", label: t("components.preview.assistant"), icon: }, + ]} + /> +
+
+ Input + } trailing={} defaultValue={label} /> + } trailing={} defaultValue={label} /> +
+ + ); +} diff --git a/design-system/apps/design-lab/src/styles.css b/design-system/apps/design-lab/src/styles.css index 2472ffdd89..a0ab0df073 100644 --- a/design-system/apps/design-lab/src/styles.css +++ b/design-system/apps/design-lab/src/styles.css @@ -3948,6 +3948,38 @@ input.lab-force-focus { padding: var(--bf-space-5); } +.component-icon-composition { + display: grid; + gap: var(--bf-space-4); + min-inline-size: 0; + padding: var(--bf-space-5); + border-block-end: var(--bf-border-width-default) solid var(--bf-color-border-subtle); +} + +.component-icon-composition h3, +.component-icon-composition p { + margin: 0; +} + +.component-icon-composition p { + color: var(--bf-color-content-muted); + font-size: var(--bf-font-size-small); +} + +.component-icon-composition__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--bf-space-4); + min-inline-size: 0; +} + +.component-icon-composition__row > code { + min-inline-size: 112px; + color: var(--bf-color-content-secondary); + font-size: var(--bf-font-size-caption); +} + .component-icon-catalog__item { display: grid; min-block-size: 88px; diff --git a/design-system/apps/design-lab/vite/component-preview-render.test.mjs b/design-system/apps/design-lab/vite/component-preview-render.test.mjs index 527aef5c61..cc34ebaf41 100644 --- a/design-system/apps/design-lab/vite/component-preview-render.test.mjs +++ b/design-system/apps/design-lab/vite/component-preview-render.test.mjs @@ -83,3 +83,16 @@ test("Switch is explicit and unknown components never silently become switches", assert.match(preview, /此组件尚未实现预览/); assert.doesNotMatch(preview, /data-bf-component="switch"/); }); + +test("Icon details include real mixed-icon compositions at every button size", () => { + const { preview } = render("Icon"); + assert.match(preview, /component-icon-composition/); + for (const size of ["xs", "sm", "md", "lg"]) { + assert.match(preview, new RegExp(`Button / ${size}`)); + assert.match(preview, new RegExp(`aria-label="SVG / ${size}"`)); + assert.match(preview, new RegExp(`aria-label="Icon / ${size}"`)); + } + assert.match(preview, /data-bf-component="tab-group"/); + assert.match(preview, /data-bf-component="input"/); + assert.match(preview, /component-icon-catalog/); +}); diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 465ead7939..5ac5cafa34 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -23,6 +23,12 @@ uncontrolled open state, trigger/region accessibility wiring, focus exclusion while collapsed, reduced-motion behavior, and independent header actions. Product copy and the revealed content remain consumer-owned. +Sized icon slots in buttons, tabs, menu items and fields own their glyph geometry. +Pass catalog `Icon` nodes through `leadingIcon`, `trailingIcon`, `icon` or the +matching component slot, just as for SVG icons. These slots constrain catalog +icons to the component's size; a standalone `Icon` retains its explicit size +(24px by default). Do not shrink the catalog globally to correct a slot mismatch. + ## Advanced selection and menus Use native `Select` for simple options. `Combobox` adds search, grouped options, diff --git a/design-system/packages/ui/src/components/ActionCard/ActionCard.module.css b/design-system/packages/ui/src/components/ActionCard/ActionCard.module.css index 730bd2d355..43713e6a71 100644 --- a/design-system/packages/ui/src/components/ActionCard/ActionCard.module.css +++ b/design-system/packages/ui/src/components/ActionCard/ActionCard.module.css @@ -92,7 +92,8 @@ color: var(--bf-color-content-secondary); } - .leading > :where(svg, img) { + .leading > :where(svg, img), + .leading > [data-bf-component="icon"] { display: block; inline-size: var(--bf-control-action-card-icon-size); block-size: var(--bf-control-action-card-icon-size); 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 334adba05d..8a7410e344 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css @@ -83,7 +83,8 @@ block-size: var(--bf-font-size-small); } - .leading > :where(svg, img) { + .leading > :where(svg, img), + .leading > [data-bf-component="icon"] { display: block; inline-size: 100%; block-size: 100%; diff --git a/design-system/packages/ui/src/components/ActivityItem/ActivityItem.module.css b/design-system/packages/ui/src/components/ActivityItem/ActivityItem.module.css index 13da75ef7b..d5760b8a64 100644 --- a/design-system/packages/ui/src/components/ActivityItem/ActivityItem.module.css +++ b/design-system/packages/ui/src/components/ActivityItem/ActivityItem.module.css @@ -68,7 +68,8 @@ justify-content: center; } - .leading > :where(svg, img) { + .leading > :where(svg, img), + .leading > [data-bf-component="icon"] { display: block; inline-size: 100%; block-size: 100%; diff --git a/design-system/packages/ui/src/components/Avatar/Avatar.module.css b/design-system/packages/ui/src/components/Avatar/Avatar.module.css index e05153751c..6f4256d49f 100644 --- a/design-system/packages/ui/src/components/Avatar/Avatar.module.css +++ b/design-system/packages/ui/src/components/Avatar/Avatar.module.css @@ -5,7 +5,7 @@ .root[data-bf-shape="square"] { border-radius: var(--bf-radius-base); } .image { inline-size: 100%; block-size: 100%; object-fit: cover; } .content { display: inline-flex; align-items: center; justify-content: center; inline-size: 100%; block-size: 100%; } - .content > :where(svg, img) { inline-size: 55%; block-size: 55%; } + .content > :where(svg, img), .content > [data-bf-component="icon"] { inline-size: 55%; block-size: 55%; } .group { display: inline-flex; align-items: center; } .group > .root + .root { margin-inline-start: calc(var(--bf-space-2) * -1); } } diff --git a/design-system/packages/ui/src/components/Button/Button.module.css b/design-system/packages/ui/src/components/Button/Button.module.css index 96d7b55448..6217833ab9 100644 --- a/design-system/packages/ui/src/components/Button/Button.module.css +++ b/design-system/packages/ui/src/components/Button/Button.module.css @@ -191,7 +191,8 @@ color: currentColor; } - .icon > :where(svg, img) { + .icon > :where(svg, img), + .icon > [data-bf-component="icon"] { display: block; inline-size: 100%; block-size: 100%; diff --git a/design-system/packages/ui/src/components/Combobox/Combobox.module.css b/design-system/packages/ui/src/components/Combobox/Combobox.module.css index 80bb7cce2d..d9cb9d98cf 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.module.css +++ b/design-system/packages/ui/src/components/Combobox/Combobox.module.css @@ -23,7 +23,7 @@ .option { display: flex; align-items: center; gap: var(--bf-space-2); min-block-size: var(--bf-overlay-menu-item-height); padding: var(--bf-space-2) var(--bf-overlay-menu-item-padding-inline); border-radius: var(--bf-overlay-menu-item-radius); cursor: pointer; overflow-wrap: anywhere; } .option[data-active="true"], .option[data-selected="true"] { background: var(--bf-color-action-neutral-surface); } .option[aria-disabled="true"] { color: var(--bf-color-content-disabled); cursor: not-allowed; } - .option > svg { flex-shrink: 0; inline-size: var(--bf-overlay-menu-item-icon-size); block-size: var(--bf-overlay-menu-item-icon-size); } + .option > svg, .option > [data-bf-component="icon"] { flex-shrink: 0; inline-size: var(--bf-overlay-menu-item-icon-size); block-size: var(--bf-overlay-menu-item-icon-size); } .copy { display: flex; flex-direction: column; flex: 1; min-inline-size: 0; } .copy small { color: var(--bf-color-content-muted); } .group, .empty { padding: var(--bf-space-2); } diff --git a/design-system/packages/ui/src/components/ConfirmDialog/ConfirmDialog.module.css b/design-system/packages/ui/src/components/ConfirmDialog/ConfirmDialog.module.css index 8e6c921751..47cae3f4e9 100644 --- a/design-system/packages/ui/src/components/ConfirmDialog/ConfirmDialog.module.css +++ b/design-system/packages/ui/src/components/ConfirmDialog/ConfirmDialog.module.css @@ -33,7 +33,8 @@ background: var(--bf-color-status-info-surface); } - .icon > svg { + .icon > svg, + .icon > [data-bf-component="icon"] { inline-size: var(--bf-layout-confirm-dialog-icon-glyph-size); block-size: var(--bf-layout-confirm-dialog-icon-glyph-size); } diff --git a/design-system/packages/ui/src/components/Empty/Empty.module.css b/design-system/packages/ui/src/components/Empty/Empty.module.css index 7cb1e6de83..610bef1a15 100644 --- a/design-system/packages/ui/src/components/Empty/Empty.module.css +++ b/design-system/packages/ui/src/components/Empty/Empty.module.css @@ -30,7 +30,8 @@ block-size: calc(var(--bf-control-height-lg) + var(--bf-space-4)); } - .media > :where(svg, img) { + .media > :where(svg, img), + .media > [data-bf-component="icon"] { display: block; max-inline-size: 100%; max-block-size: 100%; diff --git a/design-system/packages/ui/src/components/IconButton/IconButton.module.css b/design-system/packages/ui/src/components/IconButton/IconButton.module.css index 7c060b49d3..f505e0abde 100644 --- a/design-system/packages/ui/src/components/IconButton/IconButton.module.css +++ b/design-system/packages/ui/src/components/IconButton/IconButton.module.css @@ -108,7 +108,8 @@ block-size: var(--_icon-button-icon-size); } - .icon > :where(svg, img) { + .icon > :where(svg, img), + .icon > [data-bf-component="icon"] { display: block; inline-size: 100%; block-size: 100%; diff --git a/design-system/packages/ui/src/components/Input/Input.module.css b/design-system/packages/ui/src/components/Input/Input.module.css index f203dd4aab..56e780077f 100644 --- a/design-system/packages/ui/src/components/Input/Input.module.css +++ b/design-system/packages/ui/src/components/Input/Input.module.css @@ -92,7 +92,9 @@ } .leading > :where(svg, img), - .trailing > :where(svg, img) { + .trailing > :where(svg, img), + .leading > [data-bf-component="icon"], + .trailing > [data-bf-component="icon"] { display: block; inline-size: var(--_field-icon-size); block-size: var(--_field-icon-size); diff --git a/design-system/packages/ui/src/components/KeyHint/KeyHint.module.css b/design-system/packages/ui/src/components/KeyHint/KeyHint.module.css index 940de0f869..194941a6a1 100644 --- a/design-system/packages/ui/src/components/KeyHint/KeyHint.module.css +++ b/design-system/packages/ui/src/components/KeyHint/KeyHint.module.css @@ -32,7 +32,8 @@ min-inline-size: 0; } - .icon > :where(svg, img) { + .icon > :where(svg, img), + .icon > [data-bf-component="icon"] { display: block; inline-size: 1em; block-size: 1em; 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 26f3058b3f..1ea0681a41 100644 --- a/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.meta.ts +++ b/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.meta.ts @@ -18,6 +18,7 @@ export const navigationPanelMeta = { tokens: [ "color.surface.subtle", "color.border.subtle", + "color.content.primary", "color.content.muted", "color.action.neutral.content", "color.action.neutral.contentDisabled", 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 ebca8565cb..3b0ee9b612 100644 --- a/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.module.css +++ b/design-system/packages/ui/src/components/NavigationPanel/NavigationPanel.module.css @@ -19,7 +19,7 @@ block-size: 100%; min-block-size: 0; flex-direction: column; - color: var(--bf-color-action-neutral-content); + color: var(--bf-color-content-primary); background: var(--bf-color-surface-subtle); } @@ -81,7 +81,8 @@ overflow: hidden; flex: 1 1 auto; min-inline-size: 0; - color: var(--bf-color-content-muted); + /* Group captions carry half the ink of destinations, not the action label tone. */ + color: color-mix(in srgb, var(--bf-color-content-primary) 50%, transparent); font-family: var(--bf-font-family-control); font-size: var(--bf-layout-navigation-panel-heading-font-size); font-weight: var(--bf-font-weight-medium); @@ -109,6 +110,10 @@ border-radius: var(--bf-layout-navigation-panel-item-radius); } + .item[data-bf-tone="neutral"]:not([data-disabled="true"]) { + color: var(--bf-color-content-primary); + } + .item > [data-bf-part="trigger"] { gap: var(--bf-layout-navigation-panel-item-gap); } @@ -122,7 +127,7 @@ background: var(--bf-color-action-neutral-surface-pressed); } - .item:has(> [data-bf-part="trigger"][aria-current]) [data-bf-part="label"] { + .item > [data-bf-part="trigger"][aria-current] > [data-bf-part="label"] { font-weight: var(--bf-font-weight-semibold); } @@ -145,7 +150,22 @@ border-block-start: var(--bf-border-width-default) solid var(--bf-color-border-subtle); } + :global([data-contrast="high"]) .headingLabel { + color: var(--bf-color-content-muted); + } + + @media (prefers-contrast: more) { + .headingLabel { + color: var(--bf-color-content-muted); + } + } + @media (forced-colors: active) { + .headingLabel, + .item[data-bf-tone="neutral"]:not([data-disabled="true"]) { + color: CanvasText; + } + .item:has(> [data-bf-part="trigger"][aria-current]) { color: HighlightText; background: Highlight; diff --git a/design-system/packages/ui/src/components/SegmentedControl/SegmentedControl.module.css b/design-system/packages/ui/src/components/SegmentedControl/SegmentedControl.module.css index 290a72fe76..0db6e35417 100644 --- a/design-system/packages/ui/src/components/SegmentedControl/SegmentedControl.module.css +++ b/design-system/packages/ui/src/components/SegmentedControl/SegmentedControl.module.css @@ -76,7 +76,8 @@ color: currentColor; } - .icon > :where(svg, img) { + .icon > :where(svg, img), + .icon > [data-bf-component="icon"] { display: block; inline-size: 100%; block-size: 100%; diff --git a/design-system/packages/ui/src/components/Select/Select.module.css b/design-system/packages/ui/src/components/Select/Select.module.css index a70375475b..86e37adcdd 100644 --- a/design-system/packages/ui/src/components/Select/Select.module.css +++ b/design-system/packages/ui/src/components/Select/Select.module.css @@ -116,7 +116,9 @@ margin-inline-end: var(--bf-control-select-trailing-inset); } - .leading > :where(svg, img, [data-bf-component="icon"]) { + .leading > :where(svg, img), + .leading > [data-bf-component="icon"], + .indicator > [data-bf-component="icon"] { display: block; inline-size: 100%; block-size: 100%; diff --git a/design-system/packages/ui/src/components/StatusPill/StatusPill.module.css b/design-system/packages/ui/src/components/StatusPill/StatusPill.module.css index 72632e4982..9750d36e39 100644 --- a/design-system/packages/ui/src/components/StatusPill/StatusPill.module.css +++ b/design-system/packages/ui/src/components/StatusPill/StatusPill.module.css @@ -31,7 +31,8 @@ block-size: var(--bf-control-status-pill-icon-size); } - .leading > :where(svg, img, [data-bf-component="icon"]) { + .leading > :where(svg, img), + .leading > [data-bf-component="icon"] { display: block; inline-size: 100%; block-size: 100%; diff --git a/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css b/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css index 19538b9693..cfaf44725f 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css @@ -93,7 +93,8 @@ color: currentColor; } - .icon > :where(svg, img) { + .icon > :where(svg, img), + .icon > [data-bf-component="icon"] { display: block; inline-size: 100%; block-size: 100%; diff --git a/design-system/packages/ui/tests/icon-slots.test.mjs b/design-system/packages/ui/tests/icon-slots.test.mjs new file mode 100644 index 0000000000..95098aa4d7 --- /dev/null +++ b/design-system/packages/ui/tests/icon-slots.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Button, Icon, IconButton, SessionIcon, TabGroup } from "../dist/index.js"; + +const slots = [ + ["Button", "icon", "inline-size", "100%"], + ["IconButton", "icon", "inline-size", "100%"], + ["TabGroup", "icon", "inline-size", "100%"], + ["ActionCard", "leading", "inline-size", "var(--bf-control-action-card-icon-size)"], + ["ActionItem", "leading", "inline-size", "100%"], + ["ActivityItem", "leading", "inline-size", "100%"], + ["SegmentedControl", "icon", "inline-size", "100%"], + ["KeyHint", "icon", "inline-size", "1em"], + ["Input", "leading", "inline-size", "var(--_field-icon-size)"], + ["Input", "trailing", "inline-size", "var(--_field-icon-size)"], + ["Select", "leading", "inline-size", "100%"], + ["Select", "indicator", "inline-size", "100%"], + ["StatusPill", "leading", "inline-size", "100%"], + ["Avatar", "content", "inline-size", "55%"], + ["Empty", "media", "max-inline-size", "100%"], + ["ConfirmDialog", "icon", "inline-size", "var(--bf-layout-confirm-dialog-icon-glyph-size)"], + ["Combobox", "option", "inline-size", "var(--bf-overlay-menu-item-icon-size)"], +]; + +test("sized slots apply the same geometry to SVG and catalog icons, regardless of stylesheet order", async () => { + for (const [component, slot, property, value] of slots) { + const css = await readFile(new URL(`../src/components/${component}/${component}.module.css`, import.meta.url), "utf8"); + // Class + attribute specificity beats Icon's class-only dimensions, even if + // the Icon stylesheet loads later during source HMR or a production build. + const selector = `.${slot} > [data-bf-component="icon"]`; + const rule = [...css.matchAll(/([^{}]+)\{([^{}]+)\}/g)].find(([, selectors]) => selectors.includes(selector)); + assert.ok(rule, `${component}.${slot} must directly size catalog icons`); + assert.match(rule[1], /svg/, `${component}.${slot} must retain SVG support`); + assert.ok(rule[2].includes(`${property}: ${value}`), `${component}.${slot}: ${property}`); + assert.ok(rule[2].includes(`${property.replace("inline", "block")}: ${value}`), `${component}.${slot}: block size`); + } +}); + +test("buttons route native and default-size catalog icons through identical slots at every size", () => { + for (const size of ["xs", "sm", "md", "lg"]) { + const catalog = createElement(Icon, { name: "settings" }); + const svg = createElement("svg", { width: 24, height: 24 }); + for (const [Component, props] of [ + [Button, { children: "Settings", leadingIcon: catalog, trailingIcon: svg }], + [IconButton, { "aria-label": "Settings", icon: catalog }], + ]) { + const markup = renderToStaticMarkup(createElement(Component, { ...props, size })); + assert.match(markup, new RegExp(`data-size="${size}"`)); + assert.match(markup, /]*class="[^"]*_icon_[^"]*"[^>]*>]*data-bf-component="icon"/); + assert.match(markup, /data-bf-name="settings"[^>]*data-size="lg"/); + } + } +}); + +test("tabs keep native session icons and catalog scene icons in the same sized region", () => { + const markup = renderToStaticMarkup(createElement(TabGroup, { + "aria-label": "Scenes", + defaultValue: "session", + items: [ + { value: "session", label: "Session", icon: createElement(SessionIcon) }, + { value: "settings", label: "Settings", icon: createElement(Icon, { name: "settings" }) }, + { value: "assistant", label: "Assistant", icon: createElement(Icon, { name: "user" }) }, + ], + })); + assert.equal((markup.match(/data-bf-part="icon"/g) ?? []).length, 3); + assert.match(markup, /data-bf-part="icon"[^>]*>]*>]*data-bf-component="icon"/); +}); + +test("standalone catalog sizes are retained instead of globally shrinking every icon", () => { + for (const size of ["2xs", "xs", "sm", "md", "lg"]) { + const markup = renderToStaticMarkup(createElement(Icon, { name: "settings", size })); + assert.match(markup, new RegExp(`data-size="${size}"`)); + } +}); diff --git a/design-system/packages/ui/tests/navigation-panel.test.mjs b/design-system/packages/ui/tests/navigation-panel.test.mjs index 1d6a086248..be5c652028 100644 --- a/design-system/packages/ui/tests/navigation-panel.test.mjs +++ b/design-system/packages/ui/tests/navigation-panel.test.mjs @@ -46,7 +46,7 @@ test("NavigationPanel composes independent header, grouped body, and footer regi assert.match(markup, /aria-label="Add"/); }); -test("NavigationPanel styling owns only layout while reusing shared action and scrollbar contracts", async () => { +test("NavigationPanel styling reuses shared action and scrollbar contracts", async () => { const styles = await readFile( new URL("../src/components/NavigationPanel/NavigationPanel.module.css", import.meta.url), "utf8", @@ -60,3 +60,26 @@ test("NavigationPanel styling owns only layout while reusing shared action and s assert.match(styles, /scrollbar-gutter: stable/); assert.doesNotMatch(styles, /#[0-9a-f]{3,8}/i); }); + +test("NavigationPanel separates group captions, destinations, and the selected destination", async () => { + const styles = await readFile( + new URL("../src/components/NavigationPanel/NavigationPanel.module.css", import.meta.url), + "utf8", + ); + const actionStyles = await readFile( + new URL("../src/components/ActionItem/ActionItem.module.css", import.meta.url), + "utf8", + ); + const heading = styles.match(/\.headingLabel\s*\{([^}]+)\}/)?.[1]; + assert.ok(heading); + assert.match(heading, /color: color-mix\(in srgb, var\(--bf-color-content-primary\) 50%, transparent\)/); + assert.match(heading, /font-size: var\(--bf-layout-navigation-panel-heading-font-size\)/); + assert.match(heading, /font-weight: var\(--bf-font-weight-medium\)/); + assert.match(styles, /\.item\[data-bf-tone="neutral"\]:not\(\[data-disabled="true"\]\)\s*\{\s*color: var\(--bf-color-content-primary\)/); + assert.match(actionStyles, /\.label\s*\{[^}]*font-size: var\(--bf-font-size-small\)/); + assert.match(actionStyles, /\.label\s*\{[^}]*font-weight: var\(--bf-font-weight-regular\)/); + assert.match(styles, /\.item > \[data-bf-part="trigger"\]\[aria-current\] > \[data-bf-part="label"\]\s*\{\s*font-weight: var\(--bf-font-weight-semibold\)/); + assert.match(styles, /@media \(prefers-contrast: more\)[\s\S]*?color: var\(--bf-color-content-muted\)/); + assert.match(styles, /:global\(\[data-contrast="high"\]\) \.headingLabel\s*\{\s*color: var\(--bf-color-content-muted\)/); + assert.match(styles, /@media \(forced-colors: active\)[\s\S]*?color: CanvasText/); +}); diff --git a/src/web-ui/src/app/layout/FloatingMiniChat.tsx b/src/web-ui/src/app/layout/FloatingMiniChat.tsx index 4bc0e12520..598107cab3 100644 --- a/src/web-ui/src/app/layout/FloatingMiniChat.tsx +++ b/src/web-ui/src/app/layout/FloatingMiniChat.tsx @@ -540,7 +540,7 @@ export const FloatingMiniChat: React.FC = () => { {renderMiniAppIcon(activeMiniAppIcon, 20)} ) : ( - + )} diff --git a/src/web-ui/src/app/layout/floatingMiniChatActivity.test.ts b/src/web-ui/src/app/layout/floatingMiniChatActivity.test.ts index 5db0abb6f2..6b9f4051f6 100644 --- a/src/web-ui/src/app/layout/floatingMiniChatActivity.test.ts +++ b/src/web-ui/src/app/layout/floatingMiniChatActivity.test.ts @@ -20,6 +20,12 @@ function readSource(relativePath: string): string { } describe('floating MiniApp chat activity', () => { + it('uses a compact glyph for the standalone chat trigger', () => { + const component = readSource('./FloatingMiniChat.tsx'); + expect(component).toContain(''); + expect(component).not.toContain(''); + }); + it.each([ 'pending', 'image_analyzing', diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.scss b/src/web-ui/src/app/scenes/settings/SettingsNav.scss index 0bcf3a6c8b..9ecb15c7b6 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.scss +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.scss @@ -1,7 +1,7 @@ /** * SettingsNav styles — scene-specific left sidebar for Settings. * - * Reuses the same design language as NavPanel (tokens, spacing, BEM). + * NavigationPanel owns group geometry, item typography, and interaction states. */ @use '../../../component-library/styles/tokens.scss' as *; @@ -11,9 +11,12 @@ @include nav-font.nav-panel-font-token-scope; @include nav-font.nav-panel-text-body; + // Resolve navigation ink inside the host's light, dark, or inverse chrome. + --bf-color-content-primary: var(--bf-appearance-token-color-text-primary); + display: flex; flex-direction: column; - gap: $size-gap-4; + gap: var(--bf-layout-navigation-panel-content-gap); width: 100%; inline-size: 100%; max-inline-size: none; @@ -25,12 +28,15 @@ background: transparent; > [data-bf-part="header"] { + display: flex; + flex-direction: column; + gap: var(--bf-layout-navigation-panel-content-gap); padding: 0; } - [data-bf-part="content"] { + // The panel owns section spacing; the scene supplies only its outer inset. + &__content { padding: 0; - gap: 0; } // ── Header: title (back/forward lives in NavBar) ────── @@ -71,64 +77,34 @@ } &__search-result-item { - @include nav-font.nav-panel-text-body; - - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - min-height: auto; - min-height: 30px; - padding: 7px $size-gap-2; - border: none; - border-radius: $size-radius-sm; - background: transparent; - cursor: pointer; - text-align: left; - font-family: inherit; - font-size: var(--bf-appearance-token-font-size-sm); - font-weight: 400; - line-height: 1.25; - width: 100%; - position: relative; - transition: color $motion-fast $easing-standard, - background $motion-fast $easing-standard; - - &:hover { - @include nav-font.nav-panel-text-heading; - - background: var(--bf-appearance-token-element-bg-soft); + padding-block: 7px; + + // Search copy lives in ActionItem's label, not on its outer wrapper. + > [data-bf-part="trigger"] > [data-bf-part="label"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; } - &.is-highlighted { - @include nav-font.nav-panel-text-heading; - - background: var(--bf-appearance-token-element-bg-soft); - } - - &.is-active { - @include nav-font.nav-panel-text-heading; - - background: var(--bf-appearance-token-element-bg-soft); - font-weight: 600; - } - - &:focus-visible { - outline: 2px solid var(--bf-appearance-token-color-accent-500); - outline-offset: -1px; + &.is-highlighted:not(:has(> [data-bf-part="trigger"][aria-current])) { + background: var(--bf-color-action-neutral-surface); } } &__search-result-line { width: 100%; + overflow: hidden; + text-overflow: ellipsis; line-height: 1.35; - font-weight: 500; } &__search-result-desc { @include nav-font.nav-panel-text-meta; width: 100%; + overflow: hidden; + text-overflow: ellipsis; font-size: var(--bf-appearance-token-font-size-2xs); line-height: 1.35; font-weight: 400; @@ -151,42 +127,6 @@ border-radius: 2px; } - // ── Scrollable sections ──────────────────────────────── - - &__sections { - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: $size-gap-4; - padding: 0; - } - - // ── Category group ───────────────────────────────────── - - &__category { - display: flex; - flex-direction: column; - gap: $size-gap-1; - } - - &__category-header { - display: flex; - align-items: center; - width: 100%; - height: 22px; - margin: 0; - padding: 0 $size-gap-2; - } - - &__category-label { - @include nav-font.nav-panel-text-meta; - - font-size: 11px; - font-weight: 500; - line-height: 1.25; - transition: color $motion-fast $easing-standard; - } - // ── Tab items ────────────────────────────────────────── &__items { @@ -203,62 +143,4 @@ white-space: nowrap; text-align: left; } - - &__item { - @include nav-font.nav-panel-text-body; - - display: flex; - align-items: center; - justify-content: space-between; - gap: $size-gap-2; - height: 30px; - padding: 0 $size-gap-2; - border: none; - border-radius: $size-radius-base; - background: transparent; - cursor: pointer; - text-align: left; - font-family: inherit; - font-size: var(--bf-appearance-token-font-size-sm); - font-weight: 400; - line-height: 1.25; - width: 100%; - position: relative; - transition: color $motion-fast $easing-standard, - background $motion-fast $easing-standard, - transform 120ms cubic-bezier(0.23, 1, 0.32, 1); - - &:hover { - @include nav-font.nav-panel-text-heading; - - background: var(--bf-appearance-token-element-bg-soft); - } - - &:active { - transform: scale(0.985); - background: var(--bf-appearance-token-element-bg-medium); - } - - &.is-active { - @include nav-font.nav-panel-text-heading; - - background: var(--bf-appearance-token-element-bg-soft); - font-weight: 600; - box-shadow: none; - } - - &:focus-visible { - @include nav-font.nav-panel-text-heading; - - outline: 2px solid var(--bf-appearance-token-color-accent-500); - outline-offset: -1px; - } - } -} - -@media (prefers-reduced-motion: reduce) { - .bitfun-settings-nav { - &__item { transition: none; } - &__search-result-item { transition: none; } - } } diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.test.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.test.tsx new file mode 100644 index 0000000000..bd48271aa0 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('./settingsRegistry', () => { + const pages = [ + { id: 'application.general', categoryId: 'application', labelKey: 'General' }, + { id: 'application.appearance', categoryId: 'application', labelKey: 'Appearance' }, + { id: 'ai.models', categoryId: 'ai', labelKey: 'Models' }, + ].map((page) => ({ ...page, descriptionKey: 'Description', keywords: ['settings'], searchPhrases: [] })); + return { + DEFAULT_SETTINGS_PAGE_ID: 'application.general', + SETTINGS_PAGE_MANIFESTS: pages, + SETTINGS_CATEGORIES: [ + { id: 'application', labelKey: 'Application', pages: pages.slice(0, 2) }, + { id: 'ai', labelKey: 'AI', pages: pages.slice(2) }, + ], + preloadSettingsPage: vi.fn(async () => undefined), + }; +}); + +vi.mock('react-i18next', () => { + const t = (key: string) => key; + const i18n = { language: 'en-US', getFixedT: () => t }; + return { useTranslation: () => ({ t, i18n }) }; +}); +vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); +vi.mock('@/shared/utils/motionPreference', () => ({ + getInteractionMotion: () => 'instant', +})); + +import SettingsNav from './SettingsNav'; +import { useSettingsStore } from './settingsStore'; + +describe('SettingsNav shared component composition', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.useFakeTimers(); + useSettingsStore.setState(useSettingsStore.getInitialState()); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => root.render()); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + }); + + async function search(query: string) { + const input = container.querySelector('input')!; + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, query); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => { await vi.advanceTimersByTimeAsync(150); }); + return input; + } + + function pressKey(target: Element, key: string) { + target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + } + + it('keeps the header outside the scroll area and applies content styles to the real content slot', () => { + const nav = container.querySelector('nav')!; + const header = nav.querySelector(':scope > [data-bf-part="header"]')!; + const viewport = nav.querySelector('[data-bf-component="scroll-area"]')!; + const content = viewport.querySelector('[data-bf-part="content"]')!; + expect(header.querySelector('.bitfun-settings-nav__title')).not.toBeNull(); + expect(header.querySelector('input')).not.toBeNull(); + expect(viewport.contains(header)).toBe(false); + expect(content.classList.contains('bitfun-settings-nav__content')).toBe(true); + expect(content.querySelectorAll(':scope > section')).toHaveLength(2); + expect(content.querySelectorAll('[data-bf-part="heading-label"]')).toHaveLength(2); + const caption = content.querySelector('.bitfun-settings-nav__category-label')!; + expect(caption.parentElement?.getAttribute('data-bf-part')).toBe('heading-label'); + expect(content.querySelectorAll('[data-testid="settings-nav-page"]')).toHaveLength(3); + }); + + it('drives the shared selected state from the active destination', async () => { + const general = container.querySelector('[data-settings-page="application.general"]')!; + const appearance = container.querySelector('[data-settings-page="application.appearance"]')!; + expect(general.getAttribute('aria-current')).toBe('page'); + expect(general.parentElement?.getAttribute('data-bf-component')).toBe('action-item'); + await act(async () => appearance.click()); + expect(useSettingsStore.getState().activePageId).toBe('application.appearance'); + expect(appearance.getAttribute('aria-current')).toBe('page'); + const selectedLabel = appearance.querySelector('[data-bf-part="label"]')!; + expect(selectedLabel.matches('.bitfun-settings-nav__item > [data-bf-part="trigger"][aria-current] > [data-bf-part="label"]')).toBe(true); + expect(selectedLabel.querySelector('.bitfun-settings-nav__item-label')).not.toBeNull(); + expect(general.hasAttribute('aria-current')).toBe(false); + expect(container.querySelectorAll('[aria-current="page"]')).toHaveLength(1); + expect(container.querySelector('.is-active')).toBeNull(); + }); + + it('keeps two-line search results and keyboard selection working with real navigation items', async () => { + const input = await search('settings'); + const results = container.querySelector('[role="listbox"]')!; + expect(results.querySelectorAll('[role="option"]')).toHaveLength(3); + const first = results.querySelector('[role="option"]')!; + const label = first.querySelector('[data-bf-part="label"]')!; + expect(label.querySelector(':scope > .bitfun-settings-nav__search-result-line')).not.toBeNull(); + expect(label.querySelector(':scope > .bitfun-settings-nav__search-result-desc')).not.toBeNull(); + expect(first.getAttribute('aria-current')).toBe('page'); + + await act(async () => pressKey(input, 'ArrowDown')); + expect(document.activeElement).toBe(results); + expect(results.getAttribute('aria-activedescendant')).toBe('settings-nav-result-0'); + act(() => pressKey(results, 'ArrowDown')); + expect(results.getAttribute('aria-activedescendant')).toBe('settings-nav-result-1'); + expect(results.querySelector('.is-highlighted > button')?.id).toBe('settings-nav-result-1'); + await act(async () => pressKey(results, 'Enter')); + expect(useSettingsStore.getState().activePageId).toBe('application.appearance'); + expect(input.value).toBe(''); + expect(container.querySelector('[role="listbox"]')).toBeNull(); + }); + + it('restores grouped navigation after clearing an empty search', async () => { + const input = await search('no-matching-page'); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + act(() => pressKey(input, 'Escape')); + expect(input.value).toBe(''); + expect(container.querySelectorAll('[data-testid="settings-nav-page"]')).toHaveLength(3); + expect(container.querySelector('[role="status"]')).toBeNull(); + }); +}); diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx index b9375aec2d..5d66a6f76d 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx @@ -206,6 +206,7 @@ const SettingsNav: React.FC = () => { return ( { data-bf-state={[active && 'active', selected && 'selected'].filter(Boolean).join(' ') || undefined} className={[ 'bitfun-settings-nav__search-result-item', - active && 'is-active', selected && 'is-highlighted', ].filter(Boolean).join(' ')} onClick={() => activate(row.destination, true)} @@ -319,10 +319,7 @@ const SettingsNav: React.FC = () => { data-bf-component="settings-nav" data-bf-part="item" data-bf-state={activePageId === page.id ? 'active' : undefined} - className={[ - 'bitfun-settings-nav__item', - activePageId === page.id && 'is-active', - ].filter(Boolean).join(' ')} + className="bitfun-settings-nav__item" selected={activePageId === page.id} onClick={() => activate({ pageId: page.id })} onPointerEnter={() => preload(page.id)} diff --git a/src/web-ui/src/app/scenes/settings/SettingsNavTypography.test.ts b/src/web-ui/src/app/scenes/settings/SettingsNavTypography.test.ts index 953edaa36f..e586e9663f 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNavTypography.test.ts +++ b/src/web-ui/src/app/scenes/settings/SettingsNavTypography.test.ts @@ -1,15 +1,38 @@ +// @vitest-environment jsdom + import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { compile } from 'sass'; import { describe, expect, it } from 'vitest'; +const stylesheetPath = resolve(__dirname, 'SettingsNav.scss'); + function readSettingsNavStylesheet(): string { - return readFileSync( - fileURLToPath(new URL('./SettingsNav.scss', import.meta.url)), - 'utf8', - ).replace(/\r\n/g, '\n'); + return readFileSync(stylesheetPath, 'utf8').replace(/\r\n/g, '\n'); } -describe('SettingsNav typography', () => { +const styleElement = document.createElement('style'); +styleElement.textContent = compile(stylesheetPath).css; +document.head.appendChild(styleElement); +const rules = Array.from(styleElement.sheet!.cssRules).filter( + (rule): rule is CSSStyleRule => rule instanceof CSSStyleRule, +); +styleElement.remove(); + +function declarations(selector: string): CSSStyleDeclaration { + const matches = rules.filter((entry) => entry.selectorText === selector); + expect(matches.length, `Missing style rule: ${selector}`).toBeGreaterThan(0); + const merged = document.createElement('div').style; + for (const rule of matches) { + for (let index = 0; index < rule.style.length; index += 1) { + const property = rule.style.item(index); + merged.setProperty(property, rule.style.getPropertyValue(property)); + } + } + return merged; +} + +describe('SettingsNav typography and layout ownership', () => { it('uses the main navigation font scope and semantic text roles', () => { const stylesheet = readSettingsNavStylesheet(); @@ -18,15 +41,42 @@ describe('SettingsNav typography', () => { expect(stylesheet).toContain('@include nav-font.nav-panel-text-body;'); expect(stylesheet).toContain('@include nav-font.nav-panel-text-heading;'); expect(stylesheet).toContain('@include nav-font.nav-panel-text-meta;'); - expect(stylesheet).toContain('font-family: inherit;'); + expect(stylesheet).not.toContain('text-transform: uppercase;'); }); - it('matches the main navigation category and item reading rhythm', () => { - const stylesheet = readSettingsNavStylesheet(); + it('keeps title, search, and body separated by the navigation spacing token', () => { + const root = declarations('.bitfun-settings-nav'); + const header = declarations('.bitfun-settings-nav > [data-bf-part=header]'); + expect(root.getPropertyValue('gap')).toBe('var(--bf-layout-navigation-panel-content-gap)'); + expect(header.getPropertyValue('display')).toBe('flex'); + expect(header.getPropertyValue('flex-direction')).toBe('column'); + expect(header.getPropertyValue('gap')).toBe('var(--bf-layout-navigation-panel-content-gap)'); + expect(header.getPropertyValue('padding')).toBe('0px'); + }); - expect(stylesheet).toContain('font-size: 11px;\n font-weight: 500;\n line-height: 1.25;'); - expect(stylesheet).toContain('font-size: var(--bf-appearance-token-font-size-sm);\n font-weight: 400;\n line-height: 1.25;'); - expect(stylesheet).toContain('&.is-active {\n @include nav-font.nav-panel-text-heading;\n\n background: var(--bf-appearance-token-element-bg-soft);\n font-weight: 600;'); - expect(stylesheet).not.toContain('text-transform: uppercase;'); + it('resolves shared navigation text against the current chrome instead of secondary action ink', () => { + const root = declarations('.bitfun-settings-nav'); + expect(root.getPropertyValue('--bf-color-content-primary')).toBe('var(--bf-appearance-token-color-text-primary)'); + expect(root.getPropertyValue('--bf-color-action-neutral-content')).toBe(''); + }); + + it('leaves group spacing, heading typography, and item states to NavigationPanel', () => { + const content = declarations('.bitfun-settings-nav__content'); + expect(content.getPropertyValue('padding')).toBe('0px'); + expect(content.getPropertyValue('gap')).toBe(''); + expect(rules.some((rule) => /\.bitfun-settings-nav__item(?=[:.\s,]|$)/.test(rule.selectorText))).toBe(false); + expect(rules.some((rule) => rule.selectorText.includes('.bitfun-settings-nav__category'))).toBe(false); + expect(readSettingsNavStylesheet()).not.toContain('element-bg-soft'); + }); + + it('stacks search copy inside the shared label without overriding the selected item', () => { + const label = declarations('.bitfun-settings-nav__search-result-item > [data-bf-part=trigger] > [data-bf-part=label]'); + expect(label.getPropertyValue('display')).toBe('flex'); + expect(label.getPropertyValue('flex-direction')).toBe('column'); + expect(label.getPropertyValue('gap')).toBe('2px'); + expect(declarations('.bitfun-settings-nav__search-result-item').getPropertyValue('background')).toBe(''); + const highlight = rules.find((rule) => rule.selectorText.includes('.is-highlighted')); + expect(highlight?.selectorText).toContain(':not(:has(> [data-bf-part=trigger][aria-current]))'); + expect(highlight?.style.getPropertyValue('background')).toBe('var(--bf-color-action-neutral-surface)'); }); });