Explorar el Código

Chore/sync neo (#5777)

* Squashed 'neo/' changes from d6b9099d15..c5ff6a9310

c5ff6a9310 Merge pull request #789 from unacms/feat/mobile-card-surface-header-band
4f64b2158d fix(native): square press highlight on Connections rows on phones
7bfcfaf5bb feat(mobile): card-surface header band, solid edge-to-edge rows, 52px subtabs
279dfc31e7 images/video/embeds in editor
5a25d99587 Merge pull request #788 from unacms/fix/native-compact-row-actions
5d50ad2582 fix(native): compact, right-aligned action buttons in profile rows on phones
206e0f2beb Merge pull request #787 from unacms/fix/ios-dev-client-url-blank-home
18afd22c22 fix(native): ignore the dev-client launch URL so the home tab isn't blank
2886fc2665 Merge pull request #786 from unacms/feat/tabbar-profile-more-android-web
12bc37cdf8 feat(tabs): profile More tab and sheet on Android and web, like iOS
7906d95e58 app versions
41495b4247 versions + tab routing
18ac632d85 Merge pull request #784 from unacms/fix/native-video-lightbox
4c7e27203c Merge pull request #785 from unacms/feat/platform-font-settings
cf93024b20 feat(fonts): set the font per platform (web, iOS, Android), Inter on all three
222d30584d fix(native): videos play in the lightbox on iOS and Android
bfa5f9e48a Merge pull request #783 from unacms/fix/android-linking-state-update-require-cycle
dfe9b5d1da fix(forms): break the form-fields require cycles
a00b155ca5 fix(android): patch expo-router so the launch link can't update an unmounted navigator
221311f4f9 Merge pull request #782 from unacms/fix/footer-links-ios-icon-sizes
d45cefef33 fix(ios): NeoButton icons take the same size as on web
da4a68e034 fix(footer): full footer links wrap instead of truncating on phones
95265443db Merge pull request #781 from unacms/feat/neobutton-migration
b587862173 fix(native): Expo UI NeoButtons fire on release, so a scroll can start on them
2f89c50b5f fix(courses): edit_course_content renders instead of throwing
96772fc307 fix(spacing): feed card header and task lists keep 44px targets apart
de848edc2b fix(a11y): name and ring the drag handles and the ProfileSwitcher trigger
fc7461c40a fix(feed): React and Share are single, properly ringed tab stops
47a30d74a2 refactor(buttons): migrate the legacy Button to NeoButton
020b3caf6e fix(feed): round play badge, even paragraph gaps, item page body spacing
5aa6f513d8 Merge pull request #780 from unacms/fix/ai-agent-chrome-fade
f1c674e755 fix(ai-agent): agent dropdown title sized like a block title; Expand icon opens the full page
6021c6d64e feat(ai-agent): operator agent dropdown gets the same edge-to-edge chrome
6818640d39 fix(ai-agent): block title over the chat is plain text, not a pill
595880092e fix(ai-agent): block chrome fades like the messenger, from the block's surface
2466c68928 Merge pull request #779 from unacms/feat/ai-agent-chat-composer
5de0384065 feat(ai-agent): chat scrolls edge to edge under glass chrome
0555761f1b fix(ai-agent): composer shadow no longer clipped by the chat frame
556649c85f feat(ai-agent): every agent chat uses the messenger's floating composer

git-subtree-dir: neo
git-subtree-split: c5ff6a9310bd687cee84fbf391bdce65fdc0517d

* sync neo
Aleksandr Trofimov hace 2 semanas
padre
commit
dfbd0c5180
Se han modificado 100 ficheros con 1899 adiciones y 830 borrados
  1. 30 0
      .github/workflows/sync-neo.yml
  2. 9 0
      neo/agents.md
  3. 3 0
      neo/apps/expo/app.config.js
  4. 14 4
      neo/apps/expo/global.combined.css
  5. 3 0
      neo/apps/expo/index.js
  6. 1 0
      neo/apps/expo/package.json
  7. 46 0
      neo/apps/expo/plugins/with-app-fonts.js
  8. 67 34
      neo/packages/app/components/block-wrapper.js
  9. 14 7
      neo/packages/app/components/elements/ai_agent.js
  10. 2 2
      neo/packages/app/components/elements/browse-list.js
  11. 23 24
      neo/packages/app/components/elements/browse.js
  12. 2 2
      neo/packages/app/components/elements/bundles.js
  13. 8 8
      neo/packages/app/components/elements/calendar.js
  14. 2 2
      neo/packages/app/components/elements/categories-list.js
  15. 6 4
      neo/packages/app/components/elements/chat/parts/convos-list-item.tsx
  16. 9 5
      neo/packages/app/components/elements/chat/parts/convos-list.tsx
  17. 2 2
      neo/packages/app/components/elements/chat/parts/message-input.tsx
  18. 2 5
      neo/packages/app/components/elements/comment-content.js
  19. 9 5
      neo/packages/app/components/elements/comments-browse.js
  20. 6 6
      neo/packages/app/components/elements/course-structure.js
  21. 8 7
      neo/packages/app/components/elements/credits-checkout.js
  22. 30 71
      neo/packages/app/components/elements/dashboard-stat-old.js
  23. 2 2
      neo/packages/app/components/elements/dashboard-stat.js
  24. 11 62
      neo/packages/app/components/elements/edit-course-content.js
  25. 8 7
      neo/packages/app/components/elements/entity-poll.js
  26. 20 4
      neo/packages/app/components/elements/entity-text.js
  27. 30 23
      neo/packages/app/components/elements/feed-item-parts.js
  28. 14 12
      neo/packages/app/components/elements/feed-item.js
  29. 2 2
      neo/packages/app/components/elements/invite-in-context.js
  30. 3 3
      neo/packages/app/components/elements/invite.js
  31. 1 1
      neo/packages/app/components/elements/messenger/parts/headers.js
  32. 5 6
      neo/packages/app/components/elements/messenger/parts/item-jot.js
  33. 31 38
      neo/packages/app/components/elements/module-structure.js
  34. 7 7
      neo/packages/app/components/elements/notifications-settings.js
  35. 32 26
      neo/packages/app/components/elements/profile-switcher.js
  36. 16 12
      neo/packages/app/components/elements/reputation.js
  37. 19 16
      neo/packages/app/components/elements/search-ai.js
  38. 8 5
      neo/packages/app/components/elements/search-sections.tsx
  39. 26 10
      neo/packages/app/components/elements/smart-grid.web.js
  40. 4 5
      neo/packages/app/components/elements/tasks-timers.js
  41. 19 7
      neo/packages/app/components/elements/tasks/actions.js
  42. 3 3
      neo/packages/app/components/elements/tasks/filter-selector.js
  43. 4 3
      neo/packages/app/components/elements/tasks/tasklists-panel.js
  44. 9 8
      neo/packages/app/components/elements/wiki-history.js
  45. 9 6
      neo/packages/app/components/form-fields/_field.js
  46. 3 0
      neo/packages/app/components/form-fields/_map.js
  47. 89 11
      neo/packages/app/components/form-fields/editor-inner-enriched.js
  48. 15 8
      neo/packages/app/components/form-fields/editor-markdown.js
  49. 2 1
      neo/packages/app/components/form-fields/embed.js
  50. 21 8
      neo/packages/app/components/form-fields/files.js
  51. 2 2
      neo/packages/app/components/form-fields/initial-members.js
  52. 8 7
      neo/packages/app/components/form-fields/labels.js
  53. 11 9
      neo/packages/app/components/form-fields/list.js
  54. 5 3
      neo/packages/app/components/form-fields/multi-field.js
  55. 7 5
      neo/packages/app/components/form-fields/phone.js
  56. 4 4
      neo/packages/app/components/form-fields/polls.js
  57. 4 5
      neo/packages/app/components/form-fields/select.js
  58. 8 7
      neo/packages/app/components/form-fields/selector.js
  59. 2 2
      neo/packages/app/components/form-fields/stripe-connect.js
  60. 5 6
      neo/packages/app/components/form-fields/visibility.js
  61. 7 8
      neo/packages/app/components/form.js
  62. 15 2
      neo/packages/app/components/menu-items/submenu-share.js
  63. 13 9
      neo/packages/app/components/menu-items/unit.js
  64. 23 22
      neo/packages/app/components/menu.js
  65. 155 13
      neo/packages/app/components/nav/footer.web.js
  66. 9 12
      neo/packages/app/components/nav/menu-account.js
  67. 8 1
      neo/packages/app/components/nav/menu-dynamic.js
  68. 93 13
      neo/packages/app/components/nav/tabs/expoui-tabs.js
  69. 9 0
      neo/packages/app/components/nav/tabs/index.js
  70. 222 0
      neo/packages/app/components/nav/tabs/more-sheet.android.tsx
  71. 3 3
      neo/packages/app/components/nav/tabs/more-sheet.ios.tsx
  72. 1 1
      neo/packages/app/components/nav/tabs/more-sheet.tsx
  73. 1 1
      neo/packages/app/components/nav/tabs/more-sheet.types.ts
  74. 29 1
      neo/packages/app/components/nav/tabs/tab-menu.js
  75. 8 0
      neo/packages/app/components/registry.js
  76. 35 32
      neo/packages/app/components/units/comments.js
  77. 1 0
      neo/packages/app/components/units/content-list/bx-organizations.js
  78. 1 0
      neo/packages/app/components/units/content-list/bx-persons.js
  79. 1 1
      neo/packages/app/components/units/feed.js
  80. 23 22
      neo/packages/app/components/units/helpers.js
  81. 7 7
      neo/packages/app/components/units/invitations.js
  82. 4 2
      neo/packages/app/components/units/notifications.js
  83. 4 4
      neo/packages/app/components/units/price.js
  84. 27 0
      neo/packages/app/components/units/unit-action-width.js
  85. 16 0
      neo/packages/app/customization/config/fonts.js
  86. 4 2
      neo/packages/app/customization/design/fonts/fonts-web.js
  87. 0 1
      neo/packages/app/customization/elements/_mapbox.js
  88. 2 2
      neo/packages/app/customization/elements/_mapbox.web.js
  89. 8 7
      neo/packages/app/default/functions.js
  90. 53 49
      neo/packages/app/default/static.js
  91. 1 0
      neo/packages/app/default/translations/de.json
  92. 1 0
      neo/packages/app/default/translations/es.json
  93. 1 0
      neo/packages/app/default/translations/fr.json
  94. 1 0
      neo/packages/app/default/translations/ru.json
  95. 4 0
      neo/packages/app/design/controls.ts
  96. 13 17
      neo/packages/app/design/controls/button-groups.tsx
  97. 112 64
      neo/packages/app/design/controls/button-menus.tsx
  98. 3 0
      neo/packages/app/design/controls/buttons.tsx
  99. 71 0
      neo/packages/app/design/controls/neo-button/control-scale.ts
  100. 145 0
      neo/packages/app/design/controls/neo-button/legacy-button-map.tsx

+ 30 - 0
.github/workflows/sync-neo.yml

@@ -0,0 +1,30 @@
+name: Sync neo subtree
+
+on:
+  push:
+    branches:
+      - main
+    paths:
+      - 'neo/**'
+
+permissions:
+  contents: read
+
+jobs:
+  sync:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Push neo subtree
+        env:
+          NEO_REPO_TOKEN: ${{ secrets.NEO_REPO_TOKEN }}
+        run: |
+          git remote add neo \
+            "https://x-access-token:${NEO_REPO_TOKEN}@github.com/unacms/neo.git"
+
+          git subtree push --prefix neo neo main

+ 9 - 0
neo/agents.md

@@ -382,6 +382,15 @@ Reference tokens from `packages/app/default/settings.js` (and the `packages/app/
 | **Borders** | `rounded-lg`, `rounded-xl`, `rounded-2xl`, `border`, `border-border/60` |
 | **Shadows** | `shadow-sm`, `shadow-md`, `shadow-lg` |
 
+### Fonts
+
+Text uses two roles, `font-main` (default in `design/typography.tsx`) and `font-title` (headings). Pick the family per role and platform in [`customization/config/fonts.js`](packages/app/customization/config/fonts.js) (defaults: [`design/fonts/font-config.js`](packages/app/design/fonts/font-config.js)), e.g. `main: { web: 'inter', ios: 'system', android: 'inter' }`:
+
+- `'system'` = the platform UI font: SF Pro on iOS, the device sans-serif on Android, the OS font stack on web.
+- Any other value is a key of [`design/fonts/font-families.js`](packages/app/design/fonts/font-families.js) (`'inter'`). Native needs one **static** file per weight (iOS/Android pick the face by `fontWeight`; variable fonts don't work). Web needs a `next/font` instance per role in `webFontFamilies` ([`fonts-web.web.ts`](packages/app/design/fonts/fonts-web.web.ts)).
+- iOS/Android values are build settings: [`plugins/with-app-fonts.js`](apps/expo/plugins/with-app-fonts.js) embeds only the selected families, so changing them needs `expo prebuild` and a native rebuild. At runtime [`design/fonts/native-fonts.ts`](packages/app/design/fonts/native-fonts.ts) points the Uniwind font variables (and navigation header fonts) at the family.
+- Don't set `fontFamily` inline; use `font-main` / `font-title`.
+
 ### Button Styling
 
 Use button variants from settings:

+ 3 - 0
neo/apps/expo/app.config.js

@@ -160,6 +160,9 @@ const expoConfig = {
         // Android NativeTabs bar takes its items' height (no 80dp floor), so it
         // is shorter with `native.tab_labels` off.
         "./plugins/with-android-tab-bar-height.js",
+        // Fonts picked for iOS / Android in customization/config/fonts.js
+        // (embedded only when a platform uses a custom family, not 'system').
+        "./plugins/with-app-fonts.js",
         [
             "expo-image-picker",
             {

+ 14 - 4
neo/apps/expo/global.combined.css

@@ -16,7 +16,9 @@
 
 /*
  * Expo/Uniwind-only @theme inline additions:
- *   - fonts come from RN System (web uses next/font on <html>)
+ *   - fonts read --neo-font-main / --neo-font-title, which
+ *     design/fonts/native-fonts sets at startup from
+ *     customization/config/fonts.js (web uses next/font on <html>)
  *   - --breakpoint-3xl matches the legacy `screens.3xl` from theme.js
  *
  * NOTE: do NOT override the Tailwind v4 --container-* scale here. Web keeps the
@@ -27,12 +29,20 @@
  * come from the imported theme.css above.
  */
 @theme inline {
-    --font-main: System;
-    --font-title: System;
-    --font-sans: System;
+    --font-main: var(--neo-font-main);
+    --font-title: var(--neo-font-title);
+    --font-sans: var(--neo-font-main);
     --breakpoint-3xl: 120rem;
 }
 
+/* Until native-fonts runs: the platform UI font. */
+@layer base {
+    :root {
+        --neo-font-main: System;
+        --neo-font-title: System;
+    }
+}
+
 /*
  * Native (Uniwind) shadow overrides.
  *

+ 3 - 0
neo/apps/expo/index.js

@@ -20,6 +20,9 @@ import { View } from 'react-native';
 import { useColorScheme } from 'react-native';
 import { KeyboardProvider } from "react-native-keyboard-controller";
 import { SafeAreaProvider, initialWindowMetrics } from 'react-native-safe-area-context';
+import { applyNativeFonts } from 'app/design/fonts/native-fonts';
+
+applyNativeFonts();
 
 SplashScreen.preventAutoHideAsync().catch(() => {});
 

+ 1 - 0
neo/apps/expo/package.json

@@ -46,6 +46,7 @@
     "expo-blur": "~57.0.2",
     "expo-build-properties": "~57.0.14",
     "expo-camera": "~57.0.4",
+    "expo-application": "~57.0.3",
     "expo-constants": "~57.0.14",
     "expo-crypto": "~57.0.2",
     "expo-document-picker": "~57.0.1",

+ 46 - 0
neo/apps/expo/plugins/with-app-fonts.js

@@ -0,0 +1,46 @@
+const path = require('path');
+const withFonts = require('expo-font/app.plugin').default;
+const { fontChoice, FONT_ROLES } = require('app/design/fonts/font-choice');
+const families = require('app/design/fonts/font-families');
+
+const FAMILIES_DIR = path.dirname(require.resolve('app/design/fonts/font-families'));
+
+/** Custom family keys that any role uses on `platform`. */
+function familiesFor(platform) {
+    const keys = FONT_ROLES.map((role) => fontChoice(role, platform)).filter((key) => key !== 'system');
+    return [...new Set(keys)];
+}
+
+/** [{ weight, path }] for a family's static files (absolute paths). */
+function fontDefinitions(key) {
+    return Object.entries(families[key].native).map(([weight, file]) => ({
+        weight: Number(weight),
+        path: path.join(FAMILIES_DIR, file),
+    }));
+}
+
+/**
+ * Embed the custom fonts that customization/config/fonts.js selects for iOS
+ * and Android (expo-font's config plugin), and nothing for 'system'.
+ *
+ * - iOS: the files go into the app bundle and UIAppFonts. iOS groups them by
+ *   the family name inside the files, and React Native picks the face by
+ *   fontWeight.
+ * - Android: an XML font family per custom family, registered with React
+ *   Native's ReactFontManager under the family `name`, so fontWeight selects
+ *   the file too.
+ *
+ * Changing the font config needs a prebuild and a native rebuild.
+ */
+module.exports = function withAppFonts(config) {
+    const iosFonts = familiesFor('ios').flatMap((key) => fontDefinitions(key).map((def) => def.path));
+    const androidFonts = familiesFor('android').map((key) => ({
+        fontFamily: families[key].name,
+        fontDefinitions: fontDefinitions(key),
+    }));
+    if (!iosFonts.length && !androidFonts.length) return config;
+    return withFonts(config, {
+        ios: { fonts: iosFonts },
+        android: { fonts: androidFonts },
+    });
+};

+ 67 - 34
neo/packages/app/components/block-wrapper.js

@@ -4,7 +4,7 @@ import { stripTags, appSetting, isUrl } from 'app/lib/util';
 import { useState } from 'react';
 import { Block as PageBlock, BlockContent, BlockName, BlockActions, BlockHeader, BlockTitle, BlockDescription, BlockIcon } from 'app/ui/molecules/page/page-block'
 import { useTranslation } from 'react-i18next'
-import { Button, ButtonLink, Modal, NeoButtonLink } from 'app/design/controls'
+import { Modal, NeoButton, NeoButtonLink } from 'app/design/controls'
 import { Icon } from 'app/ui/atoms/icon'
 import { components } from 'app/components/registry'
 import { usePathname } from 'app/lib/hooks/router'
@@ -47,15 +47,27 @@ function BlockHeaderMenu({ menu, pathname }) {
 
 // `clip` is opt-in: tile/card shadows paint outside the content box, so blocks
 // never hide overflow by default. Inner scrollers own their own overflow.
-export function BlockWrapper({ config, block, wrapperClassses, showTitle, showBg, fullWidth, contentOnly, list, showPadding, extraProps, fill, clip = false, children }) {
+//
+// `overlayHeader`: for content that scrolls edge to edge under its own chrome (the AI
+// agent chat). The block drops its padding and clips to its own corners, and the
+// header is not laid out above the content: `children` is called as
+// `children({ header, surface })` with the usual header — icon, title, description,
+// actions — unpadded (null where the design box hides the title), for the content to
+// float over
+// itself, and the colour it sits on — 'card' where the block has its background,
+// 'background' (the page) where it has none — for fades over the content to start
+// from. Its gutters are then the content's to keep, inside the scroller.
+export function BlockWrapper({ config, block, wrapperClassses, showTitle, showBg, fullWidth, contentOnly, list, showPadding, extraProps, fill, clip = false, overlayHeader = false, children }) {
     const { t } = useTranslation()
     const [showHelp, setShowHelp] = useState(false)
     const pathname = usePathname()
 
     const blockMenu = block?.menu?.items?.length ? block.menu : null
 
+    const renderChildren = (header = null, surface = 'background') => (typeof children === 'function' ? children({ header, surface }) : children)
+
     if (block?.designbox_id == null)
-        return children;
+        return renderChildren();
 
     block.designbox_id = Number(block.designbox_id);
     const aNoTitle = [0, 10, 13, 3];
@@ -96,6 +108,7 @@ export function BlockWrapper({ config, block, wrapperClassses, showTitle, showBg
     if (typeof showPadding !== 'undefined') {
         bIsShowPadding = showPadding;
     }
+    if (overlayHeader) bIsShowPadding = false;
 
     const cssClasses = extraProps?.cssClasses || "";
     // Streamlined logic: avoid unnecessary fragment, ensure BlockContent is not wrapping elements twice
@@ -106,7 +119,7 @@ export function BlockWrapper({ config, block, wrapperClassses, showTitle, showBg
 
     if (contentOnly) {
         return (<View className={wrapperClassses}>
-            {children}
+            {renderChildren()}
         </View>)
     }
     const pureHelp = stripTags(block.help);
@@ -122,6 +135,48 @@ export function BlockWrapper({ config, block, wrapperClassses, showTitle, showBg
     const isHelp = !!block.help
     const hasHeaderActions = !!(config?.header_more_url || blockMenu)
     const fillClass = fill ? 'flex h-full min-h-0 flex-1 flex-col' : ''
+    // Title on some tiers only: hidden where it's off.
+    const titleTiersClass = Array.isArray(bIsShowTitle) ? responsiveClasses('title', bIsShowTitle) : ''
+
+    const headerName = (
+        <BlockName>
+            <View className="flex-row items-center gap-2">
+                {!!block.icon && <BlockIcon>
+                    <Icon icon={block.icon} size={appSetting('theme', 'blocks')['u-block-icon-size']}/>
+                </BlockIcon>}
+                <BlockTitle>{stripTags(block.title)}</BlockTitle></View>
+            {!!block.description && <BlockDescription>{block.description}</BlockDescription>}
+        </BlockName>
+    )
+    const headerActions = (
+        <>
+            {hasHeaderActions && (<BlockActions>
+                {blockMenu ? (
+                    <BlockHeaderMenu menu={blockMenu} pathname={pathname} />
+                ) : null}
+                {config?.header_more_url ? (
+                    <NeoButtonLink
+                        href={config?.header_more_url}
+                        label={t(config?.header_more_text || 'See all')}
+                        style="link"
+                        borderShape="roundedRectangle"
+                        controlSize="small"
+                    />
+                ) : null}
+            </BlockActions>)}
+            {(isHelp && isHelpLink) && <NeoButtonLink href={pureHelp} target="_blank" style="borderless" image="LifeBuoy" label={t('Help')} />}
+            {(isHelp && !isHelpLink) && <NeoButton style="borderless" image="LifeBuoy" label={t('Help')} onPress={() => setShowHelp(true)} />}
+        </>
+    )
+
+    // Plain text, like any block header: the content's fade is its chrome. (A pill is
+    // for interactive elements.) The content positions and pads it.
+    const overlayHeaderNode = overlayHeader && bIsShowTitle ? (
+        <BlockHeader className={titleTiersClass || undefined} pointerEvents="box-none">
+            {headerName}
+            {headerActions}
+        </BlockHeader>
+    ) : null
 
     return (
         <View className={`${wrapperClassses || 'w-full'} ${fillClass}`}>
@@ -137,47 +192,24 @@ export function BlockWrapper({ config, block, wrapperClassses, showTitle, showBg
                     className={[
                         "w-full mx-auto",
                         fill ? "flex h-full min-h-0 flex-1 flex-col" : "",
+                        // Content runs to the block's edges: keep it inside the rounded corners.
+                        overlayHeader ? "overflow-hidden" : "",
                         (!fullWidth && !cssClasses.includes("max-w-") ? appSetting('layout', 'max_width_block') : ""),
                         cssClasses,
                     ].filter(Boolean).join(" ")}
 
                 >
-                    {bIsShowTitle && (
+                    {bIsShowTitle && !overlayHeader && (
                         <BlockHeader
                             isPad={bIsShowPadding}
                             className={[
                                 fill ? 'shrink-0' : '',
                                 fill && !bIsShowPadding ? 'px-4 pt-4' : '',
-                                // Title on some tiers only: hidden where it's off.
-                                Array.isArray(bIsShowTitle) ? responsiveClasses('title', bIsShowTitle) : '',
+                                titleTiersClass,
                             ].filter(Boolean).join(' ') || undefined}
                         >
-                             
-                            <BlockName>
-                                <View className="flex-row items-center gap-2">
-                            {!!block.icon && <BlockIcon>
-                                <Icon icon={block.icon} size={appSetting('theme', 'blocks')['u-block-icon-size']}/>
-                            </BlockIcon>}
-                                <BlockTitle>{stripTags(block.title)}</BlockTitle></View>
-                                {!!block.description && <BlockDescription>{block.description}</BlockDescription>}
-                            </BlockName>
-
-                            {hasHeaderActions && (<BlockActions>
-                                {blockMenu ? (
-                                    <BlockHeaderMenu menu={blockMenu} pathname={pathname} />
-                                ) : null}
-                                {config?.header_more_url ? (
-                                    <NeoButtonLink
-                                        href={config?.header_more_url}
-                                        label={t(config?.header_more_text || 'See all')}
-                                        style="link"
-                                        borderShape="roundedRectangle"
-                                        controlSize="small"
-                                    />
-                                ) : null}
-                            </BlockActions>)}
-                            {(isHelp && isHelpLink) && <ButtonLink href={pureHelp} target="_blank" title={t('Help')} startDecorator='LifeBuoy' variant="text" />}
-                            {(isHelp && !isHelpLink) && <Button onPress={() => setShowHelp(true)} title={t('Help')} startDecorator='LifeBuoy' variant="text" />}
+                            {headerName}
+                            {headerActions}
                         </BlockHeader>
                     )}
                     <BlockContent
@@ -188,7 +220,8 @@ export function BlockWrapper({ config, block, wrapperClassses, showTitle, showBg
                                 : undefined
                         }
                     >
-                        {children}
+                        {/* Background on some tiers only counts as a card: it is where it shows most. */}
+                        {renderChildren(overlayHeaderNode, bIsShowBg ? 'card' : 'background')}
                     </BlockContent>
                 </PageBlock>
             </View>

+ 14 - 7
neo/packages/app/components/elements/ai_agent.js

@@ -13,13 +13,20 @@ export default function ElementAiAgent({ data, blockWrapperProps }) {
     const contextProfileId = Number(data?.context_profile_id) || 0;
 
     return (
-        <BlockWrapper {...blockWrapperProps}>
-            <AiAgent
-                key={`${agentId}:${contextProfileId}`}
-                data={data}
-                initialMessage={initialMessage}
-                hideInitialMessage={!!initialMessage}
-            />
+        // The chat scrolls edge to edge under its own chrome: the block's title floats
+        // over the transcript on its fade, and the chat keeps the margins inside.
+        <BlockWrapper {...blockWrapperProps} overlayHeader>
+            {({ header, surface }) => (
+                <AiAgent
+                    key={`${agentId}:${contextProfileId}`}
+                    data={data}
+                    initialMessage={initialMessage}
+                    hideInitialMessage={!!initialMessage}
+                    header={header}
+                    fadeSurface={surface}
+                    gutter
+                />
+            )}
         </BlockWrapper>
     );
 }

+ 2 - 2
neo/packages/app/components/elements/browse-list.js

@@ -26,7 +26,7 @@ import { Text } from 'app/design/typography'
 import { getSkeletonForList } from 'app/lib/skeleton-helpers'
 import { subscribe } from 'app/ui/atoms/socket'
 import { useCurrentUser } from 'app/context/user'
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import { useWindowHeight } from 'app/context/measure';
 import emitter, { EVENTS } from 'app/context/emitter'
 import Snackbar from 'app/ui/atoms/snackbar'
@@ -278,7 +278,7 @@ export default function Browse(props) {
     return (
         <BlockWrapper {...props.blockWrapperProps}><View className='w-full' style={styles}>
             {formProps && <View className=" w-full">
-                <Row className="w-full items-end justify-end"><Button startDecorator="Settings2" variant="outline" title={!showFilters ? "Show filters" : "Hide filters"} onPress={() => setShowFilters(!showFilters)} /></Row>
+                <Row className="w-full items-end justify-end"><NeoButton image="Settings2" label={!showFilters ? "Show filters" : "Hide filters"} onPress={() => setShowFilters(!showFilters)} classNames={{ root: 'self-end' }} /></Row>
                 {showFilters && <Form {...formProps} key="form" name={formProps.name} onChange={handleFilterFormChange} />}
             </View>
             }

+ 23 - 24
neo/packages/app/components/elements/browse.js

@@ -31,9 +31,8 @@ import { useTranslation } from 'react-i18next'
 import { subscribe } from 'app/ui/atoms/socket'
 import { useCurrentUser } from 'app/context/user'
 import { layoutForList } from 'app/customization/functions'
-import Link from 'app/ui/atoms/link'
 import Galery from 'app/ui/molecules/content/gallery'
-import { Button } from 'app/design/controls'
+import { NeoButtonLink } from 'app/design/controls'
 import { useWindowHeight } from 'app/context/measure';
 import emitter, { EVENTS } from 'app/context/emitter'
 import Snackbar from 'app/ui/atoms/snackbar'
@@ -379,11 +378,11 @@ export default function Browse(props) {
     const filterElement = !!formProps ? (
         <Row className="w-full items-end justify-end mb-3 mt-3 sm:mt-0">
             <DropdownPopup
-                trigger={
-
-                    <Button startDecorator="Settings2" variant="outline" title={!showFilters ? "Show filters" : "Hide filters"} />
-
-                }
+                buttonProps={{
+                    image: 'Settings2',
+                    label: !showFilters ? "Show filters" : "Hide filters",
+                    classNames: { root: 'self-end' },
+                }}
                 minPopupWidth={120}
                 open={showFilters}
                 onOpenChange={handleOpenChange}
@@ -449,31 +448,31 @@ export default function Browse(props) {
                 <View className="w-full" ></View>
                 <View className={`w-full ${props.showBg ? blockTheme['u-block-bg'] + ' ' + blockTheme['u-block-pad'] + ' ' + blockTheme['u-block-base'] : ''} ${responsiveClasses('rounded', props.blockWrapperProps?.config?.rounded)}`} style={isOneLine || !isWeb ? {} : styles}>
                     {isShowTitleInside && (
-                        <Row className={`items-center justify-between ${props.showBg ? '' : 'p-2 '}`}>
+                        <Row className={`items-center justify-between gap-2 ${props.showBg ? '' : 'p-2 '}`}>
                              <View className="p-2">
                                 <BlockTitle>
                                 {t(props.block.title)}
                                 </BlockTitle>
                             </View>
                             {!!props.addLink && (
-                                <Link href={props.addLink.url}>
-                                    <Button
-                                        variant="link"
-                                        size="sm"
-                                        rounded
-                                        title={t(props.addLink.text)}
-                                    />
-                                </Link>
+                                <NeoButtonLink
+                                    href={props.addLink.url}
+                                    style="borderless"
+                                    controlSize="small"
+                                    borderShape="capsule"
+                                    label={t(props.addLink.text)}
+                                    classNames={{ root: 'self-center' }}
+                                />
                             )}
                             {(isOneLine && data.params.home_url) && (
-                                <Link href={data.params.home_url}>
-                                    <Button
-                                        variant="link"
-                                        size="sm"
-                                        rounded
-                                        title={t('View All')}
-                                    />
-                                </Link>
+                                <NeoButtonLink
+                                    href={data.params.home_url}
+                                    style="borderless"
+                                    controlSize="small"
+                                    borderShape="capsule"
+                                    label={t('View All')}
+                                    classNames={{ root: 'self-center' }}
+                                />
                             )}
 
                         </Row>

+ 2 - 2
neo/packages/app/components/elements/bundles.js

@@ -1,5 +1,5 @@
 import { BlockWrapper } from 'app/components/block-wrapper'
-import { Button } from 'app/design/controls';
+import { NeoButton } from 'app/design/controls';
 import { fetcher } from 'app/lib/fetcher';
 import { Row } from 'app/design/view';
 import { Text } from 'app/design/typography';
@@ -24,7 +24,7 @@ function ElementBundle({ data }) {
         <CardList className="w-[250px]" padding="p-3 sm:p-2 items-center gap-y-4">
             <Text className="font-semibold text-4xl tracking-tight text-secondary-foreground">{data.title}</Text>
             <Row><Text className="text-2xl font-semibold text-foreground">{t(data.currency_code)} {data.price}</Text></Row>
-            <Button variant="primary" fullWidth title={data.buttons[0].title} onPress={handlePurchase} />
+            <NeoButton style="borderedProminent" width="fill" label={data.buttons[0].title} onPress={handlePurchase} classNames={{ root: 'self-stretch flex-none' }} />
         </CardList>
     )
 }

+ 8 - 8
neo/packages/app/components/elements/calendar.js

@@ -2,7 +2,7 @@ import { View, Row } from 'app/design/view'
 import Dropdown from 'app/ui/atoms/dropdown'
 import { useState, useReducer, useMemo, useEffect } from 'react';
 import { Modal } from 'app/design/controls'
-import { Button } from 'app/design/controls';
+import { NeoButton } from 'app/design/controls';
 import { Text } from 'app/design/typography';
 import { Icon } from 'app/ui/atoms/icon';
 import { useTheme } from 'app/design/theme';
@@ -44,12 +44,12 @@ const CalendarHeader = (dValue, addMonth) => {
         return (<Row className='w-full justify-center mb-4 items-center mt-2'><Text className=" font-medium text-muted-foreground text-lg">{i18n.t('Select date')}</Text></Row>)
     }
 
-    return (<Row className='w-full justify-between mb-4 items-center mt-2'>
-        <Button size="sm" rounded startDecorator="ChevronsLeft" onPress={() => addMonth('y', -1)} />
-        <Button size="sm" rounded startDecorator="ChevronLeft" onPress={() => addMonth('m', -1)} />
+    return (<Row className='w-full justify-between gap-2 mb-4 items-center mt-2'>
+        <NeoButton controlSize="small" borderShape="circle" image="ChevronsLeft" accessibilityLabel={i18n.t('Previous year')} classNames={{ root: 'self-center' }} onPress={() => addMonth('y', -1)} />
+        <NeoButton controlSize="small" borderShape="circle" image="ChevronLeft" accessibilityLabel={i18n.t('Previous month')} classNames={{ root: 'self-center' }} onPress={() => addMonth('m', -1)} />
         <Text className=" font-medium text-muted-foreground text-lg">{formatValueDate(dValue)}</Text>
-        <Button size="sm" rounded startDecorator="ChevronRight" onPress={() => addMonth('m', 1)} />
-        <Button size="sm" rounded startDecorator="ChevronsRight" onPress={() => addMonth('y', 1)} />
+        <NeoButton controlSize="small" borderShape="circle" image="ChevronRight" accessibilityLabel={i18n.t('Next month')} classNames={{ root: 'self-center' }} onPress={() => addMonth('m', 1)} />
+        <NeoButton controlSize="small" borderShape="circle" image="ChevronsRight" accessibilityLabel={i18n.t('Next year')} classNames={{ root: 'self-center' }} onPress={() => addMonth('y', 1)} />
     </Row>)
 };
 
@@ -149,14 +149,14 @@ export default function ({ name, value = '', type, onChange, blockWrapperProps }
                                 />
                             </View>
                         </Row>
-                            <Button title={t('Apply')} onPress={() => { setFieldValue(dValue) }} />
+                            <NeoButton label={t('Apply')} classNames={{ root: 'self-center' }} onPress={() => { setFieldValue(dValue) }} />
                         </View>
                         )
                     }
                 </View>
             </Modal>
             <Row>
-                <Button title={formatValue(cValue, bIsTime)} endDecorator="Calendar" onPress={() => { setShowModal(true) }} />
+                <NeoButton label={formatValue(cValue, bIsTime)} image="Calendar" imagePlacement="trailing" onPress={() => { setShowModal(true) }} />
             </Row>
         </BlockWrapper>
     );

+ 2 - 2
neo/packages/app/components/elements/categories-list.js

@@ -1,7 +1,7 @@
 import { View } from 'app/design/view'
 import Link from 'app/ui/atoms/link'
 import { Text } from 'app/design/typography'
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import { BlockWrapper } from 'app/components/block-wrapper'
 
 export default function CategoriesList({ data, blockWrapperProps }) {
@@ -12,7 +12,7 @@ export default function CategoriesList({ data, blockWrapperProps }) {
                     <View key={item.id || item.name || item.url || `menu-${index}`} className='w-full '>
                         <Link href={item.url}>
                             <View className="flex-row gap-x-1 border border-border/60  p-1 bg-card rounded-xl m-1">
-                                <Button variant="text" startDecorator="Folder" />
+                                <NeoButton style="borderless" image="Folder" />
                                 <Text className=" text-base my-auto font-medium text-secondary-foreground  ">
                                     {item.name}
                                 </Text>

+ 6 - 4
neo/packages/app/components/elements/chat/parts/convos-list-item.tsx

@@ -26,8 +26,10 @@ type ConvosListItemProps = {
  * One row of a conversation list: avatar, title, time, one-line preview, unread
  * badge. Shared by the messenger and the agents page so both lists look the same.
  *
- * 8px outside + 8px inside, so avatars line up with the 16px list header and
- * tabs while the highlight keeps an 8px inset.
+ * Phones: an edge-to-edge row on the card surface (`bg-card`), 1px apart
+ * (feed cards are 2px), 16px inside. From sm: 8px outside + 8px inside, so avatars
+ * line up with the 16px list header and tabs while the highlight keeps an 8px
+ * inset.
  */
 export default function ConvosListItem({
     profile,
@@ -46,8 +48,8 @@ export default function ConvosListItem({
     const rowBg = selected ? ' bg-accent ' : (pressed ? ' bg-muted/60 ' : ' web:group-hover:bg-muted/60 active:bg-muted/60 ');
 
     return (
-        <Pressable className="group w-full px-2 py-0.5 web:cursor-pointer" onPress={onPress} {...pressProps}>
-            <Row className={rowBg + ' relative w-full overflow-hidden gap-3 rounded-xl px-2 py-2 web:duration-200'}>
+        <Pressable className="group w-full mt-px bg-card sm:mt-0 sm:bg-transparent sm:px-2 sm:py-0.5 web:cursor-pointer" onPress={onPress} {...pressProps}>
+            <Row className={rowBg + ' relative w-full overflow-hidden gap-3 sm:rounded-xl px-4 sm:px-2 py-2 web:duration-200'}>
                 <View className=" rounded-full flex-none bg-secondary mb-auto">
                     <Profile
                         {...profile}

+ 9 - 5
neo/packages/app/components/elements/chat/parts/convos-list.tsx

@@ -1,8 +1,9 @@
 import { memo, useCallback, useMemo, useState, type ReactNode } from 'react';
-import { Platform, type LayoutChangeEvent } from 'react-native';
+import { Platform, useWindowDimensions, type LayoutChangeEvent } from 'react-native';
 import { Text } from 'app/design/typography';
 import { View } from 'app/design/view';
 import UniList from 'app/ui/atoms/unilist';
+import { getBreakpoint } from 'app/lib/util';
 import type { UniListRenderItem } from 'app/ui/atoms/unilist/shared';
 import {
     ConvosListHeader,
@@ -80,10 +81,13 @@ const ConvosList = memo(function ConvosList({
     const fillScreen = !isWeb && !!isSmallScreen;
     const listH = Math.max(0, listHeight || panelHeight || 0);
     const overlayList = !isSmallScreen;
-    // Rows inset themselves 8px on the sides but sit 2px apart. 6px more at the
-    // top and bottom puts the first and last rows 8px from the list's edges,
-    // like Notifications (`paddingForList` py-1.5, on web and native).
-    const listEdge = 6;
+    // Phones: edge-to-edge card rows (each 1px below the one above, the first
+    // below the header too), like Notifications. Wider: rows
+    // inset themselves 8px on the sides but sit 2px apart; 6px more at the top
+    // and bottom puts the first and last rows 8px from the list's edges, like
+    // Notifications (`paddingForList` sm:py-1.5, on web and native).
+    const { width: windowWidth } = useWindowDimensions();
+    const listEdge = getBreakpoint(windowWidth) ? 6 : 0;
     const headerInset = (overlayList ? chromeHeight : 0) + listEdge;
     const bottomInset = footerInset + listEdge;
     const listOverlayProps = useMemo(() => {

+ 2 - 2
neo/packages/app/components/elements/chat/parts/message-input.tsx

@@ -25,7 +25,7 @@ type MessageInputProps = {
  * The message composer's surface, shared by the messenger form and the agent
  * chat: a translucent pill (Liquid Glass on iOS 26) with the avatar bottom-left
  * and the actions bottom-right. Empty, it is one rounded-full line and the
- * input leaves room for the avatar; once it has content it becomes rounded-2xl
+ * input leaves room for the avatar (if any); once it has content it becomes rounded-2xl
  * and the actions drop under the text.
  */
 export function MessageInput({ expanded, avatar, actions, header, footer, children }: MessageInputProps) {
@@ -43,7 +43,7 @@ export function MessageInput({ expanded, avatar, actions, header, footer, childr
                 <View className="flex-auto ">
                     <View className=" items-stretch ">
                         <View
-                            className={`p-2.5 min-h-12 flex-auto items-center justify-center ${expanded ? 'mb-10' : 'ms-10'}`}
+                            className={`p-2.5 min-h-12 flex-auto items-center justify-center ${expanded ? 'mb-10' : avatar ? 'ms-10' : ''}`}
                             style={isWeb
                                 ? ({ transition: 'height 0.1s cubic-bezier(0.25, 0.1, 0.25, 1), padding-bottom 0.1s cubic-bezier(0.25, 0.1, 0.25, 1)' } as unknown as ViewStyle /* web-only `transition` */)
                                 : undefined}

+ 2 - 5
neo/packages/app/components/elements/comment-content.js

@@ -1,7 +1,6 @@
 import { View } from 'app/design/view';
 import { ContentMore } from 'app/ui/molecules/content/content-more';
-import Link from 'app/ui/atoms/link'
-import { Button } from 'app/design/controls';
+import { NeoButtonLink } from 'app/design/controls';
 import { BlockWrapper } from 'app/components/block-wrapper'
 import { useTranslation } from 'react-i18next'
 
@@ -10,9 +9,7 @@ export default function ({data, blockWrapperProps}) {
     return (
         <BlockWrapper {...blockWrapperProps}><View className="w-full px-3 sm:px-4">
             <ContentMore numberOfSymbols={360} showLess={true} content={data.text} numberOfLines={3} openSmall={false} customClassName="u-vanilla-html-small" />
-            <Link href={data.link}>
-                <Button size="sm" title={t('View all comments')} variant="link" endDecorator="ChevronRight"/>
-            </Link>
+            <NeoButtonLink href={data.link} style="borderless" controlSize="small" label={t('View all comments')} image="ChevronRight" imagePlacement="trailing" classNames={{ root: 'mt-1' }} />
         </View></BlockWrapper>
     );
 }

+ 9 - 5
neo/packages/app/components/elements/comments-browse.js

@@ -5,7 +5,7 @@ import { EdgeBlurView, edgeBlurConfig } from 'app/ui/atoms/edge-blur'
 import { Text } from 'app/design/typography'
 import { useState, useReducer, useRef, useEffect, useCallback } from 'react';
 import { components } from 'app/components/registry';;
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import useFetchForm from 'app/lib/hooks/use-fetch-form'
 import { fetcher } from 'app/lib/fetcher';
 import Loading from 'app/ui/atoms/loading'
@@ -391,9 +391,13 @@ export function CommentsBrowse({
                     <DropdownMenu items={[
                         { id: 'newest', name: 'asc', title: t('Oldest first'), selected: browseParams.order_way == 'asc' },
                         { id: 'oldest', name: 'desc', title: t('Newest first'), selected: browseParams.order_way == 'desc' }
-                    ]} onSelect={(oItem) => { handleOrder(oItem.name) }}>
-                        <Button variant="secondary" startDecorator={browseParams.order_way == 'asc' ? "ArrowDownAZ" : "ArrowDownZA"} size="xs" />
-                    </DropdownMenu>
+                    ]} onSelect={(oItem) => { handleOrder(oItem.name) }}
+                        buttonProps={{
+                            controlSize: 'mini',
+                            image: browseParams.order_way == 'asc' ? 'ArrowDownAZ' : 'ArrowDownZA',
+                            accessibilityLabel: t('Sort'),
+                        }}
+                    />
                 </Pressable>
             </View>}
         </Row>) : <></>;
@@ -861,7 +865,7 @@ function CommentsFormInner ({ form: initialForm, requestUrl, module, objectId, i
                             <Text className=' text-sm overflow-hidden text-popover-foreground ' numberOfLines={3}>{formData.parent_id > 0 ? formData.text : ''}</Text>
                         </View>
                         <View className=" -right-1 -top-1">
-                            <Button align="start" rounded startDecorator="X" size="xs" variant="text" onPress={() => handleCancel()} />
+                            <NeoButton style="borderless" controlSize="mini" borderShape="circle" image="X" accessibilityLabel={t('Cancel reply')} onPress={() => handleCancel()} />
                         </View>
                     </Row>
                 </View>)

+ 6 - 6
neo/packages/app/components/elements/course-structure.js

@@ -1,7 +1,7 @@
 import { View, Row } from 'app/design/view'
 import { Text } from 'app/design/typography'
 import DropdownMenu from 'app/ui/atoms/dropdown-menu';
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import Link from 'app/ui/atoms/link'
 import Card from 'app/ui/molecules/page/card'
 import Progress from 'app/ui/atoms/progress'
@@ -58,7 +58,7 @@ function CourseStructure({ data }) {
     };
 
     const handleAddModule = async (event) => {
-        event.preventDefault();
+        event?.preventDefault?.();
         const fetchedData = await fetcher(`/api.php?r=system/perfom_action_api/TemplServiceGrid/&params[]=&o=bx_courses_cnt_structure_manage&a=add&parent_id=0&entry_id=${courseId}`);
         const content = { content: fetchedData.data, designbox_id: 0 };
         setBottomSheetData({ title: content.content[0]?.title || " ", content: <View className='px-1'><BlockByData onFormEmpty={handleUpdate} block={content} /></View> });
@@ -120,17 +120,17 @@ function CourseStructure({ data }) {
 
                                 <Row className='justify-between'>
                                     <View className='my-2 text-xs '><Text className="text-white">{t('Module {{index}}', { index: item.index })}</Text></View>
-                                    {isEditable && <Row className='items-center justify-center gap-x-2'><DropdownMenu items={manageMenu} onSelect={(oItem) => { handleManage(oItem, item.id) }}><Button rounded startDecorator="Cog" size='sm' /></DropdownMenu><DragControl dragHandleProps={dragHandleProps}><Button rounded startDecorator="ArrowLeftRight" size='sm' /></DragControl></Row>}
+                                    {isEditable && <Row className='items-center justify-center gap-x-2'><DropdownMenu items={manageMenu} onSelect={(oItem) => { handleManage(oItem, item.id) }} buttonProps={{ controlSize: 'small', borderShape: 'circle', image: 'Cog', accessibilityLabel: t('Settings') }} /><DragControl dragHandleProps={dragHandleProps} accessibilityLabel={t('Reorder')} className="u-neo-btn-link hit-area-4"><NeoButton controlSize="small" borderShape="circle" image="ArrowLeftRight" interactive /></DragControl></Row>}
                                 </Row>
                                 <View className='h-12'>
                                     <Text className="text-white text-lg leading-tight tracking-tight font-bold text-secondary-foreground " numberOfLines={2}>{item.title}</Text>
                                 </View>
                             </View>
                             <Row className='gap-x-2 items-end mt-4'>
-                                <Button textColor={`text-${colorButtonText}`} bgColor={`bg-${colorButton}`} startDecorator={icon} variant="outline" title={item.status} size="xs" rounded />
+                                <NeoButton controlSize="mini" borderShape="capsule" image={icon} label={item.status} />
                                 {item?.counters?.map((item2, index) => {
                                     return (
-                                        <Button key={`cnt-${index}`} bgColor={`bg-white`} variant="default" title={`${item2.cn_progress} ${item2.cn_title}`} size="xs" rounded />
+                                        <NeoButton key={`cnt-${index}`} controlSize="mini" borderShape="capsule" label={`${item2.cn_progress} ${item2.cn_title}`} />
                                     )
                                 })}
                             </Row>
@@ -155,7 +155,7 @@ function CourseStructure({ data }) {
                     {isEditable && (<View className='m-2 w-72'>
                         <Card rounded=' rounded-none sm:rounded-2xl  ' >
                             <View className='h-40 items-center justify-center'>
-                                <Button rounded startDecorator="Plus" title={t('Add new')} onPress={(event) => { handleAddModule(event, courseId) }} size='sm' />
+                                <NeoButton controlSize="small" borderShape="capsule" image="Plus" label={t('Add new')} classNames={{ root: 'self-center' }} onPress={(event) => { handleAddModule(event, courseId) }} />
                             </View>
                         </Card>
                     </View>)

+ 8 - 7
neo/packages/app/components/elements/credits-checkout.js

@@ -3,7 +3,7 @@
 import { useRef, useState } from 'react'
 import { View, Row } from 'app/design/view'
 import { Text } from 'app/design/typography'
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import { BlockWrapper } from 'app/components/block-wrapper'
 import { BlockByDataInt as BlockByData } from 'app/components/block'
 import { fetcher } from 'app/lib/fetcher'
@@ -135,9 +135,9 @@ export default function CreditsCheckout({ blockWrapperProps, data, onFormEmpty }
                         block={{ content: resultContent, designbox_id: 0 }}
                     />
                     <View className="items-center">
-                        <Button
-                            variant="default"
-                            title={t('OK')}
+                        <NeoButton
+                            label={t('OK')}
+                            classNames={{ root: 'self-center' }}
                             onPress={() => onFormEmpty?.()}
                         />
                     </View>
@@ -197,10 +197,11 @@ export default function CreditsCheckout({ blockWrapperProps, data, onFormEmpty }
                 )}
 
                 <View className="items-center pt-1">
-                    <Button
-                        variant="default"
-                        title={t('Checkout')}
+                    <NeoButton
+                        label={t('Checkout')}
                         disabled={loading || !requestUrl}
+                        expoUI={false}
+                        classNames={{ root: 'self-center' }}
                         onPress={handleCheckout}
                     />
                 </View>

+ 30 - 71
neo/packages/app/components/elements/dashboard-stat-old.js

@@ -2,9 +2,9 @@ import { Icon } from 'app/ui/atoms/icon'
 import Card from 'app/ui/molecules/page/card'
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { View, Row, Pressable } from 'app/design/view'
+import { View, Row } from 'app/design/view'
 import { Text } from 'app/design/typography'
-import { Button } from 'app/design/controls'
+import { NeoButton, NeoButtonLink } from 'app/design/controls'
 import Link from 'app/ui/atoms/link'
 import { useCurrentUser } from 'app/context/user'
 import Profile from 'app/ui/molecules/profile/profile'
@@ -106,29 +106,22 @@ export default function PageLayout(props) {
                                     </Link>
                                 </View> 
                                 <View className="flex-none">
-                                <ProfileSwitcher hideTitle={true} >
-                                    <Button variant="outline" startDecorator="RefreshCw"  rounded  />
+                                <ProfileSwitcher hideTitle={true} className="u-neo-btn-link" accessibilityLabel={t('Switch profile')}>
+                                    <NeoButton borderShape="circle" image="RefreshCw" interactive />
                                 </ProfileSwitcher>
                                 </View>
                             </View>
                             <View className="flex-row  items-center gap-x-2 my-auto hidden">
-                               <ProfileSwitcher hideTitle={true} >
-                                    <Button variant="outline" startDecorator="RefreshCw"  rounded  />
+                               <ProfileSwitcher hideTitle={true} className="u-neo-btn-link" accessibilityLabel={t('Switch profile')}>
+                                    <NeoButton borderShape="circle" image="RefreshCw" interactive />
                                 </ProfileSwitcher>
                                 {
                                     appSetting('layout', 'avaliable_langs').length > 1 && (
                                         <View><DropdownMenu 
                                             items={langItems}
-                                            onSelect={(oItem) => {handleLang(oItem.id)}}>
-                                               
-                                                    <Button
-                                                        variant="outline"                                               
-                                                        startDecorator="Languages"
-                                                        rounded
-                                                        align="left"
-                                                    />
-                                                
-                                        </DropdownMenu></View>)
+                                            onSelect={(oItem) => {handleLang(oItem.id)}}
+                                            buttonProps={{ borderShape: 'circle', image: 'Languages', accessibilityLabel: t('Language') }}
+                                        /></View>)
                                 }
                                 
                                 {
@@ -139,20 +132,13 @@ export default function PageLayout(props) {
                                                 name: theme,
                                                 title: t('theme_' + theme)
                                             }))} 
-                                            onSelect={(oItem) => {handleTheme(oItem.id)}}>
-                                               
-                                                    <Button
-                                                        variant="outline"
-                                                        startDecorator="Moon"
-                                                        rounded
-                                                        align="left"
-                                                    />
-                                              
-                                        </DropdownMenu></View>)
+                                            onSelect={(oItem) => {handleTheme(oItem.id)}}
+                                            buttonProps={{ borderShape: 'circle', image: 'Moon', accessibilityLabel: t('Theme') }}
+                                        /></View>)
                                 }
                                
                                 
-                                <Link href="/logout"><Button variant="outline" startDecorator="LogOut" rounded /></Link>
+                                <NeoButtonLink href="/logout" borderShape="circle" image="LogOut" accessibilityLabel={t('Sign out')} />
                             </View>
                             
                         </View>
@@ -172,17 +158,9 @@ export default function PageLayout(props) {
                                         <View className="mb-2">
                                         <DropdownMenu 
                                             items={langItems}
-                                            onSelect={(oItem) => {handleLang(oItem.id)}}>
-                                            
-                                                <Button
-                                                    variant="secondary"
-                                                    title= {t('lang_' + i18n.language)}
-                                                    startDecorator="Languages"
-                                                    fullWidth
-                                                    
-                                                    align="left"
-                                            />
-                                        </DropdownMenu>
+                                            onSelect={(oItem) => {handleLang(oItem.id)}}
+                                            buttonProps={{ label: t('lang_' + i18n.language), image: 'Languages', width: 'fill', align: 'start' }}
+                                        />
                                         </View>
                                         )
                                 }
@@ -196,18 +174,9 @@ export default function PageLayout(props) {
                                                 name: theme,
                                                 title: t('theme_' + theme)
                                             }))} 
-                                            onSelect={(oItem) => {handleTheme(oItem.id)}}>
-                                               
-                                                    <Button
-                                                        variant="secondary"
-                                                        title= {t('theme_' + currentTheme)}
-                                                        startDecorator="Moon"
-                                                        fullWidth
-                                                        
-                                                        align="left"
-                                                    />
-                                              
-                                        </DropdownMenu>
+                                            onSelect={(oItem) => {handleTheme(oItem.id)}}
+                                            buttonProps={{ label: t('theme_' + currentTheme), image: 'Moon', width: 'fill', align: 'start' }}
+                                        />
                                         </View>)
                                 }
                                 
@@ -222,27 +191,17 @@ export default function PageLayout(props) {
                                                 name: lang,
                                                 title: t('format_' + lang)
                                             }))} 
-                                            onSelect={(oItem) => {handleFormat(oItem.id)}}>
-                                                <Pressable>
-                                                    <Button
-                                                        variant="secondary"
-                                                        title= {t('format_' + currentFormat)}
-                                                        startDecorator="Layout"
-                                                        fullWidth
-                                                       
-                                                        align="left"
-                                                    />
-                                                </Pressable>
-                                        </DropdownMenu></View>)
+                                            onSelect={(oItem) => {handleFormat(oItem.id)}}
+                                            buttonProps={{ label: t('format_' + currentFormat), image: 'Layout', width: 'fill', align: 'start' }}
+                                        /></View>)
                                 }
-                                <Link href="/logout"><Button
-                                    variant="secondary"
-                                    title= {t("Sign out")}
-                                    startDecorator="LogOut"
-                                    fullWidth
-                                  
-                                    align="left"
-                                /></Link>
+                                <NeoButtonLink
+                                    href="/logout"
+                                    label={t("Sign out")}
+                                    image="LogOut"
+                                    width="fill"
+                                    align="start"
+                                />
                     </Card>
                 </View>
                 
@@ -281,7 +240,7 @@ function ElementDashboardStat(props) {
                                             {
                                                 item.count > 0 ? <Text className=" text-3xl -translate-y-1 font-semibold flex-auto text-secondary-foreground web:group-hover:text-foreground    ">
                                                     {item.count}
-                                                </Text> : <View><Link href={item2.link2} emulate={true}><Button variant="outline" startDecorator="Plus" size="sm" rounded /></Link></View>
+                                                </Text> : <View><NeoButtonLink href={item2.link2} emulate={true} controlSize="small" borderShape="circle" image="Plus" accessibilityLabel={t('Add')} /></View>
                                             }
                                             <View className="flex-none  text-secondary-foreground web:group-hover:text-foreground   font-semibold ">
                                                 <Icon icon={item2.icon} width={24} height={24} color={colors.default}/>

+ 2 - 2
neo/packages/app/components/elements/dashboard-stat.js

@@ -196,12 +196,12 @@ function DashboardStatHeader() {
                 </BlockDescription>
             </BlockName>
             <BlockActions>
-                <ProfileSwitcher hideTitle={true}>
+                <ProfileSwitcher hideTitle={true} className="u-neo-btn-link" accessibilityLabel={t('Switch profile')}>
                     <NeoButton
                         style="bordered"
                         borderShape="circle"
                         image="RefreshCw"
-                        accessibilityLabel={t('Switch profile')}
+                        interactive
                     />
                 </ProfileSwitcher>
             </BlockActions>

+ 11 - 62
neo/packages/app/components/elements/edit-course-content.js

@@ -1,65 +1,14 @@
-import { View, Row } from 'app/design/view'
-import { Text } from 'app/design/typography'
-import { Button } from 'app/design/controls'
-import Link from 'app/ui/atoms/link'
-import Card from 'app/ui/molecules/page/card'
-import Progress from 'app/ui/atoms/progress'
-import Scroll from 'app/ui/molecules/page/scroll'
 import { memo } from 'react'
-import { useTranslation } from 'react-i18next'
-
-
-
-function CourseStructure(props) {
-    const { t } = useTranslation();
-
-    return <Scroll horizontal={true} step={250} className='w-full'>
-        {
-            props.data.map((item) => {
-                let icon = "Check";
-                let color = "gray-600";
-                let colorButton = "emerald-400";
-                let colorButtonText = "emerald-400";
-                if (item.status == "in process") {
-                    icon = "Hourglass";
-                    color = "red-400";
-                    colorButton = "white";
-                    colorButtonText = "red-400";
-                }
-                if (item.status == "not started") {
-                    icon = "Bookmark";
-                    color = "gray-400";
-                    colorButton = "gray-500";
-                    colorButtonText = "white";
-                }
-
-                return (
-                    <View className='m-2 w-72' key={item.index}>
-                        <Card rounded=' rounded-none sm:rounded-2xl  ' margin={'bg-' + color + ' max-w-screen-lg mx-auto w-full p-3 sm:p-4 mb-1 sm:mb-4 '}>
-                            <Link href={item.link}>
-                                <View className={`mb-2 bg-${color}`}>
-                                    <Progress value={item.percent} />
-                                    <View className='my-2 text-xs '><Text className="text-white">{t('Module {{index}}', { index: item.index })}</Text></View>
-                                    <View className='h-12'>
-                                        <Text className="text-white text-lg leading-tight tracking-tight font-bold text-secondary-foreground " numberOfLines={2}>{item.title}</Text>
-                                    </View>
-                                </View>
-                                <Row className='gap-x-2 items-end mt-4'>
-                                    <Button textColor={`text-${colorButtonText}`} bgColor={`bg-${colorButton}`} startDecorator={icon} variant="outline" title={item.status} size="xs" rounded />
-                                    {item.counters.map((item2, index) => {
-                                        return (
-                                            <Button key={`cnt-${index}`} bgColor={`bg-white`} variant="default" title={`${item2.cn_progress} ${item2.cn_title}`} size="xs" rounded />
-                                        )
-                                    })}
-                                </Row>
-                            </Link>
-                        </Card>
-                    </View>
-                )
-            })
-        }
-        <Button  title={t('Add module')} onPress={addModule}/>
-    </Scroll>
+import CourseStructure from './course-structure'
+
+/**
+ * Course modules in edit mode. The add / edit / delete / reorder flow (the
+ * bx_courses_cnt_structure_manage grid forms in the bottom sheet) lives in
+ * course-structure.js behind UNA's `isEditable` flag, so this renders it with the
+ * same payload ({ items, entry_id, isEditable }), editable unless UNA says no.
+ */
+function EditCourseContent({ data }) {
+    return <CourseStructure data={{ ...data, isEditable: data?.isEditable ?? true }} />
 }
 
-export default memo(CourseStructure);
+export default memo(EditCourseContent);

+ 8 - 7
neo/packages/app/components/elements/entity-poll.js

@@ -6,9 +6,10 @@ import Video from 'app/ui/atoms/video';
 import Youtube from 'app/ui/molecules/content/youtube'
 import { fetcher } from 'app/lib/fetcher';
 import { useReducer } from 'react'
+import { useTranslation } from 'react-i18next'
 import RadioButton from 'app/ui/atoms/radiobutton';
 import { getColor } from 'app/components/elements/chart';
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import { BlockWrapper } from 'app/components/block-wrapper'
 
 function Results({ data }) {
@@ -50,6 +51,7 @@ function Results({ data }) {
 }
 
 export function PollItem({ data, showTitle, onDelete, disabled = false, results_url = '/api.php?r=bx_polls/get_block_results' }) {
+    const { t } = useTranslation();
     const initialState = {
         isShowResults: data.is_performed,
         isVoted: data.is_performed,
@@ -94,16 +96,15 @@ export function PollItem({ data, showTitle, onDelete, disabled = false, results_
             {showTitle && <Text className="text-foreground p-1 rounded-xl  text-lg tracking-tight font-semibold">{data.title}</Text>}
             {(!data.is_hidden_results && totalVotes > 0) && (
                 
-                    <Button
-                        title={state.isShowResults ? "Show poll" : "Show results"}
-                        variant="secondary"
-                        size="sm"
-                        ring
+                    <NeoButton
+                        label={state.isShowResults ? "Show poll" : "Show results"}
+                        controlSize="small"
+                        classNames={{ root: 'self-center' }}
                         onPress={() => dispatch({ type: 'TOGGLE_RESULTS' })}
                     />
                 
             )}
-            {onDelete && (<Button onPress={() => { onDelete(data.id) }} startDecorator="X" rounded ring variant="secondary" size="sm" />)}
+            {onDelete && (<NeoButton onPress={() => { onDelete(data.id) }} image="X" borderShape="circle" controlSize="small" accessibilityLabel={t('Delete')} expoUI={false} classNames={{ root: 'self-center' }} />)}
         </Row>
             {state.isShowResults && <Results data={state.results} />}
 

+ 20 - 4
neo/packages/app/components/elements/entity-text.js

@@ -1,9 +1,10 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { Platform } from 'react-native';
 import { useForm, FormProvider } from 'react-hook-form';
 import { View, Pressable } from 'app/design/view';
 import Image, { POST_ENTRY_COVER_SIZES, POST_ENTRY_COVER_WIDTH_CAP } from 'app/ui/atoms/image';
 import Html from 'app/ui/atoms/html';
+import { splitInlineMedia } from 'app/lib/editor/inline-video';
 import { Text, H1C } from 'app/design/typography';
 import { Input } from 'app/design/controls';
 import { appSetting, clearLinks, getYouTubeVideoId, cn } from 'app/lib/util'
@@ -13,6 +14,8 @@ import EntityAttachments from './entity-attachments';
 import TextMore from 'app/ui/molecules/content/text-more';
 import Video from 'app/ui/atoms/video';
 import Youtube from 'app/ui/molecules/content/youtube'
+import InlineVideo from 'app/ui/molecules/content/inline-video'
+import InlineEmbed from 'app/ui/molecules/content/inline-embed'
 import { BlockWrapper } from 'app/components/block-wrapper'
 import RftText from 'app/components/form-fields/editor-rft-text'
 
@@ -237,6 +240,20 @@ const getImagesData = (data) => {
     return att;
 };
 
+/** Post body with editor videos and embeds (standalone link paragraphs) rendered inline. */
+function HtmlWithMedia({ html }) {
+    // Split the raw HTML: clearLinks would turn UNA hrefs into relative ones.
+    const segments = useMemo(() => splitInlineMedia(html), [html])
+    if (!segments.some((s) => s.type !== 'html'))
+        return <Html data={clearLinks(html)} />
+
+    return segments.map((s, i) => {
+        if (s.type === 'video') return <InlineVideo key={i} src={s.src} poster={s.poster} />
+        if (s.type === 'embed') return <InlineEmbed key={i} url={s.url} />
+        return <Html key={i} data={clearLinks(s.html)} />
+    })
+}
+
 function Default({ data, showPad, sidebar, block, blockWrapperProps, editable, afterTitle }) {
     const att = getImagesData(data);
     const isSmall = block?.module == "bx_market";
@@ -289,10 +306,9 @@ function Default({ data, showPad, sidebar, block, blockWrapperProps, editable, a
                         initialValue={data.entry_text}
                     >
                         {(body) => {
-                            const display = clearLinks(body)
                             return isSmall
-                                ? <ContentMore showLess={true} content={display} numberOfLines={3} numberOfSymbols={360} openSmall={false} customClassName="u-vanilla-html" />
-                                : <Html data={display} />
+                                ? <ContentMore showLess={true} content={clearLinks(body)} numberOfLines={3} numberOfSymbols={360} openSmall={false} customClassName="u-vanilla-html" />
+                                : <HtmlWithMedia html={body} />
                         }}
                     </EditableTextField>
                 </View>

+ 30 - 23
neo/packages/app/components/elements/feed-item-parts.js

@@ -23,7 +23,7 @@ import {
 import { Text } from 'app/design/typography'
 import { View, Row } from 'app/design/view'
 import { Platform, StyleSheet } from 'react-native'
-import { Button, Modal } from 'app/design/controls'
+import { Modal, NeoButton, NeoButtonLink } from 'app/design/controls'
 import Menu from 'app/components/menu'
 import DropdownMenu from 'app/ui/atoms/dropdown-menu'
 import { fetcher } from 'app/lib/fetcher'
@@ -96,24 +96,27 @@ export const CommentsSection = memo(
         url,
         t,
     }) => {
-        const ShowMoreCmts = (
-            <Button variant="text" size="xs" title={t('View more comments...')} />
-        )
         return (
             <View className="border-t border-border/40 p-1 lg:p-2 ">
                 
                     {isShowMoreComments && (
                         <View className="p-0.5 me-auto">
                             {isCommentsModal ? (
-                                <Pressable
+                                <NeoButton
+                                    style="borderless"
+                                    controlSize="mini"
+                                    label={t('View more comments...')}
                                     onPress={() => {
                                         showCommentsModal()
                                     }}
-                                >
-                                    {ShowMoreCmts}
-                                </Pressable>
+                                />
                             ) : (
-                                <Link href={url}>{ShowMoreCmts}</Link>
+                                <NeoButtonLink
+                                    href={url}
+                                    style="borderless"
+                                    controlSize="mini"
+                                    label={t('View more comments...')}
+                                />
                             )}
                         </View>
                     )}
@@ -327,6 +330,7 @@ export const ItemInfo = memo(({ data, t }) => {
 })
 
 export const MenuManage = ({ id, menu, setViewState, data }) => {
+    const { t } = useTranslation()
     const [menuData, setMenuData] = useState(false)
     const fetchedRef = useRef(false)
     const dataRef = useRef(data)
@@ -346,11 +350,14 @@ export const MenuManage = ({ id, menu, setViewState, data }) => {
     if (!menuData)
         return (
             <SafeMenuTrigger>
-                <Button
-                    variant="text"
-                    size="sm"
-                    startDecorator="Ellipsis"
-                    rounded="true"
+                <NeoButton
+                    style="borderless"
+                    controlSize="small"
+                    borderShape="circle"
+                    image="Ellipsis"
+                    accessibilityLabel={t('More options')}
+                    expoUI={false}
+                    classNames={{ root: 'self-center' }}
                     onPress={() => {
                         fetchedRef.current = true
                         if (Platform.OS === 'web')
@@ -444,14 +451,14 @@ const MenuManage_ = memo(({ id, menu, setViewState, defaultOpen }) => {
                         items={aMenuManageItems}
                         defaultOpen={defaultOpen}
                         onSelect={handleMenuManageSelect}
-                    >
-                        <Button
-                            variant="text"
-                            size="sm"
-                            startDecorator="Ellipsis"
-                            rounded="true"
-                        />
-                    </DropdownMenu>
+                        buttonProps={{
+                            style: 'borderless',
+                            controlSize: 'small',
+                            borderShape: 'circle',
+                            image: 'Ellipsis',
+                            accessibilityLabel: t('More options'),
+                        }}
+                    />
                 </View>
                 {!!oReport && oReport}
             </>
@@ -473,7 +480,7 @@ export const ActionMenu = memo(({ data }) => {
     const data_other = {...data, items: data?.items.filter(item => item.class != " bx-mi-primary" && item.primary != 1 )};
     const data_primary = {...data, items: data?.items.filter(item => item.class == " bx-mi-primary" || item.primary == 1 )};
 
-    return settings && <Row className="flex-auto justify-between">
+    return settings && <Row className="flex-auto justify-between gap-1">
         <Menu {...data_primary} displayType="button" params={settings} />
         <Menu {...data_other} displayType="button" params={settings} />
     </Row>

+ 14 - 12
neo/packages/app/components/elements/feed-item.js

@@ -60,18 +60,20 @@ export default function ElementFeedItem({ data, isModal, blockWrapperProps }) {
 
     return (
         <BlockWrapper {...blockWrapperProps}>
-            
-                <Html key={tlContent || 'empty'} data={tlContent} customClassName="u-vanilla-html-small" />
-                {!!content?.embed && <Embed data={content.embed} />}
-                {content?.polls_attach?.length > 0 && (
-                    <View className='w-full'>
-                        {content.polls_attach.map((item, index) => {
-                            return <View key={"att" + index} className='mt-4'><PollItem data={item} showTitle={true} results_url='/api.php?r=bx_timeline/get_block_poll_results' /></View>
-                        })}
-                    </View>
-                    )
-                }
-                {content_attach.length > 0 && <UnitImages images={content_attach} />}
+                {/* Same stack and gap as the feed card (DefaultView in units/feed-views.js). */}
+                <View className="w-full gap-y-3">
+                    <Html key={tlContent || 'empty'} data={tlContent} customClassName="u-vanilla-html-small" />
+                    {!!content?.embed && <Embed data={content.embed} />}
+                    {content?.polls_attach?.length > 0 && (
+                        <View className='w-full'>
+                            {content.polls_attach.map((item, index) => {
+                                return <View key={"att" + index} className='mt-4'><PollItem data={item} showTitle={true} results_url='/api.php?r=bx_timeline/get_block_poll_results' /></View>
+                            })}
+                        </View>
+                        )
+                    }
+                    {content_attach.length > 0 && <UnitImages images={content_attach} />}
+                </View>
                 {
                     data.event.menu_actions.items.length > 0 && (<View className=" flex-row items-center ">
                         <View className="flex-auto  gap-2 mt-2 ">

+ 2 - 2
neo/packages/app/components/elements/invite-in-context.js

@@ -1,5 +1,5 @@
 import { View } from 'app/design/view';
-import { ButtonLink, Input } from 'app/design/controls';
+import { NeoButtonLink, Input } from 'app/design/controls';
 import { useFetch } from 'app/lib/hooks/use-fetch';
 import { useState } from 'react'
 import Profile from 'app/ui/molecules/profile/profile'
@@ -29,7 +29,7 @@ export default function InviteInContext({ blockWrapperProps }) {
                     <View>
                         <CardTitle>You're joining:</CardTitle>
                         <View className='w-full mt-4 justify-between sm:flex-row gap-y-3'><Profile {...res.data} displayType="unit" size="lg" />
-                            <ButtonLink href={res.data.url} variant="primary" size="base" title={t('Continue')} />
+                            <NeoButtonLink href={res.data.url} style="borderedProminent" label={t('Continue')} />
                         </View>
                     </View>
                 </Card>

+ 3 - 3
neo/packages/app/components/elements/invite.js

@@ -1,6 +1,6 @@
 import { Text } from 'app/design/typography'
 import { View, Row } from 'app/design/view'
-import { Button, Input, Modal } from 'app/design/controls';
+import { NeoButton, Input, Modal } from 'app/design/controls';
 import { fetcher } from 'app/lib/fetcher';
 import { useState } from 'react';
 import { setClipboard } from 'app/lib/util'
@@ -25,13 +25,13 @@ export default function ElementInvite({ data, blockWrapperProps }) {
             <Modal id={'file-preview'} title={t('Invitation link')} onVisible={!!showModal} onClose={() => { setShowModal(null) }}>
                 <Row className='gap-x-4'>
                     <Input value={APP_URL + showModal} />
-                    <Button variant="text" size="base" rounded startDecorator="Clipboard" onPress={() => handleCopy()} />
+                    <NeoButton style="borderless" borderShape="circle" image="Clipboard" accessibilityLabel={t('Copy link')} onPress={() => handleCopy()} />
                 </Row>
             </Modal>
             <View className="p-4">
                 <Text className="text-card-foreground text-center">{t('You can invite your friends to join. You have {{count}} invites to share.', { count: data.remain })}</Text>
                 <View className='mx-auto pt-4'>
-                    <Button variant="default" size="base" rounded title={"Get invite link"} onPress={() => handleClick()} />
+                    <NeoButton borderShape="capsule" label={"Get invite link"} onPress={() => handleClick()} />
                 </View>
             </View>
         </BlockWrapper>

+ 1 - 1
neo/packages/app/components/elements/messenger/parts/headers.js

@@ -100,7 +100,7 @@ export function MobileListHeader({ pageData, onSearch, addButtons, menuItems, me
             {menuItems?.length > 1 ? (
                 // Same box as the conductor tab row (web `conductor.menu_cnt`,
                 // native-ui TabBar), so pills sit at the same height under the title.
-                <Row className="w-full items-center px-4 min-h-12 lg:min-h-14">
+                <Row className="w-full items-center px-4 min-h-13 lg:min-h-14 mb-0.5 lg:mb-0">
                     <MenuSwitcher items={menuItems} index={menuIndex} onChange={onMenuChange} />
                 </Row>
             ) : null}

+ 5 - 6
neo/packages/app/components/elements/messenger/parts/item-jot.js

@@ -2,7 +2,7 @@ import { View, Row } from 'app/design/view';
 import { fetcher } from 'app/lib/fetcher';
 import React, { useState, useMemo, useCallback } from 'react';
 import MessageItem from 'app/components/elements/chat/parts/message-item'
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import Form from 'app/components/elements/form';
 import useFetchForm from 'app/lib/hooks/use-fetch-form'
 import DropdownMenu from 'app/ui/atoms/dropdown-menu';
@@ -69,9 +69,9 @@ export default function JotItem({ item, index, handleReply }) {
             footer={
             <View className="flex-row justify-between items-center mt-0.5">
                 <View className="pl-12">
-                    <Button align="start" title={t("Reply")} size="xs" startDecorator="MessageCircle" variant="link" onPress={() => handleReplyInner(item)} rounded />
+                    <NeoButton style="borderless" controlSize="mini" borderShape="capsule" align="start" image="MessageCircle" label={t("Reply")} haptics={false} onPress={() => handleReplyInner(item)} />
                 </View>
-                <Row className='gap-1'>
+                <Row className='gap-2'>
                     <View className=''>
                         <Reactions key={'reactions_' + item.id} {...reactionsWithUpdatedParams} />
                     </View>
@@ -83,9 +83,8 @@ export default function JotItem({ item, index, handleReply }) {
                                 link: aItem.link,
                                 title: aItem.title
                             };
-                        })} onSelect={handleManageMenuSelect}>
-                            <Button variant="text" size="xs" startDecorator="Ellipsis" rounded />
-                        </DropdownMenu>
+                        })} onSelect={handleManageMenuSelect}
+                            buttonProps={{ style: 'borderless', controlSize: 'mini', borderShape: 'circle', image: 'Ellipsis', accessibilityLabel: t('More options') }} />
                     }
                 </Row>
             </View>

+ 31 - 38
neo/packages/app/components/elements/module-structure.js

@@ -1,10 +1,9 @@
 import { View, Row, Pressable, ScrollView  } from 'app/design/view'
 import { useState, useReducer, useCallback } from 'react'
 import { Text } from 'app/design/typography'
-import Link from 'app/ui/atoms/link'
 import Card from 'app/ui/molecules/page/card'
 import CircularProgress from 'app/ui/atoms/circular-progress'
-import { Button, Modal } from 'app/design/controls'
+import { Modal, NeoButton, NeoButtonLink } from 'app/design/controls'
 import { Icon } from 'app/ui/atoms/icon'
 import { fetcher } from 'app/lib/fetcher'
 import { ContentMore } from 'app/ui/molecules/content/content-more';
@@ -129,7 +128,7 @@ export default function ModuleStructure({ data }) {
             const formData = await fetcher(`/api.php?r=system/perfom_action_api/TemplServiceGrid/&params[]=&o=bx_courses_cnt_structure_manage&a=edit&parent_id=${moduleData.parent_id}&entry_id=${courseId}&id=${id}`);
             const lessonData = await fetcher(`/api.php?r=bx_courses/entity_node_block/&params[]=${courseId}&params[]=${id}`);
             const gridData = await fetcher(`/api.php?r=system/perfom_action_api/TemplServiceGrid/&params[]=&o=bx_courses_cnt_data_manage&a=display&parent_id=${id}&entry_id=${courseId}`);
-            setBottomSheetData({ title: formData.data[0]?.title || " ", content: <View className='px-1'><EditLesson dispatch={dispatch} gridData={gridData} handleUpdate={handleUpdate} formData={formData.data} courseId={courseId} moduleId={moduleId} lessonId={id} lessonData={lessonData.data[0].data} /></View> });
+            setBottomSheetData({ title: formData.data[0]?.title || " ", content: <View className='px-1.5 pt-1.5'><EditLesson dispatch={dispatch} gridData={gridData} handleUpdate={handleUpdate} formData={formData.data} courseId={courseId} moduleId={moduleId} lessonId={id} lessonData={lessonData.data[0].data} /></View> });
 
         }
         if (item.action == "delete") {
@@ -139,7 +138,7 @@ export default function ModuleStructure({ data }) {
     };
 
     const handleAddLesson = useCallback(async (event) => {
-        event.preventDefault();
+        event?.preventDefault?.();
         const fetchedData = await fetcher(`/api.php?r=system/perfom_action_api/TemplServiceGrid/&params[]=&o=bx_courses_cnt_structure_manage&a=add&parent_id=${moduleId}&entry_id=${courseId}`);
         const content = { content: fetchedData.data, designbox_id: 0 };
         setBottomSheetData({ title: content.content[0]?.title || " ", content: <View className='px-1'><BlockByData onFormEmpty={handleUpdate} block={content} /></View> });
@@ -157,7 +156,7 @@ export default function ModuleStructure({ data }) {
     const lessonItemData = lessonIndex !== -1 ? lessonData.steps[lessonIndex] : null;
 
     let title = lessonId > 0 ? <View className='flex-1 ml-4 sm:ml-0'>
-        <Button startDecorator="ArrowLeft" variant="default" title={'Back'} size="sm" rounded onPress={() => { dispatch({ type: 'SET_LESSON_ID', lessonId: null }) }} />
+        <NeoButton controlSize="small" borderShape="capsule" image="ArrowLeft" label={'Back'} onPress={() => { dispatch({ type: 'SET_LESSON_ID', lessonId: null }) }} />
     </View> : <View className='flex-1 ml-4 sm:ml-0'>
         <Text className="text-xl leading-tight tracking-tight font-bold text-secondary-foreground ">{lessonData?.title}</Text>
         <Text className="text-xs text-secondary-foreground ">{lessonData?.sample} {lessonData?.index}</Text>
@@ -205,20 +204,20 @@ export default function ModuleStructure({ data }) {
                         {item.pass_status == 'completed' && <View className='h-16 w-16 rounded-full bg-emerald-400 items-center justify-center'><Text className=" text-white text-4xl"><Icon icon={icon} /></Text></View>}
                         {item.pass_status == 'in process' && <CircularProgress classes='h-16 w-16' progressColor="#F87171" percentage={item.pass_percent} />}
                         {item.pass_status == 'not started' && <View className='h-16 w-16 rounded-full bg-gray-500 items-center justify-center'><Text className=" text-white text-4xl"><Icon icon={icon} /></Text></View>}
-                        <Button variant="default" title={item.pass_progress} size="xs" rounded />
+                        <NeoButton controlSize="mini" borderShape="capsule" label={item.pass_progress} classNames={{ root: 'self-center' }} />
                     </View>}
                     <View className='flex-1'>
                         <View className='my-2 text-xs'><Text>{t('Lesson {{index}}', { index: index + 1 })}</Text></View>
                         <View className='h-12'>
                             <Text className="text-lg leading-tight tracking-tight font-bold text-secondary-foreground " numberOfLines={2}>{item.title}</Text>
                         </View>
-                        {(!isEditable && !!item.pass_title) && <Button startDecorator={icon} variant="default" textColor={`text-${textColor}`} bgColor={`bg-${color}`} title={item.pass_status} size="xs" rounded />}
-                        {isEditable && <Button variant="default" title={item.pass_progress} size="xs" rounded />}
+                        {(!isEditable && !!item.pass_title) && <NeoButton controlSize="mini" borderShape="capsule" image={icon} label={item.pass_status} />}
+                        {isEditable && <NeoButton controlSize="mini" borderShape="capsule" label={item.pass_progress} />}
                     </View>
                     <Row className='items-center justify-center gap-x-2'>
-                        {(!isEditable && !!item.pass_title) && <Button endDecorator="ArrowRight" variant="default" title={item.pass_title} size="sm" rounded onPress={() => { getLessonData(item.id, item.parent_id, true) }} />}
+                        {(!isEditable && !!item.pass_title) && <NeoButton controlSize="small" borderShape="capsule" image="ArrowRight" imagePlacement="trailing" label={item.pass_title} classNames={{ root: 'self-center' }} onPress={() => { getLessonData(item.id, item.parent_id, true) }} />}
                         {isEditable && <>
-                            <DropdownMenu items={manageMenu} onSelect={(oItem) => { handleManage(oItem, item.id) }}><Button rounded startDecorator="Cog" size='sm' /></DropdownMenu><DragControl dragHandleProps={dragHandleProps}><Button rounded startDecorator="ArrowUpDown" size='sm' /></DragControl>
+                            <DropdownMenu items={manageMenu} onSelect={(oItem) => { handleManage(oItem, item.id) }} buttonProps={{ controlSize: 'small', borderShape: 'circle', image: 'Cog', accessibilityLabel: t('Settings'), classNames: { root: 'self-center' } }} /><DragControl dragHandleProps={dragHandleProps} accessibilityLabel={t('Reorder')} className="u-neo-btn-link hit-area-4"><NeoButton controlSize="small" borderShape="circle" image="ArrowUpDown" interactive /></DragControl>
                             </>}
                     </Row>
                 </Row>
@@ -257,7 +256,7 @@ export default function ModuleStructure({ data }) {
                     {isEditable && (<Card rounded=' rounded-none sm:rounded-2xl  ' margin='mx-2  w-full p-3 sm:p-4 mb-1 sm:mb-4 '>
 
                         <Row className='w-full'>
-                            <Button rounded startDecorator="Plus" title={t('Add new')} onPress={(event) => { handleAddLesson(event, courseId) }} size='sm' />
+                            <NeoButton controlSize="small" borderShape="capsule" image="Plus" label={t('Add new')} onPress={(event) => { handleAddLesson(event, courseId) }} />
                         </Row>
                     </Card>
                     )
@@ -300,17 +299,16 @@ function EditLesson({ formData, handleUpdate, lessonData: initedLessonData, cour
 
     return (
         <View>
-            <Row className='gap-x-2'>
+            <Row className='gap-x-2 mb-2'>
 
                 {buttonConfigs.map(({ title, viewType: type }) => (
-                    <Button
+                    <NeoButton
                         key={type}
-                        variant="default"
-                        title={title}
-                        size="sm"
-                        rounded
+                        controlSize="small"
+                        borderShape="capsule"
+                        label={title}
                         onPress={() => setViewType(type)}
-                        pressed={viewType === type}
+                        selected={viewType === type}
                     />
                 ))}
             </Row>
@@ -327,13 +325,13 @@ function LessonStructure({ lessonData, startLessonPart }) {
 
     return (
         <View className='w-full '>
-            <ScrollView className='w-full'>
+            <ScrollView className='w-full px-1.5'>
                 <View className='mb-4'>
                     <ContentMore numberOfSymbols={360} showLess={true} content={lessonData?.text} numberOfLines={3} openSmall={false} customClassName="u-vanilla-html" />
                 </View>
                 <Row className='gap-x-4 mb-4'>
-                    <Button variant="default" textColor={`text-white`} bgColor={`${viewType === 0 ? 'bg-red-400' : 'bg-gray-400'}`} title={t('Lesson')} size="sm" onPress={() => { setViewType(0) }} />
-                    <Button variant="default" textColor={`text-white`} bgColor={`${viewType === 1 ? 'bg-red-400' : 'bg-gray-400'}`} title={t('Attachments')} size="sm" onPress={() => { setViewType(1) }} />
+                    <NeoButton controlSize="small" label={t('Lesson')} selected={viewType === 0} onPress={() => { setViewType(0) }} />
+                    <NeoButton controlSize="small" label={t('Attachments')} selected={viewType === 1} onPress={() => { setViewType(1) }} />
                 </Row>
                 {viewType === 0 && <LessonSteps lessonData={lessonData} startLessonPart={startLessonPart} />}
                 {viewType === 1 && <LessonAttach lessonData={lessonData} />}
@@ -390,13 +388,11 @@ function LessonAttach({ lessonData, isEditable, reloadData, courseId, lessonId,
                 </Text>
             </View>
             <Row className="justify-center items-start gap-x-2">
-                <Link href={item.download_link}>
-                    <Button startDecorator="Download" variant="outline" title={t('Download')} size="sm" />
-                </Link>
+                <NeoButtonLink href={item.download_link} controlSize="small" image="Download" label={t('Download')} />
                 {isEditable && (
                     <>
-                        <Button startDecorator="Trash" variant="outline" size="sm" onPress={() => deleteAttachment(item.id)} />
-                        <Button startDecorator="Pencil" variant="outline" size="sm" onPress={() => editAttachment(item.id)} />
+                        <NeoButton controlSize="small" image="Trash" accessibilityLabel={t('Delete')} expoUI={false} onPress={() => deleteAttachment(item.id)} />
+                        <NeoButton controlSize="small" image="Pencil" accessibilityLabel={t('Edit')} onPress={() => editAttachment(item.id)} />
                     </>
                 )}
             </Row>
@@ -405,9 +401,7 @@ function LessonAttach({ lessonData, isEditable, reloadData, courseId, lessonId,
 
     return <>
         {attachments.map(renderAttachment)}
-        {isEditable && <View className='py-2'><DropdownMenu items={addParams.values} onSelect={(oItem) => { handleAction(oItem) }}>
-            <Button startDecorator="Plus" variant="default" size="sm" title={addParams.title} />
-        </DropdownMenu></View>
+        {isEditable && <View className='py-2'><DropdownMenu items={addParams.values} onSelect={(oItem) => { handleAction(oItem) }} buttonProps={{ controlSize: 'small', image: 'Plus', label: addParams.title }} /></View>
         }
         {formData && <Modal onClose={handleClose} title={`Add ${formData.title}`}><BlockByData onFormEmpty={() => handleUpdate()} block={formData} /></Modal>}
     </>
@@ -435,6 +429,7 @@ const showAddForm = async (url) => {
 
 
 function LessonSteps({ lessonData, startLessonPart, isEditable, reloadData, courseId, lessonId, moduleId, addParams, dispatch, gridData  }) {
+    const { t } = useTranslation();
     const steps = lessonData.steps;
     const [formData, setFormData] = useState(null);
 
@@ -498,18 +493,18 @@ function LessonSteps({ lessonData, startLessonPart, isEditable, reloadData, cour
                             {item.image?.src && <Image view='cover' alt='' className="rounded" src={item.image?.src} />}
                         </View>
                         <View className={`flex-auto`}>
-                            <Button variant="default" textColor={`text-white`} bgColor={`bg-` + getColorByType(item.type)} title={item.type} size="xs" rounded />
+                            <NeoButton controlSize="mini" borderShape="capsule" label={item.type} />
                             <Text className="mt-2 text-lg leading-tight tracking-tight font-bold text-secondary-foreground " numberOfLines={2}>{item.title}</Text>
                         </View>
                         <Row className="justify-center items-center gap-x-2">
                             {(!!item.pass_link && !isEditable) && (
-                                <Button endDecorator="ArrowRight" variant="default" title={item.pass_title} size="sm" rounded onPress={() => { startLessonPart(item.id, true) }} />
+                                <NeoButton controlSize="small" borderShape="capsule" image="ArrowRight" imagePlacement="trailing" label={item.pass_title} classNames={{ root: 'self-center' }} onPress={() => { startLessonPart(item.id, true) }} />
                             )}
                             {isEditable && (
                                 <>
-                                    <Button startDecorator="Trash" variant="outline" size="sm" onPress={() => deleteStep(item.id)} />
-                                    <Button startDecorator="Pencil" variant="outline" size="sm" onPress={() => editStep(actionEdit)} />
-                                    <DragControl dragHandleProps={dragHandleProps}><Button  variant="outline" startDecorator="ArrowUpDown" size='sm' /></DragControl>
+                                    <NeoButton controlSize="small" image="Trash" accessibilityLabel={t('Delete')} expoUI={false} classNames={{ root: 'self-center' }} onPress={() => deleteStep(item.id)} />
+                                    <NeoButton controlSize="small" image="Pencil" accessibilityLabel={t('Edit')} classNames={{ root: 'self-center' }} onPress={() => editStep(actionEdit)} />
+                                    <DragControl dragHandleProps={dragHandleProps} accessibilityLabel={t('Reorder')} className="u-neo-btn-link hit-area-4"><NeoButton controlSize="small" image="ArrowUpDown" interactive /></DragControl>
                                 </>
                             )}
                         </Row>
@@ -531,9 +526,7 @@ function LessonSteps({ lessonData, startLessonPart, isEditable, reloadData, cour
         }
    
         </DragContext>
-        {isEditable && <View className='py-2'><DropdownMenu items={addParams.values} onSelect={(oItem) => { handleAction(oItem) }}>
-            <Button startDecorator="Plus" variant="default" size="sm" title={addParams.title} />
-        </DropdownMenu></View>
+        {isEditable && <View className='py-2'><DropdownMenu items={addParams.values} onSelect={(oItem) => { handleAction(oItem) }} buttonProps={{ controlSize: 'small', image: 'Plus', label: addParams.title }} /></View>
         }
         {formData && <Modal onClose={handleClose} title={`${formData.title}`}><BlockByData onFormEmpty={() => handleUpdate()} block={formData} /></Modal>}
     </>
@@ -584,7 +577,7 @@ function LessonItem({ lessonItemData, lessonIndex, lessonData, startLessonPart }
                 <ContentMore numberOfSymbols={360} showLess={false} content={lessonItemData?.text} numberOfLines={3} openSmall={true} customClassName="u-vanilla-html" />
             </View>
             {lessonIndex != steps.length - 1 &&
-                <Button endDecorator="ArrowRight" variant="default" title={'Next'} size="sm" rounded onPress={() => { startLessonPart(lessonData.steps[lessonIndex + 1].id, true) }} />
+                <NeoButton controlSize="small" borderShape="capsule" image="ArrowRight" imagePlacement="trailing" label={'Next'} onPress={() => { startLessonPart(lessonData.steps[lessonIndex + 1].id, true) }} />
             }
         </>
     );

+ 7 - 7
neo/packages/app/components/elements/notifications-settings.js

@@ -3,7 +3,7 @@ import { View, Row } from 'app/design/view'
 import { useState } from 'react';
 import Switch from 'app/ui/atoms/switcher'
 import { fetcher } from 'app/lib/fetcher';
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import { firstLetterCap } from 'app/lib/util';
 import { useTranslation } from 'react-i18next';
 import { BlockWrapper } from 'app/components/block-wrapper'
@@ -41,12 +41,12 @@ export default function (props, blockWrapperProps) {
         <Row className='p-2'>
             {
                 data.map((item, index) => {
-                    return <View className='mr-2 ' key={'tab' + index}><Button
-                        variant={index == activeIndex ? 'outline' : "text"}
-                        pressed={index == activeIndex ? true : false}
-                        title={t(firstLetterCap(item.title))}
-                        rounded
-                        size='sm'
+                    return <View className='mr-2 ' key={'tab' + index}><NeoButton
+                        style={index == activeIndex ? undefined : 'borderless'}
+                        selected={index == activeIndex}
+                        label={t(firstLetterCap(item.title))}
+                        borderShape="capsule"
+                        controlSize="small"
                         onPress={() => setActiveIndex(index)}
                     /></View>
                 })

+ 32 - 26
neo/packages/app/components/elements/profile-switcher.js

@@ -1,7 +1,7 @@
 import { View, Pressable, Row } from 'app/design/view'
 import Link from 'app/ui/atoms/link'
 import { useState, useRef } from 'react'
-import { Button } from 'app/design/controls'
+import { NeoButton, NeoButtonLink } from 'app/design/controls'
 import { Text } from 'app/design/typography'
 import Profile from 'app/ui/molecules/profile/profile'
 import { useCurrentUser } from 'app/context/user'
@@ -12,7 +12,12 @@ import { useTranslation } from 'react-i18next';
 import { Modal } from 'app/design/controls'
 import { BlockWrapper } from 'app/components/block-wrapper'
 
-export default function ProfileSwitcher({ className, rounded = 'rounded-lg', children, hideTitle, listOnly, blockWrapperProps }) {
+/**
+ * Children mode: the Pressable host owns the press, so `accessibilityLabel` names
+ * it (a passive NeoButton inside drops its own). Callers pass `u-neo-btn-link`
+ * (+ `hit-area-4` small / `hit-area-8` mini) in `className` for the ring and target.
+ */
+export default function ProfileSwitcher({ className, rounded = 'rounded-lg', children, hideTitle, listOnly, blockWrapperProps, accessibilityLabel }) {
     const { t } = useTranslation();
     const { currentUser, setCurrentUser } = useCurrentUser();
     const [show, setShow] = useState(false)
@@ -45,7 +50,12 @@ export default function ProfileSwitcher({ className, rounded = 'rounded-lg', chi
     return (
         <BlockWrapper {...blockWrapperProps}>
             {true ?
-                <Pressable className={wrapperClassName} onPress={() => handleClick()}>
+                <Pressable
+                    className={wrapperClassName}
+                    accessibilityRole="button"
+                    accessibilityLabel={accessibilityLabel}
+                    onPress={() => handleClick()}
+                >
                     {children}
                 </Pressable> :
                 <Link href={currentUser.url} emulate={true} >
@@ -66,12 +76,11 @@ export default function ProfileSwitcher({ className, rounded = 'rounded-lg', chi
                                 </Text></View>
                         </Row>
                         <View className='flex-none p-1.5'>
-                            {currentUser.profiles_count > 1 && <Button
-                                variant="text"
-                                size="sm"
-                                startDecorator="RefreshCw"
-                                fullWidth
-                                align="right"
+                            {currentUser.profiles_count > 1 && <NeoButton
+                                style="borderless"
+                                controlSize="small"
+                                image="RefreshCw"
+                                accessibilityLabel={t('Switch profile')}
                                 onPress={() => handleClick()}
                             />}
                         </View>
@@ -100,7 +109,7 @@ export default function ProfileSwitcher({ className, rounded = 'rounded-lg', chi
                                     <View className="flex-none ">{profile}</View>
                                     <Text className='text-sm my-auto flex-auto font-semibold truncate text-popover-foreground '>{item.display_name}</Text>
                                     <View className="text-sm bont-semibold flex-none my-auto">
-                                        <Button id="menu" startDecorator="RefreshCw" title={t('Switch')} variant='outline' size='sm' onPress={() => handleSwitch(item.id)} />
+                                        <NeoButton controlSize="small" image="RefreshCw" label={t('Switch')} expoUI={false} onPress={() => handleSwitch(item.id)} />
                                     </View>
                                 </View>
                             </Link>
@@ -109,25 +118,22 @@ export default function ProfileSwitcher({ className, rounded = 'rounded-lg', chi
                     {!listOnly && <View className='sm:flex-row justity-between mt-4 w-full sm:mx-0'>
                         {!!currentUser?.menu?.items && currentUser.menu.items.map((item, index) => (
                             <View key={index} className={'mb-2 sm:mb-0 w-full sm:w-1/' + (currentUser.menu.items.length + 1) + ' pr-2 '}>
-                                <Link href={`/${item.name}`}>
-                                    <Button
-                                        variant="outline"
-                                        title={t("New " + item.title)}
-                                        startDecorator="UserCircle"
-                                        fullWidth
-                                    />
-                                </Link>
+                                <NeoButtonLink
+                                    href={`/${item.name}`}
+                                    width="fill"
+                                    image="UserCircle"
+                                    label={t("New " + item.title)}
+                                />
                             </View>
                         ))}
                         {!!currentUser?.menu?.items && <View className={'w-full sm:w-1/' + (currentUser.menu.items.length + 1) + ''}>
-                            <Link href="/logout">
-                                <Button
-                                    variant="outline"
-                                    title={t("Sign out")}
-                                    startDecorator="LogOut"
-                                    fullWidth
-                                />
-                            </Link>
+                            <NeoButtonLink
+                                href="/logout"
+                                width="fill"
+                                image="LogOut"
+                                label={t("Sign out")}
+                                expoUI={false}
+                            />
                         </View>}
                     </View>}
                 </View>

+ 16 - 12
neo/packages/app/components/elements/reputation.js

@@ -4,7 +4,7 @@ import Time from 'app/ui/atoms/time'
 import Profile from 'app/ui/molecules/profile/profile'
 import { Icon } from 'app/ui/atoms/icon'
 import { Svg, Path } from 'react-native-svg'
-import { Button, Modal } from 'app/design/controls'
+import { Modal, NeoButton } from 'app/design/controls'
 import { useState, useCallback, useMemo } from 'react'
 import { useQueries } from '@tanstack/react-query'
 import { useFetch, fetchQueryOptions } from 'app/lib/hooks/use-fetch'
@@ -165,7 +165,7 @@ function ReputationSummarySimple({ data }) {
                     displaySize="xl"
                 />
 
-                <View className="flex-auto gap-0.5 justify-center">
+                <View className="flex-auto gap-y-2 justify-center">
                     <Text className="font-semibold text-lg text-foreground">
                         {data.author_data.display_name}
                     </Text>
@@ -179,18 +179,22 @@ function ReputationSummarySimple({ data }) {
                                 data={{ text: item.title, icon: item.icon }}
                             />
 
-                            <Button
-                                variant="text"
-                                rounded
-                                size="xs"
-                                startDecorator="ListPlus"
+                            <NeoButton
+                                style="borderless"
+                                borderShape="circle"
+                                controlSize="mini"
+                                image="ListPlus"
+                                accessibilityLabel={t('Levels')}
+                                classNames={{ root: 'self-center' }}
                                 onPress={() => setIsModal2(true)}
                             />
-                            <Button
-                                variant="text"
-                                rounded
-                                size="xs"
-                                startDecorator="Info"
+                            <NeoButton
+                                style="borderless"
+                                borderShape="circle"
+                                controlSize="mini"
+                                image="Info"
+                                accessibilityLabel={t('Score rules')}
+                                classNames={{ root: 'self-center' }}
                                 onPress={() => setIsModal(true)}
                             />
                         </View>

+ 19 - 16
neo/packages/app/components/elements/search-ai.js

@@ -3,10 +3,9 @@ import { useTranslation } from 'react-i18next'
 import emitter, { EVENTS } from 'app/context/emitter'
 import { View, Row } from 'app/design/view'
 import { Text } from 'app/design/typography'
-import { InputWithIcons, Button } from 'app/design/controls'
+import { InputWithIcons, NeoButton, NeoButtonLink } from 'app/design/controls'
 import { BlockWrapper } from 'app/components/block-wrapper'
 import { Icon } from 'app/ui/atoms/icon'
-import Link from 'app/ui/atoms/link'
 import Redirect from 'app/ui/atoms/redirect'
 import BrowseSimple, { BrowseSimpleView } from 'app/components/elements/browse-simple'
 import { UnitSearchResultsSmall } from 'app/components/units/search-results'
@@ -221,13 +220,11 @@ function SearchAiExtended({ blockWrapperProps, block }) {
 
                 {semantic && (
                     <Row className="flex-wrap gap-1 items-center">
-                        <Button
-                            size="xs"
-                            rounded
-                            variant="secondary"
-                            startDecorator="Sparkles"
-                            title={t('By meaning') + ': ' + query.trim()}
-                            hitarea={false}
+                        <NeoButton
+                            controlSize="mini"
+                            borderShape="capsule"
+                            image="Sparkles"
+                            label={t('By meaning') + ': ' + query.trim()}
                         />
                     </Row>
                 )}
@@ -261,10 +258,11 @@ function SearchAiExtended({ blockWrapperProps, block }) {
                                 />
                                 {hasMore && (
                                     <Row className="justify-center mt-1">
-                                        <Button
-                                            variant="outline"
-                                            size="sm"
-                                            title={loading ? t('Loading…') : t('Load more')}
+                                        <NeoButton
+                                            controlSize="small"
+                                            label={t('Load more')}
+                                            loading={loading}
+                                            loadingLabel={t('Loading…')}
                                             disabled={loading}
                                             onPress={() => load(results.length, true)}
                                         />
@@ -438,9 +436,14 @@ function SearchAiResults({ results, loading, section, allResultsUrl, onPress })
             )}
             {!!allResultsUrl && (
                 <Row className="justify-end mt-1">
-                    <Link href={allResultsUrl}>
-                        <Button variant="link" size="sm" endDecorator="ChevronsRight" title={t('See all results')} />
-                    </Link>
+                    <NeoButtonLink
+                        href={allResultsUrl}
+                        style="borderless"
+                        controlSize="small"
+                        image="ChevronsRight"
+                        imagePlacement="trailing"
+                        label={t('See all results')}
+                    />
                 </Row>
             )}
         </View>

+ 8 - 5
neo/packages/app/components/elements/search-sections.tsx

@@ -2,8 +2,7 @@
 import { View, Row } from 'app/design/view';
 import { Text } from 'app/design/typography'
 import { useTranslation } from 'react-i18next';
-import Link from 'app/ui/atoms/link'
-import { Button } from 'app/design/controls';
+import { NeoButtonLink } from 'app/design/controls';
 import { BlockWrapper } from 'app/components/block-wrapper'
 import { Icon } from 'app/ui/atoms/icon'
 import BrowseSimple, { BrowseSimpleView } from 'app/components/elements/browse-simple'
@@ -48,9 +47,13 @@ export default function ElementSearchSections({ blockWrapperProps, data }: Searc
                         <View key={item.section}>
                             <Row className='items-center justify-between p-2 mb-0.5'>
                                 <Text className=" text-base font-semibold tracking-tight text-muted-foreground  ">{t(item.section_name)}</Text>
-                                <Link href={`/search-keyword?keyword=test&section=${item.section}`}>
-                                    <Button variant='link' size='sm' title={t('View all')} />
-                                </Link>
+                                <NeoButtonLink
+                                    href={`/search-keyword?keyword=test&section=${item.section}`}
+                                    style="borderless"
+                                    controlSize="small"
+                                    label={t('View all')}
+                                    classNames={{ root: 'self-center' }}
+                                />
                             </Row>
                             <BrowseSimple
                                 data={{

+ 26 - 10
neo/packages/app/components/elements/smart-grid.web.js

@@ -4,7 +4,7 @@ import Image from 'app/ui/atoms/image';
 import { View, Pressable, Row } from 'app/design/view';
 import { Text } from 'app/design/typography';
 import { useState, useEffect, useMemo, memo } from 'react';
-import { Button, Modal } from "app/design/controls";
+import { NeoButton, Modal } from "app/design/controls";
 import { fetcher } from 'app/lib/fetcher';
 import Form from 'app/components/elements/form';
 import { appSetting } from 'app/lib/util'
@@ -292,11 +292,27 @@ export default function ({ is_allowed_edit, block_id, content_id, content_module
             <View key={block.i} className=" shadow groupweb:duration-200 overflow-hidden sm:rounded-2xl bg-card">
                 {blockContent}
                 {bAllowEdit && <View className="absolute left-1/4 w-1/2 flex-row justify-center gap-x-4  items-center bottom-5 z-50">
-                    <Pressable onMouseDown={(event) => onRemove(event, block.i)} onTouchStart={(event) => onRemove(event, block.i)}>
-                        <Button variant='default' size='xs' rounded startDecorator='X' />
+                    <Pressable
+                        className="u-neo-btn-link hit-area-8"
+                        role="button"
+                        tabIndex={0}
+                        aria-label={t('Remove')}
+                        onMouseDown={(event) => onRemove(event, block.i)}
+                        onTouchStart={(event) => onRemove(event, block.i)}
+                        onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') onRemove(event, block.i) }}
+                    >
+                        <NeoButton style="glass" controlSize="mini" borderShape="circle" image="X" interactive />
                     </Pressable>
-                    <Pressable onPressIn={(event) => { onChange(event, block.i) }}>
-                        <Button variant='default' size='xs' rounded startDecorator='Pencil' />
+                    <Pressable
+                        className="u-neo-btn-link hit-area-8"
+                        role="button"
+                        tabIndex={0}
+                        aria-label={t('Edit')}
+                        onMouseDown={(event) => onChange(event, block.i)}
+                        onTouchStart={(event) => onChange(event, block.i)}
+                        onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') onChange(event, block.i) }}
+                    >
+                        <NeoButton style="glass" controlSize="mini" borderShape="circle" image="Pencil" interactive />
                     </Pressable>
                 </View>}
 
@@ -331,11 +347,11 @@ export default function ({ is_allowed_edit, block_id, content_id, content_module
                         <Form {...form} resetOnSubmit={true} onFormSubmit={onFormSubmit} />
                     </View>
                 </Modal>}
-                {bAllowEdit && <Row className="gap-x-4 items-center justify-center">
-                    <Button variant='text' size='base' rounded startDecorator='NotepadText' onPress={() => { onAdd('text') }} />
-                    <Button variant='text' size='base' rounded startDecorator='Link' onPress={() => { onAdd('link') }} />
-                    <Button variant='text' size='base' rounded startDecorator='Image' onPress={() => { onAdd('image') }} />
-                    <Button variant='text' size='base' rounded startDecorator='MapPin' onPress={() => { onAdd('map') }} />
+                {bAllowEdit && <Row className="gap-x-4 items-center justify-center pt-1.5">
+                    <NeoButton style="borderless" borderShape="circle" image="NotepadText" accessibilityLabel={t('Add text')} onPress={() => { onAdd('text') }} />
+                    <NeoButton style="borderless" borderShape="circle" image="Link" accessibilityLabel={t('Add link')} onPress={() => { onAdd('link') }} />
+                    <NeoButton style="borderless" borderShape="circle" image="Image" accessibilityLabel={t('Add image')} onPress={() => { onAdd('image') }} />
+                    <NeoButton style="borderless" borderShape="circle" image="MapPin" accessibilityLabel={t('Add map')} onPress={() => { onAdd('map') }} />
                 </Row>}
                 <View className="-ml-2 -mr-2">
                     <ResponsiveReactGridLayoutM

+ 4 - 5
neo/packages/app/components/elements/tasks-timers.js

@@ -3,7 +3,7 @@ import { View, Row } from 'app/design/view';
 import { Text } from 'app/design/typography';
 import { BlockWrapper } from 'app/components/block-wrapper';
 import BrowseSimple from 'app/components/elements/browse-simple';
-import { Button } from 'app/design/controls';
+import { NeoButton } from 'app/design/controls';
 import { components } from 'app/components/registry';
 import { fetcher } from 'app/lib/fetcher';
 import emitter, { EVENTS } from 'app/context/emitter';
@@ -62,11 +62,10 @@ export default function ElementTasksTimers({ data, blockWrapperProps }) {
                 {globalActions.length > 0 && sections.length > 0 ? (
                     <Row className="flex-wrap items-center justify-end gap-2 px-2">
                         {globalActions.map((item) => (
-                            <Button
+                            <NeoButton
                                 key={item.name}
-                                size="sm"
-                                variant="default"
-                                title={item.title || item.name}
+                                controlSize="small"
+                                label={item.title || item.name}
                                 onPress={() => runGlobalAction(item)}
                             />
                         ))}

+ 19 - 7
neo/packages/app/components/elements/tasks/actions.js

@@ -2,7 +2,8 @@
  * UNA tasks actions: `modal` opens a form, `callback` hits an endpoint
  * (optionally behind a confirm dialog), `menu` carries plain menu items.
  */
-import { Button } from 'app/design/controls';
+import { useTranslation } from 'react-i18next';
+import { NeoButton } from 'app/design/controls';
 import DropdownMenu from 'app/ui/atoms/dropdown-menu';
 import { fetcher } from 'app/lib/fetcher';
 import { apiUrl, emitTasksListRefresh } from './helpers';
@@ -60,13 +61,23 @@ export async function runTaskAction(action, { setFormBlock, setShowConfirm }) {
 }
 
 export function TaskActionsDropdown({ actions, ...handlers }) {
+    const { t } = useTranslation();
     const items = actionsToDropdownItems(actions);
     if (!items.length) return null;
 
     return (
-        <DropdownMenu items={items} onSelect={(item) => runTaskAction(item.action, handlers)}>
-            <Button variant="text" size="sm" rounded startDecorator="Ellipsis" />
-        </DropdownMenu>
+        <DropdownMenu
+            items={items}
+            onSelect={(item) => runTaskAction(item.action, handlers)}
+            buttonProps={{
+                style: 'borderless',
+                controlSize: 'small',
+                borderShape: 'circle',
+                image: 'Ellipsis',
+                accessibilityLabel: t('More options'),
+                classNames: { root: 'self-center' },
+            }}
+        />
     );
 }
 
@@ -95,10 +106,11 @@ export function MenuObjectActions({ actions, setShowConfirm }) {
     };
 
     return menuItems.map((item, index) => (
-        <Button
+        <NeoButton
             key={item?.id || item?.name || `menu-item-${index}`}
-            size="sm"
-            title={item?.title || item?.name}
+            style="glass"
+            controlSize="small"
+            label={item?.title || item?.name}
             onPress={() => onMenuItemPress(item)}
         />
     ));

+ 3 - 3
neo/packages/app/components/elements/tasks/filter-selector.js

@@ -8,7 +8,7 @@ import { ActivityIndicator } from 'react-native';
 import { useTranslation } from 'react-i18next';
 import { View, Row, Pressable } from 'app/design/view';
 import { Text } from 'app/design/typography';
-import { Button } from 'app/design/controls';
+import { NeoButton } from 'app/design/controls';
 import DropdownPopup from 'app/ui/atoms/dropdown-popup';
 import CheckBox from 'app/ui/atoms/checkbox';
 import { Icon } from 'app/ui/atoms/icon';
@@ -507,9 +507,9 @@ export default function TasksFilterSelector({
 
             {activeFilters.length > 0 && !isSavedFilterSelected ? (
                 <Row className="items-center gap-1">
-                    <Button size="sm" variant="text" title={t('Clear')} onPress={clearFilters} />
+                    <NeoButton style="borderless" controlSize="small" label={t('Clear')} onPress={clearFilters} />
                     {requestUrlSave ? (
-                        <Button size="sm" variant="text" title={t('Save')} onPress={onSave} />
+                        <NeoButton style="borderless" controlSize="small" label={t('Save')} onPress={onSave} />
                     ) : null}
                 </Row>
             ) : null}

+ 4 - 3
neo/packages/app/components/elements/tasks/tasklists-panel.js

@@ -111,13 +111,14 @@ function TasklistRow({ item, selected, onSelect, actionHandlers }) {
 
 /** A context (space/profile) with its tasklists, collapsible. */
 function TasklistGroup({ group, selectedId, selectedContextId, onSelect, actionHandlers }) {
+    const { t } = useTranslation();
     const [expanded, setExpanded] = useState(true);
     const groupSelected = selectedContextId != null
         && String(selectedContextId) === String(group.id)
         && listIdOrNull(selectedId) == null;
 
     return (
-        <View className="gap-0.5">
+        <View className="gap-1">
             <Row className={cn(
                 'items-center rounded-lg',
                 groupSelected && 'bg-muted/50',
@@ -137,7 +138,7 @@ function TasklistGroup({ group, selectedId, selectedContextId, onSelect, actionH
                     onPress={() => setExpanded((prev) => !prev)}
                     className="shrink-0 p-2 web:hover:bg-muted/50 rounded-lg"
                     accessibilityRole="button"
-                    accessibilityLabel={expanded ? 'Collapse' : 'Expand'}
+                    accessibilityLabel={expanded ? t('Collapse') : t('Expand')}
                 >
                     <Chevron expanded={expanded} size={14} />
                 </Pressable>
@@ -195,7 +196,7 @@ export default function TasklistsPanel({
 
             {expanded ? (
                 <View className={cn(
-                    'p-2 gap-0.5',
+                    'p-2 gap-1',
                     fill && 'lg:min-h-0 lg:flex-1 lg:overflow-y-auto',
                 )}>
                     {tasklists.map((item) => (

+ 9 - 8
neo/packages/app/components/elements/wiki-history.js

@@ -6,7 +6,7 @@ import Time from 'app/ui/atoms/time'
 import Link from 'app/ui/atoms/link'
 import Profile from 'app/ui/molecules/profile/profile'
 import CheckBox from 'app/ui/atoms/checkbox'
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import { fetcher } from 'app/lib/fetcher'
 import emitter, { EVENTS } from 'app/context/emitter'
 import { useTranslation } from 'react-i18next'
@@ -147,17 +147,18 @@ export default function WikiHistory({
 
                 {editMode ? (
                     <Row className="gap-3 pt-3 justify-between items-center">
-                        <Button
-                            title={allSelected ? t('Deselect all') : t('Select all')}
-                            size="sm"
-                            variant="link"
+                        <NeoButton
+                            label={allSelected ? t('Deselect all') : t('Select all')}
+                            style="borderless"
+                            controlSize="small"
                             disabled={selected.size === 0 || submitting}
                             onPress={toggleAll}
                         />
-                        <Button
-                            title={t('Delete selected')}
-                            size="sm"
+                        <NeoButton
+                            label={t('Delete selected')}
+                            controlSize="small"
                             disabled={selected.size === 0 || submitting}
+                            expoUI={false}
                             onPress={handleSubmit}
                         />
                     </Row>

+ 9 - 6
neo/packages/app/components/form-fields/_field.js

@@ -6,7 +6,8 @@ import { Text } from 'app/design/typography'
 import Link from 'app/ui/atoms/link'
 import { appSetting, cn, stripTags } from 'app/lib/util'
 import { Icon } from 'app/ui/atoms/icon'
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
+import i18n from 'i18next'
 import { queueFormEnsureVisible } from 'app/lib/form/form-ensure-visible'
 import { nativeDriver } from 'app/lib/platform/animation'
 
@@ -276,12 +277,14 @@ export default function Field({
         <View ref={wrapRef} collapsable={false} className={sClassName}>
             {name && last_changed == name && (
                 <View className="absolute right-0 top-0 mb-1">
-                    <Button
+                    <NeoButton
+                        style="borderedProminent"
+                        controlSize="mini"
+                        borderShape="circle"
+                        image="RotateCw"
+                        accessibilityLabel={i18n.t('Refresh')}
+                        haptics={false}
                         onPress={handleSubmit}
-                        startDecorator="RotateCw"
-                        variant="primary"
-                        size="xs"
-                        rounded
                     />
                 </View>
             )}

+ 3 - 0
neo/packages/app/components/form-fields/_map.js

@@ -1,4 +1,5 @@
 import dynamic from 'next/dynamic';
+import { coreFormFields } from 'app/components/registry';
 import { DynamicFallback } from 'app/lib/dynamic-fallback';
 
 // Each field type is its own chunk: fetched when a form renders it, and
@@ -85,3 +86,5 @@ export const componentsMapDefault = {
     checkbox_set: CheckboxSet,
     list: List
 };
+
+Object.assign(coreFormFields, componentsMapDefault);

+ 89 - 11
neo/packages/app/components/form-fields/editor-inner-enriched.js

@@ -4,6 +4,8 @@ import { useState, useRef, useEffect, useMemo, useCallback, Component } from 're
 import { Platform } from 'react-native'
 import { EnrichedTextInput } from 'react-native-enriched-html'
 import { View } from 'app/design/view'
+import { Text } from 'app/design/typography'
+import { useTranslation } from 'react-i18next'
 import { EditorToolbar, useEnrichedToolbar } from 'app/lib/editor/editor-toolbar'
 import { useThemeValue } from 'app/design/theme'
 import { appSetting, cn } from 'app/lib/util'
@@ -22,7 +24,12 @@ import { useTiptapEditor } from 'app/lib/editor/use-tiptap-editor'
 import { useEnrichedMentionDropdown } from 'app/lib/editor/use-enriched-mention-dropdown'
 import { useEnrichedKeyboard } from 'app/lib/editor/use-enriched-keyboard'
 import { useIsDesktop } from 'app/context/measure'
-import { fitInlineImageSize, getEditorPasteImagesMode } from 'app/lib/editor/editor-paste-images'
+import { fitInlineImageSize, getEditorPasteImagesMode, revokePastedBlobUri } from 'app/lib/editor/editor-paste-images'
+import { pickEditorImages, pickEditorVideo } from 'app/lib/editor/pick-editor-images'
+import { uploadInlineVideo, toVideoPosterSrc } from 'app/lib/editor/inline-video'
+import { uploadInlineImage, swapEditorImageSrc } from 'app/lib/editor/upload-inline-image'
+import { trackFormUploadStart, trackFormUploadEnd } from 'app/lib/form/form-helpers'
+import { useFormInstanceId } from 'app/context/form-instance'
 
 const isWeb = Platform.OS === 'web'
 const inputSettings = appSetting('theme', 'inputs')
@@ -86,6 +93,8 @@ export default function RftTextEnriched(props) {
     const containerRef = useRef(null)
     const dropdownRef = useRef(null)
     const isDesktop = useIsDesktop()
+    const formInstanceId = useFormInstanceId()
+    const { t } = useTranslation()
     const isToolBar = html == 2 || html == 1
     // UNA html=3: rich editor, no toolbar. Images paste into attachments, not <img>.
     const isLimitedHtml = html == 3
@@ -236,11 +245,75 @@ export default function RftTextEnriched(props) {
         lastSetValue.current = next
     }, [field, isLimitedHtml, name, onFocus, resetField])
 
-    // ---- image paste (library onPasteImages: blob: on web, file:// on native) ----
+    // ---- images (paste + toolbar picker) ----
+    // Toolbar editors put images in the body by default; html=3 cannot keep <img>.
+    // Inline: upload to the editor storage (like UNA's Quill) and insert the link —
+    // not via the files field, whose ghosts become attachments with private URLs.
+    const pasteImagesMode = form_name
+        ? getEditorPasteImagesMode(form_name, isToolBar ? 'inline' : undefined)
+        : 'off'
+    const isInline = !isLimitedHtml && pasteImagesMode === 'inline'
+
+    const sendImagesToFiles = useCallback((images) => {
+        if (!images?.length || pasteImagesMode === 'off') return
+        if (isInline) {
+            images.forEach(async (img, index) => {
+                // Placeholder: the local blob:/file:// image right away, swapped for the
+                // uploaded URL (or dropped on failure). Submit waits on the pending upload.
+                const uploadId = `inline-${Date.now()}-${index}`
+                const size = fitInlineImageSize(img.width, img.height)
+                trackFormUploadStart(form_name, formInstanceId, uploadId)
+                callEditor(editorRef, 'setImage', img.uri, size.width, size.height)
+                try {
+                    const src = await uploadInlineImage(img)
+                    await swapEditorImageSrc(editorRef, tipTap, img.uri, src)
+                } finally {
+                    trackFormUploadEnd(form_name, formInstanceId, uploadId)
+                    revokePastedBlobUri(img.uri)
+                }
+            })
+            return
+        }
+        emitter.emit(EVENTS.form(form_name), {
+            action: 'pasted_images',
+            images,
+            inlinePaste: false,
+        })
+    }, [form_name, formInstanceId, isInline, pasteImagesMode, tipTap])
+
+    const onInsertImage = useCallback(async () => {
+        const images = await pickEditorImages()
+        sendImagesToFiles(images)
+    }, [sendImagesToFiles])
+
+    // Video: the body keeps its poster image (the editors have no <video>/<iframe>; an
+    // image is atomic on every platform); the post view renders it as a player — see
+    // splitInlineMedia. Until transcoded the poster link shows UNA's video-na.png.
+    const [videoProgress, setVideoProgress] = useState(null)
+    const onInsertVideo = useCallback(async () => {
+        const video = await pickEditorVideo()
+        if (!video) return
+        const uploadId = `inline-video-${Date.now()}`
+        trackFormUploadStart(form_name, formInstanceId, uploadId)
+        setVideoProgress(0)
+        try {
+            const res = await uploadInlineVideo(video, setVideoProgress)
+            if (res?.link) {
+                const size = video.width && video.height
+                    ? fitInlineImageSize(video.width, video.height)
+                    : { width: 720, height: 405 }
+                callEditor(editorRef, 'setImage', toVideoPosterSrc(res.link), size.width, size.height)
+            }
+        } finally {
+            setVideoProgress(null)
+            trackFormUploadEnd(form_name, formInstanceId, uploadId)
+        }
+    }, [form_name, formInstanceId])
+
+    // Library onPasteImages: blob: on web, file:// on native.
     const onPasteImages = useCallback((e) => {
         const imgs = e?.nativeEvent?.images || []
-        if (!imgs.length || !form_name) return
-        if (getEditorPasteImagesMode(form_name) === 'off') return
+        if (!imgs.length) return
         const images = imgs.map((img, index) => {
             const mimeType = img.type || 'image/png'
             const ext = mimeType.split('/')[1] || 'png'
@@ -252,13 +325,8 @@ export default function RftTextEnriched(props) {
                 height: img.height,
             }
         })
-        // html=3 cannot keep <img> in the body; still upload to the files field.
-        emitter.emit(EVENTS.form(form_name), {
-            action: 'pasted_images',
-            images,
-            ...(isLimitedHtml ? { inlinePaste: false } : null),
-        })
-    }, [form_name, isLimitedHtml])
+        sendImagesToFiles(images)
+    }, [sendImagesToFiles])
 
     // ---- Enter routing (desktop only; mobile Enter inserts newline) ----
     const submitOnEnter = isDesktop && (
@@ -321,6 +389,7 @@ export default function RftTextEnriched(props) {
         callEditor(editorRef, 'focus')
     }, [isDisabled])
 
+
     const { items: toolbarItems, linkBar } = useEnrichedToolbar({
         editorRef,
         containerRef,
@@ -328,6 +397,9 @@ export default function RftTextEnriched(props) {
         styleState,
         selection,
         enabled: isToolBar,
+        onInsertImage: pasteImagesMode === 'off' ? undefined : onInsertImage,
+        onInsertVideo: isInline ? onInsertVideo : undefined,
+        embeds: !isLimitedHtml,
     })
 
     const surfaceClass = isToolBar
@@ -387,9 +459,15 @@ export default function RftTextEnriched(props) {
                     />
                 </EnrichedEditorBoundary>
 
+
                 {isToolBar ? (
                     <EditorToolbar items={toolbarItems} linkBar={linkBar} />
                 ) : null}
+                {videoProgress != null ? (
+                    <Text className="text-xs text-muted-foreground mt-1">
+                        {t('Uploading video')} {Math.round(videoProgress * 100)}%
+                    </Text>
+                ) : null}
             </View>
         </View>
     )

+ 15 - 8
neo/packages/app/components/form-fields/editor-markdown.js

@@ -4,7 +4,7 @@ import { useFormContext } from 'react-hook-form'
 import { useFieldController } from 'app/lib/form/use-form-field'
 import { useEffect, useMemo, useRef, useState } from 'react'
 import { EnrichedMarkdownTextInput } from 'react-native-enriched-markdown'
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import { View } from 'app/design/view'
 import { useTheme, useThemeValue } from 'app/design/theme'
 import { useCSSVariable } from 'uniwind'
@@ -68,6 +68,7 @@ export function MarkdownTextInput({
     showToolbar = true,
     rules = {},
 }) {
+    const { t } = useTranslation()
     const externalValue = valueProp ?? ''
     const { field } = useFieldController({
         name,
@@ -161,24 +162,28 @@ export function MarkdownTextInput({
             />
 
             {showToolbar && !disabled && styleState ? (
-                <View className="mt-2 flex-row flex-wrap gap-1">
+                <View className="mt-2 flex-row flex-wrap gap-2">
                     <ToolbarButton
                         label="B"
+                        accessibilityLabel={t('Bold')}
                         active={styleState.bold?.isActive}
                         onPress={() => editorRef.current?.toggleBold()}
                     />
                     <ToolbarButton
                         label="I"
+                        accessibilityLabel={t('Italic')}
                         active={styleState.italic?.isActive}
                         onPress={() => editorRef.current?.toggleItalic()}
                     />
                     <ToolbarButton
                         label="U"
+                        accessibilityLabel={t('Underline')}
                         active={styleState.underline?.isActive}
                         onPress={() => editorRef.current?.toggleUnderline()}
                     />
                     <ToolbarButton
                         label="S"
+                        accessibilityLabel={t('Strikethrough')}
                         active={styleState.strikethrough?.isActive}
                         onPress={() => editorRef.current?.toggleStrikethrough()}
                     />
@@ -199,13 +204,15 @@ export function MarkdownTextInput({
     )
 }
 
-function ToolbarButton({ label, active, onPress }) {
+function ToolbarButton({ label, accessibilityLabel, active, onPress }) {
     return (
-        <Button
-            variant="text"
-            size="xs"
-            pressed={!!active}
-            title={label}
+        <NeoButton
+            style="borderless"
+            controlSize="mini"
+            selected={!!active}
+            label={label}
+            accessibilityLabel={accessibilityLabel}
+            haptics={false}
             onPress={onPress}
         />
     )

+ 2 - 1
neo/packages/app/components/form-fields/embed.js

@@ -10,7 +10,8 @@ import { useFormField } from 'app/lib/form/use-form-field';
 function InnerEmbed({ url }) {
     // keepPreviousData: the old preview stays while the next URL resolves.
     const { data: response } = useFetch(
-        url ? `/api.php?r=${appSetting("urls", "embeds_new")}${url}` : null,
+        // Encoded: the URL is a query value (`params[]=`), its own `&` would split it.
+        url ? `/api.php?r=${appSetting("urls", "embeds_new")}${encodeURIComponent(url)}` : null,
         { keepPreviousData: true }
     );
 

+ 21 - 8
neo/packages/app/components/form-fields/files.js

@@ -2,7 +2,7 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
 import Field, { getValidationRules } from './_field';
 import { View, Row, Pressable } from 'app/design/view';
 import * as ImagePicker from 'expo-image-picker';
-import { Button, NeoButton } from 'app/design/controls';
+import { NeoButton, legacyToNeoButtonProps } from 'app/design/controls';
 import { Icon } from 'app/ui/atoms/icon';
 import { genRnd, appSetting, prepareImageForUpload } from 'app/lib/util';
 import * as DocumentPicker from 'expo-document-picker';
@@ -738,7 +738,7 @@ export default function (props) {
                 if (data.data !== undefined && !formResponseHasFieldErrors(data.data)) resetImages();
             }
             if (data.action == 'pasted_images' && acceptsPastedImages && data.images?.length) {
-                const inlinePaste = data.inlinePaste === false ? false : isInlineImagePaste(formName)
+                const inlinePaste = typeof data.inlinePaste === 'boolean' ? data.inlinePaste : isInlineImagePaste(formName)
                 const assets = data.images.map((img) => ({ ...img, inlinePaste }))
                 uploadImages(assets).then((k) => setImageSource({ images: k }));
             }
@@ -899,7 +899,20 @@ function ActionButton({ imagesList, props, selectImage, handleDelete, bMultiple,
             }
         }
     };
-    let button = <Button startDecorator={props.icon ? props.icon : sIcon} tooltip={t("Add " + props.name)} title={props.title ? props.title : sTitle} size={props.size ? props.size : "base"} variant={props.variant ? props.variant : "text"} rounded={props.rounded ? props.rounded : false} onPress={() => selectImage(props.source ?? 'library')} />
+    const sLabel = props.title ? props.title : sTitle;
+    const sTooltip = t("Add " + props.name);
+    let button = <NeoButton
+        {...legacyToNeoButtonProps({
+            variant: props.variant || 'text',
+            size: props.size || 'base',
+            rounded: props.rounded,
+            title: sLabel,
+            startDecorator: props.icon ? props.icon : sIcon,
+            tooltip: sTooltip,
+        })}
+        accessibilityLabel={sLabel || sTooltip}
+        onPress={() => selectImage(props.source ?? 'library')}
+    />
 
     if (!bMultiple || props.useSingle) {
         let img = (imagesList || []).find((item) => !item?.inlinePaste) || null;
@@ -1024,11 +1037,11 @@ function ButtonCover({ imageSource, selectImage }) {
     let img = imagesList && imagesList.find(item => item.preload === true)
     let isImages = imagesList && imagesList.find(item => item.preload !== true)
 
-    return !isImages && <Button
-        title={t('Add Cover')}
-        startDecorator={img?.preload ? "_loading" : "Image"}
-        variant="outline"
-        size="xs"
+    return !isImages && <NeoButton
+        label={t('Add Cover')}
+        image="Image"
+        loading={!!img?.preload}
+        controlSize="mini"
         onPress={() => selectImage('library')}
     />
 }

+ 2 - 2
neo/packages/app/components/form-fields/initial-members.js

@@ -4,7 +4,7 @@ import { View, Row, Pressable, ScrollView } from 'app/design/view'
 import { useState, useEffect, useReducer } from 'react';
 import { fetcher } from 'app/lib/fetcher';
 import Profile from 'app/ui/molecules/profile/profile'
-import { Button, NeoButton, Input, Modal } from 'app/design/controls'
+import { NeoButton, Input, Modal } from 'app/design/controls'
 import Loading from 'app/ui/atoms/loading'
 import { Icon } from 'app/ui/atoms/icon'
 import { useTranslation } from 'react-i18next';
@@ -146,7 +146,7 @@ export function SelectUsers({ onSave, initedData = [], requestUrl, isSingle = fa
                     onChangeText={onChangeText}
                     role="textbox"
                 />
-                <Button variant="primary" size="lg" disabled={state.selectedUsers.length == 0} startDecorator="Check" onPress={() => onSaveInt()} />
+                <NeoButton style="borderedProminent" controlSize="large" image="Check" accessibilityLabel={t('Save')} disabled={state.selectedUsers.length == 0} onPress={() => onSaveInt()} />
             </Row>
             <Row className="text-center w-full flex-wrap gap-1 ">
                 {!state.searchText && extraList.length > 0 && state.suggestedUsers.length > 0 && !state.showLoading ? (

+ 8 - 7
neo/packages/app/components/form-fields/labels.js

@@ -2,7 +2,7 @@ import Field from './_field';
 import { Text } from 'app/design/typography'
 import { View, Row } from 'app/design/view'
 import { useState, useEffect , useCallback } from 'react';
-import { Button, Modal, NeoButton } from "app/design/controls";
+import { Modal, NeoButton } from "app/design/controls";
 import ChkList from 'app/ui/molecules/form-controls/checkbox-list';
 import emitter, { EVENTS } from 'app/context/emitter';
 import { LabelButton, wellButtonProps } from 'app/lib/form/form-helpers'
@@ -82,14 +82,15 @@ export default function FormFieldLabels(props) {
     );
 
     const header = <Row className=' w-full justify-between items-center'>
-        <View><Button onPress={() => { setIsModal(null) }} variant='secondary' rounded startDecorator="ArrowLeft" /></View>
+        <View><NeoButton style="glass" controlSize="regular" borderShape="circle" image="ArrowLeft" accessibilityLabel={t('Back')} onPress={() => { setIsModal(null) }} /></View>
         <View className='w-full flex-auto items-center justify-center'><Text className="text-secondary-foreground  text-xl font-bold">{'Choose ' + props.caption}</Text></View>
         <View >
-            <Button
-                startDecorator="Check"
-                variant="primary"
-                size="base"
-                rounded
+            <NeoButton
+                style="glassProminent"
+                controlSize="regular"
+                borderShape="circle"
+                image="Check"
+                accessibilityLabel={t('Done')}
                 onPress={() => setFormValue(value2)}
             />
         </View>

+ 11 - 9
neo/packages/app/components/form-fields/list.js

@@ -1,12 +1,14 @@
 import Field from './_field';
-import { Input, Button } from 'app/design/controls'
+import { Input, NeoButton } from 'app/design/controls'
 import { useEffect, useState } from 'react';
 import { View, Row } from 'app/design/view'
 import { strToObj } from 'app/lib/util';
 import { useFormField } from 'app/lib/form/use-form-field';
 import { listInitialRows } from 'app/lib/form/field-initial-values';
+import { useTranslation } from 'react-i18next';
 
 export default function FormFieldList(props) {
+    const { t } = useTranslation();
     const params = strToObj(props.params) || {};
     const minCount = params.minCount || 1;
     const maxCount = params.maxCount || 10;
@@ -70,21 +72,21 @@ export default function FormFieldList(props) {
                         ))}
                         {index >= minCount && (
                             <View>
-                                <Button
-                                    variant="secondary"
+                                <NeoButton
+                                    controlSize="large"
+                                    image="X"
+                                    accessibilityLabel={t('Remove')}
                                     onPress={() => deleteValue(index)}
-                                    size="lg"
-                                    startDecorator="X"
                                 />
                             </View>
                         )}
                         {index == 0 && values.length < maxCount && (
                             <View>
-                                <Button
-                                    variant="secondary"
+                                <NeoButton
+                                    controlSize="large"
+                                    image="Plus"
+                                    accessibilityLabel={t('Add')}
                                     onPress={addNew}
-                                    size="lg"
-                                    startDecorator="Plus"
                                 />
                             </View>
                         )}

+ 5 - 3
neo/packages/app/components/form-fields/multi-field.js

@@ -2,11 +2,13 @@ import Field from './_field';
 import { useFormContext } from 'react-hook-form';
 import { Input } from 'app/design/controls'
 import React, { useEffect, useState } from 'react';
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
+import { useTranslation } from 'react-i18next';
 import { View, Row } from 'app/design/view';
 import { multiFieldFormValues, multiFieldInitialRows } from 'app/lib/form/field-initial-values';
 
 export default function FormFieldMultiField(props) {
+    const { t } = useTranslation();
     const name = props.name;
     const minCount = props.minCount || 2;
 
@@ -47,8 +49,8 @@ export default function FormFieldMultiField(props) {
 
                             />
 
-                            {index >= minCount && <View className='pl-2'><Button variant="secondary" onPress={() => deleteValue(value.id)} size="lg" startDecorator="X" /></View>}
-                            {index == minCount - 1 && <View className='pl-2'><Button variant="secondary" onPress={addNew} size="lg" startDecorator="Plus" /></View>}
+                            {index >= minCount && <View className='pl-2'><NeoButton onPress={() => deleteValue(value.id)} controlSize="large" image="X" accessibilityLabel={t('Remove')} /></View>}
+                            {index == minCount - 1 && <View className='pl-2'><NeoButton onPress={addNew} controlSize="large" image="Plus" accessibilityLabel={t('Add')} /></View>}
 
                         </Row>
                     )

+ 7 - 5
neo/packages/app/components/form-fields/phone.js

@@ -3,7 +3,7 @@ import CountryFlag from 'react-native-country-flag';
 import { countries } from 'country-codes-flags-phone-codes';
 import { useTranslation } from 'react-i18next';
 import { Row, View } from 'app/design/view';
-import { Button, Input, Modal } from 'app/design/controls';
+import { NeoButton, Input, Modal } from 'app/design/controls';
 import RbList from 'app/ui/molecules/form-controls/radio-list';
 import Field from './_field';
 import { useFormField } from 'app/lib/form/use-form-field';
@@ -91,12 +91,14 @@ export default function FormFieldPhone(props) {
     return (
         <Field {...props} value={field.value} focused={focused} isAdaptiveLabel={isAdaptiveLabel}>
             <Row className="gap-x-2">
-                <Button
-                    size="lg"
-                    startDecorator={
+                <NeoButton
+                    controlSize="large"
+                    image={
                         <CountryFlag isoCode={selectedCountry.value} size={24} />
                     }
-                    title={selectedCountry.dialCode}
+                    label={selectedCountry.dialCode}
+                    accessibilityLabel={t('Country code') + ' ' + selectedCountry.dialCode}
+                    expoUI={false}
                     onPress={() => setModalVisible(true)}
                 />
                 <Input

+ 4 - 4
neo/packages/app/components/form-fields/polls.js

@@ -1,6 +1,6 @@
 import Field, { getValidationRules } from './_field';
 import { fetcher } from 'app/lib/fetcher';
-import { Button, Modal } from "app/design/controls";
+import { Modal, NeoButton } from "app/design/controls";
 import { useState, useEffect, useCallback } from 'react';
 import Form from 'app/components/elements/form';
 import useFetchForm from 'app/lib/hooks/use-fetch-form'
@@ -82,9 +82,9 @@ export default function FormFieldPolls(props) {
             {pollSource && pollSource.map((item, index) => {
                 return <PollItem results_url='/api.php?r=bx_timeline/get_block_poll_results'  disabled = {true} onDelete={() => { deletePoll(props.request_remove, item) }} key={"att" + index} data={item} showTitle={true} />
             })}
-             {!props.hide_button && <Button
-                className="mt-4"
-                title={t('Add Poll')}
+             {!props.hide_button && <NeoButton
+                classNames={{ root: 'mt-4' }}
+                label={t('Add Poll')}
                 onPress={() => {
                     emitter.emit(EVENTS.fieldPolls(name), { action: 'add' });
                 }}

+ 4 - 5
neo/packages/app/components/form-fields/select.js

@@ -1,7 +1,7 @@
 import Field, { getValidationRules } from './_field';
 import Dropdown from 'app/ui/atoms/dropdown'
 import { Row } from 'app/design/view'
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import { useFormField } from 'app/lib/form/use-form-field';
 
 export default function FormFieldSelect(props) {
@@ -23,11 +23,10 @@ export default function FormFieldSelect(props) {
             <Field {...props}>
                 <Row className="gap-x-2">
                     {values.map((item) => (
-                        <Button
+                        <NeoButton
                             key={item.value}
-                            variant="outline"
-                            pressed={field.value == item.value}
-                            title={item.label}
+                            selected={field.value == item.value}
+                            label={item.label}
                             onPress={() => {
                                 setValueF(item.value);
                             }}

+ 8 - 7
neo/packages/app/components/form-fields/selector.js

@@ -1,7 +1,7 @@
 import Field from './_field';
 import { View, Row } from 'app/design/view'
 import { useState, useMemo, useCallback } from 'react';
-import { Button, Modal, NeoButton } from "app/design/controls";
+import { Modal, NeoButton } from "app/design/controls";
 import ChkList from 'app/ui/molecules/form-controls/checkbox-list';
 import { Text } from 'app/design/typography';
 import { useTranslation } from 'react-i18next';
@@ -70,14 +70,15 @@ export default function FormFieldSelector(props) {
     }, [field.value, valuesList]);
 
     const header = <Row className=' w-full justify-between items-center'>
-        <View><Button onPress={() => { setIsModal(null) }} variant='outline' rounded startDecorator="X" /></View>
+        <View><NeoButton onPress={() => { setIsModal(null) }} style="glass" controlSize="regular" borderShape="circle" image="X" accessibilityLabel={t('Close')} /></View>
         <View className='w-full flex-auto items-center justify-center'><Text className="text-muted-foreground  text-xl font-bold">{'Choose ' + props.caption}</Text></View>
         <View >
-            <Button
-                startDecorator="Check"
-                variant="primary"
-                size="base"
-                rounded
+            <NeoButton
+                image="Check"
+                style="glassProminent"
+                controlSize="regular"
+                borderShape="circle"
+                accessibilityLabel={t('Done')}
                 onPress={() => { setFormValue(value2) }}
             />
         </View>

+ 2 - 2
neo/packages/app/components/form-fields/stripe-connect.js

@@ -1,5 +1,5 @@
 import Field from './_field';
-import { Button } from 'app/design/controls';
+import { NeoButton } from 'app/design/controls';
 import { fetcher } from 'app/lib/fetcher';
 import { Row } from 'app/design/view'
 import { useRouter, redirectTo } from 'app/lib/hooks/router'
@@ -25,7 +25,7 @@ export default function FormFieldText(props) {
             <Msg onVisible={showMsg} title={showMsg} handleOk={() => { setShowMsg(false) }} />
             <Row className="gap-x-2">
                 {props.content.map((item, index) => (
-                    <Button key={`btn${index}`} onPress={() => { onClick(item.callback) }} title={item.title} />
+                    <NeoButton key={`btn${index}`} label={item.title} onPress={() => { onClick(item.callback) }} />
                 ))}
             </Row>
         </Field>

+ 5 - 6
neo/packages/app/components/form-fields/visibility.js

@@ -1,7 +1,7 @@
 import { useState } from 'react';
 import Field, { getValidationRules } from './_field';
 import { useFormContext } from 'react-hook-form';
-import { Button, NeoButton, Modal, Hidden } from 'app/design/controls'
+import { NeoButton, Modal, Hidden } from 'app/design/controls'
 import { getVisibilityValues } from './select';
 import { visibilityById } from 'app/lib/util';
 import RbList from 'app/ui/molecules/form-controls/radio-list';
@@ -134,7 +134,7 @@ export default function FormFieldVisibility(props) {
                 <Text className="text-secondary-foreground text-2xl font-bold tracking-tight text-center">{selectedItem?.label}</Text>
             </View>
             <View>
-                <Button variant="secondary" size="base" rounded startDecorator="ArrowLeft" onPress={() => setSubKind(null)} />
+                <NeoButton style="glass" controlSize="regular" borderShape="circle" image="ArrowLeft" accessibilityLabel={t('Back')} onPress={() => setSubKind(null)} />
             </View>
         </Row>
     );
@@ -147,10 +147,9 @@ export default function FormFieldVisibility(props) {
         <>
             <RbList values={filteredValues} setValue={handleValueChange} selectedValue={field.value} />
             <View className="flex-row justify-end pt-3 mt-3 border-t border-border/60 ">
-                <Button
-                    title={t('Done')}
-                    size="base"
-                    variant="primary"
+                <NeoButton
+                    style="borderedProminent"
+                    label={t('Done')}
                     disabled={needsSub(field.value) && selectedSubLabels.length == 0}
                     onPress={applyVisibility}
                 />

+ 7 - 8
neo/packages/app/components/form.js

@@ -3,7 +3,7 @@ import { useForm, FormProvider } from 'react-hook-form';
 import { getFormFieldByData, isFormResponseComplete, normalizeFormResponseData, updateFormDirtyState, isFormUnsavedCloseGuardSkipped, formHasPendingUploads, clearFormPendingUploads, formResponseHasFieldErrors, FILES_FIELD_MIRRORS } from 'app/lib/form/form-helpers'
 import { getDefaultPasteStorageKey } from 'app/components/form-fields/media-attachments'
 import { markFormBaseline } from 'app/lib/form/use-form-field'
-import { getFormInitialValues } from 'app/lib/form/field-initial-values'
+import { getFormInitialValues } from 'app/lib/form/form-initial-values'
 import { View, Row } from 'app/design/view'
 import { components } from 'app/components/registry';
 import { FeedbackHaptics, appSetting, isNumeric, isObjectsEqual } from 'app/lib/util';
@@ -12,7 +12,7 @@ import { Platform } from 'react-native';
 import emitter, { EVENTS } from 'app/context/emitter';
 import { FormInstanceProvider } from 'app/context/form-instance';
 import useDebounce from 'app/lib/hooks/use-debounce'
-import { Button } from 'app/design/controls';
+import { NeoButton } from 'app/design/controls';
 import useFetchForm from 'app/lib/hooks/use-fetch-form'
 import { FormError } from 'app/components/form-fields/_field'
 import { useTranslation } from 'react-i18next'
@@ -654,12 +654,11 @@ export default function Form({
                     <View className={`${layout === 'hor' ? 'flex-row gap-x-4 items-center w-full' : 'w-full gap-4'}`}>
                         {inputs}
                         {(isAutoChange) && <Row className={`items-center justify-between  ${layout === 'hor' ? ' ' : ' '} `}>
-                            {(stableStringify(defaultFormValues) != stableStringify(currentFormValues)) && <Button
-                                title={t('Reset Filters')}
-                                startDecorator='X'
-                                size='sm'
-                                fullWidth
-                                variant='secondary'
+                            {(stableStringify(defaultFormValues) != stableStringify(currentFormValues)) && <NeoButton
+                                controlSize="small"
+                                width="fill"
+                                image="X"
+                                label={t('Reset Filters')}
                                 onPress={() => {
                                     if (isWeb) {
                                         const url = new URL(window.location.href);

+ 15 - 2
neo/packages/app/components/menu-items/submenu-share.js

@@ -6,6 +6,7 @@ import { setClipboard, appSetting } from 'app/lib/util';
 import Msg from 'app/ui/molecules/dialogs/msg';
 import { useState } from 'react';
 import MenuItemActionBase from 'app/lib/menu-item-helpers';
+import { toControlSize } from 'app/design/controls';
 import { useTranslation } from 'react-i18next';
 
 export default function MenuItemSubmenuShare(oProps) {
@@ -62,6 +63,12 @@ export default function MenuItemSubmenuShare(oProps) {
         };
     });
 
+    // The trigger owns the press, so it carries the button's focus ring and 44px hit area.
+    const triggerSize = toControlSize(oProps.params?.button_size) ?? 'small';
+    const triggerHostClass = triggerSize === 'mini'
+        ? 'u-neo-btn-link hit-area-8'
+        : (triggerSize === 'small' ? 'u-neo-btn-link hit-area-4' : 'u-neo-btn-link');
+
     if (aSubmenuItems.length === 1) {
         return <></>;
     }
@@ -69,8 +76,14 @@ export default function MenuItemSubmenuShare(oProps) {
     return (
         <>
             <Msg onVisible={showMsg} title={showMsg} handleOk={() => { setShowMsg(false); }} />
-            <Pressable className={'flex-auto' + (oProps.params?.no_gap_between_buttons === true ? (oProps.params?.button_full_width ? '  px-0 ' : '  pr-2 ') : '')} onPress={(event) => { event.preventDefault(); }}>
-                <DropdownMenu items={aSubmenuItems} onSelect={handleMenuManageSelect}>
+            {/* Click guard only (keeps the click from reaching an enclosing link); the dropdown trigger is the button. */}
+            <Pressable className={'flex-auto' + (oProps.params?.no_gap_between_buttons === true ? (oProps.params?.button_full_width ? '  px-0 ' : '  pr-2 ') : '')} role="none" tabIndex={-1} onPress={(event) => { event?.preventDefault?.(); }}>
+                <DropdownMenu
+                    items={aSubmenuItems}
+                    onSelect={handleMenuManageSelect}
+                    triggerAccessibilityLabel={oProps?.title || t('Share')}
+                    triggerClassName={triggerHostClass}
+                >
                     <MenuItemActionBase
                         item={oProps}
                         title={oProps?.title ? oProps.title : ''}

+ 13 - 9
neo/packages/app/components/menu-items/unit.js

@@ -1,8 +1,11 @@
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import { components } from 'app/components/registry';
+import { useUnitActionHugs } from 'app/components/units/unit-action-width';
 
 
 export default function MenuItemSubmenu({ menuItem, isPrimary }) {
+    // Profile rows on phones: a compact button on the right (unit-action-width.js).
+    const hugs = useUnitActionHugs();
     if (!menuItem) return null;
     if (menuItem.data?.type) {
         const Element = components['molecule'][String(menuItem.data.type)]
@@ -13,7 +16,7 @@ export default function MenuItemSubmenu({ menuItem, isPrimary }) {
                 params: {
                     ...menuItem.data?.params,
                     button_rounded: menuItem.data?.params?.button_rounded || false,
-                    button_full_width: menuItem.data?.params?.button_full_width ?? true,
+                    button_full_width: hugs ? false : (menuItem.data?.params?.button_full_width ?? true),
                     only_icon: menuItem.data?.params?.only_icon || false,
                     on_done: (sAction, oData) => {
                         // Handle action completion
@@ -24,13 +27,14 @@ export default function MenuItemSubmenu({ menuItem, isPrimary }) {
         }
     } else {
         return (
-            <Button
-                variant={isPrimary ? "primary" : "secondary"}
-                size="sm"
-                fullWidth
-                title={menuItem.title}
-                className="my-auto"
-                startDecorator={menuItem.icon || false}
+            <NeoButton
+                style={isPrimary ? 'borderedProminent' : undefined}
+                controlSize="small"
+                width={hugs ? undefined : 'fill'}
+                image={menuItem.icon || undefined}
+                label={menuItem.title || undefined}
+                classNames={{ root: 'my-auto' }}
+                expoUI={false}
                 onPress={menuItem.onPress}
             />
         );

+ 23 - 22
neo/packages/app/components/menu.js

@@ -3,35 +3,30 @@ import { appSetting, menuItemsByName } from 'app/lib/util';
 import { components } from 'app/components/registry';
 import { useCurrentUser } from 'app/context/user'
 import { useMemo, memo, useContext } from "react";
-import { Button, NeoButton } from 'app/design/controls';
+import { NeoButton, toNeoStyle, toControlSize } from 'app/design/controls';
 import DynamicMenu from 'app/components/nav/menu-dynamic';
 import { DropdownMenuOpenContext } from 'app/ui/atoms/dropdown-menu';
 import { Platform } from 'react-native'
 import { useOpenModalByUrl } from 'app/context/jotai/modal';
+import { useTranslation } from 'react-i18next';
+
+// DropdownMenu trigger host classes by the inner NeoButton's controlSize (static literals for Tailwind/Uniwind).
+const TRIGGER_HOST_CLASS = { mini: 'u-neo-btn-link hit-area-8', small: 'u-neo-btn-link hit-area-4' };
 
 const ButtonEx = memo(({ visibleItemsCount, params }) => {
     const isOpen = useContext(DropdownMenuOpenContext) ?? false;
     return (
         <View key="btn" className="">
-            {params.button_style ? (
-                <NeoButton
-                    image="Ellipsis"
-                    style={params.button_style}
-                    controlSize={params.button_size}
-                    borderShape={params.button_border_shape}
-                    // Parent DropdownPopup Pressable owns onPress; interactive
-                    // enables hover/focus visuals on the NeoButton surface.
-                    interactive
-                    selected={isOpen}
-                />
-            ) : (
-                <Button
-                    size={params.button_size}
-                    variant={params.button_variant || 'default'}
-                    startDecorator="Ellipsis"
-                    rounded={params.button_rounded}
-                />
-            )}
+            <NeoButton
+                image="Ellipsis"
+                style={params.button_style || toNeoStyle(params.button_variant || 'default').style}
+                controlSize={toControlSize(params.button_size)}
+                borderShape={params.button_border_shape ?? (params.button_rounded === true || params.button_rounded === 'true' ? 'circle' : undefined)}
+                // Parent DropdownPopup Pressable owns onPress; interactive
+                // enables hover/focus visuals on the NeoButton surface.
+                interactive
+                selected={isOpen}
+            />
         </View>
     );
 });
@@ -70,6 +65,7 @@ export default function ElementMenu(oProps) {
     const isWeb = Platform.OS == 'web'
     const { currentUser } = useCurrentUser();
     const openModalByUrl = useOpenModalByUrl();
+    const { t } = useTranslation();
     /*
      * Display type specified in menu can be overwritten with display type specified in item.
      * default display types: mixed, link, button, element, etc.
@@ -95,10 +91,13 @@ export default function ElementMenu(oProps) {
 
     //--- show vertical
     const bShowVertical = oProps?.params?.showVertical === true;
-
-    sClassName += bShowVertical ? ' flex-col items-center gap-y-2 w-full ' : ' flex-row  ';
     const oParams = oProps?.params || {};
 
+    // Horizontal gap keeps neighbouring buttons' 44px hit areas apart (mini needs 8px, small 4px).
+    // `menu_gap` is a class literal from settings; items with their own `menu_item_spacing` get none.
+    const sRowGap = oParams.menu_gap ?? (oParams.menu_item_spacing != null ? '' : 'gap-x-2');
+    sClassName += bShowVertical ? ' flex-col items-center gap-y-2 w-full ' : ` flex-row ${sRowGap} `;
+
 
     //--- horizontal menu items alignment
     const sAlignItems = oProps.alignItems || oParams.justify_items || 'between';
@@ -271,5 +270,7 @@ export default function ElementMenu(oProps) {
         menuClasses={sClassName}
         isButtonOutside={false}
         menuExClasses="mr-auto ml-3 sm:ml-4 items-end gap-y-2"
+        triggerAccessibilityLabel={t('More options')}
+        triggerClassName={TRIGGER_HOST_CLASS[toControlSize(oProps?.params?.button_size)] ?? 'u-neo-btn-link'}
     />
 }

+ 155 - 13
neo/packages/app/components/nav/footer.web.js

@@ -4,21 +4,25 @@ import { Text } from 'app/design/typography'
 import { appSetting } from 'app/lib/util'
 import { useCurrentUser } from 'app/context/user';
 import Profile from 'app/ui/molecules/profile/profile';
-import { usePathname } from 'app/lib/hooks/router';
+import { useLocalSearchParams, usePathname } from 'app/lib/hooks/router';
 import { getFriendsCounter } from 'app/customization/functions';
 import { Icon } from 'app/ui/atoms/icon'
 import { useTranslation } from 'react-i18next';
-import { useFooter, useSetFooterHeight } from 'app/context/jotai/layout';
+import { useFooter, useFooterHeight, useSetFooterHeight } from 'app/context/jotai/layout';
 import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
 import { useSound } from 'app/lib/hooks/use-sound';
 import { isTabBarLabelsEnabled } from 'app/lib/util';
 import DropdownMenu, { DropdownMenuOpenContext } from 'app/ui/atoms/dropdown-menu';
+import { NeoButton } from 'app/design/controls';
+import { cn } from 'app/lib/util';
+import { OperatorAgentPanel, useOperatorAgentData } from 'app/ui/molecules/system/operator-agent';
 import {
     splitTabBarItems,
     buildTabBarMoreMenuItems,
     inferTabIndexFromUrl,
     isTabBarUrlActive,
     resolveTabUrl,
+    splitProfileMoreMenu,
 } from 'app/components/nav/tabs/tab-menu';
 import {
     consumeSkipTabInfer,
@@ -229,9 +233,38 @@ function MenuBottomItem({ link, tabKey, rootUrl, title, badge, icon, isActive, i
     );
 }
 
+/** The viewer's avatar, sized like a tab icon. */
+function TabAvatar({ currentUser, url }) {
+    return (
+        <View className="w-6 h-6">
+            <Profile
+                {...currentUser}
+                url_avatar={currentUser?.avatar}
+                url={url}
+                showLinks={false}
+                displayType="unit_wo_info"
+                displaySize="xs"
+            />
+        </View>
+    );
+}
+
+/**
+ * Last tab when the menu collapses into More. Like the native tab bars: a
+ * `{profile}` item first in More makes the tab the viewer's avatar and heads
+ * the menu (avatar, name, "View profile" and the operator agent button); if
+ * the profile is its only link and there is no agent, the avatar is a plain
+ * profile tab. Below `lg` the header's account item (and its Agent button) is
+ * hidden, so the agent opens from here.
+ */
 function MoreBottomItem({ visibleTabs, overflowTabs, pathname, currentUser }) {
     const { t } = useTranslation();
-    const items = useMemo(
+    const params = useLocalSearchParams();
+    const footerHeight = useFooterHeight();
+    const [menuOpen, setMenuOpen] = useState(false);
+    const [agentOpen, setAgentOpen] = useState(false);
+    const agentData = useOperatorAgentData(!!currentUser);
+    const allItems = useMemo(
         () => buildTabBarMoreMenuItems({
             visible: visibleTabs,
             overflow: overflowTabs,
@@ -242,9 +275,29 @@ function MoreBottomItem({ visibleTabs, overflowTabs, pathname, currentUser }) {
         }),
         [visibleTabs, overflowTabs, currentUser, t, pathname]
     );
+    const { header: profileItem, items: profileMenuItems, profileOnly } = splitProfileMoreMenu({
+        items: allItems,
+        overflow: overflowTabs,
+        hasAgent: !!agentData,
+    });
+    const items = profileItem ? profileMenuItems : allItems;
     const isOverflowActive = overflowTabs.some((tab) =>
         isTabBarUrlActive(pathname, resolveTabUrl(tab, currentUser))
     );
+    const avatarUser = profileItem ? currentUser : null;
+
+    if (profileItem && profileOnly) {
+        return (
+            <Link href={profileItem.link} className="group flex-1 min-w-0" alt={profileItem.title}>
+                <MoreBottomTrigger isOverflowActive={isOverflowActive} avatarUser={avatarUser} title={profileItem.title} />
+            </Link>
+        );
+    }
+
+    const toggleAgent = () => {
+        setMenuOpen(false);
+        setAgentOpen((open) => !open);
+    };
 
     return (
         <View className="flex-1 min-w-0">
@@ -252,16 +305,101 @@ function MoreBottomItem({ visibleTabs, overflowTabs, pathname, currentUser }) {
                 mode="popup"
                 showOnTop
                 openOnFocus={false}
+                open={menuOpen}
+                onOpenChange={setMenuOpen}
                 items={items}
                 triggerClassName="group flex-1 w-full h-full min-w-0"
+                header={profileItem || agentData ? (
+                    <MoreMenuHeader
+                        profileItem={profileItem}
+                        currentUser={currentUser}
+                        agentActive={agentOpen}
+                        onProfilePress={() => setMenuOpen(false)}
+                        onAgentPress={agentData ? toggleAgent : null}
+                    />
+                ) : null}
             >
-                <MoreBottomTrigger isOverflowActive={isOverflowActive} />
+                <MoreBottomTrigger
+                    isOverflowActive={isOverflowActive}
+                    avatarUser={avatarUser}
+                    title={profileItem ? profileItem.title : t('More')}
+                />
             </DropdownMenu>
+            {agentData ? (
+                <OperatorAgentPanel
+                    data={agentData}
+                    open={agentOpen}
+                    onClose={() => setAgentOpen(false)}
+                    bottomOffset={footerHeight + 8}
+                    params={params}
+                />
+            ) : null}
         </View>
     );
 }
 
-function MoreBottomTrigger({ isOverflowActive }) {
+/** Top of the More menu: the profile row (opens the profile) and the Agent button. */
+function MoreMenuHeader({ profileItem, currentUser, agentActive, onProfilePress, onAgentPress }) {
+    const { t } = useTranslation();
+    return (
+        <Row className="items-center gap-2 pb-1.5 mb-1.5 border-b border-border">
+            {profileItem ? (
+                <Link
+                    href={profileItem.link}
+                    onClick={onProfilePress}
+                    className="flex-1 min-w-0"
+                    alt={profileItem.title}
+                >
+                    <Row
+                        className={cn(
+                            'items-center gap-3 rounded-lg p-2 web:hover:bg-muted/50 web:cursor-pointer',
+                            profileItem.selected && 'bg-accent'
+                        )}
+                    >
+                        <View className="w-11 h-11">
+                            <Profile
+                                {...currentUser}
+                                url_avatar={currentUser?.avatar}
+                                url={profileItem.link}
+                                showLinks={false}
+                                displayType="unit_wo_info"
+                                displaySize="base"
+                            />
+                        </View>
+                        <View className="flex-1 min-w-0">
+                            <Text
+                                numberOfLines={1}
+                                className={cn(
+                                    'text-sm font-semibold',
+                                    profileItem.selected ? 'text-accent-foreground' : 'text-foreground'
+                                )}
+                            >
+                                {currentUser?.display_name || profileItem.title}
+                            </Text>
+                            <Text numberOfLines={1} className="text-xs text-muted-foreground">
+                                {t('View profile')}
+                            </Text>
+                        </View>
+                    </Row>
+                </Link>
+            ) : (
+                <View className="flex-1" />
+            )}
+            {onAgentPress ? (
+                <NeoButton
+                    style="borderless"
+                    borderShape="circle"
+                    image="Sparkles"
+                    selected={agentActive}
+                    accessibilityLabel={t(agentActive ? 'operator_agent_close' : 'operator_agent_open')}
+                    onPress={onAgentPress}
+                />
+            ) : null}
+        </Row>
+    );
+}
+
+function MoreBottomTrigger({ isOverflowActive, avatarUser, title }) {
     const { t } = useTranslation();
     const isOpen = useContext(DropdownMenuOpenContext) ?? false;
     const isActive = isOverflowActive || isOpen;
@@ -271,19 +409,23 @@ function MoreBottomTrigger({ isOverflowActive }) {
             className={`w-full my-auto items-center rounded-full min-h-12 justify-center p-1.5 text-center gap-1.5 web:hover:bg-muted/50 ${isActive ? 'bg-accent/60' : ''}`}
         >
             <View className="items-center justify-center">
-                <Icon
-                    icon="ChevronsUpDown"
-                    size={24}
-                    className={isActive
-                        ? 'text-accent-foreground'
-                        : 'text-secondary-foreground web:group-hover:text-foreground'}
-                />
+                {avatarUser ? (
+                    <TabAvatar currentUser={avatarUser} url={avatarUser.url} />
+                ) : (
+                    <Icon
+                        icon="ChevronsUpDown"
+                        size={24}
+                        className={isActive
+                            ? 'text-accent-foreground'
+                            : 'text-secondary-foreground web:group-hover:text-foreground'}
+                    />
+                )}
             </View>
             {isTabBarLabelsEnabled() ? (
                 <Text
                     className={`web:group-hover:text-accent-foreground text-[11px] tracking-tight leading-none font-medium whitespace-nowrap ${isActive ? 'text-accent-foreground' : 'text-secondary-foreground web:group-hover:text-foreground'}`}
                 >
-                    {t('More')}
+                    {title || t('More')}
                 </Text>
             ) : null}
         </View>

+ 9 - 12
neo/packages/app/components/nav/menu-account.js

@@ -1,5 +1,5 @@
 import { Row, Pressable } from 'app/design/view'
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import { useCurrentUser } from 'app/context/user'
 import {
     appSetting,
@@ -206,17 +206,14 @@ export default function MenuAccount({ buttonProps, children }) {
                     }
                     if (item.link == '{switch_profile_selector}') {
                         sTitle = (
-                            <Row className="w-full items-center flex-auto my-1">
-                                <ProfileSwitcher className="w-full" hideTitle={true}>
-                                    <Button
-                                        variant="secondary"
-                                        fullWidth
-                                        align="center"
-                                        solid
-                                        size="sm"
-
-                                        startDecorator="CircleUserRound"
-                                        title={t('See all profiles')}
+                            <Row className="w-full items-center flex-auto my-1 px-1.5">
+                                <ProfileSwitcher className="w-full u-neo-btn-link hit-area-4" hideTitle={true} accessibilityLabel={t('See all profiles')}>
+                                    <NeoButton
+                                        controlSize="small"
+                                        width="fill"
+                                        image="CircleUserRound"
+                                        label={t('See all profiles')}
+                                        interactive
                                     />
                                 </ProfileSwitcher>
                             </Row>

+ 8 - 1
neo/packages/app/components/nav/menu-dynamic.js

@@ -17,7 +17,12 @@ function getFixedVisibleCount(persistent, allowZeroPersistant, isDesktop) {
     );
 }
 
-export default function DynamicMenu({ name, isFixedCount, MenuItem, MenuItemEx, ButtonEx, allowZeroPersistant = false, items, menuClasses, menuExClasses, isButtonOutside, offsetWidth = 50, persistent = 0 }) {
+/**
+ * `triggerClassName` / `triggerAccessibilityLabel` go to the overflow menu's
+ * trigger (the Pressable around `ButtonEx`): its accessible name, and the
+ * `u-neo-btn-link hit-area-*` host classes for a passive NeoButton inside.
+ */
+export default function DynamicMenu({ name, isFixedCount, MenuItem, MenuItemEx, ButtonEx, allowZeroPersistant = false, items, menuClasses, menuExClasses, isButtonOutside, offsetWidth = 50, persistent = 0, triggerClassName, triggerAccessibilityLabel }) {
     const isDesktop = useIsDesktop();
     const { setBottomSheetData } = useBottomSheetData();
     const openModalByUrl = useOpenModalByUrl();
@@ -99,6 +104,8 @@ export default function DynamicMenu({ name, isFixedCount, MenuItem, MenuItemEx,
             }}
             mode={isWeb ? "popup" : ""}
             variant='nopad'
+            triggerClassName={triggerClassName}
+            triggerAccessibilityLabel={triggerAccessibilityLabel}
             items={items.slice(visibleItemsCount).map((aItem, iKey) => ({
                 id: 'menu-' + iKey,
                 link: aItem.link,

+ 93 - 13
neo/packages/app/components/nav/tabs/expoui-tabs.js

@@ -28,6 +28,7 @@ import {
     isExternalTabUrl,
     isProfileTab,
     resolveTabUrl,
+    splitProfileMoreMenu,
     splitTabBarItems,
     tabBarMoreSeparator,
     trimMenuSeparators,
@@ -93,7 +94,7 @@ function getOverflowBadgeLabel(currentUser, overflow = []) {
 /** UITabBar draws `src` icons square at intrinsic size — resize + circle-mask once. */
 const TAB_AVATAR_PT = 28;
 /** Avatar in the More sheet's profile header. */
-const SHEET_AVATAR_PT = 40;
+const SHEET_AVATAR_PT = 44;
 const avatarIconCache = new Map();
 
 /** Absolute avatar URL, resized by the native images proxy when configured. */
@@ -212,7 +213,56 @@ function MoreTabTarget({ itemCount, onPress }) {
     );
 }
 
-/** Transparent hit target over the native More tab — opens the same popup as JS tabs (not with the iOS sheet). */
+/**
+ * Android: the bottom bar tints every tab icon with one color list, so the
+ * avatar can't be a native tab icon (it would come out as a flat tinted disc).
+ * It is drawn over its tab instead, centered on the Material 3 icon slot —
+ * 28dp below the bar's top edge, with or without labels. With `onPress` the
+ * whole tab column is the More target (the native tab is `disabled` while it
+ * opens the sheet); without, touches go through to the native tab.
+ */
+const ANDROID_TAB_ICON_CENTER = 28;
+
+function AndroidTabOverlay({ slotIndex, slotCount, barHeight, avatar, onPress, accessibilityLabel }) {
+    const { width } = useWindowDimensions();
+    const slot = width / slotCount;
+    return (
+        <Pressable
+            onPress={onPress}
+            disabled={!onPress}
+            pointerEvents={onPress ? 'auto' : 'none'}
+            accessibilityRole={onPress ? 'button' : undefined}
+            accessibilityLabel={onPress ? accessibilityLabel : undefined}
+            style={{
+                position: 'absolute',
+                bottom: 0,
+                left: slotIndex * slot,
+                width: slot,
+                height: barHeight,
+                zIndex: 9999,
+                elevation: 24,
+            }}
+        >
+            {avatar ? (
+                <Image
+                    source={{ uri: avatar }}
+                    alt=""
+                    contentFit="cover"
+                    style={{
+                        position: 'absolute',
+                        top: ANDROID_TAB_ICON_CENTER - TAB_AVATAR_PT / 2,
+                        left: (slot - TAB_AVATAR_PT) / 2,
+                        width: TAB_AVATAR_PT,
+                        height: TAB_AVATAR_PT,
+                        borderRadius: TAB_AVATAR_PT / 2,
+                    }}
+                />
+            ) : null}
+        </Pressable>
+    );
+}
+
+/** Transparent hit target over the native More tab — opens the same popup as JS tabs (without a native sheet). */
 function NativeTabsMoreOverlay({
     visible,
     isShowTabs,
@@ -385,19 +435,23 @@ export default function ExpoUITabNavigator({
 
     const collapsed = overflow.length > 0;
     const moreRoot = overflow[0];
-    // iOS: a `{profile}` item heading the More list makes the More tab the
-    // user's avatar and the sheet's header (Linear style).
-    const profileMore = collapsed && isIos && isProfileTab(moreRoot);
+    // A `{profile}` item heading the More list makes the More tab the user's
+    // avatar and the sheet's header (Linear style); web's footer does the same.
+    const profileMore = collapsed && isProfileTab(moreRoot);
     const sheetExclude = appSetting('theme', 'expo_ui', 'tabs')?.more_sheet_exclude || [];
-    const sheetHeader = profileMore && hasNativeMoreSheet
-        ? overflowMenuItems.find((item) => item.tab === moreRoot) ?? null
-        : null;
-    const sheetItems = trimMenuSeparators(overflowMenuItems.filter(
-        (item) => item !== sheetHeader && !sheetExclude.includes(item.tab?.url)
-    ));
+    const profileMenu = splitProfileMoreMenu({
+        items: overflowMenuItems,
+        overflow,
+        hasAgent: !!agentData,
+        exclude: sheetExclude,
+    });
+    const sheetHeader = profileMore && hasNativeMoreSheet ? profileMenu.header : null;
+    const sheetItems = sheetHeader
+        ? profileMenu.items
+        : trimMenuSeparators(overflowMenuItems.filter((item) => !sheetExclude.includes(item.tab?.url)));
     // The profile is the only More link left: the avatar opens it directly
     // (unless the sheet is also where the operator agent opens).
-    const profileOnly = !!sheetHeader && !sheetItems.some((item) => item.isOverflow) && !agentData;
+    const profileOnly = !!sheetHeader && profileMenu.profileOnly;
     // Popup More menu (Android, iOS without the sheet): Agent heads the list,
     // shown selected while its chat is open.
     const popupMenuItems = agentData
@@ -430,6 +484,14 @@ export default function ExpoUITabNavigator({
     const moreTitle = profileMore ? t(moreRoot.title) : t('More');
     const overflowBadge = getOverflowBadgeLabel(currentUser, overflow);
 
+    // Android: avatar and More target drawn over the bar (see AndroidTabOverlay).
+    // Slots are the tabs the bar shows, plus More.
+    const barTabs = (collapsed ? visible : TabList).filter((tab) => tab.hide !== true);
+    const slotCount = barTabs.length + (collapsed ? 1 : 0);
+    const androidAvatar = isAndroid && currentUser?.avatar ? avatarImageUri(currentUser.avatar, TAB_AVATAR_PT) : null;
+    const avatarSlot = collapsed && profileMore ? barTabs.length : barTabs.findIndex(usesAvatarIcon);
+    const androidMoreTarget = isAndroid && isShowTabs && collapsed && hasNativeMoreSheet && !profileOnly;
+
     return (
         <View className="flex-1">
             <NativeTabs
@@ -504,7 +566,7 @@ export default function ExpoUITabNavigator({
                     </NativeTabs.Trigger>
                 ) : null}
             </NativeTabs>
-            {collapsed && hasNativeMoreSheet && isShowTabs && !profileOnly && !Platform.isPad ? (
+            {isIos && collapsed && hasNativeMoreSheet && isShowTabs && !profileOnly && !Platform.isPad ? (
                 <MoreTabTarget
                     itemCount={visible.filter((tab) => tab.hide !== true).length + 1}
                     onPress={handleMoreTabPress}
@@ -525,6 +587,24 @@ export default function ExpoUITabNavigator({
                     agentActive={agentOpen}
                 />
             ) : null}
+            {androidMoreTarget ? (
+                <AndroidTabOverlay
+                    slotIndex={barTabs.length}
+                    slotCount={slotCount}
+                    barHeight={tabBarHeight}
+                    avatar={profileMore ? androidAvatar : null}
+                    onPress={handleMoreTabPress}
+                    accessibilityLabel={moreTitle}
+                />
+            ) : null}
+            {isAndroid && isShowTabs && androidAvatar && avatarSlot >= 0 && !(androidMoreTarget && avatarSlot === barTabs.length) ? (
+                <AndroidTabOverlay
+                    slotIndex={avatarSlot}
+                    slotCount={slotCount}
+                    barHeight={tabBarHeight}
+                    avatar={androidAvatar}
+                />
+            ) : null}
             {collapsed && !hasNativeMoreSheet ? (
                 <NativeTabsMoreOverlay
                     visible={visible}

+ 9 - 0
neo/packages/app/components/nav/tabs/index.js

@@ -73,7 +73,16 @@ const BOOTSTRAP_TIMEOUT_MS = 5000;
 /** AbortController is unreliable on some Android RN builds — race a hard timer. */
 const BOOTSTRAP_HARD_TIMEOUT_MS = 5500;
 
+/**
+ * `<scheme>://expo-development-client/?url=<metro>` — what `expo run:ios` /
+ * `run:android` open to point a dev client at Metro. Not an app route: without
+ * a dev launcher it reaches us as the initial URL, and its `?url=` query would
+ * become the home tab's page ("/?url=http://…:8081"), which loads nothing.
+ */
+const DEV_CLIENT_URL = /^[a-z][\w.+-]*:\/\/expo-development-client(?:[/?]|$)/i;
+
 function processUrl(url, router, currentUser, TabList) {
+    if (DEV_CLIENT_URL.test(url)) return;
     if (currentUser?.id) {
         const LinksForTabs = buildTabUrlIndex(TabList);
 

+ 222 - 0
neo/packages/app/components/nav/tabs/more-sheet.android.tsx

@@ -0,0 +1,222 @@
+import { useEffect, useRef, useState } from 'react';
+import { Pressable } from 'react-native';
+import { useTranslation } from 'react-i18next';
+import {
+    Column,
+    HorizontalDivider,
+    Host,
+    Image,
+    ModalBottomSheet,
+    RNHostView,
+    Row,
+    Spacer,
+    Text,
+    type ModalBottomSheetRef,
+} from '@expo/ui/jetpack-compose';
+import {
+    background,
+    clickable,
+    clip,
+    fillMaxWidth,
+    padding,
+    Shapes,
+    size,
+    weight,
+} from '@expo/ui/jetpack-compose/modifiers';
+import { useNativeTokenColor } from 'app/design/controls/neo-button/native-style-colors';
+import { nativeFonts } from 'app/design/fonts/native-fonts';
+import { appSetting } from 'app/lib/util';
+import { Icon } from 'app/ui/atoms/icon';
+import type { MoreSheetItem, MoreSheetProfile, NativeMoreSheetProps } from 'app/components/nav/tabs/more-sheet.types';
+
+/**
+ * Android: More opens a Material 3 modal bottom sheet (Jetpack Compose via
+ * Expo UI) — the counterpart of the iOS Liquid Glass sheet. Same content: a
+ * `{profile}` header with the agent button, then the tab routes, a divider and
+ * the More routes. `expo_ui.tabs.more_sheet`.
+ */
+export const hasNativeMoreSheet = !!appSetting('theme', 'expo_ui', 'tabs')?.more_sheet;
+
+const ICON_DP = 24;
+const AVATAR_DP = 44;
+const AGENT_BUTTON_DP = 44;
+// Compose resolves the family through React Native's font manager ('Inter'),
+// or the platform default for the system font.
+const FONT_FAMILY = nativeFonts.main === 'sans-serif' ? 'default' : nativeFonts.main;
+
+/**
+ * The app's own Lucide icon, hosted in the sheet. Compose's vector loader fills
+ * the stroke-only Lucide drawables, so they can't be Compose `Icon`s. The host
+ * sends touches inside it to JS, past the row's Compose `clickable`, so the
+ * icon takes the row's press itself.
+ */
+function RowIcon({ icon, color, sizeDp = ICON_DP, label, onPress }: { icon?: string; color?: string; sizeDp?: number; label?: string; onPress?: () => void }) {
+    if (!icon) return null;
+    return (
+        <RNHostView matchContents>
+            <Pressable
+                collapsable={false}
+                onPress={onPress}
+                disabled={!onPress}
+                accessibilityLabel={label}
+                style={{ width: sizeDp, height: sizeDp }}
+            >
+                <Icon icon={icon} width={sizeDp} height={sizeDp} color={color} />
+            </Pressable>
+        </RNHostView>
+    );
+}
+
+/**
+ * Sheet colors as hex: `fill` / `ink` = the selected tab's indicator and ink
+ * (`native_tabs.selected`, as on the Android bar); `muted` = secondary text;
+ * `rest` / `restInk` = the agent button at rest.
+ */
+type SheetColors = { fill?: string; ink?: string; muted?: string; rest?: string; restInk?: string };
+
+function rowModifiers(selected: boolean | undefined, colors: SheetColors, onPress: () => void, vertical: number) {
+    return [
+        fillMaxWidth(),
+        clip(Shapes.RoundedCorner(28)),
+        ...(selected && colors.fill ? [background(colors.fill)] : []),
+        clickable(onPress),
+        padding(16, vertical, 16, vertical),
+    ];
+}
+
+function SheetRow({ item, selectedColors, onPress }: { item: MoreSheetItem; selectedColors: SheetColors; onPress: () => void }) {
+    const ink = item.selected ? selectedColors.ink : undefined;
+    return (
+        <Row
+            horizontalArrangement={{ spacedBy: 16 }}
+            verticalAlignment="center"
+            modifiers={rowModifiers(item.selected, selectedColors, onPress, 14)}
+        >
+            <RowIcon icon={item.icon} color={ink ?? selectedColors.restInk} onPress={onPress} />
+            <Text color={ink} style={{ fontSize: 16, fontWeight: '500', fontFamily: FONT_FAMILY }} maxLines={1}>
+                {item.title ?? ''}
+            </Text>
+        </Row>
+    );
+}
+
+/** Avatar and name heading the sheet — opens the profile. */
+function ProfileHeader({ profile, selectedColors, onPress }: { profile: MoreSheetProfile; selectedColors: SheetColors; onPress: () => void }) {
+    const { t } = useTranslation();
+    const { item, name, avatar } = profile;
+    const ink = item.selected ? selectedColors.ink : undefined;
+    return (
+        <Row
+            horizontalArrangement={{ spacedBy: 14 }}
+            verticalAlignment="center"
+            modifiers={[weight(1), ...rowModifiers(item.selected, selectedColors, onPress, 8)]}
+        >
+            {avatar ? (
+                <Image
+                    source={{ uri: avatar }}
+                    contentScale="crop"
+                    contentDescription={null}
+                    modifiers={[size(AVATAR_DP, AVATAR_DP), clip(Shapes.Circle)]}
+                />
+            ) : (
+                <RowIcon icon="CircleUserRound" sizeDp={AVATAR_DP} color={selectedColors.restInk} onPress={onPress} />
+            )}
+            <Column>
+                <Text color={ink} style={{ fontSize: 17, fontWeight: '600', fontFamily: FONT_FAMILY }} maxLines={1}>
+                    {name || item.title || ''}
+                </Text>
+                <Text style={{ fontSize: 14, fontFamily: FONT_FAMILY }} color={selectedColors.muted} maxLines={1}>
+                    {t('View profile')}
+                </Text>
+            </Column>
+        </Row>
+    );
+}
+
+/**
+ * Icon-only Agent button on the right of the header row — toggles the operator
+ * agent. A muted circle at rest; the selected tab's indicator / ink while the
+ * chat is open.
+ */
+function AgentButton({ active, colors, onPress }: { active?: boolean; colors: SheetColors; onPress: () => void }) {
+    const { t } = useTranslation();
+    const fill = active ? colors.fill : colors.rest;
+    const ink = active ? colors.ink : colors.restInk;
+    return (
+        <Row
+            horizontalArrangement="center"
+            verticalAlignment="center"
+            modifiers={[
+                size(AGENT_BUTTON_DP, AGENT_BUTTON_DP),
+                clip(Shapes.Circle),
+                ...(fill ? [background(fill)] : []),
+                clickable(onPress),
+            ]}
+        >
+            <RowIcon icon="Sparkles" color={ink} sizeDp={22} label={t(active ? 'operator_agent_close' : 'operator_agent_open')} onPress={onPress} />
+        </Row>
+    );
+}
+
+export function NativeMoreSheet({ open, onOpenChange, items, onSelect, profile, onAgentPress, agentActive }: NativeMoreSheetProps) {
+    const tabsSelected = appSetting('theme', 'native_tabs', 'selected');
+    const colors: SheetColors = {
+        fill: useNativeTokenColor(tabsSelected?.indicator) as string | undefined,
+        ink: useNativeTokenColor(tabsSelected?.foreground) as string | undefined,
+        muted: useNativeTokenColor('text-muted-foreground') as string | undefined,
+        rest: useNativeTokenColor('bg-muted') as string | undefined,
+        restInk: useNativeTokenColor('text-foreground') as string | undefined,
+    };
+    const sheetRef = useRef<ModalBottomSheetRef>(null);
+
+    // The sheet is shown while mounted. When the parent closes it (a row was
+    // picked), animate it out first, then unmount.
+    const [mounted, setMounted] = useState(open);
+    if (open && !mounted) setMounted(true);
+    useEffect(() => {
+        if (open || !mounted) return;
+        let cancelled = false;
+        const hide = sheetRef.current?.hide() ?? Promise.resolve();
+        hide.finally(() => {
+            if (!cancelled) setMounted(false);
+        });
+        return () => {
+            cancelled = true;
+        };
+    }, [open, mounted]);
+
+    // Swipe down, back or a scrim tap: Compose has already hidden the sheet.
+    const handleDismiss = () => {
+        setMounted(false);
+        onOpenChange(false);
+    };
+
+    const hasHeader = !!profile || !!onAgentPress;
+    return (
+        // The sheet is a window of its own; its host only needs to be mounted.
+        <Host style={{ position: 'absolute', width: 0, height: 0 }}>
+            {mounted ? (
+                <ModalBottomSheet ref={sheetRef} onDismissRequest={handleDismiss} skipPartiallyExpanded>
+                    <Column modifiers={[padding(12, 0, 12, 24)]} verticalArrangement={{ spacedBy: 2 }}>
+                        {hasHeader ? (
+                            <Row verticalAlignment="center" horizontalArrangement={{ spacedBy: 8 }} modifiers={[fillMaxWidth(), padding(0, 0, 4, 0)]}>
+                                {profile ? (
+                                    <ProfileHeader profile={profile} selectedColors={colors} onPress={() => onSelect(profile.item)} />
+                                ) : (
+                                    <Spacer modifiers={[weight(1)]} />
+                                )}
+                                {onAgentPress ? <AgentButton active={agentActive} colors={colors} onPress={onAgentPress} /> : null}
+                            </Row>
+                        ) : null}
+                        {hasHeader ? <HorizontalDivider modifiers={[padding(16, 8, 16, 8)]} /> : null}
+                        {items.map((item, index) => (
+                            item.type === 'separator'
+                                ? <HorizontalDivider key={`separator-${index}`} modifiers={[padding(16, 8, 16, 8)]} />
+                                : <SheetRow key={item.id || `item-${index}`} item={item} selectedColors={colors} onPress={() => onSelect(item)} />
+                        ))}
+                    </Column>
+                </ModalBottomSheet>
+            ) : null}
+        </Host>
+    );
+}

+ 3 - 3
neo/packages/app/components/nav/tabs/more-sheet.ios.tsx

@@ -33,7 +33,7 @@ type SFSymbol = NonNullable<ComponentProps<typeof Image>['systemName']>;
 export const hasNativeMoreSheet = !!appSetting('theme', 'expo_ui', 'tabs')?.more_sheet;
 
 const ICON_PT = 22;
-const AVATAR_PT = 40;
+const AVATAR_PT = 44;
 const AGENT_BUTTON_PT = 44;
 
 const AVATAR_STYLE = StyleSheet.create({
@@ -50,7 +50,7 @@ function RowIcon({ icon, color, size = ICON_PT }: { icon?: string; color?: strin
         : <Image systemName={name as SFSymbol} size={size - 2} color={color} modifiers={[box]} />;
 }
 
-/** Selected-row colors (`native_tabs.selected`): fill + icon / label ink, as hex. */
+/** Selected-row colors (`native_tabs.selected`: iosMoreIndicator fill + foreground ink), as hex. */
 type SelectedColors = { fill?: string; ink?: string };
 
 /** Selected row: the toggled capsule of the active tab (system fill if unresolved). */
@@ -171,7 +171,7 @@ function AgentButton({ active, onPress }: { active?: boolean; onPress: () => voi
 export function NativeMoreSheet({ open, onOpenChange, items, onSelect, profile, onAgentPress, agentActive }: NativeMoreSheetProps) {
     const tabsSelected = appSetting('theme', 'native_tabs', 'selected');
     const selectedColors: SelectedColors = {
-        fill: useNativeTokenColor(tabsSelected?.indicator) as string | undefined,
+        fill: useNativeTokenColor(tabsSelected?.iosMoreIndicator ?? tabsSelected?.indicator) as string | undefined,
         ink: useNativeTokenColor(tabsSelected?.foreground) as string | undefined,
     };
     const hasHeader = !!profile || !!onAgentPress;

+ 1 - 1
neo/packages/app/components/nav/tabs/more-sheet.tsx

@@ -1,6 +1,6 @@
 import type { NativeMoreSheetProps } from 'app/components/nav/tabs/more-sheet.types';
 
-/** Android / web keep the JS `DropdownMenu` popup for More; iOS has a native sheet. */
+/** Web keeps the JS `DropdownMenu` popup for More; iOS and Android have native sheets. */
 export const hasNativeMoreSheet = false;
 
 export function NativeMoreSheet(_props: NativeMoreSheetProps) {

+ 1 - 1
neo/packages/app/components/nav/tabs/more-sheet.types.ts

@@ -1,4 +1,4 @@
-// Props shared by more-sheet.tsx (no-op) and more-sheet.ios.tsx (native sheet).
+// Props shared by more-sheet.tsx (no-op) and the native sheets (more-sheet.ios.tsx, more-sheet.android.tsx).
 
 /** Items from `buildTabBarMoreMenuItems`: tab routes, a separator, overflow routes. */
 export type MoreSheetItem = {

+ 29 - 1
neo/packages/app/components/nav/tabs/tab-menu.js

@@ -96,6 +96,13 @@ export function buildTabUrlIndex(tabList = []) {
     return [...baseLinks, ...extra];
 }
 
+/** Tab route (`/tabN`) whose url (or transpile_urls entry) occurs in `url`; null when none matches. */
+export function findTabPathForUrl(url, tabList = []) {
+    if (!url) return null;
+    const item = buildTabUrlIndex(tabList).find((link) => url.includes(link.url));
+    return item ? `/tab${item.index}` : null;
+}
+
 export function getTabsSessionKey(currentUser) {
     return currentUser?.id
         ? `user-${currentUser.id}-${currentUser.confirmed}`
@@ -207,6 +214,8 @@ export function buildTabBarMoreMenuItems({
             animated: tab.animated,
             addClassName: tab.addClassName,
             selected,
+            isOverflow: !!isOverflow,
+            isProfile: isProfileTab(tab),
         };
 
         if (mode === 'web') {
@@ -214,7 +223,6 @@ export function buildTabBarMoreMenuItems({
         } else {
             item.tab = tab;
             item.tabIndex = tabIndex;
-            item.isOverflow = !!isOverflow;
         }
 
         return item;
@@ -231,3 +239,23 @@ export function buildTabBarMoreMenuItems({
 
     return [...visibleItems, tabBarMoreSeparator(), ...overflowItems];
 }
+
+/**
+ * More menu headed by the own profile, on every platform: a `{profile}` item
+ * first in More makes the More tab the user's avatar, and the menu's header
+ * row (avatar, name, "View profile", plus the operator agent button).
+ *
+ * `items` from `buildTabBarMoreMenuItems`; `exclude`: tab urls left out of the
+ * menu (native sheets: `expo_ui.tabs.more_sheet_exclude`). Returns the header
+ * item (null without a profile More), the rest of the menu, and `profileOnly`
+ * — the profile is the only More link and there is no agent, so the avatar is
+ * a plain profile tab instead of a menu.
+ */
+export function splitProfileMoreMenu({ items = [], overflow = [], hasAgent = false, exclude = [] }) {
+    const header = isProfileTab(overflow[0]) ? items.find((item) => item.isProfile) ?? null : null;
+    const rest = trimMenuSeparators(items.filter(
+        (item) => item !== header && !(item.tab?.url && exclude.includes(item.tab.url))
+    ));
+    const profileOnly = !!header && !hasAgent && !rest.some((item) => item.isOverflow);
+    return { header, items: rest, profileOnly };
+}

+ 8 - 0
neo/packages/app/components/registry.js

@@ -25,6 +25,14 @@ const TYPES = [
  */
 export const components = Object.fromEntries(TYPES.map((type) => [type, {}]));
 
+/**
+ * Core form-field components (`form-fields/_map` as shipped, before fork
+ * overrides), filled by that map. form-initial-values compares the registered
+ * field with these instead of importing the map, which would close a require
+ * cycle through the fields that use the form helpers.
+ */
+export const coreFormFields = {};
+
 export function registerComponent(type, name, component) {
     (components[type] ||= {})[name] = component;
 }

+ 35 - 32
neo/packages/app/components/units/comments.js

@@ -3,7 +3,7 @@ import { Platform, Animated } from 'react-native'
 import { menuItemsByName, FeedbackHaptics, appSetting, getDataForMenu, attachmentKindByName } from 'app/lib/util';
 import { Text } from 'app/design/typography'
 import { View, Row } from 'app/design/view'
-import { Button, Modal } from 'app/design/controls'
+import { NeoButton, NeoButtonLink, Modal } from 'app/design/controls'
 import Time from 'app/ui/atoms/time'
 import Profile from 'app/ui/molecules/profile/profile'
 import { ContentMore } from 'app/ui/molecules/content/content-more'
@@ -340,34 +340,34 @@ function UnitCommentsDefault(props) {
                         )}
                     </View>
                     {(viewState.view != 'edited' && !data.disabled) && (
-                        <Row className="gap-1">
+                        <Row className="gap-2">
                             {(!!currentUser && isShowReply) && (
                                 props.handleReply === 'link' ? (
                                     <View>
-                                        <Link href={`${props.contentUrl}#cmt_id=${data.cmt_id}`}>
-                                            <Button
-                                                align="start"
-                                                title={t('Reply')}
-                                                size="xs"
-                                                rounded
-                                                startDecorator="MessageCircle"
-                                                variant="link"
-                                            />
-                                        </Link>
+                                        <NeoButtonLink
+                                            href={`${props.contentUrl}#cmt_id=${data.cmt_id}`}
+                                            style="borderless"
+                                            controlSize="mini"
+                                            borderShape="capsule"
+                                            align="start"
+                                            image="MessageCircle"
+                                            label={t('Reply')}
+                                        />
                                     </View>
                                 ) : (
-                                    <Button
+                                    <NeoButton
+                                        style="borderless"
+                                        controlSize="mini"
+                                        borderShape="capsule"
                                         align="start"
-                                        title={t('Reply')}
-                                        size="xs"
-                                        rounded
-                                        startDecorator="MessageCircle"
-                                        variant="link"
+                                        image="MessageCircle"
+                                        label={t('Reply')}
+                                        haptics={false}
                                         onPress={() => handleReply(data)}
                                     />
                                 )
                             )}
-                            <View className="flex-row flex-auto gap-1 ">
+                            <View className="flex-row flex-auto gap-2 ">
                                 {data.menu_actions?.items?.length > 0 && <Menu
                                     {...data.menu_actions}
                                     displayType="element"
@@ -415,6 +415,7 @@ const MenuManage = ({
     cmt_id,
 }) => {
     const [menuData, setMenuData] = useState(false)
+    const { t } = useTranslation()
 
     if (!menu?.object) return null
 
@@ -432,11 +433,12 @@ const MenuManage = ({
 
     if (!menuData)
         return (
-            <Button
-                variant="text"
-                size="xs"
-                rounded
-                startDecorator="Ellipsis"
+            <NeoButton
+                style="borderless"
+                controlSize="mini"
+                borderShape="circle"
+                image="Ellipsis"
+                accessibilityLabel={t('More options')}
                 onPress={() => {
                     if (Platform.OS === 'web')
                         setMenuData({ ...menu, items: [{ name: 'loader' }] })
@@ -469,6 +471,7 @@ const MenuManage_ = memo(
         cmt_id,
     }) => {
         let { currentUser, setCurrentUser } = useCurrentUser()
+        const { t } = useTranslation()
 
         //const refReport = useRef(null);
         //const [reportTitle, setReportTitle] = useState(null);
@@ -563,14 +566,14 @@ const MenuManage_ = memo(
                             }
                         })}
                         onSelect={handleManageMenuSelect}
-                    >
-                        <Button
-                            variant="text"
-                            size="xs"
-                            startDecorator="Ellipsis"
-                            rounded
-                        />
-                    </DropdownMenu>
+                        buttonProps={{
+                            style: 'borderless',
+                            controlSize: 'mini',
+                            borderShape: 'circle',
+                            image: 'Ellipsis',
+                            accessibilityLabel: t('More options'),
+                        }}
+                    />
                     {!!oReport && oReport}
                 </>
             )

+ 1 - 0
neo/packages/app/components/units/content-list/bx-organizations.js

@@ -62,6 +62,7 @@ export default function Unit({ data, unitType, module }) {
                         skeleton={isSkeleton}
                         className="flex-none flex-row gap-2 sm:gap-0 sm:flex-col sm:pt-2"
                         primaryClassName="sm:w-full"
+                        compactOnMobile
                         secondaryClassName={`sm:w-auto${primaryMenuItem ? ' sm:mt-2' : ''}`}
                         skeletonClassName="h-9 w-24 sm:w-full"
                     />

+ 1 - 0
neo/packages/app/components/units/content-list/bx-persons.js

@@ -60,6 +60,7 @@ export default function Unit({ data, unitType, module }) {
                         skeleton={isSkeleton}
                         className="flex-none flex-row sm:flex-col gap-2"
                         primaryClassName="sm:w-full"
+                        compactOnMobile
                         secondaryClassName="sm:w-full"
                         skeletonClassName="h-9 w-24 sm:w-full"
                     />

+ 1 - 1
neo/packages/app/components/units/feed.js

@@ -134,7 +134,7 @@ function DefaultFeedUnit({ data }) {
                 <Row className="gap-3 p-4 pb-3 flex-auto">
                     <Skeleton visible={isSkeleton} preset='feed_author'>
                         <Author data={data} url={url} t={t} />
-                        <Row className="flex-none mb-auto items-center gap-1">
+                        <Row className="flex-none mb-auto items-center gap-2">
                             <AuthorActions data={data} />
                             <MenuManage
                                 id={data.id}

+ 23 - 22
neo/packages/app/components/units/helpers.js

@@ -4,7 +4,7 @@ import Image from 'app/ui/atoms/image';
 import Letter from 'app/ui/atoms/letter';
 import { Text } from 'app/design/typography';
 import { View, Row } from 'app/design/view';
-import { Button } from 'app/design/controls';
+import { NeoButton, legacyToNeoButtonProps } from 'app/design/controls';
 import Profile from 'app/ui/molecules/profile/profile';
 import ProfilesList from 'app/ui/molecules/profile/profile-list';
 import Badge from 'app/ui/molecules/profile/badge';
@@ -13,6 +13,7 @@ import { components } from 'app/components/registry';
 import { fetcher } from 'app/lib/fetcher';
 import { appSetting, cn, tp, sanitazeUrl } from 'app/lib/util';
 import { getUnitMenuItems } from 'app/customization/functions';
+import { UnitActionWidthContext } from 'app/components/units/unit-action-width';
 import { useRouter, useCurrentTabPath, useFocusEffect } from 'app/lib/hooks/router';
 import { nativeTabHref } from 'app/lib/navigation/tab-history';
 import { useTranslation } from 'react-i18next';
@@ -23,9 +24,9 @@ import { FLUSH_LIST_MODULES } from 'app/components/units/flush-list';
 
 const UNIT_SURFACES = { card: Card, list: CardList };
 
-// Mobile: 8px inside the row's 8px margin (u-card-list-flush), so the photo
+// Mobile: 16px inside the edge-to-edge row (u-card-list-flush), so the photo
 // lines up with the 16px page header and subtabs.
-const FLUSH_LIST_PADDING = 'px-2 py-2 sm:p-2';
+const FLUSH_LIST_PADDING = 'px-4 py-2 sm:p-2';
 const FLUSH_ACTION_PRIMARY = 'w-1/2 sm:w-full pr-2 sm:pr-0';
 
 const UnitSurfaceContext = createContext(false);
@@ -84,7 +85,7 @@ function UnitSurface({
                     {flushOnMobile && Platform.OS !== 'web' && navPending ? (
                         <View
                             pointerEvents="none"
-                            className="absolute inset-0 rounded-xl sm:rounded-2xl bg-muted/60 animate-pulse"
+                            className="absolute inset-0 sm:rounded-2xl bg-muted/60 animate-pulse"
                         />
                     ) : null}
                     {children}
@@ -523,6 +524,8 @@ export function UnitActions({
     inlineSecondary = false,
     /** Give it a width when the buttons size to their content (no w-full parent). */
     skeletonClassName = 'h-9 w-full',
+    /** Buttons hug their label below `sm` (profile rows on phones); see unit-action-width.js. */
+    compactOnMobile = false,
 }) {
     const flushOnMobile = use(UnitSurfaceContext);
     const resolvedPrimaryClassName = primaryClassName ?? (
@@ -541,15 +544,17 @@ export function UnitActions({
     ) : null;
 
     return (
-        <View className={className}>
-            <View className={resolvedPrimaryClassName}>
-                <Skeleton className={skeletonClassName} rounded="rounded-lg" visible={skeleton}>
-                    {primaryMenuItem}
-                    {inlineSecondary ? secondary : null}
-                </Skeleton>
+        <UnitActionWidthContext value={compactOnMobile ? 'compact-mobile' : 'fill'}>
+            <View className={className}>
+                <View className={resolvedPrimaryClassName}>
+                    <Skeleton className={skeletonClassName} rounded="rounded-lg" visible={skeleton}>
+                        {primaryMenuItem}
+                        {inlineSecondary ? secondary : null}
+                    </Skeleton>
+                </View>
+                {inlineSecondary ? null : secondary}
             </View>
-            {inlineSecondary ? null : secondary}
-        </View>
+        </UnitActionWidthContext>
     );
 }
 
@@ -575,18 +580,14 @@ export function UnitInvitationActions({
 
     return (
         <Row className="gap-x-2">
-            <Button
-                title={t('Accept')}
-                size={size}
-                variant={variant}
-                rounded
+            <NeoButton
+                {...legacyToNeoButtonProps({ variant, size, rounded: true, title: t('Accept') })}
+                expoUI={false}
                 onPress={() => processInvitation(data.callback_accept)}
             />
-            <Button
-                title={t('Decline')}
-                size={size}
-                variant={variant}
-                rounded
+            <NeoButton
+                {...legacyToNeoButtonProps({ variant, size, rounded: true, title: t('Decline') })}
+                expoUI={false}
                 onPress={() => processInvitation(data.callback_decline)}
             />
         </Row>

+ 7 - 7
neo/packages/app/components/units/invitations.js

@@ -4,7 +4,7 @@ import { Text } from 'app/design/typography'
 import { View, Row } from 'app/design/view'
 import { fetcher } from 'app/lib/fetcher';
 import { useState } from 'react';
-import { ButtonLink, Button } from 'app/design/controls'
+import { NeoButtonLink, NeoButton } from 'app/design/controls'
 import Image from 'app/ui/atoms/image'
 import ProfilesList from 'app/ui/molecules/profile/profile-list'
 import { useTranslation } from 'react-i18next';
@@ -70,9 +70,9 @@ export default function Unit(props) {
                             </View>
                             <View className="flex-row w-full gap-x-2">
 
-                                {!!data.callback_accept && <Button title={t('Accept')} size="sm" fullWidth variant="primary" onPress={() => { processInvitation(data.callback_accept) }} />}
-                                {!!data.callback_decline && <Button title={t('Decline')} size="sm" fullWidth variant="secondary" onPress={() => { processInvitation(data.callback_decline) }} />}
-                                {!!data.redirect_url && <ButtonLink href={data.redirect_url} title={data.redirect_title} size="sm" fullWidth variant="secondary" />}
+                                {!!data.callback_accept && <NeoButton style="borderedProminent" controlSize="small" width="fill" label={t('Accept')} expoUI={false} onPress={() => { processInvitation(data.callback_accept) }} />}
+                                {!!data.callback_decline && <NeoButton controlSize="small" width="fill" label={t('Decline')} expoUI={false} onPress={() => { processInvitation(data.callback_decline) }} />}
+                                {!!data.redirect_url && <NeoButtonLink href={data.redirect_url} controlSize="small" width="fill" label={data.redirect_title} />}
                             </View>
                         </View>
                     </View>
@@ -98,9 +98,9 @@ export default function Unit(props) {
                     </Text>
                     <View className="flex-none">
                         <Row className='gap-2'>
-                            {!!data.callback_accept && <Button title={t('Accept')} size="xs" variant="primary" rounded onPress={() => { processInvitation(data.callback_accept) }} />}
-                            {!!data.callback_decline && <Button startDecorator="X" size="xs" variant="default" rounded onPress={() => { processInvitation(data.callback_decline) }} />}
-                            {!!data.redirect_url && <Link href={data.redirect_url}><Button title={data.redirect_title} size="xs" fullWidth variant="default" /></Link>}
+                            {!!data.callback_accept && <NeoButton style="borderedProminent" controlSize="mini" borderShape="capsule" label={t('Accept')} expoUI={false} onPress={() => { processInvitation(data.callback_accept) }} />}
+                            {!!data.callback_decline && <NeoButton controlSize="mini" borderShape="circle" image="X" accessibilityLabel={t('Decline')} expoUI={false} onPress={() => { processInvitation(data.callback_decline) }} />}
+                            {!!data.redirect_url && <NeoButtonLink href={data.redirect_url} controlSize="mini" width="fill" label={data.redirect_title} />}
                         </Row>
                     </View>
                 </View>

+ 4 - 2
neo/packages/app/components/units/notifications.js

@@ -46,8 +46,10 @@ function Unit({ data }) {
     const pressProps = Platform.OS === 'web' ? null : { onPressIn: () => setPressed(true), onPressOut: () => setPressed(false) };
     return (
         <LinkOrModal href={url} showInModal={data.type ? appSetting('browse', 'show_in_modal', data.type) : false} {...pressProps}>
-            <View className="group w-full max-w-4xl mx-auto px-2 py-0.5 web:cursor-pointer">
-                <Row className={`${pressed ? 'bg-muted/60 ' : ''}relative w-full overflow-hidden gap-3 rounded-xl px-2 py-2 web:group-hover:bg-muted/60 active:bg-muted/60 web:duration-200`}>
+            {/* Phones: an edge-to-edge row on the card surface, 1px apart (feed
+                cards are 2px); from sm an 8px-inset rounded highlight. */}
+            <View className="group w-full max-w-4xl mx-auto mt-px bg-card sm:mt-0 sm:bg-transparent sm:px-2 sm:py-0.5 web:cursor-pointer">
+                <Row className={`${pressed ? 'bg-muted/60 ' : ''}relative w-full overflow-hidden gap-3 sm:rounded-xl px-4 sm:px-2 py-2 web:group-hover:bg-muted/60 active:bg-muted/60 web:duration-200`}>
                     <View className="rounded-full flex-none bg-secondary mb-auto">
                         <Skeleton visible={isSkeleton} className="h-14 w-14 rounded-full">
                             <Profile {...data.author_data} displayType="unit_wo_info" displaySize="xl" />

+ 4 - 4
neo/packages/app/components/units/price.js

@@ -1,6 +1,6 @@
 import { View, Row } from 'app/design/view'
 import { Text } from 'app/design/typography'
-import { Button, ButtonLink } from 'app/design/controls';
+import { NeoButton, NeoButtonLink } from 'app/design/controls';
 import { Icon } from 'app/ui/atoms/icon';
 import {
     Card,
@@ -89,9 +89,9 @@ function UniPriceCard({ data, onBuy }) {
             <CardFooter>
                 {currentUser
                     ? (!!firstNonEmpty && (
-                        <Button variant="primary" title={firstNonEmpty.title} onPress={() => { onBuy(firstNonEmpty) }} />
+                        <NeoButton style="borderedProminent" label={firstNonEmpty.title} expoUI={false} onPress={() => { onBuy(firstNonEmpty) }} />
                     ))
-                    : <ButtonLink href="/create-account" variant="primary" title={t('Create  account')} />}
+                    : <NeoButtonLink href="/create-account" style="borderedProminent" label={t('Create  account')} />}
             </CardFooter>
         </Card>
     )
@@ -107,7 +107,7 @@ function UniPriceList({ data, onBuy }) {
     return <Card className=" w-full text-left max-w-sm justify-center my-4 mx-auto" >
         <Row className='gap-x-4 items-center justify-between'>
             <Text className="text-2xl text-foreground font-semibold">{t(data.price.value.currency)} {priceParts.price} {priceParts.period ? `/ ${priceParts.period}` : ''}</Text>
-            {!!firstNonEmpty && <Button variant="primary" title={firstNonEmpty.title} onPress={() => { onBuy(firstNonEmpty) }} />}
+            {!!firstNonEmpty && <NeoButton style="borderedProminent" label={firstNonEmpty.title} classNames={{ root: 'self-center' }} expoUI={false} onPress={() => { onBuy(firstNonEmpty) }} />}
         </Row>
     </Card>
 }

+ 27 - 0
neo/packages/app/components/units/unit-action-width.js

@@ -0,0 +1,27 @@
+import { createContext, use } from 'react';
+import { Platform, useWindowDimensions } from 'react-native';
+import { getBreakpoint } from 'app/lib/util';
+
+/**
+ * How unit action buttons (menu-items/unit.js) size, set by `UnitActions`:
+ * - 'fill' (default): the button fills its slot — card grids, the flush
+ *   mobile rows' half-width actions.
+ * - 'compact-mobile': below `sm` the button hugs its label (profile rows on
+ *   phones: avatar, name, then a compact button on the right); from `sm` up
+ *   it fills, as in the card grid.
+ *
+ * Its own module: units/helpers.js imports the menu-item map through
+ * customization/functions, so the menu item can't import helpers back.
+ */
+export const UnitActionWidthContext = createContext('fill');
+
+/**
+ * True when the unit action button should hug its label. Native only: on
+ * web, `fill` in the row's auto-sized slot already hugs (CSS shrink-to-fit),
+ * while the native layout engine widens it.
+ */
+export function useUnitActionHugs() {
+    const mode = use(UnitActionWidthContext);
+    const { width } = useWindowDimensions();
+    return mode === 'compact-mobile' && Platform.OS !== 'web' && !getBreakpoint(width);
+}

+ 16 - 0
neo/packages/app/customization/config/fonts.js

@@ -0,0 +1,16 @@
+// DON'T EDIT THIS FILE IN MAIN REPO!!!
+// only for custom projects: pick the font per role and platform here.
+//
+// 'system' = the platform's own UI font (SF Pro on iOS, the device sans-serif
+// on Android, the OS UI font stack on web); any other value is a key of
+// app/design/fonts/font-families.js (e.g. 'inter'). See font-config.js.
+// ios/android are build settings: rebuild the native app after changing them.
+//
+// Example — Inter on web and Android, SF Pro on iOS:
+//
+// module.exports = {
+//     main: { web: 'inter', ios: 'system', android: 'inter' },
+//     title: { web: 'inter', ios: 'system', android: 'inter' },
+// };
+
+module.exports = require('../../design/fonts/font-config');

+ 4 - 2
neo/packages/app/customization/design/fonts/fonts-web.js

@@ -1,8 +1,9 @@
-import { mainFont, titleFont } from 'app/design/fonts/fonts-web';
+import { webFontClassName } from 'app/design/fonts/fonts-web';
 
 // DON'T EDIT THIS FILE IN MAIN REPO!!!
 // only for custom projects
 // 
+// Pick Inter or the system font per platform in customization/config/fonts.js.
 // To use local/custom fonts instead of Google Fonts, uncomment below:
 // 
 // import localFont from 'next/font/local';
@@ -19,4 +20,5 @@ import { mainFont, titleFont } from 'app/design/fonts/fonts-web';
 //   display: 'swap',
 // });
 
-export const fontVars = `${mainFont.variable} ${titleFont.variable}`;
+// With local fonts: export const fontVars = `${mainFont.variable} ${titleFont.variable}`;
+export const fontVars = webFontClassName();

+ 0 - 1
neo/packages/app/customization/elements/_mapbox.js

@@ -1,7 +1,6 @@
 import { Text } from 'app/design/typography'
 import { View, Row } from 'app/design/view'
 import { useState, useRef, useCallback } from 'react';
-import { Button } from 'app/design/controls'
 import Link from 'app/ui/atoms/link'
 //import Mapbox from "@rnmapbox/maps";
 import { useWindowHeight } from 'app/context/measure';

+ 2 - 2
neo/packages/app/customization/elements/_mapbox.web.js

@@ -1,6 +1,6 @@
 import { View, Row } from 'app/design/view'
 import { useState, useRef, useCallback, lazy, Suspense } from 'react';
-import { Button } from 'app/design/controls'
+import { NeoButton } from 'app/design/controls'
 import Loading from 'app/ui/atoms/loading'
 import { BlockWrapper } from 'app/components/block-wrapper'
 
@@ -87,7 +87,7 @@ export default function ElementMapBox({ data, blockWrapperProps }) {
         <View className=" items-center">
             <Row className="gap-x-4 my-2">
                 {dataSources.map((layer, index) => (
-                    <Button size="sm" key={layer.key} title={layer.name} pressed={selectedlayers.includes(layer.key)} onPress={() => selectedlayers.includes(layer.key) ? setSelectedLayers(selectedlayers.filter(name => name !== layer.key)) : setSelectedLayers([...selectedlayers, layer.key])} />
+                    <NeoButton controlSize="small" key={layer.key} label={layer.name} selected={selectedlayers.includes(layer.key)} onPress={() => selectedlayers.includes(layer.key) ? setSelectedLayers(selectedlayers.filter(name => name !== layer.key)) : setSelectedLayers([...selectedlayers, layer.key])} />
                 ))}
             </Row>
             <View className="aspect-square w-full max-w-3xl ">

+ 8 - 7
neo/packages/app/default/functions.js

@@ -83,20 +83,21 @@ export function paddingForList(endpoint) {
     // Timeline uses no padding (full width items)
     if (!endpoint || endpoint?.request_url?.includes('bx_timeline') || endpoint?.params?.request_url?.includes('bx_timeline'))
         return card;
-    // Notification rows inset themselves 8px on the sides but sit 2px apart:
+    // Notification rows: on phones edge-to-edge card rows, each 1px below the
+    // one above (the first below the header band). From sm they inset themselves 8px on the sides and sit 2px apart:
     // 6px more at the top and bottom puts the first and last rows 8px from the
     // list's edges too (below the subtabs, and inside the card where it shows).
     if (endpoint?.unit == "notifications")
-        return ['py-1.5', card].join(' ').trim();
+        return ['sm:py-1.5', card].join(' ').trim();
     // Groups and grid layouts get padding for better spacing
     if (endpoint?.module == 'bx_groups' || endpoint?.request_url?.includes('r=bx_groups'))
         return ['m-1.5 @list-md/list:m-1.5', card].join(' ').trim();
-    // Profile lists run flush rows on mobile: each row is its own card 8px in
-    // from the sides, with no gap between rows. 8px at the top and bottom puts
-    // the first and last cards 8px from the list's edges too (and 14px under the
-    // subtabs, like Notifications and Messages). The sm+ grid spaces its cards.
+    // Profile lists run flush rows on mobile: edge-to-edge card rows, each 1px
+    // below the one above, like the Notifications and Messages rows. The
+    // sm+ grid spaces its cards (`padding_content`: container queries from
+    // list-sm, 40rem, so never on a phone).
     if (FLUSH_LIST_MODULES.has(endpoint?.module))
-        return [settingsLayout.layout.padding_content, 'py-2 sm:py-0', card].join(' ').trim();
+        return [settingsLayout.layout.padding_content, card].join(' ').trim();
 
     // Default padding for grid-based content lists
     return [settingsLayout.layout.padding_content, card].join(' ').trim();

+ 53 - 49
neo/packages/app/default/static.js

@@ -1,6 +1,6 @@
 import { View, ScrollView, Row } from 'app/design/view'
 import { Text, H1 } from 'app/design/typography'
-import { Button, Modal } from 'app/design/controls'
+import { Modal, NeoButton, NeoButtonLink } from 'app/design/controls'
 import Svg, { Path, Circle, Ellipse } from 'react-native-svg'
 import Link from 'app/ui/atoms/link'
 import { Icon } from 'app/ui/atoms/icon'
@@ -319,11 +319,11 @@ const PageError = ({ error, reset }) => {
                     {error.message}
                 </Text>
 
-                <Button
+                <NeoButton
                     onPress={() => reset()}
-                    variant="default"
-                    title={t('Reload page')}
-                ></Button>
+                    label={t('Reload page')}
+                    classNames={{ root: 'self-center' }}
+                />
             </View>
         </View>
     )
@@ -336,7 +336,7 @@ const BootstrapOffline = ({ onRetry }) => {
             <Text className="text-center text-base text-foreground">
                 {t('No internet connection')}
             </Text>
-            {onRetry ? <Button title={t('Retry')} onPress={onRetry} /> : null}
+            {onRetry ? <NeoButton label={t('Retry')} onPress={onRetry} classNames={{ root: 'self-center' }} /> : null}
         </View>
     )
 }
@@ -499,51 +499,55 @@ const ComponentsPricingFooter = () => {
 
 const ComponentsFullFooter = () => {
     const { t } = useTranslation()
+    // The links wrap to a second row on narrow phones, so the spacer that keeps room
+    // for the fixed bar follows its measured height (h-16 is the one-row height).
+    // Web only: native has no `fixed`, the bar stays in flow and the spacer stays h-16.
+    // On phones the bar starts at the screen edge, not at its inset block, so the
+    // centred links don't run past the right edge (wider screens keep their placement).
+    const [barHeight, setBarHeight] = useState()
     return (
         <>
-            <View className="w-full h-16"></View>
-            <View className=" w-full p-3 flex-row justify-center bg-background border-t border-border/60 fixed bottom-0 ">
-                <Link href="/">
-                    <Button
-                        variant="text"
-                        title={t('Home')}
-                        className="mt-auto"
-                        size="sm"
-                    />
-                </Link>
-                <Link href="/about">
-                    <Button
-                        variant="text"
-                        title={t('About')}
-                        className="mt-auto"
-                        size="sm"
-                    />
-                </Link>
-
-                <Link href="/contact">
-                    <Button
-                        variant="text"
-                        title={t('Contact')}
-                        className="mt-auto"
-                        size="sm"
-                    />
-                </Link>
-                <Link href="/privacy">
-                    <Button
-                        variant="text"
-                        title={t('Privacy')}
-                        className="mt-auto"
-                        size="sm"
-                    />
-                </Link>
-                <Link href="/terms">
-                    <Button
-                        variant="text"
-                        title={t('Terms')}
-                        className="mt-auto"
-                        size="sm"
-                    />
-                </Link>
+            <View className="w-full min-h-16" style={{ height: barHeight }}></View>
+            <View
+                onLayout={isWeb ? (e) => setBarHeight(e.nativeEvent.layout.height) : undefined}
+                className=" w-full p-3 flex-row flex-wrap justify-center gap-2 bg-background border-t border-border/60 fixed bottom-0 max-sm:left-0 "
+            >
+                <NeoButtonLink
+                    href="/"
+                    style="borderless"
+                    controlSize="small"
+                    label={t('Home')}
+                    classNames={{ root: 'mt-auto' }}
+                />
+                <NeoButtonLink
+                    href="/about"
+                    style="borderless"
+                    controlSize="small"
+                    label={t('About')}
+                    classNames={{ root: 'mt-auto' }}
+                />
+
+                <NeoButtonLink
+                    href="/contact"
+                    style="borderless"
+                    controlSize="small"
+                    label={t('Contact')}
+                    classNames={{ root: 'mt-auto' }}
+                />
+                <NeoButtonLink
+                    href="/privacy"
+                    style="borderless"
+                    controlSize="small"
+                    label={t('Privacy')}
+                    classNames={{ root: 'mt-auto' }}
+                />
+                <NeoButtonLink
+                    href="/terms"
+                    style="borderless"
+                    controlSize="small"
+                    label={t('Terms')}
+                    classNames={{ root: 'mt-auto' }}
+                />
             </View>
         </>
     )

+ 1 - 0
neo/packages/app/default/translations/de.json

@@ -199,6 +199,7 @@
     "Your Profiles": "Deine Profile",
     "Dark Mode": "Dunkelmodus",
     "Switch Profile": "Profil wechseln",
+    "Switch profile": "Profil wechseln",
     "Sign out": "Abmelden",
     "Account Settings": "Einstellungen",
     "Friends": "Freunde",

+ 1 - 0
neo/packages/app/default/translations/es.json

@@ -199,6 +199,7 @@
     "Your Profiles": "Tus perfiles",
     "Dark Mode": "Modo oscuro",
     "Switch Profile": "Cambiar perfil",
+    "Switch profile": "Cambiar perfil",
     "Sign out": "Cerrar sesión",
     "Account Settings": "Ajustes",
     "Friends": "Amigos",

+ 1 - 0
neo/packages/app/default/translations/fr.json

@@ -199,6 +199,7 @@
     "Your Profiles": "Vos profils",
     "Dark Mode": "Mode sombre",
     "Switch Profile": "Changer de profil",
+    "Switch profile": "Changer de profil",
     "Sign out": "Se déconnecter",
     "Account Settings": "Réglages",
     "Friends": "Amis",

+ 1 - 0
neo/packages/app/default/translations/ru.json

@@ -199,6 +199,7 @@
     "Your Profiles": "Твои профили",
     "Dark Mode": "Темная тема",
     "Switch Profile": "Переключить профили",
+    "Switch profile": "Сменить профиль",
     "Sign out": "Выход",
     "Account Settings": "Настройки",
     "Friends": "Друзья",

+ 4 - 0
neo/packages/app/design/controls.ts

@@ -27,6 +27,10 @@ export {
     NeoButtonStyleProvider, NeoControlSizeProvider,
     useNeoButtonExpoUI,
 } from 'app/design/controls/neo-button/neo-button';
+export type { NeoButtonProps } from 'app/design/controls/neo-button/neo-button';
+export { NeoButtonGroup, useNeoButtonGroupItem } from 'app/design/controls/neo-button/neo-button-group';
+export { legacyToNeoButtonProps } from 'app/design/controls/neo-button/legacy-button-map';
+export { toNeoStyle, toControlSize, hitSlopForHeight, MIN_TARGET } from 'app/design/controls/neo-button/control-scale';
 
 export {
     isExpoUI,

+ 13 - 17
neo/packages/app/design/controls/button-groups.tsx

@@ -1,29 +1,25 @@
-import { View, Row } from 'app/design/view'
-import { appSetting } from 'app/lib/util'
-import { Fragment, type ReactNode } from 'react';
-
-const BtnClsSize = appSetting('theme', 'button_sizes');
-const BtnCls = appSetting('theme', 'button_styles');
+import type { ReactNode } from 'react';
+import { NeoButtonGroup } from 'app/design/controls/neo-button/neo-button-group';
+import { toControlSize } from 'app/design/controls/neo-button/control-scale';
 
 type ButtonsGroupProps = {
-    /** Theme `button_sizes` key (for the group rounding). */
+    /** Legacy size (`xs`/`sm`/`base`/`lg`/`xl`) or Neo controlSize, for the group rounding. */
     size?: string
-    children?: ReactNode[]
+    children?: ReactNode
     [key: string]: unknown
 }
 
+/**
+ * @deprecated use `NeoButtonGroup` with `ButtonMenuGroupItem` segments.
+ * Kept as a thin alias over NeoButtonGroup until the legacy cleanup.
+ */
 export function ButtonsGroup({
     size = 'base',
     children
 }: ButtonsGroupProps) {
     return (
-        <Row className={`${BtnCls.group?.container} ${BtnClsSize[size]?.rounded}`}>
-            {children?.map((child, i) => (
-                <Fragment key={`btngr-${i}`}>
-                    {child}
-                    {i < children.length - 1 && <View className={`${BtnCls.group?.separator}`} />}
-                </Fragment>
-            ))}
-        </Row>
+        <NeoButtonGroup controlSize={toControlSize(size) ?? 'regular'}>
+            {children}
+        </NeoButtonGroup>
     )
-}
+}

+ 112 - 64
neo/packages/app/design/controls/button-menus.tsx

@@ -1,42 +1,48 @@
-import type { ReactNode } from 'react'
-import { appSetting } from 'app/lib/util'
-import { Button, type ButtonProps } from 'app/design/controls/buttons';
-import { ButtonsGroup } from 'app/design/controls/button-groups';
+/**
+ * Action / counter buttons of UNA object menus (likes, reactions, scores, …)
+ * and their segmented group. They keep the legacy Button vocabulary
+ * (`variant`, `size`, `title`, `startDecorator`, `pressed`, …) and render
+ * NeoButton through `legacyToNeoButtonProps`. Neo props passed alongside
+ * (`style`, `controlSize`, `borderShape`, `label`, `image`, `selected`, …) win.
+ */
+import type { ReactNode } from 'react';
+import { appSetting } from 'app/lib/util';
+import { NeoButton, useResolvedNeoButton } 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';
+import type { NeoButtonProps, NeoControlSize } from 'app/design/controls/neo-button/neo-button.types';
 
-const LEGACY_BUTTON_SIZES = new Set(['xs', 'sm', 'base', 'lg']);
-const safeLegacySize = (size: string | undefined, fallback = 'sm') => size && LEGACY_BUTTON_SIZES.has(size) ? size : fallback;
+type ButtonMenuProps = Record<string, any>;
 
-export function ButtonMenuGroupItem({ variant, size, ...rest }: ButtonProps) {
-    return (
-        <Button
-            grouped={true}
-            variant='text'
-            size={safeLegacySize(size, 'sm')}
-            {...rest}
-        />
-    );
+/**
+ * Adapter sizes: Neo names pass through, `xs/sm/base/lg/xl` map to Neo, and
+ * anything else (`md`, `''`, unknown, none) is `small` — the legacy adapters
+ * coerced those to `sm` (36px).
+ */
+function adapterControlSize(size: unknown): NeoControlSize {
+    if (typeof size === 'string') {
+        if (NEO_CONTROL_SIZES.has(size)) return size as NeoControlSize;
+        if (size !== 'md' && LEGACY_TO_CONTROL_SIZE[size]) return LEGACY_TO_CONTROL_SIZE[size]!;
+    }
+    return 'small';
 }
 
-export function ButtonsGroupMenu(props: ButtonProps & { children?: ReactNode[] }) {
-    const {
-        variant,
-        size = 'xs',
-        rounded = true,
-        children,
-        ...rest
-    } = props;
-
-    return (
-        <ButtonsGroup
-            fullWidth={true}
-            variant={variant ?? appSetting('layout', 'button_style_for_actions')}
-            size={safeLegacySize(size, 'xs')}
-            rounded={rounded}
-            {...rest}
-        >
-            {children}
-        </ButtonsGroup>
-    )
+/** Shared adapter defaults: a stable JS render and no default haptics (consumers fire their own). */
+function adapterProps(neo: NeoButtonProps, { pressed, haptics, forwardedRef }: {
+    pressed: unknown;
+    haptics: unknown;
+    forwardedRef: unknown;
+}): NeoButtonProps {
+    return {
+        ...neo,
+        // Always a boolean: a stable JS render on native, so handlers get real
+        // RN events (consumers call `event.preventDefault()` unguarded).
+        selected: neo.selected ?? !!pressed,
+        haptics: haptics === undefined ? false : (haptics as NeoButtonProps['haptics']),
+        // A measured ref (ReactionPopover) needs the JS surface: Expo UI drops refs.
+        ...(forwardedRef && neo.expoUI === undefined ? { expoUI: false } : null),
+    };
 }
 
 function ButtonMenuAction({
@@ -46,42 +52,84 @@ function ButtonMenuAction({
     pressed = false,
     disabled = false,
     fullWidth = false,
+    haptics,
     ...rest
-}: ButtonProps) {
+}: ButtonMenuProps) {
+    const neo = legacyToNeoButtonProps({
+        ...rest,
+        variant: variant ?? appSetting('layout', 'button_style_for_actions'),
+        rounded,
+        disabled,
+        fullWidth,
+        pressed,
+    });
     return (
-        <Button
-            variant={variant ?? appSetting('layout', 'button_style_for_actions')}
-            size={safeLegacySize(size)}
-            rounded={rounded}
-            pressed={pressed}
-            disabled={disabled}
-            fullWidth={fullWidth}
-            {...rest}
+        <NeoButton
+            {...adapterProps(neo, { pressed, haptics, forwardedRef: rest.forwardedRef })}
+            controlSize={rest.controlSize ?? adapterControlSize(size)}
         />
     );
 }
 
-function ButtonMenuCounter(props: ButtonProps) {
-    const {
-        variant,
-        size = 'sm',
-        rounded = true,
-        pressed = false,
-        disabled = false,
-        fullWidth = false,
-        ...rest
-    } = props;
+/**
+ * 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.
+ */
+export function ButtonMenuGroupItem({
+    variant: _forcedText,
+    size,
+    pressed = false,
+    rounded: _noRounding,
+    haptics,
+    ...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} />;
+    }
 
-    return <Button
-        variant={variant ?? appSetting('layout', 'button_style_for_actions')}
-        size={safeLegacySize(size)}
-        rounded={rounded}
-        pressed={pressed}
-        disabled={disabled}
-        fullWidth={fullWidth}
-        {...rest}
-    />
+    const neo = legacyToNeoButtonProps({ ...rest, pressed });
+    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,
+            }}
+        />
+    );
+}
+
+/**
+ * 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).
+ */
+export function ButtonsGroupMenu({ size, className, children }: {
+    size?: string;
+    className?: string;
+    children?: ReactNode;
+    [key: string]: unknown;
+}) {
+    return (
+        <NeoButtonGroup controlSize={adapterControlSize(size)} className={className}>
+            {children}
+        </NeoButtonGroup>
+    );
 }
 
 export { ButtonMenuAction as ButtonMenuActionDefault, ButtonMenuAction as ButtonMenuActionText };
-export { ButtonMenuCounter as ButtonMenuCounterDefault, ButtonMenuCounter as ButtonMenuCounterText };
+export { ButtonMenuAction as ButtonMenuCounterDefault, ButtonMenuAction as ButtonMenuCounterText };

+ 3 - 0
neo/packages/app/design/controls/buttons.tsx

@@ -327,6 +327,7 @@ const ButtonContent = React.memo(({
 });
 
 
+/** @deprecated use NeoButton / NeoButtonLink (`legacyToNeoButtonProps` for legacy-vocabulary props). */
 export const Button = ({
     variant = BtnClsSize.default_variant,
     size = BtnClsSize.default_size,
@@ -483,12 +484,14 @@ export const Button = ({
     );
 }
 
+/** @deprecated use NeoButtonRef (or NeoButton with `forwardedRef`). */
 export const ButtonRef = React.forwardRef<any, ButtonProps>((props, forwardedRef) => {
     return (
         <Button {...props} forwardedRef={forwardedRef} />
     );
 });
 
+/** @deprecated use NeoButtonLink. */
 export const ButtonLink = ({
     href = '',
     target = '',

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

@@ -0,0 +1,71 @@
+/**
+ * NeoButton control scale + legacy vocabulary — single source of truth.
+ *
+ * Pure data, no React: the resolver, the legacy mapper and the button adapters
+ * all read the size/style mapping from here, so the migration tables (legacy
+ * `xs/sm/base/lg/xl`, legacy variants) live in one place.
+ *
+ * Heights stay in the theme (`neo_button.controlSizes`); the hit area is
+ * derived from them with `hitSlopForHeight` so every control reaches
+ * `MIN_TARGET`. The resolver's dev-only `checkScaleOnce` warns when a theme
+ * size drifts (target below 44, or a value with no utility class).
+ */
+import type { NeoButtonStyle, NeoControlSize } from 'app/design/controls/neo-button/neo-button.types';
+
+/** Minimum touch / click target (pt / px), WCAG 2.5.5 + HIG. */
+export const MIN_TARGET = 44;
+
+/** Hit area per side so a control of `height` reaches MIN_TARGET: mini 28 → 8, small 36 → 4, 44+ → 0. */
+export const hitSlopForHeight = (height: number) => Math.max(0, Math.ceil((MIN_TARGET - height) / 2));
+
+/** Legacy `button_sizes` keys (+ UNA `md`) → Neo controlSize. */
+export const LEGACY_TO_CONTROL_SIZE: Readonly<Record<string, NeoControlSize>> = {
+    xs: 'mini',
+    sm: 'small',
+    base: 'regular',
+    md: 'regular',
+    lg: 'large',
+    xl: 'xlarge',
+};
+
+export const NEO_CONTROL_SIZES: ReadonlySet<string> = new Set(['mini', 'small', 'regular', 'large', 'xlarge']);
+
+/** Neo or legacy size → Neo controlSize. `''` / unknown / nullish → undefined (theme default). */
+export function toControlSize(size: unknown): NeoControlSize | undefined {
+    if (typeof size !== 'string' || !size) return undefined;
+    if (NEO_CONTROL_SIZES.has(size)) return size as NeoControlSize;
+    return LEGACY_TO_CONTROL_SIZE[size];
+}
+
+/**
+ * Legacy variant → best visual Neo match (migration spec §2). Legacy keys win
+ * over same-named Neo styles (`link`). `accent` has no Neo style: the mapper
+ * adds the accent wash on top of `bordered` (see `legacyToNeoButtonProps`).
+ */
+export const LEGACY_VARIANT_TO_NEO: Readonly<Record<string, { style: NeoButtonStyle; role?: 'destructive' }>> = {
+    default: { style: 'bordered' },
+    secondary: { style: 'bordered' },
+    outline: { style: 'bordered' },
+    primary: { style: 'borderedProminent' },
+    danger: { style: 'borderedProminent', role: 'destructive' },
+    text: { style: 'borderless' },
+    link: { style: 'borderless' },
+    ghost: { style: 'plain' },
+    accent: { style: 'bordered' },
+};
+
+export const NEO_STYLES: ReadonlySet<string> = new Set([
+    'plain', 'bordered', 'borderedProminent', 'borderless', 'link', 'glass', 'glassProminent',
+]);
+
+/**
+ * `undefined`/`null` → `{}` (NeoButton default style); legacy key → table;
+ * Neo name → itself; `''`, `false`, `custom`, `group-item`, `none`, unknown → `plain`
+ * (legacy applied no classes for those).
+ */
+export function toNeoStyle(variant: unknown): { style?: NeoButtonStyle; role?: 'destructive' } {
+    if (variant === undefined || variant === null) return {};
+    if (typeof variant === 'string' && LEGACY_VARIANT_TO_NEO[variant]) return { ...LEGACY_VARIANT_TO_NEO[variant] };
+    if (typeof variant === 'string' && NEO_STYLES.has(variant)) return { style: variant as NeoButtonStyle };
+    return { style: 'plain' };
+}

+ 145 - 0
neo/packages/app/design/controls/neo-button/legacy-button-map.tsx

@@ -0,0 +1,145 @@
+/**
+ * Legacy Button vocabulary → NeoButton props.
+ *
+ * `legacyToNeoButtonProps` serves call sites whose variant / size / rounded
+ * come from props, UNA params or a public API that keeps the legacy names
+ * (Snackbar, UNA menus), the `ButtonMenu*` adapters, and legacy `buttonProps`
+ * on DropdownMenu / DropdownPopup. Mapping tables: `control-scale.ts`.
+ */
+import React from 'react';
+import { Row } from 'app/design/view';
+import { Text } from 'app/design/typography';
+import { Icon } from 'app/ui/atoms/icon';
+import { cn, isEmoji } from 'app/lib/util';
+import { useNeoButtonContent } from 'app/design/controls/neo-button/neo-button';
+import { toControlSize, toNeoStyle } from 'app/design/controls/neo-button/control-scale';
+import type { NeoButtonClassNames, NeoButtonProps } from 'app/design/controls/neo-button/neo-button.types';
+
+/** Legacy props NeoButton has no use for (ignored by legacy Button too, or replaced by Neo state). */
+const DROPPED = [
+    'grouped', 'ring', 'solid', 'bgColor', 'textColor', 'hide_icon', 'onlyIcon', 'padding',
+    'legacyButton', 'useNeoButton', 'neoButton', 'accessibilityRole', 'id', 'isoCode',
+];
+/** Neo semantic roles; any other `role` is the legacy ARIA role and is dropped. */
+const NEO_ROLES = new Set(['default', 'cancel', 'close', 'confirm', 'destructive']);
+/** Keys that mark `buttonProps` as legacy (DropdownMenu / DropdownPopup routing). */
+const LEGACY_KEYS = ['variant', 'size', 'startDecorator', 'endDecorator', 'title', 'rounded', 'fullWidth'];
+/** Routing flags of trigger `buttonProps`; never NeoButton props. */
+const ROUTING_KEYS = ['legacyButton', 'neoButton', 'useNeoButton'];
+
+const truthy = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1';
+const isDecorator = (v: unknown) => (typeof v === 'string' && v !== '') || React.isValidElement(v);
+const ALIGN: Record<string, 'start' | 'end' | 'between' | undefined> = {
+    start: 'start', left: 'start', end: 'end', right: 'end', between: 'between',
+};
+
+/**
+ * Trigger `buttonProps` that need the legacy mapper: `legacyButton: true`, or
+ * any legacy key with a value (`variant`, `size`, `startDecorator`, …).
+ */
+export function isLegacyButtonProps(props: Record<string, any> | null | undefined): boolean {
+    if (!props) return false;
+    if (props.legacyButton === true) return true;
+    return LEGACY_KEYS.some((key) => props[key] != null);
+}
+
+/** `buttonProps` without the routing flags (`legacyButton` / `neoButton` / `useNeoButton`). */
+export function stripRoutingKeys(props: Record<string, any> = {}): NeoButtonProps {
+    const out: Record<string, any> = { ...props };
+    for (const key of ROUTING_KEYS) delete out[key];
+    return out;
+}
+
+/**
+ * Legacy `startDecorator` array (stacked reaction icons) as one image. Each
+ * icon / emoji takes the enclosing button's label classes for its style and
+ * state (rest / hover / selected, plus `textClassName`), like legacy did.
+ */
+export function LegacyDecoratorStack({ items }: { items: unknown[] }) {
+    const content = useNeoButtonContent();
+    const iconSize = content?.iconSize ?? 20;
+    // Legacy dropped `overflow-hidden` on icons (it clips glyph overhang).
+    const cls = cn((content?.textCls ?? '').replace('overflow-hidden', ''), 'shrink-0');
+    return (
+        <Row className="flex-row items-center gap-x-0.5">
+            {items.map((src, i) => {
+                if (React.isValidElement(src)) return <React.Fragment key={i}>{src}</React.Fragment>;
+                if (typeof src !== 'string' || !src) return null;
+                if (isEmoji(src)) return <Text key={i} className={cls}>{src}</Text>;
+                return <Icon key={i} icon={src} size={iconSize} className={cn(cls, 'pointer-events-none')} />;
+            })}
+        </Row>
+    );
+}
+
+/**
+ * Legacy Button props (variant / size / title / decorators / rounded /
+ * fullWidth / pressed / …) → NeoButton props. Neo keys already present win;
+ * the result holds only Neo keys and uses `style`, so it spreads into
+ * NeoButtonLink too. Explicit props written after the spread win.
+ */
+export function legacyToNeoButtonProps(p: Record<string, any> = {}): NeoButtonProps {
+    const {
+        variant, size, title, startDecorator, endDecorator, rounded, fullWidth, pressed, align, alt,
+        addon, classTextName, className, onTouchStart, tooltip, ...rest
+    } = p;
+    for (const key of DROPPED) delete rest[key];
+    if (rest.role !== undefined && !NEO_ROLES.has(rest.role)) delete rest.role;
+
+    const out: NeoButtonProps = { ...rest };
+
+    const mapped = toNeoStyle(variant);
+    if (out.style === undefined && out.buttonStyle === undefined && mapped.style) out.style = mapped.style;
+    if (out.role === undefined && mapped.role) out.role = mapped.role;
+
+    if (out.controlSize === undefined) {
+        const controlSize = toControlSize(size);
+        if (controlSize) out.controlSize = controlSize;
+    }
+
+    // Legacy hid `false`, `''` and `0` titles.
+    if (out.label === undefined && title !== undefined && title !== null && title !== false && title !== '' && title !== 0) {
+        out.label = title;
+    }
+
+    if (startDecorator === '_loading' || endDecorator === '_loading') out.loading = out.loading ?? true;
+    const start = startDecorator === '_loading' ? undefined : startDecorator;
+    const end = endDecorator === '_loading' ? undefined : endDecorator;
+    if (out.image === undefined && out.children === undefined) {
+        if (Array.isArray(start) && start.length) {
+            out.image = <LegacyDecoratorStack items={start} />;
+        } else if (isDecorator(start)) {
+            out.image = start;
+        } else if (isDecorator(end)) {
+            out.image = end;
+            out.imagePlacement = out.imagePlacement ?? 'trailing';
+        }
+        if (isDecorator(start) && isDecorator(end) && process.env.NODE_ENV !== 'production') {
+            console.warn('[legacyToNeoButtonProps] start + end decorators: the trailing one is dropped; use children');
+        }
+    }
+
+    // Never a circle with a label (it would clip): capsule.
+    if (out.borderShape === undefined && truthy(rounded)) out.borderShape = out.label !== undefined ? 'capsule' : 'circle';
+    if (out.width === undefined && truthy(fullWidth)) out.width = 'fill';
+    if (out.selected === undefined && pressed !== undefined) out.selected = !!pressed;
+    if (out.align === undefined && typeof align === 'string' && ALIGN[align]) out.align = ALIGN[align];
+    if (out.accessibilityLabel === undefined && alt) out.accessibilityLabel = alt;
+    if (out.tooltip === undefined && tooltip) out.tooltip = tooltip;
+    // Legacy hid a `0` counter.
+    if (out.addon === undefined && addon !== 0 && !(addon && typeof addon === 'object' && addon.text === 0)) out.addon = addon;
+    if (out.textClassName === undefined && classTextName) out.textClassName = classTextName;
+    if (out.onPressIn === undefined && onTouchStart) out.onPressIn = onTouchStart;
+
+    // Legacy `accent` (a tinted chip) has no Neo style: bordered + the accent wash.
+    const classNames: NeoButtonClassNames = { ...(out.classNames ?? {}) };
+    if (variant === 'accent') {
+        if (classNames.surface === undefined) classNames.surface = 'bg-accent/60';
+        if (out.textClassName === undefined) out.textClassName = 'text-accent-foreground';
+    }
+    // Legacy `className` sat on the outer box: layout classes reach the parent.
+    if (className) classNames.root = cn(classNames.root, className);
+    if (out.classNames !== undefined || Object.keys(classNames).length) out.classNames = classNames;
+
+    return out;
+}

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio