Jelajahi Sumber

Informer: redesign with dark mode, WCAG fixes, button size scale and Lucide icons

Informer
- Restyle as a status surface (indicator circle 36px with 20px icon, content,
  optional trailing action button) with dark-mode colour ramps; all text and
  icon pairs meet WCAG AA contrast.
- BxDolInformer::add() accepts optional params: icon, action_url, action_title.
- PHP resolves a single icon per message (type default or override) instead of
  four hidden SVGs toggled by CSS; informer.css shrinks to the rule Ocean imports.
- Profile-system notice becomes an info message with a "Create profile" action;
  new string _sys_txt_account_profile_system_action (en, ru). Ocean template
  renders the action too.

Buttons / accessibility
- Size scale shared with the NEO client: bx-btn-mini 28px (hit area extended to
  44px), bx-btn-small 36px, bx-btn 44px, bx-btn-large 52px, bx-btn-xlarge 60px,
  with corner radius 6/8/10/12/14px.
- Focus rings move from focus: (20% ring) to focus-visible: with a solid ring
  and offset; links no longer lose their focus outline globally.
- Primary button hover contrast fixed (white on blue-700).

Iconsets
- BxBaseIconset::getIconHtml() returns iconset-independent HTML (false for
  font iconsets). Lucide implements it as inline SVG from the vendored
  lucide-static icons, so informers show Lucide icons even when the site
  default is FontAwesome, without loading the Lucide runtime.
- Lucide runtime served from a vendored, pinned copy (build:lucide) instead of
  unpkg @latest; alias values in the name map normalised to canonical names.
- API mode emits Lucide PascalCase icon names for NEO clients; existing keys
  unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Andrey Yasko 1 bulan lalu
induk
melakukan
41bd6bd2b1

+ 8 - 1
inc/classes/BxDolInformer.php

@@ -78,8 +78,12 @@ class BxDolInformer extends BxDolFactory implements iBxDolSingleton
      * @param $sId - message id
      * @param $sMsg - message text
      * @param $iType - message type: BX_INFORMER_ALERT, BX_INFORMER_INFO or BX_INFORMER_ERROR
+     * @param $aParams - optional params:
+     *      - `icon` - [string] font icon name to show instead of the default type icon, e.g. `user-plus`
+     *      - `action_url` - [string] URL of the trailing action button
+     *      - `action_title` - [string] title of the trailing action button, the button is shown only when both URL and title are provided
      */
-    public function add ($sId, $sMsg, $iType = BX_INFORMER_INFO)
+    public function add ($sId, $sMsg, $iType = BX_INFORMER_INFO, $aParams = [])
     {
         if(!$this->_bEnabled)
             return;
@@ -89,6 +93,9 @@ class BxDolInformer extends BxDolFactory implements iBxDolSingleton
             'id' => $sId,
             'msg' => $sMsg,
             'type' => $iType,
+            'icon' => !empty($aParams['icon']) ? $aParams['icon'] : '',
+            'action_url' => !empty($aParams['action_url']) ? $aParams['action_url'] : '',
+            'action_title' => !empty($aParams['action_title']) ? $aParams['action_title'] : '',
         ];
     }
 

+ 5 - 2
inc/classes/BxDolProfile.php

@@ -839,8 +839,11 @@ class BxDolProfile extends BxDolFactory implements iBxDolProfile
     	$aProfiles = $this->_oQuery->getProfilesByAccount($aInfo['account_id']);
 
         if ($aInfo['type'] == 'system' && count($aProfiles) == 1) {
-            $sUrl = BxDolPermalinks::getInstance()->permalink('page.php?i=account-profile-switcher');
-            $oInformer->add('sys-account-profile-system', _t('_sys_txt_account_profile_system', $sUrl), BX_INFORMER_ALERT);
+            $oInformer->add('sys-account-profile-system', _t('_sys_txt_account_profile_system'), BX_INFORMER_INFO, [
+                'icon' => 'user-plus',
+                'action_url' => BxDolPermalinks::getInstance()->permalink('page.php?i=account-profile-switcher'),
+                'action_title' => _t('_sys_txt_account_profile_system_action'),
+            ]);
         }
     }
 }

