Site: buildings lift on hover and focus, sink on press; ordered keyboard links

This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 23:40:54 -04:00
1 parent 366f7529ba
commit 0f62a36182
5 files changed
+138 -8

No files matched your search

+1 -1
View File
@@ -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
+24
View File
@@ -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;
}
}
+15 -7
View File
@@ -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<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) {
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);
+38
View File
@@ -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;
}
+60
View File
@@ -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<string, SVGGElement[]>): 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<SVGGElement>(".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`;
}