diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json
index 5b7d62c95d..ddc868e9cf 100644
--- a/internal/cloudcp/portal/dist/build_manifest.json
+++ b/internal/cloudcp/portal/dist/build_manifest.json
@@ -1,5 +1,5 @@
{
- "source_hash": "5a37d7a95cfb4c6cfa06bac62785c5638c391bcf52d6472e05c265f548f367e7",
+ "source_hash": "b48ed843c8ab211f26d40bdb069e5a6a3f35c206eedef2cf15301ca0f640e8e3",
"build_inputs": [
"package.json",
"tsconfig.json",
diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css
index f865d526f1..043c1a1697 100644
--- a/internal/cloudcp/portal/dist/portal_app.css
+++ b/internal/cloudcp/portal/dist/portal_app.css
@@ -885,6 +885,16 @@ header .logout-btn:hover,
grid-template-columns: 1fr;
gap: 14px;
}
+.workspace-operations-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.9fr);
+ gap: 14px;
+ align-items: start;
+}
+.workspace-operations-main,
+.workspace-operations-detail {
+ min-width: 0;
+}
.workspace-management-panel,
.team-management-panel {
min-height: 100%;
@@ -894,7 +904,7 @@ header .logout-btn:hover,
box-shadow: var(--shadow-md);
}
.workspace-management-panel {
- background: var(--panel-muted);
+ background: #fff;
}
.workspace-management-content {
display: grid;
@@ -1151,6 +1161,14 @@ header .logout-btn:hover,
flex-direction: column;
gap: 12px;
}
+.service-detail-shell {
+ min-height: 100%;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: #fff;
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
+}
.service-brief-card {
padding: 18px;
border: 1px solid var(--line);
@@ -1180,6 +1198,15 @@ header .logout-btn:hover,
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fff;
+ transition:
+ border-color 140ms ease,
+ box-shadow 140ms ease,
+ transform 140ms ease;
+}
+.service-action-row:hover,
+.service-action-row.active {
+ border-color: rgba(15, 109, 115, 0.28);
+ box-shadow: 0 10px 24px rgba(10, 25, 41, 0.08);
}
.service-action-tags {
display: flex;
@@ -1260,10 +1287,43 @@ header .logout-btn:hover,
padding: 22px;
border-radius: var(--radius-md);
background: #fff;
+ min-height: 100%;
}
.service-panel.visible.auth-panel {
display: block;
}
+.service-panel-empty {
+ display: none;
+}
+.service-panel-empty.visible {
+ display: block;
+}
+.service-empty-points {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin-top: 20px;
+}
+.service-empty-point {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--panel-muted);
+}
+.service-empty-point strong {
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-muted);
+}
+.service-empty-point span {
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--ink-soft);
+}
.service-section-auth {
max-width: 760px;
}
@@ -1428,7 +1488,9 @@ header .logout-btn:hover,
.team-management-grid,
.account-overview-secondary,
.service-shell,
- .service-action-list {
+ .service-action-list,
+ .service-empty-points,
+ .workspace-operations-shell {
grid-template-columns: 1fr;
}
.portal-shell-layout,
diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js
index 9a7fcf4d5c..87bd71140f 100644
--- a/internal/cloudcp/portal/dist/portal_app.js
+++ b/internal/cloudcp/portal/dist/portal_app.js
@@ -80,7 +80,7 @@
if (!empty || !content || !title || !meta || !summary || !actionButton || !closeButton) return;
var workspace = entry.selectedWorkspaceID ? findWorkspace(account, entry.selectedWorkspaceID) : null;
var hasSelection = !!workspace;
- panel.classList.toggle("visible", hasSelection);
+ panel.classList.add("visible");
empty.hidden = hasSelection;
content.hidden = !hasSelection;
if (!workspace) {
@@ -1102,11 +1102,22 @@
}
function renderOpenPanels(openPanelID) {
var panels = ["manage-service-panel", "retrieve-service-panel", "refund-service-panel", "data-service-panel"];
+ var emptyPanel = getElement3("service-panel-empty");
+ if (emptyPanel) {
+ emptyPanel.classList.toggle("visible", !openPanelID);
+ }
for (var i = 0; i < panels.length; i++) {
var panel = getElement3(panels[i]);
if (!panel) continue;
panel.classList.toggle("visible", panels[i] === openPanelID);
}
+ var serviceButtons = document.querySelectorAll('[data-account-service-action="open-service-panel"]');
+ for (var j = 0; j < serviceButtons.length; j += 1) {
+ var button = serviceButtons[j];
+ var row = button.closest(".service-action-row");
+ if (!row) continue;
+ row.classList.toggle("active", button.getAttribute("data-account-service-panel") === openPanelID);
+ }
}
function renderRefundPanel(refundState, bootstrap) {
var root = getElement3("refund-service-root");
@@ -1782,7 +1793,7 @@
var workspaceHTML = workspaces.length ? '
Workspace Health Lifecycle Actions
' + workspaces.map(function(workspace) {
return renderWorkspaceCard(account, workspace, accountAPIBasePath);
}).join("") + "
" : 'No hosted workspaces yet. Create one to get started.
';
- return '' + workspaceHTML + '' + workspaceManagement + "
";
+ return '' + workspaceHTML + '
' + workspaceManagement + "
";
}
function renderAccountTeamSection(account) {
return '
People on this account Keep the roster small and role assignment explicit. The people listed here are the ones who can operate the hosted fleet.
Invite someone new Add another operator with the minimum role they need for this account.
Email
Role Admin Tech Read-only
Admin Billing and hosted operations.
Tech Operational access without billing control.
Read-only Review hosted state without making changes.
Invite ';
@@ -1805,7 +1816,7 @@
var hostedContent = accounts.map(function(account) {
return '' + renderAccountOverviewSection(account) + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + renderAccountTeamSection(account) + "
";
}).join("");
- return '' + renderOverviewBand(accounts) + '
' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '
Data and privacy Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.
Payment-card data stays with Stripe. For Stripe deletion support, contact
' + escapeHTML(context.bootstrap.support_email || "") + ' .
' + renderSupportSection(context) + " ";
+ return '' + renderOverviewBand(accounts) + '
' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '
Select a service
Choose a commercial account action Open a billing, license, refund, or privacy flow from the service navigator. The active request stays here so the account-services area behaves like one working surface instead of a list of disconnected tools.
Billing Open Stripe customer portal access after verification.
Licenses Recover the latest active self-hosted license and invoice link.
Privacy Request export or deletion without leaving Pulse Account.
Data and privacy Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.
Payment-card data stays with Stripe. For Stripe deletion support, contact
' + escapeHTML(context.bootstrap.support_email || "") + ' .
' + renderSupportSection(context) + " ";
}
function renderSignedOutPortalHTML(context) {
var statusHTML = "";
diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts
index a78cd44e46..c156064651 100644
--- a/internal/cloudcp/portal/frontend/src/account_view.ts
+++ b/internal/cloudcp/portal/frontend/src/account_view.ts
@@ -98,7 +98,7 @@ export function renderWorkspaceManagement(account: PortalAccountSummary, entry:
var workspace = entry.selectedWorkspaceID ? findWorkspace(account, entry.selectedWorkspaceID) : null;
var hasSelection = !!workspace;
- panel.classList.toggle('visible', hasSelection);
+ panel.classList.add('visible');
empty.hidden = hasSelection;
content.hidden = !hasSelection;
diff --git a/internal/cloudcp/portal/frontend/src/services_view.test.ts b/internal/cloudcp/portal/frontend/src/services_view.test.ts
index 5087a20861..9230278228 100644
--- a/internal/cloudcp/portal/frontend/src/services_view.test.ts
+++ b/internal/cloudcp/portal/frontend/src/services_view.test.ts
@@ -80,6 +80,9 @@ describe('services view', function() {
it('toggles the visible service panel by id', function() {
document.body.innerHTML =
+ '
' +
+ ' ' +
+ ' ' +
'
' +
'
' +
'
' +
@@ -90,6 +93,8 @@ describe('services view', function() {
expect(document.getElementById('retrieve-service-panel')?.classList.contains('visible')).toBe(true);
expect(document.getElementById('manage-service-panel')?.classList.contains('visible')).toBe(false);
expect(document.getElementById('refund-service-panel')?.classList.contains('visible')).toBe(false);
+ expect(document.getElementById('service-panel-empty')?.classList.contains('visible')).toBe(false);
+ expect(document.querySelectorAll('.service-action-row.active')).toHaveLength(1);
});
it('renders retrieve panel result state with invoice and token metadata', function() {
diff --git a/internal/cloudcp/portal/frontend/src/services_view.ts b/internal/cloudcp/portal/frontend/src/services_view.ts
index 934c4a10f1..a8fab11b42 100644
--- a/internal/cloudcp/portal/frontend/src/services_view.ts
+++ b/internal/cloudcp/portal/frontend/src/services_view.ts
@@ -69,11 +69,22 @@ export function renderButton(id: string | undefined, disabled: boolean, label: s
export function renderOpenPanels(openPanelID: string): void {
var panels = ['manage-service-panel', 'retrieve-service-panel', 'refund-service-panel', 'data-service-panel'];
+ var emptyPanel = getElement('service-panel-empty');
+ if (emptyPanel) {
+ emptyPanel.classList.toggle('visible', !openPanelID);
+ }
for (var i = 0; i < panels.length; i++) {
var panel = getElement(panels[i]);
if (!panel) continue;
panel.classList.toggle('visible', panels[i] === openPanelID);
}
+ var serviceButtons = document.querySelectorAll('[data-account-service-action="open-service-panel"]');
+ for (var j = 0; j < serviceButtons.length; j += 1) {
+ var button = serviceButtons[j];
+ var row = button.closest('.service-action-row');
+ if (!row) continue;
+ row.classList.toggle('active', button.getAttribute('data-account-service-panel') === openPanelID);
+ }
}
export function renderRefundPanel(refundState: RefundState, bootstrap: PortalBootstrapData): void {
diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts
index 95ae248aeb..ab281b3d37 100644
--- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts
+++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts
@@ -169,6 +169,8 @@ describe('shell view', function() {
expect(html).toContain('data-action="workspace-action"');
expect(html).toContain('service-action-row');
expect(html).toContain('service-action-button');
+ expect(html).toContain('id="service-panel-empty"');
+ expect(html).toContain('Choose a commercial account action');
expect(html).toContain('id="open-retrieve-service"');
expect(html).toContain('id="data-service-panel"');
});
diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts
index fcc4c328c7..3be5c85a17 100644
--- a/internal/cloudcp/portal/frontend/src/shell_view.ts
+++ b/internal/cloudcp/portal/frontend/src/shell_view.ts
@@ -525,9 +525,13 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI
'Use the fleet view to open workspaces, watch health posture, and keep management actions explicit.
' +
'' +
'' +
- workspaceHTML +
- '' +
- workspaceManagement +
+ '
' +
+ '
' +
+ workspaceHTML +
+ '
' +
+ '
' +
+ workspaceManagement +
+ '
' +
'
' +
''
);
@@ -723,19 +727,31 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
'
' +
'' +
'' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
Data and privacy ' +
- '
Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.
' +
- '
' +
- '
' +
- '
Payment-card data stays with Stripe. For Stripe deletion support, contact
' +
- escapeHTML(context.bootstrap.support_email || '') +
- ' .
' +
+ '
' +
+ '
' +
+ '
Select a service
' +
+ '
Choose a commercial account action ' +
+ '
Open a billing, license, refund, or privacy flow from the service navigator. The active request stays here so the account-services area behaves like one working surface instead of a list of disconnected tools.
' +
+ '
' +
+ '
Billing Open Stripe customer portal access after verification.
' +
+ '
Licenses Recover the latest active self-hosted license and invoice link.
' +
+ '
Privacy Request export or deletion without leaving Pulse Account.
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
Data and privacy ' +
+ '
Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.
' +
+ '
' +
+ '
' +
+ '
Payment-card data stays with Stripe. For Stripe deletion support, contact
' +
+ escapeHTML(context.bootstrap.support_email || '') +
+ ' .
' +
+ '
' +
'
' +
'
' +
'
' +
diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css
index a0796483b2..a10ddcd4cf 100644
--- a/internal/cloudcp/portal/frontend/src/styles.css
+++ b/internal/cloudcp/portal/frontend/src/styles.css
@@ -986,6 +986,18 @@ header .logout-btn:hover,
gap: 14px;
}
+.workspace-operations-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.9fr);
+ gap: 14px;
+ align-items: start;
+}
+
+.workspace-operations-main,
+.workspace-operations-detail {
+ min-width: 0;
+}
+
.workspace-management-panel,
.team-management-panel {
min-height: 100%;
@@ -997,7 +1009,7 @@ header .logout-btn:hover,
}
.workspace-management-panel {
- background: var(--panel-muted);
+ background: #fff;
}
.workspace-management-content {
@@ -1294,6 +1306,15 @@ header .logout-btn:hover,
gap: 12px;
}
+.service-detail-shell {
+ min-height: 100%;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ background: #fff;
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
+}
+
.service-brief-card {
padding: 18px;
border: 1px solid var(--line);
@@ -1327,6 +1348,13 @@ header .logout-btn:hover,
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #fff;
+ transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
+}
+
+.service-action-row:hover,
+.service-action-row.active {
+ border-color: rgba(15, 109, 115, 0.28);
+ box-shadow: 0 10px 24px rgba(10, 25, 41, 0.08);
}
.service-action-tags {
@@ -1419,12 +1447,51 @@ header .logout-btn:hover,
padding: 22px;
border-radius: var(--radius-md);
background: #fff;
+ min-height: 100%;
}
.service-panel.visible.auth-panel {
display: block;
}
+.service-panel-empty {
+ display: none;
+}
+
+.service-panel-empty.visible {
+ display: block;
+}
+
+.service-empty-points {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin-top: 20px;
+}
+
+.service-empty-point {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--panel-muted);
+}
+
+.service-empty-point strong {
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-muted);
+}
+
+.service-empty-point span {
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--ink-soft);
+}
+
.service-section-auth {
max-width: 760px;
}
@@ -1611,7 +1678,9 @@ header .logout-btn:hover,
.team-management-grid,
.account-overview-secondary,
.service-shell,
- .service-action-list {
+ .service-action-list,
+ .service-empty-points,
+ .workspace-operations-shell {
grid-template-columns: 1fr;
}