diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 00fc56a4bc..bad69ea8a6 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "db22f20e5c01de6c11aa19319736af2e3d32a613e07002fbcab79c4d07f08e5d", + "source_hash": "52cc4e938d4e2d9faf8baccb4ba63be6dfcbf6172126273c48ed6661ac7231ea", "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 15bbdec7f6..24743bd600 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -165,8 +165,8 @@ header .logout-btn:hover, .portal-hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 18px; - padding: 12px 16px; + gap: 14px; + padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: #fff; @@ -174,20 +174,27 @@ header .logout-btn:hover, } .portal-hero-compact { align-items: center; - padding: 12px 16px; + padding: 10px 14px; } .portal-hero-copy { display: flex; flex-direction: column; - gap: 6px; + gap: 2px; max-width: 900px; } .portal-hero-heading-row { display: flex; align-items: center; - gap: 12px; + gap: 10px; flex-wrap: wrap; } +.portal-hero-brand { + font-size: 26px; + font-weight: 800; + line-height: 1; + letter-spacing: -0.045em; + color: var(--ink); +} .portal-hero-kicker, .account-panel-kicker, .service-card-kicker, @@ -205,29 +212,26 @@ header .logout-btn:hover, .account-eyebrow { color: var(--ink-muted); } -.portal-hero h1, -.intro-card h1 { - margin: 0; - font-size: clamp(1.55rem, 2.2vw, 2.15rem); - line-height: 1; - letter-spacing: -0.045em; -} .portal-hero p, .intro-card p { margin: 0; - font-size: 13px; - line-height: 1.5; + font-size: 12px; + line-height: 1.45; color: var(--ink-soft); } +.portal-hero p strong { + color: var(--ink); + font-weight: 700; +} .portal-hero-chip { display: inline-flex; align-items: center; - min-height: 28px; - padding: 0 12px; + min-height: 24px; + padding: 0 10px; border-radius: 999px; background: #edf5f7; color: var(--accent-strong); - font-size: 11px; + font-size: 10px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; @@ -235,29 +239,29 @@ header .logout-btn:hover, .portal-hero-stats { display: flex; align-items: stretch; - gap: 8px; + gap: 6px; align-self: center; } .portal-hero-stat { display: flex; flex-direction: column; justify-content: center; - min-width: 132px; + min-width: 120px; min-height: 0; - padding: 10px 12px; + padding: 8px 10px; border: 1px solid var(--line); border-radius: 12px; background: #fbfcfd; } .portal-hero-stat-label { - font-size: 12px; + font-size: 11px; font-weight: 700; color: var(--ink-muted); letter-spacing: 0.06em; text-transform: uppercase; } .portal-hero-stat-value { - font-size: 16px; + font-size: 14px; font-weight: 800; line-height: 1.25; letter-spacing: -0.04em; @@ -459,11 +463,11 @@ header .logout-btn:hover, margin-top: 22px; } .account-surface-header { - display: flex; - align-items: start; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; gap: 16px; - padding: 12px 4px 14px; + padding: 6px 4px 12px; border: none; border-bottom: 1px solid var(--line); border-radius: var(--radius-md); @@ -471,17 +475,18 @@ header .logout-btn:hover, box-shadow: none; } .account-heading h2 { - margin: 4px 0 0; - font-size: 27px; + margin: 2px 0 0; + font-size: 24px; line-height: 1.02; letter-spacing: -0.05em; } .account-summary { - margin-top: 6px; - font-size: 13px; + margin-top: 5px; + font-size: 12px; color: var(--ink-soft); } .account-badges, +.account-context-strip, .workspace-badges, .workspace-actions, .account-actions, @@ -492,6 +497,23 @@ header .logout-btn:hover, gap: 10px; flex-wrap: wrap; } +.account-context-strip { + justify-content: flex-end; +} +.account-context-chip { + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: #fbfcfd; + color: var(--ink-soft); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} .workspace-actions { justify-content: flex-end; } diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index b4a0cc86bd..f5a333798b 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -1646,9 +1646,6 @@ if (workspace.last_health_check) return "unhealthy"; return "checking"; } - function roleBadgeHTML(role) { - return '' + escapeHTML(titleCase(role || "member")) + ""; - } function accountKindLabel(account) { if (account.kind === "msp") return "MSP account"; if (account.kind === "cloud") return "Cloud account"; @@ -1736,9 +1733,9 @@ function renderOverviewBand(accounts) { var hosted = hasHostedAccounts(accounts); var workspaceTotal = countWorkspaces(accounts); - var title = hosted ? "Pulse Account" : "Self-hosted Pulse Account"; - var summary = hosted ? "Hosted operations, operator access, and commercial account services live in one account console." : "Billing, license recovery, refunds, and privacy actions live here until hosted access is attached to this email."; - return '
' + (hosted ? "Hosted access is active on this account." : "No hosted workspace access is attached to this account yet.") + '

' + title + '

' + (hosted ? "Operator ready" : "Self-hosted only") + "

" + summary + '

