手写可以有随机感,语义不能靠随机数。

这不是一套把字体换成手写体的 CSS,而是一条从作者语法到语义 DOM 的编译链。

MDX Handwritten 给 MDX 增加了一组 手写批注语法:行内的下划线、圈画和箭头,块级的便签、边注与括号,以及能从结构化文本派生整组解释的 Annotation scene。

这些效果看起来很视觉化,真正困难的部分却不在「怎么画」。当页面要同时支持 SSR、React Server Components、无客户端 JavaScript、窄屏、打印、强制色和复制粘贴时,系统必须先回答几个更基础的问题:

  • 哪些文字是正文,哪些只是装饰?
  • 箭头指向的目标由作者、编译器还是浏览器决定?
  • 样式丢失以后,标签和关系是否还在?
  • 同一份 MDX 在不同机器上构建,结果是否一致?
  • 一段批注写错以后,应该继续猜,还是 让构建失败

本文沿着仓库当前发布的 0.2.0 实现回答这些问题,源码基线固定在 commit 8d615963 。文中的「已实现」以这个提交的源码和测试为准;ADR 或 spec 中尚未进入生产 renderer 的能力会单独说明。

问题不是画一条线,而是决定这条线属于谁

最直接的手写批注实现通常是:浏览器拿到 DOM,测量目标矩形,再用 Canvas 或绝对定位 SVG 画一条线。它很适合交互式白板,却会 把文档语义绑在一次具体 layout 上

  1. 服务端渲染时没有最终几何信息;
  2. 字体、容器宽度或语言变化会让坐标失效;
  3. Canvas、背景图和伪元素里的文字不一定进入阅读顺序;
  4. 打印、forced-colors 或 CSS 加载失败后,图形可能消失;
  5. 客户端若重新判断「箭头应该指谁」,构建产物就不再是唯一真相。

mdx-handwritten 选择了相反的顺序:先把作者意图编译成 真实文本、语义元素和稳定关系CSS 只增强,不负责补语义,再让 CSS 与装饰性 SVG 提供手写外观。

  • 有意义的文字进入真实 DOM
  • 目标和关系在编译阶段确定
  • renderer 不需要测量浏览器布局
  • 视觉失效时仍保留线性阅读顺序
四条底线

这里有两个容易混淆的概念:

  • Annotation gesture 是一次低层编辑动作,例如给现有文字加下划线、在旁边写标签、用括号归组。
  • Annotation scene 是一个完整的解释单元:它包含原始内容、若干目标、标签、目标之间的关系,以及如何表达这些关系的 gesture。

gesture一次局部编辑动作 的语言面刻意很小;scene一组可审查的语义关系 则把复杂度收进一个版本化的数据模型。

从一行 MDX 到页面:完整编译链

先看一行真实语法:

:hw-annotate[`CLI-042`]{
  label="stable ID"
  placement="block-start-inline-end"
  mark="bracket"
  arrow="straight"
  shift-inline="1"
}

mdx-handwritten 自己并不解析冒号语法。宿主先运行 remark-directive,把源码变成 mdast directive:

{
  type: 'textDirective',
  name: 'hw-annotate',
  attributes: {
    label: 'stable ID',
    placement: 'block-start-inline-end',
    mark: 'bracket',
    arrow: 'straight',
    'shift-inline': '1'
  },
  children: [{type: 'inlineCode', value: 'CLI-042'}]
}

随后 remarkMdxHandwritten 才接手。它不会立即生成 JSX,而是先完成 schema、嵌套、安全 URL、静态属性和数量限制检查,把通过校验的结果放进 per-file metadata;最后再根据 output 选择 lowering 目标。插件入口的顺序是明确的:validateTree → validateConfiguration → strict error gate → transformChildren → optional auto-import 源码

strict error gate 在 transform 之前:错误 directive 不会先生成 JSX,再把问题拖到运行时。

MDX source

  ├─ remark-directive
  │    └─ textDirective / leafDirective / containerDirective

  ├─ remarkMdxHandwritten
  │    ├─ validate kind / attributes / nesting / URL / limits
  │    ├─ fill defaults and compute stable visual variant
  │    └─ component / element / strip lowering

  ├─ MDX or Markdown renderer
  │    └─ React component tree or semantic HTML

  └─ @madinah/mdx-handwritten-theme
       └─ data-hw* selectors, font, color and decorative geometry

四个发布包沿着这条链分工:

输入输出不负责的事情
@madinah/mdx-handwritten-scenerecipe、source 或 reviewed candidate版本化 ScenePlanV1MDX、React、DOM、CSS、网络
@madinah/mdx-handwritten-remarkdirective mdastcomponent、element 或 strip AST浏览器布局与主题样式
@madinah/mdx-handwritten-reactHand* props 或 Scene plan可 SSR 的 React 元素hooks、hydration、DOM 测量
@madinah/mdx-handwritten-theme稳定的 data-hw* DOMCSS 外观目标识别与语义推导

