shadok-ai 0.8.17 → 0.8.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test test/*.ts context/*/test/*.test.mjs",
package/public/index.html CHANGED
@@ -9,7 +9,9 @@
9
9
  fields (see the coarse-pointer query) fix it at the source instead. -->
10
10
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
11
11
  <title>shadok-ai — cockpit</title>
12
- <link rel="icon" id="favicon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2314161d'/%3E%3Ctext x='16' y='25' font-family='monospace' font-size='26' font-weight='700' fill='%23f0a848' text-anchor='middle'%3E%E2%80%BA%3C/text%3E%3C/svg%3E">
12
+ <!-- The idle favicon: a resting Shadok. paint() swaps in the pumping / "!" / "?"
13
+ variants once the JS runs; this is only the pre-script icon. -->
14
+ <link rel="icon" id="favicon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%227%22%20fill%3D%22%2314161d%22%2F%3E%3Cg%20fill%3D%22%23f0a848%22%3E%3Crect%20x%3D%2211.4%22%20y%3D%2221%22%20width%3D%221.7%22%20height%3D%229%22%20rx%3D%220.85%22%2F%3E%3Crect%20x%3D%2217.4%22%20y%3D%2221%22%20width%3D%221.7%22%20height%3D%229%22%20rx%3D%220.85%22%2F%3E%3Crect%20x%3D%229.2%22%20y%3D%2228.7%22%20width%3D%224.6%22%20height%3D%221.7%22%20rx%3D%220.85%22%2F%3E%3Crect%20x%3D%2216.4%22%20y%3D%2228.7%22%20width%3D%224.6%22%20height%3D%221.7%22%20rx%3D%220.85%22%2F%3E%3C%2Fg%3E%3Cg%20fill%3D%22%23f0a848%22%3E%3Cellipse%20cx%3D%2215%22%20cy%3D%2216.8%22%20rx%3D%227.4%22%20ry%3D%227.6%22%2F%3E%3Cg%20transform%3D%22rotate(6%2019.6%2015.6)%22%3E%3Crect%20x%3D%2218.6%22%20y%3D%2215%22%20width%3D%222.1%22%20height%3D%228.4%22%20rx%3D%221.05%22%2F%3E%3C%2Fg%3E%3Cpath%20d%3D%22M14.6%2010%20C%2014.6%206.6%2C%2016.6%204.8%2C%2018.9%204.8%22%20stroke%3D%22%23f0a848%22%20stroke-width%3D%223%22%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%2F%3E%3Ccircle%20cx%3D%2219.5%22%20cy%3D%224.7%22%20r%3D%223%22%2F%3E%3Cpath%20d%3D%22M22.1%203.5%20L26.4%205%20L22.1%206.5%20Z%22%2F%3E%3Ccircle%20cx%3D%2220.2%22%20cy%3D%224.2%22%20r%3D%220.95%22%20fill%3D%22%2314161d%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
13
15
  <style>
14
16
  :root {
15
17
  --bg: #14161d;
@@ -679,6 +681,12 @@
679
681
  .prof-drift { margin-left: 8px; font-size: 11px; color: var(--amber); }
680
682
  /* À qui ce secret est injecté. Discret : c'est une information de contexte,
681
683
  pas une alerte — contrairement à sec-orphan juste en dessous. */
684
+ /* Le pont ESM n'a jamais chargé : le dire plutôt que d'afficher un écran
685
+ amputé sans explication. */
686
+ .bridge-dead {
687
+ position: fixed; left: 50%; transform: translateX(-50%); bottom: 12px; z-index: 200;
688
+ background: #e2564a; color: #fff; padding: 8px 14px; border-radius: 8px; font-size: 13px;
689
+ }
682
690
  .sec-users { margin-left: 8px; font-size: 11px; color: var(--text-dim); }
683
691
  .sec-orphan { margin-left: 8px; font-size: 11px; color: var(--amber); font-family: var(--sans, inherit); }
684
692
  /* Profile picker for a running agent (the tab's context menu). */
@@ -999,7 +1007,7 @@
999
1007
  .freetext-row input { flex: 1; }
