@huaqiu/dsh-tool-plugin-update 0.6.12 → 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,200 +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(item: PluginUpdateItem, copy: UpdateCopy, open: boolean): HTMLElement {
135
- const drawer = el('div', 'hqpu-changelog-drawer')
136
- drawer.style.display = open ? '' : 'none'
137
- drawer.append(el('div', 'hqpu-changelog-drawer-title', copy.ui.changelogTitle))
138
- const ul = el('ul')
139
- for (const change of item.changes ?? []) {
140
- if (typeof change === 'string' && change.length > 0) ul.append(el('li', '', change))
141
- }
142
- drawer.append(ul)
143
- return drawer
144
- }
145
-
146
- /** One package card per the mockup: avatar initials, display name + package
147
- * subtitle, old→new version row (new in emerald), a 日志 toggle expanding the
148
- * inline changelog. */
149
- function packageCard(
150
- item: PluginUpdateItem,
151
- copy: UpdateCopy,
152
- open: boolean,
153
- onToggle: () => void,
154
- onInstall: () => void,
155
- onIgnore: () => void,
156
- ): HTMLLIElement {
157
- const card = el('li', 'hqpu-card')
158
- card.dataset.pluginName = item.name
159
- card.dataset.state = item.state
160
-
161
- const head = el('div', 'hqpu-card-head')
162
- const left = el('div')
163
- const avatar = el('span', 'hqpu-avatar', avatarInitials(item))
164
- avatar.setAttribute('aria-hidden', 'true')
165
-
166
- const main = el('div', 'hqpu-card-main')
167
- const titleText =
168
- typeof item.displayName === 'string' && item.displayName.trim().length > 0
169
- ? item.displayName.trim()
170
- : shortName(item.name)
171
- main.append(el('span', 'hqpu-card-title', titleText))
172
- main.append(el('span', 'hqpu-card-sub', shortName(item.name)))
173
-
174
- // old → new version row; the NEW version renders in emerald.
175
- const ver = el('div', 'hqpu-version-row')
176
- if (item.currentVersion !== null && item.currentVersion !== undefined) {
177
- ver.append(el('span', 'hqpu-ver-old', item.currentVersion))
178
- ver.append(el('span', 'hqpu-ver-arrow', '→'))
179
- }
180
- const size = formatMb(item.sizeBytes ?? 0)
181
- ver.append(
182
- el('span', 'hqpu-ver-new', size.length > 0 ? `${item.availableVersion} (${size})` : item.availableVersion),
183
- )
184
- main.append(ver)
185
- left.append(avatar, main)
186
-
187
- const right = el('div', 'hqpu-card-end')
188
- if (item.state === 'update' || item.state === 'install') {
189
- const install = el('button', 'hqpu-log-toggle', copy.installOne)
190
- install.type = 'button'
191
- install.title = copy.installOneTip
192
- install.addEventListener('click', onInstall)
193
- const ignore = el('button', 'hqpu-log-toggle', copy.ignore)
194
- ignore.type = 'button'
195
- ignore.addEventListener('click', onIgnore)
196
- right.append(install, ignore)
197
- } else if (item.state === 'ignored') {
198
- const show = el('button', 'hqpu-log-toggle', copy.unignore)
199
- show.type = 'button'
200
- show.addEventListener('click', onIgnore)
201
- right.append(show)
202
- }
203
- if (Array.isArray(item.changes) && item.changes.length > 0) {
204
- const toggle = el('button', 'hqpu-log-toggle')
205
- toggle.type = 'button'
206
- toggle.dataset.open = open ? 'true' : 'false'
207
- 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>`
208
- toggle.addEventListener('click', onToggle)
209
- right.append(toggle)
210
- }
211
- head.append(left, right)
212
- card.append(head)
213
-
214
- // Inline changelog drawer (actionable items only).
215
- if (Array.isArray(item.changes) && item.changes.length > 0) {
216
- card.append(changelogDrawer(item, copy, open))
217
- }
218
- return card
219
- }
220
-
221
- /** A section: head (title + count pill + expand-all toggle) then its cards. */
222
- function group(
223
- heading: string,
224
- items: PluginUpdateItem[],
225
- copy: UpdateCopy,
226
- changelogOpen: Set<string>,
227
- allOpen: boolean,
228
- onToggle: (name: string) => void,
229
- onToggleAll: () => void,
230
- onInstall: (name: string) => void,
231
- onIgnore: (item: PluginUpdateItem) => void,
232
- ): HTMLElement | null {
233
- if (items.length === 0) return null
234
- const section = el('section', 'hqpu-group')
235
- section.dataset.group = heading
236
-
237
- const head = el('div', 'hqpu-section-head')
238
- const titleWrap = el('div', 'hqpu-section-title')
239
- titleWrap.append(el('span', '', heading))
240
- titleWrap.append(el('span', 'hqpu-count-pill', String(items.length)))
241
- head.append(titleWrap)
242
- // Expand-all only makes sense over actionable, changelog-bearing cards.
243
- const actionable = items.some((i) => Array.isArray(i.changes) && i.changes.length > 0)
244
- if (actionable) {
245
- const all = el('button', 'hqpu-all-changelog', copy.ui.allChangelog)
246
- all.type = 'button'
247
- all.addEventListener('click', onToggleAll)
248
- head.append(all)
249
- }
250
-
251
- const list = el('ul', 'hqpu-cards')
252
- for (const item of items) {
253
- list.append(packageCard(item, copy, allOpen || changelogOpen.has(item.name), () => onToggle(item.name), () => onInstall(item.name), () => onIgnore(item)))
254
- }
255
-
256
- section.append(head, list)
257
- 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)
258
157
  }
259
158
 
260
- /** First-read placeholders, sharing the card and text-line layout. */
261
- function skeleton(copy: UpdateCopy): HTMLElement {
262
- const section = el('section', 'hqpu-group')
263
- section.setAttribute('role', 'status')
264
- section.dataset.loading = ''
265
- const head = el('div', 'hqpu-group-head')
266
- head.setAttribute('aria-hidden', 'true')
267
- const heading = el('span', 'hqpu-group-title hqpu-skeleton-text hqpu-skeleton-heading')
268
- const headingBar = el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar')
269
- heading.append(headingBar)
270
- head.append(heading)
271
-
272
- const list = el('ul', 'hqpu-cards')
273
- list.setAttribute('aria-hidden', 'true')
274
- for (let index = 0; index < 3; index += 1) {
275
- const card = el('li', 'hqpu-card')
276
- const cardHead = el('div', 'hqpu-card-head')
277
- const glyph = el('span', 'hqpu-card-icon hqpu-skeleton-fill hqpu-skeleton-icon')
278
- const main = el('div', 'hqpu-card-main')
279
- const titleRow = el('div', 'hqpu-title-row')
280
- const title = el('span', 'hqpu-card-title hqpu-skeleton-text hqpu-skeleton-title')
281
- title.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
282
- titleRow.append(title)
283
- const desc = el('span', 'hqpu-card-desc hqpu-skeleton-text hqpu-skeleton-desc')
284
- desc.append(el('span', 'hqpu-skeleton-fill hqpu-skeleton-bar'))
285
- main.append(titleRow, desc)
286
- cardHead.append(glyph, main)
287
- const end = el('div', 'hqpu-card-end')
288
- end.setAttribute('aria-hidden', 'true')
289
- cardHead.append(end)
290
- card.append(cardHead)
291
- list.append(card)
292
- }
293
- section.append(head, list)
294
- // The skeleton replaces the whole column, so label it for screen readers.
295
- section.setAttribute('aria-label', copy.loading)
296
- return section
159
+ function isActionable(item: PluginUpdateItem): boolean {
160
+ return item.state === 'update' || item.state === 'install'
297
161
  }
298
162
 
299
- // ── Durable job views (active / result / recent) ───────────────────────────
163
+ // ── Time / size formatting ────────────────────────────────────────────────
300
164
 
301
165
  /** Format a byte count for the progress line, or null when unknown. */
302
166
  function formatBytes(n: number | undefined): string | null {
@@ -307,16 +171,7 @@ function formatBytes(n: number | undefined): string | null {
307
171
  return `${n} B`
308
172
  }
309
173
 
310
- /** Format an ETA in seconds to a short human string, or null when unknown. */
311
- function formatEta(seconds: number | undefined): string | null {
312
- if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) return null
313
- if (seconds < 60) return `${Math.round(seconds)} s`
314
- const minutes = Math.round(seconds / 60)
315
- if (minutes < 60) return `${minutes} min`
316
- return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
317
- }
318
-
319
- /** "上次检查: N 分钟前" — relative time from an ISO timestamp. */
174
+ /** "N 分钟前" — relative time from an ISO timestamp. */
320
175
  function relativeAgo(iso: string | null | undefined, copy: UpdateCopy): string | null {
321
176
  if (typeof iso !== 'string' || iso.length === 0) return null
322
177
  const then = new Date(iso).getTime()
@@ -329,6 +184,19 @@ function relativeAgo(iso: string | null | undefined, copy: UpdateCopy): string |
329
184
  return copy.ui.agoDays(Math.round(hours / 24))
330
185
  }
331
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
+
332
200
  function jobKindLabel(job: UpdateJob, copy: UpdateCopy): string {
333
201
  return job.kind === 'runtime' ? copy.job.kindRuntime : copy.job.kindPluginSet
334
202
  }
@@ -338,39 +206,13 @@ function phaseLabel(job: UpdateJob, copy: UpdateCopy): string {
338
206
  return copy.job.phase[job.phase] ?? job.phase
339
207
  }
340
208
 
341
- /** One per-step line for plugin-set installs (and any job that reports steps). */
342
- function stepsList(job: UpdateJob, copy: UpdateCopy): HTMLElement | null {
343
- if (!Array.isArray(job.steps) || job.steps.length === 0) return null
344
- const wrap = el('div', 'hqpu-job-steps')
345
- wrap.append(el('p', 'hqpu-group-count', copy.job.stepsTitle))
346
- const ol = el('ol', 'hqpu-steps')
347
- for (const step of job.steps) {
348
- const li = el('li', `hqpu-step hqpu-step--${step.state}`)
349
- const dot = el('span', 'hqpu-dot')
350
- dot.dataset.state =
351
- step.state === 'installed'
352
- ? 'done'
353
- : step.state === 'failed'
354
- ? 'error'
355
- : step.state === 'running'
356
- ? 'warning'
357
- : 'idle'
358
- li.append(dot)
359
- const text = step.message && step.message.length > 0 ? `${step.name} — ${step.message}` : step.name
360
- li.append(el('span', '', text))
361
- ol.append(li)
362
- }
363
- wrap.append(ol)
364
- return wrap
365
- }
366
-
367
209
  /** Map a job phase onto the 4-step indicator index (下载/校验/替换/重启). */
368
210
  function stepIndex(phase: UpdateJob['phase']): number {
369
211
  switch (phase) {
370
212
  case 'downloading':
213
+ case 'resolving':
371
214
  return 0
372
215
  case 'verifying':
373
- case 'resolving':
374
216
  return 1
375
217
  case 'staging':
376
218
  case 'activating':
@@ -380,25 +222,228 @@ function stepIndex(phase: UpdateJob['phase']): number {
380
222
  case 'verifying-after-restart':
381
223
  return 3
382
224
  case 'queued':
383
- return -1
384
225
  default:
385
226
  return -1
386
227
  }
387
228
  }
388
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
+
405
+ /**
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
+
389
437
  /**
390
- * The ACTIVE-JOB banner per the mockup: pulsing brand dot + job title with the
391
- * target version, mono percentage, a gradient bar only when `percent != null`,
392
- * phase + speed line ("3.1 MB/s (286MB / 450MB)"), and a 4-step indicator row
393
- * (done=emerald, active=pulsing brand, upcoming=muted).
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.
394
441
  */
395
442
  function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
396
443
  const section = el('section', 'hqpu-group')
397
444
  section.dataset.group = 'active-job'
398
-
399
445
  const card = el('div', 'hqpu-active')
400
446
 
401
- // Top row: pulse dot + title, mono percent.
402
447
  const top = el('div', 'hqpu-active-top')
403
448
  const titleWrap = el('div', 'hqpu-active-title')
404
449
  const pulse = el('span', 'hqpu-pulse-dot')
@@ -407,23 +452,24 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
407
452
  job.kind === 'runtime'
408
453
  ? copy.ui.jobRuntimeTitle(job.targetVersion)
409
454
  : copy.ui.jobPluginTitle(job.targetVersion)
410
- titleWrap.append(pulse, el('span', '', title))
455
+ titleWrap.append(pulse, el('span', 'hqpu-active-label', title))
456
+ top.append(titleWrap)
411
457
  const pct = job.progress.percent
412
- if (pct !== null) {
413
- top.append(el('span', 'hqpu-active-percent', `${Math.round(pct)}%`))
414
- }
458
+ if (pct !== null) top.append(el('span', 'hqpu-active-percent', `${Math.round(pct)}%`))
415
459
  card.append(top)
416
460
 
417
- // Gradient bar — only when the server publishes a real percentage.
418
461
  if (pct !== null) {
419
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)))
420
467
  const fill = el('div', 'hqpu-progress-fill')
421
468
  fill.style.width = `${Math.min(100, Math.max(0, pct))}%`
422
469
  bar.append(fill)
423
470
  card.append(bar)
424
471
  }
425
472
 
426
- // Phase text + telemetry line: "3.1 MB/s (286MB / 450MB)".
427
473
  const meta = el('div', 'hqpu-active-meta')
428
474
  const phaseText =
429
475
  typeof job.message === 'string' && job.message.length > 0 ? job.message : phaseLabel(job, copy)
@@ -439,28 +485,29 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
439
485
  : done !== null && total !== null
440
486
  ? copy.job.bytes(done, total)
441
487
  : ''
442
- if (telemetry.length > 0) meta.append(el('span', '', telemetry))
488
+ if (telemetry.length > 0) meta.append(el('span', 'hqpu-num', telemetry))
443
489
  card.append(meta)
444
490
 
445
- // previous → target transition + what happens next (restart / elevation),
446
- // and the honest "closing does not cancel" note.
447
- const transition = el('div', 'hqpu-active-meta')
448
- 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(
449
495
  el(
450
- 'span',
451
- '',
496
+ 'div',
497
+ 'hqpu-active-versions',
452
498
  job.previousVersion !== null && job.previousVersion.length > 0
453
499
  ? `${job.previousVersion} → ${job.targetVersion}`
454
500
  : job.targetVersion,
455
501
  ),
456
502
  )
457
- transition.append(
458
- 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
+ ),
459
509
  )
460
- card.append(transition)
461
- card.append(el('p', 'hqpu-job-meta', copy.job.closeNote))
462
510
 
463
- // 4-step indicator: done=emerald, active=pulsing brand, upcoming=muted.
464
511
  const current = stepIndex(job.phase)
465
512
  const stepsRow = el('div', 'hqpu-job-steps4')
466
513
  copy.ui.jobSteps.forEach((label: string, index: number) => {
@@ -472,7 +519,9 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
472
519
  : index === current
473
520
  ? 'hqpu-job-step--active'
474
521
  : 'hqpu-job-step--todo'
475
- 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}`))
476
525
  })
