From 43882f397618c837b58eec9591fcf029523e7a0b Mon Sep 17 00:00:00 2001 From: Renfei Wang Date: Sun, 26 Jul 2026 19:11:42 -0400 Subject: [PATCH] perf: add responsive capture presentations and stable cards --- .gitignore | 1 + README.md | 2 +- README.zh-CN.md | 2 +- apps/theme-manager/src-tauri/src/catalog.rs | 99 +++++++-- apps/theme-manager/src/renderer/app.js | 182 +++++++++++++---- apps/theme-manager/src/renderer/bridge.js | 4 + apps/theme-manager/src/renderer/styles.css | 15 +- docs/CHANGELOG.md | 4 + docs/architecture.md | 6 +- docs/desktop-manager.md | 11 +- schemas/registry.schema.json | 67 ++++++- scripts/build.mjs | 8 +- scripts/lib/capture-presentation.mjs | 211 ++++++++++++++++++++ scripts/lib/png.mjs | 7 +- scripts/lib/theme-generator.mjs | 79 ++++++-- scripts/prepare-desktop.mjs | 42 ++-- scripts/validate.mjs | 171 ++++++++++++++-- site/assets/app.js | 174 ++++++++++++++-- site/assets/styles.css | 28 ++- site/index.html | 7 +- tests/repository.test.mjs | 131 +++++++++--- tests/source-contract.test.mjs | 65 ++++++ 22 files changed, 1143 insertions(+), 173 deletions(-) create mode 100644 scripts/lib/capture-presentation.mjs diff --git a/.gitignore b/.gitignore index 2f10c5c..faf8ae3 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ themes/registry.json themes/*/ !themes/source-art/ !themes/source-art/** +screenshots/codex-beta-*/presentation/ .DS_Store Thumbs.db *.log diff --git a/README.md b/README.md index 835e6af..b0b0aa6 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ The collection currently includes 68 themes and 136 light/dark modes. The Englis - 2 disclosed, unofficial Codex community tributes; - the broader original and fan-art catalog, available through language-aware discovery in the Gallery. -Every mode has a 1440×810 capture from the pinned Beta test bench. Registry records bind each capture to the exact app version, background hash, runtime hash, byte count, and renderer readback. +Every mode retains a 1440×810 original capture from the pinned Beta test bench. The generator derives integrity-bound 320, 480, and 960px presentation PNGs from that evidence; Gallery browsing uses those responsive variants, while the full original is loaded only after you explicitly choose **View original evidence**. Registry records bind both layers to the exact app version, background hash, runtime hash, byte count, and renderer readback. “Saint Tibo” is an affectionate, unofficial community parody. It is not endorsed by OpenAI or by the person depicted, and no official product artwork is bundled. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8236dec..b03722d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -134,7 +134,7 @@ Windows 版“应用并保持完整皮肤”已经通过 ChatGPT Beta `26.715.36 - 5 套面向美国工作场景的原创主题,包括海岸工作室和深夜餐馆; - 1 套 2007 桌面怀旧 Q 版主题,以及 2 套 Codex 社区致意主题。 -每个模式都有固定 Beta 测试台生成的 1440×810 实机截图。Registry 会把截图与准确应用版本、背景哈希、运行时哈希、字节数和页面读回结果绑定。 +每个模式都会保留固定 Beta 测试台生成的 1440×810 原始实机截图。生成器会从这份证据派生带完整性绑定的 320、480、960px 展示 PNG;Gallery 日常浏览只加载这些响应式版本,只有明确点击“查看原始证据”才会加载完整原图。Registry 会把两层素材与准确应用版本、背景哈希、运行时哈希、字节数和页面读回结果绑定。 “提博大神”是社区表达喜爱的非官方戏仿与致意,不代表 OpenAI 或被描绘者背书,也不包含官方产品素材。 diff --git a/apps/theme-manager/src-tauri/src/catalog.rs b/apps/theme-manager/src-tauri/src/catalog.rs index 6beb589..8396363 100644 --- a/apps/theme-manager/src-tauri/src/catalog.rs +++ b/apps/theme-manager/src-tauri/src/catalog.rs @@ -16,6 +16,7 @@ const REMOTE_CATALOG_URL: &str = const REMOTE_REGISTRY_URL: &str = "https://rwang23.github.io/awesome-codex-theme/themes/registry.json"; const MODES: [&str; 2] = ["light", "dark"]; +const PRESENTATION_SIZES: [(u64, u64); 3] = [(320, 180), (480, 270), (960, 540)]; #[derive(Clone)] pub struct Catalog { @@ -237,23 +238,57 @@ fn validate_mode( } let capture = &record["capture"]; - let capture_path = capture["path"].as_str().unwrap_or_default(); + let evidence = &capture["evidence"]; + let capture_path = evidence["path"].as_str().unwrap_or_default(); if !safe_relative_path(capture_path) || !capture_path.starts_with("screenshots/") - || !capture["sha256"].as_str().is_some_and(valid_sha256) - || !capture["assetSha256"].as_str().is_some_and(valid_sha256) - || !capture["runtimeSha256"].as_str().is_some_and(valid_sha256) - || capture["markerVersion"].as_str() != Some("act-full-skin-v1") - || capture["width"].as_u64() != Some(1440) - || capture["height"].as_u64() != Some(810) - || capture["fixture"].as_str() != Some("full-skin-home-v1") - || capture["appVersion"] + || !evidence["sha256"].as_str().is_some_and(valid_sha256) + || !evidence["assetSha256"].as_str().is_some_and(valid_sha256) + || !evidence["runtimeSha256"].as_str().is_some_and(valid_sha256) + || evidence["markerVersion"].as_str() != Some("act-full-skin-v1") + || evidence["width"].as_u64() != Some(1440) + || evidence["height"].as_u64() != Some(810) + || evidence["fixture"].as_str() != Some("full-skin-home-v1") + || evidence["appVersion"] .as_str() .unwrap_or_default() .is_empty() { return fail(format!("{theme_id} {mode} 实机截图记录无效")); } + let (capture_directory, capture_file) = capture_path + .rsplit_once('/') + .ok_or_else(|| format!("{theme_id} {mode} 实机截图路径无效"))?; + let capture_stem = capture_file + .strip_suffix(".png") + .ok_or_else(|| format!("{theme_id} {mode} 实机截图格式无效"))?; + let presentation = array(&capture["presentation"], "capture.presentation")?; + if presentation.len() != PRESENTATION_SIZES.len() { + return fail(format!("{theme_id} {mode} 缩略图数量无效")); + } + for (width, height) in PRESENTATION_SIZES { + let variant = presentation + .iter() + .find(|candidate| candidate["width"].as_u64() == Some(width)) + .ok_or_else(|| format!("{theme_id} {mode} 缺少 {width}px 缩略图"))?; + let variant_path = variant["path"].as_str().unwrap_or_default(); + let expected_path = format!("{capture_directory}/presentation/{capture_stem}-{width}.png"); + if !safe_relative_path(variant_path) + || variant_path != expected_path + || variant["format"].as_str() != Some("png") + || variant["height"].as_u64() != Some(height) + || !variant["sha256"].as_str().is_some_and(valid_sha256) + || variant["bytes"] + .as_u64() + .is_none_or(|bytes| bytes == 0 || bytes > 1_048_576) + || variant["evidenceSha256"] != evidence["sha256"] + || !variant["renderFingerprint"] + .as_str() + .is_some_and(valid_sha256) + { + return fail(format!("{theme_id} {mode} {width}px 缩略图记录无效")); + } + } let full_skin = &record["fullSkin"]; let full_skin_asset = full_skin["asset"].as_str().unwrap_or_default(); @@ -523,6 +558,16 @@ fn local_capture_path(app: &AppHandle, relative: &str) -> Result Ok(path.to_string_lossy().into_owned()) } +fn presentation_path(capture: &Value, width: u64) -> Result<&str, String> { + let variant = array(&capture["presentation"], "capture.presentation")? + .iter() + .find(|candidate| candidate["width"].as_u64() == Some(width)) + .ok_or_else(|| format!("缺少 {width}px 缩略图"))?; + variant["path"] + .as_str() + .ok_or_else(|| format!("{width}px 缩略图路径无效")) +} + pub fn present_catalog(app: &AppHandle, catalog: &Catalog) -> Result { let collections = catalog.registry["collections"].clone(); let themes = array(&catalog.registry["themes"], "themes")? @@ -532,19 +577,28 @@ pub fn present_catalog(app: &AppHandle, catalog: &Catalog) -> Result { + entries.forEach((entry) => { + const card = state.themeCards.get(entry.target.dataset.themeId); + if (!card) return; + card.nearViewport = entry.isIntersecting; + if (entry.isIntersecting) updateObservedThumbnail(card); + }); + }, { + root: elements.themeList, + rootMargin: "180px 0px", + }); +} + +function resetThumbnailObserver() { + state.thumbnailObserver?.disconnect(); + state.thumbnailObserver = null; +} + +function createThemeCard(theme) { + const button = document.createElement("button"); + button.type = "button"; + button.role = "option"; + button.dataset.themeId = theme.id; + + const thumb = document.createElement("span"); + thumb.className = "theme-thumb"; + const image = document.createElement("img"); + image.alt = ""; + image.width = 320; + image.height = 180; + image.loading = "lazy"; + image.decoding = "async"; + thumb.append(image); + + const copy = document.createElement("span"); + copy.className = "theme-list-copy"; + const meta = document.createElement("small"); + meta.className = "theme-card-meta"; + const title = document.createElement("strong"); + const subtitle = document.createElement("small"); + copy.append(meta, title, subtitle); + + const detail = document.createElement("span"); + detail.className = "theme-detail-action"; + button.append(thumb, copy, detail); + const card = { + theme, + button, + image, + meta, + title, + subtitle, + detail, + visible: false, + nearViewport: false, + }; + state.themeCards.set(theme.id, card); + return card; +} + +function ensureThemeCards() { + if (!state.catalog) return; + const catalogIds = state.catalog.themes.map((theme) => theme.id); + const intact = catalogIds.length === state.themeCards.size + && catalogIds.every((id) => state.themeCards.has(id)); + if (intact) return; + resetThumbnailObserver(); + state.themeCards.clear(); + const fragment = document.createDocumentFragment(); + state.catalog.themes.forEach((theme) => fragment.append(createThemeCard(theme).button)); + elements.themeList.replaceChildren(fragment); +} + +function updateThemeCard(theme, visible, visibleIndex) { + const card = state.themeCards.get(theme.id); + if (!card) return; + const active = theme.id === state.themeId; + card.button.hidden = !visible; + card.button.classList.toggle("is-active", active); + card.button.setAttribute("aria-selected", String(active)); + card.visible = visible; + if (!visible) { + card.nearViewport = false; + state.thumbnailObserver?.unobserve(card.button); + card.image.removeAttribute("src"); + delete card.image.dataset.source; + return; + } + + const collection = state.catalog.collections.find((record) => record.id === theme.collection); + const name = localized(theme.name); + card.meta.textContent = localized(collection?.name) + " · " + t(theme.variant); + card.title.textContent = name; + card.subtitle.textContent = localized(theme.tagline); + card.detail.textContent = t("viewDetails") + " →"; + card.button.setAttribute("aria-label", name + " · " + t("viewDetails")); + ensureThumbnailObserver(); + if (state.thumbnailObserver) { + state.thumbnailObserver.observe(card.button); + updateObservedThumbnail(card); + } else if (visibleIndex < 4) { + updateObservedThumbnail(card, true); + } + card.image.fetchPriority = visibleIndex < 2 ? "high" : "auto"; +} + function renderThemeList() { const themes = filteredThemes(); + ensureThemeCards(); elements.resultCount.textContent = t("resultsCount", { count: themes.length }); if (themes.length && !themes.some((theme) => theme.id === state.themeId)) { state.themeId = themes[0].id; } if (!themes.length) state.themeId = null; - const fragment = document.createDocumentFragment(); - themes.forEach((theme) => { - const button = document.createElement("button"); - button.type = "button"; - button.role = "option"; - button.dataset.themeId = theme.id; - button.setAttribute("aria-selected", String(theme.id === state.themeId)); - button.classList.toggle("is-active", theme.id === state.themeId); - - const thumb = document.createElement("span"); - thumb.className = "theme-thumb"; - const image = document.createElement("img"); - image.src = theme.previews[state.mode].imageUrl; - image.alt = ""; - thumb.append(image); - const copy = document.createElement("span"); - copy.className = "theme-list-copy"; - const meta = document.createElement("small"); - meta.className = "theme-card-meta"; - meta.textContent = localized(state.catalog.collections.find((collection) => collection.id === theme.collection)?.name) - + " · " + t(theme.variant); - const title = document.createElement("strong"); - title.textContent = localized(theme.name); - const subtitle = document.createElement("small"); - subtitle.textContent = localized(theme.tagline); - copy.append(meta, title, subtitle); - const arrow = document.createElement("span"); - arrow.className = "theme-arrow"; - arrow.textContent = "↗"; - button.append(thumb, copy, arrow); - fragment.append(button); + const visibleIndexes = new Map(themes.map((theme, index) => [theme.id, index])); + state.catalog.themes.forEach((theme) => { + updateThemeCard(theme, visibleIndexes.has(theme.id), visibleIndexes.get(theme.id)); }); - elements.themeList.replaceChildren(fragment); + themes.forEach((theme) => elements.themeList.append(state.themeCards.get(theme.id).button)); elements.emptyState.hidden = themes.length > 0; if (themes.length) { renderSelectedTheme(); @@ -611,7 +712,7 @@ function renderSelectedTheme() { if (!theme) return; elements.applySkin.disabled = state.targets.length === 0; const preview = theme.previews[state.mode]; - elements.themeCapture.src = preview.imageUrl; + elements.themeCapture.src = previewSource(preview, "image"); elements.themeCapture.alt = localized(theme.name) + " · " + t(state.mode) + " · " + t("captureAlt"); elements.captureVersion.textContent = preview.capture.appVersion; elements.captureHash.textContent = shortHash(preview.capture.sha256); @@ -798,7 +899,20 @@ function acceptCatalog(payload) { status: payload.status, message: payload.message, }; - if (payload.catalog) state.catalog = payload.catalog; + if (payload.catalog) { + const nextKey = [ + payload.catalog.registrySha256 || "", + payload.catalog.source || "", + payload.catalog.themes?.map((theme) => theme.id).join(",") || "", + ].join("|"); + if (nextKey !== state.catalogKey) { + resetThumbnailObserver(); + state.themeCards.clear(); + elements.themeList.replaceChildren(); + state.catalogKey = nextKey; + } + state.catalog = payload.catalog; + } if (!state.catalog) return; const ordered = state.catalog.themes.slice().sort(compareThemePriority); state.themeId = state.catalog.themes.some((theme) => theme.id === previousTheme) diff --git a/apps/theme-manager/src/renderer/bridge.js b/apps/theme-manager/src/renderer/bridge.js index f3dab95..24f75ca 100644 --- a/apps/theme-manager/src/renderer/bridge.js +++ b/apps/theme-manager/src/renderer/bridge.js @@ -7,6 +7,10 @@ function normalizeCatalog(catalog) { for (const theme of catalog.themes || []) { for (const mode of ["light", "dark"]) { const preview = theme.previews?.[mode]; + if (preview?.thumbnailPath) { + preview.thumbnailUrl = convertFileSrc(preview.thumbnailPath); + delete preview.thumbnailPath; + } if (preview?.imagePath) { preview.imageUrl = convertFileSrc(preview.imagePath); delete preview.imagePath; diff --git a/apps/theme-manager/src/renderer/styles.css b/apps/theme-manager/src/renderer/styles.css index ece47af..fa6418e 100644 --- a/apps/theme-manager/src/renderer/styles.css +++ b/apps/theme-manager/src/renderer/styles.css @@ -705,7 +705,7 @@ kbd { .theme-list > button { display: grid; - grid-template-rows: auto auto; + grid-template-rows: auto auto auto; min-width: 0; padding: 5px; border: 1px solid rgba(23, 59, 52, 0.11); @@ -713,9 +713,15 @@ kbd { background: rgba(255, 255, 255, 0.46); text-align: left; cursor: pointer; + content-visibility: auto; + contain-intrinsic-size: 178px 184px; transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease; } +.theme-list > button[hidden] { + display: none; +} + .theme-list > button:hover { border-color: rgba(31, 105, 88, 0.32); transform: translateY(-1px); @@ -775,8 +781,11 @@ kbd { text-transform: uppercase; } -.theme-arrow { - display: none; +.theme-detail-action { + padding: 2px 3px 1px; + color: var(--jade); + font-size: 8px; + font-weight: 720; } .empty-state { diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3a733bf..558d310 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Added integrity-bound 320px, 480px, and 960px presentation derivatives for + every real Beta capture. Gallery cards and the hero now use responsive + `srcset` media, the original evidence is an explicit metadata-backed action, + and Theme Manager reuses stable keyed cards with shared list/detail assets. - Reworked theme generation into a bounded, incremental pipeline with source-art provenance checks, complete render fingerprints, atomic per-theme writes, cache-aware progress, and reproducible benchmark commands. diff --git a/docs/architecture.md b/docs/architecture.md index 746f3c5..4befa3c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -90,15 +90,15 @@ The site build copies: - theme packages and manifests; - Full Skin background assets; - Native fallback files; -- real Beta screenshots; +- original real Beta screenshots and their 320px, 480px, and 960px presentation variants; - manager screenshot; - portable Windows Native helper. -Gallery cards use the Registry capture first and fall back to the reviewed preview only when capture evidence is absent. +Gallery cards and the hero use responsive presentation variants, never the original evidence PNG during ordinary browsing. Opening the explicit evidence action loads the original 1440×810 capture together with its version, SHA-256, byte count, and fixture metadata. Each presentation variant carries its own hash, dimensions, renderer fingerprint, and a binding to the original evidence hash. ### Desktop application -The manager ships a bundled Registry and verified screenshots for browsing. Every catalog carries a monotonically increasing `catalogRevision`; a stale cache or remote descriptor can never replace a newer bundled catalog. The manager also checks the fixed catalog URL, revision, byte count, theme count, and SHA-256 before accepting a remote Registry. Full Skin PNGs are downloaded on demand and retained in the application cache only after hash verification. +The manager ships a bundled Registry plus shared 320px list and 960px detail presentations; it does not duplicate the original 1440×810 evidence PNGs for its browse surface. Cards are keyed by theme ID and retained across search, filter, and mode changes. An `IntersectionObserver` rooted at the list assigns list image sources only to visible or near-visible cards, so a mode change does not mutate distant card images. Every catalog carries a monotonically increasing `catalogRevision`; a stale cache or remote descriptor can never replace a newer bundled catalog. The manager also checks the fixed catalog URL, revision, byte count, theme count, and SHA-256 before accepting a remote Registry. Full Skin PNGs are downloaded on demand and retained in the application cache only after hash verification. ### Native fallback diff --git a/docs/desktop-manager.md b/docs/desktop-manager.md index 6ffa491..768537d 100644 --- a/docs/desktop-manager.md +++ b/docs/desktop-manager.md @@ -65,9 +65,12 @@ install_app_update ## Registry 与素材 -管理器包含一个构建时 Registry,以及从 136 张已验证实机截图确定性缩放出的 -720×405 浏览缩略图。完整 1440×810 证据仍保留在仓库和 Pages;桌面包不重复 -携带全部原始 PNG。启动后,管理器会读取 GitHub Pages 的 +管理器包含一个构建时 Registry,以及从 136 张已验证实机截图确定性派生的 +320×180 列表图和 960×540 详情图。Gallery 还会使用 480×270 响应式卡片图; +完整 1440×810 原始证据仍保留在仓库和 Pages,且只在用户明确选择查看原始证据时加载。 +桌面包不重复携带全部原始 PNG,列表卡片也按主题 ID 稳定复用;以列表为根的 +`IntersectionObserver` 只会为可见或临近可见的卡片设置 320px 图片源,因此筛选、搜索和 +模式切换不会重建整张卡片网格,也不会改写远处卡片的图片源。启动后,管理器会读取 GitHub Pages 的 `downloads/catalog.json`,先核对 Registry URL、单调递增的 `catalogRevision`、SHA-256、字节数、主题数和模式数,再下载 Registry。 较旧的缓存或远端目录不能覆盖较新的内置目录;远端失败时继续使用当前已验证目录。 @@ -136,7 +139,7 @@ Gatekeeper 和 Full Skin 读回仍需单独验收。 2560×1440 Blob 图片,并在 Pull requests 与首页之间保持文档背景; - 关闭后 Theme Manager、Beta 进程、用户启动项和本次动态 CDP 端口全部无残留; - 68 套主题、136 个模式完成实机截图; -- Pages 构建包含 136 张 Full Skin 原图。 +- Pages 构建包含 136 张原始实机证据,以及每张证据派生的 320、480、960px 展示图;Gallery 日常浏览不请求原始证据。 ## 发布与签名 diff --git a/schemas/registry.schema.json b/schemas/registry.schema.json index 180c485..524a28f 100644 --- a/schemas/registry.schema.json +++ b/schemas/registry.schema.json @@ -187,7 +187,8 @@ "assetSha256", "assetBytes", "fullSkin", - "nativeTheme" + "nativeTheme", + "capture" ], "properties": { "preview": { @@ -332,6 +333,24 @@ } }, "captureRecord": { + "type": "object", + "additionalProperties": false, + "required": ["evidence", "presentation"], + "properties": { + "evidence": { + "$ref": "#/$defs/captureEvidenceRecord" + }, + "presentation": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "$ref": "#/$defs/capturePresentationRecord" + } + } + } + }, + "captureEvidenceRecord": { "type": "object", "additionalProperties": false, "required": [ @@ -419,6 +438,52 @@ } } }, + "capturePresentationRecord": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "format", + "width", + "height", + "sha256", + "bytes", + "evidenceSha256", + "renderFingerprint" + ], + "properties": { + "path": { + "type": "string", + "pattern": "^screenshots/codex-beta-[0-9.]+/presentation/[a-z0-9-]+-(light|dark)-(320|480|960)\\.png$" + }, + "format": { + "const": "png" + }, + "width": { + "enum": [320, 480, 960] + }, + "height": { + "enum": [180, 270, 540] + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "bytes": { + "type": "integer", + "minimum": 1, + "maximum": 1048576 + }, + "evidenceSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "renderFingerprint": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + }, "hex": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" diff --git a/scripts/build.mjs b/scripts/build.mjs index 875d52a..27fe67d 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -92,6 +92,10 @@ export async function buildSite(outputPath = DEFAULT_OUTPUT) { const copied = new Set(); for (const theme of registry.themes) { + const capturePaths = (capture) => [ + capture?.evidence?.path, + ...(capture?.presentation || []).map((variant) => variant.path), + ]; const paths = [ theme.package.path, theme.package.manifest, @@ -102,8 +106,8 @@ export async function buildSite(outputPath = DEFAULT_OUTPUT) { theme.previews.dark.fullSkin?.asset, theme.previews.light.nativeTheme.path, theme.previews.dark.nativeTheme.path, - theme.previews.light.capture?.path, - theme.previews.dark.capture?.path, + ...capturePaths(theme.previews.light.capture), + ...capturePaths(theme.previews.dark.capture), ].filter(Boolean); for (const relativePath of paths) { if (copied.has(relativePath)) continue; diff --git a/scripts/lib/capture-presentation.mjs b/scripts/lib/capture-presentation.mjs new file mode 100644 index 0000000..d07f42a --- /dev/null +++ b/scripts/lib/capture-presentation.mjs @@ -0,0 +1,211 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { decodePng, readPngDimensions, resizeDecodedPng } from "./png.mjs"; + +export const CAPTURE_PRESENTATION_FORMAT = "png"; +export const CAPTURE_PRESENTATION_RENDERER = "act-capture-presentation-v1"; +export const CAPTURE_PRESENTATION_SIZES = Object.freeze([ + Object.freeze({ width: 320, height: 180 }), + Object.freeze({ width: 480, height: 270 }), + Object.freeze({ width: 960, height: 540 }), +]); +export const GALLERY_MEDIA_BUDGET = Object.freeze({ + maxCardBytes: 768 * 1024, + maxInitialBytes: 3 * 1024 * 1024, +}); + +let temporarySequence = 0; + +function sha256(buffer) { + return createHash("sha256").update(buffer).digest("hex"); +} + +function jsonBuffer(value) { + return Buffer.from(JSON.stringify(value, null, 2) + "\n", "utf8"); +} + +function safePath(root, relativePath) { + const target = path.resolve(root, ...relativePath.split("/")); + const boundary = root.endsWith(path.sep) ? root : root + path.sep; + if (!target.startsWith(boundary)) throw new Error("Capture presentation path escaped the repository"); + return target; +} + +export function presentationPathFor(evidencePath, width) { + const slash = evidencePath.lastIndexOf("/"); + const directory = evidencePath.slice(0, slash); + const name = evidencePath.slice(slash + 1).replace(/\.png$/u, ""); + return directory + "/presentation/" + name + "-" + width + ".png"; +} + +export function presentationManifestPathFor(evidencePath) { + const slash = evidencePath.lastIndexOf("/"); + return evidencePath.slice(0, slash) + "/presentation/manifest.json"; +} + +export function presentationFingerprint(capture, size) { + return sha256(Buffer.from(JSON.stringify({ + renderer: CAPTURE_PRESENTATION_RENDERER, + evidencePath: capture.path, + evidenceSha256: capture.sha256, + width: size.width, + height: size.height, + }), "utf8")); +} + +function captureKey(capture) { + return capture.themeId + "|" + capture.mode; +} + +function presentationMetadata(capture, size, data) { + return { + path: presentationPathFor(capture.path, size.width), + format: CAPTURE_PRESENTATION_FORMAT, + width: size.width, + height: size.height, + sha256: sha256(data), + bytes: data.length, + evidenceSha256: capture.sha256, + renderFingerprint: presentationFingerprint(capture, size), + }; +} + +async function atomicWrite(target, data) { + await mkdir(path.dirname(target), { recursive: true }); + const temporary = target + "." + process.pid + "." + Date.now() + "." + temporarySequence++ + ".tmp"; + await writeFile(temporary, data); + try { + await rename(temporary, target); + } catch (error) { + if (error.code !== "EEXIST" && error.code !== "EPERM") { + await rm(temporary, { force: true }); + throw error; + } + await rm(target, { force: true }); + await rename(temporary, target); + } +} + +async function matchesPresentation(root, record, capture, size) { + if (!record + || record.path !== presentationPathFor(capture.path, size.width) + || record.format !== CAPTURE_PRESENTATION_FORMAT + || record.width !== size.width + || record.height !== size.height + || record.evidenceSha256 !== capture.sha256 + || record.renderFingerprint !== presentationFingerprint(capture, size) + || typeof record.sha256 !== "string" + || !Number.isInteger(record.bytes)) { + return false; + } + try { + const data = await readFile(safePath(root, record.path)); + const dimensions = readPngDimensions(data); + return data.length === record.bytes + && sha256(data) === record.sha256 + && dimensions.width === size.width + && dimensions.height === size.height; + } catch { + return false; + } +} + +async function reusablePresentation(root, capture, record) { + if (!record || record.evidence?.path !== capture.path || record.evidence?.sha256 !== capture.sha256) { + return null; + } + const variants = record.presentation; + if (!Array.isArray(variants) || variants.length !== CAPTURE_PRESENTATION_SIZES.length) return null; + const ordered = []; + for (const size of CAPTURE_PRESENTATION_SIZES) { + const variant = variants.find((candidate) => candidate.width === size.width); + if (!await matchesPresentation(root, variant, capture, size)) return null; + ordered.push(variant); + } + return ordered; +} + +async function reconcileFile(root, relativePath, data, check, drift) { + const target = safePath(root, relativePath); + try { + if ((await readFile(target)).equals(data)) return; + } catch {} + if (check) { + drift.push(relativePath); + return; + } + await atomicWrite(target, data); +} + +async function readExistingManifest(root, manifestPath) { + try { + return JSON.parse(await readFile(safePath(root, manifestPath), "utf8")); + } catch { + return null; + } +} + +export async function generateCapturePresentations({ root, captureContext, check = false } = {}) { + if (!captureContext?.manifest || !Array.isArray(captureContext.manifest.captures)) { + return { index: new Map(), cacheHits: 0, cacheMisses: 0, drift: [] }; + } + const captures = captureContext.manifest.captures; + if (!captures.length) return { index: new Map(), cacheHits: 0, cacheMisses: 0, drift: [] }; + const manifestPath = presentationManifestPathFor(captures[0].path); + const existing = await readExistingManifest(root, manifestPath); + const existingRecords = new Map((existing?.captures || []).map((record) => [ + record.themeId + "|" + record.mode, + record, + ])); + const drift = []; + const index = new Map(); + const records = []; + let cacheHits = 0; + let cacheMisses = 0; + + for (const capture of captures) { + const key = captureKey(capture); + let presentation = await reusablePresentation(root, capture, existingRecords.get(key)); + if (presentation) { + cacheHits += presentation.length; + } else { + const source = await readFile(safePath(root, capture.path)); + if (sha256(source) !== capture.sha256) { + throw new Error("Capture evidence hash does not match manifest for " + key); + } + const decoded = decodePng(source); + presentation = []; + for (const size of CAPTURE_PRESENTATION_SIZES) { + const data = resizeDecodedPng(decoded, size.width, size.height); + const metadata = presentationMetadata(capture, size, data); + await reconcileFile(root, metadata.path, data, check, drift); + presentation.push(metadata); + cacheMisses += 1; + } + } + index.set(key, presentation); + records.push({ + themeId: capture.themeId, + mode: capture.mode, + evidence: { + path: capture.path, + sha256: capture.sha256, + bytes: capture.bytes, + width: capture.width, + height: capture.height, + }, + presentation, + }); + } + + const manifest = { + schemaVersion: "act-capture-presentation-manifest-v1", + renderer: CAPTURE_PRESENTATION_RENDERER, + sourceManifest: presentationManifestPathFor(captures[0].path).replace(/\/presentation\/manifest\.json$/u, "/manifest.json"), + captures: records, + }; + await reconcileFile(root, manifestPath, jsonBuffer(manifest), check, drift); + return { index, cacheHits, cacheMisses, drift }; +} diff --git a/scripts/lib/png.mjs b/scripts/lib/png.mjs index 13e3e35..db96f80 100644 --- a/scripts/lib/png.mjs +++ b/scripts/lib/png.mjs @@ -556,8 +556,7 @@ function sampleBilinear(image, x, y) { return result; } -export function resizePng(buffer, width, height) { - const image = decodePng(buffer); +export function resizeDecodedPng(image, width, height) { const scaleX = image.width / width; const scaleY = image.height / height; return encodePng(width, height, (x, y) => sampleBilinear( @@ -567,6 +566,10 @@ export function resizePng(buffer, width, height) { )); } +export function resizePng(buffer, width, height) { + return resizeDecodedPng(decodePng(buffer), width, height); +} + function gradeSourcePixel(pixel, theme, mode, normalizedX, normalizedY) { const grading = SOURCE_ART_RENDERER_CONSTANTS.grading; const background = parseHex(theme[mode].tokens.background); diff --git a/scripts/lib/theme-generator.mjs b/scripts/lib/theme-generator.mjs index e49afa3..accd9d1 100644 --- a/scripts/lib/theme-generator.mjs +++ b/scripts/lib/theme-generator.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { Worker } from "node:worker_threads"; +import { generateCapturePresentations } from "./capture-presentation.mjs"; import { readPngDimensions } from "./png.mjs"; import { renderFingerprint, SOURCE_ART_RENDERER_ID } from "./source-art-contract.mjs"; import { createZip } from "./zip.mjs"; @@ -374,12 +375,27 @@ function createThemeOutputs(theme, assets, previews, nativeThemes, manifest, man ]; } -function registryRecordFor(theme, manifest, manifestBuffer, canonicalPackage, previews, assets, nativeThemes, sourceProvenance, captureContext) { +function registryRecordFor( + theme, + manifest, + manifestBuffer, + canonicalPackage, + previews, + assets, + nativeThemes, + sourceProvenance, + captureContext, + presentationIndex, +) { const rights = rightsFor(theme); const themeRoot = "themes/" + theme.id; const modeRecords = {}; for (const mode of MODES) { const capture = captureContext.index.get(theme.id + "|" + mode); + const presentation = presentationIndex.get(theme.id + "|" + mode); + if (capture && !presentation) { + throw new Error("Capture presentation metadata is missing for " + theme.id + " " + mode); + } modeRecords[mode] = { preview: themeRoot + "/previews/" + mode + ".png", previewSha256: sha256(previews[mode]), @@ -406,20 +422,23 @@ function registryRecordFor(theme, manifest, manifestBuffer, canonicalPackage, pr }, ...(capture ? { capture: { - path: capture.path, - sha256: capture.sha256, - bytes: capture.bytes, - width: capture.width, - height: capture.height, - assetSha256: capture.assetSha256, - runtimeSha256: capture.runtimeSha256, - markerVersion: capture.markerVersion, - selectors: capture.selectors, - appVersion: captureContext.manifest.testBench.version, - packageFullName: captureContext.manifest.testBench.packageFullName, - fixture: captureContext.manifest.fixture.id, - modelLabel: capture.modelLabel, - capturedAt: capture.capturedAt, + evidence: { + path: capture.path, + sha256: capture.sha256, + bytes: capture.bytes, + width: capture.width, + height: capture.height, + assetSha256: capture.assetSha256, + runtimeSha256: capture.runtimeSha256, + markerVersion: capture.markerVersion, + selectors: capture.selectors, + appVersion: captureContext.manifest.testBench.version, + packageFullName: captureContext.manifest.testBench.packageFullName, + fixture: captureContext.manifest.fixture.id, + modelLabel: capture.modelLabel, + capturedAt: capture.capturedAt, + }, + presentation, }, } : {}), }; @@ -539,7 +558,17 @@ async function runBounded(items, concurrency, worker) { return results; } -async function generateTheme({ root, theme, sourceProvenance, existingManifest, existingRecord, force, render, captureContext }) { +async function generateTheme({ + root, + theme, + sourceProvenance, + existingManifest, + existingRecord, + force, + render, + captureContext, + presentationIndex, +}) { const assets = {}; const previews = {}; const missing = []; @@ -611,6 +640,7 @@ async function generateTheme({ root, theme, sourceProvenance, existingManifest, nativeThemes, sourceProvenance, captureContext, + presentationIndex, ), }; } @@ -628,6 +658,9 @@ function hasCurrentRegistryRecord(record) { return MODES.every((mode) => ( Number.isInteger(record?.previews?.[mode]?.previewBytes) && typeof record.previews[mode].previewRenderFingerprint === "string" + && typeof record.previews[mode].capture?.evidence?.path === "string" + && Array.isArray(record.previews[mode].capture?.presentation) + && record.previews[mode].capture.presentation.length === 3 )); } @@ -755,8 +788,13 @@ export async function generateRepository({ } const captureContext = await loadCaptureContext(root); + const capturePresentations = await generateCapturePresentations({ + root, + captureContext, + check: normalized.check, + }); const records = new Map(existingRecords); - const drift = []; + const drift = [...capturePresentations.drift]; const stats = { cacheHits: 0, cacheMisses: 0, @@ -785,6 +823,7 @@ export async function generateRepository({ force: normalized.force, render, captureContext, + presentationIndex: capturePresentations.index, }); for (const output of result.outputs) { await reconcileFile(root, output, normalized.check, drift, collectOutput); @@ -812,12 +851,14 @@ export async function generateRepository({ } const summary = { - cacheHits: stats.cacheHits, - cacheMisses: stats.cacheMisses, + cacheHits: stats.cacheHits + capturePresentations.cacheHits, + cacheMisses: stats.cacheMisses + capturePresentations.cacheMisses, elapsedMs: Date.now() - stats.startedAt, generatedThemes: themes.length, peakRssBytes: stats.peakRssBytes, renderer: SOURCE_ART_RENDERER_ID, + presentationCacheHits: capturePresentations.cacheHits, + presentationCacheMisses: capturePresentations.cacheMisses, totalThemes: catalog.themes.length, ...(drift.length ? { drift } : {}), }; diff --git a/scripts/prepare-desktop.mjs b/scripts/prepare-desktop.mjs index 970a802..ef9d18a 100644 --- a/scripts/prepare-desktop.mjs +++ b/scripts/prepare-desktop.mjs @@ -1,8 +1,8 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { encodePng, resizePng } from "./lib/png.mjs"; +import { encodePng } from "./lib/png.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(SCRIPT_DIR, ".."); @@ -10,8 +10,10 @@ const REGISTRY = path.join(ROOT, "themes", "registry.json"); const ICON = path.join(ROOT, "apps", "theme-manager", "build", "icon.png"); const DESKTOP_BUILD = path.join(ROOT, "apps", "theme-manager", "build"); const DESKTOP_CATALOG = path.join(DESKTOP_BUILD, "catalog"); -const CAPTURE_WIDTH = 720; -const CAPTURE_HEIGHT = 405; +const PRESENTATION_SIZES = [ + { width: 320, height: 180 }, + { width: 960, height: 540 }, +]; function insideRoundedRect(x, y, left, top, right, bottom, radius) { const closestX = Math.max(left + radius, Math.min(right - radius, x)); @@ -71,27 +73,31 @@ async function main() { throw new Error("Desktop catalog output escaped the generated build directory"); } await rm(DESKTOP_CATALOG, { recursive: true, force: true }); - let captures = 0; + let presentationAssets = 0; for (const theme of registry.themes) { for (const mode of ["light", "dark"]) { - const relative = theme.previews?.[mode]?.capture?.path; - if (typeof relative !== "string" - || !/^screenshots\/codex-beta-[A-Za-z0-9.-]+\/[a-z0-9-]+-(?:light|dark)\.png$/.test(relative)) { - throw new Error(theme.id + " " + mode + " has an unsafe desktop capture path"); + const presentation = theme.previews?.[mode]?.capture?.presentation; + for (const size of PRESENTATION_SIZES) { + const variant = presentation?.find((candidate) => candidate?.width === size.width); + const relative = variant?.path; + if (typeof relative !== "string" + || variant.height !== size.height + || !new RegExp( + "^screenshots/codex-beta-[A-Za-z0-9.-]+/presentation/[a-z0-9-]+-(?:light|dark)-" + size.width + "\\.png$", + ).test(relative)) { + throw new Error(theme.id + " " + mode + " has an unsafe desktop presentation path"); + } + const source = path.join(ROOT, ...relative.split("/")); + const destination = path.join(DESKTOP_CATALOG, ...relative.split("/")); + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(source, destination); + presentationAssets += 1; } - const source = path.join(ROOT, ...relative.split("/")); - const destination = path.join(DESKTOP_CATALOG, ...relative.split("/")); - await mkdir(path.dirname(destination), { recursive: true }); - await writeFile( - destination, - resizePng(await readFile(source), CAPTURE_WIDTH, CAPTURE_HEIGHT), - ); - captures += 1; } } console.log( "Prepared desktop assets for " + registry.themes.length + " themes and " - + captures + " verified " + CAPTURE_WIDTH + "x" + CAPTURE_HEIGHT + " capture thumbnails.", + + presentationAssets + " shared 320px list and 960px detail presentations.", ); } diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 52ebd04..a81e37c 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -4,6 +4,14 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { readPngDimensions } from "./lib/png.mjs"; +import { + CAPTURE_PRESENTATION_RENDERER, + CAPTURE_PRESENTATION_SIZES, + GALLERY_MEDIA_BUDGET, + presentationFingerprint, + presentationManifestPathFor, + presentationPathFor, +} from "./lib/capture-presentation.mjs"; import { extractStoredEntry, listZipEntries } from "./lib/zip.mjs"; import { CODEX_NATIVE_TESTED_VERSION, @@ -21,6 +29,10 @@ const FORBIDDEN_PROMPT = /凡人修仙传|仙逆|剑来|斗破苍穹|studio\s+gh const MAX_IMAGE_BYTES = 16 * 1024 * 1024; const CODEX_BETA_CAPTURE_VERSION = "26.715.3651.0"; const CODEX_BETA_PACKAGE = "OpenAI.CodexBeta_26.715.3651.0_x64__2p2nqsd0c76g0"; +const CAPTURE_MANIFEST_PATH = "screenshots/codex-beta-" + CODEX_BETA_CAPTURE_VERSION + "/manifest.json"; +const CAPTURE_PRESENTATION_MANIFEST_PATH = presentationManifestPathFor( + "screenshots/codex-beta-" + CODEX_BETA_CAPTURE_VERSION + "/placeholder.png", +); const FULL_SKIN_FORMAT = "act-full-skin-v1"; const SOURCE_WIDTH = 1536; const SOURCE_HEIGHT = 1024; @@ -168,13 +180,17 @@ function validateNativeTheme(theme, mode, nativeBytes, modeRecord, manifestMode, export async function validateRepository() { const errors = []; let captureCount = 0; + let presentationVariantCount = 0; + let largestCardPresentationBytes = 0; + let largestPresentationBytes = 0; let fullSkinCount = 0; const nativeThemeOwners = new Map(); - const [catalog, registry, sourceJobs, captureManifest, themeSchema, registrySchema, runtimeCss, runtimeJs] = await Promise.all([ + const [catalog, registry, sourceJobs, captureManifest, presentationManifest, themeSchema, registrySchema, runtimeCss, runtimeJs] = await Promise.all([ readJson("themes/catalog.json"), readJson("themes/registry.json"), readJson("themes/source-art/jobs.json"), - readJson("screenshots/codex-beta-26.715.3651.0/manifest.json"), + readJson(CAPTURE_MANIFEST_PATH), + readJson(CAPTURE_PRESENTATION_MANIFEST_PATH), readJson("schemas/theme-pack.schema.json"), readJson("schemas/registry.schema.json"), readFile(path.join(ROOT, "packages", "full-skin", "runtime.css"), "utf8"), @@ -185,6 +201,34 @@ export async function validateRepository() { check(themeSchema.$schema?.includes("2020-12"), "Theme schema is not JSON Schema 2020-12", errors); check(registrySchema.$schema?.includes("2020-12"), "Registry schema is not JSON Schema 2020-12", errors); check(registry.standard === "act-theme-pack-v1", "Registry standard id mismatch", errors); + check( + presentationManifest.schemaVersion === "act-capture-presentation-manifest-v1", + "Capture presentation manifest schema mismatch", + errors, + ); + check( + presentationManifest.renderer === CAPTURE_PRESENTATION_RENDERER, + "Capture presentation renderer mismatch", + errors, + ); + check( + presentationManifest.sourceManifest === CAPTURE_MANIFEST_PATH, + "Capture presentation manifest source mismatch", + errors, + ); + const presentationRecords = Array.isArray(presentationManifest.captures) + ? presentationManifest.captures + : []; + const presentationByCapture = new Map(presentationRecords.map((record) => [ + record.themeId + "|" + record.mode, + record, + ])); + check( + presentationRecords.length === registry.themes.length * 2 + && presentationByCapture.size === presentationRecords.length, + "Capture presentation manifest must have one unique record for every theme mode", + errors, + ); check( Number.isInteger(catalog.catalogRevision) && catalog.catalogRevision > 0 @@ -570,28 +614,105 @@ export async function validateRepository() { ); if (!duplicateOwner) nativeThemeOwners.set(nativeThemeSha256, theme.id + " " + mode); const capture = modeRecord.capture; - check(Boolean(capture), theme.id + " " + mode + " is missing a real Codex Beta capture", errors); - if (capture) { + const evidence = capture?.evidence; + check(Boolean(evidence), theme.id + " " + mode + " is missing a real Codex Beta capture", errors); + if (evidence) { captureCount += 1; - check(isSafeRelativePath(capture.path), theme.id + " " + mode + " capture path is unsafe", errors); + check(isSafeRelativePath(evidence.path), theme.id + " " + mode + " capture path is unsafe", errors); check( - capture.path === "screenshots/codex-beta-" + CODEX_BETA_CAPTURE_VERSION + "/" + theme.id + "-" + mode + ".png", + evidence.path === "screenshots/codex-beta-" + CODEX_BETA_CAPTURE_VERSION + "/" + theme.id + "-" + mode + ".png", theme.id + " " + mode + " capture path mismatch", errors, ); - const captureBytes = await readBytes(capture.path); + const captureBytes = await readBytes(evidence.path); const captureDimensions = readPngDimensions(captureBytes); check(captureDimensions.width === 1440 && captureDimensions.height === 810, theme.id + " " + mode + " capture dimensions mismatch", errors); - check(capture.width === 1440 && capture.height === 810, theme.id + " " + mode + " capture metadata dimensions mismatch", errors); - check(capture.bytes === captureBytes.length, theme.id + " " + mode + " capture byte count mismatch", errors); - check(capture.sha256 === sha256(captureBytes), theme.id + " " + mode + " capture hash mismatch", errors); - check(capture.assetSha256 === modeRecord.fullSkin.sha256, theme.id + " " + mode + " capture asset hash mismatch", errors); - check(capture.runtimeSha256 === runtimeSha256, theme.id + " " + mode + " capture runtime hash mismatch", errors); - check(capture.markerVersion === FULL_SKIN_FORMAT, theme.id + " " + mode + " capture marker mismatch", errors); - check(capture.selectors?.main === true, theme.id + " " + mode + " capture main selector was not verified", errors); - check(capture.appVersion === CODEX_BETA_CAPTURE_VERSION, theme.id + " " + mode + " capture app version mismatch", errors); - check(capture.packageFullName === CODEX_BETA_PACKAGE, theme.id + " " + mode + " capture package identity mismatch", errors); - check(capture.fixture === "full-skin-home-v1", theme.id + " " + mode + " capture fixture mismatch", errors); + check(evidence.width === 1440 && evidence.height === 810, theme.id + " " + mode + " capture metadata dimensions mismatch", errors); + check(evidence.bytes === captureBytes.length, theme.id + " " + mode + " capture byte count mismatch", errors); + check(evidence.sha256 === sha256(captureBytes), theme.id + " " + mode + " capture hash mismatch", errors); + check(evidence.assetSha256 === modeRecord.fullSkin.sha256, theme.id + " " + mode + " capture asset hash mismatch", errors); + check(evidence.runtimeSha256 === runtimeSha256, theme.id + " " + mode + " capture runtime hash mismatch", errors); + check(evidence.markerVersion === FULL_SKIN_FORMAT, theme.id + " " + mode + " capture marker mismatch", errors); + check(evidence.selectors?.main === true, theme.id + " " + mode + " capture main selector was not verified", errors); + check(evidence.appVersion === CODEX_BETA_CAPTURE_VERSION, theme.id + " " + mode + " capture app version mismatch", errors); + check(evidence.packageFullName === CODEX_BETA_PACKAGE, theme.id + " " + mode + " capture package identity mismatch", errors); + check(evidence.fixture === "full-skin-home-v1", theme.id + " " + mode + " capture fixture mismatch", errors); + + const presentation = Array.isArray(capture?.presentation) ? capture.presentation : []; + check( + presentation.length === CAPTURE_PRESENTATION_SIZES.length, + theme.id + " " + mode + " must have every required capture presentation size", + errors, + ); + const expectedPresentation = []; + for (const size of CAPTURE_PRESENTATION_SIZES) { + const variant = presentation.find((candidate) => candidate?.width === size.width); + check(Boolean(variant), theme.id + " " + mode + " is missing the " + size.width + "px presentation", errors); + if (!variant) continue; + expectedPresentation.push(variant); + check(isSafeRelativePath(variant.path), theme.id + " " + mode + " presentation path is unsafe", errors); + check( + variant.path === presentationPathFor(evidence.path, size.width), + theme.id + " " + mode + " presentation path mismatch for " + size.width + "px", + errors, + ); + check(variant.format === "png", theme.id + " " + mode + " presentation format mismatch", errors); + check( + variant.width === size.width && variant.height === size.height, + theme.id + " " + mode + " presentation dimensions mismatch for " + size.width + "px", + errors, + ); + check(variant.evidenceSha256 === evidence.sha256, theme.id + " " + mode + " presentation evidence hash mismatch", errors); + check( + variant.renderFingerprint === presentationFingerprint(evidence, size), + theme.id + " " + mode + " presentation render fingerprint mismatch", + errors, + ); + const presentationBytes = await readBytes(variant.path); + const presentationDimensions = readPngDimensions(presentationBytes); + check( + presentationDimensions.width === size.width && presentationDimensions.height === size.height, + theme.id + " " + mode + " presentation file dimensions mismatch for " + size.width + "px", + errors, + ); + check(variant.bytes === presentationBytes.length, theme.id + " " + mode + " presentation byte count mismatch", errors); + check(variant.sha256 === sha256(presentationBytes), theme.id + " " + mode + " presentation hash mismatch", errors); + check( + presentationBytes.length <= 1024 * 1024, + theme.id + " " + mode + " presentation exceeds the per-asset byte limit", + errors, + ); + largestPresentationBytes = Math.max(largestPresentationBytes, presentationBytes.length); + if (size.width === 480) { + largestCardPresentationBytes = Math.max(largestCardPresentationBytes, presentationBytes.length); + check( + presentationBytes.length <= GALLERY_MEDIA_BUDGET.maxCardBytes, + theme.id + " " + mode + " 480px card presentation exceeds the gallery byte budget", + errors, + ); + } + presentationVariantCount += 1; + } + const manifestRecord = presentationByCapture.get(theme.id + "|" + mode); + check(Boolean(manifestRecord), theme.id + " " + mode + " is missing from the presentation manifest", errors); + if (manifestRecord) { + check( + JSON.stringify(manifestRecord.evidence) === JSON.stringify({ + path: evidence.path, + sha256: evidence.sha256, + bytes: evidence.bytes, + width: evidence.width, + height: evidence.height, + }), + theme.id + " " + mode + " presentation manifest evidence mismatch", + errors, + ); + check( + JSON.stringify(manifestRecord.presentation) === JSON.stringify(expectedPresentation), + theme.id + " " + mode + " presentation manifest variants mismatch", + errors, + ); + } } validateTokens(theme.id, mode, manifestMode.tokens, errors); validateNativeTheme(theme, mode, nativeTheme, modeRecord, manifestMode, packageBytes, errors); @@ -599,6 +720,16 @@ export async function validateRepository() { } check(captureCount === registry.themes.length * 2, "Registry must expose one real capture for every theme mode", errors); + check( + presentationVariantCount === registry.themes.length * 2 * CAPTURE_PRESENTATION_SIZES.length, + "Registry must expose every required capture presentation variant", + errors, + ); + check( + 3 * largestPresentationBytes <= GALLERY_MEDIA_BUDGET.maxInitialBytes, + "High-DPR Gallery initial-media byte budget is exceeded", + errors, + ); check(fullSkinCount === registry.themes.length * 2, "Registry must expose one full-skin record for every theme mode", errors); if (errors.length) { throw new Error("Validation failed:\n- " + errors.join("\n- ")); @@ -611,6 +742,8 @@ export async function validateRepository() { fullSkinExports: fullSkinCount, nativeExports: registry.themes.length * 2, captures: captureCount, + presentationVariants: presentationVariantCount, + conservativeGalleryInitialBytes: 3 * largestPresentationBytes, }; } @@ -620,7 +753,9 @@ async function main() { "Validated " + result.sources + " source images, " + result.themes + " themes, " + result.modes + " modes, " + result.packages + " code-free packages, " + result.fullSkinExports + " full-skin records, " + result.nativeExports + " Codex Native fallbacks, and " - + result.captures + " real Beta captures.", + + result.captures + " real Beta captures with " + result.presentationVariants + + " presentation variants (conservative initial Gallery media: " + + result.conservativeGalleryInitialBytes + " bytes).", ); } diff --git a/site/assets/app.js b/site/assets/app.js index 6507870..e67a31c 100644 --- a/site/assets/app.js +++ b/site/assets/app.js @@ -49,6 +49,13 @@ const translations = { filterScene: "Memory scene", clearFilters: "Clear filters", emptyState: "No themes match this view.", + viewDetails: "View details", + viewEvidence: "View original evidence", + previewMode: "Preview mode", + heroMode: "Hero preview mode", + closeDialog: "Close details", + evidenceMetadata: "Original evidence · Beta {version} · SHA-256 {hash} · {bytes} bytes · {fixture}", + presentationFallback: "Presentation image unavailable; showing reviewed cover art.", standardEyebrow: "ACT theme pack standard", standardTitle: "Before a theme looks good, it should earn your trust.", standardIntro: "The canonical .act-theme file is a ZIP-compatible package with one manifest and declared assets. JavaScript, shell scripts, remote CSS, and hidden runtime behavior are not allowed inside it.", @@ -174,6 +181,13 @@ const translations = { filterScene: "名场面", clearFilters: "清除筛选", emptyState: "当前条件下没有主题。", + viewDetails: "查看详情", + viewEvidence: "查看原始证据", + previewMode: "预览模式", + heroMode: "首页预览模式", + closeDialog: "关闭详情", + evidenceMetadata: "原始证据 · Beta {version} · SHA-256 {hash} · {bytes} 字节 · {fixture}", + presentationFallback: "展示图暂不可用,正在显示已审查的封面图。", standardEyebrow: "ACT 主题包标准", standardTitle: "好看之前,先让一套主题值得信任。", standardIntro: "标准 .act-theme 是兼容 ZIP 的纯声明包,只包含一份 manifest 和已声明素材。包内不允许 JavaScript、Shell 脚本、远程 CSS 或隐藏运行逻辑。", @@ -298,24 +312,30 @@ const elements = { gallery: document.querySelector("#gallery"), empty: document.querySelector("#emptyState"), dialog: document.querySelector("#installDialog"), + dialogClose: document.querySelector(".dialog-close"), dialogPreview: document.querySelector("#dialogPreview"), dialogVariant: document.querySelector("#dialogVariant"), dialogTitle: document.querySelector("#dialogTitle"), dialogTagline: document.querySelector("#dialogTagline"), dialogDescription: document.querySelector("#dialogDescription"), + evidenceMetadata: document.querySelector("#evidenceMetadata"), dialogMode: document.querySelector("#dialogMode"), capabilityNote: document.querySelector("#capabilityNote"), dialogRights: document.querySelector("#dialogRights"), nativeThemeString: document.querySelector("#nativeThemeString"), copyTheme: document.querySelector("#copyTheme"), downloadNativeTheme: document.querySelector("#downloadNativeTheme"), + viewEvidence: document.querySelector("#viewEvidence"), downloadPackage: document.querySelector("#downloadPackage"), trustNote: document.querySelector("#trustNote"), toast: document.querySelector("#toast") }; -function t(key) { - return translations[state.locale][key] || translations.en[key] || key; +function t(key, values = {}) { + const template = translations[state.locale][key] || translations.en[key] || key; + return Object.entries(values).reduce(function (result, entry) { + return result.replaceAll("{" + entry[0] + "}", String(entry[1])); + }, template); } function renderCollectionIntro() { @@ -376,18 +396,57 @@ function reorderGalleryForLocale() { state.heroTheme = themes[0] || null; } -function visualFor(modeRecord) { - if (modeRecord.capture) { +function captureEvidence(modeRecord) { + const capture = modeRecord.capture; + if (!capture) return null; + return capture.evidence || (capture.path ? capture : null); +} + +function presentationFor(modeRecord, width) { + return modeRecord.capture?.presentation?.find(function (variant) { + return variant.width === width; + }) || null; +} + +function presentationSrcset(modeRecord) { + const presentation = modeRecord.capture?.presentation; + if (!Array.isArray(presentation)) return ""; + return presentation + .slice() + .sort(function (left, right) { return left.width - right.width; }) + .map(function (variant) { return assetUrl(variant.path) + " " + variant.width + "w"; }) + .join(", "); +} + +function visualFor(modeRecord, purpose = "card") { + const evidence = captureEvidence(modeRecord); + const presentation = presentationFor(modeRecord, purpose === "card" ? 480 : 960); + if (presentation && evidence) { return { - path: modeRecord.capture.path, + path: presentation.path, label: t("realCapture"), - badge: t("captureBadge") + " · BETA " + modeRecord.capture.appVersion + badge: t("captureBadge") + " · BETA " + evidence.appVersion, + width: presentation.width, + height: presentation.height, + srcset: presentationSrcset(modeRecord) }; } return { path: modeRecord.preview, label: t("coverArt"), - badge: t("coverBadge") + badge: t("coverBadge"), + width: 960, + height: 540 + }; +} + +function fallbackVisual(modeRecord) { + return { + path: modeRecord.preview, + label: t("coverArt"), + badge: t("coverBadge"), + width: 960, + height: 540 }; } @@ -401,6 +460,26 @@ function assetUrl(relativePath) { return new URL("./" + relativePath, window.location.href).href; } +function setVisualImage(image, visual, sizes, fallback = null, onFallback = null) { + const source = assetUrl(visual.path); + image.dataset.visualSource = source; + image.onerror = fallback && fallback.path !== visual.path ? function () { + if (image.dataset.visualSource !== source) return; + onFallback?.(fallback); + setVisualImage(image, fallback, sizes); + } : null; + if (visual.srcset) { + image.srcset = visual.srcset; + image.sizes = sizes; + } else { + image.removeAttribute("srcset"); + image.removeAttribute("sizes"); + } + if (visual.width) image.width = visual.width; + if (visual.height) image.height = visual.height; + image.src = source; +} + function setActiveButton(container, attribute, value) { container.querySelectorAll("button").forEach(function (button) { const active = button.getAttribute(attribute) === value; @@ -484,12 +563,23 @@ function renderFilterLabels() { function updateHero(theme = state.heroTheme) { if (!theme) return; state.heroTheme = theme; - const visual = visualFor(theme.previews[state.heroMode]); - elements.heroPreview.src = assetUrl(visual.path); + const modeRecord = theme.previews[state.heroMode]; + const visual = visualFor(modeRecord, "hero"); + setVisualImage( + elements.heroPreview, + visual, + "(max-width: 720px) 100vw, (max-width: 1200px) 80vw, 960px", + fallbackVisual(modeRecord), + function (fallback) { + elements.heroPreview.alt = localized(theme.name) + " · " + fallback.label + " · " + t(state.heroMode); + elements.heroThemeMeta.textContent = fallback.label + " · " + localized(collectionFor(theme)?.name) + " · " + t(state.heroMode); + elements.heroVisualKind.textContent = "COVER ART"; + }, + ); elements.heroPreview.alt = localized(theme.name) + " · " + visual.label + " · " + t(state.heroMode); elements.heroThemeName.textContent = localized(theme.name); elements.heroThemeMeta.textContent = visual.label + " · " + localized(collectionFor(theme)?.name) + " · " + t(state.heroMode); - elements.heroVisualKind.textContent = theme.previews[state.heroMode].capture ? "BETA CAPTURE" : "COVER ART"; + elements.heroVisualKind.textContent = captureEvidence(theme.previews[state.heroMode]) ? "BETA CAPTURE" : "COVER ART"; setActiveButton(elements.heroMode, "data-mode", state.heroMode); } @@ -503,6 +593,8 @@ function updateLanguage() { document.querySelectorAll("[data-i18n-placeholder]").forEach(function (node) { node.placeholder = t(node.dataset.i18nPlaceholder); }); + elements.heroMode.setAttribute("aria-label", t("heroMode")); + elements.dialogClose.setAttribute("aria-label", t("closeDialog")); renderCollectionPicker(); renderFilterLabels(); @@ -517,7 +609,9 @@ function updateLanguage() { card.variant.textContent = t(theme.variant); card.original.textContent = theme.rightsProfile === "fan-art" ? t("fanArtBadge") : t("original"); card.collection.textContent = localized(collectionFor(theme)?.name); - card.install.setAttribute("aria-label", t("installTheme") + ": " + localized(theme.name)); + card.install.setAttribute("aria-label", t("viewDetails") + ": " + localized(theme.name)); + card.install.textContent = t("viewDetails"); + card.toggle.setAttribute("aria-label", t("previewMode")); card.lightButton.setAttribute("aria-label", t("light")); card.darkButton.setAttribute("aria-label", t("dark")); card.lightButton.textContent = state.locale === "zh-CN" ? "日" : "L"; @@ -539,7 +633,7 @@ function makeButton(label, className) { return button; } -function createCard(theme, index) { +function createCard(theme) { const article = document.createElement("article"); article.className = "theme-card"; article.dataset.variant = theme.variant; @@ -550,14 +644,14 @@ function createCard(theme, index) { const visual = document.createElement("div"); visual.className = "card-visual"; const image = document.createElement("img"); - image.loading = index < 2 ? "eager" : "lazy"; + image.loading = "lazy"; image.decoding = "async"; visual.append(image); const toggle = document.createElement("div"); toggle.className = "card-mode-toggle"; toggle.setAttribute("role", "group"); - toggle.setAttribute("aria-label", "Preview mode"); + toggle.setAttribute("aria-label", t("previewMode")); const lightButton = makeButton(state.locale === "zh-CN" ? "日" : "L"); const darkButton = makeButton(state.locale === "zh-CN" ? "夜" : "D"); lightButton.dataset.mode = "light"; @@ -587,7 +681,8 @@ function createCard(theme, index) { const description = document.createElement("p"); description.className = "card-description"; copy.append(meta, title, tagline, description); - const install = makeButton("↗", "card-install"); + const install = makeButton(t("viewDetails"), "card-install"); + install.setAttribute("aria-label", t("viewDetails") + ": " + localized(theme.name)); body.append(copy, install); article.append(visual, body); @@ -596,8 +691,18 @@ function createCard(theme, index) { function updateMode(mode) { state.cardModes.set(theme.id, mode); - const visualRecord = visualFor(theme.previews[mode]); - image.src = assetUrl(visualRecord.path); + const modeRecord = theme.previews[mode]; + const visualRecord = visualFor(modeRecord, "card"); + setVisualImage( + image, + visualRecord, + "(max-width: 720px) 100vw, (max-width: 1100px) 50vw, 520px", + fallbackVisual(modeRecord), + function (fallback) { + image.alt = localized(theme.name) + " · " + fallback.label + " · " + t(mode); + license.textContent = t("presentationFallback"); + }, + ); image.alt = localized(theme.name) + " · " + visualRecord.label + " · " + t(mode); license.textContent = visualRecord.badge; setActiveButton(toggle, "data-mode", mode); @@ -632,6 +737,7 @@ function createCard(theme, index) { variant, collection, install, + toggle, lightButton, darkButton, updateMode @@ -687,13 +793,39 @@ function updateDialog() { const theme = state.currentTheme; if (!theme) return; const modeRecord = theme.previews[state.dialogMode]; - const visual = visualFor(modeRecord); + const visual = visualFor(modeRecord, "hero"); + const evidence = captureEvidence(modeRecord); elements.dialogTitle.textContent = localized(theme.name); elements.dialogTagline.textContent = localized(theme.tagline); elements.dialogDescription.textContent = localized(theme.description); - elements.dialogPreview.src = assetUrl(visual.path); + const evidenceMetadata = evidence + ? t("evidenceMetadata", { + version: evidence.appVersion, + hash: evidence.sha256, + bytes: new Intl.NumberFormat(state.locale).format(evidence.bytes), + fixture: evidence.fixture + }) + : ""; + setVisualImage( + elements.dialogPreview, + visual, + "100vw", + fallbackVisual(modeRecord), + function (fallback) { + elements.dialogPreview.alt = localized(theme.name) + " · " + fallback.label + " · " + t(state.dialogMode); + elements.evidenceMetadata.textContent = [t("presentationFallback"), evidenceMetadata].filter(Boolean).join(" "); + }, + ); elements.dialogPreview.alt = localized(theme.name) + " · " + visual.label + " · " + t(state.dialogMode); elements.dialogVariant.textContent = visual.label + " · " + localized(collectionFor(theme)?.name) + " · " + t(theme.variant); + elements.evidenceMetadata.textContent = evidenceMetadata; + elements.evidenceMetadata.hidden = !evidence; + elements.viewEvidence.hidden = !evidence; + if (evidence) { + elements.viewEvidence.href = assetUrl(evidence.path); + } else { + elements.viewEvidence.removeAttribute("href"); + } elements.capabilityNote.textContent = t("capabilityNative"); elements.dialogRights.hidden = theme.rightsProfile !== "fan-art"; elements.dialogRights.textContent = theme.rightsProfile === "fan-art" @@ -830,8 +962,8 @@ async function loadRegistry() { elements.themeStatValue.textContent = String(registry.themes.length).padStart(2, "0"); renderCollectionPicker(); const fragment = document.createDocumentFragment(); - registry.themes.forEach(function (theme, index) { - fragment.append(createCard(theme, index)); + registry.themes.forEach(function (theme) { + fragment.append(createCard(theme)); }); elements.gallery.replaceChildren(fragment); elements.gallery.setAttribute("aria-busy", "false"); diff --git a/site/assets/styles.css b/site/assets/styles.css index bb4d65b..fc96aef 100644 --- a/site/assets/styles.css +++ b/site/assets/styles.css @@ -1091,6 +1091,8 @@ img { border-radius: 26px; background: rgba(255, 255, 255, 0.68); box-shadow: 0 10px 28px rgba(25, 41, 36, 0.05); + content-visibility: auto; + contain-intrinsic-size: 520px 500px; transition: transform 220ms ease, box-shadow 220ms ease, border 220ms ease; } @@ -1123,6 +1125,7 @@ img { } .card-visual img { + display: block; width: 100%; height: 100%; object-fit: cover; @@ -1261,20 +1264,22 @@ img { .card-install { display: grid; - width: 48px; - height: 48px; + min-width: 108px; + min-height: 42px; + padding: 0 12px; place-items: center; border-radius: 16px; color: #fff; background: var(--jade); cursor: pointer; - font-size: 16px; + font-size: 10px; + font-weight: 720; transition: background 160ms ease, transform 160ms ease; } .card-install:hover { background: var(--vermilion); - transform: rotate(-4deg); + transform: translateY(-1px); } .empty-state { @@ -1787,6 +1792,15 @@ img { line-height: 1.65; } +.evidence-metadata { + margin: 12px 0 0; + color: var(--ink-soft); + font-family: var(--mono); + font-size: 9px; + line-height: 1.55; + overflow-wrap: anywhere; +} + .dialog-rights[hidden] { display: none; } @@ -1954,6 +1968,12 @@ img { background: rgba(21, 35, 31, 0.04); } +.evidence-action { + grid-column: 1 / -1; + color: var(--jade); + background: var(--jade-pale); +} + .trust-note { border-left-color: var(--jade); margin-bottom: 0; diff --git a/site/index.html b/site/index.html index 9098926..c3e4914 100644 --- a/site/index.html +++ b/site/index.html @@ -86,7 +86,7 @@

- +