- Help.tsx: role tabs, TOC scroll-spy, search, lightbox, react-markdown renderer
- 7 markdown guides (global, client, linguist, reviewer, production, PM, admin)
with explicit click/drag/keyboard annotations throughout
- Sidebar: Help button added at bottom of nav (all roles)
- App.tsx: /help route, no RoleGate
- frontend/public/help-screenshots/{role}/: directories ready for screenshots
- tools/capture-help-screenshots.ts: Playwright screenshot script
- Clicks "Local login" toggle before filling credentials
- Uses test-admin local account (not SSO)
- backend/scripts/seed_test_users.py: idempotent MongoDB seed script
creates 6 local-auth users (admin + 5 roles) for capture + local dev
- .env.screenshots.example: template with test-admin credentials
- Removes docs/video_accessibility_user_guide_v3.md (superseded by in-app guides)
- Deps: react-markdown, remark-gfm, rehype-raw added to frontend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
/**
|
|
* Screenshot capture script for the Help guides.
|
|
*
|
|
* Usage (run from frontend/ directory — needs frontend/node_modules/@playwright/test):
|
|
* cd frontend && npx tsx ../tools/capture-help-screenshots.ts
|
|
*
|
|
* Requires a .env.screenshots file at the repo root (see .env.screenshots.example).
|
|
* Before running, seed test users on optical-dev:
|
|
* docker compose exec backend python scripts/seed_test_users.py
|
|
*/
|
|
|
|
import { chromium, type Page, type BrowserContext } from '@playwright/test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as dotenv from 'dotenv';
|
|
|
|
// Load credentials from .env.screenshots
|
|
dotenv.config({ path: path.join(__dirname, '../.env.screenshots') });
|
|
|
|
const BASE_URL = process.env.BASE_URL || 'https://optical-dev.oliver.solutions/video-accessibility';
|
|
const OUT_DIR = path.join(__dirname, '../frontend/public/help-screenshots');
|
|
|
|
// Ensure output directories exist
|
|
const ROLES = ['global', 'client', 'linguist', 'reviewer', 'production', 'project-manager', 'admin'];
|
|
for (const role of ROLES) {
|
|
fs.mkdirSync(path.join(OUT_DIR, role), { recursive: true });
|
|
}
|
|
|
|
interface AnnotationRect {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
label?: string;
|
|
}
|
|
|
|
async function annotateAndScreenshot(
|
|
page: Page,
|
|
outputPath: string,
|
|
annotations: AnnotationRect[] = []
|
|
) {
|
|
if (annotations.length > 0) {
|
|
// Inject red rectangles as overlay divs
|
|
await page.evaluate((rects) => {
|
|
for (const rect of rects) {
|
|
const div = document.createElement('div');
|
|
div.style.cssText = `
|
|
position: fixed;
|
|
left: ${rect.x}px;
|
|
top: ${rect.y}px;
|
|
width: ${rect.width}px;
|
|
height: ${rect.height}px;
|
|
border: 3px solid #EF4444;
|
|
border-radius: 4px;
|
|
z-index: 99999;
|
|
pointer-events: none;
|
|
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3);
|
|
`;
|
|
if (rect.label) {
|
|
const label = document.createElement('span');
|
|
label.textContent = rect.label;
|
|
label.style.cssText = `
|
|
position: absolute;
|
|
top: -22px;
|
|
left: 0;
|
|
background: #EF4444;
|
|
color: white;
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
white-space: nowrap;
|
|
font-family: system-ui, sans-serif;
|
|
`;
|
|
div.appendChild(label);
|
|
}
|
|
document.body.appendChild(div);
|
|
}
|
|
}, annotations);
|
|
}
|
|
|
|
await page.screenshot({ path: outputPath, fullPage: false });
|
|
|
|
// Clean up overlays
|
|
if (annotations.length > 0) {
|
|
await page.evaluate(() => {
|
|
document.querySelectorAll('[data-annotation-overlay]').forEach(el => el.remove());
|
|
});
|
|
}
|
|
|
|
console.log(` ✓ ${path.relative(OUT_DIR, outputPath)}`);
|
|
}
|
|
|
|
async function getBBox(page: Page, selector: string): Promise<AnnotationRect | null> {
|
|
const el = page.locator(selector).first();
|
|
try {
|
|
const bb = await el.boundingBox({ timeout: 3000 });
|
|
if (!bb) return null;
|
|
return { x: bb.x, y: bb.y, width: bb.width, height: bb.height };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function login(page: Page, email: string, password: string) {
|
|
await page.goto(`${BASE_URL}/login`);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// The login page defaults to Microsoft SSO view.
|
|
// Click "Local login" to reveal the email/password form.
|
|
try {
|
|
const toggle = page.getByRole('button', { name: /local login/i });
|
|
await toggle.click({ timeout: 3000 });
|
|
await page.waitForSelector('input[type="email"]', { timeout: 3000 });
|
|
} catch {
|
|
// Toggle absent (form already visible or different build) — proceed
|
|
}
|
|
|
|
await page.fill('input[type="email"]', email);
|
|
await page.fill('input[type="password"]', password);
|
|
await page.click('button[type="submit"]');
|
|
await page.waitForURL(url => !url.toString().includes('/login'), { timeout: 10000 });
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
|
|
async function captureGlobal(context: BrowserContext) {
|
|
console.log('\n📸 Global screenshots...');
|
|
const page = await context.newPage();
|
|
|
|
// 01 — Login screen
|
|
await page.goto(`${BASE_URL}/login`);
|
|
await page.waitForLoadState('networkidle');
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/01-login.png'));
|
|
|
|
// 02 — Email field highlighted
|
|
const emailBB = await getBBox(page, 'input[type="email"]');
|
|
if (emailBB) {
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/02-login-email.png'), [
|
|
{ ...emailBB, label: 'Left-click here to type email' }
|
|
]);
|
|
}
|
|
|
|
// Login as test-admin to capture authenticated global screenshots
|
|
await login(page, process.env.TEST_ADMIN_EMAIL!, process.env.TEST_ADMIN_PASSWORD!);
|
|
|
|
// 03 — Interface overview
|
|
await page.goto(`${BASE_URL}/`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/04-interface-overview.png'));
|
|
|
|
// 04 — Sidebar
|
|
const sidebarBB = await getBBox(page, 'nav');
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/05-sidebar.png'));
|
|
|
|
// 05 — Navbar
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/06-navbar.png'));
|
|
|
|
// 06 — Notifications (click bell)
|
|
const bell = page.locator('button[aria-label*="notification"], button:has-text("🔔")').first();
|
|
try {
|
|
await bell.click({ timeout: 3000 });
|
|
await page.waitForTimeout(300);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'global/07-notifications.png'));
|
|
await page.keyboard.press('Escape');
|
|
} catch {
|
|
// Bell might not exist or be differently structured
|
|
}
|
|
|
|
await page.close();
|
|
}
|
|
|
|
async function captureClient(context: BrowserContext) {
|
|
console.log('\n📸 Client screenshots...');
|
|
const page = await context.newPage();
|
|
await login(page, process.env.TEST_CLIENT_EMAIL!, process.env.TEST_CLIENT_PASSWORD!);
|
|
|
|
// Dashboard
|
|
await page.goto(`${BASE_URL}/`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/01-dashboard.png'));
|
|
|
|
// New Job page
|
|
await page.goto(`${BASE_URL}/jobs/new`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/02-new-job.png'));
|
|
|
|
// Drop zone
|
|
const dropzoneBB = await getBBox(page, '[data-testid="upload-dropzone"], .dropzone, input[type="file"]');
|
|
if (dropzoneBB) {
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/03-upload-dropzone.png'), [
|
|
{ ...dropzoneBB, label: 'Drag file here or left-click to browse' }
|
|
]);
|
|
}
|
|
|
|
// Jobs list
|
|
await page.goto(`${BASE_URL}/jobs`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/12-jobs-list.png'));
|
|
|
|
// Briefs list
|
|
await page.goto(`${BASE_URL}/briefs`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'client/16-new-brief.png'));
|
|
|
|
await page.close();
|
|
}
|
|
|
|
async function captureLinguist(context: BrowserContext) {
|
|
console.log('\n📸 Linguist screenshots...');
|
|
const page = await context.newPage();
|
|
await login(page, process.env.TEST_LINGUIST_EMAIL!, process.env.TEST_LINGUIST_PASSWORD!);
|
|
|
|
// Dashboard
|
|
await page.goto(`${BASE_URL}/`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'linguist/01-dashboard.png'));
|
|
|
|
// QC Queue
|
|
await page.goto(`${BASE_URL}/qc/queue`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'linguist/02-qc-queue.png'));
|
|
|
|
await page.close();
|
|
}
|
|
|
|
async function captureReviewer(context: BrowserContext) {
|
|
console.log('\n📸 Reviewer screenshots...');
|
|
const page = await context.newPage();
|
|
await login(page, process.env.TEST_REVIEWER_EMAIL!, process.env.TEST_REVIEWER_PASSWORD!);
|
|
|
|
await page.goto(`${BASE_URL}/`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/01-dashboard.png'));
|
|
|
|
await page.goto(`${BASE_URL}/qc/queue`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/02-qc-queue.png'));
|
|
|
|
await page.goto(`${BASE_URL}/qc/reviewer-queue`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/03-reviewer-queue.png'));
|
|
|
|
await page.goto(`${BASE_URL}/admin/final`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'reviewer/08-final-list.png'));
|
|
|
|
await page.close();
|
|
}
|
|
|
|
async function captureProduction(context: BrowserContext) {
|
|
console.log('\n📸 Production screenshots...');
|
|
const page = await context.newPage();
|
|
await login(page, process.env.TEST_PRODUCTION_EMAIL!, process.env.TEST_PRODUCTION_PASSWORD!);
|
|
|
|
await page.goto(`${BASE_URL}/`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/01-dashboard.png'));
|
|
|
|
await page.goto(`${BASE_URL}/jobs`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/02-jobs-list-bulk.png'));
|
|
|
|
await page.goto(`${BASE_URL}/admin/failures`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/05-failures.png'));
|
|
|
|
await page.goto(`${BASE_URL}/admin/audit-log`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'production/06-audit-log.png'));
|
|
|
|
await page.close();
|
|
}
|
|
|
|
async function captureProjectManager(context: BrowserContext) {
|
|
console.log('\n📸 Project Manager screenshots...');
|
|
const page = await context.newPage();
|
|
await login(page, process.env.TEST_PM_EMAIL!, process.env.TEST_PM_PASSWORD!);
|
|
|
|
await page.goto(`${BASE_URL}/`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'project-manager/01-dashboard.png'));
|
|
|
|
await page.goto(`${BASE_URL}/admin/final`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'project-manager/02-final-list.png'));
|
|
|
|
await page.goto(`${BASE_URL}/admin/clients`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'project-manager/05-clients.png'));
|
|
|
|
await page.close();
|
|
}
|
|
|
|
async function captureAdmin(context: BrowserContext) {
|
|
console.log('\n📸 Admin screenshots...');
|
|
const page = await context.newPage();
|
|
await login(page, process.env.TEST_ADMIN_EMAIL!, process.env.TEST_ADMIN_PASSWORD!);
|
|
|
|
await page.goto(`${BASE_URL}/`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'admin/01-dashboard.png'));
|
|
|
|
// User Management
|
|
await page.goto(`${BASE_URL}/admin/users`);
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForTimeout(500);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'admin/02-user-list.png'));
|
|
|
|
// Create User modal
|
|
const createBtn = page.getByRole('button', { name: /create user/i });
|
|
try {
|
|
await createBtn.click({ timeout: 3000 });
|
|
await page.waitForTimeout(400);
|
|
await annotateAndScreenshot(page, path.join(OUT_DIR, 'admin/03-create-user-modal.png'));
|
|
await page.keyboard.press('Escape');
|
|
} catch { /* modal may not have opened */ }
|
|
|
|
await page.close();
|
|
}
|
|
|
|
async function main() {
|
|
console.log('🚀 Capturing help screenshots...');
|
|
console.log(` Target: ${BASE_URL}`);
|
|
console.log(` Output: ${OUT_DIR}\n`);
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1440, height: 900 },
|
|
deviceScaleFactor: 1,
|
|
});
|
|
|
|
try {
|
|
await captureGlobal(context);
|
|
await captureClient(context);
|
|
await captureLinguist(context);
|
|
await captureReviewer(context);
|
|
await captureProduction(context);
|
|
await captureProjectManager(context);
|
|
await captureAdmin(context);
|
|
} finally {
|
|
await context.close();
|
|
await browser.close();
|
|
}
|
|
|
|
console.log('\n✅ All screenshots captured!');
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('Screenshot capture failed:', err);
|
|
process.exit(1);
|
|
});
|