///|
/// 组件规范(1.0 规范性强化):弱模型最常产出「能用但不精致」的文档,五类根因:
/// 1. 操作组件宽高刚好卡住文案——没有留白概念,框贴着字
/// 2. 圆角体系混乱——按钮族该「方正圆润」(8px 档)却给 0,或全宽条带圆角顶出屏幕边缘
/// 3. 全宽条/全宽列表该直角却带圆角
/// 4. 齐缝列表行没有行间分割线、没有分组——菜单/设置页十几行裸排下去,
/// 与成品列表页(行底发丝线、分组卡片)一眼就是两档
/// 5. **撞色**——文字与背景同色系(黑底黑字/白底白字),完全不可读;
/// 用户决策 2026-10-11:严禁高保真背景色和文字同色系,对比度 < 2.0
/// 零容忍,auto_fix 按背景亮度把文字翻转到黑/白。另:组件必须横平竖直,
/// rotate 非 90° 倍数归零。
///
/// 定位:**指导与规范,不做硬门**。偏差记入 critique 第 9 维(norms,给 Agent
/// 事后反馈),auto_fix 一键修复(审查-修复循环的执行手)——不改双门语义,
/// 存量文档不产生新视觉债。
///|
/// 操作组件族:承载短文案、点击即动作。文案与边缘必须有留白。
pub fn norm_is_action_component(component : String) -> Bool {
component == "button" ||
component == "chip" ||
component == "tag" ||
component == "fab"
}
///|
/// 方正圆润族:默认 8px 圆角档(avatar 999 正圆、全宽条 0 不在此列)
pub fn norm_is_rounded_component(component : String) -> Bool {
component == "button" ||
component == "chip" ||
component == "tag" ||
component == "select" ||
component == "search_bar" ||
component == "fab"
}
///|
/// 全宽顶边/底边条与全宽行:占满画板宽度时应当直角(圆角会顶出屏幕边缘)
pub fn norm_is_full_bleed_component(component : String) -> Bool {
component == "app_bar" ||
component == "tab_bar" ||
component == "list_item" ||
component == "divider"
}
///|
/// 横向留白下限(左右各 12):与 svg.mbt 渲染层 text pad 同值
pub const NORM_TEXT_PAD_X : Double = 12.0
///|
/// 纵向留白下限(上下各 10)
pub const NORM_TEXT_PAD_Y : Double = 10.0
///|
/// 方正圆润圆角档:按钮族默认 8px
pub const NORM_RADIUS_BASE : Double = 8.0
///|
/// 组件规范一句话(list_components 随条目下发——Agent 选组件时即可见规范,
/// 不必等 critique 事后纠正)。返回 None 的组件不下发该字段。
pub fn component_norm(id : String) -> String? {
match id {
"button" =>
Some(
"宽 ≥ 文案宽+24(左右各留 12),高 36~48,圆角 8;别让框刚好卡住文字",
)
"fab" => Some("56×56,圆角 16;单屏只放一个主操作")
"chip" => Some("高 28~32,左右留白 ≥10,圆角取 高/2(胶囊)或 8")
"tag" => Some("高 22~26,左右留白 ≥8,圆角 4~6")
"select" => Some("高 40~44,圆角 8,右侧留 24 放箭头")
"search_bar" => Some("高 36~44,圆角取 高/2(胶囊)或 10")
"list_item" =>
Some("高 56~76;全宽直角(radius 0);成排堆叠时行间要有 1px divider 或按功能分组")
"app_bar" => Some("全宽顶边条:高 44~56,圆角 0")
"tab_bar" => Some("全宽底边条:高 56~64,圆角 0")
"avatar" => Some("正圆:radius 999,常用 40/48/56")
"divider" => Some("全宽 1px,圆角 0")
_ => None
}
}
///|
/// 操作组件的高度底线(norm_issues 与 norm_fix_pass 共用,两端不得漂移):
/// - chip/tag/fab 有 component_norm 下发的独立档位(高 28~32 / 22~26 / 56×56),
/// 通用「字号+上下留白」底线会把档内合规件抬出档位——按组件取档下限;
/// - 多行文案按实际行数要高度(文本块高 + 上下留白)——通用档位按单行标签设定。
fn norm_need_h(
component : String,
text : String,
font_size : Double,
ts : Size,
) -> Double {
let band = match component {
"chip" => 28.0
"tag" => 22.0
"fab" => 56.0
_ => font_size + NORM_TEXT_PAD_Y * 2.0
}
let mut lines = 1
for c in text.iter() {
if c == '\n' {
lines = lines + 1
}
}
let multi = ts.height + NORM_TEXT_PAD_Y * 2.0
if lines > 1 && multi > band {
multi
} else {
band
}
}
///|
/// 检测组件规范偏差,返回人可读问题清单(critique 第 9 维 norms)。
/// 每条自带建议尺寸——Agent 读到就能改,不必自己换算。
/// 顺序即优先级:结构性偏差(列表缺分割线)在前,逐条样式在后;
/// 上限 8 条,控制报告体积(deepDesign 侧检视结果有 12KB 截断)。
pub fn norm_issues(
doc : Document,
layout : SolvedLayout,
radius_base? : Double = NORM_RADIUS_BASE,
) -> Array[String] {
let issues : Array[String] = []
let root_w = match layout.rect_of(doc.root_id) {
Some(r) => r.width
None => 0.0
}
// 0) 齐缝列表行连排:整段没有任何分割线/分组(每个 run 报一条)。
// 结构性问题最影响成品观感,放最前——不被逐条样式问题挤出报告。
for run in norms_list_runs(doc, layout).iter() {
if issues.length() >= 8 {
break
}
let first = run.row_ids[0]
let last = run.row_ids[run.row_ids.length() - 1]
issues.push(
"\{json_escape(run.parent_id)} 页 \{run.row_ids.length()} 行列表(\{json_escape(first)}…\{json_escape(last)})行间无分割线——行间加 1px divider,或用 Frame 按功能分组卡片化(auto_fix 可修)",
)
}
for id in doc.nodes.keys() {
if issues.length() >= 8 {
break
}
match doc.nodes.get(id) {
None => ()
Some(n) =>
match layout.rect_of(id) {
None => ()
Some(r) => {
// 0) 撞色零容忍(严禁背景色和文字同色系):文字与有效背景对比度
// < 2.0 即同色系不可读(黑底黑字 ≈1.08 / 白底白字 1.0)。
// 品牌色正常设计(微信绿白字 ≈2.4)不误伤。优先级最高——
// 不可读比一切样式问题都严重。
if n.text.length() > 0 {
match norm_text_collision(doc, id, n.style.fill, n.style.text_color, n.text) {
Some(cr) => {
let bg = norm_effective_bg(doc, id, n.style.fill)
issues.push(
"\{json_escape(id)}(\{n.component}「\{json_escape(n.text)}」)文字与背景撞色:\{bg} 底 \{n.style.text_color} 字,对比度仅 \{cr}(同色系严禁)→ auto_fix 翻转文字色",
)
continue
}
None => ()
}
}
// 0.5) 横平竖直:rotate 必须是 90° 的倍数,任意角度破坏网格
if n.style.rotate % 90.0 != 0.0 {
issues.push(
"\{json_escape(id)}(\{n.component}) rotate \{n.style.rotate.to_int()}° 非直角倍数 → rotate 0(横平竖直,auto_fix 可修)",
)
continue
}
// 1) 操作组件:文案必须与边缘留白
if norm_is_action_component(n.component) && n.text.length() > 0 {
let ts = text_intrinsic(
n.text,
n.style.font_size,
line_height=n.style.line_height,
)
let need_w = ts.width + NORM_TEXT_PAD_X * 2.0
let need_h = norm_need_h(n.component, n.text, n.style.font_size, ts)
if r.width < need_w - 0.5 || r.height < need_h - 0.5 {
// 问题串会进 critique 的 JSON——id/文案过 json_escape(引号、
// 反斜杠与控制符),结构靠「」与转义共同保证
issues.push(
"\{json_escape(id)}(\{n.component}「\{json_escape(n.text)}」)现 \{r.width.to_int()}×\{r.height.to_int()},文案需 ≥\{need_w.to_int()}×\{need_h.to_int()}(左右各留 12)",
)
continue
}
}
// 2) 方正圆润族:圆角低于 8px 档(或超出 高/2 变畸形)
if norm_is_rounded_component(n.component) &&
n.style.corner_radius < 999.0 {
let r_cap = r.height / 2.0
let r_now = n.style.corner_radius
if r_now < radius_base - 0.5 && radius_base <= r_cap {
issues.push(
"\{json_escape(id)}(\{n.component}) 圆角 \{r_now.to_int()} 低于方正圆润档 → radius \{radius_base.to_int()}",
)
continue
}
if r_now > r_cap + 0.5 {
issues.push(
"\{json_escape(id)}(\{n.component}) 圆角 \{r_now.to_int()} 超出 高/2=\{r_cap.to_int()} → radius \{r_cap.to_int()}",
)
continue
}
}
// 3) 全宽条/全宽行:占满画板宽度却带圆角
if norm_is_full_bleed_component(n.component) &&
n.style.corner_radius > 0.5 &&
root_w > 0.0 &&
r.width >= root_w - 0.5 {
issues.push(
"\{json_escape(id)}(\{n.component}) 全宽 \{r.width.to_int()} 带圆角 \{n.style.corner_radius.to_int()} 顶出边缘 → radius 0",
)
continue
}
}
}
}
}
issues
}
///|
/// 规范修复:与 norm_issues 同一判据,直接改文档并返回修复明细
/// (JSON 片段,格式与 autofix 的 fix_* 家族一致)。
pub fn norm_fix_pass(
doc : Document,
layout : SolvedLayout,
radius_base? : Double = NORM_RADIUS_BASE,
) -> Array[String] {
let fixes : Array[String] = []
let root_w = match layout.rect_of(doc.root_id) {
Some(r) => r.width
None => 0.0
}
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
None => ()
Some(n) =>
match layout.rect_of(id) {
None => ()
Some(r) => {
let mut new_w = r.width
let mut new_h = r.height
let mut new_r = n.style.corner_radius
let mut new_text_color = ""
let mut new_rot = n.style.rotate
let mut why = ""
// 0) 撞色修复(严禁背景色和文字同色系):对比度 < 2.0 时文字色按
// 背景亮度翻转到黑/白——不可读比任何设计偏好都优先。
if n.text.length() > 0 {
let bg = norm_effective_bg(doc, id, n.style.fill)
if norm_is_hex6(bg) && norm_is_hex6(n.style.text_color) {
let cr = norm_contrast_ratio(
norm_luminance(bg),
norm_luminance(n.style.text_color),
)
if cr < NORM_CONTRAST_MIN {
new_text_color = norm_readable_text(norm_luminance(bg))
why = "撞色修复"
}
}
}
// 0.5) 横平竖直:rotate 必须是 90° 的倍数,其余归零
if n.style.rotate % 90.0 != 0.0 {
new_rot = 0.0
if why == "" { why = "rotate 归零(横平竖直)" }
}
// 1) 操作组件放大到文案 + 留白(Fill 宽不动——它由父容器决定)
if norm_is_action_component(n.component) && n.text.length() > 0 {
let ts = text_intrinsic(
n.text,
n.style.font_size,
line_height=n.style.line_height,
)
let need_w = ts.width + NORM_TEXT_PAD_X * 2.0
let need_h = norm_need_h(n.component, n.text, n.style.font_size, ts)
let w_fixed = match n.width {
Fixed(_) => true
_ => false
}
if r.width < need_w - 0.5 && w_fixed {
new_w = need_w
why = "文案留白"
}
if r.height < need_h - 0.5 {
new_h = need_h
why = "文案留白"
}
}
// 2) 圆角归档:方正圆润族抬到 8(不超过 高/2);超 高/2 的收到 高/2
if norm_is_rounded_component(n.component) &&
n.style.corner_radius < 999.0 {
let r_cap = new_h / 2.0
let r_now = n.style.corner_radius
if r_now < radius_base - 0.5 && radius_base <= r_cap {
new_r = radius_base
if why == "" { why = "圆角归档 \{radius_base.to_int()}" }
} else if r_now > r_cap + 0.5 {
new_r = r_cap
if why == "" { why = "圆角收敛到 高/2" }
}
}
// 3) 全宽条去圆角
if norm_is_full_bleed_component(n.component) &&
n.style.corner_radius > 0.5 &&
root_w > 0.0 &&
r.width >= root_w - 0.5 {
new_r = 0.0
why = "全宽条直角"
}
let changed = (new_w != r.width && new_w > 0.0) ||
(new_h != r.height && new_h > 0.0) ||
new_r != n.style.corner_radius ||
(new_text_color != "" && new_text_color != n.style.text_color) ||
new_rot != n.style.rotate
if changed && why != "" {
// 只覆写真正变化的维度:width 变化必经 w_fixed 守卫(Fixed→Fixed),
// 其余维度保持 x.width/x.height 原值——Fill/Hug 尺寸策略不得被
// 圆角-only / 高度-only 路径静默降级为 Fixed
let _ = doc.update_node(id, fn(x) {
{
..x,
width: if new_w != r.width && new_w > 0.0 {
Fixed(new_w)
} else {
x.width
},
height: if new_h != r.height && new_h > 0.0 {
Fixed(new_h)
} else {
x.height
},
style: {
..x.style,
corner_radius: new_r,
text_color: if new_text_color != "" {
new_text_color
} else {
x.style.text_color
},
rotate: if new_rot != x.style.rotate { new_rot } else { x.style.rotate },
},
}
})
fixes.push(
"{\"node\":\"\{json_escape(id)}\",\"action\":\"component_norm\",\"why\":\"\{why}\",\"before\":\"\{r.width.to_int()}×\{r.height.to_int()} r\{n.style.corner_radius.to_int()}\",\"after\":\"\{new_w.to_int()}×\{new_h.to_int()} r\{new_r.to_int()}\"}",
)
}
}
}
}
}
fixes
}
///|
/// 齐缝列表行连排:同一父节点下 ≥3 个 list_item,宽度一致(±2)、高度一致
/// (±4)、纵向齐缝或近齐缝(行距 -1~6)——「裸列表」形态,是分割线规范的
/// 检测对象。检测与修复(norm_list_divider_fix)共用本结构。
pub(all) struct NormListRun {
parent_id : String
row_ids : Array[String]
}
///|
/// 按 solved y 插入排序(run 规模小,平行数组位移,不依赖排序 API 形态)
fn norms_sort_by_y(ids : Array[String], layout : SolvedLayout) -> Array[String] {
let out : Array[String] = []
let ys : Array[Double] = []
for id in ids.iter() {
let y = match layout.rect_of(id) {
Some(r) => r.y
None => 0.0
}
let mut pos = out.length()
while pos > 0 && ys[pos - 1] > y {
pos = pos - 1
}
out.push(id)
ys.push(y)
let mut i = out.length() - 1
while i > pos {
out[i] = out[i - 1]
ys[i] = ys[i - 1]
i = i - 1
}
out[pos] = id
ys[pos] = y
}
out
}
///|
/// 两行是否齐缝相连(纵向贴住或近贴,宽高一致)
fn norms_flush(
a : String,
b : String,
layout : SolvedLayout,
) -> Bool {
match (layout.rect_of(a), layout.rect_of(b)) {
(Some(ra), Some(rb)) => {
let gap = rb.y - (ra.y + ra.height)
gap <= 6.0 && gap >= -1.0 && (rb.width - ra.width).abs() <= 2.0 &&
(rb.height - ra.height).abs() <= 4.0
}
_ => false
}
}
///|
/// 行内是否已带 divider 子节点(norm_fix 修复后的形态)
fn norms_row_has_divider_child(
doc : Document,
row_id : String,
) -> Bool {
match doc.nodes.get(row_id) {
Some(r) =>
for cid in r.children.iter() {
match doc.nodes.get(cid) {
Some(c) => if c.component == "divider" { return true }
None => ()
}
}
None => ()
}
false
}
///|
/// 同级 divider 是否落在两行交界处(行底发丝线的页级画法)
fn norms_boundary_has_divider(
doc : Document,
layout : SolvedLayout,
parent_id : String,
top : Double,
bottom : Double,
min_w : Double,
) -> Bool {
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
None => ()
Some(n) =>
if n.component == "divider" {
match doc.find_parent(id) {
Some(p) =>
if p == parent_id {
match layout.rect_of(id) {
Some(r) =>
if r.y >= top - 2.0 && r.y <= bottom + 2.0 && r.width >= min_w * 0.5 {
return true
}
None => ()
}
}
None => ()
}
}
_ => ()
}
}
false
}
///|
/// run 的任一行间是否存在分隔(行内 divider 子节点 / 交界处页级 divider)
fn norms_run_has_separators(
doc : Document,
layout : SolvedLayout,
parent_id : String,
rows : Array[String],
start : Int,
end : Int,
) -> Bool {
for k in start..
if norms_boundary_has_divider(
doc,
layout,
parent_id,
rc.bottom(),
rn.y,
rc.width,
) {
return true
}
_ => ()
}
}
false
}
///|
/// 扫描齐缝列表行连排(NormListRun)。每个 run 是一个「裸列表」:
/// 检测(norm_issues)与修复(autofix 的 norm_list_divider_fix)都消费它。
pub fn norms_list_runs(
doc : Document,
layout : SolvedLayout,
) -> Array[NormListRun] {
let runs : Array[NormListRun] = []
let by_parent : Map[String, Array[String]] = Map::new()
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
None => ()
Some(n) =>
if n.component == "list_item" {
match doc.find_parent(id) {
Some(pid) =>
match by_parent.get(pid) {
Some(arr) => arr.push(id)
None => by_parent.set(pid, [id])
}
None => ()
}
}
_ => ()
}
}
for pid in by_parent.keys() {
match by_parent.get(pid) {
None => ()
Some(group) => {
let rows = norms_sort_by_y(group, layout)
let mut i = 0
while i < rows.length() {
let mut j = i
while j + 1 < rows.length() && norms_flush(rows[j], rows[j + 1], layout) {
j = j + 1
}
if j - i + 1 >= 3 &&
!norms_run_has_separators(doc, layout, pid, rows, i, j) {
let run_ids : Array[String] = []
for k in i..=j {
run_ids.push(rows[k])
}
runs.push({ parent_id: pid, row_ids: run_ids })
}
i = j + 1
}
}
}
}
runs
}
///|
/// 颜色搭配标准(用户决策 2026-10-11:严禁高保真背景色和文字同色系):
/// 文字与**有效背景**(自身实底,透明则向上找最近实底祖先,兜底白底)的
/// WCAG 对比度 < 2.0 即撞色——黑底黑字(≈1.08)、白底白字(1.0)一类,
/// 完全不可读。品牌色正常设计(微信绿白字 ≈2.4)不受影响。
/// 判不了的形态(非 #RRGGBB)一律不判,绝不误报。
///|
pub const NORM_CONTRAST_MIN : Double = 2.0
///|
fn norm_is_hex6(color : String) -> Bool {
if color.length() != 7 {
return false
}
if color.get_char(0) != Some('#') {
return false
}
for i in 1..<7 {
match color.get_char(i) {
Some(ch) => {
let code = ch.to_int()
let ok = (code >= 48 && code <= 57) || (code >= 97 && code <= 102) || (code >= 65 && code <= 70)
if not(ok) {
return false
}
}
None => return false
}
}
true
}
///|
/// sRGB 通道 → 线性亮度分量
fn norm_srgb_lin(c : Double) -> Double {
let v = c / 255.0
if v <= 0.03928 {
v / 12.92
} else {
Double::pow((v + 0.055) / 1.055, 2.4)
}
}
///|
/// WCAG 相对亮度(仅接受 #RRGGBB;调用方先用 norm_is_hex6 把关)
fn norm_luminance(color : String) -> Double {
let (r, g, b) = parse_hex_rgb(color)
0.2126 * norm_srgb_lin(r) + 0.7152 * norm_srgb_lin(g) + 0.0722 * norm_srgb_lin(b)
}
///|
/// WCAG 对比度比
fn norm_contrast_ratio(a : Double, b : Double) -> Double {
let hi = if a > b { a } else { b }
let lo = if a > b { b } else { a }
(hi + 0.05) / (lo + 0.05)
}
///|
/// 有效背景色:自身实底优先;透明(none)向上找最近的实底祖先;
/// 全透明按白底收尾。
fn norm_effective_bg(
doc : Document,
node_id : String,
own_fill : String,
) -> String {
if own_fill != "none" && norm_is_hex6(own_fill) {
return own_fill
}
let mut cur = node_id
let mut walking = true
while walking {
match doc.find_parent(cur) {
None => walking = false
Some(p) => {
cur = p
match doc.nodes.get(p) {
None => walking = false
Some(pn) =>
if pn.style.fill != "none" && norm_is_hex6(pn.style.fill) {
walking = false
return pn.style.fill
}
}
}
}
}
"#FFFFFF"
}
///|
/// 撞色判定:文字与有效背景对比度 < NORM_CONTRAST_MIN。
/// 返回 Some(对比度) 表示撞色。
fn norm_text_collision(
doc : Document,
node_id : String,
fill : String,
text_color : String,
text : String,
) -> Double? {
if text.length() == 0 {
return None
}
if !norm_is_hex6(text_color) || !norm_is_hex6(fill) {
let bg = norm_effective_bg(doc, node_id, fill)
if !norm_is_hex6(text_color) || !norm_is_hex6(bg) {
return None
}
}
let bg = norm_effective_bg(doc, node_id, fill)
if !norm_is_hex6(bg) || !norm_is_hex6(text_color) {
return None
}
let cr = norm_contrast_ratio(norm_luminance(bg), norm_luminance(text_color))
if cr < NORM_CONTRAST_MIN {
Some(cr)
} else {
None
}
}
///|
/// 撞色修复的文字色:按背景亮度翻转到黑/白
fn norm_readable_text(bg_luminance : Double) -> String {
if bg_luminance > 0.35 {
"#1A1C1E"
} else {
"#FFFFFF"
}
}
///|
/// 返回类节点判定(与宿主端 isBackish 启发式同语义,双端不再各自维护):
/// - id 词边界含 back/prev/return(backdrop 不算)
/// - 文本为 返回/后退/上一步/back/←/‹/«,或以「返回」开头,或 ≤3 字含左箭头
/// runtime 对返回类节点的 tap 有内置返回兜底(无 flow/marker 时强制返回
/// 上一层),热区导出与校验共用本判定。
pub fn is_back_like_node(n : Node) -> Bool {
let idl = n.id.to_lower()
if has_word(idl, "back") || has_word(idl, "prev") || has_word(idl, "return") {
return true
}
let t = n.text.trim().to_lower()
if t == "返回" ||
t == "后退" ||
t == "上一步" ||
t == "back" ||
t == "←" ||
t == "‹" ||
t == "«" {
return true
}
if t.has_prefix("返回") {
return true
}
if t.length() <= 3 &&
(t.contains("←") || t.contains("‹") || t.contains("«")) {
return true
}
false
}
///|
/// 词边界子串匹配:出现位置两侧必须是 串边/[_-](与宿主端正则
/// (^|[_-])(back|prev|return)([_-]|$) 同语义;backdrop/dashboard 不误判)
fn has_word(s_lower : String, word : String) -> Bool {
let n = s_lower.length()
let w = word.length()
if w == 0 || n < w {
return false
}
for i in 0..=(n - w) {
if s_lower.substring(start=i, end=i + w) == word {
let b_ok = if i == 0 {
true
} else {
match s_lower.get_char(i - 1) {
Some('_') | Some('-') => true
_ => false
}
}
let a_ok = if i + w == n {
true
} else {
match s_lower.get_char(i + w) {
Some('_') | Some('-') => true
_ => false
}
}
if b_ok && a_ok {
return true
}
}
}
false
}
///|
/// 返回交互校验(返回交互是必须的,用户决策 2026-10-11):
/// 非入口画板必须有离开路径——出边 flow,或返回类节点(runtime 对返回类
/// 节点的 tap 有内置返回兜底)。两者皆无 = 用户会卡死在该页。
/// 豁免条件与实现一致:有出边 flow 的画板、单画板项目。引擎不记录入口
/// 画板(runtime initial_artboard 是会话参数,不入 Project)——未连线的
/// 首页同样按本规则判,需给出边或返回类节点,不做入口特判。
pub fn norm_back_issues(self : Project, artboard~ : String) -> Array[String] {
let issues : Array[String] = []
match self.artboards.get(artboard) {
None => ()
Some(ab) => {
// 单画板项目豁免:没有「上一层」,返回无意义
if self.artboards.size() <= 1 {
return issues
}
let mut has_out = false
for e in self.flows.iter() {
if e.from == artboard {
has_out = true
break
}
}
if has_out {
return issues
}
for id in ab.doc.nodes.keys() {
match ab.doc.nodes.get(id) {
Some(n) => if is_back_like_node(n) { return issues }
None => ()
}
}
issues.push(
"画板「\{json_escape(artboard)}」无任何返回/离开交互——用户会卡死在此页:加返回类节点(back/prev/返回/←/‹/«,runtime 内置返回兜底)或出边 flow",
)
}
}
issues
}
// ---------------------------------------------------------------------------
// Norms v2 — 设计语言扩展(对标 Claude Design codebase-aware 约束 +
// Open Design agent 设计知识):触控目标 / 间距网格 / 字号层级 / 节点密度
// ---------------------------------------------------------------------------
const NORM_TOUCH_MIN : Double = 44.0
const NORM_GRID_UNIT : Double = 4.0
const NORM_TYPE_SCALE_MAX : Int = 5
const NORM_NODE_BUDGET : Int = 80
fn norm_is_interactive(component : String) -> Bool {
norm_is_action_component(component) || component == "tab_bar" || component == "search_bar" || component == "slider" || component == "switch" || component == "checkbox"
}
///|
/// 触控目标按组件分档(与 component_norm 四端同判据:list_components 下发、
/// norm_issues/norm_fix_pass、norm_touch_issues/norm_touch_fix_pass、
/// critique 第 9 维):chip/tag 有下发的独立高度档(28~32 / 22~26),
/// 通用 44×44 会把档内合规件(chip 默认 72×30)判违规、被 touch_fix_pass
/// 抬出档位——chip/tag 只按档下限判高度,宽度无档位、不另设线(文案
/// 留白由 v1 文案规则兜底);其余交互件统一 44×44 全尺寸。
fn norm_touch_is_banded(component : String) -> Bool {
component == "chip" || component == "tag"
}
///|
fn norm_touch_min_h(component : String, touch_min : Double) -> Double {
match component {
"chip" => 28.0
"tag" => 22.0
_ => touch_min
}
}
pub fn norm_touch_issues(
doc : Document,
layout : SolvedLayout,
touch_min? : Double = NORM_TOUCH_MIN,
) -> Array[String] {
let issues : Array[String] = []
for id in doc.nodes.keys() {
if issues.length() >= 4 { break }
match doc.nodes.get(id) {
None => continue
Some(n) => {
if !norm_is_interactive(n.component) { continue }
let banded = norm_touch_is_banded(n.component)
let min_h = norm_touch_min_h(n.component, touch_min)
match layout.rect_of(id) {
None => continue
Some(r) => {
let w_short = !banded && r.width < touch_min - 0.5
let h_short = r.height < min_h - 0.5
if w_short || h_short {
let min_desc = if banded {
"height min " + min_h.to_int().to_string()
} else {
"min 44x44"
}
issues.push(json_escape(id) + "(" + n.component + ") " + r.width.to_int().to_string() + "x" + r.height.to_int().to_string() + " below touch " + min_desc)
}
}
}
}
}
}
issues
}
///|
/// Norms v2 触控修复:交互组件低于触控最小值的抬到最小值。与
/// norm_touch_issues 同一判据(同一组件集、同一分档、同一 0.5 容差);
/// 只抬 Fixed 维度——Hug/Fill 尺寸策略不得被静默降级为 Fixed。放大可能
/// 引入溢出,由 auto_fix 在本 pass 之后的谓词循环清偿(与文案留白放大同序)。
pub fn norm_touch_fix_pass(
doc : Document,
layout : SolvedLayout,
touch_min? : Double = NORM_TOUCH_MIN,
) -> Array[String] {
let fixes : Array[String] = []
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
None => ()
Some(n) => {
if !norm_is_interactive(n.component) { continue }
let banded = norm_touch_is_banded(n.component)
let min_h = norm_touch_min_h(n.component, touch_min)
match layout.rect_of(id) {
None => ()
Some(r) => {
let w_short = !banded && r.width < touch_min - 0.5
let h_short = r.height < min_h - 0.5
if !w_short && !h_short { continue }
let w_fixed = match n.width {
Fixed(_) => true
_ => false
}
let h_fixed = match n.height {
Fixed(_) => true
_ => false
}
let new_w = if w_short && w_fixed { touch_min } else { r.width }
let new_h = if h_short && h_fixed { min_h } else { r.height }
if new_w != r.width || new_h != r.height {
let why = if banded {
"触控目标 高≥" + min_h.to_int().to_string() + "(chip/tag 档位)"
} else {
"触控目标 44×44"
}
let _ = doc.update_node(id, fn(x) {
{
..x,
width: if new_w != r.width { Fixed(new_w) } else { x.width },
height: if new_h != r.height { Fixed(new_h) } else { x.height },
}
})
fixes.push(
"{\"node\":\"\{json_escape(id)}\",\"action\":\"touch_norm\",\"why\":\"\{why}\",\"before\":\"\{r.width.to_int()}×\{r.height.to_int()}\",\"after\":\"\{new_w.to_int()}×\{new_h.to_int()}\"}",
)
}
}
}
}
}
}
fixes
}
pub fn norm_grid_issues(doc : Document) -> Array[String] {
let issues : Array[String] = []
for id in doc.nodes.keys() {
if issues.length() >= 4 { break }
match doc.nodes.get(id) {
None => continue
Some(n) => {
match n.layout {
None => continue
Some(l) => {
if l.gap > 0.5 && l.gap % NORM_GRID_UNIT != 0.0 {
issues.push(json_escape(id) + " gap " + l.gap.to_string() + " not on 4pt grid")
}
}
}
}
}
}
issues
}
pub fn norm_type_issues(doc : Document) -> Array[String] {
let sizes : Map[Int, Int] = Map::new()
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
None => continue
Some(n) => {
if n.text.length() == 0 { continue }
let fs = n.style.font_size.to_int()
sizes.set(fs, sizes.get(fs).unwrap_or(0) + 1)
}
}
}
let issues : Array[String] = []
if sizes.size() > NORM_TYPE_SCALE_MAX {
let detail = StringBuilder::new()
let mut first = true
for fs in sizes.keys() {
if !first { detail.write_string(" ") }
first = false
detail.write_string(fs.to_string() + "px x" + sizes.get(fs).unwrap_or(0).to_string())
}
issues.push("type scale " + sizes.size().to_string() + " > " + NORM_TYPE_SCALE_MAX.to_string() + " (" + detail.to_string() + ")")
}
issues
}
pub fn norm_density_issues(doc : Document) -> Array[String] {
let issues : Array[String] = []
if doc.nodes.size() > NORM_NODE_BUDGET {
issues.push("node count " + doc.nodes.size().to_string() + " > " + NORM_NODE_BUDGET.to_string())
}
issues
}
///|
/// Norms v2:v1 五规则 + v2 四规则(触控/字号/密度/间距网格)
// ---------------------------------------------------------------------------
// issue #23:视觉可读性硬标准(critique 第 9 维检测项,软路径不进双门)
// ---------------------------------------------------------------------------
///|
/// 正文文字最小对比度(WCAG AA 正文 4.5)。
pub const NORM_TEXT_CONTRAST_MIN : Double = 4.5
///|
/// 大字号(>=24px)放宽到 WCAG AA 大文本 3.0。
pub const NORM_TEXT_CONTRAST_LARGE_MIN : Double = 3.0
///|
/// 纯黑/近黑灰背景禁令:灰度(通道差 < 8)且相对亮度 < 0.01
/// (覆盖 #000000–#151515 一类硬填黑,暗色主题的蓝调深底不受影响)。
pub const NORM_PURE_DARK_LUMA_MAX : Double = 0.01
///|
fn norm_rgb_spread(hex : String) -> Int {
let (r, g, b) = parse_hex_rgb(hex)
let mx = if r > g {
if r > b {
r
} else {
b
}
} else if g > b {
g
} else {
b
}
let mn = if r < g {
if r < b {
r
} else {
b
}
} else if g < b {
g
} else {
b
}
(mx - mn).to_int()
}
///|
pub fn norm_is_pure_dark(hex : String) -> Bool {
norm_is_hex6(hex) &&
norm_rgb_spread(hex) < 8 &&
norm_luminance(hex) < NORM_PURE_DARK_LUMA_MAX
}
///|
/// issue #23 检测项(Project 级):组件状态可辨识。
/// 选中态(selected/checked/active)补丁必须改变至少一个视觉字段,
/// 否则「选中」在演示时不可辨识;选中色同样受纯黑禁令约束。
pub fn norm_selected_state_issues(self : Project, artboard~ : String) -> Array[String] {
let issues : Array[String] = []
match self.artboards.get(artboard) {
None => ()
Some(ab) =>
for node_id in ab.doc.nodes.keys() {
match self.node_states.get(node_id) {
None => ()
Some(def) =>
for state_name in ["selected", "checked", "active"].iter() {
match def.states.get(state_name) {
None => ()
Some(patch) => {
let sets_visual = patch.fill is Some(_) ||
patch.text_color is Some(_) ||
patch.stroke is Some(_) ||
patch.corner_radius is Some(_) ||
patch.font_size is Some(_)
if !sets_visual {
issues.push(
"\{json_escape(node_id)} 的「\{state_name}」态未设置任何视觉字段 → 选中态与默认态无差异(至少改 fill/text_color/stroke)",
)
}
match patch.fill {
Some(f) =>
if norm_is_pure_dark(f) {
issues.push(
"\{json_escape(node_id)} 的「\{state_name}」态选中色 \{f} 为纯黑/近黑灰 → 换品牌色或令牌色",
)
}
None => ()
}
}
}
}
}
}
}
issues
}
///|
/// issue #23 检测项(doc 级):
/// 1) 可读性对比度:正文 < 4.5、大字号(>=24px)< 3.0——2.0 以下已由 v1
/// 撞色规则报告并 auto_fix,这里补 [2.0, need) 区间(检测项,给改法)。
/// 2) 组件不可见:叶子节点纯色填充与父背景完全同色、无描边、无文本。
/// 3) 纯黑/近黑灰背景:有子节点的容器硬填灰度近黑(生硬刺眼,用户明确不要)。
pub fn norm_readability_issues(
doc : Document,
layout : SolvedLayout,
contrast_min? : Double = NORM_TEXT_CONTRAST_MIN,
) -> Array[String] {
let issues : Array[String] = []
for id in doc.nodes.keys() {
if issues.length() >= 8 {
break
}
match doc.nodes.get(id) {
None => ()
Some(n) => {
let is_frame = match n.kind {
Frame => true
_ => false
}
if n.text.length() > 0 {
let bg = norm_effective_bg(doc, id, n.style.fill)
if norm_is_hex6(bg) && norm_is_hex6(n.style.text_color) {
let cr = norm_contrast_ratio(
norm_luminance(bg),
norm_luminance(n.style.text_color),
)
let large = n.style.font_size >= 24.0
let need = if large {
// 大字号仍放宽(默认 3.0;spec 收紧时按 2/3 比例跟随)
let scaled = contrast_min * 2.0 / 3.0
if scaled > NORM_TEXT_CONTRAST_LARGE_MIN {
scaled
} else {
NORM_TEXT_CONTRAST_LARGE_MIN
}
} else {
contrast_min
}
if cr >= NORM_CONTRAST_MIN && cr < need {
issues.push(
"\{json_escape(id)}(\{n.component}「\{json_escape(n.text)}」)文字对比度 \{cr} < 可读性标准 \{need}(\{bg} 底 \{n.style.text_color} 字)→ 加深文字或调整背景(检测项,不自动改色)",
)
continue
}
}
}
// 不可见判定只看非 Frame 叶子:空 Frame 是合法占位容器(懒放置的
// card 等),同色不代表不可见。
if !is_frame &&
n.text.length() == 0 &&
n.children.length() == 0 &&
n.style.stroke == "none" &&
norm_is_hex6(n.style.fill) {
let pbg = norm_effective_bg(doc, id, "none")
if norm_is_hex6(pbg) && n.style.fill.to_lower() == pbg.to_lower() {
issues.push(
"\{json_escape(id)}(\{n.component})组件不可见:填充与父背景同为 \{pbg} 且无描边/文本 → 换填充或删除该节点",
)
continue
}
}
// 背景角色 = 有子节点的容器或 Frame 类(card/frame 容器)
if (n.children.length() > 0 || is_frame) && norm_is_pure_dark(n.style.fill) {
issues.push(
"\{json_escape(id)}(\{n.component})纯黑/近黑灰背景 \{n.style.fill} → 深色底改用主题令牌(暗色主题画布色),不要硬填 #000/#111",
)
}
}
}
}
issues
}
pub fn norm_issues_v2(
doc : Document,
layout : SolvedLayout,
radius_base? : Double = NORM_RADIUS_BASE,
contrast_min? : Double = NORM_TEXT_CONTRAST_MIN,
touch_min? : Double = NORM_TOUCH_MIN,
) -> Array[String] {
let issues = norm_issues(doc, layout, radius_base~)
for issue in norm_touch_issues(doc, layout, touch_min~).iter() {
if issues.length() < 8 { issues.push(issue) }
}
for issue in norm_type_issues(doc).iter() {
if issues.length() < 8 { issues.push(issue) }
}
for issue in norm_density_issues(doc).iter() {
if issues.length() < 8 { issues.push(issue) }
}
for issue in norm_grid_issues(doc).iter() {
if issues.length() < 8 { issues.push(issue) }
}
// issue #23:可读性硬标准(对比度 / 不可见 / 纯黑底),检测项
for issue in norm_readability_issues(doc, layout, contrast_min~).iter() {
if issues.length() < 8 { issues.push(issue) }
}
issues
}