@v587d/capital-generation 2.8.3 → 2.9.0

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 (164) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +40 -19
  3. package/chart-ui/README.md +1 -1
  4. package/chart-ui/client.src.cjs +7 -16
  5. package/chart-ui/index.js +12 -33
  6. package/chart-ui/package.json +2 -2
  7. package/config/client.js +18 -39
  8. package/config/client.src.cjs +79 -217
  9. package/config/index.js +22 -72
  10. package/config/package.json +2 -2
  11. package/cordis.patch.yml +46 -51
  12. package/lib/agents/bash-guard.d.ts +9 -55
  13. package/lib/agents/bash-guard.js +27 -67
  14. package/lib/agents/preset-roster.d.ts +22 -0
  15. package/lib/agents/preset-roster.js +87 -0
  16. package/lib/agents/role-tool-policy.d.ts +46 -0
  17. package/lib/agents/role-tool-policy.js +205 -0
  18. package/lib/agents/root-tool-policy.d.ts +28 -124
  19. package/lib/agents/root-tool-policy.js +164 -237
  20. package/lib/agents/selected-skills-provider.d.ts +2 -6
  21. package/lib/agents/selected-skills-provider.js +27 -84
  22. package/lib/capital-settings.d.ts +27 -0
  23. package/lib/capital-settings.js +23 -0
  24. package/lib/chart/artifact-ref.d.ts +3 -8
  25. package/lib/chart/artifact-ref.js +3 -8
  26. package/lib/chart/downsample.d.ts +2 -5
  27. package/lib/chart/downsample.js +3 -8
  28. package/lib/chart/errors.d.ts +3 -7
  29. package/lib/chart/errors.js +1 -2
  30. package/lib/chart/event-context.d.ts +8 -0
  31. package/lib/chart/event-context.js +39 -0
  32. package/lib/chart/events.d.ts +21 -78
  33. package/lib/chart/events.js +40 -189
  34. package/lib/chart/html.d.ts +2 -5
  35. package/lib/chart/html.js +8 -15
  36. package/lib/chart/runtime.d.ts +5 -18
  37. package/lib/chart/runtime.js +14 -34
  38. package/lib/chart/series.d.ts +10 -17
  39. package/lib/chart/series.js +22 -47
  40. package/lib/chart/source-token.d.ts +5 -13
  41. package/lib/chart/source-token.js +18 -17
  42. package/lib/chart/spec.d.ts +6 -12
  43. package/lib/chart/spec.js +7 -31
  44. package/lib/chart/tool.d.ts +6 -29
  45. package/lib/chart/tool.js +9 -23
  46. package/lib/chart-render-entry.d.ts +9 -0
  47. package/lib/chart-render-entry.js +17 -0
  48. package/lib/chart-source-entry.d.ts +13 -0
  49. package/lib/chart-source-entry.js +20 -0
  50. package/lib/collect-entry.d.ts +9 -0
  51. package/lib/collect-entry.js +10 -0
  52. package/lib/collector-runtime.d.ts +22 -0
  53. package/lib/collector-runtime.js +97 -0
  54. package/lib/data-collector/args.d.ts +6 -24
  55. package/lib/data-collector/args.js +6 -24
  56. package/lib/data-collector/dataset-tools.d.ts +7 -1
  57. package/lib/data-collector/dataset-tools.js +27 -56
  58. package/lib/data-collector/describe.d.ts +8 -33
  59. package/lib/data-collector/describe.js +17 -70
  60. package/lib/data-collector/hub.d.ts +27 -85
  61. package/lib/data-collector/hub.js +19 -85
  62. package/lib/data-collector/query-time.d.ts +7 -26
  63. package/lib/data-collector/query-time.js +8 -27
  64. package/lib/data-collector/store.d.ts +40 -117
  65. package/lib/data-collector/store.js +32 -111
  66. package/lib/data-collector/time-axis.d.ts +16 -76
  67. package/lib/data-collector/time-axis.js +25 -111
  68. package/lib/data-collector/tools.d.ts +6 -6
  69. package/lib/data-collector/tools.js +34 -43
  70. package/lib/dataset-read-entry.d.ts +9 -0
  71. package/lib/dataset-read-entry.js +7 -0
  72. package/lib/index.d.ts +7 -117
  73. package/lib/index.js +43 -422
  74. package/lib/net/eastmoney-client.d.ts +13 -65
  75. package/lib/net/eastmoney-client.js +12 -53
  76. package/lib/net/throttle.d.ts +2 -13
  77. package/lib/net/throttle.js +3 -14
  78. package/lib/ocr/client.d.ts +12 -21
  79. package/lib/ocr/client.js +23 -40
  80. package/lib/ocr/markdown.d.ts +20 -26
  81. package/lib/ocr/markdown.js +23 -36
  82. package/lib/ocr/store.d.ts +14 -47
  83. package/lib/ocr/store.js +15 -45
  84. package/lib/ocr-entry.d.ts +9 -0
  85. package/lib/ocr-entry.js +10 -0
  86. package/lib/policy-entry.d.ts +17 -0
  87. package/lib/policy-entry.js +22 -0
  88. package/lib/preset-claim.d.ts +22 -0
  89. package/lib/preset-claim.js +24 -0
  90. package/lib/preset-ids.d.ts +38 -0
  91. package/lib/preset-ids.js +46 -0
  92. package/lib/retriever-config.d.ts +37 -0
  93. package/lib/retriever-config.js +36 -0
  94. package/lib/retriever-runtime.d.ts +31 -0
  95. package/lib/retriever-runtime.js +82 -0
  96. package/lib/root-extras-entry.d.ts +17 -0
  97. package/lib/root-extras-entry.js +25 -0
  98. package/lib/runtime-entry.d.ts +15 -0
  99. package/lib/runtime-entry.js +5 -0
  100. package/lib/runtime.d.ts +21 -0
  101. package/lib/runtime.js +33 -0
  102. package/lib/search-entry.d.ts +9 -0
  103. package/lib/search-entry.js +9 -0
  104. package/lib/sources/eastmoney-http.js +20 -68
  105. package/lib/sources/ftshare-rest.d.ts +6 -34
  106. package/lib/sources/ftshare-rest.js +84 -292
  107. package/lib/sources/fuyao-core.d.ts +8 -18
  108. package/lib/sources/fuyao-core.js +10 -21
  109. package/lib/sources/fuyao-rest.d.ts +2 -6
  110. package/lib/sources/fuyao-rest.js +23 -102
  111. package/lib/sources/security-code.d.ts +6 -12
  112. package/lib/sources/security-code.js +6 -12
  113. package/lib/sources/security-identity.d.ts +1 -4
  114. package/lib/sources/security-identity.js +3 -8
  115. package/lib/sources/tencent-http.js +14 -28
  116. package/lib/sources/tencent-public-core.d.ts +8 -60
  117. package/lib/sources/tencent-public-core.js +12 -77
  118. package/lib/sources/wind-mcp.d.ts +0 -5
  119. package/lib/sources/wind-mcp.js +23 -78
  120. package/lib/sources-entry.d.ts +9 -0
  121. package/lib/sources-entry.js +9 -0
  122. package/lib/time/tools.d.ts +8 -31
  123. package/lib/time/tools.js +30 -66
  124. package/lib/time-entry.d.ts +9 -0
  125. package/lib/time-entry.js +7 -0
  126. package/lib/tool-exec.d.ts +45 -11
  127. package/lib/tool-exec.js +59 -6
  128. package/lib/user-customization.d.ts +14 -0
  129. package/lib/user-customization.js +47 -0
  130. package/lib/watchlist/tools.d.ts +3 -13
  131. package/lib/watchlist/tools.js +10 -30
  132. package/lib/watchlist-entry.d.ts +9 -0
  133. package/lib/watchlist-entry.js +6 -0
  134. package/lib/web-retriever/engines.d.ts +6 -11
  135. package/lib/web-retriever/engines.js +9 -14
  136. package/lib/web-retriever/html-markdown.d.ts +14 -34
  137. package/lib/web-retriever/html-markdown.js +39 -75
  138. package/lib/web-retriever/local-fetch.d.ts +22 -58
  139. package/lib/web-retriever/local-fetch.js +33 -77
  140. package/lib/web-retriever/retriever.d.ts +6 -18
  141. package/lib/web-retriever/retriever.js +7 -7
  142. package/lib/web-retriever/sources.d.ts +37 -124
  143. package/lib/web-retriever/sources.js +64 -196
  144. package/lib/web-retriever/tools.d.ts +43 -27
  145. package/lib/web-retriever/tools.js +99 -137
  146. package/lib/web-retriever/wind-client.d.ts +14 -31
  147. package/lib/web-retriever/wind-client.js +16 -32
  148. package/locale/zh.json +1 -1
  149. package/package.json +67 -7
  150. package/preset/capital-generation/README.md +87 -53
  151. package/preset/capital-generation/agent.patch.yml +526 -337
  152. package/preset/capital-generation/skills/capital-data-profile/SKILL.md +2 -2
  153. package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +1 -1
  154. package/preset/capital-generation/skills/capital-equity-research/SKILL.md +1 -1
  155. package/preset/capital-generation/skills/capital-event-impact/SKILL.md +1 -1
  156. package/preset/capital-generation/skills/capital-orchestration/SKILL.md +15 -19
  157. package/preset/capital-generation/skills/capital-portfolio-review/SKILL.md +1 -1
  158. package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +9 -12
  159. package/preset/capital-generation/skills/capital-web-evidence/SKILL.md +1 -1
  160. package/watchlist/client.js +15 -148
  161. package/watchlist/client.src.cjs +148 -662
  162. package/watchlist/holding-rules.js +11 -38
  163. package/watchlist/index.js +127 -397
  164. package/watchlist/package.json +2 -2
@@ -1,73 +1,16 @@
1
1
  /**
2
- * `@v587d/watchlist` 的浏览器半边。
3
- *
4
- * 两个 surface,缺一都不成立:
5
- * 1. `ctx.commandUi` 的一条 **contribution**(不是 decoration——我们没有同名宿主命令),
6
- * `available(session)` 按会话的 agent preset 收敛:`ClientSessionContext` 只带
7
- * `sessionId`,preset 要从 `ctx.sessions.list` 的投影里读(官方 `AgentPresetLabel` 同一读法)。
8
- * 2. `conversation.input.overlay` 的一条 list entry(`kind:'list'` / `scope:'session'`),
9
- * 弹窗由官方 `Modal` 以 `headless` 卡片渲染(遮罩 / Esc / 焦点进出是官方的,内部几何是自己的),
10
- * 开合状态住在 `createSnapshotStore` 里经 `hooks` 交给组件——
11
- * `run(session)` 在 React 之外触发,没有 store 就没有通道。
12
- *
13
- * ⛔ 座位是常驻的:这个组件在**每个**会话页都会 mount(bundle 挂 host 平面,与 preset 无关),
14
- * 所以关闭时必须 `return null` 且 mount 期间零 fetch——"只在 Capital 出现"到这里才真正落地。
15
- *
16
- * ⛔ 不占 turn-tail 座位、不自造会话事件(本仓 2026-09-18 事故,判据见
17
- * docs/dev/chart-delivery-events.md §6.3 与 AGENTS.md §7)。
18
- *
19
- * 出网:只由用户动作触发(打开面板一次批量刷新 + 手动「刷新报价」+ 添加完顺手刷一次),
20
- * 无轮询、无自动重试;`/search` 那一路另有三条闸(尾部防抖 / IME 合成期不发 / 同一个词不重发,
21
- * 见 `onQueryChange`)——把"每敲一个键出一次网"压成"每次真正改词才出网"。全组件唯一的定时器
22
- * 就是那个防抖。请求打 host 平面的 `/capital-watchlist/*`(`webServer` 前缀路由不在 typed
23
- * remote 命名空间表里,所以用 `fetch`,与 `chart-ui` 取序列同一写法)。
24
- *
25
- * 行内「更多」菜单用官方 `Menu` primitive(`portal: true`):清单区是滚动容器,就地渲染的浮层
26
- * 会被它裁掉;portal 模式把菜单挂到 `document.body`(官方 `.portal` = `z-index: 1100`,压在
27
- * 模态的 1000 之上),Escape / Tab / 焦点归还 / 点到外面收起全归它自己,且它按官方的模态层
28
- * 契约在 capture 阶段吃掉 Escape——弹窗本体因此不会跟着一起关。我们只提供锚点按钮、
29
- * `items` 只放「置顶」,持仓那一簇(勾选行 / 比例行 / 移除)全在 `children` 里,不自己写浮层几何。
30
- * ⚠️ 菜单是 portal 出去的:它**读不到**本文件挂在 `.capital-watchlist-card` 上的自定义属性
31
- * (自定义属性只随 DOM 树继承,而列表在 `document.body` 下)。菜单里的样式一律走
32
- * `--dsw-alias-*` 与裸类选择器,别指望 `--capital-watchlist-up` 那一对透进去。
33
- *
34
- * 失败显示:**两档颜色、五个落点,位置跟着 cause 走**(2026-09-29 用户点名:整批刷新的红字飘在
35
- * 输入框下方、离它指的按钮隔了一整张表,而同样是"没成"的两个通道一个红一个黄):
36
- * - **黄 = 你这一下动作没成**:搜索失败占下拉的 hint 位(与「搜索中…」「输入完成后自动搜索」
37
- * 「没有匹配的标的」同一个槽,所以它**替换**它们而不是并排多出一条——同一屏不许同时说"没有
38
- * 这个票"和"服务限流");那三句按"这个词到底查过没有"分档:合成期说再等一步、防抖/在途说
39
- * 搜索中、「没有匹配的标的」**只在当前这个词真有一次空回包**时才说(⛔ 没查过 ≠ 查无此票)。
40
- * 添加失败(`list_full` / `ambiguous`)留在搜索区下方。这两条是**两个字段**:`list_full` 与
41
- * 输入无关,改一个字就把它抹掉是错的——但**成功移除一行**要抹掉它(清单腾出了空位,那句话
42
- * 当场不再成立)。
43
- * - **红 = 数据不可信**:整批刷新失败跟在「刷新报价」按钮下面(跟着动词走),逐标的失败只在这一
44
- * 行的价格位标红——且**只有这一行从来没有过值**才标 `—`。取数失败不等于没有价:上一次成功的
45
- * 快照仍在域里(后端 `doRefresh` 只写成功的那几条),面板就把那个值照画,只是降一档色 +
46
- * tooltip 说清它是哪一刻的快照。底部那个时间是整批共用的,不替单行背书。**清单本身读不回来**
47
- * (域不可用 / 登录失效)时红字占空态那一格,绝不渲染成「还没有自选股」;401 / 403 有自己
48
- * 那条文案,不冒充"行情服务暂时不可用"——那等于让用户去等一个根本不会自己恢复的错误。
2
+ * Browser half of `@v587d/watchlist`: a session-scoped overlay entry rendered by
3
+ * the official headless `Modal`, plus a preset-gated `commandUi` contribution.
4
+ * The overlay mounts in every session, so it returns null when closed and never
5
+ * fetches while closed. No turn-tail slot and no custom session events (closed
6
+ * vocabulary, AGENTS.md §7). Only the search debounce is a timer.
49
7
  */
50
8
  const { createElement: h, useEffect, useState } = require('react')
51
9
  const { createSnapshotStore } = require('@deepseek-ai/dsh-client-store')
