diff --git a/TASKS.md b/TASKS.md
index d932a94..0470141 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -2,19 +2,19 @@
## Current
-### `feat/demo-assets`
+### `feat/alpine-polish`
Status: in_progress
DoD:
-- project has an idempotent PostgreSQL demo seed for live UI screens
-- demo seed fills workspaces, memberships, invitations, operations, agents, keys and usage
-- seed is controlled by env and does not live in migrations
-- docs explain how to enable and use demo data
+- Alpine UI pages have consistent feedback and shell state
+- alert()-based errors are removed from primary screens
+- header identity reflects live session and current workspace
+- settings and workspace flows avoid crude localStorage/logout workarounds
## Next
-- `feat/alpine-polish`
+- `feat/current-workspace-session-model`
## Backlog
diff --git a/apps/ui/css/pages.css b/apps/ui/css/pages.css
index 1fb23f3..7da02f0 100644
--- a/apps/ui/css/pages.css
+++ b/apps/ui/css/pages.css
@@ -125,6 +125,77 @@
.btn-icon.danger:hover { background: var(--red-bg); color: var(--red); border-color: var(--red-border); }
+/* ══════════════════════════════════════════════════
+ TOASTS
+══════════════════════════════════════════════════ */
+.toast-stack {
+ position: fixed;
+ top: 84px;
+ right: 20px;
+ z-index: 1200;
+ width: min(360px, calc(100vw - 32px));
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ pointer-events: none;
+}
+
+.toast-card {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px 16px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: rgba(15, 23, 34, 0.96);
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
+ pointer-events: auto;
+ transition: opacity 0.18s ease, transform 0.18s ease;
+}
+
+.toast-card.closing {
+ opacity: 0;
+ transform: translateY(-6px);
+}
+
+.toast-info { border-color: rgba(56, 189, 248, 0.28); }
+.toast-success { border-color: rgba(63, 185, 80, 0.32); }
+.toast-error { border-color: rgba(248, 81, 73, 0.34); }
+
+.toast-copy {
+ min-width: 0;
+ flex: 1;
+}
+
+.toast-title {
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ color: var(--text-primary);
+ margin-bottom: 3px;
+}
+
+.toast-message {
+ font-size: 12.5px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+}
+
+.toast-close {
+ width: 22px;
+ height: 22px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.toast-close:hover { color: var(--text-primary); }
+
/* ══════════════════════════════════════════════════
SECTION CARD
══════════════════════════════════════════════════ */
@@ -1456,3 +1527,12 @@
flex-shrink: 0;
opacity: 0.7;
}
+
+@media (max-width: 900px) {
+ .toast-stack {
+ top: 72px;
+ right: 16px;
+ left: 16px;
+ width: auto;
+ }
+}
diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html
index 00adaa0..f368f16 100644
--- a/apps/ui/html/agents.html
+++ b/apps/ui/html/agents.html
@@ -15,6 +15,7 @@
+
@@ -292,7 +293,7 @@
- MCP endpoint:
+ MCP endpoint:
diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html
index edf3069..07c30fd 100644
--- a/apps/ui/html/api-keys.html
+++ b/apps/ui/html/api-keys.html
@@ -26,6 +26,7 @@
+
diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html
index 0b83fd3..118c960 100644
--- a/apps/ui/html/settings.html
+++ b/apps/ui/html/settings.html
@@ -13,6 +13,7 @@
+
@@ -542,21 +543,21 @@
Rename workspace
Changes the workspace slug used in all API endpoints. Existing API keys will continue to work. All existing tool call references will break.
-
+
Export all data
Download a ZIP archive of all operation configs, schemas, mappings and invocation history as JSON. May take a few minutes.
-
+
Delete workspace
Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.
-
+
diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html
index fc9c96f..a67e11f 100644
--- a/apps/ui/html/wizard/index.html
+++ b/apps/ui/html/wizard/index.html
@@ -13,6 +13,7 @@
+
diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html
index 070d735..dc53806 100644
--- a/apps/ui/html/workspace-setup.html
+++ b/apps/ui/html/workspace-setup.html
@@ -14,6 +14,7 @@
+
diff --git a/apps/ui/index.html b/apps/ui/index.html
index f50b520..795152b 100644
--- a/apps/ui/index.html
+++ b/apps/ui/index.html
@@ -13,6 +13,7 @@
+
diff --git a/apps/ui/js/agents.js b/apps/ui/js/agents.js
index 81588e2..999cdea 100644
--- a/apps/ui/js/agents.js
+++ b/apps/ui/js/agents.js
@@ -282,7 +282,9 @@ document.addEventListener('alpine:init', function() {
await this.reload();
this.closeDrawer();
} catch (error) {
- alert(error.message || 'Failed to save agent');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to save agent', 'Agent update failed');
+ }
this.saving = false;
}
},
@@ -294,7 +296,9 @@ document.addEventListener('alpine:init', function() {
await window.CrankApi.deleteAgent(this.workspaceId, id);
await this.reload();
} catch (error) {
- alert(error.message || 'Failed to delete agent');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to delete agent', 'Delete failed');
+ }
}
},
diff --git a/apps/ui/js/api-keys.js b/apps/ui/js/api-keys.js
index b1d3b9d..3ce574c 100644
--- a/apps/ui/js/api-keys.js
+++ b/apps/ui/js/api-keys.js
@@ -72,7 +72,9 @@ async function revokeKey(id) {
await window.CrankApi.revokePlatformApiKey(currentWorkspaceId, id);
await loadKeys();
} catch (error) {
- alert(error.message || 'Failed to revoke key');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to revoke key', 'Revoke failed');
+ }
}
}
@@ -83,7 +85,9 @@ async function deleteKey(id) {
await window.CrankApi.deletePlatformApiKey(currentWorkspaceId, id);
await loadKeys();
} catch (error) {
- alert(error.message || 'Failed to delete key');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to delete key', 'Delete failed');
+ }
}
}
@@ -231,7 +235,9 @@ document.getElementById('modal-confirm-btn').addEventListener('click', async fun
return;
}
if (!selectedScopes.size) {
- alert('Select at least one scope.');
+ if (window.CrankUi) {
+ window.CrankUi.info('Select at least one scope before creating a key.', 'Missing scope');
+ }
return;
}
@@ -245,7 +251,9 @@ document.getElementById('modal-confirm-btn').addEventListener('click', async fun
document.getElementById('modal-footer-done').style.display = '';
renderTable();
} catch (error) {
- alert(error.message || 'Failed to create key');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to create key', 'Key creation failed');
+ }
}
});
diff --git a/apps/ui/js/auth.js b/apps/ui/js/auth.js
index bde39fd..76c8a21 100644
--- a/apps/ui/js/auth.js
+++ b/apps/ui/js/auth.js
@@ -3,6 +3,20 @@
var sessionCache = null;
var sessionPromise = null;
+ function currentWorkspaceLabel() {
+ if (window.getCurrentWorkspace) {
+ var workspace = window.getCurrentWorkspace();
+ if (workspace && workspace.name) {
+ return workspace.name;
+ }
+ }
+ try {
+ return localStorage.getItem('crank_workspace_slug') || 'workspace';
+ } catch (_error) {
+ return 'workspace';
+ }
+ }
+
function isLoginPage() {
return /\/html\/login\.html$/.test(window.location.pathname);
}
@@ -21,12 +35,25 @@
try {
localStorage.removeItem(STORAGE_KEY);
} catch (_error) {}
+ renderShellIdentity(null);
}
function primaryMembership(session) {
- return session && session.memberships && session.memberships.length
- ? session.memberships[0]
- : null;
+ if (!(session && session.memberships && session.memberships.length)) {
+ return null;
+ }
+ if (window.getCurrentWorkspace) {
+ var currentWorkspace = window.getCurrentWorkspace();
+ if (currentWorkspace) {
+ var matched = session.memberships.find(function(item) {
+ return item.workspace && item.workspace.id === currentWorkspace.id;
+ });
+ if (matched) {
+ return matched;
+ }
+ }
+ }
+ return session.memberships[0];
}
function initials(displayName, email) {
@@ -56,9 +83,47 @@
} catch (_error) {}
}
+ function mirroredUser() {
+ try {
+ return JSON.parse(localStorage.getItem(STORAGE_KEY));
+ } catch (_error) {
+ return null;
+ }
+ }
+
+ function renderShellIdentity(session) {
+ var membership = primaryMembership(session);
+ var fallbackUser = mirroredUser();
+ var displayName = session && session.user
+ ? (session.user.display_name || session.user.email || 'Crank')
+ : (fallbackUser && fallbackUser.name) || 'Crank';
+ var email = session && session.user
+ ? session.user.email
+ : fallbackUser && fallbackUser.email;
+ var role = membership
+ ? String(membership.role || '').replace(/^\w/, function(char) { return char.toUpperCase(); })
+ : (fallbackUser && fallbackUser.role) || 'Viewer';
+ var workspace = membership && membership.workspace
+ ? (membership.workspace.display_name || membership.workspace.slug || membership.workspace.id)
+ : currentWorkspaceLabel();
+ var avatarValue = initials(displayName, email);
+
+ document.querySelectorAll('.nav-avatar').forEach(function(element) {
+ element.textContent = avatarValue;
+ });
+ document.querySelectorAll('.user-dropdown-name').forEach(function(element) {
+ element.textContent = displayName;
+ });
+ document.querySelectorAll('.user-dropdown-role').forEach(function(element) {
+ element.textContent = role + ' · ' + workspace;
+ });
+ }
+
function replaceSession(session) {
sessionCache = session;
persistUserMirror(session);
+ renderShellIdentity(session);
+ window.dispatchEvent(new CustomEvent('crank:sessionchange', { detail: session }));
return session;
}
@@ -144,4 +209,16 @@
logout: logout,
handleUnauthorized: handleUnauthorized,
};
+
+ window.addEventListener('crank:workspacechange', function() {
+ renderShellIdentity(sessionCache);
+ });
+
+ window.addEventListener('crank:sessionchange', function(event) {
+ renderShellIdentity(event.detail || sessionCache);
+ });
+
+ document.addEventListener('DOMContentLoaded', function() {
+ renderShellIdentity(sessionCache);
+ });
}());
diff --git a/apps/ui/js/catalog.js b/apps/ui/js/catalog.js
index c3bbf16..112fae2 100644
--- a/apps/ui/js/catalog.js
+++ b/apps/ui/js/catalog.js
@@ -369,7 +369,9 @@ document.addEventListener('alpine:init', function() {
}).filter(Boolean))).sort();
this.stats = computeStats(this.operations);
} catch (error) {
- alert(error.message || 'Failed to delete operation');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to delete operation', 'Delete failed');
+ }
}
},
diff --git a/apps/ui/js/settings.js b/apps/ui/js/settings.js
index c4262b4..6f8add3 100644
--- a/apps/ui/js/settings.js
+++ b/apps/ui/js/settings.js
@@ -1,5 +1,20 @@
var settingsSession = null;
+function settingsWorkspaceId() {
+ var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
+ return workspace ? workspace.id : null;
+}
+
+function downloadSettingsJson(fileName, value) {
+ var blob = new Blob([JSON.stringify(value, null, 2)], { type: 'application/json;charset=utf-8' });
+ var url = URL.createObjectURL(blob);
+ var link = document.createElement('a');
+ link.href = url;
+ link.download = fileName;
+ link.click();
+ URL.revokeObjectURL(url);
+}
+
function initials(displayName, email) {
var source = displayName || email || 'Crank';
return source
@@ -255,7 +270,9 @@ async function loadWorkspaceSettings() {
saveButton.textContent = 'Saved ✓';
} catch (error) {
- alert(error.message || 'Failed to save workspace settings');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to save workspace settings', 'Workspace save failed');
+ }
saveButton.textContent = original;
} finally {
setTimeout(function () {
@@ -268,11 +285,65 @@ async function loadWorkspaceSettings() {
}
}
+function bindDangerZoneActions() {
+ var renameButton = document.getElementById('settings-ws-rename-btn');
+ var exportButton = document.getElementById('settings-ws-export-btn');
+ var deleteButton = document.getElementById('settings-ws-delete-btn');
+
+ if (renameButton && renameButton.dataset.bound !== 'true') {
+ renameButton.dataset.bound = 'true';
+ renameButton.addEventListener('click', function() {
+ document.getElementById('settings-ws-slug').focus();
+ if (window.CrankUi) {
+ window.CrankUi.info('Update the workspace slug above and save changes to rename it.', 'Rename workspace');
+ }
+ });
+ }
+
+ if (exportButton && exportButton.dataset.bound !== 'true') {
+ exportButton.dataset.bound = 'true';
+ exportButton.addEventListener('click', async function() {
+ var workspaceId = settingsWorkspaceId();
+ if (!workspaceId || !window.CrankApi) return;
+ try {
+ var snapshot = await window.CrankApi.exportWorkspace(workspaceId);
+ var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
+ downloadSettingsJson((workspace ? workspace.slug : 'workspace') + '-snapshot.json', snapshot);
+ if (window.CrankUi) {
+ window.CrankUi.success('Workspace export downloaded.', 'Export complete');
+ }
+ } catch (error) {
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to export workspace', 'Export failed');
+ }
+ }
+ });
+ }
+
+ if (deleteButton && deleteButton.dataset.bound !== 'true') {
+ deleteButton.dataset.bound = 'true';
+ deleteButton.addEventListener('click', async function() {
+ var workspaceId = settingsWorkspaceId();
+ if (!workspaceId || !window.CrankApi || !window.CrankAuth) return;
+ if (!window.confirm('Delete workspace? This cannot be undone.')) return;
+ try {
+ await window.CrankApi.deleteWorkspace(workspaceId);
+ await window.CrankAuth.logout();
+ } catch (error) {
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to delete workspace', 'Delete failed');
+ }
+ }
+ });
+ }
+}
+
document.addEventListener('DOMContentLoaded', function () {
injectLanguageSwitcher();
bindSectionNavigation();
loadProfile();
bindProfileSave();
bindPasswordSave();
+ bindDangerZoneActions();
loadWorkspaceSettings();
});
diff --git a/apps/ui/js/ui-feedback.js b/apps/ui/js/ui-feedback.js
new file mode 100644
index 0000000..9bdcf56
--- /dev/null
+++ b/apps/ui/js/ui-feedback.js
@@ -0,0 +1,70 @@
+(function() {
+ var CONTAINER_ID = 'crank-toast-container';
+ var nextToastId = 0;
+
+ function ensureContainer() {
+ var container = document.getElementById(CONTAINER_ID);
+ if (container) return container;
+
+ container = document.createElement('div');
+ container.id = CONTAINER_ID;
+ container.className = 'toast-stack';
+ document.body.appendChild(container);
+ return container;
+ }
+
+ function dismiss(toast) {
+ if (!toast || !toast.parentNode) return;
+ toast.classList.add('closing');
+ window.setTimeout(function() {
+ if (toast.parentNode) toast.parentNode.removeChild(toast);
+ }, 180);
+ }
+
+ function notify(options) {
+ var config = options || {};
+ var duration = config.duration === 0 ? 0 : (config.duration || 3600);
+ var toast = document.createElement('div');
+
+ toast.id = 'toast_' + (++nextToastId);
+ toast.className = 'toast-card toast-' + (config.type || 'info');
+ toast.innerHTML =
+ '' +
+ (config.title ? '
' + config.title + '
' : '') +
+ (config.message ? '
' + config.message + '
' : '') +
+ '
' +
+ '';
+
+ toast.querySelector('.toast-close').addEventListener('click', function() {
+ dismiss(toast);
+ });
+
+ ensureContainer().appendChild(toast);
+
+ if (duration > 0) {
+ window.setTimeout(function() {
+ dismiss(toast);
+ }, duration);
+ }
+
+ return toast.id;
+ }
+
+ window.CrankUi = {
+ notify: notify,
+ success: function(message, title) {
+ return notify({ type: 'success', title: title || 'Done', message: message });
+ },
+ error: function(message, title) {
+ return notify({ type: 'error', title: title || 'Request failed', message: message });
+ },
+ info: function(message, title) {
+ return notify({ type: 'info', title: title || 'Info', message: message });
+ }
+ };
+}());
diff --git a/apps/ui/js/wizard.js b/apps/ui/js/wizard.js
index 673bd3d..5c4fb11 100644
--- a/apps/ui/js/wizard.js
+++ b/apps/ui/js/wizard.js
@@ -1179,7 +1179,9 @@ async function saveOperation(stayOnPage) {
try {
await persistCurrentDraft(stayOnPage);
} catch (error) {
- alert(error.message || 'Failed to save operation');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to save operation', 'Save failed');
+ }
}
}
diff --git a/apps/ui/js/workspace-setup.js b/apps/ui/js/workspace-setup.js
index 2762c5b..a518b91 100644
--- a/apps/ui/js/workspace-setup.js
+++ b/apps/ui/js/workspace-setup.js
@@ -342,7 +342,9 @@ async function submitForm() {
submit.disabled = false;
}, 1400);
} catch (error) {
- alert(error.message || 'Failed to save workspace');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to save workspace', 'Save failed');
+ }
submit.disabled = false;
submit.textContent = originalLabel;
}
@@ -404,7 +406,9 @@ async function sendInvite() {
document.getElementById('invite-success').style.display = '';
await loadWorkspaceAccessData();
} catch (error) {
- alert(error.message || 'Failed to send invite');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to send invite', 'Invite failed');
+ }
}
}
@@ -424,7 +428,9 @@ async function updateRole(select, userId) {
renderMembers();
} catch (error) {
select.value = previous;
- alert(error.message || 'Failed to update role');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to update role', 'Role update failed');
+ }
} finally {
select.disabled = false;
}
@@ -442,7 +448,9 @@ async function removeMember(userId, name) {
await window.CrankApi.deleteMembership(workspaceFormState.workspaceId, userId);
await loadWorkspaceAccessData();
} catch (error) {
- alert(error.message || 'Failed to remove member');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to remove member', 'Member removal failed');
+ }
}
}
@@ -455,7 +463,9 @@ async function revokeInviteById(invitationId) {
await window.CrankApi.deleteInvitation(workspaceFormState.workspaceId, invitationId);
await loadWorkspaceAccessData();
} catch (error) {
- alert(error.message || 'Failed to revoke invite');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to revoke invite', 'Invite revoke failed');
+ }
}
}
@@ -489,7 +499,9 @@ async function exportWorkspaceSnapshot() {
: 'workspace';
downloadJsonFile(slug + '-snapshot.json', snapshot);
} catch (error) {
- alert(error.message || 'Failed to export workspace');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to export workspace', 'Export failed');
+ }
}
}
@@ -505,7 +517,9 @@ async function deleteWorkspaceAction() {
await window.CrankApi.deleteWorkspace(workspaceFormState.workspaceId);
await window.CrankAuth.logout();
} catch (error) {
- alert(error.message || 'Failed to delete workspace');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to delete workspace', 'Delete failed');
+ }
}
}
@@ -536,7 +550,9 @@ async function initPage() {
applyWorkspaceRecord(record);
await loadWorkspaceAccessData();
} catch (error) {
- alert(error.message || 'Failed to load workspace');
+ if (window.CrankUi) {
+ window.CrankUi.error(error.message || 'Failed to load workspace', 'Workspace load failed');
+ }
}
}