").is_empty(),
+ "只有原型语料有 ⇒ 也放行(设计基线词汇)"
+ );
+
+ // 边界:`my-ghost` / `ghost-name` 不算 `ghost` 的一次出现(类名里的 `-` 是名字的一部分)。
+ assert!(
+ orphan_selector_tokens(&css_rules(css), "class=\"my-ghost\"", "").len() == 1,
+ "`my-ghost` 不是 `ghost`"
+ );
+ assert!(
+ orphan_selector_tokens(&css_rules(css), "class=\"ghost-name\"", "").len() == 1,
+ "`ghost-name` 不是 `ghost`"
+ );
+
+ // `@media` 里的规则照常入册(坑 #781:只收顶层字符的提取器会让整段断点静默失明)。
+ let nested = "@media (max-width: 900px) { .sidebar #ghost-btn { display: none; } }\n";
+ assert_eq!(
+ orphan_selector_tokens(&css_rules(nested), "", ""),
+ vec![
+ "#ghost-btn — .sidebar #ghost-btn".to_string(),
+ ".sidebar — .sidebar #ghost-btn".to_string(),
+ ],
+ "媒体查询里的规则必须入册(两个 token 都在选择器里,谁都不在语料里)—— \
+ 漏掉它就是 R132 探针第一版的失明"
+ );
+ }
+
+ /// R4 变异体:每条腿只翻它针对的那一条(在活树上改一处)。
+ #[test]
+ fn each_orphan_variant_flips_only_its_own_leg() {
+ let app = app_corpus();
+ let base = css_rules(STYLE_CSS);
+ assert!(
+ orphan_selector_tokens(&base, &app, PROTOTYPE_HTML).is_empty(),
+ "基线必须全绿"
+ );
+
+ // m1 —— 往样式表里加一条谁也不带的规则(模拟「元素被删、规则留下」这个原始缺陷):
+ // 恰好那一个 token 翻红。
+ let mutated = format!("{STYLE_CSS}\n.legacy-widget {{ color: red; }}\n");
+ assert_eq!(
+ orphan_selector_tokens(&css_rules(&mutated), &app, PROTOTYPE_HTML),
+ vec![".legacy-widget — .legacy-widget".to_string()],
+ "注入一条孤儿规则应当恰好报它一条"
+ );
+
+ // m2 —— 把原型语料抽掉:`.delta` 那一族就没人兜了(证明「设计基线」这条腿是承重的)。
+ let without_proto = orphan_selector_tokens(&base, &app, "");
+ assert_eq!(
+ without_proto,
+ vec![
+ ".delta — .stat-card .delta / .stat-card .delta.danger / .stat-card .delta.ok"
+ .to_string()
+ ],
+ "抽掉原型语料后应恰好剩 `.delta` 一族"
+ );
+
+ // m3 —— 把脚本那一半抽掉:只有 `app.js` 才有的 token 立刻变成孤儿
+ // (证明「应用语料必须含脚本」这条腿是承重的)。
+ let markup_only = orphan_selector_tokens(&base, INDEX_HTML, PROTOTYPE_HTML);
+ assert!(
+ markup_only.iter().any(|o| o.starts_with(".row-active ")),
+ "抽掉脚本后 `.row-active`(只在 app.js 里)必须翻红:{markup_only:?}"
+ );
+ assert!(
+ markup_only.iter().all(|o| !o.starts_with(".check-line ")),
+ "`.check-line` 在 markup 里就有 ⇒ 不该被牵连:{markup_only:?}"
+ );
+ }
+
/// 逐腿读数(门禁自己把看到的东西印出来,别让主张只能靠断言名来读)。
#[test]
fn leg_readings() {
@@ -1215,5 +1520,28 @@ mod tests {
);
println!("R3 违规 = {:?}", report.dead_class_sites);
println!("样式表规则总数 = {}", css_rules(STYLE_CSS).len());
+ let roster = selector_tokens(&css_rules(STYLE_CSS));
+ let ids = roster
+ .keys()
+ .filter(|(sigil, _)| *sigil == '#')
+ .collect::
>();
+ println!(
+ "R4 token 名册(派生自 ui/css/style.css) = {} 个(其中 id {} 个:{:?})",
+ roster.len(),
+ ids.len(),
+ ids
+ );
+ println!(
+ "R4 违规 = {:?}",
+ orphan_selector_tokens(&css_rules(STYLE_CSS), &app_corpus(), PROTOTYPE_HTML)
+ );
+ println!(
+ "R4 只由原型语料兜住的 token = {:?}",
+ roster
+ .iter()
+ .filter(|((_, name), _)| !occurs_on_boundary(&app_corpus(), name))
+ .map(|((sigil, name), _)| format!("{sigil}{name}"))
+ .collect::>()
+ );
}
}
diff --git a/src/main.rs b/src/main.rs
index 241c7a0..78a29e1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -63,8 +63,9 @@ mod readme_gate;
// 都在编译期读入,加 #[cfg(test)] 后不会进入发布产物。
#[cfg(test)]
mod smtp_port_gate;
-// 布局门禁(R128):同样是仅测试期编译 —— ui/index.html 与 ui/css/style.css
-// 都在编译期读入,加 #[cfg(test)] 后不会进入发布产物。
+// 布局门禁(R128 + R132):同样是仅测试期编译 —— ui/index.html / ui/css/style.css /
+// docs/prototype/aitokenpool-console.html(设计基线)都在编译期读入,
+// 加 #[cfg(test)] 后不会进入发布产物。
#[cfg(test)]
mod layout_gate;
// 前端声明门禁(R131):同样是仅测试期编译 —— ui/index.html 与 ui/js/*.js
diff --git a/ui/README.md b/ui/README.md
index 4616aa8..373ed33 100644
--- a/ui/README.md
+++ b/ui/README.md
@@ -235,7 +235,7 @@ ui/
- **视觉 polish**:`.login-form .input:focus` 加 `0 0 0 3px var(--accent-soft)` 聚焦光晕(深/亮主题通用);⚠️ 2026-09-11 全站 UI 重设计(`e33bf2e` / #153)更正:本条原先记录的 `.login-card .logo`(52px 渐变微光)与 `.login-brand h1`(22px)已随「单卡片 → 左右分栏」删除 —— logo 现为 `.logo-tile` 圆角方块(无 ring),标题现为 `h1.lb-headline`(`clamp(30px, min(4.4vw, 6.2vh), 54px)`);
- **行内校验**:空邮箱 →「请输入邮箱 / 账号」、空密码 →「请输入密码」(复用 `setFieldError`/`field-error` 组件:红边框 + 行内文案 + 聚焦首个错误 + 输入自动清除);表单 `novalidate` 自管校验;输入框带 id(`#login-email` / `#login-pass`);
-- **记住我**:`#login-remember` checkbox → localStorage `atp-remember`(登录提交时存,DOMContentLoaded 时还原);⚠️ 2026-08-19 演示数据清理(`35291b3` / #90,v0.6.0)更正:本条原写「小字显示演示账号」,那行演示账号提示已随演示数据一并删除 —— 今天登录表单再无演示账号文案;`ui/css/style.css` 的 `.demo-hint` 规则成了**孤儿**(没有任何元素再带这个类),语言包键 `login.demo` 已进不可达日落清单;
+- **记住我**:`#login-remember` checkbox → localStorage `atp-remember`(登录提交时存,DOMContentLoaded 时还原);⚠️ 2026-08-19 演示数据清理(`35291b3` / #90,v0.6.0)更正:本条原写「小字显示演示账号」,那行演示账号提示已随演示数据一并删除 —— 今天登录表单再无演示账号文案;`ui/css/style.css` 的 `.demo-hint` 规则此后一直是**孤儿**(没有任何元素再带这个类),R132(2026-09-30)已把它连同 `.login-divider` / `.wallet-note` / `.sidebar #toggle-mode-btn` 一并删除;语言包键 `login.demo` 已进不可达日落清单;
- 冒烟测试注意:stub 中 `setFieldError` 依赖 `input.parentNode.querySelector(".field-error")` —— stub 的 parentNode 需实现该查询;`insertAdjacentElement` 记录插入元素供断言。
## 接入端点卡片约定(v1.19,rant 2026-08-17T20:44:18)
@@ -1267,3 +1267,11 @@ key**(正常操作)于是让运营者看到红色故障警报,而屏幕上
都从 `sharing.rs` **派生**,⛔ 不写快照。
- ⚠️ **射程**:**词法** —— 证「那一格印的是谁的值、值从哪来」,**不证**屏幕上的像素(仓内 CI 无
JS 运行器,与本文档其余形状门禁同款)。
+
+## 样式表的选择器必须落在元素上(R132,2026-09-30)
+
+- **不变量**:`ui/css/style.css` 里每一个 `.类` / `#id` 选择器都必须有主人 —— 那个名字得以**标识符边界**出现在**应用语料**(`ui/index.html` 标记 + `ui/js/*.js` 脚本,类名有一半是 JS 动态拼出来的)或**设计基线语料**(`docs/prototype/aitokenpool-console.html`,`style.css` 文件头自己声明的那个)里。
+- **为什么单列一条**:一条谁都不带的规则**今天仍然生效**(写一个 `class="wallet-note"` 照样拿到 12px),所以它比一条已经删掉的规则更能骗人 —— 读者看到的是活着的组件规则,而它服务的元素早就没了。R132 一次删掉四条这样的规则:`.demo-hint`(元素随 #90 演示数据清理走掉)· `.login-divider`(#159 起标记改用原型规范名 `.divider`,旧名只留了一句「历史别名」注释)· `.wallet-note`(#156 把元素改写成 `.wallet-hero-note`,旧规则原地留下)· `.sidebar #toggle-mode-btn`(要藏的按钮从来没进过产品,同段清单里的 `#logout-btn` 却是活的)。
+- **`.delta` 刻意保留**:原型 `statCard()` 会真的吐出 ``,它是设计基线词汇、不是残留 —— 这也是把原型语料算进来的原因(实测全表只有它靠这条腿过)。
+- **门禁** `src/layout_gate.rs::every_selector_lands_on_an_element`(R4;token 名册从选择器文本派生,脚本名册借用 `js_gate` 那份 ⇒ 新增 `ui/js/*.js` 不会被静默漏掉,⛔ 零手写类名清单)。
+- ⚠️ **射程**:**词法**且刻意**宽口径** —— 它证「这个名字有落点」,**不证**落点就是它要服务的东西(注释里、字符串里、别处的死代码里的一次同名出现都算数;提到名字 ≠ 穿在身上,与 `js_gate` 同源),也**不**证屏幕上的像素。原型语料是一个**刻意的逃生口**(原型里出现过的任何词都放行)。
diff --git a/ui/css/style.css b/ui/css/style.css
index a1b0367..e5d24d7 100644
--- a/ui/css/style.css
+++ b/ui/css/style.css
@@ -751,12 +751,11 @@ mark {
}
.checkbox-line { display: flex; align-items: center; gap: 6px; flex-direction: row !important; color: var(--text-dim); cursor: pointer; }
.checkbox-line input { accent-color: var(--accent); width: 14px; height: 14px; }
-.demo-hint { color: var(--text-mute); font-size: 11px; }
-/* 「或」分隔线(原型 .divider;.login-divider 为登录页历史别名,两者同规格)
- bug 2026-09-11T22:01:43:原型类名 .divider 在实现中缺失(只有 .login-divider 别名)。 */
-.divider, .login-divider { display: flex; align-items: center; gap: 12px; margin: 16px 0; color: var(--muted); font-size: 11px; }
-.divider::before, .divider::after,
-.login-divider::before, .login-divider::after { content: ""; flex: 1; height: 1px; background: var(--border); }
+/* 「或」分隔线(原型类名 .divider)。#159 曾把它与历史别名 .login-divider 并列成同规格的
+ 一条规则,R132 删掉了那个别名 —— 它从 #159 起就不再匹配任何元素(登录页当时已改用
+ `.divider`),只留下「历史别名」这句注释在替它说话。 */
+.divider { display: flex; align-items: center; gap: 12px; margin: 16px 0; color: var(--muted); font-size: 11px; }
+.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--border); }
.login-foot { margin-top: 16px; text-align: center; font-size: 13px; color: var(--text-dim); }
.login-foot a { color: var(--accent-text); text-decoration: none; }
.login-tip { margin-top: 14px; text-align: center; color: var(--text-mute); font-size: 11px; }
@@ -906,7 +905,6 @@ mark {
.wallet-hero-hint b { color: var(--text); font-weight: 600; }
.wallet-hero-note { display: block; margin-top: 10px; }
.wallet-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
-.wallet-note { font-size: 12px; }
.wallet-hint { color: var(--text-mute); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* 充值/加额表单动作行(原型 .row:横向按钮排布,不占满整行) */
.form .row, .row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
@@ -1239,7 +1237,7 @@ textarea.input:focus { border-color: var(--accent); }
@media (max-width: 900px) {
.sidebar { width: 64px; padding: 12px 8px; }
.sidebar .brand-text, .sidebar .nav-item .label, .sidebar .user-chip .chip-text,
- .sidebar #toggle-mode-btn, .sidebar #logout-btn { display: none; }
+ .sidebar #logout-btn { display: none; }
.nav-item { justify-content: center; }
.main { padding: 20px 16px 40px; }
.form-row { grid-template-columns: 1fr; }
diff --git a/ui/index.html b/ui/index.html
index 823e804..7ad0361 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -5,7 +5,7 @@
AITokenPool
-
+