From c66f9195a1e42c2738f1bbe99f8c401aea2a821b Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Wed, 9 Sep 2026 00:43:23 +0800 Subject: [PATCH] fix(ui): align tab and segmented control geometry --- .../src/pages/ComponentDetailPage.tsx | 9 +++++---- .../design-lab/src/pages/PatternsPage.tsx | 6 +++--- .../src/pages/ReferencePatterns.tsx | 4 ++-- .../design-tokens/src/system.tokens.json | 9 ++++++--- .../design-tokens/tests/contract.test.mjs | 6 +++--- design-system/packages/ui/README.md | 2 ++ .../SegmentedControl/SegmentedControl.meta.ts | 5 +++-- .../SegmentedControl.module.css | 10 ++++++---- .../src/components/TabGroup/TabGroup.meta.ts | 8 ++++++-- .../components/TabGroup/TabGroup.module.css | 20 ++++++++++++------- .../packages/ui/tests/registry.test.mjs | 5 +++++ .../ui/generated/design-system.css | 9 ++++++--- .../miniapps/views/MiniAppGalleryView.tsx | 2 +- .../miniapps/views/MiniAppMarketView.tsx | 2 +- .../settings/components/SettingsViewPage.tsx | 1 + 15 files changed, 63 insertions(+), 35 deletions(-) diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index 73075f9fd7..711ce87dbf 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -356,7 +356,7 @@ export function ComponentDetailPage({ const [pageHeaderRequired, setPageHeaderRequired] = useState(false); const [actionItemShowMetadata, setActionItemShowMetadata] = useState(false); const [actionCardSize, setActionCardSize] = useState("sm"); - const [tabGroupSize, setTabGroupSize] = useState("md"); + const [tabGroupSize, setTabGroupSize] = useState("sm"); const [toolbarSize, setToolbarSize] = useState("sm"); const [previewState, setPreviewState] = useState( component.name === "Card" @@ -615,7 +615,7 @@ export function ComponentDetailPage({ } if (component.name === "SegmentedControl") { const defaultMode = previewState === "unselected" ? "agent" : "chat"; - return `import { Icon, SegmentedControl } from "@openbitfun/ui";\n\n, label: "${t("components.preview.segmentedChat")}", value: "chat" },\n { label: "${t("components.preview.segmentedAgent")}", value: "agent" },\n ]}\n/>`; + return `import { Icon, SegmentedControl } from "@openbitfun/ui";\n\n, label: "${t("components.preview.segmentedChat")}", value: "chat" },\n { label: "${t("components.preview.segmentedAgent")}", value: "agent" },\n ]}\n/>`; } if (component.name === "StatusPill") { return `import { Icon, StatusPill } from "@openbitfun/ui";\n\n} tone="${previewState}">\n Ask\n`; @@ -1886,8 +1886,9 @@ export function ComponentDetailPage({ const defaultMode = state === "unselected" ? "agent" : "chat"; return ( - + @@ -107,7 +107,7 @@ export function PatternsPage({ colorScheme, contrast, density, tokenOverrides }: - + @@ -155,7 +155,7 @@ export function PatternsPage({ colorScheme, contrast, density, tokenOverrides }: - } description={t("patterns.search.description")} title={t("patterns.search.title")} /> + } description={t("patterns.search.description")} title={t("patterns.search.title")} /> } onClear={() => setQuery("")} onValueChange={setQuery} placeholder={t("patterns.search.searchPlaceholder")} shortcut={Ctrl K} value={query} />
diff --git a/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx b/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx index 2a401cad10..f3fdf8e8e1 100644 --- a/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx +++ b/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx @@ -141,14 +141,14 @@ function ProviderFields() { export function SceneToolbarPattern() { const { t } = useI18n(); - const [tabs, setTabs] = useState(["README.md", "models.ts", "design-system"]); + const [tabs, setTabs] = useState(["README.md", "models.ts", "design-system/packages/ui/src/components/TabGroup/TabGroup.tsx"]); const [active, setActive] = useState("README.md"); const [search, setSearch] = useState(false); const [details, setDetails] = useState(false); const nextTab = useRef(1); const close = (value: string) => { const next = tabs.filter(tab => tab !== value); setTabs(next); if (active === value) setActive(next[0] ?? ""); }; return - ({ value, label: value, id: `pattern-tab-${value}`, panelId: `pattern-panel-${value}`, icon: , endAction: } variant="quiet" size="xs" onClick={() => close(value)} /> }))} />} + ({ value, label: value, id: `pattern-tab-${value}`, panelId: `pattern-panel-${value}`, icon: value === "README.md" ? undefined : , endAction: } variant="quiet" size="xs" onClick={() => close(value)} /> }))} />} trailing={ diff --git a/design-system/packages/design-tokens/src/system.tokens.json b/design-system/packages/design-tokens/src/system.tokens.json index 2b56f707da..6146081bb2 100644 --- a/design-system/packages/design-tokens/src/system.tokens.json +++ b/design-system/packages/design-tokens/src/system.tokens.json @@ -530,9 +530,11 @@ "$type": "dimension", "gap": { "$value": "{space.2}" }, "itemGap": { "$value": "6px" }, + "itemGapSm": { "$value": "{space.1}" }, "itemHeight": { "$value": "{control.height.md}" }, "itemHeightSm": { "$value": "30px" }, "itemIconSize": { "$value": "16px" }, + "itemIconSizeSm": { "$value": "14px" }, "itemPaddingInline": { "$value": "{space.4}" }, "itemPaddingBlockSm": { "$value": "7px" }, "itemPaddingInlineSm": { "$value": "{space.3}" }, @@ -542,14 +544,15 @@ }, "segmentedControl": { "$type": "dimension", - "gap": { "$value": "2px" }, + "gap": { "$value": "{space.1}" }, "padding": { "$value": "2px" }, "paddingMd": { "$value": "3px" }, "radius": { "$value": "{radius.pill}" }, - "segmentHeight": { "$value": "22px" }, - "segmentHeightMd": { "$value": "28px" }, + "segmentHeight": { "$value": "24px" }, + "segmentHeightMd": { "$value": "30px" }, "pillSegmentHeight": { "$value": "24px" }, "segmentPaddingInline": { "$value": "{space.2}" }, + "segmentPaddingInlineMd": { "$value": "{space.3}" }, "segmentGap": { "$value": "{space.1}" }, "segmentRadius": { "$value": "{radius.pill}" }, "pillSegmentRadius": { "$value": "{radius.sm}" }, diff --git a/design-system/packages/design-tokens/tests/contract.test.mjs b/design-system/packages/design-tokens/tests/contract.test.mjs index 75350bce22..bacbf51dfa 100644 --- a/design-system/packages/design-tokens/tests/contract.test.mjs +++ b/design-system/packages/design-tokens/tests/contract.test.mjs @@ -68,11 +68,11 @@ test("TabGroup geometry preserves the capsule selected and outline contract", as test("SegmentedControl geometry preserves compact, filled, and filter compositions", async () => { const systemDocument = await readSource("system.tokens.json"); - assert.equal(tokens["control.segmentedControl.gap"], "2px"); + assert.equal(tokens["control.segmentedControl.gap"], "4px"); assert.equal(tokens["control.segmentedControl.padding"], "2px"); assert.equal(tokens["control.segmentedControl.paddingMd"], "3px"); - assert.equal(tokens["control.segmentedControl.segmentHeight"], "22px"); - assert.equal(tokens["control.segmentedControl.segmentHeightMd"], "28px"); + assert.equal(tokens["control.segmentedControl.segmentHeight"], "24px"); + assert.equal(tokens["control.segmentedControl.segmentHeightMd"], "30px"); assert.equal(tokens["control.segmentedControl.pillSegmentHeight"], "24px"); assert.equal(tokens["control.segmentedControl.segmentPaddingInline"], "8px"); assert.equal(tokens["control.segmentedControl.segmentGap"], "4px"); diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 5fa5ef09b8..b4fc296381 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -413,3 +413,5 @@ 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. + +Compact tabs use `size="sm"` (30px, 14px icons, 4px icon gap); standard tabs retain 40px and 16px icons. Tabs share the outline-button surface contract and keep selection separate from pointer press. `SegmentedControl size="md"` uses a borderless 36px bar with 30px segments, 3px inset, 4px gaps and 12px segment padding. The default `sm` bar keeps its 28px outer height; separate pills retain their existing heights. Mobile controls own their touch geometry independently. diff --git a/design-system/packages/ui/src/components/SegmentedControl/SegmentedControl.meta.ts b/design-system/packages/ui/src/components/SegmentedControl/SegmentedControl.meta.ts index b9a496d0eb..8808ae16eb 100644 --- a/design-system/packages/ui/src/components/SegmentedControl/SegmentedControl.meta.ts +++ b/design-system/packages/ui/src/components/SegmentedControl/SegmentedControl.meta.ts @@ -16,13 +16,13 @@ export const segmentedControlMeta = { { defaultValue: '"accent"', name: "tone", type: '"accent" | "neutral"' }, { defaultValue: '"bar"', name: "variant", type: '"bar" | "pills"' }, ], - states: ["selected", "unselected", "hover", "disabled"], + states: ["selected", "unselected", "hover", "active", "disabled"], tokens: [ "color.action.neutral.content", "color.action.neutral.contentDisabled", "color.action.neutral.surfaceHover", "color.action.neutral.surfacePressed", - "color.border.subtle", + "space.3", "color.content.primary", "color.focus.ring", "color.surface.raised", @@ -35,6 +35,7 @@ export const segmentedControlMeta = { "control.segmentedControl.segmentHeightMd", "control.segmentedControl.pillSegmentHeight", "control.segmentedControl.segmentPaddingInline", + "control.segmentedControl.segmentPaddingInlineMd", "control.segmentedControl.segmentGap", "control.segmentedControl.segmentRadius", "control.segmentedControl.pillSegmentRadius", 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 372908d7c8..8cf5a44861 100644 --- a/design-system/packages/ui/src/components/SegmentedControl/SegmentedControl.module.css +++ b/design-system/packages/ui/src/components/SegmentedControl/SegmentedControl.module.css @@ -11,9 +11,9 @@ align-items: center; gap: var(--openbitfun-control-segmented-control-gap); padding: var(--openbitfun-control-segmented-control-padding); - border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + border: 0; border-radius: var(--openbitfun-control-segmented-control-radius); - background: var(--openbitfun-color-action-neutral-surface); + background: var(--openbitfun-color-action-neutral-surface-hover); } .root[data-distribution="fill"] { @@ -63,6 +63,7 @@ .root[data-size="md"] .segment { block-size: var(--openbitfun-control-segmented-control-segment-height-md); + padding-inline: var(--openbitfun-control-segmented-control-segment-padding-inline-md); } .root[data-size="md"] .icon { @@ -108,7 +109,8 @@ background: var(--openbitfun-color-action-neutral-surface-hover); } - .segment:active:not(:disabled)[aria-checked="false"] { + .segment:active:not(:disabled)[aria-checked="false"], + .root[data-openbitfun-preview-state="active"] .segment[aria-checked="false"]:not(:disabled) { background: var(--openbitfun-color-action-neutral-surface-pressed); } @@ -153,7 +155,7 @@ @media (forced-colors: active) { .root { - border-color: ButtonText; + outline: var(--openbitfun-border-width-default) solid ButtonText; } .segment[aria-checked="true"] { diff --git a/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts b/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts index 5aeb62a13b..80baf00f27 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts @@ -13,10 +13,12 @@ export const tabGroupMeta = { { name: "onValueChange", type: "(value: string) => void" }, { defaultValue: "md", name: "size", type: "sm | md" }, ], - states: ["selected", "unselected", "hover", "disabled"], + states: ["selected", "unselected", "hover", "active", "disabled"], tokens: [ "opacity.iconArtwork", - "color.action.neutral.border", + "component.button.outlineBorder", + "component.button.outlineBorderInteractive", + "component.button.fillBackground", "color.action.neutral.content", "color.action.neutral.contentDisabled", "color.action.neutral.surface", @@ -26,9 +28,11 @@ export const tabGroupMeta = { "color.focus.ring", "control.tabGroup.gap", "control.tabGroup.itemGap", + "control.tabGroup.itemGapSm", "control.tabGroup.itemHeight", "control.tabGroup.itemHeightSm", "control.tabGroup.itemIconSize", + "control.tabGroup.itemIconSizeSm", "control.tabGroup.itemPaddingInline", "control.tabGroup.itemPaddingBlockSm", "control.tabGroup.itemPaddingInlineSm", 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 5cd105e92d..f165e9239b 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css @@ -3,10 +3,12 @@ --_tab-group-item-height: var(--openbitfun-control-tab-group-item-height); --_tab-group-item-padding-block: var(--openbitfun-space-component-block); --_tab-group-item-padding-inline: var(--openbitfun-control-tab-group-item-padding-inline); + --_tab-group-item-gap: var(--openbitfun-control-tab-group-item-gap); + --_tab-group-item-icon-size: var(--openbitfun-control-tab-group-item-icon-size); --_tab-group-item-action-reserve: calc( var(--openbitfun-control-tab-group-item-action-size) + var(--openbitfun-control-tab-group-item-action-inset) - + var(--openbitfun-control-tab-group-item-gap) + + var(--_tab-group-item-gap) ); display: inline-flex; @@ -18,6 +20,8 @@ --_tab-group-item-height: var(--openbitfun-control-tab-group-item-height-sm); --_tab-group-item-padding-block: var(--openbitfun-control-tab-group-item-padding-block-sm); --_tab-group-item-padding-inline: var(--openbitfun-control-tab-group-item-padding-inline-sm); + --_tab-group-item-gap: var(--openbitfun-control-tab-group-item-gap-sm); + --_tab-group-item-icon-size: var(--openbitfun-control-tab-group-item-icon-size-sm); } .item { @@ -30,9 +34,9 @@ .tab { --_tab-background: transparent; - --_tab-background-hover: var(--openbitfun-color-action-neutral-surface); - --_tab-background-active: var(--openbitfun-color-action-neutral-surface-pressed); - --_tab-border: var(--openbitfun-color-action-neutral-border); + --_tab-background-hover: var(--openbitfun-component-button-fill-background); + --_tab-background-active: var(--openbitfun-component-button-fill-background); + --_tab-border: var(--openbitfun-component-button-outline-border); --_tab-content: var(--openbitfun-color-content-primary); display: inline-flex; @@ -82,11 +86,13 @@ .item:hover .tab:not(:disabled), .tabGroup[data-openbitfun-preview-state="hover"] .tab[aria-selected="false"]:not(:disabled) { background: var(--_tab-background-hover); + border-color: var(--openbitfun-component-button-outline-border-interactive); } .tab:active:not(:disabled), .tabGroup[data-openbitfun-preview-state="active"] .tab[aria-selected="false"]:not(:disabled) { background: var(--_tab-background-active); + border-color: var(--openbitfun-component-button-outline-border-interactive); font-weight: var(--openbitfun-type-label-selected-font-weight); } @@ -106,9 +112,9 @@ flex: 0 0 auto; align-items: center; justify-content: center; - inline-size: var(--openbitfun-control-tab-group-item-icon-size); - block-size: var(--openbitfun-control-tab-group-item-icon-size); - margin-inline-end: var(--openbitfun-control-tab-group-item-gap); + inline-size: var(--_tab-group-item-icon-size); + block-size: var(--_tab-group-item-icon-size); + margin-inline-end: var(--_tab-group-item-gap); color: currentColor; } diff --git a/design-system/packages/ui/tests/registry.test.mjs b/design-system/packages/ui/tests/registry.test.mjs index be6bceee58..f789373f64 100644 --- a/design-system/packages/ui/tests/registry.test.mjs +++ b/design-system/packages/ui/tests/registry.test.mjs @@ -124,6 +124,11 @@ test("every registered component declares states and owned tokens", () => { token.startsWith("border.") || token.startsWith("color.") || (component.name === "Button" && token.startsWith("component.button.")) || + (component.name === "TabGroup" && [ + "component.button.outlineBorder", + "component.button.outlineBorderInteractive", + "component.button.fillBackground", + ].includes(token)) || token.startsWith("control.") || token.startsWith("effect.") || token.startsWith("font.") || diff --git a/src/apps/data-migrator/ui/generated/design-system.css b/src/apps/data-migrator/ui/generated/design-system.css index da5dba6489..64812c647a 100644 --- a/src/apps/data-migrator/ui/generated/design-system.css +++ b/src/apps/data-migrator/ui/generated/design-system.css @@ -103,7 +103,7 @@ --openbitfun-control-launcher-button-min-inline-size: 104px; --openbitfun-control-launcher-button-padding-inline: 14px; --openbitfun-control-launcher-button-radius: var(--openbitfun-radius-lg); - --openbitfun-control-segmented-control-gap: 2px; + --openbitfun-control-segmented-control-gap: var(--openbitfun-space-1); --openbitfun-control-segmented-control-icon-size: 12px; --openbitfun-control-segmented-control-icon-size-md: 14px; --openbitfun-control-segmented-control-padding: 2px; @@ -112,9 +112,10 @@ --openbitfun-control-segmented-control-pill-segment-radius: var(--openbitfun-radius-sm); --openbitfun-control-segmented-control-radius: var(--openbitfun-radius-pill); --openbitfun-control-segmented-control-segment-gap: var(--openbitfun-space-1); - --openbitfun-control-segmented-control-segment-height: 22px; - --openbitfun-control-segmented-control-segment-height-md: 28px; + --openbitfun-control-segmented-control-segment-height: 24px; + --openbitfun-control-segmented-control-segment-height-md: 30px; --openbitfun-control-segmented-control-segment-padding-inline: var(--openbitfun-space-2); + --openbitfun-control-segmented-control-segment-padding-inline-md: var(--openbitfun-space-3); --openbitfun-control-segmented-control-segment-radius: var(--openbitfun-radius-pill); --openbitfun-control-select-content-gap: var(--openbitfun-space-2); --openbitfun-control-select-indicator-size: 14px; @@ -137,9 +138,11 @@ --openbitfun-control-tab-group-item-action-inset: var(--openbitfun-space-2); --openbitfun-control-tab-group-item-action-size: var(--openbitfun-space-5); --openbitfun-control-tab-group-item-gap: 6px; + --openbitfun-control-tab-group-item-gap-sm: var(--openbitfun-space-1); --openbitfun-control-tab-group-item-height: var(--openbitfun-control-height-md); --openbitfun-control-tab-group-item-height-sm: 30px; --openbitfun-control-tab-group-item-icon-size: 16px; + --openbitfun-control-tab-group-item-icon-size-sm: 14px; --openbitfun-control-tab-group-item-padding-block-sm: 7px; --openbitfun-control-tab-group-item-padding-inline: var(--openbitfun-space-4); --openbitfun-control-tab-group-item-padding-inline-sm: var(--openbitfun-space-3); diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx index 0382cfc75a..a60af58eff 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx @@ -536,7 +536,7 @@ const MiniAppGalleryView: React.FC = ({ tabs }) => { data-openbitfun-component="miniapp-gallery-view" data-openbitfun-part="categoryFilters" > - { const normalizedCategory = category.toLowerCase(); diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx index abc34b7e6e..8af8edd1f7 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx @@ -312,7 +312,7 @@ const MiniAppMarketView: React.FC = ({ tabs }) => { /> )} > - ({ label: categoryLabel(value, t), diff --git a/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.tsx b/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.tsx index 61e2950f6f..64f0c9dd82 100644 --- a/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.tsx +++ b/src/web-ui/src/app/scenes/settings/components/SettingsViewPage.tsx @@ -46,6 +46,7 @@ export const SettingsViewPage: React.FC = ({ >