@spooky-sync/core 0.0.1-canary.264 → 0.0.1-canary.266
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 +137 -122
- package/dist/types.d.ts +11 -10
- package/package.json +3 -3
- package/src/modules/auth/impersonation-banner.ts +157 -147
- package/src/modules/auth/impersonation.test.ts +43 -6
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.266";
|
|
1080
|
+
const WASM_VERSION = "0.0.1-canary.266";
|
|
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.266",
|
|
13029
13029
|
schemaHash: hash53(config.schemaSurql),
|
|
13030
13030
|
endpoint: config.database.endpoint ?? "",
|
|
13031
13031
|
namespace: config.database.namespace,
|
|
@@ -13296,16 +13296,18 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
13296
13296
|
|
|
13297
13297
|
//#endregion
|
|
13298
13298
|
//#region src/modules/auth/impersonation-banner.ts
|
|
13299
|
-
/** Default height of the warning
|
|
13300
|
-
*
|
|
13299
|
+
/** Default height of the warning bar above the framed page. The effective
|
|
13300
|
+
* height is published as the CSS variable
|
|
13301
13301
|
* `--sp00ky-impersonation-banner-height` on `<html>` (`0px` when hidden), so
|
|
13302
13302
|
* an app can offset its own fixed header. */
|
|
13303
|
-
const BANNER_HEIGHT_PX =
|
|
13304
|
-
/** Default
|
|
13303
|
+
const BANNER_HEIGHT_PX = 40;
|
|
13304
|
+
/** Default gutter left, right and below the framed page. */
|
|
13305
|
+
const FRAME_INSET_PX = 10;
|
|
13306
|
+
/** Default radius of the framed page's corners. */
|
|
13305
13307
|
const PAGE_RADIUS_PX = 14;
|
|
13306
|
-
/**
|
|
13307
|
-
*
|
|
13308
|
-
const
|
|
13308
|
+
/** The default backdrop: a warm amber-to-orange wash with a faint diagonal
|
|
13309
|
+
* hazard rake over it. Layered background-image, topmost layer first. */
|
|
13310
|
+
const BANNER_BACKGROUND = "repeating-linear-gradient(45deg, rgba(69, 26, 3, .07) 0 9px, rgba(69, 26, 3, 0) 9px 18px), linear-gradient(135deg, #fbbf24 0%, #fb923c 52%, #f59e0b 100%)";
|
|
13309
13311
|
const HOST_TAG = "sp00ky-impersonation-banner";
|
|
13310
13312
|
/** How long a `custom` banner has to acknowledge itself before the built-in
|
|
13311
13313
|
* one is shown anyway. Long enough for a first paint, short enough that
|
|
@@ -13314,22 +13316,37 @@ const CUSTOM_BANNER_GRACE_MS = 2500;
|
|
|
13314
13316
|
function resolveTheme(theme = {}) {
|
|
13315
13317
|
return {
|
|
13316
13318
|
heightPx: theme.heightPx ?? BANNER_HEIGHT_PX,
|
|
13319
|
+
insetPx: theme.insetPx ?? FRAME_INSET_PX,
|
|
13317
13320
|
radiusPx: theme.radiusPx ?? PAGE_RADIUS_PX,
|
|
13318
|
-
background: theme.background ??
|
|
13319
|
-
|
|
13320
|
-
|
|
13321
|
-
|
|
13322
|
-
accentText: theme.accentText ?? "#1c1403",
|
|
13321
|
+
background: theme.background ?? BANNER_BACKGROUND,
|
|
13322
|
+
text: theme.text ?? "#45230a",
|
|
13323
|
+
accent: theme.accent ?? "#45230a",
|
|
13324
|
+
accentText: theme.accentText ?? "#fff7ed",
|
|
13323
13325
|
stopLabel: theme.stopLabel ?? "Stop",
|
|
13324
13326
|
noPageShift: theme.noPageShift ?? false,
|
|
13325
13327
|
label: theme.label ?? ((info) => `Impersonating **${info.target}** as ${info.admin}`)
|
|
13326
13328
|
};
|
|
13327
13329
|
}
|
|
13328
|
-
/**
|
|
13329
|
-
*
|
|
13330
|
-
*
|
|
13331
|
-
|
|
13332
|
-
|
|
13330
|
+
/**
|
|
13331
|
+
* How much the page has to shrink to fit inside the frame, and how wide its
|
|
13332
|
+
* layout box must be to fill the frame at that scale.
|
|
13333
|
+
*
|
|
13334
|
+
* Pure so the arithmetic is unit tested: the frame takes `heightPx` off the
|
|
13335
|
+
* top and `insetPx` off the other three sides, and the page is scaled
|
|
13336
|
+
* uniformly (one factor for both axes, so nothing is distorted) by the
|
|
13337
|
+
* vertical ratio. `width` is the CSS width the page's box needs, since a
|
|
13338
|
+
* zoomed box occupies `width * scale` of its parent.
|
|
13339
|
+
*/
|
|
13340
|
+
function frameMetrics(viewport, theme) {
|
|
13341
|
+
const { width: w, height: h } = viewport;
|
|
13342
|
+
const contentHeight = Math.max(1, h - theme.heightPx - theme.insetPx);
|
|
13343
|
+
const contentWidth = Math.max(1, w - theme.insetPx * 2);
|
|
13344
|
+
const scale = h > 0 ? contentHeight / h : 1;
|
|
13345
|
+
return {
|
|
13346
|
+
scale,
|
|
13347
|
+
width: contentWidth / scale,
|
|
13348
|
+
contentHeight
|
|
13349
|
+
};
|
|
13333
13350
|
}
|
|
13334
13351
|
function bannerCss(t) {
|
|
13335
13352
|
return `
|
|
@@ -13342,78 +13359,63 @@ function bannerCss(t) {
|
|
|
13342
13359
|
transform: none !important;
|
|
13343
13360
|
clip-path: none !important;
|
|
13344
13361
|
}
|
|
13345
|
-
.
|
|
13346
|
-
|
|
13347
|
-
|
|
13348
|
-
|
|
13349
|
-
/* The page's rounded top corners, drawn over whatever the app puts there.
|
|
13350
|
-
The body element's own border-radius rounds its background, but a fixed app
|
|
13351
|
-
header is not clipped by it, so each corner is also painted here: a striped
|
|
13352
|
-
square masked down to the nub OUTSIDE the corner's curve, so the app's own
|
|
13353
|
-
colour still shows inside it. */
|
|
13354
|
-
.corners {
|
|
13355
|
-
position: fixed; left: 0; right: 0; top: ${t.heightPx}px;
|
|
13356
|
-
height: ${t.radiusPx}px; z-index: 2147483646; pointer-events: none;
|
|
13357
|
-
}
|
|
13358
|
-
.corner {
|
|
13359
|
-
position: absolute; top: 0; width: ${t.radiusPx}px; height: ${t.radiusPx}px;
|
|
13360
|
-
background: ${t.background};
|
|
13361
|
-
}
|
|
13362
|
-
.corner.left {
|
|
13363
|
-
left: 0;
|
|
13364
|
-
-webkit-mask: ${cornerMask("100% 100%", t.radiusPx)};
|
|
13365
|
-
mask: ${cornerMask("100% 100%", t.radiusPx)};
|
|
13366
|
-
}
|
|
13367
|
-
.corner.right {
|
|
13368
|
-
right: 0;
|
|
13369
|
-
-webkit-mask: ${cornerMask("0% 100%", t.radiusPx)};
|
|
13370
|
-
mask: ${cornerMask("0% 100%", t.radiusPx)};
|
|
13371
|
-
}
|
|
13362
|
+
/* The bar. The backdrop itself is painted on the root element, which is
|
|
13363
|
+
BEHIND the page's sheet; an overlay here would cover the page. The bar
|
|
13364
|
+
repeats that background with a fixed attachment, so its gradient lines up
|
|
13365
|
+
with the root's to the pixel. */
|
|
13372
13366
|
.bar {
|
|
13373
13367
|
position: fixed; inset: 0 0 auto 0; height: ${t.heightPx}px; z-index: 2147483647;
|
|
13374
13368
|
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
13375
|
-
padding: 0
|
|
13376
|
-
|
|
13377
|
-
|
|
13378
|
-
|
|
13379
|
-
|
|
13380
|
-
|
|
13381
|
-
|
|
13382
|
-
|
|
13369
|
+
padding: 0 ${Math.max(t.insetPx, 10)}px; box-sizing: border-box;
|
|
13370
|
+
background-image: ${t.background};
|
|
13371
|
+
background-attachment: fixed;
|
|
13372
|
+
color: ${t.text};
|
|
13373
|
+
font: 600 12.5px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
13374
|
+
letter-spacing: .01em;
|
|
13375
|
+
}
|
|
13376
|
+
.sign {
|
|
13377
|
+
flex: none; display: grid; place-items: center;
|
|
13378
|
+
width: 17px; height: 17px; border-radius: 50%;
|
|
13379
|
+
background: ${t.text}; color: #fff7ed;
|
|
13380
|
+
font-size: 11px; font-weight: 800;
|
|
13383
13381
|
}
|
|
13384
|
-
.sign { flex: none; font-size: 14px; line-height: 1; }
|
|
13385
13382
|
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
13383
|
+
.text b { font-weight: 800; }
|
|
13386
13384
|
button {
|
|
13387
13385
|
flex: none; cursor: pointer; border: 0; border-radius: 999px;
|
|
13388
13386
|
background: ${t.accent}; color: ${t.accentText};
|
|
13389
|
-
font:
|
|
13390
|
-
box-shadow: 0 1px
|
|
13387
|
+
font: 700 11.5px/1 system-ui, sans-serif; padding: 6px 11px;
|
|
13388
|
+
box-shadow: 0 1px 2px rgba(69, 26, 3, .35);
|
|
13391
13389
|
}
|
|
13392
|
-
button:hover { filter: brightness(1.
|
|
13390
|
+
button:hover { filter: brightness(1.15); }
|
|
13393
13391
|
button:disabled { opacity: .6; cursor: progress; }
|
|
13394
13392
|
@media (max-width: 520px) {
|
|
13395
|
-
.bar { gap:
|
|
13396
|
-
.
|
|
13393
|
+
.bar { gap: 7px; padding: 0 8px; font-size: 11.5px; }
|
|
13394
|
+
.sign { display: none; }
|
|
13397
13395
|
}
|
|
13398
13396
|
`;
|
|
13399
13397
|
}
|
|
13400
13398
|
/**
|
|
13401
13399
|
* The "you are impersonating" warning.
|
|
13402
13400
|
*
|
|
13403
|
-
*
|
|
13404
|
-
*
|
|
13405
|
-
*
|
|
13406
|
-
*
|
|
13407
|
-
*
|
|
13408
|
-
*
|
|
13409
|
-
*
|
|
13401
|
+
* The page becomes a sheet on a warm striped backdrop: a bar across the top
|
|
13402
|
+
* names the session, and the page itself is scaled down to sit inside the
|
|
13403
|
+
* remaining frame with rounded corners. Scaled, not pushed: growing the
|
|
13404
|
+
* document moved the bottom of an app-shell layout off-screen, while a
|
|
13405
|
+
* uniform scale keeps the aspect ratio and every pixel reachable, and
|
|
13406
|
+
* `100vh`/`100dvh` layouts still resolve to exactly the frame.
|
|
13407
|
+
*
|
|
13408
|
+
* The bar lives in a closed shadow root, so app styles cannot hide or
|
|
13409
|
+
* restyle it; the framing is written as `!important` inline styles on
|
|
13410
|
+
* `<html>` / `<body>` (nothing in a stylesheet outranks that) and the
|
|
13411
|
+
* previous inline styles are restored on unmount.
|
|
13410
13412
|
*
|
|
13411
13413
|
* Every piece of text goes through `textContent`.
|
|
13412
13414
|
*/
|
|
13413
13415
|
var ImpersonationBanner = class ImpersonationBanner {
|
|
13414
13416
|
host = null;
|
|
13415
13417
|
text = null;
|
|
13416
|
-
/** `style` attributes as the page had them before the
|
|
13418
|
+
/** `style` attributes as the page had them before the frame. */
|
|
13417
13419
|
saved = null;
|
|
13418
13420
|
theme;
|
|
13419
13421
|
mode;
|
|
@@ -13422,11 +13424,15 @@ var ImpersonationBanner = class ImpersonationBanner {
|
|
|
13422
13424
|
/** The app said its own banner is on screen for this impersonation. */
|
|
13423
13425
|
acknowledged = false;
|
|
13424
13426
|
warnedAboutNone = false;
|
|
13427
|
+
onResize = null;
|
|
13425
13428
|
constructor(onStop, config = {}) {
|
|
13426
13429
|
this.onStop = onStop;
|
|
13427
13430
|
this.mode = config.mode ?? "default";
|
|
13428
13431
|
this.theme = resolveTheme(config.theme);
|
|
13429
13432
|
}
|
|
13433
|
+
static supported() {
|
|
13434
|
+
return typeof document !== "undefined" && typeof document.createElement === "function";
|
|
13435
|
+
}
|
|
13430
13436
|
/**
|
|
13431
13437
|
* The app's own banner is rendered. Cancels the `custom`-mode fallback, and
|
|
13432
13438
|
* removes the built-in banner if the fallback already fired (a slow first
|
|
@@ -13437,9 +13443,6 @@ var ImpersonationBanner = class ImpersonationBanner {
|
|
|
13437
13443
|
this.clearFallback();
|
|
13438
13444
|
if (this.mode === "custom") this.teardown();
|
|
13439
13445
|
}
|
|
13440
|
-
static supported() {
|
|
13441
|
-
return typeof document !== "undefined" && typeof document.createElement === "function";
|
|
13442
|
-
}
|
|
13443
13446
|
update(info) {
|
|
13444
13447
|
if (!ImpersonationBanner.supported()) return;
|
|
13445
13448
|
if (!info) {
|
|
@@ -13471,16 +13474,21 @@ var ImpersonationBanner = class ImpersonationBanner {
|
|
|
13471
13474
|
/** Mount (if needed) and write the current identities into the bar. */
|
|
13472
13475
|
render(info) {
|
|
13473
13476
|
this.mount();
|
|
13477
|
+
const label = this.theme.label(info);
|
|
13474
13478
|
const text = this.text;
|
|
13475
13479
|
text.textContent = "";
|
|
13476
|
-
text.append(...labelNodes(
|
|
13477
|
-
text.title = `${
|
|
13480
|
+
text.append(...labelNodes(label));
|
|
13481
|
+
text.title = `${label.replace(/\*\*/g, "")}. Every write is audited.`;
|
|
13478
13482
|
}
|
|
13479
|
-
/** Remove the bar and
|
|
13483
|
+
/** Remove the bar and put the page back exactly as it was. */
|
|
13480
13484
|
teardown() {
|
|
13481
13485
|
this.host?.remove();
|
|
13482
13486
|
this.host = this.text = null;
|
|
13483
13487
|
if (typeof document === "undefined") return;
|
|
13488
|
+
if (this.onResize) {
|
|
13489
|
+
window.removeEventListener("resize", this.onResize);
|
|
13490
|
+
this.onResize = null;
|
|
13491
|
+
}
|
|
13484
13492
|
if (this.saved) {
|
|
13485
13493
|
restoreStyle(document.documentElement, this.saved.html);
|
|
13486
13494
|
restoreStyle(document.body, this.saved.body);
|
|
@@ -13498,26 +13506,13 @@ var ImpersonationBanner = class ImpersonationBanner {
|
|
|
13498
13506
|
const root = host.attachShadow({ mode: "closed" });
|
|
13499
13507
|
const style = document.createElement("style");
|
|
13500
13508
|
style.textContent = bannerCss(this.theme);
|
|
13501
|
-
const layer = document.createElement("div");
|
|
13502
|
-
layer.className = "layer";
|
|
13503
|
-
layer.setAttribute("aria-hidden", "true");
|
|
13504
|
-
const corners = document.createElement("div");
|
|
13505
|
-
corners.className = "corners";
|
|
13506
|
-
corners.setAttribute("aria-hidden", "true");
|
|
13507
|
-
for (const side of ["left", "right"]) {
|
|
13508
|
-
const corner = document.createElement("div");
|
|
13509
|
-
corner.className = `corner ${side}`;
|
|
13510
|
-
corners.append(corner);
|
|
13511
|
-
}
|
|
13512
|
-
if (this.theme.noPageShift || this.theme.radiusPx <= 0) corners.remove();
|
|
13513
13509
|
const bar = document.createElement("div");
|
|
13514
13510
|
bar.className = "bar";
|
|
13515
13511
|
bar.setAttribute("role", "alert");
|
|
13516
|
-
const pill = document.createElement("div");
|
|
13517
|
-
pill.className = "pill";
|
|
13518
13512
|
const sign = document.createElement("span");
|
|
13519
13513
|
sign.className = "sign";
|
|
13520
|
-
sign.textContent = "
|
|
13514
|
+
sign.textContent = "!";
|
|
13515
|
+
sign.setAttribute("aria-hidden", "true");
|
|
13521
13516
|
const text = document.createElement("span");
|
|
13522
13517
|
text.className = "text";
|
|
13523
13518
|
const button = document.createElement("button");
|
|
@@ -13529,28 +13524,28 @@ var ImpersonationBanner = class ImpersonationBanner {
|
|
|
13529
13524
|
button.disabled = false;
|
|
13530
13525
|
});
|
|
13531
13526
|
});
|
|
13532
|
-
|
|
13533
|
-
|
|
13534
|
-
root.append(style, layer, corners, bar);
|
|
13527
|
+
bar.append(sign, text, button);
|
|
13528
|
+
root.append(style, bar);
|
|
13535
13529
|
document.documentElement.appendChild(host);
|
|
13536
|
-
this.
|
|
13530
|
+
this.frame();
|
|
13537
13531
|
this.host = host;
|
|
13538
13532
|
this.text = text;
|
|
13539
13533
|
}
|
|
13540
13534
|
/**
|
|
13541
|
-
*
|
|
13535
|
+
* Turn `<body>` into the framed sheet.
|
|
13542
13536
|
*
|
|
13543
|
-
*
|
|
13544
|
-
*
|
|
13545
|
-
*
|
|
13546
|
-
* the
|
|
13547
|
-
*
|
|
13537
|
+
* `zoom`, not `transform`: it scales at layout level, so `100vh` children
|
|
13538
|
+
* resolve to the frame instead of overflowing it, scrollbars stay correct,
|
|
13539
|
+
* and `position: fixed` keeps meaning the viewport (a transform would make
|
|
13540
|
+
* the body a containing block and every fixed header would scroll away).
|
|
13541
|
+
* The body's CSS width is widened by `1 / scale` so the scaled box still
|
|
13542
|
+
* fills the frame.
|
|
13548
13543
|
*/
|
|
13549
|
-
|
|
13544
|
+
frame() {
|
|
13550
13545
|
const root = document.documentElement;
|
|
13551
13546
|
const body = document.body;
|
|
13552
13547
|
if (!root || !body) return;
|
|
13553
|
-
const { heightPx,
|
|
13548
|
+
const { heightPx, insetPx, radiusPx, noPageShift } = this.theme;
|
|
13554
13549
|
root.style.setProperty("--sp00ky-impersonation-banner-height", `${heightPx}px`);
|
|
13555
13550
|
if (noPageShift) return;
|
|
13556
13551
|
this.saved = {
|
|
@@ -13558,28 +13553,49 @@ var ImpersonationBanner = class ImpersonationBanner {
|
|
|
13558
13553
|
body: body.getAttribute("style")
|
|
13559
13554
|
};
|
|
13560
13555
|
const surface = resolveSurface(root, body);
|
|
13561
|
-
root.style.setProperty("background
|
|
13562
|
-
root.style.setProperty("
|
|
13563
|
-
|
|
13564
|
-
|
|
13565
|
-
|
|
13566
|
-
|
|
13567
|
-
|
|
13568
|
-
|
|
13569
|
-
|
|
13570
|
-
|
|
13556
|
+
root.style.setProperty("background", this.theme.background, "important");
|
|
13557
|
+
root.style.setProperty("overflow", "hidden", "important");
|
|
13558
|
+
const apply = () => {
|
|
13559
|
+
const { scale, width } = frameMetrics({
|
|
13560
|
+
width: window.innerWidth,
|
|
13561
|
+
height: window.innerHeight
|
|
13562
|
+
}, {
|
|
13563
|
+
heightPx,
|
|
13564
|
+
insetPx
|
|
13565
|
+
});
|
|
13566
|
+
body.style.setProperty("position", "fixed", "important");
|
|
13567
|
+
body.style.setProperty("top", `${heightPx}px`, "important");
|
|
13568
|
+
body.style.setProperty("left", `${insetPx}px`, "important");
|
|
13569
|
+
body.style.setProperty("margin", "0", "important");
|
|
13570
|
+
body.style.setProperty("zoom", String(scale), "important");
|
|
13571
|
+
body.style.setProperty("width", `${width}px`, "important");
|
|
13572
|
+
body.style.setProperty("height", "100vh", "important");
|
|
13573
|
+
body.style.setProperty("overflow", "auto", "important");
|
|
13574
|
+
body.style.setProperty("background-color", surface, "important");
|
|
13575
|
+
body.style.setProperty("box-shadow", "0 6px 24px rgba(69, 26, 3, .28)", "important");
|
|
13576
|
+
if (radiusPx > 0) {
|
|
13577
|
+
const radius = `${radiusPx / scale}px`;
|
|
13578
|
+
body.style.setProperty("border-radius", radius, "important");
|
|
13579
|
+
body.style.setProperty("clip-path", `inset(0 round ${radius})`, "important");
|
|
13580
|
+
}
|
|
13581
|
+
};
|
|
13582
|
+
apply();
|
|
13583
|
+
this.onResize = apply;
|
|
13584
|
+
window.addEventListener("resize", apply);
|
|
13571
13585
|
}
|
|
13572
13586
|
};
|
|
13573
|
-
/** Put an element's inline styles back exactly as they were.
|
|
13587
|
+
/** Put an element's inline styles back exactly as they were. An element that
|
|
13588
|
+
* had no `style` attribute (or an empty one) ends up without one again. */
|
|
13574
13589
|
function restoreStyle(el, saved) {
|
|
13575
13590
|
if (!el) return;
|
|
13576
|
-
if (saved
|
|
13577
|
-
else el.
|
|
13591
|
+
if (saved) el.setAttribute("style", saved);
|
|
13592
|
+
else el.removeAttribute("style");
|
|
13578
13593
|
}
|
|
13579
13594
|
/**
|
|
13580
|
-
* The colour to paint
|
|
13581
|
-
*
|
|
13582
|
-
* colour
|
|
13595
|
+
* The colour to paint the sheet with: whatever the page already paints (body
|
|
13596
|
+
* first, then html), and failing that the canvas colour for the viewer's
|
|
13597
|
+
* colour scheme. A transparent sheet would show the backdrop through the
|
|
13598
|
+
* whole page.
|
|
13583
13599
|
*/
|
|
13584
13600
|
function resolveSurface(root, body) {
|
|
13585
13601
|
for (const el of [body, root]) {
|
|
@@ -13594,10 +13610,9 @@ function isTransparent(color) {
|
|
|
13594
13610
|
return !!parts && parts.length === 4 && Number(parts[3]) === 0;
|
|
13595
13611
|
}
|
|
13596
13612
|
/**
|
|
13597
|
-
* The label
|
|
13598
|
-
*
|
|
13599
|
-
*
|
|
13600
|
-
* emphasis instead.
|
|
13613
|
+
* The label as DOM nodes, emphasising the runs between `**` markers. The
|
|
13614
|
+
* label is a plain string (an app's own function can return anything), so it
|
|
13615
|
+
* never reaches the DOM as markup.
|
|
13601
13616
|
*/
|
|
13602
13617
|
function labelNodes(label) {
|
|
13603
13618
|
return labelSegments(label).map(({ text, bold }) => {
|
package/dist/types.d.ts
CHANGED
|
@@ -96,26 +96,27 @@ declare class ImpersonationTimers {
|
|
|
96
96
|
* level.
|
|
97
97
|
*/
|
|
98
98
|
interface ImpersonationBannerTheme {
|
|
99
|
-
/** Height of the bar
|
|
99
|
+
/** Height of the warning bar above the page. */
|
|
100
100
|
heightPx?: number;
|
|
101
|
-
/**
|
|
101
|
+
/** Gutter left, right and below the page. `0` frames only from the top. */
|
|
102
|
+
insetPx?: number;
|
|
103
|
+
/** Radius of the framed page's corners. `0` squares them off. */
|
|
102
104
|
radiusPx?: number;
|
|
103
|
-
/**
|
|
105
|
+
/** The backdrop behind the page. Any CSS `background` value. */
|
|
104
106
|
background?: string;
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
/** Text colour inside the pill. */
|
|
108
|
-
pillText?: string;
|
|
107
|
+
/** Colour of the warning text on the backdrop. */
|
|
108
|
+
text?: string;
|
|
109
109
|
/** Background of the Stop button. */
|
|
110
110
|
accent?: string;
|
|
111
111
|
/** Text colour of the Stop button. */
|
|
112
112
|
accentText?: string;
|
|
113
113
|
/** Label for the Stop button. Defaults to `Stop`. */
|
|
114
114
|
stopLabel?: string;
|
|
115
|
-
/** The warning sentence. Defaults to `Impersonating <target> as <admin>`.
|
|
115
|
+
/** The warning sentence. Defaults to `Impersonating <target> as <admin>`.
|
|
116
|
+
* Wrap a run in `**` to emphasise it; the result is text, never markup. */
|
|
116
117
|
label?: (info: ImpersonationInfo) => string;
|
|
117
|
-
/**
|
|
118
|
-
*
|
|
118
|
+
/** Keep the bar but leave the page's own layout completely alone. The CSS
|
|
119
|
+
* variable is still published, so the app can do its own offset. */
|
|
119
120
|
noPageShift?: boolean;
|
|
120
121
|
}
|
|
121
122
|
interface ImpersonationBannerConfig {
|
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.266",
|
|
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.266",
|
|
67
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.266",
|
|
68
68
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
69
69
|
"@surrealdb/wasm": "^3.0.3",
|
|
70
70
|
"blurhash": "^2.0.5",
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import type { ImpersonationInfo } from './impersonation';
|
|
2
2
|
|
|
3
|
-
/** Default height of the warning
|
|
4
|
-
*
|
|
3
|
+
/** Default height of the warning bar above the framed page. The effective
|
|
4
|
+
* height is published as the CSS variable
|
|
5
5
|
* `--sp00ky-impersonation-banner-height` on `<html>` (`0px` when hidden), so
|
|
6
6
|
* an app can offset its own fixed header. */
|
|
7
|
-
export const BANNER_HEIGHT_PX =
|
|
7
|
+
export const BANNER_HEIGHT_PX = 40;
|
|
8
8
|
|
|
9
|
-
/** Default
|
|
9
|
+
/** Default gutter left, right and below the framed page. */
|
|
10
|
+
export const FRAME_INSET_PX = 10;
|
|
11
|
+
|
|
12
|
+
/** Default radius of the framed page's corners. */
|
|
10
13
|
export const PAGE_RADIUS_PX = 14;
|
|
11
14
|
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
export const
|
|
15
|
-
'repeating-linear-gradient(45deg,
|
|
15
|
+
/** The default backdrop: a warm amber-to-orange wash with a faint diagonal
|
|
16
|
+
* hazard rake over it. Layered background-image, topmost layer first. */
|
|
17
|
+
export const BANNER_BACKGROUND =
|
|
18
|
+
'repeating-linear-gradient(45deg, rgba(69, 26, 3, .07) 0 9px, rgba(69, 26, 3, 0) 9px 18px),' +
|
|
19
|
+
' linear-gradient(135deg, #fbbf24 0%, #fb923c 52%, #f59e0b 100%)';
|
|
16
20
|
|
|
17
21
|
const HOST_TAG = 'sp00ky-impersonation-banner';
|
|
18
22
|
|
|
@@ -28,26 +32,27 @@ export const CUSTOM_BANNER_GRACE_MS = 2500;
|
|
|
28
32
|
* level.
|
|
29
33
|
*/
|
|
30
34
|
export interface ImpersonationBannerTheme {
|
|
31
|
-
/** Height of the bar
|
|
35
|
+
/** Height of the warning bar above the page. */
|
|
32
36
|
heightPx?: number;
|
|
33
|
-
/**
|
|
37
|
+
/** Gutter left, right and below the page. `0` frames only from the top. */
|
|
38
|
+
insetPx?: number;
|
|
39
|
+
/** Radius of the framed page's corners. `0` squares them off. */
|
|
34
40
|
radiusPx?: number;
|
|
35
|
-
/**
|
|
41
|
+
/** The backdrop behind the page. Any CSS `background` value. */
|
|
36
42
|
background?: string;
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
/** Text colour inside the pill. */
|
|
40
|
-
pillText?: string;
|
|
43
|
+
/** Colour of the warning text on the backdrop. */
|
|
44
|
+
text?: string;
|
|
41
45
|
/** Background of the Stop button. */
|
|
42
46
|
accent?: string;
|
|
43
47
|
/** Text colour of the Stop button. */
|
|
44
48
|
accentText?: string;
|
|
45
49
|
/** Label for the Stop button. Defaults to `Stop`. */
|
|
46
50
|
stopLabel?: string;
|
|
47
|
-
/** The warning sentence. Defaults to `Impersonating <target> as <admin>`.
|
|
51
|
+
/** The warning sentence. Defaults to `Impersonating <target> as <admin>`.
|
|
52
|
+
* Wrap a run in `**` to emphasise it; the result is text, never markup. */
|
|
48
53
|
label?: (info: ImpersonationInfo) => string;
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
54
|
+
/** Keep the bar but leave the page's own layout completely alone. The CSS
|
|
55
|
+
* variable is still published, so the app can do its own offset. */
|
|
51
56
|
noPageShift?: boolean;
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -68,7 +73,8 @@ export interface ImpersonationBannerConfig {
|
|
|
68
73
|
theme?: ImpersonationBannerTheme;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
interface ResolvedTheme
|
|
76
|
+
interface ResolvedTheme
|
|
77
|
+
extends Required<Omit<ImpersonationBannerTheme, 'label' | 'stopLabel'>> {
|
|
72
78
|
stopLabel: string;
|
|
73
79
|
label: (info: ImpersonationInfo) => string;
|
|
74
80
|
}
|
|
@@ -76,23 +82,39 @@ interface ResolvedTheme extends Required<Omit<ImpersonationBannerTheme, 'label'
|
|
|
76
82
|
function resolveTheme(theme: ImpersonationBannerTheme = {}): ResolvedTheme {
|
|
77
83
|
return {
|
|
78
84
|
heightPx: theme.heightPx ?? BANNER_HEIGHT_PX,
|
|
85
|
+
insetPx: theme.insetPx ?? FRAME_INSET_PX,
|
|
79
86
|
radiusPx: theme.radiusPx ?? PAGE_RADIUS_PX,
|
|
80
|
-
background: theme.background ??
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
accentText: theme.accentText ?? '#1c1403',
|
|
87
|
+
background: theme.background ?? BANNER_BACKGROUND,
|
|
88
|
+
text: theme.text ?? '#45230a',
|
|
89
|
+
accent: theme.accent ?? '#45230a',
|
|
90
|
+
accentText: theme.accentText ?? '#fff7ed',
|
|
85
91
|
stopLabel: theme.stopLabel ?? 'Stop',
|
|
86
92
|
noPageShift: theme.noPageShift ?? false,
|
|
87
93
|
label: theme.label ?? ((info) => `Impersonating **${info.target}** as ${info.admin}`),
|
|
88
94
|
};
|
|
89
95
|
}
|
|
90
96
|
|
|
91
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
/**
|
|
98
|
+
* How much the page has to shrink to fit inside the frame, and how wide its
|
|
99
|
+
* layout box must be to fill the frame at that scale.
|
|
100
|
+
*
|
|
101
|
+
* Pure so the arithmetic is unit tested: the frame takes `heightPx` off the
|
|
102
|
+
* top and `insetPx` off the other three sides, and the page is scaled
|
|
103
|
+
* uniformly (one factor for both axes, so nothing is distorted) by the
|
|
104
|
+
* vertical ratio. `width` is the CSS width the page's box needs, since a
|
|
105
|
+
* zoomed box occupies `width * scale` of its parent.
|
|
106
|
+
*/
|
|
107
|
+
export function frameMetrics(
|
|
108
|
+
viewport: { width: number; height: number },
|
|
109
|
+
theme: { heightPx: number; insetPx: number }
|
|
110
|
+
): { scale: number; width: number; contentHeight: number } {
|
|
111
|
+
const { width: w, height: h } = viewport;
|
|
112
|
+
const contentHeight = Math.max(1, h - theme.heightPx - theme.insetPx);
|
|
113
|
+
const contentWidth = Math.max(1, w - theme.insetPx * 2);
|
|
114
|
+
// Uniform: the vertical fit decides, so the aspect ratio is untouched and
|
|
115
|
+
// the page keeps every pixel of its height reachable.
|
|
116
|
+
const scale = h > 0 ? contentHeight / h : 1;
|
|
117
|
+
return { scale, width: contentWidth / scale, contentHeight };
|
|
96
118
|
}
|
|
97
119
|
|
|
98
120
|
// The shadow root keeps app CSS away from the bar, but the host element itself
|
|
@@ -110,58 +132,39 @@ export function bannerCss(t: ResolvedTheme): string {
|
|
|
110
132
|
transform: none !important;
|
|
111
133
|
clip-path: none !important;
|
|
112
134
|
}
|
|
113
|
-
.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
/* The page's rounded top corners, drawn over whatever the app puts there.
|
|
118
|
-
The body element's own border-radius rounds its background, but a fixed app
|
|
119
|
-
header is not clipped by it, so each corner is also painted here: a striped
|
|
120
|
-
square masked down to the nub OUTSIDE the corner's curve, so the app's own
|
|
121
|
-
colour still shows inside it. */
|
|
122
|
-
.corners {
|
|
123
|
-
position: fixed; left: 0; right: 0; top: ${t.heightPx}px;
|
|
124
|
-
height: ${t.radiusPx}px; z-index: 2147483646; pointer-events: none;
|
|
125
|
-
}
|
|
126
|
-
.corner {
|
|
127
|
-
position: absolute; top: 0; width: ${t.radiusPx}px; height: ${t.radiusPx}px;
|
|
128
|
-
background: ${t.background};
|
|
129
|
-
}
|
|
130
|
-
.corner.left {
|
|
131
|
-
left: 0;
|
|
132
|
-
-webkit-mask: ${cornerMask('100% 100%', t.radiusPx)};
|
|
133
|
-
mask: ${cornerMask('100% 100%', t.radiusPx)};
|
|
134
|
-
}
|
|
135
|
-
.corner.right {
|
|
136
|
-
right: 0;
|
|
137
|
-
-webkit-mask: ${cornerMask('0% 100%', t.radiusPx)};
|
|
138
|
-
mask: ${cornerMask('0% 100%', t.radiusPx)};
|
|
139
|
-
}
|
|
135
|
+
/* The bar. The backdrop itself is painted on the root element, which is
|
|
136
|
+
BEHIND the page's sheet; an overlay here would cover the page. The bar
|
|
137
|
+
repeats that background with a fixed attachment, so its gradient lines up
|
|
138
|
+
with the root's to the pixel. */
|
|
140
139
|
.bar {
|
|
141
140
|
position: fixed; inset: 0 0 auto 0; height: ${t.heightPx}px; z-index: 2147483647;
|
|
142
141
|
display: flex; align-items: center; justify-content: center; gap: 10px;
|
|
143
|
-
padding: 0
|
|
144
|
-
|
|
142
|
+
padding: 0 ${Math.max(t.insetPx, 10)}px; box-sizing: border-box;
|
|
143
|
+
background-image: ${t.background};
|
|
144
|
+
background-attachment: fixed;
|
|
145
|
+
color: ${t.text};
|
|
146
|
+
font: 600 12.5px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
147
|
+
letter-spacing: .01em;
|
|
145
148
|
}
|
|
146
|
-
.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
background: ${t.
|
|
150
|
-
|
|
149
|
+
.sign {
|
|
150
|
+
flex: none; display: grid; place-items: center;
|
|
151
|
+
width: 17px; height: 17px; border-radius: 50%;
|
|
152
|
+
background: ${t.text}; color: #fff7ed;
|
|
153
|
+
font-size: 11px; font-weight: 800;
|
|
151
154
|
}
|
|
152
|
-
.sign { flex: none; font-size: 14px; line-height: 1; }
|
|
153
155
|
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
156
|
+
.text b { font-weight: 800; }
|
|
154
157
|
button {
|
|
155
158
|
flex: none; cursor: pointer; border: 0; border-radius: 999px;
|
|
156
159
|
background: ${t.accent}; color: ${t.accentText};
|
|
157
|
-
font:
|
|
158
|
-
box-shadow: 0 1px
|
|
160
|
+
font: 700 11.5px/1 system-ui, sans-serif; padding: 6px 11px;
|
|
161
|
+
box-shadow: 0 1px 2px rgba(69, 26, 3, .35);
|
|
159
162
|
}
|
|
160
|
-
button:hover { filter: brightness(1.
|
|
163
|
+
button:hover { filter: brightness(1.15); }
|
|
161
164
|
button:disabled { opacity: .6; cursor: progress; }
|
|
162
165
|
@media (max-width: 520px) {
|
|
163
|
-
.bar { gap:
|
|
164
|
-
.
|
|
166
|
+
.bar { gap: 7px; padding: 0 8px; font-size: 11.5px; }
|
|
167
|
+
.sign { display: none; }
|
|
165
168
|
}
|
|
166
169
|
`;
|
|
167
170
|
}
|
|
@@ -169,20 +172,24 @@ button:disabled { opacity: .6; cursor: progress; }
|
|
|
169
172
|
/**
|
|
170
173
|
* The "you are impersonating" warning.
|
|
171
174
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
175
|
+
* The page becomes a sheet on a warm striped backdrop: a bar across the top
|
|
176
|
+
* names the session, and the page itself is scaled down to sit inside the
|
|
177
|
+
* remaining frame with rounded corners. Scaled, not pushed: growing the
|
|
178
|
+
* document moved the bottom of an app-shell layout off-screen, while a
|
|
179
|
+
* uniform scale keeps the aspect ratio and every pixel reachable, and
|
|
180
|
+
* `100vh`/`100dvh` layouts still resolve to exactly the frame.
|
|
181
|
+
*
|
|
182
|
+
* The bar lives in a closed shadow root, so app styles cannot hide or
|
|
183
|
+
* restyle it; the framing is written as `!important` inline styles on
|
|
184
|
+
* `<html>` / `<body>` (nothing in a stylesheet outranks that) and the
|
|
185
|
+
* previous inline styles are restored on unmount.
|
|
179
186
|
*
|
|
180
187
|
* Every piece of text goes through `textContent`.
|
|
181
188
|
*/
|
|
182
189
|
export class ImpersonationBanner {
|
|
183
190
|
private host: HTMLElement | null = null;
|
|
184
191
|
private text: HTMLElement | null = null;
|
|
185
|
-
/** `style` attributes as the page had them before the
|
|
192
|
+
/** `style` attributes as the page had them before the frame. */
|
|
186
193
|
private saved: { html: string | null; body: string | null } | null = null;
|
|
187
194
|
private theme: ResolvedTheme;
|
|
188
195
|
private mode: 'default' | 'custom' | 'none';
|
|
@@ -191,6 +198,7 @@ export class ImpersonationBanner {
|
|
|
191
198
|
/** The app said its own banner is on screen for this impersonation. */
|
|
192
199
|
private acknowledged = false;
|
|
193
200
|
private warnedAboutNone = false;
|
|
201
|
+
private onResize: (() => void) | null = null;
|
|
194
202
|
|
|
195
203
|
constructor(
|
|
196
204
|
private onStop: () => Promise<void>,
|
|
@@ -200,6 +208,10 @@ export class ImpersonationBanner {
|
|
|
200
208
|
this.theme = resolveTheme(config.theme);
|
|
201
209
|
}
|
|
202
210
|
|
|
211
|
+
static supported(): boolean {
|
|
212
|
+
return typeof document !== 'undefined' && typeof document.createElement === 'function';
|
|
213
|
+
}
|
|
214
|
+
|
|
203
215
|
/**
|
|
204
216
|
* The app's own banner is rendered. Cancels the `custom`-mode fallback, and
|
|
205
217
|
* removes the built-in banner if the fallback already fired (a slow first
|
|
@@ -211,10 +223,6 @@ export class ImpersonationBanner {
|
|
|
211
223
|
if (this.mode === 'custom') this.teardown();
|
|
212
224
|
}
|
|
213
225
|
|
|
214
|
-
static supported(): boolean {
|
|
215
|
-
return typeof document !== 'undefined' && typeof document.createElement === 'function';
|
|
216
|
-
}
|
|
217
|
-
|
|
218
226
|
update(info: ImpersonationInfo | null): void {
|
|
219
227
|
if (!ImpersonationBanner.supported()) return;
|
|
220
228
|
if (!info) {
|
|
@@ -230,7 +238,7 @@ export class ImpersonationBanner {
|
|
|
230
238
|
// oxlint-disable-next-line no-console
|
|
231
239
|
console.warn(
|
|
232
240
|
'[sp00ky] impersonation is active and impersonationBanner.mode is "none": ' +
|
|
233
|
-
|
|
241
|
+
"nothing marks this page as another user's session."
|
|
234
242
|
);
|
|
235
243
|
}
|
|
236
244
|
return;
|
|
@@ -257,17 +265,22 @@ export class ImpersonationBanner {
|
|
|
257
265
|
/** Mount (if needed) and write the current identities into the bar. */
|
|
258
266
|
private render(info: ImpersonationInfo): void {
|
|
259
267
|
this.mount();
|
|
268
|
+
const label = this.theme.label(info);
|
|
260
269
|
const text = this.text!;
|
|
261
270
|
text.textContent = '';
|
|
262
|
-
text.append(...labelNodes(
|
|
263
|
-
text.title = `${
|
|
271
|
+
text.append(...labelNodes(label));
|
|
272
|
+
text.title = `${label.replace(/\*\*/g, '')}. Every write is audited.`;
|
|
264
273
|
}
|
|
265
274
|
|
|
266
|
-
/** Remove the bar and
|
|
275
|
+
/** Remove the bar and put the page back exactly as it was. */
|
|
267
276
|
private teardown(): void {
|
|
268
277
|
this.host?.remove();
|
|
269
278
|
this.host = this.text = null;
|
|
270
279
|
if (typeof document === 'undefined') return;
|
|
280
|
+
if (this.onResize) {
|
|
281
|
+
window.removeEventListener('resize', this.onResize);
|
|
282
|
+
this.onResize = null;
|
|
283
|
+
}
|
|
271
284
|
if (this.saved) {
|
|
272
285
|
restoreStyle(document.documentElement, this.saved.html);
|
|
273
286
|
restoreStyle(document.body, this.saved.body);
|
|
@@ -288,29 +301,13 @@ export class ImpersonationBanner {
|
|
|
288
301
|
const style = document.createElement('style');
|
|
289
302
|
style.textContent = bannerCss(this.theme);
|
|
290
303
|
|
|
291
|
-
// The striped ground the bar's text sits on: fixed, so it stays put while
|
|
292
|
-
// the page scrolls underneath. Never takes a click.
|
|
293
|
-
const layer = document.createElement('div');
|
|
294
|
-
layer.className = 'layer';
|
|
295
|
-
layer.setAttribute('aria-hidden', 'true');
|
|
296
|
-
const corners = document.createElement('div');
|
|
297
|
-
corners.className = 'corners';
|
|
298
|
-
corners.setAttribute('aria-hidden', 'true');
|
|
299
|
-
for (const side of ['left', 'right']) {
|
|
300
|
-
const corner = document.createElement('div');
|
|
301
|
-
corner.className = `corner ${side}`;
|
|
302
|
-
corners.append(corner);
|
|
303
|
-
}
|
|
304
|
-
if (this.theme.noPageShift || this.theme.radiusPx <= 0) corners.remove();
|
|
305
|
-
|
|
306
304
|
const bar = document.createElement('div');
|
|
307
305
|
bar.className = 'bar';
|
|
308
306
|
bar.setAttribute('role', 'alert');
|
|
309
|
-
const pill = document.createElement('div');
|
|
310
|
-
pill.className = 'pill';
|
|
311
307
|
const sign = document.createElement('span');
|
|
312
308
|
sign.className = 'sign';
|
|
313
|
-
sign.textContent = '
|
|
309
|
+
sign.textContent = '!';
|
|
310
|
+
sign.setAttribute('aria-hidden', 'true');
|
|
314
311
|
const text = document.createElement('span');
|
|
315
312
|
text.className = 'text';
|
|
316
313
|
const button = document.createElement('button');
|
|
@@ -322,73 +319,87 @@ export class ImpersonationBanner {
|
|
|
322
319
|
button.disabled = false;
|
|
323
320
|
});
|
|
324
321
|
});
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
// rounded top, and a bar inside it would be clipped away with everything
|
|
330
|
-
// else.
|
|
322
|
+
bar.append(sign, text, button);
|
|
323
|
+
root.append(style, bar);
|
|
324
|
+
// Hosted on `<html>`, not `<body>`: the body is the framed sheet, and a
|
|
325
|
+
// bar inside it would be scaled and clipped along with the page.
|
|
331
326
|
document.documentElement.appendChild(host);
|
|
332
327
|
|
|
333
|
-
this.
|
|
328
|
+
this.frame();
|
|
334
329
|
this.host = host;
|
|
335
330
|
this.text = text;
|
|
336
331
|
}
|
|
337
332
|
|
|
338
333
|
/**
|
|
339
|
-
*
|
|
334
|
+
* Turn `<body>` into the framed sheet.
|
|
340
335
|
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
* the
|
|
345
|
-
*
|
|
336
|
+
* `zoom`, not `transform`: it scales at layout level, so `100vh` children
|
|
337
|
+
* resolve to the frame instead of overflowing it, scrollbars stay correct,
|
|
338
|
+
* and `position: fixed` keeps meaning the viewport (a transform would make
|
|
339
|
+
* the body a containing block and every fixed header would scroll away).
|
|
340
|
+
* The body's CSS width is widened by `1 / scale` so the scaled box still
|
|
341
|
+
* fills the frame.
|
|
346
342
|
*/
|
|
347
|
-
private
|
|
343
|
+
private frame(): void {
|
|
348
344
|
const root = document.documentElement;
|
|
349
345
|
const body = document.body;
|
|
350
346
|
if (!root || !body) return;
|
|
351
|
-
const { heightPx,
|
|
352
|
-
// Published even when the
|
|
347
|
+
const { heightPx, insetPx, radiusPx, noPageShift } = this.theme;
|
|
348
|
+
// Published even when the framing is off, so an app doing its own offset
|
|
353
349
|
// has one number to read either way.
|
|
354
350
|
root.style.setProperty('--sp00ky-impersonation-banner-height', `${heightPx}px`);
|
|
355
351
|
if (noPageShift) return;
|
|
356
352
|
|
|
357
353
|
this.saved = { html: root.getAttribute('style'), body: body.getAttribute('style') };
|
|
358
354
|
const surface = resolveSurface(root, body);
|
|
359
|
-
root
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
'clip-path',
|
|
370
|
-
`inset(0 round ${radiusPx}px ${radiusPx}px 0 0)`,
|
|
371
|
-
'important'
|
|
355
|
+
// The root paints the backdrop: it is the one box that is guaranteed to
|
|
356
|
+
// be behind the page's sheet. `overflow: hidden` keeps the document
|
|
357
|
+
// itself from scrolling — the sheet is fixed and scrolls internally.
|
|
358
|
+
root.style.setProperty('background', this.theme.background, 'important');
|
|
359
|
+
root.style.setProperty('overflow', 'hidden', 'important');
|
|
360
|
+
|
|
361
|
+
const apply = () => {
|
|
362
|
+
const { scale, width } = frameMetrics(
|
|
363
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
364
|
+
{ heightPx, insetPx }
|
|
372
365
|
);
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
366
|
+
body.style.setProperty('position', 'fixed', 'important');
|
|
367
|
+
body.style.setProperty('top', `${heightPx}px`, 'important');
|
|
368
|
+
body.style.setProperty('left', `${insetPx}px`, 'important');
|
|
369
|
+
body.style.setProperty('margin', '0', 'important');
|
|
370
|
+
body.style.setProperty('zoom', String(scale), 'important');
|
|
371
|
+
body.style.setProperty('width', `${width}px`, 'important');
|
|
372
|
+
body.style.setProperty('height', '100vh', 'important');
|
|
373
|
+
body.style.setProperty('overflow', 'auto', 'important');
|
|
374
|
+
body.style.setProperty('background-color', surface, 'important');
|
|
375
|
+
body.style.setProperty('box-shadow', '0 6px 24px rgba(69, 26, 3, .28)', 'important');
|
|
376
|
+
if (radiusPx > 0) {
|
|
377
|
+
const radius = `${radiusPx / scale}px`;
|
|
378
|
+
body.style.setProperty('border-radius', radius, 'important');
|
|
379
|
+
// Clips the page's own fixed layers (a loading overlay, a modal
|
|
380
|
+
// backdrop) to the sheet, so nothing paints over the frame.
|
|
381
|
+
body.style.setProperty('clip-path', `inset(0 round ${radius})`, 'important');
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
apply();
|
|
385
|
+
this.onResize = apply;
|
|
386
|
+
window.addEventListener('resize', apply);
|
|
378
387
|
}
|
|
379
388
|
}
|
|
380
389
|
|
|
381
|
-
/** Put an element's inline styles back exactly as they were.
|
|
390
|
+
/** Put an element's inline styles back exactly as they were. An element that
|
|
391
|
+
* had no `style` attribute (or an empty one) ends up without one again. */
|
|
382
392
|
function restoreStyle(el: HTMLElement | null, saved: string | null): void {
|
|
383
393
|
if (!el) return;
|
|
384
|
-
if (saved
|
|
385
|
-
else el.
|
|
394
|
+
if (saved) el.setAttribute('style', saved);
|
|
395
|
+
else el.removeAttribute('style');
|
|
386
396
|
}
|
|
387
397
|
|
|
388
398
|
/**
|
|
389
|
-
* The colour to paint
|
|
390
|
-
*
|
|
391
|
-
* colour
|
|
399
|
+
* The colour to paint the sheet with: whatever the page already paints (body
|
|
400
|
+
* first, then html), and failing that the canvas colour for the viewer's
|
|
401
|
+
* colour scheme. A transparent sheet would show the backdrop through the
|
|
402
|
+
* whole page.
|
|
392
403
|
*/
|
|
393
404
|
function resolveSurface(root: HTMLElement, body: HTMLElement): string {
|
|
394
405
|
for (const el of [body, root]) {
|
|
@@ -407,10 +418,9 @@ function isTransparent(color: string): boolean {
|
|
|
407
418
|
}
|
|
408
419
|
|
|
409
420
|
/**
|
|
410
|
-
* The label
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
* emphasis instead.
|
|
421
|
+
* The label as DOM nodes, emphasising the runs between `**` markers. The
|
|
422
|
+
* label is a plain string (an app's own function can return anything), so it
|
|
423
|
+
* never reaches the DOM as markup.
|
|
414
424
|
*/
|
|
415
425
|
function labelNodes(label: string): Node[] {
|
|
416
426
|
return labelSegments(label).map(({ text, bold }) => {
|
|
@@ -283,24 +283,50 @@ describe('timers', () => {
|
|
|
283
283
|
|
|
284
284
|
describe('banner configuration', () => {
|
|
285
285
|
it('builds CSS from the theme, defaults included', async () => {
|
|
286
|
-
const { bannerCss,
|
|
286
|
+
const { bannerCss, BANNER_BACKGROUND } = await import('./impersonation-banner');
|
|
287
287
|
// `bannerCss` takes a resolved theme; the client resolves it, so this
|
|
288
288
|
// pins the rendered output for a fully specified one.
|
|
289
289
|
const css = bannerCss({
|
|
290
290
|
heightPx: 60,
|
|
291
|
+
insetPx: 16,
|
|
291
292
|
radiusPx: 0,
|
|
292
293
|
background: 'var(--brand-warning)',
|
|
293
|
-
|
|
294
|
-
pillText: '#fff',
|
|
294
|
+
text: '#222',
|
|
295
295
|
accent: '#f00',
|
|
296
296
|
accentText: '#fff',
|
|
297
297
|
stopLabel: 'Leave',
|
|
298
298
|
noPageShift: true,
|
|
299
299
|
label: () => 'x',
|
|
300
300
|
});
|
|
301
|
-
expect(css).toContain('height: 60px
|
|
302
|
-
expect(css).toContain('background:
|
|
303
|
-
expect(css).
|
|
301
|
+
expect(css).toContain('height: 60px');
|
|
302
|
+
expect(css).toContain('background-image: var(--brand-warning)');
|
|
303
|
+
expect(css).toContain('background: #f00; color: #fff');
|
|
304
|
+
expect(css).not.toContain(BANNER_BACKGROUND);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('scales the page to the frame without distorting it', async () => {
|
|
308
|
+
const { frameMetrics } = await import('./impersonation-banner');
|
|
309
|
+
const theme = { heightPx: 40, insetPx: 10 };
|
|
310
|
+
|
|
311
|
+
const desktop = frameMetrics({ width: 1440, height: 900 }, theme);
|
|
312
|
+
// 900 - 40 - 10 = 850 of 900: one factor for both axes, so the aspect
|
|
313
|
+
// ratio is untouched and nothing is cropped.
|
|
314
|
+
expect(desktop.scale).toBeCloseTo(850 / 900);
|
|
315
|
+
expect(desktop.contentHeight).toBe(850);
|
|
316
|
+
// A zoomed box occupies width * scale, so the layout box is widened to
|
|
317
|
+
// still fill the frame exactly.
|
|
318
|
+
expect(desktop.width * desktop.scale).toBeCloseTo(1440 - 20);
|
|
319
|
+
|
|
320
|
+
const phone = frameMetrics({ width: 390, height: 844 }, theme);
|
|
321
|
+
expect(phone.contentHeight).toBe(844 - 50);
|
|
322
|
+
expect(phone.width * phone.scale).toBeCloseTo(370);
|
|
323
|
+
|
|
324
|
+
// Degenerate viewports (a hidden tab reporting 0) must not divide by zero
|
|
325
|
+
// or hand back a negative box.
|
|
326
|
+
const zero = frameMetrics({ width: 0, height: 0 }, theme);
|
|
327
|
+
expect(zero.scale).toBe(1);
|
|
328
|
+
expect(zero.contentHeight).toBeGreaterThan(0);
|
|
329
|
+
expect(zero.width).toBeGreaterThan(0);
|
|
304
330
|
});
|
|
305
331
|
|
|
306
332
|
it('emphasises the label between ** markers instead of parsing markup', async () => {
|
|
@@ -315,4 +341,15 @@ describe('banner configuration', () => {
|
|
|
315
341
|
{ text: '<img src=x onerror=alert(1)>', bold: false },
|
|
316
342
|
]);
|
|
317
343
|
});
|
|
344
|
+
|
|
345
|
+
it('resolves a partial theme over the defaults', async () => {
|
|
346
|
+
const { __test, BANNER_HEIGHT_PX } = await import('./impersonation-banner');
|
|
347
|
+
const theme = __test.resolveTheme({ insetPx: 0, stopLabel: 'Exit' });
|
|
348
|
+
expect(theme.insetPx).toBe(0);
|
|
349
|
+
expect(theme.stopLabel).toBe('Exit');
|
|
350
|
+
expect(theme.heightPx).toBe(BANNER_HEIGHT_PX);
|
|
351
|
+
expect(theme.label({ target: 'user:b', admin: 'user:a', session: 's', tokenExpiresAt: null })).toBe(
|
|
352
|
+
'Impersonating **user:b** as user:a'
|
|
353
|
+
);
|
|
354
|
+
});
|
|
318
355
|
});
|