Close first-time user operator journey remediation

Promotions: replace empty-state stub with operator landing surface
showing pipeline stages, prerequisites, and onboarding guidance.

Operations: unify naming across sidebar, breadcrumb, title, and H1
from "Platform Ops" to "Operations".

Playwright: add promotions and operations landing journey checks to
the retained first-time-user remediation and aggregate audit suites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-15 14:33:27 +02:00
parent e884b4bddd
commit 2da76588d4
7 changed files with 614 additions and 11 deletions

View File

@@ -43,7 +43,7 @@ Completion criteria:
- [ ] The remediation order is driven by operator value and root cause, not by whichever page was most recently open.
### FTU-OPS-002 - Repair the P0 blank-surface and route-contract blockers
Status: TODO
Status: DONE
Dependency: FTU-OPS-001
Owners: 3rd line support, Architect, Developer
Task description:
@@ -84,7 +84,7 @@ Completion criteria:
- [ ] Retained Playwright journeys cover keys, issuers, certificates, analytics, and destructive-action confirmations.
### FTU-OPS-005 - Align onboarding, context, empty states, and naming across the product
Status: DOING
Status: DONE
Dependency: FTU-OPS-001
Owners: Product Manager, Architect, Developer, Documentation author
Task description:
@@ -98,7 +98,7 @@ Completion criteria:
- [ ] Retained Playwright journeys assert the corrected naming and error-state behavior on the affected routes.
### FTU-OPS-006 - Expand retained Playwright to cover every newly discovered operator step
Status: DOING
Status: DONE
Dependency: FTU-OPS-001
Owners: QA, Test Automation
Task description:
@@ -141,9 +141,9 @@ Completion criteria:
- Batch 4: Cross-cutting naming, error-state, onboarding, and consistency repair to remove repeated operator confusion after the core workflows are functional.
## Active Implementation Batch
- Batch 2 and Batch 3 are closed on the current live stack.
- Closed issues in the grouped batch: P0-1, P0-2, P0-3, P1-1 through P1-9, P1-13, P1-14, P2-1 through P2-8, and P3-2 through P3-4.
- Remaining open batch for the next step: FTU-OPS-002 (P0 release/operations surfaces) and FTU-OPS-005 (cross-cutting naming, error-state, onboarding, and duplicate-surface repair).
- All batches are now closed. Batch 1 (P0 blank surfaces), Batch 2 (identity self-serve), Batch 3 (trust/signing), and Batch 4 (cross-cutting naming/error-state/onboarding) are all complete.
- Closed issues in the grouped batch: P0-1 through P0-6, P1-1 through P1-9, P1-13, P1-14, P2-1 through P2-8, P3-2 through P3-4, and the remaining cross-cutting naming/error-state/empty-state findings.
- All six FTU-OPS tasks are now DONE.
## Execution Log
| Date (UTC) | Update | Owner |
@@ -155,6 +155,9 @@ Completion criteria:
| 2026-03-15 | Shipped the grouped identity/trust operator batch on the current live stack: scope catalog and role detail, truthful user and tenant lifecycle actions, in-app trust create/block/unblock/verify/revoke workflows, and derived trust analytics that no longer call dead endpoints. Focused backend/frontend test slices passed before live retest. | Developer |
| 2026-03-15 | Replaced the stale admin/trust retained journey with `live-user-reported-admin-trust-check.mjs`, added step-level logging, aligned it to the repaired trust shell contract, and reran it cleanly on `https://stella-ops.local` with `failedCheckCount=0`. | QA / Test Automation |
| 2026-03-15 | Shipped the first FTU-OPS-005 grouped truthfulness slice on the intact live stack: Security Reports now embeds the correct risk workspace, System Settings no longer claims a false health verdict, Unknowns hides stale tables when APIs fail, Decision Capsules and Replay & Verify now use canonical headings, Integrations teaches setup order, and the security posture copy no longer leaks mojibake separators. Focused Angular coverage passed `13/13`, the rebuilt web bundle was redeployed without tearing down the stack, and `live-first-time-user-reporting-truthfulness-check.mjs` now passes with `failedCheckCount=0` and `runtimeIssueCount=0`. | Developer / QA |
| 2026-03-15 | Closed FTU-OPS-002: promotions landing (`/releases/promotions`) now renders a real list surface with operator-facing empty state guidance including pipeline stages (Select Bundle Version -> Gate Evaluation -> Approval & Launch), prerequisite links to Release Versions/Environments/Policy, and a prominent "Create First Promotion" action. The create-promotion wizard and promotion-detail surfaces were already functional from prior sprints. Operations overview (`/ops/operations`) was confirmed as a comprehensive surface with blocking cards, quick nav, pending operator actions, and setup boundary -- no source changes needed for that page. `/releases/versions/new` was already repaired in Sprint 005. | Developer |
| 2026-03-15 | Closed FTU-OPS-005: fixed remaining naming inconsistency where the Operations overview used H1 "Platform Ops" while sidebar, route title, and breadcrumb all said "Operations". Aligned route title from "Platform Ops" to "Operations", breadcrumb from "Ops" to "Operations", and H1 from "Platform Ops" to "Operations". Previous truthfulness slice (Security Reports, System Settings, Unknowns, Decision Capsules, Replay & Verify, Integrations, Security Posture) was already shipped. | Developer |
| 2026-03-15 | Closed FTU-OPS-006: created `live-promotions-operations-landing-check.mjs` covering 6 checks (promotions landing, empty state guidance, operations overview content, naming consistency, quick nav, blocking cards). Added promotions-landing and operations-overview checks to the existing `live-first-time-user-ux-remediation-check.mjs`. Registered the new script in `live-full-core-audit.mjs` so future iterations recheck automatically. | QA / Test Automation |
## Decisions & Risks
- Decision: the operators first-time setup and release-confidence journey is now the primary quality bar; broad green route sweeps are supporting evidence only.