仓库的 workspace 和构建顺序也反映了这条依赖方向:scene 先构建,remark 与 React 分别依赖它,theme 保持为纯 CSS 入口。 源码

scene 定义意义,remark 选择 AST,theme 只负责画。

本站没有为 MDX 注入 React component map,因此直接采用语义 HTML:

remarkPlugins: [
  remarkFrontmatter,
  remarkGfm,
  remarkDirective,
  [remarkMdxHandwritten, {
    output: 'element',
    diagnostics: 'strict'
  }]
]

这篇文章里的 hw-* 不是截图或手写的 HTML。你看到的每个批注都经过了这条 element + strict 管线。

八种 gesture:小而封闭的作者语言

低层语言固定为八个 directive,hw-scene 是它们之上的 recipe 容器,不算第九个 gesture

名称directive 形态核心语义
hw-text:hw-text[...]给一段文字增加手写编辑语气
hw-link:hw-link[...]保持原生链接语义的手绘行动点
hw-mark:hw-mark[...]给现有文字应用闭集 Mark treatment
hw-annotate:hw-annotate[...]目标文字、真实标签与装饰 connector
hw-note::hw-note[...]一行状态、胶带或面板式说明
hw-brace:::hw-brace[label]用 caption 归组一段内容
hw-margin:::hw-margin[label]带真实 aside 标签的边注
hw-watermark:::hw-watermark[label]明确为装饰的水印

三种冒号数量不是排版偏好,而是 AST kind:

:hw-mark[inline target]{kind="underline"}

::hw-note[one flow-level note]{appearance="line"}

:::hw-brace[group label]
Content grouped by the brace.
:::
  • 单冒号生成 textDirective,可以进入段落的 phrasing content;
  • 双冒号生成 leafDirective,自身占据一个 flow 位置但没有容器 body;
  • 三冒号生成 containerDirective,方括号是纯文本 label,围栏内部才是 body。

这种区分让编译器能在进入 React 前就拒绝错误形态。例如把 hw-note 写成单冒号,不会得到一个「勉强能用」的行内 note,而会触发 directive-wrong-kind

Mark treatment 是视觉闭集,不是新 gesture

hw-mark.kindhw-annotate.mark 共享七种 treatment。它们不是只存在于属性表里,下面七个短语就是实际渲染结果:

稳定主张关键结论关注对象被否决方案

封闭边界仍待确认结构标识

hw-annotate 还可以使用 mark="none",只画关系而不处理 target;strength 则只属于 hw-mark

这是一项刻意的设计约束:circle、wavy 或 bracket 只改变已有文字的画法,不引入新的读者文案,因此 CSS 消失后可以退化成普通 phrasing content。想补充一句话 时,应使用 annotate label、note 或 margin,而不是让伪元素生成文字。

视觉 treatment 可以增加;低层语义动作不必随之膨胀。

完整 schema 不是散落在组件 props 中,而是集中定义了每个 directive 的 AST kind、属性白名单、枚举、默认值、必填项和 label 上限。 源码 文末附录给出了全部属性。

remark 编译器:先验证,再 lowering

remarkMdxHandwritten 的 public options 看起来不多,但每一项都对应一条编译边界:

interface HandwrittenOptions {
  output?: 'component' | 'element' | 'strip'
  imports?: {mode: 'manual'} | {mode: 'auto'; source: string}
  components?: Partial<HandwrittenComponentNames>
  variant?: {
    count?: 1 | 2 | 3 | 4
    seed?: string
    projectRoot?: string
  }
  diagnostics?: 'strict' | 'warn'
  limits?: {maxDirectivesPerFile?: number}
  reviewedPlans?: {projectRoot: string}
  sceneCompiler?: ConfiguredSceneCompiler
  recordUsage?: boolean
}

默认值是:

选项默认值含义
outputcomponent输出 Hand* MDX components
importsmanual由宿主提供 component map
variant.count4每种视觉有四个稳定变体
variant.seedmdx-handwritten项目级变体盐值
diagnosticsstrict作者错误终止构建
limits.maxDirectivesPerFile500防止异常 directive-heavy 输入
recordUsagefalse是否记录到 file.data.mdxHandwritten

默认值和 scene compiler bridge 都在插件初始化阶段解析,而不是在每个 React component 内反复判断。 源码

为什么还要回读原始源码

remark-directive 解析以后,某些作者写法会被统一成相似的 AST。如果只看 node.attributes,编译器不一定能区分下面几种情况:

:hw-text[x]{tone="muted"}  <!-- 合法 -->
:hw-text[x]{tone=muted}    <!-- 未加引号 -->
:hw-text[x]{tone}          <!-- 没有静态值 -->
:hw-text[x]{tone="muted" tone="danger"} <!-- 重复 -->