477
526
  card.append(stepsRow)
478
527
 
@@ -480,226 +529,410 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
480
529
  return section
481
530
  }
482
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
+
483
562
  /**
484
- * The RESULT view for a terminal job: success / failed / rolled-back /
485
- * abandoned, previous→installed, per-package steps, errorCode + message, a log
486
- * 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.
487
568
  */
488
- 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 {
489
575
  const section = el('section', 'hqpu-group')
490
576
  section.dataset.group = 'job-result'
491
-
492
- const tone =
493
- job.phase === 'done' ? 'success' : job.phase === 'failed' ? 'danger' : 'warning'
494
- const titleText =
495
- job.phase === 'done'
496
- ? copy.job.resultDone
497
- : job.phase === 'failed'
498
- ? copy.job.resultFailed
499
- : job.phase === 'rolled-back'
500
- ? copy.job.resultRolledBack
501
- : copy.job.resultAbandoned
502
-
503
- const head = el('div', 'hqpu-group-head')
504
- const tag = el('span', 'hqpu-tag')
505
- tag.dataset.tone = tone
506
- tag.textContent = titleText
507
- head.append(el('h3', 'hqpu-group-title', `${jobKindLabel(job, copy)} ${job.targetVersion}`), tag)
508
- section.append(head)
509
-
510
- 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))
511
588
  const res = job.result
512
589
  const from = job.previousVersion ?? res?.installedVersion ?? '—'
513
590
  const to = res?.installedVersion ?? job.targetVersion
514
- const finished =
515
- res !== undefined && res.finishedAt.length > 0
516
- ? ` · ${new Date(res.finishedAt).toLocaleString()}`
517
- : ''
518
- card.append(el('p', 'hqpu-status', `${from} → ${to}${finished}`))
519
- if (res !== undefined && res.message !== undefined && res.message.length > 0) {
520
- card.append(el('p', 'hqpu-status', res.message))
521
- }
522
- if (res !== undefined && res.errorCode !== undefined && res.errorCode.length > 0) {
523
- card.append(el('p', 'hqpu-status', res.errorCode))
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))
524
595
  }
