Просмотр исходного кода

Interface sounds from Cuelume, with a Sounds section in Studio settings (#5855)

* Neo: interface sounds from Cuelume

Replace NEO's four sound files and web-haptics' synthesized click with
Cuelume's cues (cuelume 0.2.6, MIT), one per interface job, picked from
Cuelume's "Which cue?" table.

- Web synthesizes the cues with the `cuelume` package: `InterfaceSounds`
  binds once at the root, controls carry data-cuelume-* attributes, and
  outcomes call playCue(). web-haptics now only vibrates.
- Native plays WAV files rendered from the same package version by
  scripts/render-sounds.mjs (headless Chrome, OfflineAudioContext). The
  `Sounds` seam keeps its export and the old keys (click, tab, success,
  notif); new cue keys fall back to them.
- Mapping: reactions, likes, favorites and other object toggles = toggle
  with direction; switcher and checkbox = toggle; accordion and menus =
  open/close subtle; tab bar = navigate subtle; in-page tabs, menu items,
  radios and ratings = select; buttons tap (subtle unless prominent) on
  web; form results = success subtle / error; notifications = ready; a
  message from someone else in the open conversation = ready subtle; back
  buttons on web = navigate back. Nothing plays on hover, scroll, typing
  or page load.
- Users get a Sounds switch (footer, dashboard settings), stored in
  layout settings; `layout.sounds` still turns sounds off for the app.
  Native interface sounds now follow the iOS silent switch.

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

* Interface sounds from Cuelume on the site and in Studio

Add Cuelume's interface cues (cuelume 0.2.6, MIT; built into
plugins_public/cuelume with its license by `build:cuelume`) to the classic
site and to Studio, picked by Cuelume's "Which cue?" table.

- Loading: getInjHeadSounds loads the module and binds it once when the
  option is on: sys_ui_sounds for the site, sys_std_ui_sounds for Studio
  (both on, in site_settings). bx_sound() in inc/js/functions.js plays
  outcomes; replies can name a cue (`cue` in processJsonData and
  bx_get_js_result).
- Controls: submit buttons tap, other form buttons tap subtly, checkboxes and
  switchers toggle, radios select, tab menus (submenus, block tabs, the
  timeline feed switcher) select. Popups open (subtle when anchored) and
  close when the visitor closes them; drawers, sliding menus and form
  sections open and close; alerts make no sound of their own.
- Outcomes: AJAX forms, comments and timeline posts play a subtle success
  when saved and error when refused; likes, reactions, favorites and
  connections toggle with direction, ratings select; live updates that
  bring something new play ready once per poll.
- Studio: launcher tiles navigate, edit mode toggles, moving an app selects
  with direction, dragging selects on pick-up and taps strongly on drop
  (launcher and page builder), deleting a block closes strongly, settings
  saves play success or error.
- Visitors turn sounds off for their browser with a Sounds switch in the
  account menu (site) and the Studio account popup.
- Nothing plays on hover, scroll, typing or page load.

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

* Load Cuelume as one bundled module

build:cuelume rolls cuelume's ESM build into plugins_public/cuelume/cuelume.min.js
(one request, 8 kB gzipped) instead of copying its dozen module files.

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

* Neo: preload the native cue players

Only the tab bar's player was warmed, so the first press of any other cue
waited for its file to load. The rest now load in the background at start.

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

* Sounds settings in Studio: where they play, the set, the volume, each kind of action

Studio > Settings > Global gets a Sounds section (category `sounds`) in place of
the two checkboxes in General:

- Play interface sounds on: Website, Studio, NEO web app, iOS app, Android app.
  Unticked, nobody hears sounds there.