所以 transformer 会根据 node.position 从 VFile 中 切回原始源码找回 AST 丢失的引号与重复属性,扫描最后一个 attribute block,保留「是否加引号」与「是否重复」这类 AST 之外的信息。随后再进行第二层 schema 校验:

  1. 属性必须是带引号的静态字符串;
  2. 未知属性和重复属性失败;
  3. 枚举值必须属于闭集;
  4. 必填字段不能为空;
  5. annotate 与 container label 受字符数限制;
  6. hw-link 额外校验 URL。

合法链接可以是相对 URL,或 httphttpsmailtotel。协议相对 URL、ASCII C0 控制字符与 DEL、反斜杠和其他 scheme 会被拒绝。React 的 HandLink 对直接 JSX 调用还会再做一次防御:不安全时不输出 href

URL 校验有两道边界:remark 在构建期拒绝危险输入;直接 JSX 的 HandLink 仍会拒绝输出不安全的 href

嵌套也是语法的一部分

手写元素并不能任意互相包含。当前规则可以压缩成下面这张图:

hw-text  ─┐
          ├─ may contain: hw-mark only
hw-note  ─┘

hw-link / hw-mark / hw-annotate
  └─ may contain: no handwritten directive

layout containers:
  hw-watermark → hw-margin → hw-brace → content

同类容器不能递归嵌套,顺序也不能逆转。hw-linkhw-annotate 不能放进 Markdown link 或交互 JSX;hw-linkhw-markhw-annotate 的 target 也不能再包含链接、控件或带事件属性的交互 JSX。

容器 label 还有一条容易忽略的规则:它必须是 parser 生成的第一个 directive-label paragraph,而且只能包含纯文本。label 不会混入 body,而会单独变成 figcaptionaside 或装饰水印。

下面不是容器顺序的伪代码,而是一段正在本文中运行的三层嵌套。四冒号外层让内部的三冒号围栏可以安全闭合:

正文始终在最内层:watermark 只做装饰,margin 提供旁注,brace 负责归组;任何一层失去样式,这句话仍在正常阅读顺序里。

内层 brace

:::

外层 watermark

手写感不是运行时随机数

完全相同的 underline 如果每次构建都画得一模一样,会显得机械;如果在浏览器里调用 Math.random()SSR 与 hydration 可能不一致,又会失去服务端与客户端的一致性。

插件采用稳定变体:

const input = [
  variant.seed,
  relativeVFilePath,
  String(directiveOrdinal),
  textContent(node).normalize('NFC')
].join('\0')

const variant = (fnv1a(input) % variant.count) + 1

结果写进 data-hw-variant="1""4"稳定输入,而非运行时随机数。路径、顺序、文字和 seed 不变,构建结果就不变;作者移动或修改批注时,变体可以自然变化。 源码

strict 与 warn 的差别是失败策略

diagnostics: 'strict' 会把作者错误写成带 source、ruleId 和 position 的 VFile message,并在 transform 前抛出第一条错误。因此 invalid directive 不会先变成 JSX,再把问题拖到运行时。

warn 不是「宽松接受」。它仍记录诊断,只是不终止构建,并把 invalid gesture strip 成可读 fallback:

:hw-text[visible copy]{tone=muted}

这段在 strict 下失败;在 warn 下,手写 wrapper 会消失,但 visible copy 仍留下。迁移旧内容时这很实用,生产构建则通常更适合 strict。

warn 保留可读内容;strict 拒绝含糊的发布输入。

component、element、strip:三种 AST 目标

三个 output mode 共用同一套 validation 和 metadata,差别发生在最后的 transformChildren 分流点。 源码

模式编译结果适合的宿主
componentHandTextHandAnnotateHandScene 等 MDX JSXReact、Next.js、带 component map 的 MDX
element原生语义元素与 data-hw-* propertiesAstro、framework-free HTML、sanitize pipeline
stripMarkdown/HTML 可读 fallback,无 Hand* contract纯文本导出、RSS-like 处理、迁移与降级

同一套语义,三种 AST 目的地。

仍以开头的 annotate 为例,component mode 会生成类似:

<HandAnnotate
  label="stable ID"
  placement="block-start-inline-end"
  mark="bracket"
  arrow="straight"
  shiftInline="1"
  data-hw-variant="…"
>
  <code>CLI-042</code>
</HandAnnotate>

注意 shift-inline 已经转成 React prop shiftInline;如果启用 auto import,插件只导入当前文档真正使用到的组件,并检查与现有 binding 是否冲突。

element mode 不经过 React SSR,而是在 mdast 节点的 data.hNamedata.hProperties 上直接声明 HTML:

<span
  data-hw="annotate"
  data-hw-placement="block-start-inline-end"
  data-hw-mark="bracket"
  data-hw-arrow="straight"
