@spooky-sync/core 0.0.1-canary.264 → 0.0.1-canary.265

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 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.264";
1080
- const WASM_VERSION = "0.0.1-canary.264";
1079
+ const CORE_VERSION = "0.0.1-canary.265";
1080
+ const WASM_VERSION = "0.0.1-canary.265";
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.264",
13028
+ coreVersion: "0.0.1-canary.265",
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 layer the page is shifted down by. The
13300
- * effective height is published as the CSS variable
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 = 44;
13304
- /** Default radius of the page's new top corners. */
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
- /** Default hazard stripes: the warning layer, and the `<html>` backdrop the
13307
- * page's rounded corners reveal. */
13308
- const BANNER_STRIPES = "repeating-linear-gradient(45deg, #f2b600 0 14px, #1c1403 14px 28px)";
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 ?? BANNER_STRIPES,
13319
- pill: theme.pill ?? "rgba(14, 11, 2, .88)",
13320
- pillText: theme.pillText ?? "#fde68a",
13321
- accent: theme.accent ?? "#fde68a",
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
- /** Keeps only the area OUTSIDE a corner's curve, so the nub is striped and
13329
- * everything inside the curve stays the app's own. `center` is the curve's
13330
- * centre inside the corner square. */
13331
- function cornerMask(center, radius) {
13332
- return `radial-gradient(circle ${radius}px at ${center}, transparent 0 ${radius}px, #000 ${radius}px)`;
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
- .layer {
13346
- position: fixed; inset: 0 0 auto 0; z-index: 2147483646; pointer-events: none;
13347
- height: ${t.heightPx}px; background: ${t.background};
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 12px; box-sizing: border-box;
13376
- font: 600 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
13377
- }
13378
- .pill {
13379
- display: flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%;
13380
- padding: 6px 10px; border-radius: 999px;
13381
- background: ${t.pill}; color: ${t.pillText};
13382
- box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
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: 800 12px/1 system-ui, sans-serif; padding: 7px 12px;
13390
- box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
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.12); }
13390
+ button:hover { filter: brightness(1.15); }
13393
13391
  button:disabled { opacity: .6; cursor: progress; }
13394
13392
  @media (max-width: 520px) {
13395
- .bar { gap: 6px; padding: 0 8px; }
13396
- .pill { padding: 5px 8px; }
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
- * Drawn as a layer the page sits on top of: hazard stripes fill the top of the
13404
- * viewport, the page is pushed down by the bar's height and its new top
13405
- * corners are rounded, so the stripes show through around them. The bar itself
13406
- * lives in a closed shadow root, so app styles cannot hide or restyle it; the
13407
- * page-shifting styles are set inline and `!important` on `<html>` / `<body>`
13408
- * (nothing in a stylesheet outranks that), and the previous inline styles are
13409
- * restored on unmount.
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 shift. */
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(this.theme.label(info)));
13477
- text.title = `${this.theme.label(info)}. Every write is audited.`;
13480
+ text.append(...labelNodes(label));
13481
+ text.title = `${label.replace(/\*\*/g, "")}. Every write is audited.`;
13478
13482
  }
13479
- /** Remove the bar and undo the page shift. */
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
- pill.append(sign, text);
13533
- bar.append(pill, button);
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.shiftPage();
13530
+ this.frame();
13537
13531
  this.host = host;
13538
13532
  this.text = text;
13539
13533
  }
