base-paint 0.3.1 → 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 (56) hide show
  1. package/dist/blocks.d.ts +141 -4
  2. package/dist/blocks.d.ts.map +1 -1
  3. package/dist/blocks.js +1015 -25
  4. package/dist/blocks.js.map +1 -1
  5. package/dist/charts.d.ts +6 -4
  6. package/dist/charts.d.ts.map +1 -1
  7. package/dist/charts.js +378 -64
  8. package/dist/charts.js.map +1 -1
  9. package/dist/controls.d.ts +20 -5
  10. package/dist/controls.d.ts.map +1 -1
  11. package/dist/controls.js +246 -34
  12. package/dist/controls.js.map +1 -1
  13. package/dist/font.d.ts +3 -3
  14. package/dist/font.js +3 -3
  15. package/dist/help.d.ts +3 -3
  16. package/dist/help.js +12 -12
  17. package/dist/help.js.map +1 -1
  18. package/dist/helpShell.d.ts.map +1 -1
  19. package/dist/helpShell.js +2 -2
  20. package/dist/helpShell.js.map +1 -1
  21. package/dist/index.d.ts +10 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +8 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/injector.d.ts +1 -1
  26. package/dist/output/saveHtml.d.ts +37 -2
  27. package/dist/output/saveHtml.d.ts.map +1 -1
  28. package/dist/output/saveHtml.js +125 -15
  29. package/dist/output/saveHtml.js.map +1 -1
  30. package/dist/pageShapes.d.ts +117 -0
  31. package/dist/pageShapes.d.ts.map +1 -0
  32. package/dist/pageShapes.js +366 -0
  33. package/dist/pageShapes.js.map +1 -0
  34. package/dist/pageUi.d.ts +37 -0
  35. package/dist/pageUi.d.ts.map +1 -0
  36. package/dist/pageUi.js +237 -0
  37. package/dist/pageUi.js.map +1 -0
  38. package/dist/spec/charts.d.ts +12 -3
  39. package/dist/spec/charts.d.ts.map +1 -1
  40. package/dist/spec/charts.js +1 -1
  41. package/dist/spec/charts.js.map +1 -1
  42. package/dist/spec/controls.d.ts +30 -5
  43. package/dist/spec/controls.d.ts.map +1 -1
  44. package/dist/spec/controls.js +14 -3
  45. package/dist/spec/controls.js.map +1 -1
  46. package/dist/spec/help.d.ts +4 -4
  47. package/dist/spec/help.d.ts.map +1 -1
  48. package/dist/spec/help.js +2 -2
  49. package/dist/spec/index.d.ts +6 -3
  50. package/dist/spec/index.d.ts.map +1 -1
  51. package/dist/spec/index.js +27 -2
  52. package/dist/spec/index.js.map +1 -1
  53. package/dist/style.d.ts.map +1 -1
  54. package/dist/style.js +265 -47
  55. package/dist/style.js.map +1 -1
  56. package/package.json +1 -1
package/dist/controls.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** base-paint/controls:#76 控件层运行时(契约 §3.3/§6.3)。
2
2
  *
3
- * 落地冻结面 44 条里 #76 的 13 条 `pending`(10 个运行时出口 + 3 个类型出口):
3
+ * 实施冻结面 44 条里 #76 的 13 条 `pending`(10 个运行时出口 + 3 个类型出口):
4
4
  * `copyText`/`createCopyRuntime`/`bindCopyAction`/`buildSharedHelpersJs`/
5
5
  * `renderToast`/`createToastController`/`renderActionBar`/`renderStatusBadge`/
6
6
  * `renderEmptyState`/`renderErrorReceipt`。签名逐字取 `src/spec/controls.ts`,本文件不改任何冻结值。
@@ -28,6 +28,12 @@
28
28
  * `readDataText → undefined` 跳过);`text === ''` → 仍写 `data-t=""`(binder 送进空串短路,无反馈);
29
29
  * 场景按钮 `kind` 取冻结 `ACTION_BAR_KINDS` **全量**(含 `ghost`),不以 §3.3 表格的「primary/red」为限;