+ 2 - 1
modules/boonex/english/data/langs/system/en.xml

@@ -4262,7 +4262,8 @@ If it is not enabled then please consider implementing this optimization, since
     <string name="_sys_txt_account_suspended"><![CDATA[Your account is suspended.]]></string>
     <string name="_sys_txt_account_unconfirmed_email"><![CDATA[Your email address is unconfirmed. Please, check your email ({1}) for a confirmation letter and click email confirmation link or use confirmation code (6 digit) to <a href="{2}">confirm your email</a>. Can't find the letter? Check your SPAM folder or <a href="{0}">send the verification letter again</a>.]]></string>
     <string name="_sys_txt_account_unconfirmed_phone"><![CDATA[Your phone is unconfirmed. Please, confirm it on <a href="{0}">verification page</a>.]]></string>
-    <string name="_sys_txt_account_profile_system"><![CDATA[Please <a href="{0}">create a profile</a> to start using the site.]]></string>
+    <string name="_sys_txt_account_profile_system"><![CDATA[Your account doesn't have a profile yet. Create one to start using the site.]]></string>
+    <string name="_sys_txt_account_profile_system_action"><![CDATA[Create profile]]></string>
     <string name="_sys_txt_account_need_to_change_password"><![CDATA[Your password has been expired. Please <a href="{0}">change your password</a>.]]></string>
     <string name="_sys_txt_account_cannot_delete"><![CDATA[Cannot delete the only Operator account]]></string>
     <string name="_sys_txt_account_deletion_scheduled"><![CDATA[Account deletion was scheduled and would be done in a few minutes.]]></string>

+ 3 - 0
modules/boonex/ocean/data/template/system/informer.html

@@ -3,6 +3,9 @@
         <bx_repeat:messages>
             <div id="__id__" class="bx-informer-msg __class__ bx-def-padding bx-def-round-corners bx-def-margin-top">
                 __msg__
+                <bx_if:action>
+                    <a class="bx-informer-action bx-btn bx-btn-small bx-def-margin-sec-left" href="__action_url__">__action_title__</a>
+                </bx_if:action>
             </div>
         </bx_repeat:messages>
     </div>

+ 2 - 1
modules/boonex/russian/data/langs/system/ru.xml

@@ -4194,7 +4194,8 @@
     <string name="_sys_txt_account_suspended"><![CDATA[Ваш аккаунт заблокирован.]]></string>
     <string name="_sys_txt_account_unconfirmed_email"><![CDATA[Ваш email не подтверждён. Пожалуйста, проверьте свой эл. ящик ({1}) на наличие письма подтверждения и перейдите по ссылке в нём или используйте код подтверждения (6 цифр), чтобы <a href="{2}">подтвердить свой адрес эл. почты</a>. <br />Не можете найти письмо? Проверьте папку "спам" или <a href="{0}">снова запросите письмо верификации</a>.]]></string>
     <string name="_sys_txt_account_unconfirmed_phone"><![CDATA[Ваш телефон не подтверждён. Пожалуйста, подтвердите его на  <a href="{0}">странице верификации</a>.]]></string>
-    <string name="_sys_txt_account_profile_system"><![CDATA[Пожалуйста, <a href="{0}">создайте профиль</a>, чтобы начать пользоваться сайтом.]]></string>
+    <string name="_sys_txt_account_profile_system"><![CDATA[У вашего аккаунта ещё нет профиля. Создайте его, чтобы начать пользоваться сайтом.]]></string>
+    <string name="_sys_txt_account_profile_system_action"><![CDATA[Создать профиль]]></string>
     <string name="_sys_txt_account_need_to_change_password"><![CDATA[Ваш пароль устарел. Пожалуйста <a href="{0}">измените Ваш пароль</a>.]]></string>
     <string name="_sys_txt_account_cannot_delete"><![CDATA[Невозможно удалить единственную учетную запись оператора.]]></string>
     <string name="_sys_txt_account_deletion_scheduled"><![CDATA[Удаление аккаунта запланировано и будет выполнено через несколько минут.]]></string>

+ 3 - 0
package.json

@@ -117,6 +117,7 @@
     "build:skrollr": "mkdir -p plugins_public/skrollr; cd plugins_public/skrollr; cp -r ../../node_modules/skrollr/dist/* .",
     "build:spin.js": "mkdir -p plugins_public/spin.js; cd plugins_public/spin.js; cp -r ../../node_modules/spin.js/spin.* .; grep -v 'export { Spinner };' spin.js > tmp && mv tmp spin.js",
     "build:stackedit.js": "mkdir -p plugins_public/stackedit.js; cd plugins_public/stackedit.js; cp -r ../../node_modules/stackedit-js/docs/lib/* .",
+    "build:lucide": "mkdir -p plugins_public/lucide/icons; cp node_modules/lucide-static/icons/*.svg plugins_public/lucide/icons; cp node_modules/lucide/dist/umd/lucide.min.js plugins_public/lucide",
     "build:tailwind-max": "NODE_ENV=production tailwindcss -i ./template/css/tailwind.css -c ./tailwind.config.full.js -o ./plugins_public/tailwind/css/tailwind.max.css --minify",
     "build:tailwind-min": "NODE_ENV=production tailwindcss -i ./template/css/tailwind.css -c ./tailwind.config.js -o ./plugins_public/tailwind/css/tailwind.min.css --minify",
     "build:theia-sticky-sidebar": "mkdir -p plugins_public/theia-sticky-sidebar; cd plugins_public/theia-sticky-sidebar; cp -r ../../node_modules/theia-sticky-sidebar/dist/* .",
@@ -165,6 +166,8 @@
     "@types/react": "^19.2.18",
     "@types/react-dom": "^19.2.5",
     "bower": "^1.8.14",
+    "lucide": "1.41.0",
+    "lucide-static": "1.41.0",
     "npm-run-all": "^4.1.5",
     "partial-json": "^0.1.7",
     "react": "^19.2.8",

+ 2 - 0
tailwind.config.js

@@ -79,6 +79,8 @@ module.exports = {
 
     'bx-informer-msg-info', 'bx-informer-msg-alert', 'bx-informer-msg-error',
 
+    'bx-btn-mini', 'bx-btn-small', 'bx-btn-large', 'bx-btn-xlarge',
+
     'sys-action-counter', 'sys-ac-only', 'sys-ac-link',
 
     'bx-vote-bls-submenu-cnt', 

+ 7 - 4
template/css/common.css

@@ -8,10 +8,13 @@
 
 a {
     text-decoration:none;
-    outline:none;
     color:#3e8685; /* 26.13.13: prev val #387877; */
 }
 
+a:focus:not(:focus-visible) {
+    outline: none;
+}
+
 a:hover {
     text-decoration:none;
 }
@@ -766,9 +769,9 @@ div.bx-popup-chain-item .bx-loading-ajax {
     }
 }
 
-button.bx-btn:focus,
-button.bx-btn:focus .bx-loading-ajax-btn {
-    outline: 0;	
+button.bx-btn:focus:not(:focus-visible),
+button.bx-btn:focus:not(:focus-visible) .bx-loading-ajax-btn {
+    outline: 0;
 }
 
 .bx-form-element-wrapper .bx-loading-ajax {

+ 1 - 15
template/css/informer.css

@@ -1,17 +1,3 @@
-
 .bx-informer-msg:first-child {
-    margin-top:0;
-}
-.bx-informer-icon {
-    display: none;
-    vertical-align: top;
-}
-.bx-informer-msg-info .bx-informer-icon-info {
-    display: inline-block;
-}
-.bx-informer-msg-alert .bx-informer-icon-alert {
-    display: inline-block;
-}
-.bx-informer-msg-error .bx-informer-icon-error {
-    display: inline-block;
+    margin-top: 0;
 }

+ 51 - 12
template/css/tailwind.css

@@ -697,7 +697,8 @@
         @apply hover:-translate-y-px;
     }
     .bx-btn-focus {
-        @apply focus:border-blue-500 dark:focus:border-blue-500 focus:border-opacity-70 focus:ring-2 focus:ring-blue-500 focus:ring-opacity-20 focus:outline-none;
+        @apply focus:outline-none;
+        @apply focus-visible:border-blue-500 dark:focus-visible:border-blue-500 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900;
     }
     
     .bx-btn-active {
@@ -707,10 +708,10 @@
         @apply active:translate-y-px;
     }
     .bx-btn {
-        @apply relative inline-flex items-center px-4 py-2;
+        @apply relative inline-flex items-center min-h-11 px-4 py-2 touch-manipulation;
         @apply bx-btn-shadow;
         @apply bx-btn-bg;
-        @apply bx-btn-border;
+        @apply bx-btn-border rounded-[10px];
         @apply bx-btn-text;
         @apply transform-gpu transition-all duration-150;
         @apply cursor-pointer;
@@ -753,8 +754,8 @@
     }
 
     .bx-btn.bx-btn-primary {
-        @apply bg-blue-600 hover:bg-blue-500 active:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600 dark:active:bg-blue-800;
-        @apply border border-blue-600 hover:border-blue-500 active:border-blue-800 dark:border-blue-600 dark:hover:border-blue-500 dark:active:border-blue-700;
+        @apply bg-blue-600 hover:bg-blue-700 active:bg-blue-800 dark:bg-blue-700 dark:hover:bg-blue-600 dark:active:bg-blue-800;
+        @apply border border-blue-600 hover:border-blue-700 active:border-blue-800 dark:border-blue-600 dark:hover:border-blue-500 dark:active:border-blue-700;
         @apply text-white hover:text-white active:text-white dark:text-white dark:hover:text-white dark:active:text-white;
     }
 
@@ -772,10 +773,24 @@
         @apply bg-gray-50 dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-400 hover:text-gray-400 dark:hover:text-gray-400 focus:ring-0 focus:ring-offset-0 shadow-none cursor-default !important;
     }
 
+    /* Button sizes (height / corner radius): mini 28px/6px with the hit area extended to 44px, small 36px/8px, default 44px/10px, large 52px/12px, xlarge 60px/14px. */
+    .bx-btn.bx-btn-mini {
+        @apply min-h-7 px-2.5 py-1 text-xs rounded-md;
+        @apply after:content-[''] after:absolute after:-inset-y-2 after:-inset-x-1;
+    }
     .bx-btn.bx-btn-small {
-        @apply py-1 text-xs;
+        @apply min-h-9 px-3 py-1 text-sm rounded-lg;
+    }
+    .bx-btn.bx-btn-large {
+        @apply min-h-[3.25rem] px-5 text-base rounded-xl;
+    }
+    .bx-btn.bx-btn-xlarge {
+        @apply min-h-[3.75rem] px-6 text-lg rounded-[14px];
     }
 
+    .bx-btn.bx-btn-mini i, 
+    .bx-btn.bx-btn-mini img,
+    .bx-btn.bx-btn-mini svg,
     .bx-btn.bx-btn-small i, 
     .bx-btn.bx-btn-small img,
     .bx-btn.bx-btn-small svg {
@@ -1237,17 +1252,41 @@
 
 
     /* Common elements: Informers */
-    .bx-informer-msg a {
-        @apply text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-600;
+    .bx-informer-msg {
+        @apply flex items-center gap-3 py-3 px-4 rounded-xl ring-1 ring-inset text-sm leading-5;
+    }
+    .bx-informer-indicator {
+        @apply flex-none flex items-center justify-center w-9 h-9 rounded-full;
+    }
+    .bx-informer-indicator .sys-icon {
+        @apply w-5 h-5 leading-5 text-xl text-center;
+    }
+    .bx-informer-content {
+        @apply flex-1 min-w-0 font-medium;
+    }
+    .bx-informer-content a:not(.bx-btn) {
+        @apply font-semibold underline underline-offset-2 hover:opacity-80;
+    }
+    .bx-informer-action {
+        @apply flex-none whitespace-nowrap;
     }
     .bx-informer-msg-info {
-        @apply bg-blue-50 text-blue-700;
+        @apply bg-blue-50 text-blue-800 ring-blue-200 dark:bg-blue-500/10 dark:text-blue-200 dark:ring-blue-500/25;
+    }
+    .bx-informer-msg-info .bx-informer-indicator {
+        @apply bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300;
     }
     .bx-informer-msg-alert {
-        @apply bg-yellow-50 text-yellow-700;
+        @apply bg-yellow-50 text-yellow-800 ring-yellow-200 dark:bg-yellow-500/10 dark:text-yellow-200 dark:ring-yellow-500/25;
+    }
+    .bx-informer-msg-alert .bx-informer-indicator {
+        @apply bg-yellow-100 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-300;
     }
     .bx-informer-msg-error {
-        @apply bg-red-50 text-red-700;
+        @apply bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/25;
+    }
+    .bx-informer-msg-error .bx-informer-indicator {
+        @apply bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300;
     }
 
 
@@ -1450,7 +1489,7 @@
     
     .bx-std .bx-btn {
         @apply inline-flex items-center;
-        @apply h-10;
+        @apply min-h-11;
     }
 
     .bx-std .bx-btn:not(.bx-btn-primary) {

+ 10 - 14
template/informer.html

@@ -1,20 +1,16 @@
 <div class="bx-def-page-width mx-auto px-4 pt-4 box-border">
-    <div id="bx-informer">
+    <div id="bx-informer" class="space-y-3">
         <bx_repeat:messages>
-            <div id="__id__" class="bx-informer-msg __class__ flex items-center mt-4 first:mt-0 p-4 rounded-md">
-                <div class="flex-none w-5 h-5">
-                    <svg class="bx-informer-icon bx-informer-icon-info text-blue-400 h-5 w-5" x-description="Heroicon name: solid/information-circle" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
-                        <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
-                    </svg>
-                    <svg class="bx-informer-icon bx-informer-icon-alert h-5 w-5 text-yellow-400" x-description="Heroicon name: solid/exclamation" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
-                        <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
-                    </svg>
-                    <svg class="bx-informer-icon bx-informer-icon-error h-5 w-5 text-red-400" x-description="Heroicon name: solid/x-circle" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
-                        <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
-                    </svg>
+            <div id="__id__" class="bx-informer-msg __class__">
+                <div class="bx-informer-indicator" aria-hidden="true">
+                    <bx_if:icon_html>__icon_html__</bx_if:icon_html>
+                    <bx_if:icon_font><i class="sys-icon __icon__"></i></bx_if:icon_font>
                 </div>
-                <div class="ml-3 text-sm">__msg__</div>
+                <div class="bx-informer-content">__msg__</div>
+                <bx_if:action>
+                    <a class="bx-informer-action bx-btn bx-btn-small bx-btn-primary" href="__action_url__">__action_title__</a>
+                </bx_if:action>
             </div>
         </bx_repeat:messages>
     </div>
-</div>
+</div>

+ 11 - 0
template/scripts/BxBaseIconset.php

@@ -40,6 +40,17 @@ class BxBaseIconset extends BxDolIconset
         return $sIcon;
     }
 
+    /**
+     * Get icon as ready to use HTML code (e.g. inline SVG) which doesn't depend on the iconset's CSS/JS being loaded on the page.
+     * @param $sIcon - icon name
+     * @param $aAttrs - additional HTML attributes, e.g. `class`
+     * @return string|false - HTML code or false if the iconset doesn't support it and a font icon (`<i class="sys-icon ...">`) should be used instead.
+     */
+    public function getIconHtml($sIcon, $aAttrs = [])
+    {
+        return false;
+    }
+
     public function getCode()
     {
         return false;

+ 64 - 15
template/scripts/BxBaseIconsetLucide.php

@@ -13,7 +13,10 @@
  */
 class BxBaseIconsetLucide extends BxBaseIconset
 {
+    const VERSION = '1.41.0'; ///< Lucide version used when the local copy (see `build:lucide` in package.json) isn't available.
+
     protected $_aMap;
+    protected static $_aCacheSvg = [];
 
     public function __construct ($aObject, $oTemplate)
     {
@@ -41,8 +44,8 @@ class BxBaseIconsetLucide extends BxBaseIconset
             'cart-plus' => 'shopping-cart',
             'cc-stripe' => 'credit-card',
             'certificate' => 'award',
-            'chart-pie' => 'pie-chart',
-            'check-circle' => 'check-circle',
+            'chart-pie' => 'chart-pie',
+            'check-circle' => 'circle-check',
             'check-double' => 'check-check',
             'clipboard-check' => 'clipboard-check',
             'cog' => 'settings',
@@ -52,14 +55,14 @@ class BxBaseIconsetLucide extends BxBaseIconset
             'comments' => 'messages-square',
             'desktop' => 'monitor',
             'donate' => 'hand-coins',
-            'ellipsis-h' => 'more-horizontal',
-            'ellipsis-v' => 'more-vertical',
+            'ellipsis-h' => 'ellipsis',
+            'ellipsis-v' => 'ellipsis-vertical',
             'envelope' => 'mail',
             'envelope-open' => 'mail-open',
             'envelope-open-text' => 'mail-open',
             'exchange-alt' => 'repeat',
-            'exclamation-circle' => 'alert-circle',
-            'exclamation-triangle' => 'alert-triangle',
+            'exclamation-circle' => 'circle-alert',
+            'exclamation-triangle' => 'triangle-alert',
             'fa-book' => 'book-text',
             'fa-bookmark' => 'bookmark',
             'fa-certificate' => 'badge',
@@ -79,15 +82,15 @@ class BxBaseIconsetLucide extends BxBaseIconset
             'google' => 'log-in', //TODO: Brand icon. Update later.
             'hand-holding-usd' => 'hand-coins',
             'hashtag' => 'hash',
-            'helpcircle' => 'help-circle',
-            'house' => 'home',
+            'helpcircle' => 'circle-question-mark',
+            'house' => 'house',
             'industry' => 'factory',
             'info-circle' => 'info',
             'keyround' => 'key-round',
             'language' => 'languages',
             'list-alt' => 'list',
             'linkedin-in' => 'log-in', //TODO: Brand icon. Update later.
-            'lockopen' => 'unlock',
+            'lockopen' => 'lock-open',
             'mail-bulk' => 'mails',
             'map-marker' => 'map-pin',
             'map-marker-alt' => 'map-pin',
@@ -96,7 +99,7 @@ class BxBaseIconsetLucide extends BxBaseIconset
             'pencil-alt' => 'pencil-line',
             'pencil-ruler' => 'ruler',
             'photo-video' => 'image-play',
-            'plus-circle' => 'plus-circle',
+            'plus-circle' => 'circle-plus',
             'qrcode' => 'qr-code',
             'question' => 'circle-question-mark',
             'question-circle' => 'circle-question-mark',
@@ -114,15 +117,15 @@ class BxBaseIconsetLucide extends BxBaseIconset
             'sync' => 'refresh-ccw',
             'sync-alt' => 'refresh-ccw',
             'tachometer-alt' => 'gauge',
-            'tasks' => 'check-square',
+            'tasks' => 'square-check',
             'th-large' => 'grid-2x2',
             'thumbtack' => 'pin',
             'times' => 'x',
-            'times-circle' => 'x-circle',
+            'times-circle' => 'circle-x',
             'toolbox' => 'tool-case',
             'trash2' => 'trash-2',
             'twitter' => 'log-in', //TODO: Brand icon. Update later.
-            'unlock-alt' => 'unlock',
+            'unlock-alt' => 'lock-open',
             'user' => 'user-round',
             'user-friends' => 'users',
             'user-plus' => 'user-round-plus',
@@ -142,16 +145,62 @@ class BxBaseIconsetLucide extends BxBaseIconset
 
     public function getPreloaderJs()
     {
-        return 'https://unpkg.com/lucide@latest';
+        if(file_exists(BX_DIRECTORY_PATH_PLUGINS_PUBLIC . 'lucide/lucide.min.js'))
+            return '{dir_plugins_public}lucide/|lucide.min.js';
+
+        return 'https://unpkg.com/lucide@' . self::VERSION;
     }
 
     public function getIcon($sIcon)
+    {
+        return bx_gen_method_name($this->_getIconName($sIcon), ['_', '-']);
+    }
+
+    public function getIconHtml($sIcon, $aAttrs = [])
+    {
+        $sName = $this->_getIconName($sIcon);
+        if(!preg_match('/^[a-z0-9-]+$/', $sName))
+            return false;
+
+        if(!isset(self::$_aCacheSvg[$sName])) {
+            $sPath = BX_DIRECTORY_PATH_PLUGINS_PUBLIC . 'lucide/icons/' . $sName . '.svg';
+
+            $sSvg = file_exists($sPath) ? file_get_contents($sPath) : false;
+            if($sSvg !== false) {
+                $sSvg = preg_replace('/<!--.*?-->/s', '', $sSvg);
+                $sSvg = trim(preg_replace('/\s+/', ' ', $sSvg));
+                $sSvg = str_replace(' />', '/>', $sSvg);
+            }
+
+            self::$_aCacheSvg[$sName] = $sSvg;
+        }
+
+        $sSvg = self::$_aCacheSvg[$sName];
+        if($sSvg === false)
+            return false;
+
+        $sClass = 'sys-icon';
+        if(!empty($aAttrs['class']))
+            $sClass .= ' ' . $aAttrs['class'];
+        unset($aAttrs['class']);
+
+        $sAttrs = '';
+        foreach($aAttrs as $sKey => $sValue)
+            $sAttrs .= ' ' . $sKey . '="' . bx_html_attribute($sValue) . '"';
+
+        return preg_replace('/^<svg class="/', '<svg' . $sAttrs . ' class="' . bx_html_attribute($sClass) . ' ', $sSvg, 1);
+    }
+
+    /**
+     * Get Lucide icon name (kebab-case) by Font Awesome or Lucide icon name.
+     */
+    protected function _getIconName($sIcon)
     {
         $sIcon = trim(preg_replace('/(sys-icon|far|col-\w+)/i', '', $sIcon));
         if(isset($this->_aMap[$sIcon]))
             $sIcon = $this->_aMap[$sIcon];
 
-        return bx_gen_method_name($sIcon, ['_', '-']);
+        return $sIcon;
     }
 
     public function getCode()

+ 55 - 12
template/scripts/BxBaseInformer.php

@@ -17,11 +17,23 @@ class BxBaseInformer extends BxDolInformer
 
     protected $_oTemplate;
 
-    protected $_aMapType2Class = array(
+    protected $_aMapType2Class = [
         BX_INFORMER_ALERT => 'bx-informer-msg-alert',
         BX_INFORMER_INFO => 'bx-informer-msg-info',
         BX_INFORMER_ERROR => 'bx-informer-msg-error',
-    );
+    ];
+
+    protected $_aMapType2Icon = [
+        BX_INFORMER_ALERT => 'exclamation-triangle',
+        BX_INFORMER_INFO => 'info-circle',
+        BX_INFORMER_ERROR => 'times-circle',
+    ];
+
+    /**
+     * Iconset used to render indicator icons as inline SVG regardless of the site's default iconset.
+     * When it isn't available (or has no such icon) a font icon of the default iconset is used.
+     */
+    protected $_sIconset = 'sys_lucide';
 
     public function __construct ($oTemplate)
     {
@@ -44,20 +56,51 @@ class BxBaseInformer extends BxDolInformer
         if (!$this->_aMessages)
             return '';
 
-        $aTmplVarsMessages = [];
-        foreach ($this->_aMessages as $sId => $a) {
-            $a['class'] = $this->_aMapType2Class[$a['type']];
+        $oIconset = BxDolIconset::getObjectInstance($this->_sIconset, $this->_oTemplate);
 
-            $aTmplVarsMessages[] = $a;
-        }
-        
-        if (bx_is_api()){
-            return $aTmplVarsMessages;
-        }
+        $aMessages = [];
+        foreach ($this->_aMessages as $a)
+            $aMessages[] = array_merge($a, [
+                'class' => $this->_aMapType2Class[$a['type']],
+                'icon' => !empty($a['icon']) ? $a['icon'] : $this->_aMapType2Icon[$a['type']],
+            ]);
+
+        if (bx_is_api())
+            return array_map(function($a) use($oIconset) {
+                // API clients (NEO) render Lucide icons natively, so send the Lucide name.
+                return array_merge($a, [
+                    'icon' => $oIconset ? $oIconset->getIcon($a['icon']) : $a['icon'],
+                ]);
+            }, $aMessages);
 
         $this->_addJsCss();
+
         return $this->_oTemplate->parseHtmlByName('informer.html', [
-            'bx_repeat:messages' => $aTmplVarsMessages
+            'bx_repeat:messages' => array_map(function($a) use($oIconset) {
+                $sIconHtml = $oIconset ? $oIconset->getIconHtml($a['icon']) : false;
+
+                return array_merge($a, [
+                    'bx_if:icon_html' => [
+                        'condition' => $sIconHtml !== false,
+                        'content' => [
+                            'icon_html' => $sIconHtml,
+                        ],
+                    ],
+                    'bx_if:icon_font' => [
+                        'condition' => $sIconHtml === false,
+                        'content' => [
+                            'icon' => $a['icon'],
+                        ],
+                    ],
+                    'bx_if:action' => [
+                        'condition' => !empty($a['action_url']) && !empty($a['action_title']),
+                        'content' => [
+                            'action_url' => bx_html_attribute($a['action_url']),
+                            'action_title' => $a['action_title'],
+                        ],
+                    ],
+                ]);
+            }, $aMessages),
         ]);
     }
 

+ 10 - 2
yarn.lock

@@ -2125,7 +2125,6 @@ headroom.js@^0.12.0:
 
 "highlight.js@github:highlightjs/highlight.js":
   version "11.12.0"
-  uid fc3f06392f189354eed922973635ab9e9268b983
   resolved "https://codeload.github.com/highlightjs/highlight.js/tar.gz/fc3f06392f189354eed922973635ab9e9268b983"
 
 hls.js@^1.7.1:
@@ -2634,7 +2633,6 @@ jstree@^3.3.17:
 
 "jush@https://github.com/vrana/jush":
   version "0.0.0"
-  uid e89a152b995b33b2f308078c3dee7bee506711e3
   resolved "https://github.com/vrana/jush#e89a152b995b33b2f308078c3dee7bee506711e3"
 
 just-diff-apply@^5.2.0:
@@ -2816,6 +2814,16 @@ lru-cache@^11.0.0, lru-cache@^11.1.0, lru-cache@^11.2.1:
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.5.2.tgz#00e16665c90c620fba14a3c368732a976493f760"
   integrity sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==
 
+lucide-static@1.41.0:
+  version "1.41.0"
+  resolved "https://registry.yarnpkg.com/lucide-static/-/lucide-static-1.41.0.tgz#56c437537404a446883ff611301ef2442835bece"
+  integrity sha512-39fX7SH+Rwis0oUmLLOipOoFSiJll9yi2DyEGDaE7Sp0qQAEhEfMQ2scQNdWKeGVENGv1uXc5ZeZqBWsuhQSFg==
+
+lucide@1.41.0:
+  version "1.41.0"
+  resolved "https://registry.yarnpkg.com/lucide/-/lucide-1.41.0.tgz#e2336f8764b5b1bbfe62e7f621c7aaa81fc1c3fd"
+  integrity sha512-fIXP7JzC1vehdqOrMhwpbcXzX6CvUMuoDf16HY1FmC4sDBeuwrH2A0c6QmLn3wnSvaMchpEyM+JWijyTvOs3kw==
+
 magic-string@^0.30.3:
   version "0.30.21"
   resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"