525
- if (typeof job.logHint === 'string' && job.logHint.length > 0) {
526
- card.append(el('p', 'hqpu-status hqpu-job-meta', copy.job.logHint(job.logHint)))
596
+ if (res?.errorCode !== undefined && res.errorCode.length > 0) {
597
+ text.append(el('code', 'hqpu-error-code', res.errorCode))
527
598
  }
599
+ head.append(icon, text)
600
+ card.append(head)
528
601
 
529
- const steps = stepsList(job, copy)
530
- if (steps !== null) card.append(steps)
531
-
532
- // Retry starts a NEW update job — never re-click the settled job id.
533
- if (job.phase === 'failed' || job.phase === 'abandoned') {
534
- const retry = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.job.retry)
535
- retry.type = 'button'
536
- retry.addEventListener('click', onRetry)
537
- card.append(retry)
538
- }
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)
539
609
  section.append(card)
540
610
  return section
541
611
  }
542
612
 
543
- /** A one-line row for older recent jobs (newest already shown as the card). */
544
- function recentJobsList(jobs: UpdateJob[], copy: UpdateCopy): HTMLElement | null {
545
- const older = jobs.slice(1, 5)
546
- if (older.length === 0) return null
547
- const wrap = el('div', 'hqpu-group')
548
- wrap.dataset.group = 'recent-jobs'
549
- wrap.append(el('h3', 'hqpu-group-count', copy.job.recentTitle))
550
- const ul = el('ul', 'hqpu-cards')
551
- for (const job of older) {
552
- const li = el('li', 'hqpu-status')
553
- const when = job.updatedAt.length > 0 ? ` · ${new Date(job.updatedAt).toLocaleString()}` : ''
554
- li.textContent = `${jobKindLabel(job, copy)} ${job.targetVersion} · ${phaseLabel(job, copy)}${when}`
555
- ul.append(li)
556
- }
557
- wrap.append(ul)
558
- return wrap
559
- }
560
-
561
613
  /**
562
- * Check-status banner: a FAILED check shows a clear error row + Retry (never
563
- * the silent "no updates" empty state); a successful check shows the time;
564
- * 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.
565
617
  */
566
- function checkBanner(
618
+ function runtimeResultAlert(
567
619
  report: PluginUpdateReport,
568
620
  copy: UpdateCopy,
569
- onRetry: () => void,
621
+ dismissed: boolean,
622
+ onDismiss: () => void,
570
623
  ): HTMLElement | null {
571
- const failed = typeof report.lastError === 'string' && report.lastError.length > 0
572
- const checked = typeof report.checkedAt === 'string' && report.checkedAt.length > 0
573
- if (!failed && !checked) return null
624
+ const lastResult = report.runtime.lastResult
625
+ if (lastResult === null || lastResult === undefined) return null
574
626
 
575
- const wrap = el('section', 'hqpu-group')
576
- wrap.dataset.group = 'check-status'
577
- if (failed) {
578
- const banner = el('div', 'hqpu-banner')
579
- banner.dataset.tone = 'danger'
580
- banner.innerHTML = WARNING_ICON(14)
581
- banner.append(el('span', '', copy.checkFailedBanner(report.lastError as string)))
582
- wrap.append(banner)
583
- if (report.items.length > 0) {
584
- wrap.append(el('p', 'hqpu-status', copy.staleNote))
585
- }
586
- const row = el('div', 'hqpu-actions')
587
- const retry = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', copy.retry)
588
- retry.type = 'button'
589
- retry.addEventListener('click', onRetry)
590
- row.append(retry)
591
- wrap.append(row)
592
- 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
593
632
  }
594
- wrap.append(
595
- el('p', 'hqpu-status', copy.checkedAt(new Date(report.checkedAt as string).toLocaleString())),
596
- )
597
- return wrap
598
- }
599
-
600
- /** Set-level release metadata (v2 manifest): summary, notes, breaking, link. */
601
- function releaseHeader(release: ReleaseInfo | null | undefined, copy: UpdateCopy): HTMLElement | null {
602
- if (release === null || release === undefined) return null
603
- const notes = Array.isArray(release.notes) ? release.notes.filter((n) => typeof n === 'string' && n.length > 0) : []
604
- const breaking = Array.isArray(release.breaking)
605
- ? release.breaking.filter((n) => typeof n === 'string' && n.length > 0)
606
- : []
607
- const hasContent =
608
- (typeof release.summary === 'string' && release.summary.length > 0) ||
609
- notes.length > 0 ||
610
- breaking.length > 0 ||
611
- typeof release.notesUrl === 'string' ||
612
- typeof release.publishedAt === 'string'
613
- if (!hasContent) return null
633
+ if (dismissed) return null
614
634
 
615
635
  const section = el('section', 'hqpu-group')
616
- section.dataset.group = 'release'
617
- section.append(el('h3', 'hqpu-group-title', copy.release.summaryTitle))
618
- if (typeof release.summary === 'string' && release.summary.length > 0) {
619
- section.append(el('p', 'hqpu-status', release.summary))
620
- }
621
- if (typeof release.publishedAt === 'string' && release.publishedAt.length > 0) {
622
- section.append(el('p', 'hqpu-status', copy.release.publishedAt(new Date(release.publishedAt).toLocaleString())))
623
- }
624
- if (release.requiresRestart === true) {
625
- section.append(el('p', 'hqpu-status', copy.job.willRestartDsh))
626
- }
627
- if (notes.length > 0) {
628
- const ul = el('ul', 'hqpu-release-notes')
629
- for (const note of notes) ul.append(el('li', '', note as string))
630
- section.append(ul)
631
- }
632
- if (breaking.length > 0) {
633
- const banner = el('div', 'hqpu-banner')
634
- banner.dataset.tone = 'danger'
635
- banner.innerHTML = WARNING_ICON(14)
636
- const inner = el('span')
637
- inner.append(el('strong', '', `${copy.release.breakingTitle}: `))
638
- inner.append(document.createTextNode(breaking.join(' · ')))
639
- banner.append(inner)
640
- section.append(banner)
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))
641
661
  }
642
- if (typeof release.notesUrl === 'string' && release.notesUrl.length > 0) {
643
- const link = el('a', 'hqpu-status hqpu-release-link', copy.release.notesLink)
644
- link.href = release.notesUrl
645
- link.target = '_blank'
646
- link.rel = 'noreferrer'
647
- section.append(link)
662
+ if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
663
+ bodyP.append(el('br'), document.createTextNode(lastResult.message))
648
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)
649
671
  return section
650
672
  }
651
673
 
652
- /** Native disclosure that hides the developer-facing source editors by
653
- * default (end users should never need to type a manifest URL). */
654
- function advancedBlock(copy: UpdateCopy, nodes: Array<HTMLElement | null>): HTMLElement {
655
- const details = el('details', 'hqpu-advanced')
656
- details.append(el('summary', '', copy.release.advanced))
657
- for (const node of nodes) {
658
- 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)
734
+ }
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)
659
754
  }
660
- return details
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
661
761
  }
662
762
 
663
- /** Mutable editor state for one manifest source (lives in the panel closure). */
664
- interface SourceState {
665
- editing: boolean
666
- saving: boolean
667
- 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
+ )
668
819
  }
669
820
 
670
821
  /**
671
- * Shared state + callbacks for one update-source editor.
672
- *
673
- * hq-edge serves both manifest sources from one shape (plugin release
674
- * manifest, runtime-update manifest), and both are editable from this panel:
675
- * an override is persisted per host, so pointing discovery at a staging
676
- * channel (or a local test rig) needs no env var and no restart. The editor
677
- * state lives in the panel closure for the same reason `armed` does — a store
678
- * 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 / 关闭.
679
824
  */
680
- export interface SourceEditorActions {
681
- /** The source editor is open (input shown instead of the read-only row). */
682
- sourceEditing: boolean
683
- /** A save is in flight. */
684
- sourceSaving: boolean
685
- /** The URL being edited (what the input shows). */
686
- sourceDraft: string
687
- onSourceEdit(): void
688
- onSourceDraft(value: string): void
689
- onSourceSave(): void
690
- onSourceCancel(): void
691
- /** Clear the user override, falling back to the configured URL. */
692
- 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
693
924
  }
694
925
 
926
+ // ── The runtime action contract ────────────────────────────────────────────
927
+
695
928
  /**
696
929
  * The runtime-update action row's state and callbacks.
697
930
  *
698
- * Installing the runtime is destructive in a way a plugin install is not: hq-edge
699
- * shuts itself down so the EDA host can swap the runtime directory. So the row
700
- * 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.
701
934
  */
