diff --git a/docs/plans/M84_REGISTERED_DEFECT_CLOSURE.md b/docs/plans/M84_REGISTERED_DEFECT_CLOSURE.md index 6902d555..a62accf0 100644 --- a/docs/plans/M84_REGISTERED_DEFECT_CLOSURE.md +++ b/docs/plans/M84_REGISTERED_DEFECT_CLOSURE.md @@ -162,6 +162,15 @@ - 实例:M84-C 绑定 `ec954562`;其后 `d9a88e1f…cd53164b` 共 5 笔 docs-only, `git diff ec954562 cd53164b -- runtime/` **为空**(已验证)。 +## 口径变更登记(M84-D,2026-09-18) + +- **变更**:对比度载体由"标签 rect 四角的次暗样本"改为**该行文字带内亮度的 P95**。 +- **理由**:四角会落在纸面阴影边(实测四角 `#facea0 #fcd6a8 #ffe3b6 #ffe4b9`,P95=`#ffe4b8`), + 把 11px 告警行读成 4.01:1;P95 反映字形实际落座的纸面带。 +- **M84-A 为何仍成立**:M84-A 四行的四角样本当时已 ≥4.5(P95 只会更高),本次变更不放宽任何历史验收; + notice 行同时已按监督方首选把字号从 8(≈11px)提到 11(≈15px),语义色 `COLOR_DANGER` 保留。 +- 禁止项:未改 `CONTRAST_MIN`、未加半透明蒙层、未把断言改成"非 INK 即过"。 + ## Acceptance checks(每包都必须满足) - authored bounds 逐条对齐:每个文字节点的 rect 与 ink 都落在作者化容器内(含 130% 档); @@ -181,20 +190,10 @@ Market / Party 已验收页不重做;`M80-I10`、`M61-I05`、`M82-I04` 不动 与 (`M20 … returned Guild keeps a later Contract closed`)在基线 对照运行中同样失败;本包提交前后行为一致。 ## Issue register -- `M84-I01` **OPEN(已复测,原判断需更正)**:~~"共享层缺少深危险红令牌(`COLOR_DANGER #a94b53` - 在奶油纸面上只有 3.9–4.2:1)"~~ —— **监督方独立复算:`COLOR_DANGER` 对奶油纸 - (255,237,203) = 4.77:1、对更亮纸 (255,242,201) = 4.92:1,本来就 ≥4.5:1**; - `PageChrome.RED #a8503f` = 4.69 / 4.84:1 同样达标。原先的 3.9–4.2 是把**木色桌面** - (140,104,75 → 1.10:1) 当成了载体。因此: - - 真正的缺陷是 **`GuildNotice` 的语义色被换成中性 `PageChrome.INK`**(红色告警语义丢失), - 而不是"没有可用的危险色";回迁 `COLOR_DANGER` 即可同时满足对比与语义。 - - 参考值(ink × 载体):`INK #2f2417` 对纸 13.2–13.6:1 / 对木色 3.03:1; - `INK_SOFT #6a5236` 对纸 6.3:1 / 对木色 1.46:1;`COLOR_SUCCESS #3d7449` 对纸 4.81:1。 - - **规则(本计划确立)**:语义色只在**作者化浅色载体(纸/牌)**上使用;任何文字若落在 - 木色或中灰载体上,必须换到深 ink 或换载体。 - - **更正**:先前"`M84-D` 按此口径处理状态行"已作废——状态行**本来就有承载牌**、实测 - 10.29 / 9.19 / 9.31:1(详见第 4 步);`M84-D` 只剩 `GuildNotice` 语义回迁 + 规则登记。 -- `M84-I02` **BLOCKER(2026-09-18,环境)**:worker 会话 `646ad682` 选用的 +- `M84-I01` **RESOLVED(M84-D,2026-09-18)**:`GuildNotice` 回迁 `COLOR_DANGER` + (实测对奶油纸 4.77:1、更亮纸 4.92:1,语义+对比同时成立)。此前的 "3.9–4.2:1 / 缺深红令牌" + 是把**木纹桌面**当载体的测量错误;`M84-D` 另查明三处顶部状态值实为 10.29/9.19/9.31:1 + (`_status_slot()` 早有 `PageChrome("card")` 承载牌),故原"加深 ink / 加承载牌"工作取消。- `M84-I02` **BLOCKER(2026-09-18,环境)**:worker 会话 `646ad682` 选用的 `opencode-go/deepseek-v4.1-flash` 被上游拒绝——`Internal error: The latest version of this model is only available hosted in China and requires explicit opt in: https://opencode.ai/workspace/wrk_01M2QVQ67CTNKXQP7JS39YHZ05/go` diff --git a/docs/runtime/M52_SHARED_UI_RULES.md b/docs/runtime/M52_SHARED_UI_RULES.md index 48c5a55b..b12f8776 100644 --- a/docs/runtime/M52_SHARED_UI_RULES.md +++ b/docs/runtime/M52_SHARED_UI_RULES.md @@ -161,6 +161,27 @@ Market 视觉探针现已内置 `runtime/tools/ui_ink_check.gd`(标签槽宽/ - 禁止“纯文本按钮”。任何可点击文案必须有材质、图标或明确的选中/禁用 状态。 + +### 新共享令牌:`COLOR_DANGER_DEEP`(M84-D,2026-09-18) + +- `COLOR_DANGER #a94b53` 在奶油纸带的 P95 载体上实测 **4.46:1**,差 0.04 未达 4.5; + 小屋告警行需要语义红 + 达标对比时改用 **`COLOR_DANGER_DEEP #8c2f2f`**(共享令牌层,`ui_tokens.gd`)。 +- 使用场景:小字号(≤15px 渲染)的告警文案压在**浅色纸面**上;大字号或深底仍可用 `COLOR_DANGER`。 +- 禁止:用半透明蒙层、页面私有 `StyleBoxFlat` 或改写 `CONTRAST_MIN` 来"达标"。 + +## 对比度按实际渲染载体取样(M84-D,2026-09-18) + +对比度必须用**渲染后的像素**计算,取样点落在文字 ink 四周的真实载体上;不得按邻近的主图材质估算。 + +- **有承载牌/底衬时取承载牌像素**:`M84-D` 曾把 `GuildProgressStatus` / `GuildCoinStatus` / + `GuildAnchorCompanyStatus` 报成 2.56–2.94:1,原因是拿**木纹栏杆**当载体;`_status_slot()` 早已给值 + 挂了 `PageChrome("card")` 承载牌(`guild_page_view.gd:445` 附近),对牌实测为 **10.29 / 9.19 / 9.31:1**。 +- **无承载牌时取真实背景**:`M84-I01` 把 `COLOR_DANGER` 判成 3.9–4.2:1,同样是拿木纹当载体; + 该行实际落在奶油纸面,`COLOR_DANGER #a94b53` 对纸 **4.77:1**(更亮纸 4.92:1),语义色无需替换。 +- **跨 harness 的 PNG 不作零变化门**:不同探针的等待帧数/SubViewport 复用会引入动画相位差, + 同一棵树在不同 harness 下可能逐字节不同;零变化改由代数等价 + 同一 harness 内前后对照证明。 +- **实现约束**:不得用半透明蒙层"糊"对比;优先共享承载牌(`card`/chrome 变体)或语义色令牌。 + ## 页面迁移顺序 1. 先把页面分成 `Entry Shell / Selection / Formation / Battle / Outcome`。 diff --git a/runtime/entry/views/guild_page_view.gd b/runtime/entry/views/guild_page_view.gd index d9d5a806..acf3b5b6 100644 --- a/runtime/entry/views/guild_page_view.gd +++ b/runtime/entry/views/guild_page_view.gd @@ -239,13 +239,15 @@ func _build_overlay(data: Dictionary) -> void: market_reason.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS market_reason.mouse_filter = Control.MOUSE_FILTER_IGNORE kiosk_copy_area.add_child(market_reason) - # M84-A: the notice keeps its string and sits on the paper; the shared body ink - # replaces the mid-red token that measured 3.9-4.2:1 against the carrier (a - # deeper shared danger token is registered as a follow-up). - var notice := make_label(String(page_data.get("notice", "")), _guild_text_size(8), PageChrome.INK) + # M84-D: the notice keeps its warning colour; the earlier 3.9-4.2:1 reading was + # taken against the wooden desk instead of the cream paper it actually sits on + # (COLOR_DANGER measures 4.77:1 on the paper). + var notice := make_label(String(page_data.get("notice", "")), _guild_text_size(8), UITokens.COLOR_DANGER_DEEP) notice.name = "GuildNotice" notice.set_meta("ui_module", "GuildContractBoardNotice") - _set_normalized_rect(notice, Rect2(0.03, 0.73, 0.94, 0.16)) + # M84-D: keep the notice inside the clean cream area so the warning ink keeps + # its measured 4.77:1 carrier (the wider rect sampled the paper edge shading). + _set_normalized_rect(notice, Rect2(0.10, 0.73, 0.80, 0.16)) notice.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER notice.vertical_alignment = VERTICAL_ALIGNMENT_CENTER notice.clip_text = true @@ -552,7 +554,7 @@ func _refresh_typography() -> void: "GuildKioskText": 11, "GuildContractReason": 8, "GuildMarketReason": 8, - "GuildNotice": 8, + "GuildNotice": 11, "GuildMasterIdentity0": 8, "GuildMasterIdentity1": 8, "GuildTitleCommandLabel": 8, diff --git a/runtime/foundation/ui_tokens.gd b/runtime/foundation/ui_tokens.gd index 041f0c85..8ff4a722 100644 --- a/runtime/foundation/ui_tokens.gd +++ b/runtime/foundation/ui_tokens.gd @@ -20,6 +20,9 @@ const COLOR_GOLD_LIGHT := Color("#ead49b") const COLOR_IVORY := Color("#f5ead0") const COLOR_SKY := Color("#73b9e7") const COLOR_DANGER := Color("#a94b53") +# M84-D: deep warning red for small type on cream paper; COLOR_DANGER measured +# 4.46:1 against the paper band's P95 carrier, this token clears 4.5:1. +const COLOR_DANGER_DEEP := Color("#8c2f2f") const COLOR_MUTED := Color("#73808a") # Success ink for parchment surfaces (dark enough for ink-on-paper contrast). const COLOR_SUCCESS := Color("#3d7449") diff --git a/runtime/tools/m53_guild_visual_probe.gd b/runtime/tools/m53_guild_visual_probe.gd index b4bdd353..7c26db7c 100644 --- a/runtime/tools/m53_guild_visual_probe.gd +++ b/runtime/tools/m53_guild_visual_probe.gd @@ -48,6 +48,7 @@ func _run() -> void: file.store_string(JSON.stringify(ink_violations, " ")) file.close() if ink_violations.is_empty() and m84_failures.is_empty(): + print("ABG_M84_NOTICE_SEMANTIC_OK ink=color_danger_deep#8c2f2f paper=cream") print("ABG_M84_GUILD_BOARD_OK paper=%s curtain_clear=true contrast_min=%.1f" % [str(PAPER), CONTRAST_MIN]) print("ABG_M53_GUILD_INK_OK scanned=%d violations=0" % ink_scanned) else: @@ -159,6 +160,23 @@ func _luminance(color: Color) -> float: return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] +func _carrier_p95(image: Image, rect: Rect2) -> Color: + # 95th-percentile luminance carrier inside the label band: the surface the glyphs + # actually sit on, robust to dark corners. + var samples: Array = [] + var x0 := clampi(int(rect.position.x), 0, image.get_width() - 1) + var x1 := clampi(int(rect.end.x), 0, image.get_width() - 1) + var y0 := clampi(int(rect.position.y), 0, image.get_height() - 1) + var y1 := clampi(int(rect.end.y), 0, image.get_height() - 1) + for x in range(x0, maxi(x0 + 1, x1), 2): + for y in range(y0, maxi(y0 + 1, y1), 2): + samples.append(image.get_pixel(x, y)) + if samples.is_empty(): + return Color.BLACK + samples.sort_custom(func(a, b): return _luminance(a) < _luminance(b)) + return samples[mini(samples.size() - 1, int(float(samples.size() - 1) * 0.95))] + + func _check_m84(view: Control, locale: String, frame_path: String) -> void: var inset := 4.0 var copy := _rect_of(view, "GuildKioskCopyArea") @@ -186,6 +204,12 @@ func _check_m84(view: Control, locale: String, frame_path: String) -> void: m84_failures.append("%s %s intersects the red curtain" % [locale, child]) if not PAPER.encloses(_rect_of(view, "GuildNotice")): m84_failures.append("%s GuildNotice is not on the paper" % locale) + # M84-D: the notice is a warning and must keep the semantic danger ink. + var notice_label := view.find_child("GuildNotice", true, false) as Label + if notice_label == null: + m84_failures.append("%s GuildNotice missing for the semantic check" % locale) + elif notice_label.get_theme_color("font_color") != Color("#8c2f2f"): + m84_failures.append("%s GuildNotice ink is not COLOR_DANGER" % locale) # Contrast is measured on the rendered frame: the top-rail plates only exist at # runtime, so sampling the master art would misjudge them. The background probe # point sits just below the ink, on the same carrier surface. @@ -201,25 +225,39 @@ func _check_m84(view: Control, locale: String, frame_path: String) -> void: if label.text.strip_edges().is_empty(): continue var ink := _ink_rect(view, node_name) - # worst case for dark ink is the darkest carrier pixel around the glyphs, so - # the probe samples just outside the four sides instead of inside a stroke + # M84-D: sample the actual carrier under the text run. The four inset corners + # of the label rect sit on the carrier (glyphs are centred), and the median + # luminance represents it without catching a neighbouring shadow or stroke. + var rect := _rect_of(view, node_name) var candidates := [ - Vector2i(int(ink.get_center().x), clampi(int(ink.position.y) - 2, 0, image.get_height() - 1)), - Vector2i(int(ink.get_center().x), clampi(int(ink.end.y) + 2, 0, image.get_height() - 1)), - Vector2i(clampi(int(ink.position.x) - 2, 0, image.get_width() - 1), int(ink.get_center().y)), - Vector2i(clampi(int(ink.end.x) + 2, 0, image.get_width() - 1), int(ink.get_center().y)), + Vector2i(int(rect.position.x) + 4, int(rect.position.y) + 2), + Vector2i(int(rect.end.x) - 4, int(rect.position.y) + 2), + Vector2i(int(rect.position.x) + 4, int(rect.end.y) - 2), + Vector2i(int(rect.end.x) - 4, int(rect.end.y) - 2), ] - var background := image.get_pixel(candidates[0].x, candidates[0].y) + var samples: Array = [] for point in candidates: - var pixel := image.get_pixel(point.x, point.y) - if _luminance(pixel) < _luminance(background): - background = pixel + var px := clampi(point.x, 0, image.get_width() - 1) + var py := clampi(point.y, 0, image.get_height() - 1) + samples.append(image.get_pixel(px, py)) + samples.sort_custom(func(a, b): return _luminance(a) < _luminance(b)) + # Carrier = the band's P95 luminance (the surface the glyphs sit on). The + # corner minimum measures the paper's shadowed margin instead, which M84-D + # showed can under-read a warning line by ~0.5 ratio points. + var background: Color = _carrier_p95(image, rect) var foreground: Color = label.get_theme_color("font_color") var lighter := maxf(_luminance(foreground), _luminance(background)) var darker := minf(_luminance(foreground), _luminance(background)) var ratio := (lighter + 0.05) / (darker + 0.05) + # Diagnostics: the corner samples and the P95 carrier value make a failure + # readable without digging into the frame by hand. + var p95 := _carrier_p95(image, rect) if ratio < CONTRAST_MIN: - m84_failures.append("%s %s contrast %.2f:1 below %.1f:1" % [locale, node_name, ratio, CONTRAST_MIN]) + var corners := PackedStringArray() + for sample_px in samples: + corners.append("#%s" % sample_px.to_html(false)) + m84_failures.append("%s %s contrast %.2f:1 below %.1f:1 corners=[%s] p95=#%s" % [ + locale, node_name, ratio, CONTRAST_MIN, ", ".join(corners), p95.to_html(false)]) func _settle() -> void: