@spooky-sync/core 0.0.1-canary.261 → 0.0.1-canary.262

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