702
- export interface RuntimeActions extends SourceEditorActions {
935
+ export interface RuntimeActions {
703
936
  /** A request is in flight (hq-edge accepted it and is restarting). */
704
937
  busy: boolean
705
938
  /** The confirm step is armed for the version the row names. */
@@ -707,8 +940,7 @@ export interface RuntimeActions extends SourceEditorActions {
707
940
  /**
708
941
  * hq-edge answered `202 Accepted`: from here on the outcome belongs to the
709
942
  * EDA host, and this process is about to be replaced. Terminal — the row
710
- * must never re-offer the button once this is true, because hq-edge is
711
- * already gone and a second request could only fail.
943
+ * must never re-offer the button once this is true.
712
944
  */
713
945
  accepted: boolean
714
946
  onArm(): void
@@ -717,11 +949,11 @@ export interface RuntimeActions extends SourceEditorActions {
717
949
  }
718
950
 
719
951
  /**
720
- * Normalize the report's `runtime.source` to the full shape.
952
+ * Normalize a report's source field to the full shape.
721
953
  *
722
954
  * An older hq-edge predates the feature and sends no `source` at all — that
723
- * must degrade to a read-only panel, not crash the render. A future server
724
- * 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.
725
957
  */
726
958
  function normalizeSource(raw: unknown): RuntimeUpdateSource | null {
727
959
  if (raw === null || typeof raw !== 'object') return null
@@ -735,465 +967,36 @@ function normalizeSource(raw: unknown): RuntimeUpdateSource | null {
735
967
  }
736
968
  }
737
969
 
738
- /** The tag naming where the URL in effect came from. */
739
- function originLabel(origin: RuntimeUpdateSource['origin'], copy: UpdateCopy): string {
740
- switch (origin) {
741
- case 'override':
742
- return copy.originOverride
743
- case 'config':
744
- return copy.originConfig
745
- default:
746
- return copy.originUnset
747
- }
748
- }
749
-
750
- /** The 更新源 fact row: the URL in effect plus an origin tag. */
751
- function sourceFactRow(
752
- facts: HTMLElement,
753
- source: RuntimeUpdateSource,
754
- copy: UpdateCopy,
755
- ): void {
756
- const wrap = el('div')
757
- wrap.append(el('dt', '', copy.runtime.source))
758
- const dd = el('dd')
759
- const line = el('div', 'hqpu-source-line')
760
- const code = el(
761
- 'code',
762
- 'hqpu-source-url',
763
- source.url.length > 0 ? source.url : copy.runtime.notConfigured,
764
- )
765
- const tag = el('span', 'hqpu-tag')
766
- tag.dataset.tone = source.override ? 'info' : source.origin === 'config' ? 'success' : 'neutral'
767
- tag.textContent = originLabel(source.origin, copy)
768
- line.append(code, tag)
769
- dd.append(line)
770
- wrap.append(dd)
771
- facts.append(wrap)
772
- }
773
-
774
- /** Which manifest an editor belongs to — drives data attributes + CSS hook. */
775
- export type SourceKind = 'runtime' | 'plugin'
776
-
777
- /** The localized strings one source editor needs (both kinds share the flow). */
778
- interface SourceEditorLabels {
779
- hint: string
780
- edit: string
781
- save: string
782
- reset: string
783
- inputLabel: string
784
- placeholder: string
785
- }
786
-
787
- /** Pick the labels for one kind out of the copy table. */
788
- function sourceLabels(copy: UpdateCopy, kind: SourceKind): SourceEditorLabels {
789
- return kind === 'runtime'
790
- ? {
791
- hint: copy.runtime.sourceHint,
792
- edit: copy.runtime.sourceEdit,
793
- save: copy.runtime.sourceSave,
794
- reset: copy.runtime.sourceReset,
795
- inputLabel: copy.runtime.sourceInputLabel,
796
- placeholder: copy.runtime.sourcePlaceholder,
797
- }
798
- : {
799
- hint: copy.pluginSource.hint,
800
- edit: copy.pluginSource.edit,
801
- save: copy.pluginSource.save,
802
- reset: copy.pluginSource.reset,
803
- inputLabel: copy.pluginSource.inputLabel,
804
- placeholder: copy.pluginSource.placeholder,
805
- }
806
- }
807
-
808
- /**
809
- * One update-source editor, shared by the plugin manifest and the runtime
810
- * manifest (the only differences are the labels and the data-attribute
811
- * prefix, so tests and CSS can target each).
812
- *
813
- * Read mode is a hint plus a 修改 button; edit mode is the URL field with
814
- * 保存 / 取消 — plus 恢复默认 while a user override shadows the configured
815
- * URL. The draft lives in {@link SourceEditorActions} (the panel closure), so
816
- * a background store refresh re-renders the typed URL instead of discarding it.
817
- */
818
- function sourceEditor(
819
- kind: SourceKind,
820
- source: RuntimeUpdateSource,
821
- copy: UpdateCopy,
822
- actions: SourceEditorActions,
823
- ): HTMLElement {
824
- const labels = sourceLabels(copy, kind)
825
- const block = el('div', `hqpu-source-editor hqpu-source-editor--${kind}`)
826
- if (!actions.sourceEditing) {
827
- // Read mode: show the URL currently in effect (mono, muted), then edit.
828
- const urlLine = el('p', 'hqpu-source-current-url', source.url)
829
- urlLine.style.cssText =
830
- '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;'
831
- block.append(urlLine)
832
- block.append(el('p', 'hqpu-status hqpu-source-hint', labels.hint))
833
- const edit = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', labels.edit)
834
- edit.type = 'button'
835
- edit.setAttribute(`data-${kind}-source-edit`, '')
836
- edit.disabled = actions.sourceSaving
837
- edit.addEventListener('click', actions.onSourceEdit)
838
- block.append(edit)
839
- return block
840
- }
841
- const input = el('input', 'hqpu-source-input')
842
- input.type = 'url'
843
- input.value = actions.sourceDraft
844
- input.placeholder = labels.placeholder
845
- input.setAttribute('aria-label', labels.inputLabel)
846
- input.setAttribute(`data-${kind}-source-input`, '')
847
- input.disabled = actions.sourceSaving
848
- input.addEventListener('input', () => {
849
- // Draft-only: no re-render, so the caret and IME stay untouched.
850
- actions.onSourceDraft(input.value)
851
- })
852
- // Enter saves, Escape cancels — the field is the whole editor, and Escape
853
- // must not fall through to the panel-wide close handler.
854
- input.addEventListener('keydown', (ev) => {
855
- if (ev.key === 'Enter') {
856
- ev.preventDefault()
857
- actions.onSourceSave()
858
- } else if (ev.key === 'Escape') {
859
- ev.preventDefault()
860
- ev.stopPropagation()
861
- actions.onSourceCancel()
862
- }
863
- })
864
- const save = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', labels.save)
865
- save.type = 'button'
866
- save.setAttribute(`data-${kind}-source-save`, '')
867
- save.disabled = actions.sourceSaving
868
- save.addEventListener('click', actions.onSourceSave)
869
- const cancel = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.cancel)
870
- cancel.type = 'button'
871
- cancel.setAttribute(`data-${kind}-source-cancel`, '')
872
- cancel.disabled = actions.sourceSaving
873
- cancel.addEventListener('click', actions.onSourceCancel)
874
- const controls = el('div', 'hqpu-actions hqpu-source-controls')
875
- controls.append(input, save, cancel)
876
- if (source.override) {
877
- const reset = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', labels.reset)
878
- reset.type = 'button'
879
- reset.setAttribute(`data-${kind}-source-reset`, '')
880
- reset.disabled = actions.sourceSaving
881
- reset.addEventListener('click', actions.onSourceReset)
882
- controls.append(reset)
883
- }
884
- block.append(controls)
885
- // Focus on open — and on any re-render while editing, which is exactly what
886
- // a rejected save wants (the user is dropped back into the field to fix it).
887
- input.focus()
888
- return block
889
- }
890
-
891
- /**
892
- * The plugin release-manifest source, as its own group above the plugin
893
- * groups: it says where the whole list comes from, so it belongs above the
894
- * list rather than inside it. Absent on an older hq-edge (no `pluginSource`
895
- * in the report) — then nothing is rendered at all.
896
- */
897
- function pluginSourceGroup(
898
- report: PluginUpdateReport,
899
- copy: UpdateCopy,
900
- actions: SourceEditorActions,
901
- ): HTMLElement | null {
902
- const source = normalizeSource(report.pluginSource)
903
- if (source === null) return null
904
- const section = el('section', 'hqpu-group')
905
- section.dataset.group = 'plugin-source'
906
- const head = el('div', 'hqpu-group-head')
907
- head.append(el('h3', 'hqpu-group-title', copy.pluginSource.title))
908
- section.append(head)
909
-
910
- const facts = el('dl', 'hqpu-facts')
911
- sourceFactRow(facts, source, copy)
912
- section.append(facts)
913
- section.append(sourceEditor('plugin', source, copy, actions))
914
- return section
915
- }
916
-
917
- /**
918
- * The host's own report of the last runtime swap. Rolled-back / failed renders
919
- * as an AMBER alert card (title, old→new body, errorCode chip, 忽略 dismiss);
920
- * succeeded renders as a subtle green inline line — not a loud banner. The
921
- * plugin has NO log-open affordance, so "查看诊断日志" is omitted (never faked).
922
- */
923
- function runtimeResultAlert(
924
- report: PluginUpdateReport,
925
- copy: UpdateCopy,
926
- dismissed: boolean,
927
- onDismiss: () => void,
928
- ): HTMLElement | null {
929
- const lastResult = report.runtime.lastResult
930
- if (lastResult === null || lastResult === undefined) return null
931
-
932
- // Green inline confirmation — quiet by design.
933
- if (lastResult.state === 'succeeded') {
934
- const line = el('p', 'hqpu-alert-green')
935
- line.dataset.role = 'runtime-success'
936
- line.textContent = copy.ui.successInline
937
- return line
938
- }
939
- if (dismissed) return null
940
-
941
- const section = el('section', 'hqpu-group')
942
- section.dataset.group = 'runtime-alert'
943
- const card = el('div', 'hqpu-alert hqpu-alert--amber')
944
- card.innerHTML = WARNING_ICON(14)
945
-
946
- const body = el('div')
947
- const title = el('div', 'hqpu-alert-title')
948
- title.textContent = lastResult.state === 'failed' ? copy.ui.failedTitle : copy.ui.rollbackTitle
949
- body.append(title)
950
-
951
- const oldVer = lastResult.installedVersion ?? lastResult.targetVersion ?? '—'
952
- const code = typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0
953
- ? lastResult.errorCode
954
- : '—'
955
- const text =
956
- lastResult.state === 'failed'
957
- ? copy.ui.failedBody(oldVer, code)
958
- : copy.ui.rollbackBody(oldVer, code)
959
- // Build the body as text + an inline <code> chip for the error code.
960
- const bodyP = el('p', 'hqpu-alert-body')
961
- const textOnly =
962
- lastResult.state === 'failed'
963
- ? copy.ui.failedBody(oldVer, '')
964
- : copy.ui.rollbackBody(oldVer, '')
965
- // Strip the trailing "code" placeholder we appended; re-add as a chip.
966
- bodyP.append(document.createTextNode(textOnly.replace(/\s*$/, '')))
967
- if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
968
- bodyP.append(document.createTextNode(' '))
969
- const chip = el('code', '', lastResult.errorCode)
970
- bodyP.append(chip)
971
- }
972
- if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
973
- bodyP.append(el('br'), document.createTextNode(lastResult.message))
974
- }
975
- body.append(bodyP)
976
-
977
- // Dismiss action (no log button — the plugin has no log-open affordance).
978
- const actions = el('div', 'hqpu-alert-actions')
979
- const dismiss = el('button', 'hqpu-alert-dismiss', copy.ui.dismiss)
980
- dismiss.type = 'button'
981
- dismiss.addEventListener('click', onDismiss)
982
- actions.append(dismiss)
983
- body.append(actions)
984
-
985
- card.append(body)
986
- section.append(card)
987
- return section
988
- }
989
-
990
- /** The HQ Edge runtime engine card per the mockup: current chip, "新版本 vY
991
- * 可用" with a 关键修复 tag when breaking/notes exist, size+summary line, a
992
- * 升级核心 button (existing arm→confirm flow), and the amber restart note. */
993
- function runtimeEngineCard(
994
- report: PluginUpdateReport,
995
- copy: UpdateCopy,
996
- actions: RuntimeActions,
997
- ): HTMLElement | null {
998
- const runtime = report.runtime
999
- const latestVersion = runtimeLatestVersion(runtime)
1000
- const card = el('section', 'hqpu-engine')
1001
- card.dataset.group = 'runtime'
1002
-
1003
- const head = el('div', 'hqpu-engine-head')
1004
- const titleWrap = el('div', 'hqpu-engine-title')
1005
- 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>`
1006
- titleWrap.append(el('span', '', copy.ui.runtimeEngine))
1007
- head.append(titleWrap)
1008
- if (typeof runtime.currentVersion === 'string' && runtime.currentVersion.length > 0) {
1009
- head.append(el('span', 'hqpu-chip', copy.ui.currentChip(runtime.currentVersion)))
1010
- }
1011
- card.append(head)
1012
-
1013
- // Update row (only when a newer distribution is known).
1014
- if (runtime.updateAvailable && latestVersion !== null) {
1015
- const row = el('div', 'hqpu-engine-update-row')
1016
- const left = el('div')
1017
- const newLine = el('div', 'hqpu-engine-new-line')
1018
- newLine.append(el('span', '', copy.ui.newVersion(latestVersion)))
1019
- const latestEntry = runtime.latest
1020
- const hasCritical =
1021
- (Array.isArray(latestEntry?.breaking) && (latestEntry?.breaking?.length ?? 0) > 0) ||
1022
- (Array.isArray(latestEntry?.notes) && (latestEntry?.notes?.length ?? 0) > 0)
1023
- if (hasCritical) newLine.append(el('span', 'hqpu-tag--brand', copy.ui.breakingTag))
1024
- left.append(newLine)
1025
- const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
1026
- const summary = typeof latestEntry?.summary === 'string' ? latestEntry.summary : ''
1027
- if (size.length > 0 || summary.length > 0) {
1028
- left.append(el('p', 'hqpu-engine-size', copy.ui.sizeLine(size, summary)))
1029
- }
1030
- row.append(left)
1031
-
1032
- // Upgrade action: arm → confirm, accepted/busy states preserved.
1033
- if (actions.accepted) {
1034
- row.append(el('span', 'hqpu-status', copy.runtime.updateAccepted))
1035
- } else if (actions.busy) {
1036
- row.append(el('span', 'hqpu-status', copy.runtime.updating))
1037
- } else if (actions.armed) {
1038
- const confirmWrap = el('div')
1039
- confirmWrap.append(el('p', 'hqpu-status', copy.runtime.updateConfirm(latestVersion)))
1040
- const yes = el('button', 'hqpu-btn hqpu-btn--engine', copy.confirmInstall)
1041
- yes.type = 'button'
1042
- yes.dataset.runtimeConfirm = ''
1043
- yes.addEventListener('click', actions.onConfirm)
1044
- const no = el('button', 'hqpu-btn hqpu-btn--engine', copy.cancel)
1045
- no.type = 'button'
1046
- no.addEventListener('click', actions.onCancel)
1047
- confirmWrap.append(yes, no)
1048
- row.append(confirmWrap)
1049
- } else {
1050
- const start = el('button', 'hqpu-btn hqpu-btn--engine', copy.ui.upgradeCore)
1051
- start.type = 'button'
1052
- start.dataset.runtimeUpdate = ''
1053
- start.addEventListener('click', actions.onArm)
1054
- row.append(start)
1055
- }
1056
- card.append(row)
1057
- const note = el('p', 'hqpu-engine-note')
1058
- note.innerHTML =
1059
- `<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>`
1060
- card.append(note)
1061
- } else if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
1062
- card.append(el('p', 'hqpu-status', `${copy.lastError}: ${runtime.lastError}`))
1063
- }
1064
- return card
1065
- }
1066
-
1067
- function renderList(
1068
- section: HTMLElement,
1069
- report: PluginUpdateReport | null,
1070
- copy: UpdateCopy,
1071
- changelogOpen: Set<string>,
1072
- allOpen: boolean,
1073
- onToggleCard: (name: string) => void,
1074
- onToggleAll: () => void,
1075
- onRetryCheck: () => void,
1076
- onInstall: (name: string) => void,
1077
- onIgnore: (item: PluginUpdateItem) => void,
1078
- ): void {
1079
- section.textContent = ''
1080
- if (report === null) {
1081
- section.append(skeleton(copy))
1082
- return
1083
- }
1084
- // Check-status banner: a failed check shows an error row + Retry (never the
1085
- // silent "no updates" empty state); a successful check shows the time.
1086
- const banner = checkBanner(report, copy, onRetryCheck)
1087
- if (banner !== null) section.append(banner)
1088
- // Set-level release metadata (v2 manifest): summary, notes, breaking.
1089
- const release = releaseHeader(report.release, copy)
1090
- if (release !== null) section.append(release)
1091
- if (report.items.length === 0) {
1092
- // A successful check with zero updates — distinct from a failed check,
1093
- // which already rendered its error row above.
1094
- if (typeof report.lastError !== 'string' || report.lastError.length === 0) {
1095
- section.append(el('div', 'hqpu-empty', copy.empty))
1096
- }
1097
- return
1098
- }
1099
-
1100
- const byState = (state: PluginUpdateItem['state']): PluginUpdateItem[] =>
1101
- report.items.filter((item) => item.state === state)
1102
-
1103
- // Most important first: what can be acted on, then what was muted, then what
1104
- // is already settled. Blocked plugins are intentionally skipped — the server
1105
- // still reports them, but they carry no user-actionable offer, so surfacing
1106
- // them only adds noise without a path forward.
1107
- const order: Array<[string, PluginUpdateItem[]]> = [
1108
- [copy.group.actionable, [...byState('update'), ...byState('install')]],
1109
- [copy.group.ignored, byState('ignored')],
1110
- [copy.group.current, byState('current')],
1111
- ]
1112
- for (const [heading, items] of order) {
1113
- const node = group(heading, items, copy, changelogOpen, allOpen, onToggleCard, onToggleAll, onInstall, onIgnore)
1114
- if (node !== null) section.append(node)
1115
- }
1116
- }
1117
-
1118
- function renderRuntime(
1119
- section: HTMLElement,
1120
- report: PluginUpdateReport | null,
1121
- copy: UpdateCopy,
1122
- actions: RuntimeActions,
1123
- dismissedAlert: boolean,
1124
- onDismissAlert: () => void,
1125
- ): void {
1126
- section.textContent = ''
1127
- if (report === null) return
1128
- const alert = runtimeResultAlert(report, copy, dismissedAlert, onDismissAlert)
1129
- if (alert !== null) section.append(alert)
1130
- const engine = runtimeEngineCard(report, copy, actions)
1131
- if (engine !== null) section.append(engine)
1132
- }
1133
-
1134
- /** The plugin-source group, above the plugin groups, hidden behind an advanced
1135
- * disclosure (manifest-URL editing is a developer affordance). No-op without
1136
- * the field. */
1137
- function renderPluginSource(
1138
- section: HTMLElement,
1139
- report: PluginUpdateReport | null,
1140
- copy: UpdateCopy,
1141
- actions: SourceEditorActions,
1142
- ): void {
1143
- section.textContent = ''
1144
- if (report === null) return
1145
- const node = pluginSourceGroup(report, copy, actions)
1146
- if (node !== null) section.append(advancedBlock(copy, [node]))
1147
- }
1148
-
1149
- /** Run the install transaction (a new durable job), then let the store poll it. */
1150
- async function runInstall(store: UpdateStoreLike, copy: UpdateCopy, pluginNames?: string[]): Promise<void> {
1151
- const report = store.report()
1152
- if (report === null) return
1153
- // Without an evaluated set there is nothing to install: hq-edge has not read
1154
- // a manifest yet, so the report is still its empty shell.
1155
- if (report.manifestVersion === null) {
1156
- showToast(copy.checkFailed, 'error')
1157
- return
1158
- }
1159
- // Flip the installing flag BEFORE the request: this is what disables the
1160
- // action buttons and shows the "DSH will restart" footer state (previously
1161
- // dead code — runInstall never called it).
1162
- store.setInstalling(true)
1163
- try {
1164
- const result = await installSet(report.manifestVersion, pluginNames)
1165
- if (result.error !== undefined) {
1166
- store.setInstalling(false)
1167
- showToast(`${copy.installError}: ${result.error}`, 'error')
1168
- return
1169
- }
1170
- // 202 accepted: a durable job now owns the lifecycle. The store's /status
1171
- // poller renders progress and clears the flag when the job settles.
1172
- showToast(copy.installSuccess, 'success')
1173
- void store.fetchStatus()
1174
- } catch (err: unknown) {
1175
- store.setInstalling(false)
1176
- showToast(
1177
- `${copy.installError}: ${err instanceof Error ? err.message : String(err)}`,
1178
- 'error',
1179
- )
1180
- }
1181
- }
970
+ // ── Toast ─────────────────────────────────────────────────────────────────
1182
971
 
1183
972
  /** The single live toast; a new message replaces the one on screen. */
1184
973
  let toastHost: HTMLElement | null = null
1185
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
1186
977
 
1187
978
  function showToast(message: string, tone: 'success' | 'error'): void {
1188
979
  if (toastHost === null) return
1189
980
  if (toastTimer !== null) window.clearTimeout(toastTimer)
1190
981
  toastHost.textContent = ''
1191
982
  const toast = el('div', 'hqpu-toast')
1192
- toast.setAttribute('role', 'status')
983
+ // An error must interrupt; a success may wait for a pause.
984
+ toast.setAttribute('role', tone === 'error' ? 'alert' : 'status')
1193
985
  const icon = el('span', 'hqpu-toast-icon')
1194
986
  icon.dataset.tone = tone
1195
987
  icon.innerHTML = tone === 'success' ? CHECK_CIRCLE_ICON(16) : WARNING_ICON(16)
1196
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)
1197
1000
  toastHost.append(toast)
1198
1001
  toastTimer = window.setTimeout(() => {
1199
1002
  toastHost?.replaceChildren()
@@ -1201,100 +1004,7 @@ function showToast(message: string, tone: 'success' | 'error'): void {
1201
1004
  }, TOAST_HOLD_MS)
1202
1005
  }
1203
1006
 
1204
- /**
1205
- * The collapsible "更新源配置 (高级)" panel (gear-toggled). It hosts the
1206
- * EXISTING plugin-source and runtime-source editors (POST /plugin-source,
1207
- * POST /runtime-source, save→check→refresh already wired). No new endpoints:
1208
- * if a manifest source has no data, that field is omitted rather than faked.
1209
- */
1210
- function renderSettingsPanel(
1211
- panel: HTMLElement,
1212
- report: PluginUpdateReport | null,
1213
- copy: UpdateCopy,
1214
- open: boolean,
1215
- pluginActions: SourceEditorActions,
1216
- runtimeActions: SourceEditorActions,
1217
- ): void {
1218
- panel.textContent = ''
1219
- panel.style.display = open ? '' : 'none'
1220
- if (!open || report === null) return
1221
-
1222
- const row = el('div', 'hqpu-settings-row')
1223
- row.append(el('div', 'hqpu-settings-title', copy.ui.settingsTitle))
1224
- // Origin badge: custom override (amber) vs the default configured source (green).
1225
- const pluginSource = normalizeSource(report.pluginSource)
1226
- const runtimeSource = normalizeSource(report.runtime.source)
1227
- const overridden = (pluginSource?.override ?? false) || (runtimeSource?.override ?? false)
1228
- const badge = el('span', 'hqpu-origin-badge', overridden ? copy.ui.originCustom : copy.ui.originDefault)
1229
- badge.dataset.origin = overridden ? 'override' : 'config'
1230
- row.append(badge)
1231
- panel.append(row)
1232
-
1233
- if (pluginSource !== null) {
1234
- panel.append(el('label', '', copy.ui.pluginUrlLabel))
1235
- panel.append(sourceEditor('plugin', pluginSource, copy, pluginActions))
1236
- }
1237
- if (runtimeSource !== null) {
1238
- panel.append(el('label', '', copy.ui.runtimeUrlLabel))
1239
- panel.append(sourceEditor('runtime', runtimeSource, copy, runtimeActions))
1240
- }
1241
- }
1242
-
1243
- /**
1244
- * The sticky footer: left meta ("准备安装 N 项兼容插件" / "版本集: vX"), a
1245
- * full-width primary "一键更新全套插件 (vX)" (install = one durable job),
1246
- * a secondary 忽略 wired to the existing ignore/unignore affordance, and the
1247
- * footnote. The count EXCLUDES ignored / already-current entries.
1248
- */
1249
- function renderFooter(
1250
- footer: HTMLElement,
1251
- installBtn: HTMLButtonElement,
1252
- ignoreBtn: HTMLButtonElement,
1253
- countEl: HTMLElement,
1254
- setEl: HTMLElement,
1255
- report: PluginUpdateReport | null,
1256
- copy: UpdateCopy,
1257
- installing: boolean,
1258
- store: UpdateStoreLike,
1259
- ): void {
1260
- if (report === null) {
1261
- countEl.textContent = ''
1262
- setEl.textContent = ''
1263
- installBtn.disabled = true
1264
- ignoreBtn.style.display = 'none'
1265
- return
1266
- }
1267
-
1268
- const actionable = report.items.filter(
1269
- (item) => item.state === 'update' || item.state === 'install',
1270
- ).length
1271
- const hasSet = report.manifestVersion !== null
1272
- const setVersion: string = report.manifestVersion ?? ''
1273
-
1274
- countEl.textContent = copy.ui.footerCount(actionable)
1275
- setEl.textContent = hasSet ? copy.ui.footerSet(setVersion) : ''
1276
-
1277
- // Install-all: honest set-level label; disabled while busy or nothing to do.
1278
- installBtn.textContent = hasSet
1279
- ? copy.ui.installAllSet(setVersion)
1280
- : copy.ui.installAllSet('—')
1281
- installBtn.disabled = installing || actionable === 0 || !hasSet || report.ignoredSet
1282
- installBtn.dataset.busy = installing ? 'true' : 'false'
1283
- installBtn.onclick = () => {
1284
- void runInstall(store, copy)
1285
- }
1286
-
1287
- // Secondary ignore button: only when there is an evaluated set to ignore.
1288
- ignoreBtn.style.display = hasSet ? '' : 'none'
1289
- ignoreBtn.textContent = report.ignoredSet ? copy.unignore : copy.ui.ignoreBtn
1290
- ignoreBtn.onclick = () => {
1291
- const version = report.manifestVersion as string
1292
- const action = report.ignoredSet ? unignoreOffer(version) : ignoreOffer(version)
1293
- void action
1294
- .then(() => store.refresh())
1295
- .catch(() => undefined)
1296
- }
1297
- }
1007
+ // ── Panel ─────────────────────────────────────────────────────────────────
1298
1008
 
1299
1009
  /**
1300
1010
  * Open (or focus) the update panel. Re-renders from the store; when the store
@@ -1307,6 +1017,8 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1307
1017
  return
1308
1018
  }
1309
1019
 
1020
+ const previouslyFocused = document.activeElement as HTMLElement | null
1021
+
1310
1022
  const overlay = el('div', 'hqpu-overlay')
1311
1023
  overlay.setAttribute('data-hq-plugin-update-view', '')
1312
1024
  overlay.addEventListener('click', (ev) => {
@@ -1317,15 +1029,14 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1317
1029
  const panel = el('div', 'hqpu-panel')
1318
1030
  panel.setAttribute('role', 'dialog')
1319
1031
  panel.setAttribute('aria-modal', 'true')
1320
- panel.setAttribute('aria-label', copy.viewTitle)
1032
+ panel.setAttribute('aria-label', copy.ui.title)
1321
1033
  panel.tabIndex = -1
1322
1034
 
1323
- // ── Sticky header: brand icon + title + relative checked time, refresh +
1324
- // gear (advanced source settings) + close ──
1325
- const head = el('div', 'hqpu-head')
1035
+ // ── Header ──
1036
+ const head = el('header', 'hqpu-head')
1326
1037
  const headMain = el('div', 'hqpu-head-main')
1327
1038
  const brandIcon = el('span', 'hqpu-brand-icon')
1328
- 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>`
1329
1040
  const titleBlock = el('div', 'hqpu-title-block')
1330
1041
  titleBlock.append(el('h1', 'hqpu-title', copy.ui.title))
1331
1042
  const sub = el('p', 'hqpu-sub')
@@ -1333,89 +1044,96 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1333
1044
  headMain.append(brandIcon, titleBlock)
1334
1045
 
1335
1046
  const toolbar = el('div', 'hqpu-toolbar')
1336
- const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(16), copy.refresh, () => {
1047
+ const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(15), copy.refresh, () => {
1337
1048
  void store.check()
1338
1049
  })
1339
- toolbar.append(refreshBtn)
1340
1050
  const gearBtn = iconButton(
1341
1051
  'hqpu-icon-btn',
1342
- `<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>`,
1343
1053
  copy.ui.gearTooltip,
1344
1054
  () => {
1345
- settingsOpen = !settingsOpen
1346
- render()
1055
+ toggleSettings()
1347
1056
  },
1348
1057
  )
1349
- toolbar.append(gearBtn)
1350
- 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, () => {
1351
1066
  onClose()
1352
1067
  })
