// ---------------------------------------------------------------------------
// issue #24:规范驱动生成(spec 在场 = 第一权威;spec 缺失 = 显式要求)
//
// spec 契约(JSON 字符串,schema 对齐 DESIGN.md 载荷):
// { "v":1,
// "palette": { "primary": "#..", "background": "#..", "surface": "#..",
// "text": "#..", "muted": "#..", "danger": "#..", "success": "#.." },
// "typography": { "display/h1/h2/h3/h4/body/caption/overline": 数字 },
// "spacing": [..], "radius": { "sm":.., "md":.., "lg":.. },
// "components": {..}, "rules": [..] }
// 应用语义:调色板 → token 覆盖 + recolor_with 重着色;字号阶 → 节点
// font_size 按默认阶到 spec 阶重映射;圆角档 → 8/10→sm、12→md、16→lg
// (999 保留正圆语义)。规则数组只随信封上报,不做硬编码裁决。
// ---------------------------------------------------------------------------
///|
/// spec 缺失时的可编辑草案(design_spec_required 随行返回)。
/// 从 intent 抽取推荐强调色;其余取引擎基线,Agent 落盘后重试 generate。
pub fn design_spec_draft_json(intent_text : String) -> String {
let primary = if intent_text.contains("健身") ||
intent_text.contains("健康") ||
intent_text.contains("运动") {
"#10B981"
} else if intent_text.contains("电商") ||
intent_text.contains("商品") ||
intent_text.contains("购物") {
"#F97316"
} else {
"#4B6BFB"
}
"{\"v\":1,\"palette\":{\"primary\":\"" +
primary +
"\",\"background\":\"#FFFFFF\",\"surface\":\"#F6F7F9\",\"text\":\"#1A1C1E\",\"muted\":\"#6B7280\",\"danger\":\"#DC2626\",\"success\":\"#16A34A\"},\"typography\":{\"display\":48,\"h1\":32,\"h2\":24,\"h3\":20,\"h4\":16,\"body\":14,\"caption\":12},\"spacing\":[4,8,12,16,24,32],\"radius\":{\"sm\":8,\"md\":12,\"lg\":16},\"rules\":[\"按钮不卡文案(宽≥文案+24)\",\"撞色零容忍\",\"齐缝列表行分隔\",\"触控目标 44\"]}"
}
///|
fn ds_is_blank(s : String) -> Bool {
s.trim() == ""
}
///|
/// spec JSON 中 key 的字符串值(首个 "key" 后的 "...")。
fn ds_string_value(json : String, key : String) -> String? {
let pat = "\"" + key + "\""
let pos = match json.find(pat) {
None => return None
Some(p) => p
}
let rest = json.substring(start=pos + pat.length())
let colon = match rest.find(":") {
None => return None
Some(c) => c
}
let after = rest.substring(start=colon + 1)
let q1 = match after.find("\"") {
None => return None
Some(q) => q
}
let tail = after.substring(start=q1 + 1)
let q2 = match tail.find("\"") {
None => return None
Some(q) => q
}
Some(tail.substring(start=0, end=q2))
}
///|
/// spec JSON 中 key 的数字值(首个 "key": 后的数字,直到 , 或 } 或换行)。
fn ds_number_value(json : String, key : String) -> Double? {
let pat = "\"" + key + "\""
let pos = match json.find(pat) {
None => return None
Some(p) => p
}
let rest = json.substring(start=pos + pat.length())
let colon = match rest.find(":") {
None => return None
Some(c) => c
}
let after = rest.substring(start=colon + 1)
let mut digits = ""
let mut seen = false
let mut dot_seen = false
for c in after.iter() {
if c >= '0' && c <= '9' {
digits = digits + "\{c}"
seen = true
} else if c == '.' && !dot_seen {
digits = digits + "."
dot_seen = true
} else if c == ' ' || c == '\n' || c == '\r' || c == '\t' {
if seen {
break
}
} else {
break
}
}
if digits == "" {
return None
}
// 手工十进制解析(整数部分 + 小数部分)
let mut int_part = 0.0
let mut frac_part = 0.0
let mut frac_div = 1.0
let mut in_frac = false
for c in digits.iter() {
if c == '.' {
in_frac = true
} else {
let d = (c.to_int() - 48).to_double()
if in_frac {
frac_div = frac_div * 10.0
frac_part = frac_part + d / frac_div
} else {
int_part = int_part * 10.0 + d
}
}
}
Some(int_part + frac_part)
}
///|
fn ds_token_copy(src : DesignTokens) -> DesignTokens {
let t : DesignTokens = {
colors: Map::new(),
spacing: Map::new(),
radii: Map::new(),
typography: Map::new(),
}
for k in src.colors.keys() {
match src.colors.get(k) {
Some(v) => t.colors.set(k, v)
None => ()
}
}
for k in src.spacing.keys() {
match src.spacing.get(k) {
Some(v) => t.spacing.set(k, v)
None => ()
}
}
for k in src.radii.keys() {
match src.radii.get(k) {
Some(v) => t.radii.set(k, v)
None => ()
}
}
for k in src.typography.keys() {
match src.typography.get(k) {
Some(v) => t.typography.set(k, v)
None => ()
}
}
t
}
///|
fn ds_apply_font_scale(doc : Document, spec : String) -> Unit {
// 默认阶 → spec 阶:按默认 typography 的值反查档位,再取 spec 同档值。
let base = DesignTokens::default()
let pairs : Array[(Double, Double)] = []
for key in base.typography.keys() {
let old = match base.typography.get(key) {
Some(v) => v
None => -1.0
}
match ds_number_value(spec, key) {
Some(nv) =>
if old > 0.0 && nv > 0.0 && nv != old {
pairs.push((old, nv))
}
None => ()
}
}
if pairs.length() == 0 {
return
}
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
None => ()
Some(n) => {
let fs = n.style.font_size
for pair in pairs.iter() {
let (old, nv) = pair
if fs > old - 0.5 && fs < old + 0.5 {
let _ = doc.update_node(id, fn(x) {
{ ..x, style: { ..x.style, font_size: nv, } }
})
break
}
}
}
}
}
}
///|
fn ds_apply_radius(doc : Document, spec : String) -> Unit {
let sm = ds_number_value(spec, "sm")
let md = ds_number_value(spec, "md")
let lg = ds_number_value(spec, "lg")
for id in doc.nodes.keys() {
match doc.nodes.get(id) {
None => ()
Some(n) => {
let r = n.style.corner_radius
let nr = if r > 7.5 && r < 8.5 {
sm
} else if r > 9.5 && r < 10.5 {
sm
} else if r > 11.5 && r < 12.5 {
md
} else if r > 15.5 && r < 16.5 {
lg
} else {
None
}
match nr {
Some(v) =>
if v != r {
let _ = doc.update_node(id, fn(x) {
{ ..x, style: { ..x.style, corner_radius: v, } }
})
}
None => ()
}
}
}
}
}
///|
/// spec 应用(issue #24):调色板 → token 覆盖 + 重着色;字号阶/圆角档按
/// spec 规范化。spec 空白 → design_spec_required(不静默兜底)。
pub fn Project::apply_design_spec(
self : Project,
artboard~ : String,
spec~ : String,
) -> Result[Unit, String] {
if ds_is_blank(spec) {
return Err("design_spec_required")
}
match self.artboards.get(artboard) {
None => return Err("unknown_artboard: " + artboard)
Some(_) => ()
}
// 1) 调色板 → token 覆盖(set_token 内建 tokens_snapshot + recolor_with
// 通道,逐角色重着色;无需额外整体 recolor)
let mut touched = false
let roles : Array[(String, String)] = [
("primary", "primary"),
("background", "background"),
("surface", "surface"),
("text", "text_primary"),
("muted", "text_secondary"),
("danger", "error"),
("success", "success"),
]
for pair in roles.iter() {
let (spec_key, token_role) = pair
match ds_string_value(spec, spec_key) {
Some(v) =>
if v != "" && norm_is_hex6(v) {
match self.set_token(token_role, v) {
Ok(_) => touched = true
Err(_) => ()
}
}
None => ()
}
}
if touched {
self.revision = self.revision + 1
}
// 2) 字号阶与圆角档
match self.artboards.get(artboard) {
None => ()
Some(ab) => {
ds_apply_font_scale(ab.doc, spec)
ds_apply_radius(ab.doc, spec)
}
}
// 3) 记录生效 spec:norms 档位判定以 spec 为准(issue #24 第 2 条)
self.design_spec = spec
Ok(())
}
///|
/// 按钮族圆角基准(norms 用):spec.radius.sm 在场取 spec,否则内置 8。
pub fn Project::design_spec_radius_base(self : Project) -> Double {
if self.design_spec.trim() == "" {
return NORM_RADIUS_BASE
}
match ds_number_value(self.design_spec, "sm") {
Some(v) => if v > 0.0 { v } else { NORM_RADIUS_BASE }
None => NORM_RADIUS_BASE
}
}
///|
/// 抽取一段以 '[' 开头的数组文本里的引号 token。
fn ds_tokens_in_brackets(s : String) -> Array[String] {
let out : Array[String] = []
let open = match s.find("[") {
None => return out
Some(o) => o
}
let body = s.substring(start=open + 1)
let close = match body.find("]") {
None => body
Some(c) => body.substring(start=0, end=c)
}
let mut cur = ""
let mut in_q = false
for c in close.iter() {
if c == '"' {
if in_q {
if cur != "" {
out.push(cur)
}
cur = ""
}
in_q = !in_q
} else if in_q {
cur = cur + "\{c}"
}
}
out
}
///|
/// 从 key 后的首个 '[' … ']' 抽取引号内 token(components/rules 列表解析用)。
fn ds_quoted_list(spec : String, key : String) -> Array[String] {
let out : Array[String] = []
let pat = "\"" + key + "\""
let pos = match spec.find(pat) {
None => return out
Some(p) => p
}
let rest = spec.substring(start=pos + pat.length())
let open = match rest.find("[") {
None => return out
Some(o) => o
}
let body = rest.substring(start=open + 1)
let close = match body.find("]") {
None => return out
Some(c) => c
}
let span = body.substring(start=0, end=close)
let mut cur = ""
let mut in_q = false
for c in span.iter() {
if c == '"' {
if in_q {
if cur != "" {
out.push(cur)
}
cur = ""
}
in_q = !in_q
} else if in_q {
cur = cur + "\{c}"
}
}
out
}
///|
/// spec.components 策略:数组 = 白名单;对象支持 allow/deny 数组。
pub fn ds_components_policy(spec : String) -> (Array[String], Array[String]) {
let allow : Array[String] = []
let deny : Array[String] = []
if spec.trim() == "" {
return (allow, deny)
}
let pat = "\"components\""
let pos = match spec.find(pat) {
None => return (allow, deny)
Some(p) => p
}
let rest = spec.substring(start=pos + pat.length())
let colon = match rest.find(":") {
None => return (allow, deny)
Some(c) => c
}
let after = rest.substring(start=colon + 1).trim().to_string()
if after.has_prefix("[") {
// 数组形态 = 白名单:直接抽取方括号内的引号 token
for v in ds_tokens_in_brackets(after).iter() {
allow.push(v)
}
return (allow, deny)
}
for v in ds_quoted_list(spec, "allow").iter() {
allow.push(v)
}
for v in ds_quoted_list(spec, "deny").iter() {
deny.push(v)
}
(allow, deny)
}
///|
/// spec 组件白名单/禁用项校验(issue #24 要求 2:规范是生成的第一权威)。
/// 结构组件(root/frame)不参与判定。
pub fn Project::design_spec_component_violations(
self : Project,
artboard~ : String,
) -> Array[String] {
let out : Array[String] = []
if self.design_spec.trim() == "" {
return out
}
let (allow, deny) = ds_components_policy(self.design_spec)
if allow.length() == 0 && deny.length() == 0 {
return out
}
match self.artboards.get(artboard) {
None => ()
Some(ab) =>
for id in ab.doc.nodes.keys() {
if id == ab.doc.root_id {
continue
}
match ab.doc.nodes.get(id) {
None => ()
Some(n) => {
if n.component == "frame" || n.component == "" {
continue
}
let mut banned = false
for d in deny.iter() {
if d == n.component {
banned = true
}
}
if banned {
out.push("\{json_escape(id)} 组件「\{n.component}」命中 spec 禁用项")
continue
}
if allow.length() > 0 {
let mut ok = false
for a in allow.iter() {
if a == n.component {
ok = true
}
}
if !ok {
out.push("\{json_escape(id)} 组件「\{n.component}」不在 spec 白名单")
}
}
}
}
}
}
out
}
///|
/// marker 后首个数字(rules[] 结构化阈值解析用:对比度≥N / 触控 N)。
fn ds_number_after_marker(text : String, marker : String) -> Double? {
let pos = match text.find(marker) {
None => return None
Some(p) => p
}
let rest = text.substring(start=pos + marker.length())
let mut digits = ""
let mut started = false
for c in rest.iter() {
if c >= '0' && c <= '9' {
digits = digits + "\{c}"
started = true
} else if c == '.' && started {
digits = digits + "."
} else if started {
break
} else if c == '>' || c == '=' || c == '≥' || c == ' ' || c == ':' || c == ':' {
continue
} else {
continue
}
}
if digits == "" {
return None
}
match ds_number_value("{\"n\":" + digits + "}", "n") {
Some(v) => Some(v)
None => None
}
}
///|
/// issue #24:norms 阈值画像——一次解析 design_spec 得到三档,供
/// critique/auto_fix/norms 各检测与修复通道共享(消除链上重复解析)。
pub(all) struct NormsProfile {
radius_base : Double
contrast_min : Double
touch_min : Double
}
///|
pub fn Project::norms_profile(self : Project) -> NormsProfile {
let base = DesignTokens::default()
let radius_base = match ds_number_value(self.design_spec, "sm") {
Some(v) => if v > 0.0 { v } else { NORM_RADIUS_BASE }
None => NORM_RADIUS_BASE
}
// 角色标记优先:正文对比度 → 泛化对比度 → 内置
let contrast_min = match ds_number_after_marker(self.design_spec, "正文对比度") {
Some(v) => v
None =>
match ds_number_after_marker(self.design_spec, "对比度") {
Some(v) => v
None => NORM_TEXT_CONTRAST_MIN
}
}
let contrast_min = if contrast_min >= 1.0 && contrast_min <= 21.0 {
contrast_min
} else {
NORM_TEXT_CONTRAST_MIN
}
let touch_min = match ds_number_after_marker(self.design_spec, "触控") {
Some(v) => if v >= 20.0 && v <= 96.0 { v } else { NORM_TOUCH_MIN }
None => NORM_TOUCH_MIN
}
let _ = base.typography
{ radius_base, contrast_min, touch_min, }
}
///|
/// rules[] 中的可读性对比度阈值(issue #24 要求 2:norms 以 spec 为准)。
pub fn Project::design_spec_text_contrast(self : Project) -> Double {
if self.design_spec.trim() == "" {
return NORM_TEXT_CONTRAST_MIN
}
// 角色标记优先("正文对比度 4.5" 先于泛化 "对比度 3.0")——rules 多条
// 阈值并存时首标记不再覆盖正文档
match ds_number_after_marker(self.design_spec, "正文对比度") {
Some(v) => return if v >= 1.0 && v <= 21.0 { v } else { NORM_TEXT_CONTRAST_MIN }
None => ()
}
match ds_number_after_marker(self.design_spec, "对比度") {
Some(v) => if v >= 1.0 && v <= 21.0 { v } else { NORM_TEXT_CONTRAST_MIN }
None => NORM_TEXT_CONTRAST_MIN
}
}
///|
/// rules[] 中的触控目标下限(默认 44)。
pub fn Project::design_spec_touch_min(self : Project) -> Double {
if self.design_spec.trim() == "" {
return NORM_TOUCH_MIN
}
match ds_number_after_marker(self.design_spec, "触控") {
Some(v) => if v >= 20.0 && v <= 96.0 { v } else { NORM_TOUCH_MIN }
None => NORM_TOUCH_MIN
}
}