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

Studio design refresh: dashboard, launcher, builders, accessibility

Dashboard
- Rebuild the Dashboard blocks on one flowing cell: Updates (version panel,
  update switches, status rows), Users (period/series chart with online
  counts), Space, Cache, Queues (chart hero + rows with per-item clear) and
  Server (Status gauges, Audit and Permissions tabs; audit re-run in place).
- Queues are now provided by modules via serviceGetDashboardQueues()
  (Notifications, Files, Payment ship the first ones); drop the sys_queues
  grid and BxDolGridQueues.
- Audit moved into BxDolStudioToolsAudit with row builders per section,
  security checks (https, install dir, display_errors, expose_php, config
  file mode), a stored report (sys_audit_report) and network throughput.

Launcher, menus, builders
- Launcher: app search with live results, keyboard moves in edit mode,
  theme radios, tour entry in the account popup, per-app context menu
  (studio/js/context_menu.js, menu_context.html) with uninstall.
- Pages builder: settings popups as sidebar + form modal, layout gallery,
  Add/Edit block popups, new bp-* and mi-* icons; Permissions grid column
  order and Lucide level icons; Polyglot/Roles/API/Templates menu icons.
- Studio grids: paginate templates, widget action template, search field
  labels, drag handle icon.

Accessibility (site and Studio)
- Popups: dialog role, aria-modal with fog, aria-expanded on inline menu
  triggers, close on focus leaving the menu; close controls are buttons.
- Forms: switcher is a role=switch button with aria-checked kept in sync;
  section titles are buttons; switcher label helper shared with Artificer.
- Grids: scope="col", sr-only titles for untitled columns, named
  switchers and select-all; layout templates drop redundant gutter classes.
- Live regions for async results on Dashboard and Launcher.

Core
- BxDolTemplate writes a file-level source map for compiled JS bundles,
  served by BxDolGzip as js.map; correct Content-Type for css/js.
- Viewport meta no longer sets maximum-scale (upgrade updates old value).

Also: new lang strings (en, ru) with upgrade lang files, upgrade SQL for
the dashboard layout, queues grid removal and permission icons, loading
spinners as SVG, UNA logo/favicon tweaks, tailwind rebuild.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Andrey Yasko 1 месяц назад
Родитель
Сommit
3be69ed6c1
100 измененных файлов с 4540 добавлено и 1154 удалено
  1. 0 106
      inc/classes/BxDolGridQueues.php
  2. 8 5
      inc/classes/BxDolGzip.php
  3. 107 2
      inc/classes/BxDolTemplate.php
  4. 8 2
      inc/classes/BxDolUpgrader.php
  5. 31 0
      inc/js/functions.js
  6. 4 1
      inc/js/jquery.dolPopup.js
  7. 10 1
      inc/js/jquery.webForms.js
  8. 36 49
      install/img/logo.svg
  9. 26 35
      install/sql/system.sql
  10. 1 1
      modules/boonex/artificer/classes/BxArtificerStudioPage.php
  11. 5 2
      modules/boonex/artificer/data/template/system/scripts/BxTemplFormView.php
  12. 166 51
      modules/boonex/english/data/langs/system/en.xml
  13. 16 0
      modules/boonex/files/classes/BxFilesModule.php
  14. 1 0
      modules/boonex/files/install/langs/en.xml
  15. 17 0
      modules/boonex/notifications/classes/BxNtfsModule.php
  16. 1 0
      modules/boonex/notifications/install/langs/en.xml
  17. 19 0
      modules/boonex/payment/classes/BxPaymentModule.php
  18. 1 0
      modules/boonex/payment/install/langs/en.xml
  19. 161 50
      modules/boonex/russian/data/langs/system/ru.xml
  20. 1 0
      studio/builders.php
  21. 377 14
      studio/classes/BxDolStudioDashboard.php
  22. 162 3
      studio/classes/BxDolStudioDashboardQuery.php
  23. 10 2
      studio/classes/BxDolStudioMenuTop.php
  24. 39 0
      studio/classes/BxDolStudioModule.php
  25. 6 0
      studio/classes/BxDolStudioTemplate.php
  26. 28 85
      studio/classes/BxDolStudioTools.php
  27. 279 104
      studio/classes/BxDolStudioToolsAudit.php
  28. 3 0
      studio/classes/BxDolStudioWidget.php
  29. 279 0
      studio/js/context_menu.js
  30. 610 89
      studio/js/dashboard.js
  31. 88 9
      studio/js/launcher.js
  32. 55 26
      studio/js/menu_top.js
  33. 8 1
      studio/js/module.js
  34. 111 0
      studio/js/page.js
  35. 1 0
      studio/launcher.php
  36. 2 1
      studio/template/_header.html
  37. 1 1
      studio/template/block_caption.html
  38. 3 3
      studio/template/bp_block_panel_top.html
  39. 2 2
      studio/template/bp_blocks.html
  40. 3 3
      studio/template/bp_edit_page_form.html
  41. 1 1
      studio/template/bp_fgt_attachments.html
  42. 4 3
      studio/template/bp_layouts.html
  43. 7 1
      studio/template/css/agents.css
  44. 0 6
      studio/template/css/badges.css
  45. 0 8
      studio/template/css/builder_forms.css
  46. 186 41
      studio/template/css/builder_page.css
  47. 0 80
      studio/template/css/common.less
  48. 714 25
      studio/template/css/dashboard.css
  49. 1 1
      studio/template/css/designer.css
  50. 10 0
      studio/template/css/forms.css
  51. 122 9
      studio/template/css/general.css
  52. 33 12
      studio/template/css/launcher.css
  53. 132 54
      studio/template/css/menu_top.css
  54. 0 4
      studio/template/css/navigation.css
  55. 1 1
      studio/template/css/options.css
  56. 329 13
      studio/template/css/page.css
  57. 48 2
      studio/template/css/page_columns.css
  58. 0 4
      studio/template/css/permissions.css
  59. 0 3
      studio/template/css/polyglot.css
  60. 0 3
      studio/template/css/roles.css
  61. 2 57
      studio/template/css/settings.css
  62. 4 1
      studio/template/css/store.css
  63. 1 1
      studio/template/css/uploaders.css
  64. 3 10
      studio/template/dbd_cache.html
  65. 26 13
      studio/template/dbd_htools.html
  66. 28 0
      studio/template/dbd_htools_audit.html
  67. 4 0
      studio/template/dbd_htools_tabs.html
  68. 7 0
      studio/template/dbd_queues.html
  69. 1 1
      studio/template/dbd_space.html
  70. 29 0
      studio/template/dbd_users.html
  71. 5 0
      studio/template/dbd_users_tabs.html
  72. 31 16
      studio/template/dbd_versions.html
  73. 11 5
      studio/template/designbox_11.html
  74. 1 1
      studio/template/dsr_cover_preview_unit_profile.html
  75. 1 1
      studio/template/dsr_icon_preview.html
  76. 18 0
      studio/template/form_field_section_title.html
  77. 1 1
      studio/template/form_ghost_template.html
  78. 36 35
      studio/template/images/dbd_version.svg
  79. 7 0
      studio/template/images/icons/ami-tour.svg
  80. BIN
      studio/template/images/icons/apple-touch-icon.png
  81. 1 0
      studio/template/images/icons/bp-settings.svg
  82. 1 0
      studio/template/images/icons/bp-view.svg
  83. 0 4
      studio/template/images/icons/db-ht-fail.svg
  84. 0 11
      studio/template/images/icons/db-ht-ok.svg
  85. 0 3
      studio/template/images/icons/db-ht-undef.svg
  86. 0 5
      studio/template/images/icons/db-ht-warn.svg
  87. 0 12
      studio/template/images/icons/db-vi-available.svg
  88. 0 11
      studio/template/images/icons/db-vi-installed.svg
  89. 0 10
      studio/template/images/icons/db-vi-updated.svg
  90. 38 37
      studio/template/images/icons/favicon.svg
  91. 1 0
      studio/template/images/icons/mi-api-config.svg
  92. 1 0
      studio/template/images/icons/mi-api-keys.svg
  93. 1 0
      studio/template/images/icons/mi-api-origins.svg
  94. 1 0
      studio/template/images/icons/mi-bp-cache.svg
  95. 1 0
      studio/template/images/icons/mi-bp-cover.svg
  96. 1 0
      studio/template/images/icons/mi-bp-injections.svg
  97. 1 0
      studio/template/images/icons/mi-bp-layout.svg
  98. 1 0
      studio/template/images/icons/mi-bp-options.svg
  99. 1 0
      studio/template/images/icons/mi-bp-seo.svg
  100. 1 0
      studio/template/images/icons/mi-bp-visibility.svg

+ 0 - 106
inc/classes/BxDolGridQueues.php

@@ -1,106 +0,0 @@
-<?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
- * @{
- */
-
-class BxDolGridQueues extends BxTemplGrid
-{
-    protected $_aSources = [
-            "emails" => [
-                'id' => "emails", 
-                'name' => "_sys_queue_emails", 
-                'all' => "SELECT COUNT(*) FROM `sys_queue_email`",
-                'action' => "DELETE FROM `sys_queue_email`"],
-            "push" => [
-                'id' => "push", 
-                'name' => "_sys_queue_push", 
-                'all' => "SELECT COUNT(*) FROM `sys_queue_push`",
-                'action' => "DELETE FROM `sys_queue_push`"],
-            "transcoding" => [
-                'id' => "transcoding", 
-                'name' => "_sys_queue_transcoding", 
-                'all' => "SELECT COUNT(*) FROM `sys_transcoder_queue`",
-                'failed'  => "SELECT COUNT(*) FROM `sys_transcoder_queue` WHERE `status` = 'failed'",
-                'action'  => "DELETE FROM `sys_transcoder_queue` WHERE `status` != 'processing'"],
-            "storage" => [
-                'id' => "storage",
-                'name' => "_sys_queue_storage",
-                'all' => "SELECT COUNT(*) FROM `sys_storage_deletions`"],
-    ];
-
-	protected function _getDataArray($sFilter, $sOrderField, $sOrderDir, $iStart, $iPerPage)
-    {
-    	$this->_aOptions['source'] = $this->_aSources;
-
-        return parent::_getDataArray($sFilter, $sOrderField, $sOrderDir, $iStart, $iPerPage);
-    }
-
-    public function __construct ($aOptions, $oTemplate = false)
-    {
-        parent::__construct ($aOptions, $oTemplate);
-    }
-    
-    protected function _getCellFailed ($mixedValue, $sKey, $aField, $aRow)
-    {
-        return $this->_getCellCount ('failed', $sKey, $aField, $aRow);
-    }
-
-    protected function _getCellAll ($mixedValue, $sKey, $aField, $aRow)
-    {
-        return $this->_getCellCount ('all', $sKey, $aField, $aRow);
-    }
-
-    protected function _getCellCount ($sField, $sKey, $aField, $aRow)
-    {
-        if (empty($aRow[$sField]))
-            $mixedValue = _t('_sys_not_available');
-        else
-            $mixedValue = BxDolDb::getInstance()->getOne($aRow[$sField]);
-        return parent::_getCellDefault($mixedValue, $sKey, $aField, $aRow);
-    }
-
-    public function performActionClear()
-    {
-        $sAction = 'edit';
-        
-        $aIds = bx_get('ids');
-        
-        if(!$aIds || !is_array($aIds)) {
-            $sId = bx_get('id');
-            if(!$sId)
-                return echoJson(array());
-
-            $aIds = array($sId);
-        }
-
-        $sId = array_shift($aIds);
-        if (empty($this->_aSources[$sId]) || empty($this->_aSources[$sId]['action'])) {
-            $sRes = echoJson(array('msg' => _t('_error occured')));
-            exit;
-        } 
-        else {
-            $sQuery = $this->_aSources[$sId]['action'];
-            $i = BxDolDb::getInstance()->query($sQuery);
-            echoJson(array('grid' => $this->getCode(false), 'blink' => $sId));
-        }
-    }
-
-	protected function _isVisibleGrid ($a)
-    {
-        return isAdmin();
-    }
-    
-    protected function _getActionClear($sType, $sKey, $a, $isSmall = false, $isDisabled = false, $aRow = array())
-    {
-        if(empty($aRow['action']))
-            return '';
-
-        return parent::_getActionDefault($sType, $sKey, $a, $isSmall, $isDisabled, $aRow);
-    }        
-}
-
-/** @} */

+ 8 - 5
inc/classes/BxDolGzip.php

@@ -30,15 +30,18 @@ class BxDolGzip extends BxDol
         $this->_sOutFile = BX_DIRECTORY_PATH_CACHE_PUBLIC . $sFile . '.gz';
 
         $aMatches = array();
-        if(!preg_match("/^([a-z0-9_-]+)\.(js|css)$/", $sFile, $aMatches))
-            die('BxDolGzip: file is not js/css');
+        if(!preg_match("/^([a-z0-9_-]+)\.(js|css|js\.map)$/", $sFile, $aMatches))
+            die('BxDolGzip: file is not js/css/js.map');
 
         switch($aMatches[2]) {
             case 'css':
-                $this->_sType = 'css';
+                $this->_sType = 'text/css';
                 break;
             case 'js':
-                $this->_sType = 'javascript';
+                $this->_sType = 'text/javascript';
+                break;
+            case 'js.map': // source map of a compiled JS bundle
+                $this->_sType = 'application/json';
                 break;
         }
 
@@ -57,7 +60,7 @@ class BxDolGzip extends BxDol
 
     function prepare()
     {
-        header("Content-type: text/" . $this->_sType);
+        header("Content-type: " . $this->_sType);
         header("Vary: Accept-Encoding");
         header("Cache-control: max-age=" . $this->_iExpirationOffset . ", public");
 

+ 107 - 2
inc/classes/BxDolTemplate.php

@@ -2964,7 +2964,9 @@ class BxDolTemplate extends BxDolFactory implements iBxDolSingleton
         //--- Collect all attached CSS/JS in one file ---//
 
         $sResult = "";
+        $iLine = 0;
         $aIncluded = array();
+        $aSections = array();
         foreach($aFiles as $aFile) {
             if($this->{'_b' . $sUpcaseType . 'Less'})
                 $aFile = $this->$sMethodLess($aFile);
@@ -2972,10 +2974,33 @@ class BxDolTemplate extends BxDolFactory implements iBxDolSingleton
             if(($sContent = $this->$sMethodCompile($aFile['path'], $aIncluded)) === false)
                 continue;                
 
-            if(!preg_match('/[\.-]min.(js|css)$/i', $aFile['path']) && $this->{'_b' . $sUpcaseType . 'Minify'}) // don't minify minified files
+            $bMinify = !preg_match('/[\.-]min.(js|css)$/i', $aFile['path']) && $this->{'_b' . $sUpcaseType . 'Minify'}; // don't minify minified files
+            if($bMinify)
                 $sContent = $this->$sMethodMinify($sContent);
-            
+
+            $iLines = substr_count($sContent, "\n");
+            if($sType == 'js' && $sContent !== '')
+                $aSections[] = array(
+                    'file' => $aFile,
+                    'line' => $iLine,
+                    'lines' => $iLines,
+                    'exact' => !$bMinify && strncmp($sContent, "\n/*--- BEGIN:", 13) === 0,
+                );
+
             $sResult .= $sContent;
+            $iLine += $iLines;
+        }
+
+        //--- The JS bundle gets a source map next to it (see _getSourceMapJs), served through the same loader ---//
+        if($sType == 'js' && !empty($sResult) && ($sMap = $this->_getSourceMapJs($sName, $aSections)) !== '') {
+            $sMapAbsolutePath = $this->_sCachePublicFolderPath . $sName . '.js.map';
+            if(($rHandler = fopen($sMapAbsolutePath, 'w')) !== false) {
+                fwrite($rHandler, $sMap);
+                fclose($rHandler);
+                @chmod($sMapAbsolutePath, BX_DOL_FILE_RIGHTS);
+
+                $sResult .= "\n//# sourceMappingURL=" . $this->_getLoaderUrl('js.map', $sName) . "\n";
+            }
         }
 
         $mixedWriteResult = false;
@@ -3572,6 +3597,86 @@ class BxDolTemplate extends BxDolFactory implements iBxDolSingleton
         return false;
     }
 
+    /**
+     * Source map for a compiled JS bundle, at file level: every line of the bundle points at the file it came from.
+     * A file that went into the bundle as it is (already minified, or minification off) maps line by line; a file
+     * minified here maps to its first line, because the PHP minifier emits no mappings. Sources are embedded, so
+     * DevTools and Lighthouse need nothing but the map.
+     *
+     * @param  string $sName     bundle name without extension.
+     * @param  array  $aSections one entry per bundled file: 'file' (url, path), 'line' (first line in the bundle), 'lines', 'exact'.
+     * @return string JSON source map, or an empty string when there is nothing to map.
+     */
+    protected function _getSourceMapJs($sName, $aSections)
+    {
+        $aSources = $aContents = $aLines = array();
+        foreach($aSections as $aSection) {
+            $sPath = $aSection['file']['path'];
+            $bExternal = strpos($sPath, 'http://') !== false || strpos($sPath, 'https://') !== false;
+            $sContent = $bExternal ? bx_file_get_contents($sPath) : @file_get_contents($sPath);
+
+            $iSource = count($aSources);
+            $aSources[] = $aSection['file']['url'];
+            $aContents[] = is_string($sContent) ? str_replace(array("\r\n", "\r"), "\n", $sContent) : '';
+
+            for($i = 0; $i < $aSection['lines']; $i++) {
+                if(!$aSection['exact'])
+                    $aLines[$aSection['line'] + $i] = array($iSource, 0);
+                else if($i >= 2 && $i < $aSection['lines'] - 1) // the BEGIN and END markers around the file are not in the source
+                    $aLines[$aSection['line'] + $i] = array($iSource, $i - 2);
+            }
+        }
+
+        if(empty($aLines))
+            return '';
+
+        $aMappings = array();
+        $iPrevSource = $iPrevLine = 0;
+        for($i = 0, $iLines = max(array_keys($aLines)) + 1; $i < $iLines; $i++) {
+            if(!isset($aLines[$i])) {
+                $aMappings[] = '';
+                continue;
+            }
+
+            list($iSource, $iLine) = $aLines[$i];
+            $aMappings[] = $this->_encodeVlq(0) . $this->_encodeVlq($iSource - $iPrevSource) . $this->_encodeVlq($iLine - $iPrevLine) . $this->_encodeVlq(0);
+            $iPrevSource = $iSource;
+            $iPrevLine = $iLine;
+        }
+
+        $sMap = json_encode(array(
+            'version' => 3,
+            'file' => $sName . '.js',
+            'sources' => $aSources,
+            'sourcesContent' => $aContents,
+            'names' => array(),
+            'mappings' => implode(';', $aMappings),
+        ), JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
+
+        return is_string($sMap) ? $sMap : '';
+    }
+
+    /**
+     * Base64 VLQ, the number encoding of source map mappings.
+     */
+    protected function _encodeVlq($iValue)
+    {
+        $sChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+
+        $iVlq = $iValue < 0 ? ((-$iValue) << 1) | 1 : $iValue << 1;
+        $sResult = '';
+        do {
+            $iDigit = $iVlq & 31;
+            $iVlq >>= 5;
+            if($iVlq > 0)
+                $iDigit |= 32;
+
+            $sResult .= $sChars[$iDigit];
+        } while($iVlq > 0);
+
+        return $sResult;
+    }
+
     /**
      * Get file name where the template would be cached.
      *

+ 8 - 2
inc/classes/BxDolUpgrader.php

@@ -116,9 +116,15 @@ class BxDolUpgrader extends BxDol
         return $a['latest_version'];
     }
 
-    public function getVersionUpdateInfo ()
+    /**
+     * @param string $sChannel 'stable' or 'beta'; empty for the channel set in the sys_upgrade_channel option
+     */
+    public function getVersionUpdateInfo ($sChannel = '')
     {
-        $sUrlVersionCheck = $this->_sUrlVersionCheck . ('beta' == getParam('sys_upgrade_channel') ? 'beta/' : '');
+        if(!$sChannel)
+            $sChannel = getParam('sys_upgrade_channel');
+
+        $sUrlVersionCheck = $this->_sUrlVersionCheck . ('beta' == $sChannel ? 'beta/' : '');
         $s = bx_file_get_contents($sUrlVersionCheck, array ('v' => bx_get_ver()));
         if (!$s)
             return null;

+ 31 - 0
inc/js/functions.js

@@ -524,16 +524,47 @@ function bx_menu_popup_inline (jSel, e, options) {
 
         var bElement = e != undefined && $(e).length != 0;
 
+        var bExpanded = bElement && $(e).is('[aria-expanded]');
+        var fOnShow = options.onShow, fOnHide = options.onHide;
+
         var o = $.extend({}, $.fn.dolPopupDefaultOptions, options, {
             pointer: bElement ? {el:$(e)} : false, 
             cssClass: 'bx-popup-menu',
             onShow: function(oPopup) {
+                if(bExpanded)
+                    $(e).attr('aria-expanded', 'true');
+
+                // remembered on the popup: the hide handler dolPopup keeps is the one from the first show, whichever trigger opened it since
+                oPopup.data('bx-popup-trigger', bExpanded ? $(e) : null);
+
                 oPopup.find('a').each(function () {
                     $(this).off('click.bx-popup-menu');
                     $(this).on('click.bx-popup-menu', function() {
                         $(jSel).dolPopupHide();
                     });
                 });
+
+                // keyboard focus leaving the menu (Shift+Tab from its first item) closes it, as Escape and an outer click do
+                oPopup.off('focusout.bx-popup-menu').on('focusout.bx-popup-menu', function(oEvent) {
+                    var oTarget = oEvent.relatedTarget;
+                    if(!oTarget || oPopup.get(0).contains(oTarget) || (bElement && $(e).get(0).contains && $(e).get(0).contains(oTarget)))
+                        return;
+
+                    $(jSel).dolPopupHide();
+                });
+
+                if(typeof fOnShow == 'function')
+                    fOnShow(oPopup);
+            },
+            onHide: function(oPopup) {
+                oPopup.off('focusout.bx-popup-menu');
+
+                var oTrigger = oPopup.data('bx-popup-trigger');
+                if(oTrigger && oTrigger.length)
+                    oTrigger.attr('aria-expanded', 'false');
+
+                if(typeof fOnHide == 'function')
+                    fOnHide(oPopup);
             },
         });
 

+ 4 - 1
inc/js/jquery.dolPopup.js

@@ -167,6 +167,9 @@
                 if (o.fog)
                     _dolPopupLockScreen(true);
 
+                // a popup behind a fog is modal for assistive technology as well
+                $el.attr('aria-modal', o.fog ? 'true' : null);
+
                 // remove any transitions before setting popup position
                 $el.removeClass('bx-popup-transitions bx-popup-inactive'); 
 
@@ -370,7 +373,7 @@
             else if(typeof(o.onBeforeHide) == 'string')
             	eval(o.onBeforeHide);
 
-            $el.removeClass('bx-popup-active').addClass('bx-popup-inactive');
+            $el.removeClass('bx-popup-active').addClass('bx-popup-inactive').removeAttr('aria-modal');
 
             var onHide = null;
             if(typeof(o.onHide) == 'function')

+ 10 - 1
inc/js/jquery.webForms.js

@@ -45,6 +45,12 @@
                 return;
             eSwitcher.addClass('bx-form-switcher-processed');
 
+            // the visible control is a button with role=switch: its aria-checked follows the on/off class
+            var fSyncState = function() {
+                eSwitcher.find('[role="switch"]').attr('aria-checked', eSwitcher.hasClass('on') ? 'true' : 'false');
+            };
+            fSyncState();
+
             eSwitcher.on('click', function() {
                 var $this = $(this);
                 if ($this.hasClass('on')) {
@@ -54,6 +60,7 @@
                     $this.removeClass('off').addClass('on');
                     $this.find('input').prop('checked', true).trigger('change');
                 }
+                fSyncState();
                 return false;
             });
 
@@ -62,6 +69,7 @@
                     $(this).prop('checked', true).trigger('change');
                 if (!eSwitcher.hasClass('on'))
                     eSwitcher.removeClass('off').addClass('on');
+                fSyncState();
             });
 
             eInput.on('disable', function () {
@@ -69,6 +77,7 @@
                     $(this).prop('checked', false).trigger('change');
                 if (!eSwitcher.hasClass('off'))
                     eSwitcher.removeClass('on').addClass('off');
+                fSyncState();
             });
         });
 
@@ -82,7 +91,7 @@
 
             eFormSection.addClass('bx-form-js-processed');
 
-            var eToggle = $('.bx-form-section-title:first a', eFormSection);
+            var eToggle = $('.bx-form-section-title:first', eFormSection).find('a, button').first(); // the caption control, a button now (anchors in older templates)
             var fSyncExpanded = function() {
                 eToggle.attr('aria-expanded', eFormSection.hasClass('bx-form-collapsed') ? 'false' : 'true');
             };

+ 36 - 49
install/img/logo.svg

@@ -1,59 +1,46 @@
-<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect width="48" height="48" rx="12" fill="url(#paint0_linear_201_173)"/>
-<path d="M23.9997 6.66663C33.5725 6.66663 41.3335 14.4268 41.3337 23.9996C41.3337 33.5726 33.5726 41.3336 23.9997 41.3336C14.4269 41.3334 6.66669 33.5725 6.66669 23.9996C6.66686 14.427 14.427 6.6668 23.9997 6.66663ZM23.9997 17.3336C20.3181 17.3338 17.3339 20.318 17.3337 23.9996C17.3337 24.7903 17.4717 25.5488 17.7243 26.2526C19.6509 26.8298 21.1693 28.3485 21.7468 30.275C22.4506 30.5278 23.2089 30.6666 23.9997 30.6666C27.6816 30.6666 30.6667 27.6815 30.6667 23.9996C30.6666 23.2088 30.5279 22.4505 30.2751 21.7467C28.3485 21.1692 26.8298 19.6509 26.2526 17.7242C25.5489 17.4716 24.7904 17.3336 23.9997 17.3336Z" fill="black" fill-opacity="0.1"/>
-<circle cx="24" cy="24" r="22" stroke="white" stroke-opacity="0.1" stroke-width="0.5" stroke-linecap="round" stroke-dasharray="1 2"/>
-<circle cx="24" cy="24" r="20" stroke="white" stroke-opacity="0.1" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="0.9 1.8"/>
-<g clip-path="url(#clip0_201_173)">
-<path d="M20.1429 33.6428C20.1429 36.4831 17.8404 38.7856 15 38.7856C12.1596 38.7856 9.85717 36.4831 9.85717 33.6428C9.85717 30.8025 12.1596 28.4999 15 28.4999C17.8404 28.4999 20.1429 30.8025 20.1429 33.6428Z" fill="black"/>
-<path d="M38.1429 15.6428C38.1429 18.4831 35.8404 20.7856 33 20.7856C30.1596 20.7856 27.8572 18.4831 27.8572 15.6428C27.8572 12.8025 30.1596 10.4999 33 10.4999C35.8404 10.4999 38.1429 12.8025 38.1429 15.6428Z" fill="black"/>
-<path d="M17.011 17.6053C18.8039 15.8246 21.2735 14.7244 24.0001 14.7244C24.285 14.7244 24.567 14.7364 24.8459 14.76C25.1147 14.7827 25.3599 14.5987 25.4188 14.3354C25.9821 11.8137 27.8256 9.77508 30.2316 8.9373C30.4562 8.85907 30.4793 8.53355 30.2574 8.44775C28.3162 7.69721 26.2062 7.28564 24.0001 7.28564C21.4493 7.28564 19.0271 7.83587 16.8456 8.82406C16.6712 8.90306 16.5613 9.07797 16.5613 9.26942V17.4266C16.5613 17.655 16.8489 17.7662 17.011 17.6053Z" fill="black"/>
-<path d="M34.3045 38.6115C34.1433 38.7306 33.9184 38.614 33.9184 38.4134V24.6428C33.9184 24.3579 33.9065 24.0758 33.8829 23.797C33.8601 23.5282 34.0441 23.2829 34.3074 23.2241C36.8292 22.6608 38.8677 20.8173 39.7055 18.4113C39.7836 18.1866 40.1092 18.1636 40.195 18.3855C40.9456 20.3267 41.3572 22.4367 41.3572 24.6428C41.3572 30.3706 38.5827 35.4503 34.3045 38.6115Z" fill="black"/>
-<path d="M8.29454 30.8742C8.21631 31.0989 7.89079 31.122 7.805 30.9C7.05445 28.9589 6.64288 26.8489 6.64288 24.6428C6.64288 18.915 9.41731 13.8353 13.6956 10.674C13.8568 10.5549 14.0817 10.6717 14.0817 10.8721V24.6428C14.0817 24.9277 14.0937 25.2098 14.1172 25.4886C14.1399 25.7574 13.956 26.0027 13.6927 26.0615C11.171 26.6248 9.13232 28.4683 8.29454 30.8742Z" fill="black"/>
-<path d="M31.1543 40.4616C31.3289 40.3825 31.4387 40.2077 31.4387 40.0163V31.8589C31.4387 31.6305 31.1511 31.5194 30.9889 31.6804C29.1962 33.461 26.7265 34.5612 23.9999 34.5612C23.715 34.5612 23.433 34.5493 23.1542 34.5257C22.8853 34.5029 22.6401 34.6869 22.5813 34.9502C22.018 37.472 20.1744 39.5104 17.7684 40.3483C17.5437 40.4266 17.5207 40.752 17.7426 40.8378C19.6839 41.5883 21.7938 41.9999 23.9999 41.9999C26.5507 41.9999 28.973 41.4497 31.1543 40.4616Z" fill="black"/>
-<path d="M20.1429 33C20.1429 35.8404 17.8405 38.1429 15 38.1429C12.1596 38.1429 9.85718 35.8404 9.85718 33C9.85718 30.1597 12.1596 27.8572 15 27.8572C17.8405 27.8572 20.1429 30.1597 20.1429 33Z" fill="url(#paint1_linear_201_173)"/>
-<path d="M38.1429 15C38.1429 17.8403 35.8404 20.1429 33 20.1429C30.1596 20.1429 27.8572 17.8403 27.8572 15C27.8572 12.1597 30.1596 9.85718 33 9.85718C35.8404 9.85718 38.1429 12.1597 38.1429 15Z" fill="url(#paint2_linear_201_173)"/>
-<path d="M17.011 16.9624C18.8039 15.1818 21.2735 14.0816 24.0001 14.0816C24.285 14.0816 24.567 14.0936 24.8459 14.1171C25.1147 14.1398 25.3599 13.9559 25.4188 13.6926C25.9821 11.1709 27.8256 9.13226 30.2316 8.29448C30.4562 8.21625 30.4793 7.89073 30.2574 7.80493C28.3162 7.05438 26.2062 6.64282 24.0001 6.64282C21.4493 6.64282 19.0271 7.19304 16.8456 8.18123C16.6712 8.26023 16.5613 8.43514 16.5613 8.62659V16.7838C16.5613 17.0122 16.8489 17.1234 17.011 16.9624Z" fill="url(#paint3_linear_201_173)" fill-opacity="0.8"/>
-<path d="M34.3045 37.9687C34.1433 38.0877 33.9184 37.9712 33.9184 37.7706V23.9999C33.9184 23.7151 33.9065 23.433 33.8829 23.1542C33.8601 22.8853 34.0441 22.6401 34.3074 22.5813C36.8292 22.018 38.8677 20.1745 39.7055 17.7684C39.7836 17.5438 40.1092 17.5208 40.195 17.7426C40.9456 19.6839 41.3572 21.7939 41.3572 23.9999C41.3572 29.7278 38.5827 34.8075 34.3045 37.9687Z" fill="url(#paint4_linear_201_173)" fill-opacity="0.8"/>
-<path d="M8.29454 30.2314C8.21631 30.4561 7.89079 30.4791 7.805 30.2572C7.05445 28.316 6.64288 26.2061 6.64288 24C6.64288 18.2722 9.41731 13.1924 13.6956 10.0312C13.8568 9.91208 14.0817 10.0288 14.0817 10.2293V24C14.0817 24.2849 14.0937 24.567 14.1172 24.8458C14.1399 25.1146 13.956 25.3599 13.6927 25.4187C11.171 25.982 9.13232 27.8255 8.29454 30.2314Z" fill="url(#paint5_linear_201_173)" fill-opacity="0.8"/>
-<path d="M31.1543 39.8188C31.3289 39.7397 31.4387 39.5649 31.4387 39.3734V31.2161C31.4387 30.9877 31.1511 30.8766 30.9889 31.0375C29.1962 32.8181 26.7265 33.9183 23.9999 33.9183C23.715 33.9183 23.433 33.9064 23.1542 33.8829C22.8853 33.8601 22.6401 34.0441 22.5813 34.3074C22.018 36.8291 20.1744 38.8676 17.7684 39.7055C17.5437 39.7838 17.5207 40.1091 17.7426 40.1949C19.6839 40.9455 21.7938 41.3571 23.9999 41.3571C26.5507 41.3571 28.973 40.8069 31.1543 39.8188Z" fill="url(#paint6_linear_201_173)" fill-opacity="0.8"/>
-<path d="M20.1429 33.6428C20.1429 36.4831 17.8404 38.7856 15 38.7856C12.1596 38.7856 9.85717 36.4831 9.85717 33.6428C9.85717 30.8025 12.1596 28.4999 15 28.4999C17.8404 28.4999 20.1429 30.8025 20.1429 33.6428Z" fill="black" fill-opacity="0.1"/>
-<path d="M38.1429 15.6428C38.1429 18.4831 35.8404 20.7856 33 20.7856C30.1596 20.7856 27.8572 18.4831 27.8572 15.6428C27.8572 12.8025 30.1596 10.4999 33 10.4999C35.8404 10.4999 38.1429 12.8025 38.1429 15.6428Z" fill="black" fill-opacity="0.1"/>
-<path d="M17.011 17.6053C18.8039 15.8246 21.2735 14.7244 24.0001 14.7244C24.285 14.7244 24.567 14.7364 24.8459 14.76C25.1147 14.7827 25.3599 14.5987 25.4188 14.3354C25.9821 11.8137 27.8256 9.77508 30.2316 8.9373C30.4562 8.85907 30.4793 8.53355 30.2574 8.44775C28.3162 7.69721 26.2062 7.28564 24.0001 7.28564C21.4493 7.28564 19.0271 7.83587 16.8456 8.82406C16.6712 8.90306 16.5613 9.07797 16.5613 9.26942V17.4266C16.5613 17.655 16.8489 17.7662 17.011 17.6053Z" fill="black" fill-opacity="0.1"/>
-<path d="M34.3045 38.6115C34.1433 38.7306 33.9184 38.614 33.9184 38.4134V24.6428C33.9184 24.3579 33.9065 24.0758 33.8829 23.797C33.8601 23.5282 34.0441 23.2829 34.3074 23.2241C36.8292 22.6608 38.8677 20.8173 39.7055 18.4113C39.7836 18.1866 40.1092 18.1636 40.195 18.3855C40.9456 20.3267 41.3572 22.4367 41.3572 24.6428C41.3572 30.3706 38.5827 35.4503 34.3045 38.6115Z" fill="black" fill-opacity="0.1"/>
-<path d="M8.29454 30.8742C8.21631 31.0989 7.89079 31.122 7.805 30.9C7.05445 28.9589 6.64288 26.8489 6.64288 24.6428C6.64288 18.915 9.41731 13.8353 13.6956 10.674C13.8568 10.5549 14.0817 10.6717 14.0817 10.8721V24.6428C14.0817 24.9277 14.0937 25.2098 14.1172 25.4886C14.1399 25.7574 13.956 26.0027 13.6927 26.0615C11.171 26.6248 9.13232 28.4683 8.29454 30.8742Z" fill="black" fill-opacity="0.1"/>
-<path d="M31.1543 40.4616C31.3289 40.3825 31.4387 40.2077 31.4387 40.0163V31.8589C31.4387 31.6305 31.1511 31.5194 30.9889 31.6804C29.1962 33.461 26.7265 34.5612 23.9999 34.5612C23.715 34.5612 23.433 34.5493 23.1542 34.5257C22.8853 34.5029 22.6401 34.6869 22.5813 34.9502C22.018 37.472 20.1744 39.5104 17.7684 40.3483C17.5437 40.4266 17.5207 40.752 17.7426 40.8378C19.6839 41.5883 21.7938 41.9999 23.9999 41.9999C26.5507 41.9999 28.973 41.4497 31.1543 40.4616Z" fill="black" fill-opacity="0.1"/>
-</g>
+<svg width="48" height="48" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="512" height="512" rx="113.78" fill="url(#paint0_una_install)"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M256 46C295.94 46 333.277 57.1504 365.065 76.5078C402.004 80.674 431.325 109.995 435.491 146.934C454.849 178.723 466 216.059 466 256C466 371.98 371.98 466 256 466C216.059 466 178.723 454.849 146.934 435.491C109.995 431.325 80.674 402.004 76.5078 365.065C57.1504 333.277 46 295.94 46 256C46 140.02 140.02 46 256 46ZM256 176C211.817 176 176 211.817 176 256C176 264.182 177.229 272.078 179.512 279.512C204.786 287.271 224.727 307.213 232.487 332.487C239.922 334.77 247.817 336 256 336C300.183 336 336 300.183 336 256C336 247.817 334.77 239.922 332.487 232.487C307.213 224.727 287.271 204.786 279.512 179.512C272.078 177.229 264.182 176 256 176Z" fill="black" fill-opacity="0.8"/>
+<path d="M325.77 327.635C327.195 326.246 329.692 327.464 329.37 329.428C328.337 335.736 327.247 341.99 326.149 348.28C321.624 374.209 316.98 400.753 316.137 433.474C316.094 435.168 315.001 436.665 313.386 437.176C295.28 442.906 276.001 446 256 446C232.809 446 210.589 441.843 190.042 434.236C187.47 433.284 187.511 429.593 189.983 428.406C215.779 416.028 234.028 388.984 235.828 358.887C235.979 356.363 238.293 354.468 240.793 354.85C245.752 355.607 250.83 356 256 356C283.14 356 307.753 345.187 325.77 327.635Z" fill="white" fill-opacity="0.8"/>
+<path d="M428.406 189.983C429.593 187.511 433.284 187.47 434.236 190.042C441.843 210.589 446 232.809 446 256C446 329.89 403.821 393.927 342.228 425.351C339.557 426.713 336.471 424.66 336.643 421.667C338.122 396.06 341.941 374.12 345.851 351.72C350.852 323.067 356 293.662 356 256C356 250.83 355.607 245.752 354.85 240.793C354.468 238.293 356.363 235.979 358.887 235.828C388.984 234.028 416.028 215.779 428.406 189.983Z" fill="white" fill-opacity="0.8"/>
+<path d="M169.771 86.6484C172.442 85.2859 175.528 87.3392 175.356 90.3322C173.877 115.939 170.059 137.88 166.149 160.28C161.148 188.933 156 218.338 156 256C156 261.17 156.393 266.248 157.149 271.206C157.531 273.706 155.636 276.02 153.112 276.171C123.015 277.971 95.9711 296.22 83.5927 322.016C82.4061 324.488 78.7154 324.529 77.7632 321.957C70.1568 301.411 66 279.191 66 256C66 182.111 108.178 118.072 169.771 86.6484Z" fill="white" fill-opacity="0.8"/>
+<path d="M256 66C279.191 66 301.411 70.1568 321.957 77.7632C324.529 78.7154 324.488 82.4061 322.016 83.5927C296.22 95.9711 277.971 123.015 276.171 153.112C276.02 155.636 273.706 157.531 271.206 157.149C266.248 156.393 261.17 156 256 156C228.86 156 204.246 166.813 186.229 184.364C184.804 185.753 182.307 184.535 182.629 182.571C183.662 176.263 184.753 170.009 185.851 163.72C190.376 137.791 195.019 111.246 195.862 78.5246C195.905 76.8306 196.998 75.3345 198.613 74.8232C216.72 69.0932 235.998 66 256 66Z" fill="white" fill-opacity="0.8"/>
+<path d="M325.77 335.635C327.195 334.246 329.692 335.464 329.37 337.428C328.337 343.736 327.247 349.99 326.149 356.28C321.624 382.209 316.98 408.753 316.137 441.474C316.094 443.168 315.001 444.665 313.386 445.176C295.28 450.906 276.001 454 256 454C232.809 454 210.589 449.843 190.042 442.236C187.47 441.284 187.511 437.593 189.983 436.406C215.779 424.028 234.028 396.984 235.828 366.887C235.979 364.363 238.293 362.468 240.793 362.85C245.752 363.607 250.83 364 256 364C283.14 364 307.753 353.187 325.77 335.635Z" fill="url(#paint1_una_install)" fill-opacity="0.5"/>
+<path d="M428.406 197.983C429.593 195.511 433.284 195.47 434.236 198.042C441.843 218.589 446 240.809 446 264C446 337.89 403.821 401.927 342.228 433.351C339.557 434.713 336.471 432.66 336.643 429.667C338.122 404.06 341.941 382.12 345.851 359.72C350.852 331.067 356 301.662 356 264C356 258.83 355.607 253.752 354.85 248.793C354.468 246.293 356.363 243.979 358.887 243.828C388.984 242.028 416.028 223.779 428.406 197.983Z" fill="url(#paint2_una_install)" fill-opacity="0.5"/>
+<path d="M169.771 94.6484C172.442 93.2859 175.528 95.3392 175.356 98.3322C173.877 123.939 170.059 145.88 166.149 168.28C161.148 196.933 156 226.338 156 264C156 269.17 156.393 274.248 157.149 279.206C157.531 281.706 155.636 284.02 153.112 284.171C123.015 285.971 95.9711 304.22 83.5927 330.016C82.4061 332.488 78.7154 332.529 77.7632 329.957C70.1568 309.411 66 287.191 66 264C66 190.111 108.178 126.072 169.771 94.6484Z" fill="url(#paint3_una_install)" fill-opacity="0.5"/>
+<path d="M256 74C279.191 74 301.411 78.1568 321.957 85.7632C324.529 86.7154 324.488 90.4061 322.016 91.5927C296.22 103.971 277.971 131.015 276.171 161.112C276.02 163.636 273.706 165.531 271.206 165.149C266.248 164.393 261.17 164 256 164C228.86 164 204.246 174.813 186.229 192.364C184.804 193.753 182.307 192.535 182.629 190.571C183.662 184.263 184.753 178.009 185.851 171.72C190.376 145.791 195.019 119.246 195.862 86.5246C195.905 84.8306 196.998 83.3345 198.613 82.8232C216.72 77.0932 235.998 74 256 74Z" fill="url(#paint4_una_install)" fill-opacity="0.5"/>
+<circle cx="356" cy="156" r="60" fill="white"/>
+<circle cx="356" cy="164" r="60" fill="url(#paint5_una_install)" fill-opacity="0.5"/>
+<circle cx="156" cy="356" r="60" fill="white"/>
+<circle cx="156" cy="366" r="60" fill="url(#paint6_una_install)" fill-opacity="0.5"/>
 <defs>
-<linearGradient id="paint0_linear_201_173" x1="24" y1="0" x2="24" y2="48" gradientUnits="userSpaceOnUse">
-<stop stop-color="#232120"/>
-<stop offset="1" stop-color="#131211"/>
+<linearGradient id="paint0_una_install" x1="256" y1="0" x2="256" y2="512" gradientUnits="userSpaceOnUse">
+<stop stop-color="#3F3F46"/>
+<stop offset="1" stop-color="#18181B"/>
 </linearGradient>
-<linearGradient id="paint1_linear_201_173" x1="15" y1="27.8572" x2="15" y2="38.1429" gradientUnits="userSpaceOnUse">
-<stop stop-color="white"/>
-<stop offset="1" stop-color="white" stop-opacity="0.8"/>
+<linearGradient id="paint1_una_install" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint2_linear_201_173" x1="33" y1="9.85718" x2="33" y2="20.1429" gradientUnits="userSpaceOnUse">
-<stop stop-color="white"/>
-<stop offset="1" stop-color="white" stop-opacity="0.8"/>
+<linearGradient id="paint2_una_install" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint3_linear_201_173" x1="24" y1="6.64282" x2="24" y2="41.3571" gradientUnits="userSpaceOnUse">
-<stop stop-color="white"/>
-<stop offset="1" stop-color="white" stop-opacity="0.8"/>
+<linearGradient id="paint3_una_install" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint4_linear_201_173" x1="24" y1="6.64282" x2="24" y2="41.3571" gradientUnits="userSpaceOnUse">
-<stop stop-color="white"/>
-<stop offset="1" stop-color="white" stop-opacity="0.8"/>
+<linearGradient id="paint4_una_install" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint5_linear_201_173" x1="24" y1="6.64282" x2="24" y2="41.3571" gradientUnits="userSpaceOnUse">
-<stop stop-color="white"/>
-<stop offset="1" stop-color="white" stop-opacity="0.8"/>
+<linearGradient id="paint5_una_install" x1="356" y1="104" x2="356" y2="224" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint6_linear_201_173" x1="24" y1="6.64282" x2="24" y2="41.3571" gradientUnits="userSpaceOnUse">
-<stop stop-color="white"/>
-<stop offset="1" stop-color="white" stop-opacity="0.8"/>
+<linearGradient id="paint6_una_install" x1="156" y1="306" x2="156" y2="426" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<clipPath id="clip0_201_173">
-<path d="M6 20.4C6 15.3595 6 12.8393 6.98094 10.9141C7.8438 9.22063 9.22063 7.8438 10.9141 6.98094C12.8393 6 15.3595 6 20.4 6H27.6C32.6405 6 35.1607 6 37.0859 6.98094C38.7794 7.8438 40.1562 9.22063 41.0191 10.9141C42 12.8393 42 15.3595 42 20.4V27.6C42 32.6405 42 35.1607 41.0191 37.0859C40.1562 38.7794 38.7794 40.1562 37.0859 41.0191C35.1607 42 32.6405 42 27.6 42H20.4C15.3595 42 12.8393 42 10.9141 41.0191C9.22063 40.1562 7.8438 38.7794 6.98094 37.0859C6 35.1607 6 32.6405 6 27.6V20.4Z" fill="white"/>
-</clipPath>
 </defs>
 </svg>

+ 26 - 35
install/sql/system.sql

@@ -386,6 +386,7 @@ INSERT INTO `sys_options`(`category_id`, `name`, `caption`, `value`, `type`, `ex
 (@iCategoryId, 'sys_revision', '_adm_stg_cpt_option_sys_revision', '0', 'digit', '', '', '', '', 5),
 (@iCategoryId, 'sys_eq_time', '_adm_stg_cpt_option_sys_eq_time', '0', 'digit', '', '', '', '', 6),
 (@iCategoryId, 'sys_push_queue_time', '_adm_stg_cpt_option_sys_push_queue_time', '0', 'digit', '', '', '', '', 7),
+(@iCategoryId, 'sys_audit_report', '_adm_stg_cpt_option_sys_audit_report', '', 'text', '', '', '', '', 8),
 
 (@iCategoryId, 'sys_ftp_login', '_adm_stg_cpt_option_sys_ftp_login', '', 'digit', '', '', '', '', 10),
 (@iCategoryId, 'sys_ftp_password', '_adm_stg_cpt_option_sys_ftp_password', '', 'secret', '', '', '', '', 11),
@@ -450,7 +451,7 @@ INSERT INTO `sys_options`(`category_id`, `name`, `caption`, `value`, `type`, `ex
 (@iCategoryId, 'sys_files_ext_imagevideo', '_adm_stg_cpt_option_sys_files_ext_imagevideo', 'jpg,jpeg,jpe,gif,png,svg,webp,avi,flv,mpg,mpeg,wmv,mp4,m4v,mov,qt,divx,xvid,3gp,3g2,webm,mkv,ogv,ogg,rm,rmvb,asf,drc,ts', 'digit', '', '', '', '', 230),
 (@iCategoryId, 'sys_files_ext_dangerous', '_adm_stg_cpt_option_sys_files_ext_dangerous', 'action,apk,app,bat,bin,cmd,com,command,cpl,csh,exe,gadget,inf,ins,inx,ipa,isu,job,jse,ksh,lnk,msc,msi,msp,mst,osx,out,paf,pif,prg,ps1,reg,rgs,run,sct,shb,shs,u3p,vb,vbe,vbs,vbscript,workflow,ws,wsf', 'digit', '', '', '', '', 240),
 
-(@iCategoryId, 'sys_viewport_meta_tag', '_adm_stg_cpt_option_sys_viewport_meta_tag', 'width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0', 'digit', '', '', '', '', 250),
+(@iCategoryId, 'sys_viewport_meta_tag', '_adm_stg_cpt_option_sys_viewport_meta_tag', 'width=device-width, initial-scale=1.0, minimum-scale=1.0', 'digit', '', '', '', '', 250),
 
 (@iCategoryId, 'sys_form_lpc_enable', '_adm_stg_cpt_option_sys_form_lpc_enable', 'on', 'checkbox', '', '', '', '', 260),
 
@@ -1411,14 +1412,14 @@ CREATE TABLE `sys_acl_levels` (
 );
 
 INSERT INTO `sys_acl_levels` (`ID`, `Name`, `Icon`, `Description`, `Active`, `Purchasable`, `Removable`, `QuotaSize`, `QuotaNumber`, `QuotaMaxFileSize`, `Order`) VALUES
-(1, '_adm_prm_txt_level_unauthenticated', 'user', '', 'yes', 'no', 'no', 0, 0, 0, 1),
-(2, '_adm_prm_txt_level_account', 'user', '', 'yes', 'no', 'no', 0, 0, 0, 2),
-(3, '_adm_prm_txt_level_standard', 'user', '', 'yes', 'no', 'no', 0, 0, 0, 3),
+(1, '_adm_prm_txt_level_unauthenticated', 'user-round-arrow-left', '', 'yes', 'no', 'no', 0, 0, 0, 1),
+(2, '_adm_prm_txt_level_account', 'at-sign', '', 'yes', 'no', 'no', 0, 0, 0, 2),
+(3, '_adm_prm_txt_level_standard', 'user-round', '', 'yes', 'no', 'no', 0, 0, 0, 3),
 (4, '_adm_prm_txt_level_unconfirmed', 'user', '', 'yes', 'no', 'no', 0, 0, 0, 4),
-(5, '_adm_prm_txt_level_pending', 'user', '', 'yes', 'no', 'no', 0, 0, 0, 5),
-(6, '_adm_prm_txt_level_suspended', 'user', '', 'yes', 'no', 'no', 0, 0, 0, 6),
-(7, '_adm_prm_txt_level_moderator', 'user-secret', '', 'yes', 'no', 'no', 0, 0, 0, 7),
-(8, '_adm_prm_txt_level_administrator', 'user-secret', '', 'yes', 'no', 'no', 0, 0, 0, 8),
+(5, '_adm_prm_txt_level_pending', 'user-round-pen', '', 'yes', 'no', 'no', 0, 0, 0, 5),
+(6, '_adm_prm_txt_level_suspended', 'user-round-x', '', 'yes', 'no', 'no', 0, 0, 0, 6),
+(7, '_adm_prm_txt_level_moderator', 'user-shield', '', 'yes', 'no', 'no', 0, 0, 0, 7),
+(8, '_adm_prm_txt_level_administrator', 'shield-user', '', 'yes', 'no', 'no', 0, 0, 0, 8),
 (9, '_adm_prm_txt_level_premium', 'user', '', 'yes', 'yes', 'no', 0, 0, 0, 9);
 
 
@@ -5182,6 +5183,7 @@ INSERT INTO `sys_menu_items` (`set_name`, `module`, `name`, `title_system`, `tit
 ('sys_studio_account_popup', 'system', 'account', '_sys_menu_item_title_system_sa_account', '_sys_menu_item_title_sa_account', '{member_url}', '', '', 'ami-account.svg', '', 2147483647, 1, 0, 0, 1),
 ('sys_studio_account_popup', 'system', 'site', '_sys_menu_item_title_system_sa_site', '_sys_menu_item_title_sa_site', '{url_root}', '', '', 'ami-site.svg', '', 2147483647, 1, 0, 0, 2),
 ('sys_studio_account_popup', 'system', 'edit', '_sys_menu_item_title_system_sa_edit', '_sys_menu_item_title_sa_edit', 'javascript:void(0)', '{js_object}.clickEdit(this);', '', 'ami-edit.svg', '', 2147483647, 1, 0, 0, 3),
+('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', '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);
@@ -5296,13 +5298,13 @@ INSERT INTO `sys_grid_fields` (`object`, `name`, `title`, `width`, `translatable
 ('sys_studio_roles_actions', 'description', '_adm_rl_txt_description', '50%', 1, 48, '', 3),
 
 ('sys_studio_acl', 'Order', '', '1%', 0, '', '', 1),
-('sys_studio_acl', 'switcher', '_adm_prm_txt_enable', '5%', 0, '', '', 2),
-('sys_studio_acl', 'Icon', '_adm_prm_txt_icon', '5%', 0, '', '', 3),
-('sys_studio_acl', 'Name', '_adm_prm_txt_title', '29%', 1, '15', '', 4),
-('sys_studio_acl', 'ActionsList', '_adm_prm_txt_actions', '10%', 0, '', '', 5),
-('sys_studio_acl', 'QuotaSize', '_adm_prm_txt_storage', '10%', 0, '', '', 6),
-('sys_studio_acl', 'QuotaMaxFileSize', '_adm_prm_txt_max_file_size', '10%', 0, '', '', 7),
-('sys_studio_acl', 'QuotaNumber', '_adm_prm_txt_max_files', '10%', 0, '', '', 8),
+('sys_studio_acl', 'switcher', '_adm_prm_txt_enable', '5%', 0, '', '', 8),
+('sys_studio_acl', 'Icon', '_adm_prm_txt_icon', '5%', 0, '', '', 2),
+('sys_studio_acl', 'Name', '_adm_prm_txt_title', '29%', 1, '15', '', 3),
+('sys_studio_acl', 'ActionsList', '_adm_prm_txt_actions', '10%', 0, '', '', 4),
+('sys_studio_acl', 'QuotaSize', '_adm_prm_txt_storage', '10%', 0, '', '', 5),
+('sys_studio_acl', 'QuotaMaxFileSize', '_adm_prm_txt_max_file_size', '10%', 0, '', '', 6),
+('sys_studio_acl', 'QuotaNumber', '_adm_prm_txt_max_files', '10%', 0, '', '', 7),
 ('sys_studio_acl', 'actions', '', '20%', 0, '', '', 9),
 
 ('sys_studio_acl_actions', 'switcher', '_adm_prm_txt_enable', '10%', 0, '', '', 1),
@@ -5649,19 +5651,6 @@ INSERT INTO `sys_grid_actions` (`object`, `type`, `name`, `title`, `icon`, `icon
 ('sys_grid_related_me', 'single', 'add', '_sys_add_relation', 'plus-circle', 1, 0, 3),
 ('sys_grid_related_me', 'single', 'delete', '_Delete', 'remove', 1, 1, 4);
 
--- GRID: queues
-INSERT INTO `sys_objects_grid` (`object`, `source_type`, `source`, `table`, `field_id`, `field_order`, `field_active`, `order_get_field`, `order_get_dir`, `paginate_url`, `paginate_per_page`, `paginate_simple`, `paginate_get_start`, `paginate_get_per_page`, `filter_fields`, `filter_fields_translatable`, `filter_mode`, `filter_get`, `sorting_fields`, `sorting_fields_translatable`, `visible_for_levels`, `responsive`, `show_total_count`, `override_class_name`, `override_class_file`) VALUES
-('sys_queues', 'Array', '', '', 'id', '', '', 'order_field', 'order_dir', '', 10, NULL, 'start', '', '', '', 'auto', 'filter', '', '', 128, 0, 0, 'BxDolGridQueues', '');
-
-INSERT INTO `sys_grid_fields` (`object`, `name`, `title`, `width`, `translatable`, `chars_limit`, `params`, `hidden_on`, `order`) VALUES
-('sys_queues', 'name', '_Name', '40%', 1, 0, '', '', 1),
-('sys_queues', 'all', '_all', '20%', 0, 0, '', '', 2),
-('sys_queues', 'failed', '_Failed', '20%', 0, 0, '', '', 3),
-('sys_queues', 'actions', '', '20%', 0, 0, '', '', 4);
-
-INSERT INTO `sys_grid_actions` (`object`, `type`, `name`, `title`, `icon`, `icon_only`, `confirm`, `order`) VALUES
-('sys_queues', 'single', 'clear', '_adm_dbd_txt_q_clear', 'eraser', 1, 1, 1);
-
 -- GRID: API Origins
 
 INSERT INTO `sys_objects_grid` (`object`, `source_type`, `source`, `table`, `field_id`, `field_order`, `field_active`, `paginate_url`, `paginate_per_page`, `paginate_simple`, `paginate_get_start`, `paginate_get_per_page`, `filter_fields`, `filter_fields_translatable`, `filter_mode`, `sorting_fields`, `sorting_fields_translatable`, `override_class_name`, `override_class_file`) VALUES
@@ -6233,7 +6222,7 @@ INSERT INTO `sys_objects_page` (`object`, `uri`, `title_system`, `title`, `modul
 ('sys_profile_settings_cfilter', 'profile-settings-cfilter', '_sys_page_title_system_profile_settings_cfilter', '_sys_page_title_profile_settings_cfilter', 'system', 1, 5, '', 2147483647, 1, 'page.php?i=profile-settings-cfilter', '', '', '', 0, 1, 0, '', '', 0),
 ('sys_unsubscribe_notifications', 'unsubscribe-notifications', '_sys_page_title_system_unsubscribe_notifications', '_sys_page_title_unsubscribe_notifications', 'system', 1, 5, '', 2147483647, 1, 'page.php?i=unsubscribe-notifications', '', '', '', 0, 1, 0, '', '', 0),
 ('sys_unsubscribe_news', 'unsubscribe-news', '_sys_page_title_system_unsubscribe_news', '_sys_page_title_unsubscribe_news', 'system', 1, 5, '', 2147483647, 1, 'page.php?i=unsubscribe-news', '', '', '', 0, 1, 0, '', '', 0),
-('sys_std_dashboard', '', '_sys_page_title_system_studio_dashboard', '_sys_page_title_studio_dashboard', 'system', 1, 4, '', 2147483647, 1, '', '', '', '', 0, 1, 0, '', '', 0),
+('sys_std_dashboard', '', '_sys_page_title_system_studio_dashboard', '_sys_page_title_studio_dashboard', 'system', 1, 5, '', 2147483647, 1, '', '', '', '', 0, 1, 0, '', '', 0),
 ('sys_cmts_view' ,'cmts-view', '_sys_page_title_system_cmts_view', '_cmt_page_view_header', 'system', 1, 5, '', 2147483647, 1, 'page.php?i=cmts-view', '', '', '', 0, 1, 0, 'BxTemplCmtsPageView', '', 0),
 ('sys_cmts_administration' ,'cmts-administration', '_sys_page_title_system_cmts_administration', '_sys_page_title_cmts_administration', 'system', 1, 5, '', 192, 1, 'page.php?i=cmts-administration', '', '', '', 0, 1, 0, '', '', 0),
 ('sys_audit' ,'audit-administration', '_sys_page_title_system_audit_administration', '_sys_page_title_audit_administration', 'system', 1, 5, '', 192, 1, 'page.php?i=audit-administration', '', '', '', 0, 1, 0, '', '', 0),
@@ -6551,18 +6540,20 @@ INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `
 ('sys_recom_friends', 1, 'system', '', '_sys_page_block_title_recom_friends', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:30:"browse_recommendations_friends";s:6:"params";a:2:{i:0;s:12:"{profile_id}";i:1;a:1:{s:13:"empty_message";b:1;}}s:5:"class";s:20:"TemplServiceProfiles";}', 0, 0, 1, 1),
 ('sys_recom_subscriptions', 1, 'system', '', '_sys_page_block_title_recom_subscriptions', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:36:"browse_recommendations_subscriptions";s:6:"params";a:2:{i:0;s:12:"{profile_id}";i:1;a:1:{s:13:"empty_message";b:1;}}s:5:"class";s:20:"TemplServiceProfiles";}', 0, 0, 1, 1),
 
-('sys_ntfs_context_invitations', 1, 'system', '', '_sys_page_block_title_ntfs_context_invitations', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:18:"browse_invitations";s:6:"params";a:2:{i:0;s:19:"{logged_profile_id}";i:1;a:1:{s:13:"empty_message";b:1;}}s:5:"class";s:20:"TemplServiceProfiles";}', 0, 1, 1, 1),
+('sys_ntfs_context_invitations', 1, 'system', '', '_sys_page_block_title_ntfs_context_invitations', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:18:"browse_invitations";s:6:"params";a:2:{i:0;s:19:"{logged_profile_id}";i:1;a:1:{s:13:"empty_message";b:1;}}s:5:"class";s:20:"TemplServiceProfiles";}', 0, 1, 1, 1);
 
 -- studio dashboard blocks
-('sys_std_dashboard', 1, 'system', '', '_sys_page_block_title_std_dash_version', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:17:"get_block_version";s:6:"params";a:0:{}s:5:"class";s:20:"TemplStudioDashboard";}', 0, 0, 1, 1),
+INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `icon`, `designbox_id`, `class`, `tabs`, `async`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
+('sys_std_dashboard', 1, 'system', '', '_sys_page_block_title_std_dash_version', 'package', 11, 'bx-dbd-block-medium', 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:17:"get_block_version";s:6:"params";a:0:{}s:5:"class";s:20:"TemplStudioDashboard";}', 0, 0, 1, 1),
+('sys_std_dashboard', 1, 'system', '', '_sys_page_block_title_std_dash_users', 'users', 11, 'bx-dbd-block-medium', 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:15:"get_block_users";s:6:"params";a:0:{}s:5:"class";s:20:"TemplStudioDashboard";}', 0, 0, 1, 2),
 
-('sys_std_dashboard', 1, 'system', '', '_sys_page_block_title_std_dash_space', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:15:"get_block_space";s:6:"params";a:0:{}s:5:"class";s:20:"TemplStudioDashboard";}', 0, 0, 1, 2),
+('sys_std_dashboard', 1, 'system', '', '_sys_page_block_title_std_dash_space', 'layers', 11, 'bx-dbd-block-medium', 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:15:"get_block_space";s:6:"params";a:0:{}s:5:"class";s:20:"TemplStudioDashboard";}', 0, 0, 1, 3),
 
-('sys_std_dashboard', 2, 'system', '', '_sys_page_block_title_std_dash_host_tools', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:20:"get_block_host_tools";s:6:"params";a:0:{}s:5:"class";s:20:"TemplStudioDashboard";}', 0, 0, 1, 1),
+('sys_std_dashboard', 1, 'system', '', '_sys_page_block_title_std_dash_host_tools', 'server', 11, 'bx-dbd-block-medium', 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:20:"get_block_host_tools";s:6:"params";a:0:{}s:5:"class";s:20:"TemplStudioDashboard";}', 0, 0, 1, 4),
 
-('sys_std_dashboard', 2, 'system', '', '_sys_page_block_title_std_dash_cache', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:15:"get_block_cache";s:6:"params";a:0:{}s:5:"class";s:20:"TemplStudioDashboard";}', 0, 0, 1, 2),
+('sys_std_dashboard', 1, 'system', '', '_sys_page_block_title_std_dash_cache', 'archive', 11, 'bx-dbd-block-medium', 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:15:"get_block_cache";s:6:"params";a:0:{}s:5:"class";s:20:"TemplStudioDashboard";}', 0, 0, 1, 5),
 
-('sys_std_dashboard', 2, 'system', '', '_sys_page_block_title_std_dash_queues', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:16:"get_block_queues";s:6:"params";a:0:{}s:5:"class";s:20:"TemplStudioDashboard";}', 0, 0, 1, 3);
+('sys_std_dashboard', 1, 'system', '', '_sys_page_block_title_std_dash_queues', 'list-todo', 11, 'bx-dbd-block-medium', 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:16:"get_block_queues";s:6:"params";a:0:{}s:5:"class";s:20:"TemplStudioDashboard";}', 0, 0, 1, 6);
 
 INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `tabs`, `async`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `active_api`, `order`) VALUES
 ('sys_cmts_view', 1, 'system', '_sys_page_block_title_system_cmts_view_content', '_cmt_page_view_title_content', 11, 0, 0, 2147483647, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:17:"get_block_content";s:6:"params";a:3:{i:0;s:8:"{system}";i:1;s:11:"{object_id}";i:2;s:12:"{comment_id}";}s:5:"class";s:17:"TemplCmtsServices";}', 0, 0, 1, 0, 0),

+ 1 - 1
modules/boonex/artificer/classes/BxArtificerStudioPage.php

@@ -20,7 +20,7 @@ class BxArtificerStudioPage extends BxTemplStudioDesign
         parent::__construct($sModule, $mixedPageName, $sPage);
 
         $this->aMenuItems = bx_array_insert_after([
-            BX_ARTIFICER_STUDIO_TEMPL_TYPE_STYLES => ['title' => '_bx_artificer_lmi_cpt_styles', 'icon' => 'paint-brush']
+            BX_ARTIFICER_STUDIO_TEMPL_TYPE_STYLES => ['title' => '_bx_artificer_lmi_cpt_styles', 'icon' => 'mi-tpl-styles.svg', 'icon_bg' => true]
         ], $this->aMenuItems, BX_DOL_STUDIO_TEMPL_TYPE_SETTINGS);
     }
 

+ 5 - 2
modules/boonex/artificer/data/template/system/scripts/BxTemplFormView.php

@@ -17,9 +17,12 @@ class BxTemplFormView extends BxBaseFormView
     public function genInputSwitcher(&$aInput)
     {
         $aCheckbox = array_merge($aInput, ['type' => 'checkbox']);
+        $bChecked = isset($aInput['checked']) && $aInput['checked'];
         return $this->oTemplate->parseHtmlByName('form_field_switcher.html', [
-            'class' => isset($aInput['checked']) && $aInput['checked'] ? 'on' : 'off',
-            'checkbox' => $this->genInputStandard($aCheckbox)
+            'class' => $bChecked ? 'on' : 'off',
+            'checked' => $bChecked ? 'true' : 'false', // the button's initial aria-checked; jquery.webForms.js keeps it in step
+            'checkbox' => $this->genInputStandard($aCheckbox),
+            'bx_if:show_label' => $this->genInputSwitcherLabel($aInput)
         ]);
     }
 }

+ 166 - 51
modules/boonex/english/data/langs/system/en.xml

@@ -515,7 +515,7 @@
 	<string name="_adm_bp_btn_block_cancel"><![CDATA[Cancel]]></string>
 	<string name="_adm_bp_btn_block_delete"><![CDATA[Delete]]></string>
 	<string name="_adm_bp_btn_block_save"><![CDATA[Save]]></string>
-	<string name="_adm_bp_btn_create_new"><![CDATA[Add New Page]]></string>
+	<string name="_adm_bp_btn_create_new"><![CDATA[New Page]]></string>
 	<string name="_adm_bp_btn_delete"><![CDATA[Delete]]></string>
 	<string name="_adm_bp_btn_page_apply"><![CDATA[Apply]]></string>
 	<string name="_adm_bp_btn_page_cancel"><![CDATA[Cancel]]></string>
@@ -665,16 +665,82 @@
         <string name="_adm_btn_settings_mix_publish"><![CDATA[Publish]]></string>
 	<string name="_adm_btn_store_search"><![CDATA[Search]]></string>
 	<string name="_adm_dbd_btn_update"><![CDATA[Update]]></string>
-        <string name="_adm_dbd_txt_available"><![CDATA[Available:]]></string>
-	<string name="_adm_dbd_txt_installed"><![CDATA[Installed:]]></string>
-	<string name="_adm_dbd_txt_updated"><![CDATA[Updated:]]></string>
+	<string name="_adm_dbd_txt_ver_installed"><![CDATA[First Installed]]></string>
+	<string name="_adm_dbd_txt_ver_updated"><![CDATA[Last Updated]]></string>
+	<string name="_adm_dbd_txt_ver_applied"><![CDATA[Installed {0}]]></string>
+	<string name="_adm_dbd_txt_ver_latest_stable"><![CDATA[Latest Stable]]></string>
+	<string name="_adm_dbd_txt_ver_latest_beta"><![CDATA[Latest Beta]]></string>
+	<string name="_adm_dbd_txt_ver_apps_count"><![CDATA[{0} Apps]]></string>
+	<string name="_adm_dbd_txt_ver_apps_updates"><![CDATA[Updates: {0}]]></string>
+	<string name="_adm_dbd_txt_ver_up_to_date"><![CDATA[Up to date]]></string>
+	<string name="_adm_dbd_txt_ver_check"><![CDATA[Check for updates]]></string>
+	<string name="_adm_dbd_txt_ver_checking"><![CDATA[Checking...]]></string>
+	<string name="_adm_dbd_txt_ver_auto"><![CDATA[Automatic Updates]]></string>
+	<string name="_adm_dbd_txt_ver_beta"><![CDATA[Beta Updates]]></string>
+	<string name="_adm_dbd_txt_ver_force"><![CDATA[Force Automatic Updates]]></string>
+	<string name="_adm_dbd_txt_ver_access"><![CDATA[Access to automatic updates]]></string>
+	<string name="_adm_dbd_txt_ver_access_active"><![CDATA[Active]]></string>
+	<string name="_adm_dbd_btn_ver_access_activate"><![CDATA[Activate]]></string>
+	<string name="_adm_dbd_txt_us_period"><![CDATA[Period]]></string>
+	<string name="_adm_dbd_txt_us_series"><![CDATA[Chart data]]></string>
+	<string name="_adm_dbd_txt_us_7d"><![CDATA[7d]]></string>
+	<string name="_adm_dbd_txt_us_7d_label"><![CDATA[Last 7 days]]></string>
+	<string name="_adm_dbd_txt_us_30d"><![CDATA[30d]]></string>
+	<string name="_adm_dbd_txt_us_30d_label"><![CDATA[Last 30 days]]></string>
+	<string name="_adm_dbd_txt_us_1y"><![CDATA[1y]]></string>
+	<string name="_adm_dbd_txt_us_1y_label"><![CDATA[Last 12 months]]></string>
+	<string name="_adm_dbd_txt_us_all"><![CDATA[Total]]></string>
+	<string name="_adm_dbd_txt_us_all_label"><![CDATA[All time]]></string>
+	<string name="_adm_dbd_txt_us_peak"><![CDATA[peak {0}]]></string>
+	<string name="_adm_dbd_txt_us_accounts"><![CDATA[Accounts]]></string>
+	<string name="_adm_dbd_txt_us_active"><![CDATA[Active users]]></string>
+	<string name="_adm_dbd_txt_us_online"><![CDATA[Online now]]></string>
+	<string name="_adm_dbd_txt_us_pending"><![CDATA[{0} pending]]></string>
         <string name="_adm_dbd_msg_upgrade_started"><![CDATA[Site will be upgraded within a minute.<br />Don't forget to <a href="{0}store.php?page=updates">check for modules updates</a> after that.]]></string>
 	<string name="_adm_dbd_txt_su_database"><![CDATA[Database]]></string>
 	<string name="_adm_dbd_txt_su_system"><![CDATA[System]]></string>
 	<string name="_adm_dbd_txt_su_system_media"><![CDATA[System Media]]></string>
-        <string name="_adm_dbd_txt_c_select"><![CDATA[Select...]]></string>
+	<string name="_adm_dbd_txt_su_local"><![CDATA[Local Storage]]></string>
+	<string name="_adm_dbd_txt_su_remote"><![CDATA[Remote Storage]]></string>
+	<string name="_adm_dbd_txt_su_remote_off"><![CDATA[Not connected]]></string>
+	<string name="_adm_dbd_txt_su_files"><![CDATA[Files]]></string>
+	<string name="_adm_dbd_txt_su_images"><![CDATA[Images]]></string>
+	<string name="_adm_dbd_txt_su_files_count"><![CDATA[{0} files]]></string>
+	<string name="_adm_dbd_txt_su_file_count_1"><![CDATA[1 file]]></string>
+	<string name="_adm_dbd_txt_chart_used"><![CDATA[{0} used]]></string>
+	<string name="_adm_dbd_txt_chart_total"><![CDATA[{0} total]]></string>
+	<string name="_adm_dbd_txt_ht_tools"><![CDATA[Server tools]]></string>
+	<string name="_adm_dbd_txt_ht_status"><![CDATA[Status]]></string>
+	<string name="_adm_dbd_txt_ht_audit"><![CDATA[Audit]]></string>
+	<string name="_adm_dbd_txt_ht_permissions"><![CDATA[Permissions]]></string>
+	<string name="_adm_dbd_txt_ht_cpu"><![CDATA[CPU]]></string>
+	<string name="_adm_dbd_txt_ht_memory"><![CDATA[Memory]]></string>
+	<string name="_adm_dbd_txt_ht_disk"><![CDATA[Disk]]></string>
+	<string name="_adm_dbd_txt_ht_network"><![CDATA[Network]]></string>
+	<string name="_adm_dbd_txt_ht_kbps"><![CDATA[kbps]]></string>
+	<string name="_adm_dbd_txt_ht_mbps"><![CDATA[Mbps]]></string>
+	<string name="_adm_dbd_txt_ht_gbps"><![CDATA[Gbps]]></string>
+	<string name="_adm_dbd_txt_ht_na"><![CDATA[n/a]]></string>
+	<string name="_adm_dbd_txt_ht_high"><![CDATA[high]]></string>
+	<string name="_adm_dbd_txt_ht_system"><![CDATA[System]]></string>
+	<string name="_adm_dbd_txt_ht_services"><![CDATA[Services]]></string>
+	<string name="_adm_dbd_txt_ht_caching"><![CDATA[Caching]]></string>
+	<string name="_adm_dbd_txt_ht_security"><![CDATA[Security]]></string>
+	<string name="_adm_dbd_txt_ht_last_audit"><![CDATA[Last Audit]]></string>
+	<string name="_adm_dbd_txt_ht_run_audit"><![CDATA[Run new audit]]></string>
+	<string name="_adm_dbd_txt_ht_passed"><![CDATA[{0} passed]]></string>
+	<string name="_adm_dbd_txt_ht_warning"><![CDATA[1 warning]]></string>
+	<string name="_adm_dbd_txt_ht_warnings"><![CDATA[{0} warnings]]></string>
+	<string name="_adm_dbd_txt_ht_failed"><![CDATA[{0} failed]]></string>
+	<string name="_adm_dbd_txt_ht_unknown"><![CDATA[{0} unknown]]></string>
+	<string name="_adm_dbd_txt_c_all"><![CDATA[All Caches]]></string>
 	<string name="_adm_dbd_txt_c_clear_all"><![CDATA[Clear all caches]]></string>
-	<string name="_adm_dbd_txt_q_clear"><![CDATA[Clear queue]]></string>
+	<string name="_adm_dbd_txt_q_all"><![CDATA[All Queues]]></string>
+	<string name="_adm_dbd_txt_q_clear_all"><![CDATA[Clear all queues]]></string>
+	<string name="_adm_dbd_txt_q_clear_item"><![CDATA[Clear {0} queue]]></string>
+	<string name="_adm_dbd_txt_q_queued"><![CDATA[{0} queued]]></string>
+	<string name="_adm_dbd_txt_q_failed"><![CDATA[{0} failed]]></string>
+	<string name="_adm_dbd_msg_q_clean_success"><![CDATA[Queue was successfully cleared]]></string>
 	<string name="_adm_dbd_txt_c_content"><![CDATA[Content]]></string>
 	<string name="_adm_dbd_txt_c_clear_content"><![CDATA[Clear content cache]]></string>
 	<string name="_adm_dbd_txt_c_db"><![CDATA[DB]]></string>
@@ -686,9 +752,11 @@
         <string name="_adm_dbd_txt_c_less"><![CDATA[LESS]]></string>
         <string name="_adm_dbd_txt_c_clear_less"><![CDATA[Clear LESS cache]]></string>
         <string name="_adm_dbd_txt_c_clear_purifier"><![CDATA[Clear Purifier cache]]></string>
+        <string name="_adm_dbd_txt_c_purifier"><![CDATA[Purifier]]></string>
         <string name="_adm_dbd_txt_c_js"><![CDATA[JavaScript]]></string>
         <string name="_adm_dbd_txt_c_clear_js"><![CDATA[Clear JavaScript cache]]></string>
         <string name="_adm_dbd_txt_c_clear_custom"><![CDATA[Clear Custom cache]]></string>
+        <string name="_adm_dbd_txt_c_custom"><![CDATA[Custom]]></string>
         <string name="_adm_dbd_txt_c_opcache"><![CDATA[Opcache]]></string>
         <string name="_adm_dbd_txt_c_clear_opcache"><![CDATA[Clear Opcache]]></string>
         <string name="_adm_dbd_err_c_clean_failed"><![CDATA[Failed to clear cache]]></string>
@@ -1170,7 +1238,7 @@
     <string name="_adm_lmi_cpt_displays"><![CDATA[Displays]]></string>
     <string name="_adm_lmi_cpt_downloaded"><![CDATA[Downloaded]]></string>
     <string name="_adm_lmi_cpt_etemplates_text"><![CDATA[Email Texts]]></string>
-    <string name="_adm_lmi_cpt_etemplates_html"><![CDATA[Email Template]]></string>
+    <string name="_adm_lmi_cpt_etemplates_html"><![CDATA[Email Templates]]></string>
     <string name="_adm_lmi_cpt_featured"><![CDATA[Featured]]></string>
     <string name="_adm_lmi_cpt_fields"><![CDATA[Fields]]></string>
     <string name="_adm_lmi_cpt_files"><![CDATA[Files]]></string>
@@ -1216,7 +1284,7 @@
 	<string name="_adm_msg_modules_success_enable"><![CDATA[The package was enabled successfully]]></string>
 	<string name="_adm_msg_modules_success_install"><![CDATA[The package was installed successfully]]></string>
 	<string name="_adm_msg_modules_success_uninstall"><![CDATA[The package was uninstalled successfully]]></string>
-	<string name="_adm_msg_oauth_need_authorize"><![CDATA[You need to to login into your <b>UNA</b> account to get data. Click <a href="{0}">here</a> to login.]]></string>
+	<string name="_adm_msg_oauth_need_authorize"><![CDATA[You need to log in to your <b>UNA</b> account to get data: <a href="{0}">log in to UNA</a>.]]></string>
         <string name="_adm_msg_oauth_non_owner_logged"><![CDATA[WARNING: You are logged in to an <a href="{0}">UNA.IO</a> account which is different from the account that generated UNA Key & Secret for your domain.]]></string>
 	<string name="_adm_nav_btn_current_icon_image_delete"><![CDATA[Delete]]></string>
 	<string name="_adm_nav_btn_items_add"><![CDATA[Add]]></string>
@@ -1376,6 +1444,23 @@
 	<string name="_adm_page_cpt_dashboard"><![CDATA[Dashboard]]></string>
 	<string name="_adm_page_cpt_designer"><![CDATA[Designer]]></string>
 	<string name="_adm_page_cpt_home"><![CDATA[Studio]]></string>
+	<string name="_adm_page_dsc_default"><![CDATA[{0} in UNA Studio, the administration area of {1}.]]></string>
+	<string name="_adm_page_dsc_home"><![CDATA[UNA Studio: every administration app of {0} in one place.]]></string>
+	<string name="_adm_page_dsc_dashboard"><![CDATA[Site status at a glance: updates, storage, cache, server health and audit results for {0}.]]></string>
+	<string name="_adm_page_dsc_settings"><![CDATA[Site-wide settings of {0}: general options, security, email, cache and every installed app.]]></string>
+	<string name="_adm_page_dsc_store"><![CDATA[Browse, install, update and manage the apps and templates available to {0}.]]></string>
+	<string name="_adm_page_dsc_polyglot"><![CDATA[Languages and translations of {0}: language packs, keys and email templates.]]></string>
+	<string name="_adm_page_dsc_builder_pages"><![CDATA[Page builder: the blocks and layouts of every page on {0}.]]></string>
+	<string name="_adm_page_dsc_builder_menus"><![CDATA[Navigation builder: menus, sets of menu items and their placement across {0}.]]></string>
+	<string name="_adm_page_dsc_builder_forms"><![CDATA[Form builder: fields, displays and pre-defined lists of every form on {0}.]]></string>
+	<string name="_adm_page_dsc_builder_permissions"><![CDATA[Permissions: what each membership level may do on {0}.]]></string>
+	<string name="_adm_page_dsc_builder_roles"><![CDATA[Studio roles: which operators of {0} may use which administration apps.]]></string>
+	<string name="_adm_page_dsc_designer"><![CDATA[Designer: templates, styles, icons, covers and the look of {0}.]]></string>
+	<string name="_adm_page_dsc_agents"><![CDATA[AI agents: models, automators and assistants configured for {0}.]]></string>
+	<string name="_adm_page_dsc_badges"><![CDATA[Badges: the badges members can earn on {0}.]]></string>
+	<string name="_adm_page_dsc_storages"><![CDATA[Storage: file storage engines, usage and image resizing tools of {0}.]]></string>
+	<string name="_adm_page_dsc_api"><![CDATA[API: keys, clients and access to the {0} API.]]></string>
+	<string name="_adm_page_dsc_audit"><![CDATA[Audit: the server and configuration checks for {0}.]]></string>
 	<string name="_adm_page_cpt_login"><![CDATA[Login]]></string>
 	<string name="_adm_page_cpt_polyglot"><![CDATA[Polyglot]]></string>
 	<string name="_adm_page_cpt_settings"><![CDATA[Settings]]></string>
@@ -1626,9 +1711,9 @@
     <string name="_adm_stg_cpt_option_site_title"><![CDATA[Community name]]></string>
     <string name="_adm_stg_inf_option_site_title"><![CDATA[The default name used to refer to your site in site texts, meta tags, emails and notifications.]]></string>
     <string name="_adm_stg_cpt_option_site_tour_home"><![CDATA[User Tour]]></string>
-    <string name="_adm_stg_inf_option_site_tour_home"><![CDATA[You can edit steps and content of the User Tour <a href="/studio/polyglot.php?page=keys">here</a>.]]></string>
+    <string name="_adm_stg_inf_option_site_tour_home"><![CDATA[You can edit the steps and content of the User Tour in the <a href="/studio/polyglot.php?page=keys">Polyglot language keys</a>.]]></string>
     <string name="_adm_stg_cpt_option_site_tour_studio"><![CDATA[Studio Tour]]></string>
-    <string name="_adm_stg_inf_option_site_tour_studio"><![CDATA[You can edit steps and content of the Studio Tour <a href="/studio/polyglot.php?page=keys">here</a>.]]></string>
+    <string name="_adm_stg_inf_option_site_tour_studio"><![CDATA[You can edit the steps and content of the Studio Tour in the <a href="/studio/polyglot.php?page=keys">Polyglot language keys</a>.]]></string>
     <string name="_adm_stg_cpt_option_smart_app_banner"><![CDATA[Smart App Banner]]></string>
     <string name="_adm_stg_inf_option_smart_app_banner"><![CDATA[]]></string>
     <string name="_adm_stg_cpt_option_smart_app_banner_ios_app_id"><![CDATA[iOS app ID for Smart App Banner]]></string>
@@ -1761,6 +1846,7 @@
     <string name="_adm_stg_cpt_option_sys_push_wonderpush_access_token"><![CDATA[WonderPush: Access Token]]></string>
     <string name="_adm_stg_cpt_option_sys_push_wonderpush_web_key"><![CDATA[WonderPush: Web Key]]></string>
     <string name="_adm_stg_cpt_option_sys_push_queue_time"><![CDATA[Push queue execution flag]]></string>
+    <string name="_adm_stg_cpt_option_sys_audit_report"><![CDATA[Last server audit report]]></string>
     <string name="_adm_stg_cpt_option_sys_push_queue_send_per_start"><![CDATA[Number of messages to send from Push queue per run]]></string>
     <string name="_adm_stg_cpt_option_sys_push_queue_send_per_start_to_recipient"><![CDATA[Number of messages to send from Push queue per run to some recipient]]></string>
     <string name="_adm_stg_cpt_option_sys_alerts_logger"><![CDATA[Alerts/hooks logger]]></string>
@@ -1988,6 +2074,7 @@
 	<string name="_adm_str_txt_pv_discount_off_csign"><![CDATA[{0}% ({1}{2}) off for Premium]]></string>
 	<string name="_adm_str_txt_pv_price"><![CDATA[Price]]></string>
 	<string name="_adm_str_txt_pv_rent"><![CDATA[Rent]]></string>
+	<string name="_adm_str_txt_pv_screenshot"><![CDATA[{0} screenshot]]></string>
 	<string name="_adm_str_txt_pv_stats"><![CDATA[Stats]]></string>
 	<string name="_adm_str_txt_pv_stats_votes"><![CDATA[{0} votes]]></string>
 	<string name="_adm_str_txt_pv_stats_reviews"><![CDATA[{0} reviews]]></string>
@@ -2018,6 +2105,7 @@
 	<string name="_adm_strg_txt_files_add_popup"><![CDATA[Add File]]></string>
 	<string name="_adm_strg_txt_images_add_popup"><![CDATA[Add Image]]></string>
 	<string name="_adm_strg_txt_images_resize_popup"><![CDATA[Resize Image]]></string>
+	<string name="_adm_strg_txt_images_resize_result"><![CDATA[Resized image]]></string>
 	<string name="_adm_strg_txt_size_mime_type"><![CDATA[{0}x{1}, {2}]]></string>
 	<string name="_adm_strg_txt_width"><![CDATA[Width]]></string>
 	<string name="_adm_strg_err_width"><![CDATA[Width is essential. Please, fill in this field.]]></string>
@@ -2028,15 +2116,25 @@
 	<string name="_adm_tmi_cpt_site"><![CDATA[Go To Site]]></string>
         <string name="_adm_tmi_cpt_search"><![CDATA[Search...]]></string>
         <string name="_adm_tmi_cpt_search_apps"><![CDATA[Applications...]]></string>
+        <string name="_adm_tmi_lbl_search_apps"><![CDATA[Search applications]]></string>
+        <string name="_adm_tmi_lbl_search_clear"><![CDATA[Clear search]]></string>
         <string name="_adm_tmi_cpt_scheme"><![CDATA[Color Scheme]]></string>
-	<string name="_adm_tmi_cpt_tour"><![CDATA[Tour]]></string>
 	<string name="_adm_tmi_cpt_launcher"><![CDATA[Launcher]]></string>
         <string name="_adm_txt_active_api"><![CDATA[Available in App]]></string>
         <string name="_adm_txt_assistant_popup_title"><![CDATA[How can I help?]]></string>
 	<string name="_adm_txt_confirm_cancel"><![CDATA[Cancel]]></string>
 	<string name="_adm_txt_confirm_ok"><![CDATA[OK]]></string>
+	<string name="_adm_txt_launcher_apps"><![CDATA[Apps]]></string>
+	<string name="_adm_txt_launcher_search_empty"><![CDATA[No apps match]]></string>
+	<string name="_adm_txt_launcher_search_matches"><![CDATA[{0} apps match]]></string>
+	<string name="_adm_txt_launcher_search_matches_one"><![CDATA[1 app matches]]></string>
+	<string name="_adm_txt_widget_action_label"><![CDATA[{0} for {1}]]></string>
+	<string name="_adm_txt_widget_move_left"><![CDATA[Move {0} left]]></string>
+	<string name="_adm_txt_widget_move_right"><![CDATA[Move {0} right]]></string>
+	<string name="_adm_txt_widget_moved"><![CDATA[{0} moved to position {1} of {2}]]></string>
 	<string name="_adm_txt_forgot_password"><![CDATA[Forgot password?]]></string>
 	<string name="_adm_txt_form_view_missing"><![CDATA[missing]]></string>
+	<string name="_adm_txt_icon_image_current"><![CDATA[Current icon image]]></string>
 	<string name="_adm_txt_login_password"><![CDATA[Password]]></string>
         <string name="_adm_txt_login_password_placeholder"><![CDATA[your password]]></string>
 	<string name="_adm_txt_login_email"><![CDATA[Email address]]></string>
@@ -2623,53 +2721,60 @@
   <string name="_sys_audit_filter_profile_select"><![CDATA[Select Profile]]></string>
   <string name="_sys_audit_filter_action_select"><![CDATA[Select Action]]></string>
 	<string name="_sys_audit_cron_jobs"><![CDATA[Cron jobs]]></string>
-	<string name="_sys_audit_cron_jobs_exec_time"><![CDATA[Last cron jobs execution]]></string>
-	<string name="_sys_audit_header_manual_audit"><![CDATA[Manual Server Audit]]></string>
+	<string name="_sys_audit_header_manual_audit"><![CDATA[Manual audit]]></string>
 	<string name="_sys_audit_header_requirements"><![CDATA[Requirements]]></string>
-	<string name="_sys_audit_header_site_optimization"><![CDATA[Site optimization]]></string>
+	<string name="_sys_audit_header_site_optimization"><![CDATA[Optimization]]></string>
 	<string name="_sys_audit_header_site_setup"><![CDATA[Site setup]]></string>
-	<string name="_sys_audit_mail_sending"><![CDATA[Mail sending]]></string>
-	<string name="_sys_audit_msg_apache_module_fail"><![CDATA[you will need to install {0} for Apache]]></string>
-	<string name="_sys_audit_msg_cron_jobs"><![CDATA[<pre class="code">{0}</pre> if you are unsure if output is correct then <a href="#manual_audit">manual server audit</a> may be required]]></string>
-	<string name="_sys_audit_msg_optimization_fail"><![CDATA[please enable this cache in Studio &gt; Settings &gt; Cache]]></string>
-	<string name="_sys_audit_msg_optimization_warn"><![CDATA[installing PHP accelerator will speed-up file cache]]></string>
-	<string name="_sys_audit_msg_x_based_cache_engine"><![CDATA[ ({0} based cache engine)]]></string>
-	<string name="_sys_audit_msg_ffmpeg"><![CDATA[<pre class="code">{0}</pre>if you don't know if output is correct then <a href="#manual_audit">manual server audit</a> may be required.]]></string>
-	<string name="_sys_audit_msg_hardware_requirements"><![CDATA[Hardware cannot be checked automatically. A <a href="#manual_audit">manual server audit</a> may be needed.]]></string>
-	<string name="_sys_audit_msg_mail_send_failed"><![CDATA[mail send failed]]></string>
-	<string name="_sys_audit_msg_mail_sending"><![CDATA[<span id="bx-sys-adm-audit-test-email"><a href="javascript:void(0);" onclick="bx_sys_adm_audit_test_email()">click here</a> to send test email</span>]]></string>
-	<string name="_sys_audit_msg_mail_sent"><![CDATA[test mail was send, please check {0} mailbox]]></string>
-	<string name="_sys_audit_msg_manual_audit"><![CDATA[<p>
-Some checks have to be done by hand. If you want help, <a target="_blank" href="mailto:team@unacms.com?subject=Server Audit Request">submit a server audit request</a>.
-</p>]]></string>
+	<string name="_sys_audit_mail_sending"><![CDATA[Email delivery]]></string>
+	<string name="_sys_audit_msg_apache_module_fail"><![CDATA[Install {0} for Apache.]]></string>
+	<string name="_sys_audit_msg_cron_jobs"><![CDATA[No run in the last hour: check the crontab entry.]]></string>
+	<string name="_sys_audit_msg_optimization_fail"><![CDATA[Enable it in Settings &gt; Cache.]]></string>
+	<string name="_sys_audit_msg_optimization_warn"><![CDATA[A PHP accelerator would speed up the file cache.]]></string>
+	<string name="_sys_audit_msg_x_based_cache_engine"><![CDATA[ ({0})]]></string>
+	<string name="_sys_audit_msg_ffmpeg"><![CDATA[Not found at {0}: video and audio conversion will not work.]]></string>
+	<string name="_sys_audit_msg_hardware_requirements"><![CDATA[Not checked automatically: see the manual audit.]]></string>
+	<string name="_sys_audit_msg_mail_send_failed"><![CDATA[Sending failed.]]></string>
+	<string name="_sys_audit_msg_mail_sending"><![CDATA[<span id="bx-sys-adm-audit-test-email"><a href="javascript:void(0);" onclick="bx_sys_adm_audit_test_email()">Send a test email</a></span>]]></string>
+	<string name="_sys_audit_msg_mail_sent"><![CDATA[Test email sent to {0}.]]></string>
+	<string name="_sys_audit_msg_manual_audit"><![CDATA[Some checks need a person. <a target="_blank" href="mailto:team@unacms.com?subject=Server Audit Request">Request a server audit</a>.]]></string>
 	<string name="_sys_audit_msg_must_be"><![CDATA[must be {0} {1}]]></string>
-	<string name="_sys_audit_msg_optimization_apache_module"><![CDATA[To apply this optimization you need to have <b>{0}</b> - {1}]]></string>
-	<string name="_sys_audit_msg_permissions"><![CDATA[please <a href="javascript:void(0);" onclick="oBxDolStudioDashboard.permissions()">click here</a> to find out if permissions are correct.]]></string>
-	<string name="_sys_audit_msg_php_accelerator_missing"><![CDATA[The script can be much faster if you install some PHP accelerator]]></string>
-	<string name="_sys_audit_msg_php_setup_inefficient"><![CDATA[your PHP setup maybe very inefficient, <a href="javascript:void(0)" onclick="bx_sys_adm_audit_phpinfo()">please check it for sure</a> and try to switch to mod_php, apache dso module or FastCGI]]></string>
-	<string name="_sys_audit_msg_serverside_compression"><![CDATA[
-can be checked <a href="#manual_audit">manually</a> or in the PageSpeed tool built into the browser.<br />
-If it is not enabled then please consider implementing this optimization, since it improves perceived site speed and saves bandwidth.<br />{0}]]></string>
+	<string name="_sys_audit_msg_permissions"><![CDATA[Details on the <a href="javascript:void(0);" onclick="oBxDolStudioDashboard.permissions()">Permissions</a> tab.]]></string>
+	<string name="_sys_audit_msg_php_accelerator_missing"><![CDATA[Install a PHP accelerator such as OPcache for a large speed-up.]]></string>
+	<string name="_sys_audit_msg_php_setup_inefficient"><![CDATA[CGI is slow: switch to mod_php, DSO or FastCGI. <a href="javascript:void(0)" onclick="bx_sys_adm_audit_phpinfo()">Open phpinfo</a>.]]></string>
 	<string name="_sys_audit_msg_should_be"><![CDATA[should be {0} {1}]]></string>
-	<string name="_sys_audit_msg_userside_caching"><![CDATA[
-<a href="{0}">click here to check it in Google PageSpeed</a><br />
-If it is not enabled then please consider implementing this optimization, since it improves perceived site speed and saves bandwidth.<br />{1}]]></string>
-	<string name="_sys_audit_msg_value_checking_failed"><![CDATA[value checking failed]]></string>
-	<string name="_sys_audit_msg_version_is_incompatible"><![CDATA[your version is incompatible with UNA, must be at least {0}]]></string>
-	<string name="_sys_audit_msg_version_is_outdated"><![CDATA[your version is outdated, upgrade to the latest {0} maybe required]]></string>
-	<string name="_sys_audit_permissions"><![CDATA[Files and folders permissions]]></string>
+	<string name="_sys_audit_msg_userside_caching"><![CDATA[<a href="{0}" target="_blank">Check in PageSpeed</a>]]></string>
+	<string name="_sys_audit_msg_value_checking_failed"><![CDATA[Could not be checked.]]></string>
+	<string name="_sys_audit_msg_version_is_incompatible"><![CDATA[Unsupported: UNA needs {0} or newer.]]></string>
+	<string name="_sys_audit_msg_version_is_outdated"><![CDATA[Outdated: the latest is {0}.]]></string>
+	<string name="_sys_audit_permissions"><![CDATA[File permissions]]></string>
+	<string name="_sys_audit_https"><![CDATA[HTTPS]]></string>
+	<string name="_sys_audit_msg_https"><![CDATA[Served over plain HTTP: switch the site to HTTPS.]]></string>
+	<string name="_sys_audit_install_dir"><![CDATA[Install directory]]></string>
+	<string name="_sys_audit_msg_install_dir"><![CDATA[Still on the server: remove the install directory.]]></string>
+	<string name="_sys_audit_display_errors"><![CDATA[Error display]]></string>
+	<string name="_sys_audit_msg_display_errors"><![CDATA[PHP shows errors to visitors: set display_errors = Off.]]></string>
+	<string name="_sys_audit_expose_php"><![CDATA[PHP version header]]></string>
+	<string name="_sys_audit_msg_expose_php"><![CDATA[The PHP version is sent in response headers: set expose_php = Off.]]></string>
+	<string name="_sys_audit_config_file"><![CDATA[Configuration file]]></string>
+	<string name="_sys_audit_msg_config_file"><![CDATA[inc/header.inc.php is writable by other users: set it to 0644.]]></string>
+	<string name="_sys_audit_present"><![CDATA[Present]]></string>
+	<string name="_sys_audit_removed"><![CDATA[Removed]]></string>
+	<string name="_sys_audit_on"><![CDATA[On]]></string>
+	<string name="_sys_audit_off"><![CDATA[Off]]></string>
 	<string name="_sys_audit_php_accelerator"><![CDATA[PHP accelerator]]></string>
-	<string name="_sys_audit_php_setup"><![CDATA[PHP setup]]></string>
+	<string name="_sys_audit_php_setup"><![CDATA[PHP handler]]></string>
 	<string name="_sys_audit_section_hardware"><![CDATA[Hardware]]></string>
+	<string name="_sys_audit_section_cache_engines"><![CDATA[Cache engines]]></string>
 	<string name="_sys_audit_section_os"><![CDATA[OS]]></string>
-	<string name="_sys_audit_section_webserver"><![CDATA[Web-server]]></string>
-	<string name="_sys_audit_serverside_compression"><![CDATA[Server-side content compression]]></string>
+	<string name="_sys_audit_section_php_extensions"><![CDATA[PHP extensions]]></string>
+	<string name="_sys_audit_section_webserver"><![CDATA[Web server]]></string>
+	<string name="_sys_audit_serverside_compression"><![CDATA[Response compression]]></string>
 	<string name="_sys_audit_title_fail"><![CDATA[Fail]]></string>
 	<string name="_sys_audit_title_ok"><![CDATA[Pass]]></string>
 	<string name="_sys_audit_title_undef"><![CDATA[Unknown]]></string>
 	<string name="_sys_audit_title_warn"><![CDATA[Warning]]></string>
-	<string name="_sys_audit_userside_caching"><![CDATA[User-side caching for static content]]></string>
-	<string name="_sys_audit_version"><![CDATA[version]]></string>
+	<string name="_sys_audit_userside_caching"><![CDATA[Browser caching]]></string>
+	<string name="_sys_audit_version"><![CDATA[Version]]></string>
 	<string name="_sys_audit_version_script"><![CDATA[UNA version]]></string>
 
 	<string name="_sys_agents_agents_popup_edit"><![CDATA[Edit "{0}" agent]]></string>
@@ -3281,6 +3386,13 @@ If it is not enabled then please consider implementing this optimization, since
 	<string name="_sys_format_size_kb"><![CDATA[{0} KB]]></string>
 	<string name="_sys_format_size_mb"><![CDATA[{0} MB]]></string>
 	<string name="_sys_format_size_tb"><![CDATA[{0} TB]]></string>
+	<string name="_sys_grid_lbl_actions"><![CDATA[Actions]]></string>
+	<string name="_sys_grid_lbl_order"><![CDATA[Order]]></string>
+	<string name="_sys_grid_lbl_select"><![CDATA[Select]]></string>
+	<string name="_sys_grid_lbl_select_all"><![CDATA[Select all]]></string>
+	<string name="_sys_str_limit_show"><![CDATA[Show full text]]></string>
+	<string name="_adm_grid_lbl_search"><![CDATA[Search]]></string>
+	<string name="_adm_grid_lbl_module"><![CDATA[Module]]></string>
 	<string name="_sys_grid_confirmation"><![CDATA[Are you sure?]]></string>
 	<string name="_sys_grid_delete_failed"><![CDATA[Delete failed]]></string>
   <string name="_sys_grid_search"><![CDATA[Search...]]></string>
@@ -3789,6 +3901,8 @@ If it is not enabled then please consider implementing this optimization, since
     <string name="_sys_menu_item_title_sa_site"><![CDATA[Visit Web App]]></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>
+    <string name="_sys_menu_item_title_sa_tour"><![CDATA[Introduction Tour]]></string>
     <string name="_sys_menu_item_title_system_sa_scheme"><![CDATA[Theme]]></string>
     <string name="_sys_menu_item_title_sa_scheme"><![CDATA[Theme]]></string>
     <string name="_sys_menu_item_title_system_sa_language"><![CDATA[Language]]></string>
@@ -3917,11 +4031,12 @@ If it is not enabled then please consider implementing this optimization, since
     <string name="_sys_page_block_title_profile_membership"><![CDATA[Membership]]></string>
     <string name="_sys_page_block_title_unsubscribe_news"><![CDATA[Unsubscribe From Site Newsletters]]></string>
     <string name="_sys_page_block_title_unsubscribe_notifications"><![CDATA[Unsubscribe From Site Notifications]]></string>
-    <string name="_sys_page_block_title_std_dash_version"><![CDATA[Version]]></string>
-    <string name="_sys_page_block_title_std_dash_space"><![CDATA[Space Usage]]></string>
-    <string name="_sys_page_block_title_std_dash_host_tools"><![CDATA[Host Tools]]></string>
+    <string name="_sys_page_block_title_std_dash_version"><![CDATA[Updates]]></string>
+    <string name="_sys_page_block_title_std_dash_space"><![CDATA[Space]]></string>
+    <string name="_sys_page_block_title_std_dash_host_tools"><![CDATA[Server]]></string>
     <string name="_sys_page_block_title_std_dash_cache"><![CDATA[Cache]]></string>
     <string name="_sys_page_block_title_std_dash_queues"><![CDATA[Queues]]></string>
+    <string name="_sys_page_block_title_std_dash_users"><![CDATA[Users]]></string>
     <string name="_sys_page_block_title_terms"><![CDATA[Terms of Service]]></string>
     <string name="_sys_page_block_title_sys_std_site_submenu"><![CDATA[Site submenu]]></string>
     <string name="_sys_page_block_title_std_site_submenu"><![CDATA[Site submenu]]></string>
@@ -4558,7 +4673,7 @@ If it is not enabled then please consider implementing this optimization, since
     <string name="_sys_queue_emails"><![CDATA[Emails]]></string>
     <string name="_sys_queue_push"><![CDATA[PUSH]]></string>
     <string name="_sys_queue_transcoding"><![CDATA[Video transcoding]]></string>
-    <string name="_sys_queue_storage"><![CDATA[Files storage]]></string>    
+    <string name="_sys_queue_storage"><![CDATA[File deletions]]></string>
     <string name="_sys_page_title_redirect"><![CDATA[Redirect Confirmation]]></string>
     <string name="_sys_page_block_title_redirect"><![CDATA[Confirm redirect]]></string>
     <string name="_sys_redirect_confirmation"><![CDATA[<div class="bx-def-font-h2 bx-def-margin-sec-bottom">You're going to a link outside {1}</div><div class="bx-def-font-small">Link: {0}</div><div class="bx-def-margin-sec-top">Are you sure you want to follow this link?</div>]]></string>

+ 16 - 0
modules/boonex/files/classes/BxFilesModule.php

@@ -254,6 +254,22 @@ class BxFilesModule extends BxBaseModFilesModule
         }
     }
 
+    /**
+     * The Studio Dashboard's Queues block: archive download jobs. Clearing removes the jobs older than an hour (finished or
+     * abandoned), the module's own cron prunes them after a day.
+     */
+    public function serviceGetDashboardQueues()
+    {
+        return array(
+            'downloads' => array(
+                'name' => '_bx_files_queue_downloads',
+                'icon' => 'download',
+                'all' => "SELECT COUNT(*) FROM `bx_files_downloading_jobs`",
+                'action' => "DELETE FROM `bx_files_downloading_jobs` WHERE UNIX_TIMESTAMP() - `started` > 3600"
+            )
+        );
+    }
+
     public function servicePruneDownloadingJobs()
     {
         $aFiles = $this->_oDb->deleteOldDownloadingJobs();

+ 1 - 0
modules/boonex/files/install/langs/en.xml

@@ -306,4 +306,5 @@
 
     <string name="_bx_files_option_allowed_ext"><![CDATA[Allow only the following extensions (comma separated list)]]></string>
     <string name="_bx_files_option_default_layout_mode"><![CDATA[Files default layout]]></string>
+    <string name="_bx_files_queue_downloads"><![CDATA[Download jobs]]></string>
 </resources>

+ 17 - 0
modules/boonex/notifications/classes/BxNtfsModule.php

@@ -110,6 +110,23 @@ class BxNtfsModule extends BxBaseModNotificationsModule
      * SERVICE METHODS
      */
 
+    /**
+     * The Studio Dashboard's Queues block: the delivery queue, which can be emptied.
+     */
+    public function serviceGetDashboardQueues()
+    {
+        $sTable = "`" . $this->_oDb->getPrefix() . "queue`";
+
+        return array(
+            'queue' => array(
+                'name' => '_bx_ntfs_queue',
+                'icon' => 'bell-ring',
+                'all' => "SELECT COUNT(*) FROM " . $sTable,
+                'action' => "DELETE FROM " . $sTable
+            )
+        );
+    }
+
     public function serviceGetSafeServices()
     {
         return [

+ 1 - 0
modules/boonex/notifications/install/langs/en.xml

@@ -226,4 +226,5 @@
     <string name="_bx_ntfs_grid_popup_title_ett_add"><![CDATA[Add Email Template]]></string>
     <string name="_bx_ntfs_grid_popup_title_ett_edit"><![CDATA[Edit Email Template]]></string>
     <string name="_bx_ntfs_grid_popup_title_ett_preview"><![CDATA[Preview Email Template]]></string>
+    <string name="_bx_ntfs_queue"><![CDATA[Notifications]]></string>
 </resources>

+ 19 - 0
modules/boonex/payment/classes/BxPaymentModule.php

@@ -188,6 +188,25 @@ class BxPaymentModule extends BxBaseModPaymentModule
         echoJson(array('code' => 0, 'content' => $sItems));
     }
 
+    /**
+     * The Studio Dashboard's Queues block: orders awaiting a provider's confirmation. Clearing drops only the stale ones
+     * (not authorized, not processed, older than a day) so a checkout in progress is left alone.
+     */
+    public function serviceGetDashboardQueues()
+    {
+        $sTable = "`" . $this->_oDb->getPrefix() . "transactions_pending`";
+
+        return array(
+            'pending' => array(
+                'name' => '_bx_payment_queue_pending',
+                'icon' => 'credit-card',
+                'all' => "SELECT COUNT(*) FROM " . $sTable . " WHERE `processed` = 0",
+                'failed' => "SELECT COUNT(*) FROM " . $sTable . " WHERE `processed` = 0 AND `error_code` <> ''",
+                'action' => "DELETE FROM " . $sTable . " WHERE `processed` = 0 AND `authorized` = 0 AND `date` < UNIX_TIMESTAMP() - 86400"
+            )
+        );
+    }
+
     public function serviceGetSafeServices()
     {
         return array_merge(parent::serviceGetSafeServices(), [

+ 1 - 0
modules/boonex/payment/install/langs/en.xml

@@ -712,4 +712,5 @@
     <string name="_bx_payment_aina_msg_subscribed"><![CDATA[Thank you! Subscription created. Please, check your email for further instructions.]]></string>
     <string name="_bx_payment_txt_name_apple_in_app"><![CDATA[Apple In-App]]></string>
     <string name="_bx_payment_txt_cart_apple_in_app"><![CDATA[Apple In-App]]></string>
+    <string name="_bx_payment_queue_pending"><![CDATA[Pending payments]]></string>
 </resources>

+ 161 - 50
modules/boonex/russian/data/langs/system/ru.xml

@@ -662,16 +662,64 @@
         <string name="_adm_btn_settings_mix_publish"><![CDATA[Публиковать]]></string>
 	<string name="_adm_btn_store_search"><![CDATA[Поиск]]></string>
 	<string name="_adm_dbd_btn_update"><![CDATA[Обновить]]></string>
-        <string name="_adm_dbd_txt_available"><![CDATA[Доступна:]]></string>
-	<string name="_adm_dbd_txt_installed"><![CDATA[Установлено:]]></string>
-        <string name="_adm_dbd_txt_updated"><![CDATA[Обновлено:]]></string>
+	<string name="_adm_dbd_txt_ver_installed"><![CDATA[Первая установка]]></string>
+	<string name="_adm_dbd_txt_ver_updated"><![CDATA[Последнее обновление]]></string>
+	<string name="_adm_dbd_txt_ver_applied"><![CDATA[Установлена {0}]]></string>
+	<string name="_adm_dbd_txt_ver_latest_stable"><![CDATA[Последняя стабильная]]></string>
+	<string name="_adm_dbd_txt_ver_latest_beta"><![CDATA[Последняя бета]]></string>
+	<string name="_adm_dbd_txt_ver_apps_count"><![CDATA[Приложения: {0}]]></string>
+	<string name="_adm_dbd_txt_ver_apps_updates"><![CDATA[Обновлений: {0}]]></string>
+	<string name="_adm_dbd_txt_ver_up_to_date"><![CDATA[Актуально]]></string>
+	<string name="_adm_dbd_txt_ver_check"><![CDATA[Проверить обновления]]></string>
+	<string name="_adm_dbd_txt_ver_checking"><![CDATA[Проверка...]]></string>
+	<string name="_adm_dbd_txt_ver_auto"><![CDATA[Автоматические обновления]]></string>
+	<string name="_adm_dbd_txt_ver_beta"><![CDATA[Бета-обновления]]></string>
+	<string name="_adm_dbd_txt_ver_force"><![CDATA[Принудительные автообновления]]></string>
 	<string name="_adm_dbd_msg_upgrade_started"><![CDATA[Сайт будет обновлён через минуту. <br />После этого нужно <a href="{0}store.php?page=updates">проверить обновления для модулей</a>.]]></string>
 	<string name="_adm_dbd_txt_su_database"><![CDATA[База данных]]></string>
 	<string name="_adm_dbd_txt_su_system"><![CDATA[Система]]></string>
 	<string name="_adm_dbd_txt_su_system_media"><![CDATA[Системные медиа]]></string>
-        <string name="_adm_dbd_txt_c_select"><![CDATA[Выбрать...]]></string>
+	<string name="_adm_dbd_txt_su_local"><![CDATA[Локальное хранилище]]></string>
+	<string name="_adm_dbd_txt_su_remote"><![CDATA[Удалённое хранилище]]></string>
+	<string name="_adm_dbd_txt_su_remote_off"><![CDATA[Не подключено]]></string>
+	<string name="_adm_dbd_txt_su_files"><![CDATA[Файлы]]></string>
+	<string name="_adm_dbd_txt_su_images"><![CDATA[Изображения]]></string>
+	<string name="_adm_dbd_txt_su_files_count"><![CDATA[{0} файлов]]></string>
+	<string name="_adm_dbd_txt_su_file_count_1"><![CDATA[1 файл]]></string>
+	<string name="_adm_dbd_txt_chart_used"><![CDATA[{0} занято]]></string>
+	<string name="_adm_dbd_txt_chart_total"><![CDATA[{0} всего]]></string>
+	<string name="_adm_dbd_txt_ht_tools"><![CDATA[Инструменты сервера]]></string>
+	<string name="_adm_dbd_txt_ht_status"><![CDATA[Статус]]></string>
+	<string name="_adm_dbd_txt_ht_audit"><![CDATA[Аудит]]></string>
+	<string name="_adm_dbd_txt_ht_permissions"><![CDATA[Права доступа]]></string>
+	<string name="_adm_dbd_txt_ht_cpu"><![CDATA[CPU]]></string>
+	<string name="_adm_dbd_txt_ht_memory"><![CDATA[Память]]></string>
+	<string name="_adm_dbd_txt_ht_disk"><![CDATA[Диск]]></string>
+	<string name="_adm_dbd_txt_ht_network"><![CDATA[Сеть]]></string>
+	<string name="_adm_dbd_txt_ht_kbps"><![CDATA[кбит/с]]></string>
+	<string name="_adm_dbd_txt_ht_mbps"><![CDATA[Мбит/с]]></string>
+	<string name="_adm_dbd_txt_ht_gbps"><![CDATA[Гбит/с]]></string>
+	<string name="_adm_dbd_txt_ht_na"><![CDATA[н/д]]></string>
+	<string name="_adm_dbd_txt_ht_high"><![CDATA[высокая]]></string>
+	<string name="_adm_dbd_txt_ht_system"><![CDATA[Система]]></string>
+	<string name="_adm_dbd_txt_ht_services"><![CDATA[Сервисы]]></string>
+	<string name="_adm_dbd_txt_ht_caching"><![CDATA[Кеширование]]></string>
+	<string name="_adm_dbd_txt_ht_security"><![CDATA[Безопасность]]></string>
+	<string name="_adm_dbd_txt_ht_last_audit"><![CDATA[Последний аудит]]></string>
+	<string name="_adm_dbd_txt_ht_run_audit"><![CDATA[Запустить новый аудит]]></string>
+	<string name="_adm_dbd_txt_ht_passed"><![CDATA[{0} пройдено]]></string>
+	<string name="_adm_dbd_txt_ht_warning"><![CDATA[1 предупреждение]]></string>
+	<string name="_adm_dbd_txt_ht_warnings"><![CDATA[{0} предупреждений]]></string>
+	<string name="_adm_dbd_txt_ht_failed"><![CDATA[{0} не пройдено]]></string>
+	<string name="_adm_dbd_txt_ht_unknown"><![CDATA[{0} не проверено]]></string>
+	<string name="_adm_dbd_txt_c_all"><![CDATA[Все кеши]]></string>
 	<string name="_adm_dbd_txt_c_clear_all"><![CDATA[Очистить весь кэш]]></string>
-	<string name="_adm_dbd_txt_q_clear"><![CDATA[Очистить очередь]]></string>
+	<string name="_adm_dbd_txt_q_all"><![CDATA[Все очереди]]></string>
+	<string name="_adm_dbd_txt_q_clear_all"><![CDATA[Очистить все очереди]]></string>
+	<string name="_adm_dbd_txt_q_clear_item"><![CDATA[Очистить очередь «{0}»]]></string>
+	<string name="_adm_dbd_txt_q_queued"><![CDATA[{0} в очереди]]></string>
+	<string name="_adm_dbd_txt_q_failed"><![CDATA[{0} с ошибкой]]></string>
+	<string name="_adm_dbd_msg_q_clean_success"><![CDATA[Очередь успешно очищена]]></string>
 	<string name="_adm_dbd_txt_c_content"><![CDATA[Содержимое страниц]]></string>
 	<string name="_adm_dbd_txt_c_clear_content"><![CDATA[Очистить кэш содержимого страниц]]></string>
 	<string name="_adm_dbd_txt_c_db"><![CDATA[БД]]></string>
@@ -683,11 +731,28 @@
         <string name="_adm_dbd_txt_c_less"><![CDATA[LESS]]></string>
         <string name="_adm_dbd_txt_c_clear_less"><![CDATA[Очистить кэш LESS]]></string>
         <string name="_adm_dbd_txt_c_clear_purifier"><![CDATA[Очистить кэш Purifier]]></string>
+        <string name="_adm_dbd_txt_c_purifier"><![CDATA[Purifier]]></string>
         <string name="_adm_dbd_txt_c_js"><![CDATA[JavaScript]]></string>
         <string name="_adm_dbd_txt_c_clear_js"><![CDATA[Очистить кэш JavaScript]]></string>
         <string name="_adm_dbd_txt_c_clear_custom"><![CDATA[Очистить специальный кэш]]></string>
+        <string name="_adm_dbd_txt_c_custom"><![CDATA[Специальный]]></string>
         <string name="_adm_dbd_txt_c_opcache"><![CDATA[Opcache]]></string>
         <string name="_adm_dbd_txt_c_clear_opcache"><![CDATA[Очистить Opcache]]></string>
+        <string name="_adm_dbd_txt_us_period"><![CDATA[Период]]></string>
+        <string name="_adm_dbd_txt_us_series"><![CDATA[Данные графика]]></string>
+        <string name="_adm_dbd_txt_us_7d"><![CDATA[7д]]></string>
+        <string name="_adm_dbd_txt_us_7d_label"><![CDATA[Последние 7 дней]]></string>
+        <string name="_adm_dbd_txt_us_30d"><![CDATA[30д]]></string>
+        <string name="_adm_dbd_txt_us_30d_label"><![CDATA[Последние 30 дней]]></string>
+        <string name="_adm_dbd_txt_us_1y"><![CDATA[1г]]></string>
+        <string name="_adm_dbd_txt_us_1y_label"><![CDATA[Последние 12 месяцев]]></string>
+        <string name="_adm_dbd_txt_us_all"><![CDATA[Всего]]></string>
+        <string name="_adm_dbd_txt_us_all_label"><![CDATA[За всё время]]></string>
+        <string name="_adm_dbd_txt_us_peak"><![CDATA[пик {0}]]></string>
+        <string name="_adm_dbd_txt_us_accounts"><![CDATA[Аккаунты]]></string>
+        <string name="_adm_dbd_txt_us_active"><![CDATA[Активные пользователи]]></string>
+        <string name="_adm_dbd_txt_us_online"><![CDATA[Сейчас онлайн]]></string>
+        <string name="_adm_dbd_txt_us_pending"><![CDATA[{0} в ожидании]]></string>
         <string name="_adm_dbd_err_c_clean_failed"><![CDATA[Очистка кэша не поддерживается]]></string>
         <string name="_adm_dbd_err_c_clean_unsupported"><![CDATA[Clean cache unsupported]]></string>
         <string name="_adm_dbd_msg_c_clean_success"><![CDATA[Кэш был удачно очищен]]></string>
@@ -1153,7 +1218,7 @@
 	<string name="_adm_lmi_cpt_displays"><![CDATA[Дисплеи]]></string>
 	<string name="_adm_lmi_cpt_downloaded"><![CDATA[Скачано]]></string>
 	<string name="_adm_lmi_cpt_etemplates_text"><![CDATA[Тексты email-ов]]></string>
-        <string name="_adm_lmi_cpt_etemplates_html"><![CDATA[Email-шаблон]]></string>
+        <string name="_adm_lmi_cpt_etemplates_html"><![CDATA[Email-шаблоны]]></string>
 	<string name="_adm_lmi_cpt_featured"><![CDATA[Продвинутые]]></string>
 	<string name="_adm_lmi_cpt_fields"><![CDATA[Поля]]></string>
 	<string name="_adm_lmi_cpt_files"><![CDATA[Файлы]]></string>
@@ -1198,7 +1263,7 @@
 	<string name="_adm_msg_modules_success_enable"><![CDATA[Пакет удачно подключён]]></string>
 	<string name="_adm_msg_modules_success_install"><![CDATA[Пакет удачно установлен]]></string>
 	<string name="_adm_msg_modules_success_uninstall"><![CDATA[Пакет удачно отменён]]></string>
-	<string name="_adm_msg_oauth_need_authorize"><![CDATA[Вам нужно зайти в свой <b>UNA</b> аккаунт, чтобы запросить данные. Нажмите <a href="{0}">здесь</a> для входа.]]></string>
+	<string name="_adm_msg_oauth_need_authorize"><![CDATA[Чтобы получить данные, нужно войти в свой аккаунт <b>UNA</b>: <a href="{0}">войти в UNA</a>.]]></string>
         <string name="_adm_msg_oauth_non_owner_logged"><![CDATA[Заметьте. Вы вошли в систему, используя UNA аккаунт, который не является владельцем пары Key&Secret.]]></string>
 	<string name="_adm_nav_btn_current_icon_image_delete"><![CDATA[Удалить]]></string>
 	<string name="_adm_nav_btn_items_add"><![CDATA[Добавить]]></string>
@@ -1357,6 +1422,23 @@
 	<string name="_adm_page_cpt_dashboard"><![CDATA[Панель]]></string>
 	<string name="_adm_page_cpt_designer"><![CDATA[Дизайнер]]></string>
 	<string name="_adm_page_cpt_home"><![CDATA[Студия]]></string>
+	<string name="_adm_page_dsc_default"><![CDATA[{0} в UNA Studio, панели администрирования {1}.]]></string>
+	<string name="_adm_page_dsc_home"><![CDATA[UNA Studio: все приложения администрирования {0} в одном месте.]]></string>
+	<string name="_adm_page_dsc_dashboard"><![CDATA[Состояние сайта {0}: обновления, хранилище, кеш, состояние сервера и результаты аудита.]]></string>
+	<string name="_adm_page_dsc_settings"><![CDATA[Общие настройки {0}: основные параметры, безопасность, почта, кеш и все установленные приложения.]]></string>
+	<string name="_adm_page_dsc_store"><![CDATA[Просмотр, установка, обновление и управление приложениями и шаблонами для {0}.]]></string>
+	<string name="_adm_page_dsc_polyglot"><![CDATA[Языки и переводы {0}: языковые пакеты, ключи и шаблоны писем.]]></string>
+	<string name="_adm_page_dsc_builder_pages"><![CDATA[Конструктор страниц: блоки и макеты всех страниц {0}.]]></string>
+	<string name="_adm_page_dsc_builder_menus"><![CDATA[Конструктор навигации: меню, наборы пунктов и их размещение на {0}.]]></string>
+	<string name="_adm_page_dsc_builder_forms"><![CDATA[Конструктор форм: поля, отображения и предопределённые списки всех форм {0}.]]></string>
+	<string name="_adm_page_dsc_builder_permissions"><![CDATA[Права доступа: что разрешено каждому уровню членства на {0}.]]></string>
+	<string name="_adm_page_dsc_builder_roles"><![CDATA[Роли Studio: каким операторам {0} доступны какие приложения администрирования.]]></string>
+	<string name="_adm_page_dsc_designer"><![CDATA[Дизайнер: шаблоны, стили, иконки, обложки и внешний вид {0}.]]></string>
+	<string name="_adm_page_dsc_agents"><![CDATA[ИИ-агенты: модели, автоматизации и ассистенты, настроенные для {0}.]]></string>
+	<string name="_adm_page_dsc_badges"><![CDATA[Значки: значки, которые участники могут получить на {0}.]]></string>
+	<string name="_adm_page_dsc_storages"><![CDATA[Хранилище: механизмы хранения файлов, использование и инструменты изменения размера изображений {0}.]]></string>
+	<string name="_adm_page_dsc_api"><![CDATA[API: ключи, клиенты и доступ к API {0}.]]></string>
+	<string name="_adm_page_dsc_audit"><![CDATA[Аудит: проверки сервера и конфигурации {0}.]]></string>
 	<string name="_adm_page_cpt_login"><![CDATA[Войти]]></string>
 	<string name="_adm_page_cpt_polyglot"><![CDATA[Полиглот]]></string>
 	<string name="_adm_page_cpt_settings"><![CDATA[Настройки]]></string>
@@ -1601,9 +1683,9 @@
     <string name="_adm_stg_cpt_option_site_title"><![CDATA[Имя сообщества]]></string>
     <string name="_adm_stg_inf_option_site_title"><![CDATA[Имя по умолчанию, используемое для ссылки на ваш сайт в текстах сайта, метатегах, электронных письмах и уведомлениях.]]></string>
     <string name="_adm_stg_cpt_option_site_tour_home"><![CDATA[Пользовательский тур]]></string>
-    <string name="_adm_stg_inf_option_site_tour_home"><![CDATA[<a href="/studio/polyglot.php?page=keys">Здесь</a> вы можете редактировать шаги и содержание пользовательского тура.]]></string>
+    <string name="_adm_stg_inf_option_site_tour_home"><![CDATA[Шаги и содержание пользовательского тура можно редактировать в <a href="/studio/polyglot.php?page=keys">языковых ключах Polyglot</a>.]]></string>
     <string name="_adm_stg_cpt_option_site_tour_studio"><![CDATA[Экскурсия по студии]]></string>
-    <string name="_adm_stg_inf_option_site_tour_studio"><![CDATA[<a href="/studio/polyglot.php?page=keys">Здесь</a> вы можете редактировать этапы и содержание тура по студии.]]></string>
+    <string name="_adm_stg_inf_option_site_tour_studio"><![CDATA[Шаги и содержание тура по Studio можно редактировать в <a href="/studio/polyglot.php?page=keys">языковых ключах Polyglot</a>.]]></string>
     <string name="_adm_stg_cpt_option_smart_app_banner"><![CDATA[Smart App Banner]]></string>
     <string name="_adm_stg_inf_option_smart_app_banner"><![CDATA[]]></string>
     <string name="_adm_stg_cpt_option_smart_app_banner_ios_app_id"><![CDATA[Идентификатор iOS прилижения для Smart App Banner]]></string>
@@ -1735,6 +1817,7 @@
     <string name="_adm_stg_cpt_option_sys_push_wonderpush_access_token"><![CDATA[WonderPush: Токен доступа]]></string>
     <string name="_adm_stg_cpt_option_sys_push_wonderpush_web_key"><![CDATA[WonderPush: Веб-ключ]]></string>
     <string name="_adm_stg_cpt_option_sys_push_queue_time"><![CDATA[Флаг выполнения Push очереди]]></string>
+    <string name="_adm_stg_cpt_option_sys_audit_report"><![CDATA[Отчёт последнего аудита сервера]]></string>
     <string name="_adm_stg_cpt_option_sys_push_queue_send_per_start"><![CDATA[Количество сообщенией, отправляемое из Push очереди при каждом запуске]]></string>
     <string name="_adm_stg_cpt_option_sys_push_queue_send_per_start_to_recipient"><![CDATA[Количество сообщенией, отправляемое из Push очереди одному и тому же получателю при каждом запуске]]></string>
     <string name="_adm_stg_cpt_option_sys_alerts_logger"><![CDATA[Журнал оповещений/хуков]]></string>
@@ -1962,6 +2045,7 @@
 	<string name="_adm_str_txt_pv_discount_off_csign"><![CDATA[{0}% ({1}{2}) скидка для Премиум]]></string>
 	<string name="_adm_str_txt_pv_price"><![CDATA[Цена]]></string>
 	<string name="_adm_str_txt_pv_rent"><![CDATA[Рента]]></string>
+	<string name="_adm_str_txt_pv_screenshot"><![CDATA[Скриншот {0}]]></string>
 	<string name="_adm_str_txt_pv_stats"><![CDATA[Статистика]]></string>
 	<string name="_adm_str_txt_pv_stats_votes"><![CDATA[{0} лайков]]></string>
 	<string name="_adm_str_txt_pv_stats_reviews"><![CDATA[{0} обзоров]]></string>
@@ -1992,6 +2076,7 @@
 	<string name="_adm_strg_txt_files_add_popup"><![CDATA[Добавить файл]]></string>
 	<string name="_adm_strg_txt_images_add_popup"><![CDATA[Добавить изображение]]></string>
 	<string name="_adm_strg_txt_images_resize_popup"><![CDATA[Изменить размер изображения]]></string>
+	<string name="_adm_strg_txt_images_resize_result"><![CDATA[Изображение с изменённым размером]]></string>
 	<string name="_adm_strg_txt_size_mime_type"><![CDATA[{0}x{1}, {2}]]></string>
 	<string name="_adm_strg_txt_width"><![CDATA[Ширина]]></string>
 	<string name="_adm_strg_err_width"><![CDATA[Нужно указать ширину. Пожалуйста, заполните это поле.]]></string>
@@ -2002,15 +2087,25 @@
 	<string name="_adm_tmi_cpt_site"><![CDATA[Перейти на сайт]]></string>
         <string name="_adm_tmi_cpt_search"><![CDATA[Искать...]]></string>
         <string name="_adm_tmi_cpt_search_apps"><![CDATA[Приложения...]]></string>
+        <string name="_adm_tmi_lbl_search_apps"><![CDATA[Поиск приложений]]></string>
+        <string name="_adm_tmi_lbl_search_clear"><![CDATA[Очистить поиск]]></string>
         <string name="_adm_tmi_cpt_scheme"><![CDATA[Цветовая схема]]></string>
-	<string name="_adm_tmi_cpt_tour"><![CDATA[Инструкции]]></string>
 	<string name="_adm_tmi_cpt_launcher"><![CDATA[Загрузчик]]></string>
         <string name="_adm_txt_active_api"><![CDATA[Доступно в приложении]]></string>
         <string name="_adm_txt_assistant_popup_title"><![CDATA[Как я могу помочь?]]></string>
 	<string name="_adm_txt_confirm_cancel"><![CDATA[Отменить]]></string>
 	<string name="_adm_txt_confirm_ok"><![CDATA[OK]]></string>
+	<string name="_adm_txt_launcher_apps"><![CDATA[Приложения]]></string>
+	<string name="_adm_txt_launcher_search_empty"><![CDATA[Ничего не найдено]]></string>
+	<string name="_adm_txt_launcher_search_matches"><![CDATA[Найдено приложений: {0}]]></string>
+	<string name="_adm_txt_launcher_search_matches_one"><![CDATA[Найдено 1 приложение]]></string>
+	<string name="_adm_txt_widget_action_label"><![CDATA[{0}: {1}]]></string>
+	<string name="_adm_txt_widget_move_left"><![CDATA[Переместить {0} влево]]></string>
+	<string name="_adm_txt_widget_move_right"><![CDATA[Переместить {0} вправо]]></string>
+	<string name="_adm_txt_widget_moved"><![CDATA[{0}: позиция {1} из {2}]]></string>
 	<string name="_adm_txt_forgot_password"><![CDATA[Забыли пароль?]]></string>
 	<string name="_adm_txt_form_view_missing"><![CDATA[отсутствует]]></string>
+	<string name="_adm_txt_icon_image_current"><![CDATA[Текущее изображение иконки]]></string>
 	<string name="_adm_txt_login_password"><![CDATA[Пароль]]></string>
         <string name="_adm_txt_login_password_placeholder"><![CDATA[ваш пароль]]></string>
 	<string name="_adm_txt_login_email"><![CDATA[Эл. адрес]]></string>
@@ -2596,53 +2691,60 @@
   <string name="_sys_audit_filter_profile_select"><![CDATA[Выберите профиль]]></string>
   <string name="_sys_audit_filter_action_select"><![CDATA[Выберите действия]]></string>
 	<string name="_sys_audit_cron_jobs"><![CDATA[Планировщик]]></string>
-	<string name="_sys_audit_cron_jobs_exec_time"><![CDATA[Последний запуск планировщика]]></string>
-	<string name="_sys_audit_header_manual_audit"><![CDATA[Ручной аудит сервера]]></string>
+	<string name="_sys_audit_header_manual_audit"><![CDATA[Ручной аудит]]></string>
 	<string name="_sys_audit_header_requirements"><![CDATA[Требования]]></string>
-	<string name="_sys_audit_header_site_optimization"><![CDATA[Оптимизация сайта]]></string>
+	<string name="_sys_audit_header_site_optimization"><![CDATA[Оптимизация]]></string>
 	<string name="_sys_audit_header_site_setup"><![CDATA[Настройка сайта]]></string>
-	<string name="_sys_audit_mail_sending"><![CDATA[Посылка почты]]></string>
-	<string name="_sys_audit_msg_apache_module_fail"><![CDATA[Вам нужно установить {0} для Apache]]></string>
-	<string name="_sys_audit_msg_cron_jobs"><![CDATA[<pre class="code">{0}</pre> если Вы не уверены, корректен ли вывод, может понадобиться <a href="#manual_audit">ручной аудит сервера</a>]]></string>
-	<string name="_sys_audit_msg_optimization_fail"><![CDATA[пожалуйста, задействуйте этот кэш в Студии &gt; Настройки &gt; Кэш]]></string>
-	<string name="_sys_audit_msg_optimization_warn"><![CDATA[установка PHP-акселератора ускорит кэширование файлов]]></string>
-	<string name="_sys_audit_msg_x_based_cache_engine"><![CDATA[ ({0} кэш-движок)]]></string>
-	<string name="_sys_audit_msg_ffmpeg"><![CDATA[<pre class="code">{0}</pre>если Вы не уверены, корректен ли вывод, может понадобиться <a href="#manual_audit">ручной аудит сервера</a>.]]></string>
-	<string name="_sys_audit_msg_hardware_requirements"><![CDATA[Оборудование нельзя проверить автоматически. Может понадобиться <a href="#manual_audit">ручной аудит сервера</a>.]]></string>
-	<string name="_sys_audit_msg_mail_send_failed"><![CDATA[не удалось отослать письмо]]></string>
-	<string name="_sys_audit_msg_mail_sending"><![CDATA[<span id="bx-sys-adm-audit-test-email"><a href="javascript:void(0);" onclick="bx_sys_adm_audit_test_email()">нажмите здесь</a>, чтобы отослать тестовое сообщение</span>]]></string>
-	<string name="_sys_audit_msg_mail_sent"><![CDATA[тестовое сообщение отослано, пожалуйста, проверьте {0} эл. ящик]]></string>
-	<string name="_sys_audit_msg_manual_audit"><![CDATA[<p>
-Некоторые проверки нужно сделать вручную. Если нужна помощь, <a target="_blank" href="mailto:team@unacms.com?subject=Ручной аудит сервера">отправьте запрос на аудит сервера</a>.
-</p>]]></string>
+	<string name="_sys_audit_mail_sending"><![CDATA[Доставка почты]]></string>
+	<string name="_sys_audit_msg_apache_module_fail"><![CDATA[Установите {0} для Apache.]]></string>
+	<string name="_sys_audit_msg_cron_jobs"><![CDATA[Не запускался последний час: проверьте запись в crontab.]]></string>
+	<string name="_sys_audit_msg_optimization_fail"><![CDATA[Включите в Настройки &gt; Кэш.]]></string>
+	<string name="_sys_audit_msg_optimization_warn"><![CDATA[PHP-акселератор ускорит файловый кэш.]]></string>
+	<string name="_sys_audit_msg_x_based_cache_engine"><![CDATA[ ({0})]]></string>
+	<string name="_sys_audit_msg_ffmpeg"><![CDATA[Не найден по пути {0}: конвертация видео и аудио не будет работать.]]></string>
+	<string name="_sys_audit_msg_hardware_requirements"><![CDATA[Автоматически не проверяется: см. ручной аудит.]]></string>
+	<string name="_sys_audit_msg_mail_send_failed"><![CDATA[Отправка не удалась.]]></string>
+	<string name="_sys_audit_msg_mail_sending"><![CDATA[<span id="bx-sys-adm-audit-test-email"><a href="javascript:void(0);" onclick="bx_sys_adm_audit_test_email()">Отправить тестовое письмо</a></span>]]></string>
+	<string name="_sys_audit_msg_mail_sent"><![CDATA[Тестовое письмо отправлено на {0}.]]></string>
+	<string name="_sys_audit_msg_manual_audit"><![CDATA[Часть проверок делается вручную. <a target="_blank" href="mailto:team@unacms.com?subject=Server Audit Request">Запросить аудит сервера</a>.]]></string>
 	<string name="_sys_audit_msg_must_be"><![CDATA[нужно {0} {1}]]></string>
-	<string name="_sys_audit_msg_optimization_apache_module"><![CDATA[Чтобы применить эту оптимизацию, Вам нужно иметь <b>{0}</b> - {1}]]></string>
-	<string name="_sys_audit_msg_permissions"><![CDATA[пожалуйста <a href="javascript:void(0);" onclick="oBxDolStudioDashboard.permissions()">нажмите здесь</a>, чтобы выяснить, корректны ли разрешения.]]></string>
-	<string name="_sys_audit_msg_php_accelerator_missing"><![CDATA[Можно значительно ускорить работу скрипта, если установить PHP-акселератор]]></string>
-	<string name="_sys_audit_msg_php_setup_inefficient"><![CDATA[Ваши PHP-настройки могут быть очень неэффективными, <a href="javascript:void(0)" onclick="bx_sys_adm_audit_phpinfo()">проверьте их</a> и попробуйте переключиться на mod_php, apache dso module или FastCGI]]></string>
-	<string name="_sys_audit_msg_serverside_compression"><![CDATA[
-может быть проверено <a href="#manual_audit">вручную</a> или с помощью инструмента PageSpeed, встроенного в браузер.<br />
-Если он не задействован, тогда попробуйте применить эту оптимизацию, так как она улучшает скорость сайта и экономит трафик.<br />{0}]]></string>
+	<string name="_sys_audit_msg_permissions"><![CDATA[Подробности на вкладке <a href="javascript:void(0);" onclick="oBxDolStudioDashboard.permissions()">Права доступа</a>.]]></string>
+	<string name="_sys_audit_msg_php_accelerator_missing"><![CDATA[Установите PHP-акселератор, например OPcache, для заметного ускорения.]]></string>
+	<string name="_sys_audit_msg_php_setup_inefficient"><![CDATA[CGI работает медленно: перейдите на mod_php, DSO или FastCGI. <a href="javascript:void(0)" onclick="bx_sys_adm_audit_phpinfo()">Открыть phpinfo</a>.]]></string>
 	<string name="_sys_audit_msg_should_be"><![CDATA[хорошо бы {0} {1}]]></string>
-	<string name="_sys_audit_msg_userside_caching"><![CDATA[
-<a href="{0}">нажмите здесь для проверки в Google PageSpeed</a><br />
-Если он не задействован, тогда попробуйте применить эту оптимизацию, так как она улучшает скорость сайта и экономит трафик.<br />{1}]]></string>
-	<string name="_sys_audit_msg_value_checking_failed"><![CDATA[проверка значения не удалась]]></string>
-	<string name="_sys_audit_msg_version_is_incompatible"><![CDATA[Ваша версия несовместима с UNA, нужно как минимум {0}]]></string>
-	<string name="_sys_audit_msg_version_is_outdated"><![CDATA[Ваша версия устарела, может потребоваться обновление до последней версии {0}]]></string>
-	<string name="_sys_audit_permissions"><![CDATA[Разрешения файлов и папок]]></string>
+	<string name="_sys_audit_msg_userside_caching"><![CDATA[<a href="{0}" target="_blank">Проверить в PageSpeed</a>]]></string>
+	<string name="_sys_audit_msg_value_checking_failed"><![CDATA[Не удалось проверить.]]></string>
+	<string name="_sys_audit_msg_version_is_incompatible"><![CDATA[Не поддерживается: UNA требует {0} или новее.]]></string>
+	<string name="_sys_audit_msg_version_is_outdated"><![CDATA[Устарела: последняя версия {0}.]]></string>
+	<string name="_sys_audit_permissions"><![CDATA[Права на файлы]]></string>
+	<string name="_sys_audit_https"><![CDATA[HTTPS]]></string>
+	<string name="_sys_audit_msg_https"><![CDATA[Сайт работает по HTTP: переведите его на HTTPS.]]></string>
+	<string name="_sys_audit_install_dir"><![CDATA[Каталог install]]></string>
+	<string name="_sys_audit_msg_install_dir"><![CDATA[Всё ещё на сервере: удалите каталог install.]]></string>
+	<string name="_sys_audit_display_errors"><![CDATA[Вывод ошибок]]></string>
+	<string name="_sys_audit_msg_display_errors"><![CDATA[PHP показывает ошибки посетителям: установите display_errors = Off.]]></string>
+	<string name="_sys_audit_expose_php"><![CDATA[Заголовок с версией PHP]]></string>
+	<string name="_sys_audit_msg_expose_php"><![CDATA[Версия PHP отправляется в заголовках ответа: установите expose_php = Off.]]></string>
+	<string name="_sys_audit_config_file"><![CDATA[Файл конфигурации]]></string>
+	<string name="_sys_audit_msg_config_file"><![CDATA[inc/header.inc.php доступен для записи другим пользователям: установите права 0644.]]></string>
+	<string name="_sys_audit_present"><![CDATA[Есть]]></string>
+	<string name="_sys_audit_removed"><![CDATA[Удалён]]></string>
+	<string name="_sys_audit_on"><![CDATA[Вкл]]></string>
+	<string name="_sys_audit_off"><![CDATA[Выкл]]></string>
 	<string name="_sys_audit_php_accelerator"><![CDATA[PHP-акселератор]]></string>
-	<string name="_sys_audit_php_setup"><![CDATA[PHP-настройка]]></string>
+	<string name="_sys_audit_php_setup"><![CDATA[Обработчик PHP]]></string>
 	<string name="_sys_audit_section_hardware"><![CDATA[Железо]]></string>
+	<string name="_sys_audit_section_cache_engines"><![CDATA[Движки кэша]]></string>
 	<string name="_sys_audit_section_os"><![CDATA[ОС]]></string>
-	<string name="_sys_audit_section_webserver"><![CDATA[Web-сервер]]></string>
-	<string name="_sys_audit_serverside_compression"><![CDATA[Сжатие содержимого на стороне сервера]]></string>
+	<string name="_sys_audit_section_php_extensions"><![CDATA[Расширения PHP]]></string>
+	<string name="_sys_audit_section_webserver"><![CDATA[Веб-сервер]]></string>
+	<string name="_sys_audit_serverside_compression"><![CDATA[Сжатие ответов]]></string>
 	<string name="_sys_audit_title_fail"><![CDATA[Ошибка]]></string>
 	<string name="_sys_audit_title_ok"><![CDATA[Пройдено]]></string>
 	<string name="_sys_audit_title_undef"><![CDATA[Неизвестно]]></string>
 	<string name="_sys_audit_title_warn"><![CDATA[Предупреждение]]></string>
-	<string name="_sys_audit_userside_caching"><![CDATA[Кэширование статичного содержимого на стороне пользователя]]></string>
-	<string name="_sys_audit_version"><![CDATA[версия]]></string>
+	<string name="_sys_audit_userside_caching"><![CDATA[Кэширование в браузере]]></string>
+	<string name="_sys_audit_version"><![CDATA[Версия]]></string>
 	<string name="_sys_audit_version_script"><![CDATA[Версия UNA]]></string>
 
 	<string name="_sys_agents_agents_popup_edit"><![CDATA[Редактирование агента "{0}"]]></string>
@@ -3254,6 +3356,13 @@
 	<string name="_sys_format_size_kb"><![CDATA[{0} КБ]]></string>
 	<string name="_sys_format_size_mb"><![CDATA[{0} МБ]]></string>
 	<string name="_sys_format_size_tb"><![CDATA[{0} ТБ]]></string>
+	<string name="_sys_grid_lbl_actions"><![CDATA[Действия]]></string>
+	<string name="_sys_grid_lbl_order"><![CDATA[Порядок]]></string>
+	<string name="_sys_grid_lbl_select"><![CDATA[Выбор]]></string>
+	<string name="_sys_grid_lbl_select_all"><![CDATA[Выбрать все]]></string>
+	<string name="_sys_str_limit_show"><![CDATA[Показать полностью]]></string>
+	<string name="_adm_grid_lbl_search"><![CDATA[Поиск]]></string>
+	<string name="_adm_grid_lbl_module"><![CDATA[Модуль]]></string>
 	<string name="_sys_grid_confirmation"><![CDATA[Вы уверены?]]></string>
 	<string name="_sys_grid_delete_failed"><![CDATA[Удалить ошибочные]]></string>
 	<string name="_sys_grid_search"><![CDATA[Поиск...]]></string>
@@ -3748,6 +3857,8 @@
     <string name="_sys_menu_item_title_sa_site"><![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>
+    <string name="_sys_menu_item_title_sa_tour"><![CDATA[Вводный тур]]></string>
     <string name="_sys_menu_item_title_system_sa_scheme"><![CDATA[Тема]]></string>
     <string name="_sys_menu_item_title_sa_scheme"><![CDATA[Тема]]></string>
     <string name="_sys_menu_item_title_system_sa_language"><![CDATA[Язык]]></string>
@@ -3876,9 +3987,9 @@
 	<string name="_sys_page_block_title_profile_membership"><![CDATA[Членство]]></string>
 	<string name="_sys_page_block_title_unsubscribe_news"><![CDATA[Отписаться от новостей сайта]]></string>
 	<string name="_sys_page_block_title_unsubscribe_notifications"><![CDATA[Отписаться от уведомлений сайта]]></string>
-	<string name="_sys_page_block_title_std_dash_version"><![CDATA[Версия]]></string>
-	<string name="_sys_page_block_title_std_dash_space"><![CDATA[Использование дискового пространства]]></string>
-	<string name="_sys_page_block_title_std_dash_host_tools"><![CDATA[Инструменты хоста]]></string>
+	<string name="_sys_page_block_title_std_dash_version"><![CDATA[Обновления]]></string>
+	<string name="_sys_page_block_title_std_dash_space"><![CDATA[Место]]></string>
+	<string name="_sys_page_block_title_std_dash_host_tools"><![CDATA[Сервер]]></string>
 	<string name="_sys_page_block_title_std_dash_cache"><![CDATA[Кэш]]></string>
         <string name="_sys_page_block_title_std_dash_queues"><![CDATA[Очереди]]></string>
 	<string name="_sys_page_block_title_terms"><![CDATA[Правила пользования]]></string>
@@ -4479,7 +4590,7 @@
   <string name="_sys_queue_emails"><![CDATA[Emails]]></string>
   <string name="_sys_queue_push"><![CDATA[PUSH]]></string>
   <string name="_sys_queue_transcoding"><![CDATA[Транскодирование видео]]></string>
-  <string name="_sys_queue_storage"><![CDATA[Хранилище файлов]]></string>
+  <string name="_sys_queue_storage"><![CDATA[Удаление файлов]]></string>
   <string name="_sys_page_title_redirect"><![CDATA[Подтверждение перехода на другой сайт]]></string>
   <string name="_sys_page_block_title_redirect"><![CDATA[Подтвердите переход на другой сайт]]></string>
   <string name="_sys_redirect_confirmation"><![CDATA[<div class="bx-def-font-h2 bx-def-margin-sec-bottom">Вы переходите по ссылке за пределы сайта "{1}"</div><div class="bx-def-font-small">Ссылка: {0}</div><div class="bx-def-margin-sec-top">Вы действительно хотите перейти по этой ссылке?</div>]]></string>

+ 1 - 0
studio/builders.php

@@ -30,6 +30,7 @@ if($sPageCode === false)
 
 $oTemplate->setPageNameIndex($oPage->getPageIndex());
 $oTemplate->setPageHeader($oPage->getPageHeader());
+$oTemplate->setPageDescription($oPage->getPageDescription());
 $oTemplate->setPageContent('page_main_code', $sPageCode);
 $oTemplate->getPageCode();
 /** @} */

+ 377 - 14
studio/classes/BxDolStudioDashboard.php

@@ -13,6 +13,9 @@ class BxDolStudioDashboard extends BxTemplStudioWidget
     protected $aItemsCache;
     protected $aItemsHTools;
 
+    protected $aAuditReport = null; // the report getAuditReport() settled on in this request
+    protected $aItemsQueues;
+
     function __construct()
     {
         parent::__construct('dashboard');
@@ -24,6 +27,36 @@ class BxDolStudioDashboard extends BxTemplStudioWidget
         	'htools' => 'serviceGetBlockHostTools',
         );
 
+        // The queues the Queues block lists: the count queries and, where a queue can be emptied, its clear query.
+        $this->aItemsQueues = array(
+            'emails' => array(
+                'name' => '_sys_queue_emails',
+                'icon' => 'mail',
+                'all' => "SELECT COUNT(*) FROM `sys_queue_email`",
+                'action' => "DELETE FROM `sys_queue_email`"
+            ),
+            'push' => array(
+                'name' => '_sys_queue_push',
+                'icon' => 'bell',
+                'all' => "SELECT COUNT(*) FROM `sys_queue_push`",
+                'action' => "DELETE FROM `sys_queue_push`"
+            ),
+            'transcoding' => array(
+                'name' => '_sys_queue_transcoding',
+                'icon' => 'film',
+                'all' => "SELECT COUNT(*) FROM `sys_transcoder_queue`",
+                'failed' => "SELECT COUNT(*) FROM `sys_transcoder_queue` WHERE `status` = 'failed'",
+                'action' => "DELETE FROM `sys_transcoder_queue` WHERE `status` != 'processing'"
+            ),
+        );
+        $this->aItemsQueues = array_merge($this->aItemsQueues, $this->getModulesQueues());
+        // last: the files waiting to be removed from storage, a queue that is watched but never emptied by hand
+        $this->aItemsQueues['storage'] = array(
+            'name' => '_sys_queue_storage',
+            'icon' => 'hard-drive',
+            'all' => "SELECT COUNT(*) FROM `sys_storage_deletions`"
+        );
+
         $this->aItemsCache = array (
             array('name' => 'all'),
             array('name' => 'content'),
@@ -48,6 +81,11 @@ class BxDolStudioDashboard extends BxTemplStudioWidget
             $sAction = bx_process_input($sAction);
 
             $aResult = array('code' => 1, 'message' => _t('_adm_err_cannot_process_action'));
+
+            // actions that change something are only taken from a POST (dashboard.js posts them)
+            if(in_array($sAction, array('set_option', 'perform_upgrade', 'clear_cache', 'clear_queue', 'run_audit')) && $_SERVER['REQUEST_METHOD'] != 'POST')
+                $sAction = '';
+
             switch($sAction) {
             	case 'get_block':
                     $sValue = bx_get('dbd_value');
@@ -68,6 +106,29 @@ class BxDolStudioDashboard extends BxTemplStudioWidget
                     $aResult = array('code' => 0, 'data' => $this->getPageCodeVersionAvailable());
                     break;
 
+                case 'set_option':
+                    // The Version block's switches: only the update options, each with its 'on' and 'off' value.
+                    $aOptions = array(
+                        'sys_autoupdate' => array('on', ''),
+                        'sys_autoupdate_force_modified_files' => array('on', ''),
+                        'sys_upgrade_channel' => array('beta', 'stable')
+                    );
+
+                    $sName = bx_get('dbd_value');
+                    if($sName === false)
+                        break;
+
+                    $sName = bx_process_input($sName);
+                    if(!isset($aOptions[$sName]))
+                        break;
+
+                    $oDb = BxDolDb::getInstance();
+                    $oDb->setParam($sName, $aOptions[$sName][(int)bx_get('dbd_state') == 1 ? 0 : 1]);
+                    $oDb->cacheParamsClear();
+
+                    $aResult = array('code' => 0, 'message' => '');
+                    break;
+
             	case 'perform_upgrade':
                     $oUpgrader = bx_instance('BxDolUpgrader');
                     if(!$oUpgrader->prepare(false)){
@@ -130,24 +191,56 @@ class BxDolStudioDashboard extends BxTemplStudioWidget
                     }
                     break;
 
-                case 'permissions':                    
-                    $oAdmTools = new BxDolStudioTools();
+                case 'clear_queue':
+                    $sValue = bx_get('dbd_value');
+                    if($sValue === false)
+                        break;
 
-                    header( 'Content-type: text/html; charset=utf-8' );
-                    echo $oAdmTools->generateStyles();
-                    $oAdmTools->checkPermissions();
-                    exit;
+                    $sValue = bx_process_input($sValue);
 
-                case 'server_audit':
-                    $oAudit = new BxDolStudioToolsAudit();
+                    $aActions = [];
+                    if($sValue == 'all') {
+                        foreach($this->aItemsQueues as $aQueue)
+                            if(!empty($aQueue['action']))
+                                $aActions[] = $aQueue['action'];
+                    }
+                    else if(!empty($this->aItemsQueues[$sValue]['action']))
+                        $aActions[] = $this->aItemsQueues[$sValue]['action'];
+
+                    if(empty($aActions))
+                        break;
+
+                    foreach($aActions as $sQuery)
+                        BxDolDb::getInstance()->query($sQuery);
 
+                    $aResult = array('code' => 0, 'message' => _t('_adm_dbd_msg_q_clean_success'), 'data' => $this->getQueuesChartData(false));
+                    break;
+
+                case 'run_audit':
+                    // a fresh audit, then the Server block redrawn from it (dashboard.js reopens the Audit tab)
+                    $this->getAuditReport(true);
+
+                    $aBlock = $this->serviceGetBlockHostTools(true);
+                    $aResult = array('code' => 0, 'data' => $aBlock['content']);
+                    break;
+
+                case 'server_audit':
                     header( 'Content-type: text/html; charset=utf-8' );
-                    echo $oAudit->generate();
+                    echo $this->getBlockHostToolsAudit();
                     exit;
             }
 
             if(!empty($aResult['message'])) {
-                $aResult['message'] = BxDolStudioTemplate::getInstance()->parseHtmlByName('page_action_result.html', array('content' => $aResult['message']));
+                // the outcome dialog (dashboard.js popup): the message beside an icon tile coloured by the outcome, with a close button
+                $sType = !empty($aResult['type']) ? $aResult['type'] : ((int)$aResult['code'] == 0 ? 'success' : 'error');
+                $aIcons = array('success' => 'circle-check', 'error' => 'circle-x', 'info' => 'info');
+
+                $aResult['message'] = BxDolStudioTemplate::getInstance()->parseHtmlByName('page_action_result.html', array(
+                    'type' => $sType,
+                    'icon' => $this->getChartIcon(isset($aIcons[$sType]) ? $aIcons[$sType] : 'info')['svg'],
+                    'icon_close' => $this->getChartIcon('x')['svg'],
+                    'content' => $aResult['message']
+                ));
                 $aResult['message'] = BxTemplStudioFunctions::getInstance()->transBox('', $aResult['message']);
             }
 
@@ -156,6 +249,116 @@ class BxDolStudioDashboard extends BxTemplStudioWidget
         }
     }
 
+    /**
+     * CPU, memory and disk usage in percent for the Server block's gauges, read from what PHP and the kernel expose
+     * (load average per core, /proc/meminfo, disk_*_space); a metric that isn't readable on this host is null.
+     */
+    /**
+     * The last server audit (BxDolStudioToolsAudit::getReport(): {time, sections}), kept in the sys_audit_report option; run
+     * afresh when asked, when there is none yet, or when the stored one is older than a day.
+     */
+    public function getAuditReport($bFresh = false)
+    {
+        if(!$bFresh && $this->aAuditReport !== null)
+            return $this->aAuditReport;
+
+        $aReport = $bFresh ? false : json_decode((string)getParam('sys_audit_report'), true);
+        if(empty($aReport['sections']) || time() - (int)$aReport['time'] >= 86400) {
+            $oAudit = new BxDolStudioToolsAudit();
+            $aReport = $oAudit->getReport();
+
+            // reloading the options (rather than only dropping their cache) keeps getParam() working for the rest of this request
+            $oDb = BxDolDb::getInstance();
+            $oDb->setParam('sys_audit_report', json_encode($aReport));
+            $oDb->cacheParams(true);
+        }
+
+        return $this->aAuditReport = $aReport;
+    }
+
+    /**
+     * The Server block's gauges: each is null when the host does not expose it, or ['percent' => the arc's share, 'value' =>
+     * the figure shown in the ring, 'unit' => a unit shown small under it, '' when the figure carries its own].
+     */
+    protected function getServerMetrics()
+    {
+        $aMetrics = ['cpu' => null, 'memory' => null, 'disk' => null, 'network' => null];
+        $fnPercent = function($iPercent) {
+            return ['percent' => $iPercent, 'value' => $iPercent . '%', 'unit' => ''];
+        };
+
+        if(function_exists('sys_getloadavg') && ($aLoad = @sys_getloadavg()) !== false && isset($aLoad[0])) {
+            $iCores = 0;
+            if(is_readable('/proc/cpuinfo') && ($sCpuInfo = @file_get_contents('/proc/cpuinfo')) !== false)
+                $iCores = preg_match_all('/^processor\s*:/m', $sCpuInfo);
+            if($iCores < 1)
+                $iCores = 1;
+
+            $aMetrics['cpu'] = $fnPercent((int)min(100, round($aLoad[0] / $iCores * 100)));
+        }
+
+        if(is_readable('/proc/meminfo') && ($sMemInfo = @file_get_contents('/proc/meminfo')) !== false && preg_match('/^MemTotal:\s*(\d+)/m', $sMemInfo, $aTotal) && preg_match('/^MemAvailable:\s*(\d+)/m', $sMemInfo, $aAvailable) && (int)$aTotal[1] > 0)
+            $aMetrics['memory'] = $fnPercent((int)round(((int)$aTotal[1] - (int)$aAvailable[1]) / (int)$aTotal[1] * 100));
+
+        $fDiskTotal = @disk_total_space(BX_DIRECTORY_PATH_ROOT);
+        $fDiskFree = @disk_free_space(BX_DIRECTORY_PATH_ROOT);
+        if($fDiskTotal !== false && $fDiskFree !== false && $fDiskTotal > 0)
+            $aMetrics['disk'] = $fnPercent((int)round(($fDiskTotal - $fDiskFree) / $fDiskTotal * 100));
+
+        // Network: the throughput over a 250 ms sample of the interface counters (this runs in the block's own ajax request, so the
+        // pause is not felt), shown as a bit rate; the arc is its share of the link speed, 1 Gbit/s when the driver reports none.
+        if(($aStart = $this->getNetworkCounters()) !== false) {
+            $iStarted = hrtime(true);
+            usleep(250000);
+            $aEnd = $this->getNetworkCounters();
+            $fSeconds = (hrtime(true) - $iStarted) / 1e9;
+
+            if($aEnd !== false && $fSeconds > 0) {
+                $fBits = max(0, $aEnd[0] - $aStart[0]) * 8 / $fSeconds;
+                $fLink = ($aEnd[1] > 0 ? $aEnd[1] : 1000) * 1e6;
+
+                $aMetrics['network'] = ['percent' => (int)min(100, round($fBits / $fLink * 100))] + $this->formatBitRate($fBits);
+            }
+        }
+
+        return $aMetrics;
+    }
+
+    /**
+     * @return array|false [bytes received plus sent over every interface but the loopback, the fastest of their link speeds in Mbit/s (0 when none reports one)]
+     */
+    protected function getNetworkCounters()
+    {
+        if(!is_readable('/proc/net/dev') || ($sDev = @file_get_contents('/proc/net/dev')) === false)
+            return false;
+
+        $iBytes = $iSpeed = 0;
+        foreach(explode("\n", $sDev) as $sLine) {
+            // "eth0: <rx bytes> <7 more receive counters> <tx bytes> ..."
+            if(!preg_match('/^\s*([^:\s]+):\s*(\d+)(?:\s+\d+){7}\s+(\d+)/', $sLine, $aMatch) || $aMatch[1] == 'lo')
+                continue;
+
+            $iBytes += (int)$aMatch[2] + (int)$aMatch[3];
+            $iSpeed = max($iSpeed, (int)@file_get_contents('/sys/class/net/' . $aMatch[1] . '/speed'));
+        }
+
+        return [$iBytes, $iSpeed];
+    }
+
+    /**
+     * @return array ['value' => the figure, one decimal under 10, 'unit' => kbps, Mbps or Gbps]
+     */
+    protected function formatBitRate($fBits)
+    {
+        foreach(['gbps' => 1e9, 'mbps' => 1e6, 'kbps' => 1e3] as $sUnit => $fDivider) {
+            if($fBits < $fDivider && $sUnit != 'kbps')
+                continue;
+
+            $fValue = $fBits / $fDivider;
+            return ['value' => $fValue < 10 ? number_format($fValue, 1) : (string)round($fValue), 'unit' => _t('_adm_dbd_txt_ht_' . $sUnit)];
+        }
+    }
+
     protected function getDbSize()
     {
         $iTotalSize = 0;
@@ -189,7 +392,10 @@ class BxDolStudioDashboard extends BxTemplStudioWidget
     {
         $oCacheUtilities = BxDolCacheUtilities::getInstance();
 
-    	$aChartData = array();
+        $aIcons = array('content' => 'file-text', 'db' => 'database', 'template' => 'layout-template', 'less' => 'braces', 'css' => 'palette', 'js' => 'code', 'purifier' => 'shield-check', 'opcache' => 'zap', 'custom' => 'box');
+
+    	$aItems = array();
+        $iTotal = 0;
     	foreach($this->aItemsCache as $aItem) {
             if($aItem['name'] == 'all')
                 continue;
@@ -198,13 +404,170 @@ class BxDolStudioDashboard extends BxTemplStudioWidget
             if($iSize === false)
                 continue;
 
-            $aChartData[] = array(bx_js_string(_t('_adm_dbd_txt_c_' . $aItem['name']), BX_ESCAPE_STR_APOS), array('v' => $iSize, 'f' => bx_js_string(_t_format_size($iSize))));
+            $iTotal += $iSize;
+            $aItems[] = array(
+                'name' => bx_process_output(_t('_adm_dbd_txt_c_' . $aItem['name'])),
+                'bytes' => $iSize,
+                'size' => _t_format_size($iSize),
+                'icon' => $this->getChartIcon(isset($aIcons[$aItem['name']]) ? $aIcons[$aItem['name']] : 'box'),
+                // the row's own clear button (dashboard.js showChart) when this cache can be cleared
+                'type' => $aItem['name'],
+                'clear' => $oCacheUtilities->isEnabled($aItem['name']) ? bx_process_output(_t('_adm_dbd_txt_c_clear_' . $aItem['name'])) : ''
+            );
     	}
 
-    	if(empty($aChartData))
+    	if(empty($aItems))
     		return false;
 
-    	return $bAsString ? json_encode($aChartData) : $aChartData;
+        // The hero shows the chart under its label, its three largest caches written under the bar, and the "clear all" button (dashboard.js showChart).
+        $aChartData = $this->getChartData($aItems, $iTotal);
+        $aChartData['label'] = bx_process_output(_t('_adm_dbd_txt_c_all'));
+        $aChartData['top'] = 3;
+        $aChartData['clear_all'] = bx_process_output(_t('_adm_dbd_txt_c_clear_all'));
+        $aChartData['icon_clear'] = $this->getChartIcon('trash');
+
+    	return $bAsString ? $this->getChartJson($aChartData) : $aChartData;
+    }
+
+    /**
+     * Queues that installed modules add to the Queues block. A module implements serviceGetDashboardQueues() and returns
+     * [id => ['name' => lang key, 'icon' => Lucide name, 'all' => COUNT query, 'failed' => COUNT query (optional),
+     * 'action' => DELETE query (optional: without it the queue is listed but cannot be cleared)]]; the ids are prefixed
+     * with the module name here, so 'clear_queue' can only ever run a query a module registered.
+     */
+    protected function getModulesQueues()
+    {
+        $aResult = array();
+
+        $aModules = BxDolModuleQuery::getInstance()->getModulesBy(array('type' => 'modules'));
+        foreach($aModules as $aModule) {
+            if(empty($aModule['name']) || !bx_is_srv($aModule['name'], 'get_dashboard_queues'))
+                continue;
+
+            $aQueues = bx_srv($aModule['name'], 'get_dashboard_queues');
+            if(!is_array($aQueues))
+                continue;
+
+            foreach($aQueues as $sId => $aQueue) {
+                if(!is_array($aQueue) || empty($aQueue['name']) || empty($aQueue['all']))
+                    continue;
+
+                $aResult[$aModule['name'] . '_' . $sId] = array_merge(array('icon' => 'list', 'module' => $aModule['name']), $aQueue);
+            }
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * The Queues block's list: a row per queue with its icon, the number of queued jobs, a note when some failed and a clear
+     * button where the queue can be cleared; the headline totals the queued and failed jobs. No share bar (dashboard.js showChart).
+     */
+    protected function getQueuesChartData($bAsString = true)
+    {
+        $oDb = BxDolDb::getInstance();
+
+        $aItems = array();
+        $iQueued = $iFailed = 0;
+        foreach($this->aItemsQueues as $sId => $aQueue) {
+            $iAll = !empty($aQueue['all']) ? (int)$oDb->getOne($aQueue['all']) : 0;
+            $iFail = !empty($aQueue['failed']) ? (int)$oDb->getOne($aQueue['failed']) : 0;
+
+            $iQueued += $iAll;
+            $iFailed += $iFail;
+
+            $sName = _t($aQueue['name']);
+            $aItems[] = array(
+                'name' => bx_process_output($sName),
+                'bytes' => $iAll,
+                'size' => $iAll,
+                'note' => $iFail > 0 ? bx_process_output(_t('_adm_dbd_txt_q_failed', $iFail)) : '',
+                'note_alert' => true, // failed jobs: the note is a warning, not a count
+                'icon' => $this->getChartIcon($aQueue['icon']),
+                'type' => $sId,
+                'clear' => !empty($aQueue['action']) ? bx_process_output(_t('_adm_dbd_txt_q_clear_item', $sName)) : ''
+            );
+        }
+
+        if(empty($aItems))
+            return false;
+
+        // The hero: the chart under its label, and the "clear all" button under the bar (dashboard.js showChart), like the Cache block's.
+        $aChartData = array(
+            'items' => $aItems,
+            'used_bytes' => $iQueued, // the bar (one slice per queue) is scaled to the queued total
+            'label' => bx_process_output(_t('_adm_dbd_txt_q_all')),
+            'top' => 3, // the three busiest queues under the bar, like the Cache block's three largest caches
+            'top_zero' => true, // queues are usually empty; the line still shows the first counts
+            'clear_all' => bx_process_output(_t('_adm_dbd_txt_q_clear_all')),
+            'action' => 'clearQueue',
+            'used' => bx_process_output(_t('_adm_dbd_txt_q_queued', $iQueued)),
+            'total' => $iFailed > 0 ? bx_process_output(_t('_adm_dbd_txt_q_failed', $iFailed)) : '',
+            'total_fail' => $iFailed > 0,
+            'icon_clear' => $this->getChartIcon('trash')
+        );
+
+        return $bAsString ? $this->getChartJson($aChartData) : $aChartData;
+    }
+
+    /**
+     * Chart data for the share charts: rows, the used size headline and, when known, the capacity the bar is scaled to.
+     * Texts are HTML-escaped here because dashboard.js inserts them as HTML.
+     */
+    protected function getChartData($aItems, $iUsed, $fTotal = 0)
+    {
+        return array(
+            'items' => $aItems,
+            'used_bytes' => $iUsed,
+            'used' => bx_process_output(_t('_adm_dbd_txt_chart_used', _t_format_size($iUsed))),
+            'total_bytes' => $fTotal,
+            'total' => $fTotal > 0 ? bx_process_output(_t('_adm_dbd_txt_chart_total', _t_format_size($fTotal))) : ''
+        );
+    }
+
+    /**
+     * Chart rows from labelled sizes, biggest first: name (HTML-escaped, dashboard.js inserts it as HTML), bytes, formatted size, icon,
+     * and, when given, the page the row links to and a note shown before the size (a file count).
+     */
+    protected function getChartItems($aItems)
+    {
+        usort($aItems, function($a, $b) {
+            return $b['value'] <=> $a['value'];
+        });
+
+        $aResult = array();
+        foreach($aItems as $aItem)
+            $aResult[] = array(
+                'name' => bx_process_output(strip_tags($aItem['label'])),
+                'bytes' => $aItem['value'],
+                'size' => _t_format_size($aItem['value']),
+                'icon' => $aItem['icon'],
+                'url' => isset($aItem['url']) ? $aItem['url'] : '',
+                'note' => isset($aItem['note']) ? bx_process_output($aItem['note']) : ''
+            );
+
+        return $aResult;
+    }
+
+    /**
+     * Chart data as a JSON literal that is safe inside the block's inline <script>.
+     */
+    protected function getChartJson($aChartData)
+    {
+        return json_encode($aChartData, JSON_HEX_TAG | JSON_HEX_AMP);
+    }
+
+    /**
+     * A chart row's icon: a module's own Studio icon by URL, or a Lucide glyph the chart draws on a gray tile.
+     */
+    protected function getChartIcon($sIcon, $bUrl = false)
+    {
+        if($bUrl)
+            return array('url' => $sIcon);
+
+        $oIconset = BxDolIconset::getObjectInstance('sys_lucide');
+        $sSvg = $oIconset ? $oIconset->getIconHtml($sIcon) : false;
+        return array('svg' => $sSvg !== false ? $sSvg : '');
     }
 }
 

+ 162 - 3
studio/classes/BxDolStudioDashboardQuery.php

@@ -14,10 +14,169 @@ class BxDolStudioDashboardQuery extends BxDolStudioPageQuery
         parent::__construct();
     }
 
-    function getModuleStorageSize($sModule)
+    /**
+     * A module's storage use split by where its files live: its objects on the Local engine and those on any remote engine.
+     * @return array ['local' => ['size' => bytes, 'objects' => count], 'remote' => ['size' => bytes, 'objects' => count]]
+     */
+    function getModuleStorageSizes($sModule)
     {
-    	$sSql = "SELECT SUM(`current_size`) AS `size` FROM `sys_objects_storage` WHERE `object` LIKE " . $this->escape($sModule . '%') . " LIMIT 1";
-    	return $this->getOne($sSql);
+        $aResult = array('local' => array('size' => 0, 'objects' => 0), 'remote' => array('size' => 0, 'objects' => 0));
+
+        $sSql = "SELECT IF(`engine` = 'Local', 'local', 'remote') AS `scope`, SUM(`current_size`) AS `size`, COUNT(*) AS `objects` FROM `sys_objects_storage` WHERE `object` LIKE " . $this->escape($sModule . '%') . " GROUP BY `scope`";
+        foreach($this->getAll($sSql) as $aRow)
+            $aResult[$aRow['scope']] = array('size' => (int)$aRow['size'], 'objects' => (int)$aRow['objects']);
+
+        return $aResult;
+    }
+
+    /**
+     * The storage objects under a name prefix, each with its engine and counters (the dashboard splits the system module's by hand).
+     */
+    function getStorageObjects($sPrefix)
+    {
+        return $this->getAll("SELECT `object`, `engine`, `current_size`, `current_number` FROM `sys_objects_storage` WHERE `object` LIKE " . $this->escape($sPrefix . '%') . " ORDER BY `object`");
+    }
+
+    /**
+     * Users block (BxBaseStudioDashboard::serviceGetBlockUsers): every account.
+     */
+    function getAccountsCount()
+    {
+        return (int)$this->getOne("SELECT COUNT(*) FROM `sys_accounts`");
+    }
+
+    /**
+     * Accounts seen since a moment: their last activity or login is at or after it.
+     */
+    function getAccountsActiveCount($iSince)
+    {
+        return (int)$this->getOne("SELECT COUNT(*) FROM `sys_accounts` WHERE `active` >= :since_active OR `logged` >= :since_logged", ['since_active' => (int)$iSince, 'since_logged' => (int)$iSince]);
+    }
+
+    /**
+     * Accounts online now: with a session touched within the online window, the way BxDolAccountQuery::isOnline counts one.
+     */
+    function getAccountsOnlineCount()
+    {
+        return (int)$this->getOne("SELECT COUNT(DISTINCT `ta`.`id`) FROM `sys_accounts` AS `ta` INNER JOIN `sys_sessions` AS `ts` ON `ta`.`id` = `ts`.`user_id` WHERE `ts`.`date` > (UNIX_TIMESTAMP() - 60 * :minutes)", ['minutes' => (int)getParam('sys_account_online_time')]);
+    }
+
+    /**
+     * Profiles of a type whose account is online now, the way getAccountsOnlineCount counts an account.
+     */
+    function getProfilesOnlineCount($sType)
+    {
+        return (int)$this->getOne("SELECT COUNT(DISTINCT `tp`.`id`) FROM `sys_profiles` AS `tp` INNER JOIN `sys_sessions` AS `ts` ON `tp`.`account_id` = `ts`.`user_id` WHERE `tp`.`type` = :type AND `ts`.`date` > (UNIX_TIMESTAMP() - 60 * :minutes)", ['type' => $sType, 'minutes' => (int)getParam('sys_account_online_time')]);
+    }
+
+    /**
+     * Every active profile with the permission level it holds, the way BxDolAclQuery::getLevelCurrent resolves it: its latest membership
+     * that has started and not expired, else Standard; and since when (the membership's start, else the account's creation). A pending or
+     * suspended profile is on its status' pseudo level, so it is left out. Shared by the level queries below as a derived table.
+     */
+    protected function getLevelMembersSql()
+    {
+        bx_import('BxDolAcl');
+        return "SELECT `tp`.`id`, `tp`.`account_id`, COALESCE(`tm`.`IDLevel`, " . (int)MEMBERSHIP_ID_STANDARD . ") AS `level`, COALESCE(UNIX_TIMESTAMP(`tm`.`DateStarts`), `ta`.`added`) AS `since`
+            FROM `sys_profiles` AS `tp`
+            INNER JOIN `sys_accounts` AS `ta` ON `ta`.`id` = `tp`.`account_id`
+            LEFT JOIN `sys_acl_levels_members` AS `tm` ON `tm`.`IDMember` = `tp`.`id` AND `tm`.`DateStarts` = (SELECT MAX(`tm2`.`DateStarts`) FROM `sys_acl_levels_members` AS `tm2` WHERE `tm2`.`IDMember` = `tp`.`id` AND `tm2`.`DateStarts` <= NOW() AND (`tm2`.`DateExpires` IS NULL OR `tm2`.`DateExpires` > NOW()))
+            WHERE `tp`.`type` <> 'system' AND `tp`.`status` = 'active'";
+    }
+
+    /**
+     * The permission levels a profile can hold, in their order: the active ones without the pseudo levels of not being logged in, having no
+     * profile, or an unconfirmed, pending or suspended one.
+     * @return array of ['ID', 'Name' (a language key), 'Icon']
+     */
+    function getLevels()
+    {
+        bx_import('BxDolAcl');
+        $aPseudo = [MEMBERSHIP_ID_NON_MEMBER, MEMBERSHIP_ID_ACCOUNT, MEMBERSHIP_ID_UNCONFIRMED, MEMBERSHIP_ID_PENDING, MEMBERSHIP_ID_SUSPENDED];
+        return $this->getAll("SELECT `ID`, `Name`, `Icon` FROM `sys_acl_levels` WHERE `Active` = 'yes' AND `ID` NOT IN (" . implode(',', array_map('intval', $aPseudo)) . ") ORDER BY `Order` ASC, `ID` ASC");
+    }
+
+    /**
+     * Current members per level.
+     * @return array level id => count
+     */
+    function getLevelsMembersCount()
+    {
+        return $this->getPairs("SELECT `level`, COUNT(*) AS `count` FROM (" . $this->getLevelMembersSql() . ") AS `t` GROUP BY `level`", 'level', 'count');
+    }
+
+    /**
+     * A level's current members by when they joined it, counted per calendar bucket like getAccountsAddedBy.
+     * @return array bucket => count
+     */
+    function getLevelMembersSinceBy($iLevel, $sFormat, $iSince)
+    {
+        return $this->getPairs("SELECT FROM_UNIXTIME(`since`, :format) AS `bucket`, COUNT(*) AS `count` FROM (" . $this->getLevelMembersSql() . ") AS `t` WHERE `level` = :level AND `since` >= :since GROUP BY `bucket` ORDER BY `bucket`", 'bucket', 'count', ['format' => $sFormat, 'level' => (int)$iLevel, 'since' => (int)$iSince]);
+    }
+
+    /**
+     * A level's current members whose account is online now, the way getAccountsOnlineCount counts an account.
+     */
+    function getLevelOnlineCount($iLevel)
+    {
+        return (int)$this->getOne("SELECT COUNT(DISTINCT `t`.`id`) FROM (" . $this->getLevelMembersSql() . ") AS `t` INNER JOIN `sys_sessions` AS `ts` ON `ts`.`user_id` = `t`.`account_id` WHERE `t`.`level` = :level AND `ts`.`date` > (UNIX_TIMESTAMP() - 60 * :minutes)", ['level' => (int)$iLevel, 'minutes' => (int)getParam('sys_account_online_time')]);
+    }
+
+    /**
+     * When the first account was added; 0 without accounts.
+     */
+    function getAccountsFirstAdded()
+    {
+        return (int)$this->getOne("SELECT MIN(`added`) FROM `sys_accounts` WHERE `added` > 0");
+    }
+
+    /**
+     * Accounts added since a moment, counted per calendar bucket named by a MySQL date format ('%Y-%m-%d' days, '%Y-%m' months, '%Y' years).
+     * @return array bucket => count
+     */
+    function getAccountsAddedBy($sFormat, $iSince)
+    {
+        return $this->getPairs("SELECT FROM_UNIXTIME(`added`, :format) AS `bucket`, COUNT(*) AS `count` FROM `sys_accounts` WHERE `added` >= :since GROUP BY `bucket` ORDER BY `bucket`", 'bucket', 'count', ['format' => $sFormat, 'since' => (int)$iSince]);
+    }
+
+    /**
+     * Accounts last seen (their latest activity or login) since a moment, counted per calendar bucket like getAccountsAddedBy.
+     * @return array bucket => count
+     */
+    function getAccountsSeenBy($sFormat, $iSince)
+    {
+        return $this->getPairs("SELECT FROM_UNIXTIME(GREATEST(`active`, `logged`), :format) AS `bucket`, COUNT(*) AS `count` FROM `sys_accounts` WHERE GREATEST(`active`, `logged`) >= :since GROUP BY `bucket` ORDER BY `bucket`", 'bucket', 'count', ['format' => $sFormat, 'since' => (int)$iSince]);
+    }
+
+    /**
+     * Rows of a module's content table (a profile type's profiles) added since a moment, counted per calendar bucket like getAccountsAddedBy.
+     * @return array bucket => count
+     */
+    function getEntriesAddedBy($sTable, $sField, $sFormat, $iSince)
+    {
+        $sTable = preg_replace('/[^a-zA-Z0-9_]/', '', $sTable);
+        $sField = preg_replace('/[^a-zA-Z0-9_]/', '', $sField);
+        return $this->getPairs("SELECT FROM_UNIXTIME(`" . $sField . "`, :format) AS `bucket`, COUNT(*) AS `count` FROM `" . $sTable . "` WHERE `" . $sField . "` >= :since GROUP BY `bucket` ORDER BY `bucket`", 'bucket', 'count', ['format' => $sFormat, 'since' => (int)$iSince]);
+    }
+
+    /**
+     * Profiles per type (the module that owns them), the system's own left out, with how many of them are active.
+     * @return array of ['type', 'total', 'active']
+     */
+    function getProfilesByType()
+    {
+        return $this->getAll("SELECT `type`, COUNT(*) AS `total`, SUM(`status` = 'active') AS `active` FROM `sys_profiles` WHERE `type` <> 'system' GROUP BY `type` ORDER BY `total` DESC, `type` ASC");
+    }
+
+    /**
+     * Whether remote storage is in use: some storage object lives on a remote engine, or new objects would (the default engine option).
+     */
+    function isRemoteStorageUsed()
+    {
+        if(getParam('sys_storage_default') != 'Local')
+            return true;
+
+        return (int)$this->getOne("SELECT COUNT(*) FROM `sys_objects_storage` WHERE `engine` <> 'Local'") > 0;
     }
 }
 

+ 10 - 2
studio/classes/BxDolStudioMenuTop.php

@@ -75,6 +75,11 @@ class BxDolStudioMenuTop extends BxDol implements iBxDolSingleton
         $oRolesUtils = BxDolStudioRolesUtils::getInstance();
         $oWidgetsDb = BxDolStudioWidgetsQuery::getInstance();
 
+        // Every app in the dock carries its context menu trigger (studio/js/context_menu.js loads the menu on first use).
+        $fContextAttrs = function($sPageName, $iWidgetId = 0) {
+            return 'data-bx-context-menu="#bx-std-cmenu-' . $sPageName . '" data-bx-context-menu-load="' . $sPageName . ':' . (int)$iWidgetId . '"';
+        };
+
         $aFeatured = $oWidgetsDb->getWidgets(array('type' => 'all_featured', 'featured' => 1));
         foreach($aFeatured as $aItem)
             if(empty($aItem['type']) || $oRolesUtils->isActionAllowed('use ' . $aItem['type']))
@@ -84,7 +89,8 @@ class BxDolStudioMenuTop extends BxDol implements iBxDolSingleton
                     'icon' => $aItem['icon'],
                     'link' => $aItem['url'],
                     'onclick' => $aItem['click'],
-                    'title' => $aItem['caption']
+                    'title' => $aItem['caption'],
+                    'attrs_add' => $fContextAttrs($aItem['page_name'], $aItem['id'])
                 );
 
         //--- Get Bookmarks
@@ -102,7 +108,8 @@ class BxDolStudioMenuTop extends BxDol implements iBxDolSingleton
                 'icon' => $aBookmark['icon'],
                 'link' => $aBookmark['url'],
                 'onclick' => $aBookmark['click'],
-                'title' => $aBookmark['caption']
+                'title' => $aBookmark['caption'],
+                'attrs_add' => $fContextAttrs($aBookmark['page_name'], $aBookmark['id'])
             );
         }
 
@@ -125,6 +132,7 @@ class BxDolStudioMenuTop extends BxDol implements iBxDolSingleton
                     continue;
 
                 $aMenuItem['class'] = 'bx-menu-item-dynamic';
+                $aMenuItem['attrs_add'] = $fContextAttrs($sPageName);
                 $aMenuItems[$sPageName] = $aMenuItem;
                 $bHistory = true;
             }

+ 39 - 0
studio/classes/BxDolStudioModule.php

@@ -92,9 +92,23 @@ class BxDolStudioModule extends BxTemplStudioWidget
                 'type' => 'switcher',
                 'name' => 'activate',
                 'caption' => '_adm_txt_pca_active',
+                'icon' => 'power',
                 'checked' => (int)$this->aModule['enabled'] == 1,
                 'onchange' => "javascript:" . $this->getPageJsObject() . ".activate(this, '" . $this->aPage['name'] . "', {widget_id})"
             ), false);
+
+        // Uninstall, for app managers, in the context menu only (the launcher tile's settings form has no place for it). It goes
+        // with the app's launcher tile id, which is what makes the server ask for confirmation first.
+        if($this->sModule != BX_DOL_STUDIO_MODULE_SYSTEM && !empty($this->aPage['wid_id']) && BxDolStudioRolesUtils::getInstance()->isActionAllowed(BX_SRA_MANAGE_APPS))
+            $this->addAction(array(
+                'type' => 'button',
+                'name' => 'uninstall',
+                'caption' => '_adm_txt_uninstall',
+                'icon' => 'trash-2',
+                'danger' => true,
+                'context_only' => true,
+                'onclick' => "javascript:" . $this->getPageJsObject() . ".uninstall('" . $this->aPage['name'] . "', " . (int)$this->aPage['wid_id'] . ", 0)"
+            ));
     }
 
     public function checkAction()
@@ -138,6 +152,16 @@ class BxDolStudioModule extends BxTemplStudioWidget
 
                 $aResult = $this->uninstall($sValue, $iWidgetId);
                 break;
+
+            case 'context':
+                $sValue = bx_process_input(bx_get($this->sParamPrefix . '_value'));
+                if(empty($sValue))
+                    break;
+
+                $iWidgetId = bx_process_input(bx_get($this->sParamPrefix . '_widget_id'), BX_DATA_INT);
+
+                $aResult = $this->context($sValue, $iWidgetId);
+                break;
         }
 
         return $aResult;
@@ -171,6 +195,21 @@ class BxDolStudioModule extends BxTemplStudioWidget
         ));
     }
 
+    /**
+     * The page's context menu for its launcher tile or dock item (studio/js/context_menu.js fetches it on the first right-click).
+     */
+    public function context($sPage, $iWidgetId = 0)
+    {
+        if(empty($iWidgetId) && !empty($this->aPage['wid_id']))
+            $iWidgetId = (int)$this->aPage['wid_id'];
+
+        $sPopup = $this->getPopupContextMenu($sPage, $iWidgetId);
+        if(empty($sPopup))
+            return array('code' => 1, 'message' => _t('_adm_err_operation_failed'));
+
+        return array('code' => 0, 'content' => $sPopup);
+    }
+
     public function activate($sPage, $iWidgetId = 0)
     {
         $aPage = $this->aPage['wid_module'];

+ 6 - 0
studio/classes/BxDolStudioTemplate.php

@@ -122,6 +122,11 @@ class BxDolStudioTemplate extends BxDolTemplate implements iBxDolSingleton
                 break;
             case 'page_breadcrumb':
                 $sRet = $this->getPageBreadcrumb();
+                break;
+            case 'page_viewport':
+                $sRet = getParam('sys_viewport_meta_tag');
+                if(empty($sRet))
+                    $sRet = 'width=device-width, initial-scale=1';
                 break;
 			case 'popup_loading':
                 $s = $this->parsePageByName('popup_loading.html', array());
@@ -246,6 +251,7 @@ class BxDolStudioTemplate extends BxDolTemplate implements iBxDolSingleton
 
         $this->setPageNameIndex($oPage->getPageIndex());
         $this->setPageHeader($oPage->getPageHeader());
+        $this->setPageDescription($oPage->getPageDescription());
         $this->setPageContent('page_caption_code', $oPage->getPageCaption());
         $this->setPageContent('page_attributes', $oPage->getPageAttributes());
         $this->setPageContent('page_menu_code', $sPageMenu);

+ 28 - 85
studio/classes/BxDolStudioTools.php

@@ -120,104 +120,47 @@ EOF;
         return $bRet;
     }
 
-    function GenCacheEnginesTable()
+    /**
+     * The permission checks as data: each path with whether it passes and its current and desired state as titles.
+     */
+    public function getPermissionsReport($isShowModules = false)
     {
-        $sRet = '<table width="100%" cellspacing="1" cellpadding="0" class="install_table">';
-        $sRet .= '
-<tr class="head troubled">
-    <td></td>
-    <td class="center_aligned">' . _t('_sys_adm_installed') . '</td>
-    <td class="center_aligned">' . _t('_sys_adm_cache_support') . '</td>
-</tr>';
-
-        $aEngines = array ('File', 'Memcache', 'APC', 'XCache');
-        foreach ($aEngines as $sEngine) {
-            $oCacheObject = @bx_instance ('BxDolCache' . $sEngine);
-            $sRet .= '
-<tr class="head troubled">
-    <td class="left_aligned">' . $sEngine . '</td>
-    <td class="center_aligned">' . (@$oCacheObject->isInstalled() ? '<font color="green">' . _t('_Yes') . '</font>' : '<font color="red">' . _t('_No') . '</font>') . '</td>
-    <td class="center_aligned">' . (@$oCacheObject->isAvailable() ? '<font color="green">' . _t('_Yes') . '</font>' : '<font color="red">' . _t('_No') . '</font>') . '</td>
-</tr>';
-        }
+        $aMessages = array();
+        $this->checkPermissions($isShowModules, false, $aMessages);
 
-        $sRet .= '</table>';
-        return $sRet;
+        $aRows = array();
+        foreach ($aMessages as $s => $r)
+            $aRows[] = $this->_getPermissionRow($s, $r);
+        return $aRows;
     }
 
-    function GenTabbedPage($isShowModules = false)
-    {
-        $sTitleC = _t('_adm_admtools_title');
-        $sAuditC = _t('');
-        $sPermissionsC = _t('');
-        $sCacheEnginesC = _t('');
-
-        $sAuditTab = $this->GenAuditPage();
-        $sPermissionsTab = $this->GenPermTable($isShowModules);
-        $sCacheEnginesTab = $this->GenCacheEnginesTable();
-
-        $sBoxContent = <<<EOF
-<script language="javascript">
-    <!--
-    function switchAdmPage(oLink)
+    /**
+     * One permission check as data: the path, whether it passes, and its current and desired state as titles.
+     */
+    protected function _getPermissionRow($s, $r)
     {
-        var sType = $(oLink).attr('id').replace('main_menu', '');
-        var sName = '#page' + sType;
-
-        $(oLink).parent('.notActive').hide().siblings('.notActive:hidden').show().siblings('.active').hide().siblings('#' + $(oLink).attr('id') + '-act').show();
-        $(sName).siblings('div:visible').bx_anim('hide', 'fade', 'slow', function(){
-            $(sName).bx_anim('show', 'fade', 'slow');
-        });
-
-        return false;
-    }
-    -->
-</script>
-
-<div class="boxContent" id="adm_pages">
-    <div id="page0" class="bx-adm-visible">{$sAuditTab}</div>
-    <div id="page1" class="bx-adm-hidden">{$sPermissionsTab}</div>
-    <div id="page2" class="bx-adm-hidden">
-        <iframe frameborder="0" width="100%" height="800" scrolling="auto" src="host_tools.php?get_phpinfo=true"></iframe>
-    </div>
-    <div id="page3" class="bx-adm-hidden">{$sCacheEnginesTab}</div>
-</div>
-EOF;
-
-        $aTopItems = array(
-            'main_menu0' => array('href' => 'javascript:void(0)', 'onclick' => 'javascript:switchAdmPage(this)', 'title' => _t('_adm_admtools_Audit'), 'active' => 1),
-            'main_menu1' => array('href' => 'javascript:void(0)', 'onclick' => 'javascript:switchAdmPage(this)', 'title' => _t('_adm_admtools_Permissions'), 'active' => 0),
-            'main_menu2' => array('href' => 'javascript:void(0)', 'onclick' => 'javascript:switchAdmPage(this)', 'title' => _t('_adm_admtools_phpinfo'), 'active' => 0),
-        );
+        $sDesired = BX_DOL_PERM_EXE == $r['type'] ? _t('_adm_admtools_Executable') : _t('_adm_admtools_Writable');
+        $sCurrent = $sDesired;
+        if (BX_DOL_PERM_FAIL == $r['res']) {
+            if (false === $this->getPermissions($s))
+                $sCurrent = _t('_adm_admtools_Not_Exists');
+            else
+                $sCurrent = BX_DOL_PERM_EXE == $r['type'] ? _t('_adm_admtools_Non_Executable') : _t('_adm_admtools_Non_Writable');
+        }
 
-        return DesignBoxAdmin($sTitleC, $sBoxContent, $aTopItems, '', 11);
-    }
-
-    function GenAuditPage()
-    {
-        $oAudit = new BxDolStudioToolsAudit();
-        return $oAudit->generate();
+        return array('path' => $s, 'ok' => BX_DOL_PERM_OK == $r['res'], 'current' => $sCurrent, 'desired' => $sDesired);
     }
 
     protected function _getHtmlPermissionRow($s, $r)
     {
-        $sAwaitedPerm = BX_DOL_PERM_EXE == $r['type'] ? _t('_adm_admtools_Executable') : _t('_adm_admtools_Writable');
-        $sResultPerm = $sAwaitedPerm;
-        $sPerm = $this->getPermissions($s);
-        $sClass = 'bx-permissions-ok';
-        if (BX_DOL_PERM_FAIL == $r['res']) {
-            $sClass = 'bx-permissions-wrong';
-            if (false === $sPerm)
-                $sResultPerm = _t('_adm_admtools_Not_Exists');
-            else
-                $sResultPerm = BX_DOL_PERM_EXE == $r['type'] ? _t('_adm_admtools_Non_Executable') : _t('_adm_admtools_Non_Writable');
-        }
+        $aRow = $this->_getPermissionRow($s, $r);
+        $sClass = $aRow['ok'] ? 'bx-permissions-ok' : 'bx-permissions-wrong';
 
         return <<<EOF
 <tr class="bx-def-color-bg-hl-even">
-    <td class="bx-def-padding-thd">{$s}</td>
-    <td class="bx-def-padding-thd"><span class="{$sClass}">{$sResultPerm}</span></td>
-    <td class="bx-def-padding-thd">{$sAwaitedPerm}</td>
+    <td class="bx-def-padding-thd">{$aRow['path']}</td>
+    <td class="bx-def-padding-thd"><span class="{$sClass}">{$aRow['current']}</span></td>
+    <td class="bx-def-padding-thd">{$aRow['desired']}</td>
 </tr>
 EOF;
     }

+ 279 - 104
studio/classes/BxDolStudioToolsAudit.php

@@ -18,6 +18,7 @@ class BxDolStudioToolsAudit extends BxDol
     protected $aType2Title;
 
     protected $sMinPhpVer;
+    protected $sLatestPhpVersion = null;
     protected $aPhpSettings;
     protected $iPhpErrorReporting;
 
@@ -247,9 +248,13 @@ class BxDolStudioToolsAudit extends BxDol
 
     protected function requirementsPHP($bEcho = true, &$aOutputMessages = null)
     {
-        $sHttpCode = null; 
-        $s = bx_file_get_contents('http://php.net/releases/index.php?serialize=1', array(), 'get', array(), $sHttpCode, array(), 20, array(CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4));
-        $sLatestPhpVersion = $s && ($a = unserialize($s)) ? $a[5]['version'] : '';
+        // php.net is asked once per audit, however many sections need the latest version
+        if($this->sLatestPhpVersion === null) {
+            $sHttpCode = null;
+            $s = bx_file_get_contents('http://php.net/releases/index.php?serialize=1', array(), 'get', array(), $sHttpCode, array(), 20, array(CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4));
+            $this->sLatestPhpVersion = $s && ($a = unserialize($s)) ? $a[5]['version'] : '';
+        }
+        $sLatestPhpVersion = $this->sLatestPhpVersion;
 
         if (version_compare(phpversion(), "5.4", ">=") == 1)
             unset($this->aPhpSettings['short_open_tag']);
@@ -299,11 +304,11 @@ class BxDolStudioToolsAudit extends BxDol
         if (preg_match ('/^(\d+)\.(\d+)\.(\d+)/', $sMysqlVer, $m)) {
             $sMysqlVer = "{$m[1]}.{$m[2]}.{$m[3]}";
             if (version_compare($sMysqlVer, $this->sMinMysqlVer, '<'))
-                $aMessage = array('type' => BX_DOL_AUDIT_FAIL, 'msg' => _t('_sys_audit_msg_version_is_incompatible', $this->sMinMysqlVer));
+                $aMessage = array('type' => BX_DOL_AUDIT_FAIL, 'msg' => _t('_sys_audit_msg_version_is_incompatible', $this->sMinMysqlVer), 'params' => array('real_val' => $sMysqlVer));
             else
-                $aMessage = array('type' => BX_DOL_AUDIT_OK);
+                $aMessage = array('type' => BX_DOL_AUDIT_OK, 'params' => array('real_val' => $sMysqlVer));
         } else {
-            $aMessage = array('type' => BX_DOL_AUDIT_UNDEF, 'msg' => _t('_sys_audit_msg_value_checking_failed'));
+            $aMessage = array('type' => BX_DOL_AUDIT_UNDEF, 'msg' => _t('_sys_audit_msg_value_checking_failed'), 'params' => array('real_val' => $sMysqlVer));
         }
 
         if (null !== $aOutputMessages)
@@ -335,170 +340,340 @@ class BxDolStudioToolsAudit extends BxDol
 
     protected function requirementsOS()
     {
-        $s = $this->getBlock(php_uname());
-        echo $this->getSection(_t('_sys_audit_section_os'), '', $s);
+        echo $this->getSection(_t('_sys_audit_section_os'), '', $this->rowsToBlocks($this->getRowsOs()));
     }
 
     protected function requirementsHardware()
     {
-        $s = $this->getBlock(_t('_sys_audit_msg_hardware_requirements'));
-        echo $this->getSection(_t('_sys_audit_section_hardware'), '', $s);
+        echo $this->getSection(_t('_sys_audit_section_hardware'), '', $this->rowsToBlocks($this->getRowsHardware()));
     }
 
     protected function siteSetup()
     {
-        $sFfmpegPath = defined('BX_SYSTEM_FFMPEG') ? BX_SYSTEM_FFMPEG : BX_DIRECTORY_PATH_PLUGINS . 'ffmpeg/ffmpeg.exe';
+        echo '<h1>' . _t('_sys_audit_header_site_setup') . '</h1>';
+        echo '<ul>' . $this->rowsToBlocks($this->getRowsSiteSetup()) . '</ul>';
+    }
+
+    protected function optimization()
+    {
+        echo '<h1>' . _t('_sys_audit_header_site_optimization') . '</h1>';
 
-        $sEmailToCkeckMailSending = class_exists('BxDolDb') && BxDolDb::getInstance() ? BxDolDb::getInstance()->getParam('site_email') : '';
+        echo $this->getSection('PHP', '', $this->rowsToBlocks($this->getRowsOptimizationPhp()));
+
+        if (!defined('BX_DOL_INSTALL'))
+            echo $this->getSection('MySQL', '', $this->rowsToBlocks($this->getRowsOptimizationMysql()));
+
+        echo $this->getSection(_t('_sys_audit_section_webserver'), '', $this->rowsToBlocks($this->getRowsOptimizationWebServer()));
+
+        if (!defined('BX_DOL_INSTALL')) {
+            echo $this->getSection('UNA', '', $this->rowsToBlocks($this->getRowsOptimizationScript()));
+            echo $this->getSection(_t('_sys_audit_section_cache_engines'), '', $this->rowsToBlocks($this->getRowsOptimizationCache()));
+        }
+    }
+
+    protected function manualCheck()
+    {
+        echo '<a name="manual_audit"></a>';
+        echo '<h1>' . _t('_sys_audit_header_manual_audit') . '</h1>';
+        echo _t('_sys_audit_msg_manual_audit');
+    }
+
+    /**
+     * The whole audit as data, for the Studio Dashboard (which stores it as the last audit): when it ran and eight sections,
+     * each with a name, title, Lucide icon, headline value (a version; '' when the section is a plain check-list) and rows.
+     * A row is name, value, type (BX_DOL_AUDIT_*) and msg, a short note that may carry HTML.
+     */
+    public function getReport()
+    {
+        $this->setErrorReporting();
+
+        $aRowsMysql = $this->getRowsMysql();
+        $aRowCron = $this->getRowCron();
+
+        $aSections = array(
+            array('name' => 'php', 'title' => 'PHP', 'icon' => 'code', 'value' => PHP_VERSION, 'rows' => array_merge($this->getRowsPhp(), $this->getRowsPhpExtensions(), $this->getRowsOptimizationPhp())),
+            array('name' => 'database', 'title' => _t('_adm_dbd_txt_su_database'), 'icon' => 'database', 'value' => $aRowsMysql[0]['value'], 'rows' => array_merge($aRowsMysql, $this->getRowsOptimizationMysql())),
+            array('name' => 'webserver', 'title' => _t('_sys_audit_section_webserver'), 'icon' => 'globe', 'value' => isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : '', 'rows' => array_merge($this->getRowsWebServer(), $this->getRowsOptimizationWebServer())),
+            array('name' => 'system', 'title' => _t('_adm_dbd_txt_ht_system'), 'icon' => 'cpu', 'value' => php_uname('s'), 'rows' => array_merge($this->getRowsOs(), $this->getRowsHardware())),
+            array('name' => 'permissions', 'title' => _t('_adm_dbd_txt_ht_permissions'), 'icon' => 'folder-lock', 'value' => '', 'rows' => $this->getRowsPermissions()),
+            array('name' => 'security', 'title' => _t('_adm_dbd_txt_ht_security'), 'icon' => 'shield-check', 'value' => '', 'rows' => $this->getRowsSecurity()),
+            array('name' => 'cron', 'title' => _t('_sys_audit_cron_jobs'), 'icon' => 'clock', 'value' => $aRowCron['value'], 'rows' => array($aRowCron)),
+            array('name' => 'services', 'title' => _t('_adm_dbd_txt_ht_services'), 'icon' => 'plug', 'value' => '', 'rows' => array($this->getRowFfmpeg(), $this->getRowMail())),
+            array('name' => 'caching', 'title' => _t('_adm_dbd_txt_ht_caching'), 'icon' => 'layers', 'value' => '', 'rows' => array_merge($this->getRowsOptimizationScript(), $this->getRowsOptimizationCache())),
+        );
+
+        $this->restoreErrorReporting();
+
+        return array('time' => time(), 'sections' => $aSections);
+    }
+
+    protected function getRow($sName, $sValue, $sType, $sMsg = '')
+    {
+        return array('name' => $sName, 'value' => (string)$sValue, 'type' => $sType, 'msg' => (string)$sMsg);
+    }
+
+    protected function rowsToBlocks($aRows)
+    {
+        $s = '';
+        foreach ($aRows as $aRow)
+            $s .= $this->getBlock($aRow['name'], $aRow['value'], $this->getMsgHTML($aRow['name'], $aRow));
+        return $s;
+    }
+
+    /**
+     * PHP version and ini settings (extensions are listed separately).
+     */
+    protected function getRowsPhp()
+    {
+        return $this->getRowsFromPhpMessages(false);
+    }
+
+    protected function getRowsPhpExtensions()
+    {
+        return $this->getRowsFromPhpMessages(true);
+    }
+
+    protected function getRowsFromPhpMessages($bExtensions)
+    {
+        $aMessages = array();
+        $this->requirementsPHP(false, $aMessages);
+
+        $aRows = array();
+        foreach ($aMessages as $sName => $r) {
+            $aSetting = isset($this->aPhpSettings[$sName]) ? $this->aPhpSettings[$sName] : array();
+            $bExtension = !empty($aSetting['op']) && $aSetting['op'] === 'module';
+            if ($bExtension != $bExtensions)
+                continue;
+
+            $aRows[] = $this->getRow(
+                $bExtension ? $aSetting['val'] : $sName,
+                $bExtension ? '' : $this->format_output($r['params']['real_val'], $aSetting),
+                $r['type'],
+                isset($r['msg']) ? $r['msg'] : ''
+            );
+        }
+        return $aRows;
+    }
+
+    protected function getRowsMysql()
+    {
+        $aMessages = array();
+        $this->requirementsMySQL(false, $aMessages);
+        $r = $aMessages['mysql'][0];
+
+        return array($this->getRow(_t('_sys_audit_version'), $r['params']['real_val'], $r['type'], isset($r['msg']) ? $r['msg'] : ''));
+    }
+
+    protected function getRowsWebServer()
+    {
+        $aMessages = array();
+        $this->requirementsWebServer(false, $aMessages);
+
+        $aRows = array();
+        foreach ($aMessages as $sName => $r)
+            $aRows[] = $this->getRow($sName, '', $r['type'], isset($r['msg']) ? $r['msg'] : '');
+        return $aRows;
+    }
+
+    protected function getRowsOs()
+    {
+        return array($this->getRow(php_uname('s'), trim(php_uname('r') . ' ' . php_uname('m')), BX_DOL_AUDIT_OK));
+    }
+
+    protected function getRowsHardware()
+    {
+        return array($this->getRow(_t('_sys_audit_section_hardware'), '', BX_DOL_AUDIT_UNDEF, _t('_sys_audit_msg_hardware_requirements')));
+    }
+
+    protected function getRowsSiteSetup()
+    {
+        $aRows = array();
 
         $oUpgrader = new BxDolUpgrader();
         if (!($sLatestVer = $oUpgrader->getLatestVersionNumber()))
             $sLatestVer = 'undefined';
-
         $sVer = bx_get_ver();
-        $aMessage = array('type' => BX_DOL_AUDIT_OK);
-        if (!version_compare($sVer, $sLatestVer, '>='))
-            $aMessage = array('type' => BX_DOL_AUDIT_WARN, 'msg' => _t('_sys_audit_msg_version_is_outdated', $sLatestVer));
+        if (version_compare($sVer, $sLatestVer, '>='))
+            $aRows[] = $this->getRow(_t('_sys_audit_version_script'), $sVer, BX_DOL_AUDIT_OK);
+        else
+            $aRows[] = $this->getRow(_t('_sys_audit_version_script'), $sVer, BX_DOL_AUDIT_WARN, _t('_sys_audit_msg_version_is_outdated', $sLatestVer));
+
+        $aRows[] = $this->getRowPermissions();
+        $aRows[] = $this->getRowFfmpeg();
+        $aRows[] = $this->getRowMail();
+        $aRows[] = $this->getRowCron();
 
-        $s = $this->getBlock(_t('_sys_audit_version_script'), $sVer, $this->getMsgHTML(_t('_sys_audit_version_script'), $aMessage));
+        return $aRows;
+    }
 
-        $s .= $this->getBlock(_t('_sys_audit_permissions'), '', _t('_sys_audit_msg_permissions'));
+    /**
+     * Security: the hardening a live site should have. Every miss is a warning, since the site works without it.
+     */
+    protected function getRowsSecurity()
+    {
+        $aRows = array();
 
-        $s .= $this->getBlock('ffmpeg', '', _t('_sys_audit_msg_ffmpeg', shell_exec("{$sFfmpegPath} 2>&1")));
+        $bHttps = strncasecmp(BX_DOL_URL_ROOT, 'https://', 8) === 0;
+        $aRows[] = $this->getRow(_t('_sys_audit_https'), $bHttps ? 'https' : 'http', $bHttps ? BX_DOL_AUDIT_OK : BX_DOL_AUDIT_WARN, $bHttps ? '' : _t('_sys_audit_msg_https'));
 
-        $s .= $this->getBlock(_t('_sys_audit_mail_sending'), '', _t('_sys_audit_msg_mail_sending'));
+        $bInstallDir = file_exists(BX_DIRECTORY_PATH_ROOT . 'install');
+        $aRows[] = $this->getRow(_t('_sys_audit_install_dir'), _t($bInstallDir ? '_sys_audit_present' : '_sys_audit_removed'), $bInstallDir ? BX_DOL_AUDIT_WARN : BX_DOL_AUDIT_OK, $bInstallDir ? _t('_sys_audit_msg_install_dir') : '');
 
-        $s .= $this->getBlock(_t('_sys_audit_cron_jobs'), '', _t('_sys_audit_msg_cron_jobs', shell_exec("crontab -l 2>&1")));
+        // display_errors also accepts stdout/stderr, which filter_var would read as off
+        foreach (array('display_errors' => '_sys_audit_display_errors', 'expose_php' => '_sys_audit_expose_php') as $sSetting => $sTitle) {
+            $sValue = strtolower(trim((string)ini_get($sSetting)));
+            $bOn = filter_var($sValue, FILTER_VALIDATE_BOOLEAN) || in_array($sValue, array('stdout', 'stderr'));
+            $aRows[] = $this->getRow(_t($sTitle), _t($bOn ? '_sys_audit_on' : '_sys_audit_off'), $bOn ? BX_DOL_AUDIT_WARN : BX_DOL_AUDIT_OK, $bOn ? _t('_sys_audit_msg_' . $sSetting) : '');
+        }
 
-        $iCronTime = getParam('sys_cron_time');
-        $s .= $this->getBlock(_t('_sys_audit_cron_jobs_exec_time'), '', !empty($iCronTime) ? bx_time_js($iCronTime, BX_FORMAT_DATE_TIME, true) : _t('_None'));
+        // the configuration file holds the database password: nobody but the owner should be able to write it
+        $iMode = @fileperms(BX_DIRECTORY_PATH_ROOT . 'inc/header.inc.php');
+        if ($iMode === false)
+            $aRows[] = $this->getRow(_t('_sys_audit_config_file'), '', BX_DOL_AUDIT_UNDEF, _t('_sys_audit_msg_value_checking_failed'));
+        else {
+            $bOwnerOnly = ($iMode & 0022) == 0;
+            $aRows[] = $this->getRow(_t('_sys_audit_config_file'), sprintf('%04o', $iMode & 0777), $bOwnerOnly ? BX_DOL_AUDIT_OK : BX_DOL_AUDIT_WARN, $bOwnerOnly ? '' : _t('_sys_audit_msg_config_file'));
+        }
 
-        echo '<h1>' . _t('_sys_audit_header_site_setup') . '</h1>';
-        echo "<ul>$s</ul>";
+        return $aRows;
     }
 
-    protected function optimization()
+    /**
+     * Single checks that the Studio Dashboard's Status tab shows on their own as well.
+     */
+    public function getRowPermissions()
     {
-        echo '<h1>' . _t('_sys_audit_header_site_optimization') . '</h1>';
+        $sType = BX_DOL_AUDIT_UNDEF;
+        if (class_exists('BxDolStudioTools')) {
+            $oTools = new BxDolStudioTools();
+            $sType = $oTools->checkPermissions(false, false) ? BX_DOL_AUDIT_OK : BX_DOL_AUDIT_FAIL;
+        }
+        return $this->getRow(_t('_sys_audit_permissions'), '', $sType, $sType == BX_DOL_AUDIT_OK ? '' : _t('_sys_audit_msg_permissions'));
+    }
 
-        $this->optimizationPhp();
+    public function getRowFfmpeg()
+    {
+        $sFfmpegPath = defined('BX_SYSTEM_FFMPEG') ? BX_SYSTEM_FFMPEG : BX_DIRECTORY_PATH_PLUGINS . 'ffmpeg/ffmpeg.exe';
+        $sFfmpegOut = (string)@shell_exec(escapeshellarg($sFfmpegPath) . ' -version 2>&1');
+        if (preg_match('/ffmpeg version (\S+)/', $sFfmpegOut, $m))
+            return $this->getRow('ffmpeg', $m[1], BX_DOL_AUDIT_OK);
 
-        if (!defined('BX_DOL_INSTALL'))
-            $this->optimizationMySQL();
+        return $this->getRow('ffmpeg', '', BX_DOL_AUDIT_WARN, _t('_sys_audit_msg_ffmpeg', $sFfmpegPath));
+    }
 
-        $this->optimizationWebServer();
+    public function getRowCron()
+    {
+        $iCronTime = (int)getParam('sys_cron_time');
+        if ($iCronTime && time() - $iCronTime < 3600)
+            return $this->getRow(_t('_sys_audit_cron_jobs'), bx_time_js($iCronTime, BX_FORMAT_DATE_TIME), BX_DOL_AUDIT_OK);
 
-        if (!defined('BX_DOL_INSTALL'))
-            $this->optimizationScript();
+        return $this->getRow(_t('_sys_audit_cron_jobs'), $iCronTime ? bx_time_js($iCronTime, BX_FORMAT_DATE_TIME) : _t('_None'), BX_DOL_AUDIT_WARN, _t('_sys_audit_msg_cron_jobs'));
+    }
 
-        if (!defined('BX_DOL_INSTALL'))
-            $this->optimizationCache();
+    /**
+     * Email delivery cannot be checked automatically: the row carries the "send a test email" link.
+     */
+    public function getRowMail()
+    {
+        return $this->getRow(_t('_sys_audit_mail_sending'), '', BX_DOL_AUDIT_UNDEF, _t('_sys_audit_msg_mail_sending'));
     }
 
-    protected function optimizationPhp()
+    /**
+     * A row per checked path (BxDolStudioTools): its current state as the value, the desired one as the note when it fails.
+     */
+    protected function getRowsPermissions()
     {
-        $s = '';
-        $aMessage = array();
+        if (!class_exists('BxDolStudioTools'))
+            return array($this->getRowPermissions());
+
+        $oTools = new BxDolStudioTools();
 
+        $aRows = array();
+        foreach ($oTools->getPermissionsReport() as $aPerm)
+            $aRows[] = $this->getRow($aPerm['path'], $aPerm['current'], $aPerm['ok'] ? BX_DOL_AUDIT_OK : BX_DOL_AUDIT_FAIL, $aPerm['ok'] ? '' : _t('_adm_admtools_Desired_level') . ': ' . $aPerm['desired']);
+        return $aRows;
+    }
+
+    public function getRowPhpAccelerator()
+    {
         $sAccel = $this->getPhpAccelerator();
-        if (!$sAccel)
-            $aMessage = array('type' => BX_DOL_AUDIT_WARN, 'msg' => _t('_sys_audit_msg_php_accelerator_missing'));
-        else
-            $aMessage = array('type' => BX_DOL_AUDIT_OK);
-        $s .= $this->getBlock(_t('_sys_audit_php_accelerator'), $sAccel, $this->getMsgHTML(_t('_sys_audit_php_accelerator'), $aMessage));
+        if ($sAccel)
+            return $this->getRow(_t('_sys_audit_php_accelerator'), $sAccel, BX_DOL_AUDIT_OK);
+
+        return $this->getRow(_t('_sys_audit_php_accelerator'), '', BX_DOL_AUDIT_WARN, _t('_sys_audit_msg_php_accelerator_missing'));
+    }
+
+    protected function getRowsOptimizationPhp()
+    {
+        $aRows = array();
+
+        $aRows[] = $this->getRowPhpAccelerator();
 
         $sSapi = php_sapi_name();
         if (0 === strcasecmp('cgi', $sSapi))
-            $aMessage = array('type' => BX_DOL_AUDIT_WARN, 'msg' => _t('_sys_audit_msg_php_setup_inefficient'));
+            $aRows[] = $this->getRow(_t('_sys_audit_php_setup'), $sSapi, BX_DOL_AUDIT_WARN, _t('_sys_audit_msg_php_setup_inefficient'));
         else
-            $aMessage = array('type' => BX_DOL_AUDIT_OK);
-        $s .= $this->getBlock(_t('_sys_audit_php_setup'), $sSapi, $this->getMsgHTML(_t('_sys_audit_php_setup'), $aMessage));
+            $aRows[] = $this->getRow(_t('_sys_audit_php_setup'), $sSapi, BX_DOL_AUDIT_OK);
 
-        echo $this->getSection('PHP', '', $s);
+        return $aRows;
     }
 
-    protected function optimizationMySQL()
+    protected function getRowsOptimizationMysql()
     {
-        $s = '';
+        $aRows = array();
         $oDb = BxDolDb::getInstance();
-
         foreach ($this->aMysqlOptimizationSettings as $sName => $r) {
             $a = $this->checkMysqlSetting($sName, $r, $oDb);
-            $aMessage = array('type' => BX_DOL_AUDIT_OK);
-            if (!$a['res'])
-                $aMessage = array('type' => BX_DOL_AUDIT_FAIL, 'msg' => _t('_sys_audit_msg_must_be', $r['op'], $this->format_output($r['val'], $r)));
-            $s .= $this->getBlock($sName, $this->format_output($a['real_val'], $r), $this->getMsgHTML($sName, $aMessage));
+            if ($a['res'])
+                $aRows[] = $this->getRow($sName, $this->format_output($a['real_val'], $r), BX_DOL_AUDIT_OK);
+            else
+                $aRows[] = $this->getRow($sName, $this->format_output($a['real_val'], $r), BX_DOL_AUDIT_FAIL, _t('_sys_audit_msg_must_be', $r['op'], $this->format_output($r['val'], $r)));
         }
-
-        echo $this->getSection('MySQL', '', $s);
+        return $aRows;
     }
 
-    protected function optimizationWebServer()
+    /**
+     * Browser caching and response compression: the Apache module check gives the status; on other servers it stays unknown.
+     */
+    protected function getRowsOptimizationWebServer()
     {
-        $s = '';
-
-        $sName = 'expires_module';
-        $sApacheModuleChack = _t('_sys_audit_msg_optimization_apache_module', $sName, $this->getMsgHTML($sName, $this->checkApacheModule($sName)));
-        $s .= $this->getBlock(_t('_sys_audit_userside_caching'), '', _t('_sys_audit_msg_userside_caching', $this->getUrlForGooglePageSpeed('LeverageBrowserCaching'), $sApacheModuleChack));
+        $aExpires = $this->checkApacheModule('expires_module');
+        $aDeflate = $this->checkApacheModule('deflate_module');
 
-        $sName = 'deflate_module';
-        $sApacheModuleChack = _t('_sys_audit_msg_optimization_apache_module', $sName, $this->getMsgHTML($sName, $this->checkApacheModule($sName)));
-        $s .= $this->getBlock(_t('_sys_audit_serverside_compression'), '', _t('_sys_audit_msg_serverside_compression', $sApacheModuleChack));
-
-        echo $this->getSection(_t('_sys_audit_section_webserver'), '', $s);
+        return array(
+            $this->getRow(_t('_sys_audit_userside_caching'), 'mod_expires', $aExpires['type'], _t('_sys_audit_msg_userside_caching', $this->getUrlForGooglePageSpeed('LeverageBrowserCaching'))),
+            $this->getRow(_t('_sys_audit_serverside_compression'), 'mod_deflate', $aDeflate['type']),
+        );
     }
 
-    protected function optimizationScript()
+    protected function getRowsOptimizationScript()
     {
-        $s = '';
+        $aRows = array();
         foreach ($this->aOptimizationSettings as $sName => $a) {
-
             $sVal = ('always_on' == $a['enabled'] || getParam($a['enabled'])) ? 'On' : 'Off';
             if ($a['cache_engine'])
                 $sVal .= _t('_sys_audit_msg_x_based_cache_engine', getParam($a['cache_engine']));
 
             if ('always_on' != $a['enabled'] && !getParam($a['enabled']))
-                $aMessage = array('type' => BX_DOL_AUDIT_FAIL, 'msg' => _t('_sys_audit_msg_optimization_fail'));
+                $aRows[] = $this->getRow($sName, $sVal, BX_DOL_AUDIT_FAIL, _t('_sys_audit_msg_optimization_fail'));
             elseif ($a['check_accel'] && !$this->getPhpAccelerator() && 'File' == getParam($a['cache_engine']))
-                $aMessage = array('type' => BX_DOL_AUDIT_WARN, 'msg' => _t('_sys_audit_msg_optimization_warn'));
+                $aRows[] = $this->getRow($sName, $sVal, BX_DOL_AUDIT_WARN, _t('_sys_audit_msg_optimization_warn'));
             else
-                $aMessage = array('type' => BX_DOL_AUDIT_OK);
-
-            $s .= $this->getBlock($sName, $sVal, $this->getMsgHTML($sName, $aMessage));
+                $aRows[] = $this->getRow($sName, $sVal, BX_DOL_AUDIT_OK);
         }
-
-        echo $this->getSection('UNA', '', $s);
+        return $aRows;
     }
 
-    protected function optimizationCache()
+    protected function getRowsOptimizationCache()
     {
-        $a = explode(',', 'File,APC,Memcache,Memcached,Redis');
-        $s = '';
-        foreach ($a as $sName) {
-
+        $aRows = array();
+        foreach (explode(',', 'File,APC,Memcache,Memcached,Redis') as $sName) {
             $sClass = 'BxDolCache' . $sName;
-            if (class_exists($sClass))
-                $o = new $sClass();
-
-            if ($o && $o->isInstalled() && $o->isAvailable()) {
-                $val = true;
-                $aMessage = array('type' => BX_DOL_AUDIT_OK);
-            } else {
-                $val = false;
-                $aMessage = array('type' => BX_DOL_AUDIT_FAIL);
-            }
-
-            $s .= $this->getBlock($sName, $this->format_output($val, ['type' => 'bool' ]), $this->getMsgHTML($sName, $aMessage));
+            $o = class_exists($sClass) ? new $sClass() : null;
+            $bAvailable = $o && $o->isInstalled() && $o->isAvailable();
+            $aRows[] = $this->getRow($sName, $this->format_output($bAvailable, array('type' => 'bool')), $bAvailable ? BX_DOL_AUDIT_OK : BX_DOL_AUDIT_FAIL);
         }
-
-        echo $this->getSection('Cache engines', '', $s);
-    }
-
-    protected function manualCheck()
-    {
-        echo '<a name="manual_audit"></a>';
-        echo '<h1>' . _t('_sys_audit_header_manual_audit') . '</h1>';
-        echo _t('_sys_audit_msg_manual_audit');
+        return $aRows;
     }
 
     protected function checkPhpSetting($sName, $a)
@@ -707,7 +882,7 @@ class BxDolStudioToolsAudit extends BxDol
     {
         $s = '';
         $s .= '<b class="' . $this->aType2ClassCSS[$a['type']]. '">' . $this->aType2Title[$a['type']]. '</b> ';
-        if (isset($a['msg']))
+        if (!empty($a['msg']))
             $s .= '(' . $a['msg'] . ')';
         return $s;
     }

+ 3 - 0
studio/classes/BxDolStudioWidget.php

@@ -37,6 +37,7 @@ class BxDolStudioWidget extends BxTemplStudioPage
                 'type' => 'switcher',
                 'name' => 'bookmark',
                 'caption' => '_adm_txt_pca_bookmark',
+                'icon' => 'pin',
                 'checked' => $this->aPage['bookmark'],
                 'onchange' => "javascript:" . $sJsObjectLauncher . ".bookmark('" . $this->aPage['name'] . "', this)"
             ));
@@ -46,6 +47,7 @@ class BxDolStudioWidget extends BxTemplStudioPage
                     'type' => 'switcher',
                     'name' => 'featured',
                     'caption' => '_adm_txt_pca_featured',
+                    'icon' => 'users',
                     'checked' => $this->aPage['featured'],
                     'onchange' => "javascript:" . $sJsObjectLauncher . ".featured('" . $this->aPage['name'] . "', this)"
                 ));
@@ -54,6 +56,7 @@ class BxDolStudioWidget extends BxTemplStudioPage
                 'type' => 'select',
                 'name' => 'rearrange',
                 'caption' => '_adm_txt_pca_rearrange',
+                'icon' => 'folder-input',
                 'value' => '{widget_type}',
                 'values' => BxDolFormQuery::getDataItems($this->_sTypesPreList),
                 'onchange' => "javascript:" . $sJsObjectLauncher . ".rearrange({widget_id}, this)"

+ 279 - 0
studio/js/context_menu.js

@@ -0,0 +1,279 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaStudio UNA Studio
+ * @{
+ */
+
+/**
+ * Context menus. An element with data-bx-context-menu="#popup" opens that popup (a transBox holding a [role="menu"] list,
+ * see studio/template/menu_context.html) on right-click, on the keyboard's Menu key or Shift+F10 (both fire 'contextmenu')
+ * and on a long press on a touch screen; open(oTrigger) opens it from a click on a visible button. The popup plugin
+ * handles the outside click, Escape and the fade; this adds the ARIA menu behaviour: focus lands on the first item,
+ * arrows, Home and End move, Tab leaves, checkbox and radio items keep their state, and focus returns to the trigger.
+ */
+function BxDolStudioContextMenu() {
+    this.sAttr = 'data-bx-context-menu';
+    this.sItems = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
+    this.iPressDelay = 500;
+    this.iGap = 4; // between the trigger and the menu
+
+    this.oTrigger = null;
+    this.oPopup = null;
+    this._iPressTimer = 0;
+    this._iPressedAt = 0;
+
+    this.init();
+}
+
+BxDolStudioContextMenu.prototype.init = function() {
+    var $this = this;
+
+    document.addEventListener('contextmenu', function(oEvent) {
+        var oTrigger = oEvent.target.closest('[' + $this.sAttr + ']');
+        if(!oTrigger)
+            return;
+
+        oEvent.preventDefault();
+        if(Date.now() - $this._iPressedAt < 800)
+            return; // the long press has already opened it (Android fires contextmenu as well)
+
+        $this.open(oTrigger);
+    });
+
+    //--- Touch: a long press (iOS never fires contextmenu), cancelled by lifting or moving the finger.
+    document.addEventListener('pointerdown', function(oEvent) {
+        if(oEvent.pointerType != 'touch')
+            return;
+
+        var oTrigger = oEvent.target.closest('[' + $this.sAttr + ']');
+        if(!oTrigger)
+            return;
+
+        clearTimeout($this._iPressTimer);
+        $this._iPressTimer = setTimeout(function() {
+            $this._iPressedAt = Date.now();
+            $this.open(oTrigger);
+        }, $this.iPressDelay);
+    });
+    ['pointerup', 'pointercancel', 'pointermove'].forEach(function(sEvent) {
+        document.addEventListener(sEvent, function() {
+            clearTimeout($this._iPressTimer);
+        });
+    });
+    document.addEventListener('click', function(oEvent) {
+        // the tap that ends a long press must not follow the trigger's link
+        if(Date.now() - $this._iPressedAt < 800 && oEvent.target.closest('[' + $this.sAttr + ']'))
+            oEvent.preventDefault();
+    }, true);
+
+    //--- Inside the menu: arrows move between the items, Tab or Escape leaves, an activated item closes it.
+    document.addEventListener('keydown', function(oEvent) {
+        var oMenu = oEvent.target.closest('[role="menu"]');
+        if(!oMenu)
+            return;
+
+        var aItems = $this.getItems(oMenu);
+        var iIndex = aItems.indexOf(document.activeElement);
+        var iCount = aItems.length;
+        switch(oEvent.key) {
+            case 'ArrowDown':
+                aItems[(iIndex + 1) % iCount].focus();
+                break;
+            case 'ArrowUp':
+                aItems[(iIndex - 1 + iCount) % iCount].focus();
+                break;
+            case 'Home':
+                aItems[0].focus();
+                break;
+            case 'End':
+                aItems[iCount - 1].focus();
+                break;
+            case 'Tab':
+            case 'Escape':
+                $this.close();
+                break;
+            default:
+                return;
+        }
+
+        oEvent.preventDefault();
+    });
+    document.addEventListener('click', function(oEvent) {
+        var oItem = oEvent.target.closest($this.sItems);
+        if(!oItem || !oItem.closest('[role="menu"]'))
+            return;
+
+        var sRole = oItem.getAttribute('role');
+        if(sRole == 'menuitemcheckbox')
+            oItem.setAttribute('aria-checked', oItem.getAttribute('aria-checked') == 'true' ? 'false' : 'true');
+        else if(sRole == 'menuitemradio') {
+            $this.getItems(oItem.closest('[role="menu"]')).forEach(function(oOther) {
+                if(oOther.getAttribute('role') == 'menuitemradio')
+                    oOther.setAttribute('aria-checked', 'false');
+            });
+            oItem.setAttribute('aria-checked', 'true');
+        }
+
+        $this.close();
+    });
+};
+
+/**
+ * Open a trigger's menu next to it, however it was asked for (pointer, keyboard, long press, a button's click): 4px under
+ * the trigger, flush with its start edge; fit() moves it above, or flush with the end edge, when the window runs out.
+ */
+BxDolStudioContextMenu.prototype.open = function(oTrigger) {
+    var $this = this;
+
+    var oPopup = $(oTrigger.getAttribute(this.sAttr));
+    if(!oPopup.length)
+        return this.load(oTrigger);
+
+    // another menu is up: it goes first
+    if(this.oPopup && this.oPopup.get(0) !== oPopup.get(0) && this.oPopup.is(':visible'))
+        this.oPopup.dolPopupHide();
+
+    this.oTrigger = oTrigger;
+    this.oPopup = oPopup;
+
+    var oAnchor = this.getAnchor(oTrigger);
+
+    if(oPopup.is(':visible')) {
+        // still fading out, or asked to show a moment ago: the plugin would toggle it, so come back when that is over
+        if(!oPopup.hasClass('bx-popup-active')) {
+            setTimeout(function() {
+                $this.open(oTrigger);
+            }, 250);
+            return false;
+        }
+
+        // the same menu asked for again: it just settles next to its trigger
+        oPopup._dolPopupSetPosition({position: 'absolute', left: oAnchor.x, top: oAnchor.y, pointer: false});
+        this.fit(oPopup, oTrigger);
+        this.focusFirst(oPopup);
+        return false;
+    }
+
+    oPopup.dolPopup({
+        position: 'absolute',
+        left: oAnchor.x,
+        top: oAnchor.y,
+        pointer: false,
+        fog: false,
+        closeOnOuterClick: true,
+        cssClass: 'bx-popup-menu',
+        onBeforeShow: function(oShowing) {
+            $this.fit(oShowing, $this.oTrigger); // laid out but still invisible here: no jump when it has to flip
+        },
+        onShow: function(oShown) {
+            $this.fit(oShown, $this.oTrigger);
+            if($this.oTrigger.hasAttribute('aria-expanded'))
+                $this.oTrigger.setAttribute('aria-expanded', 'true');
+            $this.focusFirst(oShown);
+        },
+        onHide: function(oHidden) {
+            if($this.oTrigger.hasAttribute('aria-expanded'))
+                $this.oTrigger.setAttribute('aria-expanded', 'false');
+
+            // focus went nowhere in particular (the item that was activated is gone from view): back to the trigger
+            if(document.activeElement == document.body || oHidden.get(0).contains(document.activeElement))
+                $this.oTrigger.focus();
+        }
+    });
+
+    return false;
+};
+
+/**
+ * A menu that is not on the page yet (launcher tiles, dock items name theirs in data-bx-context-menu-load as "page:tileId"):
+ * fetch it from the page's module endpoint, keep it in the document, then open it.
+ */
+BxDolStudioContextMenu.prototype.load = function(oTrigger) {
+    var $this = this;
+
+    var sLoad = oTrigger.getAttribute(this.sAttr + '-load');
+    if(!sLoad || this._bLoading)
+        return false;
+
+    var aLoad = sLoad.split(':');
+    this._bLoading = true;
+    $.get(sUrlStudio + 'module.php', {mod_action: 'context', mod_value: aLoad[0], mod_widget_id: parseInt(aLoad[1]) || 0, _t: Date.now()}, function(oData) {
+        if(!oData || oData.code != 0 || !oData.content)
+            return;
+
+        $('body').append(oData.content);
+        $this.open(oTrigger);
+    }, 'json').always(function() {
+        $this._bLoading = false;
+    });
+
+    return false;
+};
+
+/**
+ * Whether a long press opened a menu just now (the launcher's own long press then leaves edit mode alone).
+ */
+BxDolStudioContextMenu.prototype.justOpened = function() {
+    return Date.now() - this._iPressedAt < 1500;
+};
+
+/**
+ * Where the menu goes first: 4px under the trigger, flush with its start edge (page coordinates). In RTL the start edge is
+ * the right one, which fit() lines the menu up with once its width is known.
+ */
+BxDolStudioContextMenu.prototype.getAnchor = function(oTrigger) {
+    var oRect = oTrigger.getBoundingClientRect();
+    return {x: oRect.left + window.scrollX, y: oRect.bottom + window.scrollY + this.iGap};
+};
+
+BxDolStudioContextMenu.prototype.isRtl = function() {
+    return document.documentElement.dir == 'rtl' || document.body.classList.contains('bx-dir-rtl');
+};
+
+/**
+ * Keep the menu on screen and off its trigger: flush with the trigger's end edge when it would run past the side of the
+ * window (start edge in RTL), 4px above the trigger when it would run past the bottom.
+ */
+BxDolStudioContextMenu.prototype.fit = function(oPopup, oTrigger) {
+    // layout size, not the rendered box: the popup is scaled while it fades in, and this may run mid-fade
+    var iWidth = oPopup.get(0).offsetWidth;
+    var iHeight = oPopup.get(0).offsetHeight;
+    var oTriggerRect = oTrigger.getBoundingClientRect();
+    var oCss = {};
+
+    var iLeft = this.isRtl() ? oTriggerRect.right - iWidth : oTriggerRect.left;
+    if(iLeft + iWidth > window.innerWidth - 8)
+        iLeft = oTriggerRect.right - iWidth;
+    if(iLeft < 8)
+        iLeft = this.isRtl() ? oTriggerRect.left : 8;
+    oCss.left = Math.round(iLeft + window.scrollX);
+
+    oCss.top = Math.round(oTriggerRect.bottom + this.iGap + window.scrollY);
+    if(oTriggerRect.bottom + this.iGap + iHeight > window.innerHeight - 8 && oTriggerRect.top - this.iGap - iHeight >= 8)
+        oCss.top = Math.round(oTriggerRect.top - this.iGap - iHeight + window.scrollY);
+
+    oPopup.css(oCss);
+};
+
+BxDolStudioContextMenu.prototype.focusFirst = function(oPopup) {
+    var aItems = this.getItems(oPopup.get(0));
+    if(aItems.length)
+        aItems[0].focus();
+};
+
+BxDolStudioContextMenu.prototype.getItems = function(oMenu) {
+    return Array.prototype.filter.call(oMenu.querySelectorAll(this.sItems), function(oItem) {
+        return !oItem.disabled && oItem.getAttribute('aria-disabled') != 'true';
+    });
+};
+
+BxDolStudioContextMenu.prototype.close = function() {
+    if(this.oPopup && this.oPopup.is(':visible'))
+        this.oPopup.dolPopupHide();
+};
+
+var oBxDolStudioContextMenu = new BxDolStudioContextMenu();
+
+/** @} */

+ 610 - 89
studio/js/dashboard.js

@@ -7,62 +7,193 @@
  */
 function BxDolStudioDashboard(oOptions) {
 	this.sActionsUrl = oOptions.sActionUrl;
+    this.aStatusTitles = oOptions.aStatusTitles || {}; // ok / warn / fail / undef, as words
     this.sObjName = oOptions.sObjName == undefined ? 'oBxDolStudioDashboard' : oOptions.sObjName;
     this.sAnimationEffect = oOptions.sAnimationEffect == undefined ? 'fade' : oOptions.sAnimationEffect;
     this.iAnimationSpeed = oOptions.iAnimationSpeed == undefined ? 'slow' : oOptions.iAnimationSpeed;
 
-    this.oData = [];
-
     var $this = this;
     $(document).ready(function() {
+        // one polite live region for everything the page finishes asynchronously (see announce)
+        $('<div id="bx-dbd-status" class="sr-only" role="status" aria-live="polite"></div>').appendTo($('#bx-std-page-content').first().length ? '#bx-std-page-content' : 'body');
     	$('.bx-dbd-block-content').bxProcessHtml();
     });
 }
 
+/**
+ * Polite live region of the page; cleared first so the same text is announced again.
+ */
+BxDolStudioDashboard.prototype.announce = function(sText) {
+    var oStatus = $('#bx-dbd-status');
+    if(!oStatus.length || !sText)
+        return;
+
+    oStatus.text('');
+    setTimeout(function() {
+        oStatus.text(sText);
+    }, 50);
+};
+
+/**
+ * Block busy state while it fetches (aria-busy on .bx-std-block). The visible indicator is the pressed button's (loadingButton); a fetch
+ * nothing was pressed for (the Server block's first load, a tab's first opening) shows the same wave inside the panel it fills (getLoader).
+ */
+BxDolStudioDashboard.prototype.loading = function(sDivId, bShow, oButton) {
+    $('#' + sDivId).closest('.bx-std-block').attr('aria-busy', bShow ? 'true' : 'false');
+
+    if(oButton)
+        this.loadingButton(oButton, bShow);
+};
+
+/**
+ * Trigger button loader: the button is disabled and its label gives way to the launcher's tile wave (page.css .bx-std-btn-loading) at the
+ * button's width, so nothing moves; the label (and the button) comes back when the request ends. The label stays the accessible name meanwhile.
+ */
+BxDolStudioDashboard.prototype.loadingButton = function(oButton, bShow) {
+    oButton = $(oButton);
+    if(!oButton.length)
+        return;
+
+    if(bShow) {
+        if(oButton.data('bx-loading'))
+            return;
+
+        oButton.data('bx-loading', {html: oButton.html(), label: oButton.attr('aria-label')});
+        if(!oButton.attr('aria-label'))
+            oButton.attr('aria-label', oButton.text().trim());
+
+        oButton.css('min-width', oButton.outerWidth() + 'px').prop('disabled', true).attr('aria-disabled', 'true').addClass('bx-btn-disabled bx-btn-loading');
+        oButton.html('<span class="bx-std-btn-loading" aria-hidden="true"></span>');
+        return;
+    }
+
+    var oSaved = oButton.data('bx-loading');
+    if(!oSaved)
+        return;
+
+    oButton.removeData('bx-loading').css('min-width', '').prop('disabled', false).removeAttr('aria-disabled').removeClass('bx-btn-disabled bx-btn-loading').html(oSaved.html);
+    if(oSaved.label == undefined)
+        oButton.removeAttr('aria-label');
+};
+
+/**
+ * The tile wave centred in a panel whose content is on its way (dashboard.css .bx-dbd-loading).
+ */
+BxDolStudioDashboard.prototype.getLoader = function() {
+    return '<div class="bx-dbd-loading" aria-hidden="true"><span class="bx-std-btn-loading"></span></div>';
+};
+
+/**
+ * Updates block: ask the server for the latest versions and fill the status rows. Runs when the page opens and from the
+ * hero's "Check for updates" button, which shows the loader (and takes no clicks) while a check is in flight.
+ */
 BxDolStudioDashboard.prototype.checkForUpgrade = function() {
     var $this = this;
-    var oDate = new Date();
     var sDivId = 'bx-dbd-version';
+    var oButton = $('#' + sDivId + ' .bx-dbd-version-check');
 
-    bx_loading(sDivId, true);
+    $this.loading(sDivId, true, oButton);
 
     $.get(
         this.sActionsUrl,
         {
             dbd_action: 'check_for_upgrade',
-            _t: oDate.getTime()
+            _t: new Date().getTime()
         },
         function(oData) {
-            bx_loading(sDivId, false);
+            var oInfo = oData && oData.data ? oData.data : {};
+            $this.setVersionRow('stable', oInfo.stable);
+            $this.setVersionRow('beta', oInfo.beta);
+            $this.setVersionRow('apps', oInfo.apps);
+
+            // the rows changed silently: read them out, name, value and status
+            var aOut = [];
+            $('#' + sDivId + ' .bx-dbd-htools-item[id]').each(function() {
+                var sName = $(this).find('.bx-dbd-htools-name').contents().first().text().trim();
+                var sStatus = $(this).find('.bx-dbd-htools-status').text().trim();
+                aOut.push(sName + ' ' + $(this).find('.bx-dbd-htools-value').text().trim() + (sStatus ? ' (' + sStatus.replace(/^,\s*/, '') + ')' : ''));
+            });
+            $this.announce(aOut.join(', '));
 
-            if(!oData.data)
-                return;
+            // the Update button lives in the block caption, outside the block content
+            if(oInfo.upgrade != undefined && parseInt(oInfo.upgrade) == 1)
+                $('#' + sDivId).closest('.bx-std-block').find('.bx-dbd-version-update').show();
+        },
+        'json'
+    ).always(function() {
+        $this.loading(sDivId, false, oButton);
+    });
+};
+
+/**
+ * Updates block: fill a status row (Latest Stable, Latest Beta, Apps) from the update check: {status, name?, value}.
+ */
+BxDolStudioDashboard.prototype.setVersionRow = function(sRow, oRow) {
+    var oItem = $('#bx-dbd-version-' + sRow);
+    if(!oItem.length || !oRow)
+        return;
+
+    oItem.removeClass('bx-dbd-htools-item-ok bx-dbd-htools-item-warn bx-dbd-htools-item-fail bx-dbd-htools-item-undef').addClass('bx-dbd-htools-item-' + oRow.status);
+    if(oRow.name)
+        oItem.find('.bx-dbd-htools-name').contents().first().replaceWith(document.createTextNode(oRow.name));
+    oItem.find('.bx-dbd-htools-status').text(this.aStatusTitles[oRow.status] ? ', ' + this.aStatusTitles[oRow.status] : '');
+    oItem.find('.bx-dbd-htools-value').html(oRow.value);
+};
 
-            if(oData.data.version != undefined && oData.data.version.length > 0)
-                $('#' + sDivId + ' .bx-dbd-version-available b').html(oData.data.version).parents('.bx-dbd-version-available:hidden').show();
+/**
+ * Version block: an update switch changed (the site's switch script has already flipped the checkbox and the on/off
+ * classes); save the option and flip everything back if the save fails.
+ */
+BxDolStudioDashboard.prototype.toggleOption = function(sName, oInput) {
+    var $this = this;
+    var oSwitch = $(oInput).closest('.bx-switcher-cont');
+    var bOn = oInput.checked;
+    var fSet = function(bState) {
+        oInput.checked = bState;
+        oSwitch.toggleClass('on', bState).toggleClass('off', !bState);
+        oSwitch.find('[role="switch"]').attr('aria-checked', bState ? 'true' : 'false');
+    };
+
+    fSet(bOn);
+
+    $.post(
+        this.sActionsUrl,
+        {
+            dbd_action: 'set_option',
+            dbd_value: sName,
+            dbd_state: bOn ? 1 : 0,
+            _t: new Date().getTime()
+        },
+        function(oData) {
+            if(oData && oData.code == 0)
+                return;
 
-            if(oData.data.upgrade != undefined && parseInt(oData.data.upgrade) == 1)
-                $('#' + sDivId + ' .bx-dbd-block-actions').show()
+            fSet(!bOn);
+            if(oData && oData.message)
+                $this.popup(oData.message);
         },
         'json'
-    );
+    ).fail(function() {
+        fSet(!bOn);
+    });
 };
 
 BxDolStudioDashboard.prototype.performUpgrade = function() {
     var $this = this;
     var oDate = new Date();
     var sDivId = 'bx-dbd-version';
+    var oButton = $('#' + sDivId).closest('.bx-std-block').find('.bx-dbd-version-update'); // the caption's Update button
 
-    bx_loading(sDivId, true);
+    $this.loading(sDivId, true, oButton);
 
-    $.get(
+    $.post(
         this.sActionsUrl,
         {
             dbd_action: 'perform_upgrade',
             _t: oDate.getTime()
         },
         function(oData) {
-            bx_loading(sDivId, false);
+            $this.loading(sDivId, false, oButton);
 
             if(!oData.message)
                 return;
@@ -74,20 +205,75 @@ BxDolStudioDashboard.prototype.performUpgrade = function() {
 };
 
 BxDolStudioDashboard.prototype.checkHostParams = function() {
-    var sType = 'htools';
-    var sDivId = 'bx-dbd-' + sType;
+    var $this = this;
 
-    this.getBlockContent(sType, function(oData) {
-        if(!oData.data)
-            return;
+    $('#bx-dbd-htools').html(this.getLoader());
+    this.getBlockContent('htools', function(oData) {
+        if(oData.data)
+            $this.replaceHostTools(oData.data);
+    });
+};
 
-        $('#' + sDivId).replaceWith(oData.data);
-        $('#' + sDivId + ' .bx-dbd-block-actions').show();
+/**
+ * Server block: swap in freshly rendered content (the Status tab shown, the Audit tab to be loaded again) and let the tabs be used.
+ */
+BxDolStudioDashboard.prototype.replaceHostTools = function(sContent) {
+    var sDivId = 'bx-dbd-htools';
+
+    $('#' + sDivId).replaceWith(sContent);
+
+    var oBlock = $('#' + sDivId).bxProcessHtml().closest('.bx-std-block').attr('aria-busy', 'false');
+
+    // the tabs stayed usable while the block loaded: show whichever one is selected (the fresh panels open on Status)
+    var oSelected = oBlock.find('[role="tablist"] [role="tab"][aria-selected="true"]');
+    if(oSelected.length)
+        this.hostToolsTab(oSelected.attr('aria-controls').replace(sDivId + '-', ''), oSelected);
+
+    // the gauges arrived silently: read them out
+    var aOut = [];
+    $('#' + sDivId + ' .bx-dbd-gauge-ring[aria-label]').each(function() {
+        aOut.push($(this).attr('aria-label'));
     });
+    this.announce(aOut.join(', '));
 };
 
-BxDolStudioDashboard.prototype.clearCacheSelect = function(oElement) {
-    bx_menu_popup_inline('#bx-std-cc-select-popup', oElement, {moveToDocRoot: false});
+/**
+ * Server block, Audit tab: run the audit again, redraw the block from the new report and come back to the Audit tab.
+ */
+BxDolStudioDashboard.prototype.runAudit = function(oButton) {
+    var $this = this;
+    var sDivId = 'bx-dbd-htools';
+    oButton = $(oButton);
+
+    $this.loading(sDivId, true, oButton);
+
+    $.post(
+        this.sActionsUrl,
+        {
+            dbd_action: 'run_audit'
+        },
+        function(oData) {
+            if(!oData || !oData.data)
+                return;
+
+            $this.replaceHostTools(oData.data);
+            $this.hostToolsTab('audit');
+        },
+        'json'
+    ).always(function() {
+        // the block is either replaced (the button with it) or left as it was, with its button usable again
+        $this.loading(sDivId, false, oButton);
+    });
+};
+
+/**
+ * Server block, Audit tab: fold or unfold a section's rows.
+ */
+BxDolStudioDashboard.prototype.auditToggle = function(oButton) {
+    var bOpen = $(oButton).attr('aria-expanded') != 'true';
+
+    $(oButton).attr('aria-expanded', bOpen ? 'true' : 'false');
+    $('#' + $(oButton).attr('aria-controls')).prop('hidden', !bOpen);
 };
 
 BxDolStudioDashboard.prototype.getBlockContent = function(sType, onComplete) {
@@ -95,7 +281,7 @@ BxDolStudioDashboard.prototype.getBlockContent = function(sType, onComplete) {
     var oDate = new Date();
     var sDivId = 'bx-dbd-' + sType;
 
-    bx_loading(sDivId, true);
+    $this.loading(sDivId, true);
 
     $.get(
         this.sActionsUrl,
@@ -105,7 +291,7 @@ BxDolStudioDashboard.prototype.getBlockContent = function(sType, onComplete) {
             _t: oDate.getTime()
         },
         function(oData) {
-            bx_loading(sDivId, false);
+            $this.loading(sDivId, false);
 
             if(typeof onComplete == 'function')
                 return onComplete(oData);
@@ -120,112 +306,447 @@ BxDolStudioDashboard.prototype.getBlockContent = function(sType, onComplete) {
 };
 
 BxDolStudioDashboard.prototype.initChart = function(sType, oData) {
-	var $this = this;
-	var sDivId = 'bx-dbd-' + sType;
-
-	bx_loading(sDivId, true);
-	google.load("visualization", "1", {packages:["corechart"], callback: function() {
-    	bx_loading(sDivId, false);
-    	$this.showChart(sType, oData);
-    }});
+    this.showChart(sType, oData);
 };
 
+/**
+ * Share chart drawn in place, no library: a hero panel with the "used" headline (and the capacity when known) over a stacked
+ * bar with one slice per item, then a row per item with its icon, size, colour mark and, when the item can be cleared on its
+ * own, a trash button.
+ * oData is {items: [{name, bytes, size, icon: {url|svg}, type?, clear?, note?, note_alert?, url?}], used, used_bytes, total, total_bytes,
+ * total_fail?, icon_clear?, bar?, action?}. With bar: false (the Queues block) it is a plain list: the headline and the
+ * rows, without the bar and the colour marks; a row's note sits before its value (in red with note_alert), a row with a url
+ * is a link to the page that manages it, and action names the method a row's clear button calls (clearCache by default).
+ * A chart with a label (the Cache block) gets a compact label / stat line over its bar instead of the big headline, top
+ * names that many of its largest items under the bar (top_zero lists items at zero too), and clear_all puts a "clear all" button under it. Several charts at once (the Space block:
+ * local and remote storage) come as {charts: [{label, items, used, total, ...}], groups: [{items}]}: the hero stacks a
+ * labelled chart per entry, the list shows one group of rows per chart, separated by a hairline; a row marked placeholder
+ * has no colour mark.
+ */
 BxDolStudioDashboard.prototype.showChart = function(sType, oData) {
-	var oChart = $('#bx-dbd-' + sType + ' .bx-dbd-chart');
-	oChart.html('');
-
-    var oDataTable = new google.visualization.DataTable();
-    oDataTable.addColumn('string', 'Label');
-    oDataTable.addColumn('number', 'Size');   
-
-    if(oData != undefined)
-		this.oData = oData;
-    oDataTable.addRows(this.oData);
-
-    var oChart = new google.visualization.PieChart(oChart[0]);
-    oChart.draw(oDataTable, {
-		chartArea: {
-			left:10,
-			top:10,
-			width:'92%',
-			height:'92%'
-		}
+    var $this = this;
+    var oChart = $('#bx-dbd-' + sType + ' .bx-dbd-chart');
+    if(!oChart.length || !oData)
+        return;
+
+    var aCharts = oData.charts || [oData];
+    var aGroups = oData.groups || [{items: oData.items || []}];
+    var bRows = aCharts.length > 1 || !!aCharts[0].label;
+    var bBar = oData.bar !== false;
+    var sClear = oData.action || 'clearCache';
+    var aColors = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f97316', '#ec4899', '#84cc16', '#06b6d4', '#f59e0b', '#ef4444', '#9ca3af'];
+    var fAttr = function(s) { return String(s).replace(/"/g, '&quot;'); };
+
+    var sHero = '';
+    for(var c = 0; c < aCharts.length; c++) {
+        var oC = aCharts[c];
+        var aItems = oC.items || [];
+        var fBase = oC.total_bytes > 0 ? oC.total_bytes : oC.used_bytes;
+
+        var sSlices = '', sLabel = '';
+        for(var i = 0; i < aItems.length; i++) {
+            var oItem = aItems[i];
+            var fShare = fBase > 0 ? oItem.bytes / fBase * 100 : 0;
+
+            // Each slice names its item in the tooltip below.
+            if(bBar && oItem.bytes > 0)
+                sSlices += '<span class="bx-dbd-chart-slice" style="width:' + fShare.toFixed(2) + '%;background:' + aColors[i % aColors.length] + '" data-label="' + fAttr(oItem.name + ': ' + oItem.size) + '"></span>';
+
+            sLabel += (sLabel ? ', ' : '') + oItem.name + ' ' + oItem.size;
+        }
+
+        // An unlabelled chart: the big headline. Labelled: a compact label / stat line over the bar.
+        var sHead = bRows
+            ? '<div class="bx-dbd-chart-head bx-dbd-chart-head-sm"><span class="bx-dbd-chart-label">' + oC.label + '</span><span class="bx-dbd-chart-stat">' + oC.used + (oC.total ? ' &middot; ' + oC.total : '') + '</span></div>'
+            : '<div class="bx-dbd-chart-head"><span class="bx-dbd-chart-used">' + oC.used + '</span>' + (oC.total ? '<span class="bx-dbd-chart-total' + (oC.total_fail ? ' bx-dbd-chart-total-fail' : '') + '">' + oC.total + '</span>' : '') + '</div>';
+        var sBar = bBar ? '<div class="bx-dbd-chart-bar" role="img" aria-label="' + fAttr((oC.label ? oC.label + ': ' : '') + (sLabel || oC.used)) + '">' + sSlices + '</div>' : '';
+
+        // The largest items, biggest first (the Cache block lists its caches in a fixed order), written under the bar like the audit's outcome counts.
+        var aTop = [];
+        var aBySize = oC.top ? aItems.slice().sort(function(a, b) { return b.bytes - a.bytes; }) : [];
+        for(var j = 0; j < aBySize.length && aTop.length < oC.top; j++)
+            if(aBySize[j].bytes > 0 || oC.top_zero)
+                aTop.push(aBySize[j].name + ' ' + aBySize[j].size);
+        var sSummary = aTop.length ? '<div class="bx-dbd-chart-summary">' + aTop.join(' &middot; ') + '</div>' : '';
+
+        sHero += bRows ? '<div class="bx-dbd-chart-row">' + sHead + sBar + sSummary + '</div>' : sHead + sBar + sSummary;
+    }
+
+    // The "clear all" button follows the chart in a foot row.
+    if(oData.clear_all)
+        sHero += '<div class="bx-dbd-chart-foot"><button type="button" class="bx-btn bx-dbd-chart-clear-all">' + oData.clear_all + '</button></div>';
+
+    var sList = '';
+    for(var g = 0; g < aGroups.length; g++) {
+        var aRows = aGroups[g].items || [];
+
+        var sRows = '';
+        for(var i = 0; i < aRows.length; i++) {
+            var oRow = aRows[i];
+
+            // An app's own icon, or a Lucide glyph on the same gray tile the block headers use; both carry the tile's hairline ring.
+            var sIcon = '';
+            if(oRow.icon && oRow.icon.url)
+                sIcon = '<span class="bx-dbd-tile bx-dbd-tile-app" aria-hidden="true"><img src="' + oRow.icon.url + '" alt=""></span>';
+            else
+                sIcon = '<span class="bx-dbd-tile" aria-hidden="true">' + (oRow.icon && oRow.icon.svg ? oRow.icon.svg : '') + '</span>';
+
+            // A row that can be cleared on its own ends with an icon-only trash button (the label is its accessible name).
+            var sAction = '';
+            if(oRow.clear && oRow.type && oData.icon_clear && oData.icon_clear.svg)
+                sAction = '<button type="button" class="bx-btn bx-dbd-chart-action" data-type="' + oRow.type + '" title="' + fAttr(oRow.clear) + '" aria-label="' + fAttr(oRow.clear) + '">' + oData.icon_clear.svg + '</button>';
+
+            // A mark in the row's bar colour, right after the value, ties the row to its slice; a note (a file count, or failed jobs) sits before the value.
+            var sMark = bBar && !oRow.placeholder ? '<span class="bx-dbd-chart-mark" style="background:' + aColors[i % aColors.length] + '" aria-hidden="true"></span>' : '';
+            var sNote = oRow.note ? '<span class="bx-dbd-chart-note' + (oRow.note_alert ? ' bx-dbd-chart-note-alert' : '') + '">' + oRow.note + '</span>' : '';
+            var sCells = sIcon + '<span class="bx-dbd-chart-name">' + oRow.name + '</span>' + sNote + '<span class="bx-dbd-chart-size">' + oRow.size + '</span>' + sMark + sAction;
+            sRows += oRow.url ? '<a class="bx-dbd-chart-item bx-dbd-chart-link" href="' + fAttr(oRow.url) + '">' + sCells + '</a>' : '<div class="bx-dbd-chart-item">' + sCells + '</div>';
+        }
+
+        sList += '<div class="bx-dbd-chart-group">' + sRows + '</div>';
+    }
+
+    oChart.html(
+        '<div class="bx-dbd-hero bx-dbd-chart-hero' + (bRows ? ' bx-dbd-chart-hero-rows' : '') + '">' + sHero + '</div>' +
+        '<div class="bx-dbd-chart-list">' + sList + '</div>'
+    );
+
+    this.bindChartTips(oChart);
+
+    if(typeof $this[sClear] != 'function')
+        return;
+
+    oChart.find('.bx-dbd-chart-action').on('click', function() {
+        $this[sClear]($(this).data('type'), this);
+        return false;
+    });
+    oChart.find('.bx-dbd-chart-clear-all').on('click', function() {
+        $this[sClear]('all', this);
+        return false;
     });
 };
 
-BxDolStudioDashboard.prototype.clearCache = function(sType) {
-	var $this = this;
-	var oDate = new Date();
-	var sDivId = 'bx-dbd-cache';
+/**
+ * Hovering a bar slice shows its data-label above the bar, in a tip that lives in the hero (the bar clips to its rounded
+ * corners). Binds every bar under oScope: a chart block's, or the Audit tab's outcome bar.
+ */
+BxDolStudioDashboard.prototype.bindChartTips = function(oScope) {
+    var oHero = oScope.find('.bx-dbd-chart-hero');
+    if(!oHero.length)
+        return;
+
+    var oTip = $('<div class="bx-dbd-chart-tip" role="tooltip" hidden></div>').appendTo(oHero);
+    oScope.find('.bx-dbd-chart-slice').on('mouseenter', function() {
+        var oSlice = $(this);
+        var oBar = oSlice.parent();
+        oTip.text(oSlice.data('label')).prop('hidden', false);
+
+        var iHalf = oTip.outerWidth() / 2;
+        var iLeft = oSlice.offset().left - oHero.offset().left + oSlice.outerWidth() / 2;
+        oTip.css({
+            left: Math.min(Math.max(iLeft, iHalf), oHero.innerWidth() - iHalf),
+            top: oBar.offset().top - oHero.offset().top - oTip.outerHeight() - 6
+        });
+    }).on('mouseleave', function() {
+        oTip.prop('hidden', true);
+    });
+};
 
-	$('#' + sDivId).parents('.bx-page-block-container').find('.bx-db-header .bx-popup-applied:visible').dolPopupHide();
+/**
+ * Clear one item (or 'all') of a chart block and redraw it from the returned data; a string comes back when the block has nothing left to chart.
+ */
+BxDolStudioDashboard.prototype.clearChartItem = function(sBlock, sAction, sType, oButton) {
+    var $this = this;
+    var oDate = new Date();
+    var sDivId = 'bx-dbd-' + sBlock;
 
-	bx_loading(sDivId, true);
+    $this.loading(sDivId, true, oButton);
 
     $.post(
-    	sUrlStudio + 'dashboard.php', 
+        this.sActionsUrl,
         {
-    		dbd_action: 'clear_cache',
-    		dbd_value: sType,
+            dbd_action: sAction,
+            dbd_value: sType,
             _t: oDate.getTime()
-        }, 
+        },
         function(oData) {
-        	bx_loading(sDivId, false);
+            $this.loading(sDivId, false, oButton);
 
-        	if(oData.message != undefined && oData.message.length > 0)
-    			$this.popup(oData.message);
+            if(oData.message != undefined && oData.message.length > 0)
+                $this.popup(oData.message, function() {
+                    // the control that was used is redrawn with the chart: hand focus to the block's first control
+                    return $('#' + sDivId).find('button, a[href]').first().get(0);
+                });
 
-            if(oData.data != undefined) {
-            	if(typeof oData.data == 'object')
-            		$this.showChart('cache', oData.data);
-            	else if(typeof oData.data == 'string')
-            		$('#' + sDivId).html(oData.data);
-            }
+            if(oData.data == undefined)
+                return;
+
+            if(typeof oData.data == 'object')
+                $this.showChart(sBlock, oData.data);
+            else if(typeof oData.data == 'string')
+                $('#' + sDivId).html(oData.data);
         },
         'json'
     );
 };
 
-BxDolStudioDashboard.prototype.permissions = function() {
-    this.hostTools('permissions');
+BxDolStudioDashboard.prototype.clearCache = function(sType, oButton) {
+    this.clearChartItem('cache', 'clear_cache', sType, oButton);
 };
 
-BxDolStudioDashboard.prototype.serverAudit = function() {
-    this.hostTools('server_audit');
+BxDolStudioDashboard.prototype.clearQueue = function(sType, oButton) {
+    this.clearChartItem('queues', 'clear_queue', sType, oButton);
 };
 
-BxDolStudioDashboard.prototype.hostTools = function(sAction) {
+/**
+ * Server block: show one tab's panel. Audit and permissions panels fetch their report the first time they open.
+ */
+BxDolStudioDashboard.prototype.hostToolsTab = function(sTab, oTab) {
     var $this = this;
-    var oDate = new Date();
     var sDivId = 'bx-dbd-htools';
+    var oBlock = $('#' + sDivId).closest('.bx-std-block');
+    var oPanel = $('#' + sDivId + '-' + sTab);
+    if(!oPanel.length)
+        return;
+
+    oTab = oTab ? $(oTab) : $('#' + sDivId + '-tab-' + sTab);
+    oBlock.find('[role="tablist"] [role="tab"]').attr({'aria-selected': 'false', 'tabindex': '-1'});
+    oTab.attr({'aria-selected': 'true', 'tabindex': '0'});
+
+    oBlock.find('.bx-dbd-htools-panel').prop('hidden', true);
+    oPanel.prop('hidden', false);
+
+    var sAction = oPanel.data('action');
+    if(!sAction || oPanel.data('loaded'))
+        return;
 
-    bx_loading(sDivId, true);
+    oPanel.data('loaded', true).html($this.getLoader());
+    $this.loading(sDivId, true);
 
     $.get(
         this.sActionsUrl,
         {
             dbd_action: sAction,
-            _t: oDate.getTime()
+            _t: new Date().getTime()
         },
         function(sData) {
-            bx_loading(sDivId, false);
+            $this.loading(sDivId, false);
 
-            if(!sData.length)
+            if(!sData.length) {
+                oPanel.data('loaded', false).empty();
                 return;
+            }
 
-            $('#' + sDivId + ' .bx-dbd-block-content').hide().html(sData).bx_anim('show', 'fade', 'slow', function() {
-                $(this).bxProcessHtml();
-            });
+            oPanel.html(sData).bxProcessHtml();
+            $this.bindChartTips(oPanel);
         },
         'html'
-    );
+    ).fail(function() {
+        // the tab can be tried again after a failed request
+        $this.loading(sDivId, false);
+        oPanel.data('loaded', false).empty();
+    });
 };
 
-BxDolStudioDashboard.prototype.popup = function(sValue) {
+/**
+ * Arrow keys move between the Server block's tabs, as a tablist expects.
+ */
+BxDolStudioDashboard.prototype.hostToolsTabKey = function(oEvent, oTab) {
+    var iStep = oEvent.key == 'ArrowRight' ? 1 : (oEvent.key == 'ArrowLeft' ? -1 : 0);
+    var bEdge = oEvent.key == 'Home' || oEvent.key == 'End';
+    if(!iStep && !bEdge)
+        return;
+
+    var oTabs = $(oTab).closest('[role="tablist"]').find('[role="tab"]');
+    var oNext = bEdge ? oTabs.eq(oEvent.key == 'Home' ? 0 : oTabs.length - 1) : $(oTabs.get((oTabs.index(oTab) + iStep + oTabs.length) % oTabs.length));
+
+    oEvent.preventDefault();
+    oNext.focus();
+    this.hostToolsTab(oNext.attr('aria-controls').replace('bx-dbd-htools-', ''), oNext);
+};
+
+/**
+ * A message in a modal popup. The dialog is named by its text and takes focus, so it is read out and Escape closes it;
+ * afterwards focus goes back to the control that was used, or to what fFocusBack() returns when that control is gone.
+ */
+BxDolStudioDashboard.prototype.popup = function(sValue, fFocusBack) {
     var sId = 'bx-std-dbd-popup';
+    var oLast = document.activeElement;
 
     $('#' + sId).remove();
     $('<div id="' + sId + '" style="display: none;"></div>').prependTo('body').html(sValue);
-    $('#' + sId).dolPopup({});
+    $('#' + sId).dolPopup({
+        closeElement: '.bx-std-action-result-close, .bx-popup-element-close', // the outcome dialog's own close button (it carries no site popup class, whose hover rule would move it)
+        onShow: function(oPopup) {
+            var oDialog = oPopup.find('[role="dialog"]').first();
+            if(!oDialog.length)
+                oDialog = oPopup;
+
+            // an outcome dialog (page_action_result.html) is an alert dialog named by its message, and focus lands on its Close button
+            var oText = oPopup.find('.bx-std-action-result-cnt').first();
+            if(oText.length) {
+                if(!oText.attr('id'))
+                    oText.attr('id', sId + '-text');
+
+                oDialog.attr({'role': 'alertdialog', 'aria-labelledby': oText.attr('id')});
+            }
+            else
+                oDialog.attr('aria-label', oPopup.text().trim().replace(/\s+/g, ' ').slice(0, 200));
+
+            oDialog.attr('tabindex', '-1');
+
+            var oClose = oPopup.find('.bx-std-action-result-close').first();
+            (oClose.length ? oClose : oDialog).get(0).focus();
+        },
+        onHide: function() {
+            var oBack = oLast && document.contains(oLast) && oLast !== document.body ? oLast : (typeof fFocusBack == 'function' ? fFocusBack() : null);
+            if(oBack)
+                oBack.focus();
+        }
+    });
+};
+/**
+ * Users block: the chart in the hero, under the charted series' figure for the period and its title, and the rows under it as its legend,
+ * each showing its own figure for the period; on the hero's right, how many of the charted series are online now. oData is {period,
+ * series_default, periods: {name: {title, label, type}}, series: {name: {title, online, periods: {name: [{label, title, count, total?}]}}},
+ * txt_peak}; everything comes with the block, so the period
+ * tabs in the block caption (dbd_users_tabs.html) and the rows only redraw. 'bars' periods: a bar per bucket, as tall as its share of the
+ * period's peak, the newest one solid, each naming its bucket and count in the slice tooltip. 'line' (the Total tab, first and selected): the
+ * running total as a line over the whole period with the area under it tinted, a transparent column per bucket carrying the tooltip.
+ */
+BxDolStudioDashboard.prototype.initUsersChart = function(oData) {
+    this.oUsersData = oData;
+    this.sUsersPeriod = oData && oData.period ? oData.period : 'all';
+    this.sUsersSeries = oData && oData.series_default ? oData.series_default : 'accounts';
+    this.usersDraw();
 };
+
+/**
+ * A period tab in the block caption was chosen.
+ */
+BxDolStudioDashboard.prototype.usersPeriod = function(sPeriod, oTab) {
+    var oData = this.oUsersData;
+    if(!oData || !oData.periods || !oData.periods[sPeriod])
+        return;
+
+    var oBlock = $('#bx-dbd-users').closest('.bx-std-block');
+    oTab = oTab ? $(oTab) : oBlock.find('[role="tab"][data-period="' + sPeriod + '"]');
+    oBlock.find('[role="tab"]').attr({'aria-selected': 'false', 'tabindex': '-1'});
+    oTab.attr({'aria-selected': 'true', 'tabindex': '0'});
+    oBlock.find('#bx-dbd-users-chart').attr('aria-labelledby', oTab.attr('id') || null);
+
+    this.sUsersPeriod = sPeriod;
+    this.usersDraw();
+};
+
+/**
+ * A row was pressed: its series is the one charted.
+ */
+BxDolStudioDashboard.prototype.usersSeries = function(sSeries, oRow) {
+    var oData = this.oUsersData;
+    if(!oData || !oData.series || !oData.series[sSeries])
+        return;
+
+    var oBlock = $('#bx-dbd-users').closest('.bx-std-block');
+    oBlock.find('.bx-dbd-users-row').attr('aria-pressed', 'false');
+    (oRow ? $(oRow) : oBlock.find('.bx-dbd-users-row[data-series="' + sSeries + '"]')).attr('aria-pressed', 'true');
+
+    this.sUsersSeries = sSeries;
+    this.usersDraw();
+};
+
+/**
+ * Draw the chosen series over the chosen period.
+ */
+BxDolStudioDashboard.prototype.usersDraw = function() {
+    var oData = this.oUsersData;
+    var oPeriod = oData && oData.periods ? oData.periods[this.sUsersPeriod] : null;
+    var oSeries = oData && oData.series ? oData.series[this.sUsersSeries] : null;
+    if(!oPeriod || !oSeries)
+        return;
+
+    var oBlock = $('#bx-dbd-users').closest('.bx-std-block');
+    var fAttr = function(s) { return String(s).replace(/"/g, '&quot;'); };
+
+    var aItems = (oSeries.periods && oSeries.periods[this.sUsersPeriod]) || [], iMax = 0, iTotal = 0;
+    for(var i = 0; i < aItems.length; i++) {
+        iTotal += aItems[i].count;
+        iMax = Math.max(iMax, aItems[i].count);
+    }
+
+    var bLine = oPeriod.type == 'line';
+    var sChart = '';
+    if(bLine) {
+        // the running total, scaled to the chart's 100 units with 4 kept above the highest point; one bucket still draws as a flat line
+        var iLast = aItems.length ? aItems[aItems.length - 1].total : 0;
+        var iSteps = Math.max(aItems.length - 1, 1), iW = iSteps * 10, iH = 100;
+        var aPoints = [], sHits = '';
+        for(var j = 0; j < aItems.length; j++) {
+            var oItem = aItems[j];
+            var fY = iLast > 0 ? iH - oItem.total / iLast * (iH - 4) : iH;
+            aPoints.push((aItems.length > 1 ? j * 10 : 0) + ',' + fY.toFixed(1));
+            if(aItems.length == 1)
+                aPoints.push(iW + ',' + fY.toFixed(1));
+
+            sHits += '<span class="bx-dbd-chart-slice bx-dbd-users-hit" data-label="' + fAttr(oItem.title + ': ' + oItem.total) + '"></span>';
+        }
+
+        sChart = '<svg class="bx-dbd-users-line-svg" viewBox="0 0 ' + iW + ' ' + iH + '" preserveAspectRatio="none" aria-hidden="true">' +
+            '<polygon class="bx-dbd-users-line-area" points="0,' + iH + ' ' + aPoints.join(' ') + ' ' + iW + ',' + iH + '"></polygon>' +
+            '<polyline class="bx-dbd-users-line-path" points="' + aPoints.join(' ') + '" vector-effect="non-scaling-stroke"></polyline>' +
+            '</svg><div class="bx-dbd-users-hits">' + sHits + '</div>';
+        iTotal = iLast;
+    }
+    else {
+        for(var k = 0; k < aItems.length; k++) {
+            var oBar = aItems[k];
+            var fShare = iMax > 0 ? oBar.count / iMax * 100 : 0;
+            sChart += '<span class="bx-dbd-chart-slice bx-dbd-users-bar' + (k == aItems.length - 1 ? ' bx-dbd-users-bar-last' : '') + '" style="height:' + fShare.toFixed(1) + '%" data-label="' + fAttr(oBar.title + ': ' + oBar.count) + '"></span>';
+        }
+    }
+
+    // every series row shows its figure for the period (the running total on the Total tab), and the hero names the charted one with its figure
+    var sPeriod = this.sUsersPeriod;
+    var fFigure = function(oOne) {
+        var aList = (oOne.periods && oOne.periods[sPeriod]) || [], iSum = 0;
+        for(var n = 0; n < aList.length; n++)
+            iSum += aList[n].count;
+        return bLine && aList.length ? aList[aList.length - 1].total : iSum;
+    };
+    for(var sName in oData.series)
+        oBlock.find('.bx-dbd-users-row[data-series="' + sName + '"] .bx-dbd-htools-value').text(fFigure(oData.series[sName]).toLocaleString());
+    oBlock.find('.bx-dbd-users-total').text(iTotal.toLocaleString());
+    oBlock.find('.bx-dbd-users-caption').text(oSeries.title);
+
+    // on the right, how many of the charted series are online now (a live figure, the same for every period)
+    var iOnline = parseInt(oSeries.online) || 0;
+    oBlock.find('.bx-dbd-users-online-count').text(iOnline.toLocaleString());
+    oBlock.find('.bx-dbd-users-online').toggleClass('bx-dbd-users-online-on', iOnline > 0);
+
+    // the chart's accessible name: the series over the period, its total and its peak bucket's count
+    var sPeak = iMax > 0 && oData.txt_peak ? ', ' + oData.txt_peak.replace('{0}', iMax) : '';
+    oBlock.find('.bx-dbd-users-bars').toggleClass('bx-dbd-users-line', bLine).toggleClass('bx-dbd-users-bars-dense', !bLine && aItems.length > 14).attr('aria-label', fAttr(oSeries.title + ', ' + oPeriod.label + ': ' + iTotal + sPeak)).html(sChart);
+
+    // the tooltip is bound to the slices, so it is rebuilt with them
+    oBlock.find('.bx-dbd-chart-tip').remove();
+    this.bindChartTips(oBlock);
+};
+
+/**
+ * Arrow keys move between the period tabs, as a tablist expects.
+ */
+BxDolStudioDashboard.prototype.usersPeriodKey = function(oEvent, oTab) {
+    var iStep = oEvent.key == 'ArrowRight' ? 1 : (oEvent.key == 'ArrowLeft' ? -1 : 0);
+    var bEdge = oEvent.key == 'Home' || oEvent.key == 'End';
+    if(!iStep && !bEdge)
+        return;
+
+    var oTabs = $(oTab).closest('[role="tablist"]').find('[role="tab"]');
+    var oNext = bEdge ? oTabs.eq(oEvent.key == 'Home' ? 0 : oTabs.length - 1) : $(oTabs.get((oTabs.index(oTab) + iStep + oTabs.length) % oTabs.length));
+
+    oEvent.preventDefault();
+    oNext.focus();
+    this.usersPeriod(oNext.data('period'), oNext);
+};
+
 /** @} */

+ 88 - 9
studio/js/launcher.js

@@ -12,6 +12,11 @@ function BxDolStudioLauncher(oOptions) {
     this.iAnimationSpeed = oOptions.iAnimationSpeed == undefined ? 'fast' : oOptions.iAnimationSpeed;
     this.bInit = oOptions.bInit == undefined ? true : oOptions.bInit;
 
+    //--- Live region texts (BxBaseStudioLauncher::getPageJsCode): search results and moves in edit mode.
+    this.sTxtMatches = oOptions.sTxtMatches == undefined ? '{0}' : oOptions.sTxtMatches;
+    this.sTxtMatchesOne = oOptions.sTxtMatchesOne == undefined ? '{0}' : oOptions.sTxtMatchesOne;
+    this.sTxtMoved = oOptions.sTxtMoved == undefined ? '{0}: {1}/{2}' : oOptions.sTxtMoved;
+
     //--- Jitter Settings ---//
     this.bJitterMode = false;
     this.aJitterConf = {
@@ -36,14 +41,17 @@ BxDolStudioLauncher.prototype.init = function() {
         var hammertime = new Hammer($('.bx-std-widgets').get(0));
         hammertime.get('press').set({time: 1000});
         hammertime.on('press', function(oEvent) {
+            if(typeof oBxDolStudioContextMenu !== 'undefined' && oBxDolStudioContextMenu.justOpened())
+                return; // the press opened a tile's context menu
+
             if(!$this.bJitterMode)
                 $this.enableJitter();
             else
                 $this.disableJitter();
         });
 
-        //--- Desktop: put the cursor in the app search so typing filters straight away (no autofocus on touch, it would raise the keyboard).
-        if(bx_is_mouse() && window.matchMedia('(min-width: 768px)').matches)
+        //--- Desktop: put the cursor in the app search so typing filters straight away (no autofocus on touch, it would raise the keyboard; not while the intro tour holds focus).
+        if(bx_is_mouse() && window.matchMedia('(min-width: 768px)').matches && !(typeof glTour !== 'undefined' && glTour.isActive()))
             $('li.bx-menu-tab-search:not(.bx-mt-compact) input[name="search"]').trigger('focus');
 
     	//--- Enable Sorting for Page Edit mode ---//
@@ -62,12 +70,12 @@ BxDolStudioLauncher.prototype.init = function() {
     	});
     });
 
-    window.addEventListener('keydown', (e) => {
-        if(e.keyCode === 114 || (e.ctrlKey && e.keyCode === 70) || (e.metaKey && e.keyCode === 70)) {
-            e.preventDefault();
+    //--- Escape leaves edit mode; the search field and popups handle their own Escape first.
+    window.addEventListener('keydown', function(oEvent) {
+        if(oEvent.key !== 'Escape' || !$this.bJitterMode || $(oEvent.target).is('input, textarea, select') || $('.bx-popup-applied:visible').length)
+            return;
 
-            oBxDolStudioMenuTop.searchOpen();
-        }
+        $this.disableJitter();
     });
 };
 
@@ -197,7 +205,7 @@ BxDolStudioLauncher.prototype.featured = function(sPageName, oLink) {
             
             var sClassStatic = 'bx-menu-item-static';
             var sClassDivider = 'bx-menu-item-divider';
-            var oItem = $('ul.bx-menu-top-center li.bx-menu-tab-active').toggleClass(sClassStatic + ' bx-menu-item-dynamic');
+            var oItem = $('#bx-menu-item-' + sPageName).toggleClass(sClassStatic + ' bx-menu-item-dynamic'); // the acted-on app's dock item, not necessarily this page's
             if(oItem.hasClass(sClassStatic))
                 $(oItem).insertBefore(oItem.siblings('.' + sClassDivider));
             else
@@ -228,7 +236,7 @@ BxDolStudioLauncher.prototype.bookmark = function(sPageName, oLink) {
             
             var sClassStatic = 'bx-menu-item-static';
             var sClassDivider = 'bx-menu-item-divider';
-            var oItem = $('ul.bx-menu-top-center li.bx-menu-tab-active').toggleClass(sClassStatic + ' bx-menu-item-dynamic');
+            var oItem = $('#bx-menu-item-' + sPageName).toggleClass(sClassStatic + ' bx-menu-item-dynamic'); // the acted-on app's dock item, not necessarily this page's
             if(oItem.hasClass(sClassStatic))
                 $(oItem).insertBefore(oItem.siblings('.' + sClassDivider));
             else
@@ -272,14 +280,85 @@ BxDolStudioLauncher.prototype.enableJitter = function() {
     $(this.aSortingConf.parent).addClass('bx-std-jitter').sortable('option', 'disabled', false);
 
     this.bJitterMode = true;
+    this.markJitter();
 };
 
 BxDolStudioLauncher.prototype.disableJitter = function() {
+    var oActive = $(document.activeElement);
+    var oWidget = oActive.closest(this.aSortingConf.item);
+
     $(this.aJitterConf.elements).fadeOut('fast');	
     $(this.aJitterConf.item).addClass('bx-std-widget-icon-trans');
     $(this.aSortingConf.parent).removeClass('bx-std-jitter').sortable('option', 'disabled', true);
 
     this.bJitterMode = false;
+    this.markJitter();
+
+    //--- Keyboard focus was on a control that just went away: keep it on the same tile, or on the first one.
+    if(oWidget.length && oActive.closest('.bx-std-widget-actions').length)
+        oWidget.find('.bx-std-widget-icon .bx-std-widget-link').trigger('focus');
+    else if(oActive.closest('#bx-std-launcher-edit').length)
+        $(this.aSortingConf.parent + ' > ' + this.aSortingConf.item).filter(':visible').first().find('.bx-std-widget-icon .bx-std-widget-link').trigger('focus');
+};
+
+/**
+ * Reflect edit mode on the Done bar and on the Manage Apps toggle in the account menu.
+ */
+BxDolStudioLauncher.prototype.markJitter = function() {
+    var bOn = this.bJitterMode;
+
+    $('#bx-std-launcher-edit').prop('hidden', !bOn);
+    $('.bx-menu-account li.edit').toggleClass('bx-menu-tab-active', bOn).find('[aria-pressed]').attr('aria-pressed', bOn ? 'true' : 'false');
+};
+
+/**
+ * Non-drag reordering for edit mode: shift one app past its visible neighbour, save, and announce the new position.
+ */
+BxDolStudioLauncher.prototype.move = function(oButton, iDirection) {
+    var sItem = this.aSortingConf.item + ':visible';
+    var oWidget = $(oButton).closest(this.aSortingConf.item);
+    var oSibling = iDirection < 0 ? oWidget.prevAll(sItem).first() : oWidget.nextAll(sItem).first();
+    if(!oSibling.length)
+        return false;
+
+    if(iDirection < 0)
+        oWidget.insertBefore(oSibling);
+    else
+        oWidget.insertAfter(oSibling);
+
+    $(oButton).trigger('focus');
+
+    var oWidgets = oWidget.parent().children(sItem);
+    this.announce(this.sTxtMoved.replace('{0}', oWidget.find('.bx-std-widget-caption').text().trim()).replace('{1}', oWidgets.index(oWidget) + 1).replace('{2}', oWidgets.length));
+
+    return this.reorder(oWidget);
+};
+
+/**
+ * Search feedback: the empty message when nothing matches, and the match count for screen readers.
+ */
+BxDolStudioLauncher.prototype.searchResult = function(sSearch, iCount) {
+    $('#bx-std-launcher-empty').prop('hidden', !sSearch.length || iCount > 0);
+
+    if(!sSearch.length)
+        $('#bx-std-launcher-status').text('');
+    else
+        this.announce((iCount == 1 ? this.sTxtMatchesOne : this.sTxtMatches).replace('{0}', iCount));
+};
+
+/**
+ * Polite live region of the launcher; cleared first so the same text is announced again.
+ */
+BxDolStudioLauncher.prototype.announce = function(sText) {
+    var oStatus = $('#bx-std-launcher-status');
+    if(!oStatus.length)
+        return;
+
+    oStatus.text('');
+    clearTimeout(this.iAnnounceTimer);
+    this.iAnnounceTimer = setTimeout(function() {
+        oStatus.text(sText);
+    }, 50);
 };
 
 /** @} */

+ 55 - 26
studio/js/menu_top.js

@@ -18,6 +18,17 @@ function BxDolStudioMenuTop(oOptions) {
         $this.markTheme();
         $this.searchInit();
     });
+
+    //--- Theme radios: arrows move between the options and apply them, as in a native radio group.
+    $(document).on('keydown', '.bx-theme-toggle [role="radio"]', function(oEvent) {
+        var aKeys = {ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1};
+        if(!(oEvent.key in aKeys))
+            return true;
+
+        var oOptions = $(this).closest('.bx-theme-toggle').find('[role="radio"]');
+        oOptions.eq((oOptions.index(this) + aKeys[oEvent.key] + oOptions.length) % oOptions.length).trigger('focus').trigger('click');
+        return false;
+    });
 }
 
 /**
@@ -31,7 +42,7 @@ BxDolStudioMenuTop.prototype.searchInit = function() {
     if(!oItem || typeof ResizeObserver === 'undefined')
         return;
 
-    var iMinField = 192 + 4; // min-w-48 plus the item's left padding
+    var iMinField = 192; // min-w-48
 
     this.fSearchMeasure = function() {
         if(oItem.classList.contains('bx-mt-active'))
@@ -52,14 +63,6 @@ BxDolStudioMenuTop.prototype.searchInit = function() {
     this.fSearchMeasure();
 };
 
-BxDolStudioMenuTop.prototype.searchOpen = function() {
-    var oItem = $('li.bx-menu-tab-search');
-    if(oItem.hasClass('bx-mt-compact') && !oItem.hasClass('bx-mt-active'))
-        this.searchToggle(oItem.find('.bx-std-search-trigger'));
-    else
-        oItem.find('input[name="search"]').trigger('focus');
-};
-
 BxDolStudioMenuTop.prototype.searchClose = function(oItem) {
     oItem = $(oItem).closest('li.bx-menu-tab-search');
     if(!oItem.hasClass('bx-mt-active'))
@@ -121,22 +124,18 @@ BxDolStudioMenuTop.prototype.markTheme = function() {
     var sTheme = bx_get_color_scheme();
 
     $('.bx-theme-option').each(function() {
-        $(this).attr('aria-checked', $(this).data('bx-theme') == sTheme ? 'true' : 'false');
+        var bChecked = $(this).data('bx-theme') == sTheme;
+        $(this).attr('aria-checked', bChecked ? 'true' : 'false').attr('tabindex', bChecked ? '0' : '-1');
     });
 };
 
 BxDolStudioMenuTop.prototype.clickEdit = function(oItem) {
     $('.bx-popup-applied:visible').dolPopupHide();
 
-    var oParent = $(oItem).parent();
-    if(oParent.hasClass('bx-menu-tab-active')) {
-        oParent.removeClass('bx-menu-tab-active');
+    if(oBxDolStudioLauncher.bJitterMode)
         oBxDolStudioLauncher.disableJitter();
-    }
-    else {
-        oParent.addClass('bx-menu-tab-active');
+    else
         oBxDolStudioLauncher.enableJitter();
-    }
 };
 
 BxDolStudioMenuTop.prototype.clickLogout = function(oItem) {
@@ -157,21 +156,51 @@ BxDolStudioMenuTop.prototype.searchToggle = function(oLink) {
 };
 
 BxDolStudioMenuTop.prototype.searchWidget = function(oEvent) {
+    this.searchFilter($(oEvent.target));
+};
+
+/**
+ * Show the apps whose name starts with the field's text (every app for an empty field), pick the first one for Enter
+ * and report the count; one pass per pause in typing.
+ */
+BxDolStudioMenuTop.prototype.searchFilter = function(oInput) {
     var $this = this;
 
-    setTimeout(function () {
-        var sSearch = $(oEvent.target).val().toLowerCase(); 
-        if(!!sSearch.length) {
-            $('.bx-std-widgets > .bx-std-widget:not([data-name^="' + sSearch + '"])').hide();
-            $('.bx-std-widgets > .bx-std-widget[data-name^="' + sSearch + '"]:hidden').show();
-        }
-        else
-            $('.bx-std-widgets > .bx-std-widget:hidden').show();
+    // the clear button shows only while there is something to clear
+    oInput.siblings('.bx-std-search-clear').prop('hidden', !oInput.val().length);
+
+    clearTimeout(this.iSearchTimer);
+    this.iSearchTimer = setTimeout(function () {
+        var sSearch = oInput.val().toLowerCase();
+        var oWidgets = $('.bx-std-widgets > .bx-std-widget');
+
+        oWidgets.each(function() {
+            $(this).toggle(!sSearch.length || String($(this).data('name')).toLowerCase().indexOf(sSearch) === 0);
+        });
 
-        $this.searchPick(sSearch.length ? $('.bx-std-widgets > .bx-std-widget:visible:first') : null);
+        var oVisible = oWidgets.filter(':visible');
+        $this.searchPick(sSearch.length ? oVisible.first() : null);
+
+        if(typeof oBxDolStudioLauncher !== 'undefined')
+            oBxDolStudioLauncher.searchResult(sSearch, oVisible.length);
     }, 100);
 };
 
+/**
+ * The field's clear button: empty the field, show every app again and hand focus back to the field.
+ */
+BxDolStudioMenuTop.prototype.searchClear = function(oButton) {
+    var oInput = $(oButton).siblings('input');
+    if(!oInput.length)
+        return false;
+
+    oInput.val('');
+    this.searchFilter(oInput);
+    oInput.trigger('focus');
+
+    return false;
+};
+
 /**
  * Highlight one tile as the choice Enter will open (the first match while filtering); pass null to clear.
  */

+ 8 - 1
studio/js/module.js

@@ -52,7 +52,8 @@ BxDolStudioModule.prototype.activate = function(oCheckbox, sName, iWidgetId) {
 
             if(iWidgetId != 0 && oData.widget.length > 0) {
                 $('#bx-std-widget-' + iWidgetId).replaceWith(oData.widget);
-                oBxDolStudioLauncher.enableJitter();
+                if(oBxDolStudioLauncher.bJitterMode)
+                    oBxDolStudioLauncher.enableJitter(); // the fresh tile needs its edit-mode controls shown
                 return;
             }
 
@@ -92,6 +93,12 @@ BxDolStudioModule.prototype.onUninstall = function(oData) {
     if(oData.code != 0 || oData.page.length == 0 || oData.widget_id.length == 0) 
         return;
 
+    // uninstalled from the app's own page: the page is gone, back to the launcher
+    if(window.location.pathname.indexOf('module.php') != -1 && window.location.search.indexOf('name=' + oData.page) != -1) {
+        window.location = sUrlStudio + 'launcher.php';
+        return;
+    }
+
     $('#bx-menu-item-' + oData.page).bx_anim('hide', this.sAnimationEffect, this.iAnimationSpeed, function() {
         $(this).remove();
     });

+ 111 - 0
studio/js/page.js

@@ -99,4 +99,115 @@ BxDolStudioPage.prototype.initCodeMirror = function(sSelector) {
     }
 };
 
+/**
+ * Phone layout: the page menu is a horizontal row of tabs, so the active one is brought into view on load.
+ */
+$(document).ready(function() {
+    if(!window.matchMedia('(max-width: 767px)').matches)
+        return;
+
+    var oMenu = document.querySelector('.bx-std-page-column.bx-std-page-menu');
+    var oActive = oMenu ? oMenu.querySelector('.bx-std-pmen-item.bx-menu-tab-active') : null;
+    if(!oActive || oMenu.scrollWidth <= oMenu.clientWidth)
+        return;
+
+    // the row scrolls on its own; scrollIntoView would move every scrollable ancestor, the page included
+    oMenu.scrollLeft += oActive.getBoundingClientRect().left - oMenu.getBoundingClientRect().left - (oMenu.clientWidth - oActive.offsetWidth) / 2;
+});
+
+
+/**
+ * Overlay scrollbar for a block's scroll box (div.bx-std-block-content, see page.css): an SVG over the block draws a track and a thumb
+ * along the box's right edge and round its rounded corners, the thumb's length and place following the scroll position.
+ * The native bar is hidden by CSS; the SVG is redrawn on scroll, on resize and when the content changes.
+ */
+function bx_std_scrollbars(sSelector) {
+    $(sSelector || 'div.bx-std-block-content').each(function() {
+        bx_std_scrollbar(this);
+    });
+}
+
+function bx_std_scrollbar(oScroller) {
+    if(oScroller.bxScrollbar)
+        return;
+
+    var oBlock = $(oScroller).closest('div.bx-std-block')[0];
+    if(!oBlock)
+        return;
+
+    var sNs = 'http://www.w3.org/2000/svg';
+    var oSvg = document.createElementNS(sNs, 'svg');
+    oSvg.setAttribute('class', 'bx-std-scrollbar');
+    oSvg.setAttribute('aria-hidden', 'true');
+    var oTrack = document.createElementNS(sNs, 'path');
+    oTrack.setAttribute('class', 'bx-std-scrollbar-track');
+    var oThumb = document.createElementNS(sNs, 'path');
+    oThumb.setAttribute('class', 'bx-std-scrollbar-thumb');
+    oSvg.appendChild(oTrack);
+    oSvg.appendChild(oThumb);
+    oBlock.insertBefore(oSvg, oBlock.firstChild); // first, so the scroll box stays the block's :last-child (page.css rounds it by that)
+
+    var oState = {scroller: oScroller, block: oBlock, svg: oSvg, track: oTrack, thumb: oThumb, timer: null};
+    oScroller.bxScrollbar = oState;
+
+    var fUpdate = function() {
+        bx_std_scrollbar_update(oState);
+    };
+    oScroller.addEventListener('scroll', function() {
+        fUpdate();
+        $(oBlock).addClass('bx-std-scrolling');
+        clearTimeout(oState.timer);
+        oState.timer = setTimeout(function() {
+            $(oBlock).removeClass('bx-std-scrolling');
+        }, 800);
+    }, {passive: true});
+    if(window.ResizeObserver)
+        new ResizeObserver(fUpdate).observe(oScroller);
+    if(window.MutationObserver)
+        new MutationObserver(function() {
+            clearTimeout(oState.mutation);
+            oState.mutation = setTimeout(fUpdate, 50);
+        }).observe(oScroller, {childList: true, subtree: true, attributes: true});
+    $(window).on('resize', fUpdate);
+
+    fUpdate();
+}
+
+function bx_std_scrollbar_update(o) {
+    var oScroller = o.scroller, iRange = oScroller.scrollHeight - oScroller.clientHeight;
+    if(iRange <= 1) {
+        o.svg.style.display = 'none';
+        return;
+    }
+    o.svg.style.display = '';
+
+    var oRb = o.block.getBoundingClientRect(), oRs = oScroller.getBoundingClientRect();
+    var fW = oRb.width, fH = oRb.height, fTop = oRs.top - oRb.top, fBottom = oRs.bottom - oRb.top;
+    var oCss = getComputedStyle(oScroller), oCssBlock = getComputedStyle(o.block);
+    var fInset = 6; // the bar's centre line, 6px in from the edge: 4px wide with a 4px gap
+    var fRb = Math.max((parseFloat(oCss.borderBottomRightRadius) || 0) - fInset, 0);
+    var fRt = fTop < 1 ? Math.max((parseFloat(oCssBlock.borderTopRightRadius) || 0) - fInset, 0) : 0;
+    var fX = fW - fInset;
+
+    // round the top corner when the scroll box starts at the block's top, down the right edge, round the bottom corner
+    var sPath = fRt > 0 ? 'M' + (fX - fRt) + ' ' + (fTop + fInset) + ' A' + fRt + ' ' + fRt + ' 0 0 1 ' + fX + ' ' + (fTop + fInset + fRt) : 'M' + fX + ' ' + (fTop + fInset);
+    sPath += ' L' + fX + ' ' + (fBottom - fInset - fRb);
+    if(fRb > 0)
+        sPath += ' A' + fRb + ' ' + fRb + ' 0 0 1 ' + (fX - fRb) + ' ' + (fBottom - fInset);
+
+    o.svg.setAttribute('viewBox', '0 0 ' + fW + ' ' + fH);
+    o.track.setAttribute('d', sPath);
+    o.thumb.setAttribute('d', sPath);
+
+    var fLength = o.thumb.getTotalLength();
+    var fThumb = Math.max(fLength * oScroller.clientHeight / oScroller.scrollHeight, 24);
+    var fOffset = (fLength - fThumb) * oScroller.scrollTop / iRange;
+    o.thumb.setAttribute('stroke-dasharray', fThumb + ' ' + fLength);
+    o.thumb.setAttribute('stroke-dashoffset', -fOffset);
+}
+
+$(document).ready(function() {
+    bx_std_scrollbars();
+});
+
 /** @} */

+ 1 - 0
studio/launcher.php

@@ -34,6 +34,7 @@ if($sPageCode === false)
 
 $oTemplate->setPageNameIndex($oPage->getPageIndex());
 $oTemplate->setPageHeader($oPage->getPageHeader());
+$oTemplate->setPageDescription($oPage->getPageDescription());
 $oTemplate->setPageContent('page_caption_code', $oPage->getPageCaption());
 $oTemplate->setPageContent('page_attributes', $oPage->getPageAttributes());
 if($oPage->isLeftColumn())

+ 2 - 1
studio/template/_header.html

@@ -4,6 +4,7 @@
         <bx_injection:injection_head_begin />
         
         <meta http-equiv="Content-Type" content="text/html; charset=__page_charset__" />
+        <meta name="viewport" content="__page_viewport__" />
         <title>__page_header__</title>
 
         __meta_info__
@@ -34,6 +35,6 @@
         <bx_injection:injection_head />
     </head>
     <body class="bx-std bx-std-bg bx-def-font bx-def-color-bg-page __class_name__<bx_injection:injection_body_style />" dir="__lang_direction__" <bx_injection:injection_body /> >
-    <a class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 bx-btn bx-btn-primary" href="#bx-std-page-content"><bx_text:_sys_skip_to_main_content /></a>
+    <a class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[1001] bx-btn bx-btn-primary" href="#bx-std-page-content"><bx_text:_sys_skip_to_main_content /></a>
         __system_injection_header__
         <bx_injection:injection_header />

+ 1 - 1
studio/template/block_caption.html

@@ -10,7 +10,7 @@
                             <div><i class="sys-icon __icon__"></i></div>
                         </bx_if:show_bc_icon>
                         <bx_if:show_bc_icon_url>
-                            <div><img class="w-4 h-4" src="__icon_url__" /></div>
+                            <div><img class="w-4 h-4" src="__icon_url__" alt="" /></div>
                         </bx_if:show_bc_icon_url>
                         <bx_if:show_bc_icon_html>
                             <div>__icon_html__</div>

+ 3 - 3
studio/template/bp_block_panel_top.html

@@ -8,11 +8,11 @@
     <div class="bx-std-pt-bp-buttons flex flex-wrap -m-1">
         <button class="bx-btn bx-btn-primary m-1" onclick="javascript:__js_object__.performAction('__action_page_create__');"><bx_text:_adm_bp_btn_create_new /></button>
         <bx_if:show_actions>
+            <button class="bx-btn m-1" onclick="javascript:__js_object__.performAction('__action_block_create__')"><bx_text:_adm_bp_btn_add_block /></button>
             <bx_if:can_view>
-                <button class="bx-btn m-1" onclick="javascript:window.open('__url_view__','_blank');"><bx_text:_adm_bp_btn_view_page /></button>
+                <button class="bx-btn bx-btn-icon m-1" onclick="javascript:window.open('__url_view__','_blank');" title="<bx_text_attribute:_adm_bp_btn_view_page />" aria-label="<bx_text_attribute:_adm_bp_btn_view_page />"><bx_icon_inline:bp-view.svg /></button>
             </bx_if:can_view>
-            <button class="bx-btn m-1" onclick="javascript:__js_object__.performAction('__action_page_edit__')"><bx_text:_adm_bp_btn_settings /></button>
-            <button class="bx-btn m-1" onclick="javascript:__js_object__.performAction('__action_block_create__')"><bx_text:_adm_bp_btn_add_block /></button>
+            <button class="bx-btn bx-btn-icon m-1" onclick="javascript:__js_object__.performAction('__action_page_edit__')" title="<bx_text_attribute:_adm_bp_btn_settings />" aria-label="<bx_text_attribute:_adm_bp_btn_settings />"><bx_icon_inline:bp-settings.svg /></button>
             <bx_if:can_delete>
                 <button class="bx-btn m-1" onclick="javascript:__js_object__.deletePage();"><bx_text:_adm_bp_btn_delete /></button>
             </bx_if:can_delete>

+ 2 - 2
studio/template/bp_blocks.html

@@ -8,7 +8,7 @@
                         <i class="sys-icon __icon__"></i>
                     </div>
                 </bx_if:icon>
-                <bx_if:image><img src="__icon_url__" /></bx_if:image>
+                <bx_if:image><img src="__icon_url__" alt="" /></bx_if:image>
             </div>
             <div class="adm-bpb-info">
                 <div class="adm-bpb-title text-gray-900">
@@ -19,7 +19,7 @@
                         <span class="text-gray-800 dark:text-gray-200">__title__</span>
                     </bx_if:show_text>
                     <bx_if:show_api>
-                        <img class="bx-std-api" src="<bx_icon_url:api.svg />" title="<bx_text_attribute:_adm_txt_active_api />" />
+                        <img class="bx-std-api" src="<bx_icon_url:api.svg />" alt="<bx_text_attribute:_adm_txt_active_api />" title="<bx_text_attribute:_adm_txt_active_api />" />
                     </bx_if:show_api>
                 </div>
                 <div class="adm-bpb-description bx-def-font-grayed">__module__ &bull; __visible_for__</div>

+ 3 - 3
studio/template/bp_edit_page_form.html

@@ -1,14 +1,14 @@
 <div class="adm-bp-epf-left">
-    <div class="adm-bp-epf-left-cnt px-5">__menu__</div>
+    <div class="adm-bp-epf-left-cnt p-2">__menu__</div>
 </div>
 <div class="adm-bp-epf-right">
-    <div id="__html_settings_groups_id__" class="adm-bp-epf-right-cnt px-5 py-0.5">
+    <div id="__html_settings_groups_id__" class="adm-bp-epf-right-cnt p-4">
         <bx_repeat:settings_groups>
             <div id="__html_settings_group_id__" class="adm-bp-settings" style="<bx_if:hidden>display: none;</bx_if:hidden>">__content__</div>
         </bx_repeat:settings_groups>
     </div>
 </div>
-<div class="adm-bp-epf-controls bx-def-padding-top px-5">
+<div class="adm-bp-epf-controls p-4">
     <button class="bx-btn bx-btn-primary" name="do_submit" value="__submit__" type="submit">__submit__</button>
     <button class="bx-btn bx-def-margin-sec-left" name="do_cancel" value="<bx_text:_adm_bp_btn_page_cancel />" type="button" onclick="__js_object__.onCloseSettings()"><bx_text:_adm_bp_btn_page_cancel /></button>
 </div>

+ 1 - 1
studio/template/bp_fgt_attachments.html

@@ -1,7 +1,7 @@
 <div id="bx-uploader-file-{storage_object}-{file_id}" class="bx-uploader-ghost bx-def-margin-sec-top bx-clearfix">
     <input type="hidden" name="__name__" value="{file_id}" />
     <div class="bx-ug-icon bx-def-margin-sec-right">
-        <img src="{file_icon}" class="bx-def-round-corners bx-base-text-icon bx-base-text-icon-{file_id}" />
+        <img src="{file_icon}" alt="{file_name}" class="bx-def-round-corners bx-base-text-icon bx-base-text-icon-{file_id}" />
     </div>
     <div class="bx-ug-content">
         <div class="bx-def-font-bold"><bx_text:_sys_txt_form_entry_input_picture_pic_url /></div>

+ 4 - 3
studio/template/bp_layouts.html

@@ -1,8 +1,9 @@
 <bx_repeat:layouts>
-    <div id="__html_id__" class="adm-bp-layout bx-def-margin-bottom <bx_if:active>adm-bp-layout-active</bx_if:active>">
-        <div class="adm-bp-layout-cnt"> 
-            <a href="javascript:void(0)" onclick="javascript:__js_object__.onChangeLayout(__id__, this)" title="__title__">
+    <div id="__html_id__" class="adm-bp-layout<bx_if:active> adm-bp-layout-active</bx_if:active>">
+        <div class="adm-bp-layout-cnt">
+            <a href="javascript:void(0)" onclick="javascript:__js_object__.onChangeLayout(__id__, this)">
                 <img src="__icon__" alt="__title__" />
+                <span class="adm-bp-layout-title" aria-hidden="true">__title__</span>
             </a>
         </div>
     </div>

+ 7 - 1
studio/template/css/agents.css

@@ -170,4 +170,10 @@
     border-radius:10px;
     font-size:12px;
     height:fit-content;
-}
+}
+
+/* Agent cards on the block's 16px content line: the list's -4px pull plus this 4px puts a card's 4px margin at 8px + 8px. */
+div.bx-std-block-content > .bx-agt-agents {
+    width: calc(100% - 0.5rem); /* the template's w-full assumed the old negative margins */
+    margin: 0.25rem;
+}

+ 0 - 6
studio/template/css/badges.css

@@ -6,9 +6,6 @@ div.bx-grid-header div.bx-form-element-wrapper {
 }
 
 /*--- Displays page -> Panel Top style ---*/
-div.bx-grid-header div#bx-form-element-module{
-    margin-right: 20px;
-}
 div.bx-grid-header div#bx-form-element-module,
 div.bx-grid-header div#bx-form-element-keyword {
     margin-top: 0px;
@@ -19,9 +16,6 @@ div.bx-grid-header div.bx-form-element-wrapper {
     position: relative;
     float: left;
 }
-div.bx-grid-header div#bx-form-element-display {
-    margin-right: 20px;
-}
 
 /*--- Fields page -> Add/Edit Field block style ---*/
 div#bx-form-element-type .bx-menu-ver li {

+ 0 - 8
studio/template/css/builder_forms.css

@@ -14,11 +14,6 @@ div.bx-form-add-display {}
 div.bx-form-add-display-cnt {}
 
 /*--- Displays page -> Panel Top style ---*/
-div.bx-grid-header div#bx-form-element-module,
-div.bx-grid-header div#bx-form-element-form,
-div.bx-grid-header div#bx-form-element-list {
-    margin-right: 20px;
-}
 div.bx-grid-header div#bx-form-element-form,
 div.bx-grid-header div#bx-form-element-display,
 div.bx-grid-header div#bx-form-element-module,
@@ -32,9 +27,6 @@ div.bx-grid-header div.bx-form-element-wrapper {
     position: relative;
     float: left;
 }
-div.bx-grid-header div#bx-form-element-display {
-    margin-right: 20px;
-}
 
 /*--- Fields page -> Add/Edit Field block style ---*/
 div#bx-form-element-type .bx-menu-ver li {

+ 186 - 41
studio/template/css/builder_page.css

@@ -5,24 +5,25 @@ div.bx-std-pt-bp-types div.bx-form-element-wrapper {
 div.bx-std-pt-bp-pages div.bx-form-element-wrapper {
     width: 20rem;
 }
-div.bx-std-pt-bp-selects .select2-container .select2-selection--single {
-    height: 2.5rem;
-    border-radius: 0.375rem;
-    opacity: 0.9;
-}
-div.bx-std-pt-bp-selects .select2-container .select2-selection--single .select2-selection__rendered {
-    padding-left: 0;
-    line-height: 2.375rem;
-}
-div.bx-std-pt-bp-selects .select2-container .select2-selection--single .select2-selection__arrow {
-    height: 2.375rem;
-}
+/* The page option in the dropdown: title over its URL, the block count on the right (the field itself is styled with every Studio select2 in tailwind.css). */
 .adm-bp-page {
     display: flex;
     justify-content: space-between;
     align-items: center;
+    gap: 0.75rem;
+    min-width: 0;
+}
+.adm-bp-page > div:first-child {
+    min-width: 0;
+}
+.adm-bp-page .adm-bpp-ttl,
+.adm-bp-page .adm-bpp-inf {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
 }
 .adm-bp-page .adm-bpp-cnt {
+    flex: none;
     font-weight: 600;
 }
 
@@ -199,11 +200,27 @@ div.bx-popup .bx-uploader-ghost .bx-ug-content input.bx-ug-dimension.bx-form-inp
 	width: 4.0rem;
 }
 
-/*--- Page Settings popups style ---*/
+/*--- Page Settings popups style: the popup content is the form itself, sidebar (8px inset, like the app pages' side menus) beside the
+   settings (16px inset), the footer with the buttons under both (16px inset) behind a hairline; the wrappers the form renderer adds carry no spacing of their own. ---*/
 div#adm-bp-add-popup div.bx-popup-content,
 div#adm-bp-edit-popup div.bx-popup-content {
-    padding-left: 0px;
-    padding-right: 0px; 
+    padding: 0;
+}
+div.adm-bp-add-form-cnt > form > .bx-form-advanced-wrapper > .bx-form-section-wrapper,
+div.adm-bp-edit-form-cnt > form > .bx-form-advanced-wrapper > .bx-form-section-wrapper {
+    margin: 0; /* the outer section only: the settings groups keep their own field spacing */
+}
+div.adm-bp-add-form-cnt > form > .bx-form-advanced-wrapper > .bx-form-section-wrapper > .bx-form-section,
+div.adm-bp-edit-form-cnt > form > .bx-form-advanced-wrapper > .bx-form-section-wrapper > .bx-form-section {
+    border: 0;
+}
+div.adm-bp-add-form-cnt > form > .bx-form-advanced-wrapper > .bx-form-section-wrapper > .bx-form-section > .bx-form-section-content,
+div.adm-bp-edit-form-cnt > form > .bx-form-advanced-wrapper > .bx-form-section-wrapper > .bx-form-section > .bx-form-section-content {
+    padding: 0;
+}
+div.adm-bp-add-form-cnt > form > .bx-form-advanced-wrapper > .bx-form-section-wrapper > .bx-form-section > .bx-form-section-content > .bx-form-element-wrapper,
+div.adm-bp-edit-form-cnt > form > .bx-form-advanced-wrapper > .bx-form-section-wrapper > .bx-form-section > .bx-form-section-content > .bx-form-element-wrapper {
+    margin: 0;
 }
 
 div.adm-bp-add-form {}
@@ -216,51 +233,179 @@ div.adm-bp-add-form fieldset.bx-form-section,
 div.adm-bp-edit-form fieldset.bx-form-section {
     padding-bottom: 0px;
 }
-div.adm-bp-add-form div.bx-std-pmen-item-cnt, 
-div.adm-bp-edit-form div.bx-std-pmen-item-cnt {
-	padding-left: 20px;
-}
 div.adm-bp-add-form div.bx-form-input-wrapper-custom, 
 div.adm-bp-edit-form div.bx-form-input-wrapper-custom {
 	position: relative;
 }
+/* The form fills the popup box: every wrapper down to the settings input is a shrinkable flex column, so the box (capped at the viewport
+   less 2rem by the Studio popup rules) sizes to the content on a tall screen and the panels scroll inside it on a short one. */
+div#adm-bp-add-popup div.bx-popup-content,
+div#adm-bp-edit-popup div.bx-popup-content,
+:is(div#adm-bp-add-popup, div#adm-bp-edit-popup) :is(div.adm-bp-add-form, div.adm-bp-add-form-cnt, div.adm-bp-edit-form, div.adm-bp-edit-form-cnt, form.bx-form-advanced, .bx-form-advanced-wrapper, .bx-form-advanced-wrapper > .bx-form-section-wrapper, .bx-form-advanced-wrapper > .bx-form-section-wrapper > .bx-form-section, .bx-form-advanced-wrapper > .bx-form-section-wrapper > .bx-form-section > .bx-form-section-content, #bx-form-element-settings, #bx-form-element-settings > .bx-form-element, #bx-form-element-settings > .bx-form-element > .bx-form-value) {
+    display: flex;
+    flex-direction: column;
+    flex: 1 1 auto;
+    min-height: 0;
+}
+/* Sidebar | settings, the footer under both: a grid whose row is the sidebar's own height (seven 48px rows, six 2px gaps, 8px inset) at
+   most, so the box keeps one height across the tabs, the settings scroll past it, and the row gives way when the viewport is short. */
+:is(div#adm-bp-add-popup, div#adm-bp-edit-popup) div.bx-form-input-wrapper-custom {
+    display: grid;
+    grid-template-columns: 14rem minmax(0, 1fr);
+    grid-template-rows: minmax(0, 22.75rem) auto;
+    flex: 1 1 auto;
+    min-height: 0;
+}
 div.adm-bp-epf-left {
-    position: absolute;
-    top: 0px;
-    left: 0px;
-    width: 14rem;
-    height: 20rem;
-    overflow-y: auto; 
+    grid-column: 1;
+    grid-row: 1;
+    min-height: 0;
+    overflow-y: auto;
+    box-shadow: 1px 0 0 0 rgba(0, 0, 0, 0.05); /* vertical hairline between the sidebar and the settings */
+}
+.dark div.adm-bp-epf-left {
+    box-shadow: 1px 0 0 0 rgba(255, 255, 255, 0.025);
 }
 div.adm-bp-epf-left-cnt {}
-div.adm-bp-epf-left .bx-std-pmen-item {
-    height: 2.5rem;
+/* The modal's sidebar keeps the desktop row layout at every viewport: its column is 14rem wide, unlike the app pages' side menu that
+   turns into a strip of square tiles under 1024px and a row of tabs under 768px (general.css). */
+div.adm-bp-epf-left div.bx-std-pmen-item {
+    flex: none;
+    height: 3rem;
+    min-width: 0;
+    margin-top: 0.125rem;
+}
+div.adm-bp-epf-left div.bx-std-pmen-item:first-child {
+    margin-top: 0;
+}
+div.adm-bp-epf-left div.bx-std-pmen-item > a {
+    display: block;
+    height: auto;
+}
+div.adm-bp-epf-left div.bx-std-pmen-item-cnt {
+    -webkit-flex-direction: row;
+    flex-direction: row;
+    -webkit-justify-content: flex-start;
+    justify-content: flex-start;
+    height: auto;
+    padding: 0.5rem;
+}
+div.adm-bp-epf-left div.bx-std-pmen-item .bx-std-pmen-ic-icon {
+    display: block;
+    width: 2rem;
+    height: 2rem;
+    margin: 0 0.75rem 0 0;
+}
+.bx-dir-rtl div.adm-bp-epf-left div.bx-std-pmen-item .bx-std-pmen-ic-icon {
+    margin: 0 0 0 0.75rem;
+}
+div.adm-bp-epf-left div.bx-std-pmen-item .bx-std-pmen-ic-title {
+    display: block;
+    -webkit-line-clamp: unset;
+    max-width: none;
+    margin-top: 0;
+    font-size: 1rem;
+    line-height: normal;
+    text-align: start;
+    white-space: nowrap;
+    overflow-wrap: normal;
 }
 div.adm-bp-epf-right {
-    position: relative;
-    height: 20rem;
-    margin-left: 14rem;
+    grid-column: 2;
+    grid-row: 1;
+    min-height: 0;
     overflow-y: auto;
 }
 div.adm-bp-epf-right-cnt {}
+div.adm-bp-epf-controls {
+    grid-column: 1 / -1;
+    grid-row: 2;
+}
+div.adm-bp-epf-controls {
+    box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.05); /* hairline over the footer, like the settings pages' Save footer */
+}
+.dark div.adm-bp-epf-controls {
+    box-shadow: 0 -1px 0 0 rgba(255, 255, 255, 0.025);
+}
 div.adm-bp-epf-controls .bx-def-margin-sec-left {
     margin-left: 0.5rem;
 }
+/* Layout gallery: a grid that fills the panel (as many 104px-plus columns as fit, stretched to the width), each layout a card on the
+   dashboard hero plate (translucent fill, 2.5% inset hairline, 16px radius) with its name under the picture, like the launcher tiles;
+   the chosen one carries a 2px blue ring and a bold name. */
 div#adm-bp-settings-group-layout {
-    margin: 0px -10px;
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
+    gap: 0.5rem;
+    margin: 0;
+}
+div#adm-bp-settings-group-layout > .bx-std-cb {
+    display: none; /* the float clearer would be an empty grid cell */
+}
+div#adm-bp-settings-group-layout > form {
+    grid-column: 1 / -1; /* the hidden layout_id field: a zero-height row, not a cell */
 }
 div.adm-bp-layout {
-	position: relative;
-	float: left;
-	width: 66px;
-	height: 66px;
-	margin-left: 0.5rem;
-	margin-right: 0.5rem;
-	border: 2px solid transparent;
+    position: relative;
+    border-radius: 1rem;
+    background: rgba(0, 0, 0, 0.05);
+    box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.025);
+    transition: background-color 0.3s;
+}
+.dark div.adm-bp-layout {
+    background: rgba(255, 255, 255, 0.025);
+    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025);
+}
+div.adm-bp-layout:hover {
+    background: rgba(0, 0, 0, 0.1);
 }
-div.adm-bp-layout-active {
-	border-color: #9999cc;
+.dark div.adm-bp-layout:hover {
+    background: rgba(255, 255, 255, 0.05);
+}
+div.adm-bp-layout-active,
+.dark div.adm-bp-layout-active {
+    box-shadow: inset 0 0 0 2px rgb(59 130 246);
+}
+div.adm-bp-layout:has(a:focus-visible) {
+    box-shadow: inset 0 0 0 2px rgb(59 130 246), 0 0 0 2px rgb(255 255 255), 0 0 0 4px rgb(59 130 246);
 }
 div.adm-bp-layout-cnt {
-	padding: 1px;
+    padding: 0.5rem;
+}
+div.adm-bp-layout-cnt > a {
+    display: -webkit-flex;
+    display: flex;
+    -webkit-flex-direction: column;
+    flex-direction: column;
+    -webkit-align-items: center;
+    align-items: center;
+    gap: 0.5rem;
+    outline: none;
+    text-decoration: none;
+}
+div.adm-bp-layout-cnt img {
+    display: block;
+    width: 66px;
+    height: 66px;
+}
+span.adm-bp-layout-title {
+    display: block;
+    max-width: 100%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: 0.75rem;
+    line-height: 1rem;
+    text-align: center;
+    color: rgb(75 85 99);
+}
+.dark span.adm-bp-layout-title {
+    color: rgb(156 163 175);
+}
+div.adm-bp-layout-active span.adm-bp-layout-title {
+    font-weight: 600;
+    color: rgb(17 24 39);
+}
+.dark div.adm-bp-layout-active span.adm-bp-layout-title {
+    color: rgb(255 255 255);
 }

+ 0 - 80
studio/template/css/common.less

@@ -29,39 +29,6 @@ a:hover > .sys-icon-a i {
 	color: rgba(10, 120, 220, 1);
 }
 
-/* design boxes */
-.bx-db-container {
-    box-shadow: none;
-}
-.bx-db-header {
-    padding: 1.25rem 1.5rem 0 1.5rem;
-}
-.bx-db-header .bx-db-menu-tab-btn .bx-btn {
-    display: inline-flex;
-}
-.bx-db-header .bx-db-title {
-    line-height: 2rem;
-    font-weight: 600;
-    font-size: 1.25rem;
-}
-.bx-db-content.bx-content-padding-block {
-    padding: 1.5rem;
-}
-.bx-db-content.bx-content-padding-block .bx-dbd-block-actions {
-    margin-left: -1.5rem;
-    margin-right: -1.5rem;
-    padding: 0 1.5rem;
-}
-.bx-db-content.bx-def-padding-top {
-    padding: 1rem;
-}
-div.bx-db-note {
-    position: relative;
-    float: left;
-    line-height: 40px;
-    font-size: 11px;
-}
-
 /* popup */
 .bx-popup-applied:not(.bx-popup-chain) .bx-popup,
 .bx-popup-applied.bx-popup-chain .bx-popup-chain-item {
@@ -106,53 +73,6 @@ div.bx-db-note {
     font-weight: 600;
 }
 
-/* button large */
-.bx-btn,
-.bx-btn.bx-btn-primary {
-    .box-sizing(border-box);
-
-    height: 2.5rem;
-    padding: 0 1rem;
-
-    .radius(8px);
-
-    -webkit-box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.08);
-    -moz-box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.08);
-    box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.08);
-
-    text-shadow: none;
-}
-
-.bx-btn.bx-btn-primary:hover,
-.bx-btn.bx-btn-primary:active {
-    .background-color(rgba(68, 134, 244, 0.8));
-    color: rgba(251, 252, 253, 1.0);
-}
-.bx-btn.bx-btn-primary .sys-icon:not(.sys-colored) {
-    color: rgba(255, 255, 255, 0.9);
-}
-.bx-btn.bx-btn-primary:hover .sys-icon:not(.sys-colored),
-.bx-btn.bx-btn-primary:active .sys-icon:not(.sys-colored) {
-    color: rgba(255, 255, 255, 1);
-}
-
-/* button small */
-.bx-btn.bx-btn-small {
-    .box-sizing(border-box);
-
-    height: 2.0rem;
-    line-height: 2.0rem;
-    padding: 0 1rem;
-
-    .radius(6px);
-
-    text-shadow: none;
-
-    font-family: sans-serif;
-    font-size: 0.875rem;
-    font-weight: 500;
-}
-
 /**
  * General
  */

+ 714 - 25
studio/template/css/dashboard.css

@@ -10,44 +10,733 @@ div.bx-dbd-block-content {
 	position: relative;
 }
 
-/*--- Versions block ---*/
-div.bx-dbd-version-image img {
+/*--- Block grid: blocks fill as many columns as fit, each at least --bx-dbd-col-min wide (room for a chart and its insets) and never more than --bx-dbd-cols-max of them; the page stops widening once every column reaches --bx-dbd-col-max. Rows are --bx-dbd-row tall and a block spans one, two or three of them (small / medium / large, chosen through the block's class in the Pages builder), so blocks always line up on the same unit grid and never change height while content loads: anything taller scrolls inside the block. ---*/
+.bx-layout-wrapper.bx-layout-1-column {
+    --bx-dbd-col-min: 27rem;
+    --bx-dbd-col-max: 40rem;
+    --bx-dbd-cols-max: 4;
+    --bx-dbd-gap: 0.75rem; /* 12px, like the gutters around the page */
+    --bx-dbd-row: 21rem; /* 336px: the 64px caption plus 272px of content */
+    max-width: calc(var(--bx-dbd-cols-max) * var(--bx-dbd-col-max) + (var(--bx-dbd-cols-max) - 1) * var(--bx-dbd-gap));
+    margin-left: auto;
+    margin-right: auto;
+}
+.bx-layout-wrapper.bx-layout-1-column div.bx-layout-cell {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(min(max(var(--bx-dbd-col-min), calc((100% - (var(--bx-dbd-cols-max) - 1) * var(--bx-dbd-gap)) / var(--bx-dbd-cols-max))), 100%), 1fr)); /* min(..., 100%): a phone narrower than --bx-dbd-col-min still gets one full-width column instead of a clipped one */
+    grid-auto-rows: var(--bx-dbd-row);
+    grid-auto-flow: row dense; /* a smaller block slots into the earliest gap left by a taller one */
+    gap: var(--bx-dbd-gap);
+}
+.bx-layout-wrapper.bx-layout-1-column .bx-page-block-container > .bx-std-block {
+    height: 100%;
+}
+.bx-page-block-container.bx-dbd-block-small {
+    grid-row: span 1;
+}
+.bx-page-block-container.bx-dbd-block-medium {
+    grid-row: span 2;
+}
+.bx-page-block-container.bx-dbd-block-large {
+    grid-row: span 3;
+}
+/* Builder blocks on this page: the block shell's 8px inset plus 8px here, so content sits 16px from the edges; the bottom inset is 8px, since every block ends with a 48px row whose own 8px of air brings its tile 16px from the edge, mirroring the hero 8px under the caption. */
+.bx-page-block-container div.bx-std-block-content {
+    padding: 1rem 1rem 0.5rem;
+}
+
+/*--- Updates block: the version panel, the update switches, then the status rows; the Update button sits in the block caption. ---*/
+/*--- Hero panels: the muted panel that opens the Updates (icon + version), Space/Cache/Queues (headline + bar), Server (gauges) and Audit (outcomes) blocks; each is as tall as its content, 16px inset, and reaches 8px past the block's 16px content inset (top and sides) to sit 8px from the block edges, flush with the audit accordion's hover plates. ---*/
+.bx-dbd-hero {
+    position: relative; /* the chart's slice tooltip is placed in it */
+    display: flex;
+    flex-direction: column;
+    margin: -0.5rem -0.5rem 0;
+    padding: 1rem;
+    border-radius: 1rem;
+    background: rgba(0, 0, 0, 0.05);
+    box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.025);
+}
+.dark .bx-dbd-hero {
+    background: rgba(255, 255, 255, 0.025);
+    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025);
+}
+/* Updates: the 64px UNA icon with the version and, under it, when that version was installed beside it, the "Check for updates" button 16px under them. */
+.bx-dbd-version-head {
+    align-items: flex-start;
+    gap: 1rem;
+}
+.bx-dbd-version-id {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    width: 100%;
+}
+/* The UNA icon is a 64px app icon with 16px corners; the rows below keep 32px tiles. */
+div.bx-std-block-content .bx-dbd-tile.bx-dbd-tile-lg {
     width: 4rem;
     height: 4rem;
-    border-radius: 0.75rem;
+    border-radius: 1rem;
+}
+.bx-dbd-vi-text {
+    flex: 1 1 auto;
+    min-width: 0;
+}
+.bx-dbd-vi-title {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    font-size: 1.25rem;
+    font-weight: 600;
+    line-height: 1.75rem;
+}
+.bx-dbd-vi-time {
+    font-size: 0.875rem;
+    line-height: 1.25rem;
+    color: rgb(75 85 99); /* gray-600: gray-500 misses 4.5:1 on white */
+}
+/* The update switches follow the panel after a 10px gap; the status rows continue the same list 2px below them. */
+#bx-dbd-version .bx-dbd-htools-items {
+    margin-top: 0.125rem;
+}
+.bx-dbd-version-toggles {
+    display: flex;
+    flex-direction: column;
+    gap: 0.125rem;
+    margin-top: 0.625rem; /* hero to list: 10px, as in the chart blocks */
 }
-.bx-dbd-version-info .bx-dbd-vii-row {
-    display: -webkit-flex;
+.bx-dbd-version-toggle {
     display: flex;
-    -webkit-align-items: center;
     align-items: center;
-    -webkit-flex-wrap: nowrap;
-    flex-wrap: nowrap;
+    gap: 0.75rem;
+    min-height: 3rem;
+}
+.bx-dbd-version-toggle-name {
+    flex: 1 1 auto;
+    min-width: 0;
+}
+.bx-dbd-htools-value a {
+    color: rgb(59 130 246);
 }
 
-/*--- Chart based blocks ---*/
+/*--- Chart based blocks: a hero panel with the "used" headline over a stacked bar with one slice per item, then a row per item with its icon, colour mark and size (Queues: the headline alone in the panel, rows without marks). ---*/
 div.bx-dbd-chart {
-	width: 400px;
-	height: 200px;
-	margin: 0px auto; 
+    display: flex;
+    flex-direction: column;
+    gap: 0.625rem; /* hero to list: 10px, so the first tile is 18px under the hero, the same air as between tiles (8 + 2 + 8) */
 }
-
-/*--- Host Tools block ---*/
-div.bx-dbd-htools .bx-btn {
-    display: inline-block;
-    float: none;
+/* Charts: the headline over the bar, 24px apart. Labelled charts (Space: local and remote; Cache; the Audit tab's outcomes): 52px rows, 8px apart, a compact label / stat line over each 28px bar; a small gray line 4px under the bar can name the largest items (Cache) or the outcome counts (Audit); a button (Cache's "clear all", "Run new audit") follows in a foot row 8px lower. */
+.bx-dbd-chart-hero {
+    gap: 1.5rem;
+}
+.bx-dbd-chart-hero-rows {
+    gap: 0.5rem;
+}
+.bx-dbd-chart-row {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+}
+.bx-dbd-chart-summary {
+    font-size: 0.875rem;
+    line-height: 1rem;
+    color: rgb(75 85 99); /* gray-600: gray-500 misses 4.5:1 on white */
+}
+.bx-dbd-chart-foot {
+    display: flex;
+    align-items: flex-end;
+    justify-content: space-between;
+    gap: 1rem;
+}
+.bx-dbd-chart-head-sm {
+    font-size: 0.875rem;
+    line-height: 1.25rem;
+}
+.bx-dbd-chart-label {
+    font-weight: 600;
+    color: rgb(55 65 81);
+}
+.dark .bx-dbd-chart-label {
+    color: rgb(209 213 219);
+}
+.bx-dbd-chart-stat {
+    color: rgb(75 85 99); /* gray-600: gray-500 misses 4.5:1 on white */
+}
+/* Slice tooltip: a dark pill above the bar, centred on the hovered slice (dashboard.js). */
+.bx-dbd-chart-tip {
+    position: absolute;
+    z-index: 1;
+    padding: 0.25rem 0.5rem;
+    border-radius: 0.375rem;
+    background: rgb(17 24 39);
+    color: #ffffff;
+    font-size: 0.75rem;
+    line-height: 1rem;
+    white-space: nowrap;
+    pointer-events: none;
+    transform: translateX(-50%);
+}
+.bx-dbd-chart-tip[hidden] {
+    display: none;
+}
+.dark .bx-dbd-chart-tip {
+    background: rgba(0, 0, 0, 0.05);
+    color: rgb(17 24 39);
+}
+.bx-dbd-chart-head {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 1rem;
+}
+.bx-dbd-chart-used {
+    font-size: 1.25rem;
+    font-weight: 600;
+}
+.bx-dbd-chart-total {
+    color: rgb(75 85 99); /* gray-600: gray-500 misses 4.5:1 on white */
+}
+.bx-dbd-chart-bar {
+    display: flex;
+    gap: 2px;
+    height: 1.75rem;
+    overflow: hidden;
+    border-radius: 0.5rem;
+    background: rgb(0 0 0 / 0.05); /* the unused remainder, translucent so it reads on the hero plate */
+    box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.025);
+}
+.dark .bx-dbd-chart-bar {
+    background: rgb(255 255 255 / 0.05);
+    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.025);
+}
+.bx-dbd-chart-slice {
+    flex: 0 1 auto;
+    min-width: 0.375rem; /* a sliver stays wide enough to hover */
+    cursor: default; /* hover shows the slice's item name */
+}
+/* Rows on the block's rhythm: 48px rows, 2px apart, a 32px icon tile like the sidebar tiles; one group of rows per chart, the groups continuing the same list. */
+.bx-dbd-chart-group {
+    display: flex;
+    flex-direction: column;
+    gap: 0.125rem;
+}
+.bx-dbd-chart-group + .bx-dbd-chart-group {
+    margin-top: 0.125rem;
+}
+.bx-dbd-chart-item {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    min-height: 3rem;
+}
+/* Colour mark after the value (before a row's clear button): an 8 x 20px pill in the row's slice colour. */
+.bx-dbd-chart-mark {
+    flex: none;
+    width: 0.5rem;
+    height: 1.25rem;
+    border-radius: 9999px;
+}
+/* Icon tiles (chart rows, the Version block): 32px sidebar-style tiles, 8px radius, hairline ring, no button shadow (the selector outweighs the tile button's shadow rule). */
+div.bx-std-block-content .bx-dbd-tile {
+    position: relative;
+    display: flex;
+    flex: none;
+    align-items: center;
+    justify-content: center;
+    width: 2rem;
+    height: 2rem;
+    border-radius: 0.5rem;
+    box-shadow: none;
+}
+/* Row icons are not buttons: the block caption icon's flat plate (black/10, white/5 in dark), no gradient, ring or shadow. App art tiles carry their own image. */
+div.bx-std-block-content .bx-dbd-tile:not(.bx-dbd-tile-app) {
+    background: rgb(0 0 0 / 0.1);
+    color: rgb(75 85 99);
+}
+.dark div.bx-std-block-content .bx-dbd-tile:not(.bx-dbd-tile-app) {
+    background: rgb(255 255 255 / 0.05);
+    color: rgb(156 163 175);
+}
+/* The glyph: black (white in the dark scheme) at 80% through the element's opacity, so a glyph's overlapping strokes do not darken where they cross. */
+.bx-dbd-tile .sys-icon {
+    color: rgb(0 0 0);
+    opacity: 0.8;
+}
+.dark .bx-dbd-tile .sys-icon {
+    color: rgb(255 255 255);
+}
+.bx-dbd-tile svg {
+    width: 1.25rem;
+    height: 1.25rem;
+}
+.bx-dbd-tile-app img {
+    width: 100%;
+    height: 100%;
+    border-radius: inherit;
+    object-fit: cover;
+}
+.bx-dbd-tile-app::after {
+    content: '';
+    position: absolute;
+    inset: 0;
+    border-radius: inherit;
+    pointer-events: none;
+    box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.1); /* the same inset hairline the tile plates draw */
+}
+.dark .bx-dbd-tile-app::after {
+    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.05);
+}
+.bx-dbd-chart-name {
+    flex: 1 1 auto;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+}
+.bx-dbd-chart-size {
+    flex: none;
+    color: rgb(75 85 99); /* gray-600: gray-500 misses 4.5:1 on white */
+}
+/* A row's note (a file count) is quiet; a warning note (failed queue jobs) and the headline's failed total are red. */
+.bx-dbd-chart-note {
+    flex: none;
+    font-size: 0.875rem;
+    color: rgb(107 114 128);
+}
+.bx-dbd-chart-note-alert,
+.bx-dbd-chart-total-fail {
+    flex: none;
+    color: rgb(239 68 68);
+}
+.dark .bx-dbd-chart-note-alert {
+    color: rgb(248 113 113);
+}
+/* Per-row clear button: the icon-only 32px square button (tailwind.css "Studio: Buttons"), kept at its width beside the flexible name. */
+.bx-dbd-chart-item .bx-dbd-chart-action {
+    flex: none;
 }
 
-div.bx-dbd-htools .bx-dbd-htools-item {
-    display: -webkit-flex;
+/*--- Server block: the segmented tabs sit in the block caption; the Overview panel holds gauges and status rows, Audit and Permissions load their reports into their own panels. ---*/
+/* Server: the three gauges side by side in the hero. */
+.bx-dbd-gauges {
+    flex-direction: row;
+    align-items: center;
+    justify-content: space-around;
+    gap: 1rem;
+}
+.bx-dbd-gauge {
+    display: flex;
+    flex: 0 1 5.5rem; /* four 88px rings when the block is wide enough, shrinking together when it is not */
+    flex-direction: column;
+    align-items: center;
+    gap: 0.25rem;
+    min-width: 0;
+}
+.bx-dbd-gauge-ring {
+    position: relative;
+    width: 100%;
+    max-width: 5.5rem;
+    aspect-ratio: 1 / 1;
+}
+/* Phones: 72px rings (r 30 at the 96 viewBox's r 40) so the four fit a 343px block. */
+@media (max-width: 767px) {
+    .bx-dbd-gauges {
+        gap: 0.5rem;
+    }
+    .bx-dbd-gauge {
+        flex-basis: 4.5rem;
+    }
+    .bx-dbd-gauge-ring {
+        max-width: 4.5rem;
+    }
+    .bx-dbd-gauge-ring .bx-dbd-gauge-value { /* outweighs the base rule below */
+        font-size: 0.875rem;
+    }
+}
+.bx-dbd-gauge-ring svg {
+    width: 100%;
+    height: 100%;
+    transform: rotate(-90deg); /* the arc starts at 12 o'clock */
+}
+.bx-dbd-gauge-track,
+.bx-dbd-gauge-arc {
+    fill: none;
+    stroke-width: 8;
+}
+/* The track is translucent so it reads on the hero plate; the arcs use the 600/700 steps in light mode (the 500s miss 3:1 on the plate) and the 500s in dark mode. */
+.bx-dbd-gauge-track {
+    stroke: rgb(0 0 0 / 0.1);
+}
+.dark .bx-dbd-gauge-track {
+    stroke: rgb(255 255 255 / 0.1);
+}
+.bx-dbd-gauge-cpu .bx-dbd-gauge-arc {
+    stroke: rgb(37 99 235); /* blue-600 */
+}
+.bx-dbd-gauge-memory .bx-dbd-gauge-arc {
+    stroke: rgb(124 58 237); /* violet-600 */
+}
+.bx-dbd-gauge-disk .bx-dbd-gauge-arc {
+    stroke: rgb(13 148 136); /* teal-600 */
+}
+.bx-dbd-gauge-network .bx-dbd-gauge-arc {
+    stroke: rgb(194 65 12); /* orange-700: orange-600 is 2.9:1 */
+}
+.bx-dbd-gauge-high .bx-dbd-gauge-arc {
+    stroke: rgb(220 38 38); /* red-600, 90% and above */
+}
+.dark .bx-dbd-gauge-cpu .bx-dbd-gauge-arc {
+    stroke: rgb(59 130 246);
+}
+.dark .bx-dbd-gauge-memory .bx-dbd-gauge-arc {
+    stroke: rgb(139 92 246);
+}
+.dark .bx-dbd-gauge-disk .bx-dbd-gauge-arc {
+    stroke: rgb(20 184 166);
+}
+.dark .bx-dbd-gauge-network .bx-dbd-gauge-arc {
+    stroke: rgb(249 115 22);
+}
+.dark .bx-dbd-gauge-high .bx-dbd-gauge-arc {
+    stroke: rgb(239 68 68);
+}
+.bx-dbd-gauge-value {
+    position: absolute;
+    inset: 0;
     display: flex;
-    -webkit-align-items: center;
+    flex-direction: column;
     align-items: center;
-    -webkit-flex-wrap: nowrap;
-    flex-wrap: nowrap;
+    justify-content: center;
+    line-height: 1.25;
+    font-size: 1.125rem;
+    font-weight: 600;
+}
+/* A figure with a unit (the network rate): the unit sits small under the number. */
+.bx-dbd-gauge-unit {
+    font-size: 0.625rem;
+    font-weight: 500;
+    line-height: 1;
+    letter-spacing: 0.02em;
+    color: rgb(75 85 99); /* gray-600: gray-500 misses 4.5:1 on white */
+}
+.bx-dbd-gauge-label {
+    font-size: 0.75rem;
+    line-height: 1.25rem;
+    letter-spacing: 0.05em;
+    text-transform: uppercase;
+    color: rgb(75 85 99); /* gray-600: gray-500 misses 4.5:1 on white */
+}
+/* Status rows: 48px rows with 2px between them; a row with a note grows to fit it. */
+.bx-dbd-htools-items {
+    display: flex;
+    flex-direction: column;
+    gap: 0.125rem;
+}
+/* Updates block: a hairline between the switches and the status rows, 8px above and below. */
+.bx-dbd-htools-items-divided {
+    margin-top: 0.5rem;
+    padding-top: 0.5rem;
+    border-top: 1px solid rgb(0 0 0 / 0.1);
+}
+.dark .bx-dbd-htools-items-divided {
+    border-top-color: rgb(255 255 255 / 0.1);
+}
+/* Server Status: the section rows follow the gauges after the usual hero-to-list gap, no divider. */
+#bx-dbd-htools-status > .bx-dbd-htools-items {
+    margin-top: 0.625rem;
+}
+/* Users: the rows follow the chart hero after the same gap; the profile rows continue that one list, no divider. */
+#bx-dbd-users > .bx-dbd-htools-items {
+    margin-top: 0.625rem;
+}
+.bx-dbd-htools-item {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    min-height: 3rem;
+    padding: 0.25rem 0;
+}
+/* The status dot sits after the row's value (like the chart rows' colour marks), 10px, in the status colour. */
+.bx-dbd-htools-dot {
+    flex: none;
+    width: 0.625rem;
+    height: 0.625rem;
+    border-radius: 9999px;
+    background: rgb(156 163 175); /* unknown */
+}
+.bx-dbd-htools-item-ok .bx-dbd-htools-dot {
+    background: rgb(34 197 94);
+}
+.bx-dbd-htools-item-warn .bx-dbd-htools-dot {
+    background: rgb(249 115 22);
+}
+.bx-dbd-htools-item-fail .bx-dbd-htools-dot {
+    background: rgb(239 68 68);
+}
+.bx-dbd-htools-name {
+    display: flex;
+    flex: 1 1 auto;
+    flex-direction: column;
+    min-width: 0;
+}
+.bx-dbd-htools-desc {
+    font-size: 0.875rem;
+    line-height: 1.25rem;
+    color: rgb(75 85 99); /* gray-600: gray-500 misses 4.5:1 on white */
+    overflow-wrap: anywhere; /* notes quote file paths, which have no break points */
+}
+.bx-dbd-htools-desc a {
+    text-decoration: underline;
+}
+.bx-dbd-htools-value {
+    flex: none;
+    margin-left: 1rem;
+    font-weight: 600;
+    text-align: right;
+}
+/* A button in a status row (Updates: Activate) sits where the value does. */
+.bx-dbd-htools-item > .bx-btn {
+    flex: none;
+    margin-left: auto;
+}
+/* Rows that open a page (Space: the Storage app's files and images, each app) are links with the audit accordion's hover plate. */
+.bx-dbd-chart-link {
+    color: inherit;
+    text-decoration: none;
+}
+/* Audit tab: the outcome bar's segments in the status colours; then the sections as an accordion, each header a button on the
+   same 48px row as the Status rows (its hover plate reaching 8px past the content), its rows folded under it, indented past the tile. */
+.bx-dbd-audit-slice-ok {
+    background: rgb(34 197 94);
+}
+.bx-dbd-audit-slice-warn {
+    background: rgb(249 115 22);
+}
+.bx-dbd-audit-slice-fail {
+    background: rgb(239 68 68);
+}
+.bx-dbd-audit-slice-undef {
+    background: rgb(156 163 175);
+}
+
+.bx-dbd-audit {
+    display: flex;
+    flex-direction: column;
+    gap: 0.125rem;
+    margin-top: 0.625rem; /* hero to list: 10px, as in the chart blocks */
+}
+.bx-dbd-audit-toggle,
+.bx-dbd-chart-link {
+    width: calc(100% + 1rem);
+    margin: 0 -0.5rem;
+    padding: 0.25rem 0.5rem;
+    border: 0;
+    border-radius: 1rem; /* the same plate as the hero panels */
+    background: none;
+    color: inherit;
+    font: inherit;
+    text-align: start;
+    cursor: pointer;
+}
+.bx-dbd-audit-toggle:hover,
+.bx-dbd-chart-link:hover {
+    background: rgba(0, 0, 0, 0.05);
+    box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.025);
+}
+.dark .bx-dbd-audit-toggle:hover,
+.dark .bx-dbd-chart-link:hover {
+    background: rgba(255, 255, 255, 0.025);
+    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025);
+}
+.bx-dbd-audit-toggle:focus-visible,
+.bx-dbd-chart-link:focus-visible {
+    outline: 2px solid rgb(59 130 246);
+    outline-offset: 2px;
+}
+.bx-dbd-audit-chevron {
+    flex: none;
+    margin-left: 0.5rem;
+    transition: transform 0.2s;
+}
+.bx-dbd-audit-chevron svg.sys-icon {
+    width: 1.25rem;
+    height: 1.25rem;
+    color: inherit; /* the row's text colour, like the Settings section chevrons, not the iconset's blue */
+}
+.bx-dbd-audit-toggle[aria-expanded="true"] .bx-dbd-audit-chevron {
+    transform: rotate(180deg);
+}
+.bx-dbd-audit-rows {
+    padding: 0 0 0.5rem 2.75rem;
+}
+.bx-dbd-audit-rows[hidden] {
+    display: none; /* the list's flex display would otherwise beat the attribute */
+}
+
+
+/* Secondary text in dark mode: gray-400 on the gray-900 blocks (gray-500 gives 3:1 there). */
+.dark .bx-dbd-vi-time,
+.dark .bx-dbd-chart-summary,
+.dark .bx-dbd-chart-stat,
+.dark .bx-dbd-chart-total,
+.dark .bx-dbd-chart-size,
+.dark .bx-dbd-chart-note,
+.dark .bx-dbd-gauge-unit,
+.dark .bx-dbd-gauge-label,
+.dark .bx-dbd-htools-desc,
+.dark .bx-dbd-audit-section-title {
+    color: rgb(156 163 175);
+}
+
+@media (prefers-reduced-motion: reduce) {
+    .bx-dbd-audit-chevron,
+    .bx-dbd-tile,
+    .bx-dbd-version-toggle .bx-switcher-cont button,
+    .bx-dbd-version-toggle .bx-switcher-cont button span {
+        transition: none;
+    }
+}
+
+/* A panel whose content is on its way (the Server block's first load, a tab's first opening): the tile wave centred where the content will be (dashboard.js getLoader). */
+.bx-dbd-loading {
+    display: flex;
+    justify-content: center;
+    padding: 1.5rem 0;
 }
 
-/*--- Queues block ---*/
-#bx-grid-table-sys_queues {
-    width:100%;
+/*--- Users block: the chart alone in the hero (dashboard.js usersDraw): the chosen series over the chosen period, a bar per day or month
+   as tall as its share of the period's peak (the newest one darker) or, on the Total tab, the running total as a line; the period tabs sit in
+   the block caption and the rows under the chart pick the series. The bars are teal-700 (4.4:1 on the plate) and teal-900 in the light
+   scheme, teal-500 at 60% and teal-400 in the dark one. ---*/
+.bx-dbd-users-hero {
+    gap: 0.75rem; /* the figure sits 12px above the chart (the other chart heroes keep 24px) */
+}
+.bx-dbd-users-head {
+    align-items: center;
+}
+/* The hero's head: the charted series' figure (the Updates hero's title size) and its title on one baseline; on the right how many of it are
+   online now: the rows' status dot (green when someone is) before the figure, "Online now" as its tooltip and for screen readers only. */
+.bx-dbd-users-figure {
+    display: flex;
+    align-items: baseline;
+    gap: 0.375rem;
+    min-width: 0;
+}
+.bx-dbd-users-online {
+    flex: none;
+    justify-content: flex-end;
+    text-align: end;
+}
+.bx-dbd-users-online-dot {
+    align-self: center;
+    width: 0.625rem;
+    height: 0.625rem;
+    margin-right: 0.125rem;
+    border-radius: 9999px;
+    background: rgb(156 163 175);
+}
+.bx-dbd-users-online-on .bx-dbd-users-online-dot {
+    background: rgb(34 197 94);
+}
+/* The rows are the chart's legend (dashboard.js usersSeries): pressable like the audit rows, the one charted on a plate; the Online now row is plain. */
+.bx-dbd-users-row {
+    width: calc(100% + 1rem);
+    margin: 0 -0.5rem;
+    padding: 0.25rem 0.5rem;
+    border: 0;
+    border-radius: 1rem; /* the same plate as the hero panels */
+    background: none;
+    color: inherit;
+    font: inherit;
+    text-align: start;
+    cursor: pointer;
+}
+.bx-dbd-users-row:hover {
+    background: rgba(0, 0, 0, 0.03);
+}
+.bx-dbd-users-row[aria-pressed="true"],
+.bx-dbd-users-row[aria-pressed="true"]:hover {
+    background: rgba(0, 0, 0, 0.05);
+    box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.025);
+}
+.bx-dbd-users-row:focus-visible {
+    outline: 2px solid rgb(59 130 246);
+    outline-offset: -2px;
+}
+.dark .bx-dbd-users-row:hover {
+    background: rgba(255, 255, 255, 0.025);
+}
+.dark .bx-dbd-users-row[aria-pressed="true"],
+.dark .bx-dbd-users-row[aria-pressed="true"]:hover {
+    background: rgba(255, 255, 255, 0.05);
+    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025);
+}
+.bx-dbd-users-bars {
+    display: flex;
+    align-items: flex-end;
+    gap: 0.25rem;
+    height: 4.5rem; /* 72px, bars and the Total line alike */
+}
+.bx-dbd-users-bars-dense {
+    gap: 0.125rem; /* 30 bars */
+}
+.bx-dbd-users-bar {
+    flex: 1 1 0;
+    min-width: 0;
+    min-height: 0.25rem; /* an empty bucket still shows as a stub */
+    border-radius: 0.25rem;
+    background: rgb(15 118 110);
+    transition: background-color 0.15s;
+}
+.bx-dbd-users-bar:hover {
+    background: rgb(13 148 136);
+}
+.bx-dbd-users-bar-last {
+    background: rgb(19 78 74);
+}
+.dark .bx-dbd-users-bar {
+    background: rgb(20 184 166 / 0.6);
+}
+.dark .bx-dbd-users-bar:hover {
+    background: rgb(20 184 166 / 0.8);
+}
+.dark .bx-dbd-users-bar-last {
+    background: rgb(45 212 191);
+}
+/* Total: the running total as a line over the whole period (dashboard.js usersPeriod), teal like the bars with the area under it tinted;
+   the transparent columns over it carry the slice tooltip. */
+.bx-dbd-users-line {
+    display: block;
+    position: relative;
+}
+.bx-dbd-users-line-svg {
+    position: absolute;
+    inset: 0;
+    width: 100%;
+    height: 100%;
+    overflow: visible;
+}
+.bx-dbd-users-line-path {
+    fill: none;
+    stroke: rgb(15 118 110);
+    stroke-width: 2;
+    stroke-linejoin: round;
+    stroke-linecap: round;
+}
+.bx-dbd-users-line-area {
+    fill: rgb(15 118 110 / 0.12);
+}
+.bx-dbd-users-hits {
+    position: absolute;
+    inset: 0;
+    display: flex;
+}
+.bx-dbd-users-hit {
+    flex: 1 1 0;
+    min-width: 0;
+}
+.dark .bx-dbd-users-line-path {
+    stroke: rgb(45 212 191);
+}
+.dark .bx-dbd-users-line-area {
+    fill: rgb(20 184 166 / 0.15);
 }

+ 1 - 1
studio/template/css/designer.css

@@ -47,5 +47,5 @@ div.bx-dsg-cover {
 
 div.bx-dsg-cover-actions .bx-btn {
     float: none;
-    display: inline-block;
+    display: inline-flex; /* keeps the 32px Studio button's centred label */
 }

+ 10 - 0
studio/template/css/forms.css

@@ -4,6 +4,16 @@
 /**
  * General
  */
+.bx-form-caption a {
+    color: inherit;
+    text-decoration: underline dotted;
+    text-underline-offset: 3px;
+}
+.bx-form-caption a:hover {
+    color: inherit;
+    text-decoration-style: solid;
+}
+
 select.bx-form-field-type option {
     padding-left: 25px;
     background-repeat: no-repeat;

+ 122 - 9
studio/template/css/general.css

@@ -51,7 +51,7 @@ div.bx-std-content-wrp {
     height: 100vh;
 }
 div.bx-std-content-wrp.bx-std-one-col {
-    padding-top: 4rem;
+    padding-top: 4.75rem; /* the fixed header's bottom edge: 12px margin plus the 64px bar */
 
     overflow-y: auto;
 }
@@ -115,10 +115,10 @@ div.bx-std-pcap-mp-right div.bx-std-pcap-action {
  */
 div.bx-std-pmen-item {
     position: relative;
-    height: 3rem; /* 36px icon + 6px padding on each side, so the item background matches the header icons' ring-4 + ring-offset-2 */
+    height: 3rem; /* 32px icon + 8px above and below (the icon's 1px white gap on hover and the item background make up the header icons' halo) */
     margin-top: 0.125rem;
 
-    border-radius: 0.875rem; /* icon radius (8px) + 6px padding */
+    border-radius: 1rem; /* icon radius (8px) + 8px padding */
 
     font-size: 1.0rem;
     font-weight: 500;
@@ -135,7 +135,7 @@ div.bx-std-pmen-item-cnt {
     -webkit-flex-wrap: nowrap;
     flex-wrap: nowrap;
 
-    padding: 0.375rem;
+    padding: 0.5rem; /* with the column's 8px inset the icon sits 16px from the column's edge, like the launcher icon in the header bar */
 }
 
 span.bx-std-pmen-item-counter {}
@@ -145,11 +145,11 @@ div.bx-std-pmen-item .bx-std-pmen-ic-icon {
     display: none;
     flex: 0 0 auto;
 
-    width: 2.25rem;
-    height: 2.25rem;
+    width: 2rem;
+    height: 2rem;
     box-sizing: border-box;
 
-    margin-right: 1rem;
+    margin-right: 0.75rem;
     
     border-radius: 0.5rem;
     background-repeat: no-repeat;
@@ -158,13 +158,19 @@ div.bx-std-pmen-item .bx-std-pmen-ic-icon {
     text-align: center;
 }
 .bx-dir-rtl div.bx-std-pmen-item .bx-std-pmen-ic-icon {
-    margin-left: 1rem;
+    margin-left: 0.75rem;
     margin-right: 0;
 }
 
 div.bx-std-pmen-item .bx-std-pmen-ic-icon.block {
     display: block;
 }
+/* The label: one line, cut with an ellipsis, beside the tile; the tablet strip below lets it wrap to two lines instead. */
+div.bx-std-pmen-item .bx-std-pmen-ic-title {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
 
 div.bx-std-pmen-item img,
 div.bx-std-pmen-item svg,
@@ -232,4 +238,111 @@ img.bx-std-api {
     display: inline-block;
     width: 1rem;
     height: 1rem;
-}
+}
+
+/*
+ * Page menu on narrow screens (the columns themselves are in page_columns.css).
+ * Tablet: a vertical strip of square tiles (as wide as the strip's items, 88px), the 32px icon and a small label centred in each.
+ * Phone: a horizontal, scrollable row of tabs with 28px tiles and 16px glyphs.
+ */
+@media (max-width: 1023px) {
+    div.bx-std-pmen-item {
+        height: 5.5rem; /* square: the item is 88px wide (page_columns.css) */
+    }
+    div.bx-std-pmen-item > a {
+        display: block;
+        height: 100%;
+    }
+    div.bx-std-pmen-item-cnt {
+        -webkit-flex-direction: column;
+        flex-direction: column;
+        -webkit-justify-content: center;
+        justify-content: center;
+
+        height: 100%;
+        padding: 0.5rem 0.25rem; /* room for a two-line label under the tile */
+    }
+    div.bx-std-pmen-item .bx-std-pmen-ic-icon,
+    .bx-dir-rtl div.bx-std-pmen-item .bx-std-pmen-ic-icon {
+        margin: 0;
+    }
+    div.bx-std-pmen-item .bx-std-pmen-ic-title {
+        display: -webkit-box;
+        -webkit-box-orient: vertical;
+        -webkit-line-clamp: 2; /* wraps to two lines, then cuts with an ellipsis */
+        max-width: 100%;
+        margin-top: 0.25rem;
+
+        font-size: 0.75rem;
+        line-height: 1rem;
+        text-align: center;
+        white-space: normal;
+        overflow-wrap: anywhere;
+    }
+}
+@media (max-width: 767px) {
+    div.bx-std-pcm-menu {
+        display: -webkit-flex;
+        display: flex;
+        -webkit-flex-wrap: nowrap;
+        flex-wrap: nowrap;
+        gap: 0.125rem;
+    }
+    div.bx-std-pcm-menu > .bx-clear {
+        display: none;
+    }
+    div.bx-std-pmen-item {
+        -webkit-flex: 0 0 auto;
+        flex: 0 0 auto;
+
+        height: auto; /* tabs, not squares */
+        min-width: 4rem;
+        margin-top: 0;
+    }
+    div.bx-std-pmen-item-cnt {
+        height: auto;
+        padding: 0.375rem 0.5rem;
+    }
+    div.bx-std-pmen-item .bx-std-pmen-ic-icon {
+        width: 1.75rem;
+        height: 1.75rem;
+        padding: 0.375rem; /* 16px glyph in the 28px tile */
+    }
+    div.bx-std-pmen-item .bx-std-tile-btn > .sys-icon {
+        font-size: 1rem;
+        line-height: 1rem;
+    }
+}
+
+/*
+ * The column's own title (shown when the header breadcrumb is off): small and centred in the strip,
+ * with its actions button under it; a plain row again above the phone tabs.
+ */
+@media (max-width: 1023px) {
+    div.bx-std-pcm-title {
+        font-size: 0.875rem;
+        line-height: 1.25rem;
+    }
+    div.bx-std-pcm-title .bx-std-pcm-title-cnt {
+        -webkit-flex-direction: column;
+        flex-direction: column;
+        -webkit-align-items: center;
+        align-items: center;
+        gap: 0.5rem;
+
+        padding: 0 0.25rem;
+
+        text-align: center;
+        overflow-wrap: anywhere;
+    }
+}
+@media (max-width: 767px) {
+    div.bx-std-pcm-title .bx-std-pcm-title-cnt {
+        -webkit-flex-direction: row;
+        flex-direction: row;
+
+        padding: 0 0.5rem;
+
+        text-align: start;
+    }
+}

+ 33 - 12
studio/template/css/launcher.css

@@ -117,8 +117,6 @@ div.bx-std-widget-empty {
 .bx-std-widget-icon-disabled {
     -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=40)";
     filter: alpha(opacity=40);
-    -moz-opacity: 0.4;
-    -khtml-opacity: 0.4;
     opacity: 0.4;    
 }
 
@@ -202,7 +200,8 @@ div.bx-std-widget-action:first-child {
     margin-top: 0;
 }
 div.bx-std-widget-action,
-div.bx-std-widget-action > a {
+div.bx-std-widget-action > a,
+div.bx-std-widget-action > button {
     position: relative;
     display: block;
     float: none;
@@ -210,14 +209,16 @@ div.bx-std-widget-action > a {
     width: 2rem;
     height: 2rem;
 }
-div.bx-std-widget-action > a {
+div.bx-std-widget-action > a,
+div.bx-std-widget-action > button {
     display: flex;
     -webkit-justify-content: center;
     justify-content: center;
     -webkit-align-items: center;
     align-items: center;
 }
-div.bx-std-widget-action > a img {
+div.bx-std-widget-action > a img,
+div.bx-std-widget-action > button img {
     width: 1.5rem;
     height: 1.5rem;
 }
@@ -229,17 +230,17 @@ div.bx-std-widget-action i.sys-icon {
 .bx-std-widget-loading {
     display: none;
     position: absolute;
-    bottom: -2.0rem;
+    top: 100%;
+    left: 0;
     width: 4.5rem;
-    height: 2.0rem;
+    height: 1rem; /* the caption's row: same 0.75rem gap under the tile and the same 1rem line */
+    margin-top: 0.75rem;
 
-    background-image: url(../images/loading.gif);
+    /* a two-row animated tile wave (the SVG carries its own keyframes, so it animates as a background image); loading.svg is the single-row sibling for other loaders */
+    background-image: url(../images/loading-double.svg);
     background-repeat: no-repeat;
     background-position: center center;
-
-    -moz-opacity: 0.5;
-    -khtml-opacity: 0.5;
-    opacity: 0.5;
+    background-size: 40px 10px;
 }
 div.bx-std-widget-caption {
     position: relative;
@@ -256,6 +257,9 @@ div.bx-std-widget-caption {
     font-weight: 400;
     font-size: 0.875rem;
 }
+div.bx-std-widget-caption > .bx-std-widget-link {
+    cursor: pointer;
+}
 div.bx-std-widget-caption-jitter {
     position: absolute;
     top: 0px;
@@ -305,6 +309,13 @@ div.bx-std-widget-caption-jitter {
     }
 }
 
+@media (prefers-reduced-motion: reduce) {
+    .bx-std-widgets.bx-std-jitter .bx-std-widget:not(.bx-std-widget-dragging):nth-child(2n),
+    .bx-std-widgets.bx-std-jitter .bx-std-widget:not(.bx-std-widget-dragging):nth-child(2n-1) {
+        animation: none;
+    }
+}
+
 /**
  * Launcher Browser
  */
@@ -450,3 +461,13 @@ div.bx-std-widget-caption-jitter {
 #bx-informer #sys-account-need-to-change-password.bx-informer-msg{
     display: flex;
 }
+
+/* Introduction tour (Shepherd): its stylesheet paints the primary button white at 75% on #3288e6 (3.6:1); blue-600 with white text gives 5.2:1. */
+.shepherd-button:not(.shepherd-button-secondary) {
+    background: rgb(37 99 235);
+    color: #fff;
+}
+.shepherd-button:not(.shepherd-button-secondary):not(:disabled):hover {
+    background: rgb(29 78 216);
+    color: #fff;
+}

+ 132 - 54
studio/template/css/menu_top.css

@@ -5,9 +5,13 @@ div.bx-std-menu-top-wrp {
     flex-wrap: nowrap;
 
     width: 100%;
-    height: 4.5rem;
+    height: 5.5rem; /* the 64px bar (32px icons in 16px padding) with 12px above and below */
 }
+/* The bar: 16px padding around the 32px icons (64px tall) and 12px of margin above and at the sides. */
 div.bx-std-menu-top-gap {
+    margin: 0.75rem 0.75rem 0;
+    padding: 1rem;
+    border-radius: 1.5rem; /* 24px, like the blocks */
     backdrop-filter: blur(16px);
 }
 div.bx-std-menu-top-wrp .bx-std-menu-top.bx-std-menu-top-center:first-child {
@@ -32,7 +36,7 @@ div.bx-std-menu-top.bx-std-menu-top-right {
 div.bx-std-menu-top.bx-std-menu-top-center {
     display: flex;
     z-index: 1;
-    height: 3.5rem;
+    height: 2rem;
 }
 div.bx-std-menu-top.bx-std-menu-top-right {
     z-index: 2;
@@ -43,14 +47,15 @@ div.bx-std-menu-top.bx-std-menu-top-right {
         margin-right: 0.75rem;
         padding-right: 0.75rem;
     }
-    /* Same 36px hairline as the divider inside the dock. */
+    /* The breadcrumb's separator bar (2 x 16px, rounded), centred on the block's edge between the 12px + 12px. */
     div.bx-std-menu-top.bx-std-menu-top-center:has(.bx-menu-top-center li)::after {
         content: '';
         position: absolute;
         top: 50%;
-        right: 0;
-        width: 1px;
-        height: 2.25rem;
+        right: -1px;
+        width: 2px;
+        height: 1rem;
+        border-radius: 1px;
         transform: translateY(-50%);
         background-color: rgba(0, 0, 0, 0.1);
     }
@@ -69,15 +74,13 @@ div.bx-std-menu-top-center ul {
     
 }
 div.bx-std-menu-top-left {
-    padding-left: 0.25rem;
     padding-right: 0.75rem; /* same 12px step as between the dock icons, so the search field never touches the dock */
-    line-height: 4rem;
+    line-height: 2rem;
     text-align: left;
     font-size: 1.5rem;
 }
 div.bx-std-menu-top-right {
-    padding-right: 0.625rem; /* icons sit 10px from the bar edge, same as the launcher icon on the left */
-    text-align: right;
+    text-align: right; /* the bar's own 16px padding keeps the icons off its edge */
 }
 
 /*--- Breadcrumb (Left) ---*/
@@ -114,26 +117,43 @@ div.bx-std-menu-top .bx-menu-top-center li {
     -webkit-align-items: center;
     align-items: center;
 
-    width: 3.0rem;
-    height: 3.5rem;
+    width: 2rem; /* icon width only: spacing comes from the dock's list gap and the breadcrumb separator's padding */
+    height: 2rem;
 }
-div.bx-std-menu-top .bx-menu-top-center li {
-    width: 2.25rem; /* icon width only, spacing comes from the list gap */
+/* Breadcrumb separator: a 2 x 16px bar that takes no room of its own (-1px margins) between 12px of padding either side, so the launcher icon and the app icon sit exactly 24px apart with the bar centred between them. */
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-div {
+    padding: 0 0.75rem;
 }
-
-div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-div img {
-    height: 1.0rem;
+div.bx-std-menu-top .bx-menu-breadcrumb .bx-menu-bc-sep {
+    display: block;
+    width: 2px;
+    height: 1rem;
+    margin: 0 -1px;
+    border-radius: 1px;
+    background-color: rgba(0, 0, 0, 0.1);
+}
+.dark div.bx-std-menu-top .bx-menu-breadcrumb .bx-menu-bc-sep {
+    background-color: rgba(255, 255, 255, 0.1);
+}
+/* Page item: the app's launcher icon fills its 32px tile (8px corners, the tile's hairline ring over the art), the title 12px after it. */
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-item.bx-menu-bc-page .bx-mi-icon.bx-icon-tile img {
+    display: block;
+    width: 100%; /* the whole tile, not the 20px glyph size the header-button rule below gives images */
+    height: 100%;
+    border-radius: inherit;
+    object-fit: cover;
+}
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-page .bx-mi-icon + .bx-mi-title {
+    margin-left: 0.5rem; /* with the title's own 4px padding: 12px from the tile */
 }
 
+/* Page title never pushes the dock: it truncates instead. */
 div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-item.bx-menu-bc-page {
     width: auto;
+    min-width: 0;
     padding-left: 0;
     padding-right: 0;
 }
-/* Page title never pushes the dock: it truncates instead. */
-div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-item.bx-menu-bc-page {
-    min-width: 0;
-}
 div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-item .bx-mi-title {
     overflow: hidden;
     white-space: nowrap;
@@ -149,23 +169,75 @@ div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-item.bx-menu-tab-search {
     flex: 1 1 auto;
     min-width: 0;
     width: auto;
-    padding: 0 0 0 0.25rem;
+    padding: 0; /* the separator before it provides the spacing, so its glyph box lands where an app icon sits on app pages */
 }
-div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-item.bx-menu-bc-hidden {
-    display: none; /* launcher: the launcher button (and its chevron, the first child) stay out of view */
+/* On the launcher the field is bare: no fill, ring or focus ring (the caret marks focus; a ring would come back the moment the user types); the search glyph is centred in a transparent 32px box at the field's left edge and the text starts 4px after that box. */
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search .bx-std-search-field > span {
+    justify-content: center;
+    width: 2rem;
+    padding-left: 0;
+    padding-right: 0;
 }
-div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-hidden + li.bx-menu-tab-search {
-    padding-left: 0.375rem; /* alone on the launcher: 10px from the bar edge, same as the buttons on the right */
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search .bx-std-search-field > input {
+    padding: 0 2.25rem; /* the glyph box on one side, the clear button on the other, whichever way the text runs */
+    background-color: transparent;
+    box-shadow: none;
 }
-div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search > a.bx-std-search-trigger {
+/* The browser's own clear control is replaced by a Lucide x: a 16px glyph in a 32px hover target at the field's right edge, shown while there is text (menu_top.js). */
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search .bx-std-search-field > input::-webkit-search-cancel-button {
+    -webkit-appearance: none;
+    appearance: none;
     display: none;
 }
-div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search.bx-mt-compact > a.bx-std-search-trigger {
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search .bx-std-search-clear {
+    position: absolute;
+    top: 0;
+    right: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 2rem;
+    height: 2rem;
+    border-radius: 0.5rem;
+    color: rgb(107 114 128);
+    cursor: pointer;
+    transition: background-color 150ms ease-in-out, color 150ms ease-in-out;
+}
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search .bx-std-search-clear[hidden] {
+    display: none;
+}
+.bx-dir-rtl div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search .bx-std-search-clear {
+    right: auto;
+    left: 0;
+}
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search .bx-std-search-clear:hover {
+    background-color: rgb(229 231 235);
+    color: rgb(55 65 81);
+}
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search .bx-std-search-clear:focus-visible {
+    outline: none;
+    box-shadow: inset 0 0 0 2px rgb(59 130 246);
+}
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search .bx-std-search-clear svg {
+    width: 1rem;
+    height: 1rem;
+}
+.dark div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search .bx-std-search-clear {
+    color: rgb(156 163 175);
+}
+.dark div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search .bx-std-search-clear:hover {
+    background-color: rgb(31 41 55);
+    color: rgb(209 213 219);
+}
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search > .bx-std-search-trigger {
+    display: none;
+}
+div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search.bx-mt-compact > .bx-std-search-trigger {
     display: flex;
     flex: 0 0 auto;
     box-sizing: border-box;
-    width: 2.25rem;
-    height: 2.25rem;
+    width: 2rem;
+    height: 2rem;
 }
 div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-tab-search.bx-mt-compact:not(.bx-mt-active) > .bx-std-search-field {
     display: none;
@@ -190,8 +262,15 @@ div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-actions svg {
 }
 
 div.bx-std-menu-top .bx-menu-top-center li.bx-menu-item-divider {
-    width: auto;
-    height: 2.25rem; /* icon height, same as the hairline after the dock */
+    width: 2px; /* the breadcrumb's separator bar: 2 x 16px, rounded, no room of its own (-1px margins) */
+    height: 1rem;
+    margin: 0 -1px;
+    border: 0;
+    border-radius: 1px;
+    background-color: rgba(0, 0, 0, 0.1);
+}
+.dark div.bx-std-menu-top .bx-menu-top-center li.bx-menu-item-divider {
+    background-color: rgba(255, 255, 255, 0.1);
 }
 div.bx-std-menu-top .bx-menu-top-center li.bx-menu-item-divider a {
     display: none;
@@ -203,8 +282,8 @@ div.bx-std-menu-top .bx-menu-top-center li a {
     z-index: 2;
     box-sizing: content-box;
 
-    width: 2.25rem;
-    height: 2.25rem;
+    width: 2rem;
+    height: 2rem;
 }
 .bx-menu-breadcrumb li.bx-menu-bc-item > a {
     display: -webkit-flex;
@@ -224,7 +303,7 @@ div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-item .bx-mi-title {
     text-align: center;
     letter-spacing: -0.02em;
 }
-/* Launcher button: a 20px glyph in a 36px button, like the toolbar. */
+/* Launcher button: a 20px glyph in a 32px button, like the toolbar. */
 div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-item .bx-mi-icon.bx-std-header-btn svg,
 div.bx-std-menu-top .bx-menu-breadcrumb li.bx-menu-bc-item .bx-mi-icon.bx-std-header-btn img {
     display: block;
@@ -239,8 +318,8 @@ div.bx-std-menu-top .bx-menu-top-center li .sys-icon {
     display: block;
     box-sizing: content-box;
 
-    width: 2.25rem;
-    height: 2.25rem;
+    width: 2rem;
+    height: 2rem;
 
     margin: 0;
     border-radius: 0.5rem; /* matches rounded-lg on the icon wrapper, whose ring/shadow must not be clipped */
@@ -272,10 +351,7 @@ div.bx-std-menu-top .bx-menu-top-center li.bx-menu-tab-divider {
 }
 
 /*--- Search box (Top Left\Center\Right) ---*/
-div.bx-std-menu-top ul li.bx-menu-tab-search {
-    overflow: visible;
-}
-div.bx-std-menu-top ul li.bx-menu-tab-search > div {
+div.bx-std-menu-top ul li.bx-menu-tab-search > div.bx-menu-tab-item {
     position: absolute;
     display: -webkit-flex;
     display: flex;
@@ -284,24 +360,25 @@ div.bx-std-menu-top ul li.bx-menu-tab-search > div {
     -webkit-flex-wrap: nowrap;
     flex-wrap: nowrap;
 
-    width: 2.25rem;
-    height: 2.25rem;
+    width: 2rem;
+    height: 2rem;
     right: 0;
 
     border-radius: 0.5rem;
 }
-.bx-dir-rtl div.bx-std-menu-top ul li.bx-menu-tab-search > div {
+.bx-dir-rtl div.bx-std-menu-top ul li.bx-menu-tab-search > div.bx-menu-tab-item {
     left: 0;
     right: auto;
 }
-div.bx-std-menu-top ul li.bx-menu-tab-search.bx-mt-active > div {
+div.bx-std-menu-top ul li.bx-menu-tab-search.bx-mt-active > div.bx-menu-tab-item {
     width: 13.75rem;
 }
-div.bx-std-menu-top ul li.bx-menu-tab-search > div > a {
+div.bx-std-menu-top ul li.bx-menu-tab-search > div.bx-menu-tab-item > a,
+div.bx-std-menu-top ul li.bx-menu-tab-search > div.bx-menu-tab-item > button {
     height: 100%; /* overrides the 3rem toolbar link height from template/css/menu.css */
 }
 div.bx-std-menu-top ul li.bx-menu-tab-search .bx-form-input-text {
-    height: 2.25rem;
+    height: 2rem;
     padding-left: 0.5rem;
     padding-right: 0.5rem;
 
@@ -337,15 +414,15 @@ div.bx-std-menu-top .bx-menu-toolbar li {
     float: none;
     display: block;
 
-    height: 2.25rem;
-    min-width: 2.25rem;
+    height: 2rem;
+    min-width: 2rem;
     
     border-radius: 0.5rem;
 
-    line-height: 2.25rem;
-    overflow: hidden;
+    line-height: 2rem;
 }
-div.bx-std-menu-top .bx-menu-toolbar li:not(.bx-menu-tab-search) a {
+div.bx-std-menu-top .bx-menu-toolbar li:not(.bx-menu-tab-search) a,
+div.bx-std-menu-top .bx-menu-toolbar li:not(.bx-menu-tab-search) button {
     position: relative;
     display: -webkit-flex;
     display: flex;
@@ -360,7 +437,8 @@ div.bx-std-menu-top .bx-menu-toolbar li:not(.bx-menu-tab-search) a {
     border-radius: 0.5rem;
 }
 
-div.bx-std-menu-top .bx-menu-toolbar li a:hover {
+div.bx-std-menu-top .bx-menu-toolbar li a:hover,
+div.bx-std-menu-top .bx-menu-toolbar li button:hover {
     color: rgb(55, 65, 81);
 }
 
@@ -389,7 +467,7 @@ body > .bx-popup-wrapper.bx-std-mod-popup-settings-wrapper,
 #bx-std-pcap-menu-popup-actions .bx-popup-gap,
 #bx-std-pcap-menu-popup-account .bx-popup-gap,
 #bx-std-pmenu-popup-actions .bx-popup-gap {
-    margin: 3px 0 0; /* with the script's 8px this puts the box 11px under a 36px trigger: flush with the header's bottom edge */
+    margin: 8px 0 0; /* with the script's 8px this puts the box 16px under the 32px trigger: flush with the header bar's bottom edge */
 }
 #bx-std-pcap-menu-popup-help .bx-popup,
 #bx-std-pcap-menu-popup-actions .bx-popup,

+ 0 - 4
studio/template/css/navigation.css

@@ -14,10 +14,6 @@ div.bx-nav-add-set {}
 div.bx-nav-add-set-cnt {}
 
 /*--- Items page -> Panel Top style ---*/
-div.bx-grid-header div#bx-form-element-set,
-div.bx-grid-header div#bx-form-element-module {
-    margin-right: 20px;
-}
 div.bx-grid-header div#bx-form-element-module,
 div.bx-grid-header div#bx-form-element-set,
 div.bx-grid-header div#bx-form-element-keyword {

+ 1 - 1
studio/template/css/options.css

@@ -7,7 +7,7 @@ div.bx-stg-cells div.bx-sc-cell {
     flex: 1 1 auto;
 }
 div.bx-stg-cells div.bx-sc-cell .bx-btn {
-    display: inline-block;
+    display: inline-flex; /* keeps the 32px Studio button's centred label */
     float: none;
     vertical-align: middle;
 }

+ 329 - 13
studio/template/css/page.css

@@ -31,10 +31,59 @@ div.bx-std-blocks-line .bx-std-block-wrp {
  * Block Styles
  */
 div.bx-std-block {
+    position: relative; /* the overlay scrollbar is drawn over the block */
     display: flex;
     flex-direction: column;
     min-height: 0;
 }
+/*
+ * Overlay scrollbar (bx_std_scrollbar in studio/js/page.js): the scroll box hides its native bar, and an SVG drawn over the block runs a
+ * thumb down the right edge and round the 24px bottom corner (the top one too when nothing sits above the scroll box), like a bar that
+ * follows the panel's shape; the thumb shows while the pointer is over the block or the content scrolls.
+ */
+div.bx-std-block-content {
+    scrollbar-width: none;
+}
+div.bx-std-block-content::-webkit-scrollbar {
+    display: none;
+}
+svg.bx-std-scrollbar {
+    position: absolute;
+    inset: 0;
+    z-index: 2;
+    width: 100%;
+    height: 100%;
+    overflow: visible;
+    pointer-events: none;
+    opacity: 0;
+    transition: opacity 0.3s;
+}
+div.bx-std-block:hover > svg.bx-std-scrollbar,
+div.bx-std-block.bx-std-scrolling > svg.bx-std-scrollbar {
+    opacity: 1;
+}
+svg.bx-std-scrollbar path {
+    fill: none;
+    stroke-width: 4;
+    stroke-linecap: round;
+}
+svg.bx-std-scrollbar .bx-std-scrollbar-track {
+    stroke: rgba(0, 0, 0, 0.05);
+}
+svg.bx-std-scrollbar .bx-std-scrollbar-thumb {
+    stroke: rgba(0, 0, 0, 0.25);
+}
+.dark svg.bx-std-scrollbar .bx-std-scrollbar-track {
+    stroke: rgba(255, 255, 255, 0.05);
+}
+.dark svg.bx-std-scrollbar .bx-std-scrollbar-thumb {
+    stroke: rgba(255, 255, 255, 0.25);
+}
+@media (prefers-reduced-motion: reduce) {
+    svg.bx-std-scrollbar {
+        transition: none;
+    }
+}
 div.bx-std-block-caption,
 div.bx-std-block-panel-top,
 div.bx-std-block-panel-bottom {
@@ -49,22 +98,71 @@ div.bx-std-block-caption {
 
     z-index: 1;
 
-    padding: 0.25rem; /* same 4px inset as the sidebar column, around a 48px row like a sidebar item */
-    box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.1); /* hairline divider drawn outside the box, so it adds no height */
+    gap: 0.75rem;
+    padding: 1rem; /* 16px around a 32px row, like the header bar: a 64px caption with its icon, buttons and tabs on the block's 16px content line */
+    box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.05); /* hairline divider drawn outside the box, so it adds no height */
 }
 .dark div.bx-std-block-caption {
-    box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.1);
+    box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.025);
+}
+/* Optional title icon: a flat gray 32px square, 8px radius (no plate, ring or shadow: a label, not a button) with a 20px glyph. */
+div.bx-std-block-caption .bx-std-bcap-icon {
+    flex: none;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 2rem;
+    height: 2rem;
+    border-radius: 0.5rem;
+    background: rgb(0 0 0 / 0.1); /* the same plate as the dashboard row icons */
+    color: rgb(75 85 99);
+}
+.dark div.bx-std-block-caption .bx-std-bcap-icon {
+    background: rgb(255 255 255 / 0.05);
+    color: rgb(156 163 175);
+}
+div.bx-std-block-caption .bx-std-bcap-icon .sys-icon {
+    color: rgb(0 0 0); /* black at 80% through the element's opacity (white in the dark scheme), not the site-wide accent that .sys-icon carries */
+    opacity: 0.8;
+    font-size: 1.25rem;
+    line-height: 1.25rem;
+}
+.dark div.bx-std-block-caption .bx-std-bcap-icon .sys-icon {
+    color: rgb(255 255 255);
+}
+div.bx-std-block-caption .bx-std-bcap-icon svg {
+    width: 1.25rem;
+    height: 1.25rem;
+}
+/* A segmented switcher in the caption sits on the right; its 32px frame is the row's height. */
+div.bx-std-block-caption > .bx-std-segmented {
+    flex: none;
+}
+/* A button in the caption (the Cache block's "Clear all caches") keeps its width beside the flexible title. */
+div.bx-std-block-caption > .bx-btn {
+    flex: none;
 }
 .bx-std-bcap-text {
     -webkit-flex: 1 1 auto;
     -ms-flex: 1 1 auto;
     flex: 1 1 auto;
 
-    padding: 0 0.75rem;
-    line-height: 3rem;
+    line-height: 2rem; /* the row is 32px with or without an icon, so every caption is the same height */
     font-weight: 600;
     font-size: 1.25rem;
 }
+/* Button loader (dashboard.js loadingButton): the launcher's tile wave (loading-double.svg, 40x10, its own keyframes) stands in for a pressed
+   button's label, at the button's width, until its request ends; the button is disabled meanwhile and takes no clicks (an <a> button has no disabled state). */
+.bx-std-btn-loading {
+    display: inline-block;
+    width: 40px;
+    height: 10px;
+    vertical-align: middle;
+    background: url(../images/loading-double.svg) center / 40px 10px no-repeat;
+}
+.bx-btn.bx-btn-loading {
+    pointer-events: none;
+}
 div.bx-std-bcap-actions {
     position: relative;
     -webkit-flex: 0 2 auto;
@@ -84,32 +182,233 @@ div.bx-std-bcap-action-div:first-child {
     display: none;
 }
 
-div.bx-std-block-panel-top {}
+/* Top panel (the Pages builder's selects): the same toolbar as a grid header, 64px with 16px sides and a hairline below it;
+   under a caption the 16px between them is split 8 / 8 like the grid toolbar. Its fields are spaced by an 8px gap alone. */
+div.bx-std-block-panel-top {
+    padding: 1rem;
+    box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.05);
+}
+.dark div.bx-std-block-panel-top {
+    box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.025);
+}
+div.bx-std-block-caption:has(+ div.bx-std-block-panel-top) {
+    padding-bottom: 0.5rem;
+    box-shadow: none; /* the panel below draws the only separator, as the sticky grid toolbar does by painting over the caption's */
+}
+div.bx-std-block-caption + div.bx-std-block-panel-top {
+    padding-top: 0.5rem;
+}
 div.bx-std-block-panel-top .bx-std-pt-cnt {
-    padding: 1.5rem 1.5rem 0 1.5rem;
+    padding: 0;
+}
+div.bx-std-block-panel-top .bx-std-pt-cnt > .flex {
+    gap: 0.5rem;
+}
+div.bx-std-block-panel-top .bx-std-pt-cnt > .flex > * {
+    margin: 0;
+}
+div.bx-std-block-panel-top .bx-form-element-wrapper {
+    margin: 0;
 }
 
 div.bx-std-block-content {
     flex: 1 1 auto;
     min-height: 0;
     overflow-y: auto;
-    border-radius: 0 0 1rem 1rem; /* the scroll box clips to the block's rounded corners instead of squaring them off */
+    border-radius: 0 0 1.5rem 1.5rem; /* the scroll box clips to the block's 24px corners instead of squaring them off */
     padding: 0.5rem;
 }
 div.bx-std-block-content:not(:last-child) {
     border-radius: 0; /* a bottom panel follows and owns the corners */
 }
 div.bx-std-block-content:first-child {
-    border-top-left-radius: 1rem; /* no caption above: the scroll box owns the top corners too */
-    border-top-right-radius: 1rem;
+    border-top-left-radius: 1.5rem; /* no caption above: the scroll box owns the top corners too */
+    border-top-right-radius: 1.5rem;
+}
+/* A grid wider than its block scrolls sideways inside the block (grid.css hides the overflow, which cuts off the action column on narrower desktops). */
+div.bx-std-block-content .bx-grid-table-wrapper {
+    overflow-x: auto;
 }
 div.bx-std-block-content div.bx-grid-header {
     margin-top: 0px;
 }
+/* Content inset: the block's 8px plus 8px from the content root itself, 16px in all. Forms, the designer's template list and
+   builder cells add theirs here; product lists do the same in store.css, agent cards in agents.css. Options forms and grids are
+   laid out as a header / scroller / footer stack below and own their inset there. */
+div.bx-std-block-content > .bx-form-advanced:not([name="adm-settings-form"]),
+div.bx-std-block-content > .bx-dsg-templates,
+div.bx-std-block-content > .adm-bp-cell,
+div.bx-std-block-content > .bx-layout-wrapper {
+    padding: 0.5rem;
+}
+
+/**
+ * Header / scroller / footer stack.
+ * A block whose content is an options form (BxDolStudioOptions, name="adm-settings-form") or a grid keeps its caption fixed, scrolls
+ * the middle, and pins a 64px footer: 16px around the 32px buttons, like the caption, hairline drawn outside the box.
+ * The form footer is the form's last section (the Save button, BxBaseStudioOptions always puts it in a section of its own); the grid
+ * footer is the grid's own footer (bulk actions, paging). Grid rows run edge to edge with 16px inside the first and last cell.
+ */
+div.bx-std-block-content:has(> form[name="adm-settings-form"]),
+div.bx-std-block-content:has(> [id^="bx-grid-wrap-"]) {
+    display: flex;
+    flex-direction: column;
+    padding: 0;
+    overflow: hidden;
+}
+
+/* Options forms */
+div.bx-std-block-content > form[name="adm-settings-form"] {
+    --bx-std-inset: 1rem; /* the scroller's inset: 16px for fields and for the accordion cards alike */
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    flex: 1 1 auto;
+    min-height: 0;
+    padding: 0;
+}
+form[name="adm-settings-form"] > .bx-form-advanced-wrapper {
+    display: flex; /* a column, so the footer can drop to the bottom (margin-top: auto) when the content does not fill the block */
+    flex-direction: column;
+    flex: 1 1 auto;
+    min-height: 0;
+    overflow-y: auto;
+    padding: var(--bx-std-inset) var(--bx-std-inset) 0; /* no bottom inset: sticky elements stop at the scroller's padding edge */
+}
+form[name="adm-settings-form"] > .bx-form-advanced-wrapper > .bx-form-section-wrapper {
+    flex: none; /* sections overflow the scroller instead of shrinking into it */
+    margin: 0 0 0.5rem; /* overrides the my-4 emitted by BxBaseFormView::getOpenSection */
+    padding: 0;
+}
+/* Plain form: the fields section is a bare divider section; its scaffolding adds no inset of its own. */
+form#adm-settings-form > .bx-form-advanced-wrapper > .bx-form-section-wrapper:not(:last-child) > .bx-form-section-divider > .bx-form-section-content {
+    padding: 0 0 0.5rem;
+}
+/* Footer: the last section, holding the Save button. */
+form[name="adm-settings-form"] > .bx-form-advanced-wrapper > .bx-form-section-wrapper:last-child:not(:first-child) {
+    position: sticky;
+    bottom: 0;
+    z-index: 1;
+    display: flex;
+    align-items: center;
+    width: auto;
+    height: 4rem;
+    margin: auto calc(-1 * var(--bx-std-inset)) 0; /* spans the block edge to edge over the scroller's inset; auto pushes it down */
+    padding: 1rem;
+    border-radius: 0 0 1.5rem 1.5rem;
+    background-color: #f5f5f7; /* gray-200 page + the block's white/60, so the sticky row matches the block around it */
+    box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.05);
+}
+form[name="adm-settings-form"] > .bx-form-advanced-wrapper > .bx-form-section-wrapper:last-child:not(:first-child) .bx-form-section-content {
+    padding: 0;
+}
+form[name="adm-settings-form"] > .bx-form-advanced-wrapper > .bx-form-section-wrapper:last-child:not(:first-child) .bx-form-element-wrapper {
+    margin: 0;
+}
+
+/* Grids */
+div.bx-std-block-content > [id^="bx-grid-wrap-"] {
+    display: flex;
+    flex-direction: column;
+    flex: 1 1 auto;
+    min-height: 0;
+    overflow-y: auto;
+    padding: 0;
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-header {
+    position: sticky; /* the filter / add toolbar stays under the caption, 64px like it */
+    top: 0;
+    z-index: 2;
+    flex: none;
+    margin: 0;
+    padding: 1rem;
+    background-color: #f5f5f7; /* gray-200 page + the block's white/60, so the sticky row matches the block around it */
+    box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.05);
+}
+/* Under a caption the toolbar is the caption's second row: the 16px between them is split 8 / 8 so the half inside the
+   toolbar (which is the top of the scroll box) leaves room for the buttons' hover and focus rings instead of clipping them. */
+div.bx-std-block-caption:has(+ div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-header) {
+    padding-bottom: 0.5rem;
+}
+div.bx-std-block-caption + div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-header {
+    padding-top: 0.5rem;
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-header .bx-grid-header-cnt {
+    padding: 0;
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-header .bx-form-element-wrapper {
+    margin: 0; /* the filter fields are spaced by the toolbar's gap alone */
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-header .bx-grid-header-controls-filter,
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-header .bx-grid-header-controls-independent {
+    column-gap: 1rem; /* 16px between toolbar fields and buttons */
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-table-wrapper2 {
+    flex: none;
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-footer {
+    position: sticky;
+    bottom: 0;
+    z-index: 2;
+    flex: none;
+    display: flex;
+    align-items: center;
+    height: 4rem;
+    margin: auto 0 0;
+    padding: 1rem;
+    border-radius: 0 0 1.5rem 1.5rem;
+    background-color: #f5f5f7; /* gray-200 page + the block's white/60, so the sticky row matches the block around it */
+    box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.05);
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-footer > hr.bx-def-hr {
+    display: none; /* the hairline above replaces it */
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-footer > .bx-grid-footer-cnt {
+    flex: 1 1 auto;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin: 0;
+    padding: 0;
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-footer > .bx-grid-footer-cnt > div {
+    float: none;
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    margin: 0;
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-footer > .bx-grid-footer-cnt > div > .bx-btn {
+    margin: 0; /* bulk action buttons carry bx-def-margin-thd; the gap above spaces them, so the first one starts on the 16px line */
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-footer > .bx-grid-footer-cnt::before,
+div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-footer > .bx-grid-footer-cnt::after {
+    display: none; /* the bx-clearfix pseudo-elements would be empty flex items and push the actions and the pager inward */
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] .bx-grid-table th:first-child,
+div.bx-std-block-content > [id^="bx-grid-wrap-"] .bx-grid-table td:first-child {
+    padding-left: 1rem;
+}
+div.bx-std-block-content > [id^="bx-grid-wrap-"] .bx-grid-table th:last-child,
+div.bx-std-block-content > [id^="bx-grid-wrap-"] .bx-grid-table td:last-child {
+    padding-right: 1rem;
+}
+.dark form[name="adm-settings-form"] > .bx-form-advanced-wrapper > .bx-form-section-wrapper:last-child:not(:first-child),
+.dark div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-header,
+.dark div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-footer {
+    background-color: #0f0f0f; /* black page + the block's white/6 */
+}
+.dark form[name="adm-settings-form"] > .bx-form-advanced-wrapper > .bx-form-section-wrapper:last-child:not(:first-child),
+.dark div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-footer {
+    box-shadow: 0 -1px 0 0 rgba(255, 255, 255, 0.025);
+}
+.dark div.bx-std-block-content > [id^="bx-grid-wrap-"] > .bx-grid-header {
+    box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.025);
+}
 
 div.bx-std-block-panel-bottom {}
 div.bx-std-block-panel-bottom .bx-std-pt-cnt {
-    padding: 0 1.5rem 1.5rem 1.5rem;
+    padding: 0 1rem 1rem 1rem;
 }
 
 
@@ -117,6 +416,23 @@ div.bx-std-block-panel-bottom .bx-std-pt-cnt {
  * Builder based blocks
  */
 .bx-layout-wrapper {
-    padding-top: 0.5rem; /* same gap below the header as the sidebar pages */
+    padding-top: 0.75rem; /* the same 12px gap below the header as the sidebar pages */
     padding-bottom: 0;
-}
+}
+
+/* Phones: the caption stays one row in a 343px block (icon, title, tabs), so the segmented tabs go compact and the title truncates. */
+@media (max-width: 767px) {
+    div.bx-std-block-caption {
+        gap: 0.5rem;
+    }
+    div.bx-std-block-caption > .bx-std-segmented .bx-std-segment {
+        padding: 0 0.5rem;
+        font-size: 0.75rem;
+    }
+    div.bx-std-block-caption .bx-std-bcap-text {
+        min-width: 0;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+    }
+}

+ 48 - 2
studio/template/css/page_columns.css

@@ -17,7 +17,7 @@ div.bx-std-page-columns-cnt {
     display: flex;
     align-items: stretch;
 
-    padding: 4.5rem 0.5rem 0.5rem 0.5rem;
+    padding: 5.5rem 0.75rem 0.75rem 0.75rem; /* top: the header wrapper's height, so the content starts 12px under the bar; 12px sides and bottom, like the header's margins */
 }
 
 /*--- Page Menu ---*/
@@ -25,6 +25,7 @@ div.bx-std-page-columns-cnt {
     position: relative;
     -webkit-flex: 0 0 20rem;
     flex: 0 0 20rem;
+    border-radius: 1.5rem; /* 24px, like the blocks and the header bar */
 
     width: 20rem;
     height: 100%;
@@ -63,7 +64,7 @@ div.bx-std-page-columns-cnt {
     flex: 1 1 auto;
     min-height: 0;
     width: 100%;
-    padding: 1px 0 1px 0.5rem; /* room for the blocks' hairline ring inside the scroll box */
+    padding: 1px 0 1px 0.75rem; /* 12px from the sidebar column; 1px of room for the blocks' hairline ring inside the scroll box */
 }
 /*
  * A page with a single block: the block takes the remaining height, its caption and bottom panel stay put
@@ -81,3 +82,48 @@ div.bx-std-page-columns-cnt {
     padding-top: 0;
     padding-bottom: 0;
 }
+
+/*
+ * Narrow screens. Below 1024px the sidebar collapses to a strip of icon tiles with small labels under them;
+ * below 768px it becomes a horizontal row of tabs above the content. Item styles are in general.css.
+ */
+@media (max-width: 1023px) {
+    .bx-std-page-column.bx-std-page-menu {
+        -webkit-flex: 0 0 6.5rem;
+        flex: 0 0 6.5rem;
+
+        width: 6.5rem; /* 88px items (80px of label room, enough for a semibold "Notifications") + the column's 8px inset */
+    }
+}
+@media (max-width: 767px) {
+    div.bx-std-page-columns-cnt {
+        -webkit-flex-direction: column;
+        flex-direction: column;
+    }
+    .bx-std-page-column.bx-std-page-menu {
+        -webkit-flex: 0 0 auto;
+        flex: 0 0 auto;
+
+        width: auto;
+        height: auto;
+        margin-bottom: 0.75rem; /* the same 12px gap as between the columns on wider screens */
+
+        overflow-x: auto;
+        overflow-y: hidden;
+        -webkit-overflow-scrolling: touch;
+    }
+    .bx-std-page-column.bx-std-page-menu div.bx-std-page-column-cnt {
+        position: static;
+    }
+    .bx-std-page-column.bx-std-page-content {
+        -webkit-flex: 1 1 auto;
+        flex: 1 1 auto;
+        min-height: 0;
+
+        height: auto;
+    }
+    .bx-std-page-column.bx-std-page-content div.bx-std-page-column-cnt {
+        padding-left: 0;
+        padding-right: 0;
+    }
+}

+ 0 - 4
studio/template/css/permissions.css

@@ -9,10 +9,6 @@ div.bx-prm-add-level {}
 div.bx-prm-add-level-cnt {}
 
 /*--- Actions page -> Panel Top style ---*/
-div.bx-grid-header div#bx-form-element-level,
-div.bx-grid-header div#bx-form-element-module {
-    margin-right: 20px;
-}
 div.bx-grid-header div#bx-form-element-module,
 div.bx-grid-header div#bx-form-element-keyword {
     margin-top: 0px;

+ 0 - 3
studio/template/css/polyglot.css

@@ -4,9 +4,6 @@ div.bx-grid-header div.bx-form-element-wrapper {
     float: left;
     width: 165px;
 }
-div.bx-grid-header div#bx-form-element-module {
-    margin-right: 20px;
-}
 div.bx-grid-header div#bx-form-element-keyword {
 	margin-top: 0px;
 }

+ 0 - 3
studio/template/css/roles.css

@@ -10,9 +10,6 @@ div.bx-rl-role {}
 div.bx-rl-role-cnt {}
 
 /*--- Actions page -> Panel Top style ---*/
-div.bx-grid-header div#bx-form-element-role {
-    margin-right: 20px;
-}
 div.bx-grid-header div#bx-form-element-keyword {
     margin-top: 0px;
 }

+ 2 - 57
studio/template/css/settings.css

@@ -1,57 +1,2 @@
-/* The settings form fills its block: sections scroll, the save row stays as the block footer. */
-.bx-std-block .bx-std-block-content {
-    display: flex;
-    flex-direction: column;
-    padding: 0;
-    overflow: hidden;
-}
-
-#adm-settings-form,
-#adm-settings-form-categorized {
-    position: relative;
-    display: flex;
-    flex-direction: column;
-    flex: 1 1 auto;
-    min-height: 0;
-}
-#adm-settings-form .bx-form-advanced-wrapper,
-#adm-settings-form-categorized .bx-form-advanced-wrapper {
-    flex: 1 1 auto;
-    min-height: 0;
-    overflow-y: auto;
-    padding: 0.5rem;
-}
-#adm-settings-form-categorized .bx-form-advanced-wrapper {
-    padding-bottom: 0; /* the sticky Save row is the bottom edge; sticky elements stop at the scroller's padding, not its edge */
-}
-#adm-settings-form-categorized .bx-form-advanced-wrapper .bx-form-section-wrapper {
-    padding: 0 0.5rem 0 0;
-}
-/* Plain (module) settings: the form adds its own 8px on top of the block's 8px, so fields sit 16px from every edge. */
-#adm-settings-form .bx-form-advanced-wrapper .bx-form-section-wrapper {
-    padding: 0.5rem;
-}
-/* Save row as a sticky footer: only when the form has separate sections, a single section keeps its Save inline. */
-#adm-settings-form .bx-form-advanced-wrapper .bx-form-section-wrapper:last-child:not(:first-child),
-#adm-settings-form-categorized .bx-form-advanced-wrapper .bx-form-section-wrapper:last-child:not(:first-child) {
-    position: sticky;
-    bottom: 0;
-    z-index: 1;
-    width: auto;
-
-    margin: 0.5rem -0.5rem 0; /* spans the block edge to edge, over the wrapper's side inset */
-    padding: 1rem 1.5rem;
-    border-radius: 0 0 1rem 1rem;
-
-    background-color: #ffffff;
-    border-top: 1px solid rgba(0, 0, 0, 0.05);
-}
-.dark #adm-settings-form .bx-form-advanced-wrapper .bx-form-section-wrapper:last-child:not(:first-child),
-.dark #adm-settings-form-categorized .bx-form-advanced-wrapper .bx-form-section-wrapper:last-child:not(:first-child) {
-    background-color: #111827;
-    border-top-color: rgba(255, 255, 255, 0.1);
-}
-
-#adm-settings-form-categorized .bx-form-advanced-wrapper .bx-form-section-wrapper:first-child {
-    padding-top: 0;
-}
+/* The settings form layout (fixed caption, scrolling sections, pinned Save footer) lives in page.css, so it applies wherever
+   an options form is embedded (settings.php, module and language pages, app settings pages). Nothing settings-specific is left here. */

+ 4 - 1
studio/template/css/store.css

@@ -187,7 +187,7 @@ span.bx-std-product-versions {
     flex-wrap: wrap;
 }
 .bx-std-pa-actions .bx-btn {
-	display: inline-block;
+	display: inline-flex; /* keeps the 32px Studio button's centred label */
 	float: none;
 	vertical-align: top;
 }
@@ -262,6 +262,9 @@ div.bx-std-pv-actions .bx-std-pv-action {
 	float: none;
 	vertical-align: top;
 }
+div.bx-std-pv-actions .bx-btn {
+	display: inline-flex; /* keeps the 32px Studio button's centred label */
+}
 
 div.bx-std-pv-actions .bx-std-pv-action.bx-std-pva-price .bx-std-pva-or {
 	line-height: 40px;

+ 1 - 1
studio/template/css/uploaders.css

@@ -17,6 +17,6 @@
 }
 .bx-uploader-ghost .bx-btn {
     float: none;
-    display: inline-block;
+    display: inline-flex; /* keeps the 32px Studio button's centred label */
     vertical-align: top; 
 }

+ 3 - 10
studio/template/dbd_cache.html

@@ -1,7 +1,7 @@
 <div id="bx-dbd-cache">
-    <div class="bx-dbd-block-content bx-def-margin-bottom">
+    <div class="bx-dbd-block-content">
         <bx_if:show_chart>
-            <div class="bx-dbd-chart bx-def-border"></div>
+            <div class="bx-dbd-chart"></div>
 
             <script language="javascript">
                 __js_object__.initChart('cache', __chart_data__);
@@ -9,11 +9,4 @@
         </bx_if:show_chart>
         <bx_if:show_empty>__message__</bx_if:show_empty>
     </div>
-    <bx_if:show_actions>
-        <div class="bx-dbd-block-actions space-x-4">
-            <a class="bx-btn" href="javascript:void(0)" onclick="javascript:__js_object__.clearCache('all')"><bx_text:_adm_dbd_txt_c_clear_all /></a>
-            <a class="bx-btn" href="javascript:void(0)" onclick="javascript:__js_object__.clearCacheSelect(this)"><bx_text:_adm_dbd_txt_c_select /></a>
-        </div>
-        __menu__
-    </bx_if:show_actions>
-</div>
+</div>

+ 26 - 13
studio/template/dbd_htools.html

@@ -1,17 +1,30 @@
 <div id="bx-dbd-htools">
-    <div class="bx-dbd-block-content bx-def-margin-bottom">
-        <bx_if:show_content>
-            <div class="bx-dbd-htools bx-clearfix">
-                <bx_repeat:items>
-                    <div class="bx-dbd-htools-item bx-def-margin-sec-top-auto"><img class="bx-stg-icon-gray-bg ltr:mr-4 rtl:ml-4" src="<bx_image_url:bx-std-spacer.gif />" style="background-image: url('__icon__')" alt="" />__title__:<b class="pl-2">__value__</b></div>
-                </bx_repeat:items>
+    <bx_if:show_content>
+        <div class="bx-dbd-htools">
+            <div id="bx-dbd-htools-status" class="bx-dbd-htools-panel" role="tabpanel" aria-labelledby="bx-dbd-htools-tab-status">
+                <div class="bx-dbd-hero bx-dbd-gauges">
+                    <bx_repeat:gauges>
+                        <div class="bx-dbd-gauge bx-dbd-gauge-__name____class_add__">
+                            <div class="bx-dbd-gauge-ring" role="img" aria-label="__title__: __label__">
+                                <svg viewBox="0 0 96 96" aria-hidden="true">
+                                    <circle class="bx-dbd-gauge-track" cx="48" cy="48" r="40" />
+                                    <circle class="bx-dbd-gauge-arc" cx="48" cy="48" r="40" stroke-dasharray="__dash__ __ring__" />
+                                </svg>
+                                <div class="bx-dbd-gauge-value" aria-hidden="true">__value__<bx_if:show_unit><small class="bx-dbd-gauge-unit">__unit__</small></bx_if:show_unit></div>
+                            </div>
+                            <div class="bx-dbd-gauge-label" aria-hidden="true">__title__</div>
+                        </div>
+                    </bx_repeat:gauges>
+                </div>
+                <div class="bx-dbd-htools-items">
+                    <bx_repeat:items>
+                        <div class="bx-dbd-htools-item bx-dbd-htools-item-__status__"><span class="bx-dbd-tile" aria-hidden="true">__icon__</span><span class="bx-dbd-htools-name">__title__<span class="bx-dbd-htools-status sr-only">, __status_title__</span><bx_if:show_desc><span class="bx-dbd-htools-desc">__desc__</span></bx_if:show_desc></span><b class="bx-dbd-htools-value">__value__</b><span class="bx-dbd-htools-dot" aria-hidden="true"></span></div>
+                    </bx_repeat:items>
+                </div>
             </div>
-        </bx_if:show_content>
-    </div>
-    <div class="bx-dbd-block-actions space-x-4" style="display:none">
-        <a class="bx-btn" href="javascript:void(0)" onclick="javascript:__js_object__.serverAudit()"><bx_text:_sys_inst_server_audit /></a>
-        <a class="bx-btn" href="javascript:void(0)" onclick="javascript:__js_object__.permissions()"><bx_text:_sys_audit_permissions /></a>
-    </div>
+            <div id="bx-dbd-htools-audit" class="bx-dbd-htools-panel" role="tabpanel" aria-labelledby="bx-dbd-htools-tab-audit" data-action="server_audit" hidden></div>
+        </div>
+    </bx_if:show_content>
     <bx_if:show_loader>
         <script language="javascript">
             $(document).ready(function() {
@@ -19,4 +32,4 @@
             });
         </script>
     </bx_if:show_loader>
-</div>
+</div>

+ 28 - 0
studio/template/dbd_htools_audit.html

@@ -0,0 +1,28 @@
+<div class="bx-dbd-hero bx-dbd-chart-hero bx-dbd-chart-hero-rows">
+    <div class="bx-dbd-chart-row">
+        <div class="bx-dbd-chart-head bx-dbd-chart-head-sm"><span class="bx-dbd-chart-label"><bx_text:_adm_dbd_txt_ht_last_audit /></span><span class="bx-dbd-chart-stat">__time__</span></div>
+        <div class="bx-dbd-chart-bar" role="img" aria-label="__bar_label__"><bx_repeat:segments><span class="bx-dbd-chart-slice bx-dbd-audit-slice-__type__" style="width:__share__%" data-label="__label__"></span></bx_repeat:segments></div>
+        <div class="bx-dbd-chart-summary">__summary__</div>
+    </div>
+    <div class="bx-dbd-chart-foot">
+        <button type="button" class="bx-btn bx-dbd-audit-run" onclick="__js_object__.runAudit(this)"><bx_text:_adm_dbd_txt_ht_run_audit /></button>
+    </div>
+</div>
+<div class="bx-dbd-audit">
+    <bx_repeat:sections>
+        <div class="bx-dbd-audit-section">
+            <button type="button" class="bx-dbd-htools-item bx-dbd-htools-item-__status__ bx-dbd-audit-toggle" aria-expanded="false" aria-controls="bx-dbd-audit-__name__" onclick="__js_object__.auditToggle(this)">
+                <span class="bx-dbd-tile" aria-hidden="true">__icon__</span>
+                <span class="bx-dbd-htools-name">__title__</span>
+                <b class="bx-dbd-htools-value">__value__</b>
+                <span class="bx-dbd-htools-dot" aria-hidden="true"></span>
+                <span class="bx-dbd-audit-chevron" aria-hidden="true">__icon_chevron__</span>
+            </button>
+            <div id="bx-dbd-audit-__name__" class="bx-dbd-htools-items bx-dbd-audit-rows" hidden>
+                <bx_repeat:rows>
+                    <div class="bx-dbd-htools-item bx-dbd-htools-item-__status__"><span class="bx-dbd-htools-name">__title__<bx_if:show_desc><span class="bx-dbd-htools-desc">__desc__</span></bx_if:show_desc></span><b class="bx-dbd-htools-value">__value__</b><span class="bx-dbd-htools-dot" aria-hidden="true"></span></div>
+                </bx_repeat:rows>
+            </div>
+        </div>
+    </bx_repeat:sections>
+</div>

+ 4 - 0
studio/template/dbd_htools_tabs.html

@@ -0,0 +1,4 @@
+<div class="bx-std-segmented" role="tablist" aria-label="<bx_text_attribute:_adm_dbd_txt_ht_tools />">
+    <button type="button" id="bx-dbd-htools-tab-status" class="bx-std-segment" role="tab" aria-selected="true" aria-controls="bx-dbd-htools-status" onclick="__js_object__.hostToolsTab('status', this)" onkeydown="__js_object__.hostToolsTabKey(event, this)"><bx_text:_adm_dbd_txt_ht_status /></button>
+    <button type="button" id="bx-dbd-htools-tab-audit" class="bx-std-segment" role="tab" aria-selected="false" tabindex="-1" aria-controls="bx-dbd-htools-audit" onclick="__js_object__.hostToolsTab('audit', this)" onkeydown="__js_object__.hostToolsTabKey(event, this)"><bx_text:_adm_dbd_txt_ht_audit /></button>
+</div>

+ 7 - 0
studio/template/dbd_queues.html

@@ -0,0 +1,7 @@
+<div id="bx-dbd-queues" class="bx-dbd-block-content">
+    <div class="bx-dbd-chart"></div>
+
+    <script language="javascript">
+        __js_object__.initChart('queues', __chart_data__);
+    </script>
+</div>

+ 1 - 1
studio/template/dbd_space.html

@@ -1,6 +1,6 @@
 <div id="bx-dbd-space" class="bx-dbd-block-content">
     <bx_if:show_content>
-        <div class="bx-dbd-chart bx-def-border"></div>
+        <div class="bx-dbd-chart"></div>
 
         <script language="javascript">
             __js_object__.initChart('space', __chart_data__);

+ 29 - 0
studio/template/dbd_users.html

@@ -0,0 +1,29 @@
+<div id="bx-dbd-users" class="bx-dbd-block-content">
+    <!-- the chart: the panel of the period tabs in the block caption (dbd_users_tabs.html); the rows below are its legend and pick the series -->
+    <div id="bx-dbd-users-chart" class="bx-dbd-hero bx-dbd-chart-hero bx-dbd-users-hero" role="tabpanel" aria-labelledby="bx-dbd-users-tab-all">
+        <div class="bx-dbd-chart-head bx-dbd-chart-head-sm bx-dbd-users-head">
+            <!-- the charted series' figure for the period and its title, and how many of it are online now (dashboard.js usersDraw) -->
+            <div class="bx-dbd-users-figure"><span class="bx-dbd-users-total bx-dbd-vi-title"></span><span class="bx-dbd-users-caption bx-dbd-chart-summary"></span></div>
+            <div class="bx-dbd-users-figure bx-dbd-users-online" title="<bx_text_attribute:_adm_dbd_txt_us_online />"><span class="bx-dbd-users-online-dot" aria-hidden="true"></span><span class="bx-dbd-users-online-count bx-dbd-vi-title"></span><span class="sr-only"><bx_text:_adm_dbd_txt_us_online /></span></div>
+        </div>
+        <div class="bx-dbd-users-bars" role="img" aria-label=""></div>
+    </div>
+    <!-- the legend: each series row shows its figure for the chosen period (dashboard.js usersDraw) -->
+    <div class="bx-dbd-htools-items bx-dbd-users-rows" role="group" aria-label="<bx_text_attribute:_adm_dbd_txt_us_series />">
+        <button type="button" class="bx-dbd-htools-item bx-dbd-users-row" data-series="accounts" aria-pressed="true" onclick="__js_object__.usersSeries('accounts', this)"><span class="bx-dbd-tile" aria-hidden="true">__icon_accounts__</span><span class="bx-dbd-htools-name"><bx_text:_adm_dbd_txt_us_accounts /></span><b class="bx-dbd-htools-value">__accounts__</b></button>
+        <button type="button" class="bx-dbd-htools-item bx-dbd-users-row" data-series="active" aria-pressed="false" onclick="__js_object__.usersSeries('active', this)"><span class="bx-dbd-tile" aria-hidden="true">__icon_active__</span><span class="bx-dbd-htools-name"><bx_text:_adm_dbd_txt_us_active /></span><b class="bx-dbd-htools-value">__active__</b></button>
+        <bx_if:show_profiles>
+            <bx_repeat:profiles>
+                <button type="button" class="bx-dbd-htools-item bx-dbd-users-row" data-series="__series__" aria-pressed="false" onclick="__js_object__.usersSeries('__series__', this)"><span class="bx-dbd-tile bx-dbd-tile-app" aria-hidden="true"><img src="__icon_url__" alt=""></span><span class="bx-dbd-htools-name">__name__<bx_if:show_note><span class="bx-dbd-htools-desc">__note__</span></bx_if:show_note></span><b class="bx-dbd-htools-value">__value__</b></button>
+            </bx_repeat:profiles>
+        </bx_if:show_profiles>
+        <bx_if:show_levels>
+            <bx_repeat:levels>
+                <button type="button" class="bx-dbd-htools-item bx-dbd-users-row" data-series="__series__" aria-pressed="false" onclick="__js_object__.usersSeries('__series__', this)"><span class="bx-dbd-tile" aria-hidden="true">__icon__</span><span class="bx-dbd-htools-name">__name__</span><b class="bx-dbd-htools-value">__value__</b></button>
+            </bx_repeat:levels>
+        </bx_if:show_levels>
+    </div>
+    <script language="javascript">
+        __js_object__.initUsersChart(__chart_data__);
+    </script>
+</div>

+ 5 - 0
studio/template/dbd_users_tabs.html

@@ -0,0 +1,5 @@
+<div class="bx-std-segmented bx-dbd-users-periods" role="tablist" aria-label="<bx_text_attribute:_adm_dbd_txt_us_period />">
+    <bx_repeat:periods>
+        <button type="button" id="bx-dbd-users-tab-__name__" class="bx-std-segment" role="tab" aria-selected="__selected__" tabindex="__tabindex__" aria-controls="bx-dbd-users-chart" data-period="__name__" onclick="__js_object__.usersPeriod('__name__', this)" onkeydown="__js_object__.usersPeriodKey(event, this)">__title__</button>
+    </bx_repeat:periods>
+</div>

+ 31 - 16
studio/template/dbd_versions.html

@@ -1,27 +1,42 @@
 <div id="bx-dbd-version">
-    <div class="bx-dbd-block-content bx-def-margin-bottom">
-        <div class="bx-dbd-version-image">
-            <img src="<bx_image_url:dbd_version.svg />" alt="" />
+    <div class="bx-dbd-hero bx-dbd-version-head">
+        <div class="bx-dbd-version-id">
+            <span class="bx-dbd-tile bx-dbd-tile-app bx-dbd-tile-lg" aria-hidden="true"><img src="<bx_image_url:dbd_version.svg />" alt=""></span>
+            <div class="bx-dbd-vi-text">
+                <div class="bx-dbd-vi-title">__domain__ __version__</div>
+                <div class="bx-dbd-vi-time">__version_time__</div>
+            </div>
         </div>
-        <div class="bx-dbd-version-info">
-            <div class="bx-dbd-vi-title bx-def-margin-sec-topbottom text-2xl font-semibold">__domain__ __version__</div>
-            <div class="bx-dbd-vi-info space-y-4">
-                <div class="bx-dbd-vii-row">
-                    <img class="bx-stg-icon-gray-bg ltr:mr-4 rtl:ml-4" src="<bx_image_url:bx-std-spacer.gif />" style="background-image: url('<bx_icon_url:db-vi-installed.svg />')" alt="" /><bx_text:_adm_dbd_txt_installed /><b class="ltr:pl-2 rtl:pr-2">__installed__</b>
+        <!-- the check also runs when the page opens (below); this button repeats it on demand -->
+        <button type="button" class="bx-btn bx-dbd-version-check" onclick="__js_object__.checkForUpgrade()"><bx_text:_adm_dbd_txt_ver_check /></button>
+    </div>
+    <div class="bx-dbd-version-toggles">
+        <bx_repeat:toggles>
+            <div class="bx-dbd-version-toggle">
+                <span class="bx-dbd-tile" aria-hidden="true">__icon__</span>
+                <span id="bx-dbd-vt-__name__" class="bx-dbd-version-toggle-name">__title__</span>
+                <div class="bx-switcher-cont __state__ relative">
+                    <button type="button" class="relative inline-flex flex-shrink-0 h-6 w-11 ring-1 ring-gray-300 dark:ring-gray-700 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" role="switch" aria-checked="__checked__" aria-labelledby="bx-dbd-vt-__name__">
+                        <span aria-hidden="true" class="inline-block h-5 w-5 rounded-full shadow transform ring-0 transition ease-in-out duration-200 pointer-events-none"></span>
+                    </button>
+                    <!-- the site's switch script flips this checkbox and the on/off classes on click; the change saves the option -->
+                    <input type="checkbox" name="__name__" style="display:none" __checked_attr__ onchange="__js_object__.toggleOption('__name__', this)">
                 </div>
-                <bx_if:show_update_info>
-                    <div class="bx-dbd-vii-row"><img class="bx-stg-icon-gray-bg ltr:mr-4 rtl:ml-4" src="<bx_image_url:bx-std-spacer.gif />" style="background-image: url('<bx_icon_url:db-vi-updated.svg />')" alt="" /><bx_text:_adm_dbd_txt_updated /><b class="ltr:pl-2 rtl:pr-2">__updated__</b></div>
-                </bx_if:show_update_info>
-                <div class="bx-dbd-vii-row bx-dbd-version-available" style="display:none"><img class="bx-stg-icon-gray-bg ltr:mr-4 rtl:ml-4" src="<bx_image_url:bx-std-spacer.gif />" style="background-image: url('<bx_icon_url:db-vi-available.svg />')" alt="" /><bx_text:_adm_dbd_txt_available /><b class="ltr:pl-2 rtl:pr-2"></b></div>
             </div>
-        </div>
+        </bx_repeat:toggles>
     </div>
-    <div class="bx-dbd-block-actions" style="display:none">
-        <a class="bx-btn bx-btn-primary" href="javascript:void(0)" onclick="javascript:__js_object__.performUpgrade()"><bx_text:_adm_dbd_btn_update /></a>
+    <div class="bx-dbd-htools-items">
+        <div class="bx-dbd-htools-item"><span class="bx-dbd-tile" aria-hidden="true">__icon_installed__</span><span class="bx-dbd-htools-name"><bx_text:_adm_dbd_txt_ver_installed /></span><b class="bx-dbd-htools-value">__installed__</b></div>
+        <div class="bx-dbd-htools-item"><span class="bx-dbd-tile" aria-hidden="true">__icon_updated__</span><span class="bx-dbd-htools-name"><bx_text:_adm_dbd_txt_ver_updated /></span><b class="bx-dbd-htools-value">__updated__</b></div>
+        <div id="bx-dbd-version-stable" class="bx-dbd-htools-item bx-dbd-htools-item-undef"><span class="bx-dbd-tile" aria-hidden="true">__icon_stable__</span><span class="bx-dbd-htools-name"><bx_text:_adm_dbd_txt_ver_latest_stable /><span class="bx-dbd-htools-status sr-only"></span></span><b class="bx-dbd-htools-value">__checking__</b><span class="bx-dbd-htools-dot" aria-hidden="true"></span></div>
+        <div id="bx-dbd-version-beta" class="bx-dbd-htools-item bx-dbd-htools-item-undef"><span class="bx-dbd-tile" aria-hidden="true">__icon_beta__</span><span class="bx-dbd-htools-name"><bx_text:_adm_dbd_txt_ver_latest_beta /><span class="bx-dbd-htools-status sr-only"></span></span><b class="bx-dbd-htools-value">__checking__</b><span class="bx-dbd-htools-dot" aria-hidden="true"></span></div>
+        <div id="bx-dbd-version-apps" class="bx-dbd-htools-item bx-dbd-htools-item-undef"><span class="bx-dbd-tile" aria-hidden="true">__icon_apps__</span><span class="bx-dbd-htools-name">__apps__<span class="bx-dbd-htools-status sr-only"></span></span><b class="bx-dbd-htools-value">__checking__</b><span class="bx-dbd-htools-dot" aria-hidden="true"></span></div>
+        <!-- Access to automatic updates: a status row when the site has it, otherwise the plan page as a primary button -->
+        <div class="bx-dbd-htools-item__access_class__"><span class="bx-dbd-tile" aria-hidden="true">__icon_access__</span><span class="bx-dbd-htools-name"><bx_text:_adm_dbd_txt_ver_access /></span><bx_if:access_active><b class="bx-dbd-htools-value"><bx_text:_adm_dbd_txt_ver_access_active /></b><span class="bx-dbd-htools-dot" aria-hidden="true"></span></bx_if:access_active><bx_if:access_inactive><a class="bx-btn bx-btn-primary bx-dbd-version-activate" href="__access_url__" target="_blank" rel="noopener"><bx_text:_adm_dbd_btn_ver_access_activate /></a></bx_if:access_inactive></div>
     </div>
     <script language="javascript">
         $(document).ready(function() {
             __js_object__.checkForUpgrade();
         });
     </script>
-</div>
+</div>

+ 11 - 5
studio/template/designbox_11.html

@@ -1,8 +1,14 @@
-<!-- Design Box 11 [start]: Content + title + background + padding -->
-<div class="bx-db-container bx-def-color-bg-block rounded-2xl">
-    <div class="bx-db-header px-3 md:px-4 pt-2 md:pt-3">
-        <h2 class="bx-db-title">__title__</h2>__caption_item__
+<!-- Design Box 11 [start]: Content + title + background + padding. The Studio page block shell (.bx-std-block-caption + .bx-std-block-content, with an optional caption icon and raw caption controls), so builder pages such as Dashboard match the other Studio pages; the box description is not shown here. -->
+<div class="bx-std-block cnt-ttl-bg">
+    <div class="bx-std-block-caption">
+        <bx_if:show_db_icon_html>
+            <div class="bx-std-bcap-icon" aria-hidden="true">__icon_html__</div>
+        </bx_if:show_db_icon_html>
+        <bx_if:show_db_icon>
+            <div class="bx-std-bcap-icon" aria-hidden="true"><i class="sys-icon __icon__"></i></div>
+        </bx_if:show_db_icon>
+        <h2 class="bx-std-bcap-text">__title__</h2>__caption_item__
     </div>
-    <div class="bx-db-content bx-content-padding-block relative">__designbox_content__</div>
+    <div class="bx-std-block-content relative">__designbox_content__</div>
 </div>
 <!-- Design Box 11 [ end ] -->

+ 1 - 1
studio/template/dsr_cover_preview_unit_profile.html

@@ -1,7 +1,7 @@
 <div class="bx-base-pofile-unit-with-cover bx-def-box-sizing">
     <div class="bx-base-pofile-unit-cnt bx-def-box bx-def-border bx-def-round-corners bx-def-color-bg-box">
         <a class="bx-base-pofile-unit-cover" <bx_if:show_bg>style="background-image:url(__image_url__)"</bx_if:show_bg> href="#">
-            <img class="bx-def-thumb bx-def-thumb-size bx-def-border" src="<bx_image_url:account.svg />" />
+            <img class="bx-def-thumb bx-def-thumb-size bx-def-border" src="<bx_image_url:account.svg />" alt="__caption__" />
         </a>
         <div class="bx-base-pofile-unit-title bx-def-margin-sec-topbottom"><a href="#">__caption__</a></div>
     </div>

+ 1 - 1
studio/template/dsr_icon_preview.html

@@ -2,7 +2,7 @@
     <div class="adm-dsg-icon-preview adm-dsg-ip-__name__ mt-4 first:mt-0">
         <div class="adm-dsg-ip-title font-bold">__caption__</div>
         <div class="adm-dsg-ip-image pt-1 pb-2">
-            <img src="__url__"<bx_if:show_width> width="__width__"</bx_if:show_width><bx_if:show_height> height="__height__"</bx_if:show_height> />
+            <img src="__url__" alt="__caption__"<bx_if:show_width> width="__width__"</bx_if:show_width><bx_if:show_height> height="__height__"</bx_if:show_height> />
         </div>
         <div class="adm-dsg-ip-action">
             <a class="bx-btn bx-btn-small" href="javascript:void(0)" onclick="__js_object__.deleteIcon('__name__');"><bx_text:_adm_dsg_btn_delete /></a>

+ 18 - 0
studio/template/form_field_section_title.html

@@ -0,0 +1,18 @@
+<bx_if:show_view>
+    <div class="bx-form-section-title px-6 py-5">__caption__<bx_if:show_view_info><br /><span class="bx-def-font-grayed">__info__</span></bx_if:show_view_info></div>
+</bx_if:show_view>
+<bx_if:show_default>
+    <div class="bx-form-section-title group relative flex justify-between align-center flex-nowrap w-full">
+        <div class="flex-1 max-w-full ltr:pr-12 rtl:pl-12">
+            <button type="button" class="flex flex-col justify-center min-h-12 w-full ltr:pl-4 rtl:pr-4 py-2 text-inherit hover:text-gray-800 dark:hover:text-gray-200 font-semibold tracking-wide text-left">
+                __caption__
+                <bx_if:show_default_info>
+                    <span class="bx-def-font-grayed block w-full truncate">__info__</span>
+                </bx_if:show_default_info>
+            </button>
+        </div>
+        <div class="absolute ltr:right-0 rtl:left-0 inset-y-0 flex items-center ltr:pr-4 rtl:pl-4">
+            <u class="bx-form-section-toggler" aria-hidden="true"><bx_icon_inline:ui-chevron-down.svg /></u>
+        </div>
+    </div>
+</bx_if:show_default>

+ 1 - 1
studio/template/form_ghost_template.html

@@ -3,7 +3,7 @@
         <a class="bx-anchor-invisible" name="album-media-{file_id}"></a>
         <input type="hidden" name="__name__" value="{file_id}" />
         <div class="bx-base-general-icon-wrapper">
-            <img src="{file_icon}" class="bx-def-round-corners bx-base-general-icon bx-base-general-icon-{file_id}" />
+            <img src="{file_icon}" alt="{file_name}" class="bx-def-round-corners bx-base-general-icon bx-base-general-icon-{file_id}" />
         </div>
         <span class="bx-base-general-uploader-ghost">
             <a class="bx-btn bx-btn-small" href="javascript:void(0);" title="<bx_text:_sys_txt_form_entry_input_picture_delete />" onclick="javascript: {js_instance_name}.deleteGhost('{file_id}');">

+ 36 - 35
studio/template/images/dbd_version.svg

@@ -1,45 +1,46 @@
-<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect width="80" height="80" fill="url(#paint0_linear_425_2730)"/>
-<path d="M34 55C34 59.4183 30.4184 63 26 63C21.5816 63 18 59.4183 18 55C18 50.5817 21.5816 47 26 47C30.4184 47 34 50.5817 34 55Z" fill="black" fill-opacity="0.2"/>
-<path d="M62 27C62 31.4183 58.4184 35 54 35C49.5816 35 46 31.4183 46 27C46 22.5817 49.5816 19 54 19C58.4184 19 62 22.5817 62 27Z" fill="black" fill-opacity="0.2"/>
-<path d="M29.1282 30.0527C31.9172 27.2828 35.7588 25.5714 40.0001 25.5714C40.4432 25.5714 40.882 25.5901 41.3157 25.6267C41.7339 25.662 42.1154 25.3759 42.2069 24.9663C43.0832 21.0437 45.9509 17.8725 49.6935 16.5692C50.043 16.4475 50.0788 15.9412 49.7336 15.8077C46.714 14.6402 43.4318 14 40.0001 14C36.0323 14 32.2643 14.8559 28.8709 16.3931C28.5996 16.516 28.4286 16.7881 28.4286 17.0859V29.7748C28.4286 30.1301 28.8761 30.3031 29.1282 30.0527Z" fill="black" fill-opacity="0.2"/>
-<path d="M56.0291 62.7292C55.7784 62.9143 55.4286 62.733 55.4286 62.421V41C55.4286 40.5568 55.41 40.118 55.3734 39.6843C55.3379 39.2661 55.6241 38.8847 56.0337 38.7932C59.9565 37.917 63.1274 35.0492 64.4308 31.3065C64.5523 30.957 65.0587 30.9213 65.1922 31.2664C66.3597 34.2861 67 37.5683 67 41C67 49.91 62.6842 57.8117 56.0291 62.7292Z" fill="black" fill-opacity="0.2"/>
-<path d="M15.5692 50.6934C15.4476 51.0428 14.9412 51.0787 14.8077 50.7335C13.6402 47.7139 13 44.4317 13 41C13 32.0902 17.3158 24.1883 23.9709 19.2708C24.2216 19.0855 24.5715 19.2671 24.5715 19.5789V41C24.5715 41.4432 24.5901 41.882 24.6268 42.3157C24.6621 42.7339 24.3759 43.1154 23.9663 43.2069C20.0437 44.0831 16.8725 46.9508 15.5692 50.6934Z" fill="black" fill-opacity="0.2"/>
-<path d="M51.1289 65.607C51.4004 65.484 51.5713 65.2121 51.5713 64.9143V52.2251C51.5713 51.8698 51.1239 51.697 50.8716 51.9474C48.0829 54.7172 44.2412 56.4286 39.9998 56.4286C39.5567 56.4286 39.1179 56.4101 38.6842 56.3734C38.266 56.3379 37.8845 56.6241 37.7931 57.0338C36.9168 60.9565 34.0491 64.1274 30.3064 65.4308C29.9569 65.5527 29.9211 66.0587 30.2663 66.1922C33.286 67.3597 36.5681 68 39.9998 68C43.9677 68 47.7357 67.1441 51.1289 65.607Z" fill="black" fill-opacity="0.2"/>
-<path d="M34 54C34 58.4183 30.4184 62 26 62C21.5816 62 18 58.4183 18 54C18 49.5817 21.5816 46 26 46C30.4184 46 34 49.5817 34 54Z" fill="url(#paint1_linear_425_2730)"/>
-<path d="M62 26C62 30.4183 58.4184 34 54 34C49.5816 34 46 30.4183 46 26C46 21.5817 49.5816 18 54 18C58.4184 18 62 21.5817 62 26Z" fill="url(#paint2_linear_425_2730)"/>
-<path d="M29.1282 29.0527C31.9172 26.2828 35.7588 24.5714 40.0001 24.5714C40.4432 24.5714 40.882 24.5901 41.3157 24.6267C41.7339 24.662 42.1154 24.3759 42.2069 23.9663C43.0832 20.0437 45.9509 16.8725 49.6935 15.5692C50.043 15.4475 50.0788 14.9412 49.7336 14.8077C46.714 13.6402 43.4318 13 40.0001 13C36.0323 13 32.2643 13.8559 28.8709 15.3931C28.5996 15.516 28.4286 15.7881 28.4286 16.0859V28.7748C28.4286 29.1301 28.8761 29.3031 29.1282 29.0527Z" fill="url(#paint3_linear_425_2730)"/>
-<path d="M56.0291 61.7292C55.7784 61.9143 55.4286 61.733 55.4286 61.421V40C55.4286 39.5568 55.41 39.118 55.3734 38.6843C55.3379 38.2661 55.6241 37.8847 56.0337 37.7932C59.9565 36.917 63.1274 34.0492 64.4308 30.3065C64.5523 29.957 65.0587 29.9213 65.1922 30.2664C66.3597 33.2861 67 36.5683 67 40C67 48.91 62.6842 56.8117 56.0291 61.7292Z" fill="url(#paint4_linear_425_2730)"/>
-<path d="M15.5692 49.6934C15.4476 50.0428 14.9412 50.0787 14.8077 49.7335C13.6402 46.7139 13 43.4317 13 40C13 31.0902 17.3158 23.1883 23.9709 18.2708C24.2216 18.0855 24.5715 18.2671 24.5715 18.5789V40C24.5715 40.4432 24.5901 40.882 24.6268 41.3157C24.6621 41.7339 24.3759 42.1154 23.9663 42.2069C20.0437 43.0831 16.8725 45.9508 15.5692 49.6934Z" fill="url(#paint5_linear_425_2730)"/>
-<path d="M51.1289 64.607C51.4004 64.484 51.5713 64.2121 51.5713 63.9143V51.2251C51.5713 50.8698 51.1239 50.697 50.8716 50.9474C48.0829 53.7172 44.2412 55.4286 39.9998 55.4286C39.5567 55.4286 39.1179 55.4101 38.6842 55.3734C38.266 55.3379 37.8845 55.6241 37.7931 56.0338C36.9168 59.9565 34.0491 63.1274 30.3064 64.4308C29.9569 64.5527 29.9211 65.0587 30.2663 65.1922C33.286 66.3597 36.5681 67 39.9998 67C43.9677 67 47.7357 66.1441 51.1289 64.607Z" fill="url(#paint6_linear_425_2730)"/>
+<svg width="80" height="80" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="512" height="512" fill="url(#paint0_una_version)"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M256 46C295.94 46 333.277 57.1504 365.065 76.5078C402.004 80.674 431.325 109.995 435.491 146.934C454.849 178.723 466 216.059 466 256C466 371.98 371.98 466 256 466C216.059 466 178.723 454.849 146.934 435.491C109.995 431.325 80.674 402.004 76.5078 365.065C57.1504 333.277 46 295.94 46 256C46 140.02 140.02 46 256 46ZM256 176C211.817 176 176 211.817 176 256C176 264.182 177.229 272.078 179.512 279.512C204.786 287.271 224.727 307.213 232.487 332.487C239.922 334.77 247.817 336 256 336C300.183 336 336 300.183 336 256C336 247.817 334.77 239.922 332.487 232.487C307.213 224.727 287.271 204.786 279.512 179.512C272.078 177.229 264.182 176 256 176Z" fill="black" fill-opacity="0.8"/>
+<path d="M325.77 327.635C327.195 326.246 329.692 327.464 329.37 329.428C328.337 335.736 327.247 341.99 326.149 348.28C321.624 374.209 316.98 400.753 316.137 433.474C316.094 435.168 315.001 436.665 313.386 437.176C295.28 442.906 276.001 446 256 446C232.809 446 210.589 441.843 190.042 434.236C187.47 433.284 187.511 429.593 189.983 428.406C215.779 416.028 234.028 388.984 235.828 358.887C235.979 356.363 238.293 354.468 240.793 354.85C245.752 355.607 250.83 356 256 356C283.14 356 307.753 345.187 325.77 327.635Z" fill="white" fill-opacity="0.8"/>
+<path d="M428.406 189.983C429.593 187.511 433.284 187.47 434.236 190.042C441.843 210.589 446 232.809 446 256C446 329.89 403.821 393.927 342.228 425.351C339.557 426.713 336.471 424.66 336.643 421.667C338.122 396.06 341.941 374.12 345.851 351.72C350.852 323.067 356 293.662 356 256C356 250.83 355.607 245.752 354.85 240.793C354.468 238.293 356.363 235.979 358.887 235.828C388.984 234.028 416.028 215.779 428.406 189.983Z" fill="white" fill-opacity="0.8"/>
+<path d="M169.771 86.6484C172.442 85.2859 175.528 87.3392 175.356 90.3322C173.877 115.939 170.059 137.88 166.149 160.28C161.148 188.933 156 218.338 156 256C156 261.17 156.393 266.248 157.149 271.206C157.531 273.706 155.636 276.02 153.112 276.171C123.015 277.971 95.9711 296.22 83.5927 322.016C82.4061 324.488 78.7154 324.529 77.7632 321.957C70.1568 301.411 66 279.191 66 256C66 182.111 108.178 118.072 169.771 86.6484Z" fill="white" fill-opacity="0.8"/>
+<path d="M256 66C279.191 66 301.411 70.1568 321.957 77.7632C324.529 78.7154 324.488 82.4061 322.016 83.5927C296.22 95.9711 277.971 123.015 276.171 153.112C276.02 155.636 273.706 157.531 271.206 157.149C266.248 156.393 261.17 156 256 156C228.86 156 204.246 166.813 186.229 184.364C184.804 185.753 182.307 184.535 182.629 182.571C183.662 176.263 184.753 170.009 185.851 163.72C190.376 137.791 195.019 111.246 195.862 78.5246C195.905 76.8306 196.998 75.3345 198.613 74.8232C216.72 69.0932 235.998 66 256 66Z" fill="white" fill-opacity="0.8"/>
+<path d="M325.77 335.635C327.195 334.246 329.692 335.464 329.37 337.428C328.337 343.736 327.247 349.99 326.149 356.28C321.624 382.209 316.98 408.753 316.137 441.474C316.094 443.168 315.001 444.665 313.386 445.176C295.28 450.906 276.001 454 256 454C232.809 454 210.589 449.843 190.042 442.236C187.47 441.284 187.511 437.593 189.983 436.406C215.779 424.028 234.028 396.984 235.828 366.887C235.979 364.363 238.293 362.468 240.793 362.85C245.752 363.607 250.83 364 256 364C283.14 364 307.753 353.187 325.77 335.635Z" fill="url(#paint1_una_version)" fill-opacity="0.5"/>
+<path d="M428.406 197.983C429.593 195.511 433.284 195.47 434.236 198.042C441.843 218.589 446 240.809 446 264C446 337.89 403.821 401.927 342.228 433.351C339.557 434.713 336.471 432.66 336.643 429.667C338.122 404.06 341.941 382.12 345.851 359.72C350.852 331.067 356 301.662 356 264C356 258.83 355.607 253.752 354.85 248.793C354.468 246.293 356.363 243.979 358.887 243.828C388.984 242.028 416.028 223.779 428.406 197.983Z" fill="url(#paint2_una_version)" fill-opacity="0.5"/>
+<path d="M169.771 94.6484C172.442 93.2859 175.528 95.3392 175.356 98.3322C173.877 123.939 170.059 145.88 166.149 168.28C161.148 196.933 156 226.338 156 264C156 269.17 156.393 274.248 157.149 279.206C157.531 281.706 155.636 284.02 153.112 284.171C123.015 285.971 95.9711 304.22 83.5927 330.016C82.4061 332.488 78.7154 332.529 77.7632 329.957C70.1568 309.411 66 287.191 66 264C66 190.111 108.178 126.072 169.771 94.6484Z" fill="url(#paint3_una_version)" fill-opacity="0.5"/>
+<path d="M256 74C279.191 74 301.411 78.1568 321.957 85.7632C324.529 86.7154 324.488 90.4061 322.016 91.5927C296.22 103.971 277.971 131.015 276.171 161.112C276.02 163.636 273.706 165.531 271.206 165.149C266.248 164.393 261.17 164 256 164C228.86 164 204.246 174.813 186.229 192.364C184.804 193.753 182.307 192.535 182.629 190.571C183.662 184.263 184.753 178.009 185.851 171.72C190.376 145.791 195.019 119.246 195.862 86.5246C195.905 84.8306 196.998 83.3345 198.613 82.8232C216.72 77.0932 235.998 74 256 74Z" fill="url(#paint4_una_version)" fill-opacity="0.5"/>
+<circle cx="356" cy="156" r="60" fill="white"/>
+<circle cx="356" cy="164" r="60" fill="url(#paint5_una_version)" fill-opacity="0.5"/>
+<circle cx="156" cy="356" r="60" fill="white"/>
+<circle cx="156" cy="366" r="60" fill="url(#paint6_una_version)" fill-opacity="0.5"/>
 <defs>
-<linearGradient id="paint0_linear_425_2730" x1="40" y1="0" x2="40" y2="80" gradientUnits="userSpaceOnUse">
-<stop stop-color="#6B7280"/>
-<stop offset="1" stop-color="#1F2937"/>
+<linearGradient id="paint0_una_version" x1="256" y1="0" x2="256" y2="512" gradientUnits="userSpaceOnUse">
+<stop stop-color="#3F3F46"/>
+<stop offset="1" stop-color="#18181B"/>
 </linearGradient>
-<linearGradient id="paint1_linear_425_2730" x1="26" y1="46" x2="26" y2="62" gradientUnits="userSpaceOnUse">
-<stop stop-color="#FDE68A"/>
-<stop offset="1" stop-color="#D97706"/>
+<linearGradient id="paint1_una_version" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint2_linear_425_2730" x1="54" y1="18" x2="54" y2="34" gradientUnits="userSpaceOnUse">
-<stop stop-color="#FDE68A"/>
-<stop offset="1" stop-color="#D97706"/>
+<linearGradient id="paint2_una_version" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint3_linear_425_2730" x1="40" y1="13" x2="40" y2="67" gradientUnits="userSpaceOnUse">
-<stop stop-color="#F9FAFB"/>
-<stop offset="1" stop-color="#9CA3AF"/>
+<linearGradient id="paint3_una_version" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint4_linear_425_2730" x1="40" y1="13" x2="40" y2="67" gradientUnits="userSpaceOnUse">
-<stop stop-color="#F9FAFB"/>
-<stop offset="1" stop-color="#9CA3AF"/>
+<linearGradient id="paint4_una_version" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint5_linear_425_2730" x1="40" y1="13" x2="40" y2="67" gradientUnits="userSpaceOnUse">
-<stop stop-color="#F9FAFB"/>
-<stop offset="1" stop-color="#9CA3AF"/>
+<linearGradient id="paint5_una_version" x1="356" y1="104" x2="356" y2="224" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint6_linear_425_2730" x1="40" y1="13" x2="40" y2="67" gradientUnits="userSpaceOnUse">
-<stop stop-color="#F9FAFB"/>
-<stop offset="1" stop-color="#9CA3AF"/>
+<linearGradient id="paint6_una_version" x1="156" y1="306" x2="156" y2="426" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
 </defs>
 </svg>

+ 7 - 0
studio/template/images/icons/ami-tour.svg

@@ -0,0 +1,7 @@
+<svg class="lucide" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+    <path d="M22 14a8 8 0 0 1-8 8"/>
+    <path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/>
+    <path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1"/>
+    <path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10"/>
+    <path d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>
+</svg>

BIN
studio/template/images/icons/apple-touch-icon.png


+ 1 - 0
studio/template/images/icons/bp-settings.svg

@@ -0,0 +1 @@
+<svg class="lucide lucide-settings" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>

+ 1 - 0
studio/template/images/icons/bp-view.svg

@@ -0,0 +1 @@
+<svg class="lucide lucide-square-arrow-out-up-right" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6"/><path d="m21 3-9 9"/><path d="M15 3h6v6"/></svg>

+ 0 - 4
studio/template/images/icons/db-ht-fail.svg

@@ -1,4 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-    <path d="M15.7494 15L9.75 9M9.75064 15L15.75 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
-    <path d="M22.75 12C22.75 6.47715 18.2728 2 12.75 2C7.22715 2 2.75 6.47715 2.75 12C2.75 17.5228 7.22715 22 12.75 22C18.2728 22 22.75 17.5228 22.75 12Z" stroke="currentColor" stroke-width="1.5" />
-</svg>

+ 0 - 11
studio/template/images/icons/db-ht-ok.svg

@@ -1,11 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_20_191)">
-<path d="M3 12C3 13.1819 3.23279 14.3522 3.68508 15.4442C4.13738 16.5361 4.80031 17.5282 5.63604 18.364C6.47177 19.1997 7.46392 19.8626 8.55585 20.3149C9.64778 20.7672 10.8181 21 12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C10.8181 3 9.64778 3.23279 8.55585 3.68508C7.46392 4.13738 6.47177 4.80031 5.63604 5.63604C4.80031 6.47177 4.13738 7.46392 3.68508 8.55585C3.23279 9.64778 3 10.8181 3 12Z" stroke="#27272A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9 12L11 14L15 10" stroke="#27272A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</g>
-<defs>
-<clipPath id="clip0_20_191">
-<rect width="24" height="24" fill="white"/>
-</clipPath>
-</defs>
-</svg>

+ 0 - 3
studio/template/images/icons/db-ht-undef.svg

@@ -1,3 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-    <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
-</svg>

+ 0 - 5
studio/template/images/icons/db-ht-warn.svg

@@ -1,5 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-    <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5" />
-    <path d="M11.992 15H12.001" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
-    <path d="M12 12L12 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
-</svg>

+ 0 - 12
studio/template/images/icons/db-vi-available.svg

@@ -1,12 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_20_210)">
-<path d="M3 12C3 13.1819 3.23279 14.3522 3.68508 15.4442C4.13738 16.5361 4.80031 17.5282 5.63604 18.364C6.47177 19.1997 7.46392 19.8626 8.55585 20.3149C9.64778 20.7672 10.8181 21 12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C10.8181 3 9.64778 3.23279 8.55585 3.68508C7.46392 4.13738 6.47177 4.80031 5.63604 5.63604C4.80031 6.47177 4.13738 7.46392 3.68508 8.55585C3.23279 9.64778 3 10.8181 3 12Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12 9V13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12 16V16.01" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</g>
-<defs>
-<clipPath id="clip0_20_210">
-<rect width="24" height="24" fill="white"/>
-</clipPath>
-</defs>
-</svg>

+ 0 - 11
studio/template/images/icons/db-vi-installed.svg

@@ -1,11 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_20_191)">
-<path d="M3 12C3 13.1819 3.23279 14.3522 3.68508 15.4442C4.13738 16.5361 4.80031 17.5282 5.63604 18.364C6.47177 19.1997 7.46392 19.8626 8.55585 20.3149C9.64778 20.7672 10.8181 21 12 21C13.1819 21 14.3522 20.7672 15.4442 20.3149C16.5361 19.8626 17.5282 19.1997 18.364 18.364C19.1997 17.5282 19.8626 16.5361 20.3149 15.4442C20.7672 14.3522 21 13.1819 21 12C21 10.8181 20.7672 9.64778 20.3149 8.55585C19.8626 7.46392 19.1997 6.47177 18.364 5.63604C17.5282 4.80031 16.5361 4.13738 15.4442 3.68508C14.3522 3.23279 13.1819 3 12 3C10.8181 3 9.64778 3.23279 8.55585 3.68508C7.46392 4.13738 6.47177 4.80031 5.63604 5.63604C4.80031 6.47177 4.13738 7.46392 3.68508 8.55585C3.23279 9.64778 3 10.8181 3 12Z" stroke="#27272A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M9 12L11 14L15 10" stroke="#27272A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</g>
-<defs>
-<clipPath id="clip0_20_191">
-<rect width="24" height="24" fill="white"/>
-</clipPath>
-</defs>
-</svg>

+ 0 - 10
studio/template/images/icons/db-vi-updated.svg

@@ -1,10 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_20_201)">
-<path d="M19.95 11C19.6971 9.07074 18.7498 7.29977 17.2853 6.01862C15.8209 4.73746 13.9397 4.03395 11.9939 4.03977C10.0482 4.04558 8.17124 4.76034 6.71448 6.05023C5.25771 7.34012 4.32098 9.11672 4.07963 11.0475C3.83829 12.9782 4.30888 14.9307 5.40332 16.5395C6.49775 18.1483 8.141 19.3031 10.0254 19.7877C11.9099 20.2722 13.9064 20.0534 15.6411 19.1722C17.3759 18.2909 18.73 16.8076 19.45 15M19.95 20V15H14.95" stroke="#27272A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</g>
-<defs>
-<clipPath id="clip0_20_201">
-<rect width="24" height="24" fill="white"/>
-</clipPath>
-</defs>
-</svg>

+ 38 - 37
studio/template/images/icons/favicon.svg

@@ -1,45 +1,46 @@
-<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect width="72" height="72" fill="url(#paint0_linear_3_192)"/>
-<path d="M30.8572 48C30.8572 51.7871 27.7872 54.8571 24 54.8571C20.2128 54.8571 17.1429 51.7871 17.1429 48C17.1429 44.2129 20.2128 41.1429 24 41.1429C27.7872 41.1429 30.8572 44.2129 30.8572 48Z" fill="white"/>
-<path d="M54.8571 24C54.8571 27.7871 51.7872 30.8571 48 30.8571C44.2128 30.8571 41.1429 27.7871 41.1429 24C41.1429 20.2129 44.2128 17.1429 48 17.1429C51.7872 17.1429 54.8571 20.2129 54.8571 24Z" fill="white"/>
-<path d="M26.6814 26.6166C29.072 24.2424 32.3648 22.7755 36.0002 22.7755C36.38 22.7755 36.7561 22.7915 37.1279 22.8229C37.4863 22.8532 37.8133 22.6079 37.8917 22.2569C38.6428 18.8946 41.1008 16.1764 44.3088 15.0594C44.6083 14.9551 44.6391 14.521 44.3432 14.4066C41.7549 13.4059 38.9416 12.8572 36.0002 12.8572C32.5992 12.8572 29.3695 13.5908 26.4609 14.9084C26.2283 15.0137 26.0818 15.2469 26.0818 15.5022V26.3784C26.0818 26.683 26.4653 26.8312 26.6814 26.6166Z" fill="url(#paint1_linear_3_192)"/>
-<path d="M49.7393 54.625C49.5244 54.7837 49.2246 54.6283 49.2246 54.3609V36C49.2246 35.6202 49.2087 35.2441 49.1773 34.8723C49.1469 34.5139 49.3922 34.1869 49.7433 34.1085C53.1056 33.3574 55.8236 30.8993 56.9407 27.6913C57.0449 27.3917 57.479 27.3611 57.5934 27.6569C58.5941 30.2453 59.143 33.0585 59.143 36C59.143 43.6371 55.4437 50.4101 49.7393 54.625Z" fill="url(#paint2_linear_3_192)"/>
-<path d="M15.0595 44.3086C14.9551 44.6082 14.5211 44.6389 14.4067 44.343C13.406 41.7548 12.8572 38.9415 12.8572 36.0001C12.8572 28.363 16.5565 21.59 22.2609 17.375C22.4758 17.2162 22.7756 17.3719 22.7756 17.6391V36.0001C22.7756 36.3799 22.7916 36.756 22.823 37.1277C22.8533 37.4862 22.608 37.8132 22.257 37.8916C18.8947 38.6427 16.1765 41.1007 15.0595 44.3086Z" fill="url(#paint3_linear_3_192)"/>
-<path d="M45.5391 57.0918C45.7719 56.9863 45.9184 56.7532 45.9184 56.498V45.6215C45.9184 45.317 45.5348 45.1689 45.3186 45.3835C42.9283 47.7576 39.6354 49.2245 36 49.2245C35.6201 49.2245 35.244 49.2086 34.8723 49.1772C34.5138 49.1468 34.1869 49.3921 34.1084 49.7433C33.3574 53.1056 30.8993 55.8235 27.6913 56.9407C27.3917 57.0452 27.3611 57.4789 27.6569 57.5933C30.2452 58.5941 33.0585 59.1429 36 59.1429C39.401 59.1429 42.6307 58.4093 45.5391 57.0918Z" fill="url(#paint4_linear_3_192)"/>
-<path d="M30.8573 48.8572C30.8573 52.6443 27.7873 55.7143 24.0001 55.7143C20.2129 55.7143 17.143 52.6443 17.143 48.8572C17.143 45.0701 20.2129 42 24.0001 42C27.7873 42 30.8573 45.0701 30.8573 48.8572Z" fill="black" fill-opacity="0.1"/>
-<path d="M54.8572 24.8571C54.8572 28.6442 51.7873 31.7143 48.0001 31.7143C44.2129 31.7143 41.143 28.6442 41.143 24.8571C41.143 21.07 44.2129 18 48.0001 18C51.7873 18 54.8572 21.07 54.8572 24.8571Z" fill="black" fill-opacity="0.1"/>
-<path d="M26.6814 27.4738C29.072 25.0995 32.3648 23.6327 36.0002 23.6327C36.38 23.6327 36.7561 23.6487 37.1279 23.6801C37.4863 23.7103 37.8133 23.4651 37.8917 23.114C38.6428 19.7517 41.1008 17.0335 44.3088 15.9165C44.6083 15.8122 44.6391 15.3782 44.3432 15.2638C41.7549 14.263 38.9416 13.7143 36.0002 13.7143C32.5992 13.7143 29.3695 14.4479 26.4609 15.7655C26.2283 15.8708 26.0818 16.1041 26.0818 16.3593V27.2355C26.0818 27.5401 26.4653 27.6884 26.6814 27.4738Z" fill="black" fill-opacity="0.1"/>
-<path d="M49.7393 55.4822C49.5244 55.6409 49.2246 55.4855 49.2246 55.218V36.8571C49.2246 36.4773 49.2087 36.1012 49.1773 35.7294C49.1469 35.371 49.3922 35.044 49.7433 34.9656C53.1056 34.2145 55.8236 31.7565 56.9407 28.5484C57.0449 28.2489 57.479 28.2182 57.5934 28.5141C58.5941 31.1024 59.143 33.9157 59.143 36.8571C59.143 44.4943 55.4437 51.2672 49.7393 55.4822Z" fill="black" fill-opacity="0.1"/>
-<path d="M15.0595 45.1658C14.9551 45.4653 14.5211 45.4961 14.4067 45.2002C13.406 42.6119 12.8572 39.7986 12.8572 36.8572C12.8572 29.2202 16.5565 22.4471 22.2609 18.2321C22.4758 18.0733 22.7756 18.229 22.7756 18.4962V36.8572C22.7756 37.237 22.7916 37.6131 22.823 37.9849C22.8533 38.3433 22.608 38.6703 22.257 38.7488C18.8947 39.4998 16.1765 41.9578 15.0595 45.1658Z" fill="black" fill-opacity="0.1"/>
-<path d="M45.5391 57.9489C45.7719 57.8434 45.9184 57.6104 45.9184 57.3551V46.4786C45.9184 46.1742 45.5348 46.026 45.3186 46.2406C42.9283 48.6147 39.6354 50.0817 36 50.0817C35.6201 50.0817 35.244 50.0658 34.8723 50.0344C34.5138 50.004 34.1869 50.2493 34.1084 50.6004C33.3574 53.9627 30.8993 56.6807 27.6913 57.7978C27.3917 57.9023 27.3611 58.336 27.6569 58.4504C30.2452 59.4512 33.0585 60 36 60C39.401 60 42.6307 59.2664 45.5391 57.9489Z" fill="black" fill-opacity="0.1"/>
+<svg width="72" height="72" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="512" height="512" rx="113.78" fill="url(#paint0_una_favicon)"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M256 46C295.94 46 333.277 57.1504 365.065 76.5078C402.004 80.674 431.325 109.995 435.491 146.934C454.849 178.723 466 216.059 466 256C466 371.98 371.98 466 256 466C216.059 466 178.723 454.849 146.934 435.491C109.995 431.325 80.674 402.004 76.5078 365.065C57.1504 333.277 46 295.94 46 256C46 140.02 140.02 46 256 46ZM256 176C211.817 176 176 211.817 176 256C176 264.182 177.229 272.078 179.512 279.512C204.786 287.271 224.727 307.213 232.487 332.487C239.922 334.77 247.817 336 256 336C300.183 336 336 300.183 336 256C336 247.817 334.77 239.922 332.487 232.487C307.213 224.727 287.271 204.786 279.512 179.512C272.078 177.229 264.182 176 256 176Z" fill="black" fill-opacity="0.8"/>
+<path d="M325.77 327.635C327.195 326.246 329.692 327.464 329.37 329.428C328.337 335.736 327.247 341.99 326.149 348.28C321.624 374.209 316.98 400.753 316.137 433.474C316.094 435.168 315.001 436.665 313.386 437.176C295.28 442.906 276.001 446 256 446C232.809 446 210.589 441.843 190.042 434.236C187.47 433.284 187.511 429.593 189.983 428.406C215.779 416.028 234.028 388.984 235.828 358.887C235.979 356.363 238.293 354.468 240.793 354.85C245.752 355.607 250.83 356 256 356C283.14 356 307.753 345.187 325.77 327.635Z" fill="white" fill-opacity="0.8"/>
+<path d="M428.406 189.983C429.593 187.511 433.284 187.47 434.236 190.042C441.843 210.589 446 232.809 446 256C446 329.89 403.821 393.927 342.228 425.351C339.557 426.713 336.471 424.66 336.643 421.667C338.122 396.06 341.941 374.12 345.851 351.72C350.852 323.067 356 293.662 356 256C356 250.83 355.607 245.752 354.85 240.793C354.468 238.293 356.363 235.979 358.887 235.828C388.984 234.028 416.028 215.779 428.406 189.983Z" fill="white" fill-opacity="0.8"/>
+<path d="M169.771 86.6484C172.442 85.2859 175.528 87.3392 175.356 90.3322C173.877 115.939 170.059 137.88 166.149 160.28C161.148 188.933 156 218.338 156 256C156 261.17 156.393 266.248 157.149 271.206C157.531 273.706 155.636 276.02 153.112 276.171C123.015 277.971 95.9711 296.22 83.5927 322.016C82.4061 324.488 78.7154 324.529 77.7632 321.957C70.1568 301.411 66 279.191 66 256C66 182.111 108.178 118.072 169.771 86.6484Z" fill="white" fill-opacity="0.8"/>
+<path d="M256 66C279.191 66 301.411 70.1568 321.957 77.7632C324.529 78.7154 324.488 82.4061 322.016 83.5927C296.22 95.9711 277.971 123.015 276.171 153.112C276.02 155.636 273.706 157.531 271.206 157.149C266.248 156.393 261.17 156 256 156C228.86 156 204.246 166.813 186.229 184.364C184.804 185.753 182.307 184.535 182.629 182.571C183.662 176.263 184.753 170.009 185.851 163.72C190.376 137.791 195.019 111.246 195.862 78.5246C195.905 76.8306 196.998 75.3345 198.613 74.8232C216.72 69.0932 235.998 66 256 66Z" fill="white" fill-opacity="0.8"/>
+<path d="M325.77 335.635C327.195 334.246 329.692 335.464 329.37 337.428C328.337 343.736 327.247 349.99 326.149 356.28C321.624 382.209 316.98 408.753 316.137 441.474C316.094 443.168 315.001 444.665 313.386 445.176C295.28 450.906 276.001 454 256 454C232.809 454 210.589 449.843 190.042 442.236C187.47 441.284 187.511 437.593 189.983 436.406C215.779 424.028 234.028 396.984 235.828 366.887C235.979 364.363 238.293 362.468 240.793 362.85C245.752 363.607 250.83 364 256 364C283.14 364 307.753 353.187 325.77 335.635Z" fill="url(#paint1_una_favicon)" fill-opacity="0.5"/>
+<path d="M428.406 197.983C429.593 195.511 433.284 195.47 434.236 198.042C441.843 218.589 446 240.809 446 264C446 337.89 403.821 401.927 342.228 433.351C339.557 434.713 336.471 432.66 336.643 429.667C338.122 404.06 341.941 382.12 345.851 359.72C350.852 331.067 356 301.662 356 264C356 258.83 355.607 253.752 354.85 248.793C354.468 246.293 356.363 243.979 358.887 243.828C388.984 242.028 416.028 223.779 428.406 197.983Z" fill="url(#paint2_una_favicon)" fill-opacity="0.5"/>
+<path d="M169.771 94.6484C172.442 93.2859 175.528 95.3392 175.356 98.3322C173.877 123.939 170.059 145.88 166.149 168.28C161.148 196.933 156 226.338 156 264C156 269.17 156.393 274.248 157.149 279.206C157.531 281.706 155.636 284.02 153.112 284.171C123.015 285.971 95.9711 304.22 83.5927 330.016C82.4061 332.488 78.7154 332.529 77.7632 329.957C70.1568 309.411 66 287.191 66 264C66 190.111 108.178 126.072 169.771 94.6484Z" fill="url(#paint3_una_favicon)" fill-opacity="0.5"/>
+<path d="M256 74C279.191 74 301.411 78.1568 321.957 85.7632C324.529 86.7154 324.488 90.4061 322.016 91.5927C296.22 103.971 277.971 131.015 276.171 161.112C276.02 163.636 273.706 165.531 271.206 165.149C266.248 164.393 261.17 164 256 164C228.86 164 204.246 174.813 186.229 192.364C184.804 193.753 182.307 192.535 182.629 190.571C183.662 184.263 184.753 178.009 185.851 171.72C190.376 145.791 195.019 119.246 195.862 86.5246C195.905 84.8306 196.998 83.3345 198.613 82.8232C216.72 77.0932 235.998 74 256 74Z" fill="url(#paint4_una_favicon)" fill-opacity="0.5"/>
+<circle cx="356" cy="156" r="60" fill="white"/>
+<circle cx="356" cy="164" r="60" fill="url(#paint5_una_favicon)" fill-opacity="0.5"/>
+<circle cx="156" cy="356" r="60" fill="white"/>
+<circle cx="156" cy="366" r="60" fill="url(#paint6_una_favicon)" fill-opacity="0.5"/>
 <defs>
-<linearGradient id="paint0_linear_3_192" x1="36" y1="0" x2="36" y2="72" gradientUnits="userSpaceOnUse">
-<stop stop-color="#71717A"/>
-<stop offset="1" stop-color="#3F3F46"/>
+<linearGradient id="paint0_una_favicon" x1="256" y1="0" x2="256" y2="512" gradientUnits="userSpaceOnUse">
+<stop stop-color="#3F3F46"/>
+<stop offset="1" stop-color="#18181B"/>
 </linearGradient>
-<linearGradient id="paint1_linear_3_192" x1="21.9707" y1="17.7985" x2="51.5689" y2="55.1767" gradientUnits="userSpaceOnUse">
-<stop stop-color="white"/>
-<stop offset="0.361803" stop-color="white" stop-opacity="0.6"/>
-<stop offset="0.60378" stop-color="white"/>
-<stop offset="1" stop-color="white" stop-opacity="0.6"/>
+<linearGradient id="paint1_una_favicon" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint2_linear_3_192" x1="21.9707" y1="17.7985" x2="51.5689" y2="55.1767" gradientUnits="userSpaceOnUse">
-<stop stop-color="white"/>
-<stop offset="0.361803" stop-color="white" stop-opacity="0.6"/>
-<stop offset="0.60378" stop-color="white"/>
-<stop offset="1" stop-color="white" stop-opacity="0.6"/>
+<linearGradient id="paint2_una_favicon" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint3_linear_3_192" x1="21.9707" y1="17.7985" x2="51.5689" y2="55.1767" gradientUnits="userSpaceOnUse">
-<stop stop-color="white"/>
-<stop offset="0.361803" stop-color="white" stop-opacity="0.6"/>
-<stop offset="0.60378" stop-color="white"/>
-<stop offset="1" stop-color="white" stop-opacity="0.6"/>
+<linearGradient id="paint3_una_favicon" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
-<linearGradient id="paint4_linear_3_192" x1="21.9707" y1="17.7985" x2="51.5689" y2="55.1767" gradientUnits="userSpaceOnUse">
-<stop stop-color="white"/>
-<stop offset="0.361803" stop-color="white" stop-opacity="0.6"/>
-<stop offset="0.60378" stop-color="white"/>
-<stop offset="1" stop-color="white" stop-opacity="0.6"/>
+<linearGradient id="paint4_una_favicon" x1="256" y1="74" x2="256" y2="454" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
+</linearGradient>
+<linearGradient id="paint5_una_favicon" x1="356" y1="104" x2="356" y2="224" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
+</linearGradient>
+<linearGradient id="paint6_una_favicon" x1="156" y1="306" x2="156" y2="426" gradientUnits="userSpaceOnUse">
+<stop stop-opacity="0.1"/>
+<stop offset="1" stop-opacity="0.3"/>
 </linearGradient>
 </defs>
 </svg>

+ 1 - 0
studio/template/images/icons/mi-api-config.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sliders-horizontal"><path d="M10 5H3"/><path d="M12 19H3"/><path d="M14 3v4"/><path d="M16 17v4"/><path d="M21 12h-9"/><path d="M21 19h-5"/><path d="M21 5h-7"/><path d="M8 10v4"/><path d="M8 12H3"/></svg>

+ 1 - 0
studio/template/images/icons/mi-api-keys.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-key-round"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg>

+ 1 - 0
studio/template/images/icons/mi-api-origins.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>

+ 1 - 0
studio/template/images/icons/mi-bp-cache.svg

@@ -0,0 +1 @@
+<svg class="lucide lucide-hard-drive" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></svg>

+ 1 - 0
studio/template/images/icons/mi-bp-cover.svg

@@ -0,0 +1 @@
+<svg class="lucide lucide-image" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>

+ 1 - 0
studio/template/images/icons/mi-bp-injections.svg

@@ -0,0 +1 @@
+<svg class="lucide lucide-code" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>

+ 1 - 0
studio/template/images/icons/mi-bp-layout.svg

@@ -0,0 +1 @@
+<svg class="lucide lucide-layout-template" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="7" x="3" y="3" rx="1"/><rect width="9" height="7" x="3" y="14" rx="1"/><rect width="5" height="7" x="16" y="14" rx="1"/></svg>

+ 1 - 0
studio/template/images/icons/mi-bp-options.svg

@@ -0,0 +1 @@
+<svg class="lucide lucide-sliders-horizontal" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 5H3"/><path d="M12 19H3"/><path d="M14 3v4"/><path d="M16 17v4"/><path d="M21 12h-9"/><path d="M21 19h-5"/><path d="M21 5h-7"/><path d="M8 10v4"/><path d="M8 12H3"/></svg>

+ 1 - 0
studio/template/images/icons/mi-bp-seo.svg

@@ -0,0 +1 @@
+<svg class="lucide lucide-search-check" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 11 2 2 4-4"/><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>

+ 1 - 0
studio/template/images/icons/mi-bp-visibility.svg

@@ -0,0 +1 @@
+<svg class="lucide lucide-eye" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>

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