1353
- toolbar.append(closeBtn)
1068
+ toolbar.append(refreshBtn, gearBtn, logBtn, closeBtn)
1354
1069
  head.append(headMain, toolbar)
1355
1070
  panel.append(head)
1356
1071
 
1357
- // ── Collapsible advanced settings panel (gear-toggled) ──
1358
- const settingsPanel = el('div', 'hqpu-settings')
1359
- 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)
1360
1076
 
1361
- // ── 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')
1362
1080
  const scroll = el('div', 'hqpu-scroll')
1363
1081
  const column = el('div', 'hqpu-column')
1364
- // The durable-job view leads: an active install/runtime update is resumed on
1365
- // open BEFORE anything else, and a recent terminal result is shown here too.
1366
1082
  const jobSection = el('div', 'hqpu-stack')
1083
+ const bannerSection = el('div', 'hqpu-stack')
1084
+ const bandSection = el('div', 'hqpu-stack')
1367
1085
  const listSection = el('div', 'hqpu-stack')
1368
- const runtimeSection = el('div', 'hqpu-stack')
1369
- const toastSection = el('div', 'hqpu-toast-host')
1370
- column.append(jobSection, listSection, runtimeSection, toastSection)
1086
+ const extraSection = el('div', 'hqpu-stack')
1087
+ column.append(jobSection, bannerSection, bandSection, listSection, extraSection)
1371
1088
  scroll.append(column)
