diff --git a/site/TASKS.md b/site/TASKS.md index dbe9c84..31638e9 100644 --- a/site/TASKS.md +++ b/site/TASKS.md @@ -10,7 +10,7 @@ - [x] S1 工程骨架:`site/` 下 Vite 8 + pnpm、TypeScript、无框架;`wrangler.jsonc` 只用静态资源(`assets.directory`,`404-page`);`pnpm build` 出 `dist/`,`pnpm preview` 可看;构建失败即报错 — R1 PASS - [~] S2 等轴几何:一种投影(真等轴,30°),无透视;积木由同一颜色的顶、左、右三面明暗构成,不用投影阴影;网格单位与高度单位;屏幕面(贴截图的平面)沿同一角度 — R1 面间条纹(改 SVG 加底轮廓);R2 假环(只约束屏上相叠的积木,加回归);R3 注释超长;R4 comments.py 漏查 ts/css 跨行注释(加回归);R5 字符串里的 `/*` 误报、行尾注释漏查(改为逐字扫描,加回归)。五轮已满,修复带回归测试,不再送审。屏幕面随 S5 建 - [x] S3 首屏模型:一张桌面上的微缩模型,六个应用是六座积木建筑,按区域排布;高度表达层级;应用之间的真实连接(ElyMark→ElyTree、导出→ElyPDF)是道路;载入时逐格生长、按节拍落位 — R1 道路断点、Mark 缺通往 PDF 的路、测试不查连通(已修,加回归);R2 PASS -- [ ] S4 交互:积木悬停上浮一格、按下下沉一格;控件在悬停、聚焦或靠近时才出现;键盘可达,焦点可见;系统减少动效时不播放生长 +- [ ] S4 交互:积木悬停上浮一格、按下下沉一格;控件在悬停、聚焦或靠近时才出现;键盘可达,焦点可见;系统减少动效时不播放生长(链接的目标区域随 S5 建) - [ ] S5 六个区域:每个应用一个区域,真实截图贴在等轴屏幕面上,正文只写做什么;状态用具象小道具(离线、开源、本地文件) - [ ] S6 中英两种语言:按浏览器语言选择,可切换;文案只在一处 - [ ] S7 下载与源码:链接 v0.1.0 发布页的六个 DMG 与源码包,写明 macOS 14、Apple Silicon、未公证的打开方式、AGPL diff --git a/site/src/iso.css b/site/src/iso.css index 501a05e..eb3d51d 100644 --- a/site/src/iso.css +++ b/site/src/iso.css @@ -30,3 +30,27 @@ animation: none; } } + +/* Interaction moves by translate; landing owns transform. */ +.block { + transition: translate 0.15s ease-out; +} + +.block[data-part] { + cursor: pointer; + outline: none; +} + +.block.lifted { + translate: 0 calc(-1 * var(--step)); +} + +.block.lifted.pressed { + translate: 0 0; +} + +@media (prefers-reduced-motion: reduce) { + .block { + transition: none; + } +} diff --git a/site/src/main.ts b/site/src/main.ts index 99e3117..c473845 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -2,6 +2,7 @@ import "./style.css"; import "./iso.css"; import { block, stage } from "./iso.ts"; import { model } from "./model.ts"; +import { touch } from "./touch.ts"; /** 112 BPM, the film's tempo; blocks land on sixteenths. */ const BEAT = 60 / 112; @@ -10,21 +11,28 @@ const STEP = BEAT / 4; const app = document.querySelector("#app"); if (!app) throw new Error("index.html has no #app"); +const apps = model.slice(1, -1); +const parts = new Map(); const blocks: SVGGElement[] = []; let beat = 1; for (const part of model) { - part.blocks.forEach((spec, i) => { + const own = part.blocks.map((spec, i) => { const g = block(spec); g.style.setProperty("--land", `${(beat * BEAT + i * STEP).toFixed(3)}s`); - blocks.push(g); + return g; }); + if (apps.includes(part)) parts.set(part.name, own); + blocks.push(...own); beat += Math.ceil(part.blocks.length / 4); } const title = document.createElement("h1"); title.textContent = "Ely Office"; -const model3d = stage(blocks); -model3d.classList.add("grow"); -model3d.setAttribute("role", "img"); -model3d.setAttribute("aria-label", model.slice(1, -1).map((p) => p.name).join(", ")); -app.append(title, model3d); +const host = document.createElement("div"); +host.className = "model"; +const svg = stage(blocks); +svg.classList.add("grow"); +svg.setAttribute("aria-hidden", "true"); +host.append(svg); +app.append(title, host); +touch(host, parts); diff --git a/site/src/style.css b/site/src/style.css index e44c843..9214062 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -30,3 +30,41 @@ h1 { max-width: 100%; height: auto; } + +.model { + position: relative; + max-width: 100%; +} + +.caption { + position: absolute; + margin: 0; + translate: -50% calc(-100% - 0.75rem); + font-size: 0.9375rem; + font-weight: 300; + color: #6e6e73; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease-out; +} + +.caption.shown { + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .caption { + transition: none; + } +} + +/* Keyboard and reader links; focus shows on the model. */ +.doors a { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/site/src/touch.ts b/site/src/touch.ts new file mode 100644 index 0000000..dbbc95a --- /dev/null +++ b/site/src/touch.ts @@ -0,0 +1,60 @@ +// Buildings lift on hover or focus and sink when pressed. +import { UNIT } from "./iso.ts"; + +/** Links each app's blocks to its region, in reading order. */ +export function touch(host: HTMLElement, parts: Map): void { + const caption = document.createElement("p"); + caption.className = "caption"; + caption.setAttribute("aria-hidden", "true"); + host.append(caption); + host.style.setProperty("--step", `${UNIT / 2}px`); + const nav = document.createElement("nav"); + nav.className = "doors"; + host.append(nav); + + for (const [name, blocks] of parts) { + const target = `#${name.toLowerCase()}`; + const inside = (node: EventTarget | null) => + node instanceof Element && node.closest(".block")?.dataset.part === name; + const lift = (up: boolean) => { + for (const b of blocks) b.classList.toggle("lifted", up); + caption.classList.toggle("shown", up); + if (up) place(caption, host, blocks, name); + }; + const press = (down: boolean) => { + for (const b of blocks) b.classList.toggle("pressed", down); + }; + const open = () => { + press(false); + location.hash = target; + }; + + for (const b of blocks) { + b.dataset.part = name; + b.addEventListener("pointerenter", () => lift(true)); + b.addEventListener("pointerleave", (e) => inside(e.relatedTarget) || (press(false), lift(false))); + b.addEventListener("pointerdown", () => press(true)); + b.addEventListener("pointerup", open); + } + const door = document.createElement("a"); + door.href = target; + door.textContent = name; + door.addEventListener("focus", () => lift(true)); + door.addEventListener("blur", () => (press(false), lift(false))); + door.addEventListener("keydown", (e) => e.key === "Enter" && press(true)); + door.addEventListener("click", () => press(false)); + nav.append(door); + } +} + +/** Puts the caption above the building's top. */ +function place(caption: HTMLElement, host: HTMLElement, blocks: SVGGElement[], name: string) { + const origin = host.getBoundingClientRect(); + const rects = blocks.map((b) => b.getBoundingClientRect()); + const left = Math.min(...rects.map((r) => r.left)); + const right = Math.max(...rects.map((r) => r.right)); + const top = Math.min(...rects.map((r) => r.top)); + caption.textContent = name; + caption.style.left = `${(left + right) / 2 - origin.left}px`; + caption.style.top = `${top - origin.top}px`; +}