@spooky-sync/core 0.0.1-canary.263 → 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.
@@ -1,31 +1,128 @@
1
1
  import type { ImpersonationInfo } from './impersonation';
2
2
 
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;
3
+ /** Default height of the warning bar above the framed page. The effective
4
+ * height is published as the CSS variable
5
+ * `--sp00ky-impersonation-banner-height` on `<html>` (`0px` when hidden), so
6
+ * an app can offset its own fixed header. */
7
+ export const BANNER_HEIGHT_PX = 40;
7
8
 
8
- /** Radius of the page's new top corners. */
9
- const PAGE_RADIUS_PX = 14;
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. */
13
+ export const PAGE_RADIUS_PX = 14;
14
+
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%)';
10
20
 
11
21
  const HOST_TAG = 'sp00ky-impersonation-banner';
12
22
 
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)';
23
+ /** How long a `custom` banner has to acknowledge itself before the built-in
24
+ * one is shown anyway. Long enough for a first paint, short enough that
25
+ * nobody browses unwarned. */
26
+ export const CUSTOM_BANNER_GRACE_MS = 2500;
27
+
28
+ /**
29
+ * Restyling of the built-in banner. Every field is optional; anything unset
30
+ * keeps the default. Colours are plain CSS values, so a project can pass its
31
+ * own tokens (`var(--brand-warning)`) as long as they resolve at the document
32
+ * level.
33
+ */
34
+ export interface ImpersonationBannerTheme {
35
+ /** Height of the warning bar above the page. */
36
+ heightPx?: number;
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. */
40
+ radiusPx?: number;
41
+ /** The backdrop behind the page. Any CSS `background` value. */
42
+ background?: string;
43
+ /** Colour of the warning text on the backdrop. */
44
+ text?: string;
45
+ /** Background of the Stop button. */
46
+ accent?: string;
47
+ /** Text colour of the Stop button. */
48
+ accentText?: string;
49
+ /** Label for the Stop button. Defaults to `Stop`. */
50
+ stopLabel?: string;
51
+ /** The warning sentence. Defaults to `Impersonating <target> as <admin>`.
52
+ * Wrap a run in `**` to emphasise it; the result is text, never markup. */
53
+ label?: (info: ImpersonationInfo) => string;
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. */
56
+ noPageShift?: boolean;
57
+ }
58
+
59
+ export interface ImpersonationBannerConfig {
60
+ /**
61
+ * - `'default'` renders the built-in banner.
62
+ * - `'custom'` leaves the page alone so the app can render its own from
63
+ * `useImpersonation()` / `auth.subscribeImpersonation`. Acknowledge it
64
+ * with `client.acknowledgeImpersonationBanner()` while it is on screen;
65
+ * if nothing acknowledges within {@link CUSTOM_BANNER_GRACE_MS}, the
66
+ * built-in banner appears, on the assumption that the custom one failed
67
+ * to render rather than that nobody should be warned.
68
+ * - `'none'` renders nothing, ever. The app takes over responsibility for
69
+ * telling whoever is looking at the page that it is not their session.
70
+ */
71
+ mode?: 'default' | 'custom' | 'none';
72
+ /** Restyle the built-in banner (also used if `custom` falls back). */
73
+ theme?: ImpersonationBannerTheme;
74
+ }
75
+
76
+ interface ResolvedTheme
77
+ extends Required<Omit<ImpersonationBannerTheme, 'label' | 'stopLabel'>> {
78
+ stopLabel: string;
79
+ label: (info: ImpersonationInfo) => string;
80
+ }
81
+
82
+ function resolveTheme(theme: ImpersonationBannerTheme = {}): ResolvedTheme {
83
+ return {
84
+ heightPx: theme.heightPx ?? BANNER_HEIGHT_PX,
85
+ insetPx: theme.insetPx ?? FRAME_INSET_PX,
86
+ radiusPx: theme.radiusPx ?? PAGE_RADIUS_PX,
87
+ background: theme.background ?? BANNER_BACKGROUND,
88
+ text: theme.text ?? '#45230a',
89
+ accent: theme.accent ?? '#45230a',
90
+ accentText: theme.accentText ?? '#fff7ed',
91
+ stopLabel: theme.stopLabel ?? 'Stop',
92
+ noPageShift: theme.noPageShift ?? false,
93
+ label: theme.label ?? ((info) => `Impersonating **${info.target}** as ${info.admin}`),
94
+ };
95
+ }
16
96
 
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)`;
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 };
22
118
  }
23
119
 
24
120
  // The shadow root keeps app CSS away from the bar, but the host element itself
25
121
  // is in the page and matches the page's selectors (`div { display: none }`).
26
122
  // An `!important` rule on `:host` wins over the page's own `!important`
27
123
  // (inner tree context), which keeps the host rendered whatever the app says.
28
- const STYLE = `
124
+ export function bannerCss(t: ResolvedTheme): string {
125
+ return `
29
126
  :host {
30
127
  all: initial !important;
31
128
  display: block !important;
@@ -35,107 +132,155 @@ const STYLE = `
35
132
  transform: none !important;
36
133
  clip-path: none !important;
37
134
  }
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
- }
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. */
65
139
  .bar {
66
- position: fixed; inset: 0 0 auto 0; height: ${BANNER_HEIGHT_PX}px; z-index: 2147483647;
140
+ position: fixed; inset: 0 0 auto 0; height: ${t.heightPx}px; z-index: 2147483647;
67
141
  display: flex; align-items: center; justify-content: center; gap: 10px;
68
- padding: 0 12px; box-sizing: border-box;
69
- 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;
70
148
  }
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);
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;
76
154
  }