1000
1008
  #thinking { display: none; align-self: flex-start; color: var(--text-dim); font-family: var(--mono); font-size: 12.5px; padding: 0 22px 6px; }
1001
1009
  #thinking.visible { display: flex; align-items: center; gap: 6px; }
1002
- /* Shimmering text (an amber sweep) + three bouncing dots. */
1010
+ /* Shimmering text (an amber sweep) + a little Shadok pumping alongside. */
1003
1011
  #thinking .tw-text {
1004
1012
  background: linear-gradient(90deg, var(--text-dim) 30%, var(--phosphor) 50%, var(--text-dim) 70%);
1005
1013
  background-size: 220% 100%;
@@ -1008,17 +1016,20 @@
1008
1016
  animation: tw-shimmer 1.9s linear infinite;
1009
1017
  }
1010
1018
  @keyframes tw-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
1011
- #thinking .tw-dots { display: inline-flex; align-items: flex-end; gap: 3px; }
1012
- #thinking .tw-dots i {
1013
- width: 3px; height: 3px; border-radius: 50%; background: var(--amber);
1014
- opacity: 0.3; animation: tw-dot 1.2s ease-in-out infinite;
1015
- }
1016
- #thinking .tw-dots i:nth-child(2) { animation-delay: 0.18s; }
1017
- #thinking .tw-dots i:nth-child(3) { animation-delay: 0.36s; }
1018
- @keyframes tw-dot { 0%, 100% { opacity: 0.25; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
1019
+ /* The pumping Shadok: `color` feeds its `currentColor` fill so it follows the
1020
+ palette. The body bobs and the arm swings — the pump — on the same beat. */
1021
+ #thinking .tw-shadok { display: inline-flex; color: var(--amber); }
1022
+ #thinking .tw-shadok svg { width: 20px; height: 20px; overflow: visible; }
1023
+ /* Body bobs with translate (no transform-origin needed — that unit is a
1024
+ cross-browser trap on SVG). The arm swings from its own top via fill-box, so
1025
+ it reads as working a pump handle. */
1026
+ #thinking .tw-shadok .sh-body { animation: tw-pump 0.7s ease-in-out infinite; }
1027
+ #thinking .tw-shadok .sh-arm { animation: tw-arm 0.7s ease-in-out infinite; transform-box: fill-box; transform-origin: top center; }
1028
+ @keyframes tw-pump { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(1.5px); } }
1029
+ @keyframes tw-arm { 0%, 100% { transform: rotate(-10deg); } 50% { transform: rotate(28deg); } }
1019
1030
  @media (prefers-reduced-motion: reduce) {
1020
1031
  #thinking .tw-text { animation: none; color: var(--phosphor); -webkit-text-fill-color: currentColor; }
1021
- #thinking .tw-dots i { animation: none; opacity: 0.6; }
1032
+ #thinking .tw-shadok .sh-body, #thinking .tw-shadok .sh-arm { animation: none; }
1022
1033
  }
1023
1034
 
1024
1035
  /* Composer */
@@ -1894,7 +1905,9 @@
1894
1905
  </div>
1895
1906
 
1896
1907
  <div id="transcripts"></div>
1897
- <div id="thinking"><span class="tw-text">Shadok is working</span><span class="tw-dots"><i></i><i></i><i></i></span></div>
1908
+ <!-- The Shadok is injected into .tw-shadok by the script (one drawing shared
1909
+ with the favicon, `shadokInner`); it pumps via CSS. -->
1910
+ <div id="thinking"><span class="tw-text">Shadok is pumping</span><span class="tw-shadok" aria-hidden="true"></span></div>
1898
1911
 
1899
1912
  <form id="composer" hidden>
1900
1913
  <textarea id="promptInput" rows="1"
@@ -1985,8 +1998,71 @@
1985
1998
  window.arcSegments = arcSegments;
1986
1999
  window.dialTitle = dialTitle;
1987
2000
  window.SWEEP_DEG = SWEEP_DEG;
2001
+ // Les bouchons du script classique sont maintenant remplacés par les vraies
2002
+ // fonctions : tout ce qui a pu être peint entre-temps est incomplet.
2003
+ window.dispatchEvent(new Event("shadok-bridge-ready"));
1988
2004
  </script>
1989
2005
  <script nonce="__CSP_NONCE__">
2006
+ // ── Bouchons du pont ESM (invariant 10) ─────────────────────────────────
2007
+ // Le bloc module ci-dessus est DIFFÉRÉ : il s'exécute après le parsing,
2008
+ // alors que ce script-ci tourne PENDANT et câble déjà les boutons.
2009
+ // Un clic dans cet intervalle appelait un window.<fn> inexistant — c'est
2010
+ // ainsi que `profileBadges` a levé sur un clic Secrets, à cinq lignes d'un
2011
+ // appel voisin correctement gardé. Garder chaque site d'appel était un
2012
+ // combat perdu : le suivant oubliait. Un bouchon neutre par nom, remplacé à
2013
+ // l'arrivée du module, rend tous les sites sûrs par construction.
2014
+ // test/esm-bridge.test.ts verrouille la correspondance nom ↔ bouchon.
2015
+ const BRIDGE_STUBS = {
2016
+ profileBlurb: () => "",
2017
+ profileBadges: () => [],
2018
+ defaultAgentName: (_p, cwd) => String(cwd ?? "").split("/").filter(Boolean).pop() || "agent",
2019
+ hasReadonlyPreset: () => false,
2020
+ applyReadonlyPreset: (deny) => deny,
2021
+ isManagedProfile: () => false,
2022
+ profileSaveBody: (form) => ({ ...form }),
2023
+ secretUsers: () => [],
2024
+ extractLiveText: () => "",
2025
+ livePreviewDecision: () => null,
2026
+ echoAuthor: () => "",
2027
+ notifyState: () => ({ color: null, badge: "", blink: false }),
2028
+ dialPos: () => 0,
2029
+ dialColor: () => "",
2030
+ arcSegments: () => [],
2031
+ dialTitle: () => "",
2032
+ bubblePlacement: () => null,
2033
+ unionRect: () => null,
2034
+ visibleSteps: (steps) => steps ?? [],
2035
+ };
2036
+ for (const [name, stub] of Object.entries(BRIDGE_STUBS)) {
2037
+ if (typeof window[name] !== "function") { stub.__stub = true; window[name] = stub; }
2038
+ }
2039
+ // Un bouchon empêche le plantage, il ne remplace pas la fonction : si le
2040
+ // module n'arrive JAMAIS (un des six fichiers en échec, une coupure), l'écran
2041
+ // resterait silencieusement amputé — pas de badges, pas d'aperçu en direct,
2042
+ // pas de notifications. Une dégradation muette est pire que l'erreur qu'elle
2043
+ // masque : on le dit.
2044
+ window.addEventListener("load", () => setTimeout(() => {
2045
+ const dead = Object.keys(BRIDGE_STUBS).filter((n) => window[n] && window[n].__stub);
2046
+ if (!dead.length) return;
2047
+ console.error("shadok-ai: le pont ESM n'a pas chargé — " + dead.join(", "));
2048
+ const bar = document.createElement("div");
2049
+ bar.className = "bridge-dead";
2050
+ bar.textContent = "⚠ This page did not load fully — some panels will look empty. Reload it.";
2051
+ document.body.appendChild(bar);
2052
+ }, 3000));
2053
+ // Un écran peint pendant que les bouchons étaient en place est incomplet
2054
+ // (une carte sans badges, une jauge à plat). Le module signale son arrivée :
2055
+ // on repeint ce qui est à l'écran, plutôt que de laisser un rendu dégradé.
2056
+ window.addEventListener("shadok-bridge-ready", () => {
2057
+ try {
2058
+ if (typeof renderProfileGrid === "function" && $("profileGrid")) renderProfileGrid(false);
2059
+ if (typeof renderProfilesList === "function" && !$("profilesOverlay").hidden) renderProfilesList();
2060
+ if (typeof renderSecrets === "function" && !$("secretsOverlay").hidden) renderSecrets();
2061
+ } catch {
2062
+ /* une repeinte ratée ne doit pas casser le chargement */
2063
+ }
2064
+ });
2065
+
1990
2066
  const $ = (id) => document.getElementById(id);
1991
2067
  const tabbar = $("tabbar"), newTabBtn = $("newTab"), newGroupBtn = $("newGroup");
1992
2068
  const ungroupedEl = $("ungrouped"), groupsEl = $("groups");
@@ -3091,33 +3167,67 @@
3091
3167
  const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
3092
3168
  return v || fallback;
3093
3169
  }
