1
0
Эх сурвалжийг харах

Neo: button groups take a button style and draw one surface (#5918)

* Neo: button groups take a button style and draw one surface

NeoButtonGroup used to wrap borderless segments in a bordered box. The
border added 2px to the button height, and the segments' corners didn't
match the box's. Now the group works like a button:

- `style` (bordered, borderedProminent, borderless, outline, glass,
  glassProminent, plain), `controlSize`, `borderShape`, `role` / `tint`
  and `width`. The group paints the style's resting surface once, with no
  border: outline and glass are shadow rings, so the group is exactly as
  tall as its buttons. Recipes are `neo_button.group` in the theme.
- Every NeoButton inside becomes a segment through context: the group's
  style, size and role, only the state washes (hover, pressed, selected,
  voted), the group's outer corners (the same radius, nothing between
  them) and square inner ones, a hit area that stays off its neighbours,
  and no press scale. Segments always use the JS surface. A glass group on
  iOS 26+ draws native liquid glass behind them (GlassSurface).
- Short centred dividers by size; `role="group"` with a label.
- A prominent group takes its role's fill as a class (destructive), since
  native can't read `var()` in an inline colour.
- New `outline` button style (a 1px shadow ring, `shadow-btn-ring`), for
  single buttons too.
- Fix: a role's per-style text class (destructive on a prominent style)
  was never used, because `default` is also a scope key.
- Object menus (reactions, likes, scores) keep using `ButtonsGroupMenu`,
  now on the theme's `group.defaultStyle` (bordered).
- /pg has a Button Groups section, and AGENTS.md documents the group.

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

* Neo: outline buttons stay on the JS surface on native

`expo_ui.button.styles` is `true`, so a labelled `outline` button with
`onPress` went to Expo UI, where SwiftUI has no outline style (it fell back
to plain) and the Compose mapping fell back to tonal (a fill). Styles with
no native rendering now keep the JS surface, which draws the ring.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 1 өдөр өмнө
parent
commit
797480aa15

+ 2 - 1
neo/AGENTS.md

