Site: app regions with screenshots, props, downloads, English and Chinese, narrow layout; hit layer follows draw order

This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 23:57:34 -04:00
1 parent 29e4e7f70f
commit 07719a1fd3
29 files changed
+1423 -97

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
// The film's tempo: 112 BPM, landings on sixteenths.
const BEAT = 60 / 112;
const STEP = BEAT / 4;
/** Times parts to land beat by beat, blocks on sixteenths. */
export function grow(parts: SVGGElement[][]): void {
let beat = 1;
for (const blocks of parts) {
blocks.forEach((g, i) => g.style.setProperty("--land", `${(beat * BEAT + i * STEP).toFixed(3)}s`));
beat += Math.ceil(blocks.length / 4);
}
}
+9
View File
@@ -42,6 +42,10 @@
cursor: pointer;
}
.hit-face.ground {
cursor: auto;
}
.block.lifted {
translate: 0 calc(-1 * var(--step));
}
@@ -55,3 +59,8 @@
transition: none;
}
}
/* Off-screen models wait until they scroll near. */
.grow:not(.seen) .block {
animation-play-state: paused;
}
+16
View File
@@ -110,3 +110,19 @@ export function stage(blocks: SVGGElement[]): SVGSVGElement {
svg.setAttribute("class", "stage");
return svg;
}
/** Pins an image across a block's left face. */
export function screen(g: SVGGElement, href: string, width: number, height: number): void {
const b = boxes.get(g);
if (!b) throw new Error("not a block");
const [e, f] = project(b.x, b.y + b.d, b.z + b.h);
const sx = (b.w * UNIT) / width;
const sy = (b.h * UNIT) / height;
const image = document.createElementNS(SVG, "image");
image.setAttribute("href", href);
image.setAttribute("width", String(width));
image.setAttribute("height", String(height));
image.setAttribute("preserveAspectRatio", "none");
image.setAttribute("transform", `matrix(${COS * sx} ${SIN * sx} 0 ${sy} ${e} ${f})`);
g.append(image);
}
+48 -19
View File
@@ -1,38 +1,67 @@
import "./style.css";
import "./iso.css";
import { block, stage } from "./iso.ts";
import { grow } from "./beat.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";
/** 112 BPM, the film's tempo; blocks land on sixteenths. */
const BEAT = 60 / 112;
const STEP = BEAT / 4;
const RELEASE = "https://github.com/ZacharyZhang-NY/ElyOffice/releases/download/v0.1.0";
const app = document.querySelector<HTMLElement>("#app");
if (!app) throw new Error("index.html has no #app");
const apps = model.slice(1, -1);
const parts = new Map<string, SVGGElement[]>();
const blocks: SVGGElement[] = [];
let beat = 1;
for (const part of model) {
const own = part.blocks.map((spec, i) => {
const g = block(spec);
g.style.setProperty("--land", `${(beat * BEAT + i * STEP).toFixed(3)}s`);
return g;
});
if (apps.includes(part)) parts.set(part.name, own);
blocks.push(...own);
beat += Math.ceil(part.blocks.length / 4);
}
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(blocks);
svg.classList.add("grow");
const svg = stage(built.flat());
svg.classList.add("grow", "seen");
svg.setAttribute("aria-hidden", "true");
host.append(svg);
app.append(title, host);
hero.append(title, said("p", "tagline"), said("p", "platforms"), host);
touch(host, svg, parts);
const sections = regions();
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) {
const a = document.createElement("a");
a.href = `${RELEASE}/${p.name}-0.1.0-arm64.dmg`;
a.textContent = p.name;
const li = document.createElement("li");
li.append(a);
list.append(li);
}
const source = said("a", "source");
source.href = `${RELEASE}/ElyOffice-0.1.0-source.tar.gz`;
const li = document.createElement("li");
li.append(source);
list.append(li);
footer.append(version, said("p", "platforms"), said("p", "needs"), said("p", "unsigned"), list);
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";
speak(lang);
});
const main = document.createElement("main");
main.append(...sections);
app.append(toggle, hero, main, footer);
speak(lang);
reveal(sections);
+120
View File
@@ -0,0 +1,120 @@
// One region per app, then the three props.
import { block, screen, stage, type Block } 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 });
/** Offline: a plug pulled from its socket. */
const cable = () => [
t(0, 0, 0, 7, 3, 0.3, DESK),
t(0.4, 1.3, 0.3, 2.6, 0.4, 0.4),
t(3, 1.15, 0.3, 0.9, 0.7, 0.7),
t(5, 0.9, 0.3, 1.4, 1.2, 1.2),
];
/** Open source: a box with its lid laid open. */
const box = () => [
t(0, 0, 0, 7, 4, 0.3, DESK),
t(1, 0.6, 0.3, 3, 0.2, 1.6),
t(1, 0.8, 0.3, 0.2, 2.2, 1.6),
t(3.8, 0.8, 0.3, 0.2, 2.2, 1.6),
t(1.2, 0.8, 0.3, 2.6, 2.2, 0.2),
t(1, 3, 0.3, 3, 0.2, 1.6),
t(4.3, 0.6, 0.3, 2.2, 2.6, 0.12),
];
/** Local files: a folder holding one sheet. */
const folder = () => [
t(0, 0, 0, 7, 3, 0.3, DESK),
t(1, 1, 0.3, 4.6, 0.2, 2.6),
t(1, 1, 2.9, 1.6, 0.2, 0.4),
t(1.4, 1.2, 0.3, 3.8, 0.1, 2.3, "#3d7fe5"),
t(1, 1.3, 0.3, 4.6, 0.2, 2),
];
/** A prop with its line. */
function prop(blocks: SVGGElement[], line: Key): HTMLElement {
grow(blocks.map((b) => [b]));
const svg = stage(blocks);
svg.classList.add("grow");
svg.setAttribute("aria-hidden", "true");
const p = said("p", line);
const cell = document.createElement("div");
cell.className = "prop";
cell.append(svg, p);
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];
}
/** Starts each model's growth when it scrolls near. */
export function reveal(sections: HTMLElement[]): void {
const seen = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
for (const svg of e.target.querySelectorAll(".stage")) svg.classList.add("seen");
seen.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -20% 0px" },
);
for (const s of sections) seen.observe(s);
}
+185 -20
View File
@@ -3,30 +3,63 @@
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", sans-serif;
color: #1d1d1f;
background: #fbfbfa;
--quiet: #6e6e73;
--accent: #3d7fe5;
}
body {
margin: 0;
}
#app {
min-height: 100vh;
display: grid;
place-content: center;
justify-items: center;
gap: 6rem;
padding: 9rem 1.5rem;
box-sizing: border-box;
}
h1 {
h1,
h2 {
margin: 0;
font-size: clamp(3rem, 8vw, 6rem);
font-weight: 700;
letter-spacing: -0.03em;
}
h1 {
font-size: clamp(3rem, 9vw, 6.5rem);
}
h2 {
font-size: clamp(2rem, 5vw, 3.5rem);
}
p {
margin: 0;
font-size: clamp(1.0625rem, 1.6vw, 1.3125rem);
font-weight: 300;
line-height: 1.5;
color: var(--quiet);
}
a {
color: var(--accent);
text-decoration: none;
}
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 2px;
}
.hero {
display: grid;
justify-items: center;
gap: 1.25rem;
padding: 10rem 1.5rem 9rem;
text-align: center;
}
.hero .model {
margin-top: 6rem;
}
.stage {
display: block;
max-width: 100%;
height: auto;
overflow: visible;
@@ -37,13 +70,143 @@ h1 {
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);
gap: 4rem;
max-width: 72rem;
margin: 0 auto;
padding: 9rem 1.5rem;
}
.prop {
display: grid;
justify-items: center;
gap: 2rem;
text-align: center;
}
footer {
display: grid;
justify-items: center;
gap: 1rem;
padding: 9rem 1.5rem 12rem;
text-align: center;
}
footer ul {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem 2.5rem;
margin: 2rem 0 0;
padding: 0;
list-style: none;
font-size: 1.0625rem;
}
.lang {
position: fixed;
top: 1.5rem;
right: 1.5rem;
z-index: 1;
padding: 0.5rem 0.75rem;
border: 0;
background: none;
font: inherit;
font-size: 0.9375rem;
font-weight: 300;
color: var(--quiet);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease-out;
}
.lang:hover,
.lang:focus-visible,
body:has(.hero:hover) .lang {
opacity: 1;
}
@media (hover: none) {
.lang {
opacity: 1;
}
}
@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;
}
}
.caption {
position: absolute;
margin: 0;
translate: -50% calc(-100% - 0.75rem);
translate: -50% -100%;
font-size: 0.9375rem;
font-weight: 300;
color: #6e6e73;
color: var(--quiet);
white-space: nowrap;
pointer-events: none;
opacity: 0;
@@ -54,12 +217,6 @@ h1 {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.caption {
transition: none;
}
}
/* Keyboard and reader links; focus shows on the model. */
.doors a {
position: absolute;
@@ -69,3 +226,11 @@ h1 {
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.caption,
.get,
.lang {
transition: none;
}
}
+63
View File
@@ -0,0 +1,63 @@
// Every word on the page, in both languages.
export type Lang = "en" | "zh";
const en = {
tagline: "Six desktop apps. They work offline.",
ElyDoc: "Letters, reports, books. Opens and saves Word files.",
ElyTable: "Formulas, filters, charts. Opens and saves Excel files.",
ElyShow: "Slides, animation, a presenter view. Opens PowerPoint files.",
ElyPDF: "Read, mark up, sign, fill forms. Finds text in scans.",
ElyTree: "Stage lines, commit, read the graph. Git without a terminal.",
ElyMark: "Markdown as you read it. Math and diagrams in place.",
offline: "Works with the network off.",
open: "Open source, under the AGPL.",
local: "Your files stay in your folders.",
download: "Download",
platforms: "Out now for macOS. Windows and Linux are coming soon.",
needs: "macOS 14 or later, Apple silicon.",
unsigned: "Not notarized. The first time, Control-click the app and choose Open.",
source: "Source code",
shot: "screenshot",
other: "中文",
};
const zh: typeof en = {
tagline: "六个桌面应用,断网也能用。",
ElyDoc: "信件、报告、书稿。打开并保存 Word 文件。",
ElyTable: "公式、筛选、图表。打开并保存 Excel 文件。",
ElyShow: "幻灯片、动画、演讲者视图。打开 PowerPoint 文件。",
ElyPDF: "阅读、批注、签名、填表。扫描件里也能查字。",
ElyTree: "按行暂存、提交、看提交图。不用终端的 Git。",
ElyMark: "写的就是看到的 Markdown。公式与图表就地显示。",
offline: "断网照常工作。",
open: "开源,AGPL 许可。",
local: "文件留在你自己的文件夹里。",
download: "下载",
platforms: "macOS 版已发布。Windows 与 Linux 版即将推出。",
needs: "macOS 14 或更新,Apple 芯片。",
unsigned: "未经公证。第一次打开时,按住 Control 点按应用,选“打开”。",
source: "源代码",
shot: "截图",
other: "English",
};
export type Key = keyof typeof en;
export const TEXT: Record<Lang, typeof en> = { en, zh };
/** An element whose text follows the language. */
export function said<K extends keyof HTMLElementTagNameMap>(tag: K, key: Key): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
el.dataset.t = key;
return el;
}
/** Writes every keyed text in `lang`. */
export function speak(lang: Lang): void {
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
for (const el of document.querySelectorAll<HTMLElement>("[data-t]")) {
el.textContent = TEXT[lang][el.dataset.t as Key];
}
for (const el of document.querySelectorAll<SVGElement>("[data-shot]")) {
el.setAttribute("aria-label", `${el.dataset.shot} ${TEXT[lang].shot}`);
}
}
+38 -28
View File
@@ -10,12 +10,13 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string,
nav.className = "doors";
host.append(nav);
const owner = new Map([...parts].flatMap(([name, blocks]) => blocks.map((b) => [b, name] as const)));
const hits = still(svg, owner);
for (const [name, blocks] of parts) {
const target = `#${name.toLowerCase()}`;
const state = { hover: false, focus: false, press: false };
for (const b of blocks) b.dataset.part = name;
const hit = area(blocks, name);
svg.append(hit);
const hit = hits.filter((h) => h.dataset.part === name);
const caption = document.createElement("p");
caption.className = "caption";
caption.textContent = name;
@@ -29,19 +30,22 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string,
b.classList.toggle("pressed", up && state.press);
}
caption.classList.toggle("shown", up);
if (up) place(caption, host, hit);
if (up) place(caption, host, svg, blocks);
};
const set = (change: Partial<typeof state>) => {
Object.assign(state, change);
show();
};
hit.addEventListener("pointerenter", () => set({ hover: true }));
hit.addEventListener("pointerleave", () => set({ hover: false, press: false }));
hit.addEventListener("pointerdown", (e) => e.button === 0 && set({ press: true }));
hit.addEventListener("pointerup", () => set({ press: false }));
hit.addEventListener("pointercancel", () => set({ press: false }));
hit.addEventListener("click", () => (location.hash = target));
const own = (node: EventTarget | null) => node instanceof SVGElement && node.dataset.part === name;
for (const h of hit) {
h.addEventListener("pointerenter", () => set({ hover: true }));
h.addEventListener("pointerleave", (e) => own(e.relatedTarget) || set({ hover: false, press: false }));
h.addEventListener("pointerdown", (e) => e.button === 0 && set({ press: true }));
h.addEventListener("pointerup", () => set({ press: false }));
h.addEventListener("pointercancel", () => set({ press: false }));
h.addEventListener("click", () => (location.hash = target));
}
const door = document.createElement("a");
door.href = target;
@@ -54,28 +58,34 @@ export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string,
}
}
/** A still hit area over the building, resting and lifted. */
function area(blocks: SVGGElement[], name: string): SVGGElement {
const hit = document.createElementNS(SVG, "g");
hit.setAttribute("class", "hit");
hit.dataset.part = name;
for (const b of blocks) {
/** Resting outlines over the model, layered as drawn. */
function still(svg: SVGSVGElement, owner: Map<SVGGElement, string>): SVGPolygonElement[] {
const layer = document.createElementNS(SVG, "g");
layer.setAttribute("class", "hits");
const hits: SVGPolygonElement[] = [];
for (const b of svg.querySelectorAll<SVGGElement>(".block")) {
const outline = b.querySelector(".outline");
if (!outline) throw new Error("block has no outline");
for (const dy of [0, -UNIT]) {
const face = outline.cloneNode() as SVGPolygonElement;
face.setAttribute("class", "hit-face");
face.setAttribute("transform", `translate(0 ${dy})`);
hit.append(face);
}
const name = owner.get(b);
b.dataset.part = name ?? "";
const face = outline.cloneNode() as SVGPolygonElement;
face.setAttribute("class", name ? "hit-face" : "hit-face ground");
face.dataset.part = name ?? "";
layer.append(face);
if (name) hits.push(face);
}
return hit;
svg.append(layer);
return hits;
}
/** Puts the caption above the lifted building. */
function place(caption: HTMLElement, host: HTMLElement, hit: SVGGElement) {
/** Puts the caption one step above the building's top. */
function place(caption: HTMLElement, host: HTMLElement, svg: SVGSVGElement, blocks: SVGGElement[]) {
const origin = host.getBoundingClientRect();
const r = hit.getBoundingClientRect();
caption.style.left = `${r.left + r.width / 2 - origin.left}px`;
caption.style.top = `${r.top - origin.top}px`;
const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
const rects = blocks.map((b) => b.querySelector(".outline")!.getBoundingClientRect());
const left = Math.min(...rects.map((r) => r.left));
const right = Math.max(...rects.map((r) => r.right));
const top = Math.min(...rects.map((r) => r.top));
caption.style.left = `${(left + right) / 2 - origin.left}px`;
caption.style.top = `${top - origin.top - UNIT * scale}px`;
}