diff --git a/.dockerignore b/.dockerignore index 7da7097..557f4ea 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,5 @@ **/*.pyc **/*.tsbuildinfo .artifacts +apps/docs/public/figures/**/*.webp .env* diff --git a/.gitignore b/.gitignore index 53e450f..785350d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ __pycache__/ !.env.example .artifacts/ + +# Browser raster derivatives are generated on the confirmed remote runner. +/apps/docs/public/figures/**/*.webp diff --git a/apps/docs/public/figures/endor-review-gate.webp b/apps/docs/public/figures/endor-review-gate.webp deleted file mode 100644 index f89eb16..0000000 Binary files a/apps/docs/public/figures/endor-review-gate.webp and /dev/null differ diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index f327d05..de914bd 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -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(/]*\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("&", "&"), diff --git a/apps/docs/scripts/build-doc-images.mjs b/apps/docs/scripts/build-doc-images.mjs index bde6b4a..8989bf7 100644 --- a/apps/docs/scripts/build-doc-images.mjs +++ b/apps/docs/scripts/build-doc-images.mjs @@ -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); diff --git a/apps/docs/src/components/FigureGallery.tsx b/apps/docs/src/components/FigureGallery.tsx new file mode 100644 index 0000000..6fad60a --- /dev/null +++ b/apps/docs/src/components/FigureGallery.tsx @@ -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 ( +
{ + if ((event.target as HTMLElement).closest("dialog")) return; + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + move(event.key === "ArrowLeft" ? -1 : 1); + } + }} + > +
+ +
+
+ +
+ {slides.map((item, index) => ( + + ))} +
+ +
+

+ 第 {active + 1} 张,共 {slides.length} 张:{slide.alt} +

