From 06dc6adc3a2d2c3814b2918348550e76d7c24034 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 27 Mar 2026 22:56:40 +0000 Subject: [PATCH] style(portal): add operator runbooks to overview and services --- .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.css | 76 ++++++++++++++-- internal/cloudcp/portal/dist/portal_app.js | 5 +- .../portal/frontend/src/shell_view.test.ts | 7 +- .../cloudcp/portal/frontend/src/shell_view.ts | 80 ++++++++++------- .../cloudcp/portal/frontend/src/styles.css | 87 +++++++++++++++++-- 6 files changed, 208 insertions(+), 49 deletions(-) diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index f516e00b9c..a3864c0737 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "69bb383eb566fc7cd58d13fdd5aad0d3d5b2b43107d66fbebc9d700607af2d26", + "source_hash": "0401fbaf26ce16d3985c1cca3d6116f9fc2f3cb72e756d1bb8bca91d0d8633e2", "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 29cf3ac020..7ce8fca438 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -720,6 +720,31 @@ header .logout-btn:hover, .overview-side-card-primary { background: var(--panel-strong); } +.overview-next-checklist { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 16px; +} +.overview-next-check { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.86); +} +.overview-next-check strong { + font-size: 13px; + line-height: 1.3; + color: var(--ink); +} +.overview-next-check span { + font-size: 12px; + line-height: 1.55; + color: var(--ink-soft); +} .overview-next-actions { display: flex; gap: 10px; @@ -1405,7 +1430,7 @@ header .logout-btn:hover, .service-shell-sidebar { display: flex; flex-direction: column; - gap: 12px; + gap: 10px; } .service-shell-sidebar-head, .service-inline-support { @@ -1456,16 +1481,20 @@ header .logout-btn:hover, line-height: 1.6; color: var(--ink-soft); } -.service-empty-columns { +.service-empty-command-grid { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(280px, 0.92fr); + grid-template-columns: minmax(220px, 0.82fr) minmax(0, 1.18fr); gap: 14px; margin-top: 18px; } -.service-empty-column { +.service-empty-command-card { display: flex; flex-direction: column; gap: 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-muted); } .service-empty-column-title { font-size: 12px; @@ -1582,10 +1611,18 @@ header .logout-btn:hover, grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.service-empty-points-stack { + grid-template-columns: 1fr; +} +.service-empty-command-card-wide { + grid-column: 2; + background: #fff; +} +.service-empty-command-card-support { + grid-column: 1 / -1; +} .service-empty-support { - margin-top: 16px; - padding-top: 14px; - border-top: 1px solid var(--line); + margin-top: 2px; font-size: 13px; line-height: 1.55; color: var(--ink-soft); @@ -1633,6 +1670,31 @@ header .logout-btn:hover, line-height: 1.5; color: var(--ink-soft); } +.service-empty-flow-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.service-empty-flow { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-muted); +} +.service-empty-flow strong { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); +} +.service-empty-flow span { + font-size: 12px; + line-height: 1.45; + color: var(--ink-soft); +} .service-section-auth { max-width: 760px; } diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 692c877cae..d2e4ae10dd 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -1818,6 +1818,7 @@ var addWorkspaceForm = ""; var nextStepTitle = unhealthyCount > 0 ? "Start in Workspaces" : checkingCount > 0 ? "Review pending checks" : "Fleet is clear"; var nextStepCopy = unhealthyCount > 0 ? "One or more workspaces need review before you treat the hosted fleet as trustworthy." : checkingCount > 0 ? "The fleet is mostly healthy, but there are still workspaces waiting on a completed health check." : "Hosted posture looks stable. Move into team or account services only if you need to change access or billing."; + var nextStepChecklist = unhealthyCount > 0 ? '
1. Review attention itemsOpen the fleet and inspect any workspace marked as checking or needs attention.
2. Resolve operator blockersUse Team if the right people are not already attached to the hosted account.
3. Escalate billing separatelyKeep account billing or self-hosted license work out of the workspace review flow.
' : checkingCount > 0 ? '
1. Verify pending health checksOpen the workspaces still settling and confirm they are safe to operate.
2. Keep the roster leanReview Team only if a pending workspace needs a different operator mix.
3. Leave billing as a separate actionUse account services or billing only when the hosted fleet is already understood.
' : '
1. Use Workspaces for the next operational taskOpen a client workspace directly when you are ready to do hosted work.
2. Use Team for access changes onlyKeep roster changes explicit instead of mixing them into routine workspace work.
3. Use account services when the task is commercialLicenses, refunds, privacy, and self-hosted billing stay in their own section.
'; var nextStepActions = '
"; if (account.can_manage) { if (account.kind === "msp") { @@ -1826,7 +1827,7 @@ } return '
"; + ) + '
" + addWorkspaceForm + '

' + escapeHTML(nextStepTitle) + "

" + escapeHTML(nextStepCopy) + "

