shadok-ai 0.2.55 → 0.2.56

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/README.md CHANGED
@@ -92,6 +92,14 @@ Enter to skip; you can add it later from the web UI).
92
92
  the card becomes that channel's tab, with the usual menu — mute, reload,
93
93
  rename, change profile, mirror to Telegram, close — and closing it brings the
94
94
  card back. Desktop only: the agents column is hidden on phones.
95
+ - **Guided tour** — on a browser's first visit, a welcome card explains what
96
+ shadok-ai is, then a spotlight walks the real controls: the agents column, an
97
+ agent's ⋯ menu, the toolbar, the quota dials. Skip and Escape end it at any
98
+ point; ⋯ → *Guided tour* replays it. A step whose target isn't on screen is
99
+ **dropped rather than faked**, so the phone (no agents column) and an empty
100
+ cockpit (no agent tab) get a shorter tour instead of a spotlight on nothing.
101
+ On a brand-new instance it comes **before** the sign-in card — being asked to
102
+ authorise an OAuth flow before you know what the thing is takes it backwards.
95
103
  - **Self-update** — polls npm and can update and reload itself in place.
96
104
 
97
105
  ### Telegram (optional)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.2.55",
3
+ "version": "0.2.56",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test test/*.ts .claude/skills/shadok-ai-agents/test/*.test.mjs",
package/public/index.html CHANGED
@@ -1212,6 +1212,35 @@
1212
1212
  backdrop-filter: blur(2px);
1213
1213
  }
1214
1214
  .overlay[hidden] { display: none; }
1215
+ /* Above the other popins: during the tour it is the thing being interacted
1216
+ with, and the sign-in card may already be on screen behind it. */
1217
+ #tourOverlay { z-index: 60; }
1218
+ #tourBubble {
1219
+ max-width: 340px;
1220
+ background: var(--bg-raised);
1221
+ border: 1px solid var(--line);
1222
+ border-radius: 10px;
1223
+ padding: 14px 16px;
1224
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
1225
+ }
1226
+ #tourBubble strong { display: block; margin-bottom: 6px; }
1227
+ #tourBubble p { color: var(--text-dim); font-size: 13px; line-height: 1.5; }
1228
+ #tourFoot { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
1229
+ #tourFoot .spacer { flex: 1; }
1230
+ /* Spotlight mode: the overlay stops centring and stops dimming — the hole
1231
+ does the dimming, so the target stays at full brightness. */
1232
+ #tourOverlay.spotlight { display: block; padding: 0; background: transparent; backdrop-filter: none; }
1233
+ #tourOverlay.spotlight #tourBubble { position: fixed; }
1234
+ #tourHole {
1235
+ position: fixed;
1236
+ border-radius: 10px;
1237
+ box-shadow: 0 0 0 9999px rgba(8, 9, 13, 0.72);
1238
+ /* Clicks in the hole fall through to the overlay, which ends the tour —
1239
+ the same "click outside" gesture as everywhere else. */
1240
+ pointer-events: none;
1241
+ transition: top .18s ease, left .18s ease, width .18s ease, height .18s ease;
1242
+ }
1243
+ #tourOverlay:not(.spotlight) #tourHole { display: none; }
1215
1244
  /* A real OAuth URL is ~400 chars. Shown in full on purpose — a headless
1216
1245
  server's operator often has to carry it to another device by hand — so it
1217
1246
  has to wrap instead of pushing the panel off screen. The page sets no <a>
@@ -1373,6 +1402,7 @@
1373
1402
  <button id="moreBtn" class="icon" title="More">⋯</button>
1374
1403
  <div id="moreMenu" hidden>
1375
1404
  <button id="toggleDiff">Diff</button>
1405
+ <button id="tourReplay">Guided tour</button>
1376
1406
  </div>
1377
1407
  </div>
1378
1408
  </div>
@@ -1631,6 +1661,23 @@
1631
1661
  </div>
1632
1662
  </div>
1633
1663
 
1664
+ <!-- The guided tour. ONE overlay, two modes: centred (the welcome card, using
1665
+ .overlay's own flex centring) and spotlight (.spotlight), where the dimming
1666
+ comes from #tourHole's huge box-shadow instead of the overlay background. -->
1667
+ <div id="tourOverlay" class="overlay" hidden>
1668
+ <div id="tourHole" aria-hidden="true"></div>
1669
+ <div id="tourBubble" role="dialog" aria-labelledby="tourTitle">
1670
+ <strong id="tourTitle"></strong>
1671
+ <p id="tourBody"></p>
1672
+ <div id="tourFoot">
1673
+ <span id="tourCount" class="check-hint"></span>
1674
+ <span class="spacer"></span>
1675
+ <button id="tourSkip">Skip</button>
1676
+ <button id="tourNext" class="primary">Next ›</button>
1677
+ </div>
1678
+ </div>
1679
+ </div>
1680
+
1634
1681
  <main>
1635
1682
  <nav id="tabbar" aria-label="Agents">
1636
1683
  <div class="label side-label">Agents</div>
@@ -1758,6 +1805,11 @@
1758
1805
  import { notifyState, BLINK_MS } from "/notify.js";
1759
1806
  import { echoAuthor } from "/echo-author.js";
1760
1807
  import { dialPos, dialColor, arcSegments, dialTitle, SWEEP_DEG } from "/gauge-dial.js";
1808
+ import { TOUR_STEPS, bubblePlacement, unionRect, visibleSteps } from "/tour-steps.js";
1809
+ window.TOUR_STEPS = TOUR_STEPS;
1810
+ window.bubblePlacement = bubblePlacement;
1811
+ window.unionRect = unionRect;
1812
+ window.visibleSteps = visibleSteps;
1761
1813
  window.extractLiveText = extractLiveText;
1762
1814
  window.profileBlurb = profileBlurb;
1763
1815
  window.profileBadges = profileBadges;
@@ -5215,6 +5267,117 @@
5215
5267
  if (r.ok) renderTelegram(await r.json());
5216
5268
  else alert((await r.json()).error || "update failed");
5217
5269
  }
5270
+ // --- Guided tour --------------------------------------------------------
5271
+ const TOUR_SEEN_KEY = "shadok.tourSeen";
5272
+ let tourSteps = [];
5273
+ let tourAt = 0;
5274
+ let tourOnEnd = null;
5275
+
5276
+ /** Every element a step points at, in DOM order. */
5277
+ function tourTargets(target) {
5278
+ const sels = Array.isArray(target) ? target : [target];
5279
+ return sels.map((s) => document.querySelector(s)).filter(Boolean);
5280
+ }
5281
+
5282
+ /** On screen = present, not hidden, and actually occupying space. */
5283
+ function tourVisible(target) {
5284
+ return tourTargets(target).some((el) => {
5285
+ const r = el.getBoundingClientRect();
5286
+ return r.width > 0 && r.height > 0;
5287
+ });
5288
+ }
5289
+
5290
+ function paintTourStep() {
5291
+ const step = tourSteps[tourAt];
5292
+ if (!step) return endTour();
5293
+ const overlay = $("tourOverlay");
5294
+ $("tourTitle").textContent = step.title;
5295
+ $("tourBody").textContent = step.body;
5296
+ $("tourCount").textContent = `${tourAt + 1}/${tourSteps.length}`;
5297
+ $("tourNext").textContent = tourAt === tourSteps.length - 1 ? "Done" : "Next ›";
5298
+
5299
+ if (step.target === null) {
5300
+ overlay.classList.remove("spotlight");
5301
+ $("tourBubble").style.top = $("tourBubble").style.left = "";
5302
+ return;
5303
+ }
5304
+ const rects = tourTargets(step.target).map((el) => {
5305
+ const r = el.getBoundingClientRect();
5306
+ return { top: r.top, left: r.left, width: r.width, height: r.height };
5307
+ });
5308
+ const box = window.unionRect(rects);
5309
+ // The step was filtered in because it was visible; if it vanished since
5310
+ // (a closed tab), skip forward rather than frame nothing.
5311
+ if (!box) { tourAt++; return paintTourStep(); }
5312
+ overlay.classList.add("spotlight");
5313
+ const pad = 6;
5314
+ Object.assign($("tourHole").style, {
5315
+ top: box.top - pad + "px",
5316
+ left: box.left - pad + "px",
5317
+ width: box.width + pad * 2 + "px",
5318
+ height: box.height + pad * 2 + "px",
5319
+ });
5320
+ const b = $("tourBubble").getBoundingClientRect();
5321
+ const p = window.bubblePlacement({
5322
+ target: box,
5323
+ bubble: { width: b.width || 340, height: b.height || 160 },
5324
+ viewport: { width: window.innerWidth, height: window.innerHeight },
5325
+ gap: 12,
5326
+ });
5327
+ Object.assign($("tourBubble").style, { top: p.top + "px", left: p.left + "px" });
5328
+ }
5329
+
5330
+ /** Start the tour. `onEnd` runs once, whether it is completed or skipped. */
5331
+ function startTour(onEnd) {
5332
+ // window.visibleSteps comes from the ESM bridge, which runs AFTER this
5333
+ // classic script — so this must never be called at parse time (invariant 10).
5334
+ tourSteps = window.visibleSteps(window.TOUR_STEPS, tourVisible);
5335
+ if (!tourSteps.length) { onEnd?.(); return; }
5336
+ tourOnEnd = onEnd ?? null;
5337
+ tourAt = 0;
5338
+ $("tourOverlay").hidden = false;
5339
+ paintTourStep();
5340
+ }
5341
+
5342
+ function endTour() {
5343
+ $("tourOverlay").hidden = true;
5344
+ $("tourOverlay").classList.remove("spotlight");
5345
+ try { localStorage.setItem(TOUR_SEEN_KEY, "1"); } catch { /* private mode */ }
5346
+ const done = tourOnEnd;
5347
+ tourOnEnd = null;
5348
+ done?.();
5349
+ }
5350
+
5351
+ $("tourNext").addEventListener("click", () => { tourAt++; paintTourStep(); });
5352
+ $("tourSkip").addEventListener("click", endTour);
5353
+ // A click on the overlay itself — including through the hole, which has
5354
+ // pointer-events: none — ends it. Nobody is trapped in a tutorial.
5355
+ $("tourOverlay").addEventListener("click", (e) => { if (e.target === $("tourOverlay")) endTour(); });
5356
+ document.addEventListener("keydown", (e) => {
5357
+ if (e.key === "Escape" && !$("tourOverlay").hidden) { endTour(); e.preventDefault(); }
5358
+ });
5359
+ // Re-place on resize: a rotated phone moves every target.
5360
+ window.addEventListener("resize", () => { if (!$("tourOverlay").hidden) paintTourStep(); });
5361
+ $("tourReplay").addEventListener("click", () => {
5362
+ $("moreMenu").hidden = true;
5363
+ startTour(null);
5364
+ });
5365
+
5366
+ /**
5367
+ * First load: welcome and tour BEFORE the sign-in card.
5368
+ *
5369
+ * A fresh instance is signed out, so the card would otherwise be the very
5370
+ * first thing anyone sees — asking someone to authorise an OAuth flow before
5371
+ * they know what the thing is. The card is DEFERRED, never skipped: signing
5372
+ * in is not optional, so it opens the moment the tour ends either way.
5373
+ */
5374
+ function maybeStartTour() {
5375
+ let seen = false;
5376
+ try { seen = !!localStorage.getItem(TOUR_SEEN_KEY); } catch { seen = true; }
5377
+ if (seen) { checkAuth(); return; }
5378
+ startTour(() => checkAuth());
5379
+ }
5380
+
5218
5381
  // --- Sign-in card -------------------------------------------------------
5219
5382
  // Opened at exactly two moments: on load when /auth reports signed out, and
5220
5383
  // on a `start` refused with code "logged-out". Never otherwise.
@@ -5461,7 +5624,7 @@
5461
5624
  document.addEventListener("DOMContentLoaded", () => {
5462
5625
  buildDialArcs();
5463
5626
  refreshUsage();
5464
- checkAuth();
5627
+ maybeStartTour(); // the tour runs first; it calls checkAuth() when it ends
5465
5628
  });
5466
5629
  setInterval(refreshUsage, 60_000);
5467
5630
 
@@ -0,0 +1,108 @@
1
+ // The guided tour: its step data and its geometry, with no DOM in sight.
2
+ //
3
+ // Loaded as-is by the browser (ESM, served by express.static) and imported by
4
+ // the node/tsx tests, like public/gauge-dial.js and public/notify.js. Keeping
5
+ // the arithmetic here is the point: bubble placement is what silently breaks on
6
+ // a screen size nobody tried, and that is exactly what a unit test can hold.
7
+ //
8
+ // See docs/superpowers/specs/2026-08-11-guided-tour-design.md.
9
+
10
+ /**
11
+ * The tour, grouped on purpose: a spotlight frames a REGION, so nine landmarks
12
+ * fit in four stops. A step per button is a tour people abandon.
13
+ *
14
+ * `target` is a CSS selector, an array of them (framed as one rectangle), or
15
+ * null for the centred welcome card.
16
+ */
17
+ export const TOUR_STEPS = [
18
+ {
19
+ id: "welcome",
20
+ title: "Welcome to shadok-ai",
21
+ body:
22
+ "Every agent here is a real Claude Code session, running in its own directory " +
23
+ "and its own git branch, so several can work at once without colliding. " +
24
+ "You drive them from this page — or from Telegram, where one topic is one agent.",
25
+ target: null,
26
+ },
27
+ {
28
+ id: "agents",
29
+ title: "Your agents live here",
30
+ body:
31
+ "Start one with “new agent”, and group them once there are a few. " +
32
+ "At the bottom, “Tweak Shadok-AI” starts an agent on the cockpit's own source — " +
33
+ "it delivers its work as a pull request.",
34
+ target: "#tabbar",
35
+ },
36
+ {
37
+ id: "tab",
38
+ title: "Each agent has its own menu",
39
+ body:
40
+ "The ⋯ on an agent's tab is where its controls live: mute it, reload it, " +
41
+ "rename it, change its profile, mirror it to Telegram, or close it.",
42
+ target: ".tab.active",
43
+ },
44
+ {
45
+ id: "tools",
46
+ title: "The toolbar",
47
+ body:
48
+ "🔑 secrets injected into agents that need them · 👤 profiles (role, guardrails, model) · " +
49
+ "⏰ scheduled prompts for monitoring · Telegram settings · 🔔 notifications · " +
50
+ "⋯ for the diff of what an agent changed.",
51
+ // NOT `.hdr-tools`, which is `display: contents` on desktop and therefore
52
+ // generates no box at all — its rect is all zeros, so the step would have
53
+ // been dropped as "not visible" on the very layout where the toolbar is
54
+ // most obvious. Framing the buttons themselves works on both layouts.
55
+ target: ["#secretsBtn", "#profilesBtn", "#cronBtn", "#telegramBtn", "#muteNotif", "#moreBtn"],
56
+ },
57
+ {
58
+ id: "quota",
59
+ title: "Watch your quota",
60
+ body:
61
+ "Your 5h and 7d subscription usage. The needle's centre is the pace that would " +
62
+ "spend the window exactly on time, so leaning right means you're burning faster. " +
63
+ "The version number next to the cockpit's name opens updates and the permission mode.",
64
+ target: ["#quota5h", "#quota7d"],
65
+ },
66
+ ];
67
+
68
+ /** Pure: the steps whose target is on screen, in order. */
69
+ export function visibleSteps(steps, isVisible) {
70
+ return steps.filter((s) => s.target === null || isVisible(s.target));
71
+ }
72
+
73
+ /**
74
+ * Pure: the rectangle enclosing them all, or null when there are none.
75
+ *
76
+ * Null rather than a zero rect: a caller that framed {0,0,0,0} would put the
77
+ * spotlight on the page's top-left corner instead of skipping the step.
78
+ */
79
+ export function unionRect(rects) {
80
+ if (!rects || !rects.length) return null;
81
+ const top = Math.min(...rects.map((r) => r.top));
82
+ const left = Math.min(...rects.map((r) => r.left));
83
+ const bottom = Math.max(...rects.map((r) => r.top + r.height));
84
+ const right = Math.max(...rects.map((r) => r.left + r.width));
85
+ return { top, left, width: right - left, height: bottom - top };
86
+ }
87
+
88
+ /** Keeps `v` within [lo, hi]; when hi < lo (a bubble bigger than the screen), lo wins. */
89
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi));
90
+
91
+ /**
92
+ * Pure: where to put the bubble for a given target.
93
+ *
94
+ * Below by default, flipped above when it would overflow the bottom, and
95
+ * clamped to the viewport on both axes so it is never partly off screen.
96
+ */
97
+ export function bubblePlacement({ target, bubble, viewport, gap = 12 }) {
98
+ const below = target.top + target.height + gap;
99
+ const fitsBelow = below + bubble.height + gap <= viewport.height;
100
+ const side = fitsBelow ? "below" : "above";
101
+ const rawTop = fitsBelow ? below : target.top - bubble.height - gap;
102
+ const rawLeft = target.left + target.width / 2 - bubble.width / 2;
103
+ return {
104
+ side,
105
+ top: clamp(rawTop, gap, viewport.height - bubble.height - gap),
106
+ left: clamp(rawLeft, gap, viewport.width - bubble.width - gap),
107
+ };
108
+ }