shadok-ai 0.2.54 → 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 +8 -0
- package/package.json +1 -1
- package/public/index.html +167 -6
- package/public/tour-steps.js +108 -0
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
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>
|
|
@@ -1367,13 +1396,13 @@
|
|
|
1367
1396
|
<div class="hdr-tools">
|
|
1368
1397
|
<button id="secretsBtn" class="icon" title="Repo secrets (injected as env)">🔑</button>
|
|
1369
1398
|
<button id="profilesBtn" class="icon" title="Agent profiles (role + guardrails + secrets)">👤</button>
|
|
1370
|
-
<button id="cronBtn" class="icon" title="Scheduled prompts for this agent (monitoring / reporting)">⏰</button>
|
|
1371
1399
|
<button id="telegramBtn" class="icon" title="Configure the Telegram bot (token, on/off, allowed chats)"><svg viewBox="0 0 24 24" width="15" height="15" style="vertical-align:-2px" aria-hidden="true"><path fill="#26A5E4" d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg></button>
|
|
1372
1400
|
<button id="muteNotif" class="icon" title="Mute the notification sound">🔔</button>
|
|
1373
1401
|
<div class="more-wrap">
|
|
1374
1402
|
<button id="moreBtn" class="icon" title="More">⋯</button>
|
|
1375
1403
|
<div id="moreMenu" hidden>
|
|
1376
1404
|
<button id="toggleDiff">Diff</button>
|
|
1405
|
+
<button id="tourReplay">Guided tour</button>
|
|
1377
1406
|
</div>
|
|
1378
1407
|
</div>
|
|
1379
1408
|
</div>
|
|
@@ -1632,6 +1661,23 @@
|
|
|
1632
1661
|
</div>
|
|
1633
1662
|
</div>
|
|
1634
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
|
+
|
|
1635
1681
|
<main>
|
|
1636
1682
|
<nav id="tabbar" aria-label="Agents">
|
|
1637
1683
|
<div class="label side-label">Agents</div>
|
|
@@ -1759,6 +1805,11 @@
|
|
|
1759
1805
|
import { notifyState, BLINK_MS } from "/notify.js";
|
|
1760
1806
|
import { echoAuthor } from "/echo-author.js";
|
|
1761
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;
|
|
1762
1813
|
window.extractLiveText = extractLiveText;
|
|
1763
1814
|
window.profileBlurb = profileBlurb;
|
|
1764
1815
|
window.profileBadges = profileBadges;
|
|
@@ -2979,13 +3030,13 @@
|
|
|
2979
3030
|
screenEl.textContent = "";
|
|
2980
3031
|
// Programmer ou arrêter n'a pas de cible : mieux vaut des boutons éteints
|
|
2981
3032
|
// qu'une popin qui parle d'un agent inexistant.
|
|
2982
|
-
$("
|
|
3033
|
+
$("chanCron").disabled = true;
|
|
2983
3034
|
$("chanStop").disabled = true;
|
|
2984
3035
|
$("emptyState").classList.add("visible");
|
|
2985
3036
|
return;
|
|
2986
3037
|
}
|
|
2987
3038
|
$("emptyState").classList.remove("visible");
|
|
2988
|
-
$("
|
|
3039
|
+
$("chanCron").disabled = !t.sessionId;
|
|
2989
3040
|
$("chanStop").disabled = false;
|
|
2990
3041
|
$("led").className = "led " +
|
|
2991
3042
|
({ setup: "", connecting: "busy", ready: "on", busy: "busy", dead: "off" }[t.status]);
|
|
@@ -4592,7 +4643,7 @@
|
|
|
4592
4643
|
active.termOn = !active.termOn;
|
|
4593
4644
|
syncTerm();
|
|
4594
4645
|
});
|
|
4595
|
-
$("chanCron").addEventListener("click", () => $("
|
|
4646
|
+
$("chanCron").addEventListener("click", () => { $("cronOverlay").hidden = false; renderCrons(); });
|
|
4596
4647
|
$("chanStop").addEventListener("click", () => {
|
|
4597
4648
|
sendTo(active, { type: "stop" });
|
|
4598
4649
|
});
|
|
@@ -4860,7 +4911,6 @@
|
|
|
4860
4911
|
$("cronDailyFields").hidden = !daily;
|
|
4861
4912
|
$("cronIntervalFields").hidden = daily;
|
|
4862
4913
|
});
|
|
4863
|
-
$("cronBtn").addEventListener("click", () => { $("cronOverlay").hidden = false; renderCrons(); });
|
|
4864
4914
|
$("cronClose").addEventListener("click", () => { $("cronOverlay").hidden = true; });
|
|
4865
4915
|
$("cronOverlay").addEventListener("click", (e) => { if (e.target === $("cronOverlay")) $("cronOverlay").hidden = true; });
|
|
4866
4916
|
$("cronAdd").addEventListener("submit", async (e) => {
|
|
@@ -5217,6 +5267,117 @@
|
|
|
5217
5267
|
if (r.ok) renderTelegram(await r.json());
|
|
5218
5268
|
else alert((await r.json()).error || "update failed");
|
|
5219
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
|
+
|
|
5220
5381
|
// --- Sign-in card -------------------------------------------------------
|
|
5221
5382
|
// Opened at exactly two moments: on load when /auth reports signed out, and
|
|
5222
5383
|
// on a `start` refused with code "logged-out". Never otherwise.
|
|
@@ -5463,7 +5624,7 @@
|
|
|
5463
5624
|
document.addEventListener("DOMContentLoaded", () => {
|
|
5464
5625
|
buildDialArcs();
|
|
5465
5626
|
refreshUsage();
|
|
5466
|
-
checkAuth()
|
|
5627
|
+
maybeStartTour(); // the tour runs first; it calls checkAuth() when it ends
|
|
5467
5628
|
});
|
|
5468
5629
|
setInterval(refreshUsage, 60_000);
|
|
5469
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
|
+
}
|