13540
13534
  /**
13541
- * Push the page below the warning and round its new top edge.
13535
+ * Turn `<body>` into the framed sheet.
13542
13536
  *
13543
- * `<html>` carries the stripes as its background, because that is what the
13544
- * page's rounded corners reveal. `<body>` therefore has to be painted
13545
- * opaque: most apps colour it themselves, but a transparent one would let
13546
- * the stripes through behind the whole page, so its resolved colour is
13547
- * computed first and pinned.
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
- shiftPage() {
13544
+ frame() {
13550
13545
  const root = document.documentElement;
13551
13546
  const body = document.body;
13552
13547
  if (!root || !body) return;
13553
- const { heightPx, radiusPx, background, noPageShift } = this.theme;
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-image", background, "important");
13562
- root.style.setProperty("padding-top", `${heightPx}px`, "important");
13563
- root.style.setProperty("box-sizing", "border-box", "important");
13564
- body.style.setProperty("background-color", surface, "important");
13565
- if (radiusPx > 0) {
13566
- body.style.setProperty("border-radius", `${radiusPx}px ${radiusPx}px 0 0`, "important");
13567
- body.style.setProperty("clip-path", `inset(0 round ${radiusPx}px ${radiusPx}px 0 0)`, "important");
13568
- }
13569
- body.style.setProperty("box-shadow", "0 -6px 18px rgba(0, 0, 0, .35)", "important");
13570
- body.style.setProperty("min-height", `calc(100vh - ${heightPx}px)`, "important");
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 === null) el.removeAttribute("style");
13577
- else el.setAttribute("style", saved);
13591
+ if (saved) el.setAttribute("style", saved);
13592
+ else el.removeAttribute("style");
13578
13593
  }
13579
13594
  /**
13580
- * The colour to paint `<body>` with while the stripes sit behind it: whatever
13581
- * the page already paints (body first, then html), and failing that the canvas
13582
- * colour for the viewer's colour scheme.
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, with every `<b>…</b>`-free segment as a text node and the record
13598
- * ids emphasised. The label is a plain string (an app's own function can
13599
- * return anything), so it never reaches the DOM as markup: `**bold**` marks
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, and therefore how far the page is pushed down. */
99
+ /** Height of the warning bar above the page. */
100
100
  heightPx?: number;
101
- /** Radius of the page's new top corners. `0` squares them off. */
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
- /** Background of the warning layer. Any CSS `background` value. */
105
+ /** The backdrop behind the page. Any CSS `background` value. */
104
106
  background?: string;
105
- /** Background of the pill the text sits in. */
106
- pill?: string;
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
- /** Drop the page shift and the rounded corners, leaving only the bar.
118
- * The CSS variable is still published, so the app can do its own offset. */
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.264",
3
+ "version": "0.0.1-canary.265",
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.264",
67
- "@spooky-sync/ssp-wasm": "0.0.1-canary.264",
66
+ "@spooky-sync/query-builder": "0.0.1-canary.265",
67
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.265",
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 layer the page is shifted down by. The
4
- * effective height is published as the CSS variable
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 = 44;
7
+ export const BANNER_HEIGHT_PX = 40;
8
8
 
9
- /** Default radius of the page's new top corners. */
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
- /** Default hazard stripes: the warning layer, and the `<html>` backdrop the
13
- * page's rounded corners reveal. */
14
- export const BANNER_STRIPES =
15
- 'repeating-linear-gradient(45deg, #f2b600 0 14px, #1c1403 14px 28px)';
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, and therefore how far the page is pushed down. */
35
+ /** Height of the warning bar above the page. */
32
36
  heightPx?: number;
33
- /** Radius of the page's new top corners. `0` squares them off. */
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
- /** Background of the warning layer. Any CSS `background` value. */
41
+ /** The backdrop behind the page. Any CSS `background` value. */
36
42
  background?: string;