>
  <span data-hw-target><code>CLI-042</code></span>
  <span data-hw-label dir="auto">stable ID</span>
  <svg aria-hidden="true">…</svg>
</span>

strip mode 则保留为:

CLI-042 (stable ID)

同样的原则也用于其他 gesture:

  • link 保留原生 <a>,新窗口链接补上 noopener noreferrer
  • highlight 使用 <mark>,strike 使用 <s>,其他 mark 使用 <em>
  • brace 使用 <figure><figcaption>
  • margin 的 label 使用 <aside>
  • watermark label 因为永远装饰而设置 aria-hidden,strip 时完全删除 label。

测试不是只比较字符串里有没有 data-hw。element 测试会验证真实文字、语义标签、装饰 SVG 的 aria-hidden,以及 scene 的 source 出现在 ordered legend 之前。 测试

Scene:把结构化源文本编译成封闭语义图

八种 gesture 适合作者明确知道「这里要画什么」的场景。面对一条结构稳定的任务记录,如果每次都手写六个 annotate,作者既重复,又容易让 label 和 target 失配。

Annotation recipe 解决的是另一类问题:识别一份可读的结构化 source,产出 targets、labels、relationships 和 gestures。当前源码实际注册的 built-in recipe 只有 task-explainer

一份 source,一张可审查的关系图。

下面不是示意 HTML,而是本文构建时真实执行的 scene:

任务解析
[ ] CLI-042 增加导出命令 #cli !high @blocked_by:CLI-041
让脚本和 Agent 都能读取 JSON 格式的任务结果
  1. 未完成任务:[ ]

  2. 稳定 ID:CLI-042

  3. 描述:增加导出命令 让脚本和 Agent 都能读取 JSON 格式的任务结果

  4. 标签:#cli

  5. 优先级:!high

  6. 自定义字段:@blocked_by:CLI-041

它识别:

  • state:[ ]
  • stable ID:CLI-042
  • description:首行标题与第二行详情
  • tag:#cli
  • priority:!high
  • field:@blocked_by:CLI-041

这里的 target ID 来自 语法槽位与结构化 key,例如 stable-idtag:clifield:blocked_by;它不是匹配文本、DOM selector、AST path 或第几个相同字符串。

ScenePlanV1 里有什么

scene package 的核心输出可以精简成:

interface ScenePlanV1 {
  schema: 'mdx-handwritten/scene-plan'
  schemaVersion: 1
  recipe: {name: string; version: number}
  localization: {
    locale: string
    catalog: {id: string; version: number}
  }
  title: string
  source: {
    text: string
    identity: {
      normalization: 'trim-lf-v1'
      algorithm: 'sha256'
      digest: string
    }
  }
  targets: AnnotationTargetV1[]
  labels: AnnotationLabelV1[]
  relationships: AnnotationRelationshipV1[]
  gestures: AnnotationGestureV1[]
  provenance: ScenePlanProvenanceV1
}

这是一个 闭合的纯 JSON 语义图 源码 它刻意不包含:

  • DOM、CSS selector 或 React element;
  • x/y 坐标、connector path、breakpoint 或碰撞结果;
  • renderer 配置、className、任意扩展字段;
  • prompt、模型回复、provider metadata;
  • 可执行 markup 或回调函数。

每个 target 带有一个或多个 source range:

interface SourceRangeV1 {
  start: number
  end: number
  exactText: string
}

range 是规范化 source 上的 UTF-16 code unit 半开区间不按字形坐标定位 [start, end)。验证器会拒绝越界、重叠、切开 surrogate pair,或 source.slice(start, end) !== exactText 的计划。重复 tag 或 field 可以让同一个语义 target 拥有多个有序 ranges,而不需要伪造多个身份。

canonical source 与 freshness fingerprint

当前 canonicalization 非常克制:

source.replace(/\r\n?/gu, '\n').trim()

它只统一换行并清理首尾空白,不做 NFC/NFKC、大小写折叠、语言检测或 fuzzy matching。

规范化结果会以 UTF-8 计算 SHA-256 digest。这个 digest 是 freshness identity:它证明 plan 对应哪一份 source,但不是数字签名,也不能证明「某个人真的审核过」。任何改变 canonical source 的变化都会让旧计划 stale;仅改变 CRLF/LF 或首尾空白则不会。系统也不会因为原字符串还能在别处找到,就自动迁移 range。

Digest 是 freshness fingerprint,不是签名,也不替代人的审核。

createScenePlan 返回完整结果或完整失败

根入口有两种互斥输入:

type CreateScenePlanInput =
  | {
      source: string
      recipe: string
      locale?: string
      corrections?: readonly SemanticCorrectionV1[]
      candidateJson?: never
    }
  | {
      source: string
      candidateJson: string
      recipe?: never
      locale?: never
      corrections?: never
    }

