Add unified Stella Assistant: mascot + search + AI chat as one

Merge three disconnected help surfaces (Stella mascot, Ctrl+K search,
Advisory AI chat) into one unified assistant. Mascot is the face,
search is its memory, AI chat is its voice.

Backend:
- DB schema (060/061): tips, greetings, glossary, tours, user_state
  tables with 189 tips + 101 greetings seed data
- REST API: GET tips/glossary/tours, GET/PUT user-state with
  longest-prefix route matching and locale fallback
- Admin endpoints: CRUD for tips, glossary, tours (SetupAdmin policy)

Frontend:
- StellaAssistantService: unified mode management (tips/search/chat),
  API-backed tips with static fallback, i18n integration
- Three-mode mascot component: tips, inline search, embedded chat
- StellaGlossaryDirective: DB-backed tooltip annotations for domain terms
- Admin tip editor: CRUD for tips/glossary/tours in Console Admin
- Tour player: step-through guided tours with element highlighting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-30 17:24:39 +03:00
parent ae5059aa1c
commit 8931fc7c0c
21 changed files with 9649 additions and 324 deletions

View File

@@ -0,0 +1,799 @@
/**
* Stella Assistant — Black-Box E2E Tests
*
* QA perspective: tests observable behavior only. No knowledge of internal
* implementation, signals, services, or component structure.
*
* Persona: "Alex" — mid-level developer, new DevOps, first time using Stella Ops.
* Tests verify that Alex can discover, use, and benefit from the assistant.
*
* Coverage:
* 1. Mascot visibility & first impression
* 2. Tips mode — contextual help per page
* 3. Search mode — type-to-search, results, navigation
* 4. Chat mode — ask questions, get answers
* 5. Mode transitions — tips ↔ search ↔ chat
* 6. Dismiss / restore behavior
* 7. Persistence across page navigations
* 8. Tour engine — guided walkthrough
* 9. Admin editor (requires admin role)
* 10. Glossary tooltips
* 11. Context-driven tips (reacts to page state)
* 12. Accessibility (keyboard, ARIA)
* 13. Responsive (mobile viewport)
*/
import { test, expect } from './fixtures/auth.fixture';
const SCREENSHOT_DIR = 'e2e/screenshots/stella-assistant';
async function snap(page: import('@playwright/test').Page, label: string) {
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: false });
}
async function go(page: import('@playwright/test').Page, path: string) {
await page.goto(path, { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
}
// ═══════════════════════════════════════════════════════════════════════════
// 1. MASCOT VISIBILITY & FIRST IMPRESSION
// ═══════════════════════════════════════════════════════════════════════════
test.describe('1 — Mascot Visibility', () => {
test('1.1 mascot appears on dashboard after login', async ({ authenticatedPage: page }) => {
await go(page, '/');
// The mascot should be visible in the bottom-right corner
const mascot = page.getByRole('button', { name: /stella helper/i });
await expect(mascot).toBeVisible({ timeout: 10_000 });
await snap(page, '01-mascot-visible');
});
test('1.2 mascot has recognizable avatar image', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascotImg = page.locator('img[alt*="Stella"]').last();
await expect(mascotImg).toBeVisible();
// Should have a non-zero rendered size
const box = await mascotImg.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThan(20);
expect(box!.height).toBeGreaterThan(20);
});
test('1.3 mascot is present on every authenticated page', async ({ authenticatedPage: page }) => {
const routes = [
'/',
'/releases',
'/security',
'/evidence/overview',
'/ops/operations',
'/setup/integrations',
'/ops/policy/vex',
];
for (const route of routes) {
await go(page, route);
const mascot = page.getByRole('button', { name: /stella helper/i });
await expect(mascot).toBeVisible({ timeout: 5000 });
}
});
test('1.4 mascot auto-opens bubble on first visit to a page', async ({ authenticatedPage: page }) => {
// Clear any stored state to simulate first visit
await page.evaluate(() => {
localStorage.removeItem('stellaops.assistant.state');
localStorage.removeItem('stellaops.helper.preferences');
});
await go(page, '/');
await page.waitForTimeout(2000);
// Bubble should auto-open with a tip
const bubble = page.locator('[role="status"]').first();
await expect(bubble).toBeVisible({ timeout: 5000 });
await snap(page, '01-auto-open-first-visit');
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 2. TIPS MODE — Contextual help per page
// ═══════════════════════════════════════════════════════════════════════════
test.describe('2 — Tips Mode', () => {
test('2.1 clicking mascot opens tip bubble', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
// Bubble should be visible with tip content
const bubble = page.locator('[role="status"]');
await expect(bubble).toBeVisible();
// Should contain a title (bold text about the current page)
const tipText = await bubble.innerText();
expect(tipText.length).toBeGreaterThan(20);
await snap(page, '02-tip-bubble-open');
});
test('2.2 dashboard tips mention dashboard concepts', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const tipText = await page.locator('[role="status"]').innerText();
// Dashboard tips should reference dashboard-related concepts
const dashboardTerms = ['dashboard', 'overview', 'environment', 'sbom', 'vulnerability', 'feed', 'status'];
const hasRelevantTerm = dashboardTerms.some(term =>
tipText.toLowerCase().includes(term)
);
expect(hasRelevantTerm, `Tip should mention a dashboard concept. Got: "${tipText.slice(0, 100)}"`).toBe(true);
});
test('2.3 tips change when navigating to a different page', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const dashboardTip = await page.locator('[role="status"]').innerText();
// Navigate to VEX page
await go(page, '/ops/policy/vex');
await mascot.click();
await page.waitForTimeout(500);
const vexTip = await page.locator('[role="status"]').innerText();
// Tips should be different
expect(vexTip).not.toEqual(dashboardTip);
// VEX tip should mention VEX-related concepts
const vexTerms = ['vex', 'vulnerability', 'exploitability', 'statement', 'affected'];
const hasVexTerm = vexTerms.some(term => vexTip.toLowerCase().includes(term));
expect(hasVexTerm, `VEX tip should mention VEX concepts. Got: "${vexTip.slice(0, 100)}"`).toBe(true);
});
test('2.4 tip navigation (prev/next) cycles through tips', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
// Should show tip counter "1 / N"
const counter = page.locator('text=/\\d+ \\/ \\d+/');
await expect(counter).toBeVisible();
const counterText = await counter.innerText();
expect(counterText).toMatch(/1 \/ \d+/);
// Get first tip title
const firstTip = await page.locator('[role="status"]').innerText();
// Click Next
const nextBtn = page.getByRole('button', { name: /next tip/i });
await expect(nextBtn).toBeEnabled();
await nextBtn.click();
await page.waitForTimeout(300);
// Counter should update
const newCounter = await counter.innerText();
expect(newCounter).toMatch(/2 \/ \d+/);
// Tip content should change
const secondTip = await page.locator('[role="status"]').innerText();
expect(secondTip).not.toEqual(firstTip);
// Click Prev
const prevBtn = page.getByRole('button', { name: /previous tip/i });
await expect(prevBtn).toBeEnabled();
await prevBtn.click();
await page.waitForTimeout(300);
const backToFirst = await counter.innerText();
expect(backToFirst).toMatch(/1 \/ \d+/);
});
test('2.5 tips with action buttons navigate to target', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
// Find and click through tips until we find one with an action button
const nextBtn = page.getByRole('button', { name: /next tip/i });
for (let i = 0; i < 7; i++) {
const actionBtn = page.locator('[role="status"] button').filter({ hasText: /scan|diagnos|check|view|open/i }).first();
if (await actionBtn.isVisible({ timeout: 500 }).catch(() => false)) {
const btnText = await actionBtn.innerText();
await actionBtn.click();
await page.waitForTimeout(2000);
// Should have navigated away from dashboard
const url = page.url();
expect(url).not.toBe('/');
await snap(page, '02-action-button-navigated');
return;
}
if (await nextBtn.isEnabled()) {
await nextBtn.click();
await page.waitForTimeout(300);
}
}
// If no action button found in 7 tips, that's acceptable but notable
});
test('2.6 close button dismisses the bubble', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const closeBtn = page.getByRole('button', { name: /close tip/i });
await expect(closeBtn).toBeVisible();
await closeBtn.click();
await page.waitForTimeout(300);
// Bubble should be gone
const bubble = page.locator('[role="status"]');
await expect(bubble).not.toBeVisible();
// Mascot should still be visible
await expect(mascot).toBeVisible();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 3. SEARCH MODE — Type to search
// ═══════════════════════════════════════════════════════════════════════════
test.describe('3 — Search Mode', () => {
test('3.1 search input is visible in the bubble', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const searchInput = page.getByPlaceholder(/search|ask stella/i);
await expect(searchInput).toBeVisible();
});
test('3.2 typing a keyword switches to search mode', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const input = page.getByPlaceholder(/search|ask stella/i);
await input.fill('SBOM vulnerability');
await input.press('Enter');
await page.waitForTimeout(1000);
// Should show "Search" in the header or a back-to-tips button
const searchHeader = page.locator('text=/Search/i').first();
const backBtn = page.locator('text=/Tips/i').first();
const isSearchMode = await searchHeader.isVisible({ timeout: 2000 }).catch(() => false)
|| await backBtn.isVisible({ timeout: 2000 }).catch(() => false);
expect(isSearchMode).toBe(true);
await snap(page, '03-search-mode');
});
test('3.3 back-to-tips button returns to tips mode', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const input = page.getByPlaceholder(/search|ask stella/i);
await input.fill('policy');
await input.press('Enter');
await page.waitForTimeout(1000);
// Click back to tips
const backBtn = page.locator('button').filter({ hasText: /tips/i }).first();
if (await backBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await backBtn.click();
await page.waitForTimeout(500);
// Should show tip content again (not search)
const counter = page.locator('text=/\\d+ \\/ \\d+/');
await expect(counter).toBeVisible({ timeout: 3000 });
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 4. CHAT MODE — Ask Stella questions
// ═══════════════════════════════════════════════════════════════════════════
test.describe('4 — Chat Mode', () => {
test('4.1 "Ask Stella" button switches to chat mode', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const askBtn = page.getByRole('button', { name: /ask stella/i });
if (await askBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await askBtn.click();
await page.waitForTimeout(500);
// Should show chat header
const chatIndicator = page.locator('text=/Ask Stella/i').first();
await expect(chatIndicator).toBeVisible({ timeout: 3000 });
// Input placeholder should change
const input = page.getByPlaceholder(/ask stella a question/i);
await expect(input).toBeVisible();
await snap(page, '04-chat-mode');
}
});
test('4.2 question in input auto-routes to chat mode', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const input = page.getByPlaceholder(/search|ask stella/i);
await input.fill('How do I scan my first container image?');
await input.press('Enter');
await page.waitForTimeout(2000);
// Questions should trigger chat mode (not search)
const chatIndicator = page.locator('text=/Ask Stella/i').first();
const isChat = await chatIndicator.isVisible({ timeout: 3000 }).catch(() => false);
// Even if Advisory AI service is down, the mode should switch
expect(isChat).toBe(true);
await snap(page, '04-question-to-chat');
});
test('4.3 chat shows error gracefully when AI service unavailable', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const askBtn = page.getByRole('button', { name: /ask stella/i });
if (await askBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await askBtn.click();
await page.waitForTimeout(500);
const input = page.getByPlaceholder(/ask stella/i);
await input.fill('What is SBOM?');
await input.press('Enter');
await page.waitForTimeout(3000);
// Should show either a response or a graceful error — NOT a blank screen
const bubble = page.locator('[role="status"]');
const bubbleText = await bubble.innerText();
expect(bubbleText.length).toBeGreaterThan(10);
await snap(page, '04-chat-error-graceful');
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 5. MODE TRANSITIONS
// ═══════════════════════════════════════════════════════════════════════════
test.describe('5 — Mode Transitions', () => {
test('5.1 tips → search → tips cycle works', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
// Verify tips mode (counter visible)
const counter = page.locator('text=/\\d+ \\/ \\d+/');
await expect(counter).toBeVisible({ timeout: 3000 });
// Switch to search
const input = page.getByPlaceholder(/search|ask stella/i);
await input.fill('policy');
await input.press('Enter');
await page.waitForTimeout(1000);
// Switch back to tips
const backBtn = page.locator('button').filter({ hasText: /tips/i }).first();
if (await backBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await backBtn.click();
await page.waitForTimeout(500);
await expect(counter).toBeVisible({ timeout: 3000 });
}
});
test('5.2 navigating to new page resets to tips mode', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
// Enter search mode
const input = page.getByPlaceholder(/search|ask stella/i);
await input.fill('test');
await input.press('Enter');
await page.waitForTimeout(500);
// Navigate to different page
await go(page, '/security');
await mascot.click();
await page.waitForTimeout(500);
// Should be back in tips mode with security-related content
const tipText = await page.locator('[role="status"]').innerText();
const securityTerms = ['security', 'posture', 'scan', 'vulnerability', 'risk'];
const hasSecTerm = securityTerms.some(t => tipText.toLowerCase().includes(t));
expect(hasSecTerm).toBe(true);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 6. DISMISS / RESTORE
// ═══════════════════════════════════════════════════════════════════════════
test.describe('6 — Dismiss & Restore', () => {
test('6.1 "Don\'t show again" hides the mascot', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const dismissBtn = page.getByRole('button', { name: /don.t show again/i });
await expect(dismissBtn).toBeVisible();
await dismissBtn.click();
await page.waitForTimeout(500);
// Full mascot should be hidden
await expect(mascot).not.toBeVisible({ timeout: 2000 });
await snap(page, '06-dismissed');
});
test('6.2 restore button brings mascot back after dismiss', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
// Dismiss
await page.getByRole('button', { name: /don.t show again/i }).click();
await page.waitForTimeout(500);
// Look for restore button (small icon with "?" badge)
const restoreBtn = page.getByRole('button', { name: /show stella|bring back/i });
await expect(restoreBtn).toBeVisible({ timeout: 3000 });
await restoreBtn.click();
await page.waitForTimeout(1000);
// Mascot should be back
await expect(page.getByRole('button', { name: /stella helper/i })).toBeVisible();
await snap(page, '06-restored');
});
test('6.3 dismiss state persists across page reload', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
await page.getByRole('button', { name: /don.t show again/i }).click();
await page.waitForTimeout(500);
// Reload page
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Mascot should still be dismissed (restore button visible instead)
await expect(mascot).not.toBeVisible({ timeout: 3000 });
const restoreBtn = page.getByRole('button', { name: /show stella|bring back/i });
await expect(restoreBtn).toBeVisible();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 7. PERSISTENCE ACROSS NAVIGATION
// ═══════════════════════════════════════════════════════════════════════════
test.describe('7 — Persistence', () => {
test('7.1 tip position is remembered per page', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
// Advance to tip 3
const nextBtn = page.getByRole('button', { name: /next tip/i });
await nextBtn.click();
await page.waitForTimeout(200);
await nextBtn.click();
await page.waitForTimeout(200);
const counter = page.locator('text=/\\d+ \\/ \\d+/');
const pos = await counter.innerText();
expect(pos).toMatch(/3 \/ \d+/);
// Navigate away and back
await go(page, '/security');
await page.waitForTimeout(500);
await go(page, '/');
await mascot.click();
await page.waitForTimeout(500);
// Should return to tip 3
const restoredPos = await counter.innerText();
expect(restoredPos).toMatch(/3 \/ \d+/);
});
test('7.2 first-visit auto-open only happens once per page', async ({ authenticatedPage: page }) => {
await page.evaluate(() => {
localStorage.removeItem('stellaops.assistant.state');
localStorage.removeItem('stellaops.helper.preferences');
});
await go(page, '/');
await page.waitForTimeout(2000);
// First visit: bubble auto-opens
let bubble = page.locator('[role="status"]');
const autoOpened = await bubble.isVisible({ timeout: 3000 }).catch(() => false);
expect(autoOpened).toBe(true);
// Close it
const closeBtn = page.getByRole('button', { name: /close tip/i });
if (await closeBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await closeBtn.click();
}
// Navigate away and back
await go(page, '/security');
await go(page, '/');
await page.waitForTimeout(2000);
// Second visit to same page: should NOT auto-open
bubble = page.locator('[role="status"]');
const autoOpenedAgain = await bubble.isVisible({ timeout: 2000 }).catch(() => false);
expect(autoOpenedAgain).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 8. CONTEXTUAL TIP RELEVANCE (per page/tab)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('8 — Contextual Relevance', () => {
const pageExpectations: { route: string; name: string; expectedTerms: string[] }[] = [
{ route: '/', name: 'Dashboard', expectedTerms: ['dashboard', 'overview', 'environment', 'sbom', 'feed'] },
{ route: '/releases', name: 'Releases', expectedTerms: ['release', 'gate', 'evidence', 'promote', 'digest'] },
{ route: '/releases/deployments', name: 'Deployments', expectedTerms: ['deployment', 'gate', 'approval', 'promote'] },
{ route: '/security', name: 'Security Posture', expectedTerms: ['security', 'risk', 'posture', 'vex', 'sbom'] },
{ route: '/security/reachability', name: 'Reachability', expectedTerms: ['reachability', 'call', 'static', 'runtime', 'code'] },
{ route: '/ops/policy/vex', name: 'VEX', expectedTerms: ['vex', 'vulnerability', 'exploitability', 'statement', 'affected'] },
{ route: '/ops/policy/governance', name: 'Governance', expectedTerms: ['budget', 'risk', 'threshold', 'policy'] },
{ route: '/evidence/overview', name: 'Evidence', expectedTerms: ['evidence', 'signed', 'proof', 'audit', 'capsule'] },
{ route: '/ops/operations', name: 'Operations', expectedTerms: ['operations', 'blocking', 'health', 'action'] },
{ route: '/ops/operations/doctor', name: 'Diagnostics', expectedTerms: ['diagnostic', 'health', 'check', 'service'] },
{ route: '/setup/integrations', name: 'Integrations', expectedTerms: ['integration', 'registry', 'connect', 'setup'] },
{ route: '/setup/trust-signing', name: 'Certificates', expectedTerms: ['signing', 'key', 'certificate', 'trust', 'crypto'] },
];
for (const { route, name, expectedTerms } of pageExpectations) {
test(`8.x tips for ${name} (${route}) are relevant`, async ({ authenticatedPage: page }) => {
await go(page, route);
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
// Collect all tip text (cycle through all tips)
let allTipText = '';
const counter = page.locator('text=/\\d+ \\/ \\d+/');
const nextBtn = page.getByRole('button', { name: /next tip/i });
const bubble = page.locator('[role="status"]');
allTipText += await bubble.innerText();
// Cycle through remaining tips
for (let i = 0; i < 10; i++) {
if (await nextBtn.isEnabled()) {
await nextBtn.click();
await page.waitForTimeout(200);
allTipText += ' ' + await bubble.innerText();
} else {
break;
}
}
// At least one expected term should appear in the combined tip text
const lowerText = allTipText.toLowerCase();
const matchedTerms = expectedTerms.filter(t => lowerText.includes(t));
expect(
matchedTerms.length,
`Tips for ${name} should mention at least one of: [${expectedTerms.join(', ')}]. Got text: "${allTipText.slice(0, 200)}..."`
).toBeGreaterThan(0);
});
}
});
// ═══════════════════════════════════════════════════════════════════════════
// 9. SEND BUTTON & INPUT BEHAVIOR
// ═══════════════════════════════════════════════════════════════════════════
test.describe('9 — Input Behavior', () => {
test('9.1 send button is disabled when input is empty', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const sendBtn = page.getByRole('button', { name: /send/i });
await expect(sendBtn).toBeDisabled();
});
test('9.2 send button enables when text is entered', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const input = page.getByPlaceholder(/search|ask stella/i);
await input.fill('test query');
const sendBtn = page.getByRole('button', { name: /send/i });
await expect(sendBtn).toBeEnabled();
});
test('9.3 Enter key submits the input', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
const input = page.getByPlaceholder(/search|ask stella/i);
await input.fill('SBOM');
await input.press('Enter');
await page.waitForTimeout(1000);
// Should have transitioned away from tips mode
// (either search mode header or chat mode header visible)
const modeIndicator = page.locator('text=/Search|Ask Stella/i').first();
await expect(modeIndicator).toBeVisible({ timeout: 3000 });
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 10. ACCESSIBILITY
// ═══════════════════════════════════════════════════════════════════════════
test.describe('10 — Accessibility', () => {
test('10.1 mascot has accessible role and label', async ({ authenticatedPage: page }) => {
await go(page, '/');
// The mascot container should have complementary role
const assistant = page.getByRole('complementary', { name: /stella/i });
await expect(assistant).toBeVisible({ timeout: 5000 });
// The mascot button should have aria-label
const mascot = page.getByRole('button', { name: /stella helper/i });
await expect(mascot).toBeVisible();
});
test('10.2 bubble uses aria-live for screen readers', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
// Content area should use role="status" (implicit aria-live="polite")
const liveRegion = page.locator('[role="status"]');
await expect(liveRegion).toBeVisible();
});
test('10.3 close and navigation buttons have accessible labels', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
await mascot.click();
await page.waitForTimeout(500);
await expect(page.getByRole('button', { name: /close tip/i })).toBeVisible();
await expect(page.getByRole('button', { name: /previous tip/i })).toBeVisible();
await expect(page.getByRole('button', { name: /next tip/i })).toBeVisible();
});
test('10.4 mascot button indicates expanded state', async ({ authenticatedPage: page }) => {
await go(page, '/');
const mascot = page.getByRole('button', { name: /stella helper/i });
// Before clicking — not expanded
await mascot.click();
await page.waitForTimeout(500);
// After clicking — should have aria-expanded="true"
const expanded = await mascot.getAttribute('aria-expanded');
expect(expanded).toBe('true');
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 11. MULTIPLE PAGES SMOKE — No crashes
// ═══════════════════════════════════════════════════════════════════════════
test.describe('11 — Smoke: Assistant survives rapid navigation', () => {
test('11.1 rapid navigation does not crash the assistant', async ({ authenticatedPage: page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error' && /NG0|TypeError|ReferenceError/.test(msg.text())) {
errors.push(msg.text());
}
});
const routes = [
'/', '/releases', '/releases/deployments', '/environments/overview',
'/security', '/security/reachability', '/ops/policy/vex',
'/evidence/overview', '/ops/operations', '/setup/integrations',
'/ops/policy/governance', '/ops/operations/doctor',
];
for (const route of routes) {
await page.goto(route, { waitUntil: 'domcontentloaded', timeout: 15_000 });
await page.waitForTimeout(800);
}
// Mascot should still be functional
const mascot = page.getByRole('button', { name: /stella helper/i });
await expect(mascot).toBeVisible();
await mascot.click();
await page.waitForTimeout(500);
const bubble = page.locator('[role="status"]');
await expect(bubble).toBeVisible();
// No critical Angular errors
const criticalErrors = errors.filter(e => /NG0/.test(e));
expect(criticalErrors, `Angular errors: ${criticalErrors.join('\n')}`).toHaveLength(0);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 12. ADMIN EDITOR (requires ui.admin scope)
// ═══════════════════════════════════════════════════════════════════════════
test.describe('12 — Admin Editor', () => {
test('12.1 admin page renders at /console-admin/assistant', async ({ authenticatedPage: page }) => {
await go(page, '/console-admin/assistant');
const heading = page.locator('text=/Stella Assistant Editor/i');
await expect(heading).toBeVisible({ timeout: 10_000 });
await snap(page, '12-admin-editor');
});
test('12.2 admin page has tabs for Tips and Glossary', async ({ authenticatedPage: page }) => {
await go(page, '/console-admin/assistant');
await page.waitForTimeout(1000);
const tipsTab = page.getByRole('tab', { name: /tips/i });
const glossaryTab = page.getByRole('tab', { name: /glossary/i });
await expect(tipsTab).toBeVisible();
await expect(glossaryTab).toBeVisible();
});
test('12.3 new tip form has preview', async ({ authenticatedPage: page }) => {
await go(page, '/console-admin/assistant');
await page.waitForTimeout(1000);
const newTipTab = page.getByRole('tab', { name: /new tip/i });
await newTipTab.click();
await page.waitForTimeout(500);
// Form fields should be visible
const routeInput = page.getByPlaceholder(/\/ops\/policy/i);
await expect(routeInput).toBeVisible();
// Preview section should exist
const preview = page.locator('text=/preview/i').first();
await expect(preview).toBeVisible();
await snap(page, '12-admin-new-tip-form');
});
});

View File

@@ -122,6 +122,7 @@ export const SEVERITY_COLORS: Record<SearchResultSeverity, string> = {
};
export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
// ── Top 5 (shown by default when palette opens) ──────────────────
{
id: 'scan',
label: 'Scan Artifact',
@@ -129,34 +130,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
description: 'Opens artifact scan dialog',
icon: 'scan',
route: '/security/scan',
keywords: ['scan', 'artifact', 'analyze'],
},
{
id: 'vex',
label: 'Create VEX Statement',
shortcut: '>vex',
description: 'Open VEX creation workflow',
icon: 'shield-check',
route: '/security/advisories-vex',
keywords: ['vex', 'create', 'statement'],
},
{
id: 'policy',
label: 'New Policy Pack',
shortcut: '>policy',
description: 'Create new policy pack',
icon: 'shield',
route: '/ops/policy/packs',
keywords: ['policy', 'new', 'pack', 'create'],
},
{
id: 'jobs',
label: 'View Jobs',
shortcut: '>jobs',
description: 'Navigate to job list',
icon: 'workflow',
route: '/ops/operations/jobs-queues',
keywords: ['jobs', 'jobengine', 'list'],
keywords: ['scan', 'artifact', 'analyze', 'image', 'container', 'vulnerability'],
},
{
id: 'findings',
@@ -165,25 +139,62 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
description: 'Navigate to findings list',
icon: 'alert-triangle',
route: '/security/triage',
keywords: ['findings', 'vulnerabilities', 'list'],
keywords: ['findings', 'vulnerabilities', 'list', 'triage', 'security'],
},
{
id: 'settings',
label: 'Go to Settings',
shortcut: '>settings',
description: 'Navigate to settings',
icon: 'settings',
route: '/setup',
keywords: ['settings', 'config', 'preferences'],
id: 'pending-approvals',
label: 'Pending Approvals',
shortcut: '>approvals',
description: 'View pending release approvals',
icon: 'check-circle',
route: '/releases/approvals',
keywords: ['approvals', 'pending', 'gate', 'release', 'review', 'decision'],
},
{
id: 'health',
label: 'Platform Health',
shortcut: '>health',
description: 'View platform health status',
icon: 'heart-pulse',
route: '/ops/operations/system-health',
keywords: ['health', 'status', 'platform', 'ops', 'doctor', 'system'],
id: 'security-posture',
label: 'Security Posture',
shortcut: '>posture',
description: 'View security posture overview',
icon: 'shield',
route: '/security',
keywords: ['security', 'posture', 'risk', 'dashboard', 'overview'],
},
{
id: 'create-release',
label: 'Create Release',
shortcut: '>release',
description: 'Create a new release version',
icon: 'package',
route: '/releases/versions/new',
keywords: ['create', 'release', 'version', 'new', 'deploy'],
},
// ── Remaining actions (alphabetical by id) ───────────────────────
{
id: 'check-policy-gates',
label: 'Check Policy Gates',
shortcut: '>gates',
description: 'Review policy gate status and results',
icon: 'shield',
route: '/ops/policy/gates',
keywords: ['policy', 'gates', 'check', 'governance', 'compliance'],
},
{
id: 'configure-advisory-sources',
label: 'Configure Advisory Sources',
shortcut: '>advisory',
description: 'Manage advisory and VEX data sources',
icon: 'plug',
route: '/setup/integrations/advisory-vex-sources',
keywords: ['advisory', 'sources', 'vex', 'configure', 'integrations', 'feed'],
},
{
id: 'create-exception',
label: 'Create Exception',
shortcut: '>exception',
description: 'Create a new policy exception or waiver',
icon: 'file-text',
route: '/exceptions',
keywords: ['create', 'exception', 'waiver', 'exemption', 'new'],
},
{
id: 'doctor-quick',
@@ -199,7 +210,43 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>diagnostics',
description: 'Run comprehensive Doctor diagnostics',
icon: 'search',
keywords: ['doctor', 'diagnostics', 'full', 'comprehensive'],
keywords: ['doctor', 'diagnostics', 'full', 'comprehensive', 'diag'],
},
{
id: 'environments',
label: 'Environments',
shortcut: '>envs',
description: 'View environment topology and targets',
icon: 'globe',
route: '/environments/overview',
keywords: ['environments', 'topology', 'regions', 'overview', 'targets', 'envs'],
},
{
id: 'evidence-export',
label: 'Export Evidence',
shortcut: '>export',
description: 'Export evidence bundles for compliance',
icon: 'download',
route: '/evidence/exports',
keywords: ['evidence', 'export', 'bundle', 'compliance', 'report'],
},
{
id: 'exception-queue',
label: 'Exception Queue',
shortcut: '>exceptions',
description: 'View and manage policy exceptions',
icon: 'file-text',
route: '/ops/policy/vex/exceptions',
keywords: ['exceptions', 'queue', 'waiver', 'policy', 'exemption'],
},
{
id: 'health',
label: 'Platform Health',
shortcut: '>health',
description: 'View platform health status',
icon: 'heart-pulse',
route: '/ops/operations/system-health',
keywords: ['health', 'status', 'platform', 'ops', 'doctor', 'system'],
},
{
id: 'integrations',
@@ -210,6 +257,42 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
route: '/ops/integrations',
keywords: ['integrations', 'connect', 'manage'],
},
{
id: 'jobs',
label: 'View Jobs',
shortcut: '>jobs',
description: 'Navigate to job list',
icon: 'workflow',
route: '/ops/operations/jobs-queues',
keywords: ['jobs', 'jobengine', 'list', 'queue'],
},
{
id: 'policy',
label: 'New Policy Pack',
shortcut: '>policy',
description: 'Create new policy pack',
icon: 'shield',
route: '/ops/policy/packs',
keywords: ['policy', 'new', 'pack', 'create'],
},
{
id: 'reachability',
label: 'Reachability Analysis',
shortcut: '>reach',
description: 'View reachability analysis and exploitability',
icon: 'cpu',
route: '/security/reachability',
keywords: ['reachability', 'analysis', 'exploitable', 'path', 'reach'],
},
{
id: 'risk-budget',
label: 'Risk Budget',
shortcut: '>budget',
description: 'View risk budget and governance dashboard',
icon: 'shield',
route: '/ops/policy/governance',
keywords: ['risk', 'budget', 'governance', 'threshold', 'consumption'],
},
{
id: 'seed-demo',
label: 'Seed Demo Data',
@@ -219,49 +302,22 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
keywords: ['seed', 'demo', 'data', 'populate', 'sample', 'mock'],
},
{
id: 'scan-image',
label: 'Scan Image',
shortcut: '>scan-image',
description: 'Scan a container image for vulnerabilities',
icon: 'scan',
route: '/security/scan',
keywords: ['scan', 'image', 'container', 'vulnerability'],
id: 'settings',
label: 'Go to Settings',
shortcut: '>settings',
description: 'Navigate to settings',
icon: 'settings',
route: '/setup',
keywords: ['settings', 'config', 'preferences'],
},
{
id: 'view-vulnerabilities',
label: 'View Vulnerabilities',
shortcut: '>vulns',
description: 'Browse vulnerability triage queue',
icon: 'alert-triangle',
route: '/triage/artifacts',
keywords: ['vulnerabilities', 'vulns', 'triage', 'cve', 'security'],
},
{
id: 'search-cve',
label: 'Search CVE',
shortcut: '>cve',
description: 'Search for a specific CVE in triage artifacts',
icon: 'search',
route: '/triage/artifacts',
keywords: ['cve', 'search', 'vulnerability', 'advisory'],
},
{
id: 'view-findings',
label: 'View Findings',
shortcut: '>view-findings',
description: 'Navigate to security findings list',
icon: 'alert-triangle',
route: '/triage/artifacts',
keywords: ['findings', 'vulnerabilities', 'security', 'list'],
},
{
id: 'create-release',
label: 'Create Release',
shortcut: '>release',
description: 'Create a new release version',
icon: 'package',
route: '/releases/versions/new',
keywords: ['create', 'release', 'version', 'new', 'deploy'],
id: 'vex',
label: 'Create VEX Statement',
shortcut: '>vex',
description: 'Open VEX creation workflow',
icon: 'shield-check',
route: '/security/advisories-vex',
keywords: ['vex', 'create', 'statement'],
},
{
id: 'view-audit-log',
@@ -272,24 +328,6 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
route: '/evidence/audit-log',
keywords: ['audit', 'log', 'evidence', 'history', 'trail'],
},
{
id: 'run-diagnostics',
label: 'Run Diagnostics',
shortcut: '>diag',
description: 'Open the Doctor diagnostics dashboard',
icon: 'activity',
route: '/ops/operations/doctor',
keywords: ['diagnostics', 'doctor', 'health', 'check', 'run'],
},
{
id: 'configure-advisory-sources',
label: 'Configure Advisory Sources',
shortcut: '>advisory',
description: 'Manage advisory and VEX data sources',
icon: 'plug',
route: '/setup/integrations/advisory-vex-sources',
keywords: ['advisory', 'sources', 'vex', 'configure', 'integrations', 'feed'],
},
{
id: 'view-promotions',
label: 'View Promotions',
@@ -300,13 +338,13 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
keywords: ['promotions', 'promote', 'environment', 'deploy', 'release'],
},
{
id: 'check-policy-gates',
label: 'Check Policy Gates',
shortcut: '>gates',
description: 'Review policy gate status and results',
icon: 'shield',
route: '/ops/policy/gates',
keywords: ['policy', 'gates', 'check', 'governance', 'compliance'],
id: 'view-vulnerabilities',
label: 'Browse Vulnerabilities',
shortcut: '>vulns',
description: 'Browse vulnerability triage queue',
icon: 'alert-triangle',
route: '/triage/artifacts',
keywords: ['vulnerabilities', 'vulns', 'triage', 'cve', 'security', 'search', 'advisory'],
},
];

View File

@@ -45,47 +45,7 @@ import {
imports: [FormsModule, ChatMessageComponent],
template: `
<div class="chat-container" [class.loading]="isLoading()">
<!-- Header -->
<header class="chat-header">
<div class="header-left">
<div class="header-brand">
<svg class="header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/>
<path d="M8.5 8.5v.01"/>
<path d="M16 15.5v.01"/>
<path d="M12 12v.01"/>
</svg>
<h2 class="header-title">Search assistant</h2>
@if (conversation()) {
<span class="conversation-id">{{ conversation()!.conversationId.substring(0, 8) }}</span>
}
</div>
</div>
<div class="header-right">
@if (conversation()) {
<button
type="button"
class="header-btn"
title="New conversation"
(click)="startNewConversation()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
}
<button
type="button"
class="header-btn close-btn"
title="Close chat"
(click)="close.emit()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</header>
<!-- Header removed — the host's title bar provides all controls -->
<!-- Messages container -->
<div class="chat-messages" #messagesContainer>
@@ -174,6 +134,7 @@ import {
[placeholder]="inputPlaceholder()"
[disabled]="isStreaming()"
[(ngModel)]="inputValue"
(ngModelChange)="onChatInput()"
(keydown)="handleKeydown($event)"
rows="1"></textarea>
<button
@@ -505,8 +466,8 @@ import {
width: 44px;
height: 44px;
border: none;
background: var(--color-primary);
color: white;
background: var(--color-brand-primary, #F5A623);
color: var(--color-text-heading, #2A1E00);
border-radius: var(--radius-lg);
cursor: pointer;
display: flex;
@@ -517,7 +478,7 @@ import {
}
.send-btn:hover:not(:disabled) {
background: var(--color-primary-hover);
background: color-mix(in srgb, var(--color-brand-primary, #F5A623) 85%, black);
}
.send-btn:disabled {
@@ -554,6 +515,9 @@ export class ChatComponent implements OnInit, OnDestroy {
}
@Output() close = new EventEmitter<void>();
@Output() expandChange = new EventEmitter<boolean>();
@Output() inputTyping = new EventEmitter<string>();
@Output() conversationCreated = new EventEmitter<string>();
@Output() linkNavigate = new EventEmitter<ParsedObjectLink>();
@Output() actionExecute = new EventEmitter<{ action: ProposedAction; turnId: string }>();
@Output() searchForMore = new EventEmitter<string>();
@@ -569,6 +533,7 @@ export class ChatComponent implements OnInit, OnDestroy {
inputValue = '';
readonly progressStage = signal<string | null>(null);
readonly expanded = signal(false);
// Expose service signals
readonly conversation = this.chatService.conversation;
@@ -577,9 +542,9 @@ export class ChatComponent implements OnInit, OnDestroy {
readonly streamingContent = this.chatService.streamingContent;
readonly error = this.chatService.error;
readonly canSend = computed(() => {
canSend(): boolean {
return this.inputValue.trim().length > 0 && !this.isStreaming();
});
}
readonly inputPlaceholder = computed(() => {
if (this.isStreaming()) {
@@ -636,7 +601,8 @@ export class ChatComponent implements OnInit, OnDestroy {
this.trySendPendingInitialMessage();
});
} else {
this.chatService.createConversation(this.tenantId, this.context).subscribe(() => {
this.chatService.createConversation(this.tenantId, this.context).subscribe((conv) => {
this.conversationCreated.emit(conv.conversationId);
this.trySendPendingInitialMessage();
});
}
@@ -656,6 +622,13 @@ export class ChatComponent implements OnInit, OnDestroy {
const conversation = this.conversation();
if (conversation) {
this.chatService.sendMessage(conversation.conversationId, message);
} else {
// No conversation yet — create one first, then send
this.chatService.createConversation(this.tenantId, this.context).subscribe({
next: (conv) => {
this.chatService.sendMessage(conv.conversationId, message);
},
});
}
}
@@ -671,6 +644,15 @@ export class ChatComponent implements OnInit, OnDestroy {
}
}
onChatInput(): void {
this.inputTyping.emit(this.inputValue);
}
toggleExpand(): void {
this.expanded.update(v => !v);
this.expandChange.emit(this.expanded());
}
startNewConversation(): void {
this.chatService.clearConversation();
this.chatService.createConversation(this.tenantId, this.context).subscribe(() => {

View File

@@ -7,6 +7,7 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, Subject, BehaviorSubject, catchError, tap, finalize, of, from } from 'rxjs';
import { AuthorityAuthService } from '../../../core/auth/authority-auth.service';
import {
Conversation,
ConversationContext,
@@ -37,6 +38,7 @@ const API_BASE = '/api/v1/advisory-ai';
})
export class ChatService {
private readonly http = inject(HttpClient);
private readonly auth = inject(AuthorityAuthService);
// Reactive state
private readonly _state = signal<ChatState>({
@@ -176,34 +178,65 @@ export class ChatService {
* Starts SSE stream for a message.
*/
private startStream(conversationId: string, message: string): void {
const url = `${API_BASE}/conversations/${conversationId}/turns`;
// Use the legacy conversation turns endpoint routed via Valkey RPC.
// The gateway maps /api/v1/advisory-ai/* to the backend via Valkey.
// HandleAddTurn returns JSON (not SSE), so we simulate streaming client-side.
this.http.post<any>(`${API_BASE}/conversations/${conversationId}/turns`, {
content: message,
}).subscribe({
next: (response) => {
// Simulate streaming: emit tokens word by word
const content = response?.response ?? response?.content ?? response?.answer ?? JSON.stringify(response);
const words = content.split(' ');
let accumulated = '';
// Use fetch for SSE since HttpClient doesn't support streaming well
const abortController = new AbortController();
this.streamEvents$.next({ event: 'start', data: {} } as any);
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
let i = 0;
const interval = setInterval(() => {
if (i < words.length) {
accumulated += words[i] + ' ';
this.updateState({ streamingContent: accumulated });
this.streamEvents$.next({ event: 'token', data: { content: words[i] + ' ' } } as any);
i++;
} else {
clearInterval(interval);
// Build the assistant turn
const conversation = this._state().conversation;
if (conversation) {
const assistantTurn: ConversationTurn = {
turnId: `resp-${Date.now()}`,
role: 'assistant',
content: accumulated.trim(),
timestamp: new Date().toISOString(),
citations: response?.citations ?? [],
proposedActions: response?.proposedActions ?? [],
groundingScore: response?.groundingScore,
};
const updated = {
...conversation,
turns: [...conversation.turns, assistantTurn],
};
this.updateState({
conversation: updated,
isStreaming: false,
streamingContent: '',
});
} else {
this.updateState({ isStreaming: false, streamingContent: '' });
}
this.streamEvents$.next({ event: 'done', data: { turnId: `resp-${Date.now()}` } } as any);
}
}, 40);
},
body: JSON.stringify({ content: message }),
signal: abortController.signal,
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('No response body');
}
return this.processStream(response.body.getReader());
})
.catch((err) => {
if (err.name !== 'AbortError') {
this.handleStreamError(err);
}
});
error: (err) => {
this.handleStreamError(new Error(err.message || 'Failed to get response'));
},
});
}
/**

View File

@@ -18,7 +18,7 @@ import { filter } from 'rxjs/operators';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
import { GlobalSearchComponent } from '../global-search/global-search.component';
// GlobalSearchComponent removed — search is now handled by the Stella mascot
import { ContextChipsComponent } from '../context-chips/context-chips.component';
import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.component';
import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
@@ -44,7 +44,6 @@ import { ContentWidthService } from '../../core/services/content-width.service';
selector: 'app-topbar',
standalone: true,
imports: [
GlobalSearchComponent,
ContextChipsComponent,
UserMenuComponent,
OfflineStatusChipComponent,
@@ -57,8 +56,8 @@ import { ContentWidthService } from '../../core/services/content-width.service';
],
template: `
<header class="topbar" role="banner">
<!-- Row 1: Logo + Search + Create + User -->
<div class="topbar__row topbar__row--primary">
<!-- Single merged row: status chips → tenant → filters → user menu -->
<div class="topbar__row">
<!-- Mobile menu toggle -->
<button
type="button"
@@ -73,35 +72,17 @@ import { ContentWidthService } from '../../core/services/content-width.service';
</svg>
</button>
<!-- Global Search -->
<div class="topbar__search">
<app-global-search></app-global-search>
</div>
<!-- Status chips (leading position) -->
@if (isAuthenticated()) {
<div class="topbar__status-chips">
<app-live-event-stream-chip></app-live-event-stream-chip>
<app-policy-baseline-chip></app-policy-baseline-chip>
<app-evidence-mode-chip></app-evidence-mode-chip>
<app-feed-snapshot-chip></app-feed-snapshot-chip>
<app-offline-status-chip></app-offline-status-chip>
</div>
}
<!-- Right section: User -->
<div class="topbar__right">
<button
type="button"
class="topbar__context-toggle"
[class.topbar__context-toggle--active]="mobileContextOpen()"
[attr.aria-expanded]="mobileContextOpen()"
aria-controls="topbar-context-row"
(click)="toggleMobileContext()"
>
Context
</button>
<!-- User menu -->
<app-user-menu></app-user-menu>
</div>
</div>
<!-- Row 2: Tenant + Status chips + Context controls -->
<div
id="topbar-context-row"
class="topbar__row topbar__row--secondary"
[class.topbar__row--secondary-open]="mobileContextOpen()"
>
<!-- Tenant (always shown when authenticated) -->
@if (isAuthenticated()) {
<div class="topbar__tenant">
@@ -204,16 +185,19 @@ import { ContentWidthService } from '../../core/services/content-width.service';
<stella-view-mode-switcher></stella-view-mode-switcher>
}
@if (isAuthenticated()) {
<!-- Status indicator chips -->
<div class="topbar__status-chips">
<app-live-event-stream-chip></app-live-event-stream-chip>
<app-policy-baseline-chip></app-policy-baseline-chip>
<app-evidence-mode-chip></app-evidence-mode-chip>
<app-feed-snapshot-chip></app-feed-snapshot-chip>
<app-offline-status-chip></app-offline-status-chip>
</div>
}
<!-- Right: mobile context toggle + user menu -->
<div class="topbar__right">
<button
type="button"
class="topbar__context-toggle"
[class.topbar__context-toggle--active]="mobileContextOpen()"
[attr.aria-expanded]="mobileContextOpen()"
(click)="toggleMobileContext()"
>
Context
</button>
<app-user-menu></app-user-menu>
</div>
</div>
</header>
`,
@@ -221,46 +205,30 @@ import { ContentWidthService } from '../../core/services/content-width.service';
/* ---- Shell ---- */
.topbar {
display: flex;
flex-direction: column;
background: var(--color-header-bg);
border-bottom: 1px solid color-mix(in srgb, var(--color-border-primary) 35%, transparent);
}
/* ---- Row layout ---- */
/* ---- Single merged row ---- */
.topbar__row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 1rem;
}
.topbar__row--primary {
height: 44px;
}
.topbar__row--secondary {
height: 32px;
gap: 0.5rem;
padding: 0 1rem;
height: 40px;
width: 100%;
overflow: visible;
}
@media (max-width: 575px) {
.topbar__row {
gap: 0.375rem;
gap: 0.25rem;
padding: 0 0.5rem;
}
.topbar__row--secondary {
padding: 0 0.5rem;
display: none;
}
.topbar__row--secondary-open {
display: flex;
flex-wrap: wrap;
height: auto;
padding: 0.35rem 0.5rem;
overflow: visible;
min-height: 36px;
padding-top: 4px;
padding-bottom: 4px;
}
}
@@ -293,20 +261,7 @@ import { ContentWidthService } from '../../core/services/content-width.service';
}
}
/* ---- Search ---- */
.topbar__search {
flex: 1;
max-width: 540px;
min-width: 0;
}
@media (max-width: 575px) {
.topbar__search {
max-width: none;
}
}
/* ---- Right section (row 1) ---- */
/* ---- Right section ---- */
.topbar__right {
display: flex;
align-items: center;
@@ -524,13 +479,13 @@ import { ContentWidthService } from '../../core/services/content-width.service';
white-space: nowrap;
}
/* ---- Status chips (right-aligned) ---- */
/* ---- Status chips (leading position) ---- */
.topbar__status-chips {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: nowrap;
margin-left: auto;
flex-shrink: 0;
}
/* ---- Content width toggle ---- */

View File

@@ -33,6 +33,7 @@ import { SynthesisPanelComponent } from '../../shared/components/synthesis-panel
import { AmbientContextService } from '../../core/services/ambient-context.service';
import { SearchChatContextService } from '../../core/services/search-chat-context.service';
import { SearchAssistantDrawerService } from '../../core/services/search-assistant-drawer.service';
import { StellaAssistantService } from '../../shared/components/stella-helper/stella-assistant.service';
import { I18nService } from '../../core/i18n';
import { TelemetryClient } from '../../core/telemetry/telemetry.client';
import { normalizeSearchActionRoute } from './search-route-matrix';
@@ -975,6 +976,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
private readonly ambientContext = inject(AmbientContextService);
private readonly searchChatContext = inject(SearchChatContextService);
private readonly assistantDrawer = inject(SearchAssistantDrawerService);
private readonly stellaAssistant = inject(StellaAssistantService);
private readonly i18n = inject(I18nService);
private readonly telemetry = inject(TelemetryClient);
private readonly destroy$ = new Subject<void>();
@@ -2321,7 +2323,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.closeResults();
this.assistantDrawer.open({
initialUserMessage: suggestedPrompt,
source: 'global_search_answer',
source: 'global_search',
fallbackFocusTarget: this.searchInputRef?.nativeElement ?? null,
});
}
@@ -2432,7 +2434,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.closeResults();
this.assistantDrawer.open({
initialUserMessage: suggestedPrompt,
source: 'global_search_entry',
source: 'global_search',
fallbackFocusTarget: this.searchInputRef?.nativeElement ?? null,
});
}

View File

@@ -6,114 +6,469 @@ import {
ViewChild,
effect,
inject,
signal,
computed,
} from '@angular/core';
import { Router } from '@angular/router';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { SearchAssistantDrawerService } from '../../core/services/search-assistant-drawer.service';
import { StellaAssistantService } from '../../shared/components/stella-helper/stella-assistant.service';
import { ChatComponent } from '../../features/advisory-ai/chat';
interface TrailBubble { x: number; y: number; size: number; delay: number; }
@Component({
selector: 'app-search-assistant-host',
standalone: true,
imports: [ChatComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (assistantDrawer.isOpen()) {
@if (assistantDrawer.isOpen() || exiting()) {
<div
class="assistant-host__backdrop"
class="ah__backdrop"
[class.ah--entering]="entering()"
[class.ah--exiting]="exiting()"
(click)="onBackdropClick($event)"
>
<section
#assistantDrawerElement
class="assistant-host__drawer assistant-drawer"
role="dialog"
aria-modal="true"
aria-label="Search assistant"
tabindex="-1"
<!-- Bubble trail -->
<div class="ah__trail"
[class.ah__trail--streaming]="stella.chatAnimationState() === 'thinking' || stella.chatAnimationState() === 'typing' || stella.searchLoading()"
aria-hidden="true">
@for (b of trailBubbles(); track b.delay) {
<span class="ah__bubble"
[style.left.px]="b.x - b.size / 2"
[style.top.px]="b.y - b.size / 2"
[style.width.px]="b.size"
[style.height.px]="b.size"
[style.animation-delay]="b.delay + 'ms'"
></span>
}
</div>
<section #drawerEl
class="ah__drawer"
[class.ah__drawer--expanded]="drawerExpanded()"
role="dialog" aria-modal="true" aria-label="Asking Stella" tabindex="-1"
>
<stellaops-chat
[tenantId]="context.tenantId() ?? 'default'"
[initialUserMessage]="assistantDrawer.initialUserMessage()"
(close)="close()"
(searchForMore)="onSearchForMore($event)"
/>
<!-- ═══ Title Bar ═══ -->
<header class="ah__titlebar">
<div class="ah__titlebar-left">
<img src="assets/img/stella-ops-icon.png" alt="" width="22" height="22" class="ah__titlebar-icon" />
<h2 class="ah__titlebar-text">Asking Stella...</h2>
</div>
<div class="ah__titlebar-right">
<!-- View toggle -->
<button class="ah__toggle-btn" (click)="onToggleView()"
[title]="stella.drawerView() === 'chat' ? 'Search Stella' : 'Ask Stella'">
@if (stella.drawerView() === 'chat') {
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>
<span>Search Stella</span>
} @else {
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>Ask Stella</span>
}
</button>
<!-- New Session -->
<button class="ah__hdr-btn" title="New Session" (click)="onNewSession()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<!-- Maximize -->
<button class="ah__hdr-btn" [title]="drawerExpanded() ? 'Collapse' : 'Maximize'" (click)="onToggleExpand()">
@if (drawerExpanded()) {
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
} @else {
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
}
</button>
<!-- Close -->
<button class="ah__hdr-btn ah__close-btn" title="Close" (click)="close()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</header>
<!-- ═══ Content: single view at a time ═══ -->
<div class="ah__content">
@if (stella.drawerView() === 'chat') {
@if (chatVisible()) {
<stellaops-chat
[tenantId]="context.tenantId() ?? 'default'"
[initialUserMessage]="assistantDrawer.initialUserMessage()"
[conversationId]="stella.activeConversationId() ?? undefined"
(conversationCreated)="onConversationCreated($event)"
(inputTyping)="onChatTyping($event)"
(searchForMore)="onSearchForMore($event)"
/>
}
} @else {
<!-- Full-panel search view -->
<div class="ah__search-view">
<div class="ah__search-bar">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>
<input class="ah__search-input"
type="text" placeholder="Search Stella..."
[value]="searchViewQuery()"
(input)="onSearchViewInput($event)" />
@if (stella.searchLoading()) {
<span class="ah__spinner"></span>
}
</div>
<div class="ah__search-body">
@if (stella.searchSynthesis(); as syn) {
<div class="ah__syn">{{ syn.summary }}</div>
}
@if (stella.searchResults().length > 0) {
@for (card of stella.searchResults(); track card.entityKey) {
<a class="ah__card" [href]="getCardRoute(card)" (click)="onCardClick($event, card)">
<span class="ah__card-type">{{ card.entityType }}</span>
<span class="ah__card-title">{{ card.title }}</span>
<span class="ah__card-snip">{{ card.snippet }}</span>
</a>
}
} @else if (!stella.searchLoading()) {
<div class="ah__search-empty">Type to search across all of Stella Ops.</div>
}
</div>
</div>
}
</div>
</section>
</div>
}
`,
styles: [`
.assistant-host__backdrop {
position: fixed;
inset: 0;
z-index: 185;
/* ===== Backdrop ===== */
.ah__backdrop {
position: fixed; inset: 0; z-index: 185;
background: rgba(15, 23, 42, 0.16);
backdrop-filter: blur(2px);
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 4.5rem 1rem 1rem;
display: flex; align-items: flex-start; justify-content: flex-end;
padding: 3rem 1rem 1rem;
animation: ah-fade-in 300ms ease both;
}
@keyframes ah-fade-in { from { opacity: 0; } to { opacity: 1; } }
.ah--exiting { animation: ah-fade-out 250ms ease both; }
@keyframes ah-fade-out { from { opacity: 1; } to { opacity: 0; } }
.assistant-host__drawer {
/* ===== Drawer ===== */
.ah__drawer {
width: min(520px, calc(100vw - 2rem));
height: min(78vh, 760px);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
border-radius: var(--radius-lg, 12px);
background: var(--color-surface-primary);
box-shadow: var(--shadow-dropdown);
box-shadow: 0 8px 40px rgba(0,0,0,0.15), 0 0 0 1px rgba(245,166,35,0.08);
overflow: hidden;
transform-origin: bottom right;
animation: ah-grow 400ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
display: flex; flex-direction: column;
}
@keyframes ah-grow {
0% { opacity:0; transform: scale(0.06) translateY(40vh); border-radius:50%; }
35% { opacity:0.9; transform: scale(0.25) translateY(22vh); border-radius:50%; }
65% { opacity:1; transform: scale(0.8) translateY(3vh); border-radius:20px; }
100% { opacity:1; transform: scale(1) translateY(0); border-radius: var(--radius-lg, 12px); }
}
.ah--exiting .ah__drawer {
animation: ah-shrink 280ms cubic-bezier(0.36,0,0.66,-0.56) both;
}
@keyframes ah-shrink {
0% { opacity:1; transform: scale(1) translateY(0); border-radius: var(--radius-lg, 12px); }
100% { opacity:0; transform: scale(0.08) translateY(35vh); border-radius:50%; }
}
@media (max-width: 900px) {
.assistant-host__backdrop {
padding: 0;
align-items: stretch;
}
.ah__drawer--expanded {
width: calc(100vw - 8rem); max-width: 1200px;
height: calc(100vh - 8rem); max-height: none;
transition: width 300ms cubic-bezier(0.22,1,0.36,1), height 300ms cubic-bezier(0.22,1,0.36,1);
}
.assistant-host__drawer {
width: 100vw;
height: 100vh;
border-radius: 0;
/* ===== Title bar ===== */
.ah__titlebar {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-primary);
background: color-mix(in srgb, var(--color-surface-primary) 95%, var(--color-brand-primary));
flex-shrink: 0;
}
.ah__titlebar-left { display: flex; align-items: center; gap: 8px; }
.ah__titlebar-icon { border-radius: 50%; }
.ah__titlebar-text { margin:0; font-size:0.8125rem; font-weight:600; color: var(--color-text-heading); }
.ah__titlebar-right { display: flex; align-items: center; gap: 4px; }
.ah__toggle-btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full, 9999px);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.625rem; font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.ah__toggle-btn:hover {
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
background: color-mix(in srgb, var(--color-brand-primary) 8%, var(--color-surface-secondary));
}
.ah__toggle-btn svg { width:14px; height:14px; flex-shrink:0; }
.ah__hdr-btn {
width:28px; height:28px; border:none; background:transparent;
color: var(--color-text-muted); cursor:pointer; border-radius: var(--radius-md);
display:flex; align-items:center; justify-content:center;
transition: all 0.15s;
}
.ah__hdr-btn:hover { background: var(--color-surface-tertiary); color: var(--color-text-primary); }
.ah__close-btn:hover { background: color-mix(in srgb, var(--color-status-error, #c62828) 10%, transparent); color: var(--color-status-error, #c62828); }
/* ===== Content ===== */
.ah__content { flex:1; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
.ah__content stellaops-chat { flex:1; }
/* ===== Search view ===== */
.ah__search-view { display:flex; flex-direction:column; height:100%; }
.ah__search-bar {
display:flex; align-items:center; gap:8px;
padding: 10px 14px;
border-bottom: 1px solid var(--color-border-primary);
flex-shrink:0;
color: var(--color-text-secondary);
}
.ah__search-input {
flex:1; border:1px solid var(--color-border-primary);
border-radius: var(--radius-full, 9999px);
background: var(--color-surface-secondary);
padding:6px 14px; font-size:0.75rem;
color: var(--color-text-primary); outline:none; min-width:0;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.ah__search-input::placeholder { color: var(--color-text-secondary); font-style:italic; }
.ah__search-input:focus {
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-primary) 12%, transparent);
background: var(--color-surface-primary);
}
.ah__spinner {
width:14px; height:14px; border:2px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius:50%; animation: spin 0.6s linear infinite; flex-shrink:0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.ah__search-body { flex:1; overflow-y:auto; padding:10px; }
.ah__syn { font-size:0.75rem; line-height:1.5; color: var(--color-text-primary); padding:8px; margin-bottom:6px; border-bottom: 1px solid color-mix(in srgb, var(--color-border-primary) 25%, transparent); }
.ah__card {
display:flex; flex-direction:column; gap:2px;
padding:8px 10px; border:1px solid transparent;
border-radius: var(--radius-md, 8px);
text-decoration:none; color:inherit; cursor:pointer;
transition: all 0.15s;
}
.ah__card:hover { background: var(--color-surface-secondary); border-color: color-mix(in srgb, var(--color-brand-primary) 20%, transparent); transform: translateX(2px); }
.ah__card-type {
font-size:0.5rem; font-weight:700; text-transform:uppercase;
letter-spacing:0.06em; color: var(--color-brand-primary);
display:inline-flex; align-items:center; gap:4px;
}
.ah__card-type::before { content:''; width:6px; height:6px; border-radius:50%; background: var(--color-brand-primary); opacity:0.5; }
.ah__card-title { font-size:0.75rem; font-weight:600; color: var(--color-text-heading); line-height:1.3; }
.ah__card-snip { font-size:0.625rem; color: var(--color-text-secondary); line-height:1.4; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.ah__search-empty { padding:24px 16px; text-align:center; color: var(--color-text-secondary); font-size:0.75rem; }
/* ===== Bubble trail ===== */
.ah__trail { position:fixed; inset:0; z-index:190; pointer-events:none; }
.ah__bubble {
position:absolute; border-radius:50%;
background: var(--color-surface-primary, #FFFCF5);
border: 2px solid var(--color-brand-primary, #F5A623);
box-shadow: 0 1px 6px rgba(245,166,35,0.15);
opacity:0;
animation: ah-pop 280ms cubic-bezier(0.34,1.56,0.64,1) forwards;
}
@keyframes ah-pop { 0%{opacity:0;transform:scale(0)} 70%{opacity:1;transform:scale(1.15)} 100%{opacity:1;transform:scale(1)} }
.ah__trail:not(.ah__trail--streaming) .ah__bubble {
animation: ah-pop 280ms cubic-bezier(0.34,1.56,0.64,1) forwards, ah-breathe 3s ease-in-out 700ms infinite;
}
@keyframes ah-breathe { 0%,100%{transform:scale(1);opacity:0.8} 50%{transform:scale(1.06);opacity:1} }
.ah__trail--streaming .ah__bubble {
animation: ah-pop 280ms cubic-bezier(0.34,1.56,0.64,1) forwards, ah-pulse 0.7s ease-in-out infinite;
}
@keyframes ah-pulse {
0%,100% { transform:scale(1); opacity:0.6; box-shadow: 0 0 4px rgba(245,166,35,0.2); }
50% { transform:scale(1.4); opacity:1; box-shadow: 0 0 18px rgba(245,166,35,0.55); }
}
.ah--exiting .ah__bubble { animation: ah-vanish 180ms ease forwards !important; }
@keyframes ah-vanish { to { opacity:0; transform:scale(0); } }
/* Entrance trail pseudo-elements */
.ah--entering .ah__backdrop::before,
.ah--entering .ah__backdrop::after {
content:''; position:fixed; border-radius:50%;
background: var(--color-brand-primary, #F5A623);
pointer-events:none; z-index:191;
}
.ah--entering .ah__backdrop::before { width:14px;height:14px; animation: ah-fly1 380ms cubic-bezier(0.18,0.89,0.32,1) both; }
.ah--entering .ah__backdrop::after { width:9px;height:9px; animation: ah-fly2 420ms cubic-bezier(0.18,0.89,0.32,1) 60ms both; }
@keyframes ah-fly1 { 0%{bottom:56px;right:80px;opacity:0.8;transform:scale(0.4)} 50%{opacity:0.6;transform:scale(1)} 100%{bottom:calc(100vh - 5rem);right:50%;opacity:0;transform:scale(0.2)} }
@keyframes ah-fly2 { 0%{bottom:50px;right:90px;opacity:0.6;transform:scale(0.3)} 60%{opacity:0.5;transform:scale(0.8)} 100%{bottom:calc(100vh - 7rem);right:45%;opacity:0;transform:scale(0.15)} }
/* ===== Mobile ===== */
@media (max-width: 900px) {
.ah__backdrop { padding:0; align-items:stretch; }
.ah__drawer { width:100vw; height:100vh; border-radius:0; animation: ah-mobile 250ms cubic-bezier(0.18,0.89,0.32,1) both; }
@keyframes ah-mobile { from{opacity:0;transform:scale(0.96)} to{opacity:1;transform:scale(1)} }
.ah__trail { display:none; }
.ah--entering .ah__backdrop::before, .ah--entering .ah__backdrop::after { display:none; }
}
/* ===== Reduced motion ===== */
@media (prefers-reduced-motion: reduce) {
.ah__drawer, .ah__bubble, .ah__backdrop,
.ah--entering .ah__backdrop::before, .ah--entering .ah__backdrop::after {
animation: none !important; opacity:1 !important;
}
}
`],
})
export class SearchAssistantHostComponent {
private readonly router = inject(Router);
readonly assistantDrawer = inject(SearchAssistantDrawerService);
readonly context = inject(PlatformContextStore);
readonly stella = inject(StellaAssistantService);
@ViewChild('assistantDrawerElement') private drawerRef?: ElementRef<HTMLElement>;
readonly entering = signal(false);
readonly exiting = signal(false);
readonly drawerExpanded = signal(false);
readonly chatVisible = signal(true);
readonly searchViewQuery = signal('');
readonly trailBubbles = signal<TrailBubble[]>([]);
@ViewChild('drawerEl') private drawerRef?: ElementRef<HTMLElement>;
constructor() {
effect(() => {
if (!this.assistantDrawer.isOpen()) {
return;
if (this.assistantDrawer.isOpen()) {
this.entering.set(true);
this.chatVisible.set(true);
setTimeout(() => { this.entering.set(false); this.computeTrail(); }, 500);
setTimeout(() => this.drawerRef?.nativeElement?.focus(), 100);
setTimeout(() => this.computeTrail(), 50);
}
setTimeout(() => this.drawerRef?.nativeElement?.focus(), 0);
});
}
close(): void {
this.assistantDrawer.close();
this.exiting.set(true);
setTimeout(() => {
this.exiting.set(false);
this.trailBubbles.set([]);
this.assistantDrawer.close();
}, 280);
}
onToggleView(): void { this.stella.toggleDrawerView(); }
onNewSession(): void {
this.stella.newSession();
this.chatVisible.set(false);
setTimeout(() => this.chatVisible.set(true), 0);
}
onToggleExpand(): void {
this.drawerExpanded.update(v => !v);
setTimeout(() => this.computeTrail(), 350);
}
onConversationCreated(conversationId: string): void {
this.stella.setActiveConversation(conversationId);
}
onChatTyping(value: string): void {
// Auto-derive search results from chat input (only in chat view)
if (this.stella.drawerView() === 'chat' && value.trim().length >= 2) {
this.stella.searchAsYouType(value);
}
}
onSearchViewInput(event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.searchViewQuery.set(value);
if (value.trim().length >= 2) {
this.stella.searchAsYouType(value);
} else if (!value.trim()) {
this.stella.clearSearch();
}
}
getCardRoute(card: any): string {
const nav = card.actions?.find((a: any) => a.actionType === 'navigate');
return nav?.route ?? '#';
}
onCardClick(event: Event, card: any): void {
event.preventDefault();
const route = this.getCardRoute(card);
if (route && route !== '#') {
this.assistantDrawer.close();
this.trailBubbles.set([]);
this.router.navigateByUrl(route);
}
}
onSearchForMore(query: string): void {
if (query.trim()) {
this.assistantDrawer.close({ restoreFocus: false });
this.stella.drawerView.set('search');
this.searchViewQuery.set(query);
this.stella.searchAsYouType(query);
}
}
onBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
this.close();
}
if (event.target === event.currentTarget) this.close();
}
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.assistantDrawer.isOpen()) {
this.close();
if (this.assistantDrawer.isOpen() && !this.exiting()) this.close();
}
@HostListener('window:resize')
onResize(): void {
if (this.assistantDrawer.isOpen()) this.computeTrail();
}
private computeTrail(): void {
const mascotEl = document.querySelector('[aria-label="Stella helper assistant"] button');
const drawerEl = this.drawerRef?.nativeElement;
if (!mascotEl || !drawerEl) { this.trailBubbles.set([]); return; }
const mR = mascotEl.getBoundingClientRect();
const dR = drawerEl.getBoundingClientRect();
const p0x = mR.left, p0y = mR.top + mR.height / 2;
const p2x = dR.left + 20, p2y = dR.bottom - 30;
const p1x = dR.left - 80, p1y = p2y + 100;
const dist = Math.hypot(p2x - p0x, p2y - p0y);
const density = this.drawerExpanded() ? 35 : 45;
const count = Math.max(8, Math.min(20, Math.round(dist / density)));
const bubbles: TrailBubble[] = [];
for (let i = 0; i < count; i++) {
const linear = i / (count - 1);
const t = Math.pow(linear, 1.8);
const mt = 1 - t;
const x = mt*mt*p0x + 2*mt*t*p1x + t*t*p2x;
const y = mt*mt*p0y + 2*mt*t*p1y + t*t*p2y;
bubbles.push({ x, y, size: 4 + 20 * linear, delay: 50 + i * 50 });
}
this.trailBubbles.set(bubbles);
}
}

View File

@@ -0,0 +1,578 @@
/**
* StellaAssistantService — Unified mascot + search + AI chat service.
*
* Three modes:
* tips — DB-backed, locale-aware contextual tips (default)
* search — Delegates to UnifiedSearchClient (same as Ctrl+K)
* chat — Delegates to ChatService (Advisory AI with SSE)
*
* Tips are fetched from the backend API per route + locale.
* Falls back to the static tips config if the API is unavailable.
*/
import { Injectable, inject, signal, computed, effect } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router, NavigationEnd } from '@angular/router';
import { filter, firstValueFrom, catchError, of, Subject, Subscription } from 'rxjs';
import { I18nService } from '../../../core/i18n/i18n.service';
import { ChatService } from '../../../features/advisory-ai/chat/chat.service';
import { SearchAssistantDrawerService } from '../../../core/services/search-assistant-drawer.service';
import { SearchChatContextService } from '../../../core/services/search-chat-context.service';
import { UnifiedSearchClient } from '../../../core/api/unified-search.client';
import type { EntityCard, SynthesisResult } from '../../../core/api/unified-search.models';
import {
StellaHelperTip,
StellaHelperPageConfig,
PAGE_TIPS,
resolvePageKey,
} from './stella-helper-tips.config';
import type { StellaContextKey } from './stella-helper-context.service';
// ---------------------------------------------------------------------------
// API response models
// ---------------------------------------------------------------------------
export interface AssistantTipDto {
tipId: string;
title: string;
body: string;
action?: { label: string; route: string };
contextTrigger?: string;
}
export interface AssistantTipsResponse {
greeting: string;
tips: AssistantTipDto[];
contextTips: AssistantTipDto[];
}
export interface GlossaryTermDto {
termId: string;
term: string;
definition: string;
extendedHelp?: string;
relatedTerms: string[];
relatedRoutes: string[];
}
export interface GlossaryResponse {
terms: GlossaryTermDto[];
}
export interface AssistantUserState {
seenRoutes: string[];
completedTours: string[];
tipPositions: Record<string, number>;
dismissed: boolean;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
/** Mascot modes: tips (contextual help) or search (inline results). Chat opens the full drawer. */
export type AssistantMode = 'tips' | 'search';
const API_BASE = '/api/v1/stella-assistant';
const USER_STATE_STORAGE_KEY = 'stellaops.assistant.state';
@Injectable({ providedIn: 'root' })
export class StellaAssistantService {
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly i18n = inject(I18nService);
private readonly assistantDrawer = inject(SearchAssistantDrawerService);
private readonly chatService = inject(ChatService);
private readonly searchClient = inject(UnifiedSearchClient);
private readonly searchChatCtx = inject(SearchChatContextService);
private routerSub?: Subscription;
// ---- Mode ----
readonly mode = signal<AssistantMode>('tips');
readonly isOpen = signal(false);
readonly isMinimized = signal(false);
// ---- Tips (API-backed with static fallback) ----
readonly greeting = signal('');
readonly tips = signal<StellaHelperTip[]>([]);
readonly contextTips = signal<StellaHelperTip[]>([]);
readonly currentTipIndex = signal(0);
readonly tipsLoading = signal(false);
private tipsApiAvailable = true; // flip to false on first failure
// ---- Context ----
readonly currentRoute = signal('/');
readonly currentPageKey = signal('dashboard');
readonly activeContexts = signal<StellaContextKey[]>([]);
// ---- Glossary ----
readonly glossary = signal<GlossaryTermDto[]>([]);
private glossaryLoaded = false;
// ---- Search ----
readonly searchQuery = signal('');
readonly searchResults = signal<EntityCard[]>([]);
readonly searchSynthesis = signal<SynthesisResult | null>(null);
readonly searchLoading = signal(false);
private searchSub?: Subscription;
// ---- Chat animation state ----
readonly chatAnimationState = signal<'idle' | 'thinking' | 'typing' | 'done' | 'error'>('idle');
private chatStreamSub?: Subscription;
// ---- Session persistence ----
readonly activeConversationId = signal<string | null>(null);
readonly conversationHistory = signal<string[]>([]);
// ---- Drawer view toggle (chat vs search) ----
readonly drawerView = signal<'chat' | 'search'>('chat');
// ---- User state ----
readonly userState = signal<AssistantUserState>(this.loadLocalState());
// ---- Derived ----
readonly effectiveTips = computed<StellaHelperTip[]>(() => {
const ctx = this.contextTips();
const page = this.tips();
// Context tips prepended (higher priority), deduped
const combined = [...ctx];
for (const t of page) {
if (!combined.some(c => c.title === t.title)) {
combined.push(t);
}
}
return combined;
});
readonly totalTips = computed(() => this.effectiveTips().length);
readonly currentTip = computed<StellaHelperTip | null>(() => {
const tips = this.effectiveTips();
const idx = this.currentTipIndex();
return tips[idx] ?? null;
});
readonly isFirstVisit = computed(() => {
const key = this.currentPageKey();
return !this.userState().seenRoutes.includes(key);
});
// Chat is handled by SearchAssistantDrawerService (full ChatComponent)
constructor() {
// Persist user state on change
effect(() => this.saveLocalState(this.userState()));
}
// ---- Lifecycle ----
init(): void {
// Set initial page
this.onRouteChange(this.router.url);
// Listen for route changes
this.routerSub = this.router.events
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
.subscribe(e => this.onRouteChange(e.urlAfterRedirects ?? e.url));
// Load glossary once
this.loadGlossary();
}
destroy(): void {
this.routerSub?.unsubscribe();
}
// ---- Mode transitions ----
enterTips(): void {
this.mode.set('tips');
this.searchQuery.set('');
}
/**
* Activate the global search bar with a query.
* The mascot does NOT have its own search — it delegates to the
* top-bar GlobalSearchComponent for a single, consistent experience.
*
* Uses the native input setter + input event to trigger Angular's
* ngModel binding correctly (dispatchEvent with InputEvent, not Event).
*/
private searchDebounceTimer?: ReturnType<typeof setTimeout>;
/**
* Search-as-you-type with 200ms debounce.
* Called on every keystroke in the mascot input.
* Results appear inline in the mascot bubble above the input.
*/
searchAsYouType(query: string): void {
this.searchQuery.set(query);
if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
if (!query.trim() || query.trim().length < 2) {
this.searchResults.set([]);
this.searchSynthesis.set(null);
this.searchLoading.set(false);
return;
}
this.searchLoading.set(true);
this.searchDebounceTimer = setTimeout(() => {
this.searchSub?.unsubscribe();
this.searchSub = this.searchClient.search(query.trim(), undefined, 5, {
currentRoute: this.currentRoute(),
}).subscribe({
next: (resp) => {
this.searchResults.set(resp.cards);
this.searchSynthesis.set(resp.synthesis);
this.searchLoading.set(false);
},
error: () => {
this.searchResults.set([]);
this.searchLoading.set(false);
},
});
}, 200);
}
/** Clear search results (when input is emptied). */
clearSearch(): void {
this.searchQuery.set('');
this.searchResults.set([]);
this.searchSynthesis.set(null);
this.searchLoading.set(false);
this.searchSub?.unsubscribe();
if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
}
/**
* Open the AI chat drawer with the full ChatComponent.
* The mascot does NOT embed chat — it delegates to the proper drawer.
*
* ALL AI chat entry points converge here:
* - Mascot "Ask Stella" button
* - Mascot question input (detected as question)
* - Search bar "Open deeper help" icon (handled by GlobalSearchComponent directly)
*/
openChat(initialMessage?: string): void {
// Pass search context to the chat if we have results
if (initialMessage || this.searchResults().length > 0) {
this.searchChatCtx.setSearchToChat({
query: initialMessage ?? this.searchQuery(),
entityCards: this.searchResults(),
synthesis: this.searchSynthesis(),
suggestedPrompt: initialMessage ?? undefined,
});
}
// Close the mascot bubble — the drawer takes over
this.isOpen.set(false);
this.drawerView.set('chat');
// If resuming an existing conversation, don't send initialMessage again
const resuming = this.activeConversationId() && !initialMessage;
// Open the full chat drawer
this.assistantDrawer.open({
initialUserMessage: resuming ? null : (initialMessage ?? null),
source: 'stella_assistant',
});
// Connect mascot animation to chat stream events
this.chatStreamSub?.unsubscribe();
this.chatAnimationState.set('idle');
this.chatStreamSub = this.chatService.streamEvents.subscribe((event) => {
const type = (event as any).event ?? '';
switch (type) {
case 'start':
case 'progress':
this.chatAnimationState.set('thinking');
break;
case 'token':
this.chatAnimationState.set('typing');
break;
case 'done':
this.chatAnimationState.set('done');
setTimeout(() => this.chatAnimationState.set('idle'), 2000);
break;
case 'error':
this.chatAnimationState.set('error');
setTimeout(() => this.chatAnimationState.set('idle'), 3000);
break;
}
});
}
open(): void {
this.isOpen.set(true);
this.isMinimized.set(false);
}
// ---- Session management ----
setActiveConversation(id: string): void {
this.activeConversationId.set(id);
}
newSession(): void {
const currentId = this.activeConversationId();
if (currentId) {
this.conversationHistory.update(h =>
h.includes(currentId) ? h : [...h, currentId]
);
}
this.chatService.clearConversation();
this.activeConversationId.set(null);
}
toggleDrawerView(): void {
this.drawerView.update(v => v === 'chat' ? 'search' : 'chat');
}
close(): void {
this.isOpen.set(false);
}
minimize(): void {
this.isMinimized.set(true);
}
dismiss(): void {
this.isOpen.set(false);
this.userState.update(s => ({ ...s, dismissed: true }));
}
restore(): void {
this.userState.update(s => ({ ...s, dismissed: false }));
this.isOpen.set(true);
this.isMinimized.set(false);
}
// ---- Tips ----
nextTip(): void {
const max = this.totalTips() - 1;
if (this.currentTipIndex() < max) {
this.currentTipIndex.update(i => i + 1);
this.saveTipPosition();
}
}
prevTip(): void {
if (this.currentTipIndex() > 0) {
this.currentTipIndex.update(i => i - 1);
this.saveTipPosition();
}
}
navigateToAction(route: string): void {
this.close();
this.router.navigateByUrl(route);
}
// ---- Context ----
pushContext(key: StellaContextKey): void {
this.activeContexts.update(list => {
if (list.includes(key)) return list;
return [...list, key];
});
// Re-load tips with new context
this.loadTipsForCurrentRoute();
}
pushContexts(keys: StellaContextKey[]): void {
this.activeContexts.update(list => {
const next = [...list];
let changed = false;
for (const k of keys) {
if (!next.includes(k)) {
next.push(k);
changed = true;
}
}
return changed ? next : list;
});
this.loadTipsForCurrentRoute();
}
removeContext(key: StellaContextKey): void {
this.activeContexts.update(list => list.filter(k => k !== key));
}
clearContexts(): void {
this.activeContexts.set([]);
}
// ---- Internal ----
private async onRouteChange(url: string): Promise<void> {
const path = url.split('?')[0].split('#')[0];
const key = resolvePageKey(url);
if (key === this.currentPageKey() && path === this.currentRoute()) return;
this.clearContexts();
this.currentRoute.set(path);
this.currentPageKey.set(key);
// Restore tip position
const saved = this.userState().tipPositions[key];
this.currentTipIndex.set(saved ?? 0);
// Load tips from API (or fallback to static)
await this.loadTipsForCurrentRoute();
// Auto-open on first visit
if (this.isFirstVisit() && !this.userState().dismissed) {
setTimeout(() => {
this.isOpen.set(true);
this.isMinimized.set(false);
this.markRouteSeen(key);
}, 800);
}
// If in search/chat mode, return to tips on navigation
if (this.mode() === 'search') {
this.enterTips();
}
}
private async loadTipsForCurrentRoute(): Promise<void> {
const route = this.currentRoute();
const locale = this.i18n.locale();
const contexts = this.activeContexts().join(',');
if (this.tipsApiAvailable) {
this.tipsLoading.set(true);
try {
const params: Record<string, string> = { route, locale };
if (contexts) params['contexts'] = contexts;
const resp = await firstValueFrom(
this.http.get<AssistantTipsResponse>(`${API_BASE}/tips`, { params }).pipe(
catchError(() => {
this.tipsApiAvailable = false;
return of(null);
})
)
);
if (resp) {
this.greeting.set(resp.greeting);
this.tips.set(resp.tips.map(this.dtoToTip));
this.contextTips.set(resp.contextTips.map(this.dtoToTip));
this.tipsLoading.set(false);
return;
}
} catch {
this.tipsApiAvailable = false;
}
this.tipsLoading.set(false);
}
// Fallback to static tips
this.loadStaticTips();
}
private loadStaticTips(): void {
const key = this.currentPageKey();
const config = PAGE_TIPS[key] ?? PAGE_TIPS['default'];
this.greeting.set(config.greeting);
this.tips.set(config.tips);
// Find context-triggered tips from static config
const contexts = this.activeContexts();
if (contexts.length > 0) {
const ctxTips: StellaHelperTip[] = [];
for (const cfg of Object.values(PAGE_TIPS)) {
for (const tip of cfg.tips) {
if (tip.contextTrigger && contexts.includes(tip.contextTrigger)) {
if (!ctxTips.some(t => t.title === tip.title)) {
ctxTips.push(tip);
}
}
}
}
this.contextTips.set(ctxTips);
} else {
this.contextTips.set([]);
}
}
private async loadGlossary(): Promise<void> {
if (this.glossaryLoaded) return;
try {
const locale = this.i18n.locale();
const resp = await firstValueFrom(
this.http.get<GlossaryResponse>(`${API_BASE}/glossary`, { params: { locale } }).pipe(
catchError(() => of({ terms: [] }))
)
);
this.glossary.set(resp.terms);
this.glossaryLoaded = true;
} catch {
// Glossary unavailable, not critical
}
}
private markRouteSeen(key: string): void {
this.userState.update(s => {
if (s.seenRoutes.includes(key)) return s;
return { ...s, seenRoutes: [...s.seenRoutes, key] };
});
}
private saveTipPosition(): void {
const key = this.currentPageKey();
const idx = this.currentTipIndex();
this.userState.update(s => ({
...s,
tipPositions: { ...s.tipPositions, [key]: idx },
}));
}
private dtoToTip(dto: AssistantTipDto): StellaHelperTip {
return {
title: dto.title,
body: dto.body,
action: dto.action,
contextTrigger: dto.contextTrigger,
};
}
// ---- Persistence (localStorage with future API sync) ----
private loadLocalState(): AssistantUserState {
try {
const raw = localStorage.getItem(USER_STATE_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
seenRoutes: Array.isArray(parsed.seenRoutes) ? parsed.seenRoutes : [],
completedTours: Array.isArray(parsed.completedTours) ? parsed.completedTours : [],
tipPositions: parsed.tipPositions && typeof parsed.tipPositions === 'object' ? parsed.tipPositions : {},
dismissed: typeof parsed.dismissed === 'boolean' ? parsed.dismissed : false,
};
}
} catch { /* ignore */ }
return { seenRoutes: [], completedTours: [], tipPositions: {}, dismissed: false };
}
private saveLocalState(state: AssistantUserState): void {
try {
localStorage.setItem(USER_STATE_STORAGE_KEY, JSON.stringify(state));
} catch { /* ignore */ }
}
// Future: sync state to backend API
// async syncStateToServer(): Promise<void> {
// await firstValueFrom(
// this.http.put(`${API_BASE}/user-state`, this.userState())
// );
// }
}

View File

@@ -0,0 +1,153 @@
import { Injectable, signal, computed } from '@angular/core';
/**
* StellaHelperContextService — Reactive context injection for the Stella Helper.
*
* Page/tab components inject this service and push context signals when they
* detect states that the helper should react to. The helper subscribes to
* these signals and surfaces context-triggered tips with priority.
*
* Architecture:
* - Components push context keys (e.g., 'sbom-missing', 'gate-blocked')
* - Helper reads activeContexts() signal and matches against tip.contextTrigger
* - Context-triggered tips take priority over generic page tips
* - Contexts are cleared automatically on navigation (page change)
*
* Usage in a component:
* ```ts
* private helperCtx = inject(StellaHelperContextService);
*
* ngOnInit() {
* if (this.environments().some(e => e.sbomStatus === 'missing')) {
* this.helperCtx.push('sbom-missing');
* }
* if (this.riskBudget() > 70) {
* this.helperCtx.push('budget-exceeded');
* }
* }
* ```
*/
/** Well-known context keys that page components can push. */
export type StellaContextKey =
// Dashboard / global
| 'sbom-missing'
| 'health-unknown'
| 'no-environments'
| 'feed-stale'
| 'feed-never-synced'
// Releases
| 'gate-blocked'
| 'gate-warn'
| 'evidence-missing'
| 'release-draft'
| 'release-failed'
| 'approval-pending'
| 'approval-expired'
// Security
| 'critical-open'
| 'high-open'
| 'no-findings'
| 'no-sbom-components'
| 'reachability-low'
| 'unknowns-present'
// Policy
| 'budget-exceeded'
| 'budget-critical'
| 'no-baseline'
| 'policy-conflict'
| 'sealed-mode-active'
| 'shadow-mode-active'
// VEX
| 'vex-zero'
| 'vex-conflicts'
| 'exceptions-pending'
| 'exceptions-expiring'
// Evidence
| 'no-capsules'
| 'no-audit-events'
| 'proof-chain-broken'
// Operations
| 'dead-letters'
| 'jobs-failed'
| 'agents-none'
| 'agents-degraded'
| 'signals-degraded'
// Integrations
| 'no-integrations'
| 'integration-error'
// Empty states (generic)
| 'empty-table'
| 'empty-list'
| 'first-visit'
// Custom (string allows extension without modifying the type)
| (string & {});
@Injectable({ providedIn: 'root' })
export class StellaHelperContextService {
/** Active context keys pushed by page components. */
private readonly _contexts = signal<Set<StellaContextKey>>(new Set());
/** Read-only signal of active contexts. */
readonly activeContexts = computed(() => [...this._contexts()]);
/** Whether any context is currently active. */
readonly hasContext = computed(() => this._contexts().size > 0);
/**
* Push a context key. The helper will check if any tips match
* this context and surface them with priority.
*/
push(key: StellaContextKey): void {
this._contexts.update(set => {
if (set.has(key)) return set;
const next = new Set(set);
next.add(key);
return next;
});
}
/**
* Push multiple context keys at once.
*/
pushAll(keys: StellaContextKey[]): void {
this._contexts.update(set => {
const next = new Set(set);
let changed = false;
for (const k of keys) {
if (!next.has(k)) {
next.add(k);
changed = true;
}
}
return changed ? next : set;
});
}
/**
* Remove a specific context (e.g., when state changes).
*/
remove(key: StellaContextKey): void {
this._contexts.update(set => {
if (!set.has(key)) return set;
const next = new Set(set);
next.delete(key);
return next;
});
}
/**
* Clear all contexts. Called automatically on route change
* by the StellaHelperComponent.
*/
clear(): void {
this._contexts.set(new Set());
}
/**
* Check if a specific context is active.
*/
has(key: StellaContextKey): boolean {
return this._contexts().has(key);
}
}

View File

@@ -0,0 +1,466 @@
import {
Component,
ChangeDetectionStrategy,
inject,
signal,
computed,
OnDestroy,
} from '@angular/core';
import { Router } from '@angular/router';
import { StellaAssistantService } from './stella-assistant.service';
/**
* Tour step definition (from DB or static).
*/
export interface TourStep {
stepOrder: number;
route: string;
selector?: string;
title: string;
body: string;
action?: { label: string; route: string };
}
export interface Tour {
tourKey: string;
title: string;
description: string;
steps: TourStep[];
}
/**
* StellaTourComponent — Guided walkthrough engine.
*
* Shows the Stella mascot at each step, highlighting the target element
* with a backdrop overlay. Steps can navigate to different pages.
*
* Usage:
* <app-stella-tour
* [tour]="activeTour()"
* (tourComplete)="onTourDone()"
* (tourCancel)="onTourCancel()"
* />
*/
@Component({
selector: 'app-stella-tour',
standalone: true,
imports: [],
template: `
@if (isActive()) {
<!-- Backdrop overlay -->
<div class="tour-backdrop" (click)="onSkip()">
@if (highlightRect(); as rect) {
<div
class="tour-highlight"
[style.top.px]="rect.top - 4"
[style.left.px]="rect.left - 4"
[style.width.px]="rect.width + 8"
[style.height.px]="rect.height + 8"
></div>
}
</div>
<!-- Step card -->
<div
class="tour-card"
[style.top.px]="cardPosition().top"
[style.left.px]="cardPosition().left"
role="dialog"
aria-label="Tour step"
>
<div class="tour-card__header">
<img
src="assets/img/stella-ops-icon.png"
alt="Stella"
class="tour-card__mascot"
width="32"
height="32"
/>
<div class="tour-card__meta">
<span class="tour-card__tour-title">{{ tourTitle() }}</span>
<span class="tour-card__step-count">Step {{ currentStepIndex() + 1 }} of {{ totalSteps() }}</span>
</div>
<button class="tour-card__close" (click)="onSkip()" title="Skip tour" aria-label="Skip tour">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="tour-card__body">
<div class="tour-card__title">{{ currentStep()?.title }}</div>
<div class="tour-card__text">{{ currentStep()?.body }}</div>
@if (currentStep()?.action; as action) {
<button class="tour-card__action" (click)="onStepAction(action.route)">
{{ action.label }}
<svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true">
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
}
</div>
<!-- Progress bar -->
<div class="tour-card__progress">
<div class="tour-card__progress-fill" [style.width.%]="progressPercent()"></div>
</div>
<div class="tour-card__nav">
<button
class="tour-card__nav-btn"
(click)="onPrev()"
[disabled]="currentStepIndex() === 0"
>
<svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true">
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back
</button>
@if (currentStepIndex() < totalSteps() - 1) {
<button class="tour-card__nav-btn tour-card__nav-btn--primary" (click)="onNext()">
Next
<svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true">
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
} @else {
<button class="tour-card__nav-btn tour-card__nav-btn--primary" (click)="onFinish()">
Done
</button>
}
</div>
</div>
}
`,
styles: [`
.tour-backdrop {
position: fixed;
inset: 0;
z-index: 900;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
animation: tour-fade-in 0.3s ease;
}
@keyframes tour-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.tour-highlight {
position: absolute;
border: 2px solid var(--color-brand-primary);
border-radius: var(--radius-md, 8px);
box-shadow:
0 0 0 9999px rgba(0, 0, 0, 0.55),
0 0 24px rgba(245, 166, 35, 0.4);
pointer-events: none;
transition: all 0.3s ease;
}
.tour-card {
position: fixed;
z-index: 950;
width: 340px;
max-width: calc(100vw - 32px);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl, 12px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
animation: tour-card-enter 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
}
@keyframes tour-card-enter {
from { opacity: 0; transform: translateY(16px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.tour-card__header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 12px 8px;
}
.tour-card__mascot {
border-radius: 50%;
border: 2px solid var(--color-brand-primary);
flex-shrink: 0;
}
.tour-card__meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
.tour-card__tour-title {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-brand-primary);
}
.tour-card__step-count {
font-size: 0.625rem;
color: var(--color-text-secondary);
}
.tour-card__close {
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--color-surface-tertiary);
color: var(--color-text-primary);
}
}
.tour-card__body {
padding: 0 16px 12px;
}
.tour-card__title {
font-size: 0.8125rem;
font-weight: 700;
color: var(--color-text-heading);
margin-bottom: 4px;
}
.tour-card__text {
font-size: 0.75rem;
line-height: 1.55;
color: var(--color-text-primary);
}
.tour-card__action {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 8px;
padding: 4px 10px;
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-md, 8px);
background: transparent;
color: var(--color-brand-primary);
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
&:hover {
background: var(--color-brand-primary);
color: var(--color-text-heading);
}
}
.tour-card__progress {
height: 3px;
background: var(--color-surface-tertiary);
}
.tour-card__progress-fill {
height: 100%;
background: var(--color-brand-primary);
transition: width 0.3s ease;
}
.tour-card__nav {
display: flex;
justify-content: space-between;
padding: 8px 12px;
}
.tour-card__nav-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md, 8px);
background: transparent;
color: var(--color-text-primary);
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.3;
cursor: default;
}
&:hover:not(:disabled) {
border-color: var(--color-brand-primary);
}
}
.tour-card__nav-btn--primary {
background: var(--color-brand-primary);
border-color: var(--color-brand-primary);
color: var(--color-text-heading);
&:hover:not(:disabled) {
opacity: 0.9;
}
}
@media (prefers-reduced-motion: reduce) {
.tour-backdrop, .tour-card { animation: none; }
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StellaTourComponent implements OnDestroy {
private readonly router = inject(Router);
private readonly assistant = inject(StellaAssistantService);
// ---- State ----
readonly activeTour = signal<Tour | null>(null);
readonly currentStepIndex = signal(0);
private resizeObserver?: ResizeObserver;
// ---- Derived ----
readonly isActive = computed(() => this.activeTour() !== null);
readonly tourTitle = computed(() => this.activeTour()?.title ?? '');
readonly totalSteps = computed(() => this.activeTour()?.steps.length ?? 0);
readonly currentStep = computed<TourStep | null>(() => {
const tour = this.activeTour();
if (!tour) return null;
return tour.steps[this.currentStepIndex()] ?? null;
});
readonly progressPercent = computed(() => {
const total = this.totalSteps();
return total > 0 ? ((this.currentStepIndex() + 1) / total) * 100 : 0;
});
readonly highlightRect = signal<DOMRect | null>(null);
readonly cardPosition = computed(() => {
const rect = this.highlightRect();
if (!rect) {
// Center the card
return { top: window.innerHeight / 2 - 120, left: window.innerWidth / 2 - 170 };
}
// Position card below or beside the highlighted element
const cardWidth = 340;
const cardHeight = 240;
let top = rect.bottom + 16;
let left = rect.left;
// If card would go below viewport, place it above
if (top + cardHeight > window.innerHeight - 16) {
top = rect.top - cardHeight - 16;
}
// Keep within horizontal bounds
if (left + cardWidth > window.innerWidth - 16) {
left = window.innerWidth - cardWidth - 16;
}
if (left < 16) left = 16;
if (top < 16) top = 16;
return { top, left };
});
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
}
// ---- Public API ----
startTour(tour: Tour): void {
this.activeTour.set(tour);
this.currentStepIndex.set(0);
this.navigateToStep(0);
}
// ---- Handlers ----
onNext(): void {
const nextIdx = this.currentStepIndex() + 1;
if (nextIdx < this.totalSteps()) {
this.currentStepIndex.set(nextIdx);
this.navigateToStep(nextIdx);
}
}
onPrev(): void {
const prevIdx = this.currentStepIndex() - 1;
if (prevIdx >= 0) {
this.currentStepIndex.set(prevIdx);
this.navigateToStep(prevIdx);
}
}
onSkip(): void {
this.endTour(false);
}
onFinish(): void {
this.endTour(true);
}
onStepAction(route: string): void {
this.router.navigateByUrl(route);
}
// ---- Internal ----
private navigateToStep(idx: number): void {
const step = this.activeTour()?.steps[idx];
if (!step) return;
// Navigate to the step's route if different from current
const currentPath = window.location.pathname;
if (step.route && step.route !== currentPath) {
this.router.navigateByUrl(step.route).then(() => {
setTimeout(() => this.highlightElement(step.selector), 500);
});
} else {
this.highlightElement(step.selector);
}
}
private highlightElement(selector?: string): void {
if (!selector) {
this.highlightRect.set(null);
return;
}
const el = document.querySelector(selector);
if (el) {
const rect = el.getBoundingClientRect();
this.highlightRect.set(rect);
// Scroll element into view if needed
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
this.highlightRect.set(null);
}
}
private endTour(completed: boolean): void {
const tour = this.activeTour();
if (tour && completed) {
this.assistant.userState.update(s => ({
...s,
completedTours: s.completedTours.includes(tour.tourKey)
? s.completedTours
: [...s.completedTours, tour.tourKey],
}));
}
this.activeTour.set(null);
this.highlightRect.set(null);
}
}

View File

@@ -4,14 +4,27 @@
* Gentle, unobtrusive navigational quick-links rendered as subtle inline text
* with arrow indicators. Replaces all pill/card-based quick-link patterns.
*
* Layouts:
* - 'inline' — horizontal dots (minimal, for contextual links)
* - 'aside' — vertical list with descriptions (sidebar/bottom positions)
* - 'strip' — horizontal header strip with title + description, right-fade
* gradient and scroll button when overflowing (canonical header pattern)
*
* Usage:
* <stella-quick-links [links]="links" label="Related" />
* <stella-quick-links [links]="links" label="Quick Links" layout="strip" />
*/
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
NgZone,
OnDestroy,
ViewChild,
inject,
signal,
} from '@angular/core';
import { RouterLink } from '@angular/router';
@@ -25,7 +38,7 @@ export interface StellaQuickLink {
icon?: string;
/** Tooltip text */
hint?: string;
/** Short description shown below the label in aside layout */
/** Short description shown below the label in aside/strip layout */
description?: string;
}
@@ -35,10 +48,56 @@ export interface StellaQuickLink {
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (label) {
@if (label && layout !== 'strip') {
<span class="sql-label">{{ label }}</span>
}
@if (layout === 'aside') {
@if (layout === 'strip') {
<div class="sql-strip-outer">
@if (label) {
<span class="sql-strip-label">{{ label }}:</span>
}
<div class="sql-strip-scroll-area">
@if (canScrollLeft()) {
<div class="sql-strip-fade sql-strip-fade--left"></div>
<button class="sql-strip-btn sql-strip-btn--left"
(mouseenter)="startAutoScroll('left')"
(mouseleave)="stopAutoScroll()"
(click)="jumpScroll('left')"
type="button" aria-label="Scroll left">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
}
<nav class="sql-strip" #stripNav [attr.aria-label]="label || 'Quick links'">
@for (link of links; track link.label) {
<a class="sql-strip-link" [routerLink]="link.route" [title]="link.hint || link.label">
<span class="sql-strip-title">{{ link.label }}</span>
@if (link.description) {
<span class="sql-strip-desc">{{ link.description }}</span>
}
</a>
}
</nav>
@if (canScrollRight()) {
<div class="sql-strip-fade sql-strip-fade--right"></div>
<button class="sql-strip-btn sql-strip-btn--right"
(mouseenter)="startAutoScroll('right')"
(mouseleave)="stopAutoScroll()"
(click)="jumpScroll('right')"
type="button" aria-label="Scroll right">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 6l6 6-6 6"/>
</svg>
</button>
}
</div>
</div>
} @else if (layout === 'aside') {
<nav class="sql-aside" [attr.aria-label]="label || 'Quick links'">
@for (link of links; track link.label) {
<a class="sql-aside-link" [routerLink]="link.route" [title]="link.hint || link.label">
@@ -182,7 +241,160 @@ export interface StellaQuickLink {
line-height: 1;
}
/* Aside layout — vertical list with descriptions */
/* ====== Strip layout — canonical header pattern ====== */
:host([layout=strip]) {
border-top: none;
padding-top: 0;
margin-top: 0;
}
.sql-strip-outer {
display: flex;
align-items: center;
gap: 0;
min-width: 0;
}
.sql-strip-label {
flex-shrink: 0;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-secondary);
opacity: 0.55;
padding-right: 0.5rem;
white-space: nowrap;
}
.sql-strip-scroll-area {
position: relative;
flex: 1;
min-width: 0;
overflow: hidden;
--_fade-color: var(--strip-fade-color, var(--color-surface-primary));
}
.sql-strip {
display: flex;
gap: 0;
align-items: stretch;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
.sql-strip::-webkit-scrollbar {
display: none;
}
.sql-strip-fade {
position: absolute;
top: 0;
bottom: 0;
width: 48px;
pointer-events: none;
z-index: 1;
}
.sql-strip-fade--left {
left: 0;
background: linear-gradient(to right, var(--_fade-color) 30%, transparent 100%);
}
.sql-strip-fade--right {
right: 0;
background: linear-gradient(to left, var(--_fade-color) 30%, transparent 100%);
}
.sql-strip-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--radius-full, 9999px);
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
color: var(--color-text-secondary);
cursor: pointer;
padding: 0;
transition: background 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
.sql-strip-btn:hover {
background: var(--color-surface-elevated, var(--color-surface-secondary));
color: var(--color-text-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.sql-strip-btn--left { left: 0; }
.sql-strip-btn--right { right: 0; }
.sql-strip-link {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0.25rem 1rem;
text-decoration: none;
color: var(--color-text-secondary);
border-right: 1px solid color-mix(in srgb, var(--color-border-primary) 35%, transparent);
white-space: nowrap;
flex-shrink: 0;
transition: color 120ms ease;
cursor: pointer;
}
.sql-strip-link:first-child {
padding-left: 0;
}
.sql-strip-link:last-child {
border-right: none;
padding-right: 2rem;
}
.sql-strip-link:hover {
color: var(--color-text-primary);
}
.sql-strip-link:hover .sql-strip-title {
text-decoration: underline;
text-decoration-color: var(--color-brand-primary);
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
.sql-strip-link:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
border-radius: 2px;
}
.sql-strip-title {
font-size: 0.75rem;
font-weight: 600;
line-height: 1.3;
}
.sql-strip-desc {
font-size: 0.625rem;
line-height: 1.3;
color: var(--color-text-muted);
margin-top: 1px;
}
.sql-strip-link:hover .sql-strip-desc {
color: var(--color-text-secondary);
}
/* ====== Aside layout — vertical list with descriptions ====== */
:host([layout=aside]) {
border-top: none;
padding-top: 0;
@@ -261,13 +473,89 @@ export interface StellaQuickLink {
}
`],
})
export class StellaQuickLinksComponent {
/** Array of quick link definitions. */
export class StellaQuickLinksComponent implements AfterViewInit, OnDestroy {
private readonly zone = inject(NgZone);
@Input() links: StellaQuickLink[] = [];
/** Optional heading label (e.g. "Related", "Shortcuts", "Jump to"). */
@Input() label?: string;
@Input() layout: 'inline' | 'aside' | 'strip' = 'inline';
/** Layout variant: 'inline' (horizontal dots) or 'aside' (vertical with descriptions). */
@Input() layout: 'inline' | 'aside' = 'inline';
@ViewChild('stripNav') stripNav?: ElementRef<HTMLElement>;
readonly canScrollLeft = signal(false);
readonly canScrollRight = signal(false);
private resizeObserver?: ResizeObserver;
private autoScrollRaf = 0;
private autoScrollDir: 'left' | 'right' | null = null;
private static readonly AUTO_SPEED = 1.5; // px per frame (~90px/s at 60fps)
private static readonly JUMP_PX = 220;
ngAfterViewInit(): void {
if (this.layout === 'strip' && this.stripNav) {
this.syncOverflow();
this.zone.runOutsideAngular(() => {
this.stripNav!.nativeElement.addEventListener('scroll', this.onScroll, { passive: true });
this.resizeObserver = new ResizeObserver(() => this.syncOverflow());
this.resizeObserver.observe(this.stripNav!.nativeElement);
});
}
}
ngOnDestroy(): void {
this.stopAutoScroll();
this.stripNav?.nativeElement.removeEventListener('scroll', this.onScroll);
this.resizeObserver?.disconnect();
}
/** Hover: start continuous animated scroll */
startAutoScroll(dir: 'left' | 'right'): void {
this.autoScrollDir = dir;
if (!this.autoScrollRaf) {
this.zone.runOutsideAngular(() => this.tick());
}
}
/** Leave: stop animation */
stopAutoScroll(): void {
this.autoScrollDir = null;
if (this.autoScrollRaf) {
cancelAnimationFrame(this.autoScrollRaf);
this.autoScrollRaf = 0;
}
}
/** Click: jump scroll, stop animation, then re-hover resumes */
jumpScroll(dir: 'left' | 'right'): void {
const el = this.stripNav?.nativeElement;
if (!el) return;
this.stopAutoScroll();
const px = dir === 'right' ? StellaQuickLinksComponent.JUMP_PX : -StellaQuickLinksComponent.JUMP_PX;
el.scrollBy({ left: px, behavior: 'smooth' });
}
private tick = (): void => {
const el = this.stripNav?.nativeElement;
if (!el || !this.autoScrollDir) { this.autoScrollRaf = 0; return; }
const delta = this.autoScrollDir === 'right'
? StellaQuickLinksComponent.AUTO_SPEED
: -StellaQuickLinksComponent.AUTO_SPEED;
el.scrollLeft += delta;
this.autoScrollRaf = requestAnimationFrame(this.tick);
};
private onScroll = (): void => { this.syncOverflow(); };
private syncOverflow(): void {
const el = this.stripNav?.nativeElement;
if (!el) return;
const left = el.scrollLeft > 4;
const right = el.scrollWidth - el.scrollLeft - el.clientWidth > 4;
if (left !== this.canScrollLeft() || right !== this.canScrollRight()) {
this.zone.run(() => {
this.canScrollLeft.set(left);
this.canScrollRight.set(right);
});
}
}
}

View File

@@ -0,0 +1,129 @@
import {
Directive,
ElementRef,
inject,
OnInit,
OnDestroy,
Renderer2,
signal,
} from '@angular/core';
import { StellaAssistantService } from '../components/stella-helper/stella-assistant.service';
/**
* StellaGlossaryDirective — Auto-annotates domain terms with hover tooltips.
*
* Usage:
* <p stellaGlossary>This SBOM contains VEX statements about CVE findings.</p>
*
* The directive scans the element's text content for known glossary terms
* (loaded from DB via StellaAssistantService.glossary()) and wraps the
* first occurrence of each term with a tooltip.
*
* Only annotates the FIRST occurrence per term per element to avoid
* visual noise.
*/
@Directive({
selector: '[stellaGlossary]',
standalone: true,
})
export class StellaGlossaryDirective implements OnInit, OnDestroy {
private readonly el = inject(ElementRef);
private readonly renderer = inject(Renderer2);
private readonly assistant = inject(StellaAssistantService);
private processed = false;
private originalHtml = '';
private tooltipEl: HTMLElement | null = null;
ngOnInit(): void {
// Wait a tick for the element to render, then annotate
setTimeout(() => this.annotate(), 500);
}
ngOnDestroy(): void {
this.removeTooltip();
}
private annotate(): void {
if (this.processed) return;
const terms = this.assistant.glossary();
if (terms.length === 0) return;
const element = this.el.nativeElement as HTMLElement;
this.originalHtml = element.innerHTML;
// Build a regex for all terms (case-insensitive, word boundaries)
// Sort by length descending to match longer terms first
const sortedTerms = [...terms].sort((a, b) => b.term.length - a.term.length);
const annotatedTerms = new Set<string>();
let html = this.originalHtml;
for (const term of sortedTerms) {
if (annotatedTerms.has(term.term.toLowerCase())) continue;
// Match only in text nodes (not inside tags)
const termRegex = new RegExp(
`(?<![<\\w])\\b(${escapeRegex(term.term)})\\b(?![\\w>])`,
'i'
);
if (termRegex.test(html)) {
html = html.replace(termRegex, (match) => {
annotatedTerms.add(term.term.toLowerCase());
return `<span class="stella-glossary-term" data-term="${escapeHtml(term.term)}" data-def="${escapeHtml(term.definition)}">${match}</span>`;
});
}
}
if (annotatedTerms.size > 0) {
element.innerHTML = html;
this.processed = true;
// Add event listeners for tooltips
const termEls = element.querySelectorAll('.stella-glossary-term');
termEls.forEach((el) => {
this.renderer.listen(el, 'mouseenter', (e: MouseEvent) => this.showTooltip(e, el as HTMLElement));
this.renderer.listen(el, 'mouseleave', () => this.removeTooltip());
});
}
}
private showTooltip(event: MouseEvent, target: HTMLElement): void {
this.removeTooltip();
const def = target.getAttribute('data-def') ?? '';
const term = target.getAttribute('data-term') ?? '';
this.tooltipEl = this.renderer.createElement('div');
this.tooltipEl!.className = 'stella-glossary-tooltip';
this.tooltipEl!.innerHTML = `<strong>${escapeHtml(term)}</strong><br>${escapeHtml(def)}`;
document.body.appendChild(this.tooltipEl!);
// Position above the term
const rect = target.getBoundingClientRect();
const tipRect = this.tooltipEl!.getBoundingClientRect();
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - tipRect.width / 2, window.innerWidth - tipRect.width - 8));
const top = rect.top - tipRect.height - 8;
this.tooltipEl!.style.left = `${left}px`;
this.tooltipEl!.style.top = `${top > 8 ? top : rect.bottom + 8}px`;
}
private removeTooltip(): void {
if (this.tooltipEl) {
this.tooltipEl.remove();
this.tooltipEl = null;
}
}
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}