From 3eeac676345a3555cc986d89570b08e8f085c8be Mon Sep 17 00:00:00 2001 From: github-ops Date: Fri, 15 May 2026 16:55:50 +0000 Subject: [PATCH] ui: add community overlay slots --- TASKS.md | 3 +- apps/ui/html/settings.html | 5 +++ apps/ui/html/wizard/step1.html | 5 +++ apps/ui/js/overlay-loader.js | 75 ++++++++++++++++++++++++++++++++++ apps/ui/js/settings.js | 26 ++++++++++-- apps/ui/js/slot-registry.js | 55 +++++++++++++++++++++++++ apps/ui/js/wizard.js | 9 +++- apps/ui/scripts/build.js | 6 +++ 8 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 apps/ui/js/overlay-loader.js create mode 100644 apps/ui/js/slot-registry.js diff --git a/TASKS.md b/TASKS.md index 6abeedb..4e1e448 100644 --- a/TASKS.md +++ b/TASKS.md @@ -45,7 +45,7 @@ Verification: - UI build and Community-targeted smoke/e2e pass. Progress: -- done: + - done: - `deploy/community/*` already acts as the canonical Community delivery contour - Community capability model is already constrained to: - `REST` @@ -170,4 +170,5 @@ Progress: - Phase 3 dependency slice: added public billing seam in `crank-core` — `BillingHook`, `BillingGate`, `BillingError`, `SharedBillingHook`, and `NoopBillingHook` — so `cloud` can layer billing policy without private-only contracts in the base repo - Phase 3 runtime slice: `RuntimeRequestContext` now carries optional metering context (`workspace_id`, `agent_id`, `source`), `RuntimeExecutorBuilder` accepts a `MeteringSink`, and `RuntimeExecutor` emits `MeteringEvent` on invocation completion while Community apps keep using the default `NoopMeteringSink` - Phase 3 dependency slice: added `CacheBackendFactory` in `crank-runtime` with `BuiltinCacheBackendFactory`; unlike the original draft, the seam lives in runtime rather than core to avoid a `crank-core -> crank-runtime` dependency cycle around `RuntimeCacheStores` + - Phase 5 / task `5.1`: added Community UI overlay foundation — `slot-registry.js`, `overlay-loader.js`, optional `CRANK_UI_OVERLAY_DIR` copy path in the UI build, settings slot mounts, and wizard protocol-card slot mounts; Community build stays noop without any private overlay package - backward-compatible alias `CommunityMachineCredentialVerifier` сохранен, поведение Community не изменено diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html index c09047f..f0ae884 100644 --- a/apps/ui/html/settings.html +++ b/apps/ui/html/settings.html @@ -277,6 +277,11 @@ + +
+
+
+
diff --git a/apps/ui/html/wizard/step1.html b/apps/ui/html/wizard/step1.html index b64682b..21d4483 100644 --- a/apps/ui/html/wizard/step1.html +++ b/apps/ui/html/wizard/step1.html @@ -34,6 +34,11 @@
+
+
+
+
+ diff --git a/apps/ui/js/overlay-loader.js b/apps/ui/js/overlay-loader.js new file mode 100644 index 0000000..dd08dea --- /dev/null +++ b/apps/ui/js/overlay-loader.js @@ -0,0 +1,75 @@ +(function() { + var manifestPromise = null; + var loadedScripts = Object.create(null); + + function overlayBasePath() { + return (window.APP_BASE || '/') + 'overlay/'; + } + + function fetchManifest() { + return fetch(overlayBasePath() + 'manifest.json', { cache: 'no-store' }) + .then(function(response) { + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error('overlay manifest request failed'); + } + return response.json(); + }) + .catch(function() { + return null; + }); + } + + function loadScript(path) { + if (loadedScripts[path]) { + return loadedScripts[path]; + } + loadedScripts[path] = new Promise(function(resolve, reject) { + var script = document.createElement('script'); + script.src = overlayBasePath() + path.replace(/^\/+/, ''); + script.async = false; + script.onload = function() { resolve(); }; + script.onerror = function() { reject(new Error('overlay script load failed')); }; + document.head.appendChild(script); + }).catch(function() { + return null; + }); + return loadedScripts[path]; + } + + function load() { + if (manifestPromise) { + return manifestPromise; + } + manifestPromise = fetchManifest().then(function(manifest) { + if (!(manifest && manifest.slots && window.CrankUiSlots)) { + return null; + } + var tasks = Object.entries(manifest.slots) + .filter(function(entry) { + return window.CrankUiSlots.isAllowed(entry[0]) && typeof entry[1] === 'string'; + }) + .map(function(entry) { + return loadScript(entry[1]); + }); + return Promise.all(tasks).then(function() { + return manifest; + }); + }); + return manifestPromise; + } + + async function render(root, context) { + await load(); + if (window.CrankUiSlots) { + window.CrankUiSlots.renderAll(root, context); + } + } + + window.CrankOverlay = { + load: load, + render: render, + }; +}()); diff --git a/apps/ui/js/settings.js b/apps/ui/js/settings.js index f044216..e987e36 100644 --- a/apps/ui/js/settings.js +++ b/apps/ui/js/settings.js @@ -193,6 +193,19 @@ async function loadCapabilities() { } } +async function renderOverlaySlots() { + if (!window.CrankOverlay || typeof window.CrankOverlay.render !== 'function') { + return; + } + + await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve()); + await window.CrankOverlay.render(document, { + workspace: window.getCurrentWorkspace ? window.getCurrentWorkspace() : null, + capabilities: settingsCapabilities, + locale: localStorage.getItem('crank_lang') || 'en', + }); +} + function bindProfileSave() { var button = document.getElementById('settings-profile-save-btn'); if (!button || button.dataset.bound === 'true') { @@ -418,12 +431,17 @@ async function loadWorkspaceSettings() { } } -document.addEventListener('DOMContentLoaded', function () { +async function initSettingsPage() { injectLanguageSwitcher(); bindSectionNavigation(); - loadProfile(); - loadCapabilities(); + await loadProfile(); + await loadCapabilities(); bindProfileSave(); bindPasswordSave(); - loadWorkspaceSettings(); + await loadWorkspaceSettings(); + await renderOverlaySlots(); +} + +document.addEventListener('DOMContentLoaded', function () { + void initSettingsPage(); }); diff --git a/apps/ui/js/slot-registry.js b/apps/ui/js/slot-registry.js new file mode 100644 index 0000000..065edbb --- /dev/null +++ b/apps/ui/js/slot-registry.js @@ -0,0 +1,55 @@ +(function() { + var allowedSlots = { + 'settings.sso_panel': true, + 'settings.totp_panel': true, + 'settings.audit_panel': true, + 'settings.billing_panel': true, + 'wizard.protocol_cards.graphql': true, + 'wizard.protocol_cards.grpc': true, + 'wizard.protocol_cards.soap': true, + 'wizard.protocol_cards.websocket': true, + }; + + var handlers = Object.create(null); + + function noop() {} + + function isAllowed(slotId) { + return !!allowedSlots[slotId]; + } + + function register(slotId, render) { + if (!isAllowed(slotId) || typeof render !== 'function') { + return false; + } + handlers[slotId] = render; + return true; + } + + function renderInto(target, slotId, context) { + if (!(target && slotId && isAllowed(slotId))) { + return; + } + var handler = handlers[slotId] || noop; + handler(target, context || {}); + } + + function renderAll(root, context) { + var scope = root || document; + scope.querySelectorAll('[data-slot]').forEach(function(target) { + renderInto(target, target.getAttribute('data-slot'), context); + }); + } + + function knownSlots() { + return Object.keys(allowedSlots); + } + + window.CrankUiSlots = { + isAllowed: isAllowed, + knownSlots: knownSlots, + register: register, + renderAll: renderAll, + renderInto: renderInto, + }; +}()); diff --git a/apps/ui/js/wizard.js b/apps/ui/js/wizard.js index 263d094..4bbce98 100644 --- a/apps/ui/js/wizard.js +++ b/apps/ui/js/wizard.js @@ -86,9 +86,16 @@ async function initWizardPage() { await loadProtocolCapabilities(); await loadWizardPanels([1, 2, 3, 4, 5]); - applyEditionProtocolVisibility(editionCapabilities); + if (window.CrankOverlay && typeof window.CrankOverlay.render === 'function') { + await window.CrankOverlay.render(document, { + workspace: workspace, + capabilities: editionCapabilities, + locale: localStorage.getItem('crank_lang') || 'en', + }); + } await loadWizardAuthResources(); bindProtocolCards(); + applyEditionProtocolVisibility(editionCapabilities); bindWizardLiveActions(); updateUpstreamAuthUi(); renderEditionCapabilityHints(editionCapabilities); diff --git a/apps/ui/scripts/build.js b/apps/ui/scripts/build.js index 7ca2cc9..7eabfa4 100644 --- a/apps/ui/scripts/build.js +++ b/apps/ui/scripts/build.js @@ -16,6 +16,8 @@ const BUNDLES = { files: [ 'js/config.js', 'js/i18n.js', + 'js/slot-registry.js', + 'js/overlay-loader.js', 'js/dom.js', 'js/diagnostics.js', 'js/workspace.js', @@ -176,6 +178,7 @@ function rewriteHtml(relativePath, replacements, prefix) { } async function main() { + var overlayDirectory = process.env.CRANK_UI_OVERLAY_DIR || ''; removeDirectory(DIST_DIR); ensureDirectory(BUNDLE_DIR); @@ -199,6 +202,9 @@ async function main() { }); copyDirectory(path.join(ROOT_DIR, 'icons'), path.join(DIST_DIR, 'icons')); copyFile(resolveBrandImage(), path.join(DIST_DIR, 'Crank.png')); + if (overlayDirectory && fs.existsSync(overlayDirectory)) { + copyDirectory(overlayDirectory, path.join(DIST_DIR, 'overlay')); + } rewriteHtml('index.html', manifest, ''); rewriteHtml('html/login.html', manifest, '..');