base-paint 0.3.1 → 0.3.3

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 (62) hide show
  1. package/README.md +94 -1
  2. package/dist/blocks.d.ts +160 -6
  3. package/dist/blocks.d.ts.map +1 -1
  4. package/dist/blocks.js +1212 -27
  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 +426 -67
  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 +242 -34
  13. package/dist/controls.js.map +1 -1
  14. package/dist/font.d.ts +3 -3
  15. package/dist/font.js +3 -3
  16. package/dist/help.d.ts +3 -3
  17. package/dist/help.d.ts.map +1 -1
  18. package/dist/help.js +32 -15
  19. package/dist/help.js.map +1 -1
  20. package/dist/helpShell.d.ts.map +1 -1
  21. package/dist/helpShell.js +2 -2
  22. package/dist/helpShell.js.map +1 -1
  23. package/dist/index.d.ts +10 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +8 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/injector.d.ts +1 -1
  28. package/dist/output/saveHtml.d.ts +37 -2
  29. package/dist/output/saveHtml.d.ts.map +1 -1
  30. package/dist/output/saveHtml.js +125 -15
  31. package/dist/output/saveHtml.js.map +1 -1
  32. package/dist/pageShapes.d.ts +117 -0
  33. package/dist/pageShapes.d.ts.map +1 -0
  34. package/dist/pageShapes.js +366 -0
  35. package/dist/pageShapes.js.map +1 -0
  36. package/dist/pageUi.d.ts +37 -0
  37. package/dist/pageUi.d.ts.map +1 -0
  38. package/dist/pageUi.js +237 -0
  39. package/dist/pageUi.js.map +1 -0
  40. package/dist/spec/charts.d.ts +16 -3
  41. package/dist/spec/charts.d.ts.map +1 -1
  42. package/dist/spec/charts.js +1 -1
  43. package/dist/spec/charts.js.map +1 -1
  44. package/dist/spec/controls.d.ts +30 -5
  45. package/dist/spec/controls.d.ts.map +1 -1
  46. package/dist/spec/controls.js +16 -3
  47. package/dist/spec/controls.js.map +1 -1
  48. package/dist/spec/help.d.ts +34 -6
  49. package/dist/spec/help.d.ts.map +1 -1
  50. package/dist/spec/help.js +21 -4
  51. package/dist/spec/help.js.map +1 -1
  52. package/dist/spec/index.d.ts +6 -3
  53. package/dist/spec/index.d.ts.map +1 -1
  54. package/dist/spec/index.js +27 -2
  55. package/dist/spec/index.js.map +1 -1
  56. package/dist/style.d.ts.map +1 -1
  57. package/dist/style.js +279 -47
  58. package/dist/style.js.map +1 -1
  59. package/dist/text.d.ts.map +1 -1
  60. package/dist/text.js +7 -2
  61. package/dist/text.js.map +1 -1
  62. package/package.json +1 -1
package/dist/blocks.js CHANGED
@@ -29,9 +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';
32
+ import { renderActionBar, renderEmptyState, renderErrorReceipt, renderStatusBadge, renderToast, TOAST_ICON_GLYPHS } from './controls.js';
33
33
  import { BODY_FONT_STACK } from './font.js';
34
- 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';
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';
35
35
  import { STYLE_PREFIX } from './style.js';
36
36
  /* ── 错误形态(与 TemplateError/ControlsError 同口径:抛、不返空;调用方按 name/code 判定) ── */
37
37
  /** 区块层错误。code 恒为 `bad-input`(输入缺失/非法一律抛,缺失阻断不返空)。 */