返回值也是判别联合:

type ScenePlanResult =
  | {ok: true; plan: ScenePlanV1; diagnostics: []}
  | {ok: false; plan: null; diagnostics: NonEmpty<SceneDiagnosticV1>}

没有「带错误的半份 plan」。内建 deterministic 路径依次执行 input、source、locale、correction 校验,解析 task,创建 targets 与 graph,生成 fingerprint,应用 correction,最后再验证整个集合。 源码

要么得到一份完整且通过闭集校验的 plan,要么得到 plan: null + diagnostics

V1 还给输入和图结构设置了实现拥有的硬上限,例如 source 最多 4,096 个 UTF-16 code units、candidate JSON 最多 65,536 bytes、targets/labels/relationships/gestures 各最多 64 个。上限不是用来截断内容:source 超限触发 scene-source-too-long;candidate JSON 或 plan 集合超限触发 scene-plan-limit-exceeded;两者都返回 plan: null

deterministic、reviewed 与第三方 recipe

同一个 ScenePlanV1 可以从不同的可信路径进入 renderer,但信任边界不同。

1. 内建 deterministic recipe

const result = createScenePlan({
  recipe: 'task-explainer',
  locale: 'zh-CN',
  source: `[ ] CLI-042 增加导出命令 #cli !high @blocked_by:CLI-041
让脚本和 Agent 都能读取 JSON 格式的任务结果`
})

内建 recipe 与 scene finalizer 一起随 package 固定。普通 MDX 构建 不访问网络,也不调用模型

2. Reviewed plan artifact

可选的上游 authoring tool 可以生成一个 untrusted proposal,作者审核后把完整 plan 物化成 sidecar。MDX 只保存 opaque binding:

:::hw-scene{recipe="task-explainer" locale="zh-CN" plan="rp1_01k4m6h8q2w9c5x7t3v0n8s6dy"}
[ ] CLI-042 增加导出命令 #cli !high
:::

宿主必须显式配置绝对 project root:

[remarkMdxHandwritten, {
  diagnostics: 'strict',
  reviewedPlans: {projectRoot: process.cwd()}
}]

resolver 只会读取:

.mdx-handwritten/plans/<binding>.json

binding 不是 path、URL 或 glob。读取层限制 64 KiB,拒绝 symlink、目录逃逸、无效 UTF-8、缺失和不可读文件;随后仍把 candidate JSON 交给 scene finalizerartifact 不是信任豁免 做 closed-shape、版本、引用、range、source 与 digest 校验。

strict 下,缺失、malformed 或 stale artifact 会让构建失败。warn 下只保留 canonical source,不会退回 deterministic recipe 重新猜意思。普通 build 也不会调用 AI、读取私有 review store,或把 provenance 当作审核的密码学证明。

3. 显式安装的第三方 Recipe package

第三方 recipe 通过标准 ESM npm 依赖进入构建:

import acmeRecipes from '@acme/mdx-handwritten-recipes'
import {createSceneCompiler} from '@madinah/mdx-handwritten-scene/recipes'

const sceneCompiler = createSceneCompiler({
  recipePackages: [{
    packageName: '@acme/mdx-handwritten-recipes',
    definition: acmeRecipes
  }]
})

const result = sceneCompiler.createScenePlan({
  recipe: '@acme/mdx-handwritten-recipes/task-summary',
  source: '[ ] CLI-042 Add export command #cli !high',
  locale: 'zh-CN'
})

再把 sceneCompiler 注入 remark:

[remarkMdxHandwritten, {sceneCompiler}]

这里没有目录扫描、远程 registry、source 驱动的 dynamic import、浏览器 loader 或全局可变 registry。host 的 import 就是一次明确的 build-time trust grantexplicit import 不等于 sandbox

这种扩展也不是 sandbox。第三方 JavaScript 与其他 npm 构建依赖拥有相同进程权限;scene core 保护的是输出边界:package 的 compile 只能返回 bounded semantic draft,normalization、localization、correction、graph validation、limits、provenance 和最终 plan 仍由 core 决定。配置级错误在 createSceneCompiler 构造时抛出 SceneCompilerConfigurationError,单个 scene 的作者错误则继续返回 plan: null + diagnostics

第三方 recipe 获得的是构建期执行权,不是绕过 ScenePlanV1 闭集验证的权利。

renderer:把 plan 映射为 source-first DOM

React 的场景接口清楚表达了两条入口:

type HandSceneProps =
  | {
      plan: ScenePlanV1
      recipe?: never
      source?: never
      locale?: never
    }
  | {
      plan?: never
      recipe: string
      source: string
      locale?: string
    }

remark 的 component 路径在编译时已经 materialize plan,并把它编码成 inert JSON ESTree expression:

<HandScene plan={/* complete ScenePlanV1 */} />

