@huaqiu/dsh-tool-plugin-update 0.6.13 → 0.6.16

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.
@@ -1,19 +1,35 @@
1
1
  /**
2
- * Plugin-update review view (browser half).
2
+ * Plugin-update review view (browser half) — the "更新中心" panel.
3
3
  *
4
- * Plain-DOM panel (no React tree) so it can never disturb the DSH shell's
5
- * reconciliation — same rule as the sidebar entry rows. Rendered content:
4
+ * Plain-DOM (no React tree) so it can never disturb the DSH shell's
5
+ * reconciliation — the same rule as the sidebar entry rows. The layout follows
6
+ * `docs/prototype/plugin_update_ui_ux_prototype.html`; the reasoning behind the
7
+ * order is in `docs/report/plugin-update-ui-ux-baseline.md`.
6
8
  *
7
- * - head: title + intro + refresh toolbar (the shell plugin-manager head row)
8
- * - state-grouped sections, each a group head + package cards
9
- * (48px hairline-framed glyph, title row with a state tag, one-line
10
- * version transition, end actions) — the shell's card geometry
11
- * - a facts group for the independent HQ Edge runtime report
12
- * - footer actions (install / ignore / show again), with a toast that
13
- * reports the outcome of an install the way the shell's Toast does
9
+ * Information architecture, top to bottom, and why:
14
10
  *
15
- * Every surface rides a `--dsw-alias-*` design token (see VIEW_CSS_TEXT), so
16
- * the panel follows the shell's light/dark theme and its focus-ring contract.
11
+ * 1. **header** — brand, title, `版本集 vX · N 分钟前`, and the four icon
12
+ * actions (refresh · sources · job log · close). The set version and the
13
+ * relative check time live here exactly once; the body never repeats them.
14
+ * 2. **settings strip** (gear) — the two manifest URLs, editable in place.
15
+ * 3. **check-failed banner** — an error, never the silent empty state.
16
+ * 4. **active job card** — pulsing title + percent + bar + telemetry +
17
+ * 4-step pipeline, resumed on open and polled to its end.
18
+ * 5. **failed-result banner** — error code + Retry + 查看日志. The full log
19
+ * lives in the header's drawer; the *outcome* must not hide behind it.
20
+ * 6. **decision band** — one line: how many plugins, and whether the runtime
21
+ * is separately upgradable. This is the "what do I have to decide" row.
22
+ * 7. **actionable cards** — 单独更新 / 忽略 promoted onto each card; the
23
+ * collapsible drawer keeps only the changelog.
24
+ * 8. **runtime card** — amber, because a swap is not a plugin install.
25
+ * 9. **已忽略 / 已是最新 / 版本说明** — collapsed by default: nothing that
26
+ * needs no action may sit above something that does.
27
+ * 10. **footer** — the primary CTA plus its honest meta line, both reactive to
28
+ * every state (idle / installing / failed check / up to date).
29
+ *
30
+ * The job log is a drawer inside the panel (`.hqpu-log`), not an inline block:
31
+ * a full set lists one row per component, which as inline content pushed every
32
+ * actionable card below the fold.
17
33
  *
18
34
  * @module
19
35
  */
