///|
/// AI 设计批评引擎:像资深设计师一样评审原型。
///
/// 不是检查规则(那是 lint),而是基于**设计原则**给出整体评价:
/// 1. 视觉层次 — 有没有清晰的"第一眼看到什么"
/// 2. 亲密性 — 相关元素是否靠近(格式塔原理)
/// 3. 对齐 — 是否有一致的视觉轴线
/// 4. 一致性 — 相似元素是否有相似处理
/// 5. 留白 — 是否有足够的呼吸空间
/// 6. 平衡 — 视觉重量是否分布合理
/// 7. 焦点 — 是否有明确的主动作(CTA)
/// 8. 节奏 — 间距和尺寸是否有韵律感
///
/// 输出:结构化评审报告(每个原则一个评分 + 具体建议)
pub(all) struct CritiqueResult {
principle : String
score : Int // 0-10
verdict : String // "good" | "fair" | "poor"
observation : String // 观察到什么
advice : String // 怎么改
}
///|
/// 综合评审:对画板进行 8 项设计原则评审
pub fn Project::critique(
self : Project,
artboard~ : String,
) -> Result[String, String] {
match self.artboards.get(artboard) {
None => Err("unknown_artboard")
Some(ab) => {
let results : Array[String] = []
let layout = solve(ab.doc)
let doc = ab.doc
// ── 1. 视觉层次 ──
let font_sizes : Map[Double, Int] = Map::new()
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
Some(n) =>
match font_sizes.get(n.style.font_size) {
Some(c) => font_sizes.set(n.style.font_size, c + 1)
None => font_sizes.set(n.style.font_size, 1)
}
None => ()
}
}
let distinct_sizes = font_sizes.size()
let hierarchy_score = if distinct_sizes >= 3 {
9
} else if distinct_sizes == 2 {
6
} else {
3
}
let hierarchy_verdict = if hierarchy_score >= 7 {
"good"
} else if hierarchy_score >= 5 {
"fair"
} else {
"poor"
}
let hierarchy_obs = "\{distinct_sizes} 种字号"
let hierarchy_advice = if distinct_sizes < 3 {
"增加字号层次:标题用 h2/h3,正文用 body,辅助信息用 caption"
} else {
"层次清晰"
}
results.push(
crit_json(
"visual_hierarchy", hierarchy_score, hierarchy_verdict, hierarchy_obs,
hierarchy_advice,
),
)
// ── 2. 亲密性(Gestalt)──
let mut tight_groups = 0
let mut total_groups = 0
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
Some(n) =>
if n.children.length() > 1 {
total_groups = total_groups + 1
match n.layout {
Some(l) => if l.gap < 4.0 { tight_groups = tight_groups + 1 }
None => ()
}
}
None => ()
}
}
let prox_score = if total_groups == 0 {
5
} else if tight_groups == 0 {
8
} else if tight_groups <= total_groups / 2 {
6
} else {
3
}
let prox_verdict = if prox_score >= 7 {
"good"
} else if prox_score >= 5 {
"fair"
} else {
"poor"
}
results.push(
crit_json(
"proximity",
prox_score,
prox_verdict,
"\{tight_groups}/\{total_groups} 容器间距过紧",
"确保相关元素间距 ≥ 8px,不相关元素间距 ≥ 24px",
),
)
// ── 3. 对齐 ──
let mut aligned_count = 0
let mut total_check = 0
for id in doc.nodes.keys() {
match layout.rect_of(id) {
Some(r) =>
if id != "root" {
total_check = total_check + 1
// 检查左边缘是否对齐到 8px 网格
if r.x % 8.0 < 1.0 || r.x % 8.0 > 7.0 {
aligned_count = aligned_count + 1
}
}
None => ()
}
}
let align_score = if total_check == 0 {
5
} else {
aligned_count * 10 / total_check
}
let align_verdict = if align_score >= 7 {
"good"
} else if align_score >= 5 {
"fair"
} else {
"poor"
}
results.push(
crit_json(
"alignment",
align_score,
align_verdict,
"\{aligned_count}/\{total_check} 节点对齐到网格",
"运行 snap_to_grid(8) 统一对齐",
),
)
// ── 4. 一致性(颜色)──
let mut token_count = 0
let mut color_count = 0
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
Some(n) =>
if n.style.fill != "none" && n.style.fill != "" {
color_count = color_count + 1
if is_token_color3(self.tokens, n.style.fill) {
token_count = token_count + 1
}
}
None => ()
}
}
let cons_score = if color_count == 0 {
7
} else {
token_count * 10 / color_count
}
let cons_verdict = if cons_score >= 7 {
"good"
} else if cons_score >= 5 {
"fair"
} else {
"poor"
}
results.push(
crit_json(
"consistency",
cons_score,
cons_verdict,
"\{token_count}/\{color_count} 颜色使用令牌",
"运行 audit_design_system 查看硬编码颜色",
),
)
// ── 5. 留白 ──
let node_count = doc.nodes.size() - 1
let area = ab.width * ab.height
let density = if area > 0.0 {
node_count.to_double() / (area / 10000.0)
} else {
0.0
}
let white_score = if density < 2.0 {
9
} else if density < 4.0 {
7
} else if density < 6.0 {
5
} else {
3
}
let white_verdict = if white_score >= 7 {
"good"
} else if white_score >= 5 {
"fair"
} else {
"poor"
}
results.push(
crit_json(
"whitespace",
white_score,
white_verdict,
"密度 \{density.to_string()} 节点/万px²",
if density > 4.0 {
"减少元素或增大画板"
} else {
"留白充分"
},
),
)
// ── 6. 平衡 ──
let mut left_weight = 0.0
let mut right_weight = 0.0
for id in doc.nodes.keys() {
match layout.rect_of(id) {
Some(r) =>
if id != "root" {
let center_x = r.x + r.width / 2.0
let weight = r.width * r.height
if center_x < ab.width / 2.0 {
left_weight = left_weight + weight
} else {
right_weight = right_weight + weight
}
}
None => ()
}
}
let total_w = left_weight + right_weight
let balance_ratio = if total_w > 0.0 {
left_weight / total_w
} else {
0.5
}
let bal_score = if balance_ratio > 0.3 && balance_ratio < 0.7 {
8
} else if balance_ratio > 0.2 && balance_ratio < 0.8 {
6
} else {
4
}
let bal_verdict = if bal_score >= 7 {
"good"
} else if bal_score >= 5 {
"fair"
} else {
"poor"
}
let left_pct = (balance_ratio * 100.0).to_int().to_string()
let right_pct = ((1.0 - balance_ratio) * 100.0).to_int().to_string()
let bal_obs = "左" + left_pct + "% / 右" + right_pct + "%"
let bal_advice = if bal_score < 6 {
"调整元素分布使左右视觉重量更均衡"
} else {
"平衡良好"
}
results.push(
crit_json("balance", bal_score, bal_verdict, bal_obs, bal_advice),
)
// ── 7. 焦点 ──
let mut has_primary_cta = false
let mut button_count = 0
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
Some(n) =>
if n.component == "button" {
button_count = button_count + 1
if n.style.fill == self.tokens.resolve_color("primary") {
has_primary_cta = true
}
}
None => ()
}
}
let focus_score = if has_primary_cta && button_count <= 3 {
9
} else if has_primary_cta {
6
} else if button_count > 0 {
4
} else {
5
}
let focus_verdict = if focus_score >= 7 {
"good"
} else if focus_score >= 5 {
"fair"
} else {
"poor"
}
results.push(
crit_json(
"focus",
focus_score,
focus_verdict,
"\{button_count} 个按钮, 主CTA: \{has_primary_cta}",
if !has_primary_cta {
"为最重要的操作使用 primary 变体按钮"
} else {
"焦点明确"
},
),
)
// ── 8. 节奏 ──
let gaps : Map[Double, Int] = Map::new()
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
Some(n) =>
match n.layout {
Some(l) =>
match gaps.get(l.gap) {
Some(c) => gaps.set(l.gap, c + 1)
None => gaps.set(l.gap, 1)
}
None => ()
}
None => ()
}
}
let distinct_gaps = gaps.size()
let rhythm_score = if distinct_gaps <= 3 {
8
} else if distinct_gaps <= 5 {
6
} else {
4
}
let rhythm_verdict = if rhythm_score >= 7 {
"good"
} else if rhythm_score >= 5 {
"fair"
} else {
"poor"
}
results.push(
crit_json(
"rhythm",
rhythm_score,
rhythm_verdict,
"\{distinct_gaps} 种间距值",
if distinct_gaps > 3 {
"统一间距到标准令牌(4/8/12/16/24)"
} else {
"节奏统一"
},
),
)
// ── 9. 组件规范(norms:按钮不卡文案 · 方正圆润 · 全宽条直角)──
// 与 auto_fix 的 norm_fix_pass 同一判据:critique 指出偏差并给出建议尺寸。
// 建议按来源区分修复路径(norm_issue_hint):v1 五规则/触控/返回在
// auto_fix 的执行范围内(norm_fix_pass / norm_touch_fix_pass /
// list_divider),给 auto_fix 指引;字号阶/密度/gap 网格是 v2 检测项、
// auto_fix 没有对应修复通道——给具体改法,不宣称一键修复。
let prof = self.norms_profile()
let norm_list = norm_issues_v2(
doc,
layout,
radius_base=prof.radius_base,
contrast_min=prof.contrast_min,
touch_min=prof.touch_min,
)
// 返回交互校验(返回是必须的):非入口画板无出边 flow 且无返回类节点
// = 用户会卡死——追加进 norms 维度,与检测/修复同一报告
for s in self.norm_back_issues(artboard=artboard).iter() {
norm_list.push(s)
}
// issue #23:状态可辨识(选中态无差异 / 纯黑选中色)
for s in self.norm_selected_state_issues(artboard=artboard).iter() {
norm_list.push(s)
}
// issue #25/#26:交互与内容完整性(可交互无去向 / 占位符 / 空壳 / 非库组件)
for s in self.qa_issues(artboard=artboard).iter() {
norm_list.push(s)
}
let n_norm = norm_list.length()
let norm_score = if n_norm == 0 {
9
} else if n_norm <= 2 {
7
} else if n_norm <= 4 {
5
} else {
3
}
let norm_verdict = if norm_score >= 7 {
"good"
} else if norm_score >= 5 {
"fair"
} else {
"poor"
}
let norm_advice = if n_norm == 0 {
"组件规范良好(按钮留白 · 圆角档 · 全宽条直角)"
} else {
let mut acc = ""
for i in 0..= 2 {
break
}
acc = acc + norm_list[i] + "→ " + norm_issue_hint(norm_list[i]) + ";"
}
acc
}
results.push(
crit_json("norms", norm_score, norm_verdict, "\{n_norm} 处规范偏差", norm_advice),
)
let j = jstr19(results)
Ok("{\"principles\":9,\"results\":[\{j}]}")
}
}
}
///|
/// 评审摘要(一行)
pub fn Project::critique_summary(
self : Project,
artboard~ : String,
) -> Result[String, String] {
match self.critique(artboard~) {
Err(e) => Err(e)
Ok(json) => {
// 计算平均分
let scores = extract_scores(json)
let total = sum_scores(scores)
let avg = if scores.length() > 0 { total / scores.length() } else { 0 }
let grade = if avg >= 8 {
"A"
} else if avg >= 6 {
"B"
} else if avg >= 4 {
"C"
} else {
"D"
}
Ok("Design Critique: \{grade} (\{avg}/10)")
}
}
}
///|
/// norms 建议按 issue 来源区分修复路径:字号阶(type scale)/密度(node
/// count)/gap 网格(not on 4pt grid)是 v2 检测项,auto_fix 没有对应修复
/// 通道——给具体改法;其余(v1 五规则/触控/返回)给 auto_fix 指引。
fn norm_issue_hint(issue : String) -> String {
if issue.contains("not on 4pt grid") {
"gap 调到 4 的倍数(4/8/12/16)"
} else if issue.contains("type scale") {
"字号收敛到 ≤5 档(如 12/14/16/20/24)"
} else if issue.contains("node count") {
"拆分画板或删减装饰节点"
} else if issue.contains("可读性标准") {
"加深文字或调整背景色(正文 4.5 / 大字号 3.0)"
} else if issue.contains("组件不可见") {
"换填充色或删除该节点"
} else if issue.contains("纯黑") {
"深色底改用主题令牌,不要硬填 #000/#111"
} else if issue.contains("无差异") {
"选中态至少改 fill / text_color / stroke"
} else if issue.contains("可交互组件无去向") {
"绑定 tap flow(flow <板> <目标板> <节点>)或改用非交互组件"
} else if issue.contains("空壳容器") {
"填 ≥2 条语义化示例条目(真实文案,非 Item 1/项目一)"
} else if issue.contains("占位符残留") {
"换语义化真实文案,去掉 TBD/TODO/xxx 一类占位"
} else if issue.contains("非组件库组件") {
"换组件库注册表内的组件(list-components 可查)"
} else {
"auto_fix 可一键修复"
}
}
///|
fn crit_json(
principle : String,
score : Int,
verdict : String,
obs : String,
advice : String,
) -> String {
"{\"principle\":\"\{principle}\",\"score\":\{score},\"verdict\":\"\{verdict}\",\"observation\":\"\{obs}\",\"advice\":\"\{advice}\"}"
}
///|
fn is_token_color3(tokens : DesignTokens, hex : String) -> Bool {
for k in tokens.colors.keys() {
match tokens.colors.get(k) {
Some(v) => if v == hex { return true }
None => ()
}
}
false
}
///|
fn extract_scores(json : String) -> Array[Int] {
let scores : Array[Int] = []
let parts = json.split(",\"score\":")
// 首段是信封头({"principles":9,"results":[…"principle":"…")而非评分,
// 跳过——否则 extract_leading_int 会抓到头部的 9,均值变成 (9+Σ九项)/10,
// 档位边界虚高一级
let mut first = true
for p0 in parts.iter() {
if first {
first = false
continue
}
let num = extract_leading_int(p0.to_string())
if num >= 0 {
scores.push(num)
}
}
scores
}
///|
fn extract_leading_int(s : String) -> Int {
let mut result = 0
let mut found = false
for c in s.iter() {
let code = c.to_int()
if code >= 48 && code <= 57 {
result = result * 10 + (code - 48)
found = true
} else if found {
break
}
}
if found {
result
} else {
-1
}
}
///|
fn sum_scores(scores : Array[Int]) -> Int {
let mut total = 0
for s in scores.iter() {
total = total + s
}
total
}
///|
fn jstr19(parts : Array[String]) -> String {
let sb = StringBuilder::new()
for i in 0.. 0 {
sb.write_string(",")
}
sb.write_string(parts[i])
}
sb.to_string()
}