3094
- /** SVG data-URI favicon: the base mark, plus a coloured corner pip if given. */
3095
- /** The indicator fills the WHOLE favicon: at rest, a dark background + an
3096
- * accent chevron; for a state (red = to do, amber = to read) the whole square
3097
- * takes the colour, dark chevron on top. Far more visible than a small pip. */
3098
- function faviconSVG(bg) {
3170
+ /** The Shadok itself — the cockpit's mascot, which famously PUMPS. One drawing
3171
+ * serves every use: the favicon states here and the in-page "pumping"
3172
+ * indicator. `dx` shifts it left to clear room for a "!" / "?"; `bob`/`armRot`
3173
+ * pose a single frame of the pump (the favicon animates by re-generating with
3174
+ * new values, like the old spinner did); `anim` instead tags the body and arm
3175
+ * groups so CSS can drive them (used only in the DOM indicator, never a data
3176
+ * URI — CSS animation inside a favicon is unreliable). Legs stay planted. */
3177
+ function shadokInner(fill, { dx = 0, bob = 0, armRot = 6, anim = false } = {}) {
3099
3178
  const base = themeColor("--bg", "#14161d");
3100
- const accent = themeColor("--amber", "#f0a848");
3101
- const svg =
3102
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">` +
3103
- `<rect width="32" height="32" rx="7" fill="${bg || base}"/>` +
3104
- `<text x="16" y="25" font-family="monospace" font-size="26" font-weight="700" fill="${bg ? base : accent}" text-anchor="middle">›</text>` +
3105
- `</svg>`;
3106
- return "data:image/svg+xml," + encodeURIComponent(svg);
3107
- }
3108
- /** The "working" favicon: one BIG centred spinner filling the favicon (a faint
3109
- * track + a rotating accent arc). */
3110
- function faviconWorkingSVG(angle) {
3179
+ const bodyCls = anim ? ' class="sh-body"' : "";
3180
+ const armCls = anim ? ' class="sh-arm"' : "";
3181
+ const bodyTf = anim ? "" : ` transform="translate(0 ${bob})"`;
3182
+ const armTf = anim ? "" : ` transform="rotate(${armRot} 19.6 15.6)"`;
3183
+ return (
3184
+ `<g transform="translate(${dx} 0)">` +
3185
+ // legs + feet (planted, not part of the bob)
3186
+ `<g fill="${fill}">` +
3187
+ `<rect x="11.4" y="21" width="1.7" height="9" rx="0.85"/><rect x="17.4" y="21" width="1.7" height="9" rx="0.85"/>` +
3188
+ `<rect x="9.2" y="28.7" width="4.6" height="1.7" rx="0.85"/><rect x="16.4" y="28.7" width="4.6" height="1.7" rx="0.85"/>` +
3189
+ `</g>` +
3190
+ // body + pumping arm + long neck + head + beak + eye
3191
+ `<g${bodyCls} fill="${fill}"${bodyTf}>` +
3192
+ `<ellipse cx="15" cy="16.8" rx="7.4" ry="7.6"/>` +
3193
+ `<g${armCls}${armTf}><rect x="18.6" y="15" width="2.1" height="8.4" rx="1.05"/></g>` +
3194
+ `<path d="M14.6 10 C 14.6 6.6, 16.6 4.8, 18.9 4.8" stroke="${fill}" stroke-width="3" fill="none" stroke-linecap="round"/>` +
3195
+ `<circle cx="19.5" cy="4.7" r="3"/><path d="M22.1 3.5 L26.4 5 L22.1 6.5 Z"/>` +
3196
+ `<circle cx="20.2" cy="4.2" r="0.95" fill="${base}"/>` +
3197
+ `</g></g>`
3198
+ );
3199
+ }
3200
+ function faviconWrap(inner) {
3111
3201
  const base = themeColor("--bg", "#14161d");
3202
+ return (
3203
+ "data:image/svg+xml," +
3204
+ encodeURIComponent(
3205
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">` +
3206
+ `<rect width="32" height="32" rx="7" fill="${base}"/>${inner}</svg>`,
3207
+ )
3208
+ );
3209
+ }
3210
+ /** The favicon: an accent Shadok on the dark ground. A waiting state adds a
3211
+ * bold "!" (something to answer) or "?" (something to read) in its colour, and
3212
+ * the Shadok slides left to make room — the coloured glyph is what the user
3213
+ * asked to distinguish the two. No symbol → the idle Shadok. */
3214
+ function faviconSVG(symbol, symColor) {
3112
3215
  const accent = themeColor("--amber", "#f0a848");
3113
- const track = accent + "38"; // ~0.22 alpha on a 6-digit-hex accent
3114
- const svg =
3115
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">` +
3116
- `<rect width="32" height="32" rx="7" fill="${base}"/>` +
3117
- `<circle cx="16" cy="16" r="11" fill="none" stroke="${track}" stroke-width="4"/>` +
3118
- `<g transform="rotate(${angle} 16 16)"><circle cx="16" cy="16" r="11" fill="none" stroke="${accent}" stroke-width="4" stroke-linecap="round" stroke-dasharray="34 100"/></g>` +
3119
- `</svg>`;
3120
- return "data:image/svg+xml," + encodeURIComponent(svg);
3216
+ const shadok = shadokInner(accent, symbol ? { dx: -3.6 } : {});
3217
+ const sym = symbol
3218
+ ? `<text x="25.8" y="13" font-family="monospace" font-size="15" font-weight="800" fill="${symColor || accent}" text-anchor="middle">${symbol}</text>`
3219
+ : "";
3220
+ return faviconWrap(shadok + sym);
3221
+ }
3222
+ /** The "working" favicon: the Shadok pumping. `phase` (0..PUMP_FRAMES-1) rides
3223
+ * a cycle — the body bobs and the arm swings, re-generated each tick. */
3224
+ const PUMP_FRAMES = 8;
3225
+ function faviconPumpingSVG(phase) {
3226
+ const accent = themeColor("--amber", "#f0a848");
3227
+ const p = (Math.sin((phase / PUMP_FRAMES) * 2 * Math.PI - Math.PI / 2) + 1) / 2; // 0..1..0
3228
+ const bob = +(p * 1.8).toFixed(2);
3229
+ const armRot = Math.round(-8 + p * 32); // -8 (up) .. +24 (down)
3230
+ return faviconWrap(shadokInner(accent, { bob, armRot }));
3121
3231
  }
3122
3232
  /** What each channel is asking for, as the pure module wants it. */
3123
3233
  function attentionInput() {
@@ -3133,8 +3243,8 @@
3133
3243
  // costs no timer at all.
3134
3244
  let blinkTimer = null;
3135
3245
  let blinkPhase = 0;
3136
- let workTimer = null; // animates the "working" favicon (spinner)
3137
- let workAngle = 0;
3246
+ let workTimer = null; // animates the "working" favicon (the pumping Shadok)
3247
+ let workPhase = 0;
3138
3248
  function stopWork() { if (workTimer) { clearInterval(workTimer); workTimer = null; } }
3139
3249
  function paint() {
3140
3250
  // The ESM bridge lands after the document is parsed (gotcha #10): until it
@@ -3154,20 +3264,24 @@
3154
3264
  phase: blinkPhase,
3155
3265
  });
3156
3266
  document.title = s.badge + titleBase;
3157
- // "working" = animated favicon (spinner). The module only returns that mode
3267
+ // "working" = the animated pumping Shadok. The module only returns that mode
3158
3268
  // when NOTHING of higher priority (blocked / unread) is waiting — priority is
3159
3269
  // therefore handled upstream: a waiting agent always beats a busy one.
3160
3270
  if (s.mode === "working") {
3161
3271
  if (blinkTimer) { clearInterval(blinkTimer); blinkTimer = null; blinkPhase = 0; }
3162
3272
  if (!workTimer) {
3163
- const tick = () => { workAngle = (workAngle + 30) % 360; favicon.href = faviconWorkingSVG(workAngle); };
3273
+ const tick = () => { workPhase = (workPhase + 1) % PUMP_FRAMES; favicon.href = faviconPumpingSVG(workPhase); };
3164
3274
  tick();
3165
- workTimer = setInterval(tick, 120);
3275
+ workTimer = setInterval(tick, 110);
3166
3276
  }
3167
3277
  return;
3168
3278
  }
3169
3279
  stopWork();
3170
- favicon.href = faviconSVG(s.color);
3280
+ // A waiting Shadok carries the glyph the user asked for: "!" when it needs an
3281
+ // answer, "?" when something's there to read. The colour is notifyState's
3282
+ // (RED / RED_DIM on the away-blink, AMBER for unread); idle → no glyph.
3283
+ const symbol = s.mode === "blocked" ? "!" : s.mode === "unread" ? "?" : null;
3284
+ favicon.href = faviconSVG(symbol, s.color);
3171
3285
  if (s.blink && !blinkTimer) {
3172
3286
  blinkTimer = setInterval(() => { blinkPhase ^= 1; paint(); }, window.BLINK_MS || 900);
3173
3287
  } else if (!s.blink && blinkTimer) {
@@ -3177,6 +3291,16 @@
3177
3291
  }
3178
3292
  }
