This is an automated email from the ASF dual-hosted git repository.

imbajin pushed a commit to branch feat/oink-core-platform
in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git

commit 61e20f4555b066e43ef1f99a82a0836450c6d80e
Author: dark <[email protected]>
AuthorDate: Fri Sep 4 19:02:06 2026 +0800

    feat(ui): add oink shell and ai extensions
    
    - add five-group docs navigation and explicit sidebar recovery\n- isolate 
and persist sidebar state by version and locale\n- add click-gated Kapa adapter 
with strict privacy defaults\n- enable OINK content helpers and branded social 
fallback\n- cover UI and AI contracts with dependency-free Node tests
---
 assets/js/hugegraph-shell.js            | 251 ++++++++++++++++++++++++++
 assets/js/kapa-adapter.js               | 310 ++++++++++++++++++++++++++++++++
 assets/scss/_styles_project.scss        | 154 +++++++++++++++-
 hugo.yaml                               |  23 +++
 layouts/_partials/ai/config.html        |  33 ++++
 layouts/_partials/hooks/body-end.html   |  41 +++++
 layouts/_partials/hooks/head-end.html   |   5 +
 layouts/_partials/navbar-item.html      |  16 +-
 layouts/_partials/navbar.html           |   7 +
 layouts/_partials/share/bar.html        |  13 ++
 static/img/social/hugegraph-default.png | Bin 0 -> 984595 bytes
 tests/ui-ai/kapa-adapter.test.cjs       | 147 +++++++++++++++
 tests/ui-ai/ui-contract.test.cjs        |  47 +++++
 13 files changed, 1027 insertions(+), 20 deletions(-)

