# Expo 布局契约

> 规定相册资源、显示模式、图片查看、共享外观与静态输出的行为。

---

LLMS 索引： [llms.txt](/zh/llms.txt)

---

> [!NOTE] OINK 1.3.1 契约
> 本契约描述 OINK v1.3.1。主题开发使用同级 checkout；发布验收在关闭替换与
> 工作区后验证公开模块。消费站升级与部署分别检查。

本页规定实现必须遵循的行为。创建内容请阅读 [Expo 相册](/zh/docs/write/expo/)，
设计理由见[设计决策](/zh/docs/design/decisions/expo/)。

## 内容与导航 {#content-and-navigation}

`type: expo` 选择相册布局，分区可通过 `cascade` 将其应用到下级页面。
一个相册对应一个 Hugo 叶子页面包，包含 `index.md` 和 `images/` 目录。
图片元数据放在相册 front matter 的 `resources` 中，无需为每张图片单独创建内容文件。

分区页面列出下级主题和相册。共享导航树支持嵌套分区、当前语言和移动端抽屉。
Expo 使用全部可用页面宽度，没有右侧大纲。页面显式设置的 `page_width` 可以覆盖宽度。
顶部导航栏遵循共享站点与页面设置，Expo 默认不会将其关闭。
与 Book 相同，没有设置 `sidebar_root_for` 祖先的相册以其顶层分区而非首页树作为侧栏与根切换器的根。

### 配置 {#configuration}

`expo/config.html` 按以下顺序解析配置：

1. 使用页面的 `expo` 块，包括 Hugo cascade 提供的配置块。
2. 页面没有该块时，使用站点的 `params.ui.expo`。
3. 逐字段验证；字段缺失或无效时，使用对应的内置默认值。

页面块整体替换站点块，解析器不会合并这两个块中的字段。

| 字段 | 可选值 | 内置默认值 |
| --- | --- | --- |
| `images` | 字符串，匹配页面包内的图片资源 | `images/*` |
| `mode` | `wall`、`cards` 或 `seamless` | `wall` |
| `size` | 160 至 360 的整数，单位为 CSS 像素 | `240` |

无效配置产生警告并使用回退值。带有 `--panicOnWarning` 的构建会因该警告失败。
尺寸是布局计算的目标值，不会同时指定每个图片块的宽和高。

### 图片资源 {#image-resources}

`expo/items.html` 为所有输出选择图片、解析元数据。它使用 Hugo 资源元数据与共享
媒体解析器，并排除非图片资源。可选的 `params.original` 必须指向同一页面包中的图片。
构建过程不会抓取远程相册图片。

| 值 | 规则 |
| --- | --- |
| 标题 | 使用资源标题；未指定时由文件名生成 |
| 替代文字 | 使用 `params.alt`；为空或缺失时回退到标题 |
| 说明与署名 | 将 `params.caption` 和 `params.byline` 作为纯文本处理 |
| 图片顺序 | 按数字 `params.weight` 升序排列，默认为零 |
| 同权重图片 | 保持资源集合的名称顺序 |
| 显式 ID | 以 ASCII 字母或数字开头，后续字符还可使用 `_` 或 `-` |
| 图片锚点 | 在 ID 前添加 `img-`；未指定 ID 时使用资源名称的哈希 |

ID 必须在相册内唯一。无效或重复 ID 产生警告，并回退到文件名哈希。
重命名文件会改变默认锚点。需要长期引用时，应保持显式 ID 与相册 URL 不变。
ID 可使用字符串或 YAML 整数。回退哈希也被占用时，追加第一个未使用的数字后缀，
确保开发构建仍输出唯一锚点。使用 `--panicOnWarning` 发布时，无效或重复的元数据会使构建失败。

可处理图片的宽或高超过 960 像素时，生成不超过 960 × 960 像素的预览。
GIF 也会生成静态预览，用于暂停和减少动态效果。动态源图仍然可用，原始文件保持不变。

相册卡片使用排序后的第一张图片作为封面。分区没有自身图片时，从下级相册中选取
首张可用图片。封面使用 4:3 画框，可能裁切预览。大型收藏由作者拆成相册；
Expo 不会自动对图片资源分页。

