base-paint 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +8 -0
  2. package/dist/blocks.d.ts +143 -6
  3. package/dist/blocks.d.ts.map +1 -1
  4. package/dist/blocks.js +1060 -35
  5. package/dist/blocks.js.map +1 -1
  6. package/dist/charts.d.ts +6 -4
  7. package/dist/charts.d.ts.map +1 -1
  8. package/dist/charts.js +378 -64
  9. package/dist/charts.js.map +1 -1
  10. package/dist/controls.d.ts +20 -5
  11. package/dist/controls.d.ts.map +1 -1
  12. package/dist/controls.js +246 -34
  13. package/dist/controls.js.map +1 -1
  14. package/dist/font.d.ts +12 -0
  15. package/dist/font.d.ts.map +1 -0
  16. package/dist/font.js +12 -0
  17. package/dist/font.js.map +1 -0
  18. package/dist/help.d.ts +3 -3
  19. package/dist/help.js +12 -12
  20. package/dist/help.js.map +1 -1
  21. package/dist/helpShell.d.ts +57 -0
  22. package/dist/helpShell.d.ts.map +1 -0
  23. package/dist/helpShell.js +65 -0
  24. package/dist/helpShell.js.map +1 -0
  25. package/dist/index.d.ts +10 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +8 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/injector.d.ts +1 -1
  30. package/dist/output/saveHtml.d.ts +64 -0
  31. package/dist/output/saveHtml.d.ts.map +1 -0
  32. package/dist/output/saveHtml.js +339 -0
  33. package/dist/output/saveHtml.js.map +1 -0
  34. package/dist/pageShapes.d.ts +117 -0
  35. package/dist/pageShapes.d.ts.map +1 -0
  36. package/dist/pageShapes.js +366 -0
  37. package/dist/pageShapes.js.map +1 -0
  38. package/dist/pageUi.d.ts +37 -0
  39. package/dist/pageUi.d.ts.map +1 -0
  40. package/dist/pageUi.js +237 -0
  41. package/dist/pageUi.js.map +1 -0
  42. package/dist/spec/charts.d.ts +12 -3
  43. package/dist/spec/charts.d.ts.map +1 -1
  44. package/dist/spec/charts.js +1 -1
  45. package/dist/spec/charts.js.map +1 -1
  46. package/dist/spec/controls.d.ts +30 -5
  47. package/dist/spec/controls.d.ts.map +1 -1
  48. package/dist/spec/controls.js +14 -3
  49. package/dist/spec/controls.js.map +1 -1
  50. package/dist/spec/help.d.ts +4 -4
  51. package/dist/spec/help.d.ts.map +1 -1
  52. package/dist/spec/help.js +2 -2
  53. package/dist/spec/index.d.ts +6 -3
  54. package/dist/spec/index.d.ts.map +1 -1
  55. package/dist/spec/index.js +27 -2
  56. package/dist/spec/index.js.map +1 -1
  57. package/dist/style.d.ts.map +1 -1
  58. package/dist/style.js +283 -55
  59. package/dist/style.js.map +1 -1
  60. package/package.json +6 -2
package/dist/blocks.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * **不从** `src/index.ts` 导出(签名测试把 `dist/index.js` 出口与冻结清单绑死,
9
9
  * 区块接口是可演进的组合层,不是冻结签名)。消费方走子路径 `base-paint/blocks`;
10
10
  * - 清单裁定(DB-1):12 区块取 `docs/visual-spec-blocks.md` §1 拟定清单为终版,
11
- * 只做命名收敛(英文键),锚点语义不变;
11
+ * 只做命名统一(英文键),锚点语义不变;
12
12
  * - 样式闭集裁定(DB-2):新增 `BLOCK_STYLE_SECTIONS` 12 区闭集(**不改**
13
13
  * `CONTROL_STYLE_SECTIONS`);区块 CSS 由本模块唯一产出者 `blocksCss()` 产出,
