feat(app-iced): 强调色接线落地(M250 ACCENTWIRE)

appearance.accent_color 此前是死线:配置键、presenter 往返、预设色板都在,但造 tokens 的
唯一入口恒走 DesignTokens::brand(board),用户填了色界面不变。

- theme.rs:Ui::with_accent + accent_scheme(唯一调用 DesignTokens::with_accent 的地方)
- app/state/theme.rs:rebuild_ui 三选一(显式强调色 > D38 取色方案 > 出厂板),diag 一行说清是谁生效
- pages/appearance:新增 accent 状态(原文/解析结果/方案/结构化错误)+ 设置页输入行与预设色块;
  非法值保持上一次合法值并给出 Short/Long/Digit 三档原因,不静默回退出厂色
- presenter::prefs:AccentChoice::parse 成为唯一解析点,set_accent_checked 交回结构化原因
- app/appearance.rs:persist_appearance 带上 accent_color(此前写不进去),只写上一次合法值
- pages/harness/app.rs:gallery 也能吃 ULL_APPEARANCE_ACCENT,供出图矩阵
- core keys.rs:订正「空 = 出厂 #0078D4;accent 令牌全域生效」这句半真半假的说明
- i18n:强调色 7 条文案(链尾新域,金表只追加)

判据:同一组件(进度条 Primary)在空配置与 #006A5C 下色值不同(App 级 + 令牌级两条);
非法值拿到结构化错误、生效色不回退、不写盘、tokens 仍可构造。
This commit is contained in:
AskaEth committed 2026-11-11 14:56:26 +08:00
1 parent 4cdb46d2e0
commit e6b2200360
18 files changed
+787 -38

No files matched your search

@@ -0,0 +1,131 @@
//! 强调色的**运行期状态**(M250 ACCENTWIRE):输入原文 → 解析结果 → 生效方案。
//!
//! 这一层存在的理由:`appearance.accent_color` 此前是死线——键、输入框、presenter 往返都在,
//! 但造 tokens 的唯一入口恒走 `DesignTokens::brand(board)`,用户填了色界面没变化。接线要落地
//! 的不只是「把 hex 喂进 `Scheme::from_accent`」,还有**失败要可见**:
//!
//! | 输入 | 生效配色 | `error` | 落盘值 |
//! |---|---|---|---|
//! | 空串 | 出厂品牌板(`DesignTokens::brand`) | `None` | `""` |
//! | `#006A5C` | `DesignTokens::with_accent`(只换三族) | `None` | `#006A5C` |
//! | `#12` / `zzzzzz` / 超长串 | **保持上一次合法的值**(不静默回退成出厂色) | `Some(..)` | 上一次合法的值 |
//!
//! 「保持上一次合法值」而不是「回退出厂板」是刻意的:回退会让「填错」和「填对但没生效」在屏幕上
//! 长得一模一样,用户唯一的线索就只剩那条错行——而那正是这里要保证不被丢掉的。
//!
//! 解析本身不在这里:`AccentChoice::parse` 是唯一真相(`unlucky_presenter::prefs`),本文件只负责
//! 把结果接到 `Scheme` 与 UI 上。
use unlucky_design::color::{Color, Scheme, SchemeFlavor};
use unlucky_presenter::prefs::{AccentChoice, AccentError};
use crate::theme::accent_scheme;
/// 出图矩阵的强调色开关(`ULL_APPEARANCE_ACCENT=#RRGGBB`;产品路径读 `launcher.json`)。
///
/// 放在本文件而不是 `state.rs` 的 `ENV_*` 那一族:`state.rs` 正好 400 行(硬线),这一条
/// 属于强调色自己,跟 `Accent` 放一起也更好找。语义与其它 `ULL_APPEARANCE_*` 一致——只影响这次进程。
pub const ENV_ACCENT: &str = "ULL_APPEARANCE_ACCENT";
/// 强调色的运行期状态(`App` 经 `pages::appearance::State` 持有一份)。
#[derive(Debug, Clone, PartialEq)]
pub struct Accent {
/// 输入框的原文(受控控件要它;非法输入也必须原样留着,否则用户看不见自己错在哪)。
text: String,
/// 上一次**合法**的解析结果(非法输入不改它 —— 生效色因此不会跳回出厂板)。
choice: AccentChoice,
/// 解析出来的强调色(`None` = 出厂板,强调色由品牌色板决定)。
color: Option<Color>,
/// 非法输入的结构化原因(`None` = 没填或填得合法)。
error: Option<AccentError>,
/// 合法自定义色时的方案(`Ui` 直接用它;`None` = 走出厂品牌板)。
scheme: Option<Scheme>,
}
impl Accent {
/// 从配置读一份(启动时调一次)。配置里存着非法值时**也**亮错行,不静默吞掉。
#[must_use]
pub fn new(text: &str, flavor: SchemeFlavor) -> Self {
let mut accent = Self {
text: text.to_owned(),
choice: AccentChoice::Factory,
color: None,
error: None,
scheme: None,
};
accent.retry(flavor);
accent
}
/// 设置页改了一次输入(受控输入框:每敲一个字符都走这里)。
pub fn set(&mut self, text: &str, flavor: SchemeFlavor) {
self.text = text.to_owned();
self.retry(flavor);
}
/// 配置被外部改写后重置(热切:CLI / AI 改完 1 秒内生效)。
pub fn reset(&mut self, text: &str, flavor: SchemeFlavor) {
*self = Self::new(text, flavor);
}
/// 深浅切了:强调族要按新模式重派生(表面色仍由 `State::refresh_palette` 那条路管)。
pub fn set_flavor(&mut self, flavor: SchemeFlavor) {
self.scheme = self.color.map(|accent| accent_scheme(flavor, accent));
}
/// 输入框原文。
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
/// 结构化失败原因(`None` = 合法)。
#[must_use]
pub fn error(&self) -> Option<AccentError> {
self.error
}
/// 解析出来的强调色(`None` = 出厂板;日志与截图判据读它)。
#[must_use]
pub fn color(&self) -> Option<Color> {
self.color
}
/// 生效的强调色方案(`Ui` 的唯一入口:`Some` ⇒ 走 [`crate::theme::Ui::with_accent`] 的同一条路)。
#[must_use]
pub fn scheme(&self) -> Option<&Scheme> {
self.scheme.as_ref()
}
/// 落盘值:`""` = 出厂,`#RRGGBB` = 自定义。**永远是上一次合法值**——非法手输不写盘。
#[must_use]
pub fn persist_value(&self) -> &str {
self.choice.as_str()
}
/// 取证日志里的一个词:`factory` / `#006A5C` / `invalid(Short { digits: 2 })`。
///
/// 为什么日志要带这个:截图与真机复现只能靠一行文字分辨「强调色没生效」与「强调色生效了但
/// 图上看不出来」——后者是这条 mission 的验收判据,前者是它的 bug。
#[must_use]
pub fn log_token(&self) -> String {
match self.error {
Some(error) => format!("invalid({error:?})"),
None if self.choice.as_str().is_empty() => "factory".to_owned(),
None => self.persist_value().to_owned(),
}
}
/// 按当前 `text` 重算:合法 ⇒ 换方案 + 清错;非法 ⇒ **方案不动** + 记错。
fn retry(&mut self, flavor: SchemeFlavor) {
match AccentChoice::parse(&self.text) {
Ok(choice) => {
self.color = Color::from_hex(choice.as_str());
self.scheme = self.color.map(|accent| accent_scheme(flavor, accent));
self.choice = choice;
self.error = None;
}
Err(error) => self.error = Some(error),
}
}
}