From 29e4e7f70f09bc421f6ef37cf4b0b2a0482582dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 23:47:27 -0400 Subject: [PATCH] Site: separate hover, focus and press; still hit areas; one-cell lift; e2e tests --- site/.gitignore | 2 + site/e2e/touch.spec.ts | 93 ++++++++++++++++++++++++++++++++++++++ site/package.json | 4 +- site/playwright.config.ts | 8 ++++ site/pnpm-lock.yaml | 28 ++++++++++++ site/src/iso.css | 5 ++- site/src/main.ts | 2 +- site/src/style.css | 1 + site/src/touch.ts | 95 ++++++++++++++++++++++++--------------- tests/scripts/comments.py | 2 + 10 files changed, 199 insertions(+), 41 deletions(-) create mode 100644 site/e2e/touch.spec.ts create mode 100644 site/playwright.config.ts diff --git a/site/.gitignore b/site/.gitignore index d391312..63f4f94 100644 --- a/site/.gitignore +++ b/site/.gitignore @@ -1,3 +1,5 @@ node_modules/ dist/ .wrangler/ +test-results +playwright-report diff --git a/site/e2e/touch.spec.ts b/site/e2e/touch.spec.ts new file mode 100644 index 0000000..96c1802 --- /dev/null +++ b/site/e2e/touch.spec.ts @@ -0,0 +1,93 @@ +import { expect, test, type Page } from "@playwright/test"; + +const CELL = 28; +const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"]; + +test.use({ reducedMotion: "reduce" }); + +/** Screen top of the building's first block, in SVG pixels. */ +const top = (page: Page, name: string) => + page.evaluate((n) => { + const svg = document.querySelector(".stage")!; + const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width; + return document.querySelector(`.block[data-part="${n}"]`)!.getBoundingClientRect().top / scale; + }, name); + +const centre = async (page: Page, name: string) => { + const box = await page.locator(`.hit[data-part="${name}"]`).boundingBox(); + if (!box) throw new Error(`${name} has no hit area`); + return { x: box.x + box.width / 2, y: box.y + box.height * 0.75 }; +}; + +let errors: string[] = []; + +test.beforeEach(async ({ page }) => { + errors = []; + page.on("pageerror", (e) => errors.push(String(e))); + await page.goto("/"); + await expect(page.locator(".hit")).toHaveCount(6); +}); + +test.afterEach(() => expect(errors).toEqual([])); + +test("six links in reading order; reduced motion skips growth", async ({ page }) => { + await expect(page.locator(".doors a")).toHaveText(APPS); + const anim = await page.locator(".block").first().evaluate((b) => getComputedStyle(b).animationName); + expect(anim).toBe("none"); +}); + +test("focus lifts one cell and survives the mouse passing", async ({ page }) => { + const rest = (await top(page, "ElyDoc")); + await page.keyboard.press("Tab"); + await expect(page.locator(".doors a").first()).toBeFocused(); + expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0); + await expect(page.locator(".caption.shown")).toHaveText("ElyDoc"); + const at = await centre(page, "ElyDoc"); + await page.mouse.move(at.x, at.y); + await page.mouse.move(5, 5); + expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0); + await expect(page.locator(".caption.shown")).toHaveText("ElyDoc"); +}); + +test("hover at the bottom edge holds still", async ({ page }) => { + const box = (await page.locator('.hit[data-part="ElyTable"]').boundingBox())!; + await page.evaluate(() => { + (window as unknown as { flips: number }).flips = 0; + const hit = document.querySelector('.hit[data-part="ElyTable"]')!; + for (const kind of ["pointerenter", "pointerleave"]) + hit.addEventListener(kind, () => (window as unknown as { flips: number }).flips++); + }); + await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2); + await page.waitForTimeout(600); + expect(await page.evaluate(() => (window as unknown as { flips: number }).flips)).toBe(1); + await expect(page.locator(".caption.shown")).toHaveText("ElyTable"); +}); + +test("a primary press sinks one cell; only a click starting on it opens", async ({ page }) => { + const rest = (await top(page, "ElyShow")); + const at = await centre(page, "ElyShow"); + await page.mouse.move(at.x, at.y); + await page.mouse.down(); + expect((await top(page, "ElyShow"))).toBeCloseTo(rest, 0); + await page.mouse.up(); + expect(rest - (await top(page, "ElyShow"))).toBeCloseTo(CELL, 0); + await expect(page).toHaveURL(/#elyshow$/); + + await page.evaluate(() => (location.hash = "")); + await page.mouse.click(at.x, at.y, { button: "right" }); + await page.mouse.move(5, 5); + await page.mouse.down(); + await page.mouse.move(at.x, at.y); + await page.mouse.up(); + expect(await page.evaluate(() => location.hash)).toBe(""); +}); + +test("holding Enter sinks the focused building until release", async ({ page }) => { + const rest = (await top(page, "ElyDoc")); + await page.keyboard.press("Tab"); + await page.keyboard.down("Enter"); + expect((await top(page, "ElyDoc"))).toBeCloseTo(rest, 0); + await expect(page).toHaveURL(/#elydoc$/); + await page.keyboard.up("Enter"); + expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0); +}); diff --git a/site/package.json b/site/package.json index 4311812..a79bd8d 100644 --- a/site/package.json +++ b/site/package.json @@ -7,9 +7,11 @@ "build": "tsc && vite build", "preview": "vite preview", "deploy": "pnpm build && wrangler deploy", - "test": "node --test test/*.test.ts" + "test": "node --test test/*.test.ts", + "e2e": "playwright test" }, "devDependencies": { + "@playwright/test": "1.64.0", "typescript": "6.0.3", "vite": "8.3.4", "wrangler": "4.149.0" diff --git a/site/playwright.config.ts b/site/playwright.config.ts new file mode 100644 index 0000000..a64cf3f --- /dev/null +++ b/site/playwright.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "e2e", + fullyParallel: true, + use: { baseURL: "http://localhost:47311", viewport: { width: 1280, height: 1000 } }, + webServer: { command: "pnpm build && pnpm preview --port 47311 --strictPort", port: 47311 }, +}); diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index d6a320d..03546b9 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@playwright/test': + specifier: 1.64.0 + version: 1.64.0 typescript: specifier: 6.0.3 version: 6.0.3 @@ -401,6 +404,11 @@ packages: '@oxc-project/types@0.153.0': resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + '@playwright/test@1.64.0': + resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} + engines: {node: '>=20'} + hasBin: true + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -649,6 +657,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.29: resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} engines: {node: ^10 || ^12 || >=14} @@ -1004,6 +1022,10 @@ snapshots: '@oxc-project/types@0.153.0': {} + '@playwright/test@1.64.0': + dependencies: + playwright: 1.64.0 + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -1185,6 +1207,12 @@ snapshots: picomatch@4.0.7: {} + playwright-core@1.64.0: {} + + playwright@1.64.0: + dependencies: + playwright-core: 1.64.0 + postcss@8.5.29: dependencies: nanoid: 3.3.20 diff --git a/site/src/iso.css b/site/src/iso.css index eb3d51d..6ae69c5 100644 --- a/site/src/iso.css +++ b/site/src/iso.css @@ -36,9 +36,10 @@ transition: translate 0.15s ease-out; } -.block[data-part] { +.hit-face { + fill: none; + pointer-events: all; cursor: pointer; - outline: none; } .block.lifted { diff --git a/site/src/main.ts b/site/src/main.ts index c473845..2acdf71 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -35,4 +35,4 @@ svg.classList.add("grow"); svg.setAttribute("aria-hidden", "true"); host.append(svg); app.append(title, host); -touch(host, parts); +touch(host, svg, parts); diff --git a/site/src/style.css b/site/src/style.css index 9214062..e58a5ce 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -29,6 +29,7 @@ h1 { .stage { max-width: 100%; height: auto; + overflow: visible; } .model { diff --git a/site/src/touch.ts b/site/src/touch.ts index dbbc95a..3344857 100644 --- a/site/src/touch.ts +++ b/site/src/touch.ts @@ -1,60 +1,81 @@ // Buildings lift on hover or focus and sink when pressed. import { UNIT } from "./iso.ts"; +const SVG = "http://www.w3.org/2000/svg"; + /** Links each app's blocks to its region, in reading order. */ -export function touch(host: HTMLElement, parts: Map): void { - const caption = document.createElement("p"); - caption.className = "caption"; - caption.setAttribute("aria-hidden", "true"); - host.append(caption); - host.style.setProperty("--step", `${UNIT / 2}px`); +export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map): void { + host.style.setProperty("--step", `${UNIT}px`); const nav = document.createElement("nav"); nav.className = "doors"; host.append(nav); for (const [name, blocks] of parts) { const target = `#${name.toLowerCase()}`; - const inside = (node: EventTarget | null) => - node instanceof Element && node.closest(".block")?.dataset.part === name; - const lift = (up: boolean) => { - for (const b of blocks) b.classList.toggle("lifted", up); + const state = { hover: false, focus: false, press: false }; + for (const b of blocks) b.dataset.part = name; + const hit = area(blocks, name); + svg.append(hit); + const caption = document.createElement("p"); + caption.className = "caption"; + caption.textContent = name; + caption.setAttribute("aria-hidden", "true"); + host.append(caption); + + const show = () => { + const up = state.hover || state.focus; + for (const b of blocks) { + b.classList.toggle("lifted", up); + b.classList.toggle("pressed", up && state.press); + } caption.classList.toggle("shown", up); - if (up) place(caption, host, blocks, name); + if (up) place(caption, host, hit); }; - const press = (down: boolean) => { - for (const b of blocks) b.classList.toggle("pressed", down); - }; - const open = () => { - press(false); - location.hash = target; + const set = (change: Partial) => { + Object.assign(state, change); + show(); }; - for (const b of blocks) { - b.dataset.part = name; - b.addEventListener("pointerenter", () => lift(true)); - b.addEventListener("pointerleave", (e) => inside(e.relatedTarget) || (press(false), lift(false))); - b.addEventListener("pointerdown", () => press(true)); - b.addEventListener("pointerup", open); - } + hit.addEventListener("pointerenter", () => set({ hover: true })); + hit.addEventListener("pointerleave", () => set({ hover: false, press: false })); + hit.addEventListener("pointerdown", (e) => e.button === 0 && set({ press: true })); + hit.addEventListener("pointerup", () => set({ press: false })); + hit.addEventListener("pointercancel", () => set({ press: false })); + hit.addEventListener("click", () => (location.hash = target)); + const door = document.createElement("a"); door.href = target; door.textContent = name; - door.addEventListener("focus", () => lift(true)); - door.addEventListener("blur", () => (press(false), lift(false))); - door.addEventListener("keydown", (e) => e.key === "Enter" && press(true)); - door.addEventListener("click", () => press(false)); + door.addEventListener("focus", () => set({ focus: true })); + door.addEventListener("blur", () => set({ focus: false, press: false })); + door.addEventListener("keydown", (e) => e.key === "Enter" && !e.repeat && set({ press: true })); + door.addEventListener("keyup", (e) => e.key === "Enter" && set({ press: false })); nav.append(door); } } -/** Puts the caption above the building's top. */ -function place(caption: HTMLElement, host: HTMLElement, blocks: SVGGElement[], name: string) { +/** A still hit area over the building, resting and lifted. */ +function area(blocks: SVGGElement[], name: string): SVGGElement { + const hit = document.createElementNS(SVG, "g"); + hit.setAttribute("class", "hit"); + hit.dataset.part = name; + for (const b of blocks) { + const outline = b.querySelector(".outline"); + if (!outline) throw new Error("block has no outline"); + for (const dy of [0, -UNIT]) { + const face = outline.cloneNode() as SVGPolygonElement; + face.setAttribute("class", "hit-face"); + face.setAttribute("transform", `translate(0 ${dy})`); + hit.append(face); + } + } + return hit; +} + +/** Puts the caption above the lifted building. */ +function place(caption: HTMLElement, host: HTMLElement, hit: SVGGElement) { const origin = host.getBoundingClientRect(); - const rects = blocks.map((b) => b.getBoundingClientRect()); - const left = Math.min(...rects.map((r) => r.left)); - const right = Math.max(...rects.map((r) => r.right)); - const top = Math.min(...rects.map((r) => r.top)); - caption.textContent = name; - caption.style.left = `${(left + right) / 2 - origin.left}px`; - caption.style.top = `${top - origin.top}px`; + const r = hit.getBoundingClientRect(); + caption.style.left = `${r.left + r.width / 2 - origin.left}px`; + caption.style.top = `${r.top - origin.top}px`; } diff --git a/tests/scripts/comments.py b/tests/scripts/comments.py index 548e78a..aec6ada 100755 --- a/tests/scripts/comments.py +++ b/tests/scripts/comments.py @@ -80,6 +80,8 @@ too_long = [] repeated = {} for pattern in PATTERNS: for path in sorted(root.glob(pattern)): + if "node_modules" in path.parts: + continue if path.suffix in SCANNED: last = None for first, final, said in scan(path.read_text(), SCANNED[path.suffix]):