@@ -24,6 +40,7 @@ import {
24
40
  installSet,
25
41
  requestRuntimeUpdate,
26
42
  runtimeLatestVersion,
43
+ runtimeUpdateOffered,
27
44
  setPluginUpdateSource,
28
45
  setRuntimeUpdateSource,
29
46
  unignoreOffer,
@@ -34,14 +51,18 @@ import {
34
51
  type ReleaseInfo,
35
52
  type RuntimeUpdateSource,
36
53
  type UpdateJob,
54
+ type UpdateStep,
37
55
  } from './api.js'
38
56
  import { formatMb, type UpdateCopy } from './copy.js'
57
+ import { actionableUpdateCount } from './version.js'
39
58
  import {
59
+ BOLT_ICON,
40
60
  CHECK_CIRCLE_ICON,
61
+ CHEVRON_RIGHT_ICON,
41
62
  CLOSE_ICON,
42
- DOWNLOAD_ICON,
43
- INFO_ICON,
44
- PLUGIN_ICON,
63
+ COPY_ICON,
64
+ EYE_OFF_ICON,
65
+ LOG_ICON,
45
66
  REFRESH_ICON,
46
67
  WARNING_ICON,
47
68
  } from './icons.js'
@@ -53,7 +74,7 @@ export interface UpdateStoreLike {
53
74
  setInstalling(flag: boolean): void
54
75
  /** The durable job the server currently reports as active (resumed). */
55
76
  activeJob(): UpdateJob | null
56
- /** Bounded recent jobs (newest first), for the result/history view. */
77
+ /** Bounded recent jobs (newest first), for the log drawer. */
57
78
  recentJobs(): UpdateJob[]
58
79
  refresh(): Promise<PluginUpdateReport | null>
59
80
  /** Force a fresh update check (used by the failed-check Retry action). */
@@ -66,6 +87,11 @@ export interface UpdateStoreLike {
66
87
  /** How long the outcome toast stays up, in milliseconds. */
67
88
  const TOAST_HOLD_MS = 4000
68
89
 
90
+ /** Which manifest an editor belongs to — drives data attributes + CSS hook. */
91
+ export type SourceKind = 'runtime' | 'plugin'
92
+
93
+ // ── Small DOM helpers ──────────────────────────────────────────────────────
94
+
69
95
  function el<K extends keyof HTMLElementTagNameMap>(
70
96
  tag: K,
71
97
  className = '',
@@ -93,6 +119,14 @@ function iconButton(
93
119
  return button
94
120
  }
95
121
 
122
+ /** A text-link button (重新尝试, 展开版本日志, 忽略此版本 …). */
123
+ function linkButton(className: string, label: string, onClick: () => void): HTMLButtonElement {
124
+ const button = el('button', className, label)
125
+ button.type = 'button'
126
+ button.addEventListener('click', onClick)
127
+ return button
128
+ }
129
+
96
130
  /**
97
131
  * Compact a package name to what a person calls it — the shell plugin
98
132
  * manager's own rule (`presentation.ts` `shortName`), so both UIs read the
@@ -103,211 +137,30 @@ export function shortName(name: string): string {
103
137
  return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
104
138
  }
105
139
 
106
- /** The one-line version transition under a card's title. */
107
- function versionLine(item: PluginUpdateItem): string {
108
- switch (item.state) {
109
- case 'current':
110
- return item.currentVersion ?? item.availableVersion
111
- case 'install':
112
- return item.availableVersion
113
- default:
114
- // An absent plugin names only the version it would gain.
115
- return item.currentVersion === null
116
- ? item.availableVersion
117
- : `${item.currentVersion} → ${item.availableVersion}`
118
- }
119
- }
120
-
121
140
  /** Two-letter avatar initials for a card (CJK-safe). */
122
141
  function avatarInitials(item: PluginUpdateItem): string {
123
142
  const src =
124
143
  typeof item.displayName === 'string' && item.displayName.trim().length > 0
125
144
  ? item.displayName.trim()
126
145
  : shortName(item.name)
127
- if (/[⺀-鿿]/.test(src)) return src.slice(0, 2)
146
+ if (/[\u2E80-\u9FFF]/.test(src)) return src.slice(0, 2)
128
147
  const words = src.split(/[\s_\-./@]+/).filter(Boolean)
129
148
  if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase()
130
149
  return src.replace(/^@/, '').slice(0, 2).toUpperCase()
131
150
  }
132
151
 
133
- /** Shared per-card changelog toggle drawer (the per-plugin changes[] list). */
134
- function changelogDrawer(
135
- item: PluginUpdateItem,
136
- copy: UpdateCopy,
137
- open: boolean,
138
- onInstall: () => void,
139
- onIgnore: () => void,
140
- ): HTMLElement {
141
- const drawer = el('div', 'hqpu-changelog-drawer')
142
- drawer.style.display = open ? '' : 'none'
143
- drawer.append(el('div', 'hqpu-changelog-drawer-title', copy.ui.changelogTitle))
144
- const ul = el('ul')
145
- for (const change of item.changes ?? []) {
146
- if (typeof change === 'string' && change.length > 0) ul.append(el('li', '', change))
147
- }
148
- if (ul.childElementCount > 0) drawer.append(ul)
149
- const actions = el('div', 'hqpu-changelog-actions')
150
- if (item.state === 'update' || item.state === 'install') {
151
- const install = el('button', 'hqpu-card-action hqpu-card-action--install', copy.installOnePlugin)
152
- install.type = 'button'
153
- install.title = copy.installOneTip
154
- install.addEventListener('click', onInstall)
155
- const ignore = el('button', 'hqpu-card-action', copy.ignore)
156
- ignore.type = 'button'
157
- ignore.addEventListener('click', onIgnore)
158
- actions.append(install, ignore)
159
- } else if (item.state === 'ignored') {
160
- const show = el('button', 'hqpu-card-action hqpu-card-action--install', copy.unignore)
161
- show.type = 'button'
162
- show.addEventListener('click', onIgnore)
163
- actions.append(show)
164
- }
165
- if (actions.childElementCount > 0) drawer.append(actions)
166
- return drawer
167
- }
168
-
169
- /** One package card per the mockup: avatar initials, display name + package
170
- * subtitle, old→new version row (new in emerald), a 日志 toggle expanding the
171
- * inline changelog. */
172
- function packageCard(
173
- item: PluginUpdateItem,
174
- copy: UpdateCopy,
175
- open: boolean,
176
- onToggle: () => void,
177
- onInstall: () => void,
178
- onIgnore: () => void,
179
- ): HTMLLIElement {
180
- const card = el('li', 'hqpu-card')
181
- card.dataset.pluginName = item.name
182
- card.dataset.state = item.state
183
-
184
- const head = el('div', 'hqpu-card-head')
185
- const left = el('div')
186
- const avatar = el('span', 'hqpu-avatar', avatarInitials(item))
187
- avatar.setAttribute('aria-hidden', 'true')
188
-
189
- const main = el('div', 'hqpu-card-main')
190
- const titleText =
191
- typeof item.displayName === 'string' && item.displayName.trim().length > 0
192
- ? item.displayName.trim()
193
- : shortName(item.name)
194
- main.append(el('span', 'hqpu-card-title', titleText))
195
- main.append(el('span', 'hqpu-card-sub', shortName(item.name)))
196
-
197
- // old → new version row; the NEW version renders in emerald.
198
- const ver = el('div', 'hqpu-version-row')
199
- if (item.currentVersion !== null && item.currentVersion !== undefined) {
200
- ver.append(el('span', 'hqpu-ver-old', item.currentVersion))
201
- ver.append(el('span', 'hqpu-ver-arrow', '→'))
202
- }
203
- const size = formatMb(item.sizeBytes ?? 0)
204
- ver.append(
205
- el('span', 'hqpu-ver-new', size.length > 0 ? `${item.availableVersion} (${size})` : item.availableVersion),
206
- )
207
- main.append(ver)
208
- left.append(avatar, main)
209
-
210
- const right = el('div', 'hqpu-card-end')
211
- const hasDetails = (Array.isArray(item.changes) && item.changes.length > 0) ||
212
- item.state === 'update' || item.state === 'install' || item.state === 'ignored'
213
- if (hasDetails) {
214
- const toggle = el('button', 'hqpu-log-toggle')
215
- toggle.type = 'button'
216
- toggle.dataset.open = open ? 'true' : 'false'
217
- toggle.innerHTML = `${copy.ui.changelogBtn}<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6"/></svg>`
218
- toggle.addEventListener('click', onToggle)
219
- right.append(toggle)
220
- }
221
- head.append(left, right)
222
- card.append(head)
223
-
224
- // The actions live in the expanded changelog/details drawer so cards keep
225
- // their compact layout until a person asks for more information.
226
- if (hasDetails) {
227
- card.append(changelogDrawer(item, copy, open, onInstall, onIgnore))
228
- }
229
- return card
230
- }
231
-
232
- /** A section: head (title + count pill + expand-all toggle) then its cards. */
233
- function group(
234
- heading: string,
235
- items: PluginUpdateItem[],
236
- copy: UpdateCopy,
237
- changelogOpen: Set<string>,
238
- allOpen: boolean,
239
- onToggle: (name: string) => void,
240
- onToggleAll: () => void,
241
- onInstall: (name: string) => void,
242
- onIgnore: (item: PluginUpdateItem) => void,
243
- ): HTMLElement | null {
244
- if (items.length === 0) return null
245
- const section = el('section', 'hqpu-group')
246
- section.dataset.group = heading
247
-
248
- const head = el('div', 'hqpu-section-head')
249
- const titleWrap = el('div', 'hqpu-section-title')
250
- titleWrap.append(el('span', '', heading))
251
- titleWrap.append(el('span', 'hqpu-count-pill', String(items.length)))
252
- head.append(titleWrap)
253
- // Expand-all only makes sense over actionable, changelog-bearing cards.
254
- const actionable = items.some((i) => Array.isArray(i.changes) && i.changes.length > 0)
255
- if (actionable) {
256
- const all = el('button', 'hqpu-all-changelog', copy.ui.allChangelog)
257
- all.type = 'button'
258
- all.addEventListener('click', onToggleAll)
259
- head.append(all)
260
- }
261
-
262
- const list = el('ul', 'hqpu-cards')
263
- for (const item of items) {
264
- list.append(packageCard(item, copy, allOpen || changelogOpen.has(item.name), () => onToggle(item.name), () => onInstall(item.name), () => onIgnore(item)))
265
- }
266
-
267
- section.append(head, list)
268
- return section
152
+ /** The name a card shows: the manifest's displayName, else the short name. */
153
+ function displayNameOf(item: PluginUpdateItem): string {
154
+ return typeof item.displayName === 'string' && item.displayName.trim().length > 0
155
+ ? item.displayName.trim()
156
+ : shortName(item.name)
269
157
  }
270
158
 
271
- /** First-read placeholders, sharing the card and text-line layout. */
272
- function skeleton(copy: UpdateCopy): HTMLElement {
273
- const section = el('section', 'hqpu-group')
274
- section.setAttribute('role', 'status')
275
- section.dataset.loading = ''
276
- const head = el('div', 'hqpu-group-head')
277
- head.setAttribute('aria-hidden', 'true')
278
- const heading = el('span', 'hqpu-group-title hqpu-skeleton-text hqpu-skeleton-heading')
279
- const headingBar = el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar')
280
- heading.append(headingBar)
281
- head.append(heading)
282
-
283
- const list = el('ul', 'hqpu-cards')
284
- list.setAttribute('aria-hidden', 'true')
285
- for (let index = 0; index < 3; index += 1) {
286
- const card = el('li', 'hqpu-card')
287
- const cardHead = el('div', 'hqpu-card-head')
288
- const glyph = el('span', 'hqpu-card-icon hqpu-skeleton-fill hqpu-skeleton-icon')
289
- const main = el('div', 'hqpu-card-main')
290
- const titleRow = el('div', 'hqpu-title-row')
291
- const title = el('span', 'hqpu-card-title hqpu-skeleton-text hqpu-skeleton-title')
292
- title.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
293
- titleRow.append(title)
294
- const desc = el('span', 'hqpu-card-desc hqpu-skeleton-text hqpu-skeleton-desc')
295
- desc.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
296
- main.append(titleRow, desc)
297
- cardHead.append(glyph, main)
298
- const end = el('div', 'hqpu-card-end')
299
- end.setAttribute('aria-hidden', 'true')
300
- cardHead.append(end)
301
- card.append(cardHead)
302
- list.append(card)
303
- }
304
- section.append(head, list)
305
- // The skeleton replaces the whole column, so label it for screen readers.
306
- section.setAttribute('aria-label', copy.loading)
307
- return section
159
+ function isActionable(item: PluginUpdateItem): boolean {
160
+ return item.state === 'update' || item.state === 'install'
308
161
  }
309
162
 
310
- // ── Durable job views (active / result / recent) ───────────────────────────
163
+ // ── Time / size formatting ────────────────────────────────────────────────
311
164
 
312
165
  /** Format a byte count for the progress line, or null when unknown. */
313
166
  function formatBytes(n: number | undefined): string | null {
@@ -318,16 +171,7 @@ function formatBytes(n: number | undefined): string | null {
318
171
  return `${n} B`
319
172
  }
320
173
 
321
- /** Format an ETA in seconds to a short human string, or null when unknown. */
322
- function formatEta(seconds: number | undefined): string | null {
323
- if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) return null
324
- if (seconds < 60) return `${Math.round(seconds)} s`
325
- const minutes = Math.round(seconds / 60)
326
- if (minutes < 60) return `${minutes} min`
327
- return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
328
- }
329
-
330
- /** "上次检查: N 分钟前" — relative time from an ISO timestamp. */
174
+ /** "N 分钟前" — relative time from an ISO timestamp. */
331
175
  function relativeAgo(iso: string | null | undefined, copy: UpdateCopy): string | null {
332
176
  if (typeof iso !== 'string' || iso.length === 0) return null
333
177
  const then = new Date(iso).getTime()
@@ -340,6 +184,19 @@ function relativeAgo(iso: string | null | undefined, copy: UpdateCopy): string |
340
184
  return copy.ui.agoDays(Math.round(hours / 24))
341
185
  }
342
186
 
187
+ /** Short absolute time for log rows: "10/29 14:34". */
188
+ function shortWhen(iso: string | null | undefined): string {
189
+ if (typeof iso !== 'string' || iso.length === 0) return ''
190
+ const at = new Date(iso)
191
+ if (Number.isNaN(at.getTime())) return ''
192
+ return at.toLocaleString(undefined, {
193
+ month: '2-digit',
194
+ day: '2-digit',
195
+ hour: '2-digit',
196
+ minute: '2-digit',
197
+ })
198
+ }
199
+
343
200
  function jobKindLabel(job: UpdateJob, copy: UpdateCopy): string {
344
201
  return job.kind === 'runtime' ? copy.job.kindRuntime : copy.job.kindPluginSet
345
202
  }
@@ -349,39 +206,13 @@ function phaseLabel(job: UpdateJob, copy: UpdateCopy): string {
349
206
  return copy.job.phase[job.phase] ?? job.phase
350
207
  }
351
208
 
352
- /** One per-step line for plugin-set installs (and any job that reports steps). */
353
- function stepsList(job: UpdateJob, copy: UpdateCopy): HTMLElement | null {
354
- if (!Array.isArray(job.steps) || job.steps.length === 0) return null
355
- const wrap = el('div', 'hqpu-job-steps')
356
- wrap.append(el('p', 'hqpu-group-count', copy.job.stepsTitle))
357
- const ol = el('ol', 'hqpu-steps')
358
- for (const step of job.steps) {
359
- const li = el('li', `hqpu-step hqpu-step--${step.state}`)
360
- const dot = el('span', 'hqpu-dot')
361
- dot.dataset.state =
362
- step.state === 'installed'
363
- ? 'done'
364
- : step.state === 'failed'
365
- ? 'error'
366
- : step.state === 'running'
367
- ? 'warning'
368
- : 'idle'
369
- li.append(dot)
370
- const text = step.message && step.message.length > 0 ? `${step.name} — ${step.message}` : step.name
371
- li.append(el('span', '', text))
372
- ol.append(li)
373
- }
374
- wrap.append(ol)
375
- return wrap
376
- }
377
-
378
209
  /** Map a job phase onto the 4-step indicator index (下载/校验/替换/重启). */
379
210
  function stepIndex(phase: UpdateJob['phase']): number {
380
211
  switch (phase) {
381
212
  case 'downloading':
213
+ case 'resolving':
382
214
  return 0
383
215
  case 'verifying':
384
- case 'resolving':
385
216
  return 1
386
217
  case 'staging':
387
218
  case 'activating':
@@ -391,25 +222,228 @@ function stepIndex(phase: UpdateJob['phase']): number {
391
222
  case 'verifying-after-restart':
392
223
  return 3
393
224
  case 'queued':
394
- return -1
395
225
  default:
396
226
  return -1
397
227
  }
398
228
  }
399
229
 
230
+ // ── Section pieces ────────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * A collapsible section: chevron + title + count, an optional right-hand note,
234
+ * and a body that only exists in the DOM while open.
235
+ *
236
+ * Native `<details>` was the alternative, but it cannot express the
237
+ * "collapsed by default, opened by state" rule the 已是最新 group needs without
238
+ * a JS patch anyway, and its summary cannot host the trailing note in one row.
239
+ */
240
+ function disclosure(opts: {
241
+ id: string
242
+ title: string
243
+ count?: string
244
+ note?: string
245
+ open: boolean
246
+ onToggle: () => void
247
+ build: () => HTMLElement[]
248
+ }): HTMLElement {
249
+ const section = el('section', 'hqpu-disc')
250
+ // The title doubles as the group hook (`[data-group="已是最新"]`), matching
251
+ // the actionable group's convention so callers need one selector style.
252
+ section.dataset.group = opts.title
253
+ const head = el('button', 'hqpu-disc-head')
254
+ head.type = 'button'
255
+ head.setAttribute('aria-expanded', opts.open ? 'true' : 'false')
256
+ head.setAttribute('aria-controls', opts.id)
257
+
258
+ const left = el('span', 'hqpu-disc-left')
259
+ const chevron = el('span', 'hqpu-disc-chevron')
260
+ chevron.innerHTML = CHEVRON_RIGHT_ICON(12)
261
+ left.append(chevron, el('span', 'hqpu-disc-title', opts.title))
262
+ if (opts.count !== undefined) left.append(el('span', 'hqpu-disc-count', opts.count))
263
+ head.append(left)
264
+ if (opts.note !== undefined && opts.note.length > 0) {
265
+ head.append(el('span', 'hqpu-disc-note', opts.note))
266
+ }
267
+ head.addEventListener('click', opts.onToggle)
268
+ section.append(head)
269
+
270
+ if (opts.open) {
271
+ const body = el('div', 'hqpu-disc-body')
272
+ body.id = opts.id
273
+ for (const node of opts.build()) body.append(node)
274
+ section.append(body)
275
+ }
276
+ return section
277
+ }
278
+
279
+ /** One muted row in a collapsed group (current or ignored plugin). */
280
+ function pluginRow(
281
+ item: PluginUpdateItem,
282
+ copy: UpdateCopy,
283
+ onToggleIgnore: () => void,
284
+ ): HTMLLIElement {
285
+ const row = el('li', 'hqpu-row')
286
+ row.dataset.pluginName = item.name
287
+ row.dataset.state = item.state
288
+ row.append(el('span', 'hqpu-row-name', displayNameOf(item)))
289
+ const end = el('span', 'hqpu-row-end')
290
+ const version = item.currentVersion ?? item.availableVersion
291
+ end.append(el('span', 'hqpu-row-meta', copy.ui.upToDateRow(version)))
292
+ if (item.state === 'ignored') {
293
+ end.append(
294
+ linkButton('hqpu-link hqpu-link--tiny', copy.unignore, onToggleIgnore),
295
+ )
296
+ }
297
+ row.append(end)
298
+ return row
299
+ }
300
+
301
+ /**
302
+ * One actionable plugin card.
303
+ *
304
+ * Actions live on the card, not inside the changelog drawer: the drawer is for
305
+ * content (what changed), and hiding 单独更新 / 忽略 behind a disclosure made
306
+ * the set-level footer CTA the only discoverable action.
307
+ */
308
+ function pluginCard(
309
+ item: PluginUpdateItem,
310
+ copy: UpdateCopy,
311
+ opts: { changesOpen: boolean; onToggleChanges: () => void; onInstall: () => void; onIgnore: () => void; busy: boolean },
312
+ ): HTMLLIElement {
313
+ const card = el('li', 'hqpu-card')
314
+ card.dataset.pluginName = item.name
315
+ card.dataset.state = item.state
316
+
317
+ const head = el('div', 'hqpu-card-head')
318
+ const id = el('div', 'hqpu-card-id')
319
+ const avatar = el('span', 'hqpu-avatar', avatarInitials(item))
320
+ avatar.setAttribute('aria-hidden', 'true')
321
+ const main = el('div', 'hqpu-card-main')
322
+ main.append(el('span', 'hqpu-card-title', displayNameOf(item)))
323
+ main.append(el('span', 'hqpu-card-sub', shortName(item.name)))
324
+ id.append(avatar, main)
325
+
326
+ const end = el('div', 'hqpu-card-end')
327
+ const install = el(
328
+ 'button',
329
+ 'hqpu-card-action hqpu-card-action--install',
330
+ item.state === 'install' ? copy.ui.singleInstall : copy.ui.singleUpdate,
331
+ )
332
+ install.type = 'button'
333
+ install.title = copy.ui.singleUpdateTip
334
+ install.disabled = opts.busy
335
+ install.addEventListener('click', opts.onInstall)
336
+ const ignore = iconButton(
337
+ 'hqpu-icon-btn hqpu-icon-btn--sm',
338
+ EYE_OFF_ICON(14),
339
+ copy.ui.ignorePlugin,
340
+ opts.onIgnore,
341
+ )
342
+ ignore.disabled = opts.busy
343
+ end.append(install, ignore)
344
+ head.append(id, end)
345
+ card.append(head)
346
+
347
+ // Version row + the changelog toggle, on one line.
348
+ const foot = el('div', 'hqpu-card-foot')
349
+ const ver = el('div', 'hqpu-version-row')
350
+ if (item.currentVersion !== null && item.currentVersion !== undefined) {
351
+ ver.append(el('span', 'hqpu-ver-old', item.currentVersion))
352
+ ver.append(el('span', 'hqpu-ver-arrow', '→'))
353
+ }
354
+ ver.append(el('span', 'hqpu-ver-new', item.availableVersion))
355
+ const size = formatMb(item.sizeBytes ?? 0)
356
+ if (size.length > 0) ver.append(el('span', 'hqpu-ver-size', `(${size})`))
357
+ foot.append(ver)
358
+
359
+ const changes = Array.isArray(item.changes)
360
+ ? item.changes.filter((c) => typeof c === 'string' && c.length > 0)
361
+ : []
362
+ if (changes.length > 0) {
363
+ const toggle = el('button', 'hqpu-toggle')
364
+ toggle.type = 'button'
365
+ toggle.dataset.changesToggle = ''
366
+ toggle.dataset.open = opts.changesOpen ? 'true' : 'false'
367
+ toggle.setAttribute('aria-expanded', opts.changesOpen ? 'true' : 'false')
368
+ toggle.append(el('span', '', copy.ui.changesToggle))
369
+ const chevron = el('span', 'hqpu-toggle-chevron')
370
+ chevron.innerHTML = CHEVRON_RIGHT_ICON(12)
371
+ toggle.append(chevron)
372
+ toggle.addEventListener('click', opts.onToggleChanges)
373
+ foot.append(toggle)
374
+ }
375
+ card.append(foot)
376
+
377
+ if (changes.length > 0 && opts.changesOpen) {
378
+ const drawer = el('div', 'hqpu-changes')
379
+ drawer.append(el('p', 'hqpu-changes-title', copy.ui.changesTitle(item.availableVersion)))
380
+ const ul = el('ul')
381
+ for (const change of changes) ul.append(el('li', '', change))
382
+ drawer.append(ul)
383
+ card.append(drawer)
384
+ }
385
+ return card
386
+ }
387
+
388
+ /** The "what do I actually have to decide" band, above every list. */
389
+ function decisionBand(report: PluginUpdateReport, copy: UpdateCopy): HTMLElement | null {
390
+ const plugins = actionableUpdateCount(report)
391
+ const runtime = runtimeUpdateOffered(report.runtime)
392
+ if (plugins === 0 && !runtime) return null
393
+ const band = el('div', 'hqpu-decision')
394
+ band.dataset.role = 'decision-band'
395
+ if (plugins > 0) band.append(el('span', 'hqpu-decision-main', copy.ui.summaryPlugins(plugins)))
396
+ if (plugins > 0 && runtime) {
397
+ const dot = el('span', 'hqpu-decision-dot')
398
+ dot.setAttribute('aria-hidden', 'true')
399
+ band.append(dot)
400
+ }
401
+ if (runtime) band.append(el('span', 'hqpu-decision-sub', copy.ui.summaryRuntime))
402
+ return band
403
+ }
404
+
400
405
  /**
401
- * The ACTIVE-JOB banner per the mockup: pulsing brand dot + job title with the
402
- * target version, mono percentage, a gradient bar only when `percent != null`,
403
- * phase + speed line ("3.1 MB/s (286MB / 450MB)"), and a 4-step indicator row
404
- * (done=emerald, active=pulsing brand, upcoming=muted).
406
+ * Check-status banner. A FAILED check is a red alert with its reason and a
407
+ * retry — never the silent "no updates" empty state, which reads as success.
408
+ */
409
+ function checkBanner(
410
+ report: PluginUpdateReport,
411
+ copy: UpdateCopy,
412
+ onRetry: () => void,
413
+ ): HTMLElement | null {
414
+ const failed = typeof report.lastError === 'string' && report.lastError.length > 0
415
+ if (!failed) return null
416
+ const section = el('section', 'hqpu-group')
417
+ section.dataset.group = 'check-status'
418
+ const card = el('div', 'hqpu-error')
419
+ card.dataset.tone = 'danger'
420
+ const head = el('div', 'hqpu-error-head')
421
+ const icon = el('span', 'hqpu-error-icon')
422
+ icon.innerHTML = WARNING_ICON(14)
423
+ const text = el('div', 'hqpu-error-text')
424
+ text.append(el('p', 'hqpu-error-title', copy.checkFailedTitle))
425
+ text.append(el('p', 'hqpu-error-body', report.lastError as string))
426
+ head.append(icon, text)
427
+ card.append(head)
428
+
429
+ const foot = el('div', 'hqpu-error-foot')
430
+ if (report.items.length > 0) foot.append(el('span', '', copy.staleNote))
431
+ foot.append(linkButton('hqpu-link', copy.retryNow, onRetry))
432
+ card.append(foot)
433
+ section.append(card)
434
+ return section
435
+ }
436
+
437
+ /**
438
+ * The active job card: title (with the target version) + percent, a
439
+ * determinate bar when the server publishes one, phase + telemetry, the honest
440
+ * "closing does not cancel" note, and the 4-step pipeline.
405
441
  */
406
442
  function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
407
443
  const section = el('section', 'hqpu-group')
408
444
  section.dataset.group = 'active-job'
409
-
410
445
  const card = el('div', 'hqpu-active')
411
446
 
412
- // Top row: pulse dot + title, mono percent.
413
447
  const top = el('div', 'hqpu-active-top')
414
448
  const titleWrap = el('div', 'hqpu-active-title')
415
449
  const pulse = el('span', 'hqpu-pulse-dot')
@@ -418,23 +452,24 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
418
452
  job.kind === 'runtime'
419
453
  ? copy.ui.jobRuntimeTitle(job.targetVersion)
420
454
  : copy.ui.jobPluginTitle(job.targetVersion)
421
- titleWrap.append(pulse, el('span', '', title))
455
+ titleWrap.append(pulse, el('span', 'hqpu-active-label', title))
456
+ top.append(titleWrap)
422
457
  const pct = job.progress.percent
423
- if (pct !== null) {
424
- top.append(el('span', 'hqpu-active-percent', `${Math.round(pct)}%`))
425
- }
458
+ if (pct !== null) top.append(el('span', 'hqpu-active-percent', `${Math.round(pct)}%`))
426
459
  card.append(top)
427
460
 
428
- // Gradient bar — only when the server publishes a real percentage.
429
461
  if (pct !== null) {
430
462
  const bar = el('div', 'hqpu-progress')
463
+ bar.setAttribute('role', 'progressbar')
464
+ bar.setAttribute('aria-valuemin', '0')
465
+ bar.setAttribute('aria-valuemax', '100')
466
+ bar.setAttribute('aria-valuenow', String(Math.round(pct)))
431
467
  const fill = el('div', 'hqpu-progress-fill')
432
468
  fill.style.width = `${Math.min(100, Math.max(0, pct))}%`
433
469
  bar.append(fill)
434
470
  card.append(bar)
435
471
  }
436
472
 
437
- // Phase text + telemetry line: "3.1 MB/s (286MB / 450MB)".
438
473
  const meta = el('div', 'hqpu-active-meta')
439
474
  const phaseText =
440
475
  typeof job.message === 'string' && job.message.length > 0 ? job.message : phaseLabel(job, copy)
@@ -450,28 +485,29 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
450
485
  : done !== null && total !== null
451
486
  ? copy.job.bytes(done, total)
452
487
  : ''
453
- if (telemetry.length > 0) meta.append(el('span', '', telemetry))
488
+ if (telemetry.length > 0) meta.append(el('span', 'hqpu-num', telemetry))
454
489
  card.append(meta)
455
490
 
456
- // previous → target transition + what happens next (restart / elevation),
457
- // and the honest "closing does not cancel" note.
458
- const transition = el('div', 'hqpu-active-meta')
459
- transition.append(
491
+ // previous → target on its own line (mono), then what happens next. A
492
+ // plugin set keeps the "closing does not cancel" note; a runtime swap has a
493
+ // stronger one of its own (HQ Edge exits, the host swaps it out).
494
+ card.append(
460
495
  el(
461
- 'span',
462
- '',
496
+ 'div',
497
+ 'hqpu-active-versions',
463
498
  job.previousVersion !== null && job.previousVersion.length > 0
464
499
  ? `${job.previousVersion} → ${job.targetVersion}`
465
500
  : job.targetVersion,
466
501
  ),
467
502
  )
468
- transition.append(
469
- el('span', '', job.kind === 'runtime' ? copy.job.willSwapRuntime : copy.job.willRestartDsh),
503
+ card.append(
504
+ el(
505
+ 'p',
506
+ 'hqpu-active-note',
507
+ job.kind === 'runtime' ? copy.job.willSwapRuntime : copy.ui.activeCloseNote,
508
+ ),
470
509
  )
471
- card.append(transition)
472
- card.append(el('p', 'hqpu-job-meta', copy.job.closeNote))
473
510
 
474
- // 4-step indicator: done=emerald, active=pulsing brand, upcoming=muted.
475
511
  const current = stepIndex(job.phase)
476
512
  const stepsRow = el('div', 'hqpu-job-steps4')
477
513
  copy.ui.jobSteps.forEach((label: string, index: number) => {
@@ -483,7 +519,9 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
483
519
  : index === current
484
520
  ? 'hqpu-job-step--active'
485
521
  : 'hqpu-job-step--todo'
486
- stepsRow.append(el('div', cls, label))
522
+ // Numbered like the prototype's pipeline ("1. 下载"): the count is the
523
+ // only cue for how much is left when the active step blinks.
524
+ stepsRow.append(el('div', cls, `${index + 1}. ${label}`))
487
525
  })
488
526
  card.append(stepsRow)
489
527
 
@@ -491,226 +529,410 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
491
529
  return section
492
530
  }
493
531
 
532
+ /** Phase → the word the log rows use for a component. */
533
+ function stepWord(state: UpdateStep['state'], copy: UpdateCopy): string {
534
+ switch (state) {
535
+ case 'installed':
536
+ return copy.job.phase.done ?? ''
537
+ case 'skipped':
538
+ return copy.ui.stepSkipped
539
+ case 'failed':
540
+ return copy.job.phase.failed ?? ''
541
+ case 'running':
542
+ return copy.job.phase.downloading ?? ''
543
+ default:
544
+ return copy.ui.stepPending
545
+ }
546
+ }
547
+
548
+ /** Dot tone for one step row. */
549
+ function stepDotState(state: UpdateStep['state']): string {
550
+ switch (state) {
551
+ case 'installed':
552
+ return 'done'
553
+ case 'failed':
554
+ return 'error'
555
+ case 'running':
556
+ return 'warning'
557
+ default:
558
+ return 'idle'
559
+ }
560
+ }
561
+
494
562
  /**
495
- * The RESULT view for a terminal job: success / failed / rolled-back /
496
- * abandoned, previous→installed, per-package steps, errorCode + message, a log
497
- * correlation hint, and retry guidance (a retry starts a NEW job).
563
+ * The terminal-result banner for a job that did NOT succeed.
564
+ *
565
+ * A successful install needs no banner — the list and the footer already say
566
+ * "everything is current". A failure does: the error code and the retry must be
567
+ * on screen, not one drawer-click away.
498
568
  */
499
- function resultView(job: UpdateJob, copy: UpdateCopy, onRetry: () => void): HTMLElement {
569
+ function jobResultBanner(
570
+ job: UpdateJob,
571
+ copy: UpdateCopy,
572
+ onRetry: () => void,
573
+ onOpenLog: () => void,
574
+ ): HTMLElement {
500
575
  const section = el('section', 'hqpu-group')
501
576
  section.dataset.group = 'job-result'
502
-
503
- const tone =
504
- job.phase === 'done' ? 'success' : job.phase === 'failed' ? 'danger' : 'warning'
505
- const titleText =
506
- job.phase === 'done'
507
- ? copy.job.resultDone
508
- : job.phase === 'failed'
509
- ? copy.job.resultFailed
510
- : job.phase === 'rolled-back'
511
- ? copy.job.resultRolledBack
512
- : copy.job.resultAbandoned
513
-
514
- const head = el('div', 'hqpu-group-head')
515
- const tag = el('span', 'hqpu-tag')
516
- tag.dataset.tone = tone
517
- tag.textContent = titleText
518
- head.append(el('h3', 'hqpu-group-title', `${jobKindLabel(job, copy)} ${job.targetVersion}`), tag)
519
- section.append(head)
520
-
521
- const card = el('div', 'hqpu-job')
577
+ const tone = job.phase === 'failed' ? 'danger' : 'warning'
578
+ const card = el('div', 'hqpu-error')
579
+ card.dataset.tone = tone
580
+
581
+ const head = el('div', 'hqpu-error-head')
582
+ const icon = el('span', 'hqpu-error-icon')
583
+ icon.innerHTML = WARNING_ICON(14)
584
+ const text = el('div', 'hqpu-error-text')
585
+ const title =
586
+ job.phase === 'rolled-back' ? copy.job.resultRolledBack : copy.job.resultFailed
587
+ text.append(el('p', 'hqpu-error-title', title))
522
588
  const res = job.result
523
589
  const from = job.previousVersion ?? res?.installedVersion ?? '—'
524
590
  const to = res?.installedVersion ?? job.targetVersion
525
- const finished =
526
- res !== undefined && res.finishedAt.length > 0
527
- ? ` · ${new Date(res.finishedAt).toLocaleString()}`
528
- : ''
529
- card.append(el('p', 'hqpu-status', `${from} → ${to}${finished}`))
530
- if (res !== undefined && res.message !== undefined && res.message.length > 0) {
531
- card.append(el('p', 'hqpu-status', res.message))
591
+ const when = res !== undefined && res.finishedAt.length > 0 ? ` · ${shortWhen(res.finishedAt)}` : ''
592
+ text.append(el('p', 'hqpu-error-body', `${from} → ${to}${when}`))
593
+ if (res?.message !== undefined && res.message.length > 0) {
594
+ text.append(el('p', 'hqpu-error-body', res.message))
532
595
  }
533
- if (res !== undefined && res.errorCode !== undefined && res.errorCode.length > 0) {
534
- card.append(el('p', 'hqpu-status', res.errorCode))
596
+ if (res?.errorCode !== undefined && res.errorCode.length > 0) {
597
+ text.append(el('code', 'hqpu-error-code', res.errorCode))
535
598
  }
536
- if (typeof job.logHint === 'string' && job.logHint.length > 0) {
537
- card.append(el('p', 'hqpu-status hqpu-job-meta', copy.job.logHint(job.logHint)))
538
- }
539
-
540
- const steps = stepsList(job, copy)
541
- if (steps !== null) card.append(steps)
599
+ head.append(icon, text)
600
+ card.append(head)
542
601
 
543
- // Retry starts a NEW update job — never re-click the settled job id.
544
- if (job.phase === 'failed' || job.phase === 'abandoned') {
545
- const retry = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.job.retry)
546
- retry.type = 'button'
547
- retry.addEventListener('click', onRetry)
548
- card.append(retry)
549
- }
602
+ const foot = el('div', 'hqpu-error-foot')
603
+ foot.append(el('span', '', jobKindLabel(job, copy)))
604
+ const actions = el('span', 'hqpu-error-actions')
605
+ actions.append(linkButton('hqpu-link', copy.ui.viewLog, onOpenLog))
606
+ actions.append(linkButton('hqpu-link', copy.job.retry, onRetry))
607
+ foot.append(actions)
608
+ card.append(foot)
550
609
  section.append(card)
551
610
  return section
552
611
  }
553
612
 
554
- /** A one-line row for older recent jobs (newest already shown as the card). */
555
- function recentJobsList(jobs: UpdateJob[], copy: UpdateCopy): HTMLElement | null {
556
- const older = jobs.slice(1, 5)
557
- if (older.length === 0) return null
558
- const wrap = el('div', 'hqpu-group')
559
- wrap.dataset.group = 'recent-jobs'
560
- wrap.append(el('h3', 'hqpu-group-count', copy.job.recentTitle))
561
- const ul = el('ul', 'hqpu-cards')
562
- for (const job of older) {
563
- const li = el('li', 'hqpu-status')
564
- const when = job.updatedAt.length > 0 ? ` · ${new Date(job.updatedAt).toLocaleString()}` : ''
565
- li.textContent = `${jobKindLabel(job, copy)} ${job.targetVersion} · ${phaseLabel(job, copy)}${when}`
566
- ul.append(li)
567
- }
568
- wrap.append(ul)
569
- return wrap
570
- }
571
-
572
613
  /**
573
- * Check-status banner: a FAILED check shows a clear error row + Retry (never
574
- * the silent "no updates" empty state); a successful check shows the time;
575
- * when a check failed but a prior report exists it is labelled stale.
614
+ * The host's own report of the last runtime swap. Rolled-back / failed renders
615
+ * as an AMBER alert card; succeeded renders as a quiet green line. The plugin
616
+ * has no log-open affordance for this, so none is invented.
576
617
  */
577
- function checkBanner(
618
+ function runtimeResultAlert(
578
619
  report: PluginUpdateReport,
579
620
  copy: UpdateCopy,
580
- onRetry: () => void,
621
+ dismissed: boolean,
622
+ onDismiss: () => void,
581
623
  ): HTMLElement | null {
582
- const failed = typeof report.lastError === 'string' && report.lastError.length > 0
583
- const checked = typeof report.checkedAt === 'string' && report.checkedAt.length > 0
584
- if (!failed && !checked) return null
624
+ const lastResult = report.runtime.lastResult
625
+ if (lastResult === null || lastResult === undefined) return null
585
626
 
586
- const wrap = el('section', 'hqpu-group')
587
- wrap.dataset.group = 'check-status'
588
- if (failed) {
589
- const banner = el('div', 'hqpu-banner')
590
- banner.dataset.tone = 'danger'
591
- banner.innerHTML = WARNING_ICON(14)
592
- banner.append(el('span', '', copy.checkFailedBanner(report.lastError as string)))
593
- wrap.append(banner)
594
- if (report.items.length > 0) {
595
- wrap.append(el('p', 'hqpu-status', copy.staleNote))
596
- }
597
- const row = el('div', 'hqpu-actions')
598
- const retry = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', copy.retry)
599
- retry.type = 'button'
600
- retry.addEventListener('click', onRetry)
601
- row.append(retry)
602
- wrap.append(row)
603
- return wrap
627
+ if (lastResult.state === 'succeeded') {
628
+ const line = el('p', 'hqpu-alert-green')
629
+ line.dataset.role = 'runtime-success'
630
+ line.textContent = copy.ui.successInline
631
+ return line
604
632
  }
605
- wrap.append(
606
- el('p', 'hqpu-status', copy.checkedAt(new Date(report.checkedAt as string).toLocaleString())),
607
- )
608
- return wrap
609
- }
610
-
611
- /** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
612
- function releaseHeader(release: ReleaseInfo | null | undefined, copy: UpdateCopy): HTMLElement | null {
613
- if (release === null || release === undefined) return null
614
- const notes = Array.isArray(release.notes) ? release.notes.filter((n) => typeof n === 'string' && n.length > 0) : []
615
- const breaking = Array.isArray(release.breaking)
616
- ? release.breaking.filter((n) => typeof n === 'string' && n.length > 0)
617
- : []
618
- const hasContent =
619
- (typeof release.summary === 'string' && release.summary.length > 0) ||
620
- notes.length > 0 ||
621
- breaking.length > 0 ||
622
- typeof release.notesUrl === 'string' ||
623
- typeof release.publishedAt === 'string'
624
- if (!hasContent) return null
633
+ if (dismissed) return null
625
634
 
626
635
  const section = el('section', 'hqpu-group')
627
- section.dataset.group = 'release'
628
- section.append(el('h3', 'hqpu-group-title', copy.release.summaryTitle))
629
- if (typeof release.summary === 'string' && release.summary.length > 0) {
630
- section.append(el('p', 'hqpu-status', release.summary))
631
- }
632
- if (typeof release.publishedAt === 'string' && release.publishedAt.length > 0) {
633
- section.append(el('p', 'hqpu-status', copy.release.publishedAt(new Date(release.publishedAt).toLocaleString())))
634
- }
635
- if (release.requiresRestart === true) {
636
- section.append(el('p', 'hqpu-status', copy.job.willRestartDsh))
637
- }
638
- if (notes.length > 0) {
639
- const ul = el('ul', 'hqpu-release-notes')
640
- for (const note of notes) ul.append(el('li', '', note as string))
641
- section.append(ul)
636
+ section.dataset.group = 'runtime-alert'
637
+ const card = el('div', 'hqpu-alert hqpu-alert--amber')
638
+ const icon = el('span', 'hqpu-alert-icon')
639
+ icon.innerHTML = WARNING_ICON(14)
640
+ card.append(icon)
641
+
642
+ const body = el('div', 'hqpu-alert-main')
643
+ body.append(
644
+ el(
645
+ 'div',
646
+ 'hqpu-alert-title',
647
+ lastResult.state === 'failed' ? copy.ui.failedTitle : copy.ui.rollbackTitle,
648
+ ),
649
+ )
650
+ const oldVer = lastResult.installedVersion ?? lastResult.targetVersion ?? '—'
651
+ const bodyP = el('p', 'hqpu-alert-body')
652
+ bodyP.append(
653
+ document.createTextNode(
654
+ lastResult.state === 'failed'
655
+ ? copy.ui.failedBody(oldVer, '')
656
+ : copy.ui.rollbackBody(oldVer, ''),
657
+ ),
658
+ )
659
+ if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
660
+ bodyP.append(el('code', '', lastResult.errorCode))
642
661
  }
643
- if (breaking.length > 0) {
644
- const banner = el('div', 'hqpu-banner')
645
- banner.dataset.tone = 'danger'
646
- banner.innerHTML = WARNING_ICON(14)
647
- const inner = el('span')
648
- inner.append(el('strong', '', `${copy.release.breakingTitle}: `))
649
- inner.append(document.createTextNode(breaking.join(' · ')))
650
- banner.append(inner)
651
- section.append(banner)
652
- }
653
- if (typeof release.notesUrl === 'string' && release.notesUrl.length > 0) {
654
- const link = el('a', 'hqpu-status hqpu-release-link', copy.release.notesLink)
655
- link.href = release.notesUrl
656
- link.target = '_blank'
657
- link.rel = 'noreferrer'
658
- section.append(link)
662
+ if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
663
+ bodyP.append(el('br'), document.createTextNode(lastResult.message))
659
664
  }
665
+ body.append(bodyP)
666
+ const actions = el('div', 'hqpu-alert-actions')
667
+ actions.append(linkButton('hqpu-alert-dismiss', copy.ui.dismiss, onDismiss))
668
+ body.append(actions)
669
+ card.append(body)
670
+ section.append(card)
660
671
  return section
661
672
  }
662
673
 
663
- /** Native disclosure that hides the developer-facing source editors by
664
- * default (end users should never need to type a manifest URL). */
665
- function advancedBlock(copy: UpdateCopy, nodes: Array<HTMLElement | null>): HTMLElement {
666
- const details = el('details', 'hqpu-advanced')
667
- details.append(el('summary', '', copy.release.advanced))
668
- for (const node of nodes) {
669
- if (node !== null) details.append(node)
674
+ /**
675
+ * The HQ Edge runtime card: current chip, the offered version with its size and
676
+ * summary, and the 升级核心 action — arm → confirm, because a runtime swap
677
+ * shuts HQ Edge down and is not undoable from here.
678
+ */
679
+ function runtimeEngineCard(
680
+ report: PluginUpdateReport,
681
+ copy: UpdateCopy,
682
+ actions: RuntimeActions,
683
+ ): HTMLElement | null {
684
+ const runtime = report.runtime
685
+ const latestVersion = runtimeLatestVersion(runtime)
686
+ const card = el('section', 'hqpu-engine')
687
+ card.dataset.group = 'runtime'
688
+
689
+ const head = el('div', 'hqpu-engine-head')
690
+ const titleWrap = el('div', 'hqpu-engine-title')
691
+ const bolt = el('span', 'hqpu-engine-bolt')
692
+ bolt.innerHTML = BOLT_ICON(14)
693
+ titleWrap.append(bolt, el('span', '', copy.ui.runtimeEngine))
694
+ head.append(titleWrap)
695
+ if (typeof runtime.currentVersion === 'string' && runtime.currentVersion.length > 0) {
696
+ head.append(el('span', 'hqpu-chip', copy.ui.currentChip(runtime.currentVersion)))
697
+ }
698
+ card.append(head)
699
+
700
+ const offered = runtime.updateAvailable && latestVersion !== null
701
+ if (!offered && typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
702
+ card.append(el('p', 'hqpu-engine-error', `${copy.lastError}: ${runtime.lastError}`))
703
+ return card
704
+ }
705
+ if (!offered) return card
706
+
707
+ const latestEntry = runtime.latest
708
+ const row = el('div', 'hqpu-engine-row')
709
+ const info = el('div', 'hqpu-engine-info')
710
+ const newLine = el('div', 'hqpu-engine-new')
711
+ newLine.append(el('span', 'hqpu-ver-new hqpu-ver-new--lg', copy.ui.newVersion(latestVersion as string)))
712
+ const hasCritical =
713
+ (Array.isArray(latestEntry?.breaking) && (latestEntry?.breaking?.length ?? 0) > 0) ||
714
+ (Array.isArray(latestEntry?.notes) && (latestEntry?.notes?.length ?? 0) > 0)
715
+ if (hasCritical) newLine.append(el('span', 'hqpu-tag--brand', copy.ui.breakingTag))
716
+ info.append(newLine)
717
+ const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
718
+ const summary = typeof latestEntry?.summary === 'string' ? latestEntry.summary : ''
719
+ if (size.length > 0 || summary.length > 0) {
720
+ info.append(el('p', 'hqpu-engine-size', copy.ui.sizeLine(size, summary)))
721
+ }
722
+ row.append(info)
723
+
724
+ if (actions.accepted) {
725
+ row.append(el('span', 'hqpu-engine-state', copy.runtime.updateAccepted))
726
+ } else if (actions.busy) {
727
+ row.append(el('span', 'hqpu-engine-state', copy.runtime.updating))
728
+ } else if (!actions.armed) {
729
+ const start = el('button', 'hqpu-btn--engine', copy.ui.upgradeCore)
730
+ start.type = 'button'
731
+ start.dataset.runtimeUpdate = ''
732
+ start.addEventListener('click', actions.onArm)
733
+ row.append(start)
670
734
  }
671
- return details
735
+ card.append(row)
736
+
737
+ // Confirm replaces the action: a full-width block, never a 150 px column.
738
+ if (actions.armed && !actions.busy && !actions.accepted) {
739
+ const confirm = el('div', 'hqpu-engine-confirm')
740
+ confirm.append(
741
+ el('p', 'hqpu-engine-confirm-text', copy.runtime.updateConfirm(latestVersion as string)),
742
+ )
743
+ const buttons = el('div', 'hqpu-engine-confirm-actions')
744
+ const no = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.cancel)
745
+ no.type = 'button'
746
+ no.addEventListener('click', actions.onCancel)
747
+ const yes = el('button', 'hqpu-btn--engine hqpu-btn--engine-solid', copy.ui.confirmRuntime)
748
+ yes.type = 'button'
749
+ yes.dataset.runtimeConfirm = ''
750
+ yes.addEventListener('click', actions.onConfirm)
751
+ buttons.append(no, yes)
752
+ confirm.append(buttons)
753
+ card.append(confirm)
754
+ }
755
+
756
+ const note = el('p', 'hqpu-engine-note')
757
+ note.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2L14.4 13.4H1.6L8 2Z"/><path d="M8 6.4V9.2"/><path d="M8 10.6V11.6"/></svg>`
758
+ note.append(el('span', '', copy.ui.runtimeRestartNote))
759
+ card.append(note)
760
+ return card
672
761
  }
673
762
 
674
- /** Mutable editor state for one manifest source (lives in the panel closure). */
675
- interface SourceState {
676
- editing: boolean
677
- saving: boolean
678
- draft: string
763
+ /** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
764
+ function releaseBody(release: ReleaseInfo, copy: UpdateCopy): HTMLElement[] {
765
+ const out: HTMLElement[] = []
766
+ const notes = Array.isArray(release.notes)
767
+ ? release.notes.filter((n) => typeof n === 'string' && n.length > 0)
768
+ : []
769
+ const breaking = Array.isArray(release.breaking)
770
+ ? release.breaking.filter((n) => typeof n === 'string' && n.length > 0)
771
+ : []
772
+ if (typeof release.summary === 'string' && release.summary.length > 0) {
773
+ out.push(el('p', 'hqpu-release-summary', release.summary))
774
+ }
775
+ if (notes.length > 0) {
776
+ const ul = el('ul', 'hqpu-release-notes')
777
+ for (const note of notes) ul.append(el('li', '', note as string))
778
+ out.push(ul)
779
+ }
780
+ if (breaking.length > 0) {
781
+ const banner = el('div', 'hqpu-banner')
782
+ banner.dataset.tone = 'danger'
783
+ const icon = el('span')
784
+ icon.innerHTML = WARNING_ICON(14)
785
+ banner.append(icon)
786
+ const inner = el('span')
787
+ inner.append(el('strong', '', `${copy.release.breakingTitle}: `))
788
+ inner.append(document.createTextNode(breaking.join(' · ')))
789
+ banner.append(inner)
790
+ out.push(banner)
791
+ }
792
+ if (typeof release.publishedAt === 'string' && release.publishedAt.length > 0) {
793
+ out.push(
794
+ el('p', 'hqpu-release-meta', copy.release.publishedAt(new Date(release.publishedAt).toLocaleString())),
795
+ )
796
+ }
797
+ if (release.requiresRestart === true) {
798
+ out.push(el('p', 'hqpu-release-meta', copy.job.willRestartDsh))
799
+ }
800
+ if (typeof release.notesUrl === 'string' && release.notesUrl.length > 0) {
801
+ const link = el('a', 'hqpu-release-link', copy.release.notesLink)
802
+ link.href = release.notesUrl
803
+ link.target = '_blank'
804
+ link.rel = 'noreferrer'
805
+ out.push(link)
806
+ }
807
+ return out
808
+ }
809
+
810
+ function hasReleaseContent(release: ReleaseInfo | null | undefined): boolean {
811
+ if (release === null || release === undefined) return false
812
+ return (
813
+ (typeof release.summary === 'string' && release.summary.length > 0) ||
814
+ (Array.isArray(release.notes) && release.notes.length > 0) ||
815
+ (Array.isArray(release.breaking) && release.breaking.length > 0) ||
816
+ typeof release.notesUrl === 'string' ||
817
+ typeof release.publishedAt === 'string'
818
+ )
679
819
  }
680
820
 
681
821
  /**
682
- * Shared state + callbacks for one update-source editor.
683
- *
684
- * hq-edge serves both manifest sources from one shape (plugin release
685
- * manifest, runtime-update manifest), and both are editable from this panel:
686
- * an override is persisted per host, so pointing discovery at a staging
687
- * channel (or a local test rig) needs no env var and no restart. The editor
688
- * state lives in the panel closure for the same reason `armed` does — a store
689
- * refresh must not discard a half-typed URL or silently close the editor.
822
+ * The job log drawer: title + outcome tag, the mono component list with a
823
+ * JOB_ID header, the result detail, older runs, and 复制日志 ID / 关闭.
690
824
  */
691
- export interface SourceEditorActions {
692
- /** The source editor is open (input shown instead of the read-only row). */
693
- sourceEditing: boolean
694
- /** A save is in flight. */
695
- sourceSaving: boolean
696
- /** The URL being edited (what the input shows). */
697
- sourceDraft: string
698
- onSourceEdit(): void
699
- onSourceDraft(value: string): void
700
- onSourceSave(): void
701
- onSourceCancel(): void
702
- /** Clear the user override, falling back to the configured URL. */
703
- onSourceReset(): void
825
+ function buildLogDrawer(
826
+ job: UpdateJob,
827
+ recent: UpdateJob[],
828
+ copy: UpdateCopy,
829
+ onCopy: (id: string) => void,
830
+ onClose: () => void,
831
+ ): HTMLElement[] {
832
+ const out: HTMLElement[] = []
833
+
834
+ const head = el('div', 'hqpu-log-head')
835
+ const titleWrap = el('div', 'hqpu-log-title')
836
+ titleWrap.append(el('span', 'hqpu-log-name', copy.ui.logTitle))
837
+ const tag = el('span', 'hqpu-tag')
838
+ tag.dataset.tone =
839
+ job.phase === 'done' ? 'success' : job.phase === 'failed' ? 'danger' : 'warning'
840
+ tag.textContent =
841
+ job.phase === 'done'
842
+ ? copy.job.resultDone
843
+ : job.phase === 'failed'
844
+ ? copy.job.resultFailed
845
+ : job.phase === 'rolled-back'
846
+ ? copy.job.resultRolledBack
847
+ : copy.job.resultAbandoned
848
+ titleWrap.append(tag)
849
+ const closeBtn = iconButton('hqpu-icon-btn hqpu-icon-btn--sm', CLOSE_ICON(14), copy.close, onClose)
850
+ head.append(titleWrap, closeBtn)
851
+ out.push(head)
852
+
853
+ const body = el('div', 'hqpu-log-body')
854
+ const block = el('div', 'hqpu-log-block')
855
+ const meta = el('div', 'hqpu-log-meta')
856
+ const logId = job.logHint !== undefined && job.logHint.length > 0 ? job.logHint : job.id
857
+ meta.append(el('span', '', `${copy.ui.jobIdLabel}: ${logId}`))
858
+ meta.append(el('span', '', shortWhen(job.result?.finishedAt ?? job.updatedAt)))
859
+ block.append(meta)
860
+
861
+ const steps = Array.isArray(job.steps) ? job.steps : []
862
+ if (steps.length > 0) {
863
+ for (const step of steps) {
864
+ const line = el('div', 'hqpu-log-line')
865
+ const dot = el('span', 'hqpu-log-dot')
866
+ dot.dataset.state = stepDotState(step.state)
867
+ line.append(dot)
868
+ const word = stepWord(step.state, copy)
869
+ const text =
870
+ step.message !== undefined && step.message.length > 0
871
+ ? `${step.name} — ${step.message}`
872
+ : step.name
873
+ line.append(el('span', 'hqpu-log-line-text', word.length > 0 ? `${text} (${word})` : text))
874
+ block.append(line)
875
+ }
876
+ } else {
877
+ block.append(el('div', 'hqpu-log-line hqpu-log-line--empty', copy.job.stepsTitle))
878
+ }
879
+ body.append(block)
880
+
881
+ // Result detail: the transition, the error code, the server's own message.
882
+ const res = job.result
883
+ const from = job.previousVersion ?? res?.installedVersion ?? '—'
884
+ const to = res?.installedVersion ?? job.targetVersion
885
+ const detail = el('div', 'hqpu-log-detail')
886
+ detail.append(el('p', '', `${from} → ${to}`))
887
+ if (res?.errorCode !== undefined && res.errorCode.length > 0) {
888
+ detail.append(el('code', 'hqpu-error-code', res.errorCode))
889
+ }
890
+ if (res?.message !== undefined && res.message.length > 0) {
891
+ detail.append(el('p', '', res.message))
892
+ }
893
+ body.append(detail)
894
+
895
+ const older = recent.filter((entry) => entry.id !== job.id).slice(0, 4)
896
+ if (older.length > 0) {
897
+ const list = el('div', 'hqpu-log-recent')
898
+ list.append(el('p', 'hqpu-log-recent-title', copy.job.recentTitle))
899
+ for (const entry of older) {
900
+ const row = el('div', 'hqpu-log-recent-row')
901
+ row.append(el('span', '', `${jobKindLabel(entry, copy)} ${entry.targetVersion}`))
902
+ row.append(el('span', 'hqpu-log-recent-meta', `${phaseLabel(entry, copy)} · ${shortWhen(entry.updatedAt)}`))
903
+ list.append(row)
904
+ }
905
+ body.append(list)
906
+ }
907
+ out.push(body)
908
+
909
+ const foot = el('div', 'hqpu-log-foot')
910
+ const copyBtn = el('button', 'hqpu-link hqpu-link--icon')
911
+ copyBtn.type = 'button'
912
+ copyBtn.dataset.copyLog = ''
913
+ const copyIcon = el('span')
914
+ copyIcon.innerHTML = COPY_ICON(12)
915
+ copyBtn.append(copyIcon, el('span', '', copy.ui.copyLogId))
916
+ copyBtn.addEventListener('click', () => onCopy(logId))
917
+ const closeFoot = el('button', 'hqpu-btn hqpu-btn--sm', copy.close)
918
+ closeFoot.type = 'button'
919
+ closeFoot.addEventListener('click', onClose)
920
+ foot.append(copyBtn, closeFoot)
921
+ out.push(foot)
922
+
923
+ return out
704
924
  }
705
925
 
926
+ // ── The runtime action contract ────────────────────────────────────────────
927
+
706
928
  /**
707
929
  * The runtime-update action row's state and callbacks.
708
930
  *
709
- * Installing the runtime is destructive in a way a plugin install is not: hq-edge
710
- * shuts itself down so the EDA host can swap the runtime directory. So the row
711
- * is a two-step confirm (arm → confirm), never a single fire-and-forget click.
931
+ * Installing the runtime is destructive in a way a plugin install is not:
932
+ * hq-edge shuts itself down so the EDA host can swap the runtime directory. So
933
+ * the row is a two-step confirm (arm → confirm), never a single click.
712
934
  */
713
- export interface RuntimeActions extends SourceEditorActions {
935
+ export interface RuntimeActions {
714
936
  /** A request is in flight (hq-edge accepted it and is restarting). */
715
937
  busy: boolean
716
938
  /** The confirm step is armed for the version the row names. */
@@ -718,8 +940,7 @@ export interface RuntimeActions extends SourceEditorActions {
718
940
  /**
719
941
  * hq-edge answered `202 Accepted`: from here on the outcome belongs to the
720
942
  * EDA host, and this process is about to be replaced. Terminal — the row
721
- * must never re-offer the button once this is true, because hq-edge is
722
- * already gone and a second request could only fail.
943
+ * must never re-offer the button once this is true.
723
944
  */
724
945
  accepted: boolean
725
946
  onArm(): void
@@ -728,11 +949,11 @@ export interface RuntimeActions extends SourceEditorActions {
728
949
  }
729
950
 
730
951
  /**
731
- * Normalize the report's `runtime.source` to the full shape.
952
+ * Normalize a report's source field to the full shape.
732
953
  *
733
954
  * An older hq-edge predates the feature and sends no `source` at all — that
734
- * must degrade to a read-only panel, not crash the render. A future server
735
- * may add fields; unknown extras are dropped here rather than trusted.
955
+ * must degrade to no editor, not crash the render. A future server may add
956
+ * fields; unknown extras are dropped here rather than trusted.
736
957
  */
737
958
  function normalizeSource(raw: unknown): RuntimeUpdateSource | null {
738
959
  if (raw === null || typeof raw !== 'object') return null
@@ -746,465 +967,36 @@ function normalizeSource(raw: unknown): RuntimeUpdateSource | null {
746
967
  }
747
968
  }
748
969
 
749
- /** The tag naming where the URL in effect came from. */
750
- function originLabel(origin: RuntimeUpdateSource['origin'], copy: UpdateCopy): string {
751
- switch (origin) {
752
- case 'override':
753
- return copy.originOverride
754
- case 'config':
755
- return copy.originConfig
756
- default:
757
- return copy.originUnset
758
- }
759
- }
760
-
761
- /** The 更新源 fact row: the URL in effect plus an origin tag. */
762
- function sourceFactRow(
763
- facts: HTMLElement,
764
- source: RuntimeUpdateSource,
765
- copy: UpdateCopy,
766
- ): void {
767
- const wrap = el('div')
768
- wrap.append(el('dt', '', copy.runtime.source))
769
- const dd = el('dd')
770
- const line = el('div', 'hqpu-source-line')
771
- const code = el(
772
- 'code',
773
- 'hqpu-source-url',
774
- source.url.length > 0 ? source.url : copy.runtime.notConfigured,
775
- )
776
- const tag = el('span', 'hqpu-tag')
777
- tag.dataset.tone = source.override ? 'info' : source.origin === 'config' ? 'success' : 'neutral'
778
- tag.textContent = originLabel(source.origin, copy)
779
- line.append(code, tag)
780
- dd.append(line)
781
- wrap.append(dd)
782
- facts.append(wrap)
783
- }
784
-
785
- /** Which manifest an editor belongs to — drives data attributes + CSS hook. */
786
- export type SourceKind = 'runtime' | 'plugin'
787
-
788
- /** The localized strings one source editor needs (both kinds share the flow). */
789
- interface SourceEditorLabels {
790
- hint: string
791
- edit: string
792
- save: string
793
- reset: string
794
- inputLabel: string
795
- placeholder: string
796
- }
797
-
798
- /** Pick the labels for one kind out of the copy table. */
799
- function sourceLabels(copy: UpdateCopy, kind: SourceKind): SourceEditorLabels {
800
- return kind === 'runtime'
801
- ? {
802
- hint: copy.runtime.sourceHint,
803
- edit: copy.runtime.sourceEdit,
804
- save: copy.runtime.sourceSave,
805
- reset: copy.runtime.sourceReset,
806
- inputLabel: copy.runtime.sourceInputLabel,
807
- placeholder: copy.runtime.sourcePlaceholder,
808
- }
809
- : {
810
- hint: copy.pluginSource.hint,
811
- edit: copy.pluginSource.edit,
812
- save: copy.pluginSource.save,
813
- reset: copy.pluginSource.reset,
814
- inputLabel: copy.pluginSource.inputLabel,
815
- placeholder: copy.pluginSource.placeholder,
816
- }
817
- }
818
-
819
- /**
820
- * One update-source editor, shared by the plugin manifest and the runtime
821
- * manifest (the only differences are the labels and the data-attribute
822
- * prefix, so tests and CSS can target each).
823
- *
824
- * Read mode is a hint plus a 修改 button; edit mode is the URL field with
825
- * 保存 / 取消 — plus 恢复默认 while a user override shadows the configured
826
- * URL. The draft lives in {@link SourceEditorActions} (the panel closure), so
827
- * a background store refresh re-renders the typed URL instead of discarding it.
828
- */
829
- function sourceEditor(
830
- kind: SourceKind,
831
- source: RuntimeUpdateSource,
832
- copy: UpdateCopy,
833
- actions: SourceEditorActions,
834
- ): HTMLElement {
835
- const labels = sourceLabels(copy, kind)
836
- const block = el('div', `hqpu-source-editor hqpu-source-editor--${kind}`)
837
- if (!actions.sourceEditing) {
838
- // Read mode: show the URL currently in effect (mono, muted), then edit.
839
- const urlLine = el('p', 'hqpu-source-current-url', source.url)
840
- urlLine.style.cssText =
841
- 'margin:2px 0 6px;font-size:9px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-tertiary);word-break:break-all;'
842
- block.append(urlLine)
843
- block.append(el('p', 'hqpu-status hqpu-source-hint', labels.hint))
844
- const edit = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', labels.edit)
845
- edit.type = 'button'
846
- edit.setAttribute(`data-${kind}-source-edit`, '')
847
- edit.disabled = actions.sourceSaving
848
- edit.addEventListener('click', actions.onSourceEdit)
849
- block.append(edit)
850
- return block
851
- }
852
- const input = el('input', 'hqpu-source-input')
853
- input.type = 'url'
854
- input.value = actions.sourceDraft
855
- input.placeholder = labels.placeholder
856
- input.setAttribute('aria-label', labels.inputLabel)
857
- input.setAttribute(`data-${kind}-source-input`, '')
858
- input.disabled = actions.sourceSaving
859
- input.addEventListener('input', () => {
860
- // Draft-only: no re-render, so the caret and IME stay untouched.
861
- actions.onSourceDraft(input.value)
862
- })
863
- // Enter saves, Escape cancels — the field is the whole editor, and Escape
864
- // must not fall through to the panel-wide close handler.
865
- input.addEventListener('keydown', (ev) => {
866
- if (ev.key === 'Enter') {
867
- ev.preventDefault()
868
- actions.onSourceSave()
869
- } else if (ev.key === 'Escape') {
870
- ev.preventDefault()
871
- ev.stopPropagation()
872
- actions.onSourceCancel()
873
- }
874
- })
875
- const save = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', labels.save)
876
- save.type = 'button'
877
- save.setAttribute(`data-${kind}-source-save`, '')
878
- save.disabled = actions.sourceSaving
879
- save.addEventListener('click', actions.onSourceSave)
880
- const cancel = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.cancel)
881
- cancel.type = 'button'
882
- cancel.setAttribute(`data-${kind}-source-cancel`, '')
883
- cancel.disabled = actions.sourceSaving
884
- cancel.addEventListener('click', actions.onSourceCancel)
885
- const controls = el('div', 'hqpu-actions hqpu-source-controls')
886
- controls.append(input, save, cancel)
887
- if (source.override) {
888
- const reset = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', labels.reset)
889
- reset.type = 'button'
890
- reset.setAttribute(`data-${kind}-source-reset`, '')
891
- reset.disabled = actions.sourceSaving
892
- reset.addEventListener('click', actions.onSourceReset)
893
- controls.append(reset)
894
- }
895
- block.append(controls)
896
- // Focus on open — and on any re-render while editing, which is exactly what
897
- // a rejected save wants (the user is dropped back into the field to fix it).
898
- input.focus()
899
- return block
900
- }
901
-
902
- /**
903
- * The plugin release-manifest source, as its own group above the plugin
904
- * groups: it says where the whole list comes from, so it belongs above the
905
- * list rather than inside it. Absent on an older hq-edge (no `pluginSource`
906
- * in the report) — then nothing is rendered at all.
907
- */
908
- function pluginSourceGroup(
909
- report: PluginUpdateReport,
910
- copy: UpdateCopy,
911
- actions: SourceEditorActions,
912
- ): HTMLElement | null {
913
- const source = normalizeSource(report.pluginSource)
914
- if (source === null) return null
915
- const section = el('section', 'hqpu-group')
916
- section.dataset.group = 'plugin-source'
917
- const head = el('div', 'hqpu-group-head')
918
- head.append(el('h3', 'hqpu-group-title', copy.pluginSource.title))
919
- section.append(head)
920
-
921
- const facts = el('dl', 'hqpu-facts')
922
- sourceFactRow(facts, source, copy)
923
- section.append(facts)
924
- section.append(sourceEditor('plugin', source, copy, actions))
925
- return section
926
- }
927
-
928
- /**
929
- * The host's own report of the last runtime swap. Rolled-back / failed renders
930
- * as an AMBER alert card (title, old→new body, errorCode chip, 忽略 dismiss);
931
- * succeeded renders as a subtle green inline line — not a loud banner. The
932
- * plugin has NO log-open affordance, so "查看诊断日志" is omitted (never faked).
933
- */
934
- function runtimeResultAlert(
935
- report: PluginUpdateReport,
936
- copy: UpdateCopy,
937
- dismissed: boolean,
938
- onDismiss: () => void,
939
- ): HTMLElement | null {
940
- const lastResult = report.runtime.lastResult
941
- if (lastResult === null || lastResult === undefined) return null
942
-
943
- // Green inline confirmation — quiet by design.
944
- if (lastResult.state === 'succeeded') {
945
- const line = el('p', 'hqpu-alert-green')
946
- line.dataset.role = 'runtime-success'
947
- line.textContent = copy.ui.successInline
948
- return line
949
- }
950
- if (dismissed) return null
951
-
952
- const section = el('section', 'hqpu-group')
953
- section.dataset.group = 'runtime-alert'
954
- const card = el('div', 'hqpu-alert hqpu-alert--amber')
955
- card.innerHTML = WARNING_ICON(14)
956
-
957
- const body = el('div')
958
- const title = el('div', 'hqpu-alert-title')
959
- title.textContent = lastResult.state === 'failed' ? copy.ui.failedTitle : copy.ui.rollbackTitle
960
- body.append(title)
961
-
962
- const oldVer = lastResult.installedVersion ?? lastResult.targetVersion ?? '—'
963
- const code = typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0
964
- ? lastResult.errorCode
965
- : '—'
966
- const text =
967
- lastResult.state === 'failed'
968
- ? copy.ui.failedBody(oldVer, code)
969
- : copy.ui.rollbackBody(oldVer, code)
970
- // Build the body as text + an inline <code> chip for the error code.
971
- const bodyP = el('p', 'hqpu-alert-body')
972
- const textOnly =
973
- lastResult.state === 'failed'
974
- ? copy.ui.failedBody(oldVer, '')
975
- : copy.ui.rollbackBody(oldVer, '')
976
- // Strip the trailing "code" placeholder we appended; re-add as a chip.
977
- bodyP.append(document.createTextNode(textOnly.replace(/\s*$/, '')))
978
- if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
979
- bodyP.append(document.createTextNode(' '))
980
- const chip = el('code', '', lastResult.errorCode)
981
- bodyP.append(chip)
982
- }
983
- if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
984
- bodyP.append(el('br'), document.createTextNode(lastResult.message))
985
- }
986
- body.append(bodyP)
987
-
988
- // Dismiss action (no log button — the plugin has no log-open affordance).
989
- const actions = el('div', 'hqpu-alert-actions')
990
- const dismiss = el('button', 'hqpu-alert-dismiss', copy.ui.dismiss)
991
- dismiss.type = 'button'
992
- dismiss.addEventListener('click', onDismiss)
993
- actions.append(dismiss)
994
- body.append(actions)
995
-
996
- card.append(body)
997
- section.append(card)
998
- return section
999
- }
1000
-
1001
- /** The HQ Edge runtime engine card per the mockup: current chip, "新版本 vY
1002
- * 可用" with a 关键修复 tag when breaking/notes exist, size+summary line, a
1003
- * 升级核心 button (existing arm→confirm flow), and the amber restart note. */
1004
- function runtimeEngineCard(
1005
- report: PluginUpdateReport,
1006
- copy: UpdateCopy,
1007
- actions: RuntimeActions,
1008
- ): HTMLElement | null {
1009
- const runtime = report.runtime
1010
- const latestVersion = runtimeLatestVersion(runtime)
1011
- const card = el('section', 'hqpu-engine')
1012
- card.dataset.group = 'runtime'
1013
-
1014
- const head = el('div', 'hqpu-engine-head')
1015
- const titleWrap = el('div', 'hqpu-engine-title')
1016
- titleWrap.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.33333 2L4.66667 8H8L6.66667 14L11.33333 8H8L9.33333 2Z"/></svg>`
1017
- titleWrap.append(el('span', '', copy.ui.runtimeEngine))
1018
- head.append(titleWrap)
1019
- if (typeof runtime.currentVersion === 'string' && runtime.currentVersion.length > 0) {
1020
- head.append(el('span', 'hqpu-chip', copy.ui.currentChip(runtime.currentVersion)))
1021
- }
1022
- card.append(head)
1023
-
1024
- // Update row (only when a newer distribution is known).
1025
- if (runtime.updateAvailable && latestVersion !== null) {
1026
- const row = el('div', 'hqpu-engine-update-row')
1027
- const left = el('div')
1028
- const newLine = el('div', 'hqpu-engine-new-line')
1029
- newLine.append(el('span', '', copy.ui.newVersion(latestVersion)))
1030
- const latestEntry = runtime.latest
1031
- const hasCritical =
1032
- (Array.isArray(latestEntry?.breaking) && (latestEntry?.breaking?.length ?? 0) > 0) ||
1033
- (Array.isArray(latestEntry?.notes) && (latestEntry?.notes?.length ?? 0) > 0)
1034
- if (hasCritical) newLine.append(el('span', 'hqpu-tag--brand', copy.ui.breakingTag))
1035
- left.append(newLine)
1036
- const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
1037
- const summary = typeof latestEntry?.summary === 'string' ? latestEntry.summary : ''
1038
- if (size.length > 0 || summary.length > 0) {
1039
- left.append(el('p', 'hqpu-engine-size', copy.ui.sizeLine(size, summary)))
1040
- }
1041
- row.append(left)
1042
-
1043
- // Upgrade action: arm → confirm, accepted/busy states preserved.
1044
- if (actions.accepted) {
1045
- row.append(el('span', 'hqpu-status', copy.runtime.updateAccepted))
1046
- } else if (actions.busy) {
1047
- row.append(el('span', 'hqpu-status', copy.runtime.updating))
1048
- } else if (actions.armed) {
1049
- const confirmWrap = el('div')
1050
- confirmWrap.append(el('p', 'hqpu-status', copy.runtime.updateConfirm(latestVersion)))
1051
- const yes = el('button', 'hqpu-btn hqpu-btn--engine', copy.confirmInstall)
1052
- yes.type = 'button'
1053
- yes.dataset.runtimeConfirm = ''
1054
- yes.addEventListener('click', actions.onConfirm)
1055
- const no = el('button', 'hqpu-btn hqpu-btn--engine', copy.cancel)
1056
- no.type = 'button'
1057
- no.addEventListener('click', actions.onCancel)
1058
- confirmWrap.append(yes, no)
1059
- row.append(confirmWrap)
1060
- } else {
1061
- const start = el('button', 'hqpu-btn hqpu-btn--engine', copy.ui.upgradeCore)
1062
- start.type = 'button'
1063
- start.dataset.runtimeUpdate = ''
1064
- start.addEventListener('click', actions.onArm)
1065
- row.append(start)
1066
- }
1067
- card.append(row)
1068
- const note = el('p', 'hqpu-engine-note')
1069
- note.innerHTML =
1070
- `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5L14 13H2L8 2.5Z"/></svg><span>${copy.ui.runtimeRestartNote}</span>`
1071
- card.append(note)
1072
- } else if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
1073
- card.append(el('p', 'hqpu-status', `${copy.lastError}: ${runtime.lastError}`))
1074
- }
1075
- return card
1076
- }
1077
-
1078
- function renderList(
1079
- section: HTMLElement,
1080
- report: PluginUpdateReport | null,
1081
- copy: UpdateCopy,
1082
- changelogOpen: Set<string>,
1083
- allOpen: boolean,
1084
- onToggleCard: (name: string) => void,
1085
- onToggleAll: () => void,
1086
- onRetryCheck: () => void,
1087
- onInstall: (name: string) => void,
1088
- onIgnore: (item: PluginUpdateItem) => void,
1089
- ): void {
1090
- section.textContent = ''
1091
- if (report === null) {
1092
- section.append(skeleton(copy))
1093
- return
1094
- }
1095
- // Check-status banner: a failed check shows an error row + Retry (never the
1096
- // silent "no updates" empty state); a successful check shows the time.
1097
- const banner = checkBanner(report, copy, onRetryCheck)
1098
- if (banner !== null) section.append(banner)
1099
- // Set-level release metadata (v2 manifest): summary, notes, breaking.
1100
- const release = releaseHeader(report.release, copy)
1101
- if (release !== null) section.append(release)
1102
- if (report.items.length === 0) {
1103
- // A successful check with zero updates — distinct from a failed check,
1104
- // which already rendered its error row above.
1105
- if (typeof report.lastError !== 'string' || report.lastError.length === 0) {
1106
- section.append(el('div', 'hqpu-empty', copy.empty))
1107
- }
1108
- return
1109
- }
1110
-
1111
- const byState = (state: PluginUpdateItem['state']): PluginUpdateItem[] =>
1112
- report.items.filter((item) => item.state === state)
1113
-
1114
- // Most important first: what can be acted on, then what was muted, then what
1115
- // is already settled. Blocked plugins are intentionally skipped — the server
1116
- // still reports them, but they carry no user-actionable offer, so surfacing
1117
- // them only adds noise without a path forward.
1118
- const order: Array<[string, PluginUpdateItem[]]> = [
1119
- [copy.group.actionable, [...byState('update'), ...byState('install')]],
1120
- [copy.group.ignored, byState('ignored')],
1121
- [copy.group.current, byState('current')],
1122
- ]
1123
- for (const [heading, items] of order) {
1124
- const node = group(heading, items, copy, changelogOpen, allOpen, onToggleCard, onToggleAll, onInstall, onIgnore)
1125
- if (node !== null) section.append(node)
1126
- }
1127
- }
1128
-
1129
- function renderRuntime(
1130
- section: HTMLElement,
1131
- report: PluginUpdateReport | null,
1132
- copy: UpdateCopy,
1133
- actions: RuntimeActions,
1134
- dismissedAlert: boolean,
1135
- onDismissAlert: () => void,
1136
- ): void {
1137
- section.textContent = ''
1138
- if (report === null) return
1139
- const alert = runtimeResultAlert(report, copy, dismissedAlert, onDismissAlert)
1140
- if (alert !== null) section.append(alert)
1141
- const engine = runtimeEngineCard(report, copy, actions)
1142
- if (engine !== null) section.append(engine)
1143
- }
1144
-
1145
- /** The plugin-source group, above the plugin groups, hidden behind an advanced
1146
- * disclosure (manifest-URL editing is a developer affordance). No-op without
1147
- * the field. */
1148
- function renderPluginSource(
1149
- section: HTMLElement,
1150
- report: PluginUpdateReport | null,
1151
- copy: UpdateCopy,
1152
- actions: SourceEditorActions,
1153
- ): void {
1154
- section.textContent = ''
1155
- if (report === null) return
1156
- const node = pluginSourceGroup(report, copy, actions)
1157
- if (node !== null) section.append(advancedBlock(copy, [node]))
1158
- }
1159
-
1160
- /** Run the install transaction (a new durable job), then let the store poll it. */
1161
- async function runInstall(store: UpdateStoreLike, copy: UpdateCopy, pluginNames?: string[]): Promise<void> {
1162
- const report = store.report()
1163
- if (report === null) return
1164
- // Without an evaluated set there is nothing to install: hq-edge has not read
1165
- // a manifest yet, so the report is still its empty shell.
1166
- if (report.manifestVersion === null) {
1167
- showToast(copy.checkFailed, 'error')
1168
- return
1169
- }
1170
- // Flip the installing flag BEFORE the request: this is what disables the
1171
- // action buttons and shows the "DSH will restart" footer state (previously
1172
- // dead code — runInstall never called it).
1173
- store.setInstalling(true)
1174
- try {
1175
- const result = await installSet(report.manifestVersion, pluginNames)
1176
- if (result.error !== undefined) {
1177
- store.setInstalling(false)
1178
- showToast(`${copy.installError}: ${result.error}`, 'error')
1179
- return
1180
- }
1181
- // 202 accepted: a durable job now owns the lifecycle. The store's /status
1182
- // poller renders progress and clears the flag when the job settles.
1183
- showToast(copy.installSuccess, 'success')
1184
- void store.fetchStatus()
1185
- } catch (err: unknown) {
1186
- store.setInstalling(false)
1187
- showToast(
1188
- `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
1189
- 'error',
1190
- )
1191
- }
1192
- }
970
+ // ── Toast ─────────────────────────────────────────────────────────────────
1193
971
 
1194
972
  /** The single live toast; a new message replaces the one on screen. */
1195
973
  let toastHost: HTMLElement | null = null
1196
974
  let toastTimer: number | null = null
975
+ /** Copy for the toast's close affordance; set when the panel mounts. */
976
+ let activeCopy: UpdateCopy | null = null
1197
977
 
1198
978
  function showToast(message: string, tone: 'success' | 'error'): void {
1199
979
  if (toastHost === null) return
1200
980
  if (toastTimer !== null) window.clearTimeout(toastTimer)
1201
981
  toastHost.textContent = ''
1202
982
  const toast = el('div', 'hqpu-toast')
1203
- toast.setAttribute('role', 'status')
983
+ // An error must interrupt; a success may wait for a pause.
984
+ toast.setAttribute('role', tone === 'error' ? 'alert' : 'status')
1204
985
  const icon = el('span', 'hqpu-toast-icon')
1205
986
  icon.dataset.tone = tone
1206
987
  icon.innerHTML = tone === 'success' ? CHECK_CIRCLE_ICON(16) : WARNING_ICON(16)
1207
988
  toast.append(icon, el('span', 'hqpu-toast-text', message))
989
+ const close = iconButton(
990
+ 'hqpu-toast-close',
991
+ CLOSE_ICON(12),
992
+ activeCopy?.close ?? 'Close',
993
+ () => {
994
+ toastHost?.replaceChildren()
995
+ if (toastTimer !== null) window.clearTimeout(toastTimer)
996
+ toastTimer = null
997
+ },
998
+ )
999
+ toast.append(close)
1208
1000
  toastHost.append(toast)
1209
1001
  toastTimer = window.setTimeout(() => {
1210
1002
  toastHost?.replaceChildren()
@@ -1212,100 +1004,7 @@ function showToast(message: string, tone: 'success' | 'error'): void {
1212
1004
  }, TOAST_HOLD_MS)
1213
1005
  }
1214
1006
 
1215
- /**
1216
- * The collapsible "更新源配置 (高级)" panel (gear-toggled). It hosts the
1217
- * EXISTING plugin-source and runtime-source editors (POST /plugin-source,
1218
- * POST /runtime-source, save→check→refresh already wired). No new endpoints:
1219
- * if a manifest source has no data, that field is omitted rather than faked.
1220
- */
1221
- function renderSettingsPanel(
1222
- panel: HTMLElement,
1223
- report: PluginUpdateReport | null,
1224
- copy: UpdateCopy,
1225
- open: boolean,
1226
- pluginActions: SourceEditorActions,
1227
- runtimeActions: SourceEditorActions,
1228
- ): void {
1229
- panel.textContent = ''
1230
- panel.style.display = open ? '' : 'none'
1231
- if (!open || report === null) return
1232
-
1233
- const row = el('div', 'hqpu-settings-row')
1234
- row.append(el('div', 'hqpu-settings-title', copy.ui.settingsTitle))
1235
- // Origin badge: custom override (amber) vs the default configured source (green).
1236
- const pluginSource = normalizeSource(report.pluginSource)
1237
- const runtimeSource = normalizeSource(report.runtime.source)
1238
- const overridden = (pluginSource?.override ?? false) || (runtimeSource?.override ?? false)
1239
- const badge = el('span', 'hqpu-origin-badge', overridden ? copy.ui.originCustom : copy.ui.originDefault)
1240
- badge.dataset.origin = overridden ? 'override' : 'config'
1241
- row.append(badge)
1242
- panel.append(row)
1243
-
1244
- if (pluginSource !== null) {
1245
- panel.append(el('label', '', copy.ui.pluginUrlLabel))
1246
- panel.append(sourceEditor('plugin', pluginSource, copy, pluginActions))
1247
- }
1248
- if (runtimeSource !== null) {
1249
- panel.append(el('label', '', copy.ui.runtimeUrlLabel))
1250
- panel.append(sourceEditor('runtime', runtimeSource, copy, runtimeActions))
1251
- }
1252
- }
1253
-
1254
- /**
1255
- * The sticky footer: left meta ("准备安装 N 项兼容插件" / "版本集: vX"), a
1256
- * full-width primary "一键更新全套插件 (vX)" (install = one durable job),
1257
- * a secondary 忽略 wired to the existing ignore/unignore affordance, and the
1258
- * footnote. The count EXCLUDES ignored / already-current entries.
1259
- */
1260
- function renderFooter(
1261
- footer: HTMLElement,
1262
- installBtn: HTMLButtonElement,
1263
- ignoreBtn: HTMLButtonElement,
1264
- countEl: HTMLElement,
1265
- setEl: HTMLElement,
1266
- report: PluginUpdateReport | null,
1267
- copy: UpdateCopy,
1268
- installing: boolean,
1269
- store: UpdateStoreLike,
1270
- ): void {
1271
- if (report === null) {
1272
- countEl.textContent = ''
1273
- setEl.textContent = ''
1274
- installBtn.disabled = true
1275
- ignoreBtn.style.display = 'none'
1276
- return
1277
- }
1278
-
1279
- const actionable = report.items.filter(
1280
- (item) => item.state === 'update' || item.state === 'install',
1281
- ).length
1282
- const hasSet = report.manifestVersion !== null
1283
- const setVersion: string = report.manifestVersion ?? ''
1284
-
1285
- countEl.textContent = copy.ui.footerCount(actionable)
1286
- setEl.textContent = hasSet ? copy.ui.footerSet(setVersion) : ''
1287
-
1288
- // Install-all: honest set-level label; disabled while busy or nothing to do.
1289
- installBtn.textContent = hasSet
1290
- ? copy.ui.installAllSet(setVersion)
1291
- : copy.ui.installAllSet('—')
1292
- installBtn.disabled = installing || actionable === 0 || !hasSet || report.ignoredSet
1293
- installBtn.dataset.busy = installing ? 'true' : 'false'
1294
- installBtn.onclick = () => {
1295
- void runInstall(store, copy)
1296
- }
1297
-
1298
- // Secondary ignore button: only when there is an evaluated set to ignore.
1299
- ignoreBtn.style.display = hasSet ? '' : 'none'
1300
- ignoreBtn.textContent = report.ignoredSet ? copy.unignore : copy.ui.ignoreBtn
1301
- ignoreBtn.onclick = () => {
1302
- const version = report.manifestVersion as string
1303
- const action = report.ignoredSet ? unignoreOffer(version) : ignoreOffer(version)
1304
- void action
1305
- .then(() => store.refresh())
1306
- .catch(() => undefined)
1307
- }
1308
- }
1007
+ // ── Panel ─────────────────────────────────────────────────────────────────
1309
1008
 
1310
1009
  /**
1311
1010
  * Open (or focus) the update panel. Re-renders from the store; when the store
@@ -1318,6 +1017,8 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1318
1017
  return
1319
1018
  }
1320
1019
 
1020
+ const previouslyFocused = document.activeElement as HTMLElement | null
1021
+
1321
1022
  const overlay = el('div', 'hqpu-overlay')
1322
1023
  overlay.setAttribute('data-hq-plugin-update-view', '')
1323
1024
  overlay.addEventListener('click', (ev) => {
@@ -1328,15 +1029,14 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1328
1029
  const panel = el('div', 'hqpu-panel')
1329
1030
  panel.setAttribute('role', 'dialog')
1330
1031
  panel.setAttribute('aria-modal', 'true')
1331
- panel.setAttribute('aria-label', copy.viewTitle)
1032
+ panel.setAttribute('aria-label', copy.ui.title)
1332
1033
  panel.tabIndex = -1
1333
1034
 
1334
- // ── Sticky header: brand icon + title + relative checked time, refresh +
1335
- // gear (advanced source settings) + close ──
1336
- const head = el('div', 'hqpu-head')
1035
+ // ── Header ──
1036
+ const head = el('header', 'hqpu-head')
1337
1037
  const headMain = el('div', 'hqpu-head-main')
1338
1038
  const brandIcon = el('span', 'hqpu-brand-icon')
1339
- brandIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5.6 2.6a6 6 0 015.6 1.6M12.4 13.4a6 6 0 01-5.6-1.6M10.6 2V5.4H7.2M5.4 14V10.6H8.8"/></svg>`
1039
+ brandIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M13.8 8.6A5.8 5.8 0 012.2 6.4"/><path d="M13.8 4.4V8.6H9.6"/><path d="M2.2 7.4A5.8 5.8 0 0113.8 9.6"/><path d="M2.2 11.6V7.4H6.4"/></svg>`
1340
1040
  const titleBlock = el('div', 'hqpu-title-block')
1341
1041
  titleBlock.append(el('h1', 'hqpu-title', copy.ui.title))
1342
1042
  const sub = el('p', 'hqpu-sub')
@@ -1344,89 +1044,96 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1344
1044
  headMain.append(brandIcon, titleBlock)
1345
1045
 
1346
1046
  const toolbar = el('div', 'hqpu-toolbar')
1347
- const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(16), copy.refresh, () => {
1047
+ const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(15), copy.refresh, () => {
1348
1048
  void store.check()
1349
1049
  })
1350
- toolbar.append(refreshBtn)
1351
1050
  const gearBtn = iconButton(
1352
1051
  'hqpu-icon-btn',
1353
- `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.2"/><path d="M13.5 9.2v-2.4l-1.8-.6a4.4 4.4 0 00-.5-1.2l.9-1.7-1.7-1.7-1.7.9a4.4 4.4 0 00-1.2-.5L7.2 2.5H4.8l-.6 1.8a4.4 4.4 0 00-1.2.5l-1.7-.9-1.7 1.7.9 1.7a4.4 4.4 0 00-.5 1.2L2.5 7.2v2.4l1.8.6a4.4 4.4 0 00.5 1.2l-.9 1.7 1.7 1.7 1.7-.9a4.4 4.4 0 001.2.5l.6 1.8h2.4l.6-1.8a4.4 4.4 0 001.2-.5l1.7.9 1.7-1.7-.9-1.7a4.4 4.4 0 00.5-1.2l1.8-.6z"/></svg>`,
1052
+ `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.2"/><path d="M13.5 9.2v-2.4l-1.8-.6a4.4 4.4 0 00-.5-1.2l.9-1.7-1.7-1.7-1.7.9a4.4 4.4 0 00-1.2-.5L7.2 2.5H4.8l-.6 1.8a4.4 4.4 0 00-1.2.5l-1.7-.9-1.7 1.7.9 1.7a4.4 4.4 0 00-.5 1.2L2.5 7.2v2.4l1.8.6a4.4 4.4 0 00.5 1.2l-.9 1.7 1.7 1.7 1.7-.9a4.4 4.4 0 001.2.5l.6 1.8h2.4l.6-1.8a4.4 4.4 0 001.2-.5l1.7.9 1.7-1.7-.9-1.7a4.4 4.4 0 00.5-1.2l1.8-.6z"/></svg>`,
1354
1053
  copy.ui.gearTooltip,
1355
1054
  () => {
1356
- settingsOpen = !settingsOpen
1357
- render()
1055
+ toggleSettings()
1358
1056
  },
1359
1057
  )
1360
- toolbar.append(gearBtn)
1361
- const closeBtn = iconButton('hqpu-icon-btn', CLOSE_ICON(16), copy.close, () => {
1058
+ gearBtn.dataset.settingsToggle = ''
1059
+ gearBtn.setAttribute('aria-pressed', 'false')
1060
+ const logBtn = iconButton('hqpu-icon-btn', LOG_ICON(15), copy.ui.logShow, () => {
1061
+ toggleLog()
1062
+ })
1063
+ logBtn.dataset.logToggle = ''
1064
+ logBtn.setAttribute('aria-pressed', 'false')
1065
+ const closeBtn = iconButton('hqpu-icon-btn hqpu-icon-btn--close', CLOSE_ICON(15), copy.close, () => {
1362
1066
  onClose()
1363
1067
  })
1364
- toolbar.append(closeBtn)
1068
+ toolbar.append(refreshBtn, gearBtn, logBtn, closeBtn)
1365
1069
  head.append(headMain, toolbar)
1366
1070
  panel.append(head)
1367
1071
 
1368
- // ── Collapsible advanced settings panel (gear-toggled) ──
1369
- const settingsPanel = el('div', 'hqpu-settings')
1370
- panel.append(settingsPanel)
1072
+ // ── Settings strip (gear-toggled, above the body so it never shrinks it) ──
1073
+ const settingsEl = el('div', 'hqpu-settings')
1074
+ settingsEl.hidden = true
1075
+ panel.append(settingsEl)
1371
1076
 
1372
- // ── Scrolling body: the narrow column ──
1077
+ // ── Body + footer + log drawer share one positioned box, so the drawer can
1078
+ // cover the body without covering the header ──
1079
+ const main = el('div', 'hqpu-main')
1373
1080
  const scroll = el('div', 'hqpu-scroll')
1374
1081
  const column = el('div', 'hqpu-column')
1375
- // The durable-job view leads: an active install/runtime update is resumed on
1376
- // open BEFORE anything else, and a recent terminal result is shown here too.
1377
1082
  const jobSection = el('div', 'hqpu-stack')
1083
+ const bannerSection = el('div', 'hqpu-stack')
1084
+ const bandSection = el('div', 'hqpu-stack')
1378
1085
  const listSection = el('div', 'hqpu-stack')
1379
- const runtimeSection = el('div', 'hqpu-stack')
1380
- const toastSection = el('div', 'hqpu-toast-host')
1381
- column.append(jobSection, listSection, runtimeSection, toastSection)
1086
+ const extraSection = el('div', 'hqpu-stack')
1087
+ column.append(jobSection, bannerSection, bandSection, listSection, extraSection)
1382
1088
  scroll.append(column)
1383
- panel.append(scroll)
1384
1089
 
1385
- // ── Sticky footer ──
1386
- const footer = el('div', 'hqpu-footer')
1090
+ const footer = el('footer', 'hqpu-footer')
1091
+ const cta = el('button', 'hqpu-btn--block')
1092
+ cta.type = 'button'
1093
+ const ctaSpinner = el('span', 'hqpu-cta-spinner')
1094
+ ctaSpinner.hidden = true
1095
+ const ctaLabel = el('span', 'hqpu-cta-label')
1096
+ cta.append(ctaSpinner, ctaLabel)
1387
1097
  const footMeta = el('div', 'hqpu-foot-meta')
1388
1098
  const footCount = el('span', '')
1389
- const footSet = el('span', 'hqpu-foot-set')
1390
- footMeta.append(footCount, footSet)
1391
- const footActions = el('div', 'hqpu-foot-actions')
1392
- const installAllBtn = el('button', 'hqpu-btn hqpu-btn--block')
1393
- installAllBtn.type = 'button'
1394
- const ignoreBtn = el('button', 'hqpu-btn--ignore', copy.ui.ignoreBtn)
1099
+ const ignoreBtn = el('button', 'hqpu-link')
1395
1100
  ignoreBtn.type = 'button'
1396
- footActions.append(installAllBtn, ignoreBtn)
1397
- const footnote = el('p', 'hqpu-footnote', copy.ui.footnote)
1398
- footer.append(footMeta, footActions, footnote)
1399
- panel.append(footer)
1101
+ footMeta.append(footCount, ignoreBtn)
1102
+ footer.append(cta, footMeta, el('p', 'hqpu-footnote', copy.ui.footnote))
1103
+
1104
+ const logDrawer = el('div', 'hqpu-log')
1105
+ logDrawer.dataset.logDrawer = ''
1106
+ logDrawer.hidden = true
1107
+
1108
+ const toastSection = el('div', 'hqpu-toast-host')
1109
+ main.append(scroll, footer, logDrawer, toastSection)
1110
+ panel.append(main)
1400
1111
 
1401
1112
  overlay.append(scrim, panel)
1402
1113
  document.body.appendChild(overlay)
1403
1114
  toastHost = toastSection
1115
+ activeCopy = copy
1404
1116
  panel.focus()
1405
1117
 
1406
1118
  const disposers: Array<() => void> = []
1407
1119
 
1408
- // ── Runtime update: arm → confirm, then hand off to hq-edge ──
1409
- // Live in the panel's closure (not the report) because they are UI state, and
1410
- // a store refresh must not silently disarm a confirm the user just armed.
1120
+ // ── Panel-closure UI state (survives store refreshes) ──
1411
1121
  let runtimeArmed = false
1412
1122
  let runtimeBusy = false
1413
1123
  let runtimeAccepted = false
1414
-
1415
- // ── Panel-closure UI state (survive store refreshes) ──
1416
- const changelogOpen = new Set<string>()
1417
- let allOpen = false
1418
1124
  let settingsOpen = false
1125
+ let settingsBuilt = false
1126
+ let logOpen = false
1419
1127
  let dismissedAlert = false
1128
+ let changesAllOpen = false
1129
+ const changesOpen = new Set<string>()
1130
+ let upToDateOpen = false
1131
+ let ignoredOpen = false
1132
+ let releaseOpen = false
1133
+ let saving = false
1134
+ let pluginDraft = ''
1135
+ let runtimeDraft = ''
1420
1136
 
1421
- // Source-editor state, same reasoning: a background refresh must not discard
1422
- // a half-typed URL or silently close either editor.
1423
- const runtimeSourceState: SourceState = { editing: false, saving: false, draft: '' }
1424
- const pluginSourceState: SourceState = { editing: false, saving: false, draft: '' }
1425
-
1426
- // Delegated, not spread: `...sourceActionsFor(...)` would evaluate the
1427
- // getters once and freeze `sourceEditing: false` — the editor would never
1428
- // open. Read every member through the live object instead.
1429
- const runtimeSourceActions = sourceActionsFor('runtime', runtimeSourceState)
1430
1137
  const runtimeActions: RuntimeActions = {
1431
1138
  get busy() {
1432
1139
  return runtimeBusy
@@ -1448,87 +1155,51 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1448
1155
  onConfirm: () => {
1449
1156
  void requestRuntime()
1450
1157
  },
1451
- get sourceEditing() {
1452
- return runtimeSourceActions.sourceEditing
1453
- },
1454
- get sourceSaving() {
1455
- return runtimeSourceActions.sourceSaving
1456
- },
1457
- get sourceDraft() {
1458
- return runtimeSourceActions.sourceDraft
1459
- },
1460
- onSourceEdit: () => runtimeSourceActions.onSourceEdit(),
1461
- onSourceDraft: (value: string) => runtimeSourceActions.onSourceDraft(value),
1462
- onSourceSave: () => runtimeSourceActions.onSourceSave(),
1463
- onSourceCancel: () => runtimeSourceActions.onSourceCancel(),
1464
- onSourceReset: () => runtimeSourceActions.onSourceReset(),
1465
1158
  }
1466
1159
 
1467
- const pluginActions: SourceEditorActions = sourceActionsFor('plugin', pluginSourceState)
1160
+ /** Fill both drafts from the report's URLs in effect. */
1161
+ function syncDrafts(): void {
1162
+ const report = store.report()
1163
+ pluginDraft = normalizeSource(report?.pluginSource)?.url ?? ''
1164
+ runtimeDraft = normalizeSource(report?.runtime.source)?.url ?? ''
1165
+ }
1468
1166
 
1469
- /** Build the {@link SourceEditorActions} half for one manifest source. */
1470
- function sourceActionsFor(
1471
- kind: SourceKind,
1472
- state: SourceState,
1473
- ): SourceEditorActions {
1474
- return {
1475
- get sourceEditing() {
1476
- return state.editing
1477
- },
1478
- get sourceSaving() {
1479
- return state.saving
1480
- },
1481
- get sourceDraft() {
1482
- return state.draft
1483
- },
1484
- onSourceEdit: () => {
1485
- // Prefill with the URL in effect, so 保存 is a no-op unless edited.
1486
- const report = store.report()
1487
- const source =
1488
- kind === 'runtime' ? normalizeSource(report?.runtime.source) : normalizeSource(report?.pluginSource)
1489
- state.draft = source?.url ?? ''
1490
- state.editing = true
1491
- render()
1492
- },
1493
- onSourceDraft: (value: string) => {
1494
- state.draft = value
1495
- },
1496
- onSourceSave: () => {
1497
- void submitSource(kind, state)
1498
- },
1499
- onSourceCancel: () => {
1500
- state.editing = false
1501
- render()
1502
- },
1503
- onSourceReset: () => {
1504
- void submitSource(kind, state, '')
1505
- },
1167
+ function toggleSettings(): void {
1168
+ settingsOpen = !settingsOpen
1169
+ if (settingsOpen) {
1170
+ syncDrafts()
1171
+ settingsBuilt = false
1506
1172
  }
1173
+ render()
1507
1174
  }
1508
1175
 
1509
1176
  /**
1510
- * Save one manifest source — or clear the override with an empty URL —
1511
- * then re-run discovery.
1177
+ * Save both manifest sources, then re-run discovery and close the strip.
1512
1178
  *
1513
1179
  * The re-check is not optional: the report is cached, so without a fresh
1514
- * `POST /check` the facts and the update button would keep naming the OLD
1515
- * manifest's offer after the source changed. hq-edge answers 422 with a
1516
- * message meant for a person on refusal (bad scheme, credentials, …), which
1517
- * the toast shows verbatim.
1180
+ * `POST /check` the list would keep naming the OLD manifest's offer after the
1181
+ * source changed. hq-edge answers 422 with a message meant for a person on
1182
+ * refusal (bad scheme, credentials, …), which the toast shows verbatim —
1183
+ * and the strip stays open with the draft intact so it can be fixed.
1518
1184
  */
1519
- async function submitSource(
1520
- kind: SourceKind,
1521
- state: SourceState,
1522
- url: string = state.draft,
1523
- ): Promise<void> {
1524
- state.saving = true
1185
+ async function saveSources(): Promise<void> {
1186
+ const report = store.report()
1187
+ const pluginSource = normalizeSource(report?.pluginSource)
1188
+ const runtimeSource = normalizeSource(report?.runtime.source)
1189
+ saving = true
1190
+ settingsBuilt = false
1525
1191
  render()
1526
- const labels = kind === 'runtime' ? copy.runtime : copy.pluginSource
1527
1192
  try {
1528
- if (kind === 'runtime') await setRuntimeUpdateSource(url)
1529
- else await setPluginUpdateSource(url)
1530
- state.editing = false
1531
- showToast(labels.saved, 'success')
1193
+ const calls: Array<Promise<unknown>> = []
1194
+ if (pluginSource !== null && pluginDraft !== pluginSource.url) {
1195
+ calls.push(setPluginUpdateSource(pluginDraft))
1196
+ }
1197
+ if (runtimeSource !== null && runtimeDraft !== runtimeSource.url) {
1198
+ calls.push(setRuntimeUpdateSource(runtimeDraft))
1199
+ }
1200
+ await Promise.all(calls)
1201
+ settingsOpen = false
1202
+ showToast(copy.pluginSource.saved, 'success')
1532
1203
  try {
1533
1204
  await checkUpdates()
1534
1205
  await store.refresh()
@@ -1538,11 +1209,40 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1538
1209
  }
1539
1210
  } catch (err: unknown) {
1540
1211
  showToast(
1541
- `${labels.saveFailed}: ${err instanceof Error ? err.message : String(err)}`,
1212
+ `${copy.pluginSource.saveFailed}: ${err instanceof Error ? err.message : String(err)}`,
1542
1213
  'error',
1543
1214
  )
1544
1215
  } finally {
1545
- state.saving = false
1216
+ saving = false
1217
+ settingsBuilt = false
1218
+ render()
1219
+ }
1220
+ }
1221
+
1222
+ /** Clear one user override so the configured URL applies again. */
1223
+ async function resetSource(kind: SourceKind): Promise<void> {
1224
+ saving = true
1225
+ settingsBuilt = false
1226
+ render()
1227
+ try {
1228
+ if (kind === 'runtime') await setRuntimeUpdateSource('')
1229
+ else await setPluginUpdateSource('')
1230
+ showToast(kind === 'runtime' ? copy.runtime.saved : copy.pluginSource.saved, 'success')
1231
+ try {
1232
+ await checkUpdates()
1233
+ await store.refresh()
1234
+ } catch {
1235
+ /* saved anyway */
1236
+ }
1237
+ syncDrafts()
1238
+ } catch (err: unknown) {
1239
+ showToast(
1240
+ `${copy.pluginSource.saveFailed}: ${err instanceof Error ? err.message : String(err)}`,
1241
+ 'error',
1242
+ )
1243
+ } finally {
1244
+ saving = false
1245
+ settingsBuilt = false
1546
1246
  render()
1547
1247
  }
1548
1248
  }
@@ -1563,13 +1263,10 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1563
1263
  return
1564
1264
  }
1565
1265
  // 202 Accepted: hq-edge exits to let the EDA host swap the runtime and
1566
- // relaunch. This panel is about to go away with the restart, so say so —
1567
- // and latch it, so a store refresh cannot re-offer the button for a
1568
- // server that is already gone.
1266
+ // relaunch. Latch it, so a store refresh cannot re-offer the button for
1267
+ // a server that is already gone.
1569
1268
  runtimeAccepted = true
1570
1269
  showToast(copy.runtime.updateAccepted, 'success')
1571
- // Adopt the new durable job immediately so the active-job view renders
1572
- // (it polls from here; this process is about to be swapped away anyway).
1573
1270
  void store.fetchStatus()
1574
1271
  } catch (err: unknown) {
1575
1272
  showToast(
@@ -1585,7 +1282,7 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1585
1282
  /** Retry a failed/abandoned job: plugin-set → a fresh install; runtime →
1586
1283
  * re-arm the runtime confirm flow. Always a NEW job, never re-clicking the
1587
1284
  * settled job id. */
1588
- const retryJob = (kind: 'plugin-set' | 'runtime'): void => {
1285
+ function retryJob(kind: 'plugin-set' | 'runtime'): void {
1589
1286
  if (kind === 'runtime') {
1590
1287
  runtimeArmed = true
1591
1288
  render()
@@ -1594,100 +1291,543 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1594
1291
  }
1595
1292
  }
1596
1293
 
1597
- const renderJobSection = (): void => {
1294
+ /** The newest settled job — what the log drawer is about. */
1295
+ function settledJob(): UpdateJob | null {
1296
+ const latest = store.recentJobs()[0]
1297
+ return latest !== undefined && TERMINAL_PHASES.has(latest.phase) ? latest : null
1298
+ }
1299
+
1300
+ function toggleLog(): void {
1301
+ logOpen = !logOpen
1302
+ render()
1303
+ }
1304
+
1305
+ async function copyLogId(id: string): Promise<void> {
1306
+ try {
1307
+ const clipboard = navigator.clipboard
1308
+ if (clipboard === undefined) throw new Error('clipboard unavailable')
1309
+ await clipboard.writeText(id)
1310
+ showToast(copy.ui.logCopied, 'success')
1311
+ } catch {
1312
+ showToast(copy.ui.logCopyFailed, 'error')
1313
+ }
1314
+ }
1315
+
1316
+ // ── Section renderers ──
1317
+
1318
+ function renderSettings(): void {
1319
+ if (!settingsOpen) {
1320
+ settingsEl.hidden = true
1321
+ settingsEl.textContent = ''
1322
+ return
1323
+ }
1324
+ settingsEl.hidden = false
1325
+ // Rebuilding on every store emit would blow away the caret of whoever is
1326
+ // typing, so the DOM is built once per open (or per explicit force).
1327
+ if (settingsBuilt) return
1328
+ settingsBuilt = true
1329
+ settingsEl.textContent = ''
1330
+
1331
+ const report = store.report()
1332
+ const pluginSource = normalizeSource(report?.pluginSource)
1333
+ const runtimeSource = normalizeSource(report?.runtime.source)
1334
+
1335
+ const row = el('div', 'hqpu-settings-row')
1336
+ row.append(el('span', 'hqpu-settings-title', copy.ui.settingsTitle))
1337
+ const overridden = (pluginSource?.override ?? false) || (runtimeSource?.override ?? false)
1338
+ const badge = el('span', 'hqpu-origin-badge', overridden ? copy.ui.originCustom : copy.ui.originDefault)
1339
+ badge.dataset.origin = overridden ? 'override' : 'config'
1340
+ row.append(badge)
1341
+ settingsEl.append(row)
1342
+
1343
+ const field = (
1344
+ kind: SourceKind,
1345
+ source: RuntimeUpdateSource | null,
1346
+ label: string,
1347
+ ariaLabel: string,
1348
+ placeholder: string,
1349
+ draft: string,
1350
+ onDraft: (value: string) => void,
1351
+ ): void => {
1352
+ if (source === null) return
1353
+ const wrap = el('div', 'hqpu-field')
1354
+ const labelRow = el('div', 'hqpu-field-head')
1355
+ const id = `hqpu-source-${kind}`
1356
+ const labelEl = el('label', '', label)
1357
+ labelEl.htmlFor = id
1358
+ labelRow.append(labelEl)
1359
+ if (source.override) {
1360
+ const reset = linkButton('hqpu-link hqpu-link--tiny', copy.ui.resetSource, () => {
1361
+ void resetSource(kind)
1362
+ })
1363
+ reset.setAttribute(`data-${kind}-source-reset`, '')
1364
+ labelRow.append(reset)
1365
+ }
1366
+ wrap.append(labelRow)
1367
+ const input = el('input', 'hqpu-source-input')
1368
+ input.id = id
1369
+ input.type = 'url'
1370
+ input.value = draft
1371
+ input.placeholder = placeholder
1372
+ input.setAttribute('aria-label', ariaLabel)
1373
+ input.setAttribute(`data-${kind}-source-input`, '')
1374
+ input.disabled = saving
1375
+ input.spellcheck = false
1376
+ input.addEventListener('input', () => {
1377
+ // Draft-only: no re-render, so the caret and IME stay untouched.
1378
+ onDraft(input.value)
1379
+ })
1380
+ // Enter saves both; Escape closes the strip without saving.
1381
+ input.addEventListener('keydown', (ev) => {
1382
+ if (ev.key === 'Enter') {
1383
+ ev.preventDefault()
1384
+ void saveSources()
1385
+ } else if (ev.key === 'Escape') {
1386
+ ev.preventDefault()
1387
+ ev.stopPropagation()
1388
+ settingsOpen = false
1389
+ syncDrafts()
1390
+ render()
1391
+ }
1392
+ })
1393
+ wrap.append(input)
1394
+ settingsEl.append(wrap)
1395
+ }
1396
+
1397
+ field(
1398
+ 'plugin',
1399
+ pluginSource,
1400
+ copy.ui.pluginUrlLabel,
1401
+ copy.pluginSource.inputLabel,
1402
+ copy.pluginSource.placeholder,
1403
+ pluginDraft,
1404
+ (value) => {
1405
+ pluginDraft = value
1406
+ },
1407
+ )
1408
+ field(
1409
+ 'runtime',
1410
+ runtimeSource,
1411
+ copy.ui.runtimeUrlLabel,
1412
+ copy.runtime.sourceInputLabel,
1413
+ copy.runtime.sourcePlaceholder,
1414
+ runtimeDraft,
1415
+ (value) => {
1416
+ runtimeDraft = value
1417
+ },
1418
+ )
1419
+ if (pluginSource === null && runtimeSource === null) {
1420
+ settingsEl.append(el('p', 'hqpu-status', copy.runtime.notConfigured))
1421
+ }
1422
+
1423
+ const actions = el('div', 'hqpu-settings-actions')
1424
+ const cancel = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.cancel)
1425
+ cancel.type = 'button'
1426
+ cancel.addEventListener('click', () => {
1427
+ settingsOpen = false
1428
+ syncDrafts()
1429
+ render()
1430
+ })
1431
+ const save = el('button', 'hqpu-btn hqpu-btn--brand hqpu-btn--sm', copy.ui.saveAndRefresh)
1432
+ save.type = 'button'
1433
+ save.disabled = saving
1434
+ save.setAttribute('data-source-save', '')
1435
+ save.addEventListener('click', () => {
1436
+ void saveSources()
1437
+ })
1438
+ actions.append(cancel, save)
1439
+ settingsEl.append(actions)
1440
+ }
1441
+
1442
+ function renderJobSection(): void {
1598
1443
  jobSection.textContent = ''
1599
1444
  const active = store.activeJob()
1600
1445
  if (active !== null && !TERMINAL_PHASES.has(active.phase)) {
1601
- // An active job leads the panel — resumed on open, polled to its end.
1602
1446
  jobSection.append(activeJobView(active, copy))
1447
+ }
1448
+ }
1449
+
1450
+ function renderBanners(report: PluginUpdateReport | null, jobActive: boolean): void {
1451
+ bannerSection.textContent = ''
1452
+ if (report !== null) {
1453
+ const banner = checkBanner(report, copy, () => {
1454
+ void store.check()
1455
+ })
1456
+ if (banner !== null) bannerSection.append(banner)
1457
+ }
1458
+ // A terminal non-success job leads the body; the full log is one click away
1459
+ // in the drawer, but the outcome itself must not hide there.
1460
+ if (jobActive) return
1461
+ const settled = settledJob()
1462
+ if (settled !== null && settled.phase !== 'done') {
1463
+ bannerSection.append(
1464
+ jobResultBanner(
1465
+ settled,
1466
+ copy,
1467
+ () => retryJob(settled.kind),
1468
+ () => {
1469
+ logOpen = true
1470
+ render()
1471
+ },
1472
+ ),
1473
+ )
1474
+ }
1475
+ }
1476
+
1477
+ function renderList(report: PluginUpdateReport | null, busy: boolean): void {
1478
+ listSection.textContent = ''
1479
+ bandSection.textContent = ''
1480
+ if (report === null) {
1481
+ listSection.append(skeleton(copy))
1603
1482
  return
1604
1483
  }
1605
- const recent = store.recentJobs()
1606
- const latest = recent[0]
1607
- if (latest !== undefined && TERMINAL_PHASES.has(latest.phase)) {
1608
- jobSection.append(resultView(latest, copy, () => retryJob(latest.kind)))
1609
- const older = recentJobsList(recent, copy)
1610
- if (older !== null) jobSection.append(older)
1484
+ const band = decisionBand(report, copy)
1485
+ if (band !== null) bandSection.append(band)
1486
+
1487
+ const actionable = report.items.filter(isActionable)
1488
+ if (actionable.length > 0) {
1489
+ const section = el('section', 'hqpu-group')
1490
+ section.dataset.group = copy.group.actionable
1491
+ const head = el('div', 'hqpu-sec-head')
1492
+ const titleWrap = el('div', 'hqpu-sec-title')
1493
+ titleWrap.append(el('span', '', copy.group.actionable))
1494
+ titleWrap.append(el('span', 'hqpu-count-pill', String(actionable.length)))
1495
+ head.append(titleWrap)
1496
+ const anyChanges = actionable.some((i) => Array.isArray(i.changes) && i.changes.length > 0)
1497
+ if (anyChanges) {
1498
+ head.append(
1499
+ linkButton(
1500
+ 'hqpu-link',
1501
+ changesAllOpen ? copy.ui.collapseLogs : copy.ui.expandLogs,
1502
+ () => {
1503
+ changesAllOpen = !changesAllOpen
1504
+ if (!changesAllOpen) changesOpen.clear()
1505
+ render()
1506
+ },
1507
+ ),
1508
+ )
1509
+ }
1510
+ section.append(head)
1511
+ const list = el('ul', 'hqpu-cards')
1512
+ for (const item of actionable) {
1513
+ list.append(
1514
+ pluginCard(item, copy, {
1515
+ changesOpen: changesAllOpen || changesOpen.has(item.name),
1516
+ onToggleChanges: () => {
1517
+ if (changesOpen.has(item.name)) changesOpen.delete(item.name)
1518
+ else changesOpen.add(item.name)
1519
+ render()
1520
+ },
1521
+ onInstall: () => {
1522
+ void runInstall(store, copy, [item.name])
1523
+ },
1524
+ onIgnore: () => {
1525
+ void togglePluginIgnore(item)
1526
+ },
1527
+ busy,
1528
+ }),
1529
+ )
1530
+ }
1531
+ section.append(list)
1532
+ listSection.append(section)
1533
+ }
1534
+ }
1535
+
1536
+ function renderExtras(report: PluginUpdateReport | null): void {
1537
+ extraSection.textContent = ''
1538
+ if (report === null) return
1539
+
1540
+ const alert = runtimeResultAlert(report, copy, dismissedAlert, () => {
1541
+ dismissedAlert = true
1542
+ render()
1543
+ })
1544
+ if (alert !== null) extraSection.append(alert)
1545
+
1546
+ const engine = runtimeEngineCard(report, copy, runtimeActions)
1547
+ if (engine !== null) extraSection.append(engine)
1548
+
1549
+ const byState = (state: PluginUpdateItem['state']): PluginUpdateItem[] =>
1550
+ report.items.filter((item) => item.state === state)
1551
+
1552
+ const ignored = byState('ignored')
1553
+ if (ignored.length > 0) {
1554
+ extraSection.append(
1555
+ disclosure({
1556
+ id: 'hqpu-disc-ignored',
1557
+ title: copy.group.ignored,
1558
+ count: `(${ignored.length})`,
1559
+ note: copy.ui.ignoredNote,
1560
+ open: ignoredOpen,
1561
+ onToggle: () => {
1562
+ ignoredOpen = !ignoredOpen
1563
+ render()
1564
+ },
1565
+ build: () => {
1566
+ const ul = el('ul', 'hqpu-rows')
1567
+ for (const item of ignored) {
1568
+ ul.append(
1569
+ pluginRow(item, copy, () => {
1570
+ void togglePluginIgnore(item)
1571
+ }),
1572
+ )
1573
+ }
1574
+ return [ul]
1575
+ },
1576
+ }),
1577
+ )
1578
+ }
1579
+
1580
+ const current = byState('current')
1581
+ if (current.length > 0) {
1582
+ extraSection.append(
1583
+ disclosure({
1584
+ id: 'hqpu-disc-current',
1585
+ title: copy.group.current,
1586
+ count: `(${current.length})`,
1587
+ note: copy.ui.upToDateNote,
1588
+ open: upToDateOpen,
1589
+ onToggle: () => {
1590
+ upToDateOpen = !upToDateOpen
1591
+ render()
1592
+ },
1593
+ build: () => {
1594
+ const ul = el('ul', 'hqpu-rows')
1595
+ for (const item of current) ul.append(pluginRow(item, copy, () => undefined))
1596
+ return [ul]
1597
+ },
1598
+ }),
1599
+ )
1600
+ }
1601
+
1602
+ if (hasReleaseContent(report.release)) {
1603
+ extraSection.append(
1604
+ disclosure({
1605
+ id: 'hqpu-disc-release',
1606
+ title: copy.ui.releaseTitle,
1607
+ note: report.manifestVersion !== null ? `v${report.manifestVersion}` : undefined,
1608
+ open: releaseOpen,
1609
+ onToggle: () => {
1610
+ releaseOpen = !releaseOpen
1611
+ render()
1612
+ },
1613
+ build: () => releaseBody(report.release as ReleaseInfo, copy),
1614
+ }),
1615
+ )
1616
+ }
1617
+
1618
+ if (report.items.length === 0 && typeof report.lastError !== 'string') {
1619
+ extraSection.append(el('div', 'hqpu-empty', copy.empty))
1611
1620
  }
1612
1621
  }
1613
1622
 
1614
- const render = (): void => {
1623
+ function renderFooter(report: PluginUpdateReport | null, busy: boolean): void {
1624
+ const actionable = report === null ? 0 : actionableUpdateCount(report)
1625
+ const setVersion = report?.manifestVersion ?? null
1626
+ const failed = typeof report?.lastError === 'string' && (report?.lastError ?? '').length > 0
1627
+ const runtimePending = report !== null && runtimeUpdateOffered(report.runtime)
1628
+ const anything = actionable > 0 || runtimePending
1629
+
1630
+ ctaSpinner.hidden = !busy
1631
+ cta.dataset.busy = busy ? 'true' : 'false'
1632
+ cta.setAttribute('aria-busy', busy ? 'true' : 'false')
1633
+
1634
+ if (busy) {
1635
+ ctaLabel.textContent = copy.ui.ctaInstalling
1636
+ cta.disabled = true
1637
+ footCount.textContent = copy.ui.footerBusy
1638
+ } else if (report === null) {
1639
+ ctaLabel.textContent = copy.ui.ctaNoSet
1640
+ cta.disabled = true
1641
+ footCount.textContent = ''
1642
+ } else if (failed) {
1643
+ // Installing against a stale manifest is the one path that can only end
1644
+ // in a 422 — say so instead of offering a button that cannot work.
1645
+ ctaLabel.textContent = copy.ui.ctaFailed
1646
+ cta.disabled = true
1647
+ footCount.textContent = copy.ui.footerFailed
1648
+ } else if (setVersion === null) {
1649
+ ctaLabel.textContent = copy.ui.ctaNoSet
1650
+ cta.disabled = true
1651
+ footCount.textContent = copy.ui.checkedNever
1652
+ } else if (actionable === 0 && runtimePending) {
1653
+ // The plugin set is done; the only remaining offer is the runtime, which
1654
+ // needs its own confirm — the CTA must not imply it would install it.
1655
+ ctaLabel.textContent = copy.ui.ctaNoPlugins
1656
+ cta.disabled = true
1657
+ footCount.textContent = copy.ui.footerRuntimeOnly
1658
+ } else if (actionable === 0 && !runtimePending) {
1659
+ ctaLabel.textContent = copy.ui.ctaUpToDate
1660
+ cta.disabled = true
1661
+ footCount.textContent = copy.ui.footerAllGood
1662
+ } else {
1663
+ ctaLabel.textContent = copy.ui.cta(actionable)
1664
+ cta.disabled = false
1665
+ footCount.textContent = copy.ui.footerCount(actionable)
1666
+ }
1667
+ cta.onclick = () => {
1668
+ void runInstall(store, copy)
1669
+ }
1670
+
1671
+ // Set-level mute: only with an evaluated set and something to mute.
1672
+ const canMute = setVersion !== null && report !== null && !busy
1673
+ ignoreBtn.hidden = !canMute || (!anything && !report?.ignoredSet)
1674
+ ignoreBtn.textContent = report?.ignoredSet === true ? copy.ui.unignoreBtn : copy.ui.ignoreBtn
1675
+ ignoreBtn.onclick = () => {
1676
+ if (setVersion === null) return
1677
+ const ignored = report?.ignoredSet === true
1678
+ const action = ignored ? unignoreOffer(setVersion) : ignoreOffer(setVersion)
1679
+ // `check()`, never `refresh()`: ignoring only writes the decision to
1680
+ // hq-edge's state file, and its cached report keeps every item at
1681
+ // `update` until discovery runs again.
1682
+ void action
1683
+ .then(() => store.check())
1684
+ .catch((err: unknown) => {
1685
+ showToast(
1686
+ `${copy.ignoreError}: ${err instanceof Error ? err.message : String(err)}`,
1687
+ 'error',
1688
+ )
1689
+ })
1690
+ }
1691
+ }
1692
+
1693
+ function renderLogDrawer(jobActive: boolean): void {
1694
+ const job = settledJob()
1695
+ const available = !jobActive && job !== null
1696
+ logBtn.disabled = !available
1697
+ logBtn.setAttribute('aria-pressed', logOpen && available ? 'true' : 'false')
1698
+ const label =
1699
+ jobActive ? copy.ui.logBusy : job === null ? copy.ui.logNone : logOpen ? copy.ui.logHide : copy.ui.logShow
1700
+ logBtn.title = label
1701
+ logBtn.setAttribute('aria-label', label)
1702
+
1703
+ // A running job (or none at all) cannot have a log; drop the open flag so
1704
+ // the drawer does not snap back open once the job settles.
1705
+ if (!available || !logOpen) {
1706
+ if (!available) logOpen = false
1707
+ logDrawer.hidden = true
1708
+ logDrawer.textContent = ''
1709
+ main.dataset.logOpen = '0'
1710
+ return
1711
+ }
1712
+ logDrawer.hidden = false
1713
+ logDrawer.textContent = ''
1714
+ // The drawer is absolutely positioned, so it cannot size its own box: on a
1715
+ // short body (nothing left to update) it would be squeezed between the
1716
+ // header and the panel edge. The flag gives `.hqpu-main` a floor while it
1717
+ // is open, and nothing at all once it closes.
1718
+ main.dataset.logOpen = '1'
1719
+ for (const node of buildLogDrawer(
1720
+ job,
1721
+ store.recentJobs(),
1722
+ copy,
1723
+ (id) => {
1724
+ void copyLogId(id)
1725
+ },
1726
+ () => {
1727
+ logOpen = false
1728
+ render()
1729
+ },
1730
+ )) {
1731
+ logDrawer.append(node)
1732
+ }
1733
+ }
1734
+
1735
+ function render(): void {
1615
1736
  const report = store.report()
1616
- // The footer "installing" state is owned by the store flag OR any active
1617
- // durable job — buttons stay disabled while either is true.
1618
- const jobActive =
1619
- store.activeJob() !== null && !TERMINAL_PHASES.has(store.activeJob()!.phase)
1620
- const installing = store.installing() || jobActive
1621
- const stale = report === null
1622
- refreshBtn.disabled = stale
1623
- refreshBtn.title = copy.refresh
1624
-
1625
- // Header sub-line: "上次检查: N 分钟前" (from report.checkedAt).
1626
- const ago = relativeAgo(report?.checkedAt, copy)
1627
- sub.textContent = ago !== null ? copy.ui.checkedAgo(ago) : copy.ui.checkedUnknown
1737
+ const active = store.activeJob()
1738
+ const jobActive = active !== null && !TERMINAL_PHASES.has(active.phase)
1739
+ const busy = store.installing() || jobActive
1628
1740
 
1629
- // Collapsible advanced settings panel (gear-toggled): existing source
1630
- // editors — plugin + runtime manifest URL override, save→check→refresh.
1631
- renderSettingsPanel(settingsPanel, report, copy, settingsOpen, pluginActions, runtimeSourceActions)
1741
+ refreshBtn.disabled = report === null
1742
+ gearBtn.setAttribute('aria-pressed', settingsOpen ? 'true' : 'false')
1743
+ gearBtn.title = copy.ui.gearTooltip
1632
1744
 
1745
+ // Header sub-line: the set version and the relative check time, once.
1746
+ const ago = relativeAgo(report?.checkedAt, copy)
1747
+ const setLabel =
1748
+ report?.manifestVersion !== null && report?.manifestVersion !== undefined
1749
+ ? copy.ui.setVersion(report.manifestVersion)
1750
+ : null
1751
+ if (setLabel !== null && ago !== null) sub.textContent = copy.ui.subJoined(setLabel, ago)
1752
+ else sub.textContent = setLabel ?? ago ?? copy.ui.checkedUnknown
1753
+
1754
+ renderSettings()
1633
1755
  renderJobSection()
1634
- if (stale) {
1635
- listSection.textContent = ''
1636
- listSection.append(skeleton(copy))
1637
- } else {
1638
- renderList(
1639
- listSection,
1640
- report,
1641
- copy,
1642
- changelogOpen,
1643
- allOpen,
1644
- (name) => {
1645
- if (changelogOpen.has(name)) changelogOpen.delete(name)
1646
- else changelogOpen.add(name)
1647
- render()
1648
- },
1649
- () => {
1650
- allOpen = !allOpen
1651
- render()
1652
- },
1653
- () => {
1654
- void store.check()
1655
- },
1656
- (name) => {
1657
- void runInstall(store, copy, [name])
1658
- },
1659
- (item) => {
1660
- const report = store.report()
1661
- if (report?.manifestVersion === null || report?.manifestVersion === undefined) return
1662
- const action = item.state === 'ignored'
1663
- ? unignorePlugin(report.manifestVersion, item.name)
1664
- : ignorePlugin(report.manifestVersion, item.name)
1665
- void action.then(() => store.check()).catch(() => undefined)
1666
- },
1756
+ renderBanners(report, jobActive)
1757
+ renderList(report, busy)
1758
+ renderExtras(report)
1759
+ renderFooter(report, busy)
1760
+ renderLogDrawer(jobActive)
1761
+ }
1762
+
1763
+ /** Flip one plugin's per-item ignore flag, then re-run discovery. */
1764
+ async function togglePluginIgnore(item: PluginUpdateItem): Promise<void> {
1765
+ const setVersion = store.report()?.manifestVersion ?? null
1766
+ if (setVersion === null) return
1767
+ const action =
1768
+ item.state === 'ignored'
1769
+ ? unignorePlugin(setVersion, item.name)
1770
+ : ignorePlugin(setVersion, item.name)
1771
+ try {
1772
+ await action
1773
+ // Discovery must re-run for the item to come back as `ignored`; a
1774
+ // refusal has to be visible, never silently swallowed.
1775
+ await store.check()
1776
+ } catch (err: unknown) {
1777
+ showToast(
1778
+ `${copy.ignoreError}: ${err instanceof Error ? err.message : String(err)}`,
1779
+ 'error',
1667
1780
  )
1668
1781
  }
1669
- renderRuntime(runtimeSection, report, copy, runtimeActions, dismissedAlert, () => {
1670
- dismissedAlert = true
1671
- render()
1672
- })
1673
- renderFooter(footer, installAllBtn, ignoreBtn, footCount, footSet, report, copy, installing, store)
1674
1782
  }
1675
1783
 
1676
1784
  const unsubscribe = store.subscribe(render)
1677
1785
  disposers.push(unsubscribe)
1678
1786
 
1679
1787
  // Refresh on open (bounded; never block the shell).
1680
- void store.refresh().then(() => {
1681
- // Store notification re-renders already; nothing extra to do.
1682
- })
1788
+ void store.refresh()
1683
1789
 
1684
- // Escape closes, the shell-wide dialog contract.
1790
+ // Escape peels one layer at a time; the shell-wide contract still holds.
1685
1791
  const onKeyDown = (ev: KeyboardEvent): void => {
1686
1792
  if (ev.key === 'Escape') {
1687
1793
  ev.stopPropagation()
1794
+ if (logOpen) {
1795
+ logOpen = false
1796
+ render()
1797
+ return
1798
+ }
1799
+ if (settingsOpen) {
1800
+ settingsOpen = false
1801
+ syncDrafts()
1802
+ render()
1803
+ return
1804
+ }
1688
1805
  onClose()
1806
+ return
1807
+ }
1808
+ if (ev.key === 'Tab') trapFocus(ev)
1809
+ }
1810
+
1811
+ /** Keep Tab inside the dialog — it declares `aria-modal="true"`. */
1812
+ function trapFocus(ev: KeyboardEvent): void {
1813
+ const focusable = [
1814
+ ...panel.querySelectorAll<HTMLElement>(
1815
+ 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
1816
+ ),
1817
+ ].filter((node) => node.closest('[hidden]') === null)
1818
+ if (focusable.length === 0) return
1819
+ const first = focusable[0]!
1820
+ const last = focusable[focusable.length - 1]!
1821
+ const activeEl = document.activeElement
1822
+ if (ev.shiftKey && (activeEl === first || activeEl === panel)) {
1823
+ ev.preventDefault()
1824
+ last.focus()
1825
+ } else if (!ev.shiftKey && activeEl === last) {
1826
+ ev.preventDefault()
1827
+ first.focus()
1689
1828
  }
1690
1829
  }
1830
+
1691
1831
  document.addEventListener('keydown', onKeyDown)
1692
1832
  disposers.push(() => document.removeEventListener('keydown', onKeyDown))
1693
1833
 
@@ -1702,8 +1842,79 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1702
1842
  if (toastTimer !== null) window.clearTimeout(toastTimer)
1703
1843
  toastTimer = null
1704
1844
  if (toastHost === toastSection) toastHost = null
1845
+ if (activeCopy === copy) activeCopy = null
1705
1846
  overlay.remove()
1847
+ // Focus goes back where it came from — the sidebar row that opened this.
1848
+ if (previouslyFocused !== null && previouslyFocused.isConnected) previouslyFocused.focus()
1706
1849
  }
1707
1850
 
1708
1851
  render()
1709
1852
  }
1853
+
1854
+ /**
1855
+ * Run the install transaction (a new durable job), then let the store poll it.
1856
+ *
1857
+ * @param pluginNames - omit for the whole set (the footer CTA), or pass one
1858
+ * name for a card's 单独更新. hq-edge honours the subset.
1859
+ */
1860
+ async function runInstall(
1861
+ store: UpdateStoreLike,
1862
+ copy: UpdateCopy,
1863
+ pluginNames?: string[],
1864
+ ): Promise<void> {
1865
+ const report = store.report()
1866
+ if (report === null) return
1867
+ // Without an evaluated set there is nothing to install: hq-edge has not read
1868
+ // a manifest yet, so the report is still its empty shell.
1869
+ if (report.manifestVersion === null) {
1870
+ showToast(copy.checkFailed, 'error')
1871
+ return
1872
+ }
1873
+ // Flip the installing flag BEFORE the request: this is what disables the
1874
+ // action buttons and shows the in-flight footer state.
1875
+ store.setInstalling(true)
1876
+ try {
1877
+ const result = await installSet(report.manifestVersion, pluginNames)
1878
+ if (result.error !== undefined) {
1879
+ store.setInstalling(false)
1880
+ showToast(`${copy.installError}: ${result.error}`, 'error')
1881
+ return
1882
+ }
1883
+ // 202 accepted: a durable job now owns the lifecycle. The store's /status
1884
+ // poller renders progress and clears the flag when the job settles.
1885
+ showToast(copy.installSuccess, 'success')
1886
+ void store.fetchStatus()
1887
+ } catch (err: unknown) {
1888
+ store.setInstalling(false)
1889
+ showToast(
1890
+ `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
1891
+ 'error',
1892
+ )
1893
+ }
1894
+ }
1895
+
1896
+ /** First-read placeholders, sharing the card layout. */
1897
+ function skeleton(copy: UpdateCopy): HTMLElement {
1898
+ const section = el('section', 'hqpu-group')
1899
+ section.setAttribute('role', 'status')
1900
+ section.dataset.loading = ''
1901
+ section.setAttribute('aria-label', copy.loading)
1902
+ const list = el('ul', 'hqpu-cards')
1903
+ list.setAttribute('aria-hidden', 'true')
1904
+ for (let index = 0; index < 3; index += 1) {
1905
+ const card = el('li', 'hqpu-card')
1906
+ const head = el('div', 'hqpu-card-head')
1907
+ const id = el('div', 'hqpu-card-id')
1908
+ id.append(el('span', 'hqpu-avatar hqpu-skeleton-fill'))
1909
+ const main = el('div', 'hqpu-card-main')
1910
+ const title = el('span', 'hqpu-card-title hqpu-skeleton-fill hqpu-skeleton-title')
1911
+ const sub = el('span', 'hqpu-card-sub hqpu-skeleton-fill hqpu-skeleton-sub')
1912
+ main.append(title, sub)
1913
+ id.append(main)
1914
+ head.append(id)
1915
+ card.append(head)
1916
+ list.append(card)
1917
+ }
1918
+ section.append(list)
1919
+ return section
1920
+ }