1372
- panel.append(scroll)
1373
1089
 
1374
- // ── Sticky footer ──
1375
- 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)
1376
1097
  const footMeta = el('div', 'hqpu-foot-meta')
1377
1098
  const footCount = el('span', '')
1378
- const footSet = el('span', 'hqpu-foot-set')
1379
- footMeta.append(footCount, footSet)
1380
- const footActions = el('div', 'hqpu-foot-actions')
1381
- const installAllBtn = el('button', 'hqpu-btn hqpu-btn--block')
1382
- installAllBtn.type = 'button'
1383
- const ignoreBtn = el('button', 'hqpu-btn--ignore', copy.ui.ignoreBtn)
1099
+ const ignoreBtn = el('button', 'hqpu-link')
1384
1100
  ignoreBtn.type = 'button'
1385
- footActions.append(installAllBtn, ignoreBtn)
1386
- const footnote = el('p', 'hqpu-footnote', copy.ui.footnote)
1387
- footer.append(footMeta, footActions, footnote)
1388
- 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)
1389
1111
 
1390
1112
  overlay.append(scrim, panel)
1391
1113
  document.body.appendChild(overlay)
1392
1114
  toastHost = toastSection
1115
+ activeCopy = copy
1393
1116
  panel.focus()
1394
1117
 
