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, '..');