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 ? '
Review the hosted fleet, open a workspace, and keep lifecycle actions explicit.
' + String(workspaces.length) + ' total' + String(healthyCount) + ' healthy' + String(checkingCount) + ' checking' + String(unhealthyCount) + ' needs attention
WorkspaceHealthLifecycleActions
' + workspaces.map(function(workspace) { return renderWorkspaceCard(account, workspace, accountAPIBasePath); }).join("") + "
" : '

No hosted workspaces yet. Create one to get started.

'; - return '

Hosted fleet

Use the fleet view to open workspaces, watch health posture, and keep management actions explicit.

' + workspaceHTML + '
' + workspaceManagement + "
"; + return '

Hosted fleet

Use the fleet view to open workspaces, watch health posture, and keep management actions explicit.

' + workspaceHTML + '
' + workspaceManagement + "
"; } function renderAccountTeamSection(account) { return '

Control who can operate this account

Owners manage billing and access. Admins and techs keep the hosted fleet running day to day.

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.

Loading\u2026

Invite someone new

Add another operator with the minimum role they need for this account.

AdminBilling and hosted operations.
TechOperational access without billing control.
Read-onlyReview hosted state without making changes.
'; @@ -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) + '
' + hostedContent + '

' + serviceHeading + '

' + serviceNote + '

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) + '
' + hostedContent + '

' + serviceHeading + '

' + serviceNote + '

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.

BillingOpen Stripe customer portal access after verification.
LicensesRecover the latest active self-hosted license and invoice link.
PrivacyRequest 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 || '') + - '.
' + + '
' + + '
' + + '' + + '

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.

' + + '
' + + '
BillingOpen Stripe customer portal access after verification.
' + + '
LicensesRecover the latest active self-hosted license and invoice link.
' + + '
PrivacyRequest 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; }