1395
1118
  const disposers: Array<() => void> = []
1396
1119
 
1397
- // ── Runtime update: arm → confirm, then hand off to hq-edge ──
1398
- // Live in the panel's closure (not the report) because they are UI state, and
1399
- // a store refresh must not silently disarm a confirm the user just armed.
1120
+ // ── Panel-closure UI state (survives store refreshes) ──
1400
1121
  let runtimeArmed = false
1401
1122
  let runtimeBusy = false
1402
1123
  let runtimeAccepted = false
1403
-
1404
- // ── Panel-closure UI state (survive store refreshes) ──
1405
- const changelogOpen = new Set<string>()
1406
- let allOpen = false
1407
1124
  let settingsOpen = false
1125
+ let settingsBuilt = false
1126
+ let logOpen = false
1408
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 = ''
1409
1136
 
1410
- // Source-editor state, same reasoning: a background refresh must not discard
1411
- // a half-typed URL or silently close either editor.
1412
- const runtimeSourceState: SourceState = { editing: false, saving: false, draft: '' }
1413
- const pluginSourceState: SourceState = { editing: false, saving: false, draft: '' }
1414
-
1415
- // Delegated, not spread: `...sourceActionsFor(...)` would evaluate the
1416
- // getters once and freeze `sourceEditing: false` — the editor would never
1417
- // open. Read every member through the live object instead.
1418
- const runtimeSourceActions = sourceActionsFor('runtime', runtimeSourceState)
1419
1137
  const runtimeActions: RuntimeActions = {
1420
1138
  get busy() {
1421
1139
  return runtimeBusy
@@ -1437,87 +1155,51 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1437
1155
  onConfirm: () => {
1438
1156
  void requestRuntime()
1439
1157
  },
1440
- get sourceEditing() {
1441
- return runtimeSourceActions.sourceEditing
1442
- },
1443
- get sourceSaving() {
1444
- return runtimeSourceActions.sourceSaving
1445
- },
1446
- get sourceDraft() {
1447
- return runtimeSourceActions.sourceDraft
1448
- },
1449
- onSourceEdit: () => runtimeSourceActions.onSourceEdit(),
1450
- onSourceDraft: (value: string) => runtimeSourceActions.onSourceDraft(value),
1451
- onSourceSave: () => runtimeSourceActions.onSourceSave(),
1452
- onSourceCancel: () => runtimeSourceActions.onSourceCancel(),
1453
- onSourceReset: () => runtimeSourceActions.onSourceReset(),
1454
1158
  }
1455
1159
 
1456
- 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
+ }
1457
1166
 
