relay-companion 0.1.85 → 0.1.87

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.
@@ -503,6 +503,29 @@
503
503
  .cv-sub { font-size:11.5px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:1px; }
504
504
  .cv-chev { flex:0 0 auto; display:flex; align-items:center; }
505
505
  .cv-empty { padding:34px 16px; text-align:center; color:var(--muted-2); font-size:12.5px; line-height:1.5; }
506
+ /* ---- contact groups (rosters) ---- */
507
+ .cvg { border-top:1px solid var(--hair-2); padding-bottom:10px; }
508
+ .cvg-head { display:flex; align-items:center; justify-content:space-between; padding:12px 16px 6px; }
509
+ .cvg-newform { display:flex; gap:7px; padding:6px 16px 10px; align-items:center; }
510
+ .cvg-newform.hidden { display:none; }
511
+ .cvg-newform .cv-input { flex:1; }
512
+ .cvg-error { color:#b4332a; font-size:11.5px; padding:0 16px; line-height:1.4; }
513
+ .cvg-error:empty { display:none; }
514
+ .cvg-item { display:flex; align-items:center; gap:11px; padding:9px 16px; cursor:pointer; text-align:left; appearance:none; border:0; border-bottom:1px solid var(--hair-2); background:transparent; width:100%; font-family:var(--sans); transition:background-color .14s var(--settle); }
515
+ .cvg-item:hover { background:rgba(31,26,23,.035); }
516
+ .cvg-name { font-size:13px; font-weight:600; color:var(--ink); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
517
+ .cvg-count { font-size:11.5px; color:var(--muted-2); }
518
+ .cvg-panel { padding:4px 16px 12px; border-bottom:1px solid var(--hair-2); display:flex; flex-direction:column; gap:8px; }
519
+ .cvg-members { display:flex; flex-wrap:wrap; gap:5px; }
520
+ .cvg-none { font-size:11.5px; color:var(--muted-2); }
521
+ .cvg-addwrap { position:relative; }
522
+ .cvg-sugg { display:flex; flex-direction:column; gap:3px; margin-top:4px; }
523
+ .cvg-sugg button { text-align:left; appearance:none; border:1px solid var(--hair-2); background:transparent; border-radius:7px; padding:5px 9px; font-size:12px; color:var(--ink); cursor:pointer; font-family:var(--sans); }
524
+ .cvg-sugg button:hover { background:rgba(31,26,23,.045); }
525
+ .cvg-actions { display:flex; gap:10px; }
526
+ .cvg-act { appearance:none; border:0; background:transparent; padding:0; font-size:11.5px; color:var(--muted-2); cursor:pointer; font-family:var(--sans); }
527
+ .cvg-act:hover { color:var(--ink); text-decoration:underline; }
528
+ .cvg-act.danger { color:#b4332a; }
506
529
  .cv-form { display:flex; flex-direction:column; gap:7px; padding:8px 16px 14px; border-bottom:1px solid var(--hair-2); }
507
530
  .cv-form.hidden { display:none; }
508
531
  .cv-row2 { display:flex; gap:7px; }
@@ -827,6 +850,23 @@
827
850
  </form>
828
851
  <div class="cv-list" id="cvList"></div>
829
852
  <div id="contactsEmpty" class="cv-empty gone">No contacts yet.<br>Add someone, or your agent saves them as you relay.</div>
853
+
854
+ <!-- Contact groups: saved rosters. A relay to a group fans out to every
855
+ member under ONE shared thread; replies reach everyone. -->
856
+ <div class="cvg" id="cvGroups">
857
+ <div class="cvg-head">
858
+ <span class="cv-title">Groups</span>
859
+ <button class="cv-add" type="button" id="cvgNew">+ New group</button>
860
+ </div>
861
+ <form class="cvg-newform hidden" id="cvgNewForm">
862
+ <input class="cv-input" id="cvgNewName" placeholder="Group name" autocomplete="off" spellcheck="false" />
863
+ <button class="cv-save" type="submit">Create</button>
864
+ <button class="cv-cancel" type="button" id="cvgNewCancel">Cancel</button>
865
+ </form>
866
+ <div class="cvg-error" id="cvgError"></div>
867
+ <div id="cvgList"></div>
868
+ <div class="cv-empty" id="cvgEmpty">No groups yet. A group relays to everyone in it as one conversation.</div>
869
+ </div>
830
870
  </section>
831
871
 
832
872
  <!-- Settings view (account card + sign-out / switch-account; rendered by JS) -->
@@ -2425,7 +2465,181 @@
2425
2465
  try { contactsList = (await window.relay.contacts()) || []; }
2426
2466
  catch { contactsList = []; }
2427
2467
  renderContacts();
2468
+ loadGroups();
2469
+ }
2470
+
2471
+ // ---- Contact groups (rosters) ----
2472
+ // Server-truth only: every mutation returns the updated group view and the
2473
+ // section re-renders from it, so nothing here can drift from the API.
2474
+ const cvgNewEl = document.getElementById("cvgNew");
2475
+ const cvgNewFormEl = document.getElementById("cvgNewForm");
2476
+ const cvgNewNameEl = document.getElementById("cvgNewName");
2477
+ const cvgNewCancelEl = document.getElementById("cvgNewCancel");
2478
+ const cvgListEl = document.getElementById("cvgList");
2479
+ const cvgEmptyEl = document.getElementById("cvgEmpty");
2480
+ const cvgErrorEl = document.getElementById("cvgError");
2481
+ let groupsList = [];
2482
+ let cvgExpandedId = null; // group whose member panel is open
2483
+ let cvgRenamingId = null;
2484
+ let cvgDeleteArmedId = null;
2485
+ let cvgSearchTimer = null;
2486
+ function cvgShowError(message) { cvgErrorEl.textContent = message || ""; }
2487
+ function cvgApply(result) {
2488
+ // A mutation's returned view replaces (or appends) its group; delete passes
2489
+ // a removal marker. Sort by name so rows don't jump around per mutation.
2490
+ if (result && result.removeId) groupsList = groupsList.filter((g) => g.id !== result.removeId);
2491
+ else if (result && result.id) {
2492
+ const at = groupsList.findIndex((g) => g.id === result.id);
2493
+ if (at === -1) groupsList.push(result);
2494
+ else groupsList[at] = result;
2495
+ }
2496
+ groupsList.sort((a, b) => String(a.name).localeCompare(String(b.name)));
2497
+ renderGroups();
2498
+ }
2499
+ async function cvgCall(fn, ...args) {
2500
+ cvgShowError("");
2501
+ let res = null;
2502
+ try { res = await fn(...args); } catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
2503
+ if (!res || res.ok !== true) {
2504
+ cvgShowError((res && res.error) || "That didn't go through — try again.");
2505
+ return null;
2506
+ }
2507
+ return res.result;
2508
+ }
2509
+ async function loadGroups() {
2510
+ const groups = await cvgCall(window.relay.groups);
2511
+ if (Array.isArray(groups)) {
2512
+ groupsList = groups.slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
2513
+ renderGroups();
2514
+ }
2515
+ }
2516
+ function renderGroups() {
2517
+ cvgEmptyEl.classList.toggle("gone", groupsList.length > 0);
2518
+ cvgListEl.innerHTML = groupsList.map((g) => {
2519
+ const open = g.id === cvgExpandedId;
2520
+ const count = Number(g.memberCount ?? (g.members || []).length) || 0;
2521
+ const row = cvgRenamingId === g.id
2522
+ ? `<form class="cvg-newform" data-rename-form="${esc(g.id)}"><input class="cv-input" data-rename-input="${esc(g.id)}" value="${esc(g.name)}" autocomplete="off" spellcheck="false" /><button class="cv-save" type="submit">Save</button><button class="cv-cancel" type="button" data-rename-cancel="${esc(g.id)}">Cancel</button></form>`
2523
+ : `<button class="cvg-item" type="button" data-group="${esc(g.id)}">
2524
+ <span class="cvg-name">${esc(g.name)}</span>
2525
+ <span class="cvg-count">${count} member${count === 1 ? "" : "s"}</span>
2526
+ <span class="cv-chev">${CHEVRON_SVG}</span>
2527
+ </button>`;
2528
+ if (!open) return row;
2529
+ const members = (g.members || []).map((m) => {
2530
+ const label = m.name || m.email || m.contactId || m.id;
2531
+ const cid = m.contactId || m.id;
2532
+ return `<span class="cv-chip"><span class="e">${esc(label)}</span><button type="button" class="cv-chip-x" data-unmember="${esc(g.id)}|${esc(cid)}" aria-label="Remove ${esc(label)}">×</button></span>`;
2533
+ }).join("");
2534
+ return row + `
2535
+ <div class="cvg-panel" data-stop="1">
2536
+ <div class="cvg-members">${members || '<span class="cvg-none">No members yet.</span>'}</div>
2537
+ <div class="cvg-addwrap">
2538
+ <input class="cv-input" data-addmember="${esc(g.id)}" placeholder="Add member — type a name…" autocomplete="off" spellcheck="false" />
2539
+ <div class="cvg-sugg" data-sugg="${esc(g.id)}"></div>
2540
+ </div>
2541
+ <div class="cvg-actions">
2542
+ <button class="cvg-act" type="button" data-rename="${esc(g.id)}">Rename</button>
2543
+ <button class="cvg-act danger" type="button" data-delete="${esc(g.id)}">${cvgDeleteArmedId === g.id ? "Really delete? Every member drops out" : "Delete group"}</button>
2544
+ </div>
2545
+ </div>`;
2546
+ }).join("");
2547
+ wireGroupRows();
2428
2548
  }
2549
+ function wireGroupRows() {
2550
+ for (const el of cvgListEl.querySelectorAll("[data-group]")) {
2551
+ el.addEventListener("click", () => {
2552
+ const id = el.getAttribute("data-group");
2553
+ cvgExpandedId = cvgExpandedId === id ? null : id;
2554
+ cvgDeleteArmedId = null; cvgRenamingId = null;
2555
+ renderGroups();
2556
+ });
2557
+ }
2558
+ for (const el of cvgListEl.querySelectorAll("[data-unmember]")) {
2559
+ el.addEventListener("click", async (ev) => {
2560
+ ev.stopPropagation();
2561
+ const [gid, cid] = el.getAttribute("data-unmember").split("|");
2562
+ const view = await cvgCall(window.relay.groupRemoveMember, gid, cid);
2563
+ if (view) cvgApply(view);
2564
+ });
2565
+ }
2566
+ for (const el of cvgListEl.querySelectorAll("[data-rename]")) {
2567
+ el.addEventListener("click", () => { cvgRenamingId = el.getAttribute("data-rename"); renderGroups();
2568
+ const input = cvgListEl.querySelector(`[data-rename-input="${CSS.escape(cvgRenamingId)}"]`);
2569
+ if (input) { input.focus(); input.select(); }
2570
+ });
2571
+ }
2572
+ for (const el of cvgListEl.querySelectorAll("[data-rename-cancel]")) {
2573
+ el.addEventListener("click", () => { cvgRenamingId = null; renderGroups(); });
2574
+ }
2575
+ for (const form of cvgListEl.querySelectorAll("[data-rename-form]")) {
2576
+ form.addEventListener("submit", async (ev) => {
2577
+ ev.preventDefault();
2578
+ const gid = form.getAttribute("data-rename-form");
2579
+ const name = form.querySelector("input").value.trim();
2580
+ if (!name) return;
2581
+ const view = await cvgCall(window.relay.groupRename, gid, name);
2582
+ if (view) { cvgRenamingId = null; cvgApply(view); }
2583
+ });
2584
+ }
2585
+ for (const el of cvgListEl.querySelectorAll("[data-delete]")) {
2586
+ el.addEventListener("click", async () => {
2587
+ const gid = el.getAttribute("data-delete");
2588
+ // Deleting a roster is destructive: first tap arms, second tap commits.
2589
+ if (cvgDeleteArmedId !== gid) { cvgDeleteArmedId = gid; renderGroups(); return; }
2590
+ const res = await cvgCall(window.relay.groupDelete, gid);
2591
+ cvgDeleteArmedId = null;
2592
+ if (res) { cvgExpandedId = null; cvgApply({ removeId: gid }); }
2593
+ });
2594
+ }
2595
+ for (const input of cvgListEl.querySelectorAll("[data-addmember]")) {
2596
+ const gid = input.getAttribute("data-addmember");
2597
+ const suggEl = cvgListEl.querySelector(`[data-sugg="${CSS.escape(gid)}"]`);
2598
+ input.addEventListener("focus", () => { if (window.relay.setFocusable) window.relay.setFocusable(true); });
2599
+ input.addEventListener("blur", () => { if (activeView !== "contacts" && window.relay.setFocusable) window.relay.setFocusable(false); });
2600
+ input.addEventListener("input", () => {
2601
+ clearTimeout(cvgSearchTimer);
2602
+ const q = input.value.trim();
2603
+ if (q.length < 2) { suggEl.innerHTML = ""; return; }
2604
+ cvgSearchTimer = setTimeout(async () => {
2605
+ const res = await cvgCall(window.relay.contactsSearch, q);
2606
+ const group = groupsList.find((x) => x.id === gid);
2607
+ const already = new Set(((group && group.members) || []).map((m) => m.contactId || m.id));
2608
+ const matches = ((res && res.matches) || []).filter((m) => m.contactId && !already.has(m.contactId)).slice(0, 4);
2609
+ suggEl.innerHTML = matches.map((m) =>
2610
+ `<button type="button" data-addpick="${esc(gid)}|${esc(m.contactId)}">${esc(m.name || m.email)}${m.email && m.name ? ` — ${esc(m.email)}` : ""}</button>`,
2611
+ ).join("");
2612
+ for (const b of suggEl.querySelectorAll("[data-addpick]")) {
2613
+ b.addEventListener("click", async () => {
2614
+ const [g2, cid] = b.getAttribute("data-addpick").split("|");
2615
+ const view = await cvgCall(window.relay.groupAddMember, g2, cid);
2616
+ if (view) cvgApply(view);
2617
+ });
2618
+ }
2619
+ }, 250);
2620
+ });
2621
+ }
2622
+ }
2623
+ cvgNewEl.addEventListener("click", () => {
2624
+ cvgNewFormEl.classList.toggle("hidden");
2625
+ if (!cvgNewFormEl.classList.contains("hidden")) {
2626
+ if (window.relay.setFocusable) window.relay.setFocusable(true);
2627
+ cvgNewNameEl.focus();
2628
+ }
2629
+ });
2630
+ cvgNewCancelEl.addEventListener("click", () => { cvgNewNameEl.value = ""; cvgNewFormEl.classList.add("hidden"); });
2631
+ cvgNewFormEl.addEventListener("submit", async (ev) => {
2632
+ ev.preventDefault();
2633
+ const name = cvgNewNameEl.value.trim();
2634
+ if (!name) return;
2635
+ const view = await cvgCall(window.relay.groupCreate, name);
2636
+ if (view) {
2637
+ cvgNewNameEl.value = "";
2638
+ cvgNewFormEl.classList.add("hidden");
2639
+ cvgExpandedId = view.id; // open the fresh group so adding members is the next tap
2640
+ cvgApply(view);
2641
+ }
2642
+ });
2429
2643
  function cvSplitName(name) {
2430
2644
  const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
2431
2645
  return { first: parts[0] || "", last: parts.slice(1).join(" ") };
package/overlay/main.cjs CHANGED
@@ -20,7 +20,22 @@
20
20
  // - Mutations (accept/reject/approve/decline/answer) call RelayClient with device-token
21
21
  // auth. ack/mark-read writes state.json directly (atomic temp+rename).
22
22
 
23
- const { app, BrowserWindow, Menu, Tray, ipcMain, nativeImage, powerMonitor, shell, screen, systemPreferences } = require("electron");
23
+ const { app, BrowserWindow, Menu, Tray, ipcMain, nativeImage, powerMonitor, powerSaveBlocker, shell, screen, systemPreferences } = require("electron");
24
+
25
+ // The pill is an accessory window that spends most of its life idle behind other
26
+ // apps. Both Chromium and macOS treat that as "safe to throttle", and the cost is
27
+ // paid by the user: the FIRST click after an idle spell waits for the renderer to
28
+ // spin back up (reported as "slow, then fast, then slow again"). These switches
29
+ // stop the OS/engine from backgrounding a window the user can still see and click.
30
+ // They must be set before app ready.
31
+ app.commandLine.appendSwitch("disable-renderer-backgrounding");
32
+ app.commandLine.appendSwitch("disable-background-timer-throttling");
33
+ app.commandLine.appendSwitch("disable-backgrounding-occluded-windows");
34
+ if (process.platform === "darwin") {
35
+ // macOS reports an always-on-top overlay as occluded far more eagerly than it
36
+ // deserves; Chromium then backgrounds the renderer behind the user's back.
37
+ app.commandLine.appendSwitch("disable-features", "MacWebContentsOcclusion");
38
+ }
24
39
 
25
40
  // A closed stdout/stderr pipe must never kill the pill. When whatever launched
26
41
  // us goes away (launchd log rotation, a terminal that spawned `relay pill`, a
@@ -961,11 +976,33 @@ function idleSecondsSafe() {
961
976
 
962
977
  // Renderer timer throttling is disabled ONLY while a card's dwell runs: the
963
978
  // 7s fold timer must be honest, but an idle hidden overlay should coast.
964
- function setThrottlingForShow(showActive) {
979
+ // Responsiveness policy: a renderer the user can SEE is never throttled. The
980
+ // earlier rule (throttle unless a notification dwell was running) saved idle CPU
981
+ // but made the first interaction after any quiet spell slow — the user feels a
982
+ // dead click far more than a fraction of a percent of CPU. Throttling now applies
983
+ // only while the window is genuinely hidden, where nobody can click it.
984
+ let appSuspensionBlockerId = null;
985
+ function applyThrottlingPolicy() {
986
+ const visible = Boolean(win && !win.isDestroyed() && win.isVisible());
987
+ try {
988
+ if (win && !win.isDestroyed()) win.webContents.setBackgroundThrottling(!visible);
989
+ } catch {}
990
+ // macOS App Nap suspends whole accessory processes; hold it off only while the
991
+ // pill is on screen, so an idle hidden pill still costs nothing.
965
992
  try {
966
- if (win && !win.isDestroyed()) win.webContents.setBackgroundThrottling(!showActive);
993
+ if (visible && appSuspensionBlockerId === null) {
994
+ appSuspensionBlockerId = powerSaveBlocker.start("prevent-app-suspension");
995
+ } else if (!visible && appSuspensionBlockerId !== null) {
996
+ powerSaveBlocker.stop(appSuspensionBlockerId);
997
+ appSuspensionBlockerId = null;
998
+ }
967
999
  } catch {}
968
1000
  }
1001
+ // Kept for the notification-dwell call sites: dwell timing still demands an
1002
+ // unthrottled renderer, and the visibility rule already guarantees it.
1003
+ function setThrottlingForShow() {
1004
+ applyThrottlingPolicy();
1005
+ }
969
1006
 
970
1007
  function abortCurrentShow(reason) {
971
1008
  if (!currentShow) return;
@@ -2340,6 +2377,9 @@ function showOverlayWindow({ force = false, reposition = true } = {}) {
2340
2377
  // window would silently flip it click-through while the renderer still thinks
2341
2378
  // the pointer handshake is live — a dead card until the pointer re-enters.
2342
2379
  win.setIgnoreMouseEvents(true, { forward: true });
2380
+ // Visible again: unthrottle immediately, so the FIRST click after this is as
2381
+ // fast as the tenth (the "slow, then fast, then slow again" report).
2382
+ applyThrottlingPolicy();
2343
2383
  win.webContents.send("shown");
2344
2384
  // Becoming visible is when host-running freshness starts mattering for
2345
2385
  // click routing again; the hidden poll cadence is slow, so take one
@@ -3001,7 +3041,7 @@ function hideFromTray() {
3001
3041
  writeOverlayPrefs();
3002
3042
  ghostActive = false;
3003
3043
  trayForcedVisible = false;
3004
- if (win && !win.isDestroyed() && win.isVisible()) win.hide();
3044
+ if (win && !win.isDestroyed() && win.isVisible()) { win.hide(); applyThrottlingPolicy(); }
3005
3045
  syncTray();
3006
3046
  writePillStatus();
3007
3047
  }
@@ -3171,6 +3211,26 @@ ipcMain.handle("relay:contacts", () => readContacts());
3171
3211
  ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
3172
3212
  ipcMain.handle("relay:contactDelete", (_e, input) => deleteContactFromBook(input));
3173
3213
 
3214
+ // Contact groups (Contacts tab): thin proxies over the owner-scoped API. Every
3215
+ // mutation returns the server's updated group view and the renderer re-renders
3216
+ // from it, so there is no local cache to drift. Errors come back as
3217
+ // { ok:false, error } — the renderer shows them inline instead of dying.
3218
+ const groupCall = async (fn) => {
3219
+ try {
3220
+ const client = await relayClient();
3221
+ return { ok: true, result: await fn(client) };
3222
+ } catch (error) {
3223
+ return { ok: false, error: error && error.message ? error.message : String(error) };
3224
+ }
3225
+ };
3226
+ ipcMain.handle("relay:groups", () => groupCall(async (c) => (await c.groups()).groups || []));
3227
+ ipcMain.handle("relay:groupCreate", (_e, name) => groupCall((c) => c.createGroup({ name })));
3228
+ ipcMain.handle("relay:groupRename", (_e, id, name) => groupCall((c) => c.renameGroup(id, { name })));
3229
+ ipcMain.handle("relay:groupDelete", (_e, id) => groupCall((c) => c.deleteGroup(id)));
3230
+ ipcMain.handle("relay:groupAddMember", (_e, id, contactId) => groupCall((c) => c.addGroupMember(id, contactId)));
3231
+ ipcMain.handle("relay:groupRemoveMember", (_e, id, contactId) => groupCall((c) => c.removeGroupMember(id, contactId)));
3232
+ ipcMain.handle("relay:contactsSearch", (_e, q) => groupCall((c) => c.searchContacts(String(q || ""))));
3233
+
3174
3234
  // Settings tab: account card + the sign-out / switch-account lifecycle.
3175
3235
  ipcMain.handle("relay:accountInfo", () => accountInfo());
3176
3236
  ipcMain.handle("relay:pairWithCode", (_e, input) => pairWithCode(input));
@@ -3271,7 +3331,7 @@ ipcMain.on("relay:dismiss", () => {
3271
3331
  writeOverlayPrefs();
3272
3332
  ghostActive = false;
3273
3333
  trayForcedVisible = false;
3274
- if (win && !win.isDestroyed() && win.isVisible()) win.hide();
3334
+ if (win && !win.isDestroyed() && win.isVisible()) { win.hide(); applyThrottlingPolicy(); }
3275
3335
  writePillStatus();
3276
3336
  });
3277
3337
  // The renderer finished registering every IPC listener (last line of its
@@ -53,6 +53,14 @@ contextBridge.exposeInMainWorld("relay", {
53
53
 
54
54
  // contacts
55
55
  contacts: () => ipcRenderer.invoke("relay:contacts"),
56
+ contactsSearch: (q) => ipcRenderer.invoke("relay:contactsSearch", q),
57
+ // contact groups (saved rosters; a group relays to every member as one thread)
58
+ groups: () => ipcRenderer.invoke("relay:groups"),
59
+ groupCreate: (name) => ipcRenderer.invoke("relay:groupCreate", name),
60
+ groupRename: (id, name) => ipcRenderer.invoke("relay:groupRename", id, name),
61
+ groupDelete: (id) => ipcRenderer.invoke("relay:groupDelete", id),
62
+ groupAddMember: (id, contactId) => ipcRenderer.invoke("relay:groupAddMember", id, contactId),
63
+ groupRemoveMember: (id, contactId) => ipcRenderer.invoke("relay:groupRemoveMember", id, contactId),
56
64
  contactSave: (input) => ipcRenderer.invoke("relay:contactSave", input),
57
65
  contactDelete: (input) => ipcRenderer.invoke("relay:contactDelete", input),
58
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "description": "Relay companion for ordinary messages, with dormant coordination features available only by explicit opt-in.",
5
5
  "type": "module",
6
6
  "bin": {