oxedyne/daimond/www/js/tools.js
22.8 KiB, 5 runs
created by r2519314175:1451, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* tools.js — what Daimond can do for you, and what more it could. |
| 2 | * |
| 3 | * THE UNIT ON THIS PANEL IS A CAPABILITY, NOT A FUNCTION THE MODEL CALLS. |
| 4 | * |
| 5 | * It used to be the other way round: twenty-two rows, each one the wire name of a tool |
| 6 | * in the registry, each with a sentence under it. That is a manifest, and a manifest |
| 7 | * puts the work of assembling "Daimond can use a website" out of `web_open`, |
| 8 | * `web_snapshot`, `web_click`, `web_type`, `web_scroll`, `web_read` and `web_close` onto |
| 9 | * the reader. Nobody does that work, so nobody knows what they have. The word "function" |
| 10 | * does not appear on this surface at all. |
| 11 | * |
| 12 | * So a row here is one thing Daimond does, said in those terms, and it OPENS to the |
| 13 | * functions that make it possible — which is where `web_click` belongs: available to |
| 14 | * anyone who wants it, in front of nobody who does not. |
| 15 | * |
| 16 | * WHERE THE ROWS COME FROM, and neither source is copied: |
| 17 | * |
| 18 | * the functions `builtin_tools()` on the wasm module — the registry the agent is |
| 19 | * actually handed, so the panel cannot promise something that is not |
| 20 | * there, nor hide something that is. Each entry carries the `pack` key |
| 21 | * it is sold under, empty for one Daimond ships. |
| 22 | * the shelf `GET /api/tools` — the gateway states which packs are on sale, what |
| 23 | * they cost, and whether this account holds them, because it is the |
| 24 | * gateway that charges. |
| 25 | * |
| 26 | * The only thing written down here is WHICH CAPABILITY A FUNCTION BELONGS TO. That map |
| 27 | * cannot invent a function: a name in it that the registry does not report simply has no |
| 28 | * members, and a function the registry reports that the map does not place falls into |
| 29 | * `other`, which is visible and which `dev/verify_toolspanel.mjs` requires to be empty. |
| 30 | * A tool added to the Rust registry therefore shows up on this panel either in its |
| 31 | * capability or as a loud failure in the gate — never silently missing. |
| 32 | * |
| 33 | * INCLUDED VERSUS SOLD is the gateway's answer and never this file's guess. A capability |
| 34 | * is on the shelf when every function in it belongs to one pack AND THE GATEWAY IS |
| 35 | * SELLING THAT PACK. A pack the catalogue has stopped listing is not sold, so the |
| 36 | * capability sits under Included and everyone keeps it — which is exactly what the |
| 37 | * gateway does with the entitlement, and the two must not disagree. Nothing that is |
| 38 | * free today can be drawn as buyable, because nothing here decides that. |
| 39 | * |
| 40 | * A pack the catalogue sells that names no function in the registry — the shape the |
| 41 | * first Research pack will arrive in — is a shelf row of its own, carrying the |
| 42 | * catalogue's name, blurb and price. It needs no code here to appear. |
| 43 | */ |
| 44 | (function () { |
| 45 | 'use strict'; |
| 46 | |
| 47 | // The wasm module, resolved against THIS script rather than the document, so the app |
| 48 | // still finds it when served from a sub-path. Same reasoning as graph.js. |
| 49 | var SELF = (document.currentScript && document.currentScript.src) || ''; |
| 50 | var PKG = SELF ? new URL('../pkg/oxedyne_daimond.js', SELF).href |
| 51 | : '../pkg/oxedyne_daimond.js'; |
| 52 | |
| 53 | var deps = null; // { builtins, onCount } |
| 54 | var els = {}; |
| 55 | var state = { |
| 56 | builtin: [], // [{tool, blurb, pack}] — the registry, verbatim |
| 57 | packs: [], // [{tool, name, blurb, price_minor, unlocked, currency}] |
| 58 | err: '', |
| 59 | busy: false, |
| 60 | loaded: false, |
| 61 | open: {}, // capability id -> disclosed, kept across redraws |
| 62 | }; |
| 63 | |
| 64 | // ── The gateway, and a session that has gone ─────────────── |
| 65 | // |
| 66 | // Both calls below go through `DaimondGateway.gwFetch`, which meets a 401 by |
| 67 | // renewing the session once and asking once more. The gateway's session lives |
| 68 | // an hour and only an unlock ever minted one, so an hour into a sitting |
| 69 | // `GET /api/tools` came back 401 and this panel said the account service |
| 70 | // could not be reached -- which was untrue, and which no amount of reopening |
| 71 | // the panel would clear. |
| 72 | // |
| 73 | // Safe to repeat here, and this is why: `common::authed_account` is the first |
| 74 | // statement of both `tools_impl` and `pack_impl` in the gateway, so a 401 is |
| 75 | // proof that nothing happened -- no body parsed, no checkout session made. |
| 76 | |
| 77 | function esc(s) { |
| 78 | return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { |
| 79 | return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; |
| 80 | }); |
| 81 | } |
| 82 | |
| 83 | /// A string, from the catalogue. |
| 84 | /// |
| 85 | /// This panel's strings used to be held in a local English table beside the |
| 86 | /// catalogue's, because the keys were new. They are in `www/i18n/en.js` now |
| 87 | /// and every locale carries them, so the copy is gone: two tables of the same |
| 88 | /// sentences are two tables that will eventually disagree, and the one an |
| 89 | /// editor never opens is the one that goes stale. |
| 90 | function t(k, v) { |
| 91 | return window.DaimondI18n ? DaimondI18n.t(k, v) : k; |
| 92 | } |
| 93 | |
| 94 | /// An unlock is a charge, so its price is quoted the way it will be taken: |
| 95 | /// in US dollars, with the converted figure beside it when the user is |
| 96 | /// reading in something else. |
| 97 | function fmtPrice(n, cur) { |
| 98 | return window.DaimondGateway |
| 99 | ? DaimondGateway.fmtBilled(n, cur || 'usd') |
| 100 | : ('$' + (n / 100).toFixed(2)); |
| 101 | } |
| 102 | |
| 103 | // ── The capabilities ──────────────────────────────────────── |
| 104 | // |
| 105 | // One entry per thing Daimond does, in the order a reader meets them: their own files |
| 106 | // first, the world outside last. `fns` names functions in the Rust registry; a name |
| 107 | // the registry does not report contributes nothing, which is how `dispatch` and |
| 108 | // `graph` sit here ready for the day `builtin_tools()` reports them (today it reads |
| 109 | // `Tool::browser()`, which holds neither) without this file claiming they are present. |
| 110 | // |
| 111 | // THE OFFICE TRIO IS IN `files` AND NOT IN A ROW OF ITS OWN. `sheet_read`, `doc_edit` |
| 112 | // and `sheet_write` are the file tools said in the only vocabulary those formats leave: |
| 113 | // a .docx is a compressed archive, so `file_edit` has no bytes to match a phrase |
| 114 | // against and `doc_edit` is what does, and a workbook's useful unit is a rectangle of |
| 115 | // cells rather than the file. The REACH is identical — the same workspace, the same |
| 116 | // paths, and `write_targets` in src/tools.rs puts them through `file_write`'s own door, |
| 117 | // so the bounds, a skill's allow-list and the absolute-path refusal all apply unchanged. |
| 118 | // A row of their own would offer the reader a thing to grant or withhold separately |
| 119 | // when there is none: whoever has `files` can already overwrite that same .docx whole |
| 120 | // and delete it. Every other row here differs from `files` in what it REACHES — your |
| 121 | // cloud, your screen, your machine, the web, a worker, the graph — and none of them |
| 122 | // splits on a file's format, which is the only thing these three do. |
| 123 | |
| 124 | var CAPS = [ |
| 125 | { id: 'files', fns: ['file_read', 'file_write', 'file_edit', 'file_list', |
| 126 | 'file_search', 'file_glob', 'file_delete', 'file_move', |
| 127 | 'dir_create', |
| 128 | 'sheet_read', 'doc_edit', 'sheet_write', |
| 129 | // The text of a file whose text is a PICTURE: the OCR |
| 130 | // counterpart to file_read, reaching the same workspace path. |
| 131 | 'ocr'] }, |
| 132 | { id: 'cloud', fns: ['file_fetch'] }, |
| 133 | { id: 'work', fns: ['artefact_add'] }, |
| 134 | // `say` sat here beside `file_show` until folding stopped being a tool: an answer |
| 135 | // is written at two depths in the model's own prose now, so there is no function |
| 136 | // to list and nothing for a person to grant or withhold. |
| 137 | { id: 'show', fns: ['file_show'] }, |
| 138 | // A CAPABILITY OF ITS OWN, and not a member of `show`. Both reach the person |
| 139 | // rather than the workspace, and there the likeness stops: `file_show` hands |
| 140 | // something over and this asks for something back. It is also the one row on |
| 141 | // this panel a reader might want to withhold for a reason nothing else here |
| 142 | // covers -- being interrupted -- so folding it into `show` would offer them a |
| 143 | // grant that does not match the thing they are granting. |
| 144 | { id: 'asking', fns: ['ask'] }, |
| 145 | // `runs` is here and not in a capability of its own: it says what the machine hand |
| 146 | // is still running and stops one of them, and a person who granted the starting of |
| 147 | // commands has already granted this. Withholding it would not stop a command being |
| 148 | // left behind -- it would only stop Daimond being able to clear it up. |
| 149 | { id: 'machine', fns: ['run', 'runs', 'shell'] }, |
| 150 | // ITS OWN CAPABILITY, not a member of `machine`, because it is the one tool that |
| 151 | // runs a process OUTSIDE the fence -- a named verifier from the tracked tree, never |
| 152 | // a command the model wrote. That is a different thing to grant from `run`, so it is |
| 153 | // a different thing to withhold. |
| 154 | { id: 'checking', fns: ['verify'] }, |
| 155 | // ONE ROW FOR BOTH, and the reason is that they reach the same place: the Social |
| 156 | // panel and the forge behind it. Reading it and publishing on it are not the same |
| 157 | // ACT -- one asks nobody anything and the other is put to the user every time -- |
| 158 | // but this panel's rows are about what Daimond can REACH, and a person granting or |
| 159 | // withholding "the Social panel" is deciding about one surface. The publishing |
| 160 | // half's own gate is not a setting and is not here: it cannot be turned off, so |
| 161 | // there is nothing for a reader to be offered. |
| 162 | { id: 'social', fns: ['social_read', 'social_send'] }, |
| 163 | { id: 'reading', fns: ['web_fetch', 'web_search'] }, |
| 164 | { id: 'browsing', fns: ['web_open', 'web_snapshot', 'web_read', 'web_click', |
| 165 | 'web_type', 'web_scroll', 'web_close'] }, |
| 166 | { id: 'typeset', fns: ['typst_compile'] }, |
| 167 | { id: 'dispatch', fns: ['spawn_agent'] }, |
| 168 | { id: 'graph', fns: ['link_list', 'link_add', 'link_remove'] }, |
| 169 | // Mail. All four are sold in the Email pack, so this becomes a shelf row with |
| 170 | // its functions disclosed under it -- and `mail_draft` writes a draft for the |
| 171 | // user rather than sending, which is the whole of a model's reach to the wire. |
| 172 | { id: 'email', fns: ['mail_list', 'mail_search', 'mail_read', 'mail_draft'] }, |
| 173 | ]; |
| 174 | |
| 175 | /// The capability a function belongs to, or `other` for one nobody has placed. |
| 176 | function capOf(name) { |
| 177 | for (var i = 0; i < CAPS.length; i++) { |
| 178 | if (CAPS[i].fns.indexOf(name) >= 0) return CAPS[i].id; |
| 179 | } |
| 180 | return 'other'; |
| 181 | } |
| 182 | |
| 183 | /// The pack listing for a key, or null when the catalogue is not selling it. |
| 184 | /// |
| 185 | /// This is the whole of the included-versus-sold rule. A function carries the pack key |
| 186 | /// it *would* be sold under; whether it IS sold is this lookup, and the gateway is the |
| 187 | /// only thing that answers it. |
| 188 | function saleOf(pack) { |
| 189 | if (!pack) return null; |
| 190 | for (var i = 0; i < state.packs.length; i++) { |
| 191 | if (state.packs[i].tool === pack) return state.packs[i]; |
| 192 | } |
| 193 | return null; |
| 194 | } |
| 195 | |
| 196 | /// The panel's rows, assembled from the registry and the catalogue. |
| 197 | /// |
| 198 | /// Returns `{ included: [row], shelf: [row] }` where a row is |
| 199 | /// `{ id, name, blurb, fns:[{name, blurb, pack}], sale, owned }`. `sale` is the |
| 200 | /// catalogue entry when this row is a pack and null when it is included. |
| 201 | function rows() { |
| 202 | var byCap = {}; |
| 203 | var order = []; |
| 204 | state.builtin.forEach(function (fn) { |
| 205 | var id = capOf(fn.tool); |
| 206 | if (!byCap[id]) { byCap[id] = []; order.push(id); } |
| 207 | byCap[id].push(fn); |
| 208 | }); |
| 209 | |
| 210 | var included = [], shelf = [], claimed = {}; |
| 211 | |
| 212 | // Capabilities in the panel's own order, then anything unplaced, so a function |
| 213 | // the map has not caught up with is at the bottom rather than absent. |
| 214 | var ids = CAPS.map(function (c) { return c.id; }); |
| 215 | order.forEach(function (id) { if (ids.indexOf(id) < 0) ids.push(id); }); |
| 216 | |
| 217 | ids.forEach(function (id) { |
| 218 | var fns = byCap[id]; |
| 219 | if (!fns || !fns.length) return; |
| 220 | var row = { |
| 221 | id: id, |
| 222 | name: t('tools.cap.' + id + '.name'), |
| 223 | blurb: t('tools.cap.' + id + '.blurb'), |
| 224 | fns: fns.map(function (f) { |
| 225 | return { name: f.tool, blurb: f.blurb, sale: saleOf(f.pack) }; |
| 226 | }), |
| 227 | sale: null, |
| 228 | owned: true, |
| 229 | }; |
| 230 | // A capability is SOLD only when every one of its functions is sold under |
| 231 | // one and the same pack the catalogue is currently listing. A mixed |
| 232 | // capability stays included and the sold function carries the note itself, |
| 233 | // because "half of this is for sale" is not a thing a row can honestly say. |
| 234 | var sales = row.fns.map(function (f) { return f.sale; }); |
| 235 | var first = sales[0]; |
| 236 | if (first && sales.every(function (s) { return s === first; })) { |
| 237 | row.sale = first; |
| 238 | row.owned = !!first.unlocked; |
| 239 | claimed[first.tool] = 1; |
| 240 | shelf.push(row); |
| 241 | } else { |
| 242 | included.push(row); |
| 243 | } |
| 244 | }); |
| 245 | |
| 246 | // Packs the catalogue sells that name no function this build reports — Email |
| 247 | // today, Research next. The catalogue owns their words, so they are shown in |
| 248 | // them, and they disclose nothing because there is nothing to disclose. |
| 249 | state.packs.forEach(function (p) { |
| 250 | if (claimed[p.tool]) return; |
| 251 | shelf.push({ |
| 252 | id: 'pack:' + p.tool, |
| 253 | name: p.name || p.tool, |
| 254 | blurb: p.blurb || '', |
| 255 | fns: [], |
| 256 | sale: p, |
| 257 | owned: !!p.unlocked, |
| 258 | }); |
| 259 | }); |
| 260 | |
| 261 | // Owned before for-sale: what you have is not a shop. |
| 262 | shelf.sort(function (a, b) { return (b.owned ? 1 : 0) - (a.owned ? 1 : 0); }); |
| 263 | return { included: included, shelf: shelf }; |
| 264 | } |
| 265 | |
| 266 | /// How much of Daimond this account can reach, out of how much there is. The number |
| 267 | /// the rail row shows, and the reason a user opens the panel at all. |
| 268 | /// |
| 269 | /// Counted in CAPABILITIES, which is the unit this panel deals in. A rail that |
| 270 | /// counted functions and a panel that listed capabilities would be two answers to one |
| 271 | /// question. |
| 272 | function counts() { |
| 273 | var r = rows(); |
| 274 | var have = r.included.length + r.shelf.filter(function (x) { return x.owned; }).length; |
| 275 | return { have: have, all: r.included.length + r.shelf.length }; |
| 276 | } |
| 277 | |
| 278 | // ── Telling the build what was not bought ─────────────────── |
| 279 | // |
| 280 | // `/api/tools` is the only thing that knows which packs this account holds, and the |
| 281 | // wasm is the only thing that can refuse a tool. This is the wire between them, and |
| 282 | // until now there was none: `set_locked_packs` existed, `Tool::guard` read what it |
| 283 | // set, `www/js/typst.js` asked `tool_locked` before building the compiler -- and |
| 284 | // NOTHING IN THE PAGE EVER CALLED THE SETTER. The gate was complete and unreached, |
| 285 | // so a pack the gateway was selling ran free on every device. |
| 286 | // |
| 287 | // Only a read that SUCCEEDED pushes. A gateway that could not be reached leaves the |
| 288 | // last good answer standing rather than replacing it with "nothing is locked": a |
| 289 | // device that has never reached the gateway locks nothing, which is the honest |
| 290 | // default, but a network blink is not a reason to hand over a pack. |
| 291 | |
| 292 | /// Hand the wasm the packs this account has not bought. |
| 293 | async function pushLocks() { |
| 294 | var locked = state.packs |
| 295 | .filter(function (p) { return !p.unlocked; }) |
| 296 | .map(function (p) { return p.tool; }) |
| 297 | .join(','); |
| 298 | try { |
| 299 | var mod = await import(PKG); |
| 300 | mod.set_locked_packs(locked); |
| 301 | } catch (e) { |
| 302 | // No module yet: `reload()` runs again once it is up, from daimond.js. |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | /// Ask the gateway what this account may do. A gateway that cannot be reached is not the |
| 307 | /// same as an account that owns nothing, so the built-ins still render and the shelf says |
| 308 | /// plainly that it could not ask. |
| 309 | async function load() { |
| 310 | try { |
| 311 | state.builtin = deps.builtins(); |
| 312 | } catch (e) { |
| 313 | state.builtin = []; |
| 314 | } |
| 315 | try { |
| 316 | if (window.DaimondGateway && !DaimondGateway.state().authed) { |
| 317 | await DaimondGateway.bootstrap(); |
| 318 | } |
| 319 | var r = await DaimondGateway.gwFetch('/api/tools', { credentials: 'same-origin' }); |
| 320 | var j = await r.json(); |
| 321 | if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status)); |
| 322 | state.packs = j.tools || []; |
| 323 | state.err = ''; |
| 324 | await pushLocks(); |
| 325 | } catch (e) { |
| 326 | // The listing goes, the engine's locks stay. The two then disagree for as |
| 327 | // long as the gateway is down -- the panel showing a pack as included that |
| 328 | // the engine will refuse -- and that is the right way round: the banner says |
| 329 | // in words that what is unlocked here is unknown, whereas keeping the last |
| 330 | // listing would make a positive claim of OWNERSHIP out of stale data, and |
| 331 | // pushing an empty lock list would hand a pack to somebody who has not |
| 332 | // bought one. A wrong chip is cheaper than either. |
| 333 | state.packs = []; |
| 334 | state.err = t('tools.unreachable'); |
| 335 | } |
| 336 | state.loaded = true; |
| 337 | render(); |
| 338 | if (deps.onCount) deps.onCount(counts()); |
| 339 | } |
| 340 | |
| 341 | /// Buy a pack. The gateway makes the session; the price on the button came from the |
| 342 | /// same table the till reads, so what is quoted is what is charged. |
| 343 | async function unlock(pack) { |
| 344 | if (state.busy) return; |
| 345 | state.busy = true; |
| 346 | render(); |
| 347 | try { |
| 348 | if (!window.DaimondGateway) throw new Error(t('tools.no_service')); |
| 349 | var r = await DaimondGateway.gwFetch('/api/checkout/pack', { |
| 350 | method: 'POST', |
| 351 | headers: { 'content-type': 'application/json' }, |
| 352 | credentials: 'same-origin', |
| 353 | body: JSON.stringify({ pack: pack }), |
| 354 | }); |
| 355 | // A 401 that survived the renewal is a session this device cannot get |
| 356 | // back, not a fault in the purchase. The gateway's own words for it |
| 357 | // ("No valid session.") say nothing a user can act on. |
| 358 | if (r.status === 401) throw new Error(t('tools.unreachable')); |
| 359 | var j = await r.json(); |
| 360 | if (!r.ok || !j.ok || !j.url) throw new Error((j && j.error) || ('HTTP ' + r.status)); |
| 361 | window.location = j.url; |
| 362 | } catch (e) { |
| 363 | state.err = (e && e.message) ? e.message : String(e); |
| 364 | state.busy = false; |
| 365 | render(); |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | function html(s) { |
| 370 | var n = document.createElement('div'); |
| 371 | n.innerHTML = s; |
| 372 | return n.firstElementChild || n; |
| 373 | } |
| 374 | |
| 375 | function el(tag, cls, text) { |
| 376 | var n = document.createElement(tag); |
| 377 | if (cls) n.className = cls; |
| 378 | if (text != null) n.textContent = text; |
| 379 | return n; |
| 380 | } |
| 381 | |
| 382 | /// One capability, closed, with everything it needs to be opened. |
| 383 | /// |
| 384 | /// The disclosure is a button and a `hidden` block rather than `<details>`: `hidden` |
| 385 | /// is genuinely not rendered, so "the function is not on screen until you open it" is |
| 386 | /// a fact a verifier can measure instead of a claim about a shadow root. |
| 387 | function capCard(row) { |
| 388 | var card = el('div', 'cap'); |
| 389 | card.setAttribute('data-cap', row.id); |
| 390 | card.setAttribute('data-state', |
| 391 | !row.sale ? 'included' : (row.owned ? 'owned' : 'locked')); |
| 392 | |
| 393 | var head = el('div', 'cap-head'); |
| 394 | var txt = el('div', 'cap-text'); |
| 395 | txt.appendChild(el('div', 'cap-name', row.name)); |
| 396 | if (row.blurb) txt.appendChild(el('div', 'cap-blurb', row.blurb)); |
| 397 | if (row.sale && !row.owned) { |
| 398 | txt.appendChild(el('div', 'cap-why', |
| 399 | t('tools.locked_why', { pack: row.sale.name || row.sale.tool }))); |
| 400 | } |
| 401 | head.appendChild(txt); |
| 402 | |
| 403 | var side = el('div', 'cap-side'); |
| 404 | if (row.sale && !row.owned) { |
| 405 | var b = el('button', 'tools-buy'); |
| 406 | b.disabled = state.busy; |
| 407 | b.textContent = t('tools.unlock_price', |
| 408 | { price: fmtPrice(row.sale.price_minor, row.sale.currency) }); |
| 409 | b.title = t('billing.usd_note'); |
| 410 | b.setAttribute('data-buy', row.sale.tool); |
| 411 | b.addEventListener('click', function () { unlock(row.sale.tool); }); |
| 412 | side.appendChild(b); |
| 413 | } else { |
| 414 | var chip = el('span', 'cap-chip' + (row.sale ? ' on' : ''), |
| 415 | t(row.sale ? 'tools.status_owned' : 'tools.status_included')); |
| 416 | side.appendChild(chip); |
| 417 | } |
| 418 | head.appendChild(side); |
| 419 | card.appendChild(head); |
| 420 | |
| 421 | if (!row.fns.length) return card; |
| 422 | |
| 423 | var listId = 'cap-fns-' + row.id.replace(/[^a-z0-9]+/gi, '-'); |
| 424 | var body = el('div', 'cap-fns'); |
| 425 | body.id = listId; |
| 426 | row.fns.forEach(function (f) { |
| 427 | var line = el('div', 'cap-fn'); |
| 428 | line.setAttribute('data-fn', f.name); |
| 429 | line.appendChild(el('code', 'cap-fn-name', f.name)); |
| 430 | var w = el('span', 'cap-fn-blurb', f.blurb); |
| 431 | line.appendChild(w); |
| 432 | // A function sold under a pack this row did not become — a mixed |
| 433 | // capability — says so on its own line, because the row above did not. |
| 434 | if (f.sale && !f.sale.unlocked && !row.sale) { |
| 435 | line.appendChild(el('span', 'cap-fn-pack', |
| 436 | t('tools.fn_pack', { pack: f.sale.name || f.sale.tool }))); |
| 437 | } |
| 438 | body.appendChild(line); |
| 439 | }); |
| 440 | |
| 441 | var more = el('button', 'cap-more'); |
| 442 | more.setAttribute('aria-controls', listId); |
| 443 | var draw = function () { |
| 444 | var on = !!state.open[row.id]; |
| 445 | more.setAttribute('aria-expanded', on ? 'true' : 'false'); |
| 446 | more.textContent = on ? t('tools.collapse') |
| 447 | : t('tools.expand', { n: row.fns.length }); |
| 448 | body.hidden = !on; |
| 449 | }; |
| 450 | more.addEventListener('click', function () { |
| 451 | state.open[row.id] = !state.open[row.id]; |
| 452 | draw(); |
| 453 | }); |
| 454 | draw(); |
| 455 | card.appendChild(more); |
| 456 | card.appendChild(body); |
| 457 | return card; |
| 458 | } |
| 459 | |
| 460 | function render() { |
| 461 | if (!els.body) return; |
| 462 | els.body.innerHTML = ''; |
| 463 | |
| 464 | var r = rows(); |
| 465 | var c = counts(); |
| 466 | |
| 467 | els.body.appendChild(html('<div class="tools-intro">' + esc(t('tools.intro')) + '</div>')); |
| 468 | els.body.appendChild(html('<div class="tools-count">' + t('tools.count', c) + '</div>')); |
| 469 | |
| 470 | if (state.err) els.body.appendChild(html('<div class="tools-err">' + esc(state.err) + '</div>')); |
| 471 | |
| 472 | els.body.appendChild(html('<div class="tools-sec">' + esc(t('tools.sec_included')) + '</div>')); |
| 473 | r.included.forEach(function (row) { els.body.appendChild(capCard(row)); }); |
| 474 | |
| 475 | // The shelf is drawn whether or not anything is on it. It is the answer to "is |
| 476 | // there more?", and a section that vanishes when the answer is "not yet" makes |
| 477 | // the reader wonder whether they missed it — and leaves nowhere for the first |
| 478 | // pack to appear without a change here, which is how a shelf ends up unreachable. |
| 479 | els.body.appendChild(html('<div class="tools-sec">' + esc(t('tools.sec_packs')) + '</div>')); |
| 480 | if (r.shelf.length) { |
| 481 | r.shelf.forEach(function (row) { els.body.appendChild(capCard(row)); }); |
| 482 | els.body.appendChild(html('<div class="tools-fine">' + esc(t('tools.packs_fine')) + '</div>')); |
| 483 | if (window.DaimondI18n && DaimondI18n.currency() !== 'USD') { |
| 484 | els.body.appendChild(html('<div class="tools-fine">' + esc(t('billing.usd_note')) + '</div>')); |
| 485 | } |
| 486 | } else { |
| 487 | els.body.appendChild(html('<div class="tools-none">' + esc(t('tools.packs_none')) + '</div>')); |
| 488 | } |
| 489 | } |
| 490 | |
| 491 | /// Show the panel, on the stage: the dock holds a noun as a list, the stage holds the |
| 492 | /// noun under inspection, and a thing with a price on it is being inspected. |
| 493 | function show() { |
| 494 | DaimondPanels.show('tools'); |
| 495 | DaimondPanels.reflow(); |
| 496 | load(); |
| 497 | } |
| 498 | |
| 499 | function init(d) { |
| 500 | deps = d; |
| 501 | els.body = document.getElementById('tools-body'); |
| 502 | if (!els.body) return; |
| 503 | // The count on the rail row is the reason anyone opens this, so it is fetched once at |
| 504 | // boot rather than waiting for the panel to be opened for the first time. |
| 505 | load(); |
| 506 | } |
| 507 | |
| 508 | window.DaimondTools = { |
| 509 | init: init, |
| 510 | show: show, |
| 511 | reload: load, |
| 512 | counts: counts, |
| 513 | // For the gate, which asks what this panel decided rather than what it drew. |
| 514 | rows: rows, |
| 515 | }; |
| 516 | |
| 517 | // The panel is drawn from state already in hand, so a language or currency |
| 518 | // change can simply draw it again. |
| 519 | if (window.DaimondI18n) DaimondI18n.onChange(function () { if (els.body) render(); }); |
| 520 | })(); |