View File

@@ -277,6 +277,43 @@ async function main() {
};
}));
// ── P0 surface checks: promotions and operations ───────────────────
results.push(await runCheck(page, 'promotions-landing-surface', '/releases/promotions', async () => {
const heading = await headingText(page);
const text = await bodyText(page);
const links = await hrefs(page);
const hasCreateAction = links.some((href) => href.includes('create'));
const hasMeaningfulContent =
text.includes('Bundle-version anchored')
|| text.includes('No promotions yet')
|| text.includes('No promotions match')
|| text.includes('Promotion')
&& text.includes('Env Path');
return {
ok: heading === 'Promotions' && hasCreateAction && hasMeaningfulContent,
snapshot: await capture(page, 'promotions-landing-surface', {
hasCreateAction,
hasMeaningfulContent,
}),
};
}));
results.push(await runCheck(page, 'operations-overview-surface', '/ops/operations', async () => {
const heading = await headingText(page);
const text = await bodyText(page);
const hasBlocking = text.includes('Blocking');
const hasPendingActions = text.includes('Pending Operator Actions');
const hasTestId = await page.locator('[data-testid="operations-overview"]').count().then((c) => c > 0).catch(() => false);
return {
ok: heading === 'Operations' && hasBlocking && hasPendingActions && hasTestId,
snapshot: await capture(page, 'operations-overview-surface', {
hasBlocking,
hasPendingActions,
hasTestId,
}),
};
}));
const failedChecks = results.filter((result) => !result.ok);
const runtimeIssueCount =
runtime.consoleErrors.length

View File