37
- /** Background of the pill the text sits in. */
38
- pill?: string;
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
- /** Drop the page shift and the rounded corners, leaving only the bar.
50
- * The CSS variable is still published, so the app can do its own offset. */
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 extends Required<Omit<ImpersonationBannerTheme, 'label' | 'stopLabel'>> {
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 ?? BANNER_STRIPES,
81
- pill: theme.pill ?? 'rgba(14, 11, 2, .88)',
82
- pillText: theme.pillText ?? '#fde68a',
83
- accent: theme.accent ?? '#fde68a',
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
- /** Keeps only the area OUTSIDE a corner's curve, so the nub is striped and
92
- * everything inside the curve stays the app's own. `center` is the curve's
93
- * centre inside the corner square. */
94
- function cornerMask(center: string, radius: number): string {
95
- return `radial-gradient(circle ${radius}px at ${center}, transparent 0 ${radius}px, #000 ${radius}px)`;
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
- .layer {
114
- position: fixed; inset: 0 0 auto 0; z-index: 2147483646; pointer-events: none;
115
- height: ${t.heightPx}px; background: ${t.background};
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 12px; box-sizing: border-box;
144
- font: 600 13px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
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
- .pill {
147
- display: flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%;
148
- padding: 6px 10px; border-radius: 999px;
149
- background: ${t.pill}; color: ${t.pillText};
150
- box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
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: 800 12px/1 system-ui, sans-serif; padding: 7px 12px;
158
- box-shadow: 0 1px 6px rgba(0, 0, 0, .45);
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.12); }
163
+ button:hover { filter: brightness(1.15); }
161
164
  button:disabled { opacity: .6; cursor: progress; }
162
165
  @media (max-width: 520px) {
163
- .bar { gap: 6px; padding: 0 8px; }
164
- .pill { padding: 5px 8px; }
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
- * Drawn as a layer the page sits on top of: hazard stripes fill the top of the
173
- * viewport, the page is pushed down by the bar's height and its new top
174
- * corners are rounded, so the stripes show through around them. The bar itself
175
- * lives in a closed shadow root, so app styles cannot hide or restyle it; the
176
- * page-shifting styles are set inline and `!important` on `<html>` / `<body>`
177
- * (nothing in a stylesheet outranks that), and the previous inline styles are
178
- * restored on unmount.
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 shift. */
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
- 'nothing marks this page as another user\'s session.'
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(this.theme.label(info)));
263
- text.title = `${this.theme.label(info)}. Every write is audited.`;
271
+ text.append(...labelNodes(label));
272
+ text.title = `${label.replace(/\*\*/g, '')}. Every write is audited.`;
264
273
  }
265
274
 
266
- /** Remove the bar and undo the page shift. */
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
- pill.append(sign, text);
326
- bar.append(pill, button);
327
- root.append(style, layer, corners, bar);
328
- // Hosted on `<html>`, not `<body>`: the page shift clips `<body>` to the
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.shiftPage();
328
+ this.frame();
334
329
  this.host = host;
335
330
  this.text = text;
336
331
  }
337
332
 
338
333
  /**
339
- * Push the page below the warning and round its new top edge.
334
+ * Turn `<body>` into the framed sheet.
340
335
  *
341
- * `<html>` carries the stripes as its background, because that is what the
342
- * page's rounded corners reveal. `<body>` therefore has to be painted
343
- * opaque: most apps colour it themselves, but a transparent one would let
344
- * the stripes through behind the whole page, so its resolved colour is
345
- * computed first and pinned.
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 shiftPage(): void {
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, radiusPx, background, noPageShift } = this.theme;
352
- // Published even when the shift is off, so an app doing its own offset
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.style.setProperty('background-image', background, 'important');
360
- root.style.setProperty('padding-top', `${heightPx}px`, 'important');
361
- root.style.setProperty('box-sizing', 'border-box', 'important');
362
-
363
- body.style.setProperty('background-color', surface, 'important');
364
- if (radiusPx > 0) {
365
- body.style.setProperty('border-radius', `${radiusPx}px ${radiusPx}px 0 0`, 'important');
366
- // Clips the app's own content — a fixed header included — to the rounded
367
- // top, which is what makes the page read as a sheet over the stripes.
368
- body.style.setProperty(
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
- body.style.setProperty('box-shadow', '0 -6px 18px rgba(0, 0, 0, .35)', 'important');
375
- // An app sized to `100vh` would otherwise overflow by the bar's height and
376
- // put a scrollbar on a page that had none.
377
- body.style.setProperty('min-height', `calc(100vh - ${heightPx}px)`, 'important');
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 === null) el.removeAttribute('style');
385
- else el.setAttribute('style', saved);
394
+ if (saved) el.setAttribute('style', saved);
395
+ else el.removeAttribute('style');
386
396
  }
387
397
 
388
398
  /**
389
- * The colour to paint `<body>` with while the stripes sit behind it: whatever
390
- * the page already paints (body first, then html), and failing that the canvas
391
- * colour for the viewer's colour scheme.
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, with every `<b>…</b>`-free segment as a text node and the record
411
- * ids emphasised. The label is a plain string (an app's own function can
412
- * return anything), so it never reaches the DOM as markup: `**bold**` marks
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, BANNER_STRIPES } = await import('./impersonation-banner');
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
- pill: '#000',
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; background: var(--brand-warning)');
302
- expect(css).toContain('background: #000; color: #fff');
303
- expect(css).not.toContain(BANNER_STRIPES);
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
  });