显式图片 ID 接受字符串或 YAML 整数。无效或重复 ID 会告警；文件名哈希也被
占用时，回退使用第一个未占用的数字后缀。`expo.images` 必须是非空的资源
通配式；`params.original` 即使为 false 或空值，也必须通过页面包图片校验。
严格发布会拒绝这些诊断。

## 布局与控件 {#layout-and-controls}

| 模式 | 排列与说明 |
| --- | --- |
| 展墙 | 按行排列图片块，间距为 6 CSS 像素；支持悬浮的设备在悬浮或聚焦时显示说明 |
| 卡片 | 每张图依次填入当前最短的列；说明始终可见 |
| 无缝 | 沿用展墙计算，图片块间距为零；说明行为相同 |

对于极宽或极高图片，图片块的宽高比有上限和下限。块内完整显示图片，因此可能留白。
不支持悬浮的设备在图片下方持续显示说明。

工具栏包含一个共用背景的三格单选组，后面依次是播放／暂停按钮和横向尺寸滑杆。
滑杆两端分别用小图与大图图标标示。所有控件垂直居中。没有 GIF 的相册保留禁用的
播放按钮。图标控件具有本地化名称、适用的提示文字，以及可见的键盘焦点。
卡片与相册封面使用预设的圆角设置；展墙与无缝图片块保持直角。获得焦点的图片块在说明之上
显示双色内嵌焦点环。

外观控件位于共享导航中。相册主体不显示外观选择器或文章操作菜单。
没有 JavaScript 时，图片链接与说明仍然可见。

### 读者偏好与外观 {#reader-preferences-and-appearance}

浏览器将所选模式与尺寸保存在 `td-expo-mode` 和 `td-expo-size` 中，只有合法值会生效。
有效的存储值优先于作者默认值，并在同一来源下跨相册、刷新保留。
存储值无效或存储不可用时，使用作者默认值。

显示模式和尺寸独立于外观。Paper、Slate、Ink、Terminal 在亮暗模式下使用共享的
颜色、字体与圆角设置。切换预设后，网格会根据字体变化重新计算，同时保留读者选择
的显示模式和尺寸。

GIF 在网格与查看器中默认播放。播放／暂停控件同时控制当前相册及其查看器的动态效果，
该设置不会跨页面加载保存。`prefers-reduced-motion: reduce` 默认使用静态预览，
读者仍可主动播放。系统偏好变化时会更新播放状态。前四个图片块立即加载，其余网格图片采用懒加载。

### 布局稳定性 {#layout-stability}

当滚动条占用布局宽度时，Expo 保留文档滚动条空间。相册高度接近视口高度时，
布局必须保持稳定。网格重算高度时，滚动条不能反复出现、消失。
打开或关闭查看器时，底层网格的可用宽度必须保持不变。

## 查看器行为 {#viewer-behavior}

查看器使用原生模态对话框。图片显示区域不包含工具栏和说明区域。
`W`、`H` 表示原图宽、高；`A`、`B` 表示图片区域可用宽、高，单位为 CSS 像素。

| 操作 | 缩放比例 |
| --- | --- |
| 打开或重置 | `min(1, A/W, B/H)` |
| 放大至适屏 | `min(A/W, B/H)` |
| 放大 | 当前比例乘以 `1.25`，不超过缩放上限 |
| 缩小 | 当前比例除以 `1.25`，不低于缩放下限 |

较小的比例决定受限边。两种自动缩放操作都完整容纳图片，图片区域不出现滚动条。
重置让小图保持 100%，放大至适屏则可以放大小图。放大至适屏不调用浏览器 Fullscreen API。
只有手动缩放可以超出图片区域，并启用滚动。

百分比将原图像素按 CSS 像素计算。缩放保留观看中心，重置使图片居中。
切换图片会恢复初始尺寸与位置。上一张／下一张在相册首尾停止；到达边界后的按键
不会改变当前缩放比例与位置。

### 指针与键盘输入 {#pointer-and-keyboard-input}

按住鼠标主键拖动，可移动放大后的图片。图片随指针移动，并在边界停止。
只有超出显示区域的方向可以移动。放大后的图片在触摸输入下保留原生滚动。
松开或取消指针、切换图片、重置、窗口失焦都会结束拖动。图片完整显示在图片区域内时，
至少 48 CSS 像素的横向滑动会切换图片；双指缩放和页面已缩放时不切换。