14
14
  * 只读冻结 token(`CSS_VAR_TOKENS`)+ 局部 CSS 常量(圆角 `{8,14,20,999}`、
@@ -29,8 +29,9 @@
29
29
  * `actionHtml`(B-10)沿用冻结语义(调用方负责内容安全)。
30
30
  */
31
31
  import { charts } from './charts.js';
32
- import { renderActionBar, renderEmptyState, renderErrorReceipt, renderStatusBadge, renderToast } from './controls.js';
33
- import { ACTION_BAR_DEFAULTS, ACTION_ID_ATTR, CHART_KINDS, COPY_ACTION_IDS, DEFAULT_DATA_ATTR, ESCAPE_HTML_CHARS, ESCAPE_HTML_ENTITIES, SCENE_STATUS, STATUS_KINDS, TOAST_DEFAULTS, } from './spec/index.js';
32
+ import { renderActionBar, renderEmptyState, renderErrorReceipt, renderStatusBadge, renderToast, TOAST_ICON_GLYPHS } from './controls.js';
33
+ import { BODY_FONT_STACK } from './font.js';
34
+ import { ACTION_BAR_DEFAULTS, ACTION_ID_ATTR, CHART_KINDS, COPY_ACTION_IDS, CSS_VAR_TOKENS, DEFAULT_DATA_ATTR, ESCAPE_HTML_CHARS, ESCAPE_HTML_ENTITIES, SCENE_STATUS, STATUS_KINDS, TOAST_DEFAULTS, } from './spec/index.js';
34
35
  import { STYLE_PREFIX } from './style.js';
35
36
  /* ── 错误形态(与 TemplateError/ControlsError 同口径:抛、不返空;调用方按 name/code 判定) ── */
36
37
  /** 区块层错误。code 恒为 `bad-input`(输入缺失/非法一律抛,缺失阻断不返空)。 */
@@ -69,6 +70,46 @@ function reqText(value, field) {
69
70
  function optText(value) {
70
71
  return typeof value === 'string' && value !== '' ? value : undefined;
71
72
  }
73
+ /** 可选数字约束(#397):`undefined` 透传;数字收 `String(value)`,字串须非空非空白且 `Number()` 有限。 */
74
+ function optNumeric(value, field) {
75
+ if (value === undefined)
76
+ return undefined;
77
+ if (typeof value === 'number') {
78
+ if (!Number.isFinite(value))
79
+ badInput(field + ' 必须是数字串或有限数');
80
+ return String(value);
81
+ }
82
+ if (typeof value !== 'string' || value.trim() === '')
83
+ badInput(field + ' 必须是数字串或有限数');
84
+ if (!Number.isFinite(Number(value)))
85
+ badInput(field + ' 必须是数字串或有限数');
86
+ return value;
87
+ }
88
+ /** 候选项(#397):`undefined` 透传;给了须是非空数组且逐项非空字串。 */
89
+ function optOptions(value, field) {
90
+ if (value === undefined)
91
+ return undefined;
92
+ if (!Array.isArray(value) || value.length === 0)
93
+ badInput(field + ' 必须是非空数组');
94
+ value.forEach((opt, j) => {
95
+ if (typeof opt === 'string') {
96
+ if (opt === '')
97
+ badInput(field + '[' + j + '] 必须是非空字符串');
98
+ return;
99
+ }
100
+ // 值+标签对照项(#474 追加):`value` 是**机器值**(下拉选中后递给宿主的还是它),
101
+ // `label` 只是给人看的显示文本;全仓只有「机器码 + 中文词」这一种用法。
102
+ if (opt === null || typeof opt !== 'object' || Array.isArray(opt)) {
103
+ badInput(field + '[' + j + '] 必须是非空字符串或 { value, label } 对象');
104
+ }
105
+ const entry = opt;
106
+ if (typeof entry.value !== 'string' || entry.value === '')
107
+ badInput(field + '[' + j + '].value 必须是非空字符串');
108
+ if (typeof entry.label !== 'string' || entry.label === '')
109
+ badInput(field + '[' + j + '].label 必须是非空字符串');
110
+ });
111
+ return value;
112
+ }
72
113
  /* ── 区块样式区闭集(DB-2:新增闭集,不改 CONTROL_STYLE_SECTIONS) ── */
73
114
  /** 12 区块样式区(DB-1 清单的代码落点;顺序即 B-01…B-12)。 */
74
115
  export const BLOCK_STYLE_SECTIONS = [
@@ -97,6 +138,17 @@ function blockRoot(section) {
97
138
  function blockPart(section, part) {
98
139
  return STYLE_PREFIX + 'block-' + sectionSlug(section) + '-' + part;
99
140
  }
141
+ /** 可打印版式根类的主体(#420):`ilife-page-printable`。它不是第 13 个区块——`BLOCK_STYLE_SECTIONS`
142
+ * 由 `test/blocks.test.mjs` 钉死 12 项,故样式落在 `pageShell` 区,仅作页面级开关。 */
143
+ const PRINTABLE_SLUG = 'page-printable';
144
+ /** 打印页名(#420):`.ilife-page-printable` 用 `page: printable` 绑到具名页 `@page printable` 上。
145
+ * 裸 `@page` 是**全局规则**(CSS 没法把它绑到类上:任何页一打印就吃它),具名页才落在类作用域内。 */
146
+ const PRINTABLE_PAGE_NAME = 'printable';
147
+ /** 页面级区块类(#420:页内导航/口径说明行):`ilife-block-<name>`。
148
+ * 与 12 区同命名空间、同 `ilife-block-` 前缀,但不进 `BLOCK_STYLE_SECTIONS`(样式仍落 `pageShell` 区)。 */
149
+ function pageLevelBlock(name) {
150
+ return STYLE_PREFIX + 'block-' + name;
151
+ }
100
152
  /** B-01:单页容器+标题三件套(eyebrow/title/subtitle)+正文。 */
101
153
  export function renderPageShell(input) {
102
154
  assertPlainObject(input, 'renderPageShell: input');
@@ -105,7 +157,9 @@ export function renderPageShell(input) {
105
157
  const title = reqText(shell.title, 'renderPageShell: input.title');
106
158
  if (typeof shell.content !== 'string')
107
159
  badInput('renderPageShell: input.content 必须是字符串');
108
- const parts = ['<section class="' + blockRoot('pageShell') + '">'];
160
+ const rootClass = blockRoot('pageShell')
161
+ + (shell.printable === true ? ' ' + STYLE_PREFIX + PRINTABLE_SLUG : '');
162
+ const parts = ['<section class="' + rootClass + '">'];
109
163
  const eyebrow = optText(shell.eyebrow);
110
164
  if (eyebrow !== undefined)
111
165
  parts.push('<p class="' + blockPart('pageShell', 'eyebrow') + '">' + esc(eyebrow) + '</p>');
@@ -117,6 +171,259 @@ export function renderPageShell(input) {
117
171
  parts.push('</section>');
118
172
  return parts.join('');
119
173
  }
174
+ /** #420-1 页内导航区块(`<nav aria-label="页内导航">` + 逐项 `<a href="#id">`)。
175
+ * 空列表=不出这一块(与「没内容不留空块」同口径,返回空串);非数组/项缺 `id`/`text` → `bad-input`。 */
176
+ export function renderTocBlock(input) {
177
+ assertPlainObject(input, 'renderTocBlock: input');
178
+ assertNoInlineHandler(input, 'renderTocBlock: input');
179
+ const block = input;
180
+ if (!Array.isArray(block.items))
181
+ badInput('renderTocBlock: input.items 必须是数组');
182
+ if (block.items.length === 0)
183
+ return '';
184
+ const links = [];
185
+ for (const item of block.items) {
186
+ assertPlainObject(item, 'renderTocBlock: items[] 元素');
187
+ assertNoInlineHandler(item, 'renderTocBlock: items[] 元素');
188
+ links.push('<a href="#' + esc(reqText(item.id, 'renderTocBlock: items[].id')) + '">'
189
+ + esc(reqText(item.text, 'renderTocBlock: items[].text')) + '</a>');
190
+ }
191
+ return '<nav class="' + pageLevelBlock('toc') + '" aria-label="页内导航">' + links.join('') + '</nav>';
192
+ }
193
+ /** 口径行的并列分隔符(U+FF5C 全角竖线):**唯一**被当作「并列」的字符。
194
+ * #514 立规把它计入可见文本的设计债(R4),本票(负责人第三轮要求 1)就是把这笔债在
195
+ * **公共层一次还掉**:该字符不再进产物文本,分隔改由版式承担(见 `renderCaliberLine` 与本区 CSS)。 */
196
+ const CALIBER_SEP = '|';
197
+ /** #420-2 口径说明行(纯文本单参;五字符转义表与区块层其余函数同源 `esc`)。
198
+ * 聚合数字旁那句灰色小字(例如「周目标口径=每日目标 × 7」)的唯一落点。
199
+ *
200
+ * t154-r3(负责人第三轮要求 1「用竖线分割的部分都要优化」):**签名一字不改**(全仓 141 处
201
+ * 调用点一行不用动),函数内部按全角竖线拆段 ⇒ 逐段一枚 `<span>`,段间分隔交给 CSS 的细竖线
202
+ * (`.…-caliber > span + span` 的 `border-left` hairline + flex 间距)——「拿字符当分隔」换成
203
+ * 「拿版式当分隔」。判据:产物该行的**文本里不再出现该竖线字符**(分隔只活在版式里)。
204
+ * · 不带分隔符的行(多数调用点)**逐字节同改前**:仍是「一个 `<p>` + 一段纯文本」;
205
+ * flex 只对「真的有 span 子件」的行生效(CSS 侧 `.…-caliber:has(> span)`)。
206
+ * · 空段丢弃(首尾竖线/连续两条竖线):不出版面的空位;全空的无实义行退化回原样文本
207
+ * (没有可分的段,不静默吞字符)。 */
208
+ export function renderCaliberLine(text) {
209
+ const raw = reqText(text, 'renderCaliberLine: text');
210
+ const cls = pageLevelBlock('caliber');
211
+ if (!raw.includes(CALIBER_SEP))
212
+ return '<p class="' + cls + '">' + esc(raw) + '</p>';
213
+ const segs = raw.split(CALIBER_SEP).map((seg) => seg.trim()).filter((seg) => seg !== '');
214
+ if (segs.length === 0)
215
+ return '<p class="' + cls + '">' + esc(raw) + '</p>';
216
+ // 段间留一个空格:flex 容器里纯空白文本节点不成匿名项(不占版面),但**文本面**(复制/
217
+ // 无障碍树/测试的可见文本读数)读出来仍是「甲 乙」而不是「甲乙」。
218
+ return '<p class="' + cls + '">'
219
+ + segs.map((seg) => '<span>' + esc(seg) + '</span>').join(' ') + '</p>';
220
+ }
221
+ /** #507 结论条(审查必改 #3):一行判定句的浅底条,形状住公共层、**调用方只传文本**。
222
+ *
223
+ * 与 `renderCaliberLine` 同族(页面级、单参纯文本、五字符转义同源 `esc`;样式随 `pageShell` 区落盘,
224
+ * 不进 `BLOCK_STYLE_SECTIONS` 那 12 项闭集)。区别只在角色:口径行是**旁注**(灰小字),
225
+ * 结论条是**主读法**(一行结论句,读者扫一眼就知道今天怎么样)。
226
+ *
227
+ * 此前这条形状是**页面内联 8 个魔法值**(`homeDocs.ts` 的 `CONCLUSION_STYLE`,逐字照搬老实物
228
+ * `.view-summary`),与本仓「版面单源住公共层」的纪律冲突——该件自述「本页不再出现任何一条
229
+ * 自写 `font-size`/`color` 规则」,而 `color:var(--blue2)` 就在那串内联里。搬到这里之后,
230
+ * 页面侧只留「传什么文本」。 */
231
+ export function renderConclusionBar(text) {
232
+ return '<p class="' + pageLevelBlock('conclusion') + '">' + esc(reqText(text, 'renderConclusionBar: text')) + '</p>';
233
+ }
234
+ /* ══════════════════════════════════════════════════════════════
235
+ * #421 页面融合四件(与领域无关:文案/数值/颜色全由调用方给)
236
+ * 占比迷你条/分布条行=「数字+图形同格」;徽章=并列小标签;字段变更行=改前改后对照。
237
+ * 四件都是页面级(不属 12 区块),样式随 `pageShell` 区落盘(同 #420 处置:不新增样式区)。
238
+ * ══════════════════════════════════════════════════════════════ */
239
+ /** 页面级子件类(#421):`ilife-block-<名>-<件>`(与 `pageLevelBlock` 同命名空间,不进 12 区闭集)。 */
240
+ function pageLevelPart(name, part) {
241
+ return pageLevelBlock(name) + '-' + part;
242
+ }
243
+ /** 百分比(#421):只收有限数,两端都夹到 0–100(越界夹取是本条唯一口径;非数走 `badInput`)。 */
244
+ function reqPct(value, field) {
245
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
246
+ badInput(field + ' 必须是 0–100 的有限数(越界夹取)');
247
+ }
248
+ return Math.min(100, Math.max(0, value));
249
+ }
250
+ /** 填充条的内联声明(#421/#431):宽度恒有;**背景只在调用方给了颜色时才出现**——不给时不落内联色,
251
+ * 填充色由样式段两条 `-fill` 规则里的**冻结 token** `var(--blue)` 兜底(缺省色走冻结 token,
252
+ * 区块自身不写死任何色值字面量;判据见 `test/page-viz-421.test.mjs`「缺省色走冻结 token」一条)。 */
253
+ function fillDecls(pct, color) {
254
+ return 'width:' + String(pct) + '%' + (color === undefined ? '' : ';background:' + color);
255
+ }
256
+ /** 冻结 token 名(#431):色值入参里 `var()` 只许引用 `CSS_VAR_TOKENS` 的 11 个名字——公共层只定义
257
+ * 这 11 个,引用没定义的名字会让填充的 `background` 变成 guaranteed-invalid(条静默变透明、不报错),
258
+ * 故清单外一律 `bad-input`(与 `blocksCss` 的「`var()` 引用必须是冻结 token」同一条纪律)。 */
259
+ function isFrozenToken(name) {
260
+ return Object.hasOwn(CSS_VAR_TOKENS, name);
261
+ }
262
+ /** CSS 具名色全表(#431 色值允许清单第四支):CSS Color Level 4「Named colors」的 148 个具名色。
263
+ * 这里只管**收不收**(比对时大小写不敏感),取值由浏览器定、本仓不写第二份。`transparent` 与
264
+ * `currentColor` 是关键字不是具名色,不在表内。 */
265
+ const CSS_NAMED_COLORS = new Set(('aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown'
266
+ + ' burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan'
267
+ + ' darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid'
268
+ + ' darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet'
269
+ + ' deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro'
270
+ + ' ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki'
271
+ + ' lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow'
272
+ + ' lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray'
273
+ + ' lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine'
274
+ + ' mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise'
275
+ + ' mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab'
276
+ + ' orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru'
277
+ + ' pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown'
278
+ + ' seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan'
279
+ + ' teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen').split(' '));
280
+ /** `rgb()`/`rgba()` 形态(#431):分量只许数字(可带 `%`),逗号或空格分隔、alpha 可用 `/` 引。
281
+ * 只校验形态、不校验取值范围(越界由 CSS 自己夹取);`expression(`/`url(` 这类带字母的实参在此挡下。 */
282
+ function isRgbFunction(raw) {
283
+ const hit = /^rgba?\(\s*([^)]*)\)$/i.exec(raw);
284
+ if (hit === null)
285
+ return false;
286
+ const parts = hit[1].split(/[,/]|\s+/).filter((part) => part !== '');
287
+ return (parts.length === 3 || parts.length === 4)
288
+ && parts.every((part) => /^\d*\.?\d+%?$/.test(part));
289
+ }
290
+ /** 色值入参(#421/#431):`undefined` 透传(缺省色走样式段的冻结 token);清单内的写法**逐字透传**
291
+ * (裸 token 名 `--x` 包成 `var(--x)`)。允许清单=`#rgb`/`#rrggbb`/`rgb()`/`rgba()`/
292
+ * `var(--<冻结 token 名>)`/CSS 具名色;**清单外一律 `bad-input`**——`;`/`expression(`/`url(` 这类
293
+ * 注入形态在这一步挡住,不让 `;` 穿进内联声明列表(审查 S3-6)。 */
294
+ function optColor(value, field) {
295
+ if (value === undefined)
296
+ return undefined;
297
+ if (typeof value !== 'string' || value.trim() === '')
298
+ badInput(field + ' 必须是非空字符串(冻结 token 名或色值)');
299
+ const raw = value.trim();
300
+ if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(raw))
301
+ return raw;
302
+ if (isRgbFunction(raw))
303
+ return raw;
304
+ const tokenRef = /^var\(\s*(--[A-Za-z0-9-]+)\s*\)$/.exec(raw);
305
+ if (tokenRef !== null) {
306
+ if (!isFrozenToken(tokenRef[1]))
307
+ badInput(field + ' 引用了未冻结的 token(只许 11 个冻结 token):' + tokenRef[1]);
308
+ return raw;
309
+ }
310
+ if (/^--[A-Za-z0-9-]+$/.test(raw)) {
311
+ if (!isFrozenToken(raw))
312
+ badInput(field + ' 引用了未冻结的 token(只许 11 个冻结 token):' + raw);
313
+ return 'var(' + raw + ')';
314
+ }
315
+ if (CSS_NAMED_COLORS.has(raw.toLowerCase()))
316
+ return raw;
317
+ badInput(field + ' 不在色值允许清单里(#rgb/#rrggbb/rgb()/rgba()/var(--<冻结 token>)/CSS 具名色):' + raw);
318
+ }
319
+ /** 附加类名(#421):`undefined` 透传;给了须是空格分隔的类名(调用方按域标色,区块不认领域)。 */
320
+ function optExtraClass(value, field) {
321
+ if (value === undefined)
322
+ return undefined;
323
+ if (typeof value !== 'string' || value.trim() === '')
324
+ badInput(field + ' 必须是非空字符串');
325
+ const names = value.trim().split(/\s+/);
326
+ for (const name of names) {
327
+ if (!/^-?[A-Za-z_][A-Za-z0-9_-]*$/.test(name))
328
+ badInput(field + ' 只许空格分隔的类名:' + name);
329
+ }
330
+ return names.join(' ');
331
+ }
332
+ /** #421 四件的类名主体(`pageLevelBlock(名)` → `ilife-block-<名>`)。 */
333
+ const MINI_BAR_NAME = 'mini-bar';
334
+ const DIST_ROW_NAME = 'dist-row';
335
+ const CHIP_NAME = 'chip';
336
+ const CHANGE_ROW_NAME = 'change-row';
337
+ /** 箭头字形(#421-4):`arrow: false` 靠可见性占位,字形逐字保留(栏位不塌)。 */
338
+ const CHANGE_ARROW_GLYPH = '\u2192';
339
+ /** #421-1 占比迷你条(「数字+图形同格」的图形侧):一截横条按 `pct` 撑宽。
340
+ * 只表达「一眼看出差距」,不替代图表——精确读数(坐标/悬停)仍走 `renderChartBlock` 的共享图表。 */
341
+ export function renderMiniBar(input) {
342
+ assertPlainObject(input, 'renderMiniBar: input');
343
+ assertNoInlineHandler(input, 'renderMiniBar: input');
344
+ const bar = input;
345
+ const pct = reqPct(bar.pct, 'renderMiniBar: input.pct');
346
+ const color = optColor(bar.color, 'renderMiniBar: input.color');
347
+ return '<span class="' + pageLevelBlock(MINI_BAR_NAME) + '" role="img" aria-label="' + String(pct) + '%">'
348
+ + '<span class="' + pageLevelPart(MINI_BAR_NAME, 'fill') + '" style="' + esc(fillDecls(pct, color)) + '"></span>'
349
+ + '</span>';
350
+ }
351
+ /** #421-2 分布条行:`名称 | 条 | 数值` 一格三栏,逐行拼出(行即件,不另加容器类)。
352
+ * `rows: []` = 空串(与「没内容不留空块」同口径);行内校验逐条 fail-fast。 */
353
+ export function renderDistributionRows(input) {
354
+ assertPlainObject(input, 'renderDistributionRows: input');
355
+ assertNoInlineHandler(input, 'renderDistributionRows: input');
356
+ const block = input;
357
+ if (!Array.isArray(block.rows))
358
+ badInput('renderDistributionRows: input.rows 必须是数组');
359
+ if (block.rows.length === 0)
360
+ return '';
361
+ return block.rows.map((row, i) => {
362
+ const field = 'renderDistributionRows: input.rows[' + i + ']';
363
+ assertPlainObject(row, field);
364
+ assertNoInlineHandler(row, field);
365
+ const item = row;
366
+ const label = reqText(item.label, field + '.label');
367
+ const pct = reqPct(item.pct, field + '.pct');
368
+ const color = optColor(item.color, field + '.color');
369
+ const extra = optExtraClass(item.labelClass, field + '.labelClass');
370
+ return '<div class="' + pageLevelBlock(DIST_ROW_NAME) + '">'
371
+ + '<span class="' + pageLevelPart(DIST_ROW_NAME, 'name') + (extra === undefined ? '' : ' ' + extra) + '">'
372
+ + esc(label) + '</span>'
373
+ + '<span class="' + pageLevelPart(DIST_ROW_NAME, 'bar') + '">'
374
+ + '<span class="' + pageLevelPart(DIST_ROW_NAME, 'fill') + '" style="' + esc(fillDecls(pct, color)) + '"></span>'
375
+ + '</span>'
376
+ + '<span class="' + pageLevelPart(DIST_ROW_NAME, 'val') + '">'
377
+ + cellText(item.value, field + '.value') + '</span>'
378
+ + '</div>';
379
+ }).join('');
380
+ }
381
+ /** #421-3 徽章(并列小标签,如分类/标签这类短词并排):逐项 `<span class="ilife-block-chip">`。
382
+ * 项即件(不另加容器类);`items: []` = 空串。 */
383
+ export function renderChips(input) {
384
+ assertPlainObject(input, 'renderChips: input');
385
+ assertNoInlineHandler(input, 'renderChips: input');
386
+ const block = input;
387
+ if (!Array.isArray(block.items))
388
+ badInput('renderChips: input.items 必须是数组');
389
+ if (block.items.length === 0)
390
+ return '';
391
+ return block.items.map((entry, i) => {
392
+ const field = 'renderChips: input.items[' + i + ']';
393
+ assertPlainObject(entry, field);
394
+ assertNoInlineHandler(entry, field);
395
+ return '<span class="' + pageLevelBlock(CHIP_NAME) + '">'
396
+ + esc(reqText(entry.text, field + '.text')) + '</span>';
397
+ }).join('');
398
+ }
399
+ /** #421-4 字段变更行(回执页「改前 → 改后」对照):`字段名 | 改前 | 箭头 | 改后`。
400
+ * `arrow: false` 只把字形藏起来、不删栏位(与老技能同一手法:左右栏靠箭位对齐);
401
+ * `rows: []` = 空串。 */
402
+ export function renderChangeRows(input) {
403
+ assertPlainObject(input, 'renderChangeRows: input');
404
+ assertNoInlineHandler(input, 'renderChangeRows: input');
405
+ const block = input;
406
+ if (!Array.isArray(block.rows))
407
+ badInput('renderChangeRows: input.rows 必须是数组');
408
+ if (block.rows.length === 0)
409
+ return '';
410
+ return block.rows.map((row, i) => {
411
+ const field = 'renderChangeRows: input.rows[' + i + ']';
412
+ assertPlainObject(row, field);
413
+ assertNoInlineHandler(row, field);
414
+ const item = row;
415
+ const label = reqText(item.label, field + '.label');
416
+ return '<div class="' + pageLevelBlock(CHANGE_ROW_NAME) + '">'
417
+ + '<span class="' + pageLevelPart(CHANGE_ROW_NAME, 'label') + '">' + esc(label) + '</span>'
418
+ + '<span class="' + pageLevelPart(CHANGE_ROW_NAME, 'old') + '">'
419
+ + cellText(item.before, field + '.before') + '</span>'
420
+ + '<span class="' + pageLevelPart(CHANGE_ROW_NAME, 'arrow') + '" aria-hidden="true"'
421
+ + (item.arrow === false ? ' style="visibility:hidden"' : '') + '>' + CHANGE_ARROW_GLYPH + '</span>'
422
+ + '<span class="' + pageLevelPart(CHANGE_ROW_NAME, 'new') + '">'
423
+ + cellText(item.after, field + '.after') + '</span>'
424
+ + '</div>';
425
+ }).join('');
426
+ }
120
427
  /** B-02:单张 KPI 卡(value 带 `tnum`;非法 status 由冻结语义降级 `empty`)。 */
121
428
  export function renderKpiCard(input) {
122
429
  assertPlainObject(input, 'renderKpiCard: input');
@@ -169,6 +476,10 @@ export function renderDataTable(input) {
169
476
  }
170
477
  if (!Array.isArray(table.rows))
171
478
  badInput('renderDataTable: input.rows 必须是数组');
479
+ if (table.cellHtml !== undefined && typeof table.cellHtml !== 'function') {
480
+ badInput('renderDataTable: input.cellHtml 必须是函数');
481
+ }
482
+ const cellHtml = table.cellHtml;
172
483
  const columns = table.columns.map((column, i) => {
173
484
  const field = 'renderDataTable: input.columns[' + i + ']';
174
485
  assertPlainObject(column, field);
@@ -186,9 +497,30 @@ export function renderDataTable(input) {
186
497
  const head = '<thead><tr>' + columns.map((column) => '<th scope="col" class="' + blockPart('dataTable', 'cell-' + column.align) + '">' + esc(column.label) + '</th>').join('') + '</tr></thead>';
187
498
  const body = '<tbody>' + table.rows.map((row, ri) => {
188
499
  assertPlainObject(row, 'renderDataTable: input.rows[' + ri + ']');
189
- return '<tr>' + columns.map((column) => '<td class="' + blockPart('dataTable', 'cell-' + column.align) + '">'
190
- + cellText(row[column.key], 'renderDataTable: input.rows[' + ri + '].' + column.key)
191
- + '</td>').join('') + '</tr>';
500
+ return '<tr>' + columns.map((column) => {
501
+ const raw = row[column.key];
502
+ // #567 `cellHtml`:给了且对本格返回字符串即受信透传(调用方已转义);
503
+ // 返回 `undefined` 即走缺省转义文本。不给 `cellHtml` 时本行与改前逐字同。
504
+ let trusted;
505
+ if (cellHtml !== undefined) {
506
+ const out = cellHtml(column.key, raw);
507
+ if (out !== undefined) {
508
+ if (typeof out !== 'string')
509
+ badInput('renderDataTable: input.cellHtml 必须返回 string/undefined');
510
+ trusted = out;
511
+ }
512
+ }
513
+ // t154-r3(负责人第三轮要求 2):每个数据格带上**列头文本**(`data-label`),窄屏行卡化
514
+ // 时由 CSS 的 `td::before{content:attr(data-label)}` 出「标签 + 值」,列头因此不必再挤在
515
+ // 390px 里。**只加属性**:既有类名、既有结构、既有文本一字不动,桌面档(≥641)没有任何
516
+ // 规则引用它 ⇒ 桌面渲染逐像素不变(自证读数见证据件)。列头文本与 `th` 同源(同一个
517
+ // `column.label`),不存在两处文案漂移。`esc` 已含 `"`(五字符表)⇒ 标签含引号也安全。
518
+ const text = trusted === undefined
519
+ ? cellText(raw, 'renderDataTable: input.rows[' + ri + '].' + column.key)
520
+ : trusted;
521
+ return '<td class="' + blockPart('dataTable', 'cell-' + column.align) + '" data-label="' + esc(column.label) + '">'
522
+ + text + '</td>';
523
+ }).join('') + '</tr>';
192
524
  }).join('') + '</tbody>';
193
525
  const caption = optText(table.caption);
194
526
  return '<div class="' + blockRoot('dataTable') + '"><table class="' + blockPart('dataTable', 'table') + '">'
@@ -241,9 +573,14 @@ export function renderListRows(input) {
241
573
  const row = item;
242
574
  const main = reqText(row.main, field + '.main');
243
575
  const done = row.done === true;
244
- const cls = blockPart('listRows', 'row') + (done ? ' ' + blockPart('listRows', 'row-done') : '');
245
576
  const left = optText(row.left);
246
577
  const right = optText(row.right);
578
+ // 没给 `left` 时**不占那一列**:`44px` 那列是给 `▲`/`▼`/`—` 这类标记用的,而栅格是自动排布——
579
+ // 若仍按三列排,`main` 会落进 44px 那列、被 `nowrap` + `ellipsis` 截断(#154 实测:备注标签
580
+ // 「晨起空腹」显示成「晨起…」)。修饰类只改列定义,不动任何既有行。
581
+ const cls = blockPart('listRows', 'row')
582
+ + (done ? ' ' + blockPart('listRows', 'row-done') : '')
583
+ + (left === undefined ? ' ' + blockPart('listRows', 'row-no-left') : '');
247
584
  return '<div class="' + cls + '">'
248
585
  + (left === undefined ? '' : '<span class="' + blockPart('listRows', 'left') + '">' + esc(left) + '</span>')
249
586
  + '<span class="' + blockPart('listRows', 'main') + '">' + esc(main) + '</span>'
@@ -346,7 +683,13 @@ export function renderDisclosure(input) {
346
683
  + '<div class="' + blockPart('disclosure', 'body') + '">' + block.contentHtml + '</div>'
347
684
  + '</details>';
348
685
  }
349
- /** B-09:参数表单(`placeholder = hint`;`required` 落 `data-required` 供宿主拦截;零 JS)。 */
686
+ /** B-09:参数表单(`placeholder = hint`;`required` 落 `data-required` 供宿主拦截;零 JS)。
687
+ * #397 可选约束:`readonly` 落裸 `readonly`;`step/min/max` 落同名属性(任一出现即
688
+ * `type="number"`,`step` 须大于 0,`min` 不得大于 `max`,非法一律 `bad-input`);
689
+ * `options` 给了即渲染 `<select>`(`value` 命中项 `selected`,`hint` 化作首项占位,
690
+ * `readonly` 化作 `disabled`,与 `step/min/max` 互斥)。
691
+ * #474 追加:`options` 收 `{ value, label }` 对象项——`value` 是机器值(`selected` 按它比)、
692
+ * `label` 是显示文本;字符串项走原路,全字符串调用方的产物**逐字节不变**。 */
350
693
  export function renderParamForm(input) {
351
694
  assertPlainObject(input, 'renderParamForm: input');
352
695
  assertNoInlineHandler(input, 'renderParamForm: input');
@@ -369,13 +712,52 @@ export function renderParamForm(input) {
369
712
  const hint = optText(item.hint);
370
713
  const value = typeof item.value === 'string' ? item.value : '';
371
714
  const required = item.required === true;
715
+ const readonly = item.readonly === true;
716
+ const step = optNumeric(item.step, path + '.step');
717
+ const min = optNumeric(item.min, path + '.min');
718
+ const max = optNumeric(item.max, path + '.max');
719
+ if (step !== undefined && !(Number(step) > 0))
720
+ badInput(path + '.step 必须是大于 0 的数字');
721
+ if (min !== undefined && max !== undefined && Number(min) > Number(max)) {
722
+ badInput(path + '.min 不得大于 ' + path + '.max');
723
+ }
724
+ const options = optOptions(item.options, path + '.options');
725
+ if (options !== undefined && (step !== undefined || min !== undefined || max !== undefined)) {
726
+ badInput(path + '.options 与 step/min/max 互斥');
727
+ }
728
+ if (options !== undefined) {
729
+ // #474:对象项按**机器值**比 `selected`,显示文本取 `label`;字符串项与旧行为逐字节一致。
730
+ const entries = options.map((opt) => (typeof opt === 'string' ? { value: opt, label: opt } : opt));
731
+ const optionHtml = entries.map((opt) => '<option value="' + esc(opt.value) + '"'
732
+ + (value === opt.value ? ' selected' : '') + '>' + esc(opt.label) + '</option>').join('');
733
+ parts.push('<label class="' + blockPart('paramForm', 'field') + '">'
734
+ + '<span class="' + blockPart('paramForm', 'label') + '">' + esc(label)
735
+ + (required ? '<span class="' + blockPart('paramForm', 'required') + '" aria-hidden="true"> *</span>' : '')
736
+ + '</span>'
737
+ + '<select class="' + blockPart('paramForm', 'input') + '" name="' + esc(name) + '"'
738
+ + (required ? ' data-required="1" required' : '')
739
+ + (readonly ? ' disabled' : '')
740
+ + '>'
741
+ + (hint === undefined ? '' : '<option value="" disabled' + (entries.some((opt) => opt.value === value) ? '' : ' selected') + '>'
742
+ + esc(hint) + '</option>')
743
+ + optionHtml
744
+ + '</select>'
745
+ + '</label>');
746
+ return;
747
+ }
372
748
  parts.push('<label class="' + blockPart('paramForm', 'field') + '">'
373
749
  + '<span class="' + blockPart('paramForm', 'label') + '">' + esc(label)
374
750
  + (required ? '<span class="' + blockPart('paramForm', 'required') + '" aria-hidden="true"> *</span>' : '')
375
751
  + '</span>'
376
- + '<input class="' + blockPart('paramForm', 'input') + '" name="' + esc(name) + '" value="' + esc(value) + '"'
752
+ + '<input class="' + blockPart('paramForm', 'input') + '"'
753
+ + (step === undefined && min === undefined && max === undefined ? '' : ' type="number"')
754
+ + ' name="' + esc(name) + '" value="' + esc(value) + '"'
377
755
  + (hint === undefined ? '' : ' placeholder="' + esc(hint) + '"')
378
756
  + (required ? ' data-required="1" required' : '')
757
+ + (readonly ? ' readonly' : '')
758
+ + (step === undefined ? '' : ' step="' + esc(step) + '"')
759
+ + (min === undefined ? '' : ' min="' + esc(min) + '"')
760
+ + (max === undefined ? '' : ' max="' + esc(max) + '"')
379
761
  + ' />'
380
762
  + '</label>');
381
763
  });
@@ -397,19 +779,27 @@ export function renderEmptyBlock(input) {
397
779
  + renderEmptyState(block)
398
780
  + '</section>';
399
781
  }
400
- /** B-11:复制区块(冻结 `renderActionBar`;id 缺省取 `COPY_ACTION_IDS.actionBar.*`,调用方须保页内唯一)。 */
782
+ /** B-11:复制区块(冻结 `renderActionBar`;id 缺省取 `COPY_ACTION_IDS.actionBar.*`,调用方须保页内唯一)。
783
+ * `dataFormats` 给三格式形态(`dataText` 单格式),两者同给 → `bad-input`。
784
+ * 标题去重(#336 base 侧兜底,与 `copyArea` 的 `COPY_TITLE_DUP_OF_BUTTON` 同口径):
785
+ * `title` 与复制数据按钮同名(`ACTION_BAR_DEFAULTS.copyDataLabel`=「复制数据」)时不出 `<h2>`
786
+ * (只留动作不留说明文本);其他标题照旧。 */
401
787
  export function renderCopyBlock(input) {
402
788
  assertPlainObject(input, 'renderCopyBlock: input');
403
789
  assertNoInlineHandler(input, 'renderCopyBlock: input');
404
790
  const block = input;
791
+ if (block.dataFormats !== undefined && block.dataText !== undefined) {
792
+ badInput('renderCopyBlock: input.dataFormats 与 input.dataText 只能给一个');
793
+ }
405
794
  const bar = {};
406
795
  if (block.buttons !== undefined)
407
796
  bar.buttons = block.buttons;
408
- if (block.dataText !== undefined || block.dataActionId !== undefined) {
797
+ if (block.dataText !== undefined || block.dataActionId !== undefined || block.dataFormats !== undefined) {
409
798
  bar.copyData = {
410
799
  actionId: block.dataActionId ?? COPY_ACTION_IDS.actionBar.copyData,
411
800
  label: ACTION_BAR_DEFAULTS.copyDataLabel,
412
801
  ...(block.dataText === undefined ? {} : { text: block.dataText }),
802
+ ...(block.dataFormats === undefined ? {} : { formats: block.dataFormats }),
413
803
  };
414
804
  }
415
805
  if (block.logText !== undefined || block.logActionId !== undefined) {
@@ -419,13 +809,56 @@ export function renderCopyBlock(input) {
419
809
  ...(block.logText === undefined ? {} : { text: block.logText }),
420
810
  };
421
811
  }
422
- const title = optText(block.title);
812
+ const rawTitle = optText(block.title);
813
+ // #336:与按钮同名的标题只留按钮(`copyArea` 去重口径的 base 侧兜底,直调本函数同样生效)。
814
+ const title = rawTitle === ACTION_BAR_DEFAULTS.copyDataLabel ? undefined : rawTitle;
423
815
  return '<section class="' + blockRoot('copyBlock') + '">'
424
816
  + (title === undefined ? '' : '<h2 class="' + blockPart('copyBlock', 'title') + '">' + esc(title) + '</h2>')
425
817
  + renderActionBar(bar)
426
818
  + '</section>';
427
819
  }
428
- /** B-12:反馈区块(冻结 `renderToast` 和/或 `renderErrorReceipt`;两者至少其一)。 */
820
+ /** 页内静态提示**不出**的内容(#154,fail-fast 而非静默丢):可点控件在静态提示里没有行为位,
821
+ * 徽章/计数是 toast 卡头部的配件。给了就抛 `bad-input`(要这些就用 toast 形态:不传 `staticNotice`)。
822
+ * 寿命字段(`timeoutMs`/`maxStack`)只关乎 toast 栈,静态形态下**无意义、静默忽略**。 */
823
+ const STATIC_NOTICE_REJECTED_FIELDS = ['actions', 'badge', 'count'];
824
+ /** B-12 的浅色形态产出器(#154):结构 = 图标 + 正文(标题/详情/多行/代码块)。
825
+ * 与 `renderToast` **同源不重述**:图标字形、`icon` 非法回落、转义表都取既有唯一产出者/冻结表。 */
826
+ function renderStaticNotice(toast) {
827
+ assertPlainObject(toast, 'renderFeedbackBlock: input.toast');
828
+ if (typeof toast.msg !== 'string')
829
+ badInput('renderFeedbackBlock: input.toast.msg 必须是字符串');
830
+ for (const field of STATIC_NOTICE_REJECTED_FIELDS) {
831
+ const value = toast[field];
832
+ if (value !== undefined && value !== null) {
833
+ badInput('renderFeedbackBlock: staticNotice 形态不出可点控件/徽章/计数,input.toast.'
834
+ + field + ' 不得给(要这些就传 toast 形态:去掉 staticNotice)');
835
+ }
836
+ }
837
+ const icon = typeof toast.icon === 'string' && toast.icon in TOAST_ICON_GLYPHS
838
+ ? toast.icon
839
+ : TOAST_DEFAULTS.defaultIcon;
840
+ const parts = [
841
+ '<div class="' + blockPart('feedbackBlock', 'note') + '">',
842
+ '<span class="' + blockPart('feedbackBlock', 'note-icon') + ' '
843
+ + blockPart('feedbackBlock', 'note-icon-' + icon) + '" aria-hidden="true">' + TOAST_ICON_GLYPHS[icon] + '</span>',
844
+ '<div class="' + blockPart('feedbackBlock', 'note-body') + '">',
845
+ '<div class="' + blockPart('feedbackBlock', 'note-title') + '">' + esc(toast.msg) + '</div>',
846
+ ];
847
+ if (typeof toast.detail === 'string' && toast.detail !== '') {
848
+ parts.push('<div class="' + blockPart('feedbackBlock', 'note-detail') + '">' + esc(toast.detail) + '</div>');
849
+ }
850
+ if (Array.isArray(toast.lines) && toast.lines.length > 0) {
851
+ parts.push('<div class="' + blockPart('feedbackBlock', 'note-lines') + '">'
852
+ + toast.lines.map((line) => esc(String(line))).join('<br>') + '</div>');
853
+ }
854
+ if (typeof toast.code === 'string' && toast.code !== '') {
855
+ parts.push('<pre class="' + blockPart('feedbackBlock', 'note-code') + '">' + esc(toast.code) + '</pre>');
856
+ }
857
+ parts.push('</div>', '</div>');
858
+ return parts.join('');
859
+ }
860
+ /** B-12:反馈区块(冻结 `renderToast` 和/或 `renderErrorReceipt`;两者至少其一)。
861
+ * `staticNotice === true` 时 `toast` 改走浅色静态形态(#154),其余逐字照旧。 */
429
862
  export function renderFeedbackBlock(input) {
430
863
  assertPlainObject(input, 'renderFeedbackBlock: input');
431
864
  assertNoInlineHandler(input, 'renderFeedbackBlock: input');
@@ -434,9 +867,12 @@ export function renderFeedbackBlock(input) {
434
867
  badInput('renderFeedbackBlock: input.toast 与 input.error 至少其一');
435
868
  }
436
869
  const title = optText(block.title);
870
+ const toastHtml = block.toast === undefined
871
+ ? ''
872
+ : (block.staticNotice === true ? renderStaticNotice(block.toast) : renderToast(block.toast));
437
873
  return '<section class="' + blockRoot('feedbackBlock') + '">'
438
874
  + (title === undefined ? '' : '<h2 class="' + blockPart('feedbackBlock', 'title') + '">' + esc(title) + '</h2>')
439
- + (block.toast === undefined ? '' : renderToast(block.toast))
875
+ + toastHtml
440
876
  + (block.error === undefined ? '' : renderErrorReceipt(block.error))
441
877
  + '</section>';
442
878
  }
@@ -450,10 +886,10 @@ const LF = String.fromCharCode(10);
450
886
  const LINE_RGB = '210, 210, 215';
451
887
  /** 主色 RGB(`--blue` `#007aff`;与 `src/style.ts` 同值,复用罗列处注明出处)。 */
452
888
  const BLUE_RGB = '0, 122, 255';
453
- /** 待开发徽章配色(沿用 HELP 壳 `help-shell-badge-dev` 逐值,不另发明)。 */
889
+ /** 待开发徽章配色(沿用 help 模板 `help-shell-badge-dev` 逐值,不另发明)。 */
454
890
  const DEV_BG = 'rgba(255, 149, 0, .12)';
455
891
  const DEV_FG = '#b25000';
456
- /** 必填星号色(沿用 HELP 壳 `help-shell-field-required` 逐值)。 */
892
+ /** 必填星号色(沿用 help 模板 `help-shell-field-required` 逐值)。 */
457
893
  const REQUIRED_FG = '#c0392b';
458
894
  /** 局部圆角常量(D-5:`{8,14,20,999}`,不新增 token 名)。 */
459
895
  const RADIUS_SM = 8;
@@ -463,6 +899,23 @@ const RADIUS_PILL = 999;
463
899
  /** 区块 CSS:12 区各自实现(键与 `BLOCK_STYLE_SECTIONS` 一一对应,缺区导入即抛,防静默缺样式)。 */
464
900
  const BLOCK_SECTION_BUILDERS = {
465
901
  pageShell: (p) => [
902
+ // #179 整页基础(文档级):本区是「共享页面模板」的唯一落点(`style.test.mjs` T22 明写 body/html
903
+ // 规则归 #104,`buildStyleSheet()` 不得产)。此前一条都没有 → 实测 `body` 吃 UA 的 8px 外边距、
904
+ // 白底(`--bg` `#f5f5f7` 定义了却没人用)+ 系统默认字体(computed 回落到 Noto Sans SC)。
905
+ // 这三条与参照件 HELP 的 `body{font-family…;background:var(--bg);color:var(--text)}` 对齐;
906
+ // 字体栈取 `src/font.ts` 唯一真相源(不各抄一份)。**不搬** HELP 的 `*{margin:0;padding:0}`:
907
+ // 本页没有任何盒子靠全局复位才不溢出(唯一需要 `box-sizing` 的输入框已自带),
908
+ // 搬了会把 `.ilife-block-page-shell` 的桌面内容宽从 960 压到 920(45 张页面一起变),
909
+ // 属另一题材。 */
910
+ 'body {',
911
+ ' margin: 0;',
912
+ ' background: var(--bg);',
913
+ ' color: var(--fg);',
914
+ ' font-family: ' + BODY_FONT_STACK + ';',
915
+ ' font-size: 15px;',
916
+ ' line-height: 1.5;',
917
+ ' -webkit-font-smoothing: antialiased;',
918
+ '}',
466
919
  '.' + p + 'block-page-shell {',
467
920
  ' display: block;',
468
921
  ' max-width: 960px;',
@@ -473,7 +926,8 @@ const BLOCK_SECTION_BUILDERS = {
473
926
  '}',
474
927
  '.' + p + 'block-page-shell-eyebrow {',
475
928
  ' margin: 0;',
476
- ' color: var(--blue);',
929
+ // #179 对比度:12px 小字压白底,`--blue` 4.02:1 不到 AA 的 4.5:1 → 同族深一档 `--blue2`(5.6:1)。
930
+ ' color: var(--blue2);',
477
931
  ' font-size: 12px;',
478
932
  ' font-weight: 600;',
479
933
  ' letter-spacing: .08em;',
@@ -481,7 +935,8 @@ const BLOCK_SECTION_BUILDERS = {
481
935
  '}',
482
936
  '.' + p + 'block-page-shell-title {',
483
937
  ' margin: 6px 0 0;',
484
- ' font-size: 32px;',
938
+ // #567 J4(§5.2 大数 28 吸收 26/28/32/40):页标题 32→28。
939
+ ' font-size: 28px;',
485
940
  ' font-weight: 700;',
486
941
  ' letter-spacing: -.4px;',
487
942
  ' line-height: 1.2;',
@@ -495,20 +950,259 @@ const BLOCK_SECTION_BUILDERS = {
495
950
  ' display: block;',
496
951
  ' margin-top: 16px;',
497
952
  '}',
953
+ // #457 顶层区块统一间隔:正文里每个区块(非首个)上方恒 16px。此前各区块根规则各自给上下
954
+ // 外边距,`dataTable`/`listRows` 两条**一条都没给**,于是「表接表」实测间距 0 —— 两张表的
955
+ // 1px 边框贴在一起,看不出是两块(267 页「延迟相关性」与「分层对比」)。
956
+ // 写在**一处**、用相邻兄弟收口,而不是给 12 个区各补一条上下边距:相邻两块的边距会折叠,
957
+ // 既有的 12/16 都不超过 16,折叠后恒为 16,不需要改各区的既有值。
958
+ // 作用域限死在本区正文的直接子级,两条都在防误伤:① `.ilife-block-kpi-card` 也是 `.ilife-block`,
959
+ // 若全局写 `.ilife-block + .ilife-block`,KPI 卡网格里第 2 张起会各被推下 16px(同排错位、网格失效);
960
+ // ② 选择器只用 `.ilife-block` 作「后一块」,不给非区块元素(口径行/图例)加边距,也不动嵌在
961
+ // 折叠区里的块(它不是正文的直接子级)。
962
+ // **与 #154「各区自补 16px」重叠(同一根因的另一处补法)**:那批区间给 `dataTable`/`listRows`/
963
+ // `kpiCardGrid` 各补了 `margin: 16px 0`,与本条命中同一处时两边同值、走 margin 折叠,恒 16px、
964
+ // 不翻倍(实测 regen-262:表接表 16px)。两票都实施后由后续票合并成单一落点,本票不撤。
965
+ '.' + p + 'block-page-shell-body > * + .' + p + 'block {',
966
+ ' margin-top: 16px;',
967
+ '}',
498
968
  '@media (max-width: 640px) {',
499
969
  ' .' + p + 'block-page-shell {',
500
970
  ' padding: 20px 16px 60px;',
501
971
  ' }',
502
972
  ' .' + p + 'block-page-shell-title {',
503
- ' font-size: 26px;',
973
+ // #567 J4:桌面 32→28 后窄屏 26 按旧收缩比(26/32≈0.81)收到 22(28×0.79),复用 KPI 主数字档,
974
+ // 不另立一档;仍比正文大 9px,主次不断。
975
+ ' font-size: 22px;',
976
+ ' }',
977
+ '}',
978
+ // #420 页面级三件的样式随本区落盘(`BLOCK_STYLE_SECTIONS` 由 `test/blocks.test.mjs` 钉死 12 项,
979
+ // 不新增样式区;三件都是页面级、与 12 区块无组合关系)。
980
+ '.' + p + 'block-toc {',
981
+ ' display: flex;',
982
+ ' flex-wrap: wrap;',
983
+ ' gap: 8px;',
984
+ ' margin: 0 0 16px;',
985
+ '}',
986
+ '.' + p + 'block-toc a {',
987
+ // #567 D6:页内导航可点高 33.5px<44px(一页两套触摸档:同页输入框与复制按钮已 44px)。
988
+ // `min-height:44px`+纵向居中定死触摸目标(`box-sizing` 显式钉死,免得被页面 `*{}` 改写);
989
+ // 纵向内距 6px→8px(4 的倍数;横向 12px 不动;`gap:8px` 已是 4 的倍数)。
990
+ ' display: inline-flex;',
991
+ ' align-items: center;',
992
+ ' box-sizing: border-box;',
993
+ ' min-height: 44px;',
994
+ ' padding: 8px 12px;',
995
+ ' border: 1px solid var(--line);',
996
+ ' border-radius: ' + RADIUS_PILL + 'px;',
997
+ ' background: var(--card);',
998
+ // #179 对比度口径:13px 小字压白底用 `--blue2`(5.6:1),`--blue` 4.02:1 不到 AA 的 4.5:1。
999
+ ' color: var(--blue2);',
1000
+ ' font-size: 13px;',
1001
+ ' font-weight: 600;',
1002
+ ' text-decoration: none;',
1003
+ '}',
1004
+ '.' + p + 'block-toc a:focus-visible {',
1005
+ ' outline: 2px solid var(--blue);',
1006
+ ' outline-offset: 2px;',
1007
+ '}',
1008
+ // 口径说明行:聚合数字旁的灰色小字。12px 取 `--fg2`(4.94:1);`--fg3` 只有 3.62:1,不到 AA。
1009
+ '.' + p + 'block-caliber {',
1010
+ ' margin: 0 0 8px;',
1011
+ ' color: var(--fg2);',
1012
+ ' font-size: 12px;',
1013
+ ' line-height: 1.5;',
1014
+ '}',
1015
+ // t154-r3(负责人第三轮要求 1):口径行的**并列分隔**从「文本里的全角竖线字符」改成「版式里的细竖线」。
1016
+ // 拆段住在产出器(`renderCaliberLine` 逐段出一枚 `<span>`),本处只给版式:段间一枚 hairline
1017
+ // + 间距;窄屏靠 `flex-wrap` 换行(不再靠竖线字符挤在一行)。
1018
+ // **`:has(> span)` 是这条的鉴别器**:只有真拆过段的行才是 flex 容器;没带分隔符的口径行
1019
+ // (多数调用点)连 flex 都不是 ⇒ 版面与改前逐值相同(「桌面档不变」在口径行这侧的保证)。
1020
+ // `gap: 4px 12px`=行距 4px/列距 12px;竖线借 `> span + span` 的 `border-left`(首段无线)。
1021
+ // 色取既有 `LINE_RGB` 的 .6 透明档(与表行分隔同一档,不新增色值、不新增 token)。
1022
+ // 列距 12px 落在「竖线两侧不对称」的观感上偏紧,故每段再补 8px 左内距:竖线两侧留白
1023
+ // 12px/8px,读到的是「一条细线把两段分开」,而不是「一行贴着线条」。
1024
+ // 注:本条注释不写那个竖线字符本身 —— 产物里「全角竖线」的命中数是个判据,注释不该去凑它。
1025
+ '.' + p + 'block-caliber:has(> span) {',
1026
+ ' display: flex;',
1027
+ ' flex-wrap: wrap;',
1028
+ ' gap: 4px 12px;',
1029
+ ' align-items: baseline;',
1030
+ '}',
1031
+ '.' + p + 'block-caliber > span + span {',
1032
+ ' padding-left: 8px;',
1033
+ ' border-left: 1px solid rgba(' + LINE_RGB + ', .6);',
1034
+ '}',
1035
+ // #507 结论条(审查必改 #3):形状住公共层,调用方只传文本。
1036
+ // 起因:这一条此前是**页面内联 8 个魔法值**(`homeDocs.ts` 的 `CONCLUSION_STYLE`:
1037
+ // `margin:0 0 16px;padding:12px 16px;border-radius:14px;background:var(--soft);color:var(--blue2);
1038
+ // font-weight:600` 写在一处 `style="…"` 里),与「版面单源住公共层」的纪律冲突(该件自述
1039
+ // 「本页不再出现任何一条自写 `font-size`/`color` 规则」,而 `color:var(--blue2)` 就在其中)。
1040
+ // 取值**逐条落在 11 个冻结 token + 圆角闭集 `{8,14,20,999}` 内**,不新增 token:
1041
+ // 底 `var(--card)`(白,与卡片同底)、强调字 `var(--blue2)`(5.6:1)、左 3px `var(--blue)` 主色边、
1042
+ // 圆角 `' + RADIUS_MD + '`(14,闭集里既有中档)、内距 12px/16px(12/16 倍数)、下距 16px(同族值)。
1043
+ // 审查第 3 点给的形态就是这一条「左 3px 主色边 + 浅底」,它同时把**结论条**与 `renderStatusBadge`
1044
+ // 的「`--soft` 底 + 色字」形态分开——此前两者底同、字色同,一个静态陈述一个算出来的档位却长得一样。
1045
+ '.' + p + 'block-conclusion {',
1046
+ ' margin: 0 0 16px;',
1047
+ ' padding: 12px 16px;',
1048
+ ' border-left: 3px solid var(--blue);',
1049
+ ' border-radius: ' + RADIUS_MD + 'px;',
1050
+ ' background: var(--card);',
1051
+ ' color: var(--blue2);',
1052
+ // #567 J4(§5.2 区块标题 15 吸收 14/15)。
1053
+ ' font-size: 15px;',
1054
+ ' font-weight: 600;',
1055
+ '}',
1056
+ // #421 页面融合四件的样式随本区落盘(同 #420 处置:四件都是页面级、不属 12 区块,
1057
+ // `BLOCK_STYLE_SECTIONS` 由 `test/blocks.test.mjs` 钉死 12 项,故不新增样式区、不新增 token)。
1058
+ // **色值口径(#431 定稿)**:区块自身不写死任何色值字面量——样式里只出现冻结 token。不给 `color`
1059
+ // 时填充色的兜底就是下面两条 `-fill` 规则里的冻结 token `var(--blue)`(缺省色走冻结 token,不是
1060
+ // 字面量,也不是「没有色」);调用方给的色值由 `optColor` 按允许清单收下、逐字进产物的内联
1061
+ // `background`。三处说法(本条注释/证据件/判据名)同口径。
1062
+ '.' + p + 'block-mini-bar {',
1063
+ ' display: inline-block;',
1064
+ ' overflow: hidden;',
1065
+ ' width: 72px;',
1066
+ ' height: 6px;',
1067
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1068
+ ' background: var(--line);',
1069
+ ' vertical-align: middle;',
1070
+ '}',
1071
+ '.' + p + 'block-mini-bar-fill {',
1072
+ ' display: block;',
1073
+ ' height: 100%;',
1074
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1075
+ ' background: var(--blue);',
1076
+ '}',
1077
+ '.' + p + 'block-dist-row {',
1078
+ ' display: grid;',
1079
+ ' grid-template-columns: minmax(0, 6em) minmax(0, 1fr) auto;',
1080
+ ' align-items: center;',
1081
+ ' gap: 8px;',
1082
+ ' padding: 5px 0;',
1083
+ ' font-size: 13px;',
1084
+ '}',
1085
+ // 13px 小字压白底:`--fg2`(4.94:1)达 AA;`--fg3`(3.62:1)不到,不用(#179 口径)。
1086
+ '.' + p + 'block-dist-row-name {',
1087
+ ' color: var(--fg2);',
1088
+ ' overflow: hidden;',
1089
+ ' text-overflow: ellipsis;',
1090
+ ' white-space: nowrap;',
1091
+ '}',
1092
+ '.' + p + 'block-dist-row-bar {',
1093
+ ' display: block;',
1094
+ ' overflow: hidden;',
1095
+ ' height: 8px;',
1096
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1097
+ ' background: var(--line);',
1098
+ '}',
1099
+ '.' + p + 'block-dist-row-fill {',
1100
+ ' display: block;',
1101
+ ' height: 100%;',
1102
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1103
+ ' background: var(--blue);',
1104
+ '}',
1105
+ '.' + p + 'block-dist-row-val {',
1106
+ ' color: var(--fg);',
1107
+ ' font-weight: 600;',
1108
+ ' font-variant-numeric: tabular-nums;',
1109
+ ' text-align: right;',
1110
+ '}',
1111
+ // 12px 小字取 `--blue2`(压 `--soft` 约 5.4:1);`--blue` 4.02:1 不到 AA 的 4.5:1(#179 口径)。
1112
+ '.' + p + 'block-chip {',
1113
+ ' display: inline-block;',
1114
+ ' margin: 0 6px 6px 0;',
1115
+ ' padding: 2px 8px;',
1116
+ ' border: 1px solid var(--line);',
1117
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1118
+ ' background: var(--soft);',
1119
+ ' color: var(--blue2);',
1120
+ ' font-size: 12px;',
1121
+ ' font-weight: 600;',
1122
+ ' line-height: 1.5;',
1123
+ '}',
1124
+ '.' + p + 'block-change-row {',
1125
+ ' display: flex;',
1126
+ ' align-items: baseline;',
1127
+ ' gap: 8px;',
1128
+ ' padding: 6px 0;',
1129
+ ' border-top: 1px solid var(--line);',
1130
+ // #567 J4(§5.2 区块标题 15 吸收 14/15;`page-viz-421` 同步改 14→15)。
1131
+ ' font-size: 15px;',
1132
+ '}',
1133
+ '.' + p + 'block-change-row-label {',
1134
+ ' flex: 1;',
1135
+ ' min-width: 0;',
1136
+ ' color: var(--fg2);',
1137
+ '}',
1138
+ // 改前=灰字加删除线;改后=正文字重 600(层次靠字重与线,不靠更浅的灰,同 #179 口径)。
1139
+ '.' + p + 'block-change-row-old {',
1140
+ ' color: var(--fg2);',
1141
+ ' text-decoration: line-through;',
1142
+ '}',
1143
+ // 箭位恒占宽:`arrow: false` 只加内联 `visibility: hidden`,栏宽与真行逐字同(`display: none` 会塌)。
1144
+ '.' + p + 'block-change-row-arrow {',
1145
+ ' flex: 0 0 auto;',
1146
+ ' min-width: 1.2em;',
1147
+ ' color: var(--fg2);',
1148
+ ' text-align: center;',
1149
+ '}',
1150
+ '.' + p + 'block-change-row-new {',
1151
+ ' color: var(--fg);',
1152
+ ' font-weight: 600;',
1153
+ ' font-variant-numeric: tabular-nums;',
1154
+ '}',
1155
+ // #533 触屏两件(照 HELP `button.copy` 与卡路里 `pageChromeCss`):壳内可点件去蓝块、防双击缩放延迟。
1156
+ // 只补两条声明,不改版式文案;第三件(`-webkit-touch-callout`)口径未定,见证据件交裁,本票不自定。
1157
+ '.' + p + 'block-page-shell button,',
1158
+ '.' + p + 'block-page-shell summary {',
1159
+ ' -webkit-tap-highlight-color: transparent;',
1160
+ ' touch-action: manipulation;',
1161
+ '}',
1162
+ // #420-3 打印段:**必须显式打开**——只有 `renderPageShell({ printable: true })` 的页才带
1163
+ // `.ilife-page-printable`;不给的调用点类名不出现,规则虽在样式段里但一律不命中(逐字零变)。
1164
+ // 打印段里**每条规则**都挂在该类作用域下(不出现裸 `body`/裸 `.wrap`):样式表是共享资产、
1165
+ // 注入到全部页面,裸选择器会在别的页上生效。页面留白交给**具名页** `@page printable`
1166
+ // (`.ilife-page-printable { page: printable }` 绑定;裸 `@page` 会让全部页面吃这 12mm)。
1167
+ // 屏幕上那 32px 留白在纸上没有意义,故打印时清零、改由页边距承担。
1168
+ '@media print {',
1169
+ ' .' + p + PRINTABLE_SLUG + ' {',
1170
+ ' page: ' + PRINTABLE_PAGE_NAME + ';',
1171
+ ' max-width: none;',
1172
+ ' padding: 0;',
1173
+ ' }',
1174
+ ' .' + p + PRINTABLE_SLUG + ' .' + p + 'block-toc {',
1175
+ ' display: none;',
1176
+ ' }',
1177
+ ' .' + p + PRINTABLE_SLUG + ' .' + p + 'block-copy-block {',
1178
+ ' display: none;',
1179
+ ' }',
1180
+ ' @page ' + PRINTABLE_PAGE_NAME + ' {',
1181
+ ' margin: 12mm;',
504
1182
  ' }',
505
1183
  '}',
506
1184
  ].join(LF),
507
1185
  kpiCard: (p) => [
1186
+ // #179 类名对账(硬证据:`.scratch/t179` 的类名对账脚本):本区 6 条内层规则此前写成
1187
+ // `block-kpi-*`,而 `blockPart('kpiCard', …)` 真产出的是 `block-kpi-card-*`
1188
+ // → label/value-row/value/unit/detail/badge **6 条全部落空**(computed 实测:值 16px/400
1189
+ // 普通正文色,不是设计的 28px/700)。本区逐条改回真产出的类名,并把说明落在每条前面。
1190
+ // #154(2026-09-14 交付页返工)① 区块间距:本区此前**块级一条 margin 都没有**,与同族区块
1191
+ // (`chartBlock`/`detailSection`/`emptyBlock`/`copyBlock`/`feedbackBlock` 都是 `margin: 16px 0`)
1192
+ // 不一致 ⇒ 交付页实测「KPI 网格与上下卡片贴死」。取同族值 `16px 0`(相邻外边距自然折叠,不翻倍)。
1193
+ // 与 #457 的 `.ilife-block-page-shell-body > * + .ilife-block`(相邻兄弟补上边距)**互补不冲突**:
1194
+ // 那条匹配不到本网格——网格根类是 `block-kpi-card-grid`,**不带** `ilife-block` 类;
1195
+ // 两处都命中同一条边距时同值(16px)且折叠,不会翻倍。
1196
+ // ② 四张卡等高:`auto-fit` 网格**逐行各自量高**,第一行若有一张卡的值长(如日期区间
1197
+ // `2026-08-09 ~ 2026-09-07`)折成 2~3 行,就把那一行撑高、同行另一张被拉长,第二行又是另一个高度。
1198
+ // `grid-auto-rows: 1fr` 让全部隐式行取同一个高(fr 轨道先取各自 max-content,余量均分 ⇒ 行行等高),
1199
+ // 网格项默认 `stretch`,四张卡因此看起来一样高(用户原话要求)。
508
1200
  '.' + p + 'block-kpi-card-grid {',
509
1201
  ' display: grid;',
510
1202
  ' grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));',
1203
+ ' grid-auto-rows: 1fr;',
511
1204
  ' gap: 12px;',
1205
+ ' margin: 16px 0;',
512
1206
  '}',
513
1207
  '.' + p + 'block-kpi-card {',
514
1208
  ' padding: 14px;',
@@ -517,43 +1211,94 @@ const BLOCK_SECTION_BUILDERS = {
517
1211
  ' background: var(--card);',
518
1212
  ' box-shadow: var(--shadow);',
519
1213
  '}',
520
- '.' + p + 'block-kpi-label {',
1214
+ '.' + p + 'block-kpi-card-label {',
521
1215
  ' color: var(--fg2);',
522
1216
  ' font-size: 12px;',
523
1217
  ' font-weight: 600;',
524
1218
  '}',
525
- '.' + p + 'block-kpi-value-row {',
1219
+ '.' + p + 'block-kpi-card-value-row {',
526
1220
  ' display: flex;',
527
1221
  ' align-items: baseline;',
528
1222
  ' gap: 6px;',
1223
+ ' min-width: 0;',
529
1224
  ' margin-top: 4px;',
530
1225
  '}',
531
- '.' + p + 'block-kpi-value {',
1226
+ '.' + p + 'block-kpi-card-value {',
1227
+ ' min-width: 0;',
532
1228
  ' color: var(--fg);',
533
- ' font-size: 28px;',
1229
+ // #154(2026-09-14 交付页返工)③ 主数字收一号:用户原话「内容太大了」。28px → 22px。
1230
+ // 22 是**阶梯里还站得住**的一档(#567 后 `detail-section-title` 17→18:22 − 18 = 4px ≥ 4px,
1231
+ // C1:349「相邻级差 ≥4px」仍成立;22 本身是 #544 终审既判的整排字号,不动);对 `unit` 13px
1232
+ // 仍大 9px ≥ 8px(本尺 B-02「unit 比 value 小 ≥8px」)。
1233
+ // `overflow-wrap: anywhere` 保留(防长串溢出)——字号小了之后长值折行显著变少。
1234
+ ' font-size: 22px;',
534
1235
  ' font-weight: 700;',
535
1236
  ' line-height: 1.2;',
1237
+ ' overflow-wrap: anywhere;',
536
1238
  ' font-variant-numeric: tabular-nums;',
537
1239
  '}',
538
- '.' + p + 'block-kpi-unit {',
1240
+ '.' + p + 'block-kpi-card-unit {',
539
1241
  ' color: var(--fg2);',
540
1242
  ' font-size: 13px;',
541
1243
  '}',
542
- '.' + p + 'block-kpi-detail {',
1244
+ '.' + p + 'block-kpi-card-detail {',
543
1245
  ' margin-top: 4px;',
544
- ' color: var(--fg3);',
1246
+ // #179 对比度:12px 小字取 `--fg3`(`#86868b`)压卡片白底只有 3.62:1,不到 AA 的 4.5:1
1247
+ // → 与 label 同色 `--fg2`(4.94:1),层次由字重(600/400)和字号承担,不靠更浅的灰。
1248
+ ' color: var(--fg2);',
545
1249
  ' font-size: 12px;',
1250
+ ' overflow-wrap: anywhere;',
546
1251
  '}',
547
- '.' + p + 'block-kpi-badge {',
1252
+ '.' + p + 'block-kpi-card-badge {',
548
1253
  ' margin-top: 8px;',
549
1254
  '}',
1255
+ // #507 段标题统一(审查必改 #5):`blocks.ts` 从 #401 起就给过这条类名产出器的位置
1256
+ // (`homeDocs.ts:247` 用 `<h2 class="ilife-block-kpi-card-title">` 出「今日速览」段标题),
1257
+ // 但**本区从来没有这条 CSS 规则** ⇒ 那个 h2 一路落回浏览器缺省 `h2{font-size:1.17em}`
1258
+ // (15px 正文的 1.17 倍=17.5px),与同级的 `.ilife-block-copy-block-title`(15px)量出来两样。
1259
+ // 本条补上与另两族**同字号同字重**的一档:15px/700/`margin:0 0 8px`
1260
+ // (边距本区原无先例,取 `copy-block-title` 那档;`chart-block-title` 的 10px 是 #154 已有值,
1261
+ // 不在本票写集,故三族**字号与字重全同、边距两档**——审查第 5 条点的是字号。)
1262
+ '.' + p + 'block-kpi-card-title {',
1263
+ ' margin: 0 0 8px;',
1264
+ ' font-size: 15px;',
1265
+ ' font-weight: 700;',
1266
+ '}',
1267
+ // #507 窄屏档(审查必改 #4):`auto-fit minmax(150px,1fr)` 只在容器有效宽 ≥474px 时排 3 列
1268
+ // (3×150 + 2×12 间隙),而手机端页面模板 `padding:20px 16px`(见本区 ≤640px 段)——
1269
+ // 512 视口下栅格有效宽仅 480px ⇒ 正好卡在 3 列下沿,第 4 张卡单独占一行、宽度只有前三张的
1270
+ // 1/3(`grid-auto-rows:1fr` 又让它与前三张**等高**),看着像漏了一张。
1271
+ // 本条只加**窄屏一档**:≤640px 显式 2 列 ⇒ 4 张卡排成整齐 2×2;`minmax(0,1fr)` 里的 0
1272
+ // 是下限(不是 `auto` 那种取内容最小宽),长值卡不会被撑破。桌面档一行不动
1273
+ // (桌面列数上限是另一票的事,本票不碰)。断点取本文件既有先例:`dataTable` 与 `pageShell`
1274
+ // 两处都是 `@media (max-width: 640px)`。
1275
+ // 选择器是 `.ilife-block-page-shell .ilife-block-kpi-card-grid`(基础类名前挂一个祖先类),
1276
+ // **不是**同名再写一条:① 网格落在页面模板正文里(`renderKpiGrid` 的唯一用法),该祖先恒成立;
1277
+ // ② 本仓的 CSS 纪律测试(`test/ui-fix-154.test.mjs` 的 `declsOf`)按「某类名的**基础规则**恰 1 条」
1278
+ // 判账,同名再起一条会被数成 2 条而红;`dataTable` 区那条同名 640px 规则是历史写法,本票不动它。
1279
+ '@media (max-width: 640px) {',
1280
+ ' .' + p + 'block-page-shell .' + p + 'block-kpi-card-grid {',
1281
+ ' grid-template-columns: repeat(2, minmax(0, 1fr));',
1282
+ ' }',
1283
+ '}',
550
1284
  ].join(LF),
551
1285
  dataTable: (p) => [
1286
+ // #154(2026-09-14 交付页返工)① 区块间距:本区块级此前**一条 margin 都没有**(只有 caption/th/td
1287
+ // 的内距),与同族区块的 `margin: 16px 0` 不一致 ⇒ 交付页实测「表格和上下卡片完全贴在一起」。
1288
+ // 取同族值 `16px 0`;与 #457 的相邻兄弟规则同值时折叠为 16px,不翻倍。
552
1289
  '.' + p + 'block-data-table {',
1290
+ ' margin: 16px 0;',
553
1291
  ' overflow-x: auto;',
554
1292
  ' border: 1px solid var(--line);',
555
1293
  ' border-radius: ' + RADIUS_MD + 'px;',
556
1294
  ' background: var(--card);',
1295
+ // #512(最后一公里)表格「两个角落」的旧解(已撤,见下):当时给表卡加宽度上限收中缝;
1296
+ // 试过的另一条路(复审也提了)**无效**:`td` 上的 `max-width` 在 `table-layout:auto` 下不生效
1297
+ // (读数见 `docs/base/base-render/t512-最后一公里-证据.md`),上限只加在 `td` 上等于没加。
1298
+ // #567 D1:撤掉 680 上限,表卡吃满内容列(#512 那 398px 窄表是旧约束:行首列收窄+数值列
1299
+ // 下限已由后票接管列宽分配;上限在时表盒 682 vs 内容列 1080、两侧各空 199px)。
1300
+ // `margin: 16px 0` 不动(#154 块级外边距+`ui-fix-154` 同值守卫);居中 `margin-inline:auto`
1301
+ // 随上限一起撤(满宽下无意义)。窄档(≤640 上限本就不触发)逐字不变。
557
1302
  '}',
558
1303
  '.' + p + 'block-data-table-table {',
559
1304
  ' width: 100%;',
@@ -571,10 +1316,18 @@ const BLOCK_SECTION_BUILDERS = {
571
1316
  ' padding: 10px 14px;',
572
1317
  ' border-bottom: 1px solid var(--line);',
573
1318
  ' background-color: transparent;',
1319
+ // #507 列头压一档(审查必改 #2):此前 `th` 取 `--fg2`/12px/600,与数据行 `--fg2`/13px/400
1320
+ // 几乎同色同重 ⇒ 列头「日期」与数据「2026-09-07」分不出主次。改成 `--fg3`/12px(#567 前是
1321
+ // 11.5px,列头最浅)+ 数据行的**数值列**取 `--fg`(见下方 `td.…-cell-right` 一条),
1322
+ // 「列头弱、数据强」的层级才读出来。
1323
+ // #572-S3-3(转 #567):`text-transform: uppercase` 把表头里的单位一并抬大写
1324
+ // (`模拟体重(kg)`→`模拟体重(KG)`、`饮水(ml)`→`饮水(ML)`,1440 档实测)。
1325
+ // 列头层级改由字号/字重/颜色承担(11.5px→12px 仍是最浅 `--fg3`+600 字重),不再靠大写。
1326
+ // 对比度账(#179):`--fg3` 压白底 3.62:1,按 WCAG 短标签 3:1 一档仍有富余;数据行一律不动色。
1327
+ // 短英文标签此前靠大写撑出的形态改由既有 `letter-spacing` 保留。
574
1328
  ' color: var(--fg3);',
575
1329
  ' font-size: 12px;',
576
1330
  ' font-weight: 600;',
577
- ' text-transform: uppercase;',
578
1331
  ' letter-spacing: .04em;',
579
1332
  ' white-space: nowrap;',
580
1333
  '}',
@@ -596,6 +1349,162 @@ const BLOCK_SECTION_BUILDERS = {
596
1349
  '.' + p + 'block-data-table-cell-right {',
597
1350
  ' text-align: right;',
598
1351
  '}',
1352
+ // #507 数值列主次(审查必改 #2):`align:'right'` 是本层「这一列是数值」的唯一既有信号
1353
+ // (`renderDataTable` 的 `align` 三态里只有它带「数字成栈右对齐」的语义),故**数值列的提权
1354
+ // 挂在这条既有类名上**,不新造类名、不改产出器签名 —— 三族调用点(60 件)一行不用改。
1355
+ // 三处逐条:
1356
+ // ① `color:var(--fg)`:同一语义(摄入=1189)在 KPI 卡里是 `--fg`/700/22px,在表里此前是
1357
+ // `--fg2`/400/13px —— 同一语义两种长法。数值列的**数据**取正文字色,与列头(`--fg3`)分开。
1358
+ // 不改字重(加粗到 600 会把整表拉响亮,`docs/visual-spec-blocks.md:73` 只要 `th` 最浅灰、
1359
+ // `td` 次级灰、未禁数据加粗;本层取最小改动=只换色,层级已够)。
1360
+ // ② `min-width:5.5em`:此前数值列**没有宽度下限**,日期列(`align` 缺省 left)默认吃掉大半
1361
+ // 行宽,两个数值列被挤到右缘四个字符里(1000px 下两列中心相距 ~160px、右侧还空 60px),
1362
+ // 读起来像「数字都堆在角落」。5.5em 按本档 13px 算=71.5px,容得下「2706」与「—」两种内容。
1363
+ // 只加下限,列宽仍由 `table-layout:auto` 按内容分。
1364
+ // ③ 等宽轨 + `tnum`:数字列换成等宽栈(与 `pre-block-code` 同一个栈口径,不新造字体面),
1365
+ // 位数不同的数字仍按列对齐;`tnum` 那一条在 `.…-data-table-table` 上已经是全表口径,
1366
+ // 这里在**单元格**上重申一次,防将来有人在 `td` 上写 `font-variant-numeric` 把它顶掉。
1367
+ // **只命中 `td`**(`align:'right'` 的列头 `th` 不跟着变重):`th` 的色与字号由本区 `th` 规则管。
1368
+ '.' + p + 'block-data-table td.' + p + 'block-data-table-cell-right {',
1369
+ ' min-width: 5.5em;',
1370
+ ' color: var(--fg);',
1371
+ ' font-family: "SF Mono", monospace;',
1372
+ ' font-variant-numeric: tabular-nums;',
1373
+ '}',
1374
+ // #512(列宽按内容给)行首列收窄到内容所需,余量归数值列。
1375
+ // 病根:`table-layout:auto` 下剩段行宽按各列 max-content 比例分,而行首列(`align` 缺省 left;
1376
+ // 本页就是日期列)的 max-content 比数值列重 ⇒ 1000/1440 档实测该列 415.23px(**占表宽 43%**),
1377
+ // 两个数值列各只 271.38px。日期串长度固定(`2026-09-07(今日)`),不需要半屏。
1378
+ // 手段:`width:1%` + `white-space:nowrap` —— 1% 把该列的**目标宽**压到最小,`nowrap` 让它的
1379
+ // min-content = max-content = 内容自然宽,两下一夹,列宽**恰好收在内容上**(实测 152.25px),
1380
+ // 余下的整段行宽归 auto 的数值列(实测 402.88px×2)。#507 那条 `min-width:5.5em` + 等宽栈
1381
+ // + `tabular-nums`(上一行 `td.…-cell-right`)本票一字不动:数值列的**下限**与**余量**是两件事。
1382
+ // 只钉**行首列**(`td.…-cell-left:first-child`):本层没有「这一列是日期」的信号(`left` 是缺省,
1383
+ // 表里人人 `cell-left`),行首是唯一的既有信号。把**所有** left 列一律钉住(试过)会把本仓 141 处
1384
+ // 调用点里「左列装长文本」的表整表重排:账单候选表(5 列)实测数值列由 99.11px 涨到 375.22px,
1385
+ // 且长文本列失去折行兜底(`nowrap`),内容一超宽就横滚 —— 本票不碰那些表。
1386
+ // 只在**桌面档**:`nowrap` 与 ≤640 档 #457 的「靠折行换不横滚」硬碰。本条按 640 断点的桌面侧
1387
+ // 加一档(`min-width:641px` 与既有 `max-width:640px` 互为补集,不新增断点值),窄屏段一字不动
1388
+ // —— 实测 512 档列宽/折行/横滚与改前逐值相同(211.97/133/133.03,0 折行、0 横滚)。
1389
+ '@media (min-width: 641px) {',
1390
+ ' .' + p + 'block-data-table td.' + p + 'block-data-table-cell-left:first-child {',
1391
+ ' width: 1%;',
1392
+ ' white-space: nowrap;',
1393
+ ' }',
1394
+ '}',
1395
+ // #457 手机端(≤640px)紧凑形态:不靠左右滑动看全 —— 收字号(表头 12→11px、单元格 13→12px)、
1396
+ // 收内边距、放开 `th` 的 `white-space: nowrap`(表宽的硬来源)。容器留 `overflow-x: auto` 兜底。
1397
+ // **与 #154 给本区根补的 `margin: 16px 0` 不冲突**:窄屏段只碰字号/内距/换行,不碰边距。
1398
+ //
1399
+ // t154-r3(负责人第三轮要求 2「这张表在手机宽度上效果很差,所有用到该表格的页面都要整改」):
1400
+ // 窄屏从「折行硬挤」改成**行卡化**。病根是上一张票选的「靠折行换不横滚」:`td{overflow-wrap:anywhere}`
1401
+ // 把单元格的最小内容宽度压到 **1 个字符**,五列表(两列长文本)在 390px 里于是被压成「一个汉字宽」,
1402
+ // 日期串按任意位置断行 → 一个字一行(负责人实拍)。
1403
+ // 行卡化=窄屏不再有「列」:`thead` 收起(列名不再占位),`tr` 变一张张小卡,每格一行「标签 + 值」,
1404
+ // 标签来自产出器写在 `td` 上的 `data-label`(`td::before{content:attr(data-label)}`)。
1405
+ // `overflow-wrap:anywhere` **仍留着**(长串——网址/UUID/长数字——不溢出),但它此时作用在
1406
+ // **满宽**的格上,最小宽度是「整格宽」而不是「1 个汉字」⇒ 不会再断成竖排。
1407
+ // 桌面档(≥641)零变化:下面每一条都住在这条既有 `@media (max-width: 640px)` 里,且没有任何
1408
+ // ≥641 的规则引用 `data-label`(属性本身不带任何桌面档样式)。
1409
+ '@media (max-width: 640px) {',
1410
+ ' .' + p + 'block-data-table-table {',
1411
+ // 表格、行、格在窄屏一律走块/栅格流(`table` 的列布局是把内容挤扁的机制本身)。
1412
+ ' display: block;',
1413
+ ' font-size: 12px;',
1414
+ ' }',
1415
+ ' .' + p + 'block-data-table-caption {',
1416
+ ' padding: 8px 10px;',
1417
+ ' font-size: 12px;',
1418
+ // 表注是 `<caption>`,缺省 `display:table-caption`;表在窄屏已 `display:block`(行卡化),
1419
+ // 表注留 `table-caption` 会被挤成**一个汉字宽**的竖排(负责人截图实拍的那一列)⇒ 显式转回块级、占满整卡。
1420
+ ' display: block;',
1421
+ ' width: 100%;',
1422
+ // #547:`width:100%` 只管内容宽,而本仓结果页没有全局 `border-box`(`pageUi.ts:173`)⇒
1423
+ // 实占 100%+左右内距,在 390 档撑出 20px 横滑(容器 376>356,真机实测)。只加这一条声明,
1424
+ // 不新增规则(`ui-fix-154` 守卫按选择器计条,加声明不触发),桌面档不在本段、一字不动。
1425
+ ' box-sizing: border-box;',
1426
+ ' }',
1427
+ // 列名不再占一行:它们改住每格的标签里(`data-label`),留着只会把行挤窄。
1428
+ ' .' + p + 'block-data-table thead {',
1429
+ ' display: none;',
1430
+ ' }',
1431
+ // 保留 #457 的 `th` 一档:本组件不再产 `thead` 之外的 `th`,但手写表可能有 `tbody` 里的
1432
+ // `th`(行首标签列),那类格走「标签 + 值」的同一套字号与换行,不能因为「本组件用不到」而删。
1433
+ ' .' + p + 'block-data-table th {',
1434
+ ' padding: 6px 8px;',
1435
+ ' font-size: 11px;',
1436
+ ' letter-spacing: 0;',
1437
+ ' white-space: normal;',
1438
+ ' }',
1439
+ ' .' + p + 'block-data-table tbody {',
1440
+ ' display: block;',
1441
+ ' }',
1442
+ // 一行=一张卡:卡间一条 hairline(首卡无线),卡内左右各 12px 内距(值与容器边框不贴)。
1443
+ ' .' + p + 'block-data-table tr {',
1444
+ ' display: block;',
1445
+ ' padding: 8px 12px;',
1446
+ ' border-top: 1px solid rgba(' + LINE_RGB + ', .6);',
1447
+ ' }',
1448
+ ' .' + p + 'block-data-table tr:first-child {',
1449
+ ' border-top: 0;',
1450
+ ' }',
1451
+ // 一格=一行「标签 + 值」。**#541(负责人第四轮)**:此前是两轨栅格(`5em` 标签轨 + `1fr` 值轨),
1452
+ // 但值轨里的文字**左对齐**且轨本身吃掉整行余宽 ⇒ 390 档实测值与容器右缘之间空掉**半张卡**(负责人
1453
+ // 截图实拍的那片空白),一列行卡读起来像「左边一小撮字,右边一大片空」。
1454
+ // 改成一个 flex 行 + `space-between`:**值贴容器右缘**,标签贴左缘,两端各自成轴。
1455
+ // 长度自适应是 `space-between` 自带的:短值(`70.4`/`2026-09-07`)的伸缩项收到内容宽、被推到右缘;
1456
+ // 长值(备注里一整句)的 max-content 超过可用宽 ⇒ 伸缩项撑满到标签之后并换行,**不会被挤成一条窄柱**。
1457
+ // `align-items: baseline`:值换行成多行时,标签仍落在**首行**的基线上(同族先例 `weightUi.ts` 的
1458
+ // `.wui-strip-v .wui-fact{justify-content:space-between}`)。
1459
+ // 格的底边线在卡形态下取消(分隔已由卡间线承担);值取正文字色 `--fg`,标签取 `--fg3`(判据同列头)。
1460
+ ' .' + p + 'block-data-table td {',
1461
+ ' display: flex;',
1462
+ ' align-items: baseline;',
1463
+ ' justify-content: space-between;',
1464
+ ' gap: 2px 10px;',
1465
+ ' padding: 4px 0;',
1466
+ ' border-bottom: 0;',
1467
+ ' color: var(--fg);',
1468
+ ' font-size: 12px;',
1469
+ ' overflow-wrap: anywhere;',
1470
+ ' }',
1471
+ ' .' + p + 'block-data-table td::before {',
1472
+ ' content: attr(data-label);',
1473
+ // #541:标签轨**不许收缩**。值很长时(备注里一整句)值的伸缩项撑满余宽,标签轨若还能被压,就会
1474
+ // 被压到「一个汉字宽」并按字断行——正是这一票要消灭的那类形态(负责人截图里的竖排)。
1475
+ // `flex: none`=`0 0 auto`:宽度取标签自己的内容宽,既不缩也不长;值那一侧 `min-width:auto` +
1476
+ // `overflow-wrap:anywhere` 仍能一直缩到 1 个字,所以整行永远不会溢出。
1477
+ ' flex: none;',
1478
+ ' color: var(--fg3);',
1479
+ ' font-size: 11.5px;',
1480
+ ' font-weight: 600;',
1481
+ ' }',
1482
+ // 没拿到标签的格(手写 `td`/极端情况下没有列头)**退化**:不出版面的空标签,值单独占满一格。
1483
+ // 判据:空标签不许出现 —— `content:none` 让伪元素整个不生成(不是「生成一个空盒子」)。
1484
+ // 两条选择器**各占一条规则**(不合并成选择器列表):机检按「选择器逐字相等」查规则,
1485
+ // 合并成列表后按单条选择器会找不到(t154-r3 用例实测)。
1486
+ // #541:三件一行的 flex 形态下「只有值」的那一格不需要任何拉开——**去掉 flex 行**,值就是一块满宽的文字。
1487
+ ' .' + p + 'block-data-table td:not([data-label]) {',
1488
+ ' display: block;',
1489
+ ' }',
1490
+ ' .' + p + 'block-data-table td[data-label=""] {',
1491
+ ' display: block;',
1492
+ ' }',
1493
+ ' .' + p + 'block-data-table td:not([data-label])::before {',
1494
+ ' content: none;',
1495
+ ' }',
1496
+ ' .' + p + 'block-data-table td[data-label=""]::before {',
1497
+ ' content: none;',
1498
+ ' }',
1499
+ // 列已经不在,列对齐(`center`/`right`)在卡里只剩副作用:`min-width:5.5em` 会把值轨顶开 ⇒ 下限
1500
+ // 一律收回 0。`text-align` 只留 `left`:`#541` 起「值贴右缘」由容器上的 `justify-content:space-between`
1501
+ // 承担(那是**盒子**的位置),这条管的是**值换行成多行时行内文字怎么排**——长文本左对齐才好读。
1502
+ ' .' + p + 'block-data-table-table td.' + p + 'block-data-table-cell-center,',
1503
+ ' .' + p + 'block-data-table-table td.' + p + 'block-data-table-cell-right {',
1504
+ ' min-width: 0;',
1505
+ ' text-align: left;',
1506
+ ' }',
1507
+ '}',
599
1508
  ].join(LF),
600
1509
  chartBlock: (p) => [
601
1510
  '.' + p + 'block-chart-block {',
@@ -605,6 +1514,10 @@ const BLOCK_SECTION_BUILDERS = {
605
1514
  ' border-radius: ' + RADIUS_MD + 'px;',
606
1515
  ' background: var(--card);',
607
1516
  '}',
1517
+ // #507 段标题统一(审查必改 #5):本条与 `copy-block-title`/`kpi-card-title` 同一条尺子。
1518
+ // 审查点的是**字号**(本族 15px vs `kpi-card-title` 落回 `h2` 缺省的 17.5px);`margin:0 0 10px`
1519
+ // 是 #154 已有的 10px 档,不在本票写集(动它会连带 `test/ui-fix-154.test.mjs` 的同族同值判据),
1520
+ // 故这条只把字号与字重对齐,边距一字不改。
608
1521
  '.' + p + 'block-chart-block-title {',
609
1522
  ' margin: 0 0 10px;',
610
1523
  ' font-size: 15px;',
@@ -615,14 +1528,20 @@ const BLOCK_SECTION_BUILDERS = {
615
1528
  '}',
616
1529
  ].join(LF),
617
1530
  listRows: (p) => [
1531
+ // #154(2026-09-14 交付页返工)① 区块间距:同 dataTable——本区块级此前一条 margin 都没有
1532
+ // (只有 row 的内距),与同族区块的 `margin: 16px 0` 不一致。取同族值(折叠后与 #457 那条同值 16px)。
618
1533
  '.' + p + 'block-list-rows {',
1534
+ ' margin: 16px 0;',
619
1535
  ' border: 1px solid var(--line);',
620
1536
  ' border-radius: ' + RADIUS_MD + 'px;',
621
1537
  ' background: var(--card);',
622
1538
  '}',
623
1539
  '.' + p + 'block-list-rows-row {',
624
1540
  ' display: grid;',
625
- ' grid-template-columns: 44px minmax(0, 1fr) auto;',
1541
+ // #567 D2:首轨 44px 定死把「合计摄入」四字硬折两行(1440 与 390 同值)。
1542
+ // `minmax(44px, auto)`:短内容(▲/▼/— 标记)仍是 44px(旧观感不动、跨行最小对齐不动),
1543
+ // 长标签按内容撑开、恒单行。行自身仍是独立栅格(跨行不对齐本就只靠 44px 下限)。
1544
+ ' grid-template-columns: minmax(44px, auto) minmax(0, 1fr) auto;',
626
1545
  ' gap: 8px;',
627
1546
  ' align-items: center;',
628
1547
  ' padding: 10px 14px;',
@@ -632,6 +1551,11 @@ const BLOCK_SECTION_BUILDERS = {
632
1551
  '.' + p + 'block-list-rows-row:first-child {',
633
1552
  ' border-top: 0;',
634
1553
  '}',
1554
+ // 没给 `left` 的行:#154 加的形态——**不占标记列**(`44px` 是给 `▲`/`▼`/`—` 用的)。
1555
+ // 不给这条规则的话,缺 `left` 的行里 `main` 会落进 44px、被 `nowrap` + `ellipsis` 截断。
1556
+ '.' + p + 'block-list-rows-row-no-left {',
1557
+ ' grid-template-columns: minmax(0, 1fr) auto;',
1558
+ '}',
635
1559
  '.' + p + 'block-list-rows-left {',
636
1560
  ' color: var(--fg3);',
637
1561
  ' font-variant-numeric: tabular-nums;',
@@ -686,7 +1610,9 @@ const BLOCK_SECTION_BUILDERS = {
686
1610
  '}',
687
1611
  '.' + p + 'block-detail-section-title {',
688
1612
  ' margin: 0;',
689
- ' font-size: 17px;',
1613
+ // #567 J4(§5.2 标题 18 吸收 17/18/20/22):17→18。KPI 主数字 22 不动(#154 用户 verdict+
1614
+ // #544 终审既判),级差 22−18=4 仍满足 `ui-fix-154`(≥4px)。
1615
+ ' font-size: 18px;',
690
1616
  ' font-weight: 600;',
691
1617
  '}',
692
1618
  '.' + p + 'block-detail-section-meta {',
@@ -699,7 +1625,8 @@ const BLOCK_SECTION_BUILDERS = {
699
1625
  '.' + p + 'block-detail-section-wake {',
700
1626
  ' color: var(--fg3);',
701
1627
  ' font-family: "SF Mono", monospace;',
702
- ' font-size: 11.5px;',
1628
+ // #567 J4(§5.2 次级 12 吸收 12/11.5)。
1629
+ ' font-size: 12px;',
703
1630
  '}',
704
1631
  '.' + p + 'block-detail-section-dev {',
705
1632
  ' display: inline-flex;',
@@ -734,8 +1661,14 @@ const BLOCK_SECTION_BUILDERS = {
734
1661
  ' background: var(--card);',
735
1662
  '}',
736
1663
  '.' + p + 'block-disclosure-summary {',
737
- ' padding: 10px 14px;',
738
- ' font-size: 14px;',
1664
+ // #179 触控目标:折叠区整条是可点区域,实测 40px 高(10px 上下留白+14px 字)不到 44px
1665
+ // → 改 44px 定高+纵向居中,横向留白不变(与 `.block-disclosure-body` 的 14px 对齐)。
1666
+ ' display: flex;',
1667
+ ' align-items: center;',
1668
+ ' min-height: 44px;',
1669
+ ' padding: 0 14px;',
1670
+ // #567 J4(§5.2 区块标题 15 吸收 14/15)。
1671
+ ' font-size: 15px;',
739
1672
  ' font-weight: 600;',
740
1673
  ' cursor: pointer;',
741
1674
  ' list-style: none;',
@@ -835,6 +1768,98 @@ const BLOCK_SECTION_BUILDERS = {
835
1768
  ' font-size: 15px;',
836
1769
  ' font-weight: 700;',
837
1770
  '}',
1771
+ // ── #154 页内静态提示(浅色形态)─────────────────────────────────────────────
1772
+ // 2026-09-14 用户返工:「顶部有个奇怪的弹窗这是什么?」——页顶那条「本窗只有 1 条记录…」由
1773
+ // `renderFeedbackBlock` 经冻结 `renderToast` 产出,穿的是**深色毛玻璃卡 +「✓ 知道了」**的
1774
+ // 弹窗外衣,而它是页面流里的一段静态块(t375 证据件:整份产物 `position: fixed` 出现 0 次、
1775
+ // 没有遮罩与焦点陷阱)。深色卡面是**运行时瞬时 toast** 的形态(2026-09-12 用户亲自裁定),
1776
+ // 两者不是一回事:本形态只在 `staticNotice === true` 时出现(缺省逐字节不变)。
1777
+ // 浅底/描边/圆角逐值取本文件里既有口径:底 `var(--soft)`、描边 1px `var(--line)`、
1778
+ // 圆角 `RADIUS_MD`(14px,闭集 {8,14,20,999} 内);**不带关闭按钮**(静态提示不该能被点掉)。
1779
+ '.' + p + 'block-feedback-block-note {',
1780
+ ' display: flex;',
1781
+ ' align-items: flex-start;',
1782
+ ' gap: 10px;',
1783
+ ' margin: 16px 0;',
1784
+ ' padding: 12px 14px;',
1785
+ ' border: 1px solid var(--line);',
1786
+ ' border-radius: ' + RADIUS_MD + 'px;',
1787
+ ' background: var(--soft);',
1788
+ ' color: var(--fg2);',
1789
+ ' font-size: 13px;',
1790
+ ' line-height: 1.5;',
1791
+ '}',
1792
+ // 图标底盘:`kind` 的语义色落在**底盘底色+字色**上。字形是 emoji(`TOAST_ICON_GLYPHS`,
1793
+ // 与 toast 同源),彩色 emoji 的 `color` 对它无效——语义色因此由底盘承担,`color` 只对
1794
+ // 无彩色字形(等宽/降级字体)生效,两处都写着,缺一不可。
1795
+ '.' + p + 'block-feedback-block-note-icon {',
1796
+ ' display: inline-flex;',
1797
+ ' flex: 0 0 auto;',
1798
+ ' align-items: center;',
1799
+ ' justify-content: center;',
1800
+ ' width: 22px;',
1801
+ ' height: 22px;',
1802
+ ' border-radius: ' + RADIUS_SM + 'px;',
1803
+ ' background: var(--card);',
1804
+ ' color: var(--fg2);',
1805
+ ' font-size: 13px;',
1806
+ ' line-height: 1;',
1807
+ '}',
1808
+ // 三档实色**逐值取同仓既有的状态徽章**(`src/style.ts` 的 `statusBadge` 区:ok `#e6f7ec`/`#1f8c3d`、
1809
+ // warn `#fff5e0`/`#a25b00`、danger `#fff0ee`/`#a83228`,原样取自旧层 `.hm-status.*`)——
1810
+ // 不发明新色值;`info`/`copy` 两档走冻结 token(`--card`/`--blue2`/`--fg2`),也不发明。
1811
+ '.' + p + 'block-feedback-block-note-icon-ok {',
1812
+ ' background: #e6f7ec;',
1813
+ ' color: #1f8c3d;',
1814
+ '}',
1815
+ '.' + p + 'block-feedback-block-note-icon-warn {',
1816
+ ' background: #fff5e0;',
1817
+ ' color: #a25b00;',
1818
+ '}',
1819
+ '.' + p + 'block-feedback-block-note-icon-danger {',
1820
+ ' background: #fff0ee;',
1821
+ ' color: #a83228;',
1822
+ '}',
1823
+ '.' + p + 'block-feedback-block-note-icon-info {',
1824
+ ' background: var(--card);',
1825
+ ' color: var(--blue2);',
1826
+ '}',
1827
+ '.' + p + 'block-feedback-block-note-icon-copy {',
1828
+ ' background: var(--card);',
1829
+ ' color: var(--fg2);',
1830
+ '}',
1831
+ '.' + p + 'block-feedback-block-note-body {',
1832
+ ' flex: 1 1 auto;',
1833
+ ' min-width: 0;',
1834
+ '}',
1835
+ // 正文=正文字色 + 600 字重(与 `.toast-title` 的层级口径一致,但取浅色面 token)。
1836
+ '.' + p + 'block-feedback-block-note-title {',
1837
+ ' color: var(--fg);',
1838
+ ' font-weight: 600;',
1839
+ '}',
1840
+ '.' + p + 'block-feedback-block-note-detail {',
1841
+ ' margin-top: 2px;',
1842
+ ' color: var(--fg2);',
1843
+ '}',
1844
+ '.' + p + 'block-feedback-block-note-lines {',
1845
+ ' margin-top: 2px;',
1846
+ ' color: var(--fg2);',
1847
+ ' white-space: pre-wrap;',
1848
+ '}',
1849
+ // 代码块:与 `preBlock`/help 的等宽口径同族(12px/1.55/pre-wrap/overflow-x:auto),
1850
+ // 但浅色面用 `var(--card)` 抬一级底色(`var(--soft)` 上再放浅底会糊成一片)。
1851
+ '.' + p + 'block-feedback-block-note-code {',
1852
+ ' margin: 6px 0 0;',
1853
+ ' padding: 8px 10px;',
1854
+ ' border-radius: ' + RADIUS_SM + 'px;',
1855
+ ' background: var(--card);',
1856
+ ' color: var(--fg2);',
1857
+ ' font-family: "SF Mono", monospace;',
1858
+ ' font-size: 12px;',
1859
+ ' line-height: 1.55;',
1860
+ ' white-space: pre-wrap;',
1861
+ ' overflow-x: auto;',
1862
+ '}',
838
1863
  ].join(LF),
839
1864
  };
840
1865
  /** 闭集漂移 fail-fast(与 `src/style.ts` 同口径):缺区实现导入即抛。 */
@@ -845,7 +1870,7 @@ for (const section of BLOCK_STYLE_SECTIONS) {
845
1870
  }
846
1871
  /** 区块样式资产唯一产出者(用法:调用方把返回值拼进 `TemplateAssets.sharedCssText`
847
1872
  * 再交 `fillTemplate` 包裹注入;不得走 `StyleSheetInput.extraCss`——其语义被契约
848
- * 限死为技能作用域 token 覆盖块,不得塞壳层样式)。恒返回非空 CSS 文本。 */
1873
+ * 限死为技能作用域 token 覆盖块,不得塞共享页面模板的样式)。恒返回非空 CSS 文本。 */
849
1874
  export function blocksCss(input) {
850
1875
  const prefix = input !== undefined && input !== null && typeof input.prefix === 'string' && input.prefix !== ''
851
1876
  ? input.prefix