" + nextStepChecklist + nextStepActions + "
" + renderAttentionPanel(workspaces) + "
"; } function renderAccountWorkspaceSection(account, accountAPIBasePath) { var workspaces = Array.isArray(account.workspaces) ? account.workspaces : []; @@ -1872,7 +1873,7 @@ var hostedContent = accounts.map(function(account) { return '
"; }).join(""); - return '
' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '
' + (accounts.length === 1 ? renderPrimaryAccountBar(accounts[0]) : "") + '
' + hostedContent + '

' + serviceHeading + '

' + serviceNote + '

Choose the next commercial 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 operating desk instead of a list of disconnected tools.

Start here
BillingOpen Stripe customer portal access after verification.
LicensesRecover the latest active self-hosted license and invoice link.
RefundsConfirm eligibility before revoking active commercial access.
PrivacyRequest export or deletion without leaving Pulse Account.
What to expect
Verification firstEach flow confirms the commercial email before opening sensitive account actions.
One task at a timeThe active commercial request stays in this panel until you finish or switch tools.
Support stays closeIf billing, licenses, refunds, or privacy behave unexpectedly, escalate from this surface.
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) + "
"; + return '
' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '
' + (accounts.length === 1 ? renderPrimaryAccountBar(accounts[0]) : "") + '
' + hostedContent + '

' + serviceHeading + '

' + serviceNote + '

Choose the next commercial action

Open a billing, license, refund, or privacy flow from the service navigator. The active request stays here so this area behaves like one commercial operating desk.

Start here
Verify firstEach flow confirms the commercial email before opening sensitive actions.
One task at a timeKeep the active request in this desk until you finish or switch tools.
Available flows
BillingStripe customer portal access after verification.
LicensesRecover the latest active self-hosted license and invoice link.
RefundsConfirm eligibility before revoking active commercial access.
PrivacyRequest export or deletion without leaving Pulse Account.
Support
Escalate quicklyIf billing, licenses, refunds, or privacy behave unexpectedly, escalate from this surface.
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 || "") + '.
' + renderSupportSection(context) + "
"; } function renderSignedOutPortalHTML(context) { var statusHTML = ""; diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 03816d0fb4..25896acb2a 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -179,7 +179,9 @@ describe('shell view', function() { expect(html).toContain('service-action-button'); expect(html).toContain('id="service-panel-empty"'); expect(html).toContain('Choose the next commercial action'); - expect(html).toContain('What to expect'); + expect(html).toContain('Task desk'); + expect(html).toContain('Available flows'); + expect(html).toContain('Escalate quickly'); expect(html).toContain('id="open-retrieve-service"'); expect(html).toContain('id="data-service-panel"'); }); @@ -195,7 +197,8 @@ describe('shell view', function() { expect(html).toContain('Pulse Account'); expect(html).toContain('Account console'); - expect(html).toContain('Use these tools for self-hosted commercial actions.'); + expect(html).toContain('Use these account tools for self-hosted licenses, billing, refunds, and privacy actions.'); + expect(html).toContain('Keep self-hosted commercial work here.'); expect(html).toContain('Account services'); expect(html).not.toContain('Self-hosted licenses and billing'); }); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 7e15172fed..2897cc2e90 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -358,6 +358,29 @@ function renderAccountOverviewSection(account: PortalAccountSummary): string { : checkingCount > 0 ? 'The fleet is mostly healthy, but there are still workspaces waiting on a completed health check.' : 'Hosted posture looks stable. Move into team or account services only if you need to change access or billing.'; + var nextStepChecklist = unhealthyCount > 0 + ? ( + '
' + + '
1. Review attention itemsOpen the fleet and inspect any workspace marked as checking or needs attention.
' + + '
2. Resolve operator blockersUse Team if the right people are not already attached to the hosted account.
' + + '
3. Escalate billing separatelyKeep account billing or self-hosted license work out of the workspace review flow.
' + + '
' + ) + : checkingCount > 0 + ? ( + '
' + + '
1. Verify pending health checksOpen the workspaces still settling and confirm they are safe to operate.
' + + '
2. Keep the roster leanReview Team only if a pending workspace needs a different operator mix.
' + + '
3. Leave billing as a separate actionUse account services or billing only when the hosted fleet is already understood.
' + + '
' + ) + : ( + '
' + + '
1. Use Workspaces for the next operational taskOpen a client workspace directly when you are ready to do hosted work.
' + + '
2. Use Team for access changes onlyKeep roster changes explicit instead of mixing them into routine workspace work.
' + + '
3. Use account services when the task is commercialLicenses, refunds, privacy, and self-hosted billing stay in their own section.
' + + '
' + ); var nextStepActions = '
' + '' + @@ -429,6 +452,7 @@ function renderAccountOverviewSection(account: PortalAccountSummary): string { '' + '

' + escapeHTML(nextStepTitle) + '

' + '

' + escapeHTML(nextStepCopy) + '

' + + nextStepChecklist + nextStepActions + '
' + renderAttentionPanel(workspaces) + @@ -765,7 +789,7 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string '
' + '' + '

