- CV header with highlights and keeping-up summary replaces the intro and Now strip - "Projects I'm proudest of": up to six featured panels; screenshots muted until hover - "Also made" list; searching it also lists matching courses - Record section: recognition & volunteering, courses by topic - /work/learning/: searchable course list (plain script), in the sitemap - Top bar: Projects, Record, Writing, CV; no Bluesky link in the work footer - Highlights: only the placement takes the accent (owner, after the preview) Owner looked at the rendered preview on 2026-11-12 before this commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
9.5 KiB
TypeScript
212 lines
9.5 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
|
import { blockThirdParty, openWork, setPref, P } from './helpers.ts';
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await blockThirdParty(page);
|
|
});
|
|
|
|
const row = (page: Page, id: string) => page.locator(`.idx a.row[data-open-sheet="${id}"]`);
|
|
const sheet = (page: Page) => page.getByRole('dialog');
|
|
|
|
test('opening a project pushes its URL; Esc restores /work/', async ({ page }) => {
|
|
await openWork(page);
|
|
await row(page, P.byHand2).click();
|
|
await expect(page).toHaveURL(new RegExp(`/work/${P.byHand2}/$`));
|
|
await expect(sheet(page)).toBeVisible();
|
|
await page.keyboard.press('Escape');
|
|
await expect(sheet(page)).toBeHidden();
|
|
await expect(page).toHaveURL(/\/work\/$/);
|
|
});
|
|
|
|
test('sheet history: Back closes, Forward reopens with focus inside', async ({ page }) => {
|
|
await openWork(page);
|
|
await row(page, P.byHand2).click();
|
|
await expect(sheet(page)).toBeVisible();
|
|
await page.goBack();
|
|
await expect(sheet(page)).toBeHidden();
|
|
await expect(page).toHaveURL(/\/work\/$/);
|
|
await page.goForward();
|
|
await expect(page).toHaveURL(new RegExp(`/work/${P.byHand2}/$`));
|
|
await expect(sheet(page)).toBeVisible();
|
|
await expect.poll(() => page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]'))).toBe(true);
|
|
});
|
|
|
|
test('focus moves into the sheet, is trapped, and returns to the row', async ({ page, isMobile }) => {
|
|
test.skip(!!isMobile, 'keyboard focus');
|
|
await openWork(page);
|
|
const opener = row(page, P.byHand2);
|
|
await opener.focus();
|
|
await page.keyboard.press('Enter');
|
|
await expect(sheet(page)).toBeVisible();
|
|
await expect.poll(() => page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]'))).toBe(true);
|
|
for (let i = 0; i < 25; i++) {
|
|
await page.keyboard.press('Tab');
|
|
expect(await page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]'))).toBe(true);
|
|
}
|
|
for (let i = 0; i < 25; i++) {
|
|
await page.keyboard.press('Shift+Tab');
|
|
expect(await page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]'))).toBe(true);
|
|
}
|
|
await page.keyboard.press('Escape');
|
|
await expect(sheet(page)).toBeHidden();
|
|
await expect(opener).toBeFocused();
|
|
});
|
|
|
|
async function visibleRowIds(page: Page): Promise<string[]> {
|
|
return page.$$eval('.idx a.row', (as) => as.filter((a) => a.getClientRects().length > 0).map((a) => (a as HTMLElement).dataset.openSheet!));
|
|
}
|
|
|
|
test('arrow keys step through the visible rows', async ({ page, isMobile }) => {
|
|
test.skip(!!isMobile, 'keyboard');
|
|
await openWork(page);
|
|
const ids = await visibleRowIds(page);
|
|
expect(ids.length).toBeGreaterThan(2);
|
|
await row(page, ids[0]!).click();
|
|
await expect(page).toHaveURL(new RegExp(`/work/${ids[0]}/$`));
|
|
await page.keyboard.press('ArrowRight');
|
|
await expect(page).toHaveURL(new RegExp(`/work/${ids[1]}/$`));
|
|
await page.keyboard.press('ArrowLeft');
|
|
await expect(page).toHaveURL(new RegExp(`/work/${ids[0]}/$`));
|
|
// One history entry only: a single Back closes the sheet.
|
|
await page.goBack();
|
|
await expect(page).toHaveURL(/\/work\/$/);
|
|
await expect(sheet(page)).toBeHidden();
|
|
});
|
|
|
|
test('arrows step through featured panels from a featured link', async ({ page, isMobile }) => {
|
|
test.skip(!!isMobile, 'keyboard');
|
|
await openWork(page);
|
|
await page.locator(`article.feat[data-id="${P.byHand}"] h3 a[data-open-sheet]`).click();
|
|
await expect(page).toHaveURL(new RegExp(`/work/${P.byHand}/$`));
|
|
await page.keyboard.press('ArrowRight');
|
|
await expect(page).toHaveURL(new RegExp(`/work/${P.someAi}/$`));
|
|
});
|
|
|
|
test.describe('under an active filter', () => {
|
|
// List order (newest first): garden-bot (ai-made), placeholder-notes (unknown), course-planner (by-hand).
|
|
test('by hand only: arrows never reach hidden rows', async ({ page, isMobile }) => {
|
|
test.skip(!!isMobile, 'keyboard');
|
|
await setPref(page, 'by-hand-only');
|
|
await openWork(page);
|
|
expect(await visibleRowIds(page)).toEqual([P.byHand2]);
|
|
await row(page, P.byHand2).click();
|
|
await page.keyboard.press('ArrowLeft');
|
|
await page.keyboard.press('ArrowRight');
|
|
await expect(page).toHaveURL(new RegExp(`/work/${P.byHand2}/$`));
|
|
});
|
|
|
|
test('not fully by AI: arrows skip the ai-made row', async ({ page, isMobile }) => {
|
|
test.skip(!!isMobile, 'keyboard');
|
|
await setPref(page, 'not-fully-ai');
|
|
await openWork(page);
|
|
expect(await visibleRowIds(page)).toEqual([P.unknown, P.byHand2]);
|
|
await row(page, P.unknown).click();
|
|
await page.keyboard.press('ArrowLeft'); // the ai-made row above is hidden
|
|
await expect(page).toHaveURL(new RegExp(`/work/${P.unknown}/$`));
|
|
await page.keyboard.press('ArrowRight');
|
|
await expect(page).toHaveURL(new RegExp(`/work/${P.byHand2}/$`));
|
|
});
|
|
});
|
|
|
|
test.describe('under by-hand-only', () => {
|
|
test.beforeEach(async ({ page }) => setPref(page, 'by-hand-only'));
|
|
|
|
test('a hidden featured panel shows a note; show anyway reveals it', async ({ page }) => {
|
|
await openWork(page);
|
|
const panel = page.locator(`article.feat[data-id="${P.someAi}"]`);
|
|
const note = panel.locator('xpath=following-sibling::*[1]');
|
|
await expect(panel).toBeHidden();
|
|
await expect(note).toBeVisible();
|
|
await expect(note).toContainText('Fixture Dashboard');
|
|
await note.getByRole('button', { name: 'show anyway' }).click();
|
|
await expect(panel).toBeVisible();
|
|
await expect(note).toBeHidden();
|
|
});
|
|
});
|
|
|
|
test('"/" focuses search', async ({ page, isMobile }) => {
|
|
test.skip(!!isMobile, 'keyboard');
|
|
await openWork(page);
|
|
await page.keyboard.press('/');
|
|
await expect(page.getByRole('searchbox', { name: /Search projects/ })).toBeFocused();
|
|
});
|
|
|
|
test('"/" focuses the search right after load, without scrolling to the list', async ({ page }) => {
|
|
await page.goto('/work/');
|
|
await page.waitForLoadState('load');
|
|
await page.waitForFunction(() => 'requestIdleCallback' in window);
|
|
const search = page.getByRole('searchbox', { name: /Search projects/ });
|
|
// The list sits below the fold; it must hydrate on idle, not when scrolled into view.
|
|
await expect.poll(async () => {
|
|
await page.keyboard.press('/');
|
|
return search.evaluate((el) => el === document.activeElement);
|
|
}, { timeout: 5_000 }).toBe(true);
|
|
});
|
|
|
|
test('searching the list shows matching courses with the count and a link to the learning page', async ({ page }) => {
|
|
await openWork(page);
|
|
const search = page.getByRole('searchbox', { name: /Search projects/ });
|
|
const strip = page.locator('.idx .cstrip');
|
|
await expect(strip).toHaveCount(0);
|
|
await search.fill('java');
|
|
await expect(page.locator('.idx .sh .n')).toHaveText('1 project · 6 courses match');
|
|
await expect(strip.locator('.crow')).toHaveCount(5);
|
|
// Newest first; the seal marks a certificate, the tag marks a lab.
|
|
await expect(strip.locator('.crow').first()).toContainText('Profiling the JVM');
|
|
await expect(strip.locator('.crow').first()).toContainText('lab');
|
|
await expect(strip.locator('.crow').first().locator('.seal')).toHaveCount(1);
|
|
await expect(strip.getByRole('link', { name: '+ 1 more on the learning page →' })).toHaveAttribute('href', '/work/learning/?q=java');
|
|
// The AI preference never hides courses.
|
|
await search.fill('');
|
|
await expect(strip).toHaveCount(0);
|
|
});
|
|
|
|
test('screenshots are muted until hovered; drawings are not', async ({ page, isMobile }) => {
|
|
test.skip(!!isMobile, 'hover');
|
|
await openWork(page);
|
|
const img = page.locator('article.feat[data-id="fixture-photo-board"] .s-image img');
|
|
await img.scrollIntoViewIfNeeded();
|
|
await expect.poll(() => img.evaluate((el) => getComputedStyle(el).filter)).toContain('saturate(0.5)');
|
|
await page.locator('article.feat[data-id="fixture-photo-board"] h3').hover();
|
|
await expect.poll(() => img.evaluate((el) => getComputedStyle(el).filter)).toBe('none');
|
|
const drawing = page.locator('article.feat[data-id="example-compute-portal"] .stage');
|
|
expect(await drawing.evaluate((el) => getComputedStyle(el).filter)).toBe('none');
|
|
});
|
|
|
|
test.describe('/work/learning/', () => {
|
|
const rows = (page: Page) => page.locator('tr[data-course]:visible');
|
|
|
|
test('lists every course; search and the topic buttons hide rows', async ({ page }) => {
|
|
await page.goto('/work/learning/');
|
|
await expect(rows(page)).toHaveCount(11);
|
|
const search = page.getByRole('searchbox', { name: 'Search courses' });
|
|
await search.fill('docker');
|
|
await expect(rows(page)).toHaveCount(1);
|
|
await expect(rows(page).first()).toContainText('Containers from Scratch');
|
|
await search.fill('');
|
|
await page.getByRole('button', { name: /^Java/ }).click();
|
|
await expect(page.getByRole('button', { name: /^Java/ })).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(rows(page)).toHaveCount(6);
|
|
await search.fill('docker');
|
|
await expect(rows(page)).toHaveCount(0);
|
|
await expect(page.getByText('No course matches')).toBeVisible();
|
|
await page.getByRole('button', { name: /^All/ }).click();
|
|
await expect(rows(page)).toHaveCount(1);
|
|
await expect(page.getByText('No course matches')).toBeHidden();
|
|
});
|
|
|
|
test('?q= pre-filters the table', async ({ page }) => {
|
|
await page.goto('/work/learning/?q=java');
|
|
await expect(page.getByRole('searchbox', { name: 'Search courses' })).toHaveValue('java');
|
|
await expect(rows(page)).toHaveCount(6);
|
|
});
|
|
|
|
test('the search strip on /work/ leads here with the query', async ({ page }) => {
|
|
await openWork(page);
|
|
await page.getByRole('searchbox', { name: /Search projects/ }).fill('java');
|
|
await page.getByRole('link', { name: /more on the learning page/ }).click();
|
|
await expect(page).toHaveURL(/\/work\/learning\/\?q=java$/);
|
|
await expect(rows(page)).toHaveCount(6);
|
|
});
|
|
});
|