style(portal): tighten top shell rhythm

This commit is contained in:
rcourtman
2026-03-27 18:32:11 +00:00
parent dac55b5221
commit a5b90e8a38
6 changed files with 130 additions and 82 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "db22f20e5c01de6c11aa19319736af2e3d32a613e07002fbcab79c4d07f08e5d",
"source_hash": "52cc4e938d4e2d9faf8baccb4ba63be6dfcbf6172126273c48ed6661ac7231ea",
"build_inputs": [
"package.json",
"tsconfig.json",
+52 -30
View File
@@ -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;
}
+5 -7
View File
@@ -1646,9 +1646,6 @@
if (workspace.last_health_check) return "unhealthy";
return "checking";
}
function roleBadgeHTML(role) {
return '<span class="badge badge-role">' + escapeHTML(titleCase(role || "member")) + "</span>";
}
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 '<section class="portal-hero portal-hero-compact"><div class="portal-hero-copy"><div class="portal-hero-kicker">' + (hosted ? "Hosted access is active on this account." : "No hosted workspace access is attached to this account yet.") + '</div><div class="portal-hero-heading-row"><h1>' + title + '</h1><span class="portal-hero-chip">' + (hosted ? "Operator ready" : "Self-hosted only") + "</span></div><p>" + summary + '</p></div><div class="portal-hero-stats"><div class="portal-hero-stat"><span class="portal-hero-stat-label">Hosted access</span><span class="portal-hero-stat-value">' + (hosted ? "Active" : "Not attached") + '</span></div><div class="portal-hero-stat"><span class="portal-hero-stat-label">Accounts</span><span class="portal-hero-stat-value">' + (accounts.length === 1 ? "1 account" : String(accounts.length) + " accounts") + '</span></div><div class="portal-hero-stat"><span class="portal-hero-stat-label">Workspace fleet</span><span class="portal-hero-stat-value">' + (workspaceTotal ? workspaceCountLabel(workspaceTotal) : "0 workspaces") + "</span></div></div></section>";
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 '<section class="portal-hero portal-hero-compact"><div class="portal-hero-copy"><div class="portal-hero-heading-row"><div class="portal-hero-brand">Pulse Account</div><span class="portal-hero-chip">' + (hosted ? "Operator ready" : "Self-hosted only") + "</span></div><p><strong>" + statusText + "</strong> " + summary + '</p></div><div class="portal-hero-stats"><div class="portal-hero-stat"><span class="portal-hero-stat-label">Hosted access</span><span class="portal-hero-stat-value">' + (hosted ? "Active" : "Not attached") + '</span></div><div class="portal-hero-stat"><span class="portal-hero-stat-label">Accounts</span><span class="portal-hero-stat-value">' + (accounts.length === 1 ? "1 account" : String(accounts.length) + " accounts") + '</span></div><div class="portal-hero-stat"><span class="portal-hero-stat-label">Workspace fleet</span><span class="portal-hero-stat-value">' + (workspaceTotal ? workspaceCountLabel(workspaceTotal) : "0 workspaces") + "</span></div></div></section>";
}
function shellSectionButton(section, activeSection, title, copy) {
return '<button class="portal-shell-nav-link' + (activeSection === section ? " active" : "") + '" type="button" data-shell-action="activate-section" data-shell-section="' + section + '"><span class="portal-shell-nav-label">' + title + '</span><span class="portal-shell-nav-copy">' + copy + "</span></button>";
@@ -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 '<section class="account-surface"><div class="account-surface-header"><div class="account-heading"><div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + "</div><h2>" + escapeHTML(account.name) + '</h2><div class="account-summary">' + escapeHTML(accountKindLabel(account) + " \xB7 " + titleCase(account.role) + " \xB7 " + workspaceCountLabel((account.workspaces || []).length)) + '</div></div><div class="account-badges"><span class="badge badge-' + escapeHTML(account.kind) + '">' + escapeHTML(account.kind_label) + "</span>" + roleBadgeHTML(account.role) + '</div></div><div class="account-surface-body">' + renderAccountOverviewSection(account) + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + renderAccountTeamSection(account) + "</div></section>";
var workspaceLabel = workspaceCountLabel((account.workspaces || []).length);
return '<section class="account-surface"><div class="account-surface-header"><div class="account-heading"><div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + "</div><h2>" + escapeHTML(account.name) + '</h2><div class="account-summary">' + escapeHTML(account.kind === "msp" ? "Operator workspace account" : "Hosted account operations") + '</div></div><div class="account-context-strip"><span class="account-context-chip">' + escapeHTML(account.kind_label) + '</span><span class="account-context-chip">' + escapeHTML(titleCase(account.role)) + '</span><span class="account-context-chip">' + escapeHTML(workspaceLabel) + '</span></div></div><div class="account-surface-body">' + renderAccountOverviewSection(account) + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + renderAccountTeamSection(account) + "</div></section>";
}).join("");
return '<div class="portal-shell" data-shell-section="' + activeSection + '">' + renderOverviewBand(accounts) + '<div class="portal-shell-layout">' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '<div class="portal-shell-main"><section class="portal-content-panel portal-content-panel-overview"><div id="accounts-root">' + hostedContent + '</div></section><section class="portal-content-panel portal-content-panel-services service-section" id="account-services-section"><div class="service-header"><div><div class="account-panel-kicker">Account services</div><h2>' + serviceHeading + '</h2></div><div class="service-note">' + serviceNote + '</div></div><div class="service-shell"><aside class="service-shell-sidebar"><div class="service-list-intro"><div class="account-panel-kicker">Commercial actions</div><p>Use these tools for self-hosted billing, license recovery, refunds, and privacy. Hosted workspace operations stay in Workspaces and Team.</p></div><div class="service-action-list">' + 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"]) + renderServiceActionRow("open-retrieve-service", "Licenses", "Retrieve licenses", "Recover the latest active self-hosted license and invoice link for a commercial email address.", "retrieve-service-panel", "retrieve-inline-email", ["Latest active license", "Invoice lookup"]) + renderServiceActionRow("open-refund-service", "Refunds", "Refund requests", "Request an immediate self-serve refund for eligible self-hosted purchases with explicit revocation confirmation.", "refund-service-panel", "refund-inline-email", ["Eligibility check", "Explicit revocation"]) + renderServiceActionRow("open-data-service", "Privacy", "Data and privacy", "Request commercial data export or deletion without leaving the account shell.", "data-service-panel", "data-export-email", ["Export or deletion", "Support escalation path"]) + '</div></aside><div class="service-shell-main"><div class="service-detail-shell"><div class="service-panel service-panel-empty visible" id="service-panel-empty"><div class="account-panel-kicker">Select a service</div><h3>Choose a commercial account action</h3><p>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.</p><div class="service-empty-points"><div class="service-empty-point"><strong>Billing</strong><span>Open Stripe customer portal access after verification.</span></div><div class="service-empty-point"><strong>Licenses</strong><span>Recover the latest active self-hosted license and invoice link.</span></div><div class="service-empty-point"><strong>Privacy</strong><span>Request export or deletion without leaving Pulse Account.</span></div></div><div class="service-empty-support">Need help with billing, refund, privacy, or license actions? <a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div></div><div class="service-panel" id="manage-service-panel"><div id="manage-service-root"></div></div><div class="service-panel" id="retrieve-service-panel"><div id="retrieve-service-root"></div></div><div class="service-panel" id="refund-service-panel"><div id="refund-service-root"></div></div><div class="service-panel" id="data-service-panel"><h3>Data and privacy</h3><p>Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.</p><div class="subsection"><div id="data-export-root"></div></div><div class="subsection"><div id="data-delete-root"></div></div><div class="helper-text">Payment-card data stays with Stripe. For Stripe deletion support, contact <a href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a>.</div></div></div><div class="service-inline-support"><div class="account-panel-kicker">Support</div><p>Use support if a billing, refund, privacy, or license flow does not behave as expected for this account.</p><a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div></div></div></section><section class="portal-content-panel portal-content-panel-support">' + renderSupportSection(context) + "</section></div></div></div>";
}
@@ -125,7 +125,7 @@ describe('shell view', function() {
})
);
expect(html).toContain('<h1>Pulse Account</h1>');
expect(html).toContain('portal-hero-brand">Pulse Account</div>');
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('<h1>Self-hosted Pulse Account</h1>');
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');
@@ -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 (
'<section class="portal-hero portal-hero-compact">' +
'<div class="portal-hero-copy">' +
'<div class="portal-hero-kicker">' + (hosted ? 'Hosted access is active on this account.' : 'No hosted workspace access is attached to this account yet.') + '</div>' +
'<div class="portal-hero-heading-row">' +
'<h1>' + title + '</h1>' +
'<div class="portal-hero-brand">Pulse Account</div>' +
'<span class="portal-hero-chip">' + (hosted ? 'Operator ready' : 'Self-hosted only') + '</span>' +
'</div>' +
'<p>' + summary + '</p>' +
'<p><strong>' + statusText + '</strong> ' + summary + '</p>' +
'</div>' +
'<div class="portal-hero-stats">' +
'<div class="portal-hero-stat">' +
@@ -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 (
'<section class="account-surface">' +
'<div class="account-surface-header">' +
'<div class="account-heading">' +
'<div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + '</div>' +
'<h2>' + escapeHTML(account.name) + '</h2>' +
'<div class="account-summary">' + escapeHTML(accountKindLabel(account) + ' · ' + titleCase(account.role) + ' · ' + workspaceCountLabel((account.workspaces || []).length)) + '</div>' +
'<div class="account-summary">' + escapeHTML(account.kind === 'msp' ? 'Operator workspace account' : 'Hosted account operations') + '</div>' +
'</div>' +
'<div class="account-badges">' +
'<span class="badge badge-' + escapeHTML(account.kind) + '">' + escapeHTML(account.kind_label) + '</span>' +
roleBadgeHTML(account.role) +
'<div class="account-context-strip">' +
'<span class="account-context-chip">' + escapeHTML(account.kind_label) + '</span>' +
'<span class="account-context-chip">' + escapeHTML(titleCase(account.role)) + '</span>' +
'<span class="account-context-chip">' + escapeHTML(workspaceLabel) + '</span>' +
'</div>' +
'</div>' +
'<div class="account-surface-body">' +
+56 -31
View File
@@ -163,8 +163,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;
@@ -173,23 +173,31 @@ 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,
@@ -210,31 +218,28 @@ header .logout-btn:hover,
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;
@@ -243,7 +248,7 @@ header .logout-btn:hover,
.portal-hero-stats {
display: flex;
align-items: stretch;
gap: 8px;
gap: 6px;
align-self: center;
}
@@ -251,16 +256,16 @@ header .logout-btn:hover,
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;
@@ -268,7 +273,7 @@ header .logout-btn:hover,
}
.portal-hero-stat-value {
font-size: 16px;
font-size: 14px;
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.04em;
@@ -502,11 +507,11 @@ header .logout-btn:hover,
}
.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);
@@ -515,19 +520,20 @@ header .logout-btn:hover,
}
.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,
@@ -539,6 +545,25 @@ header .logout-btn:hover,
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;
}