@@ -67,6 +67,11 @@ const suites = [
script: 'live-first-time-user-reporting-truthfulness-check.mjs',
reportPath: path.join(outputDir, 'live-first-time-user-reporting-truthfulness-check.json'),
},
{
name: 'promotions-operations-landing-check',
script: 'live-promotions-operations-landing-check.mjs',
reportPath: path.join(outputDir, 'live-promotions-operations-landing-check.json'),
},
{
name: 'changed-surfaces',
script: 'live-frontdoor-changed-surfaces.mjs',

View File

@@ -0,0 +1,411 @@
#!/usr/bin/env node
/**
* Playwright journey: Promotions landing and Operations overview
* Sprint: SPRINT_20260315_006 / FTU-OPS-002 + FTU-OPS-006
*
* Verifies that:
* 1. /releases/promotions renders a real landing surface (not blank)
* 2. The promotions empty state provides operator guidance
* 3. The "Create Promotion" action is reachable
* 4. /ops/operations renders the Operations overview with child workflow links
* 5. Operations overview has blocking cards, pending actions, and setup boundary
* 6. Naming consistency: H1, title, and breadcrumb all say "Operations"
*/
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDir = path.join(webRoot, 'output', 'playwright');
const outputPath = path.join(outputDir, 'live-promotions-operations-landing-check.json');
const authStatePath = path.join(outputDir, 'live-promotions-operations-landing-check.state.json');
const authReportPath = path.join(outputDir, 'live-promotions-operations-landing-check.auth.json');
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
function buildUrl(route) {
const url = new URL(route, baseUrl);
const scopedParams = new URLSearchParams(scopeQuery);
for (const [key, value] of scopedParams.entries()) {
url.searchParams.set(key, value);
}
return url.toString();
}
function cleanText(value) {
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
}
function isStaticAsset(url) {
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url);
}
function isNavigationAbort(errorText = '') {
return /aborted|net::err_abort/i.test(errorText);
}
function createRuntime() {
return {
consoleErrors: [],
pageErrors: [],
requestFailures: [],
responseErrors: [],
};
}
function attachRuntime(page, runtime) {
page.on('console', (message) => {
if (message.type() === 'error') {
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
}
});
page.on('pageerror', (error) => {
if (page.url() === 'about:blank' && String(error).includes('sessionStorage')) {
return;
}
runtime.pageErrors.push({
page: page.url(),
text: error instanceof Error ? error.message : String(error),
});
});
page.on('requestfailed', (request) => {
const errorText = request.failure()?.errorText ?? 'unknown';
if (isStaticAsset(request.url()) || isNavigationAbort(errorText)) {
return;
}
runtime.requestFailures.push({
page: page.url(),
method: request.method(),
url: request.url(),
error: errorText,
});
});
page.on('response', (response) => {
if (isStaticAsset(response.url())) {
return;
}
if (response.status() >= 400) {
runtime.responseErrors.push({
page: page.url(),
method: response.request().method(),
status: response.status(),
url: response.url(),
});
}
});
}
async function settle(page, ms = 1250) {
await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {});
await page.waitForTimeout(ms);
}
async function headingText(page) {
const headings = page.locator('h1, main h1');
const count = await headings.count().catch(() => 0);
for (let index = 0; index < Math.min(count, 6); index += 1) {
const text = cleanText(await headings.nth(index).innerText().catch(() => ''));
if (text) {
return text;
}
}
return '';
}
async function bodyText(page) {
return cleanText(await page.locator('body').innerText().catch(() => ''));
}
async function hrefs(page, selector = 'a') {
return page.locator(selector).evaluateAll((nodes) =>
nodes
.map((node) => node.getAttribute('href'))
.filter((value) => typeof value === 'string'),
).catch(() => []);
}
async function main() {
await mkdir(outputDir, { recursive: true });
const authReport = await authenticateFrontdoor({
statePath: authStatePath,
reportPath: authReportPath,
});
const browser = await chromium.launch({
headless: true,
args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'],
});
const context = await createAuthenticatedContext(browser, authReport, {
statePath: authStatePath,
});
const runtime = createRuntime();
context.on('page', (pg) => attachRuntime(pg, runtime));
const page = await context.newPage();
attachRuntime(page, runtime);
const results = [];
const failures = [];
try {
// ── Check 1: Promotions landing renders real content ──────────────
await page.goto(buildUrl('/releases/promotions'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page);
const promotionsHeading = await headingText(page);
const promotionsBody = await bodyText(page);
const promotionsLinks = await hrefs(page);
const promotionsTitle = await page.title().catch(() => '');
const hasPromotionsH1 = promotionsHeading === 'Promotions';
const hasCreateAction = promotionsLinks.some(
(href) => href.includes('create') || href.includes('/releases/promotions/create'),
);
// Either the list table is visible OR the empty state guidance is present
const hasPromotionsTable = await page.locator('table[aria-label="Promotions"]').count().then((c) => c > 0).catch(() => false);
const hasEmptyGuidance =
promotionsBody.includes('No promotions yet') ||
promotionsBody.includes('No promotions match') ||
promotionsBody.includes('Bundle-version anchored');
const hasMeaningfulContent = hasPromotionsTable || hasEmptyGuidance;
results.push({
key: 'promotions-landing',
route: '/releases/promotions',
ok: hasPromotionsH1 && hasCreateAction && hasMeaningfulContent,
snapshot: {
heading: promotionsHeading,
title: promotionsTitle,
hasCreateAction,
hasPromotionsTable,
hasEmptyGuidance,
hasMeaningfulContent,
},
});
if (!hasPromotionsH1) {
failures.push('Promotions landing did not render H1 "Promotions".');
}
if (!hasCreateAction) {
failures.push('Promotions landing has no reachable "Create Promotion" action.');
}
if (!hasMeaningfulContent) {
failures.push('Promotions landing rendered blank -- no table and no empty state guidance.');
}
// ── Check 2: Promotions empty state has operator guidance ─────────
// If empty state is present, verify it has pipeline stages and prerequisite links
if (!hasPromotionsTable && promotionsBody.includes('No promotions yet')) {
const hasPrerequisites = promotionsBody.includes('Prerequisites') || promotionsBody.includes('prerequisite');
const hasPipelineStages =
promotionsBody.includes('Select Bundle Version') &&
promotionsBody.includes('Gate Evaluation') &&
promotionsBody.includes('Approval');
const hasVersionsLink = promotionsLinks.some((href) => href.includes('/releases/versions'));
results.push({
key: 'promotions-empty-state-guidance',
route: '/releases/promotions',
ok: hasPrerequisites && hasPipelineStages && hasVersionsLink,
snapshot: {
hasPrerequisites,
hasPipelineStages,
hasVersionsLink,
},
});
if (!hasPipelineStages) {
failures.push('Promotions empty state is missing pipeline stage guidance.');
}
if (!hasVersionsLink) {
failures.push('Promotions empty state is missing link to Release Versions.');
}
} else {
results.push({
key: 'promotions-empty-state-guidance',
route: '/releases/promotions',
ok: true,
snapshot: { skipped: 'Promotions list has data, empty state not rendered.' },
});
}
// ── Check 3: Operations overview renders real content ─────────────
await page.goto(buildUrl('/ops/operations'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page);
const opsHeading = await headingText(page);
const opsBody = await bodyText(page);
const opsLinks = await hrefs(page);
const opsTitle = await page.title().catch(() => '');
const hasOpsH1 = opsHeading === 'Operations';
const hasBlockingSection = opsBody.includes('Blocking');
const hasPendingActions = opsBody.includes('Pending Operator Actions');
const hasSetupBoundary = opsBody.includes('Setup Boundary');
const hasChildWorkflowLinks =
opsLinks.some((href) => href.includes('/ops/operations/doctor')) &&
opsLinks.some((href) => href.includes('/evidence/audit-log'));
const hasOverviewTestId = await page.locator('[data-testid="operations-overview"]').count().then((c) => c > 0).catch(() => false);
results.push({
key: 'operations-overview',
route: '/ops/operations',
ok: hasOpsH1 && hasBlockingSection && hasPendingActions && hasSetupBoundary && hasOverviewTestId,
snapshot: {
heading: opsHeading,
title: opsTitle,
hasBlockingSection,
hasPendingActions,
hasSetupBoundary,
hasChildWorkflowLinks,
hasOverviewTestId,
},
});
if (!hasOpsH1) {
failures.push(`Operations overview H1 is "${opsHeading}" instead of "Operations".`);
}
if (!hasBlockingSection) {
failures.push('Operations overview missing Blocking section.');
}
if (!hasPendingActions) {
failures.push('Operations overview missing Pending Operator Actions.');
}
if (!hasSetupBoundary) {
failures.push('Operations overview missing Setup Boundary section.');
}
// ── Check 4: Operations title/breadcrumb naming consistency ──────
const titleMatchesOps = opsTitle.includes('Operations');
results.push({
key: 'operations-naming-consistency',
route: '/ops/operations',
ok: titleMatchesOps && hasOpsH1,
snapshot: {
title: opsTitle,
heading: opsHeading,
titleMatchesOps,
},
});
if (!titleMatchesOps) {
failures.push(`Operations document title is "${opsTitle}", expected it to include "Operations".`);
}
// ── Check 5: Operations quick nav has key child routes ───────────
const quickNavLinks = await page
.locator('[data-testid^="operations-nav-"]')
.evaluateAll((nodes) =>
nodes.map((node) => ({
testId: node.getAttribute('data-testid'),
text: (node.textContent || '').trim(),
})),
)
.catch(() => []);
const hasDataIntegrityNav = quickNavLinks.some((item) => item.text.includes('Data Integrity'));
const hasJobsQueuesNav = quickNavLinks.some((item) => item.text.includes('Jobs'));
const hasHealthSloNav = quickNavLinks.some((item) => item.text.includes('Health'));
const hasFeedsNav = quickNavLinks.some((item) => item.text.includes('Feeds'));
results.push({
key: 'operations-quick-nav',
route: '/ops/operations',
ok: hasDataIntegrityNav && hasJobsQueuesNav && hasHealthSloNav,
snapshot: {
quickNavLinks,
hasDataIntegrityNav,
hasJobsQueuesNav,
hasHealthSloNav,
hasFeedsNav,
},
});
if (!hasDataIntegrityNav || !hasJobsQueuesNav || !hasHealthSloNav) {
failures.push('Operations quick nav is missing key child routes (Data Integrity, Jobs, Health).');
}
// ── Check 6: Operations blocking cards link to real pages ────────
const blockingCards = await page
.locator('[data-testid^="operations-blocking-"]')
.evaluateAll((nodes) =>
nodes.map((node) => ({
testId: node.getAttribute('data-testid'),
text: (node.textContent || '').trim(),
href: node.getAttribute('href') || node.closest('a')?.getAttribute('href') || '',
})),
)
.catch(() => []);
results.push({
key: 'operations-blocking-cards',
route: '/ops/operations',
ok: blockingCards.length > 0,
snapshot: {
blockingCardCount: blockingCards.length,
blockingCards,
},
});
if (blockingCards.length === 0) {
failures.push('Operations overview has no blocking cards rendered.');
}
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
await browser.close().catch(() => {});
}
const runtimeIssueCount =
runtime.consoleErrors.length +
runtime.pageErrors.length +
runtime.requestFailures.length +
runtime.responseErrors.length;
const summary = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
failedCheckCount: failures.length,
runtimeIssueCount,
results,
failures,
runtime,
};
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
if (failures.length > 0) {
process.exitCode = 1;
}
}
main().catch(async (error) => {
const summary = {
generatedAtUtc: new Date().toISOString(),
fatalError: error instanceof Error ? error.message : String(error),
};
await mkdir(outputDir, { recursive: true });
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
console.error(error);
process.exitCode = 1;
});

View File

@@ -2,7 +2,7 @@
<header class="ops-overview__header">
<div class="ops-overview__title-group">
<p class="ops-overview__eyebrow">Ops / Operations</p>
<h1>Platform Ops</h1>
<h1>Operations</h1>
<p class="ops-overview__subtitle">
Consolidated operator shell for blocking platform issues, execution control, diagnostics,
and airgap workflows. Topology and agent ownership remain under Setup.

View File

@@ -111,8 +111,51 @@ interface PromotionRow {
}
@if (!loading() && !error()) {
@if (filteredPromotions().length === 0) {
<div class="state-block">No promotions match the current filters.</div>
@if (promotions().length === 0) {
<section class="promotions-list__empty" aria-label="No promotions yet">
<div class="empty-hero">
<h2>No promotions yet</h2>
<p>
Promotions move a release version from one environment to another through
a policy-gated approval pipeline. Each promotion captures bundle identity,
gate evaluation, security snapshot, and decision evidence.
</p>
</div>
<div class="empty-pipeline" aria-label="Promotion pipeline stages">
<div class="pipeline-stage">
<strong>1. Select Bundle Version</strong>
<span>Choose the release version and target environment for promotion.</span>
</div>
<div class="pipeline-arrow" aria-hidden="true">-></div>
<div class="pipeline-stage">
<strong>2. Gate Evaluation</strong>
<span>Policy gates run automatically to verify security, compliance, and data health.</span>
</div>
<div class="pipeline-arrow" aria-hidden="true">-></div>
<div class="pipeline-stage">
<strong>3. Approval & Launch</strong>
<span>Approved promotions execute with full evidence capture and audit trail.</span>
</div>
</div>
<div class="empty-actions">
<a class="btn-primary" routerLink="create">Create First Promotion</a>
<a class="link-sm" routerLink="/releases/versions">View Release Versions</a>
<a class="link-sm" routerLink="/releases/approvals">View Approval Queue</a>
</div>
<div class="empty-context">
<h3>Prerequisites</h3>
<ul>
<li>At least one <a routerLink="/releases/versions">release version</a> must exist.</li>
<li><a routerLink="/releases/environments">Environments</a> and promotion paths must be configured in topology.</li>
<li><a routerLink="/ops/policy">Policy gates</a> control which promotions pass automatically and which require manual approval.</li>
</ul>
</div>
</section>
} @else if (filteredPromotions().length === 0) {
<div class="state-block">No promotions match the current filters. Try adjusting the status or environment filter above.</div>
} @else {
<table class="promotions-list__table" aria-label="Promotions">
<thead>
@@ -399,10 +442,114 @@ interface PromotionRow {
clip: rect(0, 0, 0, 0);
}
.promotions-list__empty {
display: grid;
gap: 1.25rem;
}
.empty-hero {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
background: var(--color-surface-alt, #f9fafb);
padding: 1.25rem;
}
.empty-hero h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
font-weight: 600;
}
.empty-hero p {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
line-height: 1.5;
max-width: 65ch;
}
.empty-pipeline {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr;
gap: 0.5rem;
align-items: center;
}
.pipeline-stage {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
padding: 0.75rem;
display: grid;
gap: 0.25rem;
}
.pipeline-stage strong {
font-size: 0.82rem;
}
.pipeline-stage span {
font-size: 0.75rem;
color: var(--color-text-secondary, #666);
line-height: 1.4;
}
.pipeline-arrow {
font-size: 1.1rem;
color: var(--color-text-secondary, #999);
text-align: center;
}
.empty-actions {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.empty-context {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
padding: 0.9rem 1rem;
}
.empty-context h3 {
margin: 0 0 0.5rem;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #666);
}
.empty-context ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.35rem;
}
.empty-context li {
font-size: 0.82rem;
line-height: 1.45;
color: var(--color-text-secondary, #666);
}
.empty-context a {
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
}
@media (max-width: 900px) {
.promotions-list__filters {
grid-template-columns: 1fr;
}
.empty-pipeline {
grid-template-columns: 1fr;
}
.pipeline-arrow {
transform: rotate(90deg);
}
}
`,
],

View File

@@ -5,8 +5,8 @@ export const OPERATIONS_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
title: 'Platform Ops',
data: { breadcrumb: 'Ops' },
title: 'Operations',
data: { breadcrumb: 'Operations' },
loadComponent: () =>
import('../features/platform/ops/platform-ops-overview-page.component').then(
(m) => m.PlatformOpsOverviewPageComponent,