diff --git a/site/e2e/film.spec.ts b/site/e2e/film.spec.ts new file mode 100644 index 0000000..4d80c07 --- /dev/null +++ b/site/e2e/film.spec.ts @@ -0,0 +1,65 @@ +import { expect, test, type Page } from "@playwright/test"; + +const PAGE = "/"; + +/** Scrolls to the film and mutes it for autoplay. */ +async function reach(page: Page): Promise { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(String(e))); + page.on("console", (m) => { + if (m.type() === "error") errors.push(m.text()); + }); + await page.goto(PAGE); + await page.locator("#film").scrollIntoViewIfNeeded(); + await expect(page.locator("#film")).toHaveClass(/seen/); + await page.evaluate(() => { + const video = document.querySelector("#film video")!; + video.muted = true; + Object.assign(window, { filmVideo: video }); + }); + return errors; +} + +const box = (page: Page) => page.locator("#film video").evaluate((v) => v.getBoundingClientRect().toJSON()); + +test("the film flattens to play and returns to its face", async ({ page }) => { + const errors = await reach(page); + await expect(page.locator("#film video")).toHaveAttribute("preload", "metadata"); + const face = await box(page); + const play = page.getByRole("button", { name: "Play the film" }); + await play.click(); + await expect.poll(() => page.locator("#film video").evaluate((v: HTMLVideoElement) => v.paused)).toBe(false); + await expect.poll(() => page.locator("#film video").evaluate((v) => v.getAnimations().length)).toBe(0); + const flat = await box(page); + expect(flat.width / flat.height).toBeCloseTo(16 / 9, 1); + expect(flat.width).toBeGreaterThan(page.viewportSize()!.width * 0.7); + expect(flat.width).toBeGreaterThan(face.width * 1.5); + await expect(page.locator("#film video")).toHaveJSProperty("controls", true); + + await page.keyboard.press("Escape"); + await expect(play).toBeFocused(); + await expect.poll(() => page.locator("#film video").evaluate((v) => v.getAnimations().length)).toBe(0); + const back = await box(page); + expect(back.width).toBeCloseTo(face.width, 0); + expect(back.height).toBeCloseTo(face.height, 0); + const state = await page.evaluate(() => { + const video = document.querySelector("#film video")!; + return { same: video === (window as unknown as { filmVideo: HTMLVideoElement }).filmVideo, paused: video.paused, at: video.currentTime }; + }); + expect(state.same).toBe(true); + expect(state.paused).toBe(true); + expect(state.at).toBeGreaterThan(0); + expect(errors).toEqual([]); +}); + +test("with reduced motion the film plays flat at once", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const errors = await reach(page); + await page.getByRole("button", { name: "Play the film" }).click(); + const video = page.locator("#film video"); + expect(await video.evaluate((v) => [v.getAnimations().length, getComputedStyle(v).transform])).toEqual([0, "none"]); + await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(false); + await page.keyboard.press("Escape"); + await expect(page.getByRole("button", { name: "Play the film" })).toBeFocused(); + expect(errors).toEqual([]); +}); diff --git a/site/e2e/night.spec.ts b/site/e2e/night.spec.ts new file mode 100644 index 0000000..0a2fb13 --- /dev/null +++ b/site/e2e/night.spec.ts @@ -0,0 +1,111 @@ +import { expect, test, type Page } from "@playwright/test"; + +const PAGE = "/"; +const DESK = { light: "rgb(230, 230, 226)", dark: "rgb(58, 58, 58)" }; + +let errors: string[] = []; + +test.beforeEach(async ({ page }) => { + errors = []; + page.on("pageerror", (e) => errors.push(String(e))); +}); + +test.afterEach(() => expect(errors).toEqual([])); + +const open = async (page: Page) => { + await page.goto(PAGE); + await expect(page.locator(".model .block").first()).toBeAttached(); +}; + +/** The hero desk's top face colour. */ +const desk = (page: Page) => + page.evaluate(() => { + const top = document.querySelector(".model .block .face.top")!; + return getComputedStyle(top).fill; + }); + +const lamp = (page: Page) => page.getByRole("button", { name: "Switch to dark" }); + +test("the lamp turns the page dark in a wave", async ({ page }) => { + await open(page); + expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("light"); + expect(await desk(page)).toBe(DESK.light); + + // Read the nearest and farthest hero blocks mid-wave. + const mid = await page.evaluate(async () => { + const tops = [...document.querySelectorAll(".model .block")]; + document.querySelector(".night")!.click(); + const wave = (g: SVGGElement) => parseFloat(g.style.getPropertyValue("--wave")); + tops.sort((a, b) => wave(a) - wave(b)); + const [near, far] = [tops[0], tops[tops.length - 1]]; + const fill = (g: SVGGElement) => getComputedStyle(g.querySelector(".face.top")!).fill; + // Sample between the near landing and the far start. + const gap = (wave(near) + 0.2 + wave(far)) / 2; + await new Promise((r) => setTimeout(r, gap * 1000)); + return { + steps: [wave(near), wave(far)], + near: [fill(near), near.style.getPropertyValue("--tone-dark")], + far: [fill(far), far.style.getPropertyValue("--tone")], + }; + }); + expect(mid.steps[0] + 0.25).toBeLessThan(mid.steps[1]); + expect(mid.steps[1]).toBeLessThanOrEqual(1); + const rgb = (hex: string) => `rgb(${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(", ")})`; + expect(mid.near[0]).toBe(rgb(mid.near[1])); + expect(mid.far[0]).toBe(rgb(mid.far[1])); + + await expect.poll(() => desk(page), { timeout: 1200 }).toBe(DESK.dark); + expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("dark"); + await expect(page.getByRole("button", { name: "Switch to light" })).toBeAttached(); + await expect(page.locator(".night-sky")).toHaveCount(0); + await expect(page.locator("html")).toHaveCSS("background-color", "rgb(37, 37, 37)"); +}); + +test("dark text keeps 4.5:1 against the page", async ({ page }) => { + await page.emulateMedia({ colorScheme: "dark" }); + await open(page); + const worst = await page.evaluate(() => { + const parse = (c: string) => c.match(/[\d.]+/g)!.slice(0, 3).map(Number); + const lum = (c: string) => { + const [r, g, b] = parse(c).map((v) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const page = lum(getComputedStyle(document.documentElement).backgroundColor); + const ratios = [...document.querySelectorAll("h1, h2, h3, p, a, li, button")] + .filter((el) => el.textContent?.trim() && getComputedStyle(el).color !== "rgba(0, 0, 0, 0)") + .map((el) => { + const text = lum(getComputedStyle(el).color); + return { ratio: (Math.max(text, page) + 0.05) / (Math.min(text, page) + 0.05), text: el.textContent }; + }); + return ratios.sort((a, b) => a.ratio - b.ratio)[0]; + }); + expect(worst.ratio, String(worst.text)).toBeGreaterThanOrEqual(4.5); +}); + +test("the system scheme sets the first theme; the lamp's choice stays", async ({ page }) => { + await page.emulateMedia({ colorScheme: "dark" }); + await open(page); + expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("dark"); + expect(await desk(page)).toBe(DESK.dark); + + await page.emulateMedia({ colorScheme: "light" }); + await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe("light"); + + await page.emulateMedia({ reducedMotion: "reduce" }); + await lamp(page).click(); + expect(await desk(page)).toBe(DESK.dark); + await page.emulateMedia({ colorScheme: "light" }); + await page.reload(); + expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("dark"); +}); + +test("reduced motion switches at once", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "light" }); + await open(page); + await lamp(page).click(); + expect(await desk(page)).toBe(DESK.dark); + await expect(page.locator(".night-sky")).toHaveCount(0); +}); diff --git a/site/e2e/play.spec.ts b/site/e2e/play.spec.ts new file mode 100644 index 0000000..e470a1e --- /dev/null +++ b/site/e2e/play.spec.ts @@ -0,0 +1,92 @@ +import { expect, test, type Page } from "@playwright/test"; + +const TREE = "Open a Markdown file's history in ElyTree."; + +/** Opens the board and waits until it has grown. */ +async function open(page: Page): Promise { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + page.on("console", (m) => m.type() === "error" && errors.push(m.text())); + await page.goto("/"); + await page.locator("#roads").scrollIntoViewIfNeeded(); + await expect(page.locator("#roads .stage")).not.toHaveClass(/grow/, { timeout: 10_000 }); + return errors; +} + +/** Screen centre of a road cell. */ +async function cell(page: Page, at: string) { + const box = (await page.locator(`#roads [data-cell="${at}"]:not(.file)`).boundingBox())!; + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +const mark = (page: Page) => page.getByRole("button", { name: "ElyMark file" }); + +/** Screen point of a grid spot on the desk top. */ +const desk = (page: Page, x: number, y: number) => + page.evaluate(([x, y]) => { + const svg = document.querySelector("#roads .stage")!; + const p = new DOMPoint((x - y) * Math.cos(Math.PI / 6) * 28, ((x + y) / 2 - 0.6) * 28).matrixTransform(svg.getScreenCTM()!); + return { x: p.x, y: p.y }; + }, [x, y]); + +test("dragging Mark's file down its road reaches ElyTree", async ({ page }) => { + const errors = await open(page); + const from = (await mark(page).boundingBox())!; + const to = await cell(page, "14,12"); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 12 }); + await page.mouse.up(); + await expect(mark(page)).toHaveAttribute("data-cell", "14,12"); + await expect(page.locator("#roads .news")).toHaveText(TREE); + await expect(page.locator('#roads [data-cell="14,12"]:not(.file)')).toHaveCSS("--tone", "#3d7fe5"); + expect(errors).toEqual([]); +}); + +test("a file dragged onto empty desk stays on its road", async ({ page }) => { + const errors = await open(page); + const from = (await mark(page).boundingBox())!; + const away = await desk(page, 10.5, 8.5); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(away.x, away.y); + await page.mouse.up(); + await page.waitForTimeout(500); + await expect(mark(page)).toHaveAttribute("data-cell", "5,10"); + await expect(page.locator("#roads .news")).toHaveText(""); + expect(errors).toEqual([]); +}); + +test("arrow keys walk Mark's file to ElyTree, then reset", async ({ page }) => { + const errors = await open(page); + await mark(page).focus(); + for (const key of ["ArrowDown", "ArrowDown", ...Array(9).fill("ArrowRight")]) + await page.keyboard.press(key); + await expect(mark(page)).toHaveAttribute("data-cell", "14,12"); + await expect(page.locator("#roads .news")).toHaveText(TREE); + await page.getByRole("button", { name: "Put the files back" }).click(); + await expect(mark(page)).toHaveAttribute("data-cell", "5,10"); + await expect(page.locator("#roads .news")).toHaveText(""); + expect(errors).toEqual([]); +}); + +test.describe("reduced motion", () => { + test.use({ reducedMotion: "reduce" }); + + test("files stack at ElyPDF and the line says why", async ({ page }) => { + const errors = await open(page); + const doc = page.getByRole("button", { name: "ElyDoc file" }); + await doc.focus(); + await page.keyboard.press("ArrowDown"); + for (let i = 0; i < 13; i++) await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowUp"); + await expect(doc).toHaveAttribute("data-cell", "15,5"); + await expect(page.locator("#roads .news")).toHaveText("Export to PDF, open it in ElyPDF."); + const show = page.getByRole("button", { name: "ElyShow file" }); + await show.focus(); + for (const key of ["ArrowDown", "ArrowDown", "ArrowRight", "ArrowRight", "ArrowRight", "ArrowRight", "ArrowRight", "ArrowUp"]) + await page.keyboard.press(key); + await expect(show).toHaveAttribute("data-cell", "15,5"); + expect(errors).toEqual([]); + }); +}); diff --git a/site/e2e/rhythm.spec.ts b/site/e2e/rhythm.spec.ts new file mode 100644 index 0000000..2124ff2 --- /dev/null +++ b/site/e2e/rhythm.spec.ts @@ -0,0 +1,116 @@ +import { expect, test, type Page } from "@playwright/test"; + +const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"]; + +let errors: string[] = []; + +test.beforeEach(async ({ page }) => { + errors = []; + page.on("pageerror", (e) => errors.push(String(e))); + await page.goto("/"); + await page.locator(".tour .model").scrollIntoViewIfNeeded(); + await button(page).hover(); + await page.evaluate(() => + Promise.all(document.querySelector(".tour .model")!.getAnimations({ subtree: true }).map((a) => a.finished)), + ); +}); + +test.afterEach(() => expect(errors).toEqual([])); + +const button = (page: Page) => page.locator(".speaker-button"); +const paused = (page: Page) => page.evaluate(() => document.querySelector(".tour audio")!.paused); + +/** Screen top of each building's first top face. */ +const tops = (page: Page) => + page.evaluate( + (apps) => + apps.map((n) => document.querySelector(`.tour .block[data-part="${n}"] .face.top`)!.getBoundingClientRect().top), + APPS, + ); + +/** Whether anything dances: hops, road light or a moving woofer. */ +const dancing = (page: Page) => + page.evaluate(() => { + const hops = [...document.querySelectorAll(".tour .block")].filter((g) => + g.style.getPropertyValue("--hop"), + ); + const moved = [...document.querySelectorAll(".tour .woofer")].filter((g) => g.style.translate); + return hops.length + document.querySelectorAll(".tour .pulse").length + moved.length; + }); + +test("the speaker plays and pauses the music", async ({ page }) => { + await expect(button(page)).toHaveAccessibleName("Play music"); + await button(page).click(); + await expect.poll(() => paused(page)).toBe(false); + await expect(button(page)).toHaveAccessibleName("Pause music"); + await button(page).click(); + await expect.poll(() => paused(page)).toBe(true); + await expect(button(page)).toHaveAccessibleName("Play music"); +}); + +test("buildings hop on the beat; all six on the bar", async ({ page }) => { + const rest = await tops(page); + await button(page).click(); + await page.waitForFunction( + ([apps, rest]) => + apps.every( + (n, i) => + document.querySelector(`.tour .block[data-part="${n}"] .face.top`)!.getBoundingClientRect().top < rest[i] - 10, + ), + [APPS, rest] as const, + { polling: "raf", timeout: 6000 }, + ); + await page.waitForFunction( + ([apps, rest]) => + apps.filter( + (n, i) => + document.querySelector(`.tour .block[data-part="${n}"] .face.top`)!.getBoundingClientRect().top < rest[i] - 10, + ).length === 1, + [APPS, rest] as const, + { polling: "raf", timeout: 6000 }, + ); +}); + +test("paused, nothing moves", async ({ page }) => { + const rest = await tops(page); + await button(page).click(); + await expect.poll(() => dancing(page)).toBeGreaterThan(0); + await button(page).click(); + await expect.poll(() => paused(page)).toBe(true); + await expect.poll(() => dancing(page)).toBe(0); + for (let i = 0; i < 4; i++) { + expect(await dancing(page)).toBe(0); + expect(await tops(page)).toEqual(rest); + await page.waitForTimeout(150); + } +}); + +test.describe("reduced motion", () => { + test.use({ reducedMotion: "reduce" }); + + test("music plays, buildings stay, the woofer stays out", async ({ page }) => { + const rest = await tops(page); + await button(page).click(); + await expect.poll(() => paused(page)).toBe(false); + await page.waitForTimeout(1200); + expect(await dancing(page)).toBe(0); + expect(await tops(page)).toEqual(rest); + const push = await page.locator(".tour .woofer").first().evaluate((g) => getComputedStyle(g).translate); + expect(push).not.toBe("none"); + }); +}); + +test("the desk cell under the pointer rises", async ({ page }) => { + const at = await page.evaluate(() => { + const svg = document.querySelector(".tour .stage")!; + const [cos, sin, unit, top] = [Math.cos(Math.PI / 6), 0.5, 28, 0.6]; + const p = new DOMPoint((11.5 - 9.5) * cos * unit, ((11.5 + 9.5) * sin - top) * unit).matrixTransform( + svg.getScreenCTM()!, + ); + return { x: p.x, y: p.y }; + }); + await page.mouse.move(at.x, at.y); + await expect(page.locator(".tour .tile")).toHaveCount(1); + await page.mouse.move(at.x, at.y - 600); + await expect(page.locator(".tour .tile")).toHaveCount(0); +}); diff --git a/site/e2e/text.spec.ts b/site/e2e/text.spec.ts index a088544..5ff9c16 100644 --- a/site/e2e/text.spec.ts +++ b/site/e2e/text.spec.ts @@ -61,6 +61,7 @@ test.describe("English locale", () => { await page.mouse.move(5, 500); await expect(lang).toHaveCSS("opacity", "0"); await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); await expect(lang).toBeFocused(); await expect(lang).toHaveCSS("opacity", "1"); }); @@ -79,9 +80,9 @@ test.describe("English locale", () => { test("links point at the release and the repository", async ({ page }) => { await page.goto("/"); - await expect(page.locator(".region .get")).toHaveCount(APPS.length); - for (const app of APPS) { - await expect(page.locator(`#${app.toLowerCase()} .get`)).toHaveAttribute("href", dmg(app)); + await expect(page.locator(".tour .get")).toHaveCount(APPS.length); + for (const [i, app] of APPS.entries()) { + await expect(page.locator(".tour .get").nth(i)).toHaveAttribute("href", dmg(app)); } await expect(page.locator("#download ul a")).toHaveText([...APPS, TEXT.en.source]); for (const [i, app] of APPS.entries()) { @@ -103,17 +104,19 @@ test.describe("English locale", () => { } }); - test("region downloads show on hover or focus", async ({ page }) => { - await page.goto("/"); - for (const app of APPS) { - const region = page.locator(`#${app.toLowerCase()}`); - const get = region.locator(".get"); - await region.scrollIntoViewIfNeeded(); - await page.mouse.move(0, 0); + test("each app's copy arrives at its anchor; its download shows on intent", async ({ page }) => { + for (const [i, app] of APPS.entries()) { + await page.goto(`/#${app.toLowerCase()}`); + const article = page.locator(".copy article").nth(i); + await expect(article).toHaveClass(/current/); + await expect(page.locator(".copy article.current")).toHaveCount(1); + await expect(page.locator(".rail a").nth(i)).toHaveAttribute("aria-current", "step"); + const get = article.locator(".get"); + await page.mouse.move(1000, 5); await expect(get).toHaveCSS("opacity", "0"); - await region.locator("h2").hover(); + await article.locator("h2").hover(); await expect(get).toHaveCSS("opacity", "1"); - await page.locator(".lang").hover(); + await page.mouse.move(1000, 5); await expect(get).toHaveCSS("opacity", "0"); await get.focus(); await expect(get).toHaveCSS("opacity", "1"); diff --git a/site/e2e/touch.spec.ts b/site/e2e/touch.spec.ts index 114284f..dcc3b20 100644 --- a/site/e2e/touch.spec.ts +++ b/site/e2e/touch.spec.ts @@ -5,12 +5,13 @@ const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"]; test.use({ reducedMotion: "reduce" }); -/** Screen top of the building's first block, in SVG pixels. */ +/** Building top over the desk top, in SVG pixels; camera-free. */ const top = (page: Page, name: string) => page.evaluate((n) => { - const svg = document.querySelector(".hero .stage")!; + const svg = document.querySelector(".model .stage")!; const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width; - return (document.querySelector(`.block[data-part="${n}"]`)!.getBoundingClientRect().top + scrollY) / scale; + const at = (sel: string) => document.querySelector(sel)!.getBoundingClientRect().top; + return (at(`.block[data-part="${n}"]`) - at('.block[data-part=""]')) / scale; }, name); /** A point that hits the building; `low` picks the lowest. */ @@ -38,7 +39,7 @@ const spot = (page: Page, name: string, low = false) => [name, low] as const, ); -const caption = (page: Page, name: string) => page.locator(".caption", { hasText: name }); +const caption = (page: Page, name: string) => page.locator(".tour .caption", { hasText: name }); let errors: string[] = []; @@ -46,7 +47,7 @@ test.beforeEach(async ({ page }) => { errors = []; page.on("pageerror", (e) => errors.push(String(e))); await page.goto("/"); - await expect(page.locator(".hero .hit-face:not(.ground)").first()).toBeAttached(); + await expect(page.locator(".model .hit-face:not(.ground)").first()).toBeAttached(); }); test.afterEach(() => expect(errors).toEqual([])); @@ -59,7 +60,7 @@ test("six links in reading order; reduced motion skips growth", async ({ page }) test("focus lifts one cell, shows the name, survives the mouse", async ({ page }) => { const rest = await top(page, "ElyDoc"); - for (let i = 0; i < 3; i++) await page.keyboard.press("Tab"); + await page.locator(".doors a").first().focus(); await expect(page.locator(".doors a").first()).toBeFocused(); expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0); await expect(caption(page, "ElyDoc")).toHaveCSS("opacity", "1"); @@ -89,7 +90,7 @@ test("hover at the bottom edge holds still", async ({ page }) => { test("the hit owner is the block you see", async ({ page }) => { const wrong = await page.evaluate(() => { - const r = document.querySelector(".hero .stage")!.getBoundingClientRect(); + const r = document.querySelector(".model .stage")!.getBoundingClientRect(); const out: string[] = []; const owner = (x: number, y: number) => { const face = document.elementsFromPoint(x, y).find((e) => e.classList.contains("face")); @@ -120,6 +121,8 @@ test("a primary press sinks one cell; only a click starting on it opens", async await page.evaluate(() => history.replaceState(null, "", "/")); await page.evaluate(() => scrollTo(0, 0)); + // The camera settles on the next frame. + await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); const again = await spot(page, "ElyShow"); await page.mouse.click(again.x, again.y, { button: "right" }); await page.mouse.move(5, 5); @@ -131,7 +134,7 @@ test("a primary press sinks one cell; only a click starting on it opens", async test("holding Enter sinks the focused building until release", async ({ page }) => { const rest = await top(page, "ElyDoc"); - for (let i = 0; i < 3; i++) await page.keyboard.press("Tab"); + await page.locator(".doors a").first().focus(); await page.keyboard.down("Enter"); expect(await top(page, "ElyDoc")).toBeCloseTo(rest, 0); await expect(page).toHaveURL(/#elydoc$/); diff --git a/site/e2e/tour.spec.ts b/site/e2e/tour.spec.ts new file mode 100644 index 0000000..c618259 --- /dev/null +++ b/site/e2e/tour.spec.ts @@ -0,0 +1,111 @@ +import { expect, test, type Page } from "@playwright/test"; + +const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"]; + +let errors: string[] = []; + +test.beforeEach(async ({ page }) => { + errors = []; + page.on("pageerror", (e) => errors.push(String(e))); +}); + +test.afterEach(() => expect(errors).toEqual([])); + +const frame = (page: Page) => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); + +/** Scrolls to `spans` stage heights into the tour. */ +async function at(page: Page, spans: number) { + await page.evaluate((s) => { + document.documentElement.style.scrollBehavior = "auto"; + scrollTo(0, s * document.querySelector(".pin")!.clientHeight); + }, spans); + await frame(page); +} + +/** Largest gap in px between clip and face corners. */ +const drift = (page: Page, i: number) => + page.evaluate((i) => { + const video = document.querySelectorAll(".clip")[i]; + const world = video.parentElement!; + const host = world.parentElement!.getBoundingClientRect(); + const m = new DOMMatrix(getComputedStyle(world).transform).multiply(new DOMMatrix(getComputedStyle(video).transform)); + const [w, h] = [video.offsetWidth, video.offsetHeight]; + const clip = [[0, 0], [w, 0], [w, h], [0, h]].map(([x, y]) => m.transformPoint(new DOMPoint(x, y))); + const row = (k: number) => document.querySelector(`.block.slab[data-app="${i}"][data-row="${k}"]`)!; + const ctm = row(0).ownerSVGElement!.getScreenCTM()!; + const corners = (g: SVGGElement) => + g.querySelector(".face.left")!.getAttribute("points")!.split(" ").map((p) => { + const [x, y] = p.split(",").map(Number); + return new DOMPoint(x, y).matrixTransform(ctm); + }); + // Left face points: bottom-left, bottom-right, top-right, top-left. + const [bl, br] = corners(row(0)); + const [, , tr, tl] = corners(row(3)); + const face = [tl, tr, br, bl]; + return Math.max(...clip.map((p, k) => Math.hypot(p.x + host.left - face[k].x, p.y + host.top - face[k].y))); + }, i); + +test("each clip stays on its screen face, held or gliding", async ({ page }) => { + await page.goto("/"); + for (const [i] of APPS.entries()) { + for (const s of [i + 1, i + 1.5]) { + await at(page, s); + await page.waitForTimeout(800); + expect(await drift(page, i), `${APPS[i]} at ${s}`).toBeLessThan(1); + } + } +}); + +test("only the current app's clip loads and plays", async ({ page }) => { + await page.goto("/"); + await at(page, 3); + const clips = page.locator(".clip"); + await expect.poll(() => clips.nth(2).evaluate((v: HTMLVideoElement) => !v.paused && v.currentTime > 0)).toBe(true); + for (const i of [0, 1, 3, 4, 5]) await expect(clips.nth(i).locator("source")).toHaveCount(0); + await at(page, 4); + await expect(clips.nth(2).locator("source")).toHaveCount(0); + await expect.poll(() => clips.nth(3).evaluate((v: HTMLVideoElement) => !v.paused)).toBe(true); + await at(page, 0); + await expect(page.locator(".clip source")).toHaveCount(0); +}); + +test("the rail scrolls to an app and marks it", async ({ page }) => { + await page.goto("/"); + await at(page, 1); + await page.locator(".rail a").nth(4).click(); + await expect(page.locator(".rail a").nth(4)).toHaveAttribute("aria-current", "step"); + await expect(page.locator(".copy article.current h2")).toHaveText("ElyTree"); +}); + +test.describe("reduced motion", () => { + test.use({ reducedMotion: "reduce" }); + + test("clips wait for a press; the camera jumps", async ({ page }) => { + await page.goto("/#elyshow"); + const clip = page.locator(".clip").nth(2); + await expect(page.locator(".copy article.current h2")).toHaveText("ElyShow"); + await expect(clip).toHaveAttribute("poster", "/clips/show.jpg"); + expect(await clip.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true); + const held = await page.evaluate(() => getComputedStyle(document.querySelector(".world")!).transform); + await at(page, 3.4); + expect(await page.evaluate(() => getComputedStyle(document.querySelector(".world")!).transform)).toBe(held); + await page.locator(".copy article.current .toggle-clip").click(); + await expect.poll(() => clip.evaluate((v: HTMLVideoElement) => !v.paused)).toBe(true); + await expect(page.locator(".copy article.current .toggle-clip")).toHaveText("Pause"); + }); +}); + +test.describe("phone", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test("no sideways scroll; copy sits below the stage", async ({ page }) => { + await page.goto("/"); + for (const s of [0, 1, 2.5, 4, 6, 7]) { + await at(page, s); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); + } + await at(page, 2); + const copy = await page.locator(".copy article.current").boundingBox(); + expect(copy!.y).toBeGreaterThan(844 * 0.55); + }); +}); diff --git a/site/public/clips/doc.jpg b/site/public/clips/doc.jpg new file mode 100644 index 0000000..13dfd90 Binary files /dev/null and b/site/public/clips/doc.jpg differ diff --git a/site/public/clips/doc.mp4 b/site/public/clips/doc.mp4 new file mode 100644 index 0000000..228bac5 Binary files /dev/null and b/site/public/clips/doc.mp4 differ diff --git a/site/public/clips/doc.webm b/site/public/clips/doc.webm new file mode 100644 index 0000000..f439f9e Binary files /dev/null and b/site/public/clips/doc.webm differ diff --git a/site/public/clips/mark.jpg b/site/public/clips/mark.jpg new file mode 100644 index 0000000..c213322 Binary files /dev/null and b/site/public/clips/mark.jpg differ diff --git a/site/public/clips/mark.mp4 b/site/public/clips/mark.mp4 new file mode 100644 index 0000000..40d5d24 Binary files /dev/null and b/site/public/clips/mark.mp4 differ diff --git a/site/public/clips/mark.webm b/site/public/clips/mark.webm new file mode 100644 index 0000000..57ee5db Binary files /dev/null and b/site/public/clips/mark.webm differ diff --git a/site/public/clips/pdf.jpg b/site/public/clips/pdf.jpg new file mode 100644 index 0000000..e0f889a Binary files /dev/null and b/site/public/clips/pdf.jpg differ diff --git a/site/public/clips/pdf.mp4 b/site/public/clips/pdf.mp4 new file mode 100644 index 0000000..33981ab Binary files /dev/null and b/site/public/clips/pdf.mp4 differ diff --git a/site/public/clips/pdf.webm b/site/public/clips/pdf.webm new file mode 100644 index 0000000..39f5bb7 Binary files /dev/null and b/site/public/clips/pdf.webm differ diff --git a/site/public/clips/show.jpg b/site/public/clips/show.jpg new file mode 100644 index 0000000..0a4daed Binary files /dev/null and b/site/public/clips/show.jpg differ diff --git a/site/public/clips/show.mp4 b/site/public/clips/show.mp4 new file mode 100644 index 0000000..59840c4 Binary files /dev/null and b/site/public/clips/show.mp4 differ diff --git a/site/public/clips/show.webm b/site/public/clips/show.webm new file mode 100644 index 0000000..1aefbca Binary files /dev/null and b/site/public/clips/show.webm differ diff --git a/site/public/clips/table.jpg b/site/public/clips/table.jpg new file mode 100644 index 0000000..0b6a533 Binary files /dev/null and b/site/public/clips/table.jpg differ diff --git a/site/public/clips/table.mp4 b/site/public/clips/table.mp4 new file mode 100644 index 0000000..2c2e907 Binary files /dev/null and b/site/public/clips/table.mp4 differ diff --git a/site/public/clips/table.webm b/site/public/clips/table.webm new file mode 100644 index 0000000..32b38f5 Binary files /dev/null and b/site/public/clips/table.webm differ diff --git a/site/public/clips/tree.jpg b/site/public/clips/tree.jpg new file mode 100644 index 0000000..4f6f65d Binary files /dev/null and b/site/public/clips/tree.jpg differ diff --git a/site/public/clips/tree.mp4 b/site/public/clips/tree.mp4 new file mode 100644 index 0000000..9fdb96f Binary files /dev/null and b/site/public/clips/tree.mp4 differ diff --git a/site/public/clips/tree.webm b/site/public/clips/tree.webm new file mode 100644 index 0000000..7db801a Binary files /dev/null and b/site/public/clips/tree.webm differ diff --git a/site/public/film/ely-office.mp4 b/site/public/film/ely-office.mp4 new file mode 100644 index 0000000..2bf2b86 Binary files /dev/null and b/site/public/film/ely-office.mp4 differ diff --git a/site/public/film/poster.jpg b/site/public/film/poster.jpg new file mode 100644 index 0000000..e0a6633 Binary files /dev/null and b/site/public/film/poster.jpg differ diff --git a/site/public/music/ely.m4a b/site/public/music/ely.m4a new file mode 100644 index 0000000..907a9ef Binary files /dev/null and b/site/public/music/ely.m4a differ diff --git a/site/public/music/ely.ogg b/site/public/music/ely.ogg new file mode 100644 index 0000000..ca8a1a3 Binary files /dev/null and b/site/public/music/ely.ogg differ diff --git a/site/src/film.css b/site/src/film.css new file mode 100644 index 0000000..8798d55 --- /dev/null +++ b/site/src/film.css @@ -0,0 +1,91 @@ +.film { + display: grid; + justify-items: center; + gap: 2.5rem; + max-width: 72rem; + margin: 0 auto; + padding: 9rem 1.5rem; + text-align: center; +} + +.film-stage { + position: relative; + width: 100%; +} + +.film-stage .stage { + width: min(100%, 52rem, calc(78svh * var(--ratio))); + margin: 0 auto; + transition: opacity 0.3s ease-out; +} + +.film.flat .stage { + opacity: 0; +} + +.film .notch { + fill: #fff; +} + +.film video { + position: absolute; + display: block; + transform-origin: 0 0; + object-fit: fill; + background: #000; + outline: var(--bezel) solid color-mix(in oklab, #3a3a3c, black 9%); + cursor: pointer; +} + +.film.flat video { + cursor: auto; +} + +/* The bezel matches the screen's left face at night. */ +:root[data-theme="dark"] .film video:not(:focus-visible) { + outline-color: color-mix(in oklab, #303032, black 9%); +} + +.film video:focus-visible { + outline-color: var(--accent); +} + +/* The film appears once its screen has landed. */ +.film:not(.seen) video, +.film:not(.seen) .film-play { + visibility: hidden; +} + +.film.seen video { + animation: shown 0.2s ease-out var(--shown) backwards; +} + +@keyframes shown { + from { + opacity: 0; + } +} + +.film-play { + position: absolute; + padding: 0; + border: 0; + background: none; + color: transparent; + font-size: 0; + cursor: pointer; +} + +.film.flat .film-play { + visibility: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .film-stage .stage { + transition: none; + } + + .film.seen video { + animation: none; + } +} diff --git a/site/src/film.ts b/site/src/film.ts new file mode 100644 index 0000000..657cc9f --- /dev/null +++ b/site/src/film.ts @@ -0,0 +1,178 @@ +// The film on a block screen; it flattens to play. +import { block, project, stage, UNIT } from "./iso.ts"; +import { grow } from "./beat.ts"; +import { ACCENT } from "./model.ts"; +import { said } from "./text.ts"; +import "./film.css"; + +const SVG = "http://www.w3.org/2000/svg"; +const PLINTH = "#e6e6e2"; +const DARK = "#3a3a3c"; +const MORPH = 520; +const EASE = "cubic-bezier(0.2, 0.8, 0.3, 1)"; + +/** The screen; the film fills its left face. */ +const SCREEN = { x: 4, y: 1, z: 1.6, w: 16.8, d: 0.8, h: 9.8, tone: DARK }; +const BEZEL = 0.4; + +/** The film on the face: corner, across, down. */ +function face(): [number, number][] { + const x = SCREEN.x + BEZEL; + const y = SCREEN.y + SCREEN.d; + const z = SCREEN.z + SCREEN.h - BEZEL; + const corner = project(x, y, z); + const across = project(x + 16, y, z); + const down = project(x, y, z - 9); + return [corner, [across[0] - corner[0], across[1] - corner[1]], [down[0] - corner[0], down[1] - corner[1]]]; +} + +/** A play triangle on a block's left face. */ +function notch(g: SVGGElement, x: number, y: number, z: number, w: number, h: number): void { + const p = (dx: number, dz: number) => project(x + dx * w, y, z + dz * h).map((v) => v.toFixed(2)).join(","); + const tri = document.createElementNS(SVG, "polygon"); + tri.setAttribute("points", [p(0.32, 0.8), p(0.32, 0.2), p(0.74, 0.5)].join(" ")); + tri.setAttribute("class", "notch"); + g.append(tri); +} + +function scene(): { svg: SVGSVGElement; prop: SVGGElement } { + const plinth = block({ x: 0, y: 0, w: 22, d: 5.2, h: 0.8, tone: PLINTH }); + const feet = [5.5, 18.5].map((x) => block({ x, y: 1.1, z: 0.8, w: 1, d: 0.6, h: 0.8, tone: DARK })); + const screen = block(SCREEN); + const spec = { x: 0.6, y: 2.4, z: 0.8, w: 2.4, d: 2.2, h: 1.6, tone: ACCENT }; + const prop = block(spec); + notch(prop, spec.x, spec.y + spec.d, spec.z, spec.w, spec.h); + grow([[plinth], [...feet, screen], [prop]]); + const svg = stage([plinth, ...feet, screen, prop]); + svg.classList.add("grow"); + svg.setAttribute("aria-hidden", "true"); + svg.style.setProperty("--step", `${UNIT * 0.5}px`); + svg.style.setProperty("--ratio", String(svg.viewBox.baseVal.width / svg.viewBox.baseVal.height)); + return { svg, prop }; +} + +/** A screen on a plinth that becomes a player. */ +export function film(): HTMLElement { + const section = document.createElement("section"); + section.id = "film"; + section.className = "film"; + const { svg, prop } = scene(); + const land = prop.style.getPropertyValue("--land"); + section.style.setProperty("--shown", land); + + const host = document.createElement("div"); + host.className = "film-stage"; + const video = document.createElement("video"); + video.src = "/film/ely-office.mp4"; + video.poster = "/film/poster.jpg"; + video.preload = "none"; + video.playsInline = true; + const play = said("button", "filmPlay"); + play.className = "film-play"; + host.append(svg, video, play); + const line = said("p", "filmLine"); + line.id = "film-line"; + video.setAttribute("aria-labelledby", line.id); + section.append(host, line); + + const reduced = matchMedia("(prefers-reduced-motion: reduce)"); + let flat = false; + let onFace = "none"; + let morph: Animation | undefined; + + const layout = () => { + const box = host.getBoundingClientRect(); + const art = svg.getBoundingClientRect(); + const vb = svg.viewBox.baseVal; + const k = art.width / vb.width; + const w = Math.floor(Math.min((box.width * 16) / (16 + 2 * BEZEL), (box.height * 16) / (9 + 2 * BEZEL))); + const h = Math.round((w * 9) / 16); + const left = (box.width - w) / 2; + const top = (box.height - h) / 2; + Object.assign(video.style, { width: `${w}px`, height: `${h}px`, left: `${left}px`, top: `${top}px` }); + video.style.setProperty("--bezel", `${(w * BEZEL) / 16}px`); + const [corner, across, down] = face(); + const ox = art.left - box.left + (corner[0] - vb.x) * k - left; + const oy = art.top - box.top + (corner[1] - vb.y) * k - top; + onFace = `matrix(${(across[0] * k) / w}, ${(across[1] * k) / w}, ${(down[0] * k) / h}, ${(down[1] * k) / h}, ${ox}, ${oy})`; + if (!flat && !morph) video.style.transform = onFace; + const p = prop.getBoundingClientRect(); + Object.assign(play.style, { + left: `${p.left - box.left}px`, + top: `${p.top - box.top}px`, + width: `${p.width}px`, + height: `${p.height}px`, + }); + }; + + const move = (to: string) => { + const from = getComputedStyle(video).transform; + morph?.cancel(); + video.style.transform = to; + if (reduced.matches) return; + morph = video.animate([{ transform: from }, { transform: to }], { duration: MORPH, easing: EASE }); + const own = morph; + own.onfinish = () => { + if (morph !== own) return; + morph = undefined; + if (!flat) video.style.transform = onFace; + }; + }; + + const open = () => { + if (flat) return; + flat = true; + section.classList.add("flat"); + video.controls = true; + move("none"); + host.scrollIntoView({ block: "center", behavior: reduced.matches ? "instant" : "smooth" }); + video.focus({ preventScroll: true }); + video.play().catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return; + throw error; + }); + }; + + const close = () => { + if (!flat) return; + flat = false; + section.classList.remove("flat"); + video.pause(); + video.controls = false; + move(onFace); + play.focus({ preventScroll: true }); + }; + + play.addEventListener("click", open); + video.addEventListener("click", () => { + if (!flat) open(); + }); + video.addEventListener("ended", close); + document.addEventListener("keydown", (e) => { + if (flat && e.key === "Escape") close(); + }); + + const lift = (up: boolean, press = false) => { + prop.classList.toggle("lifted", up); + prop.classList.toggle("pressed", up && press); + }; + play.addEventListener("pointerenter", () => lift(true)); + play.addEventListener("pointerleave", () => lift(play.matches(":focus-visible"))); + play.addEventListener("focus", () => lift(true)); + play.addEventListener("blur", () => lift(false)); + play.addEventListener("pointerdown", () => lift(true, true)); + play.addEventListener("pointerup", () => lift(true)); + + new ResizeObserver(layout).observe(host); + new IntersectionObserver( + (entries, seen) => { + if (!entries.some((e) => e.isIntersecting)) return; + svg.classList.add("seen"); + section.classList.add("seen"); + video.preload = "metadata"; + seen.disconnect(); + }, + { rootMargin: "50% 0px" }, + ).observe(section); + return section; +} diff --git a/site/src/iso.css b/site/src/iso.css index 14d3284..356775e 100644 --- a/site/src/iso.css +++ b/site/src/iso.css @@ -1,15 +1,24 @@ /* One colour, three tones; no shadows. */ +.block { + --ink: var(--tone); +} + +/* At night a block takes its dark tone. */ +:root[data-theme="dark"] .block { + --ink: var(--tone-dark, var(--tone)); +} + .face.top { - fill: var(--tone); + fill: var(--ink); } .face.left, .face.outline { - fill: color-mix(in oklab, var(--tone), black 9%); + fill: color-mix(in oklab, var(--ink), black 9%); } .face.right { - fill: color-mix(in oklab, var(--tone), black 20%); + fill: color-mix(in oklab, var(--ink), black 20%); } /* Each block drops one step and lands on its beat. */ diff --git a/site/src/main.ts b/site/src/main.ts index f0976f1..1a21842 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -1,50 +1,43 @@ import "./style.css"; import "./iso.css"; -import { block, stage } from "./iso.ts"; -import { grow } from "./beat.ts"; +import "./tour.css"; +import { tour } from "./tour.ts"; +import { props, reveal } from "./regions.ts"; +import { night } from "./night.ts"; +import { rhythm } from "./rhythm.ts"; +import { playground } from "./play.ts"; +import { film } from "./film.ts"; import { model } from "./model.ts"; -import { touch } from "./touch.ts"; -import { regions, reveal } from "./regions.ts"; import { said, speak, type Lang } from "./text.ts"; const REPO = "https://github.com/ZacharyZhang-NY/ElyOffice"; const RELEASE = `${REPO}/releases/download/v0.1.0`; +const APPS = model.slice(1, -1).map((p) => p.name); const app = document.querySelector("#app"); if (!app) throw new Error("index.html has no #app"); -const apps = model.slice(1, -1); -const built = model.map((part) => part.blocks.map(block)); -grow(built); -const parts = new Map(apps.map((p) => [p.name, built[model.indexOf(p)]])); - const hero = document.createElement("header"); -hero.className = "hero"; const title = document.createElement("h1"); title.textContent = "Ely Office"; -const host = document.createElement("div"); -host.className = "model"; -const svg = stage(built.flat()); -svg.classList.add("grow", "seen"); -svg.setAttribute("aria-hidden", "true"); -host.append(svg); const github = document.createElement("a"); github.href = REPO; github.textContent = "GitHub"; -hero.append(title, said("p", "tagline"), said("p", "platforms"), github, host); -touch(host, svg, parts); +hero.append(title, said("p", "tagline"), said("p", "platforms"), github); +const { section, host, svg } = tour(hero); +rhythm(host, svg); -const sections = regions(); +const shelf = props(); const footer = document.createElement("footer"); footer.id = "download"; const version = document.createElement("h2"); version.textContent = "Ely Office 0.1.0"; const list = document.createElement("ul"); -for (const p of apps) { +for (const name of APPS) { const a = document.createElement("a"); - a.href = `${RELEASE}/${p.name}-0.1.0-arm64.dmg`; - a.textContent = p.name; + a.href = `${RELEASE}/${name}-0.1.0-arm64.dmg`; + a.textContent = name; const li = document.createElement("li"); li.append(a); list.append(li); @@ -59,13 +52,16 @@ footer.append(version, said("p", "platforms"), said("p", "needs"), said("p", "un const toggle = said("button", "other"); toggle.className = "lang"; let lang: Lang = navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en"; -toggle.addEventListener("click", () => { - lang = lang === "en" ? "zh" : "en"; +/** Writes the page in `next`; the toggle names the other. */ +const say = (next: Lang) => { + lang = next; speak(lang); -}); + toggle.lang = lang === "en" ? "zh-CN" : "en"; +}; +toggle.addEventListener("click", () => say(lang === "en" ? "zh" : "en")); const main = document.createElement("main"); -main.append(...sections); -app.append(toggle, hero, main, footer); -speak(lang); -reveal(sections); +main.append(section, playground(), shelf, film()); +app.append(night(), toggle, main, footer); +say(lang); +reveal([shelf]); diff --git a/site/src/night.css b/site/src/night.css new file mode 100644 index 0000000..3f87a99 --- /dev/null +++ b/site/src/night.css @@ -0,0 +1,73 @@ +/* Night: soft greys, never black; the accent stays. */ +:root[data-theme="dark"] { + color-scheme: dark; + color: #f5f5f7; + background: #252525; + --quiet: #c4c2c0; +} + +/* The accent passes 4.5:1 as text only when lighter. */ +:root[data-theme="dark"] { + --link: #78a6ee; +} + +/* Faces and words re-tone as the wave reaches them. */ +.waving .face { + transition: fill 0.2s ease-out var(--wave, 0s); +} + +.waving :is(h1, h2, h3, p, a, li, button) { + transition: color 0.2s ease-out var(--wave, 0s); +} + +.night-sky { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; +} + +.night { + position: fixed; + top: 1rem; + left: 1.5rem; + z-index: 1; + padding: 0.25rem; + border: 0; + background: none; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease-out; +} + +.night .stage { + width: 2.5rem; + height: auto; +} + +.night:hover, +.night:focus-visible, +body:has(.hero:hover) .night { + opacity: 1; +} + +@media (hover: none) { + .night { + opacity: 1; + } +} + +.night-name { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +@media (prefers-reduced-motion: reduce) { + .night { + transition: none; + } +} diff --git a/site/src/night.ts b/site/src/night.ts new file mode 100644 index 0000000..7fc418b --- /dev/null +++ b/site/src/night.ts @@ -0,0 +1,163 @@ +// A desk lamp that turns the page dark and back. +import "./night.css"; +import { block, stage } from "./iso.ts"; +import { TEXT, type Lang } from "./text.ts"; + +const SIXTEENTH = 60 / 112 / 4; +/** Wave steps; the last block lands by 1.14 s. */ +const STEPS = 7; +const LAND = 0.2; +const KEY = "ely-theme"; +const PAGE = { light: "#fbfbfa", dark: "#252525" }; + +type Theme = keyof typeof PAGE; + +/** Light tone to night tone, by the same three-face rule. */ +export const NIGHT: Record = { + "#f4f4f2": "#5c5c5a", + "#e6e6e2": "#3a3a3a", + "#3a3a3c": "#303032", + "#3d7fe5": "#3d7fe5", +}; +const GLOW = "#f2e6c9"; +const DESK = "#e6e6e2"; +const PAPER = "#f4f4f2"; + +/** Gives every block under `root` its night tone. */ +function tone(root: ParentNode): void { + const blocks = [...root.querySelectorAll("g.block")]; + if (root instanceof SVGGElement && root.matches("g.block")) blocks.push(root); + for (const g of blocks) { + if (g.style.getPropertyValue("--tone-dark")) continue; + const light = g.style.getPropertyValue("--tone").trim().toLowerCase(); + const dark = NIGHT[light]; + if (!dark) throw new Error(`no night tone for ${light}`); + g.style.setProperty("--tone-dark", dark); + } +} + +function stored(): Theme | null { + try { + const v = localStorage.getItem(KEY); + return v === "dark" || v === "light" ? v : null; + } catch { + return null; + } +} + +function keep(theme: Theme): void { + try { + localStorage.setItem(KEY, theme); + } catch { + // Storage may be off; the choice lasts this page. + } +} + +/** Base, post, arm, shade, bulb and a pool of light. */ +function lamp(): SVGSVGElement { + const glow = [ + block({ x: 0.9, y: 0.1, z: 0.2, w: 0.8, d: 0.8, h: 0.04, tone: DESK }), + block({ x: 1.1, y: 0.3, z: 0.95, w: 0.4, d: 0.4, h: 0.35, tone: DESK }), + ]; + for (const g of glow) g.style.setProperty("--tone-dark", GLOW); + const svg = stage([ + block({ x: 0, y: 0, w: 1.8, d: 1.8, h: 0.2, tone: DESK }), + glow[0], + block({ x: 0.2, y: 0.35, z: 0.2, w: 0.35, d: 0.3, h: 2.0, tone: PAPER }), + block({ x: 0.55, y: 0.35, z: 1.9, w: 0.75, d: 0.3, h: 0.3, tone: PAPER }), + glow[1], + block({ x: 0.95, y: 0.15, z: 1.3, w: 0.7, d: 0.7, h: 0.6, tone: PAPER }), + ]); + svg.setAttribute("aria-hidden", "true"); + return svg; +} + +const lang = (): Lang => (document.documentElement.lang.startsWith("zh") ? "zh" : "en"); + +let run = 0; + +/** Turns the page to `theme`, rippling out from `from`. */ +function turn(theme: Theme, from: DOMRect | null): void { + const root = document.documentElement; + const before = root.dataset.theme; + if (before === theme) return; + const still = !from || !before || matchMedia("(prefers-reduced-motion: reduce)").matches; + const id = ++run; + root.querySelector(".night-sky")?.remove(); + if (still) { + root.classList.remove("waving"); + root.style.removeProperty("background-color"); + root.dataset.theme = theme; + return; + } + const x = from.x + from.width / 2; + const y = from.y + from.height / 2; + const corner = Math.max(...[0, innerWidth].flatMap((cx) => [0, innerHeight].map((cy) => Math.hypot(cx - x, cy - y)))); + const all = [...document.querySelectorAll("g.block, h1, h2, h3, p, a, li, button")]; + const away = all.map((el) => { + const r = el.getBoundingClientRect(); + const seen = r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth; + return { el, seen, d: Math.hypot(r.x + r.width / 2 - x, r.y + r.height / 2 - y) }; + }); + // The farthest thing in view lands on the last step. + const reach = Math.max(1, ...away.filter((a) => a.seen).map((a) => a.d)); + for (const { el, d } of away) { + const step = Math.min(STEPS, Math.round((d / reach) * STEPS)); + el.style.setProperty("--wave", `${(step * SIXTEENTH).toFixed(3)}s`); + } + // The new sky spreads at the wave's pace. + const sky = document.createElement("div"); + sky.className = "night-sky"; + sky.style.background = PAGE[theme]; + root.style.backgroundColor = PAGE[before as Theme]; + document.body.append(sky); + root.classList.add("waving"); + root.dataset.theme = theme; + const last = STEPS * SIXTEENTH; + const span = last + LAND; + const circle = (r: number) => ({ clipPath: `circle(${r.toFixed(1)}px at ${x}px ${y}px)` }); + const grown = sky.animate( + [circle(0), { ...circle(Math.min(reach, corner)), offset: last / span }, circle(corner)], + { duration: span * 1000, easing: "linear", fill: "forwards" }, + ); + grown.finished.then(() => { + if (id !== run) return; + sky.remove(); + root.style.removeProperty("background-color"); + root.classList.remove("waving"); + }); +} + +/** The lamp button; it follows the system until pressed. */ +export function night(): HTMLElement { + const button = document.createElement("button"); + button.className = "night"; + const name = document.createElement("span"); + name.className = "night-name"; + button.append(lamp(), name); + + const label = () => { + const key = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + name.dataset.t = key; + name.textContent = TEXT[lang()][key]; + }; + const system = matchMedia("(prefers-color-scheme: dark)"); + const set = (theme: Theme, from: DOMRect | null) => { + turn(theme, from); + label(); + }; + + tone(document); + new MutationObserver((changes) => { + for (const c of changes) for (const n of c.addedNodes) if (n instanceof Element) tone(n); + }).observe(document.documentElement, { childList: true, subtree: true }); + + set(stored() ?? (system.matches ? "dark" : "light"), null); + system.addEventListener("change", () => stored() || set(system.matches ? "dark" : "light", button.getBoundingClientRect())); + button.addEventListener("click", () => { + const theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + keep(theme); + set(theme, button.getBoundingClientRect()); + }); + return button; +} diff --git a/site/src/play.css b/site/src/play.css new file mode 100644 index 0000000..1abec20 --- /dev/null +++ b/site/src/play.css @@ -0,0 +1,91 @@ +.play { + display: grid; + justify-items: center; + gap: 4rem; + max-width: 64rem; + margin: 0 auto; + padding: 9rem 1.5rem; + text-align: center; +} + +.play .board { + position: relative; + width: 100%; +} + +.play .stage { + width: 100%; + user-select: none; + -webkit-user-select: none; +} + +.play .caption { + translate: -50% -100%; +} + +.play:hover .caption, +.play:focus-within .caption { + opacity: 1; +} + +@media (hover: none) { + .play .caption { + opacity: 1; + } +} + +.file { + cursor: grab; + touch-action: none; + outline: none; +} + +.file:active { + cursor: grabbing; +} + +.file:focus-visible .face.outline { + stroke: #1d1d1f; + stroke-width: 2; + stroke-linejoin: round; +} + +:root[data-theme="dark"] .file:focus-visible .face.outline { + stroke: #f4f4f2; +} + +.play .foot { + display: grid; + justify-items: center; + gap: 1.5rem; + min-height: 5.5rem; +} + +.again { + padding: 0.5rem 0.75rem; + border: 0; + background: none; + font: inherit; + font-size: 1.0625rem; + color: var(--link); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease-out; +} + +.play:hover .again, +.again:focus-visible { + opacity: 1; +} + +@media (hover: none) { + .again { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .again { + transition: none; + } +} diff --git a/site/src/play.ts b/site/src/play.ts new file mode 100644 index 0000000..38ba04e --- /dev/null +++ b/site/src/play.ts @@ -0,0 +1,458 @@ +// Roads: drag a file from its app to its target. +import "./play.css"; +import { block, order, project, stage, UNIT, type Block } from "./iso.ts"; +import { ACCENT, model } from "./model.ts"; +import { said, TEXT, type Key } from "./text.ts"; + +type Cell = [number, number]; + +const BEAT = 60 / 112; +const STEP = BEAT / 4; +const TOP = 0.6; +const RISE = 0.1; +const FILE = 0.56; +const THICK = 0.36; +const DESK = "#e6e6e2"; +const PAPER = "#f4f4f2"; +const ROAD = "#d3d3ce"; +const ROAD_NIGHT = "#474745"; +const REACH = 0.9; + +/** Each building's back corner on the board. */ +const HOMES: Record = { + ElyDoc: [1, 1], + ElyTable: [5, 1], + ElyShow: [9, 1], + ElyPDF: [14, 1], + ElyMark: [1, 9], + ElyTree: [13, 11], +}; + +/** Cells along straight runs between corners. */ +function walk(...corners: Cell[]): Cell[] { + const out: Cell[] = [corners[0]]; + for (const [x, y] of corners.slice(1)) { + let [cx, cy] = out[out.length - 1]; + if (cx !== x && cy !== y) throw new Error("roads turn at right angles"); + while (cx !== x || cy !== y) { + cx += Math.sign(x - cx); + cy += Math.sign(y - cy); + out.push([cx, cy]); + } + } + return out; +} + +interface Link { + from: string; + to: string; + line: Key; + path: Cell[]; +} + +const LINKS: Link[] = [ + { from: "ElyDoc", to: "ElyPDF", line: "toPdf", path: walk([2, 5], [2, 6], [15, 6], [15, 5]) }, + { from: "ElyTable", to: "ElyPDF", line: "toPdf", path: walk([6, 5], [6, 6], [15, 6], [15, 5]) }, + { from: "ElyShow", to: "ElyPDF", line: "toPdf", path: walk([10, 4], [10, 6], [15, 6], [15, 5]) }, + { from: "ElyMark", to: "ElyPDF", line: "toPdf", path: walk([5, 10], [5, 6], [15, 6], [15, 5]) }, + { from: "ElyMark", to: "ElyTree", line: "toTree", path: walk([5, 10], [5, 12], [14, 12]) }, +]; + +const SOURCES = ["ElyDoc", "ElyTable", "ElyShow", "ElyMark"] as const; +const NAMES: Record<(typeof SOURCES)[number], Key> = { + ElyDoc: "fileDoc", + ElyTable: "fileTable", + ElyShow: "fileShow", + ElyMark: "fileMark", +}; + +/** Arrows as screen directions; ties take this grid step. */ +const ARROWS: Record = { + ArrowRight: [1, 0, [1, 0]], + ArrowLeft: [-1, 0, [-1, 0]], + ArrowUp: [0, -1, [0, -1]], + ArrowDown: [0, 1, [0, 1]], +}; + +const id = (c: Cell) => `${c[0]},${c[1]}`; +const same = (a: Cell, b: Cell) => a[0] === b[0] && a[1] === b[1]; +const still = () => matchMedia("(prefers-reduced-motion: reduce)").matches; +const lang = () => (document.documentElement.lang === "zh-CN" ? "zh" : "en"); + +interface File { + name: string; + start: Cell; + cell: Cell; + goal: Cell; + level: number; + done: boolean; + wrap: SVGGElement; + links: Link[]; + ways: Set; +} + +interface Item { + el: SVGGElement; + box: () => Required; +} + +/** A building moved home; accent is kept for files. */ +function placed(name: string): Required[] { + const part = model.find((p) => p.name === name); + if (!part) throw new Error(`no building ${name}`); + const [x, y] = HOMES[name]; + const left = Math.min(...part.blocks.map((b) => b.x)); + const back = Math.min(...part.blocks.map((b) => b.y)); + const floor = Math.min(...part.blocks.map((b) => b.z)); + const snap = (v: number) => Math.round(v * 1000) / 1000; + return part.blocks.map((b) => ({ ...b, tone: PAPER, x: snap(b.x - left + x), y: snap(b.y - back + y), z: snap(b.z - floor + TOP) })); +} + +const fileBox = (f: File): Required => ({ + x: f.cell[0] + (1 - FILE) / 2, + y: f.cell[1] + (1 - FILE) / 2, + z: TOP + RISE + f.level * THICK, + w: FILE, + d: FILE, + h: THICK, + tone: ACCENT, +}); + +/** The roads section: board, files, and one line. */ +export function playground(): HTMLElement { + const section = document.createElement("section"); + section.id = "roads"; + section.className = "play"; + const names = document.createElement("div"); + names.hidden = true; + const hint = said("span", "moveHint"); + hint.id = "roads-hint"; + names.append(hint); + + const items: Item[] = []; + const add = (spec: Required) => { + const g = block(spec); + items.push({ el: g, box: () => spec }); + return g; + }; + const desk = add({ x: 0, y: 0, z: 0, w: 17.5, d: 13.5, h: TOP, tone: DESK }); + const buildings = new Map(Object.keys(HOMES).map((n) => [n, placed(n).map(add)])); + const roads = new Map(); + for (const cell of LINKS.flatMap((l) => l.path)) { + if (roads.has(id(cell))) continue; + const g = add({ x: cell[0] + 0.05, y: cell[1] + 0.05, z: TOP, w: 0.9, d: 0.9, h: RISE, tone: ROAD }); + paint(g, ROAD, ROAD_NIGHT); + g.dataset.cell = id(cell); + roads.set(id(cell), g); + } + + const files: File[] = SOURCES.map((name) => { + const links = LINKS.filter((l) => l.from === name); + const start = links[0].path[0]; + const wrap = document.createElementNS("http://www.w3.org/2000/svg", "g"); + wrap.setAttribute("class", "file"); + wrap.setAttribute("tabindex", "0"); + wrap.setAttribute("role", "button"); + const label = said("span", NAMES[name]); + label.id = `roads-${name}`; + names.append(label); + wrap.setAttribute("aria-labelledby", label.id); + wrap.setAttribute("aria-describedby", hint.id); + const f: File = { + name, start, cell: start, goal: start, level: 0, done: false, wrap, links, + ways: new Set(links.flatMap((l) => l.path.map(id))), + }; + items.push({ el: wrap, box: () => fileBox(f) }); + return f; + }); + files.forEach(render); + + const svg = stage(items.flatMap((i) => (i.el.classList.contains("file") ? [] : [i.el]))); + svg.classList.add("grow"); + svg.style.setProperty("--step", `${UNIT}px`); + const total = timed(desk, buildings, roads, files); + + const board = document.createElement("div"); + board.className = "board"; + board.append(svg, ...captions(svg, buildings)); + + const news = document.createElement("p"); + news.className = "news"; + news.setAttribute("aria-live", "polite"); + const again = said("button", "putBack"); + again.className = "again"; + again.type = "button"; + again.hidden = true; + const foot = document.createElement("div"); + foot.className = "foot"; + foot.append(news, again); + section.append(said("h2", "roads"), board, foot, names); + + let ready = false; + const timers = new Set(); + const later = (ms: number, run: () => void) => { + const t = window.setTimeout(() => { + timers.delete(t); + run(); + }, ms); + timers.add(t); + }; + const grown = () => { + svg.classList.remove("grow"); + ready = true; + }; + if (still()) grown(); + const seen = new IntersectionObserver((entries) => { + if (!entries.some((e) => e.isIntersecting)) return; + seen.disconnect(); + svg.classList.add("seen"); + if (!ready) window.setTimeout(grown, total * 1000 + 250); + }, { rootMargin: "0px 0px -20% 0px" }); + seen.observe(section); + + /** Painter order; `fixed` keeps its DOM place and focus. */ + const arrange = (fixed: SVGGElement | null) => { + const els = order(items.map((i) => i.box())).map((i) => items[i].el); + const at = fixed ? els.indexOf(fixed) : -1; + if (!fixed || at < 0) return svg.append(...els); + for (const el of els.slice(0, at)) svg.insertBefore(el, fixed); + svg.append(...els.slice(at + 1)); + }; + arrange(null); + + let walking: SVGGElement | null = null; + const step = (f: File, next: Cell) => { + for (const o of files) { + if (o === f || !same(o.cell, f.cell) || o.level < f.level) continue; + o.level -= 1; + render(o); + } + f.level = files.filter((o) => o !== f && same(o.cell, next)).length; + f.cell = next; + render(f); + arrange(f.wrap); + again.hidden = false; + if (!still()) { + f.wrap.animate([{ translate: `0 -${UNIT}px` }, { translate: "0 0" }], { + duration: STEP * 1000, + easing: "cubic-bezier(0.2, 0.8, 0.3, 1)", + }); + click(); + } + const link = f.links.find((l) => same(l.path[l.path.length - 1], next)); + if (link) arrive(f, link); + }; + + const tick = (f: File) => { + walking = null; + while (!f.done && !same(f.cell, f.goal)) { + step(f, route(f, f.goal)[1]); + if (!still() && !f.done && !same(f.cell, f.goal)) { + walking = f.wrap; + return later(STEP * 1000, () => tick(f)); + } + } + }; + const go = (f: File, goal: Cell) => { + if (f.done || same(goal, f.goal)) return; + f.goal = goal; + if (walking !== f.wrap) tick(f); + }; + + const arrive = (f: File, link: Link) => { + f.done = true; + f.goal = f.cell; + news.dataset.t = link.line; + news.textContent = TEXT[lang()][link.line]; + link.path.forEach((cell, i) => { + const g = roads.get(id(cell))!; + const light = () => paint(g, ACCENT, ACCENT); + if (still()) light(); + else later((i + 1) * STEP * 1000, light); + }); + if (still()) return; + const house = buildings.get(link.to)!; + for (const b of house) b.classList.add("lifted"); + later(BEAT * 1000, () => house.forEach((b) => b.classList.remove("lifted"))); + }; + + again.addEventListener("click", () => { + for (const t of timers) clearTimeout(t); + timers.clear(); + walking = null; + for (const f of files) Object.assign(f, { cell: f.start, goal: f.start, level: 0, done: false }); + files.forEach(render); + for (const g of roads.values()) paint(g, ROAD, ROAD_NIGHT); + for (const b of buildings.values()) b.forEach((g) => g.classList.remove("lifted")); + delete news.dataset.t; + news.textContent = ""; + arrange(null); + files[files.length - 1].wrap.focus(); + again.hidden = true; + }); + + let drag: { f: File; pointer: number; dx: number; dy: number } | null = null; + for (const f of files) { + f.wrap.addEventListener("pointerdown", (e) => { + if (!ready || f.done || e.button !== 0) return; + e.preventDefault(); + f.wrap.focus({ focusVisible: false }); + wake(); + svg.setPointerCapture(e.pointerId); + const [gx, gy] = grid(svg, e); + drag = { f, pointer: e.pointerId, dx: gx - f.cell[0] - 0.5, dy: gy - f.cell[1] - 0.5 }; + }); + f.wrap.addEventListener("keydown", (e) => { + const arrow = ARROWS[e.key]; + if (!arrow) return; + e.preventDefault(); + if (!ready || f.done) return; + wake(); + const next = toward(f, f.goal, arrow); + if (next) go(f, next); + }); + } + svg.addEventListener("pointermove", (e) => { + if (!drag || e.pointerId !== drag.pointer) return; + const [gx, gy] = grid(svg, e); + const near = nearest(drag.f, gx - drag.dx, gy - drag.dy); + if (near) go(drag.f, near); + }); + const drop = (e: PointerEvent) => { + if (drag && e.pointerId === drag.pointer) drag = null; + }; + svg.addEventListener("pointerup", drop); + svg.addEventListener("pointercancel", drop); + return section; +} + +/** Sets a road cell's day and night tones. */ +function paint(g: SVGGElement, day: string, night: string): void { + g.style.setProperty("--tone", day); + g.style.setProperty("--tone-dark", night); +} + +/** Rebuilds the file's block at its cell and level. */ +function render(f: File): void { + f.wrap.replaceChildren(block(fileBox(f))); + f.wrap.dataset.cell = id(f.cell); +} + +/** Landing times: desk, buildings, roads in waves, then files. */ +function timed(desk: SVGGElement, buildings: Map, roads: Map, files: File[]): number { + let at = BEAT; + const land = (gs: SVGGElement[]) => { + for (const g of gs) g.style.setProperty("--land", `${at.toFixed(3)}s`); + at += STEP; + }; + land([desk]); + for (const house of buildings.values()) land(house); + const waves = new Map(); + for (const [key, g] of roads) { + const sum = key.split(",").map(Number).reduce((a, b) => a + b); + waves.set(sum, [...(waves.get(sum) ?? []), g]); + } + for (const sum of [...waves.keys()].sort((a, b) => a - b)) land(waves.get(sum)!); + land(files.map((f) => f.wrap)); + return at; +} + +/** Building names above their tops, placed in viewBox percent. */ +function captions(svg: SVGSVGElement, buildings: Map): HTMLElement[] { + const view = svg.viewBox.baseVal; + return [...buildings.keys()].map((name) => { + const blocks = placed(name); + const xs = blocks.flatMap((b) => [b.x, b.x + b.w]); + const ys = blocks.flatMap((b) => [b.y, b.y + b.d]); + const tops = blocks.flatMap((b) => [project(b.x, b.y, b.z + b.h)[1], project(b.x + b.w, b.y, b.z + b.h)[1]]); + const [cx] = project((Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2, 0); + const p = document.createElement("p"); + p.className = "caption"; + p.textContent = name; + p.setAttribute("aria-hidden", "true"); + p.style.left = `${((cx - view.x) / view.width) * 100}%`; + p.style.top = `${((Math.min(...tops) - UNIT * 0.5 - view.y) / view.height) * 100}%`; + return p; + }); +} + +/** The pointer on the road plane, in grid units. */ +function grid(svg: SVGSVGElement, e: PointerEvent): [number, number] { + const m = svg.getScreenCTM(); + if (!m) throw new Error("board is not rendered"); + const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse()); + const [cx, sy] = project(1, 0, 0); + const a = p.x / cx; + const b = (p.y + (TOP + RISE) * UNIT) / sy; + return [(a + b) / 2, (b - a) / 2]; +} + +/** Nearest road cell to a grid point, if close. */ +function nearest(f: File, x: number, y: number): Cell | null { + let best: Cell | null = null; + let far = REACH; + for (const key of f.ways) { + const cell = key.split(",").map(Number) as Cell; + const d = Math.hypot(cell[0] + 0.5 - x, cell[1] + 0.5 - y); + if (d < far) [best, far] = [cell, d]; + } + return best; +} + +/** Shortest road from the file to `goal`, ends included. */ +function route(f: File, goal: Cell): Cell[] { + const back = new Map([[id(f.cell), null]]); + const queue: Cell[] = [f.cell]; + while (queue.length) { + const at = queue.shift()!; + if (same(at, goal)) { + const path: Cell[] = []; + for (let c: Cell | null = at; c; c = back.get(id(c))!) path.unshift(c); + return path; + } + for (const n of around(f, at)) { + if (back.has(id(n))) continue; + back.set(id(n), at); + queue.push(n); + } + } + throw new Error(`no road from ${id(f.cell)} to ${id(goal)}`); +} + +const around = (f: File, [x, y]: Cell): Cell[] => + ([[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]] as Cell[]).filter((c) => f.ways.has(id(c))); + +/** Road cell beside `from` that best follows an arrow. */ +function toward(f: File, from: Cell, [ax, ay, prefer]: [number, number, Cell]): Cell | null { + let best: Cell | null = null; + let score = 0.1; + for (const n of around(f, from)) { + const step: Cell = [n[0] - from[0], n[1] - from[1]]; + const [sx, sy] = project(step[0], step[1], 0); + const s = (sx * ax + sy * ay) / Math.hypot(sx, sy) + (same(step, prefer) ? 0.01 : 0); + if (s > score) [best, score] = [n, s]; + } + return best; +} + +let audio: AudioContext | null = null; + +/** Starts sound only after the reader touches a file. */ +function wake(): void { + if (!audio && !still()) audio = new AudioContext(); +} + +/** A short tick for each landing. */ +function click(): void { + if (!audio) return; + const t = audio.currentTime; + const tone = audio.createOscillator(); + const gain = audio.createGain(); + tone.type = "triangle"; + tone.frequency.value = 1320; + gain.gain.setValueAtTime(0.04, t); + gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.04); + tone.connect(gain).connect(audio.destination); + tone.start(t); + tone.stop(t + 0.05); +} diff --git a/site/src/regions.ts b/site/src/regions.ts index f6480f6..5ac36b0 100644 --- a/site/src/regions.ts +++ b/site/src/regions.ts @@ -1,38 +1,10 @@ -// One region per app, then the three props. -import { block, screen, stage, type Block } from "./iso.ts"; +// Three props: offline, open source, local files. +import { block, stage } from "./iso.ts"; import { grow } from "./beat.ts"; -import { model, type Part } from "./model.ts"; import { said, type Key } from "./text.ts"; -const RELEASE = "https://github.com/ZacharyZhang-NY/ElyOffice/releases/download/v0.1.0"; - const PAPER = "#f4f4f2"; const DESK = "#e6e6e2"; -const SCREEN = "#3a3a3c"; -const SHOT = [1440, 810]; - -/** A part's blocks moved to corner (x, y). */ -function moved(part: Part, x: number, y: number): Required[] { - const left = Math.min(...part.blocks.map((b) => b.x)); - const back = Math.min(...part.blocks.map((b) => b.y)); - const floor = Math.min(...part.blocks.map((b) => b.z)); - const snap = (v: number) => Math.round(v * 1000) / 1000; - return part.blocks.map((b) => ({ ...b, x: snap(b.x - left + x), y: snap(b.y - back + y), z: snap(b.z - floor + 0.4) })); -} - -/** A plinth, the app's screen, and its building. */ -function scene(part: Part): SVGSVGElement { - const plinth = block({ x: 0, y: 0, w: 11.6, d: 5, h: 0.4, tone: DESK }); - const slab = block({ x: 0.4, y: 1.2, z: 0.4, w: 7.2, d: 0.25, h: 4.05, tone: SCREEN }); - screen(slab, `/shots/${part.name.slice(3).toLowerCase()}.jpg`, SHOT[0], SHOT[1]); - const house = moved(part, 8.4, 1.2).map(block); - grow([[plinth], [slab], house]); - const svg = stage([plinth, slab, ...house]); - svg.classList.add("grow"); - svg.setAttribute("role", "img"); - svg.dataset.shot = part.name; - return svg; -} const t = (x: number, y: number, z: number, w: number, d: number, h: number, tone = PAPER) => block({ x, y, z, w, d, h, tone }); @@ -78,30 +50,12 @@ function prop(blocks: SVGGElement[], line: Key): HTMLElement { return cell; } -/** The app regions and the props row. */ -export function regions(): HTMLElement[] { - const apps = model.slice(1, -1).map((part) => { - const section = document.createElement("section"); - section.id = part.name.toLowerCase(); - section.className = "region"; - const h2 = document.createElement("h2"); - h2.textContent = part.name; - const get = said("a", "download"); - get.className = "get"; - get.href = `${RELEASE}/${part.name}-0.1.0-arm64.dmg`; - const text = document.createElement("div"); - text.append(h2, said("p", part.name as Key), get); - section.append(scene(part), text); - return section; - }); - const props = document.createElement("section"); - props.className = "props"; - props.append( - prop(cable(), "offline"), - prop(box(), "open"), - prop(folder(), "local"), - ); - return [...apps, props]; +/** The props row. */ +export function props(): HTMLElement { + const row = document.createElement("section"); + row.className = "props"; + row.append(prop(cable(), "offline"), prop(box(), "open"), prop(folder(), "local")); + return row; } /** Starts each model's growth when it scrolls near. */ diff --git a/site/src/rhythm.css b/site/src/rhythm.css new file mode 100644 index 0000000..f3a82e7 --- /dev/null +++ b/site/src/rhythm.css @@ -0,0 +1,75 @@ +/* The speaker shows on intent and stays while it plays. */ +.stage .block.speaker { + animation: none; + opacity: 0; + transition: opacity 0.2s ease-out; +} + +.model:has(.stage:hover, .speaker-button:hover) .speaker, +.model:focus-within .speaker, +.model.playing .speaker { + opacity: 1; +} + +@media (hover: none) { + .stage .block.speaker { + opacity: 1; + } +} + +.speaker-button { + position: absolute; + padding: 0; + border: 0; + border-radius: 6px; + background: none; + cursor: pointer; +} + +.speaker-button:focus-visible { + outline: 2px solid #3d7fe5; + outline-offset: 4px; +} + +.speaker-button span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* Buildings hop by their faces; translate stays touch's. */ +.dance > .face { + transform: translateY(var(--hop, 0px)); +} + +.pulse { + fill: color-mix(in oklab, var(--ink), white 60%); +} + +/* The desk cell under the pointer rises a little. */ +.stage .block.tile { + animation: none; + translate: 0 -6px; + transition: translate 0.12s ease-out; +} + +@starting-style { + .stage .block.tile { + translate: 0 0; + } +} + +/* Still, the woofer stays pushed out while it plays. */ +@media (prefers-reduced-motion: reduce) { + .stage .block.speaker, + .stage .block.tile { + transition: none; + } + + .model.playing .woofer { + translate: -4.85px 2.8px; + } +} diff --git a/site/src/rhythm.ts b/site/src/rhythm.ts new file mode 100644 index 0000000..ad169f0 --- /dev/null +++ b/site/src/rhythm.ts @@ -0,0 +1,284 @@ +// A desk speaker; the buildings dance to its music. +import "./rhythm.css"; +import { block, order, project, UNIT, type Block } from "./iso.ts"; +import { ACCENT, model } from "./model.ts"; +import { NIGHT } from "./night.ts"; +import { slab } from "./tour.ts"; +import { TEXT, type Key } from "./text.ts"; + +type Box = Required; + +const SVG = "http://www.w3.org/2000/svg"; +const BEAT = 60 / 112; +const STEP = BEAT / 4; +/** Takeoff on the beat, landing on the eighth. */ +const HOP = 2 * STEP; +const COS = Math.cos(Math.PI / 6); +const SIN = Math.sin(Math.PI / 6); +const DESK = model[0].blocks[0]; +const TOP = DESK.z + DESK.h; +const APPS = model.slice(1, -1).map((p) => p.name); +const ROADS = model[model.length - 1].blocks; +/** Every box the stage draws: the model and the screens. */ +const DRAWN = [...model.flatMap((p) => p.blocks), ...APPS.flatMap(slab)]; + +/** Cabinet with two cones on its front-left face. */ +const CABINET: Box = { x: 13, y: 8, z: TOP, w: 1.4, d: 1.2, h: 1.9, tone: "#3a3a3c" }; +const CONE = "#e6e6e2"; +const cone = (z: number, s: number, out = 0): Box => ({ + x: CABINET.x + (CABINET.w - s) / 2, y: CABINET.y + CABINET.d + out, z: TOP + z, w: s, d: 0.14, h: s, tone: CONE, +}); +const WOOFER = cone(0.25, 0.9); +const CAP = cone(0.5, 0.4, 0.14); +const TWEETER = cone(1.35, 0.4); +/** How far the woofer moves at full level, in cells. */ +const THROW = 0.2; + +/** Cuts a road into `n` cells along one axis. */ +function cut(road: Box, n: number, axis: "x" | "y"): Box[] { + const long = axis === "x" ? road.w : road.d; + return Array.from({ length: n }, (_, i) => { + const at = road[axis] + (long * i) / n; + return axis === "x" ? { ...road, x: at, w: long / n } : { ...road, y: at, d: long / n }; + }); +} + +/** Light runs along these cells: to ElyPDF, and to ElyTree. */ +const ROUTES = [cut(ROADS[3], 11, "x"), [...cut(ROADS[4], 2, "y"), ...cut(ROADS[5], 3, "x")]]; + +/** Footprints a desk cell must not touch. */ +const TAKEN = [...DRAWN.filter((b) => b !== DESK), CABINET, WOOFER, CAP, TWEETER]; + +/** Adds the speaker; the desk dances to its music. */ +export function rhythm(host: HTMLElement, svg: SVGSVGElement): void { + const specs = locate(svg); + const buildings = APPS.map((name) => { + const blocks = [...svg.querySelectorAll(`.block[data-part="${name}"]`)]; + if (!blocks.length) throw new Error(`${name} has no blocks; mount after touch()`); + for (const b of blocks) b.classList.add("dance"); + return { blocks, lift: 0 }; + }); + const roads = ROUTES.map((cells) => + cells.map((cell) => ({ cell, g: owner(specs, ROADS.find((r) => inside(cell, r))!) })), + ); + + const speaker = [CABINET, WOOFER, CAP, TWEETER].map((spec) => { + const g = block(spec); + paint(g, spec.tone); + g.classList.add("speaker"); + insert(svg, specs, g, spec); + return g; + }); + const woofer = speaker.slice(1, 3); + for (const g of woofer) g.classList.add("woofer"); + + const audio = document.createElement("audio"); + audio.preload = "none"; + for (const [src, type] of [["/music/ely.ogg", "audio/ogg; codecs=opus"], ["/music/ely.m4a", "audio/mp4"]]) { + const source = document.createElement("source"); + source.src = src; + source.type = type; + audio.append(source); + } + + const button = document.createElement("button"); + button.type = "button"; + button.className = "speaker-button"; + const name = document.createElement("span"); + button.append(name); + // The button rides the stage's camera, next to it. + const world = svg.parentElement; + if (!world || getComputedStyle(world).position === "static") throw new Error("stage needs a positioned parent"); + host.append(audio); + world.append(button); + const label = (key: Key) => { + name.dataset.t = key; + name.textContent = TEXT[document.documentElement.lang === "zh-CN" ? "zh" : "en"][key]; + }; + label("play"); + + const view = svg.viewBox.baseVal; + const corners = [CABINET, CAP].flatMap((b) => + [0, 1].flatMap((i) => [0, 1].flatMap((j) => [0, 1].map((k) => project(b.x + i * b.w, b.y + j * b.d, b.z + k * b.h)))), + ); + const [xs, ys] = [corners.map((c) => c[0]), corners.map((c) => c[1])]; + button.style.left = `${Math.min(...xs) - view.x}px`; + button.style.top = `${Math.min(...ys) - view.y}px`; + button.style.width = `${Math.max(...xs) - Math.min(...xs)}px`; + button.style.height = `${Math.max(...ys) - Math.min(...ys)}px`; + + let level: { analyser: AnalyserNode; data: Float32Array; peak: number } | null = null; + let context: AudioContext | null = null; + button.addEventListener("click", async () => { + if (!audio.paused) return audio.pause(); + if (!context) { + context = new AudioContext(); + const analyser = context.createAnalyser(); + analyser.fftSize = 512; + context.createMediaElementSource(audio).connect(analyser).connect(context.destination); + level = { analyser, data: new Float32Array(analyser.fftSize), peak: 0 }; + } + await context.resume(); + await audio.play(); + }); + + const still = matchMedia("(prefers-reduced-motion: reduce)"); + let frame = 0; + let clock = { media: -1, wall: 0 }; + + const pulses = ROUTES.map(() => { + const p = document.createElementNS(SVG, "polygon"); + p.setAttribute("class", "pulse"); + return p; + }); + + const tick = (now: number) => { + frame = requestAnimationFrame(tick); + // currentTime updates coarsely; extrapolate between updates. + if (audio.currentTime !== clock.media) clock = { media: audio.currentTime, wall: now }; + const t = clock.media + (now - clock.wall) / 1000; + const n = Math.floor(t / BEAT); + const into = (t - n * BEAT) / HOP; + const lift = into < 1 ? 4 * into * (1 - into) : 0; + const bar = Math.floor(n / 4); + const who = n % 4 === 0 ? -1 : (bar * 3 + (n % 4) - 1) % APPS.length; + buildings.forEach((b, i) => hop(b, who < 0 || who === i ? lift : 0)); + const cell = Math.floor((t - bar * 4 * BEAT) / STEP); + roads.forEach((route, i) => pulse(pulses[i], route[cell] ?? null)); + if (!level) throw new Error("playing without an analyser"); + level.analyser.getFloatTimeDomainData(level.data); + const peak = level.data.reduce((m, v) => Math.max(m, Math.abs(v)), 0); + level.peak = Math.max(peak, level.peak * 0.8); + for (const g of woofer) push(g, Math.min(1, level.peak * 1.5) * THROW); + }; + + const run = () => { + const go = !audio.paused && !still.matches && document.visibilityState === "visible"; + if (go && !frame) frame = requestAnimationFrame(tick); + if (go) return; + cancelAnimationFrame(frame); + frame = 0; + for (const b of buildings) hop(b, 0); + for (const p of pulses) p.remove(); + for (const g of woofer) g.style.removeProperty("translate"); + }; + + const playing = (on: boolean) => { + host.classList.toggle("playing", on); + label(on ? "pause" : "play"); + for (const g of [speaker[1], speaker[3]]) paint(g, on ? ACCENT : CONE); + if (on) lay(null); + run(); + }; + audio.addEventListener("play", () => playing(true)); + audio.addEventListener("pause", () => playing(false)); + audio.addEventListener("ended", () => playing(false)); + still.addEventListener("change", run); + document.addEventListener("visibilitychange", run); + + let tile: { at: string; g: SVGGElement | null } = { at: "", g: null }; + const lay = (cell: [number, number] | null) => { + const at = cell ? cell.join() : ""; + if (at === tile.at) return; + tile.g?.remove(); + if (tile.g) specs.delete(tile.g); + tile = { at, g: null }; + if (!cell) return; + const spec: Box = { x: cell[0], y: cell[1], z: TOP, w: 1, d: 1, h: 0.12, tone: "#f4f4f2" }; + if (spec.x < 0 || spec.y < 0 || spec.x >= DESK.w || spec.y >= DESK.d) return; + if (TAKEN.some((b) => b.x < spec.x + 1 && spec.x < b.x + b.w && b.y < spec.y + 1 && spec.y < b.y + b.d)) return; + tile.g = block(spec); + paint(tile.g, spec.tone); + tile.g.classList.add("tile"); + insert(svg, specs, tile.g, spec); + }; + svg.addEventListener("pointermove", (e) => { + const ground = e.target instanceof SVGElement && e.target.dataset.part === ""; + if (e.pointerType !== "mouse" || !audio.paused || !ground) return lay(null); + const ctm = svg.getScreenCTM(); + if (!ctm) throw new Error("stage is not rendered"); + const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse()); + // Invert the projection on the desk's top plane. + const a = p.x / (COS * UNIT); + const b = (p.y / UNIT + TOP) / SIN; + lay([Math.floor((a + b) / 2), Math.floor((b - a) / 2)]); + }); + svg.addEventListener("pointerleave", () => lay(null)); +} + +/** Sets a block's tone and its night tone. */ +function paint(g: SVGGElement, tone: string) { + const dark = NIGHT[tone]; + if (!dark) throw new Error(`no night tone for ${tone}`); + g.style.setProperty("--tone", tone); + g.style.setProperty("--tone-dark", dark); +} + +/** Lifts a building by `lift` cells. */ +function hop(b: { blocks: SVGGElement[]; lift: number }, lift: number) { + if (lift === b.lift) return; + b.lift = lift; + for (const g of b.blocks) { + if (lift) g.style.setProperty("--hop", `${(-lift * UNIT).toFixed(1)}px`); + else g.style.removeProperty("--hop"); + } +} + +/** Lights one road cell, or none. */ +function pulse(p: SVGPolygonElement, at: { cell: Box; g: SVGGElement } | null) { + if (!at) return p.remove(); + const { x, y, w, d } = at.cell; + const z = at.cell.z + at.cell.h; + const points = [project(x, y, z), project(x + w, y, z), project(x + w, y + d, z), project(x, y + d, z)]; + p.setAttribute("points", points.map(([px, py]) => `${px.toFixed(2)},${py.toFixed(2)}`).join(" ")); + if (p.parentNode !== at.g) at.g.append(p); +} + +/** Pushes the woofer out along its face normal. */ +function push(g: SVGGElement, cells: number) { + g.style.translate = `${(-COS * UNIT * cells).toFixed(2)}px ${(SIN * UNIT * cells).toFixed(2)}px`; +} + +function inside(a: Box, b: Box): boolean { + return a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w + 1e-9 && a.y + a.d <= b.y + b.d + 1e-9; +} + +/** Matches drawn blocks to model boxes by top face. */ +function locate(svg: SVGSVGElement): Map { + const near = (pt: DOMPoint, [x, y]: [number, number]) => Math.abs(pt.x - x) < 0.02 && Math.abs(pt.y - y) < 0.02; + const found = new Map(); + for (const g of svg.querySelectorAll(".block")) { + const top = g.querySelector(".face.top"); + if (!top) throw new Error("block has no top"); + const spec = DRAWN.find( + (b) => + near(top.points[0], project(b.x, b.y, b.z + b.h)) && near(top.points[2], project(b.x + b.w, b.y + b.d, b.z + b.h)), + ); + if (!spec) throw new Error("stage has a block the model lacks"); + found.set(g, spec); + } + return found; +} + +function owner(specs: Map, spec: Box): SVGGElement { + const g = [...specs].find(([, s]) => s === spec)?.[0]; + if (!g) throw new Error("road is not drawn"); + return g; +} + +/** Inserts `g` after what it covers, before its coverers. */ +function insert(svg: SVGSVGElement, specs: Map, g: SVGGElement, spec: Box) { + const drawn = [...svg.querySelectorAll(".block")]; + let last = -1; + let first = drawn.length; + drawn.forEach((b, i) => { + const s = specs.get(b); + if (!s) throw new Error("stage has an unknown block"); + if (order([spec, s])[0] === 1) last = i; + else if (order([s, spec])[0] === 1) first = Math.min(first, i); + }); + if (last >= first) throw new Error("no paint slot for the block"); + if (last >= 0) drawn[last].after(g); + else drawn[first].before(g); + specs.set(g, spec); +} diff --git a/site/src/style.css b/site/src/style.css index c47dbe1..c4f0114 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -2,9 +2,11 @@ color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", sans-serif; color: #1d1d1f; - background: #fbfbfa; + --page: #fbfbfa; + background: var(--page); --quiet: #6e6e73; --accent: #3d7fe5; + --link: #2a6ad0; } body { @@ -35,7 +37,7 @@ p { } a { - color: var(--accent); + color: var(--link); text-decoration: none; } @@ -50,7 +52,7 @@ button:focus-visible { display: grid; justify-items: center; gap: 1.25rem; - padding: 10rem 1.5rem 9rem; + padding: 7rem 1.5rem 0; text-align: center; } @@ -58,10 +60,6 @@ button:focus-visible { font-size: 1.0625rem; } -.hero .model { - margin-top: 6rem; -} - .stage { display: block; max-width: 100%; @@ -74,51 +72,6 @@ button:focus-visible { max-width: 100%; } -.region { - display: grid; - grid-template-columns: 1.6fr 1fr; - align-items: center; - gap: 6rem; - max-width: 72rem; - margin: 0 auto; - padding: 9rem 1.5rem; -} - -.region > svg { - width: 100%; -} - -.region:nth-child(even) { - grid-template-columns: 1fr 1.6fr; -} - -.region:nth-child(even) > svg { - order: 2; -} - -.region div { - display: grid; - gap: 1rem; - justify-items: start; -} - -.get { - font-size: 1.0625rem; - opacity: 0; - transition: opacity 0.15s ease-out; -} - -.region:hover .get, -.region:focus-within .get { - opacity: 1; -} - -@media (hover: none) { - .get { - opacity: 1; - } -} - .props { display: grid; grid-template-columns: repeat(3, 1fr); @@ -186,23 +139,14 @@ body:has(.hero:hover) .lang { } @media (max-width: 760px) { - .region, .props { grid-template-columns: 1fr; gap: 3rem; padding: 6rem 1.5rem; } - .region:nth-child(even) { - grid-template-columns: 1fr; - } - - .region:nth-child(even) > svg { - order: 0; - } - .hero { - padding: 7rem 1.5rem 6rem; + padding-top: 5rem; } } @@ -235,7 +179,6 @@ body:has(.hero:hover) .lang { @media (prefers-reduced-motion: reduce) { .caption, - .get, .lang { transition: none; } diff --git a/site/src/text.ts b/site/src/text.ts index 2acb763..93722d3 100644 --- a/site/src/text.ts +++ b/site/src/text.ts @@ -19,6 +19,23 @@ const en = { source: "Source code", shot: "screenshot", other: "中文", + roads: "Drag a file down the road.", + fileDoc: "ElyDoc file", + fileTable: "ElyTable file", + fileShow: "ElyShow file", + fileMark: "ElyMark file", + moveHint: "Arrow keys move it along the road.", + toTree: "Open a Markdown file's history in ElyTree.", + toPdf: "Export to PDF, open it in ElyPDF.", + putBack: "Put the files back", + play: "Play music", + pause: "Pause music", + filmPlay: "Play the film", + filmLine: "90 seconds. Real use, no mockups.", + dark: "Switch to dark", + light: "Switch to light", + clipPlay: "Play", + clipPause: "Pause", }; const zh: typeof en = { @@ -39,6 +56,23 @@ const zh: typeof en = { source: "源代码", shot: "截图", other: "English", + roads: "把文件沿着路拖过去。", + fileDoc: "ElyDoc 文件", + fileTable: "ElyTable 文件", + fileShow: "ElyShow 文件", + fileMark: "ElyMark 文件", + moveHint: "方向键沿路移动它。", + toTree: "在 ElyTree 里看这份 Markdown 的历史。", + toPdf: "导出 PDF,在 ElyPDF 打开。", + putBack: "把文件放回去", + play: "播放音乐", + pause: "暂停音乐", + filmPlay: "播放影片", + filmLine: "90 秒,全部是真实操作。", + dark: "切换到深色", + light: "切换到浅色", + clipPlay: "播放", + clipPause: "暂停", }; export type Key = keyof typeof en; diff --git a/site/src/tour.css b/site/src/tour.css new file mode 100644 index 0000000..198dc9e --- /dev/null +++ b/site/src/tour.css @@ -0,0 +1,248 @@ +/* One stage pins while the camera visits each app. */ +.tour { + --span: 100svh; + position: relative; + height: calc(var(--span) * 8); +} + +@media (prefers-reduced-motion: no-preference) { + html { + scroll-behavior: smooth; + } +} + +.pin { + position: sticky; + top: 0; + height: var(--span); + overflow: clip; +} + +.pin .hero { + position: absolute; + inset: 0 0 auto; + z-index: 1; +} + +.pin .model { + position: absolute; + inset: 0; + max-width: none; +} + +.world { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; +} + +.world .stage { + max-width: none; +} + +.stop { + position: absolute; + left: 0; + width: 1px; + height: 1px; +} + +/* Screen rows land on sixteenths as the reader arrives. */ +.grow .block.slab { + animation: none; +} + +.block.slab { + opacity: 0; + visibility: hidden; + translate: 0 calc(-1 * var(--step)); + transition: + opacity 0.1s ease-out, + translate 0.1s ease-out, + visibility 0s 0.1s; +} + +.block.slab.up { + opacity: 1; + visibility: visible; + translate: 0 0; + transition: + opacity 0.2s cubic-bezier(0.2, 0.8, 0.3, 1) var(--at), + translate 0.2s cubic-bezier(0.2, 0.8, 0.3, 1) var(--at); +} + +.clip { + position: absolute; + top: 0; + left: 0; + width: 320px; + height: 180px; + object-fit: fill; + transform-origin: 0 0; + cursor: pointer; + opacity: 0; + visibility: hidden; + transition: + opacity 0.1s ease-out, + visibility 0s 0.1s; +} + +.clip.up { + opacity: 1; + visibility: visible; + transition: opacity 0.2s ease-out var(--at); +} + +/* The copy owns a plain strip beside the model. */ +.copy { + position: absolute; + inset: 0 auto 0 0; + z-index: 1; + display: grid; + align-items: center; + box-sizing: border-box; + width: 38%; + padding: 0 3vw 0 7vw; + background: var(--page); + translate: -100% 0; + transition: translate 0.2s cubic-bezier(0.2, 0.8, 0.3, 1); +} + +.touring .copy { + translate: 0 0; +} + +:root[data-theme="dark"] { + --page: #252525; +} + +.copy article { + display: grid; + grid-area: 1 / 1; + gap: 1rem; + justify-items: start; + align-content: center; + opacity: 0; + visibility: hidden; + transition: + opacity 0.15s ease-out, + visibility 0s 0.15s; +} + +.copy article.current { + opacity: 1; + visibility: visible; + transition: opacity 0.2s ease-out 0.1s; +} + +.acts { + display: flex; + gap: 2rem; + align-items: baseline; +} + +.get, +.toggle-clip { + font-size: 1.0625rem; + opacity: 0; + transition: opacity 0.15s ease-out; +} + +.toggle-clip { + padding: 0; + border: 0; + background: none; + font: inherit; + font-size: 1.0625rem; + font-weight: 300; + color: var(--quiet); + cursor: pointer; +} + +.copy:hover :is(.get, .toggle-clip), +.copy article:focus-within :is(.get, .toggle-clip) { + opacity: 1; +} + +.rail { + position: absolute; + top: 50%; + right: 2rem; + z-index: 1; + display: grid; + gap: 0.5rem; + translate: 0 -50%; + opacity: 0; + visibility: hidden; + transition: + opacity 0.15s ease-out, + visibility 0s 0.15s; +} + +.touring .rail { + opacity: 1; + visibility: visible; + transition: opacity 0.2s ease-out; +} + +.rail a { + display: block; + padding: 0.25rem; +} + +.rail .stage { + width: 1.25rem; +} + +.rail .block { + transition: translate 0.15s ease-out; +} + +.rail a:hover .block, +.rail a:focus-visible .block { + translate: 0 -4px; +} + +.rail a[aria-current] .block { + --ink: var(--accent); +} + +@media (hover: none) { + .get, + .toggle-clip { + opacity: 1; + } +} + +@media (max-width: 760px) { + .copy { + inset: 58% 0 0; + align-items: start; + width: auto; + padding: 2rem 1.5rem 0; + translate: 0 100%; + } + + .rail { + top: 30%; + right: 0.75rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .copy, + .block.slab, + .block.slab.up, + .clip, + .clip.up, + .copy article, + .copy article.current, + .rail, + .touring .rail, + .rail .block, + .get, + .toggle-clip { + transition: none; + } +} diff --git a/site/src/tour.ts b/site/src/tour.ts new file mode 100644 index 0000000..8a36d2a --- /dev/null +++ b/site/src/tour.ts @@ -0,0 +1,348 @@ +// The camera glides from the desk into each app. +import { block, project, stage, type Block } from "./iso.ts"; +import { grow } from "./beat.ts"; +import { model } from "./model.ts"; +import { touch } from "./touch.ts"; +import { said, TEXT, type Key } from "./text.ts"; + +const RELEASE = "https://github.com/ZacharyZhang-NY/ElyOffice/releases/download/v0.1.0"; +const SCREEN = "#3a3a3c"; +const DESK = "#e6e6e2"; +const STEP = 60 / 112 / 4; +const ROWS = 4; +/** Share of each span the camera holds still, per side. */ +const HOLD = 0.35; +const CLIP = [320, 180]; +const NARROW = 760; +const TOP = model[0].blocks[0].h; + +const apps = model.slice(1, -1); +const last = apps.length + 1; + +/** Each app's screen: corner x, y, post height, width. */ +const SPOTS: Record = { + ElyDoc: [0.1, 0, 0.5, 4], + ElyTable: [4.4, 0, 1.6, 4], + ElyShow: [10.8, 0.5, 2.2, 4], + ElyPDF: [12, 2.3, 0.3, 4], + ElyTree: [5.9, 5.6, 0, 4], + ElyMark: [0.7, 4.3, 0.1, 4], +}; + +/** An app's post, then its screen rows, bottom up. */ +export function slab(name: string): Required[] { + const spot = SPOTS[name]; + if (!spot) throw new Error(`no screen for ${name}`); + const [x, y, post, w] = spot; + const d = 0.2; + const row = (w * 9) / 16 / ROWS; + const out: Required[] = []; + if (post > 0) out.push({ x: x + w / 2 - 0.1, y, z: TOP, w: 0.2, d, h: post, tone: SCREEN }); + // Each row sits on the last; sums keep faces flush. + let z = TOP + post; + for (let i = 0; i < ROWS; i++) { + out.push({ x, y, z, w, d, h: row, tone: SCREEN }); + z += row; + } + return out; +} + +type Box = [number, number, number, number]; + +/** Screen bounds of blocks, in SVG units. */ +function bounds(blocks: Required[]): Box { + const points = blocks.flatMap((b) => + [0, 1].flatMap((i) => [0, 1].flatMap((j) => [0, 1].map((k) => project(b.x + i * b.w, b.y + j * b.d, b.z + k * b.h)))), + ); + const xs = points.map((p) => p[0]); + const ys = points.map((p) => p[1]); + return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]; +} + +/** World point c drawn at screen point a, scale s. */ +interface Cam { + s: number; + cx: number; + cy: number; + ax: number; + ay: number; +} + +/** Fits `box` into the screen rectangle `area`. */ +function fit(box: Box, area: Box, most: number): Cam { + const s = Math.min((area[2] - area[0]) / (box[2] - box[0]), (area[3] - area[1]) / (box[3] - box[1]), most); + return { s, cx: (box[0] + box[2]) / 2, cy: (box[1] + box[3]) / 2, ax: (area[0] + area[2]) / 2, ay: (area[1] + area[3]) / 2 }; +} + +const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); +const mix = (a: number, b: number, t: number) => a + (b - a) * t; + +function between(a: Cam, b: Cam, t: number): Cam { + return { + s: Math.exp(mix(Math.log(a.s), Math.log(b.s), t)), + cx: mix(a.cx, b.cx, t), + cy: mix(a.cy, b.cy, t), + ax: mix(a.ax, b.ax, t), + ay: mix(a.ay, b.ay, t), + }; +} + +/** Maps a clip's box onto the screen's left face. */ +function face(rows: Required[], origin: [number, number]): string { + const [first, top] = [rows[0], rows[rows.length - 1]]; + const y = first.y + first.d; + const z = top.z + top.h; + const corner = project(first.x, y, z); + const across = project(first.x + first.w, y, z); + const down = project(first.x, y, first.z); + const m = [ + (across[0] - corner[0]) / CLIP[0], + (across[1] - corner[1]) / CLIP[0], + (down[0] - corner[0]) / CLIP[1], + (down[1] - corner[1]) / CLIP[1], + corner[0] - origin[0], + corner[1] - origin[1], + ]; + return `matrix(${m.join(", ")})`; +} + +/** A clip that loads only while its app is current. */ +function clip(name: string, transform: string, lands: number): HTMLVideoElement { + const video = document.createElement("video"); + video.className = "clip"; + video.muted = true; + video.defaultMuted = true; + video.loop = true; + video.playsInline = true; + video.preload = "none"; + video.dataset.src = `/clips/${name.slice(3).toLowerCase()}`; + video.style.transform = transform; + video.style.setProperty("--at", `${lands.toFixed(3)}s`); + video.setAttribute("aria-hidden", "true"); + return video; +} + +function start(video: HTMLVideoElement): void { + if (!video.querySelector("source")) { + for (const kind of ["webm", "mp4"]) { + const source = document.createElement("source"); + source.src = `${video.dataset.src}.${kind}`; + source.type = `video/${kind}`; + video.append(source); + } + } + video.play().catch((e: DOMException) => { + // A pause during loading interrupts play; that is expected. + if (e.name !== "AbortError") throw e; + }); +} + +function stop(video: HTMLVideoElement): void { + if (!video.querySelector("source")) return; + video.pause(); + video.replaceChildren(); + video.load(); +} + +/** A rail cube for one app. */ +function cube(): SVGSVGElement { + const svg = stage([block({ x: 0, y: 0, w: 1, d: 1, h: 0.5, tone: DESK })]); + svg.setAttribute("aria-hidden", "true"); + return svg; +} + +const lang = () => (document.documentElement.lang.startsWith("zh") ? "zh" : "en"); + +/** Sets a button's keyed text now and on language change. */ +function label(el: HTMLElement, key: Key): void { + el.dataset.t = key; + el.textContent = TEXT[lang()][key]; +} + +/** The pinned tour, starting with `hero` over the whole desk. */ +export function tour(hero: HTMLElement): { section: HTMLElement; host: HTMLElement; svg: SVGSVGElement } { + const calm = matchMedia("(prefers-reduced-motion: reduce)"); + const still = () => calm.matches; + const built = model.map((part) => part.blocks.map(block)); + grow(built); + const screens = apps.map((p) => slab(p.name)); + const shown = screens.map((list, a) => + list.map((b, i) => { + const g = block(b); + g.classList.add("slab"); + g.dataset.app = String(a); + g.dataset.row = String(i - list.length + ROWS); + g.style.setProperty("--at", `${(i * STEP).toFixed(3)}s`); + return g; + }), + ); + const svg = stage([...built.flat(), ...shown.flat()]); + svg.classList.add("grow", "seen"); + svg.setAttribute("aria-hidden", "true"); + const view = svg.viewBox.baseVal; + // Exact size keeps clips on their faces when zoomed. + svg.style.width = `${view.width}px`; + svg.style.height = `${view.height}px`; + const origin: [number, number] = [view.x, view.y]; + + const world = document.createElement("div"); + world.className = "world"; + world.append(svg); + const host = document.createElement("div"); + host.className = "model"; + host.append(world); + + // Screens stay out of the hit layer. + const marks = shown.flat().map((g) => { + const mark = document.createComment(""); + g.replaceWith(mark); + return [mark, g] as const; + }); + touch(host, svg, new Map(apps.map((p, i) => [p.name, built[i + 1]]))); + for (const [mark, g] of marks) mark.replaceWith(g); + + const clips = apps.map((p, i) => { + const rows = screens[i].filter((b) => b.w > 0.2); + return clip(p.name, face(rows, origin), shown[i].length * STEP); + }); + world.append(...clips); + + const plays = clips.map((video) => { + const button = document.createElement("button"); + button.className = "toggle-clip"; + const sync = () => label(button, video.paused ? "clipPlay" : "clipPause"); + video.addEventListener("play", sync); + video.addEventListener("pause", sync); + button.addEventListener("click", () => (video.paused ? start(video) : video.pause())); + video.addEventListener("click", () => (video.paused ? start(video) : video.pause())); + sync(); + return button; + }); + + const copy = document.createElement("div"); + copy.className = "copy"; + const articles = apps.map((p, i) => { + const article = document.createElement("article"); + const h2 = document.createElement("h2"); + h2.textContent = p.name; + const get = said("a", "download"); + get.className = "get"; + get.href = `${RELEASE}/${p.name}-0.1.0-arm64.dmg`; + const acts = document.createElement("div"); + acts.className = "acts"; + acts.append(get, plays[i]); + article.append(h2, said("p", p.name as Key), acts); + return article; + }); + copy.append(...articles); + + const rail = document.createElement("div"); + rail.className = "rail"; + const steps = apps.map((p) => { + const a = document.createElement("a"); + a.href = `#${p.name.toLowerCase()}`; + a.setAttribute("aria-label", p.name); + a.append(cube()); + return a; + }); + rail.append(...steps); + + const pin = document.createElement("div"); + pin.className = "pin"; + hero.classList.add("hero"); + pin.append(hero, host, copy, rail); + const section = document.createElement("section"); + section.className = "tour"; + const stops = apps.map((p, i) => { + const stop = document.createElement("div"); + stop.id = p.name.toLowerCase(); + stop.className = "stop"; + stop.style.top = `calc(var(--span) * ${i + 1})`; + return stop; + }); + section.append(pin, ...stops); + + const desk = bounds(model.flatMap((p) => p.blocks)); + const homes = apps.map((p, i) => bounds([...p.blocks, ...screens[i]])); + let top = 0; + let span = 1; + let cams: Cam[] = []; + let current = -1; + let queued = false; + + const measure = () => { + const [w, h] = [pin.clientWidth, pin.clientHeight]; + top = section.getBoundingClientRect().top + scrollY; + span = h; + const below = hero.offsetTop + hero.offsetHeight + 32; + const whole = fit(desk, [24, below, w - 24, h - 32], 2); + const area: Box = w <= NARROW ? [16, h * 0.04, w - 52, h * 0.56] : [w * 0.42, h * 0.08, w - 88, h * 0.92]; + cams = [whole, ...homes.map((box) => fit(box, area, 8)), whole]; + }; + + const choose = (next: number) => { + if (next === current) return; + if (current >= 0) { + for (const g of shown[current]) g.classList.remove("up"); + clips[current].classList.remove("up"); + stop(clips[current]); + articles[current].classList.remove("current"); + steps[current].removeAttribute("aria-current"); + } + current = next; + pin.classList.toggle("touring", current >= 0); + if (current < 0) return; + for (const g of shown[current]) g.classList.add("up"); + for (const i of [current - 1, current, current + 1]) { + if (clips[i] && !clips[i].poster) clips[i].poster = `${clips[i].dataset.src}.jpg`; + } + clips[current].classList.add("up"); + articles[current].classList.add("current"); + steps[current].setAttribute("aria-current", "step"); + if (!still() && !document.hidden) start(clips[current]); + }; + + const render = () => { + queued = false; + // The first measure comes with the first layout. + if (!cams.length) return; + const p = Math.min(Math.max((scrollY - top) / span, 0), last); + const k = Math.min(Math.floor(p), last - 1); + const f = p - k; + let cam: Cam; + if (still()) cam = cams[Math.round(p)]; + // Leaving the hero, the desk moves at once. + else if (k === 0 && f < 1 - HOLD) cam = between(cams[0], cams[1], ease(f / (1 - HOLD))); + else if (f <= HOLD) cam = cams[k]; + else if (f >= 1 - HOLD) cam = cams[k + 1]; + else cam = between(cams[k], cams[k + 1], ease((f - HOLD) / (1 - 2 * HOLD))); + const tx = cam.ax - cam.s * (cam.cx - origin[0]); + const ty = cam.ay - cam.s * (cam.cy - origin[1]); + world.style.transform = `matrix(${cam.s}, 0, 0, ${cam.s}, ${tx}, ${ty})`; + const fade = still() ? (p < 0.5 ? 1 : 0) : Math.max(0, 1 - p / HOLD); + hero.style.opacity = String(fade); + hero.style.visibility = fade > 0 ? "" : "hidden"; + hero.style.translate = still() ? "" : `0 ${(-p * span * 0.3).toFixed(1)}px`; + const at = Math.round(p); + choose(at >= 1 && at < last ? at - 1 : -1); + }; + + const later = () => { + if (queued) return; + queued = true; + requestAnimationFrame(render); + }; + addEventListener("scroll", later, { passive: true }); + const sizes = new ResizeObserver(() => { + measure(); + later(); + }); + sizes.observe(pin); + sizes.observe(hero); + document.addEventListener("visibilitychange", () => { + if (current < 0) return; + if (document.hidden) clips[current].pause(); + else if (!still()) start(clips[current]); + }); + return { section, host, svg }; +} diff --git a/site/test/tour.test.ts b/site/test/tour.test.ts new file mode 100644 index 0000000..558a8b8 --- /dev/null +++ b/site/test/tour.test.ts @@ -0,0 +1,44 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { order, type Block } from "../src/iso.ts"; +import { model } from "../src/model.ts"; +import { slab } from "../src/tour.ts"; + +type Box = Required; +const apps = model.slice(1, -1); +const all = model.flatMap((p) => p.blocks); +const screens = apps.map((p) => slab(p.name)); + +const spans = (b: Box) => [ + [b.x - b.z - b.h, b.x + b.w - b.z], + [b.y - b.z - b.h, b.y + b.d - b.z], + [b.x - b.y - b.d, b.x + b.w - b.y], +]; +const overlap = (a: Box, b: Box) => spans(a).every(([lo, hi], i) => lo < spans(b)[i][1] && spans(b)[i][0] < hi); +const drawn = (list: Box[]) => { + const at = order(list); + return (b: Box) => at.indexOf(list.indexOf(b)); +}; + +test("the desk and every screen draw without intersections", () => { + const list = [...all, ...screens.flat()]; + assert.equal(order(list).length, list.length); +}); + +test("nothing covers a screen's face, so its clip shows true", () => { + for (const [i, p] of apps.entries()) { + const rows = screens[i].filter((b) => b.w > 0.2); + const list = [...all, ...screens[i]]; + const rank = drawn(list); + for (const row of rows) + for (const b of all) assert.ok(!(overlap(row, b) && rank(b) > rank(row)), `${p.name} covered by ${JSON.stringify(b)}`); + } +}); + +test("screens are sixteen by nine", () => { + for (const rows of screens) { + const face = rows.filter((b) => b.w > 0.2); + const h = face.reduce((s, b) => s + b.h, 0); + assert.ok(Math.abs(face[0].w / h - 16 / 9) < 1e-9); + } +}); diff --git a/site/video/web.sh b/site/video/web.sh new file mode 100755 index 0000000..cfc2604 --- /dev/null +++ b/site/video/web.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Builds web clips, posters and the launch film. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +rec="$here/out/rec" +clips="$here/../public/clips" +film="$here/../public/film" +mkdir -p "$clips" "$film" + +# name source start end +cuts=( + "doc doc 3.0 10.6" + "table table 16.0 23.6" + "show show-play 2.6 9.6" + "pdf pdf 8.8 16.3" + "tree tree 9.5 16.8" + "mark mark 5.4 13.4" +) + +fade=0.2 +size="scale=1280:720:flags=lanczos,fps=30,format=yuv420p" + +for cut in "${cuts[@]}"; do + read -r name src start end <<<"$cut" + len=$(echo "$end - $start" | bc) + at=$(echo "$len - $fade" | bc) + one=$(echo "$start + 0.05" | bc) + frames=$(echo "$len * 30 / 1" | bc) + # Last 0.2 s fades into the first frame. + graph="[0:v]trim=start=$start:end=$end,setpts=PTS-STARTPTS,$size[a]; +[0:v]trim=start=$start:end=$one,setpts=PTS-STARTPTS,$size,trim=end_frame=1,tpad=stop_mode=clone:stop_duration=$fade[b]; +[a][b]xfade=transition=fade:duration=$fade:offset=$at[v]" + ffmpeg -v error -y -i "$rec/$src.mp4" -filter_complex "$graph" -map "[v]" -frames:v "$frames" -an \ + -c:v libx264 -profile:v high -preset slow -crf 24 -pix_fmt yuv420p \ + -movflags +faststart "$clips/$name.mp4" + ffmpeg -v error -y -i "$clips/$name.mp4" -an \ + -c:v libvpx-vp9 -crf 36 -b:v 0 -row-mt 1 -deadline good -cpu-used 2 \ + "$clips/$name.webm" + ffmpeg -v error -y -i "$clips/$name.mp4" -frames:v 1 -q:v 4 "$clips/$name.jpg" +done + +src="$here/out/ElyOffice-launch.mp4" +ffmpeg -v error -y -i "$src" -vf "scale=1920:1080:flags=lanczos,format=yuv420p" \ + -c:v libx264 -profile:v high -preset slow -crf 23 \ + -c:a aac -b:a 160k -movflags +faststart "$film/ely-office.mp4" +ffmpeg -v error -y -ss 17 -i "$src" -frames:v 1 -q:v 3 "$film/poster.jpg" + +ls -l "$clips" "$film"