@@ -70,6 +70,46 @@ function reqText(value, field) {
70
70
  function optText(value) {
71
71
  return typeof value === 'string' && value !== '' ? value : undefined;
72
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
+ }
73
113
  /* ── 区块样式区闭集(DB-2:新增闭集,不改 CONTROL_STYLE_SECTIONS) ── */
74
114
  /** 12 区块样式区(DB-1 清单的代码落点;顺序即 B-01…B-12)。 */
75
115
  export const BLOCK_STYLE_SECTIONS = [
@@ -98,6 +138,17 @@ function blockRoot(section) {
98
138
  function blockPart(section, part) {
99
139
  return STYLE_PREFIX + 'block-' + sectionSlug(section) + '-' + part;
100
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
+ }
101
152
  /** B-01:单页容器+标题三件套(eyebrow/title/subtitle)+正文。 */
102
153
  export function renderPageShell(input) {
103
154
  assertPlainObject(input, 'renderPageShell: input');
@@ -106,7 +157,9 @@ export function renderPageShell(input) {
106
157
  const title = reqText(shell.title, 'renderPageShell: input.title');
107
158
  if (typeof shell.content !== 'string')
108
159
  badInput('renderPageShell: input.content 必须是字符串');
109
- 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 + '">'];
110
163
  const eyebrow = optText(shell.eyebrow);
111
164
  if (eyebrow !== undefined)
112
165
  parts.push('<p class="' + blockPart('pageShell', 'eyebrow') + '">' + esc(eyebrow) + '</p>');
@@ -118,6 +171,272 @@ export function renderPageShell(input) {
118
171
  parts.push('</section>');
119
172
  return parts.join('');
120
173
  }
174
+ /** #420-1 页内导航区块(`<nav aria-label="页内导航">` + 逐项 `<a href="#id">`)。
175
+ * 空列表=不出这一块(与「没内容不留空块」同口径,返回空串);缺失(`undefined`)→ `bad-input`
176
+ * (非数组即拒);项缺 `id`/`text` → `bad-input`。 */
177
+ export function renderTocBlock(input) {
178
+ assertPlainObject(input, 'renderTocBlock: input');
179
+ assertNoInlineHandler(input, 'renderTocBlock: input');
180
+ const block = input;
181
+ if (!Array.isArray(block.items))
182
+ badInput('renderTocBlock: input.items 必须是数组');
183
+ if (block.items.length === 0)
184
+ return '';
185
+ const links = [];
186
+ for (const item of block.items) {
187
+ assertPlainObject(item, 'renderTocBlock: items[] 元素');
188
+ assertNoInlineHandler(item, 'renderTocBlock: items[] 元素');
189
+ links.push('<a href="#' + esc(reqText(item.id, 'renderTocBlock: items[].id')) + '">'
190
+ + esc(reqText(item.text, 'renderTocBlock: items[].text')) + '</a>');
191
+ }
192
+ return '<nav class="' + pageLevelBlock('toc') + '" aria-label="页内导航">' + links.join('') + '</nav>';
193
+ }
194
+ /** 口径行的并列分隔符(U+FF5C 全角竖线):**唯一**被当作「并列」的字符。
195
+ * #514 立规把它计入可见文本的设计债(R4),本票(负责人第三轮要求 1)就是把这笔债在
196
+ * **公共层一次还掉**:该字符不再进产物文本,分隔改由版式承担(见 `renderCaliberLine` 与本区 CSS)。 */
197
+ const CALIBER_SEP = '|';
198
+ /** #420-2 口径说明行(纯文本单参;五字符转义表与区块层其余函数同源 `esc`)。
199
+ * 聚合数字旁那句灰色小字(例如「周目标口径=每日目标 × 7」)的唯一落点。
200
+ *
201
+ * t154-r3(负责人第三轮要求 1「用竖线分割的部分都要优化」):**签名一字不改**(全仓 141 处
202
+ * 调用点一行不用动),函数内部按全角竖线拆段 ⇒ 逐段一枚 `<span>`,段间分隔交给 CSS 的细竖线
203
+ * (`.…-caliber > span + span` 的 `border-left` hairline + flex 间距)——「拿字符当分隔」换成
204
+ * 「拿版式当分隔」。判据:产物该行的**文本里不再出现该竖线字符**(分隔只活在版式里)。
205
+ * · 不带分隔符的行(多数调用点)**逐字节同改前**:仍是「一个 `<p>` + 一段纯文本」;
206
+ * flex 只对「真的有 span 子件」的行生效(CSS 侧 `.…-caliber:has(> span)`)。
207
+ * · 空段丢弃(首尾竖线/连续两条竖线):不出版面的空位;全空的无实义行退化回原样文本
208
+ * (没有可分的段,不静默吞字符)。 */
209
+ export function renderCaliberLine(text) {
210
+ const raw = reqText(text, 'renderCaliberLine: text');
211
+ const cls = pageLevelBlock('caliber');
212
+ if (!raw.includes(CALIBER_SEP))
213
+ return '<p class="' + cls + '">' + esc(raw) + '</p>';
214
+ const segs = raw.split(CALIBER_SEP).map((seg) => seg.trim()).filter((seg) => seg !== '');
215
+ if (segs.length === 0)
216
+ return '<p class="' + cls + '">' + esc(raw) + '</p>';
217
+ // 段间留一个空格:flex 容器里纯空白文本节点不成匿名项(不占版面),但**文本面**(复制/
218
+ // 无障碍树/测试的可见文本读数)读出来仍是「甲 乙」而不是「甲乙」。
219
+ return '<p class="' + cls + '">'
220
+ + segs.map((seg) => '<span>' + esc(seg) + '</span>').join(' ') + '</p>';
221
+ }
222
+ /** #507 结论条(审查必改 #3):一行判定句的浅底条,形状住公共层、**调用方只传文本**。
223
+ *
224
+ * 与 `renderCaliberLine` 同族(页面级、单参纯文本、五字符转义同源 `esc`;样式随 `pageShell` 区落盘,
225
+ * 不进 `BLOCK_STYLE_SECTIONS` 那 12 项闭集)。区别只在角色:口径行是**旁注**(灰小字),
226
+ * 结论条是**主读法**(一行结论句,读者扫一眼就知道今天怎么样)。
227
+ *
228
+ * 此前这条形状是**页面内联 8 个魔法值**(`homeDocs.ts` 的 `CONCLUSION_STYLE`,逐字照搬老实物
229
+ * `.view-summary`),与本仓「版面单源住公共层」的纪律冲突——该件自述「本页不再出现任何一条
230
+ * 自写 `font-size`/`color` 规则」,而 `color:var(--blue2)` 就在那串内联里。搬到这里之后,
231
+ * 页面侧只留「传什么文本」。 */
232
+ export function renderConclusionBar(text) {
233
+ return '<p class="' + pageLevelBlock('conclusion') + '">' + esc(reqText(text, 'renderConclusionBar: text')) + '</p>';
234
+ }
235
+ /* ══════════════════════════════════════════════════════════════
236
+ * #421 页面融合四件(与领域无关:文案/数值/颜色全由调用方给)
237
+ * 占比迷你条/分布条行=「数字+图形同格」;徽章=并列小标签;字段变更行=改前改后对照。
238
+ * 四件都是页面级(不属 12 区块),样式随 `pageShell` 区落盘(同 #420 处置:不新增样式区)。
239
+ * ══════════════════════════════════════════════════════════════ */
240
+ /** 页面级子件类(#421):`ilife-block-<名>-<件>`(与 `pageLevelBlock` 同命名空间,不进 12 区闭集)。 */
241
+ function pageLevelPart(name, part) {
242
+ return pageLevelBlock(name) + '-' + part;
243
+ }
244
+ /** 百分比(#421):只收有限数,两端都夹到 0–100(越界夹取是本条唯一口径;非数走 `badInput`)。 */
245
+ function reqPct(value, field) {
246
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
247
+ badInput(field + ' 必须是 0–100 的有限数(越界夹取)');
248
+ }
249
+ return Math.min(100, Math.max(0, value));
250
+ }
251
+ /** 填充条的内联声明(#421/#431):宽度恒有;**背景只在调用方给了颜色时才出现**——不给时不落内联色,
252
+ * 填充色由样式段两条 `-fill` 规则里的**冻结 token** `var(--blue)` 兜底(缺省色走冻结 token,
253
+ * 区块自身不写死任何色值字面量;判据见 `test/page-viz-421.test.mjs`「缺省色走冻结 token」一条)。 */
254
+ function fillDecls(pct, color) {
255
+ return 'width:' + String(pct) + '%' + (color === undefined ? '' : ';background:' + color);
256
+ }
257
+ /** 冻结 token 名(#431):色值入参里 `var()` 只许引用 `CSS_VAR_TOKENS` 的 11 个名字——公共层只定义
258
+ * 这 11 个,引用没定义的名字会让填充的 `background` 变成 guaranteed-invalid(条静默变透明、不报错),
259
+ * 故清单外一律 `bad-input`(与 `blocksCss` 的「`var()` 引用必须是冻结 token」同一条纪律)。 */
260
+ function isFrozenToken(name) {
261
+ return Object.hasOwn(CSS_VAR_TOKENS, name);
262
+ }
263
+ /** CSS 具名色全表(#431 色值允许清单第四支):CSS Color Level 4「Named colors」的 148 个具名色。
264
+ * 这里只管**收不收**(比对时大小写不敏感),取值由浏览器定、本仓不写第二份。`transparent` 与
265
+ * `currentColor` 是关键字不是具名色,不在表内。 */
266
+ const CSS_NAMED_COLORS = new Set(('aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown'
267
+ + ' burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan'
268
+ + ' darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid'
269
+ + ' darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet'
270
+ + ' deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro'
271
+ + ' ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki'
272
+ + ' lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow'
273
+ + ' lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray'
274
+ + ' lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine'
275
+ + ' mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise'
276
+ + ' mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab'
277
+ + ' orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru'
278
+ + ' pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown'
279
+ + ' seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan'
280
+ + ' teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen').split(' '));
281
+ /** `rgb()`/`rgba()` 形态(#431):分量只许数字(可带 `%`),逗号或空格分隔、alpha 可用 `/` 引。
282
+ * 只校验形态、不校验取值范围(越界由 CSS 自己夹取);`expression(`/`url(` 这类带字母的实参在此挡下。 */
283
+ function isRgbFunction(raw) {
284
+ const hit = /^rgba?\(\s*([^)]*)\)$/i.exec(raw);
285
+ if (hit === null)
286
+ return false;
287
+ const parts = hit[1].split(/[,/]|\s+/).filter((part) => part !== '');
288
+ return (parts.length === 3 || parts.length === 4)
289
+ && parts.every((part) => /^\d*\.?\d+%?$/.test(part));
290
+ }
291
+ /** 色值入参(#421/#431):`undefined` 透传(缺省色走样式段的冻结 token);清单内的写法**逐字透传**
292
+ * (裸 token 名 `--x` 包成 `var(--x)`)。允许清单=`#rgb`/`#rrggbb`/`rgb()`/`rgba()`/
293
+ * `var(--<冻结 token 名>)`/CSS 具名色;**清单外一律 `bad-input`**——`;`/`expression(`/`url(` 这类
294
+ * 注入形态在这一步挡住,不让 `;` 穿进内联声明列表(审查 S3-6)。
295
+ * 迁移提示(#441 实测,复核 D1 补第 4 处):生产侧显式传色 4 处(`diet/nutritionPortDocs.ts:339` 一处带 3 个 `#rrggbb` 值、
296
+ * `render/sportPortDocs.ts:609/775` 两处经 `categoryColor()` 取 4 个 `#rrggbb` 值,`diet/reviewDocs.ts:339-341` 一处经 `MEAL_COLORS` 由 `diet/todayDocs.ts:242` 进入生产),均属允许形态,
297
+ * 无须改动;其余生产调用不传色,走缺省冻结 token;新增写法先对清单,清单外改走冻结 token 或具名色。 */
298
+ function optColor(value, field) {
299
+ if (value === undefined)
300
+ return undefined;
301
+ if (typeof value !== 'string' || value.trim() === '')
302
+ badInput(field + ' 必须是非空字符串(冻结 token 名或色值)');
303
+ const raw = value.trim();
304
+ if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(raw))
305
+ return raw;
306
+ if (isRgbFunction(raw))
307
+ return raw;
308
+ const tokenRef = /^var\(\s*(--[A-Za-z0-9-]+)\s*\)$/.exec(raw);
309
+ if (tokenRef !== null) {
310
+ if (!isFrozenToken(tokenRef[1]))
311
+ badInput(field + ' 引用了未冻结的 token(只许 11 个冻结 token):' + tokenRef[1]);
312
+ return raw;
313
+ }
314
+ if (/^--[A-Za-z0-9-]+$/.test(raw)) {
315
+ if (!isFrozenToken(raw))
316
+ badInput(field + ' 引用了未冻结的 token(只许 11 个冻结 token):' + raw);
317
+ return 'var(' + raw + ')';
318
+ }
319
+ if (CSS_NAMED_COLORS.has(raw.toLowerCase()))
320
+ return raw;
321
+ badInput(field + ' 不在色值允许清单里(#rgb/#rrggbb/rgb()/rgba()/var(--<冻结 token>)/CSS 具名色):' + raw);
322
+ }
323
+ /** 附加类名(#421):`undefined` 透传;给了须是空格分隔的类名(调用方按域标色,区块不认领域)。 */
324
+ function optExtraClass(value, field) {
325
+ if (value === undefined)
326
+ return undefined;
327
+ if (typeof value !== 'string' || value.trim() === '')
328
+ badInput(field + ' 必须是非空字符串');
329
+ const names = value.trim().split(/\s+/);
330
+ for (const name of names) {
331
+ if (!/^-?[A-Za-z_][A-Za-z0-9_-]*$/.test(name))
332
+ badInput(field + ' 只许空格分隔的类名:' + name);
333
+ }
334
+ return names.join(' ');
335
+ }
336
+ /** #421 四件的类名主体(`pageLevelBlock(名)` → `ilife-block-<名>`)。 */
337
+ const MINI_BAR_NAME = 'mini-bar';
338
+ const DIST_ROW_NAME = 'dist-row';
339
+ const CHIP_NAME = 'chip';
340
+ const CHANGE_ROW_NAME = 'change-row';
341
+ /** 箭头字形(#421-4):`arrow: false` 靠可见性占位,字形逐字保留(栏位不塌)。 */
342
+ const CHANGE_ARROW_GLYPH = '\u2192';
343
+ /** #421-1 占比迷你条(「数字+图形同格」的图形侧):一截横条按 `pct` 撑宽。
344
+ * 只表达「一眼看出差距」,不替代图表——精确读数(坐标/悬停)仍走 `renderChartBlock` 的共享图表。 */
345
+ export function renderMiniBar(input) {
346
+ assertPlainObject(input, 'renderMiniBar: input');
347
+ assertNoInlineHandler(input, 'renderMiniBar: input');
348
+ const bar = input;
349
+ const pct = reqPct(bar.pct, 'renderMiniBar: input.pct');
350
+ const color = optColor(bar.color, 'renderMiniBar: input.color');
351
+ return '<span class="' + pageLevelBlock(MINI_BAR_NAME) + '" role="img" aria-label="' + String(pct) + '%">'
352
+ + '<span class="' + pageLevelPart(MINI_BAR_NAME, 'fill') + '" style="' + esc(fillDecls(pct, color)) + '"></span>'
353
+ + '</span>';
354
+ }
355
+ /** #421-2 分布条行:`名称 | 条 | 数值` 一格三栏,逐行拼出(行即件,不另加容器类)。
356
+ * `rows: []` = 空串(与「没内容不留空块」同口径);缺失(`undefined`)→ `bad-input`(非数组即拒);
357
+ * 行内校验逐条 fail-fast。 */
358
+ export function renderDistributionRows(input) {
359
+ assertPlainObject(input, 'renderDistributionRows: input');
360
+ assertNoInlineHandler(input, 'renderDistributionRows: input');
361
+ const block = input;
362
+ if (!Array.isArray(block.rows))
363
+ badInput('renderDistributionRows: input.rows 必须是数组');
364
+ if (block.rows.length === 0)
365
+ return '';
366
+ return block.rows.map((row, i) => {
367
+ const field = 'renderDistributionRows: input.rows[' + i + ']';
368
+ assertPlainObject(row, field);
369
+ assertNoInlineHandler(row, field);
370
+ const item = row;
371
+ const label = reqText(item.label, field + '.label');
372
+ const pct = reqPct(item.pct, field + '.pct');
373
+ const color = optColor(item.color, field + '.color');
374
+ const extra = optExtraClass(item.labelClass, field + '.labelClass');
375
+ return '<div class="' + pageLevelBlock(DIST_ROW_NAME) + '">'
376
+ + '<span class="' + pageLevelPart(DIST_ROW_NAME, 'name') + (extra === undefined ? '' : ' ' + extra) + '">'
377
+ + esc(label) + '</span>'
378
+ + '<span class="' + pageLevelPart(DIST_ROW_NAME, 'bar') + '">'
379
+ + '<span class="' + pageLevelPart(DIST_ROW_NAME, 'fill') + '" style="' + esc(fillDecls(pct, color)) + '"></span>'
380
+ + '</span>'
381
+ + '<span class="' + pageLevelPart(DIST_ROW_NAME, 'val') + '">'
382
+ + cellText(item.value, field + '.value') + '</span>'
383
+ + '</div>';
384
+ }).join('');
385
+ }
386
+ /** #421-3 徽章(并列小标签,如分类/标签这类短词并排):逐项 `<span class="ilife-block-chip">`。
387
+ * 项即件(不另加容器类);`items: []` = 空串;缺失(`undefined`)→ `bad-input`(非数组即拒)。 */
388
+ export function renderChips(input) {
389
+ assertPlainObject(input, 'renderChips: input');
390
+ assertNoInlineHandler(input, 'renderChips: input');
391
+ const block = input;
392
+ if (!Array.isArray(block.items))
393
+ badInput('renderChips: input.items 必须是数组');
394
+ if (block.items.length === 0)
395
+ return '';
396
+ return block.items.map((entry, i) => {
397
+ const field = 'renderChips: input.items[' + i + ']';
398
+ assertPlainObject(entry, field);
399
+ assertNoInlineHandler(entry, field);
400
+ return '<span class="' + pageLevelBlock(CHIP_NAME) + '">'
401
+ + esc(reqText(entry.text, field + '.text')) + '</span>';
402
+ }).join('');
403
+ }
404
+ /** #421-4 字段变更行(回执页「改前 → 改后」对照):`字段名 | 改前 | 箭头 | 改后`。
405
+ * `arrow: false` 只把字形藏起来、不删栏位(与老技能同一手法:左右栏靠箭位对齐);
406
+ * `rows: []` = 空串;缺失(`undefined`)→ `bad-input`(非数组即拒)。 */
407
+ export function renderChangeRows(input) {
408
+ assertPlainObject(input, 'renderChangeRows: input');
409
+ assertNoInlineHandler(input, 'renderChangeRows: input');
410
+ const block = input;
411
+ if (!Array.isArray(block.rows))
412
+ badInput('renderChangeRows: input.rows 必须是数组');
413
+ if (block.rows.length === 0)
414
+ return '';
415
+ return block.rows.map((row, i) => {
416
+ const field = 'renderChangeRows: input.rows[' + i + ']';
417
+ assertPlainObject(row, field);
418
+ assertNoInlineHandler(row, field);
419
+ const item = row;
420
+ const label = reqText(item.label, field + '.label');
421
+ return '<div class="' + pageLevelBlock(CHANGE_ROW_NAME) + '">'
422
+ + '<span class="' + pageLevelPart(CHANGE_ROW_NAME, 'label') + '">' + esc(label) + '</span>'
423
+ + '<span class="' + pageLevelPart(CHANGE_ROW_NAME, 'old') + '">'
424
+ + cellText(item.before, field + '.before') + '</span>'
425
+ + '<span class="' + pageLevelPart(CHANGE_ROW_NAME, 'arrow') + '" aria-hidden="true"'
426
+ + (item.arrow === false ? ' style="visibility:hidden"' : '') + '>' + CHANGE_ARROW_GLYPH + '</span>'
427
+ + '<span class="' + pageLevelPart(CHANGE_ROW_NAME, 'new') + '">'
428
+ + cellText(item.after, field + '.after') + '</span>'
429
+ + '</div>';
430
+ }).join('');
431
+ }
432
+ /** #418 条位判档(只此一处):>=90 高档/>=60 中档/其余低档。 */
433
+ function kpiBarClass(pct) {
434
+ if (pct >= 90)
435
+ return 'bar-high';
436
+ if (pct >= 60)
437
+ return 'bar-mid';
438
+ return 'bar-low';
439
+ }
121
440
  /** B-02:单张 KPI 卡(value 带 `tnum`;非法 status 由冻结语义降级 `empty`)。 */
122
441
  export function renderKpiCard(input) {
123
442
  assertPlainObject(input, 'renderKpiCard: input');
@@ -136,6 +455,17 @@ export function renderKpiCard(input) {
136
455
  const detail = optText(card.detail);
137
456
  if (detail !== undefined)
138
457
  parts.push('<div class="' + blockPart('kpiCard', 'detail') + '">' + esc(detail) + '</div>');
458
+ const bar = card.bar;
459
+ if (bar !== undefined) {
460
+ assertPlainObject(bar, 'renderKpiCard: bar');
461
+ const pct = bar.pct;
462
+ if (typeof pct !== 'number' || !Number.isFinite(pct) || pct < 0 || pct > 100) {
463
+ badInput('renderKpiCard: bar.pct 必须在 0–100 之间');
464
+ }
465
+ parts.push('<div class="' + blockPart('kpiCard', 'bar') + '">'
466
+ + '<div class="' + blockPart('kpiCard', 'bar-fill') + ' ' + blockPart('kpiCard', kpiBarClass(pct)) + '" style="width:' + pct + '%"></div>'
467
+ + '</div>');
468
+ }
139
469
  if (card.status !== undefined) {
140
470
  const status = STATUS_KINDS.includes(card.status) ? card.status : 'empty';
141
471
  const badge = renderStatusBadge(card.statusText === undefined ? { status } : { status, text: card.statusText });
@@ -145,11 +475,22 @@ export function renderKpiCard(input) {
145
475
  return parts.join('');
146
476
  }
147
477
  /** B-02 伴生布局:KPI 卡网格(不是第 13 个区块,无独立样式区,用 `kpiCard` 区)。 */
148
- export function renderKpiGrid(cards) {
478
+ export function renderKpiGrid(cards, opts) {
149
479
  if (!Array.isArray(cards) || cards.length === 0)
150
480
  badInput('renderKpiGrid: cards 必须是非空数组');
151
- return '<div class="' + blockPart('kpiCard', 'grid') + '">'
481
+ if (opts !== undefined) {
482
+ assertPlainObject(opts, 'renderKpiGrid: opts');
483
+ assertNoInlineHandler(opts, 'renderKpiGrid: opts');
484
+ }
485
+ const grid = '<div class="' + blockPart('kpiCard', 'grid') + '">'
152
486
  + cards.map((card) => renderKpiCard(card)).join('') + '</div>';
487
+ // #513:不给 title 即返回原 grid 串(与改前逐字节同);给了即标题 h2 +原 grid
488
+ // (与页面当刻手写形一致,页面票后迁可逐字节对)。标题类复用 #507 已落盘的 `kpi-card-title`
489
+ //(15px/700),不新增类名、不新增样式区。
490
+ const title = opts === undefined ? undefined : optText(opts.title);
491
+ if (title === undefined)
492
+ return grid;
493
+ return '<h2 class="' + blockPart('kpiCard', 'title') + '">' + esc(title) + '</h2>' + grid;
153
494
  }
154
495
  const TABLE_ALIGNS = ['left', 'center', 'right'];
155
496
  function cellText(value, field) {
@@ -170,6 +511,10 @@ export function renderDataTable(input) {
170
511
  }
171
512
  if (!Array.isArray(table.rows))
172
513
  badInput('renderDataTable: input.rows 必须是数组');
514
+ if (table.cellHtml !== undefined && typeof table.cellHtml !== 'function') {
515
+ badInput('renderDataTable: input.cellHtml 必须是函数');
516
+ }
517
+ const cellHtml = table.cellHtml;
173
518
  const columns = table.columns.map((column, i) => {
174
519
  const field = 'renderDataTable: input.columns[' + i + ']';
175
520
  assertPlainObject(column, field);
@@ -187,9 +532,41 @@ export function renderDataTable(input) {
187
532
  const head = '<thead><tr>' + columns.map((column) => '<th scope="col" class="' + blockPart('dataTable', 'cell-' + column.align) + '">' + esc(column.label) + '</th>').join('') + '</tr></thead>';
188
533
  const body = '<tbody>' + table.rows.map((row, ri) => {
189
534
  assertPlainObject(row, 'renderDataTable: input.rows[' + ri + ']');
190
- return '<tr>' + columns.map((column) => '<td class="' + blockPart('dataTable', 'cell-' + column.align) + '">'
191
- + cellText(row[column.key], 'renderDataTable: input.rows[' + ri + '].' + column.key)
192
- + '</td>').join('') + '</tr>';
535
+ // #513 行标记槽:可选 `marker`(非空字符串 ⇒ 首格前插行徽标;缺省/空串 ⇒ 零渲染,
536
+ // 本行与改前逐字节同);非字符串非 undefined 即 `bad-input`(与 `cellText` 同严格)。
537
+ const rawMarker = row.marker;
538
+ if (rawMarker !== undefined && typeof rawMarker !== 'string') {
539
+ badInput('renderDataTable: input.rows[' + ri + '].marker 必须是非空字符串/undefined');
540
+ }
541
+ const marker = rawMarker === undefined ? undefined : optText(rawMarker);
542
+ return '<tr>' + columns.map((column, ci) => {
543
+ const raw = row[column.key];
544
+ // #567 `cellHtml`:给了且对本格返回字符串即受信透传(调用方已转义);
545
+ // 返回 `undefined` 即走缺省转义文本。不给 `cellHtml` 时本行与改前逐字同。
546
+ let trusted;
547
+ if (cellHtml !== undefined) {
548
+ const out = cellHtml(column.key, raw);
549
+ if (out !== undefined) {
550
+ if (typeof out !== 'string')
551
+ badInput('renderDataTable: input.cellHtml 必须返回 string/undefined');
552
+ trusted = out;
553
+ }
554
+ }
555
+ // t154-r3(负责人第三轮要求 2):每个数据格带上**列头文本**(`data-label`),窄屏行卡化
556
+ // 时由 CSS 的 `td::before{content:attr(data-label)}` 出「标签 + 值」,列头因此不必再挤在
557
+ // 390px 里。**只加属性**:既有类名、既有结构、既有文本一字不动,桌面档(≥641)没有任何
558
+ // 规则引用它 ⇒ 桌面渲染逐像素不变(自证读数见证据件)。列头文本与 `th` 同源(同一个
559
+ // `column.label`),不存在两处文案漂移。`esc` 已含 `"`(五字符表)⇒ 标签含引号也安全。
560
+ const text = trusted === undefined
561
+ ? cellText(raw, 'renderDataTable: input.rows[' + ri + '].' + column.key)
562
+ : trusted;
563
+ const open = '<td class="' + blockPart('dataTable', 'cell-' + column.align) + '" data-label="' + esc(column.label) + '">';
564
+ // #513:行标记只进首格(转义后前插;`cellHtml` 受信透传不受影响)。
565
+ const head = ci === 0 && marker !== undefined
566
+ ? '<span class="' + blockPart('dataTable', 'row-marker') + '">' + esc(marker) + '</span>'
567
+ : '';
568
+ return open + head + text + '</td>';
569
+ }).join('') + '</tr>';
193
570
  }).join('') + '</tbody>';
194
571
  const caption = optText(table.caption);
195
572
  return '<div class="' + blockRoot('dataTable') + '"><table class="' + blockPart('dataTable', 'table') + '">'
@@ -242,9 +619,14 @@ export function renderListRows(input) {
242
619
  const row = item;
243
620
  const main = reqText(row.main, field + '.main');
244
621
  const done = row.done === true;
245
- const cls = blockPart('listRows', 'row') + (done ? ' ' + blockPart('listRows', 'row-done') : '');
246
622
  const left = optText(row.left);
247
623
  const right = optText(row.right);
624
+ // 没给 `left` 时**不占那一列**:`44px` 那列是给 `▲`/`▼`/`—` 这类标记用的,而栅格是自动排布——
625
+ // 若仍按三列排,`main` 会落进 44px 那列、被 `nowrap` + `ellipsis` 截断(#154 实测:备注标签
626
+ // 「晨起空腹」显示成「晨起…」)。修饰类只改列定义,不动任何既有行。
627
+ const cls = blockPart('listRows', 'row')
628
+ + (done ? ' ' + blockPart('listRows', 'row-done') : '')
629
+ + (left === undefined ? ' ' + blockPart('listRows', 'row-no-left') : '');
248
630
  return '<div class="' + cls + '">'
249
631
  + (left === undefined ? '' : '<span class="' + blockPart('listRows', 'left') + '">' + esc(left) + '</span>')
250
632
  + '<span class="' + blockPart('listRows', 'main') + '">' + esc(main) + '</span>'
@@ -347,7 +729,13 @@ export function renderDisclosure(input) {
347
729
  + '<div class="' + blockPart('disclosure', 'body') + '">' + block.contentHtml + '</div>'
348
730
  + '</details>';
349
731
  }
350
- /** B-09:参数表单(`placeholder = hint`;`required` 落 `data-required` 供宿主拦截;零 JS)。 */
732
+ /** B-09:参数表单(`placeholder = hint`;`required` 落 `data-required` 供宿主拦截;零 JS)。
733
+ * #397 可选约束:`readonly` 落裸 `readonly`;`step/min/max` 落同名属性(任一出现即
734
+ * `type="number"`,`step` 须大于 0,`min` 不得大于 `max`,非法一律 `bad-input`);
735
+ * `options` 给了即渲染 `<select>`(`value` 命中项 `selected`,`hint` 化作首项占位,
736
+ * `readonly` 化作 `disabled`,与 `step/min/max` 互斥)。
737
+ * #474 追加:`options` 收 `{ value, label }` 对象项——`value` 是机器值(`selected` 按它比)、
738
+ * `label` 是显示文本;字符串项走原路,全字符串调用方的产物**逐字节不变**。 */
351
739
  export function renderParamForm(input) {
352
740
  assertPlainObject(input, 'renderParamForm: input');
353
741
  assertNoInlineHandler(input, 'renderParamForm: input');
@@ -370,13 +758,52 @@ export function renderParamForm(input) {
370
758
  const hint = optText(item.hint);
371
759
  const value = typeof item.value === 'string' ? item.value : '';
372
760
  const required = item.required === true;
761
+ const readonly = item.readonly === true;
762
+ const step = optNumeric(item.step, path + '.step');
763
+ const min = optNumeric(item.min, path + '.min');
764
+ const max = optNumeric(item.max, path + '.max');
765
+ if (step !== undefined && !(Number(step) > 0))
766
+ badInput(path + '.step 必须是大于 0 的数字');
767
+ if (min !== undefined && max !== undefined && Number(min) > Number(max)) {
768
+ badInput(path + '.min 不得大于 ' + path + '.max');
769
+ }
770
+ const options = optOptions(item.options, path + '.options');
771
+ if (options !== undefined && (step !== undefined || min !== undefined || max !== undefined)) {
772
+ badInput(path + '.options 与 step/min/max 互斥');
773
+ }
774
+ if (options !== undefined) {
775
+ // #474:对象项按**机器值**比 `selected`,显示文本取 `label`;字符串项与旧行为逐字节一致。
776
+ const entries = options.map((opt) => (typeof opt === 'string' ? { value: opt, label: opt } : opt));
777
+ const optionHtml = entries.map((opt) => '<option value="' + esc(opt.value) + '"'
778
+ + (value === opt.value ? ' selected' : '') + '>' + esc(opt.label) + '</option>').join('');
779
+ parts.push('<label class="' + blockPart('paramForm', 'field') + '">'
780
+ + '<span class="' + blockPart('paramForm', 'label') + '">' + esc(label)
781
+ + (required ? '<span class="' + blockPart('paramForm', 'required') + '" aria-hidden="true"> *</span>' : '')
782
+ + '</span>'
783
+ + '<select class="' + blockPart('paramForm', 'input') + '" name="' + esc(name) + '"'
784
+ + (required ? ' data-required="1" required' : '')
785
+ + (readonly ? ' disabled' : '')
786
+ + '>'
787
+ + (hint === undefined ? '' : '<option value="" disabled' + (entries.some((opt) => opt.value === value) ? '' : ' selected') + '>'
788
+ + esc(hint) + '</option>')
789
+ + optionHtml
790
+ + '</select>'
791
+ + '</label>');
792
+ return;
793
+ }
373
794
  parts.push('<label class="' + blockPart('paramForm', 'field') + '">'
374
795
  + '<span class="' + blockPart('paramForm', 'label') + '">' + esc(label)
375
796
  + (required ? '<span class="' + blockPart('paramForm', 'required') + '" aria-hidden="true"> *</span>' : '')
376
797
  + '</span>'
377
- + '<input class="' + blockPart('paramForm', 'input') + '" name="' + esc(name) + '" value="' + esc(value) + '"'
798
+ + '<input class="' + blockPart('paramForm', 'input') + '"'
799
+ + (step === undefined && min === undefined && max === undefined ? '' : ' type="number"')
800
+ + ' name="' + esc(name) + '" value="' + esc(value) + '"'
378
801
  + (hint === undefined ? '' : ' placeholder="' + esc(hint) + '"')
379
802
  + (required ? ' data-required="1" required' : '')
803
+ + (readonly ? ' readonly' : '')
804
+ + (step === undefined ? '' : ' step="' + esc(step) + '"')
805
+ + (min === undefined ? '' : ' min="' + esc(min) + '"')
806
+ + (max === undefined ? '' : ' max="' + esc(max) + '"')
380
807
  + ' />'
381
808
  + '</label>');
382
809
  });
@@ -398,19 +825,27 @@ export function renderEmptyBlock(input) {
398
825
  + renderEmptyState(block)
399
826
  + '</section>';
400
827
  }
401
- /** B-11:复制区块(冻结 `renderActionBar`;id 缺省取 `COPY_ACTION_IDS.actionBar.*`,调用方须保页内唯一)。 */
828
+ /** B-11:复制区块(冻结 `renderActionBar`;id 缺省取 `COPY_ACTION_IDS.actionBar.*`,调用方须保页内唯一)。
829
+ * `dataFormats` 给三格式形态(`dataText` 单格式),两者同给 → `bad-input`。
830
+ * 标题去重(#336 base 侧兜底,与 `copyArea` 的 `COPY_TITLE_DUP_OF_BUTTON` 同口径):
831
+ * `title` 与复制数据按钮同名(`ACTION_BAR_DEFAULTS.copyDataLabel`=「复制数据」)时不出 `<h2>`
832
+ * (只留动作不留说明文本);其他标题照旧。 */
402
833
  export function renderCopyBlock(input) {
403
834
  assertPlainObject(input, 'renderCopyBlock: input');
404
835
  assertNoInlineHandler(input, 'renderCopyBlock: input');
405
836
  const block = input;
837
+ if (block.dataFormats !== undefined && block.dataText !== undefined) {
838
+ badInput('renderCopyBlock: input.dataFormats 与 input.dataText 只能给一个');
839
+ }
406
840
  const bar = {};
407
841
  if (block.buttons !== undefined)
408
842
  bar.buttons = block.buttons;
409
- if (block.dataText !== undefined || block.dataActionId !== undefined) {
843
+ if (block.dataText !== undefined || block.dataActionId !== undefined || block.dataFormats !== undefined) {
410
844
  bar.copyData = {
411
845
  actionId: block.dataActionId ?? COPY_ACTION_IDS.actionBar.copyData,
412
846
  label: ACTION_BAR_DEFAULTS.copyDataLabel,
413
847
  ...(block.dataText === undefined ? {} : { text: block.dataText }),
848
+ ...(block.dataFormats === undefined ? {} : { formats: block.dataFormats }),
414
849
  };
415
850
  }
416
851
  if (block.logText !== undefined || block.logActionId !== undefined) {
@@ -420,13 +855,56 @@ export function renderCopyBlock(input) {
420
855
  ...(block.logText === undefined ? {} : { text: block.logText }),
421
856
  };
422
857
  }
423
- const title = optText(block.title);
858
+ const rawTitle = optText(block.title);
859
+ // #336:与按钮同名的标题只留按钮(`copyArea` 去重口径的 base 侧兜底,直调本函数同样生效)。
860
+ const title = rawTitle === ACTION_BAR_DEFAULTS.copyDataLabel ? undefined : rawTitle;
424
861
  return '<section class="' + blockRoot('copyBlock') + '">'
425
862
  + (title === undefined ? '' : '<h2 class="' + blockPart('copyBlock', 'title') + '">' + esc(title) + '</h2>')
426
863
  + renderActionBar(bar)
427
864
  + '</section>';
428
865
  }
429
- /** B-12:反馈区块(冻结 `renderToast` 和/或 `renderErrorReceipt`;两者至少其一)。 */
866
+ /** 页内静态提示**不出**的内容(#154,fail-fast 而非静默丢):可点控件在静态提示里没有行为位,
867
+ * 徽章/计数是 toast 卡头部的配件。给了就抛 `bad-input`(要这些就用 toast 形态:不传 `staticNotice`)。
868
+ * 寿命字段(`timeoutMs`/`maxStack`)只关乎 toast 栈,静态形态下**无意义、静默忽略**。 */
869
+ const STATIC_NOTICE_REJECTED_FIELDS = ['actions', 'badge', 'count'];
870
+ /** B-12 的浅色形态产出器(#154):结构 = 图标 + 正文(标题/详情/多行/代码块)。
871
+ * 与 `renderToast` **同源不重述**:图标字形、`icon` 非法回落、转义表都取既有唯一产出者/冻结表。 */
872
+ function renderStaticNotice(toast) {
873
+ assertPlainObject(toast, 'renderFeedbackBlock: input.toast');
874
+ if (typeof toast.msg !== 'string')
875
+ badInput('renderFeedbackBlock: input.toast.msg 必须是字符串');
876
+ for (const field of STATIC_NOTICE_REJECTED_FIELDS) {
877
+ const value = toast[field];
878
+ if (value !== undefined && value !== null) {
879
+ badInput('renderFeedbackBlock: staticNotice 形态不出可点控件/徽章/计数,input.toast.'
880
+ + field + ' 不得给(要这些就传 toast 形态:去掉 staticNotice)');
881
+ }
882
+ }
883
+ const icon = typeof toast.icon === 'string' && toast.icon in TOAST_ICON_GLYPHS
884
+ ? toast.icon
885
+ : TOAST_DEFAULTS.defaultIcon;
886
+ const parts = [
887
+ '<div class="' + blockPart('feedbackBlock', 'note') + '">',
888
+ '<span class="' + blockPart('feedbackBlock', 'note-icon') + ' '
889
+ + blockPart('feedbackBlock', 'note-icon-' + icon) + '" aria-hidden="true">' + TOAST_ICON_GLYPHS[icon] + '</span>',
890
+ '<div class="' + blockPart('feedbackBlock', 'note-body') + '">',
891
+ '<div class="' + blockPart('feedbackBlock', 'note-title') + '">' + esc(toast.msg) + '</div>',
892
+ ];
893
+ if (typeof toast.detail === 'string' && toast.detail !== '') {
894
+ parts.push('<div class="' + blockPart('feedbackBlock', 'note-detail') + '">' + esc(toast.detail) + '</div>');
895
+ }
896
+ if (Array.isArray(toast.lines) && toast.lines.length > 0) {
897
+ parts.push('<div class="' + blockPart('feedbackBlock', 'note-lines') + '">'
898
+ + toast.lines.map((line) => esc(String(line))).join('<br>') + '</div>');
899
+ }
900
+ if (typeof toast.code === 'string' && toast.code !== '') {
901
+ parts.push('<pre class="' + blockPart('feedbackBlock', 'note-code') + '">' + esc(toast.code) + '</pre>');
902
+ }
903
+ parts.push('</div>', '</div>');
904
+ return parts.join('');
905
+ }
906
+ /** B-12:反馈区块(冻结 `renderToast` 和/或 `renderErrorReceipt`;两者至少其一)。
907
+ * `staticNotice === true` 时 `toast` 改走浅色静态形态(#154),其余逐字照旧。 */
430
908
  export function renderFeedbackBlock(input) {
431
909
  assertPlainObject(input, 'renderFeedbackBlock: input');
432
910
  assertNoInlineHandler(input, 'renderFeedbackBlock: input');
@@ -435,9 +913,12 @@ export function renderFeedbackBlock(input) {
435
913
  badInput('renderFeedbackBlock: input.toast 与 input.error 至少其一');
436
914
  }
437
915
  const title = optText(block.title);
916
+ const toastHtml = block.toast === undefined
917
+ ? ''
918
+ : (block.staticNotice === true ? renderStaticNotice(block.toast) : renderToast(block.toast));
438
919
  return '<section class="' + blockRoot('feedbackBlock') + '">'
439
920
  + (title === undefined ? '' : '<h2 class="' + blockPart('feedbackBlock', 'title') + '">' + esc(title) + '</h2>')
440
- + (block.toast === undefined ? '' : renderToast(block.toast))
921
+ + toastHtml
441
922
  + (block.error === undefined ? '' : renderErrorReceipt(block.error))
442
923
  + '</section>';
443
924
  }
@@ -464,7 +945,7 @@ const RADIUS_PILL = 999;
464
945
  /** 区块 CSS:12 区各自实现(键与 `BLOCK_STYLE_SECTIONS` 一一对应,缺区导入即抛,防静默缺样式)。 */
465
946
  const BLOCK_SECTION_BUILDERS = {
466
947
  pageShell: (p) => [
467
- // #179 整页基座(文档级):本区是「共享页面模板」的唯一落点(`style.test.mjs` T22 明写 body/html
948
+ // #179 整页基础(文档级):本区是「共享页面模板」的唯一落点(`style.test.mjs` T22 明写 body/html
468
949
  // 规则归 #104,`buildStyleSheet()` 不得产)。此前一条都没有 → 实测 `body` 吃 UA 的 8px 外边距、
469
950
  // 白底(`--bg` `#f5f5f7` 定义了却没人用)+ 系统默认字体(computed 回落到 Noto Sans SC)。
470
951
  // 这三条与参照件 HELP 的 `body{font-family…;background:var(--bg);color:var(--text)}` 对齐;
@@ -500,7 +981,8 @@ const BLOCK_SECTION_BUILDERS = {
500
981
  '}',
501
982
  '.' + p + 'block-page-shell-title {',
502
983
  ' margin: 6px 0 0;',
503
- ' font-size: 32px;',
984
+ // #567 J4(§5.2 大数 28 吸收 26/28/32/40):页标题 32→28。
985
+ ' font-size: 28px;',
504
986
  ' font-weight: 700;',
505
987
  ' letter-spacing: -.4px;',
506
988
  ' line-height: 1.2;',
@@ -514,12 +996,316 @@ const BLOCK_SECTION_BUILDERS = {
514
996
  ' display: block;',
515
997
  ' margin-top: 16px;',
516
998
  '}',
999
+ // #457 顶层区块统一间隔:正文里每个区块(非首个)上方恒 16px。此前各区块根规则各自给上下
1000
+ // 外边距,`dataTable`/`listRows` 两条**一条都没给**,于是「表接表」实测间距 0 —— 两张表的
1001
+ // 1px 边框贴在一起,看不出是两块(267 页「延迟相关性」与「分层对比」)。
1002
+ // 写在**一处**、用相邻兄弟收口,而不是给 12 个区各补一条上下边距:相邻两块的边距会折叠,
1003
+ // 既有的 12/16 都不超过 16,折叠后恒为 16,不需要改各区的既有值。
1004
+ // 作用域限死在本区正文的直接子级,两条都在防误伤:① `.ilife-block-kpi-card` 也是 `.ilife-block`,
1005
+ // 若全局写 `.ilife-block + .ilife-block`,KPI 卡网格里第 2 张起会各被推下 16px(同排错位、网格失效);
1006
+ // ② 选择器只用 `.ilife-block` 作「后一块」,不给非区块元素(口径行/图例)加边距,也不动嵌在
1007
+ // 折叠区里的块(它不是正文的直接子级)。
1008
+ // **与 #154「各区自补 16px」重叠(同一根因的另一处补法)**:那批区间给 `dataTable`/`listRows`/
1009
+ // `kpiCardGrid` 各补了 `margin: 16px 0`,与本条命中同一处时两边同值、走 margin 折叠,恒 16px、
1010
+ // 不翻倍(实测 regen-262:表接表 16px)。两票都实施后由后续票合并成单一落点,本票不撤。
1011
+ '.' + p + 'block-page-shell-body > * + .' + p + 'block {',
1012
+ ' margin-top: 16px;',
1013
+ '}',
517
1014
  '@media (max-width: 640px) {',
518
1015
  ' .' + p + 'block-page-shell {',
519
1016
  ' padding: 20px 16px 60px;',
520
1017
  ' }',
521
1018
  ' .' + p + 'block-page-shell-title {',
522
- ' font-size: 26px;',
1019
+ // #567 J4:桌面 32→28 后窄屏 26 按旧收缩比(26/32≈0.81)收到 22(28×0.79),复用 KPI 主数字档,
1020
+ // 不另立一档;仍比正文大 9px,主次不断。
1021
+ ' font-size: 22px;',
1022
+ ' }',
1023
+ '}',
1024
+ // #420 页面级三件的样式随本区落盘(`BLOCK_STYLE_SECTIONS` 由 `test/blocks.test.mjs` 钉死 12 项,
1025
+ // 不新增样式区;三件都是页面级、与 12 区块无组合关系)。
1026
+ '.' + p + 'block-toc {',
1027
+ ' display: flex;',
1028
+ ' flex-wrap: wrap;',
1029
+ ' gap: 8px;',
1030
+ ' margin: 0 0 16px;',
1031
+ '}',
1032
+ '.' + p + 'block-toc a {',
1033
+ // #567 D6:页内导航可点高 33.5px<44px(一页两套触摸档:同页输入框与复制按钮已 44px)。
1034
+ // `min-height:44px`+纵向居中定死触摸目标(`box-sizing` 显式钉死,免得被页面 `*{}` 改写);
1035
+ // 纵向内距 6px→8px(4 的倍数;横向 12px 不动;`gap:8px` 已是 4 的倍数)。
1036
+ ' display: inline-flex;',
1037
+ ' align-items: center;',
1038
+ ' box-sizing: border-box;',
1039
+ ' min-height: 44px;',
1040
+ ' padding: 8px 12px;',
1041
+ ' border: 1px solid var(--line);',
1042
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1043
+ ' background: var(--card);',
1044
+ // #179 对比度口径:13px 小字压白底用 `--blue2`(5.6:1),`--blue` 4.02:1 不到 AA 的 4.5:1。
1045
+ ' color: var(--blue2);',
1046
+ ' font-size: 13px;',
1047
+ ' font-weight: 600;',
1048
+ ' text-decoration: none;',
1049
+ '}',
1050
+ '.' + p + 'block-toc a:focus-visible {',
1051
+ ' outline: 2px solid var(--blue);',
1052
+ ' outline-offset: 2px;',
1053
+ '}',
1054
+ // 口径说明行:聚合数字旁的灰色小字。12px 取 `--fg2`(4.94:1);`--fg3` 只有 3.62:1,不到 AA。
1055
+ '.' + p + 'block-caliber {',
1056
+ ' margin: 0 0 8px;',
1057
+ ' color: var(--fg2);',
1058
+ ' font-size: 12px;',
1059
+ ' line-height: 1.5;',
1060
+ '}',
1061
+ // t154-r3(负责人第三轮要求 1):口径行的**并列分隔**从「文本里的全角竖线字符」改成「版式里的细竖线」。
1062
+ // 拆段住在产出器(`renderCaliberLine` 逐段出一枚 `<span>`),本处只给版式:段间一枚 hairline
1063
+ // + 间距;窄屏靠 `flex-wrap` 换行(不再靠竖线字符挤在一行)。
1064
+ // **`:has(> span)` 是这条的鉴别器**:只有真拆过段的行才是 flex 容器;没带分隔符的口径行
1065
+ // (多数调用点)连 flex 都不是 ⇒ 版面与改前逐值相同(「桌面档不变」在口径行这侧的保证)。
1066
+ // `gap: 4px 12px`=行距 4px/列距 12px;竖线借 `> span + span` 的 `border-left`(首段无线)。
1067
+ // 色取既有 `LINE_RGB` 的 .6 透明档(与表行分隔同一档,不新增色值、不新增 token)。
1068
+ // 列距 12px 落在「竖线两侧不对称」的观感上偏紧,故每段再补 8px 左内距:竖线两侧留白
1069
+ // 12px/8px,读到的是「一条细线把两段分开」,而不是「一行贴着线条」。
1070
+ // 注:本条注释不写那个竖线字符本身 —— 产物里「全角竖线」的命中数是个判据,注释不该去凑它。
1071
+ '.' + p + 'block-caliber:has(> span) {',
1072
+ ' display: flex;',
1073
+ ' flex-wrap: wrap;',
1074
+ ' gap: 4px 12px;',
1075
+ ' align-items: baseline;',
1076
+ '}',
1077
+ '.' + p + 'block-caliber > span + span {',
1078
+ ' padding-left: 8px;',
1079
+ ' border-left: 1px solid rgba(' + LINE_RGB + ', .6);',
1080
+ '}',
1081
+ // #507 结论条(审查必改 #3):形状住公共层,调用方只传文本。
1082
+ // 起因:这一条此前是**页面内联 8 个魔法值**(`homeDocs.ts` 的 `CONCLUSION_STYLE`:
1083
+ // `margin:0 0 16px;padding:12px 16px;border-radius:14px;background:var(--soft);color:var(--blue2);
1084
+ // font-weight:600` 写在一处 `style="…"` 里),与「版面单源住公共层」的纪律冲突(该件自述
1085
+ // 「本页不再出现任何一条自写 `font-size`/`color` 规则」,而 `color:var(--blue2)` 就在其中)。
1086
+ // 取值**逐条落在 11 个冻结 token + 圆角闭集 `{8,14,20,999}` 内**,不新增 token:
1087
+ // 底 `var(--card)`(白,与卡片同底)、强调字 `var(--blue2)`(5.6:1)、左 3px `var(--blue)` 主色边、
1088
+ // 圆角 `' + RADIUS_MD + '`(14,闭集里既有中档)、内距 12px/16px(12/16 倍数)、下距 16px(同族值)。
1089
+ // 审查第 3 点给的形态就是这一条「左 3px 主色边 + 浅底」,它同时把**结论条**与 `renderStatusBadge`
1090
+ // 的「`--soft` 底 + 色字」形态分开——此前两者底同、字色同,一个静态陈述一个算出来的档位却长得一样。
1091
+ '.' + p + 'block-conclusion {',
1092
+ ' margin: 0 0 16px;',
1093
+ ' padding: 12px 16px;',
1094
+ ' border-left: 3px solid var(--blue);',
1095
+ ' border-radius: ' + RADIUS_MD + 'px;',
1096
+ ' background: var(--card);',
1097
+ ' color: var(--blue2);',
1098
+ // #567 J4(§5.2 区块标题 15 吸收 14/15)。
1099
+ ' font-size: 15px;',
1100
+ ' font-weight: 600;',
1101
+ '}',
1102
+ // #434 操作卡头部(#422 共用件 `operationHead()` 的根类+四色档+四子件;样式落本区,不新增区)。
1103
+ // 形态对齐老实物 `crud_receipt` 的 `id-card` 左色条(样张 `t156-样张-写后回执.html:153-160`:
1104
+ // `border-left:4px solid` 随 op 切):基规则出卡片+ 4px 左条(缺省中性 `var(--line)`),
1105
+ // 四色档只改 `border-left-color`。版式把页级四条覆盖收编(`sportUi.ts` 的 `.ilife-page …`
1106
+ // 四条本票删除后落点):图标+徽章+标题同排,记录号/时刻行各占整行(`flex:1 0 100%`)。
1107
+ // 内距取同族卡片档 14px(`kpiCard`/`chartBlock`/`detailSection` 同值),样张的 18px 20px
1108
+ // 是 throwaway 密度不照搬;块级 `margin:16px 0` 取 #154 同族值(与 #457 相邻兄弟规则折叠恒 16)。
1109
+ '.' + p + 'block-op-head {',
1110
+ ' display: flex;',
1111
+ ' flex-wrap: wrap;',
1112
+ ' align-items: center;',
1113
+ ' gap: 8px 12px;',
1114
+ ' margin: 16px 0;',
1115
+ ' padding: 14px;',
1116
+ ' border: 1px solid var(--line);',
1117
+ ' border-left: 4px solid var(--line);',
1118
+ ' border-radius: ' + RADIUS_MD + 'px;',
1119
+ ' background: var(--card);',
1120
+ '}',
1121
+ // 四色档(`OPERATION_TONES` ok/warn/danger/empty 的样式落点;只改左条色)。
1122
+ // 色值口径:ok 取冻结 token `var(--ok)`(值 `#34c759`,与样张 `opColors` 同值);
1123
+ // 冻结表无 warn/danger 灯位,故 warn/danger 取同仓状态徽章同源 D-5 常量
1124
+ // (`src/style.ts` statusBadge 区 warn `#a25b00`/danger `#a83228`,不新增 token 名);
1125
+ // empty 取冻结 `var(--fg2)`(与 empty 徽章字色同值,中性)。
1126
+ // 样张的 `#0071e3` 在 H-01 禁色表内(`test/style.test.mjs:241`),一律不用;
1127
+ // 色条是非文本,不套 #179 的文本对比度档。
1128
+ '.' + p + 'block-op-head-ok {',
1129
+ ' border-left-color: var(--ok);',
1130
+ '}',
1131
+ '.' + p + 'block-op-head-warn {',
1132
+ ' border-left-color: #a25b00;',
1133
+ '}',
1134
+ '.' + p + 'block-op-head-danger {',
1135
+ ' border-left-color: #a83228;',
1136
+ '}',
1137
+ '.' + p + 'block-op-head-empty {',
1138
+ ' border-left-color: var(--fg2);',
1139
+ '}',
1140
+ // 子件四条(`operationHead()` 的 `-icon`/`-title`/`-id`/`-time`)。
1141
+ // 图标 40px 中性盒(样张 12px 圆角按 D-5 收进闭集取 14;底色取中性 `var(--soft)`,
1142
+ // 色档由左条+徽章承担,图标不跟色);标题 18px/700 取 `detail-section-title` 档
1143
+ // (#567 J4 标题 18 吸收 17/18/20/22);记录号 13px 取 `--fg2`;时刻行 12px 取
1144
+ // `--fg2`(#179:12px 小字须 4.5:1,`--fg3` 3.62:1 不用)。
1145
+ // 时刻行首的「写入时间 」键(`sportUi.ts` 迁回):`actionAt` 即写入时间
1146
+ // (`body/receipt.ts:162` 同值同词 `{ k: '写入时间', v: actionAt }`),CSS 内容只进
1147
+ // 渲染层;第二消费族出现时提到调用方文案面并删此条(过渡债务,本票如实记)。
1148
+ '.' + p + 'block-op-head-icon {',
1149
+ ' display: inline-flex;',
1150
+ ' align-items: center;',
1151
+ ' justify-content: center;',
1152
+ ' width: 40px;',
1153
+ ' height: 40px;',
1154
+ ' border-radius: ' + RADIUS_MD + 'px;',
1155
+ ' background: var(--soft);',
1156
+ ' color: var(--fg);',
1157
+ ' font-size: 20px;',
1158
+ ' line-height: 1;',
1159
+ '}',
1160
+ '.' + p + 'block-op-head-title {',
1161
+ ' display: inline-flex;',
1162
+ ' align-items: center;',
1163
+ ' margin: 0;',
1164
+ ' color: var(--fg);',
1165
+ ' font-size: 18px;',
1166
+ ' font-weight: 700;',
1167
+ '}',
1168
+ '.' + p + 'block-op-head-id {',
1169
+ ' flex: 1 0 100%;',
1170
+ ' margin: 0;',
1171
+ ' color: var(--fg2);',
1172
+ ' font-size: 13px;',
1173
+ '}',
1174
+ '.' + p + 'block-op-head-time {',
1175
+ ' flex: 1 0 100%;',
1176
+ ' margin: 0;',
1177
+ ' color: var(--fg2);',
1178
+ ' font-size: 12px;',
1179
+ '}',
1180
+ '.' + p + 'block-op-head-time::before {',
1181
+ ' content: "写入时间 ";',
1182
+ '}',
1183
+ // #421 页面融合四件的样式随本区落盘(同 #420 处置:四件都是页面级、不属 12 区块,
1184
+ // `BLOCK_STYLE_SECTIONS` 由 `test/blocks.test.mjs` 钉死 12 项,故不新增样式区、不新增 token)。
1185
+ // **色值口径(#431 定稿)**:区块自身不写死任何色值字面量——样式里只出现冻结 token。不给 `color`
1186
+ // 时填充色的兜底就是下面两条 `-fill` 规则里的冻结 token `var(--blue)`(缺省色走冻结 token,不是
1187
+ // 字面量,也不是「没有色」);调用方给的色值由 `optColor` 按允许清单收下、逐字进产物的内联
1188
+ // `background`。三处说法(本条注释/证据件/判据名)同口径。
1189
+ '.' + p + 'block-mini-bar {',
1190
+ ' display: inline-block;',
1191
+ ' overflow: hidden;',
1192
+ ' width: 72px;',
1193
+ ' height: 6px;',
1194
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1195
+ ' background: var(--line);',
1196
+ ' vertical-align: middle;',
1197
+ '}',
1198
+ '.' + p + 'block-mini-bar-fill {',
1199
+ ' display: block;',
1200
+ ' height: 100%;',
1201
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1202
+ ' background: var(--blue);',
1203
+ '}',
1204
+ '.' + p + 'block-dist-row {',
1205
+ ' display: grid;',
1206
+ ' grid-template-columns: minmax(0, 6em) minmax(0, 1fr) auto;',
1207
+ ' align-items: center;',
1208
+ ' gap: 8px;',
1209
+ ' padding: 5px 0;',
1210
+ ' font-size: 13px;',
1211
+ '}',
1212
+ // 13px 小字压白底:`--fg2`(4.94:1)达 AA;`--fg3`(3.62:1)不到,不用(#179 口径)。
1213
+ '.' + p + 'block-dist-row-name {',
1214
+ ' color: var(--fg2);',
1215
+ ' overflow: hidden;',
1216
+ ' text-overflow: ellipsis;',
1217
+ ' white-space: nowrap;',
1218
+ '}',
1219
+ '.' + p + 'block-dist-row-bar {',
1220
+ ' display: block;',
1221
+ ' overflow: hidden;',
1222
+ ' height: 8px;',
1223
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1224
+ ' background: var(--line);',
1225
+ '}',
1226
+ '.' + p + 'block-dist-row-fill {',
1227
+ ' display: block;',
1228
+ ' height: 100%;',
1229
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1230
+ ' background: var(--blue);',
1231
+ '}',
1232
+ '.' + p + 'block-dist-row-val {',
1233
+ ' color: var(--fg);',
1234
+ ' font-weight: 600;',
1235
+ ' font-variant-numeric: tabular-nums;',
1236
+ ' text-align: right;',
1237
+ '}',
1238
+ // 12px 小字取 `--blue2`(压 `--soft` 约 5.4:1);`--blue` 4.02:1 不到 AA 的 4.5:1(#179 口径)。
1239
+ '.' + p + 'block-chip {',
1240
+ ' display: inline-block;',
1241
+ ' margin: 0 6px 6px 0;',
1242
+ ' padding: 2px 8px;',
1243
+ ' border: 1px solid var(--line);',
1244
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1245
+ ' background: var(--soft);',
1246
+ ' color: var(--blue2);',
1247
+ ' font-size: 12px;',
1248
+ ' font-weight: 600;',
1249
+ ' line-height: 1.5;',
1250
+ '}',
1251
+ '.' + p + 'block-change-row {',
1252
+ ' display: flex;',
1253
+ ' align-items: baseline;',
1254
+ ' gap: 8px;',
1255
+ ' padding: 6px 0;',
1256
+ ' border-top: 1px solid var(--line);',
1257
+ // #567 J4(§5.2 区块标题 15 吸收 14/15;`page-viz-421` 同步改 14→15)。
1258
+ ' font-size: 15px;',
1259
+ '}',
1260
+ '.' + p + 'block-change-row-label {',
1261
+ ' flex: 1;',
1262
+ ' min-width: 0;',
1263
+ ' color: var(--fg2);',
1264
+ '}',
1265
+ // 改前=灰字加删除线;改后=正文字重 600(层次靠字重与线,不靠更浅的灰,同 #179 口径)。
1266
+ '.' + p + 'block-change-row-old {',
1267
+ ' color: var(--fg2);',
1268
+ ' text-decoration: line-through;',
1269
+ '}',
1270
+ // 箭位恒占宽:`arrow: false` 只加内联 `visibility: hidden`,栏宽与真行逐字同(`display: none` 会塌)。
1271
+ '.' + p + 'block-change-row-arrow {',
1272
+ ' flex: 0 0 auto;',
1273
+ ' min-width: 1.2em;',
1274
+ ' color: var(--fg2);',
1275
+ ' text-align: center;',
1276
+ '}',
1277
+ '.' + p + 'block-change-row-new {',
1278
+ ' color: var(--fg);',
1279
+ ' font-weight: 600;',
1280
+ ' font-variant-numeric: tabular-nums;',
1281
+ '}',
1282
+ // #533 触屏两件(照 HELP `button.copy` 与卡路里 `pageChromeCss`):壳内可点件去蓝块、防双击缩放延迟。
1283
+ // 只补两条声明,不改版式文案;第三件(`-webkit-touch-callout`)口径未定,见证据件交裁,本票不自定。
1284
+ '.' + p + 'block-page-shell button,',
1285
+ '.' + p + 'block-page-shell summary {',
1286
+ ' -webkit-tap-highlight-color: transparent;',
1287
+ ' touch-action: manipulation;',
1288
+ '}',
1289
+ // #420-3 打印段:**必须显式打开**——只有 `renderPageShell({ printable: true })` 的页才带
1290
+ // `.ilife-page-printable`;不给的调用点类名不出现,规则虽在样式段里但一律不命中(逐字零变)。
1291
+ // 打印段里**每条规则**都挂在该类作用域下(不出现裸 `body`/裸 `.wrap`):样式表是共享资产、
1292
+ // 注入到全部页面,裸选择器会在别的页上生效。页面留白交给**具名页** `@page printable`
1293
+ // (`.ilife-page-printable { page: printable }` 绑定;裸 `@page` 会让全部页面吃这 12mm)。
1294
+ // 屏幕上那 32px 留白在纸上没有意义,故打印时清零、改由页边距承担。
1295
+ '@media print {',
1296
+ ' .' + p + PRINTABLE_SLUG + ' {',
1297
+ ' page: ' + PRINTABLE_PAGE_NAME + ';',
1298
+ ' max-width: none;',
1299
+ ' padding: 0;',
1300
+ ' }',
1301
+ ' .' + p + PRINTABLE_SLUG + ' .' + p + 'block-toc {',
1302
+ ' display: none;',
1303
+ ' }',
1304
+ ' .' + p + PRINTABLE_SLUG + ' .' + p + 'block-copy-block {',
1305
+ ' display: none;',
1306
+ ' }',
1307
+ ' @page ' + PRINTABLE_PAGE_NAME + ' {',
1308
+ ' margin: 12mm;',
523
1309
  ' }',
524
1310
  '}',
525
1311
  ].join(LF),
@@ -528,12 +1314,37 @@ const BLOCK_SECTION_BUILDERS = {
528
1314
  // `block-kpi-*`,而 `blockPart('kpiCard', …)` 真产出的是 `block-kpi-card-*`
529
1315
  // → label/value-row/value/unit/detail/badge **6 条全部落空**(computed 实测:值 16px/400
530
1316
  // 普通正文色,不是设计的 28px/700)。本区逐条改回真产出的类名,并把说明落在每条前面。
1317
+ // #154(2026-09-14 交付页返工)① 区块间距:本区此前**块级一条 margin 都没有**,与同族区块
1318
+ // (`chartBlock`/`detailSection`/`emptyBlock`/`copyBlock`/`feedbackBlock` 都是 `margin: 16px 0`)
1319
+ // 不一致 ⇒ 交付页实测「KPI 网格与上下卡片贴死」。取同族值 `16px 0`(相邻外边距自然折叠,不翻倍)。
1320
+ // 与 #457 的 `.ilife-block-page-shell-body > * + .ilife-block`(相邻兄弟补上边距)**互补不冲突**:
1321
+ // 那条匹配不到本网格——网格根类是 `block-kpi-card-grid`,**不带** `ilife-block` 类;
1322
+ // 两处都命中同一条边距时同值(16px)且折叠,不会翻倍。
1323
+ // ② 四张卡等高:`auto-fit` 网格**逐行各自量高**,第一行若有一张卡的值长(如日期区间
1324
+ // `2026-08-09 ~ 2026-09-07`)折成 2~3 行,就把那一行撑高、同行另一张被拉长,第二行又是另一个高度。
1325
+ // `grid-auto-rows: 1fr` 让全部隐式行取同一个高(fr 轨道先取各自 max-content,余量均分 ⇒ 行行等高),
1326
+ // 网格项默认 `stretch`,四张卡因此看起来一样高(用户原话要求)。
531
1327
  '.' + p + 'block-kpi-card-grid {',
532
1328
  ' display: grid;',
533
1329
  ' grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));',
1330
+ ' grid-auto-rows: 1fr;',
534
1331
  ' gap: 12px;',
1332
+ ' margin: 16px 0;',
1333
+ '}',
1334
+ // #507 桌面列数上限(用户点名:6 张 KPI 在宽屏挤成一行):基座 `auto-fit minmax(150px,1fr)`
1335
+ // 无上限,越宽列越多。只加 ≥1024 一档 `repeat(3,…)`,基座一行不动;窄屏 2×2(#507 窄屏档)
1336
+ // 与 400 单列(`pageUi` ⑦)各管各档。断点取票面值 1024;选择器挂祖先类(与窄屏档同法),
1337
+ // 同名基座仍恰 1 条(`declsOf` 口径),不新类名、不新样式区。
1338
+ '@media (min-width: 1024px) {',
1339
+ ' .' + p + 'block-page-shell .' + p + 'block-kpi-card-grid {',
1340
+ ' grid-template-columns: repeat(3, minmax(0, 1fr));',
1341
+ ' }',
535
1342
  '}',
1343
+ // #513 徽章贴底:卡片改纵向 flex 列(子元素仍纵向满宽,`align-items` 缺省 `stretch`、
1344
+ // 无子元素设交叉轴尺寸 ⇒ 视觉同块流),余量由徽章的 `margin-top: auto` 吃掉(见下)。
536
1345
  '.' + p + 'block-kpi-card {',
1346
+ ' display: flex;',
1347
+ ' flex-direction: column;',
537
1348
  ' padding: 14px;',
538
1349
  ' border: 1px solid var(--line);',
539
1350
  ' border-radius: ' + RADIUS_MD + 'px;',
@@ -555,7 +1366,12 @@ const BLOCK_SECTION_BUILDERS = {
555
1366
  '.' + p + 'block-kpi-card-value {',
556
1367
  ' min-width: 0;',
557
1368
  ' color: var(--fg);',
558
- ' font-size: 28px;',
1369
+ // #154(2026-09-14 交付页返工)③ 主数字收一号:用户原话「内容太大了」。28px → 22px。
1370
+ // 22 是**阶梯里还站得住**的一档(#567 后 `detail-section-title` 17→18:22 − 18 = 4px ≥ 4px,
1371
+ // C1:349「相邻级差 ≥4px」仍成立;22 本身是 #544 终审既判的整排字号,不动);对 `unit` 13px
1372
+ // 仍大 9px ≥ 8px(本尺 B-02「unit 比 value 小 ≥8px」)。
1373
+ // `overflow-wrap: anywhere` 保留(防长串溢出)——字号小了之后长值折行显著变少。
1374
+ ' font-size: 22px;',
559
1375
  ' font-weight: 700;',
560
1376
  ' line-height: 1.2;',
561
1377
  ' overflow-wrap: anywhere;',
@@ -573,16 +1389,81 @@ const BLOCK_SECTION_BUILDERS = {
573
1389
  ' font-size: 12px;',
574
1390
  ' overflow-wrap: anywhere;',
575
1391
  '}',
1392
+ // #513(续):`margin-top: auto` 把徽章压到底;`padding-top: 8px` 保住改前 8px 最小间距
1393
+ // (最高卡无余量时徽章位置与改前一致,只动有余量的短卡——正是要修的那几张)。
576
1394
  '.' + p + 'block-kpi-card-badge {',
1395
+ ' margin-top: auto;',
1396
+ ' padding-top: 8px;',
1397
+ '}',
1398
+ // #418 条位:detail 之后 badge 之前产条(见 renderKpiCard)。色值单源(甲路):
1399
+ // 三档各一处字面量,源 spec/charts.ts CHART_PALETTE[1..3],与状态徽章底色同值。
1400
+ '.' + p + 'block-kpi-card-bar {',
1401
+ ' height: 6px;',
1402
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1403
+ ' background: var(--line);',
577
1404
  ' margin-top: 8px;',
1405
+ ' overflow: hidden;',
1406
+ '}',
1407
+ '.' + p + 'block-kpi-card-bar-fill {',
1408
+ ' height: 100%;',
1409
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1410
+ '}',
1411
+ '.' + p + 'block-kpi-card-bar-high {',
1412
+ ' background: #34c759;',
1413
+ '}',
1414
+ '.' + p + 'block-kpi-card-bar-mid {',
1415
+ ' background: #ff9500;',
1416
+ '}',
1417
+ '.' + p + 'block-kpi-card-bar-low {',
1418
+ ' background: #ff3b30;',
1419
+ '}',
1420
+ // #507 段标题统一(审查必改 #5):`blocks.ts` 从 #401 起就给过这条类名产出器的位置
1421
+ // (`homeDocs.ts:247` 用 `<h2 class="ilife-block-kpi-card-title">` 出「今日速览」段标题),
1422
+ // 但**本区从来没有这条 CSS 规则** ⇒ 那个 h2 一路落回浏览器缺省 `h2{font-size:1.17em}`
1423
+ // (15px 正文的 1.17 倍=17.5px),与同级的 `.ilife-block-copy-block-title`(15px)量出来两样。
1424
+ // 本条补上与另两族**同字号同字重**的一档:15px/700/`margin:0 0 8px`
1425
+ // (边距本区原无先例,取 `copy-block-title` 那档;`chart-block-title` 的 10px 是 #154 已有值,
1426
+ // 不在本票写集,故三族**字号与字重全同、边距两档**——审查第 5 条点的是字号。)
1427
+ '.' + p + 'block-kpi-card-title {',
1428
+ ' margin: 0 0 8px;',
1429
+ ' font-size: 15px;',
1430
+ ' font-weight: 700;',
1431
+ '}',
1432
+ // #507 窄屏档(审查必改 #4)+ #427 收窄为单列:`auto-fit minmax(150px,1fr)` 只在容器有效宽 ≥474px 时排 3 列
1433
+ // (3×150 + 2×12 间隙),而手机端页面模板 `padding:20px 16px`(见本区 ≤640px 段)——
1434
+ // 512 视口下栅格有效宽仅 480px ⇒ 正好卡在 3 列下沿,第 4 张卡单独占一行、宽度只有前三张的
1435
+ // 1/3(`grid-auto-rows:1fr` 又让它与前三张**等高**),看着像漏了一张。
1436
+ // #427(#507§8 三条窄屏归 #427,同一段代码):≤640 显式单列 ⇒ 390 每张占整行上下排列;
1437
+ // `minmax(0,1fr)` 的 0 是下限(不是 `auto` 那种取内容最小宽),长值卡不会被撑破。
1438
+ // 桌面档一行不动(桌面列数上限 #507 的 1024 三列帽另管)。断点取本文件既有先例:`dataTable` 与 `pageShell`
1439
+ // 两处都是 `@media (max-width: 640px)`。
1440
+ // 选择器是 `.ilife-block-page-shell .ilife-block-kpi-card-grid`(基础类名前挂一个祖先类),
1441
+ // **不是**同名再写一条:① 网格落在页面模板正文里(`renderKpiGrid` 的唯一用法),该祖先恒成立;
1442
+ // ② 本仓的 CSS 纪律测试(`test/ui-fix-154.test.mjs` 的 `declsOf`)按「某类名的**基础规则**恰 1 条」
1443
+ // 判账,同名再起一条会被数成 2 条而红;`dataTable` 区那条同名 640px 规则是历史写法,本票不动它。
1444
+ '@media (max-width: 640px) {',
1445
+ ' .' + p + 'block-page-shell .' + p + 'block-kpi-card-grid {',
1446
+ ' grid-template-columns: minmax(0, 1fr);',
1447
+ ' }',
578
1448
  '}',
579
1449
  ].join(LF),
580
1450
  dataTable: (p) => [
1451
+ // #154(2026-09-14 交付页返工)① 区块间距:本区块级此前**一条 margin 都没有**(只有 caption/th/td
1452
+ // 的内距),与同族区块的 `margin: 16px 0` 不一致 ⇒ 交付页实测「表格和上下卡片完全贴在一起」。
1453
+ // 取同族值 `16px 0`;与 #457 的相邻兄弟规则同值时折叠为 16px,不翻倍。
581
1454
  '.' + p + 'block-data-table {',
1455
+ ' margin: 16px 0;',
582
1456
  ' overflow-x: auto;',
583
1457
  ' border: 1px solid var(--line);',
584
1458
  ' border-radius: ' + RADIUS_MD + 'px;',
585
1459
  ' background: var(--card);',
1460
+ // #512(最后一公里)表格「两个角落」的旧解(已撤,见下):当时给表卡加宽度上限收中缝;
1461
+ // 试过的另一条路(复审也提了)**无效**:`td` 上的 `max-width` 在 `table-layout:auto` 下不生效
1462
+ // (读数见 `docs/base/base-render/t512-最后一公里-证据.md`),上限只加在 `td` 上等于没加。
1463
+ // #567 D1:撤掉 680 上限,表卡吃满内容列(#512 那 398px 窄表是旧约束:行首列收窄+数值列
1464
+ // 下限已由后票接管列宽分配;上限在时表盒 682 vs 内容列 1080、两侧各空 199px)。
1465
+ // `margin: 16px 0` 不动(#154 块级外边距+`ui-fix-154` 同值守卫);居中 `margin-inline:auto`
1466
+ // 随上限一起撤(满宽下无意义)。窄档(≤640 上限本就不触发)逐字不变。
586
1467
  '}',
587
1468
  '.' + p + 'block-data-table-table {',
588
1469
  ' width: 100%;',
@@ -596,15 +1477,38 @@ const BLOCK_SECTION_BUILDERS = {
596
1477
  ' font-weight: 600;',
597
1478
  ' text-align: left;',
598
1479
  '}',
1480
+ // #513 行标记槽:首格前插的行徽标(`renderDataTable` 的 `rows[i].marker`)。行内小徽标:
1481
+ // 色走冻结 token(底 `--soft`+字 `--blue2`,与 `block-chip` 同口径,不新增 token;
1482
+ // 12px 小字对比度约 5.4:1,#179 口径达 AA);`white-space: nowrap` 保徽标不断行;
1483
+ // 只吃内容宽,不改窄屏档位(横溢口径见证据件)。
1484
+ '.' + p + 'block-data-table-row-marker {',
1485
+ ' display: inline-block;',
1486
+ ' margin-right: 6px;',
1487
+ ' padding: 1px 7px;',
1488
+ ' border-radius: ' + RADIUS_PILL + 'px;',
1489
+ ' background: var(--soft);',
1490
+ ' color: var(--blue2);',
1491
+ ' font-size: 12px;',
1492
+ ' font-weight: 600;',
1493
+ ' white-space: nowrap;',
1494
+ ' vertical-align: baseline;',
1495
+ '}',
599
1496
  '.' + p + 'block-data-table th {',
600
1497
  ' padding: 10px 14px;',
601
1498
  ' border-bottom: 1px solid var(--line);',
602
1499
  ' background-color: transparent;',
603
- // #179 对比度:表头 12px 取 `--fg3` 只有 3.62:1,不到 AA 的 4.5:1 → 取 `--fg2`(4.94:1)。
604
- ' color: var(--fg2);',
1500
+ // #507 列头压一档(审查必改 #2):此前 `th` 取 `--fg2`/12px/600,与数据行 `--fg2`/13px/400
1501
+ // 几乎同色同重 ⇒ 列头「日期」与数据「2026-09-07」分不出主次。改成 `--fg3`/12px(#567 前是
1502
+ // 11.5px,列头最浅)+ 数据行的**数值列**取 `--fg`(见下方 `td.…-cell-right` 一条),
1503
+ // 「列头弱、数据强」的层级才读出来。
1504
+ // #572-S3-3(转 #567):`text-transform: uppercase` 把表头里的单位一并抬大写
1505
+ // (`模拟体重(kg)`→`模拟体重(KG)`、`饮水(ml)`→`饮水(ML)`,1440 档实测)。
1506
+ // 列头层级改由字号/字重/颜色承担(11.5px→12px 仍是最浅 `--fg3`+600 字重),不再靠大写。
1507
+ // 对比度账(#179):`--fg3` 压白底 3.62:1,按 WCAG 短标签 3:1 一档仍有富余;数据行一律不动色。
1508
+ // 短英文标签此前靠大写撑出的形态改由既有 `letter-spacing` 保留。
1509
+ ' color: var(--fg3);',
605
1510
  ' font-size: 12px;',
606
1511
  ' font-weight: 600;',
607
- ' text-transform: uppercase;',
608
1512
  ' letter-spacing: .04em;',
609
1513
  ' white-space: nowrap;',
610
1514
  '}',
@@ -617,6 +1521,13 @@ const BLOCK_SECTION_BUILDERS = {
617
1521
  '.' + p + 'block-data-table tr:last-child td {',
618
1522
  ' border-bottom: none;',
619
1523
  '}',
1524
+ // #507 表格斑马纹(用户点名的两条之一;另一条数值列等宽栈已由 #507 视觉底座落地,本票不重做):
1525
+ // 偶数数据行取 `var(--bg)`(既有冻结 token,不新增语义 token)。只染 `tbody` 行,列头不动;
1526
+ // 背景落 `td`(本表 `border-collapse: collapse`,行背景不可靠)。伪类挂既有类上,不新类名、
1527
+ // 不新样式区;窄屏行卡化(#457)下偶数卡整格同底,仍可读。
1528
+ '.' + p + 'block-data-table tbody tr:nth-child(even) td {',
1529
+ ' background-color: var(--bg);',
1530
+ '}',
620
1531
  '.' + p + 'block-data-table-cell-left {',
621
1532
  ' text-align: left;',
622
1533
  '}',
@@ -626,6 +1537,162 @@ const BLOCK_SECTION_BUILDERS = {
626
1537
  '.' + p + 'block-data-table-cell-right {',
627
1538
  ' text-align: right;',
628
1539
  '}',
1540
+ // #507 数值列主次(审查必改 #2):`align:'right'` 是本层「这一列是数值」的唯一既有信号
1541
+ // (`renderDataTable` 的 `align` 三态里只有它带「数字成栈右对齐」的语义),故**数值列的提权
1542
+ // 挂在这条既有类名上**,不新造类名、不改产出器签名 —— 三族调用点(60 件)一行不用改。
1543
+ // 三处逐条:
1544
+ // ① `color:var(--fg)`:同一语义(摄入=1189)在 KPI 卡里是 `--fg`/700/22px,在表里此前是
1545
+ // `--fg2`/400/13px —— 同一语义两种长法。数值列的**数据**取正文字色,与列头(`--fg3`)分开。
1546
+ // 不改字重(加粗到 600 会把整表拉响亮,`docs/visual-spec-blocks.md:73` 只要 `th` 最浅灰、
1547
+ // `td` 次级灰、未禁数据加粗;本层取最小改动=只换色,层级已够)。
1548
+ // ② `min-width:5.5em`:此前数值列**没有宽度下限**,日期列(`align` 缺省 left)默认吃掉大半
1549
+ // 行宽,两个数值列被挤到右缘四个字符里(1000px 下两列中心相距 ~160px、右侧还空 60px),
1550
+ // 读起来像「数字都堆在角落」。5.5em 按本档 13px 算=71.5px,容得下「2706」与「—」两种内容。
1551
+ // 只加下限,列宽仍由 `table-layout:auto` 按内容分。
1552
+ // ③ 等宽轨 + `tnum`:数字列换成等宽栈(与 `pre-block-code` 同一个栈口径,不新造字体面),
1553
+ // 位数不同的数字仍按列对齐;`tnum` 那一条在 `.…-data-table-table` 上已经是全表口径,
1554
+ // 这里在**单元格**上重申一次,防将来有人在 `td` 上写 `font-variant-numeric` 把它顶掉。
1555
+ // **只命中 `td`**(`align:'right'` 的列头 `th` 不跟着变重):`th` 的色与字号由本区 `th` 规则管。
1556
+ '.' + p + 'block-data-table td.' + p + 'block-data-table-cell-right {',
1557
+ ' min-width: 5.5em;',
1558
+ ' color: var(--fg);',
1559
+ ' font-family: "SF Mono", monospace;',
1560
+ ' font-variant-numeric: tabular-nums;',
1561
+ '}',
1562
+ // #512(列宽按内容给)行首列收窄到内容所需,余量归数值列。
1563
+ // 病根:`table-layout:auto` 下剩段行宽按各列 max-content 比例分,而行首列(`align` 缺省 left;
1564
+ // 本页就是日期列)的 max-content 比数值列重 ⇒ 1000/1440 档实测该列 415.23px(**占表宽 43%**),
1565
+ // 两个数值列各只 271.38px。日期串长度固定(`2026-09-07(今日)`),不需要半屏。
1566
+ // 手段:`width:1%` + `white-space:nowrap` —— 1% 把该列的**目标宽**压到最小,`nowrap` 让它的
1567
+ // min-content = max-content = 内容自然宽,两下一夹,列宽**恰好收在内容上**(实测 152.25px),
1568
+ // 余下的整段行宽归 auto 的数值列(实测 402.88px×2)。#507 那条 `min-width:5.5em` + 等宽栈
1569
+ // + `tabular-nums`(上一行 `td.…-cell-right`)本票一字不动:数值列的**下限**与**余量**是两件事。
1570
+ // 只钉**行首列**(`td.…-cell-left:first-child`):本层没有「这一列是日期」的信号(`left` 是缺省,
1571
+ // 表里人人 `cell-left`),行首是唯一的既有信号。把**所有** left 列一律钉住(试过)会把本仓 141 处
1572
+ // 调用点里「左列装长文本」的表整表重排:账单候选表(5 列)实测数值列由 99.11px 涨到 375.22px,
1573
+ // 且长文本列失去折行兜底(`nowrap`),内容一超宽就横滚 —— 本票不碰那些表。
1574
+ // 只在**桌面档**:`nowrap` 与 ≤640 档 #457 的「靠折行换不横滚」硬碰。本条按 640 断点的桌面侧
1575
+ // 加一档(`min-width:641px` 与既有 `max-width:640px` 互为补集,不新增断点值),窄屏段一字不动
1576
+ // —— 实测 512 档列宽/折行/横滚与改前逐值相同(211.97/133/133.03,0 折行、0 横滚)。
1577
+ '@media (min-width: 641px) {',
1578
+ ' .' + p + 'block-data-table td.' + p + 'block-data-table-cell-left:first-child {',
1579
+ ' width: 1%;',
1580
+ ' white-space: nowrap;',
1581
+ ' }',
1582
+ '}',
1583
+ // #457 手机端(≤640px)紧凑形态:不靠左右滑动看全 —— 收字号(表头 12→11px、单元格 13→12px)、
1584
+ // 收内边距、放开 `th` 的 `white-space: nowrap`(表宽的硬来源)。容器留 `overflow-x: auto` 兜底。
1585
+ // **与 #154 给本区根补的 `margin: 16px 0` 不冲突**:窄屏段只碰字号/内距/换行,不碰边距。
1586
+ //
1587
+ // t154-r3(负责人第三轮要求 2「这张表在手机宽度上效果很差,所有用到该表格的页面都要整改」):
1588
+ // 窄屏从「折行硬挤」改成**行卡化**。病根是上一张票选的「靠折行换不横滚」:`td{overflow-wrap:anywhere}`
1589
+ // 把单元格的最小内容宽度压到 **1 个字符**,五列表(两列长文本)在 390px 里于是被压成「一个汉字宽」,
1590
+ // 日期串按任意位置断行 → 一个字一行(负责人实拍)。
1591
+ // 行卡化=窄屏不再有「列」:`thead` 收起(列名不再占位),`tr` 变一张张小卡,每格一行「标签 + 值」,
1592
+ // 标签来自产出器写在 `td` 上的 `data-label`(`td::before{content:attr(data-label)}`)。
1593
+ // `overflow-wrap:anywhere` **仍留着**(长串——网址/UUID/长数字——不溢出),但它此时作用在
1594
+ // **满宽**的格上,最小宽度是「整格宽」而不是「1 个汉字」⇒ 不会再断成竖排。
1595
+ // 桌面档(≥641)零变化:下面每一条都住在这条既有 `@media (max-width: 640px)` 里,且没有任何
1596
+ // ≥641 的规则引用 `data-label`(属性本身不带任何桌面档样式)。
1597
+ '@media (max-width: 640px) {',
1598
+ ' .' + p + 'block-data-table-table {',
1599
+ // 表格、行、格在窄屏一律走块/栅格流(`table` 的列布局是把内容挤扁的机制本身)。
1600
+ ' display: block;',
1601
+ ' font-size: 12px;',
1602
+ ' }',
1603
+ ' .' + p + 'block-data-table-caption {',
1604
+ ' padding: 8px 10px;',
1605
+ ' font-size: 12px;',
1606
+ // 表注是 `<caption>`,缺省 `display:table-caption`;表在窄屏已 `display:block`(行卡化),
1607
+ // 表注留 `table-caption` 会被挤成**一个汉字宽**的竖排(负责人截图实拍的那一列)⇒ 显式转回块级、占满整卡。
1608
+ ' display: block;',
1609
+ ' width: 100%;',
1610
+ // #547:`width:100%` 只管内容宽,而本仓结果页没有全局 `border-box`(`pageUi.ts:173`)⇒
1611
+ // 实占 100%+左右内距,在 390 档撑出 20px 横滑(容器 376>356,真机实测)。只加这一条声明,
1612
+ // 不新增规则(`ui-fix-154` 守卫按选择器计条,加声明不触发),桌面档不在本段、一字不动。
1613
+ ' box-sizing: border-box;',
1614
+ ' }',
1615
+ // 列名不再占一行:它们改住每格的标签里(`data-label`),留着只会把行挤窄。
1616
+ ' .' + p + 'block-data-table thead {',
1617
+ ' display: none;',
1618
+ ' }',
1619
+ // 保留 #457 的 `th` 一档:本组件不再产 `thead` 之外的 `th`,但手写表可能有 `tbody` 里的
1620
+ // `th`(行首标签列),那类格走「标签 + 值」的同一套字号与换行,不能因为「本组件用不到」而删。
1621
+ ' .' + p + 'block-data-table th {',
1622
+ ' padding: 6px 8px;',
1623
+ ' font-size: 11px;',
1624
+ ' letter-spacing: 0;',
1625
+ ' white-space: normal;',
1626
+ ' }',
1627
+ ' .' + p + 'block-data-table tbody {',
1628
+ ' display: block;',
1629
+ ' }',
1630
+ // 一行=一张卡:卡间一条 hairline(首卡无线),卡内左右各 12px 内距(值与容器边框不贴)。
1631
+ ' .' + p + 'block-data-table tr {',
1632
+ ' display: block;',
1633
+ ' padding: 8px 12px;',
1634
+ ' border-top: 1px solid rgba(' + LINE_RGB + ', .6);',
1635
+ ' }',
1636
+ ' .' + p + 'block-data-table tr:first-child {',
1637
+ ' border-top: 0;',
1638
+ ' }',
1639
+ // 一格=一行「标签 + 值」。**#541(负责人第四轮)**:此前是两轨栅格(`5em` 标签轨 + `1fr` 值轨),
1640
+ // 但值轨里的文字**左对齐**且轨本身吃掉整行余宽 ⇒ 390 档实测值与容器右缘之间空掉**半张卡**(负责人
1641
+ // 截图实拍的那片空白),一列行卡读起来像「左边一小撮字,右边一大片空」。
1642
+ // 改成一个 flex 行 + `space-between`:**值贴容器右缘**,标签贴左缘,两端各自成轴。
1643
+ // 长度自适应是 `space-between` 自带的:短值(`70.4`/`2026-09-07`)的伸缩项收到内容宽、被推到右缘;
1644
+ // 长值(备注里一整句)的 max-content 超过可用宽 ⇒ 伸缩项撑满到标签之后并换行,**不会被挤成一条窄柱**。
1645
+ // `align-items: baseline`:值换行成多行时,标签仍落在**首行**的基线上(同族先例 `weightUi.ts` 的
1646
+ // `.wui-strip-v .wui-fact{justify-content:space-between}`)。
1647
+ // 格的底边线在卡形态下取消(分隔已由卡间线承担);值取正文字色 `--fg`,标签取 `--fg3`(判据同列头)。
1648
+ ' .' + p + 'block-data-table td {',
1649
+ ' display: flex;',
1650
+ ' align-items: baseline;',
1651
+ ' justify-content: space-between;',
1652
+ ' gap: 2px 10px;',
1653
+ ' padding: 4px 0;',
1654
+ ' border-bottom: 0;',
1655
+ ' color: var(--fg);',
1656
+ ' font-size: 12px;',
1657
+ ' overflow-wrap: anywhere;',
1658
+ ' }',
1659
+ ' .' + p + 'block-data-table td::before {',
1660
+ ' content: attr(data-label);',
1661
+ // #541:标签轨**不许收缩**。值很长时(备注里一整句)值的伸缩项撑满余宽,标签轨若还能被压,就会
1662
+ // 被压到「一个汉字宽」并按字断行——正是这一票要消灭的那类形态(负责人截图里的竖排)。
1663
+ // `flex: none`=`0 0 auto`:宽度取标签自己的内容宽,既不缩也不长;值那一侧 `min-width:auto` +
1664
+ // `overflow-wrap:anywhere` 仍能一直缩到 1 个字,所以整行永远不会溢出。
1665
+ ' flex: none;',
1666
+ ' color: var(--fg3);',
1667
+ ' font-size: 11.5px;',
1668
+ ' font-weight: 600;',
1669
+ ' }',
1670
+ // 没拿到标签的格(手写 `td`/极端情况下没有列头)**退化**:不出版面的空标签,值单独占满一格。
1671
+ // 判据:空标签不许出现 —— `content:none` 让伪元素整个不生成(不是「生成一个空盒子」)。
1672
+ // 两条选择器**各占一条规则**(不合并成选择器列表):机检按「选择器逐字相等」查规则,
1673
+ // 合并成列表后按单条选择器会找不到(t154-r3 用例实测)。
1674
+ // #541:三件一行的 flex 形态下「只有值」的那一格不需要任何拉开——**去掉 flex 行**,值就是一块满宽的文字。
1675
+ ' .' + p + 'block-data-table td:not([data-label]) {',
1676
+ ' display: block;',
1677
+ ' }',
1678
+ ' .' + p + 'block-data-table td[data-label=""] {',
1679
+ ' display: block;',
1680
+ ' }',
1681
+ ' .' + p + 'block-data-table td:not([data-label])::before {',
1682
+ ' content: none;',
1683
+ ' }',
1684
+ ' .' + p + 'block-data-table td[data-label=""]::before {',
1685
+ ' content: none;',
1686
+ ' }',
1687
+ // 列已经不在,列对齐(`center`/`right`)在卡里只剩副作用:`min-width:5.5em` 会把值轨顶开 ⇒ 下限
1688
+ // 一律收回 0。`text-align` 只留 `left`:`#541` 起「值贴右缘」由容器上的 `justify-content:space-between`
1689
+ // 承担(那是**盒子**的位置),这条管的是**值换行成多行时行内文字怎么排**——长文本左对齐才好读。
1690
+ ' .' + p + 'block-data-table-table td.' + p + 'block-data-table-cell-center,',
1691
+ ' .' + p + 'block-data-table-table td.' + p + 'block-data-table-cell-right {',
1692
+ ' min-width: 0;',
1693
+ ' text-align: left;',
1694
+ ' }',
1695
+ '}',
629
1696
  ].join(LF),
630
1697
  chartBlock: (p) => [
631
1698
  '.' + p + 'block-chart-block {',
@@ -635,6 +1702,10 @@ const BLOCK_SECTION_BUILDERS = {
635
1702
  ' border-radius: ' + RADIUS_MD + 'px;',
636
1703
  ' background: var(--card);',
637
1704
  '}',
1705
+ // #507 段标题统一(审查必改 #5):本条与 `copy-block-title`/`kpi-card-title` 同一条尺子。
1706
+ // 审查点的是**字号**(本族 15px vs `kpi-card-title` 落回 `h2` 缺省的 17.5px);`margin:0 0 10px`
1707
+ // 是 #154 已有的 10px 档,不在本票写集(动它会连带 `test/ui-fix-154.test.mjs` 的同族同值判据),
1708
+ // 故这条只把字号与字重对齐,边距一字不改。
638
1709
  '.' + p + 'block-chart-block-title {',
639
1710
  ' margin: 0 0 10px;',
640
1711
  ' font-size: 15px;',
@@ -645,14 +1716,20 @@ const BLOCK_SECTION_BUILDERS = {
645
1716
  '}',
646
1717
  ].join(LF),
647
1718
  listRows: (p) => [
1719
+ // #154(2026-09-14 交付页返工)① 区块间距:同 dataTable——本区块级此前一条 margin 都没有
1720
+ // (只有 row 的内距),与同族区块的 `margin: 16px 0` 不一致。取同族值(折叠后与 #457 那条同值 16px)。
648
1721
  '.' + p + 'block-list-rows {',
1722
+ ' margin: 16px 0;',
649
1723
  ' border: 1px solid var(--line);',
650
1724
  ' border-radius: ' + RADIUS_MD + 'px;',
651
1725
  ' background: var(--card);',
652
1726
  '}',
653
1727
  '.' + p + 'block-list-rows-row {',
654
1728
  ' display: grid;',
655
- ' grid-template-columns: 44px minmax(0, 1fr) auto;',
1729
+ // #567 D2:首轨 44px 定死把「合计摄入」四字硬折两行(1440 与 390 同值)。
1730
+ // `minmax(44px, auto)`:短内容(▲/▼/— 标记)仍是 44px(旧观感不动、跨行最小对齐不动),
1731
+ // 长标签按内容撑开、恒单行。行自身仍是独立栅格(跨行不对齐本就只靠 44px 下限)。
1732
+ ' grid-template-columns: minmax(44px, auto) minmax(0, 1fr) auto;',
656
1733
  ' gap: 8px;',
657
1734
  ' align-items: center;',
658
1735
  ' padding: 10px 14px;',
@@ -662,6 +1739,11 @@ const BLOCK_SECTION_BUILDERS = {
662
1739
  '.' + p + 'block-list-rows-row:first-child {',
663
1740
  ' border-top: 0;',
664
1741
  '}',
1742
+ // 没给 `left` 的行:#154 加的形态——**不占标记列**(`44px` 是给 `▲`/`▼`/`—` 用的)。
1743
+ // 不给这条规则的话,缺 `left` 的行里 `main` 会落进 44px、被 `nowrap` + `ellipsis` 截断。
1744
+ '.' + p + 'block-list-rows-row-no-left {',
1745
+ ' grid-template-columns: minmax(0, 1fr) auto;',
1746
+ '}',
665
1747
  '.' + p + 'block-list-rows-left {',
666
1748
  ' color: var(--fg3);',
667
1749
  ' font-variant-numeric: tabular-nums;',
@@ -716,7 +1798,9 @@ const BLOCK_SECTION_BUILDERS = {
716
1798
  '}',
717
1799
  '.' + p + 'block-detail-section-title {',
718
1800
  ' margin: 0;',
719
- ' font-size: 17px;',
1801
+ // #567 J4(§5.2 标题 18 吸收 17/18/20/22):17→18。KPI 主数字 22 不动(#154 用户 verdict+
1802
+ // #544 终审既判),级差 22−18=4 仍满足 `ui-fix-154`(≥4px)。
1803
+ ' font-size: 18px;',
720
1804
  ' font-weight: 600;',
721
1805
  '}',
722
1806
  '.' + p + 'block-detail-section-meta {',
@@ -729,7 +1813,8 @@ const BLOCK_SECTION_BUILDERS = {
729
1813
  '.' + p + 'block-detail-section-wake {',
730
1814
  ' color: var(--fg3);',
731
1815
  ' font-family: "SF Mono", monospace;',
732
- ' font-size: 11.5px;',
1816
+ // #567 J4(§5.2 次级 12 吸收 12/11.5)。
1817
+ ' font-size: 12px;',
733
1818
  '}',
734
1819
  '.' + p + 'block-detail-section-dev {',
735
1820
  ' display: inline-flex;',
@@ -770,7 +1855,8 @@ const BLOCK_SECTION_BUILDERS = {
770
1855
  ' align-items: center;',
771
1856
  ' min-height: 44px;',
772
1857
  ' padding: 0 14px;',
773
- ' font-size: 14px;',
1858
+ // #567 J4(§5.2 区块标题 15 吸收 14/15)。
1859
+ ' font-size: 15px;',
774
1860
  ' font-weight: 600;',
775
1861
  ' cursor: pointer;',
776
1862
  ' list-style: none;',
@@ -788,6 +1874,13 @@ const BLOCK_SECTION_BUILDERS = {
788
1874
  '.' + p + 'block-disclosure[open] > .' + p + 'block-disclosure-summary::before {',
789
1875
  ' transform: rotate(90deg);',
790
1876
  '}',
1877
+ // #507 减动效归零档(照老壳 `style.ts` 归零档补:本区唯一的 `transition` 在此归零;
1878
+ // 只停过渡,`rotate(90deg)` 开合终态照走)。
1879
+ '@media (prefers-reduced-motion: reduce) {',
1880
+ ' .' + p + 'block-disclosure-summary::before {',
1881
+ ' transition: none;',
1882
+ ' }',
1883
+ '}',
791
1884
  '.' + p + 'block-disclosure-body {',
792
1885
  ' padding: 0 14px 14px;',
793
1886
  '}',
@@ -870,6 +1963,98 @@ const BLOCK_SECTION_BUILDERS = {
870
1963
  ' font-size: 15px;',
871
1964
  ' font-weight: 700;',
872
1965
  '}',
1966
+ // ── #154 页内静态提示(浅色形态)─────────────────────────────────────────────
1967
+ // 2026-09-14 用户返工:「顶部有个奇怪的弹窗这是什么?」——页顶那条「本窗只有 1 条记录…」由
1968
+ // `renderFeedbackBlock` 经冻结 `renderToast` 产出,穿的是**深色毛玻璃卡 +「✓ 知道了」**的
1969
+ // 弹窗外衣,而它是页面流里的一段静态块(t375 证据件:整份产物 `position: fixed` 出现 0 次、
1970
+ // 没有遮罩与焦点陷阱)。深色卡面是**运行时瞬时 toast** 的形态(2026-09-12 用户亲自裁定),
1971
+ // 两者不是一回事:本形态只在 `staticNotice === true` 时出现(缺省逐字节不变)。
1972
+ // 浅底/描边/圆角逐值取本文件里既有口径:底 `var(--soft)`、描边 1px `var(--line)`、
1973
+ // 圆角 `RADIUS_MD`(14px,闭集 {8,14,20,999} 内);**不带关闭按钮**(静态提示不该能被点掉)。
1974
+ '.' + p + 'block-feedback-block-note {',
1975
+ ' display: flex;',
1976
+ ' align-items: flex-start;',
1977
+ ' gap: 10px;',
1978
+ ' margin: 16px 0;',
1979
+ ' padding: 12px 14px;',
1980
+ ' border: 1px solid var(--line);',
1981
+ ' border-radius: ' + RADIUS_MD + 'px;',
1982
+ ' background: var(--soft);',
1983
+ ' color: var(--fg2);',
1984
+ ' font-size: 13px;',
1985
+ ' line-height: 1.5;',
1986
+ '}',
1987
+ // 图标底盘:`kind` 的语义色落在**底盘底色+字色**上。字形是 emoji(`TOAST_ICON_GLYPHS`,
1988
+ // 与 toast 同源),彩色 emoji 的 `color` 对它无效——语义色因此由底盘承担,`color` 只对
1989
+ // 无彩色字形(等宽/降级字体)生效,两处都写着,缺一不可。
1990
+ '.' + p + 'block-feedback-block-note-icon {',
1991
+ ' display: inline-flex;',
1992
+ ' flex: 0 0 auto;',
1993
+ ' align-items: center;',
1994
+ ' justify-content: center;',
1995
+ ' width: 22px;',
1996
+ ' height: 22px;',
1997
+ ' border-radius: ' + RADIUS_SM + 'px;',
1998
+ ' background: var(--card);',
1999
+ ' color: var(--fg2);',
2000
+ ' font-size: 13px;',
2001
+ ' line-height: 1;',
2002
+ '}',
2003
+ // 三档实色**逐值取同仓既有的状态徽章**(`src/style.ts` 的 `statusBadge` 区:ok `#e6f7ec`/`#1f8c3d`、
2004
+ // warn `#fff5e0`/`#a25b00`、danger `#fff0ee`/`#a83228`,原样取自旧层 `.hm-status.*`)——
2005
+ // 不发明新色值;`info`/`copy` 两档走冻结 token(`--card`/`--blue2`/`--fg2`),也不发明。
2006
+ '.' + p + 'block-feedback-block-note-icon-ok {',
2007
+ ' background: #e6f7ec;',
2008
+ ' color: #1f8c3d;',
2009
+ '}',
2010
+ '.' + p + 'block-feedback-block-note-icon-warn {',
2011
+ ' background: #fff5e0;',
2012
+ ' color: #a25b00;',
2013
+ '}',
2014
+ '.' + p + 'block-feedback-block-note-icon-danger {',
2015
+ ' background: #fff0ee;',
2016
+ ' color: #a83228;',
2017
+ '}',
2018
+ '.' + p + 'block-feedback-block-note-icon-info {',
2019
+ ' background: var(--card);',
2020
+ ' color: var(--blue2);',
2021
+ '}',
2022
+ '.' + p + 'block-feedback-block-note-icon-copy {',
2023
+ ' background: var(--card);',
2024
+ ' color: var(--fg2);',
2025
+ '}',
2026
+ '.' + p + 'block-feedback-block-note-body {',
2027
+ ' flex: 1 1 auto;',
2028
+ ' min-width: 0;',
2029
+ '}',
2030
+ // 正文=正文字色 + 600 字重(与 `.toast-title` 的层级口径一致,但取浅色面 token)。
2031
+ '.' + p + 'block-feedback-block-note-title {',
2032
+ ' color: var(--fg);',
2033
+ ' font-weight: 600;',
2034
+ '}',
2035
+ '.' + p + 'block-feedback-block-note-detail {',
2036
+ ' margin-top: 2px;',
2037
+ ' color: var(--fg2);',
2038
+ '}',
2039
+ '.' + p + 'block-feedback-block-note-lines {',
2040
+ ' margin-top: 2px;',
2041
+ ' color: var(--fg2);',
2042
+ ' white-space: pre-wrap;',
2043
+ '}',
2044
+ // 代码块:与 `preBlock`/help 的等宽口径同族(12px/1.55/pre-wrap/overflow-x:auto),
2045
+ // 但浅色面用 `var(--card)` 抬一级底色(`var(--soft)` 上再放浅底会糊成一片)。
2046
+ '.' + p + 'block-feedback-block-note-code {',
2047
+ ' margin: 6px 0 0;',
2048
+ ' padding: 8px 10px;',
2049
+ ' border-radius: ' + RADIUS_SM + 'px;',
2050
+ ' background: var(--card);',
2051
+ ' color: var(--fg2);',
2052
+ ' font-family: "SF Mono", monospace;',
2053
+ ' font-size: 12px;',
2054
+ ' line-height: 1.55;',
2055
+ ' white-space: pre-wrap;',
2056
+ ' overflow-x: auto;',
2057
+ '}',
873
2058
  ].join(LF),
874
2059
  };
875
2060
  /** 闭集漂移 fail-fast(与 `src/style.ts` 同口径):缺区实现导入即抛。 */