@@ -212,9 +212,10 @@ Text uses two roles, `font-main` (the default in `design/typography.tsx`) and `f
 
 Use `NeoButton`, `NeoButtonLink` or `NeoButtonRef` from `app/design/controls`. The legacy `Button`, `ButtonRef` and `ButtonLink` are deprecated.
 
-- Axes: `style` (`plain`, `bordered`, `borderedProminent`, `borderless`, `link`, `glass`, `glassProminent`), `role` (`default`, `cancel`, `close`, `confirm`, `destructive`), `controlSize` (`mini`, `small`, `regular`, `large`, `xlarge`), `borderShape` and `tint`.
+- Axes: `style` (`plain`, `bordered`, `borderedProminent`, `borderless`, `outline`, `link`, `glass`, `glassProminent`), `role` (`default`, `cancel`, `close`, `confirm`, `destructive`), `controlSize` (`mini`, `small`, `regular`, `large`, `xlarge`), `borderShape` and `tint`.
 - Code lives in `design/controls/neo-button/`: props in `neo-button.types.ts`, scope and style precedence in `neo-button-resolver.tsx`, the legacy variant mapping in `control-scale.ts`. The theme is `neo_button` in `settings/theme/buttons.js`.
 - On iOS and Android, `native.expo_ui_buttons` (`settings/configs.js`) renders buttons with native Expo UI (SwiftUI / Compose) views; the `expoUI` prop controls it per button.
+- `NeoButtonGroup` joins buttons into one control with the same `style`, `controlSize`, `borderShape`, `role` / `tint` and `width` axes. The group paints the style's surface once (no border: outline and glass are shadow rings); each NeoButton inside becomes a segment through context, with the group's style and size, only the state washes, square inner corners and no hit area over its neighbours. Segments always use the JS surface; a `glass` group on iOS 26+ draws native liquid glass behind them. Recipes are `neo_button.group` in `settings/theme/buttons.js`. Object menus (reactions, likes, scores) build theirs through `ButtonsGroupMenu`, which uses `group.defaultStyle`.
 - `/pg` is a playground for every axis (`apps/next/app/pg/page.js`, `apps/expo/app/pg/`). It isn't gated, so production builds serve it too.
 
 ### Interface sounds

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

@@ -25,6 +25,7 @@ import {
     useResolvedNeoButton, usePointerCapability,
     resolveScoped,
 } from 'app/design/controls/neo-button/neo-button';
+import { NeoButtonGroup } from 'app/design/controls/neo-button/neo-button-group';
 import { Icon } from 'app/ui/atoms/icon';
 import Tabs from 'app/ui/molecules/tabs/tabs';
 import { appSetting } from 'app/lib/util';
@@ -45,6 +46,133 @@ function Stack({ children }) {
     return <View className="w-full gap-2">{children}</View>;
 }
 
+/* ------------------------- Button groups gallery ------------------------- */
+
+const GROUP_STYLES = ['bordered', 'borderedProminent', 'outline', 'borderless', 'glass', 'glassProminent'];
+const ALIGNMENTS = [
+    { key: 'left', icon: 'AlignLeft', label: 'Align left' },
+    { key: 'center', icon: 'AlignCenter', label: 'Align center' },
+    { key: 'right', icon: 'AlignRight', label: 'Align right' },
+    { key: 'justify', icon: 'AlignJustify', label: 'Justify' },
+];
+
+function GroupRow({ label, children }) {
+    return (
+        <View className="w-full flex-row flex-wrap items-center gap-3">
+            <Text className="w-36 text-xs text-muted-foreground">{label}</Text>
+            {children}
+        </View>
+    );
+}
+
+function ButtonGroupsGallery() {
+    const [align, setAlign] = React.useState('left');
+    const [liked, setLiked] = React.useState(true);
+    const noop = () => {};
+
+    return (
+        <Stack>
+            {GROUP_STYLES.map((style) => (
+                <GroupRow key={style} label={style}>
+                    <NeoButtonGroup style={style} accessibilityLabel={`${style} group`}>
+                        <NeoButton label="First" onPress={noop} />
+                        <NeoButton label="Second" onPress={noop} />
+                        <NeoButton label="Third" onPress={noop} />
+                    </NeoButtonGroup>
+                    <NeoButtonGroup style={style} accessibilityLabel="Text alignment">
+                        {ALIGNMENTS.map((item) => (
+                            <NeoButton
+                                key={item.key}
+                                image={item.icon}
+                                accessibilityLabel={item.label}
+                                selected={align === item.key}
+                                onPress={() => setAlign(item.key)}
+                            />
+                        ))}
+                    </NeoButtonGroup>
+                </GroupRow>
+            ))}
+
+            <GroupRow label="actions + counters">
+                <NeoButtonGroup accessibilityLabel="Fork">
+                    <NeoButton label="Fork" image="GitFork" addon="24" onPress={noop} />
+                    <NeoButton image="ChevronDown" accessibilityLabel="Fork options" onPress={noop} />
+                </NeoButtonGroup>
+                <NeoButtonGroup accessibilityLabel="Rate">
+                    <NeoButton
+                        label="2.4K"
+                        image="ThumbsUp"
+                        selected={liked}
+                        selectedState="voted"
+                        accessibilityLabel="Like, 2.4K"
+                        onPress={() => setLiked((v) => !v)}
+                    />
+                    <NeoButton image="ThumbsDown" accessibilityLabel="Dislike" onPress={noop} />
+                </NeoButtonGroup>
+                <NeoButtonGroup accessibilityLabel="Pages">
+                    <NeoButton label="Previous" image="ChevronLeft" onPress={noop} />
+                    <NeoButton label="Next" image="ChevronRight" imagePlacement="trailing" onPress={noop} />
+                </NeoButtonGroup>
+                <NeoButtonGroup accessibilityLabel="Media">
+                    <NeoButton label="Photos" image="Image" onPress={noop} />
+                    <NeoButton label="Videos" image="Video" onPress={noop} />
+                    <NeoButton image="Ellipsis" accessibilityLabel="More" onPress={noop} />
+                </NeoButtonGroup>
+            </GroupRow>
+
+            <GroupRow label="capsule">
+                <NeoButtonGroup borderShape="capsule" accessibilityLabel="Capsule group">
+                    <NeoButton label="Day" onPress={noop} />
+                    <NeoButton label="Week" onPress={noop} />
+                    <NeoButton label="Month" onPress={noop} />
+                </NeoButtonGroup>
+                <NeoButtonGroup style="glass" borderShape="capsule" accessibilityLabel="Capsule glass group">
+                    <NeoButton image="Heart" accessibilityLabel="Like" onPress={noop} />
+                    <NeoButton image="MessageCircle" accessibilityLabel="Comment" onPress={noop} />
+                    <NeoButton image="Share" accessibilityLabel="Share" onPress={noop} />
+                </NeoButtonGroup>
+            </GroupRow>
+
+            <GroupRow label="controlSize">
+                {['mini', 'small', 'regular', 'large'].map((size) => (
+                    <NeoButtonGroup key={size} controlSize={size} accessibilityLabel={`${size} group`}>
+                        <NeoButton label={size} onPress={noop} />
+                        <NeoButton image="ChevronDown" accessibilityLabel="Options" onPress={noop} />
+                    </NeoButtonGroup>
+                ))}
+            </GroupRow>
+
+            <GroupRow label="role destructive">
+                <NeoButtonGroup style="borderedProminent" role="destructive" accessibilityLabel="Delete">
+                    <NeoButton label="Delete" image="Trash2" onPress={noop} />
+                    <NeoButton image="ChevronDown" accessibilityLabel="Delete options" onPress={noop} />
+                </NeoButtonGroup>
+                <NeoButtonGroup style="bordered" role="destructive" accessibilityLabel="Remove">
+                    <NeoButton label="Remove" onPress={noop} />
+                    <NeoButton label="Block" onPress={noop} />
+                </NeoButtonGroup>
+            </GroupRow>
+
+            <GroupRow label="disabled segment">
+                <NeoButtonGroup accessibilityLabel="Undo and redo">
+                    <NeoButton label="Undo" image="Undo2" onPress={noop} />
+                    <NeoButton label="Redo" image="Redo2" disabled onPress={noop} />
+                </NeoButtonGroup>
+            </GroupRow>
+
+            <GroupRow label={'width="fill"'}>
+                <View className="flex-1 min-w-64">
+                    <NeoButtonGroup width="fill" accessibilityLabel="View">
+                        <NeoButton label="List" image="List" onPress={noop} />
+                        <NeoButton label="Grid" image="LayoutGrid" onPress={noop} />
+                        <NeoButton label="Map" image="Map" onPress={noop} />
+                    </NeoButtonGroup>
+                </View>
+            </GroupRow>
+        </Stack>
+    );
+}
+
 /* ---------------------------- Tabs gallery ---------------------------- */
 
 const TAB_TITLES = ['Overview', 'Activity', 'Members', 'Media', 'Settings'];
@@ -314,11 +442,19 @@ export default function PageLayoutPlayground({ data, children }) {
                         <NeoButton style="bordered" label="Bordered" image="Settings" onPress={() => {}} />
                         <NeoButton style="borderedProminent" label="Prominent" image="Check" onPress={() => {}} />
                         <NeoButton style="borderless" label="Borderless" image="Sparkles" onPress={() => {}} />
+                        <NeoButton style="outline" label="Outline" image="Square" onPress={() => {}} />
                         <NeoButton style="link" label="Open details" image="ExternalLink" imagePlacement="trailing" onPress={() => {}} />
                         <NeoButton style="glass" label="Glass" image="Layers" onPress={() => {}} />
                         <NeoButton style="glassProminent" label="Glass CTA" image="Rocket" onPress={() => {}} />
                     </Section>
 
+                    <Section
+                        title="Button Groups"
+                        hint="NeoButtonGroup takes a button style and paints its surface once; the buttons inside become segments (shared style and size, square inner corners, no hit area over neighbours). Selected segments, counters, capsule, sizes, destructive, disabled and fill."
+                    >
+                        <ButtonGroupsGallery />
+                    </Section>
+
                     <Section
                         title="Control Sizes"
                         hint="Use small controls for toolbars, regular for most actions, and large/xlarge for high-emphasis rows or onboarding."

+ 13 - 24
neo/packages/app/design/controls/button-menus.tsx

@@ -7,7 +7,7 @@
  */
 import type { ReactNode } from 'react';
 import { appSetting } from 'app/lib/util';
-import { NeoButton, useResolvedNeoButton } from 'app/design/controls/neo-button/neo-button';
+import { NeoButton } from 'app/design/controls/neo-button/neo-button';
 import { NeoButtonGroup, useNeoButtonGroupItem } from 'app/design/controls/neo-button/neo-button-group';
 import { legacyToNeoButtonProps } from 'app/design/controls/neo-button/legacy-button-map';
 import { LEGACY_TO_CONTROL_SIZE, NEO_CONTROL_SIZES } from 'app/design/controls/neo-button/control-scale';
@@ -72,11 +72,10 @@ function ButtonMenuAction({
 }
 
 /**
- * Segment of a `ButtonsGroupMenu` / `NeoButtonGroup`: borderless, square
- * inner corners, the group's outer rounding, and a hit area that extends only
- * past the group's outer edges. Its own `size` sets its height (default `sm`);
- * the group's size only drives the rounding. Outside a group it renders as a
- * plain text action.
+ * Segment of a `ButtonsGroupMenu` / `NeoButtonGroup`: a NeoButton that takes
+ * the group's style, size, corners and hit area from the group (see
+ * `NeoButtonGroup`). Its own `size` sets its height (default: the group's).
+ * Outside a group it renders as a plain text action.
  */
 export function ButtonMenuGroupItem({
     variant: _forcedText,
@@ -87,8 +86,6 @@ export function ButtonMenuGroupItem({
     ...rest
 }: ButtonMenuProps) {
     const group = useNeoButtonGroupItem();
-    const controlSize = rest.controlSize ?? adapterControlSize(size);
-    const { hitSlop: slop } = useResolvedNeoButton({ controlSize });
 
     if (!group) {
         return <ButtonMenuAction {...rest} variant="text" size={size} rounded={false} pressed={pressed} haptics={haptics} />;
@@ -98,34 +95,26 @@ export function ButtonMenuGroupItem({
     return (
         <NeoButton
             {...adapterProps(neo, { pressed, haptics, forwardedRef: rest.forwardedRef })}
-            style={rest.style ?? 'borderless'}
-            borderShape="rectangle"
-            controlSize={controlSize}
-            classNames={{ ...neo.classNames, surface: [group.roundedCls, neo.classNames?.surface].filter(Boolean).join(' ') }}
-            // Segments share edges: no interior slop, so none covers its neighbour.
-            hitSlop={rest.hitSlop ?? {
-                top: slop,
-                bottom: slop,
-                left: group.outerStart ? slop : 0,
-                right: group.outerEnd ? slop : 0,
-            }}
+            controlSize={rest.controlSize ?? (size ? adapterControlSize(size) : group.controlSize)}
         />
     );
 }
 
 /**
- * Segmented group of `ButtonMenuGroupItem`s. Legacy ignored `variant`,
- * `fullWidth` and `rounded` on the group; so does this. `size` sets the
- * corner rounding (default `sm` → small).
+ * Group of `ButtonMenuGroupItem`s (object menus: reactions, likes, scores…).
+ * `style` is the group's button style (default: theme
+ * `neo_button.group.defaultStyle`); `size` is its control size (default `sm`
+ * → small). Legacy `variant`, `fullWidth` and `rounded` are ignored.
  */
-export function ButtonsGroupMenu({ size, className, children }: {
+export function ButtonsGroupMenu({ size, style, className, children }: {
     size?: string;
+    style?: string;
     className?: string;
     children?: ReactNode;
     [key: string]: unknown;
 }) {
     return (
-        <NeoButtonGroup controlSize={adapterControlSize(size)} className={className}>
+        <NeoButtonGroup style={style} controlSize={adapterControlSize(size)} className={className}>
             {children}
         </NeoButtonGroup>
     );

+ 1 - 1
neo/packages/app/design/controls/neo-button/control-scale.ts

@@ -55,7 +55,7 @@ export const LEGACY_VARIANT_TO_NEO: Readonly<Record<string, { style: NeoButtonSt
 };
 
 export const NEO_STYLES: ReadonlySet<string> = new Set([
-    'plain', 'bordered', 'borderedProminent', 'borderless', 'link', 'glass', 'glassProminent',
+    'plain', 'bordered', 'borderedProminent', 'borderless', 'outline', 'link', 'glass', 'glassProminent',
 ]);
 
 /**

+ 133 - 42
neo/packages/app/design/controls/neo-button/neo-button-group.tsx

@@ -1,39 +1,64 @@
 /**
- * NeoButtonGroup — segmented control: one bordered container, separators
- * between segments, outer corners rounded by the group's controlSize.
+ * NeoButtonGroup — buttons joined into one control, with the same axes as a
+ * NeoButton: `style`, `controlSize`, `borderShape`, `role` / `tint`, `width`.
  *
- * Segments read their position from context (`useNeoButtonGroupItem`) to
- * round only the outer corners and to keep their hit area off the interior
- * edges (no segment steals a neighbour's clicks). `ButtonMenuGroupItem` is the
- * segment component. No `overflow-hidden`: it would clip the focus rings.
+ *   <NeoButtonGroup style="bordered" controlSize="small" accessibilityLabel="Text alignment">
+ *     <NeoButton image="AlignLeft" accessibilityLabel="Left" onPress={…} />
+ *     <NeoButton image="AlignCenter" accessibilityLabel="Center" onPress={…} />
+ *   </NeoButtonGroup>
+ *
+ * The group paints the style's resting surface once (theme `neo_button.group`):
+ * a fill, or a shadow ring for outline and glass, never a border, so it is
+ * exactly as tall as its buttons. Every NeoButton inside reads its segment
+ * context (`useNeoButtonGroupItem`) and becomes a segment: the group's style,
+ * size and role, only the style's state washes, square inner corners and the
+ * group's outer corners (the same radius: nothing sits between them), a hit
+ * area that stays off its neighbours, and no press scale. Segments always use
+ * the JS surface; a `glass` group on iOS 26 draws real liquid glass behind them.
+ * No `overflow-hidden`: it would clip the segments' focus rings.
  */
 import React, { createContext, useContext, Fragment, type ReactNode } from 'react';
 import { Row, View } from 'app/design/view';
-import { appSetting, cn } from 'app/lib/util';
-import { NeoControlSizeProvider, useResolvedNeoButton } from 'app/design/controls/neo-button/neo-button-resolver';
-import type { NeoControlSize } from 'app/design/controls/neo-button/neo-button.types';
+import { GlassSurface } from 'app/ui/atoms/glass-surface';
+import { appSetting, cn, hasLiquidGlass } from 'app/lib/util';
+import {
+    NeoButtonStyleProvider,
+    NeoControlSizeProvider,
+    normalizeStyleProp,
+    useNeoButtonStyle,
+    useResolvedNeoButton,
+} from 'app/design/controls/neo-button/neo-button-resolver';
+import type { NeoBorderShape, NeoButtonStyle, NeoControlSize } from 'app/design/controls/neo-button/neo-button.types';
 
 type SegmentPosition = 'only' | 'first' | 'middle' | 'last';
 
-// Static literals (Tailwind/Uniwind). Must mirror
-// `neo_button.borderShapes.roundedRectangle.rounded` in settings/theme/buttons.js.
+// Outer corners per position, keyed by the group's resolved rounding (static
+// literals for Tailwind / Uniwind; covers every `neo_button.borderShapes` value).
 const SEGMENT_ROUNDED: Record<string, Record<SegmentPosition, string>> = {
-    mini:    { only: 'rounded-md',  first: 'rounded-s-md',  middle: 'rounded-none', last: 'rounded-e-md' },
-    small:   { only: 'rounded-lg',  first: 'rounded-s-lg',  middle: 'rounded-none', last: 'rounded-e-lg' },
-    regular: { only: 'rounded-xl',  first: 'rounded-s-xl',  middle: 'rounded-none', last: 'rounded-e-xl' },
-    large:   { only: 'rounded-xl',  first: 'rounded-s-xl',  middle: 'rounded-none', last: 'rounded-e-xl' },
-    xlarge:  { only: 'rounded-2xl', first: 'rounded-s-2xl', middle: 'rounded-none', last: 'rounded-e-2xl' },
+    'rounded-none': { only: 'rounded-none', first: 'rounded-none',   middle: 'rounded-none', last: 'rounded-none' },
+    'rounded-md':   { only: 'rounded-md',   first: 'rounded-s-md',   middle: 'rounded-none', last: 'rounded-e-md' },
+    'rounded-lg':   { only: 'rounded-lg',   first: 'rounded-s-lg',   middle: 'rounded-none', last: 'rounded-e-lg' },
+    'rounded-xl':   { only: 'rounded-xl',   first: 'rounded-s-xl',   middle: 'rounded-none', last: 'rounded-e-xl' },
+    'rounded-2xl':  { only: 'rounded-2xl',  first: 'rounded-s-2xl',  middle: 'rounded-none', last: 'rounded-e-2xl' },
+    'rounded-full': { only: 'rounded-full', first: 'rounded-s-full', middle: 'rounded-none', last: 'rounded-e-full' },
 };
 
+const PROMINENT_STYLES = new Set(['borderedProminent', 'glassProminent']);
+
 export type NeoButtonGroupItemContextValue = {
-    /** The group's size (drives the corner rounding). */
+    /** The group's resolved axes; segments use them unless they set their own. */
+    style: string;
     controlSize: NeoControlSize;
+    role?: string;
+    tint?: string | null;
     position: SegmentPosition;
     /** Outer-corner rounding for this segment's surface. */
     roundedCls: string;
-    /** Whether the segment touches the group's start / end edge (hit area may extend there). */
+    /** Whether the segment touches the group's start / end edge (its hit area may extend there). */
     outerStart: boolean;
     outerEnd: boolean;
+    /** `width="fill"`: segments share the group's width. */
+    fill: boolean;
 };
 
 const NeoButtonGroupItemContext = createContext<NeoButtonGroupItemContextValue | null>(null);
@@ -42,40 +67,106 @@ const NeoButtonGroupItemContext = createContext<NeoButtonGroupItemContextValue |
 export const useNeoButtonGroupItem = () => useContext(NeoButtonGroupItemContext);
 
 export type NeoButtonGroupProps = {
+    /** Button style of the whole group (default: theme `neo_button.group.defaultStyle`, or a parent provider's). */
+    style?: NeoButtonStyle | string;
     controlSize?: NeoControlSize;
+    /** `roundedRectangle` (default) or `capsule`; `rectangle` for square corners. */
+    borderShape?: NeoBorderShape | string;
+    /** SwiftUI role for every segment (`destructive` tints a prominent group). */
+    role?: string;
+    /** Fill colour of a prominent group; label / icon colour of the others. */
+    tint?: string | null;
+    /** `fill`: the group takes its parent's width and the segments share it. */
+    width?: 'auto' | 'fill';
+    /** Dividers between segments (default on). */
+    separators?: boolean;
+    /** Names the group for assistive technology (`role="group"`). */
+    accessibilityLabel?: string;
     className?: string;
     children?: ReactNode;
 };
 
-export function NeoButtonGroup({ controlSize = 'small', className, children }: NeoButtonGroupProps) {
+export function NeoButtonGroup({
+    style: styleProp,
+    controlSize = 'small',
+    borderShape,
+    role,
+    tint,
+    width = 'auto',
+    separators = true,
+    accessibilityLabel,
+    className,
+    children,
+}: NeoButtonGroupProps) {
     const theme = appSetting('theme', 'neo_button', 'group') || {};
-    const { rounded } = useResolvedNeoButton({ controlSize, borderShape: 'roundedRectangle' });
+    const groupStyles = theme.styles || {};
+    const providerStyle = useNeoButtonStyle();
+    const requested = normalizeStyleProp(styleProp) ?? normalizeStyleProp(providerStyle) ?? theme.defaultStyle ?? 'bordered';
+    const style = groupStyles[requested] ? requested : (theme.defaultStyle ?? 'bordered');
+    const resolved = useResolvedNeoButton({ style, controlSize, borderShape, role, tint });
+
     // Drops falsy children; a single child renders as `only`.
     const items = React.Children.toArray(children).filter(React.isValidElement);
     if (!items.length) return null;
 
+    const groupStyle = groupStyles[style] || {};
+    const liquidGlass = hasLiquidGlass && style === 'glass';
+    // A prominent group is filled with its tint: a role's fill class (it may
+    // be a CSS variable, which native can't read inline), or a `tint` colour.
+    const prominent = PROMINENT_STYLES.has(style);
+    const roleFill = prominent && !tint ? appSetting('theme', 'neo_button', 'roles')?.[role ?? '']?.fillClass : null;
+    const tintFill = prominent && !roleFill && resolved.tint ? resolved.tint : null;
+    const fill = width === 'fill';
+    const corners = SEGMENT_ROUNDED[resolved.rounded] ?? SEGMENT_ROUNDED['rounded-xl']!;
+
     return (
-        <NeoControlSizeProvider size={controlSize}>
-            <Row className={cn(theme.container, rounded, className)}>
-                {items.map((child, i) => {
-                    const position: SegmentPosition = items.length === 1
-                        ? 'only'
-                        : i === 0 ? 'first' : i === items.length - 1 ? 'last' : 'middle';
-                    const value: NeoButtonGroupItemContextValue = {
-                        controlSize,
-                        position,
-                        roundedCls: SEGMENT_ROUNDED[controlSize]?.[position] ?? '',
-                        outerStart: position === 'first' || position === 'only',
-                        outerEnd: position === 'last' || position === 'only',
-                    };
-                    return (
-                        <Fragment key={child.key ?? i}>
-                            {i > 0 ? <View aria-hidden className={theme.separator} /> : null}
-                            <NeoButtonGroupItemContext.Provider value={value}>{child}</NeoButtonGroupItemContext.Provider>
-                        </Fragment>
-                    );
-                })}
-            </Row>
-        </NeoControlSizeProvider>
+        <NeoButtonStyleProvider style={style}>
+            <NeoControlSizeProvider size={resolved.controlSize}>
+                <Row
+                    role="group"
+                    aria-label={accessibilityLabel}
+                    accessibilityLabel={accessibilityLabel}
+                    className={cn(
+                        'relative flex-row items-stretch',
+                        fill ? 'w-full' : 'self-start max-w-full',
+                        resolved.rounded,
+                        liquidGlass ? groupStyle.liquidContainer : groupStyle.container,
+                        roleFill,
+                        className,
+                    )}
+                    style={tintFill ? { backgroundColor: tintFill } : undefined}
+                >
+                    {liquidGlass ? <GlassSurface rounded={resolved.rounded} /> : null}
+                    {items.map((child, i) => {
+                        const position: SegmentPosition = items.length === 1
+                            ? 'only'
+                            : i === 0 ? 'first' : i === items.length - 1 ? 'last' : 'middle';
+                        const value: NeoButtonGroupItemContextValue = {
+                            style,
+                            controlSize: resolved.controlSize as NeoControlSize,
+                            role,
+                            tint,
+                            position,
+                            roundedCls: corners[position],
+                            outerStart: position === 'first' || position === 'only',
+                            outerEnd: position === 'last' || position === 'only',
+                            fill,
+                        };
+                        return (
+                            <Fragment key={child.key ?? i}>
+                                {i > 0 && separators ? (
+                                    <View
+                                        aria-hidden
+                                        pointerEvents="none"
+                                        className={cn('w-px self-center', theme.separatorHeight?.[resolved.controlSize], groupStyle.separator)}
+                                    />
+                                ) : null}
+                                <NeoButtonGroupItemContext.Provider value={value}>{child}</NeoButtonGroupItemContext.Provider>
+                            </Fragment>
+                        );
+                    })}
+                </Row>
+            </NeoControlSizeProvider>
+        </NeoButtonStyleProvider>
     );
 }

+ 29 - 18
neo/packages/app/design/controls/neo-button/neo-button-resolver.tsx

@@ -367,6 +367,10 @@ const STYLES_WITHOUT_HOVER = new Set(['plain']);
 
 const GLASS_STYLES = new Set(['glass', 'glassProminent']);
 
+// Styles with no native (Expo UI) rendering: `outline` is a shadow ring that
+// SwiftUI and Compose button styles don't draw.
+const JS_ONLY_STYLES = new Set(['outline']);
+
 const SWIFTUI_CONTROL_SIZE: Record<string, string> = {
     mini: 'mini',
     small: 'small',
@@ -417,7 +421,7 @@ function resolveSlot(slotMap: Record<string, any> | undefined, state: string, ct
  * `automatic`, etc.), we fall back to `bordered` so the button still
  * renders something usable instead of going completely unstyled.
  */
-function normalizeStyleProp(value: unknown): string | undefined {
+export function normalizeStyleProp(value: unknown): string | undefined {
     // React Native `Link`/`Pressable` also use a `style` prop (object/array).
     // When `NeoButtonLink` is rendered with `asChild`, that layout `style` can
     // overwrite the SwiftUI-style string — ignore non-strings here.
@@ -457,20 +461,18 @@ export function useNeoButtonExpoUI(props: NeoButtonProps = {}) {
 
     const allowedStyles = resolveScoped(config.styles, ctx);
     if (allowedStyles === false) return null;
-    if (Array.isArray(allowedStyles)) {
-        const defaults = tree?.defaults || {};
-        const stylesTree = tree?.styles || {};
-        const role = props.role ?? defaults.role ?? 'default';
-        const roleConfig = (tree?.roles || {})[role] || {};
-        const style = resolveStyle({
-            propStyle: normalizeStyleProp(props.buttonStyle) ?? normalizeStyleProp(props.style),
-            providerStyle,
-            defaultsStyle: defaults.style,
-            roleConfig,
-            stylesTree,
-        });
-        if (!allowedStyles.includes(style)) return null;
-    }
+    const defaults = tree?.defaults || {};
+    const role = props.role ?? defaults.role ?? 'default';
+    const style = resolveStyle({
+        propStyle: normalizeStyleProp(props.buttonStyle) ?? normalizeStyleProp(props.style),
+        providerStyle,
+        defaultsStyle: defaults.style,
+        roleConfig: (tree?.roles || {})[role] || {},
+        stylesTree: tree?.styles || {},
+    });
+    // No SwiftUI / Compose equivalent: these keep the JS surface.
+    if (JS_ONLY_STYLES.has(style)) return null;
+    if (Array.isArray(allowedStyles) && !allowedStyles.includes(style)) return null;
 
     return { platform, config };
 }
@@ -579,10 +581,19 @@ export function useResolvedNeoButton(props: NeoButtonProps = {}) {
         const containerSlot = styleDef.container || {};
         const textSlot = styleDef.text || {};
         const containerCls = (state: string) => resolveSlot(containerSlot, state, ctx);
+        // A button inside a NeoButtonGroup: the group paints the resting
+        // surface, so the segment only takes the style's state washes.
+        const segmentSlot = tree?.group?.styles?.[style]?.segment;
+        const segmentCls = (state: string) => segmentSlot ? resolveSlot(segmentSlot, state, ctx) : containerCls(state);
         const textCls = (state: string) => {
             const base = resolveSlot(textSlot, state, ctx);
-            // Role text override (string or per-style object).
-            let roleText = resolveScoped(roleConfig.textClass, ctx);
+            // Role text override (string or per-style object). The per-style
+            // key goes first: `default` is also a scope key, so resolveScoped
+            // would return it for every style.
+            const roleTextCfg = roleConfig.textClass;
+            let roleText = isPlainObject(roleTextCfg) && roleTextCfg[style] !== undefined
+                ? resolveScoped(roleTextCfg[style], ctx)
+                : resolveScoped(roleTextCfg, ctx);
             if (isPlainObject(roleText)) roleText = roleText[style] ?? roleText.default;
             return [base, roleText].filter(Boolean).join(' ');
         };
@@ -646,7 +657,7 @@ export function useResolvedNeoButton(props: NeoButtonProps = {}) {
             height, paddingX, fontCls, iconSize, hitSlop, labelGap, contentInsets, rounded, aspectSquare, iconOnlyPadded,
             heightCls, paddingXCls, labelGapCls, hitAreaCls,
             // Slot resolvers (call with current state)
-            containerCls, textCls,
+            containerCls, segmentCls, textCls,
             // Behaviour
             transition, behaviors, haptics, glassFx,
             // Visuals

+ 41 - 16
neo/packages/app/design/controls/neo-button/neo-button.tsx

@@ -72,6 +72,7 @@ import {
 } from 'app/design/controls/neo-button/neo-button-resolver';
 import { NeoButtonExpoUI, getNativeIcon, hostsCustomContent } from 'app/design/controls/neo-button/neo-button-expoui';
 import { selectedColorScheme, useNeoButtonColorScheme } from 'app/design/controls/neo-button/color-scheme-context';
+import { useNeoButtonGroupItem } from 'app/design/controls/neo-button/neo-button-group';
 import type { NeoButtonAddon, NeoButtonClassNames, NeoButtonProps } from 'app/design/controls/neo-button/neo-button.types';
 import { cueAttributes, usePressCueOverride, type PressCue } from 'app/lib/sound';
 
@@ -459,8 +460,10 @@ export const NeoButton = (rawProps: NeoButtonProps) => {
     const props = decodeTextProps(rawProps);
     const native = useNeoButtonExpoUI(props);
     const titleHidden = useTitleHidden(props.showTitleFromSize);
+    // A group segment draws on the group's surface, so it stays JS.
+    const segment = useNeoButtonGroupItem();
 
-    if (native && NeoButtonExpoUI) {
+    if (native && NeoButtonExpoUI && !segment) {
         // `showTitleFromSize` below its breakpoint: the native button turns
         // icon-only and keeps the label as its accessible name.
         const label = props.label ?? props.title;
@@ -558,9 +561,19 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
         ...rest
     } = props;
 
+    // Inside a NeoButtonGroup: the group's role and tint unless the button sets
+    // its own (style and size come through the group's providers). No press
+    // scale, which would push the segment over its neighbours, and no web glass
+    // layers: the group draws the surface.
+    const segment = useNeoButtonGroupItem();
     const resolved = useResolvedNeoButton({
-        role, style, buttonStyle, controlSize, borderShape, tint, transition,
-        focusRing, pressAnimation, glassEffect,
+        role: role ?? segment?.role,
+        style, buttonStyle, controlSize, borderShape,
+        tint: tint ?? segment?.tint,
+        transition: segment ? false : transition,
+        focusRing,
+        pressAnimation: segment ? false : pressAnimation,
+        glassEffect: segment ? 'standard' : glassEffect,
         image: image ?? systemImage,
         systemImage,
         imagePlacement, align, width, disabled, haptics,
@@ -666,8 +679,16 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
     // Native applies it to the RN Pressable via this prop; web mirrors it with
     // the `hit-area-*` utility class (see `hitAreaClass` below) — RN has no
     // `::before`, so the two platforms diverge in mechanism but not in value.
-    const resolvedHitSlop = hitSlop !== undefined
-        ? hitSlop
+    // A segment's hit area extends only past the group's outer edges, so no
+    // segment covers its neighbour.
+    const effectiveHitSlop = hitSlop ?? (segment && hitarea !== false ? {
+        top: resolved.hitSlop,
+        bottom: resolved.hitSlop,
+        left: segment.outerStart ? resolved.hitSlop : 0,
+        right: segment.outerEnd ? resolved.hitSlop : 0,
+    } : undefined);
+    const resolvedHitSlop = effectiveHitSlop !== undefined
+        ? effectiveHitSlop
         : (hitarea === false ? undefined : {
             top: resolved.hitSlop, right: resolved.hitSlop,
             bottom: resolved.hitSlop, left: resolved.hitSlop,
@@ -702,23 +723,25 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
     // `hitSlop` prop (number or per-side insets) uses `hit-area-sides` with
     // `--neo-hit-*` variables.
     const webHitArea = hitarea !== false && isActive && isWeb;
-    const customHit = webHitArea && hitSlop !== undefined;
-    const hitInsets = typeof hitSlop === 'number'
-        ? { top: hitSlop, right: hitSlop, bottom: hitSlop, left: hitSlop }
-        : (hitSlop ?? {});
+    const customHit = webHitArea && effectiveHitSlop !== undefined;
+    const hitInsets = typeof effectiveHitSlop === 'number'
+        ? { top: effectiveHitSlop, right: effectiveHitSlop, bottom: effectiveHitSlop, left: effectiveHitSlop }
+        : (effectiveHitSlop ?? {});
     const hitAreaClass = !webHitArea ? '' : (customHit ? 'hit-area-sides' : (resolved.hitAreaCls ?? ''));
 
     /* ------------------- container / text classes ----------------------- */
 
+    // A segment's corners: the group's radius on its outer edges, square inside.
+    const roundedCls = segment ? segment.roundedCls : resolved.rounded;
     const containerCls = cn(
         'flex-row items-center web:transition-[background-color,box-shadow,opacity] web:duration-200 web:ease-out',
         isInteractive && 'web:cursor-pointer',
-        resolved.rounded,
+        roundedCls,
         (resolved.aspectSquare || pinIconOnlySquare) ? 'aspect-square' : '',
         sizeClass,
         paddingClass,
         `justify-${resolved.align}`,
-        resolved.containerCls(stateKey),
+        segment ? resolved.segmentCls(stateKey) : resolved.containerCls(stateKey),
         // `min-w-0` is for labelled ellipsis. On icon-only it tw-merges away
         // `min-w-*` from the size class and the box collapses to the glyph.
         resolved.width === 'fill'
@@ -739,8 +762,9 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
     // (and the text inherits the prominent foreground colour, not the tint).
     // Mirrors SwiftUI's simple split: prominent styles use tint as fill;
     // non-prominent styles use tint for the label/image accent.
-    const tintFillsSurface =
-        resolved.tint && (resolved.style === 'borderedProminent' || resolved.style === 'glassProminent');
+    const isProminent = resolved.style === 'borderedProminent' || resolved.style === 'glassProminent';
+    // A segment's fill is the group's (it paints the tint itself).
+    const tintFillsSurface = !segment && resolved.tint && isProminent;
 
     // Only the values that have no utility-class equivalent remain inline:
     // unmapped size/padding fallbacks, asymmetric contentInsets overrides, and
@@ -842,7 +866,7 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
         // When tint repaints the surface (borderedProminent / glassProminent),
         // the label/image keep the prominent foreground colour from the theme
         // so contrast stays correct on the freshly-tinted background.
-        if (tintFillsSurface) return undefined;
+        if (tintFillsSurface || (segment && isProminent)) return undefined;
         return resolved.tint;
     })();
 
@@ -895,7 +919,7 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
             ) : (resolved.style !== 'plain' && resolved.style !== 'link') ? (
                 <PressHighlight
                     active={isPressed}
-                    rounded={resolved.rounded}
+                    rounded={roundedCls}
                     color={resolved.highlightBg}
                 />
             ) : null}
@@ -916,7 +940,8 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
     // wide native screen (isDesktop) would lose the root layout classes.
     const tooltipWraps = isWeb && !!tooltip && isDesktop && !!appSetting('layout', 'tooltips');
     const rootLayoutClass = cn(
-        resolved.width === 'fill'
+        // Segments of a `width="fill"` group share its width.
+        resolved.width === 'fill' || segment?.fill
             ? FILL_WIDTH_CLASS
             : (pinIconOnlySquare ? 'self-start' : 'self-start max-w-full min-w-0'),
         classNames?.root,

+ 1 - 0
neo/packages/app/design/controls/neo-button/neo-button.types.ts

@@ -8,6 +8,7 @@ export type NeoButtonStyle =
     | 'bordered'
     | 'borderedProminent'
     | 'borderless'
+    | 'outline'
     | 'link'
     | 'glass'
     | 'glassProminent'

+ 6 - 0
neo/packages/app/design/styles/theme.css

@@ -144,6 +144,8 @@
     --shadow-input-outline-deep: var(--neo-shadow-input-outline-deep);
     --shadow-btn-outline: var(--neo-shadow-btn-outline);
     --shadow-btn-outline-deep: var(--neo-shadow-btn-outline-deep);
+    --shadow-btn-ring: var(--neo-shadow-btn-ring);
+    --shadow-btn-ring-deep: var(--neo-shadow-btn-ring-deep);
     --shadow-btn-glass: var(--neo-shadow-btn-glass);
     --shadow-btn-glass-deep: var(--neo-shadow-btn-glass-deep);
     --shadow-btn-glass-pressed: var(--neo-shadow-btn-glass-pressed);
@@ -194,6 +196,10 @@
        
         --neo-shadow-btn-outline:        inset 0 0 0 1px rgb(0 0 0 / 0.04), inset 0 0 0 2px rgb(255 255 255 / 0.5), 0 1px 3px -1px rgb(0 0 0 / 0.05);
         --neo-shadow-btn-outline-deep:   inset 0 0 0 1px rgb(255 255 255 / 0.025), 0 4px 12px -4px rgb(0 0 0 / 0.5);
+
+        /* `outline` buttons and groups: a 1px ring outside the box, so it adds no size and segment fills can't cover it. */
+        --neo-shadow-btn-ring:           0 0 0 1px rgb(0 0 0 / 0.12), 0 1px 2px -1px rgb(0 0 0 / 0.06);
+        --neo-shadow-btn-ring-deep:      0 0 0 1px rgb(255 255 255 / 0.14);
        
         --neo-shadow-btn-glass:                        inset 0 0 0 1px rgb(255 255 255 / 0.50), inset 0 1px 0 0 rgb(255 255 255 / 0.50), 0 0 0 1px rgb(0 0 0 / 0.05), 0 2px 8px -2px rgb(0 0 0 / 0.05), 0 4px 16px -4px rgb(0 0 0 / 0.05);
         --neo-shadow-btn-glass-deep:                   inset 0 0 0 1px rgb(255 255 255 / 0.05), inset 0 1px 0 0 rgb(255 255 255 / 0.10), 0 0 0 1px rgb(0 0 0 / 0.2), 0 2px 8px -2px rgb(0 0 0 / 0.2), 0 4px 16px -4px rgb(0 0 0 / 0.2);

+ 137 - 5
neo/packages/app/settings/theme/buttons.js

@@ -345,12 +345,115 @@ export const settingsButtons = {
             circle:    { rounded: 'rounded-full', aspectSquare: true },
         },
 
-        // Segmented group (`NeoButtonGroup`, `ButtonsGroupMenu`). Rounding comes
-        // from the group's controlSize (`roundedRectangle`). No `overflow-hidden`:
-        // it would clip the segments' focus rings.
+        // Button group (`NeoButtonGroup`, `ButtonsGroupMenu`). The group takes
+        // the same `style` as a button and paints that style's resting surface
+        // once: a fill, or a shadow ring for outline and glass, never a border,
+        // so the group is exactly as tall as its buttons. Each button inside is
+        // a segment: no surface of its own, only the state washes below, square
+        // inner corners and the group's outer corners (same radius: there is
+        // no padding between them). No `overflow-hidden`: it would clip the
+        // segments' focus rings.
         group: {
-            container: 'flex-row items-stretch border border-border',
-            separator: 'w-px self-stretch bg-border/60',
+            // When neither the group nor a parent `NeoButtonStyleProvider` sets one.
+            defaultStyle: 'bordered',
+            // Short centred divider between segments, by controlSize (static literals).
+            separatorHeight: { mini: 'h-3', small: 'h-4', regular: 'h-5', large: 'h-6', xlarge: 'h-7' },
+            styles: {
+                plain: {
+                    container: '',
+                    separator: 'bg-foreground/10',
+                    segment: { default: '', disabled: 'opacity-50' },
+                },
+                bordered: {
+                    container: 'bg-button',
+                    separator: 'bg-foreground/10',
+                    segment: {
+                        default: '',
+                        hovered: 'bg-button-hover',
+                        focused: 'bg-button-hover',
+                        pressed: 'bg-button-hover',
+                        active: 'bg-button-hover',
+                        pressedToggle: 'bg-accent',
+                        voted: 'bg-accent/60',
+                        disabled: 'opacity-50',
+                    },
+                },
+                outline: {
+                    container: 'shadow-btn-ring dark:shadow-btn-ring-deep',
+                    separator: 'bg-foreground/10',
+                    segment: {
+                        default: '',
+                        hovered: 'bg-muted/50',
+                        focused: 'bg-muted/50',
+                        pressed: 'bg-muted',
+                        active: 'bg-muted/50',
+                        pressedToggle: 'bg-accent',
+                        voted: 'bg-accent/60',
+                        disabled: 'opacity-50',
+                    },
+                },
+                borderless: {
+                    container: '',
+                    separator: 'bg-foreground/10',
+                    segment: {
+                        default: '',
+                        hovered: 'bg-muted/50',
+                        focused: 'bg-muted/50',
+                        pressed: 'bg-accent/80',
+                        active: 'bg-muted/50',
+                        pressedToggle: 'bg-muted',
+                        voted: 'bg-accent/60',
+                        disabled: 'opacity-50',
+                    },
+                },
+                // Prominent fills (primary, or a role / `tint` colour): states
+                // darken the fill, so they work on any tint.
+                borderedProminent: {
+                    container: 'bg-primary',
+                    separator: 'bg-primary-foreground/30',
+                    segment: {
+                        default: '',
+                        hovered: 'bg-black/10',
+                        focused: 'bg-black/10',
+                        pressed: 'bg-black/15',
+                        active: 'bg-black/10',
+                        pressedToggle: 'bg-black/20',
+                        voted: 'bg-black/15',
+                        disabled: 'opacity-50',
+                    },
+                },
+                // iOS 26 draws real liquid glass behind the segments instead
+                // (`liquidContainer` replaces `container` there).
+                glass: {
+                    container: 'web:backdrop-blur-md bg-popover/50 shadow-btn-glass dark:shadow-btn-glass-deep',
+                    liquidContainer: '',
+                    separator: 'bg-foreground/10',
+                    segment: {
+                        default: '',
+                        hovered: 'bg-foreground/5',
+                        focused: 'bg-foreground/5',
+                        pressed: 'bg-foreground/10',
+                        active: 'bg-foreground/5',
+                        pressedToggle: 'bg-accent',
+                        voted: 'bg-accent/60',
+                        disabled: 'opacity-50',
+                    },
+                },
+                glassProminent: {
+                    container: 'web:backdrop-blur-md bg-primary shadow-btn-glass-prominent dark:shadow-btn-glass-prominent-deep',
+                    separator: 'bg-primary-foreground/30',
+                    segment: {
+                        default: '',
+                        hovered: 'bg-black/10',
+                        focused: 'bg-black/10',
+                        pressed: 'bg-black/15',
+                        active: 'bg-black/10',
+                        pressedToggle: 'bg-black/20',
+                        voted: 'bg-black/15',
+                        disabled: 'opacity-50',
+                    },
+                },
+            },
         },
 
         // SwiftUI roles. `defaultStyle` is used when no `style` prop is
@@ -364,6 +467,8 @@ export const settingsButtons = {
             confirm:     { defaultStyle: 'borderedProminent' },
             destructive: {
                 tint: 'rgb(var(--destructive))',
+                // Prominent fill as a class: native can't read `var()` in an inline colour.
+                fillClass: 'bg-destructive',
                 // Prominent styles paint the tint as the fill, so their label stays on the foreground.
                 textClass: { default: 'text-destructive', borderedProminent: 'text-destructive-foreground', glassProminent: 'text-destructive-foreground' },
             },
@@ -478,6 +583,33 @@ export const settingsButtons = {
                 },
             },
 
+            // No SwiftUI equivalent: no fill, a 1px ring drawn with a shadow
+            // (it adds no size, unlike a border). Hover / press wash like borderless.
+            outline: {
+                container: {
+                    base: 'shadow-btn-ring dark:shadow-btn-ring-deep',
+                    default: '',
+                    hovered: 'bg-muted/50',
+                    focused: 'bg-muted/50',
+                    pressed: 'bg-muted',
+                    active: 'bg-muted/50',
+                    pressedToggle: 'bg-accent',
+                    voted: 'bg-accent/60',
+                    disabled: 'opacity-50',
+                },
+                text: {
+                    base: 'font-medium tracking-tight',
+                    default: 'text-card-foreground',
+                    hovered: 'text-foreground',
+                    focused: 'text-foreground',
+                    pressed: 'text-foreground',
+                    active: 'text-foreground',
+                    pressedToggle: 'text-accent-foreground',
+                    voted: 'text-accent-foreground',
+                    disabled: 'text-muted-foreground',
+                },
+            },
+
             // SwiftUI has no link style; we provide one that matches a web
             // text link (no fill, hover-underline on web).
             link: {