style(portal): simplify shell and team states

This commit is contained in:
rcourtman
2026-03-27 22:36:19 +00:00
parent 18dcd335c1
commit 9530be2c1b
8 changed files with 193 additions and 146 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "e0dc04d4f7be0d3c9048256e3dfff63acb71c236f2c8fd44b237612191a7d696",
"source_hash": "69bb383eb566fc7cd58d13fdd5aad0d3d5b2b43107d66fbebc9d700607af2d26",
"build_inputs": [
"package.json",
"tsconfig.json",
+57 -29
View File
@@ -381,8 +381,8 @@ header .logout-btn:hover,
.portal-shell-main {
min-width: 0;
min-height: 820px;
padding: 0 0 28px;
background: #f5f7fa;
padding: 0 0 24px;
background: #f3f6fa;
}
.portal-shell-main > .portal-content-panel {
display: none !important;
@@ -391,15 +391,27 @@ header .logout-btn:hover,
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: end;
padding: 16px 22px 18px;
align-items: center;
padding: 18px 22px 16px;
border-bottom: 1px solid var(--line);
background: rgba(244, 247, 250, 0.92);
background: rgba(255, 255, 255, 0.84);
}
.portal-account-bar-copy {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
}
.portal-account-bar-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.portal-account-bar-separator,
.portal-account-bar-access {
font-size: 12px;
font-weight: 700;
color: var(--ink-muted);
}
.portal-account-bar-row {
display: flex;
@@ -410,9 +422,9 @@ header .logout-btn:hover,
}
.portal-account-bar-row h2 {
margin: 0;
font-size: 28px;
line-height: 0.98;
letter-spacing: -0.05em;
font-size: 32px;
line-height: 0.96;
letter-spacing: -0.06em;
}
.portal-account-bar-copy p {
margin: 0;
@@ -459,13 +471,13 @@ header .logout-btn:hover,
gap: 26px;
}
.portal-content-panel {
padding: 20px 22px 0;
padding: 16px 22px 0;
}
.portal-top-section,
.service-section {
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
}
.portal-top-section-header h2,
.service-header h2 {
@@ -548,11 +560,11 @@ header .logout-btn:hover,
.account-content-panel-overview,
.account-content-panel-workspaces,
.account-content-panel-team {
padding: 18px;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: #eef3f7;
box-shadow: 0 12px 28px rgba(10, 25, 41, 0.045);
border-radius: 14px;
background: #edf2f6;
box-shadow: 0 10px 22px rgba(10, 25, 41, 0.04);
}
.account-command-deck,
.account-overview-card,
@@ -607,12 +619,12 @@ header .logout-btn:hover,
.account-command-deck {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
gap: 12px;
align-items: stretch;
padding: 16px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.6);
border-radius: 12px;
background: rgba(255, 255, 255, 0.72);
}
.account-overview-card {
display: grid;
@@ -756,7 +768,7 @@ header .logout-btn:hover,
.portal-support-panel {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 22px;
padding: 18px 20px;
background: var(--panel);
box-shadow: var(--shadow-sm);
}
@@ -944,17 +956,16 @@ header .logout-btn:hover,
grid-template-columns: minmax(0, 1.42fr) minmax(360px, 1fr);
gap: 14px;
align-items: start;
padding: 16px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.56);
border-radius: 12px;
background: rgba(255, 255, 255, 0.68);
}
.workspace-operations-main,
.workspace-operations-detail {
min-width: 0;
}
.workspace-management-panel,
.team-management-panel {
.workspace-management-panel {
min-height: 100%;
}
.workspace-management-panel.visible,
@@ -1298,16 +1309,33 @@ header .logout-btn:hover,
}
.team-list-message {
display: flex;
align-items: center;
flex-direction: column;
align-items: flex-start;
justify-content: center;
min-height: 120px;
padding: 16px;
gap: 6px;
min-height: 148px;
padding: 18px;
border: 1px dashed var(--line-strong);
border-radius: 10px;
color: var(--ink-muted);
background: var(--panel-muted);
}
.team-list-message-title {
font-size: 16px;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--ink);
}
.team-list-message-copy {
font-size: 13px;
line-height: 1.55;
color: var(--ink-soft);
}
.team-list-message.error {
border-color: rgba(166, 27, 27, 0.2);
background: var(--danger-soft);
}
.team-list-message.error .team-list-message-title,
.team-list-message.error .team-list-message-copy {
color: var(--danger);
}
.btn-remove {
File diff suppressed because one or more lines are too long
@@ -83,13 +83,16 @@ describe('account view', function() {
teamVisible: true,
teamQuery: { status: 'loading', error: '', data: [] },
}));
expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Loading');
expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Loading roster');
expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Invites');
renderTeamSection('acct_1', createEntry({
teamVisible: true,
teamQuery: { status: 'error', error: 'Failed to load team.', data: [] },
}));
expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Roster needs attention');
expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Failed to load team.');
expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Invite only');
renderTeamSection(
'acct_1',
@@ -163,11 +163,18 @@ export function renderWorkspaceManagement(account: PortalAccountSummary, entry:
closeButton.disabled = entry.manageWorkspace.pending;
}
function setContainerMessage(container: HTMLElement, msg: string, isError: boolean): void {
function setContainerMessage(container: HTMLElement, title: string, msg: string, isError: boolean): void {
container.textContent = '';
var message = document.createElement('div');
message.className = 'team-list-message' + (isError ? ' error' : '');
message.textContent = msg;
var heading = document.createElement('strong');
heading.className = 'team-list-message-title';
heading.textContent = title;
var copy = document.createElement('span');
copy.className = 'team-list-message-copy';
copy.textContent = msg;
message.appendChild(heading);
message.appendChild(copy);
container.appendChild(message);
}
@@ -187,11 +194,15 @@ function renderTeamStats(accountID: string, entry: PortalAccountUIEntry): void {
return;
}
if (entry.teamQuery.status === 'loading') {
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value">Loading…</span></div>';
stats.innerHTML =
'<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value">Loading…</span></div>' +
'<div class="team-stat-card"><span class="team-stat-label">Invites</span><span class="team-stat-value">Ready</span></div>';
return;
}
if (entry.teamQuery.status === 'error') {
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value team-stat-error">Needs attention</span></div>';
stats.innerHTML =
'<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value team-stat-error">Needs attention</span></div>' +
'<div class="team-stat-card"><span class="team-stat-label">Fallback</span><span class="team-stat-value">Invite only</span></div>';
return;
}
@@ -317,15 +328,15 @@ export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry
return;
}
if (entry.teamQuery.status === 'loading') {
setContainerMessage(roster, 'Loading…', false);
setContainerMessage(roster, 'Loading roster', 'Checking who currently has access to this account.', false);
return;
}
if (entry.teamQuery.status === 'error') {
setContainerMessage(roster, entry.teamQuery.error, true);
setContainerMessage(roster, 'Roster needs attention', entry.teamQuery.error, true);
return;
}
if (!entry.teamQuery.data.length) {
setContainerMessage(roster, 'No team members.', false);
setContainerMessage(roster, 'No operators yet', 'Invite someone new when this hosted account needs shared access.', false);
return;
}
@@ -125,9 +125,6 @@ describe('shell view', function() {
})
);
expect(html).toContain('portal-shell-head-kicker">Pulse Account</div>');
expect(html).toContain('portal-shell-head-title">Operator console</h1>');
expect(html).toContain('Hosted operations, operator access, and commercial account services.');
expect(html).toContain('portal-shell-nav');
expect(html).toContain('Overview');
expect(html).toContain('Workspaces');
@@ -136,6 +133,7 @@ describe('shell view', function() {
expect(html).toContain('id="account-services-section"');
expect(html).toContain('Self-hosted licenses and billing');
expect(html).toContain('portal-account-bar');
expect(html).toContain('Owner access');
expect(html).toContain('id="accounts-root"');
expect(html).toContain('MSP account');
expect(html).toContain('Acme MSP');
@@ -196,9 +194,7 @@ describe('shell view', function() {
);
expect(html).toContain('Pulse Account');
expect(html).toContain('Self-hosted only');
expect(html).toContain('portal-shell-head-title">Account console</h1>');
expect(html).toContain('Billing, license recovery, refunds, and privacy actions until hosted access is attached.');
expect(html).toContain('Account console');
expect(html).toContain('Use these tools for self-hosted commercial actions.');
expect(html).toContain('Account services');
expect(html).not.toContain('Self-hosted licenses and billing');
@@ -193,36 +193,6 @@ function renderAttentionPanel(workspaces: PortalWorkspaceSummary[]): string {
);
}
function renderOverviewBand(accounts: PortalAccountSummary[]): string {
var hosted = hasHostedAccounts(accounts);
var workspaceTotal = countWorkspaces(accounts);
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-shell-head">' +
'<div class="portal-shell-head-main">' +
'<div class="portal-shell-head-kicker">Pulse Account</div>' +
'<div class="portal-shell-head-row">' +
'<div class="portal-shell-head-copy">' +
'<div class="portal-shell-head-brand-row">' +
'<h1 class="portal-shell-head-title">' + (hosted ? 'Operator console' : 'Account console') + '</h1>' +
'<span class="portal-shell-head-chip">' + (hosted ? 'Operator ready' : 'Self-hosted only') + '</span>' +
'</div>' +
'<p>' + summary + '</p>' +
'</div>' +
'<div class="portal-shell-head-summary">' +
'<span class="portal-shell-summary-pill"><strong>Hosted access</strong>' + (hosted ? 'Active' : 'Not attached') + '</span>' +
'<span class="portal-shell-summary-pill"><strong>Accounts</strong>' + (accounts.length === 1 ? '1 account' : String(accounts.length) + ' accounts') + '</span>' +
'<span class="portal-shell-summary-pill"><strong>Workspace fleet</strong>' + (workspaceTotal ? workspaceCountLabel(workspaceTotal) : '0 workspaces') + '</span>' +
'</div>' +
'</div>' +
'</div>' +
'</section>'
);
}
function renderPrimaryAccountBar(account: PortalAccountSummary): string {
var workspaceLabel = workspaceCountLabel((account.workspaces || []).length);
var actionHTML = '';
@@ -249,7 +219,11 @@ function renderPrimaryAccountBar(account: PortalAccountSummary): string {
return (
'<section class="portal-account-bar">' +
'<div class="portal-account-bar-copy">' +
'<div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + '</div>' +
'<div class="portal-account-bar-meta">' +
'<span class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + '</span>' +
'<span class="portal-account-bar-separator">/</span>' +
'<span class="portal-account-bar-access">' + escapeHTML(titleCase(account.role)) + ' access</span>' +
'</div>' +
'<div class="portal-account-bar-row">' +
'<h2>' + escapeHTML(account.name) + '</h2>' +
'<div class="portal-account-bar-chips">' +
@@ -657,28 +631,37 @@ function renderAccountTeamSection(account: PortalAccountSummary): string {
'</div>' +
'</div>' +
'<div class="team-side-column">' +
'<div class="team-invite-panel">' +
'<div class="team-panel-heading">' +
'<h4>Invite someone new</h4>' +
'<p>Add another operator with the minimum role they need for this account.</p>' +
'<div class="team-operations-panel">' +
'<div class="team-panel-heading team-panel-heading-tight">' +
'<div class="account-panel-kicker">Access desk</div>' +
'<h4>Invite and role policy</h4>' +
'<p>Keep the roster deliberate. Invite the smallest role first, then tighten access as responsibilities become clearer.</p>' +
'</div>' +
'<div class="team-invite">' +
'<div><label for="invite-email-' +
escapeAttr(account.id) +
'">Email</label><input type="email" id="invite-email-' +
escapeAttr(account.id) +
'" placeholder="user@example.com" autocomplete="off"></div>' +
'<div><label for="invite-role-' +
escapeAttr(account.id) +
'">Role</label><select id="invite-role-' +
escapeAttr(account.id) +
'"><option value="admin">Admin</option><option value="tech">Tech</option><option value="read_only">Read-only</option></select></div>' +
'<button type="button" class="btn-primary btn-compact" data-action="invite-member" data-account-id="' +
escapeAttr(account.id) +
'">Invite</button>' +
'<div class="team-operations-grid">' +
'<div class="team-invite-panel">' +
'<div class="team-panel-heading">' +
'<h4>Invite someone new</h4>' +
'<p>Add another operator with the minimum role they need for this account.</p>' +
'</div>' +
'<div class="team-invite">' +
'<div><label for="invite-email-' +
escapeAttr(account.id) +
'">Email</label><input type="email" id="invite-email-' +
escapeAttr(account.id) +
'" placeholder="user@example.com" autocomplete="off"></div>' +
'<div><label for="invite-role-' +
escapeAttr(account.id) +
'">Role</label><select id="invite-role-' +
escapeAttr(account.id) +
'"><option value="admin">Admin</option><option value="tech">Tech</option><option value="read_only">Read-only</option></select></div>' +
'<button type="button" class="btn-primary btn-compact" data-action="invite-member" data-account-id="' +
escapeAttr(account.id) +
'">Invite</button>' +
'</div>' +
'</div>' +
accessPolicy +
'</div>' +
'</div>' +
accessPolicy +
'</div>' +
'</div>' +
'</section>' +
@@ -765,7 +748,6 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
'<div class="portal-shell-layout">' +
renderShellNavigation(accounts, context.bootstrap.support_email || '', activeSection) +
'<div class="portal-shell-main">' +
renderOverviewBand(accounts) +
(accounts.length === 1 ? renderPrimaryAccountBar(accounts[0]) : '') +
'<section class="portal-content-panel portal-content-panel-overview">' +
'<div id="accounts-root">' + hostedContent + '</div>' +
+62 -29
View File
@@ -411,8 +411,8 @@ header .logout-btn:hover,
.portal-shell-main {
min-width: 0;
min-height: 820px;
padding: 0 0 28px;
background: #f5f7fa;
padding: 0 0 24px;
background: #f3f6fa;
}
.portal-shell-main > .portal-content-panel {
@@ -423,16 +423,30 @@ header .logout-btn:hover,
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: end;
padding: 16px 22px 18px;
align-items: center;
padding: 18px 22px 16px;
border-bottom: 1px solid var(--line);
background: rgba(244, 247, 250, 0.92);
background: rgba(255, 255, 255, 0.84);
}
.portal-account-bar-copy {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
}
.portal-account-bar-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.portal-account-bar-separator,
.portal-account-bar-access {
font-size: 12px;
font-weight: 700;
color: var(--ink-muted);
}
.portal-account-bar-row {
@@ -445,9 +459,9 @@ header .logout-btn:hover,
.portal-account-bar-row h2 {
margin: 0;
font-size: 28px;
line-height: 0.98;
letter-spacing: -0.05em;
font-size: 32px;
line-height: 0.96;
letter-spacing: -0.06em;
}
.portal-account-bar-copy p {
@@ -502,14 +516,14 @@ header .logout-btn:hover,
}
.portal-content-panel {
padding: 20px 22px 0;
padding: 16px 22px 0;
}
.portal-top-section,
.service-section {
display: flex;
flex-direction: column;
gap: 16px;
gap: 14px;
}
.portal-top-section-header h2,
@@ -606,11 +620,11 @@ header .logout-btn:hover,
.account-content-panel-overview,
.account-content-panel-workspaces,
.account-content-panel-team {
padding: 18px;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: #eef3f7;
box-shadow: 0 12px 28px rgba(10, 25, 41, 0.045);
border-radius: 14px;
background: #edf2f6;
box-shadow: 0 10px 22px rgba(10, 25, 41, 0.04);
}
.account-command-deck,
@@ -670,12 +684,12 @@ header .logout-btn:hover,
.account-command-deck {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
gap: 12px;
align-items: stretch;
padding: 16px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.6);
border-radius: 12px;
background: rgba(255, 255, 255, 0.72);
}
.account-overview-card {
@@ -838,7 +852,7 @@ header .logout-btn:hover,
.portal-support-panel {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 22px;
padding: 18px 20px;
background: var(--panel);
box-shadow: var(--shadow-sm);
}
@@ -1055,10 +1069,10 @@ header .logout-btn:hover,
grid-template-columns: minmax(0, 1.42fr) minmax(360px, 1fr);
gap: 14px;
align-items: start;
padding: 16px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.56);
border-radius: 12px;
background: rgba(255, 255, 255, 0.68);
}
.workspace-operations-main,
@@ -1066,8 +1080,7 @@ header .logout-btn:hover,
min-width: 0;
}
.workspace-management-panel,
.team-management-panel {
.workspace-management-panel {
min-height: 100%;
}
@@ -1465,17 +1478,37 @@ header .logout-btn:hover,
.team-list-message {
display: flex;
align-items: center;
flex-direction: column;
align-items: flex-start;
justify-content: center;
min-height: 120px;
padding: 16px;
gap: 6px;
min-height: 148px;
padding: 18px;
border: 1px dashed var(--line-strong);
border-radius: 10px;
color: var(--ink-muted);
background: var(--panel-muted);
}
.team-list-message-title {
font-size: 16px;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--ink);
}
.team-list-message-copy {
font-size: 13px;
line-height: 1.55;
color: var(--ink-soft);
}
.team-list-message.error {
border-color: rgba(166, 27, 27, 0.2);
background: var(--danger-soft);
}
.team-list-message.error .team-list-message-title,
.team-list-message.error .team-list-message-copy {
color: var(--danger);
}