1458
- /** Build the {@link SourceEditorActions} half for one manifest source. */
1459
- function sourceActionsFor(
1460
- kind: SourceKind,
1461
- state: SourceState,
1462
- ): SourceEditorActions {
1463
- return {
1464
- get sourceEditing() {
1465
- return state.editing
1466
- },
1467
- get sourceSaving() {
1468
- return state.saving
1469
- },
1470
- get sourceDraft() {
1471
- return state.draft
1472
- },
1473
- onSourceEdit: () => {
1474
- // Prefill with the URL in effect, so 保存 is a no-op unless edited.
1475
- const report = store.report()
1476
- const source =
1477
- kind === 'runtime' ? normalizeSource(report?.runtime.source) : normalizeSource(report?.pluginSource)
1478
- state.draft = source?.url ?? ''
1479
- state.editing = true
1480
- render()
1481
- },
1482
- onSourceDraft: (value: string) => {
1483
- state.draft = value
1484
- },
1485
- onSourceSave: () => {
1486
- void submitSource(kind, state)
1487
- },
1488
- onSourceCancel: () => {
1489
- state.editing = false
1490
- render()
1491
- },
1492
- onSourceReset: () => {
1493
- void submitSource(kind, state, '')
1494
- },
1167
+ function toggleSettings(): void {
1168
+ settingsOpen = !settingsOpen
1169
+ if (settingsOpen) {
1170
+ syncDrafts()
1171
+ settingsBuilt = false
1495
1172
  }
1173
+ render()
1496
1174
  }
1497
1175
 
1498
1176
  /**
1499
- * Save one manifest source — or clear the override with an empty URL —
1500
- * then re-run discovery.
1177
+ * Save both manifest sources, then re-run discovery and close the strip.
1501
1178
  *
1502
1179
  * The re-check is not optional: the report is cached, so without a fresh
1503
- * `POST /check` the facts and the update button would keep naming the OLD
1504
- * manifest's offer after the source changed. hq-edge answers 422 with a
1505
- * message meant for a person on refusal (bad scheme, credentials, …), which
1506
- * 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.
1507
1184
  */
1508
- async function submitSource(
1509
- kind: SourceKind,
1510
- state: SourceState,
1511
- url: string = state.draft,
1512
- ): Promise<void> {
1513
- 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
1514
1191
  render()
1515
- const labels = kind === 'runtime' ? copy.runtime : copy.pluginSource
1516
1192
  try {
1517
- if (kind === 'runtime') await setRuntimeUpdateSource(url)
1518
- else await setPluginUpdateSource(url)
1519
- state.editing = false
1520
- 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')
1521
1203
  try {
1522
1204
  await checkUpdates()
1523
1205
  await store.refresh()
@@ -1527,11 +1209,40 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1527
1209
  }
1528
1210
  } catch (err: unknown) {
1529
1211
  showToast(
1530
- `${labels.saveFailed}: ${err instanceof Error ? err.message : String(err)}`,
1212
+ `${copy.pluginSource.saveFailed}: ${err instanceof Error ? err.message : String(err)}`,
1531
1213
  'error',
1532
1214
  )
1533
1215
  } finally {
1534
- 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
1535
1246
  render()
1536
1247
  }
1537
1248
  }
@@ -1552,13 +1263,10 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1552
1263
  return
1553
1264
  }
1554
1265
  // 202 Accepted: hq-edge exits to let the EDA host swap the runtime and
1555
- // relaunch. This panel is about to go away with the restart, so say so —
1556
- // and latch it, so a store refresh cannot re-offer the button for a
1557
- // 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.
1558
1268
  runtimeAccepted = true
1559
1269
  showToast(copy.runtime.updateAccepted, 'success')
1560
- // Adopt the new durable job immediately so the active-job view renders
1561
- // (it polls from here; this process is about to be swapped away anyway).
1562
1270
  void store.fetchStatus()
1563
1271
  } catch (err: unknown) {
1564
1272
  showToast(
@@ -1574,7 +1282,7 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1574
1282
  /** Retry a failed/abandoned job: plugin-set → a fresh install; runtime →
1575
1283
  * re-arm the runtime confirm flow. Always a NEW job, never re-clicking the
1576
1284
  * settled job id. */
1577
- const retryJob = (kind: 'plugin-set' | 'runtime'): void => {
1285
+ function retryJob(kind: 'plugin-set' | 'runtime'): void {
1578
1286
  if (kind === 'runtime') {
1579
1287
  runtimeArmed = true
1580
1288
  render()
@@ -1583,100 +1291,543 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1583
1291
  }
1584
1292
  }
1585
1293
 
1586
- 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 {
1587
1443
  jobSection.textContent = ''
1588
1444
  const active = store.activeJob()
1589
1445
  if (active !== null && !TERMINAL_PHASES.has(active.phase)) {
1590
- // An active job leads the panel — resumed on open, polled to its end.
1591
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))
1482
+ return
1483
+ }
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))
1620
+ }
1621
+ }
1622
+
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'
1592
1710
  return
1593
1711
  }
1594
- const recent = store.recentJobs()
1595
- const latest = recent[0]
1596
- if (latest !== undefined && TERMINAL_PHASES.has(latest.phase)) {
1597
- jobSection.append(resultView(latest, copy, () => retryJob(latest.kind)))
1598
- const older = recentJobsList(recent, copy)
1599
- if (older !== null) jobSection.append(older)
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)
1600
1732
  }
1601
1733
  }
1602
1734
 
1603
- const render = (): void => {
1735
+ function render(): void {
1604
1736
  const report = store.report()
1605
- // The footer "installing" state is owned by the store flag OR any active
1606
- // durable job — buttons stay disabled while either is true.
1607
- const jobActive =
1608
- store.activeJob() !== null && !TERMINAL_PHASES.has(store.activeJob()!.phase)
1609
- const installing = store.installing() || jobActive
1610
- const stale = report === null
1611
- refreshBtn.disabled = stale
1612
- refreshBtn.title = copy.refresh
1613
-
1614
- // Header sub-line: "上次检查: N 分钟前" (from report.checkedAt).
1615
- const ago = relativeAgo(report?.checkedAt, copy)
1616
- 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
1617
1740
 
1618
- // Collapsible advanced settings panel (gear-toggled): existing source
1619
- // editors — plugin + runtime manifest URL override, save→check→refresh.
1620
- 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
1621
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()
1622
1755
  renderJobSection()
1623
- if (stale) {
1624
- listSection.textContent = ''
1625
- listSection.append(skeleton(copy))
1626
- } else {
1627
- renderList(
1628
- listSection,
1629
- report,
1630
- copy,
1631
- changelogOpen,
1632
- allOpen,
1633
- (name) => {
1634
- if (changelogOpen.has(name)) changelogOpen.delete(name)
1635
- else changelogOpen.add(name)
1636
- render()
1637
- },
1638
- () => {
1639
- allOpen = !allOpen
1640
- render()
1641
- },
1642
- () => {
1643
- void store.check()
1644
- },
1645
- (name) => {
1646
- void runInstall(store, copy, [name])
1647
- },
1648
- (item) => {
1649
- const report = store.report()
1650
- if (report?.manifestVersion === null || report?.manifestVersion === undefined) return
1651
- const action = item.state === 'ignored'
1652
- ? unignorePlugin(report.manifestVersion, item.name)
1653
- : ignorePlugin(report.manifestVersion, item.name)
1654
- void action.then(() => store.check()).catch(() => undefined)
1655
- },
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',
1656
1780
  )
1657
1781
  }
1658
- renderRuntime(runtimeSection, report, copy, runtimeActions, dismissedAlert, () => {
1659
- dismissedAlert = true
1660
- render()
1661
- })
1662
- renderFooter(footer, installAllBtn, ignoreBtn, footCount, footSet, report, copy, installing, store)
1663
1782
  }
1664
1783
 
1665
1784
  const unsubscribe = store.subscribe(render)
1666
1785
  disposers.push(unsubscribe)
1667
1786
 
1668
1787
  // Refresh on open (bounded; never block the shell).
1669
- void store.refresh().then(() => {
1670
- // Store notification re-renders already; nothing extra to do.
1671
- })
1788
+ void store.refresh()
1672
1789
 
1673
- // Escape closes, the shell-wide dialog contract.
1790
+ // Escape peels one layer at a time; the shell-wide contract still holds.
1674
1791
  const onKeyDown = (ev: KeyboardEvent): void => {
1675
1792
  if (ev.key === 'Escape') {
1676
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
+ }
1677
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()
1678
1828
  }
1679
1829
  }
1830
+
1680
1831
  document.addEventListener('keydown', onKeyDown)
1681
1832
  disposers.push(() => document.removeEventListener('keydown', onKeyDown))
1682
1833
 
@@ -1691,8 +1842,79 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1691
1842
  if (toastTimer !== null) window.clearTimeout(toastTimer)
1692
1843
  toastTimer = null
1693
1844
  if (toastHost === toastSection) toastHost = null
1845
+ if (activeCopy === copy) activeCopy = null
1694
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()
1695
1849
  }
1696
1850
 
1697
1851
  render()
1698
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
+ }