Преглед изворни кода

Neo: segmented tab styles (flat + glass), tab and dropdown fixes (#5814)

* Neo: segmented tab styles (flat + glass), tab and dropdown fixes

Tabs
- Selection shape comes from the theme: each tabs_variants entry sets
  indicator 'pill' | 'line' (inferred from pill/line classes when missing),
  so themes can add pill-style variants.
- Flat default uses NeoButton bordered colours (bg-button track, pill
  bg-popover / dark:bg-button-hover); new glass variant uses NeoButton glass.
- pillClassName prop; variant text/text_active label classes.
- Track sits outside the clipping box so the glass ring and shadow show.
- Web pill re-measures with ResizeObserver (was off its tab after load).
- Native scroll mode draws a static pill/underline on the active tab.
- Secondary variant inactive labels get a colour (were black in dark).
- sm size gets gap-1 like md/lg.
- No focus ring on the first mouse click after load (Radix focuses from
  mousedown; ours focuses with focusVisible: false first).
- Tabs without content render no panel (empty panels were a tab stop).
- overflow="collapse": widths are tagged with the tab set instead of
  cleared from an effect, which wiped first measurements and left the
  row un-collapsed until a resize.

Dropdowns
- While menu content is loading (registry rows load with next/dynamic),
  the popup reserves one row with a spinner and shows the rows together.
- tabs-overflow menu padding p-2.

Playground: /pg has Tabs sections for every variant, size and overflow mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: tabs and dropdown review fixes

Dropdown popup
- Loading latch is a ref, so a 0-height and a positive layout arriving
  before a re-render can't leave the spinner stuck; maxHeight stays unset
  until the trigger is measured, so content never measures empty because
  of it (native opens no longer wait 350 ms).
- Pending rows are hidden with visibility: hidden on web, so Tab can't
  reach invisible items.
- Web anchors an above-open popup by bottom, so it grows upwards in place
  from the loading row instead of jumping.

Tabs
- Web pill re-measures through the row wrapper's onLayout instead of its
  own ResizeObserver.
- The collapse measure row measures labels with the variant's text classes,
  inactive and active stacked, so the wider one counts.
- In collapse + hug the outer box no longer clips the track's shadow.
- Playground: collapse + hug row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: rename Tabs to TypeScript (no content change)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: type Tabs props (shared tabs.types.ts)

tabs.tsx and tabs.web.tsx share TabsProps / TabItem; per-prop docs move
there. Refs, layout callbacks and menu handlers are typed (web design-View
refs as RNView & HTMLDivElement, as in tooltip.web.tsx), and the web pill
style uses numbers instead of px strings. tabsDebug takes its (ignored)
arguments. Same behaviour; typecheck is back to master's baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: flat tab rail bg-muted/60

Translucent muted rail instead of bg-button, so it takes on tinted
backgrounds; the pill stays lighter than the rail in both schemes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: re-measure the web tab pill when a label resizes

Each visible tab label sits in a design View whose onLayout re-measures the
pill, so a label that changes width while the row keeps its size (a web
font swap in a full-width bar) no longer leaves the pill off its tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko пре 1 недеља
родитељ
комит
290505d40f

+ 92 - 0
neo/packages/app/components/page-layout/_playground/playground.js

@@ -11,6 +11,8 @@
  * NeoButtonStyleProvider / NeoControlSizeProvider cascades, per-style
  * transition behavior, and a "resolved config" debug strip showing the
  * exact env the resolver picked up (platform / pointer / breakpoint / theme).
+ * The Tabs gallery at the end shows the segmented styles that match it
+ * (flat = `bordered`, glass = `glass`).
  */
 
 import React from 'react';
@@ -24,6 +26,7 @@ import {
     resolveScoped,
 } from 'app/design/controls/neo-button/neo-button';
 import { Icon } from 'app/ui/atoms/icon';
+import Tabs from 'app/ui/molecules/tabs/tabs';
 import { appSetting } from 'app/lib/util';
 
 function Section({ title, hint, children }) {
@@ -42,6 +45,53 @@ function Stack({ children }) {
     return <View className="w-full gap-2">{children}</View>;
 }
 
+/* ---------------------------- Tabs gallery ---------------------------- */
+
+const TAB_TITLES = ['Overview', 'Activity', 'Members', 'Media', 'Settings'];
+
+function demoTabs(count = 3) {
+    return TAB_TITLES.slice(0, count).map((title) => ({ key: title.toLowerCase(), title }));
+}
+
+function TabsRow({ label, children }) {
+    return (
+        <View className="w-full gap-1">
+            <Text className="font-mono text-xs text-muted-foreground">{label}</Text>
+            {children}
+        </View>
+    );
+}
+
+function TabsGallery({ variant }) {
+    return (
+        <Stack>
+            {['sm', 'md', 'lg'].map((size) => (
+                <TabsRow key={size} label={`size="${size}"`}>
+                    <Tabs variant={variant} size={size} tabs={demoTabs()} equalWidth />
+                </TabsRow>
+            ))}
+            <TabsRow label="rounded hug">
+                <Tabs variant={variant} rounded hug tabs={demoTabs()} />
+            </TabsRow>
+            <TabsRow label='overflow="collapse" (narrow box)'>
+                <View className="max-w-xs w-full">
+                    <Tabs variant={variant} overflow="collapse" tabs={demoTabs(5)} />
+                </View>
+            </TabsRow>
+            <TabsRow label='overflow="collapse" hug (narrow box)'>
+                <View className="max-w-xs w-full">
+                    <Tabs variant={variant} overflow="collapse" hug tabs={demoTabs(5)} />
+                </View>
+            </TabsRow>
+            <TabsRow label='overflow="scroll" (narrow box)'>
+                <View className="max-w-xs w-full">
+                    <Tabs variant={variant} tabs={demoTabs(5)} />
+                </View>
+            </TabsRow>
+        </Stack>
+    );
+}
+
 /* ---------------------- Sizes resolver chart -------------------------- */
 
 // Synthetic resolver contexts. The chart runs `resolveScoped` against each
@@ -508,6 +558,48 @@ export default function PageLayoutPlayground({ data, children }) {
                         />
                     </Section>
 
+                    <Section
+                        title={'Tabs: flat (variant="default")'}
+                        hint="Translucent bg-muted/60 rail with NeoButton bordered pill colours; the pill is lighter in both schemes."
+                    >
+                        <TabsGallery variant="default" />
+                    </Section>
+
+                    <Section
+                        title={'Tabs: glass (variant="glass")'}
+                        hint="NeoButton glass: blurred track with the glass ring, a lighter lens as the pill. Shown on a tinted backdrop."
+                    >
+                        <View className="w-full rounded-2xl p-4 bg-primary/15">
+                            <TabsGallery variant="glass" />
+                        </View>
+                    </Section>
+
+                    <Section
+                        title={'Tabs: underline (variant="secondary")'}
+                        hint="indicator: 'line'."
+                    >
+                        <TabsGallery variant="secondary" />
+                    </Section>
+
+                    <Section
+                        title="Tabs: trackClassName + pillClassName"
+                        hint="A muted track hides the flat pill in dark mode; pillClassName lifts it per instance."
+                    >
+                        <Stack>
+                            <TabsRow label='trackClassName="bg-muted"'>
+                                <Tabs trackClassName="bg-muted" tabs={demoTabs()} equalWidth />
+                            </TabsRow>
+                            <TabsRow label='trackClassName="bg-muted" pillClassName="bg-popover dark:bg-foreground/20"'>
+                                <Tabs
+                                    trackClassName="bg-muted"
+                                    pillClassName="bg-popover dark:bg-foreground/20"
+                                    tabs={demoTabs()}
+                                    equalWidth
+                                />
+                            </TabsRow>
+                        </Stack>
+                    </Section>
+
                     <Section
                         title="Forwarded ref"
                         hint="NeoButtonRef forwards the ref to the underlying Pressable."

+ 37 - 6
neo/packages/app/settings/theme/tabs.js

@@ -40,24 +40,55 @@ export const settingsTabs = {
             'absolute pointer-events-none',
     },
 
+    /**
+     * Tab bar styles, picked with `<Tabs variant="…">`. Each entry:
+     *   indicator         'pill' (sliding surface behind the active tab) or 'line' (underline).
+     *                     Missing: 'pill' when `pill` is set, else 'line' when `line` is set.
+     *   track             surface behind the whole row (absolute, fills the bar)
+     *   row               the tab list row
+     *   trigger_active / trigger_inactive   each tab (hover washes go on inactive)
+     *   pill / line       the selection indicator
+     *   text / text_active   optional label classes; replace `u-controls-tabs-header-item-text*` when set
+     */
     tabs_variants: {
+        // Flat segmented control: a translucent muted rail (it takes on tinted
+        // backgrounds) with NeoButton `bordered` pill colours. The pill is lighter
+        // than the rail in both schemes (white in light, button-hover in dark,
+        // where the 60% rail sits darker than solid muted).
         default: {
+            indicator: 'pill',
             track:
-                'absolute inset-0 z-0 bg-background border border-muted/60 pointer-events-none',
+                'absolute inset-0 z-0 bg-muted/60 pointer-events-none',
             row: '',
-            trigger_active: ' border border-transparent inset-0 ',
+            trigger_active: '',
             trigger_inactive:
-                ' group text-secondary-foreground web:hover:text-foreground web:hover:bg-muted/50 border border-transparent web:hover:border-background inset-0 ',
-            pill: ' bg-popover/80 shadow-sm border border-popover/80 inset-px',
+                ' group text-secondary-foreground web:hover:text-foreground web:hover:bg-button-hover/60 web:duration-200 ',
+            pill: 'bg-popover dark:bg-button-hover shadow-sm',
             line: '',
         },
+        // Glass segmented control: NeoButton `glass` (`theme.neo_button.styles.glass`).
+        // Track = the glass button at rest, pill = a lighter lens with the pressed shadow.
+        glass: {
+            indicator: 'pill',
+            track:
+                'absolute inset-0 z-0 web:backdrop-blur-md bg-popover/50 shadow-btn-glass dark:shadow-btn-glass-deep pointer-events-none',
+            row: '',
+            trigger_active: '',
+            trigger_inactive:
+                ' group web:hover:bg-muted/50 web:duration-200 ',
+            pill: 'bg-popover shadow-btn-glass-pressed dark:bg-foreground/15 dark:shadow-btn-glass-pressed-deep',
+            line: '',
+            text: 'font-medium text-card-foreground',
+            text_active: 'font-medium text-foreground',
+        },
         secondary: {
+            indicator: 'line',
             track:
                 'absolute inset-0 z-0 bg-transparent pointer-events-none',
             row: '',
             trigger_active: ' ',
             trigger_inactive:
-                ' group web:hover:bg-muted/50 web:duration-200',
+                ' group text-secondary-foreground web:hover:text-foreground web:hover:bg-muted/50 web:duration-200',
             pill: '',
             line: 'bg-ring',
         },
@@ -67,7 +98,7 @@ export const settingsTabs = {
     tabs_sizes: {
         default_size: 'md',
         sm: {
-            header: ' p-1 ',
+            header: ' p-1 gap-1 ',
             /** px — matches horizontal list padding; scroll-into-view uses this so tabs don’t sit flush on the viewport edge */
             scroll_inset: 4,
             gap_px: 4,

+ 1 - 1
neo/packages/app/ui/atoms/dropdown-menu.tsx

@@ -290,7 +290,7 @@ function DropdownMenuPopup({
                     openOnFocus ?? variant === 'tabs-overflow'
                 }
                 contentClassName={cn(
-                    variant === 'tabs-overflow' && 'overflow-visible p-1',
+                    variant === 'tabs-overflow' && 'overflow-visible p-2',
                     contentClassNameProp
                 )}
                 open={isOpen}

+ 85 - 13
neo/packages/app/ui/atoms/dropdown-popup.tsx

@@ -14,6 +14,7 @@ import emitter, { EVENTS } from 'app/context/emitter';
 import { NeoButtonRef, legacyToNeoButtonProps } from 'app/design/controls'
 import { isLegacyButtonProps, stripRoutingKeys } from 'app/design/controls/neo-button/legacy-button-map';
 import type { ComponentType } from 'react';
+import ElementLoading from 'app/ui/atoms/loading';
 
 // Trigger component, loosely typed: the popup passes RN / ARIA props
 // (collapsable, aria-*, onFocusCapture) that NeoButton forwards to its surface.
@@ -21,6 +22,13 @@ const NeoTriggerButton: ComponentType<any> = NeoButtonRef;
 
 const dropdownTheme = appSetting('theme', 'dropdown');
 
+/**
+ * After content was seen loading, wait this long without a height change before showing it.
+ * Each registry row has its own Suspense boundary and React reveals sibling boundaries up to
+ * 300ms apart, so without the wait the menu grows row by row.
+ */
+const CONTENT_SETTLE_MS = 350;
+
 const WEB_FOCUSABLE_SELECTOR = [
     'a[href]',
     'button:not([disabled])',
@@ -111,9 +119,20 @@ export default function DropdownPopup({
     const isDesktop = useIsDesktop();
     const contentRef = useRef<any>(null);
     const restoreFocusRef = useRef<any>(null);
-    const [buttonPos, setButtonPos] = useState({ triggerY: 0, triggerHeight: 0, x: 0, width: 0, height: 0, maxHeight: 0, shouldOpenAbove: false });
+    // `viewportHeight` (web): height of the fixed popup layer, for anchoring an above-open popup by its bottom edge.
+    const [buttonPos, setButtonPos] = useState({ triggerY: 0, triggerHeight: 0, x: 0, width: 0, height: 0, maxHeight: 0, shouldOpenAbove: false, viewportHeight: 0 });
     const [popupHeight, setPopupHeight] = useState(0);
     const [popupRenderedWidth, setPopupRenderedWidth] = useState(0);
+    /**
+     * On web the menu rows come from the component registry (`next/dynamic`). On the first
+     * open their chunk is still loading, so the popup showed as a thin empty strip and then
+     * grew row by row. When the content measures empty we reserve one row with a spinner,
+     * and show the rows once their height has been stable for CONTENT_SETTLE_MS.
+     * The latch is a ref: several layout events can arrive before a re-render.
+     */
+    const [contentPending, setContentPending] = useState(false);
+    const contentLoadingRef = useRef(false);
+    const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
     const { width: windowWidth, height: windowHeight } = useWindowSize();
     const isWeb = useMemo(() => Platform.OS === 'web', []);
     const isIos = useMemo(() => Platform.OS == 'ios', []);
@@ -203,9 +222,10 @@ export default function DropdownPopup({
                 height,
                 maxHeight,
                 shouldOpenAbove,
+                viewportHeight: isWeb ? document.documentElement.clientHeight : 0,
             });
         })) return;
-    }, [measureElement, minPopupWidth, showOnTop, windowHeight, windowWidth]);
+    }, [measureElement, minPopupWidth, showOnTop, windowHeight, windowWidth, isWeb]);
 
     const handleToggle = useCallback(
         (bOpen: boolean) => {
@@ -231,12 +251,20 @@ export default function DropdownPopup({
             }
         } else {
             measureRetryRef.current = 0;
-            setButtonPos({ triggerY: 0, triggerHeight: 0, x: 0, width: 0, height: 0, maxHeight: 0, shouldOpenAbove: false });
+            setButtonPos({ triggerY: 0, triggerHeight: 0, x: 0, width: 0, height: 0, maxHeight: 0, shouldOpenAbove: false, viewportHeight: 0 });
             setPopupHeight(0);
             setPopupRenderedWidth(0);
+            setContentPending(false);
+            contentLoadingRef.current = false;
+            if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
+            settleTimerRef.current = null;
         }
     }, [isRealOpen, updateButtonPosition]);
 
+    useEffect(() => () => {
+        if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
+    }, []);
+
     /** Reposition when any scrollable ancestor scrolls (menu stays anchored to trigger). */
     useEffect(() => {
         if (!isWeb || !isRealOpen) return;
@@ -293,20 +321,23 @@ export default function DropdownPopup({
     // we additionally drive `opacity` + `pointerEvents` to keep the popup
     // hidden while it is still anchored at (0, 0) on native.
     const triggerMeasured = buttonPos.height > 0 || buttonPos.width > 0;
-    // For "above" we also need to know the popup's own height (measured via
-    // onLayout) to anchor it at `triggerTop - popupHeight - gap`. For "below"
-    // we don't need it at all (popup is anchored at `triggerBottom + gap`).
-    const popupMeasured = (!buttonPos.shouldOpenAbove || popupHeight > 0) && popupRenderedWidth > 0;
+    // Native "above" needs the popup's own height (measured via onLayout) to
+    // anchor it at `triggerTop - popupHeight - gap`. Web anchors it by `bottom`,
+    // so it grows upwards in place (e.g. from the loading row to the full menu).
+    // "Below" is anchored at `triggerBottom + gap` on both.
+    const anchorAboveByBottom = isWeb && buttonPos.shouldOpenAbove;
+    const popupMeasured = (!buttonPos.shouldOpenAbove || anchorAboveByBottom || popupHeight > 0) && popupRenderedWidth > 0;
     const hasMeasured = triggerMeasured && popupMeasured;
 
+    // Wait for the content too: on a first (loading) open the rows don't exist yet.
     useEffect(() => {
-        if (!isWeb || !isRealOpen || !hasMeasured) return;
+        if (!isWeb || !isRealOpen || !hasMeasured || contentPending) return;
         const id = requestAnimationFrame(() => {
             const first = getWebFocusableElements(contentRef.current)[0];
             first?.focus?.();
         });
         return () => cancelAnimationFrame(id);
-    }, [isWeb, isRealOpen, hasMeasured]);
+    }, [isWeb, isRealOpen, hasMeasured, contentPending]);
 
     useEffect(() => {
         if (!isWeb || !isRealOpen || !hasMeasured) return;
@@ -393,6 +424,28 @@ export default function DropdownPopup({
         }
     };
 
+    const handleScrollLayout = (e: { nativeEvent: { layout: { height: number } } }) => {
+        const h = Math.round(e.nativeEvent.layout.height);
+        if (h <= 0) {
+            // Nothing rendered yet.
+            if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
+            settleTimerRef.current = null;
+            if (!contentLoadingRef.current) {
+                contentLoadingRef.current = true;
+                setContentPending(true);
+            }
+            return;
+        }
+        if (!contentLoadingRef.current) return;
+        // Content was loading: show it once it stops growing.
+        if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
+        settleTimerRef.current = setTimeout(() => {
+            settleTimerRef.current = null;
+            contentLoadingRef.current = false;
+            setContentPending(false);
+        }, CONTENT_SETTLE_MS);
+    };
+
     const Content = (
         <View
             ref={contentRef}
@@ -400,7 +453,9 @@ export default function DropdownPopup({
             pointerEvents={hasMeasured ? 'auto' : 'none'}
             style={{
                 position: 'absolute',
-                top: popupTop,
+                ...(anchorAboveByBottom
+                    ? { bottom: buttonPos.viewportHeight - buttonPos.triggerY + triggerGapAbove }
+                    : { top: popupTop }),
                 left: finalLeft,
                 opacity: hasMeasured ? 1 : 0,
                 visibility: hasMeasured ? 'visible' : 'hidden',
@@ -408,19 +463,36 @@ export default function DropdownPopup({
                 minWidth: maxPopupWidth ? Math.min(minPopupWidth, cappedPopupWidth) : minPopupWidth,
                 maxWidth: cappedPopupWidth,
                 ...(maxPopupWidth ? { width: cappedPopupWidth } : {}),
-                maxHeight: buttonPos.maxHeight,
+                // Unset until the trigger is measured: a 0 max height would measure the
+                // content as empty and take the loading path.
+                maxHeight: buttonPos.maxHeight || undefined,
                 zIndex: 1000,
-            } as ViewStyle /* web-only `visibility` */}
+            } as unknown as ViewStyle /* web-only `visibility` */}
             className={cn(contentClasses, contentClassName)}
         >
             <ScrollView
                 showsVerticalScrollIndicator={false}
                 tabIndex={isWeb ? -1 : undefined}
-                className={cn('web:outline-none', maxPopupWidth && 'w-full min-w-0 max-w-full')}
+                onLayout={handleScrollLayout}
+                pointerEvents={contentPending ? 'none' : undefined}
+                className={cn(
+                    'web:outline-none',
+                    maxPopupWidth && 'w-full min-w-0 max-w-full',
+                    // Rows that render while loading stay out of flow and hidden (web:
+                    // `visibility: hidden`, so they are out of the Tab order too), and the
+                    // popup keeps its one-row height until the whole menu is ready.
+                    contentPending && 'absolute left-0 right-0 top-0 opacity-0 web:invisible'
+                )}
                 contentContainerClassName={maxPopupWidth ? 'w-full min-w-0' : undefined}
             >
                 {children}
             </ScrollView>
+            {contentPending ? (
+                // One menu row (44px, the regular control height) with a small spinner.
+                <View className="h-11 w-full items-center justify-center">
+                    <ElementLoading size="small" />
+                </View>
+            ) : null}
         </View>
     );
     const openTrigger = () => handleToggle(true);

+ 1 - 1
neo/packages/app/ui/molecules/tabs/tabs-debug.js

@@ -3,7 +3,7 @@
  *   globalThis.__NEO_TABS_DEBUG__ = true
  * Watch Metro for [Tabs:…] lines (render storms, sync loops, layout churn).
  */
-export function tabsDebug() {
+export function tabsDebug(..._args) {
     if (!__DEV__) return;
     if (!globalThis.__NEO_TABS_DEBUG__) return;
 }

+ 46 - 4
neo/packages/app/ui/molecules/tabs/tabs-shared.js

@@ -24,22 +24,37 @@ export const tabsVariants =
 /** Stable fallback so size config is never a fresh (mutable) object per render. */
 const EMPTY_SIZE_CFG = Object.freeze({});
 
+/**
+ * `'pill'` or `'line'`, from the variant's `indicator` key. Older theme entries
+ * without it get `'pill'` when they set a `pill` class, else `'line'` when they
+ * set a `line` class.
+ */
+export function getTabsIndicator(variantCfg) {
+    if (variantCfg?.indicator === 'pill' || variantCfg?.indicator === 'line') {
+        return variantCfg.indicator;
+    }
+    if (variantCfg?.pill?.trim()) return 'pill';
+    if (variantCfg?.line?.trim()) return 'line';
+    return 'pill';
+}
+
 /** Size / variant config and the radii derived from it. Pure. */
 export function getTabsSizing({ size, variant, rounded }) {
     const currentSizeKey = size || tabsSizes?.default_size || 'md';
     const sizeCfg = tabsSizes?.[currentSizeKey] || tabsSizes?.md || EMPTY_SIZE_CFG;
     const variantCfg =
         tabsVariants[variant] || tabsVariants.default || tabsVariants.secondary;
+    const indicator = getTabsIndicator(variantCfg);
 
-    /** Secondary uses a bottom line, not a pill — rounding on outer track/row clips the indicator. */
+    /** A line indicator sits on the bottom edge — rounding the outer track/row would clip it. */
     const radiusTrack =
-        variant === 'secondary'
+        indicator === 'line'
             ? ''
             : rounded
               ? 'rounded-full'
               : sizeCfg.track || 'rounded-xl';
     const radiusRow =
-        variant === 'secondary'
+        indicator === 'line'
             ? ''
             : rounded
               ? 'rounded-full'
@@ -52,6 +67,7 @@ export function getTabsSizing({ size, variant, rounded }) {
         currentSizeKey,
         sizeCfg,
         variantCfg,
+        indicator,
         radiusTrack,
         radiusRow,
         radiusPill,
@@ -63,6 +79,27 @@ export function getTabsSizing({ size, variant, rounded }) {
     };
 }
 
+/**
+ * Tabs without `content` (a bar used only as a switcher) render no panel. An empty panel
+ * is still a tab stop on web (Radix gives it `tabIndex={0}`) and drew a focus ring under the bar.
+ */
+export function hasTabContent(tab) {
+    return tab.content !== undefined && tab.content !== null && tab.content !== false;
+}
+
+/** Label classes for a tab (or the "More" trigger). A variant's `text` / `text_active` replace the theme's. Pure. */
+export function getTabTextClass(active, sizeCfg, variantCfg) {
+    return active
+        ? cn(
+              variantCfg?.text_active || tabsTheme['u-controls-tabs-header-item-text-active'],
+              sizeCfg.text_active
+          )
+        : cn(
+              variantCfg?.text || tabsTheme['u-controls-tabs-header-item-text'],
+              sizeCfg.text
+          );
+}
+
 /**
  * `overflow="collapse"`: which tabs fit, and the "More" menu for the rest.
  * Call `markMenuSelect()` before switching tabs from the menu so the effect
@@ -178,7 +215,12 @@ export function TabsMeasureRow({ ViewComponent = View, tabs, collapseLayout, var
                         'flex-row'
                     )}
                 >
-                    <Text className={cn(sizeCfg.text)}>{tab.title}</Text>
+                    {/* Both label styles stacked: the item measures as wide as the wider one, so a
+                        variant whose `text` / `text_active` change the font still collapses right. */}
+                    <ViewComponent className="flex-col">
+                        <Text className={getTabTextClass(false, sizeCfg, variantCfg)}>{tab.title}</Text>
+                        <Text className={getTabTextClass(true, sizeCfg, variantCfg)}>{tab.title}</Text>
+                    </ViewComponent>
                 </ViewComponent>
             ))}
         </ViewComponent>

+ 209 - 248
neo/packages/app/ui/molecules/tabs/tabs.js → neo/packages/app/ui/molecules/tabs/tabs.tsx

@@ -6,20 +6,23 @@ import {
     useLayoutEffect,
     useMemo,
 } from 'react';
-import { View, ScrollView, Platform } from 'react-native';
+import { View, ScrollView, Platform, type LayoutChangeEvent } from 'react-native';
 import { Text } from 'app/design/typography';
 import * as TabsPrimitive from 'app/ui/primitives/tabs';
 import { cn } from 'app/lib/util';
 import { useTranslation } from 'react-i18next';
-import DropdownMenu from 'app/ui/atoms/dropdown-menu';
+import DropdownMenu, { type DropdownMenuItemData } from 'app/ui/atoms/dropdown-menu';
 import { Icon } from 'app/ui/atoms/icon';
 import {
     tabsTheme,
     getTabsSizing,
     useTabsMoreMenu,
     getTabsBarLayout,
+    getTabTextClass,
+    hasTabContent,
     TabsMeasureRow,
 } from 'app/ui/molecules/tabs/tabs-shared';
+import type { TabItem, TabsProps } from './tabs.types';
 import {
     TABS_UNDERLINE_HEIGHT_PX,
 } from 'app/ui/molecules/tabs/tabs-selection-constants';
@@ -29,10 +32,16 @@ import { tabsDebug } from 'app/ui/molecules/tabs/tabs-debug';
 const useIsomorphicLayoutEffect =
     Platform.OS === 'web' ? useLayoutEffect : useEffect;
 
+/** Underline height for the static (native scroll) line indicator. */
+const staticLineStyle = { height: TABS_UNDERLINE_HEIGHT_PX };
+
+type Layout = { x: number; y: number; width: number; height: number };
+type IndicatorRect = { left: number; top: number; width: number; height: number };
+
 /** RN layout often repeats with tiny float noise; always creating new objects in `onLayout` → `setState` re-renders forever. */
 const LAYOUT_EPS = 0.5;
 
-function isRnLayoutUnchanged(prev, next) {
+function isRnLayoutUnchanged(prev: Layout | undefined, next: Layout) {
     if (!prev || !next) return false;
     return (
         Math.abs(prev.x - next.x) < LAYOUT_EPS &&
@@ -42,7 +51,7 @@ function isRnLayoutUnchanged(prev, next) {
     );
 }
 
-function isIndicatorUnchanged(prev, next) {
+function isIndicatorUnchanged(prev: IndicatorRect, next: IndicatorRect) {
     return (
         Math.abs(prev.left - next.left) < LAYOUT_EPS &&
         Math.abs(prev.top - next.top) < LAYOUT_EPS &&
@@ -52,7 +61,7 @@ function isIndicatorUnchanged(prev, next) {
 }
 
 /** Whole pixels — avoids subpixel layout ↔ indicator sync loops on Fabric. */
-function normalizeRnLayout(layout) {
+function normalizeRnLayout(layout: Layout): Layout {
     return {
         x: Math.round(layout.x),
         y: Math.round(layout.y),
@@ -62,27 +71,9 @@ function normalizeRnLayout(layout) {
 }
 
 /**
- * @param {Array} tabs - { key, title, content }
- * @param {string} [activeTab]
- * @param {boolean} [equalWidth] — When true (and `hug` is false), tabs share extra space equally; each tab keeps at least `min-content` width (label + padding), never shrinking below that.
- * @param {boolean} [fullWidth] — Deprecated: use `equalWidth` instead (same behavior).
- * @param {'default'|'secondary'} [variant]
- * @param {string} [size] sm | md | lg
- * @param {string} [contentClassName]
- * @param {string} [trackClassName]
- * @param {string} [headerClassName] — classes on `Tabs` root (container)
- * @param {string} [tabBarClassName] — Bar wrapper: with `overflow="scroll"`, outer tab bar; with `overflow="collapse"`, the full-width measure row — use `flex flex-row justify-center` (web) / `flex-row justify-center` (native) to center a `hug` strip in the parent.
- * @param {string} [listWrapperClassName] — inner box that contains track + list (e.g. `mx-auto` with `hug`)
- * @param {string} [listClassName] — `TabsList` row only (e.g. `gap-1`, `justify-center`)
- * @param {string} [triggerClassName] — each tab trigger only (e.g. `mx-1`); does not affect tab panel content
- * @param {boolean} [rounded] — pill/track/row use `rounded-full`; when false, radii come from `tabs_sizes` (track, row, pill)
- * @param {boolean} [hug] — triggers only as wide as labels (no equal flex stretch). Combine with `equalWidth={false}` so the strip does not span the parent.
- * @param {'scroll'|'collapse'} [overflow] — `scroll`: horizontal scroll (default). `collapse`: overflow tabs move into a "More" menu.
- * @param {string} [moreMenuTitle] — label for the overflow trigger (default: translated "More"). Pass `""` for icon-only.
- * @param {string} [moreLabel] — alias for `moreMenuTitle` (deprecated).
- * @param {string} [moreMenuIcon] — Lucide icon name for the overflow trigger (default: `ChevronDown`).
- * @param {(key: string) => void} [onTabChange] — fired after the user selects a tab (new tab key).
- * @param {boolean} [disableScrollIntoView] — When true, skip horizontal scroll-to-active-tab after selection (useful on native if scroll fights layout).
+ * Segmented tab bar (native; web is tabs.web.tsx). Styles come from `theme.tabs_variants`
+ * (settings/theme/tabs.js): `variant="default"` is a flat segmented control, `"glass"` NeoButton
+ * glass, `"secondary"` an underline. Props are documented in tabs.types.ts.
  */
 export default function Tabs({
     tabs,
@@ -99,6 +90,7 @@ export default function Tabs({
     size,
     contentClassName = '',
     trackClassName,
+    pillClassName,
     headerClassName,
     tabBarClassName,
     listWrapperClassName,
@@ -106,7 +98,7 @@ export default function Tabs({
     triggerClassName,
     onTabChange,
     disableScrollIntoView = false,
-}) {
+}: TabsProps) {
     /** `fullWidth` is deprecated — same as `equalWidth` (first wins if both are set). */
     const useEqualWidth = equalWidth ?? fullWidth ?? false;
     /**
@@ -136,7 +128,7 @@ export default function Tabs({
 
     const renderDiagRef = useRef(0);
     useIsomorphicLayoutEffect(() => {
-        if (!__DEV__ || !globalThis.__NEO_TABS_DEBUG__) return;
+        if (!__DEV__ || !(globalThis as { __NEO_TABS_DEBUG__?: boolean }).__NEO_TABS_DEBUG__) return;
         renderDiagRef.current += 1;
         tabsDebug('render', {
             n: renderDiagRef.current,
@@ -150,21 +142,21 @@ export default function Tabs({
     /** Layout of `TabsPrimitive.List` relative to the header row (same coords as the selection layer). */
     const listLayoutRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
     /** Per-tab `Pressable` layout relative to the list — avoids `measureLayout`, which can hang on Fabric. */
-    const triggerLayoutsRef = useRef({});
+    const triggerLayoutsRef = useRef<Record<string, Layout>>({});
     /** "More" row view — `onLayout` is not relative to the tab list (Dropdown wraps the trigger). */
-    const moreViewRef = useRef(null);
+    const moreViewRef = useRef<View | null>(null);
     const currentTabRef = useRef(currentTab);
     useIsomorphicLayoutEffect(() => {
         currentTabRef.current = currentTab;
     });
 
-    const headerRowLayoutRef = useRef(null);
-    const scrollViewRef = useRef(null);
+    const headerRowLayoutRef = useRef<View | null>(null);
+    const scrollViewRef = useRef<ScrollView | null>(null);
     const scrollXRef = useRef(0);
     const scrollViewWidthRef = useRef(0);
     const skipFirstScrollIntoViewRef = useRef(true);
 
-    const [indicatorLayout, setIndicatorLayout] = useState({
+    const [indicatorLayout, setIndicatorLayout] = useState<IndicatorRect>({
         left: 0,
         top: 0,
         width: 0,
@@ -174,6 +166,7 @@ export default function Tabs({
         currentSizeKey,
         sizeCfg,
         variantCfg,
+        indicator,
         radiusTrack,
         radiusRow,
         radiusPill,
@@ -212,14 +205,14 @@ export default function Tabs({
         const tabKey = currentTabRef.current;
         const list = listLayoutRef.current;
 
-        const applyFromRect = (leftInHeader, topInHeader, width, height) => {
+        const applyFromRect = (leftInHeader: number, topInHeader: number, width: number, height: number) => {
             const l = Math.round(leftInHeader);
             const t0 = Math.round(topInHeader);
             const w = Math.round(width);
             const rawH = Math.round(height);
             let top;
             let h;
-            if (variant === 'secondary') {
+            if (indicator === 'line') {
                 top = t0 + rawH - TABS_UNDERLINE_HEIGHT_PX;
                 h = TABS_UNDERLINE_HEIGHT_PX;
             } else {
@@ -256,7 +249,7 @@ export default function Tabs({
 
         if (!list || list.width <= 0) return;
 
-        const triggerL = triggerLayoutsRef.current[tabKey];
+        const triggerL = tabKey ? triggerLayoutsRef.current[tabKey] : undefined;
         if (!triggerL) return;
 
         applyFromRect(
@@ -266,7 +259,7 @@ export default function Tabs({
             triggerL.height
         );
     }, [
-        variant,
+        indicator,
         overflow,
         collapseLayout.visibleCount,
         nativeScrollSkipIndicator,
@@ -278,10 +271,10 @@ export default function Tabs({
     });
 
     /** Coalesce native indicator sync to one rAF — avoids layout↔setState reentrancy on Fabric when many triggers fire onLayout after a selection change. */
-    const indicatorRafRef = useRef(null);
+    const indicatorRafRef = useRef<number | null>(null);
     const indicatorSyncPendingRef = useRef(false);
 
-    const scheduleIndicatorSync = useCallback((source) => {
+    const scheduleIndicatorSync = useCallback((source: string) => {
         if (nativeScrollSkipIndicator) return;
         tabsDebug('schedule', { source });
         const flush = () => {
@@ -311,7 +304,7 @@ export default function Tabs({
     );
 
     const onListLayout = useCallback(
-        (e) => {
+        (e: LayoutChangeEvent) => {
             const layout = normalizeRnLayout(e.nativeEvent.layout);
             if (isRnLayoutUnchanged(listLayoutRef.current, layout)) return;
             listLayoutRef.current = layout;
@@ -321,7 +314,7 @@ export default function Tabs({
     );
 
     const onTriggerLayout = useCallback(
-        (tabKey) => (e) => {
+        (tabKey: string) => (e: LayoutChangeEvent) => {
             const layout = normalizeRnLayout(e.nativeEvent.layout);
             const prev = triggerLayoutsRef.current[tabKey];
             if (isRnLayoutUnchanged(prev, layout)) return;
@@ -334,7 +327,7 @@ export default function Tabs({
 
     const onMoreLayout = collapseLayout.onMoreLayout;
     const onMoreLayoutForTabs = useCallback(
-        (e) => {
+        (e: LayoutChangeEvent) => {
             onMoreLayout(e);
             if (!nativeScrollSkipIndicator) scheduleIndicatorSync('more');
         },
@@ -353,7 +346,7 @@ export default function Tabs({
 
     const scrollActiveTabIntoView = useCallback(() => {
         if (overflow === 'collapse') return;
-        const triggerL = triggerLayoutsRef.current[currentTab];
+        const triggerL = currentTab ? triggerLayoutsRef.current[currentTab] : undefined;
         const list = listLayoutRef.current;
         const scrollView = scrollViewRef.current;
         if (!triggerL || !list || !scrollView) return;
@@ -400,7 +393,7 @@ export default function Tabs({
     /** Theme `u-controls-tabs-selection-layer`: absolute + pointer-events-none + z-1 */
     const selectionStyle = useMemo(
         () => ({
-            position: 'absolute',
+            position: 'absolute' as const,
             left: indicatorLayout.left,
             top: indicatorLayout.top,
             width: indicatorLayout.width,
@@ -411,7 +404,7 @@ export default function Tabs({
     );
 
     /** Stable identity for `TabsPrimitive.Root` — unstable parent `onTabChange` must not recreate context every render. */
-    const handleTabChange = useCallback((value) => {
+    const handleTabChange = useCallback((value: string) => {
         tabsDebug('press', { from: currentTabRef.current, to: value });
         setCurrentTab(value);
         /** Defer sound/haptics so they do not run in the same sync stack as the press → commit (reduces native stalls). */
@@ -420,9 +413,9 @@ export default function Tabs({
     }, []);
 
     const handleOverflowMenuSelect = useCallback(
-        (item) => {
+        (item: DropdownMenuItemData) => {
             markMenuSelect();
-            handleTabChange(item.id);
+            handleTabChange(String(item.id));
         },
         [handleTabChange, markMenuSelect]
     );
@@ -439,6 +432,20 @@ export default function Tabs({
     const { moreIsActive, moreTriggerEndAlign, collapseHugStrip, tabBarWidthClass } =
         getTabsBarLayout({ overflow, hug, equalWidth: useEqualWidth, collapseLayout, currentTab });
 
+    /**
+     * Native scroll mode has no sliding layer (see `nativeScrollSkipIndicator`), so the
+     * active trigger draws the indicator itself: the pill as its own surface, the line
+     * as a child on its bottom edge.
+     */
+    const staticPill =
+        nativeScrollSkipIndicator && indicator === 'pill'
+            ? cn(variantCfg.pill, pillClassName)
+            : '';
+    const staticLine =
+        nativeScrollSkipIndicator && indicator === 'line'
+            ? cn('absolute left-0 right-0 bottom-0 rounded-full', variantCfg.line, pillClassName)
+            : '';
+
     const trackView = (
         <View
             className={cn(
@@ -454,12 +461,13 @@ export default function Tabs({
             pointerEvents="none"
             collapsable={false}
         >
-            {variant === 'default' ? (
+            {indicator === 'pill' ? (
                 <View
                     className={cn(
                         'absolute inset-0',
                         radiusPill,
-                        variantCfg.pill
+                        variantCfg.pill,
+                        pillClassName
                     )}
                 />
             ) : (
@@ -467,7 +475,8 @@ export default function Tabs({
                     className={cn(
                         'absolute inset-0',
                         sizeCfg.indicator_inner,
-                        variantCfg.line
+                        variantCfg.line,
+                        pillClassName
                     )}
                 />
             )}
@@ -477,6 +486,7 @@ export default function Tabs({
     const collapseListInner = (
         <>
             <TabsMeasureRow
+                key={collapseLayout.measureKey}
                 ViewComponent={View}
                 tabs={tabs}
                 collapseLayout={collapseLayout}
@@ -513,7 +523,7 @@ export default function Tabs({
                     listClassName
                 )}
             >
-                {collapseLayout.visibleTabs.map((tab) => (
+                {collapseLayout.visibleTabs.map((tab: TabItem) => (
                     <TabsPrimitive.Trigger
                         key={tab.key}
                         value={tab.key}
@@ -532,21 +542,7 @@ export default function Tabs({
                     >
                         {({ isSelected }) => (
                             <Text
-                                className={cn(
-                                    isSelected
-                                        ? cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text-active'
-                                              ],
-                                              sizeCfg.text_active
-                                          )
-                                        : cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text'
-                                              ],
-                                              sizeCfg.text
-                                          )
-                                )}
+                                className={getTabTextClass(isSelected, sizeCfg, variantCfg)}
                             >
                                 {tab.title}
                             </Text>
@@ -587,21 +583,7 @@ export default function Tabs({
                         >
                             {resolvedMoreMenuTitle ? (
                                 <Text
-                                    className={cn(
-                                        moreIsActive
-                                            ? cn(
-                                                  tabsTheme[
-                                                      'u-controls-tabs-header-item-text-active'
-                                                  ],
-                                                  sizeCfg.text_active
-                                              )
-                                            : cn(
-                                                  tabsTheme[
-                                                      'u-controls-tabs-header-item-text'
-                                                  ],
-                                                  sizeCfg.text
-                                              )
-                                    )}
+                                    className={getTabTextClass(moreIsActive, sizeCfg, variantCfg)}
                                 >
                                     {resolvedMoreMenuTitle}
                                 </Text>
@@ -609,26 +591,12 @@ export default function Tabs({
                             <Icon
                                 icon={moreMenuIcon}
                                 size={moreIconSize}
-                                className={cn(
-                                    moreIsActive
-                                        ? cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text-active'
-                                              ],
-                                              sizeCfg.text_active
-                                          )
-                                        : cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text'
-                                              ],
-                                              sizeCfg.text
-                                          )
-                                )}
+                                className={getTabTextClass(moreIsActive, sizeCfg, variantCfg)}
                             />
                         </View>
                     </DropdownMenu>
                 )}
-                {collapseLayout.overflowTabs.map((tab) => (
+                {collapseLayout.overflowTabs.map((tab: TabItem) => (
                     <TabsPrimitive.Trigger
                         key={tab.key}
                         value={tab.key}
@@ -639,21 +607,7 @@ export default function Tabs({
                     >
                         {({ isSelected }) => (
                             <Text
-                                className={cn(
-                                    isSelected
-                                        ? cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text-active'
-                                              ],
-                                              sizeCfg.text_active
-                                          )
-                                        : cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text'
-                                              ],
-                                              sizeCfg.text
-                                          )
-                                )}
+                                className={getTabTextClass(isSelected, sizeCfg, variantCfg)}
                             >
                                 {tab.title}
                             </Text>
@@ -672,34 +626,58 @@ export default function Tabs({
         >
             <View
                 className={cn(
-                    'relative min-w-0 overflow-hidden',
+                    'relative min-w-0',
                     tabBarWidthClass,
-                    overflow !== 'collapse' && tabBarClassName,
-                    !collapseHugStrip && radiusTrack
+                    overflow !== 'collapse' && tabBarClassName
                 )}
             >
-                {/* Track fills tab bar; in collapse+hug it lives inside the w-max strip */}
+                {/* Track fills tab bar (in collapse+hug it lives inside the w-max strip). It sits outside the clipping box so its outer shadow (glass ring + drop) is not cut off. */}
                 {!collapseHugStrip && trackView}
-                {overflow === 'collapse' ? (
-                    <View
-                        ref={(node) => {
-                            collapseLayout.setContainerRef(node);
-                        }}
-                        onLayout={collapseLayout.onContainerLayout}
-                        className={cn(
-                            'relative z-10 w-full min-w-0',
-                            tabBarClassName
-                        )}
-                    >
-                        {collapseHugStrip ? (
-                            <View
-                                className={cn(
-                                    'relative min-w-0 overflow-hidden',
-                                    'w-max max-w-full self-start',
-                                    radiusTrack
-                                )}
-                            >
-                                {trackView}
+                <View
+                    className={cn(
+                        // Clips the row to the track's corners. In collapse + hug the track and its own
+                        // clip live in the w-max strip below; clipping here would cut the track's shadow.
+                        'relative min-w-0 w-full',
+                        !collapseHugStrip && cn('overflow-hidden', radiusTrack)
+                    )}
+                >
+                    {overflow === 'collapse' ? (
+                        <View
+                            ref={(node) => {
+                                collapseLayout.setContainerRef(node);
+                            }}
+                            onLayout={collapseLayout.onContainerLayout}
+                            className={cn(
+                                'relative z-10 w-full min-w-0',
+                                tabBarClassName
+                            )}
+                        >
+                            {collapseHugStrip ? (
+                                <View className="relative min-w-0 w-max max-w-full self-start">
+                                    {trackView}
+                                    <View
+                                        className={cn(
+                                            'relative min-w-0 w-full overflow-hidden',
+                                            radiusTrack
+                                        )}
+                                    >
+                                        <View
+                                            ref={headerRowLayoutRef}
+                                            collapsable={
+                                                Platform.OS === 'android'
+                                                    ? false
+                                                    : undefined
+                                            }
+                                            className={cn(
+                                                'relative web:isolate min-w-0 w-full',
+                                                listWrapperClassName
+                                            )}
+                                        >
+                                            {collapseListInner}
+                                        </View>
+                                    </View>
+                                </View>
+                            ) : (
                                 <View
                                     ref={headerRowLayoutRef}
                                     collapsable={
@@ -714,129 +692,112 @@ export default function Tabs({
                                 >
                                     {collapseListInner}
                                 </View>
-                            </View>
-                        ) : (
+                            )}
+                        </View>
+                    ) : (
+                        <ScrollView
+                            ref={scrollViewRef}
+                            horizontal
+                            showsHorizontalScrollIndicator={false}
+                            nestedScrollEnabled
+                            keyboardShouldPersistTaps="handled"
+                            scrollEventThrottle={16}
+                            className="relative z-10 w-full max-w-full"
+                            onLayout={(e) => {
+                                scrollViewWidthRef.current =
+                                    e.nativeEvent.layout.width;
+                            }}
+                            onScroll={(e) => {
+                                scrollXRef.current =
+                                    e.nativeEvent.contentOffset.x;
+                            }}
+                            contentContainerStyle={{
+                                flexGrow: 0,
+                                flexDirection: 'row',
+                                alignItems: 'stretch',
+                                ...(hug ? { alignSelf: 'flex-start' } : {}),
+                            }}
+                        >
                             <View
                                 ref={headerRowLayoutRef}
                                 collapsable={
-                                    Platform.OS === 'android'
-                                        ? false
-                                        : undefined
+                                    Platform.OS === 'android' ? false : undefined
                                 }
                                 className={cn(
-                                    'relative web:isolate min-w-0 w-full',
+                                    'relative web:isolate',
+                                    hug
+                                        ? 'w-max self-start'
+                                        : 'min-w-full w-max',
                                     listWrapperClassName
                                 )}
                             >
-                                {collapseListInner}
-                            </View>
-                        )}
-                    </View>
-                ) : (
-                    <ScrollView
-                        ref={scrollViewRef}
-                        horizontal
-                        showsHorizontalScrollIndicator={false}
-                        nestedScrollEnabled
-                        keyboardShouldPersistTaps="handled"
-                        scrollEventThrottle={16}
-                        className="relative z-10 w-full max-w-full"
-                        onLayout={(e) => {
-                            scrollViewWidthRef.current =
-                                e.nativeEvent.layout.width;
-                        }}
-                        onScroll={(e) => {
-                            scrollXRef.current =
-                                e.nativeEvent.contentOffset.x;
-                        }}
-                        contentContainerStyle={{
-                            flexGrow: 0,
-                            flexDirection: 'row',
-                            alignItems: 'stretch',
-                            ...(hug ? { alignSelf: 'flex-start' } : {}),
-                        }}
-                    >
-                        <View
-                            ref={headerRowLayoutRef}
-                            collapsable={
-                                Platform.OS === 'android' ? false : undefined
-                            }
-                            className={cn(
-                                'relative web:isolate',
-                                hug
-                                    ? 'w-max self-start'
-                                    : 'min-w-full w-max',
-                                listWrapperClassName
-                            )}
-                        >
-                            {!nativeScrollSkipIndicator && selectionLayer}
+                                {!nativeScrollSkipIndicator && selectionLayer}
 
-                            <TabsPrimitive.List
-                                onLayout={onListLayout}
-                                className={cn(
-                                    tabsTheme['u-controls-tabs-header-row'],
-                                    variantCfg.row,
-                                    radiusRow,
-                                    sizeCfg.header,
-                                    '!flex-none shrink-0 min-w-0',
-                                    hug
-                                        ? 'w-max justify-start self-start'
-                                        : useEqualWidth
-                                          ? 'w-full min-w-full'
-                                          : 'w-max min-w-full',
-                                    'relative z-20',
-                                    listClassName
-                                )}
-                            >
-                                {tabs.map((tab) => (
-                                    <TabsPrimitive.Trigger
-                                        key={tab.key}
-                                        value={tab.key}
-                                        onLayout={onTriggerLayout(tab.key)}
-                                        className={cn(
-                                            tabsTheme[
-                                                'u-controls-tabs-header-item'
-                                            ],
-                                            sizeCfg.item,
-                                            radiusPill,
-                                            tabStretch,
-                                            'relative z-30',
-                                            tab.key === currentTab
-                                                ? variantCfg.trigger_active
-                                                : variantCfg.trigger_inactive,
-                                            triggerClassName
-                                        )}
-                                    >
-                                        {({ isSelected }) => (
-                                            <Text
-                                                className={cn(
-                                                    isSelected
-                                                        ? cn(
-                                                              tabsTheme[
-                                                                  'u-controls-tabs-header-item-text-active'
-                                                              ],
-                                                              sizeCfg.text_active
-                                                          )
-                                                        : cn(
-                                                              tabsTheme[
-                                                                  'u-controls-tabs-header-item-text'
-                                                              ],
-                                                              sizeCfg.text
-                                                          )
-                                                )}
-                                            >
-                                                {tab.title}
-                                            </Text>
-                                        )}
-                                    </TabsPrimitive.Trigger>
-                                ))}
-                            </TabsPrimitive.List>
-                        </View>
-                    </ScrollView>
-                )}
+                                <TabsPrimitive.List
+                                    onLayout={onListLayout}
+                                    className={cn(
+                                        tabsTheme['u-controls-tabs-header-row'],
+                                        variantCfg.row,
+                                        radiusRow,
+                                        sizeCfg.header,
+                                        '!flex-none shrink-0 min-w-0',
+                                        hug
+                                            ? 'w-max justify-start self-start'
+                                            : useEqualWidth
+                                              ? 'w-full min-w-full'
+                                              : 'w-max min-w-full',
+                                        'relative z-20',
+                                        listClassName
+                                    )}
+                                >
+                                    {tabs.map((tab) => (
+                                        <TabsPrimitive.Trigger
+                                            key={tab.key}
+                                            value={tab.key}
+                                            onLayout={onTriggerLayout(tab.key)}
+                                            className={cn(
+                                                tabsTheme[
+                                                    'u-controls-tabs-header-item'
+                                                ],
+                                                sizeCfg.item,
+                                                radiusPill,
+                                                tabStretch,
+                                                'relative z-30',
+                                                tab.key === currentTab
+                                                    ? variantCfg.trigger_active
+                                                    : variantCfg.trigger_inactive,
+                                                staticPill &&
+                                                    tab.key === currentTab &&
+                                                    staticPill,
+                                                triggerClassName
+                                            )}
+                                        >
+                                            {({ isSelected }) => (
+                                                <>
+                                                    <Text
+                                                        className={getTabTextClass(isSelected, sizeCfg, variantCfg)}
+                                                    >
+                                                        {tab.title}
+                                                    </Text>
+                                                    {staticLine && isSelected ? (
+                                                        <View
+                                                            pointerEvents="none"
+                                                            className={staticLine}
+                                                            style={staticLineStyle}
+                                                        />
+                                                    ) : null}
+                                                </>
+                                            )}
+                                        </TabsPrimitive.Trigger>
+                                    ))}
+                                </TabsPrimitive.List>
+                            </View>
+                        </ScrollView>
+                    )}
+                </View>
             </View>
 
-            {tabs.map((tab) => (
+            {tabs.filter(hasTabContent).map((tab) => (
                 <TabsPrimitive.Content
                     key={tab.key}
                     value={tab.key}

+ 55 - 0
neo/packages/app/ui/molecules/tabs/tabs.types.ts

@@ -0,0 +1,55 @@
+// Props shared by tabs.tsx (native) and tabs.web.tsx.
+import type { ReactNode } from 'react';
+
+export type TabItem = {
+    key: string;
+    title: string;
+    /** Panel content. A tab without content gets no panel (a switcher-only bar). */
+    content?: ReactNode;
+};
+
+export type TabsProps = {
+    tabs?: TabItem[];
+    /** Selected tab key. Changes to it select that tab. */
+    activeTab?: string;
+    /** Key of `theme.tabs_variants`: `default` (flat segmented control, NeoButton `bordered` colours), `glass` (NeoButton `glass`), `secondary` (underline), or a theme's own. The entry's `indicator` (`'pill'` | `'line'`) picks the selection shape. */
+    variant?: string;
+    /** Key of `theme.tabs_sizes` (`sm`, `md`, `lg`). Default `tabs_sizes.default_size`. */
+    size?: string;
+    /** Tabs share extra space equally (unless `hug`); each keeps at least its label width. */
+    equalWidth?: boolean;
+    /** @deprecated Use `equalWidth`. */
+    fullWidth?: boolean;
+    /** Track, row and pill use `rounded-full`; otherwise radii come from `tabs_sizes`. */
+    rounded?: boolean;
+    /** Tabs only as wide as their labels. Combine with `equalWidth={false}` so the strip doesn't span the parent. */
+    hug?: boolean;
+    /** `scroll` (default): the row scrolls horizontally. `collapse`: tabs that don't fit move into a "More" menu. */
+    overflow?: 'scroll' | 'collapse';
+    /** Label of the overflow trigger (default: translated "More"). `""` for icon-only. */
+    moreMenuTitle?: string;
+    /** @deprecated Use `moreMenuTitle`. */
+    moreLabel?: string;
+    /** Lucide icon name of the overflow trigger (default `ChevronDown`). */
+    moreMenuIcon?: string;
+    /** Each tab panel. */
+    contentClassName?: string;
+    /** The surface behind the row (e.g. `bg-muted`). */
+    trackClassName?: string;
+    /** The selection indicator: the pill, or the underline for `indicator: 'line'` (e.g. a lighter pill on a tinted track). */
+    pillClassName?: string;
+    /** The `Tabs` root (container). */
+    headerClassName?: string;
+    /** With `overflow="scroll"`, the outer tab bar; with `overflow="collapse"`, the full-width measure row. `flex flex-row justify-center` centres a `hug` strip. */
+    tabBarClassName?: string;
+    /** The box around track, list and indicator (e.g. `mx-auto` with `hug`). */
+    listWrapperClassName?: string;
+    /** The tab row only (e.g. `gap-1`, `justify-center`). */
+    listClassName?: string;
+    /** Each tab trigger (not the panels). */
+    triggerClassName?: string;
+    /** Fired when the user selects a tab (not when `activeTab` changes). */
+    onTabChange?: (key: string) => void;
+    /** Native: skip scrolling the selected tab into view (if scrolling fights layout). */
+    disableScrollIntoView?: boolean;
+};

+ 127 - 141
neo/packages/app/ui/molecules/tabs/tabs.web.js → neo/packages/app/ui/molecules/tabs/tabs.web.tsx

@@ -1,18 +1,25 @@
 import { Text } from 'app/design/typography';
-import { useState, useRef, useEffect, useCallback } from 'react';
+import { useState, useRef, useEffect, useCallback, type MouseEvent, type RefObject } from 'react';
 import * as TabsPrimitive from '@radix-ui/react-tabs';
 import { View } from 'app/design/view';
 import { cn } from 'app/lib/util';
 import { useTranslation } from 'react-i18next';
-import DropdownMenu from 'app/ui/atoms/dropdown-menu';
+import DropdownMenu, { type DropdownMenuItemData } from 'app/ui/atoms/dropdown-menu';
 import { Icon } from 'app/ui/atoms/icon';
 import {
     tabsTheme,
     getTabsSizing,
     useTabsMoreMenu,
     getTabsBarLayout,
+    getTabTextClass,
+    hasTabContent,
     TabsMeasureRow,
 } from 'app/ui/molecules/tabs/tabs-shared';
+import type { View as RNView } from 'react-native';
+import type { TabItem, TabsProps } from './tabs.types';
+
+/** Design `View` ref on web: typed as the RN view, backed by a DOM element. */
+type WebViewRef = RNView & HTMLDivElement;
 import {
     TABS_SELECTION_DURATION_MS,
     TABS_UNDERLINE_HEIGHT_PX,
@@ -20,7 +27,11 @@ import {
 } from 'app/ui/molecules/tabs/tabs-selection-constants';
 
 /** Skip scrolling the active tab into view on the first paint only (tab changes after that scroll). */
-function useScrollActiveTabIntoView(currentTab, triggerRefs, enabled) {
+function useScrollActiveTabIntoView(
+    currentTab: string | undefined,
+    triggerRefs: RefObject<Record<string, HTMLElement>>,
+    enabled: boolean
+) {
     const skipFirstScrollRef = useRef(true);
 
     useEffect(() => {
@@ -29,7 +40,7 @@ function useScrollActiveTabIntoView(currentTab, triggerRefs, enabled) {
             skipFirstScrollRef.current = false;
             return;
         }
-        const el = triggerRefs.current?.[currentTab];
+        const el = currentTab ? triggerRefs.current[currentTab] : undefined;
         if (!el) return;
         requestAnimationFrame(() => {
             el.scrollIntoView({
@@ -41,24 +52,26 @@ function useScrollActiveTabIntoView(currentTab, triggerRefs, enabled) {
     }, [currentTab, enabled]);
 }
 
+/**
+ * Radix focuses the trigger from its own mousedown handler (`event.currentTarget.focus()`).
+ * Right after a page load Chrome treats that script focus as keyboard focus, so the first
+ * click showed the focus ring. Ours runs first and focuses with `focusVisible: false`;
+ * Radix's call then hits an already focused element and changes nothing. Browsers without
+ * the option ignore it.
+ */
+function focusTriggerOnMouseDown(event: MouseEvent<HTMLButtonElement>) {
+    if (event.button !== 0 || event.ctrlKey) return;
+    event.currentTarget.focus({ focusVisible: false });
+}
+
+/** Spread on a trigger whose tab has no panel, so `aria-controls` doesn't name a missing id. */
+const NO_PANEL_PROPS = Object.freeze({ 'aria-controls': undefined });
 
 /**
- * @param {'default'|'secondary'} [variant]
- * @param {string} [trackClassName]
- * @param {string} [headerClassName]
- * @param {boolean} [rounded]
- * @param {boolean} [equalWidth] — When true (and `hug` is false), tabs share extra space equally; each tab keeps at least `min-content` width (label + padding).
- * @param {boolean} [fullWidth] — Deprecated: use `equalWidth` instead.
- * @param {boolean} [hug] — label-width triggers; use with `equalWidth={false}` for a compact strip
- * @param {'scroll'|'collapse'} [overflow] — `scroll` (default) or `collapse` into a "More" menu
- * @param {string} [moreMenuTitle] — label for the overflow trigger (default: translated "More"). Pass `""` for icon-only.
- * @param {string} [moreLabel] — alias for `moreMenuTitle` (deprecated)
- * @param {string} [moreMenuIcon] — Lucide icon name for the overflow trigger (default: `ChevronDown`)
- * @param {string} [tabBarClassName] — With `overflow="scroll"`, outer tab bar; with `overflow="collapse"`, the full-width measure row — use `flex flex-row justify-center` to center a `hug` strip in the parent.
- * @param {string} [listWrapperClassName] — box around track + list + indicator
- * @param {string} [listClassName] — tab row only
- * @param {string} [triggerClassName] — each trigger only; not `TabsContent`
- * @param {(key: string) => void} [onTabChange] — user selection; not `activeTab` prop sync
+ * Segmented tab bar (web; native is tabs.tsx). Styles come from `theme.tabs_variants`
+ * (settings/theme/tabs.js): `variant="default"` is a flat segmented control, `"glass"` NeoButton
+ * glass, `"secondary"` an underline. A theme can add more; each entry's `indicator`
+ * (`'pill'` | `'line'`) picks the selection shape. Props are documented in tabs.types.ts.
  */
 export default function Tabs({
     tabs,
@@ -75,13 +88,14 @@ export default function Tabs({
     size,
     contentClassName = '',
     trackClassName,
+    pillClassName,
     headerClassName,
     tabBarClassName,
     listWrapperClassName,
     listClassName,
     triggerClassName,
     onTabChange,
-}) {
+}: TabsProps) {
     /** `fullWidth` is deprecated — same as `equalWidth` (first wins if both are set). */
     const useEqualWidth = equalWidth ?? fullWidth ?? false;
     const { t } = useTranslation();
@@ -90,10 +104,10 @@ export default function Tabs({
     const [currentTab, setCurrentTab] = useState(
         () => activeTab ?? tabs?.[0]?.key
     );
-    const headerWrapperRef = useRef(null);
-    const listRef = useRef(null);
-    const moreRef = useRef(null);
-    const triggerRefs = useRef({});
+    const headerWrapperRef = useRef<WebViewRef | null>(null);
+    const listRef = useRef<WebViewRef | null>(null);
+    const moreRef = useRef<WebViewRef | null>(null);
+    const triggerRefs = useRef<Record<string, HTMLElement>>({});
     const [rect, setRect] = useState({
         left: 0,
         top: 0,
@@ -107,6 +121,7 @@ export default function Tabs({
         currentSizeKey,
         sizeCfg,
         variantCfg,
+        indicator,
         radiusTrack,
         radiusRow,
         radiusPill,
@@ -140,15 +155,15 @@ export default function Tabs({
     const onTabChangeRef = useRef(onTabChange);
     onTabChangeRef.current = onTabChange;
 
-    const handleTabChange = useCallback((value) => {
+    const handleTabChange = useCallback((value: string) => {
         setCurrentTab(value);
         onTabChangeRef.current?.(value);
     }, []);
 
     const handleOverflowMenuSelect = useCallback(
-        (item) => {
+        (item: DropdownMenuItemData) => {
             markMenuSelect();
-            handleTabChange(item.id);
+            handleTabChange(String(item.id));
         },
         [handleTabChange, markMenuSelect]
     );
@@ -164,7 +179,7 @@ export default function Tabs({
             const wrapper = headerWrapperRef.current;
             if (!wrapper) return;
 
-            const applyRect = (elRect) => {
+            const applyRect = (elRect: DOMRect) => {
                 const wrapperRect = wrapper.getBoundingClientRect();
                 const left = elRect.left - wrapperRect.left;
                 const topRel = elRect.top - wrapperRect.top;
@@ -172,14 +187,21 @@ export default function Tabs({
                 const heightRel = elRect.height;
                 let top;
                 let height;
-                if (variant === 'secondary') {
+                if (indicator === 'line') {
                     top = topRel + heightRel - TABS_UNDERLINE_HEIGHT_PX;
                     height = TABS_UNDERLINE_HEIGHT_PX;
                 } else {
                     top = topRel;
                     height = heightRel;
                 }
-                setRect({ left, top, width, height });
+                setRect((prev) =>
+                    prev.left === left &&
+                    prev.top === top &&
+                    prev.width === width &&
+                    prev.height === height
+                        ? prev
+                        : { left, top, width, height }
+                );
                 if (!readyRef.current) {
                     readyRef.current = true;
                     setReady(true);
@@ -200,13 +222,13 @@ export default function Tabs({
                 return;
             }
 
-            const currentEl = triggerRefs.current?.[currentTab];
+            const currentEl = currentTab ? triggerRefs.current[currentTab] : undefined;
             if (!currentEl || typeof currentEl.getBoundingClientRect !== 'function') {
                 return;
             }
             applyRect(currentEl.getBoundingClientRect());
         } catch (e) {}
-    }, [currentTab, variant, overflow, tabs, collapseLayout.visibleCount]);
+    }, [currentTab, indicator, overflow, tabs, collapseLayout.visibleCount]);
 
     useEffect(() => {
         updateIndicator();
@@ -224,6 +246,15 @@ export default function Tabs({
         };
     }, [updateIndicator, rounded, hug, overflow, collapseLayout.visibleCount]);
 
+    /**
+     * Re-measures the pill through the shared `onLayout` path (ResizeObserver in
+     * `design/view.web.tsx`): on the row wrapper, for a parent that changes width, and on
+     * each tab label, for label widths that change while the row keeps its size (a web
+     * font swap in a full-width bar moves the selected tab). The first measurement can run
+     * before the layout settles; window resize alone doesn't catch that.
+     */
+    const onHeaderLayout = useCallback(() => updateIndicator(), [updateIndicator]);
+
     const transitionStyle =
         ready
             ? {
@@ -260,20 +291,21 @@ export default function Tabs({
                 'z-[1]'
             )}
             style={{
-                left: `${rect.left}px`,
-                top: `${rect.top}px`,
-                width: `${rect.width}px`,
-                height: `${rect.height}px`,
+                left: rect.left,
+                top: rect.top,
+                width: rect.width,
+                height: rect.height,
                 position: 'absolute',
                 ...transitionStyle,
             }}
         >
-            {variant === 'default' ? (
+            {indicator === 'pill' ? (
                 <View
                     className={cn(
                         'absolute inset-0',
                         radiusPill,
-                        variantCfg.pill
+                        variantCfg.pill,
+                        pillClassName
                     )}
                 />
             ) : (
@@ -281,7 +313,8 @@ export default function Tabs({
                     className={cn(
                         'absolute inset-0',
                         sizeCfg.indicator_inner,
-                        variantCfg.line
+                        variantCfg.line,
+                        pillClassName
                     )}
                 />
             )}
@@ -300,6 +333,7 @@ export default function Tabs({
                     listWrapperClassName
                 )}
                 ref={headerWrapperRef}
+                onLayout={onHeaderLayout}
             >
                 {selectionLayer}
                 <TabsPrimitive.List
@@ -325,6 +359,8 @@ export default function Tabs({
                             }}
                             key={tab.key}
                             value={tab.key}
+                            onMouseDown={focusTriggerOnMouseDown}
+                            {...(hasTabContent(tab) ? null : NO_PANEL_PROPS)}
                             style={{
                                 scrollMarginInline: scrollInset,
                             }}
@@ -340,25 +376,13 @@ export default function Tabs({
                                 triggerClassName
                             )}
                         >
-                            <Text
-                                className={cn(
-                                    tab.key === currentTab
-                                        ? cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text-active'
-                                              ],
-                                              sizeCfg.text_active
-                                          )
-                                        : cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text'
-                                              ],
-                                              sizeCfg.text
-                                          )
-                                )}
-                            >
-                                {tab.title}
-                            </Text>
+                            <View className="min-w-0" onLayout={onHeaderLayout}>
+                                <Text
+                                    className={getTabTextClass(tab.key === currentTab, sizeCfg, variantCfg)}
+                                >
+                                    {tab.title}
+                                </Text>
+                            </View>
                         </TabsPrimitive.Trigger>
                     ))}
                 </TabsPrimitive.List>
@@ -369,6 +393,7 @@ export default function Tabs({
     const collapseRowInner = (
         <>
             <TabsMeasureRow
+                key={collapseLayout.measureKey}
                 tabs={tabs}
                 collapseLayout={collapseLayout}
                 variantCfg={variantCfg}
@@ -403,13 +428,15 @@ export default function Tabs({
                         listClassName
                     )}
                 >
-                    {collapseLayout.visibleTabs.map((tab) => (
+                    {collapseLayout.visibleTabs.map((tab: TabItem) => (
                         <TabsPrimitive.Trigger
                             ref={(node) => {
                                 if (node) triggerRefs.current[tab.key] = node;
                             }}
                             key={tab.key}
                             value={tab.key}
+                            onMouseDown={focusTriggerOnMouseDown}
+                            {...(hasTabContent(tab) ? null : NO_PANEL_PROPS)}
                             className={cn(
                                 'relative z-[3]',
                                 tabsTheme['u-controls-tabs-header-item'],
@@ -422,25 +449,13 @@ export default function Tabs({
                                 triggerClassName
                             )}
                         >
-                            <Text
-                                className={cn(
-                                    tab.key === currentTab
-                                        ? cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text-active'
-                                              ],
-                                              sizeCfg.text_active
-                                          )
-                                        : cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text'
-                                              ],
-                                              sizeCfg.text
-                                          )
-                                )}
-                            >
-                                {tab.title}
-                            </Text>
+                            <View className="min-w-0" onLayout={onHeaderLayout}>
+                                <Text
+                                    className={getTabTextClass(tab.key === currentTab, sizeCfg, variantCfg)}
+                                >
+                                    {tab.title}
+                                </Text>
+                            </View>
                         </TabsPrimitive.Trigger>
                     ))}
                     {collapseLayout.overflowTabs.length > 0 && (
@@ -471,21 +486,7 @@ export default function Tabs({
                             >
                                 {resolvedMoreMenuTitle ? (
                                     <Text
-                                        className={cn(
-                                            moreIsActive
-                                                ? cn(
-                                                      tabsTheme[
-                                                          'u-controls-tabs-header-item-text-active'
-                                                      ],
-                                                      sizeCfg.text_active
-                                                  )
-                                                : cn(
-                                                      tabsTheme[
-                                                          'u-controls-tabs-header-item-text'
-                                                      ],
-                                                      sizeCfg.text
-                                                  )
-                                        )}
+                                        className={getTabTextClass(moreIsActive, sizeCfg, variantCfg)}
                                     >
                                         {resolvedMoreMenuTitle}
                                     </Text>
@@ -493,51 +494,24 @@ export default function Tabs({
                                 <Icon
                                     icon={moreMenuIcon}
                                     size={moreIconSize}
-                                    className={cn(
-                                        moreIsActive
-                                            ? cn(
-                                                  tabsTheme[
-                                                      'u-controls-tabs-header-item-text-active'
-                                                  ],
-                                                  sizeCfg.text_active
-                                              )
-                                            : cn(
-                                                  tabsTheme[
-                                                      'u-controls-tabs-header-item-text'
-                                                  ],
-                                                  sizeCfg.text
-                                              )
-                                    )}
+                                    className={getTabTextClass(moreIsActive, sizeCfg, variantCfg)}
                                 />
                             </View>
                         </DropdownMenu>
                     )}
-                    {collapseLayout.overflowTabs.map((tab) => (
+                    {collapseLayout.overflowTabs.map((tab: TabItem) => (
                         <TabsPrimitive.Trigger
                             key={tab.key}
                             value={tab.key}
                             ref={(node) => {
                                 if (node) triggerRefs.current[tab.key] = node;
                             }}
+                            {...(hasTabContent(tab) ? null : NO_PANEL_PROPS)}
                             className="sr-only absolute h-px w-px overflow-hidden opacity-0 pointer-events-none"
                             tabIndex={-1}
                         >
                             <Text
-                                className={cn(
-                                    tab.key === currentTab
-                                        ? cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text-active'
-                                              ],
-                                              sizeCfg.text_active
-                                          )
-                                        : cn(
-                                              tabsTheme[
-                                                  'u-controls-tabs-header-item-text'
-                                              ],
-                                              sizeCfg.text
-                                          )
-                                )}
+                                className={getTabTextClass(tab.key === currentTab, sizeCfg, variantCfg)}
                             >
                                 {tab.title}
                             </Text>
@@ -559,22 +533,24 @@ export default function Tabs({
             )}
         >
             {collapseHugStrip ? (
-                <View
-                    className={cn(
-                        'relative min-w-0 overflow-hidden',
-                        'w-max max-w-full self-start',
-                        radiusTrack
-                    )}
-                >
+                <View className="relative min-w-0 w-max max-w-full self-start">
                     {trackView}
                     <View
                         className={cn(
-                            'relative min-w-0 w-full',
-                            listWrapperClassName
+                            'relative min-w-0 w-full overflow-hidden',
+                            radiusTrack
                         )}
-                        ref={headerWrapperRef}
                     >
-                        {collapseRowInner}
+                        <View
+                            className={cn(
+                                'relative min-w-0 w-full',
+                                listWrapperClassName
+                            )}
+                            ref={headerWrapperRef}
+                            onLayout={onHeaderLayout}
+                        >
+                            {collapseRowInner}
+                        </View>
                     </View>
                 </View>
             ) : (
@@ -584,6 +560,7 @@ export default function Tabs({
                         listWrapperClassName
                     )}
                     ref={headerWrapperRef}
+                    onLayout={onHeaderLayout}
                 >
                     {collapseRowInner}
                 </View>
@@ -600,19 +577,28 @@ export default function Tabs({
                 headerClassName
             )}
         >
+            {/* Track outside the clipping box so its outer shadow (glass ring + drop) is not cut off. */}
             <View
                 className={cn(
-                    'relative min-w-0 overflow-hidden',
+                    'relative min-w-0',
                     tabBarWidthClass,
-                    overflow !== 'collapse' && tabBarClassName,
-                    !collapseHugStrip && radiusTrack
+                    overflow !== 'collapse' && tabBarClassName
                 )}
             >
                 {!collapseHugStrip && trackView}
-                {overflow === 'collapse' ? collapseRow : scrollRow}
+                <View
+                    className={cn(
+                        // Clips the row to the track's corners. In collapse + hug the track and its own
+                        // clip live in the w-max strip below; clipping here would cut the track's shadow.
+                        'relative min-w-0 w-full',
+                        !collapseHugStrip && cn('overflow-hidden', radiusTrack)
+                    )}
+                >
+                    {overflow === 'collapse' ? collapseRow : scrollRow}
+                </View>
             </View>
 
-            {tabs.map((tab) => (
+            {tabs.filter(hasTabContent).map((tab) => (
                 <TabsPrimitive.Content
                     className={cn(
                         tabsTheme['u-controls-tabs-tab-content'],

+ 20 - 11
neo/packages/app/ui/molecules/tabs/use-tabs-collapse-layout.js

@@ -10,6 +10,9 @@ import { TABS_DEFAULT_MORE_BUTTON_WIDTH_PX } from 'app/ui/molecules/tabs/tabs-se
 
 const WIDTH_EPS = 0.5;
 
+/** Stable empty list, so a stale measurement doesn't produce a new array each render. */
+const EMPTY_WIDTHS = Object.freeze([]);
+
 function isWidthUnchanged(prev, next) {
     return Math.abs((prev ?? 0) - (next ?? 0)) < WIDTH_EPS;
 }
@@ -75,19 +78,22 @@ export function useTabsCollapseLayout({
     );
 
     const [containerWidth, setContainerWidth] = useState(0);
-    const [tabWidths, setTabWidths] = useState(() => []);
+    /**
+     * Widths from the hidden measure row, tagged with the tab set they were measured for.
+     * Widths for another tab set are ignored rather than cleared from an effect: the first
+     * `onLayout` / ResizeObserver results can arrive before an effect runs, and clearing them
+     * then left the row un-collapsed until the next resize (an unchanged size never re-fires).
+     */
+    const [measured, setMeasured] = useState(() => ({ key: '', widths: [] }));
     const [moreWidth, setMoreWidth] = useState(0);
 
     const containerRef = useRef(null);
     const resizeObserverRef = useRef(null);
     const { width: windowWidth } = useWindowDimensions();
 
-    useEffect(() => {
-        if (overflow !== 'collapse') return;
-        setContainerWidth(0);
-        setTabWidths([]);
-        setMoreWidth(0);
-    }, [overflow, tabKeys, tabCount]);
+    /** Changes with the tab set; `TabsMeasureRow` uses it as its key so every tab re-reports its width. */
+    const measureKey = `${tabCount}\u0000${tabKeys}`;
+    const tabWidths = measured.key === measureKey ? measured.widths : EMPTY_WIDTHS;
 
     useEffect(() => {
         return () => {
@@ -162,10 +168,12 @@ export function useTabsCollapseLayout({
     const onTabLayout = useCallback(
         (index) => (e) => {
             const w = e.nativeEvent.layout.width;
-            setTabWidths((prev) => {
+            setMeasured((prevMeasured) => {
+                const prev =
+                    prevMeasured.key === measureKey ? prevMeasured.widths : EMPTY_WIDTHS;
                 const prevWidth = prev[index] ?? 0;
                 if (prev.length >= tabCount && isWidthUnchanged(prevWidth, w)) {
-                    return prev;
+                    return prevMeasured;
                 }
 
                 const next =
@@ -176,10 +184,10 @@ export function useTabsCollapseLayout({
                               (_, i) => prev[i] ?? 0
                           );
                 next[index] = w;
-                return next;
+                return { key: measureKey, widths: next };
             });
         },
-        [tabCount]
+        [tabCount, measureKey]
     );
 
     const onMoreLayout = useCallback((e) => {
@@ -236,6 +244,7 @@ export function useTabsCollapseLayout({
     );
 
     return {
+        measureKey,
         visibleCount,
         visibleTabs,
         overflowTabs,