关于 deploy
stardust:deploy 是一项技能,可将带样式的 HTML 原型转换为 vanilla aem-boilerplate 项目中的可编辑 EDS 区块和内容页。它包含运行时检查、转换指引,以及通过 DA Source API 批量部署的驱动程序。
主要功能
- 01将原型转换为 EDS 区块和页面
- 02支持内联、外部 CSS 及行内样式 HTML
- 03检测目标 boilerplate 运行时行为
- 04提供验证与 lint 指引
- 05通过持久化记录批量部署
- 06重试临时部署故障
适用场景
- 01将带样式的页面原型转为 EDS 内容
- 02为现有原型准备可编辑内容
- 03通过 DA Source API 部署多个内容页
- 04恢复未完成的批量部署
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ adobe/ skills --skill deploy安装后在 Claude Code 中输入/deploy确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
da-deploy-protocol.mddavids-model.mdreference/ai-readability.mdscripts/ai-readability.mjsblock-roundtrip.mjsbuild-harness.mjscontent-inventory.mjscss-as-text.mjsda-media-upload.mjsdavids-model-lint.mjsdeploy-batch.mjsdiff-profiles.mjseds-path.mjsew-editability-probe.mjsfile-lock.mjslocalize-links.mjsmartech-scaffold.mjsqa-gate.mjsrender-harness.mjssanitise.jssection-schema.mjsstyle-fingerprint.mjsscripts/test/block-roundtrip.test.mjsbuild-harness.test.mjsda-media-upload.test.mjsdavids-model-lint.test.mjsdeploy-batch.test.mjseds-path.test.mjsew-editability-probe.test.mjsfile-lock.test.mjs详细介绍
原文在翻译后有更新,译文可能不是最新。
stardust:deploy — 原型 → EDS/AEM
适用场景
用户拥有:
- 每页一个带样式的 HTML 原型——每页一个文件,每个文件都包含自己的 CSS。接受以下任意形式:
- 包含内联
<style>的单文件,带有:root令牌和语义化<section class="…">(例如 stardust 输出,或 claude-design 的 “Stardust”/Mobirise/Relume 风格页面)。最简单——直接转换。 - 每页一个外部
.css文件(<style>位于同级样式表中)。像读取内联<style>一样读取链接的 CSS。 <x-dc>文档内容,所有样式均为内联样式(逐元素使用style="…")。较难——必须将内联样式提取到作用域限定的样式表中。- React/JSX 原型(一个在运行时挂载
.jsx组件的 HTML 外壳)。先预渲染为静态 HTML(运行它,或截图并读取 JSX 以重建 DOM);不能装饰一个没有服务端渲染<main>的外壳。 原型通常位于stardust/prototypes/**或samples/<Name>/文件夹下——不要硬编码路径,应自行发现。
- 包含内联
- 仓库根目录下有一个 EDS 项目——原版
aem-boilerplate(github.com/adobe/aem-boilerplate):scripts/aem.js+scripts/scripts.js、包含header/footer/fragment的blocks/、styles/styles.css+styles/fonts.css、head.html。本技能仅针对这一运行时——绝不移植、随附或编辑任何运行时文件。 - 目标是转换:原型 → 可创作的 EDS 区块 +
content/**下的 EDS 内容页面。
如果用户有原型但没有 EDS 脚手架,应停止并询问是否要从 adobe/aem-boilerplate 搭建(模板保持原样;转换过程绝不会修改 scripts/aem.js)。如果用户有 EDS 但没有原型,则本技能不适用。
流程守卫(stardust 项目)。 如果 stardust/state.json 存在但没有 flow,且用户请求的是迁移(URL 加上“迁移”/“到 EDS”/“重新平台化”),则在第 1 步前停止:输出主技能的双流程表(skills/stardust/SKILL.md § 双迁移流程),并交还给其路由流程,由其写入 flow(skills/stardust/reference/state-machine.md § 流程键)。没有 state.json 的手工编写原型不受影响——这是本技能的独立用途。
目标运行时——原版 aem-boilerplate(生成的代码可依赖的内容)
原版 boilerplate 已提供转换所需的一切;运行时绝不修改。加载链(head.html → scripts.js → loadEager → loadLazy → loadDelayed)提供了:
- Section DOM: 每个
main > div都会变为<div class="section">;连续的默认内容会包在div.default-content-wrapper中;每个块表格都会在<div class="<name> block" data-block-name="<name>">外围包一层div.<name>-wrapper,并为 section 添加.<name>-container。section 在加载完成前会被隐藏(通过data-section-status+ 内联display:none)——未装饰内容闪现由运行时处理,而非基础 CSS。 - 单元格规范化(
wrapTextNodes,位于decorateBlock中 — #104): 如果任何块单元格的第一个子元素不属于P/PRE/UL/OL/PICTURE/TABLE/H1–6,或者以<picture>开头且后面还有其他内容,那么在运行你的decorate()之前,其全部内容都会折叠到一个<p>中。因此,媒体内容开头的混合单元格(<img> + <h3> + <p>)会以单个包装<p>的形式传入;读取cell.children的收集器只会看到一个节点,并悄然丢弃图片之后的所有内容。使用会展开包装元素的收集器进行解析(步骤 8,#62/#104)。 - Body 门控:
styles.css提供body { display: none }+body.appear { display: block };loadEager()会在decorateMain()之后添加appear。这个门控是正确的——请保留。任何流水线外的渲染(harness、探测)都必须加载实际的scripts/scripts.js,以满足此门控;渲染空白意味着运行时未启动,而不是应该移除门控。 - 按钮:
decorateButtons()(位于scripts.js中)只会将作者设置了格式的链接转换为按钮类元素——生成的类名系列见步骤 5。 - 页面框架:
header/footer块(由loadLazy加载)会获取作者编写的片段文档——默认是/nav和/footer,也可通过页面级nav/footer元数据覆盖。块 JS 会运行,因此交互式页面框架(汉堡菜单、下拉菜单)使用的是真实 JS。见步骤 6。 - 字体:
@font-face位于styles/fonts.css中,由loadFonts()加载(桌面端首次加载时立即加载;重复访问时通过fonts-loaded会话标记加载;loadLazy中则始终加载);styles.css包含指标匹配的回退字体。见步骤 4。 - 自动块钩子:
scripts.js中的buildAutoBlocks()由项目负责——用于放置 D1 自动块(视频/嵌入 URL、片段链接)。 - Lint: 生成的块和样式使用项目自身的配置进行 lint;不存在需要豁免的内置运行时。不要为运行时文件创建
.eslintignore。编写前先了解规则: 行长 ≤ 100 个字符(一次记录的运行中,max-len触发了 91 次,共 100 次命中);CSS 自定义属性使用 kebab-case;遇到问题时针对性修改,绝不要重写整个文件(曾有 6 个块文件因行长问题被重写);另一个 Agent 正在编辑的文件,切勿运行--fix。当DESIGN.json中设置了extensions.breakpoints.target时,npm run lint会包含breakpoint-lint.mjs(副本交接约定 § 样板代码 lint):每个@media宽度和 JS 阈值都必须对应一个目标断点。 - Experience Workspace 埋点(da.live 画布“quick-edit”——你的块必须兼容的内联编辑器)。 Workspace 会在作者文档中每个最外层的
h1–h6 / p / ol / ul / pre / blockquote上添加data-prose-index,在每个img上添加data-image-index,在每个块div上添加data-block-index;随后将带有这些埋点的 HTML 替换进document.body,并重新运行页面自身的loadPage()——因此,每个块的decorate()都会处理带埋点的标记。在 workspace 中,每个块单元格都包含一个<p>(prose2aem 会复制单元格 innerHTML;发布流水线会将只含一个段落的单元格展开为纯文本,运行时的wrapTextNodes会再次包装,因此decorate()在两种情况下看到的都是<p>)。之后,只有仍带有data-prose-index的元素才会变为编辑器(querySelector('[data-prose-index="N"]').replaceWith(editor));没有任何机制会修复丢失的索引。你的块若使用textContent/innerHTML重建文本、合成文本或重新标记文本,就会导致其无法编辑,而且不会发出提示。约定与门控:步骤 8 § Experience Workspace 可编辑性约定(EW1–EW10)。 样板本身也会变化(例如,当前main会生成p.button-wrapper;较旧的克隆版本会生成p.button-container,并将裸链接转换为按钮)。切勿想当然——下面的运行时检测探针会读取目标自身的scripts.js/aem.js,记录该目标的实际行为。
Playwright 重新探测(运行任何渲染操作前)
之前阶段通过 --no-save 安装的 Playwright,会被之后任何实际的 npm i 清理掉——包括添加 devDependency 的设置步骤(摘自 SKILL.md § Setup → --no-save 安装是临时的)。运行 Local-QA 测试工具、计算布局门禁或以下任何探针之前,请先在项目根目录运行 node -e "import('playwright').then(()=>process.exit(0))" 确认安装可用;如果失败,则重新安装(npm i -D playwright --no-save --legacy-peer-deps)。
运行时检测探针(在步骤 1 之前运行——写入 stardust/runtime-contract.json)
样板克隆版本会变化(不同版本的按钮类、包装器名称和按钮化规则各不相同),此处的错误假设不会显现,且会影响整个站点。转换任何内容之前,请先阅读目标自身的 scripts/scripts.js + scripts/aem.js——按钮装饰器会生成什么、需要什么,decorateBlock 如何包装区块——并记录答案:
{
"runtime": "vanilla-eds",
"blockWrapperClass": "block",
"buttonClasses": ".button / .button.primary / .button.secondary / .button.accent, in p.button-wrapper",
"buttonization": "formatted-only | bare-links-too",
"fragmentScriptPolicy": "inert-innerHTML",
"emptySectionCollapse": true
}
区块 CSS/JS 生成和 Local-QA 测试工具会读取此契约,而不是依赖假设。以上值是当前 adobe/aem-boilerplate main 的值;针对每个目标,请核实以下两个已知的变化维度:
buttonClasses— 当前 main 会在p.button-wrapper中生成a.button(以及.primary/.secondary/.accent);较旧的克隆版本会生成p.button-container,有些版本还会将段落中的裸<a>单独转换为按钮(buttonization: bare-links-too),而当前 main 要求作者使用<strong>/<em>。给错误的容器类设置样式,会导致间距/分组布局悄然失效;对按钮化规则判断错误,会让纯文本链接以按钮形式发布(或让 CTA 以裸链接形式发布)。blockWrapperClass—decorateBlock会添加.block+data-block-name,并将区块包装在div.<name>-wrapper中(section 会添加.<name>-container)。将区块 CSS 限定在.<name>下(这是所有版本都会设置的类);通过在无头渲染中断言网格容器计算出的display: grid,以实测方式确认。错误的作用域判断会让所有网格退化为display: block(“桌面端呈现移动端布局”),而排版看起来仍然正常。
当 emptySectionCollapse 为 true 时(页面元数据区块的内容被移入 <head> 后,会留下一个带内边距的空 section),请在基础样式中添加 main .section:empty { display: none }——否则页眉和第一个实际 section 之间会出现约 88px 的空白带。
部署(DA Source API,由本地 Agent 执行)
步骤 1–9 是转换方法;部署是唯一与传输方式相关的步骤。由本地 Agent(Claude Code / CLI)执行时,每个转换后的页面都会以无头方式部署:
| 阶段 | 方式 |
|---|---|
| 代码 | git push 推送分支 → AEM Code Sync 构建代码 |
| 本地化链接 | skills/deploy/scripts/localize-links.mjs --source-host <live-host> [--redirects stardust/redirects.tsv] — 每次生成器运行后、每次写入前,都要对整个目录树执行(见下文) |
| 清理 | skills/deploy/scripts/sanitise.js — 写入前运行(DA 会破坏原始 UTF-8) |
| 内容写入 | DA Source API:PUT admin.da.live/source/<org>/<repo>/<path>.html(multipart,字段名 data,type=text/html) |
| 上线 | POST admin.hlx.page/preview/<org>/<repo>/<branch>/<path>(之后可选择调用 /live/...) |
| 身份验证 | IMS 令牌(DA_TOKEN)——参见 da-content / da-auth skills |
内容载荷是 body 片段(见步骤 9)。部署前,必须先将代码分支推送到 GitHub,以便分支预览(<branch>--<repo>--<org>.aem.page)能使用你的区块进行渲染。完整的 curl 契约请参见 da-deploy-protocol.md。
页面数超过少量时,请使用随附的驱动程序,而不要手写循环(#4)。 node skills/deploy/scripts/deploy-batch.mjs --org <org> --repo <repo> --branch <branch> --content content [--concurrency 4] [--no-publish] 会以受限并发在整个内容树上运行 PUT → preview → live,并使用持久化账本(content/.deploy-ledger.json),因此重新运行时会跳过已上线的页面,只重新驱动失败项;在 000/429/5xx 时(以及在 401 时——见下方 DA_TOKEN 生命周期规则:若 401 持续出现,则批次会以退出码 3 停止,并提供准确的重新运行命令,绝不会将其记为单页失败)进行上限退避重试;使用仅追加的日志(重启后仍保留);并在将页面切换为 live 前检查已交付的 .plain.html(管理端返回 200 ≠ 已交付)。它具有幂等性——可以安全地按 Ctrl-C 停止后重新运行,这是针对瞬时故障导致的半完成部署所记录的恢复方法。串行手写 bash 循环在重启时会截断自己的日志,这正是此工具要取代的反面模式。
每页原子交付契约。 只有完整链路按顺序通过,页面才算已部署:对整个内容树运行 localize-links.mjs(随后 --check 退出码为 0——不存在目标在本地的源站 href)→ davids-model-lint.mjs 退出码为 0(0 🔴——内容结构门槛)→ 经清理包装的文件(scripts/sanitise.js)→ PUT(multipart 字段 data,type=text/html)→ POST /preview/ → POST /live/ → GET 渲染后的 .plain.html 并断言:HTTP 200、<body> 包装完整、恰好一个 <h1>、不含 about:error、不含 /img/ src;此外,当网站声明了关键事实时(#86——DESIGN.json.extensions.metadata.keyFacts[],由 direct 写入;若字段不存在,则跳过门槛并注明跳过),对包含这些事实的页面,在原始完整页面 HTML(而非渲染后的 DOM)中逐条 grep 对应的事实字符串。→ AI 可读性门槛(#100):node skills/deploy/scripts/ai-readability.mjs --origin <live> <paths>——已发布页面的检查器得分(已提供词数 ÷ 渲染 DOM 词数,忽略地标;片段计入)必须**≥ 98**;在门槛报告(reference/ai-readability.md)中记录严格得分和每个区块的已提供内容差距。只有此时才能将页面的账本条目切换为已部署——绝不能仅凭 POST 状态码判断(管理端返回 200 ≠ 已交付)。
链接本地化是流水线阶段,而非事后补救。 生成器会将捕获的 href 保留为完全限定的源域 URL(D4「不透明令牌」规则),这会让访客在新源站上访问已存在的页面时被带回线上网站。D4 是针对媒体和外部目标的捕获保真规则,绝不是内部链接的交付规则。 流水线维护 URL 映射(内容树中的每个已提供路径 + stardust/redirects.tsv 条目),而 localize-links.mjs 会幂等地重写每个源主机 <a href>:只要其路径能在映射中解析,就改写为 EDS 提供的根相对 URL(eds-path.mjs:不带 .html;文件夹索引为 /x/,叶节点为 /x/y),并保留查询和片段;同时以相同方式规范化根相对内部 href。其他链接都保持绝对形式:真正的外部目标和尚未迁移的页面是明确的集成边界,该工具会列出它们。每完成一轮,都要在整个目录树上重新运行此工具:只有后续轮次发布了页面后,早期轮次的页面才会获得新的有效内部目标。davids-model-lint.mjs --source-host <live-host> 会将任何仍可本地化的链接标记为 🟡 LOCALIZE 提示(此版本中为提示;修复方式是通过该阶段处理,绝不能手动编辑)。
.plain.html 检查并非布局检查——添加一条计算样式断言(#静默失败防护)。 即使页面最终渲染成单列堆叠,上面的文本级断言也可能全部通过:这是因为块级 CSS 的作用域设置有误(选择器依赖目标运行时不会输出的包装类——参见运行时契约中的 blockWrapperClass),导致所有网格布局都回退为 display: block,而排版仍然正确。这个问题曾在真实的端到端测试站点上漏过,检查全绿。因此,契约的最终关卡是:对交付后的线上 URL 进行无头计算样式检查(而不是检查 .plain.html):在无头浏览器中加载页面,并断言每种模板的第一页上,所有 CSS 声明了网格/弹性布局的块都计算为 display: grid/flex(而非 block),main .section 数量 > 0,块已添加装饰(存在 data-block-name),pageerror 为 0、损坏图片为 0,且每张已加载的可见图片都能以非零尺寸渲染:clientWidth > 0(#122)。已加载 ≠ 已渲染:naturalWidth > 0 的 <img> 仍可能渲染为 0×0(有记录:一个弹性项目的宽度取决于图片,而图片的 max-width: 100% 又取决于该项目——循环尺寸计算会使尺寸归零),.plain.html 检查和 naturalWidth === 0 的损坏图片探测都发现不了这一问题。应该采用网格布局的块若计算结果为 block,则该页面检查失败——不要将其切换为 deployed。这是运行时契约中 blockWrapperClass 所要求的断言;它必须在原子契约中实际运行,并且每种模板运行一次。两条值得记录的现场经验:大量 PUT 400 错误表示路径格式错误,而非触发了速率限制——每个路径段都要转为小写,绝不能出现双斜杠(content//… 会导致 PUT 返回 400,而预览/线上页面仍返回 200),路径段末尾不能是 -/_;较长的循环应写入 bash 脚本文件,并使用二进制文件的绝对路径(/usr/bin/curl、完整的 node 路径)——在某些上下文中,zsh 会在 while/for 内丢弃 PATH,随后出现的 command not found 错误会让人误以为传输失败。
令牌卫生(#16)。 IMS 令牌通常保存在仓库的 .env 文件中,名称为 DA_TOKEN。首次提交前,确保 .gitignore 排除 .env 和 .env.*,并且该配置位于用于创建测试分支的分支上——否则每个测试子分支都会再次暴露令牌(主 skill 的 Setup 步骤 6 会写入托管区块;本地测试框架和预渲染文件位于 stardust/.work/ 下,已由 stardust/.gitignore 排除)。开发令牌有效期约为 24 小时;若收到空响应正文的 401,表示令牌已过期 → 刷新令牌并重试(写入操作是幂等的)。
DA_TOKEN 生命周期——预检并重新检查,绝不因此让页面失败。 设置时,预检令牌:在存在时解码 JWT 的 exp 声明(对中间部分进行 base64 解码),并在任何批处理开始前对 DA 执行一次经过身份验证的调用,进行冒烟测试。每次长批处理前都要重新检查——设置时有效的令牌可能在运行过程中间过期。批处理中途遇到 401 时,不要立即将其视为过期——并发突发请求可能导致有效令牌收到 401(已有记录:一次媒体批处理的首轮突发请求全部收到 401,尽管令牌还有数小时才过期,而单个顺序执行的 PUT 则直接成功)。两个已发布的驱动程序都会自行应用此规则——da-media-upload.mjs(媒体)和 deploy-batch.mjs(页面):JWT 的 exp 已过期时,会在发出任何请求前以退出码 3 退出;管理 API(媒体 PUT、页面 PUT、预览和线上环境)返回 401 时,会像处理 429 一样重试——上传器还会先单独执行首次上传,以验证令牌;任何文件或页面在重试后仍持续收到 401,都会以退出码 3 停止批处理——账本已保存检查点,正在执行的任务会完成,其余任务不再尝试,并在 stderr 中输出一行可直接采取行动的信息,包含精确的重新运行命令(HALT <path> 401 after <n> attempts — refresh DA_TOKEN and re-run: …;同一命令会从账本继续运行)。401 绝不会被视为单个文件或页面的 FAIL:令牌过期曾导致一个包含 894 行的批处理逐个文件失败,持续 5.5 小时。**各驱动程序的令牌来源:**两个驱动程序都接受这两个参数——--token-env <NAME>(保存令牌的环境变量,默认为 DA_TOKEN)或 --token-file <path>(在启动时读取一次并去除首尾空白;两者不可同时使用——适用于通过文件而非变量传入令牌的执行环境;有记录的一次无人值守运行中,令牌保存在文件里,但上传器只读取了 DA_TOKEN);令牌值绝不会被打印、写入重新运行命令、账本或日志;每条过期或停止信息都会指出需要刷新哪个变量或文件(文件模式下,重新运行命令不包含 <var>=<fresh token> 前缀)。手动编写循环时(仅限少量页面,不得超过上文所述的范围),也遵循相同模式:保存账本检查点,将 401 像 429 一样重试,并且只有在单个顺序请求仍收到 401 时,才停止并给出一条可直接采取行动的指示。令牌过期是 Agent 无法自行恢复的唯一凭据故障;即使在无人值守运行中,这也是合理的硬性停止条件。
驱动其他一切的一条规则
一个独特的视觉模式 = 一个 EDS 区块;没有模式的区段根本不是区块。 写入 DA 的内容结构必须遵循 David's Model(随此技能附带的 davids-model.md,其中 15 条规则映射到此技能的约定;全文以 D#N 引用)。第一条规则决定了这里的一切:
- D1——区块并不适合内容创作。 区块是作者必须维护的表格。内容为纯文本的区段——标题、段落、图片、CTA,且没有重复单元,也没有专门定制的交互结构——应在自己的区段中作为默认内容创作,绝不能包装成区块。它的外观通过最简的区段元数据
style值实现(见步骤 3);区段语义仍使用原生的<h2>/<p>/<picture>/<a>。绝不要为纯默认内容创建text/heading/image区块——这正是 D1 原文所说的反模式。 - **区块用于表达默认内容无法表达的结构:**重复单元(卡片、常见问题、徽标、团队)、真正专门定制的组合(倒计时、数据条、电影感主视觉)以及交互组件。对于这些内容,一个独特的原型模式对应一个区块。不要臆测性地抽象,也不要跨原型提取“模式”,除非各区段确实属于同一种模式——每种模式专属的 CSS 不能被错误地共享;随意违反这条规则曾导致彻底重置(见反模式)。
唯一有意为之的例外——将相同模式的区段合并到一个区块中,并使用变体类。 当两个或多个区段属于同一种内容模式(卡片网格、文本/CTA 条、引言、折叠面板),只是外观不同,则输出一个规范区块(cards、text、quote、accordion),并通过变体类(class="cards brands")设置各区段的外观,在变体 CSS 中编写品牌样式。区块 JS 保持通用(按内容对单元格分类);只有 CSS 因变体而异。这正是 David's Model 库的优势所在(D9:小巧、可复用、由变体驱动,而非 20 个专门命名的区块),并且已证实能保留视觉还原度。真正独特的区段(如主视觉、倒计时组件)仍应专门定制。请为此预留时间:变体 CSS 需要仔细编写,有些网格还会因项目数量而异。
原型就是视觉规范。区块的作用是创作其内容——内容创作得当时应是什么样,见下方的 编码约定;区块必须如何防御性地解析内容,见反模式。
你将产出的内容
对于典型的 5–10 页网站:
- 每种独特的原型模式对应一个区块(D1/D9)。有 5 个页面、每页 6 个区段的网站通常需要约 8–14 个区块:文本条作为默认内容呈现,相同模式的区段共用一个区块和变体,只有真正专门定制的区段才单独创建区块。
- 每个原型页面对应一个 EDS 内容页面。 页面数量相同。
- 导航和页脚文档位于
content/nav.html和content/footer.html——这些是内容文档,像任何页面一样部署和发布,并由原生header/footer区块获取(D12:网站框架区域是片段的典型用途)。 - 每个站点的
blocks/header和blocks/footerCSS/JS,用于还原原型的网站框架区域(见步骤 6)。 - 更新后的
styles/styles.css,包含从原型:root提取的品牌令牌、重置样式、EDS 区段框架、全局按钮系统(见下方“遵循 EDS 按钮惯例”),以及默认内容区段使用的少数区段元数据style值对应的样式。除此之外不添加其他内容。 - 不创建共享工具模块。 不创建波浪系统。不创建动画库。原型已在各区段中定义这些内容;请将它们保留在所属区块中。区段元数据
style值应保持为一小组固定值(dark、tinted等),仅供默认内容区段使用——区块应自行绘制其区段。
编码约定——创作得当的内容是什么样的
以下反模式针对 解码 侧:无论 DA 交给区块什么内容,区块都必须能稳健地解析。这是 编码 侧:内容页面最初应该输出什么——也是执行 David's Model 的地方(完整的逐条映射规则见 davids-model.md;node skills/deploy/scripts/davids-model-lint.mjs content/ 是机械检查关卡——每个页面都必须以状态码 0 退出,才能进行任何 DA 写入)。所有规则都由一个原则驱动:
必须在 DA 中保留的装饰,应使用语义内联标签承载——绝不使用类名,也绝不使用自创的分隔符。 DA 会剥除区块单元格中的
<span>和作者添加的类名,但会保留<strong>、<em>、<code>、<a>、<picture>/<img>。
结构规则(lint 的 🔴 级):
- 绝不嵌套区块表格(D2)。 区块单元格中不能再包含另一个区块。共享/重复内容应使用片段链接或自动生成区块的 URL;复杂的嵌套视觉元素(选项卡、手风琴)应建模为多个区段,并在客户端组合。
- 除区块名称表头外,不得跨越多行或多列(D3),且区块不得超过 4 列(D10)——列数更多表示内容被拆分的方式破坏了默认内容语义。例外:真正的数据表格。
- 创作内容中使用完整限定 URL(D4)——适用于 MEDIA 和 EXTERNAL 目标。 作者应将 URL 视为不透明令牌;代码会提取路径名。(图片 src 的具体规则:见下文“图片”。)指向迁移集内页面的内部链接应使用根相对路径(
/path,不带扩展名;仅文件夹索引路径末尾加斜杠):D4 是捕获保真度规则,绝不是交付规则——localize-links.mjs(§ Deploy)会改写指向本地存在目标的已捕获来源主机 href,并报告目标不存在的链接。 - 任何单元格中都不得将 HTML、CSS 或 JSON 作为可见文本(D15)。
<tag>、{ }绑定或 CSS 规则若作为作者可见文本出现,说明建模有误——代码应放在代码仓库中,而不是文档中。这也包括抓取器将内联脚本文本提取为正文的情况(try { window.X.wcm…、SPOFFCAR_PARTNER之类的营销活动令牌):读取textContent的实时 DOM 导入器会把<script>正文和分析令牌作为段落提取出来;之后的“仅保留前 N 项”上限就会保留这些垃圾内容,丢弃真正的句子(已记录,涉及两个区段)。导入器的每一步文本收集都必须有固定的排除列表,并在应用任何上限之前执行;lint 会将文本中的window./try {/function (标记为 🔴,并将类似 ALL_CAPS_TOKEN 的内容标记为 🟡。 - 视频/嵌入 URL 应自动生成区块,而不是作为区块创作(D1)。 单独占一行的 YouTube/Vimeo/嵌入 URL 应在内容中保持为普通链接;
scripts.js中的buildAutoBlocks()会在装饰阶段将其转换为嵌入内容。绝不要要求作者围绕 URL 创建embed/video区块表格。 - 替代文本仅描述图片(D13)——绝不承载区块要解析的数据。
- 强调 →
<em>,绝不要用<span class="em">— DA 会移除 span,强调效果会悄然丢失。确保区块 CSS 同时作用于em和.em。 - 关键信息必须位于服务器渲染的页面内容中,绝不能只存在于浏览器界面或片段中(#86,D12)。
/nav、/footer和片段链接通过客户端获取,因此只存在于这些位置的文案对不执行渲染的爬虫不可见;请在页面上编写可引用的信息,并通过检索原始服务 HTML 进行验证。完整推理、检查器公式和浏览器界面的权衡:reference/ai-readability.md。 decorate()不会向 DOM 添加任何文字(#100)。 每个可见字符串都由作者编写;循环克隆仅用于呈现(无文字、alt=""、无 href);列表以文档为先(由作者编写行,非文本内容使用索引并补齐);仅为运行时值生成文字,并按区块和字符串进行允许列表控制。Adobe 的 AI Content Visibility Checker 会计算服务端文字数 ÷ 渲染后文字数——区块添加的文字才是全部问题,隐藏文字和浏览器界面不在此列(reference/ai-readability.md)。- 子字段 → 保留置于开头的标签,绝不用内嵌分隔符。 不要发明
Step|Title、flag :: desc、name|tag这类微语法(作者需要学习它们)。在单元格开头使用字段标签——引导语 →<strong>,代码/标志/路径 →<code>——区块将开头的标签解析为术语,其余部分作为值。(区块可以仍将分隔符作为向后兼容的回退解析方式——这是解码,而非输出格式。) - 内容中不得出现原始呈现用 HTML。 不要使用
<sup>(将单位上标移至区块中——把185+拆分为数字和生成的<sup>);不要使用<br>控制布局(通过 Shift+Enter 插入有意的编辑性换行是可以的——这不属于“暴露代码”)。 - 纯空白内容无法通过处理流程(#112) —
<p> </p>占位段落和末尾的<br> 都会被删除。如果源页面会将它们渲染为实际行框(AEM-classic 富文本会如此),请用区块 CSS(padding/margin)模拟这些高度,绝不要用编写的空白内容实现——删除是静默的,只有像素探测才能发现高度缺失。 - 绝不要编写
<hr>— 它是章节分隔符(#119)。 在 DA/EDS 中编写<hr>会将文档拆分为多个章节;在章节内编写时,它会悄然将该章节拆成多个部分,导致所有下游章节选择器和样式失效。若要显示水平线,请编写一个带有章节元数据style值的空章节,并通过 CSS 绘制线条(border 或::after)。Lint 会阻止它(🔴)。 - 分组项目集合 → 每个项目占一行。 包含 N 个相似单元(指标、统计数据、功能卡片)的横向区域,应为每个单元设置一行,并将其各部分作为该单元格中的扁平同级元素——不要每个原子元素占一个单元格(会丢失标签与数字的对应关系),也不要全部塞进一个单元格。区块会按行分段。
- 列表/常见问题 → 使用行,而非嵌套列表或单个大块(D5)。 手风琴/常见问题应先设置标题单元格,然后每组问答占一行(问题单元格 + 答案单元格)。简单的推断列表(相关文章)可以将链接放在同一个单元格中;代码会提取链接。
- 章节标题 → 默认内容,而非区块行(D1)。 位于重复区块(卡片、指标、常见问题、洞察)上方的眉题/标题/导语属于文案,不属于区块结构——请将其作为章节中的默认内容编写,并置于区块之前,以便 DA 和
.plain.html不会将其纳入区块表格。区块会在装饰时重新吸收这些内容(见下文“章节标题”),因此这只会优化标记/编写方式,不会造成任何像素变化。(真正的组件除外:其行本身就是结构,例如倒计时。) - 按钮 → 使用强调约定(D6): 主要按钮使用
<strong><a>,次要按钮使用<em><a>,高影响强调按钮使用<em><strong><a>(谨慎使用——每个横向区域最多一个)。尺寸/颜色由区块/章节上下文决定,绝不由作者选择;如果需要超过这三个选项,就说明设计系统决策泄漏给了作者(超过 3 种样式时的替代方案见“遵循 EDS 按钮约定”)。 - 标题 → 使用真正的大纲层级,不要跳级: 一个
<h1>;章节标题使用<h2>;卡片/子标题使用<h3>(绝不要跳到<h4>)。将原型中的卡片<h4>规范化为<h3>是正确做法。 - 元数据保持名称/值形式(D14) — 仅用于配置,映射到
data-/<meta>;绝不要用名称/值表示显示内容(标题、图片、或以值单元格建模的正文文案都会触发 lint 🔴)。
Section 标题:块重新吸收的默认内容(像素零变化)
带标题的块(即重复单元上方有 section 眉题/标题/导语的块)不应将标题作为块的行。请将标题作为 默认内容 写在 section 中、块之前;块表格只包含重复单元。然后,块会重新吸收标题,使装饰后的 DOM——以及每一个像素——都与旧的表格内形式完全一致:
- 首选方案——在原位置设置标题样式,不使用任何 JS。 运行时会为 section 添加
.<name>-container类,因此可直接定位标题:.cards-container .default-content-wrapper { … }。当原型中的标题在视觉上位于块网格之外(常见情况)时,这就是完整的实现方式——无需重新吸收。 - 只有当标题必须位于块的布局容器内时才重新吸收(例如,标题是与各单元共用同一网格的一个网格单元)。在
decorate(block)中,读取 section 开头的默认内容包装器,构建与块之前根据首几行构建的.section-head布局完全相同的结构——将包装器中的子节点移入其中(head.append(...wrapper.childNodes);它们本来就是可内联编辑的默认内容,若从textContent重建就会破坏这一点——EW8)——并移除清空后的包装器。标题包装器是块的.<name>-wrapper的兄弟节点,而不是块本身的兄弟节点:使用block.parentElement.previousElementSibling,匹配.default-content-wrapper(单独使用block.previousElementSibling会得到null——块嵌套在其包装器中)。 - 将旧的表格内标题(开头的非单元行)保留为防御性解码回退方案。
- 验证零变化: 对比新旧版本经装饰后的块
outerHTML(将 id/media_<hash>规范化)——两者必须逐字节相同,且装饰后剩余的默认内容包装器数量必须为 0。
可能会出现 FOUC(标题先作为默认内容绘制,然后被重新吸收);最终布局完全一致——只有在默认内容的外边距与标题的差异明显时,才需要关注 CLS。
图像:编辑性内容 → 作者可编辑内容;装饰性内容 → 仅使用 CSS
任何具有含义的栅格/品牌图像都属于编辑性内容,必须可作为内容供作者编辑——包括位于文字和遮罩层后方的主视觉/特色内容/CTA 横幅背景(作者会根据不同活动替换它们;它们是页面中最重要的视觉元素)。对于编辑性图像:
- 将二进制文件上传到 DA:
PUT https://admin.da.live/source/{org}/{repo}/media/<scope>/<file>(multipart,字段名data,使用正确的Content-Type;只上传 JPG/PNG/webp 源文件——流水线会生成响应式变体)。随附的上传器可处理整个文件夹或清单,并打印每个文件的创作 URL —DA_TOKEN=… node skills/deploy/scripts/da-media-upload.mjs --org <org> --repo <repo> --scope <name> --dir stardust/current/assets/media(--scope是media/下的文件夹——绝不能写成media/<name>:路径会重复成media/media/<name>,脚本会拒绝;有一次记录在案的运行因此上传并删除了 242 个文件;提取媒体列表请用--manifest <json>;其他选项请用--help):它会跳过非图像文件,拒绝下文所述的不安全 SVG,保留可恢复的记录,区分源文件的 403 和 404,并仅通过首次上传运行验证令牌(401 会像 429 一样重试;只有在此处持续出现的错误才会导致停止,并提供重新运行说明)。绝不要手动编写 PUT 循环(有一次记录在案的运行从头编写了一个 112 文件上传脚本)。**SVG:必须是纯矢量且体积小(#99)。**嵌入了栅格数据的 SVG(在pattern填充后使用<image>/data:imagebase64 的形式——导出的奖章/徽标中很常见)会导致任何引用它的页面预览失败,报错409 "error from content-bus",且不会指出具体资源。超过 约 40KB 的 SVG 也会导致同样的问题(这是流水线的硬性限制,即使是纯矢量也不例外;问题只会在预览时出现,上传时不会)。提取嵌入的栅格图像(data:image/png;base64,…→.png),或将超大矢量图栅格化,然后使用 PNG;小型纯矢量 SVG(徽标、图标)可以正常导入。lint 会对创作内容中的.svg媒体 URL 发出提示。如果预览出现 409,但找不到明显原因,请按照da-deploy-protocol.md第 3a 步进行两步诊断(先将已知正常的文档放到同一路径,再逐一排查 SVG 图像),不要卡在不透明的错误上。 - 在单元格中创作一个
<img src="https://content.da.live/{org}/{repo}/media/<scope>/<file>" alt="…">(与分支无关;流水线会生成响应式<picture>)。内容中绝不能使用相对于仓库的/img/…(→<img src="about:error">)。绝不能按索引将图像写死在区块 JS 中(CARD_IMAGES/LOGOS)——这类图像无法通过内容创作。 - 区块会将创作的
<img>渲染为背景图层(.hero-bg/.card-media/.text-media);遮罩/渐变是覆盖其上的 CSS::before。保留固定的 CSS 资源作为无图时的后备。绝不能将流水线生成的后备<img src>复制到 CSSbackground中(#110)——它带有width=750(最小规格的变体),因此全幅图层在任何 Hero 宽度下都会显得模糊;请先改写宽度参数(width=2000)。<picture>渲染的图像不受影响(浏览器会选择实际规格的变体)。 - **
<picture>包装器会添加行内基线下伸部(#111):**流水线生成的<p><picture>…会让图像以内联方式对齐文本基线——与裸<img>源相比,每个图像段落的高度都会系统性地多出 6/7px,而且文本检查无法发现。对图像段落设置line-height: 0可恢复一致。
装饰性 = 仅使用 CSS,适用于不含图像的效果(渐变、遮罩、纹理、纯色覆盖),以及确实固定不变、以 CSS 背景形式引用且路径相对于根目录的品牌资源(/img/<brand>/…——由浏览器获取,不会被导入,因此不会出现 about:error,也无需上传)。
**能抓出头号错误的检查:**预览后,搜索交付的 .plain.html,核对预期的 <img> 和 alt 数量。“能渲染”会掩盖 CSS 背景图像——它们不会出现在 .plain.html 中,没有 alt,既无法通过内容创作,也不会被 AI/SEO 识别。
当图片的 src 是来源/外部 URL(迁移)时,务必先验证它能否正常访问,再编写内容。 直接复用来源页面上的图片,是导致 <img src="about:error"> 的最常见原因:预览采集器会请求编写的 URL,如果请求失败,交付的图片就会变成 about:error——而且不会有明显提示,因为页面仍然可以渲染。因此,请验证每个 src 指向来源/CDN 的 <img>,但验证请求必须与 extract 到达源站时采用的方式一致,且验证失败并不意味着一定要“省略”。
使用记录的抓取方式验证,而不是直接运行 curl(#2 — 由机器人管理的源站)。 许多真实的迁移目标位于 Akamai / Cloudflare / Imperva / F5 之后,这些服务会对普通 curl(以及无头请求)返回 403,但对真正的浏览器正常提供相同的资源。因此,笼统地规定“用 curl 请求;如果不是 200 就省略”,会导致整个有机器人防护的网站上的所有真实品牌图片都被剔除——这正是质量标准明确禁止的失败情况。应改为读取 _crawl-log.json#discovery.fetchTechnique。如果值为 headed-chrome,请通过已打开的浏览器上下文执行页面内 fetch 来验证图片 URL(page.evaluate(async u => (await fetch(u)).status, url))——这样会沿用 JA3/H2 指纹和 Cookie(参见 extract/reference/playwright-recipe.md § Bot-management → Sub-resource fetches);直接使用 curl/request 会错误地报告资源无法访问。只有当 fetchTechnique 为 plain 时,才可以用普通的 curl -s -o /dev/null -w '%{http_code}' <url> 代替。
从已捕获的 src 重新托管——绝不重建或猜测 URL(#118)。 资源 CDN(尤其是电商目录 CDN)会在路径错误时返回 HTTP 200 和一张通用备用图片——不会报错,但交付的图片内容是错的(已有记录:通过猜测的目录 URL 上传了六张商品图片;CDN 对六个 URL 都返回了同一张占位图,但所有状态都显示正常)。务必从 lift/extract 记录的确切 URL 字符串获取资源(捕获映射 / stardust/current/assets/ 清单),并在上传前比较获取到的资源与捕获副本的尺寸和字节大小——对于会返回备用图片的 CDN,仅凭 200 不能证明任何问题。
区分 403 机器人墙和 404 资源缺失,并优先重新托管,而不是省略(da-media-upload.mjs --manifest 会对本地文件缺失的每个条目准确应用此规则:FAIL … source 403 (bot wall … never omit) 对比 FAIL … source 404 (missing at origin …))。CDN 返回 403/401 表示热链请求被拦截,而非资源缺失;即便该 URL 当前能被采集器以 200 访问,从交付主机热链来源 CDN 也不可靠(预览时可能因跨域返回 403,导致 about:error)。因此,403/被拦截的已捕获图片,默认补救方式应为下载并重新托管,而不是省略:extract 已通过页面内 fetch 将本地副本保存在 stardust/current/assets/media/ 下——将其上传到 DA(PUT …/source/{org}/{repo}/media/<scope>/<file>),并编写 content.da.live URL(参见上文的图片部分)。只有在尝试下述变体/分隔符修复后,确认确实为 404(资源确已不存在)时,才省略;绝不能用通用徽标/占位图(…-logo…)冒充内容图片。有两种真实的失败情况,资源其实存在——应修复 URL,而不是删除资源:(1)变体版本错误——页面仅提供一个返回 404 的衍生图,但同系列的其他版本可以访问(例如,肖像的 …/4x3/768/… 返回 404,而 …/original/768/… 返回 200)→ 改用可正常解析的版本;(2)缺少查询分隔符——…/<id>&wid=600&hei=… 中缺少 ?,导致 <id>&wid=… 被解析为无效 ID → 返回 403 → 将 ID 后的第一个 & 改为 ?。
content.da.live 媒体 URL 需要身份验证——不要匿名调用 curl。 将资源迁移到 DA 后,推荐使用的 https://content.da.live/{org}/{repo}/media/… src 即使正确且预览流水线能正常摄取,也会向匿名 curl 返回 401。不要把这个 401 当成“损坏”而省略资源。对于已迁移到 DA/托管在 DA 的图像,正确的验证方式是在预览后进行:对交付的 .plain.html 执行 grep,确认 about:error 的数量为 0,并断言 <img>/alt 数量符合预期——参见 da-deploy-protocol.md 第 3b 步。完全不要对 content.da.live(以及 admin.da.live)URL 执行作者操作前的 200 检查。
摄取器仅会重新托管 <img>/<picture>——视频/音频/PDF 绝不能使用 content.da.live(#103)。 以链接形式编写的 content.da.live/....mp4 会原样保留在区块的 <video src> 中;由于该主机需要身份验证,每位匿名 VISITOR 都会收到 401——只显示海报,不会有任何提示(页面看起来一切正常,只有实时视频/控制台探测才能发现问题)。从 CODE ORIGIN 提供视频:提交 mp4 并使用根相对路径引用(/media/<scope>/<file>.mp4,遵循 #67 的固定资源语义),或使用外部视频主机;海报则保留为可编写的编辑 <img>。
步骤
1. 审查(轻量)
首先,将输入规范化为静态 HTML。 如果原型是 React/JSX(一个挂载 .jsx 到 #root 的 HTML 外壳),请在审查前将其预渲染为静态 HTML(#24)。可靠的做法:
# 1. serve the prototype's OWN folder with a plain static server (NOT file:// —
# babel-standalone XHRs the .jsx and file:// CORS-blocks it; the aem dev
# server CSP-blocks the inline scripts too).
( cd samples/<proto> && python3 -m http.server 8765 & )
# 2. load in Playwright (React/babel load from unpkg — needs internet), wait for
# mount, capture #root's innerHTML, save it for the block agents to read:
# page.goto('http://localhost:8765/<file>.html'); waitForTimeout(4000);
# fs.writeFileSync('stardust/.work/prerender/<proto>.html', root.innerHTML) # never write into samples/ (user input)
之后,它的转换方式与外部 CSS 原型相同(语义化类名 + 原型的 .css)。如果是 <x-dc> 文档内容,各个区块仍然是 <section>/<div> 元素;只需注意,它使用的是内联 style="…",而不是 <style> 块。本技能的其余部分均假设存在静态 <main>。
查看路由或登录后的页面(#27)。 如果你要查看的页面不是默认渲染结果(例如,登录流程之后的已登录仪表盘),应在应用启动前预置已持久化的状态,而不是截取着陆页。许多原型会将路由持久化到 localStorage:先运行 page.addInitScript(() => localStorage.setItem('<key>', JSON.stringify({page:'dashboard', user:'Alex'}))),再导航。通用替代方案:在 UI 中操作到目标页面(填写并提交登录表单,然后截取),或设置路由器哈希/URL。截取你实际要转换的页面中的 #root。
阅读每个原型的 <main> 标记(暂时跳过 <style>),并为每个页面生成区块列表:
home: hero, work, approach, team, clients, closing
approach: approach-hero, manifesto, tenets-detailed, cadence, closing
team: team-hero, team-roster, work-style, recent, careers, closing
…
一种实用做法是:如果运行环境支持,使用以下确切要求派发一个只读探索子 Agent(Claude Code:Explore Agent,thoroughness=quick);否则直接运行搜索。你不需要一份包含 22 种模式的详尽清单——你需要的是文件名和区块名称。克制自己,不要试图“寻找共用模式”。 当两个区块最终被证明逐字节相同时,模式复用自然会显现。
在编写区块代码之前,先为每个实例的差异建立指纹(#90)。 区块名称列表只能反映复制层级的信息;它无法揭示重复组内部的实例外观不同——例如,一个激活状态的筛选标签与其描边同类项、一个填充强调色的 CTA 与描边 CTA、图片卡片与无图片标题卡片。这些细节会在按内容转换时悄然被抹平(整个网格变成一模一样的卡片,一个 CTA 被设置成与其他项相同的样式),而强制检查项(一个 <h1>、网格计算为 grid)仍会通过。因此,请一开始就主动探查:
node skills/deploy/scripts/style-fingerprint.mjs "file://<abs>/<proto>.html"。对于每组同级实例,它会根据组合签名对每个实例进行聚类——计算得到的样式差异(background/border/color/background-image/weight/align)以及结构特征(hasImg、hasSvg、子项数量)——并将簇数大于 1 的组报告为所属区块需要复现的实例差异候选项。此结果仅供参考:它也会标记合理的差异(例如,页脚中一个加粗链接与其他普通链接并存),因此请自行判断并排除误报;但切勿仅仅因为区块对同级项一视同仁,就抹平真实的变体(激活的标签、强调色 CTA、无图片卡片)。结构特征同样至关重要:图片卡片与无图片卡片(以及任何由 :has()/:not() 驱动的变体)具有相同的顶层计算样式,因此仅探查样式会遗漏它们——请纳入结构信号。这份清单将成为区块作者的检查表;它是对第 10 步部署后 content-diff 的前置补充(后者也能捕获同类遗漏,但为时已晚)。
2. 确定名称并复用 — 编写任何代码前先定下来
根据本节的规定,命名之前先回答两个分流问题(在转换日志中记录答案):
- 它到底算不算一个区块(D1)? 没有重复单元,也没有定制的交互结构 → 默认内容 + 一个 section 元数据
style值。它不是区块,不需要命名。覆盖 D1 的例外情况: 动态清单标记为client-only(计算器、筛选器、比较)或带有模态框的 section,应有专属区块——把它压平成文字内容,正是交互结构丢失时记录的处理方式。 - 它是否符合 Block Collection 模式(D11)? Hero、卡片、分栏、手风琴、引言、表格、嵌入内容、轮播/标签页——如果符合,沿用该区块的名称和内容模型(来自
github.com/adobe/aem-block-collection的创作结构和行语义),即使 CSS 完全是定制的。任何见过 EDS 网站的作者都能据此创作你的页面。只有不符合任何集合模式的 section 才需要自创名称。
对于确实是区块且不符合任何集合模式的 section,命名规则如下:
- 区块名称 = 原型的
<section class="X">值,采用 kebab-case(hero、work、closing、approach)。 - 切勿使用 EDS 保留类名命名区块(#15)。
section、block、wrap和button用于运行时的 section/装饰 DOM;以-wrapper或-container结尾的名称会与decorateBlock派生的 wrapper/container 类(.<name>-wrapper、.<name>-container)冲突——名为section的区块会与<div class="section">冲突,导致装饰失效。如果原型的 section 类名过于通用或属于保留名称(有个样例两次使用class="section"),应根据 section 的data-screen-label/ 意图推导语义名称(new-products、discover),并将tinted等修饰项作为区块变体处理。 - 同一个 section 在多个页面中以相同的视觉样式出现时,创建一个区块并在所有页面中复用。典型示例:每个页面末尾的
closingCTA。 - 同一个 section 在多个页面中出现但外观不同(例如首页的
hero、案例研究页的case-hero、服务页的service-hero),则应作为不同区块。为其添加页面原型前缀。 - 同一原型中的两个 section 视觉样式相同但文案不同(例如案例研究页的
discovery和decisions都是带眉题和标题的双栏文字内容)时,可以合并为一个区块(case-prose-2col),并使用单个文本变体单元(“tinted” / “default”)。自行判断。
命名流程的规模应与页面数量相匹配。 对于单页转换,如果每个 <section class="X"> 的名称都明确且唯一(hero、quick、used、stats……),就没有跨页面复用决策需要做——只需确定 block name = section class,然后继续;不要不断向用户提问。以下问题适用于多页网站,因为其中会重复出现外观相似的 section,必须决定是复用还是添加原型前缀。
多页网站在编写任何区块代码前,先向用户提出 3–5 个命名问题:
- “首页的 Hero 叫什么?
hero?” - “所有页面的 closing CTA 都相同吗?是否使用同一个
closing区块?” - “案例研究页的 discovery/decisions/solutions 应该合并成一个区块,还是拆成三个?”
- “每项服务的 Hero 是否有别于首页的 Hero?是否要单独创建
service-hero?”
将答案以文字形式确定下来(写入 stardust/eds-conversion-log.md 或类似文件)。这是整个流程中最具杠杆效应的一步。
2b. Section 架构 + 解码层级 — 编写代码前完成往返验证(#93、#95)
丢失 CTA、角色互换、变体被压平这一类问题,根源只有一个:作者分别编写已创作行(编码)和区块的 decorate()(解码),却寄希望于两者互为逆运算。提前完成两项工作即可闭环;随后,循环中的 block-roundtrip 门禁(#94,第 8 步)会验证闭环是否完成。
生成分区架构——共享的 ENCODE/DECODE 契约(#93)。 名称锁定后,生成双方据以编写的逐分区契约:
node skills/deploy/scripts/section-schema.mjs "http://localhost:8791/<prototype>.html" \
--out stardust/eds-schema/<page>.json
每个分区都会输出按顺序排列、按角色分类的清单(heading / eyebrow / cta+href / body——与 skills/deploy/scripts/content-inventory.mjs 中 content-diff 和 block-roundtrip 使用的分类器相同),以及重复单元组(数量 + 每个单元的组成:headings/ctas/imgs/textRuns,是否统一)。两侧都要使用它:
- ENCODE:每个重复单元一行,字段按架构顺序排列;每个架构项都必须出现在编写的内容中。你有意舍弃的项目,必须作为一项决策记录在转换日志中——绝不能是无意遗漏。
- DECODE:区块的 JSDoc 引用其分区的架构路径;
decorate()对架构列出的角色进行精确分类,架构中的单元数量则作为 decorate 后的数量断言(#48/#52)。
将 repeats[].uniform 与 #90 指纹交叉核对:uniform: false 表示存在逐实例变体(当前选中项、强调 CTA、无图片卡片),区块必须复现这些变体,不能将其抹平。
按分区选择解码层级——模板插槽式与重构式(#95)。 解码错误通常发生在重构环节,因此只在作者需要结构自由度时进行重构:
- 模板插槽式 = NODE-slotting(通过构造确保保真度和可编辑性)。 对于组成固定、作者无法更改其结构的分区(定制主视觉区、电影感横幅、统计数字/倒计时组合):
decorate()将原型分区的内部 DOM 作为模板字面量,其中的文本位置是空插槽容器(<div class="headline"></div>、<div class="eyebrow"></div>、<div class="actions"></div>、<div class="media"></div>),并按角色将作者编写的元素移动到其中——作者编写的<h1>移入.headline,眉题<p>移入.eyebrow,每个 CTA 的<p>移入.actions,作者编写的<picture>移入.media。包装元素承载布局类;作者编写的元素保留其标签、属性和身份。装饰后的 DOM 在结构上与原型相同,因此不会出现分段错误(#48/#52/#56/#76);并且作者编写的每段文本在 Experience Workspace(EW1/EW2)中都保持内联可编辑。值插槽式——将作者编写的文本复制到模板节点中(slot.textContent = cell.textContent、使用${text}插值的innerHTML模板)——禁止使用: 这种方式虽然渲染出来像素级完美,却会导致内容在工作区中完全无法编辑(真实网站上的每个模板插槽式区块都因此未通过;反模式 18)。编辑者仍拥有每一行文案的编辑权——内容页面未作更改,且由服务器渲染(这不是客户端注入的界面元素;#86 不适用)。结构编辑需要开发者参与:对于无人会修改结构的分区,这是值得的取舍。 - 重构式(结构可编辑)。 对于作者可以增删单元的重复/数据型分区(卡片、常见问题、列表、菜单):按照 #48/#50/#52 进行分类和防御性分段——并由架构和往返关卡承担验证责任。
在转换日志中记录每个区块的层级。默认规则:定制的一次性分区使用模板插槽式,重复分组使用重构式。两个层级都必须满足 Experience Workspace 的可编辑性契约(步骤 8,EW1–EW10);关卡命令为 block-roundtrip --ew(默认开启)/ew-editability-probe.mjs——区块即使通过角色往返测试,但仍留下无法编辑的文本,也不能算完成。
在架构中为每个区段额外记录两项内容(D1/D11 + 向前兼容): 在步骤 2 中被归类为默认内容的区段需标记为 "defaultContent": true(编码端输出的是正文,而非表格——lint 会标记包裹裸默认内容的区块);并且每个区块的编写形态都必须能表示为三种组件模型形态之一——简单型(每行一个属性)、键值型(配置)或容器型(自身行 + 每个子项各一行)——这样之后采用 Universal Editor 时无需迁移内容(参见 aem.live 的“组件模型定义”)。如果某种形态不符合这三种中的任何一种,说明模型有误,而不是需要第四种形态。
3. 基础
为 styles/styles.css 重新打造品牌——替换样板代码中的 DEMO 层(roboto 令牌、demo 字体比例、demo 按钮颜色),同时逐字保留其结构层:body { display: none } / body.appear { display: block } 门控(运行时会添加 appear——移除门控并不能修复空白的测试框架渲染;加载真实的 scripts.js 才能解决问题)、header { height: var(--nav-height) } + header .header { visibility: hidden } → [data-block-status="loaded"] 的页面框架预留,以及 main > .section 脚手架。需要编写的内容如下:
- 从原型的
<style>中逐字提取:root令牌(颜色、字体、字号比例、字重、字间距、布局、动效缓动)。 - 文档重置(
box-sizing、外边距重置、scroll-behavior、正文的字体和背景、::selection、图片默认样式、按钮重置)。重置 MUST 包含全局规则*, *::before, *::after { box-sizing: border-box }——样板代码中没有这条规则(#106)。 从 Bootstrap 时代的源代码移植任何“百分比宽度 + 内边距”网格时,若使用content-box,每一列都会悄无声息地换行(真实页面:三列卡片渲染成 2+1,两列版块堆叠,页脚换行——文档高度增加 1731px,所有文本门槛都通过;只有像素探测或肉眼才能发现)。如果因故跳过全局重置,请将任何同时包含width: N%和padding的块级 CSS 视为缺陷。img重置 MUST 为img { display: block; max-width: 100%; height: auto; }(#36)。 EDS 的媒体管线会输出带有width/height属性的<img>;如果没有height: auto,宽度约束会让图片纵向拉伸(横向 1920×1258 的图片渲染为 677×1258)。原型中看不到这个问题(原始<img>,没有属性)——它只会在管线处理后出现。 - 版块骨架:保留样板代码的
main > .section内边距/外边距规则,并根据原型的节奏调整。未装饰内容的隐藏是 RUNTIME 的工作(data-section-status+ body 门控)——不要为 section 或main > div添加display: none规则。 - 限制
main > .section > div宽度的页面模板必须定义一个全宽逃逸规则,且在模板级别定义。 模板规则如.inner main > .section > div { max-width: 1158px }的优先级高于每个 block 自身的全宽包装规则(main .X-container > div { max-width: none }),会把 hero、深色版块和卡片网格挤进受限列,并在两侧留下白色边距——看起来像是 block 出了问题;这种情况在找到模板规则之前,曾在不同页面上出现过三次(有记录)。在模板选择器优先级下定义一次逃逸规则(.inner main > .section > div:where(.hero-wrapper, .band-wrapper, …),或.full-bleedsection 样式),让所有全宽 block 都使用它,并通过以下命令断言:qa-gate.mjs --full-bleed hero,band会在所列 block 的 section 包装宽度小于视口时发出警告。 - 版块骨架会按所属层级应用捕获到的宽度上限(#124)。 DESIGN.json
extensions.breakpoints(cap-probe)会按类型提供containerMaxWidth、probeWidth和caps[]:shell/content宽度上限会转为main { max-width: var(--max-width); margin: 0 auto };module宽度上限则保留在样板代码的main > .section > div { max-width: var(--max-width) }上,并对modules[]列为全宽的 section 使用上述逃逸规则。只声明令牌却从未应用,是有记录的问题(两个像素门槛都通过——1440 小于宽度上限);qa-gate.mjs --design DESIGN.json会在probeWidth下同时对测试环境和已发布的源站断言宽度上限。 - 默认内容 section(D1)的 section 样式值。 默认内容 section 使用的每个
style值都要在此处定义规则——这是一个较小的封闭集合(main .section.dark { … }、.tinted等),仅用于排版/背景。目前的站点中,管线会在服务端渲染这些类(section-metadata 的style→ section div 上的类;其他键 →data-*;metadata 区块本身会从服务的 HTML 中移除——渲染版本 ≥ 2 / 2026-05 之后创建的站点)。Block 仍会绘制自己的 section;设置这套词汇的唯一目的是让文本 section 不必依赖 block。 - 每个 section 只能有一个
style值——多个style值不可靠(#120)。 在真实页面上,style: a, b(逗号或空格分隔)最终只会提供第一个类。当 section 需要第二个样式维度时,应将其锚定到内容上:main .section.a:has(img[alt^="…"])——以内容为锚点的:has()选择器可在 metadata 管线处理中保留;第二个 style 令牌则未必。 - 空的带样式 section 必须遵守 section-status 隐藏规则(#121)。 如果带有
style的 section 除此之外为空,需要使用display: block !important覆盖样板代码的:empty { display: none }——但未限定范围的覆盖也会破坏 RUNTIME 的预加载隐藏(data-section-status+ 行内display:none,见上文骨架规则),使 section 在页面其余部分之前绘制,造成显著的布局偏移(测得 CLS 为 0.75)。始终限定覆盖范围:main .section.x[data-section-status='loaded'] { display: block !important }。 - 将多个包装元素绘制为同一表面的 section 样式,必须包含子元素的外边距。 “深色背景上的白色卡片” section 中,
default-content-wrapper和 block wrapper 都是白色时,两者之间出现了一条 29px 的深色条纹:标题的下外边距穿过未设置内边距的包装元素发生折叠,把下一个包装元素推到 section 背景上(有记录)——每个包装元素自身的计算外边距都显示为 0,因此这段间距看起来莫名其妙。为这类包装元素设置display: flow-root(或至少 1px 的垂直内边距);诊断时检查每个包装元素的第一个/最后一个子元素的外边距,而不是包装元素本身的外边距。 - 全局按钮系统(见下一节)。这是唯一一个不由每个 block 的 CSS 自行绘制的地方——按钮是全站通用且遵循约定的。
- Experience Workspace 编辑模式基础规则(EW3/EW6/EW10——步骤 8 § 合约)。
styles.css中包含三个代码片段;它们都限定在.prosemirror-editor内,该元素只存在于 da.live 行内编辑器中,因此不会影响已发布页面:- (a) 编辑模式下重绘 CTA。
decorateButtons()在decorate()之前为作者编写的锚点添加了.button.primary/.secondary;编辑器会根据作者编写的<strong>/<em>标记重新渲染 CTA 段落,但不带任何类,因此作者一点击,按钮样式就会消失。根据标记和项目自身的按钮声明重新绘制(strong= primary 样式,em= secondary 样式):
媒体上/深色背景上的这些重绘变体要放在文件末尾(/* Experience Workspace edit mode: repaint CTAs from their authored marks */ .prosemirror-editor p > :is(strong, em) > a:any-link, .prosemirror-editor p > a:any-link:has(> :is(strong, em):only-child) { /* the project's a.button declarations */ } .prosemirror-editor p > a:any-link > :is(strong, em) { font-weight: inherit; font-style: inherit; } .prosemirror-editor p > strong > a:any-link, .prosemirror-editor p > a:any-link:has(> strong:only-child) { /* a.button.primary paint */ } .prosemirror-editor p > em > a:any-link, .prosemirror-editor p > a:any-link:has(> em:only-child) { /* a.button.secondary paint */ }main :is(.on-media, .banner, .hero .gradient) .prosemirror-editor p > em > a:any-link { color: #fff })——后面不能再有任何内容。 - (b) 卡片链接中的内层锚点。 将 CTA 段落移入卡片
<a>后(EW6),编辑时会重新渲染其中的链接;它必须看起来像卡片文字:a .prosemirror-editor a:any-link { color: inherit; text-decoration: none; }。 - (c) 样式文本链接工具类必须以包装元素变体的形式存在,且优先级相同。 Block 应用于作者编写的链接或列表的任何工具类(
.affordance、.eyebrow、.meta、.link-download)都会在编辑模式下失效(编辑器会移除作者编写元素上的类),因此每个工具类也都要写成包装元素后代变体,并使用:where()以免改变层叠优先级:.affordance, .affordance-wrap :where(a) { … }、.affordance::after, .affordance-wrap :where(a)::after { … }。普通的.affordance-wrap a优先级会高于a:any-link,并悄悄改变链接颜色(真实站点上从海军蓝变成青绿色);:where()可使其优先级与.affordance相同。 - (EW10) Section 文本规则遵循相同的选择器规则。 编辑器会在作者编写的元素上方插入两个包装 div(
div.prosemirror-editor > div.ProseMirror > <tag>),因此,默认内容上的 section 样式若写成main .section.x .default-content-wrapper > p:first-child或p:has(picture) + p,编辑时就不再匹配。请使用后代选择器,不要在文本元素上使用位置伪类(main .section.x .default-content-wrapper p)。
- (a) 编辑模式下重绘 CTA。
- 为页眉的实际高度预留空间,否则延迟加载的 chrome 会导致首个 section 位移 → CLS(#81)。
headerblock 会在首次绘制之后通过loadLazy加载。在常见布局中,页眉位于首个 section 上方并处于文档流内(全宽 hero 在导航栏下方),hero 会先在y=0处渲染,然后在导航栏出现时向下跳动一个页眉高度——这会造成较大的布局偏移,浏览器会将其归因于 hero block(某真实页面测得 CLS 0.143,其中约 0.13 来自 hero;匹配度量值的字体也无法解决,因为原因是页眉盒出现,而不是字体替换)。样板代码已原生预留空间——header { height: var(--nav-height) },并在[data-block-status="loaded"]之前设置header .header { visibility: hidden }——因此需要让预留空间与 chrome 相匹配:将--nav-height设置为原型页眉实际渲染高度(按断点响应式设置),并为裸<header>设置 chrome 自身的background,这样预留高度与实际高度之间的差异就不会显现:
让页眉高度保持确定,确保预留值确实匹配:让预留空间的断点与 header block 的断点保持同步(若设置了:root { --nav-height: 98px; } /* desktop nav+banner height */ header { background: var(--brand-ground); } @media (width <= 767px) { :root { --nav-height: 102px; } } @media (width <= 480px) { :root { --nav-height: 120px; } } /* banner wraps to 2 lines */DESIGN.jsonextensions.breakpoints.target步骤列表,则两者都使用该列表——每个步骤对应一个--nav-height),并避开导航链接的换行区间(例如,扩大 burger/hamburger 断点,避免行内链接在尴尬的宽度下换到第二行)。多行 chrome(工具栏 + 导航栏)的高度会超过默认的 64px——请实际测量,不要沿用默认值。预留高度应略高于自然高度(几 px),这样就不会预留不足并导致位移。页脚不需要预留空间——它位于首屏以下,延迟加载不会使上方内容发生位移。使用 CLS 探测验证(PlaywrightPerformanceObserver({type:'layout-shift'})),并延迟 woff2/导航栏请求,以复现 PSI 测量的慢速网络切换——仅对**已部署的预览 URL(#101)**运行此探测:本地资源加载很快,因此在测试环境中运行会掩盖位移,导致错误通过。 - 预留高度规则会压扁 block 内部的
<header>元素(#107)。 默认的header { height: var(--nav-height) }+ 可见性规则会匹配页面上的每个<header>,而不只是 chrome 容器——如果某个 block 的decorate()输出语义化的<header>(逐字移植原型 DOM 时很常见),它就会被限制为导航栏高度并隐藏;所有此类 block 都会同时损坏,而文本门槛仍然全部通过。不要在 block DOM 中输出<header>——改用<div class="…-head">。(从根本上将默认选择器限定为body > header可以修复问题,但会修改样板代码的结构层——如果你已经在添加上文的border-box重置,就在同一轮中限定预留规则的范围;否则请按此警告修复。) - 叠加式 chrome——#81 中无需预留空间的情况(#108)。 如果原型中的页眉透明且浮在 hero 上方(hero 内容从
y=0开始,位于页眉后方),预留--nav-height会把 hero 推到源页面渲染位置下方。设置--nav-height: 0,并将页眉绝对定位(header { position: absolute; top: 0; left: 0; right: 0; },由 block 自行设置背景)——它不占据文档流,因此延迟加载 chrome 不会导致位移(某真实页面采用此模式时测得 CLS 为 0.0004)。 - Hero 必须提前加载其 LCP 图片,并为媒体位置预留空间(#100)。 Metadata 区块会让页面的第一个 section 为空,因此 RUNTIME 的
loadSection(main.querySelector('.section'), waitForFirstImage)不会提前加载任何图片——hero 管线输出的<img loading="lazy">仍保持延迟加载。另外,样式为width: auto的 hero 图片(在最大高度内适配的布局)在加载前会有一个高度为 0 的盒子,因此图片加载后 hero 会按图片的完整高度扩展,使其下方的每个 section 都发生位移(某真实页面测得 CLS 为 0.134,归因于 hero 下方的 section)。Hero block 中需要同时处理这两点:decorate()为其首个<img>设置loading="eager"+fetchpriority="high",并通过 CSS 预留媒体位置(按断点为 figure 设置min-height,或显式设置aspect-ratio)。仅对已部署的预览 URL(#101)运行 CLS 探测——测试环境的图片是本地图片,加载很快,因此在测试环境中测得的 CLS 约为 0,而线上页面却会发生位移(这种错误通过曾经实际发生过);本地 CLS 数值无论大小都无法说明问题。 仅此而已。不使用动效原语。除按钮系统外,不使用工具类。区块style值保持在上述小型固定集合内——绝不为区块自有区段另建一套样式系统(参见反模式 2)。
scripts/scripts.js 保持原样,只有项目自有钩子例外:buildAutoBlocks() 增加站点的 D1 自动区块(嵌入/视频 URL);decorateMain()/loadEager 绝不重构。不添加滚动显现效果。不初始化跑马灯。不添加页眉滚动状态。每个区块的动画由该区块自己的 CSS 管理。
Token 完整性门禁——区块引用的每个 var(--x) 都必须在 :root 中定义(#91)。
将区段 CSS 提取到区块中时,常常会带入区块作者未添加到基础样式中的 token(渐变中的 var(--navy-700)、悬停效果中的 var(--accent-2))。引用了但未定义的自定义属性会静默地使整个声明失效——如果 --navy-700 未定义,background: linear-gradient(var(--navy) 0%, var(--navy-700) 100%) 会导致整个背景失效,元素回退到默认状态(海军蓝卡片会呈现浅色),不会报错,也不会触发 lint 标记。在基础样式完成后、部署前,通过机械方式进行门禁检查:
comm -23 <(grep -rhoE 'var\(--[a-z0-9-]+\)' blocks/**/*.css | sed 's/var(//;s/)//' | sort -u) \
<(grep -oE '\--[a-z0-9-]+' styles/styles.css | sort -u) # MUST be empty
任何输出的行都是区块使用但 :root 未定义的 token——将其添加到基础样式的 :root 中。
Favicon——提供站点图标(唯一允许添加到 head.html 的内容)。
提取过程会将源站点的 favicon 保存到
stardust/current/assets/favicon.<ext>;部署后的 EDS 站点必须提供该文件:
- 将其复制到仓库根目录,命名为
favicon.<ext>,保留原格式。favicon.ico会自动通过/favicon.ico提供——无需其他操作。 - 如果格式不是
.ico(svg/png),则在head.html中恰好添加一行:<link rel="icon" href="/favicon.<ext>">。该 favicon 链接是此技能唯一会修改的head.html内容——字体禁令(步骤 4,反模式 #10)保持不变。 - 沙盒/应用运行(
_eds/包约定):改为将文件写入_eds/code/favicon.<ext>——宿主发布器会随代码树一同推送,并以确定性方式注入head.html链接。 - 将此项作为原子契约的一部分,在已发布源站验证:
node skills/qa/scripts/qa.mjs --base <origin> --checks metadata --max-pages 1不得报告favicon-broken或favicon-default——仅返回 200 并不能证明任何事,样板自带的图标也会返回 200;未随代码推送的仓库根目录文件会悄无声息地漏掉,导致发布内容仍使用默认图标。
如果提取时没有获取到 favicon,**必须醒目警告并记录在部署日志中——绝不要自行编造,也绝不要悄悄跳过。**缺少 stardust/current/assets/favicon.<ext> 通常意味着有限范围的提取(--single/--pages)在 crawl.mjs 以所有模式抓取 favicon 之前就已运行——对入口页执行一次抓取(或手动获取 link[rel~="icon"]//favicon.ico)即可补回;否则部署的站点会使用默认图标,这会让客户端觉得站点有问题。
4. 自托管字体并尽量减少 CLS——绝不要在 head.html 中加载字体
每个项目都按以下顺序应用四项原则:
**0. 为每个命名字体系列都提供 @font-face,而不只是正文使用的字体(#65)。**原型会指定显示字体和正文字体(--display: "Hebden Incised", …; --body: "Lekton", …)。如果你只自托管正文使用的字体,那么所有字体栈指定了未提供的显示字体系列的标题、数字和名称都会静默回退到 Times New Roman/Arial(通用衬线/无衬线字体)——字号/颜色检查无法发现这种问题(字形不同,但度量值相同;只有 #66 的 FONT MISMATCH 探测标记或人工检查才能发现)。这与 #11/#22(字重错误)和 #30(opsz)不同:这里的字体系列已经命名,却从未提供。对于 --display/--body/任何标题字体栈中引号内的每个字体系列,都要自托管匹配的 @font-face(下载并提交 woff2 到 fonts/,使用相对于根目录的引用——绝不使用原型的品牌 CDN 来源,#44)。**检查清单:**搜索 styles.css 字体栈中的每个带引号的字体系列,并与声明的 @font-face { font-family } 名称核对——任何不匹配的名称都会导致静默回退。
然后,在每个项目中按以下顺序应用四项原则:
1. 不要改动 head.html。不要添加字体代码,绝对不要。
不要添加 Google Fonts <link>。不要添加用于字体的 CDN <link rel="stylesheet">。不要添加声明 @font-face 的 <style> 块。也不要添加 <link rel="preload" as="font">——即使是自托管字体的预加载也不应放在 head.html 中。品牌 @font-face 声明放在 styles/fonts.css 中(loadFonts() 加载的文件——桌面端和通过 fonts-loaded 会话标记触发的重复视图中都会预先加载,并且始终在 loadLazy 中加载);度量值匹配的 -fallback 字体放在 styles/styles.css 中(必须在首次绘制时就可用)。回退拆分(原则 3)可以消除通常需要预加载才能避免的 CLS。
2. 自托管所有品牌字体,包括专有字体,并发出许可警告(#80)。 检查原型,确认每个字体系列及其许可证:
- SIL OFL 1.1(Inter、JetBrains Mono、Fraunces、Roboto、Open Sans、IBM Plex、Source Sans 等)→ 自托管。许可证允许重新分发,包括嵌入到所服务的域名中。
- Apache 2.0(部分 Google Fonts)→ 自托管。
- 专有商用字体(Pangram Pangram、Adobe Fonts / Typekit、Monotype、直接向字体厂商获取的字体)→ 仍然要自托管以确保还原度,但必须发出许可警告(#80)。默认做法是忠实还原品牌:将品牌展示字体静默降级为 Arial 的转化/售前页面,在客户看来就像是出了问题(这正是利益相关方最先注意到的)。因此,提取原型实际使用的 Web 字体——如果原型提供了
.otf/.ttf文件(claude-design/stardust 原型通常会在assets/fonts/下提供),使用fontTools将其转换为拉丁子集 woff2(f.flavor='woff2'; f.save(...),每个文件约 30–60 KB),并像 OFL 字体一样在styles/fonts.css中声明。由于这些字体是专有字体,你必须在三个位置标明许可义务,避免未经注意就上线:- 在
styles/styles.css顶部添加横幅注释(⚠️ FONT LICENSING REQUIRED BEFORE GOING LIVE+ 每个字体系列对应的字体厂商 + "do not publish toaem.liveuntil the webfont/embedding license is confirmed"); - 添加
fonts/LICENSING.md文件(表格列出:文件 → 字体系列 → 字体厂商 → 状态,并附上移除和回退说明); - 在转换日志和给用户的交接消息中说明。
记录移除方式:如果无法确认许可,就删除
.woff2文件及对应的@font-face规则,字体栈便会回退到度量值匹配的系统备用字体(原则 3/4)。这与旧的“保留 CDN / 接受 Arial”指导相反——应优先确保还原度并发出醒目的警告,而不是静默回退到通用字体。(只有在原型本身从 Adobe/Typekit CDN 加载字体且无法获取字体文件时,才保留 CDN 加载——此时要记录 CDN 依赖和 CLS 代价。)
- 在
对于 OFL 字体,获取拉丁子集可变 woff2 文件。最快且可靠的来源是 jsDelivr 的 @fontsource-variable/<name> 软件包:
mkdir -p fonts
curl -sSL -o fonts/<name>-variable.woff2 \
"https://cdn.jsdelivr.net/npm/@fontsource-variable/<name>@latest/files/<name>-latin-wght-normal.woff2"
# italic, if used:
curl -sSL -o fonts/<name>-italic-variable.woff2 \
"https://cdn.jsdelivr.net/npm/@fontsource-variable/<name>@latest/files/<name>-latin-wght-italic.woff2"
仅包含 Latin 字符的可变字体 woff2 通常每个文件为 30–60 KB,包含 100–900 的字重范围。
匹配原型加载的轴 — 包括光学尺寸(#30)。 检查原型的 Google Fonts <link> URL。如果请求了 opsz(光学尺寸)轴 — 例如 Source+Serif+4:opsz,[email protected],400;… — 默认的 @fontsource-variable/<name> 文件(<name>-latin-wght-normal.woff2)仅包含 wght 轴(光学主字重固定),因此大号标题的渲染会略有偏差(字形会更粗/不同)。请改为获取 opsz 文件(同时包含 wght 和 opsz,大小约为 2 倍);随后 font-optical-sizing: auto(CSS 默认值)会随字号调整:
curl -sSL -o fonts/<name>-opsz.woff2 \
"https://cdn.jsdelivr.net/npm/@fontsource-variable/<name>@latest/files/<name>-latin-opsz-normal.woff2"
更一般地说,应自托管与原型加载轴相匹配的变体(仅 wght 轴或包含 opsz;如使用斜体,也要匹配斜体)。
非可变字体(#11)。 许多 Google 字体仅提供按名称区分的静态字重 — 没有可变轴(例如 Barlow、Barlow Condensed、Anton)。这些字体没有 @fontsource-variable/<name>;请使用静态 @fontsource/<name> 包,并获取实际使用的各个字重:
curl -sSL -o fonts/<name>-700.woff2 \
"https://cdn.jsdelivr.net/npm/@fontsource/<name>@latest/files/<name>-latin-700-normal.woff2"
静态 @fontsource 包也不会发布“Fallback” @font-face,因此你必须使用 fonttools 根据 woff2 自行计算指标覆盖值(原则 3):
from fontTools.ttLib import TTFont
f = TTFont("fonts/<name>-400.woff2"); upm=f['head'].unitsPerEm; hhea=f['hhea']; os2=f['OS/2']
arial = dict(upm=2048, xavg=904) # Arial reference (use Times metrics for a serif brand)
size_adjust = (os2.xAvgCharWidth/upm) / (arial['xavg']/arial['upm'])
adj = upm*size_adjust
print(f"size-adjust:{size_adjust*100:.2f}% ascent-override:{hhea.ascent/adj*100:.2f}% "
f"descent-override:{abs(hhea.descent)/adj*100:.2f}% line-gap-override:{hhea.lineGap/adj*100:.2f}%")
按照原则 3,将这些值应用到 <brand>-fallback @font-face(来源为 local("Arial") / local("Times New Roman"))。
3. 延迟加载 fonts.css,并使用指标匹配的 -fallback @font-face — 面向品牌字体的标准样板机制。
品牌字体在首次绘制时绝不能渲染 — 在原生 EDS 下也不会:品牌 @font-face 位于 styles/fonts.css 中,由 loadFonts() 在首次绘制后加载(桌面端和重复访问时立即加载,其他情况则在 loadLazy 中加载)。品牌字体加载前,会渲染字体栈中的第二种字体 — 因此应将第二种字体设为指标匹配的本地字体,并按照样板自身的 roboto-fallback 约定,在 styles/styles.css 中声明:
/* styles/fonts.css — loaded by loadFonts(), NOT parsed at first paint */
@font-face {
font-family: "<Brand>";
src: url("../fonts/<brand>-variable.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
/* styles/styles.css — available at first paint. A local system face
re-declared with the BRAND font's metrics, named <brand>-fallback
(the stock convention — see roboto-fallback in the boilerplate). */
@font-face {
font-family: "<brand>-fallback";
src: local("Arial"); /* local("Times New Roman") for a serif brand */
size-adjust: <X>%;
ascent-override: <Y>%;
descent-override: <Z>%;
line-gap-override: 0%;
}
:root {
--body-font-family: "<Brand>", "<brand>-fallback", sans-serif;
}
body { font-family: var(--body-font-family); }
所有包含品牌字体的字体栈都必须将其 -fallback 字体列为第二种字体 — 仅在 :root 中声明该回退字体没有作用;它必须针对每个字体栈生效。
保留 body { display: none } / body.appear 门控 — 它属于运行时,而非基础设置(#40)。 loadEager() 会在 decorateMain() 之后立即添加 appear,因此在任何真实页面上(以及任何加载了真实 scripts/scripts.js 的测试框架中),首次绘制前都会满足该门控。离线管线渲染出空白页面,意味着运行时从未启动 — 测试框架没有加载 scripts.js,或者脚本抛出了异常 — 应修复测试框架,绝不要移除门控。(qa-gate 的运行时启动检查和已部署页面的计算样式保护机制可作为空白渲染的兜底。)
指标覆盖值来自 @fontsource-variable/<name> 包发布的校准数据 — 获取其 CSS:
curl -s "https://cdn.jsdelivr.net/npm/@fontsource-variable/<name>@latest/index.css" \
| grep -A 6 "Fallback"
每个 fontsource 包都会发布一个包含 size-adjust、ascent-override 和 descent-override 值的 <Name> Fallback @font-face。将这三个数值原样填入 <brand>-fallback 字体(根据分类,来源为 local("Arial") / local("Times New Roman") / local("Courier New"))。
由此产生的 CLS 链:
- 初始绘制:
fonts.css尚未加载,因此"<Brand>"是未知字体,字体栈会回退到"<brand>-fallback"— 即经过指标调整的本地系统字体。行框指标已与品牌字体匹配。 loadFonts()加载fonts.css:品牌 woff2 开始获取;回退字体继续以匹配的指标渲染。零布局偏移。- 品牌字体加载完成:字体切换完成(
font-display: swap)。由于指标已匹配,零布局偏移。 4. 让回退字体的分类与品牌字体相匹配。 为回退字体使用相同类别的字体,以便加载期间保持视觉节奏: - 无衬线品牌字体 →
<brand>-fallback使用local("Arial")作为源;字体栈以sans-serif结尾。 - 衬线品牌字体 →
<brand>-fallback使用local("Times New Roman")作为源;字体栈以serif结尾。 - 等宽品牌字体 →
<brand>-fallback使用local("Courier New")作为源;字体栈以monospace结尾。(注意:如果等宽字体只用于小型眉题/标签,可以不做等宽字体的度量匹配,因为 CLS 影响可以忽略。在转换日志中记录这一选择。)
绝不要替换字体分类(不要让衬线品牌字体匹配 Arial,也不要让无衬线品牌字体匹配 Times)。即使设置了度量覆盖,字符宽度和节奏仍有足够大的差异,会造成明显的视觉跳变。
分类还包括宽度——压缩/窄体展示字体需要搭配压缩回退字体,绝不能用普通 Arial(#80)。“无衬线→Arial”只适用于常规宽度的无衬线字体。窄体/压缩展示字体(PP Formula Narrow、Bebas Neue、Oswald、Barlow/Archivo Condensed、Anton)回退到普通 Arial,会造成宽度类别不匹配:Arial 的宽度约宽 15–20%,字形也不同,因此标题会失去窄体特征,换行方式也会改变——即使字号/字重/字距完全相同,这种隐蔽的偏差仍会被肉眼察觉。(某个银行应用原型的验收版本曾将 PP Formula 回退为 Arial;宽度测试显示,同一段 H1 字符串中,Arial 的宽度为 975,而 PP Formula 为 839。)当压缩品牌字体由项目自行托管时(原则 2,现在是默认做法),只有在 Web 字体被拦截时才会出现这个问题,但回退字体仍必须保持相同宽度:在字体栈中将压缩系统字体放在 arial 前面——"<Brand>", "Arial Narrow", arial, sans-serif(macOS/Windows 上有系统字体 Arial Narrow,但 Android/Linux 上没有;若要确保覆盖所有平台,请自行托管免费的 OFL 压缩字体替代品,如 Oswald / Barlow Semi Condensed / Archivo Narrow)。扩展/宽体品牌字体也遵循相同逻辑。基础搭建期间快速检查:对于首个字体为压缩字体的每个 --*-font-family 令牌,确认最后一个非 sans-serif 回退字体也是压缩字体。
自行托管原型预期使用的回退字体,而不是它意外渲染出来的系统字体——并用宽度测试验证,绝不要使用 document.fonts.check(#77)。原型通常不会加载任何 @font-face,并会将专有品牌字体列在首位(--display: "Bellfort", "Bebas Neue", system-ui)。在缺少品牌字体的设备上,原型会静默渲染为 system-ui——所以屏幕上显示的展示字体只是当前查看设备上的意外结果,并非设计意图。不要匹配这种意外结果(不要因为“原型显示的就是 system-ui”而将 EDS 的 --display 设为 system-ui)。原型自身的字体栈才记录了设计意图:自行托管首个可再分发的回退字体(OFL/Apache——例如 Bebas Neue),让 EDS 使用设计所需的压缩展示字体;在转换日志中记录专有字体系列。用宽度测试验证实际渲染的字体——document.fonts.check('24px "X"') 对页面引用的任何字体系列名称都会返回 true,无论该字体是否已安装,因此会产生“字体匹配”的假象。应改为测量:设置某个跨度的 font-family:"X",monospace,如果其宽度与已知不存在的字体名称相同,就表示 X 已回退(不存在);如果宽度不同,则表示 X 确实正在渲染。(某个啤酒酿造商项目曾因 fonts.check 误报,将 --display 设为 Bebas Neue;之后宽度测试显示,原型实际渲染的是 system-ui,但将 Bebas Neue 作为记录的预期回退字体仍然是正确的。)
多个展示字体系列(#12)。 品牌可能会使用多个字体系列,例如 Barlow(正文)+ Barlow Condensed + Barlow Semi Condensed(展示字体)。它们都会通过 fonts.css 延迟加载,因此每个需要关注字体切换的系列都需要有自己的度量匹配 -fallback;没有备用字体的字体栈会采用度量不匹配的备用字体,导致页面偏移。将每个系列定义为一个 :root 令牌(--font-cond、--font-semi),并在使用它们的元素上按块引用。是否为每个展示字体系列完全匹配度量属于可选的细节优化——对于使用较少的展示文字(眉题、大号窄体标题),CLS 影响较小;应在转换日志中记录这一权衡,而不是过度处理。但是,如果展示字体系列用于首屏上方的标题(主视觉 <h1>、LCP 标题),也要匹配其度量——根据 woff2 计算其 size-adjust/ascent-override/descent-override(使用与 #11 相同的 fonttools 方法),并将专用备用字体放在该系列字体栈的第二位:--hero: "Lilita One", "lilita-one-fallback", …。备用字体系列必须有自己的名称——不要重复使用正文字体的 -fallback(#11),因为它携带的是正文字体的度量,而不是展示字体的度量。(注意:实际上,首屏所在区块的主要 CLS 通常来自延迟加载的页眉框,而不是展示字体切换(#81);应先修复页眉预留空间,再匹配首屏上方展示字体的度量,以消除剩余偏移。)
匹配原型中的实际字重(#22)。 只有单一字重的展示字体(例如 Anton,仅提供 400)在原型中通常看起来比其唯一字重更粗:裸 <h1>/<h2> 会继承浏览器默认的标题字重(700),而浏览器会对仅有 400 字重的字体进行伪粗体处理。如果基础样式设置了 h1,h2,h3 { font-weight: 400 },标题会明显比原型更细。应设置为原型实际显示的字重(通常为 700),以匹配伪粗体效果——不要想当然地认为“文件中只有一个字重 ⇒ font-weight: 400”。
5. 遵循 EDS 按钮约定——不要在区块 JS 中自行创建按钮锚点
样板代码中的 decorateButtons()(位于 scripts/scripts.js)会在作者用行内强调包裹链接时应用按钮类(D6)。它会在 decorateMain() 中、任何区块的 decorate() 之前运行——因此,区块 JS 读取单元格时,其中的锚点已经带有按钮类。区块 JS 会移动 CTA 的段落——actions.append(a.closest('p') || a),绝不克隆:工作区行内编辑器用于关联按键的索引位于 <p> 上,而不在 <a> 上(EW3);cloneNode/childNodes 复制会把作者编写的段落留在原处,使其失效。编辑时 .button.* 类会消失;基础样式的编辑模式重绘(第 3 步 (a))会处理这一点。
作者标记 → 自动应用的类(当前 adobe/aem-boilerplate main——针对每个目标确认 runtime-contract.json,较旧的克隆版本有所不同):
| 作者标记 | 应用的类 | 外观 |
|---|---|---|
<strong><a> | a.button.primary,父级 p.button-wrapper | 品牌填充色 |
<em><a> | a.button.secondary,父级 p.button-wrapper | 透明 + 描边(适配颜色) |
<em><strong><a> | a.button.accent,父级 p.button-wrapper | 强调型 CTA——谨慎使用 |
<p> 中单独的裸 <a> | 无(当前 main 要求强调标记) | 普通链接 |
两个解析注意事项:装饰器只匹配 p a[href]——CTA 必须由段落包裹,且在该段落中单独存在(编码端已经如此处理);它会用带类的锚点替换强调标签,因此区块 JS 绝不能假设 <strong>/<em> 包裹标签仍然存在——应优先通过 a.button 检测 CTA,并将带强调标签包裹的 <a> 作为未装饰结构的回退检测方式。
在 styles/styles.css 中重新设置样板代码按钮规则的样式(保留选择器,将演示样式替换为品牌系统):
a.button:any-link, button.button {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 16px 26px;
font-size: 12px;
font-weight: var(--weight-bold);
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid transparent;
transition: background 0.25s var(--ease-out), color 0.25s var(--ease-out), border-color 0.25s var(--ease-out);
}
a.button.primary { background: var(--color-wavelength); color: var(--color-ink-rich); border-color: var(--color-wavelength); }
a.button.primary:hover { background: var(--color-canvas); border-color: var(--color-canvas); }
a.button.secondary { background: transparent; color: currentcolor; border-color: rgb(255 255 255 / 40%); }
a.button.secondary:hover { border-color: currentcolor; background: rgb(255 255 255 / 5%); }
/* On light surfaces, secondary uses dark-tinted outline. List the dark sections explicitly. */
main .section:not(.dark, .closing, .hero, .team) a.button.secondary { border-color: var(--color-rule-strong); color: var(--color-ink-rich); }
main .section:not(.dark, .closing, .hero, .team) a.button.secondary:hover { border-color: var(--color-ink-rich); }
/* Trailing arrow on primary/accent. */
a.button.primary::after, a.button.accent::after { content: "→"; font-weight: 600; transition: transform 0.3s var(--ease-out); }
a.button.primary:hover::after, a.button.accent:hover::after { transform: translateX(4px); }
p.button-wrapper { display: inline-flex; flex-wrap: wrap; gap: 16px; align-items: center; margin: 0; }
(相邻的 CTA 分别位于单独的 p.button-wrapper 中——每个撰写的段落对应一个 wrapper——因此,组间距由区块中 .actions 容器内各 wrapper 共享的 flex 行控制,而不是由单个组元素控制。)
适配表面的变体:作用域应限定为 BLOCK 类,而不只是 section(#41)。 当按钮/链接/文本的样式因深色或浅色表面而异时,原型中的深色表面标记(例如 .hero、.cta-dark)在转换后会成为一个区块类,即嵌套在 <div class="section"> 内的 <div class="hero block">。因此,写成 main .section.hero a.button.secondary 的覆盖规则永远不会生效(.hero 在 .section 下一级),深色表面上的 CTA 就会悄然呈现为深色文字配深色背景。深色表面的覆盖规则应同时限定两者:main .section.dark a.button.secondary, main .hero a.button.secondary { … }。检查深色背景上的所有区块,确保次要/幽灵 CTA 的对比度足够(浅色描边 + 浅色文字)——按钮在指标中会显示为“存在”,因此只有检查对比度/肉眼查看才能发现问题。
区块 JS 模式——移动 CTA 段落(EW3):
// Get the cell that holds the CTAs
const ctaCell = rows[N]?.firstElementChild;
if (ctaCell && ctaCell.querySelector('a')) {
const actions = document.createElement('div');
actions.className = 'actions';
// the workspace's editor index is on the <p>, so move the paragraph, not the anchor;
// capture the list BEFORE moving (append() ends a live sibling walk — EW1)
[...ctaCell.querySelectorAll('a')].forEach((a) => actions.append(a.closest('p') || a));
container.append(actions);
}
绝不要对锚点使用 cloneNode(true) 或复制 childNodes——克隆出来的内容看起来一模一样,但在 Experience Workspace 中无法使用(携带 data-prose-index 的原始 <p> 已被丢弃)。如果区块本身需要在 CTA 上添加变体类(#25 多变体系统),请将其加在 .actions wrapper 上,并设置 .actions.ghost a 的样式,而不是加在撰写的锚点上。
不要使用 cta.className = 'btn-loud' 来创建锚点,也不要注入自定义 SVG 箭头。 全局 ::after 箭头 + 约定的类系统足以处理 95% 的情况。
区块 CSS 模式——只覆盖实际不同的部分:
closing 区块的 CTA 比全局默认尺寸稍大。这是合理的覆盖:
.closing .actions a.button.primary { padding: 22px 32px; font-size: 13px; }
三行代码。目标是全局类,而不是自定义类。这就是“区块对默认样式稍作扩展”的全部模式。
不应使用此约定的情况:
有些链接不是按钮。例如:
- section 页脚中的波长下划线文本链接(“How we work →”)。这是带样式的文本链接,不是胶囊按钮。
- 磁贴网格中的整卡锚点(
<a class="tile">…</a>)。整张磁贴都是点击目标。 - closing CTA 中的联系方式值(
<a href="tel:…">801-363-0101</a>)。这是联系方式值,不是 CTA。 - 正文中的
mailto:/tel:链接。
对于这些情况:作者在内容中保留普通锚点 <a>(不使用 <strong> / <em> 包裹),由所属区块通过专属 CSS 设置样式。此约定适用于按钮;如果它不是按钮,就不要应用此约定。
多变体按钮系统(#25)。 strong/em 约定只命名三个插槽(primary/secondary/accent——D6 指出,通常需要更多插槽,意味着错误地将设计系统决策交给了作者,因此应先尝试合并)。当原型确实有更多依上下文而异的变体时——例如机场原型中的 .btn--accent(黄色)、.btn--primary(蓝色)、.btn--ghost(描边)、.btn--onblue(蓝底白字)——作者强调样式无法表达这些变体。不要勉强套用:将原型的完整按钮变体系统移入 styles/styles.css(保留原型自身的类名),在内容中将 CTA 写成普通 <a>,并让每个区块为克隆的锚点应用正确的变体类(区块知道所属 section 的变体——选择权归设计系统,而非作者)。这与“如果不适用,就自行设置样式”的备用方案相同,只是应用在按钮系统层面,而不是单个链接上。
6. Chrome——撰写 /nav + /footer 文档,使用模板插槽的页眉/页脚区块
Chrome 是规范的片段用例(D12):内容位于两份由作者编写的 DA 文档中——content/nav.html 和 content/footer.html;呈现则由每个站点的 blocks/header 和 blocks/footer CSS/JS 负责。默认区块会获取这些文档(loadFragment('/nav') / loadFragment('/footer'),每个页面可通过 nav/footer 元数据覆盖路径);你需要用原型的 chrome 替换示例 CSS/JS。导航链接可以由作者编写;区块 JS 会运行,因此交互式 chrome 使用的是真正的 JS,而不是 CSS 小技巧。
导航/页脚文档(ENCODE 端)。 格式与任何内容页面相同,均为正文片段格式(步骤 9),通过同一条链路部署和发布——必须将它们加入发布清单,否则 chrome 会返回 404。内容仅使用默认内容,并按区段组织:
content/nav.html:区段 1 = 品牌(Logo 链接),区段 2 = 导航链接列表(<ul>),区段 3 = 工具/CTA(默认页眉区块会将这三个区段分别读取到.nav-brand/.nav-sections/.nav-tools中——请保留此约定,以确保汉堡菜单逻辑正常运行)。- 导航 DECODE:流水线会在每个列表项的触发链接外包裹一层
<p>(线上 #98)。 作者/测试工具中的结构为<li><a>…<ul>,交付后的结构为<li><p><a></p><ul>——使用:scope > a查找触发链接以及任何.nav-links > li > aCSS 在线上都会悄然失效,而测试工具中却能通过(第 #79 项,影响 chrome)。在decorate()中进行规范化:匹配:scope > a, :scope > p > a并移除<p>包裹层。请在部署预览中检查桌面导航的样式化渲染,而不只是检查测试工具。 content/footer.html:每个页脚横栏对应一个区段(链接列使用列表、法律声明行、社交链接)。页脚区块按顺序渲染这些区段。- 图片(Logo)遵循标准的编辑图片规则:将图片上传到 DA
/media,在content.da.live中编写<img>——流水线会生成<picture>。内部链接使用根相对路径;外部链接使用完整限定 URL(D4)。 页眉/页脚区块(DECODE 侧)——模板插槽化(#95),从构造上确保像素级一致。 将blocks/header/header.css的演示 CSS 替换为原型的框架 CSS(作用域限定在header .nav-*/footer .footer下),并调整header.js的decorate(),逐字构建原型的框架 DOM,按角色将编写的内容插入对应插槽——logo 放入品牌插槽,每个编写的<li>链接放入导航链接模板,工具/CTA 放入对应插槽。行为遵循页眉契约(stardust/chrome/header-contract.json、../diff/SKILL.md§ 页眉契约):每个控件、打开控件的操作(悬停、点击、Enter)、面板显示的内容、关闭方式、焦点、滚动锁定和动效,均适用于桌面端和移动端。内置机制(toggleMenu、aria-expanded、Escape 和焦点移出时关闭、isDesktop)是需要扩展的无障碍基础,不是目标:沿用它的运行结果交付了只支持点击的单层下拉菜单,而不是悬停式大型菜单。大型菜单面板属于编写的内容:在/nav中使用嵌套列表;或者当面板包含数十个以上链接时,为每个顶级项目提供一个导航片段(/nav/<item>,首次打开时加载,并与导航一同发布)。搜索、购物车、账户和区域设置要复现其 UI(浮层、输入联想、抽屉、空状态);后端提供的数据则属于动态内容,由网站所有者决定。 - 提升框架元素自身的盒模型样式(#31)——在原型的
<header>/<footer>元素自身设置的margin、padding、border——到header/footer上(宿主元素位于<main>外),而不只是提升内部内容样式。最后一个区块与页脚之间的间距完全来自页脚自身的顶部外边距(例如footer { margin-top: 72px })。这很容易遗漏:内部内容看起来没问题,页脚却紧贴最后一个区块。 - **根类名钩子(#26):**区块在
header .header/footer .footer内渲染。如果原型的框架样式使用了不同的根类名(例如.utilnav/.site-footer),请让decorate()输出<div class="<that-class>">包装元素,以便直接应用提升后的 CSS。 - **多行框架(工具栏 + 导航):**在
/nav中将工具栏编写为额外区段;页眉区块会将其插入导航行上方。将--nav-height(#81)更新为组合后的高度。 - **框架行中绝不要同时设置固定的
height和垂直padding。**在全局border-box重置(#106)下,height: 40px; padding-top: 12px会将内容盒压缩到 28px,使每个工具栏行项目都偏离中心 6px——一条声明,整个框架偏移(已有记录)。框架行应根据内容确定尺寸(需要下限时使用min-height);部署前,用 grep 检查框架 CSS 中 flex 行的height:是否与padding同时出现。 - **悬停下拉菜单需要连续的悬停区域。**如果子列表位于触发器
<li>下方并带有偏移(top: calc(100% + Npx)),指针经过间隙时菜单就会关闭(已有记录:5/5 个桌面端下拉菜单无法穿过绿色裁剪区访问)。让<li>覆盖整个导航行,并将子列表设为top: 100%;或者(复刻方案)在子列表上用不可见的::before补上间隙,并在移动端媒体查询中关闭;qa 的dropdown-unreachable会遍历指针路径。 **仍无法运行的内容(#20、#102):**编写的内容不会包含<script>(D15),而 EDS 下发的 CSP(script-src 'nonce-…' 'strict-dynamic')意味着,任何标记中的内联on*处理程序都不会触发。浏览器界面中的表单(例如页脚里的新闻简报注册表单)由区块 JS 负责接入:从区块渲染<form>,并在decorate()中添加真正的submit监听器。滚动状态相关的浏览器界面(粘滞阴影、滚动时缩小)由页眉区块的 JS 接入。**区块依赖项不得编译 WebAssembly(#102):**CSP 未包含wasm-unsafe-eval,因此基于 WASM 的播放器(dotlottie、WASM 编解码器/解析器)在每个真实环境中都会静默回退,但在本地可以正常运行——对于 Lottie,请通过固定 CDN 的模块import()使用lottie-web的纯 JSsvg渲染器(strict-dynamic 会信任模块导入)。第 10 步:检查已部署页面的浏览器控制台是否有 CSP 违规——优雅回退会让所有布局门禁都检测不到这类问题。
**按页面设置浏览器界面变体:**在页面的元数据区块中设置 nav: /nav-minimal(或 footer: /footer-legal),将该页面指向另一个编写好的文档——这取代了旧的 header: off 开关(没有内置的关闭开关;不带浏览器界面的页面应指向你编写的最简导航文档)。多语言网站也采用同样的路由方式:/fr/nav、/fr/footer。
7. 区块(并行 Agent)
为每组页面类型(实用页面、服务、案例研究等)分派一个 Agent。每个 Agent 负责一组互不重叠的新区块和内容页面。并行 Agent 以 3 至 4 个为宜。
**任务说明的长度与阅读规范。**任务说明指向文件——stardust/eds-schema/<page>.json、转换日志中与其页面对应的分类行、本文档的 §§ 7–8、davids-model.md——而不会把参考文本粘贴到提示词中。每个 Agent 按章节阅读(先列出标题,再阅读所需范围),而非通读整个文件:在十二次字段迁移中,这份约 2.7 万字的文档每次运行约被从头到尾读了二十遍,每个分派的 Agent 读一遍;有一次记录显示,运行框架的无进展监视器终止了两个转换 Agent,它们的任务说明都最冗长,而用精简任务说明和按行范围阅读重新分派后,同样的页面顺利完成。长时间运行的步骤(捕获、门禁、批量推送)在后台执行,并配有进度文件,Agent 会逐页追加进度,因此协调者可以查看进度,而不必盲目等待。协调者自身的等待也遵循主技能的等待规范:任何操作都不会在前台运行超过约 2 分钟,单次 sleep 不会达到 5 分钟(这是提示词缓存窗口——在部署阶段的上下文大小下,每次过期都会重写整个前缀),并且每 4 分钟内最多从文件读取一次进度。
任务说明模板:
根据项目已锁定的方向:每个原型
<section>都要成为独立的 EDS 区块。逐字提取该区块对应部分的原型<style>,将其作用域限定在区块类名下(使用.block-name .x,而不是section.x .y),并通过接收 EDS 表格区块输入的decorate(block)函数重建原型 DOM。由你负责:原型 [list]、内容页面 [list]、区段 [list]。
现有区块——重复使用,勿重建:[list with one-line authoring shape per block]。
品牌令牌是全局定义在
styles/styles.css中的;不要重新定义。往返校验约定(#93/#94):页面编写的行和区块的解码逻辑,都要根据
stardust/eds-schema/<page>.json(角色 + 重复单元——步骤 2b)编写;在区块 JSDoc 中注明 schema 路径。每写完一个区块,就运行node skills/deploy/scripts/block-roundtrip.mjs "<protoURL>" content/<page>.html --blocks <name>——只有退出码为 0(结构性 🔴 数量为 0),区块才算完成。区段布局——还原原型的最大宽度容器(#13):如果原型区段将内容包裹在居中的最大宽度容器中(
<div class="wrap">/.container/.inner),你的区块就必须还原该容器——将内容放入.wrapdiv 中(block.replaceChildren(wrap)),使有色背景/区段背景延伸至全宽,但内容仍限制在页面最大宽度内。只有当原型区段本身是全出血布局(没有内部容器)时,才让内容铺满边缘。在 ≤1440px 宽度下,这个问题不易察觉,只有视口较宽时才会显现。移动端覆盖规则必须与变体具有相同的特异性(#109):通用移动端规则(
.cards .card-list)在任何媒体查询中都会输给桌面变体规则(.cards.color .card-list)——媒体查询改变的是规则何时生效,而不是规则的优先级。每条移动端覆盖规则都要与对应变体具有相同的特异性(每个变体各写一条覆盖规则,或用:where()降低变体选择器的特异性)。单一变体区块不会触发这个问题,但新增变体时就会踩坑。另有两个移动端/CSS 陷阱:在媒体查询中取消列浮动会失去浮动的 BFC 外边距包容效果(#113)——最后一个子元素的外边距会溢出(仅移动端,溢出几像素);在取消浮动的覆盖规则中添加display: flow-root。此外,包装器重置规则可能比区块自身的规则特异性更高(#114)——footer .footer > div { padding: 0 }会悄然压过footer .f-root { padding: … };重置规则的特异性必须低于可能被其覆盖的规则(用:where()降低特异性)。Flex/网格子项——不要用
<br>,不要写裸子项规则:在 flex 或网格容器内,编写的<br>会变成一个有尺寸的 flex 子项(已记录案例:约 26px 高的<br>子项将两行标签撑开,横跨 120px 宽的卡片)——请输出独立元素。此外,绝不要在变体 CSS 中写.label > span { display: block }这类裸子项规则:它的特异性高于较低特异性的.explore { display: none },会使隐藏元素重新显示——请用区块拥有的每个状态类配合:not()限定选择器,并通过子项列表的计算样式输出进行验证,不要只靠目测。图片——使用
<image-slot>占位符(#2):claude-design 原型使用<image-slot>自定义元素作为图片投放目标;通常没有真实图片资源。将每张图片视为可选的编写单元格,其中包含<picture>/<img>(const pic = cell.querySelector('picture, img'); if (pic) …)。单元格为空时,通过区块 CSS 使用原型的背景样式(例如深色--ink或占位矩形)作为回退,确保没有图片时区段外观仍然正确。编写内容片段时,图片单元格保持为空。滚动显示/由 JS 隐藏的内容(#14):如果原型通过内联
<script>在滚动时切换类来隐藏内容(.reveal { opacity:0 }加上会添加.in的 IntersectionObserver),不要提取opacity:0——原型脚本不会在 EDS 中运行,内容会永久不可见。应让内容保持可见;移除显示效果(保留 hover/:hover过渡)。遵循prefers-reduced-motion。交互式/组件驱动区段(#17):如果区段由组件驱动(状态、
<sc-for>之类的列表循环、<sc-if>之类的条件、{{ }}绑定、data-count计数器、标签页/选择器),就要拆分:数据 → 可编写的行(每个列表项一行,项的字段作为单元格);行为 → 区块 JS。与静态片段不同,区块 JS 会运行——因此,decorate()正适合绑定点击处理程序、IntersectionObserver 计数动画、标签页切换等。标记中应渲染默认/活动状态;其余状态由 JS 持有的本地状态驱动。{{ }}/<sc-for>/<sc-if>都不是 EDS 语法——应将它们理解为“循环这些行”/“显示一种状态”。同样的拆分也适用于数据在运行时加载的区块——stardust/dynamic-features.md中 disposition 为data-fed或index-backed的行:区块的编写行就是回退内容(任何 fetch 解析前就会渲染;如果 fetch 从未成功,也会发布这些内容);decorate()会获取Source/索引并重新渲染;任务说明需注明 endpoint、卡片所需字段及失败时的处理方式。绝不要编写内容仅由 fetch 加载的区块。每个页面的库存清单都是任务输入:#modal链接标记、播放器 URL、scripts/site-config.js中每页的表单 endpoint。模式说明见:skills/dynamics/reference/patterns.md。David 模型(编写结构约定——
davids-model.md):没有重复单元且没有定制结构的纯文本区段属于默认内容,不是区块(D1——转换日志中的步骤 2 分类说明了你负责的区段中哪些属于此类);不要嵌套区块表格(D2);区块列数不得超过 4 列(D10);如果区段符合转换日志中指定的 Block Collection 模式,就遵循该区块的编写格式(D11);单元格中不得显示代码文本(D15)。页面必须通过node skills/deploy/scripts/davids-model-lint.mjs content/<page>.html,且 🔴 数量为 0。按钮:不要自行创建按钮锚点。在内容页面中,将 CTA 编写为由段落包裹的
<strong><a>(主要)或<em><a>(次要)——decorateButtons()会在你的decorate()运行前为其添加类名(a.button.primary/.secondary);在区块 JS 中,将 CTA 段落移入.actions包装器(actions.append(a.closest('p') || a))——绝不要克隆它(EW3)。只有在全局按钮样式确实不合适时(例如需要更大的尺寸),区块 CSS 才覆盖全局按钮样式。带装饰效果(波长下划线)的文本链接不是按钮——保持为普通<a>,并在区块内为其设置样式。Experience Workspace 可编辑性约定(步骤 8,EW1–EW10)——你编写的每个区块都必须遵守,并在完成前通过 EW 检查。 将编写的
h1–h6/p/ul/ol/picture移入生成的包装器中(wrap.append(heading));绝不要通过textContent/innerHTML重建内容,不要cloneNode后丢弃原内容,也不要改标签类型或对显示文本调用.trim()(EW1)。包装器承载布局类;通过后代选择器为编写的元素设置样式(.headline :is(h2, h3)),绝不要给编写的元素添加类名,也不要在选择器路径中使用>/:first-child/:nth-child(EW2)。CTA 应连同其<p>一起移动(EW3)。展示用克隆(轮播幻灯片、跑马灯副本)必须调用stripInstrumentation()(EW4)。无法设为可编辑的文本必须通过@ew-exemptJSDoc 标签声明,绝不能悄然丢弃(EW5)。<button>/<summary>不能作为编辑器宿主(EW7)。block-roundtrip默认带--ew运行:未豁免的文本失效或索引重复都会产生 🔴。EDS 区块约定:每个区块放在
blocks/<name>/<name>.{js,css}。JS 导出default async function decorate(block)。区块输入格式为<div class="block-name"><div>row<div>cell</div></div>…</div>(运行时会添加.block+data-block-name,并在 JS 运行前将其嵌入.<name>-wrapper)。CSS 限定在.block-name下。内联 SVG 标记按区块编写(不使用共享工具函数)。遵循prefers-reduced-motion。EDS 内容页面格式:不得有
<head>元素(EDS 会注入项目的head.html);<header></header>/<footer></footer>必须为空;<main>中的每个顶层<div>都是一个区段,其中包含一个区块或默认内容;区段元数据只能作为步骤 3 中默认内容区段的style值;不得有<style>/<script>;图片 URL 必须完整限定。完成标准:[list of paths]。返回新增区块列表,以及每个页面的一行摘要。 Agent 无需协调共享区块——说明会告知它们复用哪些现有区块。
8. 区块 JS 脚手架
/**
* <block-name> — <one-line description from prototype data-intent attribute>
*
* Authoring rows (positional):
* 1. <picture> background image
* 2. eyebrow text
* 3. headline — render as a REAL heading element, never a bare <div>: the
* hero/lead block's headline becomes the page's single <h1>; every other
* section title becomes <h2> (sub-items <h3>). (use <strong> for emphasis)
* 4. body paragraph
* 5. CTA links — wrap primary in <strong>, secondary in <em>; the EDS link
* decorator applies .button.primary / .button.secondary
* 6..N: card rows — cells: num | label | description
*/
// ── Experience Workspace helpers (EW1–EW4) — copy into every block, no shared import ──
// Wrap an AUTHORED element in a generated wrapper that carries the layout class.
// The element moves (keeps its tag, attributes and data-prose-index); never copy it.
function wrapNode(node, className) {
const w = document.createElement('div');
w.className = className;
w.append(node);
return w;
}
// CTA label: MOVE the authored <p> (the editor index is on the paragraph) and,
// for card-as-link layouts (EW6), unwrap the inner anchor after reading its href.
function labelWrap(link, className, { unwrap = false } = {}) {
const w = document.createElement('div');
w.className = className;
const par = link.closest('p');
w.append(par || link);
if (unwrap && par) link.replaceWith(...link.childNodes);
return w;
}
// Presentational clones (loop slides, marquee duplicates) must not keep the
// editor's indices — one element per index, and it must be the visible one (EW4).
function stripInstrumentation(el) {
el.querySelectorAll('[data-prose-index], [data-image-index]').forEach((n) => {
n.removeAttribute('data-prose-index');
n.removeAttribute('data-image-index');
});
el.removeAttribute('data-prose-index');
return el;
}
// Read-only classification helper — use for DECISIONS (is this the eyebrow?),
// never to produce displayed text (EW1).
const text = (el) => (el ? el.textContent.trim() : '');
const pic = (cell) => (cell ? cell.querySelector('picture, img') : null);
export default async function decorate(block) {
const rows = [...block.children];
if (!rows.length) return;
// 1. QUERY content and CAPTURE traversal starts before moving anything (#42, EW1):
// const heading = block.querySelector('h1, h2, h3');
// const ps = [...block.querySelectorAll('p')];
// const lede = ps.find((p) => !p.querySelector('a, picture, img'));
// const ctas = ps.filter((p) => p.querySelector('a'));
// const media = block.querySelector('picture, img');
// const start = heading ? heading.nextElementSibling : null; // capture BEFORE append()
// 2. CREATE wrappers that carry the prototype's layout classes:
// const box = document.createElement('div'); box.className = 'stage-box';
// 3. MOVE the authored nodes into them — never textContent/innerHTML/cloneNode/retag:
// if (media) box.append(wrapNode(media, 'media'));
// if (heading) box.append(wrapNode(heading, 'headline')); // CSS: .headline :is(h1, h2, h3)
// if (lede) box.append(wrapNode(lede, 'lede'));
// if (ctas.length) { const a = document.createElement('div'); a.className = 'actions'; a.append(...ctas); box.append(a); }
// Sibling walks: `const next = node.nextElementSibling; wrapper.append(node); node = next;`
// 4. block.replaceChildren(box); — the emptied rows go; the authored nodes already moved.
}
Experience Workspace 可编辑性契约(EW1–EW10)
stardust 输出的核心特性:**作者在 DA 文档中编写的任何文本,在页面通过生成的区块 JS 完成装饰后,都可以在 Experience Workspace(da.live 画布、“快速编辑”)中内联编辑,而且区块在编辑期间外观保持不变。**实现机制:§ 目标运行时 → Experience Workspace 插桩。生成的区块是旧版指导原则(值插槽、text(cell)、克隆锚点)的正确实现;问题出在指导原则本身——对 29 页样本的测量显示,之前 1452 处作者文本中有 841 处可编辑,之后有 1416 处可编辑,视觉变化为 0 px,其余每一处都属于已声明的例外。
**EW1 — 移动作者编写的元素;绝不重建。**每个作者编写的 h1–h6 / p / ul / ol / pre / blockquote / picture / img 都要通过 append()/prepend()/before() 插入生成的布局。绝不能从 textContent/innerHTML 重新创建,绝不能 cloneNode 后丢弃,绝不能更改标签(作者编写的 <h2> 必须保持为 <h2>;ENCODE 端会编写设计所需的标签),绝不能对显示文本执行 .trim()/规范化(编辑器的光标位置计算依赖 textContent 的长度)。之后丢弃已清空的行(block.replaceChildren(...))没有问题——节点已经移动。移动节点时遍历同级元素,必须在 append() 之前获取 nextElementSibling——否则移动节点会中断遍历(曾有一个真实区块因此静默丢失了所有子链接)。
EW2 — 布局类放在包装元素上;通过后代选择器从包装元素设置作者编写元素的样式。编辑器替换内容时会用相同标签重新渲染,不带类或 span,并插入两个包装 div(div.prosemirror-editor > div.ProseMirror > <tag>),因此:
- 将
h3.headline { … }改写为.headline :is(h2, h3, h4) { … }——特异性相同,层叠规则相同(元素保留全局标题类型和外边距),并且仍能匹配编辑器中的<h3>; - 绝不能在通向作者编写元素的选择器路径上使用子组合符或位置伪类(
.affordance > p、header > p、:first-child、:nth-child);要在前导段落规则中排除已移动的 CTA 段落,可使用header p:where(:not(.affordance p))(零特异性排除); - 只有在旧规则本身设置了元素类型(专属 hero 标题)时,才在包装元素上设置
font-*,并在内部元素上设置font: inherit; color: inherit; margin: 0; - 如果过去由 span 承载颜色,包装元素上的
color必须显式设置(全局h1–h6 { color }的优先级高于继承); - 只有作为展示细节、且包装元素不依赖其结构时,才允许在作者编写元素内部进行结构调整(逐行
<span>)。
EW3 — CTA 要连同其段落一起移动。索引位于 <p> 上,而不是 <a> 上:actions.append(a.closest('p') || a)。在运行 decorate() 之前,decorateButtons() 已将 .button.primary/.secondary 添加到锚点上;编辑时这些类会消失,因此基础层会根据作者编写的标记提供编辑模式重绘(步骤 3 (a))——对已发布页面没有任何影响。区块自身添加到作者编写的锚点或列表上的类(a.link-download、ul.icon-list-items)也会以同样方式消失:应改为按元素或属性设置样式(.icon-list ul、a[href*=".pdf"])。
**EW4 — 展示用克隆要移除插桩。**轮播循环幻灯片、跑马灯副本、粘性副本:使用 stripInstrumentation(clone)(脚手架辅助函数)。每个索引只能对应一个元素,而且必须是可见的元素——重复的索引会让编辑器关联到 DOM 顺序中的第一个副本,而它往往是隐藏的克隆。
EW5 — 豁免文本需明确声明,不可静默丢弃。 分为三类:(a)文本即元数据,永不显示(字形键、label | value 键、变体选择器、视频/嵌入源链接、隐藏配置行);(b)派生文本(例如将 ISO 日期重新渲染为日/月片段)——优先直接编写显示形式;(c)由索引/API 驱动的区块,其编写的行仅作为无 JS 时的回退内容(新闻发布列表、职位列表、表单、地图)。在区块的 JSDoc 中用机器可读标签声明每一项——@ew-exempt <p> ISO date (cell 1) — derived,或 @ew-exempt all — index-driven listing, authored rows are the no-JS fallback——以便门禁将其列入白名单。第四种情况——一段编写的段落被渲染成 N 个元素(面包屑导航)——无法通过区块变为可编辑:ENCODE 端必须编写 <ul>(列表是一个可编辑单元)。
EW6 — 卡片即链接:在实时 DOM 中展开编写的内层锚点(读取其 href 后执行 link.replaceWith(...link.childNodes));索引段落会保留在卡片 <a> 内,发布后的页面不会出现嵌套锚点(labelWrap(link, 'affordance-wrap', { unwrap: true }))。编辑时,编辑器会重新渲染内层链接,因此 foundation 提供了 a .prosemirror-editor a:any-link { color: inherit; text-decoration: none }(步骤 3(b))。
EW7 — 交互式容器不能承载编辑器。 <button>(或 <summary>)会拦截焦点和点击:手风琴标题应移至同级的 div.accordion-title,整行标题由点击处理程序响应,按钮则变为仅显示箭头且带有 aria-label 的切换控件。静止状态下隐藏的内容(折叠面板、未激活的标签页、非当前幻灯片)可编辑,但只有作者打开后才能访问——确保这些控件在工作区内仍可使用(其中会阻止链接导航,但不会阻止按钮)。
EW8 — 从默认内容中重新吸收的区块标题,应移动包装器的子元素(它们已经是可编辑的默认内容),并移除空包装器(§ 区块标题)。
EW9 — 可重入性。 每次 setBody() 都会在新的 body 上重新运行 loadPage();采用同级区块的区块(标签页、手风琴)必须将那些已装饰的区块整体移动,且不得依赖上一次运行留下的模块级状态。
EW10 — 默认内容样式遵循相同的选择器规则。 应用于正文的区块样式(main .section.x .default-content-wrapper > p:first-child、p:has(picture) + p)也会因相同的双包装器问题而在编辑模式下错位;应使用后代选择器,并避免在正文元素上使用位置伪类(步骤 3)。
EW 门禁——在循环中逐个区块运行。 block-roundtrip.mjs 默认使用 --ew 运行(如下),并会在存在未豁免且失效的文本或重复索引时失败。独立探测工具会在整页上测量同一指标,而 --simulate-editor 会执行编辑器替换,并报告每段文本的字体/颜色/高度偏移以及区块高度 Δ:
# harness mode — no server, same technique as block-roundtrip
node skills/deploy/scripts/ew-editability-probe.mjs --content content/<page>.html --verbose --simulate-editor
# URL mode — a served page (dev server, preview origin); --blocks-dir lets it read @ew-exempt tags
node skills/deploy/scripts/ew-editability-probe.mjs http://localhost:3000/stardust/.work/harness/page.html --blocks-dir blocks --verbose --simulate-editor
退出码 0 = 每段未豁免的编写文本都可编辑,且无重复项;1 = 存在失效/重复项;2 = 探测错误。应通过移动问题元素(EW1)或将问题选择器改为包装器后代形式(EW2)来修复——绝不可放宽门禁。由此可见,两篇外部说明都忽略了一点:导致编辑失效的并非 cloneNode(true)(克隆会保留该属性——这正是基于克隆的区块有效、基于 textContent 的区块却无效的原因);克隆仍然是错误做法(会造成重复和身份过期),但正确的修复方式是“移动”,而非“避免克隆”。
在循环中证明每个区块的往返结果——部署前逐个验证(#94)。 第 10 步的 content-diff 是部署后的验证;不应等到那时才发现缺陷。编写区块(及其编写的行)后,运行测试框架进行往返验证:
node skills/deploy/scripts/block-roundtrip.mjs \
"http://localhost:8791/<prototype>.html" content/<page>.html --blocks <name> # omit --blocks for all
它会使用区块自身的 JS+CSS 在本地装饰已编写的内容(render-harness 技术——无需 DA、无需开发服务器),使用与 content-diff 相同的分类器(skills/deploy/scripts/content-inventory.mjs)提取已装饰区段和匹配的原型区段中的角色清单,并对两者进行差异比较。如果原型区段的类名与区块名称不同,请传入 --map <name>=<selector>;如果仓库布局不是 eds/ 或根目录级别,请传入 --styles/--blocks-dir。任何结构性 🔴(MISSING CTA/HEADING/EYEBROW、ROLE SWAP)或任何装饰错误都会导致退出码为 2——修复解码问题(或已编写的行)并重新运行;退出码为 0 即表示区块完成。这里有意不检查字体分支(harness 会渲染本地字体;字型文件属于 Step 4 + Step 10 的工作)。decorate errors 行表示区块在运行中途抛出了错误,或其 JS 从未安装(harness 会内联区块 JS,因此无法解析模块作用域中的 import——请内联辅助函数,或通过开发服务器 harness + Step 10 验证该区块);无论哪种情况,原始行都可能与原型错误匹配,因此仅此问题本身就足以让检查失败。模板插槽区块(#95)仍会运行检查:它可以捕获插槽填充错误和已编写行的偏移。启用 --ew(默认开启)时,同一次运行会在 decorate() 之前,将工作区的 data-prose-index 标记到已编写的可编辑元素上,并按区块报告 editable N/M, dead K, duplicated D, exempt E——非豁免的无效文本(DEAD TEXT)或重复索引(DUPLICATED INDEX)会像 MISSING CTA/HEADING(参见上面的契约)一样成为 🔴;--no-ew 仅用于单独诊断角色差异。
**Playwright ESM 规则的复制说明:**将这些检查复制到项目中时(提取 SKILL.md § Setup——捆绑脚本必须在项目中运行,才能解析 import 'playwright'),请复制 skills/deploy/scripts/——现在它是自包含的(共享角色分类器 content-inventory.mjs + diff-profiles.mjs 位于其中;block-roundtrip.mjs/section-schema.mjs 在本地导入它们)。只有在还运行 Step-10 建议使用的 content-diff 时,才需要额外复制 skills/diff/scripts/(content-diff.mjs + live-session.mjs,它们会反向导入 ../../deploy/scripts/)。
主视觉/首屏区块:按内容查询,不要硬编码行索引(#42)。 如果主视觉按 rows[3]=headline, rows[4]=lede, rows[5]=CTA 读取内容,一旦内容结构不同就会出问题——而强制元数据 / 单个 <h1> 的 SEO 重构(#34/#35)会主动将标题 + 导语 + CTA 合并到一个单元格中,因此固定索引会返回 undefined,主视觉 .wrap(LCP 元素,也是唯一的 <h1>)会在没有报错的情况下渲染为空。装饰主视觉区块时应使用查询方式(block.querySelector('h1,h2');包含链接的 <p> = CTA;可从任意位置获取 picture),使其同时兼容丰富的多行结构和合并后的单元格结构。**按顺序/长度区分眉题与导语,不要依赖“第一个 <p>”(#51):**两者都是不含链接的 <p>,因此“第一个不含链接的段落 = 导语”会把它们弄反(短眉题在前)。标准主视觉顺序为 眉题 → 标题 → 导语:眉题是标题之前的简短/大写行;导语是标题之后的句子长度 <p>。本地 QA 检查:装饰完成后,断言主视觉的内层 wrap 非空且包含 <h1>。
绝不能自行构建标题元素——应移动作者编写的标题(#55,EW1)。 旧做法(构建一个实时的 <h1>,再将单元格的 childNodes 克隆到其中)会在单元格已有标题时生成 <h1><h1>…</h1></h1>(#35/#42 正是这种情况),而即使采用“先取消包装,再克隆 inner.childNodes”的修复方式,在 Experience Workspace 中仍会留下无法编辑的标题——childNodes 不带 data-prose-index。现在的规则是:const heading = cell.querySelector('h1,h2,h3,h4,h5,h6'); box.append(wrapNode(heading, 'headline'))——作者编写的标签就是标题标签(ENCODE 端为主标题编写 <h1>,其他位置编写 <h2>,见 #35/#57);包装元素携带类名;CSS 为 .headline :is(h1, h2, h3)。只有当单元格没有标题元素时(流水线外测试工具中的内容),才退而包装纯文本——在 JSDoc 中标明这个分支,它绝不会在 DA 内容中触发。本地 QA:确保恰好有一个 <h1>,.headline 内没有后代标题元素,并且 EW 检查显示标题可编辑。
标记注入必须具备幂等性——且绝不能编辑作者编写的文本节点(#70,EW1)。 当 decorate() 在作者编写的标题/链接文本前置固定装饰标记(字符 ▶、徽章、CH NN 编号)时,作者(或 #34/#35 的 SEO 内容重建)可能已经输入了该标记,导致标记重复(▶ ▶ Latest signal)。应在生成内容侧解决:将标记作为独立元素输出(在移动后的标题前放置 <span class="marker" aria-hidden="true">▶</span>,或在包装元素上使用 CSS ::before),并在作者编写的文本已以该字符开头时跳过输出(/^\s*▶/.test(heading.textContent))。绝不要对作者编写的文本节点调用 replace()——这会改变其 textContent 长度,并破坏编辑器的光标偏移(EW1)。在区块注入该标记的每个位置都应用同一检查(章节标题和卡片链接),不能只处理部分位置。(部署后的目测检查会发现重复字符 X X …。)
轮播幻灯片分段由标题边界驱动,且不依赖顺序(#69)。 只在标题边界处分段——一个标题开启一张幻灯片(第一张标题前的 <picture> 可以开启第 0 张幻灯片)。不论作者编写的顺序如何,都要将两个标题之间的所有内容归入当前幻灯片(眉题/标签可能出现在标题之后,而非之前):第一个非链接文本段 = 眉题,链接 = CTA,额外文本 = 描述。绝不要让标题后的文本节点开启新幻灯片(这会占用标题位置,且 CTA 永远无法关联——症状是幻灯片数量多出 2 张、CTA 数量为 0)。本地 QA:渲染后的幻灯片数量 == 作者编写的标题数量,且每张幻灯片的 CTA 数量 == 作者编写的数量。
分栏/图片叠加分段:缓冲标题前面的眉题(#76)。 标题边界分段(#52/#69/#73)在标题处开启新组——但在分栏面板和图片叠加区域中,眉题是标题上方的小标签,因此它会先于标题出现;“标题后才开始收集”的循环会将其完全丢弃。更糟的是,标题后的正文行会落入空置的眉题槽位(继承其强调色/大写样式),而下一组的眉题会串到前一组的摘要中。修复方法:保留一个 pendingEyebrow——标题前出现的任何文本都先缓冲,并附加到该标题开启的组;当一个组已有正文/CTA 后,后续单独出现的文本应重新缓冲为下一组的待处理眉题,而不是当前组的摘要。这与 #69 互补(轮播眉题可能出现在标题之后):不要假设顺序固定——缓冲标题前的文本,并根据标题后的内容是否已填充来分类。所有内容都在 DOM 中,因此即使渲染错乱,数量检查仍会通过。本地 QA:断言每组的眉题/标题/正文/CTA 都落在各自的槽位中(眉题文本 ≠ 正文文本),不能只检查是否存在 N 组。在啤酒制造商 the-people 上遇到此问题。
轮播/切换器的主标题:所有幻灯片中服务器端恰好只有一个 <h1>(#57)。 轮播主视觉区域会为 N 张幻灯片编写标题;如果每个标题都是 <h1>,生成的 HTML 就会有 N 个 <h1>(区块可以在 JS 执行后轮播同一个实时 <h1>,但爬虫会看到全部 N 个)。将第一张/主要幻灯片的标题设为页面 <h1>,其他每张幻灯片的标题都设为 <h2>——区块会通用地读取这些标题(querySelector('h1,h2…')),因此轮播仍可正常工作,而且服务器端 HTML 只有一个 <h1>(#35)。本地 QA:统计 content 文件中的 <h1> 数量,应为 1。
多行页头/简介必须整体收集,不能只取第一行(#56)。 如果区块把“第一行不含图片的行”作为页头,那么当页头被编写为 N 个单独的单元格行(眉题/标题/CTA)时,就会出错:只有眉题会成为页头,区块标题和 CTA 会泄漏到条目网格中,变成无效卡片(区块标题随后会以卡片标题大小呈现)。页头是第一个内容/图片单元格之前的全部内容——将所有前置的不含图片的行都收集到页头中。(与 #52 的扁平化操作相反。)本地 QA:条目网格中的数量应恰好符合预期,区块标题应以区块标题大小呈现。
默认采用 DA 扁平化的单单元格约定(#62——大多数解码错误的根本原因)。 当区块 JS 和内容页面在同一轮中生成时,两者必须对行结构达成一致——DA 交付的大多数区块都是一行,其中一个单元格包含所有作为扁平同级元素的内容,而不是原型所暗示的丰富多行布局。若区块按多行索引约定编写(rows[0]=眉题, rows[1]=标题, rows[2]=卡片…),它就会将整个单元格读作 rows[0],并发现 rows[1..] 未定义——结果只呈现 N 项中的 1 项(混乱的主视觉区域、只有一张卡片的卡片网格),却能通过 lint。因此,默认采用优先扁平化,通过 CELL 级级联收集器实现(#68/#71)——不要使用单个选择器,也不要使用区块级回退链。陷阱(#71):区块级链(在整个区块上尝试 :scope>div>div>*,否则……)只要任一单元格有子元素,就会在第一层匹配成功。因此,在一个混有元素单元格(img/h1/a)和纯文本单元格(眉题、导语、数量、元数据、日期)的区块中,它只会返回元素单元格,并悄无声息地丢弃每个纯文本单元格——这正是最常见的每行一个元素的 DA 结构。规范的收集器会遍历各个单元格并逐一处理:
function collectNodes(block) {
const out = [];
block.querySelectorAll(':scope > div > div').forEach((cell) => {
let kids = [...cell.children];
// #104 — the runtime's wrapTextNodes (decorateBlock) folds any MEDIA-LED or
// unlisted-first-child cell's ENTIRE content into ONE <p> (first child not in
// P/PRE/UL/OL/PICTURE/TABLE/H1-6, or PICTURE followed by anything). Expand it
// back, or every sibling after the image silently vanishes (thumbnails
// without titles). The roundtrip harness mimics wrapTextNodes, so an
// unexpanded collector fails the gate instead of shipping.
if (kids.length === 1 && kids[0].tagName === 'P' && kids[0].children.length
&& kids[0].querySelector('picture, img')) {
const inner = [...kids[0].childNodes].map((n) => {
if (n.nodeType === 1) return n;
// HARNESS-ONLY fallback (EW5): a bare text node inside the wrapper <p>. In DA
// content every text is already a <p>/<h*> (prose2aem keeps the <p> in every
// cell), so this branch never fires there; a synthesized <p> is dead in the
// workspace. Move the TEXT NODE into a fresh <p> (p.append(n)) — never copy
// its textContent — so the harness and the gate see the same node.
if (n.textContent.trim()) { const p = document.createElement('p'); p.append(n); return p; }
return null;
}).filter(Boolean);
kids = inner;
}
if (kids.length) out.push(...kids);
// HARNESS-ONLY fallback (EW5) — same rule: wrap the existing text nodes, never
// synthesize from textContent. Flag the branch in the block JSDoc.
else if (cell.textContent.trim()) { const p = document.createElement('p'); p.append(...cell.childNodes); out.push(p); }
});
return out.length ? out : [...block.children]; // last-ditch: direct children
}
collectNodes() 返回的是已有的编写元素——这正是关键:它返回的所有内容随后都会被移动到布局中(EW1)。两个合成 <p> 的分支只是用于流水线外内容的测试框架回退方案,必须注释说明这一点;如果 EW 门禁在 DA 结构内容上报告有文本丢失,说明区块正在收集器下游的某处复制文本。
随后按内容分段/分类返回的节点;每行一个单元格只是回退方案。本地 QA:断言每个区块的主要内容容器在 decorate 后都不为空(childCount>0 / height>0)——否则,0 节点的选择器不匹配会呈现为空白框,且不会发出提示(只有逐区块高度探测才能发现空白主视觉区域;客户评价区块则表现为内容缺失)。本地 QA / 检查清单断言:decorate() 完成后,渲染数量必须与编写数量相同——主视觉区域容器不为空,且包含 <h1>;卡片网格包含 N 张卡片(= 源内容中的 N 个重复标题),绝不能只有 1 张。基于索引的约定可以通过 lint,但会悄无声息地只渲染 N 项中的 1 项。
交替使用不同背景色的卡片网格:当标记消失时,按 INDEX 重建节奏(#61)。 原型通过结构编码网格的明暗交替(第 2 张卡片带有 --dark),但该标记不会保留在 DA 内容中。如果区块仅在明确编写了“dark”的单元格上切换为深色,那么每张卡片都会呈现为浅色——深色卡片上的白色标题/CTA 会变得不可见(由 #59 的 SURFACE/GROUND MISMATCH 标记捕获)。回退到位置模式:const dark = explicitMarker ?? (i % 2 === 1)。然后,将深色背景上的按钮/文本覆盖样式限定在生成的 .card.dark 区块类中(#41)。
这适用于每个区块,不只是 hero(#48)。 作者的行/单元格布局很少符合 Agent 的假设——区块硬编码 rows[4] = <dl> data cell,但内容作者改为每行写一组 dt|dd 时,内容会被悄悄丢弃;而当作者写的是一个 "01 — Title" 单元格时,[num]|[label] 的双单元格假设会重复眉题。这些问题都是静默发生的:页面仍会渲染出内容,而仅基于指标的差异(拉伸/贴边/空白)不会捕获 CTA 被丢弃、眉题重复或数据网格为空的问题。按内容对行/单元格分类——是否含有标题、<picture>、链接、数字前缀(/^\d+/)、dt|dd 双单元格结构——绝不要按 block.children[N] 分类。当 DA 扁平化内容时,内容会变成“每行一条,分隔符承载结构”(#50)——因此要稳健地解码。(编码则相反:当生成器控制编写内容时,不要自行添加分隔符——应使用保留的标签标注每个子字段;详见编码约定。区块仅将分隔符解析作为向后兼容的回退方式。)DA/snowflake 内容很少会保留原型中的语义化 <dl>/<dt>/<dd>/<ol>/<ul>/<li>——而是将它们扁平化为一系列单元格为一个的 <p> 行,并用行内分隔符连接:Address: PLACEHOLDER · Brand team to supply(键值对)、01 · Tabernacle · Imp. Stout · 6.5%(以 · 分隔的规格行)、Heber Valley · Utah · Est 1996(普通底部说明行)。因此,使用 querySelector('dl, dt') 或 'ol, ul, li' 查找元素的区块会什么也找不到,并悄悄丢弃整张数据表/规格列表/菜单。**按分隔符而非标签解析:**在第一个冒号处分割 Key: value,生成 dt/dd;将以 · 分隔的行拆成多个 span;根据前面的标题(On tap this week)判断列表起始位置,而不是寻找 <ol>。(即使是 #48 的“每行一组 dt|dd”,也会漏掉这种情况——这一行只有一个单元格,而不是两个。)检查清单:对于任何表格/规格列表/菜单区块,装饰完成后都要断言数据容器的行数符合预期且不为零。
必须处理每种作者编写的元素类型,否则内容会悄悄消失(丢弃内容类别)。 如果 decorate() 移动了标题、段落和 CTA,却没有处理 <ul>,作者编写的优点列表就会变成空白——没有错误,lint 也会通过(内容本身有效;是解码器丢弃了它),而且这种情况已在两个区段中记录。每个区块都要处理完整的默认内容类型——h1–h6、p、ul/ol、picture、table、blockquote;或者在 decorate() 结尾添加一个剩余元素处理流程,将任何未被命名插槽接收的作者编写元素追加进去(这样,被遗漏的类型至少会以可见的默认样式显示,而不会消失)。门禁:block-roundtrip(默认 --ew)会将作者编写的文本中有词语未出现在装饰后单元中的情况报告为内容丢失 🔴,与死文本(重建内容,EW1)区分开;两者都会导致区块失败。
重复的卡片/磁贴网格:DA 会将 N 个单元压平为一个单元格——应分段,而不是遍历行(#52)。 包含 N 个相似单元的区块(例如三列卡片网格、两列磁贴横排、Logo 行、团队网格)经常会被 DA 折叠为单行中的一个单元格,其中每个单元的元素(标题、图片、段落、链接)都成为平级元素。若代码按每张卡片对应一行 DOM 的方式编写,最终会渲染出 0 张卡片(整个网格折叠进区块标题)或 1 张——其余产品/磁贴会被悄然丢弃。检测这种扁平结构(rows.length === 1 且单元格中有多个标题),并将平级元素列表分段,每个重复标题对应一组。重复单元的边界是单元格中最常见的标题标签(卡片使用 h3;单独的区块标题在上一级,使用 h2)——按“任意标题”分段会把区块标题误作第一个卡片。同时支持扁平的单单元格结构和每个单元占一行的结构。****分段顺序(#63、#73):(0)每张卡片占一行——如果有 ≥2 个 :scope > div 行,且每行都包含卡片标题(h3/h4),则按 AUTHORED 字段顺序,通过 [...row.children] 为每行构建一组(将开头不含卡片标题的行视为区块头部);绝不要假设字段顺序是 tag → media → heading——如果内容按 media → tag → heading 编写,每张卡片的图片就会被归到上一张卡片名下,第一张卡片的图片则会丢失(这是 #73 中通过卡片数量检查、但图片整体错位一张的情况);否则,(1)如果存在,按每张卡片的标题边界分段;否则,(2)每个以分隔符分开的 <p> 行对应一张卡片——当卡片没有各自的标题(只有区块标题)时,每张卡片是一行 Name · meta · meta · Badge(这是编写简短卡片时的自然方式):按单元分隔符(·)拆分,第一段是名称,末尾的关键词(New/Limited/Sold out……)是徽标,中间部分是元数据;否则,(3)每个单行单元对应一张卡片。这样便统一处理了 #50(分隔符解析)和 #52(卡片分段)之间、没有标题的卡片/磁贴横排——否则它会悄然渲染出 0 张卡片(区块标题仍会显示,因此只有 #49 CONTENT GAP / 卡片数量断言能发现问题)。本地 QA:即使区块中没有找到每张卡片的标题(这种情况下会返回 0),也要断言网格数量;还要断言每张卡片的图片 src 与其自身标题匹配(#73——图片错位一张时,仅检查数量无法发现问题)。**绝不要用 <picture> 作为卡片的主要边界(#64):**图片主导的原型容易让人认为“每张卡片都从图片开始”,但 claude-design 内容通常没有图片(#2),因此按图片划分会把 N 张卡片折叠成 1 张。应优先按每张卡片的标题分段;图片只能用作判断媒体内容归属哪个分段的提示。(如果网格只渲染出 N 张中的 1 张,应怀疑使用了按图片划分、但没有标题回退逻辑的边界。)
分类器必须匹配元素本身或其后代(#53),媒体必须匹配 picture, img(#72)。 在扁平结构中,分段后的“单元格”是裸露的平级元素(<img>、<a>、<h3>),因此 cell.querySelector('img') 会返回 null,内容会悄然消失。使用 el.matches(sel) || el.querySelector(sel) 进行分类。对于媒体,始终测试 picture, img(而非仅测试 picture)——未经管道处理的/测试框架中的内容和粘贴的外部 URL 会提供不带 <picture> 包装的裸 <img>:const media = el.matches('picture, img') ? el : el.querySelector('picture, img')。(对于 A / Hn 也应如此。)
始终结合逐区块截图目视检查(#23)和 CONTENT GAP 探测标志(#49)——标题/内容框数量或主内容高度与原型之间的差异,意味着内容被丢弃或重复。
标题——每页恰好一个 <h1>,并提供真正的内容大纲(#35)。 原型中的标题通常用没有标题语义的 <div>/<span> 设置样式。应将其提升为语义标题:主视觉区/引导区块将其标题呈现为页面唯一的 <h1>;其他所有分区标题呈现为 <h2>(子项用 <h3>)。切勿让标题只是一个裸 <div>——<h1> 是页面上最强的相关性信号,也是页面 <title> 的来源(#34);内容大纲则会影响爬虫、AI 答案引擎和屏幕阅读器(WCAG)。这也适用于交互区块:流程/测验/仪表板应在服务器可见的标记中将其引导标题呈现为 <h1>,而不只是等到 JS 运行后才呈现。(这可以避免以下问题:转换后的页面没有任何 <h*> 元素,或存在多个同级 <h2>,却没有 <h1>。)
交互区块(#28)。 当原型分区是有状态组件(选择器、筛选器、会修改数据的表单、多步骤流程)时,应将其重现为一个自包含并管理自身状态的交互区块——区块 JS 会运行,因此完全支持这种做法:
- 数据 → 可通过键标识和编写的行。 对于异构数据,以每行的第一个单元格作为键(
account | id | name | …、txn | date | desc | …),并按键解析。对于同构列表,每项只需一行。 - 行为 → 本地状态 + 定向重新渲染。 保留一个可变的
state对象;编写简短的render*()函数(例如renderCards()、renderList()),每次交互时只重新调用受影响的函数——这相当于手动实现 React 重新渲染。修改数据的表单应进行验证、更新state、重新渲染受影响的部分(卡片、总计、<option>),并显示确认信息。 - 这相当于在 React 中将状态提升至父级:如果多个控件共享数据,应将它们放在同一个区块中,而不是尝试跨区块同步状态。(如确实需要跨区块协调,可使用 DOM
CustomEvent。) - 顺序流程与可独立访问的视图——选择恰当的拆分方式(#33)。 如果一个顺序流程中的视图都从同一个起点进入,且无法独立访问(搜索 → 结果 → 座位 → 确认;入门向导;结账),那么它应该是一个区块,使用
state.view字段和render()分派器,通过replaceChildren()替换为当前视图。这与 #29 相反:具有不同框架布局的可独立访问视图应拆分为多个页面。经验法则——从单一入口依次推进 → 一个区块;可独立访问且有自身框架布局 → 多个页面。 - 验证行为,而不只是外观——参见本地 QA:操作每个控件并断言状态发生变化。(若在点击后立即断言计算样式,应先将指针移出元素并等待 CSS 过渡结束,否则读取到过渡中或
:hover状态可能会导致误判。)
9. 内容页脚手架
每个内容页都必须以 metadata 块(#34)开头。 至少要包含 标题(约 50–60 个字符:品牌 + 主要关键词/地点,根据页面实际的 <h1> 撰写,绝不能使用区块或章节名称)和 描述(约 150–160 个字符,概述页面内容)。如果省略,EDS 会从第一个内容单元格生成 <title>,产生 <title>Hero</title> / <title>Quiz</title> 之类的垃圾标题,并且不输出描述;此外,由于 EDS 会将标题/描述同步到 og:/twitter:,这些垃圾内容也会污染社交媒体/AI 分享卡片。编写这一个块,即可一次性设置标题、描述、og:title、og:description、twitter:title 和 twitter:description。需要时,将 nav / footer 路径覆盖项 / Robots 行放在同一个块中(无论该块位于 <main> 中的何处,管道都会提取它——可以放在开头或末尾)。header/footer 区块会自动加载 /nav 和 /footer;无需进行其他逐页配置。
内容页是 DA 的 body 片段(#7)。 DA Source API(无头部署路径)要求文档从 <body> 开始——不得包含 <!DOCTYPE>、<html> 或 <head>(管道会从 Code Bus 注入 head/scripts/styles)。请准确输出:
<body>
<header></header>
<main>
<div>
<div class="metadata">
<div><div>Title</div><div>Brand — primary keyword / location (≤60 chars, from the <h1>)</div></div>
<div><div>Description</div><div>A 150–160 character summary of the page.</div></div>
</div>
</div>
<div>
<div class="hero">
<div><div><h1>The page's lead headline</h1></div></div>
<div><div>body copy</div></div>
<div><div><strong><a href="/path">Primary CTA</a></strong> <em><a href="/path">Secondary CTA</a></em></div></div>
</div>
</div>
<div>
</div>
</main>
<footer></footer>
</body>
(只有基于挂载的部署能容忍完整的 <!DOCTYPE html><html>…</html> 文档——摄取时会移除 <head>。对于 Source API/curl 路径,请输出上面的 body 片段。在进行任何 DA 写入之前,运行 node skills/deploy/scripts/sanitise.js <file>,将非 ASCII 字符——® · – —、重音字符、表情符号——编码为 HTML 实体,否则 DA 会将它们损坏为 U+FFFD。)
若要为特定页面使用不同的外框,将 nav 和/或 footer 路径行添加到同一个 metadata 块中(header/footer 区块会读取这些行——步骤 6):
<div>
<div class="metadata">
<div><div>nav</div><div>/nav-minimal</div></div>
</div>
</div>
多视图 SPA → 多个页面(#29)。 如果原型是包含多个视图的单页应用,且这些视图的外框不同(例如,带完整导航栏的营销首页与带简化栏的登录后仪表板),请将每个视图转换为独立的 EDS 页面(按 #27 对每个视图进行预渲染),并用真实 href 链接它们。在 vanilla EDS 中,这很简单:编写第二个导航文档(content/nav-app.html),并通过 nav: /nav-app 元数据让仪表板页面使用它——一个 header 区块,两个自定义导航。若页脚相同,则共用 /footer。(Harness 注意事项:metadata 块会由交付管道写入 <head> 中的 <meta>,但本地 harness 没有管道,因此本地不会应用 nav 覆盖项;请在部署预览中验证每页的外框。)
不要输出 <head> 元素。 EDS 内容页是等同于 Markdown 的片段:文档元数据(标题、meta、样式表、脚本)存放在项目的 head.html 中,由 EDS 在交付时注入。内容页中的 <head> 块最多是无用内容,严重时会造成重复冲突。
如果原型包含真实图片,编写的内容 <img src> URL 必须指向预览摄取器可以获取的主机——优先使用 https://content.da.live/{org}/{repo}/media/<scope>/<file>(不依赖分支;请先通过 Source API 上传二进制文件——参见 The ENCODE contract → Images)。完整限定的 https://main--<repo>--<owner>.aem.page/… 也可以摄取,但会锁定分支。编写的内容中绝不能使用相对于仓库的 /img/…——它会以 <img src="about:error"> 的形式交付。这仅适用于编写的内容 <img>——不适用于区块 JS/CSS 中作为 CSS 背景引用的固定资源(#67):这些资源应继续使用根相对路径 /img/<brand>/…(反模式 9b),由浏览器获取,绝不会被摄取。 如果原型使用 <image-slot> 占位符(没有真实资源——claude-design 原型中很常见),请将图片单元格留空(<div></div>);区块 CSS 的背景回退(步骤 7)会在没有图片的情况下正确呈现该部分。作者之后再添加真实图片。
部署前本地 QA(无 DA)
aem up --html-folder content 并不是预览新页面的可靠方式:它会以静态方式提供仓库文件,并且只有当某个路径已存在于远程路由索引中时,才会通过完整流水线渲染该路径——全新路径在渲染路由上会返回 404。若要在本地验证装饰效果,请构建一个自包含测试环境,并通过开发服务器打开它(开发服务器会在实际路径上提供仓库代码):
# 1. dev server (serves /scripts, /styles, /blocks, /fragments at their real paths)
npx -y @adobe/aem-cli up --no-open &
# 2. harness — use the committed helper (do NOT hand-roll the metadata strip, #46):
# it removes the metadata block by balanced tag-counting and rewrites absolute
# /img/ URLs to root-relative (#43), then emits the full harness doc.
node skills/deploy/scripts/build-harness.mjs content/<path>.html stardust/.work/harness/page.html # .work/ is gitignored via stardust/.gitignore
打开 http://localhost:3000/stardust/.work/harness/page.html——scripts.js 会运行 loadPage()(添加 body.appear、执行装饰并加载分区),阻止从代码源加载内容,页面框架则通过 header/footer 区块获取 /nav 和 /footer。使用无头 Chrome(--virtual-time-budget=9000 --screenshot / --dump-dom)或 Playwright 截图/检查,以确认装饰已运行,而不是评判像素效果:视觉效果的判定应在第 10 步对已部署页面进行。
进行任何 DA 推送前,运行整页往返检查门(#94)——不带 --blocks 的 block-roundtrip.mjs(涵盖所有区块,不依赖 DA):它能捕捉逐区块运行可能遗漏的跨区块丢失问题(某个区块头被错误地并入其他区块、已编写区块与原型分区之间的实例数不匹配)。其 --ew 检查(默认开启)是整页的Experience Workspace 可编辑性检查门:声明的 @ew-exempt 之外,不得有不可编辑文本,且不得有重复索引。
然后运行编辑模式模拟(第 8 步 § contract)——node skills/deploy/scripts/ew-editability-probe.mjs --content content/<page>.html --simulate-editor --verbose:它会对每个保留的文本执行工作区的编辑器替换,并报告每段文本的字体系列/大小/字重、行高和颜色偏差,以及每个区块的高度变化。目标:无偏差,区块高度 Δ ≤ 2 px。出现偏差意味着类位于已编写元素或其跨度上,或使用了 >/:first-child 路径——将选择器改为 wrapper 后代形式(EW2)。render-harness.mjs --ew --simulate-editor 会为相同状态截图,供人工检查(它会隐藏 body > header,因此高区块元素的截图不会受到粘性页眉影响)。
编辑性转换前后已发布渲染的像素一致性——将已发布区块转换为基于移动的模式时,先在隐藏 body > header 且排除实时组件 iframe 的情况下,以 1440 宽度截取每个区块实例的元素截图;转换后重新截图,并确保每个实例的 pixelmatch = 0。检查门绝不能以牺牲保真度换取可编辑性(一次包含 27 个实例的转换发布后,像素差异为 0)。
然后运行标准 QA 检查门——不要手写探测脚本(#101):
node skills/deploy/scripts/qa-gate.mjs http://localhost:3000/stardust/.work/harness/page.html \
--schema stardust/eds-schema/<page>.json # exit 0 required
一次运行即可验证完整的装饰契约:运行时已启动(body.appear)、恰好有一个内部无嵌套内容的 <h1>(#35/#55)、所有区块均为 loaded 且渲染内容非空、无页面错误/损坏图片、渲染的模式单位数量正确(1/N 分段塌缩问题,#48/#52/#62)、在 DESIGN.json 推导出的 probeWidth 下内容宽度上限(#124,失败)以及该宽度下的逐区块换行检查(#13,警告需交叉核对)。对于原型中渲染为通栏的区块,请传入 --full-bleed <block,block>:#13 的反向情况——模板级 max-width 限制挤压通栏包装器——会作为警告打印,并附上测量宽度(第 3 步 § template escape)。交互驱动测试(#28)仍需手动编写。
本地 QA 范围边界(#101)——三次端到端运行表明,以下四项刻意不在本地通过测试框架验证,因为它们耗时且会造成虚假的信心:
- CLS:仅验证已部署 URL。 测试框架资源是本地的,加载即时——真实页面在本地测得 0.0007,线上则为 0.134(#100)。切勿根据测试框架判断 CLS。
content-diff(仅供参考):仅验证已部署 URL(步骤 10)。block-roundtrip通过后,它在本地按设计就检测不到任何内容(使用相同的分类器和 DOM);它唯一的价值是汇总线上页面的 DA 传输情况。提前在测试框架中运行它纯属浪费。(像素级visual-diff探测已弃用——见步骤 10。)- 逐页 chrome 覆盖项(
nav:/footer:元数据):仅验证已部署 URL——测试框架没有应用这些覆盖项的流程。 - 像素/视觉保真度(副本的源保真度门槛、页眉/页脚的
crop-compare区域、整页肉眼检查):仅验证已部署 URL(步骤 10)。 测试框架用于结构断言(qa-gate、block-roundtrip);其像素数值不能作为判断依据——先部署,再在预览源上评估。一次有记录的交付会话在测试框架中针对像素差异迭代 CSS,耗尽了全部预算,却没有交付任何页面。
在真实视口中截图并滚动,而不是使用一个巨大的窗口(#19)。 在巨大的截图窗口(例如 7800px)下,min-height:100vh 英雄区会变得与窗口一样高,其居中内容会被推得很靠下,从顶部裁剪区域中消失——看起来就像英雄区文字不见了。正确做法是使用 Playwright 在正常视口中(例如 1440×900)操作,并在每次截图前对每个区块调用 scrollIntoView()。
视觉肉眼检查应在已部署页面上进行,而不是测试框架中(#23、#105、端到端基准测试)。 本地测试框架仅用于自动化门禁(qa-gate、block-roundtrip);不要在这里对原型和测试框架做整页肉眼检查——测试框架提供了错误的 chrome(反向代理),也不会应用服务器端的区块元数据样式,因此无法代表真实情况,并且在整个基准测试中都造成了误导。关键的视觉比对是步骤 10 中对已部署页面的整页肉眼检查(桌面端 + 移动端),只有在此处才能发现测试框架的盲点(<picture>+<img> 媒体丢失 #72、多 CTA 未设置按钮、区块元数据布局、字体替换)。程序化检查仍会漏掉肉眼能发现的问题(页眉对齐、刻意添加的 <br>、标题字重、区块的背景/颜色)——因此要把这项肉眼检查留到已部署页面上进行。
驱动交互区块并断言状态变化(#28)。 对任何交互区块,都不要只停留在静态渲染——用 Playwright 驱动每个控件并断言结果:点击选择器/标签页 → 预期活动项/筛选计数发生变化;提交无效表单 → 预期出现错误文本;提交有效表单 → 断言可见状态已改变(例如余额从 $4,862.13 变成 $3,862.13,或出现确认信息)。也要对已部署的预览运行同样的操作——在测试框架中正常工作的区块 JS,在线上仍可能因 CSP 或缺少依赖而出错。
在推导出的宽度下进行宽屏检查(#13 → #124)。 如果上限宽度大于视口宽度,就无法看见它,因此 qa-gate.mjs --design DESIGN.json 会将视口调整为 DESIGN.json 推导出的 probeWidth(绝不能使用固定的 1600 或 1920——在上限宽度本身进行探测无法发现它),当内容宽度上限未能保持在 containerMaxWidth 的 ±20 px 范围内(或构建产物限制了流式源)时会失败,然后针对内容超出 --maxw 的区块逐一发出警告;逐项对照原型检查每条警告——只有当原型区块没有内部最大宽度包装器时,全幅延伸才是正确的。
步骤 10 — 在已部署 URL 上进行核对(content-diff 仅供参考 + 肉眼检查 + CLS)
部署后,只能在已部署的 URL 上将 EDS 页面与源原型进行核对(#78、#101)。每页原子化交付契约中已经运行了关键的自动化部署门禁:计算样式保护检查(网格计算结果为 grid、区块已装饰、损坏图片数为 0、页面错误数为 0——可捕获网格因作用域坍缩而静默变成块级布局的问题),以及 .plain.html 验证(一个 <h1>、0 个 about:error、作者编写的图片/alt 计数)。第 10 步会在此基础上增加结构摘要、人工目视检查、CLS 探测、Chrome 截图裁切门禁,以及内容上限行。
QA 范围说明(端到端基准测试,5 个页面)。 在整个基准测试中,实际缺陷由以下手段捕获:
block-roundtrip(A6,部署前,区块内内容)、计算样式保护检查 +.plain.html(网格/section-layout + 图片落位)、部署后的目视检查(测试框架盲区),以及部署后的 CLS 探测。content-diff没有捕获任何独有问题(其逐节点 🔴 判定系统性地误报了自动生成的 href 方案和排版),而像素级visual-diff探测已弃用(没有独有捕获;其标记是由延迟加载时序导致的伪影和需要单独排除的 object-fit 误报)。因此,content-diff现在是供参考的摘要,而非阻断性门禁;部署后的目视检查才是关键的视觉检查。
1. content-diff — 供参考的结构摘要(skills/diff/scripts/content-diff.mjs)。 从各个 <main> 中提取按顺序排列、按角色分类的清单({heading, eyebrow, cta+href, body}),再进行差异比较(根据计算样式和标签,因此原型的 .ds-* DOM 与 EDS 区块 DOM 可以对称比较)。先阅读 SUMMARY 行——按角色或 img 计数的较大差异,是内容区块丢失的快速信号。将其逐节点的 MISSING/ROLE SWAP 发现视为需要目视核实的参考线索,而非自动阻断项:block-roundtrip(#94,A6)已在部署前使用相同的分类器对区块内内容保真度进行门禁检查,因此第 10 步出现而 #94 未显示的 🔴,要么是实际的 DA 传输重塑(标签被剥除、未包裹的 <p> #79、被扁平化的行 #50/#62——应修复),要么属于已知误报类型(自动生成的目录/锚点 href 方案;逐字内容与作者编写内容之间的排版差异——现在已通过分类器中的撇号/引号/省略号/破折号规范化基本消除)。采取行动前,先通过目视检查确认是哪一种;无论如何,该脚本都以 0 退出(仅供参考)。
2. 部署后全页目视检查,桌面端 + 移动端(#23/#105)——关键的视觉检查。 在两个视口中打开已部署页面(不是测试框架——测试框架提供的 Chrome 外观不正确,也不会应用服务器端的 section-metadata 样式),并与原型进行比较。这是唯一能可靠捕获基准测试所揭示的测试框架盲区的步骤:<picture> + <img> 媒体内容丢失(#72——裸 <img> 测试框架无法发现)、未转换为按钮的多 CTA 段落(测试框架中不会运行 decorateButtons)、服务器端 section-metadata 布局,以及字体替换效果。仅在探测/门禁发现问题时(roundtrip 🟡、qa-gate 警告、fingerprint 分组),或针对定制/电影感区块,才截取分区截图。
3. 在已部署 URL 上进行 CLS 探测(#100/#101)。 运行 CLS 探测,并延迟 woff2/nav 请求,以复现慢速网络下的字体替换;目标值 < 0.1。此项按设计只能在部署后进行(测试框架的 CLS 数值毫无意义——本地资源会立即加载)。这是唯一能捕获字体回退度量偏移的步骤(根据测得的渲染宽度比计算回退字体的 size-adjust,不能只依赖 xAvgCharWidth——某基准页面曾因错误数值上线而产生 0.60 的 CLS),也能捕获 Chrome 外观的延迟偏移。
**4. Chrome 裁剪门禁——页眉 + 页脚带,差异率 ≥98%(#115)。**整页条会稀释 Chrome 的影响,而 Chrome 会在每个页面重复出现(../replica/reference/source-fidelity-gate.md § Pass bar)。在相同的拼接截图上,分别裁剪参考页面与已部署页面(在 replica/migration 流程中,参考侧是实时原始页面;在 redesign 流程中则是原型)的页眉带(y 0..nav-height)和页脚带(底部 footer-height 行),并确保每个区域的差异率都 ≤2%:node skills/replica/scripts/crop-compare.mjs live.png deployed.png --y 0 --height <nav-h>(然后用 --y <docH-footerH> 裁剪页脚带;如果文档高度相差几 px,使用 --y-b 指定每侧的偏移量,否则 ±1px 的差异会导致页脚裁剪区域出现整带误报)。每个模板至少运行一次——Chrome 是共享的,因此任何一个带未通过,都会影响所有页面的交付。先通过样式诊断,再迭代像素:node skills/replica/scripts/chrome-parity.mjs <reference> <deployed> --region header=header --region footer=footer会配对 Chrome 的文本元素,并仅输出计算样式、矩形、按钮框和图标的差异——先清除这些差异,再让裁剪门禁确认。这些带反映的是静止状态下的页眉;页眉的行为则由页眉契约规定(../diff/SKILL.md § The header contract):在 1440 和 390 宽度下探索已部署页面的页眉,并使用 chrome-compare 将其与契约进行比较;退出码 2 会像裁剪带失败一样阻止基础架构通过。
**5. 已部署 URL 上的内容宽度上限行(#116 → #124)。**两个门禁宽度都比大多数内容宽度上限窄,因此只有在更宽的屏幕上,丢失的容器宽度上限或解析后的 px 提升才会产生偏差。每个模板运行一次 node skills/replica/scripts/cap-probe.mjs <live> --against <deployed-url> --design DESIGN.json --slug <slug> --label deployed:必须 PASS(按类型,所有实时页面的宽度上限都控制在 ±20 px 以内,且构建版本上没有单独设置宽度上限的内容)。出现 ✗ 表示需要修复脚手架或区块包装层上的尺寸规则(步骤 3),然后按照 replica/reference/recreation-procedure.md § Lift the sizing MODEL 重新提升——绝不要微调像素。
6. 几何修复验证规范(#117)。有一次“奇偶一致性已验证”的说法同时在三个方面都错了;验证任何几何修复时:(a) 探测承载规则的元素,而不是启发式匹配对象(“宽度超过 400px 的白色栏”碰巧选中了另一个框,而非承载已提升规则的框)——在构建版本上配对修复规则所针对的元素与实时页面上被提升了源规则的元素;(b) 确保在无缓存情况下提供页面——审阅者的 DevTools 显示的是旧行号上的旧规则,尽管两个主机都已提供修复后的版本;通过带外方式检查:curl --compressed <css-url> | grep '<new-rule>'(区块 CSS 使用 gzip 编码——直接运行 curl | grep 会扫描二进制内容,并静默地匹配不到任何内容),并在全新的无头上下文中重新渲染,同时提醒人工审阅者在重新评判前执行强制刷新;(c) 审阅者的截图会记录其缩放比例——根据任何带有已知百分比规则的元素,反推其 CSS 视口(某个卡片在 width: 50% 下宽 851px → 视口为 1702px),并在无头环境下复现该视口;仅按截图表面数值评判时,他们的数字会与正确修复相矛盾;在实时页面和构建版本上复现 1702 视口后,两者达到了像素级一致。
**7. 交付时的全页面已发布源站门禁(#125)。**步骤 10 针对单个页面;页面名单部署后,在项目根目录通过 run-bg.mjs 运行 node stardust/scripts/replica/gate-all.mjs。DELIVERED = 像素差异率 ≤ 10 且 |Δh| ≤ 5 %,并且裁切文本 / 控件数量为 0、缺失 / 隐藏链接 / 标题数量为 0——曾有一个记录为通过像素门槛的页面,其所有卡片都被裁切,链接也被固定高度的 overflow: hidden 区块隐藏。clip.json 中的分组会标明裁切来源(修复该区块的 CSS);content.json 中的 HIDDEN 表示 CSS 问题,MISSING 表示编码器问题。规则参见:../replica/reference/source-fidelity-gate.md § The all-pages published-origin gate。
已弃用的 visual-diff 类别已由其他机制覆盖:拉伸图片由 img { height:auto } 重置规则(#36)和人工目视检查覆盖;被移除的最大宽度换行和外层上限由 qa-gate 宽屏检查(#13/#124)覆盖;空白或损坏的渲染由计算样式守卫覆盖;图片缺失由 .plain.html 图片/替代文本计数(#75)和人工目视检查覆盖。
content-diff 不依赖技术栈,可通过 skills/deploy/scripts/diff-profiles.mjs 使用(--profile eds | generic);它与 skills/deploy/scripts/content-inventory.mjs 中的 block-roundtrip/section-schema 共用角色分类器。
# Prereq: a RENDERABLE prototype. Static → serve from its own dir so relative
# ../assets resolve. JSX → pre-render first (#24/#27). ONE server, ONE port —
# probe before starting one (curl is always present, lsof is not):
curl -sI localhost:8791/ | head -1 # 200/404 = something serves the port; no line = free
curl -sI localhost:8791/<prototype>.html | head -1 # 200 = it serves YOUR dir: reuse it
command -v lsof >/dev/null && lsof -nP -iTCP:8791 -sTCP:LISTEN # optional: names the pid
( cd <prototype-dir> && python3 -m http.server 8791 & ) # only when nothing answered
# Answers but not your file → a foreign server: never kill a listener you did not
# start; prefer a per-project port — a stale server from another project makes the
# diff below silently measure a foreign prototype. `lsof … || echo free` is not a
# probe — without lsof it prints "free" beside a live listener (recorded: a second
# server on the same port died at once and the round chased 404s).
# Structural content + typography diff — ADVISORY summary. Use the DEPLOYED EDS URL
# so blocks are decorated; a raw content .plain.html has no roles to classify.
node skills/diff/scripts/content-diff.mjs \
"http://localhost:8791/<prototype>.html" \
"https://<branch>--<repo>--<owner>.aem.page/<path>" \
--profile eds # --json to dump both inventories; exits 0 (advisory)
content-diff 与 block-roundtrip/section-schema 共用角色分类器和配置档案
(skills/deploy/scripts/content-inventory.mjs、diff-profiles.mjs);非 EDS 构建使用 --profile generic。
还要运行独立的固定资源 URL grep(#44)——它不依赖任何探测:
grep -rn "http://localhost\|aem\.page/img\|aem\.live/img" blocks/ 的结果必须为空。注入固定图片(徽标/图标/水印)的 Block JS/CSS 必须使用根相对路径 /img/... 引用资源;绝对源地址在本地 QA 中可以通过(开发服务器为 localhost:3000),但在所有真实环境中都会返回 404。
如何解读 content-diff(仅供参考的线索,#78):
- 先看摘要行——两侧各角色和
img的计数;差异较大是快速发现区段丢失的信号。这是基准测试中探测结果最可靠的部分。 - **
MISSING CTA/HEADING/EYEBROW/ROLE SWAP:**仅供参考的线索,不会自动阻止部署。如果block-roundtrip(#94)在部署前为绿色,那么这里出现的新发现要么是 DA 传输导致的结构变形(分段丢失 #76、未包装的<p>#79、扁平化的行 #50/#62——这是实际问题,应修复解码),要么是已知的误报(自动生成的 href 方案;残留的排版问题)。在更改代码前,先目视确认已部署页面。 - **
MISSING BODY/EXTRA(🟡):**正文文案丢失,或 EDS 文案没有原型来源——通常是原型中的占位内容被合理改写。请确认这是预期情况。 - **
FONT FORK(🟠):**匹配行的渲染字体 FACE 不同(宽度探测)。proto X→sys表示原型指定了 X,但回退到了系统字体;EDS 自托管预期的回退字体是正确做法(#77)。短字符串的字体分叉(单词标题、数字)属于宽度探测噪声——采取行动前,请对照已部署页面目视确认。
确认已部署页面的目视效果忠实还原原型,且 CLS 探测值 < 0.1;content-diff 摘要、原子契约计算样式守卫和 .plain.html 是自动化的兜底检查。标记列表也可用作回归检查清单——发现新的静默回归时,值得同时添加修复和门禁信号。
步骤 10 是转换过程中针对每个页面与原型进行核对——不是全站检查。 若要在 rollout 后全面检查已部署网站与其提取快照和视觉基准的一致性(路由、内容保真度、模板符合性、渲染完整性、元数据/SEO、链接、无障碍性、性能预算),请使用只读的 the stardust qa skill。两者相辅相成:步骤 10 询问“转换后的页面是否与原型一致?”,the stardust qa skill 询问“网站上发布的所有内容是否都正确?”——它们的参照对象、范围和阶段都不同,也不共用代码。
反模式(付出惨痛代价后总结的教训)
这些做法看起来合理,但会让你不得不彻底重来。
1. 将原型区段抽象为“带变体的 Block”。
构建一个带有五种类变体处理方式(dark / light / image / full-bleed / with-wave)的 hero Block,看起来符合 DRY 原则。实际上,这些变体在标记或 CSS 上没有足够的共性,无法精简;JS 会分叉出太多路径;CSS 也会变得脆弱。每个不同的原型区段都构建一个 Block。仅在区段逐字节完全相同时才复用。
1b. 合并 data-ground/表面不同的两个原型色带(#58)。
电影感/编辑风格的原型通常会交替使用不同的地面色带——浅色的 data-ground="dust" 引言(深色标题)后接深色的 data-ground="ink" 场景(浅色标题)。将它们合并为一个在单一地面色上渲染的区块,会悄无声息地反转被合并色带的呈现效果(其标题会在深色↔浅色之间翻转,设计节奏也随之消失)——Lint 检查会通过,内容也都在,只有肉眼检查或 SURFACE/GROUND MISMATCH 探测器标记(#59)才能发现。如果一个区块必须跨越多个地面色,就要在其中将每个地面色都还原为独立的全出血子色带(一个 .loop-head 浅色子色带 + 深色场景),绝不能合并成一个。审核提示:合并前记录每个区段的 data-ground;跨地面色合并必须保留两者。
2. 与区块变体对应的区段元数据样式类。
将 .section.dark、.section.prose-2col、.section.eyebrow 等定义为区块区段的样式系统,会形成一条与区块 CSS 重叠的路径——作者会不知道该在区段还是区块上设置 dark。划分依据是内容类型,而非偏好:由区块负责的区段完全由各区块自己的 CSS 渲染(变体使用区块自身的类);默认内容区段(D1)使用步骤 3 中少量且封闭的 style 词汇表——之所以需要这套词汇表,仅仅是因为 prose 区段没有区块来渲染它。一个区段上绝不能同时使用两种方式。
3. 共享实用工具模块(波浪、动画基元)。 所有区块都导入同一个波浪 SVG,看起来似乎可以复用。但每个原型区段使用波浪的方式都不相同(尺寸、颜色、动画各异)。从代码表面上看,将 SVG 内联到所属区块会增加代码,但这样可以消除耦合,并让每个区块都自成一体。
4. 在区块 JS 中手动创建按钮锚点。
类似 cta.className = 'btn-loud'; cta.innerHTML = '<span>…</span>' + ARROW_SVG; 的代码重复实现了 EDS 按钮装饰器的工作,会与其应用类的顺序冲突,还会让区块 JS 依赖特定的按钮类。**移动 CTA 的段落(a.closest('p'))——decorateButtons() 已在区块运行前为锚点添加了类,而段落携带工作区的编辑器索引(EW3)。**只有在样式确实不同(尺寸、悬停变体)时,区块 CSS 才覆盖全局按钮样式。
5. 对页面框架内容进行重构式解析。
通过启发式规则重新分类任意作者内容的页面框架区块(“第一个列表 = 导航,第二个链接 = CTA,其余内容自行猜测”)既脆弱又有损——作者只改一处,页眉就会乱套。页面框架采用模板插槽(步骤 6):区块包含原型的页面框架 DOM,并根据 /nav//footer 文档的固定区段约定(品牌/链接/工具)填充固定的角色插槽。如果你发现自己在为页眉/页脚编写开放式解析启发式规则,请停下来,明确并固定文档约定。
6. 不检查 EDS 的区段 DOM,而是靠猜。
原生结构是 <div class="section <name>-container"><div class="default-content-wrapper">…</div><div class="<name>-wrapper"><div class="<name> block">…</div></div></div>,但克隆项目的结构可能不同。设计依赖包装结构的 CSS 前,先在浏览器中检查渲染后的页面,并将结构记录在 runtime-contract.json 中。
7. 审计做得过于深入。 对 22 种模式进行审计会催生抽象。你只需要一份逐页区段清单。只有在发现两个逐字节完全相同的区段时,模式复用才会自然显现。
8. 尚未敲定决策就开始构建。 命名和复用决策看似不起眼,却会影响每个区块和内容页面。**一开始就向用户列出 3–5 个命名问题。**在编写任何区块代码之前,先以书面形式敲定答案。
9. 使用通用的占位图片路径。
除非上传了对应图片,否则 /img/case-studies/foo.jpg 会返回 404。使用原型托管主机的 URL,这样从第一天起,你编写的内容就能在 EDS 预览中正确渲染。
9b. 将绝对源站 URL 写入代码块 — JS 字符串字面量和 CSS background-image: url(...)(#44、#67)。
代码块引用的任何固定品牌素材 — JS 字符串字面量(徽标/图标/水印/后备资源)或 CSS background-image: url("…")(铺满整版的区域背景)— 都必须使用根相对路径 /img/<brand>/x.png,绝不能使用绝对源站 URL(http://localhost:3000/img/...、分支 --…aem.page/img/... 主机)。绝对源站 URL 能通过本地 QA(开发服务器就是 localhost:3000,所以资源能加载),但在所有真实环境中都会返回 404。**这直接覆盖反模式 #9/步骤 9“完整限定主机 URL”规则对固定区块素材的要求(#67):**该规则仅适用于上传的/Media-Bus 素材的作者内容 <img src> — 区块 CSS/JS 中的固定图片(区域背景、水印、CSS 后备资源)始终使用根相对路径。(电影感原型大量使用 CSS 背景,因此这种情况很常见。)部署前检查:grep -rn "http://localhost\|aem\.page/img\|aem\.live/img" blocks/ 必须没有匹配项(也会扫描 CSS)。
10. 为字体改动 head.html(包括预加载)。
Google Fonts <link> 标签、Adobe Fonts 脚本标签、任何由 CDN 托管的样式表,以及 <link rel="preload" as="font"> 行,都不应放在 head.html 中。前三者会增加 DNS/握手往返并造成外部依赖;预加载看似有用,实际并非如此 — 与指标匹配的 -fallback 模式(原则 3)让预加载对 CLS 毫无影响,而且添加预加载会让字体发现分散到两个文件中。品牌 @font-face 应声明在 styles/fonts.css 中,-fallback 字体应声明在 styles/styles.css 中 — 仅限这两个位置。专有品牌字体也应自行托管(以确保还原度),并发出许可提醒(#80)— 只有确实无法获取字体文件时才保留 CDN 加载,并记录 CDN 依赖和 CLS 方面的权衡。
11. 跳过与指标匹配的后备 @font-face。
如果系统字体后备字体没有 size-adjust + ascent-override + descent-override,那么 woff2 加载完成后,从系统字体切换到品牌字体时,页面上每一行文字都会发生位移。对于可变品牌字体,从匹配的 @fontsource-variable/<name> 软件包中获取校准值;对于非可变品牌字体(仅有静态字重,没有已发布的 Fallback 字体),使用 fonttools 从 woff2 计算校准值(步骤 4,#11)。在 styles/styles.css 中将其声明为 <brand>-fallback 字体(沿用标准的 roboto-fallback 约定),并在所有使用该品牌字体系列的字体栈中将其放在第二位。
12. 过度套用按钮约定。
并非所有链接都是按钮。整卡平铺锚点、tel:/mailto: 联系方式值以及经过样式处理的文本链接(例如带波长下划线的“我们的工作方式 →”)都不是按钮。作者应将这些保留为普通 <a>;由各区块的 CSS 为其设置样式。此约定适用于有可点击边界的圆角标签;如果不是这种情况,就不要套用。
13. 移除原型的最大宽度容器。
原型会将区块内容包裹在居中的最大宽度容器(.wrap / .container)中,同时让区块背景铺满整个宽度。如果你的区块直接将内容追加到区块根元素,内容在宽视口下就会延伸到两侧边缘。请重建容器(block.replaceChildren(wrap),或为内容设置 CSS max-width: var(--maxw); margin: 0 auto; padding: 0 24px)。这是最容易漏掉的错误,因为在 ≤1440px 时看不出来——请在宽视口下进行 QA(参见 Local QA)。并行开发区块的 Agent 尤其容易犯这个错误:请在每份任务说明中明确写出这条规则。**纯背景区块(#37)最容易踩坑:**Agent 通常会在全宽的色带区块中保留容器(有色背景能让边缘问题一目了然),但会在背景与页面背景相同的区块中将其省略——在这类区块中,缺少约束的情况只有经过测量才能发现。每个区块都必须将内容限制在 --maxw 范围内;唯一保持全宽的是区块背景(例如,保留内层网格中的容器,这样 hero 的渐变背景仍能铺满视口)。**而且,容器必须设置样式,不能只输出标记(#74):**如果区块的 JS 写入了 class="wrap",但其 CSS 没有定义 .{block} .wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 24px },内容就会同样贴着左侧显示——标记看起来没问题,只是缺少了对应规则(只有在没有内层卡片自带内边距时才会显现)。外层宽度上限是区块无法提供的脚手架规则(#124)——使用 cap-probe.mjs <live> --against <build> 测量。门槛:对于 JS 输出 .wrap / 容器类名的每个区块,都要在其 CSS 中搜索对应规则。
14. 忘记 <image-slot> 占位符没有真实素材。
Claude-design 原型使用 <image-slot> 作为投放目标,而不是带有真实 src 的 <img>。不要硬编码原型图片 URL(会返回 404),也不要发布无法显示的 <img>。应将图片视为可选单元格,并为区块提供 CSS 背景回退方案,这样空状态下仍能正常显示。
15.(已弃用。) 仅适用于旧版 AuthorKit 运行时(页脚重复加载);vanilla EDS 只有一条 chrome 加载路径。保留该编号,以便交叉引用保持稳定。
16. 移除 JS 切换的 opacity:0 显示效果。
原型通常会使用 .reveal { opacity:0 } 隐藏区块,再通过内联 <script> 中的 IntersectionObserver 显示它们。该脚本在 EDS 中不会运行,因此移植后的 opacity:0 会让内容永久不可见——而且在原型中看起来一切正常,所以很容易漏掉。请直接渲染可见内容;移除显示效果(或在所属区块的 decorate() 中重新实现——区块 JS 会运行)。根本原因:转换后原型 <script> 不会执行(D15——内容中不能包含代码)。
17. 不要从区块 JS 注入 <main> 元素。
自行构建布局/视图包装器的区块(交互式区块常见,例如切换视图的区块 #33)不得使用 <main>。页面已经有一个 <main>——再添加一个会构成无效 HTML,并产生重复的地标;任何执行 document.querySelector('main') 的运行时代码或探针代码(运行时自身的 loadSections、差异探针的内容清单提取)都可能绑定到错误的元素,从而静默跳过或重复处理区块。作用于 main .section / main > .section 的结构 CSS 也可能开始匹配原本不该匹配的注入 DOM。请使用 <section>(或让注入节点保持在区块元素内部)。不要将原型中的 <main> 包装器原样移植到区块注入的 DOM 中。
18. 值插槽:将编写的文本复制到模板节点中。
slot.textContent = cell.textContent、h.innerHTML = heading.innerHTML、`<h2 class="t">${text(cell)}</h2>` ——真实网站上每个通过模板插槽插入内容并重建的区块都会这样做,因为旧脚手架就是这么教的。发布后的页面看起来完美,但在 Experience Workspace 中完全无法编辑(携带 data-prose-index 的编写元素已被丢弃),而且没有任何保真度门禁能检测到这一问题——角色往返、内容差异和像素差异检查全都通过。最终是客户在画布中点击文本时才发现的。改用节点插槽:模板中设置空插槽容器,将编写元素移入其中(步骤 2b、EW1)。现在,--ew 门禁会在循环中将其拦截。
19. 编写元素上的类(h3.headline、ul.items、a.link-download、a.button)。
元素可编辑,但作者一点击,外观就会崩塌:编辑器会用相同的标签重新渲染,但不带任何类,并在其上方添加两个包装 div。将类放在包装元素上;通过包装元素的后代选择器(.headline :is(h2, h3))、元素选择器(.icon-list ul)或属性选择器(a[href*=".pdf"])为编写元素设置样式;在 .prosemirror-editor 下,根据按钮的 <strong>/<em> 标记重新绘制按钮(步骤 3、EW2/EW3)。--simulate-editor 探针会测量偏差。
检查清单(每个页面)
- 原型
<main>中的每个区段都在内容页面中有对应区段——模式区段对应一个块,散文区段对应默认内容(D1,转换日志中记录了 Step 2 分诊)。 -
davids-model-lint检查通过——运行node skills/deploy/scripts/davids-model-lint.mjs content/,退出码为 0(0 个 🔴;已审阅 🟡 提示,并在转换日志中修复或说明理由)。 - 内容页面是一个 body 片段,用于 Source-API 部署:从
<body>开始,不含<!DOCTYPE>/<html>/<head>——EDS 会在交付时注入项目的head.html。(只有挂载部署允许使用完整文档。) - 在写入 DA 之前,对内容运行了
node skills/deploy/scripts/sanitise.js(非 ASCII 字符 → 实体)。 - 已为每个实例标记变体指纹(#90)——在编写块代码之前,对原型运行了
style-fingerprint.mjs;每组中具有多个样式/结构簇的情况(活动状态芯片、强调 CTA、有图与无图卡片)都由对应块复现,而非扁平化处理。 - Token 完整性检查通过(#91)——
blocks/**/*.css中引用的每个var(--x)都在styles.css的:root中定义(comm -23grep 不输出任何内容);没有因变量未定义而丢失的背景色/颜色。 - 已审阅
content-diff摘要(提示项),涵盖每个模板的首页——已核对各角色/图片数量差异;对于每个节点的 🔴,已由部署后人工检查确认是 DA 传输导致的真实结构变化,还是已知误报(块内部保真度已由部署前的block-roundtrip把关)。 - 已生成并使用区段架构(#93)——
stardust/eds-schema/<page>.json存在;编写的行遵循其顺序/单位;每个块的 JSDoc 都引用其区段架构;有意舍弃的内容已记录在转换日志中。 - 每个块都在部署前通过了
block-roundtrip(#94)——针对原型区段逐块运行,退出码为 0(0 个结构性 🔴);DA 推送前还运行了一次整页检查(不带--blocks),结果通过。 - 每个块的 EW 检查(EW1–EW10)均以 0 退出——
block-roundtrip --ew(默认)/ew-editability-probe.mjs:除已声明的@ew-exempt标签外,dead = 0、duplicated = 0;每项豁免都属于三个已声明类别之一(metadata / derived / index-driven),并写在块的 JSDoc 中,绝不隐含。 - 编辑模式模拟通过——
ew-editability-probe.mjs --simulate-editor:任何文本都没有字体/颜色/行高偏移;块高度 Δ ≤ 2 px;基础框架包含编辑模式 CTA 重绘、卡片作为链接时的内部锚点规则,以及每种带样式文本链接工具类的:where()包装器变体(Step 3)。 - 已静态审查块的 JS/CSS(EW1/EW2/EW4)——没有使用
textContent =/innerHTML =/${…}插值从已编写内容的元素生成显示文本;没有对索引元素执行cloneNode却未使用stripInstrumentation();包装器类与已编写内容的元素之间没有>/:first-child/:nth-child;没有给已编写内容的h*/p/ul/a添加类(类应放在包装器上);没有使用<button>/<summary>承载已编写的文本(EW7)。 - AI 可读性(#100)——已发布页面的
ai-readability.mjs代码评分 ≥ 98;没有块生成可见文本;轮播克隆不带文本;索引驱动列表使用已编写的行(reference/ai-readability.md)。 - 可编辑性转换前后的像素一致性——在 1440 分辨率下,对每个块实例进行元素截图,隐藏
body > header并排除实时小组件 iframe:每个实例的pixelmatch= 0(绝不以牺牲保真度换取可编辑性)。 - 为每个区段选定解码层级(#95)——专属固定构图区段使用模板插槽(原型 DOM 原样保留 + 角色插槽);只有可重复/可编写的区段才进行重构;层级记录在转换日志中。
-
<header></header>和<footer></footer>为空(header/footer块会自动加载/nav和/footer);content/nav.html+content/footer.html存在,符合区段规范(品牌 / 链接 / 工具),并列入发布清单。 - 页面以
metadata块开头(#34):真实标题(≤60 个字符,取自<h1>,绝不能使用块名)+ 描述(约 155 个字符)。需要时,将nav/footer路径覆盖项 /Robots行放在同一个块中。 - 每页恰有一个
<h1>(#35):主视觉/导语标题使用<h1>;区段标题使用<h2>/<h3>;没有标题被保留为裸<div>(交互块也不例外——导语标题在服务器可见标记中是<h1>)。 - 编辑内容中的图片可供编写——上传到 DA
/media,并以带alt的content.da.live<img>形式编写(不得使用相对于仓库的/img/,也不得按索引写入块 JS)。主视觉/特色/CTA 横幅的背景图片也属于编辑内容。装饰性的无图处理(渐变/遮罩/柔化效果)和固定品牌资源继续使用 CSS 背景。验证:交付的.plain.html包含预期数量的<img>+alt(其中不包含 CSS 背景图片)。<image-slot>占位符 → CSS 背景回退方案为空单元格。 - ENCODE 结构(参见 ENCODE 规范): 分组横幅 = 每项一行;FAQ/手风琴 = 标题单元格 + 问答行;子字段由前置的
<strong>/<code>标签承载,不得发明::/|分隔符;强调使用<em>,而非<span class="em">;内容中不得使用<sup>/用于布局的<br>;任何位置都不得使用<hr>(#119——它是区段分隔符,会拆分区段)。 - 区段标题是默认内容,不是块行(D1)——重复块上方的眉题/标题/导语作为区段默认内容编写;通过
.<name>-container .default-content-wrapper就地设置样式,或者(仅当它必须位于块布局内部时)通过匹配.default-content-wrapper的block.parentElement.previousElementSibling重新纳入;重新纳入时装饰后的 DOM 必须逐字节相同(验证:剩余包装器为 0,装饰后的 outerHTML 未变)。 - 相同模式的区段共用一个块 + 变体类(
cards/text/…);只有真正独特的区段才使用专属块(参见“One rule”)。 - 标题大纲没有层级跳跃(
h2 → h3,绝不h2 → h4)。 - 每个块都复现了原型的最大宽度容器——包括纯背景区段(#37)——并通过了内容上限行检查(#124):
cap-probe.mjs <live> --against <deployed> --design DESIGN.json输出 PASS。 - Chrome 裁切检查通过(#115)——在已部署 URL 上,页眉和页脚带的差异均 ≤2%(
crop-compare.mjs);每个模板至少检查一次。 - 全局
img重置为display: block; max-width: 100%; height: auto;(#36)——EDS 会添加 width/height 属性;若缺少height: auto,图片会纵向拉伸。 - 当页眉以正常文档流形式位于首个区段上方时,应根据实际 chrome 高度响应式设置
--nav-height(#81)——页眉块延迟加载不得将主视觉向下推移(在已部署预览上使用延迟获取探针验证 CLS;目标 < 0.1)。 - 主视觉 LCP 图片使用
loading="eager"+fetchpriority="high"(在decorate()中设置——空的 metadata 首区段会导致运行时的waitForFirstImage失效,#100),其媒体插槽通过 CSS 预留空间(min-height/aspect-ratio);CLS 探针仅针对已部署预览运行(#101——测试框架上的 CLS 数值毫无意义)。 - 已针对测试框架运行
qa-gate.mjs(提示性冒烟测试)——使用标准脚本 + 页面的 eds-schema,不得自制探针(#101)。它在部署前的独特价值是检查架构单位计数和单个<h1>;装饰/图片损坏/网格检查已由已部署的计算样式防护取代(测试框架上的 qa-gate 因 chrome 为空或 DA 图片需要授权而失败,属于已知的测试框架限制,不是缺陷)。提示性的content-diff仅针对已部署 URL 运行。 - 任何依赖块单元格内
<span>/类的样式都在decorate()中重建(#39)——EDS 会剥离块单元格中的<span>(例如,在 JS 中包装.stars内容,不要直接编写它)。 - 每个块都按单元格对纯文本字段进行分类(通过
cell.textContent作出判断,#79),但通过移动单元格的元素来显示它们(EW1)——运行时的wrapTextNodes(#104)会在decorate()之前将纯文本单元格重新包装进<p>,工作区也始终会交付<p>,因此这两种环境中都有可移动的元素;仅读取p而跳过单元格的做法仍会导致旧版运行时丢失内容。请对装饰后的线上/预览渲染结果进行验证,并断言每个文本字段都存在且可编辑(仅核对数量无法发现问题)。 - 内容页面中没有
<style>或<script>标签,任何单元格中都没有作为文本显示的代码(D15)。 - Section-metadata 块只出现在默认内容区段中,
style值来自 Step 3 的封闭集合(绝不出现在块区段中——反模式 2)。 - 结尾 CTA 复用共享的
closing块。 - 内部链接已本地化——在内容树上运行
localize-links.mjs --source-host <live-host> --check,退出码为 0:没有指向源站主机且路径在本地存在的<a href>,没有.html/尾部斜杠形式的内部 href;列出的保留绝对链接确实指向外部,或尚未迁移。每次迁移一批内容后,都要重新运行普通模式。 - CTA 链接使用段落包裹和强调包裹:
<strong>(主要)、<em>(次要)、<em><strong>(强调,酌情使用——D6)。文本链接和卡片平铺链接使用普通<a>。 - 块 JS 导出
default async function decorate(block),并带有描述行结构、注明任何按钮约定的 JSDoc。 - 块 CSS 限定在
.block-name下。 - 块 CSS 不会重新定义全局 token。
- 块 CSS 不会重新定义全局按钮类(仅允许尺寸或悬停变体等合理覆盖)。
- SVG 标记内联在块 JS 中(不使用共享 waves 工具)。
- 块 JS 不会用自定义类生成按钮锚点。
- 所有动画都遵循
prefers-reduced-motion: reduce。 - 没有从原型中移植由 JS 切换的
opacity:0显示效果——内容始终可见(原型的滚动显示脚本在 EDS 中不会运行)。 - 块名称不得使用 EDS 保留类名(
section、block、wrap、button,或以-wrapper/-container结尾的名称)。 -
head.html除单独一行网站图标<link rel="icon">外未作改动(Step 3 § Favicon)。未添加任何字体<link>、<script>、<style>或<link rel="preload" as="font">行。品牌@font-face位于styles/fonts.css;-fallback字体位于styles/styles.css。品牌 woff2 字体文件位于fonts/。 - 若提取时捕获了网站图标,仓库根目录中的
favicon.<ext>或沙盒运行时的_eds/code/favicon.<ext>已随站点发布;若未捕获任何图标,部署日志须明确记录 WARN(很可能是提取范围受限——应找回图标,而不是默默使用默认图标)。 - 所有具名品牌字体都由站点自行托管——包括专有字体(#80);原型中的专有
.otf/.ttf已用 fontTools 转换为 woff2。如果发布了任何专有字体,三个位置都必须包含许可警示(styles.css 横幅 +fonts/LICENSING.md+ 转换日志),且交接消息需标明“aem.live部署前必须获得许可”。 - 窄体/压缩显示字体应回退到窄体字体,而非普通 Arial(#80):
"<Brand>", "Arial Narrow", arial, …(或自行托管的免费窄体替代字体)。宽度类别属于分类依据的一部分。 - 每个品牌字体族的字体栈都将度量匹配的
-fallback字体列为第二个("<Brand>", "<brand>-fallback", sans-serif)——首屏绘制时先使用回退字体,直到loadFonts()加载fonts.css。 - 每个
<brand>-fallback@font-face(位于styles.css,并根据分类使用src: local("Arial")/local("Times New Roman"))都声明size-adjust/ascent-override/descent-override。对于可变字体品牌,从@fontsource-variable/<name>获取校准值;对于非可变字体品牌,使用 fonttools 从 woff2 计算校准值。结果:字体切换时 CLS 为零。 - 不得在各块中声明
font-family: var(--body-font-family)。品牌字体通过继承从body传递。各块只能通过:roottoken 显式设置显示字体/等宽字体/衬线字体族。 - 标准
styles.css中的body { display: none }/body.appear门控和header { height: var(--nav-height) }占位 必须保持不变(基础框架重新设计品牌样式时绝不能移除)。
完成后
更新 stardust/eds-conversion-log.md(或创建该文件),记录:最终区块清单、已确定的决策、本次运行中避免的反模式,以及下一位操作者需要了解的任何特定于网站的信息。该日志用于持续记录“为什么它会呈现成这样”。
参考资料
davids-model.md— David 的模型(aem.live)精要:15 条规则(D#N),每条规则都对应本技能中负责执行的契约或门禁,另附组件模型形状兼容性说明。da-deploy-protocol.md— 基于 curl 的 DA Source API 部署契约(身份验证、source PUT、预览/发布、先处理资源再预览的顺序)。- 本文档和部署/差异脚本中的
(#NN)引用指向部署改进记录(发现 #1–#80),该记录已从代码树中移除;使用git show 940b8795:plugins/stardust/notes/deploy-improvements-archive.md阅读。新的发现记入skills/stardust/reference/learnings.md。 scripts/ew-editability-probe.mjs— Experience Workspace 可编辑性门禁(步骤 8 § 契约):插桩 → 装饰 → 统计保留项;--simulate-editor编辑模式漂移;URL 和--content测试框架模式;读取@ew-exemptJSDoc 标签。- Experience Workspace 契约经验证所依据的源代码(如果机制似乎发生了变化,请阅读这些源代码):da.live
blocks/canvas/editor-utils/editor-utils.js(getInstrumentedHTML— 标记的内容)、blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js、blocks/shared/prose2aem.js(单元格保留其<p>);da-nxnx/public/plugins/quick-edit/quick-edit.js(setBody→loadPage→restoreBlockIndices)、src/prose.js(createEditor的替换形状)、src/images.js、src/dom-index.js、src/selection.js(基于textContent长度的光标计算)。