77
- .sign { flex: none; font-size: 14px; line-height: 1; }
78
155
  .text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
79
- .text b { color: #fff; font-weight: 800; }
156
+ .text b { font-weight: 800; }
80
157
  button {
81
158
  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);
159
+ background: ${t.accent}; color: ${t.accentText};
160
+ font: 700 11.5px/1 system-ui, sans-serif; padding: 6px 11px;
161
+ box-shadow: 0 1px 2px rgba(69, 26, 3, .35);
85
162
  }
86
- button:hover { background: #fff; }
163
+ button:hover { filter: brightness(1.15); }
87
164
  button:disabled { opacity: .6; cursor: progress; }
88
165
  @media (max-width: 520px) {
89
- .bar { gap: 6px; padding: 0 8px; }
90
- .pill { padding: 5px 8px; }
166
+ .bar { gap: 7px; padding: 0 8px; font-size: 11.5px; }
167
+ .sign { display: none; }
91
168
  }
92
169
  `;
170
+ }
93
171
 
94
172
  /**
95
173
  * The "you are impersonating" warning.
96
174
  *
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.
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.
104
186
  *
105
187
  * Every piece of text goes through `textContent`.
106
188
  */
107
189
  export class ImpersonationBanner {
108
190
  private host: HTMLElement | null = null;
109
191
  private text: HTMLElement | null = null;
110
- /** `style` attributes as the page had them before the shift. */
192
+ /** `style` attributes as the page had them before the frame. */
111
193
  private saved: { html: string | null; body: string | null } | null = null;
194
+ private theme: ResolvedTheme;
195
+ private mode: 'default' | 'custom' | 'none';
196
+ /** Grace timer for `custom` mode. */
197
+ private fallbackTimer: ReturnType<typeof setTimeout> | null = null;
198
+ /** The app said its own banner is on screen for this impersonation. */
199
+ private acknowledged = false;
200
+ private warnedAboutNone = false;
201
+ private onResize: (() => void) | null = null;
112
202
 
113
- constructor(private onStop: () => Promise<void>) {}
203
+ constructor(
204
+ private onStop: () => Promise<void>,
205
+ config: ImpersonationBannerConfig = {}
206
+ ) {
207
+ this.mode = config.mode ?? 'default';
208
+ this.theme = resolveTheme(config.theme);
209
+ }
114
210
 
115
211
  static supported(): boolean {
116
212
  return typeof document !== 'undefined' && typeof document.createElement === 'function';
117
213
  }
118
214
 
215
+ /**
216
+ * The app's own banner is rendered. Cancels the `custom`-mode fallback, and
217
+ * removes the built-in banner if the fallback already fired (a slow first
218
+ * paint, say). No-op in the other modes.
219
+ */
220
+ acknowledge(): void {
221
+ this.acknowledged = true;
222
+ this.clearFallback();
223
+ if (this.mode === 'custom') this.teardown();
224
+ }
225
+
119
226
  update(info: ImpersonationInfo | null): void {
120
227
  if (!ImpersonationBanner.supported()) return;
121
228
  if (!info) {
229
+ this.acknowledged = false;
122
230
  this.unmount();
123
231
  return;
124
232
  }
233
+ if (this.mode === 'none') {
234
+ // The app owns the warning entirely. Said once, loudly, because the
235
+ // page now looks like an ordinary session of someone else's account.
236
+ if (!this.warnedAboutNone) {
237
+ this.warnedAboutNone = true;
238
+ // oxlint-disable-next-line no-console
239
+ console.warn(
240
+ '[sp00ky] impersonation is active and impersonationBanner.mode is "none": ' +
241
+ "nothing marks this page as another user's session."
242
+ );
243
+ }
244
+ return;
245
+ }
246
+ if (this.mode === 'custom' && !this.acknowledged) {
247
+ // Give the app its grace period, then warn anyway.
248
+ if (!this.fallbackTimer && !this.host) {
249
+ this.fallbackTimer = setTimeout(() => {
250
+ this.fallbackTimer = null;
251
+ if (!this.acknowledged) this.render(info);
252
+ }, CUSTOM_BANNER_GRACE_MS);
253
+ }
254
+ return;
255
+ }
256
+ if (this.mode === 'custom') return;
257
+ this.render(info);
258
+ }
259
+
260
+ unmount(): void {
261
+ this.clearFallback();
262
+ this.teardown();
263
+ }
264
+
265
+ /** Mount (if needed) and write the current identities into the bar. */
266
+ private render(info: ImpersonationInfo): void {
125
267
  this.mount();
268
+ const label = this.theme.label(info);
126
269
  const text = this.text!;
127
- text.replaceChildren(
128
- document.createTextNode('Impersonating '),
129
- bold(info.target),
130
- document.createTextNode(` as ${info.admin}`)
131
- );
132
- text.title = `Impersonating ${info.target} as ${info.admin}. Every write is audited.`;
270
+ text.textContent = '';
271
+ text.append(...labelNodes(label));
272
+ text.title = `${label.replace(/\*\*/g, '')}. Every write is audited.`;
133
273
  }
134
274
 
135
- unmount(): void {
275
+ /** Remove the bar and put the page back exactly as it was. */
276
+ private teardown(): void {
136
277
  this.host?.remove();
137
278
  this.host = this.text = null;
138
279
  if (typeof document === 'undefined') return;
280
+ if (this.onResize) {
281
+ window.removeEventListener('resize', this.onResize);
282
+ this.onResize = null;
283
+ }
139
284
  if (this.saved) {
140
285
  restoreStyle(document.documentElement, this.saved.html);
141
286
  restoreStyle(document.body, this.saved.body);
@@ -144,111 +289,117 @@ export class ImpersonationBanner {
144
289
  document.documentElement?.style.setProperty('--sp00ky-impersonation-banner-height', '0px');
145
290
  }
146
291
 
292
+ private clearFallback(): void {
293
+ if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
294
+ this.fallbackTimer = null;
295
+ }
296
+
147
297
  private mount(): void {
148
298
  if (this.host?.isConnected) return;
149
299
  const host = document.createElement(HOST_TAG);
150
300
  const root = host.attachShadow({ mode: 'closed' });
151
301
  const style = document.createElement('style');
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
- }
302
+ style.textContent = bannerCss(this.theme);
167
303
 
168
304
  const bar = document.createElement('div');
169
305
  bar.className = 'bar';
170
306
  bar.setAttribute('role', 'alert');
171
- const pill = document.createElement('div');
172
- pill.className = 'pill';
173
307
  const sign = document.createElement('span');
174
308
  sign.className = 'sign';
175
- sign.textContent = '⚠';
309
+ sign.textContent = '!';
310
+ sign.setAttribute('aria-hidden', 'true');
176
311
  const text = document.createElement('span');
177
312
  text.className = 'text';
178
313
  const button = document.createElement('button');
179
314
  button.type = 'button';
180
- button.textContent = 'Stop';
315
+ button.textContent = this.theme.stopLabel;
181
316
  button.addEventListener('click', () => {
182
317
  button.disabled = true;
183
318
  this.onStop().finally(() => {
184
319
  button.disabled = false;
185
320
  });
186
321
  });
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.
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.
193
326
  document.documentElement.appendChild(host);
194
327
 
195
- this.shiftPage();
328
+ this.frame();
196
329
  this.host = host;
197
330
  this.text = text;
198
331
  }
199
332
 
200
333
  /**
201
- * Push the page below the warning and round its new top edge.
334
+ * Turn `<body>` into the framed sheet.
202
335
  *
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.
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.
208
342
  */
209
- private shiftPage(): void {
343
+ private frame(): void {
210
344
  const root = document.documentElement;
211
345
  const body = document.body;
212
346
  if (!root || !body) return;
213
- this.saved = { html: root.getAttribute('style'), body: body.getAttribute('style') };
347
+ const { heightPx, insetPx, radiusPx, noPageShift } = this.theme;
348
+ // Published even when the framing is off, so an app doing its own offset
349
+ // has one number to read either way.
350
+ root.style.setProperty('--sp00ky-impersonation-banner-height', `${heightPx}px`);
351
+ if (noPageShift) return;
214
352
 
353
+ this.saved = { html: root.getAttribute('style'), body: body.getAttribute('style') };
215
354
  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');
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 }
365
+ );
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);
238
387
  }
239
388
  }
240
389
 
241
- /** 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. */
242
392
  function restoreStyle(el: HTMLElement | null, saved: string | null): void {
243
393
  if (!el) return;
244
- if (saved === null) el.removeAttribute('style');
245
- else el.setAttribute('style', saved);
394
+ if (saved) el.setAttribute('style', saved);
395
+ else el.removeAttribute('style');
246
396
  }
247
397
 
248
398
  /**
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.
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.
252
403
  */
253
404
  function resolveSurface(root: HTMLElement, body: HTMLElement): string {
254
405
  for (const el of [body, root]) {
@@ -266,8 +417,27 @@ function isTransparent(color: string): boolean {
266
417
  return !!parts && parts.length === 4 && Number(parts[3]) === 0;
267
418
  }
268
419
 
269
- function bold(value: string): HTMLElement {
270
- const b = document.createElement('b');
271
- b.textContent = value;
272
- return b;
420
+ /**
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.
424
+ */
425
+ function labelNodes(label: string): Node[] {
426
+ return labelSegments(label).map(({ text, bold }) => {
427
+ if (!bold) return document.createTextNode(text);
428
+ const b = document.createElement('b');
429
+ b.textContent = text;
430
+ return b;
431
+ });
432
+ }
433
+
434
+ /** The label split into plain and emphasised runs. Pure, so it is unit tested. */
435
+ function labelSegments(label: string): Array<{ text: string; bold: boolean }> {
436
+ return label
437
+ .split(/\*\*(.+?)\*\*/g)
438
+ .map((text, i) => ({ text, bold: i % 2 === 1 }))
439
+ .filter((part) => part.text !== '');
273
440
  }
441
+
442
+ /** Exported for unit tests only. */
443
+ export const __test = { labelSegments, resolveTheme };
@@ -280,3 +280,76 @@ describe('timers', () => {
280
280
  timers.clear();
281
281
  });
282
282
  });
283
+
284
+ describe('banner configuration', () => {
285
+ it('builds CSS from the theme, defaults included', async () => {
286
+ const { bannerCss, BANNER_BACKGROUND } = await import('./impersonation-banner');
287
+ // `bannerCss` takes a resolved theme; the client resolves it, so this
288
+ // pins the rendered output for a fully specified one.
289
+ const css = bannerCss({
290
+ heightPx: 60,
291
+ insetPx: 16,
292
+ radiusPx: 0,
293
+ background: 'var(--brand-warning)',
294
+ text: '#222',
295
+ accent: '#f00',
296
+ accentText: '#fff',
297
+ stopLabel: 'Leave',
298
+ noPageShift: true,
299
+ label: () => 'x',
300
+ });
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);
330
+ });
331
+
332
+ it('emphasises the label between ** markers instead of parsing markup', async () => {
333
+ const { __test } = await import('./impersonation-banner');
334
+ // A label is a plain string an app supplies, so markup in it must stay text.
335
+ expect(__test.labelSegments('Impersonating **user:bob** as user:alice')).toEqual([
336
+ { text: 'Impersonating ', bold: false },
337
+ { text: 'user:bob', bold: true },
338
+ { text: ' as user:alice', bold: false },
339
+ ]);
340
+ expect(__test.labelSegments('<img src=x onerror=alert(1)>')).toEqual([
341
+ { text: '<img src=x onerror=alert(1)>', bold: false },
342
+ ]);
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
+ });
355
+ });