@spooky-sync/core 0.0.1-canary.261 → 0.0.1-canary.262
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/dist/index.js +157 -22
- package/package.json +3 -3
- package/src/modules/auth/impersonation-banner.ts +181 -20
package/dist/index.js
CHANGED
|
@@ -1076,8 +1076,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
1076
1076
|
|
|
1077
1077
|
//#endregion
|
|
1078
1078
|
//#region src/modules/devtools/index.ts
|
|
1079
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
1080
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
1079
|
+
const CORE_VERSION = "0.0.1-canary.262";
|
|
1080
|
+
const WASM_VERSION = "0.0.1-canary.262";
|
|
1081
1081
|
const SURREAL_VERSION = "3.0.3";
|
|
1082
1082
|
var DevToolsService = class DevToolsService {
|
|
1083
1083
|
eventsHistory = [];
|
|
@@ -13025,7 +13025,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
13025
13025
|
return new TabsCoordinator({
|
|
13026
13026
|
tabId: s.tabId,
|
|
13027
13027
|
fingerprint: computeTabsFingerprint({
|
|
13028
|
-
coreVersion: "0.0.1-canary.
|
|
13028
|
+
coreVersion: "0.0.1-canary.262",
|
|
13029
13029
|
schemaHash: hash53(config.schemaSurql),
|
|
13030
13030
|
endpoint: config.database.endpoint ?? "",
|
|
13031
13031
|
namespace: config.database.namespace,
|
|
@@ -13296,11 +13296,22 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
13296
13296
|
|
|
13297
13297
|
//#endregion
|
|
13298
13298
|
//#region src/modules/auth/impersonation-banner.ts
|
|
13299
|
-
/** Height the
|
|
13300
|
-
* `--sp00ky-impersonation-banner-height` on `<html>` (`0px`
|
|
13301
|
-
* an app can offset fixed
|
|
13302
|
-
const BANNER_HEIGHT_PX =
|
|
13299
|
+
/** Height of the warning layer the page is shifted down by. Also published as
|
|
13300
|
+
* the CSS variable `--sp00ky-impersonation-banner-height` on `<html>` (`0px`
|
|
13301
|
+
* when hidden), so an app can offset its own fixed header. */
|
|
13302
|
+
const BANNER_HEIGHT_PX = 44;
|
|
13303
|
+
/** Radius of the page's new top corners. */
|
|
13304
|
+
const PAGE_RADIUS_PX = 14;
|
|
13303
13305
|
const HOST_TAG = "sp00ky-impersonation-banner";
|
|
13306
|
+
/** Hazard stripes: the warning layer, and the `<html>` backdrop the page's
|
|
13307
|
+
* rounded corners reveal. */
|
|
13308
|
+
const STRIPES = "repeating-linear-gradient(45deg, #f2b600 0 14px, #1c1403 14px 28px)";
|
|
13309
|
+
/** Keeps only the area OUTSIDE a corner's curve, so the nub is striped and
|
|
13310
|
+
* everything inside the curve stays the app's own. `center` is the curve's
|
|
13311
|
+
* centre inside the corner square. */
|
|
13312
|
+
function cornerMask(center) {
|
|
13313
|
+
return `radial-gradient(circle ${PAGE_RADIUS_PX}px at ${center}, transparent 0 ${PAGE_RADIUS_PX}px, #000 ${PAGE_RADIUS_PX}px)`;
|
|
13314
|
+
}
|
|
13304
13315
|
const STYLE = `
|
|
13305
13316
|
:host {
|
|
13306
13317
|
all: initial !important;
|
|
@@ -13311,29 +13322,79 @@ const STYLE = `
|
|
|
13311
13322
|
transform: none !important;
|
|
13312
13323
|
clip-path: none !important;
|
|
13313
13324
|
}
|
|
13325
|
+
.layer {
|
|
13326
|
+
position: fixed; inset: 0 0 auto 0; z-index: 2147483646; pointer-events: none;
|
|
13327
|
+
height: ${BANNER_HEIGHT_PX}px; background: ${STRIPES};
|
|
13328
|
+
}
|
|
13329
|
+
/* The page's rounded top corners, drawn over whatever the app puts there.
|
|
13330
|
+
The body element's own border-radius rounds its background, but a fixed app
|
|
13331
|
+
header is not clipped by it, so each corner is also painted here: a striped
|
|
13332
|
+
square masked down to the nub OUTSIDE the corner's curve, so the app's own
|
|
13333
|
+
colour still shows inside it. */
|
|
13334
|
+
.corners {
|
|
13335
|
+
position: fixed; left: 0; right: 0; top: ${BANNER_HEIGHT_PX}px;
|
|
13336
|
+
height: ${PAGE_RADIUS_PX}px; z-index: 2147483646; pointer-events: none;
|
|
13337
|
+
}
|
|
13338
|
+
.corner {
|
|
13339
|
+
position: absolute; top: 0; width: ${PAGE_RADIUS_PX}px; height: ${PAGE_RADIUS_PX}px;
|
|
13340
|
+
background: ${STRIPES};
|
|
13341
|
+
}
|
|
13342
|
+
.corner.left {
|
|
13343
|
+
left: 0;
|
|
13344
|
+
-webkit-mask: ${cornerMask("100% 100%")};
|
|
13345
|
+
mask: ${cornerMask("100% 100%")};
|
|
13346
|
+
}
|
|
13347
|
+
.corner.right {
|
|
13348
|
+
right: 0;
|
|
13349
|
+
-webkit-mask: ${cornerMask("0% 100%")};
|
|
13350
|
+
mask: ${cornerMask("0% 100%")};
|
|
13351
|
+
}
|
|
13314
13352
|
.bar {
|
|
13315
13353
|
position: fixed; inset: 0 0 auto 0; height: ${BANNER_HEIGHT_PX}px; z-index: 2147483647;
|
|
13316
|
-
display: flex; align-items: center; justify-content: center; gap:
|
|
13317
|
-
box-sizing: border-box;
|
|
13354
|
+
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
13355
|
+
padding: 0 12px; box-sizing: border-box;
|
|
13318
13356
|
font: 600 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
13319
|
-
box-shadow: 0 1px 4px rgba(0,0,0,.35);
|
|
13320
13357
|
}
|
|
13358
|
+
.pill {
|
|
13359
|
+
display: flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%;
|
|
13360
|
+
padding: 6px 10px; border-radius: 999px;
|
|
13361
|
+
background: rgba(14, 11, 2, .88); color: #fde68a;
|
|
13362
|
+
box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
|
|
13363
|
+
}
|
|
13364
|
+
.sign { flex: none; font-size: 14px; line-height: 1; }
|
|
13321
13365
|
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
13322
|
-
.text b { font-weight: 800; }
|
|
13366
|
+
.text b { color: #fff; font-weight: 800; }
|
|
13323
13367
|
button {
|
|
13324
|
-
flex: none; cursor: pointer; border:
|
|
13325
|
-
background: #
|
|
13368
|
+
flex: none; cursor: pointer; border: 0; border-radius: 999px;
|
|
13369
|
+
background: #fde68a; color: #1c1403;
|
|
13370
|
+
font: 800 12px/1 system-ui, sans-serif; padding: 7px 12px;
|
|
13371
|
+
box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
|
|
13326
13372
|
}
|
|
13373
|
+
button:hover { background: #fff; }
|
|
13327
13374
|
button:disabled { opacity: .6; cursor: progress; }
|
|
13375
|
+
@media (max-width: 520px) {
|
|
13376
|
+
.bar { gap: 6px; padding: 0 8px; }
|
|
13377
|
+
.pill { padding: 5px 8px; }
|
|
13378
|
+
}
|
|
13328
13379
|
`;
|
|
13329
13380
|
/**
|
|
13330
|
-
* The "you are impersonating"
|
|
13331
|
-
*
|
|
13332
|
-
*
|
|
13381
|
+
* The "you are impersonating" warning.
|
|
13382
|
+
*
|
|
13383
|
+
* Drawn as a layer the page sits on top of: hazard stripes fill the top of the
|
|
13384
|
+
* viewport, the page is pushed down by the bar's height and its new top
|
|
13385
|
+
* corners are rounded, so the stripes show through around them. The bar itself
|
|
13386
|
+
* lives in a closed shadow root, so app styles cannot hide or restyle it; the
|
|
13387
|
+
* page-shifting styles are set inline and `!important` on `<html>` / `<body>`
|
|
13388
|
+
* (nothing in a stylesheet outranks that), and the previous inline styles are
|
|
13389
|
+
* restored on unmount.
|
|
13390
|
+
*
|
|
13391
|
+
* Every piece of text goes through `textContent`.
|
|
13333
13392
|
*/
|
|
13334
13393
|
var ImpersonationBanner = class ImpersonationBanner {
|
|
13335
13394
|
host = null;
|
|
13336
13395
|
text = null;
|
|
13396
|
+
/** `style` attributes as the page had them before the shift. */
|
|
13397
|
+
saved = null;
|
|
13337
13398
|
constructor(onStop) {
|
|
13338
13399
|
this.onStop = onStop;
|
|
13339
13400
|
}
|
|
@@ -13354,7 +13415,13 @@ var ImpersonationBanner = class ImpersonationBanner {
|
|
|
13354
13415
|
unmount() {
|
|
13355
13416
|
this.host?.remove();
|
|
13356
13417
|
this.host = this.text = null;
|
|
13357
|
-
if (typeof document
|
|
13418
|
+
if (typeof document === "undefined") return;
|
|
13419
|
+
if (this.saved) {
|
|
13420
|
+
restoreStyle(document.documentElement, this.saved.html);
|
|
13421
|
+
restoreStyle(document.body, this.saved.body);
|
|
13422
|
+
this.saved = null;
|
|
13423
|
+
}
|
|
13424
|
+
document.documentElement?.style.setProperty("--sp00ky-impersonation-banner-height", "0px");
|
|
13358
13425
|
}
|
|
13359
13426
|
mount() {
|
|
13360
13427
|
if (this.host?.isConnected) return;
|
|
@@ -13362,28 +13429,96 @@ var ImpersonationBanner = class ImpersonationBanner {
|
|
|
13362
13429
|
const root = host.attachShadow({ mode: "closed" });
|
|
13363
13430
|
const style = document.createElement("style");
|
|
13364
13431
|
style.textContent = STYLE;
|
|
13432
|
+
const layer = document.createElement("div");
|
|
13433
|
+
layer.className = "layer";
|
|
13434
|
+
layer.setAttribute("aria-hidden", "true");
|
|
13435
|
+
const corners = document.createElement("div");
|
|
13436
|
+
corners.className = "corners";
|
|
13437
|
+
corners.setAttribute("aria-hidden", "true");
|
|
13438
|
+
for (const side of ["left", "right"]) {
|
|
13439
|
+
const corner = document.createElement("div");
|
|
13440
|
+
corner.className = `corner ${side}`;
|
|
13441
|
+
corners.append(corner);
|
|
13442
|
+
}
|
|
13365
13443
|
const bar = document.createElement("div");
|
|
13366
13444
|
bar.className = "bar";
|
|
13367
13445
|
bar.setAttribute("role", "alert");
|
|
13446
|
+
const pill = document.createElement("div");
|
|
13447
|
+
pill.className = "pill";
|
|
13448
|
+
const sign = document.createElement("span");
|
|
13449
|
+
sign.className = "sign";
|
|
13450
|
+
sign.textContent = "⚠";
|
|
13368
13451
|
const text = document.createElement("span");
|
|
13369
13452
|
text.className = "text";
|
|
13370
13453
|
const button = document.createElement("button");
|
|
13371
13454
|
button.type = "button";
|
|
13372
|
-
button.textContent = "Stop
|
|
13455
|
+
button.textContent = "Stop";
|
|
13373
13456
|
button.addEventListener("click", () => {
|
|
13374
13457
|
button.disabled = true;
|
|
13375
13458
|
this.onStop().finally(() => {
|
|
13376
13459
|
button.disabled = false;
|
|
13377
13460
|
});
|
|
13378
13461
|
});
|
|
13379
|
-
|
|
13380
|
-
|
|
13381
|
-
(
|
|
13382
|
-
document.documentElement.
|
|
13462
|
+
pill.append(sign, text);
|
|
13463
|
+
bar.append(pill, button);
|
|
13464
|
+
root.append(style, layer, corners, bar);
|
|
13465
|
+
document.documentElement.appendChild(host);
|
|
13466
|
+
this.shiftPage();
|
|
13383
13467
|
this.host = host;
|
|
13384
13468
|
this.text = text;
|
|
13385
13469
|
}
|
|
13470
|
+
/**
|
|
13471
|
+
* Push the page below the warning and round its new top edge.
|
|
13472
|
+
*
|
|
13473
|
+
* `<html>` carries the stripes as its background, because that is what the
|
|
13474
|
+
* page's rounded corners reveal. `<body>` therefore has to be painted
|
|
13475
|
+
* opaque: most apps colour it themselves, but a transparent one would let
|
|
13476
|
+
* the stripes through behind the whole page, so its resolved colour is
|
|
13477
|
+
* computed first and pinned.
|
|
13478
|
+
*/
|
|
13479
|
+
shiftPage() {
|
|
13480
|
+
const root = document.documentElement;
|
|
13481
|
+
const body = document.body;
|
|
13482
|
+
if (!root || !body) return;
|
|
13483
|
+
this.saved = {
|
|
13484
|
+
html: root.getAttribute("style"),
|
|
13485
|
+
body: body.getAttribute("style")
|
|
13486
|
+
};
|
|
13487
|
+
const surface = resolveSurface(root, body);
|
|
13488
|
+
root.style.setProperty("--sp00ky-impersonation-banner-height", `${BANNER_HEIGHT_PX}px`);
|
|
13489
|
+
root.style.setProperty("background-image", STRIPES, "important");
|
|
13490
|
+
root.style.setProperty("padding-top", `${BANNER_HEIGHT_PX}px`, "important");
|
|
13491
|
+
root.style.setProperty("box-sizing", "border-box", "important");
|
|
13492
|
+
body.style.setProperty("background-color", surface, "important");
|
|
13493
|
+
body.style.setProperty("border-radius", `${PAGE_RADIUS_PX}px ${PAGE_RADIUS_PX}px 0 0`, "important");
|
|
13494
|
+
body.style.setProperty("clip-path", `inset(0 round ${PAGE_RADIUS_PX}px ${PAGE_RADIUS_PX}px 0 0)`, "important");
|
|
13495
|
+
body.style.setProperty("box-shadow", "0 -6px 18px rgba(0, 0, 0, .35)", "important");
|
|
13496
|
+
body.style.setProperty("min-height", `calc(100vh - ${BANNER_HEIGHT_PX}px)`, "important");
|
|
13497
|
+
}
|
|
13386
13498
|
};
|
|
13499
|
+
/** Put an element's inline styles back exactly as they were. */
|
|
13500
|
+
function restoreStyle(el, saved) {
|
|
13501
|
+
if (!el) return;
|
|
13502
|
+
if (saved === null) el.removeAttribute("style");
|
|
13503
|
+
else el.setAttribute("style", saved);
|
|
13504
|
+
}
|
|
13505
|
+
/**
|
|
13506
|
+
* The colour to paint `<body>` with while the stripes sit behind it: whatever
|
|
13507
|
+
* the page already paints (body first, then html), and failing that the canvas
|
|
13508
|
+
* colour for the viewer's colour scheme.
|
|
13509
|
+
*/
|
|
13510
|
+
function resolveSurface(root, body) {
|
|
13511
|
+
for (const el of [body, root]) {
|
|
13512
|
+
const color = getComputedStyle(el).backgroundColor;
|
|
13513
|
+
if (color && !isTransparent(color)) return color;
|
|
13514
|
+
}
|
|
13515
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches ? "#111111" : "#ffffff";
|
|
13516
|
+
}
|
|
13517
|
+
function isTransparent(color) {
|
|
13518
|
+
if (color === "transparent") return true;
|
|
13519
|
+
const parts = color.match(/[\d.]+/g);
|
|
13520
|
+
return !!parts && parts.length === 4 && Number(parts[3]) === 0;
|
|
13521
|
+
}
|
|
13387
13522
|
function bold(value) {
|
|
13388
13523
|
const b = document.createElement("b");
|
|
13389
13524
|
b.textContent = value;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.262",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
67
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
66
|
+
"@spooky-sync/query-builder": "0.0.1-canary.262",
|
|
67
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.262",
|
|
68
68
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
69
69
|
"@surrealdb/wasm": "^3.0.3",
|
|
70
70
|
"blurhash": "^2.0.5",
|
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import type { ImpersonationInfo } from './impersonation';
|
|
2
2
|
|
|
3
|
-
/** Height the
|
|
4
|
-
* `--sp00ky-impersonation-banner-height` on `<html>` (`0px`
|
|
5
|
-
* an app can offset fixed
|
|
6
|
-
export const BANNER_HEIGHT_PX =
|
|
3
|
+
/** Height of the warning layer the page is shifted down by. Also published as
|
|
4
|
+
* the CSS variable `--sp00ky-impersonation-banner-height` on `<html>` (`0px`
|
|
5
|
+
* when hidden), so an app can offset its own fixed header. */
|
|
6
|
+
export const BANNER_HEIGHT_PX = 44;
|
|
7
|
+
|
|
8
|
+
/** Radius of the page's new top corners. */
|
|
9
|
+
const PAGE_RADIUS_PX = 14;
|
|
7
10
|
|
|
8
11
|
const HOST_TAG = 'sp00ky-impersonation-banner';
|
|
9
12
|
|
|
13
|
+
/** Hazard stripes: the warning layer, and the `<html>` backdrop the page's
|
|
14
|
+
* rounded corners reveal. */
|
|
15
|
+
const STRIPES = 'repeating-linear-gradient(45deg, #f2b600 0 14px, #1c1403 14px 28px)';
|
|
16
|
+
|
|
17
|
+
/** Keeps only the area OUTSIDE a corner's curve, so the nub is striped and
|
|
18
|
+
* everything inside the curve stays the app's own. `center` is the curve's
|
|
19
|
+
* centre inside the corner square. */
|
|
20
|
+
function cornerMask(center: string): string {
|
|
21
|
+
return `radial-gradient(circle ${PAGE_RADIUS_PX}px at ${center}, transparent 0 ${PAGE_RADIUS_PX}px, #000 ${PAGE_RADIUS_PX}px)`;
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
// The shadow root keeps app CSS away from the bar, but the host element itself
|
|
11
25
|
// is in the page and matches the page's selectors (`div { display: none }`).
|
|
12
26
|
// An `!important` rule on `:host` wins over the page's own `!important`
|
|
@@ -21,30 +35,80 @@ const STYLE = `
|
|
|
21
35
|
transform: none !important;
|
|
22
36
|
clip-path: none !important;
|
|
23
37
|
}
|
|
38
|
+
.layer {
|
|
39
|
+
position: fixed; inset: 0 0 auto 0; z-index: 2147483646; pointer-events: none;
|
|
40
|
+
height: ${BANNER_HEIGHT_PX}px; background: ${STRIPES};
|
|
41
|
+
}
|
|
42
|
+
/* The page's rounded top corners, drawn over whatever the app puts there.
|
|
43
|
+
The body element's own border-radius rounds its background, but a fixed app
|
|
44
|
+
header is not clipped by it, so each corner is also painted here: a striped
|
|
45
|
+
square masked down to the nub OUTSIDE the corner's curve, so the app's own
|
|
46
|
+
colour still shows inside it. */
|
|
47
|
+
.corners {
|
|
48
|
+
position: fixed; left: 0; right: 0; top: ${BANNER_HEIGHT_PX}px;
|
|
49
|
+
height: ${PAGE_RADIUS_PX}px; z-index: 2147483646; pointer-events: none;
|
|
50
|
+
}
|
|
51
|
+
.corner {
|
|
52
|
+
position: absolute; top: 0; width: ${PAGE_RADIUS_PX}px; height: ${PAGE_RADIUS_PX}px;
|
|
53
|
+
background: ${STRIPES};
|
|
54
|
+
}
|
|
55
|
+
.corner.left {
|
|
56
|
+
left: 0;
|
|
57
|
+
-webkit-mask: ${cornerMask('100% 100%')};
|
|
58
|
+
mask: ${cornerMask('100% 100%')};
|
|
59
|
+
}
|
|
60
|
+
.corner.right {
|
|
61
|
+
right: 0;
|
|
62
|
+
-webkit-mask: ${cornerMask('0% 100%')};
|
|
63
|
+
mask: ${cornerMask('0% 100%')};
|
|
64
|
+
}
|
|
24
65
|
.bar {
|
|
25
66
|
position: fixed; inset: 0 0 auto 0; height: ${BANNER_HEIGHT_PX}px; z-index: 2147483647;
|
|
26
|
-
display: flex; align-items: center; justify-content: center; gap:
|
|
27
|
-
box-sizing: border-box;
|
|
67
|
+
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
68
|
+
padding: 0 12px; box-sizing: border-box;
|
|
28
69
|
font: 600 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
29
|
-
box-shadow: 0 1px 4px rgba(0,0,0,.35);
|
|
30
70
|
}
|
|
71
|
+
.pill {
|
|
72
|
+
display: flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%;
|
|
73
|
+
padding: 6px 10px; border-radius: 999px;
|
|
74
|
+
background: rgba(14, 11, 2, .88); color: #fde68a;
|
|
75
|
+
box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
|
|
76
|
+
}
|
|
77
|
+
.sign { flex: none; font-size: 14px; line-height: 1; }
|
|
31
78
|
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
32
|
-
.text b { font-weight: 800; }
|
|
79
|
+
.text b { color: #fff; font-weight: 800; }
|
|
33
80
|
button {
|
|
34
|
-
flex: none; cursor: pointer; border:
|
|
35
|
-
background: #
|
|
81
|
+
flex: none; cursor: pointer; border: 0; border-radius: 999px;
|
|
82
|
+
background: #fde68a; color: #1c1403;
|
|
83
|
+
font: 800 12px/1 system-ui, sans-serif; padding: 7px 12px;
|
|
84
|
+
box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
|
|
36
85
|
}
|
|
86
|
+
button:hover { background: #fff; }
|
|
37
87
|
button:disabled { opacity: .6; cursor: progress; }
|
|
88
|
+
@media (max-width: 520px) {
|
|
89
|
+
.bar { gap: 6px; padding: 0 8px; }
|
|
90
|
+
.pill { padding: 5px 8px; }
|
|
91
|
+
}
|
|
38
92
|
`;
|
|
39
93
|
|
|
40
94
|
/**
|
|
41
|
-
* The "you are impersonating"
|
|
42
|
-
*
|
|
43
|
-
*
|
|
95
|
+
* The "you are impersonating" warning.
|
|
96
|
+
*
|
|
97
|
+
* Drawn as a layer the page sits on top of: hazard stripes fill the top of the
|
|
98
|
+
* viewport, the page is pushed down by the bar's height and its new top
|
|
99
|
+
* corners are rounded, so the stripes show through around them. The bar itself
|
|
100
|
+
* lives in a closed shadow root, so app styles cannot hide or restyle it; the
|
|
101
|
+
* page-shifting styles are set inline and `!important` on `<html>` / `<body>`
|
|
102
|
+
* (nothing in a stylesheet outranks that), and the previous inline styles are
|
|
103
|
+
* restored on unmount.
|
|
104
|
+
*
|
|
105
|
+
* Every piece of text goes through `textContent`.
|
|
44
106
|
*/
|
|
45
107
|
export class ImpersonationBanner {
|
|
46
108
|
private host: HTMLElement | null = null;
|
|
47
109
|
private text: HTMLElement | null = null;
|
|
110
|
+
/** `style` attributes as the page had them before the shift. */
|
|
111
|
+
private saved: { html: string | null; body: string | null } | null = null;
|
|
48
112
|
|
|
49
113
|
constructor(private onStop: () => Promise<void>) {}
|
|
50
114
|
|
|
@@ -71,9 +135,13 @@ export class ImpersonationBanner {
|
|
|
71
135
|
unmount(): void {
|
|
72
136
|
this.host?.remove();
|
|
73
137
|
this.host = this.text = null;
|
|
74
|
-
if (typeof document
|
|
75
|
-
|
|
138
|
+
if (typeof document === 'undefined') return;
|
|
139
|
+
if (this.saved) {
|
|
140
|
+
restoreStyle(document.documentElement, this.saved.html);
|
|
141
|
+
restoreStyle(document.body, this.saved.body);
|
|
142
|
+
this.saved = null;
|
|
76
143
|
}
|
|
144
|
+
document.documentElement?.style.setProperty('--sp00ky-impersonation-banner-height', '0px');
|
|
77
145
|
}
|
|
78
146
|
|
|
79
147
|
private mount(): void {
|
|
@@ -82,27 +150,120 @@ export class ImpersonationBanner {
|
|
|
82
150
|
const root = host.attachShadow({ mode: 'closed' });
|
|
83
151
|
const style = document.createElement('style');
|
|
84
152
|
style.textContent = STYLE;
|
|
153
|
+
|
|
154
|
+
// The striped ground the bar's text sits on: fixed, so it stays put while
|
|
155
|
+
// the page scrolls underneath. Never takes a click.
|
|
156
|
+
const layer = document.createElement('div');
|
|
157
|
+
layer.className = 'layer';
|
|
158
|
+
layer.setAttribute('aria-hidden', 'true');
|
|
159
|
+
const corners = document.createElement('div');
|
|
160
|
+
corners.className = 'corners';
|
|
161
|
+
corners.setAttribute('aria-hidden', 'true');
|
|
162
|
+
for (const side of ['left', 'right']) {
|
|
163
|
+
const corner = document.createElement('div');
|
|
164
|
+
corner.className = `corner ${side}`;
|
|
165
|
+
corners.append(corner);
|
|
166
|
+
}
|
|
167
|
+
|
|
85
168
|
const bar = document.createElement('div');
|
|
86
169
|
bar.className = 'bar';
|
|
87
170
|
bar.setAttribute('role', 'alert');
|
|
171
|
+
const pill = document.createElement('div');
|
|
172
|
+
pill.className = 'pill';
|
|
173
|
+
const sign = document.createElement('span');
|
|
174
|
+
sign.className = 'sign';
|
|
175
|
+
sign.textContent = '⚠';
|
|
88
176
|
const text = document.createElement('span');
|
|
89
177
|
text.className = 'text';
|
|
90
178
|
const button = document.createElement('button');
|
|
91
179
|
button.type = 'button';
|
|
92
|
-
button.textContent = 'Stop
|
|
180
|
+
button.textContent = 'Stop';
|
|
93
181
|
button.addEventListener('click', () => {
|
|
94
182
|
button.disabled = true;
|
|
95
183
|
this.onStop().finally(() => {
|
|
96
184
|
button.disabled = false;
|
|
97
185
|
});
|
|
98
186
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
(
|
|
102
|
-
|
|
187
|
+
pill.append(sign, text);
|
|
188
|
+
bar.append(pill, button);
|
|
189
|
+
root.append(style, layer, corners, bar);
|
|
190
|
+
// Hosted on `<html>`, not `<body>`: the page shift clips `<body>` to the
|
|
191
|
+
// rounded top, and a bar inside it would be clipped away with everything
|
|
192
|
+
// else.
|
|
193
|
+
document.documentElement.appendChild(host);
|
|
194
|
+
|
|
195
|
+
this.shiftPage();
|
|
103
196
|
this.host = host;
|
|
104
197
|
this.text = text;
|
|
105
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Push the page below the warning and round its new top edge.
|
|
202
|
+
*
|
|
203
|
+
* `<html>` carries the stripes as its background, because that is what the
|
|
204
|
+
* page's rounded corners reveal. `<body>` therefore has to be painted
|
|
205
|
+
* opaque: most apps colour it themselves, but a transparent one would let
|
|
206
|
+
* the stripes through behind the whole page, so its resolved colour is
|
|
207
|
+
* computed first and pinned.
|
|
208
|
+
*/
|
|
209
|
+
private shiftPage(): void {
|
|
210
|
+
const root = document.documentElement;
|
|
211
|
+
const body = document.body;
|
|
212
|
+
if (!root || !body) return;
|
|
213
|
+
this.saved = { html: root.getAttribute('style'), body: body.getAttribute('style') };
|
|
214
|
+
|
|
215
|
+
const surface = resolveSurface(root, body);
|
|
216
|
+
root.style.setProperty('--sp00ky-impersonation-banner-height', `${BANNER_HEIGHT_PX}px`);
|
|
217
|
+
root.style.setProperty('background-image', STRIPES, 'important');
|
|
218
|
+
root.style.setProperty('padding-top', `${BANNER_HEIGHT_PX}px`, 'important');
|
|
219
|
+
root.style.setProperty('box-sizing', 'border-box', 'important');
|
|
220
|
+
|
|
221
|
+
body.style.setProperty('background-color', surface, 'important');
|
|
222
|
+
body.style.setProperty(
|
|
223
|
+
'border-radius',
|
|
224
|
+
`${PAGE_RADIUS_PX}px ${PAGE_RADIUS_PX}px 0 0`,
|
|
225
|
+
'important'
|
|
226
|
+
);
|
|
227
|
+
// Clips the app's own content — a fixed header included — to the rounded
|
|
228
|
+
// top, which is what makes the page read as a sheet over the stripes.
|
|
229
|
+
body.style.setProperty(
|
|
230
|
+
'clip-path',
|
|
231
|
+
`inset(0 round ${PAGE_RADIUS_PX}px ${PAGE_RADIUS_PX}px 0 0)`,
|
|
232
|
+
'important'
|
|
233
|
+
);
|
|
234
|
+
body.style.setProperty('box-shadow', '0 -6px 18px rgba(0, 0, 0, .35)', 'important');
|
|
235
|
+
// An app sized to `100vh` would otherwise overflow by the bar's height and
|
|
236
|
+
// put a scrollbar on a page that had none.
|
|
237
|
+
body.style.setProperty('min-height', `calc(100vh - ${BANNER_HEIGHT_PX}px)`, 'important');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Put an element's inline styles back exactly as they were. */
|
|
242
|
+
function restoreStyle(el: HTMLElement | null, saved: string | null): void {
|
|
243
|
+
if (!el) return;
|
|
244
|
+
if (saved === null) el.removeAttribute('style');
|
|
245
|
+
else el.setAttribute('style', saved);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The colour to paint `<body>` with while the stripes sit behind it: whatever
|
|
250
|
+
* the page already paints (body first, then html), and failing that the canvas
|
|
251
|
+
* colour for the viewer's colour scheme.
|
|
252
|
+
*/
|
|
253
|
+
function resolveSurface(root: HTMLElement, body: HTMLElement): string {
|
|
254
|
+
for (const el of [body, root]) {
|
|
255
|
+
const color = getComputedStyle(el).backgroundColor;
|
|
256
|
+
if (color && !isTransparent(color)) return color;
|
|
257
|
+
}
|
|
258
|
+
const dark =
|
|
259
|
+
typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
|
|
260
|
+
return dark ? '#111111' : '#ffffff';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isTransparent(color: string): boolean {
|
|
264
|
+
if (color === 'transparent') return true;
|
|
265
|
+
const parts = color.match(/[\d.]+/g);
|
|
266
|
+
return !!parts && parts.length === 4 && Number(parts[3]) === 0;
|
|
106
267
|
}
|
|
107
268
|
|
|
108
269
|
function bold(value: string): HTMLElement {
|