diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 87bacdae7..d35c7ba72 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "f26a39d5beef5f6fc0649b14f45a48c68df81e299b579087430cd05e42c810bd", + "source_hash": "07cc157c9e51171f54b181dc953c685e2687e14af66407867c678e0bcd370540", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index b731ad6f3..88c320544 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -213,6 +213,13 @@ header .logout-btn:hover { .btn-danger:hover { background: #fef2f2; } +.btn-compact { + padding: 8px 14px; + font-size: 13px; +} +.link-button { + text-decoration: none; +} .account-actions { margin-top: 14px; display: flex; @@ -285,6 +292,14 @@ header .logout-btn:hover { border-bottom: 1px solid #f1f5f9; vertical-align: middle; } +.team-message-cell { + text-align: center; + padding: 16px; + color: #94a3b8; +} +.team-message-cell.error { + color: #991b1b; +} .team-table select { border: 1px solid #cbd5e1; border-radius: 6px; @@ -498,6 +513,9 @@ header .logout-btn:hover { color: #0f172a; word-break: break-word; } +.service-result { + margin-top: 14px; +} .service-panel .subsection { margin-top: 18px; padding-top: 18px; @@ -572,8 +590,26 @@ header .logout-btn:hover { margin: 0; font-size: 15px; } +.empty-state-spaced { + margin-top: 48px; +} +.support-copy { + margin-top: 12px; + font-size: 13px; +} +.support-link { + color: #1d4ed8; +} +.badge-role { + background: #f1f5f9; + color: #64748b; +} +.workspace-state-label { + font-size: 13px; + color: #94a3b8; +} .spinner { - display: none; + display: inline-block; width: 18px; height: 18px; border: 2px solid #93c5fd; @@ -581,6 +617,9 @@ header .logout-btn:hover { border-radius: 50%; animation: spin 0.6s linear infinite; } +[hidden] { + display: none !important; +} @keyframes spin { to { transform: rotate(360deg); diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index ef993df0a..88c380ebb 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -15,7 +15,7 @@ var tr = document.createElement("tr"); var td = document.createElement("td"); td.setAttribute("colspan", "3"); - td.style.cssText = "text-align:center;padding:16px;color:" + (isError ? "#991b1b" : "#94a3b8"); + td.className = "team-message-cell" + (isError ? " error" : ""); td.textContent = msg; tr.appendChild(td); tbody.appendChild(tr); @@ -63,7 +63,7 @@ if (!form) return; form.classList.toggle("visible", entry.addWorkspaceOpen); if (spinner) { - spinner.style.display = entry.createWorkspace.pending ? "block" : "none"; + spinner.hidden = !entry.createWorkspace.pending; } } function renderTeamSection(accountID, entry) { @@ -832,7 +832,7 @@ function setVisible(id, visible) { var el = getElement3(id); if (el) { - el.style.display = visible ? "block" : "none"; + el.hidden = !visible; } } function setValue(id, value) { @@ -876,23 +876,19 @@ function renderManagePanel(flowState) { var root = getElement3("manage-service-root"); if (!root) return; - root.innerHTML = '

Manage subscriptions

Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.

Need a new code? Send again
'; + root.innerHTML = '

Manage subscriptions

Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.

Need a new code? Send again
'; } function renderRetrievePanel(flowState) { var root = getElement3("retrieve-service-root"); if (!root) return; var result = flowState.result; var invoiceURL = result && result.invoice_url ? result.invoice_url : "#"; - var invoiceDisplay = result && result.invoice_url ? "inline-block" : "none"; - var copyDisplay = result ? "inline-block" : "none"; - var resultDisplay = result ? "block" : "none"; - root.innerHTML = '

Retrieve licenses

Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.

View Invoice
Use the latest active self-hosted license for this commercial email.
Plan
' + escapeText(result ? result.tier : "") + '
Issued
' + escapeText(result ? new Date(result.issued_at).toLocaleString() : "") + '
Expires
' + escapeText(result ? result.expires_at ? new Date(result.expires_at).toLocaleString() : "Does not expire" : "") + '
Purchase Email
' + escapeText(result ? result.email : "") + "
"; + root.innerHTML = '

Retrieve licenses

Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.

View Invoice
Use the latest active self-hosted license for this commercial email.
Plan
' + escapeText(result ? result.tier : "") + '
Issued
' + escapeText(result ? new Date(result.issued_at).toLocaleString() : "") + '
Expires
' + escapeText(result ? result.expires_at ? new Date(result.expires_at).toLocaleString() : "Does not expire" : "") + '
Purchase Email
' + escapeText(result ? result.email : "") + "
"; } function renderExportPanel(flowState) { var root = getElement3("data-export-root"); if (!root) return; - var resultDisplay = flowState.result ? "block" : "none"; - root.innerHTML = '

Export My Data

Need a new code? Send again
"; + root.innerHTML = '

Export My Data

Need a new code? Send again
"; } function renderExportResult(result) { setVisible("data-export-result", !!result); @@ -901,7 +897,7 @@ function renderDeletePanel(flowState) { var root = getElement3("data-delete-root"); if (!root) return; - root.innerHTML = '

Delete My Data

Warning: deleting commercial data also revokes license records and cannot be undone.
I understand this permanently deletes my commercial data and revokes associated licenses.
Need a new code? Send again
'; + root.innerHTML = '

Delete My Data

Warning: deleting commercial data also revokes license records and cannot be undone.
I understand this permanently deletes my commercial data and revokes associated licenses.
Need a new code? Send again
'; } // src/services_controller.ts @@ -1378,9 +1374,9 @@ return date.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" }); } function roleBadgeHTML(role) { - if (role === "owner") return 'Owner'; - if (role === "admin") return 'Admin'; - if (role === "tech") return 'Tech'; + if (role === "owner") return 'Owner'; + if (role === "admin") return 'Admin'; + if (role === "tech") return 'Tech'; return ""; } function renderWorkspaceCard(account, workspace, accountAPIBasePath) { @@ -1391,7 +1387,7 @@ if (state === "active") { openAction = '
'; } else { - openAction = '' + safeState + ""; + openAction = '' + safeState + ""; } var manageAction = ""; if (account.can_manage && (state === "active" || state === "suspended" || state === "failed")) { @@ -1415,9 +1411,9 @@ var addWorkspaceForm = ""; if (account.can_manage) { actions = '
' + (account.kind === "msp" ? '' : "") + (account.has_billing ? '' : "") + '
'; - teamSection = '

Team members

EmailRole
Loading\u2026
'; + teamSection = '

Team members

EmailRole
Loading\u2026
'; if (account.kind === "msp") { - addWorkspaceForm = '
'; + addWorkspaceForm = '
'; } } return '

' + escapeHTML(account.name) + '

' + escapeHTML(account.kind_label) + "" + roleBadgeHTML(account.role) + "
" + workspaceHTML + actions + teamSection + addWorkspaceForm + "
"; @@ -1426,12 +1422,12 @@ if (context.bootstrap.authenticated) { return "" + escapeHTML(context.bootstrap.email || "") + ''; } - return 'Create account'; + return 'Create account'; } function renderAccountsHTML(context) { var safeAccounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : []; if (safeAccounts.length === 0) { - return '

No workspaces found. If you just signed up, check your email for setup instructions.

Need help? Contact ' + escapeHTML(context.bootstrap.support_email || "") + "

"; + return '

No workspaces found. If you just signed up, check your email for setup instructions.

Need help? Contact ' + escapeHTML(context.bootstrap.support_email || "") + "

"; } return safeAccounts.map(function(account) { return renderAccountSection(account, context.accountAPIBasePath); @@ -1447,7 +1443,7 @@ } else if (context.loginState.success) { statusHTML = `
Magic link sent. Check your inbox and click the link to sign in.

Don't see it? Send a new link.
`; } - return '

Pulse Account

Sign in to manage Cloud workspaces, MSP access, and commercial account services from one account surface.

Sign in

Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.

Create an account
' + statusHTML + "
"; + return '

Pulse Account

Sign in to manage Cloud workspaces, MSP access, and commercial account services from one account surface.

Sign in

Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.

Create an account
' + statusHTML + "
"; } // src/shell.ts diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts index 397e21d0a..6b3152633 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts @@ -80,7 +80,7 @@ describe('account runtime', function() { document.body.innerHTML = '
' + '' + - '' + + '' + '
'; runtime.toggleAddWorkspace('acct_1'); @@ -103,7 +103,7 @@ describe('account runtime', function() { expect(deps.showToast).toHaveBeenCalledWith('Workspace created!'); expect(deps.store.getAccountState().byAccountID.acct_1.addWorkspaceOpen).toBe(false); expect(deps.store.getAccountState().byAccountID.acct_1.createWorkspace.pending).toBe(false); - expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).style.display).toBe('none'); + expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).hidden).toBe(true); }); it('loads and updates team membership from runtime actions', async function() { diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts index 8c326e73e..44b086d83 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts @@ -28,17 +28,17 @@ describe('account view', function() { it('renders add-workspace visibility from account UI state', function() { document.body.innerHTML = '
' + - ''; + ''; renderAddWorkspaceSection('acct_1', createEntry({ addWorkspaceOpen: true })); expect(document.getElementById('add-ws-form-acct_1')?.classList.contains('visible')).toBe(true); - expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).style.display).toBe('none'); + expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).hidden).toBe(true); renderAddWorkspaceSection('acct_1', createEntry({ addWorkspaceOpen: true, createWorkspace: { pending: true, error: '' }, })); - expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).style.display).toBe('block'); + expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).hidden).toBe(false); renderAddWorkspaceSection('acct_1', createEntry({ addWorkspaceOpen: false })); expect(document.getElementById('add-ws-form-acct_1')?.classList.contains('visible')).toBe(false); diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index ae1b5f596..4dcebd577 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -20,7 +20,7 @@ function setTbodyMessage(tbody: HTMLElement, msg: string, isError: boolean): voi var tr = document.createElement('tr'); var td = document.createElement('td'); td.setAttribute('colspan', '3'); - td.style.cssText = 'text-align:center;padding:16px;color:' + (isError ? '#991b1b' : '#94a3b8'); + td.className = 'team-message-cell' + (isError ? ' error' : ''); td.textContent = msg; tr.appendChild(td); tbody.appendChild(tr); @@ -73,7 +73,7 @@ export function renderAddWorkspaceSection(accountID: string, entry: PortalAccoun if (!form) return; form.classList.toggle('visible', entry.addWorkspaceOpen); if (spinner) { - spinner.style.display = entry.createWorkspace.pending ? 'block' : 'none'; + spinner.hidden = !entry.createWorkspace.pending; } } diff --git a/internal/cloudcp/portal/frontend/src/app.test.ts b/internal/cloudcp/portal/frontend/src/app.test.ts index aad76ea69..179d4f483 100644 --- a/internal/cloudcp/portal/frontend/src/app.test.ts +++ b/internal/cloudcp/portal/frontend/src/app.test.ts @@ -295,8 +295,8 @@ describe('portal app', function() { }), ]); expect((store.getServiceState().flows.retrieve.result as { token?: string } | null)?.token).toBe('pulse_token_123'); - expect((document.getElementById('retrieve-inline-result') as HTMLElement | null)?.style.display).toBe('block'); - expect((document.getElementById('retrieve-inline-copy') as HTMLButtonElement | null)?.style.display).toBe('inline-block'); + expect((document.getElementById('retrieve-inline-result') as HTMLElement | null)?.hidden).toBe(false); + expect((document.getElementById('retrieve-inline-copy') as HTMLButtonElement | null)?.hidden).toBe(false); expect(document.getElementById('retrieve-inline-status')?.textContent).toContain('License retrieved successfully.'); }); diff --git a/internal/cloudcp/portal/frontend/src/services_view.test.ts b/internal/cloudcp/portal/frontend/src/services_view.test.ts index a9a3796ef..5087a2086 100644 --- a/internal/cloudcp/portal/frontend/src/services_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/services_view.test.ts @@ -115,12 +115,12 @@ describe('services view', function() { var invoiceLink = document.getElementById('retrieve-inline-invoice') as HTMLAnchorElement; var tokenArea = document.getElementById('retrieve-inline-token') as HTMLTextAreaElement; - expect(copyButton.style.display).toBe('inline-block'); + expect(copyButton.hidden).toBe(false); expect(invoiceLink.href).toBe('https://license.pulserelay.pro/invoices/inv_123'); - expect(invoiceLink.style.display).toBe('inline-block'); + expect(invoiceLink.hidden).toBe(false); expect(tokenArea.value).toBe('pulse_key_123'); expect(document.getElementById('retrieve-inline-email-value')?.textContent).toBe('buyer@example.com'); - expect(document.getElementById('retrieve-inline-result')?.style.display).toBe('block'); + expect((document.getElementById('retrieve-inline-result') as HTMLElement).hidden).toBe(false); }); it('renders refund and delete panels from owned state', function() { @@ -152,13 +152,13 @@ describe('services view', function() { expect(refundToken.value).toBe('pulse_token'); expect(document.getElementById('refund-service-root')?.innerHTML).toContain('/refund.html?email=owner%40example.com'); expect(deleteCheck.checked).toBe(true); - expect(document.getElementById('data-delete-step2')?.style.display).toBe('block'); + expect((document.getElementById('data-delete-step2') as HTMLElement).hidden).toBe(false); }); it('renders export panel and updates export result payload visibility', function() { document.body.innerHTML = '
' + - '' + + '' + ''; renderExportPanel( @@ -175,8 +175,8 @@ describe('services view', function() { renderExportResult(payload); expect((document.getElementById('data-export-email') as HTMLInputElement).value).toBe('owner@example.com'); - expect(document.getElementById('data-export-step2')?.style.display).toBe('block'); - expect(document.getElementById('data-export-result')?.style.display).toBe('block'); + expect((document.getElementById('data-export-step2') as HTMLElement).hidden).toBe(false); + expect((document.getElementById('data-export-result') as HTMLElement).hidden).toBe(false); expect((document.getElementById('data-export-payload') as HTMLTextAreaElement).value).toBe( JSON.stringify(payload, null, 2) ); @@ -202,7 +202,7 @@ describe('services view', function() { expect((document.getElementById('manage-inline-email') as HTMLInputElement).value).toBe('owner@example.com'); expect((document.getElementById('manage-inline-code') as HTMLInputElement).value).toBe('123456'); - expect(document.getElementById('manage-inline-step2')?.style.display).toBe('block'); + expect((document.getElementById('manage-inline-step2') as HTMLElement).hidden).toBe(false); expect(document.getElementById('manage-inline-status')?.className).toContain('success'); expect(document.getElementById('manage-inline-status')?.textContent).toBe('Code sent.'); }); diff --git a/internal/cloudcp/portal/frontend/src/services_view.ts b/internal/cloudcp/portal/frontend/src/services_view.ts index b0d6141bb..934c4a10f 100644 --- a/internal/cloudcp/portal/frontend/src/services_view.ts +++ b/internal/cloudcp/portal/frontend/src/services_view.ts @@ -36,7 +36,7 @@ export function focusElement(id: string): void { export function setVisible(id: string, visible: boolean): void { var el = getElement(id); if (el) { - el.style.display = visible ? 'block' : 'none'; + el.hidden = !visible; } } @@ -114,7 +114,7 @@ export function renderManagePanel(flowState: VerificationFlowState): void { '' + '' + '' + - '
' + + '
' + '
' + '' + '' + @@ -139,9 +139,6 @@ export function renderRetrievePanel(flowState: VerificationFlowState): void { email?: string; } | null; var invoiceURL = result && result.invoice_url ? result.invoice_url : '#'; - var invoiceDisplay = result && result.invoice_url ? 'inline-block' : 'none'; - var copyDisplay = result ? 'inline-block' : 'none'; - var resultDisplay = result ? 'block' : 'none'; root.innerHTML = '' + '

Retrieve licenses

' + '

Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.

' + @@ -154,20 +151,20 @@ export function renderRetrievePanel(flowState: VerificationFlowState): void { '' + '
' + '
' + - '
' + + '
' + '
' + '' + '' + '
' + '
' + '' + - '' + - 'View Invoice' + + '' + + 'View Invoice' + '
' + '
Use the latest active self-hosted license for this commercial email.
' + '
' + '
' + - '
' + + '
' + '' + '' + '
' + @@ -182,7 +179,6 @@ export function renderRetrievePanel(flowState: VerificationFlowState): void { export function renderExportPanel(flowState: VerificationFlowState): void { var root = getElement('data-export-root'); if (!root) return; - var resultDisplay = flowState.result ? 'block' : 'none'; root.innerHTML = '' + '

Export My Data

' + '
' + @@ -194,7 +190,7 @@ export function renderExportPanel(flowState: VerificationFlowState): void { '' + '
' + '
' + - '
' + + '
' + '
' + '' + '' + @@ -205,7 +201,7 @@ export function renderExportPanel(flowState: VerificationFlowState): void { '
Need a new code? Send again
' + '
' + '
' + - '
' + + '
' + '' + '' + '
'; @@ -231,7 +227,7 @@ export function renderDeletePanel(flowState: VerificationFlowState): void { '' + '
' + '
' + - '
' + + '
' + '
' + '' + '' + diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 365fdd597..2b414084d 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -33,9 +33,9 @@ function formatWorkspaceDate(value: unknown): string { } function roleBadgeHTML(role: string): string { - if (role === 'owner') return 'Owner'; - if (role === 'admin') return 'Admin'; - if (role === 'tech') return 'Tech'; + if (role === 'owner') return 'Owner'; + if (role === 'admin') return 'Admin'; + if (role === 'tech') return 'Tech'; return ''; } @@ -52,7 +52,7 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor '' + ''; } else { - openAction = '' + safeState + ''; + openAction = '' + safeState + ''; } var manageAction = ''; @@ -141,7 +141,7 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath: '' + - 'Loading…' + + 'Loading…' + '' + '' + '
' + @@ -155,7 +155,7 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath: '">Role
' + - '' + '
' + @@ -181,7 +181,7 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath: '">Cancel' + '
' + + '" hidden>
' + '
' + '
'; } @@ -209,18 +209,18 @@ export function renderHeaderHTML(context: ShellViewContext): string { '' ); } - return 'Create account'; + return 'Create account'; } export function renderAccountsHTML(context: ShellViewContext): string { var safeAccounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : []; if (safeAccounts.length === 0) { return ( - '
' + + '
' + '

No workspaces found. If you just signed up, check your email for setup instructions.

' + - '

Need help? Contact Need help? Contact ' + + '" class="support-link">' + escapeHTML(context.bootstrap.support_email || '') + '

' + '
' @@ -309,7 +309,7 @@ export function renderSignedOutPortalHTML(context: ShellViewContext): string { '' + - 'Create an account' + + 'Create an account' + '
' + statusHTML + '
' + diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index c923420f8..d7cf5dbd4 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -36,6 +36,8 @@ .btn-secondary:hover { background: #f8fafc; border-color: #94a3b8; } .btn-danger { background: #fff; color: #dc2626; border: 1px solid #fca5a5; border-radius: 8px; padding: 7px 14px; font-size: 13px; cursor: pointer; } .btn-danger:hover { background: #fef2f2; } + .btn-compact { padding: 8px 14px; font-size: 13px; } + .link-button { text-decoration: none; } .account-actions { margin-top: 14px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .add-workspace-form { margin-top: 12px; display: none; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; } .add-workspace-form.visible { display: block; } @@ -48,6 +50,8 @@ .team-table { width: 100%; border-collapse: collapse; font-size: 14px; } .team-table th { text-align: left; font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 8px; border-bottom: 1px solid #e2e8f0; } .team-table td { padding: 8px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; } + .team-message-cell { text-align: center; padding: 16px; color: #94a3b8; } + .team-message-cell.error { color: #991b1b; } .team-table select { border: 1px solid #cbd5e1; border-radius: 6px; padding: 4px 8px; font-size: 13px; background: #fff; } .team-table .btn-remove { background: none; border: none; color: #dc2626; cursor: pointer; font-size: 13px; padding: 4px 8px; } .team-table .btn-remove:hover { text-decoration: underline; } @@ -83,6 +87,7 @@ .service-panel .result-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 14px; } .service-panel .result-meta-label { font-size: 12px; color: #64748b; margin-bottom: 4px; } .service-panel .result-meta-value { font-size: 14px; color: #0f172a; word-break: break-word; } + .service-result { margin-top: 14px; } .service-panel .subsection { margin-top: 18px; padding-top: 18px; border-top: 1px solid #e2e8f0; } .service-panel .subsection:first-of-type { margin-top: 0; padding-top: 0; border-top: none; } .service-panel .subsection h4 { margin: 0 0 8px; font-size: 15px; font-weight: 700; } @@ -96,7 +101,13 @@ .service-status.success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; } .empty-state { background: #fff; border: 1px dashed #cbd5e1; border-radius: 10px; padding: 32px; text-align: center; color: #64748b; } .empty-state p { margin: 0; font-size: 15px; } - .spinner { display: none; width: 18px; height: 18px; border: 2px solid #93c5fd; border-top-color: #1d4ed8; border-radius: 50%; animation: spin 0.6s linear infinite; } + .empty-state-spaced { margin-top: 48px; } + .support-copy { margin-top: 12px; font-size: 13px; } + .support-link { color: #1d4ed8; } + .badge-role { background: #f1f5f9; color: #64748b; } + .workspace-state-label { font-size: 13px; color: #94a3b8; } + .spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid #93c5fd; border-top-color: #1d4ed8; border-radius: 50%; animation: spin 0.6s linear infinite; } + [hidden] { display: none !important; } @keyframes spin { to { transform: rotate(360deg); } } .toast { position: fixed; bottom: 24px; right: 24px; background: #1e293b; color: #f8fafc; border-radius: 8px; padding: 12px 20px; font-size: 14px; display: none; z-index: 100; } .toast.visible { display: block; animation: fadein 0.2s; }