sideby 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +453 -2
  3. package/README.zh-CN.md +455 -0
  4. package/dist/build.json +1 -0
  5. package/dist/src/cli/args.d.ts +13 -0
  6. package/dist/src/cli/args.js +38 -0
  7. package/dist/src/cli/commands.d.ts +37 -0
  8. package/dist/src/cli/commands.js +487 -0
  9. package/dist/src/cli/format.d.ts +16 -0
  10. package/dist/src/cli/format.js +80 -0
  11. package/dist/src/cli/json-schemas.d.ts +2 -0
  12. package/dist/src/cli/json-schemas.js +224 -0
  13. package/dist/src/cli/main.d.ts +2 -0
  14. package/dist/src/cli/main.js +186 -0
  15. package/dist/src/cli/schemas.d.ts +1 -0
  16. package/dist/src/cli/schemas.js +16 -0
  17. package/dist/src/core/account-name.d.ts +2 -0
  18. package/dist/src/core/account-name.js +2 -0
  19. package/dist/src/core/accounts.d.ts +18 -0
  20. package/dist/src/core/accounts.js +116 -0
  21. package/dist/src/core/config.d.ts +60 -0
  22. package/dist/src/core/config.js +181 -0
  23. package/dist/src/core/create.d.ts +30 -0
  24. package/dist/src/core/create.js +126 -0
  25. package/dist/src/core/doctor.d.ts +41 -0
  26. package/dist/src/core/doctor.js +171 -0
  27. package/dist/src/core/errors.d.ts +12 -0
  28. package/dist/src/core/errors.js +14 -0
  29. package/dist/src/core/file-memo.d.ts +24 -0
  30. package/dist/src/core/file-memo.js +41 -0
  31. package/dist/src/core/fs-safe.d.ts +38 -0
  32. package/dist/src/core/fs-safe.js +205 -0
  33. package/dist/src/core/handoff.d.ts +41 -0
  34. package/dist/src/core/handoff.js +54 -0
  35. package/dist/src/core/identity.d.ts +23 -0
  36. package/dist/src/core/identity.js +79 -0
  37. package/dist/src/core/last-doctor.d.ts +36 -0
  38. package/dist/src/core/last-doctor.js +69 -0
  39. package/dist/src/core/launch.d.ts +35 -0
  40. package/dist/src/core/launch.js +108 -0
  41. package/dist/src/core/login-path.d.ts +23 -0
  42. package/dist/src/core/login-path.js +96 -0
  43. package/dist/src/core/paths.d.ts +13 -0
  44. package/dist/src/core/paths.js +32 -0
  45. package/dist/src/core/quota-levels.d.ts +19 -0
  46. package/dist/src/core/quota-levels.js +34 -0
  47. package/dist/src/core/secret-file.d.ts +30 -0
  48. package/dist/src/core/secret-file.js +116 -0
  49. package/dist/src/core/share-mode-names.d.ts +1 -0
  50. package/dist/src/core/share-mode-names.js +10 -0
  51. package/dist/src/core/share-modes.d.ts +24 -0
  52. package/dist/src/core/share-modes.js +405 -0
  53. package/dist/src/core/shell-init.d.ts +25 -0
  54. package/dist/src/core/shell-init.js +61 -0
  55. package/dist/src/core/skill-check.d.ts +11 -0
  56. package/dist/src/core/skill-check.js +57 -0
  57. package/dist/src/core/usage-days.d.ts +15 -0
  58. package/dist/src/core/usage-days.js +33 -0
  59. package/dist/src/core/version.d.ts +9 -0
  60. package/dist/src/core/version.js +46 -0
  61. package/dist/src/desktop/app.d.ts +60 -0
  62. package/dist/src/desktop/app.js +316 -0
  63. package/dist/src/desktop/icon.d.ts +16 -0
  64. package/dist/src/desktop/icon.js +174 -0
  65. package/dist/src/families/claude/index.d.ts +10 -0
  66. package/dist/src/families/claude/index.js +124 -0
  67. package/dist/src/families/claude/layout.d.ts +6 -0
  68. package/dist/src/families/claude/layout.js +5 -0
  69. package/dist/src/families/claude/quota-cache.d.ts +31 -0
  70. package/dist/src/families/claude/quota-cache.js +111 -0
  71. package/dist/src/families/claude/quota.d.ts +3 -0
  72. package/dist/src/families/claude/quota.js +53 -0
  73. package/dist/src/families/claude/usage.d.ts +22 -0
  74. package/dist/src/families/claude/usage.js +146 -0
  75. package/dist/src/families/codex/index.d.ts +5 -0
  76. package/dist/src/families/codex/index.js +67 -0
  77. package/dist/src/families/codex/rollout.d.ts +67 -0
  78. package/dist/src/families/codex/rollout.js +298 -0
  79. package/dist/src/families/grok/index.d.ts +8 -0
  80. package/dist/src/families/grok/index.js +78 -0
  81. package/dist/src/families/index.d.ts +3 -0
  82. package/dist/src/families/index.js +40 -0
  83. package/dist/src/families/logos.d.ts +21 -0
  84. package/dist/src/families/logos.js +26 -0
  85. package/dist/src/families/pi/index.d.ts +3 -0
  86. package/dist/src/families/pi/index.js +54 -0
  87. package/dist/src/families/shared/toml.d.ts +5 -0
  88. package/dist/src/families/shared/toml.js +16 -0
  89. package/dist/src/index.d.ts +5 -0
  90. package/dist/src/index.js +4 -0
  91. package/dist/src/panel/background.d.ts +62 -0
  92. package/dist/src/panel/background.js +274 -0
  93. package/dist/src/panel/handler.d.ts +93 -0
  94. package/dist/src/panel/handler.js +411 -0
  95. package/dist/src/panel/i18n.d.ts +287 -0
  96. package/dist/src/panel/i18n.js +570 -0
  97. package/dist/src/panel/page-logic.d.ts +154 -0
  98. package/dist/src/panel/page-logic.js +326 -0
  99. package/dist/src/panel/page-script.d.ts +2 -0
  100. package/dist/src/panel/page-script.js +1633 -0
  101. package/dist/src/panel/page-styles.d.ts +7 -0
  102. package/dist/src/panel/page-styles.js +514 -0
  103. package/dist/src/panel/page.d.ts +20 -0
  104. package/dist/src/panel/page.js +96 -0
  105. package/dist/src/panel/server.d.ts +32 -0
  106. package/dist/src/panel/server.js +133 -0
  107. package/dist/src/panel/theme.d.ts +116 -0
  108. package/dist/src/panel/theme.js +132 -0
  109. package/dist/src/plugins/account-script.d.ts +4 -0
  110. package/dist/src/plugins/account-script.js +66 -0
  111. package/dist/src/plugins/bus.d.ts +33 -0
  112. package/dist/src/plugins/bus.js +84 -0
  113. package/dist/src/plugins/loader.d.ts +58 -0
  114. package/dist/src/plugins/loader.js +250 -0
  115. package/dist/src/quota/claude-statusline.d.ts +28 -0
  116. package/dist/src/quota/claude-statusline.js +301 -0
  117. package/dist/src/quota/statusline-tap.d.ts +11 -0
  118. package/dist/src/quota/statusline-tap.js +104 -0
  119. package/dist/src/runtime.d.ts +149 -0
  120. package/dist/src/runtime.js +371 -0
  121. package/dist/src/types.d.ts +278 -0
  122. package/dist/src/types.js +3 -0
  123. package/dist/testing/demo.d.ts +8 -0
  124. package/dist/testing/demo.js +50 -0
  125. package/dist/testing/index.d.ts +36 -0
  126. package/dist/testing/index.js +117 -0
  127. package/package.json +66 -4
  128. package/schemas/alias.json +102 -0
  129. package/schemas/app-install.json +74 -0
  130. package/schemas/app-uninstall.json +54 -0
  131. package/schemas/config.json +166 -0
  132. package/schemas/doctor.json +229 -0
  133. package/schemas/error.json +25 -0
  134. package/schemas/list.json +177 -0
  135. package/schemas/new.json +181 -0
  136. package/schemas/next.json +127 -0
  137. package/schemas/plugins.json +74 -0
  138. package/schemas/quota-setup.json +60 -0
  139. package/schemas/quota-teardown.json +30 -0
  140. package/schemas/quota.json +203 -0
  141. package/skills/sideby/SKILL.md +52 -0
  142. package/skills/sideby/references/accounts.md +31 -0
  143. package/skills/sideby/references/diagnose.md +47 -0
  144. package/skills/sideby/references/handoff.md +41 -0
  145. package/skills/sideby/references/quota.md +14 -0