diff --git a/assets/js/hugegraph-shell.js b/assets/js/hugegraph-shell.js
new file mode 100644
index 000000000..a66f6ec36
--- /dev/null
+++ b/assets/js/hugegraph-shell.js
@@ -0,0 +1,251 @@
+/**
+ * HugeGraph additions around OINK's shell.
+ *
+ * This file deliberately does not replace OINK's command palette. It only
+ * persists authored tree disclosures, makes a collapsed/dismissed sidebar
+ * inert, and adds an explicit retry control to the existing search error.
+ */
+(function (global) {
+  'use strict';
+
+  function readConfig(documentObject) {
+    var node = documentObject.getElementById('hg-shell-config');
+    if (!node) return { version: 'latest', locale: 'en' };
+    try {
+      return JSON.parse(node.textContent || '{}');
+    } catch (_) {
+      return { version: 'latest', locale: 'en' };
+    }
+  }
+
+  function safeStorage(windowObject) {
+    try {
+      var storage = windowObject.localStorage;
+      var probe = '__hg_sidebar_probe__';
+      storage.setItem(probe, '1');
+      storage.removeItem(probe);
+      return storage;
+    } catch (_) {
+      return null;
+    }
+  }
+
+  function setTreeExpanded(button, expanded, documentObject) {
+    var target = documentObject.getElementById(
+      button.getAttribute('aria-controls'),
+    );
+    if (!target) return;
+    button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
+    target.classList.toggle('td-is-open', expanded);
+    var label = expanded
+      ? button.dataset.tdLabelCollapse
+      : button.dataset.tdLabelExpand;
+    if (label) button.setAttribute('aria-label', label);
+  }
+
+  function initTreePersistence(windowObject, documentObject, config) {
+    var buttons = Array.prototype.slice.call(
+      
documentObject.querySelectorAll('[data-td-shell-tree-toggle][aria-controls]'),
+    );
+    if (!buttons.length) return;
+    var storage = safeStorage(windowObject);
+    var key =
+      'oink.sidebar.v1.' +
+      String(config.version || 'latest') +
+      '.' +
+      String(config.locale || 'en');
+    var valid = new Set(
+      buttons.map(function (button) {
+        return button.getAttribute('aria-controls');
+      }),
+    );
+    var saved = [];
+    if (storage) {
+      try {
+        var parsed = JSON.parse(storage.getItem(key) || '[]');
+        if (Array.isArray(parsed)) {
+          saved = parsed.filter(function (id) {
+            return typeof id === 'string' && valid.has(id);
+          });
+        }
+      } catch (_) {
+        saved = [];
+      }
+    }
+    var remembered = new Set(saved);
+
+    buttons.forEach(function (button) {
+      var item = button.closest('li');
+      var activePath = item && item.classList.contains('td-active-path');
+      setTreeExpanded(
+        button,
+        Boolean(activePath || 
remembered.has(button.getAttribute('aria-controls'))),
+        documentObject,
+      );
+      button.addEventListener('click', function () {
+        global.queueMicrotask(function () {
+          if (!storage) return;
+          var expanded = buttons
+            .filter(function (candidate) {
+              var candidateItem = candidate.closest('li');
+              return (
+                candidate.getAttribute('aria-expanded') === 'true' &&
+                !(candidateItem &&
+                  candidateItem.classList.contains('td-active-path'))
+              );
+            })
+            .map(function (candidate) {
+              return candidate.getAttribute('aria-controls');
+            });
+          try {
+            storage.setItem(key, JSON.stringify(expanded));
+          } catch (_) {
+            /* Active-path expansion remains the storage-free fallback. */
+          }
+        });
+      });
+    });
+
+    // Rewriting the filtered set removes stale node IDs after navigation
+    // changes without retaining a second schema/version marker.
+    if (storage) {
+      try {
+        storage.setItem(key, JSON.stringify(saved));
+      } catch (_) {
+        /* Ignore storage becoming unavailable after the probe. */
+      }
+    }
+  }
+
+  function initSidebarIsolation(windowObject, documentObject) {
+    var html = documentObject.documentElement;
+    var sidebar = documentObject.getElementById('td-shell-sidebar');
+    if (!sidebar) return;
+    var restore = documentObject.querySelector('.hg-sidebar-restore');
+    var desktop = windowObject.matchMedia('(min-width: 768px)');
+
+    function sync() {
+      var collapsed =
+        html.getAttribute('data-td-shell-sidebar') === 'collapsed';
+      var drawerOpen =
+        html.getAttribute('data-td-shell-drawer') === 'open';
+      var isolated = desktop.matches ? collapsed : !drawerOpen;
+      sidebar.inert = isolated;
+      if (isolated) sidebar.setAttribute('aria-hidden', 'true');
+      else sidebar.removeAttribute('aria-hidden');
+      if (
+        isolated &&
+        sidebar.contains(documentObject.activeElement) &&
+        restore &&
+        restore.offsetParent !== null
+      ) {
+        restore.focus();
+      }
+    }
+
+    new MutationObserver(sync).observe(html, {
+      attributes: true,
+      attributeFilter: ['data-td-shell-sidebar', 'data-td-shell-drawer'],
+    });
+    desktop.addEventListener('change', sync);
+    documentObject
+      .querySelectorAll('[data-td-shell-sidebar-toggle], 
[data-td-shell-drawer-close]')
+      .forEach(function (button) {
+        button.addEventListener('click', function () {
+          global.queueMicrotask(sync);
+        });
+      });
+    sync();
+  }
+
+  function initSearchRetry(windowObject, documentObject) {
+    var root = documentObject.getElementById('td-shell-search');
+    if (!root) return;
+    var list = root.querySelector('.td-shell-search__list');
+    var input = root.querySelector('.td-shell-search__input');
+    var status = root.querySelector('[data-td-shell-search-status]');
+    if (!list || !input || !status) return;
+    var scheduled = false;
+
+    function sync() {
+      scheduled = false;
+      var existing = list.querySelector('[data-hg-search-retry]');
+      if (existing) existing.remove();
+      var failure = root.dataset.tdTIndexUnavailable || '';
+      var failed =
+        failure &&
+        (status.textContent.trim() === failure ||
+          Array.prototype.some.call(
+            list.querySelectorAll('.td-shell-search__empty'),
+            function (node) {
+              return node.textContent.trim() === failure;
+            },
+          ));
+      if (!failed) return;
+
+      var notice = documentObject.createElement('div');
+      notice.className = 'hg-search-retry';
+      notice.dataset.hgSearchRetry = '';
+      var text = documentObject.createElement('span');
+      text.textContent = failure;
+      var button = documentObject.createElement('button');
+      button.type = 'button';
+      button.className = 'btn btn-sm btn-outline-primary';
+      button.textContent =
+        documentObject.documentElement.lang === 'cn' ||
+        documentObject.documentElement.lang.indexOf('zh') === 0
+          ? '重试'
+          : 'Retry';
+      button.addEventListener('click', function () {
+        input.dispatchEvent(new Event('input', { bubbles: true }));
+        input.focus();
+      });
+      notice.appendChild(text);
+      notice.appendChild(button);
+      list.appendChild(notice);
+    }
+
+    function schedule() {
+      if (scheduled) return;
+      scheduled = true;
+      global.requestAnimationFrame(sync);
+    }
+    new MutationObserver(schedule).observe(list, {
+      childList: true,
+      subtree: true,
+      characterData: true,
+    });
+    new MutationObserver(schedule).observe(status, {
+      childList: true,
+      subtree: true,
+      characterData: true,
+    });
+    schedule();
+  }
+
+  function init(windowObject, documentObject) {
+    var config = readConfig(documentObject);
+    initTreePersistence(windowObject, documentObject, config);
+    initSidebarIsolation(windowObject, documentObject);
+    initSearchRetry(windowObject, documentObject);
+  }
+
+  var api = {
+    init: init,
+    readConfig: readConfig,
+    safeStorage: safeStorage,
+    setTreeExpanded: setTreeExpanded,
+  };
+  global.HugeGraphShell = api;
+  if (typeof module === 'object' && module.exports) module.exports = api;
+
+  if (global.document) {
+    if (global.document.readyState === 'loading') {
+      global.document.addEventListener('DOMContentLoaded', function () {
+        init(global, global.document);
+      });
+    } else {
+      init(global, global.document);
+    }
+  }
+})(typeof window === 'object' ? window : globalThis);
diff --git a/assets/js/kapa-adapter.js b/assets/js/kapa-adapter.js
new file mode 100644
index 000000000..9cb1cede6
--- /dev/null
+++ b/assets/js/kapa-adapter.js
@@ -0,0 +1,310 @@
+/**
+ * Click-gated Kapa adapter for OINK.
+ *
+ * The third-party bundle URL and privacy posture are fixed here. The page
+ * supplies only reviewed public identifiers and localized labels.
+ */
+(function (global) {
+  'use strict';
+
+  var BUNDLE_URL = 'https://widget.kapa.ai/kapa-widget.bundle.js';
+  var TIMEOUT_MS = 5000;
+
+  function trimmedQuery(value) {
+    return String(value || '').trim();
+  }
+
+  function readConfig(documentObject) {
+    var node = documentObject.getElementById('hg-ai-config');
+    if (!node) return null;
+    try {
+      var config = JSON.parse(node.textContent || '{}');
+      return config.websiteId && config.sourceGroupId ? config : null;
+    } catch (_) {
+      return null;
+    }
+  }
+
+  function invokeKapa(windowObject, method, value) {
+    var api = windowObject.Kapa;
+    if (typeof api === 'function') return api(method, value);
+    if (api && typeof api[method] === 'function') return api[method](value);
+    throw new Error('Kapa API is unavailable');
+  }
+
+  function preinitialize(windowObject) {
+    if (windowObject.Kapa) return;
+    var queue = function () {
+      queue.c(arguments);
+    };
+    queue.q = [];
+    queue.c = function (args) {
+      queue.q.push(args);
+    };
+    windowObject.Kapa = queue;
+  }
+
+  function scriptAttributes(config) {
+    return {
+      'data-website-id': config.websiteId,
+      'data-source-group-ids-include': config.sourceGroupId,
+      'data-language': config.locale,
+      'data-project-name': 'Apache HugeGraph',
+      'data-project-color': '#532fc9',
+      'data-project-color-dark': '#9f83ff',
+      'data-surface-color': '#ffffff',
+      'data-surface-elevated-color': '#f6f4fb',
+      'data-surface-hover-color': '#eeeafd',
+      'data-text-color': '#24212d',
+      'data-text-muted-color': '#686275',
+      'data-border-color': '#d9d4e4',
+      'data-anchor-color': '#532fc9',
+      'data-surface-color-dark': '#17151d',
+      'data-surface-elevated-color-dark': '#221f2b',
+      'data-surface-hover-color-dark': '#302b3d',
+      'data-text-color-dark': '#f0edf7',
+      'data-text-muted-color-dark': '#b6afc2',
+      'data-border-color-dark': '#494254',
+      'data-anchor-color-dark': '#b6a3ff',
+      'data-color-scheme-selector': "[data-bs-theme='dark']",
+      'data-font-family':
+        '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, 
Arial, sans-serif',
+      'data-modal-content-border-radius': '12px',
+      'data-modal-content-border': '1px solid #d9d4e4',
+      'data-modal-content-border-dark': '1px solid #494254',
+      'data-launcher-button-hidden': 'true',
+      'data-render-on-load': 'false',
+      'data-search-mode-enabled': 'false',
+      'data-modal-open-on-command-k': 'false',
+      'data-consent-required': 'false',
+      'data-user-analytics-cookie-enabled': 'false',
+      'data-user-analytics-fingerprint-enabled': 'false',
+      'data-exit-feedback-enabled': 'false',
+      'data-user-satisfaction-feedback-enabled': 'false',
+      'data-bot-protection-mechanism': 'hcaptcha',
+    };
+  }
+
+  function createController(windowObject, documentObject, config) {
+    var state = 'idle';
+    var attempt = 0;
+    var timer = 0;
+    var lastTrigger = null;
+    var status = documentObject.querySelector('[data-hg-ai-status]');
+
+    function renderState(next, message) {
+      state = next;
+      documentObject.querySelectorAll('[data-hg-ask-ai]').forEach(function 
(button) {
+        button.dataset.hgAiState = next;
+        button.disabled = next === 'loading';
+        if (next === 'loading') button.setAttribute('aria-busy', 'true');
+        else button.removeAttribute('aria-busy');
+        if (message) button.title = message;
+      });
+      if (status) {
+        status.textContent = message || '';
+        status.classList.toggle('visually-hidden', !message);
+      }
+    }
+
+    function openWidget(query, submit) {
+      invokeKapa(windowObject, 'setSourceGroupIDs', [config.sourceGroupId]);
+      invokeKapa(windowObject, 'open', {
+        mode: 'ai',
+        query: query,
+        submit: submit,
+      });
+    }
+
+    function fail(serial) {
+      if (serial !== attempt || state !== 'loading') return;
+      windowObject.clearTimeout(timer);
+      renderState('error', config.labels.error);
+    }
+
+    function ready(serial, query, submit) {
+      if (serial !== attempt || state !== 'loading') return;
+      windowObject.clearTimeout(timer);
+      renderState('ready', '');
+      openWidget(query, submit);
+    }
+
+    function ensureScript(serial, query, submit) {
+      var script = documentObject.querySelector(
+        'script[data-hg-kapa-widget]',
+      );
+      if (!script) {
+        preinitialize(windowObject);
+        script = documentObject.createElement('script');
+        script.async = true;
+        script.src = BUNDLE_URL;
+        script.dataset.hgKapaWidget = '';
+        var attrs = scriptAttributes(config);
+        Object.keys(attrs).forEach(function (name) {
+          script.setAttribute(name, attrs[name]);
+        });
+        script.addEventListener('error', function () {
+          script.remove();
+          fail(serial);
+        }, { once: true });
+        documentObject.head.appendChild(script);
+      }
+      try {
+        invokeKapa(windowObject, 'render', {
+          onRender: function () {
+            ready(serial, query, submit);
+          },
+        });
+      } catch (_) {
+        fail(serial);
+      }
+    }
+
+    function activate(query, submit, trigger) {
+      query = trimmedQuery(query);
+      lastTrigger = trigger || documentObject.activeElement;
+      if (state === 'loading') return;
+      if (state === 'ready') {
+        openWidget(query, Boolean(submit && query));
+        return;
+      }
+      var serial = ++attempt;
+      renderState('loading', '');
+      timer = windowObject.setTimeout(function () {
+        fail(serial);
+      }, TIMEOUT_MS);
+      ensureScript(serial, query, Boolean(submit && query));
+    }
+
+    preinitialize(windowObject);
+    invokeKapa(windowObject, 'onModalClose', function () {
+      if (lastTrigger && typeof lastTrigger.focus === 'function') {
+        lastTrigger.focus();
+      }
+    });
+
+    return {
+      activate: activate,
+      getState: function () { return state; },
+    };
+  }
+
+  function init(windowObject, documentObject) {
+    var config = readConfig(documentObject);
+    if (!config) return null;
+    var controller = createController(windowObject, documentObject, config);
+    var root = documentObject.getElementById('td-shell-search');
+    var input = root && root.querySelector('.td-shell-search__input');
+    var list = root && root.querySelector('.td-shell-search__list');
+    var syncing = false;
+
+    function bind(button) {
+      if (button.dataset.hgAiBound !== undefined) return;
+      button.dataset.hgAiBound = '';
+      button.addEventListener('click', function () {
+        controller.activate(
+          button.dataset.hgAiQuery || '',
+          button.dataset.hgAiSubmit === 'true',
+          button,
+        );
+      });
+    }
+    documentObject.querySelectorAll('[data-hg-ask-ai]').forEach(bind);
+
+    function syncTail() {
+      syncing = false;
+      if (!root || !input || !list || root.hidden) return;
+      var old = list.querySelector('[data-hg-ai-search-tail]');
+      if (old) old.remove();
+      var query = trimmedQuery(input.value);
+      if (!query || query.charAt(0) === '>') return;
+      var choiceLabel = root.dataset.tdTChoice || '';
+      if (
+        choiceLabel &&
+        Array.prototype.some.call(
+          list.querySelectorAll('.td-shell-search__group-label'),
+          function (label) { return label.textContent.trim() === choiceLabel; 
},
+        )
+      ) return;
+      var loading = root.dataset.tdTLoading || '';
+      if (
+        loading &&
+        Array.prototype.some.call(
+          list.querySelectorAll('.td-shell-search__empty'),
+          function (node) { return node.textContent.trim() === loading; },
+        )
+      ) return;
+
+      var group = documentObject.createElement('div');
+      group.className = 'td-shell-search__group hg-ai-search-tail';
+      group.dataset.hgAiSearchTail = '';
+      group.setAttribute('role', 'group');
+      var label = documentObject.createElement('div');
+      label.className = 'td-shell-search__group-label';
+      label.textContent = config.labels.ask;
+      var row = documentObject.createElement('button');
+      row.type = 'button';
+      row.className = 'td-shell-search__item hg-ai-search-tail__button';
+      row.dataset.hgAskAi = '';
+      row.dataset.hgAiQuery = query;
+      row.dataset.hgAiSubmit = 'true';
+      var icon = documentObject.createElement('i');
+      icon.className =
+        'fa-solid fa-wand-magic-sparkles td-shell-search__item-icon';
+      icon.setAttribute('aria-hidden', 'true');
+      var meta = documentObject.createElement('span');
+      meta.className = 'td-shell-search__item-meta';
+      var title = documentObject.createElement('span');
+      title.className = 'td-shell-search__item-title';
+      title.textContent = config.labels.ask + ': “' + query + '”';
+      var detail = documentObject.createElement('span');
+      detail.className = 'td-shell-search__item-ref';
+      detail.textContent =
+        config.labels.description +
+        (config.historical ? ' ' + config.labels.latest + '.' : '');
+      meta.appendChild(title);
+      meta.appendChild(detail);
+      row.appendChild(icon);
+      row.appendChild(meta);
+      group.appendChild(label);
+      group.appendChild(row);
+      list.appendChild(group);
+      bind(row);
+    }
+
+    if (list) {
+      new MutationObserver(function () {
+        if (syncing) return;
+        syncing = true;
+        windowObject.requestAnimationFrame(syncTail);
+      }).observe(list, { childList: true, subtree: true });
+      input.addEventListener('input', syncTail);
+      syncTail();
+    }
+    return controller;
+  }
+
+  var api = {
+    BUNDLE_URL: BUNDLE_URL,
+    TIMEOUT_MS: TIMEOUT_MS,
+    createController: createController,
+    init: init,
+    invokeKapa: invokeKapa,
+    preinitialize: preinitialize,
+    readConfig: readConfig,
+    scriptAttributes: scriptAttributes,
+    trimmedQuery: trimmedQuery,
+  };
+  global.HugeGraphKapa = api;
+  if (typeof module === 'object' && module.exports) module.exports = api;
+
+  if (global.document) {
+    if (global.document.readyState === 'loading') {
+      global.document.addEventListener('DOMContentLoaded', function () {
+        init(global, global.document);
+      });
+    } else {
+      init(global, global.document);
+    }
+  }
+})(typeof window === 'object' ? window : globalThis);
diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss
index e0941c5d5..19330f863 100644
--- a/assets/scss/_styles_project.scss
+++ b/assets/scss/_styles_project.scss
@@ -1,6 +1,28 @@
 // HugeGraph homepage: preserve the original product copy and isometric brand
 // artwork while using OINK's accessible navigation and landing primitives.
-$hg-navbar-purple: #532fc9;
+// `params.ui.theme_color` publishes the project accent as this CSS custom
+// property in hooks/head-end.html.
+
+:root {
+  --hg-theme-color-soft: color-mix(
+    in srgb,
+    var(--hg-theme-color) 12%,
+    var(--bs-body-bg)
+  );
+  --hg-theme-color-hover: color-mix(
+    in srgb,
+    var(--hg-theme-color) 18%,
+    var(--bs-body-bg)
+  );
+}
+
+:where(a, button, input, summary):focus-visible {
+  outline-color: var(--hg-theme-color);
+}
+
+::selection {
+  background: var(--hg-theme-color-soft);
+}
 
 .td-home {
   // Keep OINK's display face for headings, but restore the original site's
@@ -39,7 +61,7 @@ $hg-navbar-purple: #532fc9;
   }
 
   &.td-scrolled {
-    background: rgba($hg-navbar-purple, 0.96);
+    background: color-mix(in srgb, var(--hg-theme-color) 96%, transparent);
     box-shadow: 0 6px 22px rgba(20, 17, 73, 0.24);
     -webkit-backdrop-filter: blur(10px);
     backdrop-filter: blur(10px);
@@ -248,7 +270,7 @@ $hg-navbar-purple: #532fc9;
 // OINK's sticky header, search, version, language, theme, and help controls.
 .td-shell-chrome .td-site-header {
   border-block-end-color: rgba(255, 255, 255, 0.22);
-  background: $hg-navbar-purple;
+  background: var(--hg-theme-color);
   color: #fff;
   -webkit-backdrop-filter: none;
   backdrop-filter: none;
@@ -310,9 +332,9 @@ $hg-navbar-purple: #532fc9;
     margin: calc(-1 * #{$spacer}) calc(-1 * #{$spacer}) 0;
     padding-inline: $spacer;
     border-block-end: 1px solid rgba(255, 255, 255, 0.22);
-    background: $hg-navbar-purple;
+    background: var(--hg-theme-color);
     color: #fff;
-    box-shadow: 1px 0 $hg-navbar-purple;
+    box-shadow: 1px 0 var(--hg-theme-color);
 
     .td-shell-wordmark {
       background: none;
@@ -465,7 +487,129 @@ $hg-navbar-purple: #532fc9;
   display: none;
 }
 
+.hg-sidebar-restore {
+  display: none;
+}
+
+@media (min-width: 768px) {
+  [data-td-shell-sidebar='collapsed'] {
+    #td-shell-sidebar {
+      visibility: hidden;
+      pointer-events: none;
+    }
+
+    // OINK v1.0 exposes a left-edge hover overlay after collapse. HugeGraph's
+    // explicit restore control replaces that hidden target entirely.
+    #td-shell-sidebar.td-shell-sidebar--overlay
+      .td-shell-sidebar__panel {
+      visibility: hidden;
+      transform: translateX(-100%);
+      pointer-events: none;
+    }
+
+    .hg-sidebar-restore {
+      display: inline-flex;
+    }
+  }
+}
+
+.hg-search-retry {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 0.75rem;
+  margin: 0.5rem 0.75rem 0.75rem;
+  padding: 0.7rem 0.8rem;
+  border: 1px solid var(--bs-border-color);
+  border-radius: 0.6rem;
+  background: var(--bs-tertiary-bg);
+  color: var(--bs-secondary-color);
+  font-size: 0.84rem;
+}
+
+.hg-ai-search-tail {
+  border-block-start: 1px solid var(--bs-border-color);
+
+  &__button {
+    width: 100%;
+    border: 0;
+    background: transparent;
+    color: inherit;
+    text-align: start;
+
+    &:hover,
+    &:focus-visible {
+      background: var(--hg-theme-color-soft);
+      color: inherit;
+    }
+  }
+}
+
+.hg-ask-ai-launcher {
+  position: fixed;
+  z-index: 1040;
+  inset-inline-end: max(1rem, env(safe-area-inset-right));
+  inset-block-end: max(1rem, env(safe-area-inset-bottom));
+  display: inline-flex;
+  align-items: center;
+  gap: 0.45rem;
+  min-height: 44px;
+  padding: 0.65rem 0.9rem;
+  border: 1px solid color-mix(in srgb, var(--hg-theme-color) 72%, #fff);
+  border-radius: 999px;
+  background: var(--hg-theme-color);
+  box-shadow: 0 8px 24px rgb(20 17 73 / 24%);
+  color: #fff;
+  font: 600 0.86rem/1 var(--td-ui-font-family);
+
+  &:hover,
+  &:focus-visible {
+    background: color-mix(in srgb, var(--hg-theme-color) 86%, #000);
+    color: #fff;
+  }
+
+  &[data-hg-ai-state='error'] {
+    border-style: dashed;
+  }
+
+  &[aria-busy='true'] {
+    cursor: wait;
+    opacity: 0.72;
+  }
+}
+
+.hg-ai-status:not(:empty) {
+  position: fixed;
+  z-index: 1039;
+  inset-inline-end: max(1rem, env(safe-area-inset-right));
+  inset-block-end: calc(max(1rem, env(safe-area-inset-bottom)) + 3.4rem);
+  width: min(18rem, calc(100vw - 2rem));
+  padding: 0.55rem 0.7rem;
+  border: 1px solid var(--bs-border-color);
+  border-radius: 0.55rem;
+  background: var(--bs-body-bg);
+  box-shadow: 0 6px 18px rgb(0 0 0 / 14%);
+  color: var(--bs-body-color);
+  font-size: 0.78rem;
+}
+
 @media (max-width: 767.98px) {
+  .td-shell-chrome .td-site-nav__menu-toggle {
+    border: 1px solid rgb(255 255 255 / 42%);
+    background: rgb(20 17 73 / 22%);
+    color: #fff;
+  }
+
+  .hg-ask-ai-launcher {
+    min-width: 44px;
+    min-height: 44px;
+    padding: 0.65rem;
+
+    span {
+      @include visually-hidden;
+    }
+  }
+
   .hg-shell-mobile-utils,
   .hg-landing-mobile-utils {
     display: grid;
diff --git a/hugo.yaml b/hugo.yaml
index 8b0afe361..febee991a 100644
--- a/hugo.yaml
+++ b/hugo.yaml
@@ -24,6 +24,11 @@ languages:
     menus:
       main:
         - { identifier: docs, name: Documentation, pageRef: /docs, weight: 10 }
+        - { identifier: docs-start, parent: docs, name: Get Started, pageRef: 
/docs/quickstart, weight: 11, params: { icon: 'fa-solid fa-rocket' } }
+        - { identifier: docs-components, parent: docs, name: Components, 
pageRef: /docs/quickstart/hugegraph, weight: 12, params: { icon: 'fa-solid 
fa-cubes' } }
+        - { identifier: docs-develop, parent: docs, name: Develop, pageRef: 
/docs/clients, weight: 13, params: { icon: 'fa-solid fa-code' } }
+        - { identifier: docs-operate, parent: docs, name: Operate, pageRef: 
/docs/config, weight: 14, params: { icon: 'fa-solid fa-screwdriver-wrench' } }
+        - { identifier: docs-reference, parent: docs, name: Reference, 
pageRef: /docs/changelog, weight: 15, params: { icon: 'fa-solid fa-book-open' } 
}
         - { identifier: download, name: Download, pageRef: 
/docs/download/download, weight: 20 }
         - { identifier: blog, name: Blog, pageRef: /blog, weight: 30 }
         - { identifier: community, name: Community, pageRef: /community, 
weight: 40 }
@@ -45,6 +50,11 @@ languages:
     menus:
       main:
         - { identifier: docs, name: 文档, pageRef: /docs, weight: 10 }
+        - { identifier: docs-start, parent: docs, name: 开始, pageRef: 
/docs/quickstart, weight: 11, params: { icon: 'fa-solid fa-rocket' } }
+        - { identifier: docs-components, parent: docs, name: 组件, pageRef: 
/docs/quickstart/hugegraph, weight: 12, params: { icon: 'fa-solid fa-cubes' } }
+        - { identifier: docs-develop, parent: docs, name: 开发, pageRef: 
/docs/clients, weight: 13, params: { icon: 'fa-solid fa-code' } }
+        - { identifier: docs-operate, parent: docs, name: 运维, pageRef: 
/docs/config, weight: 14, params: { icon: 'fa-solid fa-screwdriver-wrench' } }
+        - { identifier: docs-reference, parent: docs, name: 参考, pageRef: 
/docs/changelog, weight: 15, params: { icon: 'fa-solid fa-book-open' } }
         - { identifier: download, name: 下载, pageRef: /docs/download/download, 
weight: 20 }
         - { identifier: blog, name: 博客, pageRef: /blog, weight: 30 }
         - { identifier: community, name: 社区, pageRef: /community, weight: 40 }
@@ -86,6 +96,7 @@ outputs:
 
 params:
   description: Apache HugeGraph is a full-stack graph database ecosystem for 
OLTP, OLAP, and graph AI.
+  images: [/img/social/hugegraph-default.png]
   github_repo: https://github.com/apache/hugegraph-doc
   github_project_repo: https://github.com/apache/hugegraph
   github_branch: master
@@ -102,12 +113,22 @@ params:
   offline_search_index: summary
   offline_search_summary_length: 70
   offline_search_max_results: 10
+  # Optional enhancement: keep disabled until both reviewed latest-only Kapa
+  # source groups and the staging CSP/corpus acceptance gates pass.
+  ai_search:
+    enabled: false
+    provider: kapa
+    website_id: 0b277570-4740-451e-96fa-1e4ac1ac5e88
+    source_groups:
+      en: ''
+      cn: ''
   footer_center_info: ''
   # The compact ASF footer owns the legal line; global controls stay in header.
   copyright: false
   print:
     toc: true
   ui:
+    theme_color: '#532fc9'
     dark_mode:
       enable: true
       show_menu: true
@@ -120,6 +141,8 @@ params:
     wide_nav_sections: [community]
     sidebar_icon_policy: groups
     sidebar_item_overflow: wrap
+    backlinks: true
+    image_zoom: true
     page_context_menu:
       enable: true
       assistant_links: false
diff --git a/layouts/_partials/ai/config.html b/layouts/_partials/ai/config.html
new file mode 100644
index 000000000..4a0217381
--- /dev/null
+++ b/layouts/_partials/ai/config.html
@@ -0,0 +1,33 @@
+{{- $raw := .Site.Params.ai_search | default dict -}}
+{{- $enabled := false -}}
+{{- $provider := "" -}}
+{{- $websiteID := "" -}}
+{{- $sourceGroups := dict -}}
+{{- if reflect.IsMap $raw -}}
+  {{- $enabled = index $raw "enabled" | default false -}}
+  {{- if ne (printf "%T" $enabled) "bool" -}}
+    {{- errorf "params.ai_search.enabled must be a boolean" -}}
+  {{- end -}}
+  {{- $provider = index $raw "provider" | default "" -}}
+  {{- $websiteID = index $raw "website_id" | default "" -}}
+  {{- $sourceGroups = index $raw "source_groups" | default dict -}}
+{{- else -}}
+  {{- errorf "params.ai_search must be a map" -}}
+{{- end -}}
+{{- $enGroup := "" -}}
+{{- $cnGroup := "" -}}
+{{- if reflect.IsMap $sourceGroups -}}
+  {{- $enGroup = index $sourceGroups "en" | default "" -}}
+  {{- $cnGroup = index $sourceGroups "cn" | default "" -}}
+{{- end -}}
+{{- if $enabled -}}
+  {{- if ne $provider "kapa" }}{{ errorf "params.ai_search.provider must be 
kapa when AI search is enabled" }}{{ end -}}
+  {{- if not $websiteID }}{{ errorf "params.ai_search.website_id is required 
when AI search is enabled" }}{{ end -}}
+  {{- if or (not $enGroup) (not $cnGroup) }}{{ errorf 
"params.ai_search.source_groups.en and .cn are required when AI search is 
enabled" }}{{ end -}}
+{{- end -}}
+{{- return (dict
+  "enabled" $enabled
+  "provider" $provider
+  "websiteID" $websiteID
+  "sourceGroups" (dict "en" $enGroup "cn" $cnGroup)
+) -}}
diff --git a/layouts/_partials/hooks/body-end.html 
b/layouts/_partials/hooks/body-end.html
new file mode 100644
index 000000000..7866c8f25
--- /dev/null
+++ b/layouts/_partials/hooks/body-end.html
@@ -0,0 +1,41 @@
+{{- $shellConfig := dict
+  "version" (.Site.Params.version | default "latest")
+  "locale" .Site.Language.Lang
+-}}
+<script type="application/json" id="hg-shell-config">{{ $shellConfig | jsonify 
| safeJS }}</script>
+{{- $shell := resources.Get "js/hugegraph-shell.js" -}}
+{{- if hugo.IsProduction }}{{ $shell = $shell | minify | fingerprint }}{{ end 
}}
+<script src="{{ $shell.RelPermalink }}"
+  {{- with $shell.Data.Integrity }} integrity="{{ . }}" 
crossorigin="anonymous"{{ end }}></script>
+
+{{- $ai := partial "ai/config.html" . -}}
+{{- if $ai.enabled -}}
+  {{- $lang := .Site.Language.Lang -}}
+  {{- $sourceGroup := index $ai.sourceGroups $lang -}}
+  {{- $historical := ne (.Site.Params.version | default "latest") "latest" -}}
+  {{- $labels := cond (eq $lang "cn")
+      (dict "ask" "询问 AI" "description" "由 Kapa 提供;仅发送你的问题。" "latest" "回答基于 
latest 文档" "retry" "重试" "error" "AI 暂时不可用,本地搜索不受影响。")
+      (dict "ask" "Ask AI" "description" "Powered by Kapa; only your question 
is sent." "latest" "Answers use the latest documentation" "retry" "Retry" 
"error" "AI is temporarily unavailable. Local search is unaffected.")
+  -}}
+  {{- $clientConfig := dict
+    "websiteId" $ai.websiteID
+    "sourceGroupId" $sourceGroup
+    "locale" (cond (eq $lang "cn") "zh" "en")
+    "historical" $historical
+    "labels" $labels
+  -}}
+<script type="application/json" id="hg-ai-config">{{ $clientConfig | jsonify | 
safeJS }}</script>
+<button type="button" class="hg-ask-ai-launcher d-print-none" data-hg-ask-ai
+  data-hg-ai-submit="false" aria-describedby="hg-ai-disclosure">
+  <i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
+  <span>{{ index $labels "ask" }}</span>
+</button>
+<p class="visually-hidden" id="hg-ai-disclosure">
+  {{ index $labels "description" }}{{ if $historical }} {{ index $labels 
"latest" }}.{{ end }}
+</p>
+<div class="hg-ai-status visually-hidden" role="status" aria-live="polite" 
data-hg-ai-status></div>
+  {{- $adapter := resources.Get "js/kapa-adapter.js" -}}
+  {{- if hugo.IsProduction }}{{ $adapter = $adapter | minify | fingerprint 
}}{{ end }}
+<script src="{{ $adapter.RelPermalink }}"
+  {{- with $adapter.Data.Integrity }} integrity="{{ . }}" 
crossorigin="anonymous"{{ end }}></script>
+{{- end -}}
diff --git a/layouts/_partials/hooks/head-end.html 
b/layouts/_partials/hooks/head-end.html
new file mode 100644
index 000000000..43baeb1fb
--- /dev/null
+++ b/layouts/_partials/hooks/head-end.html
@@ -0,0 +1,5 @@
+{{- $themeColor := index .Site.Params.ui "theme_color" | default "" -}}
+{{- if not (findRE `^#[0-9a-fA-F]{6}$` $themeColor) -}}
+  {{- errorf "params.ui.theme_color must be a six-digit hexadecimal color" -}}
+{{- end }}
+<style>:root { --hg-theme-color: {{ $themeColor | safeCSS }}; }</style>
diff --git a/layouts/_partials/navbar-item.html 
b/layouts/_partials/navbar-item.html
index a3f2dec9d..d07dd32d2 100644
--- a/layouts/_partials/navbar-item.html
+++ b/layouts/_partials/navbar-item.html
@@ -6,7 +6,6 @@
 {{- $mode := .mode -}}
 {{- $index := .index -}}
 {{- $hasChildren := $entry.HasChildren -}}
-{{- $hasVersionLinks := and (eq $entry.Identifier "docs") (gt (len 
($page.Site.Params.versions | default slice)) 0) -}}
 {{- $taxonomyPage := false -}}
 {{- with $entry.Page -}}
   {{- if eq .Kind "taxonomy" }}{{ $taxonomyPage = . }}{{ end -}}
@@ -28,7 +27,7 @@
     {{- end -}}
   {{- end -}}
 {{- end -}}
-{{- $hasPanel := or $hasChildren $taxonomyPage $hasVersionLinks -}}
+{{- $hasPanel := or $hasChildren $taxonomyPage -}}
 {{- $key := $entry.Identifier | default $entry.Name | urlize -}}
 {{- $panelID := printf "td-navbar-%s-%s-%d" $mode $key $index -}}
 
@@ -53,19 +52,6 @@
       {{ partialCached "navbar-taxonomy-tags.html"
           (dict "page" $page "taxonomyPage" $taxonomyPage)
           $page.Site.Language.Lang $taxonomyPage.RelPermalink }}
-    {{- else if $hasVersionLinks -}}
-      {{- range $page.Site.Params.versions -}}
-        {{- $url := strings.TrimSuffix "/" (.url | default "") -}}
-        {{- if $url -}}
-          {{- $isActive := eq .version $page.Site.Params.version -}}
-          <a class="td-nav-menu__item-link{{ if $isActive }} active{{ end }}" 
href="{{ $url }}/"
-             data-td-navbar-level="1" data-td-navbar-kind="version"
-             {{- if $isActive }} aria-current="page"{{ end }}>
-            <i class="fa-solid fa-code-branch" aria-hidden="true"></i>
-            <span class="td-navbar-entry__label">{{ .name | default .version 
}}</span>
-          </a>
-        {{- end -}}
-      {{- end -}}
     {{- else -}}
       {{ partial "navbar-group-items.html" (dict "page" $page "items" 
$entry.Children "mode" "desktop" "top" $entry "depth" 1) }}
     {{- end }}
diff --git a/layouts/_partials/navbar.html b/layouts/_partials/navbar.html
index bfcfca10e..852b21ecf 100644
--- a/layouts/_partials/navbar.html
+++ b/layouts/_partials/navbar.html
@@ -53,6 +53,13 @@
       {{- /* Right zone: the search box leads as the elastic boundary before
            the fixed controls — version, language, theme, GitHub. */ -}}
       <div class="td-nav-links td-nav-util-zone">
+        {{- if $drawerMode }}
+        <button class="td-nav-util hg-sidebar-restore" type="button"
+          data-td-shell-sidebar-toggle title="{{ T "ui_sidebar_expand" }}"
+          aria-label="{{ T "ui_sidebar_expand" }}" 
aria-controls="td-shell-sidebar">
+          {{- partialCached "shell/icon.html" "panel-left" "sidebar-restore" 
-}}
+        </button>
+        {{- end }}
         {{- if $localSearch }}
         <button type="button" class="td-nav-search-box" 
data-td-shell-search-open
           title="{{ T "ui_search" }}" aria-label="{{ T "ui_search" }}"
diff --git a/layouts/_partials/share/bar.html b/layouts/_partials/share/bar.html
new file mode 100644
index 000000000..39e0820fa
--- /dev/null
+++ b/layouts/_partials/share/bar.html
@@ -0,0 +1,13 @@
+{{- if and .IsPage (eq .Section "blog") -}}
+<section class="td-share d-print-none" data-td-share data-td-page-context
+  data-td-t-copied="{{ T "ui_copy_link_success" }}"
+  data-td-t-copy-error="{{ T "ui_copy_link_error" }}">
+  <div class="td-share__items" role="group" aria-label="{{ T "ui_share" }}">
+    <button type="button" class="td-share__item td-share__item--copy"
+      data-td-action="copy_link" data-td-action-url="{{ .Permalink }}">
+      <span class="td-share__icon"><i class="fa-solid fa-link" 
aria-hidden="true"></i></span>
+      <span>{{ T "ui_copy_link" }}</span>
+    </button>
+  </div>
+</section>
+{{- end -}}
diff --git a/static/img/social/hugegraph-default.png 
b/static/img/social/hugegraph-default.png
new file mode 100644
index 000000000..4e3e3a0c1
Binary files /dev/null and b/static/img/social/hugegraph-default.png differ
diff --git a/tests/ui-ai/kapa-adapter.test.cjs 
b/tests/ui-ai/kapa-adapter.test.cjs
new file mode 100644
index 000000000..f11f5b6db
--- /dev/null
+++ b/tests/ui-ai/kapa-adapter.test.cjs
@@ -0,0 +1,147 @@
+const assert = require('node:assert/strict');
+const test = require('node:test');
+
+const adapter = require('../../assets/js/kapa-adapter.js');
+
+function harness() {
+  const calls = [];
+  const timers = new Map();
+  let nextTimer = 1;
+  let onRender = null;
+  const trigger = {
+    dataset: {},
+    disabled: false,
+    attrs: {},
+    setAttribute(name, value) { this.attrs[name] = value; },
+    removeAttribute(name) { delete this.attrs[name]; },
+    focus() { this.focused = true; },
+  };
+  const status = {
+    textContent: '',
+    classList: { toggle() {} },
+  };
+  const script = {};
+  const documentObject = {
+    activeElement: trigger,
+    querySelector(selector) {
+      if (selector === '[data-hg-ai-status]') return status;
+      if (selector === 'script[data-hg-kapa-widget]') return script;
+      return null;
+    },
+    querySelectorAll(selector) {
+      return selector === '[data-hg-ask-ai]' ? [trigger] : [];
+    },
+  };
+  const windowObject = {
+    Kapa(method, value) {
+      calls.push([method, value]);
+      if (method === 'render') onRender = value.onRender;
+    },
+    setTimeout(callback) {
+      const id = nextTimer++;
+      timers.set(id, callback);
+      return id;
+    },
+    clearTimeout(id) { timers.delete(id); },
+  };
+  const config = {
+    websiteId: 'website',
+    sourceGroupId: 'source-en',
+    locale: 'en',
+    labels: { error: 'unavailable' },
+  };
+  return {
+    calls,
+    config,
+    documentObject,
+    fireRender() { onRender(); },
+    fireTimeout() { Array.from(timers.values()).forEach((callback) => 
callback()); },
+    trigger,
+    windowObject,
+  };
+}
+
+test('uses one fixed bundle and explicit privacy-safe widget settings', () => {
+  assert.equal(
+    adapter.BUNDLE_URL,
+    'https://widget.kapa.ai/kapa-widget.bundle.js',
+  );
+  const attrs = adapter.scriptAttributes({
+    websiteId: 'website',
+    sourceGroupId: 'source-cn',
+    locale: 'zh',
+  });
+  assert.equal(attrs['data-render-on-load'], 'false');
+  assert.equal(attrs['data-launcher-button-hidden'], 'true');
+  assert.equal(attrs['data-search-mode-enabled'], 'false');
+  assert.equal(attrs['data-modal-open-on-command-k'], 'false');
+  assert.equal(attrs['data-consent-required'], 'false');
+  assert.equal(attrs['data-user-analytics-cookie-enabled'], 'false');
+  assert.equal(attrs['data-user-analytics-fingerprint-enabled'], 'false');
+  assert.equal(attrs['data-bot-protection-mechanism'], 'hcaptcha');
+  assert.equal(attrs['data-source-group-ids-include'], 'source-cn');
+});
+
+test('sends only the trimmed query after explicit activation and render', () 
=> {
+  const h = harness();
+  const controller = adapter.createController(
+    h.windowObject,
+    h.documentObject,
+    h.config,
+  );
+  assert.deepEqual(h.calls.map(([name]) => name), ['onModalClose']);
+
+  controller.activate('  how to start?  ', true, h.trigger);
+  assert.equal(controller.getState(), 'loading');
+  assert.deepEqual(h.calls.map(([name]) => name), ['onModalClose', 'render']);
+
+  h.fireRender();
+  assert.equal(controller.getState(), 'ready');
+  assert.deepEqual(h.calls.slice(-2), [
+    ['setSourceGroupIDs', ['source-en']],
+    ['open', { mode: 'ai', query: 'how to start?', submit: true }],
+  ]);
+  assert.equal(
+    JSON.stringify(h.calls).includes('http'),
+    false,
+    'no page URL is passed to Kapa',
+  );
+});
+
+test('ignores duplicate activation and never opens after a late render', () => 
{
+  const h = harness();
+  const controller = adapter.createController(
+    h.windowObject,
+    h.documentObject,
+    h.config,
+  );
+  controller.activate('first', true, h.trigger);
+  controller.activate('second', true, h.trigger);
+  assert.equal(
+    h.calls.filter(([name]) => name === 'render').length,
+    1,
+  );
+
+  h.fireTimeout();
+  assert.equal(controller.getState(), 'error');
+  h.fireRender();
+  assert.equal(
+    h.calls.filter(([name]) => name === 'open').length,
+    0,
+  );
+});
+
+test('launcher opens a blank session without auto-submit', () => {
+  const h = harness();
+  const controller = adapter.createController(
+    h.windowObject,
+    h.documentObject,
+    h.config,
+  );
+  controller.activate('', false, h.trigger);
+  h.fireRender();
+  assert.deepEqual(h.calls.at(-1), [
+    'open',
+    { mode: 'ai', query: '', submit: false },
+  ]);
+});
diff --git a/tests/ui-ai/ui-contract.test.cjs b/tests/ui-ai/ui-contract.test.cjs
new file mode 100644
index 000000000..1a18b8de3
--- /dev/null
+++ b/tests/ui-ai/ui-contract.test.cjs
@@ -0,0 +1,47 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const test = require('node:test');
+
+const root = path.resolve(__dirname, '../..');
+const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
+
+test('disabled AI emits no widget or adapter markup', () => {
+  const config = read('hugo.yaml');
+  const hook = read('layouts/_partials/hooks/body-end.html');
+  assert.match(config, /ai_search:\n\s+enabled: false/);
+  assert.match(hook, /\{\{- if \$ai\.enabled -\}\}/);
+  assert.equal(config.includes('widget.kapa.ai'), false);
+});
+
+test('theme color and social fallback have one configuration authority', () => 
{
+  const config = read('hugo.yaml');
+  const css = read('assets/scss/_styles_project.scss');
+  assert.match(config, /theme_color: '#532fc9'/);
+  assert.match(config, /images: \[\/img\/social\/hugegraph-default\.png\]/);
+  assert.equal(css.includes('$hg-navbar-purple'), false);
+  assert.equal(css.includes('#532fc9'), false);
+});
+
+test('documentation menu has five groups and no duplicate version panel', () 
=> {
+  const config = read('hugo.yaml');
+  const navbarItem = read('layouts/_partials/navbar-item.html');
+  for (const id of [
+    'docs-start',
+    'docs-components',
+    'docs-develop',
+    'docs-operate',
+    'docs-reference',
+  ]) {
+    assert.match(config, new RegExp(`identifier: ${id}`));
+  }
+  assert.equal(navbarItem.includes('$hasVersionLinks'), false);
+});
+
+test('shell persistence uses the version and locale scoped key', () => {
+  const source = read('assets/js/hugegraph-shell.js');
+  assert.match(source, /oink\.sidebar\.v1\./);
+  assert.match(source, /config\.version/);
+  assert.match(source, /config\.locale/);
+  assert.match(source, /sidebar\.inert = isolated/);
+});

Reply via email to