双击或双触在初始尺寸与近看之间切换。缩小显示的原图以 100% 显示，并保持指针下的位置不动；
已经是 100% 的原图放大至适屏。处于其他尺寸时恢复初始尺寸。

查看器快捷键在打开的对话框内生效，不接管文字输入、输入法组合输入或
Ctrl／Command／Alt 组合键。对话框打开时，页面级单键导航暂停。
`keyboard_nav` 控制页面导航脚本，不会禁用 Expo 查看器脚本。

| 按键 | 操作 |
| --- | --- |
| W / A / S / D | 将放大后的图片向上／左／下／右移动，每次 80 CSS 像素 |
| Q 或 H | 上一张 |
| E 或 L | 下一张 |
| J 或 − | 缩小 |
| K 或 + | 放大 |
| R 或 0 | 重置尺寸和位置 |
| F | 放大至适屏并居中 |
| Escape | 关闭查看器 |

无需横向滚动时，左右方向键用于切换图片；否则保留原生滚动。
按钮提示显示适用的快捷键。从右到左书写的语言中相册顺序镜像：指向起始方向的方向键打开上一张，
滑动方向与之一致，上一张／下一张图标同样镜像。

### 链接、焦点与加载 {#links-focus-and-loading}

打开原图在新标签页中显示原图文件。在相册中定位会关闭查看器、更新 URL 锚点、
滚动到当前图片，并聚焦其触发链接。普通关闭操作将焦点返回最初打开查看器的链接。
原生对话框约束焦点，并阻止与底层页面交互。查看器打开时，底层页面不能滚动。
查看器注册到 `OinkSurfaceCoordinator`：打开时关闭其他浮层，`closeOthers()` 或
`closeAll()` 也能关闭查看器。移交到新界面后，焦点保持在新界面中。

原图加载期间，查看器按最终尺寸显示网格预览；原图加载失败时保留该预览。
每张图片加载完成后，查看器预取前后各一张相邻图片。

Expo 只在有图片的 HTML 相册中加载查看器和布局脚本。普通 Image Zoom 保持单图行为，
不会再次包裹 Expo 图片链接。没有 JavaScript 或不支持对话框时，这些链接直接打开原图。

切换图片时，先清除上一张已解码的图片；下一张原图加载期间，按最终尺寸显示
新的网格预览，原图加载失败时保留预览。相册正文与图片标题在 Markdown 中
保持为独立区块。

## 其他输出 {#other-outputs}

所有输出使用同一套图片解析器。各格式通过常规 OINK 输出配置启用。

| 输出 | 图片内容 |
| --- | --- |
| Print | 静态预览、标题、说明、署名与原图链接；不加载 Expo 脚本 |
| Markdown 与 LLMSFULL | 原图链接与元数据 |
| LLMS | 在现有索引中列出相册页面 |
| RSS | 静态图片说明 |
| 浏览器打印 | 展开相册内容，隐藏操作控件 |

作者负责翻译相册内容和资源元数据，图片控件使用共享界面语言目录。
已有的 `gallery` 围栏仍是正文组件，遵循自身契约。

## 验收与发布边界 {#acceptance-and-release-boundary}

在主题 checkout 中运行专项检查：

```sh
python3 bin/check-expo.py
node --test tests/js/expo-geometry.test.js
python3 bin/generate-config-schema.py --check
```

在同级文档站 checkout 中运行站点验收：

```sh
make check
make browser
```

[双语示例](/zh/expo/)是集成测试与视觉检查页面。
`tests/browser/expo.spec.mjs` 检查交互与无障碍。
`expo-stability.spec.mjs` 在视口高度临界点采样连续帧，并强制启用占宽度的滚动条，
不依赖宿主设置。

要补充 Chromium、Firefox 和 WebKit 检查，先安装匹配的 Playwright 浏览器，
再从文档站 checkout 运行：

```sh
HUGO_MODULE_REPLACEMENTS="github.com/pgsty/oink -> $(cd ../oink && pwd)" \
  npm run test:expo:engines
```

独立的 Graph 副本提供大型收藏的补充证据，不替代文档站的验收测试。
本地验收、主题发布、消费站升级与部署是不同的步骤。

---

反链：

- [设计](/zh/docs/design/)
- [外壳与导航](/zh/docs/design/shell/)
- [Expo 相册](/zh/docs/write/expo/)