+
+ ); +} diff --git a/apps/docs/src/pages/docs/[...slug].tsx b/apps/docs/src/pages/docs/[...slug].tsx index c92dd88..b4f7207 100644 --- a/apps/docs/src/pages/docs/[...slug].tsx +++ b/apps/docs/src/pages/docs/[...slug].tsx @@ -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({ - 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 ; + return node?.children.some( (child) => child.type === "element" && child.tagName === "img", ) ? (
{children}
) : (

{children}

- ), + ); + }, img: ({ src, alt }) => typeof src === "string" && src.startsWith("/figures/endor-") ? ( diff --git a/apps/docs/src/styles/globals.css b/apps/docs/src/styles/globals.css index 8c7971f..7f165ba 100644 --- a/apps/docs/src/styles/globals.css +++ b/apps/docs/src/styles/globals.css @@ -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; diff --git a/content/docs/03-figures.md b/content/docs/03-figures.md index bafedde..29739ee 100644 --- a/content/docs/03-figures.md +++ b/content/docs/03-figures.md @@ -33,4 +33,10 @@ pnpm --filter @endor/docs test:images 原图相对 `tools/figures/assets` 的路径会映射到 `apps/docs/public/figures` 下同名的 `.webp`。例如 `endor-review-gate.png` 对应 `/figures/endor-review-gate.webp`。文档只嵌入后一个地址,制作原稿不随网页发布。 -开发启动和生产构建会运行压缩脚本;CI 还会重新编码并核对提交的 WebP,防止修改原稿或策略后遗漏更新产物。检查同时拒绝公开目录里的原始位图及 Markdown 图片或 HTML img 直接引用的原始 PNG、JPEG。SVG 无需转成位图。新增位图时一起提交原稿、生成的 WebP 与制作记录,沿用共同策略。 +远程生产构建会运行压缩脚本;CI 先生成 WebP,再重新编码核对产物与原稿、策略是否一致。WebP 是构建派生文件,不必在本机生成或提交;Git 保存原稿、制作记录与共享策略。检查同时拒绝公开目录里的原始位图及 Markdown 图片或 HTML img 直接引用的原始 PNG、JPEG。SVG 无需转成位图,仍按前述流程提交可编辑输出。 + +## 同一位置切换多张配图 + +把两张或更多 `/figures/endor-` 图片写在同一段,中间换行但不插入空行,正文会将它们呈现为图组。左右按钮和键盘左右方向键可以切换,下方圆点表示当前图片,也可点击直接选择。图片保留点击放大能力。 + +图组一次只挂载当前图片,切换后才加载下一张压缩图,不预先下载整组原图。 diff --git a/content/docs/04-endor-purpose-draft.md b/content/docs/04-endor-purpose-draft.md index 8b9cd03..772f695 100644 --- a/content/docs/04-endor-purpose-draft.md +++ b/content/docs/04-endor-purpose-draft.md @@ -19,6 +19,9 @@ ## 让产出成为检查下一次产出的帮手 ![Coding Agent 不断产出相同的原始文件:上方待检查文件积累为积分 dW,单人的检查门保持固定能力 C₀;下方 Endor 只把本次新增文件 dW 送到人与检查门前,处理完成的文件变成结构化蓝色帮手,箭头回到检查门,表示历史成果积累为新的检查能力](/figures/endor-review-gate.webp) +![检查台版:上方单人检查台面对积累的待处理文件;下方只检查本次新增文件,已处理文件变成蓝色帮手,向检查台贡献更多检查工具](/figures/endor-review-desk.webp) + +两张漫画用检查门与检查台表达同一个目标,可用左右按钮或下方圆点切换。 上方把 Coding Agent 比作一台不断吐出工作结果的机器。机器可以持续产出,但人的时间和精力没有同步增长;等待检查的材料越积越多。虚线圈出的 **∫dW** 表示累积的待处理工作,门上的 **C₀** 表示在这个对比中保持不变的检查能力。通过门、带上勾的文件才表示已处理的结果。 diff --git a/package.json b/package.json index 17981e8..4623585 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "lint": "pnpm --filter @endor/docs run lint", "typecheck": "pnpm --filter @endor/docs run typecheck", "figures": "uv run --locked --project tools/figures endor-figures", - "check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build", + "check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm --filter @endor/docs run images && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build", "test:auth": "pnpm --filter @endor/docs run test:auth" } } diff --git a/scripts/check-container.sh b/scripts/check-container.sh index 53f6a2e..7ec2013 100644 --- a/scripts/check-container.sh +++ b/scripts/check-container.sh @@ -6,6 +6,7 @@ uv sync --project tools/figures --locked pnpm run lint pnpm run typecheck pnpm --filter @endor/docs run test:auth +pnpm --filter @endor/docs run images pnpm --filter @endor/docs run test:images cp -R apps/docs/public/figures /tmp/expected-figures pnpm run figures diff --git a/scripts/smoke.py b/scripts/smoke.py index 87dff2f..9296a3b 100644 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -27,7 +27,7 @@ def verify(base: str) -> None: assert status == 200 and json.loads(health) == {"status": "ok"} status, _, login = get("/login") assert status == 200 and "输入动态码" in login.decode() - for path in ("/", "/docs/00-endor-concepts-draft", "/figures/endor-architecture.svg", "/figures/endor-review-gate.webp", "/docs/pretext/01-chapter", "/materials/pretext/verify.cjs", "/materials/pretext/results.chrome156.json"): + for path in ("/", "/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"): status, headers, _ = get(path) assert status == 307, path target = urllib.parse.urlparse(urllib.parse.urljoin(base, headers["Location"])) diff --git a/tools/figures/assets/endor-review-desk.png b/tools/figures/assets/endor-review-desk.png new file mode 100644 index 0000000..b80e6dc Binary files /dev/null and b/tools/figures/assets/endor-review-desk.png differ diff --git a/tools/figures/assets/endor-review-desk.txt b/tools/figures/assets/endor-review-desk.txt new file mode 100644 index 0000000..2f3f5ba --- /dev/null +++ b/tools/figures/assets/endor-review-desk.txt @@ -0,0 +1,14 @@ +Endor inspection desk comic — approved second slide + +Source: built-in image_gen; physics inspection-bench variant with corrected top-row flow. +Original: exec-432e0f3d-d01d-4658-b5d5-0c63db95e472.png +The user selected this alongside the gate variant for one carousel. + +The two identical machines emit raw papers. Above, a dashed integral dW boundary +encloses the backlog BEFORE one person's fixed-capacity C0 inspection desk. +Below, a dashed dW boundary encloses the one new paper being inspected. Earlier +checked outputs become structured document helpers that contribute additional +magnifiers to the Endor desk, whose capacity is C0 + k integral dW. + +Store this PNG as the production master outside public. The confirmed remote +runner generates /figures/endor-review-desk.webp using content/image-policy.json. diff --git a/tools/figures/assets/endor-review-gate.txt b/tools/figures/assets/endor-review-gate.txt index 18f23e2..d250c48 100644 --- a/tools/figures/assets/endor-review-gate.txt +++ b/tools/figures/assets/endor-review-gate.txt @@ -3,7 +3,8 @@ Endor review gate comic — approved source Source: built-in image_gen, physics gate variant, approved by the user on 2026-10-28. Original: exec-380f8db3-6450-4981-97df-473c13a62223.png The PNG is the production master, not a public browser asset. -Generate the browser WebP with: pnpm --filter @endor/docs images +The confirmed remote runner generates the browser WebP with: +pnpm --filter @endor/docs images Visual specification: Two rows, white background, charcoal outlines, blue accents, identical coding