它不会把 recipe + source 交给 React 再推导一次,因此 reviewed plan 与 Semantic correction 不会在 adapter 边界丢失。 源码

直接写 React 时仍可以使用便利形式:

<HandScene
  recipe="task-explainer"
  source="[ ] CLI-042 Add export command #cli !high"
  locale="en"
/>

这个分支会在 React render 中同步调用 createScenePlan。因此准确的边界是:remark 生产路径先物化 plan;React public API 额外保留 direct-source convenience form,而不是笼统地说「所有 renderer 都只能消费 plan」。

成功的 HandScene 始终输出:

figure
├─ figcaption       generated title
├─ pre > code       canonical source, targets become span/mark
└─ ol               complete relationship legend

source 在 legend 之前DOM 顺序本身就是 fallback,且两者从一开始就在 DOM 中;CSS 不是等窄屏以后才「补」出 fallback。失败的 direct-source scene 则只输出包含原 source 的 <pre><code> React 源码

普通 gesture 也遵循同样的 semantic-first 做法。HandAnnotate 的核心结构近似:

<span data-hw="annotate" data-hw-placement={placement}>
  <span data-hw-target>{children}</span>
  <span data-hw-label dir="auto">{label}</span>
  {arrow === 'none'
    ? null
    : <HandConnector kind={arrow} placement={placement} />}
</span>

label 是真实文字connector 是装饰 SVG,设置 aria-hiddenfocusable="false",并带固定 viewBox 和尺寸,避免无 CSS 时膨胀成巨型图形。

component renderer 与 element renderer 是两套实现,不承诺 DOM 字节级一致。它们共享的契约是 source-first、完整 legend 和稳定的 data-hw* 语义。当前 element scene 还会生成稳定 target/annotation ID,并用 aria-describedby 把 target 指向 legend;文章或测试不应把这一实现细节无条件外推到所有 renderer。

theme:CSS 只负责画,不负责解释

theme 的选择器只认识公开的 data-hw*

:where([data-hw="mark"][data-hw-kind="underline"]) { /* ... */ }
:where([data-hw="annotate"][data-hw-placement="inline-end"]) { /* ... */ }
:where([data-hw-scene-source]) { /* ... */ }
:where([data-hw-scene-legend]) { /* ... */ }

它可以改变字体、颜色、旋转、间距、connector 和 mark treatment,却不能:

  • 创建读者必须听见的文案;
  • 重新选择 target;
  • 改写 relationship;
  • 让一个 invalid plan 变成 valid;
  • 根据浏览器测量生成另一份语义

Source、label 和 legend 先存在,手写字体、绝对定位和 SVG 才有资格增强它们。

窄屏、RTL、强制色与打印

主题使用 logical properties 和逻辑 placement方向来自 writing mode,而不是把 left/right 写进作者语法。短 label 带 dir="auto";RTL 下需要改变方向的 glyph 由 CSS 翻转。

当容器或 viewport 变窄时:

  • annotate label 回到行内,connector 隐藏;
  • brace caption 和 margin note 回到文档流;
  • watermark label 隐藏;
  • scene source 和 legend 保持原来的 DOM 顺序。

forced-colors 下,颜色回到 CanvasText 等系统色,阴影、scene legend connector 与 watermark label 被移除;普通 annotate connector 仍可保留。打印时 annotate 线性化,annotate connector、brace glyph、margin glyph、scene legend connector 与 watermark label 隐藏;note/link 的辅助 glyph 不在这份隐藏列表里。

@media (forced-colors: active) {
  :where([data-hw]) {
    --hw-color: CanvasText;
    box-shadow: none !important;
  }
  :where(
    [data-hw-scene-legend] [data-hw-connector],
    [data-hw="watermark"] > [data-hw-label]
  ) {
    display: none;
  }
}

@media print {
  :where([data-hw="annotate"]) {
    display: inline;
  }
  :where(
    [data-hw="annotate"] > [data-hw-connector],
    [data-hw="brace"] > [data-hw-brace],
    [data-hw="margin"] > [data-hw-label] > [data-hw-glyph],
    [data-hw="watermark"] > [data-hw-label],
    [data-hw-scene-legend] [data-hw-connector]
  ) {
    display: none;
  }
}

默认主题通过 Fontsource 打包 Shantell Sans 的 Latin、Latin Extended、Cyrillic、Cyrillic Extended 与 Vietnamese 五个 variable-weight subsets;没有打包 CJK webfont。中文 label 走系统手写/楷体 fallback,打印再切换到 system-only stack。这既控制默认字体体积,也避免把字体选择写进 Scene plan。

Rich-layout envelope 是设计边界,不是现有碰撞引擎

项目 ADR 描述过 recipe-owned Rich-layout envelope:只有 recipe 能证明内容处于有限、无碰撞的布局范围时,renderer 才可以增加空间化 label rail;超出范围应保持线性形式。