52
- // 图标只从官方集取(`dsh-client-ui-primitives` 导出的 Icon*Regular / Icon*Medium 那一份,
53
- // 里面没有 Star / Bookmark 类收藏图标——收藏语义靠 IconChecklistOutlineRegular)。
54
- // 不用 primitives 的 Button:面板里的每个动作都有自带几何(圆形关闭、描边刷新、行内省略号),
55
- // 套官方 Button 反而要对抗它的 sm/md 高度与 variant 调色。
56
- // `MenuItemButton` 是官方给"组件行"用的那一颗(与数据行走同一套 markup、同一条键盘走位):
57
- // 「移除」走它,才能带着 `separatorBefore` 的发丝线排在持仓那一簇之后。
58
- // `Switch` 也是官方件(`role="switch"` + `aria-checked`,视觉状态与读屏状态同一个来源):
59
- // 「持仓」那一行的开关就是它,我们不再自己画勾选态。
60
- // 持仓那一行的图标用 Database(一摞圆盘)。这一支不是按名字猜的:把 24 支候选从安装态里连 SVG
61
- // 一起抽出来、排成深色菜单的对照表按眼睛比过——Archive 那只箱子在 14px 下糊成一坨,Plan /
62
- // Checklist 读起来是"单子"不是"仓位",Data 带齿轮太忙,Folder 是"文件";这摞圆盘在小尺寸下
63
- // 像一摞硬币,几何最干净。官方集里**没有**钱包 / 饼图 / 公文包 / 硬币这类金融图标(对着安装态的
64
- // export 表把全部 Icon* 逐名核过),也没有 minus——所以步进键是文本 −/+ 一对,不混用 IconPlus
65
- // 组件与文本减号。⛔ 也不用 `IconCheckCircle*`:那两支圆心里都画着东西,未勾选挂着它就像已经被
66
- // 选中(2026-10-09 实机点名的正是这个错觉)。
10
+ // Official icons only; the set has no wallet/pie/coin glyph, so stepper keys use text −/+.
11
+ // `MenuItemButton` gives the remove row official geometry and its separator line.
67
12
  const { IconChecklistOutlineRegular, IconCloseOutlineRegular, IconDatabaseOutlineRegular, IconEllipsisOutlineRegular, IconGoalOutlineRegular, IconListPenOutlineRegular, IconPinOutlineRegular, IconRefreshOutlineRegular, IconSearchOutlineRegular, IconTrashOutlineRegular, Menu, MenuItemButton, Modal, Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
68
- // 「哪些标的可以是一个仓位」与「占比合计封顶多少」都来自 `watchlist/holding-rules.js`
69
- // (构建期由 esbuild 内联进来):面板画不画那一行、`+` 什么时候置灰,与 `/holding` 收不收这一格
70
- // 不许各说一套。
13
+ // Holdability and ceiling come from the same module the host gate uses.
71
14
  const { isHoldable, weightCeiling } = require('./holding-rules.js')
72
15
 
73
16
  const ENTRY_NAME = 'capital-watchlist'
@@ -76,11 +19,7 @@ const SLOT = 'conversation.input.overlay'
76
19
  const ROUTE = '/capital-watchlist'
77
20
  const PRESET = 'capital-generation'
78
21
  const SEARCH_DEBOUNCE_MS = 300
79
- /**
80
- * 持仓比例的步进幅度(百分点)。用户点名"步长 10、不设默认值":勾选那一刻是"持有、比例还没填",
81
- * 第一次按 `+` 才落 10。⛔ 这是**几何参数**不是文案,所以住在这里而不是字典里。
82
- * 不吸附:域里存着 37.5(旧版自由文本填的)就照它 ±10,不替用户把一个他写过的数掰正。
83
- */
22
+ /** Weight step in percentage points; no snapping (37.5 stays 37.5 ± 10). */
84
23
  const HOLDING_STEP = 10
85
24
  const STYLE_TAG = 'capital-watchlist-css'
86
25
 
@@ -101,69 +40,44 @@ const zh = {
101
40
  colAction: '操作',
102
41
  updated: '更新时间',
103
42
  notRefreshed: '未刷新',
104
- /** 陈旧值的 tooltip:「……限流,请稍后再试 · 上一次快照 09-29 19:04」。 */
43
+ /** Stale tooltip: failure reason · last snapshot time. */
105
44
  stale: '上一次快照',
106
- /**
107
- * 港美股回包带的是**交易所当地时间**(HKT / 美东),与页脚那个"我们几点取的数"是两件事。
108
- * 只写进 tooltip,不做时区换算(换算要用到我们没有的偏移表与夏令时口径)。
109
- */
45
+ /** Exchange-local time, tooltip only; no timezone conversion (no offset/DST table). */
110
46
  exchangeTime: '交易所时间',
111
- /** 有最新价、但没有涨跌(停牌 / 未开盘)时 tooltip 说的那件事。 */
47
+ /** Price exists but change does not (halted / not open). */
112
48
  noChangePct: '暂无涨跌数据',
113
- /**
114
- * 徽标说**市场**(2026-10-07 用户点名:「股票」这一档要拆成 A股 / 港股 / 美股,指数三档跟着拆)。
115
- * 六档一字典,场内 ETF 不单列——它的报价同样是二级市场行情,归 A股那一档("这是 ETF"那一层
116
- * 留在递进输入框那句话里,见下面 `kindStock` 一族)。
117
- */
49
+ /** Badge names the market only; ETF is covered by A-share (same secondary-market quote). */
118
50
  typeAShare: 'A股',
119
51
  typeHkStock: '港股',
120
52
  typeUsStock: '美股',
121
53
  typeAIndex: 'A指',
122
54
  typeHkIndex: '港指',
123
55
  typeUsIndex: '美指',
124
- /**
125
- * ⛔ 这一族**不是徽标**:它只进「预测」「复盘」递进输入框的那句话,说的是**取数端点那一族**。
126
- * 徽标按市场配色之后就不说"股票 / 指数 / ETF"了,而 Agent 恰恰靠这三档选端点
127
- * (A 股股票、A 股指数、场内基金是三个不同上游端点,港美股又各两档)。
128
- * 市场不在这儿重复:中间那段 thscode 自己带(`.SZ` / `.HK` / `.US`)。
129
- */
56
+ /** Prompt-only wording: the Agent picks an endpoint from stock / index / ETF; market comes from thscode. */
130
57
  kindStock: '股票',
131
58
  kindIndex: '指数',
132
59
  kindEtf: 'ETF',
133
60
  remove: '移除',
134
61
  more: '更多',
135
62
  pin: '置顶',
136
- /**
137
- * 持仓标记这一族(2026-10-09 起,主 Agent 经 `get_watchlist` 读的就是它)。
138
- * ⚠️ 文案说的始终是**用户自报**的标记,不是券商账上的事实。
139
- *
140
- * 这一族只有五行:行标签与开关的可达名共用 `holdingName`(「持仓」两个字,名词——
141
- * 状态由那颗开关说,不需要"标记 / 取消标记"两个动词各写一份),其余四行是比例那一簇。
142
- * 比例读数里那句 `0~85` 不在这里:它是**数字**(这一格的剩余额度,随其余持仓变),
143
- * 不是措辞,所以在代码里拼。旧的 `holdingMenu`(标记持仓)与 `holdingUnset`(未填)都删了
144
- * ——用户点名"改成持仓两个字"与"未填不用显示,用 0~100 这种 placeholder 提示"。
145
- */
63
+ /** User-reported holding, never broker fact; `0~N` headroom is computed, not stored in the dictionary. */
146
64
  holdingWeightLabel: '持仓比例',
147
65
  holdingStepper: '持仓比例(每档 10 个百分点)',
148
66
  holdingStepUp: '增加持仓比例',
149
67
  holdingStepDown: '减少持仓比例',
150
- /** 行标签、开关的 aria-label,以及名称标红之外那句非颜色通道(红名与"红涨"同色)共用这一个词。 */
68
+ /** Shared by row label, switch aria-label and the non-color channel (red name = red-up color). */
151
69
  holdingName: '持仓',
152
70
  predict: '预测',
153
71
  predictTip: 'AI预测',
154
72
  review: '复盘',
155
73
  reviewTip: 'AI复盘',
156
- /**
157
- * ⚠️「预测」与「复盘」写进输入框的那句话**只改这四行**(zh / en 各两处,两套语言必须同时改)。
158
- * 中间的 `名称 (thscode · 类型)` 是结构不是措辞,不住在这里:Agent 靠 thscode 认标的、
159
- * 靠类型选端点(股票 / 指数 / ETF 是三个不同的上游端点),少一段就多半一轮取数或一轮反问。
160
- */
74
+ /** Prompt shape is `name (thscode · kind)`; thscode and kind are data, not copy. */
161
75
  predictPromptPrefix: '请预测 ',
162
76
  predictPromptSuffix: ':未来 5 个交易日的走势与关键点位,说明依据,并给出判断的不确定性。',
163
77
  reviewPromptPrefix: '请复盘 ',
164
78
  reviewPromptSuffix: ':今日走势与量价、近 20 日趋势、基础面、消息面,给出结论和必要风险评估。',
165
79
  searching: '搜索中…',
166
- /** 拼音合成期下拉里那句:它要说"再等一步",不能把"还没查"说成"查了,没有"。 */
80
+ /** IME composition: say "wait", never "already checked, no match". */
167
81
  composingHint: '输入完成后自动搜索',
168
82
  clearInput: '清空输入',
169
83
  confirmTitle: '确认移除?',
@@ -171,18 +85,9 @@ const zh = {
171
85
  confirmDescSuffix: ' 从自选中移除吗?',
172
86
  confirmCancel: '取消',
173
87
  confirmOk: '确认移除',
174
- /**
175
- * `truncated`:候选已经**一行不落地全画出来了**(服务端不切片),所以这句里没有"候选不止这些"
176
- * ——那半句说的是"还有没显示的",此刻是假话。2026-10-08 用户改写成"没有更多":它讲的是
177
- * **上游那一路回满了自己那一档**(A 股 50 / 港 10 / 美 10),而它自己就住在滚动区末尾,
178
- * 滚到底才看得见(不做成钉在底部的常驻条)。也**故意不写 N**:数字的上游是宿主常量,
179
- * 抄进两套字典就迟早只改红一边(`error.list_full` 那条教训)。
180
- */
88
+ /** "No more" means a branch filled its own cap; lives at scroll end and never names N. */
181
89
  truncated: '没有更多,请输入准确的证券代码或名称。',
182
- /**
183
- * 候选下拉的筛选头(全部 / CN / HK / US,默认全部):**只在客户端筛已经拿全的候选**,
184
- * 不重新出网、也不改变上游入参(`asset_type` 白名单那条纪律讲的是入参收窄,不是这里的显示筛选)。
185
- */
90
+ /** Client-side filter over fully fetched candidates: no refetch, no upstream param change. */
186
91
  filterAll: '全部',
187
92
  filterLabel: '按市场筛选候选',
188
93
  filterNoHit: '这个市场下没有匹配的标的。',
@@ -190,15 +95,10 @@ const zh = {
190
95
  'error.invalid_query': '输入不被接受',
191
96
  'error.not_found': '没有找到该标的',
192
97
  'error.ambiguous': '命中多条,请从候选里选一条',
193
- /**
194
- * `{max}` 由宿主回包里的 `limit` 填(`list_full` 那条 409 一直带着它):上限是服务端的一个常量,
195
- * 2.5.2 把它在中英文字典里各抄了一遍数字,抬上限时只会改红一边——这里起只留一个真值源。
196
- * 万一拿到的是没有 `limit` 的旧回包,退到 `error.list_full_noMax` 那句不带数字的话,
197
- * 而不是把 `{max}` 原样印在面板上。
198
- */
98
+ /** `{max}` comes from the host's `limit`; fall back to the no-number sentence. */
199
99
  'error.list_full': '自选股最多 {max} 条,先删一条再加',
200
100
  'error.list_full_noMax': '自选股已达上限,先删一条再加',
201
- 'error.credential_missing': '未配置 Fuyao 密钥:在「插件」页的 Capital 模式卡片里填 API Key(只影响 A 股那一路,港美股照常用)',
101
+ 'error.credential_missing': '未配置 Fuyao 密钥:在「插件」页的 自动模式卡片里填 API Key(只影响 A 股那一路,港美股照常用)',
202
102
  'error.rate_limited': '行情服务限流,请稍后再试',
203
103
  'error.fuyao_unavailable': 'A 股行情暂时取不到,请稍后再试',
204
104
  'error.tencent_unavailable': '港美股行情暂时取不到,请稍后再试',
@@ -207,10 +107,7 @@ const zh = {
207
107
  'error.store_unavailable': '自选股存储不可用',
208
108
  'error.unauthorized': '登录状态已失效:请先回到宿主窗口重新登录',
209
109
  errorSuffix: '(其他标的仍是上一次成功快照)',
210
- /**
211
- * 某一路检索挂了:候选照常给其余路,这一句说**挂掉的那一路**(R12 / 设计文档 §5.4)。
212
- * 值自带冒号,中英文各按各的标点;接的是同一份 `error.*` 文案,不另起一套说法。
213
- */
110
+ /** One branch failed; other branches still return candidates. */
214
111
  'partial.a-share': 'A 股检索不可用:',
215
112
  'partial.hk': '港股检索不可用:',
216
113
  'partial.us': '美股检索不可用:',
@@ -248,14 +145,13 @@ const en = {
248
145
  remove: 'Remove',
249
146
  more: 'More',
250
147
  pin: 'Pin to top',
251
- // 持仓那一族:五个 key,理由与"行标签与开关共用同一个名词"都写在 zh 那一处(两套语言必须同时改)。
148
+ // Holding family: five keys; row label and switch share one singular noun.
252
149
  holdingWeightLabel: 'Holding weight',
253
150
  holdingStepper: 'Holding weight, in steps of 10 percentage points',
254
151
  holdingStepUp: 'Increase holding weight',
255
152
  holdingStepDown: 'Decrease holding weight',
256
153
  holdingName: 'Holding',
257
- /** ⚠️ 这两条标签受**格宽预算**约束:动作列是定值 52px(见 CSS 里 grid 那一处),
258
- * 12px/500 下八个字符就顶到边。要改名先量长度,别让它溢出描边。 */
154
+ /** Predict / Review labels are constrained by the fixed 52px action column. */
259
155
  predict: 'Predict',
260
156
  predictTip: 'AI prediction',
261
157
  review: 'Review',
@@ -297,14 +193,8 @@ const en = {
297
193
  }
298
194
 
299
195
  /**
300
- * `asset_type` → 类型徽标文案的**字典键**。文案本身住两套字典里(2.5.2 是在这里写死中文,
301
- * 英文界面于是画着「股票 / 指数」,还绕过了"两套语言 key 必须齐平"那道闸门)。
302
- *
303
- * 六档只说**市场**(2026-10-07 用户点名:「股票」拆成 A股 / 港股 / 美股,指数跟着拆三档,
304
- * 三市三种底色)。两件事因此从徽标上退场,各自另有落点:
305
- * ① "这是 ETF / 这是股票还是指数"进递进输入框那句话(`KIND_LABEL_KEYS`,Agent 按它选端点);
306
- * ② 港股 ETF 在检索阶段本来就分不出与个股的差别(实测 `03037` 搜索给 `GP`、快照给 `GP-FUND`),
307
- * 所以 `港股` 那一档同时盖住两者是**如实**,不是合并出了错。
196
+ * `asset_type` → badge copy key. Six market-only badges: product type lives in the
197
+ * prompt wording, and HK ETFs are indistinguishable from stocks at search time.
308
198
  */
309
199
  const TYPE_LABEL_KEYS = {
310
200
  'a-share': 'typeAShare',
@@ -313,10 +203,10 @@ const TYPE_LABEL_KEYS = {
313
203
  'a-share-index': 'typeAIndex',
314
204
  'hk-index': 'typeHkIndex',
315
205
  'us-index': 'typeUsIndex',
316
- // 场内 ETF 归 A股那一档:徽标说市场,而它交易的就是 A 股二级市场的价格。
206
+ // ETF trades on the A-share secondary market, so it shares that badge.
317
207
  'fund-etf': 'typeAShare',
318
208
  }
319
- /** 递进输入框那句话里的"这一族走哪个端点",与徽标各管各的(见字典里 `kindStock` 一族的注释)。 */
209
+ /** Endpoint family for prompts, separate from the market badge. */
320
210
  const KIND_LABEL_KEYS = {
321
211
  'a-share': 'kindStock',
322
212
  'hk-stock': 'kindStock',
@@ -326,7 +216,7 @@ const KIND_LABEL_KEYS = {
326
216
  'us-index': 'kindIndex',
327
217
  'fund-etf': 'kindEtf',
328
218
  }
329
- /** 未知类型(上游哪天多一档)照原样画出来,比硬套一个错的中文标签诚实。 */
219
+ /** Unknown types print verbatim rather than a wrong label. */
330
220
  function typeLabel(assetType, t) {
331
221
  const key = TYPE_LABEL_KEYS[assetType]
332
222
  return key === undefined ? String(assetType ?? '') : t(key)
@@ -337,21 +227,13 @@ function kindLabel(assetType, t) {
337
227
  }
338
228
 
339
229
  /**
340
- * 面板**不画币种**(2026-10-07 用户两次点名:先不要 ISO 码、再不要 `¥` / `$`——"正常人都 get
341
- * 货币单元的问题")。落点只有这一处:`quote.currency` 照旧取、照旧入库、照旧过 schema,
342
- * 只是不进 DOM。要恢复显示,只需要在这一处重新读它,上游与宿主都不必动。
343
- * ⚠️ 已知代价(记录在 `docs/design/watchlist-hk-us.md` R9 与 P10):`80700` 这类 `-R` 柜台是
344
- * **港币市场、人民币计价**,同一屏里它与 `00700`(HKD) 现在长得一样。用户按市场读币种,
345
- * 在这一类标的上会读错——这是这一版接受了的取舍,不是没看见。
230
+ * Currency is stored but not rendered; known cost: `-R` counters are HKD-market,
231
+ * CNY-denominated and look identical to HKD quotes.
346
232
  */
347
233
 
348
234
  /**
349
- * 上游(同花顺搜索结果)的标的名在进面板、进 tooltip、进输入框之前,先剥掉不可见与双向控制字符。
350
- * 这是本仓对外部内容共用的出口口径(host 侧同一份字符类在 `src/web-retriever/html-markdown.ts`
351
- * 的 `INVISIBLE_PATTERN`):零宽与方向覆盖符肉眼看不见,却能把一句话的**显示顺序**改成与 Agent
352
- * 实际收到的不一样——用户按发送时读到的不是他核对过的那句。
353
- * 两份实现隔着构建边界(浏览器半边不能 import 插件侧 TS),所以由 test/watchlist-client.test.mjs
354
- * 逐字比对两边的字符类:谁改了、另一边没跟上,测试就红。
235
+ * Strip zero-width and bidi controls before display; the host uses the same class
236
+ * (`INVISIBLE_PATTERN`), and tests compare both sides verbatim.
355
237
  */
356
238
  const INVISIBLE_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u00AD\u200B-\u200F\u2028\u2029\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/gu
357
239
 
@@ -359,15 +241,11 @@ function scrubInvisible(value) {
359
241
  return String(value ?? '').replace(INVISIBLE_CHARS, '')
360
242
  }
361
243
 
362
- /** 宿主回包里的每一行都过这一道:显示、tooltip、aria-label 与递进输入框的那句话因此是同一个名字。 */
244
+ /** Host rows are scrubbed once so display, tooltip, aria-label and prompt share one name. */
363
245
  function cleanRows(rows) {
364
246
  return Array.isArray(rows) ? rows.map((row) => (row === null || typeof row !== 'object' ? row : { ...row, name: scrubInvisible(row.name) })) : rows
365
247
  }
366
- /**
367
- * 类型徽标的配色:**一色只说一件事 = 市场**(2026-10-07 用户点名:A 股 / 港股 / 美股三种底色;
368
- * 此前 A 股与港股共用同一支琥珀色,三市在色上分不开)。同一市场里股票与指数同色,
369
- * 品种那一层只由文字给(`A股` / `A指`)——颜色不再同时表达市场与品种两件事。
370
- */const TYPE_TONES = {
248
+ /** Color encodes market only; product type is carried by the label. */const TYPE_TONES = {
371
249
  'a-share': 'capital-watchlist-tag-cn',
372
250
  'a-share-index': 'capital-watchlist-tag-cn',
373
251
  'fund-etf': 'capital-watchlist-tag-cn',
@@ -377,305 +255,136 @@ function cleanRows(rows) {
377
255
  'us-index': 'capital-watchlist-tag-us',
378
256
  }
379
257
 
380
- /**
381
- * 候选筛选头的四档(2026-10-08 用户点名:**默认「全部」**,三市都摆出来让用户自己筛)。
382
- * 只在客户端筛**已经拿全的候选**:A 股 50 + 港 10 + 美 10 一次回包,切换不重新出网,
383
- * 也不碰上游入参(`asset_type` 白名单那条纪律讲的是入参收窄,与这里的显示筛选是两件事)。
384
- * `marketIdOf` 是这一层唯一的判据:候选的 `exchange` 存的是**市场**(`SH`/`SZ`/`HK`/`US`,
385
- * 美股的具体交易所留在 canonical 代码里),所以 A 股那一档由"不是 HK 也不是 US"给出。
386
- */
258
+ /** Client-side filter over fully fetched candidates; `exchange` stores the market. */
387
259
  const MARKET_FILTERS = [{ id: 'all' }, { id: 'cn', label: 'CN' }, { id: 'hk', label: 'HK' }, { id: 'us', label: 'US' }]
388
260
  const marketIdOf = (candidate) => (candidate.exchange === 'HK' ? 'hk' : candidate.exchange === 'US' ? 'us' : 'cn')
389
261
 
390
262
  /**
391
- * 面板几何。
392
- *
393
- * 官方 `Modal` 的 `.dialog` / `.header` / `.body` 是 CSS Module 的**散列类**(本机
394
- * `dsh-web-frontend` 产物里是 `_body_17vb8_5` 这种),插件 CSS 够不着它们——所以弹窗用
395
- * `headless: true`(官方 props 之一:卡片、遮罩、Esc、aria-label 都还在,只去掉默认
396
- * header/body/footer 骨架),几何全部由下面这些自有类承担。
397
- * 唯一还需要压官方的是**卡片本身**:`.capital-watchlist-dialog` 与官方类同落在 dialog 元素上,
398
- * 双类名选择器(重复一次类名)把 specificity 抬到 0-2-0 才赢得过 `.dialog` 的默认宽/内边距/圆角。
399
- *
400
- * 颜色一律取宿主 design token,而且**按角色取**、不按"看着深浅"取:官方规则(`docs/web-styling`)
401
- * 是功能 CSS 不得写主题选择器、不得写颜色字面量,明暗两套值由 token 自己翻。浅色踩坑的三条:
402
- * 1. `--dsw-alias-label-dimmed` 在浅色解析成 `#e1e5ee`(深色是 `#43454a`)——压在白卡片上等于隐形,
403
- * 所以**凡是承载信息的文字**都走 `label-secondary` / `label-tertiary`,`dimmed` 只留给
404
- * "看不见也无所谓"的东西(我们的面板里它一处都不剩)。
405
- * 2. `bg-layer-1/2/3` 在浅色**同为纯白**:控件底面要反差就用 `bg-module-platform`
406
- * (浅 `#f5f6f7` / 深 `#353638`,官方 `.selector`、`.badge` 都是这一条)。浮层用
407
- * `--dsw-specific-menu` + `--dsw-elevation-prominent`——官方口径是高层级表面 `border: 0`、
408
- * 靠 0.5px 描边投影分离,**不得**把 `border-l*` 与 elevation 投影配对。
409
- * ⚠️ `--dsw-specific-menu` 是**半透明**菜单材质(浅 `#f8f9fa94` = 58%、深 `#43454a73` = 45%,
410
- * 只有 macOS 才近不透明),官方规则要求画它的表面**必须**同时声明
411
- * `backdrop-filter: var(--dsw-menu-backdrop-filter)`(`blur(40px) saturate(150%)`):
412
- * 只拿填充不拿模糊,浮层底下的清单行会直接透视上来(`MenuSurface .material`、会话侧
413
- * `.media` 滚动面板都是这一对)。深色那档描边不用我们分支——宿主有
414
- * `body[data-ds-dark-theme] [data-menu-material] { …border-l3 }`,节点挂上
415
- * `data-menu-material="translucent"`(官方 `MenuSurface` 的挂法)就自动翻,功能 CSS
416
- * 写主题选择器是违规的。
417
- * 3. `interactive-bg-hover` 在浅色只有 6% 不透明度,画不出"常态灰底",只配当 hover;
418
- * 常态灰底用 `bg-module-platform`,hover 加深用 `interactive-bg-hover-accent`。
419
- * 只有 A 股口径的**红涨绿跌**两个色是字面量——涨跌语义不在宿主调色板里,宿主也没逐元素声明
420
- * `color-scheme`(`light-dark()` 用不了),所以浅深两套共用一对值:#d1493f / #17a063 在
421
- * 「浅卡 / 深卡」四种组合里最差的一档是 3.14:1(涨 on 深卡),已是最优折中。
422
- * 这一对写在 `.capital-watchlist-card` 的两个自定义属性上、由规则去 `var()` 它们:
423
- * "持仓的那一行名称标红"用的就是**同一个红**(用户点名),而字面量闸门只许这一对存在一次。
424
- * ⛔ 也因此这两个变量只覆盖卡片子树——portal 出去的菜单读不到(见文件头那条)。
263
+ * Official Modal classes are CSS-module hashes, so the dialog uses `headless: true`
264
+ * and our own classes; repeated class names raise specificity over `.dialog`.
265
+ * Colors use host tokens by role; menu surfaces pair `--dsw-specific-menu` with its
266
+ * backdrop filter. The red/green pair is the only literal color.
425
267
  */
426
268
  const CSS = `
427
- /* 宽 420 / 圆角 18 / 无内边距(内边距住在各段自己头上,好让表头与分隔线对齐)。
428
- 限高按官方口径走 max-height: 100%(对 .root 的 padding box),不自己算 vh。 */
429
269
  .capital-watchlist-dialog.capital-watchlist-dialog { width: min(420px, 92vw); max-height: 100%; padding: 0; gap: 0; border-radius: 18px; }
430
- /* 底色与投影归官方 .dialog;滚动条按官方契约只在容器上重绑 l2 那一对,不自己写 scrollbar-* 选择器。 */
431
270
  .capital-watchlist-card { position: relative; display: flex; flex-direction: column; min-height: 0; overflow: hidden; border-radius: 18px; --capital-watchlist-up: #d1493f; --capital-watchlist-down: #17a063; --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); }
432
271
  .capital-watchlist-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 18px 20px 14px; }
433
272
  .capital-watchlist-title { margin: 0; font-size: 17px; line-height: 24px; font-weight: 600; letter-spacing: .3px; color: var(--dsw-alias-label-primary); }
434
- /* 圆形灰底键:灰底 bg-module-platform + glyph label-secondary,两套主题都拉得开;正圆须配 corner-shape: round。 */
435
273
  .capital-watchlist-close { flex: none; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 50%; corner-shape: round; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary); cursor: pointer; }
436
274
  .capital-watchlist-close:hover { background: var(--dsw-alias-interactive-bg-hover-accent); color: var(--dsw-alias-label-primary); }
437
- /* 搜索段:position:relative 是下拉的包含块(下拉 top: calc(100% - 8px) 贴着输入框下沿)。 */
438
275
  .capital-watchlist-search { position: relative; padding: 0 20px 16px; }
439
276
  .capital-watchlist-field { position: relative; display: flex; align-items: center; height: 42px; padding: 0 14px 0 40px; border: none; border-radius: 12px; background: var(--dsw-alias-bg-module-platform); transition: box-shadow .2s var(--ds-ease-in-out); }
440
- /* 聚焦用 inset 投影而不是 border(官方 .selector 这类控件就没有 border,加了会顶出 1px 抖动);
441
- 颜色 brand-primary 浅=近黑、深=近白,与官方 Input 的 focus-within 同一支笔。 */
442
277
  .capital-watchlist-field:focus-within { box-shadow: inset 0 0 0 1.5px var(--dsw-alias-brand-primary); }
443
278
  .capital-watchlist-searchicon { position: absolute; left: 14px; display: inline-flex; color: var(--dsw-alias-label-tertiary); pointer-events: none; }
444
279
  .capital-watchlist-input { flex: 1 1 auto; min-width: 0; height: 100%; padding: 0; border: none; outline: none; background: transparent; color: var(--dsw-alias-label-primary); font-family: inherit; font-size: 14px; line-height: 22px; }
445
- /* 占位符比官方 Input 提一档到 tertiary:dimmed / caption 压在浅底只有 1.26 / 1.97:1,
446
- 而这一行是面板里唯一告诉用户"这里能输入什么"的地方。 */
447
280
  .capital-watchlist-input::placeholder { color: var(--dsw-alias-label-tertiary); }
448
- /* 清空键:与关闭键同一支笔,按 42px 的框缩一档。底面不能用 bg-module-platform——它正坐在
449
- 同色的输入框底上会糊成一片,所以走 interactive-bg-hover,hover 再深一档到 hover-accent。 */
450
281
  .capital-watchlist-clear { flex: none; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 50%; corner-shape: round; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); cursor: pointer; }
451
282
  .capital-watchlist-clear:hover { background: var(--dsw-alias-interactive-bg-hover-accent); color: var(--dsw-alias-label-primary); }
452
- /* 下拉候选:官方半透明菜单材质。--dsw-specific-menu 浅 58% / 深 45%,**必须**与
453
- --dsw-menu-backdrop-filter(blur40 saturate150)成对声明——只拿填充不拿模糊,底下的清单行会
454
- 直接透视上来(官方滚动浮层 .media 面板与 MenuSurface .material 都是这一对)。border: 0 +
455
- elevation-prominent 分离;描边浅色重绑 l1,深色由宿主 [data-menu-material] 规则翻成 l3。 */
456
- /* 候选下拉是**卡片内**的浮层,而卡片 overflow: hidden(圆角与清单滚动都靠它):浮层一旦比
457
- 卡片剩余的空间高,多出来的部分就被无声裁掉。所以它的 max-height 必须与下面
458
- capital-watchlist-list-open 那格的 min-height **同值**(下拉正好铺满清单那一格:够高,又压不到
459
- 页脚)——246px → **320px**(2026-10-08 用户点名"一屏只能显示 3 个标的":行高压到约 47px、
460
- 浮层抬到 320,一屏 5~6 行;这两个数一改必须一起改,测试里有同值断言)。
461
- 这一层自己不滚:里面 .capital-watchlist-options 才是滚动区——筛选头钉在顶上("锁定这个头"),
462
- "没有更多"那句住在**滚动区末尾**(滚到底才看得见,不冻结在底部),其余 hint 贴在选项之后。
463
- 候选池是 50+10+10=70 行(三路上游硬顶之和,服务端不切片)。 */
283
+ /* max-height must equal the .capital-watchlist-list-open min-height (test pins both). */
464
284
  .capital-watchlist-dropdown { position: absolute; top: calc(100% - 8px); left: 20px; right: 20px; z-index: 20; display: flex; flex-direction: column; max-height: 320px; overflow: hidden; background: var(--dsw-specific-menu); backdrop-filter: var(--dsw-menu-backdrop-filter); border: 0; border-radius: 12px; --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); animation: capital-watchlist-drop .18s var(--ds-ease-in-out); }
465
- /* 筛选头与那几句"此刻怎么了"的 hint 都是 flex: none,只有选项层 flex: 0 1 auto 可收缩、可滚:
466
- 头永远在顶,hint 在选项之后(内容短时紧贴最后一行,内容长时也不会被挤没)。 */
467
285
  .capital-watchlist-filter { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-bottom: .5px solid var(--dsw-alias-border-l2); }
468
286
  .capital-watchlist-options { flex: 0 1 auto; min-height: 0; overflow-y: auto; }
469
287
  .capital-watchlist-filterbtn { display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px; border: none; border-radius: 999px; corner-shape: round; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); font-family: inherit; font-size: 12px; line-height: 16px; cursor: pointer; }
470
288
  .capital-watchlist-filterbtn:hover { background: var(--dsw-alias-interactive-bg-hover-accent); color: var(--dsw-alias-label-primary); }
471
- /* 选中态读 aria-pressed:状态住在属性里,样式只是它的投影(读屏也听得见"已选中")。
472
- 压在半透明菜单材质上用 color-mix 的透明淡染——它不写背景实底,底下的菜单模糊照常透上来。 */
473
289
  .capital-watchlist-filterbtn[aria-pressed="true"] { background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent); color: var(--dsw-alias-label-primary); }
474
290
  .capital-watchlist-filtercount { font-size: 11px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
475
291
  @keyframes capital-watchlist-drop { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
476
292
  @keyframes capital-watchlist-breathe { 0% { opacity: 1; } 40% { opacity: .6; } 80%, 100% { opacity: 1; } }
477
293
  @media (prefers-reduced-motion: reduce) { .capital-watchlist-dropdown { animation: none; } .capital-watchlist-skeleton { animation: none; } }
478
- /* 候选行高度(2026-10-08 用户点名"减少每个标的展示高度"):padding 6 + 名称 18 + 间隙 1 + 代码行 15
479
- ≈ 47px。与上面那条"浮层 320px"一起算:(320 − 筛选头约 32) ÷ 47 ≈ 6 行一屏(改前是 3 行)。
480
- 两个数是一对——只抬浮层不压行高,多出来的空间会被行高吃掉;只压行高不抬浮层,一屏还是 3 行。 */
481
294
  .capital-watchlist-option { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 12px; border: none; border-bottom: .5px solid var(--dsw-alias-border-l2); background: transparent; color: inherit; font-family: inherit; text-align: left; cursor: pointer; }
482
295
  .capital-watchlist-option:last-of-type { border-bottom: none; }
483
- /* 「已添加」与"在途"这一档读的是 **aria-disabled 而不是 :disabled**(真 disabled 会把焦点踢到
484
- body,搜索段的 onBlur 就判"点到框外"收起下拉 —— 见渲染处那段),所以视觉降档也跟着读它:
485
- 整行 opacity .45、指针 not-allowed,焦点照旧留在这一行。 */
296
+ /* Reads aria-disabled, not :disabled: real disabled would drop focus and close the dropdown. */
486
297
  .capital-watchlist-option:hover:not([aria-disabled="true"]) { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); }
487
298
  .capital-watchlist-option[aria-disabled="true"] { opacity: .45; cursor: not-allowed; }
488
- /* 键盘焦点走官方表达式:环色由宿主按主题与输入模态管好(指针模态自动透明),不自己造环。 */
489
299
  .capital-watchlist-close:focus-visible, .capital-watchlist-clear:focus-visible, .capital-watchlist-option:focus-visible, .capital-watchlist-filterbtn:focus-visible, .capital-watchlist-more:focus-visible, .capital-watchlist-predict:focus-visible, .capital-watchlist-review:focus-visible, .capital-watchlist-refresh:focus-visible, .capital-watchlist-confirmbtn:focus-visible, .capital-watchlist-stepperbtn:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
490
300
  .capital-watchlist-optionmain { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
491
301
  .capital-watchlist-optionname { font-size: 14px; line-height: 18px; font-weight: 500; color: var(--dsw-alias-label-primary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
492
302
  .capital-watchlist-optioncode { font-size: 12px; line-height: 15px; color: var(--dsw-alias-label-tertiary); }
493
- /* 右侧那颗「添加」是**整行这个 button 的可见动词**,不是第二个可聚焦控件(button 里套 button
494
- 是非法 HTML):所以它只是一颗胶囊——常态灰底、hover 跟着整行加深;「已添加」那档由整行 disabled
495
- 的 opacity 统一降下去,不再往代码行里塞「· 已添加」(代码 + 徽标才是第二行要读的两样)。 */
496
- /* 右侧那颗「添加」是**整行这个 button 的可见动词**,不是第二个可聚焦控件(button 里套 button
497
- 是非法 HTML)。两态(2026-10-08 第三轮用户点名):
498
- - **添加 = 醒目**:沿用「刷新报价」那颗 verb 按钮的配方(state 色 12% 淡底 + 25% 描边 + 同色字),
499
- 整行里唯一带色的元素就是它,一眼知道点哪儿;
500
- - **已添加 = 暗**:透明底 + l1 描边 + tertiary 字(data-state="added"),跟整行的 aria-disabled
501
- 一起降下去,并且点了也不再出网(guard 在渲染处)。 */
502
303
  .capital-watchlist-addbtn { flex: none; padding: 3px 9px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary) 25%, transparent); border-radius: 999px; corner-shape: round; background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent); color: var(--dsw-alias-state-business-primary); font-size: 12px; line-height: 15px; font-weight: 500; }
503
304
  .capital-watchlist-option:hover:not([aria-disabled="true"]) .capital-watchlist-addbtn { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 20%, transparent); }
504
305
  .capital-watchlist-addbtn[data-state="added"] { border-color: var(--dsw-alias-border-l1); background: transparent; color: var(--dsw-alias-label-tertiary); font-weight: 400; }
505
306
  .capital-watchlist-tag { flex: none; display: inline-block; padding: 2px 7px; border-radius: 6px; font-size: 11px; line-height: 14px; font-weight: 500; }
506
- /* 类型徽标 = 官方状态徽标那对写法:底色用 state-*-tertiary(主题包为"状态色的底"准备的实心档,
507
- 自己 color-mix 出来的透明档压在白卡片上等于没混),文字用同色系 primary;
508
- amber 例外走 warn-label——warn-primary 压白底只有 2:1,官方"状态色当文字"另有 label 一档。 */
509
- /* 三档徽标按**市场**配色,取的是"钞票的主色"这把直觉:人民币=红、港币=金琥珀、美元=绿
510
- (2026-10-07 用户点名,并明确绿那一档"和美元很像")。股票与指数同市同色,品种只由文字分。
511
- ⚠️ 红档与"红涨"那颗红是两种东西:徽标是整行固定的底,价格只在涨跌时上色。
512
- state-*-tertiary 是主题包给状态色准备的**实心底**;error 那一族的 tertiary 本仓从未用过、
513
- 无法在此确认存在,于是给一条 color-mix 兜底——猜错 token 的症状是"底色没了",不会报错。
514
- ⚠️ 这段 CSS 住在模板字符串里:注释里不许出现反引号,出现就把整段样式截断(本轮踩过)。 */
307
+ /* Badge uses a solid tertiary background; state color as text uses the label tier. */
515
308
  .capital-watchlist-tag-cn { color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-state-error-tertiary, color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)); }
516
309
  .capital-watchlist-tag-hk { color: var(--dsw-alias-state-warn-label); background: var(--dsw-alias-state-warn-tertiary); }
517
310
  .capital-watchlist-tag-us { color: var(--dsw-alias-state-success-primary); background: var(--dsw-alias-state-success-tertiary); }
518
- /* 表头与数据行共用同一个 grid 定义:宽度只在这一处,两处各自排版就会串行(实机踩过)。
519
- 三列:名称(第二行是「代码 · 徽标」)/ 报价 / 「操作」那一簇(「预测」「复盘」「更多」三枚)。
520
- 92px 与 84px 必须是**定值**:表头与数据行是两个独立的 grid 容器,只有定值才让两行的列边线对齐;
521
- 写成 auto 就变成"谁的内容谁宽",表头跟着数据串行。84px = 两枚 26px 图标键 + 一枚 28px「更多」
522
- + 两个 2px 间隙,整簇贴右缘。
523
- 这一版是 2026-10-01 用户第三、第四轮反馈的结果:两枚动作从"独立两列的铺满方块"改成图标键
524
- ("太宽、显得笨、且挤"、"不要显示中文,tooltip 提示"→ 字面只活在 aria-label 与 title 里),
525
- 座位再从中性化的名称行**挪回「操作」列**——三个动作同一栏,"这一行能干什么"才一处可读。 */
311
+ /* Header and rows are separate grids, so 92px / 84px must stay fixed for column alignment. */
526
312
  .capital-watchlist-grid { display: grid; grid-template-columns: 1fr 92px 84px; gap: 8px; align-items: center; }
527
313
  .capital-watchlist-head { padding: 0 20px 8px; font-size: 12px; line-height: 18px; letter-spacing: .2px; white-space: nowrap; color: var(--dsw-alias-label-tertiary); }
528
- /* 「操作」贴在最后一列之上**居右**(2026-10-01 用户点名:居中太丑,读起来像第三列的列名)。
529
- 这一格装着这一行的三个动作:「预测」「复盘」与「更多」,表头贴的就是它们共同那条右缘。 */
530
314
  .capital-watchlist-headaction { text-align: right; }
531
315
  .capital-watchlist-list { max-height: 300px; padding: 0 12px 8px; overflow-y: auto; }
532
- /* 下拉打开时把这一格撑到浮层的最大高度(见上面 capital-watchlist-dropdown 那一段的理由):
533
- **这两个数必须同值**——一个改了另一个不改,下拉就会被卡片裁掉一截(或者清单被顶出卡片外)。
534
- 2026-10-08 一起从 246 抬到 320(用户点名"一屏只能显示 3 个标的"),测试里有同值断言。
535
- 只在打开时撑:平时 1-2 行的短清单不该平白多出一截空白。
536
- ⚠️ 打开时 min-height(320) > 上面那条 max-height(300),而 CSS 里 min 恒胜 max ⇒ 这一格此时按
537
- 320 高、内部照旧 overflow-y: auto 自己滚;收起后 max-height 300 恢复生效。 */
316
+ /* Must equal the dropdown max-height; min wins over max-height while open. */
538
317
  .capital-watchlist-list-open { min-height: 320px; }
539
318
  .capital-watchlist-row { padding: 11px 8px; border-radius: 10px; transition: background .15s var(--ds-ease-in-out); }
540
319
  .capital-watchlist-row:hover { background: var(--dsw-alias-interactive-bg-hover); }
541
320
  .capital-watchlist-namewrap { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
542
321
  .capital-watchlist-name { font-size: 14px; font-weight: 500; color: var(--dsw-alias-label-primary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
543
- /* 用户在这条面板上亲手标过持仓的那一行:名称标红(2026-10-09 用户点名,替代原先那枚徽标)。
544
- ⚠️ 颜色不是这条标记的唯一通道——它与"红涨"同色,所以 title 里同时写着"持仓 20%",
545
- 读屏与鼠标悬停都拿得到同一句话(见 nameTitle)。 */
546
322
  .capital-watchlist-name-held { color: var(--capital-watchlist-up); }
547
- /* 代码与类型徽标同处第二行(2026-10-07 起这一行就这两样,币种不进面板):gap 是这一对唯一的间距来源,徽标自带内边距,别再各写 margin。10px 与候选行 capital-watchlist-option 那条 gap 同值——同一件事的两种排法,不该一处紧一处松;6px 在实机上读起来是"贴着"(2026-10-07 用户点名)。 */
548
323
  .capital-watchlist-codeline { display: flex; align-items: center; gap: 10px; min-width: 0; }
549
324
  .capital-watchlist-code { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
550
- /* 最新价在上、涨跌幅在下,右对齐:两行都同一列里排,列宽才不需要跟着涨跌位数抖。
551
- padding-right 是**数字与动作区之间那口气的唯一来源**(2026-10-01 用户点名:报价和预测/复盘太近,
552
- 整行不均衡):右对齐的数字永远贴着一列的右缘收笔,所以把这一列改窄**不会**让数字离按钮更远——
553
- 列宽只改左边界,右边界一直是那 8px 的 gap。表头「最新价 / 涨跌幅」借同一个类,跟着一起左移,
554
- 标签与它底下的数字因此始终对齐。 */
325
+ /* Right-aligned numbers end at the same edge, so padding-right is the only gap before actions. */
555
326
  .capital-watchlist-quote { min-width: 0; padding-right: 12px; text-align: right; font-variant-numeric: tabular-nums; }
556
327
  .capital-watchlist-price { font-size: 14px; font-weight: 500; color: var(--dsw-alias-label-primary); white-space: nowrap; }
557
328
  .capital-watchlist-change { margin-top: 1px; font-size: 12px; font-weight: 500; white-space: nowrap; }
558
329
  .capital-watchlist-up { color: var(--capital-watchlist-up); }
559
330
  .capital-watchlist-down { color: var(--capital-watchlist-down); }
560
331
  .capital-watchlist-flat { color: var(--dsw-alias-label-secondary); }
561
- /* 第四档:价有、涨跌还没有(停牌 / 未开盘)。刻意比 flat 再浅一档——flat 那一档说的是"今日平盘"
562
- 这个结论,借用它就把"没这个数"画成了一个投资判断。 */
563
332
  .capital-watchlist-nodata { color: var(--dsw-alias-label-tertiary); }
564
- /* 这一行本次没取到报价、且从来没有过值:破折号标红(红 = 数据不可信,与整批刷新同一档)。
565
- 写在 up/down/flat 之后,与 .capital-watchlist-price 同 specificity 靠源码序赢——不靠 !important 抢。 */
566
333
  .capital-watchlist-quotefailed { color: var(--dsw-alias-state-error-primary); }
567
- /* 本次没取到、但上一次成功的快照还在(后端只覆写成功的那几条):值照画,两行都降到 secondary。
568
- 红涨绿跌是"当前"的语义,一个陈旧值不该借用;它是哪一刻的写在 tooltip 里。 */
334
+ /* Failed refresh keeps the last good snapshot; the tooltip names its time. */
569
335
  .capital-watchlist-stale { color: var(--dsw-alias-label-secondary); }
570
- /* 骨架条:bg-skeleton 在浅色只有 4%,官方那一条(.skeletonBar)也是配 2s 呼吸一起用的。 */
571
336
  .capital-watchlist-skeleton { display: block; height: 12px; margin: 2px 0 0 auto; border-radius: 6px; background: var(--dsw-alias-bg-skeleton); animation: capital-watchlist-breathe 2s cubic-bezier(.36, 0, .64, 1) infinite; }
572
337
  .capital-watchlist-skeleton-price { width: 68px; }
573
338
  .capital-watchlist-skeleton-change { width: 44px; height: 10px; }
574
- /* 「更多」键:dimmed 在浅底等于图标整个消失,所以常态取 label-tertiary,hover 提亮到 primary
575
- (破坏性的「移除」降级成菜单里的一条 danger 行,行内不再常驻一个红色动作)。 */
576
339
  .capital-watchlist-more { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; }
577
340
  .capital-watchlist-more:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
578
341
  .capital-watchlist-more:disabled { opacity: .45; cursor: not-allowed; }
579
- /* 官方 Menu 的锚点包着一层 span(.root 是 inline-flex):贴右缘靠 justify-content——旧版删除键是
580
- 格子本身,用 margin-left:auto;包一层之后 auto 外边距落在 span 自己身上,已无效果。
581
- 现在它住在 .capital-watchlist-rowactions 那一簇里,同一簇还有「预测」「复盘」两枚图标键。 */
582
342
  .capital-watchlist-actions { justify-content: flex-end; }
583
343
  .capital-watchlist-rowactions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 2px; min-width: 0; }
584
- /* 「预测」与「复盘」= 动作列里的两枚**图标键**(2026-10-01 用户两次点名:不显示中文,靠 tooltip 说;
585
- 铺满整格的描边方块"太宽、显得笨、且挤";座位从名称行挪回「操作」列,三个动作同栏才读得出"这一行能干什么")。
586
- 图标取官方集里语义直的那两支:**靶心** IconGoalOutlineRegular = 那句话要的是未来走势与关键点位,
587
- **带笔的清单** IconListPenOutlineRegular = 对着已发生的行情把这一行写一遍。字面只活在 aria-label 与
588
- title 里,所以列宽与字体度量不再是这件事的约束——英文标签写成什么都不会溢出。
589
- 同一支笔照行内「更多」键来:透明底、无描边(一行里挂三个方框会抢读),hover 才落 bg-hover-accent
590
- (bg-hover 在浅色只有 6%,透明底上等于没反应);也不借用涨跌的红绿——它们不报数据。
591
- 26×24 是这一档图标键的下限,再小就成点不到的装饰。 */
344
+ /* 26×24 is the minimum hit target for icon-only actions. */
592
345
  .capital-watchlist-review, .capital-watchlist-predict { flex: none; width: 26px; height: 24px; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; }
593
346
  .capital-watchlist-review:hover:not(:disabled), .capital-watchlist-predict:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-accent); color: var(--dsw-alias-label-primary); }
594
347
  .capital-watchlist-review:disabled, .capital-watchlist-predict:disabled { opacity: .45; cursor: not-allowed; }
595
- /* 底部一行:左列是「刷新报价」动词 + 它自己的整批失败行,右列是共用的更新时间。
596
- flex-start 而不是 center:失败行出现时左列变高,居中的话更新时间会跟着往下坠。 */
597
348
  .capital-watchlist-footer { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 14px 20px 18px; border-top: .5px solid var(--dsw-alias-border-l2); }
598
349
  .capital-watchlist-footcol { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-width: 0; }
599
- /* 刷新按钮底走官方 Tag 的 info 配方(state 色 + color-mix 透明档,要的是"轻",实心 tertiary 在按钮上
600
- 会糊成一块色斑);边框按口径例外:状态色 border 保持 1px,0.5px 那条只管中性 border。 */
601
350
  .capital-watchlist-refresh { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary) 25%, transparent); border-radius: 10px; background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent); color: var(--dsw-alias-state-business-primary); font-family: inherit; font-size: 13px; line-height: 20px; font-weight: 500; cursor: pointer; }
602
351
  .capital-watchlist-refresh:hover:not(:disabled) { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 20%, transparent); }
603
352
  .capital-watchlist-refresh:disabled { opacity: .45; cursor: not-allowed; }
604
- /* 34px = 刷新按钮的高度:与它同一行的时间按按钮高度行高居中,左列多出失败行也不受影响。 */
605
353
  .capital-watchlist-updated { font-size: 12px; line-height: 34px; color: var(--dsw-alias-label-tertiary); }
606
- /* flex: none:2026-10-08 起 hint 是下拉这个 flex 列的直接子元素(滚动区只剩选项那一层),
607
- 不钉住的话空间一紧它就会被压扁——hint 没有 overflow,压扁就是文字被裁。 */
608
354
  .capital-watchlist-hint { flex: none; padding: 8px 14px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
609
- /* 黄 = 动作没成。两条黄字都用 warn-label:官方"状态色用作文字"给的是 label 那一档,
610
- warn-primary 压在浅底上只有 2:1,报错文字反而是最不该看不见的一行。 */
611
- /* 搜索失败:占下拉的 hint 位(与「搜索中…」「没有匹配的标的」同一个槽,替换而非并列)。
612
- 不写 padding——它骑在 .capital-watchlist-hint 的 8px 14px 上,只改颜色。 */
613
355
  .capital-watchlist-searcherror { color: var(--dsw-alias-state-warn-label); }
614
- /* 添加失败(list_full / ambiguous):与输入无关,跟着搜索区坐着,改字不清它。 */
615
356
  .capital-watchlist-adderror { padding: 0 20px 10px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-state-warn-label); }
616
- /* 整批刷新失败:红 = 数据不可信,贴在「刷新报价」下面(跟着动词走),不再居中飘到输入框下方。 */
617
357
  .capital-watchlist-notice { font-size: 12px; line-height: 18px; color: var(--dsw-alias-state-error-primary); }
618
358
  .capital-watchlist-empty { padding: 24px 20px; text-align: center; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); }
619
- /* 清单读取失败:占空态那一格,但颜色是"数据不可信"那一档(红)而不是空清单的次要灰。
620
- 写在 .capital-watchlist-empty 之后——两个类同落在这一行,靠源码序赢。 */
621
359
  .capital-watchlist-loaderror { color: var(--dsw-alias-state-error-primary); }
622
- /* 删除二次确认:贴在卡片内的绝对层(.dialog 的 overflow:hidden 顺带裁掉圆角外沿)。
623
- 常驻挂载 + 类切换,才有淡入淡出;visibility:hidden 的子节点天然退出 Tab 序。 */
624
360
  .capital-watchlist-confirm { position: absolute; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; border-radius: 18px; background: var(--dsw-alias-bg-mask-1); opacity: 0; visibility: hidden; transition: opacity .2s var(--ds-ease-in-out), visibility .2s; }
625
361
  .capital-watchlist-confirm-open { opacity: 1; visibility: visible; }
626
- /* 确认盒是浮层,按官方模态卡口径画:border:0 + layer-2 + elevation-prominent,
627
- 不自己拿 bg-mask 当影子(浅底下 16% 描边 + 24% 黑影会糊成一团脏边)。 */
628
- /* 确认盒原先写死 280px:桌面端窗口拖窄时(92vw 小于约 320px)它比卡片还宽,被
629
- capital-watchlist-card 的 overflow: hidden 裁掉——「取消 / 确认移除」两颗按钮直接够不着,
630
- 这一次删除既出不去也点不掉。跟着卡片收,280px 只是上限。 */
362
+ /* Width follows the card, or a narrow window clips the buttons inside overflow:hidden. */
631
363
  .capital-watchlist-confirmbox { width: min(280px, calc(100% - 24px)); padding: 22px 20px 18px; border: 0; border-radius: 14px; background: var(--dsw-alias-bg-layer-2); box-shadow: var(--dsw-elevation-prominent); text-align: center; transform: scale(.95); transition: transform .2s var(--ds-ease-in-out); }
632
364
  .capital-watchlist-confirm-open .capital-watchlist-confirmbox { transform: scale(1); }
633
365
  .capital-watchlist-confirmtitle { font-size: 15px; line-height: 22px; font-weight: 600; color: var(--dsw-alias-label-primary); }
634
366
  .capital-watchlist-confirmdesc { margin-top: 8px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
635
367
  .capital-watchlist-confirmdesc strong { color: var(--dsw-alias-label-primary); font-weight: 500; }
636
368
  .capital-watchlist-confirmactions { display: flex; gap: 10px; margin-top: 20px; }
637
- /* 次要按钮灰底用 button-primary-dimmed(官方给"次要按钮"的实心档):
638
- interactive-bg-hover 在浅色只有 6%,两只按钮在白色确认盒上就只剩边框里的空气。 */
639
369
  .capital-watchlist-confirmbtn { flex: 1 1 0; height: 36px; border: none; border-radius: 10px; background: var(--dsw-alias-button-primary-dimmed); color: var(--dsw-alias-label-primary); font-family: inherit; font-size: 13px; line-height: 20px; font-weight: 500; cursor: pointer; }
640
370
  .capital-watchlist-confirmbtn:hover { background: var(--dsw-alias-interactive-bg-hover-accent); }
641
371
  .capital-watchlist-confirmdanger { border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 25%, transparent); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 15%, transparent); color: var(--dsw-alias-state-error-primary); }
642
372
  .capital-watchlist-confirmdanger:hover { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 25%, transparent); }
643
- /* 持仓这一簇住在「更多」菜单里(勾一下才长出比例那一行)。⛔ 这些选择器必须是**裸类**:菜单是
644
- portal 到 document.body 的,".capital-watchlist-card …" 那种后代选择器够不到它,挂在那儿
645
- 的规则会静默不生效。颜色也一律走 --dsw-alias-*:card 上那对涨跌变量同样透不进去。
646
- 行的几何照官方 .item 抄(min-height 34 / padding 6px 8px / gap 6 / radius md / 13px 20px):
647
- 自己发明的行高会在同一张菜单里长出三种节奏。 */
373
+ /* Portal menu: selectors must be bare classes; card-scoped custom properties do not reach it. */
648
374
  .capital-watchlist-checkrow { box-sizing: border-box; display: flex; align-items: center; gap: 6px; width: 100%; min-height: 34px; padding: 6px 8px; border-radius: var(--dsw-radius-md); font-family: inherit; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-primary); text-align: left; }
649
- /* 行首图标照官方 .itemIcon 的几何(14x14、flex: none、menu-icon 色):与「置顶」「移除」那两行
650
- 同一支位,图标大小一不一列才不抖。 */
651
375
  .capital-watchlist-checkicon { display: inline-flex; flex: none; width: 14px; height: 14px; align-items: center; justify-content: center; color: var(--dsw-alias-menu-icon); }
652
376
  .capital-watchlist-checklabel { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
653
- /* 比例那一行:标题在左、−/读数/+ 在同一行的右边(2026-10-09 用户点名"和标记持仓一个级别")。 */
654
377
  .capital-watchlist-stepper { box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-height: 34px; padding: 6px 8px; border-radius: var(--dsw-radius-md); font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-primary); }
655
378
  .capital-watchlist-stepperlabel { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
656
379
  .capital-watchlist-stepperline { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: none; }
657
- /* 步进键:小方键,描边与底面照搜索框那一条口径(中性描边 0.5px,状态色才 1px)。
658
- 半径刻意写 8px 而不是 999px / 50%:面板有一条"正圆与胶囊必须配 corner-shape: round"的
659
- 计数闸门,为一颗按键多配一对没有意义。 */
660
380
  .capital-watchlist-stepperbtn { flex: none; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary); font-family: inherit; font-size: 15px; line-height: 1; cursor: pointer; }
661
381
  .capital-watchlist-stepperbtn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-accent); }
662
382
  .capital-watchlist-stepperbtn:disabled { opacity: .45; cursor: not-allowed; }
663
- /* 读数:min-width 让 0~100 / 10% / 100% 这几种写法都不把两边的键推走(数字用 tabular-nums)。
664
- 没填的时候显示的就是这一格的**剩余额度**(0~N),不再写"未填"(用户点名用 placeholder 提示);
665
- placeholder 降一档颜色,免得"没这个数"读起来像一个数。 */
666
383
  .capital-watchlist-stepperreadout { min-width: 46px; text-align: center; font-size: 13px; line-height: 18px; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary); }
667
384
  .capital-watchlist-stepperplaceholder { color: var(--dsw-alias-label-tertiary); }
668
- /* ⛔ 这张菜单必须**固定宽度**,不能按内容长。官方 .list 只给 min-width(收缩到内容宽),
669
- 而我们是 align:'end' 贴右缘开的——比例那一行一出现就把列表撑宽几十像素,左缘整块跳一下
670
- (2026-10-09 实机:"点击标记后整个 div 严重位移")。同一处内容超出还会长出横向滚动条:
671
- .scrollable .viewport 写了 overflow-y:auto,按 CSS 口径 overflow-x 就计算成 auto。
672
- 256 = 列表自身 4px padding × 2 + 最宽那一行的内容(图标行与比例行都在 205 上下),
673
- 中英两套语言都不截断。 */
385
+ /* Fixed width: content-sized growth from the stepper row shifts the whole right-aligned menu. */
674
386
  .capital-watchlist-rows { width: 256px; }
675
- /* 行内的每一颗按钮都按 border-box 算宽:官方 .item 是 width:100% + padding 6px 8px 而**没有**
676
- 声明 box-sizing(这套主题没有全局 border-box 重置,只有 .list / .root 自己声明了),于是它天然
677
- 比列表内容盒宽 16px——那 16px 就是上面那条横向滚动条的另一半。
678
- 选择器根在我们自己挂在 portal 列表上的类,所以够得着(后代链没有离开这张列表)。 */
387
+ /* Theme has no global border-box reset; the official item padding would overflow the list. */
679
388
  .capital-watchlist-rows button { box-sizing: border-box; }
680
389
  `
681
390
 
@@ -693,12 +402,8 @@ function installStyles() {
693
402
  }
694
403
 
695
404
  /**
696
- * 与 host 路由的唯一出口:JSON 进、JSON 出,业务失败按 code 上抛。
697
- *
698
- * 成功判据是**正文里的 `ok:true`**,不是 HTTP 状态——`{}` / 一段 HTML / 一个 200 的意外响应
699
- * 都不许被当成"清单是空的"(那正好是把服务故障说成"你还没有自选股")。状态码在这里只用来
700
- * 补正文没给的判据:401 / 403 讲的是"这个页面已经不属于你了",把它翻译成"行情服务暂时不可用"
701
- * 会把用户支去等一个根本不会恢复的错误。
405
+ * Success is `ok: true` in the body, not the HTTP status: 401 / 403 must surface
406
+ * as unauthorized instead of an empty list or a service error.
702
407
  */
703
408
  async function call(path, { query, body, signal } = {}) {
704
409
  const url = query !== undefined ? `${ROUTE}${path}?${query}` : `${ROUTE}${path}`
@@ -720,7 +425,6 @@ async function call(path, { query, body, signal } = {}) {
720
425
  const error = new Error(code)
721
426
  error.code = code
722
427
  error.candidates = payload.candidates
723
- // `list_full` 带着宿主的 `limit`:那句"最多 N 条"的数字只有一个真值源(服务端常量)。
724
428
  error.limit = payload.limit
725
429
  throw error
726
430
  }
@@ -733,84 +437,42 @@ const blank = () => ({
733
437
  items: [],
734
438
  candidates: [],
735
439
  truncated: false,
736
- /**
737
- * 候选筛选头(`all` / `cn` / `hk` / `us`,**默认 `all`**)。住 store 而不是组件 `useState`:
738
- * 两条复位路径各归各的——面板重开(`open()` 的 `...blank()`)回到「全部」;换词**不**掰回
739
- * (用户筛了港,下一个词他还想看港),清空输入也留着(下拉收起时它本来看不见)。
740
- */
440
+ /** Filter lives here so a panel reopen resets it; changing the query keeps it. */
741
441
  marketFilter: 'all',
742
442
  query: '',
743
- /** 下拉是否盖在清单上:由输入打开、由选中/点到框外收起(Esc 也收,见组件的 onKeyDown)。 */
744
443
  dropdownOpen: false,
745
- /**
746
- * 行内「更多」菜单开在哪一行(`thscode`;null = 都关着)。开合状态住 store 而不是组件的
747
- * `useState`:面板关掉再打开时,`open()` 的 `...blank()` 会把上一轮的菜单一起收走。
748
- */
444
+ /** Menu state lives here so `open()` reset closes it. */
749
445
  rowMenu: null,
750
446
  /**
751
- * 待移除的那一行(null = 确认层收起)。**必须与 `rowMenu` 住同一个地方**:它原先是组件的
752
- * `useState`,而面板关闭走的是 `return null`(座位常驻,组件不卸载,2.5.3 review 里那条
753
- * blocker 就是从这里来的)——确认层于是跨开合活了下来:用户点遮罩关掉面板、下次重开,
754
- * 第一屏就是「确定将上一轮那只票移除吗?」,顺手一按就删掉了这次根本没打算动的标的。
447
+ * Must live beside `rowMenu`: closing renders null instead of unmounting, so
448
+ * component state would survive and reopen the confirm layer on the next panel.
755
449
  */
756
450
  pendingRemove: null,
757
- /**
758
- * 持仓**没有**自己的 store 格(2026-10-09 由"另开一层表单 + 一格草稿"改成菜单内勾选):
759
- * 标没标、比例多少都在那一行的 `holding` 上,步进器开在哪一行就是 `rowMenu`。
760
- * 这是 2.5.3 那条 blocker(座位常驻、组件不卸载 ⇒ 表单跨开合活下来)的**结构性**解法——
761
- * 没有一份"只活在表单里"的状态,就没有一份能活过关面板的草稿。
762
- */
763
- /** 整批共用的一次刷新时刻(底部那一行就报它)。 */
764
451
  refreshed_at: null,
765
- /** 整批级失败:红字跟在「刷新报价」下面(数据不可信那一档)。 */
766
452
  error: null,
767
- /**
768
- * 搜索这一路失败(限流 / 服务不可用):黄字占下拉的 hint 位。
769
- * 与 `addError` **分开两个字段**是刻意的——两者清空的时机不同(见 `onQueryChange`)。
770
- */
453
+ /** Separate from `addError`: the two clear at different times. */
771
454
  searchError: null,
772
- /** 某一路检索挂了:候选照常给其余路,这一句只说挂掉的那一路(R12)。 */
773
455
  searchPartial: null,
774
- /** 添加动作的失败(list_full / ambiguous):与输入无关,显示在搜索区下方,改字不清它。 */
775
456
  addError: null,
776
- /**
777
- * 清单**读回来失败**(域打不开 / 登录失效 / 服务挂了):占列表那一片的位置。
778
- * 没有这一格,一次 503 就会渲染成"还没有自选股,用上面的输入框添加。"——服务端为了不
779
- * 骗用户专门把域失败报成 `store_unavailable`,客户端不能把它翻译成空清单。
780
- */
457
+ /** A 503 must not render as an empty list. */
781
458
  loadError: null,
782
- /** 逐标的失败:只影响那一行。 */
783
459
  failures: {},
784
- /**
785
- * 输入法合成期(拼音还没上屏)。住在 store 而不是 surface 字段上,是为了让**渲染**能看见它:
786
- * 合成期不许提前报「没有匹配的标的」(那是把"还没查"说成"查了,没有",正是文件头要消掉的假话)。
787
- * 同时它有了第二条复位路径(`onQueryChange` 读原生事件的 `isComposing`)——个别输入法会丢掉
788
- * `compositionend`,只靠那一个事件复位就会把搜索整块闩死。
789
- */
460
+ /** Composition must be visible to render; `isComposing` is the reset path (some IMEs drop `compositionend`). */
790
461
  composing: false,
791
- /** 换了词、防抖还没到 / 请求在途:下拉此刻没有可点的候选,hint 说"搜索中"而不是"没有匹配"。 */
792
462
  pendingSearch: false,
793
463
  })
794
464
 
795
- /**
796
- * 一个会话一个 surface(座位是 session scope,`inject(sessionId)` 每会话调一次)。
797
- * 开合与数据全住在这一个 store 里,组件通过 `useDialog` 订阅。
798
- */
465
+ /** One surface per session (session-scoped slot); open state and data live in this store. */
799
466
  class WatchlistSurface {
800
467
  constructor() {
801
468
  this.store = createSnapshotStore(blank())
802
469
  this.searchController = null
803
470
  this.timer = null
804
471
  this.pending = null
805
- /** 上一次**真正发出去**的查询词(去重用;`composing` 期间的拼音片段不算发过)。 */
806
472
  this.lastQuery = null
807
473
  /**
808
- * 在途的持仓写:`thscode → { seq, baseline }`。
809
- *
810
- * 为什么要有这一格:勾选之后用户可以连按 `+`,每一次都是一条 `/holding`,而**HTTP 回包的先后
811
- * 顺序不由服务端的写锁决定**(锁排的是写入,不是响应到达)。没有这一格,早先那一次的回包会
812
- * 晚到并把行改回旧比例——下一次 `+` 从那个旧值起步,用户看到的是"按了不动"。
813
- * `baseline` 是最近一次**宿主确认过**的那一行,失败时只回滚这一行到它(⛔ 不复原整张数组)。
474
+ * Out-of-order `/holding` replies: keep only the latest `seq` and roll back one
475
+ * row to its last host-confirmed baseline, never the whole array.
814
476
  */
815
477
  this.holdingOps = new Map()
816
478
  }
@@ -827,7 +489,6 @@ class WatchlistSurface {
827
489
  this.store.update((draft) => Object.assign(draft, changes))
828
490
  }
829
491
 
830
- /** 打开即一次数据请求:清单先出,报价同一次动作里刷一次(空清单不出网)。 */
831
492
  open() {
832
493
  this.patch({ ...blank(), open: true, loading: true })
833
494
  this.load().then(() => this.refresh(), () => this.patch({ loading: false }))
@@ -837,28 +498,25 @@ class WatchlistSurface {
837
498
  if (this.timer !== null) clearTimeout(this.timer)
838
499
  if (this.searchController !== null) this.searchController.abort()
839
500
  this.lastQuery = null
840
- // 合成状态跟着一起归零:面板关在半个拼音上(用户直接点遮罩),`composing` 留在 true 的话,
841
- // 下次打开时输入框里那几个字母会照常发 `input`,而搜索被一条再没人复位的状态永久闩死。
842
- // `rowMenu: null` 现在也管着持仓:菜单收了,比例步进器就跟着没地方画(它住在菜单那一格里)。
501
+ // Reset composition too: leaving it true would permanently latch searches.
843
502
  this.patch({ open: false, rowMenu: null, composing: false, pendingSearch: false })
844
- // 在途的持仓写一并作废:清单是重开的,那一次回执说的已经不是眼前这一屏了。
503
+ // In-flight holding replies no longer describe this screen.
845
504
  this.holdingOps.clear()
846
505
  }
847
506
 
848
507
  async load() {
849
508
  try {
850
509
  const payload = await call('/list')
851
- // 整张清单换掉 = 宿主说了为准:任何在途的持仓回执都不许再落回行上(见 `holdingOps`)。
852
510
  this.holdingOps.clear()
853
511
  this.patch({ items: cleanRows(payload.items) ?? [], seeded_at: payload.seeded_at, loading: false, loadError: null })
854
512
  } catch (error) {
855
513
  this.patch({ loading: false, loadError: { code: error.code ?? 'fuyao_unavailable' } })
856
- // 继续上抛:`open()` 靠这个 rejection 决定"清单都没读到,不要再花一次出网刷报价"。
514
+ // `open()` uses the rejection to skip the refresh when the list never loaded.
857
515
  throw error
858
516
  }
859
517
  }
860
518
 
861
- /** 单闸门:开面板与手动刷新共用,重复点击不会排第二个请求。 */
519
+ /** Single gate shared by panel open and manual refresh. */
862
520
  refresh() {
863
521
  if (this.pending !== null) return this.pending
864
522
  this.patch({ refreshing: true })
@@ -868,26 +526,21 @@ class WatchlistSurface {
868
526
  for (const item of payload.failures ?? []) failures[item.thscode] = item.code
869
527
  const landed = cleanRows(payload.items)
870
528
  const items = landed ?? this.snapshot.items
871
- // 刷新整张换掉清单(宿主的写回带着 `holding`,见 store 那条"刷新写回不许抹掉刚标的持仓"):
872
- // 在途的持仓回执就此作废——让它去改一张已经不是眼前这一屏的表,会把刚落地或刚撤销的
873
- // 标记又画回来。落不下来的那一次(回包没有 items)不动这份账,屏上还是原来那张表。
529
+ // A landed list invalidates in-flight holding replies; no items leaves the screen alone.
874
530
  if (landed !== null) this.holdingOps.clear()
875
531
  this.patch({
876
532
  items,
877
- // 整批一条都没落地时宿主回 `null`:底部那行时间讲的是"最后一次真取到数",
878
- // 跟着一次失败的点击往前走,就成了每一行都陈旧、只有页脚是新的。
533
+ // `null` = nothing landed; the footer keeps the last real success time.
879
534
  refreshed_at: payload.refreshed_at ?? this.snapshot.refreshed_at,
880
535
  failures,
881
- // ⛔ 「其他标的仍是上一次成功快照」只在**确实还有**没被这次失败波及的行时才挂:
882
- // 全批一起失败时(上游整个挂掉就是这种),满屏都标着陈旧,那句"其他"是假话,
883
- // 会把"这份数据整体不可信"说成"只有几行有问题"。
536
+ // "Other symbols still show a snapshot" only when some row is actually unaffected.
884
537
  error: payload.error
885
538
  ? { code: payload.error.code, partial: items.length > 0 && items.some((row) => failures[row.thscode] === undefined) }
886
539
  : null,
887
540
  })
888
541
  })
889
542
  .catch((error) => {
890
- // 同一条口径:这一次出网什么都没拿到,屏上还有旧快照可看才谈得上"仍是上一次成功快照"。
543
+ // Same rule: only claim a previous snapshot if old values are still on screen.
891
544
  this.patch({
892
545
  error: {
893
546
  code: error.code ?? 'fuyao_unavailable',
@@ -904,23 +557,14 @@ class WatchlistSurface {
904
557
  }
905
558
 
906
559
  /**
907
- * 输入框每敲一下都走这里,但**敲字 ≠ 要查**——`/search` 直连上游且无缓存,一次输入
908
- * 逐字母出网就是"打爆上游"的那条路(中文尤其明显)。三条闸:
909
- * 1. 尾部防抖:停手 `SEARCH_DEBOUNCE_MS` 才发,中途的字符只作废上一个定时器;
910
- * 2. IME 合成期不发:拼音输入法逐字母敲的是 `input` 事件,`ning` / `ningd` 那串片段
911
- * 既查不出东西(每次都是"没有匹配的标的"闪一下),又不是用户要的词;
912
- * 3. 同一个词不重发:清空重打、光标移位、compositionend 之后浏览器再补一次 `input`,
913
- * 都不该多出一次网。
914
- * 永远只有一条在途请求(`search` 里 abort 上一条)。
915
- * @param isComposing 原生 `input` 事件的 `isComposing`(缺省时沿用 store 里的值)——
916
- * 复位以它为准,`compositionend` 只是加速器而不是唯一路径:丢掉那个事件的输入法否则会把
917
- * 搜索永久闩死(现象是输入框再也查不出东西,且没有任何地方报错)。
560
+ * Typing is not querying: `/search` hits upstream uncached, so three gates apply —
561
+ * trailing debounce, no send during IME composition, and no resend of the same
562
+ * term. Only one request is in flight.
918
563
  */
919
564
  onQueryChange(query, isComposing) {
920
565
  const trimmed = query.trim()
921
566
  const composing = isComposing === undefined ? this.snapshot.composing === true : isComposing === true
922
- // 改字清的是"上一次搜索没成"(那一行正被新的在途请求取代);**不清** `addError`——
923
- // `list_full` 讲的是清单,跟输入框里是什么字无关。
567
+ // Typing clears the failed search, never `addError` (list_full is unrelated to the query).
924
568
  this.patch({ query, searchError: null, composing, dropdownOpen: trimmed.length > 0 })
925
569
  if (this.timer !== null) clearTimeout(this.timer)
926
570
  if (trimmed.length === 0) {
@@ -929,22 +573,18 @@ class WatchlistSurface {
929
573
  this.patch({ candidates: [], truncated: false, searching: false, pendingSearch: false })
930
574
  return
931
575
  }
932
- // 词一换,上一个词的结果就不再是这一屏的合法候选:留在下拉里,用户点下去添加的是
933
- // **和输入框不同名**的那只票(⛔ 2.5.3 review 的 blocker:清空重打时窗口很宽)。
934
- // 同一个词、且它的回包已经落地才是不必重发(去重记在 `lastQuery` 上)。
576
+ // Old results are not candidates for the new term; a landed same-term reply is reused.
935
577
  if (trimmed === this.lastQuery && this.snapshot.pendingSearch === false) return
936
578
  this.patch({ candidates: [], truncated: false, pendingSearch: !composing })
937
579
  if (composing === true) return
938
580
  this.timer = setTimeout(() => this.search(trimmed), SEARCH_DEBOUNCE_MS)
939
581
  }
940
582
 
941
- /** 合成开始:这段输入不是查询词,先把在途的防抖撤掉(上一次上屏的词已经没有意义了)。 */
942
583
  beginComposition() {
943
584
  if (this.timer !== null) clearTimeout(this.timer)
944
585
  this.patch({ composing: true })
945
586
  }
946
587
 
947
- /** 上屏:这一刻才拿真正的词走一次正常路径。 */
948
588
  endComposition(query) {
949
589
  this.onQueryChange(query, false)
950
590
  }
@@ -953,35 +593,25 @@ class WatchlistSurface {
953
593
  this.patch({ dropdownOpen: false })
954
594
  }
955
595
 
956
- /**
957
- * 筛选头(全部 / CN / HK / US):只换**显示**哪几行,不出网——候选是一次拿全的,
958
- * 切档也不该把下拉收起(它就在下拉顶上)。
959
- * 未知档名直接忽略:这一格的取值只有 `MARKET_FILTERS` 那四个,拼错的值不许让下拉变空白。
960
- */
596
+ /** Display-only filter; unknown ids are ignored so a typo cannot blank the dropdown. */
961
597
  setMarketFilter(filter) {
962
598
  if (!MARKET_FILTERS.some((entry) => entry.id === filter)) return
963
599
  this.patch({ marketFilter: filter })
964
600
  }
965
601
 
966
- /** 行内「更多」键:再点一次就收起(同一个键既开又关,不用去别处找关闭入口)。 */
967
602
  toggleRowMenu(thscode) {
968
603
  this.patch({ rowMenu: this.snapshot.rowMenu === thscode ? null : thscode })
969
604
  }
970
605
 
971
- /** 官方 Menu 的 onClose(点到外面 / Escape)与选中之后都走这里。 */
972
606
  closeRowMenu() {
973
607
  if (this.snapshot.rowMenu !== null) this.patch({ rowMenu: null })
974
608
  }
975
609
 
976
- /** 待移除那一行住 store(见 `blank()` 里那一格):`open()` 的 `...blank()` 才收得走它。 */
977
610
  setPendingRemove(item) {
978
611
  this.patch({ pendingRemove: item ?? null })
979
612
  }
980
613
 
981
- /**
982
- * 用宿主回包那一条覆盖这一行:标记时刻归服务端(浏览器钟不比它准),名字再走一遍
983
- * `cleanRows`——显示、tooltip、aria-label 与递进输入框那句话因此还是同一个名字。
984
- */
614
+ /** Overwrite one row with the host's copy; the server owns `marked_at`; scrub the name again. */
985
615
  replaceRow(row) {
986
616
  const [clean] = cleanRows([row])
987
617
  if (clean === undefined || typeof clean?.thscode !== 'string') return
@@ -994,25 +624,21 @@ class WatchlistSurface {
994
624
  const controller = new AbortController()
995
625
  this.searchController = controller
996
626
  this.patch({ searching: true, searchError: null, searchPartial: null })
997
- // 回包落地前再看一眼输入框:从发出到返回这段时间里用户可能已经改字(新词只排了个防抖
998
- // 定时器,还没出网)。那一串候选属于**上一个词**,画出来就是能被点错的那一行——丢掉,
999
- // 让新词的定时器自己去查。
627
+ // Drop results for a term the user already changed; let the new debounce run.
1000
628
  const stillCurrent = () => this.snapshot.query.trim() === query
1001
629
  try {
1002
630
  const payload = await call('/search', { query: `q=${encodeURIComponent(query)}`, signal: controller.signal })
1003
631
  if (!stillCurrent()) {
1004
- // 被弃掉的那一次不许留下转圈的搜索态(新词还没出网时,spinner 说的是上一个词)。
1005
632
  this.patch({ searching: false })
1006
633
  return
1007
634
  }
1008
635
  this.patch({ candidates: cleanRows(payload.items) ?? [], truncated: payload.truncated === true, searching: false, pendingSearch: false, searchPartial: Array.isArray(payload.partial) && payload.partial.length > 0 ? payload.partial : null })
1009
636
  } catch (error) {
1010
637
  if (error.name === 'AbortError') return
1011
- // 失败不记在"这个词查过了"的账上:同样几个字重打一遍还得能再出网。
638
+ // A failure is not "this term was checked": retyping it must search again.
1012
639
  if (this.lastQuery === query) this.lastQuery = null
1013
640
  if (!stillCurrent()) return
1014
- // 走 `searchError` 而不是 `addError`:它占下拉的 hint 位,替换掉「没有匹配的标的」——
1015
- // 同一屏不许一边说"没有这个票"、一边说"服务限流"(候选清空了,noHit 本来就会亮起来)。
641
+ // Search errors replace the no-hit hint; the same screen must not say both.
1016
642
  this.patch({ candidates: [], truncated: false, searching: false, pendingSearch: false, searchError: { code: error.code ?? 'fuyao_unavailable' }, searchPartial: null })
1017
643
  }
1018
644
  }
@@ -1021,34 +647,23 @@ class WatchlistSurface {
1021
647
  try {
1022
648
  await call('/add', { body: { thscode: candidate.thscode } })
1023
649
  } catch (error) {
1024
- // 歧义与超限是"添加"这个动作的失败:留在搜索区(黄),不去占刷新那条红字的位置。
650
+ // Ambiguity / cap are add failures: stay in the search area, not the refresh notice.
1025
651
  this.patch({ addError: { code: error.code ?? 'fuyao_unavailable', candidates: error.candidates, limit: error.limit } })
1026
652
  if (error.candidates !== undefined) this.patch({ candidates: error.candidates, truncated: false })
1027
653
  return
1028
654
  }
1029
- // ⛔ 上面那一下成功之后,**任何后续失败都不许再写成「添加失败」**(原先整段在一个
1030
- // try 里:`/add` 已经落库、紧接着清单读回来 503,面板就报"自选股已达上限"式的黄字,
1031
- // 用户照着再点一次——同一个标的被加了第二遍)。读清单的失败归 `loadError`(列表那
1032
- // 一片),刷报价的失败归 `error`(页脚那条红字),各自有位置,都不占添加这句话。
1033
- // ⛔ 成功之后**不再收下拉、不清输入**(2026-10-08 用户点名"能在下拉框里连续添加"):
1034
- // 收起 = 每加一条都得重敲一遍词,"连续添加"根本无从谈起。这一行当场标 `已添加` 并随行禁用
1035
- // (幂等,点不进第二次),其余候选、筛选档与输入框里的词都原样留着。`lastQuery` 因此保持原值
1036
- // ⇒ 同一个词不会被重发(三条闸里"同一个词不重发"那条正是靠它)。
1037
- // ⛔ `searchError` / `searchPartial` 也**不清**:它们说的是"这次搜索有一路没参与",下拉还开着,
1038
- // 把真话擦掉就是让它失效。
655
+ // After success, later failures must not be reported as add failures (the symbol is
656
+ // already stored). Keep the dropdown and query open for consecutive adds; the row is
657
+ // marked added and disabled, and `lastQuery` stays so the same term is not resent.
1039
658
  this.patch({
1040
659
  addError: null,
1041
660
  candidates: this.snapshot.candidates.map((row) => (row.thscode === candidate.thscode ? { ...row, in_list: true } : row)),
1042
661
  })
1043
662
  await this.load().catch(() => {})
1044
- // 添加是一次完整动作:读完清单顺手刷一次报价,新行当场就有价。停在「未刷新」
1045
- // 等于把用户刚加的东西留成半成品——开面板那一步本来就是同一套 load→refresh。
663
+ // Adding is a full action: reload and refresh so the new row has a quote immediately.
1046
664
  await this.refresh()
1047
- // ⛔ 上面那一次可能被单闸门折叠进了**为上一批标的**出的在途请求:它的回包里没有刚加的
1048
- // 这一行,于是新行停在「未刷新」、页脚却报刚刚刷过(2.5.3 review 的 blocker 3)。面板一
1049
- // 打开就自动刷新,清单里有 ETF 时那一批要跑几十秒,窗口很宽——用户正是在这时候点候选的。
1050
- // 落地后核对:这一行既没报价、又没被记失败(说明它根本没被问过),就补发一次真刷新。
1051
- // 仍然只在用户"添加"这一下动作里,不轮询、不自动重试。
665
+ // The refresh can be folded into an in-flight batch that never asked this row. If it has
666
+ // no quote and no recorded failure, ask once more — still only inside this user action.
1052
667
  const landed = this.snapshot.items.find((item) => item.thscode === candidate.thscode)
1053
668
  if (landed !== undefined && landed.quote === null && this.snapshot.failures[candidate.thscode] === undefined) {
1054
669
  await this.refresh()
@@ -1060,8 +675,7 @@ class WatchlistSurface {
1060
675
  this.patch({ items: before.filter((row) => row.thscode !== thscode), rowMenu: null })
1061
676
  try {
1062
677
  await call('/remove', { body: { thscode } })
1063
- // 清单腾出一格,"最多 10 条,先删一条再加"当场不再成立:成功移除顺手清掉这句。
1064
- // 只清 `list_full`——`ambiguous` 讲的是上一次添加的歧义,与清单空位无关。
678
+ // A successful removal frees a slot, so a stale `list_full` no longer applies.
1065
679
  if (this.snapshot.addError?.code === 'list_full') this.patch({ addError: null })
1066
680
  } catch {
1067
681
  this.patch({ items: before })
@@ -1069,9 +683,8 @@ class WatchlistSurface {
1069
683
  }
1070
684
 
1071
685
  /**
1072
- * 置顶:先把这一行提到第一行(乐观),再让宿主把 `pinned_at` 写进域。
1073
- * 失败回滚成原顺序——与 `remove` 同一条口径:面板不弹错框,行自己弹回去就是回执。
1074
- * 返回的 `items` 是宿主的排序真值(服务端只记时间戳,顺序由读法决定)。
686
+ * Pin optimistically, then let the host write `pinned_at`; failure restores only
687
+ * the order, never row contents changed by other actions meanwhile.
1075
688
  */
1076
689
  async pin(thscode) {
1077
690
  const before = this.snapshot.items
@@ -1082,9 +695,7 @@ class WatchlistSurface {
1082
695
  const payload = await call('/pin', { body: { thscode } })
1083
696
  if (Array.isArray(payload.items)) this.patch({ items: cleanRows(payload.items) })
1084
697
  } catch {
1085
- // ⛔ 失败只复原**顺序**,不复原每一行的内容:整张数组复原会把这一段时间里落地的其它改动
1086
- // (刚标上的持仓、刚加进来的行)一起倒回点击前——那些是别的动作的成功回执,域里也已经有了。
1087
- // 期间被移除的行不补回,末尾接上期间新到的行。
698
+ // Restore order only; other rows may have landed changes. Keep new rows at the end.
1088
699
  const current = this.snapshot.items
1089
700
  const ordered = before.map((row) => current.find((entry) => entry.thscode === row.thscode)).filter(Boolean)
1090
701
  const known = new Set(ordered.map((row) => row.thscode))
@@ -1093,13 +704,8 @@ class WatchlistSurface {
1093
704
  }
1094
705
 
1095
706
  /**
1096
- * 持仓这一格的**唯一**写路径:乐观改那一行 → 恰好一次 `/holding` → 用宿主那一条覆盖,
1097
- * 失败只把那一行弹回最近一次宿主确认的值。
1098
- *
1099
- * 两道闸都是为"连点"存在的(见 `holdingOps`):
1100
- * - 回执只认最新那一次意图(`op.seq`),晚到的旧回包直接丢弃——否则用户看到的是"按了不动";
1101
- * - 回滚逐行,不整张复原——否则一次失败的 `+` 会把**别的行**刚落地的一切一起抹掉。
1102
- * ⛔ 这里也不碰 `rowMenu`:改比例是"在这一行里继续操作",收起菜单就是把用户刚点的东西拿走。
707
+ * Only write path for holding: optimistic row → exactly one `/holding` → replace
708
+ * with the host row, or roll that single row back. Never touches `rowMenu`.
1103
709
  */
1104
710
  async writeHolding(thscode, body, nextRow) {
1105
711
  const target = this.snapshot.items.find((row) => row.thscode === thscode)
@@ -1122,16 +728,12 @@ class WatchlistSurface {
1122
728
  }
1123
729
  }
1124
730
 
1125
- /**
1126
- * 标记持仓 = 恰好一次 `/holding`。`weightPct` 传 `null` 是"标了持仓、比例没填",与 0 是两句话
1127
- * (域 schema 同一条口径:没有这个数 ≠ 这个数是零)——勾选那一刻发的就是这一档。
1128
- */
731
+ /** `null` means held without a weight; 0 is a distinct real value. */
1129
732
  markHolding(thscode, weightPct) {
1130
733
  return this.writeHolding(thscode, { thscode, weight_pct: weightPct },
1131
734
  (row) => ({ ...row, holding: { weight_pct: weightPct, marked_at: Date.now() } }))
1132
735
  }
1133
736
 
1134
- /** 取消持仓 = 只抹那一格,**关注条目本身不动**(它不是"移除",也不该有任何位移)。 */
1135
737
  clearHolding(thscode) {
1136
738
  return this.writeHolding(thscode, { thscode, clear: true }, (row) => {
1137
739
  const next = { ...row }
@@ -1141,13 +743,8 @@ class WatchlistSurface {
1141
743
  }
1142
744
 
1143
745
  /**
1144
- * 比例 ± 一档。⛔ 不吸附:域里存着 `37.5`(旧版自由文本填的)就 ±10 得 `47.5 / 27.5`——
1145
- * 把一个用户亲手写过的数掰成 10 的整数倍,等于替他说了一句他没说的话(与工具侧
1146
- * "自报值原样引用"同一条线)。"未填"没有数值起点:第一次 `+` 落 10,那一档的 `−` 是置灰的。
1147
- *
1148
- * 顶上封的是 `weightCeiling`(单只 100 与"120 减掉其余持仓"取小的):面板把 `+` 置灰与这里
1149
- * 夹住用的是同一个减法,宿主 `/holding` 拒绝用的也是它(AGENTS.md §9.7)。这里夹住是必需的,
1150
- * 不只是"按钮已经灰了":调用点还有一颗 `+` 之外的入口(键盘连按时屏幕上的额度是上一帧的)。
746
+ * No snapping: 37.5 ± 10 stays 47.5 / 27.5, never rounded. The ceiling is the
747
+ * same single-row / remaining-total rule the host enforces.
1151
748
  */
1152
749
  stepHolding(thscode, delta) {
1153
750
  const rows = this.snapshot.items
@@ -1158,15 +755,11 @@ class WatchlistSurface {
1158
755
  return this.markHolding(thscode, next)
1159
756
  }
1160
757
 
1161
- /**
1162
- * 留档后门:**面板上没有按钮**(用户反馈"挺那个啥的")。真要用的时候,
1163
- * 从 devtools 拿到该会话的 surface 调 `await surface.copyToClipboard()`。
1164
- */
758
+ /** Dev-only escape hatch: no panel button; call `surface.copyToClipboard()` from devtools. */
1165
759
  async copyToClipboard() {
1166
760
  const lines = this.snapshot.items.map((row) => [
1167
761
  row.thscode,
1168
762
  scrubInvisible(row.name),
1169
- // 留档走机器可读的 `asset_type` 原值,不走界面语言的类型词:这份 TSV 是拿来归档/再处理的。
1170
763
  row.asset_type,
1171
764
  row.quote?.price ?? '',
1172
765
  row.quote?.change_pct ?? '',
@@ -1189,17 +782,13 @@ function formatTime(value) {
1189
782
  return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
1190
783
  }
1191
784
 
1192
- /** 类型徽标:候选行与数据行共用同一支笔(文案与配色都在这两处一次对齐)。 */
1193
785
  function TypeTag({ assetType, t }) {
1194
786
  return h('span', { className: `capital-watchlist-tag ${TYPE_TONES[assetType] ?? 'capital-watchlist-tag-cn'}` }, typeLabel(assetType, t))
1195
787
  }
1196
788
 
1197
789
  /**
1198
- * 「预测」与「复盘」共用的**形状**:`<前缀>名称 (thscode · 品种)<后缀>`。
1199
- * 括号、空格与中点是结构,写死在这里(中英文同一形状);句子两半住字典的
1200
- * `<kind>PromptPrefix` / `<kind>PromptSuffix`(kind 是 `predict` / `review`),改文案去那儿改。
1201
- * 中间那个品种词读 `KIND_LABEL_KEYS`,**不是徽标那套市场词**:Agent 靠它选端点
1202
- * (股票 / 指数 / 场内基金是三个不同上游),市场由 thscode 自己带。
790
+ * Prompt shape `<prefix>name (thscode · kind)<suffix>`; kind is endpoint family.
791
+ * The Agent picks an endpoint from stock / index / ETF; market comes from thscode.
1203
792
  */
1204
793
  function promptOf(item, t, kind) {
1205
794
  const name = scrubInvisible(item.name)
@@ -1207,13 +796,8 @@ function promptOf(item, t, kind) {
1207
796
  }
1208
797
 
1209
798
  /**
1210
- * 最新价在上、涨跌幅在下,右对齐;两位小数,红涨绿跌按 A 股习惯。
1211
- * "这一行本次没取到数"**不是**"这一行没有数":后端只覆写成功的记录,上一次成功的快照仍在
1212
- * `quote` 里,所以画法是四态而不是两态——
1213
- * 1. 在途且还没有任何值 → skeleton;
1214
- * 2. 本次失败、从来没有过值 → 红色 `—`(数据不可信那一档)+ 原因;
1215
- * 3. 本次失败、但手上有旧快照 → 值照画,两行降一档色,tooltip 说清是哪一刻的;
1216
- * 4. 从没刷过 → 「未刷新」。
799
+ * Quote has four states: skeleton, red dash with no prior value, last good snapshot
800
+ * (dimmed + tooltip), or "not refreshed".
1217
801
  */
1218
802
  function QuoteCell({ quote, failureCode, refreshing, t }) {
1219
803
  const stale = failureCode !== undefined
@@ -1229,9 +813,7 @@ function QuoteCell({ quote, failureCode, refreshing, t }) {
1229
813
  }
1230
814
  return h('div', { className: 'capital-watchlist-quote' }, h('div', { className: 'capital-watchlist-price' }, t('notRefreshed')))
1231
815
  }
1232
- // 涨跌可以是"没有"(停牌 / 未开盘时上游回 null,而最新价仍然有值)。这一档**不许**画成
1233
- // 0.00%:0 在 A 股口径里是"今日平盘"这个真值,借用它就把一个未知说成了结论。破折号 + 次要灰,
1234
- // 与红/绿/平三档并列为第四档,读法上是"这个数还没出"。
816
+ // Missing change is missing, never 0.00% (0 is a real flat day).
1235
817
  const hasPct = typeof quote.change_pct === 'number' && Number.isFinite(quote.change_pct)
1236
818
  const pct = hasPct ? `${quote.change_pct > 0 ? '+' : ''}${quote.change_pct.toFixed(2)}%` : '—'
1237
819
  const tone = stale
@@ -1239,10 +821,7 @@ function QuoteCell({ quote, failureCode, refreshing, t }) {
1239
821
  : !hasPct
1240
822
  ? 'capital-watchlist-nodata'
1241
823
  : quote.change_pct > 0 ? 'capital-watchlist-up' : quote.change_pct < 0 ? 'capital-watchlist-down' : 'capital-watchlist-flat'
1242
- /**
1243
- * tooltip 的三句话按本次状态拼:失败原因 + 上一次快照时刻(陈旧行)、"暂无涨跌数据"(价有涨跌没有)、
1244
- * 交易所当地时间(**只有港美股那一路带 `source_time`**,A 股那一路没有这格就不画、不编一句)。
1245
- */
824
+ /** Failure reason + snapshot time, plus exchange-local time when provided. */
1246
825
  const exchange = typeof quote.source_time === 'string' && quote.source_time.length > 0
1247
826
  ? `${t('exchangeTime')} ${quote.source_time}`
1248
827
  : ''
@@ -1258,11 +837,7 @@ function QuoteCell({ quote, failureCode, refreshing, t }) {
1258
837
  h('div', { className: `capital-watchlist-change ${tone}` }, pct))
1259
838
  }
1260
839
 
1261
- /**
1262
- * 添加失败那一行的文案。只有 `list_full` 特殊:那句里的数字归宿主(`limit`),
1263
- * 客户端不许再存一份常量副本——2.5.2 就是服务端一个 `MAX_ITEMS` + 中英文字典各写一遍"10",
1264
- * 这次抬到 30 时只会改红一边。没有 `limit` 的旧回包退到不带数字的那句,绝不把 `{max}` 印出来。
1265
- */
840
+ /** `list_full` uses the host's `limit`; never print the `{max}` placeholder. */
1266
841
  function addErrorText(t, addError) {
1267
842
  if (addError.code !== 'list_full') return t(`error.${addError.code}`)
1268
843
  const limit = addError.limit
@@ -1271,12 +846,8 @@ function addErrorText(t, addError) {
1271
846
  }
1272
847
 
1273
848
  /**
1274
- * 持仓那句话:没填比例就是光秃秃两个字,填了就跟着数字。
1275
- * 数字是**用户自报**的占比、不是这里算出来的,所以原样给——补成 `20.00%` 会像一位从市值
1276
- * 除出来的数,而面板从来没有市值。
1277
- *
1278
- * 2026-10-09 起它不再是一枚徽标,而是**名称的 tooltip**(`nameTitle`):行上改成名称标红,
1279
- * 而红与"红涨"同色,所以这句话必须还有一条不靠颜色的通道。
849
+ * User-reported weight, printed as entered; no market value exists to justify
850
+ * reformatting. Red is not the only channel: the name tooltip repeats this.
1280
851
  */
1281
852
  function holdingLabel(holding, t) {
1282
853
  const weight = holding?.weight_pct
@@ -1284,7 +855,6 @@ function holdingLabel(holding, t) {
1284
855
  return `${t('holdingName')} ${weight}%`
1285
856
  }
1286
857
 
1287
- /** 名称那一格 tooltip:`名称 代码`,标过持仓的再跟着那一句自报的标记。 */
1288
858
  function nameTitle(item, t) {
1289
859
  const base = `${item.name} ${item.ticker}`
1290
860
  return item.holding === null || item.holding === undefined ? base : `${base} · ${holdingLabel(item.holding, t)}`
@@ -1294,16 +864,13 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1294
864
  const state = useDialog((value) => value)
1295
865
  const [busy, setBusy] = useState(false)
1296
866
  /**
1297
- * 在途的那一次「添加」落在哪一行(null = 没有)。⛔ 与 `busy` 分开:`busy` 关的是**清单那半边**
1298
- * (预测 / 复盘 / 更多 / 刷新按钮),候选行只许禁它自己那一条——全禁的话,"连续添加"会被每一次
1299
- * 在途请求打断(第二条正灰着、点不进去)。并发添加是安全的:服务端把清单读写串在一条队列上。
867
+ * Which row's add is in flight. Separate from `busy`: candidates disable only
868
+ * themselves so consecutive adds are not interrupted.
1300
869
  */
1301
870
  const [addingThscode, setAddingThscode] = useState(null)
1302
871
  /**
1303
- * 待移除的那一行,走面板内的二次确认层——不是 `window.confirm`:那是宿主窗口级别的系统弹窗,
1304
- * 样式与语言都不归插件管,而且会把"从自选中移除"这件事从面板里搬出去。
1305
- * 这一格住 store 而不是 `useState`:`if (!state.open) return null` 是**收起**不是卸载
1306
- * (座位常驻),组件状态因此跨开合活下来——2.5.3 review 里那条 blocker 就是这么来的。
872
+ * In-panel confirm, not `window.confirm`; state lives in the store because closing
873
+ * renders null instead of unmounting.
1307
874
  */
1308
875
  const pendingRemove = state.pendingRemove ?? null
1309
876
 
@@ -1313,10 +880,7 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1313
880
  const showDropdown = state.dropdownOpen === true
1314
881
  const confirming = pendingRemove !== null
1315
882
 
1316
- // Esc 的归属:移除确认层 > 下拉 > 整个弹窗(最后一个是官方 Modal 自己的 document 监听)。
1317
- // stopPropagation 让事件不再走到那一层,写法照官方 headless 对话框(directory-picker-browse)。
1318
- // 「更多」菜单(连带里面的比例步进)不在这一串里:官方 `Menu` 自己在 **capture 阶段**吃掉
1319
- // Escape 并 `preventDefault`(账本 L23 探的就是这一条),所以它收起时弹窗本体不跟着关。
883
+ // Esc ownership: confirm layer, then dropdown; the official Menu handles its own Escape.
1320
884
  const onKeyDown = (event) => {
1321
885
  if (event.key !== 'Escape') return
1322
886
  if (confirming) {
@@ -1329,8 +893,7 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1329
893
  surface.hideDropdown()
1330
894
  }
1331
895
  }
1332
- // 点到框外就收起下拉:判据是"焦点离开**搜索段**",而候选行就住在搜索段里,
1333
- // 所以鼠标点行的那一下(focus 收进 option)不算离开。
896
+ // Focus leaving the search section closes the dropdown; candidates live inside it.
1334
897
  const onBlur = (event) => {
1335
898
  if (!showDropdown) return
1336
899
  const next = event.relatedTarget
@@ -1345,23 +908,15 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1345
908
  `${t(`error.${error.code}`)}${error.partial === true ? t('errorSuffix') : ''}`)
1346
909
 
1347
910
  /**
1348
- * 「预测」/「复盘」= 把那句话**追加**进输入框,剩下由用户看过自己发送。不 `submit()`(那等于替
1349
- * 用户决定发什么),也不 `setDraft()`(那会吞掉他已经敲了一半的草稿)。`insertText` 自带
1350
- * revision 守卫:草稿在这一下变了、或正在提交而锁住编辑器就返回 false——那时**面板不关**,
1351
- * 用户看得见这一行,再点一次就是。
1352
- * `inputActions` 是框架发给每个 session scope 座位组件的标准件(本座位正是 session scope),
1353
- * 不是我们自己的注入。
911
+ * Append to the draft instead of submitting; keep the panel open when `insertText`
912
+ * refuses (revision guard or locked editor).
1354
913
  */
1355
914
  const appendPrompt = (item, kind) => {
1356
915
  if (inputActions.insertText(promptOf(item, t, kind), inputActions.captureInsertion()) !== true) return
1357
916
  surface.close()
1358
917
  }
1359
918
 
1360
- /**
1361
- * 筛选头只筛**已经拿全**的候选(A 股 50 + 港 10 + 美 10 一次回包):切档不出网、也不收下拉。
1362
- * 计数按市场算,「全部」那一档是三市之和——用户那句"怎么全是 A 股,港股美股呢",答案就写在
1363
- * 这三个数字里(港 10 / 美 10,不是 0),不用他逐档点过去找。档名 `CN` / `HK` / `US` 中英文同一套。
1364
- */
919
+ // Filtering is display-only; counts show each market's real total.
1365
920
  const visibleCandidates = state.marketFilter === 'all'
1366
921
  ? state.candidates
1367
922
  : state.candidates.filter((candidate) => marketIdOf(candidate) === state.marketFilter)
@@ -1374,7 +929,6 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1374
929
  key: entry.id,
1375
930
  type: 'button',
1376
931
  className: 'capital-watchlist-filterbtn',
1377
- // 选中态只写在 `aria-pressed` 上、样式读它:状态先进属性,读屏也听得见"已选中"。
1378
932
  'aria-pressed': state.marketFilter === entry.id,
1379
933
  onClick: () => surface.setMarketFilter(entry.id),
1380
934
  },
@@ -1384,8 +938,7 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1384
938
  const dropdown = !showDropdown
1385
939
  ? null
1386
940
  : h('div', { className: 'capital-watchlist-dropdown', 'aria-label': t('title'), 'data-menu-material': 'translucent' },
1387
- // 筛选头**不进滚动区**:`role="listbox"` 因此跟着选项搬到里面那层(列表框里只许摆 option,
1388
- // 头自己是 group),滚动的也只有选项那一层——头由 CSS 钉在下拉顶部,即用户要的"锁定这个头"。
941
+ // Filter bar stays out of the scroll area; the listbox holds only options.
1389
942
  filterBar,
1390
943
  h('div', { className: 'capital-watchlist-options', role: 'listbox', 'aria-label': t('title') },
1391
944
  visibleCandidates.map((candidate) => h('button', {
@@ -1393,17 +946,11 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1393
946
  type: 'button',
1394
947
  role: 'option',
1395
948
  className: 'capital-watchlist-option',
1396
- // hover 出来的是**证券全称**(2026-10-08 用户点名):旧版这一格写的是 `添加`,那句
1397
- // "这个按钮能干嘛"已经由右侧那颗「添加」说了;美股长名会被 CSS 截断,全称只有这个
1398
- // 原生 tooltip 给得全。
949
+ // Native tooltip exposes the full name; CSS truncates long US names.
1399
950
  title: candidate.name,
1400
- // ⛔ 这一格用 `aria-disabled` 而不是真 `disabled`(2026-10-08 第三轮用户点名"点一下添加、
1401
- // 下拉就没了"):真 `disabled` 会让浏览器**立刻把焦点从这一行踢到 body**,搜索段的 onBlur
1402
- // 于是拿到 `relatedTarget: null` → 判"点到框外" → 收起下拉,连续添加被这一下打断。
1403
- // `aria-disabled` 不动焦点(行照样可聚焦,读屏也听得见"不可用"),"禁止点击"由下面的 guard 承担。
951
+ // aria-disabled, not disabled: real disabled drops focus to body and closes the dropdown.
1404
952
  'aria-disabled': candidate.in_list === true || addingThscode === candidate.thscode,
1405
953
  onClick: () => {
1406
- // 幂等 + 防在途双击:已添加的那条点多少次都不再出网,在途那条也拦掉。
1407
954
  if (candidate.in_list === true || addingThscode === candidate.thscode) return
1408
955
  setBusy(true)
1409
956
  setAddingThscode(candidate.thscode)
@@ -1412,50 +959,35 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1412
959
  },
1413
960
  h('span', { className: 'capital-watchlist-optionmain' },
1414
961
  h('span', { className: 'capital-watchlist-optionname' }, candidate.name),
1415
- // ⛔ 第二行画**完整 canonical 代码**,不是裸码:`00700` 一次同时命中港个股与
1416
- // `000700.SZ` 模塑科技,搜「恒生指数」还会同时回 `HSI` / `HSIGTR` / `HSINTR` 三条真指数——
1417
- // 裸码并列时用户没有可核对的信息,点错就是加错票。
1418
- // 类型徽标挪到代码右边(`codeline` 直接复用清单第一列那个类 ⇒ 排法与清单同款,用户
1419
- // 2026-10-08 点名"类似自选股列表第一列的样式"),右侧因此腾出来放「添加」。
962
+ // Second line shows the full canonical code; a bare `00700` collides with
963
+ // `000700.SZ`, and HSI / HSIGTR / HSINTR are all real indices.
1420
964
  h('span', { className: 'capital-watchlist-codeline' },
1421
965
  h('span', { className: 'capital-watchlist-optioncode' }, candidate.thscode),
1422
966
  h(TypeTag, { assetType: candidate.asset_type, t }))),
1423
- // 右侧那颗「添加」是**整行这个 button 的可见动词**,不是第二个可聚焦控件(button 里再
1424
- // 套 button 是非法 HTML);已在清单里就换 `已添加`,随行一起禁用(点了也不会重复写库)。
1425
- // 两态(2026-10-08 第三轮用户点名):**添加 = 醒目**(沿用「刷新报价」那颗 verb 按钮的配方,
1426
- // 整行里唯一带色的就是它 ⇒ 一眼知道点哪儿)、**已添加 = 暗**(透明底 + l1 描边 + tertiary 字,
1427
- // 配合整行 aria-disabled 的 opacity 一起降)。状态只写在 `data-state` 上,样式读它。
967
+ // The label is this button's visible verb, not a nested button; state lives on `data-state`.
1428
968
  h('span', { className: 'capital-watchlist-addbtn', 'data-state': candidate.in_list === true ? 'added' : 'idle' },
1429
969
  candidate.in_list === true ? t('added') : t('add')))),
1430
- // ⛔ "没有更多"那句住在**滚动区末尾**(2026-10-08 用户点名"不要冻结"):它跟着内容一起滚,
1431
- // 滚到底才看得见——钉在浮层底部就成了一条常驻横幅,还挡着最后一行候选。
1432
- // 筛选档里一行都不剩时不说它(那一格由下面的 `filterNoHit` 负责)。
970
+ // "No more" scrolls at the end of the list; only shown when this filter has rows.
1433
971
  state.truncated === true && visibleCandidates.length > 0
1434
972
  ? h('div', { className: 'capital-watchlist-hint capital-watchlist-endhint' }, t('truncated'))
1435
973
  : null),
1436
- // 下面这几句 hint **不进滚动区**:它们说的是"这一屏此刻怎么了"(正在查 / 查不到 / 有路挂了),
1437
- // 被内容滚走就等于没人看见——所以留在浮层里、贴在选项之后。
974
+ // Status hints stay outside the scroll area so they cannot scroll out of sight.
1438
975
  state.composing === true
1439
- // ⛔ 合成期(拼音还没上屏)不许报「没有匹配的标的」:那是把"还没查"说成"查了,没有",
1440
- // 用户会以为自己的输入法打错了字。这一格说的是下一步会发生什么。
976
+ // During composition, say what happens next; never claim "no match".
1441
977
  ? h('div', { className: 'capital-watchlist-hint' }, t('composingHint'))
1442
978
  : state.searching === true || state.pendingSearch === true
1443
- // 防抖未到 / 请求在途都算"正在查":候选是空的,但那不是"没有匹配"(noHit 只在
1444
- // 当前这个词真有一次回包、且回包为空时才亮)。
979
+ // Debounce pending or request in flight both mean "searching", not "no match".
1445
980
  ? h('div', { className: 'capital-watchlist-hint' }, t('searching'))
1446
981
  : state.searchError !== null && state.searchError !== undefined
1447
- // **替换** noHit 而不是并排多一条:限流的时候说"没有匹配的标的",是把服务的故障
1448
- // 说成用户查错了。同一个 hint 槽、同一档材质,只换颜色与 role。
982
+ // Replaces noHit: a service failure must not read as "you typed it wrong".
1449
983
  ? h('div', { className: 'capital-watchlist-hint capital-watchlist-searcherror', role: 'alert', 'data-code': state.searchError.code }, t(`error.${state.searchError.code}`))
1450
984
  : state.candidates.length === 0
1451
985
  ? h('div', { className: 'capital-watchlist-hint' }, t('noHit'))
1452
986
  : visibleCandidates.length === 0
1453
- // 筛选把候选滤空了(总数 > 0):这句与 noHit 说的是两件事——票是有的,只是不在
1454
- // 这一档里。说成"没有匹配"就是把用户的筛选说成他查错了。
987
+ // Filtered to zero while candidates exist: a filter miss, not "not found".
1455
988
  ? h('div', { className: 'capital-watchlist-hint', 'data-filter': state.marketFilter }, t('filterNoHit'))
1456
989
  : null,
1457
- // 某一路挂了而其余路照常给候选:这一行说的是**那一路**(R12)。它与上面的 hint 各说
1458
- // 各的事——上面那句讲"这一屏有没有匹到",这句讲"有一路今天没参与",不互相顶替。
990
+ // Per-branch failure: separate from the "does this screen have a match" hint.
1459
991
  ...(state.searchPartial ?? []).map((branch) => h('div', {
1460
992
  key: `partial-${branch.market}`,
1461
993
  className: 'capital-watchlist-hint capital-watchlist-searcherror',
@@ -1467,17 +999,11 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1467
999
  const head = state.items.length === 0
1468
1000
  ? null
1469
1001
  : h('div', { className: 'capital-watchlist-grid capital-watchlist-head' },
1470
- // 类型不再是独立一列(并进名称列第二行),所以表头也只剩三格。
1471
1002
  h('span', null, t('colName')),
1472
- // 表头只借报价列的右对齐,不复用数据列的类(那是 14px 与 label-primary,混用一张表头三种字号)。
1473
1003
  h('span', { className: 'capital-watchlist-quote' }, t('colQuote')),
1474
- // 「操作」只管最后一列(「预测」「复盘」与「更多」三枚),居右与它们共同那条右缘对齐。
1475
1004
  h('span', { className: 'capital-watchlist-headaction' }, t('colAction')))
1476
1005
 
1477
- /**
1478
- * 空态那一格有第三种说法:**读不到清单 ≠ 没有自选股**。服务端为了不骗用户专门把域失败
1479
- * 报成 `store_unavailable`,客户端不能把一次 503 渲染成"还没有自选股,用上面的输入框添加"。
1480
- */
1006
+ /** A 503 is not an empty watchlist. */
1481
1007
  const emptyState = state.loadError !== null && state.loadError !== undefined
1482
1008
  ? h('p', { className: 'capital-watchlist-empty capital-watchlist-loaderror', role: 'alert', 'data-code': state.loadError.code }, t(`error.${state.loadError.code}`))
1483
1009
  : h('p', { className: 'capital-watchlist-empty' }, t('empty'))
@@ -1489,15 +1015,11 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1489
1015
  : h('div', { className: showDropdown ? 'capital-watchlist-list capital-watchlist-list-open' : 'capital-watchlist-list' }, state.items.map((item, index) => {
1490
1016
  const held = item.holding !== null && item.holding !== undefined
1491
1017
  const weight = item.holding?.weight_pct
1492
- // 勾选出不出现在这一行的菜单里,归 `isHoldable` 那**一份**判定管(与宿主 `/holding` 的闸门同源)。
1493
- // `|| held` 是给改判前的遗留记录(或手改出来的 JSON)留的退路:只按类型画,那一格就再也
1494
- // 取消不掉,而主 Agent 会一直把一句用户早就不认的话当成他的仓位。
1018
+ // `|| held` keeps legacy index holdings removable; host gate shares `isHoldable`.
1495
1019
  const markable = isHoldable(item) || held
1496
- // 这一格最多还能填到多少:单只上限与"120% 减掉其余持仓"取小的那一个(与宿主同一份判据)。
1020
+ // Same ceiling rule as the host; unset weight has no numeric floor.
1497
1021
  const ceiling = weightCeiling(state.items, item.thscode)
1498
- // 「未填」与 0 是两句话:没填过的那一档 `−` 置灰(按它得不到"未填",只会得到一个假的 0%)。
1499
1022
  const atFloor = !held || weight === null || weight === undefined || weight <= 0
1500
- // `+` 置灰 = 再上一档就出额度了:额度可能是单只的 100,也可能只剩 20(其余持仓占掉了 100)。
1501
1023
  const atCeil = (typeof weight === 'number' ? weight : 0) + HOLDING_STEP > ceiling
1502
1024
  return h('div', {
1503
1025
  key: item.thscode,
@@ -1505,19 +1027,16 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1505
1027
  'data-thscode': item.thscode,
1506
1028
  },
1507
1029
  h('span', { className: 'capital-watchlist-namewrap' },
1508
- // 标过持仓的行:名称标红(2026-10-09 用户点名,替代原先那枚徽标)。 颜色不是唯一
1509
- // 通道——它与"红涨"同色,所以 `title` 里同时写着"持仓 20%",悬停与读屏都拿得到同一句。
1030
+ // Held rows tint the name red; title repeats the holding as the non-color channel.
1510
1031
  h('span', {
1511
1032
  className: held ? 'capital-watchlist-name capital-watchlist-name-held' : 'capital-watchlist-name',
1512
1033
  title: nameTitle(item, t),
1513
1034
  }, item.name),
1514
- // 第二行只有代码与市场徽标两样:币种不进面板(2026-10-07),持仓也不再在这儿占一格
1515
- // (那一族徽标按市场配色,且有闸门按它的节点数列档位——多一枚进去就是替它改口径)。
1035
+ // Second line is code + market badge only; currency and holding stay out.
1516
1036
  h('span', { className: 'capital-watchlist-codeline' },
1517
1037
  h('span', { className: 'capital-watchlist-code' }, item.ticker),
1518
1038
  h(TypeTag, { assetType: item.asset_type, t }))),
1519
1039
  h(QuoteCell, { quote: item.quote, failureCode: state.failures[item.thscode], refreshing: state.refreshing, t }),
1520
- // 三个动作同住「操作」那一列(2026-10-01 用户点名):图标键在前、菜单在最后,整簇贴右缘。
1521
1040
  h('span', { className: 'capital-watchlist-rowactions' },
1522
1041
  h('button', {
1523
1042
  type: 'button',
@@ -1535,12 +1054,7 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1535
1054
  'aria-label': `${t('review')} ${item.name}`,
1536
1055
  onClick: () => appendPrompt(item, 'review'),
1537
1056
  }, h(IconListPenOutlineRegular, { size: 14 })),
1538
- // 行内「更多」收进官方 Menu(portal):就地在滚动容器里画会被裁掉,见文件头。
1539
- // 持仓这件事**全部**发生在这一格里(2026-10-09 由"另开一层表单"改过来):勾一下就是
1540
- // 持仓、再勾一下取消,勾上之后紧跟一行 `− 值 +` 按 10 个百分点调档,没有第二层弹窗。
1541
- // ⛔ 摆法只有一种:官方把 `children` 排在**所有** `items` 之后。置顶是一次性位移、
1542
- // 做完就收起,所以它住 items;勾选行、比例行与"带发丝线的移除"彼此要贴着并按这个顺序长,
1543
- // 就都得住 children(items 里放不进带键的行,也不能在它中间插一条分隔线)。
1057
+ // Menu is portaled (in-place would be clipped): items = pin, children = holding + remove.
1544
1058
  h(Menu, {
1545
1059
  open: state.rowMenu === item.thscode,
1546
1060
  anchor: h('button', {
@@ -1554,16 +1068,10 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1554
1068
  onClick: () => surface.toggleRowMenu(item.thscode),
1555
1069
  }, h(IconEllipsisOutlineRegular, { size: 14 })),
1556
1070
  items: [
1557
- // 已经在第一行就没有可置顶的位移:这一条置灰,菜单不长出一堆 "取消置顶" 的反向动作。
1558
1071
  { id: 'pin', label: t('pin'), icon: h(IconPinOutlineRegular, { size: 14 }), disabled: index === 0 },
1559
1072
  ],
1560
1073
  children: [
1561
- // 持仓那一行:图标 + 名词 + 一颗官方 `Switch`(2026-10-09 第四轮,用户点名)。
1562
- // ⛔ 整行**不是**按钮,标记与取消都只能碰那颗开关。上一版是"未标时整行可点、已标时
1563
- // 只剩圆圈可点"——两态的 DOM 形状不同,实机看就是"点一下标记,整块浮层跳了一下"。
1564
- // 这一行用 role="group"(`menu` 允许的合法子角色)而不是裸容器:那颗 `role="switch"`
1565
- // 本身不是菜单项,挂在 group 里才不冒充 menuitem。开关的视觉状态读 aria-checked
1566
- // (官方 Switch 自己画),我们不再画任何勾选态图标。
1074
+ // Holding row: official Switch only; role=group keeps it from posing as a menuitem.
1567
1075
  ...(markable ? [h('div', {
1568
1076
  key: 'holding-check',
1569
1077
  role: 'group',
@@ -1578,7 +1086,6 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1578
1086
  label: t('holdingName'),
1579
1087
  onChange: (next) => (next ? surface.markHolding(item.thscode, null) : surface.clearHolding(item.thscode)),
1580
1088
  }))] : []),
1581
- // 比例那一行只在"已标且这个类型能持有"时出现:遗留的指数持仓有取消出口,但没有调档。
1582
1089
  ...(held && isHoldable(item) ? [h('div', {
1583
1090
  key: 'holding-step',
1584
1091
  className: 'capital-watchlist-stepper',
@@ -1594,8 +1101,7 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1594
1101
  disabled: atFloor,
1595
1102
  onClick: () => surface.stepHolding(item.thscode, -HOLDING_STEP),
1596
1103
  }, '−'),
1597
- // role=status:读数改档时这是唯一会被播报出来的地方。没填就显示**剩余额度**
1598
- // (`0~N` 是数字不是文案,所以不住在字典里),不再写"未填"那一档。
1104
+ // role=status announces the readout; unset shows headroom, not "unset".
1599
1105
  h('span', {
1600
1106
  className: typeof weight === 'number' ? 'capital-watchlist-stepperreadout' : 'capital-watchlist-stepperreadout capital-watchlist-stepperplaceholder',
1601
1107
  role: 'status',
@@ -1604,16 +1110,12 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1604
1110
  type: 'button',
1605
1111
  className: 'capital-watchlist-stepperbtn',
1606
1112
  'aria-label': t('holdingStepUp'),
1607
- // 置灰的那一颗自己说为什么点不动:这一格的上限是"单只 100"还是"只剩 20",
1608
- // 用户对着一个灰键无从猜起。挂在键上而不是读数上——读数那里是播报值的地方。
1113
+ // Disabled key explains the current ceiling on itself, not on the readout.
1609
1114
  title: `0~${ceiling}`,
1610
1115
  disabled: atCeil,
1611
1116
  onClick: () => surface.stepHolding(item.thscode, HOLDING_STEP),
1612
1117
  }, '+')))] : []),
1613
- // 移除排在最后,且带一条官方发丝线:它离持仓那一簇太近就会有点错的机会,
1614
- // 而这一颗是不可撤销的(清单里少一行)。
1615
- // ⛔ 它要点掉菜单:确认层住在卡片里(z 50),菜单是 portal 出去的(z 1100),
1616
- // 不收起就会压在自家确认框上面。
1118
+ // Remove last with a separator; it must close the menu (portal z 1100 > confirm z 50).
1617
1119
  h(MenuItemButton, {
1618
1120
  key: 'remove',
1619
1121
  danger: true,
@@ -1626,9 +1128,7 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1626
1128
  }, t('remove')),
1627
1129
  ],
1628
1130
  onSelect: (id) => {
1629
- // 这一路现在**只有置顶**会进来(勾选与比例两行各自带 onClick,住在 children 里)。
1630
- // 置顶做完就该回到清单;其余动作都不许收这张菜单——收起等于把用户刚点开的东西
1631
- // 从他手上拿走,而"改比例"紧接着就要再按一次 `+`。
1131
+ // Only pin reaches this handler; closing on other actions would yank the menu away.
1632
1132
  if (id !== 'pin') return
1633
1133
  surface.closeRowMenu()
1634
1134
  surface.pin(item.thscode)
@@ -1662,14 +1162,11 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1662
1162
  },
1663
1163
  }, t('confirmOk')))))
1664
1164
 
1665
- // 面板里**只有一层**常驻遮罩(上面那颗移除确认)。持仓不再有自己那一层:
1666
- // 它整个住在「更多」菜单里,勾一下标、再勾一下取消,比例就贴在勾选行下面调。
1667
1165
  return h(Modal, {
1668
1166
  open: true,
1669
1167
  onClose: () => surface.close(),
1670
1168
  title: t('title'),
1671
- // headless:卡片、遮罩、Esc、焦点进出(含 data-modal-autofocus 的回焦)都是官方的,
1672
- // 只有默认 header/body/footer 骨架让给下面这套几何。见 CSS 顶部注释。
1169
+ // headless: official chrome, Escape and focus handling; geometry is ours.
1673
1170
  headless: true,
1674
1171
  shortcutModal: 'watchlist',
1675
1172
  className: 'capital-watchlist-dialog',
@@ -1696,7 +1193,7 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1696
1193
  onCompositionStart: () => surface.beginComposition(),
1697
1194
  onCompositionEnd: (event) => surface.endComposition(event.target.value),
1698
1195
  }),
1699
- // 有字才有清空键(空框再挂一个 X 是纯噪音)。按下不吃焦点:清完接着打字。
1196
+ // Clear key only with text; keep focus so typing can continue.
1700
1197
  state.query.length > 0
1701
1198
  ? h('button', {
1702
1199
  type: 'button',
@@ -1710,17 +1207,14 @@ function WatchlistDialog({ useDialog, surface, t, inputActions }) {
1710
1207
  dropdown),
1711
1208
 
1712
1209
  state.addError !== null && state.addError !== undefined
1713
- // 超限与歧义是"添加"这个动作的失败:留在搜索区下方(黄),且**不随改字消失**——
1714
- // `list_full` 讲的是清单满不满,跟输入框里是什么字无关。
1210
+ // Add failures stay in the search area and survive typing.
1715
1211
  ? h('p', { className: 'capital-watchlist-adderror', role: 'status', 'data-code': state.addError.code }, addErrorText(t, state.addError))
1716
1212
  : null,
1717
1213
 
1718
1214
  head,
1719
1215
  list,
1720
1216
 
1721
- // 底部一行:左列 = 「刷新报价」动词 + 它自己的整批失败行(报错跟着动词走,不再飘到输入
1722
- // 框下面、离按钮隔一整张表);右列 = 整批共用的一次更新时间(逐行重复同一个时间是噪音,
1723
- // 顶部再放一个「刷新报价」按钮也与这行重复)。
1217
+ // Footer: refresh verb + its batch error on the left, shared timestamp on the right.
1724
1218
  h('div', { className: 'capital-watchlist-footer' },
1725
1219
  h('div', { className: 'capital-watchlist-footcol' },
1726
1220
  h('button', {
@@ -1760,11 +1254,7 @@ function apply(ctx) {
1760
1254
  const value = row?.projectionValues?.agentPreset
1761
1255
  return typeof value === 'string' ? value : undefined
1762
1256
  }
1763
- /**
1764
- * 投影到达之前 `projectionValues` 整段缺席(实测:`byId` 行里没有这个键)。
1765
- * 这里对每个会话**一次性**催读 `refreshProjections`,让菜单行在投影落地后自己出现——
1766
- * 不是轮询,也没有定时器。
1767
- */
1257
+ /** Projections can arrive late; nudge once per session, no polling or timer. */
1768
1258
  const nudged = new Set()
1769
1259
  const isCapitalSession = (sessionId) => {
1770
1260
  const preset = presetOf(sessionId)
@@ -1795,11 +1285,7 @@ function apply(ctx) {
1795
1285
  scope.effect(() => scope.commandUi.register({
1796
1286
  name: 'watchlist',
1797
1287
  label: () => t('command.label'),
1798
- // 行面上**不带**英文别名(2026-09-29 用户点名删掉):菜单过滤只匹配 label / detail,命令名不参与,
1799
- // 所以过去把 `watchlist` 塞进 description 换的是「/watch 打得开这行」,代价是每个中文用户都看见
1800
- // 一行尾巴上多出的英文单词。删掉后过滤面只剩「自选股」与这句描述;typed 路径不受影响——
1801
- // Enter 认的是与注册名**完全相等**的 bare token(`dsh-client-ui-commands`:contributions act on the
1802
- // bare token only),`/watchlist`↵ 照开,`/watch`↵ 本来就不开(不是合法命令名)。
1288
+ // No English alias in the row: filtering matches label/detail, and the bare token still works.
1803
1289
  description: () => t('command.description'),
1804
1290
  icon: IconChecklistOutlineRegular,
1805
1291
  available: (session) => isCapitalSession(session.sessionId),