@falari/harness 0.0.0-stage → 0.8.9

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.
package/client.js ADDED
@@ -0,0 +1,3303 @@
1
+ /**
2
+ * Falari Data — Web client half.
3
+ *
4
+ * M2: Agent Key status, upload with quote/budget preview, and a task list driven
5
+ * by the Host upload queue. "My data" and "Download" are placeholders until M3.
6
+ */
7
+ window.__ModuleLoader__.load({
8
+ id: '@falari/harness',
9
+ factory(require) {
10
+ const React = require('react');
11
+ const h = React.createElement;
12
+ const { useCallback, useEffect, useId, useMemo, useRef, useState } = React;
13
+
14
+ const NS = 'falari-harness';
15
+ const ROW_ID = 'falari-harness';
16
+ const API = '/api/falari-harness';
17
+
18
+ const dictionaries = {
19
+ en: {
20
+ 'panel.title': 'Falari Data',
21
+ 'panel.subtitle': 'Agent-native storage for DeepSeek Harness.',
22
+ 'panel.badge': 'M3',
23
+ 'panel.footer': 'Uploads use the configured Agent Key and spend FAR from the owner account.',
24
+ 'tab.upload': 'Upload',
25
+ 'tab.data': 'My data',
26
+ 'tab.download': 'Download',
27
+ 'tab.settings': 'Settings',
28
+ 'common.refresh': 'Refresh',
29
+ 'common.loading': 'Loading…',
30
+ 'common.error': 'Error',
31
+ 'common.copied': 'Copied',
32
+ 'status.title': 'Agent Key status',
33
+ 'status.missing': 'Not configured',
34
+ 'status.healthy': 'Active',
35
+ 'status.unhealthy': 'Needs attention',
36
+ 'status.master': 'Main address',
37
+ 'status.keyState': 'Key state',
38
+ 'status.tiers': 'Permission tiers',
39
+ 'keyState.active': 'Active',
40
+ 'keyState.expired': 'Expired',
41
+ 'keyState.revoked': 'Revoked',
42
+ 'keyState.frozen': 'Frozen',
43
+ 'keyState.disabled': 'Disabled',
44
+ 'keyState.pending': 'Pending',
45
+ 'tier.write_data': 'Write data',
46
+ 'tier.manage_data': 'Manage data',
47
+ 'tier.transfer_funds': 'Transfer funds',
48
+ 'common.listSeparator': ', ',
49
+ 'status.allowance': 'Allowance (total / daily)',
50
+ 'status.balance': 'Owner balance',
51
+ 'status.expiry': 'Expires',
52
+ 'status.network': 'Network',
53
+ 'status.miners': 'Active miners',
54
+ 'status.noKey': 'No Agent Key configured',
55
+ 'status.noKeyHint': 'Paste a far_ export string on the plugin page: Plugins → Falari Data → Configure.',
56
+ 'status.never': 'Never',
57
+ 'status.unlimited': 'Unlimited',
58
+ 'status.frozen': 'Frozen (0)',
59
+ 'status.updatedAt': 'Updated',
60
+ 'status.refreshing': 'refreshing…',
61
+ 'status.stale': 'Showing the last known status; the chain read failed.',
62
+ 'status.loadFailed': 'Could not read the key status.',
63
+ 'upload.dropTitle': 'Drop files here',
64
+ 'upload.dropHint': 'or choose files from this device. Nothing is uploaded until you press Start.',
65
+ 'upload.choose': 'Choose files',
66
+ 'upload.clear': 'Clear selection',
67
+ 'upload.privacy': 'Privacy',
68
+ 'upload.public': 'Public',
69
+ 'upload.private': 'Private (encrypted on this machine)',
70
+ 'upload.duration': 'Storage duration',
71
+ 'upload.durationPermanent': 'Permanent',
72
+ 'upload.duration30d': '30 days',
73
+ 'upload.duration90d': '90 days',
74
+ 'upload.duration1y': '1 year',
75
+ 'upload.duration5y': '5 years',
76
+ 'upload.duration10y': '10 years',
77
+ 'upload.durationCustom': 'Custom…',
78
+ 'upload.unitDays': 'days',
79
+ 'upload.unitMonths': 'months',
80
+ 'upload.unitYears': 'years',
81
+ 'upload.customEquals': '≈ {days} days billed · expires {date}',
82
+ 'upload.customTooShort': 'A custom term must be at least 1 day.',
83
+ 'upload.customRange': '1 day – 100 years; anything past 36 years is billed as permanent',
84
+ 'upload.totalSize': 'Selected',
85
+ 'upload.quote': 'Estimated cost',
86
+ 'upload.quoteMulti': 'Each file is its own order; the estimate covers the largest file.',
87
+ 'upload.quoteLoading': 'Pricing…',
88
+ 'upload.quoteFailed': 'Could not price this upload.',
89
+ 'upload.deposit': 'Escrow deposit (refunded at finalize minus usage)',
90
+ 'upload.serviceFees': 'Service fees (never refunded)',
91
+ 'upload.total': 'Total',
92
+ 'upload.budgetOk': 'Allowance and balance cover this upload.',
93
+ 'upload.budgetBad': 'This upload does not fit the current budget.',
94
+ 'upload.budgetBlocked': 'Allowance or balance does not cover this upload.',
95
+ 'upload.start': 'Start upload',
96
+ 'upload.starting': 'Uploading…',
97
+ 'upload.uploadingFile': 'Sending',
98
+ 'upload.queuedHint': 'Files are queued on the Host; the task list below follows them.',
99
+ 'upload.noKey': 'Configure an Agent Key on the plugin page before uploading.',
100
+ 'upload.tooLarge': 'File is larger than the configured limit',
101
+ 'upload.addFailed': 'Some files could not be queued.',
102
+ 'task.title': 'Tasks',
103
+ 'task.empty': 'No upload tasks yet.',
104
+ 'task.dataKeyHint': 'Keep this key: private data cannot be decrypted without it.',
105
+ 'task.copyIntent': 'Copy intent id',
106
+ 'task.copyDataKey': 'Copy data key',
107
+ 'task.pause': 'Pause',
108
+ 'task.resume': 'Resume',
109
+ 'task.retry': 'Retry',
110
+ 'task.cancel': 'Cancel and refund',
111
+ 'refund.title': 'Cancel and refund',
112
+ 'refund.titleLocal': 'Cancel upload',
113
+ 'refund.confirmLocal': 'Confirm cancel',
114
+ 'refund.loading': 'Reading the escrow from the chain…',
115
+ 'refund.file': 'File',
116
+ 'refund.status': 'Status',
117
+ 'refund.uploaded': 'Already uploaded',
118
+ 'refund.escrow': 'Escrow held on chain',
119
+ 'refund.rate': 'Cancellation rate',
120
+ 'refund.compensation': 'Cancellation compensation (withheld)',
121
+ 'refund.serviceFee': 'Terminate service fee (from the main balance)',
122
+ 'refund.refundable': 'Refund to the main account',
123
+ 'refund.actual': 'Refunded',
124
+ 'refund.noReceipts': 'No shard has been committed yet, so nothing is withheld and the whole escrow comes back.',
125
+ 'refund.estimate': 'Estimate. The chain settles the exact amount when the deal closes.',
126
+ 'refund.doneNote': 'The chain closed the deal and released the escrow.',
127
+ 'refund.noIntent': 'No on-chain order exists yet, so cancelling refunds nothing.',
128
+ 'refund.finalized': 'This order is already settled. Cancelling falls back to the early-termination rule, which returns the miner share that has not been released yet; the chain decides the exact amount.',
129
+ 'refund.pending': 'The terminate transaction is in flight. Refresh in a moment to see the final refund.',
130
+ 'refund.recheck': 'Refresh',
131
+ 'refund.failed': 'Cancel failed',
132
+ 'refund.close': 'Close',
133
+ 'refund.back': 'Keep uploading',
134
+ 'refund.confirm': 'Confirm cancel and refund',
135
+ 'refund.submitting': 'Cancelling…',
136
+ 'task.remove': 'Remove',
137
+ 'task.attempt': 'Attempt',
138
+ 'task.status.creating': 'Creating intent',
139
+ 'task.status.queued': 'Queued',
140
+ 'task.status.uploading': 'Uploading',
141
+ 'task.status.paused': 'Paused',
142
+ 'task.status.needs_source': 'Source missing',
143
+ 'task.status.failed': 'Failed',
144
+ 'task.status.completed': 'Completed',
145
+ 'task.status.terminated': 'Cancelled',
146
+ 'common.started': 'Started',
147
+ 'task.status.running': 'Downloading',
148
+ 'task.kindUpload': 'Upload',
149
+ 'task.kindDownload': 'Download',
150
+ 'task.outPath': 'Saved to',
151
+ 'data.searchPlaceholder': 'Filter by file name or intent id',
152
+ 'data.empty': 'No intents yet. Upload a file first.',
153
+ 'data.loading': 'Loading intents…',
154
+ 'data.refreshing': 'Refreshing…',
155
+ 'data.cachedHint': 'Showing the last snapshot while the chain answers.',
156
+ 'data.unnamed': 'Untitled',
157
+ 'data.loadFailed': 'Could not load intents.',
158
+ 'data.privacy': 'Privacy',
159
+ 'data.privacyAll': 'All',
160
+ 'data.privacyPublic': 'Public',
161
+ 'data.privacyPrivate': 'Private',
162
+ 'data.file': 'File',
163
+ 'data.size': 'Size',
164
+ 'data.created': 'Created',
165
+ 'data.refresh': 'Refresh list',
166
+ 'data.download': 'Download',
167
+ 'data.copyId': 'Copy id',
168
+ 'download.intentLabel': 'Intent id',
169
+ 'download.intentPlaceholder': 'intent_…',
170
+ 'download.dataKeyLabel': 'Data key (optional)',
171
+ 'download.dataKeyPlaceholder': 'Base64 32-byte key',
172
+ 'download.dataKeyHint': 'Only needed when this machine cannot unwrap the intent.',
173
+ 'download.outLabel': 'File name (optional)',
174
+ 'download.outPlaceholder': 'Name inside the download directory',
175
+ 'download.start': 'Download',
176
+ 'download.dir': 'Saves to',
177
+ 'download.noIntent': 'Paste an intent id first.',
178
+ 'download.result': 'Latest download',
179
+ 'download.failed': 'Download failed.',
180
+ 'settings.summary': 'Agent Key and storage defaults for this plugin.',
181
+ 'settings.locked': 'Locked',
182
+ 'settings.unlocked': 'Unlocked',
183
+ 'settings.plaintextBadge': 'Key is not encrypted',
184
+ 'settings.pinIntro': 'The Agent Key is stored encrypted. Choose a PIN: it is the only thing that can open the stored key, and it is never saved anywhere.',
185
+ 'settings.pin': 'PIN',
186
+ 'settings.pinConfirm': 'Repeat PIN',
187
+ 'settings.pinPlaceholder': 'at least 6 characters',
188
+ 'settings.pinMismatch': 'The two PINs do not match.',
189
+ 'settings.saveEncrypted': 'Encrypt and save',
190
+ 'settings.plaintextWarning': 'The stored Agent Key is still plain text, so other plugins in this profile can read it. Set a PIN to encrypt it in place; the key itself is unchanged and nothing is uploaded.',
191
+ 'settings.secureExisting': 'Encrypt with this PIN',
192
+ 'settings.securing': 'Encrypting…',
193
+ 'settings.secured': 'The stored key is now encrypted with your PIN.',
194
+ 'settings.lockedHint': 'The key is encrypted on disk and locked. Enter the PIN in the plugin to unlock it; the decrypted key stays in memory only.',
195
+ 'settings.unlockedHint': 'Unlocked: uploads work until you lock it or the process exits.',
196
+ 'settings.idleSuffix': 'locks after {n} min idle',
197
+ 'settings.idleNever': 'no idle lock',
198
+ 'settings.uploadActiveHint': 'An upload is running, so the automatic lock is paused until it finishes.',
199
+ 'settings.confirmLockDuringUpload': 'An upload is still running. Locking now pauses it until you enter the PIN again. Lock anyway?',
200
+ 'settings.unlock': 'Unlock',
201
+ 'settings.unlocking': 'Unlocking…',
202
+ 'settings.unlockedNotice': 'Unlocked.',
203
+ 'settings.lock': 'Lock now',
204
+ 'settings.locking': 'Locking…',
205
+ 'settings.lockedNotice': 'Locked. The decrypted key was dropped from memory.',
206
+ 'upload.lockedTitle': 'Agent Key is locked',
207
+ 'upload.lockedHint': 'Enter the PIN to upload.',
208
+ 'upload.setPin': 'Set a PIN to encrypt the key',
209
+ 'upload.openSettings': 'Open settings',
210
+ 'settings.keySection': 'Agent Key',
211
+ 'settings.keyHint': 'Paste the far_ export string from the Falari wallet. It is validated against the chain and stored in the DSH credential store.',
212
+ 'settings.keyPlaceholder': 'far_…',
213
+ 'settings.saving': 'Validating…',
214
+ 'settings.clear': 'Clear key',
215
+ 'settings.clearing': 'Clearing…',
216
+ 'settings.saved': 'Agent Key saved. Status is shown below.',
217
+ 'settings.cleared': 'Agent Key cleared from this profile.',
218
+ 'settings.confirmClear': 'Clear the stored Falari Agent Key from this profile?',
219
+ 'settings.networkSection': 'Network and defaults',
220
+ 'settings.network': 'Network',
221
+ 'settings.networkTestnet': 'Testnet',
222
+ 'settings.networkMainnet': 'Mainnet (not live)',
223
+ 'settings.privacy': 'Default privacy',
224
+ 'settings.public': 'Public',
225
+ 'settings.private': 'Private',
226
+ 'settings.duration': 'Default duration',
227
+ 'settings.maxSize': 'Upload size limit',
228
+ 'settings.lockIdle': 'Auto-lock after (minutes, 0 = never)',
229
+ 'settings.lockIdleHint': 'Leave 0 to stay unlocked until DSH exits. Presets are shortcuts; type any value from 0 to 10080 minutes.',
230
+ 'settings.lockNever': 'Never (until DSH exits)',
231
+ 'settings.lockNeverShort': 'Never',
232
+ 'settings.minutesUnit': 'min',
233
+ 'settings.daysUnit': '{n} d',
234
+ 'settings.hoursUnit': '{n} h',
235
+ 'settings.minsUnit': '{n} min',
236
+ 'settings.downloadDir': 'Download directory',
237
+ 'settings.downloadDirPlaceholder': 'Absolute path; empty uses the plugin data directory',
238
+ 'settings.allowOutside': 'Upload outside the workspace',
239
+ 'settings.off': 'Refuse (recommended)',
240
+ 'settings.on': 'Allow',
241
+ 'settings.configSave': 'Save configuration',
242
+ 'settings.configSaved': 'Saved. The plugin reloads to apply the change.',
243
+ 'settings.footer': 'Saving restarts the plugin row; running tasks keep their state in the SDK queue.',
244
+ },
245
+ zh: {
246
+ 'panel.title': 'Falari 数据',
247
+ 'panel.subtitle': '面向 Agent 的 DeepSeek Harness 存储客户端。',
248
+ 'panel.badge': 'M3',
249
+ 'panel.footer': '上传使用已配置的 Agent Key,并从主账户支出 FAR。',
250
+ 'tab.upload': '上传',
251
+ 'tab.data': '我的数据',
252
+ 'tab.download': '下载',
253
+ 'tab.settings': '设置',
254
+ 'common.refresh': '刷新',
255
+ 'common.loading': '加载中…',
256
+ 'common.error': '错误',
257
+ 'common.copied': '已复制',
258
+ 'status.title': 'Agent Key 状态',
259
+ 'status.missing': '未配置',
260
+ 'status.healthy': '正常',
261
+ 'status.unhealthy': '需要处理',
262
+ 'status.master': '主地址',
263
+ 'status.keyState': 'Key 状态',
264
+ 'status.tiers': '权限档',
265
+ 'keyState.active': '正常',
266
+ 'keyState.expired': '已过期',
267
+ 'keyState.revoked': '已吊销',
268
+ 'keyState.frozen': '已冻结',
269
+ 'keyState.disabled': '已停用',
270
+ 'keyState.pending': '待生效',
271
+ 'tier.write_data': '写入数据',
272
+ 'tier.manage_data': '管理数据',
273
+ 'tier.transfer_funds': '转账资金',
274
+ 'common.listSeparator': '、',
275
+ 'status.allowance': '额度(总额 / 每日)',
276
+ 'status.balance': '主账户余额',
277
+ 'status.expiry': '有效期',
278
+ 'status.network': '网络',
279
+ 'status.miners': '活跃矿工',
280
+ 'status.noKey': '尚未配置 Agent Key',
281
+ 'status.noKeyHint': '请在插件页粘贴 far_ 开头的 Key:插件 → Falari 数据 → 配置。',
282
+ 'status.never': '永不过期',
283
+ 'status.unlimited': '不限',
284
+ 'status.frozen': '冻结(0)',
285
+ 'status.updatedAt': '更新于',
286
+ 'status.refreshing': '刷新中…',
287
+ 'status.stale': '当前显示的是上次读取的状态,链上读取失败。',
288
+ 'status.loadFailed': '读取 Key 状态失败。',
289
+ 'upload.dropTitle': '把文件拖到这里',
290
+ 'upload.dropHint': '或从本机选择文件。按下「开始上传」前不会上传任何内容。',
291
+ 'upload.choose': '选择文件',
292
+ 'upload.clear': '清空选择',
293
+ 'upload.privacy': '隐私',
294
+ 'upload.public': '公开',
295
+ 'upload.private': '私有(在本机加密)',
296
+ 'upload.duration': '存储时长',
297
+ 'upload.durationPermanent': '永久',
298
+ 'upload.duration30d': '30 天',
299
+ 'upload.duration90d': '90 天',
300
+ 'upload.duration1y': '1 年',
301
+ 'upload.duration5y': '5 年',
302
+ 'upload.duration10y': '10 年',
303
+ 'upload.durationCustom': '自定义…',
304
+ 'upload.unitDays': '天',
305
+ 'upload.unitMonths': '个月',
306
+ 'upload.unitYears': '年',
307
+ 'upload.customEquals': '约按 {days} 天计费 · 到期 {date}',
308
+ 'upload.customTooShort': '自定义时长至少 1 天。',
309
+ 'upload.customRange': '1 天 – 100 年;超过 36 年按永久计费',
310
+ 'upload.totalSize': '已选择',
311
+ 'upload.quote': '预估费用',
312
+ 'upload.quoteMulti': '每个文件单独下单;下方按最大的一个文件估算。',
313
+ 'upload.quoteLoading': '正在报价…',
314
+ 'upload.quoteFailed': '无法为该上传报价。',
315
+ 'upload.deposit': '押金(结算时按实际用量退还)',
316
+ 'upload.serviceFees': '服务费(不退还)',
317
+ 'upload.total': '合计',
318
+ 'upload.budgetOk': '额度与余额足以支付本次上传。',
319
+ 'upload.budgetBad': '本次上传超出当前预算。',
320
+ 'upload.budgetBlocked': '额度或余额不足,无法上传。',
321
+ 'upload.start': '开始上传',
322
+ 'upload.starting': '上传中…',
323
+ 'upload.uploadingFile': '正在上传',
324
+ 'upload.queuedHint': '文件已进入 Host 队列,下方任务列表会跟踪进度。',
325
+ 'upload.noKey': '请先在插件页配置 Agent Key 再上传。',
326
+ 'upload.tooLarge': '文件超过配置上限',
327
+ 'upload.addFailed': '部分文件未能加入队列。',
328
+ 'task.title': '任务',
329
+ 'task.empty': '还没有上传任务。',
330
+ 'task.dataKeyHint': '请保存这把钥匙:私有数据没有它将无法解密。',
331
+ 'task.copyIntent': '复制 intent id',
332
+ 'task.copyDataKey': '复制 Data Key',
333
+ 'task.pause': '暂停',
334
+ 'task.resume': '继续',
335
+ 'task.retry': '重试',
336
+ 'task.cancel': '取消并退款',
337
+ 'refund.title': '取消并退款',
338
+ 'refund.titleLocal': '取消上传',
339
+ 'refund.confirmLocal': '确认取消',
340
+ 'refund.loading': '正在从链上读取托管金额…',
341
+ 'refund.file': '文件',
342
+ 'refund.status': '状态',
343
+ 'refund.uploaded': '已上传',
344
+ 'refund.escrow': '链上托管金额',
345
+ 'refund.rate': '取消费率',
346
+ 'refund.compensation': '取消补偿(扣留)',
347
+ 'refund.serviceFee': '终止服务费(另从主账户余额扣)',
348
+ 'refund.refundable': '退回主账户',
349
+ 'refund.actual': '实际退款',
350
+ 'refund.noReceipts': '尚未提交任何分片,因此不扣取消补偿,托管金额全额退回。',
351
+ 'refund.estimate': '预估值,最终以链上结算为准。',
352
+ 'refund.doneNote': '链上已关闭该订单并释放托管金额。',
353
+ 'refund.noIntent': '尚未创建链上订单,取消不涉及退款。',
354
+ 'refund.finalized': '该订单已完成结算。取消按「提前终止」规则退还尚未释放的矿工份额,具体金额由链上结算。',
355
+ 'refund.pending': '终止交易已提交,链上确认中。稍后刷新即可看到最终退款。',
356
+ 'refund.recheck': '刷新',
357
+ 'refund.failed': '取消失败',
358
+ 'refund.close': '关闭',
359
+ 'refund.back': '继续上传',
360
+ 'refund.confirm': '确认取消并退款',
361
+ 'refund.submitting': '取消中…',
362
+ 'task.remove': '移除',
363
+ 'task.attempt': '尝试',
364
+ 'task.status.creating': '创建订单',
365
+ 'task.status.queued': '排队中',
366
+ 'task.status.uploading': '上传中',
367
+ 'task.status.paused': '已暂停',
368
+ 'task.status.needs_source': '源文件丢失',
369
+ 'task.status.failed': '失败',
370
+ 'task.status.completed': '已完成',
371
+ 'task.status.terminated': '已取消',
372
+ 'common.started': '已开始',
373
+ 'task.status.running': '下载中',
374
+ 'task.kindUpload': '上传',
375
+ 'task.kindDownload': '下载',
376
+ 'task.outPath': '保存到',
377
+ 'data.searchPlaceholder': '按文件名或 intent id 过滤',
378
+ 'data.empty': '还没有 intent。先上传一个文件。',
379
+ 'data.loading': '正在加载 intent…',
380
+ 'data.refreshing': '刷新中…',
381
+ 'data.cachedHint': '当前显示上次的快照,正在从链上刷新。',
382
+ 'data.unnamed': '未命名',
383
+ 'data.loadFailed': '加载 intent 失败。',
384
+ 'data.privacy': '隐私',
385
+ 'data.privacyAll': '全部',
386
+ 'data.privacyPublic': '公开',
387
+ 'data.privacyPrivate': '私有',
388
+ 'data.file': '文件',
389
+ 'data.size': '大小',
390
+ 'data.created': '创建时间',
391
+ 'data.refresh': '刷新列表',
392
+ 'data.download': '下载',
393
+ 'data.copyId': '复制 id',
394
+ 'download.intentLabel': 'Intent id',
395
+ 'download.intentPlaceholder': 'intent_…',
396
+ 'download.dataKeyLabel': 'Data Key(可选)',
397
+ 'download.dataKeyPlaceholder': 'Base64 编码的 32 字节 Key',
398
+ 'download.dataKeyHint': '仅当本机无法解开该 intent 时才需要填写。',
399
+ 'download.outLabel': '文件名(可选)',
400
+ 'download.outPlaceholder': '下载目录内的文件名',
401
+ 'download.start': '下载',
402
+ 'download.dir': '保存到',
403
+ 'download.noIntent': '请先粘贴 intent id。',
404
+ 'download.result': '最近一次下载',
405
+ 'download.failed': '下载失败。',
406
+ 'settings.summary': '本插件的 Agent Key 与存储默认值。',
407
+ 'settings.locked': '已锁定',
408
+ 'settings.unlocked': '已解锁',
409
+ 'settings.plaintextBadge': 'Key 未加密',
410
+ 'settings.pinIntro': 'Agent Key 以密文保存。请设置 PIN:它是打开这把钥匙的唯一凭据,不会被保存在任何地方。',
411
+ 'settings.pin': 'PIN 密码',
412
+ 'settings.pinConfirm': '再次输入 PIN',
413
+ 'settings.pinPlaceholder': '至少 6 位',
414
+ 'settings.pinMismatch': '两次输入的 PIN 不一致。',
415
+ 'settings.saveEncrypted': '加密并保存',
416
+ 'settings.plaintextWarning': '当前 Agent Key 仍是明文存储,同一 profile 里的其他插件可以读取。设置 PIN 即可原地加密:Key 本身不变,也不会触发上传。',
417
+ 'settings.secureExisting': '用此 PIN 加密',
418
+ 'settings.securing': '加密中…',
419
+ 'settings.secured': '存储的 Key 已用你的 PIN 加密。',
420
+ 'settings.lockedHint': 'Key 已加密并锁定。在插件里输入 PIN 解锁,解密后的 Key 只存在于内存中。',
421
+ 'settings.unlockedHint': '已解锁:上传可用,直到你手动锁定或退出 DSH。',
422
+ 'settings.idleSuffix': '空闲 {n} 分钟后自动锁定',
423
+ 'settings.idleNever': '不自动锁定',
424
+ 'settings.uploadActiveHint': '有上传任务正在进行,自动锁定会等它完成后再计时。',
425
+ 'settings.confirmLockDuringUpload': '还有上传任务在进行。现在锁定会暂停上传,直到你再次输入 PIN。仍要锁定吗?',
426
+ 'settings.unlock': '解锁',
427
+ 'settings.unlocking': '解锁中…',
428
+ 'settings.unlockedNotice': '已解锁。',
429
+ 'settings.lock': '立即锁定',
430
+ 'settings.locking': '锁定中…',
431
+ 'settings.lockedNotice': '已锁定,内存中的明文 Key 已清除。',
432
+ 'upload.lockedTitle': 'Agent Key 已锁定',
433
+ 'upload.lockedHint': '输入 PIN 后才能上传。',
434
+ 'upload.setPin': '设置 PIN 加密 Key',
435
+ 'upload.openSettings': '打开设置',
436
+ 'settings.keySection': 'Agent Key',
437
+ 'settings.keyHint': '粘贴 Falari 钱包导出的 far_ 串。校验会读取链上状态,通过后保存到 DSH 凭据存储。',
438
+ 'settings.keyPlaceholder': 'far_…',
439
+ 'settings.saving': '验证中…',
440
+ 'settings.clear': '清除 Key',
441
+ 'settings.clearing': '清除中…',
442
+ 'settings.saved': 'Agent Key 已保存,状态见下方。',
443
+ 'settings.cleared': '已从当前 profile 清除 Agent Key。',
444
+ 'settings.confirmClear': '确认从当前 profile 清除已保存的 Falari Agent Key?',
445
+ 'settings.networkSection': '网络与默认参数',
446
+ 'settings.network': '网络',
447
+ 'settings.networkTestnet': 'Testnet',
448
+ 'settings.networkMainnet': 'Mainnet(尚未上线)',
449
+ 'settings.privacy': '默认隐私',
450
+ 'settings.public': '公开',
451
+ 'settings.private': '私有',
452
+ 'settings.duration': '默认时长',
453
+ 'settings.maxSize': '上传大小上限',
454
+ 'settings.lockIdle': '无操作自动锁定(分钟,0 表示不自动锁)',
455
+ 'settings.lockIdleHint': '填 0 表示一直保持解锁直到 DSH 退出。上面的快捷项只是预设,可自行输入 0–10080 分钟之间的任意值。',
456
+ 'settings.lockNever': '不自动锁定(直到 DSH 退出)',
457
+ 'settings.lockNeverShort': '不锁定',
458
+ 'settings.minutesUnit': '分钟',
459
+ 'settings.daysUnit': '{n} 天',
460
+ 'settings.hoursUnit': '{n} 小时',
461
+ 'settings.minsUnit': '{n} 分钟',
462
+ 'settings.downloadDir': '下载目录',
463
+ 'settings.downloadDirPlaceholder': '绝对路径;留空则使用插件数据目录',
464
+ 'settings.allowOutside': '允许上传工作区外的文件',
465
+ 'settings.off': '拒绝(推荐)',
466
+ 'settings.on': '允许',
467
+ 'settings.configSave': '保存配置',
468
+ 'settings.configSaved': '已保存,插件正在重新加载以应用配置。',
469
+ 'settings.footer': '保存会重启该插件行;进行中的任务状态保存在 SDK 队列里。',
470
+ },
471
+ };
472
+
473
+ /*
474
+ * 视觉口径取自客户端的 Android 钱包(clients/android …/ui/theme/Theme.kt):
475
+ * 灰蓝/夜空底 + 三团彩色光斑、半透明玻璃面板 + 1px 高光描边、不外溢实心大色块。
476
+ * 这里只取它的**语义**,颜色仍落到 Harness 的主题变量上,所以深浅两档跟着宿主走,
477
+ * 不会在浅色宿主里透出深色玻璃。
478
+ *
479
+ * 令牌名与 Android 側一一对应(Bg/Ink/Hairline/GlassPanel…),改一处时两边对着看。
480
+ */
481
+ const colors = {
482
+ // 墨
483
+ text: 'var(--dsw-alias-label-primary)',
484
+ muted: 'var(--dsw-alias-label-secondary)',
485
+ // 线
486
+ border: 'var(--dsw-alias-border-l2)',
487
+ hairline: 'color-mix(in srgb, var(--dsw-alias-border-l2) 70%, transparent)',
488
+ divider: 'color-mix(in srgb, var(--dsw-alias-border-l2) 55%, transparent)',
489
+ // 面(玻璃)
490
+ surface: 'color-mix(in srgb, var(--dsw-alias-bg-layer-1) 78%, transparent)',
491
+ surfaceSoft: 'color-mix(in srgb, var(--dsw-alias-bg-layer-1) 55%, transparent)',
492
+ surface2: 'color-mix(in srgb, var(--dsw-alias-bg-layer-2) 88%, transparent)',
493
+ base: 'var(--dsw-alias-bg-base)',
494
+ // 品牌
495
+ brand: 'var(--dsw-alias-brand-primary)',
496
+ brandSoft: 'color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent)',
497
+ // 状态
498
+ success: 'var(--dsw-alias-state-success-primary)',
499
+ successSoft: 'color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent)',
500
+ error: 'var(--dsw-alias-state-error-primary)',
501
+ errorSoft: 'color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent)',
502
+ warn: 'var(--dsw-alias-state-warn-primary)',
503
+ warnSoft: 'color-mix(in srgb, var(--dsw-alias-state-warn-primary) 14%, transparent)',
504
+ // 光斑:三团压低透明度的品牌/紫/青,落在面板后面
505
+ orb1: 'color-mix(in srgb, var(--dsw-alias-brand-primary) 26%, transparent)',
506
+ orb2: 'color-mix(in srgb, #8b5cf6 20%, transparent)',
507
+ orb3: 'color-mix(in srgb, #2dd4bf 18%, transparent)',
508
+ // 高光描边:玻璃面板的 1px 内圈
509
+ glassEdge: 'color-mix(in srgb, #ffffff 62%, transparent)',
510
+ };
511
+
512
+ // 圆角 / 间距 / 字号:与 Android 的 FalariShape / FalariSpace / FalariType 同刻序,
513
+ // 卡片 20 > 弹窗 16 > 内块 14 > 控件 10,避免“哪里都一样圆”。
514
+ const radius = { card: 20, dialog: 16, block: 14, field: 10, tile: 12 };
515
+ const space = { page: 22, card: 18, section: 14, row: 12, inline: 8, tight: 4 };
516
+ const type = {
517
+ display: 24,
518
+ title: 19,
519
+ headline: 16,
520
+ body: 14,
521
+ callout: 13,
522
+ caption: 12,
523
+ micro: 11,
524
+ kicker: 10.5,
525
+ };
526
+
527
+ const DURATIONS = [
528
+ ['permanent', 'upload.durationPermanent'],
529
+ ['30d', 'upload.duration30d'],
530
+ ['90d', 'upload.duration90d'],
531
+ ['1y', 'upload.duration1y'],
532
+ ['5y', 'upload.duration5y'],
533
+ ['10y', 'upload.duration10y'],
534
+ ];
535
+
536
+ // ── backdrop ─────────────────────────────────────────────────────────
537
+
538
+ /**
539
+ * The three soft colour fields the Android wallet puts behind every page.
540
+ * They are what makes a translucent panel read as glass instead of as a flat
541
+ * grey box, so they live behind the content rather than on it.
542
+ */
543
+ function Backdrop() {
544
+ const orb = (color, size, left, top) =>
545
+ h('div', {
546
+ style: {
547
+ position: 'absolute',
548
+ width: size,
549
+ height: size,
550
+ left,
551
+ top,
552
+ borderRadius: '50%',
553
+ background: 'radial-gradient(circle at center, ' + color + ' 0%, transparent 68%)',
554
+ filter: 'blur(46px)',
555
+ pointerEvents: 'none',
556
+ },
557
+ });
558
+ return h(
559
+ 'div',
560
+ {
561
+ 'aria-hidden': true,
562
+ style: { position: 'fixed', inset: 0, overflow: 'hidden', pointerEvents: 'none', zIndex: 0 },
563
+ },
564
+ orb(colors.orb1, 420, '-8%', '-12%'),
565
+ orb(colors.orb2, 380, '62%', '4%'),
566
+ orb(colors.orb3, 400, '18%', '58%'),
567
+ );
568
+ }
569
+
570
+ // ── primitives ───────────────────────────────────────────────────────
571
+
572
+ // The panel's top-left mark is the original rendered logo, dark backdrop and
573
+ // all: the brief asked for the artwork as drawn, not a cut-out. It is
574
+ // inlined as a data URI so the client bundle stays a single file.
575
+ const FALARI_LOGO = 'data:image/png;base64,' + 'iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAABRNUlEQVR42oW9d9xdVZU+/qxzbi9vEpooMqAgoqNSLPSOiHRUIAQITZqIAoPfsQ2iM+M4o6JDCQ4dQglNQGoIJRAIRQERUBDpIIaE5C23vO9779nr98fZZ++19jnhl/kwMW+599xz9l57rWc9z7No7XXXZ9g/BIABgDn9F9lvMEBEYLD9HkBRZL9N7pdJ/g4AEIGQfV1+w74sM4gIVPA9iNcmgvsZ5uyl3NXan8x+lmDYpNcc5V+XiMDMufdIX4vEyxJA7N9Tf1QAZK/fXqD7RcpuIpDeMXdtEPeXKLjX9rMwCMwmvS/iPuXui708zn6POXf/mdn9LtnXzu5n9vVSdg8ZHDx39jei6GYxwAT/EDi9ItJXABDZD2pAFPmbJG6Jf19SCwMw9oaS+6DuOrKn4t4jvElsf4Tsj6c/Rxy8kr336f0w8vGCQP7h28/jb6dJX5PJPv/snrC9rOxesFsG2fVydr/kA87uOxv3N4PEwrebBeQWDotnkT5UA2Jy1+gXDvmfddsl/VrJXSBnd0Iuc7ly/e53qzjcrEx61ZPcDQDD2Ofv9wVn/86+Zb+nVn22sLINxiy2E9lrEQ/afiZiApO/7myHgRlRHIENYzgcIiJCqVQCG0bCBulyitRSzV4iu5Vuo7uFpt5GbKrsoo24p2JRwN8StlEj/XoEMMMwg6LgXtnPoJ6A/VVD6ef2N1Tcf9axhpkRMTPYiDcmRu5V7Q/7cKJDWroT3DKxi5LFuvNhipG+n9xL4Ox9DAyL9+Lg/eG/7q+Hg5vtHwS7607/HdkdNUwSjK4aw9TUNNqtJuI4xqrRUfQn+4goQhTFesHL64Bf02p7ZD/D8O/JrM4N95nYf54sMPtPQvZt2T08f9/Z/rqxP2uC55RFBhPceajj0oU8BkrZRbIKFCxCCfvzKDs/KQuC+nxnsdsj8Zp+wZAPbQwYGBs4yJ1RZD+wPiMjdx1ulev44xa7MWJf2BsYRTEAoNfrYzAcYP31P4w9DjkIe+21JzbY4J/Q6XTw0EMP48abbsaf//IiDDNazaYL5eEtdA/CnR8+2JG9uQz/fRKBl+UPu13Mwedgkf+QX1AEEWHZbTT3WyxfzR5nhsVpY2wE9BuE1lpnPdYPj/L5Gvl9L344fSAEECJ1vpH9HkVRkFzqh8XZzwVZqP4+bO4QJKpZIqTyIvI7j4A4isAM9Pp9DIdDfPpTn8QhB38N++7zZWywwQZBvgGMT0xg0aL7Mf+qa/HI0kdhjEGz2UQURTDG2I0hjyEKc1F/H9zrUrBMkVu4UDlQ/md8OiaOQrDP2xCEedK5pU88KZcH0Zprf4hlzpZdSHFiTurNsg+YZeUsdntRds/iBJQrXCZkciFm75hm85RfBNn1uiQwfffIvma310NEEbbYYjMcfNBX8dWv7I9Zs2ZhOBxiamoaREAURzbaMSrlMqI4RpIkuP2Ou3D5FVfhkUcfw3A4RLvVBhHDGH/TWWwEHbXyn5oodxPEfeDgk/lsHUFlRvJYYJ8XMBc9L/YbKvdA7bXPWuuD7FYeRS60+Bsb3HqZ7diH7EKlzVLdj6pSBkEpyC4TJbD+nnhTdg+UdPnHMo1P/4qiGMYYdLtdRFGEz3/uszjxhK9jt112QntkBJNTkxgOBojjEogofV23UMnmJwZxHKNUKmE4TPDA4sU4/4ILsXTpY0iMQavRtEehscco6xKMRRJLvlohWYJSVl2kSyAC2fyZxU4It5usWIzL2SF2c5q6RirJlGVg9jxV5TVrrQ+y/AF5HJAMs8XngvrL3cisPrb5gVpGqqzJRxUd5Emt3vA6sn+nITpBp9NFrVbDLjvviEMO/hp223VnjIyMoN/rY5gMEcUxIotfUIZlUD4SAUCSJCiVSojjGIlJcPfCRbj00ivx4INLACI0m00AjCQxhaWtWsQuqQs3BHJYik98I3FnOIefuOpDLCoYVbOqhNJjBCz2D4PWsAvAnd2gNId0K5eCY4F0EV30QbKV7SII+SROxbWwziIVBUksmtyyICCOYzAzOp0OarUadtx+exx//LHYcYftUS6X0O31YJIEcRzbIynNJaIsd8kiWPi5xB9jGHEcIY7T6PK72+7EueddgKefeQYRRWg262BmJMaoZJdEmNeAT8E+EgtE5VfFQV0kdmyTadbFm4gwCI7c/BGw5gc5j3AZEb7Ila/kdnSAxNlVGIlQ6qJ6wSJRSYrMjkEB4kbwJ4evRCKL9nW7PVTKZey66844+qi52HGH7VGtVtHpdACkSWCGBsqH7T6H+G+16Y69V3IhdLs93PK723DRRZfiqT8+g3KphHq97j63g1rsphQAhsIC3L0AdGImKyJxMfpfMvFjgVOIvSRQT5+vaLyCZq25ro8blKFfvlQhkWZ7SDa/ohjFyaM7VpDf2YygnFNZPYm3SR96FEVIEoNOt4Nms4nddt0Zh82ZjZ123AGlUhm9XhcMQimO3bW6HIWAyOUl2QIAoihysc8ji8glndmnTJIEcZweDZ1OBzfc+FtcdvmV+OMf/4RKtYp6rQbmNFnMNgAXZWhiIXq0kXO3lgvKBSKNdvrX18mzzu6DYtMmvjRrzXVZlQm5ejQSD141B3IFDolwjeDByivIykdfNvqdLvaGu0lRnD74bqeDRrOBnXbcASeecBy+8PnPo1QuodftAmwzeorcjSdKw72/rjD0k+hphItTJ6rpR06vzLBBMhyiFJdQKpcxOjqKa6+7ARdedClefvkVVCoV1Gq1tHQUv1e0MTTWT66WCrH+/O/5JC/7L1voIUZiTIpCRiI6u+03Y9Y6DiCU4VCWbOlNJVWn+owybKeI8z6AiOUWJ4V/kdvlDAt/UoRSXIIxCSY6HdTrNXx5zy9hzqGzsc3WX0h3fL/nHnxE2UK1uIHY7aR2vr+JkU0C/U21D4DIgT0aFvcRMt3lBkkyRKlURqVSwT/+sQzX33ATLrt8Pv760ktoNpuoVKswSeIXF8tQThohzHZ3UV/JbkYXpVxXLKtIghCfLXyB4MpDJgvkNDJrbZYrxiVqQUMwu6mF+YuEhynF7ZArIZHrMIoD04M9nD4YZka320Wj2cDOO+6II488Ajtsvx0YjF6vn3ayglAPojR3tnkCEYDIY5IyB6DIP/jIJaf5hhYH6JW//6wWgjEJKuUKypUK3vn7O7j4sstx7bU34I0330wXQrnsYO4ixC+sgvRJRP4ecwr0+ioKukvKelul3zP6dck/axsBdIghey6xhIHl2UMedlT7OQSRyHeewsNNtzL8jmQAvW4XZZvcff3YY/C5z34W5UoZ3U7XgjexOt/TUC5AIIp0DkB+EZOobKLIRogoKsxZSF1rED5t4sUWIQQIJjFIkgTVWhWVSgUvv/wKLr7kMlx73Y14770VaDRaKJdLtnQ0blHKBprvQfiIE2b3WQ+AbAeWEQJorPIn3TPRUTtdAHpZ6J1NEtkvytaCF8xavi6JlFhAvo/P8IlYr9tFFMfYZacdMffIw7HD9jsgLsXodXtux2etB4LO4t3uzj18Xca6BRoAQaoSICoummRfIAOC7N+yPWtMmizWazWUKxX86dnncPU1C3D9DTdh+bsr0B5pI46iFFWktIRjItdhNcy5DRMR5cK4OqjttehyWdxpB1HryEIzZq3NDiygSGH5LMpAXz4EGHywGDziRIgIKtBJyDgjbBDIQraELbfcAsd//RjsuttuKJXL6PfSMz6OI/GgRK/B/iMSiV/krjdSYV7W+xm87KJBBg4RacATYaLrQRRmAxiBBuo2JNiCScYY1Op1VMoV/PGZP+Hcc8/HXfcswsREBzPa7TRJY5PL9OXRwypx1n0S1QGVSKLiacChtRx0+mlk5trsodkIvu1OPtsnOLxcLz6dGKrSUYRon2RGooIg9Po9JInBFz63JeYeORe77LwTWq02ut0uAEKpFOd6DuocJ990iuAzf3I5QCS+FiSCoscgI8fqACH5MTPI2NffLLqxrAg2zEBiEpjEoNFoAACeeOL3uPDiS3HnnXdjmCRoNhuukylr/ZDYQQUEJhntTXYsC3CJReQz2ZIWZaNYACikTykKkoAfw+NCk0NIwbd6FxL6k5MYDofYfLPNMGfOodj7y3ui2Wqh0+2CE4NSuYQoy+ThEzyVvIgwH6mwnz7MYrRPLBz38EWZKP736v/Y89+wDdUI2rAQ/X6PJmZoIZjRarXAzFh073248KJL8NCSR8DGoNlsAHbBZNfj1hcbV6qz58m49rJoDYhn4vkZFJHrYJJYIDQycy2m4IQPkarV7QhZM2erzK04GRiiCHEUYXJyCtODaWy88UY4+qgjse/ee6PVHkGv17PlVEk8UHLVgQvvwS4PGzpR8D29y4uOAPLcQZVXFMOCElWTBBnFGWAO6GMeqWPL8EmSBARCq9XA5NQUFi5chHPPm4dnnnkWFBGajUa6uER4Z8EzIKKAnsaiKQdFFQubjaQ2LoPaM9Zimcsz5aNM2LSRUSBMnsJqIiLC9GCAfr+HjTfaCIcdNgf77L0P1llnbXS7PYusRa4MpIgQ26RQ7vhI9BGiiNTrk+oIRrl2dBoREEQU226JBNSdlaIiCZS5j3sIHDCAEDZZdBPIdw19zZ9iCAZRFGFkpI3R0VHceedduOyK+Xji90+iWq2gXq/DJMYnmmFjSBFBSRWX6lgSmVj4LKk9Y03W8CGpfg3lqbw2LY3ETZL4vz2TowiDwQC9bhcbbrgBDjzwAMw++BCs+6EPodvtYDhMENufU3stIsTu/PY1e5Y3RKLko0gvDIf8OUzAw4seFPKlV4gHpP9+nzxAtH8RMoU4SN7UzyOIGDbxs5HBmBRebrVaGBsbww033oQrrpyPZ597HvV6HdVKBUliYCzgQ6KfwoIppSo45vcjWvvj2UWAgIziMP+QkSuQPZ/U+eMgLpVgEoNut4NZa6yBObMPwexDZ+PD662PXr+HwfR02pYlCnZa2LQJQ7xH7sKsPkoBANfN9LiAf8AykqnjgPLNphwAQwLBk6xiAepoFrDIF4z/frZJXLInyscUXk5b0O12G8tXLMe1C67HRRddgrfeehv1Rh3lcnpvOWRHy75KhitkeQkXQM+SptYeWZOL08siJo8/f+TOJyLEUQyAMTHRQbvdxv7774vDDjscn/zkJ9Hv9TA1NY0oSs931+EL+vy6vCPbvNElXvbAfdYufjeKEJFE/ewikh1BkUcApIAhiRmoTqhsToVES9aNLg5KQdm7V1HALgJF8bbJ3nBoUK2W0Wq28Mqrr+LKq67G1VcvwLJly9ButxGXYpjEqJ4FOGAFOYqcIO/Ia8++3BpZg9XKCdoh4vTwMKkgOmRduk63i2qlgl132RknnHA8tthiS0xOTaPf69kzO9IdfSpoHgU1fpjtq90PcRQIsElVAJGHhl1DSyw0BGSTKKSe0erLQblNDAtGNevwrxs2UPkDqwfvF0rWwMnApHqjjhdefBHz5v0fbrvjToyOjmGk3QYRkBhWFLH3oyCGOEy6ANpr8P8v62c1CBkRMD0YYnKyj2223hqnfPOb2G677QAidCY6hV1E2ekDa/BFlXgI0T3SUYPSXIEpo3tHtuvmmT4u24cO/RIP8BGFclkyinQPIQ+UCzg7rEkYJqCys8ol8q1dR/ZkRmIYzAaNeh2VagV/fPoZXPCb3+C22+9EYgyajaYjrMq2ssJyxJbWfRoGNduzWDJvVFs8x9MT/eaIkAyHWHvttXHiCcdh9uzZKJerGJ+YsNBlJM5Mzxt0uQVRTtalOpLi3CYbZTQJVYfuLDnMYFP1GhEFJR4pFq5MGol0x7AYH4l0LhBWAyL0yhDv9Q5hL19HAv87fiEYY2AYaLeaKJVi3Hvf/bjgNxfhwYceQr1eRxzF/vUpFIPlicsu8rkFQHn8mELpD6et0ggEwwnqtTpuuH4Btt12O/ztlVdhzBDlctn/LAeNFLeGdB+MKCCICEKpfHjuLBe6RIc6iqpAHhHpgvD8xEgsQMcwkuAVeQhboapU0Ekp6OcjkJ75Gl4ngqxatezqd1lhSNFN9ntJkoCiKEUPCbjm6gX40Y9/oihpXAATUrCHs3seV6r1s3I0rzy0FxAxGRRFGA4TvPS3v8EYg4022gjNZhPT09NpgkJUQGKioL3JHtMO4gEVMHKIUNBZlOgd5fV8CDhQXEC5EWxm4tUdgKTYzD4P4lx3G8GBF8osWaGFvBoENtBLyGMzyo4W4K233sbixQ9ienrgEmzB2YEk7OeI3QxQc2QNDklEmkmqyweH0oFhGOj3e4ijGDvttCMOm3MYttl2G9SqVXR73TRJzChXBVQrTy6JIJ+aTArTvCx9z0hEBLmDZTJIguYdZRQG1wb2bx+J3MRhEaoSySIMF9DDSLFvPbQe6PXAohKA7hrK3R+IZmRGnx0JSZKAiNBsNjA5OYUlSx7GZZddjgcefBC1ai0lvkYpOcQkrHMVDhaBpOY322twmPVySN9ymHaC6cEAw8EQlWoVjXotrWFNStCsVavYaqutcNSRR2HrbbdCpVxBv9eHYUYcUY7rl3WhJSk0S6ZlaM9BvY6gUkD4FDQvCgit6vwXCZ9cDCFGUKyUIsGwLlbfSbKzgpADmBiqwxhAywwkJtVLNuppyH9k6aOYN+8CPProY5icmkyVS0QYDhN0u13EcYxarRbU/FRAKLW3omUXgNRt+KaB5q2xMTjzzB+gVCrj/Hnz8OrLL6PRnuHZLsag1+uhWq1ixx13wOzZs7H9dtuBogj9Xj89o6PIaQ+lwDfXTBLIlqzjHSgEjfBlTzSSFYAs+QJWM4EteES61RqRUjflLQ+kxl7uKxJCTp/jhH0CBSJJhZCCjWFZRgb1eh3lUhmPPfY4Lr38Cty9cCH6/T5azSaiKNUsjI+Po1Fv4PDDD8WKFe/httvuQK1W8xAyFemO4JPAULioq4A0Sx4Oh5g5cyaeevJxrPuBD+C1117DOefNw/z5V2PF8vfQHmm7cM+coNfro1wuY489vogj587FZptvDjDQn+w7Fg5pdpwWoQTv78rJsBVMsl0coIBSoSRRwxzLye/y9DP4lrVSI4Xtb0HrDpNCV/EE/gsEqDo8V04yY5gkqFZrqFWreP4vf8Ell1yKG2+8CZ1OF81WE+W4BANGt9MFg7HzTjvgjNNPxc4774QlDz+C/Q44yB9rOiHKLUpqtmYyFJ2LgmQw/ZDZAnj4ofvxofU+jOFgiFargWf+9Cf86tfn4toF16Vfa7fcrjTGoNftYWTGCHbffXccc/RR2HTTT2B6MMDU1KTo+lEuAjiMX3DfQszAI4MC0xc9AwRlndY3aMxBchOpgB5H5BciB0mW2ywEK7UnfbYTh4SCvB6YU5VRpVxGq9XEa6+9jt9ceBFuuPFGLPvHMjSbLVthpXnX5OQUvvD5z+H0076FvfbaExRFmJ4a4Inf/x6HHHq49T2IRCJaTAujRmsmS1DGrXjW4EySJGi32ljy0P3Y+GMbY3TVKJLEoN1uoVqtYNG99+GXZ/8a9933gE1Wmm4XJEl6Pq299lrYd9/9cPBBX8MmH/84JvuTmJqeTsmdEeUsYSS2XsRN1BHAJ5wkSznKC1Vdj8FR0sI+QIgwCocOoXhGAdMRCo/XqlyV/Igz2lgOxEi7hb+/swzXLrgOl1x6GV7+2ytotVuolCsAGJNTU+h1e/j4ph/Hicd/HXOPmINWq4XxiQlMTU6h1WrhscefwMGHHIbEJCnLOktMNVHAJ/qN1gxWrFMqKHuILOrUwJKHHsCmH98EY2PjdtUmYAZmzhzBYDjELbfcip///Gz88Zk/oVqtol6rw3BauybDBL1eD2uvszZmH3IIDvraQfjghz6EyclUvh2LJhEC5ZA/HiJV25No86bnf161rHsJXnAaESkKHMhz7yCoZQ7ilRGCdBmJ0A2IdKmriRiwBJEEpbiEkZEWVq4aw02/vRnz5l2Av7zwIqqVKur1OogIg8E0Ot0uPvTBD+Loo+bixBOPw9prrYVer4+pqSm7yYaoNxp4/PEncPDsw5EME0ECybHy/QKot2ZylGFGATxLQgdvjEGjUcdDi+/DJz/5CYyPT3hKNDOGgyGiOMKaa6yJlatW4qqrrsZ551+Al1/6G5rtNiqVslXLEKYH05jsT+KjH/0IvvqVr+CrX/0a1l5nbfR6PRhjUvXuavkIEt6FQx19Agh1fmclposCkaetReJIkTrGIlhYfk+xnlmIYEUjyu9877xDTnhqEEcRRkZa6PUmcf0NN+DiSy/DU394GnEpRr3RQESEwXCIzkQHa6+9Fo6aewSOPfYofOQjG6Zt9l7PfjY4oUqj2cSjjz2GQ2YfgcQkrqnFBMX7lVs8rlRqZykAhhAKAlwyViqVcNSRh2OdddbBYDB0hEZCKs4AAxMTE4gts3e//fZFuVLBiy/+FSuWr0ClUkFkOQDVWg2jq0bx4JIlePTRpahWKvjoRz+KdquN6anJ9AM4YwjtQYCCNqdXNmtTK+a8YpZy0isNVGmCJ+uuW5YDBA0B7RHkdZIQC9KkQkHMnDECEOGuuxfi9H/5Ds6f9xssX/4emq0mKuUKjDEYGx9HtVrFwQd9DeedczYOPfRgzJo5gk63i2EydPdG2u+Uy2W8+cabuOHGm7y8LyC26OY+2STQuU9xDiHL+v7GGFSrFTz4wCJ8+tOfRrebAj3D4TCVHllkCswwSYJhkqDRaKDdauHFl/6Kc845D9csuA6dThcjrZYXlEYR+v0+mA0233wLHHPM0dh5p51QKpfRs2zhKIpVb8BJ0COt/NFlo8wnIqVxzBhFys3Ediils1xEkuJGqqMmc4pIfE26mmQRJpNmzRhJWcAPLXkY//OLs7F48UMut4qidIFMdCYQxyXsucfu+M4Zp2Grrb6Qml10O/b9YzAnMMb3CJiBYZKg1Wzi4aWPYPahR6SLjQLbGGZ7nPlqqsS5UoE1CVRIuIwxbucjYOWyEZx0IsSlEvr9SXS7Paz/4Q/jvHP/F3PmzMb58/4Pt976O0wPBhhpt8BsUK1VQSA8/fTTOPXUZ7DD9jvgiLlHYLvttgUzPIZgQx5neK2RZ4QHPIwh1xlMO2AGzP56s45cKN5kZhhBDUuyCsS10dJyKe1sBy5fBaYYWRew3W4higi///2TOG/eb3Drrbeh3+/br6eZeqfbgxkm2HXXnfHNk0/El/f8EgBgamoKSZIgImtcBa1DSK24Emsc5VvMsjxV7mPiOGIwSkTKdUfDhdZPJvXCS/OAwXCowkoUETiB19M5TYFxD21sfAJjYxPYYvMtcOVll+Cee+/Fr359Dh5++BEAQMOyYRuNBpgZDyxejEcefRR77bkn5h5xBD75qX+GSRJMTU0JoIcCHr6ntiYwKZpod3tKl84OfuPt7FTCGGqBtN0NS/21YWds5YgX0gDSEjtazQbK5TJe/OtLOO/8C3DtNddhvDOBVquFGTNGnMwtSRJsueXmOOXkk3DQQV9FHMeYHgyQDIeiFBUEUZYcf2OPPXL5hVdtMcKnK8XmYKAkXb84B3VynhPPJjhPLVGEPe+cIgAmshy2zKyJsWrVKKKIsOvOO2PbbbbBwnsW4uxfnYMnn3wKtVoVtXodJmE0m2mP+7c3/xb3P/AA9t57bxxz9FHYcMMNMTk5heFwgCiKfRhm6b4nhJ2URgOnPmJGZNjByJFl5kDg/szeOZIjBOpg6LYvZRaHhIjS9zaJQbNRR61WxVtvvY1zzrsAC667Hn9/++8YGWlj5oyZAKVRrd/v458/+Qmc+u1v4pBDvoZGo4lkmGByasrK3CIvGgkomSzZxtKPEFrizqFMDJxuavunpHrZGRjkQHprcChFD5xnnJAwiGLS5kmpsRJcY4gBLH9vJUpRhP322Q+77rIrFlx3PS66+BL8+fk/o1avo1qtggC0W21MTU1h/lXz8cDiBzD74EPwla98Beuuu25KJbdllLZi0eJu5yRq0g6lYSCK0ptgIh9JMrElUeR7FDmyR0aBg4eybfY3NAbVahUzRlp4551lmH/1Nbjkksvw8suvoNlsYtYas9KQPj2NTqeDjT/6URx99Fwc9/Wjseaaa4LBmJ6eRmQp9MYk3hMRlJOC56wcg8ZhZgcnG+05N1YQ4nKlfhZB+91pSziozOiIww/D+ut/GIPBUIQ7bymjTSM9qTQlwbIXgTJjotMBUYTttt8OX/vqgZg5cyZeePFFLF+2DHGpjDguASDUazV0Jjp4+OGH8eBDD6JcLuNjH/sYmq0mpqam0p0dRQEr1pdoivMnF6eUq0vXUVd2aum4ppunn9cYg3KphFlrzERnootLL78CJ59yKm686bfo9ydTDl+cYiDjE+OYOWMGTjrxOJx/3v9ir732RKPRwNT0tO1DRAXsoCJtggSTvMS8Wq3g9dffwI033qx7kiQIswFsHZcq1bO8Q0bA9c+OB+HTN/eIOVh//fUxGAyUNk2iS2GY0j48VgxpP3BiEoyPTaBSqeCLu++GvffaE9VqFc8++xzGRsdQqZQdI6hWq2PlqpW4//4H8NRTT6HdamHjj30MlXIF04OB6h+wdJzIUdOClijrBE4CSfLzSW2i4ZTTP2vmLBhmXHPNtTj9jP+HK6+8Ct1eF+12Ct0mJsHE+DjiUowjjzwc553zK8w5dDZGRkbsg+fUykaykZ0SSBMRtbmD5m9kC+C119/Ab397c2DeuXqxU4lCEiS/j4s3s/W8yZGL7e+zqs8JnCaJhvOJVuSFDaVyCZNT03jjrb9j5sw18B///hN89StfwXnnz8PvbrsT3e4E2u02AEa1UgVVCH/4wx/w9NNPY7fddsURR8zF5z//OSTDJA2jceRhZFYHlFuomQ9S9sCjyLubRayoH8rwIi15CTNGRmDY4I677sL551+Ahx5agnK5jBkzRmzZnGC8M4FqpYoDDtgfp377ZGy7zTYAgOnp6ZTTGEe5SiLnKirudPY1VwUEfMK0JDTCIIoASpNeDlnO9tVKzh5YKWPtqop8b545cAl1TdX0TWQHMVsIbgFFNrQaUTuLKi7DA8iaO45PdLDRxh/DBfPOx1FHPoGf//JsLF78IACkujowmo0GEja46+67sWTJw9h3331w3HHHYcMNNnTQchTHLi+RWDhJKNRm2MYQyFYISeKvJ0MATWJgALTbLdRqNTzx+OP45a9+jfvuux/DxGDGjBH32TrdDpgZu+6yE844/TTsttsuAIDBYJCG3TjWBtwkyFwsTy9yZA6NMLOWiwmFss8RSBBZWbuTiIVQ0rAW5QwhfTJh2SYW9MkYEemOSX1zPBAS6brcXihJpM05Y5EiRWRl3tjYOMYAbL7FFrh6/pVYtGgR5v3mN3h06WNpj6HRQARCu9XGMElw9dXX4KElSzDn0ENx0NcOwlprrYVut4uEByihlLqr2ALYsJdOsyjtsuQpIh8FmRlDk6Beb6DVauKZZ57BJZdejptvuRkT4x2MjLTRsHZ1vX6KzW+91Rfw7VNOxle+sj/iuITp6Wkw2OY02pJPcdAYOeSOA5ZWcPQLYwjpHOo3Mouoy9nGFCVlSQdzDixHdCNZedM6xSlUmHCLjsi7grOsSAXaQFrHSuxxiNhCyyvfWwWKCF/+8p744hd3x7ULFuD/LrwYL7zwIur1GqrVGuKIMGPmDKxYvgL/8z+/wO2334Hjvv517Lbbbqg36uj3+4g4AkVxkDGzmBCQhSRjreIZZjhEpVJBqzUDr732Gi6/4kpcfc3VWLlyFVrNJmbOTHd9f3IS/X4fn9j04zjxhONw1NwjXII6sM6kkaK9kVbyZA9X1uKp/UghyZ+s/5ziIUrjaAEDZ2WsrOilDX9JMxNIGXo6/1v25zfDSG6xWJUU2ESJRMW5YPhdGOrXiCjNVpkVhFEqxTBssOzd5SiVyjj88MOx15e/jBtuvAn/d+GFePPNt9BsNlGOYpQrZVSrFbz00kv413/9V3xh661w4vEnYNttt8FgOLRAUuysYVjw+KT5VmISlMtltEaaWLVyFS666CLMv+pqvP7GG2g2Gpg1cwYAwtTUNLrdLjbYcAN848QTcMRhs/GBddfF1NQUut0uStaZFBwaq3I4KCQvPwcV/4Bo2GX2NCwqHxehhSWtJqxrEkppdc7UpDaoQKEMB7JZDgygjTpr5drK6wy1y5EjVQhgInvd2DqGvfPOMtSqNZx00knYd5+9cfmVV+Lqaxbg3WXL0G6PgGNCvV4DM7B06VL88ak/Yr/998WRc4/EJptsgr51Dk8zb082zUq6KIowY8YMTExM4Mor5uOyyy/H839+HvV6AzNnzAAADIcJxscnsO66H8DJ3zgRJ55wHDbY4J8wNTmFifG0GRbb/oWCkQNT0NyTVTMSUCA2QU5/4I6vUKJOaaj3+GjeBABEiMvl2lkcMAVJG+C7UJUkCeYcOhsf+chHMBwOnRetPC8lYwaS5y6GkqzGjBMhXS5zwZa08DiKMRgmGBsbQ6vVxpf22AN77LEbOt0u/vrSS+h2eqhWKyACqtUaDDOeeuop3HffvWBmbPLxTdBqNjE1PfBSbft3s9VCXCrhgfsfwL9+97u4cv58jI6Oot1qo1wqwRiD8fEJlCsVHDr7YFxw/jmYc+ghaDTq6HS6aXIbF1nTaFl2QHfIuYMB2qaOC7QGygLCtoQrlQpefe1V3HzzLemRo5vqoiFEEglUgp+AS+9/kYRmLU/rTvsE6YVw7rzK5NBGtXglV177rKeDnwJqlUDi0m5eCb1+H51uFx9Y54P49dlnY86hh+LCCy/C3QvvAZBSqOMowkh7BKOjY/ivn/0Md9x5J4459hjsvvvuKMWl1NWrUkGlUsEfnvwDLr7oYtx3/30YDAZotVqu0TXR6SCOCAceuB++dcrJ2HabrTEcJli5clW6462BVbENsLffQbEZaV4mVmCnxRzOmGItQZd6A5kHMHy2I3x6iWwvgHJiDW2dmoXIJDGpzUnuQ2Zki7TshHtDOOlzKY7RGGmj1+tjenrKW9GxFi7K48AYo7xts5skS844jjE+0cH4xAQ2+8xmuOCCeVi0aBEuvexyPPbY44iiCHVr0lQpl/Hcc8/hjH85A7vutiv23WcfrPehD2PlypV4YPEDuPV3t2JiYgKtZgvVai3t0nU6ADN22nF7fPPkk7DPXnuBAYyNjbuSTkvpIZWz4hg0CovjovBHQlHFEuuHUg5lXzcsZebwqmOlU5ATRYz2QHRVABfYtcuxJq45lCpW9Somn61SxpJNV7yxP99ut7F8+Qrce2/KJVhvvQ+j2+t6xTAH767csISdCciVNWSBhBRDIDATVq5aBQJh992+iJ133hl33XU35s2bhz//5QVUq1XUajU0mk2wMVi0cBEeXPwgWu0Wut0u+r0eGs0WZthzPl2o09hy881wxhmnYZ+990KlUsXo2BjAhFI5Xp2ENjfwQGuus/OZcsISOZsolJi5GT0hGSVwbmPWc4jymIdRSu1SaNgsTUZC1wsG7NnvHchYRgSlc0svYtasWbjrrrvw3//9Mzz55FP4zGc+jcsuuQzrfuhD6PX66cMTu9twMAzCNnFguDgjFt3AKI4BZqxY8R6iOMK+++yLHXbYHrfceisuv/wKvPLyq2i1myiVSmi2m4BJ/YejKEa7PQNEjGmb2W/6iY/j5BNPwCGHHIxZs2Zh1egout0+ojhGHJNG7iiQDbrELzSA1OBOGN5lou0nggQ4QKAhCO3gTU6UyjkKq2RJRWL6jLaMz0mF0ovJ0CwovxwTZK4p07VSqeLKK67AUUceiWeeeQZrrbUWnn32WZx2+qmpU0hGWuT8dLGcxo6oqGKy/5HnBlrOIBuDd99dDqIYXz/mWNxw/XU48YTjUalUMDk5lS42wIEzIEa320er1cL3vvsd3H3HbTj+hOMBIix7dzmMYetQqktYoxJ3rRNk489kw3pqmMnum1QLGZNXDUH/npxKJnOMjBnkj4Bw2iXlNZEsCxQpYBDYs5JsyjJQ8QXF90zqlFmv1/HXF/+C73//ewABzWYT/X4PIyMz8MDixbjoogsxa+ZMxyQKjSc9WBd6X+YzrczXR5owpJ2uGIPBNN7++zuoVGr4/ve/j8svuxTr/9P6mJycdJVLZJVLW2yxGe6683c466wfod5oYdmydzG0ti065GowKTSAKBwPJeVhcofD/zuzmXevZ3TrN58QSmcQMVYv4E2G1nXyy1GgGlex1QgHahSML9EmUXqxDZMh1lhzTXzgAx/AcJg4jdv09DSazSbOPvuXWLLkQbTbIxgOEzVvkDk/ptUhZdKMirQCKADG3E2OoghTU1N45x//wJZbfg5n/uAHadZuk81hMsSMmTPwq7N/gU984pN4551lmJ6eRqlUVuNttMxLWrIVKIOUBQwCnN5S7LKfy0wnoX2Icr6DzGk1ZUO7CRJBsv2GxGi6mu58skoOo/wnIAS9E6eBW503HgJvH1CEqckpfGi99XDmmWeiFMfOFctwOpRpcmoKPzrrRxgfH0OpXPYkEhbCzcDBPBKUbVYsF89bjALz50xHQABKcQnLlr2Lj278May//vrWtyj1L/zEpptik002wbJl7zq/wow0EQo8c6eQXAkh5upCvfHHgfUTVrsy9AW0yXe2EY2IGjLZQ7AQkyRJo6poXfslR8rE06Yqkjpo1Orw1rDSElV4B7GYXCUHFzAjLsUYGx3DfvvthwP23w9jq0YdjSubx/f7J57AL8/+BVqtZnrh3mdWu3wW41gF84rS6kCJH4QyJ/s8pThCtVpJI1xGpqhU3HEgzR4VrsGhLYwWg/Jq6nbOFlFOB6gjCTvzqCyHyHZ7QAdjRqELP1FujAzLhJ4ZYVoYSdsSYk01cAMHDMRKDTJ+cN45U/xcr9fHv5zxHWy80UaYnJpM+YFIZ/a2R0Zw8cUX4d5FizBjZIYrMbN6WZ75GdGRxI4OTqzctG2yYVZNE0UqVefgTE8sBUt9HsHAYZmMshgNK42flPY/8AcIZgxkD8fIyGD81pPHA4we+sCSHiYGRmV+weFYWtVzAdSKi6TDBoeWUiTekmRZIcAJuXpFZpudIZOTk/jIRhvhP//zpyjHsSvz2E4FYWac9eMf4d3ly1GpVFMunJ3MbcS83mwthKPfqXggp6CDwZ232c+ZoMeeUd7BxaPumfPDIjlXhkEtAmUkHZhHy6YddKwtTDSZoHokzNpbydPDgyS94Nawkn6yHE7HOQmxDvGZ8aEpTnokDUyEzDiOMT42hr323huHzj4U46OjjhBhjEGtVsefn38OP/3pv6NWrbr3lDdT98XspVORssfeAGnhTpK8kp7piTVbzG5fhLRszGBuYu3s4RRBDN3iFtHTBLtbdfYCizgW/QeG6v/oaErQ33fvY3LmFBTgOW6MrgnuUDDoOyoSSbG0OSNNODAcTqzm8IAKZFtp8tbr9fHd730Pm222WWorE0cuaWmPzMD111+H22//HWbOnOXRxvD1gqlYTswBbaokJdEKz0BogeJjpzFZLc2Ftm7yd0LLIZW5M4KHHjxBRfRCgWNYAQVcLBrOeQ8jyNskruP7AXo0vZjT5M46ggZk3Jhy9n605EOMMexABw6IihS+KaUKlzXXXAs//OEPrdsVi8kb6XHw45+chVdefgmNet2e+RwAIpSzZCEEDg1ERQR6Dyw6qFQfAonl06nMXvEzMvxdunyyasio5FCdxzLA6snO8v6FyXRWTspICBbHHkt7GagyEly8qELeQSRDgy5pWGXXLtTJI0AQDjiwu5DJCSid5Tc2PoY99tgTJ5xwIjoT4y4KGMupf+P11/HTn/4H4lIs7GAoZwyRJnZwPQgPWLKaJCI/qDrlxPja7IvDZOjEm1RU6sEEpR9UckgcAFFBFFHlnQr1Jvi6/M+I/IFd7mJYLLBQz8mB3xppqNglmJyVzUFW6EIIU34imEWt8lis8OezgsoMatXzAiN0ej2cetrp2GKLze2Q59iSLIZotUfwu9tuxTVXX4V2u43hICmadgppIaAs3I1sg4Zhn7WfXyCWMsNh2q7OT0dS2X5oA5NtOCO0eRKuDXd0YX8forvHProwyH7N1/2Gw0Vt9BFhJBNLCkFM7mhOjwCFE8uyQbQdIdErUyBTChmpAqOGVtsOpqfRHhnBmWeehWq5giQZirPVoFyu4uc//x+8+MJf0GjUYUwSOHTrSUgcZN2ZuRJniJl8MNAmWPIpp4OeTI6kyQFLJzyks0FNMh/IgB4lOrVYfbZ704kjcIkas0m/ZkyKDJp8AmkS417HVwOk5xAwr2bQlddk+CJPRgB4UIRIaSqEjZrXuKvtWGBtGnoLZ78VxzG6nQ522313HHvs19Hrdh1yZ4xBpVrBsmXL8LP//ql3FycqRt8UmZWV6zaEPLIIhAlNJ8LSjYOSNw94QU0BUQmrPSaz2t6oxSl2uX3wGbxrHB4gQKE06Qg+Q3avjTaettJ8P/ibBSEFBZMKSeQAwk41DeHkBHIEP3nbsLYpz83LIQ5mChazhFauXIXTz/gOttzys5js9Zz0OxkO0Wq3cdddd+LKKy5Du91OP5SaicNKEcOiRAopZ8pk00LRhPwMZJkPGFUC+8/AqrQzApSBqyAkZi+NnzMnFWMHSGW73RhdbnucX5eMocM4Q4+vgZPGc9Ak0vdAN3gZkQ/f+dm0LMNgRgw1RnaDc4lODjVVBsmEYZIAILTbI3jkkaUYGxtHZHn1WYJskgSlcgUXXXQhXn/9dVSqVddDJwrq6tUAHporkTKOhfu0HrXGfifKks/9H+cjhEmnxvnFYDxmn1UK7vx2D8UnfG7ByAWVHRm2qyqfTS6nMILqaeQRYxTljEjZgyjUEoqohhDrFwIC/7QxHCbq1FDomAmOaPYOHIaBJBlixsgIBoMBfvzjs3DsMUfh9TdeQ7lcFkkKO3HIG2++jtHRUZRLZZ8RMwpka2KMikgK1YygcHFKlFMBX6ZQi48w9AsY3LDMro14+DoHUee6zLFEJHBGkWGEy2kuxeiZwIiSSPg8SDMJw7kcpqRZoqSSiAyqFZPp0J+cdFmlQeQTEWN80qGQOALbwVCN9kwsfXQpfvLjH+OxRx9Go9FENa6mH8K6b8VRjG6vhzginHbaGdho443R63WFSULYPQveT/r1cuCcL+nqTGr5ZKSK7IAxogUckegdCLeRvD0sKcdV5cbFSN1HssSYoVDHkE3FDJ3QBdm/xhAC5FYSdkImMcvGGqOkxrYEEJcRxM7Yfi+xlDA3w5ahBhsG8AmGwwSNRhP9fhe//OUvcM45v8bExATaIyPpZE27giNrRdfpTOCfP/Vp/PAHP8Cuu+2BsbGxEGjMlXMhMVJP+8pPzOWcWpZ1E4VDgpzmSGYiH+miUjTQNWOzyc5VOOBTPWSTdx6XFD0jQoBhzoE6IZSsoGyWU2H81ZaKb5GfZes56mE/PEWS7SxrDSaJZG/NNdfAo0sfw5k/+jc88sjDaNTraDabafmX2bjEMTqdDsrlMk466Rs4/bTTUWu28N57K2wLGQ4J1JrZMOMjBE0ChCxqP4FdwLwkzmw1CJKcqRJZuzVkSimpXxJmks6YyXHpI63ooByFMmcdK48vDS7lbWdl6eeTz5wHLXTXzLMESuG8WQ5zaNLh0p85lOO/c2CHGscxLr7oIvzXf/0U7777bprRmwRJMnSOIcYk6EyM458/9Wn86Ec/ws477YJur4dup4tSqSwSJLLnqRfDhM7aAVHLZ8f+bkHJ7hFw640/d02BcMWr7YySbikrdmuowWybTAjGxTMp6jNj9Z8jK/VCcqiRvX0DnUga9qJQ4y2AWQne2Wk9StLlWurj3EcSkCdUIsF6x3kFAowxaLfbWHj3XTj11G+jVq+j1W5hMBg69VAcR+h0O6hWajjppG/g1G+fhhkzZ2HlqlE7d1BwWTg/cbzA5sHdbpaJUfYoMyJlBGUCIXWQvpUdalVY0N+jAP6gYIa23s25+YIZbCyKOVrdzEHWbGFpP+v6ESKHc9HMPRcjIhF5Eo/AcEpAQYRwHIG80XBiEjWAyOTEa/51oihCpVpFqVROcwdmxHEMk6S7/lOf/gx+8uOfYPvtt0en08Po2BjiOPKgh9FlHltxg6ZPscqUQ1m1lE9xRhmz7CBVS8sjIBRbOINR4QAqVFYsBjIwAdHqiKEulWbl46aOHSH7Zi6a12s3WdaaJ9Iwt22skVsI2uXEGWKxI4VyUQTNUW6yU88Y3TamEJ21lifdTgfbbb8Dtv78F9DtdlI+famETrcLEOHU007HjTfciG232x6rRsfsCNk4h/uzRPeCubxhkyVj1ISjWXRHsYgtQapMU719ScIImDu5xDnL8sOaXbauRbYum0GSJGrUaFoWC1t4LQnMwMPeHLrD6wFYHDiqZaoviXAZyU8XpZb7EBkqZzxiFdahTKlzZbPRxMnf/CbYGAymB5gYH8dWX/gCrr/uOpz5b2eiVm9gfHxcjDsJoWTpV6xTP85VBAhIHJQbzqRHsnknFNclM0bV/KpFq4AbE/ybFT4vrdvCYyBcHL5fITAMw5pZnAFH8DiF/PwmkX0CUxh7PP6l+yIlZ/BQMI8+8IfwRwAL8+SCEoqZEVOM8YkJ7LDTztjzS1/CE088ge9//7s4+qhjUK3V8d7KVYiIrIcgcr31zIk8O6s1JOq5CkA4jDHo/xupyDECFxcuJ6JhA2mxknGIbEIF4RtoDDvTSSJRZgV0BBJJoS8GDIpGy+nNCGX/Iu8LSx4AOKeS4oDLL6GeTB7mykDKdPvEyim8YF3YvnmSqzDUGSQcMDIK1v+eey66nQ422mhjjI2NY2JiAqW4ZLNYo1y/Mzw7jmO0WiOYmppCr9dTEzjcDTBFAno9zBlqYFPW6zBenGpvVpIkqe+xiyrGk6womLZJQghjzSWz7po7LrOS0IJAVDAgQu42DrJ/BQYYn9w6Ugz7aOGegfEkU+VwSqwrAQFkpL7sYuSpsnVjadEgeOes5WSSlsuJUePLh8MBWs022u0RrFixMp3SaV07nbMIyfFqjEq5gmXLl2HxAzdii823wCabborB9AC9fs+RNll6KiiXDCNavEZn8siDSNn/HA6GmB4MrIyd3cQxzjwvqFgTQFmEUDOE2SWGzirHzkHKmFdUpCkoHESZySKNgojVMQWkx4DsGioQSJt0SFpayYM9glHGebeQ7M9wMFRGyQpeNsVJ2jAZggfprmY2Hg4F52RphhnlahnzzjsX8+dfgXXW+QC+9KUvY+7cI7Hxxz6OXq+L/mRKL1f+ONLPQPXwSVmy+GLLeGQvm86VGDHgkVySxAV2FpkCWvFtOJiBKI36gznB0lCjSA+Y3U9DEG1toeoRSKQqCZU0zIj+gXElO4k5B5EUNRCQa5yEy9NLkTQHzjN0gmSMpZU6C8Z4PsEwJp1JsGL5Cjz22GOo15vodruYP/9yzDnsEJz9y//Bu+8uw6xZs0BRnAJKls9PahwbC7RMcx1YsRVZhHejGzeKvbM6jZ7JkBnXzzeOOMgOUArZwKyInlmb2Hj+gNFdxZxegXWC6aNhQMUrnP/OiqgaqVqaBOkQxXa06XyAYppTOEo1ZceYgDYeMHgCzn2lXMGLL/wFb//9LURWht0amYGJiQ7OPffXOOywQ3DBvHPR63XRbo8AFKV5CUORJJjz6HBoMkEC/HDyK3uOpv9bw7V5ZlGKCbJV8WTVQiI6hFCkEF9RZMylLHvPvBTcFBZj0nuHgIgqKWdGRO+A98hK10mBv6OvIiLPvhWizGCws7pRQfmju8icP8okZ8BwroUsyRjGJKhWq3jpry+i1+06cWYyHCKKCK2REaxYsQK/+Pl/Y+4Rh+KKKy7F9PQU2u02EmMwHAxVA8jAd97YeLIEUWSPI310JMYgSYwtq1LI2pWGMgJKvMBYUEbV/KSSMsnb8ypgk8MFcs0cA0seMe7fOWW8uLcmGyVPWsAKFDXSvCYisBFmkASDCjx20zcVglFlQ6JLFS2ykKPUSDleZQaTvX4fW2+zDT628cYYG11lAaK0LT0cDFAqldBqt/Haa6/iv3767zhy7hzcestN1t1rpnMxcTsl0dfItpsXZyQUyAEP2cMy+YdlBEU7M8y0f2eInJPQiyiUPTTJ9zPC+kVqKZQFvN0QUjmMkG6evX/AViqAEJ3MnqEZW1FO9q06T9Ign3I1NyRhAlTczEB+1RlVz3rWTDo+ZhIf3ehjWHDdjTj11NNQr1fRmZiww6TShzYcDlGplNFstfDyy3/Dv/3wezj5pOOw8J47USqX0Wg0Ha3aCJQsbAnn9ITMSokbagSddo8FZy+keauz2iiBaTjsAQqC1pC2TLMUELWaoyRnKRP8i4JqI3uNCAWjTnKyZwqEFhASJWhkSw9H5JybuDoSArw+I19MTk6h2RrBaad/B9ctuBFzjzwqHUjd6ThiqTHpQqhW00ETf/jDE/iX076Fb3/rG3j00UfclDKTtWahiRQELWXLHjDErveULkHmdOE4XcgSEzAs2bwIdqsondXIFy5gDHlCSlGP3+cOPr+QlHAKqPDMrBzZJFUuUt2WwCq4SIHrlDxATtfmLWG9O6jnpYcKonAal1/pBGAwPcB7K1dh/Q0+gn8788eYP/8afHmvvdIhlJ2Os5A3Jg359UYDjUYTjz/2GL558gm49JIL0/m52S7JcUb0CNgcdds+1ITZmlskKkvPawIROHVJKFeYRpg8JA0O1b+hkITFe/ucwIj3h5hsonUBxdpPNy4PyBM6C4bhrj75kL12cHCmkVrxGeBDgU6P2atIMsk0Ix01M9nvY3R0DBttvAl+/otf4zcX/B923HFH9Hs99Pv91PETKZKXmAT1RiorW/rwEgyHA4svmVwVoK5LKIS9psDYks7kkkgWpZtJjCJ1Zs0yLRzhvLuIygdCwmdAHHWLxrjIlDjjbp0PGOacYFdWANLSBxkfQA40VhPDxZTv8OxQYVQtIFbcwNQ7zygQJBSShJkqgsQwjgn9fh/GGHz+C9vgc5/fCnfffSeuvPJyPPfss6AoQq1ag2GTklaZ8ZnPbIZSqWxBo9iVJNLJXEKmsDu9COOApXdFUSJ8Czy5wo4aU8GSAlTIBF5dmk4ftKER+gsYR/zISC2a0i4EMQXqbQlEUdD9dTZxJM5hEk7TjhoiyYhZ0uFeOHQEDyBaKW0GlGw5Oy7kWSVVNc4fj1LqWK/fB5ix1977YceddsWdt9+KSy69GG+/9RZK5Qqq1XTUzOZbfDY1mhROpx5P9xPCswdnrKhCnsERpWVYJlxh43s+qb2dPQay6bLE7rVSKpt25GCEiiYO8hBopC+bxCa4Cp7sSQq1zY4Hxc+iIj2khv4iFaKo2IJEquV63Z6bb687XIHCJujhqynWIXdQmSYYbZcSiCujKAJFESbGJ0AUYfacubhy/rX4xsmnYI1Zs9DtTGD99T+MT2++Oaamp/3Dk9GJgikoIhwr0CZLsLJQK0wwTGbuBB2qs/6DMamrqgkcwIxJZwYb8drpUZIlmMZz++zvJxy8htAysPs5O8J3OAyYwkbUovZQZk9nK2nfWVKh3rVibYeOohIeWboUb77xJtZbbz2Mjo05Dn8oUfKoe8Z9l7RnXbPmhRfIoYpaTk2I4ggmSbBy5Uq0WiM46Rvfwl577YM77rgNm22+BWa0Z6I/NemydDc+JmADW+2biwBZSeXKN9uyNglAkRFnqFFz5AwZr0q2FQHZxlcEcl1G53gaurDDqNmdsjoJbd7Csn2YJG5075NPPellYJLcGhzTjtXF2hAoyPhZcXDLlQpee/UVHHPssfjLCy9gjVmzUCrFGA4GipUi38QoZasnLgKSn58mWxxq5HW3B/kuVfpwBsMBVq5cibXXWRcnnnQKNt/8s+hP9tPSlpXGJ2hba0eUbCcnRuMHpgCLN5kBtu0fGCUH0z4/JjB3SH/HuI3h8ALDObs4ae4kDaMMM4bJEMNkiHqjjlJcwgW/uQD3LLonnb9ojFIr5MpeNzRKkB5DbwUKTBiYDSrVBh5/7BHss88+OProo3Dk3COx3oc/jE6nYyddRnqODWv0yZ6SMEYyXylXLkkkhMUY1tCtMLu+KIoddyDLdh1hUjpvEcSAC00zc1g8mzT5ZSCKYE2rKfdfoKkRsxUi/znZ+Go7O2aNsF9nP201a/nKyKdBpPRVExvya/UaiGI8/vhjuGr+lVi6dGk6c1F1GgOb+qAyKCHg9OfNHwKDAU5QqTWxctVK/OLn/40bb7wRp5xyCvbb/wDMGBnBxETHDl6gnFiTc4ZLJp8EctD0gDg+gopD4+92EKJFC02gSczb2gS2rllL2DaBTJaYGDuzOBLjdyM7jMGRaayrurtnxsnUpGgmX2obN6nURTwTRGIWyKk9WqrlCkq1Ol76218x/8orcc89CzE9NY1Gs+EwAhS+d86APh0eXThSTi3voCVsUvvUqFLFa6++in85/VTMn38VTjrpJOy99z4gIjtd3PvuGdbmElzE6mX9QHL2ayhIUrNZvkW6eNZonbN0z60DQmISa4TNdq6Bd0A1ZBBx5Hd54ucOZ0wgsrI6Eu6MHv7gQEImWEFKbR2MizN+0SdJes+bjRb+/vbbWLDgGtx2221YuWIF6s0GGs2GS1aL/5C6wb4MFFy0iArm1GSDmQk6ebEq3kqtBgLhj0//AScc/3Xs8aW9cNxxx2GbbbaFMQl6lgWsoonJunQMPfSKc30DBGTOrAo3Oe9bCnoUvNoHEEJiZMu9TLfAhtOHbu1k3fRTNUqHlI0sCfdty8iH9qUj1VHUEcIewVkAIP8ZsmZYq9XCylUrcdNvb8J1C67BG6+9imq9gVa7ZauIJPT01XMGiUMveC8NU8cCsYYP3SAnUv3lLJtNw7NBpZZOAL9n4R14cPH92P+AA/GNb5yMjTbaCJOTk27yt/LNNawUR2FUkN7BCXPAyRcmC4Elm3Iuk1Y17uYqEplS4TgcnsVgacOu1texnHL0MuMGRWjxKYkh3QiOtZCqneaE6VU3Gg1MTk3hrrvuxOWXX4YXX3zBdkRnWA6ByW1Yxdsv6H+QOOJLei5QGP21kM29dkRC3WKpYUmaKFVrTSQmwfXXXYN7770Xs2fPxhFHzMX66/8TOhMTmB4OXGjVZ71umkhvfIYFZUCiJqdA/cpuGAMb6XnAOS1ALuoEFnMZWJTlKAyDyETOcCkSk8/C9cAgIGE3oCHjUhjng0wFfAjkKqB6vQ4wY8mSh3DttdfgiSeeACF1Xc8iAwKtoqbEhyN30kIzpYCSqALUwFrSi4dEB0UQRV3YA+XO9VQ5lC6E0dFRzDv/HNx222044YQTsM8++2FkZAQTnbS9C4p8zaz0fMqaULmUZdm3bKAY9i6faWmGnJ5BTuhkpZ7whrTGGTPA2ctyBFvHp3V/REBCHjtVtisMO15PW3HktSihtZwdypmk83/jUgnPP/8cFiy4FgsXLkQyHKLRbLijSqmbpb6B1BAfgTvo4M+CJVTSgaFAvkp+OjXJn2FSLx6C+ZnSp1xu4M03XscPf/BdLFhwDY4//kR8cY89QWB0un0X1rO6OHT9COXmIUagxCSkrVb8hK58+ZizvEcIw6b+B9lUVDfMisiP1yXYY00YVDAJZa9RritcIMFPsYAhSuUKWu0a3nzzDVw1fz4W3n0XxsdWodZoolqtuPZzqCzOZnCpe1BgayfChb1P6auVcgdHoC0iZyakz1YWlQXlVjkLA0ZGpVoDEeG5Z/+Eb53yTey0yy44cu5R2HrrbTFMhuj3+752B2sjKgkPh1arCIYmsGqmBsJKSzohUQUI/YLJSboIyMpBUtbJwmOAfJ7gcj0WBp0sjtb84jPGIKIIrVYby1csx9VXz8ctN/8Wf3/rDVRqDTSabctPNMHkP849LjfFlVdDCRG9CmngVQqnSipVLPmsX+L2jttOJAZHcc6XL9sJ2bldrtbBhvHgA/fhkSVLsM++++PoY4/FxhttjMnJSTdVWzdLSO101RswHNTMek4hBL1bdy65YEJCWt6anFrHcwaNBYYgRSpk1b6kcQemJH/eC/YzEVBvNDDZ7+OOO27H5Zdfglf+9hJK5SqarZG0X+AwfDnZlJU7umq557YgFwh+daAv6TmCrCd3KSlR8QSKghFW6U9GpAcb2xVPACrVtGa95eYbsHjxA/jqV7+GOXMOwzrrrItOdwLDZOhCq8LFgzpfETBFVZDDM6SMSkqpJaosyBWGGbEwfIjkRAVOy+U0qZPSMJHxZ0ock7mseGSSDaNarSKKYjyy5GFcc81VeOrJP6QZv93xw2Sojyg5oleKaEjK3rgwyqhJUAWimJJvksjutOSQF7BEWM714VxRhHBmEQuTQqLU/BFApdbA2PgYLrn4N1i06B4cccRc7PGlPdFutwMgSWfNDk1VBgvaS4/DXrvbzZGrFqDMooRFS8ZMsiAWk2dHOwtb2avIjQzTsmy2IpRKtYJStYw///k5XH/9dbj/3nsxHAxQbzSskilBYU7Geb6Ajwyy10+558TwnstuIDh5o4oS5dA+Kpp9mccLi2caa7YPFTtzOUDNJopRuYI3Xn8N//kfZ+GWW36Lrx93ArbZZjuACP1eLz1qskkeBYwXKtIk2GYOCRGmlIEXMFhdCzcTdEQ2VKekX2OnnJmc/DrnIyGjjjEoVyqo1Wp4552/4/rrFuDOO27DxPgYqvUGKpWKdUNFbhRdMBtHPw0idbQQ63mjhU+TsiNLuIQx5Awa8mXM+/0JxAYFI2m100ho3gSIB8MwyQDlSgVEhOef+xNOP+3b2GnnnTFnzuHYcsvPYmpqGpNTk6nxfDg4gfO0qjQ506gb2IdvLopU8DRskxg/dtXyCaJMdkjaRodC3Y3xZplRFKHZamHF8uVYcM3VuOPO2/D2m2+gWmug3mylk1iTBIEXtsjuqMB6K5OkSY8Gdv7MIcCVe3SsN3VJjegScKsKZ0V6c2HsmAOhpDJW+zAU0A21WrZcSTl9i++/F48uXYoDDvwKDjzwq9hwww0xOTmFwWAAsv6mqTmj8bR05cNrRMWi5nbmePNqE1i+HZm05CPb2ALinJlTRHriknf4JjSbTZvg3YYF116NV1/+K0qVusrsScnx85HY53sE6Vqkx8SwJuGzzNT1YGhNCWM/PFo/qMy3hvKDFqRnSqAezp6up8GxT1SIil19soGgzuiRHKhTrtQxHA5x3bVX4Z6FC7H3Pvvi4IMOwVprrYVur+e8A6TmPuP5yRaqdDfNaOdwfQ/x2W1v3liv35TmF9ncKLJeAHqSmQSZMq5D1fZGHl36CBZcew2eeeZpAEC9OZLKxkwihmCxdvQORr7qFpEcA0/5hE/R76nQDzkn+STkbeJyKliBoPlKVEQKj+EFnHN9MZlyhgPps/S1lwehsTNuq7UGRsdGcdWVl2Lx4gdw8MGH4Et77Il6ve4SxczHWLpyrM7a3ZDmyKmFbzy5g8TiZmO8xEzMJHBqG8MoV6oolUp47tk/4dZbfouHH1mCwdQUao1GmrRaXwUvbxcUOfLMTBlTSPmP6X/l3DxCM+z3O8KF1qNEYko4E3KUbQd2RPk16VvMhKKuOwXiRC6etOx5FJT10Xz9nLVBo3IFb735Bs7+xc9wz8K7ccTco7DFlp9FFBH6vb4dGUdKWeMoiIYtCCIw/AIt49AaOTs/H0opU9kikJZ6TKk7aqlcRq1ew5tvvolbbr4Ji+5ZiH6vk57zjSYSYyAptn64trB2DYRVuh6jHLjGqlzPqyz8OiBhWkJ6CJU9IkvSX4yKyAEMe+aGuQC7N1FvzCS8allr01gPUFahKxsirbosdmdYGlWlUgUIeO7ZP+L73/t/2GabbfG1g2bjU5/6NIbDIaampsS0EFI0dKlD1JCpWLSWsJnNLCKmFPnhYKcxIy6VUKvXsXLlKtxx+1VYePddWLH8H6hU66g1WimolJV1VKib9ZooRSmngDfAweYKRDkStKIAvw2cwamgaC+x6EX7+XxF/YHVKc/0OBIKZ8EHkHK+uCTdPs36DkxqtIsEUirVBgwzHl6yGE888QS++MU9sP/+B+KjG22MqalJTE+niSIJb6EM5zBDaWwlu47akMnTvnwL2iQmzewbTUx0OrjvvkX43a234PVX/4ZSuYZ6s2UZRYlGSJW2Uh+RauwMqJCQu5qxmR5jkY0uEoaULHshVGixW9JMUVIUMCJWpIf3/8PqgsNZNVSQ+WuDxbAsCytOL+o3Vo1brTUxTBLccfsteGjJg9h/vwOwz777Y9asNdDr9ZCYITJjXxY8vvT35Rogp7rJPPiU15/dyfV6DYPBEA8teRA333QjXnzxL6AoQr3RdhM9OIek5D+nHEgZDvQmBNEvZ4rLwi0kSAfcaU559hTB29iGVUCIp4fJI1EeLczv8iBaMGkzxKJTREIfEtqVU0hcdGLfebJ/JSZBFBHiWgPdThdXzb8MDz30IPbd/wDssvNuqDfqzmAqimLXO0hFkZEed28Yg+mBs0wyQqeYEi0jPPnkk7j9tlvxx6efsjMPaymgZYwa5c70Pos+x74O/Y9JpXvK8Nq5XbByZvVf0+RPonCGIXlvQ8p0AdklEHuLGA7YqoFJhDd20tMrJYzpa1Vavb+rPNko5/+tZuGuJmu0PjqptQxVKnjzzTdx/jm/wgP33Yt99z8QW221NSrlCnq9XhraIysNi6TZs2UF2wZMkhggMahUyiiXK3jllZdx5x2/w8NLlmA4mHY0uEyJk+U8yl5nNaWv116QlcSHc5BZlPKixS2zVw4qAiom9jJ8K19GeSbv7Fzy9zPQBIZzeIlyM3dUg4XzdWuOLs1U1DUIalMWU/202XN2tqnBkGIcDCcJypUKIqrgz88/hz//5c/YcsvP4eCDDsGmn/xnJEmCwfQ0pqeHutIWTqPDYQJmg3q9gXeXv4u777wdDz34ACbGR1Gp1lGt11256L2RKKTjBcbQEjMMbGI5SKhJ8iPl2S83VQD6sm45h4pgNQyU9NFaymePyJd7VGCvJrFfSdV2IA+9v+K4yGMYlIOUpQFVDozKJaYMmAQJUfqgmPHUHx7D888+i5122RX77XcANvzIR1CvVVGKS/K22mOC0Gy2sGrVKtx91524/75FeHfZ2yiVa6jVm07GRRkTI3D8CnrpqzG6FjFTlIQEzRAKJXlhXUVFgFRBsihzMoIG7giEmKL4LG8yHBYiUlkS5AAIZwxBDHoseh0lQSocpCAzb8c9lFNxWTeVnKGVqHXJnpNZpl8upxNJXnrxeTz88MOYmpzESLuNB5csxvJ330UUlwEwkuEAH9/0k+h2OzjvvF/jkSX3oz81jWq1XjD4ivT/59BHkDXQxKupiDj/EKkIkKP3b8sU3WmoWUEU3H8foSmKShymqXKgYs5Og4pPcWXuC+2oHfr1eSJJyPGnglVOOV9/OZ5WXjqrlIq0EQKlEjIzHKDeaFpnMQkYMeq1OqampzCYmkKlVi9wE+EAJAvrbMrvdULegZWEgRYhB+2GmN/qS2/WORgX/aTsC8hr4XABSOOmQEyRO89Xvx6L8Of8pIr8XJdsF/gdI7JjXt2GoNXqH7Lfl/UvUYo5mKFn64jBKGBjUll3FNn+Aa/2PXLBlrXOIKSOae/dgL9PBawKRkDOYcHSYt08UmaTrHl6pLtwOjc1iHLIsZrVS/lCnVbHN5FDid4v4/cuFWyC0XThlG6VHetB6eFLc7gLgi6jg3eTJEU2iRDYHtgxtqzmI7vEifK1Pa2OJ1W0GKjAeDHXJ6NgBjYLQgcJiXc44IGDmQ+sk8xCTWX6v/8/OeUl1v75dgUAAAAASUVORK5CYII=';
576
+
577
+ function FalariLogoImage({ size = 46 }) {
578
+ return h('img', {
579
+ src: FALARI_LOGO,
580
+ alt: '',
581
+ width: size,
582
+ height: size,
583
+ style: {
584
+ width: size,
585
+ height: size,
586
+ flex: 'none',
587
+ display: 'block',
588
+ borderRadius: radius.tile,
589
+ objectFit: 'cover',
590
+ boxShadow: '0 6px 18px rgba(8, 20, 40, 0.22)',
591
+ },
592
+ });
593
+ }
594
+
595
+ // The Falari mark: isometric cube with the `>_` prompt on its left face.
596
+ // Geometry measured from the official artwork and scaled to a 100×100 box:
597
+ // three lit faces, the inner edges drawn as bands inside the faces, and the
598
+ // prompt glyph. `bold` darkens the faces so the silhouette survives at the
599
+ // 20px sidebar size.
600
+ function FalariMark({ size = 32, bold = false }) {
601
+ const uid = 'falari' + useId().replace(/[:]/g, '');
602
+ const top = [
603
+ ['0', '#ffffff'], ['0.55', '#f4f5f7'], ['1', bold ? '#cdd0d6' : '#d3d6db'],
604
+ ];
605
+ const left = [
606
+ ['0', '#f6f8f9'], ['0.45', bold ? '#e3e5e9' : '#e8eaee'], ['1', bold ? '#b2b5bc' : '#b9bcc2'],
607
+ ];
608
+ const right = [
609
+ ['0', '#ffffff'], ['0.45', bold ? '#e5e7eb' : '#eaecf0'], ['1', bold ? '#b6b9c0' : '#bdc0c6'],
610
+ ];
611
+ const stops = (list) => list.map(([offset, color]) => h('stop', { key: offset, offset, stopColor: color }));
612
+ const ink = '#101215';
613
+ return h(
614
+ 'svg',
615
+ {
616
+ width: size,
617
+ height: size,
618
+ viewBox: '0 0 100 100',
619
+ 'aria-hidden': true,
620
+ style: { display: 'block', flex: 'none' },
621
+ },
622
+ h(
623
+ 'defs',
624
+ null,
625
+ h('linearGradient', { id: uid + '-top', x1: '0.5', y1: '0', x2: '0.14', y2: '0.9' }, stops(top)),
626
+ h('linearGradient', { id: uid + '-left', x1: '0.15', y1: '0', x2: '0.6', y2: '1' }, stops(left)),
627
+ h('linearGradient', { id: uid + '-right', x1: '0.55', y1: '0.02', x2: '0.25', y2: '1' }, stops(right)),
628
+ h(
629
+ 'clipPath',
630
+ { id: uid + '-cube' },
631
+ h('path', { d: 'M49.86 3 89.42 26.38 89.42 75.51 49.86 96.77 10.3 75.51 10.3 26.38Z' }),
632
+ ),
633
+ ),
634
+ h('path', { d: 'M49.86 3 89.42 26.38 49.74 44.57 10.3 26.38Z', fill: 'url(#' + uid + '-top)' }),
635
+ h('path', { d: 'M10.3 26.38 49.74 44.57 49.86 96.77 10.3 75.51Z', fill: 'url(#' + uid + '-left)' }),
636
+ h('path', { d: 'M49.74 44.57 89.42 26.38 89.42 75.51 49.86 96.77Z', fill: 'url(#' + uid + '-right)' }),
637
+ h(
638
+ 'g',
639
+ { fill: ink, clipPath: 'url(#' + uid + '-cube)' },
640
+ h('path', { d: 'M10.3 26.38 49.74 44.57 48.95 46.29 9.51 28.1Z' }),
641
+ h('path', { d: 'M89.42 26.38 49.74 44.57 50.53 46.29 90.21 28.1Z' }),
642
+ h('path', { d: 'M47.85 44.57H51.63V96.77H47.85Z' }),
643
+ h('circle', { cx: '49.74', cy: '44.57', r: '1.89' }),
644
+ ),
645
+ h('path', {
646
+ d: 'M49.86 3 89.42 26.38 89.42 75.51 49.86 96.77 10.3 75.51 10.3 26.38Z',
647
+ fill: 'none',
648
+ stroke: bold ? '#9fa5ae' : '#b6bbc3',
649
+ strokeWidth: bold ? 1.5 : 1.1,
650
+ strokeLinejoin: 'round',
651
+ }),
652
+ h('circle', { cx: '49.86', cy: '96.77', r: '1.89', fill: ink }),
653
+ h(
654
+ 'g',
655
+ {
656
+ fill: 'none',
657
+ stroke: ink,
658
+ strokeWidth: bold ? 4.1 : 3.66,
659
+ strokeLinecap: 'round',
660
+ strokeLinejoin: 'round',
661
+ },
662
+ h('path', { d: 'M19.03 41.68 29.35 53.48 19.03 58.44' }),
663
+ h('path', { d: 'M27.76 64.48 39.66 74.18' }),
664
+ ),
665
+ );
666
+ }
667
+
668
+ function SidebarIcon({ size }) {
669
+ const edge = typeof size === 'number' ? Math.min(28, Math.max(16, size)) : 20;
670
+ return h(FalariMark, { size: edge, bold: edge < 28 });
671
+ }
672
+
673
+ /** The one panel primitive: translucent glass, 1px highlight edge, soft lift. */
674
+ function Card({ children, style, soft }) {
675
+ return h(
676
+ 'div',
677
+ {
678
+ style: Object.assign(
679
+ {
680
+ border: '1px solid ' + colors.hairline,
681
+ background: soft ? colors.surfaceSoft : colors.surface,
682
+ backdropFilter: 'blur(18px) saturate(1.08)',
683
+ WebkitBackdropFilter: 'blur(18px) saturate(1.08)',
684
+ boxShadow: 'inset 0 1px 0 ' + colors.glassEdge + ', 0 8px 26px rgba(8, 20, 40, 0.07)',
685
+ borderRadius: radius.card,
686
+ padding: space.card + 'px ' + (space.card + 2) + 'px',
687
+ boxSizing: 'border-box',
688
+ },
689
+ style,
690
+ ),
691
+ },
692
+ children,
693
+ );
694
+ }
695
+
696
+ /** A labelled section title in the wallet's "label" register: small, wide, bold. */
697
+ function SectionTitle({ children, t, right }) {
698
+ return h(
699
+ 'div',
700
+ { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: space.section } },
701
+ h(
702
+ 'div',
703
+ {
704
+ style: {
705
+ flex: 1,
706
+ minWidth: 0,
707
+ fontSize: type.kicker,
708
+ fontWeight: 800,
709
+ letterSpacing: '0.08em',
710
+ textTransform: 'uppercase',
711
+ color: colors.muted,
712
+ },
713
+ },
714
+ children,
715
+ ),
716
+ right || null,
717
+ );
718
+ }
719
+
720
+ function Badge({ text, tone = 'muted' }) {
721
+ const toneColor =
722
+ tone === 'success' ? colors.success : tone === 'error' ? colors.error : tone === 'warn' ? colors.warn : tone === 'brand' ? colors.brand : colors.muted;
723
+ return h(
724
+ 'span',
725
+ {
726
+ style: {
727
+ display: 'inline-flex',
728
+ alignItems: 'center',
729
+ gap: 6,
730
+ border: '1px solid color-mix(in srgb, ' + toneColor + ' 28%, transparent)',
731
+ background: 'color-mix(in srgb, ' + toneColor + ' 12%, transparent)',
732
+ borderRadius: 999,
733
+ padding: '3px 9px',
734
+ fontSize: type.micro,
735
+ lineHeight: '15px',
736
+ fontWeight: 600,
737
+ letterSpacing: '0.01em',
738
+ color: toneColor,
739
+ whiteSpace: 'nowrap',
740
+ },
741
+ },
742
+ h('span', { style: { width: 6, height: 6, borderRadius: '50%', background: toneColor, display: 'inline-block' } }),
743
+ text,
744
+ );
745
+ }
746
+
747
+ function Button({ children, onClick, disabled, primary, subtle, danger, style }) {
748
+ return h(
749
+ 'button',
750
+ {
751
+ type: 'button',
752
+ onClick,
753
+ disabled,
754
+ style: Object.assign(
755
+ {
756
+ height: 34,
757
+ borderRadius: radius.field,
758
+ border: '1px solid ' + (primary || danger ? 'transparent' : colors.hairline),
759
+ background: primary
760
+ ? 'linear-gradient(135deg, ' + colors.brand + ', color-mix(in srgb, ' + colors.brand + ' 72%, #0a2a4a))'
761
+ : danger
762
+ ? 'linear-gradient(135deg, ' + colors.error + ', color-mix(in srgb, ' + colors.error + ' 76%, #3a0b06))'
763
+ : subtle
764
+ ? colors.surface2
765
+ : colors.surfaceSoft,
766
+ color: primary || danger ? '#fff' : colors.text,
767
+ boxShadow: primary || danger ? '0 4px 14px ' + (primary ? colors.brandSoft : colors.errorSoft) : 'none',
768
+ padding: '0 14px',
769
+ fontSize: type.callout,
770
+ fontWeight: primary || danger ? 650 : 550,
771
+ letterSpacing: '0.01em',
772
+ cursor: disabled ? 'not-allowed' : 'pointer',
773
+ opacity: disabled ? 0.45 : 1,
774
+ whiteSpace: 'nowrap',
775
+ transition: 'transform 120ms ease, box-shadow 160ms ease, opacity 160ms ease',
776
+ },
777
+ style,
778
+ ),
779
+ },
780
+ children,
781
+ );
782
+ }
783
+
784
+ function inputStyle() {
785
+ return {
786
+ height: 36,
787
+ border: '1px solid ' + colors.hairline,
788
+ borderRadius: radius.field,
789
+ background: colors.surface2,
790
+ color: colors.text,
791
+ padding: '0 12px',
792
+ fontSize: type.callout,
793
+ fontFamily: 'inherit',
794
+ boxSizing: 'border-box',
795
+ width: '100%',
796
+ outline: 'none',
797
+ };
798
+ }
799
+
800
+ function Field({ label, children, hint }) {
801
+ return h(
802
+ 'label',
803
+ { style: { display: 'flex', flexDirection: 'column', gap: 7, minWidth: 0 } },
804
+ h(
805
+ 'span',
806
+ { style: { fontSize: type.micro, fontWeight: 700, letterSpacing: '0.04em', color: colors.muted, textTransform: 'uppercase' } },
807
+ label,
808
+ ),
809
+ children,
810
+ hint ? h('span', { style: { fontSize: type.caption, color: colors.muted, lineHeight: 1.55 } }, hint) : null,
811
+ );
812
+ }
813
+
814
+ // ── formatting ───────────────────────────────────────────────────────
815
+
816
+ function deduction(wei) {
817
+ let value = 0n;
818
+ try {
819
+ value = BigInt(String(wei ?? 0));
820
+ } catch {
821
+ value = 0n;
822
+ }
823
+ return value === 0n ? '0 FAR' : '-' + formatFar(value);
824
+ }
825
+
826
+ function formatBytes(bytes) {
827
+ const value = Number(bytes) || 0;
828
+ if (value < 1024) return value + ' B';
829
+ const units = ['KiB', 'MiB', 'GiB', 'TiB'];
830
+ let scaled = value / 1024;
831
+ let index = 0;
832
+ while (scaled >= 1024 && index < units.length - 1) {
833
+ scaled /= 1024;
834
+ index += 1;
835
+ }
836
+ return scaled.toFixed(scaled >= 10 ? 0 : 1) + ' ' + units[index];
837
+ }
838
+
839
+ function shortAddress(value) {
840
+ const text = String(value || '');
841
+ if (text.length <= 16) return text || '—';
842
+ return text.slice(0, 8) + '…' + text.slice(-6);
843
+ }
844
+
845
+ function formatExpiry(value, t) {
846
+ if (!value) return t('status.never');
847
+ const date = new Date(value);
848
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
849
+ }
850
+
851
+ const TIERS = new Set(['write_data', 'manage_data', 'transfer_funds']);
852
+ const KEY_STATES = new Set(['active', 'expired', 'revoked', 'frozen', 'disabled', 'pending']);
853
+
854
+ /** The key's chain state, in the reader's language. */
855
+ function keyStateText(state, t) {
856
+ const value = String(state || '').trim();
857
+ if (!value) return '—';
858
+ return KEY_STATES.has(value) ? t('keyState.' + value) : value;
859
+ }
860
+
861
+ /** The three wire tier names, in the reader's language. */
862
+ function tiersText(tiers, t) {
863
+ const list = Array.isArray(tiers) ? tiers.filter(Boolean) : [];
864
+ if (list.length === 0) return '—';
865
+ return list.map((tier) => (TIERS.has(tier) ? t('tier.' + tier) : String(tier))).join(t('common.listSeparator'));
866
+ }
867
+
868
+ function allowanceText(key, t) {
869
+ if (!key) return '—';
870
+ const one = (limit, used) => {
871
+ if (String(limit) === '-1') return t('status.unlimited');
872
+ if (String(limit) === '0') return t('status.frozen');
873
+ return String(limit) + ' / ' + String(used ?? '0');
874
+ };
875
+ return one(key.totalLimit, key.usedTotal) + ' · ' + one(key.dailyLimit, key.usedToday);
876
+ }
877
+
878
+ async function copyText(value) {
879
+ try {
880
+ if (navigator.clipboard && navigator.clipboard.writeText) {
881
+ await navigator.clipboard.writeText(String(value));
882
+ return true;
883
+ }
884
+ } catch {
885
+ // fall through to the textarea path
886
+ }
887
+ try {
888
+ const area = document.createElement('textarea');
889
+ area.value = String(value);
890
+ area.style.position = 'fixed';
891
+ area.style.opacity = '0';
892
+ document.body.appendChild(area);
893
+ area.select();
894
+ document.execCommand('copy');
895
+ document.body.removeChild(area);
896
+ return true;
897
+ } catch {
898
+ return false;
899
+ }
900
+ }
901
+
902
+ // ── API ──────────────────────────────────────────────────────────────
903
+
904
+ function api(path, options = {}) {
905
+ return fetch(API + path, {
906
+ ...options,
907
+ headers: Object.assign({ 'content-type': 'application/json' }, options.headers || {}),
908
+ }).then(async (res) => {
909
+ let body = null;
910
+ try {
911
+ body = await res.json();
912
+ } catch {
913
+ body = null;
914
+ }
915
+ if (!res.ok || !body || body.ok !== true) {
916
+ const error = body && body.error ? body.error : {};
917
+ const failure = new Error(error.message || 'Falari request failed with HTTP ' + res.status);
918
+ failure.code = error.code || 'http_' + res.status;
919
+ failure.hint = error.hint;
920
+ throw failure;
921
+ }
922
+ return body.value;
923
+ });
924
+ }
925
+
926
+ function uploadOne(file, options) {
927
+ const query = new URLSearchParams({
928
+ fileName: file.name,
929
+ privacy: options.privacy,
930
+ duration: options.duration,
931
+ });
932
+ return fetch(API + '/uploads?' + query.toString(), {
933
+ method: 'POST',
934
+ headers: { 'content-type': 'application/octet-stream' },
935
+ body: file,
936
+ }).then(async (res) => {
937
+ let body = null;
938
+ try {
939
+ body = await res.json();
940
+ } catch {
941
+ body = null;
942
+ }
943
+ if (!res.ok || !body || body.ok !== true) {
944
+ const error = body && body.error ? body.error : {};
945
+ const failure = new Error(error.message || 'Upload failed with HTTP ' + res.status);
946
+ failure.code = error.code || 'http_' + res.status;
947
+ failure.hint = error.hint;
948
+ throw failure;
949
+ }
950
+ return body.value;
951
+ });
952
+ }
953
+
954
+ // ── status: local cache first, then the Host ─────────────────────────
955
+
956
+ const STATUS_CACHE = 'falari-harness/status-cache';
957
+ const STATUS_POLL_MS = 5000;
958
+
959
+ function readStatusCache() {
960
+ try {
961
+ const raw = window.localStorage.getItem(STATUS_CACHE);
962
+ const parsed = raw ? JSON.parse(raw) : null;
963
+ return parsed && typeof parsed === 'object' ? parsed : null;
964
+ } catch {
965
+ return null;
966
+ }
967
+ }
968
+
969
+ function writeStatusCache(value) {
970
+ try {
971
+ if (value && value.configured !== undefined) {
972
+ window.localStorage.setItem(STATUS_CACHE, JSON.stringify(value));
973
+ } else {
974
+ window.localStorage.removeItem(STATUS_CACHE);
975
+ }
976
+ } catch {
977
+ // private mode or a full quota only costs the warm start
978
+ }
979
+ }
980
+
981
+ /**
982
+ * The panel paints the last known status immediately, then asks the Host,
983
+ * which answers from its own cache and refreshes the chain in the
984
+ * background. Polling picks up the newer numbers when they land.
985
+ */
986
+ function useStatus() {
987
+ const [status, setStatus] = useState(() => readStatusCache());
988
+ const [refreshing, setRefreshing] = useState(true);
989
+ const [error, setError] = useState(null);
990
+ const alive = useRef(true);
991
+
992
+ const load = useCallback(async (options = {}) => {
993
+ setRefreshing(true);
994
+ try {
995
+ const value = await api('/status' + (options.refresh ? '?refresh=1' : ''));
996
+ if (!alive.current) return;
997
+ setStatus(value);
998
+ writeStatusCache(value);
999
+ setError(null);
1000
+ } catch (failure) {
1001
+ if (alive.current) setError(failure);
1002
+ } finally {
1003
+ if (alive.current) setRefreshing(false);
1004
+ }
1005
+ }, []);
1006
+
1007
+ useEffect(() => {
1008
+ alive.current = true;
1009
+ load();
1010
+ const timer = setInterval(() => load(), STATUS_POLL_MS);
1011
+ return () => {
1012
+ alive.current = false;
1013
+ clearInterval(timer);
1014
+ };
1015
+ }, [load]);
1016
+
1017
+ return { status, refreshing, error, reload: load };
1018
+ }
1019
+
1020
+ // ── status card ──────────────────────────────────────────────────────
1021
+
1022
+ function statusRows(status, t) {
1023
+ const key = status && status.agentKey;
1024
+ if (!key) return [];
1025
+ return [
1026
+ [t('status.master'), shortAddress(status.owner)],
1027
+ [t('status.keyState'), keyStateText(key.status, t)],
1028
+ [t('status.tiers'), tiersText(key.tiers, t)],
1029
+ [t('status.allowance'), allowanceText(key, t)],
1030
+ [t('status.balance'), formatBalance(status.balanceWei, status.balance)],
1031
+ [t('status.expiry'), formatExpiry(key.expiresAt, t)],
1032
+ [t('status.network'), status.network || '—'],
1033
+ [t('status.miners'), String(status.minerCount ?? 0)],
1034
+ ];
1035
+ }
1036
+
1037
+ function StatusCard({ status, t, compact, refreshing }) {
1038
+ if (!status) return h(Card, null, h('div', { style: { color: colors.muted, fontSize: type.callout } }, t('common.loading')));
1039
+ // A locked key is a normal state, not a failure: the vault is sealed and
1040
+ // the chain simply has not been asked yet.
1041
+ if (status.locked) {
1042
+ return h(
1043
+ Card,
1044
+ { style: { borderColor: colors.warn } },
1045
+ h(
1046
+ 'div',
1047
+ { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
1048
+ h('div', { style: { fontSize: type.title, fontWeight: 750, flex: 1, minWidth: 0, letterSpacing: '-0.015em' } }, t('status.title')),
1049
+ h(Badge, { text: t('settings.locked'), tone: 'warning' }),
1050
+ ),
1051
+ h('div', { style: { marginTop: 8, color: colors.muted, fontSize: type.callout, lineHeight: 1.6 } }, t('settings.lockedHint')),
1052
+ );
1053
+ }
1054
+ if (status.configured === false) {
1055
+ return h(
1056
+ Card,
1057
+ null,
1058
+ h(
1059
+ 'div',
1060
+ { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
1061
+ h('div', { style: { width: 10, height: 10, borderRadius: '50%', background: colors.muted } }),
1062
+ h(
1063
+ 'div',
1064
+ { style: { flex: '1 1 240px', minWidth: 0 } },
1065
+ h('div', { style: { fontSize: type.headline, fontWeight: 700 } }, t('status.noKey')),
1066
+ h('div', { style: { marginTop: 4, color: colors.muted, fontSize: type.callout, lineHeight: 1.6 } }, t('status.noKeyHint')),
1067
+ ),
1068
+ h(Badge, { text: t('status.missing') }),
1069
+ ),
1070
+ );
1071
+ }
1072
+ if (status.error) {
1073
+ return h(
1074
+ Card,
1075
+ { style: { borderColor: colors.error } },
1076
+ h(
1077
+ 'div',
1078
+ { style: { display: 'flex', alignItems: 'flex-start', gap: 10 } },
1079
+ h('div', { style: { width: 10, height: 10, borderRadius: '50%', background: colors.error, marginTop: 4 } }),
1080
+ h(
1081
+ 'div',
1082
+ { style: { minWidth: 0 } },
1083
+ h('div', { style: { fontSize: type.headline, fontWeight: 700 } }, t('status.unhealthy')),
1084
+ h('div', { style: { marginTop: 4, color: colors.muted, fontSize: type.callout, lineHeight: 1.6 } }, status.error.message || t('status.loadFailed')),
1085
+ status.error.hint ? h('div', { style: { marginTop: 4, color: colors.muted, fontSize: type.micro } }, status.error.hint) : null,
1086
+ ),
1087
+ h(Badge, { text: status.error.code || 'error', tone: 'error' }),
1088
+ ),
1089
+ );
1090
+ }
1091
+ const rows = statusRows(status, t);
1092
+ const stamp = status.cachedAt
1093
+ ? new Date(status.cachedAt * 1000)
1094
+ : status.checkedAt
1095
+ ? new Date(status.checkedAt)
1096
+ : null;
1097
+ const stampText = stamp && !Number.isNaN(stamp.getTime()) ? stamp.toLocaleTimeString() : '';
1098
+ return h(
1099
+ Card,
1100
+ null,
1101
+ h(
1102
+ 'div',
1103
+ { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 12 } },
1104
+ h('div', { style: { width: 10, height: 10, borderRadius: '50%', background: status.healthy ? colors.success : colors.warn } }),
1105
+ h('div', { style: { fontSize: type.title, fontWeight: 750, flex: 1, minWidth: 0, letterSpacing: '-0.015em' } }, t('status.title')),
1106
+ stampText
1107
+ ? h(
1108
+ 'span',
1109
+ { style: { color: colors.muted, fontSize: type.micro, whiteSpace: 'nowrap' } },
1110
+ t('status.updatedAt') + ' ' + stampText + (refreshing ? ' · ' + t('status.refreshing') : ''),
1111
+ )
1112
+ : null,
1113
+ h(Badge, { text: status.healthy ? t('status.healthy') : t('status.unhealthy'), tone: status.healthy ? 'success' : 'error' }),
1114
+ ),
1115
+ h(
1116
+ 'div',
1117
+ { style: { display: 'grid', gridTemplateColumns: compact ? 'repeat(auto-fit, minmax(190px, 1fr))' : 'repeat(auto-fit, minmax(240px, 1fr))', gap: '14px 20px' } },
1118
+ rows.map(([label, value]) =>
1119
+ h(
1120
+ 'div',
1121
+ { key: label, style: { minWidth: 0 } },
1122
+ h(
1123
+ 'div',
1124
+ {
1125
+ style: {
1126
+ color: colors.muted,
1127
+ fontSize: type.kicker,
1128
+ fontWeight: 700,
1129
+ letterSpacing: '0.05em',
1130
+ textTransform: 'uppercase',
1131
+ },
1132
+ },
1133
+ label,
1134
+ ),
1135
+ h('div', { style: { marginTop: 2, fontSize: type.callout, lineHeight: 1.45, wordBreak: 'break-all' } }, value),
1136
+ ),
1137
+ ),
1138
+ ),
1139
+ status.refreshError
1140
+ ? h(
1141
+ 'div',
1142
+ { style: { marginTop: 10, color: colors.warn, fontSize: type.micro, lineHeight: 1.5 } },
1143
+ t('status.stale') + ' ' + (status.refreshError.message || ''),
1144
+ )
1145
+ : null,
1146
+ );
1147
+ }
1148
+
1149
+ // ── tasks ────────────────────────────────────────────────────────────
1150
+
1151
+ function statusTone(status) {
1152
+ if (status === 'completed') return 'success';
1153
+ if (status === 'failed' || status === 'terminated') return 'error';
1154
+ if (status === 'paused' || status === 'needs_source') return 'warn';
1155
+ if (status === 'uploading' || status === 'queued' || status === 'creating') return 'brand';
1156
+ return 'muted';
1157
+ }
1158
+
1159
+ function TaskRow({ task, t, onAction, onRefund, busy }) {
1160
+ const [copied, setCopied] = useState('');
1161
+ const isDownload = task.kind === 'download';
1162
+ const active = !isDownload && (task.status === 'uploading' || task.status === 'queued' || task.status === 'creating');
1163
+ const finished = task.status === 'completed' || task.status === 'terminated';
1164
+ const removable = finished || (isDownload && task.status === 'failed');
1165
+ const copy = async (label, value) => {
1166
+ if (await copyText(value)) {
1167
+ setCopied(label);
1168
+ setTimeout(() => setCopied(''), 1600);
1169
+ }
1170
+ };
1171
+ return h(
1172
+ 'div',
1173
+ {
1174
+ style: {
1175
+ border: '1px solid ' + colors.hairline,
1176
+ background: colors.surfaceSoft,
1177
+ borderRadius: radius.block,
1178
+ padding: '13px 15px',
1179
+ display: 'flex',
1180
+ flexDirection: 'column',
1181
+ gap: space.inline,
1182
+ boxShadow: 'inset 0 1px 0 ' + colors.glassEdge,
1183
+ },
1184
+ },
1185
+ h(
1186
+ 'div',
1187
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
1188
+ h('div', { style: { fontSize: type.headline, fontWeight: 700, minWidth: 0, flex: '1 1 200px', overflowWrap: 'anywhere', letterSpacing: '-0.01em' } }, task.fileName),
1189
+ h(Badge, { text: isDownload ? t('task.kindDownload') : t('task.kindUpload') }),
1190
+ h(Badge, { text: task.privacy === 'private' ? t('upload.private') : t('upload.public') }),
1191
+ h(Badge, { text: t('task.status.' + task.status), tone: statusTone(task.status) }),
1192
+ h('span', { style: { color: colors.muted, fontSize: type.micro, fontVariantNumeric: 'tabular-nums' } }, formatBytes(task.bytesDone) + ' / ' + formatBytes(task.fileSize)),
1193
+ ),
1194
+ h(
1195
+ 'div',
1196
+ { style: { height: 6, borderRadius: 999, background: 'color-mix(in srgb, ' + colors.text + ' 10%, transparent)', overflow: 'hidden' } },
1197
+ h('div', {
1198
+ style: {
1199
+ width: Math.max(0, Math.min(100, task.percent)) + '%',
1200
+ height: '100%',
1201
+ background:
1202
+ task.status === 'failed'
1203
+ ? colors.error
1204
+ : task.status === 'completed'
1205
+ ? colors.success
1206
+ : 'linear-gradient(90deg, ' + colors.brand + ', color-mix(in srgb, ' + colors.brand + ' 60%, #7cc4ff))',
1207
+ transition: 'width 240ms ease',
1208
+ },
1209
+ }),
1210
+ ),
1211
+ h(
1212
+ 'div',
1213
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', color: colors.muted, fontSize: type.micro } },
1214
+ h('span', { style: { fontWeight: 700, color: colors.text, fontVariantNumeric: 'tabular-nums' } }, task.percent + '%'),
1215
+ task.stage ? h('span', null, '· ' + task.stage) : null,
1216
+ task.attempt > 1 ? h('span', null, '· ' + t('task.attempt') + ' ' + task.attempt) : null,
1217
+ task.error ? h('span', { style: { color: colors.error, overflowWrap: 'anywhere' } }, '· ' + task.error.message) : null,
1218
+ ),
1219
+ task.intentId
1220
+ ? h(
1221
+ 'div',
1222
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
1223
+ h('code', { title: task.intentId, style: { fontSize: type.micro, overflowWrap: 'anywhere', color: colors.muted, fontVariantNumeric: 'tabular-nums' } }, shortIntent(task.intentId)),
1224
+ h(Button, { subtle: true, onClick: () => copy('intent', task.intentId) }, copied === 'intent' ? t('common.copied') : t('task.copyIntent')),
1225
+ )
1226
+ : null,
1227
+ task.outPath
1228
+ ? h(
1229
+ 'div',
1230
+ { style: { fontSize: type.micro, color: colors.muted, overflowWrap: 'anywhere' } },
1231
+ t('task.outPath') + ': ' + task.outPath,
1232
+ )
1233
+ : null,
1234
+ task.dataKey
1235
+ ? h(
1236
+ 'div',
1237
+ { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
1238
+ h('div', { style: { fontSize: type.micro, color: colors.muted } }, t('task.dataKeyHint')),
1239
+ h(
1240
+ 'div',
1241
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
1242
+ h('code', { style: { fontSize: type.micro, overflowWrap: 'anywhere', color: colors.text } }, task.dataKey),
1243
+ h(Button, { subtle: true, onClick: () => copy('key', task.dataKey) }, copied === 'key' ? t('common.copied') : t('task.copyDataKey')),
1244
+ ),
1245
+ )
1246
+ : null,
1247
+ h(
1248
+ 'div',
1249
+ { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
1250
+ active ? h(Button, { onClick: () => onAction(task.id, 'pause'), disabled: busy === task.id }, t('task.pause')) : null,
1251
+ !isDownload && (task.status === 'paused' || task.status === 'needs_source')
1252
+ ? h(Button, { onClick: () => onAction(task.id, 'resume'), disabled: busy === task.id }, t('task.resume'))
1253
+ : null,
1254
+ !isDownload && task.status === 'failed'
1255
+ ? h(Button, { onClick: () => onAction(task.id, 'retry'), disabled: busy === task.id }, t('task.retry'))
1256
+ : null,
1257
+ !finished && !isDownload
1258
+ ? h(
1259
+ Button,
1260
+ { onClick: () => (onRefund ? onRefund(task.id) : onAction(task.id, 'cancel')), disabled: busy === task.id },
1261
+ t('task.cancel'),
1262
+ )
1263
+ : null,
1264
+ removable ? h(Button, { onClick: () => onAction(task.id, 'remove'), disabled: busy === task.id }, t('task.remove')) : null,
1265
+ ),
1266
+ );
1267
+ }
1268
+
1269
+ // ── cancel + refund dialog ───────────────────────────────────────────
1270
+
1271
+ function formatFar(wei) {
1272
+ if (wei === null || wei === undefined || wei === '') return '—';
1273
+ let value;
1274
+ try {
1275
+ value = BigInt(String(wei));
1276
+ } catch {
1277
+ return String(wei);
1278
+ }
1279
+ const negative = value < 0n;
1280
+ if (negative) value = -value;
1281
+ const base = 10n ** 18n;
1282
+ const whole = value / base;
1283
+ const fraction = (value % base).toString().padStart(18, '0').replace(/0+$/, '');
1284
+ const text = fraction ? whole.toString() + '.' + fraction.slice(0, 6) : whole.toString();
1285
+ return (negative ? '-' : '') + text + ' FAR';
1286
+ }
1287
+
1288
+ function deduction(wei) {
1289
+ let value = 0n;
1290
+ try {
1291
+ value = BigInt(String(wei ?? 0));
1292
+ } catch {
1293
+ value = 0n;
1294
+ }
1295
+ return value === 0n ? '0 FAR' : '-' + formatFar(value);
1296
+ }
1297
+
1298
+ function formatBytes(bytes) {
1299
+ const value = Number(bytes || 0);
1300
+ if (!Number.isFinite(value) || value <= 0) return '0 B';
1301
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
1302
+ let index = 0;
1303
+ let left = value;
1304
+ while (left >= 1024 && index < units.length - 1) {
1305
+ left /= 1024;
1306
+ index += 1;
1307
+ }
1308
+ return (index === 0 ? String(Math.round(left)) : left.toFixed(left >= 100 ? 0 : 1)) + ' ' + units[index];
1309
+ }
1310
+
1311
+ function Dialog({ t, title, children, footer, onClose, busy }) {
1312
+ useEffect(() => {
1313
+ const onKey = (event) => {
1314
+ if (event.key === 'Escape' && !busy) onClose();
1315
+ };
1316
+ window.addEventListener('keydown', onKey);
1317
+ return () => window.removeEventListener('keydown', onKey);
1318
+ }, [onClose, busy]);
1319
+ return h(
1320
+ 'div',
1321
+ {
1322
+ style: {
1323
+ position: 'fixed',
1324
+ inset: 0,
1325
+ zIndex: 1000,
1326
+ background: 'rgba(15, 17, 21, 0.46)',
1327
+ display: 'flex',
1328
+ alignItems: 'center',
1329
+ justifyContent: 'center',
1330
+ padding: 24,
1331
+ },
1332
+ onClick: (event) => {
1333
+ if (event.target === event.currentTarget && !busy) onClose();
1334
+ },
1335
+ },
1336
+ h(
1337
+ 'div',
1338
+ {
1339
+ style: {
1340
+ width: '100%',
1341
+ maxWidth: 520,
1342
+ maxHeight: '86vh',
1343
+ overflow: 'auto',
1344
+ background: colors.base,
1345
+ color: colors.text,
1346
+ border: '1px solid ' + colors.border,
1347
+ borderRadius: 14,
1348
+ padding: '20px 22px',
1349
+ boxShadow: '0 24px 60px rgba(0,0,0,0.28)',
1350
+ display: 'flex',
1351
+ flexDirection: 'column',
1352
+ gap: 14,
1353
+ },
1354
+ },
1355
+ h('div', { style: { fontSize: type.title, fontWeight: 750, letterSpacing: '-0.015em' } }, title),
1356
+ children,
1357
+ footer ? h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' } }, footer) : null,
1358
+ ),
1359
+ );
1360
+ }
1361
+
1362
+ function RefundDialog({ t, taskId, onClose, onChanged, initial }) {
1363
+ const [preview, setPreview] = useState(initial || null);
1364
+ const [error, setError] = useState(null);
1365
+ const [phase, setPhase] = useState(initial ? 'review' : 'loading');
1366
+ const [result, setResult] = useState(null);
1367
+
1368
+ useEffect(() => {
1369
+ if (initial) return undefined;
1370
+ let cancelled = false;
1371
+ (async () => {
1372
+ try {
1373
+ const value = await api('/tasks/' + encodeURIComponent(taskId) + '/refund');
1374
+ if (cancelled) return;
1375
+ setPreview(value);
1376
+ setPhase('review');
1377
+ } catch (failure) {
1378
+ if (cancelled) return;
1379
+ setError(failure);
1380
+ setPhase('error');
1381
+ }
1382
+ })();
1383
+ return () => {
1384
+ cancelled = true;
1385
+ };
1386
+ }, [taskId, initial]);
1387
+
1388
+ const confirm = async () => {
1389
+ setPhase('submitting');
1390
+ setError(null);
1391
+ try {
1392
+ const value = await api('/tasks/' + encodeURIComponent(taskId) + '/cancel', { method: 'POST' });
1393
+ setResult(value && value.refund ? value.refund : preview);
1394
+ setPhase('done');
1395
+ onChanged();
1396
+ } catch (failure) {
1397
+ setError(failure);
1398
+ setPhase('review');
1399
+ }
1400
+ };
1401
+
1402
+ const row = (label, value, tone) =>
1403
+ h(
1404
+ 'div',
1405
+ { key: label, style: { display: 'flex', gap: 12, alignItems: 'baseline', fontSize: type.callout } },
1406
+ h('div', { style: { color: colors.muted, minWidth: 150, flex: '0 0 auto' } }, label),
1407
+ h(
1408
+ 'div',
1409
+ {
1410
+ style: {
1411
+ flex: 1,
1412
+ minWidth: 0,
1413
+ textAlign: 'right',
1414
+ fontWeight: tone === 'strong' ? 600 : 400,
1415
+ color: tone === 'error' ? colors.error : tone === 'success' ? colors.success : colors.text,
1416
+ overflowWrap: 'anywhere',
1417
+ },
1418
+ },
1419
+ value,
1420
+ ),
1421
+ );
1422
+
1423
+ const body = [];
1424
+ const current = phase === 'done' && result ? result : preview;
1425
+ const finalized = Boolean(current && current.unavailable === 'finalized');
1426
+ // Nothing was ever created on chain: cancelling is local bookkeeping, so
1427
+ // the money rows would only be noise.
1428
+ const localOnly = Boolean(current && current.hasIntent === false);
1429
+ if (current) {
1430
+ body.push(
1431
+ row(t('refund.file'), current.fileName || '—'),
1432
+ row(t('refund.status'), current.status || '—'),
1433
+ row(t('refund.uploaded'), formatBytes(current.uploadedSize)),
1434
+ );
1435
+ }
1436
+ if (current && !finalized && !localOnly) {
1437
+ body.push(
1438
+ row(t('refund.escrow'), formatFar(current.escrowWei), 'strong'),
1439
+ current.cancellationBps
1440
+ ? row(
1441
+ t('refund.rate'),
1442
+ String((current.cancellationBps / 100).toFixed(current.cancellationBps % 100 === 0 ? 0 : 2)) + '%',
1443
+ )
1444
+ : null,
1445
+ row(t('refund.compensation'), deduction(current.cancellationFeeWei), 'error'),
1446
+ row(t('refund.serviceFee'), deduction(current.serviceFeeWei)),
1447
+ h('div', { key: 'rule', style: { borderTop: '1px solid ' + colors.border } }),
1448
+ row(
1449
+ phase === 'done' ? t('refund.actual') : t('refund.refundable'),
1450
+ current.hasIntent === false ? '—' : formatFar(current.refundWei),
1451
+ 'success',
1452
+ ),
1453
+ );
1454
+ }
1455
+ if (current) {
1456
+ if (current.hasIntent === false) {
1457
+ body.push(
1458
+ h('div', { key: 'nointent', style: { color: colors.muted, fontSize: type.micro, lineHeight: 1.6 } }, t('refund.noIntent')),
1459
+ );
1460
+ }
1461
+ if (current.unavailable === 'finalized') {
1462
+ body.push(
1463
+ h('div', { key: 'finalized', style: { color: colors.warn, fontSize: type.micro, lineHeight: 1.6 } }, t('refund.finalized')),
1464
+ );
1465
+ }
1466
+ if (!current.hasReceipts && current.hasIntent && !current.finalized) {
1467
+ body.push(
1468
+ h('div', { key: 'noreceipts', style: { color: colors.muted, fontSize: type.micro, lineHeight: 1.6 } }, t('refund.noReceipts')),
1469
+ );
1470
+ }
1471
+ if (current.pending) {
1472
+ body.push(
1473
+ h('div', { key: 'pending', style: { color: colors.muted, fontSize: type.micro, lineHeight: 1.6 } }, t('refund.pending')),
1474
+ );
1475
+ }
1476
+ if (current.estimated && !localOnly) {
1477
+ body.push(h('div', { key: 'estimate', style: { color: colors.muted, fontSize: type.micro } }, t('refund.estimate')));
1478
+ }
1479
+ if (phase === 'done') {
1480
+ body.push(h('div', { key: 'done', style: { color: colors.success, fontSize: type.callout } }, t('refund.doneNote')));
1481
+ }
1482
+ } else if (phase === 'loading') {
1483
+ body.push(h('div', { key: 'loading', style: { color: colors.muted, fontSize: type.callout } }, t('refund.loading')));
1484
+ }
1485
+ if (error) {
1486
+ body.push(
1487
+ h(
1488
+ 'div',
1489
+ { key: 'error', style: { color: colors.error, fontSize: type.callout, lineHeight: 1.5 } },
1490
+ t('refund.failed') + ': ' + (error.message || ''),
1491
+ ),
1492
+ );
1493
+ }
1494
+
1495
+ const footer =
1496
+ phase === 'done'
1497
+ ? [
1498
+ result && result.pending
1499
+ ? h(Button, { key: 'recheck', subtle: true, onClick: onClose }, t('refund.recheck'))
1500
+ : null,
1501
+ h(Button, { key: 'close', onClick: onClose }, t('refund.close')),
1502
+ ].filter(Boolean)
1503
+ : [
1504
+ h(Button, { key: 'back', subtle: true, onClick: onClose, disabled: phase === 'submitting' }, t('refund.back')),
1505
+ h(
1506
+ Button,
1507
+ { key: 'go', onClick: confirm, disabled: phase !== 'review' },
1508
+ phase === 'submitting'
1509
+ ? t('refund.submitting')
1510
+ : localOnly
1511
+ ? t('refund.confirmLocal')
1512
+ : t('refund.confirm'),
1513
+ ),
1514
+ ];
1515
+
1516
+ return h(
1517
+ Dialog,
1518
+ { t, title: localOnly ? t('refund.titleLocal') : t('refund.title'), footer, onClose, busy: phase === 'submitting' },
1519
+ body,
1520
+ );
1521
+ }
1522
+
1523
+ const ACTIVE_TASK = new Set(['creating', 'queued', 'uploading']);
1524
+
1525
+ function useTasks() {
1526
+ const [tasks, setTasks] = useState([]);
1527
+ const [busy, setBusy] = useState('');
1528
+ const [error, setError] = useState(null);
1529
+ const timer = useRef(null);
1530
+
1531
+ useEffect(() => {
1532
+ let cancelled = false;
1533
+ const tick = async () => {
1534
+ try {
1535
+ const value = await api('/tasks');
1536
+ if (cancelled) return;
1537
+ setTasks(value);
1538
+ setError(null);
1539
+ const active = value.some((task) => ACTIVE_TASK.has(task.status));
1540
+ timer.current = setTimeout(tick, active ? 1200 : 4000);
1541
+ } catch (failure) {
1542
+ if (cancelled) return;
1543
+ setError(failure);
1544
+ timer.current = setTimeout(tick, 5000);
1545
+ }
1546
+ };
1547
+ tick();
1548
+ return () => {
1549
+ cancelled = true;
1550
+ if (timer.current) clearTimeout(timer.current);
1551
+ };
1552
+ }, []);
1553
+
1554
+ const act = useCallback(async (id, action) => {
1555
+ setBusy(id);
1556
+ try {
1557
+ await api('/tasks/' + encodeURIComponent(id) + '/' + action, { method: 'POST' });
1558
+ setTasks(await api('/tasks'));
1559
+ } catch (failure) {
1560
+ setError(failure);
1561
+ } finally {
1562
+ setBusy('');
1563
+ }
1564
+ }, []);
1565
+
1566
+ const reload = useCallback(async () => {
1567
+ try {
1568
+ setTasks(await api('/tasks'));
1569
+ } catch (failure) {
1570
+ setError(failure);
1571
+ }
1572
+ }, []);
1573
+
1574
+ return { tasks, busy, error, act, reload };
1575
+ }
1576
+
1577
+ // ── upload tab ───────────────────────────────────────────────────────
1578
+
1579
+ function UploadTab({ t, onRefund, onOpenSettings, status, config, tasks, busy, taskError, act, reloadStatus }) {
1580
+ const [files, setFiles] = useState([]);
1581
+ const [privacy, setPrivacy] = useState(config && config.defaultPrivacy ? config.defaultPrivacy : 'public');
1582
+ const [duration, setDuration] = useState(config && config.defaultDuration ? config.defaultDuration : 'permanent');
1583
+ const [customAmount, setCustomAmount] = useState('30');
1584
+ const [customUnit, setCustomUnit] = useState('days');
1585
+ const [quote, setQuote] = useState(null);
1586
+ const [quoting, setQuoting] = useState(false);
1587
+ const [quoteError, setQuoteError] = useState(null);
1588
+ const [uploading, setUploading] = useState(false);
1589
+ const [notice, setNotice] = useState(null);
1590
+ const [dragging, setDragging] = useState(false);
1591
+ const inputRef = useRef(null);
1592
+
1593
+ // Custom terms are sent as whole seconds; the Host and the SDK price them
1594
+ // exactly like a preset (per-day billing, permanent past 36 years).
1595
+ const customSeconds = durationSecondsFrom(customAmount, customUnit);
1596
+ const effectiveDuration = duration === 'custom' ? String(customSeconds) : duration;
1597
+ const customValid = duration !== 'custom' || customSeconds >= 86400;
1598
+ const customEndsAt = customSeconds > 0 ? new Date(Date.now() + customSeconds * 1000) : null;
1599
+
1600
+ const configured = Boolean(status && status.configured && !status.error && !status.locked);
1601
+ const security = (status && status.security) || { mode: 'none', locked: false };
1602
+ const needsPin = security.mode === 'legacy' || (security.mode === 'sealed' && security.locked);
1603
+ const [pin, setPin] = useState('');
1604
+ const [unlocking, setUnlocking] = useState(false);
1605
+ const [pinError, setPinError] = useState(null);
1606
+
1607
+ const unlockNow = async () => {
1608
+ if (!pin || unlocking) return;
1609
+ setUnlocking(true);
1610
+ setPinError(null);
1611
+ try {
1612
+ await api('/key/unlock', { method: 'POST', body: JSON.stringify({ pin }) });
1613
+ setPin('');
1614
+ await reloadStatus();
1615
+ } catch (failure) {
1616
+ setPinError(failure);
1617
+ } finally {
1618
+ setUnlocking(false);
1619
+ }
1620
+ };
1621
+
1622
+ const totalSize = useMemo(() => files.reduce((sum, entry) => sum + entry.file.size, 0), [files]);
1623
+ // Every file becomes its own intent with its own fixed fees, so a quote
1624
+ // over the sum would overstate the cost; the largest file is the one that
1625
+ // decides what the next upload needs.
1626
+ const largestSize = useMemo(() => files.reduce((max, entry) => Math.max(max, entry.file.size), 0), [files]);
1627
+ const overLimit = config ? totalSize > config.maxFileSizeBytes : false;
1628
+
1629
+ useEffect(() => {
1630
+ if (files.length === 0 || !customValid) {
1631
+ // An incomplete custom term must not fall through to 0 = permanent.
1632
+ setQuote(null);
1633
+ return;
1634
+ }
1635
+ let cancelled = false;
1636
+ setQuoting(true);
1637
+ setQuoteError(null);
1638
+ const timer = setTimeout(async () => {
1639
+ try {
1640
+ const value = await api('/quote', { method: 'POST', body: JSON.stringify({ sizeBytes: largestSize, duration: effectiveDuration }) });
1641
+ if (!cancelled) setQuote(value);
1642
+ } catch (failure) {
1643
+ if (!cancelled) {
1644
+ setQuote(null);
1645
+ setQuoteError(failure);
1646
+ }
1647
+ } finally {
1648
+ if (!cancelled) setQuoting(false);
1649
+ }
1650
+ }, 250);
1651
+ return () => {
1652
+ cancelled = true;
1653
+ clearTimeout(timer);
1654
+ };
1655
+ }, [largestSize, effectiveDuration, files.length]);
1656
+
1657
+ const addFiles = (list) => {
1658
+ const accepted = [];
1659
+ const rejected = [];
1660
+ for (const file of Array.from(list || [])) {
1661
+ if (!file || !file.size) {
1662
+ rejected.push(file && file.name ? file.name : '?');
1663
+ continue;
1664
+ }
1665
+ if (config && file.size > config.maxFileSizeBytes) {
1666
+ rejected.push(file.name);
1667
+ continue;
1668
+ }
1669
+ accepted.push({ file, name: file.name, size: file.size, id: file.name + ':' + file.size + ':' + file.lastModified });
1670
+ }
1671
+ if (accepted.length > 0) {
1672
+ setFiles((current) => {
1673
+ const seen = new Set(current.map((entry) => entry.id));
1674
+ return [...current, ...accepted.filter((entry) => !seen.has(entry.id))];
1675
+ });
1676
+ }
1677
+ setNotice(rejected.length > 0 ? { tone: 'error', text: t('upload.tooLarge') + ': ' + rejected.join(', ') } : null);
1678
+ };
1679
+
1680
+ const start = async () => {
1681
+ if (files.length === 0 || uploading) return;
1682
+ setUploading(true);
1683
+ setNotice(null);
1684
+ const failures = [];
1685
+ const done = [];
1686
+ for (const [index, entry] of files.entries()) {
1687
+ setNotice({
1688
+ tone: 'info',
1689
+ text: t('upload.uploadingFile') + ' ' + (index + 1) + '/' + files.length + ' · ' + entry.name + ' · ' + formatBytes(entry.size),
1690
+ });
1691
+ try {
1692
+ await uploadOne(entry.file, { privacy, duration: effectiveDuration });
1693
+ done.push(entry.id);
1694
+ } catch (failure) {
1695
+ failures.push(entry.name + ': ' + failure.message);
1696
+ }
1697
+ }
1698
+ setUploading(false);
1699
+ if (done.length > 0) {
1700
+ // A file that already queued must not be sent again on the next click.
1701
+ setFiles((current) => current.filter((entry) => !done.includes(entry.id)));
1702
+ }
1703
+ if (failures.length > 0) {
1704
+ setNotice({ tone: 'error', text: t('upload.addFailed') + ' ' + failures.join(' · ') });
1705
+ } else {
1706
+ setNotice({ tone: 'success', text: t('upload.queuedHint') });
1707
+ setFiles([]);
1708
+ if (inputRef.current) inputRef.current.value = '';
1709
+ }
1710
+ };
1711
+
1712
+ const dropzone = h(
1713
+ 'div',
1714
+ {
1715
+ onDragOver: (event) => {
1716
+ event.preventDefault();
1717
+ setDragging(true);
1718
+ },
1719
+ onDragLeave: () => setDragging(false),
1720
+ onDrop: (event) => {
1721
+ event.preventDefault();
1722
+ setDragging(false);
1723
+ addFiles(event.dataTransfer ? event.dataTransfer.files : []);
1724
+ },
1725
+ onClick: () => inputRef.current && inputRef.current.click(),
1726
+ role: 'button',
1727
+ tabIndex: 0,
1728
+ onKeyDown: (event) => {
1729
+ if (event.key === 'Enter' || event.key === ' ') {
1730
+ event.preventDefault();
1731
+ if (inputRef.current) inputRef.current.click();
1732
+ }
1733
+ },
1734
+ style: {
1735
+ border: '1.5px dashed ' + (dragging ? colors.brand : colors.border),
1736
+ borderRadius: radius.block,
1737
+ padding: '30px 20px',
1738
+ textAlign: 'center',
1739
+ cursor: 'pointer',
1740
+ background: dragging ? colors.brandSoft : colors.surfaceSoft,
1741
+ transition: 'border-color 160ms ease, background 160ms ease',
1742
+ },
1743
+ },
1744
+ h(
1745
+ 'div',
1746
+ {
1747
+ style: {
1748
+ width: 44,
1749
+ height: 44,
1750
+ margin: '0 auto 12px',
1751
+ display: 'grid',
1752
+ placeItems: 'center',
1753
+ borderRadius: radius.tile,
1754
+ background: colors.brandSoft,
1755
+ border: '1px solid ' + colors.hairline,
1756
+ },
1757
+ },
1758
+ h('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: colors.brand, strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
1759
+ h('path', { d: 'M12 16V4' }), h('path', { d: 'm7 9 5-5 5 5' }), h('path', { d: 'M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2' })),
1760
+ ),
1761
+ h('div', { style: { fontSize: type.headline, fontWeight: 700 } }, t('upload.dropTitle')),
1762
+ h('div', { style: { marginTop: 6, color: colors.muted, fontSize: type.caption, lineHeight: 1.6 } }, t('upload.dropHint')),
1763
+ h('input', {
1764
+ ref: inputRef,
1765
+ type: 'file',
1766
+ multiple: true,
1767
+ style: { display: 'none' },
1768
+ onChange: (event) => addFiles(event.target.files),
1769
+ }),
1770
+ );
1771
+
1772
+ const fileList =
1773
+ files.length === 0
1774
+ ? null
1775
+ : h(
1776
+ 'div',
1777
+ { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
1778
+ files.map((entry) =>
1779
+ h(
1780
+ 'div',
1781
+ { key: entry.id, style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: type.callout, minWidth: 0 } },
1782
+ h('span', { style: { flex: '1 1 auto', minWidth: 0, overflowWrap: 'anywhere' } }, entry.name),
1783
+ h('span', { style: { color: colors.muted, fontVariantNumeric: 'tabular-nums', flex: 'none' } }, formatBytes(entry.size)),
1784
+ h(
1785
+ 'button',
1786
+ {
1787
+ type: 'button',
1788
+ onClick: (event) => {
1789
+ event.stopPropagation();
1790
+ setFiles((current) => current.filter((item) => item.id !== entry.id));
1791
+ },
1792
+ style: { border: 'none', background: 'transparent', color: colors.muted, cursor: 'pointer', fontSize: 12 },
1793
+ },
1794
+ '×',
1795
+ ),
1796
+ ),
1797
+ ),
1798
+ h(
1799
+ 'div',
1800
+ { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
1801
+ h(Button, { subtle: true, onClick: () => inputRef.current && inputRef.current.click() }, t('upload.choose')),
1802
+ h(Button, { onClick: () => setFiles([]) }, t('upload.clear')),
1803
+ h('span', { style: { color: colors.muted, fontSize: 12 } }, t('upload.totalSize') + ': ' + formatBytes(totalSize)),
1804
+ overLimit ? h(Badge, { text: t('upload.tooLarge'), tone: 'error' }) : null,
1805
+ ),
1806
+ );
1807
+
1808
+ const quoteCard = quote
1809
+ ? h(
1810
+ Card,
1811
+ { style: { background: colors.surface2 } },
1812
+ h(
1813
+ 'div',
1814
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap', marginBottom: 10 } },
1815
+ h('div', { style: { fontSize: type.headline, fontWeight: 700 } }, t('upload.quote')),
1816
+ quote.budget.ok
1817
+ ? h(Badge, { text: t('upload.budgetOk'), tone: 'success' })
1818
+ : h(Badge, { text: t('upload.budgetBad'), tone: 'error' }),
1819
+ files.length > 1
1820
+ ? h('span', { style: { color: colors.muted, fontSize: type.micro } }, t('upload.quoteMulti'))
1821
+ : null,
1822
+ ),
1823
+ h(
1824
+ 'div',
1825
+ { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))', gap: '8px 16px' } },
1826
+ [
1827
+ [t('upload.deposit'), quote.estimatedLockFee],
1828
+ [t('upload.serviceFees'), quote.nonRefundableFee],
1829
+ [t('upload.total'), quote.estimatedTotalFee],
1830
+ ].map(([label, value]) =>
1831
+ h(
1832
+ 'div',
1833
+ { key: label, style: { minWidth: 0 } },
1834
+ h(
1835
+ 'div',
1836
+ {
1837
+ style: {
1838
+ color: colors.muted,
1839
+ fontSize: type.kicker,
1840
+ fontWeight: 700,
1841
+ letterSpacing: '0.05em',
1842
+ textTransform: 'uppercase',
1843
+ },
1844
+ },
1845
+ label,
1846
+ ),
1847
+ h(
1848
+ 'div',
1849
+ { style: { marginTop: 4, fontSize: type.headline, fontWeight: 650, fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.01em' } },
1850
+ value,
1851
+ ),
1852
+ ),
1853
+ ),
1854
+ ),
1855
+ )
1856
+ : quoting
1857
+ ? h(Card, { style: { background: colors.surface2 } }, h('div', { style: { color: colors.muted, fontSize: type.callout } }, t('upload.quoteLoading')))
1858
+ : quoteError
1859
+ ? h(
1860
+ Card,
1861
+ { style: { background: colors.surface2, borderColor: colors.error } },
1862
+ h('div', { style: { color: colors.error, fontSize: type.callout } }, quoteError.message || t('upload.quoteFailed')),
1863
+ quoteError.hint ? h('div', { style: { marginTop: 4, color: colors.muted, fontSize: type.micro } }, quoteError.hint) : null,
1864
+ )
1865
+ : null;
1866
+
1867
+ return h(
1868
+ 'div',
1869
+ { style: { display: 'flex', flexDirection: 'column', gap: 14 } },
1870
+ security.mode === 'none'
1871
+ ? h(Card, { style: { borderColor: colors.warn } }, h('div', { style: { fontSize: type.callout, color: colors.warn } }, t('upload.noKey')))
1872
+ : null,
1873
+ security.mode === 'legacy'
1874
+ ? h(
1875
+ Card,
1876
+ { style: { borderColor: colors.warn } },
1877
+ h('div', { style: { fontWeight: 600, fontSize: 13, color: colors.warn } }, t('settings.plaintextBadge')),
1878
+ h('div', { style: { marginTop: 6, color: colors.muted, fontSize: type.callout, lineHeight: 1.6 } }, t('settings.plaintextWarning')),
1879
+ h(
1880
+ 'div',
1881
+ { style: { marginTop: 10 } },
1882
+ h(Button, { primary: true, onClick: onOpenSettings }, t('upload.setPin')),
1883
+ ),
1884
+ )
1885
+ : null,
1886
+ security.mode === 'sealed' && security.locked
1887
+ ? h(
1888
+ Card,
1889
+ { style: { borderColor: colors.warn } },
1890
+ h(
1891
+ 'div',
1892
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
1893
+ h('div', { style: { fontWeight: 600, fontSize: 13, flex: '1 1 200px', color: colors.warn } }, t('upload.lockedTitle')),
1894
+ h('span', { style: { color: colors.muted, fontSize: type.micro } }, t('upload.lockedHint')),
1895
+ ),
1896
+ h(
1897
+ 'div',
1898
+ { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginTop: 10 } },
1899
+ h('input', {
1900
+ type: 'password',
1901
+ value: pin,
1902
+ placeholder: t('settings.pin'),
1903
+ onChange: (event) => setPin(event.target.value),
1904
+ onKeyDown: (event) => {
1905
+ if (event.key === 'Enter') unlockNow();
1906
+ },
1907
+ style: Object.assign(inputStyle(), { width: 200 }),
1908
+ autoComplete: 'current-password',
1909
+ }),
1910
+ h(Button, { primary: true, onClick: unlockNow, disabled: unlocking || !pin }, unlocking ? t('settings.unlocking') : t('settings.unlock')),
1911
+ h(Button, { subtle: true, onClick: onOpenSettings }, t('upload.openSettings')),
1912
+ ),
1913
+ pinError
1914
+ ? h(
1915
+ 'div',
1916
+ { style: { marginTop: 8, color: colors.error, fontSize: 12, lineHeight: 1.55 } },
1917
+ pinError.message + (pinError.hint ? ' ' + pinError.hint : ''),
1918
+ )
1919
+ : null,
1920
+ )
1921
+ : null,
1922
+ h(Card, null, dropzone, h('div', { style: { height: 12 } }), fileList),
1923
+ h(
1924
+ Card,
1925
+ null,
1926
+ h(
1927
+ 'div',
1928
+ { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 } },
1929
+ h(
1930
+ Field,
1931
+ { label: t('upload.privacy') },
1932
+ h(
1933
+ 'select',
1934
+ { value: privacy, onChange: (event) => setPrivacy(event.target.value), style: inputStyle() },
1935
+ h('option', { value: 'public' }, t('upload.public')),
1936
+ h('option', { value: 'private' }, t('upload.private')),
1937
+ ),
1938
+ ),
1939
+ h(
1940
+ Field,
1941
+ {
1942
+ label: t('upload.duration'),
1943
+ hint:
1944
+ duration === 'custom'
1945
+ ? customValid
1946
+ ? t('upload.customEquals')
1947
+ .replace('{days}', String(Math.max(1, Math.ceil(customSeconds / 86400))))
1948
+ .replace('{date}', customEndsAt ? customEndsAt.toLocaleDateString() : '—')
1949
+ : t('upload.customTooShort')
1950
+ : null,
1951
+ },
1952
+ h(
1953
+ 'div',
1954
+ { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
1955
+ h(
1956
+ 'select',
1957
+ { value: duration, onChange: (event) => setDuration(event.target.value), style: inputStyle() },
1958
+ DURATIONS.map(([value, key]) => h('option', { key: value, value }, t(key))),
1959
+ h('option', { value: 'custom' }, t('upload.durationCustom')),
1960
+ ),
1961
+ duration === 'custom'
1962
+ ? h(
1963
+ 'div',
1964
+ { style: { display: 'flex', gap: 8, alignItems: 'center' } },
1965
+ h('input', {
1966
+ type: 'number',
1967
+ min: 1,
1968
+ step: 1,
1969
+ value: customAmount,
1970
+ onChange: (event) => setCustomAmount(event.target.value),
1971
+ style: Object.assign(inputStyle(), { width: 110, flex: 'none' }),
1972
+ }),
1973
+ h(
1974
+ 'select',
1975
+ {
1976
+ value: customUnit,
1977
+ onChange: (event) => setCustomUnit(event.target.value),
1978
+ style: Object.assign(inputStyle(), { width: 110, flex: 'none' }),
1979
+ },
1980
+ h('option', { value: 'days' }, t('upload.unitDays')),
1981
+ h('option', { value: 'months' }, t('upload.unitMonths')),
1982
+ h('option', { value: 'years' }, t('upload.unitYears')),
1983
+ ),
1984
+ h(
1985
+ 'span',
1986
+ { style: { color: colors.muted, fontSize: type.micro, lineHeight: 1.5 } },
1987
+ t('upload.customRange'),
1988
+ ),
1989
+ )
1990
+ : null,
1991
+ ),
1992
+ ),
1993
+ ),
1994
+ quoteCard ? h('div', { style: { height: 12 } }) : null,
1995
+ quoteCard,
1996
+ h(
1997
+ 'div',
1998
+ { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginTop: 12 } },
1999
+ h(
2000
+ Button,
2001
+ {
2002
+ primary: true,
2003
+ onClick: start,
2004
+ disabled:
2005
+ !configured ||
2006
+ needsPin ||
2007
+ files.length === 0 ||
2008
+ uploading ||
2009
+ overLimit ||
2010
+ !customValid ||
2011
+ (quote ? !quote.budget.ok : false),
2012
+ },
2013
+ uploading ? t('upload.starting') : t('upload.start'),
2014
+ ),
2015
+ quote && !quote.budget.ok
2016
+ ? h('span', { style: { fontSize: 12, color: colors.error, lineHeight: 1.5 } }, t('upload.budgetBlocked'))
2017
+ : null,
2018
+ notice
2019
+ ? h(
2020
+ 'span',
2021
+ {
2022
+ style: {
2023
+ fontSize: 12,
2024
+ lineHeight: 1.5,
2025
+ color: notice.tone === 'error' ? colors.error : notice.tone === 'info' ? colors.muted : colors.success,
2026
+ },
2027
+ },
2028
+ notice.text,
2029
+ )
2030
+ : null,
2031
+ ),
2032
+ ),
2033
+ h(TaskList, { t, tasks, busy, taskError, act, onRefund }),
2034
+ );
2035
+ }
2036
+
2037
+ function TaskList({ t, tasks, busy, taskError, act, onRefund }) {
2038
+ return h(
2039
+ Card,
2040
+ null,
2041
+ h(SectionTitle, { t }, t('task.title'), tasks.length > 0 ? h(Badge, { text: String(tasks.length) }) : null),
2042
+ taskError ? h('div', { style: { color: colors.error, fontSize: type.callout, marginBottom: 10 } }, taskError.message) : null,
2043
+ tasks.length === 0
2044
+ ? h('div', { style: { color: colors.muted, fontSize: type.callout } }, t('task.empty'))
2045
+ : h(
2046
+ 'div',
2047
+ { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
2048
+ tasks.map((task) => h(TaskRow, { key: task.id, task, t, onAction: act, onRefund, busy })),
2049
+ ),
2050
+ );
2051
+ }
2052
+
2053
+ const INTENTS_CACHE = 'falari-harness/intents-cache';
2054
+
2055
+ /** The cached list belongs to the master account the status card last saw. */
2056
+ function intentsCacheOwner() {
2057
+ const status = readStatusCache();
2058
+ return status && status.owner ? String(status.owner).toLowerCase() : '';
2059
+ }
2060
+
2061
+ function readIntentsCache() {
2062
+ try {
2063
+ const raw = window.localStorage.getItem(INTENTS_CACHE);
2064
+ const parsed = raw ? JSON.parse(raw) : null;
2065
+ if (!parsed || !Array.isArray(parsed.rows)) return null;
2066
+ // A different key or network must not flash another account's files.
2067
+ if ((parsed.owner || '') !== intentsCacheOwner()) return null;
2068
+ return parsed;
2069
+ } catch {
2070
+ return null;
2071
+ }
2072
+ }
2073
+
2074
+ function writeIntentsCache(rows) {
2075
+ try {
2076
+ window.localStorage.setItem(INTENTS_CACHE, JSON.stringify({ at: Date.now(), owner: intentsCacheOwner(), rows }));
2077
+ } catch {
2078
+ // private mode or a full quota only costs the warm start
2079
+ }
2080
+ }
2081
+
2082
+ /**
2083
+ * The list paints from the last snapshot in localStorage, then replaces it
2084
+ * with the chain's answer; the first open in a profile paints skeletons.
2085
+ */
2086
+ function useIntents() {
2087
+ const cached = useRef(readIntentsCache());
2088
+ const [intents, setIntents] = useState(() => (cached.current ? cached.current.rows : []));
2089
+ const [cachedAt, setCachedAt] = useState(() => (cached.current ? cached.current.at : 0));
2090
+ const [loading, setLoading] = useState(true);
2091
+ const [error, setError] = useState(null);
2092
+ const load = useCallback(async () => {
2093
+ setLoading(true);
2094
+ try {
2095
+ const rows = await api('/intents?limit=100');
2096
+ setIntents(rows);
2097
+ setCachedAt(Date.now());
2098
+ writeIntentsCache(rows);
2099
+ setError(null);
2100
+ } catch (failure) {
2101
+ setError(failure);
2102
+ } finally {
2103
+ setLoading(false);
2104
+ }
2105
+ }, []);
2106
+ useEffect(() => {
2107
+ load();
2108
+ }, [load]);
2109
+ return { intents, loading, error, reload: load, cachedAt };
2110
+ }
2111
+
2112
+ /**
2113
+ * A balance to four decimals. It floors rather than rounds, so the card never
2114
+ * claims more than the account actually holds; the full precision is in the
2115
+ * tooltip and in `balanceWei` for anything that needs to calculate.
2116
+ */
2117
+ /** Custom term arithmetic: days are calendar days, months 30, years 365. */
2118
+ function durationSecondsFrom(amount, unit) {
2119
+ const perUnit = { days: 86400, months: 30 * 86400, years: 365 * 86400 };
2120
+ const value = Math.max(0, Math.floor(Number(amount) || 0));
2121
+ return value * (perUnit[unit] || 86400);
2122
+ }
2123
+
2124
+ /** A minute count in words: 0 = never, 60 = "1 h", 90 = "1 h 30 min". */
2125
+ function lockIdleLabel(minutes, t) {
2126
+ const value = Math.max(0, Math.round(Number(minutes) || 0));
2127
+ if (value === 0) return t('settings.lockNever');
2128
+ const days = Math.floor(value / (24 * 60));
2129
+ const hours = Math.floor((value % (24 * 60)) / 60);
2130
+ const mins = value % 60;
2131
+ const unit = (n, key) => t(key).replace('{n}', String(n));
2132
+ const parts = [];
2133
+ if (days) parts.push(unit(days, 'settings.daysUnit'));
2134
+ if (hours) parts.push(unit(hours, 'settings.hoursUnit'));
2135
+ if (mins) parts.push(unit(mins, 'settings.minsUnit'));
2136
+ return parts.join(' ');
2137
+ }
2138
+
2139
+ function formatBalance(wei, fallback) {
2140
+ let value;
2141
+ try {
2142
+ value = wei === null || wei === undefined || wei === '' ? null : BigInt(String(wei));
2143
+ } catch {
2144
+ value = null;
2145
+ }
2146
+ if (value === null) {
2147
+ // Older Host versions only send the formatted string ("12.5 FAR").
2148
+ const text = String(fallback ?? '').trim();
2149
+ if (!text) return '—';
2150
+ const match = text.match(/^(-?\d+)(?:\.(\d+))?/);
2151
+ if (!match) return text;
2152
+ const fraction = (match[2] || '').slice(0, 4).replace(/0+$/, '');
2153
+ return fraction ? match[1] + '.' + fraction + ' FAR' : match[1] + ' FAR';
2154
+ }
2155
+ const negative = value < 0n;
2156
+ if (negative) value = -value;
2157
+ const base = 10n ** 18n;
2158
+ const whole = value / base;
2159
+ const fraction = (value % base).toString().padStart(18, '0').slice(0, 4).replace(/0+$/, '');
2160
+ return (negative ? '-' : '') + whole.toString() + (fraction ? '.' + fraction : '') + ' FAR';
2161
+ }
2162
+
2163
+ /** `intent_` plus a shortened hash: enough to recognise, small enough to read. */
2164
+ function shortIntent(intentId) {
2165
+ const text = String(intentId || '');
2166
+ if (!text) return '';
2167
+ if (text.length <= 24) return text;
2168
+ return text.slice(0, 17) + '…' + text.slice(-6);
2169
+ }
2170
+
2171
+ function formatDate(unixSeconds) {
2172
+ const value = Number(unixSeconds) || 0;
2173
+ if (!value) return '—';
2174
+ return new Date(value * 1000).toLocaleString();
2175
+ }
2176
+
2177
+ const SKELETON_STYLE = '@keyframes falari-skeleton{0%{opacity:.35}50%{opacity:.85}100%{opacity:.35}}';
2178
+
2179
+ /** Placeholder rows for the first open, before any cache exists. */
2180
+ function SkeletonRows({ t, columns }) {
2181
+ const widths = [['72%', '54%'], ['48%'], ['60%'], ['64%'], ['70%']];
2182
+ return h(
2183
+ 'div',
2184
+ { style: { display: 'flex', flexDirection: 'column', gap: 2 } },
2185
+ h('style', null, SKELETON_STYLE),
2186
+ [0, 1, 2, 3, 4].map((row) =>
2187
+ h(
2188
+ 'div',
2189
+ {
2190
+ key: 'skeleton-' + row,
2191
+ style: {
2192
+ display: 'grid',
2193
+ gridTemplateColumns: columns,
2194
+ gap: 10,
2195
+ alignItems: 'center',
2196
+ padding: '12px 0',
2197
+ borderTop: row === 0 ? 'none' : '1px solid ' + colors.border,
2198
+ },
2199
+ },
2200
+ widths.map((variants, cell) =>
2201
+ h(
2202
+ 'div',
2203
+ { key: 'cell-' + cell, style: { minWidth: 0, display: 'flex', flexDirection: 'column', gap: 5 } },
2204
+ variants.map((width, line) =>
2205
+ h('div', {
2206
+ key: 'line-' + line,
2207
+ style: {
2208
+ height: line === 0 ? 10 : 8,
2209
+ width,
2210
+ borderRadius: 4,
2211
+ background: colors.border,
2212
+ animation: 'falari-skeleton 1.4s ease-in-out infinite',
2213
+ animationDelay: (row * 90 + cell * 60 + line * 40) + 'ms',
2214
+ },
2215
+ }),
2216
+ ),
2217
+ ),
2218
+ ),
2219
+ ),
2220
+ ),
2221
+ h('div', { style: { color: colors.muted, fontSize: type.micro, paddingTop: 10 } }, t('data.loading')),
2222
+ );
2223
+ }
2224
+
2225
+ const GRID_COLUMNS = 'minmax(0,2.2fr) 84px 84px 150px 150px';
2226
+
2227
+ function DataTab({ t, onRefund, tasks, busy, taskError, act, online }) {
2228
+ const { intents, loading, error, reload, cachedAt } = useIntents();
2229
+ const [query, setQuery] = useState('');
2230
+ const [privacy, setPrivacy] = useState('all');
2231
+ const [busyId, setBusyId] = useState('');
2232
+ const [copied, setCopied] = useState('');
2233
+ const [notice, setNotice] = useState(null);
2234
+
2235
+ const rows = useMemo(() => {
2236
+ const needle = query.trim().toLowerCase();
2237
+ return intents.filter((row) => {
2238
+ if (privacy !== 'all' && row.privacy !== privacy) return false;
2239
+ if (!needle) return true;
2240
+ return String(row.fileName || '').toLowerCase().includes(needle) || String(row.intentId || '').toLowerCase().includes(needle);
2241
+ });
2242
+ }, [intents, query, privacy]);
2243
+
2244
+ const download = async (intentId) => {
2245
+ setBusyId(intentId);
2246
+ setNotice(null);
2247
+ try {
2248
+ await api('/downloads', { method: 'POST', body: JSON.stringify({ intentId }) });
2249
+ setNotice({ tone: 'success', text: t('common.started') + ' ' + intentId });
2250
+ } catch (failure) {
2251
+ setNotice({ tone: 'error', text: failure.message });
2252
+ } finally {
2253
+ setBusyId('');
2254
+ }
2255
+ };
2256
+
2257
+ const copy = async (intentId) => {
2258
+ if (await copyText(intentId)) {
2259
+ setCopied(intentId);
2260
+ setTimeout(() => setCopied(''), 1600);
2261
+ }
2262
+ };
2263
+
2264
+ const cell = (text, style) => h('div', { style: Object.assign({ minWidth: 0, overflowWrap: 'anywhere' }, style) }, text);
2265
+
2266
+ return h(
2267
+ 'div',
2268
+ { style: { display: 'flex', flexDirection: 'column', gap: 14 } },
2269
+ h(
2270
+ Card,
2271
+ null,
2272
+ h(
2273
+ 'div',
2274
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
2275
+ h('input', {
2276
+ value: query,
2277
+ placeholder: t('data.searchPlaceholder'),
2278
+ onChange: (event) => setQuery(event.target.value),
2279
+ style: Object.assign(inputStyle(), { flex: '1 1 240px', width: 'auto' }),
2280
+ }),
2281
+ h(
2282
+ 'select',
2283
+ { value: privacy, onChange: (event) => setPrivacy(event.target.value), style: Object.assign(inputStyle(), { width: 'auto', minWidth: 120 }) },
2284
+ h('option', { value: 'all' }, t('data.privacyAll')),
2285
+ h('option', { value: 'public' }, t('data.privacyPublic')),
2286
+ h('option', { value: 'private' }, t('data.privacyPrivate')),
2287
+ ),
2288
+ h(Button, { onClick: reload, disabled: loading }, loading ? t('data.refreshing') : t('data.refresh')),
2289
+ cachedAt && loading && intents.length > 0
2290
+ ? h('span', { style: { color: colors.muted, fontSize: type.micro } }, t('data.cachedHint'))
2291
+ : null,
2292
+ ),
2293
+ notice
2294
+ ? h('div', { style: { marginTop: 10, fontSize: 12, color: notice.tone === 'error' ? colors.error : colors.success, overflowWrap: 'anywhere' } }, notice.text)
2295
+ : null,
2296
+ ),
2297
+ h(
2298
+ Card,
2299
+ null,
2300
+ error
2301
+ ? h('div', { style: { color: colors.error, fontSize: type.callout } }, error.message || t('data.loadFailed'))
2302
+ : loading && intents.length === 0
2303
+ ? h(SkeletonRows, { t, columns: GRID_COLUMNS })
2304
+ : rows.length === 0
2305
+ ? h('div', { style: { color: colors.muted, fontSize: type.callout } }, t('data.empty'))
2306
+ : h(
2307
+ 'div',
2308
+ { style: { display: 'flex', flexDirection: 'column', gap: 2 } },
2309
+ h(
2310
+ 'div',
2311
+ {
2312
+ style: {
2313
+ display: 'grid',
2314
+ gridTemplateColumns: GRID_COLUMNS,
2315
+ gap: 10,
2316
+ padding: '0 0 10px',
2317
+ color: colors.muted,
2318
+ fontSize: type.kicker,
2319
+ fontWeight: 700,
2320
+ letterSpacing: '0.07em',
2321
+ textTransform: 'uppercase',
2322
+ },
2323
+ },
2324
+ h('div', null, t('data.file')),
2325
+ h('div', null, t('data.size')),
2326
+ h('div', null, t('data.privacy')),
2327
+ h('div', null, t('data.created')),
2328
+ h('div', null, ''),
2329
+ ),
2330
+ rows.map((row) =>
2331
+ h(
2332
+ 'div',
2333
+ {
2334
+ key: row.intentId,
2335
+ style: {
2336
+ display: 'grid',
2337
+ gridTemplateColumns: GRID_COLUMNS,
2338
+ gap: 10,
2339
+ alignItems: 'center',
2340
+ padding: '11px 0',
2341
+ borderTop: '1px solid ' + colors.divider,
2342
+ fontSize: type.callout,
2343
+ },
2344
+ },
2345
+ h(
2346
+ 'div',
2347
+ { style: { minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 } },
2348
+ h(
2349
+ 'div',
2350
+ { style: { display: 'flex', gap: 6, alignItems: 'baseline', flexWrap: 'wrap', minWidth: 0 } },
2351
+ h(
2352
+ 'span',
2353
+ { style: { fontWeight: 700, overflowWrap: 'anywhere', letterSpacing: '-0.01em' } },
2354
+ row.fileName || t('data.unnamed'),
2355
+ ),
2356
+ h(
2357
+ 'code',
2358
+ { title: row.intentId, style: { color: colors.muted, fontSize: type.micro, overflowWrap: 'anywhere', fontVariantNumeric: 'tabular-nums' } },
2359
+ shortIntent(row.intentId),
2360
+ ),
2361
+ ),
2362
+ ),
2363
+ cell(formatBytes(row.size), { color: colors.text, fontVariantNumeric: 'tabular-nums', fontWeight: 550 }),
2364
+ h('div', null, h(Badge, { text: row.privacy === 'private' ? t('data.privacyPrivate') : t('data.privacyPublic') })),
2365
+ cell(formatDate(row.createdAtUnix), { color: colors.muted, fontVariantNumeric: 'tabular-nums' }),
2366
+ h(
2367
+ 'div',
2368
+ { style: { display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' } },
2369
+ h(Button, { subtle: true, onClick: () => copy(row.intentId) }, copied === row.intentId ? t('common.copied') : t('data.copyId')),
2370
+ h(Button, { onClick: () => download(row.intentId), disabled: busyId === row.intentId || !online },
2371
+ busyId === row.intentId ? t('common.loading') : t('data.download')),
2372
+ ),
2373
+ ),
2374
+ ),
2375
+ ),
2376
+ ),
2377
+ h(TaskList, { t, tasks, busy, taskError, act, onRefund }),
2378
+ );
2379
+ }
2380
+
2381
+ function DownloadTab({ t, onRefund, config, tasks, busy, taskError, act, online }) {
2382
+ const [intentId, setIntentId] = useState('');
2383
+ const [dataKey, setDataKey] = useState('');
2384
+ const [outPath, setOutPath] = useState('');
2385
+ const [starting, setStarting] = useState(false);
2386
+ const [error, setError] = useState(null);
2387
+ const [startedId, setStartedId] = useState('');
2388
+
2389
+ const start = async () => {
2390
+ if (!intentId.trim()) {
2391
+ setError(new Error(t('download.noIntent')));
2392
+ return;
2393
+ }
2394
+ setStarting(true);
2395
+ setError(null);
2396
+ try {
2397
+ const task = await api('/downloads', {
2398
+ method: 'POST',
2399
+ body: JSON.stringify({
2400
+ intentId: intentId.trim(),
2401
+ ...(dataKey.trim() ? { dataKey: dataKey.trim() } : {}),
2402
+ ...(outPath.trim() ? { outPath: outPath.trim() } : {}),
2403
+ }),
2404
+ });
2405
+ setStartedId(task.id);
2406
+ } catch (failure) {
2407
+ setError(failure);
2408
+ } finally {
2409
+ setStarting(false);
2410
+ }
2411
+ };
2412
+
2413
+ const latest = (startedId && tasks.find((task) => task.id === startedId)) || null;
2414
+ const dir = config && config.downloadDir ? config.downloadDir : '';
2415
+
2416
+ return h(
2417
+ 'div',
2418
+ { style: { display: 'flex', flexDirection: 'column', gap: 14 } },
2419
+ h(
2420
+ Card,
2421
+ null,
2422
+ h(
2423
+ 'div',
2424
+ { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 } },
2425
+ h(
2426
+ Field,
2427
+ { label: t('download.intentLabel') },
2428
+ h('input', {
2429
+ value: intentId,
2430
+ placeholder: t('download.intentPlaceholder'),
2431
+ spellCheck: false,
2432
+ onChange: (event) => setIntentId(event.target.value),
2433
+ onKeyDown: (event) => {
2434
+ if (event.key === 'Enter') {
2435
+ event.preventDefault();
2436
+ start();
2437
+ }
2438
+ },
2439
+ style: inputStyle(),
2440
+ }),
2441
+ ),
2442
+ h(
2443
+ Field,
2444
+ { label: t('download.dataKeyLabel'), hint: t('download.dataKeyHint') },
2445
+ h('input', {
2446
+ value: dataKey,
2447
+ type: 'password',
2448
+ placeholder: t('download.dataKeyPlaceholder'),
2449
+ autoComplete: 'off',
2450
+ spellCheck: false,
2451
+ onChange: (event) => setDataKey(event.target.value),
2452
+ style: inputStyle(),
2453
+ }),
2454
+ ),
2455
+ h(
2456
+ Field,
2457
+ { label: t('download.outLabel') },
2458
+ h('input', {
2459
+ value: outPath,
2460
+ placeholder: t('download.outPlaceholder'),
2461
+ onChange: (event) => setOutPath(event.target.value),
2462
+ style: inputStyle(),
2463
+ }),
2464
+ ),
2465
+ ),
2466
+ dir ? h('div', { style: { marginTop: 10, color: colors.muted, fontSize: type.micro, overflowWrap: 'anywhere' } }, t('download.dir') + ': ' + dir) : null,
2467
+ h(
2468
+ 'div',
2469
+ { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginTop: 12 } },
2470
+ h(Button, { primary: true, onClick: start, disabled: starting || !online }, starting ? t('common.loading') : t('download.start')),
2471
+ ),
2472
+ error
2473
+ ? h(
2474
+ 'div',
2475
+ { style: { marginTop: 10, color: colors.error, fontSize: type.callout, lineHeight: 1.6 } },
2476
+ h('div', { style: { fontWeight: 600 } }, error.message || t('download.failed')),
2477
+ error.hint ? h('div', { style: { marginTop: 3, color: colors.muted } }, error.hint) : null,
2478
+ )
2479
+ : null,
2480
+ ),
2481
+ latest
2482
+ ? h(
2483
+ Card,
2484
+ { style: { background: colors.surface2 } },
2485
+ h('div', { style: { fontSize: type.headline, fontWeight: 700, marginBottom: 10 } }, t('download.result')),
2486
+ h(TaskRow, { task: latest, t, onAction: act, busy }),
2487
+ )
2488
+ : null,
2489
+ h(TaskList, { t, tasks, busy, taskError, act, onRefund }),
2490
+ );
2491
+ }
2492
+
2493
+ function Tabs({ tab, onChange, t }) {
2494
+ const items = [
2495
+ ['upload', 'tab.upload'],
2496
+ ['data', 'tab.data'],
2497
+ ['download', 'tab.download'],
2498
+ ['settings', 'tab.settings'],
2499
+ ];
2500
+ // A floating segmented control: the four labels sit on one glass pill and
2501
+ // the active one gets a raised inner pill, so the row reads as a control
2502
+ // rather than as a page divider.
2503
+ return h(
2504
+ 'div',
2505
+ {
2506
+ role: 'tablist',
2507
+ style: {
2508
+ display: 'flex',
2509
+ gap: 4,
2510
+ padding: 4,
2511
+ borderRadius: radius.block,
2512
+ border: '1px solid ' + colors.hairline,
2513
+ background: colors.surfaceSoft,
2514
+ backdropFilter: 'blur(16px)',
2515
+ WebkitBackdropFilter: 'blur(16px)',
2516
+ alignSelf: 'flex-start',
2517
+ flexWrap: 'wrap',
2518
+ },
2519
+ },
2520
+ items.map(([value, key]) =>
2521
+ h(
2522
+ 'button',
2523
+ {
2524
+ key: value,
2525
+ type: 'button',
2526
+ role: 'tab',
2527
+ 'aria-selected': tab === value,
2528
+ onClick: () => onChange(value),
2529
+ style: {
2530
+ height: 32,
2531
+ padding: '0 16px',
2532
+ borderRadius: radius.field,
2533
+ border: '1px solid ' + (tab === value ? colors.hairline : 'transparent'),
2534
+ background: tab === value ? colors.surface : 'transparent',
2535
+ boxShadow: tab === value ? '0 2px 8px rgba(8,20,40,0.08), inset 0 1px 0 ' + colors.glassEdge : 'none',
2536
+ color: tab === value ? colors.text : colors.muted,
2537
+ fontSize: type.callout,
2538
+ fontWeight: tab === value ? 700 : 550,
2539
+ letterSpacing: '0.01em',
2540
+ cursor: 'pointer',
2541
+ transition: 'background 160ms ease, box-shadow 160ms ease, color 160ms ease',
2542
+ },
2543
+ },
2544
+ t(key),
2545
+ ),
2546
+ ),
2547
+ );
2548
+ }
2549
+
2550
+ // ── panel ────────────────────────────────────────────────────────────
2551
+
2552
+ function Panel({ t }) {
2553
+ const { status, refreshing, error: statusError, reload: reloadStatus } = useStatus();
2554
+ const [config, setConfig] = useState(null);
2555
+ const [tab, setTab] = useState('upload');
2556
+ const { tasks, busy, error: taskError, act, reload: reloadTasks } = useTasks();
2557
+ const [refundTarget, setRefundTarget] = useState(null);
2558
+ const online = Boolean(status && status.configured && !status.error);
2559
+
2560
+ const loadConfig = useCallback(async () => {
2561
+ try {
2562
+ setConfig(await api('/config'));
2563
+ } catch {
2564
+ // the panel keeps working without the defaults
2565
+ }
2566
+ }, []);
2567
+
2568
+ useEffect(() => {
2569
+ loadConfig();
2570
+ }, [loadConfig]);
2571
+
2572
+ const refresh = useCallback(() => {
2573
+ reloadStatus({ refresh: true });
2574
+ loadConfig();
2575
+ }, [reloadStatus, loadConfig]);
2576
+
2577
+ return h(
2578
+ 'div',
2579
+ {
2580
+ style: {
2581
+ position: 'relative',
2582
+ height: '100%',
2583
+ overflow: 'auto',
2584
+ background: colors.base,
2585
+ color: colors.text,
2586
+ padding: space.page + 'px ' + (space.page + 4) + 'px ' + (space.page + 18) + 'px',
2587
+ boxSizing: 'border-box',
2588
+ fontFamily: 'var(--ds-font-family, inherit)',
2589
+ },
2590
+ },
2591
+ h(Backdrop),
2592
+ h(
2593
+ 'div',
2594
+ {
2595
+ style: {
2596
+ position: 'relative',
2597
+ zIndex: 1,
2598
+ maxWidth: 1080,
2599
+ margin: '0 auto',
2600
+ display: 'flex',
2601
+ flexDirection: 'column',
2602
+ gap: space.section,
2603
+ },
2604
+ },
2605
+ h(
2606
+ 'div',
2607
+ {
2608
+ style: {
2609
+ display: 'flex',
2610
+ flexDirection: 'row',
2611
+ alignItems: 'center',
2612
+ gap: 14,
2613
+ padding: '14px 16px',
2614
+ borderRadius: radius.card,
2615
+ border: '1px solid ' + colors.hairline,
2616
+ background: colors.surface,
2617
+ backdropFilter: 'blur(20px) saturate(1.1)',
2618
+ WebkitBackdropFilter: 'blur(20px) saturate(1.1)',
2619
+ boxShadow: 'inset 0 1px 0 ' + colors.glassEdge + ', 0 10px 30px rgba(8, 20, 40, 0.06)',
2620
+ },
2621
+ },
2622
+ h(FalariLogoImage, { size: 46 }),
2623
+ h(
2624
+ 'div',
2625
+ { style: { minWidth: 0, flex: 1, overflow: 'hidden' } },
2626
+ h(
2627
+ 'div',
2628
+ { style: { display: 'flex', alignItems: 'baseline', gap: 9, flexWrap: 'wrap' } },
2629
+ h(
2630
+ 'h1',
2631
+ {
2632
+ style: {
2633
+ margin: 0,
2634
+ fontSize: type.display,
2635
+ lineHeight: 1.15,
2636
+ fontWeight: 750,
2637
+ letterSpacing: '-0.02em',
2638
+ color: colors.text,
2639
+ },
2640
+ },
2641
+ t('panel.title'),
2642
+ ),
2643
+ h(Badge, { text: t('panel.badge'), tone: 'brand' }),
2644
+ config ? h(Badge, { text: config.network }) : null,
2645
+ ),
2646
+ h(
2647
+ 'p',
2648
+ {
2649
+ style: {
2650
+ margin: '4px 0 0',
2651
+ color: colors.muted,
2652
+ fontSize: type.caption,
2653
+ lineHeight: 1.5,
2654
+ whiteSpace: 'nowrap',
2655
+ overflow: 'hidden',
2656
+ textOverflow: 'ellipsis',
2657
+ },
2658
+ },
2659
+ t('panel.subtitle'),
2660
+ ),
2661
+ ),
2662
+ h(Button, { onClick: refresh, disabled: refreshing }, refreshing ? t('common.loading') : t('common.refresh')),
2663
+ ),
2664
+ statusError
2665
+ ? h(Card, { style: { borderColor: colors.error } }, h('div', { style: { color: colors.error, fontSize: type.callout } }, statusError.message))
2666
+ : null,
2667
+ h(StatusCard, { status, t, compact: true, refreshing }),
2668
+ h(Tabs, { tab, onChange: setTab, t }),
2669
+ tab === 'upload'
2670
+ ? h(UploadTab, {
2671
+ t,
2672
+ onRefund: setRefundTarget,
2673
+ onOpenSettings: () => setTab('settings'),
2674
+ reloadStatus,
2675
+ status,
2676
+ config,
2677
+ tasks,
2678
+ busy,
2679
+ taskError,
2680
+ act,
2681
+ })
2682
+ : tab === 'data'
2683
+ ? h(DataTab, { t, onRefund: setRefundTarget, tasks, busy, taskError, act, online })
2684
+ : tab === 'settings'
2685
+ ? h(SettingsView, { t })
2686
+ : h(DownloadTab, { t, onRefund: setRefundTarget, config, tasks, busy, taskError, act, online }),
2687
+ h(
2688
+ 'div',
2689
+ { style: { color: colors.muted, fontSize: type.micro, lineHeight: 1.6, paddingTop: space.tight, textAlign: 'center' } },
2690
+ t('panel.footer'),
2691
+ ),
2692
+ ),
2693
+ refundTarget
2694
+ ? h(RefundDialog, {
2695
+ t,
2696
+ taskId: refundTarget,
2697
+ onClose: () => setRefundTarget(null),
2698
+ onChanged: () => {
2699
+ reloadTasks();
2700
+ },
2701
+ })
2702
+ : null,
2703
+ );
2704
+ }
2705
+
2706
+ // ── settings ─────────────────────────────────────────────────────────
2707
+
2708
+ function Settings({ t }) {
2709
+ const { status, refreshing: statusLoading, error: statusError, reload: reloadStatus } = useStatus();
2710
+ const [config, setConfig] = useState(null);
2711
+ const [keyInput, setKeyInput] = useState('');
2712
+ const [pin, setPin] = useState('');
2713
+ const [pinConfirm, setPinConfirm] = useState('');
2714
+ const [saving, setSaving] = useState(false);
2715
+ const [securing, setSecuring] = useState(false);
2716
+ const [unlocking, setUnlocking] = useState(false);
2717
+ const [locking, setLocking] = useState(false);
2718
+ const [clearing, setClearing] = useState(false);
2719
+ const [error, setError] = useState(null);
2720
+ const [notice, setNotice] = useState('');
2721
+ const [form, setForm] = useState(null);
2722
+ const [savingConfig, setSavingConfig] = useState(false);
2723
+ const [configNotice, setConfigNotice] = useState('');
2724
+ const [configError, setConfigError] = useState(null);
2725
+
2726
+ const load = useCallback(async () => {
2727
+ try {
2728
+ const nextConfig = await api('/config');
2729
+ setConfig(nextConfig);
2730
+ setForm({
2731
+ network: nextConfig.network || 'testnet',
2732
+ defaultPrivacy: nextConfig.defaultPrivacy || 'public',
2733
+ defaultDuration: nextConfig.defaultDuration || 'permanent',
2734
+ maxSizeMiB: String(Math.max(1, Math.round((nextConfig.maxFileSizeBytes || 0) / (1024 * 1024)))),
2735
+ downloadDir: nextConfig.downloadDir || '',
2736
+ allowUploadOutsideWorkspace: Boolean(nextConfig.allowUploadOutsideWorkspace),
2737
+ });
2738
+ } catch (failure) {
2739
+ setError(failure);
2740
+ }
2741
+ }, []);
2742
+
2743
+ useEffect(() => {
2744
+ load();
2745
+ }, [load]);
2746
+
2747
+ const save = async () => {
2748
+ if (!keyInput.trim() || saving) return;
2749
+ setError(null);
2750
+ setNotice('');
2751
+ if (pin !== pinConfirm) {
2752
+ setError(new Error(t('settings.pinMismatch')));
2753
+ return;
2754
+ }
2755
+ setSaving(true);
2756
+ try {
2757
+ const next = await api('/key', { method: 'POST', body: JSON.stringify({ key: keyInput, pin }) });
2758
+ writeStatusCache(next);
2759
+ reloadStatus();
2760
+ setKeyInput('');
2761
+ setPin('');
2762
+ setPinConfirm('');
2763
+ setNotice(t('settings.saved'));
2764
+ } catch (failure) {
2765
+ setError(failure);
2766
+ } finally {
2767
+ setSaving(false);
2768
+ }
2769
+ };
2770
+
2771
+ const secureExisting = async () => {
2772
+ if (securing) return;
2773
+ setError(null);
2774
+ setNotice('');
2775
+ if (pin !== pinConfirm) {
2776
+ setError(new Error(t('settings.pinMismatch')));
2777
+ return;
2778
+ }
2779
+ setSecuring(true);
2780
+ try {
2781
+ await api('/key/secure', { method: 'POST', body: JSON.stringify({ pin }) });
2782
+ reloadStatus();
2783
+ setPin('');
2784
+ setPinConfirm('');
2785
+ setNotice(t('settings.secured'));
2786
+ } catch (failure) {
2787
+ setError(failure);
2788
+ } finally {
2789
+ setSecuring(false);
2790
+ }
2791
+ };
2792
+
2793
+ const unlock = async () => {
2794
+ if (!pin || unlocking) return;
2795
+ setError(null);
2796
+ setNotice('');
2797
+ setUnlocking(true);
2798
+ try {
2799
+ const next = await api('/key/unlock', { method: 'POST', body: JSON.stringify({ pin }) });
2800
+ writeStatusCache(next);
2801
+ reloadStatus();
2802
+ setPin('');
2803
+ setNotice(t('settings.unlockedNotice'));
2804
+ } catch (failure) {
2805
+ setError(failure);
2806
+ } finally {
2807
+ setUnlocking(false);
2808
+ }
2809
+ };
2810
+
2811
+ const lockKey = async () => {
2812
+ if (locking) return;
2813
+ setLocking(true);
2814
+ setError(null);
2815
+ setNotice('');
2816
+ try {
2817
+ try {
2818
+ await api('/key/lock', { method: 'POST', body: '{}' });
2819
+ } catch (failure) {
2820
+ if (failure.code !== 'upload_in_progress') throw failure;
2821
+ if (typeof window.confirm === 'function' && !window.confirm(t('settings.confirmLockDuringUpload'))) return;
2822
+ await api('/key/lock', { method: 'POST', body: JSON.stringify({ force: true }) });
2823
+ }
2824
+ writeStatusCache(null);
2825
+ reloadStatus();
2826
+ setNotice(t('settings.lockedNotice'));
2827
+ } catch (failure) {
2828
+ setError(failure);
2829
+ } finally {
2830
+ setLocking(false);
2831
+ }
2832
+ };
2833
+
2834
+ const clear = async () => {
2835
+ if (clearing) return;
2836
+ if (typeof window.confirm === 'function' && !window.confirm(t('settings.confirmClear'))) return;
2837
+ setClearing(true);
2838
+ setError(null);
2839
+ setNotice('');
2840
+ try {
2841
+ const next = await api('/key', { method: 'DELETE' });
2842
+ writeStatusCache(next);
2843
+ reloadStatus();
2844
+ setKeyInput('');
2845
+ setNotice(t('settings.cleared'));
2846
+ } catch (failure) {
2847
+ setError(failure);
2848
+ } finally {
2849
+ setClearing(false);
2850
+ }
2851
+ };
2852
+
2853
+ const update = (field, value) => setForm((current) => (current ? { ...current, [field]: value } : current));
2854
+
2855
+ const saveConfig = async () => {
2856
+ if (!form || savingConfig) return;
2857
+ setSavingConfig(true);
2858
+ setConfigError(null);
2859
+ setConfigNotice('');
2860
+ try {
2861
+ await api('/config', {
2862
+ method: 'POST',
2863
+ body: JSON.stringify({
2864
+ network: form.network,
2865
+ defaultPrivacy: form.defaultPrivacy,
2866
+ defaultDuration: form.defaultDuration,
2867
+ maxFileSizeBytes: Math.max(1, Math.round(Number(form.maxSizeMiB) || 0)) * 1024 * 1024,
2868
+ downloadDir: form.downloadDir.trim(),
2869
+ lockIdleMinutes: Math.max(0, Math.min(1440, Math.round(Number(form.lockIdleMinutes) || 0))),
2870
+ allowUploadOutsideWorkspace: Boolean(form.allowUploadOutsideWorkspace),
2871
+ }),
2872
+ });
2873
+ setConfigNotice(t('settings.configSaved'));
2874
+ } catch (failure) {
2875
+ setConfigError(failure);
2876
+ } finally {
2877
+ setSavingConfig(false);
2878
+ }
2879
+ };
2880
+
2881
+ const configured = Boolean(status && status.configured);
2882
+ const security = (status && status.security) || { mode: 'none', locked: false, minPinLength: 6, idleMinutes: 15, attemptsLeft: 5 };
2883
+ const pinMatches = pin.length >= (security.minPinLength || 6) && pin === pinConfirm;
2884
+
2885
+ return h(
2886
+ 'div',
2887
+ {
2888
+ style: {
2889
+ color: colors.text,
2890
+ padding: '4px 0 8px',
2891
+ boxSizing: 'border-box',
2892
+ fontFamily: 'var(--ds-font-family, inherit)',
2893
+ },
2894
+ },
2895
+ h(
2896
+ 'div',
2897
+ { style: { display: 'flex', flexDirection: 'column', gap: 16 } },
2898
+ h(
2899
+ Card,
2900
+ null,
2901
+ h(
2902
+ 'div',
2903
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 12 } },
2904
+ h('div', { style: { fontSize: type.title, fontWeight: 750, letterSpacing: '-0.015em' } }, t('settings.keySection')),
2905
+ security.mode === 'sealed'
2906
+ ? h(Badge, {
2907
+ text: security.locked ? t('settings.locked') : t('settings.unlocked'),
2908
+ tone: security.locked ? 'warning' : 'success',
2909
+ })
2910
+ : security.mode === 'legacy'
2911
+ ? h(Badge, { text: t('settings.plaintextBadge'), tone: 'error' })
2912
+ : null,
2913
+ ),
2914
+
2915
+ // 1. No key yet: paste it, choose a PIN, the Host encrypts it.
2916
+ security.mode === 'none'
2917
+ ? h(
2918
+ 'div',
2919
+ { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
2920
+ h('div', { style: { color: colors.muted, fontSize: 12, lineHeight: 1.55 } }, t('settings.pinIntro')),
2921
+ h('div', { style: { color: colors.muted, fontSize: type.micro, lineHeight: 1.5 } }, t('settings.keyHint')),
2922
+ h('textarea', {
2923
+ value: keyInput,
2924
+ placeholder: t('settings.keyPlaceholder'),
2925
+ onChange: (event) => setKeyInput(event.target.value),
2926
+ rows: 3,
2927
+ style: Object.assign(inputStyle(), { resize: 'vertical', fontFamily: 'var(--ds-font-family-mono, monospace)', fontSize: type.micro }),
2928
+ }),
2929
+ h(
2930
+ 'div',
2931
+ { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 } },
2932
+ h(
2933
+ Field,
2934
+ { label: t('settings.pin') },
2935
+ h('input', {
2936
+ type: 'password',
2937
+ value: pin,
2938
+ placeholder: t('settings.pinPlaceholder'),
2939
+ onChange: (event) => setPin(event.target.value),
2940
+ style: inputStyle(),
2941
+ autoComplete: 'new-password',
2942
+ }),
2943
+ ),
2944
+ h(
2945
+ Field,
2946
+ { label: t('settings.pinConfirm') },
2947
+ h('input', {
2948
+ type: 'password',
2949
+ value: pinConfirm,
2950
+ onChange: (event) => setPinConfirm(event.target.value),
2951
+ style: inputStyle(),
2952
+ autoComplete: 'new-password',
2953
+ }),
2954
+ ),
2955
+ ),
2956
+ h(
2957
+ 'div',
2958
+ { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
2959
+ h(
2960
+ Button,
2961
+ { primary: true, onClick: save, disabled: saving || !keyInput.trim() || pin.length < security.minPinLength },
2962
+ saving ? t('settings.saving') : t('settings.saveEncrypted'),
2963
+ ),
2964
+ ),
2965
+ )
2966
+ : null,
2967
+
2968
+ // 2. Legacy plaintext: the same key, re-sealed in place.
2969
+ security.mode === 'legacy'
2970
+ ? h(
2971
+ 'div',
2972
+ { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
2973
+ h('div', { style: { color: colors.warn, fontSize: type.callout, lineHeight: 1.6 } }, t('settings.plaintextWarning')),
2974
+ h(
2975
+ 'div',
2976
+ { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 } },
2977
+ h(
2978
+ Field,
2979
+ { label: t('settings.pin') },
2980
+ h('input', {
2981
+ type: 'password',
2982
+ value: pin,
2983
+ placeholder: t('settings.pinPlaceholder'),
2984
+ onChange: (event) => setPin(event.target.value),
2985
+ style: inputStyle(),
2986
+ autoComplete: 'new-password',
2987
+ }),
2988
+ ),
2989
+ h(
2990
+ Field,
2991
+ { label: t('settings.pinConfirm') },
2992
+ h('input', {
2993
+ type: 'password',
2994
+ value: pinConfirm,
2995
+ onChange: (event) => setPinConfirm(event.target.value),
2996
+ style: inputStyle(),
2997
+ autoComplete: 'new-password',
2998
+ }),
2999
+ ),
3000
+ ),
3001
+ h(
3002
+ 'div',
3003
+ { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
3004
+ h(
3005
+ Button,
3006
+ { primary: true, onClick: secureExisting, disabled: securing || pin.length < security.minPinLength },
3007
+ securing ? t('settings.securing') : t('settings.secureExisting'),
3008
+ ),
3009
+ ),
3010
+ )
3011
+ : null,
3012
+
3013
+ // 3. Sealed: unlock with the PIN, or lock it again.
3014
+ security.mode === 'sealed'
3015
+ ? h(
3016
+ 'div',
3017
+ { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
3018
+ h(
3019
+ 'div',
3020
+ { style: { color: colors.muted, fontSize: type.caption, lineHeight: 1.55 } },
3021
+ security.locked
3022
+ ? t('settings.lockedHint')
3023
+ : t('settings.unlockedHint') +
3024
+ ' · ' +
3025
+ (security.idleMinutes > 0
3026
+ ? t('settings.idleSuffix').replace('{n}', String(security.idleMinutes))
3027
+ : t('settings.idleNever')),
3028
+ ),
3029
+ security.uploadActive && !security.locked
3030
+ ? h(
3031
+ 'div',
3032
+ { style: { color: colors.warn, fontSize: type.micro, lineHeight: 1.55 } },
3033
+ t('settings.uploadActiveHint'),
3034
+ )
3035
+ : null,
3036
+ security.locked
3037
+ ? h(
3038
+ 'div',
3039
+ { style: { display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' } },
3040
+ h(
3041
+ Field,
3042
+ { label: t('settings.pin') },
3043
+ h('input', {
3044
+ type: 'password',
3045
+ value: pin,
3046
+ onChange: (event) => setPin(event.target.value),
3047
+ onKeyDown: (event) => {
3048
+ if (event.key === 'Enter') unlock();
3049
+ },
3050
+ style: Object.assign(inputStyle(), { width: 220 }),
3051
+ autoComplete: 'current-password',
3052
+ }),
3053
+ ),
3054
+ h(Button, { primary: true, onClick: unlock, disabled: unlocking || !pin }, unlocking ? t('settings.unlocking') : t('settings.unlock')),
3055
+ )
3056
+ : null,
3057
+ h(
3058
+ 'div',
3059
+ { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
3060
+ security.locked
3061
+ ? null
3062
+ : h(Button, { onClick: lockKey, disabled: locking }, locking ? t('settings.locking') : t('settings.lock')),
3063
+ h(Button, { onClick: clear, disabled: saving || clearing }, clearing ? t('settings.clearing') : t('settings.clear')),
3064
+ ),
3065
+ )
3066
+ : null,
3067
+
3068
+ notice ? h('div', { style: { marginTop: 10, color: colors.success, fontSize: type.callout } }, notice) : null,
3069
+ error
3070
+ ? h(
3071
+ 'div',
3072
+ { style: { marginTop: 10, color: colors.error, fontSize: type.callout, lineHeight: 1.6 } },
3073
+ h('div', { style: { fontWeight: 600 } }, error.message || t('common.error')),
3074
+ error.hint ? h('div', { style: { marginTop: 3, color: colors.muted } }, error.hint) : null,
3075
+ )
3076
+ : null,
3077
+ ),
3078
+ h(
3079
+ Card,
3080
+ null,
3081
+ h('div', { style: { fontSize: type.title, fontWeight: 750, letterSpacing: '-0.015em', marginBottom: 12 } }, t('status.title')),
3082
+ statusLoading && !status
3083
+ ? h('div', { style: { color: colors.muted, fontSize: type.callout } }, t('common.loading'))
3084
+ : h(StatusCard, { status, t, refreshing: statusLoading }),
3085
+ ),
3086
+ h(
3087
+ Card,
3088
+ null,
3089
+ h('div', { style: { fontSize: type.title, fontWeight: 750, letterSpacing: '-0.015em', marginBottom: 12 } }, t('settings.networkSection')),
3090
+ form
3091
+ ? h(
3092
+ 'div',
3093
+ { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 12 } },
3094
+ h(
3095
+ Field,
3096
+ { label: t('settings.network') },
3097
+ h(
3098
+ 'select',
3099
+ { value: form.network, onChange: (event) => update('network', event.target.value), style: inputStyle() },
3100
+ h('option', { value: 'testnet' }, t('settings.networkTestnet')),
3101
+ h('option', { value: 'mainnet' }, t('settings.networkMainnet')),
3102
+ ),
3103
+ ),
3104
+ h(
3105
+ Field,
3106
+ { label: t('settings.privacy') },
3107
+ h(
3108
+ 'select',
3109
+ { value: form.defaultPrivacy, onChange: (event) => update('defaultPrivacy', event.target.value), style: inputStyle() },
3110
+ h('option', { value: 'public' }, t('settings.public')),
3111
+ h('option', { value: 'private' }, t('settings.private')),
3112
+ ),
3113
+ ),
3114
+ h(
3115
+ Field,
3116
+ { label: t('settings.duration') },
3117
+ h(
3118
+ 'select',
3119
+ { value: form.defaultDuration, onChange: (event) => update('defaultDuration', event.target.value), style: inputStyle() },
3120
+ DURATIONS.map(([value, key]) => h('option', { key: value, value }, t(key))),
3121
+ ),
3122
+ ),
3123
+ h(
3124
+ Field,
3125
+ { label: t('settings.maxSize') },
3126
+ h('input', {
3127
+ type: 'number',
3128
+ min: 1,
3129
+ value: form.maxSizeMiB,
3130
+ onChange: (event) => update('maxSizeMiB', event.target.value),
3131
+ style: inputStyle(),
3132
+ }),
3133
+ ),
3134
+ h(
3135
+ Field,
3136
+ { label: t('settings.lockIdle'), hint: t('settings.lockIdleHint') },
3137
+ h(
3138
+ 'div',
3139
+ { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
3140
+ // Presets for the common answers, and a free field for anything else.
3141
+ h(
3142
+ 'div',
3143
+ { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
3144
+ [15, 30, 60, 120, 240, 0].map((minutes) =>
3145
+ h(
3146
+ 'button',
3147
+ {
3148
+ key: 'lock-' + minutes,
3149
+ type: 'button',
3150
+ onClick: () => update('lockIdleMinutes', String(minutes)),
3151
+ style: {
3152
+ height: 28,
3153
+ padding: '0 11px',
3154
+ borderRadius: 999,
3155
+ border: '1px solid ' + (String(form.lockIdleMinutes) === String(minutes) ? colors.brand : colors.hairline),
3156
+ background:
3157
+ String(form.lockIdleMinutes) === String(minutes) ? colors.brandSoft : 'transparent',
3158
+ color: String(form.lockIdleMinutes) === String(minutes) ? colors.brand : colors.text,
3159
+ fontSize: type.micro,
3160
+ fontWeight: 650,
3161
+ cursor: 'pointer',
3162
+ },
3163
+ },
3164
+ minutes === 0 ? t('settings.lockNeverShort') : lockIdleLabel(minutes, t),
3165
+ ),
3166
+ ),
3167
+ ),
3168
+ h(
3169
+ 'div',
3170
+ { style: { display: 'flex', gap: 8, alignItems: 'center' } },
3171
+ h('input', {
3172
+ type: 'number',
3173
+ min: 0,
3174
+ max: 10080,
3175
+ step: 1,
3176
+ value: form.lockIdleMinutes,
3177
+ onChange: (event) => update('lockIdleMinutes', event.target.value),
3178
+ style: Object.assign(inputStyle(), { width: 120, flex: 'none' }),
3179
+ }),
3180
+ h('span', { style: { color: colors.muted, fontSize: type.micro } }, t('settings.minutesUnit')),
3181
+ h(
3182
+ 'span',
3183
+ { style: { color: colors.brand, fontSize: type.micro, fontWeight: 650 } },
3184
+ lockIdleLabel(Math.max(0, Math.min(10080, Math.round(Number(form.lockIdleMinutes) || 0))), t),
3185
+ ),
3186
+ ),
3187
+ ),
3188
+ ),
3189
+ h(
3190
+ Field,
3191
+ { label: t('settings.downloadDir') },
3192
+ h('input', {
3193
+ value: form.downloadDir,
3194
+ placeholder: t('settings.downloadDirPlaceholder'),
3195
+ onChange: (event) => update('downloadDir', event.target.value),
3196
+ style: inputStyle(),
3197
+ }),
3198
+ ),
3199
+ h(
3200
+ Field,
3201
+ { label: t('settings.allowOutside') },
3202
+ h(
3203
+ 'select',
3204
+ {
3205
+ value: form.allowUploadOutsideWorkspace ? 'yes' : 'no',
3206
+ onChange: (event) => update('allowUploadOutsideWorkspace', event.target.value === 'yes'),
3207
+ style: inputStyle(),
3208
+ },
3209
+ h('option', { value: 'no' }, t('settings.off')),
3210
+ h('option', { value: 'yes' }, t('settings.on')),
3211
+ ),
3212
+ ),
3213
+ )
3214
+ : null,
3215
+ form
3216
+ ? h(
3217
+ 'div',
3218
+ { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginTop: 12 } },
3219
+ h(Button, { primary: true, onClick: saveConfig, disabled: savingConfig }, savingConfig ? t('common.loading') : t('settings.configSave')),
3220
+ configNotice ? h('span', { style: { fontSize: 12, color: colors.success } }, configNotice) : null,
3221
+ configError
3222
+ ? h('span', { style: { fontSize: 12, color: colors.error } }, configError.message || t('common.error'))
3223
+ : null,
3224
+ )
3225
+ : null,
3226
+ ),
3227
+ h('div', { style: { color: colors.muted, fontSize: type.micro, lineHeight: 1.5 } }, t('settings.footer')),
3228
+ ),
3229
+ );
3230
+ }
3231
+
3232
+ // ── boundary + registration ──────────────────────────────────────────
3233
+
3234
+ class Boundary extends React.Component {
3235
+ constructor(props) {
3236
+ super(props);
3237
+ this.state = { failed: false };
3238
+ }
3239
+
3240
+ static getDerivedStateFromError() {
3241
+ return { failed: true };
3242
+ }
3243
+
3244
+ render() {
3245
+ if (this.state.failed) {
3246
+ return h(
3247
+ 'div',
3248
+ { style: { padding: 24, color: 'var(--dsw-alias-state-error-primary)', background: 'var(--dsw-alias-bg-base)', fontSize: 13 } },
3249
+ 'Falari Data failed to render.',
3250
+ );
3251
+ }
3252
+ return this.props.children;
3253
+ }
3254
+ }
3255
+
3256
+ function withBoundary(Component) {
3257
+ return function FalariBoundary(props) {
3258
+ return h(Boundary, null, h(Component, props));
3259
+ };
3260
+ }
3261
+
3262
+ const PanelView = withBoundary(Panel);
3263
+ const SettingsView = withBoundary(Settings);
3264
+
3265
+ function apply(ctx) {
3266
+ ctx.effect(() => ctx.locale.register(NS, dictionaries), 'falari-harness: dictionaries');
3267
+ const t = ctx.locale.bind(NS);
3268
+
3269
+ ctx.slots.inject('sidebar.panellist', () =>
3270
+ ctx.slots.register({ name: 'sidebar.panellist', id: NS, order: 40, label: () => t('panel.title'), locale: NS }, SidebarIcon),
3271
+ );
3272
+
3273
+ ctx.slots.inject('main', () =>
3274
+ ctx.slots.register({ name: 'main', key: NS, locale: NS }, (props) => h(PanelView, Object.assign({}, props, { t }))),
3275
+ );
3276
+
3277
+ // The plugin's own configuration lives in the Plugins page, not in the
3278
+ // global Settings dialog: one surface on the bundle page and one behind
3279
+ // the row's configure control.
3280
+ // The plugin manager asks for a one-line summary in compact rows and for
3281
+ // the full page in its own view; keep both on one registration.
3282
+ const renderSettings = (props) =>
3283
+ props && props.view === 'summary'
3284
+ ? t('settings.summary')
3285
+ : h(SettingsView, Object.assign({}, props, { t }));
3286
+
3287
+ ctx.slots.inject('plugins.bundle.config', () =>
3288
+ ctx.slots.register({ name: 'plugins.bundle.config', key: NS, locale: NS }, renderSettings),
3289
+ );
3290
+
3291
+ ctx.slots.inject('plugins.row.config', () =>
3292
+ ctx.slots.register({ name: 'plugins.row.config', key: `${NS}#${ROW_ID}`, locale: NS }, renderSettings),
3293
+ );
3294
+ }
3295
+
3296
+ return {
3297
+ name: 'falari-harness',
3298
+ inject: ['slots', 'locale'],
3299
+ apply,
3300
+ __internals: { RefundDialog, TaskRow, DataTab, UploadTab, SkeletonRows, shortIntent, formatFar, formatBalance, formatBytes, lockIdleLabel, durationSecondsFrom, tiersText, keyStateText, StatusCard },
3301
+ };
3302
+ },
3303
+ });