当前 0.2.0 的生产 React/theme 实现已经有 source-first DOM、普通 scene grid、响应式单列与打印/forced-colors 降级,但没有 recipe-specific capacity evaluator、全局 packing、障碍路由或浏览器测量,也没有完整实现 spec 里的 rich rail/numbered marker 方案。

设计允许未来增加有界空间增强;当前实现不能被描述成一台已经交付的自动避碰布局引擎。

安全边界:关闭自己的语言,不冒充全篇 sanitizer

MDX 可以执行 JavaScript。mdx-handwritten 的作者模型因此分成两层:

  1. 对可信 MDX:transformer 仍把每个 hw-* directive 的属性关闭在 schema 内,拒绝 expression、spread、未知属性、任意 class/style/id 注入和不安全 URL。
  2. 对不可信作者:不要接收 MDX;只接收纯 Markdown,以 output: 'element' 编译,再由宿主配置 rehype-sanitize allow-list。

第一层只约束 handwritten directive 自己,不会遍历整篇 MDX 并删除所有 JSX 的 classNamestyleonClick。交互 JSX 只在 nesting 校验需要判断 link/annotate target 是否安全时被识别。把 transformer 称为「全篇 MDX sanitizer」会扩大它实际没有承担的安全责任。

这也解释了 element mode 的价值:宿主可以对原生 element 和显式 data-hw-* contract 建立清晰 allow-list,而不必执行作者提供的 React component。

两层作者信任模型

测试:把设计约束变成 release truth

「无 JavaScript 也能读」「打印时不丢字」如果只写在 README 里,很容易在一次 CSS 重构后失效。这个仓库把它们拆成不同层次的检查:

检查层验证内容
scene Vitestgrammar、canonical source、ranges、引用、limits、stale、corrections
remark Vitestschema、diagnostics、nesting、component/element/strip AST
React SSR语义标签、URL 防御、materialized plan 不重复 derive、invalid fallback
Playwright semanticsource/legend 顺序、LTR/RTL、窄屏、无 CSS、font failure
AxeWCAG A/AA 规则下的发布 fixture
print / forced-colors装饰消失后仍保留完整文字
pinned Chromium screenshots在固定 Linux 镜像中做可复现 pixel diff
budget gateESM、consumer bundle、CSS、字体、transport、SSR HTML、npm tarball

视觉截图不是 scene contract。Canonical fixture 的源文本和读者意义才是 release truth;Chromium pixel baseline 只是一份固定环境里的派生证据,Firefox 与 WebKit 更适合检查语义、可达性、overflow 与 fallback。

读者意义、canonical fixture 与可读 fallback 才是发布判断的主轴。

性能预算也保护架构边界

当前 blocking budgets 包括:

  • scene ESM:64 KiB raw / 14 KiB gzip;
  • remark ESM:64 KiB raw / 15 KiB gzip;
  • React ESM:18 KiB raw / 4 KiB gzip;
  • theme CSS:40 KiB raw / 6 KiB gzip;
  • 默认字体:225 KiB total / 85 KiB common Latin;
  • 标准 component Scene plan transport:6 KiB;
  • 标准 materialized-plan SSR HTML:6 KiB;
  • scene 意义所需的 client-only runtime:0 B。

「0 B client-only runtime」不等于宿主 React 应用没有 bundle;它表示 Annotation scene 的意义与默认呈现 不要求 hydration度量对象是 scene runtime。预算也不能通过删除完整 legend、弱化 validation 或把编译推到浏览器来达成。

预算脚本使用 esbuild metafile 收集完整静态依赖图,再计算 raw 与 canonical gzip 大小,避免用一个很小的 re-export 入口藏住真实依赖。 测量源码

回到开头:手写感只是最后一层

mdx-handwritten 最值得复用的思路,不是某一条漂亮的 SVG path,而是它为文档建立的责任顺序:

  1. 作者写可读 source 和有限的语义意图;
  2. remark 在编译期验证语言并选择输出 AST;
  3. scene compiler 把复杂关系物化成 closed plan;
  4. renderer 把 plan 映射为 source-first semantic DOM;
  5. theme 最后才增加手写字体、颜色与几何装饰;
  6. 测试同时验证正常呈现与样式失效后的阅读路径。

只要 source、label 和 relationship 先成为文档的一部分,手写外观就可以大胆变化;反过来,把意义藏进一次 layout 或一张 Canvas,视觉越精巧,系统边界反而越脆弱。

semantic first

先编译意义,再绘制个性。

可以从 Live playground 直接试写;完整实现、ADR 与测试位于 GitHub repository

附录 A:八种 directive 的完整属性

表中加粗值为必填,括号内为默认值。所有属性都必须写成带引号的静态字符串。