@@ -0,0 +1,1633 @@
1
+ // The Panel page's inline script. Plain browser JavaScript in a string (no template literals inside, so String.raw
2
+ // keeps it verbatim); the pure helpers from page-logic.ts are prepended so the tested code is the code that runs.
3
+ import { aliasArgsOf, aliasIssue, attentionReasons, cacheHitRate, formatDuration, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
4
+ const LOGIC = [
5
+ aliasArgsOf,
6
+ aliasIssue,
7
+ attentionReasons,
8
+ cacheHitRate,
9
+ formatDuration,
10
+ maskEmail,
11
+ matchesQuery,
12
+ mergeDaily,
13
+ newAccountDir,
14
+ nextReset,
15
+ panelHint,
16
+ panelLang,
17
+ passedWindow,
18
+ quotaPressure,
19
+ sortAccounts,
20
+ suggestAlias,
21
+ suggestName,
22
+ updatedAge,
23
+ windowState,
24
+ ]
25
+ .map((fn) => fn.toString())
26
+ .join('\n');
27
+ const MAIN = String.raw `
28
+ const BOOT = JSON.parse(document.getElementById('sideby-boot').textContent)
29
+ const MESSAGES = JSON.parse(document.getElementById('sideby-i18n').textContent)
30
+ const WARN = BOOT.warn
31
+ const FAIL = BOOT.fail
32
+ // Quota data older than this is greyed and marked with its age.
33
+ const STALE_MS = 60 * 60000
34
+ // Findings listed before the rest fold behind a button.
35
+ const FINDINGS_SHOWN = 5
36
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
37
+ const SVG_NS = 'http://www.w3.org/2000/svg'
38
+ const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches
39
+ // Auto refresh: the state reloads every AUTO_MS while the page is visible, and every FAST_MS for FAST_FOR_MS after
40
+ // quota is turned on, so the first numbers show up soon after a status line reports them.
41
+ const AUTO_MS = 20000
42
+ const FAST_MS = 5000
43
+ const FAST_FOR_MS = 3 * 60000
44
+ const S = {
45
+ state: null, loadError: null, loading: false, loadedAt: 0, history: null,
46
+ report: null, checking: false, fixing: false, checkError: null, checkRetry: null, fixes: null,
47
+ // silent: the running load is a background one (no spinner); lastTry: when the last load started;
48
+ // pending: a background load finished while the viewer was busy, so its render waits; fastUntil/fastFamily:
49
+ // the quick polling after quota was turned on.
50
+ silent: false, lastTry: 0, pending: false, fastUntil: 0, fastFamily: null,
51
+ }
52
+ // View state that is not saved: the search text, the attention filter, the Health check filter.
53
+ const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, highlight: null, fade: true }
54
+ const $ = (id) => document.getElementById(id)
55
+
56
+ // ---------- preferences ----------
57
+ // Kept in localStorage, which can be missing or throw (private windows, blocked storage): every access is guarded
58
+ // and the defaults apply.
59
+ const PREFS_KEY = 'sideby.panel.v2'
60
+ const CHOICES = { view: ['list', 'cards'], sort: ['pressure', 'name', 'recent'], quota: ['used', 'left'], theme: ['system', 'light', 'dark'], lang: ['en', 'zh'] }
61
+ const prefs = loadPrefs()
62
+ function loadPrefs() {
63
+ let raw = {}
64
+ try { raw = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}') || {} } catch {}
65
+ const p = { view: 'list', sort: 'pressure', quota: 'used', theme: 'system', lang: '', family: '', collapsed: {}, hideEmail: false }
66
+ for (const k of Object.keys(CHOICES)) if (CHOICES[k].includes(raw[k])) p[k] = raw[k]
67
+ if (raw.hideEmail === true) p.hideEmail = true
68
+ if (typeof raw.family === 'string') p.family = raw.family
69
+ if (raw.collapsed && typeof raw.collapsed === 'object')
70
+ for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
71
+ return p
72
+ }
73
+ function savePrefs() { try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) } catch {} }
74
+ // A host lang other than auto wins over the saved choice (which stays saved) and hides the switch.
75
+ const LANG_FIXED = BOOT.lang === 'en' || BOOT.lang === 'zh'
76
+ let lang = panelLang(BOOT.lang, prefs.lang, navigator.language)
77
+
78
+ // ---------- text ----------
79
+ function t(key, vars) {
80
+ const table = MESSAGES[lang] || {}
81
+ let s = Object.hasOwn(table, key) ? table[key] : Object.hasOwn(MESSAGES.en, key) ? MESSAGES.en[key] : key
82
+ if (vars) s = s.replace(/\{(\w+)\}/g, (m, k) => (Object.hasOwn(vars, k) ? String(vars[k]) : m))
83
+ return s
84
+ }
85
+ function tn(key, n, vars) { return t(key + (n === 1 ? '.one' : '.other'), Object.assign({ n }, vars)) }
86
+ // A message whose placeholders are nodes, such as a code span inside a sentence.
87
+ function tx(key, vars) {
88
+ const s = t(key)
89
+ const out = []
90
+ let last = 0
91
+ s.replace(/\{(\w+)\}/g, (m, k, i) => {
92
+ out.push(s.slice(last, i), vars && Object.hasOwn(vars, k) ? vars[k] : m)
93
+ last = i + m.length
94
+ return m
95
+ })
96
+ out.push(s.slice(last))
97
+ return out.filter((x) => x !== '')
98
+ }
99
+ const locale = () => (lang === 'zh' ? 'zh-CN' : 'en-US')
100
+
101
+ function h(tag, props, ...kids) {
102
+ const el = document.createElement(tag)
103
+ if (props) for (const k of Object.keys(props)) {
104
+ const v = props[k]
105
+ if (v === null || v === undefined || v === false) continue
106
+ if (k === 'class') el.className = v
107
+ else if (k === 'css') for (const p of Object.keys(v)) el.style.setProperty(p, v[p])
108
+ else if (k.slice(0, 2) === 'on') el.addEventListener(k.slice(2), v)
109
+ else el.setAttribute(k, v === true ? '' : String(v))
110
+ }
111
+ add(el, kids)
112
+ // Every button gets an explicit accessible name; one without aria-label is named by its visible text.
113
+ if (tag === 'button' && !el.hasAttribute('aria-label') && !el.hasAttribute('aria-labelledby')) labelFromText(el)
114
+ return el
115
+ }
116
+ function visibleText(node) {
117
+ const parts = []
118
+ for (const k of node.childNodes) {
119
+ if (k.nodeType === 3) parts.push(k.data)
120
+ else if (k.nodeType === 1 && k.getAttribute('aria-hidden') !== 'true') parts.push(visibleText(k))
121
+ }
122
+ return parts.join(' ').replace(/\s+/g, ' ').trim()
123
+ }
124
+ function labelFromText(el) {
125
+ const text = visibleText(el)
126
+ if (text) el.setAttribute('aria-label', text)
127
+ else el.removeAttribute('aria-label')
128
+ }
129
+ // replaceChildren() would render null as the text "null"; route children through add(), which skips it.
130
+ function setKids(el, kids) {
131
+ el.replaceChildren()
132
+ add(el, kids)
133
+ }
134
+ function add(el, kid) {
135
+ if (kid === null || kid === undefined || kid === false) return
136
+ if (Array.isArray(kid)) { for (const k of kid) add(el, k); return }
137
+ el.append(kid instanceof Node ? kid : document.createTextNode(String(kid)))
138
+ }
139
+ function dots(parts) {
140
+ const out = []
141
+ for (const p of parts) {
142
+ if (p === null || p === undefined || p === false || p === '') continue
143
+ if (out.length) out.push(h('span', { class: 'sep', 'aria-hidden': 'true' }, '·'))
144
+ out.push(p)
145
+ }
146
+ return out
147
+ }
148
+
149
+ // ---------- updating in place ----------
150
+ // A refresh rebuilds the markup, then keeps every existing element whose markup did not change, so focus, hover,
151
+ // text selection and scroll stay put and only changed rows are swapped. Children match by data-key, else by
152
+ // position; a data-patch container that changed is updated child by child instead of being replaced. Rows carry
153
+ // a data-sig of their account, so equal markup also means their click handlers see the same data.
154
+ function markup(el) { return el.outerHTML.replace(/tip-[0-9]+/g, 'tip') }
155
+ function syncAttrs(from, to) {
156
+ for (const a of Array.from(from.attributes)) if (!to.hasAttribute(a.name)) from.removeAttribute(a.name)
157
+ for (const a of Array.from(to.attributes)) if (from.getAttribute(a.name) !== a.value) from.setAttribute(a.name, a.value)
158
+ }
159
+ function flat(kids, out) {
160
+ out = out || []
161
+ if (kids === null || kids === undefined || kids === false) return out
162
+ if (Array.isArray(kids)) { for (const k of kids) flat(k, out); return out }
163
+ out.push(kids instanceof Node ? kids : document.createTextNode(String(kids)))
164
+ return out
165
+ }
166
+ function patchKids(box, kids) {
167
+ const next = flat(kids)
168
+ const old = Array.from(box.childNodes)
169
+ const byKey = new Map()
170
+ for (const o of old) if (o.nodeType === 1 && o.dataset.key) byKey.set(o.dataset.key, o)
171
+ const used = new Set()
172
+ const result = next.map((n, i) => {
173
+ const key = n.nodeType === 1 ? n.dataset.key : ''
174
+ const o = key ? byKey.get(key) : old[i] && !(old[i].nodeType === 1 && old[i].dataset.key) ? old[i] : null
175
+ if (!o || used.has(o) || o.nodeType !== n.nodeType || o.nodeName !== n.nodeName) return n
176
+ if (n.nodeType === 3) { if (o.data !== n.data) o.data = n.data; used.add(o); return o }
177
+ if (n.nodeType !== 1) return n
178
+ if (markup(o) === markup(n)) { used.add(o); return o }
179
+ if (o.hasAttribute('data-patch') && n.hasAttribute('data-patch')) { syncAttrs(o, n); patchKids(o, Array.from(n.childNodes)); used.add(o); return o }
180
+ return n
181
+ })
182
+ for (const o of old) if (!used.has(o)) o.remove()
183
+ result.forEach((n, i) => { if (box.childNodes[i] !== n) box.insertBefore(n, box.childNodes[i] || null) })
184
+ }
185
+ function pathOf(el, root) {
186
+ const p = []
187
+ while (el && el !== root) {
188
+ const up = el.parentElement
189
+ if (!up) return null
190
+ p.unshift(Array.prototype.indexOf.call(up.children, el))
191
+ el = up
192
+ }
193
+ return el === root ? p : null
194
+ }
195
+ // Replaces box's children like setKids, keeping unchanged elements. Focus inside a swapped element moves to the
196
+ // element at the same place in its replacement, found through its keyed ancestors (rows can change order).
197
+ function patch(box, kids) {
198
+ const active = document.activeElement
199
+ const keys = []
200
+ let anchor = box
201
+ if (active && active !== document.body && box.contains(active)) {
202
+ for (let el = active.parentElement; el && el !== box; el = el.parentElement) if (el.hasAttribute('data-key')) keys.unshift(el)
203
+ if (keys.length) anchor = keys[keys.length - 1]
204
+ }
205
+ const path = anchor !== box || (active && box.contains(active) && active !== box) ? pathOf(active, anchor) : null
206
+ const chain = keys.map((el) => el.getAttribute('data-key'))
207
+ patchKids(box, kids)
208
+ if (path && !active.isConnected) {
209
+ let el = box
210
+ for (const k of chain) el = el && Array.from(el.querySelectorAll('[data-key]')).find((x) => x.getAttribute('data-key') === k)
211
+ for (const i of path) el = el && el.children[i]
212
+ if (el && typeof el.focus === 'function') el.focus({ preventScroll: true })
213
+ }
214
+ }
215
+ // A short stable fingerprint of a value.
216
+ function sig(value) {
217
+ const s = JSON.stringify(value) || ''
218
+ let x = 5381
219
+ for (let i = 0; i < s.length; i++) x = ((x * 33) ^ s.charCodeAt(i)) >>> 0
220
+ return x.toString(36) + s.length.toString(36)
221
+ }
222
+ // Whether the viewer is in the middle of something a background render would disturb: an open menu, dialog or
223
+ // tooltip, a focused field, or a text selection in the account list.
224
+ function uiBusy() {
225
+ if (menu.el || (dlg.el && dlg.el.open) || document.querySelector('.tip.is-open')) return true
226
+ const el = document.activeElement
227
+ if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) return true
228
+ const sel = window.getSelection && window.getSelection()
229
+ return Boolean(sel && !sel.isCollapsed && sel.anchorNode && $('accounts').contains(sel.anchorNode))
230
+ }
231
+
232
+ const ICONS = {
233
+ refresh: 'M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7',
234
+ search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4.2-4.2',
235
+ chevron: 'M9 6l6 6-6 6',
236
+ more: 'M5 12h.01M12 12h.01M19 12h.01',
237
+ plus: 'M12 5v14M5 12h14',
238
+ sun: 'M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4',
239
+ moon: 'M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z',
240
+ monitor: 'M3 5h18v11H3zM8 20h8M12 16v4',
241
+ copy: 'M9 9h11v11H9zM5 15H4V4h11v1',
242
+ terminal: 'M4 17l6-5-6-5M12 19h8',
243
+ key: 'M15 9a3 3 0 1 0-.01 0M12.9 11.1L4 20M7 17l2 2M9.5 14.5l2 2',
244
+ folder: 'M3 6h6l2 2h10v11H3z',
245
+ check: 'M9 12l2 2 4-4M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z',
246
+ userPlus: 'M15 19a6 6 0 0 0-12 0M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM19 8v6M16 11h6',
247
+ x: 'M6 6l12 12M18 6L6 18',
248
+ grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
249
+ list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
250
+ clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
251
+ mail: 'M3 6h18v12H3zM3 7l9 6 9-6',
252
+ eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
253
+ eyeOff: 'M3 3l18 18M10.6 5.1A10.8 10.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.6 6.6A17.4 17.4 0 0 0 2 12s3.5 7 10 7a10.4 10.4 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2',
254
+ }
255
+ function icon(name, cls) {
256
+ const svg = document.createElementNS(SVG_NS, 'svg')
257
+ svg.setAttribute('viewBox', '0 0 24 24')
258
+ svg.setAttribute('class', 'ic' + (cls ? ' ' + cls : ''))
259
+ svg.setAttribute('aria-hidden', 'true')
260
+ svg.setAttribute('focusable', 'false')
261
+ const path = document.createElementNS(SVG_NS, 'path')
262
+ path.setAttribute('d', ICONS[name])
263
+ svg.append(path)
264
+ return svg
265
+ }
266
+
267
+ // ---------- server calls ----------
268
+ // The token is per server process: after sideby ui restarts, the first write gets 403 "bad-token". The page then
269
+ // fetches the new token from api/session and sends that same request once more; the server refused it before
270
+ // running it, so the retry cannot apply anything twice. A request that got no answer is never replayed.
271
+ let token = BOOT.token
272
+ function failure(message, retryable, network, status, code) {
273
+ const e = new Error(message)
274
+ e.code = code || ''
275
+ e.retryable = retryable
276
+ e.network = Boolean(network)
277
+ e.status = status || 0
278
+ return e
279
+ }
280
+ async function send(path, init) {
281
+ let res
282
+ try { res = await fetch(BOOT.basePath + path, Object.assign({ cache: 'no-store' }, init)) } catch {
283
+ connectionLost()
284
+ throw failure(t('err.noAnswer'), true, true)
285
+ }
286
+ let data = null
287
+ try { data = await res.json() } catch {}
288
+ return { res, data }
289
+ }
290
+ async function refreshToken() {
291
+ const { res, data } = await send('/api/session')
292
+ if (!res.ok || !data || typeof data.token !== 'string') throw failure(t('err.session'), false)
293
+ token = data.token
294
+ }
295
+ async function api(path, body) {
296
+ for (let attempt = 0; ; attempt++) {
297
+ const init = body === undefined ? {} : {
298
+ method: 'POST',
299
+ headers: { 'content-type': 'application/json', 'x-sideby-token': token },
300
+ body: JSON.stringify(body),
301
+ }
302
+ const { res, data } = await send(path, init)
303
+ if (res.ok) return data
304
+ if (res.status === 403 && data && data.code === 'bad-token') {
305
+ if (attempt === 0) { await refreshToken(); continue }
306
+ throw failure(t('err.expired'), false)
307
+ }
308
+ const msg = (data && data.error) || 'request failed (' + res.status + ')'
309
+ // 4xx messages are written for people (what is wrong, what to do); 5xx ones are internal, so they get a lead-in.
310
+ if (res.status >= 500) throw failure(t('err.server', { msg }), true, false, res.status)
311
+ throw failure(msg.charAt(0).toUpperCase() + msg.slice(1), false, false, res.status, data && typeof data.code === 'string' ? data.code : '')
312
+ }
313
+ }
314
+
315
+ // ---------- connection ----------
316
+ // A request that cannot reach the server shows a small notice and polls api/health, waiting 1 s and then up to 5 s
317
+ // between tries. Once it answers, the page renews its token and reloads the state; writes are left for the user.
318
+ const conn = { lost: false, timer: 0, delay: 1000 }
319
+ function connectionLost() {
320
+ if (conn.lost) return
321
+ conn.lost = true
322
+ conn.delay = 1000
323
+ renderConnection()
324
+ conn.timer = setTimeout(ping, conn.delay)
325
+ }
326
+ async function ping() {
327
+ try {
328
+ const res = await fetch(BOOT.basePath + '/api/health', { cache: 'no-store' })
329
+ const data = await res.json()
330
+ if (!res.ok || !data || data.app !== 'sideby') throw new Error('not sideby')
331
+ const s = await fetch(BOOT.basePath + '/api/session', { cache: 'no-store' }).then((r) => r.json())
332
+ if (typeof s.token !== 'string') throw new Error('no session')
333
+ token = s.token
334
+ } catch {
335
+ conn.delay = Math.min(5000, Math.round(conn.delay * 1.6))
336
+ conn.timer = setTimeout(ping, conn.delay)
337
+ return
338
+ }
339
+ conn.lost = false
340
+ renderConnection()
341
+ toast(t('toast.reconnected'))
342
+ load()
343
+ }
344
+ function renderConnection() {
345
+ $('conn').replaceChildren(...(conn.lost ? [spinner(), t('conn.lost')] : []))
346
+ $('conn').hidden = !conn.lost
347
+ }
348
+
349
+ // ---------- toasts ----------
350
+ // Bottom center, newest last, at most three. The same text again restarts its timer instead of stacking.
351
+ // Hovering a toast pauses it, so an Undo stays reachable.
352
+ const toasts = []
353
+ // The toast stack is a manual popover so it sits in the top layer; raising it again after a modal dialog
354
+ // opens keeps toasts above that dialog's backdrop. Without popover support it stays a fixed element.
355
+ function raiseToasts() {
356
+ const el = $('toasts')
357
+ if (typeof el.showPopover !== 'function') return
358
+ try { if (el.matches(':popover-open')) el.hidePopover(); el.showPopover() } catch {}
359
+ }
360
+ function toast(text, opts) {
361
+ opts = opts || {}
362
+ const kind = opts.kind || 'ok'
363
+ const ms = opts.duration || Math.max(2000, Math.min(8000, 1800 + text.length * 40 + (opts.action ? 4000 : 0) + (kind === 'fail' ? 2000 : 0)))
364
+ // A keyed toast (such as the theme) replaces the earlier one with that key.
365
+ const keyed = opts.key ? toasts.find((x) => x.key === opts.key && x.text !== text) : null
366
+ if (keyed) dismiss(keyed)
367
+ let item = toasts.find((x) => x.text === text)
368
+ if (item) {
369
+ clearTimeout(item.timer)
370
+ item.el.classList.remove('bump')
371
+ void item.el.offsetWidth
372
+ item.el.classList.add('bump')
373
+ } else {
374
+ item = { text, ms, key: opts.key || null }
375
+ const action = opts.action ? h('button', { class: 'toast-action', type: 'button', onclick: () => { dismiss(item); opts.action.run() } }, opts.action.label) : null
376
+ item.el = h('div', { class: 'toast toast-' + kind, role: kind === 'fail' ? 'alert' : null },
377
+ h('span', { class: 'toast-dot', 'aria-hidden': 'true' }),
378
+ h('span', { class: 'toast-text' }, text),
379
+ action,
380
+ h('button', { class: 'toast-x', type: 'button', 'aria-label': t('toast.dismiss'), onclick: () => dismiss(item) }, icon('x')))
381
+ item.el.addEventListener('mouseenter', () => clearTimeout(item.timer))
382
+ item.el.addEventListener('mouseleave', () => { item.timer = setTimeout(() => dismiss(item), 2500) })
383
+ toasts.push(item)
384
+ $('toasts').append(item.el)
385
+ if (dlg.el && dlg.el.open) raiseToasts()
386
+ while (toasts.length > 3) dismiss(toasts[0])
387
+ }
388
+ item.timer = setTimeout(() => dismiss(item), ms)
389
+ }
390
+ function dismiss(item) {
391
+ clearTimeout(item.timer)
392
+ const i = toasts.indexOf(item)
393
+ if (i >= 0) toasts.splice(i, 1)
394
+ item.el.remove()
395
+ }
396
+
397
+ // ---------- small parts ----------
398
+ function ago(iso) {
399
+ const ts = Date.parse(iso)
400
+ if (!Number.isFinite(ts)) return ''
401
+ const s = Math.max(0, (Date.now() - ts) / 1000)
402
+ if (s < 45) return t('time.justNow')
403
+ const m = Math.round(s / 60)
404
+ if (m < 60) return t('time.minAgo', { n: m })
405
+ const hr = Math.round(m / 60)
406
+ if (hr < 24) return t('time.hAgo', { n: hr })
407
+ return t('time.dAgo', { n: Math.round(hr / 24) })
408
+ }
409
+ // The header's "Updated …" age (see updatedAge).
410
+ function agoShort(ms) {
411
+ const a = updatedAge(ms)
412
+ return t(a.key, { n: a.n })
413
+ }
414
+ function clock(iso) {
415
+ const d = new Date(iso)
416
+ if (!Number.isFinite(d.getTime())) return '?'
417
+ const time = d.toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' })
418
+ const now = new Date()
419
+ if (d.toDateString() === now.toDateString()) return time
420
+ if (Math.abs(d - now) < 6 * 864e5) return d.toLocaleDateString(locale(), { weekday: 'short' }) + ' ' + time
421
+ return d.toLocaleDateString(locale(), { month: 'short', day: 'numeric' }) + ' ' + time
422
+ }
423
+ function day(iso) {
424
+ const d = new Date(iso)
425
+ return Number.isFinite(d.getTime()) ? d.toLocaleDateString(locale(), { month: 'short', day: 'numeric' }) : '?'
426
+ }
427
+ function countdown(iso, now) {
428
+ const at = Date.parse(iso)
429
+ if (!Number.isFinite(at)) return '?'
430
+ return t('time.in', { d: formatDuration(at - now, lang) })
431
+ }
432
+ function tokens(n) {
433
+ if (!Number.isFinite(n)) return '0'
434
+ const units = [[1e9, 'B'], [1e6, 'M'], [1e3, 'K']]
435
+ for (const [v, u] of units) if (n >= v) return (n / v).toFixed(n / v >= 100 ? 0 : 1).replace(/\.0$/, '') + u
436
+ return String(Math.round(n))
437
+ }
438
+ function isStale(iso, now) {
439
+ const ts = Date.parse(iso)
440
+ return Number.isFinite(ts) && now - ts > STALE_MS
441
+ }
442
+
443
+ function family(id) { return (S.state && S.state.families.find((f) => f.id === id)) || { id, title: id, bin: id, installUrl: '', installed: false } }
444
+ function famTitle(a) { return a.familyTitle || family(a.family).title }
445
+ // The email as the page shows it: masked everywhere while "Hide emails" is on (copying still gives the real one).
446
+ function emailText(email) { return prefs.hideEmail ? maskEmail(email) : email }
447
+ // A Family's mark: the logo its FamilyDef sets (checked by the loader), or its initials on a color derived from the id.
448
+ // size: '' (cards), 'sm' (rows, groups, dialogs) or 'xs' (inside a chip; no initials fallback there).
449
+ function badge(id, size) {
450
+ const logo = family(id).logo
451
+ const cls = 'badge' + (size ? ' badge-' + size : '')
452
+ if (logo) {
453
+ const svg = document.createElementNS(SVG_NS, 'svg')
454
+ svg.setAttribute('viewBox', '0 0 24 24')
455
+ svg.setAttribute('focusable', 'false')
456
+ const path = document.createElementNS(SVG_NS, 'path')
457
+ path.setAttribute('d', logo.path)
458
+ if (logo.fillRule) path.setAttribute('fill-rule', logo.fillRule)
459
+ svg.append(path)
460
+ return h('span', { class: cls + ' badge-logo', css: logo.color ? { color: logo.color } : null, title: logo.title || family(id).title, 'aria-hidden': 'true' }, svg)
461
+ }
462
+ if (size === 'xs') return null
463
+ let x = 0
464
+ for (const c of id) x = (x * 31 + c.charCodeAt(0)) % 360
465
+ return h('span', { class: cls, css: { background: 'hsl(' + x + ' 55% 46%)' }, 'aria-hidden': 'true' }, id.slice(0, 2).toUpperCase())
466
+ }
467
+ function spinner() { return h('span', { class: 'spin', 'aria-hidden': 'true' }) }
468
+ function link(text, url, cls) {
469
+ let ok = false
470
+ try { const u = new URL(url); ok = u.protocol === 'https:' || u.protocol === 'http:' } catch {}
471
+ return ok ? h('a', { class: cls, href: url, target: '_blank', rel: 'noopener noreferrer' }, text) : null
472
+ }
473
+ async function copyText(text) {
474
+ let ok = true
475
+ try { await navigator.clipboard.writeText(text) } catch {
476
+ const ta = h('textarea', { css: { position: 'fixed', opacity: '0' } })
477
+ ta.value = text
478
+ document.body.append(ta)
479
+ ta.select()
480
+ try { ok = document.execCommand('copy') } catch { ok = false }
481
+ ta.remove()
482
+ }
483
+ if (ok) toast(t('toast.copied', { text: prefs.hideEmail && text.includes('@') ? maskEmail(text) : text }))
484
+ else toast(t('toast.copyFailed'), { kind: 'fail' })
485
+ }
486
+ function codeCopy(text) {
487
+ return h('span', { class: 'copy' }, h('code', null, text),
488
+ h('button', { class: 'btn btn-sm', type: 'button', 'aria-label': t('btn.copyText', { text }), onclick: () => copyText(text) }, icon('copy'), t('btn.copy')))
489
+ }
490
+
491
+ // A "?" next to a term; the explanation shows on hover and on keyboard focus.
492
+ let tipSeq = 0
493
+ function tipRaw(term, body) {
494
+ const id = 'tip-' + (++tipSeq)
495
+ return h('span', { class: 'tip' },
496
+ h('button', { class: 'tip-btn', type: 'button', 'aria-label': t('tip.label', { term }), 'aria-describedby': id }, '?'),
497
+ h('span', { class: 'tip-body', role: 'tooltip', id }, body))
498
+ }
499
+ function tip(key) { return tipRaw(t('tip.' + key + '.term'), t('tip.' + key + '.body')) }
500
+ function showTip(btn) {
501
+ const body = btn.nextElementSibling
502
+ if (!body) return
503
+ body.parentElement.classList.add('is-open')
504
+ const r = btn.getBoundingClientRect()
505
+ const w = body.offsetWidth
506
+ const hgt = body.offsetHeight
507
+ const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2))
508
+ const top = r.top - hgt - 8 >= 8 ? r.top - hgt - 8 : r.bottom + 8
509
+ body.style.setProperty('left', left + 'px')
510
+ body.style.setProperty('top', top + 'px')
511
+ }
512
+ function hideTip(btn) { if (btn.parentElement) btn.parentElement.classList.remove('is-open') }
513
+ document.addEventListener('pointerover', (e) => { const b = e.target.closest && e.target.closest('.tip-btn'); if (b) showTip(b) })
514
+ document.addEventListener('pointerout', (e) => { const b = e.target.closest && e.target.closest('.tip-btn'); if (b && !b.contains(e.relatedTarget)) hideTip(b) })
515
+ document.addEventListener('focusin', (e) => { if (e.target.classList && e.target.classList.contains('tip-btn')) showTip(e.target) })
516
+ document.addEventListener('focusout', (e) => { if (e.target.classList && e.target.classList.contains('tip-btn')) hideTip(e.target) })
517
+
518
+ // ---------- row menu ----------
519
+ const menu = { el: null, anchor: null }
520
+ function openMenu(anchor, items, label) {
521
+ closeMenu()
522
+ const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
523
+ items.filter(Boolean).map((it) => it === '-' ? h('div', { class: 'menu-sep', role: 'separator' })
524
+ : h('button', { class: 'menu-item', type: 'button', role: 'menuitem', tabindex: '-1', disabled: it.disabled, onclick: () => { closeMenu(true); it.run() } },
525
+ icon(it.icon), h('span', { class: 'menu-label' }, it.label), it.hint ? h('code', { class: 'menu-hint', title: it.hint }, it.hint.length > 24 ? '…' + it.hint.slice(-23) : it.hint) : null)))
526
+ el.addEventListener('keydown', menuKeys)
527
+ document.body.append(el)
528
+ const r = anchor.getBoundingClientRect()
529
+ const w = el.offsetWidth
530
+ const hgt = el.offsetHeight
531
+ const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.right - w))
532
+ const top = r.bottom + 4 + hgt > window.innerHeight - 8 && r.top - hgt - 4 > 8 ? r.top - hgt - 4 : r.bottom + 4
533
+ el.style.setProperty('left', left + 'px')
534
+ el.style.setProperty('top', top + 'px')
535
+ anchor.setAttribute('aria-expanded', 'true')
536
+ menu.el = el
537
+ menu.anchor = anchor
538
+ const first = el.querySelector('.menu-item:not(:disabled)')
539
+ if (first) first.focus()
540
+ }
541
+ function closeMenu(restore) {
542
+ if (!menu.el) return
543
+ menu.el.remove()
544
+ menu.anchor.setAttribute('aria-expanded', 'false')
545
+ if (restore && menu.anchor.isConnected) menu.anchor.focus()
546
+ menu.el = null
547
+ menu.anchor = null
548
+ }
549
+ function menuKeys(e) {
550
+ const items = Array.from(menu.el.querySelectorAll('.menu-item:not(:disabled)'))
551
+ const i = items.indexOf(document.activeElement)
552
+ if (e.key === 'ArrowDown') items[(i + 1) % items.length].focus()
553
+ else if (e.key === 'ArrowUp') items[(i - 1 + items.length) % items.length].focus()
554
+ else if (e.key === 'Home') items[0].focus()
555
+ else if (e.key === 'End') items[items.length - 1].focus()
556
+ else if (e.key === 'Escape') closeMenu(true)
557
+ else if (e.key === 'Tab') { closeMenu(true); return }
558
+ else return
559
+ e.preventDefault()
560
+ }
561
+ document.addEventListener('pointerdown', (e) => { if (menu.el && !menu.el.contains(e.target) && !menu.anchor.contains(e.target)) closeMenu() })
562
+ window.addEventListener('resize', () => closeMenu())
563
+ window.addEventListener('scroll', () => closeMenu(), { passive: true })
564
+
565
+ function accountMenu(a, anchor) {
566
+ const run = 'sideby run ' + a.ref
567
+ const items = [
568
+ { icon: 'terminal', label: t('menu.copyRun'), hint: run, run: () => copyText(run) },
569
+ ...(a.aliases || []).slice(0, 2).map((x) => ({ icon: 'terminal', label: t('menu.copyAlias'), hint: x, run: () => copyText(x) })),
570
+ a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
571
+ a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
572
+ a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
573
+ '-',
574
+ { icon: 'check', label: t('menu.check'), disabled: S.checking || S.fixing || Boolean(ui.checkingRef), run: () => checkAccount(a.ref) },
575
+ BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) },
576
+ ]
577
+ openMenu(anchor, items, t('menu.label', { ref: a.ref }))
578
+ }
579
+ function moreButton(a) {
580
+ const b = h('button', { class: 'icon-btn more', type: 'button', 'aria-haspopup': 'menu', 'aria-expanded': 'false', 'aria-label': t('menu.label', { ref: a.ref }), title: t('menu.title') }, icon('more'))
581
+ b.addEventListener('click', () => { if (menu.anchor === b) closeMenu(); else accountMenu(a, b) })
582
+ return b
583
+ }
584
+
585
+ // ---------- Doctor history ----------
586
+ // Health comes from the Doctor history, which keeps the latest result per Account: an Account that was
587
+ // never checked shows "Not checked", never "Healthy".
588
+ function entries() {
589
+ const hist = S.history
590
+ return hist ? Object.values(hist.general).concat(Object.values(hist.accounts)) : []
591
+ }
592
+ function findingsNow() {
593
+ const list = entries()
594
+ return list.length ? [].concat(...list.map((e) => e.findings)) : null
595
+ }
596
+ function lastCheckAt() {
597
+ const times = entries().map((e) => e.at).sort()
598
+ return times.length ? times[times.length - 1] : null
599
+ }
600
+ function healthOf(ref) {
601
+ const e = S.history && S.history.accounts[ref]
602
+ if (!e) return null
603
+ let fail = 0, warn = 0
604
+ for (const f of e.findings) { if (f.level === 'fail') fail++; else if (f.level === 'warn') warn++ }
605
+ return { fail, warn }
606
+ }
607
+ function reasonsOf(a, now) { return attentionReasons(a, healthOf(a.ref), now, WARN) }
608
+ function setupFor(fam) { return S.state && S.state.quotaSetups.find((s) => s.family === fam) }
609
+
610
+ // ---------- header and summary ----------
611
+ function renderUpdated() {
612
+ const busy = S.loading && !S.silent
613
+ const text = S.loading && !S.state ? t('top.loading') : busy ? t('top.refreshing') : S.loadedAt ? t('top.updated', { ago: agoShort(Date.now() - S.loadedAt) }) : ''
614
+ const el = $('updated')
615
+ if (el.textContent !== text) el.textContent = text
616
+ el.title = S.loadedAt ? t('top.updatedTitle', { at: clock(new Date(S.loadedAt).toISOString()), s: Math.round((S.fastUntil > Date.now() ? FAST_MS : AUTO_MS) / 1000) }) : ''
617
+ }
618
+ function renderTop() {
619
+ renderUpdated()
620
+ // A background refresh keeps the button still; only one the viewer asked for spins it.
621
+ const busy = S.loading && !S.silent
622
+ const refresh = $('refresh')
623
+ refresh.disabled = busy
624
+ refresh.classList.toggle('is-spinning', busy)
625
+ if (busy) refresh.setAttribute('aria-busy', 'true'); else refresh.removeAttribute('aria-busy')
626
+ }
627
+ function renderStatic() {
628
+ document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'
629
+ for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.getAttribute('data-i18n'))
630
+ $('refresh').setAttribute('aria-label', t('top.refresh'))
631
+ const lb = $('lang')
632
+ lb.hidden = LANG_FIXED
633
+ lb.textContent = lang === 'zh' ? 'EN' : '中文'
634
+ lb.setAttribute('aria-label', t('top.language'))
635
+ lb.title = t('top.language')
636
+ const tb = $('theme')
637
+ tb.hidden = BOOT.colorScheme !== 'auto'
638
+ setKids(tb, icon(prefs.theme === 'light' ? 'sun' : prefs.theme === 'dark' ? 'moon' : 'monitor'))
639
+ tb.title = t('theme.' + prefs.theme)
640
+ tb.setAttribute('aria-label', t('theme.' + prefs.theme))
641
+ }
642
+ function applyTheme() {
643
+ if (BOOT.colorScheme !== 'auto') return
644
+ if (prefs.theme === 'system') document.documentElement.removeAttribute('data-theme')
645
+ else document.documentElement.setAttribute('data-theme', prefs.theme)
646
+ }
647
+ function stat(label, value, sub, extra, opts) {
648
+ const kids = [h('div', { class: 'stat-text' }, h('div', { class: 'stat-label' }, label), h('div', { class: 'stat-value' }, value), h('div', { class: 'stat-sub' }, sub)), extra]
649
+ if (opts && opts.onclick) return h('button', { class: 'stat stat-btn' + (opts.cls ? ' ' + opts.cls : ''), type: 'button', 'aria-pressed': String(Boolean(opts.pressed)), 'aria-label': t('stat.btnLabel', { label, value, sub, action: opts.title || '' }), title: opts.title, onclick: opts.onclick }, kids)
650
+ return h('div', { class: 'stat' + (opts && opts.cls ? ' ' + opts.cls : '') }, kids)
651
+ }
652
+ function spark(daily, cls) {
653
+ if (!Array.isArray(daily) || !daily.length) return null
654
+ const max = Math.max(0, ...daily.map((d) => Number(d.totalTokens) || 0))
655
+ return h('span', { class: 'spark' + (cls ? ' ' + cls : ''), role: 'img', 'aria-label': t('usage.sparkLabel', { n: daily.length }) },
656
+ daily.map((d, i) => {
657
+ const v = Number(d.totalTokens) || 0
658
+ const date = new Date(d.date + 'T12:00:00')
659
+ const day = Number.isFinite(date.getTime()) ? date.toLocaleDateString(locale(), { weekday: 'short', month: 'numeric', day: 'numeric' }) : d.date
660
+ // Every day keeps its slot; an empty day shows only a short stub on the baseline, so it reads as a gap in time.
661
+ return h('i', { class: (i === daily.length - 1 ? 'today' : '') + (v ? '' : ' zero') || null, title: day + ': ' + tokens(v) },
662
+ h('b', { css: { height: v && max ? Math.max(10, Math.round((v / max) * 100)) + '%' : null } }))
663
+ }))
664
+ }
665
+ function renderSummary() {
666
+ const box = $('summary')
667
+ if (!S.state) { if (S.loadError) box.hidden = true; return }
668
+ const list = S.state.accounts
669
+ box.hidden = !list.length
670
+ box.removeAttribute('aria-busy')
671
+ if (!list.length) return
672
+ const now = Date.now()
673
+ const famCount = new Set(list.map((a) => a.family)).size
674
+ const apiCount = list.filter((a) => a.kind === 'api').length
675
+ const counts = {}
676
+ let need = 0
677
+ for (const a of list) {
678
+ const rs = reasonsOf(a, now)
679
+ if (rs.length) need++
680
+ for (const r of rs) counts[r] = (counts[r] || 0) + 1
681
+ }
682
+ const order = ['error', 'health-fail', 'signed-out', 'not-installed', 'quota-high', 'health-warn']
683
+ const needSub = need ? order.filter((r) => counts[r]).slice(0, 2).map((r) => tn('need.' + r, counts[r])).join(' · ') : t('stat.allClear')
684
+ const usages = list.filter((a) => a.usage && a.usage.status === 'ok')
685
+ const total = usages.reduce((s, a) => s + (Number(a.usage.totalTokens) || 0), 0)
686
+ const sessions = usages.reduce((s, a) => s + (Number(a.usage.sessions) || 0), 0)
687
+ const daily = mergeDaily(usages.map((a) => a.usage.daily)).slice(-BOOT.usageDays)
688
+ const nr = nextReset(list, now)
689
+ patch(box, [
690
+ stat(t('stat.accounts'), String(list.length), dots([tn('stat.families', famCount), apiCount ? tn('stat.api', apiCount) : null])),
691
+ need
692
+ ? stat(t('stat.attention'), String(need), needSub, null, { cls: 'stat-warn', pressed: ui.attention, title: t(ui.attention ? 'stat.attentionOff' : 'stat.attentionOn'), onclick: () => { ui.attention = !ui.attention; renderSummary(); renderAccounts() } })
693
+ : stat(t('stat.attention'), '0', needSub, null, { cls: 'stat-ok' }),
694
+ stat(t('stat.tokens', { n: BOOT.usageDays }), tokens(total), tn('stat.sessions', sessions), spark(daily, 'spark-lg')),
695
+ stat(h('span', { class: 'label-tip' }, t('stat.nextReset'), tip('age')),
696
+ nr ? t('time.in', { d: formatDuration(nr.at - now, lang) }) : '—',
697
+ nr ? dots([h('span', { class: 'mono' }, nr.ref), nr.label, clock(new Date(nr.at).toISOString())]) : t('stat.noQuota')),
698
+ ])
699
+ }
700
+
701
+ // ---------- toolbar ----------
702
+ const tb = {}
703
+ function seg(label, options, value, onPick) {
704
+ const btns = options.map((o) => h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(o[0] === value), title: o[2] || null, 'aria-label': o[2] || t('tb.segLabel', { group: label, option: o[1] }), onclick: () => {
705
+ for (const b of btns) b.setAttribute('aria-pressed', String(b === btn(o[0])))
706
+ onPick(o[0])
707
+ } }, o[1]))
708
+ const btn = (v) => btns[options.findIndex((o) => o[0] === v)]
709
+ return h('div', { class: 'seg', role: 'group', 'aria-label': label }, btns)
710
+ }
711
+ function buildToolbar() {
712
+ const hadFocus = tb.search && document.activeElement === tb.search
713
+ tb.search = h('input', { type: 'search', id: 'q', class: 'search-input', autocomplete: 'off', spellcheck: 'false', 'aria-label': t('tb.search'), placeholder: t('tb.searchPh'), 'aria-keyshortcuts': '/ Meta+F Control+F' })
714
+ tb.search.value = ui.query
715
+ tb.search.addEventListener('input', () => { ui.query = tb.search.value; renderAccounts() })
716
+ tb.search.addEventListener('keydown', (e) => {
717
+ if (e.key !== 'Escape') return
718
+ e.preventDefault()
719
+ if (tb.search.value) { tb.search.value = ''; ui.query = ''; renderAccounts() } else tb.search.blur()
720
+ })
721
+ tb.family = h('select', { class: 'tb-select', 'aria-label': t('tb.family') })
722
+ tb.family.addEventListener('change', () => { prefs.family = tb.family.value; savePrefs(); renderAccounts() })
723
+ tb.sort = h('select', { class: 'tb-select', 'aria-label': t('tb.sort') },
724
+ CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v))))
725
+ tb.sort.value = prefs.sort
726
+ tb.sort.addEventListener('change', () => { prefs.sort = tb.sort.value; savePrefs(); renderAccounts() })
727
+ const quota = seg(t('tb.quota'), [['used', t('tb.used')], ['left', t('tb.left')]], prefs.quota, (v) => { prefs.quota = v; savePrefs(); renderAccounts() })
728
+ const view = seg(t('tb.view'), [['list', icon('list'), t('tb.list')], ['cards', icon('grid'), t('tb.cards')]], prefs.view, (v) => { prefs.view = v; savePrefs(); ui.fade = true; renderAccounts() })
729
+ tb.hideEmail = h('button', { class: 'icon-btn tb-icon', type: 'button', 'aria-pressed': String(prefs.hideEmail), onclick: () => {
730
+ prefs.hideEmail = !prefs.hideEmail
731
+ savePrefs()
732
+ renderEmailToggle()
733
+ renderAccounts()
734
+ } })
735
+ const create = BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', 'aria-label': t('tb.new'), onclick: () => openCreate(null) }, icon('plus'), h('span', null, t('tb.new')))
736
+ setKids($('toolbar'), [
737
+ h('label', { class: 'search' }, icon('search', 'search-ic'), tb.search, h('kbd', { class: 'kbd', 'aria-hidden': 'true' }, '/')),
738
+ h('div', { class: 'tools' }, tb.family, tb.sort, quota, view, tb.hideEmail),
739
+ create,
740
+ ])
741
+ renderFamilyFilter()
742
+ renderEmailToggle()
743
+ if (hadFocus) tb.search.focus()
744
+ }
745
+ // "Hide emails": shown once any account has one; its icon and label say what a click does.
746
+ function renderEmailToggle() {
747
+ const b = tb.hideEmail
748
+ if (!b) return
749
+ b.hidden = !(S.state && S.state.accounts.some((a) => a.identity && a.identity.email))
750
+ b.setAttribute('aria-pressed', String(prefs.hideEmail))
751
+ const label = t(prefs.hideEmail ? 'tb.showEmails' : 'tb.hideEmails')
752
+ b.setAttribute('aria-label', label)
753
+ b.title = label
754
+ setKids(b, icon(prefs.hideEmail ? 'eyeOff' : 'eye'))
755
+ }
756
+ function renderFamilyFilter() {
757
+ if (!tb.family) return
758
+ const fams = S.state ? S.state.families.filter((f) => S.state.accounts.some((a) => a.family === f.id)) : []
759
+ tb.family.replaceChildren(h('option', { value: '' }, t('tb.allFamilies')),
760
+ ...fams.map((f) => h('option', { value: f.id }, f.title + ' (' + S.state.accounts.filter((a) => a.family === f.id).length + ')')))
761
+ if (prefs.family && S.state && !fams.some((f) => f.id === prefs.family)) prefs.family = ''
762
+ tb.family.value = prefs.family
763
+ tb.family.hidden = fams.length < 2 && !prefs.family
764
+ }
765
+ document.addEventListener('keydown', (e) => {
766
+ if (dlg.el && dlg.el.open) return
767
+ const el = e.target
768
+ const typing = el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)
769
+ const find = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === 'f' || e.key === 'F')
770
+ if ((find || (e.key === '/' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey)) && tb.search && S.state && S.state.accounts.length) {
771
+ e.preventDefault()
772
+ tb.search.focus()
773
+ tb.search.select()
774
+ }
775
+ })
776
+
777
+ // ---------- accounts ----------
778
+ // One column per Quota window label. 5h and 7d always have one, so rows of every group line up under the same
779
+ // header; a label only some account reports (such as 60m) adds a column, at most three in all.
780
+ const KNOWN_WINDOWS = { '5h': 300, '7d': 10080 }
781
+ function quotaColumns(list) {
782
+ const mins = Object.assign({}, KNOWN_WINDOWS)
783
+ for (const a of list)
784
+ if (a.quota && a.quota.status === 'ok')
785
+ for (const w of a.quota.windows || []) if (!Object.hasOwn(mins, w.label)) mins[w.label] = Number(w.windowMinutes) || 0
786
+ return Object.keys(mins).sort((x, y) => mins[x] - mins[y]).slice(0, 3)
787
+ }
788
+ // A window's cell: percent and countdown on one line, the bar under them. The label shows where no column
789
+ // header does (cards, narrow screens).
790
+ function meter(w, now, stale, observedAt) {
791
+ const st = windowState(w, now, WARN, FAIL)
792
+ const passed = passedWindow(w, observedAt, now)
793
+ const left = prefs.quota === 'left'
794
+ const shown = left ? 100 - st.pct : st.pct
795
+ const title = passed ? t('quota.resetNoData', { date: day(passed.since) }) : t('quota.resetsAt', { at: clock(w.resetsAt) })
796
+ return h('div', { class: 'meter lvl-' + st.level + (stale || passed ? ' lvl-stale' : ''), title },
797
+ h('div', { class: 'm-top' },
798
+ h('span', { class: 'm-label' }, w.label),
799
+ h('span', { class: 'm-pct' }, shown + '%', h('span', { class: 'm-unit' }, ' ' + t(left ? 'quota.unitLeft' : 'quota.unitUsed'))),
800
+ h('span', { class: 'm-reset' + (passed ? ' m-passed' : '') }, passed ? t('quota.resetShort') : countdown(w.resetsAt, now))),
801
+ h('div', { class: 'bar', role: 'meter', 'aria-label': t(left ? 'quota.meterLeft' : 'quota.meterUsed', { label: w.label }), 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(shown), 'aria-valuetext': t(left ? 'quota.pctLeft' : 'quota.pctUsed', { n: shown }) }, h('span', { css: { width: shown + '%' } })))
802
+ }
803
+ // A column whose window this account does not report right now, such as a 5h window that has not started.
804
+ function noMeter(label) {
805
+ return h('div', { class: 'meter meter-none', title: t('quota.missing', { label }) },
806
+ h('div', { class: 'm-top' }, h('span', { class: 'm-label' }, label), h('span', { class: 'm-none', 'aria-label': t('quota.missing', { label }) }, '—')))
807
+ }
808
+ function quotaNote(a, q) {
809
+ const setup = setupFor(a.family)
810
+ // One setup turns quota on for every account of the Family, so the switch lives in the group header only.
811
+ if (q.reason === 'not-enabled') {
812
+ const host = famTitle(a)
813
+ const body = setup && setup.plan.status === 'ready' && !BOOT.readOnly ? t('tip.quotaOff.body', { host })
814
+ : setup && setup.plan.status === 'blocked' ? setup.plan.message
815
+ : t('tip.quotaOff.cli', { host, family: a.family })
816
+ return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.off')), tipRaw(t('tip.quotaOff.term'), body))
817
+ }
818
+ if (q.reason === 'api-account') return h('div', { class: 'q-note' }, t('quota.api'), tip('api'))
819
+ const text = { 'no-session': 'quota.noSession', 'no-source': 'quota.noSource', unrecognized: 'quota.unrecognized' }[q.reason]
820
+ return h('div', { class: 'q-note', title: q.reason === 'no-session' ? t('quota.noSessionHint') : q.detail || null }, text ? t(text) : t('quota.unavailable', { reason: q.reason }))
821
+ }
822
+ function quotaBlock(a, now, cols) {
823
+ const q = a.quota
824
+ if (!q) return h('div', { class: 'quota' }, h('div', { class: 'q-note' }, '—'))
825
+ if (q.status !== 'ok') return h('div', { class: 'quota' }, quotaNote(a, q))
826
+ const stale = isStale(q.observedAt, now)
827
+ const wins = q.windows || []
828
+ return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title: stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) }) },
829
+ wins.length ? cols.map((c) => {
830
+ const w = wins.find((x) => x.label === c)
831
+ return w ? meter(w, now, stale, q.observedAt) : noMeter(c)
832
+ }) : h('div', { class: 'q-note' }, t('quota.noWindows')),
833
+ stale ? h('div', { class: 'q-asof' }, icon('clock'), t('quota.asOf', { ago: ago(q.observedAt) })) : null)
834
+ }
835
+ function usageBlock(a) {
836
+ const u = a.usage
837
+ if (!u) return h('div', { class: 'usage' })
838
+ if (u.status !== 'ok') {
839
+ const key = u.reason === 'no-session' ? 'usage.none' : u.reason === 'no-source' ? 'usage.noSource' : 'usage.unreadable'
840
+ return h('div', { class: 'usage' }, h('span', { class: 'u-note', title: u.detail || null }, t(key, { n: BOOT.usageDays })))
841
+ }
842
+ const rate = cacheHitRate(u)
843
+ const detail = t('usage.detail', { days: u.days, sessions: u.sessions, input: tokens(u.inputTokens), output: tokens(u.outputTokens), cache: tokens(u.cacheReadTokens + u.cacheWriteTokens) })
844
+ return h('div', { class: 'usage', title: detail },
845
+ h('div', { class: 'u-text' },
846
+ h('span', { class: 'u-num' }, tokens(u.totalTokens), h('span', { class: 'u-unit' }, ' ' + t('usage.tokens'))),
847
+ h('div', { class: 'u-sub' }, rate === null ? tn('usage.sessions', u.sessions) : t('usage.cache', { n: Math.round(rate * 100) }))),
848
+ u.totalTokens ? spark(u.daily) : null)
849
+ }
850
+ function lastUsed(a) {
851
+ const at = a.usage && a.usage.status === 'ok' && a.usage.lastActivityAt
852
+ return h('div', { class: 'last', title: at ? t('last.title', { at: clock(at) }) : t('last.unknown') }, at ? ago(at) : h('span', { class: 'faint' }, '—'))
853
+ }
854
+ function healthChip(a) {
855
+ const hs = healthOf(a.ref)
856
+ let lvl = ''
857
+ let text
858
+ if (ui.checkingRef === a.ref || (S.checking && !hs)) text = t('health.checking')
859
+ else if (a.error) { lvl = 'fail'; text = t('health.error') }
860
+ else if (!hs) text = t('health.notChecked')
861
+ else if (hs.fail) { lvl = 'fail'; text = tn('health.issues', hs.fail) }
862
+ else if (hs.warn) { lvl = 'warn'; text = tn('health.warnings', hs.warn) }
863
+ else { lvl = 'ok'; text = t('health.ok') }
864
+ return h('button', { class: 'chip chip-btn health' + (lvl ? ' chip-' + lvl : ''), type: 'button', title: t('health.filterHint'), 'aria-label': t('health.chipLabel', { ref: a.ref, status: text }), onclick: () => filterHealth(a.ref) },
865
+ h('span', { class: 'dot' + (lvl ? ' dot-' + lvl : ''), 'aria-hidden': 'true' }), text)
866
+ }
867
+ function nameLine(a) {
868
+ return h('div', { class: 'ident-name' },
869
+ h('span', { class: 'name', title: a.name }, a.name),
870
+ (a.aliases || []).slice(0, 3).map((x) => {
871
+ const args = aliasArgsOf(a, x)
872
+ return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
873
+ }),
874
+ a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
875
+ isNext(a)
876
+ ? h('button', { class: 'tag tag-ok tag-btn', type: 'button', title: t('acct.nextHint', { family: a.family }), 'aria-label': t('acct.nextLabel', { ref: a.ref, family: a.family }), onclick: () => copyText('sideby next ' + a.family) }, t('acct.next'))
877
+ : null,
878
+ a.login === 'logged-out' && a.kind !== 'api'
879
+ ? h('button', { class: 'tag tag-fail tag-btn', type: 'button', title: t('acct.signInHint'), 'aria-label': t('acct.signedOutLabel', { ref: a.ref }), onclick: () => copyText('sideby login ' + a.ref) }, h('span', { class: 'dot dot-fail', 'aria-hidden': 'true' }), t('acct.signedOut'))
880
+ : null,
881
+ a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t('acct.notInstalled')) : null)
882
+ }
883
+ // Who the account is signed in as, under its name; masked while "Hide emails" is on.
884
+ function emailLine(a) {
885
+ const id = a.identity
886
+ if (!id || (!id.email && !id.org)) return null
887
+ const email = id.email ? emailText(id.email) : ''
888
+ return h('div', { class: 'ident-email', title: [email, id.org].filter(Boolean).join(' · ') },
889
+ dots([email ? h('span', { class: 'email' }, email) : null, id.org ? h('span', { class: 'org' }, id.org) : null]))
890
+ }
891
+ function subLine(a) {
892
+ const plan = a.quota && a.quota.status === 'ok' && a.quota.plan
893
+ return h('div', { class: 'ident-sub' }, dots([
894
+ h('span', { class: 'mono' }, a.ref),
895
+ a.kind === 'api' && a.model ? h('span', { class: 'mono', title: t('acct.model') }, a.model) : null,
896
+ a.kind !== 'api' && plan ? h('span', { class: 'plan' }, plan) : null,
897
+ ]))
898
+ }
899
+ function notes(a) {
900
+ const fam = family(a.family)
901
+ const out = []
902
+ if (a.error) out.push(h('div', { class: 'note note-fail' }, h('b', null, t('acct.readError')), ' ', h('span', null, a.error)))
903
+ if (a.hostInstalled === false) out.push(h('div', { class: 'note note-warn' }, tx('acct.installHost', { host: fam.title, link: link(t('acct.installIt'), fam.installUrl) || '' })))
904
+ return out.length ? h('div', { class: 'row-notes' }, out) : null
905
+ }
906
+ // What a row or card shows depends on more than the account (health, view preferences, language); the
907
+ // fingerprint covers what its click handlers use.
908
+ function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a)]) }
909
+ // The Account 'sideby next <family>' would pick; the server ranks it with the CLI's rule (spec §3.13).
910
+ function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
911
+ function row(a, now, cols) {
912
+ return h('div', { class: 'row' + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a) },
913
+ h('div', { class: 'ident' }, badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a))),
914
+ quotaBlock(a, now, cols),
915
+ usageBlock(a),
916
+ lastUsed(a),
917
+ h('div', { class: 'cell-health' }, healthChip(a)),
918
+ moreButton(a),
919
+ notes(a))
920
+ }
921
+ function card(a, now, cols) {
922
+ return h('article', { class: 'card' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref },
923
+ h('header', { class: 'card-head' },
924
+ badge(a.family, 'sm'),
925
+ h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a)),
926
+ moreButton(a)),
927
+ notes(a),
928
+ quotaBlock(a, now, cols),
929
+ h('footer', { class: 'card-foot' }, usageBlock(a), h('div', { class: 'card-side' }, lastUsed(a), healthChip(a))))
930
+ }
931
+ // The list's column titles, once per group under its header. Screen readers get each cell's own label instead.
932
+ function colHead(cols, list) {
933
+ const left = prefs.quota === 'left'
934
+ // A group where no account reports windows (no public source, quota off) keeps the columns but not their titles.
935
+ const titled = list.some((a) => a.quota && a.quota.status === 'ok')
936
+ return h('div', { class: 'col-head', 'aria-hidden': 'true', 'data-key': 'cols' },
937
+ h('span', { class: 'ch-id' }, t('col.account')),
938
+ h('span', { class: 'quota' }, titled ? cols.map((c) => h('span', null, t(left ? 'col.left' : 'col.used', { label: c }))) : null),
939
+ h('span', { class: 'ch-usage' }, t('col.tokens', { n: BOOT.usageDays })),
940
+ h('span', { class: 'ch-last' }, t('col.last')),
941
+ h('span', { class: 'ch-health' }, t('col.health')),
942
+ h('span', { class: 'ch-more' }))
943
+ }
944
+ // Quota is on but no account of the Family has numbers yet: say what brings the first ones.
945
+ function waitingForQuota(fam) {
946
+ const setup = setupFor(fam)
947
+ if (!setup || setup.plan.status !== 'enabled') return false
948
+ const all = S.state.accounts.filter((a) => a.family === fam && a.kind !== 'api')
949
+ const waiting = all.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'no-session')
950
+ const any = all.some((a) => a.quota && a.quota.status === 'ok')
951
+ return waiting && !any
952
+ }
953
+ function group(fam, list, total, now, cols) {
954
+ const info = family(fam)
955
+ const filtering = Boolean(ui.query.trim()) || ui.attention
956
+ const collapsed = !filtering && prefs.collapsed[fam] === true
957
+ const need = list.filter((a) => reasonsOf(a, now).length).length
958
+ const setup = setupFor(fam)
959
+ const off = list.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'not-enabled')
960
+ const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
961
+ const cards = prefs.view === 'cards'
962
+ const head = h('div', { class: 'group-head', 'data-key': 'head' },
963
+ h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: info.title, count: list.length === total ? String(total) : list.length + ' / ' + total, action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => {
964
+ if (collapsed) delete prefs.collapsed[fam]; else prefs.collapsed[fam] = true
965
+ savePrefs()
966
+ renderAccounts()
967
+ } },
968
+ icon('chevron', 'chev'), badge(fam, 'sm'), h('span', { class: 'group-title' }, info.title),
969
+ h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
970
+ h('div', { class: 'group-meta' },
971
+ waitingForQuota(fam) ? h('span', { class: 'group-wait', role: 'status' }, h('span', { class: 'pulse', 'aria-hidden': 'true' }), t('quota.waiting', { host: info.title })) : null,
972
+ need ? h('span', { class: 'tag tag-warn' }, tn('group.attention', need)) : null,
973
+ off && setup && setup.plan.status === 'ready' && !BOOT.readOnly
974
+ ? h('span', { class: 'group-quota' },
975
+ h('span', { class: 'muted small' }, t('quota.turnOnFamilyNote', { host: info.title })),
976
+ h('button', { class: 'btn btn-sm', type: 'button', title: setup.summary, 'aria-label': t('quota.turnOnFamilyLabel', { host: info.title }), onclick: () => openSetup(fam) }, t('quota.turnOnFamily')))
977
+ : null,
978
+ off && setup && setup.plan.status === 'blocked' ? h('span', { class: 'muted small label-tip' }, t('quota.blocked'), tipRaw(t('quota.blocked'), setup.plan.message)) : null))
979
+ const body = collapsed ? null : cards
980
+ ? h('div', { class: 'grid', id: bodyId, 'data-key': 'cards', 'data-patch': '' }, list.map((a) => card(a, now, cols)))
981
+ : h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, list.map((a) => row(a, now, cols)))
982
+ return h('section', { class: 'group' + (cards ? ' group-cards' : ' group-list') + (collapsed ? ' is-collapsed' : ''), 'aria-label': info.title, 'data-key': 'g:' + fam, 'data-patch': '' },
983
+ head, collapsed || cards ? null : colHead(cols, list), body)
984
+ }
985
+ function emptyState() {
986
+ const fams = S.state.families.slice().sort((x, y) => Number(y.installed) - Number(x.installed))
987
+ return h('div', { class: 'empty' },
988
+ h('span', { class: 'logo', 'aria-hidden': 'true' }, h('i'), h('i')),
989
+ h('h3', null, t('empty.title')),
990
+ h('p', { class: 'muted' }, t('empty.body')),
991
+ fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
992
+ f.installed ? h('span', { class: 'muted small' }, tx('empty.runOnce', { bin: h('code', null, f.bin) })) : link(t('empty.install'), f.installUrl, 'btn btn-sm')))) : h('p', { class: 'muted small' }, t('empty.noFamilies')),
993
+ BOOT.readOnly ? null : h('button', { class: 'btn', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')))
994
+ }
995
+ function clearFilters() {
996
+ ui.query = ''
997
+ ui.attention = false
998
+ prefs.family = ''
999
+ savePrefs()
1000
+ if (tb.search) tb.search.value = ''
1001
+ if (tb.family) tb.family.value = ''
1002
+ renderSummary()
1003
+ renderAccounts()
1004
+ }
1005
+ function noMatch() {
1006
+ return h('div', { class: 'empty empty-sm' },
1007
+ h('h3', null, t('filter.none')),
1008
+ h('p', { class: 'muted' }, ui.query.trim() ? t('filter.noneQuery', { q: ui.query.trim() }) : t('filter.noneBody')),
1009
+ h('button', { class: 'btn', type: 'button', onclick: clearFilters }, t('filter.clear')))
1010
+ }
1011
+ function loadErrorBlock() {
1012
+ return h('div', { class: 'empty empty-sm empty-fail', role: 'alert' },
1013
+ h('h3', null, t('load.failed')),
1014
+ h('p', null, S.loadError.message),
1015
+ h('button', { class: 'btn', type: 'button', onclick: () => load({ check: !S.report }) }, icon('refresh'), t('btn.retry')))
1016
+ }
1017
+ function renderAccounts() {
1018
+ closeMenu()
1019
+ const box = $('accounts')
1020
+ const count = $('acct-count')
1021
+ if (!S.state) {
1022
+ if (S.loadError) { box.removeAttribute('aria-busy'); patch(box, loadErrorBlock()); setKids(count, null) }
1023
+ return
1024
+ }
1025
+ box.removeAttribute('aria-busy')
1026
+ const all = S.state.accounts
1027
+ const now = Date.now()
1028
+ $('toolbar').hidden = !all.length
1029
+ if (!all.length) { setKids(count, null); patch(box, emptyState()); return }
1030
+ const shown = all.filter((a) => (!prefs.family || a.family === prefs.family) && matchesQuery(a, ui.query, famTitle(a)) && (!ui.attention || reasonsOf(a, now).length))
1031
+ const filtered = shown.length !== all.length
1032
+ patch(count, filtered
1033
+ ? [t('count.filtered', { n: shown.length, total: all.length }), ' ', h('button', { class: 'link-btn', type: 'button', onclick: clearFilters }, t('filter.clear'))]
1034
+ : tn('count.accounts', all.length))
1035
+ if (!shown.length) { patch(box, noMatch()); return }
1036
+ const cols = quotaColumns(shown)
1037
+ box.style.setProperty('--qn', String(cols.length))
1038
+ const ids = S.state.families.map((f) => f.id)
1039
+ for (const a of all) if (!ids.includes(a.family)) ids.push(a.family)
1040
+ const groups = []
1041
+ for (const fam of ids) {
1042
+ const list = shown.filter((a) => a.family === fam)
1043
+ if (list.length) groups.push(group(fam, sortAccounts(list, prefs.sort, now), all.filter((a) => a.family === fam).length, now, cols))
1044
+ }
1045
+ patch(box, groups)
1046
+ if (ui.fade && !REDUCED) {
1047
+ box.classList.add('view-in')
1048
+ setTimeout(() => box.classList.remove('view-in'), 400)
1049
+ }
1050
+ ui.fade = false
1051
+ // A new account is highlighted once its dialog is closed, so the flash is not spent behind it.
1052
+ if (ui.highlight && !(dlg.el && dlg.el.open)) flashRow(ui.highlight)
1053
+ }
1054
+ function flashRow(ref) {
1055
+ const el = document.querySelector('[data-ref="' + CSS.escape(ref) + '"]')
1056
+ if (!el) return
1057
+ ui.highlight = null
1058
+ el.classList.add('flash')
1059
+ el.scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'center' })
1060
+ setTimeout(() => el.classList.remove('flash'), 2600)
1061
+ }
1062
+
1063
+ // ---------- Health check ----------
1064
+ async function runCheck() {
1065
+ if (S.checking || S.fixing) return
1066
+ S.checking = true
1067
+ S.checkError = null
1068
+ renderCheckup(); renderAccounts()
1069
+ try {
1070
+ const r = await api('/api/doctor', {})
1071
+ S.report = r.report
1072
+ S.history = r.history
1073
+ } catch (e) { S.checkError = e; S.checkRetry = runCheck }
1074
+ S.checking = false
1075
+ renderCheckup(); renderAccounts(); renderSummary()
1076
+ // A check can follow a change made outside the Panel; pick up the rest of the state too.
1077
+ load({ silent: true })
1078
+ }
1079
+ async function checkAccount(ref) {
1080
+ if (S.checking || S.fixing || ui.checkingRef) return
1081
+ ui.checkingRef = ref
1082
+ renderAccounts(); renderCheckup()
1083
+ try {
1084
+ const r = await api('/api/doctor', { target: ref })
1085
+ S.history = r.history
1086
+ const hs = healthOf(ref)
1087
+ if (!hs || (!hs.fail && !hs.warn)) toast(t('toast.checkOk', { ref }))
1088
+ else toast(t('toast.checkFound', { ref, what: hs.fail ? tn('health.issues', hs.fail) : tn('health.warnings', hs.warn) }), { kind: hs.fail ? 'fail' : 'info', action: { label: t('toast.show'), run: () => filterHealth(ref) } })
1089
+ } catch (e) { toast(t('toast.checkFailed', { ref, msg: e.message }), { kind: 'fail' }) }
1090
+ ui.checkingRef = null
1091
+ renderAccounts(); renderCheckup(); renderSummary()
1092
+ load({ silent: true })
1093
+ }
1094
+ async function runFix() {
1095
+ if (S.checking || S.fixing) return
1096
+ S.fixing = true
1097
+ S.checkError = null
1098
+ S.fixes = null
1099
+ renderCheckup()
1100
+ try {
1101
+ const r = await api('/api/fix', {})
1102
+ S.report = r.report
1103
+ S.history = r.history
1104
+ S.fixes = r.report.fixes
1105
+ const failed = S.fixes.filter((f) => !f.ok).length
1106
+ toast(S.fixes.length ? t('fix.result', { ok: S.fixes.length - failed, n: S.fixes.length }) : t('fix.nothing'), { kind: failed ? 'fail' : 'ok' })
1107
+ } catch (e) { S.checkError = e; S.checkRetry = runFix }
1108
+ S.fixing = false
1109
+ renderCheckup()
1110
+ load()
1111
+ }
1112
+ function filterHealth(ref) {
1113
+ ui.healthRef = ref
1114
+ renderCheckup()
1115
+ $('checkup').scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'start' })
1116
+ }
1117
+ function findingRow(f) {
1118
+ return h('li', { class: 'finding lvl-' + f.level },
1119
+ h('span', { class: 'dot', title: f.level }),
1120
+ h('div', { class: 'finding-body' },
1121
+ h('div', { class: 'finding-top' },
1122
+ ui.healthRef ? null : h('button', { class: 'chip chip-btn mono', type: 'button', title: t('health.only', { ref: f.account }), 'aria-label': t('health.only', { ref: f.account }), onclick: () => filterHealth(f.account) }, badge(f.account.split(':')[0], 'xs'), f.account),
1123
+ h('span', { class: 'muted small mono' }, f.item),
1124
+ f.fixable ? h('span', { class: 'chip chip-sm chip-accent' }, t('health.autoFix')) : null,
1125
+ f.source && f.source !== 'core' ? h('span', { class: 'muted small' }, t('health.from', { source: f.source })) : null),
1126
+ h('p', null, f.message),
1127
+ f.hint ? h('p', { class: 'hint' }, panelHint(f.hint, f.fixable && !BOOT.readOnly, t('fix.hintAction'))) : null))
1128
+ }
1129
+ function renderCheckup() {
1130
+ const box = $('checkup')
1131
+ const all = findingsNow()
1132
+ const ref = ui.healthRef
1133
+ const findings = all && ref ? all.filter((f) => f.account === ref) : all
1134
+ const at = lastCheckAt()
1135
+ const busy = S.checking || S.fixing
1136
+ const fixable = all ? all.filter((f) => f.fixable).length : 0
1137
+ // Say why Fix all is off, unless a running check or fix already explains it.
1138
+ const fixWhy = busy || fixable ? null : !all ? t('fix.whyNoCheck') : all.length ? t('fix.whyManual') : t('fix.whyNothing')
1139
+ const status = S.checking ? t('health.running') : S.fixing ? t('health.fixing') : at ? t('health.last', { ago: ago(at) }) : t('health.never')
1140
+ const out = [h('div', { class: 'section-head' },
1141
+ h('div', null,
1142
+ h('h2', { id: 'h-checkup' }, t('health.title')),
1143
+ h('p', { class: 'muted small' }, status, h('span', { class: 'sep', 'aria-hidden': 'true' }, '·'),
1144
+ tx('health.lead', { shared: h('span', { class: 'label-tip' }, t('health.sharedTerm'), tip('shared')), drift: h('span', { class: 'label-tip' }, t('health.driftTerm'), tip('drift')) }))),
1145
+ h('div', { class: 'actions' },
1146
+ h('button', { class: 'btn', type: 'button', disabled: busy || !S.state, 'aria-busy': S.checking ? 'true' : null, title: !S.state && !busy ? t('health.waiting') : null, onclick: runCheck }, S.checking ? spinner() : null, S.checking ? t('health.running') : t('health.run')),
1147
+ BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', disabled: busy || !fixable, 'aria-busy': S.fixing ? 'true' : null, title: fixWhy, onclick: runFix },
1148
+ S.fixing ? spinner() : null, S.fixing ? t('health.fixing') : t('fix.all') + (fixable ? ' (' + fixable + ')' : ''))))]
1149
+ if (ref) out.push(h('div', { class: 'filter-bar' },
1150
+ h('span', { class: 'chip chip-accent mono' }, badge(ref.split(':')[0], 'xs'), ref),
1151
+ h('span', { class: 'muted small' }, t('health.filtered')),
1152
+ ui.checkingRef === ref ? h('span', { class: 'muted small' }, spinner(), ' ', t('health.checking'))
1153
+ : h('button', { class: 'link-btn', type: 'button', disabled: busy || Boolean(ui.checkingRef), onclick: () => checkAccount(ref) }, t('health.checkThis')),
1154
+ h('button', { class: 'link-btn', type: 'button', onclick: () => { ui.healthRef = null; renderCheckup() } }, t('health.showAll'))))
1155
+ if (S.checkError) out.push(errorNote(S.checkError, S.checkRetry))
1156
+ if (!BOOT.readOnly && all && all.length && fixWhy && !ref) out.push(h('p', { class: 'disabled-why' }, t('fix.off', { why: fixWhy })))
1157
+ if (S.fixes) {
1158
+ const failed = S.fixes.filter((f) => !f.ok)
1159
+ out.push(h('div', { class: 'fix-result ' + (failed.length ? 'is-warn' : 'is-ok'), role: 'status' },
1160
+ S.fixes.length ? t('fix.result', { ok: S.fixes.length - failed.length, n: S.fixes.length }) : t('fix.nothing'),
1161
+ failed.length ? h('ul', null, failed.map((f) => h('li', null, h('b', null, f.account + ' · ' + f.item + ': '), f.message))) : null))
1162
+ }
1163
+ if (!findings) {
1164
+ if (busy || !S.state) out.push(h('span', { class: 'sk sk-line w70' }), h('span', { class: 'sk sk-line w50' }))
1165
+ else out.push(h('p', { class: 'muted' }, t('health.intro')))
1166
+ } else if (ref && !healthOf(ref) && !findings.length) {
1167
+ out.push(h('p', { class: 'muted' }, t('health.refNever', { ref })))
1168
+ } else if (!findings.length) {
1169
+ out.push(h('div', { class: 'all-good' }, h('span', { class: 'check-icon', 'aria-hidden': 'true' }, '✓'),
1170
+ h('div', null, h('b', null, ref ? t('health.refGood', { ref }) : t('health.allGood')), h('p', { class: 'muted small' }, t('health.allGoodSub')))))
1171
+ } else {
1172
+ // Long lists show the most serious first and fold the rest behind one button.
1173
+ const rank = { fail: 0, warn: 1, ok: 2 }
1174
+ const sorted = findings.slice().sort((x, y) => rank[x.level] - rank[y.level])
1175
+ const cut = ui.allFindings || sorted.length <= FINDINGS_SHOWN + 1 ? sorted.length : FINDINGS_SHOWN
1176
+ out.push(h('ul', { class: 'findings' }, sorted.slice(0, cut).map(findingRow)))
1177
+ if (cut < sorted.length) out.push(h('button', { class: 'btn btn-sm more-findings', type: 'button', onclick: () => { ui.allFindings = true; renderCheckup() } }, tn('health.more', sorted.length - cut)))
1178
+ }
1179
+ patch(box, out)
1180
+ }
1181
+ // An error from a server call, with a Retry button when trying again can help.
1182
+ function errorNote(err, retry, extra) {
1183
+ return h('div', { class: 'note note-fail error-note', role: 'alert' },
1184
+ h('span', null, err.message),
1185
+ err.retryable && retry ? h('button', { class: 'btn btn-sm', type: 'button', onclick: retry }, t('btn.retry')) : null,
1186
+ extra ? h('span', { class: 'note-sub' }, extra) : null)
1187
+ }
1188
+
1189
+ // ---------- dialogs ----------
1190
+ const dlg = { el: null, kind: null, busy: false, onClose: null }
1191
+ function ensureDialog() {
1192
+ if (dlg.el) return dlg.el
1193
+ dlg.el = h('dialog', { class: 'dialog', 'aria-labelledby': 'dlg-title' })
1194
+ dlg.el.addEventListener('cancel', (e) => { if (dlg.busy) e.preventDefault() })
1195
+ dlg.el.addEventListener('click', (e) => { if (e.target === dlg.el && !dlg.busy) dlg.el.close() })
1196
+ dlg.el.addEventListener('close', () => {
1197
+ const after = dlg.onClose
1198
+ dlg.kind = null
1199
+ dlg.onClose = null
1200
+ if (after) after()
1201
+ })
1202
+ document.body.append(dlg.el)
1203
+ return dlg.el
1204
+ }
1205
+ function dialogFrame(title, fam, body, foot) {
1206
+ return [
1207
+ h('div', { class: 'dlg-head' },
1208
+ h('h2', { id: 'dlg-title', class: 'title-logo' }, fam ? badge(fam, 'sm') : null, title),
1209
+ h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('btn.close'), disabled: dlg.busy, onclick: () => dlg.el.close() }, icon('x'))),
1210
+ h('div', { class: 'dlg-body' }, body),
1211
+ foot ? h('div', { class: 'dlg-foot' }, foot) : null,
1212
+ ]
1213
+ }
1214
+ function showDialog(kind) {
1215
+ const el = ensureDialog()
1216
+ dlg.kind = kind
1217
+ if (!el.open) { el.showModal(); raiseToasts() }
1218
+ }
1219
+
1220
+ // New account. The form is built once per opening so typing keeps focus; only its derived parts re-render.
1221
+ let nf = null
1222
+ function familyStatus(f) {
1223
+ const hasMain = S.state && S.state.accounts.some((a) => a.family === f.id && a.isMain)
1224
+ if (hasMain) return 'ready'
1225
+ return f.installed ? 'no-main' : 'missing'
1226
+ }
1227
+ function openCreate(prefill) {
1228
+ if (BOOT.readOnly || !S.state) return
1229
+ const fams = S.state.families.slice().sort((x, y) => {
1230
+ const rank = (f) => ({ ready: 0, 'no-main': 1, missing: 2 })[familyStatus(f)]
1231
+ return rank(x) - rank(y)
1232
+ })
1233
+ const ready = fams.filter((f) => familyStatus(f) === 'ready')
1234
+ const pick = prefill && ready.some((f) => f.id === prefill.family) ? prefill.family : prefs.family && ready.some((f) => f.id === prefs.family) ? prefs.family : ready.length ? ready[0].id : ''
1235
+ // name / alias: what the user typed, or null while the field shows the suggestion (an emptied alias stays empty).
1236
+ nf = { fams, family: pick, api: Boolean(prefill && prefill.api), name: null, alias: null, creating: false, result: null, error: null, els: {} }
1237
+ showDialog('create')
1238
+ renderCreate()
1239
+ if (nf.els.name) { nf.els.name.focus(); nf.els.name.select() }
1240
+ }
1241
+ function suggestionFor(fam) {
1242
+ return fam && S.state ? suggestName(S.state.accounts.filter((a) => a.family === fam).map((a) => a.name)) : ''
1243
+ }
1244
+ function nameProblem() {
1245
+ const name = nf.els.name.value.trim()
1246
+ if (!name) return ''
1247
+ if (name === 'main') return t('nf.mainTaken')
1248
+ if (!NAME_RE.test(name)) return t('nf.badName')
1249
+ if (S.state && S.state.accounts.some((a) => a.ref === nf.family + ':' + name)) return t('nf.exists', { ref: nf.family + ':' + name })
1250
+ return ''
1251
+ }
1252
+ // The short command offered for the typed name: the Family's alias pattern (cc001… gives cc008), if it is usable.
1253
+ function aliasSuggestion() {
1254
+ const name = nf.els.name.value.trim()
1255
+ if (!nf.family || !name || nameProblem()) return ''
1256
+ const sug = suggestAlias(S.state.accounts, nf.family, name)
1257
+ return sug && !aliasIssue(sug, BOOT.aliasPattern, BOOT.reservedAliases, S.state.configAliases, nf.family + ':' + name) ? sug : ''
1258
+ }
1259
+ function followAlias() {
1260
+ if (nf.alias !== null) return
1261
+ nf.els.alias.value = aliasSuggestion()
1262
+ }
1263
+ function aliasProblem() {
1264
+ const alias = nf.els.alias.value.trim()
1265
+ const ref = nf.family + ':' + nf.els.name.value.trim()
1266
+ const issue = aliasIssue(alias, BOOT.aliasPattern, BOOT.reservedAliases, S.state.configAliases, ref)
1267
+ if (issue === 'pattern') return t('nf.aliasBad')
1268
+ if (issue === 'reserved') return t('nf.aliasReserved', { alias })
1269
+ if (issue === 'taken') return t('nf.aliasTaken', { alias, ref: S.state.configAliases[alias] })
1270
+ return ''
1271
+ }
1272
+ function renderCreate() {
1273
+ const el = dlg.el
1274
+ if (nf.result) { setKids(el, createdView()); return }
1275
+ const e = nf.els
1276
+ const sug = suggestionFor(nf.family)
1277
+ e.name = h('input', { id: 'nf-name', name: 'name', type: 'text', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', placeholder: sug || 'work', maxlength: '32', 'aria-label': t('nf.name'), 'aria-describedby': 'nf-sug nf-err', 'aria-invalid': 'false' })
1278
+ e.name.value = nf.name === null ? sug : nf.name
1279
+ e.alias = h('input', { id: 'nf-alias', name: 'alias', type: 'text', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', placeholder: t('nf.aliasPh'), maxlength: '64', 'aria-describedby': 'nf-alias-hint nf-alias-err', 'aria-invalid': 'false' })
1280
+ e.aliasHint = h('p', { class: 'field-hint', id: 'nf-alias-hint' })
1281
+ e.aliasErr = h('p', { class: 'field-error', id: 'nf-alias-err', role: 'alert' })
1282
+ e.api = h('input', { id: 'nf-api', type: 'checkbox', 'aria-label': t('nf.api') })
1283
+ e.api.checked = nf.api
1284
+ e.suggest = h('p', { class: 'field-hint', id: 'nf-sug' })
1285
+ e.error = h('p', { class: 'field-error', id: 'nf-err', role: 'alert' })
1286
+ e.preview = h('dl', { class: 'preview' })
1287
+ e.submit = h('button', { class: 'btn btn-primary', type: 'submit', form: 'nf-form', 'aria-label': t('nf.create') }, t('nf.create'))
1288
+ e.name.addEventListener('input', () => { nf.name = e.name.value; followAlias(); validateCreate(); renderPreview() })
1289
+ e.alias.addEventListener('input', () => { nf.alias = e.alias.value; validateCreate(); renderPreview() })
1290
+ e.api.addEventListener('change', () => {
1291
+ nf.api = e.api.checked
1292
+ // A refusal was about the old choice; the hint under it no longer applies.
1293
+ if (e.errNote) { e.errNote.remove(); e.errNote = null; nf.error = null }
1294
+ })
1295
+ const cards = nf.fams.map((f) => {
1296
+ const st = familyStatus(f)
1297
+ const input = h('input', { type: 'radio', name: 'nf-family', value: f.id, class: 'sr-only', disabled: st !== 'ready', 'aria-label': f.title })
1298
+ input.checked = f.id === nf.family
1299
+ input.addEventListener('change', () => {
1300
+ // A name the user has not changed follows the Family: Claude's next free name differs from Cursor's.
1301
+ const typed = e.name.value.trim()
1302
+ const untouched = nf.name === null || !typed || typed === suggestionFor(nf.family)
1303
+ nf.family = f.id
1304
+ if (untouched) { nf.name = null; e.name.value = suggestionFor(f.id); e.name.placeholder = e.name.value || 'work' }
1305
+ followAlias()
1306
+ validateCreate()
1307
+ renderPreview()
1308
+ })
1309
+ const n = S.state.accounts.filter((a) => a.family === f.id).length
1310
+ const sub = st === 'ready' ? tn('nf.accounts', n) : st === 'no-main' ? tx('nf.runOnce', { bin: h('code', null, f.bin) }) : t('nf.notInstalled')
1311
+ return h('label', { class: 'fam-card' + (st === 'ready' ? '' : ' is-off') }, input, badge(f.id, 'sm'),
1312
+ h('span', { class: 'fam-text' }, h('span', { class: 'fam-title' }, f.title), h('span', { class: 'fam-sub' }, sub)))
1313
+ })
1314
+ const body = h('form', { id: 'nf-form', class: 'form', novalidate: true, onsubmit: (ev) => { ev.preventDefault(); create() } },
1315
+ h('p', { class: 'muted small' }, t('nf.intro')),
1316
+ h('fieldset', { class: 'field fam-field' }, h('legend', null, t('nf.tool')), nf.fams.length ? h('div', { class: 'fam-grid' }, cards) : h('p', { class: 'muted small' }, t('empty.noFamilies'))),
1317
+ h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-name' }, h('span', null, t('nf.name')), e.name), e.suggest),
1318
+ h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-alias' }, h('span', null, t('nf.alias'), ' ', h('span', { class: 'muted small' }, t('nf.optional'))), e.alias), e.aliasHint, e.aliasErr),
1319
+ h('div', { class: 'check-row' }, h('label', { class: 'check' }, e.api, h('span', null, h('b', null, t('nf.api')), h('span', { class: 'muted small' }, t('nf.apiHint')))), tip('api')),
1320
+ e.preview,
1321
+ e.error,
1322
+ e.errNote = nf.error ? errorNote(nf.error, create, nf.error.code === 'create-refused' && !nf.api ? t('nf.refusedApi') : null) : null)
1323
+ setKids(el, dialogFrame(t('nf.title'), null, body, [
1324
+ h('button', { class: 'btn', type: 'button', onclick: () => el.close() }, t('btn.cancel')),
1325
+ e.submit,
1326
+ ]))
1327
+ if (nf.alias === null) followAlias(); else e.alias.value = nf.alias
1328
+ validateCreate()
1329
+ if (!nf.family) e.error.textContent = t('nf.noReady')
1330
+ renderPreview()
1331
+ }
1332
+ // Create stays off until the name is usable; the message under the field says why.
1333
+ function validateCreate() {
1334
+ const e = nf.els
1335
+ const msg = nameProblem()
1336
+ const name = e.name.value.trim()
1337
+ e.name.classList.toggle('invalid', Boolean(msg))
1338
+ e.name.setAttribute('aria-invalid', String(Boolean(msg)))
1339
+ e.error.textContent = msg
1340
+ const sug = suggestionFor(nf.family)
1341
+ e.suggest.textContent = sug && name === sug ? t('nf.suggested') : ''
1342
+ const aliasMsg = aliasProblem()
1343
+ const alias = e.alias.value.trim()
1344
+ e.alias.classList.toggle('invalid', Boolean(aliasMsg))
1345
+ e.alias.setAttribute('aria-invalid', String(Boolean(aliasMsg)))
1346
+ e.aliasErr.textContent = aliasMsg
1347
+ e.aliasHint.textContent = alias && alias === aliasSuggestion() ? t('nf.aliasSuggested', { host: family(nf.family).title }) : t('nf.aliasHint')
1348
+ if (!nf.creating) e.submit.disabled = Boolean(msg) || Boolean(aliasMsg) || !name || !nf.family
1349
+ return msg || aliasMsg
1350
+ }
1351
+ function renderPreview() {
1352
+ const name = nf.els.name.value.trim()
1353
+ const ok = name && !nameProblem() && nf.family
1354
+ const shown = ok ? name : '<' + t('nf.namePh') + '>'
1355
+ const dir = nf.family ? newAccountDir(S.state.accounts, nf.family, shown) : null
1356
+ const alias = nf.els.alias.value.trim()
1357
+ const run = nf.family ? 'sideby run ' + nf.family + ':' + shown : '—'
1358
+ setKids(nf.els.preview, [
1359
+ dir ? [h('dt', null, t('nf.dir')), h('dd', { class: 'mono' }, dir)] : null,
1360
+ h('dt', null, t('nf.launch')),
1361
+ alias && !aliasProblem() && nf.family
1362
+ ? h('dd', null, h('span', { class: 'mono' }, alias), h('span', { class: 'muted small' }, ' ' + t('nf.or') + ' '), h('span', { class: 'mono' }, run))
1363
+ : h('dd', { class: 'mono' }, run),
1364
+ ])
1365
+ nf.els.preview.classList.toggle('is-draft', !ok)
1366
+ }
1367
+ async function create() {
1368
+ if (nf.creating) return
1369
+ const name = nf.els.name.value.trim()
1370
+ const alias = nf.els.alias.value.trim()
1371
+ const aliasMsg = aliasProblem()
1372
+ const msg = nameProblem() || (nf.family ? '' : t('nf.noReady')) || (name ? '' : t('nf.enterName'))
1373
+ validateCreate()
1374
+ if (msg) { nf.els.error.textContent = msg; nf.els.name.focus(); return }
1375
+ if (aliasMsg) { nf.els.alias.focus(); return }
1376
+ nf.creating = true
1377
+ nf.error = null
1378
+ dlg.busy = true
1379
+ nf.els.submit.disabled = true
1380
+ nf.els.submit.setAttribute('aria-busy', 'true')
1381
+ nf.els.submit.replaceChildren(spinner(), t('nf.creating'))
1382
+ labelFromText(nf.els.submit)
1383
+ try {
1384
+ nf.result = await api('/api/accounts', alias ? { family: nf.family, name, api: nf.els.api.checked, alias } : { family: nf.family, name, api: nf.els.api.checked })
1385
+ toast(t(nf.result.ok ? 'toast.created' : 'toast.createdProblems', { ref: nf.result.account.ref }), { kind: nf.result.ok ? 'ok' : 'info' })
1386
+ ui.highlight = nf.result.account.ref
1387
+ dlg.onClose = () => { if (ui.highlight) flashRow(ui.highlight) }
1388
+ load({ check: true })
1389
+ } catch (e) { nf.error = e }
1390
+ nf.creating = false
1391
+ dlg.busy = false
1392
+ renderCreate()
1393
+ if (!nf.result && nf.els.name) {
1394
+ nf.els.name.value = name
1395
+ nf.alias = alias
1396
+ nf.els.alias.value = alias
1397
+ validateCreate()
1398
+ renderPreview()
1399
+ // A refused subscription account is usually fixed by turning on "API key account".
1400
+ if (nf.error && nf.error.code === 'create-refused' && !nf.api) nf.els.api.focus()
1401
+ else if (nf.error && nf.error.code === 'alias-invalid') nf.els.alias.focus()
1402
+ else nf.els.name.focus()
1403
+ }
1404
+ }
1405
+ function stepRow(s) {
1406
+ const fill = /^fill in (.+)$/.exec(s)
1407
+ if (fill) return h('li', null, h('span', null, t('nf.fillIn')), codeCopy(fill[1]))
1408
+ if (s.startsWith('sideby ')) return h('li', null, codeCopy(s))
1409
+ return h('li', null, s)
1410
+ }
1411
+ function createdView() {
1412
+ const r = nf.result
1413
+ const failed = r.steps.filter((s) => !s.ok)
1414
+ // An incomplete account creation is not shown as a success.
1415
+ const body = h('div', { class: 'created', role: 'status' },
1416
+ r.ok
1417
+ ? h('div', { class: 'created-head' }, h('span', { class: 'check-icon', 'aria-hidden': 'true' }, '✓'), h('span', null, tx('nf.created', { ref: h('b', { class: 'mono' }, r.account.ref) })))
1418
+ : h('div', { class: 'created-head' }, h('span', { class: 'warn-icon', 'aria-hidden': 'true' }, '!'), h('span', null, tx('nf.createdProblems', { ref: h('b', { class: 'mono' }, r.account.ref) }))),
1419
+ failed.length || r.hookErrors.length ? h('div', { class: 'note note-warn' },
1420
+ failed.map((s) => h('span', { class: 'small' }, s.item + ': ' + (s.message || 'failed'))),
1421
+ r.hookErrors.map((e) => h('span', { class: 'small' }, 'plugin ' + e.plugin + ': ' + e.message))) : null,
1422
+ r.account.dir ? h('p', { class: 'small' }, h('span', { class: 'muted' }, t('nf.dir') + ' '), h('code', null, r.account.dir)) : null,
1423
+ r.alias && r.alias.added ? h('div', { class: 'small created-alias' }, h('span', { class: 'muted' }, t('nf.alias') + ' '), codeCopy(r.alias.name), h('span', { class: 'muted' }, ' ' + t('nf.aliasNewShell'))) : null,
1424
+ r.alias && !r.alias.added ? h('div', { class: 'note note-warn' }, h('b', null, t('nf.aliasNotAdded')), ' ', h('span', { class: 'small' }, r.alias.message || '')) : null,
1425
+ (r.shellInitFiles || []).map((f) => f.ok
1426
+ ? h('p', { class: 'muted small' }, t(f.action === 'unchanged' ? 'nf.shellFileSame' : 'nf.shellFileUpdated', { file: f.path }))
1427
+ : h('div', { class: 'note note-warn' }, h('span', { class: 'small' }, f.message))),
1428
+ h('p', { class: 'muted small' }, t('nf.next')),
1429
+ h('ol', { class: 'steps' }, r.nextSteps.map(stepRow)))
1430
+ return dialogFrame(t('nf.title'), r.account.family, body, [
1431
+ h('button', { class: 'btn', type: 'button', onclick: () => openCreate({ family: r.account.family, api: nf.api }) }, t('nf.another')),
1432
+ h('button', { class: 'btn btn-primary', type: 'button', onclick: () => dlg.el.close() }, t('btn.done')),
1433
+ ])
1434
+ }
1435
+
1436
+ // Quota setup: preview the diff, apply, then offer Undo (teardown) in the toast.
1437
+ let qs = null
1438
+ function diffView(diff) {
1439
+ return h('pre', { class: 'diff' }, diff.split('\n').map((l) => h('span', {
1440
+ class: l.startsWith('@@') ? 'hunk' : l.startsWith('+') && !l.startsWith('+++') ? 'add' : l.startsWith('-') && !l.startsWith('---') ? 'del' : null,
1441
+ }, l || ' ')))
1442
+ }
1443
+ async function openSetup(fam) {
1444
+ if (BOOT.readOnly) return
1445
+ qs = { family: fam, loading: true }
1446
+ showDialog('setup')
1447
+ renderSetup()
1448
+ try {
1449
+ const r = await api('/api/quota/setup', { family: fam })
1450
+ if (qs && qs.family === fam) qs = { family: fam, plan: r.plan, summary: r.summary }
1451
+ } catch (e) { if (qs && qs.family === fam) qs = { family: fam, error: e } }
1452
+ if (dlg.kind === 'setup') renderSetup()
1453
+ }
1454
+ function renderSetup() {
1455
+ const fam = family(qs.family)
1456
+ const body = []
1457
+ const foot = []
1458
+ if (qs.loading) body.push(h('p', { class: 'muted' }, spinner(), ' ', t('qs.preparing')))
1459
+ // With a plan on screen, its Apply button is the retry.
1460
+ if (qs.error) body.push(errorNote(qs.error, qs.plan ? null : () => openSetup(qs.family)))
1461
+ if (qs.plan) {
1462
+ const p = qs.plan
1463
+ if (p.status === 'enabled') body.push(h('p', null, p.message || t('qs.alreadyOn', { host: fam.title })))
1464
+ else if (p.status === 'blocked') body.push(h('p', { class: 'note note-warn' }, p.message))
1465
+ else {
1466
+ body.push(h('p', { class: 'muted' }, qs.summary || p.message))
1467
+ if (p.file) body.push(h('p', { class: 'small' }, tx('qs.changes', { file: h('code', null, p.file) })))
1468
+ if (p.diff) body.push(diffView(p.diff))
1469
+ body.push(h('p', { class: 'muted small' }, t('qs.undoHint')))
1470
+ foot.push(h('button', { class: 'btn', type: 'button', disabled: qs.applying, onclick: () => dlg.el.close() }, t('btn.cancel')),
1471
+ h('button', { class: 'btn btn-primary', type: 'button', disabled: qs.applying, 'aria-busy': qs.applying ? 'true' : null, onclick: applySetup }, qs.applying ? spinner() : null, qs.applying ? t('qs.applying') : t('qs.apply')))
1472
+ }
1473
+ }
1474
+ if (!foot.length) foot.push(h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close')))
1475
+ setKids(dlg.el, dialogFrame(t('qs.title', { host: fam.title }), qs.family, body, foot))
1476
+ }
1477
+ async function applySetup() {
1478
+ const st = qs
1479
+ st.applying = true
1480
+ st.error = null
1481
+ dlg.busy = true
1482
+ renderSetup()
1483
+ try {
1484
+ const r = await api('/api/quota/setup', { family: st.family, confirm: true })
1485
+ dlg.busy = false
1486
+ if (r.plan.status === 'enabled') {
1487
+ const fam = st.family
1488
+ dlg.el.close()
1489
+ toast(t('toast.quotaOn', { host: family(fam).title }), { action: { label: t('toast.undo'), run: () => undoSetup(fam) }, duration: 10000 })
1490
+ // The first numbers arrive with the next status line refresh; poll quickly for a while to show them soon.
1491
+ S.fastUntil = Date.now() + FAST_FOR_MS
1492
+ S.fastFamily = fam
1493
+ load()
1494
+ return
1495
+ }
1496
+ qs = { family: st.family, plan: r.plan, summary: r.summary }
1497
+ } catch (e) { st.applying = false; st.error = e; dlg.busy = false }
1498
+ renderSetup()
1499
+ }
1500
+ async function undoSetup(fam) {
1501
+ S.fastUntil = 0
1502
+ S.fastFamily = null
1503
+ try {
1504
+ const r = await api('/api/quota/teardown', { family: fam })
1505
+ if (r && r.ok === false) toast(r.message || t('toast.undoFailed', { msg: '' }), { kind: 'fail' })
1506
+ else toast(t('toast.quotaOff', { host: family(fam).title }))
1507
+ } catch (e) {
1508
+ toast(e.status === 404 ? t('toast.undoMissing') : t('toast.undoFailed', { msg: e.message }), { kind: 'fail' })
1509
+ }
1510
+ load()
1511
+ }
1512
+
1513
+ // ---------- banners ----------
1514
+ function renderBanners() {
1515
+ const out = []
1516
+ if (S.loadError && S.state) out.push(h('div', { class: 'banner banner-fail', role: 'alert' },
1517
+ h('div', null, h('b', null, t('load.failedShort') + ' '), S.loadError.message),
1518
+ h('button', { class: 'btn btn-sm', type: 'button', onclick: () => load({ check: !S.report }) }, t('btn.retry'))))
1519
+ if (S.state) {
1520
+ if (BOOT.readOnly) out.push(h('div', { class: 'banner banner-quiet' }, h('div', { class: 'muted small' }, t('banner.readOnly'))))
1521
+ const errs = S.state.pluginErrors
1522
+ if (errs.length) out.push(h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null,
1523
+ h('b', null, tn('banner.plugins', errs.length) + ' '), t('banner.pluginsHint'),
1524
+ h('ul', null, errs.map((e) => h('li', null, h('code', null, e.where), ': ', e.message))))))
1525
+ }
1526
+ patch($('banners'), out)
1527
+ }
1528
+
1529
+ // ---------- loading ----------
1530
+ function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderEmailToggle(); renderAccounts(); renderCheckup() }
1531
+ // A load that starts while another runs is queued, never dropped, so a change made meanwhile (a new
1532
+ // account, a fix) always ends up on screen; and only the newest response is ever rendered. A silent load is a
1533
+ // background refresh: no spinner, and its render waits while the viewer is busy (see uiBusy).
1534
+ let loadSeq = 0
1535
+ let queued = null
1536
+ async function load(opts) {
1537
+ opts = opts || {}
1538
+ if (S.loading) {
1539
+ queued = {
1540
+ check: Boolean((queued && queued.check) || opts.check),
1541
+ announce: Boolean((queued && queued.announce) || opts.announce),
1542
+ silent: Boolean((queued ? queued.silent : true) && opts.silent),
1543
+ }
1544
+ return
1545
+ }
1546
+ const seq = ++loadSeq
1547
+ S.loading = true
1548
+ S.silent = Boolean(opts.silent)
1549
+ S.lastTry = Date.now()
1550
+ renderTop()
1551
+ let state = null
1552
+ let error = null
1553
+ try { state = await api('/api/state') } catch (e) { error = e }
1554
+ if (seq === loadSeq) {
1555
+ if (state) {
1556
+ if (!S.state) ui.fade = true
1557
+ S.state = state; S.loadError = null; S.loadedAt = Date.now()
1558
+ // The server now runs another build (sideby was reinstalled and restarted): this page's script is old.
1559
+ if (state.build && BOOT.build && state.build !== BOOT.build && !S.newBuild) {
1560
+ S.newBuild = true
1561
+ if (uiBusy()) toast(t('toast.updated'))
1562
+ }
1563
+ // A read-only Panel never records checks, so keep the result of its own last check over the disk.
1564
+ if (!BOOT.readOnly || !S.history) S.history = state.history || null
1565
+ } else if (!error.network) S.loadError = error // a lost connection has its own notice
1566
+ }
1567
+ S.loading = false
1568
+ S.silent = false
1569
+ const next = queued
1570
+ queued = null
1571
+ if (next) return load(next)
1572
+ if (opts.silent && uiBusy()) { S.pending = true; renderTop() } else { S.pending = false; renderAll() }
1573
+ if (opts.announce) {
1574
+ if (state) toast(t('toast.refreshed'))
1575
+ else toast(t('toast.refreshFailed', { msg: error.message }), { kind: 'fail' })
1576
+ }
1577
+ if (S.state && opts.check) runCheck()
1578
+ reloadForNewBuild()
1579
+ }
1580
+ // Reloads into the new build once the viewer is not in the middle of something (a dialog, a form, a selection).
1581
+ function reloadForNewBuild() {
1582
+ if (S.newBuild && !uiBusy() && !S.fixing && !S.checking && !dlg.busy) location.reload()
1583
+ }
1584
+ // Once a second: the "Updated" age ticks, a deferred render lands once the viewer is done, and the state
1585
+ // reloads on schedule while the page is visible.
1586
+ function tick() {
1587
+ renderUpdated()
1588
+ reloadForNewBuild()
1589
+ if (document.visibilityState !== 'visible' || !S.state) return
1590
+ if (S.pending && !S.loading && !uiBusy()) { S.pending = false; renderAll() }
1591
+ const now = Date.now()
1592
+ const every = now < S.fastUntil ? FAST_MS : AUTO_MS
1593
+ if (!S.loading && !conn.lost && !S.fixing && !S.checking && !dlg.busy && now - S.lastTry >= every) load({ silent: true })
1594
+ }
1595
+ // Coming back to the page refreshes at once.
1596
+ function wake() {
1597
+ if (document.visibilityState !== 'visible' || !S.state || S.loading || conn.lost) return
1598
+ if (Date.now() - S.lastTry > 3000) load({ silent: true })
1599
+ }
1600
+
1601
+ // ---------- start ----------
1602
+ $('ver').textContent = BOOT.version ? 'v' + BOOT.version : ''
1603
+ $('refresh').addEventListener('click', () => load({ check: true, announce: true }))
1604
+ $('lang').addEventListener('click', () => {
1605
+ if (LANG_FIXED) return
1606
+ lang = lang === 'zh' ? 'en' : 'zh'
1607
+ prefs.lang = lang
1608
+ savePrefs()
1609
+ renderStatic()
1610
+ buildToolbar()
1611
+ renderAll()
1612
+ if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
1613
+ else if (dlg.kind === 'setup' && qs) renderSetup()
1614
+ })
1615
+ $('theme').addEventListener('click', () => {
1616
+ const order = CHOICES.theme
1617
+ prefs.theme = order[(order.indexOf(prefs.theme) + 1) % order.length]
1618
+ savePrefs()
1619
+ applyTheme()
1620
+ renderStatic()
1621
+ toast(t('theme.' + prefs.theme), { key: 'theme', kind: 'plain' })
1622
+ })
1623
+ applyTheme()
1624
+ renderStatic()
1625
+ buildToolbar()
1626
+ raiseToasts()
1627
+ load({ check: true })
1628
+ setInterval(tick, 1000)
1629
+ document.addEventListener('visibilitychange', wake)
1630
+ window.addEventListener('focus', wake)
1631
+ `;
1632
+ /** The page script: the tested helpers, then the page logic, in one strict-mode function. */
1633
+ export const PAGE_SCRIPT = `(() => {\n 'use strict'\n${LOGIC}\n${MAIN}})()\n`;