- Level for people who haven't chosen (Full, Quiet, Off), the sound set
  (Cuelume's themes: Glass, Mechanical, Bubble, Press) and the volume.
- A set of their own, or no sound, for buttons, choices, likes and reactions,
  menus and popups, navigation, results and notifications.
- Each select plays its choice as it is picked, before saving and even while
  Studio is silent (bx_sound_preview).

BxDolSounds reads the options for everyone: the site and Studio pass them to
bx_sound_init, and the API page payload carries them as `sounds`, so the apps
never wait on a request to play a sound.

Each visitor picks their own level in this browser instead of on/off: the
site's account menu item steps through Full, Quiet (40%) and Off, Studio's
account popup has a three-option radio group like its theme control. On the
web a capture listener gives each press its kind of action's set, or mutes it,
before Cuelume's bind() plays it; outcomes (bx_sound) read the settings as they
play. Likes, reactions, ratings, favorites and follows play as reactions.

Studio app tiles play Cuelume's soft `loading` cue instead of `navigate`: an
app opening shows a loading indicator, not a swoosh.

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

* Neo: follow UNA's Sounds settings, with a level per user

- UNA's settings (`data.sounds` on every page) reach `applySoundConfig` in
  Layouts and are kept in memory and storage (`lib/sound/config.ts`). Sounds
  read that copy only, so no sound waits on a request; changes apply from the
  next page.
- `neo_web`, `ios` and `android` follow their platform switch; the set,
  volume and each kind of action's set or silence apply on both platforms. On
  web a capture listener gives each press its set before `bind()` plays it.
- Native ships every set: `render-sounds.mjs` renders the nine cue variants in
  default, mech, bubble and press, as mono 32 kHz WAV (36 files, about 0.7 MB,
  less than twice the stereo 48 kHz default set alone). Keys are
  `[<set>-]<cue>...` and fall back to the default set.
- Users pick Full, Quiet or Off from a menu in the footer and the dashboard
  settings (`useSoundsLevel`); unset follows UNA's starting level. The chosen
  level answers at its new loudness. Likes, reactions, ratings, favorites,
  reposts and scores play as reactions.

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

* Play the result sound only when a reply says how it went

- bx_sound_form_reply plays success only for code 0, a redirect, a reload or
  a script to run, and error for a non-zero code or a field error. A reply
  that only carries a message (it can say either) plays nothing, and one that
  names its own `cue` gets only that one from processJsonData, so a Studio
  settings save no longer sounds twice.
- NEO forms: a `msg` block with a non-zero code is an error; one without a
  code plays nothing unless a redirect or reload comes with it.
- NEO comments: success once the reply carries the new comment, error on
  field errors, nothing otherwise.
- bx_sound_init catches a module that fails to load (SonarCloud S9383).

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

* Neo: star ratings on content sound as reactions

The rating picker's select used the choices settings, so "Sound for likes
and reactions" (its set, or no sound) skipped it while taking a rating back
followed it. StarsAction takes a `soundGroup`; content ratings pass
`reactions`, the stars form field stays a choice.

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

* Neo: a page's sound settings win over a late read of the stored copy

On native the stored copy is read asynchronously. A page that brought the
same settings as the defaults left `current` unchanged, so the read that
finished later replaced them with an older stored copy. Settings from a page
now mark the run, the stored copy only applies before any page brought
settings, and a page's settings are written to storage once whenever they
differ from what storage holds.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 1 неделя назад
Родитель
Сommit
a59ff1492f
100 измененных файлов с 1742 добавлено и 288 удалено
  1. 96 0
      inc/classes/BxDolSounds.php
  2. 11 0
      inc/js/classes/BxDolCmts.js
  3. 7 0
      inc/js/classes/BxDolConnection.js
  4. 7 0
      inc/js/classes/BxDolFavorite.js
  5. 16 0
      inc/js/classes/BxDolVote.js
  6. 2 0
      inc/js/classes/BxDolVoteReactions.js
  7. 16 0
      inc/js/classes/BxDolVoteStars.js
  8. 371 0
      inc/js/functions.js
  9. 32 2
      inc/js/jquery.dolPopup.js
  10. 4 0
      inc/js/jquery.webForms.js
  11. 21 0
      install/sql/system.sql
  12. 4 0
      modules/boonex/artificer/data/template/system/_header_js_custom.html
  13. 44 0
      modules/boonex/english/data/langs/system/en.xml
  14. 44 0
      modules/boonex/russian/data/langs/system/ru.xml
  15. 1 0
      modules/boonex/timeline/classes/BxTimelineMenuView.php
  16. 7 0
      modules/boonex/timeline/js/post.js
  17. 7 0
      modules/boonex/timeline/js/view.js
  18. 15 2
      neo/AGENTS.md
  19. 2 0
      neo/apps/next/app/providers.js
  20. 1 0
      neo/package.json
  21. 5 0
      neo/packages/app/components/elements/comments-browse.js
  22. 3 0
      neo/packages/app/components/elements/covers/cover-elements.js
  23. 33 1
      neo/packages/app/components/elements/dashboard-stat.js
  24. 13 0
      neo/packages/app/components/elements/messenger/parts/common.js
  25. 3 1
      neo/packages/app/components/form-fields/selector.js
  26. 33 1
      neo/packages/app/components/form.js
  27. 1 4
      neo/packages/app/components/forms/comments.js
  28. 1 4
      neo/packages/app/components/forms/feed.js
  29. 1 4
      neo/packages/app/components/forms/messenger.js
  30. 7 0
      neo/packages/app/components/layouts.js
  31. 4 0
      neo/packages/app/components/menu-items/dropdown-item.js
  32. 3 4
      neo/packages/app/components/nav/footer.web.js
  33. 40 1
      neo/packages/app/components/nav/menu-footer.js
  34. 3 3
      neo/packages/app/components/nav/tab-feedback.js
  35. 2 0
      neo/packages/app/components/nav/tabs/index.js
  36. 12 1
      neo/packages/app/context/layout-settings.ts
  37. BIN
      neo/packages/app/customization/resources/sounds/bubble-close-subtle.wav
  38. BIN
      neo/packages/app/customization/resources/sounds/bubble-error.wav
  39. BIN
      neo/packages/app/customization/resources/sounds/bubble-navigate-subtle.wav
  40. BIN
      neo/packages/app/customization/resources/sounds/bubble-open-subtle.wav
  41. BIN
      neo/packages/app/customization/resources/sounds/bubble-ready-subtle.wav
  42. BIN
      neo/packages/app/customization/resources/sounds/bubble-ready.wav
  43. BIN
      neo/packages/app/customization/resources/sounds/bubble-success-subtle.wav
  44. BIN
      neo/packages/app/customization/resources/sounds/bubble-toggle-back.wav
  45. BIN
      neo/packages/app/customization/resources/sounds/bubble-toggle.wav
  46. BIN
      neo/packages/app/customization/resources/sounds/click.mp3
  47. BIN
      neo/packages/app/customization/resources/sounds/close-subtle.wav
  48. BIN
      neo/packages/app/customization/resources/sounds/error.wav
  49. BIN
      neo/packages/app/customization/resources/sounds/mech-close-subtle.wav
  50. BIN
      neo/packages/app/customization/resources/sounds/mech-error.wav
  51. BIN
      neo/packages/app/customization/resources/sounds/mech-navigate-subtle.wav
  52. BIN
      neo/packages/app/customization/resources/sounds/mech-open-subtle.wav
  53. BIN
      neo/packages/app/customization/resources/sounds/mech-ready-subtle.wav
  54. BIN
      neo/packages/app/customization/resources/sounds/mech-ready.wav
  55. BIN
      neo/packages/app/customization/resources/sounds/mech-success-subtle.wav
  56. BIN
      neo/packages/app/customization/resources/sounds/mech-toggle-back.wav
  57. BIN
      neo/packages/app/customization/resources/sounds/mech-toggle.wav
  58. BIN
      neo/packages/app/customization/resources/sounds/navigate-subtle.wav
  59. BIN
      neo/packages/app/customization/resources/sounds/notif.mp3
  60. BIN
      neo/packages/app/customization/resources/sounds/open-subtle.wav
  61. BIN
      neo/packages/app/customization/resources/sounds/press-close-subtle.wav
  62. BIN
      neo/packages/app/customization/resources/sounds/press-error.wav
  63. BIN
      neo/packages/app/customization/resources/sounds/press-navigate-subtle.wav
  64. BIN
      neo/packages/app/customization/resources/sounds/press-open-subtle.wav
  65. BIN
      neo/packages/app/customization/resources/sounds/press-ready-subtle.wav
  66. BIN
      neo/packages/app/customization/resources/sounds/press-ready.wav
  67. BIN
      neo/packages/app/customization/resources/sounds/press-success-subtle.wav
  68. BIN
      neo/packages/app/customization/resources/sounds/press-toggle-back.wav
  69. BIN
      neo/packages/app/customization/resources/sounds/press-toggle.wav
  70. BIN
      neo/packages/app/customization/resources/sounds/ready-subtle.wav
  71. BIN
      neo/packages/app/customization/resources/sounds/ready.wav
  72. BIN
      neo/packages/app/customization/resources/sounds/success-subtle.wav
  73. BIN
      neo/packages/app/customization/resources/sounds/success.mp3
  74. BIN
      neo/packages/app/customization/resources/sounds/toggle-back.wav
  75. BIN
      neo/packages/app/customization/resources/sounds/toggle.wav
  76. 65 5
      neo/packages/app/default/sounds.js
  77. 7 0
      neo/packages/app/default/translations/de.json
  78. 7 0
      neo/packages/app/default/translations/en.json
  79. 7 0
      neo/packages/app/default/translations/es.json
  80. 7 0
      neo/packages/app/default/translations/fr.json
  81. 7 0
      neo/packages/app/default/translations/ru.json
  82. 3 0
      neo/packages/app/design/controls/buttons.tsx
  83. 40 0
      neo/packages/app/design/controls/neo-button/neo-button.tsx
  84. 8 0
      neo/packages/app/design/controls/neo-button/neo-button.types.ts
  85. 13 162
      neo/packages/app/lib/hooks/use-sound.ts
  86. 0 33
      neo/packages/app/lib/hooks/use-sound.web.ts
  87. 39 0
      neo/packages/app/lib/hooks/use-sounds-level.ts
  88. 11 47
      neo/packages/app/lib/platform/feedback-haptics.web.ts
  89. 158 0
      neo/packages/app/lib/sound/config.ts
  90. 118 0
      neo/packages/app/lib/sound/cues.ts
  91. 6 0
      neo/packages/app/lib/sound/index.ts
  92. 13 0
      neo/packages/app/lib/sound/press-cue-context.ts
  93. 205 0
      neo/packages/app/lib/sound/sound.ts
  94. 128 0
      neo/packages/app/lib/sound/sound.web.ts
  95. 1 2
      neo/packages/app/settings/layout.js
  96. 9 10
      neo/packages/app/ui/atoms/accordion.tsx
  97. 5 0
      neo/packages/app/ui/atoms/checkbox.tsx
  98. 2 0
      neo/packages/app/ui/atoms/dropdown-grid-item.tsx
  99. 9 0
      neo/packages/app/ui/atoms/dropdown-popup.tsx
  100. 2 1
      neo/packages/app/ui/atoms/radiobutton.tsx

+ 96 - 0
inc/classes/BxDolSounds.php

@@ -0,0 +1,96 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+/**
+ * Interface sounds settings (Studio > Settings > Global > Sounds) for Cuelume's cues: where they play, the sound set
+ * and volume, the level people start at, and a set (or silence) for each kind of action.
+ *
+ * The site and Studio read it in BxBaseFunctions::getInjHeadSounds; apps get the same config with every page
+ * (BxBasePage::getPageAPI), so playing a sound never waits on a request.
+ */
+class BxDolSounds extends BxDolFactory implements iBxDolSingleton
+{
+    public const PLATFORMS = ['site', 'studio', 'neo_web', 'ios', 'android'];
+
+    /** What each person can pick for themselves; the option sets where they start. */
+    public const LEVELS = ['full', 'quiet', 'off'];
+
+    /** Cuelume's themes: the same cues in another material. */
+    public const SETS = ['default', 'mech', 'bubble', 'press'];
+
+    public const VOLUMES = [100, 75, 50, 25];
+
+    /** Kinds of action that can have their own set, or no sound. */
+    public const GROUPS = ['buttons', 'choices', 'reactions', 'menus', 'navigation', 'results', 'notifications'];
+
+    public const GROUP_OFF = 'off';
+
+    protected function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Prevent cloning the instance
+     */
+    public function __clone()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error('Clone is not allowed for the class: ' . get_class($this), E_USER_ERROR);
+    }
+
+    /**
+     * Get singleton instance of the class
+     */
+    public static function getInstance()
+    {
+        if(!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolSounds();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public function isOn($sPlatform)
+    {
+        return in_array($sPlatform, $this->getPlatforms(), true);
+    }
+
+    public function getPlatforms()
+    {
+        return array_values(array_intersect(self::PLATFORMS, explode(',', (string)getParam('sys_sounds_platforms'))));
+    }
+
+    /**
+     * The config every client applies: `level` is where people start until they pick their own, `volume` is 0-1,
+     * `groups` lists only the kinds of action that differ from `set` (a set name or 'off').
+     */
+    public function getConfig()
+    {
+        $sLevel = getParam('sys_sounds_level');
+        $sSet = getParam('sys_sounds_set');
+        $iVolume = (int)getParam('sys_sounds_volume');
+
+        $aGroups = [];
+        foreach(self::GROUPS as $sGroup) {
+            $sGroupSet = getParam('sys_sounds_' . $sGroup);
+            if($sGroupSet == self::GROUP_OFF || (in_array($sGroupSet, self::SETS, true) && $sGroupSet != $sSet))
+                $aGroups[$sGroup] = $sGroupSet;
+        }
+
+        return [
+            'platforms' => $this->getPlatforms(),
+            'level' => in_array($sLevel, self::LEVELS, true) ? $sLevel : self::LEVELS[0],
+            'set' => in_array($sSet, self::SETS, true) ? $sSet : self::SETS[0],
+            'volume' => (in_array($iVolume, self::VOLUMES, true) ? $iVolume : self::VOLUMES[0]) / 100,
+            'groups' => (object)$aGroups,
+        ];
+    }
+}
+
+/** @} */

+ 11 - 0
inc/js/classes/BxDolCmts.js

@@ -148,6 +148,10 @@ BxDolCmts.prototype.cmtAfterPostSubmit = function (oCmtForm, oData)
 
     this._loadingInButton($(oCmtForm).children().find(':submit'), false);
 
+    // Posted: a subtle success; turned down (the form comes back, or a message instead of the comment): error.
+    if(oData && typeof(bx_sound) == 'function')
+        bx_sound(oData.id != undefined ? 'success' : 'error', oData.id != undefined ? {emphasis: 'subtle'} : undefined);
+
     if(oData && oData.msg != undefined)
         bx_alert(oData.msg, fContinue);
     else 
@@ -199,6 +203,13 @@ BxDolCmts.prototype.cmtAfterEditSubmit = function (oCmtForm, oData, onComplete)
 
     this._loadingInButton($(oCmtForm).children().find(':submit'), false);
 
+    // Saved: a subtle success; the form back with an error: error.
+    if(oData && typeof(bx_sound) == 'function')
+        if(oData.form != undefined && oData.form_id != undefined)
+            bx_sound('error');
+        else if(oData.id != undefined)
+            bx_sound('success', {emphasis: 'subtle'});
+
     if(oData && oData.msg != undefined)
         bx_alert(oData.msg, fContinue);
     else 

+ 7 - 0
inc/js/classes/BxDolConnection.js

@@ -47,6 +47,13 @@ BxDolConnection.prototype.connect = function(oLink, sAction, iContentId, bConfir
                 if(typeof(oData) != 'object')
                     return;
 
+                // Following or befriending is a toggle: on when made, off when removed or declined.
+                if(typeof(bx_sound) == 'function')
+                    if(oData.err)
+                        bx_sound('error');
+                    else
+                        bx_sound('toggle', {direction: /^(remove|reject|cancel|unfollow|unsubscribe)/i.test(sAction) ? 'back' : 'forward', group: 'reactions'});
+
                 if(oData.err)
                     bx_alert(oData.msg);
                 else {

+ 7 - 0
inc/js/classes/BxDolFavorite.js

@@ -109,6 +109,13 @@ BxDolFavorite.prototype.onEditFavoriteList = function (oData, oElement) {
 BxDolFavorite.prototype.onFavorite = function(oData, oElement)
 {
     var $this = this;
+
+    // A favorite is a toggle: on when added, off when taken back.
+    if(typeof(bx_sound) == 'function' && oData)
+        if(oData.code != undefined && parseInt(oData.code) != 0)
+            bx_sound('error');
+        else
+            bx_sound('toggle', {direction: oData.favorited ? 'forward' : 'back', group: 'reactions'});
     var fContinue = function() {
         if(oData && oData.code != 0)
             return;

+ 16 - 0
inc/js/classes/BxDolVote.js

@@ -67,6 +67,8 @@ BxDolVote.prototype.vote = function(oLink, iValue, onComplete)
     	this._sActionsUrl,
     	oParams,
     	function(oData) {
+            $this._cueOnVote(oData);
+
             if(oData && oData.message != undefined)
                 bx_alert(oData.message, function() {
                     $this.onVote(oLink, oData, onComplete);
@@ -78,6 +80,20 @@ BxDolVote.prototype.vote = function(oLink, iValue, onComplete)
     );
 };
 
+/**
+ * The interface sound of the visitor's own vote: a like is a toggle, on when given and off when taken back.
+ */
+BxDolVote.prototype._cueOnVote = function(oData)
+{
+    if(typeof(bx_sound) != 'function' || !oData)
+        return;
+
+    if(oData.code != undefined && parseInt(oData.code) != 0)
+        bx_sound('error');
+    else
+        bx_sound('toggle', {direction: oData.voted ? 'forward' : 'back', group: 'reactions'});
+};
+
 BxDolVote.prototype.onVoteAs = function(oData)
 {
     var oLink = $('#' + this._aHtmlIds['main']).find('.' + this._sSP + '-do-vote');

+ 2 - 0
inc/js/classes/BxDolVoteReactions.js

@@ -65,6 +65,8 @@ BxDolVoteReactions.prototype.vote = function(oLink, iValue, sReaction, onComplet
     	this._sActionsUrl,
     	oParams,
     	function(oData) {
+            $this._cueOnVote(oData);
+
             if(oData && oData.message != undefined)
                 bx_alert(oData.message, function() {
                     $this.onVote(oLink, oData, onComplete);

+ 16 - 0
inc/js/classes/BxDolVoteStars.js

@@ -42,6 +42,22 @@ BxDolVoteStars.prototype.init = function ()
     });
 };
 
+/**
+ * Rating is choosing a value: a select, and a toggle going off when the rating is taken back.
+ */
+BxDolVoteStars.prototype._cueOnVote = function(oData)
+{
+    if(typeof(bx_sound) != 'function' || !oData)
+        return;
+
+    if(oData.code != undefined && parseInt(oData.code) != 0)
+        bx_sound('error');
+    else if(oData.voted)
+        bx_sound('select', {group: 'reactions'});
+    else
+        bx_sound('toggle', {direction: 'back', group: 'reactions'});
+};
+
 BxDolVoteStars.prototype.onVote = function (oLink, oData, onComplete)
 {
     if(oData && oData.code != 0)

+ 371 - 0
inc/js/functions.js

@@ -45,6 +45,10 @@ function processJsonData(oData) {
 	        eval(oData.eval);
 	};
 
+	// A reply can name its interface sound: 'error', or {cue: 'success', emphasis: 'subtle'}. It plays now, before any alert.
+	if(oData && oData.cue != undefined)
+		bx_sound(typeof(oData.cue) == 'object' ? oData.cue.cue : oData.cue, typeof(oData.cue) == 'object' ? oData.cue : undefined);
+
 	if(oData && oData.object != undefined && oData.grid != undefined)
 		if(glGrids[oData.object] != undefined)
 			glGrids[oData.object].processJson(oData);
@@ -748,6 +752,9 @@ function bx_menu_slide_inline (sMenu, e, sPosition) {
         sPosition = 'block';
 
     if (eSlider.is(':visible')) {
+        if(typeof(bx_sound) == 'function')
+            bx_sound('close', {emphasis: 'subtle'});
+
         fClose();
         $(e).focus();
 
@@ -760,6 +767,10 @@ function bx_menu_slide_inline (sMenu, e, sPosition) {
         $(document).off('click.bx-sliding-menu touchend.bx-sliding-menu');
         $(window).off('resize.bx-sliding-menu');
 
+        // Some templates (Artificer) draw a sidebar instead and keep this call on the trigger: no menu, no sound.
+        if(eSlider.length && typeof(bx_sound) == 'function')
+            bx_sound('open', {emphasis: 'subtle'});
+
         fOpen();
         eSlider.find('.bx-popup-content a:first').focus();
         eSlider.find('a').each(function () {
@@ -805,6 +816,10 @@ function bx_menu_slide_close_all_opened () {
 function bx_menu_show_live_update(oData) {
     var sSelectorAddon = '.bx-menu-item-addon';
 
+    // Something arrived (a notification, a message, a request): one ready, however many counters went up.
+    if(parseInt(oData.count_new) > parseInt(oData.count_old))
+        bx_sound('ready');
+
     //--- Update Child Menu Item
     if(oData.mi_child) {
         var oMenuItem = $('.bx-menu-object-' + oData.mi_child.menu_object + ' .bx-menu-item-' + oData.mi_child.menu_item);
@@ -1728,6 +1743,362 @@ function bx_set_color_scheme_html()
         $('html').removeClass('dark')
 }
 
+/*
+ * Interface sounds are Cuelume's cues (plugins_public/cuelume, MIT): one cue per
+ * interface job, picked by the "Which cue?" table at https://cuelume.dev/agents.md.
+ * Controls carry data-cuelume-* attributes, which Cuelume's bind() plays as they
+ * are pressed; outcomes call bx_sound(). Nothing plays on hover, on page load, or
+ * per item of a burst.
+ *
+ * Settings > Global > Sounds (BxDolSounds, passed to bx_sound_init) picks where
+ * sounds play, the sound set, the volume, a set or silence for each kind of action,
+ * and the level people start at. Each visitor picks their own level for this
+ * browser: full, quiet or off (bx_sound_set_level). The module loads only once a
+ * sound can play.
+ */
+var glBxSounds = {url: '', config: null, module: null, cuelume: null, readyAt: 0, restore: null};
+
+/**
+ * The kind of action each cue belongs to (its set or silence in the settings). Likes, reactions, ratings, favorites
+ * and follows say so themselves: group 'reactions' in bx_sound(), or data-bx-sound-group on a control.
+ */
+var aBxSoundGroups = {tap: 'buttons', select: 'choices', toggle: 'choices', open: 'menus', close: 'menus', navigate: 'navigation', loading: 'navigation', success: 'results', warning: 'results', error: 'results', ready: 'notifications', attention: 'notifications'};
+
+/**
+ * The attributes bind() plays, and the cue each one plays when it doesn't name another.
+ */
+var aBxSoundAttributes = {'data-cuelume-tap': 'tap', 'data-cuelume-select': 'select', 'data-cuelume-toggle': 'toggle', 'data-cuelume-open': 'open', 'data-cuelume-close': 'close', 'data-cuelume-navigate': 'navigate'};
+
+/**
+ * The cue Settings > Sounds plays for each kind of action as it is changed.
+ */
+var aBxSoundSamples = {buttons: 'tap', choices: 'toggle', reactions: 'toggle', menus: 'open', navigation: 'navigate', results: 'success', notifications: 'ready'};
+
+/**
+ * Quiet plays at this share of the volume.
+ */
+var BX_SOUND_QUIET = 0.4;
+
+function bx_sound_init(sUrl, oConfig)
+{
+    glBxSounds.url = sUrl;
+    glBxSounds.config = oConfig;
+
+    // A module that fails to load leaves the page silent; nothing else depends on it.
+    if(bx_sound_is_on())
+        bx_sound_load().catch(function() {});
+
+    $(document).ready(function() {
+        bx_sound_mark_switches();
+    });
+}
+
+function bx_sound_load()
+{
+    if(!glBxSounds.url)
+        return Promise.reject(new Error('Interface sounds are not set up on this page.'));
+
+    if(!glBxSounds.module)
+        glBxSounds.module = import(glBxSounds.url).then(function(oCuelume) {
+            glBxSounds.cuelume = oCuelume;
+            bx_sound_apply();
+
+            // Ahead of bind(), which listens on document: the kind of action decides how this one press sounds.
+            ['pointerdown', 'click', 'keydown', 'keyup', 'change'].forEach(function(sEvent) {
+                window.addEventListener(sEvent, bx_sound_gate, true);
+            });
+            oCuelume.bind();
+
+            return oCuelume;
+        });
+
+    return glBxSounds.module;
+}
+
+/**
+ * The visitor's level: their own pick in this browser, or where the settings start everyone; off where sounds are off.
+ */
+function bx_sound_level()
+{
+    var oConfig = glBxSounds.config;
+    if(!oConfig || !oConfig.on)
+        return 'off';
+
+    try {
+        var sLevel = localStorage.getItem('bx-sounds');
+        if(sLevel == 'full' || sLevel == 'quiet' || sLevel == 'off')
+            return sLevel;
+    }
+    catch(e) {}
+
+    return oConfig.level;
+}
+
+function bx_sound_is_on()
+{
+    return bx_sound_level() != 'off';
+}
+
+function bx_sound_volume()
+{
+    return glBxSounds.config.volume * (bx_sound_level() == 'quiet' ? BX_SOUND_QUIET : 1);
+}
+
+/**
+ * The set a kind of action plays: its own, the main one, or 'off'.
+ */
+function bx_sound_set(sGroup)
+{
+    var oGroups = glBxSounds.config.groups || {};
+    return sGroup && oGroups[sGroup] ? oGroups[sGroup] : glBxSounds.config.set;
+}
+
+/**
+ * Cuelume's settings for this page. A press that needs other ones changes them for itself (bx_sound_gate).
+ */
+function bx_sound_apply()
+{
+    glBxSounds.restore = null;
+
+    if(!glBxSounds.cuelume)
+        return;
+
+    glBxSounds.cuelume.setTheme(glBxSounds.config.set);
+    glBxSounds.cuelume.setVolume(bx_sound_volume());
+}
+
+/**
+ * Runs before bind() for each event that can press a marked control: a kind of action with a set of its own plays in
+ * it, and one that is off, or a visitor who turned sounds off, plays nothing. Cuelume reads the set and volume as it
+ * plays, so they are back to the page's own on the next tick.
+ */
+function bx_sound_gate(oEvent)
+{
+    if(!glBxSounds.cuelume || !(oEvent.target instanceof Element))
+        return;
+
+    var oControl = oEvent.target.closest(Object.keys(aBxSoundAttributes).map(function(sAttribute) {
+        return '[' + sAttribute + ']';
+    }).join(','));
+    if(!oControl)
+        return;
+
+    var sSet = bx_sound_is_on() ? bx_sound_set(bx_sound_group(oControl)) : 'off';
+    if(sSet == glBxSounds.config.set)
+        return;
+
+    if(sSet == 'off')
+        glBxSounds.cuelume.setVolume(0);
+    else
+        glBxSounds.cuelume.setTheme(sSet);
+
+    if(!glBxSounds.restore)
+        glBxSounds.restore = setTimeout(bx_sound_apply, 0);
+}
+
+/**
+ * The kind of action of a marked control: data-bx-sound-group on it or around it, otherwise the one of its cue.
+ */
+function bx_sound_group(oControl)
+{
+    var oGroup = oControl.closest('[data-bx-sound-group]');
+    if(oGroup)
+        return oGroup.getAttribute('data-bx-sound-group');
+
+    for(var sAttribute in aBxSoundAttributes) {
+        if(!oControl.hasAttribute(sAttribute))
+            continue;
+
+        var sCue = oControl.getAttribute(sAttribute);
+        return aBxSoundGroups[aBxSoundGroups[sCue] ? sCue : aBxSoundAttributes[sAttribute]];
+    }
+
+    return '';
+}
+
+/**
+ * Play the cue for an outcome, e.g. bx_sound('success', {emphasis: 'subtle'}) once a form is saved. `group` names
+ * the kind of action when the cue alone doesn't ('reactions' for a like). Arrivals ('ready') that come together,
+ * like several live updates in one poll, play once.
+ */
+function bx_sound(sCue, oOptions)
+{
+    if(!bx_sound_is_on())
+        return;
+
+    var sSet = bx_sound_set(oOptions && oOptions.group ? oOptions.group : aBxSoundGroups[sCue]);
+    if(sSet == 'off')
+        return;
+
+    if(sCue == 'ready') {
+        var iNow = Date.now();
+        if(iNow - glBxSounds.readyAt < 2000)
+            return;
+
+        glBxSounds.readyAt = iNow;
+    }
+
+    bx_sound_load().then(function(oCuelume) {
+        oCuelume.play(sCue, $.extend({}, oOptions, {theme: sSet}));
+    }, function() {});
+}
+
+/**
+ * The reply to an AJAX form: error when UNA refuses it (a non-zero code, or the form or a popup back with a field
+ * error), a subtle success when it says the form went through (code 0, a redirect, a reload or a script to run).
+ * A reply that names its own sound (`cue`) gets only that one, from processJsonData; a reply that only carries a
+ * message plays nothing, since a message alone can say either.
+ */
+function bx_sound_form_reply(oData)
+{
+    if(!oData || oData.cue != undefined)
+        return;
+
+    var mixedPopup = oData.popup && typeof(oData.popup) == 'object' ? oData.popup.html : oData.popup;
+    if((oData.code != undefined && Number.parseInt(oData.code) != 0) || bx_sound_has_field_error(oData.form) || bx_sound_has_field_error(mixedPopup))
+        bx_sound('error');
+    else if(oData.code != undefined || oData.redirect != undefined || oData.reload != undefined || oData.eval != undefined)
+        bx_sound('success', {emphasis: 'subtle'});
+}
+
+/**
+ * Whether a form's HTML came back with a field error in it.
+ */
+function bx_sound_has_field_error(sHtml)
+{
+    return typeof(sHtml) == 'string' && $('<div>' + sHtml + '</div>').find('.bx-form-warn').toArray().some(function(oWarn) {
+        return oWarn.textContent.trim().length > 0;
+    });
+}
+
+/**
+ * The visitor's level of interface sounds in this browser: 'full', 'quiet' or 'off'. A new level that can be heard
+ * answers from the same click, which also lets the browser start audio; off is heard as silence.
+ */
+function bx_sound_set_level(sLevel)
+{
+    if(!glBxSounds.config || !glBxSounds.config.on)
+        return;
+
+    var aLevels = ['off', 'quiet', 'full'];
+    var iFrom = aLevels.indexOf(bx_sound_level());
+    try {
+        localStorage.setItem('bx-sounds', sLevel);
+    }
+    catch(e) {}
+
+    bx_sound_mark_switches();
+
+    if(sLevel == 'off') {
+        // Whatever still rings stops at once; the module stays ready for the next level.
+        if(glBxSounds.cuelume) {
+            glBxSounds.cuelume.setEnabled(false);
+            glBxSounds.cuelume.setEnabled(true);
+        }
+        return;
+    }
+
+    bx_sound_load().then(function() {
+        bx_sound_apply();
+        bx_sound('toggle', {direction: aLevels.indexOf(sLevel) > iFrom ? 'forward' : 'back'});
+    }, function() {});
+}
+
+/**
+ * The account menu's Sounds item steps through the levels: full, quiet, off.
+ */
+function bx_sound_toggle(oSwitch)
+{
+    bx_sound_set_level({full: 'quiet', quiet: 'off', off: 'full'}[bx_sound_level()]);
+}
+
+/**
+ * Show the visitor's level on every Sounds control: the account menu item's name and icon (volume-up, volume-down,
+ * volume-mute; the Lucide iconset redraws it from a new <i>), and the checked option of Studio's radio group. Where
+ * sounds are off for everyone, the item goes away.
+ */
+function bx_sound_mark_switches()
+{
+    var oConfig = glBxSounds.config;
+    var sLevel = bx_sound_level();
+    var sIcon = {full: 'volume-up', quiet: 'volume-down', off: 'volume-mute'}[sLevel];
+    var bRedraw = false;
+
+    $('[data-bx-sounds-switch]').each(function() {
+        if(!oConfig || !oConfig.on) {
+            $(this).closest('li').hide();
+            return;
+        }
+
+        $(this).attr({'aria-label': oConfig.labels[sLevel], title: oConfig.labels[sLevel]});
+
+        $(this).find('.sys-icon.volume-up, .sys-icon.volume-down, .sys-icon.volume-mute').each(function() {
+            if($(this).hasClass(sIcon))
+                return;
+
+            if(this.tagName.toLowerCase() == 'i')
+                $(this).removeClass('volume-up volume-down volume-mute').addClass(sIcon);
+            else {
+                $(this).replaceWith($('<i>').addClass('sys-icon ' + sIcon));
+                bRedraw = true;
+            }
+        });
+    });
+
+    $('[data-bx-sounds-level]').each(function() {
+        var bChecked = $(this).attr('data-bx-sounds-level') == sLevel;
+        $(this).attr('aria-checked', bChecked ? 'true' : 'false').attr('tabindex', bChecked ? '0' : '-1');
+    });
+
+    if(bRedraw && typeof(bx_iconset_init_lucide) == 'function')
+        bx_iconset_init_lucide();
+}
+
+/**
+ * Settings > Sounds: a choice plays as it is made, with the set and volume picked in the form, before it is saved and
+ * even while sounds are off here.
+ */
+function bx_sound_preview(oSelect)
+{
+    var oForm = $(oSelect).closest('form');
+    var fValue = function(sName) {
+        return oForm.find('[name="sys_sounds_' + sName + '"]').val();
+    };
+
+    var sOption = $(oSelect).attr('data-bx-sound-option');
+    var sValue = $(oSelect).val();
+    var sSet = fValue('set') || 'default';
+    var fVolume = (parseInt(fValue('volume')) || 100) / 100;
+    var sCue = 'toggle';
+
+    if(sOption == 'level') {
+        if(sValue == 'off')
+            return;
+        if(sValue == 'quiet')
+            fVolume *= BX_SOUND_QUIET;
+    }
+    else if(aBxSoundSamples[sOption]) {
+        if(sValue == 'off')
+            return;
+        if(sValue)
+            sSet = sValue;
+        sCue = aBxSoundSamples[sOption];
+    }
+
+    bx_sound_load().then(function(oCuelume) {
+        // play() takes the volume now; the page's own is back at once.
+        oCuelume.setVolume(fVolume);
+        oCuelume.play(sCue, {theme: sSet});
+        bx_sound_apply();
+    }, function() {});
+}
+
+$(document).ready(function() {
+    // A cached menu can still carry the Sounds item where sounds are off for everyone.
+    if(!glBxSounds.config)
+        $('[data-bx-sounds-switch]').closest('li').hide();
+});
+
 function bx_is_mouse()
 {
     return matchMedia('(pointer:fine)').matches;

+ 32 - 2
inc/js/jquery.dolPopup.js

@@ -25,7 +25,8 @@
         onShow: function () {},
         onBeforeHide: function () {},
         onHide: function () {},
-        speed: 200
+        speed: 200,
+        cue: true // play the open/close interface sound (see bx_sound); alerts report outcomes, which play their own
     }; 
 
     $.fn.dolPopupDefaultPointerOptions = {
@@ -80,6 +81,26 @@
         return widthNoScroll - widthWithScroll;
     };
 
+    /**
+     * A popup sounds as it opens and when the visitor closes it (close element, Escape, outer click, its trigger).
+     * One closed from code after a save stays quiet: the save plays its own cue. Menus anchored to a pointer are
+     * frequent, so they are subtle.
+     */
+    function _dolPopupCue (o, sCue, $el) {
+        if (!o || o.cue === false || typeof(bx_sound) != 'function')
+            return;
+
+        // A press on the trigger of an open popup is an outer mousedown and then the trigger's own click: one close.
+        if ($el) {
+            var iNow = Date.now();
+            if (sCue == 'close' && iNow - ($el.data('bx-popup-cue-closed') || 0) < 500)
+                return;
+            $el.data('bx-popup-cue-closed', sCue == 'close' ? iNow : 0);
+        }
+
+        bx_sound(sCue, {emphasis: false != o.pointer ? 'subtle' : 'normal'});
+    };
+
     function _dolPopupLockScreen (bLock) {
         var eBody = $(document.body);
         var eBodyHtml = $("html, body");
@@ -126,8 +147,10 @@
                 height: $(window).height()
             });
         }).on('keyup.popup', function(oEvent) {
-            if(o.closeOnOuterClick && oEvent.which == 27)
+            if(o.closeOnOuterClick && oEvent.which == 27) {
+                _dolPopupCue(o, 'close', $this);
                 $this.dolPopupHide();
+            }
         });
 
         return this.each(function() {
@@ -140,10 +163,13 @@
                 return false;
 
             if ($el.is(":visible")) { // if popup is shown - hide it
+                _dolPopupCue($el.data('bx-popup-options') || o, 'close', $el);
                 $el.dolPopupHide();
                 return true;
             } 
 
+            _dolPopupCue(o, 'open', $el);
+
             if (false != o.pointer)
                 $el.addClass('bx-popup-with-pointer'); // to make all popups with pointer not responsive add: .removeClass('bx-popup-responsive')
 
@@ -187,6 +213,7 @@
                         $(o.closeElement, $el)
                             .css('cursor', 'hand')
                             .click(function() {
+                                _dolPopupCue($el.data('bx-popup-options'), 'close', $el);
                                 $el.dolPopupHide();
                             });
                     }
@@ -196,6 +223,7 @@
                         var fCallback = function(e) {
                             if ($el.hasClass('bx-popup-applied') && $el.is(':visible')) {
                                 if ($(e.target).parents('#' + $el.attr('id')).length == 0) {
+                                    _dolPopupCue($el.data('bx-popup-options'), 'close', $el);
                                     $el.dolPopupHide();
                                 }
                             }
@@ -710,6 +738,7 @@
                 oBtnOk.html(sDefBtnOkTitle);
         };
 
+        options.cue = false;
         oAPopup.dolPopup(options);
     };
 
@@ -758,6 +787,7 @@
             if(bFuncNo)
                 options.onClickNo(oCPopup);
 
+            _dolPopupCue(options, 'close', oCPopup);
             oCPopup.dolPopupHide();
         });
 

+ 4 - 0
inc/js/jquery.webForms.js

@@ -135,6 +135,10 @@
             };
 
             $('.bx-form-section-title:first', eFormSection).click(function() {
+                // an accordion: a subtle open or close (interface sounds, see bx_sound)
+                if(typeof(bx_sound) == 'function')
+                    bx_sound(eFormSection.hasClass('bx-form-collapsed') ? 'open' : 'close', {emphasis: 'subtle'});
+
                 fCallback();
                 fSyncExpanded();
             });

+ 21 - 0
install/sql/system.sql

@@ -853,6 +853,25 @@ INSERT INTO `sys_options`(`category_id`, `name`, `caption`, `info`, `value`, `ty
 (@iCategoryId, 'sys_vote_reactions_per_user', '_adm_stg_cpt_option_sys_vote_reactions_per_user', '_adm_stg_inf_option_sys_vote_reactions_per_user', '1', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:35:"get_options_vote_reactions_per_user";s:5:"class";s:13:"TemplServices";}', '', '', 3),
 (@iCategoryId, 'sys_vote_reactions_split_counters', '_adm_stg_cpt_option_sys_vote_reactions_split_counters', '_adm_stg_inf_option_sys_vote_reactions_split_counters', '', 'checkbox', '', '', '', 4);
 
+--
+-- CATEGORY: Sounds
+--
+INSERT INTO `sys_options_categories`(`type_id`, `name`, `caption`, `hidden`, `order`) VALUES (@iTypeId, 'sounds', '_adm_stg_cpt_category_sounds', 0, 24);
+SET @iCategoryId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options`(`category_id`, `name`, `caption`, `info`, `value`, `type`, `extra`, `check`, `check_error`, `order`) VALUES
+(@iCategoryId, 'sys_sounds_platforms', '_adm_stg_cpt_option_sys_sounds_platforms', '_adm_stg_inf_option_sys_sounds_platforms', 'site,studio,neo_web,ios,android', 'list', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:28:"get_options_sounds_platforms";s:5:"class";s:13:"TemplServices";}', '', '', 1),
+(@iCategoryId, 'sys_sounds_level', '_adm_stg_cpt_option_sys_sounds_level', '_adm_stg_inf_option_sys_sounds_level', 'full', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:24:"get_options_sounds_level";s:5:"class";s:13:"TemplServices";}', '', '', 2),
+(@iCategoryId, 'sys_sounds_set', '_adm_stg_cpt_option_sys_sounds_set', '_adm_stg_inf_option_sys_sounds_set', 'default', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:22:"get_options_sounds_set";s:5:"class";s:13:"TemplServices";}', '', '', 3),
+(@iCategoryId, 'sys_sounds_volume', '_adm_stg_cpt_option_sys_sounds_volume', '_adm_stg_inf_option_sys_sounds_volume', '100', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:25:"get_options_sounds_volume";s:5:"class";s:13:"TemplServices";}', '', '', 4),
+(@iCategoryId, 'sys_sounds_buttons', '_adm_stg_cpt_option_sys_sounds_buttons', '_adm_stg_inf_option_sys_sounds_buttons', '', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:24:"get_options_sounds_group";s:5:"class";s:13:"TemplServices";}', '', '', 10),
+(@iCategoryId, 'sys_sounds_choices', '_adm_stg_cpt_option_sys_sounds_choices', '_adm_stg_inf_option_sys_sounds_choices', '', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:24:"get_options_sounds_group";s:5:"class";s:13:"TemplServices";}', '', '', 11),
+(@iCategoryId, 'sys_sounds_reactions', '_adm_stg_cpt_option_sys_sounds_reactions', '_adm_stg_inf_option_sys_sounds_reactions', '', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:24:"get_options_sounds_group";s:5:"class";s:13:"TemplServices";}', '', '', 12),
+(@iCategoryId, 'sys_sounds_menus', '_adm_stg_cpt_option_sys_sounds_menus', '_adm_stg_inf_option_sys_sounds_menus', '', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:24:"get_options_sounds_group";s:5:"class";s:13:"TemplServices";}', '', '', 13),
+(@iCategoryId, 'sys_sounds_navigation', '_adm_stg_cpt_option_sys_sounds_navigation', '_adm_stg_inf_option_sys_sounds_navigation', '', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:24:"get_options_sounds_group";s:5:"class";s:13:"TemplServices";}', '', '', 14),
+(@iCategoryId, 'sys_sounds_results', '_adm_stg_cpt_option_sys_sounds_results', '_adm_stg_inf_option_sys_sounds_results', '', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:24:"get_options_sounds_group";s:5:"class";s:13:"TemplServices";}', '', '', 15),
+(@iCategoryId, 'sys_sounds_notifications', '_adm_stg_cpt_option_sys_sounds_notifications', '_adm_stg_inf_option_sys_sounds_notifications', '', 'select', 'a:3:{s:6:"module";s:6:"system";s:6:"method";s:24:"get_options_sounds_group";s:5:"class";s:13:"TemplServices";}', '', '', 16);
+
 --
 -- CATEGORY (HIDDEN): Audit
 --
@@ -5108,6 +5127,7 @@ INSERT INTO `sys_menu_items` (`set_name`, `module`, `name`, `title_system`, `tit
 ('sys_account_notifications', 'system', 'cart', '_sys_menu_item_title_system_cart', '_sys_menu_item_title_cart', 'cart.php', '', '', 'cart-plus', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:20:"get_cart_items_count";s:6:"params";a:0:{}s:5:"class";s:21:"TemplPaymentsServices";}', '', 2147483646, 1, 1, 6),
 ('sys_account_notifications', 'system', 'orders', '_sys_menu_item_title_system_orders', '_sys_menu_item_title_orders', 'orders.php', '', '', 'cart-arrow-down', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:16:"get_orders_count";s:6:"params";a:1:{i:0;s:3:"new";}s:5:"class";s:21:"TemplPaymentsServices";}', '', 2147483646, 0, 1, 7),
 ('sys_account_notifications', 'system', 'invoices', '_sys_menu_item_title_system_invoices', '_sys_menu_item_title_invoices', 'invoices.php', '', '', 'file-invoice', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:18:"get_invoices_count";s:6:"params";a:1:{i:0;s:6:"unpaid";}s:5:"class";s:21:"TemplPaymentsServices";}', '', 2147483646, 0, 1, 8),
+('sys_account_notifications', 'system', 'sounds', '_sys_menu_item_title_system_sounds', '_sys_menu_item_title_sounds', 'javascript:void(0);', 'bx_sound_toggle(this);', '', 'volume-up', '', '', 2147483646, 1, 1, 9998),
 ('sys_account_notifications', 'system', 'logout', '_sys_menu_item_title_system_logout', '_sys_menu_item_title_logout', 'page.php?i=logout', '', '', 'sign-out-alt', '', '', 2147483646, 1, 1, 9999);
 
 -- account settings menu
@@ -5217,6 +5237,7 @@ INSERT INTO `sys_menu_items` (`set_name`, `module`, `name`, `title_system`, `tit
 ('sys_studio_account_popup', 'system', 'tour', '_sys_menu_item_title_system_sa_tour', '_sys_menu_item_title_sa_tour', '{url_studio}launcher.php?tour=1', 'if(typeof glTour !== ''undefined'') { glTour.start(); return false; }', '', 'ami-tour.svg', '', 2147483647, 1, 0, 0, 4),
 ('sys_studio_account_popup', 'system', 'scheme', '_sys_menu_item_title_system_sa_scheme', '_sys_menu_item_title_sa_scheme', 'javascript:void(0)', '', '', 'ami-theme.svg', '', 2147483647, 1, 0, 0, 7),
 ('sys_studio_account_popup', 'system', 'language', '_sys_menu_item_title_system_sa_language', '_sys_menu_item_title_sa_language', 'javascript:void(0)', 'bx_menu_popup(''sys_switch_language_popup'', window);', '', 'ami-language.svg', '', 2147483647, 1, 0, 0, 5),
+('sys_studio_account_popup', 'system', 'sounds', '_sys_menu_item_title_system_sa_sounds', '_sys_menu_item_title_sa_sounds', 'javascript:void(0)', 'bx_sound_toggle(this);', '', 'ami-sounds.svg', '', 2147483647, 1, 0, 0, 8),
 ('sys_studio_account_popup', 'system', 'logout', '_sys_menu_item_title_system_sa_logout', '_sys_menu_item_title_sa_logout', '{url_root}logout.php', '{js_object}.clickLogout(this);', '', 'ami-logout.svg', '', 2147483647, 1, 0, 0, 6);
 -- --------------------------------------------------------
 

+ 4 - 0
modules/boonex/artificer/data/template/system/_header_js_custom.html

@@ -77,6 +77,10 @@
         var oSidebar = bx_sidebar_get(sType);
         var bSidebarActive = bx_sidebar_active(sType);
 
+        // A drawer opening or closing (interface sounds, see bx_sound).
+        if(typeof(bx_sound) == 'function')
+            bx_sound(bSidebarActive ? 'close' : 'open');
+
         oSidebar.toggleClass('bx-sidebar-active', !bSidebarActive);
 
         var oTrigger = $('.bx-sidebar-' + sType + '-trigger');

+ 44 - 0
modules/boonex/english/data/langs/system/en.xml

@@ -1899,6 +1899,17 @@
     <string name="_adm_stg_inf_option_sys_live_search_limit"><![CDATA[Set the maximum number of results displayed in Live Search suggestion box.]]></string>
     <string name="_adm_stg_cpt_option_sys_profiles_search_limit"><![CDATA[Profile Search Results]]></string>
     <string name="_adm_stg_inf_option_sys_profiles_search_limit"><![CDATA[Set the maximum number of results displayed per page in Profile Search.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_buttons"><![CDATA[Submit and other buttons. In the apps, buttons answer with haptics only.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_choices"><![CDATA[Tabs, menu items, switches, checkboxes, radio buttons.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_level"><![CDATA[Everyone can pick Full, Quiet or Off for themselves: in the account menu on the site and in Studio, in the footer and the dashboard of the apps.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_menus"><![CDATA[Menus, popups, drawers and sections opening and closing.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_navigation"><![CDATA[The apps' tab bar and back buttons, opening Studio apps.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_notifications"><![CDATA[New notifications, messages and requests.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_platforms"><![CDATA[Short sounds when people press, open, save and get notified. Where a platform is off, nobody hears them there. The apps pick up changes with the next page they load.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_reactions"><![CDATA[Likes, reactions, ratings, favorites, follows.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_results"><![CDATA[Saved, posted or sent; refused with an error.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_set"><![CDATA[Glass is warm and calm. Mechanical is dry and precise. Bubble is playful. Press feels like a trackpad and rings longer. Each choice plays as you pick it.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_volume"><![CDATA[Quiet plays at 40% of it.]]></string>
     <string name="_adm_stg_inf_option_sys_vote_reactions_default"><![CDATA[Given with a single click or tap; other reactions are a hover or long press away. With none, the reaction button always opens the choice of reactions.]]></string>
     <string name="_adm_stg_inf_option_sys_vote_reactions_mode"><![CDATA[Solo: one reaction, the default one, given with a click. Few: a short list of reactions to choose from. Many: any emoji, chosen in an emoji picker.]]></string>
     <string name="_adm_stg_inf_option_sys_vote_reactions_per_user"><![CDATA[How many different reactions one person may give to the same content. Works in the Few and Many modes.]]></string>
@@ -2008,6 +2019,7 @@
     <string name="_adm_stg_cpt_option_sys_pwa_sw_offline"><![CDATA[Offline page]]></string>
 
     <string name="_adm_stg_cpt_category_sockets"><![CDATA[Sockets server]]></string>
+    <string name="_adm_stg_cpt_category_sounds"><![CDATA[Sounds]]></string>
     <string name="_adm_stg_cpt_option_sys_sockets_type"><![CDATA[Type]]></string>
     <string name="_adm_stg_cpt_option_sys_sockets_url"><![CDATA[Url]]></string>
     <string name="_adm_stg_cpt_option_sys_sockets_app_id"><![CDATA[App id]]></string>
@@ -2015,6 +2027,31 @@
     <string name="_adm_stg_cpt_option_sys_sockets_secret"><![CDATA[App secret]]></string>
     <string name="_adm_stg_cpt_option_sys_sockets_type_sys_sockets_disabled"><![CDATA[Disabled]]></string>
     <string name="_adm_stg_cpt_option_sys_sockets_type_sys_sockets_soketi"><![CDATA[Soketi]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_buttons"><![CDATA[Sound for buttons]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_choices"><![CDATA[Sound for choices]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_group_main"><![CDATA[Main sound set]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_group_off"><![CDATA[No sound]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_level"><![CDATA[Level for people who haven't chosen]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_level_full"><![CDATA[Full]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_level_off"><![CDATA[Off]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_level_quiet"><![CDATA[Quiet]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_menus"><![CDATA[Sound for menus and popups]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_navigation"><![CDATA[Sound for navigation]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_notifications"><![CDATA[Sound for notifications]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platform_android"><![CDATA[Android app]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platform_ios"><![CDATA[iOS app]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platform_neo_web"><![CDATA[NEO web app]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platform_site"><![CDATA[Website]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platform_studio"><![CDATA[Studio]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platforms"><![CDATA[Play interface sounds on]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_reactions"><![CDATA[Sound for likes and reactions]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_results"><![CDATA[Sound for results]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_set"><![CDATA[Sound set]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_set_bubble"><![CDATA[Bubble]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_set_default"><![CDATA[Glass]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_set_mech"><![CDATA[Mechanical]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_set_press"><![CDATA[Press]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_volume"><![CDATA[Volume]]></string>
     
     <string name="_adm_stg_cpt_option_sys_std_show_header_left"><![CDATA[Studio: Show header left area (Breadcrumb)]]></string>
     <string name="_adm_stg_cpt_option_sys_std_show_header_left_search"><![CDATA[Studio: Show search in header left]]></string>
@@ -3508,7 +3545,9 @@
     <string name="_sys_menu_item_title_social_sharing_linked_in"><![CDATA[Share in LinkedIn]]></string>
     <string name="_sys_menu_item_title_system_social_sharing_linked_in"><![CDATA[Share in LinkedIn]]></string>
     <string name="_sys_menu_item_title_social_sharing_whatsapp"><![CDATA[Share in Whatsapp]]></string>
+    <string name="_sys_menu_item_title_sounds"><![CDATA[Sounds]]></string>
     <string name="_sys_menu_item_title_system_social_sharing_whatsapp"><![CDATA[Share in Whatsapp]]></string>
+    <string name="_sys_menu_item_title_system_sounds"><![CDATA[Sounds]]></string>
     
     <string name="_sys_menu_item_title_sm_in_reply_to"><![CDATA[In reply to]]></string>
     <string name="_sys_menu_item_title_sm_author"><![CDATA[Author]]></string>
@@ -3811,7 +3850,9 @@
     <string name="_sys_menu_item_title_system_sa_account"><![CDATA[Account]]></string>
     <string name="_sys_menu_item_title_sa_account"><![CDATA[{member_display_name}]]></string>
     <string name="_sys_menu_item_title_system_sa_site"><![CDATA[Visit Web App]]></string>
+    <string name="_sys_menu_item_title_system_sa_sounds"><![CDATA[Sounds]]></string>
     <string name="_sys_menu_item_title_sa_site"><![CDATA[Visit Web App]]></string>
+    <string name="_sys_menu_item_title_sa_sounds"><![CDATA[Sounds]]></string>
     <string name="_sys_menu_item_title_system_sa_edit"><![CDATA[Manage Apps]]></string>
     <string name="_sys_menu_item_title_sa_edit"><![CDATA[Manage Apps]]></string>
     <string name="_sys_menu_item_title_system_sa_tour"><![CDATA[Introduction Tour]]></string>
@@ -4434,6 +4475,9 @@
         <string name="_sys_txt_share_popup_url"><![CDATA[URL]]></string>
         <string name="_sys_txt_share_popup_copy_to_clipboard_text"><![CDATA[Click for copy to the clipboard]]></string>
         <string name="_sys_txt_share_popup_embeded_code"><![CDATA[Embeded code]]></string>
+        <string name="_sys_txt_sounds_level_full"><![CDATA[Sounds: Full]]></string>
+        <string name="_sys_txt_sounds_level_off"><![CDATA[Sounds: Off]]></string>
+        <string name="_sys_txt_sounds_level_quiet"><![CDATA[Sounds: Quiet]]></string>
     
         <string name="_sys_txt_search_sort_by"><![CDATA[Sort by]]></string>
         <string name="_sys_txt_search_sort_by_default"><![CDATA[Default]]></string>

+ 44 - 0
modules/boonex/russian/data/langs/system/ru.xml

@@ -1871,6 +1871,17 @@
     <string name="_adm_stg_inf_option_sys_live_search_limit"><![CDATA[Установите максимальное количество результатов, отображаемых в поле предложений интерактивного поиска.]]></string>
     <string name="_adm_stg_cpt_option_sys_profiles_search_limit"><![CDATA[Результаты поиска профилей]]></string>
     <string name="_adm_stg_inf_option_sys_profiles_search_limit"><![CDATA[Установите максимальное количество результатов, отображаемых на странице поиска профиля.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_buttons"><![CDATA[Кнопки отправки и другие кнопки. В приложениях кнопки отвечают только вибрацией.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_choices"><![CDATA[Вкладки, пункты меню, переключатели, флажки, радиокнопки.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_level"><![CDATA[Каждый может выбрать для себя «Громко», «Тихо» или «Выкл.»: в меню аккаунта на сайте и в Студии, в подвале и на панели управления в приложениях.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_menus"><![CDATA[Открытие и закрытие меню, всплывающих окон, панелей и разделов.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_navigation"><![CDATA[Панель вкладок и кнопки «Назад» в приложениях, открытие приложений Студии.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_notifications"><![CDATA[Новые уведомления, сообщения и запросы.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_platforms"><![CDATA[Короткие звуки, когда люди нажимают, открывают, сохраняют и получают уведомления. Там, где платформа выключена, их не слышит никто. Приложения получают изменения со следующей загруженной страницей.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_reactions"><![CDATA[Лайки, реакции, оценки, избранное, подписки.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_results"><![CDATA[Сохранено, опубликовано или отправлено; отклонено с ошибкой.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_set"><![CDATA[«Стекло» тёплое и спокойное. «Механика» сухая и точная. «Пузыри» игривые. «Трекпад» звучит как нажатие трекпада и звенит дольше. Каждый вариант звучит, когда вы его выбираете.]]></string>
+    <string name="_adm_stg_inf_option_sys_sounds_volume"><![CDATA[Уровень «Тихо» звучит на 40% от неё.]]></string>
     <string name="_adm_stg_inf_option_sys_vote_reactions_default"><![CDATA[Ставится одним нажатием, остальные реакции открываются наведением или долгим нажатием. Если не выбрана, кнопка реакций всегда открывает выбор.]]></string>
     <string name="_adm_stg_inf_option_sys_vote_reactions_mode"><![CDATA[Одна: только реакция по умолчанию, ставится одним нажатием. Несколько: короткий список реакций на выбор. Много: любой эмодзи из полного списка.]]></string>
     <string name="_adm_stg_inf_option_sys_vote_reactions_per_user"><![CDATA[Сколько разных реакций один человек может поставить одному материалу. Работает в режимах «Несколько» и «Много».]]></string>
@@ -1979,6 +1990,7 @@
     <string name="_adm_stg_cpt_option_sys_pwa_sw_offline"><![CDATA[Офлайн-страница]]></string>
 
     <string name="_adm_stg_cpt_category_sockets"><![CDATA[Сервер для сокетов]]></string>
+    <string name="_adm_stg_cpt_category_sounds"><![CDATA[Звуки]]></string>
     <string name="_adm_stg_cpt_option_sys_sockets_type"><![CDATA[Тип]]></string>
     <string name="_adm_stg_cpt_option_sys_sockets_url"><![CDATA[Ссылка]]></string>
     <string name="_adm_stg_cpt_option_sys_sockets_app_id"><![CDATA[App id]]></string>
@@ -1986,6 +1998,31 @@
     <string name="_adm_stg_cpt_option_sys_sockets_secret"><![CDATA[App secret]]></string>
     <string name="_adm_stg_cpt_option_sys_sockets_type_sys_sockets_disabled"><![CDATA[Выключена]]></string>
     <string name="_adm_stg_cpt_option_sys_sockets_type_sys_sockets_soketi"><![CDATA[Soketi]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_buttons"><![CDATA[Звук кнопок]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_choices"><![CDATA[Звук выбора]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_group_main"><![CDATA[Основной набор]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_group_off"><![CDATA[Без звука]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_level"><![CDATA[Уровень для тех, кто его не выбрал]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_level_full"><![CDATA[Громко]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_level_off"><![CDATA[Выкл.]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_level_quiet"><![CDATA[Тихо]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_menus"><![CDATA[Звук меню и окон]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_navigation"><![CDATA[Звук переходов]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_notifications"><![CDATA[Звук уведомлений]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platform_android"><![CDATA[Приложение для Android]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platform_ios"><![CDATA[Приложение для iOS]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platform_neo_web"><![CDATA[Веб-приложение NEO]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platform_site"><![CDATA[Сайт]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platform_studio"><![CDATA[Студия]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_platforms"><![CDATA[Звуки интерфейса звучат]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_reactions"><![CDATA[Звук лайков и реакций]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_results"><![CDATA[Звук результатов]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_set"><![CDATA[Набор звуков]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_set_bubble"><![CDATA[Пузыри]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_set_default"><![CDATA[Стекло]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_set_mech"><![CDATA[Механика]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_set_press"><![CDATA[Трекпад]]></string>
+    <string name="_adm_stg_cpt_option_sys_sounds_volume"><![CDATA[Громкость]]></string>
 
     <string name="_adm_stg_cpt_option_sys_std_show_header_left"><![CDATA[Студия: Показать левую область заголовка (Хлебные крошки)]]></string>
     <string name="_adm_stg_cpt_option_sys_std_show_header_left_search"><![CDATA[Студия: Показать поиск в заголовке слева]]></string>
@@ -3475,6 +3512,7 @@
     <string name="_sys_menu_item_title_social_sharing_facebook"><![CDATA[Поделиться в Facebook]]></string>
     <string name="_sys_menu_item_title_social_sharing_twitter"><![CDATA[Поделиться в Twitter]]></string>
     <string name="_sys_menu_item_title_social_sharing_pinterest"><![CDATA[Поделиться в Pinterest]]></string>
+    <string name="_sys_menu_item_title_sounds"><![CDATA[Звуки]]></string>
     <string name="_sys_menu_item_title_sm_in_reply_to"><![CDATA[В ответ к]]></string>
     <string name="_sys_menu_item_title_sm_author"><![CDATA[Автор]]></string>
     <string name="_sys_menu_item_title_sm_date"><![CDATA[Дата]]></string>
@@ -3605,6 +3643,7 @@
     <string name="_sys_menu_item_title_system_social_sharing_facebook"><![CDATA[Поделиться в Facebook]]></string>
     <string name="_sys_menu_item_title_system_social_sharing_twitter"><![CDATA[Поделиться в Twitter]]></string>
     <string name="_sys_menu_item_title_system_social_sharing_pinterest"><![CDATA[Поделиться в Pinterest]]></string>
+    <string name="_sys_menu_item_title_system_sounds"><![CDATA[Звуки]]></string>
     <string name="_sys_menu_item_title_system_sm_in_reply_to"><![CDATA[В ответ к]]></string>
     <string name="_sys_menu_item_title_system_sm_author"><![CDATA[Автор]]></string>
     <string name="_sys_menu_item_title_system_sm_date"><![CDATA[Дата]]></string>
@@ -3768,7 +3807,9 @@
     <string name="_sys_menu_item_title_system_sa_account"><![CDATA[Аккаунт]]></string>
     <string name="_sys_menu_item_title_sa_account"><![CDATA[{member_display_name}]]></string>
     <string name="_sys_menu_item_title_system_sa_site"><![CDATA[Открыть веб-приложение]]></string>
+    <string name="_sys_menu_item_title_system_sa_sounds"><![CDATA[Звуки]]></string>
     <string name="_sys_menu_item_title_sa_site"><![CDATA[Открыть веб-приложение]]></string>
+    <string name="_sys_menu_item_title_sa_sounds"><![CDATA[Звуки]]></string>
     <string name="_sys_menu_item_title_system_sa_edit"><![CDATA[Управление приложениями]]></string>
     <string name="_sys_menu_item_title_sa_edit"><![CDATA[Управление приложениями]]></string>
     <string name="_sys_menu_item_title_system_sa_tour"><![CDATA[Вводный тур]]></string>
@@ -4356,6 +4397,9 @@
         <string name="_sys_txt_share_popup_url"><![CDATA[URL]]></string>
         <string name="_sys_txt_share_popup_copy_to_clipboard_text"><![CDATA[Кликните чтобы скопировать]]></string>
         <string name="_sys_txt_share_popup_embeded_code"><![CDATA[Код для вставки]]></string>
+        <string name="_sys_txt_sounds_level_full"><![CDATA[Звуки: громко]]></string>
+        <string name="_sys_txt_sounds_level_off"><![CDATA[Звуки: выкл.]]></string>
+        <string name="_sys_txt_sounds_level_quiet"><![CDATA[Звуки: тихо]]></string>
         <string name="_sys_txt_search_sort_by"><![CDATA[Сортировать по]]></string>
         <string name="_sys_txt_search_sort_by_default"><![CDATA[Умолчанию]]></string>
         <string name="_sys_unconfirmed"><![CDATA[Неподтвержденный]]></string>

+ 1 - 0
modules/boonex/timeline/classes/BxTimelineMenuView.php

@@ -25,6 +25,7 @@ class BxTimelineMenuView extends BxTemplMenuCustom
         parent::__construct($aObject, $this->_oModule->_oTemplate);
 
         $this->_bShowDivider = false;
+        $this->_bCueSelect = true; // the feed switcher is a row of tabs (interface sounds)
     }
 }
 

+ 7 - 0
modules/boonex/timeline/js/post.js

@@ -118,6 +118,13 @@ BxTimelinePost.prototype.afterFormPostSubmit = function (oForm, oData)
 
     this.loadingInButton($(oForm).children().find(':submit'), false);
 
+    // Posted: a subtle success; the form back with an error: error.
+    if(oData && typeof(bx_sound) == 'function')
+        if(oData.form != undefined && oData.form_id != undefined)
+            bx_sound('error');
+        else if(oData.id != undefined && parseInt(oData.id) > 0)
+            bx_sound('success', {emphasis: 'subtle'});
+
     if(oData && oData.message != undefined)
         bx_alert(oData.message, fContinue);
     else

+ 7 - 0
modules/boonex/timeline/js/view.js

@@ -845,6 +845,13 @@ BxTimelineView.prototype.afterFormEditSubmit = function (oForm, oData)
 
     this.loadingInButton($(oForm).children().find(':submit'), false);
 
+    // Saved: a subtle success; the form back with an error: error.
+    if(oData && typeof(bx_sound) == 'function')
+        if(oData.form != undefined && oData.form_id != undefined)
+            bx_sound('error');
+        else if(oData.id != undefined && parseInt(oData.id) > 0)
+            bx_sound('success', {emphasis: 'subtle'});
+
     if(oData && oData.message != undefined)
         bx_alert(oData.message, fContinue);
     else

+ 15 - 2
neo/AGENTS.md

@@ -60,7 +60,7 @@ Rules for forks:
 | `config/package.next.js`, `config/package.expo.js` | Extra npm dependencies. The root `preinstall` script merges them into `apps/next/package.json` and `apps/expo/package.json`; don't commit those rewritten files, and restore them before a sync. |
 | `resources/web/` | Flat files copied into `apps/next/public/static/` (gitignored) whenever the Next config loads: `favicon.svg`, `manifest.json` and every icon the manifest names. |
 | `resources/native/` | App icon, splash and other native assets. Reference them from `config/app.config.js` as `../../packages/app/customization/resources/native/...`. |
-| `resources/sounds/` | Default sounds. Point `sounds.js` at your own files. |
+| `resources/sounds/` | Native interface sounds: Cuelume cues rendered to WAV, in every sound set, by `scripts/render-sounds.mjs`. Point keys of `sounds.js` at your own files; see [Interface sounds](#interface-sounds). |
 
 ## Layout and stack
 
@@ -74,7 +74,7 @@ packages/app     shared code, imported as app/...
   context/, lib/ state, fetcher, hooks and helpers
   default/, settings/   defaults that customization/ re-exports
   customization/ fork overrides
-scripts/         typecheck.js, merge-package-customization.js, check-translations.js
+scripts/         typecheck.js, merge-package-customization.js, check-translations.js, render-sounds.mjs
 patches/         patch-package patches, applied on postinstall
 ```
 
@@ -215,6 +215,19 @@ Use `NeoButton`, `NeoButtonLink` or `NeoButtonRef` from `app/design/controls`. T
 - On iOS and Android, `native.expo_ui_buttons` (`settings/configs.js`) renders buttons with native Expo UI (SwiftUI / Compose) views; the `expoUI` prop controls it per button.
 - `/pg` is a playground for every axis (`apps/next/app/pg/page.js`, `apps/expo/app/pg/`). It isn't gated, so production builds serve it too.
 
+### Interface sounds
+
+Sounds are [Cuelume](https://cuelume.dev/agents.md)'s cues, one per interface job. Pick a cue by its job from Cuelume's "Which cue?" table: subtle emphasis for frequent actions, strong only for rare ones that matter. Never play one on hover, on page load, per item of a burst, or for a number that updates constantly. Everything is in `app/lib/sound`.
+
+- Web synthesizes cues with the `cuelume` package. `InterfaceSounds` (`ui/molecules/system/interface-sounds.tsx`, mounted once in `apps/next/app/providers.js` and `components/nav/tabs/index.js`) calls `bind()` and applies the settings.
+- Native plays WAV files rendered from the same package version, in each of Cuelume's four sets (themes): `Sounds` in `default/sounds.js`, keyed `[<set>-]<cue>[-<emphasis>][-back]` (the default set has no prefix). A cue without its own key falls back to a plainer key, then to the default set's, then to the pre-Cuelume key for its job (`click`, `tab`, `success`, `notif`). After bumping `cuelume` (pinned in `package.json`), or to give native a new variant, add it to `CUES` in `scripts/render-sounds.mjs`, run `node scripts/render-sounds.mjs` (needs Chrome; mono 32 kHz, every set) and add the keys to `default/sounds.js`.
+- A press: `cueAttributes(cue)` on the pressed element (web; `bind()` plays it as the pointer goes down) plus `playPressCue(cue)` in the handler (native). NeoButton has a `sound` prop: by default a button with haptics taps (subtle unless prominent), a menu trigger opens or closes, `close` / `cancel` roles close. `PressCueContext` changes the cue for the buttons inside (menu items select). Native buttons answer with haptics only; native plays cues for toggles, disclosures, the tab bar and outcomes.
+- An outcome: `playCue('success', { emphasis: 'subtle' })` once UNA has saved, `playCue('error')` when it refuses. `Form` already does this for every submit with a reply (not for filters or save-on-change forms).
+- Kinds of action: UNA's settings can give buttons, choices, reactions, menus, navigation, results and notifications their own set, or no sound. A cue's kind comes from the cue (`cueGroup` in `lib/sound/cues.ts`); a like, reaction, rating, favorite or follow plays `toggle` with `group: 'reactions'` (`cueAttributes` turns that into `data-bx-sound-group`).
+- Settings: UNA's Studio > Settings > Global > Sounds (`BxDolSounds`) picks where sounds play (`neo_web`, `ios`, `android`), the set, the volume, the level people start at and the set per kind of action. They come with every page as `data.sounds`; `Layouts` passes them to `applySoundConfig` (`lib/sound/config.ts`), which keeps them in memory and storage. Sounds only read that copy, so nothing waits on a request, and changes apply from the next page. On web, a capture listener (`gatePress` in `sound.web.ts`) gives each press its kind's set or silence before `bind()` plays it.
+- Users pick their own level, full, quiet (40%) or off (`useSoundsLevel`, in the footer and the dashboard settings), stored as `layoutSettings.sounds`; unset follows UNA's starting level. `layout.sounds` turns sounds off for the whole build. Native interface sounds follow the iOS silent switch.
+- `useSound` / `playSound` with the old names still work; they play the cue for that job.
+
 ### Page header
 
 The page header is mounted once per `Layout` (`ui/molecules/header/page-header.js` / `.web.js`). A page never writes header state from an effect; it declares what it needs in its render tree with `PageHeaderOptions` from `app/ui/molecules/header/options`:

+ 2 - 0
neo/apps/next/app/providers.js

@@ -10,6 +10,7 @@ import { I18nextProvider, initReactI18next } from 'react-i18next'
 import * as WebBrowser from 'expo-web-browser'
 import { useEffect, useState } from 'react'
 import Subscriber from 'app/ui/molecules/system/subscriber'
+import InterfaceSounds from 'app/ui/molecules/system/interface-sounds'
 import { useLayoutSettings } from 'app/context/layout-settings'
 import { addI18nResources, changeI18nLanguage } from 'app/lib/i18n-resources'
 
@@ -99,6 +100,7 @@ export function Providers({ children, initialLang = 'en', initialResources }) {
                     <SpeedInsights />
                     {children}
                     <Subscriber />
+                    <InterfaceSounds />
                 </QueryClientProvider>
             </JotaiProvider>
         </I18nextProvider>

+ 1 - 0
neo/package.json

@@ -45,6 +45,7 @@
   ],
   "dependencies": {
     "@10play/react-native-web-webview": "^0.0.3",
+    "cuelume": "0.2.6",
     "dotenv-cli": "^11.0.0",
     "web-haptics": "^0.0.6"
   },

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

@@ -7,6 +7,8 @@ import { useState, useReducer, useRef, useEffect, useCallback } from 'react';
 import { components } from 'app/components/registry';;
 import { NeoButton } from 'app/design/controls'
 import useFetchForm from 'app/lib/hooks/use-fetch-form'
+import { formResponseHasFieldErrors } from 'app/lib/form/form-helpers'
+import { playCue } from 'app/lib/sound'
 import { fetcher } from 'app/lib/fetcher';
 import Loading from 'app/ui/atoms/loading'
 import Form from 'app/components/elements/form';
@@ -813,6 +815,9 @@ function CommentsFormInner ({ form: initialForm, requestUrl, module, objectId, i
 
 
     useEffect(() => {
+        // The reply to a posted comment: error when UNA rejects it, subtle success only once the new comment is in it.
+        if (formResponseHasFieldErrors(dynamicData?.data)) playCue('error');
+        else if (dynamicData?.data?.browse?.new?.[0]) playCue('success', { emphasis: 'subtle' });
         if (dynamicData?.data?.browse && dynamicData?.data?.browse?.new?.[0]) {
             emitter.emit(EVENTS.commentThread(module, objectId), { action: 'new_content', data: dynamicData.data.browse.data.data[0]['i' + dynamicData.data.browse.new[0]] });
             handleCancel() 

+ 3 - 0
neo/packages/app/components/elements/covers/cover-elements.js

@@ -19,6 +19,7 @@ import { navigateBackInTab } from 'app/lib/navigation/tab-history'
 import { useCoverBackTabKey, useShowCoverBackButton } from 'app/components/elements/use-cover-back'
 import { useIsPushedScreen } from 'app/context/tab-chrome'
 import { useTranslation } from 'react-i18next'
+import { playCue } from 'app/lib/sound'
 
 // Matches `top-3` overlay spacing on the full cover.
 const COVER_CONTROL_TOP = 10
@@ -43,6 +44,7 @@ function BackButton() {
     const handleBackPress = () => {
         FeedbackHaptics('Medium')
         if (Platform.OS === 'web') {
+            playCue('navigate', { direction: 'back' })
             if (typeof window !== 'undefined' && window.history.length > 2) {
                 window.history.back()
             } else if (router?.replace) {
@@ -70,6 +72,7 @@ function BackButton() {
                 controlSize="regular"
                 borderShape="circle"
                 accessibilityLabel={t('Back')}
+                sound={false}
                 onPress={handleBackPress}
             />
         </View>

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

@@ -24,6 +24,7 @@ import { useFetch } from 'app/lib/hooks/use-fetch'
 import DasbordStatOld from 'app/components/elements/dashboard-stat-old'
 import { useLayoutSettings } from 'app/context/layout-settings'
 import { BlockWrapper } from 'app/components/block-wrapper'
+import { SOUND_LEVEL_ITEMS, useSoundsLevel } from 'app/lib/hooks/use-sounds-level'
 
 const DASHBOARD_GRID =
     'grid w-full grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4'
@@ -342,7 +343,9 @@ function DashboardSettingsMenu() {
     const showTheme = !!appSetting('dashboard', 'switch_theme')
     const showLayouts = appSetting('layout', 'avaliable_layouts').length > 1
     const showFeedUnits = appSetting('layout', 'avaliable_feed_units').length > 1
-    if (!(showLangs || showTheme || showLayouts || showFeedUnits)) return null
+    const { available: showSounds, level: soundLevel, setLevel: setSoundLevel } = useSoundsLevel()
+    const currentSoundLevel = SOUND_LEVEL_ITEMS.find((option) => option.id === soundLevel) ?? SOUND_LEVEL_ITEMS[0]
+    if (!(showLangs || showTheme || showLayouts || showFeedUnits || showSounds)) return null
 
     return (
         <View className={DASHBOARD_GRID}>
@@ -450,6 +453,35 @@ function DashboardSettingsMenu() {
                     </DropdownMenu>
                 </View>
             ) : null}
+            {showSounds ? (
+                <View className="w-full min-w-0">
+                    <DropdownMenu
+                        triggerClassName="w-full"
+                        // The chosen level answers itself, at the new level: its option makes no sound of its own.
+                        items={SOUND_LEVEL_ITEMS.map((option) => ({
+                            key: option.id,
+                            id: option.id,
+                            name: option.id,
+                            title: t(option.titleKey),
+                            selected: soundLevel === option.id,
+                            sound: false,
+                        }))}
+                        onSelect={(oItem) => {
+                            setSoundLevel(oItem.id)
+                        }}
+                    >
+                        <NeoButton
+                            label={t(currentSoundLevel.labelKey)}
+                            style="bordered"
+                            controlSize="large"
+                            image={currentSoundLevel.icon}
+                            width="fill"
+                            align="start"
+                            interactive
+                        />
+                    </DropdownMenu>
+                </View>
+            ) : null}
         </View>
     )
 }

+ 13 - 0
neo/packages/app/components/elements/messenger/parts/common.js

@@ -5,6 +5,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
 import { appSetting, isNativeTabsEnabled } from 'app/lib/util'
 import { Platform } from 'react-native'
 import useFetchForm from 'app/lib/hooks/use-fetch-form'
+import { playCue } from 'app/lib/sound'
 import { useCurrentUser } from 'app/context/user';
 import { subscribe } from 'app/ui/atoms/socket';
 import { KbStickyView } from 'app/ui/atoms/kb-avoiding-view';
@@ -119,6 +120,11 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
         data.form.data.inputs.message.value = '';
         setReplyItem(false);
         setCommentForm(false);
+        // The reply to a sent message: subtle success, or error when UNA refuses it.
+        if (dynamicData?.data?.jot_id > 0)
+            playCue('success', { emphasis: 'subtle' });
+        else if (dynamicData?.data?.data?.data?.msg)
+            playCue('error');
         if (dynamicData?.data?.jot_id > 0)
             scrolTo();
 
@@ -282,6 +288,13 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
 
     const onNewMessage = (data) => {
         if (data.id == convoId) {
+            // Someone else's message in the open conversation: one subtle ready per update.
+            const incoming = data.action == 'added' && Array.isArray(data.data?.jots) ? data.data.jots : [];
+            const fromOthers = incoming.some((jot) => {
+                const author = jot?.author_data?.id ?? jot?.author;
+                return author != null && author != currentUser?.id;
+            });
+            if (fromOthers) playCue('ready', { emphasis: 'subtle' });
             setJotUpdated(data);
         }
     }

+ 3 - 1
neo/packages/app/components/form-fields/selector.js

@@ -70,7 +70,7 @@ export default function FormFieldSelector(props) {
     }, [field.value, valuesList]);
 
     const header = <Row className=' w-full justify-between items-center'>
-        <View><NeoButton onPress={() => { setIsModal(null) }} style="glass" controlSize="regular" borderShape="circle" image="X" accessibilityLabel={t('Close')} /></View>
+        <View><NeoButton onPress={() => { setIsModal(null) }} style="glass" controlSize="regular" borderShape="circle" image="X" accessibilityLabel={t('Close')} sound="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 >
             <NeoButton
@@ -111,6 +111,8 @@ export default function FormFieldSelector(props) {
             {...buttonProps}
             image={isMultiple ? 'Plus' : undefined}
             label={isMultiple ? t('Add') : selectedItems.length > 0 ? t('Change') : t('Select')}
+            // Opens the picker dialog.
+            sound="open"
             onPress={showSelect}
         />
     );

+ 33 - 1
neo/packages/app/components/form.js

@@ -7,6 +7,7 @@ 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';
+import { playCue } from 'app/lib/sound';
 import { sessionCacheGet, sessionCacheSet } from 'app/lib/util/storage';
 import { Platform } from 'react-native';
 import emitter, { EVENTS } from 'app/context/emitter';
@@ -150,6 +151,8 @@ export default function Form({
     }, [name, formInstanceId]);
 
     const { data: dynamicData, error: submitError } = useFetchForm(request?.url, postData);
+    // A submit whose result makes no sound: the `isSubmit` auto-submit.
+    const quietResultRef = useRef(false);
 
     useEffect(() => {
         if (submitError) {
@@ -157,6 +160,33 @@ export default function Form({
         }
     }, [submitError, name, formInstanceId]);
 
+    // The result of a submit: subtle success once UNA takes it, error when it
+    // rejects it (field errors, or a message with a non-zero code). A message
+    // without a code can say either, so it plays nothing unless a redirect or
+    // reload says the form went through. The next step of a wizard is neither.
+    // Filter forms and forms that save every change stay quiet: their controls
+    // sound already.
+    useEffect(() => {
+        if (!dynamicData && !submitError) return;
+        const quiet = quietResultRef.current || isAutoChange || saveOnChanges;
+        quietResultRef.current = false;
+        if (quiet) return;
+        const items = normalizeFormResponseData(dynamicData?.data);
+        const message = items.find((item) => item?.type === 'msg');
+        const hasCode = message?.code !== undefined && message?.code !== null;
+        if (submitError || formResponseHasFieldErrors(dynamicData?.data) || (hasCode && Number(message.code) !== 0)) {
+            playCue('error');
+            return;
+        }
+        if (message && !hasCode && !items.some((item) => item?.type === 'redirect' || item?.reload)) return;
+        const formItem = items.find((item) => item?.type === 'form');
+        const nextDisplay = formItem?.data?.params?.display || formItem?.name;
+        const currentDisplay = formName || formBundle.form?.params?.display;
+        if (nextDisplay && currentDisplay && nextDisplay !== currentDisplay) return;
+        playCue('success', { emphasis: 'subtle' });
+    // eslint-disable-next-line react-hooks/exhaustive-deps -- once per reply
+    }, [dynamicData, submitError]);
+
     // update state when form is submitted
     const onFormSubmit = onFormSubmitInternal ?? ((formData, d) => {
         if (request?.url) {
@@ -436,7 +466,8 @@ export default function Form({
     }
 
     const onError = async d => {
-        //TODO: handle error
+        // A field failed validation before the submit left the browser.
+        playCue('error', { emphasis: 'subtle' });
     }
 
 
@@ -497,6 +528,7 @@ export default function Form({
 
     useEffect(() => {
         if (isSubmit) {
+            quietResultRef.current = true;
             _handleSubmit();
         }
     }, [isSubmit, _handleSubmit]);

+ 1 - 4
neo/packages/app/components/forms/comments.js

@@ -9,7 +9,6 @@ import Profile from 'app/ui/molecules/profile/profile'
 import { FileButton } from 'app/lib/form/form-helpers'
 import { useWindowHeight, useIsDesktop } from 'app/context/measure';
 import emitter, { EVENTS } from 'app/context/emitter';
-import { useSound } from 'app/lib/hooks/use-sound';
 import { useTranslation } from 'react-i18next'
 import { StarsAction } from 'app/ui/atoms/stars'
 
@@ -54,11 +53,9 @@ export default function FormComments(props) {
         // setIsExImage(false)
     }
 
-    const playSound = useSound('success');
-
     useEffect(() => {
+        // The success cue plays when UNA returns the new comment (comments-browse).
         if (formContext.formState.isSubmitted) {
-            playSound();
             formContext.setValue('cmt_text', '');
             if (props.data.inputs['cmt_mood']) {
                 formContext.setValue('cmt_mood', 0);

+ 1 - 4
neo/packages/app/components/forms/feed.js

@@ -15,7 +15,6 @@ import { useIsDesktop, useWindowHeight } from 'app/context/measure';
 import emitter, { EVENTS } from 'app/context/emitter';
 import { Icon } from 'app/ui/atoms/icon'
 import { Text } from 'app/design/typography';
-import { useSound } from 'app/lib/hooks/use-sound';
 import { useTranslation } from 'react-i18next';
 
 const dropdownTheme = appSetting('theme', 'dropdown');
@@ -263,16 +262,14 @@ export default function FormFeed({ data, handleSubmit, exProps, name, response }
 
     const hasText = useMemo(() => stripTags(rawEditorText || '').trim().length > 0, [rawEditorText]);
 
-    const playSound = useSound('success');
-
     function onClose() {
         setShowImage(false)
         exProps?.onClose?.();
     }
 
     useEffect(() => {
+        // The success cue plays in `Form` when UNA takes the post.
         if (response?.id && response?.id != responseId) {
-            playSound();
             emitter.emit(EVENTS.feed, { action: 'new_content', data: response });
             onClose();
             setResponseId(response?.id)

+ 1 - 4
neo/packages/app/components/forms/messenger.js

@@ -8,7 +8,6 @@ import { useCurrentUser } from 'app/context/user'
 import Profile from 'app/ui/molecules/profile/profile'
 import { FileButton } from 'app/lib/form/form-helpers'
 import { useWindowHeight, useIsDesktop } from 'app/context/measure'
-import { useSound } from 'app/lib/hooks/use-sound'
 import emitter, { EVENTS } from 'app/context/emitter'
 import { useTranslation } from 'react-i18next'
 import { MessageInput } from 'app/components/elements/chat/parts/message-input'
@@ -36,11 +35,9 @@ export default function FormMessenger(props) {
         [rawEditorText]
     )
 
-    const playSound = useSound('success')
-
     useEffect(() => {
+        // The success cue plays when UNA returns the sent message (messenger/parts/common.js).
         if (formContext.formState.isSubmitted) {
-            playSound()
             formContext.setValue('message', '')
             emitter.emit(EVENTS.editor, { action: 'set_content', value: '' })
             emitter.emit(EVENTS.fieldFiles('files'), { action: 'clear' })

+ 7 - 0
neo/packages/app/components/layouts.js

@@ -17,6 +17,7 @@ import { coerceVersion, isBelowRange, isAboveRange } from 'app/lib/server-versio
 import { FormModalHost } from 'app/ui/molecules/dialogs/form-modal';
 import { UnsavedFormConfirmHost } from 'app/ui/molecules/dialogs/unsaved-form-confirm-host';
 import { VersionIncompatible, VersionWarning } from 'app/ui/molecules/system/version-notice';
+import { applySoundConfig } from 'app/lib/sound';
 
 function WindowSizeSync() {
     const { width, height } = useWindowDimensions();
@@ -32,6 +33,12 @@ export default function Layouts({ path, data }) {
     useEffect(() => {
         urlRef.current = url;
     }, [url]);
+
+    // UNA's interface sounds settings come with every page; sounds read the last ones, never wait for them.
+    const sounds = data?.sounds;
+    useEffect(() => {
+        applySoundConfig(sounds);
+    }, [sounds]);
     const isWeb = Platform.OS === 'web';
     if (isWeb)
         registerAll();

+ 4 - 0
neo/packages/app/components/menu-items/dropdown-item.js

@@ -7,6 +7,7 @@ import { Icon } from 'app/ui/atoms/icon';
 import Image from 'app/ui/atoms/image';
 import { isEmoji, appSetting, resolveMenuItemDescription } from 'app/lib/util';
 import Link from 'app/ui/atoms/link'
+import { cueAttributes } from 'app/lib/sound';
 const menuSettings = appSetting('theme', 'dropdown_menu');
 
 const getIcon = (oItem, iconSize = 20, rowHovered) => {
@@ -128,6 +129,9 @@ export default function DropdownMenuItem({ item, index, link, handleSelect, clas
             key={key}
             {...pressProps}
             {...rowHoverProps}
+            // A menu option (web; native menus are bottom sheets and stay quiet). `item.sound` changes it; false for
+            // an option whose choice answers itself (the Sounds level).
+            {...(handleSelect || link ? cueAttributes(item.sound ?? 'select') : {})}
         >
             <Row className={`${rowClassName} w-full min-w-0`}>
                 <View className={itemCntClass}>

+ 3 - 4
neo/packages/app/components/nav/footer.web.js

@@ -10,7 +10,7 @@ import { Icon } from 'app/ui/atoms/icon'
 import { useTranslation } from 'react-i18next';
 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 { cueAttributes } from 'app/lib/sound';
 import { isTabBarLabelsEnabled } from 'app/lib/util';
 import DropdownMenu, { DropdownMenuOpenContext } from 'app/ui/atoms/dropdown-menu';
 import { NeoButton } from 'app/design/controls';
@@ -172,7 +172,6 @@ export default function () {
 
 function MenuBottomItem({ link, tabKey, rootUrl, title, badge, icon, isActive, iFrCounter, notifCount, animated, addClassName }) {
     const { currentUser } = useCurrentUser();
-    const playSound = useSound('click');
     const useAnimatedIcon = animated === true;
     const [groupHovered, setGroupHovered] = useState(false);
     const badgeObj = [
@@ -195,9 +194,8 @@ function MenuBottomItem({ link, tabKey, rootUrl, title, badge, icon, isActive, i
     ].find(item => item.condition)?.value || null;
 
     const handlePress = useCallback(() => {
-        playSound();
         rememberSelectedTab(tabKey, { fromPress: true });
-    }, [playSound, tabKey]);
+    }, [tabKey]);
     const baseIconClassName = isActive
         ? 'text-accent-foreground'
         : 'text-secondary-foreground web:group-hover:text-foreground';
@@ -212,6 +210,7 @@ function MenuBottomItem({ link, tabKey, rootUrl, title, badge, icon, isActive, i
                  onClick={handlePress}
             >
                 <View
+                    {...cueAttributes({ cue: 'navigate', emphasis: 'subtle' })}
                     className={` 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'}`}
                     onMouseEnter={useAnimatedIcon ? () => setGroupHovered(true) : undefined}
                     onMouseLeave={useAnimatedIcon ? () => setGroupHovered(false) : undefined}

+ 40 - 1
neo/packages/app/components/nav/menu-footer.js

@@ -7,6 +7,7 @@ import { useCurrentUserNoCounters } from 'app/context/user';
 import { useMenuData } from 'app/context/menu-data';
 import { useLayoutSettings } from 'app/context/layout-settings';
 import DropdownMenu from 'app/ui/atoms/dropdown-menu';
+import { SOUND_LEVEL_ITEMS, useSoundsLevel } from 'app/lib/hooks/use-sounds-level';
 
 export const MENU_FOOTER_CLASSES = 'flex w-full items-center border-t border-border/60 justify-center flex-row flex-wrap gap-1 p-4 min-h-16';
 // Settings use the historical misspelling; keep this key aligned with settings/layout.js.
@@ -105,6 +106,40 @@ function FooterThemeSwitcher({ visualProps }) {
     );
 }
 
+function FooterSoundsLevel({ visualProps }) {
+    const { t } = useTranslation();
+    const { level, setLevel } = useSoundsLevel();
+
+    // The chosen level answers itself (`setLevel` plays it at the new level), so its option makes no sound of its own.
+    const items = SOUND_LEVEL_ITEMS.map((option) => ({
+        key: option.id,
+        id: option.id,
+        name: option.id,
+        title: t(option.titleKey),
+        icon: option.icon,
+        selected: level === option.id,
+        sound: false,
+    }));
+    const current = SOUND_LEVEL_ITEMS.find((option) => option.id === level) ?? SOUND_LEVEL_ITEMS[0];
+    const label = t(current.labelKey);
+
+    return (
+        <DropdownMenu
+            items={items}
+            onSelect={(item) => setLevel(item.id)}
+            buttonProps={{
+                label,
+                image: current.icon,
+                accessibilityLabel: label,
+                style: visualProps.style,
+                controlSize: visualProps.controlSize,
+                className: visualProps.className,
+                textClassName: visualProps.textClassName,
+            }}
+        />
+    );
+}
+
 function FooterLanguageSwitcher({ visualProps }) {
     const { t } = useTranslation();
     const { lang, langCode, setLang } = useLayoutSettings();
@@ -158,8 +193,10 @@ function MenuFooterComponent({
     const { hydrated } = useLayoutSettings();
     const usesRemoteFooter = !menu_items && appSetting('layout', 'user_remote_config');
     const showThemeSwitcher = appSetting('dashboard', 'switch_theme');
+    const { available: soundsAvailable } = useSoundsLevel();
     const configuredLangs = appSetting('layout', AVAILABLE_LANGS_SETTING_KEY);
     const showLanguageSwitcher = hydrated && Array.isArray(configuredLangs) && configuredLangs.length > 1;
+    const showSoundsLevel = hydrated && soundsAvailable;
     const { menuData, isFetched, isLoading } = useMenuData(
         (menu_items || !hydrated) ? null : appSetting('menu_items', 'objects', 'footer')
     );
@@ -188,11 +225,12 @@ function MenuFooterComponent({
             <MenuFooterSkeleton cntClasses={cntClasses} role="contentinfo">
                 {showLanguageSwitcher ? <FooterLanguageSwitcher visualProps={visualProps} /> : null}
                 {showThemeSwitcher ? <FooterThemeSwitcher visualProps={visualProps} /> : null}
+                {showSoundsLevel ? <FooterSoundsLevel visualProps={visualProps} /> : null}
             </MenuFooterSkeleton>
         );
     }
 
-    if (menu_launcher_items.length === 0 && menuData && !showThemeSwitcher && !showLanguageSwitcher)
+    if (menu_launcher_items.length === 0 && menuData && !showThemeSwitcher && !showLanguageSwitcher && !showSoundsLevel)
         return null;
 
     return (
@@ -210,6 +248,7 @@ function MenuFooterComponent({
             ))}
             {showLanguageSwitcher ? <FooterLanguageSwitcher visualProps={visualProps} /> : null}
             {showThemeSwitcher ? <FooterThemeSwitcher visualProps={visualProps} /> : null}
+            {showSoundsLevel ? <FooterSoundsLevel visualProps={visualProps} /> : null}
         </View>
     );
 }

+ 3 - 3
neo/packages/app/components/nav/tab-feedback.js

@@ -1,13 +1,13 @@
 import { FeedbackHaptics } from 'app/lib/util';
-import { playSound } from 'app/lib/hooks/use-sound';
+import { playCue } from 'app/lib/sound';
 
 let lastAt = 0;
 
-/** Tab press: impact haptic + `tab` click when `layout.sounds` is on. */
+/** Tab press: impact haptic + a subtle `navigate` (route change; tabs are pressed often). */
 export function playTabFeedback() {
     const now = Date.now();
     if (now - lastAt < 80) return;
     lastAt = now;
     FeedbackHaptics('Light');
-    playSound('tab');
+    playCue('navigate', { emphasis: 'subtle' });
 }

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

@@ -9,6 +9,7 @@ import { scheduleOneSignalSubscription } from 'app/lib/platform/one-signal';
 import Suggestions from 'app/ui/molecules/misc/suggestions';
 import AsyncWorker from 'app/ui/molecules/system/async-worker';
 import Subscriber from 'app/ui/molecules/system/subscriber';
+import InterfaceSounds from 'app/ui/molecules/system/interface-sounds';
 import { useFonts } from 'expo-font';
 import { enableFreeze, enableScreens } from 'react-native-screens';
 import fonts from 'app/customization/design/fonts/fonts';
@@ -266,6 +267,7 @@ export default function Tabs() {
         <>
             <Suggestions />
             <Subscriber />
+            <InterfaceSounds />
             <View className="flex-1">
                 {nativeTabs ? null : (
                     <View className="w-full z-50"><AsyncWorker /></View>

+ 12 - 1
neo/packages/app/context/layout-settings.ts

@@ -9,6 +9,7 @@ import { fetcher } from 'app/lib/fetcher'
 import { changeI18nLanguage } from 'app/lib/i18n-resources'
 import emitter, { EVENTS } from 'app/context/emitter';
 import { clearAllPageCache } from 'app/lib/cache/clear-page-cache';
+import type { SoundLevel } from 'app/lib/sound/config';
 
 const STORAGE_KEY = 'layout-settings';
 const LANG_MODE_COOKIE = 'neo_lang';
@@ -25,6 +26,8 @@ export type LayoutSettings = {
     density?: string;
     /** Language code or `auto` (device language). */
     lang?: string;
+    /** The user's level of interface sounds; unset follows UNA's settings. `false` (older builds) is `off`. */
+    sounds?: SoundLevel | boolean;
     [key: string]: unknown;
 };
 
@@ -201,6 +204,12 @@ export const useLayoutSettings = () => {
         }, []
     );
 
+    const setSoundLevel = useCallback(
+        async (value: SoundLevel) => {
+            await useLayoutSettingsStore.getState().updateLayoutSettings({ sounds: value });
+        }, []
+    );
+
     const setDensity = useCallback(
         async (value: any) => {
             await useLayoutSettingsStore.getState().updateLayoutSettings({ density: value });
@@ -237,6 +246,8 @@ export const useLayoutSettings = () => {
 
     const { density, name: layoutName, theme: themeName, lang } = layoutSettings ?? {};
     const langCode = resolveLangCode(lang);
+    const storedSounds = layoutSettings?.sounds;
+    const ownSoundLevel: SoundLevel | undefined = storedSounds === false ? 'off' : typeof storedSounds === 'string' ? storedSounds : undefined;
 
-    return { layoutSettings, setLayoutSettings, updateLayoutSettings, hydrated, density, layoutName, themeName, setThemeName, setLayoutName, setDensity, setLang, lang, langCode };
+    return { layoutSettings, setLayoutSettings, updateLayoutSettings, hydrated, density, layoutName, themeName, setThemeName, setLayoutName, setDensity, setLang, lang, langCode, ownSoundLevel, setSoundLevel };
 };

BIN
neo/packages/app/customization/resources/sounds/bubble-close-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/bubble-error.wav


BIN
neo/packages/app/customization/resources/sounds/bubble-navigate-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/bubble-open-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/bubble-ready-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/bubble-ready.wav


BIN
neo/packages/app/customization/resources/sounds/bubble-success-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/bubble-toggle-back.wav


BIN
neo/packages/app/customization/resources/sounds/bubble-toggle.wav


BIN
neo/packages/app/customization/resources/sounds/click.mp3


BIN
neo/packages/app/customization/resources/sounds/close-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/error.wav


BIN
neo/packages/app/customization/resources/sounds/mech-close-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/mech-error.wav


BIN
neo/packages/app/customization/resources/sounds/mech-navigate-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/mech-open-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/mech-ready-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/mech-ready.wav


BIN
neo/packages/app/customization/resources/sounds/mech-success-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/mech-toggle-back.wav


BIN
neo/packages/app/customization/resources/sounds/mech-toggle.wav


BIN
neo/packages/app/customization/resources/sounds/navigate-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/notif.mp3


BIN
neo/packages/app/customization/resources/sounds/open-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/press-close-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/press-error.wav


BIN
neo/packages/app/customization/resources/sounds/press-navigate-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/press-open-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/press-ready-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/press-ready.wav


BIN
neo/packages/app/customization/resources/sounds/press-success-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/press-toggle-back.wav


BIN
neo/packages/app/customization/resources/sounds/press-toggle.wav


BIN
neo/packages/app/customization/resources/sounds/ready-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/ready.wav


BIN
neo/packages/app/customization/resources/sounds/success-subtle.wav


BIN
neo/packages/app/customization/resources/sounds/success.mp3


BIN
neo/packages/app/customization/resources/sounds/toggle-back.wav


BIN
neo/packages/app/customization/resources/sounds/toggle.wav


+ 65 - 5
neo/packages/app/default/sounds.js

@@ -1,8 +1,68 @@
+/**
+ * Native interface sounds: Cuelume cues (https://cuelume.dev, MIT) rendered
+ * to files by `scripts/render-sounds.mjs`. Web synthesizes the same cues live
+ * and does not use these files.
+ *
+ * Keys are `[<set>-]<cue>[-<emphasis>][-back]`, the default set without a
+ * prefix. A cue without its own key plays a plainer one, then the default set's,
+ * then the key that did its job before Cuelume (`click`, `tab`, `success`,
+ * `notif`); see `cueKeys` in `lib/sound/cues.ts`. A fork replaces a sound by
+ * pointing a key at its own file in `customization/sounds.js`.
+ */
+const toggle = require('app/customization/resources/sounds/toggle.wav');
+const navigateSubtle = require('app/customization/resources/sounds/navigate-subtle.wav');
+const successSubtle = require('app/customization/resources/sounds/success-subtle.wav');
+const ready = require('app/customization/resources/sounds/ready.wav');
+
 const SoundsDefault = {
-    click: require('app/customization/resources/sounds/click.mp3'),
-    tab: require('app/customization/resources/sounds/click.mp3'),
-    success: require('app/customization/resources/sounds/success.mp3'),
-    notif: require('app/customization/resources/sounds/notif.mp3'),
+    toggle,
+    'toggle-back': require('app/customization/resources/sounds/toggle-back.wav'),
+    'open-subtle': require('app/customization/resources/sounds/open-subtle.wav'),
+    'close-subtle': require('app/customization/resources/sounds/close-subtle.wav'),
+    'navigate-subtle': navigateSubtle,
+    'success-subtle': successSubtle,
+    ready,
+    'ready-subtle': require('app/customization/resources/sounds/ready-subtle.wav'),
+    error: require('app/customization/resources/sounds/error.wav'),
+
+    // The Mechanical set (`mech`): Studio > Settings > Sounds picks it for the app or for one kind of action.
+    'mech-toggle': require('app/customization/resources/sounds/mech-toggle.wav'),
+    'mech-toggle-back': require('app/customization/resources/sounds/mech-toggle-back.wav'),
+    'mech-open-subtle': require('app/customization/resources/sounds/mech-open-subtle.wav'),
+    'mech-close-subtle': require('app/customization/resources/sounds/mech-close-subtle.wav'),
+    'mech-navigate-subtle': require('app/customization/resources/sounds/mech-navigate-subtle.wav'),
+    'mech-success-subtle': require('app/customization/resources/sounds/mech-success-subtle.wav'),
+    'mech-ready': require('app/customization/resources/sounds/mech-ready.wav'),
+    'mech-ready-subtle': require('app/customization/resources/sounds/mech-ready-subtle.wav'),
+    'mech-error': require('app/customization/resources/sounds/mech-error.wav'),
+
+    // The Bubble set (`bubble`): Studio > Settings > Sounds picks it for the app or for one kind of action.
+    'bubble-toggle': require('app/customization/resources/sounds/bubble-toggle.wav'),
+    'bubble-toggle-back': require('app/customization/resources/sounds/bubble-toggle-back.wav'),
+    'bubble-open-subtle': require('app/customization/resources/sounds/bubble-open-subtle.wav'),
+    'bubble-close-subtle': require('app/customization/resources/sounds/bubble-close-subtle.wav'),
+    'bubble-navigate-subtle': require('app/customization/resources/sounds/bubble-navigate-subtle.wav'),
+    'bubble-success-subtle': require('app/customization/resources/sounds/bubble-success-subtle.wav'),
+    'bubble-ready': require('app/customization/resources/sounds/bubble-ready.wav'),
+    'bubble-ready-subtle': require('app/customization/resources/sounds/bubble-ready-subtle.wav'),
+    'bubble-error': require('app/customization/resources/sounds/bubble-error.wav'),
+
+    // The Press set (`press`): Studio > Settings > Sounds picks it for the app or for one kind of action.
+    'press-toggle': require('app/customization/resources/sounds/press-toggle.wav'),
+    'press-toggle-back': require('app/customization/resources/sounds/press-toggle-back.wav'),
+    'press-open-subtle': require('app/customization/resources/sounds/press-open-subtle.wav'),
+    'press-close-subtle': require('app/customization/resources/sounds/press-close-subtle.wav'),
+    'press-navigate-subtle': require('app/customization/resources/sounds/press-navigate-subtle.wav'),
+    'press-success-subtle': require('app/customization/resources/sounds/press-success-subtle.wav'),
+    'press-ready': require('app/customization/resources/sounds/press-ready.wav'),
+    'press-ready-subtle': require('app/customization/resources/sounds/press-ready-subtle.wav'),
+    'press-error': require('app/customization/resources/sounds/press-error.wav'),
+
+    // Keys from before Cuelume, for code that plays sounds by these names.
+    click: toggle,
+    tab: navigateSubtle,
+    success: successSubtle,
+    notif: ready,
 };
 
-export const Sounds = SoundsDefault;
+export const Sounds = SoundsDefault;

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

@@ -43,6 +43,13 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Hell",
     "theme_footer_dark": "Dunkel",
+    "Sounds": "Töne",
+    "sounds_full": "Laut",
+    "sounds_quiet": "Leise",
+    "sounds_off": "Aus",
+    "sounds_level_full": "Töne: laut",
+    "sounds_level_quiet": "Töne: leise",
+    "sounds_level_off": "Töne: aus",
     "theme": "Design",
     "format_hor": "Horizontal",
     "format_ver": "Vertikal",

+ 7 - 0
neo/packages/app/default/translations/en.json

@@ -43,6 +43,13 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Light",
     "theme_footer_dark": "Dark",
+    "Sounds": "Sounds",
+    "sounds_full": "Full",
+    "sounds_quiet": "Quiet",
+    "sounds_off": "Off",
+    "sounds_level_full": "Sounds: Full",
+    "sounds_level_quiet": "Sounds: Quiet",
+    "sounds_level_off": "Sounds: Off",
     "theme": "Theme",
     "format_hor": "Horizontal",
     "format_ver": "Vertical",

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

@@ -43,6 +43,13 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Claro",
     "theme_footer_dark": "Oscuro",
+    "Sounds": "Sonidos",
+    "sounds_full": "Alto",
+    "sounds_quiet": "Bajo",
+    "sounds_off": "Desactivados",
+    "sounds_level_full": "Sonidos: alto",
+    "sounds_level_quiet": "Sonidos: bajo",
+    "sounds_level_off": "Sonidos: desactivados",
     "theme": "Tema",
     "format_hor": "Horizontal",
     "format_ver": "Vertical",

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

@@ -43,6 +43,13 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Clair",
     "theme_footer_dark": "Sombre",
+    "Sounds": "Sons",
+    "sounds_full": "Fort",
+    "sounds_quiet": "Doux",
+    "sounds_off": "Désactivés",
+    "sounds_level_full": "Sons : fort",
+    "sounds_level_quiet": "Sons : doux",
+    "sounds_level_off": "Sons : désactivés",
     "theme": "Thème",
     "format_hor": "Horizontal",
     "format_ver": "Vertical",

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

@@ -43,6 +43,13 @@
     "theme_footer_auto": "Авто",
     "theme_footer_light": "Светлая",
     "theme_footer_dark": "Тёмная",
+    "Sounds": "Звуки",
+    "sounds_full": "Громко",
+    "sounds_quiet": "Тихо",
+    "sounds_off": "Выкл.",
+    "sounds_level_full": "Звуки: громко",
+    "sounds_level_quiet": "Звуки: тихо",
+    "sounds_level_off": "Звуки: выкл.",
     "theme": "Тема",
     "format_hor": "Горизонтальный",
     "format_ver": "Вертикальный",

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

@@ -9,6 +9,7 @@ import Loading from 'app/ui/atoms/loading'
 import { useIsDesktop } from 'app/context/measure';
 import { Platform, type Insets } from 'react-native';
 import { useThemeName } from 'app/design/theme';
+import { cueAttributes } from 'app/lib/sound';
 
 
 const BtnCls = appSetting('theme', 'button_styles');
@@ -415,6 +416,8 @@ export const Button = ({
 
     const cntProps = {
         className: `btn-${variant}-${size} ${flexClasses} ${hitareaClass}`,
+        // Web: a button with haptics taps (Cuelume `bind()`), as NeoButton does.
+        ...(isWeb && isActive && haptics ? cueAttributes({ cue: 'tap', emphasis: 'subtle' }) : {}),
 
         ...(isPressable ? {
             disabled: isPressable ? !isActive : disabled,

+ 40 - 0
neo/packages/app/design/controls/neo-button/neo-button.tsx

@@ -71,6 +71,7 @@ import {
 } from 'app/design/controls/neo-button/neo-button-resolver';
 import { NeoButtonExpoUI, getNativeIcon, hostsCustomContent } from 'app/design/controls/neo-button/neo-button-expoui';
 import type { NeoButtonAddon, NeoButtonClassNames, NeoButtonProps } from 'app/design/controls/neo-button/neo-button.types';
+import { cueAttributes, usePressCueOverride, type PressCue } from 'app/lib/sound';
 
 export type { NeoButtonProps } from 'app/design/controls/neo-button/neo-button.types';
 
@@ -454,6 +455,27 @@ export const NeoButton = (props: NeoButtonProps) => {
     return <NeoButtonStyled {...props} titleHidden={titleHidden} />;
 };
 
+/**
+ * A button's press cue when it sets none (Cuelume's "Which cue?"): a menu
+ * trigger opens or closes, a close / cancel button closes, a prominent or
+ * confirming button taps, other buttons tap subtly. Buttons without haptics
+ * (editor toolbars, object actions that play their own cue) stay quiet.
+ */
+function defaultPressCue({ role, style, haptics, menuTrigger, expanded }: {
+    role: string;
+    style: string;
+    haptics: boolean;
+    menuTrigger: boolean;
+    expanded: boolean;
+}): PressCue {
+    if (menuTrigger) return { cue: expanded ? 'close' : 'open', emphasis: 'subtle' };
+    if (!haptics) return false;
+    if (role === 'close') return 'close';
+    if (role === 'cancel') return { cue: 'close', emphasis: 'subtle' };
+    if (role === 'confirm' || style === 'borderedProminent' || style === 'glassProminent') return 'tap';
+    return { cue: 'tap', emphasis: 'subtle' };
+}
+
 /* ----------------------- JS-styled implementation ------------------------- */
 
 const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
@@ -472,6 +494,7 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
         addon = '',
         interactive = false,
         haptics,
+        sound,
         onPress,
         onPressIn,
         onPressOut,
@@ -536,6 +559,7 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
     const isInteractive = !disabled && (isPressable || interactive);
     const isActive = isPressable && !disabled;
     const isPressedToggle = !!selected;
+    const soundOverride = usePressCueOverride();
 
     const accessibleName =
         accessibilityLabel ?? alt ?? getAccessibleName(undefined, tooltip, effectiveLabel, effectiveImage);
@@ -586,6 +610,21 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
     // A menu trigger reports its state with `aria-expanded` (DropdownPopup);
     // `aria-pressed` next to it would announce a toggle instead.
     const isMenuTrigger = !!rest['aria-haspopup'];
+
+    // Web press sound (`bind()` plays it from the attributes). Native buttons
+    // answer with haptics only.
+    const pressCue: PressCue = sound !== undefined
+        ? sound
+        : soundOverride !== undefined
+            ? soundOverride
+            : defaultPressCue({
+                role: role ?? 'default',
+                style: resolved.style,
+                haptics: !!resolved.haptics,
+                menuTrigger: isMenuTrigger,
+                expanded: !!rest['aria-expanded'],
+            });
+    const soundAttributes = isWeb && isActive && !loading ? cueAttributes(pressCue) : {};
     const buttonAttributes = isPressable
         ? {
               ...(accessibleName ? { 'aria-label': accessibleName, alt: accessibleName } : {}),
@@ -733,6 +772,7 @@ const NeoButtonStyled = (props: NeoButtonProps & { titleHidden?: boolean }) => {
 
     const cntProps: Record<string, any> = {
         ...rest,
+        ...soundAttributes,
         className: cn(
             `neo-btn neo-btn-${resolved.style} neo-btn-${resolved.controlSize}`,
             resolved.glassFx && cn('neo-glass', resolved.style === 'glassProminent' && 'neo-glass-prominent'),

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

@@ -1,6 +1,7 @@
 // Public props of NeoButton, shared by neo-button.tsx and the Expo UI renderers
 // (neo-button-expoui.{ios,android}.tsx + the web / fallback stubs).
 import type { ComponentType, ReactElement, ReactNode, Ref } from 'react'
+import type { PressCue } from 'app/lib/sound'
 
 export type NeoButtonStyle =
     | 'plain'
@@ -75,6 +76,13 @@ export type NeoButtonProps = {
     interactive?: boolean
     /** `FeedbackHaptics` type; `false` / `''` opts out. */
     haptics?: string | false
+    /**
+     * Web press sound (a Cuelume cue, `app/lib/sound`); `false` for none. By
+     * default a button with haptics taps (subtle unless prominent), a menu
+     * trigger opens / closes, `close` / `cancel` roles close. Native buttons
+     * answer with haptics only.
+     */
+    sound?: PressCue
     /** Fires on release; a scroll that starts on the button cancels it. */
     onPress?: (event?: any) => void
     /**

+ 13 - 162
neo/packages/app/lib/hooks/use-sound.ts

@@ -1,177 +1,28 @@
 import { useCallback } from 'react';
-import {
-    createAudioPlayer,
-    preload,
-    setAudioModeAsync,
-    setIsAudioActiveAsync,
-} from 'expo-audio';
-import { Sounds } from 'app/customization/sounds';
-import { appSetting } from 'app/lib/util'
-import type { AudioPlayer, AudioSource } from 'expo-audio';
-
-const sounds = Sounds as Record<string, AudioSource | undefined>;
-
-const cache = new Map<string, AudioPlayer>();
-const isSounds = appSetting('layout', 'sounds');
-let primed = false;
-let priming: Promise<void> | null = null;
-const soundProfiles: Record<string, { playbackRate?: number; volume?: number }> = {
-    tab: {
-        playbackRate: 0.78,
-        volume: 0.02,
-    },
-};
-
-/** Two tab players so a re-tap never waits on `seekTo` of a still-playing click. */
-const TAB_POOL_SIZE = 2;
-const tabPool: AudioPlayer[] = [];
-let tabPoolIndex = 0;
-
-const sfxPlayerOptions = {
-    /** Default `false` deactivates AVAudioSession when a click ends — next play waits ~300–500ms. */
-    keepAudioSessionActive: true,
-    updateInterval: 250,
-};
-
-function applyProfile(player: AudioPlayer, name: string) {
-    const profile = soundProfiles[name];
-    if (!profile) return;
-    if (profile.volume != null) player.volume = profile.volume;
-    if (profile.playbackRate != null) player.setPlaybackRate(profile.playbackRate);
-}
-
-function attachRewind(player: AudioPlayer) {
-    player.addListener('playbackStatusUpdate', (status) => {
-        if (status.didJustFinish) {
-            void player.seekTo(0, 0, 0);
-        }
-    });
-}
-
-function createSfxPlayer(source: AudioSource, name: string): AudioPlayer {
-    const player = createAudioPlayer(source, sfxPlayerOptions);
-    applyProfile(player, name);
-    attachRewind(player);
-    return player;
-}
-
-function ensureTabPool() {
-    const source = sounds.tab;
-    if (!source) return tabPool;
-    while (tabPool.length < TAB_POOL_SIZE) {
-        tabPool.push(createSfxPlayer(source, 'tab'));
-    }
-    return tabPool;
-}
+import { LEGACY_SOUNDS, playCue, startInterfaceSounds } from 'app/lib/sound';
+import type { Cue } from 'app/lib/sound';
 
 /**
- * `preload` buffers the AVPlayerItem, but iOS only spins up the audio render
- * pipeline on the first `play()` (~100–300ms). A muted play per pooled player
- * at init pays that cost before the user's first tab tap.
+ * Before Cuelume: `playSound('click' | 'tab' | 'success' | 'notif')`. Kept for
+ * code that still plays sounds by those names; each now plays the cue for its
+ * job. New code calls `playCue` (outcomes) or `cueAttributes` / `playPressCue`
+ * (presses) from `app/lib/sound`.
  */
-async function warmPlayer(player: AudioPlayer, name: string) {
-    try {
-        player.volume = 0;
-        player.play();
-        await new Promise((resolve) => setTimeout(resolve, 60));
-        player.pause();
-        await player.seekTo(0, 0, 0);
-    } catch {
-        // Warm-up is best-effort.
-    } finally {
-        player.volume = soundProfiles[name]?.volume ?? 1;
-    }
-}
-
-function getCachedPlayer(name: string): AudioPlayer | null {
-    let player = cache.get(name);
-    if (player) return player;
-    const source = sounds[name];
-    if (!source) return null;
-    player = createSfxPlayer(source, name);
-    cache.set(name, player);
-    return player;
-}
-
-function playReady(player: AudioPlayer) {
-    if (!player.playing && player.currentTime < 0.02) {
-        player.play();
-        return;
-    }
-    void player.seekTo(0, 0, 0).then(() => {
-        player.play();
-    });
-}
-
-function playTabSound() {
-    const pool = ensureTabPool();
-    if (!pool.length) return;
-    const player = pool[tabPoolIndex]!;
-    tabPoolIndex = (tabPoolIndex + 1) % pool.length;
-    playReady(player);
-}
-
-function playSoundNow(name: string) {
-    try {
-        if (!sounds[name]) return;
-        if (name === 'tab') {
-            playTabSound();
-            return;
-        }
-        const player = getCachedPlayer(name);
-        if (player) playReady(player);
-    } catch (e) {
-        // don't crash the app
-    }
-}
-
 export const playSound = (name: string): void => {
-    if (!isSounds)
-        return;
-    if (!primed) {
-        void initAudio().then(() => playSoundNow(name)).catch(() => playSoundNow(name));
+    const legacy = LEGACY_SOUNDS[name];
+    if (legacy) {
+        const { cue, ...options } = legacy;
+        playCue(cue, options);
         return;
     }
-    playSoundNow(name);
+    playCue(name as Cue);
 };
 
-/** Stable callback per `name` — callers use it in `useCallback` deps without churning parent `onChange` handlers. */
+/** Stable callback per `name`, safe in `useCallback` deps. */
 export function useSound(name: string) {
     return useCallback(() => playSound(name), [name]);
 }
 
 export const initAudio = async (): Promise<void> => {
-    if (primed) return;
-    if (priming) return priming;
-    priming = (async () => {
-        try {
-            await setAudioModeAsync({
-                playsInSilentMode: true,
-                shouldPlayInBackground: false,
-                interruptionMode: 'mixWithOthers',
-            });
-            try {
-                await setIsAudioActiveAsync(true);
-            } catch {
-                // Session activation is best-effort — play() will activate if needed.
-            }
-            const tabSource = sounds.tab;
-            if (tabSource) {
-                try {
-                    await preload(tabSource);
-                } catch {
-                    // Preload is optional; createAudioPlayer still works without it.
-                }
-                await Promise.all(ensureTabPool().map((player) => warmPlayer(player, 'tab')));
-            }
-        } finally {
-            primed = true;
-            priming = null;
-        }
-    })();
-    return priming;
+    startInterfaceSounds();
 };
-
-if (isSounds) {
-    initAudio().catch(() => {});
-}

+ 0 - 33
neo/packages/app/lib/hooks/use-sound.web.ts

@@ -1,33 +0,0 @@
-import { Sounds } from 'app/customization/sounds';
-import { appSetting } from 'app/lib/util'
-import { hasRecentUserGesture } from 'app/lib/platform/feedback-haptics';
-
-const cache = new Map<string, HTMLAudioElement>();
-const isSounds = appSetting('layout', 'sounds');
-
-/**
- * Interaction sounds must follow a real pointer/key activation — mount effects
- * and URL sync must not click. Event sounds (`notif` from the daemon poll,
- * `success` after a network round trip) are allowed on sticky activation.
- */
-const GESTURE_GATED_SOUNDS = new Set(['click', 'tab']);
-
-export const playSound = (name: string): void => {
-    if (!isSounds)
-        return;
-    if (GESTURE_GATED_SOUNDS.has(name) && !hasRecentUserGesture())
-        return;
-    try {
-        let audio = cache.get(name);
-        if (!audio) {
-            audio = new Audio((Sounds as Record<string, string>)[name]);
-            audio.volume = 0.5;
-            cache.set(name, audio);
-        }
-        audio.currentTime = 0;
-        audio.play().catch(() => {});
-    } catch (e) {}
-};
-
-export const useSound = (name: string) => () => playSound(name);
-export const initAudio = async (): Promise<void> => {};

+ 39 - 0
neo/packages/app/lib/hooks/use-sounds-level.ts

@@ -0,0 +1,39 @@
+import { useCallback } from 'react';
+import { useLayoutSettings } from 'app/context/layout-settings';
+import type { SoundLevel } from 'app/lib/sound';
+import { playCue, setInterfaceSoundsState, SOUND_LEVELS, soundLevel, soundsAvailable, soundState, useSoundConfig } from 'app/lib/sound';
+
+/** Levels as menus list them, with their icons and translation keys. */
+export const SOUND_LEVEL_ITEMS: { id: SoundLevel; icon: string; titleKey: string; labelKey: string }[] = [
+    { id: 'full', icon: 'Volume2', titleKey: 'sounds_full', labelKey: 'sounds_level_full' },
+    { id: 'quiet', icon: 'Volume1', titleKey: 'sounds_quiet', labelKey: 'sounds_level_quiet' },
+    { id: 'off', icon: 'VolumeX', titleKey: 'sounds_off', labelKey: 'sounds_level_off' },
+];
+
+/**
+ * The user's level of interface sounds: full, quiet or off. UNA's settings say
+ * where everyone starts and whether this app plays sounds at all; `available`
+ * is false when they don't, or when `layout.sounds` turns sounds off for the
+ * build, and then there is nothing to pick.
+ */
+export function useSoundsLevel() {
+    const { ownSoundLevel, setSoundLevel } = useLayoutSettings();
+    const config = useSoundConfig();
+    const available = soundsAvailable(config);
+    const level = soundLevel(config, ownSoundLevel);
+
+    const setLevel = useCallback(
+        (next: SoundLevel) => {
+            const louder = SOUND_LEVELS.indexOf(next) > SOUND_LEVELS.indexOf(level);
+            // Applied now rather than when the setting is stored, so the answer plays at the new level.
+            setInterfaceSoundsState(soundState(config, next));
+            void setSoundLevel(next);
+            // Off is heard at once as silence. A level that can be heard answers from the same press, which also
+            // lets the browser start audio.
+            if (next !== 'off') playCue('toggle', { direction: louder ? 'forward' : 'back' });
+        },
+        [config, level, setSoundLevel]
+    );
+
+    return { available, level, setLevel };
+}

+ 11 - 47
neo/packages/app/lib/platform/feedback-haptics.web.ts

@@ -1,9 +1,9 @@
 import { WebHaptics } from 'web-haptics';
-import { appSetting } from 'app/config';
 
 /**
- * Mobile web haptics via [web-haptics](https://github.com/lochie/web-haptics).
- * Synth “click” audio (like [haptics.lochie.me](https://haptics.lochie.me/)) uses the library’s `debug` audio path; gated by `layout.sounds` + `layout.web_haptics_sounds`.
+ * Mobile web haptics via [web-haptics](https://github.com/lochie/web-haptics):
+ * vibration only. Interface sounds are Cuelume's (`app/lib/sound`), so the
+ * library's synthesized "debug" click stays off.
  * Maps the same `type` strings as native `expo-haptics` in `feedback-haptics.ts`.
  */
 let singleton: WebHaptics | undefined;
@@ -28,33 +28,18 @@ const PRESET_BY_TYPE: Record<string, string> = {
     Nudge: 'nudge',
 };
 
-function shouldPlayWebHapticsSynthAudio() {
-    if (!Boolean(appSetting('layout', 'sounds'))) return false;
-    const raw = appSetting('layout', 'web_haptics_sounds');
-    if (raw === '' || raw === undefined) return true;
-    return Boolean(raw);
-}
-
 /**
- * web-haptics `trigger()` awaits `ensureAudio()` before `playClick()`. That async gap
- * runs after the browser’s user-gesture window, so the first synth click is often
- * silent until `AudioContext` is already running. Radix tabs fire `onValueChange` from
- * `onMouseDown`, which is *after* `pointerdown`, so we kick off `ensureAudio()` on
- * capture-phase `pointerdown` (still a user gesture) so resume + playback line up.
- * Keyboard tab activation uses keydown (Enter/Space) without a preceding pointerdown.
- *
- * Clicks/keys also stamp a recent-gesture flag. Tab auto-select and other mount
- * effects must not play audio — only a real pointer/key activation should.
+ * Clicks and Enter/Space stamp a recent-gesture flag: tab auto-select and
+ * other mount effects must not vibrate, only a real pointer/key activation.
  */
-let webHapticsAudioPrimed = false;
 let lastUserGestureAt = 0;
-const USER_GESTURE_AUDIO_MS = 1000;
+const USER_GESTURE_MS = 1000;
 
 function markUserGesture() {
     lastUserGestureAt = Date.now();
 }
 
-export function hasRecentUserGesture(maxAgeMs = USER_GESTURE_AUDIO_MS) {
+export function hasRecentUserGesture(maxAgeMs = USER_GESTURE_MS) {
     if (lastUserGestureAt <= 0) return false;
     if (typeof navigator !== 'undefined' && navigator.userActivation?.isActive) {
         return true;
@@ -62,40 +47,21 @@ export function hasRecentUserGesture(maxAgeMs = USER_GESTURE_AUDIO_MS) {
     return Date.now() - lastUserGestureAt < maxAgeMs;
 }
 
-function primeWebHapticsAudioFromUserGesture() {
-    markUserGesture();
-    if (webHapticsAudioPrimed) return;
-    if (!shouldPlayWebHapticsSynthAudio()) {
-        webHapticsAudioPrimed = true;
-        return;
-    }
-    const h = getHaptics();
-    if (!h) return;
-    h.setDebug(true);
-    // ensureAudio is private in web-haptics' typings; called on purpose to warm up audio.
-    void (h as any).ensureAudio?.();
-    webHapticsAudioPrimed = true;
-}
-
-function installWebHapticsAudioPriming() {
+function installUserGestureTracking() {
     if (typeof document === 'undefined') return;
-    document.addEventListener(
-        'pointerdown',
-        primeWebHapticsAudioFromUserGesture,
-        { capture: true, passive: true }
-    );
+    document.addEventListener('pointerdown', markUserGesture, { capture: true, passive: true });
     document.addEventListener(
         'keydown',
         (e) => {
             if (e.key === 'Enter' || e.key === ' ') {
-                primeWebHapticsAudioFromUserGesture();
+                markUserGesture();
             }
         },
         { capture: true }
     );
 }
 
-installWebHapticsAudioPriming();
+installUserGestureTracking();
 
 export function FeedbackHaptics(type?: string) {
     if (typeof type !== 'string') return;
@@ -105,7 +71,5 @@ export function FeedbackHaptics(type?: string) {
     const preset = PRESET_BY_TYPE[type];
     if (!preset) return;
 
-    h.setDebug(shouldPlayWebHapticsSynthAudio());
-
     void h.trigger(preset).catch(() => {});
 }

+ 158 - 0
neo/packages/app/lib/sound/config.ts

@@ -0,0 +1,158 @@
+import { useSyncExternalStore } from 'react';
+import { Platform } from 'react-native';
+import { appSetting } from 'app/lib/util';
+import { persistedGet, persistedSet } from 'app/lib/util/storage';
+import type { Cue, CueOptions, SoundGroup, SoundSet } from './cues';
+import { cueGroup, SOUND_SETS } from './cues';
+
+/**
+ * UNA's interface sounds settings (Studio > Settings > Global > Sounds,
+ * `BxDolSounds`) arrive with every page as `data.sounds`. NEO keeps the last
+ * copy in memory and in storage, so a sound never waits for them: the stored
+ * copy applies from start, a new one from the page that brings it.
+ */
+
+/** Each person's own level; UNA's settings say where everyone starts. */
+export type SoundLevel = 'full' | 'quiet' | 'off';
+
+export const SOUND_LEVELS: SoundLevel[] = ['off', 'quiet', 'full'];
+
+export type SoundConfig = {
+    /** Where sounds play: `site`, `studio`, `neo_web`, `ios`, `android`. */
+    platforms: string[];
+    /** Where people start until they pick their own level. */
+    level: SoundLevel;
+    set: SoundSet;
+    /** 0–1. */
+    volume: number;
+    /** Kinds of action with a set of their own, or no sound. */
+    groups: Partial<Record<SoundGroup, SoundSet | 'off'>>;
+};
+
+/** What the player needs: everything decided, nothing left to look up when a sound plays. */
+export type SoundState = {
+    on: boolean;
+    volume: number;
+    set: SoundSet;
+    groups: Partial<Record<SoundGroup, SoundSet | 'off'>>;
+};
+
+/** This app in UNA's list of platforms. */
+export const SOUND_PLATFORM = Platform.OS === 'web' ? 'neo_web' : Platform.OS;
+
+/** Quiet plays at this share of the volume. */
+export const QUIET_VOLUME = 0.4;
+
+const STORAGE_KEY = 'sounds-config';
+
+export const DEFAULT_SOUND_CONFIG: SoundConfig = {
+    platforms: ['site', 'studio', 'neo_web', 'ios', 'android'],
+    level: 'full',
+    set: 'default',
+    volume: 1,
+    groups: {},
+};
+
+const isSet = (value: unknown): value is SoundSet => SOUND_SETS.includes(value as SoundSet);
+const isLevel = (value: unknown): value is SoundLevel => SOUND_LEVELS.includes(value as SoundLevel);
+
+/** The settings from a page, or null when it has none (an older UNA): the app keeps what it has. */
+export function parseSoundConfig(raw: unknown): SoundConfig | null {
+    if (!raw || typeof raw !== 'object') return null;
+    const value = raw as Record<string, unknown>;
+    if (!Array.isArray(value.platforms)) return null;
+    const groups: SoundConfig['groups'] = {};
+    if (value.groups && typeof value.groups === 'object') {
+        for (const [group, set] of Object.entries(value.groups as Record<string, unknown>)) {
+            if (set === 'off' || isSet(set)) groups[group as SoundGroup] = set;
+        }
+    }
+    const volume = Number(value.volume);
+    return {
+        platforms: value.platforms.filter((platform): platform is string => typeof platform === 'string'),
+        level: isLevel(value.level) ? value.level : DEFAULT_SOUND_CONFIG.level,
+        set: isSet(value.set) ? value.set : DEFAULT_SOUND_CONFIG.set,
+        volume: Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : DEFAULT_SOUND_CONFIG.volume,
+        groups,
+    };
+}
+
+let current = DEFAULT_SOUND_CONFIG;
+let currentJson = JSON.stringify(current);
+const listeners = new Set<() => void>();
+
+/** Settings a page brought this run: they win over the stored copy, even when it is read later. */
+let fromPage = false;
+/** What storage holds, once written or read this run. */
+let storedJson: string | null = null;
+
+function setConfig(next: SoundConfig) {
+    const json = JSON.stringify(next);
+    if (json === currentJson) return;
+    current = next;
+    currentJson = json;
+    listeners.forEach((listener) => listener());
+}
+
+/** Apply the settings a page brought (`data.sounds`); unchanged settings cost a string compare. */
+export function applySoundConfig(raw: unknown): void {
+    const next = parseSoundConfig(raw);
+    if (!next) return;
+    fromPage = true;
+    const json = JSON.stringify(next);
+    if (json !== storedJson) {
+        storedJson = json;
+        void persistedSet(STORAGE_KEY, next);
+    }
+    setConfig(next);
+}
+
+export function getSoundConfig(): SoundConfig {
+    return current;
+}
+
+function subscribe(listener: () => void) {
+    listeners.add(listener);
+    return () => {
+        listeners.delete(listener);
+    };
+}
+
+export function useSoundConfig(): SoundConfig {
+    return useSyncExternalStore(subscribe, getSoundConfig, () => DEFAULT_SOUND_CONFIG);
+}
+
+/** Whether UNA plays sounds in this app at all, and the build hasn't turned them off (`layout.sounds`). */
+export function soundsAvailable(config: SoundConfig): boolean {
+    return !!appSetting('layout', 'sounds') && config.platforms.includes(SOUND_PLATFORM);
+}
+
+/** The person's level: their own pick, or where UNA starts everyone. */
+export function soundLevel(config: SoundConfig, own: SoundLevel | undefined): SoundLevel {
+    if (!soundsAvailable(config)) return 'off';
+    return own ?? config.level;
+}
+
+export function soundState(config: SoundConfig, own: SoundLevel | undefined): SoundState {
+    const level = soundLevel(config, own);
+    return {
+        on: level !== 'off',
+        volume: config.volume * (level === 'quiet' ? QUIET_VOLUME : 1),
+        set: config.set,
+        groups: config.groups,
+    };
+}
+
+/** The set a cue plays in: its kind of action's own, the main one, or 'off'. */
+export function cueSet(state: SoundState, cue: Cue, options?: CueOptions): SoundSet | 'off' {
+    const group = cueGroup(cue, options);
+    return (group && state.groups[group]) || state.set;
+}
+
+// The stored copy, read once at start; settings a page brought first win.
+void persistedGet(STORAGE_KEY).then((stored) => {
+    const parsed = parseSoundConfig(stored);
+    if (!parsed || fromPage) return;
+    storedJson = JSON.stringify(parsed);
+    setConfig(parsed);
+});

+ 118 - 0
neo/packages/app/lib/sound/cues.ts

@@ -0,0 +1,118 @@
+/**
+ * Interface sounds are Cuelume's cues (https://cuelume.dev): one sound per
+ * interface job. Web synthesizes them live with the `cuelume` package; native
+ * plays files rendered from the same package (`scripts/render-sounds.mjs`).
+ *
+ * Pick a cue by the job, not by how it sounds; the "Which cue?" table in
+ * https://cuelume.dev/agents.md is the contract. Subtle emphasis for frequent
+ * actions, strong only for rare ones that matter. Never on hover, never once
+ * per item of a burst, never on page load.
+ */
+
+export type Cue =
+    | 'tap'
+    | 'type'
+    | 'select'
+    | 'toggle'
+    | 'open'
+    | 'close'
+    | 'navigate'
+    | 'success'
+    | 'warning'
+    | 'error'
+    | 'loading'
+    | 'ready'
+    | 'attention'
+    | 'count';
+
+export type CueEmphasis = 'subtle' | 'normal' | 'strong';
+
+/** Cuelume's themes: the same cues in another material. UNA's settings pick one, and one per kind of action. */
+export type SoundSet = 'default' | 'mech' | 'bubble' | 'press';
+
+export const SOUND_SETS: SoundSet[] = ['default', 'mech', 'bubble', 'press'];
+
+/** Kinds of action UNA's settings give their own set, or no sound (Studio > Settings > Sounds). */
+export type SoundGroup = 'buttons' | 'choices' | 'reactions' | 'menus' | 'navigation' | 'results' | 'notifications';
+
+export type CueOptions = {
+    emphasis?: CueEmphasis;
+    /** `select` rises going forward; `back` plays `navigate`, `toggle` and `count` falling. */
+    direction?: 'forward' | 'back';
+    /** The kind of action when the cue alone doesn't say: `reactions` for likes, ratings, favorites and follows. */
+    group?: SoundGroup;
+};
+
+/** The kind of action each cue belongs to; likes and the like name theirs (`group: 'reactions'`). */
+const CUE_GROUPS: Partial<Record<Cue, SoundGroup>> = {
+    tap: 'buttons',
+    select: 'choices',
+    toggle: 'choices',
+    open: 'menus',
+    close: 'menus',
+    navigate: 'navigation',
+    loading: 'navigation',
+    success: 'results',
+    warning: 'results',
+    error: 'results',
+    ready: 'notifications',
+    attention: 'notifications',
+};
+
+export function cueGroup(cue: Cue, options: CueOptions = {}): SoundGroup | undefined {
+    return options.group ?? CUE_GROUPS[cue];
+}
+
+/** A control's press cue: a cue, a cue with options, or `false` for none. */
+export type PressCue = Cue | false | ({ cue: Cue } & CueOptions);
+
+/**
+ * The `Sounds` key, and file name, of one variant: `[<set>-]<cue>[-<emphasis>][-back]`,
+ * as `scripts/render-sounds.mjs` names the files. The default set has no prefix.
+ */
+export function cueKey(cue: Cue, options: CueOptions = {}, set: SoundSet = 'default'): string {
+    const prefix = set !== 'default' ? `${set}-` : '';
+    const emphasis = options.emphasis && options.emphasis !== 'normal' ? `-${options.emphasis}` : '';
+    const back = options.direction === 'back' ? '-back' : '';
+    return `${prefix}${cue}${emphasis}${back}`;
+}
+
+/** The key that played each job before Cuelume, so a `Sounds` map with only those keys still sounds. */
+const LEGACY_KEY: Partial<Record<Cue, string>> = {
+    tap: 'click',
+    select: 'click',
+    toggle: 'click',
+    open: 'click',
+    close: 'click',
+    navigate: 'tab',
+    success: 'success',
+    warning: 'success',
+    ready: 'notif',
+    attention: 'notif',
+};
+
+/**
+ * `Sounds` keys to try for a variant, best first: exact, without the emphasis, the plain cue, in `set`
+ * and then in the default set, and last the old key.
+ */
+export function cueKeys(cue: Cue, options: CueOptions = {}, set: SoundSet = 'default'): string[] {
+    const sets: SoundSet[] = set === 'default' ? ['default'] : [set, 'default'];
+    const keys = [
+        ...sets.flatMap((s) => [cueKey(cue, options, s), cueKey(cue, { direction: options.direction }, s), cueKey(cue, {}, s)]),
+        LEGACY_KEY[cue],
+    ];
+    return keys.filter((key, i): key is string => !!key && keys.indexOf(key) === i);
+}
+
+/** Names `useSound` / `playSound` took before Cuelume, mapped to the cue for their job. */
+export const LEGACY_SOUNDS: Record<string, { cue: Cue } & CueOptions> = {
+    click: { cue: 'tap', emphasis: 'subtle' },
+    tab: { cue: 'navigate', emphasis: 'subtle' },
+    success: { cue: 'success', emphasis: 'subtle' },
+    notif: { cue: 'ready' },
+};
+
+export function pressCueParts(press: PressCue | null | undefined): ({ cue: Cue } & CueOptions) | null {
+    if (!press) return null;
+    return typeof press === 'string' ? { cue: press } : press;
+}

+ 6 - 0
neo/packages/app/lib/sound/index.ts

@@ -0,0 +1,6 @@
+export type { Cue, CueEmphasis, CueOptions, PressCue, SoundGroup, SoundSet } from './cues';
+export { cueKey, cueKeys, LEGACY_SOUNDS, SOUND_SETS } from './cues';
+export type { SoundConfig, SoundLevel, SoundState } from './config';
+export { applySoundConfig, SOUND_LEVELS, soundLevel, soundsAvailable, soundState, useSoundConfig } from './config';
+export { PressCueContext, usePressCueOverride } from './press-cue-context';
+export { cueAttributes, playCue, playPressCue, setInterfaceSoundsState, startInterfaceSounds } from './sound';

+ 13 - 0
neo/packages/app/lib/sound/press-cue-context.ts

@@ -0,0 +1,13 @@
+import { createContext, useContext } from 'react';
+import type { PressCue } from './cues';
+
+/**
+ * The press cue for buttons inside, when their job is not a plain tap: the
+ * items of a menu select, a menu's trigger opens it. A button's own `sound`
+ * prop still wins.
+ */
+export const PressCueContext = createContext<PressCue | undefined>(undefined);
+
+export function usePressCueOverride(): PressCue | undefined {
+    return useContext(PressCueContext);
+}

+ 205 - 0
neo/packages/app/lib/sound/sound.ts

@@ -0,0 +1,205 @@
+import {
+    createAudioPlayer,
+    preload,
+    setAudioModeAsync,
+    setIsAudioActiveAsync,
+} from 'expo-audio';
+import type { AudioPlayer, AudioSource } from 'expo-audio';
+import { Sounds } from 'app/customization/sounds';
+import type { Cue, CueOptions, PressCue, SoundSet } from './cues';
+import { cueKeys, pressCueParts, SOUND_SETS } from './cues';
+import type { SoundState } from './config';
+import { cueSet, getSoundConfig, soundState } from './config';
+
+/**
+ * Native: React Native has no Web Audio, so each cue plays a file rendered
+ * from Cuelume (`scripts/render-sounds.mjs`), looked up in `Sounds` by
+ * `cueKeys` in the set UNA's settings pick for its kind of action. Press cues
+ * play from the control's handler (`playPressCue`); there is no `bind()` here.
+ */
+
+const sounds = Sounds as Record<string, AudioSource | undefined>;
+
+/** UNA's settings and the person's level (`InterfaceSounds`); read as a sound plays, never waited for. */
+let state: SoundState = soundState(getSoundConfig(), undefined);
+let primed = false;
+let priming: Promise<void> | null = null;
+
+/** Two players per sound, so a quick repeat never waits on `seekTo` of one still playing. */
+const POOL_SIZE = 2;
+const pools = new Map<string, { players: AudioPlayer[]; next: number }>();
+
+/** The tab bar's cue: warmed at start so the first tab tap is not late. */
+const warmKeys = () => cueKeys('navigate', { emphasis: 'subtle' }, state.set);
+
+const playerOptions = {
+    /** Default `false` deactivates AVAudioSession when a sound ends: the next play waits ~300–500ms. */
+    keepAudioSessionActive: true,
+    updateInterval: 250,
+};
+
+function resolveKey(cue: Cue, options: CueOptions | undefined, set: SoundSet): string | null {
+    return cueKeys(cue, options, set).find((key) => !!sounds[key]) ?? null;
+}
+
+/** The sets this app can play now: the main one and any a kind of action has of its own. */
+function setsInUse(): Set<SoundSet> {
+    const sets = new Set<SoundSet>([state.set]);
+    for (const set of Object.values(state.groups)) if (set && set !== 'off') sets.add(set);
+    return sets;
+}
+
+/** Loads the players of the sets in use, so their first sound is not late; other sets load when first played. */
+function preloadSets() {
+    const sets = setsInUse();
+    const loaded = new Set<AudioSource>();
+    for (const key of Object.keys(sounds)) {
+        const source = sounds[key];
+        // `mech-toggle` belongs to `mech`; a key without a set's prefix to the default set.
+        const prefix = key.split('-')[0] as SoundSet;
+        const set: SoundSet = SOUND_SETS.includes(prefix) ? prefix : 'default';
+        if (!source || !sets.has(set) || loaded.has(source)) continue;
+        loaded.add(source);
+        poolFor(key);
+    }
+}
+
+function createPlayer(source: AudioSource): AudioPlayer {
+    const player = createAudioPlayer(source, playerOptions);
+    player.addListener('playbackStatusUpdate', (status) => {
+        if (status.didJustFinish) {
+            void player.seekTo(0, 0, 0);
+        }
+    });
+    return player;
+}
+
+function poolFor(key: string) {
+    let pool = pools.get(key);
+    if (pool) return pool;
+    const source = sounds[key];
+    if (!source) return null;
+    pool = { players: [], next: 0 };
+    while (pool.players.length < POOL_SIZE) pool.players.push(createPlayer(source));
+    pools.set(key, pool);
+    return pool;
+}
+
+function playReady(player: AudioPlayer) {
+    if (!player.playing && player.currentTime < 0.02) {
+        player.play();
+        return;
+    }
+    void player.seekTo(0, 0, 0).then(() => {
+        player.play();
+    });
+}
+
+function playKey(key: string) {
+    try {
+        const pool = poolFor(key);
+        if (!pool) return;
+        const player = pool.players[pool.next]!;
+        pool.next = (pool.next + 1) % pool.players.length;
+        player.volume = state.volume;
+        playReady(player);
+    } catch {
+        // A sound must never crash the app.
+    }
+}
+
+/**
+ * `preload` buffers the AVPlayerItem, but iOS only spins up the audio render
+ * pipeline on the first `play()` (~100–300ms). A muted play per pooled player
+ * at start pays that cost before the first tab tap.
+ */
+async function warmPlayer(player: AudioPlayer) {
+    try {
+        player.volume = 0;
+        player.play();
+        await new Promise((resolve) => setTimeout(resolve, 60));
+        player.pause();
+        await player.seekTo(0, 0, 0);
+    } catch {
+        // Warm-up is best-effort.
+    } finally {
+        player.volume = state.volume;
+    }
+}
+
+export function startInterfaceSounds(): () => void {
+    if (primed || priming) return () => {};
+    priming = (async () => {
+        try {
+            // Interface sounds follow the silent switch (ambient) and mix with other audio.
+            await setAudioModeAsync({
+                playsInSilentMode: false,
+                shouldPlayInBackground: false,
+                interruptionMode: 'mixWithOthers',
+            });
+            try {
+                await setIsAudioActiveAsync(true);
+            } catch {
+                // Session activation is best-effort; play() activates it if needed.
+            }
+            const warmKey = warmKeys().find((key) => !!sounds[key]);
+            const pool = warmKey ? poolFor(warmKey) : null;
+            if (warmKey && pool) {
+                try {
+                    await preload(sounds[warmKey]!);
+                } catch {
+                    // Preload is optional; the players work without it.
+                }
+                await Promise.all(pool.players.map(warmPlayer));
+            }
+            // The other cues of the sets in use load in the background, so their first play is not late either.
+            preloadSets();
+        } finally {
+            primed = true;
+            priming = null;
+        }
+    })();
+    return () => {};
+}
+
+export function setInterfaceSoundsState(next: SoundState): void {
+    const before = setsInUse();
+    state = next;
+    if (primed && [...setsInUse()].some((set) => !before.has(set))) preloadSets();
+    if (next.on) return;
+    // Off is heard at once: stop whatever is still ringing.
+    for (const pool of pools.values()) {
+        for (const player of pool.players) {
+            if (player.playing) player.pause();
+        }
+    }
+}
+
+export function playCue(cue: Cue, options?: CueOptions): void {
+    if (!state.on) return;
+    const set = cueSet(state, cue, options);
+    if (set === 'off') return;
+    const key = resolveKey(cue, options, set);
+    if (!key) return;
+    if (!primed) {
+        startInterfaceSounds();
+        const waiting = priming as Promise<void> | null;
+        if (waiting) {
+            void waiting.then(() => state.on && playKey(key));
+            return;
+        }
+    }
+    playKey(key);
+}
+
+/** Web only: native plays press cues with `playPressCue` from the control's handler. */
+export function cueAttributes(_press: PressCue | null | undefined): Record<string, string> {
+    return {};
+}
+
+export function playPressCue(press: PressCue | null | undefined): void {
+    const parts = pressCueParts(press);
+    if (!parts) return;
+    const { cue, ...options } = parts;
+    playCue(cue, options);
+}

+ 128 - 0
neo/packages/app/lib/sound/sound.web.ts

@@ -0,0 +1,128 @@
+import { bind, play, setEnabled, setTheme, setVolume } from 'cuelume';
+import type { Cue, CueOptions, PressCue } from './cues';
+import { pressCueParts } from './cues';
+import type { SoundState } from './config';
+import { cueSet, getSoundConfig, soundState } from './config';
+
+/**
+ * Web: Cuelume synthesizes every cue live. `bind()` (once, from
+ * `InterfaceSounds`) plays the press cue of any element carrying
+ * `data-cuelume-*` attributes; `playCue` is for outcomes (saved, failed,
+ * arrived). Cuelume stays silent until the page has had a user gesture.
+ *
+ * UNA's settings (`SoundState`, from `InterfaceSounds`) pick the set, the
+ * volume and a set or silence per kind of action. Outcomes read them as they
+ * play; a press gets them from `gatePress`, which runs before `bind()`.
+ */
+
+const PRESS_ATTRIBUTES: Partial<Record<Cue, string>> = {
+    tap: 'data-cuelume-tap',
+    select: 'data-cuelume-select',
+    toggle: 'data-cuelume-toggle',
+    open: 'data-cuelume-open',
+    close: 'data-cuelume-close',
+    navigate: 'data-cuelume-navigate',
+};
+
+const PRESS_SELECTOR = Object.values(PRESS_ATTRIBUTES).map((attribute) => `[${attribute}]`).join(',');
+
+/** Names the kind of action of a control whose cue doesn't (`reactions` on a like). */
+const GROUP_ATTRIBUTE = 'data-bx-sound-group';
+
+/** Events `bind()` plays from. */
+const PRESS_EVENTS = ['pointerdown', 'click', 'keydown', 'keyup', 'change'];
+
+let state: SoundState = soundState(getSoundConfig(), undefined);
+let unbind: (() => void) | null = null;
+let restoring = false;
+
+function applyState() {
+    restoring = false;
+    setTheme(state.set);
+    setVolume(state.volume);
+}
+
+/** The cue a marked control plays: the one its attribute names, else the attribute's own. */
+function pressedCue(control: Element): Cue | undefined {
+    for (const [cue, attribute] of Object.entries(PRESS_ATTRIBUTES)) {
+        if (control.hasAttribute(attribute)) return (control.getAttribute(attribute) || cue) as Cue;
+    }
+    return undefined;
+}
+
+/**
+ * Runs before `bind()` (it listens on `document`) for every event it plays from: a press whose kind of action has a
+ * set of its own plays in it, one with no sound, or any press while sounds are off, plays nothing. Cuelume reads the
+ * set and volume as it plays, so the page's own are back on the next tick.
+ */
+function gatePress(event: Event) {
+    if (!(event.target instanceof Element)) return;
+    const control = event.target.closest(PRESS_SELECTOR);
+    if (!control) return;
+    const cue = pressedCue(control);
+    if (!cue) return;
+    const group = control.closest(`[${GROUP_ATTRIBUTE}]`)?.getAttribute(GROUP_ATTRIBUTE) as CueOptions['group'] | undefined;
+    const set = state.on ? cueSet(state, cue, group ? { group } : undefined) : 'off';
+    if (set === state.set) return;
+    if (set === 'off') setVolume(0);
+    else setTheme(set);
+    if (!restoring) {
+        restoring = true;
+        setTimeout(applyState, 0);
+    }
+}
+
+export function startInterfaceSounds(): () => void {
+    if (!unbind) {
+        for (const name of PRESS_EVENTS) window.addEventListener(name, gatePress, true);
+        const undo = bind();
+        unbind = () => {
+            undo();
+            for (const name of PRESS_EVENTS) window.removeEventListener(name, gatePress, true);
+        };
+    }
+    return () => {
+        unbind?.();
+        unbind = null;
+    };
+}
+
+export function setInterfaceSoundsState(next: SoundState): void {
+    const wasOn = state.on;
+    state = next;
+    applyState();
+    if (wasOn && !next.on) {
+        // Off is heard at once: whatever still rings fades; presses are muted by `gatePress` from now on.
+        setEnabled(false);
+        setEnabled(true);
+    }
+}
+
+export function playCue(cue: Cue, options?: CueOptions): void {
+    if (!state.on) return;
+    const set = cueSet(state, cue, options);
+    if (set === 'off') return;
+    play(cue, { emphasis: options?.emphasis, direction: options?.direction, theme: set });
+}
+
+/**
+ * Attributes that make `bind()` play `press` when the element is pressed:
+ * the press on pointerdown for a mouse, on lift for touch, both at once for
+ * Enter and Space. Toggles and selects read their direction from
+ * `aria-checked` / `aria-pressed` / `aria-selected`, so keep those current.
+ * Put them on the element that is pressed (the innermost marked one wins).
+ */
+export function cueAttributes(press: PressCue | null | undefined): Record<string, string> {
+    const parts = pressCueParts(press);
+    if (!parts) return {};
+    const attribute = PRESS_ATTRIBUTES[parts.cue];
+    const attributes: Record<string, string> = attribute
+        ? { [attribute]: '' }
+        : { 'data-cuelume-tap': parts.cue };
+    if (parts.emphasis) attributes['data-cuelume-emphasis'] = parts.emphasis;
+    if (parts.group) attributes[GROUP_ATTRIBUTE] = parts.group;
+    return attributes;
+}
+
+/** Native only: web plays press cues through `cueAttributes`. */
+export function playPressCue(_press: PressCue | null | undefined): void {}

+ 1 - 2
neo/packages/app/settings/layout.js

@@ -60,10 +60,9 @@ export const settingsLayout = {
         background_image: '', 
         background_image_dark: '', 
 
+        /** Interface sounds (Cuelume cues, `app/lib/sound`). Users can turn them off in their settings; false turns them off for everyone. */
         sounds: true,
         tab_sounds: false,
-        /** When `sounds` is true: enables [web-haptics](https://github.com/lochie/web-haptics) synthesized click audio on mobile web / desktop (same idea as [haptics.lochie.me](https://haptics.lochie.me/)). When false: vibration only, no synth sound. */
-        web_haptics_sounds: true,
 
         show_login_modal: 0,
         //redirect_on_forbidden: '/home',

+ 9 - 10
neo/packages/app/ui/atoms/accordion.tsx

@@ -1,12 +1,12 @@
 import { useCallback, type ReactNode, type Ref } from 'react';
-import { Platform, type GestureResponderEvent, type TextProps, type ViewProps } from 'react-native';
+import { type GestureResponderEvent, type TextProps, type ViewProps } from 'react-native';
 import { View } from 'app/design/view';
 import * as AccordionPrimitive from 'app/ui/primitives/accordion';
 import { cn } from 'app/lib/util';
 import { Icon } from 'app/ui/atoms/icon';
 import { Text } from 'app/design/typography';
 import { appSetting, FeedbackHaptics } from 'app/lib/util';
-import { useSound } from 'app/lib/hooks/use-sound';
+import { cueAttributes, playPressCue } from 'app/lib/sound';
 
 const accordionTheme = appSetting('theme', 'accordion') ?? {};
 
@@ -72,7 +72,7 @@ type AccordionTriggerProps = {
     children?: ReactNode;
     chevronClassName?: string;
     onPress?: (event: GestureResponderEvent) => void;
-    /** When false, skips `FeedbackHaptics` + native click sound (web still uses web-haptics only if you call it — here we skip all feedback). */
+    /** When false, skips `FeedbackHaptics` and the open / close sound. */
     feedback?: boolean;
     ref?: Ref<any>;
     [key: string]: unknown;
@@ -88,19 +88,17 @@ export function AccordionTrigger({
     feedback = true,
     ...props
 }: AccordionTriggerProps) {
-    const isWeb = Platform.OS === 'web';
-    const playClick = useSound('click');
-    /** Native: `click` asset when `layout.sounds`. Web: [web-haptics](https://github.com/lochie/web-haptics) `nudge` + synth when `layout.sounds` + `layout.web_haptics_sounds` (see `feedback-haptics.web.ts`); skip `useSound` on web to avoid doubling. */
+    const { isExpanded } = AccordionPrimitive.useIsExpanded();
+    // Expanding and collapsing are frequent: subtle open / close.
+    const cue = isExpanded ? 'close' : 'open';
     const handlePress = useCallback(
         (event: GestureResponderEvent) => {
             onPress?.(event);
             if (!feedback) return;
-            if (!isWeb) {
-                playClick();
-            }
+            playPressCue({ cue, emphasis: 'subtle' });
             FeedbackHaptics('Nudge');
         },
-        [feedback, isWeb, onPress, playClick]
+        [feedback, onPress, cue]
     );
 
     const label =
@@ -116,6 +114,7 @@ export function AccordionTrigger({
         <AccordionPrimitive.Header className="flex">
             <AccordionPrimitive.Trigger
                 className={cn(accordionTheme.trigger, className)}
+                {...(feedback ? cueAttributes({ cue, emphasis: 'subtle' }) : {})}
                 {...props}
                 onPress={handlePress}
             >

+ 5 - 0
neo/packages/app/ui/atoms/checkbox.tsx

@@ -1,5 +1,6 @@
 import { Text } from 'app/design/typography'
 import { View, Pressable } from 'app/design/view'
+import { cueAttributes } from 'app/lib/sound'
 import * as CheckboxPrimitive from '@rn-primitives/checkbox'
 import { isValidElement, type ReactNode } from 'react'
 import { appSetting, cn } from 'app/lib/util'
@@ -64,6 +65,8 @@ export default function Checkbox({
                 onPress={onPress}
                 accessibilityRole="checkbox"
                 accessibilityState={{ checked: selected }}
+                aria-checked={selected}
+                {...cueAttributes('toggle')}
                 className="items-center justify-center shrink-0 p-0.5"
             >
                 <CheckboxMark selected={selected} indicatorClass={indicatorClass} />
@@ -76,6 +79,8 @@ export default function Checkbox({
             onPress={onPress}
             accessibilityRole="checkbox"
             accessibilityState={{ checked: selected }}
+            aria-checked={selected}
+            {...cueAttributes('toggle')}
             className={`${resolvedIcon ? 'flex-row gap-x-3' : 'flex-row-reverse'} ${isBackground ? checkboxTheme['u-controls-checkbox-container-bg'] : ''} ${margin} ${checkboxTheme['u-controls-checkbox-container']}`}
         >
             {!!resolvedIcon && <View className="w-8">{resolvedIcon}</View>}

+ 2 - 0
neo/packages/app/ui/atoms/dropdown-grid-item.tsx

@@ -2,6 +2,7 @@ import { useState } from 'react';
 import type { GestureResponderEvent } from 'react-native';
 import { Platform } from 'react-native';
 import { Pressable, View } from 'app/design/view';
+import { cueAttributes } from 'app/lib/sound';
 import { Text } from 'app/design/typography';
 import { Icon } from 'app/ui/atoms/icon';
 import Image from 'app/ui/atoms/image';
@@ -93,6 +94,7 @@ export default function DropdownGridItem({ item, index, link, handleSelect, clas
             accessibilityLabel={title || undefined}
             {...pressProps}
             {...rowHoverProps}
+            {...(handleSelect || link ? cueAttributes('select') : {})}
         >
             {icon ? <View className={menuSettings.item_icon_grid}>{icon}</View> : null}
             {title ? (

+ 9 - 0
neo/packages/app/ui/atoms/dropdown-popup.tsx

@@ -15,6 +15,7 @@ import { NeoButtonRef, legacyToNeoButtonProps } from 'app/design/controls'
 import { isLegacyButtonProps, stripRoutingKeys } from 'app/design/controls/neo-button/legacy-button-map';
 import type { ComponentType } from 'react';
 import ElementLoading from 'app/ui/atoms/loading';
+import { cueAttributes, playCue, PressCueContext } from 'app/lib/sound';
 
 // Trigger component, loosely typed: the popup passes RN / ARIA props
 // (collapsable, aria-*, onFocusCapture) that NeoButton forwards to its surface.
@@ -284,6 +285,7 @@ export default function DropdownPopup({
         const onKeyDown = (e: KeyboardEvent) => {
             if (e.key === 'Escape') {
                 e.preventDefault();
+                playCue('close', { emphasis: 'subtle' });
                 handleToggle(false);
             }
         };
@@ -504,6 +506,8 @@ export default function DropdownPopup({
         'aria-haspopup': 'true',
         'aria-expanded': isRealOpen,
     };
+    // Menus open and close often: subtle (NeoTriggerButton derives the same from aria-expanded).
+    const triggerCue = { cue: isRealOpen ? 'close' : 'open', emphasis: 'subtle' } as const;
     const dialogLabel =
         triggerAccessibilityLabel ||
         buttonProps?.accessibilityLabel ||
@@ -530,11 +534,14 @@ export default function DropdownPopup({
                 />
             ) : (
                 <DropdownMenuOpenContext.Provider value={isRealOpen}>
+                    {/* A button inside the trigger plays the trigger's cue, not its own tap. */}
+                    <PressCueContext.Provider value={triggerCue}>
                     <Pressable
                         collapsable={false}
                         ref={buttonRef}
                         onPress={openTrigger}
                         {...triggerFocusProps}
+                        {...cueAttributes(triggerCue)}
                         accessibilityRole="button"
                         accessibilityLabel={triggerAccessibilityLabel}
                         aria-haspopup="true"
@@ -544,6 +551,7 @@ export default function DropdownPopup({
                     >
                         {trigger}
                     </Pressable>
+                    </PressCueContext.Provider>
                 </DropdownMenuOpenContext.Provider>
             )}
 
@@ -591,6 +599,7 @@ export default function DropdownPopup({
                                     bottom: 0,
                                     zIndex: 0,
                                 }}
+                                {...(hoverMode ? {} : cueAttributes({ cue: 'close', emphasis: 'subtle' }))}
                                 onPress={(event: any) => handleBackdropPress(event)}
                             />
                             {hoverMode ? (

+ 2 - 1
neo/packages/app/ui/atoms/radiobutton.tsx

@@ -1,6 +1,7 @@
 
 import { Text } from 'app/design/typography'
 import { View, Pressable } from 'app/design/view'
+import { cueAttributes } from 'app/lib/sound'
 import * as CheckboxPrimitive from '@rn-primitives/checkbox';
 import React, { isValidElement } from 'react';
 import { appSetting } from 'app/lib/util';
@@ -34,7 +35,7 @@ export default function RadioButton({ title, info, onPress, status, value, disab
             {selected ? <View className="h-2.5 w-2.5 rounded-full bg-primary" /> : null}
         </View>
     return (
-        <Pressable disabled={disabled} onPress={onPress} className={`flex-row gap-x-3 ${checkboxTheme['u-controls-checkbox-container']} ${checkboxTheme['u-controls-checkbox-container-bg']} ${disabled ? '' : ' web:hover:bg-background'} `}>
+        <Pressable disabled={disabled} onPress={onPress} aria-checked={selected} {...(disabled ? {} : cueAttributes('select'))} className={`flex-row gap-x-3 ${checkboxTheme['u-controls-checkbox-container']} ${checkboxTheme['u-controls-checkbox-container-bg']} ${disabled ? '' : ' web:hover:bg-background'} `}>
             {!!resolvedIcon && <View className={checkboxTheme['u-controls-checkbox-icon']}>{resolvedIcon}</View>}
             <View className='flex-auto '>
                 <Text className={checkboxTheme['u-controls-checkbox-text']}>{title}</Text>

Некоторые файлы не были показаны из-за большого количества измененных файлов