3179
3293
  function refreshBadge() { paint(); }
3294
+ // The in-page "pumping" Shadok, drawn once from the SAME `shadokInner` as the
3295
+ // favicon. `currentColor` lets the CSS palette (var(--amber)) tint it, so it
3296
+ // follows a theme change with no redraw; `anim` tags the body/arm for the CSS
3297
+ // pump. The element sits earlier in the document, so it's here to fill now.
3298
+ {
3299
+ const twShadok = document.querySelector("#thinking .tw-shadok");
3300
+ if (twShadok)
3301
+ twShadok.innerHTML =
3302
+ `<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">${shadokInner("currentColor", { anim: true })}</svg>`;
3303
+ }
3180
3304
  // Coming back must stop the blink at once, not at the next tick. Both signals
3181
3305
  // matter: switching tabs fires visibilitychange, switching application only
3182
3306
  // fires focus/blur.
@@ -3324,7 +3448,7 @@
3324
3448
  $("chanBarName").title = t.cwd || "";
3325
3449
  updateSendDisabled(); // t === active here; also honours a pending upload
3326
3450
  thinking.classList.toggle("visible", t.status === "busy");
3327
- // Showing/hiding "Shadok is working…" changes the transcript area's height:
3451
+ // Showing/hiding "Shadok is pumping" changes the transcript area's height:
3328
3452
  // without re-pinning to the bottom, the last message slips below the fold
3329
3453
  // (typically "cut off" right after sending a prompt).
3330
3454
  pinBottom(t);
package/public/notify.js CHANGED
@@ -65,8 +65,8 @@ export function notifyState(channels, view) {
65
65
  : { color: RED, badge: "● ", blink: true, mode: "blocked" };
66
66
  }
67
67
  if (unread) return { color: AMBER, badge: "● ", blink: false, mode: "unread" };
68
- // "working" has neither pip colour nor badge: the favicon is animated (a
69
- // spinner) by the caller for as long as the mode is "working".
68
+ // "working" has neither pip colour nor badge: the caller animates the favicon
69
+ // (the Shadok pumping) for as long as the mode is "working".
70
70
  if (working) return { color: null, badge: "", blink: false, mode: "working" };
71
71
  return { color: null, badge: "", blink: false, mode: null };
72
72
  }