Hosted access' + (hosted ? "Active" : "Not attached") + '
Accounts' + (accounts.length === 1 ? "1 account" : String(accounts.length) + " accounts") + '
Workspace fleet' + (workspaceTotal ? workspaceCountLabel(workspaceTotal) : "0 workspaces") + "
"; + var statusText = hosted ? "Hosted access is active on this account." : "No hosted workspace access is attached to this account yet."; + var summary = hosted ? "Hosted operations, operator access, and commercial account services." : "Billing, license recovery, refunds, and privacy actions until hosted access is attached."; + return '
Pulse Account
' + (hosted ? "Operator ready" : "Self-hosted only") + "

" + statusText + " " + summary + '

Hosted access' + (hosted ? "Active" : "Not attached") + '
Accounts' + (accounts.length === 1 ? "1 account" : String(accounts.length) + " accounts") + '
Workspace fleet' + (workspaceTotal ? workspaceCountLabel(workspaceTotal) : "0 workspaces") + "
"; } function shellSectionButton(section, activeSection, title, copy) { return '"; @@ -1826,7 +1823,8 @@ var serviceHeading = hosted ? "Self-hosted licenses and billing" : "Account services"; var serviceNote = hosted ? "Hosted operations live above. Use these commercial tools for self-hosted licenses, billing, refunds, and privacy actions." : "Use these account tools for self-hosted licenses, billing, refunds, and privacy actions."; var hostedContent = accounts.map(function(account) { - return '
' + renderAccountOverviewSection(account) + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + renderAccountTeamSection(account) + "
"; + var workspaceLabel = workspaceCountLabel((account.workspaces || []).length); + return '
' + renderAccountOverviewSection(account) + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + renderAccountTeamSection(account) + "
"; }).join(""); 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.
Need help with billing, refund, privacy, or license actions? ' + escapeHTML(context.bootstrap.support_email || "") + '

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 || "") + '.

Use support if a billing, refund, privacy, or license flow does not behave as expected for this account.

' + escapeHTML(context.bootstrap.support_email || "") + '
' + renderSupportSection(context) + "
"; } diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 5c2d895277..038135f54c 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -125,7 +125,7 @@ describe('shell view', function() { }) ); - expect(html).toContain('

Pulse Account

'); + expect(html).toContain('portal-hero-brand">Pulse Account'); expect(html).toContain('Hosted access is active on this account.'); expect(html).toContain('portal-shell-nav'); expect(html).toContain('Overview'); @@ -136,8 +136,9 @@ describe('shell view', function() { expect(html).toContain('Self-hosted licenses and billing'); expect(html).toContain('id="accounts-root"'); expect(html).toContain('MSP account'); - expect(html).toContain('MSP account · Owner · 3 workspaces'); expect(html).toContain('Acme MSP'); + expect(html).toContain('Operator workspace account'); + expect(html).toContain('3 workspaces'); expect(html).toContain('Account operations'); expect(html).toContain('Manage the client fleet from this account surface.'); expect(html).toContain('Operator overview'); @@ -186,7 +187,8 @@ describe('shell view', function() { }) ); - expect(html).toContain('

Self-hosted Pulse Account

'); + expect(html).toContain('Pulse Account'); + expect(html).toContain('Self-hosted only'); expect(html).toContain('No hosted workspace access is attached to this account yet.'); expect(html).toContain('Use these tools for self-hosted billing, license recovery, refunds, and privacy.'); expect(html).toContain('Account services'); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 5775bd2db9..c91115bebb 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -205,20 +205,19 @@ function renderAttentionPanel(workspaces: PortalWorkspaceSummary[]): string { function renderOverviewBand(accounts: PortalAccountSummary[]): string { var hosted = hasHostedAccounts(accounts); var workspaceTotal = countWorkspaces(accounts); - var title = hosted ? 'Pulse Account' : 'Self-hosted Pulse Account'; + var statusText = hosted ? 'Hosted access is active on this account.' : 'No hosted workspace access is attached to this account yet.'; var summary = hosted - ? 'Hosted operations, operator access, and commercial account services live in one account console.' - : 'Billing, license recovery, refunds, and privacy actions live here until hosted access is attached to this email.'; + ? 'Hosted operations, operator access, and commercial account services.' + : 'Billing, license recovery, refunds, and privacy actions until hosted access is attached.'; return ( '
' + '
' + - '
' + (hosted ? 'Hosted access is active on this account.' : 'No hosted workspace access is attached to this account yet.') + '
' + '
' + - '

' + title + '

' + + '
Pulse Account
' + '' + (hosted ? 'Operator ready' : 'Self-hosted only') + '' + '
' + - '

' + summary + '

' + + '

' + statusText + ' ' + summary + '

' + '
' + '
' + '
' + @@ -678,17 +677,19 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string ? 'Hosted operations live above. Use these commercial tools for self-hosted licenses, billing, refunds, and privacy actions.' : 'Use these account tools for self-hosted licenses, billing, refunds, and privacy actions.'; var hostedContent = accounts.map(function(account) { + var workspaceLabel = workspaceCountLabel((account.workspaces || []).length); return ( '