30
30
  * `format` 不参与渲染(序列化归 #77);
31
+ * - `renderActionBar` 的 `CopyButtonInput.formats`(#247 三格式形态)→ 按钮只开合菜单、**不写** `data-t`,
32
+ * 菜单三项各写自己的 `data-t`(键取 `COPY_FORMATS`,值=该格式已序列化文本);菜单项的
33
+ * `data-action-id` **留空**(三颗逐字不同的 id 与「同次渲染内唯一」口径不合,见
34
+ * `CopyButtonInput.formats` 注释);`formats` 与 `text` 同给 → `bad-input`;
35
+ * `formats` 缺某个格式/非字符串 → `bad-input`;`hints` 非三个串 → `bad-input`;
36
+ * 菜单的开合/点外收起/选中后按该项 `data-t` 复制并报所选格式 → 归 helpers 运行时的委派;
31
37
  * - `renderStatusBadge` 的 `text === ''` → 视为缺省(旧层口径);非法 `status` 降级 `'empty'`(冻结语义);
32
38
  * - `renderEmptyState` 的 `actionHtml` → **受信 HTML 透传**(契约同口径,调用方负责其内容安全),可含
33
39
  * 内联 `onclick`;「零注入面」只约束 base-paint **自产**标记,不约束调用方透传的受信片段;
@@ -44,7 +50,7 @@
44
50
  * (`base.js:96`)本就是页面侧代码。两侧分工见契约 §3.3「toast 移动端收窄的分工(FX-76-2)」。
45
51
  */
46
52
  import { STYLE_PREFIX } from './style.js';
47
- import { ACTION_BAR_DEFAULTS, ACTION_BAR_KINDS, ACTION_ID_ATTR, CONTROL_STYLE_SECTIONS, COPY_ACTION_IDS, COPY_TEXT_DEFAULTS, DEFAULT_DATA_ATTR, ESCAPE_HTML_CHARS, ESCAPE_HTML_ENTITIES, HELP_COPY_ACTIONS, HELP_SHELL_ID, STATUS_DEFAULT_TEXT, STATUS_KINDS, TOAST_DEFAULTS, } from './spec/index.js';
53
+ import { ACTION_BAR_DEFAULTS, ACTION_BAR_KINDS, ACTION_ID_ATTR, CONTROL_STYLE_SECTIONS, COPY_ACTION_IDS, COPY_FORMATS, COPY_TEXT_DEFAULTS, DEFAULT_DATA_ATTR, ESCAPE_HTML_CHARS, ESCAPE_HTML_ENTITIES, HELP_COPY_ACTIONS, HELP_SHELL_ID, STATUS_DEFAULT_TEXT, STATUS_KINDS, TOAST_DEFAULTS, } from './spec/index.js';
48
54
  /* ── 错误形态(§3.3:与既有 RenderError 并列、互不继承、一律抛出、不返空) ── */
49
55
  /** 控件层错误。**不**从 `src/index.ts` 导出:`SPEC_FROZEN_SURFACE` 44 条内无该运行时条目,
50
56
  * 调用方按 `name === 'ControlsError'` + `code` 判定(与 `fillTemplate` 的 `TemplateError` 同口径)。 */
@@ -85,15 +91,17 @@ function isStatusKind(value) {
85
91
  return STATUS_KINDS.includes(value);
86
92
  }
87
93
  /* ── toast(`renderToast` + `createToastController`) ─────────────────── */
88
- /** 图标字形:`TOAST_ICONS` 是**冻结的枚举名**(不是字形),字形属文档未规定项(旧层 `base.js:74` 同值)。 */
89
- const TOAST_ICON_GLYPHS = Object.freeze({
94
+ /** 图标字形:`TOAST_ICONS` 是**冻结的枚举名**(不是字形),字形属文档未规定项(旧层 `base.js:74` 同值)。
95
+ * **导出(#154)**:区块层 B-12 的「页内静态提示」形态要把同一个图标放进浅色块里,
96
+ * 字形只此一份(`blocks.ts` 引用本表,不另抄);本表**不进** `src/index.ts` 出口面。 */
97
+ export const TOAST_ICON_GLYPHS = Object.freeze({
90
98
  copy: '📋',
91
99
  ok: '✅',
92
100
  warn: '⚠️',
93
101
  danger: '❌',
94
102
  info: '💡',
95
103
  });
96
- /** 徽章类型白名单:取冻结类型 `ToastBadge['type']` 的成员(非法值回落 `'ok'`,旧层口径)。 */
104
+ /** 徽章类型允许清单:取冻结类型 `ToastBadge['type']` 的成员(非法值回落 `'ok'`,旧层口径)。 */
97
105
  const TOAST_BADGE_TYPES = ['ok', 'warn', 'danger'];
98
106
  /** 关闭按钮文案(文档无规定;沿用旧基线 `✓ 知道了`)。 */
99
107
  const TOAST_CLOSE_LABEL = '✓ 知道了';
@@ -347,7 +355,7 @@ export const createCopyRuntime = (ports) => {
347
355
  * - id 来源**只有** `port.listActionIds()`:不猜 id、不通配前缀、不订阅未列出的 id;
348
356
  * - 重复 id 由 binder 自行去重(同一 id 只订阅一次);
349
357
  * - 激活时读 `port.readDataText(actionId)`:非字符串(含 `undefined`)→ 跳过(不抛错、不产 toast),
350
- * 非复制按钮(场景按钮)即由此跳过,不另设白名单;
358
+ * 非复制按钮(场景按钮)即由此跳过,不另设允许清单;
351
359
  * - 反馈一律走 `ports.toast`(`copyText` 内部),binder 不另出反馈;
352
360
  * - **激活回调恒不产生未处理拒绝**(FX-76-6):`copyText` 正常路径的失败已在内部 `settle()` 处理,
353
361
  * 这里只兜「逃出 `copyText` 的异常」(端口实现抛错/`ports.toast.mount` 抛错/回调抛错)——
@@ -417,7 +425,7 @@ function helpersDataAttr(input) {
417
425
  function styleSectionSlug(section) {
418
426
  return section.replace(/[A-Z]/g, (ch) => '-' + ch.toLowerCase());
419
427
  }
420
- /** HELP 壳类名命名空间 kebab 后缀(`help-shell`;缺省前缀下根 = `ilife-help-shell`)。
428
+ /** HELP模板类名命名空间 kebab 后缀(`help-shell`;缺省前缀下根 = `ilife-help-shell`)。
421
429
  * **延迟求值**(不在模块顶层):`style.ts → charts.ts → controls.ts → style.ts` 存在模块环,
422
430
  * 顶层读 `STYLE_PREFIX` 会在 `style.js` 初始化完成前触发 TDZ(实测 `ReferenceError`),
423
431
  * 故派生式只在 `buildSharedHelpersJs()` **调用时**执行(此时全部模块已初始化)。 */
@@ -475,7 +483,7 @@ const HELP_HIT_EMPTY = '没有找到相关场景,换个词试试~';
475
483
  *
476
484
  * **#88 S4 追加(HELP 速查台运行时增强;文档无规定 → 本文记账)**:全部挂进**既有 `boot()`**、
477
485
  * 共用**既有幂等 marker**(`MARKER_SEL` + `querySelector` 早退),**不新增第二个标记**;每一项都
478
- * 先判 HELP 壳是否在页面(helpers 被所有技能页面共享,非 HELP 页逐项早退、零副作用):
486
+ * 先判 HELP模板是否在页面(helpers 被所有技能页面共享,非 HELP 页逐项早退、零副作用):
479
487
  * - **卡级复制按钮**(`card-copy`):每张场景卡的**卡头**注入 1 个按钮,`actionId`/文案恒读
480
488
  * `HELP_COPY_ACTIONS.prompt`,`dataAttr` 取同卡 `<pre class="prompt">` 的原文(=该卡 prompt 逐字;
481
489
  * #88 S5 起改以 `<pre>` 为**主源**,读不到才回落 Sheet 内 prompt 按钮的 data-t),
@@ -502,7 +510,7 @@ export const buildSharedHelpersJs = (input) => {
502
510
  const dataAttr = helpersDataAttr(input);
503
511
  const markerAttr = 'data-' + prefix.replace(/-+$/, '') + '-helpers';
504
512
  const markerSelector = '[' + markerAttr + '="1"]';
505
- // #88 S4:HELP 壳命名空间(缺省 `ilife-help-shell`;`prefix` 覆盖时随动,不残留缺省前缀)。
513
+ // #88 S4:HELP模板命名空间(缺省 `ilife-help-shell`;`prefix` 覆盖时随动,不残留缺省前缀)。
506
514
  const shellClass = prefix + helpShellSlug();
507
515
  const shellPart = (suffix) => shellClass + '-' + suffix;
508
516
  const promptAttr = 'data-' + prefix.replace(/-+$/, '') + '-help-prompt';
@@ -518,15 +526,21 @@ export const buildSharedHelpersJs = (input) => {
518
526
  ' var STACK_CLASS = ' + jsStr(prefix + 'toast-stack') + ';',
519
527
  ' var TOAST_CLASS = ' + jsStr(prefix + 'toast') + ';',
520
528
  ' var ICON_CLASS = ' + jsStr(prefix + 'toast-icon') + ';',
521
- ' var ICON_GLYPH = ' + jsStr(TOAST_ICON_GLYPHS[TOAST_DEFAULTS.defaultIcon]) + ';',
529
+ ' var ICON_OK = ' + jsStr(TOAST_ICON_GLYPHS.copy) + ';',
530
+ ' var ICON_FAIL = ' + jsStr(TOAST_ICON_GLYPHS.danger) + ';',
531
+ ' var CHIP_CLASS = ' + jsStr(prefix + 'toast-chip') + ';',
532
+ ' var CHIP_DANGER_CLASS = ' + jsStr(prefix + 'toast-chip-danger') + ';',
522
533
  ' var TITLE_CLASS = ' + jsStr(prefix + 'toast-title') + ';',
523
534
  ' var BODY_CLASS = ' + jsStr(prefix + TOAST_BODY_CLASS) + ';',
524
535
  ' var TITLE_ROW_CLASS = ' + jsStr(prefix + TOAST_TITLE_ROW_CLASS) + ';',
525
536
  ' var DETAIL_CLASS = ' + jsStr(prefix + TOAST_DETAIL_CLASS) + ';',
526
537
  ' var CLOSE_CLASS = ' + jsStr(prefix + TOAST_CLOSE_CLASS) + ';',
527
538
  ' var OK_MSG = ' + jsStr(COPY_TEXT_DEFAULTS.okMessage) + ';',
539
+ ' var OK_DETAIL = ' + jsStr(COPY_TEXT_DEFAULTS.okDetail) + ';',
528
540
  ' var FAIL_MSG = ' + jsStr(COPY_TEXT_DEFAULTS.failMessage) + ';',
529
541
  ' var FAIL_DETAIL = ' + jsStr(COPY_TEXT_DEFAULTS.failDetail) + ';',
542
+ // 失败徽章文案取 `STATUS_DEFAULT_TEXT.danger`(与静态产出器 `copyText` 的失败徽章同一处常量,不产第二份真相)
543
+ ' var FAIL_BADGE = ' + jsStr(STATUS_DEFAULT_TEXT.danger) + ';',
530
544
  ' var CLOSE_LABEL = ' + jsStr(TOAST_CLOSE_LABEL) + ';',
531
545
  ' var TIMEOUT_MS = ' + TOAST_DEFAULTS.timeoutMs + ';',
532
546
  ' var MAX_STACK = ' + TOAST_DEFAULTS.maxStack + ';',
@@ -534,6 +548,16 @@ export const buildSharedHelpersJs = (input) => {
534
548
  ' var MOBILE_MAX_PX = ' + TOAST_DEFAULTS.mobileMaxPx + ';',
535
549
  ' var LF = String.fromCharCode(10);',
536
550
  ' var LF2 = LF + LF;',
551
+ // #247:复制数据的三格式形态(菜单开合器 + 格式菜单项)。开合标记/格式键都是**属性**,
552
+ // 菜单容器、菜单项与按钮的共同父节点走类名——四者与渲染端(`copyMenuHtml`)逐字同值,
553
+ // 不产第二份真相。
554
+ ' var MENU_OPEN_SEL = ' + jsStr('[' + COPY_MENU_OPEN_ATTR + '="1"]') + ';',
555
+ ' var MENU_ITEM_SEL = ' + jsStr('[' + COPY_MENU_FMT_ATTR + ']') + ';',
556
+ ' var MENU_WRAP_CLASS = ' + jsStr(prefix + COPY_MENU_WRAP_CLASS) + ';',
557
+ ' var MENU_CLASS = ' + jsStr(prefix + COPY_MENU_CLASS) + ';',
558
+ ' var MENU_LABEL_CLASS = ' + jsStr(prefix + COPY_MENU_LABEL_CLASS) + ';',
559
+ ' var MENU_OPEN_CLASS = ' + jsStr(COPY_MENU_OPEN_CLASS) + ';',
560
+ ' var FORMAT_OK_MSG = ' + jsStr(COPY_FORMAT_OK_MSG) + ';',
537
561
  // #88 S4:HELP 速查台类名/选择器(同一命名空间,逐条由 `shellClass` 派生)
538
562
  ' var SHELL_SEL = ' + jsStr('.' + shellClass) + ';',
539
563
  ' var CARD_CLASS = ' + jsStr(shellPart('card')) + ';',
@@ -608,34 +632,93 @@ export const buildSharedHelpersJs = (input) => {
608
632
  ' if (box && box.parentNode) box.parentNode.removeChild(box);',
609
633
  ' return;',
610
634
  ' }',
635
+ ' if (onMenuClick(node)) return;',
611
636
  ' var btn = node.closest("[" + ACTION_ATTR + "]");',
612
637
  ' if (!btn) return;',
638
+ // 点同一行另一颗复制按钮(复制日志/复制指令)时顺手收起菜单:与「点别处收起」同效,
639
+ // 且不影响它自己的复制(菜单与按钮是两个互不干扰的路径)。
640
+ ' var menuOpen = document.querySelector("." + MENU_OPEN_CLASS);',
641
+ ' if (menuOpen) closeMenu(menuOpen.parentNode);',
613
642
  ' var text = btn.getAttribute(TEXT_ATTR);',
614
643
  ' if (text === null) return;',
615
644
  ' copy(text, btn);',
616
645
  ' }',
617
646
  '',
618
- ' function copy(text, btn) {',
647
+ ' /* #247:复制数据的三格式形态——按钮(`data-fmt-open`)只开合菜单,菜单项(`data-fmt`)各自带',
648
+ ' 自己的 `data-t`。两条分支都排在复制委派**之前**:开合器**没有** `data-action-id`,故不会与',
649
+ ' 复制委派抢同一次点击;菜单项走复制但**不用** id 反查(读被点击元素自己的属性,与 HELP 速查台',
650
+ ' 逐行写同一冻结 id 的口径一致)。返回真=本次点击已被菜单消费。 */',
651
+ ' function onMenuClick(node) {',
652
+ ' var opener = node.closest(MENU_OPEN_SEL);',
653
+ ' if (!opener) {',
654
+ ' /* 菜单项:处理完**直接返回**,不下落到复制委派——它没有 `data-action-id`,落下去只会空转。',
655
+ ' 复制仍然走同一条 `copy()`(双通道 + `copied` 态 + 提示),只是多了「报所选格式」这一项。 */',
656
+ ' var item = node.closest(MENU_ITEM_SEL);',
657
+ ' if (item) {',
658
+ ' var itemText = item.getAttribute(TEXT_ATTR);',
659
+ ' var itemWrap = item.closest("." + MENU_WRAP_CLASS);',
660
+ ' if (itemWrap) closeMenu(itemWrap);',
661
+ ' var itemLabel = item.querySelector("." + MENU_LABEL_CLASS);',
662
+ ' copy(itemText, item, itemLabel ? itemLabel.textContent : "");',
663
+ ' return true;',
664
+ ' }',
665
+ ' /* 点别处收起(老仓 `document` 上那条「不在 .fmt-wrap 里就收起」的同效):',
666
+ ' 页面上最多只开一个菜单,故扫到开着的就收。 */',
667
+ ' var open = document.querySelector("." + MENU_OPEN_CLASS);',
668
+ ' if (open) closeMenu(open.parentNode);',
669
+ ' return false;',
670
+ ' }',
671
+ ' var wrap = opener.closest("." + MENU_WRAP_CLASS);',
672
+ ' if (!wrap) return true;',
673
+ ' var menu = wrap.querySelector("." + MENU_CLASS);',
674
+ ' if (menu) toggleMenu(opener, menu);',
675
+ ' return true;',
676
+ ' }',
677
+ '',
678
+ ' /* 开合只翻**可视态**(`copy-menu-open` 类 + `aria-expanded`),**不动 DOM**(#249 返修):',
679
+ ' 菜单是浮层,层叠归 CSS(`position:absolute` + `z-index:20`),不在常规流里;而 DOM 序=内容序',
680
+ ' ——ghost 行是两列网格、按序铺格,开时搬一颗,两颗就换位,且收起时回不去。 */',
681
+ ' function toggleMenu(opener, menu) {',
682
+ ' var open = hasClass(menu, MENU_OPEN_CLASS);',
683
+ ' if (open) {',
684
+ ' removeClass(menu, MENU_OPEN_CLASS);',
685
+ ' opener.setAttribute("aria-expanded", "false");',
686
+ ' return;',
687
+ ' }',
688
+ ' addClass(menu, MENU_OPEN_CLASS);',
689
+ ' opener.setAttribute("aria-expanded", "true");',
690
+ ' }',
691
+ '',
692
+ ' function closeMenu(wrap) {',
693
+ ' if (!wrap) return;',
694
+ ' var opener = wrap.querySelector(MENU_OPEN_SEL);',
695
+ ' var menu = wrap.querySelector("." + MENU_CLASS);',
696
+ ' if (!menu || !hasClass(menu, MENU_OPEN_CLASS)) return;',
697
+ ' removeClass(menu, MENU_OPEN_CLASS);',
698
+ ' if (opener) opener.setAttribute("aria-expanded", "false");',
699
+ ' }',
700
+ '',
701
+ ' function copy(text, btn, fmt) {',
619
702
  ' var clip = navigator.clipboard;',
620
703
  ' if (clip && typeof clip.writeText === "function") {',
621
704
  ' try {',
622
705
  ' var done = clip.writeText(text);',
623
706
  ' if (done && typeof done.then === "function") {',
624
- ' done.then(function () { markCopied(btn); feedback(OK_MSG, false); }, function () { fallback(text, btn); });',
707
+ ' done.then(function () { markCopied(btn); feedback(OK_MSG, false, fmt); }, function () { fallback(text, btn, fmt); });',
625
708
  ' return;',
626
709
  ' }',
627
710
  ' markCopied(btn);',
628
- ' feedback(OK_MSG, false);',
711
+ ' feedback(OK_MSG, false, fmt);',
629
712
  ' return;',
630
713
  ' } catch (err) {',
631
- ' fallback(text, btn);',
714
+ ' fallback(text, btn, fmt);',
632
715
  ' return;',
633
716
  ' }',
634
717
  ' }',
635
- ' fallback(text, btn);',
718
+ ' fallback(text, btn, fmt);',
636
719
  ' }',
637
720
  '',
638
- ' function fallback(text, btn) {',
721
+ ' function fallback(text, btn, fmt) {',
639
722
  ' var sink = document.createElement("textarea");',
640
723
  ' sink.value = text;',
641
724
  ' sink.setAttribute("readonly", "readonly");',
@@ -648,7 +731,7 @@ export const buildSharedHelpersJs = (input) => {
648
731
  ' try { done = document.execCommand("copy"); } catch (err) { done = false; }',
649
732
  ' if (sink.parentNode) sink.parentNode.removeChild(sink);',
650
733
  ' if (done) markCopied(btn);',
651
- ' feedback(done ? OK_MSG : FAIL_MSG, !done);',
734
+ ' feedback(done ? OK_MSG : FAIL_MSG, !done, fmt);',
652
735
  ' }',
653
736
  '',
654
737
  ' /* #121(H-16 双反馈的按钮通道):成功态加 `copied` 类,450ms 后移除。',
@@ -660,7 +743,7 @@ export const buildSharedHelpersJs = (input) => {
660
743
  ' setTimeout(function () { removeClass(btn, COPIED_CLASS); }, COPIED_MS);',
661
744
  ' }',
662
745
  '',
663
- ' function feedback(msg, bad) {',
746
+ ' function feedback(msg, bad, fmt) {',
664
747
  ' var cap = MAX_STACK;',
665
748
  ' if (window.matchMedia && window.matchMedia("(max-width: " + MOBILE_MAX_PX + "px)").matches) cap = MOBILE_MAX_STACK;',
666
749
  ' var host = document.querySelector("." + STACK_CLASS);',
@@ -686,22 +769,31 @@ export const buildSharedHelpersJs = (input) => {
686
769
  ' var icon = document.createElement("span");',
687
770
  ' icon.className = ICON_CLASS;',
688
771
  ' icon.setAttribute("aria-hidden", "true");',
689
- ' icon.textContent = ICON_GLYPH;',
772
+ ' icon.textContent = bad ? ICON_FAIL : ICON_OK;',
690
773
  ' var body = document.createElement("div");',
691
774
  ' body.className = BODY_CLASS;',
692
775
  ' var titleRow = document.createElement("div");',
693
776
  ' titleRow.className = TITLE_ROW_CLASS;',
694
777
  ' var title = document.createElement("span");',
695
778
  ' title.className = TITLE_CLASS;',
696
- ' title.textContent = msg;',
779
+ // #247:选了格式的那条**成功**提示报所选格式(「数据复制成功(纯文本/JSON/CSV)」)——格式名取
780
+ // 菜单项的标签文本(渲染期写死的那个键),不在运行时里另立一张中文表,选一个格式=一个提示。
781
+ // **失败提示照旧**(`FAIL_MSG` + ❌ +「失败」徽章):失败与格式无关,别把它也套上格式名。
782
+ ' title.textContent = bad ? msg : (fmt ? FORMAT_OK_MSG + "(" + fmt + ")" : msg);',
697
783
  ' titleRow.appendChild(title);',
698
- ' body.appendChild(titleRow);',
784
+ // 5a(2026-09-12 用户裁定「UI 上要统一」):失败态与静态产出器/老仓同形——红底「失败」徽章进标题行。
699
785
  ' if (bad) {',
700
- ' var detail = document.createElement("div");',
701
- ' detail.className = DETAIL_CLASS;',
702
- ' detail.textContent = FAIL_DETAIL;',
703
- ' body.appendChild(detail);',
786
+ ' var chip = document.createElement("span");',
787
+ ' chip.className = CHIP_CLASS + " " + CHIP_DANGER_CLASS;',
788
+ ' chip.textContent = FAIL_BADGE;',
789
+ ' titleRow.appendChild(chip);',
704
790
  ' }',
791
+ ' body.appendChild(titleRow);',
792
+ // 详情行两态都有:成功「粘贴给 AI」、失败「长按选择文本手动复制」(都取 `COPY_TEXT_DEFAULTS`,与老仓逐字同)。
793
+ ' var detail = document.createElement("div");',
794
+ ' detail.className = DETAIL_CLASS;',
795
+ ' detail.textContent = bad ? FAIL_DETAIL : OK_DETAIL;',
796
+ ' body.appendChild(detail);',
705
797
  ' box.appendChild(icon);',
706
798
  ' box.appendChild(body);',
707
799
  ' var close = document.createElement("button");',
@@ -756,7 +848,7 @@ export const buildSharedHelpersJs = (input) => {
756
848
  ' /* 卡级复制按钮(#88 R1-1):卡头注入 1 个按钮——actionId/文案恒读冻结常量,',
757
849
  ' 文本取同卡 `<pre class="prompt">` 的**原文**(=该卡 prompt 逐字,与 F3 卡面同源);',
758
850
  ' 读不到 <pre> 才回落同卡 Sheet 内 prompt 按钮的 data-t,两者都无才跳过',
759
- ' (#88 S5/B 段红队 S3-a:真实壳恒有 <pre>,故 436/436 恒有按钮,不静默丢卡)→ 复用既有委派。 */',
851
+ ' (#88 S5/B 段红队 S3-a:真实help模板恒有 <pre>,故 436/436 恒有按钮,不静默丢卡)→ 复用既有委派。 */',
760
852
  ' function injectCardCopy(cards) {',
761
853
  ' for (var i = 0; i < cards.length; i++) {',
762
854
  ' var card = cards[i];',
@@ -831,7 +923,7 @@ export const buildSharedHelpersJs = (input) => {
831
923
  ' }',
832
924
  '',
833
925
  ' /* 复制按钮文本同步:prompt 目标(卡头 + Sheet 内)= 预览文本;params 目标=label: value 行,',
834
- ' 无字段时回落该卡 CLI 文本(与冻结壳 paramsText 同口径)。 */',
926
+ ' 无字段时回落该卡 CLI 文本(与冻结help模板 paramsText 同口径)。 */',
835
927
  ' function syncCopyText(card, text, lines) {',
836
928
  ' var buttons = allOf(card, "[" + ACTION_ATTR + "]");',
837
929
  ' for (var i = 0; i < buttons.length; i++) {',
@@ -1052,15 +1144,88 @@ function normalizeButtons(buttons) {
1052
1144
  };
1053
1145
  });
1054
1146
  }
1147
+ /** 菜单形态的标记属性(#247;运行时委派据它分辨「开合菜单的按钮」与「直接复制的按钮」)。
1148
+ * 该按钮**不写** `data-action-id`:它不是复制目标、也不是复制动作——点击的效果是开合菜单。
1149
+ * `CopyActionHostPort.listActionIds()` 与 `bindCopyAction` 因此天然不碰它(无需为此改冻结签名)。 */
1150
+ const COPY_MENU_OPEN_ATTR = 'data-fmt-open';
1151
+ /** 菜单项的**格式键**承载属性(三格式形态里唯一分辨三项的东西——值取 `COPY_FORMATS`)。
1152
+ * 与老仓逐字同名:老仓 `.fmt-item` 就是靠 `data-fmt` 分辨纯文本/JSON/CSV。 */
1153
+ const COPY_MENU_FMT_ATTR = 'data-fmt';
1154
+ /** 格式菜单容器与按钮的共同父节点类名(运行时据**最近的这个父节点**开合/收起菜单)。 */
1155
+ const COPY_MENU_WRAP_CLASS = 'copy-menu-wrap';
1156
+ /** 菜单容器自身的类名(浮动那一层)。 */
1157
+ const COPY_MENU_CLASS = 'copy-menu';
1158
+ /** 菜单项的类名。 */
1159
+ const COPY_MENU_ITEM_CLASS = 'copy-menu-item';
1160
+ /** 菜单项**标签**文本的类名(`data-fmt` 只放格式键;标签是给人看的中文名,见 `COPY_FORMAT_LABELS`)。 */
1161
+ const COPY_MENU_LABEL_CLASS = 'copy-menu-label';
1162
+ /** 菜单项**可见标签**:格式键 → 中文名(#496)。
1163
+ *
1164
+ * `data-fmt` 是机器面(键值恒为 `COPY_FORMATS` 成员,运行时与测试都按它分辨三项),
1165
+ * **上屏的是这张表的中文名**——原先是把格式键直接印给用户看,菜单点开是 `text` / `json` / `csv`
1166
+ * 三个小写英文词,读不懂(`.scratch/t155o/text-review-P0.md` 第 20/44 条,卡路里 81 页 +
1167
+ * 饼干记账的复制区页同形)。`JSON` 与 `CSV` 是这个仓既有写法里的通用缩写,保留原样。
1168
+ * 运行时「数据复制成功(〈标签〉)」那条提示读回的就是这里的值,故它跟着一起变中文。 */
1169
+ const COPY_FORMAT_LABELS = { text: '纯文本', json: 'JSON', csv: 'CSV' };
1170
+ /** 菜单项**用途提示**(老仓原样:`粘贴给 AI / 自己看`/`结构化存档`/`表格导入`)的类名。
1171
+ * 老仓靠 `.fmt-item span`(后代选择器)收这行灰小字;本仓**显式给类名**——一是不用后代选择器
1172
+ * 把标签也一并染成 11px 灰(`.copy-menu-item span` 会连窗口里两个 span 一起命中),
1173
+ * 二是 CSS 里每个类名都要指得出产出者(`style.test.mjs` T10)。 */
1174
+ const COPY_MENU_HINT_CLASS = 'copy-menu-hint';
1175
+ /** 菜单**开着**时加在容器上的类(`opacity` / `visibility` 的开合开关;`aria-expanded` 同步)。 */
1176
+ const COPY_MENU_OPEN_CLASS = 'copy-menu-open';
1177
+ /** 开合器 `aria-label` 的后半句(**#525 第二轮**:可见文字里的 `▾` 已删,那句「可以选格式」改住这里)。
1178
+ *
1179
+ * 为什么删那个字符:用户裁定第 5 条逐字「该问题是用这些符号简化了 UI 展示的设计」——
1180
+ * `复制数据 ▾` 里的三角是「这里能展开」的纯装饰,用一个字形顶替了一次设计。现在三角由
1181
+ * `style.ts` copyButton 区的 `::after` 用 `border` + `rotate` 画出来(零字符),
1182
+ * 而**语义不留白**:这句写进 `aria-label`,读屏与抓取器仍读得出「这颗按钮要选格式」。 */
1183
+ const COPY_MENU_OPENER_ARIA = '(点开选格式)';
1184
+ /** 选中某个格式后的成功提示词干(老仓 `.fmt-menu` 那张菜单当年报的是「…数据复制成功(格式)」;
1185
+ * 本仓句头沿用既有「已复制」以外的**独立**一句,避免与单格式提示混同)。格式名由运行时从菜单项
1186
+ * 标签读回(即 `COPY_FORMAT_LABELS` 的中文名),不在运行时另立第二张表。 */
1187
+ const COPY_FORMAT_OK_MSG = '数据复制成功';
1188
+ /** 三格式形态的校验 + 归一(#247):与 `text` 互斥;三格式恒齐、值恒为串;`hints` 恒三串。 */
1189
+ function normalizeFormats(formats, field, hasText) {
1190
+ if (formats === undefined || formats === null)
1191
+ return undefined;
1192
+ assertPlainObject(formats, 'renderActionBar: input.' + field + '.formats');
1193
+ if (hasText)
1194
+ badInput('renderActionBar: input.' + field + '.formats 与 .text 只能给一个');
1195
+ const texts = [];
1196
+ for (const key of COPY_FORMATS) {
1197
+ const value = formats[key];
1198
+ if (typeof value !== 'string')
1199
+ badInput('renderActionBar: input.' + field + '.formats.' + key + ' 必须是字符串');
1200
+ texts.push(value);
1201
+ }
1202
+ const rawHints = formats.hints;
1203
+ if (rawHints === undefined || rawHints === null)
1204
+ return { texts, hints: [] };
1205
+ if (!Array.isArray(rawHints) || rawHints.length !== COPY_FORMATS.length) {
1206
+ badInput('renderActionBar: input.' + field + '.formats.hints 必须是 ' + COPY_FORMATS.length + ' 个字符串');
1207
+ }
1208
+ const hints = [];
1209
+ for (const hint of rawHints) {
1210
+ if (typeof hint !== 'string')
1211
+ badInput('renderActionBar: input.' + field + '.formats.hints 必须是字符串');
1212
+ hints.push(hint);
1213
+ }
1214
+ return { texts, hints };
1215
+ }
1055
1216
  function normalizeCopyButton(input, fallbackLabel, field) {
1056
1217
  if (input === undefined || input === null)
1057
1218
  return null;
1058
1219
  assertPlainObject(input, 'renderActionBar: input.' + field);
1059
1220
  assertNoInlineHandler(input, 'renderActionBar: input.' + field);
1221
+ const button = input;
1222
+ const formats = normalizeFormats(button.formats, field, typeof button.text === 'string');
1060
1223
  return {
1061
- label: typeof input.label === 'string' && input.label !== '' ? input.label : fallbackLabel,
1062
- actionId: assertActionId(input.actionId, 'renderActionBar: input.' + field + '.actionId'),
1063
- text: typeof input.text === 'string' ? input.text : undefined,
1224
+ label: typeof button.label === 'string' && button.label !== '' ? button.label : fallbackLabel,
1225
+ actionId: assertActionId(button.actionId, 'renderActionBar: input.' + field + '.actionId'),
1226
+ text: typeof button.text === 'string' ? button.text : undefined,
1227
+ // 三格式形态的按钮是**菜单开合器**,不是复制目标:`data-t` 恒缺(点了不直接复制)。
1228
+ ...(formats === undefined ? {} : { formats }),
1064
1229
  };
1065
1230
  }
1066
1231
  function sceneButtonHtml(button) {
@@ -1069,8 +1234,42 @@ function sceneButtonHtml(button) {
1069
1234
  }
1070
1235
  function copyButtonHtml(button) {
1071
1236
  const textAttr = button.text === undefined ? '' : ' ' + DEFAULT_DATA_ATTR + '="' + esc(button.text) + '"';
1237
+ const disabledAttr = button.disabled === true ? ' disabled' : '';
1072
1238
  return '<button type="button" class="' + STYLE_PREFIX + 'copy-btn ' + STYLE_PREFIX + 'copy-btn-ghost" '
1073
- + ACTION_ID_ATTR + '="' + esc(button.actionId) + '"' + textAttr + '>' + esc(button.label) + '</button>';
1239
+ + ACTION_ID_ATTR + '="' + esc(button.actionId) + '"' + textAttr + disabledAttr + '>' + esc(button.label) + '</button>';
1240
+ }
1241
+ /** 复制数据的**三格式形态**(#247):`<div class="ilife-copy-menu-wrap">` 里一颗开合器 + 一个菜单。
1242
+ *
1243
+ * 按钮:`data-fmt-open` 是**开合标记**(不是 `data-action-id`——见 `COPY_MENU_OPEN_ATTR` 注释),
1244
+ * 带 `aria-haspopup`/`aria-expanded`(`aria-expanded` 由运行时翻转);
1245
+ * 菜单:三个格式项,每项 `data-fmt="键"`(键取 `COPY_FORMATS`)+ 自己的 `data-t`(该格式文本),
1246
+ * 标签与用途提示取 `formats.hints`(对应位)。零内联脚本:开合与选中一律归 helpers 运行时的委派。
1247
+ * 菜单容器**不加 `hidden`**:它是浮层,用 CSS 的 `opacity` 开合(`hidden` 的 `display:none` 会让浏览器
1248
+ * 把 `opacity` 过渡整个跳过,且开合时重排整页——手机档上会看到内容跳一下)。 */
1249
+ function copyMenuHtml(button, formats) {
1250
+ // #525 第二轮:开合器**不再把 `▾` 打上屏**(用户裁定第 5 条「用符号简化了 UI 展示的设计」是债)。
1251
+ // 这颗三角是「这里可以展开」的纯装饰,可见文字里删掉、改由 CSS 画(`style.ts` 的 copyButton 区
1252
+ // `.ilife-copy-menu-wrap > .ilife-copy-btn::after`,`border` + `transform: rotate`,一个字符都不打)。
1253
+ // 删字符不能删语义:`aria-label` 里把那句「可以选格式」写成文字(读屏与无图形环境仍读得出)。
1254
+ const openLabel = button.label;
1255
+ const items = [];
1256
+ for (let i = 0; i < COPY_FORMATS.length; i += 1) {
1257
+ const key = COPY_FORMATS[i];
1258
+ const hint = formats.hints[i];
1259
+ const hintHtml = hint === undefined || hint === '' ? '' : '<span class="' + STYLE_PREFIX + COPY_MENU_HINT_CLASS + '">' + esc(hint) + '</span>';
1260
+ items.push('<button type="button" class="' + STYLE_PREFIX + COPY_MENU_ITEM_CLASS + '" ' + COPY_MENU_FMT_ATTR + '="' + esc(key) + '" '
1261
+ + DEFAULT_DATA_ATTR + '="' + esc(formats.texts[i]) + '">'
1262
+ + '<span class="' + STYLE_PREFIX + COPY_MENU_LABEL_CLASS + '">' + esc(COPY_FORMAT_LABELS[key] ?? key) + '</span>' + hintHtml + '</button>');
1263
+ }
1264
+ const opener = '<button type="button" class="' + STYLE_PREFIX + 'copy-btn ' + STYLE_PREFIX + 'copy-btn-ghost" '
1265
+ + COPY_MENU_OPEN_ATTR + '="1" aria-haspopup="menu" aria-expanded="false" '
1266
+ + 'aria-label="' + esc(openLabel + COPY_MENU_OPENER_ARIA) + '">' + esc(openLabel) + '</button>';
1267
+ return '<div class="' + STYLE_PREFIX + COPY_MENU_WRAP_CLASS + '">' + opener
1268
+ + '<div class="' + STYLE_PREFIX + COPY_MENU_CLASS + '" role="menu">' + items.join('') + '</div></div>';
1269
+ }
1270
+ /** 一颗复制按钮:单格式=普通按钮(`actionId` + 可选 `data-t`);三格式=菜单形态(见 `copyMenuHtml`)。 */
1271
+ function copyControlHtml(button) {
1272
+ return button.formats === undefined ? copyButtonHtml(button) : copyMenuHtml(button, button.formats);
1074
1273
  }
1075
1274
  /** 冻结签名:`renderActionBar(input: ActionBarInput): string`。
1076
1275
  *
@@ -1078,13 +1277,26 @@ function copyButtonHtml(button) {
1078
1277
  * (`ACTION_BAR_DEFAULTS.ghostOwnRow`);复制文本渲染期写入 `DEFAULT_DATA_ATTR`、id 写入 `ACTION_ID_ATTR`,
1079
1278
  * 零内联脚本。缺 `actionId`/空串/同次渲染内重复 → `ControlsError` code `bad-input`。
1080
1279
  * 逐区尺寸(`minHeightPx`/`fontSizePx`/`fontWeight`/`ghostBorderAlpha`/`evenRowPairs`)由 #75 的
1081
- * 共享样式区消费(本文件不产样式常量)。 */
1280
+ * 共享样式区消费(本文件不产样式常量)。
1281
+ *
1282
+ * #336 双按钮一行(base 侧兜底):有数据位(`copyData` 在场)无日志位时自动补一颗禁用态复制日志
1283
+ * (文案/id 沿用 `ACTION_BAR_DEFAULTS.copyLogLabel`/`COPY_ACTION_IDS.actionBar.copyLog`,
1284
+ * 无 `data-t` + `disabled`,只占 ghost 行第二格,点不动、互不串味)。两边都在场/两边都不在场照旧;
1285
+ * 仅日志位在场不补数据位。补位同样参与同次渲染内 id 唯一校验。 */
1082
1286
  export const renderActionBar = (input) => {
1083
1287
  assertPlainObject(input, 'renderActionBar: input');
1084
1288
  const bar = input;
1085
1289
  const buttons = normalizeButtons(bar.buttons);
1086
1290
  const copyData = normalizeCopyButton(bar.copyData, ACTION_BAR_DEFAULTS.copyDataLabel, 'copyData');
1087
- const copyLog = normalizeCopyButton(bar.copyLog, ACTION_BAR_DEFAULTS.copyLogLabel, 'copyLog');
1291
+ let copyLog = normalizeCopyButton(bar.copyLog, ACTION_BAR_DEFAULTS.copyLogLabel, 'copyLog');
1292
+ if (copyData !== null && copyLog === null) {
1293
+ copyLog = {
1294
+ label: ACTION_BAR_DEFAULTS.copyLogLabel,
1295
+ actionId: COPY_ACTION_IDS.actionBar.copyLog,
1296
+ text: undefined,
1297
+ disabled: true,
1298
+ };
1299
+ }
1088
1300
  const ids = new Set();
1089
1301
  for (const button of buttons) {
1090
1302
  if (ids.has(button.actionId))
@@ -1099,7 +1311,7 @@ export const renderActionBar = (input) => {
1099
1311
  ids.add(copy.actionId);
1100
1312
  }
1101
1313
  const sceneHtml = buttons.map(sceneButtonHtml).join('');
1102
- const ghostHtml = [copyData, copyLog].filter((copy) => copy !== null).map(copyButtonHtml).join('');
1314
+ const ghostHtml = [copyData, copyLog].filter((copy) => copy !== null).map(copyControlHtml).join('');
1103
1315
  const rowClass = STYLE_PREFIX + 'action-row';
1104
1316
  const ghostRowClass = rowClass + ' ' + STYLE_PREFIX + 'action-row-ghost';
1105
1317
  const rows = [];