Billing, licenses, refunds, and privacy

' + - '

Use these tools for self-hosted commercial actions. Hosted workspace operations stay in Workspaces and Team.

' + + '

Keep self-hosted commercial work here. Hosted workspace operations stay in Workspaces and Team.

' + '
' + '
' + renderServiceActionRow('open-manage-service', 'Billing', 'Manage subscriptions', 'Open Stripe billing access for existing self-hosted subscriptions without leaving the Pulse Account shell.', 'manage-service-panel', 'manage-inline-email', ['Invoices and plan changes', 'Subscription self-service']) + @@ -777,33 +801,38 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string '
' + '
' + '
' + - '' + + '' + '

Choose the next commercial 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 operating desk instead of a list of disconnected tools.

' + - '
' + - '
' + + '

Open a billing, license, refund, or privacy flow from the service navigator. The active request stays here so this area behaves like one commercial operating desk.

' + + '
' + + '
' + '
Start here
' + - '
' + - '
BillingOpen Stripe customer portal access after verification.
' + - '
LicensesRecover the latest active self-hosted license and invoice link.
' + - '
RefundsConfirm eligibility before revoking active commercial access.
' + - '
PrivacyRequest export or deletion without leaving Pulse Account.
' + + '
' + + '
Verify firstEach flow confirms the commercial email before opening sensitive actions.
' + + '
One task at a timeKeep the active request in this desk until you finish or switch tools.
' + '
' + '
' + - '
' + - '
What to expect
' + - '
' + - '
Verification firstEach flow confirms the commercial email before opening sensitive account actions.
' + - '
One task at a timeThe active commercial request stays in this panel until you finish or switch tools.
' + - '
Support stays closeIf billing, licenses, refunds, or privacy behave unexpectedly, escalate from this surface.
' + + '
' + + '
Available flows
' + + '
' + + '
BillingStripe customer portal access after verification.
' + + '
LicensesRecover the latest active self-hosted license and invoice link.
' + + '
RefundsConfirm eligibility before revoking active commercial access.
' + + '
PrivacyRequest export or deletion without leaving Pulse Account.
' + '
' + '
' + + '
' + + '
Support
' + + '
' + + '
Escalate quicklyIf billing, licenses, refunds, or privacy behave unexpectedly, escalate from this surface.
' + + '
' + + '
Need help with billing, refund, privacy, or license actions? ' + + escapeHTML(context.bootstrap.support_email || '') + + '
' + + '
' + '
' + - '
Need help with billing, refund, privacy, or license actions? ' + - escapeHTML(context.bootstrap.support_email || '') + - '
' + '
' + '
' + '
' + @@ -820,15 +849,6 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string '.
' + '
' + '
' + - '
' + - '' + - '

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

' + - '' + - escapeHTML(context.bootstrap.support_email || '') + - '' + - '
' + '
' + '
' + '' + diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index 7a08d9c68a..e0649ddf1b 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -795,6 +795,35 @@ header .logout-btn:hover, background: var(--panel-strong); } +.overview-next-checklist { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 16px; +} + +.overview-next-check { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.86); +} + +.overview-next-check strong { + font-size: 13px; + line-height: 1.3; + color: var(--ink); +} + +.overview-next-check span { + font-size: 12px; + line-height: 1.55; + color: var(--ink-soft); +} + .overview-next-actions { display: flex; gap: 10px; @@ -1589,7 +1618,7 @@ header .logout-btn:hover, .service-shell-sidebar { display: flex; flex-direction: column; - gap: 12px; + gap: 10px; } .service-shell-sidebar-head, @@ -1649,17 +1678,21 @@ header .logout-btn:hover, color: var(--ink-soft); } -.service-empty-columns { +.service-empty-command-grid { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(280px, 0.92fr); + grid-template-columns: minmax(220px, 0.82fr) minmax(0, 1.18fr); gap: 14px; margin-top: 18px; } -.service-empty-column { +.service-empty-command-card { display: flex; flex-direction: column; gap: 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-muted); } .service-empty-column-title { @@ -1793,10 +1826,21 @@ header .logout-btn:hover, gap: 10px; } +.service-empty-points-stack { + grid-template-columns: 1fr; +} + +.service-empty-command-card-wide { + grid-column: 2; + background: #fff; +} + +.service-empty-command-card-support { + grid-column: 1 / -1; +} + .service-empty-support { - margin-top: 16px; - padding-top: 14px; - border-top: 1px solid var(--line); + margin-top: 2px; font-size: 13px; line-height: 1.55; color: var(--ink-soft); @@ -1852,6 +1896,35 @@ header .logout-btn:hover, color: var(--ink-soft); } +.service-empty-flow-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.service-empty-flow { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-muted); +} + +.service-empty-flow strong { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.service-empty-flow span { + font-size: 12px; + line-height: 1.45; + color: var(--ink-soft); +} + .service-section-auth { max-width: 760px; }