Directive属性
:hw-text[...]tone: inherit(默认)、neutral、muted、info、success、warning、danger、accent;size: inherit(默认)、sm、md、lg、display;weight: inherit(默认)、regular、medium、semibold;rotate: -3 到 3(默认 0)
:hw-link[...]hreftarget: self(默认)、blank;tonesizeweight 同 text;rotate: -3 到 3(默认 0);underline: subtle、strong(默认);icon: none(默认)、arrow-forward、arrow-back、external
:hw-mark[...]kind: underline(默认)、highlight、circle、strike、box、wavy、bracket;tone: inherit(默认)、neutral、muted、info、success、warning、danger、accent;strength: subtle、normal(默认)、strong
:hw-annotate[...]label,最长 120 字符;placement: block-start(默认)、block-start-inline-start、block-start-inline-end、block-end、block-end-inline-start、block-end-inline-end、inline-start、inline-end;tone: muted(默认)及其他 tone;mark: 七种 treatment(默认 highlight)或 none;arrow: curved(默认)、straight、none;distance: tight、normal(默认)、loose;shift-inline / shift-block: -3 到 3(默认 0);rotate: -3 到 3(默认 -2)
::hw-note[...]appearance: line(默认)、tape、panel;tone: neutral(默认)、muted、info、success、warning、danger、accent;icon: auto(默认)、none、check、cross、info、warning、spark;size: sm、md(默认)、lg、display;weight: regular(默认)、medium、semibold;rotate: -3 到 3(默认 0);align: start(默认)、center、end;density: compact、normal(默认)
:::hw-brace[label]label 必填、纯文本、最长 80 字符,body 必填;side: inline-start、inline-end(默认);align: start、center(默认)、end;tone: muted(默认)及其他 tone;rotate: -3 到 3(默认 -2);distance: tight、normal、loose(默认)
:::hw-margin[label]label 必填、纯文本、最长 160 字符,body 必填;side: inline-start(默认)、inline-end、block-start、block-end;align: start、center、end(默认);tone: muted(默认)及其他 tone;size: sm、md(默认)、lg、display;weight: regular(默认)、medium、semibold;rotate: -3 到 3(默认 -2);icon: none、arrow-toward(默认)、arrow-away;distance: tight、normal(默认)、loose
:::hw-watermark[label]label 必填、纯文本、最长 120 字符,body 必填;placement: block-start-inline-start、block-start-inline-end(默认)、block-end-inline-start、block-end-inline-end、center;tone: muted(默认)及其他 tone;size: sm、md、lg、display(默认);weight: regular、medium、semibold(默认);rotate: -3 到 3(默认 3);strength: ghost、faint(默认)、soft

补充集合:

type Tone =
  | 'inherit'
  | 'neutral'
  | 'muted'
  | 'info'
  | 'success'
  | 'warning'
  | 'danger'
  | 'accent'

type Rotation = '-3' | '-2' | '-1' | '0' | '1' | '2' | '3'
type Align = 'start' | 'center' | 'end'
type Distance = 'tight' | 'normal' | 'loose'

hw-notehw-marginhw-watermark 的 size 和 weight 不接受 inherithw-note 的 tone 也不接受 inherit,因为它们是独立的 flow-level 表达,而不是继承段落语气的行内 phrasing。

附录 B:嵌套与失败速查

写法结果
hw-texthw-note 内嵌 hw-mark合法
hw-texthw-note 内嵌其他 handwritten directivenesting-invalid
hw-linkhw-markhw-annotate 内嵌 handwritten directivenesting-invalid
link/annotate 放入 Markdown link 或交互 JSXnesting-invalid
watermark → margin → brace → content合法容器顺序
同类容器递归,或反向嵌套nesting-invalid
属性未加引号、使用表达式或 spreadattribute-dynamic
属性重复attribute-duplicate
schema 外属性attribute-unknown
错误枚举、缺少必填属性、空值attribute-invalid
不安全 hrefurl-unsafe
container label 非纯文本attribute-invalid
container label 过长label-too-long
文件中超过默认 500 个 hw-* 节点directive-limit
unknown hw-*directive-unknown
hw-* directive留给其他插件,不处理

Scene 另有自己的规则:

  • 必须使用 :::hw-scene{recipe="..."} container;
  • 没有 bracket label;
  • 只接受 recipelocaleplan 三个带引号的静态属性:recipe 必填且非空,locale 默认 enplan 是可选的 reviewed artifact opaque binding;
  • 使用 plan 时,artifact 内的 recipe 与 locale 必须和 directive 声明一致;
  • body 必须是 plain source,不能嵌套 hw-*、HTML 或复杂 Markdown;
  • scene 不能嵌套 scene,也不能放进其他 handwritten directive;
  • expected author error 返回 plan: null + diagnostics,不抛出半份 plan。

附录 C:关键源码索引