test(e2e): cover gate, spaces, preference, deep links and no-js
Also gates the start page's remaining :hover effects behind (hover: hover), which the load-time animation test showed leaking onto touch emulation. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
da88338cf5
commit
7feaf6bc3b
8 files changed
+502
-5
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { blockThirdParty, setPref, P, I } from './helpers.ts';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await blockThirdParty(page);
|
||||
});
|
||||
|
||||
for (const id of [P.byHand, P.someAi, P.aiMade, P.unknown, P.byHand2]) {
|
||||
test(`/work/${id}/ loads directly`, async ({ page }) => {
|
||||
const res = await page.goto(`/work/${id}/`);
|
||||
expect(res!.status()).toBe(200);
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const id of [I.song, I.byHand, I.unknown, I.someAi]) {
|
||||
test(`/off-the-clock/${id}/ loads directly`, async ({ page }) => {
|
||||
const res = await page.goto(`/off-the-clock/${id}/`);
|
||||
expect(res!.status()).toBe(200);
|
||||
await expect(page.locator(`article.card[data-id="${id}"]`)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('draft projects have no page', async ({ page }) => {
|
||||
const res = await page.goto(`/work/${P.draft}/`);
|
||||
expect(res!.status()).toBe(404);
|
||||
});
|
||||
|
||||
for (const path of ['/work/nope/', '/off-the-clock/x/', '/nothing-here/']) {
|
||||
test(`${path} is a real 404`, async ({ page }) => {
|
||||
const res = await page.goto(path);
|
||||
expect(res!.status()).toBe(404);
|
||||
await expect(page.getByRole('heading', { name: "That page isn't here." })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Work' })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('a filtered-out project opened by URL still shows, with a note', async ({ page }) => {
|
||||
await setPref(page, 'by-hand-only');
|
||||
await page.goto(`/work/${P.aiMade}/`);
|
||||
await expect(page.locator('html')).toHaveAttribute('data-pref', 'by-hand-only');
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
await expect(page.locator('.pref-note')).toBeVisible();
|
||||
await expect(page.locator('.pref-note')).toContainText('shown because you opened it directly');
|
||||
});
|
||||
|
||||
test('a filtered-out item opened by URL still shows, with a note', async ({ page }) => {
|
||||
await setPref(page, 'by-hand-only');
|
||||
await page.goto(`/off-the-clock/${I.unknown}/`);
|
||||
await expect(page.locator(`article.card[data-id="${I.unknown}"]`)).toBeVisible();
|
||||
await expect(page.locator('.pref-note')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a project that is not hidden shows no note', async ({ page }) => {
|
||||
await setPref(page, 'by-hand-only');
|
||||
await page.goto(`/work/${P.byHand}/`);
|
||||
await expect(page.locator('.pref-note')).toBeHidden();
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { blockThirdParty, hydrated } from './helpers.ts';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await blockThirdParty(page);
|
||||
});
|
||||
|
||||
test('clicking each side navigates', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('a[data-enter="work"]').click();
|
||||
await expect(page).toHaveURL(/\/work\/$/);
|
||||
await page.goto('/');
|
||||
await page.locator('a[data-enter="personal"]').click();
|
||||
await expect(page).toHaveURL(/\/off-the-clock\/$/);
|
||||
});
|
||||
|
||||
test('the everything pill and G lead to /everything/', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#garden-link')).toHaveAttribute('href', '/everything/');
|
||||
await page.locator('#garden-link').click();
|
||||
await expect(page).toHaveURL(/\/everything\/$/);
|
||||
});
|
||||
|
||||
test('G key opens /everything/', async ({ page, isMobile }) => {
|
||||
test.skip(!!isMobile, 'keyboard shortcut');
|
||||
await page.goto('/');
|
||||
await hydrated(page, 'Gate');
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('g');
|
||||
await expect(page).toHaveURL(/\/everything\/$/, { timeout: 500 });
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('arrows lean and Enter enters', async ({ page, isMobile }) => {
|
||||
test.skip(!!isMobile, 'keyboard shortcut');
|
||||
await page.goto('/');
|
||||
await hydrated(page, 'Gate');
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.locator('#gate')).toHaveClass(/lean-art/, { timeout: 500 });
|
||||
}).toPass();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(page.locator('#gate')).toHaveClass(/lean-tech/);
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(/\/work\/$/);
|
||||
});
|
||||
|
||||
test('nothing animates on load', async ({ page }) => {
|
||||
// A pointer resting over a side would legitimately lean it; take the page out of hit testing
|
||||
// from the first moment so only the page's own load-time motion is measured.
|
||||
await page.addInitScript(() => {
|
||||
new MutationObserver((_m, obs) => {
|
||||
if (!document.documentElement) return;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '*{pointer-events:none!important}';
|
||||
document.documentElement.append(st);
|
||||
obs.disconnect();
|
||||
}).observe(document, { childList: true });
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('load');
|
||||
const running = await page.evaluate(() =>
|
||||
document.getAnimations().map((a) => `${(a.effect as KeyframeEffect | null)?.target?.tagName ?? '?'} ${(a as CSSAnimation).animationName ?? (a as CSSTransition).transitionProperty}`),
|
||||
);
|
||||
expect(running).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export const PREF_KEY = 'lw:projects-made';
|
||||
|
||||
// Fixture ids (see fixtures/content): levels by-hand / some-ai / ai-made / unknown.
|
||||
export const P = {
|
||||
byHand: 'example-compute-portal', // featured 1 (not in the list)
|
||||
byHand2: 'demo-course-planner',
|
||||
someAi: 'fixture-dashboard', // featured 2
|
||||
aiMade: 'sample-garden-bot',
|
||||
unknown: 'placeholder-notes',
|
||||
draft: 'draft-unfinished-thing',
|
||||
};
|
||||
export const I = {
|
||||
song: 'example-song', // youtube link, no ai (a "share")
|
||||
byHand: 'own-drawing',
|
||||
unknown: 'fixture-life',
|
||||
someAi: 'synthetic-life-note',
|
||||
};
|
||||
|
||||
// Keeps tests offline: only the local server is reachable. Returns the blocked URLs.
|
||||
export async function blockThirdParty(page: Page): Promise<string[]> {
|
||||
const blocked: string[] = [];
|
||||
await page.route(/^https?:\/\/(?!localhost:4378)/, (r) => {
|
||||
const url = r.request().url();
|
||||
// The analytics script is the one allowed third-party request; it is aborted but not counted.
|
||||
if (!url.startsWith('https://analytics.lucaswinther.info/')) blocked.push(url);
|
||||
return r.abort();
|
||||
});
|
||||
return blocked;
|
||||
}
|
||||
|
||||
export async function setPref(page: Page, value: string) {
|
||||
await page.addInitScript(([k, v]) => localStorage.setItem(k!, v!), [PREF_KEY, value]);
|
||||
}
|
||||
|
||||
// Waits until the named islands have hydrated (Astro drops the `ssr` attribute), scrolling
|
||||
// client:visible ones into view first.
|
||||
export async function hydrated(page: Page, ...names: string[]) {
|
||||
for (const n of names) {
|
||||
await page.evaluate((name) => {
|
||||
const el = document.querySelector(`astro-island[component-url*="/${name}."]`);
|
||||
(el?.firstElementChild ?? el?.parentElement)?.scrollIntoView({ block: 'center' });
|
||||
}, n);
|
||||
await page.waitForFunction((name) => {
|
||||
const el = document.querySelector(`astro-island[component-url*="/${name}."]`);
|
||||
return !!el && !el.hasAttribute('ssr');
|
||||
}, n);
|
||||
}
|
||||
}
|
||||
|
||||
// Open /work/ with the sheet, the list and the preference switch live.
|
||||
export async function openWork(page: Page) {
|
||||
await page.goto('/work/');
|
||||
await hydrated(page, 'PreferenceSwitch', 'ProjectSheet', 'ProjectList');
|
||||
await page.evaluate(() => scrollTo(0, 0));
|
||||
}
|
||||
|
||||
// Open /off-the-clock/ with the chips live.
|
||||
export async function openPersonal(page: Page) {
|
||||
await page.goto('/off-the-clock/');
|
||||
await hydrated(page, 'Chips');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { P, I } from './helpers.ts';
|
||||
|
||||
test.use({ javaScriptEnabled: false });
|
||||
|
||||
test('start page shows both gates and the everything link', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('a[data-enter="work"]')).toBeVisible();
|
||||
await expect(page.locator('a[data-enter="personal"]')).toBeVisible();
|
||||
await expect(page.locator('#garden-link')).toBeVisible();
|
||||
});
|
||||
|
||||
test('work page lists every project and hides none', async ({ page }) => {
|
||||
await page.goto('/work/');
|
||||
for (const id of [P.byHand2, P.aiMade, P.unknown]) {
|
||||
await expect(page.locator(`.idx a.row[data-open-sheet="${id}"]`)).toBeVisible();
|
||||
}
|
||||
await expect(page.locator('.filtered-note:visible')).toHaveCount(0);
|
||||
await expect(page.locator('.idx a.row')).toHaveCount(3);
|
||||
await expect(page.locator('html[data-pref]')).toHaveCount(0);
|
||||
await expect(page.locator('article.feat:visible')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('personal wall shows every card, including AI-marked and unknown', async ({ page }) => {
|
||||
await page.goto('/off-the-clock/');
|
||||
const total = await page.locator('[data-wall-card]').count();
|
||||
expect(total).toBeGreaterThan(8);
|
||||
// Older years sit in collapsed <details>; nothing may be display:none (that is what the filter does).
|
||||
const none = await page.$$eval('[data-wall-card]', (cs) => cs.filter((c) => getComputedStyle(c).display === 'none').length);
|
||||
expect(none).toBe(0);
|
||||
for (const id of [I.song, I.byHand, I.unknown, I.someAi]) await expect(page.locator(`[data-wall-card][data-id="${id}"]`)).toHaveCount(1);
|
||||
await expect(page.locator(`[data-wall-card][data-id="${I.someAi}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('project and item pages are complete', async ({ page }) => {
|
||||
await page.goto(`/work/${P.aiMade}/`);
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
await expect(page.locator('.pref-note')).toBeHidden();
|
||||
await page.goto(`/off-the-clock/${I.song}/`);
|
||||
await expect(page.locator(`article.card[data-id="${I.song}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('everything page renders', async ({ page }) => {
|
||||
const res = await page.goto('/everything/');
|
||||
expect(res!.status()).toBe(200);
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { blockThirdParty, hydrated, I } from './helpers.ts';
|
||||
|
||||
async function live(page: import('@playwright/test').Page) {
|
||||
await page.goto('/off-the-clock/');
|
||||
await hydrated(page, 'Chips');
|
||||
}
|
||||
|
||||
test('chips filter the wall by interest', async ({ page }) => {
|
||||
await blockThirdParty(page);
|
||||
await live(page);
|
||||
const cards = page.locator('[data-wall-card]');
|
||||
const all = await cards.count();
|
||||
await page.getByRole('button', { name: /^music/ }).click();
|
||||
const shown = await page.$$eval('[data-wall-card]', (cs) => cs.filter((c) => c.getClientRects().length > 0).map((c) => (c as HTMLElement).dataset.interest));
|
||||
expect(shown.length).toBeGreaterThan(0);
|
||||
expect(shown.length).toBeLessThan(all);
|
||||
expect(new Set(shown)).toEqual(new Set(['music']));
|
||||
await page.getByRole('button', { name: /^everything/ }).click();
|
||||
const back = await page.$$eval('[data-wall-card]', (cs) => cs.filter((c) => c.getClientRects().length > 0).length);
|
||||
expect(back).toBeGreaterThanOrEqual(shown.length + 1);
|
||||
});
|
||||
|
||||
test('opening a card updates the URL; Esc closes it', async ({ page }) => {
|
||||
await blockThirdParty(page);
|
||||
await live(page);
|
||||
const card = page.locator(`[data-wall-card][data-id="${I.song}"]`);
|
||||
await card.click({ position: { x: 8, y: 8 } });
|
||||
await expect(page).toHaveURL(new RegExp(`/off-the-clock/${I.song}/$`));
|
||||
await expect(card).toHaveClass(/open/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/\/off-the-clock\/$/);
|
||||
await expect(card).not.toHaveClass(/open/);
|
||||
});
|
||||
|
||||
test('the song embed iframe appears only after clicking play', async ({ page }) => {
|
||||
const blocked = await blockThirdParty(page);
|
||||
await live(page);
|
||||
await page.locator(`[data-wall-card][data-id="${I.song}"]`).click({ position: { x: 8, y: 8 } });
|
||||
const card = page.locator(`[data-wall-card][data-id="${I.song}"]`);
|
||||
await expect(card).toHaveClass(/open/);
|
||||
expect(await card.locator('iframe').count()).toBe(0);
|
||||
expect(blocked).toEqual([]);
|
||||
await card.locator('a.embed-go').click();
|
||||
const frame = card.locator('iframe');
|
||||
await expect(frame).toHaveCount(1);
|
||||
await expect(frame).toHaveAttribute('src', /youtube/);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { blockThirdParty, hydrated, openWork, setPref, P, I, PREF_KEY } from './helpers.ts';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await blockThirdParty(page);
|
||||
});
|
||||
|
||||
const rowOf = (page: Page, id: string) => page.locator(`.idx a.row[data-open-sheet="${id}"]`);
|
||||
|
||||
async function chooseByHand(page: Page) {
|
||||
await page.getByRole('button', { name: 'by hand only' }).first().click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-pref', 'by-hand-only');
|
||||
}
|
||||
|
||||
test('by hand only hides ai-made, some-ai and unknown; keeps by-hand', async ({ page }) => {
|
||||
await openWork(page);
|
||||
for (const id of [P.byHand2, P.aiMade, P.unknown]) await expect(rowOf(page, id)).toBeVisible();
|
||||
await chooseByHand(page);
|
||||
await expect(rowOf(page, P.byHand2)).toBeVisible();
|
||||
for (const id of [P.aiMade, P.unknown]) await expect(rowOf(page, id)).toBeHidden();
|
||||
await expect(page.locator('.hiddenby')).toContainText('hidden by your AI filter');
|
||||
});
|
||||
|
||||
test('featured hidden panels show "show anyway"; archive link appears', async ({ page }) => {
|
||||
await openWork(page);
|
||||
await expect(page.locator('.filtered-note')).toHaveCount(2);
|
||||
await expect(page.locator('.filtered-note').first()).toBeHidden();
|
||||
await expect(page.locator('a[href="https://old.lucaswinther.info/"]')).toHaveCount(0);
|
||||
await chooseByHand(page);
|
||||
const visibleNotes = page.locator('.filtered-note:visible');
|
||||
await expect(visibleNotes).toHaveCount(1); // only the some-ai featured project
|
||||
await expect(visibleNotes.getByRole('button', { name: 'show anyway' })).toBeVisible();
|
||||
await expect(page.locator('a[href="https://old.lucaswinther.info/"]:visible').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('personal shares (no data-ai) are never hidden', async ({ page }) => {
|
||||
await setPref(page, 'by-hand-only');
|
||||
await page.goto('/off-the-clock/');
|
||||
await hydrated(page, 'Chips');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-pref', 'by-hand-only');
|
||||
const song = page.locator(`[data-wall-card][data-id="${I.song}"]`);
|
||||
expect(await song.getAttribute('data-ai')).toBeNull();
|
||||
await expect(song).toBeVisible();
|
||||
await expect(page.locator(`[data-wall-card][data-id="${I.byHand}"]`)).toBeVisible();
|
||||
await expect(page.locator(`[data-wall-card][data-id="${I.unknown}"]`)).toBeHidden();
|
||||
await expect(page.locator(`[data-wall-card][data-id="${I.someAi}"]`)).toBeHidden();
|
||||
});
|
||||
|
||||
test('preference persists across reload', async ({ page }) => {
|
||||
await openWork(page);
|
||||
await chooseByHand(page);
|
||||
expect(await page.evaluate((k) => localStorage.getItem(k), PREF_KEY)).toBe('by-hand-only');
|
||||
await page.reload();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-pref', 'by-hand-only');
|
||||
await expect(rowOf(page, P.aiMade)).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'by hand only' }).first()).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('data-pref is set before first paint (no flash)', async ({ page }) => {
|
||||
await page.addInitScript((k) => {
|
||||
localStorage.setItem(k, 'by-hand-only');
|
||||
const w = window as unknown as { __seen: { dcl?: string | null; early?: string | null } };
|
||||
w.__seen = {};
|
||||
// The earliest moment the attribute exists, with no <body> yet: the inline head script ran.
|
||||
new MutationObserver((_m, obs) => {
|
||||
const v = document.documentElement.getAttribute('data-pref');
|
||||
if (v) {
|
||||
w.__seen.early = `${v}|body=${!!document.body}`;
|
||||
obs.disconnect();
|
||||
}
|
||||
}).observe(document, { attributes: true, attributeFilter: ['data-pref'], subtree: true });
|
||||
document.addEventListener('DOMContentLoaded', () => (w.__seen.dcl = document.documentElement.getAttribute('data-pref')));
|
||||
}, PREF_KEY);
|
||||
await page.goto('/work/');
|
||||
const seen = await page.evaluate(() => (window as unknown as { __seen: unknown }).__seen);
|
||||
expect(seen).toEqual({ dcl: 'by-hand-only', early: 'by-hand-only|body=false' });
|
||||
});
|
||||
|
||||
test('an invalid stored value falls back to any', async ({ page }) => {
|
||||
await setPref(page, 'nonsense');
|
||||
await page.goto('/work/');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-pref', 'any');
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
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();
|
||||
});
|
||||
@@ -31,7 +31,7 @@
|
||||
.tech .lede { font-size: 17px; line-height: 1.5; max-width: 26em; margin: 18px 0 0; color: #343a3e; }
|
||||
.t-panel { margin-top: 28px; width: min(100%, 460px); border-top: 1px solid var(--t-ink); text-align: left; }
|
||||
.t-row { display: grid; grid-template-columns: 76px 1fr auto; gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--t-line); font-size: 14px; align-items: baseline; transition: background .15s; }
|
||||
.t-row:hover { background: rgba(255,255,255,.55); }
|
||||
@media (hover: hover) { .t-row:hover { background: rgba(255,255,255,.55); } }
|
||||
.t-row .k { font-family: var(--mono); font-size: 11px; color: var(--t-mute); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.t-row .v { color: var(--t-ink); min-width: 0; overflow-wrap: anywhere; }
|
||||
.tech .ai-mark { font-size: 11px; color: var(--t-mute); white-space: nowrap; }
|
||||
@@ -45,11 +45,11 @@
|
||||
.t-enter { margin-top: 22px; font-family: var(--mono); font-size: 13px; display: inline-flex; gap: 10px; align-items: center; }
|
||||
.t-enter kbd, .a-enter kbd, .foot kbd { font-family: var(--mono); font-size: 10px; border: 1px solid currentColor; border-radius: 3px; padding: 0 4px; opacity: .7; }
|
||||
.arrow { display: inline-block; transition: transform .2s; }
|
||||
.tech:hover .t-enter .arrow { transform: translateX(-4px); }
|
||||
@media (hover: hover) { .tech:hover .t-enter .arrow { transform: translateX(-4px); } }
|
||||
.readout { position: absolute; bottom: 24px; left: 48px; z-index: 1; font-family: var(--mono); font-size: 10px; color: var(--t-mute); opacity: 0; transition: opacity .2s; text-align: left; line-height: 1.6; pointer-events: none; }
|
||||
.tech:hover .readout { opacity: 1; }
|
||||
@media (hover: hover) { .tech:hover .readout { opacity: 1; } }
|
||||
.xh { position: absolute; z-index: 0; pointer-events: none; opacity: 0; transition: opacity .2s; }
|
||||
.tech:hover .xh { opacity: 1; }
|
||||
@media (hover: hover) { .tech:hover .xh { opacity: 1; } }
|
||||
.xh.h { left: 0; right: 0; height: 1px; background: rgba(217,83,43,.35); }
|
||||
.xh.v { top: 0; bottom: 0; width: 1px; background: rgba(217,83,43,.35); }
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
.garden-link { position: absolute; bottom: 26px; left: 50%; transform: translateX(-50%); z-index: 5; display: inline-flex; align-items: center; gap: 10px;
|
||||
background: #121416; color: #e8eae6; border-radius: 99px; padding: 8px 16px 8px 12px; font-family: var(--mono); font-size: 12px; text-decoration: none; white-space: nowrap;
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,.08); transition: background .2s, padding .2s; }
|
||||
.garden-link:hover { background: #000; padding-right: 20px; }
|
||||
@media (hover: hover) { .garden-link:hover { background: #000; padding-right: 20px; } }
|
||||
.garden-link .sprout { width: 14px; height: 14px; }
|
||||
.garden-link em { font-family: var(--serif); font-size: 14px; color: #e8b04a; }
|
||||
.garden-link kbd { font-family: var(--mono); font-size: 10px; border: 1px solid #555; border-radius: 3px; padding: 0 4px; color: #9aa09b; }
|
||||
|
||||
Reference in new issue
Block a user