Site: scroll tour with live clips, beat-reactive desk and speaker, road playground, night wave, film on an isometric screen

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 00:33:25 -04:00
1 parent b6b882a8a1
commit 70cd57bd5d
46 files changed
+2708 -169

No files matched your search

+65
View File
@@ -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<string[]> {
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<HTMLVideoElement>("#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<HTMLVideoElement>("#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([]);
});
+111
View File
@@ -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<SVGGElement>(".model .block")];
document.querySelector<HTMLButtonElement>(".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<HTMLElement>("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);
});
+92
View File
@@ -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<string[]> {
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<SVGSVGElement>("#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([]);
});
});
+116
View File
@@ -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<HTMLAudioElement>(".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<SVGGElement>(".tour .block")].filter((g) =>
g.style.getPropertyValue("--hop"),
);
const moved = [...document.querySelectorAll<SVGGElement>(".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<SVGSVGElement>(".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);
});
+15 -12
View File
@@ -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");
+11 -8
View File
@@ -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<SVGSVGElement>(".hero .stage")!;
const svg = document.querySelector<SVGSVGElement>(".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$/);
+111
View File
@@ -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<HTMLElement>(".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<HTMLVideoElement>(".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<SVGGElement>(`.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);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.
Binary file not shown.
+91
View File
@@ -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;
}
}
+178
View File
@@ -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;
}
+12 -3
View File
@@ -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. */
+25 -29
View File
@@ -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<HTMLElement>("#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]);
+73
View File
@@ -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;
}
}
+163
View File
@@ -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<string, string> = {
"#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<SVGGElement>("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<HTMLElement | SVGElement>("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;
}
+91
View File
@@ -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;
}
}
+458
View File
@@ -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<string, Cell> = {
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<string, [number, number, Cell]> = {
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<string>;
}
interface Item {
el: SVGGElement;
box: () => Required<Block>;
}
/** A building moved home; accent is kept for files. */
function placed(name: string): Required<Block>[] {
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<Block> => ({
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<Block>) => {
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<string, SVGGElement>();
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<number>();
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<string, SVGGElement[]>, roads: Map<string, SVGGElement>, 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<number, SVGGElement[]>();
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<string, SVGGElement[]>): 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<string, Cell | null>([[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);
}
+8 -54
View File
@@ -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<Block>[] {
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. */
+75
View File
@@ -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;
}
}
+284
View File
@@ -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<Block>;
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<SVGGElement>(`.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<ArrayBuffer>; 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<SVGGElement, Box> {
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<SVGGElement, Box>();
for (const g of svg.querySelectorAll<SVGGElement>(".block")) {
const top = g.querySelector<SVGPolygonElement>(".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<SVGGElement, Box>, 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<SVGGElement, Box>, g: SVGGElement, spec: Box) {
const drawn = [...svg.querySelectorAll<SVGGElement>(".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);
}
+6 -63
View File
@@ -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;
}
+34
View File
@@ -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;
+248
View File
@@ -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;
}
}
+348
View File
@@ -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<string, [number, number, number, number]> = {
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<Block>[] {
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<Block>[] = [];
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<Block>[]): 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<Block>[], 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 };
}
+44
View File
@@ -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<Block>;
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);
}
});
+49
View File
@@ -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"