Add a compressed illustration carousel with slide indicators
Verify and deploy Tabularium / verify-and-deploy (pull_request) Failing after 5m44s

This commit is contained in:
2026-10-28 18:16:17 +08:00
parent c08371647d
commit bd932c36b2
16 changed files with 191 additions and 17 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

+20 -8
View File
@@ -142,6 +142,7 @@ async function main() {
"/docs/00-endor-concepts-draft",
"/figures/endor-architecture.svg",
"/figures/endor-review-gate.webp",
"/figures/endor-review-desk.webp",
"/docs/pretext/01-chapter",
"/materials/pretext/verify.cjs",
"/materials/pretext/results.chrome156.json",
@@ -200,15 +201,26 @@ async function main() {
2,
"The comic preview and lightbox must use WebP",
);
assert.ok(!purpose.includes("endor-review-gate.png"));
const comic = await request("/figures/endor-review-gate.webp", { headers });
assert.equal(comic.status, 200);
assert.match(comic.headers.get("content-type") || "", /image\/webp/);
assert.deepEqual(
Buffer.from(await comic.arrayBuffer()),
readFileSync(join(__dirname, "public/figures/endor-review-gate.webp")),
assert.ok(purpose.includes('aria-label="上一张图片"'));
assert.ok(purpose.includes('aria-label="下一张图片"'));
assert.ok(purpose.includes('aria-label="显示第 1 张图片" aria-current="true"'));
assert.ok(purpose.includes('aria-label="显示第 2 张图片"'));
assert.equal(
[...purpose.matchAll(/<img\b[^>]*\bsrc="\/figures\/endor-review-desk\.webp"/g)].length,
0,
"The second slide should not download until selected",
);
assert.equal((await request("/figures/endor-review-gate.png", { headers })).status, 404);
for (const name of ["endor-review-gate", "endor-review-desk"]) {
assert.ok(!purpose.includes(`/figures/${name}.png`));
const comic = await request(`/figures/${name}.webp`, { headers });
assert.equal(comic.status, 200);
assert.match(comic.headers.get("content-type") || "", /image\/webp/);
assert.deepEqual(
Buffer.from(await comic.arrayBuffer()),
readFileSync(join(__dirname, `public/figures/${name}.webp`)),
);
assert.equal((await request(`/figures/${name}.png`, { headers })).status, 404);
}
const assets = new Set(
[...home.matchAll(/(?:src|href)="(\/_next\/static\/[^" ]+)"/g)].map((m) =>
m[1].replaceAll("&amp;", "&"),
+2 -2
View File
@@ -52,8 +52,8 @@ for (const source of await files(sources)) {
throw new Error(`${relative}: ${encoded.length} bytes exceeds the ${policy.maxBytes}-byte image budget.`);
}
if (check) {
const committed = await readFile(destination);
if (!encoded.equals(committed)) throw new Error(`${relative} is stale. Run pnpm --filter @endor/docs images and commit the output.`);
const generated = await readFile(destination);
if (!encoded.equals(generated)) throw new Error(`${relative} is stale. Regenerate browser images on the confirmed remote runner.`);
} else {
await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, encoded);
@@ -0,0 +1,66 @@
import { useId, useState } from "react";
import { ArchitectureFigure } from "./ArchitectureFigure";
type Slide = { source: string; alt: string };
export function FigureGallery({ slides }: { slides: Slide[] }) {
const [active, setActive] = useState(0);
const viewport = useId();
const slide = slides[active];
if (!slide) return null;
function move(offset: number) {
setActive((current) => (current + offset + slides.length) % slides.length);
}
return (
<section
className="figure-gallery"
role="region"
aria-roledescription="轮播"
aria-label="文档配图"
onKeyDown={(event) => {
if ((event.target as HTMLElement).closest("dialog")) return;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
move(event.key === "ArrowLeft" ? -1 : 1);
}
}}
>
<div
id={viewport}
role="group"
aria-roledescription="幻灯片"
aria-label={`第 ${active + 1} 张,共 ${slides.length} 张`}
>
<ArchitectureFigure key={slide.source} source={slide.source} alt={slide.alt} />
</div>
<div className="figure-gallery-controls">
<button type="button" className="figure-gallery-arrow" aria-label="上一张图片" aria-controls={viewport} onClick={() => move(-1)}>
<span aria-hidden="true">‹</span>
</button>
<div className="figure-gallery-dots" role="group" aria-label="选择图片">
{slides.map((item, index) => (
<button
key={item.source}
type="button"
className="figure-gallery-dot"
aria-label={`显示第 ${index + 1} 张图片`}
aria-current={index === active ? "true" : undefined}
aria-controls={viewport}
onClick={() => setActive(index)}
>
<span aria-hidden="true" />
</button>
))}
</div>
<button type="button" className="figure-gallery-arrow" aria-label="下一张图片" aria-controls={viewport} onClick={() => move(1)}>
<span aria-hidden="true">›</span>
</button>
</div>
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">
第 {active + 1} 张,共 {slides.length} 张:{slide.alt}
</p>
</section>
);
}
+19 -3
View File
@@ -17,6 +17,7 @@ import remarkGfm from "remark-gfm";
import { listDocs, readDoc, type Doc, type DocLink } from "@/lib/docs";
import { Shell, Navigation } from "@/components/Shell";
import { ArchitectureFigure } from "@/components/ArchitectureFigure";
import { FigureGallery } from "@/components/FigureGallery";
import { CodeBlock } from "@/components/CodeBlock";
import { collectionEntries, collectionLabel } from "@/lib/collections";
import { Icon } from "@/components/Icon";
@@ -102,15 +103,30 @@ export default function DocPage({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children, node }) =>
node?.children.some(
p: ({ children, node }) => {
const slides = node?.children.flatMap((child) =>
child.type === "element" && child.tagName === "img" &&
typeof child.properties.src === "string" &&
child.properties.src.startsWith("/figures/endor-")
? [{ source: child.properties.src, alt: String(child.properties.alt ?? "") }]
: [],
) ?? [];
const onlyImages = node?.children.every((child) =>
(child.type === "element" && child.tagName === "img" &&
typeof child.properties.src === "string" &&
child.properties.src.startsWith("/figures/endor-")) ||
(child.type === "text" && child.value.trim() === ""),
);
if (slides.length > 1 && onlyImages) return <FigureGallery slides={slides} />;
return node?.children.some(
(child) =>
child.type === "element" && child.tagName === "img",
) ? (
<div>{children}</div>
) : (
<p>{children}</p>
),
);
},
img: ({ src, alt }) =>
typeof src === "string" && src.startsWith("/figures/endor-") ? (
<ArchitectureFigure source={src} alt={alt ?? "Endor 架构图"} />
+51
View File
@@ -882,6 +882,57 @@ button {
border-radius: 0;
background: transparent;
}
.figure-gallery {
margin: 28px 0;
}
.figure-gallery .architecture-preview {
margin: 0;
}
.figure-gallery-controls,
.figure-gallery-dots {
display: flex;
align-items: center;
justify-content: center;
}
.figure-gallery-controls {
gap: 20px;
margin-top: 12px;
}
.figure-gallery-arrow,
.figure-gallery-dot {
display: grid;
place-items: center;
min-width: 36px;
min-height: 36px;
padding: 0;
border: 0;
border-radius: 50%;
color: #57534e;
background: transparent;
cursor: pointer;
}
.figure-gallery-arrow {
font-size: 28px;
line-height: 1;
}
.figure-gallery-dot span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #d6d3d1;
}
.figure-gallery-dot[aria-current="true"] span {
background: #2563eb;
}
.figure-gallery-arrow:hover,
.figure-gallery-dot:hover {
background: #f5f5f4;
}
.figure-gallery-arrow:focus-visible,
.figure-gallery-dot:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.image-lightbox {
width: 100vw;
height: 100dvh;