Make portal shell labels literal

This commit is contained in:
rcourtman
2026-03-29 16:53:12 +01:00
parent a7fb6cc3fb
commit dccdc59ddb
9 changed files with 135 additions and 76 deletions
@@ -264,7 +264,11 @@ Core rules:
40. Portal task and status copy must stay literal. Customer-facing copy may
not rely on commentary such as `obvious`, `actual work`, `trustworthy`,
or `settled` when the runtime already knows the concrete state, action,
or failure being shown.
or failure being shown. The same rule applies to shell badges, section
labels, and error headings: they must say the exact state or action (for
example `Manage access`, `Hosted billing attached`, or `Failed to load
roster`) instead of shorthand such as `Manage`, `Hosted`, `Email`, or
generic alert labels.
## Screen Model
@@ -374,7 +374,10 @@ That same typed portal runtime contract must also keep task and status copy
literal across the account surface: customer-facing wording may not use
commentary such as `obvious`, `actual work`, `trustworthy`, or `settled` when
the runtime already knows the concrete state, action, or failure being
rendered.
rendered. The same typed contract applies to shell badges, section labels,
and error headings: they must render the exact action or state (`Manage
access`, `Hosted billing attached`, `Failed to load roster`) instead of
shorthand such as `Manage`, `Hosted`, or generic alert labels.
That same typed overview contract must also preserve a sharp, high-density enterprise visual aesthetic (e.g. Cloudflare/GCP density standards) across all portal scenarios, removing gradients and heavy box-shadows to ensure a calm, rigorous visual language with standard 256px sidebars, Inter-grade typography, clean text-transform rules, and cleanly unboxed typography without excessive pills or stacked metrics.
plus a package-local `tsc --noEmit` gate, so future account-shell work should
extend the typed source boundary instead of reviving opaque global runtime
@@ -942,6 +942,10 @@ That same portal shell/runtime boundary must also keep task and status copy
literal across the account surface: customer-facing wording may not lean on
commentary such as `obvious`, `actual work`, `trustworthy`, or `settled` when
the runtime already knows the concrete state, action, or failure being shown.
That includes shell badges, section labels, and error headings: they must use
the exact action or state (`Manage access`, `Hosted billing attached`,
`Failed to load roster`) instead of shorthand such as `Manage`, `Hosted`, or
generic alert labels.
That same canonical shell/runtime boundary now also owns the bootstrap truth
for when self-hosted commercial history is relevant. Hosted-only accounts must
not render self-hosted license, refund, privacy, or support-escalation copy
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "21a6dc0fc99f359ec17be255e817006a957679e26097181db88399b969d7aadf",
"source_hash": "646baefa83486237ab1a2dabf6fb5fb019d16f45cbe8632aa8e881d986d9bb71",
"build_inputs": [
"package.json",
"tsconfig.json",
+38 -19
View File
@@ -240,11 +240,11 @@
return;
}
if (entry.accessQuery.status === "loading") {
stats.innerHTML = '<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value">Loading\u2026</span></div><div class="access-stat-card"><span class="access-stat-label">Mode</span><span class="access-stat-value">' + (canManage ? "Manage" : "View") + "</span></div>";
stats.innerHTML = '<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value">Loading\u2026</span></div><div class="access-stat-card"><span class="access-stat-label">Access</span><span class="access-stat-value">' + (canManage ? "Manage access" : "View roster") + "</span></div>";
return;
}
if (entry.accessQuery.status === "error") {
stats.innerHTML = '<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value access-stat-error">Needs attention</span></div><div class="access-stat-card"><span class="access-stat-label">Mode</span><span class="access-stat-value">' + (canManage ? "Manage" : "View") + "</span></div>";
stats.innerHTML = '<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value access-stat-error">Load failed</span></div><div class="access-stat-card"><span class="access-stat-label">Access</span><span class="access-stat-value">' + (canManage ? "Manage access" : "View roster") + "</span></div>";
return;
}
var members = entry.accessQuery.data;
@@ -411,7 +411,7 @@
}
if (entry.accessQuery.status === "error") {
if (rosterPanel) rosterPanel.classList.add("state-only");
setContainerMessage(roster, "Roster needs attention", entry.accessQuery.error, true);
setContainerMessage(roster, "Failed to load roster", entry.accessQuery.error, true);
return;
}
if (!entry.accessQuery.data.length) {
@@ -1988,6 +1988,25 @@
function suspendedWorkspaceChipLabel(count) {
return count === 1 ? "1 suspended workspace" : String(count) + " suspended workspaces";
}
function overviewNavBadgeLabel(hosted, attentionCount, readyCount) {
if (!hosted) return "Billing available";
if (attentionCount > 0) return reviewWorkspaceChipLabel(attentionCount);
return readyWorkspaceChipLabel(readyCount);
}
function workspacesNavBadgeLabel(hosted, totalWorkspaces) {
if (!hosted) return "Unavailable";
return workspaceCountLabel(totalWorkspaces);
}
function accessNavBadgeLabel(hosted, canManage) {
if (!hosted) return "Unavailable";
return canManage ? "Manage access" : "View roster";
}
function billingNavBadgeLabel(hostedBillingCount) {
return hostedBillingCount > 0 ? "Hosted billing" : "Self-hosted billing";
}
function supportNavBadgeLabel() {
return "Escalation only";
}
function hasHostedAccounts(accounts) {
return accounts.length > 0;
}
@@ -2072,7 +2091,7 @@
return results;
}
function accountContextRoleMeta(account) {
return portalRoleLabel(account.role) + (account.can_manage ? " access" : " role");
return portalRoleLabel(account.role) + " role";
}
function accountContextLeadCopy(account) {
var accountPrefix = account.kind === "msp" ? "Hosted workspace account" : "Hosted account";
@@ -2082,11 +2101,11 @@
return accountPrefix + (account.has_billing ? " where you can open workspaces and review who already has access. An owner or admin handles access changes and billing." : " where you can open workspaces and review who already has access. An owner or admin handles account changes.");
}
function accountContextAccessSummary(account) {
return account.can_manage ? portalRoleLabel(account.role) : "View only";
return account.can_manage ? "Manage access" : "View roster";
}
function accountContextBillingSummary(account) {
if (!account.has_billing) return "Not attached";
return account.can_manage ? "Billing enabled" : "Owner/admin required";
return account.can_manage ? "Hosted billing attached" : "Owner/admin required";
}
function renderAccountContextStrip(account) {
var workspaceLabel = workspaceCountLabel((account.workspaces || []).length);
@@ -2153,7 +2172,7 @@
}
}
}
return '<aside class="portal-shell-nav" aria-label="Pulse Account sections"><div class="portal-shell-nav-header"><div class="portal-shell-nav-eyebrow">Pulse Account</div><div class="portal-shell-nav-title">Account tasks</div><div class="portal-shell-nav-support">' + (hosted ? "Workspaces, Access, Billing, then Support." : "Billing first. Support only after the billing path fails.") + '</div></div><div class="portal-shell-nav-group">' + shellSectionButton("overview", activeSection, "01", "Overview", "Review status, ready workspaces, and the next action.", attentionCount > 0 ? String(attentionCount) + " review" : hosted ? String(readyWorkspaces) + " ready" : "Summary") + shellSectionButton("workspaces", activeSection, "02", "Workspaces", workspaceNavCopy(hosted, canManage), hosted ? String(readyWorkspaces) + " ready" : "Unavailable") + shellSectionButton("access", activeSection, "03", "Access", accessNavCopy(hosted, canManage), hosted ? canManage ? "Manage" : "View" : "Unavailable") + shellSectionButton("billing", activeSection, "04", "Billing", billingNavCopy(hostedBillingCount, canManageHostedBilling), hostedBillingCount > 0 ? hostedBillingCount > 1 ? "Hosted +" : "Hosted" : "Self-hosted") + shellSectionButton("support", activeSection, "05", "Support", supportNavCopy(hosted, canManage), supportEmail ? "Email" : "Help") + "</div></aside>";
return '<aside class="portal-shell-nav" aria-label="Pulse Account sections"><div class="portal-shell-nav-header"><div class="portal-shell-nav-eyebrow">Pulse Account</div><div class="portal-shell-nav-title">Account tasks</div><div class="portal-shell-nav-support">' + (hosted ? "Use Workspaces, then Access, then Billing. Use Support only if those paths fail." : "Use Billing first. Use Support only if that path fails.") + '</div></div><div class="portal-shell-nav-group">' + shellSectionButton("overview", activeSection, "01", "Overview", "Review workspace counts, current state, and the current action.", overviewNavBadgeLabel(hosted, attentionCount, readyWorkspaces)) + shellSectionButton("workspaces", activeSection, "02", "Workspaces", workspaceNavCopy(hosted, canManage), workspacesNavBadgeLabel(hosted, totalWorkspaces)) + shellSectionButton("access", activeSection, "03", "Access", accessNavCopy(hosted, canManage), accessNavBadgeLabel(hosted, canManage)) + shellSectionButton("billing", activeSection, "04", "Billing", billingNavCopy(hostedBillingCount, canManageHostedBilling), billingNavBadgeLabel(hostedBillingCount)) + shellSectionButton("support", activeSection, "05", "Support", supportNavCopy(hosted, canManage), supportNavBadgeLabel()) + "</div></aside>";
}
function renderWorkspaceCard(account, workspace, accountAPIBasePath) {
var status = workspaceHealthState(workspace);
@@ -2218,7 +2237,7 @@
) + '</p><div class="overview-task-list"><div class="overview-task-item"><strong>Ready</strong><span>' + escapeHTML(
entries.length > 0 ? readyWorkspaceHeadline(ready.length) : accounts.length > 0 ? readyWorkspaceHeadline(0) : "0 hosted workspaces are ready to use"
) + '</span></div><div class="overview-task-item"><strong>Suspended</strong><span>' + escapeHTML(
suspendedCount > 0 ? suspendedCount === 1 ? "1 workspace is suspended and excluded from routine use until you resume it." : String(suspendedCount) + " workspaces are suspended and excluded from routine use until you resume them." : "0 suspended workspaces."
suspendedCount > 0 ? suspendedCount === 1 ? "1 workspace is suspended. Resume it before opening it again." : String(suspendedCount) + " workspaces are suspended. Resume them before opening them again." : "0 suspended workspaces."
) + "</span></div></div></article>";
}
return '<article class="overview-task-card overview-task-card-attention"><div class="account-panel-kicker">Needs attention</div><h4>' + escapeHTML(reviewWorkspaceHeadline(attention.length)) + '</h4><p>Each listed workspace is failed or still waiting on a completed health check.</p><div class="overview-task-list">' + attention.slice(0, 3).map(function(entry) {
@@ -2239,7 +2258,7 @@
return '<article class="overview-task-card"><div class="account-panel-kicker">Ready</div><h4>' + escapeHTML(
!accounts.length ? "Billing is available" : readyWorkspaceHeadline(0)
) + "</h4><p>" + escapeHTML(
!accounts.length ? "Use Billing for self-hosted subscriptions, licenses, refunds, and privacy requests." : totalWorkspaces > 0 ? suspendedCount === totalWorkspaces ? "Every hosted workspace is suspended right now." : "Open Workspaces for the current workspace state before routine use." : canManageHosted ? "The first hosted workspace still needs to be created before routine work can start." : "An owner or admin still needs to create the first hosted workspace before routine work can start."
!accounts.length ? "Use Billing for self-hosted subscriptions, licenses, refunds, and privacy requests." : totalWorkspaces > 0 ? suspendedCount === totalWorkspaces ? "Every hosted workspace is suspended right now." : "Open Workspaces to see the current state of each hosted workspace." : canManageHosted ? "No hosted workspace exists yet. Create the first one in Workspaces." : "No hosted workspace exists yet. An owner or admin must create the first one."
) + "</p></article>";
}
return '<article class="overview-task-card"><div class="account-panel-kicker">Ready</div><h4>' + escapeHTML(readyWorkspaceHeadline(ready.length)) + '</h4><p>Each listed workspace is active and passed its latest health check.</p><div class="overview-task-list">' + ready.slice(0, 3).map(function(entry) {
@@ -2265,10 +2284,10 @@
}) || null;
var hostedViewOnly = accounts.length > 0 && !accessAccount;
if (attention.length) {
title = "Review workspace health";
description = attention.length > 1 ? "Open Workspaces. Review each failed or pending workspace before taking another account action." : "Open Workspaces. Review " + attention[0].workspace.display_name + " before taking another account action.";
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Review workspaces</button>';
secondaryAction = accessAccount ? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>' : "";
title = "Open Workspaces";
description = attention.length > 1 ? "Open Workspaces to review each failed or pending workspace." : "Open Workspaces to review " + attention[0].workspace.display_name + ".";
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Open Workspaces</button>';
secondaryAction = accessAccount ? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>' : "";
} else if (ready.length) {
title = "Open workspace";
description = accounts.length > 1 ? "Open " + ready[0].workspace.display_name + " in " + ready[0].account.name + "." : "Open the ready workspace.";
@@ -2278,12 +2297,12 @@
title = "Create workspace";
description = "No workspace is ready. Create a workspace in " + creatableAccount.name + ".";
primaryAction = '<button class="btn-primary btn-compact" type="button" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(creatableAccount.id) + '">Create workspace</button>';
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Manage access</button>';
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
} else if (billingAccount) {
title = "Open billing";
description = "Use Billing for invoices, payment methods, or subscription changes.";
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="billing">Open billing</button>';
secondaryAction = accessAccount ? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>' : "";
secondaryAction = accessAccount ? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>' : "";
} else if (!accounts.length) {
title = "Open billing";
description = "Use Billing for self-hosted subscriptions, licenses, refunds, or privacy requests.";
@@ -2292,12 +2311,12 @@
if (totalWorkspaces > 0) {
title = "Review workspace state";
description = "No workspace is ready. Open Workspaces to review current state, then hand off changes to an owner or admin.";
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Review workspaces</button>';
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>';
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Open Workspaces</button>';
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
} else {
title = "Review who can act";
description = showSelfHostedCommercial ? "No hosted workspace is attached. Review Access to see who can manage this hosted account, or use Billing for self-hosted tasks." : "No hosted workspace is attached yet. Review Access to see who can create or manage the first workspace on this account.";
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>';
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
secondaryAction = showSelfHostedCommercial ? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="billing">Open billing</button>' : "";
}
} else if (accessAccount) {
@@ -2328,7 +2347,7 @@
reviewWorkspaceChipLabel(attentionCount),
suspendedWorkspaceChipLabel(suspendedCount)
] : ["No hosted account", "0 hosted workspaces", "Billing available", "Support only on escalation"];
return '<section class="account-content-panel account-content-panel-overview"><div class="account-stage-header account-stage-header-overview overview-stage-header"><div><div class="account-panel-kicker">Overview</div><h3>Account state</h3><p>Current hosted workspace state, readiness, and next action.</p>' + renderSectionContextChips(chips) + '</div></div><div class="overview-task-grid">' + renderOverviewAttentionCard(accounts, entries, showSelfHostedCommercial) + renderOverviewReadyCard(accounts, entries, context.accountAPIBasePath) + renderOverviewNextActionCard(accounts, entries, context.accountAPIBasePath, showSelfHostedCommercial) + "</div></section>";
return '<section class="account-content-panel account-content-panel-overview"><div class="account-stage-header account-stage-header-overview overview-stage-header"><div><div class="account-panel-kicker">Overview</div><h3>Account state</h3><p>Hosted workspace counts, current state, and current action.</p>' + renderSectionContextChips(chips) + '</div></div><div class="overview-task-grid">' + renderOverviewAttentionCard(accounts, entries, showSelfHostedCommercial) + renderOverviewReadyCard(accounts, entries, context.accountAPIBasePath) + renderOverviewNextActionCard(accounts, entries, context.accountAPIBasePath, showSelfHostedCommercial) + "</div></section>";
}
function renderNoHostedWorkspacesSection() {
return '<section class="account-content-panel account-content-panel-workspaces"><div class="account-stage-header"><div><div class="account-panel-kicker">Workspaces</div><h3>Workspaces</h3><p>No hosted workspace is attached to this account.</p>' + renderSectionContextChips(["None attached", "Billing instead"]) + '</div></div><div class="empty-state empty-state-spaced"><p>There is nothing to open or manage here yet.</p><p class="support-copy">Use Billing for self-hosted subscriptions, licenses, refunds, or privacy requests.</p></div></section>';
@@ -104,7 +104,7 @@ describe('account view', function() {
expect(document.getElementById('add-ws-form-acct_1')?.classList.contains('visible')).toBe(false);
});
it('renders team loading, error, and populated member states', function() {
it('renders access loading, error, and populated member states', function() {
document.body.innerHTML =
'<div id="access-section-acct_1" class="access-section" data-actor-role="owner" data-can-manage="true">' +
'<div id="access-stats-acct_1"></div>' +
@@ -116,16 +116,17 @@ describe('account view', function() {
accessQuery: { status: 'loading', error: '', data: [] },
}));
expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Loading roster');
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Mode');
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Manage');
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Access');
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Manage access');
renderAccessSection('acct_1', createEntry({
accessVisible: true,
accessQuery: { status: 'error', error: 'Failed to load access roster.', data: [] },
}));
expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Roster needs attention');
expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Failed to load roster');
expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Failed to load access roster.');
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Manage');
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Load failed');
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Manage access');
renderAccessSection(
'acct_1',
@@ -179,13 +179,13 @@ function renderAccessStats(accountID: string, entry: PortalAccountUIEntry, canMa
if (entry.accessQuery.status === 'loading') {
stats.innerHTML =
'<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value">Loading…</span></div>' +
'<div class="access-stat-card"><span class="access-stat-label">Mode</span><span class="access-stat-value">' + (canManage ? 'Manage' : 'View') + '</span></div>';
'<div class="access-stat-card"><span class="access-stat-label">Access</span><span class="access-stat-value">' + (canManage ? 'Manage access' : 'View roster') + '</span></div>';
return;
}
if (entry.accessQuery.status === 'error') {
stats.innerHTML =
'<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value access-stat-error">Needs attention</span></div>' +
'<div class="access-stat-card"><span class="access-stat-label">Mode</span><span class="access-stat-value">' + (canManage ? 'Manage' : 'View') + '</span></div>';
'<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value access-stat-error">Load failed</span></div>' +
'<div class="access-stat-card"><span class="access-stat-label">Access</span><span class="access-stat-value">' + (canManage ? 'Manage access' : 'View roster') + '</span></div>';
return;
}
@@ -384,7 +384,7 @@ export function renderAccessSection(accountID: string, entry: PortalAccountUIEnt
}
if (entry.accessQuery.status === 'error') {
if (rosterPanel) rosterPanel.classList.add('state-only');
setContainerMessage(roster, 'Roster needs attention', entry.accessQuery.error, true);
setContainerMessage(roster, 'Failed to load roster', entry.accessQuery.error, true);
return;
}
if (!entry.accessQuery.data.length) {
@@ -136,17 +136,17 @@ describe('shell view', function() {
expect(html).toContain('Billing');
expect(html).toContain('Support');
expect(html).toContain('Account tasks');
expect(html).toContain('Review status, ready workspaces, and the next action.');
expect(html).toContain('Review workspace counts, current state, and the current action.');
expect(html).toContain('1 account');
expect(html).toContain('3 workspaces');
expect(html).toContain('1 ready workspace');
expect(html).toContain('2 workspaces to review');
expect(html).toContain('Manage');
expect(html).toContain('Manage access');
expect(html).toContain('id="billing-section"');
expect(html).toContain('portal-account-context');
expect(html).toContain('Owner access');
expect(html).toContain('Owner role');
expect(html).toContain('portal-account-context-summary');
expect(html).toContain('Billing enabled');
expect(html).toContain('Hosted billing attached');
expect(html).toContain('id="accounts-root"');
expect(html).toContain('MSP account');
expect(html).toContain('Acme MSP');
@@ -156,15 +156,15 @@ describe('shell view', function() {
expect(html).not.toContain('Manage billing');
expect(html).not.toContain('Manage team');
expect(html).toContain('Account state');
expect(html).toContain('Current hosted workspace state, readiness, and next action.');
expect(html).toContain('Hosted workspace counts, current state, and current action.');
expect(html).toContain('section-context-strip');
expect(html).toContain('Needs attention');
expect(html).toContain('Ready');
expect(html).toContain('Next action');
expect(html).toContain('2 workspaces need review');
expect(html).toContain('1 workspace is ready to use');
expect(html).toContain('Review workspaces');
expect(html).toContain('Review access');
expect(html).toContain('Open Workspaces');
expect(html).toContain('Open Access');
expect(html).toContain('account-stage-header-actions');
expect(html).toContain('0 suspended workspaces');
expect(html).toContain('Alpha Workspace');
@@ -348,9 +348,10 @@ describe('shell view', function() {
expect((html.match(/portal-account-context\"/g) || []).length).toBe(1);
expect((html.match(/portal-account-context-stat/g) || []).length).toBe(3);
expect((html.match(/account-context-chip\"/g) || []).length).toBe(3);
expect(html).toContain('Admin access');
expect(html).toContain('Admin role');
expect(html).toContain('Hosted account for workspace access, access control, and billing.');
expect(html).toContain('Billing enabled');
expect(html).toContain('Manage access');
expect(html).toContain('Hosted billing attached');
});
it('renders a view-only access surface when the account cannot manage access', function() {
@@ -386,11 +387,11 @@ describe('shell view', function() {
expect(html).toContain('Open a workspace and review current state here. An owner or admin must create or change hosted workspaces.');
expect(html).toContain('Tech role');
expect(html).toContain('Hosted account where you can open workspaces and review who already has access. An owner or admin handles access changes and billing.');
expect(html).toContain('View only');
expect(html).toContain('View roster');
expect(html).toContain('Owner/admin required');
expect(html).toContain('0 workspaces need review');
expect(html).toContain('1 workspace is ready to use');
expect(html).toContain('Review access');
expect(html).toContain('View roster');
expect(html).toContain('Owner or admin required');
expect(html).toContain('Review who already has access to this hosted account. An owner or admin must make changes.');
expect(html).toContain('Review the hosted roster here. An owner or admin must make changes.');
@@ -481,7 +482,7 @@ describe('shell view', function() {
expect(html).toContain('Review who can act');
expect(html).toContain('Review Access to see who can create or manage the first workspace on this account.');
expect(html).toContain('Review access');
expect(html).toContain('Open Access');
expect(html).not.toContain('Choose the right task path');
expect(html).not.toContain('If this is an access change, go to Access. If it is a billing or license issue, go to Billing. Support is only for escalation.');
});
@@ -508,8 +509,8 @@ describe('shell view', function() {
);
expect(html).toContain('0 workspaces are ready to use');
expect(html).toContain('An owner or admin still needs to create the first hosted workspace before routine work can start.');
expect(html).not.toContain('Open Workspaces for the current workspace state before routine use.');
expect(html).toContain('No hosted workspace exists yet. An owner or admin must create the first one.');
expect(html).not.toContain('Open Workspaces to see the current state of each hosted workspace.');
});
it('keeps ready state honest for managed hosted accounts with no workspace yet', function() {
@@ -534,8 +535,8 @@ describe('shell view', function() {
);
expect(html).toContain('0 workspaces are ready to use');
expect(html).toContain('The first hosted workspace still needs to be created before routine work can start.');
expect(html).not.toContain('Open Workspaces for the current workspace state before routine use.');
expect(html).toContain('No hosted workspace exists yet. Create the first one in Workspaces.');
expect(html).not.toContain('Open Workspaces to see the current state of each hosted workspace.');
});
it('keeps next action on review surfaces for suspended hosted view-only accounts', function() {
@@ -569,8 +570,8 @@ describe('shell view', function() {
expect(html).toContain('Review workspace state');
expect(html).toContain('Open Workspaces to review current state, then hand off changes to an owner or admin.');
expect(html).toContain('Review workspaces');
expect(html).toContain('Review access');
expect(html).toContain('Open Workspaces');
expect(html).toContain('Open Access');
expect(html).not.toContain('Choose the right task path');
});
@@ -604,7 +605,7 @@ describe('shell view', function() {
);
expect(html).toContain('0 workspaces are ready to use');
expect(html).toContain('1 workspace is suspended and excluded from routine use until you resume it.');
expect(html).toContain('1 workspace is suspended. Resume it before opening it again.');
expect(html).not.toContain('Active workspaces look clear for routine use.');
});
@@ -715,7 +716,7 @@ describe('shell view', function() {
expect(html).toContain('0 workspaces are ready to use');
expect(html).toContain('Every hosted workspace is suspended right now.');
expect(html).toContain('Create workspace');
expect(html).toContain('1 workspace is suspended and excluded from routine use until you resume it.');
expect(html).toContain('1 workspace is suspended. Resume it before opening it again.');
});
it('preserves the high-density grid, standard sidebar hooks, and strictly horizontal, pill-free action constraints in the rendered shell', function() {
@@ -77,6 +77,30 @@ function suspendedWorkspaceChipLabel(count: number): string {
return count === 1 ? '1 suspended workspace' : String(count) + ' suspended workspaces';
}
function overviewNavBadgeLabel(hosted: boolean, attentionCount: number, readyCount: number): string {
if (!hosted) return 'Billing available';
if (attentionCount > 0) return reviewWorkspaceChipLabel(attentionCount);
return readyWorkspaceChipLabel(readyCount);
}
function workspacesNavBadgeLabel(hosted: boolean, totalWorkspaces: number): string {
if (!hosted) return 'Unavailable';
return workspaceCountLabel(totalWorkspaces);
}
function accessNavBadgeLabel(hosted: boolean, canManage: boolean): string {
if (!hosted) return 'Unavailable';
return canManage ? 'Manage access' : 'View roster';
}
function billingNavBadgeLabel(hostedBillingCount: number): string {
return hostedBillingCount > 0 ? 'Hosted billing' : 'Self-hosted billing';
}
function supportNavBadgeLabel(): string {
return 'Escalation only';
}
function hasHostedAccounts(accounts: PortalAccountSummary[]): boolean {
return accounts.length > 0;
}
@@ -197,7 +221,7 @@ function attentionWorkspaces(workspaces: PortalWorkspaceSummary[]): PortalWorksp
}
function accountContextRoleMeta(account: PortalAccountSummary): string {
return portalRoleLabel(account.role) + (account.can_manage ? ' access' : ' role');
return portalRoleLabel(account.role) + ' role';
}
function accountContextLeadCopy(account: PortalAccountSummary): string {
@@ -213,12 +237,12 @@ function accountContextLeadCopy(account: PortalAccountSummary): string {
}
function accountContextAccessSummary(account: PortalAccountSummary): string {
return account.can_manage ? portalRoleLabel(account.role) : 'View only';
return account.can_manage ? 'Manage access' : 'View roster';
}
function accountContextBillingSummary(account: PortalAccountSummary): string {
if (!account.has_billing) return 'Not attached';
return account.can_manage ? 'Billing enabled' : 'Owner/admin required';
return account.can_manage ? 'Hosted billing attached' : 'Owner/admin required';
}
function renderAccountContextStrip(account: PortalAccountSummary): string {
@@ -344,14 +368,17 @@ function renderShellNavigation(accounts: PortalAccountSummary[], supportEmail: s
'<div class="portal-shell-nav-header">' +
'<div class="portal-shell-nav-eyebrow">Pulse Account</div>' +
'<div class="portal-shell-nav-title">Account tasks</div>' +
'<div class="portal-shell-nav-support">' + (hosted ? 'Workspaces, Access, Billing, then Support.' : 'Billing first. Support only after the billing path fails.') + '</div>' +
'<div class="portal-shell-nav-support">' + (hosted
? 'Use Workspaces, then Access, then Billing. Use Support only if those paths fail.'
: 'Use Billing first. Use Support only if that path fails.'
) + '</div>' +
'</div>' +
'<div class="portal-shell-nav-group">' +
shellSectionButton('overview', activeSection, '01', 'Overview', 'Review status, ready workspaces, and the next action.', attentionCount > 0 ? String(attentionCount) + ' review' : (hosted ? String(readyWorkspaces) + ' ready' : 'Summary')) +
shellSectionButton('workspaces', activeSection, '02', 'Workspaces', workspaceNavCopy(hosted, canManage), hosted ? String(readyWorkspaces) + ' ready' : 'Unavailable') +
shellSectionButton('access', activeSection, '03', 'Access', accessNavCopy(hosted, canManage), hosted ? (canManage ? 'Manage' : 'View') : 'Unavailable') +
shellSectionButton('billing', activeSection, '04', 'Billing', billingNavCopy(hostedBillingCount, canManageHostedBilling), hostedBillingCount > 0 ? (hostedBillingCount > 1 ? 'Hosted +' : 'Hosted') : 'Self-hosted') +
shellSectionButton('support', activeSection, '05', 'Support', supportNavCopy(hosted, canManage), supportEmail ? 'Email' : 'Help') +
shellSectionButton('overview', activeSection, '01', 'Overview', 'Review workspace counts, current state, and the current action.', overviewNavBadgeLabel(hosted, attentionCount, readyWorkspaces)) +
shellSectionButton('workspaces', activeSection, '02', 'Workspaces', workspaceNavCopy(hosted, canManage), workspacesNavBadgeLabel(hosted, totalWorkspaces)) +
shellSectionButton('access', activeSection, '03', 'Access', accessNavCopy(hosted, canManage), accessNavBadgeLabel(hosted, canManage)) +
shellSectionButton('billing', activeSection, '04', 'Billing', billingNavCopy(hostedBillingCount, canManageHostedBilling), billingNavBadgeLabel(hostedBillingCount)) +
shellSectionButton('support', activeSection, '05', 'Support', supportNavCopy(hosted, canManage), supportNavBadgeLabel()) +
'</div>' +
'</aside>'
);
@@ -544,8 +571,8 @@ function renderOverviewAttentionCard(
) + '</span></div>' +
'<div class="overview-task-item"><strong>Suspended</strong><span>' + escapeHTML(suspendedCount > 0
? suspendedCount === 1
? '1 workspace is suspended and excluded from routine use until you resume it.'
: String(suspendedCount) + ' workspaces are suspended and excluded from routine use until you resume them.'
? '1 workspace is suspended. Resume it before opening it again.'
: String(suspendedCount) + ' workspaces are suspended. Resume them before opening them again.'
: '0 suspended workspaces.'
) + '</span></div>' +
'</div>' +
@@ -599,10 +626,10 @@ function renderOverviewReadyCard(
: totalWorkspaces > 0
? suspendedCount === totalWorkspaces
? 'Every hosted workspace is suspended right now.'
: 'Open Workspaces for the current workspace state before routine use.'
: 'Open Workspaces to see the current state of each hosted workspace.'
: canManageHosted
? 'The first hosted workspace still needs to be created before routine work can start.'
: 'An owner or admin still needs to create the first hosted workspace before routine work can start.'
? 'No hosted workspace exists yet. Create the first one in Workspaces.'
: 'No hosted workspace exists yet. An owner or admin must create the first one.'
) + '</p>' +
'</article>'
);
@@ -655,13 +682,13 @@ function renderOverviewNextActionCard(
var hostedViewOnly = accounts.length > 0 && !accessAccount;
if (attention.length) {
title = 'Review workspace health';
title = 'Open Workspaces';
description = attention.length > 1
? 'Open Workspaces. Review each failed or pending workspace before taking another account action.'
: 'Open Workspaces. Review ' + attention[0].workspace.display_name + ' before taking another account action.';
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Review workspaces</button>';
? 'Open Workspaces to review each failed or pending workspace.'
: 'Open Workspaces to review ' + attention[0].workspace.display_name + '.';
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Open Workspaces</button>';
secondaryAction = accessAccount
? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>'
? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>'
: '';
} else if (ready.length) {
title = 'Open workspace';
@@ -676,13 +703,13 @@ function renderOverviewNextActionCard(
title = 'Create workspace';
description = 'No workspace is ready. Create a workspace in ' + creatableAccount.name + '.';
primaryAction = '<button class="btn-primary btn-compact" type="button" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(creatableAccount.id) + '">Create workspace</button>';
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Manage access</button>';
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
} else if (billingAccount) {
title = 'Open billing';
description = 'Use Billing for invoices, payment methods, or subscription changes.';
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="billing">Open billing</button>';
secondaryAction = accessAccount
? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>'
? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>'
: '';
} else if (!accounts.length) {
title = 'Open billing';
@@ -692,14 +719,14 @@ function renderOverviewNextActionCard(
if (totalWorkspaces > 0) {
title = 'Review workspace state';
description = 'No workspace is ready. Open Workspaces to review current state, then hand off changes to an owner or admin.';
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Review workspaces</button>';
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>';
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Open Workspaces</button>';
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
} else {
title = 'Review who can act';
description = showSelfHostedCommercial
? 'No hosted workspace is attached. Review Access to see who can manage this hosted account, or use Billing for self-hosted tasks.'
: 'No hosted workspace is attached yet. Review Access to see who can create or manage the first workspace on this account.';
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>';
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
secondaryAction = showSelfHostedCommercial
? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="billing">Open billing</button>'
: '';
@@ -753,7 +780,7 @@ function renderShellOverviewSection(context: ShellViewContext): string {
'<div>' +
'<div class="account-panel-kicker">Overview</div>' +
'<h3>Account state</h3>' +
'<p>Current hosted workspace state, readiness, and next action.</p>' +
'<p>Hosted workspace counts, current state, and current action.</p>' +
renderSectionContextChips(chips) +
'</div>' +
'</div>' +