Make self-hosted portal nav honest

This commit is contained in:
rcourtman
2026-03-28 19:49:05 +00:00
parent 0c4f7b3d43
commit 55f030d11e
7 changed files with 21 additions and 9 deletions
@@ -159,7 +159,8 @@ Core rules:
current account set, not a repeated per-account dashboard.
17. When `Workspaces` or `Access` are unavailable, the tab must show an
explicit unavailable state that points to the right live task instead of
rendering blank space.
rendering blank space. The shell navigation must stay honest too: it must
not badge or describe unavailable hosted tasks as though they are live.
18. `Access` must stay action-first: roster, invite, role change, and remove
access are the job; view-only users may review the roster but must never
see live controls that imply they can mutate it.
@@ -293,7 +293,9 @@ self-hosted commercial history is relevant to the signed-in account, so
hosted-only accounts do not render self-hosted license, refund, privacy, or
self-hosted escalation paths by default, and self-hosted-only accounts do not
front-load an empty hosted-billing block before the real self-hosted jobs.
`Support` follows the same
The same account-shape runtime contract must also keep the shell navigation
honest: unavailable hosted `Workspaces` or `Access` tasks must read as
unavailable in the nav itself rather than implying live hosted work. `Support` follows the same
account-shape runtime contract: self-hosted-only accounts expose only the
billing escalation path and billing-specific handoff packet, and hosted
workspace/access escalation controls must not render when no hosted account
@@ -827,7 +827,9 @@ standalone Relay portal. That shell now also owns two product rules
explicitly: `Overview` is one shell-level triage surface across the current
account set rather than a repeated per-account dashboard, and unavailable
top-level `Workspaces` or `Access` tasks must render an explicit unavailable
state instead of blank space. The same shell also owns action-first task
state instead of blank space. The shell navigation must stay honest to that
same account shape rather than labelling unavailable hosted tasks as though
they are still live. The same shell also owns action-first task
surfaces for `Access`, `Billing`, and `Support`: access mutations must be
permission-honest and roster-led, billing must reduce to one obvious job at a
time with hosted billing first when relevant, support must stay a failed-path
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "f417480ba4d79a766c8bb7511a50e02f825cb2199eb6961c618b5a9429d63975",
"source_hash": "176432adedcb3aa8f1e151678f7e93e3db683ef6029b8d6b60a04264e2bff8fd",
"build_inputs": [
"package.json",
"tsconfig.json",
+2 -1
View File
@@ -2038,6 +2038,7 @@
}
function renderShellNavigation(accounts, supportEmail, activeSection) {
var hosted = hasHostedAccounts(accounts);
var selfHostedOnly = !hosted;
var workspaces = collectWorkspaces(accounts);
var totalWorkspaces = workspaces.length;
var readyWorkspaces = countReadyWorkspaces(workspaces);
@@ -2052,7 +2053,7 @@
hostedBillingCount += 1;
}
}
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 ? "Start with the job you need to finish: workspace work, access, billing, then escalation." : "Use billing tools first and escalate only when the self-serve path stops.") + '</div></div><div class="portal-shell-nav-group">' + shellSectionButton("overview", activeSection, "01", "Overview", "What needs attention, what is ready, and the next obvious action.", attentionCount > 0 ? String(attentionCount) + " review" : hosted ? String(readyWorkspaces) + " ready" : "Summary") + shellSectionButton("workspaces", activeSection, "02", "Workspaces", hosted ? "Open a workspace, review lifecycle state, or create one." : "No hosted workspaces are attached yet.", hosted ? String(readyWorkspaces) + " ready" : "None") + shellSectionButton("access", activeSection, "03", "Access", hosted ? "Invite people, change roles, and remove account access." : "Account access and membership controls.", canManage ? "Manage" : "View") + shellSectionButton("billing", activeSection, "04", "Billing", hostedBillingCount > 0 ? "Hosted billing first, then self-hosted licenses, refunds, and privacy only when relevant." : "Self-hosted billing, licenses, refunds, and privacy.", hostedBillingCount > 0 ? hostedBillingCount > 1 ? "Hosted +" : "Hosted" : "Self-hosted") + shellSectionButton("support", activeSection, "05", "Support", "Escalation only after the workspace, access, or billing path is exhausted.", 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 ? "Start with the job you need to finish: workspace work, access, billing, then escalation." : "Use billing tools first and escalate only when the self-serve path stops.") + '</div></div><div class="portal-shell-nav-group">' + shellSectionButton("overview", activeSection, "01", "Overview", "What needs attention, what is ready, and the next obvious action.", attentionCount > 0 ? String(attentionCount) + " review" : hosted ? String(readyWorkspaces) + " ready" : "Summary") + shellSectionButton("workspaces", activeSection, "02", "Workspaces", hosted ? "Open a workspace, review lifecycle state, or create one." : "Unavailable on this account. Hosted workspaces are not attached here.", hosted ? String(readyWorkspaces) + " ready" : "Unavailable") + shellSectionButton("access", activeSection, "03", "Access", hosted ? "Invite people, change roles, and remove account access." : "Unavailable on this account. Hosted roster and role controls live only on hosted workspace accounts.", hosted ? canManage ? "Manage" : "View" : "Unavailable") + shellSectionButton("billing", activeSection, "04", "Billing", hostedBillingCount > 0 ? "Hosted billing first, then self-hosted licenses, refunds, and privacy only when relevant." : "Self-hosted billing, licenses, refunds, and privacy.", hostedBillingCount > 0 ? hostedBillingCount > 1 ? "Hosted +" : "Hosted" : "Self-hosted") + shellSectionButton("support", activeSection, "05", "Support", selfHostedOnly ? "Escalation only after the billing path is exhausted." : "Escalation only after the workspace, access, or billing path is exhausted.", supportEmail ? "Email" : "Help") + "</div></aside>";
}
function renderWorkspaceCard(account, workspace, accountAPIBasePath) {
var status = workspaceHealthState(workspace);
@@ -397,6 +397,11 @@ describe('shell view', function() {
expect(html).toContain('Account tasks');
expect(html).toContain('Self-hosted');
expect(html).toContain('Account triage');
expect(html).toMatch(/Workspaces[\s\S]*Unavailable on this account\. Hosted workspaces are not attached here\./);
expect(html).toContain('Unavailable on this account. Hosted workspaces are not attached here.');
expect(html).toMatch(/Access[\s\S]*Unavailable on this account\. Hosted roster and role controls live only on hosted workspace accounts\./);
expect(html).toContain('Unavailable on this account. Hosted roster and role controls live only on hosted workspace accounts.');
expect(html).toMatch(/Support[\s\S]*Escalation only after the billing path is exhausted\./);
expect(html).toContain('No hosted account');
expect(html).toContain('Billing tools are ready');
expect(html).toContain('There is nothing to open or manage here yet.');
@@ -360,6 +360,7 @@ function shellSectionButton(section: PortalShellSection, activeSection: PortalSh
function renderShellNavigation(accounts: PortalAccountSummary[], supportEmail: string, activeSection: PortalShellSection): string {
var hosted = hasHostedAccounts(accounts);
var selfHostedOnly = !hosted;
var workspaces = collectWorkspaces(accounts);
var totalWorkspaces = workspaces.length;
var readyWorkspaces = countReadyWorkspaces(workspaces);
@@ -379,14 +380,14 @@ 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 ? 'Start with the job you need to finish: workspace work, access, billing, then escalation.' : 'Use billing tools first and escalate only when the self-serve path stops.' ) + '</div>' +
'<div class="portal-shell-nav-support">' + (hosted ? 'Start with the job you need to finish: workspace work, access, billing, then escalation.' : 'Use billing tools first and escalate only when the self-serve path stops.') + '</div>' +
'</div>' +
'<div class="portal-shell-nav-group">' +
shellSectionButton('overview', activeSection, '01', 'Overview', 'What needs attention, what is ready, and the next obvious action.', attentionCount > 0 ? String(attentionCount) + ' review' : (hosted ? String(readyWorkspaces) + ' ready' : 'Summary')) +
shellSectionButton('workspaces', activeSection, '02', 'Workspaces', hosted ? 'Open a workspace, review lifecycle state, or create one.' : 'No hosted workspaces are attached yet.', hosted ? String(readyWorkspaces) + ' ready' : 'None') +
shellSectionButton('access', activeSection, '03', 'Access', hosted ? 'Invite people, change roles, and remove account access.' : 'Account access and membership controls.', canManage ? 'Manage' : 'View') +
shellSectionButton('workspaces', activeSection, '02', 'Workspaces', hosted ? 'Open a workspace, review lifecycle state, or create one.' : 'Unavailable on this account. Hosted workspaces are not attached here.', hosted ? String(readyWorkspaces) + ' ready' : 'Unavailable') +
shellSectionButton('access', activeSection, '03', 'Access', hosted ? 'Invite people, change roles, and remove account access.' : 'Unavailable on this account. Hosted roster and role controls live only on hosted workspace accounts.', hosted ? (canManage ? 'Manage' : 'View') : 'Unavailable') +
shellSectionButton('billing', activeSection, '04', 'Billing', hostedBillingCount > 0 ? 'Hosted billing first, then self-hosted licenses, refunds, and privacy only when relevant.' : 'Self-hosted billing, licenses, refunds, and privacy.', hostedBillingCount > 0 ? (hostedBillingCount > 1 ? 'Hosted +' : 'Hosted') : 'Self-hosted') +
shellSectionButton('support', activeSection, '05', 'Support', 'Escalation only after the workspace, access, or billing path is exhausted.', supportEmail ? 'Email' : 'Help') +
shellSectionButton('support', activeSection, '05', 'Support', selfHostedOnly ? 'Escalation only after the billing path is exhausted.' : 'Escalation only after the workspace, access, or billing path is exhausted.', supportEmail ? 'Email' : 'Help') +
'</div>' +
'</aside>'
);